Skip to content

Commit 37892f8

Browse files
dzhelezovclaude
andauthored
fix(harness): ab-diff — extend the issue-#36 tolerance to transactions (wholly-absent-block predicate) (#49)
* fix(harness): ab-diff — extend the issue-#36 tolerance to transactions (wholly-absent-block predicate) The A/B soak differ already tolerates the known issue #36 onlyB row-loss class (rows present in leg B, absent in leg A) for the logs and blocks tables, but any onlyB transaction still classified as UNEXPECTED → FAIL. The hourly finalized-overlap cross-validation proved the tx facet is real and is the SAME leg-A loss one table deeper: a batch of B-only transactions all sat in blocks WHOLLY ABSENT from leg A (at those heights leg A has zero blocks / zero logs / zero txs; leg B has the block plus its logs plus its txs), the upper span exactly matching the already-tolerated logs/blocks span. Extend the tolerance to onlyB transactions with a STRICT predicate: an onlyB tx is tolerated-as-#36 IFF its block is wholly absent from leg A (no leg-A block row at that (chain, block_number)) AND the block is at/above the per-chain realtime-era floor within the open window. The wholly-absent-block set is the blocks-table onlyB set the blocks classifier already computes. Anything else — most importantly an onlyB tx whose block EXISTS in leg A (a tx-level-only loss) — stays UNEXPECTED → FAIL: that is a genuinely new divergence class and must keep failing loudly. - new pure exported classifyOnlyBTx: the per-tx predicate (wholly-absent-block gate + classifyOnlyBRow's floor/window logic verbatim). - new pure exported classifyOnlyBTxDiff: folds the tolerance into a classifyTxDiff result — tolerated onlyB txs leave unexpectedB and are counted in toleratedIssue36; onlyA (realtime-parent-tx-gap), sharedMismatch, #27 and #32 facets untouched. - diffTx now carries each onlyB tx's block number; compareChain builds the wholly-A-absent set from blocksRes.onlyBRows and FAILS CLOSED (empty set → tolerate nothing) when the blocks onlyB stream was capped or its collector cross-check failed, so an untrustworthy evidence source can never widen the tolerance. - toleratedIssue36 gains a transactions sub-object (per-chain counts) in both the aggregate and the human-readable TOLERATED echo line, with the same "REMOVE when issue #36 is resolved" note. Tests: +16 (classifyOnlyBTx / classifyOnlyBTxDiff strictness + tolerated + mix + fail-closed, e2e through compareChain, reporting). Full repo-tests suite 284 → 300, all green. Mutation-verified both ways (loosen the predicate → the strictness tests fail; drop the tolerance → the tolerated-case tests fail). Rider (labels only, zero behavior change): the deploy-helpers "(F5)" test pins the runCli derive-database-url loop's last-DATABASE_URL selection, a SEPARATE loop from filterCarriedEnv (already pinned by its own unit test); its name/comment now say so. Refs #36 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(harness): pin the !collector.ok half of the tx-tolerance fail-closed gate The absentBlocks guard `blocksRes.capped || !blocksCollector.ok` had its `capped` half covered by test #138, but the `!blocksCollector.ok` half was untested — mutating the expression to drop only that conjunct left the whole suite green (a silent coverage gap). Add a sibling E2E test: blocks NOT capped, but the onlyB collector received 1 row while diff.onlyB=2 (a silent wiring drop), so crossCheckOnlyBCollector returns {ok:false}. The otherwise-tolerable onlyB tx in that block must stay in unexpectedB and the run must FAIL. Mutation-verified both ways. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent ef10932 commit 37892f8

3 files changed

Lines changed: 526 additions & 25 deletions

File tree

harness/soak-ab/ab-diff.mjs

Lines changed: 181 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66
// • transactions (shared): full-row identity — a tx in BOTH stores must be byte-identical
77
// • transactions : asserted to be EXACTLY the expected class — B may be MISSING parent txs for
88
// realtime-ingested spans (the verified stream wire gap), and every such tx must be referenced
9-
// by an A-side log. Any B-extra tx, or an A-only tx no log references, is a FAIL.
9+
// by an A-side log. Any B-extra tx, or an A-only tx no log references, is a FAIL — EXCEPT a B-extra
10+
// tx whose block is WHOLLY ABSENT from leg A (issue #36 tx facet): the same leg-A block loss the
11+
// logs/blocks class tolerates, one table deeper, reported + PASS-compatible (classifyOnlyBTx).
1012
// • per-1000-block ordered md5 checkpoint hashes both sides (persisted for drift tracking)
1113
// • _ponder_checkpoint monotonicity across runs
1214
// • persist-stagnation guard (issue #38): a one-sided freeze of the newest-persisted-row block
@@ -173,6 +175,49 @@ export function classifyOnlyBRow(
173175
return tolerated ? 'tolerated' : 'mismatch';
174176
}
175177

178+
// Classify ONE onlyB TRANSACTION (a tx present in leg B but MISSING in leg A) against the issue #36
179+
// class. This is the transactions facet of the SAME leg-A loss the logs/blocks classifier already
180+
// tolerates: when leg A dropped a whole block it dropped that block's logs AND its txs (12:22Z hourly
181+
// cross-validation: 30 B-only chain-1 txs, all in blocks WHOLLY ABSENT from leg A, upper span exactly
182+
// matching the tolerated logs/blocks span). Returns:
183+
// • 'tolerated' — the exact issue #36 shape: the tx's block is WHOLLY ABSENT from leg A (no leg-A
184+
// block row at that height) AND the block is at/above the per-chain realtime-era floor within the
185+
// (open) window. PASS-compatible, counted SEPARATELY, loudly reported.
186+
// • 'mismatch' — ANYTHING else → a hard onlyB tx → FAIL, exactly as before the class existed.
187+
// STRICTER than the logs/blocks predicate by ONE extra conjunct — the wholly-absent-block gate:
188+
// • `absentBlocks` is the set of block numbers WHOLLY ABSENT from leg A (leg B has the block row, leg
189+
// A has none), i.e. the blocks-table onlyB set the blocks classifier already computes. A tx whose
190+
// block is NOT in that set — a tx-level-only loss where leg A HAS the block but is MISSING just this
191+
// tx — is a genuinely NEW divergence class and MUST keep failing loudly, so it stays 'mismatch'.
192+
// This is the whole point of the strictness: tolerate only the block-loss shape we have evidenced
193+
// and root-caused, never a bare tx gap in an A-present block.
194+
// The remaining conjuncts REUSE classifyOnlyBRow's floor/window logic verbatim (same floor config, same
195+
// #30 missing-floor semantic, same open-window handling) so the two facets can never drift apart: a
196+
// wholly-A-absent block BELOW the floor is still a HARD FAIL (below the floor leg A's store came from
197+
// the complete-by-construction historical backfill, so any A-missing row there is a real gap, never
198+
// this class). `absentBlocks` MUST be complete: a caller that could not enumerate every wholly-absent
199+
// block (e.g. the blocks onlyB collector was CAPPED) MUST NOT call this as tolerating — pass an empty
200+
// set / treat every onlyB tx as a hard FAIL, so an incomplete absent set can never widen the tolerance.
201+
// Pure + exported so every adversarial case is unit-tested and mutation-verified in isolation.
202+
export function classifyOnlyBTx(
203+
{ blockNumber },
204+
chain,
205+
absentBlocks,
206+
classes = TOLERATED_ONLYB_CLASSES,
207+
) {
208+
const absent =
209+
absentBlocks instanceof Set ? absentBlocks : new Set(absentBlocks ?? []);
210+
// The wholly-absent-block gate — the extra conjunct over classifyOnlyBRow. A tx whose block leg A
211+
// HAS (present in A's blocks table) is a tx-level-only loss → NEW divergence class → HARD FAIL.
212+
if (!absent.has(Number(blockNumber))) {
213+
return 'mismatch';
214+
}
215+
216+
// Reuse the floor/window predicate verbatim (floor config, #30 missing-floor semantic, open window):
217+
// a wholly-absent block still has to clear the realtime-era floor to be this class.
218+
return classifyOnlyBRow({ blockNumber }, chain, classes);
219+
}
220+
176221
// Decide the FINAL verdict of a logs/blocks table diff once its onlyB rows have been classified against
177222
// the issue #36 class. `diff` is the streamingDiff result (onlyA/onlyB/mismatch are COUNTS). `onlyBRows`
178223
// is the array of that diff's onlyB rows with { blockNumber } (fetched by the bounded targeted query in
@@ -308,6 +353,83 @@ export function classifyTxDiff(
308353
};
309354
}
310355

356+
// Fold the issue #36 onlyB-transaction tolerance into a classifyTxDiff result AFTER the wholly-A-absent
357+
// block set is known (it comes from the blocks-table diff, which runs in parallel with diffTx — so this
358+
// composition happens in compareChain, exactly as the logs/blocks onlyB tolerance does, never inside
359+
// diffTx). `txClass` is a classifyTxDiff result; `onlyBTxRows` are its onlyB txs carried as
360+
// { hash, blockNumber } (diffTx now collects the block number too); `absentBlocks` is the set of block
361+
// numbers WHOLLY ABSENT from leg A (the blocks onlyB set). Returns a NEW tx class with the tolerated
362+
// onlyB txs REMOVED from `unexpectedB` and folded into `toleratedIssue36`, and fail/class recomputed:
363+
// • an onlyB tx classifyOnlyBTx → 'tolerated' (its block is wholly A-absent AND at/above the floor,
364+
// within the open window) is PASS-compatible, counted SEPARATELY, no longer a fail.
365+
// • any OTHER onlyB tx (block EXISTS in A → tx-level-only loss, below floor, unknown chain, past
366+
// window) stays in `unexpectedB` → HARD FAIL, exactly as before the class existed.
367+
// The onlyA / unreferencedA (realtime-parent-tx-gap) and sharedMismatch / issue #27 / knownBadRows
368+
// facets are UNTOUCHED — this only reclassifies onlyB txs. Fail-closed on an incomplete absent set: if
369+
// the caller could not enumerate every wholly-absent block (blocks onlyB CAPPED), it passes an empty
370+
// `absentBlocks` and NO onlyB tx is tolerated. If `onlyBTxRows` is missing (a diffTx that didn't carry
371+
// them, or no onlyB txs), the class is returned unchanged. Pure + exported so the fold — the tolerated
372+
// partition, the fail recomputation, and the untouched-facet guarantee — is mutation-verified in isolation.
373+
export function classifyOnlyBTxDiff(
374+
txClass,
375+
onlyBTxRows,
376+
chain,
377+
absentBlocks,
378+
classes = TOLERATED_ONLYB_CLASSES,
379+
) {
380+
const rows = onlyBTxRows ?? [];
381+
if (rows.length === 0) {
382+
return txClass;
383+
}
384+
385+
const absent =
386+
absentBlocks instanceof Set ? absentBlocks : new Set(absentBlocks ?? []);
387+
const perChain = {};
388+
let toleratedCount = 0;
389+
const hardUnexpectedB = [];
390+
for (const row of rows) {
391+
const verdict = classifyOnlyBTx(row, chain, absent, classes);
392+
if (verdict === 'tolerated') {
393+
toleratedCount += 1;
394+
perChain[chain] = (perChain[chain] ?? 0) + 1;
395+
} else {
396+
hardUnexpectedB.push(row.hash);
397+
}
398+
}
399+
400+
// Recompute the verdict from the HARD onlyB txs only — the tolerated ones no longer fail. The onlyA
401+
// (unreferencedA) and sharedMismatch causes are unchanged, read straight off the incoming class.
402+
const fail =
403+
hardUnexpectedB.length > 0 ||
404+
txClass.unreferencedA.length > 0 ||
405+
(txClass.sharedMismatch ?? 0) > 0;
406+
407+
return {
408+
...txClass,
409+
fail,
410+
class: fail ? 'UNEXPECTED' : 'realtime-parent-tx-gap',
411+
unexpectedB: hardUnexpectedB,
412+
// Reported, never fails: onlyB txs leg A lost with its wholly-absent blocks, tolerated per issue #36
413+
// (block wholly absent from leg A AND at/above the per-chain realtime-era floor). Mirrors the
414+
// logs/blocks toleratedIssue36 shape so aggregateToleratedIssue36 rolls all three tables the same way.
415+
toleratedIssue36: { count: toleratedCount, perChain },
416+
};
417+
}
418+
419+
// Drop diffTx's internal `onlyBTxRows` carrier (the raw onlyB txs with block numbers, used only to fold
420+
// the issue #36 tx tolerance in compareChain) from a tx class before it goes into the status JSON, so the
421+
// persisted diffClasses stay bounded — only the tolerated COUNT/perChain surfaces, never the raw row list.
422+
// Pure + exported so the strip contract is asserted directly.
423+
export function stripTxOnlyBRows(txClass) {
424+
if (!txClass || !('onlyBTxRows' in txClass)) {
425+
return txClass;
426+
}
427+
428+
const { onlyBTxRows: _drop, ...rest } = txClass;
429+
430+
return rest;
431+
}
432+
311433
// Classify ONE shared tx whose FULL-row md5 diverges between side A and side B. Returns:
312434
// • 'knownBadRow' — the tx hash is pinned in `knownBadRows` (issue #32) AND the chain matches AND
313435
// BOTH sides are concrete (neither access_list NULL) AND only access_list differs (ex-AL md5s
@@ -1479,6 +1601,10 @@ async function diffTx(urlA, urlB, chain, lo, hi) {
14791601
const txSql = buildTxSql(chain, lo, hi);
14801602
const onlyA = [];
14811603
const onlyB = [];
1604+
// onlyB txs carried WITH their block number (issue #36 tx facet): the wholly-A-absent-block tolerance
1605+
// is applied in compareChain once the blocks-onlyB set is known, so diffTx must surface each onlyB
1606+
// tx's block, not just its hash. `onlyB` (bare hashes) stays the input to classifyTxDiff unchanged.
1607+
const onlyBTxRows = [];
14821608
let sharedMismatch = 0;
14831609
const toleratedIssue27 = { count: 0, perChain: {} };
14841610
// perHash carries the EXACT pin identity a chain-only tally loses: which pinned hash fired, so
@@ -1497,6 +1623,7 @@ async function diffTx(urlA, urlB, chain, lo, hi) {
14971623
a = await ia.next();
14981624
} else if (ha === null || hb < ha) {
14991625
onlyB.push(hb);
1626+
onlyBTxRows.push({ hash: hb, blockNumber: b.value[TX_COL.blockNumber] });
15001627
b = await ib.next();
15011628
} else {
15021629
// shared tx (same hash on both sides) — the full-row md5 must be identical, EXCEPT for the one
@@ -1550,14 +1677,19 @@ async function diffTx(urlA, urlB, chain, lo, hi) {
15501677
);
15511678
});
15521679

1553-
return classifyTxDiff(
1680+
const txClass = classifyTxDiff(
15541681
onlyA,
15551682
onlyB,
15561683
referenced,
15571684
sharedMismatch,
15581685
toleratedIssue27,
15591686
knownBadRowsTally,
15601687
);
1688+
1689+
// Carry the onlyB tx rows (with block numbers) so compareChain can fold the issue #36 tx tolerance
1690+
// once it holds the blocks-onlyB (wholly-A-absent) set — the same compose-in-compareChain pattern the
1691+
// logs/blocks onlyB tolerance uses. classifyTxDiff's own verdict here is the PRE-tolerance view.
1692+
return { ...txClass, onlyBTxRows };
15611693
}
15621694

15631695
async function bucketHashes(url, chain, lo, hi, bucket) {
@@ -1769,6 +1901,25 @@ export async function compareChain(
17691901
const blocksFail =
17701902
blocksClass.fail || blocksRes.capped || !blocksCollector.ok;
17711903

1904+
// Fold the issue #36 onlyB-TRANSACTION tolerance (12:22Z hourly cross-validation: 30 B-only chain-1
1905+
// txs, ALL in blocks WHOLLY ABSENT from leg A — the same leg-A loss the logs/blocks class already
1906+
// tolerates, one table deeper). An onlyB tx is tolerated IFF its block is wholly absent from leg A AND
1907+
// at/above the per-chain floor (see classifyOnlyBTx); any other onlyB tx (block EXISTS in A → a
1908+
// tx-level-only loss, a genuinely NEW divergence class) stays a HARD FAIL.
1909+
//
1910+
// The wholly-A-absent block set is the blocks-onlyB set (blocks present in B, absent in A). FAIL-CLOSED
1911+
// when that evidence is incomplete: if the blocks onlyB stream was CAPPED (blocksRes.capped) OR its
1912+
// collector cross-check failed (!blocksCollector.ok), the absent set is not authoritative, so we pass
1913+
// an EMPTY set and tolerate NO onlyB tx — an untrustworthy evidence source must never widen tolerance.
1914+
// (Same separate-snapshot residual as the logs/blocks tolerance — the finalized overlap is append-only
1915+
// and stable between the blocks and tx queries outside an active repair; a repair in flight surfaces as
1916+
// a transient that clears next run, never a false PASS. See bucketHashesExcluding's SEPARATE-SNAPSHOT note.)
1917+
const absentBlocks =
1918+
blocksRes.capped || !blocksCollector.ok
1919+
? new Set()
1920+
: new Set(blocksRes.onlyBRows.map((r) => Number(r.blockNumber)));
1921+
const txClass = classifyOnlyBTxDiff(tx, tx.onlyBTxRows, chain, absentBlocks);
1922+
17721923
// The tolerated onlyB LOG rows (block-table onlyB rows have no log_index and do not affect the log
17731924
// bucket hashes) — the set to remove from leg B's buckets when attributing a bucket mismatch. Only
17741925
// rows the class actually TOLERATED are removed; an untolerated onlyB row is NOT removed, so a bucket
@@ -1845,7 +1996,9 @@ export async function compareChain(
18451996
? {}
18461997
: { collectorMismatch: blocksCollector.collectorMismatch }),
18471998
},
1848-
transactions: tx,
1999+
// `onlyBTxRows` is diffTx's internal carrier for the tolerance fold above — stripped here so the
2000+
// status JSON stays bounded (only the tolerated COUNT/perChain surfaces, never the raw row list).
2001+
transactions: stripTxOnlyBRows(txClass),
18492002
checkpointBuckets: {
18502003
ok: bucketClass.ok,
18512004
mismatches: buckets.mismatches.length,
@@ -1861,7 +2014,13 @@ export async function compareChain(
18612014
// but NEVER suppresses a windowed failure's own reporting — a stagnation-only FAIL and a windowed FAIL
18622015
// compose to one FAIL, each visible in its own class. classes.persistStagnation is attached in every
18632016
// path above.
1864-
if (logsFail || blocksFail || tx.fail || bucketsFail || stagnation.fail) {
2017+
if (
2018+
logsFail ||
2019+
blocksFail ||
2020+
txClass.fail ||
2021+
bucketsFail ||
2022+
stagnation.fail
2023+
) {
18652024
out.verdict = 'FAIL';
18662025
}
18672026

@@ -2086,16 +2245,19 @@ export function formatToleratedIssue27Line(tolerated) {
20862245
);
20872246
}
20882247

2089-
// Sum the per-chain, per-TABLE issue #36 onlyB-row-loss tallies (logs + blocks) from every chain result
2090-
// into one { count, logs, blocks, perChain }. `logs`/`blocks` are per-table {count, perChain} rolls;
2091-
// `perChain` is the combined per-chain total; `count` is the grand total. Pure + exported so the status
2092-
// JSON's top-level counter and the human line both read this, and a miscount is caught directly. Note
2093-
// the block-table onlyB rows and the log-table onlyB rows are DISTINCT rows (a block gap is not a log
2094-
// gap), so summing them is a true total, not double-counting.
2248+
// Sum the per-chain, per-TABLE issue #36 onlyB-row-loss tallies (logs + blocks + transactions) from every
2249+
// chain result into one { count, logs, blocks, transactions, perChain }. `logs`/`blocks`/`transactions`
2250+
// are per-table {count, perChain} rolls; `perChain` is the combined per-chain total; `count` is the grand
2251+
// total. Pure + exported so the status JSON's top-level counter and the human line both read this, and a
2252+
// miscount is caught directly. Note the block-table, log-table and tx-table onlyB rows are DISTINCT rows
2253+
// (a block gap is not a log gap is not a tx gap — even for the SAME wholly-absent block, its block row,
2254+
// its log rows and its tx rows are three different rows), so summing them is a true total, not
2255+
// double-counting.
20952256
export function aggregateToleratedIssue36(results) {
20962257
const perChain = {};
20972258
const logs = { count: 0, perChain: {} };
20982259
const blocks = { count: 0, perChain: {} };
2260+
const transactions = { count: 0, perChain: {} };
20992261
const rollTable = (dst, tol) => {
21002262
if (!tol) {
21012263
return;
@@ -2110,9 +2272,16 @@ export function aggregateToleratedIssue36(results) {
21102272
for (const r of results) {
21112273
rollTable(logs, r?.classes?.logs?.toleratedIssue36);
21122274
rollTable(blocks, r?.classes?.blocks?.toleratedIssue36);
2275+
rollTable(transactions, r?.classes?.transactions?.toleratedIssue36);
21132276
}
21142277

2115-
return { count: logs.count + blocks.count, logs, blocks, perChain };
2278+
return {
2279+
count: logs.count + blocks.count + transactions.count,
2280+
logs,
2281+
blocks,
2282+
transactions,
2283+
perChain,
2284+
};
21162285
}
21172286

21182287
// One human-readable line for a run that carried tolerated issue #36 onlyB rows (empty string ⇒ print
@@ -2130,7 +2299,7 @@ export function formatToleratedIssue36Line(tolerated) {
21302299

21312300
return (
21322301
`TOLERATED (known issue #36 — REMOVE when issue #36 is resolved (A repaired or leg retired)): ` +
2133-
`${tolerated.count} onlyB rows leg A lost (logs:${tolerated.logs?.count ?? 0} blocks:${tolerated.blocks?.count ?? 0}; per-chain ${breakdown})`
2302+
`${tolerated.count} onlyB rows leg A lost (logs:${tolerated.logs?.count ?? 0} blocks:${tolerated.blocks?.count ?? 0} transactions:${tolerated.transactions?.count ?? 0}; per-chain ${breakdown})`
21342303
);
21352304
}
21362305

0 commit comments

Comments
 (0)