@geravant/sinain 1.0.1
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.
- package/README.md +183 -0
- package/index.ts +2096 -0
- package/install.js +155 -0
- package/openclaw.plugin.json +59 -0
- package/package.json +21 -0
- package/sinain-memory/common.py +403 -0
- package/sinain-memory/demo_knowledge_transfer.sh +85 -0
- package/sinain-memory/embedder.py +268 -0
- package/sinain-memory/eval/__init__.py +0 -0
- package/sinain-memory/eval/assertions.py +288 -0
- package/sinain-memory/eval/judges/__init__.py +0 -0
- package/sinain-memory/eval/judges/base_judge.py +61 -0
- package/sinain-memory/eval/judges/curation_judge.py +46 -0
- package/sinain-memory/eval/judges/insight_judge.py +48 -0
- package/sinain-memory/eval/judges/mining_judge.py +42 -0
- package/sinain-memory/eval/judges/signal_judge.py +45 -0
- package/sinain-memory/eval/schemas.py +247 -0
- package/sinain-memory/eval_delta.py +109 -0
- package/sinain-memory/eval_reporter.py +642 -0
- package/sinain-memory/feedback_analyzer.py +221 -0
- package/sinain-memory/git_backup.sh +19 -0
- package/sinain-memory/insight_synthesizer.py +181 -0
- package/sinain-memory/memory/2026-03-01.md +11 -0
- package/sinain-memory/memory/playbook-archive/sinain-playbook-2026-03-01-1418.md +15 -0
- package/sinain-memory/memory/playbook-logs/2026-03-01.jsonl +1 -0
- package/sinain-memory/memory/sinain-playbook.md +21 -0
- package/sinain-memory/memory-config.json +39 -0
- package/sinain-memory/memory_miner.py +183 -0
- package/sinain-memory/module_manager.py +695 -0
- package/sinain-memory/playbook_curator.py +225 -0
- package/sinain-memory/requirements.txt +3 -0
- package/sinain-memory/signal_analyzer.py +141 -0
- package/sinain-memory/test_local.py +402 -0
- package/sinain-memory/tests/__init__.py +0 -0
- package/sinain-memory/tests/conftest.py +189 -0
- package/sinain-memory/tests/test_curator_helpers.py +94 -0
- package/sinain-memory/tests/test_embedder.py +210 -0
- package/sinain-memory/tests/test_extract_json.py +124 -0
- package/sinain-memory/tests/test_feedback_computation.py +121 -0
- package/sinain-memory/tests/test_miner_helpers.py +71 -0
- package/sinain-memory/tests/test_module_management.py +458 -0
- package/sinain-memory/tests/test_parsers.py +96 -0
- package/sinain-memory/tests/test_tick_evaluator.py +430 -0
- package/sinain-memory/tests/test_triple_extractor.py +255 -0
- package/sinain-memory/tests/test_triple_ingest.py +191 -0
- package/sinain-memory/tests/test_triple_migrate.py +138 -0
- package/sinain-memory/tests/test_triplestore.py +248 -0
- package/sinain-memory/tick_evaluator.py +392 -0
- package/sinain-memory/triple_extractor.py +402 -0
- package/sinain-memory/triple_ingest.py +290 -0
- package/sinain-memory/triple_migrate.py +275 -0
- package/sinain-memory/triple_query.py +184 -0
- package/sinain-memory/triplestore.py +498 -0
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""EAV Triple Store — SQLite-backed entity-attribute-value store.
|
|
3
|
+
|
|
4
|
+
Provides a Datomic/RhizomeDB-inspired immutable triple store with 4 covering
|
|
5
|
+
indexes (EAVT, AEVT, VAET, AVET) for fast graph traversal and lookup.
|
|
6
|
+
|
|
7
|
+
Each fact is a (entity_id, attribute, value) triple with a value_type tag.
|
|
8
|
+
Triples are asserted within transactions; retraction marks triples as logically
|
|
9
|
+
deleted without physically removing them (until GC).
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
from triplestore import TripleStore
|
|
13
|
+
store = TripleStore("memory/triplestore.db")
|
|
14
|
+
tx = store.begin_tx("signal_analyzer", session_key="abc")
|
|
15
|
+
store.assert_triple(tx, "signal:2026-03-01T10:00:00Z", "description", "OCR backpressure")
|
|
16
|
+
store.assert_triple(tx, "signal:2026-03-01T10:00:00Z", "related_to", "concept:ocr", value_type="ref")
|
|
17
|
+
entity = store.entity("signal:2026-03-01T10:00:00Z")
|
|
18
|
+
|
|
19
|
+
Self-test:
|
|
20
|
+
python3 triplestore.py --self-test
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import sqlite3
|
|
26
|
+
import sys
|
|
27
|
+
from datetime import datetime, timedelta, timezone
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
# Schema
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
_SCHEMA_SQL = """
|
|
36
|
+
CREATE TABLE IF NOT EXISTS transactions (
|
|
37
|
+
tx_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
38
|
+
source TEXT NOT NULL,
|
|
39
|
+
session_key TEXT,
|
|
40
|
+
parent_tx INTEGER,
|
|
41
|
+
metadata TEXT,
|
|
42
|
+
created_at TEXT NOT NULL
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
CREATE TABLE IF NOT EXISTS triples (
|
|
46
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
47
|
+
tx_id INTEGER NOT NULL REFERENCES transactions(tx_id),
|
|
48
|
+
entity_id TEXT NOT NULL,
|
|
49
|
+
attribute TEXT NOT NULL,
|
|
50
|
+
value TEXT NOT NULL,
|
|
51
|
+
value_type TEXT NOT NULL DEFAULT 'string',
|
|
52
|
+
retracted INTEGER NOT NULL DEFAULT 0,
|
|
53
|
+
retracted_tx INTEGER,
|
|
54
|
+
created_at TEXT NOT NULL
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
CREATE TABLE IF NOT EXISTS entity_types (
|
|
58
|
+
entity_id TEXT PRIMARY KEY,
|
|
59
|
+
entity_type TEXT NOT NULL
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
-- EAVT: "what does entity X look like?"
|
|
63
|
+
CREATE INDEX IF NOT EXISTS idx_eavt
|
|
64
|
+
ON triples(entity_id, attribute, value, tx_id);
|
|
65
|
+
|
|
66
|
+
-- AEVT: "which entities have attribute Y?"
|
|
67
|
+
CREATE INDEX IF NOT EXISTS idx_aevt
|
|
68
|
+
ON triples(attribute, entity_id, value, tx_id);
|
|
69
|
+
|
|
70
|
+
-- VAET: "what references entity Z?" (ref edges only)
|
|
71
|
+
CREATE INDEX IF NOT EXISTS idx_vaet
|
|
72
|
+
ON triples(value, attribute, entity_id, tx_id)
|
|
73
|
+
WHERE value_type = 'ref';
|
|
74
|
+
|
|
75
|
+
-- AVET: "find entity by unique attribute+value"
|
|
76
|
+
CREATE INDEX IF NOT EXISTS idx_avet
|
|
77
|
+
ON triples(attribute, value, entity_id, tx_id);
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _now_iso() -> str:
|
|
82
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _entity_type(entity_id: str) -> str:
|
|
86
|
+
"""Extract type prefix from entity_id (e.g. 'signal:...' → 'signal')."""
|
|
87
|
+
colon = entity_id.find(":")
|
|
88
|
+
return entity_id[:colon] if colon > 0 else "unknown"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class TripleStore:
|
|
92
|
+
"""SQLite-backed EAV triple store with WAL mode and 4 covering indexes."""
|
|
93
|
+
|
|
94
|
+
def __init__(self, db_path: str | Path) -> None:
|
|
95
|
+
self.db_path = str(db_path)
|
|
96
|
+
os.makedirs(os.path.dirname(self.db_path) or ".", exist_ok=True)
|
|
97
|
+
self._conn = sqlite3.connect(self.db_path, timeout=10)
|
|
98
|
+
self._conn.row_factory = sqlite3.Row
|
|
99
|
+
self._conn.execute("PRAGMA journal_mode=WAL")
|
|
100
|
+
self._conn.execute("PRAGMA busy_timeout=10000")
|
|
101
|
+
self._conn.executescript(_SCHEMA_SQL)
|
|
102
|
+
self._conn.commit()
|
|
103
|
+
|
|
104
|
+
def close(self) -> None:
|
|
105
|
+
self._conn.close()
|
|
106
|
+
|
|
107
|
+
# ----- Transactions -----
|
|
108
|
+
|
|
109
|
+
def begin_tx(
|
|
110
|
+
self,
|
|
111
|
+
source: str,
|
|
112
|
+
session_key: str | None = None,
|
|
113
|
+
parent_tx: int | None = None,
|
|
114
|
+
metadata: dict | None = None,
|
|
115
|
+
) -> int:
|
|
116
|
+
"""Begin a new transaction, returns tx_id."""
|
|
117
|
+
cur = self._conn.execute(
|
|
118
|
+
"INSERT INTO transactions (source, session_key, parent_tx, metadata, created_at) "
|
|
119
|
+
"VALUES (?, ?, ?, ?, ?)",
|
|
120
|
+
(
|
|
121
|
+
source,
|
|
122
|
+
session_key,
|
|
123
|
+
parent_tx,
|
|
124
|
+
json.dumps(metadata) if metadata else None,
|
|
125
|
+
_now_iso(),
|
|
126
|
+
),
|
|
127
|
+
)
|
|
128
|
+
self._conn.commit()
|
|
129
|
+
return cur.lastrowid
|
|
130
|
+
|
|
131
|
+
def latest_tx(self) -> int:
|
|
132
|
+
"""Return the latest transaction id, or 0 if none."""
|
|
133
|
+
row = self._conn.execute(
|
|
134
|
+
"SELECT MAX(tx_id) FROM transactions"
|
|
135
|
+
).fetchone()
|
|
136
|
+
return row[0] or 0
|
|
137
|
+
|
|
138
|
+
# ----- Assert / Retract -----
|
|
139
|
+
|
|
140
|
+
def assert_triple(
|
|
141
|
+
self,
|
|
142
|
+
tx_id: int,
|
|
143
|
+
entity_id: str,
|
|
144
|
+
attribute: str,
|
|
145
|
+
value: str,
|
|
146
|
+
value_type: str = "string",
|
|
147
|
+
) -> int:
|
|
148
|
+
"""Assert a triple within a transaction. Returns the triple id.
|
|
149
|
+
|
|
150
|
+
Auto-populates entity_types from entity_id prefix.
|
|
151
|
+
"""
|
|
152
|
+
now = _now_iso()
|
|
153
|
+
cur = self._conn.execute(
|
|
154
|
+
"INSERT INTO triples (tx_id, entity_id, attribute, value, value_type, retracted, created_at) "
|
|
155
|
+
"VALUES (?, ?, ?, ?, ?, 0, ?)",
|
|
156
|
+
(tx_id, entity_id, attribute, value, value_type, now),
|
|
157
|
+
)
|
|
158
|
+
# Upsert entity type
|
|
159
|
+
etype = _entity_type(entity_id)
|
|
160
|
+
self._conn.execute(
|
|
161
|
+
"INSERT OR IGNORE INTO entity_types (entity_id, entity_type) VALUES (?, ?)",
|
|
162
|
+
(entity_id, etype),
|
|
163
|
+
)
|
|
164
|
+
self._conn.commit()
|
|
165
|
+
return cur.lastrowid
|
|
166
|
+
|
|
167
|
+
def retract_triple(
|
|
168
|
+
self,
|
|
169
|
+
tx_id: int,
|
|
170
|
+
entity_id: str,
|
|
171
|
+
attribute: str,
|
|
172
|
+
value: str | None = None,
|
|
173
|
+
) -> int:
|
|
174
|
+
"""Retract triples matching entity+attribute (and optionally value).
|
|
175
|
+
|
|
176
|
+
Sets retracted=1 and retracted_tx to the retraction transaction.
|
|
177
|
+
The original tx_id is preserved for temporal (as_of_tx) queries.
|
|
178
|
+
Returns the count of triples retracted.
|
|
179
|
+
"""
|
|
180
|
+
if value is not None:
|
|
181
|
+
cur = self._conn.execute(
|
|
182
|
+
"UPDATE triples SET retracted = 1, retracted_tx = ? "
|
|
183
|
+
"WHERE entity_id = ? AND attribute = ? AND value = ? AND retracted = 0",
|
|
184
|
+
(tx_id, entity_id, attribute, value),
|
|
185
|
+
)
|
|
186
|
+
else:
|
|
187
|
+
cur = self._conn.execute(
|
|
188
|
+
"UPDATE triples SET retracted = 1, retracted_tx = ? "
|
|
189
|
+
"WHERE entity_id = ? AND attribute = ? AND retracted = 0",
|
|
190
|
+
(tx_id, entity_id, attribute),
|
|
191
|
+
)
|
|
192
|
+
self._conn.commit()
|
|
193
|
+
return cur.rowcount
|
|
194
|
+
|
|
195
|
+
# ----- Query: EAVT (entity view) -----
|
|
196
|
+
|
|
197
|
+
def entity(self, entity_id: str, as_of_tx: int | None = None) -> dict[str, list[str]]:
|
|
198
|
+
"""Return all active attributes for an entity as {attr: [values]}.
|
|
199
|
+
|
|
200
|
+
Uses EAVT index. When as_of_tx is set, shows triples that were
|
|
201
|
+
asserted on or before that tx AND not yet retracted at that point.
|
|
202
|
+
"""
|
|
203
|
+
if as_of_tx is not None:
|
|
204
|
+
rows = self._conn.execute(
|
|
205
|
+
"SELECT attribute, value FROM triples "
|
|
206
|
+
"WHERE entity_id = ? AND tx_id <= ? "
|
|
207
|
+
"AND (retracted = 0 OR retracted_tx > ?) "
|
|
208
|
+
"ORDER BY attribute, id",
|
|
209
|
+
(entity_id, as_of_tx, as_of_tx),
|
|
210
|
+
).fetchall()
|
|
211
|
+
else:
|
|
212
|
+
rows = self._conn.execute(
|
|
213
|
+
"SELECT attribute, value FROM triples "
|
|
214
|
+
"WHERE entity_id = ? AND retracted = 0 "
|
|
215
|
+
"ORDER BY attribute, id",
|
|
216
|
+
(entity_id,),
|
|
217
|
+
).fetchall()
|
|
218
|
+
result: dict[str, list[str]] = {}
|
|
219
|
+
for row in rows:
|
|
220
|
+
result.setdefault(row["attribute"], []).append(row["value"])
|
|
221
|
+
return result
|
|
222
|
+
|
|
223
|
+
# ----- Query: AEVT (attribute scan) -----
|
|
224
|
+
|
|
225
|
+
def entities_with_attr(
|
|
226
|
+
self, attribute: str, as_of_tx: int | None = None
|
|
227
|
+
) -> list[tuple[str, str]]:
|
|
228
|
+
"""Return [(entity_id, value)] for all entities having the given attribute.
|
|
229
|
+
|
|
230
|
+
Uses AEVT index.
|
|
231
|
+
"""
|
|
232
|
+
if as_of_tx is not None:
|
|
233
|
+
rows = self._conn.execute(
|
|
234
|
+
"SELECT entity_id, value FROM triples "
|
|
235
|
+
"WHERE attribute = ? AND tx_id <= ? "
|
|
236
|
+
"AND (retracted = 0 OR retracted_tx > ?) "
|
|
237
|
+
"ORDER BY entity_id",
|
|
238
|
+
(attribute, as_of_tx, as_of_tx),
|
|
239
|
+
).fetchall()
|
|
240
|
+
else:
|
|
241
|
+
rows = self._conn.execute(
|
|
242
|
+
"SELECT entity_id, value FROM triples "
|
|
243
|
+
"WHERE attribute = ? AND retracted = 0 "
|
|
244
|
+
"ORDER BY entity_id",
|
|
245
|
+
(attribute,),
|
|
246
|
+
).fetchall()
|
|
247
|
+
return [(r["entity_id"], r["value"]) for r in rows]
|
|
248
|
+
|
|
249
|
+
# ----- Query: VAET (backrefs) -----
|
|
250
|
+
|
|
251
|
+
def backrefs(
|
|
252
|
+
self,
|
|
253
|
+
target: str,
|
|
254
|
+
attribute: str | None = None,
|
|
255
|
+
as_of_tx: int | None = None,
|
|
256
|
+
) -> list[tuple[str, str]]:
|
|
257
|
+
"""Return [(entity_id, attribute)] for ref triples pointing to target.
|
|
258
|
+
|
|
259
|
+
Uses VAET index (partial index on value_type='ref').
|
|
260
|
+
"""
|
|
261
|
+
conditions = ["value = ?", "value_type = 'ref'"]
|
|
262
|
+
params: list = [target]
|
|
263
|
+
if attribute:
|
|
264
|
+
conditions.append("attribute = ?")
|
|
265
|
+
params.append(attribute)
|
|
266
|
+
if as_of_tx is not None:
|
|
267
|
+
conditions.append("tx_id <= ?")
|
|
268
|
+
conditions.append("(retracted = 0 OR retracted_tx > ?)")
|
|
269
|
+
params.append(as_of_tx)
|
|
270
|
+
params.append(as_of_tx)
|
|
271
|
+
else:
|
|
272
|
+
conditions.append("retracted = 0")
|
|
273
|
+
where = " AND ".join(conditions)
|
|
274
|
+
rows = self._conn.execute(
|
|
275
|
+
f"SELECT entity_id, attribute FROM triples WHERE {where} ORDER BY entity_id",
|
|
276
|
+
params,
|
|
277
|
+
).fetchall()
|
|
278
|
+
return [(r["entity_id"], r["attribute"]) for r in rows]
|
|
279
|
+
|
|
280
|
+
# ----- Query: AVET (lookup by attribute+value) -----
|
|
281
|
+
|
|
282
|
+
def lookup(
|
|
283
|
+
self, attribute: str, value: str, as_of_tx: int | None = None
|
|
284
|
+
) -> list[str]:
|
|
285
|
+
"""Return entity_ids that have the exact attribute=value.
|
|
286
|
+
|
|
287
|
+
Uses AVET index.
|
|
288
|
+
"""
|
|
289
|
+
if as_of_tx is not None:
|
|
290
|
+
rows = self._conn.execute(
|
|
291
|
+
"SELECT DISTINCT entity_id FROM triples "
|
|
292
|
+
"WHERE attribute = ? AND value = ? AND tx_id <= ? "
|
|
293
|
+
"AND (retracted = 0 OR retracted_tx > ?)",
|
|
294
|
+
(attribute, value, as_of_tx, as_of_tx),
|
|
295
|
+
).fetchall()
|
|
296
|
+
else:
|
|
297
|
+
rows = self._conn.execute(
|
|
298
|
+
"SELECT DISTINCT entity_id FROM triples "
|
|
299
|
+
"WHERE attribute = ? AND value = ? AND retracted = 0",
|
|
300
|
+
(attribute, value),
|
|
301
|
+
).fetchall()
|
|
302
|
+
return [r["entity_id"] for r in rows]
|
|
303
|
+
|
|
304
|
+
# ----- Graph traversal: BFS neighbors -----
|
|
305
|
+
|
|
306
|
+
def neighbors(
|
|
307
|
+
self, entity_id: str, depth: int = 1, as_of_tx: int | None = None
|
|
308
|
+
) -> dict[str, dict[str, list[str]]]:
|
|
309
|
+
"""BFS traversal via ref edges. Returns {entity_id: {attr: [vals]}} for all
|
|
310
|
+
entities reachable within `depth` hops."""
|
|
311
|
+
visited: set[str] = set()
|
|
312
|
+
frontier = {entity_id}
|
|
313
|
+
result: dict[str, dict[str, list[str]]] = {}
|
|
314
|
+
|
|
315
|
+
for _ in range(depth + 1):
|
|
316
|
+
next_frontier: set[str] = set()
|
|
317
|
+
for eid in frontier:
|
|
318
|
+
if eid in visited:
|
|
319
|
+
continue
|
|
320
|
+
visited.add(eid)
|
|
321
|
+
attrs = self.entity(eid, as_of_tx)
|
|
322
|
+
if attrs:
|
|
323
|
+
result[eid] = attrs
|
|
324
|
+
# Follow outgoing refs
|
|
325
|
+
for attr, vals in attrs.items():
|
|
326
|
+
# Check which values are refs
|
|
327
|
+
for val in vals:
|
|
328
|
+
ref_rows = self._conn.execute(
|
|
329
|
+
"SELECT 1 FROM triples "
|
|
330
|
+
"WHERE entity_id = ? AND attribute = ? AND value = ? "
|
|
331
|
+
"AND value_type = 'ref' AND retracted = 0 LIMIT 1",
|
|
332
|
+
(eid, attr, val),
|
|
333
|
+
).fetchone()
|
|
334
|
+
if ref_rows:
|
|
335
|
+
next_frontier.add(val)
|
|
336
|
+
# Follow incoming refs (backrefs)
|
|
337
|
+
for src_eid, _ in self.backrefs(eid, as_of_tx=as_of_tx):
|
|
338
|
+
next_frontier.add(src_eid)
|
|
339
|
+
frontier = next_frontier - visited
|
|
340
|
+
|
|
341
|
+
return result
|
|
342
|
+
|
|
343
|
+
# ----- Novelty (change feed) -----
|
|
344
|
+
|
|
345
|
+
def novelty(
|
|
346
|
+
self, since_tx: int, until_tx: int | None = None
|
|
347
|
+
) -> list[dict]:
|
|
348
|
+
"""Return triples asserted or retracted since since_tx (exclusive)."""
|
|
349
|
+
if until_tx is not None:
|
|
350
|
+
rows = self._conn.execute(
|
|
351
|
+
"SELECT id, tx_id, entity_id, attribute, value, value_type, retracted, created_at "
|
|
352
|
+
"FROM triples WHERE tx_id > ? AND tx_id <= ? ORDER BY id",
|
|
353
|
+
(since_tx, until_tx),
|
|
354
|
+
).fetchall()
|
|
355
|
+
else:
|
|
356
|
+
rows = self._conn.execute(
|
|
357
|
+
"SELECT id, tx_id, entity_id, attribute, value, value_type, retracted, created_at "
|
|
358
|
+
"FROM triples WHERE tx_id > ? ORDER BY id",
|
|
359
|
+
(since_tx,),
|
|
360
|
+
).fetchall()
|
|
361
|
+
return [dict(r) for r in rows]
|
|
362
|
+
|
|
363
|
+
# ----- Stats -----
|
|
364
|
+
|
|
365
|
+
def stats(self) -> dict:
|
|
366
|
+
"""Return store statistics."""
|
|
367
|
+
triple_count = self._conn.execute(
|
|
368
|
+
"SELECT COUNT(*) FROM triples WHERE retracted = 0"
|
|
369
|
+
).fetchone()[0]
|
|
370
|
+
entity_count = self._conn.execute(
|
|
371
|
+
"SELECT COUNT(*) FROM entity_types"
|
|
372
|
+
).fetchone()[0]
|
|
373
|
+
tx_count = self._conn.execute(
|
|
374
|
+
"SELECT COUNT(*) FROM transactions"
|
|
375
|
+
).fetchone()[0]
|
|
376
|
+
try:
|
|
377
|
+
db_size = os.path.getsize(self.db_path)
|
|
378
|
+
except OSError:
|
|
379
|
+
db_size = 0
|
|
380
|
+
return {
|
|
381
|
+
"triples": triple_count,
|
|
382
|
+
"entities": entity_count,
|
|
383
|
+
"transactions": tx_count,
|
|
384
|
+
"db_size_bytes": db_size,
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
# ----- Garbage collection -----
|
|
388
|
+
|
|
389
|
+
def gc(self, older_than_days: int = 30) -> int:
|
|
390
|
+
"""Physically delete retracted triples older than N days. Returns count."""
|
|
391
|
+
cutoff = (datetime.now(timezone.utc) - timedelta(days=older_than_days)).isoformat()
|
|
392
|
+
cur = self._conn.execute(
|
|
393
|
+
"DELETE FROM triples WHERE retracted = 1 AND created_at < ?",
|
|
394
|
+
(cutoff,),
|
|
395
|
+
)
|
|
396
|
+
self._conn.commit()
|
|
397
|
+
return cur.rowcount
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
# ---------------------------------------------------------------------------
|
|
401
|
+
# Self-test
|
|
402
|
+
# ---------------------------------------------------------------------------
|
|
403
|
+
|
|
404
|
+
def _self_test() -> None:
|
|
405
|
+
"""Run a quick self-test with a temp in-memory database."""
|
|
406
|
+
import tempfile
|
|
407
|
+
|
|
408
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
409
|
+
db_path = os.path.join(tmpdir, "test.db")
|
|
410
|
+
store = TripleStore(db_path)
|
|
411
|
+
|
|
412
|
+
# Transaction
|
|
413
|
+
tx1 = store.begin_tx("self-test", session_key="test-session")
|
|
414
|
+
assert tx1 > 0, "begin_tx should return positive tx_id"
|
|
415
|
+
assert store.latest_tx() == tx1
|
|
416
|
+
|
|
417
|
+
# Assert triples
|
|
418
|
+
store.assert_triple(tx1, "signal:2026-03-01", "description", "OCR stall detected")
|
|
419
|
+
store.assert_triple(tx1, "signal:2026-03-01", "priority", "high")
|
|
420
|
+
store.assert_triple(tx1, "signal:2026-03-01", "related_to", "concept:ocr", value_type="ref")
|
|
421
|
+
store.assert_triple(tx1, "concept:ocr", "name", "OCR")
|
|
422
|
+
store.assert_triple(tx1, "pattern:frame-batching", "text", "Frame batching improves OCR")
|
|
423
|
+
store.assert_triple(tx1, "pattern:frame-batching", "related_to", "concept:ocr", value_type="ref")
|
|
424
|
+
|
|
425
|
+
# EAVT: entity view
|
|
426
|
+
ent = store.entity("signal:2026-03-01")
|
|
427
|
+
assert "description" in ent, f"entity missing 'description': {ent}"
|
|
428
|
+
assert ent["description"] == ["OCR stall detected"]
|
|
429
|
+
assert ent["priority"] == ["high"]
|
|
430
|
+
assert ent["related_to"] == ["concept:ocr"]
|
|
431
|
+
print(" [OK] EAVT: entity view")
|
|
432
|
+
|
|
433
|
+
# AEVT: attribute scan
|
|
434
|
+
with_desc = store.entities_with_attr("description")
|
|
435
|
+
assert ("signal:2026-03-01", "OCR stall detected") in with_desc
|
|
436
|
+
print(" [OK] AEVT: entities_with_attr")
|
|
437
|
+
|
|
438
|
+
# VAET: backrefs
|
|
439
|
+
refs = store.backrefs("concept:ocr")
|
|
440
|
+
entity_ids = [r[0] for r in refs]
|
|
441
|
+
assert "signal:2026-03-01" in entity_ids
|
|
442
|
+
assert "pattern:frame-batching" in entity_ids
|
|
443
|
+
print(" [OK] VAET: backrefs")
|
|
444
|
+
|
|
445
|
+
# AVET: lookup
|
|
446
|
+
found = store.lookup("name", "OCR")
|
|
447
|
+
assert "concept:ocr" in found
|
|
448
|
+
print(" [OK] AVET: lookup")
|
|
449
|
+
|
|
450
|
+
# BFS neighbors
|
|
451
|
+
nbrs = store.neighbors("concept:ocr", depth=1)
|
|
452
|
+
assert "concept:ocr" in nbrs
|
|
453
|
+
assert "signal:2026-03-01" in nbrs or "pattern:frame-batching" in nbrs
|
|
454
|
+
print(" [OK] BFS neighbors")
|
|
455
|
+
|
|
456
|
+
# Novelty
|
|
457
|
+
tx2 = store.begin_tx("self-test-2")
|
|
458
|
+
store.assert_triple(tx2, "concept:test", "name", "Test")
|
|
459
|
+
changes = store.novelty(tx1)
|
|
460
|
+
assert len(changes) >= 1
|
|
461
|
+
assert any(c["entity_id"] == "concept:test" for c in changes)
|
|
462
|
+
print(" [OK] Novelty feed")
|
|
463
|
+
|
|
464
|
+
# Retraction
|
|
465
|
+
count = store.retract_triple(tx2, "signal:2026-03-01", "priority")
|
|
466
|
+
assert count == 1
|
|
467
|
+
ent_after = store.entity("signal:2026-03-01")
|
|
468
|
+
assert "priority" not in ent_after
|
|
469
|
+
print(" [OK] Retraction")
|
|
470
|
+
|
|
471
|
+
# as_of_tx isolation
|
|
472
|
+
ent_before = store.entity("signal:2026-03-01", as_of_tx=tx1)
|
|
473
|
+
assert "priority" in ent_before, "as_of_tx should see pre-retraction state"
|
|
474
|
+
print(" [OK] as_of_tx isolation")
|
|
475
|
+
|
|
476
|
+
# GC (retracted triples are fresh, so gc with 0 days should get them)
|
|
477
|
+
gc_count = store.gc(older_than_days=0)
|
|
478
|
+
assert gc_count >= 1
|
|
479
|
+
print(" [OK] Garbage collection")
|
|
480
|
+
|
|
481
|
+
# Stats
|
|
482
|
+
s = store.stats()
|
|
483
|
+
assert s["triples"] >= 1
|
|
484
|
+
assert s["entities"] >= 1
|
|
485
|
+
assert s["transactions"] >= 2
|
|
486
|
+
assert s["db_size_bytes"] > 0
|
|
487
|
+
print(f" [OK] Stats: {s}")
|
|
488
|
+
|
|
489
|
+
store.close()
|
|
490
|
+
print("\n All self-tests passed!")
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
if __name__ == "__main__":
|
|
494
|
+
if "--self-test" in sys.argv:
|
|
495
|
+
print("Running triplestore self-test...")
|
|
496
|
+
_self_test()
|
|
497
|
+
else:
|
|
498
|
+
print("Usage: python3 triplestore.py --self-test")
|