Skip to content

docs: measurement corpus — human ground truth, machine labels, and two protocols - #206

Merged
alexwolson merged 11 commits into
mainfrom
ground-truth-trca-labels
Aug 6, 2026
Merged

docs: measurement corpus — human ground truth, machine labels, and two protocols#206
alexwolson merged 11 commits into
mainfrom
ground-truth-trca-labels

Conversation

@alexwolson

Copy link
Copy Markdown
Collaborator

Eleven commits of measurement work, all under docs/. No file outside docs/ is touched — nothing under scrapers/ or deploy/, so this cannot change what the nightly does. uv run pytest: 800 passed.

Why it's one branch and not four

It grew past its name. The branch is ground-truth-trca-labels and only 7 of 27 files are TRCA — it accumulated four deliverables, because each one was the answer to a question the previous one raised. They cross-reference: the machine labels are scored against the ground-truth set and cite it by path. Splitting them now would either break those references or duplicate them. The 11 commits are the real split; each is a coherent finding with its own headline.

What's in it

area what
docs/ground-truth/trca-bid-labels/ human labels for TRCA bid records; parser measured at 57% → 59%/93% recall; second labeller's set, 95% inter-rater agreement where coverage overlaps
docs/ground-truth/document-classification/ 125-document human sample, 13-class taxonomy; a linear model matches the LLMs
docs/machine-labels/document-classification/ machine labels for all 5,750 remaining documents, 96% flag recall, honeypot-validated
docs/protocols/ model selection by measured ordering rather than leaderboard; extraction schema by document type

The line count is 98% one file

136,150 insertions sounds enormous. 133,083 of them are JSON data and 130,549 are labels-machine.json alone — a 3.6 MB machine-generated artifact. The reviewable surface is ~2,300 lines: 973 of markdown and 1,334 of one-off analysis Python. Review those; skip the JSON.

Two things to know before reading

Machine labels are deliberately not ground truth, and are stored so they can't be mistaken for it. They live in docs/machine-labels/, a sibling of docs/ground-truth/ and never inside it. The JSON carries provenance: "machine", a warning string, and per-row vote detail. Where the two disagree, the human file is right until shown otherwise.

The flag uses OR-of-votes, not majority — that's the finding worth keeping. Majority vote left 57 of 5,750 documents on the wrong side of contains_bid_or_award despite at least one reader catching real award language; 27 had a procurement_award vote, some naming a supplier and an amount the other two readers missed outright ("Award to Carollo Engineers Canada Ltd./EXP Services Inc. $2,765,968.25; three suppliers bid"). That's this archive's usual asymmetry: a false positive costs one wasted extraction call, a false negative is never extracted and nothing downstream notices. kind stays majority-with-null — no equivalent asymmetry there, and a guessed label is worse than an honest gap.

Known open, documented as limits rather than hidden

  • Kind accuracy ~63% — matches calibration, was never this run's target, and per the flag results doesn't change what gets extracted.
  • Honeypot n=120 is small for a hard 95%+ recall claim; one miss moves the headline 4 points.
  • The single honeypot flag miss (C088) is a Vendor-of-Record contract extension, called false unanimously by both passes. Whether an extension of an already-awarded arrangement should trip the flag is a schema question, not obviously a model error — left for a human call.
  • The 200-document SVM verification is unlabelled and now doubles as an independent human check on these machine labels for the same documents.

🤖 Generated with Claude Code

https://claude.ai/code/session_01RiYypnPbDwFtp9zAp7tz6W

Alex Waolson and others added 11 commits July 29, 2026 18:24
…recall

Every prior evaluation in this repo used a parser's own output as ground truth,
so that parser scored 100% by construction and anything disagreeing with it
scored as wrong. These labels break that circularity.

15 excerpts, labelled from scratch (no candidate names shown — pre-populating
them would have baked in the union of the parser's and the model's assumptions,
making any bidder they both missed invisible), blind to which machine proposed
what, with per-contract attribution.

Incumbent trca_board parser against Alex's 10 documents / 89 companies:

  recall     57%   precision 88% (all 7 unmatched rows adjudicated non-errors)
  single-contract documents  96%
  multi-contract documents   44%

The failure is structural, not transcriptional: it reads a bid list correctly
whenever there is one, and loses over half the record whenever a document holds
several contracts, because it anchors on "received from the following", takes
that list and stops. D09 holds four contracts and yields 25%.

This retires #203's conclusion that TRCA should keep its regex because cells
could not be attributed to a solicitation — the regex has the same attribution
failure, invisible only because the regex WAS the ground truth.

Labels carry a corrections block: three source-decided fixes applied with
Alex's permission, and an explicit list of what was deliberately left alone,
including the still-open definitional question of whether a pre-qualification
submitter with no tabulated price counts as a bid.

Gabe's set is outstanding, so there is no inter-rater agreement figure yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W53WHx8mm2UHuLFAQWeF62
The three companies excluded in D01 and the three in D10 looked like one
definitional question. They are opposite cases, and the reports say so:

- D01 is TWO-STAGE. Six firms made pre-qualification submissions; tender
  documents "were issued to the following three (3) Proponent(s)". Buildscapes,
  Pine Valley and Shoreline never received tenders and never bid. Excluding them
  is correct and the parser counting them is an error.
- D10 had eight tenders SUBMITTED, three "disqualified because of the incomplete
  submission package". Those three did bid and belong in the record with no
  valid price — the archive's existing Non-Compliant convention (#94).

Rule: a company is a bidder if it SUBMITTED A BID for that contract.
Pre-qualified then eliminated, no; submitted then disqualified, yes.

Also reverts a name change I made in error: D01's results table (the actual bid
record) reads "Limited"; only the pre-qualification list reads "Ltd", so the
labeller's original was verbatim from the right place.

Parser vs corrected labels: recall 59% (54/92), precision 93%. Its 3 confirmed
precision errors are all one class — pre-qualification list read as a bid list —
and its recall deficit is entirely structural: 96% on single-contract documents
against 44% on multi-contract ones.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W53WHx8mm2UHuLFAQWeF62
…was reverted)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W53WHx8mm2UHuLFAQWeF62
…RAGE OVERLAPS

Gabe's 10 documents (69 rows) landed, 5 overlapping with Alex's.

Raw company-set agreement across the overlap is 69%, which is misleading. Per
contract:

  contracts both labellers read     42/44 companies agreed = 95%
  companies in contracts only one
  labeller reached                  13

The 2 residual disagreements are name variants where both forms appear verbatim.

Every substantive disagreement is contract COVERAGE, not reading. On D06 Gabe
found 1 of 3 contracts; on D09, 1 of 4 — both large meeting packages, and
exactly where the incumbent parser also stops at the first bid list.

Three consequences worth recording:

- The labels are trustworthy where they overlap: reading a bid list is not the
  ambiguous part of this task.
- The hard part is finding all the contracts, for humans as much as for
  software. That reframes the failure as a property of the documents rather than
  of regex.
- Ground truth on multi-contract packages must be assumed INCOMPLETE, Alex's
  included. Two independent readers both under-covered. Recall measured against
  these labels is an upper bound on the labels, not on the documents — which
  means the models' 100% means they matched Alex's coverage, not that they
  exceeded it.

Independent corroboration of one earlier adjudication: Alex's D10 omitted three
disqualified tenderers, added by reading the report's wording; Gabe included all
three unprompted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W53WHx8mm2UHuLFAQWeF62
…'s file

The model results were measured but never committed — the earlier command chain
aborted on a failed copy, so the README append and commit silently never ran.
Caught by verifying what was actually in the branch rather than assuming.

  incumbent parser   59% / 93%   (96% single-contract, 44% multi-contract)
  gpt-5.6-luna      100% / 100%  (~$4 for the whole backlog)
  gpt-5.6-terra      99% / 99%
  gpt-5.6-sol       100% / 100%
  claude-opus-5     100% / 100%  (~$290, no better)

Records the method rather than the script, which was not preserved, and states
plainly what the numbers do NOT show: 100% precision means the models proposed
nothing Alex did not have, so they matched his coverage rather than exceeding
it — and two independent readers both under-covered the multi-contract packages.

Also notes that the first run reported Sol at 22% because of a missing backoff
in my own retry loop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W53WHx8mm2UHuLFAQWeF62
Scored on Gabe's 5 solo documents (D11-D15, 25 companies Alex never saw):

  incumbent parser        68% recall / 81% precision
  all four hosted models 100% recall / 52-56% precision

The low precision is the LABELS, not the models. All 23 "false positives" are
verbatim in the source and every one carries a contract number Gabe never
reached. Two traced to source:

- D14 / RFP 10041763: "four (4) proposals were received from the following
  vendor(s)" lists exactly the four the model returned. Gabe labelled one of the
  document's four contracts.
- D15 / RFP 10020367: a three-stage procurement — 8 pre-qualification
  submissions, 7 issued RFP documents, then "Five (5) proposals were received".
  The model returned exactly those five and EXCLUDED the two who pre-qualified
  and received documents but never proposed, applying the
  pre-qualified-vs-submitted rule correctly to an unseen case.

This answers what the earlier round could not: against Alex's set the models
matched his coverage without exceeding it, so nothing could be said about bids
both humans missed. Here they recovered 23 such bids.

Also records an intra-rater inconsistency: on D12 Gabe listed the City's typo
"Airborne Imagine Inc." as a separate company alongside "Airborne Imaging Inc."
and omitted Aeroquest Mapcon entirely, having labelled the same report correctly
as D08.

Consequence: precision against these labels does not measure extractor
correctness. Only recall is sound; disagreements must be adjudicated against the
source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W53WHx8mm2UHuLFAQWeF62
… leaderboard

General leaderboards aggregate capabilities your task does not use, so a
price/performance frontier computed on one recommends the wrong model. The
protocol uses the general index only to SAMPLE candidates, measures the real
task against ground truth, then finds the published benchmark that orders models
the way the task does and recomputes the frontier on that — buying a cheap
reusable predictor with a one-time measurement.

Documents the four steps, why it is economically right (direct measurement is
more accurate than any proxy; the proxy exists only to narrow hundreds of models
to a handful), and the failure modes — including the dangerous one, where a
broken harness produces a false ordering and silently selects the wrong
benchmark.

Worked example from this repo's bid extraction, and it is explicit that step 3
COULD NOT BE COMPLETED here: the Intelligence Index spans 37.8-60.7 and produced
a flat 99-100% line with a single outlier, and one failure is not an ordering.
Completing it would require deliberately testing models expected to fail.

The index's own weighting explains the mismatch: long-context reasoning is 6.25%
of it and instruction-following another 6.25%, and this task is essentially only
those two.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W53WHx8mm2UHuLFAQWeF62
…s best paid

Full ladder against the human labels: every model beats the incumbent parser
(59% recall), and the whole spread from free to $290 sits at 99-100% except
DeepSeek V4 Flash at 74%. Price buys nothing above the free tier.

  nemotron-3-ultra:free   99% / 99%   $0
  gpt-5.6-luna           100% / 100%  ~$4 whole backlog

Nemotron's limits are operational rather than qualitative and are recorded as
such: no structured-output support at all (it emits JSON by instruction, not
constrained decoding), 20 req/min and 1,000/day, and free endpoints rotate out
without notice. Recommended shape is Nemotron by default with Luna as automatic
fallback — a 1-point difference, so the fallback costs pennies and removes a
single point of failure.

Also records what each benchmark got right. The Intelligence Index does not
predict this task (long-context reasoning is 6.25% of it, instruction-following
another 6.25%, and the task is essentially only those two). AA-LCR does order
the one discriminating pair correctly, but only after a frontier bug was fixed:
scoring a model's max-effort variant while pricing it at base rates had ranked
DeepSeek above Nemotron, i.e. the bug was flattering the single model that fails
the task.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W53WHx8mm2UHuLFAQWeF62
A procurement archive collects bids, awards and suppliers. Everything else is
classified and then deliberately ignored — and this records the ignoring
explicitly, per type, so "we don't extract X" is a decision rather than an
omission nobody noticed.

Four types extract: procurement award reports (full schema, contract-level not
document-level, because one report can describe four contracts), meeting
packages (split first, dedup on reference — 142 of 240 standalone TRCA reports
also appear inside a package), minutes (decision date and body only), and
agreements/MOU (conditional, refuse to a review queue rather than guess).

Seven types extract nothing, each with its reason recorded, under one uniform
escape hatch: a "nothing" document that turns out to contain a bid list was
MISCLASSIFIED — re-route and log it. A nothing-verdict is a claim about the
type, not permission to stop looking.

Settled with Alex:
- land/property out of scope (no solicitation, no bid, no supplier)
- update/status reports contribute award REFERENCES for cross-checking only,
  never creating rows; on disagreement surface, never overwrite
- prequalified/invited_to_tender are STORED, in their own table rather than in
  `bid`, since `bid` means "submitted a bid" and every count depends on that
- minutes: decision date only, resolution text not retained

Notes the consequence to accept knowingly: participation names flow into the
supplier dimension, so supplier counts will include firms that never bid.

Largest open item stays flagged: 1,902 documents (31%) do not classify, EP
worst at 819 of 1,200. That is a classifier failure, not a document type, and
those must route through procurement detection rather than be discarded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W53WHx8mm2UHuLFAQWeF62
…he LLMs

Stage one of the two-stage design, measured against 125 human-labelled
documents spanning every source, stratified and shuffled, source attribution
withheld from the labeller.

  majority class            24%          0/22 procurement recall
  rules baseline            36%         12/22
  char 3-5gram LinearSVC    65%   CV    22/22   free, deterministic
  Nemotron 3 Ultra (free)   61-64%      19-22/22, VARIES BY RUN
  GPT-5.6 Luna (high)       67%         21/22   $0.009
  GPT-5.2-Codex (xhigh)     62%         21/22   $1.37

Findings worth keeping:

- A bag-of-words linear model matches the LLMs on the only metric that governs
  the design, with zero leakage into or out of the procurement class, and the
  features it learns are the archive's own documented procurement idiom.
- Reasoning effort makes classification WORSE, monotonically, while costing 50%
  more. AA-LCR ranks those Luna variants in the opposite order — a long-context
  benchmark does not transfer to a 2,500-character header.
- The most expensive model is the worst on every column.
- LLM runs vary: Nemotron scored 22/22 once and 19/21 on repeat, same prompt and
  documents. A single run is not a measurement — which corrects a headline I
  reported earlier from n=1.
- The two methods fail DIFFERENTLY: the SVM collapses to the majority class, the
  LLM to attachment_or_map, scoring 0/7 on empty_or_unreadable where the SVM got
  5/7. Errors are complementary, not shared.
- governance_finance <-> status_update confuses both symmetrically, which reads
  as a definitional boundary rather than weak models. Both extract nothing.
- The taxonomy is right: free-text "Other" came back empty across all 125.
- Classify-then-skip did not hide procurement data; the only non-procurement
  documents flagged as containing awards were 2 minutes, exactly as the schema
  predicts.

Limits recorded: 2-fold CV on 125 documents, two classes never appeared at all
(agenda, meeting_package), and the free tier throttles at CONC=6 in a way the
harness reports as INVALID rather than as a zero score.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W53WHx8mm2UHuLFAQWeF62
… recall

Stage-one classification applied past the 125-document human sample to every
remaining document in background_pdf. Subagents reading FULL document text —
the earlier LLM measurements were all against a 2,500-char header, which is an
API constraint this method does not have.

Two independent haiku passes over all 5,750, a blind third fable vote on the
820 disagreements, and 120 of the human-labelled documents copied under fresh
opaque ids and mixed into the run so the DELIVERABLE — after judging, not a raw
vote — is scored end-to-end against known answers. Honeypots: 94% flag accuracy,
23/24 flag recall, 63% kind accuracy (kind tracks calibration and was never the
target; the flag is what gates extraction).

The finding worth keeping: MAJORITY VOTE IS THE WRONG RULE FOR THE FLAG, and
measurably so. It left 57 documents on the wrong side of it where at least one
reader had caught real award language — 27 with a procurement_award vote, some
naming a supplier and an amount the other two readers missed outright ("Award to
Carollo Engineers Canada Ltd./EXP Services Inc. $2,765,968.25; three suppliers
bid"). That is the archive's usual asymmetry: a false positive costs one wasted
extraction call, a false negative is never extracted and nothing downstream
notices. The flag is now OR-of-votes; kind stays majority-with-null-on-tie,
where a guessed label is worse than an honest gap and no such asymmetry applies.

Deliberately NOT ground truth and kept out of docs/ground-truth/ — labels-machine
.json is self-describing (provenance, per-vote detail, evidence quote per row) so
it can never be mistaken for the human set it was measured against.

One honeypot miss is a real schema question rather than a model error: a
Vendor-of-Record contract EXTENSION, called flag=false unanimously by both
passes. Whether an extension of an already-awarded arrangement should trip the
flag is Alex's call.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TnxZz2vgmB3GXEyY7ERWy
@alexwolson
alexwolson merged commit 196da56 into main Aug 6, 2026
1 check passed
@alexwolson
alexwolson deleted the ground-truth-trca-labels branch August 6, 2026 12:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant