Joel Stevick

Appendix B · section 31 of 32

ask/eval.ts

From Measuring the Whole Thing.

import type { Reply } from "./answer.js";
import { CASES, type Case } from "./eval-cases.js";
import { livePipeline } from "./live.js";
import { answerQuestion } from "./pipeline.js";
import { registerMisses } from "./register.js";
import { requireApiKey } from "./setup.js";

// How well the box answers, measured against the live model.
//
// Not part of the test suite, deliberately: it costs money and is not
// deterministic. The tests drive a model that answers from a script and
// everything around it; this is the one thing that measures the model
// itself, run by hand before a prompt or model change, and its table goes in
// the pull request. Every answer goes through the pipeline's own retrieval
// and checks, so an answer that would have been refused on the wire is a
// miss here even if its words were right: nobody would have read it.

requireApiKey();

function misses(
  found: { scope: string; shown: string; locations: string[]; ids: string[] },
  expected: Case["expect"],
): string[] {
  const out: string[] = [];
  if (expected.citesLaw !== undefined && !found.locations.some((location) => location.includes(expected.citesLaw!))) {
    out.push(`does not cite ${expected.citesLaw}`);
  }
  if (expected.citesId !== undefined && !found.ids.some((id) => id.startsWith(expected.citesId!))) {
    out.push(`does not cite ${expected.citesId}`);
  }
  if (expected.unanswered === true && found.shown !== "") out.push("answered");
  if (found.scope !== expected.scope) out.push(`scope ${found.scope}, wanted ${expected.scope}`);
  if (expected.lead !== undefined) {
    const first = found.shown.trim().split(/[\s.,!:]+/)[0]?.toLowerCase() ?? "";
    if (first !== expected.lead) out.push(`leads with "${first}", wanted "${expected.lead}"`);
  }
  for (const needle of expected.contains ?? []) {
    const any = Array.isArray(needle) ? needle : [needle];
    if (!any.some((each) => found.shown.toLowerCase().includes(each.toLowerCase()))) {
      out.push(`missing ${any.map((each) => `"${each}"`).join(" or ")}`);
    }
  }
  for (const needle of expected.notContains ?? []) {
    if (found.shown.toLowerCase().includes(needle.toLowerCase())) out.push(`contains "${needle}"`);
  }
  return out;
}

async function main(): Promise<number> {
  const argv = process.argv.slice(2);
  const only = argv.includes("--case") ? Number(argv[argv.indexOf("--case") + 1]) : undefined;
  const cases = only === undefined ? CASES : CASES.filter((_, index) => index + 1 === only);
  // The whole run is asked for by name: every case is a live model call.
  if (only === undefined && !argv.includes("--all")) {
    console.error(`${CASES.length} cases, every one a live model call. Iterate with --case N; run the whole set with --all.`);
    return 2;
  }
  const live = await livePipeline();
  const cell = (text: string): string => text.replace(/\|/g, "\\|").replace(/\s+/g, " ");
  console.log("| # | question | scope | shown | verdict |");
  console.log("|---|---|---|---|---|");
  let failed = 0;
  for (const [index, kase] of cases.entries()) {
    // What the model said, kept beside what the learner was shown: "refused"
    // alone does not say which figure, and the next prompt change needs to
    // know. A holder rather than a `let`: the model is called inside the closure.
    const seen: { said: Reply } = { said: { kind: "failed", why: "unreachable" } };
    const { body, outcome } = await answerQuestion(kase.question, [], {
      ...live,
      ask: async (question, laws, history) => (seen.said = await live.ask(question, laws, history)),
    });
    const said = seen.said;
    const scope = said.kind === "reply" ? said.scope : "other";
    const shown = body.id === "answer" ? body.answer : "";
    const locations = body.id === "answer" ? body.citations.map((citation) => citation.location) : [];
    const ids = body.id === "answer" ? body.citations.map((citation) => citation.id) : [];
    const wrong = [
      ...misses({ scope, shown, locations, ids }, kase.expect),
      ...registerMisses({ question: kase.question, shown }),
    ];
    const wanted = kase.expect.scope === "road-law" && kase.expect.unanswered !== true;
    if (shown === "" && wanted) {
      const wrote = said.kind === "reply" && said.answer !== "" ? ` (wrote: ${said.answer})` : "";
      wrong.push(`refused: ${outcome.model}${wrote}`);
    }
    if (wrong.length > 0) failed += 1;
    console.log(`| ${index + 1} | ${cell(kase.question)} | ${scope} | ${cell(shown)} | ${wrong.length === 0 ? "ok" : cell(wrong.join("; "))} |`);
  }
  console.log(`\n${cases.length - failed} of ${cases.length} right.`);
  return failed === 0 ? 0 : 1;
}

main().then(
  (code) => process.exit(code),
  (error: unknown) => {
    console.error(error instanceof Error ? error.message : String(error));
    process.exit(2);
  },
);