Joel Stevick

Appendix B · section 19 of 32

ask/rerank.ts

From Choosing What It Reads.

import Anthropic from "@anthropic-ai/sdk";
import { fileURLToPath } from "node:url";
import { LAWS, type Law } from "./corpus.js";
import { retrieve } from "./fuse.js";
import { SMALL_MODEL, requireApiKey, textOf } from "./setup.js";
import { sideJobClient } from "./rephrase.js";
import { embeddingText } from "./vectors.js";

// The laws the model is given, chosen by a reranker from a wider pool.
// Retrieval ranks by how a law is worded; a reranker reads the question and
// the laws together and judges which answer it. Absent or failing, the model
// gets what retrieval gave it before the reranker existed.

/** How many retrieved laws the reranker reads. */
export const CANDIDATE_LAWS = 24;

// How many when the question was also searched in the law's words. Fusing a
// search's rankings beside the question's own moves laws out of the pool as
// well as in, so the pool is wider when searches were fused.
export const SEARCHED_CANDIDATE_LAWS = 32;

/** How many it gives the model. */
export const ANSWER_LAWS = 3;

/** How many laws Haiku names, best first. The rest are unranked and keep their place in the pool. */
const HAIKU_RANKS = 5;

export function poolSize(searches: readonly unknown[]): number {
  return searches.length > 0 ? SEARCHED_CANDIDATE_LAWS : CANDIDATE_LAWS;
}

/** A score for each text, in the order given. */
export type Rerank = (question: string, texts: string[]) => Promise<number[]>;

const HAIKU_RULES = `You choose which laws answer a learner driver's question.
You are given the question and a numbered list of laws from one state's driver manual and vehicle code.
Return the numbers of the laws that answer the question, best first, at most ${HAIKU_RANKS}.
A law that states the rule, figure or penalty asked about ranks above one that only names the subject.
Match the situation asked about: an adult's limit is not an under-21 limit, a side window is not a windshield, a first offence is not a second.
Return an empty list if none answers it.`;

const HAIKU_SCHEMA = {
  type: "object",
  properties: { ranked: { type: "array", items: { type: "integer" } } },
  required: ["ranked"],
  additionalProperties: false,
} as const;

// Haiku as a reranker: the laws it names score from HAIKU_RANKS down, in its
// order; every other law is -Infinity and keeps its place. Throws on anything
// it cannot read, so the caller falls back.
export function haikuRerankFor(client: Pick<Anthropic, "messages">): Rerank {
  return async (question, texts) => {
    const listed = texts.map((text, index) => `[${index}] ${text}`).join("\n\n");
    const response = await client.messages.create({
      model: SMALL_MODEL,
      max_tokens: 1024,
      system: HAIKU_RULES,
      output_config: {
        effort: "low",
        format: { type: "json_schema", schema: HAIKU_SCHEMA },
      },
      messages: [{ role: "user", content: `Question: ${question}\n\nLaws:\n\n${listed}` }],
    });
    const text = textOf(response);
    const ranked = (JSON.parse(text) as { ranked?: unknown }).ranked;
    if (
      !Array.isArray(ranked) ||
      ranked.some((id) => !Number.isInteger(id) || (id as number) < 0 || (id as number) >= texts.length)
    ) {
      throw new Error(`the reranker named laws that were not given: ${text}`);
    }
    const scores = texts.map(() => Number.NEGATIVE_INFINITY);
    [...new Set(ranked as number[])]
      .slice(0, HAIKU_RANKS)
      .forEach((id, position) => (scores[id] = HAIKU_RANKS - position));
    return scores;
  };
}

/**
 * The laws the model answers from: the reranker's best ANSWER_LAWS of the
 * pool, or, with no reranker or a failing one, `fallback` -- what retrieval
 * gave before the reranker existed. A row of the state's own page the
 * reranker puts among its first is given beside the three, never in place of
 * one.
 */
export async function chooseLaws(
  pool: Law[],
  fallback: Law[],
  question: string,
  rerank?: Rerank,
): Promise<Law[]> {
  if (rerank === undefined || pool.length === 0) return fallback;
  try {
    const scores = await rerank(question, pool.map(embeddingText));
    if (scores.length !== pool.length) throw new Error(`${scores.length} scores for ${pool.length} laws`);
    const ranked = pool.map((law, index) => ({ law, score: scores[index] })).sort((a, b) => b.score - a.score);
    const page = (law: Law): boolean => law.kind === "process";
    const pages = ranked.slice(0, ANSWER_LAWS).filter(({ law }) => page(law)).map(({ law }) => law);
    const chosen = ranked.filter(({ law }) => !page(law)).slice(0, ANSWER_LAWS).map(({ law }) => law);
    return [...chosen, ...pages];
  } catch (error) {
    console.error("the reranker did not answer:", error instanceof Error ? error.message : error);
    return fallback;
  }
}

async function main(): Promise<void> {
  requireApiKey();
  const question = process.argv.slice(2).join(" ");
  if (question.length === 0) {
    console.error('usage: tsx ask/rerank.ts "<question>"');
    process.exit(1);
  }
  const pool = retrieve(LAWS, question, CANDIDATE_LAWS);
  const fallback = retrieve(LAWS, question);
  for (const law of await chooseLaws(pool, fallback, question, haikuRerankFor(sideJobClient()))) {
    console.log(`${law.id}  ${law.law}`);
  }
}

if (process.argv[1] === fileURLToPath(import.meta.url)) {
  main().catch((error: unknown) => {
    console.error(error instanceof Error ? error.message : String(error));
    process.exit(1);
  });
}