/** * Eval harness: corpus pinning, provenance capture, and run records. * * The scoring math lives in `eval.ts`; this module is everything around it * that makes a number *comparable to a later number*. Three problems it * exists to solve, all of which bit the first eval round * (docs/hybrid-retrieval-design.md, "Eval results"): * * 1. **The corpus is the repo.** Retrieval is measured over hoocode itself, * so every commit moves the thing being measured. A baseline taken today * and a rerun taken after a retrieval change differ by both the change * and the intervening commits, and nothing in the output says so. Fix: * run against a detached git worktree pinned to an explicit SHA, and put * that SHA in the record. * 2. **Nothing was recorded.** Results were printed to a terminal and * hand-copied into a markdown table with no repo SHA, no embedder * identity, and no index state. Fix: emit a machine-readable run record. * 3. **A degraded run looks like a real one.** With no embsearch binary the * semantic and hybrid rows silently degrade to lexical, producing a table * that is all-lexical but reads like a full sweep. Fix: `embedder` in the * record, plus a per-row degraded count that the writer refuses to hide. */ import type { EmbsearchService } from "../embsearch/embsearch-service.js"; import { type EvalConfig, type EvalQuery, type EvalQueryResult } from "./eval.js"; /** Metrics aggregated per config across the whole gold set. */ export interface EvalAggregate { label: string; recallAt1: number; recallAt5: number; recallAt10: number; recallAt50: number; mrr: number; /** Queries scored under this config. */ n: number; /** How many of them ran degraded (requested retriever unavailable). */ degraded: number; } /** Everything needed to decide whether two run records may be compared. */ export interface EvalProvenance { timestampMs: number; /** SHA of the corpus actually indexed and searched. */ corpusSha: string; /** Ref the caller asked for, before resolution (e.g. "HEAD"). */ corpusRef: string; /** True when the corpus came from the live working tree rather than a * pinned worktree — results are then not reproducible. */ corpusFromWorkingTree: boolean; /** Uncommitted changes present at run time. Only meaningful (and only * possible) when `corpusFromWorkingTree` is true. */ corpusDirty: boolean; /** Files removed from the corpus before indexing — see * {@link CORPUS_EXCLUSIONS}. A score against a different exclusion list is * a score against a different corpus, so it is recorded, not assumed. */ corpusExcluded: string[]; /** * Set only when the run scored a deliberately shrunk corpus. * * A smaller distractor pool makes every query easier, so these metrics are * higher than a full-corpus run's and are **not** comparable to one. They * are comparable to another subsampled run with the same target and seed, * which is what makes this useful for screening model arms. */ corpusSubsample?: CorpusSubsampleInfo; /** SHA of the tree whose retrieval code ran. Usually equals `corpusSha`, * but differs when pinning an old corpus with today's code. */ harnessSha: string; /** * Content hash of `src/core/search` + the chunker. Every tuning constant * that shapes a result — the fusion cap, top-k depths, rerank weights, * chunk sizing — lives in those files, so a changed hash means the * numbers are not comparable, without this module having to maintain a * hand-copied (and inevitably stale) list of constants. */ retrievalSourceHash: string; /** Embedding backend state. `available: false` means every semantic and * hybrid row in this record degraded to lexical. */ embedder: { available: boolean; reason?: string; /** Indexed chunk count when the index reached `ready`. */ chunkCount?: number; phase: string; /** Binary that served the embeddings, and its self-reported version. */ binaryPath?: string; binaryVersion?: string; /** * Model id the daemon reported — the thing that actually identifies * which model produced these scores. * * This used to be inferred from `binaryVersion`, on the reasoning that * the model was baked into the binary at build time. `--model ` * ends that: one binary now serves any number of models, so two arms of * a model comparison would have carried identical provenance and been * indistinguishable in the record. The id is a hash over the model's * whole spec (pooling, token limit, prefixes), so a change to any of * them shows up here. */ modelId?: string; /** Model directory passed as `--model`, when the run overrode the * bundled model. Absent means the binary's own model was used. */ modelDir?: string; }; /** Daemon-side BM25 hybrid store, when the run included one. Absent means * the record has no `daemon-hybrid` rows. */ daemonHybrid?: { available: boolean; phase: string; }; /** * Wall time, split at the seam between building the index and scoring the * gold set. * * Recorded because the cost side of a model comparison is almost entirely * indexing, and a single total cannot show it: the first such comparison * could only report "17 -> 60 min" for whole runs and had to note that the * figure was "not isolated from query work", which left the headline cost * of the change unmeasured. These two numbers are machine- and * load-dependent and say nothing about retrieval quality; they are a budget, * not a metric. */ /** * Chunker character cap, when an arm overrode it. Absent means the shipped * `CHUNK_MAX_CHARS`. Records differing here are not comparable: the chunks * are different text, so every id, span and vector differs. */ chunkMaxChars?: number; timing?: { /** Seconds spent bringing the index(es) to `ready`, model load included. */ indexSeconds: number; /** Seconds spent running every config over every gold query. */ querySeconds: number; /** True when one hybrid store served both the dense and BM25 roles * rather than the corpus being embedded twice. Runs with this false * paid roughly double the indexing time. */ sharedStore: boolean; }; runtime: { node: string; platform: string; arch: string; }; } export interface EvalRunRecord { provenance: EvalProvenance; goldSet: { queryCount: number; byClass: Record; goldSpanCount: number; }; configs: readonly EvalConfig[]; aggregates: EvalAggregate[]; perQuery: Array<{ id: string; class: string; results: EvalQueryResult[]; }>; } /** Hash every retrieval-shaping source file, so a tuning change is visible as * a changed provenance field rather than an unexplained metric shift. */ export declare function hashRetrievalSource(repoRoot: string): string; export interface PinnedCorpus { /** Directory to index and search. */ cwd: string; sha: string; fromWorkingTree: boolean; dirty: boolean; /** Files removed from the corpus before indexing. Empty when the corpus is * the live working tree, which is never mutated. */ excluded: string[]; /** Present only on a subsampled run. Its presence is what marks a record as * incomparable to a full-corpus one. */ subsample?: CorpusSubsampleInfo; /** Removes the worktree, if one was created. */ dispose: () => void; } /** Request to shrink the corpus to a chunk budget. See {@link pinCorpus}. */ export interface CorpusSubsampleRequest { /** Approximate chunk budget. Gold-bearing files are kept past it. */ targetChunks: number; /** Files that must survive regardless of budget — the gold-bearing ones. */ keepRelPaths: readonly string[]; /** Seed for the distractor draw, so a budget reproduces exactly. */ seed: number; } /** What a subsampled run did, recorded so it can never be read as a full one. */ export interface CorpusSubsampleInfo { targetChunks: number; /** Chunks actually kept. Exceeds the target when gold files alone do. */ chunkCount: number; filesKept: number; filesDropped: number; /** Gold-bearing files, all of which are kept unconditionally. */ goldFilesKept: number; seed: number; } /** * Files that describe this eval rather than being searched by it. * * The fixtures hold all 62 query strings verbatim, so every query is a perfect * lexical match against its own entry, and the design note quotes the same * queries while discussing the classes they belong to. Measured before this * exclusion existed: **56 of 62 queries had one of these files in the top 10, * 27 of 62 had one as the #1 result, and they consumed 133 of the 620 * top-10 slots** — a fifth of the window, spent on the eval reading itself. * * That is not a ranking artifact a reranker can fix: it displaces real answers * out of the window entirely, which is why two boundary-class queries were * absent from the top *50* rather than merely buried. Retrieving your own * question is not retrieval, so the corpus is scored without them. * * Removed from the pinned worktree before indexing, never from the repo — and * recorded in the run's provenance so a score is never silently taken against * a different corpus than it claims. */ export declare const CORPUS_EXCLUSIONS: readonly string[]; /** * Materialize the corpus to evaluate. * * With a `ref`, checks out a detached worktree at that commit so the corpus is * byte-identical on every rerun. Without one, falls back to the live working * tree and reports `dirty` so the record shows the run was not reproducible. * * `subsample` shrinks the corpus to a chunk budget, for screening runs where a * full arm costs too much to iterate on. It keeps every gold-bearing file and * draws distractors deterministically. This is a real change to what is being * measured — a smaller distractor pool makes retrieval easier and inflates * every metric — so it is recorded in the record and folded into the worktree * path, and it needs a `ref`. */ export declare function pinCorpus(repoRoot: string, ref: string | undefined, subsample?: CorpusSubsampleRequest): PinnedCorpus; export declare function collectProvenance(repoRoot: string, corpus: PinnedCorpus, corpusRef: string, service: EmbsearchService | undefined, embsearchBinary?: string, hybridService?: EmbsearchService, modelDir?: string, timing?: EvalProvenance["timing"], chunkMaxChars?: number): EvalProvenance; export declare function summarizeGoldSet(dataset: readonly EvalQuery[]): EvalRunRecord["goldSet"]; export interface RunEvalSuiteOptions { cwd: string; dataset: readonly EvalQuery[]; configs: readonly EvalConfig[]; service?: EmbsearchService; /** Second service backed by a daemon-side BM25 hybrid store, for the * `daemon-hybrid` configs. Absent means those rows are omitted. */ hybridService?: EmbsearchService; onQuery?: (index: number, query: EvalQuery) => void; } export declare function runEvalSuite(options: RunEvalSuiteOptions): Promise<{ aggregates: EvalAggregate[]; perQuery: EvalRunRecord["perQuery"]; }>; export declare function formatAggregateTable(aggregates: readonly EvalAggregate[]): string; //# sourceMappingURL=eval-harness.d.ts.map