@heretek-ai/epistemic-swarm 0.4.7 → 0.4.8

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 (74) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/bin/cli.js +59 -7
  3. package/config/mcp_launcher.py +75 -48
  4. package/extensions/pi/index.js +25 -5
  5. package/install.sh +5 -5
  6. package/package.json +1 -1
  7. package/plugins/antigravity/plugin.json +1 -1
  8. package/plugins/gemini/gemini-extension.json +1 -1
  9. package/plugins/opencode/index.js +308 -142
  10. package/plugins/opencode/tui.js +7 -9
  11. package/plugins/research-cache/skills/research_cache/hasher.py +27 -9
  12. package/plugins/socratic-grilling/skills/grilling/socratic_tree.py +26 -18
  13. package/runner/__pycache__/__init__.cpython-311.pyc +0 -0
  14. package/runner/__pycache__/auctioneer.cpython-311.pyc +0 -0
  15. package/runner/__pycache__/auditor_engine.cpython-311.pyc +0 -0
  16. package/runner/__pycache__/claim_store.cpython-311.pyc +0 -0
  17. package/runner/__pycache__/claim_witness.cpython-311.pyc +0 -0
  18. package/runner/__pycache__/living_dossiers.cpython-311.pyc +0 -0
  19. package/runner/__pycache__/mcp_protocol.cpython-311.pyc +0 -0
  20. package/runner/__pycache__/mcp_server.cpython-311.pyc +0 -0
  21. package/runner/__pycache__/path_safety.cpython-311.pyc +0 -0
  22. package/runner/__pycache__/pcrb.cpython-311.pyc +0 -0
  23. package/runner/__pycache__/pcrb_verify.cpython-311.pyc +0 -0
  24. package/runner/__pycache__/refinement.cpython-311.pyc +0 -0
  25. package/runner/__pycache__/research_swarm.cpython-311.pyc +0 -0
  26. package/runner/__pycache__/state_machine.cpython-311.pyc +0 -0
  27. package/runner/auditor_engine.py +122 -102
  28. package/runner/claim_store.py +5 -4
  29. package/runner/living_dossiers.py +32 -10
  30. package/runner/mcp_protocol.py +1 -1
  31. package/runner/mcp_server.py +23 -11
  32. package/runner/path_safety.py +62 -0
  33. package/runner/pcrb.py +68 -39
  34. package/runner/pcrb_verify.py +82 -52
  35. package/runner/refinement.py +61 -47
  36. package/runner/research_swarm.py +61 -7
  37. package/runner/state_machine.py +9 -6
  38. package/runner/tests/__pycache__/test_auction_order.cpython-311.pyc +0 -0
  39. package/runner/tests/__pycache__/test_bet1_spike.cpython-311.pyc +0 -0
  40. package/runner/tests/__pycache__/test_claim_store.cpython-311.pyc +0 -0
  41. package/runner/tests/__pycache__/test_claim_witness.cpython-311.pyc +0 -0
  42. package/runner/tests/__pycache__/test_domain_packs.cpython-311.pyc +0 -0
  43. package/runner/tests/__pycache__/test_fleet_seam.cpython-311.pyc +0 -0
  44. package/runner/tests/__pycache__/test_living_dossiers.cpython-311.pyc +0 -0
  45. package/runner/tests/__pycache__/test_mcp_server.cpython-311.pyc +0 -0
  46. package/runner/tests/__pycache__/test_opencode_ux.cpython-311.pyc +0 -0
  47. package/runner/tests/__pycache__/test_pcrb.cpython-311.pyc +0 -0
  48. package/runner/tests/__pycache__/test_refinement.cpython-311.pyc +0 -0
  49. package/runner/tests/__pycache__/test_swarm.cpython-311.pyc +0 -0
  50. package/runner/tests/__pycache__/test_sweep_regressions.cpython-311.pyc +0 -0
  51. package/runner/tests/__pycache__/test_webcache.cpython-311.pyc +0 -0
  52. package/runner/tests/test_mcp_server.py +0 -1
  53. package/runner/tests/test_opencode_ux.py +368 -33
  54. package/runner/tests/test_swarm.py +20 -9
  55. package/runner/tests/test_sweep_regressions.py +373 -0
  56. package/scripts/__pycache__/bet1_advisory_spike.cpython-311.pyc +0 -0
  57. package/scripts/__pycache__/build_adapters.cpython-311.pyc +0 -0
  58. package/scripts/__pycache__/divergence_experiment.cpython-311.pyc +0 -0
  59. package/scripts/auction_experiment.py +38 -30
  60. package/scripts/bet1_advisory_spike.py +41 -25
  61. package/scripts/build_adapters.py +52 -37
  62. package/scripts/divergence_experiment.py +61 -31
  63. package/skills/brainstorming/scripts/brainstorm.py +3 -2
  64. package/skills/epistemic_search/scripts/__pycache__/search.cpython-311.pyc +0 -0
  65. package/skills/epistemic_search/scripts/fetch.py +0 -1
  66. package/skills/epistemic_search/scripts/search.py +202 -73
  67. package/skills/epistemic_search/scripts/webcache.py +1 -1
  68. package/skills/grilling/socratic_tree.py +26 -18
  69. package/skills/research_cache/__pycache__/__init__.cpython-311.pyc +0 -0
  70. package/skills/research_cache/__pycache__/hasher.cpython-311.pyc +0 -0
  71. package/skills/research_cache/hasher.py +27 -9
  72. package/skills/swarm_config/__pycache__/__init__.cpython-311.pyc +0 -0
  73. package/skills/swarm_config/__pycache__/configure.cpython-311.pyc +0 -0
  74. package/skills/swarm_config/configure.py +32 -28
@@ -0,0 +1,373 @@
1
+ #!/usr/bin/env python3
2
+ """Regression tests for defects introduced by the Sonar remediation sweep (41d5f6f).
3
+
4
+ That commit landed green because these modules sit outside the unit suite:
5
+
6
+ * ``scripts/build_adapters.py`` — import-time ``NameError: name 'List' is not
7
+ defined`` (annotations added by the complexity extraction, import never added).
8
+ This broke CI job ``parity-matrix`` while the 138 tests stayed green.
9
+ * ``skills/epistemic_search/scripts/search.py`` — the ReDoS replacement
10
+ (DDGLiteParser) decoded HTML entities and then emitted them unescaped, so a
11
+ snippet containing ``&`` produced malformed XML; and a ``result-link`` nested
12
+ inside a ``result-snippet`` destroyed parser state so the snippet was never
13
+ recorded and every later row was mispaired.
14
+ * ``scripts/divergence_experiment.py`` — an extraction inverted the scope
15
+ selection from "last scored audit" to "first", so multi-scope runs reported a
16
+ different divergence than before.
17
+ * ``runner/research_swarm.py`` — a five-name backend allowlist rejected any
18
+ PATH-resolvable backend outside it (``python3.11``, ``aider``, ``codex``).
19
+ * ``runner/path_safety.py`` — the containment helper had no callers at all.
20
+
21
+ Each test here targets exactly one of those.
22
+ """
23
+
24
+ import importlib.util
25
+ import json
26
+ import os
27
+ import subprocess
28
+ import sys
29
+ import tempfile
30
+ import typing
31
+ import unittest
32
+ import xml.dom.minidom
33
+ from pathlib import Path
34
+
35
+ PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
36
+ if str(PROJECT_ROOT) not in sys.path:
37
+ sys.path.insert(0, str(PROJECT_ROOT))
38
+
39
+
40
+ def _load_module(rel_path: str, name: str):
41
+ """Import a plain script that is not part of a package."""
42
+ path = PROJECT_ROOT / rel_path
43
+ spec = importlib.util.spec_from_file_location(name, path)
44
+ module = importlib.util.module_from_spec(spec)
45
+ sys.modules[name] = module
46
+ spec.loader.exec_module(module)
47
+ return module
48
+
49
+
50
+ # Imported at module level (not stored on the TestCase) so the helpers are
51
+ # called as plain functions — assigning them to a class attribute would bind
52
+ # `self` as their first argument.
53
+ from runner.path_safety import safe_join, safe_resolve_path # noqa: E402
54
+
55
+
56
+ class TestBuildAdaptersImportable(unittest.TestCase):
57
+ """Importing build_adapters must not raise — this is the parity-matrix gate."""
58
+
59
+ def test_module_imports_and_annotations_resolve(self):
60
+ # The defect was `-> List[str]` with no `from typing import List`.
61
+ #
62
+ # Importing alone is NOT enough to catch it: Python 3.11 (what CI runs)
63
+ # evaluates annotations at definition time and dies on import, but
64
+ # Python 3.14 (PEP 649) defers annotation evaluation, so the NameError
65
+ # only surfaces when something actually resolves the annotations. We
66
+ # force that resolution with get_type_hints() so the test reproduces
67
+ # the CI failure on every interpreter.
68
+ try:
69
+ module = _load_module("scripts/build_adapters.py", "iumbtems_build_adapters")
70
+ except NameError as e:
71
+ self.fail(f"scripts/build_adapters.py fails to import on eager-annotation Python: {e}")
72
+
73
+ unresolved = []
74
+ for attr_name, attr in list(vars(module).items()):
75
+ if not callable(attr):
76
+ continue
77
+ fn = getattr(attr, "__func__", attr)
78
+ try:
79
+ # Reading __annotations__ (or resolving hints) forces PEP 649
80
+ # deferred evaluation — which is where a missing `List` blows up.
81
+ typing.get_type_hints(fn)
82
+ except NameError as e:
83
+ unresolved.append(f"{attr_name}: {e}")
84
+ except Exception:
85
+ # Callable without resolvable hints (builtins, partials) — not
86
+ # an annotation defect.
87
+ continue
88
+ self.assertEqual(
89
+ unresolved, [],
90
+ "annotations reference undefined names (this is the Python 3.11 "
91
+ f"import-time NameError): {unresolved}",
92
+ )
93
+
94
+ def test_check_mode_exits_zero(self):
95
+ res = subprocess.run(
96
+ [sys.executable, str(PROJECT_ROOT / "scripts" / "build_adapters.py"), "--check"],
97
+ capture_output=True,
98
+ text=True,
99
+ cwd=str(PROJECT_ROOT),
100
+ )
101
+ self.assertEqual(res.returncode, 0, f"--check failed:\n{res.stdout}\n{res.stderr}")
102
+
103
+
104
+ class TestSearchParserRegression(unittest.TestCase):
105
+ """F4 (invalid XML) and F5 (nested-link desync) in the DDG lite parser."""
106
+
107
+ @classmethod
108
+ def setUpClass(cls):
109
+ cls.search = _load_module(
110
+ "skills/epistemic_search/scripts/search.py", "iumbtems_search"
111
+ )
112
+
113
+ def _parse(self, html: str):
114
+ parser = self.search.DDGLiteParser()
115
+ parser.feed(html)
116
+ parser.flush()
117
+ return parser.results
118
+
119
+ def test_nested_result_link_inside_snippet_keeps_snippet(self):
120
+ """A result-link nested in a snippet must not hijack capture state.
121
+
122
+ Before the fix this returned [] — the nested anchor reset the buffer and
123
+ the outer </td> found no open capture, so the snippet was never recorded
124
+ and zip(links, snippets) produced nothing.
125
+ """
126
+ html = (
127
+ '<table><tr>'
128
+ '<td><a class="result-link" href="https://a.example/x">Title</a></td>'
129
+ '<td class="result-snippet">See '
130
+ '<a href="/l/?uddg=https%3A%2F%2Fy.example" class="result-link">this</a> page'
131
+ "</td></tr></table>"
132
+ )
133
+ results = self._parse(html)
134
+ self.assertEqual(len(results), 1, f"expected 1 result, got {results}")
135
+ self.assertEqual(results[0]["title"], "Title")
136
+ self.assertEqual(results[0]["snippet"], "See this page")
137
+
138
+ def test_missing_snippet_does_not_desynchronise_later_rows(self):
139
+ """A row without a snippet must not shift every subsequent pairing."""
140
+ html = (
141
+ "<table>"
142
+ '<tr><td><a class="result-link" href="https://a.example/1">T1</a></td>'
143
+ '<td class="result-snippet">S1</td></tr>'
144
+ '<tr><td><a class="result-link" href="https://a.example/2">T2</a></td></tr>'
145
+ '<tr><td><a class="result-link" href="https://a.example/3">T3</a></td>'
146
+ '<td class="result-snippet">S3</td></tr>'
147
+ "</table>"
148
+ )
149
+ results = self._parse(html)
150
+ by_title = {r["title"]: r["snippet"] for r in results}
151
+ self.assertEqual(by_title, {"T1": "S1", "T2": "", "T3": "S3"})
152
+
153
+ def test_nested_anchor_in_title_does_not_close_capture_early(self):
154
+ html = (
155
+ "<table><tr>"
156
+ '<td><a class="result-link" href="https://a.example/x">Main '
157
+ "<span>and</span> <b>more</b></a></td>"
158
+ '<td class="result-snippet">S</td>'
159
+ "</tr></table>"
160
+ )
161
+ results = self._parse(html)
162
+ self.assertEqual(results[0]["title"], "Main and more")
163
+
164
+ def test_entity_bearing_fields_produce_well_formed_xml(self):
165
+ """F4: decoded text must be re-escaped at emit time.
166
+
167
+ A bare `&` from `&amp;` (or from a URL query string) makes the
168
+ <search_results> document malformed.
169
+ """
170
+ xml_text = self.search.format_xml([
171
+ {
172
+ "title": "Q&A",
173
+ "url": "https://x.example/?a=1&b=2",
174
+ "snippet": "A & B and C++ <tips>",
175
+ }
176
+ ])
177
+ try:
178
+ xml.dom.minidom.parseString(xml_text)
179
+ except Exception as e: # pragma: no cover - failure path
180
+ self.fail(f"format_xml produced malformed XML: {e}\n{xml_text}")
181
+ self.assertIn("&amp;", xml_text)
182
+ self.assertIn("&lt;tips&gt;", xml_text)
183
+
184
+ def test_entity_roundtrip_through_parser_and_formatter(self):
185
+ """HTML `&amp;` decodes to `&` then re-encodes to `&amp;`."""
186
+ html = (
187
+ "<table><tr>"
188
+ '<td><a class="result-link" href="https://a.example/x">Q&amp;A</a></td>'
189
+ '<td class="result-snippet">A &amp; B</td>'
190
+ "</tr></table>"
191
+ )
192
+ results = self._parse(html)
193
+ self.assertEqual(results[0]["title"], "Q&A")
194
+ self.assertEqual(results[0]["snippet"], "A & B")
195
+ xml_text = self.search.format_xml([{
196
+ "title": results[0]["title"],
197
+ "url": "https://a.example/x",
198
+ "snippet": results[0]["snippet"],
199
+ }])
200
+ xml.dom.minidom.parseString(xml_text) # raises if malformed
201
+ self.assertIn("<title>Q&amp;A</title>", xml_text)
202
+ self.assertIn("<snippet>A &amp; B</snippet>", xml_text)
203
+
204
+
205
+ class TestDivergenceAggregation(unittest.TestCase):
206
+ """F6: multi-scope runs must reduce deterministically, not by traversal order."""
207
+
208
+ @classmethod
209
+ def setUpClass(cls):
210
+ cls.div = _load_module(
211
+ "scripts/divergence_experiment.py", "iumbtems_divergence"
212
+ )
213
+
214
+ def _write_audits(self, base: Path, by_scope: dict):
215
+ for scope, summary in by_scope.items():
216
+ d = base / "scratchpads" / scope
217
+ d.mkdir(parents=True, exist_ok=True)
218
+ (d / "audit_report.json").write_text(
219
+ json.dumps({"summary": summary}), encoding="utf-8"
220
+ )
221
+
222
+ def test_single_scope_is_unchanged(self):
223
+ with tempfile.TemporaryDirectory() as tmp:
224
+ base = Path(tmp)
225
+ self._write_audits(base, {"scope_a": {"divergence_score": 0.5, "verified_passed": True}})
226
+ score, verified = self.div._extract_audit_divergence(base)
227
+ self.assertAlmostEqual(score, 0.5)
228
+ self.assertTrue(verified)
229
+
230
+ def test_multiple_scopes_reduce_to_mean_not_first_or_last(self):
231
+ """The rule is the mean — independent of scope-name sort order."""
232
+ with tempfile.TemporaryDirectory() as tmp:
233
+ base = Path(tmp)
234
+ self._write_audits(base, {
235
+ "aaa_first": {"divergence_score": 0.0, "verified_passed": True},
236
+ "mmm_mid": {"divergence_score": 0.5, "verified_passed": True},
237
+ "zzz_last": {"divergence_score": 1.0, "verified_passed": False},
238
+ })
239
+ score, verified = self.div._extract_audit_divergence(base)
240
+ self.assertAlmostEqual(score, 0.5)
241
+ # Not the first scope's 0.0, not the last scope's 1.0.
242
+ self.assertNotAlmostEqual(score, 0.0)
243
+ self.assertNotAlmostEqual(score, 1.0)
244
+ # verified requires every scored audit to pass.
245
+ self.assertFalse(verified)
246
+
247
+ def test_unscored_audits_are_skipped(self):
248
+ with tempfile.TemporaryDirectory() as tmp:
249
+ base = Path(tmp)
250
+ self._write_audits(base, {
251
+ "aaa": {"verified_passed": True}, # no score
252
+ "bbb": {"divergence_score": 0.25, "verified_passed": True},
253
+ })
254
+ score, verified = self.div._extract_audit_divergence(base)
255
+ self.assertAlmostEqual(score, 0.25)
256
+ self.assertTrue(verified)
257
+
258
+ def test_no_audits_returns_none(self):
259
+ with tempfile.TemporaryDirectory() as tmp:
260
+ score, verified = self.div._extract_audit_divergence(Path(tmp))
261
+ self.assertIsNone(score)
262
+ self.assertIsNone(verified)
263
+
264
+
265
+ class TestBackendValidation(unittest.TestCase):
266
+ """The backend check must resolve like subprocess, not allowlist five names."""
267
+
268
+ @classmethod
269
+ def setUpClass(cls):
270
+ cls.swarm = _load_module("runner/research_swarm.py", "iumbtems_swarm")
271
+
272
+ def test_known_shortcut_names_pass(self):
273
+ self.assertEqual(
274
+ self.swarm._validate_backend(["claude", "-p"]), ["claude", "-p"]
275
+ )
276
+
277
+ def test_path_resolvable_backends_are_accepted(self):
278
+ """`python3.11`, `sh`, `npx`… were rejected by the five-name allowlist."""
279
+ for name in ("python3", "sh", "env"):
280
+ with self.subTest(backend=name):
281
+ self.assertEqual(self.swarm._validate_backend([name]), [name])
282
+
283
+ def test_absolute_path_to_executable_is_accepted(self):
284
+ resolved = self.swarm.shutil.which("python3")
285
+ self.assertIsNotNone(resolved, "python3 must be on PATH for this test")
286
+ self.assertEqual(self.swarm._validate_backend([resolved]), [resolved])
287
+
288
+ def test_unresolvable_backend_still_raises(self):
289
+ with self.assertRaises(ValueError):
290
+ self.swarm._validate_backend(["definitely-not-a-real-binary-xyz"])
291
+
292
+ def test_empty_backend_falls_back_to_default(self):
293
+ self.assertEqual(self.swarm._validate_backend([]), ["claude", "-p"])
294
+
295
+ def test_bad_backend_becomes_runtime_error_not_propagated_valueerror(self):
296
+ """A bad backend must surface as a run failure, not abort the swarm."""
297
+ runner = self.swarm.SwarmRunner(mock_mode=False, base_dir=Path(tempfile.mkdtemp()))
298
+ original = runner.build_agent_cmd
299
+ runner.build_agent_cmd = lambda *a, **k: ["definitely-not-a-real-binary-xyz"]
300
+ try:
301
+ with self.assertRaises(RuntimeError) as ctx:
302
+ runner.run_claude_process("prompt")
303
+ self.assertIn("Invalid agent backend", str(ctx.exception))
304
+ finally:
305
+ runner.build_agent_cmd = original
306
+
307
+
308
+ class TestPathSafety(unittest.TestCase):
309
+ """The containment helper must actually contain (and be importable)."""
310
+
311
+ def test_relative_inside_base_is_allowed(self):
312
+ with tempfile.TemporaryDirectory() as tmp:
313
+ p = safe_resolve_path("sub/file.json", tmp)
314
+ self.assertTrue(str(p).startswith(os.path.realpath(tmp)))
315
+
316
+ def test_dotdot_escape_is_rejected(self):
317
+ with tempfile.TemporaryDirectory() as tmp:
318
+ with self.assertRaises(ValueError):
319
+ safe_resolve_path("../outside.json", tmp)
320
+
321
+ def test_absolute_outside_base_is_rejected(self):
322
+ with tempfile.TemporaryDirectory() as tmp:
323
+ with self.assertRaises(ValueError):
324
+ safe_resolve_path("/etc/passwd", tmp)
325
+
326
+ def test_absolute_inside_base_is_allowed(self):
327
+ with tempfile.TemporaryDirectory() as tmp:
328
+ inner = Path(tmp) / "sub"
329
+ inner.mkdir()
330
+ p = safe_resolve_path(str(inner), tmp)
331
+ self.assertEqual(p, Path(os.path.realpath(str(inner))))
332
+
333
+ def test_safe_join_rejects_separators_and_dotdot(self):
334
+ with tempfile.TemporaryDirectory() as tmp:
335
+ for bad in ("../x", "a/b", "..", "", "."):
336
+ with self.subTest(segment=bad):
337
+ with self.assertRaises(ValueError):
338
+ safe_join(tmp, bad)
339
+
340
+ def test_safe_join_accepts_bare_names(self):
341
+ with tempfile.TemporaryDirectory() as tmp:
342
+ p = safe_join(tmp, "abc123.json")
343
+ self.assertEqual(p, Path(os.path.realpath(tmp)) / "abc123.json")
344
+
345
+
346
+ class TestKeyFileFailsLoudly(unittest.TestCase):
347
+ """A typo'd --key-file must not silently export an unsigned bundle."""
348
+
349
+ def test_pcrb_resolve_key_rejects_missing_file(self):
350
+ from runner.pcrb import _resolve_key
351
+ with self.assertRaises(ValueError):
352
+ _resolve_key(None, Path("/definitely/not/a/real/key/file"))
353
+
354
+ def test_pcrb_resolve_key_rejects_directory(self):
355
+ from runner.pcrb import _resolve_key
356
+ with tempfile.TemporaryDirectory() as tmp:
357
+ with self.assertRaises(ValueError):
358
+ _resolve_key(None, Path(tmp))
359
+
360
+ def test_pcrb_resolve_key_reads_valid_file(self):
361
+ from runner.pcrb import _resolve_key
362
+ with tempfile.TemporaryDirectory() as tmp:
363
+ kf = Path(tmp) / "key.txt"
364
+ kf.write_text("secret\n", encoding="utf-8")
365
+ self.assertEqual(_resolve_key(None, kf), b"secret")
366
+
367
+ def test_pcrb_resolve_key_absent_returns_none(self):
368
+ from runner.pcrb import _resolve_key
369
+ self.assertIsNone(_resolve_key(None, None))
370
+
371
+
372
+ if __name__ == "__main__":
373
+ unittest.main()
@@ -23,11 +23,12 @@ import argparse
23
23
  import contextlib
24
24
  import io
25
25
  import json
26
+ import os
26
27
  import sys
27
28
  import time
28
29
  from datetime import datetime, timezone
29
30
  from pathlib import Path
30
- from typing import Any, Dict, Optional
31
+ from typing import Any, Dict, Optional, Tuple
31
32
 
32
33
  PROJECT_ROOT = Path(__file__).resolve().parent.parent
33
34
  if str(PROJECT_ROOT) not in sys.path:
@@ -41,10 +42,37 @@ GATE_BAR_CLAIMS_PER_TOKEN = 0.20
41
42
 
42
43
 
43
44
  def load_fixture(path: Path = FIXTURE) -> Dict[str, Any]:
44
- with open(path, "r", encoding="utf-8") as f:
45
+ canonical_path = Path(os.path.realpath(str(path)))
46
+ with open(canonical_path, "r", encoding="utf-8") as f:
45
47
  return json.load(f)
46
48
 
47
49
 
50
+ def _collect_scope_metrics(scope_dir: Path) -> Tuple[int, int, int]:
51
+ chars = 0
52
+ verified = 0
53
+ tokens = 0
54
+ for name in ("alpha_dossier.json", "beta_dossier.json"):
55
+ p = scope_dir / name
56
+ if p.exists():
57
+ chars += p.stat().st_size
58
+ audit_path = scope_dir / "audit_report.json"
59
+ if audit_path.exists():
60
+ try:
61
+ summary = json.loads(audit_path.read_text(encoding="utf-8")).get("summary", {})
62
+ verified += int(summary.get("verified_passed", 0))
63
+ except (OSError, json.JSONDecodeError):
64
+ pass
65
+ manifest_path = scope_dir / "manifest.json"
66
+ if manifest_path.exists():
67
+ try:
68
+ telemetry = json.loads(manifest_path.read_text(encoding="utf-8")).get("telemetry", {})
69
+ if "tokens_used" in telemetry:
70
+ tokens += int(telemetry["tokens_used"])
71
+ except (OSError, json.JSONDecodeError):
72
+ pass
73
+ return chars, verified, tokens
74
+
75
+
48
76
  def run_once(
49
77
  fixture: Dict[str, Any],
50
78
  base_dir: Path,
@@ -52,11 +80,7 @@ def run_once(
52
80
  mock: bool,
53
81
  ) -> Dict[str, Any]:
54
82
  """One full swarm run under one allocation policy. Returns metrics."""
55
- # Seed a fixed 4-scope manifest by pre-writing the scope list through a
56
- # stub orchestrator: we pass scopes via a tiny monkeypatch so both arms
57
- # see the IDENTICAL scope set (fair comparison).
58
83
  runner = SwarmRunner(base_dir=base_dir, mock_mode=mock, allocation=allocation)
59
-
60
84
  fixed_scopes = fixture["scopes"]
61
85
 
62
86
  def fixed_orchestrate(objective, frontier_file=None):
@@ -72,32 +96,15 @@ def run_once(
72
96
  runner.run_swarm(fixture["objective"])
73
97
  elapsed = time.perf_counter() - started
74
98
 
75
- # Metrics from scope manifests + audit reports.
76
99
  total_chars = 0
77
100
  total_tokens_est = 0
78
101
  verified = 0
79
102
  for s in fixed_scopes:
80
103
  sid = s["scope_id"]
81
- scope_dir = base_dir / "scratchpads" / sid
82
- for name in ("alpha_dossier.json", "beta_dossier.json"):
83
- p = scope_dir / name
84
- if p.exists():
85
- total_chars += p.stat().st_size
86
- audit_path = scope_dir / "audit_report.json"
87
- if audit_path.exists():
88
- try:
89
- summary = json.loads(audit_path.read_text()).get("summary", {})
90
- verified += int(summary.get("verified_passed", 0))
91
- except (OSError, json.JSONDecodeError):
92
- pass
93
- manifest_path = scope_dir / "manifest.json"
94
- if manifest_path.exists():
95
- try:
96
- telemetry = json.loads(manifest_path.read_text()).get("telemetry", {})
97
- if "tokens_used" in telemetry:
98
- total_tokens_est += int(telemetry["tokens_used"])
99
- except (OSError, json.JSONDecodeError):
100
- pass
104
+ c, v, t = _collect_scope_metrics(base_dir / "scratchpads" / sid)
105
+ total_chars += c
106
+ verified += v
107
+ total_tokens_est += t
101
108
 
102
109
  if total_tokens_est == 0:
103
110
  total_tokens_est = estimate_tokens("x" * total_chars)
@@ -107,7 +114,7 @@ def run_once(
107
114
  "wall_clock_s": round(elapsed, 3),
108
115
  "verified_claims": verified,
109
116
  "chars": total_chars,
110
- "tokens_estimate": total_tokens_est, # FLAGGED APPROXIMATION (chars/4)
117
+ "tokens_estimate": total_tokens_est,
111
118
  "claims_per_token": (
112
119
  round(verified / total_tokens_est, 6) if total_tokens_est else None
113
120
  ),
@@ -121,8 +128,9 @@ def main(argv: Optional[list] = None) -> int:
121
128
  parser.add_argument("--fixture", default=str(FIXTURE))
122
129
  args = parser.parse_args(argv)
123
130
 
124
- fixture = load_fixture(Path(args.fixture))
125
- out_dir = Path(args.out_dir) if args.out_dir else (PROJECT_ROOT / ".research" / "experiments")
131
+ canonical_fixture = Path(os.path.realpath(str(args.fixture)))
132
+ fixture = load_fixture(canonical_fixture)
133
+ out_dir = Path(os.path.realpath(str(args.out_dir))) if args.out_dir else (PROJECT_ROOT / ".research" / "experiments")
126
134
  out_dir.mkdir(parents=True, exist_ok=True)
127
135
 
128
136
  results = {}