Chapter 7 · section 2 of 5
The reranker
Listing: ask/rerank.ts
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);
});
} Run it:
npx tsx ask/rerank.ts "how far from a fire hydrant can i park"
npx tsx ask/rerank.ts "how many friends can i have in the car with a provisional license" The first prints three laws with MD-S01 first, as retrieval would have. The second is the interesting one: the corpus has no law about passenger limits, and the pool for that question is a handful of laws that share the word car or license. Haiku returns an empty list, every score is -Infinity, and the three laws given are the pool’s first three, which the model will decline under the first rule and the check will refuse as not covered. The reranker cannot find a law that is not there, and it does not pretend to.
Four decisions in that file are measurements.
Twenty-four, or thirty-two. poolSize is Chapter 6’s finding applied: when searches were fused the pool is wider, because fusion moved laws out as well as in. The five laws Chapter 6 names were within the first 32; California’s implied-consent and pass-on-right laws were out at 32 too.
Three, with no score floor. A Spanish question against the English hydrant law scored 0.065 on the cross-encoder where the English question scored 0.516, and was still the first candidate. A floor that suited English would have cut the Spanish answer, so there is none: the three best are the three best, whatever their scores.
Unranked laws keep their place. Haiku names at most five laws and says nothing about the rest. The rest score -Infinity, and because the sort is stable they stay in the order retrieval gave them. When Haiku names fewer than three, retrieval’s order fills the rest.
A page row goes beside the three. When the reranker puts a row of the agency’s own page among its first three, the row is given in addition to three laws, not in place of one. The measurement is from 11 October 2026: Maryland’s how much is the ticket if I’m driving without my license named the ticket family, the court’s “take one of four actions within 30 days” took a place, § 16-112 lost its own, and with it went the schedule’s $50. The box answered nothing. A pool with no page rows is chosen exactly as before.
And one decision is the same one every stage makes: a reranker that throws, times out, or names an index outside the pool is a reranker that did not answer, and the model gets fallback.