Joel Stevick

Appendix B · section 13 of 32

ask/retrieve.ts

From Finding It by Its Words.

import { fileURLToPath } from "node:url";
import { LAWS, type Law } from "./corpus.js";

// Retrieval by words: deterministic, in process, over the state's own file.
// The model sees the laws this returns and nothing else from the corpus, so
// a test can say exactly which laws a question reaches.

/** How many laws a question is answered from: enough that a paraphrase still reaches its law, few enough that the model is not reading a chapter. */
export const RETRIEVED_LAWS = 8;

// Words that say nothing about which law is meant. "park" and "far" stay: in
// a question about road law they are the whole question.
const STOP = new Set(
  "a an and are as at be by can could do does for from how i if in is it its me my of on or should so that the their them there this to was what when where which who why will with would you your".split(
    " ",
  ),
);

export function words(text: string): string[] {
  return (text.toLowerCase().match(/[a-z0-9]+/g) ?? [])
    .filter((word) => !STOP.has(word))
    // "hydrants" must find "hydrant"; "bus" must not become "bu".
    .map((word) =>
      word.length > 3 && word.endsWith("s") && !word.endsWith("ss") ? word.slice(0, -1) : word,
    );
}

// What BM25 needs of a laws array that does not depend on the question: each
// law's word counts and length, the average length, and how many laws each
// word is in. Built once per array and kept, because the pipeline retrieves
// over the same array several times a question.
interface Index {
  length: number;
  /** How many laws are ranked by words: every one but the state's own pages. */
  size: number;
  docs: { length: number; counts: Map<string, number> }[];
  average: number;
  frequency: Map<string, number>;
}

const INDEXES = new WeakMap<Law[], Index>();

// A row of the state's own page is not ranked by words. Every page row carries
// its family's question, and five of them a family would fill the shortlist
// for any question that shares words with it. A page row reaches the model
// by its family's key and by meaning, never by words.
function ranked(law: Law): boolean {
  return law.kind !== "process";
}

function indexOf(laws: Law[]): Index {
  const kept = INDEXES.get(laws);
  if (kept !== undefined && kept.length === laws.length) return kept;
  const frequency = new Map<string, number>();
  const docs = laws.map((law) => {
    const tokens = ranked(law) ? words(`${law.law} ${law.question} ${law.concept.replace(/-/g, " ")}`) : [];
    const counts = new Map<string, number>();
    for (const word of tokens) counts.set(word, (counts.get(word) ?? 0) + 1);
    for (const word of counts.keys()) frequency.set(word, (frequency.get(word) ?? 0) + 1);
    return { length: tokens.length, counts };
  });
  const counted = docs.filter((_, index) => ranked(laws[index]));
  const built: Index = {
    length: laws.length,
    size: counted.length,
    docs,
    average: counted.reduce((sum, doc) => sum + doc.length, 0) / Math.max(1, counted.length),
    frequency,
  };
  INDEXES.set(laws, built);
  return built;
}

/** The laws most likely to answer `question` by the words they share with it, best first. A law sharing no word is never returned. */
export function bm25(laws: Law[], question: string, limit = RETRIEVED_LAWS): Law[] {
  if (laws.length === 0) return [];
  const { docs, average, frequency, size } = indexOf(laws);
  const asked = [...new Set(words(question).flatMap((word) => spelledAs(word, frequency)))];
  if (asked.length === 0) return [];
  const k1 = 1.2;
  const b = 0.75;
  return docs
    .map((doc, index) => {
      let score = 0;
      for (const word of asked) {
        const n = frequency.get(word);
        if (n === undefined) continue;
        const tf = doc.counts.get(word) ?? 0;
        if (tf === 0) continue;
        const idf = Math.log(1 + (size - n + 0.5) / (n + 0.5));
        score += (idf * tf * (k1 + 1)) / (tf + k1 * (1 - b + (b * doc.length) / average));
      }
      return { law: laws[index], score };
    })
    .filter((scored) => scored.score > 0)
    .sort((a, b) => b.score - a.score)
    .slice(0, limit)
    .map((scored) => scored.law);
}

// The corpus's words a learner's word stands for. A word the laws use is
// itself. One they do not is matched to the laws' words it contains
// ("firehydrant" → fire, hydrant) or is one letter away from ("behing" →
// behind), because a learner types on a phone.
function spelledAs(word: string, vocabulary: Map<string, number>): string[] {
  if (vocabulary.has(word)) return [word];
  if (word.length < 4) return [];
  const found: string[] = [];
  for (const known of vocabulary.keys()) {
    if (known.length >= 4 && word.includes(known)) found.push(known);
    else if (word.length >= 5 && Math.abs(known.length - word.length) <= 1 && oneEditApart(word, known)) {
      found.push(known);
    }
  }
  return found;
}

/** True when one insertion, deletion or substitution turns `a` into `b`. */
function oneEditApart(a: string, b: string): boolean {
  if (a === b) return false;
  let i = 0;
  while (i < a.length && i < b.length && a[i] === b[i]) i += 1;
  if (a.length === b.length) return a.slice(i + 1) === b.slice(i + 1);
  return a.length > b.length ? a.slice(i + 1) === b.slice(i) : a.slice(i) === b.slice(i + 1);
}

function main(): void {
  const question = process.argv.slice(2).join(" ");
  if (question.length === 0) {
    console.error('usage: tsx ask/retrieve.ts "<question>"');
    process.exit(1);
  }
  console.log(`words: ${words(question).join(" ")}`);
  for (const law of bm25(LAWS, question)) console.log(`${law.id}  ${law.law}`);
}

if (process.argv[1] === fileURLToPath(import.meta.url)) main();