Joel Stevick

Appendix B · section 10 of 32

ask/figures.ts

From The Check That Is Not the Model.

// Every figure a text states, as strings of digits: its digit runs and its
// spelled numbers. Statutes spell their figures where manuals print digits,
// and the prompt asks the model for digits, so "closer than five hundred feet"
// in a law has to ground "500 feet" in an answer.

const SMALL = [
  "zero", "one", "two", "three", "four", "five", "six", "seven", "eight",
  "nine", "ten", "eleven", "twelve",
];

const UNITS: Record<string, number> = {
  one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, seven: 7, eight: 8,
  nine: 9, ten: 10, eleven: 11, twelve: 12, thirteen: 13, fourteen: 14,
  fifteen: 15, sixteen: 16, seventeen: 17, eighteen: 18, nineteen: 19,
};

const TENS: Record<string, number> = {
  twenty: 20, thirty: 30, forty: 40, fifty: 50, sixty: 60, seventy: 70,
  eighty: 80, ninety: 90,
};

export function figuresIn(text: string): Set<string> {
  // "1,000" is one figure, not 1 and 000. "0.08" is read as ".08", the way
  // the manuals write a blood alcohol level, so the leading zero is not a
  // figure of its own.
  const normalised = text
    .replace(/(\d),(?=\d{3}(?!\d))/g, "$1")
    .replace(/\b0(\.\d)/g, "$1");
  const found = new Set<string>(normalised.match(/\d+/g) ?? []);
  const lower = normalised.toLowerCase();

  SMALL.forEach((word, value) => {
    if (new RegExp(`\\b${word}\\b`).test(lower)) found.add(String(value));
  });
  for (const word of lower.match(/[a-z]+/g) ?? []) {
    if (UNITS[word] !== undefined) found.add(String(UNITS[word]));
    if (TENS[word] !== undefined) found.add(String(TENS[word]));
    if (word === "hundred") found.add("100");
    if (word === "thousand") found.add("1000");
  }
  for (const value of compounds(lower)) found.add(String(value));

  // "One" is a pronoun far more often than a figure in a road-law answer --
  // "within 15 feet of one", "a one-way street" -- and read as 1 it refused
  // correct answers. A digit 1 standing on its own is still a figure.
  if (!/(^|[^\d.])1(?![\d])/.test(normalised)) found.delete("1");
  return found;
}

// Spelled compounds as the whole numbers they are: "five hundred" is 500,
// "twenty-five" is 25, "one thousand five hundred" is 1500. Added beside the
// word-by-word values, never instead of them, so nothing that was grounded
// before stops being grounded.
export function compounds(text: string): number[] {
  const out: number[] = [];
  let total = 0;
  let chunk = 0;
  let open = false;
  const close = (): void => {
    if (open) out.push(total + chunk);
    total = 0;
    chunk = 0;
    open = false;
  };
  for (const word of text.toLowerCase().match(/[a-z]+/g) ?? []) {
    if (UNITS[word] !== undefined) chunk += UNITS[word];
    else if (TENS[word] !== undefined) chunk += TENS[word];
    else if (word === "hundred" && open) chunk = (chunk === 0 ? 1 : chunk) * 100;
    else if (word === "thousand" && open) {
      total += (chunk === 0 ? 1 : chunk) * 1000;
      chunk = 0;
    } else if (word === "and" && open) continue;
    else {
      close();
      continue;
    }
    open = true;
  }
  close();
  return out;
}