From 971bea8260bbf41e6abd3008cf348d5c0aaf642a Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 4 Aug 2026 10:30:49 +0200 Subject: [PATCH 1/3] docs: record ChatGPT lifecycle-anchor evidence Problem: the polylogue-uqwd blocker required a fresh production-route check against the current quarantined ChatGPT population before any comparison-layer exception could be considered. What changed: add the read-only classifier evidence packet, index it in the generated docs surface, and add a moved-anchor red twin proving different lifecycle content remains a conflict. Compatibility/migration: no production behavior, archive database, Beads state, daemon state, or services changed. The packet records the current source/index identities, cohort coverage, historical cohort outcomes, and residual uncertainty. --- devtools/docs_surface.py | 6 + docs/README.md | 1 + ...polylogue-uqwd-chatgpt-lifecycle-anchor.md | 121 ++++++++++++++++++ docs/audits/README.md | 1 + .../test_session_revision_membership.py | 45 +++++++ 5 files changed, 174 insertions(+) create mode 100644 docs/audits/2026-08-04-polylogue-uqwd-chatgpt-lifecycle-anchor.md diff --git a/devtools/docs_surface.py b/devtools/docs_surface.py index 3da625e702..b2f76d2803 100644 --- a/devtools/docs_surface.py +++ b/devtools/docs_surface.py @@ -450,6 +450,12 @@ def _entry(title: str, path: str, description: str, tier: DocsTier) -> DocsEntry "I3 live evidence, source-tier reconciliation safeguards, and the direct-reindex gate.", "archive", ), + _entry( + "ChatGPT Lifecycle-Anchor Evidence Packet", + "audits/2026-08-04-polylogue-uqwd-chatgpt-lifecycle-anchor.md", + "Current-corpus evidence for ChatGPT generation lifecycle-anchor drift.", + "archive", + ), _entry("Audit Record Index", "audits/README.md", "Index of dated investigation records.", "archive"), _entry( "1498 Cascade Retrospective", diff --git a/docs/README.md b/docs/README.md index 2928993e3e..6b5caac180 100644 --- a/docs/README.md +++ b/docs/README.md @@ -137,6 +137,7 @@ Start with **Guides** for a task, **Reference** for a surface contract, and **Ar | [Race Window Audit](audits/2026-07-09-race-window-audit.md) | Race-window investigation record. | | [Reindex Forcing-Class Audit](audits/2026-08-04-reindex-forcing-class-audit.md) | Forcing-class and reindex-gate evidence audit. | | [Blob-Reference Liveness Closure Audit](audits/2026-08-04-blob-ref-liveness-closure.md) | I3 live evidence, source-tier reconciliation safeguards, and the direct-reindex gate. | +| [ChatGPT Lifecycle-Anchor Evidence Packet](audits/2026-08-04-polylogue-uqwd-chatgpt-lifecycle-anchor.md) | Current-corpus evidence for ChatGPT generation lifecycle-anchor drift. | | [Audit Record Index](audits/README.md) | Index of dated investigation records. | | [1498 Cascade Retrospective](retro/2026-05-24-1498-cascade.md) | Historical cascade incident retrospective. | | [Retrospective Index](retro/README.md) | Index of historical incident retrospectives. | diff --git a/docs/audits/2026-08-04-polylogue-uqwd-chatgpt-lifecycle-anchor.md b/docs/audits/2026-08-04-polylogue-uqwd-chatgpt-lifecycle-anchor.md new file mode 100644 index 0000000000..1927e363e2 --- /dev/null +++ b/docs/audits/2026-08-04-polylogue-uqwd-chatgpt-lifecycle-anchor.md @@ -0,0 +1,121 @@ +# polylogue-uqwd evidence packet: ChatGPT lifecycle-anchor drift + +Date: 2026-08-04. Worktree: `feature/investigate/chatgpt-lifecycle-anchor` at `f7b721ab38538c82021d417faea363ba152c8758`. Scope: resolve whether the current quarantined ChatGPT population still reproduces the `generation_lifecycle` moved-anchor conflict described by polylogue-uqwd, without changing Beads, source.db, index.db, the blob store, backups, daemon state, or services. + +## Verdict + +The blocker does not reproduce in the current corpus. The full production-route run found zero pairs with exactly one `generation_lifecycle` event on each side, different anchors, equal non-anchor lifecycle content, equal message content, equal attachment content, and an event-axis conflict. No comparison-layer exception is justified by this snapshot. + +The historical semantic risk remains real in principle and is preserved by the red-twin regression in `tests/unit/archive/test_session_revision_membership.py`. That test keeps a moved-anchor pair with different lifecycle state as a conflict. The existing parser fix `b1e01d878` already makes the ChatGPT generation-timing anchor deterministic, which is the upstream failure mode described by the bead. + +## Exact proof route + +The run used SQLite `mode=ro` connections to the current `source.db` and resolved `index.db`, read immutable blob files from the content-addressed blob store, then called the production functions directly in this order: + +```text +polylogue.sources.revision_backfill._parse_one(Provider.CHATGPT, payload, source_path, ...) +polylogue.pipeline.ids.session_revision_projection(session) +polylogue.archive.session_revision_membership._relation(left, right) +polylogue.archive.session_revision_membership.classify_membership_revisions(revisions, existing_accepted_raw_id=head) +``` + +The population was selected from `raw_sessions` with `origin='chatgpt-export'` and `revision_authority='quarantined'`. Cohorts were taken from persisted `raw_session_memberships.logical_source_key`, not reconstructed by SQL content heuristics. Singleton logical keys were retained in the population count and excluded only from pairwise comparison because a singleton cannot conflict. Current `raw_revision_heads` were read from the resolved index generation and passed to the classifier as `existing_accepted_raw_id`; no writeback or replay apply function was called. + +The exact population query was: + +```sql +SELECT raw_id, native_id, source_path, source_index, + lower(hex(blob_hash)) AS blob_hash, blob_size, + revision_kind, logical_source_key, revision_authority +FROM raw_sessions +WHERE origin = 'chatgpt-export' AND revision_authority = 'quarantined' +ORDER BY raw_id; +``` + +Membership keys came from: + +```sql +SELECT m.raw_id, m.logical_source_key +FROM raw_session_memberships AS m +JOIN raw_sessions AS r ON r.raw_id = m.raw_id +WHERE r.origin = 'chatgpt-export' AND r.revision_authority = 'quarantined' +ORDER BY m.logical_source_key, m.raw_id; +``` + +The transient full cohort report was `/realm/tmp/work/polylogue-uqwd-real-classifier-20260804.json`, SHA-256 `a00d15d0bcf49e6eee18613d419536a2f7217bc06b4de95bd7c6a479e629a49c`. The tracked packet below records the report's identity and decision-bearing results. + +## Corpus identity and coverage + +| Measure | Result | +| --- | ---: | +| Quarantined ChatGPT rows | 7,498 | +| Membership rows | 7,498 | +| Logical keys | 2,570 | +| Singleton logical keys | 15 | +| Multi-candidate logical keys | 2,555 | +| Rows in multi-candidate keys | 7,483 | +| Distinct non-null native ids | 2,464 | +| Rows with null native id | 5,021 | +| Candidate raws expected and parsed | 7,483 / 7,483 | +| Sessions returned and projected | 7,483 / 7,483 | +| Missing blobs | 0 | +| Parse errors | 0 | +| Parsed-key mismatches | 0 | +| Cohorts classified | 2,555 | + +Source identity at the run boundary: `/realm/db/polylogue/source.db`, resolved to the same path, device 63, inode 1515046, size 1,891,467,264 bytes, mtime ns 1785817591883760785, `user_version=24`, `schema_version=157`, WAL mode. + +Index identity at the run boundary: `/realm/db/polylogue/index.db` resolved to `/realm/db/polylogue/.index-generations/gen-1785377665711-06297b00/index.db`, device 63, inode 3789735, size 40,554,500,096 bytes, mtime ns 1785748371749663866, `user_version=46`, `schema_version=309`, WAL mode. + +## Classifier result + +Across all pairwise comparisons in the 2,555 multi-candidate cohorts, the production relation counts were `equal=7,348`, `a_contains_b=126`, `b_contains_a=82`, and `conflict=71`. Those 71 conflict pairs were distributed across 18 cohorts. The 18 conflict cohorts were not lifecycle-anchor drift: their shapes were message conflicts, attachment conflicts, event growth with missing lifecycle events, or many lifecycle events on both sides. The detailed shape census contained zero pairs with one lifecycle event on each side and moved anchor plus equal non-anchor content. + +The classifier returned an accepted raw for 2,554 cohorts, an ambiguous raw set for 7 cohorts, and equivalent raws for 2,538 cohorts. These are read-only simulated verdicts. The durable `raw_session_memberships.decision` values were not changed. + +As a cross-check of the narrower grouping used by the earlier evidence note, the current non-null `native_id` population has 5 multi-row cohorts and 18 rows. The same production parser, projection, and relation calls found `equal=8`, `a_contains_b=14`, `b_contains_a=5`, `conflict=0`, and lifecycle-anchor candidates `0`. + +## Historical cohorts named by the bead + +The premise that the named cohorts disappeared is false in the current snapshot. A read-only fragment query finds all three native ids in `raw_sessions`, and a membership query finds three rows for each logical key: + +```sql +SELECT m.raw_id, m.logical_source_key, m.provider_session_id, + m.decision, r.native_id, r.source_path, r.source_index +FROM raw_session_memberships AS m +JOIN raw_sessions AS r ON r.raw_id = m.raw_id +WHERE m.logical_source_key LIKE '%687f4424%' + OR m.logical_source_key LIKE '%6898a012%' + OR m.logical_source_key LIKE '%689b90d9%' +ORDER BY m.logical_source_key, m.raw_id; +``` + +Current real-parser outcomes for those three cohorts are one accepted raw plus two equivalent raws each, with no pairwise conflict: + +| Logical key fragment | Current parsed shape | Lifecycle-anchor result | Classifier result | +| --- | --- | --- | --- | +| `687f4424` | 46 messages, 1 lifecycle event, 3 revisions | all three anchor `986a3a7e-6e2b-4fba-851f-ab308fe52fda`; identical event-content fingerprint | 1 accepted, 2 equivalent | +| `6898a012` | 1,372 messages, 60 lifecycle events, 3 revisions | all three revisions have the same 60-event content fingerprint; not the one-event target shape | 1 accepted, 2 equivalent | +| `689b90d9` | 19 messages, 1 lifecycle event, 3 revisions | all three anchor `7c7fad67-9702-4a8b-8538-489a7b345e5a`; identical event-content fingerprint | 1 accepted, 2 equivalent | + +The current data therefore shows the original conflict has disappeared through stable parser output, not through a comparison exception. Commit `b1e01d878` changed `_extract_generation_timings` to use the candidate message id instead of mapping iteration position as the final tiebreak and added a parser regression for mapping-order stability. That is the recorded upstream explanation for why the moved anchor no longer changes across equivalent exports. The exact reason the 2026-08-03 read-only note failed to locate these rows cannot be established from this snapshot alone. The current evidence supersedes that negative lookup: the rows are now present and clean under the current parser. + +## Red-twin safeguard + +The added test constructs two ChatGPT-shaped revisions with one lifecycle event each, different anchors, and different `state` content. It calls `session_revision_projection`, `_relation`, and `classify_membership_revisions` with an existing head. It asserts equal message content, different event content, a `conflict` relation, no accepted replacement, and both raws remaining ambiguous. This is the minimal conditional regression needed if a future change revisits the proposed exactly-one-orphan comparison exception. + +## Acceptance match + +| Acceptance criterion | Status | Evidence | +| --- | --- | --- | +| Run the real classifier and projection path against current quarantined ChatGPT data | Satisfied | 7,483/7,483 candidate raws parsed and projected through production functions; 0 missing blobs, parse errors, or key mismatches | +| Confirm moved lifecycle anchors still produce conflicts | Not reproduced | 0 target-shaped pairs across 2,555 logical-key cohorts; named historical cohorts are clean | +| Implement the narrow comparison exception if reproduced | Not applicable | No production code change made | +| Preserve different-content moved-anchor behavior | Satisfied | Red-twin production-route regression added | +| Reclassify the current blocker edge honestly | Recommended | Current measurable blocker claim is unsupported; retain the historical semantic-risk note until a pre-fix replay artifact exists | + +## Graph disposition and residual uncertainty + +The `xselt/818fy` blocker edge is not justified as a current reindex blocker by this corpus. It is reasonable to retain it only as a historical semantic-risk reference until the graph owner reclassifies it. Objective evidence for removing the blocker edge is: the full current production-route census remains zero for the target predicate, the three named cohorts remain accepted/equivalent with stable anchors, the red twin remains green, and an archived pre-`b1e01d878` replay or fixture demonstrates the old moved-anchor conflict and the current parser's correction. The current packet does not fabricate that pre-fix replay because historical parser execution and backup access were outside this lane. + +Residual uncertainty is limited to provenance of the archive transition between the 2026-08-03 negative lookup and this 2026-08-04 snapshot, and the absence of an archived pre-fix parser output for the original 10/136 sample. The live durable membership decisions remain unchanged because this lane performed no writeback. diff --git a/docs/audits/README.md b/docs/audits/README.md index 7da12b0bb5..d132fcf77a 100644 --- a/docs/audits/README.md +++ b/docs/audits/README.md @@ -12,3 +12,4 @@ and [Developer Tools](../devtools.md) references for present-tense behavior. - [Race window audit](2026-07-09-race-window-audit.md) - [Reindex forcing-class audit](2026-08-04-reindex-forcing-class-audit.md) - [Blob-reference liveness closure audit](2026-08-04-blob-ref-liveness-closure.md) +- [ChatGPT lifecycle-anchor evidence packet](2026-08-04-polylogue-uqwd-chatgpt-lifecycle-anchor.md) diff --git a/tests/unit/archive/test_session_revision_membership.py b/tests/unit/archive/test_session_revision_membership.py index 267557b245..2524665f8c 100644 --- a/tests/unit/archive/test_session_revision_membership.py +++ b/tests/unit/archive/test_session_revision_membership.py @@ -619,6 +619,51 @@ def test_refuses_generation_lifecycle_state_change_despite_duration_tolerance() assert result.ambiguous_raw_ids == ("raw-new",) +def test_refuses_different_content_moved_generation_lifecycle_anchor() -> None: + """A moved anchor does not excuse different lifecycle content. + + This is the red twin for the proposed, deliberately unlanded comparison + exception in polylogue-uqwd. A future exception may fold one orphaned + lifecycle event per side only when the non-anchor content is equal. It + must keep this different-state pair as a conflict even though the event + anchor moves between revisions. + """ + + def revision(raw_id: str, anchor: str, state: str) -> MembershipRevision: + session = ParsedSession( + source_name=Provider.CHATGPT, + provider_session_id="session", + messages=[ParsedMessage(provider_message_id="0", role=Role.ASSISTANT, text="answer")], + session_events=[ + ParsedSessionEvent( + event_type="generation_lifecycle", + timestamp="13.0", + source_message_provider_id=anchor, + payload={ + "state": state, + "evidence_source": "provider_native", + "fidelity": "exact", + "duration_semantics": "provider_reported_elapsed", + "elapsed_duration_ms": 13000, + }, + ) + ], + ) + return MembershipRevision(raw_id, session_revision_projection(session)) + + left = revision("raw-left", "anchor-left", "completed") + right = revision("raw-right", "anchor-right", "in_progress") + + assert left.projection.message_contents == right.projection.message_contents + assert left.projection.event_contents != right.projection.event_contents + assert _relation(left.projection, right.projection) == "conflict" + + result = classify_membership_revisions([left, right], existing_accepted_raw_id="raw-left") + + assert result.accepted_raw_ids == () + assert result.ambiguous_raw_ids == ("raw-left", "raw-right") + + def test_non_allowlisted_event_type_keeps_its_full_payload_as_content() -> None: """The allowlist is scoped to `generation_lifecycle` -- an unrelated event type with no registered allowlist compares its FULL payload, so a real From 257b851f2ce4bde2d6501ea364a987718be8cac2 Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 4 Aug 2026 11:15:44 +0200 Subject: [PATCH 2/3] test(audit): make ChatGPT anchor census reproducible Problem: the PR's current-corpus conclusion depended on an untracked report, and its direct ParsedSession test did not exercise the historical parser ordering defect. What changed: add a read-only production-route census command, a real SQLite archive regression, an end-to-end parser-to-classifier ordering test, generated command reference, and a packet that preserves graph gates pending a historical replay fixture. Compatibility/migration: no archive, blob, daemon, service, or Beads state is changed. Co-Authored-By: Claude --- devtools/chatgpt_lifecycle_anchor_audit.py | 250 ++++++++++++++++++ devtools/command_catalog.py | 14 + ...polylogue-uqwd-chatgpt-lifecycle-anchor.md | 112 ++------ docs/devtools.md | 1 + .../test_session_revision_membership.py | 10 +- .../test_chatgpt_lifecycle_anchor_audit.py | 164 ++++++++++++ tests/unit/sources/test_parsers_chatgpt.py | 41 +++ 7 files changed, 490 insertions(+), 102 deletions(-) create mode 100644 devtools/chatgpt_lifecycle_anchor_audit.py create mode 100644 tests/unit/devtools/test_chatgpt_lifecycle_anchor_audit.py diff --git a/devtools/chatgpt_lifecycle_anchor_audit.py b/devtools/chatgpt_lifecycle_anchor_audit.py new file mode 100644 index 0000000000..4daf343b5d --- /dev/null +++ b/devtools/chatgpt_lifecycle_anchor_audit.py @@ -0,0 +1,250 @@ +"""Read-only ChatGPT lifecycle-anchor census through the production parser route. + +This command audits whether quarantined ChatGPT revisions currently exhibit +the historical mapping-order failure: two exports with equal transcript and +lifecycle content but a different generation-lifecycle anchor. It does not +change archive state. The only optional write is a caller-selected, +sanitized JSON receipt outside the archive. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sqlite3 +import subprocess +from collections import Counter, defaultdict +from collections.abc import Iterable +from dataclasses import dataclass +from pathlib import Path +from typing import Literal, TextIO + +from polylogue.archive.session_revision_membership import MembershipRevision, _relation, classify_membership_revisions +from polylogue.core.enums import Provider +from polylogue.pipeline.ids import session_revision_projection +from polylogue.sources.parsers.base import ParsedSession +from polylogue.sources.revision_backfill import _parse_one +from polylogue.storage.blob_store import BlobStore + +_Relation = Literal["equal", "a_contains_b", "b_contains_a", "conflict"] + +SCHEMA = "polylogue.chatgpt-lifecycle-anchor-audit.v1" +TARGET_PREDICATE = ( + "A pair in one persisted logical_source_key cohort where each parsed session has exactly one " + "generation_lifecycle event, their source_message_provider_id anchors differ, message_contents and " + "attachment_contents are equal, non-anchor lifecycle content hashes are equal, and the production _relation is conflict." +) +SELECTION_SQL = """ +SELECT r.raw_id, r.source_path, lower(hex(r.blob_hash)) AS blob_hash, + m.logical_source_key, m.provider_session_id +FROM raw_sessions AS r +JOIN raw_session_memberships AS m ON m.raw_id = r.raw_id +WHERE r.origin = 'chatgpt-export' AND r.revision_authority = 'quarantined' +ORDER BY m.logical_source_key, r.raw_id +""".strip() +POPULATION_SQL = """ +SELECT raw_id +FROM raw_sessions +WHERE origin = 'chatgpt-export' AND revision_authority = 'quarantined' +ORDER BY raw_id +""".strip() + + +@dataclass(frozen=True, slots=True) +class _RawMember: + raw_id: str + source_path: str + blob_hash: str + logical_source_key: str + provider_session_id: str + + +@dataclass(frozen=True, slots=True) +class _ParsedMember: + revision: MembershipRevision + session: ParsedSession + + +def _connect_read_only(path: Path) -> sqlite3.Connection: + return sqlite3.connect(f"file:{path}?mode=ro", uri=True) + + +def _database_provenance(conn: sqlite3.Connection, path: Path) -> dict[str, int]: + stat = path.stat() + return { + "size_bytes": stat.st_size, + "mtime_ns": stat.st_mtime_ns, + "sqlite_schema_version": int(conn.execute("PRAGMA schema_version").fetchone()[0]), + "sqlite_user_version": int(conn.execute("PRAGMA user_version").fetchone()[0]), + } + + +def _git_revision() -> str | None: + repo_root = Path(__file__).resolve().parents[1] + try: + return subprocess.check_output( + ["git", "-C", os.fspath(repo_root), "rev-parse", "HEAD"], text=True, stderr=subprocess.DEVNULL + ).strip() + except (OSError, subprocess.CalledProcessError): + return None + + +def _matches_target(left: _ParsedMember, right: _ParsedMember, relation: _Relation) -> bool: + if len(left.session.session_events) != 1 or len(right.session.session_events) != 1: + return False + left_event, right_event = left.session.session_events[0], right.session.session_events[0] + left_projection = left.revision.projection + right_projection = right.revision.projection + return ( + left_event.event_type == right_event.event_type == "generation_lifecycle" + and left_event.source_message_provider_id != right_event.source_message_provider_id + and left_projection.message_contents == right_projection.message_contents + and left_projection.attachment_contents == right_projection.attachment_contents + and left_projection.event_contents != right_projection.event_contents + and {content for _, content in left_projection.event_contents} + == {content for _, content in right_projection.event_contents} + and relation == "conflict" + ) + + +def _load_existing_heads(index_conn: sqlite3.Connection) -> dict[str, str]: + return { + str(row[0]): str(row[1]) + for row in index_conn.execute("SELECT logical_source_key, accepted_raw_id FROM raw_revision_heads") + } + + +def _parse_member(member: _RawMember, blob_store: BlobStore, archive_root: Path) -> _ParsedMember: + sessions = _parse_one( + Provider.CHATGPT, + blob_store.read_all(member.blob_hash), + member.source_path, + archive_root=archive_root, + fallback_id_override=member.provider_session_id, + ) + matches = [session for session in sessions if session.provider_session_id == member.provider_session_id] + if len(matches) != 1: + raise RuntimeError( + "ChatGPT lifecycle-anchor audit expected one parsed session for a persisted membership row, " + f"got {len(matches)}" + ) + session = matches[0] + return _ParsedMember(MembershipRevision(member.raw_id, session_revision_projection(session)), session) + + +def _cohorts(rows: Iterable[_RawMember]) -> dict[str, list[_RawMember]]: + grouped: dict[str, list[_RawMember]] = defaultdict(list) + for row in rows: + grouped[row.logical_source_key].append(row) + return dict(grouped) + + +def run_audit(archive_root: Path) -> dict[str, object]: + """Run the full current-corpus census without opening an archive writer.""" + source_db = archive_root / "source.db" + index_db = archive_root / "index.db" + blob_store = BlobStore(archive_root / "blob") + source_conn = _connect_read_only(source_db) + index_conn = _connect_read_only(index_db) + try: + population_raw_ids = {str(row[0]) for row in source_conn.execute(POPULATION_SQL)} + rows = [_RawMember(*map(str, row)) for row in source_conn.execute(SELECTION_SQL)] + rows_by_raw_id: dict[str, list[_RawMember]] = defaultdict(list) + for row in rows: + rows_by_raw_id[row.raw_id].append(row) + duplicated_membership_raw_count = sum(1 for members in rows_by_raw_id.values() if len(members) != 1) + if duplicated_membership_raw_count: + raise RuntimeError("ChatGPT lifecycle-anchor audit requires exactly one membership row per selected raw") + cohorts = _cohorts(rows) + relation_counts: Counter[str] = Counter() + classifier_counts: Counter[str] = Counter() + target_pair_count = 0 + parsed_raw_count = 0 + heads = _load_existing_heads(index_conn) + for logical_source_key in sorted(cohorts): + revisions = [ + _parse_member(member, blob_store, archive_root) + for member in sorted(cohorts[logical_source_key], key=lambda member: member.raw_id) + ] + parsed_raw_count += len(revisions) + for index, left in enumerate(revisions): + for right in revisions[index + 1 :]: + relation = _relation(left.revision.projection, right.revision.projection) + relation_counts[relation] += 1 + if _matches_target(left, right, relation): + target_pair_count += 1 + classification = classify_membership_revisions( + [revision.revision for revision in revisions], existing_accepted_raw_id=heads.get(logical_source_key) + ) + classifier_counts["cohorts_with_accepted_raw"] += bool(classification.accepted_raw_ids) + classifier_counts["cohorts_with_equivalent_raw"] += bool(classification.equivalent_raw_ids) + classifier_counts["cohorts_with_ambiguous_raw"] += bool(classification.ambiguous_raw_ids) + cohort_sizes = Counter(len(members) for members in cohorts.values()) + return { + "schema": SCHEMA, + "provenance": { + "archive_access": "SQLite source.db and index.db opened mode=ro; blob files read only; no archive writer created.", + "producer_git_revision": _git_revision(), + "production_route": [ + "polylogue.sources.revision_backfill._parse_one", + "polylogue.pipeline.ids.session_revision_projection", + "polylogue.archive.session_revision_membership._relation", + "polylogue.archive.session_revision_membership.classify_membership_revisions", + ], + "source_db": _database_provenance(source_conn, source_db), + "index_db": _database_provenance(index_conn, index_db), + }, + "selection": {"sql": SELECTION_SQL, "population_sql": POPULATION_SQL}, + "target_predicate": TARGET_PREDICATE, + "denominators": { + "selected_quarantined_chatgpt_raw_count": len(population_raw_ids), + "selected_membership_row_count": len(rows), + "membershipless_selected_raw_count": len(population_raw_ids - set(rows_by_raw_id)), + "logical_source_key_count": len(cohorts), + "singleton_cohort_count": cohort_sizes[1], + "multi_candidate_cohort_count": sum(count for size, count in cohort_sizes.items() if size > 1), + "raws_in_multi_candidate_cohorts": sum( + size * count for size, count in cohort_sizes.items() if size > 1 + ), + "parsed_and_projected_raw_count": parsed_raw_count, + }, + "outcomes": { + "pair_relation_counts": { + name: relation_counts[name] for name in ("equal", "a_contains_b", "b_contains_a", "conflict") + }, + "target_pair_count": target_pair_count, + "classifier_cohort_counts": dict(sorted(classifier_counts.items())), + }, + "scope": { + "sanitized": "No raw ids, native ids, source paths, blob hashes, titles, or payload content are emitted.", + "conclusion_limit": ( + "A zero target_pair_count describes only this current parser-and-corpus snapshot. It does not establish " + "the historical pre-fix replay required to reclassify or remove any graph gate." + ), + }, + } + finally: + index_conn.close() + source_conn.close() + + +def _write_receipt(path: Path, receipt: dict[str, object]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(receipt, indent=2, sort_keys=True) + "\n") + + +def main(argv: list[str] | None = None, *, stdout: TextIO | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--archive-root", type=Path, required=True, help="Archive root to inspect without mutation.") + parser.add_argument("--receipt", type=Path, help="Optional worktree-local path for the sanitized JSON receipt.") + args = parser.parse_args(argv) + receipt = run_audit(args.archive_root) + if args.receipt is not None: + _write_receipt(args.receipt, receipt) + print(json.dumps(receipt, indent=2, sort_keys=True), file=stdout) + return 0 + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/devtools/command_catalog.py b/devtools/command_catalog.py index 32c0c4561e..9ddef226de 100644 --- a/devtools/command_catalog.py +++ b/devtools/command_catalog.py @@ -985,6 +985,20 @@ def to_dict(self) -> dict[str, object]: "devtools workspace raw-live-source-reconciliation --limit 500 --sample-limit 20", ), ), + CommandSpec( + "workspace chatgpt-lifecycle-anchor-audit", + "workspace", + "Census the current quarantined ChatGPT corpus for lifecycle-anchor conflicts.", + "devtools.chatgpt_lifecycle_anchor_audit", + use_when=( + "Run the read-only parser-to-classifier census behind the historical ChatGPT mapping-order defect. " + "It emits only aggregate, sanitized evidence and never reclassifies source rows or graph gates." + ), + examples=( + "devtools workspace chatgpt-lifecycle-anchor-audit --archive-root /path/to/archive", + "devtools workspace chatgpt-lifecycle-anchor-audit --archive-root /path/to/archive --receipt docs/audits/receipt.json", + ), + ), CommandSpec( "workspace raw-live-source-reconciliation-apply", "workspace", diff --git a/docs/audits/2026-08-04-polylogue-uqwd-chatgpt-lifecycle-anchor.md b/docs/audits/2026-08-04-polylogue-uqwd-chatgpt-lifecycle-anchor.md index 1927e363e2..2687f6d2a2 100644 --- a/docs/audits/2026-08-04-polylogue-uqwd-chatgpt-lifecycle-anchor.md +++ b/docs/audits/2026-08-04-polylogue-uqwd-chatgpt-lifecycle-anchor.md @@ -1,121 +1,41 @@ # polylogue-uqwd evidence packet: ChatGPT lifecycle-anchor drift -Date: 2026-08-04. Worktree: `feature/investigate/chatgpt-lifecycle-anchor` at `f7b721ab38538c82021d417faea363ba152c8758`. Scope: resolve whether the current quarantined ChatGPT population still reproduces the `generation_lifecycle` moved-anchor conflict described by polylogue-uqwd, without changing Beads, source.db, index.db, the blob store, backups, daemon state, or services. +Date: 2026-08-04. Worktree: `feature/fix/chatgpt-anchor-audit`. Scope: record a reproducible current-corpus census for the `generation_lifecycle` moved-anchor conflict without changing Beads, source.db, index.db, the blob store, backups, daemon state, or services. ## Verdict -The blocker does not reproduce in the current corpus. The full production-route run found zero pairs with exactly one `generation_lifecycle` event on each side, different anchors, equal non-anchor lifecycle content, equal message content, equal attachment content, and an event-axis conflict. No comparison-layer exception is justified by this snapshot. +The prior census was only an untracked `/realm/tmp` report, so its current-corpus conclusion was not independently auditable. This packet is now backed by the committed, reproducible `devtools workspace chatgpt-lifecycle-anchor-audit` command and its sanitized receipt at `docs/audits/2026-08-04-polylogue-uqwd-chatgpt-lifecycle-anchor-receipt.json`. -The historical semantic risk remains real in principle and is preserved by the red-twin regression in `tests/unit/archive/test_session_revision_membership.py`. That test keeps a moved-anchor pair with different lifecycle state as a conflict. The existing parser fix `b1e01d878` already makes the ChatGPT generation-timing anchor deterministic, which is the upstream failure mode described by the bead. +The historical semantic risk remains real in principle. `tests/unit/sources/test_parsers_chatgpt.py` now carries an end-to-end regression from two mapping orders through the ChatGPT parser, revision projection, relation, and classifier. The direct `ParsedSession` test in `tests/unit/archive/test_session_revision_membership.py` remains only as a classifier-only different-content guard. -## Exact proof route +The receipt does **not** de-gate `uqwd`, `xselt`, or `818fy`. A zero result from the current parser and corpus is insufficient to establish the bead's required historical replay fixture. No graph state, archive state, blob store, daemon state, or service state was written by this work. -The run used SQLite `mode=ro` connections to the current `source.db` and resolved `index.db`, read immutable blob files from the content-addressed blob store, then called the production functions directly in this order: +## Reproducible receipt -```text -polylogue.sources.revision_backfill._parse_one(Provider.CHATGPT, payload, source_path, ...) -polylogue.pipeline.ids.session_revision_projection(session) -polylogue.archive.session_revision_membership._relation(left, right) -polylogue.archive.session_revision_membership.classify_membership_revisions(revisions, existing_accepted_raw_id=head) -``` +The command opens `source.db` and `index.db` with SQLite `mode=ro`, reads blobs, then invokes `_parse_one`, `session_revision_projection`, `_relation`, and `classify_membership_revisions`. It selects persisted `raw_session_memberships.logical_source_key` cohorts, reads current `raw_revision_heads` as classifier heads, and does not call a replay or writeback function. The exact SQL, predicate, code revision, schema versions, file sizes and mtimes are in the receipt. -The population was selected from `raw_sessions` with `origin='chatgpt-export'` and `revision_authority='quarantined'`. Cohorts were taken from persisted `raw_session_memberships.logical_source_key`, not reconstructed by SQL content heuristics. Singleton logical keys were retained in the population count and excluded only from pairwise comparison because a singleton cannot conflict. Current `raw_revision_heads` were read from the resolved index generation and passed to the classifier as `existing_accepted_raw_id`; no writeback or replay apply function was called. +The receipt deliberately contains no raw ids, native ids, source paths, blob hashes, titles, or payload content. It records 7,498 selected quarantined ChatGPT raws, 2,570 persisted cohorts, 15 singleton cohorts, 2,555 multi-candidate cohorts, and 7,483 raws in those multi-candidate cohorts. All 7,498 selected raws were parsed and projected. -The exact population query was: +Across pairwise comparisons the production relation counts were `equal=7,348`, `a_contains_b=126`, `b_contains_a=82`, and `conflict=71`. The target predicate count was zero: no pair had exactly one `generation_lifecycle` event per side, different anchors, equal transcript and attachment content, equal non-anchor lifecycle content, and a conflict relation. This does not imply that the remaining conflicts are harmless or that historical gates can be removed. -```sql -SELECT raw_id, native_id, source_path, source_index, - lower(hex(blob_hash)) AS blob_hash, blob_size, - revision_kind, logical_source_key, revision_authority -FROM raw_sessions -WHERE origin = 'chatgpt-export' AND revision_authority = 'quarantined' -ORDER BY raw_id; -``` +## Regression safeguards -Membership keys came from: +The end-to-end regression constructs two otherwise identical ChatGPT export mappings with different insertion orders, then invokes the real parser, projection, relation, and classifier. It asserts a common anchor, an equal relation, one accepted raw, one equivalent raw, and no ambiguity. It fails against the historical position-based tie-break. -```sql -SELECT m.raw_id, m.logical_source_key -FROM raw_session_memberships AS m -JOIN raw_sessions AS r ON r.raw_id = m.raw_id -WHERE r.origin = 'chatgpt-export' AND r.revision_authority = 'quarantined' -ORDER BY m.logical_source_key, m.raw_id; -``` - -The transient full cohort report was `/realm/tmp/work/polylogue-uqwd-real-classifier-20260804.json`, SHA-256 `a00d15d0bcf49e6eee18613d419536a2f7217bc06b4de95bd7c6a479e629a49c`. The tracked packet below records the report's identity and decision-bearing results. - -## Corpus identity and coverage - -| Measure | Result | -| --- | ---: | -| Quarantined ChatGPT rows | 7,498 | -| Membership rows | 7,498 | -| Logical keys | 2,570 | -| Singleton logical keys | 15 | -| Multi-candidate logical keys | 2,555 | -| Rows in multi-candidate keys | 7,483 | -| Distinct non-null native ids | 2,464 | -| Rows with null native id | 5,021 | -| Candidate raws expected and parsed | 7,483 / 7,483 | -| Sessions returned and projected | 7,483 / 7,483 | -| Missing blobs | 0 | -| Parse errors | 0 | -| Parsed-key mismatches | 0 | -| Cohorts classified | 2,555 | - -Source identity at the run boundary: `/realm/db/polylogue/source.db`, resolved to the same path, device 63, inode 1515046, size 1,891,467,264 bytes, mtime ns 1785817591883760785, `user_version=24`, `schema_version=157`, WAL mode. - -Index identity at the run boundary: `/realm/db/polylogue/index.db` resolved to `/realm/db/polylogue/.index-generations/gen-1785377665711-06297b00/index.db`, device 63, inode 3789735, size 40,554,500,096 bytes, mtime ns 1785748371749663866, `user_version=46`, `schema_version=309`, WAL mode. - -## Classifier result - -Across all pairwise comparisons in the 2,555 multi-candidate cohorts, the production relation counts were `equal=7,348`, `a_contains_b=126`, `b_contains_a=82`, and `conflict=71`. Those 71 conflict pairs were distributed across 18 cohorts. The 18 conflict cohorts were not lifecycle-anchor drift: their shapes were message conflicts, attachment conflicts, event growth with missing lifecycle events, or many lifecycle events on both sides. The detailed shape census contained zero pairs with one lifecycle event on each side and moved anchor plus equal non-anchor content. - -The classifier returned an accepted raw for 2,554 cohorts, an ambiguous raw set for 7 cohorts, and equivalent raws for 2,538 cohorts. These are read-only simulated verdicts. The durable `raw_session_memberships.decision` values were not changed. - -As a cross-check of the narrower grouping used by the earlier evidence note, the current non-null `native_id` population has 5 multi-row cohorts and 18 rows. The same production parser, projection, and relation calls found `equal=8`, `a_contains_b=14`, `b_contains_a=5`, `conflict=0`, and lifecycle-anchor candidates `0`. - -## Historical cohorts named by the bead - -The premise that the named cohorts disappeared is false in the current snapshot. A read-only fragment query finds all three native ids in `raw_sessions`, and a membership query finds three rows for each logical key: - -```sql -SELECT m.raw_id, m.logical_source_key, m.provider_session_id, - m.decision, r.native_id, r.source_path, r.source_index -FROM raw_session_memberships AS m -JOIN raw_sessions AS r ON r.raw_id = m.raw_id -WHERE m.logical_source_key LIKE '%687f4424%' - OR m.logical_source_key LIKE '%6898a012%' - OR m.logical_source_key LIKE '%689b90d9%' -ORDER BY m.logical_source_key, m.raw_id; -``` - -Current real-parser outcomes for those three cohorts are one accepted raw plus two equivalent raws each, with no pairwise conflict: - -| Logical key fragment | Current parsed shape | Lifecycle-anchor result | Classifier result | -| --- | --- | --- | --- | -| `687f4424` | 46 messages, 1 lifecycle event, 3 revisions | all three anchor `986a3a7e-6e2b-4fba-851f-ab308fe52fda`; identical event-content fingerprint | 1 accepted, 2 equivalent | -| `6898a012` | 1,372 messages, 60 lifecycle events, 3 revisions | all three revisions have the same 60-event content fingerprint; not the one-event target shape | 1 accepted, 2 equivalent | -| `689b90d9` | 19 messages, 1 lifecycle event, 3 revisions | all three anchor `7c7fad67-9702-4a8b-8538-489a7b345e5a`; identical event-content fingerprint | 1 accepted, 2 equivalent | - -The current data therefore shows the original conflict has disappeared through stable parser output, not through a comparison exception. Commit `b1e01d878` changed `_extract_generation_timings` to use the candidate message id instead of mapping iteration position as the final tiebreak and added a parser regression for mapping-order stability. That is the recorded upstream explanation for why the moved anchor no longer changes across equivalent exports. The exact reason the 2026-08-03 read-only note failed to locate these rows cannot be established from this snapshot alone. The current evidence supersedes that negative lookup: the rows are now present and clean under the current parser. - -## Red-twin safeguard - -The added test constructs two ChatGPT-shaped revisions with one lifecycle event each, different anchors, and different `state` content. It calls `session_revision_projection`, `_relation`, and `classify_membership_revisions` with an existing head. It asserts equal message content, different event content, a `conflict` relation, no accepted replacement, and both raws remaining ambiguous. This is the minimal conditional regression needed if a future change revisits the proposed exactly-one-orphan comparison exception. +The retained direct classifier guard constructs `ParsedSession` values with different lifecycle `state` content and moved anchors. It asserts conflict and ambiguity with an existing head. Its scope is limited to classifier behavior and it intentionally does not cover parser ordering. ## Acceptance match | Acceptance criterion | Status | Evidence | | --- | --- | --- | -| Run the real classifier and projection path against current quarantined ChatGPT data | Satisfied | 7,483/7,483 candidate raws parsed and projected through production functions; 0 missing blobs, parse errors, or key mismatches | -| Confirm moved lifecycle anchors still produce conflicts | Not reproduced | 0 target-shaped pairs across 2,555 logical-key cohorts; named historical cohorts are clean | +| Run the real classifier and projection path against current quarantined ChatGPT data | Reproducible current-corpus evidence | The committed command and sanitized receipt make the parser, projection, relation, classifier, SQL selection, denominators, and archive provenance reviewable | +| Confirm moved lifecycle anchors still produce conflicts | Historical fixture still required | The end-to-end regression reproduces the mapping-order mechanism, but the current snapshot does not substitute for the required archived pre-fix replay | | Implement the narrow comparison exception if reproduced | Not applicable | No production code change made | -| Preserve different-content moved-anchor behavior | Satisfied | Red-twin production-route regression added | -| Reclassify the current blocker edge honestly | Recommended | Current measurable blocker claim is unsupported; retain the historical semantic-risk note until a pre-fix replay artifact exists | +| Preserve different-content moved-anchor behavior | Satisfied, classifier scope only | The direct `ParsedSession` guard keeps changed lifecycle content conflicting; the parser-to-classifier regression owns ordering behavior | +| Reclassify the current blocker edge honestly | Not satisfied | This packet makes no reclassification or de-gating recommendation until the retained historical replay fixture exists | ## Graph disposition and residual uncertainty -The `xselt/818fy` blocker edge is not justified as a current reindex blocker by this corpus. It is reasonable to retain it only as a historical semantic-risk reference until the graph owner reclassifies it. Objective evidence for removing the blocker edge is: the full current production-route census remains zero for the target predicate, the three named cohorts remain accepted/equivalent with stable anchors, the red twin remains green, and an archived pre-`b1e01d878` replay or fixture demonstrates the old moved-anchor conflict and the current parser's correction. The current packet does not fabricate that pre-fix replay because historical parser execution and backup access were outside this lane. +No graph-edge conclusion is made here. The current-corpus receipt and regression establish that the active parser is protected against the known ordering bug, but they do not provide a retained pre-`b1e01d878` historical replay fixture. The packet therefore preserves the no-de-gating stance for `uqwd`, `xselt`, and `818fy`. Residual uncertainty is limited to provenance of the archive transition between the 2026-08-03 negative lookup and this 2026-08-04 snapshot, and the absence of an archived pre-fix parser output for the original 10/136 sample. The live durable membership decisions remain unchanged because this lane performed no writeback. diff --git a/docs/devtools.md b/docs/devtools.md index c545e7f48e..59413011ee 100644 --- a/docs/devtools.md +++ b/docs/devtools.md @@ -231,6 +231,7 @@ These are the commands worth remembering during normal repo work: | `devtools workspace beads-state-report` | Self-contained HTML state-of-the-backlog report over the whole bead population. | | `devtools workspace binary-artifact-reclassify-apply` | Persist raw_artifacts classification for binary-shaped raw rows. | | `devtools workspace binary-artifact-sweep` | Find raw_sessions rows whose bytes are a non-session binary format (SQLite, etc). | +| `devtools workspace chatgpt-lifecycle-anchor-audit` | Census the current quarantined ChatGPT corpus for lifecycle-anchor conflicts. | | `devtools workspace claim-vs-evidence` | Build a structured failure follow-up claim-vs-evidence demo. | | `devtools workspace cli-surface-audit` | Capture a current-curated CLI surface audit demo. | | `devtools workspace degraded-archive-proof` | Build a degraded archive self-healing proof artifact. | diff --git a/tests/unit/archive/test_session_revision_membership.py b/tests/unit/archive/test_session_revision_membership.py index 2524665f8c..052ad7b545 100644 --- a/tests/unit/archive/test_session_revision_membership.py +++ b/tests/unit/archive/test_session_revision_membership.py @@ -620,13 +620,11 @@ def test_refuses_generation_lifecycle_state_change_despite_duration_tolerance() def test_refuses_different_content_moved_generation_lifecycle_anchor() -> None: - """A moved anchor does not excuse different lifecycle content. + """Classifier-only guard: a moved anchor does not excuse changed content. - This is the red twin for the proposed, deliberately unlanded comparison - exception in polylogue-uqwd. A future exception may fold one orphaned - lifecycle event per side only when the non-anchor content is equal. It - must keep this different-state pair as a conflict even though the event - anchor moves between revisions. + This deliberately constructs ParsedSession values and therefore does not + cover ChatGPT parser selection or mapping-order stability. The end-to-end + regression in test_parsers_chatgpt.py owns that upstream contract. """ def revision(raw_id: str, anchor: str, state: str) -> MembershipRevision: diff --git a/tests/unit/devtools/test_chatgpt_lifecycle_anchor_audit.py b/tests/unit/devtools/test_chatgpt_lifecycle_anchor_audit.py new file mode 100644 index 0000000000..d019fdd0c3 --- /dev/null +++ b/tests/unit/devtools/test_chatgpt_lifecycle_anchor_audit.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +import json +import sqlite3 +from pathlib import Path +from typing import cast + +import pytest + +from devtools.chatgpt_lifecycle_anchor_audit import SCHEMA, TARGET_PREDICATE, main, run_audit +from devtools.command_catalog import COMMANDS +from polylogue.core.enums import Origin, Provider +from polylogue.storage.blob_store import BlobStore +from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root +from polylogue.storage.sqlite.archive_tiers.source_write import write_source_raw_session + + +def _node(node_id: str, role: str, text: str, parent: str | None, children: list[str]) -> dict[str, object]: + return { + "id": node_id, + "parent": parent, + "children": children, + "message": { + "id": node_id, + "author": {"role": role}, + "content": {"content_type": "text", "parts": [text]}, + "metadata": {"finished_duration_sec": 5} if role == "assistant" else {}, + "end_turn": role == "assistant", + }, + } + + +def _payload(order: list[str]) -> bytes: + nodes = { + "u1": _node("u1", "user", "do the work", None, ["node_a"]), + "node_a": _node("node_a", "assistant", "first draft", "u1", ["node_b"]), + "node_b": _node("node_b", "assistant", "final draft", "node_a", []), + } + return json.dumps( + {"id": "tie-break-order", "mapping": {node_id: nodes[node_id] for node_id in order}, "current_node": "node_b"}, + separators=(",", ":"), + ).encode() + + +def _write_raw(source: sqlite3.Connection, *, raw_id: str, payload: bytes) -> None: + write_source_raw_session( + source, + origin=Origin.CHATGPT_EXPORT, + capture_mode=Provider.CHATGPT, + payload=payload, + source_path="/redacted/chatgpt-export.json", + source_index=0, + acquired_at_ms=1, + raw_id=raw_id, + ) + source.execute( + """ + INSERT INTO raw_session_memberships( + raw_id, logical_source_key, provider_session_id, source_revision, + normalized_content_hash, message_count, revision_authority + ) VALUES (?, 'chatgpt-export:tie-break-order', 'tie-break-order', ?, ?, 3, 'quarantined') + """, + (raw_id, raw_id, b"x" * 32), + ) + + +def _archive_with_ordered_exports(tmp_path: Path) -> Path: + root = tmp_path / "archive" + initialize_active_archive_root(root) + source = sqlite3.connect(root / "source.db") + try: + blob_store = BlobStore(root / "blob") + for payload in (_payload(["u1", "node_a", "node_b"]), _payload(["u1", "node_b", "node_a"])): + blob_store.write_from_bytes(payload) + _write_raw(source, raw_id="raw-left", payload=_payload(["u1", "node_a", "node_b"])) + _write_raw(source, raw_id="raw-right", payload=_payload(["u1", "node_b", "node_a"])) + source.commit() + finally: + source.close() + index = sqlite3.connect(root / "index.db") + try: + index.execute( + """ + INSERT INTO raw_revision_heads( + logical_source_key, session_id, accepted_raw_id, accepted_source_revision, + accepted_content_hash, accepted_frontier_kind, accepted_frontier, + acquisition_generation, append_end_offset, decided_at_ms + ) VALUES ('chatgpt-export:tie-break-order', 'chatgpt-export:tie-break-order', 'raw-left', + 'raw-left', ?, 'semantic', ?, 0, NULL, 0) + """, + (b"y" * 32, 0), + ) + index.commit() + finally: + index.close() + return root + + +def test_audit_runs_the_parser_to_classifier_route_read_only_and_is_sanitized(tmp_path: Path) -> None: + root = _archive_with_ordered_exports(tmp_path) + source_before = (root / "source.db").read_bytes() + index_before = (root / "index.db").read_bytes() + + receipt = run_audit(root) + + assert receipt["schema"] == SCHEMA + assert receipt["target_predicate"] == TARGET_PREDICATE + assert receipt["denominators"] == { + "selected_quarantined_chatgpt_raw_count": 2, + "selected_membership_row_count": 2, + "membershipless_selected_raw_count": 0, + "logical_source_key_count": 1, + "singleton_cohort_count": 0, + "multi_candidate_cohort_count": 1, + "raws_in_multi_candidate_cohorts": 2, + "parsed_and_projected_raw_count": 2, + } + outcomes = cast(dict[str, object], receipt["outcomes"]) + assert cast(dict[str, int], outcomes["pair_relation_counts"]) == { + "equal": 1, + "a_contains_b": 0, + "b_contains_a": 0, + "conflict": 0, + } + assert outcomes["target_pair_count"] == 0 + rendered = json.dumps(receipt, sort_keys=True) + assert "raw-left" not in rendered + assert "raw-right" not in rendered + assert "/redacted/chatgpt-export.json" not in rendered + assert (root / "source.db").read_bytes() == source_before + assert (root / "index.db").read_bytes() == index_before + + +def test_audit_receipt_is_deterministic_and_cli_registers_the_command( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + root = _archive_with_ordered_exports(tmp_path) + first = run_audit(root) + second = run_audit(root) + assert first == second + receipt_path = tmp_path / "receipt.json" + + assert main(["--archive-root", str(root), "--receipt", str(receipt_path)]) == 0 + assert json.loads(receipt_path.read_text()) == first + assert json.loads(capsys.readouterr().out) == first + command = COMMANDS["workspace chatgpt-lifecycle-anchor-audit"] + assert command.module == "devtools.chatgpt_lifecycle_anchor_audit" + + +def test_audit_requires_a_real_sqlite_archive(tmp_path: Path) -> None: + root = tmp_path / "archive" + root.mkdir() + (root / "source.db").touch() + (root / "index.db").touch() + with sqlite3.connect(root / "source.db") as conn: + conn.execute("CREATE TABLE raw_sessions(raw_id TEXT)") + with sqlite3.connect(root / "index.db") as conn: + conn.execute("CREATE TABLE raw_revision_heads(logical_source_key TEXT, accepted_raw_id TEXT)") + try: + run_audit(root) + except sqlite3.OperationalError as error: + assert "origin" in str(error) + else: # pragma: no cover + raise AssertionError("audit accepted an archive without the production source schema") diff --git a/tests/unit/sources/test_parsers_chatgpt.py b/tests/unit/sources/test_parsers_chatgpt.py index c3bfbcb39c..44514c5cd1 100644 --- a/tests/unit/sources/test_parsers_chatgpt.py +++ b/tests/unit/sources/test_parsers_chatgpt.py @@ -1557,6 +1557,47 @@ def _anchor(order: list[dict[str, Any]]) -> str | None: assert anchor_forward == anchor_reversed +def test_chatgpt_mapping_order_does_not_create_revision_conflict() -> None: + """The parser's stable tie-break reaches the membership classifier. + + The historical implementation used mapping insertion position as the final + timing-candidate tiebreak. The two otherwise identical export orders then + anchored their lifecycle event to different messages, which made the + production revision classifier quarantine both raws as a conflict. + """ + from polylogue.archive.session_revision_membership import ( + MembershipRevision, + _relation, + classify_membership_revisions, + ) + + user = _branch_node("u1", "user", "do the work", parent=None, children=["node_a"]) + node_a = _branch_node("node_a", "assistant", "first draft", parent="u1", children=["node_b"]) + node_b = _branch_node("node_b", "assistant", "final draft", parent="node_a", children=[]) + for node in (node_a, node_b): + node["message"]["metadata"] = {"finished_duration_sec": 5} + + def parsed(order: list[dict[str, Any]]) -> ParsedSession: + return chatgpt_parse( + {"id": "tie-break-order", "mapping": {node["id"]: node for node in order}, "current_node": "node_b"}, + "fallback-id", + ) + + left, right = parsed([user, node_a, node_b]), parsed([user, node_b, node_a]) + revisions = [ + MembershipRevision(raw_id, session_revision_projection(session)) + for raw_id, session in (("raw-left", left), ("raw-right", right)) + ] + + assert left.session_events[0].source_message_provider_id == right.session_events[0].source_message_provider_id + assert revisions[0].projection.event_contents == revisions[1].projection.event_contents + assert _relation(revisions[0].projection, revisions[1].projection) == "equal" + result = classify_membership_revisions(revisions, existing_accepted_raw_id="raw-left") + assert result.accepted_raw_ids == ("raw-left",) + assert result.equivalent_raw_ids == ("raw-right",) + assert result.ambiguous_raw_ids == () + + # --------------------------------------------------------------------------- # #1744 — non-`parts` content is preserved (code interpreter, execution output) # --------------------------------------------------------------------------- From 2a39bb687436c473344b04a837aaf27392664700 Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 4 Aug 2026 11:20:58 +0200 Subject: [PATCH 3/3] docs(audit): record ChatGPT anchor census receipt Problem: the current-corpus census needed a reviewable artifact tied to the committed production-route audit command. What changed: record the sanitized aggregate receipt with its SQL selection, predicate, denominators, source and index provenance, and explicit no-de-gating limit. Compatibility/migration: the receipt is evidence only. It performs no archive or service mutation. Co-Authored-By: Claude --- ...uqwd-chatgpt-lifecycle-anchor-receipt.json | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 docs/audits/2026-08-04-polylogue-uqwd-chatgpt-lifecycle-anchor-receipt.json diff --git a/docs/audits/2026-08-04-polylogue-uqwd-chatgpt-lifecycle-anchor-receipt.json b/docs/audits/2026-08-04-polylogue-uqwd-chatgpt-lifecycle-anchor-receipt.json new file mode 100644 index 0000000000..a4e24afa42 --- /dev/null +++ b/docs/audits/2026-08-04-polylogue-uqwd-chatgpt-lifecycle-anchor-receipt.json @@ -0,0 +1,58 @@ +{ + "denominators": { + "logical_source_key_count": 2570, + "membershipless_selected_raw_count": 0, + "multi_candidate_cohort_count": 2555, + "parsed_and_projected_raw_count": 7498, + "raws_in_multi_candidate_cohorts": 7483, + "selected_membership_row_count": 7498, + "selected_quarantined_chatgpt_raw_count": 7498, + "singleton_cohort_count": 15 + }, + "outcomes": { + "classifier_cohort_counts": { + "cohorts_with_accepted_raw": 2561, + "cohorts_with_ambiguous_raw": 18, + "cohorts_with_equivalent_raw": 2538 + }, + "pair_relation_counts": { + "a_contains_b": 126, + "b_contains_a": 82, + "conflict": 71, + "equal": 7348 + }, + "target_pair_count": 0 + }, + "provenance": { + "archive_access": "SQLite source.db and index.db opened mode=ro; blob files read only; no archive writer created.", + "index_db": { + "mtime_ns": 1785748371749663866, + "size_bytes": 40554500096, + "sqlite_schema_version": 309, + "sqlite_user_version": 46 + }, + "producer_git_revision": "257b851f2ce4bde2d6501ea364a987718be8cac2", + "production_route": [ + "polylogue.sources.revision_backfill._parse_one", + "polylogue.pipeline.ids.session_revision_projection", + "polylogue.archive.session_revision_membership._relation", + "polylogue.archive.session_revision_membership.classify_membership_revisions" + ], + "source_db": { + "mtime_ns": 1785817591883760785, + "size_bytes": 1891467264, + "sqlite_schema_version": 157, + "sqlite_user_version": 24 + } + }, + "schema": "polylogue.chatgpt-lifecycle-anchor-audit.v1", + "scope": { + "conclusion_limit": "A zero target_pair_count describes only this current parser-and-corpus snapshot. It does not establish the historical pre-fix replay required to reclassify or remove any graph gate.", + "sanitized": "No raw ids, native ids, source paths, blob hashes, titles, or payload content are emitted." + }, + "selection": { + "population_sql": "SELECT raw_id\nFROM raw_sessions\nWHERE origin = 'chatgpt-export' AND revision_authority = 'quarantined'\nORDER BY raw_id", + "sql": "SELECT r.raw_id, r.source_path, lower(hex(r.blob_hash)) AS blob_hash,\n m.logical_source_key, m.provider_session_id\nFROM raw_sessions AS r\nJOIN raw_session_memberships AS m ON m.raw_id = r.raw_id\nWHERE r.origin = 'chatgpt-export' AND r.revision_authority = 'quarantined'\nORDER BY m.logical_source_key, r.raw_id" + }, + "target_predicate": "A pair in one persisted logical_source_key cohort where each parsed session has exactly one generation_lifecycle event, their source_message_provider_id anchors differ, message_contents and attachment_contents are equal, non-anchor lifecycle content hashes are equal, and the production _relation is conflict." +}