ADR-012: Reconstruct the notes corpus in CI from a clone, and fail on an absent one — kb-agent reaches SYS-017 tier 1¶
Status: Accepted Date: 2026-08-02 Deciders: San Lee
Context¶
system/SYS-017
was adopted on 2026-08-02 with a four-rung ladder, and it places this repo at tier 0 —
"measured, not gated." scripts/eval_retrieval.py exists, is offline, needs no API key, embeds
locally with all-MiniLM-L6-v2, and appears in no workflow. What CI runs today is ruff, two
cross-repo contract checks, the ADR lint, and pytest. Retrieval quality is measured on the
author's workstation and quoted in prose.
SYS-017's rollout names this repo as the primary move, 0 → 1, and puts almost all of the cost in
one step that is not CI plumbing at all: the eval's corpus is not reconstructible in CI.
Concretely, three facts that compound:
- 12 of the gold set's 27 queries expect sources outside this repo.
note-01–note-10,adv-01, andadv-02declareexpected_sourcesunderlearning-notes/(eval/gold_set.yaml:109-189). Those files live in a separate repo. - The only pointer to them is an absolute Windows path.
projects.yaml'snotes_dirsheldC:\Users\sanle\code\learning-notes, read byscripts/index.py::notes_dirs()fromREPO_ROOT / "projects.yaml"with no override of any kind. - A missing notes directory was skipped with a warning.
collect_documents()printednotes_dir not found, skipping: …and carried on, so an index built anywhere but that one machine came out quietly short of the entirenoteskind.
Wire the eval up naively against that and it scores zero recall on 44% of the gold set — not
because retrieval regressed, but because the documents are absent, while every step in the job
exits 0. That is SYS-017's second house corollary exactly ("a gate that cannot fail is theater"),
and it is why corpus provenance is the ladder's tier-1 entry condition rather than a detail of
the kb-agent rollout.
Decision¶
Three parts, in the order SYS-017 §3 requires — reconstruct, then measure, and only then consider floors.
1. KB_AGENT_NOTES_DIRS overrides notes_dirs, and the override wins outright¶
scripts/index.py::notes_dirs() now reads KB_AGENT_NOTES_DIRS — an os.pathsep-separated list
of directories — in preference to projects.yaml. It does not merge the two: a projects.yaml
entry naming a path that exists only on one workstation is precisely what the override exists to
displace, so merging would reintroduce the machine dependency it removes. The empty string is a
real answer meaning "index no external notes"; only an unset variable falls back to
projects.yaml.
This follows the shape already in the repo for KB_AGENT_MODEL (CLAUDE.md Conventions): an
environment variable that redirects a hard-coded constant without a source edit.
notes_dirs() now returns (dirs, origin). The origin string is not decoration — it is what lets
the error in part 2 name the knob the reader has to turn, rather than making them guess between
two configuration surfaces.
2. A configured-but-absent notes directory is a hard error¶
collect_documents() raises FileNotFoundError instead of warning and continuing. The message
names the missing path, the origin that supplied it, both repairs (check the corpus out there, or
repoint KB_AGENT_NOTES_DIRS), and the opt-out (KB_AGENT_NOTES_DIRS="").
This is the load-bearing half of the change, and it is deliberately not CI-conditional. A skip
is only safe if nothing downstream reports a number over the result, and something now does. Making
the error fire only under CI=true would leave the workstation able to build a short index and
publish a figure measured against it — the failure this ADR exists to close, preserved in the one
environment where the repo's published numbers actually come from. An absent directory is a
misconfiguration everywhere.
The cost is real and accepted: any machine whose checkout does not sit at the projects.yaml path
now fails the index build instead of silently degrading. KB_AGENT_NOTES_DIRS is the fix, and the
error says so.
3. CI clones learning-notes, builds the index, and runs both eval arms — reporting only¶
Four steps appended to .github/workflows/ci.yml, after the existing suite so lint and test
failures still surface first:
git clone --depth 1oflearning-notesinto${{ runner.temp }}/learning-notes. The repo is public, so no token is involved. Precedent isarchitecture/.github/workflows/portal.yml, which already shallow-clones this repo and two siblings.scripts/index.pywithKB_AGENT_NOTES_DIRSpointed at the clone. The existing~/.cache/chroma+~/.cache/huggingfacecache step already covers the ~80MB model download, because the integration test needed it first.scripts/eval_retrieval.py— the unfiltered arm.scripts/eval_retrieval.py --kind-filter.
The clone's directory name is part of the contract. index.py records a note's source as
<notes_dir name>/<filename>, and the gold set spells them learning-notes/…. Cloning to any
other directory name scores all 12 notes queries as misses with nothing else looking wrong. That is
now stated in collect_documents()'s docstring and in the workflow comment.
Both arms run, and that is a detection argument rather than symmetry. The unfiltered arm is the
headline number and the harder setting. The --kind-filter arm is the only thing that exercises
search_kb's metadata where path, whose failure the unfiltered arm cannot see. Two local runs
over an already-built index cost seconds.
4. No floors, no gate — and that is a finding, not an omission¶
Nothing in this change can fail the build on a value. eval_retrieval.py has no threshold logic;
a non-zero exit from these steps means the harness itself broke (unindexed KB, a non-success
observation), never that retrieval got worse.
SYS-017's rollout is explicit that no existing number is floor-eligible: every published figure — including both arms of ADR-010's A/B — was measured on the workstation against the corpus CI could not reconstruct. A number measured in one environment is not a floor in another. The eligible runs are the ones these steps now produce, plural, because a floor needs a run-to-run noise band under it and one pass cannot supply one. Tier 2 is a separate later job.
scripts/eval_kind_usage.py stays out of CI entirely. It spends one model call per gold query per
run, and SYS-017 puts paid legs on owner-triggered lanes or nowhere.
What the reconstructed corpus actually measures¶
Measured on 2026-08-02, first by running the CI recipe by hand — fresh --depth 1 clone into a
temp dir, KB_AGENT_NOTES_DIRS pointed at it, index built, both arms run — and then confirmed by
the CI leg itself:
| Arm | n | recall@1 | recall@3 | recall@5 | MRR |
|---|---|---|---|---|---|
| unfiltered | 27 | 0.741 | 0.852 | 0.926 | 0.813 |
--kind-filter |
27 | 0.963 | 1.000 | 1.000 | 0.981 |
The first real CI run reproduced both arms exactly — every figure above, to three decimals, over an identical 269 chunks from 44 files (run 30772268815, Ubuntu, against Windows locally). That is a stronger provenance signal than either run alone: the reconstruction is deterministic across operating systems, so the corpus really is defined by version control and not by the machine.
It is still not a baseline, and none of these numbers is a floor. Two identical runs of the
same corpus do not establish a noise band — they establish that this corpus is stable, which is a
different claim. The band that tier 2 needs comes from runs spread over real changes to kb/ and
to learning-notes, and those accumulate from here.
The reconstruction produces a complete measurement rather than a hole: both misses are
projects-kind queries losing to notes chunks, and every notes query the absent corpus would
have zeroed now resolves at rank 1 or 2.
It also makes SYS-017's point concretely: the index is 269 chunks over 44 files, against the 325 chunks the README's 2026-07-17 figures were measured on. The workstation corpus was larger than anything version control can rebuild. The old number and the new one are not comparable, which is the whole reason neither is a floor.
Downstream surfaces¶
.github/workflows/ci.yml— the four new steps. This is the tier-1 mechanism; if the clone step is removed or renamed, the eval silently reverts to measuring an absent corpus.scripts/index.py—NOTES_DIRS_ENV,notes_dirs()'s changed return type, and theFileNotFoundErrorincollect_documents().notes_dirs()now returns a tuple; it has one caller today, and a second one added later must unpack rather than iterate.projects.yaml— itsnotes_dirsentry is now a default rather than the only source. Unmodified by this ADR; the absolute path is still correct on the workstation.CLAUDE.md— Conventions gains theKB_AGENT_NOTES_DIRSrule and the hard-error behavior, perdecisions/README.md's split (the ADR is the "why",CLAUDE.mdis the "do this"). Architecture §2 describesindex.pyand mentions the notes sweep.README.md— the Retrieval eval section quotes 2026-07-17 workstation figures measured on a 325-chunk index. Those numbers are not wrong, but they are now un-reproducible by CI and should be read as dated workstation measurements. The section gains a note saying so and pointing at the CI arm.tests/test_index.py— seven new tests covering the override precedence, multi-path parsing, the empty-string opt-out, and both hard-error paths (env-supplied and yaml-supplied). An autouse fixture clearsKB_AGENT_NOTES_DIRSso an ambient value on the author's shell cannot leak into the suite.docs/notes/test-coverage-backfill.md:28— names "the notes walk + skip-missing incollect_documents" as an uncovered gap. The skip-missing branch no longer exists and the replacement is tested; the row is updated rather than left describing deleted code.- ADR-010 — its A/B arms are
explicitly named by SYS-017 as not floor-eligible, for the reason above. The ADR's conclusion
(dense-only) is untouched; only its numbers' eligibility as thresholds is being characterized.
The
--hybridflag stays out of CI: it is an experiment hook, not a shipped path. - ADR-004 — the 8/5/10/4 composition this decision refuses to shrink. SYS-017 forecloses scoping the eval to the 15 in-repo queries; that option is closed here too.
- ADR-011 — CI now runs
index.pyon its default incremental path against a coldchroma_db/, so every CI run is a first-run full embed. No conflict; noted because it means the incremental diff is not what CI exercises. eval/gold_set.yaml— itslearning-notes/<filename>source convention is now depended on by a workflow, which pins the clone's directory name.system/SYS-017— this is itskb-agentrollout row,0 → 1. Its fleet table still records this repo at tier 0 and goes stale with this merge; the table is a dated observation by its own classification and is not enforced.learning-notes— CI now depends on it being public and cloneable. If it is ever made private, this leg breaks and needs a token.
Consequences¶
- CI depends on a second repo. Named as a cost by SYS-017 and accepted. A
learning-notesoutage or a visibility change turns this leg red. It fails loudly rather than silently, which is the trade being bought. - CI measures
learning-notesmain, not a pinned revision. A note edited there can move this repo's retrieval numbers with no change here. At tier 1 that is only a report moving, and it is arguably the honest behavior — the corpus really did change. At tier 2 it becomes a flapping gate, and pinning the clone to a SHA is the obvious fix. Deliberately not done now: pinning costs a bump chore, and until a floor exists there is nothing for the drift to break. - The live
notes-apileg is absent in CI, deterministically.collect_notes_from_api()warns and returns empty when the service is unreachable, which it always is on a runner. No gold-set query expects anotes-api/note/…source, so the corpus is complete without it — but this means the CI index is a strict subset of a fully-warm workstation index, by construction. - A workstation whose checkout moved now fails instead of degrading. Intended, and the error names the repair. This is the change most likely to surprise someone.
- Tier 2 is now unblocked but not started. The remaining work is: collect several CI runs to get a noise band, commit a baseline, write a gate script and a floors file, and make it a required status check. None of that is in this change.
- The gold set's composition is preserved. No query was dropped or filtered to make CI green, which was the tempting shortcut and the one SYS-017 closes in writing.
Alternatives Considered¶
| Option | Reason Not Chosen |
|---|---|
Commit a small fixture corpus under eval/ for CI to index |
Reproducible, but it measures a corpus assembled to be measurable. The gold set's expected_sources name real learning-notes/ files, so a fixture means either rewriting 12 queries or shipping stand-ins under the same filenames — and the number would still be called "retrieval recall". SYS-017 rejects the sibling form of this (copying the corpus in) for drift; a purpose-built fixture is the same fork with less content. |
Vendor a snapshot of learning-notes into this repo |
SYS-017's rejected-alternatives table already closes this: it forks the notes, the copy drifts from the source the moment either moves, and the drift is invisible precisely because the eval keeps passing against the stale copy. It also makes this repo a second home for content it does not own. |
Have CI write a projects.yaml before indexing |
The other option SYS-017 §3 offers. Rejected because it makes CI mutate a tracked file that ingest.py, ingest.py --check, and tools.py all read, so a half-run job leaves a rewritten config behind. An env var is inspectable, testable without touching the filesystem, and matches KB_AGENT_MODEL. |
| Keep the skip-with-warning, and make the eval step assert the corpus separately | Two mechanisms where one will do, and it leaves the silent-short-index behavior reachable by every other caller of index.py. The check belongs where the corpus is read. |
Make the hard error fire only when CI is set |
Preserves the exact failure mode on the machine that produces the repo's published numbers. See §2. |
Scope the CI eval to the 15 queries whose sources are committed under kb/ |
Foreclosed by SYS-017 in writing: it discards the entire notes kind plus both rag adversarial pairs from a composition settled in ADR-004, while still calling the result "retrieval recall". |
Commit a prebuilt chroma_db/ so CI need not index |
Git-ignored by existing convention, binary, and large. It would also make the eval pass without proving the index build still works, which is a meaningful part of what this leg protects. |
| Containerize the eval to make it reproducible | SYS-017 §4 rejects containers as the mechanism: an image packages whatever went into it, so "where did the notes come from" survives inside it, harder to inspect. Deferred there as an optimization with a trigger (cache misses or model-version drift making the gate flap), and nothing here changes that. |
| Set floors from the numbers in this ADR and gate now | The numbers above are one local run of the CI recipe. SYS-017 and classifier/ADR-014 both require a measured noise band under a floor, and a single pass cannot supply one. Gating on it would be an aspirational floor wearing a measured number's clothes. |
Also wire scripts/eval_kind_usage.py into the PR leg |
It spends one model call per gold query per run. SYS-017's third corollary puts paid legs on owner-triggered lanes or nowhere, and recommends nowhere for this one. |