Skip to content

Commit 0861823

Browse files
sriumcpclaude
andcommitted
feat: campaign-index pure functions, foundation for nous-mcp (AI-native-Systems-Research#126 Phase A)
The MCP server (AI-native-Systems-Research#126) exposes campaigns as resources and tools. Phase A ships the pure-function layer that the eventual stdio MCP transport will wrap: list_campaigns, search_principles, get_arm_results, compare_iterations. Each function takes a search/campaign root on disk and returns JSON-friendly dicts/lists; no MCP runtime dependency, no network, no global state. Why split A/B: shipping the pure functions first means * the CLI can use them too (a future "nous list", "nous find-principle" has zero new code to write — just argparse plumbing), * Routines (AI-native-Systems-Research#134) can publish findings into the same store via the same API, * the MCP transport choice (stdio JSON-RPC, the mcp Python SDK version pin, etc.) is a separate review without coupling to the indexing logic. Phase A surface: list_campaigns(search_root, *, query, status, repo) -> [summary] Walks search_root for campaign roots (state.json + ledger.json), filters by run_id substring / phase / repo, returns sorted summaries. completed_iterations comes from ledger; active_principles filters by status=="active" so retired entries don't inflate the count. search_principles(search_root, text, *, only_active) -> [hit] Case-insensitive substring match against statement / description / category / id. Default skips retired. Sorted by (run_id, principle.id). Embedding-based search noted in the issue is gated on OPENAI_API_KEY and ships as Phase B. get_arm_results(campaign_root, iteration, arm) -> {seeds: [...]} Reads runs/iter-N/results/<arm>/<seed>/. Returns relative file paths, sorted, so MCP clients have stable references. compare_iterations(campaign_root, iter_a, iter_b) -> {a, b, delta} Deterministic diff: arm_status_changes, principles_added. Calling twice on the same data must produce byte-equal output — no timestamps, no map iteration order leaks. The acceptance criterion for AI-native-Systems-Research#126 explicitly calls out determinism. Out of scope (Phase B): - The stdio MCP server itself (bin/nous-mcp, ~/.claude.json snippet). - Embedding-based semantic search behind OPENAI_API_KEY. Behavioral tests (17 in tests/test_campaign_index.py): list_campaigns: - returns three synthesized campaigns with expected counts/phases - query="saturation" filters down to that one run - status="DONE" filters by phase - active_principles count excludes status=="retired" entries - results are sorted by run_id (determinism) - empty search root returns [] - repo path resolves to <repo> when work_dir was created at <repo>/.nous/<run-id> search_principles: - finds principle by substring in statement - case-insensitive - skips retired by default; only_active=False includes them - sorted by (run_id, principle.id) — determinism get_arm_results: - aggregates multiple seeds with file listings sorted - missing arm returns empty seeds list compare_iterations: - arm status change appears in delta; unchanged arms don't - principles_added is a sorted set difference between iter updates - byte-equal output across repeated calls All assertions describe what the function returned given on-disk inputs. None inspect helper invocations or internal walk order. The walk implementation can change freely as long as the contract holds. Test suite: 338 baseline + 17 new = 355 passing. Refs AI-native-Systems-Research#120, AI-native-Systems-Research#126. Issue stays open pending Phase B (MCP transport). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 49510b3 commit 0861823

2 files changed

Lines changed: 583 additions & 0 deletions

File tree

orchestrator/campaign_index.py

Lines changed: 334 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,334 @@
1+
"""Campaign index — pure functions over the on-disk artifact tree (#126).
2+
3+
These functions are the contract that ``nous-mcp`` (a stdio MCP server,
4+
shipped in a follow-up phase) exposes as resources and tools. Keeping
5+
them pure and import-free of MCP itself means:
6+
7+
* They're trivially testable without spinning up an MCP transport.
8+
* The CLI can use them too (``nous list``, ``nous find-principle``)
9+
without coupling to the MCP runtime.
10+
* A future Routines invocation (#134) can use the same functions to
11+
publish findings into a shared store.
12+
13+
Conventions:
14+
15+
* A "campaign root" is a directory containing ``state.json``,
16+
``ledger.json``, ``principles.json``. Typically ``<repo>/.nous/<run-id>``.
17+
* A "search root" is a directory under which we walk to find campaign
18+
roots. Searches are bounded to depth 4 so we don't accidentally walk
19+
a giant repo.
20+
* Functions return plain ``dict``/``list`` JSON-friendly structures so
21+
MCP serialization is a no-op.
22+
"""
23+
from __future__ import annotations
24+
25+
import json
26+
import re
27+
from dataclasses import dataclass, field
28+
from pathlib import Path
29+
from typing import Any, Iterable
30+
31+
_MAX_DEPTH = 4
32+
33+
34+
def _walk_campaign_roots(search_root: Path, max_depth: int = _MAX_DEPTH) -> Iterable[Path]:
35+
"""Yield directories under ``search_root`` that look like campaign roots."""
36+
search_root = Path(search_root)
37+
if not search_root.is_dir():
38+
return
39+
stack: list[tuple[Path, int]] = [(search_root, 0)]
40+
while stack:
41+
path, depth = stack.pop()
42+
if depth > max_depth:
43+
continue
44+
try:
45+
entries = list(path.iterdir())
46+
except (PermissionError, OSError):
47+
continue
48+
for entry in entries:
49+
if not entry.is_dir():
50+
continue
51+
# Heuristic: a campaign root has state.json + ledger.json.
52+
if (entry / "state.json").exists() and (entry / "ledger.json").exists():
53+
yield entry
54+
# Don't descend further inside a campaign root — its
55+
# subdirs (runs/iter-N) aren't themselves campaigns.
56+
continue
57+
stack.append((entry, depth + 1))
58+
59+
60+
def _read_json(path: Path) -> Any:
61+
try:
62+
return json.loads(path.read_text())
63+
except (json.JSONDecodeError, OSError):
64+
return None
65+
66+
67+
@dataclass
68+
class CampaignSummary:
69+
run_id: str
70+
path: str
71+
phase: str
72+
iteration: int
73+
completed_iterations: int
74+
active_principles: int
75+
repo: str | None = None
76+
77+
def as_dict(self) -> dict[str, Any]:
78+
return {
79+
"run_id": self.run_id,
80+
"path": self.path,
81+
"phase": self.phase,
82+
"iteration": self.iteration,
83+
"completed_iterations": self.completed_iterations,
84+
"active_principles": self.active_principles,
85+
"repo": self.repo,
86+
}
87+
88+
89+
def _summarize(root: Path) -> CampaignSummary | None:
90+
state = _read_json(root / "state.json")
91+
if not isinstance(state, dict):
92+
return None
93+
ledger = _read_json(root / "ledger.json")
94+
completed = 0
95+
if isinstance(ledger, dict):
96+
rows = ledger.get("iterations", [])
97+
if isinstance(rows, list):
98+
completed = sum(
99+
1 for r in rows
100+
if isinstance(r, dict) and isinstance(r.get("iteration"), int)
101+
and r["iteration"] >= 1
102+
)
103+
principles = _read_json(root / "principles.json")
104+
active = 0
105+
if isinstance(principles, dict):
106+
plist = principles.get("principles", [])
107+
if isinstance(plist, list):
108+
active = sum(
109+
1 for p in plist
110+
if isinstance(p, dict) and p.get("status", "active") == "active"
111+
)
112+
# Best-effort: target repo is the great-grandparent when work_dir
113+
# was created as <repo>/.nous/<run-id>.
114+
repo: str | None = None
115+
if root.parent.name == ".nous":
116+
repo = str(root.parent.parent.resolve())
117+
return CampaignSummary(
118+
run_id=state.get("run_id", root.name),
119+
path=str(root.resolve()),
120+
phase=state.get("phase", "UNKNOWN"),
121+
iteration=int(state.get("iteration", 0) or 0),
122+
completed_iterations=completed,
123+
active_principles=active,
124+
repo=repo,
125+
)
126+
127+
128+
# ─── list_campaigns ─────────────────────────────────────────────────────────
129+
130+
131+
def list_campaigns(
132+
search_root: Path,
133+
*,
134+
query: str | None = None,
135+
status: str | None = None,
136+
repo: str | None = None,
137+
) -> list[dict[str, Any]]:
138+
"""List campaign summaries under ``search_root``.
139+
140+
Args:
141+
search_root: directory to walk.
142+
query: case-insensitive substring filter against run_id.
143+
status: filter on state.phase (``DONE``, ``EXECUTE_ANALYZE``, etc.).
144+
repo: filter on resolved repo path (substring match).
145+
146+
Returns: list of summary dicts, sorted by run_id.
147+
"""
148+
out: list[dict[str, Any]] = []
149+
for root in _walk_campaign_roots(Path(search_root)):
150+
summary = _summarize(root)
151+
if summary is None:
152+
continue
153+
if query and query.lower() not in summary.run_id.lower():
154+
continue
155+
if status and summary.phase != status:
156+
continue
157+
if repo:
158+
if not summary.repo or repo not in summary.repo:
159+
continue
160+
out.append(summary.as_dict())
161+
out.sort(key=lambda d: d["run_id"])
162+
return out
163+
164+
165+
# ─── search_principles ────────────────────────────────────────────────────
166+
167+
168+
@dataclass
169+
class PrincipleHit:
170+
run_id: str
171+
path: str # campaign root
172+
principle: dict[str, Any]
173+
score: float = 1.0 # placeholder for future semantic search
174+
175+
def as_dict(self) -> dict[str, Any]:
176+
return {
177+
"run_id": self.run_id,
178+
"path": self.path,
179+
"score": self.score,
180+
"principle": self.principle,
181+
}
182+
183+
184+
def search_principles(
185+
search_root: Path,
186+
text: str,
187+
*,
188+
only_active: bool = True,
189+
) -> list[dict[str, Any]]:
190+
"""Find principles whose statement/description matches ``text``.
191+
192+
Phase A is plain case-insensitive substring matching; the issue notes
193+
embedding-based search as an optional follow-up gated on
194+
``OPENAI_API_KEY``.
195+
"""
196+
needle = text.lower().strip()
197+
if not needle:
198+
return []
199+
hits: list[PrincipleHit] = []
200+
for root in _walk_campaign_roots(Path(search_root)):
201+
principles = _read_json(root / "principles.json")
202+
if not isinstance(principles, dict):
203+
continue
204+
plist = principles.get("principles", [])
205+
if not isinstance(plist, list):
206+
continue
207+
state = _read_json(root / "state.json") or {}
208+
run_id = state.get("run_id", root.name)
209+
for p in plist:
210+
if not isinstance(p, dict):
211+
continue
212+
if only_active and p.get("status", "active") != "active":
213+
continue
214+
haystack = " ".join(
215+
str(p.get(field, "")) for field in
216+
("statement", "description", "category", "id")
217+
).lower()
218+
if needle in haystack:
219+
hits.append(PrincipleHit(
220+
run_id=run_id, path=str(root.resolve()),
221+
principle=p,
222+
))
223+
# Stable order: by run_id, then principle id.
224+
hits.sort(key=lambda h: (h.run_id, str(h.principle.get("id", ""))))
225+
return [h.as_dict() for h in hits]
226+
227+
228+
# ─── get_arm_results ──────────────────────────────────────────────────────
229+
230+
231+
def get_arm_results(
232+
campaign_root: Path,
233+
iteration: int,
234+
arm: str,
235+
) -> dict[str, Any]:
236+
"""Aggregate results for one arm of one iteration.
237+
238+
Returns: ``{"arm": ..., "iteration": N, "seeds": [{"seed": ..., "files": [...]}]}``.
239+
Seeds and their result files are read from ``runs/iter-N/results/<arm>/<seed>/``.
240+
"""
241+
campaign_root = Path(campaign_root)
242+
arm_dir = campaign_root / "runs" / f"iter-{iteration}" / "results" / arm
243+
seeds: list[dict[str, Any]] = []
244+
if arm_dir.is_dir():
245+
for seed_dir in sorted(arm_dir.iterdir()):
246+
if not seed_dir.is_dir():
247+
continue
248+
files = sorted(
249+
str(p.relative_to(campaign_root))
250+
for p in seed_dir.rglob("*") if p.is_file()
251+
)
252+
seeds.append({"seed": seed_dir.name, "files": files})
253+
return {"arm": arm, "iteration": iteration, "seeds": seeds}
254+
255+
256+
# ─── compare_iterations ───────────────────────────────────────────────────
257+
258+
259+
def compare_iterations(
260+
campaign_root: Path,
261+
iter_a: int,
262+
iter_b: int,
263+
) -> dict[str, Any]:
264+
"""Deterministic diff between two iterations' findings.
265+
266+
Returns the high-level shape:
267+
``{"a": <findings>, "b": <findings>, "delta": {...}}``.
268+
269+
The delta names which arms changed status (e.g. CONFIRMED → REFUTED)
270+
and which principles were added between the two iterations. No
271+
timestamps, no stochastic ordering — calling this twice on the same
272+
data must produce byte-equal output.
273+
"""
274+
campaign_root = Path(campaign_root)
275+
276+
def _findings(n: int) -> dict[str, Any] | None:
277+
f = _read_json(campaign_root / "runs" / f"iter-{n}" / "findings.json")
278+
return f if isinstance(f, dict) else None
279+
280+
a = _findings(iter_a) or {}
281+
b = _findings(iter_b) or {}
282+
283+
def _arm_status_map(f: dict) -> dict[str, str]:
284+
out: dict[str, str] = {}
285+
for arm in f.get("arms", []) or []:
286+
if isinstance(arm, dict):
287+
out[str(arm.get("arm_id", ""))] = str(arm.get("status", ""))
288+
return dict(sorted(out.items()))
289+
290+
delta = {
291+
"iter_a": iter_a,
292+
"iter_b": iter_b,
293+
"arm_status_changes": _arm_status_diff(_arm_status_map(a), _arm_status_map(b)),
294+
"principles_added": _principles_added(campaign_root, iter_a, iter_b),
295+
}
296+
return {"a": a, "b": b, "delta": delta}
297+
298+
299+
def _arm_status_diff(a: dict[str, str], b: dict[str, str]) -> list[dict[str, str]]:
300+
changes = []
301+
for arm_id in sorted(set(a) | set(b)):
302+
sa = a.get(arm_id, "absent")
303+
sb = b.get(arm_id, "absent")
304+
if sa != sb:
305+
changes.append({"arm_id": arm_id, "from": sa, "to": sb})
306+
return changes
307+
308+
309+
def _principles_added(root: Path, iter_a: int, iter_b: int) -> list[str]:
310+
def _ids(n: int) -> set[str]:
311+
u = _read_json(root / "runs" / f"iter-{n}" / "principle_updates.json")
312+
if not isinstance(u, list):
313+
return set()
314+
return {str(p.get("id", "")) for p in u if isinstance(p, dict) and "id" in p}
315+
return sorted(_ids(iter_b) - _ids(iter_a))
316+
317+
318+
# ─── Resource paths (the strings the MCP server publishes as resources) ──
319+
320+
321+
def resource_uri_for_campaign(run_id: str) -> str:
322+
return f"nous://campaigns/{run_id}"
323+
324+
325+
def resource_uri_for_state(run_id: str) -> str:
326+
return f"nous://campaigns/{run_id}/state"
327+
328+
329+
def resource_uri_for_principles(run_id: str) -> str:
330+
return f"nous://campaigns/{run_id}/principles"
331+
332+
333+
def resource_uri_for_iter_findings(run_id: str, iteration: int) -> str:
334+
return f"nous://campaigns/{run_id}/iter/{iteration}/findings"

0 commit comments

Comments
 (0)