@heretek-ai/epistemic-swarm 0.7.11 → 0.7.13

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 (63) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/package.json +1 -1
  3. package/plugins/research-cache/skills/research_cache/hasher.py +56 -14
  4. package/prompts/agent_brainstormer.md +14 -0
  5. package/prompts/agent_darkharvest.md +20 -1
  6. package/runner/__pycache__/__init__.cpython-311.pyc +0 -0
  7. package/runner/__pycache__/auctioneer.cpython-311.pyc +0 -0
  8. package/runner/__pycache__/auditor_engine.cpython-311.pyc +0 -0
  9. package/runner/__pycache__/claim_store.cpython-311.pyc +0 -0
  10. package/runner/__pycache__/claim_witness.cpython-311.pyc +0 -0
  11. package/runner/__pycache__/darkharvest_claims.cpython-311.pyc +0 -0
  12. package/runner/__pycache__/living_dossiers.cpython-311.pyc +0 -0
  13. package/runner/__pycache__/mcp_protocol.cpython-311.pyc +0 -0
  14. package/runner/__pycache__/mcp_server.cpython-311.pyc +0 -0
  15. package/runner/__pycache__/path_safety.cpython-311.pyc +0 -0
  16. package/runner/__pycache__/pcrb.cpython-311.pyc +0 -0
  17. package/runner/__pycache__/pcrb_verify.cpython-311.pyc +0 -0
  18. package/runner/__pycache__/refinement.cpython-311.pyc +0 -0
  19. package/runner/__pycache__/research_swarm.cpython-311.pyc +0 -0
  20. package/runner/__pycache__/retrieval_log.cpython-311.pyc +0 -0
  21. package/runner/__pycache__/state_machine.cpython-311.pyc +0 -0
  22. package/runner/auditor_engine.py +267 -80
  23. package/runner/darkharvest_claims.py +326 -0
  24. package/runner/refinement.py +74 -0
  25. package/runner/research_swarm.py +119 -24
  26. package/runner/retrieval_log.py +90 -0
  27. package/runner/state_machine.py +35 -0
  28. package/runner/tests/__pycache__/test_auction_order.cpython-311.pyc +0 -0
  29. package/runner/tests/__pycache__/test_backends.cpython-311.pyc +0 -0
  30. package/runner/tests/__pycache__/test_bet1_spike.cpython-311.pyc +0 -0
  31. package/runner/tests/__pycache__/test_claim_store.cpython-311.pyc +0 -0
  32. package/runner/tests/__pycache__/test_claim_witness.cpython-311.pyc +0 -0
  33. package/runner/tests/__pycache__/test_claude_plugin.cpython-311.pyc +0 -0
  34. package/runner/tests/__pycache__/test_darkharvest_audit.cpython-311.pyc +0 -0
  35. package/runner/tests/__pycache__/test_domain_packs.cpython-311.pyc +0 -0
  36. package/runner/tests/__pycache__/test_dossier_contract.cpython-311.pyc +0 -0
  37. package/runner/tests/__pycache__/test_factory.cpython-311.pyc +0 -0
  38. package/runner/tests/__pycache__/test_fleet_seam.cpython-311.pyc +0 -0
  39. package/runner/tests/__pycache__/test_grounding.cpython-311.pyc +0 -0
  40. package/runner/tests/__pycache__/test_living_dossiers.cpython-311.pyc +0 -0
  41. package/runner/tests/__pycache__/test_manifest_concurrency.cpython-311.pyc +0 -0
  42. package/runner/tests/__pycache__/test_mcp_server.cpython-311.pyc +0 -0
  43. package/runner/tests/__pycache__/test_opencode_ux.cpython-311.pyc +0 -0
  44. package/runner/tests/__pycache__/test_opencode_v2_registrars.cpython-311.pyc +0 -0
  45. package/runner/tests/__pycache__/test_pcrb.cpython-311.pyc +0 -0
  46. package/runner/tests/__pycache__/test_refinement.cpython-311.pyc +0 -0
  47. package/runner/tests/__pycache__/test_swarm.cpython-311.pyc +0 -0
  48. package/runner/tests/__pycache__/test_swarm_config.cpython-311.pyc +0 -0
  49. package/runner/tests/__pycache__/test_sweep_regressions.cpython-311.pyc +0 -0
  50. package/runner/tests/__pycache__/test_webcache.cpython-311.pyc +0 -0
  51. package/runner/tests/test_darkharvest_audit.py +290 -0
  52. package/runner/tests/test_grounding.py +183 -0
  53. package/scripts/__pycache__/bet1_advisory_spike.cpython-311.pyc +0 -0
  54. package/scripts/__pycache__/build_adapters.cpython-311.pyc +0 -0
  55. package/scripts/__pycache__/divergence_experiment.cpython-311.pyc +0 -0
  56. package/skills/epistemic_search/scripts/__pycache__/search.cpython-311.pyc +0 -0
  57. package/skills/epistemic_search/scripts/search.py +16 -0
  58. package/skills/epistemic_search/scripts/webcache.py +11 -0
  59. package/skills/research_cache/__pycache__/__init__.cpython-311.pyc +0 -0
  60. package/skills/research_cache/__pycache__/hasher.cpython-311.pyc +0 -0
  61. package/skills/research_cache/hasher.py +56 -14
  62. package/skills/swarm_config/__pycache__/__init__.cpython-311.pyc +0 -0
  63. package/skills/swarm_config/__pycache__/configure.cpython-311.pyc +0 -0
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env python3
2
+ """Append-only retrieval telemetry for a research workspace.
3
+
4
+ Agents invoke the skill scripts (`skills/epistemic_search/scripts/search.py`,
5
+ `skills/research_cache/hasher.py`, `webcache.py`) as *subprocesses*, so a shared
6
+ JSONL log is the only place their retrieval activity is visible to the runner.
7
+ Without it a run that never searched looks identical to one whose cache writes
8
+ failed — the exact confusion in Heretek-AI/IUMBTEMS#3.
9
+
10
+ `.research/retrieval.jsonl`, one event per line::
11
+
12
+ {"kind": "query", "query": "...", "results": 5, "ts": "..."}
13
+ {"kind": "cache", "url": "...", "hash": "...", "ts": "..."}
14
+
15
+ Telemetry is best-effort: it must never break the caller.
16
+ """
17
+
18
+ import json
19
+ import os
20
+ from datetime import datetime, timezone
21
+ from pathlib import Path
22
+ from typing import Any, Dict, Optional
23
+
24
+ LOG_NAME = "retrieval.jsonl"
25
+ RESEARCH_DIR_ENV = "IUMBTEMS_RESEARCH_DIR"
26
+
27
+
28
+ def default_base_dir() -> Path:
29
+ """Workspace for a skill subprocess: env override, else cwd/.research."""
30
+ override = os.environ.get(RESEARCH_DIR_ENV, "").strip()
31
+ if override:
32
+ return Path(override)
33
+ return Path.cwd() / ".research"
34
+
35
+
36
+ def log_path(base_dir) -> Path:
37
+ return Path(base_dir) / LOG_NAME
38
+
39
+
40
+ def log_event(base_dir, kind: str, **fields: Any) -> None:
41
+ """Append one telemetry event. Never raises."""
42
+ try:
43
+ path = log_path(base_dir)
44
+ path.parent.mkdir(parents=True, exist_ok=True)
45
+ event = {"kind": kind, "ts": datetime.now(timezone.utc).isoformat()}
46
+ event.update(fields)
47
+ with open(path, "a", encoding="utf-8") as f:
48
+ f.write(json.dumps(event) + "\n")
49
+ except OSError:
50
+ pass
51
+
52
+
53
+ def current_offset(base_dir) -> int:
54
+ """Byte offset to pass as `since` for a per-run delta."""
55
+ try:
56
+ return log_path(base_dir).stat().st_size
57
+ except OSError:
58
+ return 0
59
+
60
+
61
+ def summarize(base_dir, since: Optional[int] = None) -> Dict[str, int]:
62
+ """Count query/cache events, optionally only those after a byte offset."""
63
+ counts = {"queries": 0, "results": 0, "cached": 0, "events": 0}
64
+ path = log_path(base_dir)
65
+ if not path.exists():
66
+ return counts
67
+ try:
68
+ with open(path, "r", encoding="utf-8") as f:
69
+ if since:
70
+ f.seek(since)
71
+ for line in f:
72
+ line = line.strip()
73
+ if not line:
74
+ continue
75
+ counts["events"] += 1
76
+ try:
77
+ event = json.loads(line)
78
+ except ValueError:
79
+ continue
80
+ if event.get("kind") == "query":
81
+ counts["queries"] += 1
82
+ try:
83
+ counts["results"] += int(event.get("results") or 0)
84
+ except (TypeError, ValueError):
85
+ pass
86
+ elif event.get("kind") == "cache":
87
+ counts["cached"] += 1
88
+ except OSError:
89
+ pass
90
+ return counts
@@ -219,6 +219,41 @@ class ResearchStateMachine:
219
219
  def get_scope_dir(self, scope_id: str) -> Path:
220
220
  return self.scratchpads_dir / scope_id
221
221
 
222
+ def find_orphan_scopes(self) -> List[Dict[str, Any]]:
223
+ """Scope dirs not referenced by any manifest — marked, never deleted.
224
+
225
+ A dir may belong to a concurrently running session, so this reports
226
+ rather than prunes (issue #5 layer 6: 14 manifest-only leftovers).
227
+ """
228
+ referenced: set = set()
229
+ manifests = [self.manifest_file, *self.base_dir.glob("manifest.*.json")]
230
+ for manifest_path in manifests:
231
+ if not manifest_path.exists():
232
+ continue
233
+ try:
234
+ data = json.loads(manifest_path.read_text(encoding="utf-8"))
235
+ except (OSError, ValueError):
236
+ continue
237
+ for scope in data.get("scopes") or []:
238
+ if isinstance(scope, dict) and scope.get("scope_id"):
239
+ referenced.add(scope["scope_id"])
240
+
241
+ orphans: List[Dict[str, Any]] = []
242
+ if not self.scratchpads_dir.is_dir():
243
+ return orphans
244
+ for scope_dir in sorted(self.scratchpads_dir.iterdir()):
245
+ if not scope_dir.is_dir() or scope_dir.name in referenced:
246
+ continue
247
+ orphans.append(
248
+ {
249
+ "scope_id": scope_dir.name,
250
+ "path": str(scope_dir),
251
+ "alpha_dossier": (scope_dir / "alpha_dossier.json").exists(),
252
+ "beta_dossier": (scope_dir / "beta_dossier.json").exists(),
253
+ }
254
+ )
255
+ return orphans
256
+
222
257
  def _read_scope_unlocked(self, scope_id: str) -> Dict[str, Any]:
223
258
  scope_manifest_file = self.get_scope_dir(scope_id) / MANIFEST_FILENAME
224
259
  if not scope_manifest_file.exists():
@@ -0,0 +1,290 @@
1
+ #!/usr/bin/env python3
2
+ """Darkharvest audit regressions (issue #5).
3
+
4
+ Covers the three layers that made darkharvest ungrounded by construction:
5
+ claim-schema normalization (auditor could not see darkharvest claims), the
6
+ alpha/beta license cross-check (MIT vs NOASSERTION reached a human as
7
+ divergence 0), and self-reported verification fields that must never count.
8
+ """
9
+
10
+ import json
11
+ import sys
12
+ import tempfile
13
+ import unittest
14
+ from pathlib import Path
15
+
16
+ PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
17
+ if str(PROJECT_ROOT) not in sys.path:
18
+ sys.path.insert(0, str(PROJECT_ROOT))
19
+
20
+ from runner.auditor_engine import EpistemicAuditorEngine # noqa: E402
21
+ from runner.darkharvest_claims import ( # noqa: E402
22
+ RepoValidator,
23
+ cross_check_repos,
24
+ normalize_dossier_claims,
25
+ sanitize_self_reported,
26
+ )
27
+ from runner.research_swarm import SwarmRunner # noqa: E402
28
+ from skills.research_cache.hasher import SourceHasher # noqa: E402
29
+
30
+
31
+ class FakeValidator:
32
+ def __init__(self, result):
33
+ self.result = result
34
+ self.calls = []
35
+
36
+ def validate(self, owner_repo):
37
+ self.calls.append(owner_repo)
38
+ return self.result
39
+
40
+
41
+ def _candidate(license_, risk, policy, shash, quote):
42
+ return {
43
+ "repo_id": "DH-01",
44
+ "name": "getpaseo/paseo",
45
+ "url": "https://github.com/getpaseo/paseo",
46
+ "source_hash": shash,
47
+ "verbatim_quote": quote,
48
+ "license": license_,
49
+ "license_risk": risk,
50
+ "harvest_policy": policy,
51
+ "verdicts": [
52
+ {"feature": "worktree isolation", "verdict": "vendor", "reason": "r"}
53
+ ],
54
+ }
55
+
56
+
57
+ class TestNormalization(unittest.TestCase):
58
+ def test_darkharvest_shape_becomes_standard_claims(self):
59
+ dossier = {
60
+ "candidate_repositories": [
61
+ _candidate("MIT", "SAFE", "depend-or-vendor", "abc", "q")
62
+ ]
63
+ }
64
+ claims = normalize_dossier_claims(dossier, "alpha")
65
+ self.assertEqual(len(claims), 1)
66
+ self.assertEqual(claims[0]["source_hash"], "abc")
67
+ self.assertEqual(claims[0]["verbatim_quote"], "q")
68
+
69
+ def test_standard_keys_win_when_present(self):
70
+ dossier = {
71
+ "affirmative_claims": [{"claim_id": "C1", "statement": "s"}],
72
+ "candidate_repositories": [
73
+ _candidate("MIT", "SAFE", "depend-or-vendor", "abc", "q")
74
+ ],
75
+ }
76
+ claims = normalize_dossier_claims(dossier, "alpha")
77
+ self.assertEqual([c["claim_id"] for c in claims], ["C1"])
78
+
79
+
80
+ class TestSelfReported(unittest.TestCase):
81
+ def test_audit_shaped_keys_are_renamed_not_counted(self):
82
+ dossier = {
83
+ "epistemic_audit": {"verified_claims": 15},
84
+ "confidence": "HIGH",
85
+ "scope_id": "s1",
86
+ }
87
+ dossier, renamed = sanitize_self_reported(dossier)
88
+ self.assertIn("epistemic_audit", renamed)
89
+ self.assertIn("confidence", renamed)
90
+ self.assertNotIn("epistemic_audit", dossier)
91
+ self.assertEqual(
92
+ dossier["self_reported_epistemic_audit"]["verified_claims"], 15
93
+ )
94
+ # Nothing audit-shaped remains to be mistaken for verified counts.
95
+ self.assertFalse(set(dossier) & {"epistemic_audit", "confidence"})
96
+
97
+
98
+ class TestLicenseCrossCheck(unittest.TestCase):
99
+ def test_mit_vs_noassertion_is_blocking_and_conservative(self):
100
+ from runner.auditor_engine import _repo_validation_enabled # noqa: F401
101
+
102
+ alpha = {
103
+ "candidate_repositories": [
104
+ _candidate("MIT", "SAFE", "depend-or-vendor", "a", "q")
105
+ ]
106
+ }
107
+ beta = {
108
+ "candidate_repositories": [
109
+ _candidate(
110
+ "NOASSERTION", "PROHIBITIVE", "clean-room-rebuild-only", "a", "q"
111
+ )
112
+ ]
113
+ }
114
+ findings = cross_check_repos(alpha, beta)
115
+ kinds = {f["kind"] for f in findings}
116
+ self.assertIn("LICENSE_DISAGREEMENT", kinds)
117
+ self.assertTrue(all(f["severity"] == "BLOCKING" for f in findings))
118
+ self.assertEqual(findings[0]["resolution"], "clean-room-rebuild-only")
119
+
120
+ def test_agreement_yields_no_findings(self):
121
+ alpha = {
122
+ "candidate_repositories": [
123
+ _candidate("MIT", "SAFE", "depend-or-vendor", "a", "q")
124
+ ]
125
+ }
126
+ beta = {
127
+ "candidate_repositories": [
128
+ _candidate("MIT", "SAFE", "depend-or-vendor", "a", "q")
129
+ ]
130
+ }
131
+ self.assertEqual(cross_check_repos(alpha, beta), [])
132
+
133
+ def test_404_is_an_unresolvable_blocking_finding(self):
134
+ alpha = {
135
+ "candidate_repositories": [
136
+ _candidate("MIT", "SAFE", "depend-or-vendor", "a", "q")
137
+ ]
138
+ }
139
+ findings = cross_check_repos(
140
+ alpha, {}, validator=FakeValidator({"exists": False})
141
+ )
142
+ self.assertTrue(any(f["kind"] == "REPO_UNRESOLVABLE" for f in findings))
143
+
144
+ def test_offline_fails_open(self):
145
+ alpha = {
146
+ "candidate_repositories": [
147
+ _candidate("MIT", "SAFE", "depend-or-vendor", "a", "q")
148
+ ]
149
+ }
150
+ findings = cross_check_repos(
151
+ alpha,
152
+ {},
153
+ validator=FakeValidator(
154
+ {"exists": None, "license": None, "archived": None}
155
+ ),
156
+ )
157
+ self.assertEqual(findings, [])
158
+
159
+ def test_validator_fail_open_on_error(self):
160
+ """Network failure must yield exists=None, never raise (offline-safe)."""
161
+ import urllib.error
162
+ from unittest import mock
163
+
164
+ v = RepoValidator(enabled=True, timeout=0.001)
165
+ with mock.patch(
166
+ "urllib.request.urlopen", side_effect=urllib.error.URLError("offline")
167
+ ):
168
+ self.assertIsNone(v.validate("owner/repo")["exists"])
169
+
170
+ def test_validator_404_is_not_exists(self):
171
+ import urllib.error
172
+ from unittest import mock
173
+
174
+ v = RepoValidator(enabled=True, timeout=0.001)
175
+ err = urllib.error.HTTPError("u", 404, "not found", {}, None)
176
+ with mock.patch("urllib.request.urlopen", side_effect=err):
177
+ self.assertFalse(v.validate("owner/missing")["exists"])
178
+
179
+
180
+ class TestDarkharvestAuditIntegration(unittest.TestCase):
181
+ def _runner_and_scope(self):
182
+ tmp = tempfile.mkdtemp()
183
+ base = Path(tmp) / ".research"
184
+ runner = SwarmRunner(base_dir=base, mock_mode=True, mode="darkharvest")
185
+ sm = runner.state_machine
186
+ sm.init_session("arena")
187
+ sm.set_scopes([{"scope_id": "s1", "title": "t", "objective": "o"}])
188
+ hasher = SourceHasher(base_dir=base)
189
+ shash = hasher.store_source(
190
+ "https://github.com/getpaseo/paseo",
191
+ "Paseo archives a clean worktree with no confirmation.",
192
+ "paseo",
193
+ )
194
+ quote = "Paseo archives a clean worktree with no confirmation."
195
+ scope_dir = sm.get_scope_dir("s1")
196
+ (scope_dir / "alpha_dossier.json").write_text(
197
+ json.dumps(
198
+ {
199
+ "scope_id": "s1",
200
+ "candidate_repositories": [
201
+ _candidate("MIT", "SAFE", "depend-or-vendor", shash, quote)
202
+ ],
203
+ "epistemic_audit": {"verified_claims": 15, "confidence": "HIGH"},
204
+ }
205
+ ),
206
+ encoding="utf-8",
207
+ )
208
+ (scope_dir / "beta_dossier.json").write_text(
209
+ json.dumps(
210
+ {
211
+ "scope_id": "s1",
212
+ "candidate_repositories": [
213
+ _candidate(
214
+ "NOASSERTION",
215
+ "PROHIBITIVE",
216
+ "clean-room-rebuild-only",
217
+ shash,
218
+ quote,
219
+ )
220
+ ],
221
+ }
222
+ ),
223
+ encoding="utf-8",
224
+ )
225
+ return runner, base
226
+
227
+ def test_auditor_sees_claims_and_blocks_license_conflict(self):
228
+ runner, base = self._runner_and_scope()
229
+ auditor = EpistemicAuditorEngine(
230
+ base_dir=base,
231
+ repo_validator=FakeValidator(
232
+ {"exists": True, "license": "NOASSERTION", "archived": False}
233
+ ),
234
+ )
235
+ report = auditor.audit_scope("s1", mode="darkharvest")
236
+ summary = report["summary"]
237
+
238
+ self.assertGreater(summary["total_claims_audited"], 0)
239
+ self.assertGreaterEqual(summary["verified_passed"], 1)
240
+ self.assertEqual(summary["verdict"], "WARNING_LICENSE_CONFLICT")
241
+ self.assertTrue(
242
+ any(f["kind"] == "LICENSE_DISAGREEMENT" for f in report["harvest_findings"])
243
+ )
244
+
245
+ def test_synthesis_section_no_longer_claims_one_hundred_percent(self):
246
+ from runner.auditor_engine import EpistemicAuditorEngine as AE
247
+
248
+ lines = AE._render_rejected_section(
249
+ [], {"verified_passed": 0, "verdict": "WARNING_LOW_GROUNDING"}
250
+ )
251
+ text = "\n".join(lines)
252
+ self.assertIn("0 claims were verified", text)
253
+ self.assertNotIn("100%", text)
254
+
255
+ def test_synthesis_section_reports_license_conflict(self):
256
+ from runner.auditor_engine import EpistemicAuditorEngine as AE
257
+
258
+ lines = AE._render_rejected_section(
259
+ [],
260
+ {
261
+ "verified_passed": 0,
262
+ "verdict": "WARNING_LICENSE_CONFLICT",
263
+ "blocking_findings": 2,
264
+ },
265
+ )
266
+ self.assertIn("blocking license/ground-truth finding", "\n".join(lines))
267
+
268
+
269
+ class TestOrphanScopes(unittest.TestCase):
270
+ def test_orphan_detected_and_not_deleted(self):
271
+ tmp = tempfile.mkdtemp()
272
+ base = Path(tmp) / ".research"
273
+ runner = SwarmRunner(base_dir=base, mock_mode=True, mode="research")
274
+ sm = runner.state_machine
275
+ sm.init_session("x")
276
+ sm.set_scopes([{"scope_id": "kept", "title": "t", "objective": "o"}])
277
+ stray = sm.get_scope_dir("abandoned")
278
+ stray.mkdir(parents=True, exist_ok=True)
279
+ (stray / "manifest.json").write_text("{}", encoding="utf-8")
280
+
281
+ orphans = sm.find_orphan_scopes()
282
+ ids = {o["scope_id"] for o in orphans}
283
+ self.assertIn("abandoned", ids)
284
+ self.assertNotIn("kept", ids)
285
+ # Marked, never deleted.
286
+ self.assertTrue(stray.exists())
287
+
288
+
289
+ if __name__ == "__main__":
290
+ unittest.main()
@@ -0,0 +1,183 @@
1
+ #!/usr/bin/env python3
2
+ """Grounding regressions (issue #3): the summary must match the audit, brainstorm
3
+ scores speculation rather than web quotes, retrieval is observable, and dossiers
4
+ are treated as untrusted input.
5
+ """
6
+
7
+ import json
8
+ import sys
9
+ import tempfile
10
+ import unittest
11
+ from pathlib import Path
12
+
13
+ PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
14
+ if str(PROJECT_ROOT) not in sys.path:
15
+ sys.path.insert(0, str(PROJECT_ROOT))
16
+
17
+ from runner.claim_witness import ( # noqa: E402
18
+ KIND_HYPOTHESIS,
19
+ KIND_INFERENCE,
20
+ TAG_HYPOTHESIS,
21
+ TAG_INFERRED,
22
+ ClaimWitness,
23
+ )
24
+ from runner.refinement import ( # noqa: E402
25
+ BRAINSTORM_MODE_THRESHOLD,
26
+ compute_brainstorm_score_from_claims,
27
+ compute_epistemic_score_from_claims,
28
+ )
29
+ from runner.research_swarm import SwarmRunner # noqa: E402
30
+ from runner.retrieval_log import log_event, summarize # noqa: E402
31
+ from runner.state_machine import ResearchStateMachine # noqa: E402
32
+
33
+
34
+ def _hypothesis(i: int, falsification: str = "measurable test") -> ClaimWitness:
35
+ return ClaimWitness(
36
+ claim_id=f"H{i}",
37
+ kind=KIND_HYPOTHESIS,
38
+ tag=TAG_HYPOTHESIS,
39
+ statement=f"speculative vector {i}",
40
+ falsification=falsification,
41
+ )
42
+
43
+
44
+ class TestRetrievalTelemetry(unittest.TestCase):
45
+ def test_events_summarize_with_run_offset(self):
46
+ with tempfile.TemporaryDirectory() as tmp:
47
+ base = Path(tmp) / ".research"
48
+ log_event(base, "query", query="a", results=5)
49
+ log_event(base, "cache", url="u", hash="h")
50
+
51
+ self.assertEqual(
52
+ summarize(base), {"queries": 1, "results": 5, "cached": 1, "events": 2}
53
+ )
54
+
55
+ offset = (base / "retrieval.jsonl").stat().st_size
56
+ log_event(base, "query", query="b", results=2)
57
+ delta = summarize(base, since=offset)
58
+ self.assertEqual(delta["queries"], 1)
59
+ self.assertEqual(delta["results"], 2)
60
+ self.assertEqual(delta["cached"], 0)
61
+
62
+ def test_missing_log_is_zero(self):
63
+ with tempfile.TemporaryDirectory() as tmp:
64
+ self.assertEqual(
65
+ summarize(Path(tmp) / "nope"),
66
+ {"queries": 0, "results": 0, "cached": 0, "events": 0},
67
+ )
68
+
69
+
70
+ class TestSummaryMatchesAudit(unittest.TestCase):
71
+ def _runner_with_scope(self, verified: int, verdict: str):
72
+ tmp = tempfile.mkdtemp()
73
+ base = Path(tmp) / ".research"
74
+ runner = SwarmRunner(base_dir=base, mock_mode=True, mode="research")
75
+ sm: ResearchStateMachine = runner.state_machine
76
+ sm.init_session("objective")
77
+ sm.set_scopes([{"scope_id": "s1", "title": "t", "objective": "o"}])
78
+ scope_dir = sm.get_scope_dir("s1")
79
+ (scope_dir / "audit_report.json").write_text(
80
+ json.dumps(
81
+ {
82
+ "summary": {
83
+ "verified_passed": verified,
84
+ "unverified_rejected": 2,
85
+ "divergence_score": 0.0,
86
+ "verdict": verdict,
87
+ }
88
+ }
89
+ ),
90
+ encoding="utf-8",
91
+ )
92
+ (scope_dir / "scope_synthesis.md").write_text(
93
+ "# scope synthesis", encoding="utf-8"
94
+ )
95
+ return runner, base
96
+
97
+ def test_zero_verified_summary_warns_and_does_not_claim_certainty(self):
98
+ runner, base = self._runner_with_scope(0, "WARNING_LOW_GROUNDING")
99
+ report = runner._compile_master_synthesis("objective").read_text(
100
+ encoding="utf-8"
101
+ )
102
+ self.assertIn("⚠️ **WARNING_LOW_GROUNDING**", report)
103
+ self.assertNotIn("Every factual statement carries", report)
104
+ self.assertIn("retrieval:", report)
105
+
106
+ def test_grounded_summary_does_not_warn(self):
107
+ runner, _ = self._runner_with_scope(7, "CERTIFIED")
108
+ report = runner._compile_master_synthesis("objective").read_text(
109
+ encoding="utf-8"
110
+ )
111
+ self.assertNotIn("⚠️ **WARNING_LOW_GROUNDING**", report)
112
+ self.assertIn("7 primary citation(s) verified", report)
113
+
114
+
115
+ class TestBrainstormScoring(unittest.TestCase):
116
+ def test_well_formed_hypotheses_pass_brainstorm(self):
117
+ claims = [_hypothesis(i) for i in range(4)]
118
+ score, breakdown = compute_brainstorm_score_from_claims(claims)
119
+ self.assertGreaterEqual(score, BRAINSTORM_MODE_THRESHOLD)
120
+ self.assertEqual(breakdown["well_formed_count"], 4)
121
+
122
+ def test_research_formula_would_fail_same_dossier(self):
123
+ claims = [_hypothesis(i) for i in range(4)]
124
+ research_score, _ = compute_epistemic_score_from_claims(claims)
125
+ self.assertLess(research_score, BRAINSTORM_MODE_THRESHOLD)
126
+
127
+ def test_hypotheses_without_falsification_score_lower(self):
128
+ good = [_hypothesis(i) for i in range(4)]
129
+ weak = [_hypothesis(i, falsification="") for i in range(4)]
130
+ self.assertGreater(
131
+ compute_brainstorm_score_from_claims(good)[0],
132
+ compute_brainstorm_score_from_claims(weak)[0],
133
+ )
134
+
135
+ def test_inference_without_parents_is_not_credited(self):
136
+ with_parents = ClaimWitness(
137
+ claim_id="I1",
138
+ kind=KIND_INFERENCE,
139
+ tag=TAG_INFERRED,
140
+ statement="because",
141
+ parent_claims=["H1"],
142
+ )
143
+ without = ClaimWitness(
144
+ claim_id="I2", kind=KIND_INFERENCE, tag=TAG_INFERRED, statement="because"
145
+ )
146
+ self.assertGreater(
147
+ compute_brainstorm_score_from_claims([with_parents])[0],
148
+ compute_brainstorm_score_from_claims([without])[0],
149
+ )
150
+
151
+
152
+ class TestDossierIsUntrusted(unittest.TestCase):
153
+ def test_scope_id_mismatch_is_rejected(self):
154
+ tmp = tempfile.mkdtemp()
155
+ base = Path(tmp) / ".research"
156
+ runner = SwarmRunner(base_dir=base, mock_mode=True, mode="research")
157
+ scope_dir = runner.state_machine.get_scope_dir("s1")
158
+ scope_dir.mkdir(parents=True, exist_ok=True)
159
+ (scope_dir / "beta_dossier.json").write_text(
160
+ json.dumps({"scope_id": "some_other_scope", "claims": []}), encoding="utf-8"
161
+ )
162
+ with self.assertRaises(FileNotFoundError):
163
+ runner._load_agent_dossier(
164
+ scope_dir / "beta_dossier.json", "s1", "beta", None
165
+ )
166
+
167
+ def test_matching_scope_id_is_accepted(self):
168
+ tmp = tempfile.mkdtemp()
169
+ base = Path(tmp) / ".research"
170
+ runner = SwarmRunner(base_dir=base, mock_mode=True, mode="research")
171
+ scope_dir = runner.state_machine.get_scope_dir("s1")
172
+ scope_dir.mkdir(parents=True, exist_ok=True)
173
+ (scope_dir / "beta_dossier.json").write_text(
174
+ json.dumps({"scope_id": "s1", "falsification_claims": []}), encoding="utf-8"
175
+ )
176
+ data = runner._load_agent_dossier(
177
+ scope_dir / "beta_dossier.json", "s1", "beta", None
178
+ )
179
+ self.assertEqual(data["scope_id"], "s1")
180
+
181
+
182
+ if __name__ == "__main__":
183
+ unittest.main()