@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.
- package/.claude-plugin/plugin.json +1 -1
- package/bin/cli.js +59 -7
- package/config/mcp_launcher.py +75 -48
- package/extensions/pi/index.js +25 -5
- package/install.sh +5 -5
- package/package.json +1 -1
- package/plugins/antigravity/plugin.json +1 -1
- package/plugins/gemini/gemini-extension.json +1 -1
- package/plugins/opencode/index.js +308 -142
- package/plugins/opencode/tui.js +7 -9
- package/plugins/research-cache/skills/research_cache/hasher.py +27 -9
- package/plugins/socratic-grilling/skills/grilling/socratic_tree.py +26 -18
- package/runner/__pycache__/__init__.cpython-311.pyc +0 -0
- package/runner/__pycache__/auctioneer.cpython-311.pyc +0 -0
- package/runner/__pycache__/auditor_engine.cpython-311.pyc +0 -0
- package/runner/__pycache__/claim_store.cpython-311.pyc +0 -0
- package/runner/__pycache__/claim_witness.cpython-311.pyc +0 -0
- package/runner/__pycache__/living_dossiers.cpython-311.pyc +0 -0
- package/runner/__pycache__/mcp_protocol.cpython-311.pyc +0 -0
- package/runner/__pycache__/mcp_server.cpython-311.pyc +0 -0
- package/runner/__pycache__/path_safety.cpython-311.pyc +0 -0
- package/runner/__pycache__/pcrb.cpython-311.pyc +0 -0
- package/runner/__pycache__/pcrb_verify.cpython-311.pyc +0 -0
- package/runner/__pycache__/refinement.cpython-311.pyc +0 -0
- package/runner/__pycache__/research_swarm.cpython-311.pyc +0 -0
- package/runner/__pycache__/state_machine.cpython-311.pyc +0 -0
- package/runner/auditor_engine.py +122 -102
- package/runner/claim_store.py +5 -4
- package/runner/living_dossiers.py +32 -10
- package/runner/mcp_protocol.py +1 -1
- package/runner/mcp_server.py +23 -11
- package/runner/path_safety.py +62 -0
- package/runner/pcrb.py +68 -39
- package/runner/pcrb_verify.py +82 -52
- package/runner/refinement.py +61 -47
- package/runner/research_swarm.py +61 -7
- package/runner/state_machine.py +9 -6
- package/runner/tests/__pycache__/test_auction_order.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_bet1_spike.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_claim_store.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_claim_witness.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_domain_packs.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_fleet_seam.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_living_dossiers.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_mcp_server.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_opencode_ux.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_pcrb.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_refinement.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_swarm.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_sweep_regressions.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_webcache.cpython-311.pyc +0 -0
- package/runner/tests/test_mcp_server.py +0 -1
- package/runner/tests/test_opencode_ux.py +368 -33
- package/runner/tests/test_swarm.py +20 -9
- package/runner/tests/test_sweep_regressions.py +373 -0
- package/scripts/__pycache__/bet1_advisory_spike.cpython-311.pyc +0 -0
- package/scripts/__pycache__/build_adapters.cpython-311.pyc +0 -0
- package/scripts/__pycache__/divergence_experiment.cpython-311.pyc +0 -0
- package/scripts/auction_experiment.py +38 -30
- package/scripts/bet1_advisory_spike.py +41 -25
- package/scripts/build_adapters.py +52 -37
- package/scripts/divergence_experiment.py +61 -31
- package/skills/brainstorming/scripts/brainstorm.py +3 -2
- package/skills/epistemic_search/scripts/__pycache__/search.cpython-311.pyc +0 -0
- package/skills/epistemic_search/scripts/fetch.py +0 -1
- package/skills/epistemic_search/scripts/search.py +202 -73
- package/skills/epistemic_search/scripts/webcache.py +1 -1
- package/skills/grilling/socratic_tree.py +26 -18
- package/skills/research_cache/__pycache__/__init__.cpython-311.pyc +0 -0
- package/skills/research_cache/__pycache__/hasher.cpython-311.pyc +0 -0
- package/skills/research_cache/hasher.py +27 -9
- package/skills/swarm_config/__pycache__/__init__.cpython-311.pyc +0 -0
- package/skills/swarm_config/__pycache__/configure.cpython-311.pyc +0 -0
- 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 `&` (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("&", xml_text)
|
|
182
|
+
self.assertIn("<tips>", xml_text)
|
|
183
|
+
|
|
184
|
+
def test_entity_roundtrip_through_parser_and_formatter(self):
|
|
185
|
+
"""HTML `&` decodes to `&` then re-encodes to `&`."""
|
|
186
|
+
html = (
|
|
187
|
+
"<table><tr>"
|
|
188
|
+
'<td><a class="result-link" href="https://a.example/x">Q&A</a></td>'
|
|
189
|
+
'<td class="result-snippet">A & 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&A</title>", xml_text)
|
|
202
|
+
self.assertIn("<snippet>A & 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()
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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,
|
|
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
|
-
|
|
125
|
-
|
|
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 = {}
|