|
| 1 | +"""Shadow completeness on the existing source-ledger SQLite connection.""" |
| 2 | +from __future__ import annotations |
| 3 | + |
| 4 | +import json |
| 5 | +import os |
| 6 | +import subprocess |
| 7 | +import uuid |
| 8 | +from datetime import datetime, timezone |
| 9 | +from typing import Any |
| 10 | + |
| 11 | +from .completeness_evidence import TraversalEvidence |
| 12 | +from .source_ledger import SourceLedgerStore |
| 13 | + |
| 14 | + |
| 15 | +def utc(value) -> str: |
| 16 | + dt = value if isinstance(value, datetime) else datetime.fromisoformat(str(value).replace("Z", "+00:00")) |
| 17 | + if dt.tzinfo is None: |
| 18 | + raise ValueError("Completeness windows require timezone-aware timestamps") |
| 19 | + return dt.astimezone(timezone.utc).isoformat(timespec="microseconds") |
| 20 | + |
| 21 | + |
| 22 | +def core_request(op: str, **fields): |
| 23 | + binary = os.environ.get("EDITORIAL_CORE_BINARY", "jeonghan-editorial-core") |
| 24 | + process = subprocess.run( |
| 25 | + [binary], input=json.dumps({"contract_version": 1, "op": op, **fields}) + "\n", |
| 26 | + text=True, capture_output=True, timeout=5, check=True, |
| 27 | + ) |
| 28 | + reply = json.loads(process.stdout) |
| 29 | + if reply.get("contract_version") != 1 or reply.get("ok") is not True: |
| 30 | + raise ValueError("Invalid editorial core response") |
| 31 | + return reply["result"] |
| 32 | + |
| 33 | + |
| 34 | +def proof_inputs(evidence: TraversalEvidence, error_class: str = "") -> tuple[dict[str, Any], dict[str, Any]]: |
| 35 | + """Convert raw traversal facts into the conservative Rust proof contract. |
| 36 | +
|
| 37 | + The Rust v1 contract already understands a generic terminal bit. Python may set |
| 38 | + that bit from provider exhaustion OR a structurally proven lower boundary, but |
| 39 | + only after every top-level in-window ID exposed by those raw pages has reached |
| 40 | + the Phase 1 observation path. |
| 41 | + """ |
| 42 | + expected = {str(value) for value in evidence.expected_window_ids if str(value)} |
| 43 | + observed = {str(value) for value in evidence.observation_ids if str(value)} |
| 44 | + missing = expected - observed |
| 45 | + coverage_complete = not missing |
| 46 | + ordered_boundary = bool( |
| 47 | + evidence.lower_boundary_proven |
| 48 | + and evidence.timeline_order_valid |
| 49 | + and coverage_complete |
| 50 | + ) |
| 51 | + terminal_proven = bool( |
| 52 | + evidence.timeline_order_valid |
| 53 | + and coverage_complete |
| 54 | + and (evidence.exhausted or ordered_boundary) |
| 55 | + ) |
| 56 | + unresolved_boundary = bool(evidence.lower_boundary and not ordered_boundary) |
| 57 | + proof = { |
| 58 | + "pages": max(0, int(evidence.pages)), |
| 59 | + "raw_count": max(0, int(evidence.raw_count)), |
| 60 | + "valid_response": bool(evidence.valid_response), |
| 61 | + "exhausted": terminal_proven, |
| 62 | + "resumed": bool(evidence.resumed), |
| 63 | + "lower_boundary": unresolved_boundary, |
| 64 | + "failed": bool(error_class), |
| 65 | + } |
| 66 | + detail = { |
| 67 | + "provider_exhausted": bool(evidence.exhausted), |
| 68 | + "lower_boundary_observed": bool(evidence.lower_boundary), |
| 69 | + "lower_boundary_proven": bool(evidence.lower_boundary_proven), |
| 70 | + "timeline_order_valid": bool(evidence.timeline_order_valid), |
| 71 | + "expected_window_ids": sorted(expected), |
| 72 | + "expected_window_count": len(expected), |
| 73 | + "observed_expected_count": len(expected & observed), |
| 74 | + "missing_expected_ids": sorted(missing), |
| 75 | + "expected_coverage_complete": coverage_complete, |
| 76 | + "terminal_proven": terminal_proven, |
| 77 | + } |
| 78 | + return proof, detail |
| 79 | + |
| 80 | + |
| 81 | +class CompletenessEngine: |
| 82 | + def __init__(self, ledger: SourceLedgerStore): |
| 83 | + self.conn = ledger.conn |
| 84 | + self.conn.executescript(""" |
| 85 | + CREATE TABLE IF NOT EXISTS completeness_attempts ( |
| 86 | + sequence INTEGER PRIMARY KEY AUTOINCREMENT, |
| 87 | + attempt_id TEXT NOT NULL UNIQUE, |
| 88 | + run_id TEXT NOT NULL, |
| 89 | + source TEXT NOT NULL, |
| 90 | + source_order INTEGER NOT NULL, |
| 91 | + window_start TEXT NOT NULL, |
| 92 | + window_end TEXT NOT NULL, |
| 93 | + status TEXT NOT NULL, |
| 94 | + attempted INTEGER NOT NULL DEFAULT 0, |
| 95 | + retry_count INTEGER NOT NULL DEFAULT 0, |
| 96 | + evidence TEXT NOT NULL DEFAULT '{}', |
| 97 | + error_class TEXT NOT NULL DEFAULT 'NotAttempted', |
| 98 | + retained_count INTEGER NOT NULL DEFAULT 0, |
| 99 | + legacy_status TEXT NOT NULL DEFAULT '', |
| 100 | + finalized INTEGER NOT NULL DEFAULT 0, |
| 101 | + UNIQUE(run_id, source) |
| 102 | + ); |
| 103 | + CREATE TABLE IF NOT EXISTS completeness_shadow_cursors ( |
| 104 | + source TEXT PRIMARY KEY, |
| 105 | + complete_through TEXT NOT NULL, |
| 106 | + attempt_id TEXT NOT NULL, |
| 107 | + sequence INTEGER NOT NULL |
| 108 | + ); |
| 109 | + CREATE TABLE IF NOT EXISTS completeness_observations ( |
| 110 | + attempt_id TEXT NOT NULL, |
| 111 | + post_id TEXT NOT NULL, |
| 112 | + PRIMARY KEY(attempt_id,post_id) |
| 113 | + ); |
| 114 | + """) |
| 115 | + |
| 116 | + def checkpoint(self, attempt_id, evidence): |
| 117 | + payload = { |
| 118 | + "pages": max(0, int(evidence.pages)), |
| 119 | + "raw_count": max(0, int(evidence.raw_count)), |
| 120 | + "provider_cursor": str(evidence.provider_cursor or "")[:4096], |
| 121 | + "valid_response": bool(evidence.valid_response), |
| 122 | + "expected_window_ids": sorted(str(value) for value in evidence.expected_window_ids)[:5000], |
| 123 | + "lower_boundary_proven": bool(evidence.lower_boundary_proven), |
| 124 | + "timeline_order_valid": bool(evidence.timeline_order_valid), |
| 125 | + } |
| 126 | + with self.conn: |
| 127 | + self.conn.execute( |
| 128 | + "UPDATE completeness_attempts SET evidence=? WHERE attempt_id=? AND finalized=0", |
| 129 | + (json.dumps(payload, sort_keys=True), attempt_id), |
| 130 | + ) |
| 131 | + |
| 132 | + def link_observation(self, attempt_id, post_id): |
| 133 | + with self.conn: |
| 134 | + self.conn.execute("""INSERT OR IGNORE INTO completeness_observations |
| 135 | + SELECT attempt_id,? FROM completeness_attempts WHERE attempt_id=? AND finalized=0""", (post_id, attempt_id)) |
| 136 | + |
| 137 | + def plan(self, sources, start, end) -> str: |
| 138 | + start, end = utc(start), utc(end) |
| 139 | + if start >= end: |
| 140 | + raise ValueError("Completeness window must be nonempty") |
| 141 | + run_id = uuid.uuid4().hex |
| 142 | + with self.conn: |
| 143 | + for index, source in enumerate(sources): |
| 144 | + if not source.get("enabled", True): |
| 145 | + continue |
| 146 | + handle = str(source.get("handle", "")).strip().lstrip("@").casefold() |
| 147 | + # Invalid enabled configuration remains visible, never disappears. |
| 148 | + handle = handle or f"invalid-source-{index}" |
| 149 | + self.conn.execute(""" |
| 150 | + INSERT OR IGNORE INTO completeness_attempts |
| 151 | + (attempt_id,run_id,source,source_order,window_start,window_end,status) |
| 152 | + VALUES(?,?,?,?,?,?,'unproven') |
| 153 | + """, (uuid.uuid4().hex, run_id, handle, index, start, end)) |
| 154 | + return run_id |
| 155 | + |
| 156 | + def start(self, run_id, source) -> str: |
| 157 | + source = source.strip().lstrip("@").casefold() |
| 158 | + with self.conn: |
| 159 | + self.conn.execute("BEGIN IMMEDIATE") |
| 160 | + row = self.conn.execute("SELECT * FROM completeness_attempts WHERE run_id=? AND source=?", (run_id, source)).fetchone() |
| 161 | + if row is None or row["finalized"] or row["attempted"]: |
| 162 | + raise ValueError("Source attempt is missing or already started") |
| 163 | + retries = self.conn.execute("""SELECT count(*) FROM completeness_attempts |
| 164 | + WHERE source=? AND window_start=? AND window_end=? AND attempted=1""", |
| 165 | + (source, row["window_start"], row["window_end"])).fetchone()[0] |
| 166 | + self.conn.execute("""UPDATE completeness_attempts SET attempted=1, |
| 167 | + status='attempting',error_class='',retry_count=? WHERE attempt_id=?""", (retries, row["attempt_id"])) |
| 168 | + return row["attempt_id"] |
| 169 | + |
| 170 | + def finish(self, attempt_id: str, evidence: TraversalEvidence, retained: int, error_class: str = ""): |
| 171 | + proof, proof_detail = proof_inputs(evidence, error_class) |
| 172 | + try: |
| 173 | + status = core_request("evaluate_completeness", proof=proof) |
| 174 | + if status not in ("complete", "partial", "unproven"): |
| 175 | + raise ValueError("Invalid completeness state") |
| 176 | + except (OSError, ValueError, KeyError, subprocess.SubprocessError): |
| 177 | + status, error_class = "unproven", "EditorialCoreUnavailable" |
| 178 | + |
| 179 | + if status == "complete" and proof_detail["provider_exhausted"]: |
| 180 | + proof_kind = "validated_provider_exhaustion" |
| 181 | + elif status == "complete" and proof_detail["lower_boundary_proven"]: |
| 182 | + proof_kind = "validated_ordered_lower_boundary" |
| 183 | + else: |
| 184 | + proof_kind = "bounded_window_unproven" |
| 185 | + payload = { |
| 186 | + **proof, |
| 187 | + **proof_detail, |
| 188 | + "provider_cursor": evidence.provider_cursor, |
| 189 | + "raw_observation_count": len(evidence.observation_ids), |
| 190 | + "observation_ids": sorted(evidence.observation_ids), |
| 191 | + "proof_kind": proof_kind, |
| 192 | + } |
| 193 | + |
| 194 | + # Serialize writers before reading the cursor; rollback covers both rows. |
| 195 | + with self.conn: |
| 196 | + self.conn.execute("BEGIN IMMEDIATE") |
| 197 | + row = self.conn.execute("SELECT * FROM completeness_attempts WHERE attempt_id=?", (attempt_id,)).fetchone() |
| 198 | + if row is None or not row["attempted"]: |
| 199 | + raise ValueError("Unknown attempt") |
| 200 | + if row["finalized"]: |
| 201 | + return # Immutable completed attempts; duplicate results are idempotent. |
| 202 | + if status == "complete": |
| 203 | + cursor = self.conn.execute("SELECT * FROM completeness_shadow_cursors WHERE source=?", (row["source"],)).fetchone() |
| 204 | + # Older/equal proven metadata is never replaced by a stale result. |
| 205 | + eligible = cursor is None or (row["window_end"] > cursor["complete_through"] and row["sequence"] > cursor["sequence"]) |
| 206 | + # A gap cannot be silently skipped by advancing a watermark. |
| 207 | + contiguous = cursor is None or row["window_start"] <= cursor["complete_through"] |
| 208 | + if eligible and contiguous: |
| 209 | + try: |
| 210 | + through = core_request("advance_cursor", state={ |
| 211 | + "source_handle": row["source"], "window_start": row["window_start"], |
| 212 | + "window_end": row["window_end"], "completeness": status, |
| 213 | + "complete_through": cursor["complete_through"] if cursor else None, |
| 214 | + }, candidate=row["window_end"]) |
| 215 | + if through != row["window_end"]: |
| 216 | + raise ValueError("Invalid core cursor") |
| 217 | + except (OSError, ValueError, KeyError, subprocess.SubprocessError): |
| 218 | + status, error_class = "unproven", "EditorialCoreUnavailable" |
| 219 | + else: |
| 220 | + self.conn.execute("""INSERT INTO completeness_shadow_cursors VALUES(?,?,?,?) |
| 221 | + ON CONFLICT(source) DO UPDATE SET complete_through=excluded.complete_through, |
| 222 | + attempt_id=excluded.attempt_id,sequence=excluded.sequence""", |
| 223 | + (row["source"], through, attempt_id, row["sequence"])) |
| 224 | + elif not contiguous: |
| 225 | + payload["cursor_gap"] = True |
| 226 | + if status != "complete": |
| 227 | + payload["proof_kind"] = "bounded_window_unproven" |
| 228 | + payload["error_summary"] = error_class[:160] |
| 229 | + self.conn.execute("""UPDATE completeness_attempts SET status=?,evidence=?, |
| 230 | + error_class=?,retained_count=?,finalized=1, |
| 231 | + legacy_status=COALESCE((SELECT last_status FROM source_cursors WHERE source=?),'') |
| 232 | + WHERE attempt_id=?""", |
| 233 | + (status, json.dumps(payload, sort_keys=True), error_class[:160], max(0, retained), row["source"], attempt_id)) |
| 234 | + |
| 235 | + def close_run(self, run_id: str, reason: str = "Interrupted"): |
| 236 | + with self.conn: |
| 237 | + self.conn.execute("""UPDATE completeness_attempts SET status='unproven', |
| 238 | + error_class=CASE WHEN attempted=1 THEN ? ELSE 'NotAttempted' END, |
| 239 | + finalized=1 WHERE run_id=? AND finalized=0""", (reason, run_id)) |
| 240 | + |
| 241 | + def report(self, run_id: str) -> dict[str, Any]: |
| 242 | + rows = [dict(row) for row in self.conn.execute( |
| 243 | + "SELECT * FROM completeness_attempts WHERE run_id=? ORDER BY source_order,source", (run_id,))] |
| 244 | + for row in rows: |
| 245 | + row["evidence"] = json.loads(row["evidence"]) |
| 246 | + linked = [link[0] for link in self.conn.execute( |
| 247 | + "SELECT post_id FROM completeness_observations WHERE attempt_id=? ORDER BY post_id", (row["attempt_id"],))] |
| 248 | + if linked: |
| 249 | + row["evidence"]["observation_ids"] = linked |
| 250 | + row["evidence"]["raw_observation_count"] = len(linked) |
| 251 | + return {"mode": "shadow", "run_id": run_id, "configured": len(rows), |
| 252 | + "attempted": sum(row["attempted"] for row in rows), |
| 253 | + "complete": sum(row["status"] == "complete" for row in rows), |
| 254 | + "healthy": bool(rows) and all(row["status"] == "complete" and row["finalized"] for row in rows), |
| 255 | + "sources": rows} |
0 commit comments