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/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-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." +} 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..2687f6d2a2 --- /dev/null +++ b/docs/audits/2026-08-04-polylogue-uqwd-chatgpt-lifecycle-anchor.md @@ -0,0 +1,41 @@ +# polylogue-uqwd evidence packet: ChatGPT lifecycle-anchor drift + +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 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. `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. + +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. + +## Reproducible receipt + +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 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. + +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. + +## Regression safeguards + +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. + +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 | 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, 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 + +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/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/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 267557b245..052ad7b545 100644 --- a/tests/unit/archive/test_session_revision_membership.py +++ b/tests/unit/archive/test_session_revision_membership.py @@ -619,6 +619,49 @@ 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: + """Classifier-only guard: a moved anchor does not excuse changed content. + + 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: + 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 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) # ---------------------------------------------------------------------------