A reproducible benchmark that answers one question:
Does a deterministic decision-grounding layer make a coding agent adhere to a team's prior decisions better than (a) dumping all the decision docs into context, (b) commodity RAG over the same docs, or (c) a general-purpose memory layer — and at what corpus size does any difference appear?
It is a standalone project. It does not depend on, or import, any specific grounding implementation; the layer under test is just one arm behind a uniform adapter.
"Frontier models plus long context just absorb the decisions, so a persistence layer adds no durable value."
That objection is correct often enough that a benchmark which cannot reproduce it is worthless marketing. So the threatening baselines are mandatory, not courtesy arms:
context_dump— paste every artifact into the answering model's context. This is the skeptic's position, implemented faithfully.naive_rag— embeddings + top-k over the same markdown. No typing, no relationship traversal.
A grounding layer earns its keep only by beating these — on the scenario types where it should, at the corpus sizes where it should.
If the typed/grounded arm ≈ naive_rag on superseded + prohibition scenarios
at N ≥ 50, the retrieval thesis is dead. We publish that result if we find it.
The benchmark is designed to be able to embarrass its sponsor; see
CONTRIBUTING.md ("publish losing results").
- Held-constant answering model. Every arm feeds context to the same fixed answering model with the same prompt scaffold, pinned by model + version + temperature + seed. Arms differ only in how they select and assemble the grounding context.
- Symmetric grounding injection. Each arm gets one equal opportunity to
populate the answering model's context:
context_dumpsupplies everything,naive_ragsupplies its top-k, the grounded arm supplies its typed retrieval. - Deterministic scoring first. Adherence is scored by structural inspection
of the agent's proposed change (did it propose the prohibited migration? did it
follow the superseded decision?). An LLM judge is a disclosed, unbuilt fallback
— see
spec/scoring-rubric.md.
This benchmark isolates retrieval/assembly quality: given one symmetric shot at the context window, which assembly strategy yields better decision-adherence? It does not test whether a pull-based MCP grounding layer actually gets consulted in production — whether an agent invokes the tool at the right moment is a separate deployment question, out of scope here. Reading a favourable result as "this layer will fix adherence in production" overstates what was measured.
- Headline metric: decision-adherence rate.
- Headline artifact: an adherence-vs-corpus-size curve over N ∈ {10, 50, 150, 300} with rising conflict density — the story is the crossover point.
- Also reported: stale-decision rate, false-permit / false-prohibit rate, per-arm run-to-run variance, and governing-decision recall — did the arm's grounding actually contain the binding decision? Recall is the mechanistic explanation for why adherence moves (the analog of MemoryBench's Hit@K). There is deliberately no composite score.
SWE-ContextBench (Zhu et al., 2026; arXiv:2602.08316)
is the closest neighbour. It finds that accurately retrieved and summarised prior
context improves coding-agent resolution accuracy and cuts runtime and token cost, while
unfiltered or wrongly-selected context gives limited or negative benefit. That is the
same gradient this benchmark's context_dump → naive_rag → rac arms exist to measure, and
it treats token cost as a first-class metric, as we do. We read its result as external
support for the premise — and as a sharpener for where our distinct contribution must lie.
Our niche is deliberately narrower:
- Durable decisions, not episodic experience. SWE-ContextBench reuses prior task solutions (related GitHub issues/PRs) — "have I solved a similar problem before?". We test adherence to long-lived governing decisions (ADR- and standard-like) — "does a recorded decision forbid what I am about to do?".
- Supersession is the discriminating signal. Our headline case is machine-stated
supersession (PEP 386→440, RFC
Obsoletes): follow the live decision and drop the superseded one. Generic retrieval is necessary but not sufficient there — which is exactly where our falsifier is aimed. If accurate retrieval alone closed the gap, the interesting result is the stale/prohibited case, not generic recall. - Deterministic, structural scoring (ADR-066) — no embeddings, no LLM judge — over a single-variable A/B: one held-constant answering model and scaffold, varying only the grounding assembly. Cheaper and more reproducible than end-to-end resolution accuracy, at the cost of a narrower question (decision adherence, not "does the patch pass tests").
(See also MemoryBench's Hit@K, the analog of our governing-decision recall.)
cd decisiongrounding
make demo # == python -m runner.cli demoThis runs the two real arms (context_dump, naive_rag) on the four worked
scenarios with a deterministic offline answering model, writes an
append-only report under results/, and emits the crossover chart
(results/crossover.svg, or .png with the [chart] extra).
The offline answering model is a deterministic stand-in so the spine runs with zero credentials. Its output is a harness illustration, NOT a benchmark result. Real runs swap in the pinned Claude answering model and a real embedding backend (
pip install -e .[real]) on real/public-derived corpora. Seerac/decisions/ADR-0001-harness-foundation.md.
Quickest from a fresh clone — one launcher creates a venv, installs the real
backends, loads your keys from .env, and runs (it probes the endpoint first if
you point ANTHROPIC_BASE_URL at a proxy):
cp .env.example .env # then add ANTHROPIC_API_KEY (+ VOYAGE_API_KEY)
./scripts/from_source.sh # headline compare (all arms)
CROSSOVER=1 BATCH=1 ./scripts/from_source.sh # + the adherence-vs-N curve, batchedOr set it up by hand:
pip install -e ".[real,schema,chart]"
export ANTHROPIC_API_KEY=... # pinned answering model: claude-opus-4-8
export VOYAGE_API_KEY=... # real embeddings for naive_rag
# stable rac arm additionally needs AsDecided Core's `decided` CLI on PATH
# (or set DECIDED_BIN)
python -m runner.cli compare \
--arms context_dump,naive_rag,rac \
--answering claude \
--embedder voyage:voyage-4-large \
--scenarios scenarios/ --seed 0naive_rag embeds corpus sections with Voyage's document role and the task
with its query role, so the RAG baseline uses asymmetric query/document
embeddings the way Voyage intends — a fair, strong baseline, not a strawman.
The default Voyage model is voyage-4-large (Voyage's current flagship);
override with --embedder voyage:<model>. Each report records the embedder id +
dimension and the installed anthropic/voyageai versions (backend_versions)
so a run says exactly what produced it.
For repeat or large runs, python -m runner.cli batch … runs the same
comparison through the Message Batches API at ~50% of standard token price.
It assembles every arm's grounding locally (AsDecided Core, embeddings) up front, then
submits all answering calls as one batch and polls to completion (asynchronous —
usually under an hour). The trade vs compare is the live, abortable per-cell
feedback; for the first exploratory run prefer compare, for bulk runs prefer
batch. (compare/run/demo remain synchronous and streamed.)
A real run is expensive, so the runner protects your spend two ways. It
preflights the configuration before doing any work — a missing
ANTHROPIC_API_KEY, VOYAGE_API_KEY, backend package, or Core CLI fails fast
with an actionable message instead of part-way through a paid sweep. And it
streams every completed run to a durable results/run-<stamp>-<label>.partial.jsonl
sidecar as it lands; a transient API error on one (arm, scenario) cell is
recorded and skipped rather than discarding the cells already done (those errors
are also collected under errors in the final report, and a run with any error
exits non-zero).
The crossover can also run on the real backends:
# Real-model crossover. --ns keeps the API spend small (arms x scenarios x |ns|).
python -m runner.cli demo \
--answering claude --embedder voyage:voyage-4-large --ns 10,50
# Repeat over several seeds for error bars: each curve point becomes mean +/- a
# 95% CI, and the rac-vs-naive_rag verdict is the paired difference's CI (the
# falsifier statistic). Cost multiplies with the seed count; --augment adds more
# seeds to an existing dataset without re-running the ones it already has.
python -m runner.cli demo --answering claude --embedder voyage:voyage-4-large \
--ns 10,50 --seeds 0-4This is where the thesis is actually tested — but note the boundary: with
--answering claude you get a real-model crossover on the tiny synthetic
scenarios. That is the plumbing for evidence, not the evidence. The real
result requires real/public-derived corpora (see CONTRIBUTING.md); until then
the crossover is plumbing, not evidence.
The answering model uses the official anthropic SDK, which honours the
ANTHROPIC_BASE_URL environment variable. So if your funded access routes
through a LiteLLM proxy (or any gateway) that exposes Anthropic's native
Messages route, no code change is needed — point the SDK at the proxy:
export ANTHROPIC_BASE_URL=https://your-litellm/... # the Anthropic-native route
export ANTHROPIC_API_KEY=sk-litellm-virtual-key # the proxy's virtual key
# then run compare / batch / demo exactly as aboveThe benchmark relies on three Anthropic-native features, so probe the endpoint first (a couple of small calls) to confirm the proxy forwards them faithfully:
python -m scripts.litellm_probe # reuses the exact request the run sendsIt checks, and prints a verdict on:
messages.createwith structured outputs (output_config+ a JSON schema) — the response must parse back into aProposedChange; this is what makes scoring deterministic. It also confirms tokenusageis reported (the cost report needs it).- The Message Batches API (
messages.batches) — thebatch/make real-batch/demo --batchpath.
How to read the result:
- Both pass → it's a transparent Anthropic passthrough. Set the two env vars
and run normally;
run_real.shprints the resolved endpoint so a proxied run is on the record. - Batch fails, structured outputs pass → the proxy doesn't expose the batch
endpoint. Run the crossover synchronously through LiteLLM (drop
--batch), and reserve--batchfor a direct-Anthropic key. - Structured outputs fail → the proxy is an OpenAI-compatible gateway
(
/chat/completions), not an Anthropic passthrough. Switch to the OpenAI-compatible answering backend below — no code change needed.
If the gateway exposes only /chat/completions (the common enterprise LiteLLM
config), use the litellm:<model-alias> answering backend. It sends the SAME
scaffold, user prompt, and ProposedChange JSON schema as the native backend —
only the wire format differs (response_format: json_schema, which LiteLLM
translates per backend), so the held-constant contract (ADR-0001) is preserved.
Probe first, then run:
export LITELLM_BASE_URL=https://your-litellm # the OpenAI-compatible root
export LITELLM_API_KEY=sk-litellm-virtual-key # (fallbacks: OPENAI_BASE_URL / OPENAI_API_KEY)
python -m scripts.litellm_probe --mode openai --model <alias> # exact adapter request
python -m runner.cli compare --answering litellm:<alias> --embedder local-hash \
--arms no_grounding,rac_grounding --ns 10,50 --seeds 0-4
# or: ANSWERING=litellm:<alias> ./scripts/run_real.shWhich mode is yours? Ask whoever runs the gateway, or just probe both — each probe is a couple of tiny calls:
| Probe result | Run with |
|---|---|
| native mode passes | --answering claude + ANTHROPIC_BASE_URL (Batch OK if line 2 passed) |
| only openai mode passes | --answering litellm:<alias> (synchronous only) |
| neither passes | the gateway blocks schema-enforced JSON — scoring needs it; fix the gateway config |
Two limits on the OpenAI surface: no Batch API (--batch / make real-batch refuse; they need a direct-Anthropic key), and stdlib transport
(no extra dependency — pip install -e '.[real]' isn't needed for a
litellm-only run).
Two caveats when proxied, regardless of mode:
- Model identity. With
--answering claudeeach report recordsanswering_model.versionasclaude-opus-4-8(a pinned constant) — make sure the proxy's alias maps to that exact model. Withlitellm:<alias>the report records the spec string itself, which is honest but only as meaningful as the alias: pin the gateway alias to a fixed model (not "latest"), or the recorded identity is misleading and the run isn't reproducible. - Cost numbers.
scoring/cost.pyprices at Anthropic's published list rates. Token counts stay accurate if the proxy forwardsusage, but the £/$ figures are list-price estimates — they won't reflect a proxy's markup or your organisation's contract.
scenarios_real/ holds the first real, public-derived corpus: the
PEP 386 → PEP 440 version-scheme supersession. PEP 386 carries
Status: Superseded / Superseded-By: 440; PEP 440 (Replaces: 386) states
"this PEP MUST be used … and supersedes PEP 386 … Tools SHOULD ignore any
versions which cannot be parsed by the rules in this PEP." An agent that reaches
for PEP 386's retired verlib/NormalizedVersion scheme is following a
superseded decision; the adherent move is to cite PEP 440 instead. See
rac/decisions/ADR-0002-real-corpus-pilot-peps.md.
The PEPs are ingested as RAC-native decision artifacts: each carries the
verbatim PEP under a ## Source Text section, wrapped in a decision envelope
(Status/Context/Decision/Consequences) plus a directional ## Supersedes
edge — every envelope value derived from the PEP's own headers — so the rac arm
can classify them and follow the supersedes edge (see
rac/decisions/ADR-0003-rac-arm-pep-integration.md). The corpus is pinned to one
immutable commit of python/peps and fully reproducible — nothing in it is
hand-written PEP prose:
# Regenerate the corpus from the pin, or verify it reproduces byte-for-byte.
python -m ingest.peps build --out scenarios_real/peps_version_supersession
python -m ingest.peps verify --out scenarios_real/peps_version_supersessionRun the pilot for real (needs [real] + both keys, exactly as above):
python -m runner.cli compare \
--arms context_dump,naive_rag,no_grounding,rac \
--answering claude --embedder voyage:voyage-4-large \
--scenarios scenarios_real --seed 0The rac arm is the grounding layer under test: it follows the typed
supersedes edge and supplies the live PEP 440, where naive_rag can surface
PEP 386's appealing verlib section without the header that marks it superseded.
It needs AsDecided Core's decided CLI on PATH (install asdecided-core, or
set DECIDED_BIN); drop ,rac to run the baselines alone.
This produces the first genuine decision-adherence result (win, tie, or loss)
on a real corpus; like every run it is appended to results/. The build
environment for this pilot had no API keys, so the scenario is offline-validated
(loads, schema-validates, scores, and the rac arm's supersedes-following is
verified against the real AsDecided Core CLI) and the real numbers are produced by whoever
holds the keys.
The headline curve grows the corpus to N ∈ {10,50,150,300}. By default it pads
with synthetic note filler (illustrative). For a real curve, pad instead with
real public PEP decisions drawn from a pinned pool — a far harder, fairer
distractor set, since a typing-blind retriever can no longer dismiss them as
non-decisions (see rac/decisions/ADR-0004-real-distractor-curve.md):
# Build the pinned real PEP pool once (~644 PEP decisions; provenance.json is
# committed, the bulky corpus is rebuilt on demand and gitignored).
python -m ingest.peps pool build # or: make pool
python -m ingest.peps pool verify # re-checks it reproduces from the pin
# The real curve. Offline here is plumbing; the real result needs keys + Voyage.
python -m runner.cli demo \
--scenarios scenarios_real \
--arms context_dump,naive_rag,no_grounding,rac \
--distractors real --ns 10,50,150,300 \
--answering claude --embedder voyage:voyage-4-large # make crossover-real ANSWERING=claude EMBEDDER=voyage:voyage-4-largeThe pool is a pin, not a content dump: provenance.json records the exact PEP
set + per-PEP sha256 + the real supersedes edges among them, and pool verify
re-checks it byte-for-byte. A robust curve needs more than one discriminating
scenario: the pool exposes ~28 real supersedes edges, and additional scenarios
derived from them (each with a hand-authored, blind gold label) are the next
increment — see ADR-0004.
Tests:
pip install -e .[dev] # or: pip install pytest
make test| Component | State |
|---|---|
| Scenario + RunResult JSON Schemas (Draft 2020-12) | ✅ real |
| Provider adapter contract | ✅ real |
context_dump, naive_rag, no_grounding arms |
✅ real, runnable offline |
| Deterministic scorer + metrics + crossover chart | ✅ real |
Runner CLI (run / compare / demo), append-only reports |
✅ real |
| Five worked scenarios (incl. negative control + conflicting-scoped) | ✅ real, synthetic |
| Governing-decision recall diagnostic | ✅ real |
Pinned Claude answering model (--answering claude, Opus 4.8) |
✅ implemented; needs [real] + ANTHROPIC_API_KEY |
Real embeddings (--embedder voyage:… / st:…) |
✅ implemented; needs [real] / [local-embeddings] |
rac arm (typed retrieval, follows supersedes) |
✅ verified against the rac CLI on the real corpus; needs rac on PATH |
| Real/public-derived scenario (PEP 386→440 supersession) | ✅ corpus built, pinned + verifiable; real run needs [real] + keys |
| Real distractor pools for the N-curve | ✅ PEP pool (ingest.peps pool, ~644) + RFC pool (ingest.rfcs pool, ~1125); curve runs well past N=300 on real distractors |
| Real discriminating scenarios — 9 PEP + 9 RFC + 1 W3C (superseded + prohibition) | ✅ deterministic, blind gold labels, offline-validated; several independently authored |
Three real domains (PEP Replaces, RFC Obsoletes, W3C Previous version edges) |
✅ ingest.peps / ingest.rfcs / ingest.w3c (pinned; verify reproduces) |
memory_provider arm |
⏳ typed stub + TODO |
| LLM-judge fallback | ⏳ disclosed, not built |
Pinned model caveat: the answering model is
claude-opus-4-8, which rejectstemperature/top_p/seed(the API 400s on them). There is no temperature/seed knob to pin; the held-constant guarantee rests on the fixed model id + scaffold + structured JSON output, and run-to-run variance is reported as a metric.temperatureis recorded asnull.
decisiongrounding/
rac/ RAC knowledge corpus — decisions/ (ADRs), roadmaps/, designs/;
the repo dogfoods the artifact format the benchmark studies
(gated by rac validate / relationships --validate / review)
spec/ FROZEN scenario taxonomy + scoring rubric (pre-registration)
schema/ JSON Schema (Draft 2020-12) for Scenario and RunResult
providers/ uniform adapter (prepare/respond) + the arms + answering/embedding
scenarios/ loader + worked scenarios with tiny synthetic corpora
scenarios_real/ real/public-derived corpora (PEP supersession pilot)
ingest/ deterministic ingest of public artifacts (PEPs) into corpora
scoring/ deterministic scorer, metrics, crossover dataset + chart
runner/ CLI; pins model + seed; append-only report writer
results/ append-only run outputs (generated; not committed)
tests/ schema, scorer, ingest, real-corpus, and offline arm-smoke coverage
- Implement a
Providerinproviders/withprepare(corpus)andrespond(task) -> ProposedChange(subclassproviders.base.Provider; the sharedrespondalready feeds the held-constant answering model — override it only if your grounding is task-dependent, asnaive_ragdoes). - Register it in
providers/__init__.pyARMSand, if it should run in the default demo, add it toREAL_ARMS. - Add it to the
armenum inschema/run_result.schema.json. - Run
make testandmake compare ARMS=context_dump,naive_rag,your_arm.
Your arm gets exactly one symmetric grounding opportunity and the same answering model as every other arm. That is the whole point.