Golden Eval Corpus — Reference

Architecture, schema, API, runbook and design rationale

Scope of this document. It covers the eval corpus feature only: the catalog, the scoring runs, and the admin surface. It is a read-only observer of the V5 ingestion pipeline and documents nothing about how the pipeline itself works — for that see .kiro/specs/UNIFIED_UPLOAD_PIPELINE/ARCHITECTURE_V5.md and V5_PARSING_REDESIGN.md.

1. What this is

A curated set of documents with known-correct expected values, plus the machinery to score the V5 pipeline against them on demand and keep the results as a time series.

Before this existed, the corpus was four unrelated directories on disk with no index, and every scoring run wrote to a file under .audit/. Two questions had no answer short of reading directories and interpreting them by hand:

Both are now queries. The catalog lives in eval_corpus_documents / eval_corpus_labels; scoring history lives in eval_runs / eval_run_documents / eval_results.

The distinction that everything else follows from

An uploaded document is a candidate, not an eval case. It becomes an eval case only when someone attaches expected values to it. A document with no ground truth adds coverage on paper and zero measurement in practice — and counting it would inflate the corpus while weakening every number derived from it. The lifecycle (unlabeled → labeled → golden) exists to enforce this, and only golden documents enter a scored run.

2. The four eval classes

The corpus proves four different things with four different denominators. They are never averaged into a single number, because a blended percentage across them measures nothing while sounding authoritative. eval_class lives on the document and every aggregate is keyed by it.

eval_classWhat it provesSourceRunnable?
document_extraction The parser reading a real document. The number that matters. benchmarks/ground-truth/*.yaml (public), Harbor fixtures (PE) Yes, when a source artifact exists
source_of_record A vendor/API backfill landed correctly. Cannot exonerate the parser — no document is involved. The 7 FMP-labelled GT files with no source_file No — nothing to parse
product_logic Deterministic calc and strategy-fit correctness. benchmarks/pe-ground-truth/pe-metrics-eval-corpus.json No — no document
document_quality Per-document QA gates on generated documents. .audit/blackwell/<company>/*.qa.json Yes (synthetic tier)

Real vs synthetic is a separate axis

corpus_kind is real or synthetic and the two are reported as separate tiers. Synthetic supply is effectively unbounded — the Blackwell generator emits the document and its expected facts together — but it cannot demonstrate real-world accuracy. Merging it into the real-document headline would make the corpus look stronger and the number weaker.

Label evidence grade

label_authority_class records how much the ground truth itself is worth. This is about the labels, not the document.

valueMeaning
primary_documentRead off the filing or fixture text itself. Strongest.
vendor_apiDerived from FMP or similar. Agreement proves consistency with a vendor, not correctness — the vendor can be wrong, and then a disagreement gets chased as our defect.
triangulatedMultiple secondary sources agreeing.
generatorSynthetic: the generator emitted document and truth together.
unpopulatedAuthored as a TODO. Not evidence yet. Cannot promote a document to golden.

Live example worth knowing. crh-fy2024 has a source PDF and data_source: FMP API. So it is parsed, but graded against vendor values rather than values read off the PDF. It is classed vendor_api for that reason. The honest count of public-equity parser evidence against primary-document ground truth is therefore 3 documents (bmw, meli, recruit), not 4 — and bmw currently cannot ingest, leaving 2 producing primary-document-grade evidence today.

3. Invariants

These are enforced by schema constraints and code, not by convention. Breaking one produces a dashboard that lies, which is worse than no dashboard.

1. Observer only — never modify the pipeline

The eval writes exclusively to eval_* tables. It never writes to, alters or migrates documents_v5, pipeline_jobs, extracted_metrics, document_canonical, document_chunks_v5 or pipeline_outbox. It drives the pipeline only through the pipeline's own pre-existing HTTP surface and reads results back with plain SELECTs. There are deliberately no foreign keys from eval_* into pipeline tables — so eval history survives pipeline row cleanup, and this feature can never block a pipeline migration. scripts/apply-eval-corpus-migration.js verifies both properties on every apply and fails loudly.

2. Every ratio ships with its denominator

Per-axis samples here are small. A percentage without its sample size is not a measurement. eval_run_documents.denom is stored explicitly and the UI renders n= beside every figure.

3. Null means "no evidence", never zero

When nothing was asserted, pass_rate is NULL and the UI renders "no evidence". A 0% that actually means "we measured nothing" is the single most misleading thing an accuracy dashboard can show. Note the corollary in gating: a gated statement with coverage = null is a gate failure, so an empty corpus surfaces the gap instead of being rewarded.

4. Unjudged is not a failure

The label set is a sample, not a census. An extracted observation outside label coverage is counted in n_unjudged and reported, never scored as a false positive. Treating uncovered observations as false positives would understate precision by construction.

5. Exclusions are explicit and reasoned

eval_corpus_labels.is_scored = false requires exclusion_reason (CHECK-enforced). Placeholder zeros and skip-flagged stubs are excluded from both numerator and denominator, and say why. Same for runnable = false requiring not_runnable_reason.

6. System output never becomes a label

Ground truth is authored from the document, not from what the pipeline produced. The Harbor dictionary models this properly: it declares authority: fixture document text, an explicit never_consulted: list naming the tables it refused to read, and per-fact confirmed_by. If label pre-fill from extraction is ever added, those labels must carry a distinct authority class and stay out of every denominator until a human confirms them.

7. No blended score

There is no column anywhere that holds an average across eval classes, across real and synthetic, or across the six Harbor fields.

4. Architecture

Admin console  public/internal/eval-corpus.html
       │  fetch + x-admin-key
       ▼
Controller     src/admin/eval-corpus.controller.ts   @Controller('admin/eval-corpus')
       │                                             @UseGuards(PlatformAdminGuard)
       ▼
Service        src/admin/eval-corpus.service.ts      EvalCorpusService
       │
       ├── catalog / coverage reads ──────────► eval_corpus_documents, eval_corpus_labels
       │
       ├── startRun() ─► mints eval_runs row (state=queued), returns runId, detaches
       │                     │
       │                     ▼  executeRun()  (background)
       │     ┌───────────────┴────────────────────────────┐
       │     │ public_equity leg                          │ private_equity leg
       │     │ spawn scripts/v5-accuracy-eval.js          │ spawn scripts/run-harbor-
       │     │   env: PIPELINE_RELEASE, GT_INCLUDE,       │   accuracy-eval.js --json
       │     │        SKIP_UPLOAD, FUNDLENS_BASE_URL      │ (read-only, no re-ingest)
       │     │   └─► POST /api/v5/dev/upload              │
       │     │        x-pipeline-release: <run tag>       │
       │     │        polls pipeline_jobs.state           │
       │     │   └─► writes .audit/v5-accuracy/<slug>.json│
       │     └───────────────┬────────────────────────────┘
       │                     ▼
       │            ingest artifacts ──► eval_run_documents, eval_results
       │                     │
       │                     ▼
       │            observePipeline()  SELECT-only:
       │              pipeline_jobs.parser_name / state
       │              document_layout.parser_name
       │              llm_invocations  → cost_usd, call count
       │              pipeline_events  → stage wall-clock
       │                     │
       │                     ▼
       │            buildSummary() ──► eval_runs.summary, gate, cost, duration
       ▼
Backfill       scripts/backfill-eval-corpus.js  (CLI, idempotent)
                 indexes the 5 on-disk sources into the catalog

Why the service spawns the existing scripts

scripts/v5-accuracy-eval.js holds roughly 500 lines of comparator logic that is almost entirely accumulated edge cases, each one a real defect that was found and fixed: balance-sheet snapshot period aliasing (gated so a 9M revenue can never satisfy an FY pin), sign-wildcard absolute-value comparison for capex printed in parentheses, an is_primary tie-break added because a non-GAAP "Adjusted FCF" was shadowing the primary statutory FCF, a ~35-entry bidirectional statement-role alias map, and registry-derived canonical-id alias merging.

Reimplementing that in TypeScript would create a second implementation of one rule. This repo's leading defect class is exactly that — the Harbor eval header records a case where the duplicate had a bug production did not. So the comparator stays where it is, proven, and the service is orchestration and persistence around it.

Accepted tradeoff: an out-of-process invocation is coarser than an in-process call and cannot stream progress per metric. It does report per-document progress, which is the granularity the console needs.

5. Database schema

Migration: prisma/migrations/20260821_eval_corpus.sql. Apply with node scripts/apply-eval-corpus-migration.js. The COMMENT ON statements in that file are the authoritative reference — this page summarises them.

eval_corpus_documents — the case registry

One row per corpus artifact. "Document" is loose: a product_logic row has no file, and carries runnable = false with a reason.

ColumnTypeNotes
idUUID PK
slugTEXT UNIQUEStable human key. Matches the on-disk GT filename where one exists (crh-fy2024) so catalog and files stay greppable against each other.
partitionVARCHAR(16)CHECK public_equity | private_equity
eval_classVARCHAR(24)CHECK — the four classes in §2
corpus_kindVARCHAR(16)CHECK real | synthetic
label_statusVARCHAR(16)CHECK unlabeled | labeled | golden | retired. Only golden enters runs.
runnableBOOLEANCan be driven through V5. CHECK: false requires not_runnable_reason.
not_runnable_reasonTEXTWhy not. Surfaced in the UI as a named gap.
company, ticker, period,
filing_type, jurisdiction,
accounting_standard,
currency, language
variousCoverage-matrix dimensions. Nullable — a PE fixture has no ticker.
source_pathTEXTRepo-relative for backfilled rows; the harness reads fixtures from disk.
s3_keyTEXTReserved for a future S3-backed corpus.
content_sha256CHAR(64)Content-addressed dedupe on upload.
source_bytesBIGINTAlso the ordering key for the smoke tier (cheapest first).
doc_format, doc_genreVARCHAR
label_authorityTEXTProvenance string as authored.
label_authority_classVARCHAR(24)CHECK — the five grades in §2
gt_source_fileTEXTWhich YAML/JSON defines the labels.
provenanceVARCHAR(24)CHECK on_disk_backfill | admin_upload
v5_document_idUUIDObserved link. Not an FK — see invariant 1.

eval_corpus_labels — the expectations

Deliberately not unified into one flat shape. Three label vocabularies already existed and each carries load-bearing distinctions a generic {expected_value, tolerance} would destroy. The native record is preserved verbatim in expected (JSONB); the promoted columns exist only so common questions are indexable.

label_kindShape and why it stays distinct
metric_axesPublic GT. Eight independent scored axes: value, role, entity scope, currency, unit scale, period, statement basis, sign.
pe_factharbor-facts/v1. Keeps stated_value/stated_scale separate from expected_value, which is what makes a scale-blind parser detectable.
qa_checkPer-document gate: pass/warn/fail/na, with automated and human gates kept distinct. A machine pass is not a human sign-off.
line_item_structureRow order and label structure, not values.

Notable columns: axis_pin_count (0–8) makes "is our per-axis coverage real or aspirational" a query rather than a guess; is_scored + exclusion_reason implement invariant 5.

Uniqueness is an expression index, not a constraint. idx_eval_corpus_labels_ident on (corpus_document_id, label_kind, label_key, COALESCE(period,'')). It started as a plain UNIQUE (…, period) and that was wrong in a way that only appears on the second run: period is NULL for every qa_check, and NULL is never equal to NULL under a UNIQUE constraint, so ON CONFLICT never matched and each backfill re-inserted the whole set (156 checks became 312). Any ON CONFLICT against this table must name the same COALESCE expression or it will compile and silently never match.

eval_runs — one row per triggered run

Modelled on strategy_dna_evaluations (counts + JSONB detail + prior_*_id delta), the closest existing precedent in this repo.

ColumnNotes
pipeline_releaseUNIQUE. The run's own tag, and the isolation mechanism. See §9.
modeCHECK reingest | rescore
scopeCHECK smoke | regression | full | custom
partition_filter, slug_filterWhat was selected. slug_filter is a JSONB array.
stateCHECK queued | running | scored | failed | cancelled
docs_total / docs_scored / docs_erroredCounts.
duration_ms, cost_usd, llm_callsCost summed read-only from llm_invocations for this release. Zero on rescore, correctly — nothing was parsed.
summaryJSONB: per-class scoreboards, per-statement gates, graded_release, run_release, mode, and a note restating the no-blending rule.
gate, gate_reasonCHECK pass | fail or NULL when nothing gated.
prior_run_idSelf-FK. Drives the regression diff.

eval_run_documents — per (run, document)

Exists because the facts that diagnose a run are not derivable from aggregates: which parser ran, where the job stopped, how long it took, what it cost. UNIQUE (run_id, corpus_document_id).

Key columns: parser_name (see §9 — the harness never recorded this), final_job_state, eval_tenant_id / v5_document_id / v5_job_id (observed, not FKs), n_pass / n_fail / n_missing / n_skipped / n_unjudged, pass_rate + denom, and canonical / chunks / per_axis JSONB observations.

eval_results — the atomic scored assertion

One row per (run, label). This is the grain that makes a regression diagnosable at assertion level rather than as a moved percentage.

verdictMeaning
pass / fail / missing / skippedThe V5 axis scorer's outcomes.
unjudgedOutside label coverage. Not a failure.
wrong_docRight value, wrong source document — a provenance defect wearing a recall failure's clothes.
scaleMagnitude present but off by a power of ten. Broken out rather than hidden inside a value failure, because the root causes are entirely different.
partialValue right, another field wrong (Harbor standard).

Also carries expected_value, actual_value, tolerance, delta_pct, matched_metric, grounded, evidence_type, axis_status JSONB (the 8 axes) and field_status JSONB (the 6 Harbor fields).

6. API reference

Base: /api/admin/eval-corpus. Every endpoint requires x-admin-key matching PLATFORM_ADMIN_KEY (or PLATFORM_ADMIN_KEY_SECONDARY). Failures return a bare 401 with no body, so the endpoint's existence isn't revealed. There is no tenant or user context — the admin key is the authorization.

MethodPathParamsReturns
GET/?partition={success, documents[]} — catalog with label counts and last-run accuracy per document
GET/coverage—{success, coverage} — byClass, dimensions, pe_value_basis, scale_traps, gaps, uncoveredRegimes
GET/runs?limit= (default 25, max 200){success, runs[]} — includes a computed stale flag
GET/runs/:runId—{success, run, documents[], regressions[]}
POST/runsbody below202 {runId, pipelineRelease, gradedRelease, mode, scope, documents[]}
POST/uploadmultipart, field file201 {slug, sourcePath, contentSha256, labelStatus:'unlabeled'}
POST/:slug/labelsbody below{inserted, rejected[], labelStatus}

POST /runs

{
  "scope":     "smoke" | "regression" | "full" | "custom",   // default "full"
  "mode":      "reingest" | "rescore",                        // default "reingest"
  "partition": "public_equity" | "private_equity",            // optional, both if omitted
  "slugs":     ["crh-fy2024", "meli-fy2024"],                 // optional; overrides scope
  "label":     "pre-release check",                           // optional, ≤200 chars
  "triggeredBy": "ajay",                                      // optional
  "targetRelease": "v5-f26-f27a"      // rescore ONLY; 400 if sent with reingest
}

Returns 202, not 200: a full reingest drives every corpus document through the real pipeline serially and is measured in hours. Poll GET /runs/:runId.

Scope semantics: smoke = 1 document, regression = 2, both ordered by source_bytes ascending. Cost is dominated by page count, and a smoke tier that takes an hour will not get run. Selection is always restricted to eval_class='document_extraction' AND runnable AND label_status='golden'.

POST /:slug/labels

{
  "labelKind": "metric_axes",          // default; or pe_fact | qa_check | line_item_structure
  "labels": [
    { "canonical_id": "revenue", "statement": "income_statement",
      "period": "FY2024", "expected_value": 34351880400, "tolerance": 0.05,
      "expected_role": "revenue", "expected_currency": "USD", "expected_sign": "positive" }
  ],
  "promote": true,                     // default true → label_status becomes 'golden'
  "authority": "10-K page 62, PDF-verified 2026-08-21",
  "authorityClass": "primary_document" // 'unpopulated' blocks promotion
}

Validation is strict and per-label: a metric_axes label without a finite expected_value is rejected and reported in rejected[] rather than silently accepted. A bad label is worse than a missing one, because it produces a confident wrong verdict that then gets chased as a product defect. If every label is rejected the whole request 400s.

POST /upload

Limits: 150 MB, one file, extension allow-list (pdf docx doc xlsx xlsm xls csv pptx html htm md txt). Requires partition. Optional metadata: company, ticker, period, filingType, accountingStandard, currency, language, jurisdiction, docGenre, corpusKind, notes, uploadedBy.

Deduped on content hash — identical bytes return the existing case with duplicate: true rather than creating a second row that would double-count in every coverage figure. Registers as unlabeled, which means excluded from runs and from every denominator until labelled and promoted.

Upload storage is not durable in a container. Files are staged to benchmarks/uploads/ on local disk, because the harness reads its fixtures from the filesystem — staging them anywhere else would let a document be catalogued but never run. For a persistent corpus, artifacts belong in version control alongside COMPANY-FILINGS/, or in S3 with a sync step. Treat upload as a curation convenience, not the system of record.

7. Runbook

Environment

VariableRequired forNotes
DATABASE_URLeverything
PLATFORM_ADMIN_KEYall endpointsPLATFORM_ADMIN_KEY_SECONDARY also accepted. If neither is set, every admin endpoint is inaccessible.
V5_DEV_UPLOAD_ENABLEDreingest runsThe harness posts to /api/v5/dev/upload. Defaults off in production.
EVAL_BASE_URLoptionalWhere the child process reaches this app. Defaults http://localhost:${PORT ?? 3000}.
EVAL_RUN_TIMEOUT_MSoptionalChild-process ceiling, default 4 h. A run still running past this + 10 min is reported stale.

First-time setup

# 1. create the eval tables (idempotent; verifies observer-only and fails on drift)
node scripts/apply-eval-corpus-migration.js

# 2. index the on-disk corpus into the catalog
node scripts/backfill-eval-corpus.js --dry-run   # report only, no writes
node scripts/backfill-eval-corpus.js             # apply + print coverage matrix

# 3. open the dashboard
open http://localhost:3000/internal/eval-corpus.html

The backfill is idempotent and safe to re-run — re-run it whenever ground-truth files change on disk. It upserts on slug and on the label identity expression, and runs inside a single transaction so a parse failure anywhere leaves the catalog untouched rather than partially updated (a partial catalog silently understates coverage).

Running an eval

From the console: Runs tab → pick scope and mode → Start run. The page polls every 15 s and refreshes the catalog when the run finishes.

From a terminal:

KEY=$(grep -m1 '^PLATFORM_ADMIN_KEY=' .env | cut -d= -f2- | tr -d '"')

# fast: grade already-stored rows (seconds, no pipeline cost)
curl -s -X POST -H "x-admin-key: $KEY" -H 'Content-Type: application/json' \
  -d '{"scope":"custom","mode":"rescore","slugs":["crh-fy2024","meli-fy2024","recruit-fy2026q3"]}' \
  http://localhost:3000/api/admin/eval-corpus/runs

# full: drive documents through V5 (hours, real Bedrock cost)
curl -s -X POST -H "x-admin-key: $KEY" -H 'Content-Type: application/json' \
  -d '{"scope":"full","mode":"reingest"}' \
  http://localhost:3000/api/admin/eval-corpus/runs

# poll
curl -s -H "x-admin-key: $KEY" \
  http://localhost:3000/api/admin/eval-corpus/runs/<runId> | jq '.run.summary'

Choosing a mode

modeCost / timeWhat it can detect
rescoreSeconds, $0A change in scoring only. It grades rows already in extracted_metrics, so it cannot detect a parsing regression. Use for demos, comparator changes, and label edits.
reingestHours, real spendEnd-to-end parsing accuracy. This is the number that means something. Use before a release.

Runs are serialized — starting one while another is active returns 400. The harness writes per-document artifacts to fixed paths under .audit/v5-accuracy/, so concurrent runs would overwrite each other's evidence and both would report whichever finished last.

Adding a document to the corpus

Two routes. Prefer the first for anything permanent.

A. On disk (durable, version-controlled — the system of record)

  1. Put the artifact under COMPANY-FILINGS/.
  2. Author benchmarks/ground-truth/<slug>.yaml following benchmarks/ground-truth/GT_FORMAT_GUIDE.md. Set source_file to the repo-relative path and data_source to an honest provenance string — if the values came from a vendor, say so.
  3. Run node scripts/backfill-eval-corpus.js.
  4. Verify it appears with runnable=true, then run a custom reingest over just that slug.

B. Via the console (curation convenience)

  1. Add document tab → upload → registers as unlabeled.
  2. POST /:slug/labels with expected values and an authority class → promotes to golden.
  3. Run a custom eval over that slug.

Interpreting a result

8. Design decisions and why

DecisionRationale
Observer-only; no pipeline changesMeasurement must not perturb the thing measured, and the eval must never be able to block a pipeline migration. Verified mechanically on every migration apply.
Spawn the existing harness rather than reimplement~500 lines of hard-won comparator edge cases. Duplicate implementations of one rule are this repo's leading defect class.
Four eval classes, no blended scoreDifferent denominators proving different things. A single average would sound authoritative and measure nothing.
Three label vocabularies preserved, not unifiedFlattening would discard the exact distinctions the corpus exists to test — notably stated-vs-expected scale, and automated-vs-human gates.
Unique pipeline_release per runWithout it a re-run silently grades the previous run's metrics. See §9.
graded_release separate from run_releaseA reingest writes rows under its own fresh tag; a rescore must read an existing tag. Conflating them makes every rescore report 0%.
Explicit partition column rather than deriving itdocuments_v5.module has the right CHECK but both upload paths write 'shared' unconditionally, so it carries no signal. deals.deal_type only works for deal-bound documents. Declaring beats deriving.
No FKs into pipeline tablesEval history must outlive pipeline row cleanup, and an FK would couple release cycles.
Runs serializedShared artifact paths. Queueing is honest; racing is not.
Local disk for uploadsThe harness reads fixtures from the filesystem. Anywhere else and the document is catalogable but not runnable. Documented as non-durable.

9. Traps and gotchas

Trap 1 — reusing a release tag grades stale results and reports success.

pipeline_jobs is UNIQUE on (document_id, pipeline_release) and its upsert conflict branch updates only updated_at — it does not reset state. Re-running a document under a tag it has already been run under returns a job already sitting in ready, the poll returns immediately, and the eval grades the previous run's metrics while looking like a fresh success. A silent wrong answer, which is worse than a failure. Mitigation: every run mints a unique tag. Requires no pipeline change — the dev-upload controller already resolves x-pipeline-release from the request header ahead of env, per request, for exactly this purpose. Do not mutate process.env.PIPELINE_RELEASE to achieve this: it is process-global and races across concurrent runs.

Trap 2 — NULL ≠ NULL under a UNIQUE constraint.

Covered in §5. Any ON CONFLICT on eval_corpus_labels must name COALESCE(period,''). A plain (…, period) target compiles and silently never matches for qa_check labels.

Trap 3 — pdf-ocr parks locally, and that is not a failure.

pdf-native parses synchronously. pdf-ocr is submitted to Bedrock Data Automation and completes only via EventBridge → SQS. Without that wiring the job sits at parsing_submitted until the dev bridge is driven (POST /api/v5/dev/bda-pending then /api/v5/dev/bda-complete, or scripts/bda-completion-poller.js). The existing harness has no BDA awareness and simply times out after 25 min per document. This is why eval_run_documents.parser_name is captured: without it, "the eval timed out" is indistinguishable from "the eval could never have completed in this environment".

Trap 4 — per-axis rates are optimistic relative to the value axis.

For a labelled axis, a missing extraction contributes skipped on axes 2–8 but missing only on value. So per-axis pass rates are computed over extracted rows and read better than the value axis whenever coverage is poor. Always show the axis denominator, and never quote a per-axis figure without it.

Trap 5 — tenant-id column types.

Per .kiro/steering/tenant-id-type-safety.md: documents_v5, pipeline_jobs, document_canonical, document_chunks_v5, pipeline_events are UUID (::uuid cast correct). extracted_metrics and llm_invocations are VARCHAR(64) (pass as a plain parameter, no cast). deals.tenant_id is TEXT. Getting this wrong raises Postgres 42883 and has caused production outages here before. Verify with one query against information_schema.columns rather than trusting any list.

Note — eval tenant derivation is duplicated.

The harness computes its per-slug tenant as '11111111-1111-1111-1111-' + sha1(slug).slice(0,12) inside a standalone script with no exports. EvalCorpusService.tenantForSlug() reproduces it so the service can read back the rows the harness wrote. If the harness ever changes this derivation, both must change together — otherwise rescore silently resolves no release and reports nothing. This is a known, deliberately-accepted duplication; the clean fix is to export it from a shared module.

Note — /internal/*.html is publicly served.

The static pages are reachable without auth; only the APIs are key-gated. Pre-existing for every admin page, not specific to this feature — but worth knowing before a URL goes on a shared screen.

10. Current state (as at 2026-08-21)

Catalog: 56 documents, 848 labels.

Partitioneval_classTierDocsRunnableLabels
private equitydocument_extractionreal7767
private equitydocument_qualitysynthetic2020145
private equityproduct_logicsynthetic1011
public equitydocument_extractionreal214521
public equitysource_of_recordreal70104

Verified reference run

Rescore over the 4 runnable public documents, graded against release v5-f26-f27a. Gate fail (income statement 88.5% against a 90% threshold). CRH's 94.7% reconciles exactly with running the harness standalone, which is the evidence that ingest is faithful rather than approximately right.

slugAccuracynNote
meli-fy2024100.0%14
crh-fy202494.7%19labels are vendor_api
recruit-fy2026q389.5%19
bmw-fy2024errored—documents_v5 row not found — never successfully ingested

Open items

11. Extending it

Add a new corpus source directory

Write a collect*() function in scripts/backfill-eval-corpus.js returning { doc, labels[] } records, and register it in the sources array in main(). Everything downstream (coverage matrix, UI, runs) is driven off the catalog and needs no change.

Add a new label vocabulary

Extend the eval_corpus_labels_kind_chk CHECK and eval_results_verdict_chk if new verdicts are needed. Keep the native record in expected verbatim; promote only the columns you need to index. Resist unifying into an existing kind — the vocabularies are distinct because the distinctions matter.

Add a new eval leg (a different scorer)

Follow the Harbor pattern in EvalCorpusService.runHarborEval(): spawn the scorer, parse its output, write one eval_run_documents row per corpus document and one eval_results row per assertion. Report its numbers beside the others — do not merge them into an existing scoreboard.

Persist eval runs from CI

Call POST /runs with trigger_source left default, or insert directly with trigger_source='cli'|'scheduled' (both already allowed by the CHECK). Gate thresholds in buildSummary() mirror scripts/lib/eval-aggregator.js (IS 0.90 / BS 0.85 / CF 0.80) so the console and CI cannot disagree about whether a run passed. If you change one, change both.

Make uploads durable

Store to S3 under an eval-corpus/ prefix and add a materialise-to-disk step before the harness runs, or require corpus artifacts to be committed. Do not simply switch to S3 without the materialise step — the harness reads from the filesystem.

12. File map

PathRole
prisma/migrations/20260821_eval_corpus.sqlSchema. The COMMENT ON statements are the authoritative reference.
scripts/apply-eval-corpus-migration.jsApplies the migration and verifies the observer-only guarantee. Lives in scripts/, not .audit/ — .audit/ is git-ignored, and a migration nobody can apply is not much use to the next engineer.
scripts/backfill-eval-corpus.jsIndexes the on-disk corpus. Idempotent. Prints the coverage matrix and named gaps.
src/admin/eval-corpus.service.tsEvalCorpusService — catalog, coverage, run lifecycle, artifact ingest, pipeline observation.
src/admin/eval-corpus.controller.tsHTTP surface under PlatformAdminGuard.
src/admin/admin.module.tsRegistration.
public/internal/eval-corpus.htmlDashboard.
public/internal/eval-corpus-docs.htmlThis page.
scripts/v5-accuracy-eval.jsPre-existing. The public-equity comparator. Spawned, not modified.
scripts/run-harbor-accuracy-eval.jsPre-existing. The PE scorer — separate precision/recall, UNJUDGED cohort, six per-fact fields. Spawned, not modified.
scripts/lib/eval-aggregator.jsPre-existing. Gate thresholds and coverage definition.
benchmarks/ground-truth/*.yamlPublic-equity ground truth. See GT_FORMAT_GUIDE.md.
benchmarks/pe-ground-truth/harbor-facts-dictionary.yamlPE ground truth, harbor-facts/v1.
.audit/blackwell/<co>/*.qa.jsonSynthetic document QA gates.
test/fixtures/sec-10k-structures/**Row-structure fixtures.
benchmarks/uploads/Console-uploaded candidates. Not durable in a container.

Related steering. .kiro/steering/tenant-id-type-safety.md (column types — read before writing raw SQL) and .kiro/steering/source-precedence.md (data-source tiering and the granularity-match rule, which governs how XBRL-derived labels may be authored).

13. Change history and deployment

Commits

CommitBranchContents
9014a60 feat/golden-eval-corpus Initial feature. 12 files, +4,888 lines: the 5-table migration, apply script, backfill, EvalCorpusService, controller, dashboard, this documentation page, console nav registration, the benchmarks/uploads/ ignore rule, and two corpus hygiene fixes (meli filing_type normalisation; the Harbor scope statement's hardcoded fact count).
8fd66bd feat/golden-eval-corpus This section — change history, deployment order, rollback.
daa1367 feat/golden-eval-corpus Table readability (fixed layouts, 9/9/10 columns reduced to 6/5/6, not-runnable reason moved to its own sub-row), a new Periods column that turns amber at 1, and scripts/eval-label-gap-report.js — which measures label-depth headroom per document. See §14.

Unrelated work also present on this branch and not part of this feature: a230477 (JWT verification), a94bf2c (cost telemetry), 0320a64 (EDINET/DART metric storage).

Branched from feat/pe-uiux-phase4. Deliberately a separate branch: the eval corpus is unrelated to the PE UI/UX work in flight on that branch, and mixing them would make either one hard to review or revert independently.

Note for whoever merges this.

Two files in the commit were staged partially, on purpose: src/admin/admin.module.ts and public/internal/index.html. Both also carried in-flight cost-telemetry / unit-economics work at the time (CostTelemetryController, CostTelemetryService, InternalOperatorGuard, and a unit-economics console card). Committing those files wholesale would have referenced source files not in this commit, producing a branch that does not compile. So only the eval-corpus registration and the two eval-corpus nav cards were committed; the other work was left untouched in the working tree for its own author to commit.

Verified at the time: 9014a60 typechecks in isolation (git worktree at that commit, tsc --noEmit, no src/admin errors) — i.e. the partial stage did not leave a branch that references files it does not contain.

Resolved, no action needed. The cost-telemetry work subsequently landed on this same branch (a94bf2c), and its registration was added alongside the eval-corpus one rather than over it. admin.module.ts now carries both EvalCorpusController/EvalCorpusService and CostTelemetryController/CostTelemetryService, and the console card grid carries both sets of cards. An earlier revision of this section predicted a merge conflict here; that did not occur, and no conflict is pending.

Deploying

Order matters — the schema must exist before the code that queries it starts serving.

# 1. Schema. Idempotent, and self-verifying: it diffs pipeline table column
#    counts before/after and fails if any eval_* table gained an FK into a
#    pipeline table. Safe to re-run.
node scripts/apply-eval-corpus-migration.js

# 2. Deploy the application (the new controller + service).
npm run build
#    then the usual path: scripts/deploy/deploy-infrastructure.sh / ECS task update

# 3. Populate the catalog. Until this runs the dashboard renders an empty
#    corpus, which is correct but not useful.
node scripts/backfill-eval-corpus.js

# 4. Smoke check
curl -s -H "x-admin-key: $PLATFORM_ADMIN_KEY" \
  https://<host>/api/admin/eval-corpus/coverage | jq '.coverage.byClass'

Environment prerequisites on the target: DATABASE_URL, PLATFORM_ADMIN_KEY, and — only if you intend to run reingest evals there — V5_DEV_UPLOAD_ENABLED=true, since the harness drives /api/v5/dev/upload and that endpoint defaults off in production. A rescore needs neither the dev upload route nor Bedrock, so it works on a locked-down host.

Do not deploy step 2 before step 1. The controller's first catalog query would hit missing tables and the console would show an error rather than an empty corpus. Conversely step 1 alone is harmless — the tables sit unused until code reads them.

Rollback

Reverting the application commit is sufficient and safe. Because the feature is an observer, the eval_* tables are inert once nothing queries them: no pipeline code reads them, no FK points into them, and no trigger fires from them. Leaving the schema in place preserves the measurement history for the next attempt.

git revert 9014a60        # or drop the branch before merge

# Only if you genuinely want the history gone. Destructive and NOT required
# for a rollback — every eval run ever recorded is in these tables.
# DROP TABLE eval_results, eval_run_documents, eval_runs,
#            eval_corpus_labels, eval_corpus_documents;

Known state at commit time

14. Growing eval depth (not document count)

The corpus has a depth problem, not a breadth problem. A 10-K presents two or three comparative years across three statements, 60–100 line items, and a segment note. We grade one period and ~15 headline metrics per document. Most of every document is unmeasured.

Run node scripts/eval-label-gap-report.js --release <release> to get this measured per document instead of estimated.

The circularity rule — non-negotiable.

The gap report reads extracted_metrics to discover where to look. It does not license copying extracted values into labels. A label whose value came from the pipeline cannot test the pipeline: it passes by construction, inflates coverage, and turns the eval into a mirror. Every value must be read off the document. The report withholds extracted values unless --show-values is passed, so the lazy path is not the default path.

Measured headroom (public equity, 4 runnable documents)

CaseLabels nowPeriods in docPairs availableProjected
crh-fy202419 (1 period)6122~141 labels
meli-fy202414 (1 period)4126~130 labels / 9.3×
recruit-fy2026q319 (1 period)790~109 labels / 5.7×
bmw-fy202414——blocked: never ingested

Corpus: 66 labels today → 379 available (5.7×) from the same documents, and roughly 3,000 assertions once the eight axes are counted per label.

The four levers, cheapest first

  1. Comparative periods. The single biggest and cheapest lever. The comparative column sits beside the one the author already read, and the author already knows the concept and statement. It also tests a real defect class that a single-period corpus cannot see at all: column selection. A parser that grabs the prior-year column is within tolerance of nothing and currently invisible. No schema or harness change needed — eval_corpus_labels is unique on (document, kind, key, period) and the harness resolves period per metric via expected_period_normalized, so extra periods work today.
  2. Axis completion. A label pinning all eight axes yields eight assertions instead of one, with no additional document reading beyond what authoring the value already required. Currently 61 of 66 runnable labels are fully pinned, so this lever is nearly exhausted on the public side — which is why it ranks second rather than first.
  3. Derived / footing relations. Free — composed from labels that already exist. Assert the arithmetic, not just the values: gross profit foots from revenue and COGS; assets equal liabilities plus equity; free cash flow equals operating cash flow less capex; net income on the income statement ties to the top of the cash flow statement. This catches a defect class per-value checks structurally cannot: a set of individually plausible figures that do not reconcile. 4–6 relations are assertable per document right now.
  4. Deeper line items. Concepts the document presents that were never labelled — 74–96 per document.

Not counted in that projection, and both matter more than the headline

Schema work this implies

Levers 1, 2 and 4 need no schema change — author more rows. Lever 3 (derived/footing) needs one migration: extend the eval_corpus_labels_kind_chk CHECK with a derived_relation kind whose expected JSONB holds the relation expression and operand list, and extend eval_results_verdict_chk if a distinct does_not_foot verdict is wanted rather than reusing fail.

Known limitation of the gap report. It compares period tokens as raw strings, while the harness aliases them at scoring time (Q1-Q3-FY2025 ↔ 9M-2025, FY2024 ↔ 2024). Recruit therefore reads "0 labelled" on every period while actually scoring 89.5%. The report detects that case and states the counts are an upper bound. The harness's ~170-line period aliaser is deliberately not reimplemented there.

Maintained alongside src/admin/eval-corpus.service.ts. If behaviour and this page disagree, the code and the migration comments are correct — and this page is a bug.