Skip to content

Near-miss DB: re-score every row against a pinned evaluator; rank seeds by divergences + size gap - #2040

Merged
andrewboudreau merged 10 commits into
mainfrom
claude/eloquent-heisenberg-98f8b0
Aug 30, 2026
Merged

Near-miss DB: re-score every row against a pinned evaluator; rank seeds by divergences + size gap#2040
andrewboudreau merged 10 commits into
mainfrom
claude/eloquent-heisenberg-98f8b0

Conversation

@tangosdev

@tangosdev tangosdev commented Aug 30, 2026

Copy link
Copy Markdown
Owner

Why

The vsdec TTC lane found, and its independent review confirmed and widened (2026-08-30), that nearmiss/db.jsonl scores had drifted out from under the ranking:

  • a row recorded at divergences=230 re-evaluated to 354 under the current evaluator; 5 of 8 randomly sampled rows drifted;
  • two rows recorded at divergence 13 no longer compiled at all (header churn under the stored sources) -- exactly the rows a closest-first worklist serves first;
  • the edit-distance metric is non-monotonic in candidate size: against an 0x414 target, a ten-instruction-short 0x3ec draft scored 225 while the one-instruction-off 0x410 drafts scored 267 and four-over 0x424 drafts scored 241. The permuter cannot add or remove instructions, so a size-blind keep-best can hold materially worse fuel than the size-closest draft -- contradicting the README's own seed advice.

The single best result of the pass: ov006 func_ov006_020fcb4c was recorded at divergences 2 on main and re-scores to 0 -- the closest seed in the whole DB, and the list --max-div bug below (or 1e9 truthiness on 0) hid it outright, so nobody browsing the worklist could see it. Stated precisely, because it is not a bank: 0 divergences under the metric, and the reloc-aware oracle still says no. The residual is two adjacent literal-pool words swapped -- at +0x21c the ROM has 6c460000 68460000 where the candidate has 68460000 6c460000 -- and _disasm normalizes [pc, #...] to [pc,#pool], so the metric cannot count it. A pool-ordering residual is one shape away from a match, and it was invisible.

What

1. Full re-evaluation pass (data commit). Every row re-scored with the canonical 2004/b56 evaluator, on a tree merged with current origin/main rather than the branch's stale base. Numbers in Verification. The DB keeps 164 rows, one per (module, addr), and nothing is deleted -- a row whose stored source no longer scores is KEPT and marked (status "noncompile"/"func-absent", error, divergences: null, last good score preserved in stale_divergences) so it ranks last in export-close, crunch, refine_wl and list instead of sitting at the top of a closest-first worklist as bait. Any compiling ingest replaces a marked row.

2. A name miss is no longer an absence. evaluate_full looked the row's stored name up in the compiled object by exact string; on a miss it marked the row unscorable and dropped it to the bottom of every worklist. Four rows were demoted that way while the object compiled fine -- each defining exactly one sized STT_FUNC. Two independent causes, neither a real absence: the row's name gets resynced to the C++ symbol while its c_source still spells the func_<addr> placeholder (ov015/ov021/ov071), and the repo spells the substitution-compressed Itanium mangling where mwccarm emits the expanded form for the same signature (_ZN5Model27LoadCompressedTextureToVramEPcjS0_ vs ...EPcjPc) -- which no exact-string lookup can ever bridge. match.sole_func_symbol asks the object what the compiler actually emitted, and only when there is exactly one candidate to name; two or more and the miss stays a miss. The byte verdict is recomputed against the resolved symbol, so a fallback row can still read as a match.

All four are size-exact (cand_size == size) -- exactly the seeds item 3's new ranking term exists to promote, ranked last instead:

module stored name symbol the object defines divergences size
arm9 _ZN5Model27LoadCompressedTextureToVramEPcjS0_ ...EPcjPc 5 184 == 184
ov071 _ZN3MrI13InitResourcesEv func_ov071_02121734 6 664 == 664
ov021 _ZN12WorkElevator13InitResourcesEv func_ov021_02111c6c 13 600 == 600
ov015 _ZN13PoleBillboard8BehaviorEv func_ov015_02111720 78 -> 18 576 == 576

div=5 and div=6 land in the top ~18 of a pool whose head runs 0,1,1,1,1,2,2,2,3,3,3,3. resync_names does not repair these: it rewrites name to the current symbol at (module, addr), and the mismatch here is between name and c_source, which it does not touch. (Dry-run: it would rename 19 rows, and for ov015 it would move name to a third spelling, _ZN14KnockDownPlank8BehaviorEv, still never matching the func_ov015_02111720 the source compiles to.)

3. Seed ranking (both metrics + combined key). Every scored row now stores cand_size (assembled byte size; size is the target's). Upserts, the dedupe collapse, and every retrieval surface rank by the combined key (divergences, |cand_size - size|): edit distance still leads and an upsert never regresses it, but of two equally-close drafts the size-closer one wins. export-close also emits divergences/size/cand_size per seed so downstream tooling can see the gap. Keeping the size-closest draft as a second stored candidate per key was considered and left out to preserve one-record-per-key; flagged as a possible follow-up. What this is worth on today's data is measured below -- it is modest, and mostly prospective.

4. Regression guard. nearmiss/eval_pin.json records the evaluator of the last full pass (compiler, flags, metric revision, date). tools/test_nearmiss_db.py fails CI whenever match.CANONICAL, match.DEFAULT_FLAGS or the new nearmiss_db.METRIC_REV moves without a fresh pass -- flags included, because -O4,p -> -O2 changes every stored divergence just as surely as a compiler bump. Both constants are read out of match.py through the AST, requiring exactly one module-level assignment each: a re.search returns the first match, so a shadow CANONICAL = ... two lines below the original would have left the guard green.

nearmiss/** is now in tool-tests.yml's path filter alongside tools/**. A PR editing only db.jsonl, or deleting eval_pin.json, matched neither filter and never ran the guard whose entire job is those two files -- and update-chaos-data.yml rewrites db.jsonl on main, so that is a live path, not a hypothetical one. A missing check reads exactly like a passing one.

Every ranking consumer also warns loudly at runtime when the live evaluator no longer matches the pin. Each evaluated row carries an evaluator fingerprint stamp, and the dedupe collapse prefers rows stamped by the current pin.

5. Adjacent defects fixed on the way, same territory:

  • crunch.py addressed the loaded DB with raw (module, addr) tuples while load_db keys by the normalized form -- permuter improvements were silently never saved back and banked rows never popped. Measured against main's own data: 126 of 164 rows store addr as a hex string and missed; the 38 storing an int worked.
  • log_attempt.py upserted the lane's claimed divergences on faith; the 230/354 row entered exactly that way. It now re-measures with the live evaluator, stamps the row, and refuses a claimed-close draft that does not evaluate -- but only when the whole evaluator is present. An except ImportError guard covers the Python deps alone: with capstone and pyelftools importable and tools/mwccarm/2004/b56/mwccarm.exe absent, compile_c returns None, the status is noncompile, and every tip is refused. It now carries the same exe.is_file() check reeval already uses, so on a machine without the compiler the lane's own number lands unstamped, as intended.
  • swarm.oracle_check wrote its temp file with the platform default encoding. Two stored sources carry a character cp1252 cannot encode -- ov015 func_ov015_021114f0 (U+2194) and ov073 ChiefChilly::Behavior (U+2192) -- so on Windows they raised UnicodeEncodeError out of the temp-file write instead of scoring. This pass could not have re-scored either row without the fix. To be exact about what that did not buy: neither row was "recovered". Both carried a live score on main (8 and 72) and both carry one now (8 and 71); nothing went from null to a number because of the encoding fix. What it removes is an environment-dependent verdict.
  • list --max-div hid bankable div=0 rows (or 1e9 truthiness on 0). See the ov006 recovery above.
  • Evaluation is now one compile instead of two (the oracle object is reused for the divergence count), halving the cost of ingest/bank-matches/reeval.

Verification

The pass, measured row-by-row against origin/main (164 rows in, 164 rows out):

scored at head 142
unchanged vs main 95
drifted 47 -- 25 up, 22 down
recovered from divergences: null 0 (main has no null rows)
unscorable at head 22 (was 26 before the name fix in item 2)
-- of those, already divergences: null on main 20 -- itcm rows whose c_source compiles to zero functions; genuinely dead
-- of those, lost a live score 2, both real noncompiles, both keeping stale_divergences: itcm _ZN7dBgW_Kc10DetectClsnER12dBgCh_SphCrr 13 -> null (undefined identifier 'pos') and ov060 func_ov060_021140c0 11 -> null (undefined identifier '_ZN5Actor13SpawnFireballE...')

Losslessness, verified rather than assumed: 164 keys in, 164 keys out, zero lost, zero added, key sets identical; no field present on a base row is absent from its head row; c_source byte-identical on all 164; python tools/nearmiss_db.py dedupe --check clean (164 unique keys).

Determinism. The pass's own run against the previous head of this branch reported 164 rows -- 138 unchanged, 0 drifted, 22 unscorable, 4 recovered, 0 bankable -- the third consecutive full pass to reproduce every score exactly, and this one against a different include/ (the merge tree's) than the previous two. Five rows were then re-scored twice each, byte-for-byte identical both times and identical to what is stored: ov006 func_ov006_020fcb4c (0, 576), arm9 func_02068398 (1, 120), arm9 Model::LoadCompressedTextureToVram (5, 184), ov071 MrI::InitResources (6, 664), ov015 PoleBillboard::Behavior (18, 576).

Only four lines of db.jsonl changed relative to the previous head -- the four recovered rows, nothing else. eval_pin.json unchanged.

Tests. The enumerated tool-tests list, run exactly as the workflow runs it: Ran 321 tests in 53.141s, OK (skipped=3). test_nearmiss_db alone: Ran 21 tests, OK. The workflow header's 312 / 306 / test_nearmiss_db (12) are updated to 321 / 315 / (21), measured by running the list, not by arithmetic. check_dead_references.py and check_python_names.py exit 0; the workflow YAML still parses.

The pin guard, mutation-tested rather than read: unmodified -> OK; shadow re-assignment of CANONICAL -> FAILS; CANONICAL bumped -> FAILS; DEFAULT_FLAGS -O4,p -> -O2 -> FAILS; eval_pin.json deleted -> FAILS; restored -> OK.

No local paths in any of the 22 stored error strings (longest is 165 chars). Note that "db.jsonl has 0 non-ASCII bytes" -- claimed in an earlier revision of this body -- is not evidence of anything: json.dumps defaults to ensure_ascii=True, so main's copy has 0 too. The four rows whose c_source contains non-ASCII characters are visible only after decoding.

Floored rows keep their marks; a floored row whose score drifted is called out by the pass as "floor claim may be stale" and is not auto-cleared.

What the ranking change is actually worth

Stated as a measured effect rather than a mechanism, because the effect is modest. Sorting the current DB by (divergences, |cand_size - size|, module, addr) against sorting it by (divergences, module, addr) moves 19 of 142 rows, by at most 3 places; 8 of the top 12 positions are untouched. 87 of the 142 scored rows have a size gap of exactly 0, which is why: on today's data the term mostly breaks ties that were already arbitrary.

And to be explicit about the "Why" section's headline example: the 0x3ec/225 vs 0x410/267 case is not fixed by the key that was built. Divergences still leads, so the 225 draft still wins that comparison. What the key buys is prospective -- it decides which draft merge_batch, crunch and log_attempt keep on every future tie, so a size-exact draft is never discarded in favour of an equally-scoring short one. That is reason enough on its own; the 0x3ec measurement is the motivation for looking at size at all, not a before/after of this key.

Landing note

db.jsonl is merge=union -- and merge=union is a local driver only. .gitattributes already records that GitHub does not honour it, so on the server two PRs touching this file conflict visibly rather than duplicating rows. The pin-stamped dedupe collapse therefore protects the local resolve-and-push path: someone running git merge origin/main in a checkout, and update-chaos-data.yml's rewrite of db.jsonl on main. Simulated: 173 rows (main's pre-correction copies of the three largest upward drifts plus every score-losing row), all 9 duplicates collapsed to the corrected copy.

One consumer sits outside that net, and is now documented at its call site and in nearmiss/README.md: tools/refine_wl.py reads db.jsonl with a raw json.loads per line instead of load_db, so after a local union merge it sees both copies and orders them with seed_rank, which has no pin-stamp term. Run python tools/nearmiss_db.py dedupe after any union merge before trusting its pool.

Cleanest is still to land this before the next refresh; if the file has churned meaningfully, re-running python tools/nearmiss_db.py reeval on the merge result regenerates the data commit in a few minutes.

…ws that no longer evaluate

Stored divergences drift silently when include/ churns under the stored
sources, when the canonical compiler moves, or when the metric changes:
5 of 8 sampled rows re-scored differently on 2026-08-30 (one 230 -> 354)
and two rows recorded at divergence 13 no longer compiled at all --
poisoned bait atop every closest-first worklist.

- evaluate_full: one compile (the oracle object is reused for the
  divergence count), returns cand_size and, for broken sources, a
  trimmed compiler diagnostic. evaluate keeps its (div, ok) shape.
- reeval subcommand: re-score every row, stamp each with the evaluator
  fingerprint, record the evaluator in nearmiss/eval_pin.json. Broken
  rows are kept but marked (status/error, divergences null, last good
  score in stale_divergences) so they rank last everywhere; a
  strictly-improving ingest replaces them and clears the mark.
- warn_stale_pin in every ranking consumer; test_nearmiss_db fails CI
  when match.CANONICAL or METRIC_REV moves without a fresh pass
  (CANONICAL parsed statically -- the CI runner has no compile stack).
- upserts and the dedupe collapse move to the combined key
  (divergences, |cand_size - size|): edit distance is non-monotonic in
  candidate size and the permuter cannot add or remove instructions,
  so a size-blind keep-best holds worse fuel. The collapse also
  prefers rows stamped by the current pin, so a union merge cannot
  resurrect a pre-reeval copy over a correction.
- oracle_check writes its temp file as utf-8: the cp1252 default made
  two candidates that score fine on Linux raise UnicodeEncodeError here.
- list: an isinstance check instead of 'or 1e9', which hid bankable
  div=0 rows from every --max-div listing.
… unnormalized DB keys

crunch.py addressed the loaded DB with raw (module, addr) tuples while
load_db keys by the normalized (module, addr_int) -- rec addr is usually
the stored hex string, so the tuple never hit: banked rows were never
popped and every permuter improvement save was a silent no-op (the exact
unnormalized-key trap nearmiss_db.ingest documents). Improvements also
now go through evaluate_full/apply_eval so the row's cand_size and
evaluator stamp stay truthful, and an improvement is judged on the
combined (divergences, size gap) key.

Both piles sort by seed_rank: closest first, size gap breaking
divergence ties, then (module, addr) for a deterministic order.
Full reeval pass on main tip (deb1e1e): 164 rows -- 110 re-scored
cleanly with cand_size and the evaluator stamp, 45 of them drifted
(examples: 230->354 class drift confirmed by the vsdec TTC review;
131->175; 203->210; 205->217), 2 recovered after the utf-8 temp-file
fix, and 26 marked unscorable instead of deleted: 2 noncompile (header
churn under the stored source, both previously recorded at divergence
13) and 24 func-absent (the stored source no longer defines the row's
symbol, mostly itcm/aeabi rows that were already unscored). A second
full pass reproduced every score exactly (0 drift), and dedupe --check
reports 164 unique keys.

eval_pin.json records the evaluator (compiler, flags, metric revision,
date); tools/test_nearmiss_db.py holds it to match.CANONICAL and
nearmiss_db.METRIC_REV from here on.
…fuse sources that do not evaluate

The tip upsert took the lane's claimed divergences on faith -- the row
recorded at 230 that re-evaluated to 354 entered exactly this way, from
a lane scoring under different headers than main tip. When the compile
stack and target bytes are available the claim is now re-measured with
the live evaluator and the row stamped; a claimed-close draft that does
not evaluate here is refused instead of landing as bait. Bare
environments still land the claimed number, unstamped. Also keys the DB
with ledger.make_key instead of a raw (module, addr) tuple, and judges
keep/replace on the combined closeness key.
@tangos-validator

tangos-validator Bot commented Aug 30, 2026

Copy link
Copy Markdown

✅ PR validation — Passed

noverify: no source/build-data changes in this PR

Each changed src/*.c|*.cpp is compiled and its relocated bytes compared to the binary data on a private build box. Passing requires every changed file to reproduce the ROM byte-for-byte with correct relocation targets — this catches WRONG-DEST relocations and non-reproducing near-misses that ledger-scoped linkcheck skips.

@andrewboudreau

Copy link
Copy Markdown
Collaborator

Gatekeeper review — MERGE AFTER FIXES (one in the code, one in the body)

The data pass is real, reproducible and lossless — verified rather than assumed. Key sets identical (164 in, 164 out, zero lost, zero added), no field present on a base row is absent from its head row, c_source byte-identical on all 164, dedupe --check clean. Every gate is identical between origin/main and the merge tree, with both exports set-verified against the hollow-green trap (12,751 and 12,750 files, ls-tree == ls-files on both).

And the pass earned its keep concretely: ov006 func_ov006_020fcb4c was div=2 on main and re-scored to div=0. A bankable function that the old code hid outright — on main, list --max-div 3 omits the div=0 row entirely because of the or 1e9 bug. That is the best result in this PR and the body doesn't mention it. Lead with it.


Blocking — reeval sends four good seeds to last place

tools/nearmiss_db.py:204-206 calls M.extract_func(obj, name) with the row's stored name and, on a miss, marks the row unscorable. Four rows were demoted this way that the compiled object itself resolves fine. Each defines exactly one sized STT_FUNC:

arm9   stored=_ZN5Model27LoadCompressedTextureToVramEPcjS0_   obj: ..EPcjPc            div=5   size 184==184
ov071  stored=_ZN3MrI13InitResourcesEv                        obj: func_ov071_02121734 div=6   size 664==664
ov021  stored=_ZN12WorkElevator13InitResourcesEv              obj: func_ov021_02111c6c div=13  size 600==600
ov015  stored=_ZN13PoleBillboard8BehaviorEv                   obj: func_ov015_02111720 div=78 -> 18

All four have cand_size == size — they are exactly the size-exact seeds this PR's own new ranking term exists to promote, and they are ranked last instead. div=5 and div=6 put two of them in the top ~15 of a 138-row pool whose head runs 0,1,1,1,1,2,2,2,3,3,3,3. One of them improves 78 -> 18 when scored correctly.

Two mechanical causes, both fixable here:

  • ov015 / ov021 / ov071 — the row's name was resynced to the C++ symbol while c_source still spells the func_<addr> placeholder. resync_names lives in this same file and repairs it.
  • arm9 — the repo spells the substitution-compressed mangling (…EPcjS0_); mwccarm 2004/b56 emits the expanded form (…EPcjPc). Same signature, and an exact-string lookup can never find this row. This is the mangled-name round-trip family: the fix is to ask the compiler what it emitted rather than trust the spelling.

Fix: when extract_func misses and the object defines exactly one sized STT_FUNC, use it. Only then is func-absent an honest verdict.

On severity, so this is not overstated — it is a worklist, not a gate, stale_divergences preserves every prior value, and nothing is destroyed. I am blocking because as merged the pass makes the worklist worse at precisely its top end, which is the only end anyone reads.

Blocking — the body's arithmetic doesn't add up

The body says "110 re-scored cleanly (45 drifted) … 2 recovered … 26 unscorable" = 138, which is 26 short of 164. Measured against origin/main:

138 scored, 92 unchanged, 46 drifted (25 up / 21 down), 26 unscorable, 0 recovered from null.

Three specific corrections:

  • "2 candidates recovered by the utf-8 fix" is not corroborable — exactly one row's c_source contains an arrow character (ov073 _ZN11ChiefChilly8BehaviorEv), and it was scored on base too (72 -> 71).
  • "db.jsonl: 0 non-ASCII bytes" is a tautology: json.dumps defaults to ensure_ascii=True, so main's copy also has 0.
  • Drift is not one-directional. 25 up vs 21 down. _rank's docstring says "their stale — typically lower — divergences"; in the measured data that is a coin flip, and it is the stated rationale for the stamped-first tiebreak.

Also worth stating because it makes the pass look better: of the 26 unscorable, 20 were already divergences: null on main (itcm rows whose c_source compiles to zero functions). Only 6 lost a live score, and all 6 kept their stale_divergences.

Non-blocking — the body oversells the ranking change

Correct on mechanism, thin on effect. Sorting the new DB by the old crunch key vs the new seed_rank: 19 of 138 rows change rank, max shift 3 places, top-11 untouched but for a 4-position local shuffle. And the "Why" section's headline 0x3ec/225 vs 0x410/267 example is not fixed by the key you built — divergences leads, so the 225 draft still wins. The body does say "edit distance still leads", so it is not a lie, but it leads with evidence for a key the PR did not build.

The real value is prospective — which draft merge_batch/crunch/log_attempt keeps on future ties. Say that instead; it is a good enough reason on its own.

The pin guard is real — verified by mutation, with three seams

Mutation-tested rather than read. CANONICAL bumped -> FAILS. METRIC_REV bumped -> FAILS. eval_pin.json deleted -> FAILS. Unmodified -> OK. That is a genuine guard and I am glad it is here.

  1. Shadow re-assignment passes green. test_nearmiss_db.py:253 uses re.search(..., re.M), which takes the first match — adding a second CANONICAL = "2006/sp2" two lines below left the guard at Ran 1 test … OK. Use re.findall and assert exactly one.
  2. Flags are recorded but not gated. eval_pin.json stores flags/cpp_flags; the test compares only (canonical, metric). Changing match.DEFAULT_FLAGS from -O4,p to -O2 — which re-scores every row far harder than a compiler bump — kept it green. Not a false claim (the body doesn't assert CI covers flags, and warn_stale_pin warns at runtime), but add flags to the assertion.
  3. Non-literal spellings fail closed with a good message. That half is right.

A path-filter gap, and a doc line that is now wrong

nearmiss/** is not in tool-tests.yml's path filter. A PR that only edits nearmiss/db.jsonl, or deletes eval_pin.json, never runs the pin guard — and update-chaos-data.yml:148 rewrites db.jsonl on main. Same family as the symbols/** gap on #2039 and the rest of this tree's "gates blind to unenumerated files" incidents: a missing check reads exactly like a passing one. Add it.

tool-tests.yml's header says "runs 312 of those assertions" and annotates test_nearmiss_db (12). Both are now wrong (321 / 21), and the header explicitly calls that count "the number to compare against if this job's total ever moves". (#2038 needs the same line touched — whichever lands second gets the conflict, which is fine.)

The union-merge defence works — one consumer short

Simulated a local union merge (173 rows: main's pre-correction copies of the 3 largest upward drifts plus all 6 score-losing rows) and ran head's load_db. All 9 duplicates collapsed to the corrected row; all 6 bait rows correctly marked. The stamped-first tiebreak does what you claim.

  • refine_wl.py:89 bypasses it — reads db.jsonl with raw json.loads, never calls load_db, so it sees both copies and sorts them with seed_rank, which has no stamp term. A resurrected stale-lower-div row would top its pool. "Every ranking consumer" is one short.
  • The landing note is half right. .gitattributes:36 already documents that GitHub does not honour merge=union and shows a real conflict — so on the server this cannot silently duplicate. The safety net protects the local resolve-and-push path. Say which path it protects.
  • Mirror-image risk, low: _rank puts stamped ahead of divergences, so a genuinely better row written on the log_attempt ImportError path lands unstamped, and a union collapse would discard it for a stamped-but-worse copy.

The five adjacent fixes — four confirmed, one overstated

  1. crunch.py raw-key bug — real, but not universal. Measured against main's own data with main's own load_db: 126 of 164 rows (77%) store addr as a hex string and miss; 38 store an int and did work. Permuter improvements were dropped for 77% of the DB, not all of it. L.make_key is the right fix.
  2. log_attempt claimed-divergences-on-faith — real. ⚠️ New failure mode to close in the same edit: the except ImportError at log_attempt.py:99 only covers missing Python deps. If elftools/capstone import but tools/mwccarm/2004/b56/mwccarm.exe is absent, compile_c returns None -> status="noncompile" -> every tip is REFUSED. Reproduced with a stubbed compiler. reeval already guards this with an exe.is_file() check; log_attempt should too. It is loud (stderr) rather than silent, but a compiler-less lane stops recording tips entirely.
  3. swarm.oracle_check encoding — real. swarm.py:1842 is a bare cfile.write_text(c_source). One-word fix, correct.
  4. list --max-div or 1e9 — real and material. See the div=0 recovery above.
  5. One compile instead of two — sound. match.compile_c has no caching layer and oracle_check compiles fresh in a TemporaryDirectory each call, so there is no object to go stale. Reusing the bytes removes the chance of the two disagreeing. Strictly better.

Determinism — verified by execution

Five rows scored twice each against the merge tree's include/ (i.e. current main, not your base), running mwccarm.exe directly with per-call temp dirs — no rombuild, no shared build/:

ov006 func_ov006_020fcb4c  (0,576)  x2   repeatable  matches stored
arm9  func_02068398        (1,120)  x2   repeatable  matches stored
arm9  func_020717c0        (1,76)   x2   repeatable  matches stored

Stronger than your own claim. save_db now sorts by seed_rank, which appends (str(module), str(addr)) — a total order, where the old divergences-only key left ties file-order dependent. 26 stored error strings, 0 containing a local path, max length 165.

Minor: reeval writes date.today() into eval_pin.json, so a no-op re-run still produces a diff. Harmless, worth knowing.

Taken on faith — stated rather than implied

The full 164-row triple pass (5 rows sampled, twice each), and the ov065 225/267/241 non-monotonicity measurement, which is not in db.jsonl and could not be reproduced. No rombuild.py/eligible.py was run — shared build/ with live agents — so no ROM-level claim here is my own measurement.

Also correcting three of my own first probes, since they were wrong in the PR's favour: test_nearmiss_db.py is not a new file (it is on main with 12 tests and already enumerated at tool-tests.yml:130, and this PR's .github/ diff is empty); the crunch.py bug is 77% not 100%; and 20 of the 26 unscorable rows were already null.


Fix the func-absent fallback and the body arithmetic and this merges. It is in the tools lane, so it does not queue behind the TU chain — I can take it as soon as those two are in.

andrewboudreau and others added 4 commits August 30, 2026 14:39
…ead of calling it absent

evaluate_full looked the row's stored `name` up in the compiled object by exact
string and, on a miss, marked the row unscorable (`func-absent`, divergences
null) -- which sends it to the bottom of every worklist. Four rows were demoted
that way while the object resolved fine, each defining exactly ONE sized
STT_FUNC. Two independent causes, neither of them a real absence:

  * the row's `name` was resynced to the C++ symbol while its `c_source` still
    spells the func_<addr> placeholder (ov015, ov021, ov071);
  * the repo spells the substitution-COMPRESSED Itanium mangling and mwccarm
    emits the expanded form for the same signature -- arm9
    _ZN5Model27LoadCompressedTextureToVramEPcjS0_ vs ...EPcjPc. No exact-string
    lookup can ever bridge that one.

match.sole_func_symbol answers "what did the compiler actually emit" when the
object holds exactly one function; two or more and there is nothing to
disambiguate with, so the miss stays a miss. oracle_check looked the same name
up, so its ok verdict is recomputed against the resolved symbol on that path.

All four were size-exact (cand_size == size) -- precisely the seeds the new
size-gap ranking term exists to promote, ranked last instead:

  arm9   ...LoadCompressedTextureToVramEPcjS0_  div 5   184 == 184
  ov071  MrI::InitResources                     div 6   664 == 664
  ov021  WorkElevator::InitResources            div 13  600 == 600
  ov015  PoleBillboard::Behavior                div 78 -> 18, 576 == 576

div=5 and div=6 land in the top ~18 of a pool whose head runs 0,1,1,1,1,2,2,2.

db.jsonl re-run on a tree merged with current origin/main: 164 rows in, 164
out, key sets identical, no field dropped, c_source byte-identical on all 164;
only those four rows changed. 138 unchanged / 0 drifted / 4 recovered / 22
unscorable (was 26; 20 of the 22 were already divergences:null on main).
dedupe --check clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdCK1xgrJdiJzPCh3bAJfQ
…e, run on nearmiss/**

Three gaps in the pin guard and the workflow that carries it, all of the
"a missing check reads exactly like a passing one" family:

* re.search takes the FIRST match, so adding a second `CANONICAL = "2006/sp2"`
  two lines below the original left the guard at "Ran 1 test ... OK" while the
  live evaluator was something else. Parse match.py with the AST instead and
  require EXACTLY ONE module-level assignment per name.

* eval_pin.json records `flags` and the assertion ignored them. Changing
  match.DEFAULT_FLAGS from -O4,p to -O2 -- which re-scores every stored source
  far harder than a compiler bump does -- kept the guard green. Gated now;
  cpp_flags is derived from DEFAULT_FLAGS (swarm.CPP_FLAGS), so one term covers
  both lanes.

* tool-tests.yml filtered on tools/** only, so a PR that edits just
  nearmiss/db.jsonl -- or DELETES eval_pin.json -- never ran the guard whose
  entire job is those files. update-chaos-data.yml rewrites db.jsonl on main,
  so that is a live path. Added nearmiss/** to both filters.

Mutation-tested rather than read: unmodified OK; shadow re-assignment FAILS;
CANONICAL bumped FAILS; DEFAULT_FLAGS -O4,p -> -O2 FAILS; eval_pin.json deleted
FAILS; restored OK.

Header counts refreshed by RUNNING the enumerated list, not by arithmetic:
Ran 321 tests, OK. test_nearmiss_db is 21, not 12.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdCK1xgrJdiJzPCh3bAJfQ
…o union-merge claims

log_attempt's `except ImportError` covers the PYTHON half of the evaluator only.
With capstone and pyelftools importable but tools/mwccarm/2004/b56/mwccarm.exe
absent, compile_c returns None, evaluate_full reports status="noncompile", and
the upsert REFUSES every near-miss tip -- a compiler-less lane silently stops
recording, which is worse than the faith-based upsert this replaced. Guard the
re-measure with the same exe.is_file() check nearmiss_db.reeval already uses;
without the compiler the lane's own number lands unstamped, as intended.

Two claims corrected in passing:

* "the safety net protects the union merge" -- name the path. merge=union is a
  LOCAL driver; .gitattributes already records that GitHub does not honour it
  and shows a real conflict instead. The stamp-first collapse protects the local
  resolve-and-push path and update-chaos-data.yml's rewrite of db.jsonl on main,
  not the PR merge. The dedupe docstring said the opposite.

* "every ranking consumer" is one consumer short: refine_wl.py reads db.jsonl
  with a raw json.loads per line, never through load_db, so after a local union
  merge it sees BOTH copies and orders them with seed_rank, which has no stamp
  term. Noted at the call site and in the README.

Also: _rank's docstring justified the stamped-first tiebreak with "their stale --
typically lower -- divergences". Drift is not one-directional; measured 25 up /
22 down across the full pass. The stamp is what makes the collapse safe, not an
assumed direction.

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

Copy link
Copy Markdown
Collaborator

Gatekeeper note: three PRs contend on the tool-tests.yml header count

origin/main currently reads runs 312 of (L13) and 306 of the 312 (L69). Three
open PRs rewrite both lines:

PR own module header total
#2035 premerge_check new, 87 312 → 399
#2038 test_symrecords new, 24 312 → 336
#2040 test_nearmiss_db 12 → 21 312 → 321

All land ⇒ the true total is 432, and 306 of the 312 moves accordingly.

Two things worth stating plainly:

  1. This number is prose, not an assertion. The workflow comment calls it "the number
    to compare against if this job's total ever moves" — nothing checks it. A wrong value
    here is a silent documentation lie that survives indefinitely, which is the exact
    failure class this repo keeps getting bitten by.
  2. It will conflict, and that is the good outcome. All three PRs touch the same two
    lines, so whichever lands second and third goes CONFLICTING rather than
    auto-merging a stale integer.

When you resolve it: do not take --ours/--theirs on this file — that silently
reverts the other side's unrelated paths: and enumeration edits. Hand-edit the two
numbers, and measure the new total by running the enumerated list exactly as the
workflow does rather than doing the arithmetic. (#2040 already did it that way; that is
the standard.)

I am serializing the merges and will hold each one to a measured number.

…nberg-98f8b0

# Conflicts:
#	.github/workflows/tool-tests.yml
@andrewboudreau

Copy link
Copy Markdown
Collaborator

Resolved the tool-tests.yml conflict myself and merging. Also: this closes a real hole.

I held this PR behind #2038 deliberately — both edit the same header prose in
tool-tests.yml, and landing them in parallel would have produced a merge where one
side's counts silently overwrote the other's. #2038 is in; this is the resolution.

The substance: the path filter was genuinely wrong, not just narrow

The rationale comment in this PR is correct and worth restating, because it is the same
failure shape this repo keeps rediscovering:

a PR that edits only nearmiss/db.jsonl, or DELETES eval_pin.json, matched neither
filter and never ran it. update-chaos-data.yml rewrites db.jsonl on main, so that
is a live path, not a hypothetical one.

test_nearmiss_db pins nearmiss/eval_pin.json against match.CANONICAL/DEFAULT_FLAGS
and nearmiss_db.METRIC_REV. Its entire job is to fail when the DB and the evaluator
that produced it drift apart — and it was unreachable from exactly the edits that cause
that drift. A missing check reads exactly like a passing one. Both the
pull_request and push filters now carry nearmiss/**.

How I resolved the three hunks

Whole-file --ours/--theirs is the wrong tool here: this file's prose is the only
record of what it covers, and a resolution that drops a legend block still goes green.
I took main's side as the base everywhere and layered on this PR's two real deltas.

The load-bearing hunk was the legend, where the two sides each added a different module's
entry. Both survive:

test_nearmiss_db  (21)   <- from this PR, with the eval-pin description
test_premerge_check (87) <- from main, kept

Full diff against origin/main is four hunks and nothing else moved.

The counts are measured, not added

Main said 423. This PR adds 9 tests. 432 is the obvious answer, and it is also the wrong
way to get it — so I ran the enumerated set instead:

$ python -m unittest $(23 enumerated modules)
Ran 432 tests in 50.228s
OK

It happens to agree with 423+9, which is a check, not the derivation.

main merged
modules enumerated in the run block 23 23 — identical set, none added, none lost
test_nearmiss_db 12 21
total 423 432
asserting (runner, six expected skips) 417 426

test_nearmiss_db runs 21 with zero skips, so it adds 9 asserting tests and leaves
the runner's six-skip accounting untouched. Header updated to 432 / 426.

A correction to my own numbers on #2038

For the record, since it affects how the next person measures this file: I reported "25
modules" there. That came from grepping tools\.test_ across the whole file, which also
catches prose mentions of tools.test_premerge_check. The run block holds 23. The
right invocation is grep -cE '^ +tools\.test_'.

Related: CI runs python -m unittest, which cannot collect pytest-style modules.
test_symrecords is 24 tests under unittest and 21 under pytest; the header's "Ran N"
claim is about unittest, so unittest is the tool to measure with. Every figure in this
comment is a unittest figure. #2038's merged content is unaffected — "adds exactly one
module" was the load-bearing claim there and both methods agree on it.

Stated gaps

No ROM build, eligible or romdata_check: this PR touches one workflow file and one
test module, compiles nothing into the ROM, and makes no byte claim. Shared build/
with a TU-promotion agent in flight would make a concurrent run produce fake failures
anyway. My local run has the ROM junctioned so test_stamp_provenance_verify does not
skip here; the six-skip figure in the header describes the runner and is main's, unchanged.

@andrewboudreau
andrewboudreau merged commit 7d24265 into main Aug 30, 2026
6 checks passed
@andrewboudreau
andrewboudreau deleted the claude/eloquent-heisenberg-98f8b0 branch August 30, 2026 21:42
Alberto12345678999 pushed a commit to Alberto12345678999/sm64ds-decomp that referenced this pull request Aug 31, 2026
`src/**` is not the whole enrolled surface.
config/arm9/overlays/ov002/delinks.txt:1837 enrolls
mods/Player_ScaleByCharFactor.c -- 52 bytes, and it is the only
non-src/ path among the 10,973 ranges enrolled tree-wide.

Measured: deleting that file makes source_coverage.py exit 1 with the
correct -52 delta and correct blame. But neither path filter matched
`mods/`, so a PR touching only mods/ never ran the job at all -- the
loss would have landed with nothing red. Same defect class as the
nearmiss/** filter gap closed in tangosdev#2040: a missing check reads exactly
like a passing one.
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.

2 participants