A valid chain can still have a hole in it
Measured against real production history while validating the ancestry surface: `aj ewing / total_bases / over / 0.5` on 2026-08-28 carries a valid ORIGIN from the 18:10 cohort AND a row from the 19:03 cohort that was stamped for publication and never recorded an action. The chain is genuinely valid and the partial row is correctly excluded from it — that part of the contract held. But answering LINEAGE_AVAILABLE and nothing else presents an incomplete chronology as a complete one, which is the exact failure this surface exists to make visible, one grain finer than a whole Read. The response now carries `chronology_complete` and `unrecorded_publications`. The state is unchanged, because the chain that exists really is valid; what changes is that the gap is disclosed instead of smoothed over. Suite 397/5,498/0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CQJeAG8vcDoL5zkiaJyVb8
This commit is contained in:
@@ -136,9 +136,22 @@ function classifyAncestry(rows) {
|
||||
});
|
||||
}
|
||||
const recaptures = chain.filter((r) => r.lineage_action === readLineage.LINEAGE_ACTION.RECAPTURE);
|
||||
// A VALID CHAIN CAN STILL HAVE A HOLE IN IT.
|
||||
//
|
||||
// Measured on production: `aj ewing / total_bases / over / 0.5` on 2026-08-28
|
||||
// carries a valid ORIGIN from the 18:10 cohort AND a row from the 19:03
|
||||
// cohort that was stamped for publication and never recorded an action. The
|
||||
// chain is genuinely valid and the partial row is correctly excluded from it
|
||||
// — but answering LINEAGE_AVAILABLE alone would present an incomplete
|
||||
// chronology as a complete one. The gap is disclosed instead of smoothed
|
||||
// over; it is the same failure this surface exists to make visible, just at
|
||||
// a finer grain than a whole Read.
|
||||
const unrecorded = attempted.filter((r) => !isValidAction(r)).length;
|
||||
return Object.freeze({
|
||||
state: ANCESTRY_STATE.LINEAGE_AVAILABLE,
|
||||
reason: null,
|
||||
chronology_complete: unrecorded === 0,
|
||||
unrecorded_publications: unrecorded,
|
||||
read_id: chron.read_id,
|
||||
read_natural_key: chain[0].read_natural_key,
|
||||
revision_count: chron.revision_count,
|
||||
|
||||
@@ -446,6 +446,27 @@ describe('ancestry surface tells the truth about what it has', () => {
|
||||
expect(p.claim_digest).toBe('d0');
|
||||
});
|
||||
|
||||
test('a valid chain with a stamped-but-unrecorded sibling discloses the hole', () => {
|
||||
// The production shape: one good ORIGIN plus a later publication that was
|
||||
// attempted and never recorded. The chain stays valid and the partial row
|
||||
// stays out of it, but the answer must not claim the chronology is whole.
|
||||
const out = ancestry.classifyAncestry([
|
||||
act({ id: 1, publication_id: 'p1' }),
|
||||
{ id: 2, publication_id: 'p2', read_natural_key: 'k' },
|
||||
]);
|
||||
expect(out.state).toBe(A.LINEAGE_AVAILABLE);
|
||||
expect(out.revisions).toHaveLength(1);
|
||||
expect(out.revisions[0].snapshot_row_id).toBe(1); // the partial is excluded
|
||||
expect(out.chronology_complete).toBe(false);
|
||||
expect(out.unrecorded_publications).toBe(1);
|
||||
});
|
||||
|
||||
test('a chain with no unrecorded siblings is complete', () => {
|
||||
const out = ancestry.classifyAncestry([act({ id: 1, publication_id: 'p1' })]);
|
||||
expect(out.chronology_complete).toBe(true);
|
||||
expect(out.unrecorded_publications).toBe(0);
|
||||
});
|
||||
|
||||
test('nothing found is NOT_FOUND, which is not an absence of lineage', () => {
|
||||
expect(ancestry.classifyAncestry([]).state).toBe(A.NOT_FOUND);
|
||||
});
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user