@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.
Files changed (53) hide show
  1. package/README.md +183 -0
  2. package/index.ts +2096 -0
  3. package/install.js +155 -0
  4. package/openclaw.plugin.json +59 -0
  5. package/package.json +21 -0
  6. package/sinain-memory/common.py +403 -0
  7. package/sinain-memory/demo_knowledge_transfer.sh +85 -0
  8. package/sinain-memory/embedder.py +268 -0
  9. package/sinain-memory/eval/__init__.py +0 -0
  10. package/sinain-memory/eval/assertions.py +288 -0
  11. package/sinain-memory/eval/judges/__init__.py +0 -0
  12. package/sinain-memory/eval/judges/base_judge.py +61 -0
  13. package/sinain-memory/eval/judges/curation_judge.py +46 -0
  14. package/sinain-memory/eval/judges/insight_judge.py +48 -0
  15. package/sinain-memory/eval/judges/mining_judge.py +42 -0
  16. package/sinain-memory/eval/judges/signal_judge.py +45 -0
  17. package/sinain-memory/eval/schemas.py +247 -0
  18. package/sinain-memory/eval_delta.py +109 -0
  19. package/sinain-memory/eval_reporter.py +642 -0
  20. package/sinain-memory/feedback_analyzer.py +221 -0
  21. package/sinain-memory/git_backup.sh +19 -0
  22. package/sinain-memory/insight_synthesizer.py +181 -0
  23. package/sinain-memory/memory/2026-03-01.md +11 -0
  24. package/sinain-memory/memory/playbook-archive/sinain-playbook-2026-03-01-1418.md +15 -0
  25. package/sinain-memory/memory/playbook-logs/2026-03-01.jsonl +1 -0
  26. package/sinain-memory/memory/sinain-playbook.md +21 -0
  27. package/sinain-memory/memory-config.json +39 -0
  28. package/sinain-memory/memory_miner.py +183 -0
  29. package/sinain-memory/module_manager.py +695 -0
  30. package/sinain-memory/playbook_curator.py +225 -0
  31. package/sinain-memory/requirements.txt +3 -0
  32. package/sinain-memory/signal_analyzer.py +141 -0
  33. package/sinain-memory/test_local.py +402 -0
  34. package/sinain-memory/tests/__init__.py +0 -0
  35. package/sinain-memory/tests/conftest.py +189 -0
  36. package/sinain-memory/tests/test_curator_helpers.py +94 -0
  37. package/sinain-memory/tests/test_embedder.py +210 -0
  38. package/sinain-memory/tests/test_extract_json.py +124 -0
  39. package/sinain-memory/tests/test_feedback_computation.py +121 -0
  40. package/sinain-memory/tests/test_miner_helpers.py +71 -0
  41. package/sinain-memory/tests/test_module_management.py +458 -0
  42. package/sinain-memory/tests/test_parsers.py +96 -0
  43. package/sinain-memory/tests/test_tick_evaluator.py +430 -0
  44. package/sinain-memory/tests/test_triple_extractor.py +255 -0
  45. package/sinain-memory/tests/test_triple_ingest.py +191 -0
  46. package/sinain-memory/tests/test_triple_migrate.py +138 -0
  47. package/sinain-memory/tests/test_triplestore.py +248 -0
  48. package/sinain-memory/tick_evaluator.py +392 -0
  49. package/sinain-memory/triple_extractor.py +402 -0
  50. package/sinain-memory/triple_ingest.py +290 -0
  51. package/sinain-memory/triple_migrate.py +275 -0
  52. package/sinain-memory/triple_query.py +184 -0
  53. package/sinain-memory/triplestore.py +498 -0
@@ -0,0 +1,210 @@
1
+ """Tests for embedder.py — dual-strategy embeddings + vector search."""
2
+
3
+ import math
4
+ import struct
5
+ import pytest
6
+ from unittest.mock import patch, MagicMock
7
+
8
+ from triplestore import TripleStore
9
+ from embedder import Embedder, _vec_to_blob, _blob_to_vec, _text_hash, _dot, _norm
10
+
11
+
12
+ @pytest.fixture
13
+ def db_path(tmp_path):
14
+ return str(tmp_path / "test.db")
15
+
16
+
17
+ @pytest.fixture
18
+ def store(db_path):
19
+ s = TripleStore(db_path)
20
+ yield s
21
+ s.close()
22
+
23
+
24
+ @pytest.fixture
25
+ def embedder(db_path, store):
26
+ """Embedder with a pre-initialized store."""
27
+ e = Embedder(db_path)
28
+ yield e
29
+ e.close()
30
+
31
+
32
+ # ----- Utility functions -----
33
+
34
+ class TestVecConversion:
35
+ def test_roundtrip(self):
36
+ vec = [0.1, 0.2, 0.3, -0.5, 1.0]
37
+ blob = _vec_to_blob(vec)
38
+ recovered = _blob_to_vec(blob)
39
+ for a, b in zip(vec, recovered):
40
+ assert abs(a - b) < 1e-6
41
+
42
+ def test_empty_vec(self):
43
+ assert _vec_to_blob([]) == b""
44
+ assert _blob_to_vec(b"") == []
45
+
46
+
47
+ class TestTextHash:
48
+ def test_deterministic(self):
49
+ assert _text_hash("hello") == _text_hash("hello")
50
+
51
+ def test_different_texts(self):
52
+ assert _text_hash("hello") != _text_hash("world")
53
+
54
+ def test_length(self):
55
+ assert len(_text_hash("test")) == 16
56
+
57
+
58
+ class TestDotAndNorm:
59
+ def test_dot_product(self):
60
+ assert _dot([1, 2, 3], [4, 5, 6]) == 32
61
+
62
+ def test_norm(self):
63
+ assert abs(_norm([3, 4]) - 5.0) < 1e-6
64
+
65
+ def test_unit_vector_norm(self):
66
+ assert abs(_norm([1, 0, 0]) - 1.0) < 1e-6
67
+
68
+
69
+ # ----- Embedder with mocked API -----
70
+
71
+ def _mock_openrouter_response(texts):
72
+ """Create a mock OpenRouter embedding response."""
73
+ # Generate deterministic fake embeddings (10-dim for testing)
74
+ embeddings = []
75
+ for i, text in enumerate(texts):
76
+ vec = [(hash(text + str(j)) % 1000) / 1000.0 for j in range(10)]
77
+ embeddings.append({"index": i, "embedding": vec})
78
+ return MagicMock(
79
+ status_code=200,
80
+ json=lambda: {"data": embeddings},
81
+ raise_for_status=lambda: None,
82
+ )
83
+
84
+
85
+ class TestEmbedOpenRouter:
86
+ @patch.dict("os.environ", {"OPENROUTER_API_KEY": "test-key"})
87
+ @patch("requests.post")
88
+ def test_embed_calls_api(self, mock_post, embedder):
89
+ mock_post.return_value = _mock_openrouter_response(["hello"])
90
+ result = embedder.embed(["hello"])
91
+ assert len(result) == 1
92
+ assert len(result[0]) == 10
93
+ mock_post.assert_called_once()
94
+
95
+ @patch.dict("os.environ", {"OPENROUTER_API_KEY": "test-key"})
96
+ @patch("requests.post")
97
+ def test_embed_multiple(self, mock_post, embedder):
98
+ texts = ["hello", "world", "test"]
99
+ mock_post.return_value = _mock_openrouter_response(texts)
100
+ result = embedder.embed(texts)
101
+ assert len(result) == 3
102
+
103
+ def test_embed_empty(self, embedder):
104
+ assert embedder.embed([]) == []
105
+
106
+
107
+ class TestEmbedFallback:
108
+ @patch.dict("os.environ", {}, clear=True)
109
+ def test_no_api_key_tries_local(self, embedder):
110
+ """Without API key, should try local model (which may not be installed)."""
111
+ result = embedder.embed(["test"])
112
+ # Either local model works or returns empty vectors
113
+ assert isinstance(result, list)
114
+
115
+
116
+ # ----- Store embeddings -----
117
+
118
+ class TestStoreEmbeddings:
119
+ @patch.dict("os.environ", {"OPENROUTER_API_KEY": "test-key"})
120
+ @patch("requests.post")
121
+ def test_store_and_dedup(self, mock_post, embedder):
122
+ mock_post.return_value = _mock_openrouter_response(["pattern text"])
123
+ count1 = embedder.store_embeddings({"pattern:test": "pattern text"})
124
+ assert count1 == 1
125
+
126
+ # Same text → should skip
127
+ count2 = embedder.store_embeddings({"pattern:test": "pattern text"})
128
+ assert count2 == 0
129
+
130
+ @patch.dict("os.environ", {"OPENROUTER_API_KEY": "test-key"})
131
+ @patch("requests.post")
132
+ def test_store_update_on_text_change(self, mock_post, embedder):
133
+ mock_post.return_value = _mock_openrouter_response(["v1"])
134
+ embedder.store_embeddings({"pattern:test": "v1"})
135
+
136
+ mock_post.return_value = _mock_openrouter_response(["v2"])
137
+ count = embedder.store_embeddings({"pattern:test": "v2"})
138
+ assert count == 1 # re-embedded because text changed
139
+
140
+ def test_store_empty(self, embedder):
141
+ assert embedder.store_embeddings({}) == 0
142
+
143
+
144
+ # ----- Vector search -----
145
+
146
+ class TestVectorSearch:
147
+ @patch.dict("os.environ", {"OPENROUTER_API_KEY": "test-key"})
148
+ @patch("requests.post")
149
+ def test_search_returns_sorted(self, mock_post, embedder):
150
+ # Store some embeddings with known vectors
151
+ # We'll bypass embed() and insert directly
152
+ from embedder import _vec_to_blob, _now_iso
153
+ vecs = {
154
+ "pattern:a": [1.0, 0.0, 0.0],
155
+ "pattern:b": [0.0, 1.0, 0.0],
156
+ "pattern:c": [0.7, 0.7, 0.0], # closest to query [1,0,0] after normalization
157
+ }
158
+ for eid, vec in vecs.items():
159
+ embedder._conn.execute(
160
+ "INSERT INTO embeddings (entity_id, vector, text_hash, model, dimensions, created_at) "
161
+ "VALUES (?, ?, ?, ?, ?, ?)",
162
+ (eid, _vec_to_blob(vec), "hash", "test", len(vec), _now_iso()),
163
+ )
164
+ embedder._conn.commit()
165
+
166
+ results = embedder.vector_search([1.0, 0.0, 0.0], top_k=3)
167
+ assert len(results) == 3
168
+ # pattern:a should be first (exact match, cosine=1.0)
169
+ assert results[0][0] == "pattern:a"
170
+ assert abs(results[0][1] - 1.0) < 1e-6
171
+
172
+ def test_search_empty_db(self, embedder):
173
+ results = embedder.vector_search([1.0, 0.0], top_k=5)
174
+ assert results == []
175
+
176
+ def test_search_empty_query(self, embedder):
177
+ assert embedder.vector_search([], top_k=5) == []
178
+
179
+ @patch.dict("os.environ", {"OPENROUTER_API_KEY": "test-key"})
180
+ def test_search_with_type_filter(self, embedder):
181
+ from embedder import _vec_to_blob, _now_iso
182
+ for eid, vec in [
183
+ ("pattern:x", [1.0, 0.0]),
184
+ ("concept:y", [0.9, 0.1]),
185
+ ]:
186
+ embedder._conn.execute(
187
+ "INSERT INTO embeddings (entity_id, vector, text_hash, model, dimensions, created_at) "
188
+ "VALUES (?, ?, ?, ?, ?, ?)",
189
+ (eid, _vec_to_blob(vec), "hash", "test", 2, _now_iso()),
190
+ )
191
+ embedder._conn.commit()
192
+
193
+ results = embedder.vector_search([1.0, 0.0], top_k=5, entity_types=["pattern"])
194
+ assert len(results) == 1
195
+ assert results[0][0] == "pattern:x"
196
+
197
+
198
+ # ----- Schema -----
199
+
200
+ class TestEmbeddingsSchema:
201
+ def test_embeddings_table_exists(self, embedder):
202
+ rows = embedder._conn.execute(
203
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='embeddings'"
204
+ ).fetchall()
205
+ assert len(rows) == 1
206
+
207
+ def test_embeddings_columns(self, embedder):
208
+ info = embedder._conn.execute("PRAGMA table_info(embeddings)").fetchall()
209
+ col_names = {row["name"] for row in info}
210
+ assert col_names == {"entity_id", "vector", "text_hash", "model", "dimensions", "created_at"}
@@ -0,0 +1,124 @@
1
+ """Tests for common.extract_json() — all three extraction stages + truncation repair."""
2
+
3
+ import pytest
4
+ from common import extract_json
5
+
6
+
7
+ class TestStage1DirectParse:
8
+ def test_clean_object(self):
9
+ result = extract_json('{"signals": [], "idle": true}')
10
+ assert result["signals"] == []
11
+ assert result["idle"] is True
12
+
13
+ def test_clean_array(self):
14
+ result = extract_json('[{"a": 1}, {"b": 2}]')
15
+ assert len(result) == 2
16
+
17
+ def test_whitespace_padded(self):
18
+ result = extract_json(' \n {"key": "value"} \n ')
19
+ assert result["key"] == "value"
20
+
21
+ def test_unicode(self):
22
+ result = extract_json('{"msg": "привет мир"}')
23
+ assert result["msg"] == "привет мир"
24
+
25
+
26
+ class TestStage2MarkdownFences:
27
+ def test_fenced_json(self):
28
+ result = extract_json('```json\n{"signals": ["x"], "idle": false}\n```')
29
+ assert result["signals"] == ["x"]
30
+
31
+ def test_fenced_no_lang_tag(self):
32
+ result = extract_json('```\n{"findings": "test"}\n```')
33
+ assert result["findings"] == "test"
34
+
35
+ def test_text_before_fence(self):
36
+ result = extract_json('Here is the result:\n```json\n{"skip": true}\n```')
37
+ assert result["skip"] is True
38
+
39
+ def test_text_after_fence(self):
40
+ result = extract_json('```json\n{"skip": false}\n```\nHope this helps!')
41
+ assert result["skip"] is False
42
+
43
+ def test_text_before_and_after_fence(self):
44
+ result = extract_json(
45
+ 'I analyzed it.\n```json\n{"curateDirective": "normal"}\n```\nLet me know.'
46
+ )
47
+ assert result["curateDirective"] == "normal"
48
+
49
+
50
+ class TestStage3BalancedBrace:
51
+ def test_prose_then_json(self):
52
+ result = extract_json('The analysis result is: {"signals": ["a"], "idle": true}')
53
+ assert result["signals"] == ["a"]
54
+
55
+ def test_json_then_prose(self):
56
+ result = extract_json('{"findings": "test"} That is all.')
57
+ assert result["findings"] == "test"
58
+
59
+ def test_nested_braces(self):
60
+ result = extract_json('{"outer": {"inner": {"deep": 1}}, "key": "val"}')
61
+ assert result["outer"]["inner"]["deep"] == 1
62
+
63
+ def test_strings_with_braces(self):
64
+ result = extract_json('{"msg": "use {braces} like this", "ok": true}')
65
+ assert result["msg"] == "use {braces} like this"
66
+
67
+ def test_prose_embedded_array(self):
68
+ # Balanced-brace scanner tries {} before [], so it finds the first object
69
+ result = extract_json('Result: [{"a": 1}, {"b": 2}]')
70
+ assert isinstance(result, (dict, list))
71
+
72
+ def test_escaped_quotes_in_strings(self):
73
+ result = extract_json(r'{"msg": "he said \"hello\"", "ok": true}')
74
+ assert result["ok"] is True
75
+
76
+
77
+ class TestStage4TruncationRepair:
78
+ def test_missing_closing_brace(self):
79
+ result = extract_json('{"signals": ["a", "b"], "idle": true, "extra": "val')
80
+ assert result["signals"] == ["a", "b"]
81
+
82
+ def test_missing_two_closing_braces(self):
83
+ result = extract_json('{"outer": {"inner": "val"')
84
+ assert result["outer"]["inner"] == "val"
85
+
86
+ def test_truncated_array_in_object(self):
87
+ result = extract_json('{"items": [1, 2, 3')
88
+ assert result["items"] == [1, 2, 3]
89
+
90
+ def test_trailing_comma(self):
91
+ result = extract_json('{"a": 1, "b": 2,')
92
+ assert result["a"] == 1
93
+
94
+ def test_mid_key_truncation(self):
95
+ result = extract_json('{"valid": 1, "partial_ke')
96
+ assert result["valid"] == 1
97
+
98
+ def test_prose_plus_truncated(self):
99
+ result = extract_json(
100
+ 'Here is the result: {"findings": "some text", "patterns": ["p1"'
101
+ )
102
+ assert result["findings"] == "some text"
103
+
104
+ def test_truncated_simple_object(self):
105
+ result = extract_json('{"unclosed": "brace"')
106
+ assert result["unclosed"] == "brace"
107
+
108
+
109
+ class TestFailureCases:
110
+ def test_no_json_at_all(self):
111
+ with pytest.raises(ValueError):
112
+ extract_json("This is just plain text with no JSON.")
113
+
114
+ def test_empty_string(self):
115
+ with pytest.raises(ValueError):
116
+ extract_json("")
117
+
118
+ def test_no_brackets(self):
119
+ with pytest.raises(ValueError):
120
+ extract_json("just some random text without any brackets")
121
+
122
+ def test_only_whitespace(self):
123
+ with pytest.raises(ValueError):
124
+ extract_json(" \n\n ")
@@ -0,0 +1,121 @@
1
+ """Tests for feedback_analyzer.py: compute_effectiveness() and determine_directive()."""
2
+
3
+ from feedback_analyzer import compute_effectiveness, determine_directive, extract_feedback_scores
4
+
5
+
6
+ class TestComputeEffectiveness:
7
+ def test_no_logs(self):
8
+ result = compute_effectiveness([])
9
+ assert result == {"outputs": 0, "positive": 0, "negative": 0, "neutral": 0, "rate": 0.0}
10
+
11
+ def test_all_skipped(self):
12
+ logs = [{"ts": "2026-02-28T10:00:00Z", "skipped": True}] * 5
13
+ result = compute_effectiveness(logs)
14
+ assert result["outputs"] == 0
15
+
16
+ def test_positive_outcomes(self):
17
+ logs = [
18
+ {"ts": "2026-02-28T10:00:00Z", "skipped": False},
19
+ {"ts": "2026-02-28T10:30:00Z", "skipped": True, "feedbackScores": {"avg": 0.5}},
20
+ {"ts": "2026-02-28T11:00:00Z", "skipped": False},
21
+ {"ts": "2026-02-28T11:30:00Z", "skipped": True, "feedbackScores": {"avg": 0.6}},
22
+ ]
23
+ result = compute_effectiveness(logs)
24
+ assert result["outputs"] == 2
25
+ assert result["positive"] == 2
26
+ assert result["rate"] == 1.0
27
+
28
+ def test_negative_outcomes(self):
29
+ logs = [
30
+ {"ts": "2026-02-28T10:00:00Z", "skipped": False},
31
+ {"ts": "2026-02-28T10:30:00Z", "skipped": True, "feedbackScores": {"avg": -0.5}},
32
+ ]
33
+ result = compute_effectiveness(logs)
34
+ assert result["negative"] == 1
35
+
36
+ def test_neutral_for_last_tick(self):
37
+ """Last tick with output has no next tick for feedback — counted as neutral."""
38
+ logs = [{"ts": "2026-02-28T10:00:00Z", "skipped": False}]
39
+ result = compute_effectiveness(logs)
40
+ assert result["neutral"] == 1
41
+
42
+ def test_mixed_outcomes(self):
43
+ logs = [
44
+ {"ts": "2026-02-28T10:00:00Z", "skipped": False},
45
+ {"ts": "2026-02-28T10:30:00Z", "feedbackScores": {"avg": 0.5}}, # positive
46
+ {"ts": "2026-02-28T11:00:00Z", "skipped": False},
47
+ {"ts": "2026-02-28T11:30:00Z", "feedbackScores": {"avg": -0.3}}, # negative
48
+ {"ts": "2026-02-28T12:00:00Z", "skipped": False},
49
+ {"ts": "2026-02-28T12:30:00Z", "feedbackScores": {"avg": 0.05}}, # neutral
50
+ ]
51
+ result = compute_effectiveness(logs)
52
+ assert result["outputs"] == 3
53
+ assert result["positive"] == 1
54
+ assert result["negative"] == 1
55
+ assert result["neutral"] == 1
56
+ assert result["rate"] == round(1 / 3, 2)
57
+
58
+ def test_rate_is_rounded(self):
59
+ logs = [
60
+ {"ts": f"2026-02-28T1{i}:00:00Z", "skipped": False}
61
+ for i in range(3)
62
+ ] + [
63
+ {"ts": f"2026-02-28T1{i}:30:00Z", "feedbackScores": {"avg": 0.5}}
64
+ for i in range(3)
65
+ ]
66
+ result = compute_effectiveness(logs)
67
+ assert isinstance(result["rate"], float)
68
+
69
+
70
+ class TestDetermineDirective:
71
+ def test_insufficient_data(self):
72
+ assert determine_directive({"outputs": 3, "rate": 0.8}) == "insufficient_data"
73
+ assert determine_directive({"outputs": 0, "rate": 0.0}) == "insufficient_data"
74
+ assert determine_directive({"outputs": 4, "rate": 0.5}) == "insufficient_data"
75
+
76
+ def test_aggressive_prune(self):
77
+ assert determine_directive({"outputs": 10, "rate": 0.2}) == "aggressive_prune"
78
+ assert determine_directive({"outputs": 5, "rate": 0.39}) == "aggressive_prune"
79
+
80
+ def test_stability(self):
81
+ assert determine_directive({"outputs": 10, "rate": 0.8}) == "stability"
82
+ assert determine_directive({"outputs": 5, "rate": 0.71}) == "stability"
83
+
84
+ def test_normal(self):
85
+ assert determine_directive({"outputs": 10, "rate": 0.5}) == "normal"
86
+ assert determine_directive({"outputs": 5, "rate": 0.4}) == "normal"
87
+ assert determine_directive({"outputs": 5, "rate": 0.7}) == "normal"
88
+
89
+ def test_boundary_values(self):
90
+ # Exactly 5 outputs is enough data
91
+ assert determine_directive({"outputs": 5, "rate": 0.5}) != "insufficient_data"
92
+ # rate=0.4 is normal (not aggressive_prune)
93
+ assert determine_directive({"outputs": 5, "rate": 0.4}) == "normal"
94
+ # rate=0.7 is normal (not stability)
95
+ assert determine_directive({"outputs": 5, "rate": 0.7}) == "normal"
96
+
97
+
98
+ class TestExtractFeedbackScores:
99
+ def test_empty_logs(self):
100
+ result = extract_feedback_scores([])
101
+ assert result["avg"] == 0
102
+ assert result["high"] == []
103
+ assert result["low"] == []
104
+
105
+ def test_with_scores(self):
106
+ logs = [
107
+ {"feedbackScores": {"avg": 0.5, "high": ["good A"], "low": ["bad A"]}},
108
+ {"feedbackScores": {"avg": 0.3, "high": ["good B"], "low": []}},
109
+ ]
110
+ result = extract_feedback_scores(logs)
111
+ assert result["avg"] == 0.4
112
+ assert "good A" in result["high"]
113
+ assert "good B" in result["high"]
114
+
115
+ def test_limits_to_5_patterns(self):
116
+ logs = [
117
+ {"feedbackScores": {"avg": 0.1, "high": [f"pattern {i}"], "low": []}}
118
+ for i in range(10)
119
+ ]
120
+ result = extract_feedback_scores(logs)
121
+ assert len(result["high"]) <= 5
@@ -0,0 +1,71 @@
1
+ """Tests for memory_miner.py: get_unmined_files() and update_mining_index()."""
2
+
3
+ from pathlib import Path
4
+ from memory_miner import get_unmined_files, update_mining_index
5
+ from common import parse_mining_index
6
+
7
+
8
+ class TestGetUnminedFiles:
9
+ def test_all_mined(self, tmp_memory_dir):
10
+ memory_dir = str(tmp_memory_dir)
11
+ mined = ["2026-02-21", "2026-02-20", "2026-02-19"]
12
+ result = get_unmined_files(memory_dir, mined)
13
+ assert result == []
14
+
15
+ def test_some_unmined(self, tmp_memory_dir):
16
+ memory_dir = str(tmp_memory_dir)
17
+ mined = ["2026-02-21"]
18
+ result = get_unmined_files(memory_dir, mined)
19
+ basenames = [Path(f).stem for f in result]
20
+ assert "2026-02-20" in basenames
21
+ assert "2026-02-19" in basenames
22
+ assert "2026-02-21" not in basenames
23
+
24
+ def test_none_mined(self, tmp_memory_dir):
25
+ memory_dir = str(tmp_memory_dir)
26
+ result = get_unmined_files(memory_dir, [])
27
+ assert len(result) == 3 # all 3 daily files
28
+
29
+ def test_no_daily_files(self, tmp_path):
30
+ empty_memory = tmp_path / "empty-memory"
31
+ empty_memory.mkdir()
32
+ result = get_unmined_files(str(empty_memory), [])
33
+ assert result == []
34
+
35
+
36
+ class TestUpdateMiningIndex:
37
+ def test_adds_new_dates(self, tmp_memory_dir):
38
+ memory_dir = str(tmp_memory_dir)
39
+ playbook = (tmp_memory_dir / "sinain-playbook.md").read_text()
40
+ update_mining_index(memory_dir, playbook, ["2026-02-22"])
41
+
42
+ updated = (tmp_memory_dir / "sinain-playbook.md").read_text()
43
+ index = parse_mining_index(updated)
44
+ assert "2026-02-22" in index
45
+
46
+ def test_deduplicates(self, tmp_memory_dir):
47
+ memory_dir = str(tmp_memory_dir)
48
+ playbook = (tmp_memory_dir / "sinain-playbook.md").read_text()
49
+ # 2026-02-21 is already in index
50
+ update_mining_index(memory_dir, playbook, ["2026-02-21"])
51
+
52
+ updated = (tmp_memory_dir / "sinain-playbook.md").read_text()
53
+ index = parse_mining_index(updated)
54
+ assert index.count("2026-02-21") == 1
55
+
56
+ def test_creates_playbook_if_missing(self, tmp_path):
57
+ memory_dir = str(tmp_path)
58
+ update_mining_index(memory_dir, "", ["2026-02-25"])
59
+
60
+ playbook = (tmp_path / "sinain-playbook.md").read_text()
61
+ assert "mining-index:" in playbook
62
+ assert "2026-02-25" in playbook
63
+
64
+ def test_sorted_descending(self, tmp_memory_dir):
65
+ memory_dir = str(tmp_memory_dir)
66
+ playbook = (tmp_memory_dir / "sinain-playbook.md").read_text()
67
+ update_mining_index(memory_dir, playbook, ["2026-02-22", "2026-02-23"])
68
+
69
+ updated = (tmp_memory_dir / "sinain-playbook.md").read_text()
70
+ index = parse_mining_index(updated)
71
+ assert index == sorted(index, reverse=True)