Capture the 14 CTA ground-truth values from in-database twins (#2152) - #2393
Conversation
…twins (#2152) Fills `pending_cta_repair` with the 14 real rows, making bs_replacement_char_cta.sql a live repair rather than the verified no-op it shipped as. The values did not come from any of the three channels the script's capture procedure lists. All three read tubafrenzy, and tubafrenzy had already retired when the capture was attempted; channel (b)'s escape hatch never materialised either, since no library.db snapshot ever gained per-track data. The procedure is preserved as the record of what was intended and annotated as unrunnable. What replaced it was already in the table. The script's own "Sequencing vs #1996" note records that post-#454 the ETL derives the correct string, and `importCompilationTracks` inserts via a bare, untargeted `onConflictDoNothing()` — so a correct row does not conflict with a corrupt one (their (library_id, artist_name, track_title) tuples differ) and lands beside it instead of being suppressed. That is the same 98.5% double-ingest shape #1996 measured and the same fact this script's twin detection already depends on; what had not been noticed is that it makes the twin a source of ground truth, not merely a unique-index hazard to route around. Release 8844 carries three generations of one credit: the clean original (110864), its #1996-double-encoded copy (1228848), and this ticket's U+FFFD row (3185320). Method: for each corrupt row the undamaged column is the join key against clean rows in the same release, and the damaged column is then matched with its U+FFFD runs as bounded wildcards, against each candidate in both stored and re-encoded form. 13 of the 14 resolved to a value present byte-exact as a live row in the same release — located, not reconstructed. Two decoding subtleties mattered: the corrupting client passed through the five bytes CP1252 leaves undefined (0x81, 0x8D, 0x8F, 0x90, 0x9D), which Python's codec refuses and which left six values looking irreversibly double-encoded; and some twins are truncated mid-character at varchar(255), so the reversal must decode tolerantly at the tail only. legacy_release_id 12988 is the one row needing the closest look at step 5's human review: its only pattern-matching twin is itself double-encoded and truncated, so reversing it drops the final character. The captured value comes from id 36019, which holds the untruncated string. The spec's placeholder gate named its own succession plan — it "goes red the moment real data lands, forcing whoever adds it to add the codepoint assertion in the SAME change". That assertion is here: byte pins for all 14 true values, which matter because several are visually identical to a wrong neighbour (U+00B7 MIDDLE DOT and U+30FB KATAKANA MIDDLE DOT both appear in release 59194). The no-op test becomes its inverse: the block declares 14 rows, splits 11/3 between track_title and artist_name as the header promises, survives the placeholder scrub, and clears every operator-error guard.
Unrelated to this PR's change, and pre-existing on main — included only because it fails `npm run format:check` and therefore blocks CI on every branch that touches a prettier-checked path. It reached main unformatted because `lint-and-typecheck` SKIPPED on its own PR (#2385): that change was docs-only, and the workflow's paths-filter does not route plan/docs edits to the lint job. The plan this file contains documents that exact paths-filter gap as a finding to fix; this is the gap demonstrating itself.
|
Note for review: this branch carries a third commit, It is pre-existing breakage on It is included here only because it blocks CI. Worth its own follow-up: the plan in that very file lists the paths-filter gap as a finding to fix, and this is the gap demonstrating itself on the plan document that documents it. CI is green with it in: |
Fills
pending_cta_repairwith the 14 real rows, turningscripts/audit/bs_replacement_char_cta.sqlfrom the verified no-op it shipped as into a live repair. Already applied to prod — see the run record on #2152.Where the values came from
Not from any of the three channels the script's capture procedure lists. All three read tubafrenzy, and tubafrenzy had already retired when the capture was attempted; channel (b)'s escape hatch never materialised either, since no
library.dbsnapshot ever gained per-track data. The procedure is preserved as the record of what was intended, annotated as unrunnable.The values were still in the table. The script's own "Sequencing vs #1996" note records that post-#454 the ETL derives the correct string, and
importCompilationTracksinserts via a bare, untargetedonConflictDoNothing()— so a correct row does not conflict with a corrupt one, because their(library_id, artist_name, track_title)tuples differ, and it lands beside it rather than being suppressed. That is the same 98.5% double-ingest shape #1996 measured, and the same fact this script's twin detection already depends on. What had not been noticed is that it makes the twin a source of ground truth, not merely a unique-index hazard to route around.Release 8844 carries three generations of one credit:
Method
For each corrupt row the undamaged column is the join key against clean rows in the same release; the damaged column is then matched with its U+FFFD runs as bounded wildcards, against each candidate in both stored and re-encoded form. 13 of the 14 resolved to a value present byte-exact as a live row in the same release — located, not reconstructed.
Two decoding subtleties mattered:
0x81,0x8D,0x8F,0x90,0x9D). Python's codec refuses them, which left six values looking irreversibly double-encoded until the codec was corrected.varchar(255), so the reversal must decode tolerantly at the tail only — never mid-string.One row needs the closest look at review:
legacy_release_id12988. Its only pattern-matching twin is itself double-encoded and truncated, so reversing it drops the final章. The captured value instead comes from id 36019, which holds the untruncated string. This is the single row whose value comes from a sibling rather than a direct correspondent.Test changes
The spec's placeholder gate named its own succession plan — it asserted the block carried no string literals and said it "goes red the moment real data lands, forcing whoever adds it to add the codepoint assertion in the SAME change." That assertion is here rather than the test being deleted: byte pins for all 14 true values. This matters more than it sounds — release 59194 contains both
·(U+00B7 MIDDLE DOT) and・(U+30FB KATAKANA MIDDLE DOT) in different titles, and an editor normalising one into the other would be invisible in review and would break the downstream byte-exact parity comparison.The no-op test becomes its inverse: the block declares 14 rows, splits 11
track_title/ 3artist_nameas the header promises, survives the placeholder scrub, and clears every operator-error guard.The DB-backed tests were not run locally — the integration harness wants a
test-userrole absent from every running container, and the only bootstrap isnpm run db:stop/db:start, wheredb:stopisdown -vagainst volumes this change did not create. Left to CI. The static assertions were verified directly against the file: 14/14 byte pins, exactly 14VALUESrows, no dollar-quoting.Prod run
Dry-run first, via the script's documented
sed 's/^COMMIT;$/ROLLBACK;/', which executes the real guards and the real DELETE/UPDATE inside a real transaction so a would-becta_unique_idxviolation surfaces exactly as it would for real. It reportedpending_declared 14 | pending_matched 14 | pending_unmatched 0— every capturedcurrent_*matched a live row byte-exactly — thenUPDATE 10 / DELETE 10 / UPDATE 4and residual 0, and discarded everything.The real run produced identical counts and committed. Independent verification after the fact:
compilation_track_artistU+FFFD residual is 0 / 0; ten of the fourteen original ids are gone, four repaired in place.10 rows took the DELETE-twin branch and 4 took UPDATE-no-twin, because the twin join requires both columns to match post-fix — repairing only the damaged column leaves the other in whatever form it had, so a fully-clean row is not a twin. Those 4 now carry a clean value in the repaired column and a still-double-encoded value in the other. That is #1996's scope, not this ticket's, and it is why the header says to land this first so #1996's twin analysis re-derives after these writes.
Closes #2152