Skip to content

Commit 4d2a4c3

Browse files
stroland02claude
andcommitted
fix: stage the symbol map CI scores against, which nothing ever did
The binding floors are the one quality number this repository gates on, and the gate has never run on a runner. `score_corpus.py` verifies a staged symbol map before it scores anything, that map lives in gitignored `.cache/specs/`, and no step in the workflow produced it -- so every run that got as far as scoring was refused with `no symbol map at .cache/specs/symbols.json`. The corpus fetch was failing earlier in the job for longer, which is the only reason this went unnoticed. `symbol_map.yaml` did record how to rebuild the map, in prose. Prose is not a step a runner can execute, and that gap is the whole defect. `stage_symbol_map.py` is the same instruction as a command: build from a pinned specification, compare against the pinned digest, and refuse before writing rather than after. A refusal leaves whatever was already staged untouched -- `.cache/` is per-worktree space several workers share, and a rebuild that truncated the file on its way to failing would break a run that was scoring correctly. Measured across v2200, v2300, v2330 and v2345: one digest, 272 symbols. The SDK document is deliberately not read, matching how the pin was taken; both forms produce the same digest, and passing the argument anyway would make the output depend on whether a large optional file happened to be staged. The test asserts position within the scoring job rather than the workflow containing the right words somewhere, because a step order that looks right and stages nothing reads as a passing job on any run where the gate is never reached. Proven able to fail by deleting the step and watching it go red. Verified locally end to end: five tree digests ok, then binding precision 1.0000 and recall 1.0000 at n=26, 7 falsifiable negatives, 17 pairs scored. Every floor cleared, which is also the measurement the line-ending fix needed and had not yet had. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 6638d05 commit 4d2a4c3

5 files changed

Lines changed: 275 additions & 0 deletions

File tree

.github/workflows/ci.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,21 @@ jobs:
132132
- name: Fetch the frozen corpus
133133
run: uv run python scripts/fetch_corpus_repositories.py
134134

135+
# The corpus has two frozen inputs and this job used to stage one of them. `repositories.yaml`
136+
# pins the trees; `symbol_map.yaml` pins the map that decides which call sites resolve at
137+
# all, and it lives in gitignored `.cache/specs/` because it is generated and large. With
138+
# nothing staging it, `score_corpus.py` refused every time it was reached -- `no symbol map
139+
# at .cache/specs/symbols.json` -- so the binding gate below has never once run on a runner.
140+
# The step before this one was failing for longer, which is what hid it.
141+
#
142+
# Both scripts verify what they write before writing it: a git blob hash per specification,
143+
# and the pinned digest for the map. Neither can quietly substitute an input.
144+
- name: Stage the pinned Stripe specifications
145+
run: uv run python scripts/fetch_measurement_inputs.py
146+
147+
- name: Stage the pinned symbol map
148+
run: uv run python scripts/stage_symbol_map.py
149+
135150
# A database of its own. `score_corpus.py` truncates the graph once per pair, so pointing
136151
# it at the one the suite used would delete what a later step might read.
137152
- name: Score the frozen corpus

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,9 @@ tools/
3131
# worktree when this line was added.
3232
.coverage.*
3333
coverage.json
34+
35+
# The corpus score, written by `scripts/score_corpus.py --json` and read by
36+
# `scripts/gate_corpus.py`. A measurement of the tree it was taken on, so a committed copy is
37+
# a number that goes stale silently while looking authoritative. The floors are what gets
38+
# committed, in `gate_corpus.py`, beside the argument for each.
39+
corpus-score.json

scripts/stage_symbol_map.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
"""Rebuild the symbol map the frozen corpus is scored against, and stage it where the scorer looks.
2+
3+
`benchmark/corpus/symbol_map.yaml` pins that map by a digest and records in prose how to rebuild
4+
it: `build_symbol_map` over a Stripe specification staged in `.cache/specs/`, with no SDK
5+
document. Prose is not a step a runner can execute, and the cost was exact -- CI fetched the
6+
corpus, reached `scripts/score_corpus.py`, and was refused with `no symbol map at
7+
.cache/specs/symbols.json`. The binding gate has never run on a runner, and the fetch failure
8+
that came before it hid that for as long as it lasted.
9+
10+
The SDK document is deliberately not read
11+
-----------------------------------------
12+
`_prepare_stripe` passes one when the tag publishes it, and the pin was taken without one. Both
13+
produce 272 symbols and the same digest, which the pin's own note records as measured. Passing
14+
the argument anyway would make this script's output depend on whether a large optional file
15+
happened to be staged, which is the class of thing the pin exists to stop.
16+
17+
Any pinned tag will do, and that is a property rather than a convenience
18+
-----------------------------------------------------------------------
19+
Measured across `v2200`, `v2300`, `v2330` and `v2345`: one digest, 272 symbols. The map is keyed
20+
by SDK symbol and derived from path segments and `x-stableId`, both of which are stable across
21+
these versions. `--spec` defaults to the head the corpus pairs are cut against; a caller naming
22+
another is checked by the same pin, so a version that ever did move the map fails here rather
23+
than scoring quietly.
24+
"""
25+
26+
from __future__ import annotations
27+
28+
import argparse
29+
import json
30+
import sys
31+
from collections.abc import Mapping
32+
from pathlib import Path
33+
from typing import Any
34+
35+
from sync.signals.stripe.symbols import build_symbol_map
36+
37+
# Run as `uv run python scripts/stage_symbol_map.py`, Python puts `scripts/` on the path and not
38+
# the repository root, so the sibling module below is unimportable by the name the tests use.
39+
# The same line for the same reason as in `scripts/score_corpus.py`.
40+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
41+
42+
from scripts.symbol_map_pin import PIN, SymbolMapMismatch, read_pin, symbol_map_digest
43+
44+
SPEC = Path(".cache/specs/v2330.json")
45+
46+
47+
def stage_symbol_map(spec: Path, into: Path, pin: Mapping[str, Any]) -> str:
48+
"""Build the map from `spec` and write it to `into`, or refuse and write nothing.
49+
50+
The check is before the write, and a refusal leaves whatever was already staged untouched.
51+
`.cache/` is per-worktree space several workers share, so a rebuild that truncated the file
52+
on its way to failing would break a run that was scoring correctly.
53+
"""
54+
mapping = build_symbol_map(json.loads(spec.read_text(encoding="utf-8")), None)
55+
digest = symbol_map_digest(mapping)
56+
if digest != pin["digest"]:
57+
raise SymbolMapMismatch(
58+
f"{spec} builds a map at digest {digest[:12]} with {len(mapping)} symbols; the "
59+
f"corpus records {pin['digest'][:12]} with {pin['symbols']}. Re-pinning is a "
60+
f"deliberate act with a measurement attached -- see {PIN}."
61+
)
62+
63+
into.parent.mkdir(parents=True, exist_ok=True)
64+
into.write_text(json.dumps(mapping), encoding="utf-8")
65+
return digest
66+
67+
68+
def main() -> int:
69+
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
70+
parser.add_argument("--spec", default=str(SPEC), help="the specification to build from")
71+
parser.add_argument("--pin", default=str(PIN), help="the pin the built map must match")
72+
args = parser.parse_args()
73+
74+
pin = read_pin(Path(args.pin))
75+
try:
76+
digest = stage_symbol_map(Path(args.spec), Path(pin["staged_at"]), pin)
77+
except (OSError, SymbolMapMismatch) as exc:
78+
print(f"refused: {exc}")
79+
return 2
80+
81+
print(f"{pin['staged_at']} {digest} {pin['symbols']} symbols")
82+
return 0
83+
84+
85+
if __name__ == "__main__":
86+
raise SystemExit(main())
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
"""A gate that cannot reach its inputs is not a gate, and this one could not for its whole life.
2+
3+
`scripts/score_corpus.py` reads two frozen inputs. The trees come from
4+
`scripts/fetch_corpus_repositories.py`, which CI has always run. The symbol map comes from
5+
`.cache/specs/`, which is gitignored and which no step staged -- so every time the workflow got
6+
as far as scoring it was refused with `no symbol map at .cache/specs/symbols.json`, and the
7+
binding floors below it have never been measured on a runner. The fetch step failing for longer
8+
is what kept that invisible.
9+
10+
The failure mode this pins is not "a step was deleted". It is a step order that looks right and
11+
stages nothing, which reads as a passing job on any run where the gate is never reached. So the
12+
assertions are on position within the job that scores, not on the workflow containing the words
13+
somewhere.
14+
"""
15+
16+
from __future__ import annotations
17+
18+
from pathlib import Path
19+
20+
import yaml
21+
22+
WORKFLOW = Path(__file__).resolve().parents[1] / ".github" / "workflows" / "ci.yml"
23+
24+
SCORER = "scripts/score_corpus.py"
25+
26+
# Everything the scorer reads that a checkout does not carry, and the script that produces it.
27+
# `fetch_corpus_repositories.py` is here too: it has never been the missing one, and a test that
28+
# only names the input that broke would let the next deletion through.
29+
STAGERS = (
30+
"scripts/fetch_corpus_repositories.py",
31+
"scripts/fetch_measurement_inputs.py",
32+
"scripts/stage_symbol_map.py",
33+
)
34+
35+
36+
def _scoring_jobs() -> list[tuple[str, list[dict]]]:
37+
workflow = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8"))
38+
return [
39+
(name, job.get("steps", []))
40+
for name, job in workflow["jobs"].items()
41+
if any(SCORER in (step.get("run") or "") for step in job.get("steps", []))
42+
]
43+
44+
45+
def _index_of(steps: list[dict], script: str) -> int | None:
46+
for position, step in enumerate(steps):
47+
if script in (step.get("run") or ""):
48+
return position
49+
return None
50+
51+
52+
def test_ci_scores_the_frozen_corpus_at_all():
53+
assert _scoring_jobs(), (
54+
f"no job in ci.yml runs {SCORER}. The binding floors are the only quality number this "
55+
"repository gates on, and with this step gone nothing measures them"
56+
)
57+
58+
59+
def test_every_input_the_scorer_reads_is_staged_before_it_runs():
60+
for job_name, steps in _scoring_jobs():
61+
scores_at = _index_of(steps, SCORER)
62+
for script in STAGERS:
63+
stages_at = _index_of(steps, script)
64+
assert stages_at is not None, (
65+
f"job {job_name!r} scores the corpus and never runs {script}. The scorer reads "
66+
f"what it produces out of gitignored space, so on a runner that input is absent "
67+
f"and the gate refuses instead of measuring"
68+
)
69+
assert stages_at < scores_at, (
70+
f"job {job_name!r} runs {script} after {SCORER}, so the scorer reads whatever "
71+
f"was there before it -- on a fresh checkout, nothing"
72+
)

tests/test_stage_symbol_map.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
"""The symbol map the corpus is scored against has to be reproducible by a command.
2+
3+
`benchmark/corpus/symbol_map.yaml` pins the map and says in prose how to rebuild it. Prose is
4+
not executable, and the consequence was measurable: CI fetched the corpus, reached
5+
`score_corpus.py`, and was refused with `no symbol map at .cache/specs/symbols.json` -- the
6+
gate has never once run on a runner, because no step could stage what it verifies.
7+
8+
The refusal is the part worth testing. A map that does not match the pin must not reach the
9+
path the scorer reads, or the next run scores a resolution nobody recorded.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import json
15+
import sys
16+
from pathlib import Path
17+
18+
import pytest
19+
20+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
21+
22+
from scripts.stage_symbol_map import stage_symbol_map
23+
from scripts.symbol_map_pin import SymbolMapMismatch, symbol_map_digest, verify_staged_map
24+
from sync.signals.stripe.symbols import build_symbol_map
25+
26+
# One path with one operation, which is all the builder needs to produce a map. The specification
27+
# the corpus really uses is 7.8 MB and gitignored; what is under test here is the pin check
28+
# around the builder, not the builder.
29+
SPEC = {
30+
"paths": {
31+
"/v1/payment_intents": {
32+
"post": {"operationId": "PostPaymentIntents"},
33+
},
34+
},
35+
}
36+
37+
38+
@pytest.fixture()
39+
def spec(tmp_path: Path) -> Path:
40+
path = tmp_path / "v2330.json"
41+
path.write_text(json.dumps(SPEC), encoding="utf-8")
42+
return path
43+
44+
45+
def _pin(mapping: dict) -> dict:
46+
return {
47+
"digest": symbol_map_digest(mapping),
48+
"symbols": len(mapping),
49+
"staged_at": ".cache/specs/symbols.json",
50+
"built_from": "this test",
51+
}
52+
53+
54+
def test_a_matching_map_is_staged_where_the_scorer_reads_it(spec, tmp_path):
55+
"""The whole point: after this runs, `verify_staged_map` accepts what is on disk."""
56+
pin = _pin(build_symbol_map(json.loads(spec.read_text(encoding="utf-8")), None))
57+
into = tmp_path / "specs" / "symbols.json"
58+
59+
digest = stage_symbol_map(spec, into, pin)
60+
61+
assert digest == pin["digest"]
62+
assert verify_staged_map(into, pin) == pin["digest"]
63+
64+
65+
def test_a_map_that_does_not_match_the_pin_never_reaches_the_disk(spec, tmp_path):
66+
"""Refused before the write, not after it.
67+
68+
Writing first and checking second leaves the wrong map staged for whatever runs next, and
69+
the scorer's own refusal is the only thing standing between that and a scored number over a
70+
resolution nobody recorded.
71+
"""
72+
pin = _pin(build_symbol_map(json.loads(spec.read_text(encoding="utf-8")), None))
73+
pin["digest"] = "0" * 64
74+
into = tmp_path / "specs" / "symbols.json"
75+
76+
with pytest.raises(SymbolMapMismatch):
77+
stage_symbol_map(spec, into, pin)
78+
79+
assert not into.exists()
80+
81+
82+
def test_a_stale_map_already_on_disk_is_left_alone_when_the_new_one_is_refused(spec, tmp_path):
83+
"""A refusal must not be a deletion either.
84+
85+
The staged path is shared -- `.cache/` is per-worktree and several workers run here at once
86+
-- so a refused rebuild that truncated the file would break a run that was scoring fine.
87+
"""
88+
pin = _pin(build_symbol_map(json.loads(spec.read_text(encoding="utf-8")), None))
89+
into = tmp_path / "specs" / "symbols.json"
90+
into.parent.mkdir(parents=True)
91+
into.write_text('{"already": {"here": "1"}}', encoding="utf-8")
92+
93+
with pytest.raises(SymbolMapMismatch):
94+
stage_symbol_map(spec, into, {**pin, "digest": "0" * 64})
95+
96+
assert json.loads(into.read_text(encoding="utf-8")) == {"already": {"here": "1"}}

0 commit comments

Comments
 (0)