Joel Stevick

Chapter 11 · section 4 of 7

The harness

A harness is the program that runs a set of cases through the thing being measured and scores the results. This one is an eval, short for evaluation: a harness run against the live model to measure how the whole pipeline answers, as opposed to a test, which measures one part with the model held still.

Listing: ask/eval.ts

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);
  },
);

Run one case first, then the set:

npx tsx ask/eval.ts --case 1
npx tsx ask/eval.ts --all

With no argument it refuses and says why, because every case is a live model call. Twelve cases through the whole pipeline cost a few cents with the stages you have; the deployed set cost about ten dollars a run at 130 cases, and on 10 October 2026 it was run five times in a day, which emptied the account twice. It has more cases now and costs more. The refusal is the cheapest line in the file.

One case through the real pipeline with the live model. What is scored is what the learner would have seen: an answer the checker refused is a miss even when the model’s words were right, because nobody would have read them.

The table is the output, and it is a table rather than a score because a score is read and a table is read carefully. Each row carries the question, the scope the model chose, the answer the learner would have seen, and either ok or the reasons in words. When an answer was refused, the row says what the model wrote anyway, in parentheses, because refused: road-law:ungrounded alone does not say which figure, and the next change to the prompt needs to know. The last line is N of 12 right, and the exit code is non-zero on any miss, so a change that broke a case cannot be called “about the same”.

Two rules in main are the measurement’s integrity. What is scored is what the learner would have seen, which is the answer when the checks pass and nothing otherwise, so a right answer the checker refused is a miss here too. And the register is scored beside the facts: a two-sentence rule that the model follows in four sentences is a miss, and the row says so.