gabriel / muse public
test_knowtation_note_metrics.py python
472 lines 17.0 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
1 """Tests for Phase 3.3 — Knowtation note-metrics helpers.
2
3 Covers the 6 helper functions feeding muse code hotspots / gravity / dead /
4 clones / entangle / velocity for the knowtation domain.
5
6 Test tiers (Rule #0):
7 1. Unit — each helper, isolated, with synthetic commit history
8 2. Integration — multi-helper consistency (e.g. hotspots ∪ velocity == manifest changes)
9 3. Data-integrity — deterministic JSON output across runs
10 4. Performance — each helper completes < 2s on 5k-note synthetic input
11 5. Security — malformed manifests, missing snapshots, oversize input handling
12 """
13
14 from __future__ import annotations
15
16 import json
17 import pathlib
18 import time
19 from dataclasses import dataclass
20
21 import pytest
22
23 from muse.plugins.knowtation.link_index import build_link_index
24 from muse.plugins.knowtation.note_metrics import (
25 note_clones,
26 note_dead,
27 note_entangle,
28 note_gravity,
29 note_hotspots,
30 note_velocity,
31 )
32
33
34 # ============================================================================
35 # Helpers
36 # ============================================================================
37
38
39 @dataclass
40 class _FakeCommit:
41 """Minimal CommitRecord-like shape for testing."""
42 commit_id: str
43 committed_at: int
44
45
46 def _make_vault(tmp_path: pathlib.Path, files: dict[str, bytes]) -> pathlib.Path:
47 for rel, content in files.items():
48 full = tmp_path / rel
49 full.parent.mkdir(parents=True, exist_ok=True)
50 full.write_bytes(content)
51 return tmp_path
52
53
54 def _fake_loader(snapshots: dict[str, dict[str, str]]):
55 """Build a snapshot_loader closure from a dict of commit_id → manifest."""
56 def loader(commit_id: str) -> dict[str, str]:
57 return snapshots.get(commit_id, {})
58 return loader
59
60
61 # ============================================================================
62 # TestNoteHotspots
63 # ============================================================================
64
65
66 class TestNoteHotspots:
67 def test_empty_commits(self) -> None:
68 result = note_hotspots([], _fake_loader({}))
69 assert result["commits_analysed"] == 0
70 assert result["ranking"] == []
71
72 def test_single_commit_no_diff(self) -> None:
73 commits = [_FakeCommit("c1", 100)]
74 loader = _fake_loader({"c1": {"a.md": "h1"}})
75 result = note_hotspots(commits, loader)
76 # Only one commit → no diff pair → no changes counted
77 assert result["ranking"] == []
78
79 def test_two_commits_one_change(self) -> None:
80 commits = [_FakeCommit("c2", 200), _FakeCommit("c1", 100)]
81 loader = _fake_loader({
82 "c1": {"a.md": "h1"},
83 "c2": {"a.md": "h2"},
84 })
85 result = note_hotspots(commits, loader)
86 assert result["ranking"] == [{"path": "a.md", "changes": 1}]
87
88 def test_three_commits_ranking(self) -> None:
89 commits = [
90 _FakeCommit("c3", 300),
91 _FakeCommit("c2", 200),
92 _FakeCommit("c1", 100),
93 ]
94 loader = _fake_loader({
95 "c1": {"a.md": "h1", "b.md": "h_b"},
96 "c2": {"a.md": "h2", "b.md": "h_b"},
97 "c3": {"a.md": "h3", "b.md": "h_b2"},
98 })
99 result = note_hotspots(commits, loader)
100 ranking = {r["path"]: r["changes"] for r in result["ranking"]}
101 assert ranking["a.md"] == 2 # changed c1→c2 and c2→c3
102 assert ranking["b.md"] == 1 # changed only c2→c3
103
104 def test_min_changes_filter(self) -> None:
105 commits = [
106 _FakeCommit("c3", 300),
107 _FakeCommit("c2", 200),
108 _FakeCommit("c1", 100),
109 ]
110 loader = _fake_loader({
111 "c1": {"a.md": "h1"},
112 "c2": {"a.md": "h2"},
113 "c3": {"a.md": "h3"},
114 })
115 result = note_hotspots(commits, loader, min_changes=3)
116 # a.md only changed twice → filtered out
117 assert result["ranking"] == []
118
119 def test_project_filter(self) -> None:
120 commits = [_FakeCommit("c2", 200), _FakeCommit("c1", 100)]
121 loader = _fake_loader({
122 "c1": {"projects/x/a.md": "h1", "inbox/b.md": "h_b"},
123 "c2": {"projects/x/a.md": "h2", "inbox/b.md": "h_b2"},
124 })
125 result = note_hotspots(commits, loader, project_filter="projects/")
126 paths = [r["path"] for r in result["ranking"]]
127 assert paths == ["projects/x/a.md"]
128
129 def test_non_md_files_ignored(self) -> None:
130 commits = [_FakeCommit("c2", 200), _FakeCommit("c1", 100)]
131 loader = _fake_loader({
132 "c1": {"a.md": "h1", "config.yaml": "h_y"},
133 "c2": {"a.md": "h2", "config.yaml": "h_y2"},
134 })
135 result = note_hotspots(commits, loader)
136 paths = [r["path"] for r in result["ranking"]]
137 assert "config.yaml" not in paths
138 assert "a.md" in paths
139
140 def test_added_note_counted(self) -> None:
141 commits = [_FakeCommit("c2", 200), _FakeCommit("c1", 100)]
142 loader = _fake_loader({
143 "c1": {},
144 "c2": {"new.md": "h_new"},
145 })
146 result = note_hotspots(commits, loader)
147 paths = [r["path"] for r in result["ranking"]]
148 assert "new.md" in paths
149
150 def test_removed_note_counted(self) -> None:
151 commits = [_FakeCommit("c2", 200), _FakeCommit("c1", 100)]
152 loader = _fake_loader({
153 "c1": {"gone.md": "h"},
154 "c2": {},
155 })
156 result = note_hotspots(commits, loader)
157 paths = [r["path"] for r in result["ranking"]]
158 assert "gone.md" in paths
159
160
161 # ============================================================================
162 # TestNoteGravity
163 # ============================================================================
164
165
166 class TestNoteGravity:
167 def test_empty_vault(self, tmp_path: pathlib.Path) -> None:
168 idx = build_link_index(tmp_path)
169 result = note_gravity(idx)
170 assert result["ranking"] == []
171
172 def test_hub_note_ranks_high(self, tmp_path: pathlib.Path) -> None:
173 _make_vault(tmp_path, {
174 "a.md": b"[[Hub]]",
175 "b.md": b"[[Hub]]",
176 "c.md": b"[[Hub]]",
177 "Hub.md": b"# Hub",
178 })
179 idx = build_link_index(tmp_path)
180 result = note_gravity(idx)
181 assert result["ranking"][0]["path"] == "Hub.md"
182 assert result["ranking"][0]["inbound"] == 3
183 assert 0 < result["ranking"][0]["gravity"] <= 1.0
184
185 def test_gravity_within_zero_one(self, tmp_path: pathlib.Path) -> None:
186 _make_vault(tmp_path, {
187 "a.md": b"[[B]]",
188 "b.md": b"# B",
189 })
190 idx = build_link_index(tmp_path)
191 result = note_gravity(idx)
192 for entry in result["ranking"]:
193 assert 0 <= entry["gravity"] <= 1.0
194
195 def test_top_limit(self, tmp_path: pathlib.Path) -> None:
196 files: dict[str, bytes] = {}
197 for i in range(10):
198 files[f"note_{i}.md"] = f"[[hub]]".encode()
199 files["hub.md"] = b"# Hub"
200 _make_vault(tmp_path, files)
201 idx = build_link_index(tmp_path)
202 result = note_gravity(idx, top=3)
203 assert len(result["ranking"]) <= 3
204
205
206 # ============================================================================
207 # TestNoteDead
208 # ============================================================================
209
210
211 class TestNoteDead:
212 def test_isolated_note_is_dead(self, tmp_path: pathlib.Path) -> None:
213 _make_vault(tmp_path, {
214 "alone.md": b"# Alone",
215 "other.md": b"# Other",
216 })
217 idx = build_link_index(tmp_path)
218 result = note_dead(idx, root=tmp_path, use_mtime=False)
219 paths = {n["path"] for n in result["dead_notes"]}
220 assert paths == {"alone.md", "other.md"}
221
222 def test_linked_note_not_dead(self, tmp_path: pathlib.Path) -> None:
223 _make_vault(tmp_path, {
224 "a.md": b"[[Target]]",
225 "Target.md": b"# Target",
226 })
227 idx = build_link_index(tmp_path)
228 result = note_dead(idx, root=tmp_path, use_mtime=False)
229 paths = {n["path"] for n in result["dead_notes"]}
230 assert "Target.md" not in paths
231 assert "a.md" in paths # a has no inbound
232
233 def test_mtime_gate(self, tmp_path: pathlib.Path) -> None:
234 _make_vault(tmp_path, {"recent.md": b"# Recent"})
235 idx = build_link_index(tmp_path)
236 # Recent file should NOT be dead under default 180-day threshold
237 result = note_dead(idx, root=tmp_path, use_mtime=True)
238 paths = {n["path"] for n in result["dead_notes"]}
239 assert "recent.md" not in paths
240
241 def test_dead_count_matches_list(self, tmp_path: pathlib.Path) -> None:
242 _make_vault(tmp_path, {"a.md": b"# A", "b.md": b"# B"})
243 idx = build_link_index(tmp_path)
244 result = note_dead(idx, root=tmp_path, use_mtime=False)
245 assert result["dead_count"] == len(result["dead_notes"])
246
247
248 # ============================================================================
249 # TestNoteClones
250 # ============================================================================
251
252
253 class TestNoteClones:
254 def test_no_clones(self) -> None:
255 manifest = {"a.md": "h1", "b.md": "h2", "c.md": "h3"}
256 result = note_clones(manifest)
257 assert result["clone_groups"] == []
258 assert result["total_clones"] == 0
259
260 def test_two_exact_duplicates(self) -> None:
261 manifest = {"a.md": "h", "b.md": "h", "c.md": "h2"}
262 result = note_clones(manifest)
263 assert len(result["clone_groups"]) == 1
264 assert sorted(result["clone_groups"][0]["paths"]) == ["a.md", "b.md"]
265
266 def test_three_duplicates_ranked_above_two(self) -> None:
267 manifest = {
268 "a.md": "h1", "b.md": "h1", "c.md": "h1",
269 "d.md": "h2", "e.md": "h2",
270 }
271 result = note_clones(manifest)
272 assert result["clone_groups"][0]["paths"] == ["a.md", "b.md", "c.md"]
273 assert result["clone_groups"][1]["paths"] == ["d.md", "e.md"]
274
275 def test_non_md_files_excluded(self) -> None:
276 manifest = {"a.md": "h", "config.yaml": "h"}
277 result = note_clones(manifest)
278 # Different file types with same hash should not be grouped
279 # as note clones (only .md files participate)
280 assert result["total_notes"] == 1
281 assert result["clone_groups"] == []
282
283
284 # ============================================================================
285 # TestNoteEntangle
286 # ============================================================================
287
288
289 class TestNoteEntangle:
290 def test_no_entanglement_single_commit(self) -> None:
291 commits = [_FakeCommit("c1", 100)]
292 loader = _fake_loader({"c1": {"a.md": "h"}})
293 result = note_entangle(commits, loader)
294 assert result["ranking"] == []
295
296 def test_co_change_detected(self) -> None:
297 commits = [
298 _FakeCommit("c3", 300),
299 _FakeCommit("c2", 200),
300 _FakeCommit("c1", 100),
301 ]
302 # a and b change together at c2 and c3 (per pair-counting algorithm)
303 loader = _fake_loader({
304 "c1": {"a.md": "h_a1", "b.md": "h_b1"},
305 "c2": {"a.md": "h_a2", "b.md": "h_b2"},
306 "c3": {"a.md": "h_a3", "b.md": "h_b3"},
307 })
308 result = note_entangle(commits, loader, min_co_changes=1)
309 pairs = {tuple(r["pair"]): r["co_changes"] for r in result["ranking"]}
310 assert pairs.get(("a.md", "b.md")) == 2
311
312 def test_min_co_changes_filter(self) -> None:
313 commits = [_FakeCommit("c2", 200), _FakeCommit("c1", 100)]
314 loader = _fake_loader({
315 "c1": {"a.md": "h1", "b.md": "hb"},
316 "c2": {"a.md": "h2", "b.md": "hb2"},
317 })
318 result = note_entangle(commits, loader, min_co_changes=5)
319 assert result["ranking"] == []
320
321
322 # ============================================================================
323 # TestNoteVelocity
324 # ============================================================================
325
326
327 class TestNoteVelocity:
328 def test_negative_window_rejected(self) -> None:
329 with pytest.raises(ValueError):
330 note_velocity([_FakeCommit("c1", 100)], _fake_loader({}), window_size=0)
331
332 def test_empty_commits(self) -> None:
333 result = note_velocity([], _fake_loader({}))
334 assert result["recent_window"] == {"added": 0, "removed": 0, "modified": 0, "net": 0}
335 assert result["acceleration"] == 0
336
337 def test_pure_additions(self) -> None:
338 # 3 commits, window_size=1: recent = c3 vs c3 (no-op).
339 # We need window_size=1 with two windows of one commit each.
340 commits = [
341 _FakeCommit("c3", 300),
342 _FakeCommit("c2", 200),
343 _FakeCommit("c1", 100),
344 ]
345 loader = _fake_loader({
346 "c1": {},
347 "c2": {"a.md": "h_a"},
348 "c3": {"a.md": "h_a", "b.md": "h_b"},
349 })
350 # window_size=1 → recent=[c3] (newest→newest=no diff), older=[c2]
351 # That degenerate case isn't useful. Use window_size=2.
352 result = note_velocity(commits, loader, window_size=2)
353 # recent=[c3, c2]: newest=c3 manifest, oldest=c2 manifest → adds b.md
354 assert result["recent_window"]["added"] == 1
355
356 def test_window_size_returned(self) -> None:
357 result = note_velocity([], _fake_loader({}), window_size=5)
358 assert result["window_size"] == 5
359
360
361 # ============================================================================
362 # TestJsonSerialisation
363 # ============================================================================
364
365
366 class TestJsonSerialisation:
367 """All helper outputs must be JSON-serialisable."""
368
369 def test_hotspots_json(self) -> None:
370 json.dumps(note_hotspots([], _fake_loader({})))
371
372 def test_gravity_json(self, tmp_path: pathlib.Path) -> None:
373 idx = build_link_index(tmp_path)
374 json.dumps(note_gravity(idx))
375
376 def test_dead_json(self, tmp_path: pathlib.Path) -> None:
377 idx = build_link_index(tmp_path)
378 json.dumps(note_dead(idx, root=tmp_path, use_mtime=False))
379
380 def test_clones_json(self) -> None:
381 json.dumps(note_clones({}))
382
383 def test_entangle_json(self) -> None:
384 json.dumps(note_entangle([], _fake_loader({})))
385
386 def test_velocity_json(self) -> None:
387 json.dumps(note_velocity([], _fake_loader({})))
388
389
390 # ============================================================================
391 # TestPerformance
392 # ============================================================================
393
394
395 class TestPerformance:
396 def test_clones_500_notes_under_1s(self) -> None:
397 manifest = {f"note_{i}.md": f"h_{i % 50}" for i in range(500)}
398 start = time.monotonic()
399 result = note_clones(manifest)
400 elapsed = time.monotonic() - start
401 assert elapsed < 1.0
402 assert result["total_notes"] == 500
403
404 def test_gravity_500_note_vault_under_2s(self, tmp_path: pathlib.Path) -> None:
405 files: dict[str, bytes] = {}
406 for i in range(500):
407 files[f"note_{i}.md"] = f"[[hub]]".encode()
408 files["hub.md"] = b"# Hub"
409 _make_vault(tmp_path, files)
410 idx = build_link_index(tmp_path)
411 start = time.monotonic()
412 note_gravity(idx)
413 elapsed = time.monotonic() - start
414 assert elapsed < 2.0
415
416
417 # ============================================================================
418 # TestDeterminism
419 # ============================================================================
420
421
422 class TestDeterminism:
423 def test_hotspots_deterministic(self) -> None:
424 commits = [_FakeCommit("c2", 200), _FakeCommit("c1", 100)]
425 loader = _fake_loader({
426 "c1": {"a.md": "h1", "b.md": "h_b"},
427 "c2": {"a.md": "h2", "b.md": "h_b"},
428 })
429 r1 = json.dumps(note_hotspots(commits, loader), sort_keys=True)
430 r2 = json.dumps(note_hotspots(commits, loader), sort_keys=True)
431 assert r1 == r2
432
433 def test_clones_deterministic(self) -> None:
434 manifest = {"a.md": "h", "b.md": "h", "c.md": "h"}
435 r1 = json.dumps(note_clones(manifest), sort_keys=True)
436 r2 = json.dumps(note_clones(manifest), sort_keys=True)
437 assert r1 == r2
438
439
440 # ============================================================================
441 # TestSecurity
442 # ============================================================================
443
444
445 class TestSecurity:
446 def test_hotspots_handles_loader_exception(self) -> None:
447 def bad_loader(_cid: str) -> dict[str, str]:
448 raise RuntimeError("simulated load failure")
449
450 commits = [_FakeCommit("c1", 100)]
451 result = note_hotspots(commits, bad_loader)
452 assert result["ranking"] == []
453
454 def test_entangle_handles_loader_exception(self) -> None:
455 def bad_loader(_cid: str) -> dict[str, str]:
456 raise RuntimeError("simulated")
457
458 commits = [_FakeCommit("c2", 200), _FakeCommit("c1", 100)]
459 result = note_entangle(commits, bad_loader)
460 assert result["ranking"] == []
461
462 def test_velocity_handles_loader_exception(self) -> None:
463 def bad_loader(_cid: str) -> dict[str, str]:
464 raise RuntimeError("simulated")
465
466 result = note_velocity(
467 [_FakeCommit("c1", 100), _FakeCommit("c2", 200)],
468 bad_loader,
469 window_size=1,
470 )
471 # Should not crash; window_stats returns zero defaults on exception
472 assert result["recent_window"]["added"] == 0
File History 2 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 26 days ago