@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,191 @@
|
|
|
1
|
+
"""Tests for triple_ingest.py — CLI entry point."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
from triplestore import TripleStore
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
KOOG_DIR = Path(__file__).resolve().parent.parent
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@pytest.fixture
|
|
16
|
+
def memory_dir(tmp_path):
|
|
17
|
+
"""Create a temporary memory directory with playbook."""
|
|
18
|
+
mem = tmp_path / "memory"
|
|
19
|
+
mem.mkdir()
|
|
20
|
+
(mem / "sinain-playbook.md").write_text(
|
|
21
|
+
"## Established Patterns\n"
|
|
22
|
+
"- OCR pipeline stalls when queue depth > 10 (score: 0.8)\n"
|
|
23
|
+
"- Use frame batching for throughput (score: 0.6)\n"
|
|
24
|
+
"- Spawn research agent proactively\n",
|
|
25
|
+
encoding="utf-8",
|
|
26
|
+
)
|
|
27
|
+
return str(mem)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@pytest.fixture
|
|
31
|
+
def modules_dir(tmp_path):
|
|
32
|
+
"""Create a temporary modules directory with a test module."""
|
|
33
|
+
modules = tmp_path / "modules"
|
|
34
|
+
modules.mkdir()
|
|
35
|
+
mod_dir = modules / "test-mod"
|
|
36
|
+
mod_dir.mkdir()
|
|
37
|
+
(mod_dir / "manifest.json").write_text(json.dumps({
|
|
38
|
+
"name": "Test Module",
|
|
39
|
+
"description": "Testing patterns",
|
|
40
|
+
"version": "1.0.0",
|
|
41
|
+
}))
|
|
42
|
+
(mod_dir / "patterns.md").write_text("## Patterns\n- Test pattern one\n- Test pattern two\n")
|
|
43
|
+
return str(modules)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class TestSignalIngest:
|
|
47
|
+
def test_signal_ingest_creates_db(self, memory_dir):
|
|
48
|
+
signal = json.dumps({
|
|
49
|
+
"signals": [{"description": "OCR stall", "priority": "high"}],
|
|
50
|
+
"output": {"suggestion": "Try batching"},
|
|
51
|
+
})
|
|
52
|
+
result = subprocess.run(
|
|
53
|
+
[sys.executable, str(KOOG_DIR / "triple_ingest.py"),
|
|
54
|
+
"--memory-dir", memory_dir,
|
|
55
|
+
"--signal-result", signal,
|
|
56
|
+
"--tick-ts", "2026-03-01T10:00:00Z"],
|
|
57
|
+
capture_output=True, text=True, timeout=30,
|
|
58
|
+
)
|
|
59
|
+
assert result.returncode == 0, f"stderr: {result.stderr}"
|
|
60
|
+
data = json.loads(result.stdout.strip())
|
|
61
|
+
assert data["ingested"] > 0
|
|
62
|
+
assert data["source"] == "signal"
|
|
63
|
+
assert "txId" in data
|
|
64
|
+
# DB should exist
|
|
65
|
+
assert Path(memory_dir, "triplestore.db").exists()
|
|
66
|
+
|
|
67
|
+
def test_signal_ingest_requires_tick_ts(self, memory_dir):
|
|
68
|
+
result = subprocess.run(
|
|
69
|
+
[sys.executable, str(KOOG_DIR / "triple_ingest.py"),
|
|
70
|
+
"--memory-dir", memory_dir,
|
|
71
|
+
"--signal-result", '{"signals":[]}'],
|
|
72
|
+
capture_output=True, text=True, timeout=10,
|
|
73
|
+
)
|
|
74
|
+
assert result.returncode != 0
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class TestPlaybookIngest:
|
|
78
|
+
def test_playbook_ingest(self, memory_dir):
|
|
79
|
+
result = subprocess.run(
|
|
80
|
+
[sys.executable, str(KOOG_DIR / "triple_ingest.py"),
|
|
81
|
+
"--memory-dir", memory_dir,
|
|
82
|
+
"--ingest-playbook"],
|
|
83
|
+
capture_output=True, text=True, timeout=30,
|
|
84
|
+
)
|
|
85
|
+
assert result.returncode == 0, f"stderr: {result.stderr}"
|
|
86
|
+
data = json.loads(result.stdout.strip())
|
|
87
|
+
assert data["ingested"] > 0
|
|
88
|
+
assert data["source"] == "playbook"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class TestSessionIngest:
|
|
92
|
+
def test_session_ingest(self, memory_dir):
|
|
93
|
+
session = json.dumps({
|
|
94
|
+
"ts": "2026-03-01T09:00:00Z",
|
|
95
|
+
"summary": "Debugging OCR pipeline issues",
|
|
96
|
+
"toolsUsed": ["Read", "Edit"],
|
|
97
|
+
})
|
|
98
|
+
result = subprocess.run(
|
|
99
|
+
[sys.executable, str(KOOG_DIR / "triple_ingest.py"),
|
|
100
|
+
"--memory-dir", memory_dir,
|
|
101
|
+
"--ingest-session", session],
|
|
102
|
+
capture_output=True, text=True, timeout=30,
|
|
103
|
+
)
|
|
104
|
+
assert result.returncode == 0, f"stderr: {result.stderr}"
|
|
105
|
+
data = json.loads(result.stdout.strip())
|
|
106
|
+
assert data["ingested"] > 0
|
|
107
|
+
assert data["source"] == "session"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class TestMiningIngest:
|
|
111
|
+
def test_mining_ingest(self, memory_dir):
|
|
112
|
+
mining = json.dumps({
|
|
113
|
+
"newPatterns": ["Frame dropping improves OCR"],
|
|
114
|
+
"preferences": ["User prefers minimal output"],
|
|
115
|
+
"contradictions": [],
|
|
116
|
+
})
|
|
117
|
+
result = subprocess.run(
|
|
118
|
+
[sys.executable, str(KOOG_DIR / "triple_ingest.py"),
|
|
119
|
+
"--memory-dir", memory_dir,
|
|
120
|
+
"--ingest-mining", mining],
|
|
121
|
+
capture_output=True, text=True, timeout=30,
|
|
122
|
+
)
|
|
123
|
+
assert result.returncode == 0, f"stderr: {result.stderr}"
|
|
124
|
+
data = json.loads(result.stdout.strip())
|
|
125
|
+
assert data["ingested"] > 0
|
|
126
|
+
assert data["source"] == "mining"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class TestModuleIngest:
|
|
130
|
+
def test_module_ingest(self, memory_dir, modules_dir):
|
|
131
|
+
result = subprocess.run(
|
|
132
|
+
[sys.executable, str(KOOG_DIR / "triple_ingest.py"),
|
|
133
|
+
"--memory-dir", memory_dir,
|
|
134
|
+
"--ingest-module", "test-mod",
|
|
135
|
+
"--modules-dir", modules_dir],
|
|
136
|
+
capture_output=True, text=True, timeout=30,
|
|
137
|
+
)
|
|
138
|
+
assert result.returncode == 0, f"stderr: {result.stderr}"
|
|
139
|
+
data = json.loads(result.stdout.strip())
|
|
140
|
+
assert data["ingested"] > 0
|
|
141
|
+
assert data["source"] == "module"
|
|
142
|
+
assert data["module"] == "test-mod"
|
|
143
|
+
|
|
144
|
+
def test_module_requires_modules_dir(self, memory_dir):
|
|
145
|
+
result = subprocess.run(
|
|
146
|
+
[sys.executable, str(KOOG_DIR / "triple_ingest.py"),
|
|
147
|
+
"--memory-dir", memory_dir,
|
|
148
|
+
"--ingest-module", "test-mod"],
|
|
149
|
+
capture_output=True, text=True, timeout=10,
|
|
150
|
+
)
|
|
151
|
+
assert result.returncode != 0
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class TestRetractModule:
|
|
155
|
+
def test_retract_module(self, memory_dir, modules_dir):
|
|
156
|
+
# First ingest
|
|
157
|
+
subprocess.run(
|
|
158
|
+
[sys.executable, str(KOOG_DIR / "triple_ingest.py"),
|
|
159
|
+
"--memory-dir", memory_dir,
|
|
160
|
+
"--ingest-module", "test-mod",
|
|
161
|
+
"--modules-dir", modules_dir],
|
|
162
|
+
capture_output=True, text=True, timeout=30,
|
|
163
|
+
)
|
|
164
|
+
# Then retract
|
|
165
|
+
result = subprocess.run(
|
|
166
|
+
[sys.executable, str(KOOG_DIR / "triple_ingest.py"),
|
|
167
|
+
"--memory-dir", memory_dir,
|
|
168
|
+
"--retract-module", "test-mod"],
|
|
169
|
+
capture_output=True, text=True, timeout=30,
|
|
170
|
+
)
|
|
171
|
+
assert result.returncode == 0, f"stderr: {result.stderr}"
|
|
172
|
+
data = json.loads(result.stdout.strip())
|
|
173
|
+
assert data["source"] == "module"
|
|
174
|
+
assert data["module"] == "test-mod"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class TestOutputFormat:
|
|
178
|
+
def test_output_is_valid_json(self, memory_dir):
|
|
179
|
+
signal = json.dumps({"signals": []})
|
|
180
|
+
result = subprocess.run(
|
|
181
|
+
[sys.executable, str(KOOG_DIR / "triple_ingest.py"),
|
|
182
|
+
"--memory-dir", memory_dir,
|
|
183
|
+
"--signal-result", signal,
|
|
184
|
+
"--tick-ts", "2026-03-01"],
|
|
185
|
+
capture_output=True, text=True, timeout=30,
|
|
186
|
+
)
|
|
187
|
+
assert result.returncode == 0
|
|
188
|
+
data = json.loads(result.stdout.strip())
|
|
189
|
+
assert isinstance(data, dict)
|
|
190
|
+
assert "ingested" in data
|
|
191
|
+
assert "source" in data
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Tests for triple_migrate.py — historical data migration to triple store."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
# Ensure sinain-koog source is importable
|
|
10
|
+
KOOG_DIR = Path(__file__).resolve().parent.parent
|
|
11
|
+
if str(KOOG_DIR) not in sys.path:
|
|
12
|
+
sys.path.insert(0, str(KOOG_DIR))
|
|
13
|
+
|
|
14
|
+
from triple_extractor import TripleExtractor
|
|
15
|
+
from triple_migrate import (
|
|
16
|
+
MIGRATION_ENTITY,
|
|
17
|
+
migrate_daily_memories,
|
|
18
|
+
migrate_modules,
|
|
19
|
+
migrate_playbook,
|
|
20
|
+
migrate_playbook_logs,
|
|
21
|
+
)
|
|
22
|
+
from triplestore import TripleStore
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.fixture
|
|
26
|
+
def store(tmp_path):
|
|
27
|
+
db = tmp_path / "triplestore.db"
|
|
28
|
+
return TripleStore(str(db))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@pytest.fixture
|
|
32
|
+
def extractor(store):
|
|
33
|
+
return TripleExtractor(store)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TestMigratePlaybook:
|
|
37
|
+
def test_extracts_patterns(self, extractor, store, tmp_memory_dir):
|
|
38
|
+
count = migrate_playbook(extractor, store, str(tmp_memory_dir))
|
|
39
|
+
assert count > 0
|
|
40
|
+
# Should find at least the OCR and research-agent patterns
|
|
41
|
+
patterns = store.entities_with_attr("text")
|
|
42
|
+
pattern_ids = [eid for eid, _ in patterns if eid.startswith("pattern:")]
|
|
43
|
+
assert len(pattern_ids) >= 2
|
|
44
|
+
|
|
45
|
+
def test_empty_playbook_returns_zero(self, extractor, store, tmp_path):
|
|
46
|
+
memory = tmp_path / "empty_memory"
|
|
47
|
+
memory.mkdir()
|
|
48
|
+
count = migrate_playbook(extractor, store, str(memory))
|
|
49
|
+
assert count == 0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class TestMigrateModules:
|
|
53
|
+
def test_active_only(self, extractor, store, tmp_modules_dir):
|
|
54
|
+
mod_count, triple_count = migrate_modules(extractor, store, str(tmp_modules_dir))
|
|
55
|
+
# Only react-native-dev is active; ocr-pipeline is suspended
|
|
56
|
+
assert mod_count == 1
|
|
57
|
+
assert triple_count > 0
|
|
58
|
+
# Module entity should exist
|
|
59
|
+
ent = store.entity("module:react-native-dev")
|
|
60
|
+
assert "name" in ent
|
|
61
|
+
assert ent["name"] == ["React Native Development"]
|
|
62
|
+
|
|
63
|
+
def test_no_registry(self, extractor, store, tmp_path):
|
|
64
|
+
mod_count, triple_count = migrate_modules(extractor, store, str(tmp_path / "nope"))
|
|
65
|
+
assert mod_count == 0
|
|
66
|
+
assert triple_count == 0
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class TestMigratePlaybookLogs:
|
|
70
|
+
def test_skips_idle_no_signals(self, extractor, store, tmp_memory_dir):
|
|
71
|
+
"""Idle entries with empty signals should be skipped."""
|
|
72
|
+
file_count, triple_count = migrate_playbook_logs(
|
|
73
|
+
extractor, store, str(tmp_memory_dir)
|
|
74
|
+
)
|
|
75
|
+
assert file_count >= 1
|
|
76
|
+
assert triple_count > 0
|
|
77
|
+
# The second entry in conftest is idle=True + signals=[], should be skipped
|
|
78
|
+
# First entry has ts=2026-02-28T10:00:00Z — should be ingested
|
|
79
|
+
ent = store.entity("signal:2026-02-28T10:00:00Z")
|
|
80
|
+
assert ent # non-idle entry should exist
|
|
81
|
+
# Idle entry should NOT exist
|
|
82
|
+
idle_ent = store.entity("signal:2026-02-28T10:30:00Z")
|
|
83
|
+
assert not idle_ent
|
|
84
|
+
|
|
85
|
+
def test_no_log_dir(self, extractor, store, tmp_path):
|
|
86
|
+
memory = tmp_path / "empty"
|
|
87
|
+
memory.mkdir()
|
|
88
|
+
file_count, triple_count = migrate_playbook_logs(extractor, store, str(memory))
|
|
89
|
+
assert file_count == 0
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class TestMigrateDailyMemories:
|
|
93
|
+
def test_creates_observation_entities(self, extractor, store, tmp_memory_dir):
|
|
94
|
+
file_count, triple_count = migrate_daily_memories(
|
|
95
|
+
extractor, store, str(tmp_memory_dir)
|
|
96
|
+
)
|
|
97
|
+
# conftest creates 3 daily memory files
|
|
98
|
+
assert file_count == 3
|
|
99
|
+
assert triple_count > 0
|
|
100
|
+
# Check one observation entity
|
|
101
|
+
ent = store.entity("observation:2026-02-21")
|
|
102
|
+
assert "text" in ent
|
|
103
|
+
assert "source" in ent
|
|
104
|
+
assert ent["source"] == ["daily_memory"]
|
|
105
|
+
|
|
106
|
+
def test_truncates_long_text(self, extractor, store, tmp_path):
|
|
107
|
+
memory = tmp_path / "memory"
|
|
108
|
+
memory.mkdir()
|
|
109
|
+
(memory / "2026-01-01.md").write_text("x" * 5000, encoding="utf-8")
|
|
110
|
+
migrate_daily_memories(extractor, store, str(memory))
|
|
111
|
+
ent = store.entity("observation:2026-01-01")
|
|
112
|
+
assert len(ent["text"][0]) == 2000
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class TestIdempotency:
|
|
116
|
+
def test_stamp_prevents_remigration(self, store, tmp_path):
|
|
117
|
+
"""Once migration:v1 exists, the script should be a no-op."""
|
|
118
|
+
tx = store.begin_tx("test")
|
|
119
|
+
store.assert_triple(tx, MIGRATION_ENTITY, "completed_at", "2026-03-05T00:00:00Z")
|
|
120
|
+
existing = store.entity(MIGRATION_ENTITY)
|
|
121
|
+
assert existing # guard entity exists
|
|
122
|
+
|
|
123
|
+
def test_full_migration_stamps(self, extractor, store, tmp_memory_dir, tmp_modules_dir):
|
|
124
|
+
"""Full migration should create the stamp entity."""
|
|
125
|
+
migrate_playbook(extractor, store, str(tmp_memory_dir))
|
|
126
|
+
migrate_modules(extractor, store, str(tmp_modules_dir))
|
|
127
|
+
migrate_playbook_logs(extractor, store, str(tmp_memory_dir))
|
|
128
|
+
migrate_daily_memories(extractor, store, str(tmp_memory_dir))
|
|
129
|
+
|
|
130
|
+
# Simulate stamping
|
|
131
|
+
stats = store.stats()
|
|
132
|
+
stamp_tx = store.begin_tx("migration:stamp")
|
|
133
|
+
store.assert_triple(stamp_tx, MIGRATION_ENTITY, "completed_at", "2026-03-05T00:00:00Z")
|
|
134
|
+
store.assert_triple(stamp_tx, MIGRATION_ENTITY, "total_triples", str(stats["triples"]))
|
|
135
|
+
|
|
136
|
+
ent = store.entity(MIGRATION_ENTITY)
|
|
137
|
+
assert "completed_at" in ent
|
|
138
|
+
assert int(ent["total_triples"][0]) > 0
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""Tests for triplestore.py — EAV triple store."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import pytest
|
|
5
|
+
from triplestore import TripleStore
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@pytest.fixture
|
|
9
|
+
def store(tmp_path):
|
|
10
|
+
"""Create a fresh triple store in a temp directory."""
|
|
11
|
+
db_path = tmp_path / "test.db"
|
|
12
|
+
s = TripleStore(str(db_path))
|
|
13
|
+
yield s
|
|
14
|
+
s.close()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.fixture
|
|
18
|
+
def populated_store(store):
|
|
19
|
+
"""Store with sample data across two transactions."""
|
|
20
|
+
tx1 = store.begin_tx("test", session_key="sess-1")
|
|
21
|
+
store.assert_triple(tx1, "signal:2026-03-01T10:00", "description", "OCR stall")
|
|
22
|
+
store.assert_triple(tx1, "signal:2026-03-01T10:00", "priority", "high")
|
|
23
|
+
store.assert_triple(tx1, "signal:2026-03-01T10:00", "related_to", "concept:ocr", "ref")
|
|
24
|
+
store.assert_triple(tx1, "concept:ocr", "name", "OCR")
|
|
25
|
+
store.assert_triple(tx1, "pattern:frame-batch", "text", "Frame batching improves OCR")
|
|
26
|
+
store.assert_triple(tx1, "pattern:frame-batch", "related_to", "concept:ocr", "ref")
|
|
27
|
+
store.assert_triple(tx1, "session:2026-03-01T09:00", "summary", "Debugging OCR pipeline")
|
|
28
|
+
store.assert_triple(tx1, "session:2026-03-01T09:00", "related_to", "concept:ocr", "ref")
|
|
29
|
+
store._tx1 = tx1 # stash for tests
|
|
30
|
+
return store
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class TestTransactions:
|
|
34
|
+
def test_begin_tx_returns_positive_id(self, store):
|
|
35
|
+
tx = store.begin_tx("test")
|
|
36
|
+
assert tx > 0
|
|
37
|
+
|
|
38
|
+
def test_latest_tx_empty_store(self, tmp_path):
|
|
39
|
+
s = TripleStore(str(tmp_path / "empty.db"))
|
|
40
|
+
assert s.latest_tx() == 0
|
|
41
|
+
s.close()
|
|
42
|
+
|
|
43
|
+
def test_latest_tx_after_writes(self, store):
|
|
44
|
+
tx1 = store.begin_tx("a")
|
|
45
|
+
tx2 = store.begin_tx("b")
|
|
46
|
+
assert store.latest_tx() == tx2
|
|
47
|
+
assert tx2 > tx1
|
|
48
|
+
|
|
49
|
+
def test_tx_metadata(self, store):
|
|
50
|
+
tx = store.begin_tx("test", metadata={"foo": "bar"})
|
|
51
|
+
row = store._conn.execute(
|
|
52
|
+
"SELECT metadata FROM transactions WHERE tx_id = ?", (tx,)
|
|
53
|
+
).fetchone()
|
|
54
|
+
import json
|
|
55
|
+
assert json.loads(row["metadata"]) == {"foo": "bar"}
|
|
56
|
+
|
|
57
|
+
def test_tx_parent(self, store):
|
|
58
|
+
tx1 = store.begin_tx("parent")
|
|
59
|
+
tx2 = store.begin_tx("child", parent_tx=tx1)
|
|
60
|
+
row = store._conn.execute(
|
|
61
|
+
"SELECT parent_tx FROM transactions WHERE tx_id = ?", (tx2,)
|
|
62
|
+
).fetchone()
|
|
63
|
+
assert row["parent_tx"] == tx1
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class TestAssertAndEntity:
|
|
67
|
+
def test_assert_returns_id(self, store):
|
|
68
|
+
tx = store.begin_tx("test")
|
|
69
|
+
tid = store.assert_triple(tx, "e:1", "name", "Test")
|
|
70
|
+
assert tid > 0
|
|
71
|
+
|
|
72
|
+
def test_entity_returns_all_attrs(self, populated_store):
|
|
73
|
+
ent = populated_store.entity("signal:2026-03-01T10:00")
|
|
74
|
+
assert ent["description"] == ["OCR stall"]
|
|
75
|
+
assert ent["priority"] == ["high"]
|
|
76
|
+
assert ent["related_to"] == ["concept:ocr"]
|
|
77
|
+
|
|
78
|
+
def test_entity_missing_returns_empty(self, store):
|
|
79
|
+
assert store.entity("nonexistent:1") == {}
|
|
80
|
+
|
|
81
|
+
def test_multiple_values_per_attr(self, store):
|
|
82
|
+
tx = store.begin_tx("test")
|
|
83
|
+
store.assert_triple(tx, "e:1", "tag", "alpha")
|
|
84
|
+
store.assert_triple(tx, "e:1", "tag", "beta")
|
|
85
|
+
ent = store.entity("e:1")
|
|
86
|
+
assert set(ent["tag"]) == {"alpha", "beta"}
|
|
87
|
+
|
|
88
|
+
def test_entity_type_auto_populated(self, store):
|
|
89
|
+
tx = store.begin_tx("test")
|
|
90
|
+
store.assert_triple(tx, "signal:abc", "x", "y")
|
|
91
|
+
row = store._conn.execute(
|
|
92
|
+
"SELECT entity_type FROM entity_types WHERE entity_id = ?",
|
|
93
|
+
("signal:abc",),
|
|
94
|
+
).fetchone()
|
|
95
|
+
assert row["entity_type"] == "signal"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class TestRetraction:
|
|
99
|
+
def test_retract_by_attr(self, populated_store):
|
|
100
|
+
tx2 = populated_store.begin_tx("retract")
|
|
101
|
+
count = populated_store.retract_triple(tx2, "signal:2026-03-01T10:00", "priority")
|
|
102
|
+
assert count == 1
|
|
103
|
+
ent = populated_store.entity("signal:2026-03-01T10:00")
|
|
104
|
+
assert "priority" not in ent
|
|
105
|
+
|
|
106
|
+
def test_retract_by_attr_and_value(self, store):
|
|
107
|
+
tx = store.begin_tx("test")
|
|
108
|
+
store.assert_triple(tx, "e:1", "tag", "a")
|
|
109
|
+
store.assert_triple(tx, "e:1", "tag", "b")
|
|
110
|
+
tx2 = store.begin_tx("retract")
|
|
111
|
+
count = store.retract_triple(tx2, "e:1", "tag", "a")
|
|
112
|
+
assert count == 1
|
|
113
|
+
ent = store.entity("e:1")
|
|
114
|
+
assert ent["tag"] == ["b"]
|
|
115
|
+
|
|
116
|
+
def test_retract_nonexistent_returns_zero(self, store):
|
|
117
|
+
tx = store.begin_tx("test")
|
|
118
|
+
count = store.retract_triple(tx, "e:nope", "attr")
|
|
119
|
+
assert count == 0
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class TestEAVTAsOfTx:
|
|
123
|
+
def test_as_of_tx_sees_old_state(self, populated_store):
|
|
124
|
+
tx1 = populated_store._tx1
|
|
125
|
+
tx2 = populated_store.begin_tx("change")
|
|
126
|
+
populated_store.retract_triple(tx2, "signal:2026-03-01T10:00", "priority")
|
|
127
|
+
|
|
128
|
+
# Current state: no priority
|
|
129
|
+
assert "priority" not in populated_store.entity("signal:2026-03-01T10:00")
|
|
130
|
+
# as_of tx1: has priority
|
|
131
|
+
assert "priority" in populated_store.entity("signal:2026-03-01T10:00", as_of_tx=tx1)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class TestAEVT:
|
|
135
|
+
def test_entities_with_attr(self, populated_store):
|
|
136
|
+
results = populated_store.entities_with_attr("name")
|
|
137
|
+
assert ("concept:ocr", "OCR") in results
|
|
138
|
+
|
|
139
|
+
def test_entities_with_attr_multiple(self, populated_store):
|
|
140
|
+
results = populated_store.entities_with_attr("related_to")
|
|
141
|
+
eids = [r[0] for r in results]
|
|
142
|
+
assert "signal:2026-03-01T10:00" in eids
|
|
143
|
+
assert "pattern:frame-batch" in eids
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class TestVAET:
|
|
147
|
+
def test_backrefs(self, populated_store):
|
|
148
|
+
refs = populated_store.backrefs("concept:ocr")
|
|
149
|
+
eids = [r[0] for r in refs]
|
|
150
|
+
assert "signal:2026-03-01T10:00" in eids
|
|
151
|
+
assert "pattern:frame-batch" in eids
|
|
152
|
+
assert "session:2026-03-01T09:00" in eids
|
|
153
|
+
|
|
154
|
+
def test_backrefs_with_attribute_filter(self, populated_store):
|
|
155
|
+
refs = populated_store.backrefs("concept:ocr", attribute="related_to")
|
|
156
|
+
eids = [r[0] for r in refs]
|
|
157
|
+
assert "signal:2026-03-01T10:00" in eids
|
|
158
|
+
|
|
159
|
+
def test_backrefs_no_results(self, store):
|
|
160
|
+
assert store.backrefs("nonexistent:1") == []
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class TestAVET:
|
|
164
|
+
def test_lookup(self, populated_store):
|
|
165
|
+
found = populated_store.lookup("name", "OCR")
|
|
166
|
+
assert "concept:ocr" in found
|
|
167
|
+
|
|
168
|
+
def test_lookup_no_match(self, populated_store):
|
|
169
|
+
found = populated_store.lookup("name", "nonexistent")
|
|
170
|
+
assert found == []
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class TestNeighbors:
|
|
174
|
+
def test_neighbors_depth_1(self, populated_store):
|
|
175
|
+
nbrs = populated_store.neighbors("concept:ocr", depth=1)
|
|
176
|
+
assert "concept:ocr" in nbrs
|
|
177
|
+
# Should find signal, pattern, and session via backrefs
|
|
178
|
+
found_eids = set(nbrs.keys())
|
|
179
|
+
assert "signal:2026-03-01T10:00" in found_eids or "pattern:frame-batch" in found_eids
|
|
180
|
+
|
|
181
|
+
def test_neighbors_depth_0(self, populated_store):
|
|
182
|
+
nbrs = populated_store.neighbors("concept:ocr", depth=0)
|
|
183
|
+
assert "concept:ocr" in nbrs
|
|
184
|
+
assert len(nbrs) == 1 # only the entity itself
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class TestNovelty:
|
|
188
|
+
def test_novelty_after_tx(self, populated_store):
|
|
189
|
+
tx1 = populated_store._tx1
|
|
190
|
+
tx2 = populated_store.begin_tx("new")
|
|
191
|
+
populated_store.assert_triple(tx2, "concept:new", "name", "New Concept")
|
|
192
|
+
changes = populated_store.novelty(tx1)
|
|
193
|
+
assert len(changes) >= 1
|
|
194
|
+
assert any(c["entity_id"] == "concept:new" for c in changes)
|
|
195
|
+
|
|
196
|
+
def test_novelty_bounded(self, populated_store):
|
|
197
|
+
tx1 = populated_store._tx1
|
|
198
|
+
tx2 = populated_store.begin_tx("a")
|
|
199
|
+
populated_store.assert_triple(tx2, "concept:a", "name", "A")
|
|
200
|
+
tx3 = populated_store.begin_tx("b")
|
|
201
|
+
populated_store.assert_triple(tx3, "concept:b", "name", "B")
|
|
202
|
+
# Only changes between tx1 and tx2
|
|
203
|
+
changes = populated_store.novelty(tx1, until_tx=tx2)
|
|
204
|
+
eids = [c["entity_id"] for c in changes]
|
|
205
|
+
assert "concept:a" in eids
|
|
206
|
+
assert "concept:b" not in eids
|
|
207
|
+
|
|
208
|
+
def test_novelty_empty(self, store):
|
|
209
|
+
tx = store.begin_tx("test")
|
|
210
|
+
assert store.novelty(tx) == []
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class TestGC:
|
|
214
|
+
def test_gc_deletes_old_retracted(self, store):
|
|
215
|
+
tx = store.begin_tx("test")
|
|
216
|
+
store.assert_triple(tx, "e:1", "x", "y")
|
|
217
|
+
tx2 = store.begin_tx("retract")
|
|
218
|
+
store.retract_triple(tx2, "e:1", "x")
|
|
219
|
+
# GC with 0 days should delete it
|
|
220
|
+
count = store.gc(older_than_days=0)
|
|
221
|
+
assert count >= 1
|
|
222
|
+
|
|
223
|
+
def test_gc_preserves_active(self, store):
|
|
224
|
+
tx = store.begin_tx("test")
|
|
225
|
+
store.assert_triple(tx, "e:1", "x", "y")
|
|
226
|
+
count = store.gc(older_than_days=0)
|
|
227
|
+
assert count == 0 # not retracted, shouldn't be deleted
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class TestStats:
|
|
231
|
+
def test_stats_populated(self, populated_store):
|
|
232
|
+
s = populated_store.stats()
|
|
233
|
+
assert s["triples"] >= 8
|
|
234
|
+
assert s["entities"] >= 4
|
|
235
|
+
assert s["transactions"] >= 1
|
|
236
|
+
assert s["db_size_bytes"] > 0
|
|
237
|
+
|
|
238
|
+
def test_stats_empty(self, store):
|
|
239
|
+
s = store.stats()
|
|
240
|
+
assert s["triples"] == 0
|
|
241
|
+
assert s["entities"] == 0
|
|
242
|
+
assert s["transactions"] == 0
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class TestWALMode:
|
|
246
|
+
def test_wal_mode_enabled(self, store):
|
|
247
|
+
mode = store._conn.execute("PRAGMA journal_mode").fetchone()[0]
|
|
248
|
+
assert mode == "wal"
|