diff --git a/polylogue/archive/session_revision_membership.py b/polylogue/archive/session_revision_membership.py index 30c6ea7cd7..b9e4d5eb5e 100644 --- a/polylogue/archive/session_revision_membership.py +++ b/polylogue/archive/session_revision_membership.py @@ -33,12 +33,14 @@ class MembershipRevision: @dataclass(frozen=True, slots=True) class MembershipClassification: #: Materialized head(s): an append-only growth chain (oldest to newest, - #: by set containment). Empty only when the cohort is a genuine, - #: irreducible conflict (see ``ambiguous_raw_ids``) -- a presence- - #: guarantee fallback that would deterministically pick a maximal- - #: evidence head even then is designed and unit-tested - #: (``_maximal_evidence_fallback``) but not yet wired into this return - #: path; see that function's docstring for why. + #: by set containment). Empty ONLY when the cohort is a genuine, + #: irreducible conflict (see ``ambiguous_raw_ids``) AND a head already + #: exists for this cohort under any authority -- see + #: ``classify_membership_revisions``'s ``existing_accepted_raw_id`` + #: parameter for the guard that decides this. A cohort with NO prior + #: head resolves to a single-element tuple via the presence-guarantee + #: fallback (``_maximal_evidence_fallback``) even on irreducible + #: conflict. accepted_raw_ids: tuple[str, ...] #: Raws proven to carry the SAME content as an accepted head (``equal`` #: per ``_relation``) -- legitimately superseded, not debt. @@ -217,7 +219,9 @@ def _equal_content_representative( return winner, loser -def classify_membership_revisions(revisions: list[MembershipRevision]) -> MembershipClassification: +def classify_membership_revisions( + revisions: list[MembershipRevision], *, existing_accepted_raw_id: str | None = None +) -> MembershipClassification: """Accept one total growth chain by set containment; never choose a branch silently. Two revisions are the same content, contain each other, or conflict -- @@ -229,15 +233,28 @@ def classify_membership_revisions(revisions: list[MembershipRevision]) -> Member with one relation, applied uniformly to every axis. A genuine, irreducible conflict -- no containment chain exists at all, - not export-vintage noise -- still quarantines every representative - (``ambiguous_raw_ids``) with nothing accepted, after browser-fidelity and - direct-export precedence have also failed to order the cohort. A - presence-guarantee alternative to that quarantine -- deterministically - materializing the maximal-evidence representative instead of withholding - every revision, with the conflict recorded as debt rather than absence - -- is designed and unit-tested (``_maximal_evidence_fallback``) but not - wired in here; see that function's docstring for the concrete, - proven reason (a real archive.py write-back invariant it would violate). + not export-vintage noise -- falls back to the presence-guarantee pick + (``_maximal_evidence_fallback``, deterministic maximal-evidence + representative, conflict recorded as debt rather than absence) ONLY when + ``existing_accepted_raw_id`` is ``None`` -- no head exists yet for this + cohort under ANY authority, so nothing is retired or downgraded by + materializing one. Any existing head at all -- even one that happens to + sit at the exact raw_id the fallback would itself pick -- refuses the + fallback and quarantines exactly as this cohort always did: every + representative in ``ambiguous_raw_ids``, nothing accepted. This is + deliberately narrower than "only refuse a DIFFERENT raw_id": re-accepting + the SAME raw_id through membership governance still overwrites that + head's ``accepted_frontier_kind``/generation metadata (e.g. downgrading + a byte-governed head to "semantic"), a real authority downgrade even + though the pointed-to raw_id never changed -- proven by a real + integration-test regression during development, not a theoretical + concern. The guard is a structural guarantee, not a runtime check that + could be bypassed: a later membership pass can never silently touch an + already-established head just because arbitration could not order the + new cohort (polylogue-miwv, PR #3211's write-back invariant). Callers + are responsible for passing the CURRENT accepted head's raw_id (via + ``archive.raw_revision_head_raw_id(logical_source_key)`` or equivalent) + -- omitting it when a head does exist defeats the guard. """ if not revisions: return MembershipClassification((), (), ()) @@ -284,11 +301,25 @@ def classify_membership_revisions(revisions: list[MembershipRevision]) -> Member tuple(sorted((*equivalents, *browser_capture_raw_ids))), (), ) - # _maximal_evidence_fallback (below) is the intended long-term behavior - # for a genuine, irreducible conflict -- see its docstring for why it is - # not called here yet. Until it is wired in, an irreducible conflict is - # quarantined exactly as it always was: every representative in - # `ambiguous_raw_ids`, nothing accepted. + # Presence-guarantee fallback, guarded against ANY interference with an + # already-established head -- see this function's own docstring for why + # this is deliberately narrower than "only refuse when the raw_id + # differs". Applying the fallback even when its pick happens to be the + # SAME raw_id as the existing head is still unsafe: verified directly + # during development (a real integration-test regression) that + # ``apply_raw_membership_classification`` re-accepting that raw_id + # through MEMBERSHIP governance downgrades a byte-governed head's own + # ``accepted_frontier_kind``/generation metadata from "byte" to + # "semantic", even though the pointed-to raw_id never changed -- a + # genuine authority downgrade, not a no-op. So the fallback applies + # ONLY when no head exists at all yet. + fallback = _maximal_evidence_fallback(representatives) + if existing_accepted_raw_id is None: + return MembershipClassification( + (fallback.raw_id,), + tuple(sorted(equivalents)), + tuple(sorted(item.raw_id for item in representatives if item.raw_id != fallback.raw_id)), + ) return MembershipClassification( (), tuple(sorted(equivalents)), @@ -306,26 +337,30 @@ def _maximal_evidence_fallback(representatives: list[MembershipRevision]) -> Mem events, most attachments, most acquired bytes) with a stable raw_id tiebreak means the same cohort always resolves to the same head regardless of processing order -- verified directly by - ``test_presence_guarantee_fallback_is_order_independent``. - - NOT called by ``classify_membership_revisions`` yet. Wiring it in would - make ``accepted_raw_ids`` non-empty for every non-empty input, moving the - other representatives into ``ambiguous_raw_ids`` as recorded conflict - debt instead of leaving the whole cohort headless. That trips a real, - carefully-designed invariant in - ``archive.py``'s ``apply_raw_membership_classification`` write-back, with - documented production incident history behind it (polylogue-miwv, PR - #3211): once a logical source has an accepted head, a later membership - pass may not silently retire it in favor of an unrelated raw unless that - raw is part of the new accepted/equivalent set. A fallback pick can - legitimately differ from an already-established head -- proven by two - real integration-test failures during verification - (``test_divergent_bundle_member_preserves_last_accepted_session`` and - its sibling), not a theoretical concern. Landing this safely needs - either the classifier or its caller to carry the existing head's raw_id - into this decision, or the write-back guard to accept a - re-affirmed-quarantined outcome explicitly -- both changes belong to - archive.py's write path, out of this lane's scope. + ``test_maximal_evidence_fallback_is_order_independent``. + + Called by ``classify_membership_revisions`` for a genuine, irreducible + conflict, but GUARDED there: the caller passes the cohort's current + accepted head's raw_id (if any) as ``existing_accepted_raw_id``, and the + fallback pick is applied ONLY when no head exists yet. ANY existing head + -- even one that happens to sit at the exact raw_id this function would + itself pick -- refuses the fallback and preserves plain quarantine + (nothing accepted) exactly as before this function was wired in. This + guard exists because, unguarded (or guarded only against a DIFFERENT + raw_id), a fallback pick can silently damage an already-established + head -- proven by real integration-test regressions during development: + ``test_divergent_bundle_member_preserves_last_accepted_session`` and its + sibling ``test_divergent_bundle_member_does_not_block_safe_members`` + exercise the DIFFERENT-raw_id case against a real, carefully-designed + invariant in ``archive.py``'s ``apply_raw_membership_classification`` + write-back (polylogue-miwv, PR #3211): once a logical source has an + accepted head, a later membership pass may not silently retire it in + favor of an unrelated raw. A second, independently-discovered case ruled + out even the SAME-raw_id "re-affirmation" shape: re-accepting that exact + raw_id through MEMBERSHIP governance still overwrites the head's own + ``accepted_frontier_kind``/generation metadata (e.g. downgrading a + byte-governed head to "semantic"), a real authority downgrade despite + the pointed-to raw_id never changing (``test_live_multi_session_divergence_reopens_raw_authority``). """ return max(representatives, key=lambda item: (_frontier(item.projection), item.raw_id)) diff --git a/polylogue/sources/live/batch.py b/polylogue/sources/live/batch.py index 0656d59ba7..5ded18ba7e 100644 --- a/polylogue/sources/live/batch.py +++ b/polylogue/sources/live/batch.py @@ -2613,7 +2613,7 @@ def _apply_membership_sessions( ), ) ) - classification = classify_membership_revisions(revisions) + classification = classify_membership_revisions(revisions, existing_accepted_raw_id=accepted_head_raw_id) membership_session_id = archive.apply_raw_membership_classification( logical_source_key, classification, diff --git a/polylogue/sources/revision_backfill.py b/polylogue/sources/revision_backfill.py index 037ca2c68c..0424ba10d4 100644 --- a/polylogue/sources/revision_backfill.py +++ b/polylogue/sources/revision_backfill.py @@ -1358,7 +1358,16 @@ def commit_replay_unit() -> None: if retention_observer is not None: retention_observer(len(member_sessions), retained_bytes) membership_classify_started = time.perf_counter() - classification = classify_membership_revisions(revisions) + # existing_accepted_raw_id is passed unconditionally (not + # just when head_raw_id's own authority is 'quarantined', + # unlike the narrower absorption-into-candidates condition + # above) -- the presence-guarantee-fallback guard inside + # classify_membership_revisions needs to know about a + # chain-governed (non-quarantined) existing head too, to + # correctly refuse ever retiring it, even though such a head + # is deliberately never absorbed into the comparison cohort + # itself. + classification = classify_membership_revisions(revisions, existing_accepted_raw_id=head_raw_id) stage_timings["membership.classify"] = stage_timings.get("membership.classify", 0.0) + ( time.perf_counter() - membership_classify_started ) diff --git a/tests/unit/archive/test_session_revision_membership.py b/tests/unit/archive/test_session_revision_membership.py index 38d06fec2f..267557b245 100644 --- a/tests/unit/archive/test_session_revision_membership.py +++ b/tests/unit/archive/test_session_revision_membership.py @@ -51,25 +51,60 @@ def test_classifies_strict_growth_and_semantic_equivalence() -> None: assert result.ambiguous_raw_ids == () -def test_refuses_divergent_maxima() -> None: - """Genuine divergence stays quarantined ambiguous. +def test_refuses_divergent_maxima_with_no_prior_head() -> None: + """A genuine fork with NO existing accepted head resolves via the + presence-guarantee fallback instead of vanishing into quarantine. raw-b and raw-c both grew from raw-a in incompatible directions (each holds a message the other lacks) -- a genuine fork, "conflict" under - ``_relation``. See ``_maximal_evidence_fallback`` for the designed, - unit-tested presence-guarantee alternative to this quarantine, and its - docstring for why it is not wired into ``classify_membership_revisions`` - yet. + ``_relation``. With ``existing_accepted_raw_id`` unset (the default, + meaning this logical source has never had an accepted head), nothing is + being retired, so ``_maximal_evidence_fallback``'s deterministic pick is + safe to apply: see ``classify_membership_revisions``'s docstring for the + guard condition. """ result = classify_membership_revisions( [_revision("raw-a", "one"), _revision("raw-b", "one", "left"), _revision("raw-c", "one", "right")] ) + assert result.accepted_raw_ids == ("raw-c",) + assert result.ambiguous_raw_ids == ("raw-a", "raw-b") + + +def test_refuses_divergent_maxima_when_it_would_retire_a_different_existing_head() -> None: + """The SAME fork, but a head already exists at a raw_id the fallback did not pick. + + ``existing_accepted_raw_id="raw-a"`` names a head the fallback (which + picks "raw-c") would silently retire -- the guard must refuse the + fallback entirely here and preserve plain quarantine, byte-for-byte the + same as before the fallback was wired in (polylogue-miwv, PR #3211). + """ + result = classify_membership_revisions( + [_revision("raw-a", "one"), _revision("raw-b", "one", "left"), _revision("raw-c", "one", "right")], + existing_accepted_raw_id="raw-a", + ) + assert result.accepted_raw_ids == () + assert result.ambiguous_raw_ids == ("raw-a", "raw-b", "raw-c") + + +def test_refuses_divergent_maxima_even_when_existing_head_already_matches_the_fallback_pick() -> None: + """An existing head refuses the fallback even when its raw_id happens to + already BE the fallback's own pick -- a "pure re-affirmation" is NOT + safe in practice: re-accepting that raw_id through membership governance + still overwrites the head's own ``accepted_frontier_kind``/generation + metadata (verified against a real write-back regression, + ``test_live_multi_session_divergence_reopens_raw_authority``), so the + guard refuses ANY existing head, not just a differing one. + """ + result = classify_membership_revisions( + [_revision("raw-a", "one"), _revision("raw-b", "one", "left"), _revision("raw-c", "one", "right")], + existing_accepted_raw_id="raw-c", + ) assert result.accepted_raw_ids == () assert result.ambiguous_raw_ids == ("raw-a", "raw-b", "raw-c") def test_maximal_evidence_fallback_picks_deterministically() -> None: - """The presence-guarantee fallback (not yet wired live) resolves a fork deterministically. + """The presence-guarantee fallback resolves a fork deterministically. Frontier tie among raw-b/raw-c falls through to the raw_id tiebreak ("raw-c" > "raw-b"), same as the frontier-sort used elsewhere in this @@ -91,6 +126,9 @@ def test_maximal_evidence_fallback_is_order_independent() -> None: revisions = [_revision("raw-a", "one"), _revision("raw-b", "one", "left"), _revision("raw-c", "one", "right")] results = {_maximal_evidence_fallback(list(ordering)).raw_id for ordering in permutations(revisions)} assert results == {"raw-c"} + for ordering in permutations(revisions): + classified = classify_membership_revisions(list(ordering)) + assert classified.accepted_raw_ids == ("raw-c",) def test_containment_chain_resolves_without_needing_the_fallback() -> None: @@ -227,27 +265,35 @@ def test_accepts_message_growth_across_a_reordered_prefix() -> None: def test_refuses_message_content_change_under_a_shared_id_despite_reorder() -> None: - """A real edit under a shared id is a conflict, not laundered by the set model.""" + """A real edit under a shared id is a conflict, not laundered by the set model. + + Frontier ties on message count (2 each); the fallback tiebreaks by + raw_id ("raw-old" > "raw-new"), and with no existing head to retire the + guard allows applying it. + """ older = _ordered_message_revision("raw-old", ("a", "one"), ("b", "two")) newer = _ordered_message_revision("raw-new", ("b", "two"), ("a", "EDITED")) assert _relation(older.projection, newer.projection) == "conflict" result = classify_membership_revisions([older, newer]) - assert result.accepted_raw_ids == () - assert result.ambiguous_raw_ids == ("raw-new", "raw-old") + assert result.accepted_raw_ids == ("raw-old",) + assert result.ambiguous_raw_ids == ("raw-new",) def test_refuses_message_id_disappearing_despite_reorder() -> None: - """A message id present in older but absent from newer is a real loss (a fork).""" + """A message id present in older but absent from newer is a real loss (a fork). + + Frontier ties on message count; fallback tiebreaks to "raw-old". + """ older = _ordered_message_revision("raw-old", ("a", "one"), ("b", "two")) newer = _ordered_message_revision("raw-new", ("b", "two"), ("c", "three")) assert _relation(older.projection, newer.projection) == "conflict" result = classify_membership_revisions([older, newer]) - assert result.accepted_raw_ids == () - assert result.ambiguous_raw_ids == ("raw-new", "raw-old") + assert result.accepted_raw_ids == ("raw-old",) + assert result.ambiguous_raw_ids == ("raw-new",) def test_message_reorder_does_change_the_session_content_hash() -> None: @@ -337,8 +383,8 @@ def test_refuses_conflicting_bytes_under_one_attachment_identity() -> None: assert _relation(left.projection, right.projection) == "conflict" result = classify_membership_revisions([left, right]) - assert result.accepted_raw_ids == () - assert result.ambiguous_raw_ids == ("raw-a", "raw-b") + assert result.accepted_raw_ids == ("raw-b",) + assert result.ambiguous_raw_ids == ("raw-a",) def test_attachment_acquisition_does_change_the_session_content_hash() -> None: @@ -448,8 +494,10 @@ def test_refuses_to_equate_colliding_attachment_identities_with_different_bytes( assert _relation(both.projection, left_only.projection) == "conflict" result = classify_membership_revisions([both, left_only]) - assert result.accepted_raw_ids == () - assert result.ambiguous_raw_ids == ("raw-both", "raw-left-only") + # "raw-both" carries strictly more attachment_contents (2 vs 1) so the + # fallback's frontier order picks it outright, no raw_id tiebreak needed. + assert result.accepted_raw_ids == ("raw-both",) + assert result.ambiguous_raw_ids == ("raw-left-only",) # --------------------------------------------------------------------------- @@ -566,8 +614,9 @@ def test_refuses_generation_lifecycle_state_change_despite_duration_tolerance() assert _relation(older.projection, newer.projection) == "conflict" result = classify_membership_revisions([older, newer]) - assert result.accepted_raw_ids == () - assert result.ambiguous_raw_ids == ("raw-new", "raw-old") + # Frontier ties (one event, one message each); fallback tiebreaks to "raw-old". + assert result.accepted_raw_ids == ("raw-old",) + assert result.ambiguous_raw_ids == ("raw-new",) def test_non_allowlisted_event_type_keeps_its_full_payload_as_content() -> None: @@ -607,8 +656,9 @@ def test_non_allowlisted_event_type_keeps_its_full_payload_as_content() -> None: assert _relation(older.projection, newer.projection) == "conflict" result = classify_membership_revisions([older, newer]) - assert result.accepted_raw_ids == () - assert result.ambiguous_raw_ids == ("raw-new", "raw-old") + # Frontier ties; fallback tiebreaks to "raw-old". + assert result.accepted_raw_ids == ("raw-old",) + assert result.ambiguous_raw_ids == ("raw-new",) def test_accepts_reordered_events_with_identical_content_as_equivalent() -> None: @@ -681,8 +731,9 @@ def test_refuses_event_growth_that_loses_an_existing_event() -> None: assert _relation(older.projection, newer.projection) == "conflict" result = classify_membership_revisions([older, newer]) - assert result.accepted_raw_ids == () - assert result.ambiguous_raw_ids == ("raw-new", "raw-old") + # Frontier ties (one message, one event each); fallback tiebreaks to "raw-old". + assert result.accepted_raw_ids == ("raw-old",) + assert result.ambiguous_raw_ids == ("raw-new",) def test_disambiguates_multiple_same_type_events_on_one_message_by_content() -> None: @@ -803,8 +854,9 @@ def test_browser_observed_generation_lifecycle_duration_is_real_content_not_stri assert _relation(left.projection, right.projection) == "conflict" result = classify_membership_revisions([left, right]) - assert result.accepted_raw_ids == () - assert result.ambiguous_raw_ids == ("raw-a", "raw-b") + # Frontier ties; fallback tiebreaks to "raw-b". + assert result.accepted_raw_ids == ("raw-b",) + assert result.ambiguous_raw_ids == ("raw-a",) # --------------------------------------------------------------------------- @@ -854,8 +906,11 @@ def test_mixed_direction_axes_is_a_conflict_not_a_pick() -> None: assert _relation(a.projection, b.projection) == "conflict" result = classify_membership_revisions([a, b]) - assert result.accepted_raw_ids == () - assert result.ambiguous_raw_ids == ("raw-a", "raw-b") + # raw-a's frontier tuple leads on message_contents (2 vs 1) -- the + # tuple-lexicographic max stops there regardless of raw-b's extra + # attachment identity. + assert result.accepted_raw_ids == ("raw-a",) + assert result.ambiguous_raw_ids == ("raw-b",) # --------------------------------------------------------------------------- @@ -915,11 +970,12 @@ def test_browser_native_snapshot_refuses_later_revision_that_loses_message_ident result = classify_membership_revisions(revisions) - # Genuine divergence (disjoint message identities) stays quarantined - # ambiguous -- neither the browser-fidelity ordering nor direct-export - # precedence can order it. - assert result.accepted_raw_ids == () - assert result.ambiguous_raw_ids == ("raw-new", "raw-old") + # Genuine divergence (disjoint message identities) -- neither the + # browser-fidelity ordering nor direct-export precedence can order it, + # so it falls through to the presence-guarantee fallback. Frontier ties + # (2 messages each); fallback tiebreaks to "raw-old". + assert result.accepted_raw_ids == ("raw-old",) + assert result.ambiguous_raw_ids == ("raw-new",) def test_browser_native_upgrade_refuses_any_shrinking_frontier_dimension() -> None: @@ -956,10 +1012,11 @@ def test_browser_native_upgrade_refuses_any_shrinking_frontier_dimension() -> No # Mixed-direction axes (newer gained a message, older's-only attachment # is now missing) is a fork -- neither browser-fidelity ordering (the # dom->native frontier check requires every dimension to be >=) nor - # direct-export precedence can order it, so it stays quarantined - # ambiguous. - assert result.accepted_raw_ids == () - assert result.ambiguous_raw_ids == ("raw-new", "raw-old") + # direct-export precedence can order it, so it falls through to the + # presence-guarantee fallback. raw-new leads on message_contents (2 vs + # 1), so the frontier tuple picks it outright. + assert result.accepted_raw_ids == ("raw-new",) + assert result.ambiguous_raw_ids == ("raw-old",) def test_direct_export_outranks_browser_capture_siblings_regardless_of_growth() -> None: diff --git a/tests/unit/sources/test_live_batch_support.py b/tests/unit/sources/test_live_batch_support.py index 8357f576a6..e82622843f 100644 --- a/tests/unit/sources/test_live_batch_support.py +++ b/tests/unit/sources/test_live_batch_support.py @@ -3821,11 +3821,16 @@ def test_live_third_raw_reunifies_with_backfill_retired_siblings(tmp_path: Path) path (``LiveBatchProcessor._ingest_full_paths_sync``, the production entry point, not a hand-simulated call). Content-wise raw_c does not strictly dominate raw_b (they diverge at message index 1) so the real - classifier correctly still refuses to pick a winner -- but critically - that decision is reached by weighing raw_c against BOTH retired - siblings, and raw_c ends up in ``raw_session_memberships`` with a real, - decided outcome alongside raw_a and raw_b, proving reunification - happened rather than raw_c being evaluated alone or dropped. + classifier still cannot order the full three-way cohort as a clean + containment chain -- but critically that decision is reached by + weighing raw_c against BOTH retired siblings: since this logical source + has never had an accepted head, the presence-guarantee fallback + (polylogue-lb39z item 5, ``_maximal_evidence_fallback``) deterministically + materializes raw_c (the largest-frontier representative) instead of + leaving the reunified cohort headless, with raw_a/raw_b recorded as its + conflict debt. All three raws end up in ``raw_session_memberships`` with + a real, decided outcome, proving reunification happened rather than + raw_c being evaluated alone or dropped. """ def conversation(native_id: str, *texts: str) -> dict[str, object]: @@ -3939,19 +3944,29 @@ def conversation(native_id: str, *texts: str) -> dict[str, object]: assert set(by_path) == {"a.json", "b.json", str(third)} assert all(decision is not None for decision in by_path.values()) - # The genuine three-way content divergence still correctly refuses to - # pick a winner -- reunification recovers a real resolution when one - # exists, it does not fabricate one. The source observation itself was - # acquired and parsed successfully, so its cursor is complete rather than - # retried as a transient file failure; the durable membership decision - # remains ambiguous/fail-closed. - assert by_path[str(third)] == "ambiguous" + # raw_a/raw_b are a genuine two-way divergence (shared "left"/"right" + # message content conflicts), and raw_c neither purely contains nor is + # contained by raw_b -- so the cohort as a whole is still an irreducible + # conflict; no clean prefix chain exists. This logical source has never + # had an accepted head (raw_a/raw_b were both retired straight to + # membership governance quarantined, never byte-governed-accepted), so + # the presence-guarantee fallback (polylogue-lb39z item 5) is free to + # deterministically materialize the maximal-evidence representative + # instead of leaving the reunified cohort headless: raw_c strictly + # contains raw_a's content plus a further "extra" message, giving it the + # largest frontier of the three, so it wins outright (no raw_id tiebreak + # needed) and raw_a/raw_b become its recorded conflict debt. The source + # observation itself was acquired and parsed successfully, so its cursor + # is complete rather than retried as a transient file failure either way. + assert by_path[str(third)] == "applied" + assert by_path["a.json"] == "ambiguous" + assert by_path["b.json"] == "ambiguous" assert third_result.failed == [] assert third_result.succeeded == [third] # Direct check of the persisted state backing "cursor is complete rather # than retried" above: ``_ingest_full_paths_sync`` has no CursorStore row # of its own, so the durable non-retry evidence is raw_sessions.parse_error - # staying NULL for third's raw despite the decided-ambiguous membership -- + # staying NULL for third's raw regardless of its membership decision -- # what actually stops the daemon from reprocessing this file as a failure # on every restart, not just the in-memory succeeded/failed lists. with sqlite3.connect(tmp_path / "source.db") as conn: diff --git a/tests/unit/sources/test_revision_backfill.py b/tests/unit/sources/test_revision_backfill.py index a964bdb0f1..b1f937277f 100644 --- a/tests/unit/sources/test_revision_backfill.py +++ b/tests/unit/sources/test_revision_backfill.py @@ -641,16 +641,23 @@ def test_divergent_bundle_member_does_not_block_safe_members(tmp_path: Path) -> ) result = backfill_historical_revision_evidence(tmp_path) - assert result.quarantined == 2 + # s1's own two revisions (base+left vs base+right) are a genuine, + # irreducible fork with no prior head for this fresh archive -- the + # presence-guarantee fallback (polylogue-lb39z item 5) now materializes + # a deterministic winner instead of leaving s1 permanently headless, so + # only the LOSING side of that fork stays quarantined (1, not 2). s2/s3 + # are each single-member "safe" cohorts and were never at risk. + assert result.quarantined == 1 + winner_raw_id = max(raw_a, raw_b) + loser_raw_id = raw_a if winner_raw_id == raw_b else raw_b with sqlite3.connect(tmp_path / "index.db") as conn: - assert set(conn.execute("SELECT native_id FROM sessions")) == {("s2",), ("s3",)} + assert set(conn.execute("SELECT native_id FROM sessions")) == {("s1",), ("s2",), ("s3",)} + s1_raw_id = conn.execute("SELECT raw_id FROM sessions WHERE native_id = 's1'").fetchone()[0] + assert s1_raw_id == winner_raw_id with sqlite3.connect(tmp_path / "source.db") as conn: - assert set(conn.execute("SELECT raw_id FROM raw_sessions WHERE parsed_at_ms IS NULL")) == { - (raw_a,), - (raw_b,), - } + assert set(conn.execute("SELECT raw_id FROM raw_sessions WHERE parsed_at_ms IS NULL")) == {(loser_raw_id,)} assert conn.execute("SELECT COUNT(*) FROM raw_session_memberships WHERE decision = 'ambiguous'").fetchone() == ( - 2, + 1, ) @@ -714,7 +721,16 @@ def test_stale_pre_fix_identity_split_folds_into_one_ambiguous_cohort(tmp_path: conn.commit() result = backfill_historical_revision_evidence(tmp_path, selected_raw_ids=[raw_correct, raw_stale]) - assert result.replayed_logical_sources == 0 + # Both raws now correctly fold into ONE cohort under the freshly + # re-derived identity -- the bug this test guards against. That cohort + # is a genuine, irreducible fork (base+left vs base+right) with no + # prior head, so the presence-guarantee fallback (polylogue-lb39z item + # 5) now deterministically accepts one side instead of leaving the + # correctly-unified cohort permanently headless; this is a single + # cohort's own arbitration outcome, not a reappearance of the + # independent-singleton-winners bug (which would have produced TWO + # accepted sessions under two different keys). + assert result.replayed_logical_sources == 1 with sqlite3.connect(tmp_path / "source.db") as conn: memberships = conn.execute( @@ -722,13 +738,12 @@ def test_stale_pre_fix_identity_split_folds_into_one_ambiguous_cohort(tmp_path: ).fetchall() assert {row[0] for row in memberships} == {raw_correct, raw_stale} # Both raws must converge on the SAME (freshly re-derived) identity -- - # not the stale key either was originally censused under -- and both - # must be recorded ambiguous, never silently accepted as a singleton. + # not the stale key either was originally censused under. assert {row[1] for row in memberships} == {correct_key} - assert {row[2] for row in memberships} == {"ambiguous"} + assert {row[2] for row in memberships} == {"applied", "ambiguous"} with sqlite3.connect(tmp_path / "index.db") as conn: - assert conn.execute("SELECT COUNT(*) FROM sessions").fetchone() == (0,) + assert conn.execute("SELECT COUNT(*) FROM sessions").fetchone() == (1,) def test_divergent_bundle_member_preserves_last_accepted_session(tmp_path: Path) -> None: diff --git a/tests/unit/storage/test_revision_replay.py b/tests/unit/storage/test_revision_replay.py index 98a86e6ff4..95551009d9 100644 --- a/tests/unit/storage/test_revision_replay.py +++ b/tests/unit/storage/test_revision_replay.py @@ -284,7 +284,14 @@ def add_member(raw_id: str, session: ParsedSession) -> MembershipRevision: add_member("branch-a-dup", branch_a), add_member("branch-b", branch_b), ] - classification = classify_membership_revisions(members) + # existing_accepted_raw_id="branch-a" forces the presence-guarantee + # fallback (which would otherwise deterministically pick "branch-b" + # here) to be REFUSED -- exercising this test's own invariant (no + # fabricated supersession authority when nothing is accepted) + # requires the guarded-refusal path, not the now-default + # fallback-applies-when-headless path (covered separately in + # tests/unit/archive/test_session_revision_membership.py). + classification = classify_membership_revisions(members, existing_accepted_raw_id="branch-a") assert classification.accepted_raw_ids == () assert classification.equivalent_raw_ids