Near-miss DB: re-score every row against a pinned evaluator; rank seeds by divergences + size gap - #2040
Conversation
…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.
✅ PR validation — Passednoverify: no source/build-data changes in this PR Each changed |
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, And the pass earned its keep concretely: Blocking —
|
…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
Gatekeeper note: three PRs contend on the
|
| 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:
- 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. - 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
Resolved the
|
| 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.
`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.
Why
The vsdec TTC lane found, and its independent review confirmed and widened (2026-08-30), that
nearmiss/db.jsonlscores had drifted out from under the ranking:The single best result of the pass:
ov006 func_ov006_020fcb4cwas recorded at divergences 2 on main and re-scores to 0 -- the closest seed in the whole DB, and thelist --max-divbug below (or 1e9truthiness 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 has6c460000 68460000where the candidate has68460000 6c460000-- and_disasmnormalizes[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/mainrather 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 instale_divergences) so it ranks last inexport-close,crunch,refine_wlandlistinstead 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_fulllooked the row's storednameup 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 sizedSTT_FUNC. Two independent causes, neither a real absence: the row'snamegets resynced to the C++ symbol while itsc_sourcestill spells thefunc_<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_symbolasks 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:_ZN5Model27LoadCompressedTextureToVramEPcjS0_...EPcjPc_ZN3MrI13InitResourcesEvfunc_ov071_02121734_ZN12WorkElevator13InitResourcesEvfunc_ov021_02111c6c_ZN13PoleBillboard8BehaviorEvfunc_ov015_02111720div=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_namesdoes not repair these: it rewritesnameto the current symbol at(module, addr), and the mismatch here is betweennameandc_source, which it does not touch. (Dry-run: it would rename 19 rows, and for ov015 it would movenameto a third spelling,_ZN14KnockDownPlank8BehaviorEv, still never matching thefunc_ov015_02111720the source compiles to.)3. Seed ranking (both metrics + combined key). Every scored row now stores
cand_size(assembled byte size;sizeis 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-closealso emitsdivergences/size/cand_sizeper 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.jsonrecords the evaluator of the last full pass (compiler, flags, metric revision, date).tools/test_nearmiss_db.pyfails CI whenevermatch.CANONICAL,match.DEFAULT_FLAGSor the newnearmiss_db.METRIC_REVmoves without a fresh pass -- flags included, because-O4,p->-O2changes every stored divergence just as surely as a compiler bump. Both constants are read out ofmatch.pythrough the AST, requiring exactly one module-level assignment each: are.searchreturns the first match, so a shadowCANONICAL = ...two lines below the original would have left the guard green.nearmiss/**is now intool-tests.yml's path filter alongsidetools/**. A PR editing onlydb.jsonl, or deletingeval_pin.json, matched neither filter and never ran the guard whose entire job is those two files -- andupdate-chaos-data.ymlrewritesdb.jsonlon 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
evaluatorfingerprint stamp, and the dedupe collapse prefers rows stamped by the current pin.5. Adjacent defects fixed on the way, same territory:
crunch.pyaddressed the loaded DB with raw(module, addr)tuples whileload_dbkeys 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 storeaddras a hex string and missed; the 38 storing an int worked.log_attempt.pyupserted 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. Anexcept ImportErrorguard covers the Python deps alone: with capstone and pyelftools importable andtools/mwccarm/2004/b56/mwccarm.exeabsent,compile_creturnsNone, the status isnoncompile, and every tip is refused. It now carries the sameexe.is_file()checkreevalalready uses, so on a machine without the compiler the lane's own number lands unstamped, as intended.swarm.oracle_checkwrote its temp file with the platform default encoding. Two stored sources carry a character cp1252 cannot encode --ov015 func_ov015_021114f0(U+2194) andov073 ChiefChilly::Behavior(U+2192) -- so on Windows they raisedUnicodeEncodeErrorout 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 fromnullto a number because of the encoding fix. What it removes is an environment-dependent verdict.list --max-divhid bankable div=0 rows (or 1e9truthiness on 0). See the ov006 recovery above.Verification
The pass, measured row-by-row against
origin/main(164 rows in, 164 rows out):divergences: nulldivergences: nullon mainc_sourcecompiles to zero functions; genuinely deadstale_divergences:itcm _ZN7dBgW_Kc10DetectClsnER12dBgCh_SphCrr13 -> null (undefined identifier 'pos') andov060 func_ov060_021140c011 -> 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_sourcebyte-identical on all 164;python tools/nearmiss_db.py dedupe --checkclean (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.jsonlchanged relative to the previous head -- the four recovered rows, nothing else.eval_pin.jsonunchanged.Tests. The enumerated
tool-testslist, run exactly as the workflow runs it:Ran 321 tests in 53.141s,OK (skipped=3).test_nearmiss_dbalone:Ran 21 tests,OK. The workflow header's312/306/test_nearmiss_db (12)are updated to321/315/(21), measured by running the list, not by arithmetic.check_dead_references.pyandcheck_python_names.pyexit 0; the workflow YAML still parses.The pin guard, mutation-tested rather than read: unmodified -> OK; shadow re-assignment of
CANONICAL-> FAILS;CANONICALbumped -> FAILS;DEFAULT_FLAGS-O4,p->-O2-> FAILS;eval_pin.jsondeleted -> FAILS; restored -> OK.No local paths in any of the 22 stored
errorstrings (longest is 165 chars). Note that "db.jsonlhas 0 non-ASCII bytes" -- claimed in an earlier revision of this body -- is not evidence of anything:json.dumpsdefaults toensure_ascii=True, so main's copy has 0 too. The four rows whosec_sourcecontains 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,crunchandlog_attemptkeep 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.jsonlismerge=union-- andmerge=unionis a local driver only..gitattributesalready 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 runninggit merge origin/mainin a checkout, andupdate-chaos-data.yml's rewrite ofdb.jsonlon 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.pyreadsdb.jsonlwith a rawjson.loadsper line instead ofload_db, so after a local union merge it sees both copies and orders them withseed_rank, which has no pin-stamp term. Runpython tools/nearmiss_db.py dedupeafter 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 reevalon the merge result regenerates the data commit in a few minutes.