realitydiff 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
realitydiff/store.py ADDED
@@ -0,0 +1,296 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sqlite3
5
+ import threading
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from typing import Literal
9
+
10
+ from realitydiff.hashing import sha1_hex
11
+ from realitydiff.models import ClaimRecord, ClaimState, Commit, utcnow
12
+
13
+
14
+ SCHEMA = """
15
+ CREATE TABLE IF NOT EXISTS claims (
16
+ id TEXT PRIMARY KEY,
17
+ created_at TEXT NOT NULL,
18
+ title TEXT NOT NULL,
19
+ watching INTEGER NOT NULL DEFAULT 1,
20
+ watch_interval_seconds INTEGER NOT NULL DEFAULT 300,
21
+ head TEXT,
22
+ last_watched_at TEXT,
23
+ working_json TEXT,
24
+ last_response_id TEXT,
25
+ compaction_json TEXT,
26
+ closed INTEGER NOT NULL DEFAULT 0,
27
+ closed_at TEXT,
28
+ outcome_verdict TEXT
29
+ );
30
+
31
+ CREATE TABLE IF NOT EXISTS commits (
32
+ id TEXT PRIMARY KEY,
33
+ claim_id TEXT NOT NULL,
34
+ parent_id TEXT,
35
+ tree_hash TEXT NOT NULL,
36
+ created_at TEXT NOT NULL,
37
+ author TEXT NOT NULL,
38
+ message TEXT NOT NULL,
39
+ reason TEXT NOT NULL DEFAULT '',
40
+ previous_confidence REAL,
41
+ new_confidence REAL NOT NULL,
42
+ state_json TEXT NOT NULL,
43
+ FOREIGN KEY(claim_id) REFERENCES claims(id)
44
+ );
45
+
46
+ CREATE INDEX IF NOT EXISTS idx_commits_claim ON commits(claim_id, created_at);
47
+ """
48
+
49
+
50
+ class BeliefStore:
51
+ """Content-addressed belief repository. Commits are immutable."""
52
+
53
+ def __init__(self, path: str | Path) -> None:
54
+ self.path = Path(path)
55
+ self.path.parent.mkdir(parents=True, exist_ok=True)
56
+ self._lock = threading.RLock()
57
+ self._conn = sqlite3.connect(str(self.path), check_same_thread=False, timeout=30)
58
+ self._conn.row_factory = sqlite3.Row
59
+ with self._lock:
60
+ self._conn.execute("PRAGMA journal_mode=WAL")
61
+ self._conn.execute("PRAGMA busy_timeout=5000")
62
+ self._conn.execute("PRAGMA foreign_keys=ON")
63
+ self._conn.executescript(SCHEMA)
64
+ self._migrate()
65
+ self._conn.commit()
66
+
67
+ def close(self) -> None:
68
+ self._conn.close()
69
+
70
+ def _migrate(self) -> None:
71
+ cols = {row[1] for row in self._conn.execute("PRAGMA table_info(claims)").fetchall()}
72
+ if "last_response_id" not in cols:
73
+ self._conn.execute("ALTER TABLE claims ADD COLUMN last_response_id TEXT")
74
+ if "compaction_json" not in cols:
75
+ self._conn.execute("ALTER TABLE claims ADD COLUMN compaction_json TEXT")
76
+ if "closed" not in cols:
77
+ self._conn.execute("ALTER TABLE claims ADD COLUMN closed INTEGER NOT NULL DEFAULT 0")
78
+ if "closed_at" not in cols:
79
+ self._conn.execute("ALTER TABLE claims ADD COLUMN closed_at TEXT")
80
+ if "outcome_verdict" not in cols:
81
+ self._conn.execute("ALTER TABLE claims ADD COLUMN outcome_verdict TEXT")
82
+
83
+ def create_claim(self, statement: str, *, claim_id: str | None = None) -> ClaimRecord:
84
+ created = utcnow()
85
+ cid = claim_id or f"cl_{sha1_hex(statement + created.isoformat())[:12]}"
86
+ record = ClaimRecord(id=cid, created_at=created, title=statement.strip())
87
+ with self._lock:
88
+ self._conn.execute(
89
+ """
90
+ INSERT INTO claims (
91
+ id, created_at, title, watching, watch_interval_seconds, head, working_json,
92
+ last_response_id, compaction_json
93
+ )
94
+ VALUES (?, ?, ?, 1, 300, NULL, ?, NULL, NULL)
95
+ """,
96
+ (record.id, record.created_at.isoformat(), record.title, None),
97
+ )
98
+ self._conn.commit()
99
+ return record
100
+
101
+ def get_claim(self, claim_id: str) -> ClaimRecord | None:
102
+ row = self._conn.execute("SELECT * FROM claims WHERE id = ?", (claim_id,)).fetchone()
103
+ if row:
104
+ return self._claim_from_row(row)
105
+ if len(claim_id) >= 8:
106
+ rows = self._conn.execute("SELECT * FROM claims WHERE id LIKE ?", (claim_id + "%",)).fetchall()
107
+ if len(rows) == 1:
108
+ return self._claim_from_row(rows[0])
109
+ return None
110
+
111
+ def list_claims(self) -> list[ClaimRecord]:
112
+ rows = self._conn.execute("SELECT * FROM claims ORDER BY created_at DESC").fetchall()
113
+ return [self._claim_from_row(row) for row in rows]
114
+
115
+ def set_watching(self, claim_id: str, watching: bool, interval: int | None = None) -> None:
116
+ claim = self.get_claim(claim_id)
117
+ if claim and claim.closed and watching:
118
+ raise RuntimeError("Claim is closed")
119
+ if interval is None:
120
+ self._conn.execute("UPDATE claims SET watching = ? WHERE id = ?", (int(watching), claim_id))
121
+ else:
122
+ self._conn.execute(
123
+ "UPDATE claims SET watching = ?, watch_interval_seconds = ? WHERE id = ?",
124
+ (int(watching), interval, claim_id),
125
+ )
126
+ self._conn.commit()
127
+
128
+ def close_claim(
129
+ self,
130
+ claim_id: str,
131
+ *,
132
+ verdict: Literal["confirmed", "falsified", "settled"],
133
+ closed_at: datetime | None = None,
134
+ ) -> None:
135
+ stamp = (closed_at or utcnow()).isoformat()
136
+ self._conn.execute(
137
+ """
138
+ UPDATE claims
139
+ SET watching = 0, closed = 1, closed_at = ?, outcome_verdict = ?
140
+ WHERE id = ?
141
+ """,
142
+ (stamp, verdict, claim_id),
143
+ )
144
+ self._conn.commit()
145
+
146
+ def mark_watched(self, claim_id: str, when: datetime | None = None) -> None:
147
+ stamp = (when or utcnow()).isoformat()
148
+ self._conn.execute("UPDATE claims SET last_watched_at = ? WHERE id = ?", (stamp, claim_id))
149
+ self._conn.commit()
150
+
151
+ def set_model_cache(
152
+ self,
153
+ claim_id: str,
154
+ *,
155
+ response_id: str | None = None,
156
+ compaction: dict | None = None,
157
+ ) -> None:
158
+ payload = json.dumps(compaction) if compaction else None
159
+ self._conn.execute(
160
+ "UPDATE claims SET last_response_id = ?, compaction_json = ? WHERE id = ?",
161
+ (response_id, payload, claim_id),
162
+ )
163
+ self._conn.commit()
164
+
165
+ def working_state(self, claim_id: str) -> ClaimState | None:
166
+ row = self._conn.execute("SELECT working_json, head FROM claims WHERE id = ?", (claim_id,)).fetchone()
167
+ if not row:
168
+ return None
169
+ if row["working_json"]:
170
+ return ClaimState.model_validate_json(row["working_json"])
171
+ if row["head"]:
172
+ commit = self.get_commit(row["head"])
173
+ return commit.state if commit else None
174
+ return None
175
+
176
+ def set_working_state(self, claim_id: str, state: ClaimState | None) -> None:
177
+ payload = state.model_dump_json() if state else None
178
+ self._conn.execute("UPDATE claims SET working_json = ? WHERE id = ?", (payload, claim_id))
179
+ self._conn.commit()
180
+
181
+ def head_commit(self, claim_id: str) -> Commit | None:
182
+ claim = self.get_claim(claim_id)
183
+ if not claim or not claim.head:
184
+ return None
185
+ return self.get_commit(claim.head)
186
+
187
+ def delete_claim(self, claim_id: str) -> None:
188
+ with self._lock:
189
+ self._conn.execute("DELETE FROM commits WHERE claim_id = ?", (claim_id,))
190
+ self._conn.execute("DELETE FROM claims WHERE id = ?", (claim_id,))
191
+ self._conn.commit()
192
+
193
+ def get_commit(self, commit_id: str, *, claim_id: str | None = None) -> Commit | None:
194
+ row = self._conn.execute("SELECT * FROM commits WHERE id = ?", (commit_id,)).fetchone()
195
+ if row:
196
+ commit = self._commit_from_row(row)
197
+ if claim_id and commit.claim_id != claim_id:
198
+ return None
199
+ return commit
200
+ if len(commit_id) < 7:
201
+ return None
202
+ sql = "SELECT * FROM commits WHERE id LIKE ?"
203
+ params: list[str] = [commit_id + "%"]
204
+ if claim_id:
205
+ sql += " AND claim_id = ?"
206
+ params.append(claim_id)
207
+ rows = self._conn.execute(sql, params).fetchall()
208
+ if len(rows) == 1:
209
+ return self._commit_from_row(rows[0])
210
+ if len(rows) > 1:
211
+ raise ValueError(f"Ambiguous commit prefix {commit_id}")
212
+ return None
213
+
214
+ def log(self, claim_id: str) -> list[Commit]:
215
+ commits: list[Commit] = []
216
+ claim = self.get_claim(claim_id)
217
+ current = claim.head if claim else None
218
+ seen: set[str] = set()
219
+ while current and current not in seen:
220
+ seen.add(current)
221
+ commit = self.get_commit(current)
222
+ if not commit:
223
+ break
224
+ commits.append(commit)
225
+ current = commit.parent_id
226
+ return commits
227
+
228
+ def append_commit(self, commit: Commit) -> Commit:
229
+ with self._lock:
230
+ existing = self.get_commit(commit.id)
231
+ if existing:
232
+ self._conn.execute("UPDATE claims SET head = ?, working_json = NULL WHERE id = ?", (commit.id, commit.claim_id))
233
+ self._conn.commit()
234
+ return existing
235
+ self._conn.execute(
236
+ """
237
+ INSERT INTO commits (
238
+ id, claim_id, parent_id, tree_hash, created_at, author, message, reason,
239
+ previous_confidence, new_confidence, state_json
240
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
241
+ """,
242
+ (
243
+ commit.id,
244
+ commit.claim_id,
245
+ commit.parent_id,
246
+ commit.tree_hash,
247
+ commit.created_at.isoformat(),
248
+ commit.author,
249
+ commit.message,
250
+ commit.reason,
251
+ commit.previous_confidence,
252
+ commit.new_confidence,
253
+ commit.state.model_dump_json(),
254
+ ),
255
+ )
256
+ self._conn.execute(
257
+ "UPDATE claims SET head = ?, working_json = NULL WHERE id = ?",
258
+ (commit.id, commit.claim_id),
259
+ )
260
+ self._conn.commit()
261
+ return commit
262
+
263
+ def _claim_from_row(self, row: sqlite3.Row) -> ClaimRecord:
264
+ return ClaimRecord(
265
+ id=row["id"],
266
+ created_at=datetime.fromisoformat(row["created_at"]),
267
+ title=row["title"],
268
+ watching=bool(row["watching"]),
269
+ watch_interval_seconds=row["watch_interval_seconds"],
270
+ head=row["head"],
271
+ last_watched_at=datetime.fromisoformat(row["last_watched_at"])
272
+ if row["last_watched_at"]
273
+ else None,
274
+ last_response_id=row["last_response_id"] if "last_response_id" in row.keys() else None,
275
+ compaction_json=row["compaction_json"] if "compaction_json" in row.keys() else None,
276
+ closed=bool(row["closed"]) if "closed" in row.keys() else False,
277
+ closed_at=datetime.fromisoformat(row["closed_at"])
278
+ if "closed_at" in row.keys() and row["closed_at"]
279
+ else None,
280
+ outcome_verdict=row["outcome_verdict"] if "outcome_verdict" in row.keys() else None,
281
+ )
282
+
283
+ def _commit_from_row(self, row: sqlite3.Row) -> Commit:
284
+ return Commit(
285
+ id=row["id"],
286
+ claim_id=row["claim_id"],
287
+ parent_id=row["parent_id"],
288
+ tree_hash=row["tree_hash"],
289
+ created_at=datetime.fromisoformat(row["created_at"]),
290
+ author=row["author"],
291
+ message=row["message"],
292
+ reason=row["reason"],
293
+ previous_confidence=row["previous_confidence"],
294
+ new_confidence=row["new_confidence"],
295
+ state=ClaimState.model_validate_json(row["state_json"]),
296
+ )
realitydiff/watcher.py ADDED
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import threading
5
+
6
+ from realitydiff.models import WatchResult
7
+ from realitydiff.service import RealityDiff
8
+
9
+ log = logging.getLogger("realitydiff.watcher")
10
+
11
+
12
+ class WatchLoop:
13
+ """Continuously surveys the internet for every watching claim."""
14
+
15
+ def __init__(self, engine: RealityDiff, *, tick_seconds: float = 15.0) -> None:
16
+ self.engine = engine
17
+ self.tick_seconds = tick_seconds
18
+ self._stop = threading.Event()
19
+ self._thread: threading.Thread | None = None
20
+
21
+ def start(self) -> None:
22
+ if self._thread and self._thread.is_alive():
23
+ return
24
+ self._stop.clear()
25
+ self._thread = threading.Thread(target=self._run, name="realitydiff-watch", daemon=True)
26
+ self._thread.start()
27
+
28
+ def stop(self) -> None:
29
+ self._stop.set()
30
+ if self._thread:
31
+ self._thread.join(timeout=2)
32
+
33
+ def _run(self) -> None:
34
+ while not self._stop.is_set():
35
+ try:
36
+ self._tick()
37
+ except Exception:
38
+ log.exception("watch tick failed")
39
+ self._stop.wait(self.tick_seconds)
40
+
41
+ def _tick(self) -> list[WatchResult]:
42
+ return self.engine.watch_due(author="watcher")