0079 tied textrank scores canonicalize by phrase not blas - CyrilB1531/lodestar GitHub Wiki

0079 โ€” Tied TextRank scores in the oracle canonicalize by phrase, not by BLAS

Status: accepted ยท Date: 2026-09-04

Context

#541: the Oracles are reproducible job failed on #540, a pull request whose diff removes an unrelated constructor and touches nothing near keywords_textrank.json. The failure:

keywords_textrank.json: cases[0].expected[1].phrase: "diophantine" vs "linear"
keywords_textrank.json: cases[0].expected[2].phrase: "linear" vs "diophantine"
keywords_textrank.json: cases[0].expected[3].phrase: "criteria" vs "natural"
keywords_textrank.json: cases[0].expected[4].phrase: "natural" vs "criteria"

Both are swaps of adjacent entries, not value drift โ€” measured, the gaps between adjacent scores in cases[0]:

numbers      0.526895906655717     gap 5.820e-02
diophantine  0.4686942795397464    gap 2.776e-16   <-- swapped pair
linear       0.46869427953974613   gap 1.906e-01
criteria     0.27808395073496167   gap 1.110e-16   <-- swapped pair
natural      0.27808395073496156

Both swapped pairs sit 1e-16-1e-15 apart โ€” one or two units in the last place, far inside the 1e-9 tools/compare_oracles.py and every oracle suite compares floats at. Decision 0077 ยง3 already forced the generator to select TextRank's dominant eigenvector by eigenvalue rather than by scipy.linalg.eig's column position, because a repeated eigenvalue leaves that column order BLAS-build-dependent. That fixed the values. It did not fix their order: when two published scores are tied this close, summa.keywords.keywords's descending sort is ordering them by the same floating-point noise 0077 already named, and a different BLAS build breaks that near- tie the other way. Applying the canonicalization below surfaced two further ties, in cases[1] and cases[2], that had frozen in non-canonical order without ever tripping a CI failure โ€” the same exposure, on ties no runner had yet happened to break the other way.

TextRankOracleTests.AssertRankingMatches (tests/Lodestar.Text.Tests/Keywords/TextRankOracleTests.cs) already treats this class of tie as free: it partitions the expectation into maximal runs of adjacent entries within 1e-9 of each other and permits any permutation inside a run, checking only that a real rank gap lands the right phrases and scores at the right position. The corpus and that test were not agreeing by construction โ€” the corpus was frozen in whatever order one generating machine's BLAS produced, and the test's permutation allowance was doing the actual work of tolerating it, silently, until two machines' noise disagreed on the same tie in a way the test's own run-relative sort happened not to absorb.

Decision

generate_keywords_textrank canonicalizes tie order before freezing, using the exact model the test already applies on replay: partition the published result into maximal runs of adjacent entries whose scores are within 1e-9, and sort each run by phrase, ordinally. Entries outside a run keep the reference's own order. The two rejected pairs settle into diophantine, linear and criteria, natural (already ordinal order, so cases[0] does not move on a machine that already produced that order); cases[1]'s learning, learn becomes learn, learning, and cases[2]'s trilling, purring and rodents, carnivorous become purring, trilling and carnivorous, rodents.

Rejected: teaching tools/compare_oracles.py to tolerate a reordering. The gate is right โ€” decision 0073 already settled that it compares what the suites compare, positionally, because a corpus that reordered an array has changed in a way no float tolerance explains for every other corpus it walks. Weakening it to permit a permutation would serve this one corpus's convenience at the cost of the exact-order guarantee every other corpus relies on it for, and would still leave the corpus itself machine-dependent โ€” the fix belongs in what freezes the order, not in what checks it.

What enforces it

_canonicalize_tied_runs in tools/generate_oracles.py, called on summa.keywords.keywords's published result before it is written to a case. tests/oracles/keywords_textrank.json was regenerated twice and compared with tools/compare_oracles.py each time: ok 98 corpora agree on both runs, and git status clean after the second, so the generator is a fixed point. Against the pre-fix corpus the only movement is the ties named above; nothing else in any of the 98 corpora moved. What this cannot show from one machine is the BLAS disagreement itself โ€” that evidence is the CI failure quoted in Context, which is what a canonical order removes. TextRankOracleTests.AssertRankingMatches needed no change โ€” corpus and test now agree by construction rather than by the test's permutation allowance quietly absorbing whatever order one machine froze.