maestro-flow 0.4.20 → 0.4.21

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 (136) hide show
  1. package/.agents/skills/maestro-ralph-execute/SKILL.md +2 -1
  2. package/.agents/skills/maestro-swarm-workflow/SKILL.md +27 -19
  3. package/.agents/skills/maestro-universal-workflow/SKILL.md +563 -0
  4. package/.agents/skills/team-adversarial-swarm/SKILL.md +235 -0
  5. package/.agents/skills/team-adversarial-swarm/scripts/aco.py +473 -0
  6. package/.agents/skills/team-adversarial-swarm/scripts/pheromone.py +144 -0
  7. package/.agents/skills/team-adversarial-swarm/scripts/scoring.py +92 -0
  8. package/.agents/skills/team-adversarial-swarm/scripts/test_aco.py +475 -0
  9. package/.agents/skills/team-adversarial-swarm/specs/ant-output-schema.md +115 -0
  10. package/.agents/skills/team-adversarial-swarm/specs/convergence-criteria.md +75 -0
  11. package/.agents/skills/team-adversarial-swarm/specs/pheromone-schema.md +90 -0
  12. package/.agents/skills/team-adversarial-swarm/specs/swarm-config-template.json +66 -0
  13. package/.agents/skills/team-adversarial-swarm/specs/swarm-protocol.md +105 -0
  14. package/.agents/skills/team-adversarial-swarm/workflows/wf-swarm-converge.js +197 -0
  15. package/.agents/skills/team-adversarial-swarm/workflows/wf-swarm-explore.js +194 -0
  16. package/.agents/skills/team-adversarial-swarm/workflows/wf-swarm-score.js +188 -0
  17. package/.agents/skills/team-adversarial-swarm/workflows/wf-swarm-synthesize.js +248 -0
  18. package/.agy/skills/maestro-ralph-execute/SKILL.md +2 -1
  19. package/.agy/skills/maestro-swarm-workflow/SKILL.md +27 -19
  20. package/.agy/skills/maestro-universal-workflow/SKILL.md +560 -0
  21. package/.agy/skills/team-adversarial-swarm/SKILL.md +244 -0
  22. package/.agy/skills/team-adversarial-swarm/scripts/aco.py +473 -0
  23. package/.agy/skills/team-adversarial-swarm/scripts/pheromone.py +144 -0
  24. package/.agy/skills/team-adversarial-swarm/scripts/scoring.py +92 -0
  25. package/.agy/skills/team-adversarial-swarm/scripts/test_aco.py +475 -0
  26. package/.agy/skills/team-adversarial-swarm/specs/ant-output-schema.md +115 -0
  27. package/.agy/skills/team-adversarial-swarm/specs/convergence-criteria.md +75 -0
  28. package/.agy/skills/team-adversarial-swarm/specs/pheromone-schema.md +90 -0
  29. package/.agy/skills/team-adversarial-swarm/specs/swarm-config-template.json +66 -0
  30. package/.agy/skills/team-adversarial-swarm/specs/swarm-protocol.md +105 -0
  31. package/.agy/skills/team-adversarial-swarm/workflows/wf-swarm-converge.js +197 -0
  32. package/.agy/skills/team-adversarial-swarm/workflows/wf-swarm-explore.js +194 -0
  33. package/.agy/skills/team-adversarial-swarm/workflows/wf-swarm-score.js +188 -0
  34. package/.agy/skills/team-adversarial-swarm/workflows/wf-swarm-synthesize.js +248 -0
  35. package/.claude/commands/maestro-ralph-execute.md +2 -1
  36. package/.claude/commands/maestro-swarm-workflow.md +27 -19
  37. package/.claude/commands/maestro-universal-workflow.md +561 -0
  38. package/.claude/skills/team-adversarial-swarm/SKILL.md +233 -0
  39. package/.claude/skills/team-adversarial-swarm/scripts/aco.py +473 -0
  40. package/.claude/skills/team-adversarial-swarm/scripts/pheromone.py +144 -0
  41. package/.claude/skills/team-adversarial-swarm/scripts/scoring.py +92 -0
  42. package/.claude/skills/team-adversarial-swarm/scripts/test_aco.py +475 -0
  43. package/.claude/skills/team-adversarial-swarm/specs/ant-output-schema.md +115 -0
  44. package/.claude/skills/team-adversarial-swarm/specs/convergence-criteria.md +75 -0
  45. package/.claude/skills/team-adversarial-swarm/specs/pheromone-schema.md +90 -0
  46. package/.claude/skills/team-adversarial-swarm/specs/swarm-config-template.json +66 -0
  47. package/.claude/skills/team-adversarial-swarm/specs/swarm-protocol.md +105 -0
  48. package/.claude/skills/team-adversarial-swarm/workflows/wf-swarm-converge.js +197 -0
  49. package/.claude/skills/team-adversarial-swarm/workflows/wf-swarm-explore.js +194 -0
  50. package/.claude/skills/team-adversarial-swarm/workflows/wf-swarm-score.js +188 -0
  51. package/.claude/skills/team-adversarial-swarm/workflows/wf-swarm-synthesize.js +248 -0
  52. package/dashboard/dist-server/dashboard/src/server/wiki/graph-analysis.js +1 -1
  53. package/dashboard/dist-server/dashboard/src/server/wiki/graph-analysis.js.map +1 -1
  54. package/dashboard/dist-server/dashboard/src/server/wiki/search.js +1 -1
  55. package/dashboard/dist-server/dashboard/src/server/wiki/search.js.map +1 -1
  56. package/dashboard/dist-server/dashboard/src/server/wiki/virtual-wiki-adapters.d.ts +1 -1
  57. package/dashboard/dist-server/dashboard/src/server/wiki/virtual-wiki-adapters.js +5 -5
  58. package/dashboard/dist-server/dashboard/src/server/wiki/virtual-wiki-adapters.js.map +1 -1
  59. package/dashboard/dist-server/dashboard/src/server/wiki/wiki-indexer.js +3 -3
  60. package/dashboard/dist-server/dashboard/src/server/wiki/wiki-indexer.js.map +1 -1
  61. package/dashboard/dist-server/src/graph/types.d.ts +111 -0
  62. package/dashboard/dist-server/src/graph/types.js +2 -0
  63. package/dashboard/dist-server/src/graph/types.js.map +1 -0
  64. package/dist/src/commands/install-backend.d.ts +0 -7
  65. package/dist/src/commands/install-backend.d.ts.map +1 -1
  66. package/dist/src/commands/install-backend.js +0 -14
  67. package/dist/src/commands/install-backend.js.map +1 -1
  68. package/dist/src/commands/install.d.ts.map +1 -1
  69. package/dist/src/commands/install.js +0 -18
  70. package/dist/src/commands/install.js.map +1 -1
  71. package/dist/src/commands/kg.d.ts +2 -2
  72. package/dist/src/commands/kg.d.ts.map +1 -1
  73. package/dist/src/commands/kg.js +150 -179
  74. package/dist/src/commands/kg.js.map +1 -1
  75. package/dist/src/graph/analyzers/fs-analyzer.d.ts +10 -0
  76. package/dist/src/graph/analyzers/fs-analyzer.d.ts.map +1 -0
  77. package/dist/src/graph/analyzers/fs-analyzer.js +959 -0
  78. package/dist/src/graph/analyzers/fs-analyzer.js.map +1 -0
  79. package/dist/src/graph/index.d.ts +6 -0
  80. package/dist/src/graph/index.d.ts.map +1 -0
  81. package/dist/src/graph/index.js +6 -0
  82. package/dist/src/graph/index.js.map +1 -0
  83. package/dist/src/graph/loader.d.ts +3 -0
  84. package/dist/src/graph/loader.d.ts.map +1 -0
  85. package/dist/src/graph/loader.js +12 -0
  86. package/dist/src/graph/loader.js.map +1 -0
  87. package/dist/src/graph/merger.d.ts +56 -0
  88. package/dist/src/graph/merger.d.ts.map +1 -0
  89. package/dist/src/graph/merger.js +896 -0
  90. package/dist/src/graph/merger.js.map +1 -0
  91. package/dist/src/graph/query.d.ts +7 -0
  92. package/dist/src/graph/query.d.ts.map +1 -0
  93. package/dist/src/graph/query.js +126 -0
  94. package/dist/src/graph/query.js.map +1 -0
  95. package/dist/src/graph/types.d.ts +112 -0
  96. package/dist/src/graph/types.d.ts.map +1 -0
  97. package/dist/src/graph/types.js +2 -0
  98. package/dist/src/graph/types.js.map +1 -0
  99. package/dist/src/i18n/locales/en.d.ts.map +1 -1
  100. package/dist/src/i18n/locales/en.js +0 -10
  101. package/dist/src/i18n/locales/en.js.map +1 -1
  102. package/dist/src/i18n/locales/zh.d.ts.map +1 -1
  103. package/dist/src/i18n/locales/zh.js +0 -10
  104. package/dist/src/i18n/locales/zh.js.map +1 -1
  105. package/dist/src/i18n/types.d.ts +0 -9
  106. package/dist/src/i18n/types.d.ts.map +1 -1
  107. package/dist/src/tui/install-ui/InstallConfirm.d.ts +0 -1
  108. package/dist/src/tui/install-ui/InstallConfirm.d.ts.map +1 -1
  109. package/dist/src/tui/install-ui/InstallConfirm.js +1 -1
  110. package/dist/src/tui/install-ui/InstallConfirm.js.map +1 -1
  111. package/dist/src/tui/install-ui/InstallExecution.d.ts +0 -1
  112. package/dist/src/tui/install-ui/InstallExecution.d.ts.map +1 -1
  113. package/dist/src/tui/install-ui/InstallExecution.js +0 -22
  114. package/dist/src/tui/install-ui/InstallExecution.js.map +1 -1
  115. package/dist/src/tui/install-ui/InstallFlow.d.ts +1 -1
  116. package/dist/src/tui/install-ui/InstallFlow.d.ts.map +1 -1
  117. package/dist/src/tui/install-ui/InstallFlow.js +5 -23
  118. package/dist/src/tui/install-ui/InstallFlow.js.map +1 -1
  119. package/dist/src/tui/install-ui/InstallHub.d.ts +0 -2
  120. package/dist/src/tui/install-ui/InstallHub.d.ts.map +1 -1
  121. package/dist/src/tui/install-ui/InstallHub.js +0 -6
  122. package/dist/src/tui/install-ui/InstallHub.js.map +1 -1
  123. package/dist/src/tui/install-ui/InstallResult.d.ts.map +1 -1
  124. package/dist/src/tui/install-ui/InstallResult.js +1 -1
  125. package/dist/src/tui/install-ui/InstallResult.js.map +1 -1
  126. package/dist/src/utils/update-notices.js +12 -0
  127. package/dist/src/utils/update-notices.js.map +1 -1
  128. package/package.json +1 -1
  129. package/workflows/swarm/wf-analyze.js +195 -34
  130. package/workflows/swarm/wf-brainstorm.js +225 -53
  131. package/workflows/swarm/wf-execute.js +199 -23
  132. package/workflows/swarm/wf-grill.js +181 -20
  133. package/workflows/swarm/wf-milestone-audit.js +178 -29
  134. package/workflows/swarm/wf-plan.js +288 -53
  135. package/workflows/swarm/wf-review.js +195 -80
  136. package/workflows/swarm/wf-verify.js +125 -28
@@ -0,0 +1,473 @@
1
+ """ACO controller CLI - the script side of team-swarm.
2
+
3
+ Subcommands: init | select | update | converged | report
4
+ Spec: ../specs/swarm-protocol.md (script <-> coordinator contract)
5
+
6
+ All commands:
7
+ - read state from --session <path>
8
+ - emit JSON to stdout
9
+ - exit 0 success, 1 runtime error, 2 config invalid
10
+
11
+ Invoked by team-swarm coordinator via Bash. No prose output, no interactive prompts.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import glob
17
+ import json
18
+ import random
19
+ import sys
20
+ import time
21
+ from pathlib import Path
22
+ from typing import List, Optional
23
+
24
+ # Local imports (script directory)
25
+ sys.path.insert(0, str(Path(__file__).parent))
26
+ from pheromone import PheromoneState # noqa: E402
27
+ from scoring import ( # noqa: E402
28
+ FallbackScorer,
29
+ ScriptScorer,
30
+ hallucination_check,
31
+ load_verified_scores,
32
+ resolve_score,
33
+ )
34
+
35
+ VERSION = "1.0"
36
+
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Session paths
40
+ # ---------------------------------------------------------------------------
41
+
42
+ class SessionPaths:
43
+ def __init__(self, session: Path):
44
+ self.session = session
45
+ self.config = session / "swarm-config.json"
46
+ self.pheromone_dir = session / "pheromone"
47
+ self.pheromone_current = self.pheromone_dir / "current.json"
48
+ self.pheromone_init = self.pheromone_dir / "init.json"
49
+ self.pheromone_history = self.pheromone_dir / "history"
50
+ self.task_space = session / "task-space.json"
51
+ self.trails = session / "trails"
52
+ self.artifacts = session / "artifacts"
53
+ self.scores = session / "scores"
54
+ self.best = session / "best.json"
55
+
56
+ def ensure_dirs(self) -> None:
57
+ for d in [self.pheromone_dir, self.pheromone_history, self.trails, self.artifacts, self.scores]:
58
+ d.mkdir(parents=True, exist_ok=True)
59
+
60
+
61
+ def _emit(payload: dict) -> None:
62
+ print(json.dumps(payload, ensure_ascii=False))
63
+
64
+
65
+ def _fail(code: int, msg: str):
66
+ _emit({"status": "error", "message": msg})
67
+ sys.exit(code)
68
+
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # init
72
+ # ---------------------------------------------------------------------------
73
+
74
+ def _discover_nodes(spec: dict) -> List[str]:
75
+ """Resolve task_space.nodes — supports explicit list or auto_discover_from glob."""
76
+ if isinstance(spec.get("nodes"), list):
77
+ return [str(n) for n in spec["nodes"]]
78
+ if "auto_discover_from" in spec:
79
+ pattern = spec["auto_discover_from"]
80
+ # Glob relative to cwd (workspace root), not session
81
+ matches = sorted(glob.glob(pattern, recursive=True))
82
+ if not matches:
83
+ raise ValueError(f"auto_discover_from '{pattern}' matched no files")
84
+ return matches
85
+ raise ValueError("task_space requires either 'nodes' list or 'auto_discover_from' glob")
86
+
87
+
88
+ def cmd_init(args: argparse.Namespace) -> None:
89
+ paths = SessionPaths(Path(args.session))
90
+ if not paths.config.exists():
91
+ _fail(2, f"config not found: {paths.config}")
92
+ config = json.loads(paths.config.read_text())
93
+
94
+ paths.ensure_dirs()
95
+
96
+ try:
97
+ nodes = _discover_nodes(config.get("task_space", {}))
98
+ except ValueError as e:
99
+ _fail(2, str(e))
100
+ return # unreachable, satisfies type checker
101
+
102
+ # Write task-space.json
103
+ task_space = {
104
+ "nodes": nodes,
105
+ "n_nodes": len(nodes),
106
+ "max_path_length": config.get("task_space", {}).get("max_path_length", 5),
107
+ "start_nodes": config.get("task_space", {}).get("start_nodes", "any"),
108
+ "edges": config.get("task_space", {}).get("edges", "complete"),
109
+ }
110
+ paths.task_space.write_text(json.dumps(task_space, indent=2, ensure_ascii=False))
111
+
112
+ # Initialize pheromone
113
+ aco_cfg = config.get("aco", {})
114
+ state = PheromoneState.initialize(nodes, aco_cfg)
115
+ state.save(paths.pheromone_current)
116
+ state.save(paths.pheromone_init)
117
+
118
+ _emit({
119
+ "status": "ok",
120
+ "command": "init",
121
+ "session": str(paths.session),
122
+ "pheromone_path": str(paths.pheromone_current),
123
+ "task_space_path": str(paths.task_space),
124
+ "n_nodes": len(nodes),
125
+ "n_edges": len(state.tau),
126
+ })
127
+
128
+
129
+ # ---------------------------------------------------------------------------
130
+ # select
131
+ # ---------------------------------------------------------------------------
132
+
133
+ def _pick_start_node(nodes: List[str], state: PheromoneState, mode: str) -> str:
134
+ if mode == "weighted":
135
+ weights = [state.node_tau.get(n, 1.0) for n in nodes]
136
+ total = sum(weights)
137
+ if total == 0:
138
+ return random.choice(nodes)
139
+ r = random.uniform(0, total)
140
+ acc = 0.0
141
+ for n, w in zip(nodes, weights):
142
+ acc += w
143
+ if acc >= r:
144
+ return n
145
+ return nodes[-1]
146
+ return random.choice(nodes)
147
+
148
+
149
+ def cmd_select(args: argparse.Namespace) -> None:
150
+ paths = SessionPaths(Path(args.session))
151
+ config = json.loads(paths.config.read_text())
152
+ state = PheromoneState.load(paths.pheromone_current)
153
+ task_space = json.loads(paths.task_space.read_text())
154
+
155
+ n_ants = config.get("swarm", {}).get("n_ants", 5)
156
+ nodes = task_space["nodes"]
157
+ max_len = task_space.get("max_path_length", 5)
158
+ start_mode = task_space.get("start_nodes", "any")
159
+
160
+ assignments = []
161
+ for i in range(1, n_ants + 1):
162
+ start = _pick_start_node(nodes, state, "weighted" if start_mode == "weighted" else "uniform")
163
+ edge_prefs = state.select_neighbors(start, nodes)
164
+ # Keep top-k edges to reduce noise
165
+ top_k = sorted(edge_prefs.items(), key=lambda x: -x[1])[:8]
166
+ assignments.append({
167
+ "ant_id": f"ANT-{args.iter}-{i}",
168
+ "start_node": start,
169
+ "edge_preferences": {f"{start}::{b}": w for b, w in top_k},
170
+ "max_path_length": max_len,
171
+ "iteration": args.iter,
172
+ })
173
+
174
+ _emit({
175
+ "status": "ok",
176
+ "command": "select",
177
+ "iteration": args.iter,
178
+ "n_assignments": len(assignments),
179
+ "assignments": assignments,
180
+ })
181
+
182
+
183
+ # ---------------------------------------------------------------------------
184
+ # update
185
+ # ---------------------------------------------------------------------------
186
+
187
+ def _load_iteration_artifacts(paths: SessionPaths, iteration: int) -> List[dict]:
188
+ pattern = str(paths.artifacts / f"ant-{iteration}-*.json")
189
+ files = sorted(glob.glob(pattern))
190
+ artifacts = []
191
+ for f in files:
192
+ try:
193
+ artifacts.append(json.loads(Path(f).read_text()))
194
+ except json.JSONDecodeError as e:
195
+ print(f"warning: skipped malformed artifact {f}: {e}", file=sys.stderr)
196
+ return artifacts
197
+
198
+
199
+ def _validate_artifact(art: dict, valid_nodes: set) -> Optional[str]:
200
+ required = ["schema_version", "ant_id", "iteration", "path", "self_score", "self_confidence"]
201
+ for f in required:
202
+ if f not in art:
203
+ return f"missing field: {f}"
204
+ if not isinstance(art["path"], list) or not art["path"]:
205
+ return "path must be non-empty list"
206
+ for n in art["path"]:
207
+ if n not in valid_nodes:
208
+ return f"path node '{n}' not in task_space"
209
+ if not 0.0 <= art["self_score"] <= 1.0:
210
+ return "self_score out of [0,1]"
211
+ return None
212
+
213
+
214
+ def cmd_update(args: argparse.Namespace) -> None:
215
+ paths = SessionPaths(Path(args.session))
216
+ config = json.loads(paths.config.read_text())
217
+ state = PheromoneState.load(paths.pheromone_current)
218
+ task_space = json.loads(paths.task_space.read_text())
219
+ valid_nodes = set(task_space["nodes"])
220
+
221
+ artifacts = _load_iteration_artifacts(paths, args.iter)
222
+ if not artifacts:
223
+ _fail(1, f"no artifacts found for iteration {args.iter}")
224
+
225
+ # Resolve scorers
226
+ scoring_cfg = config.get("scoring", {})
227
+ script_scorer = None
228
+ if scoring_cfg.get("mode") in ("script", "hybrid"):
229
+ rule_path = scoring_cfg.get("script_path")
230
+ if rule_path:
231
+ full = Path(args.session).parent.parent / rule_path if not Path(rule_path).is_absolute() else Path(rule_path)
232
+ if full.exists():
233
+ script_scorer = ScriptScorer(full)
234
+ fallback = FallbackScorer(scoring_cfg.get("self_score_discount", 0.5))
235
+ verified_scores = load_verified_scores(paths.scores / f"iter-{args.iter}-scores.json")
236
+
237
+ # Evaporate first
238
+ state.evaporate()
239
+
240
+ # Process each ant
241
+ trail_log = []
242
+ hallucinations = []
243
+ scored = []
244
+ for art in artifacts:
245
+ err = _validate_artifact(art, valid_nodes)
246
+ if err:
247
+ print(f"warning: invalid artifact {art.get('ant_id', '?')}: {err}", file=sys.stderr)
248
+ continue
249
+ score, src = resolve_score(art, verified_scores, script_scorer, fallback)
250
+ scored.append({"ant_id": art["ant_id"], "score": score, "source": src})
251
+
252
+ if src == "verified_llm":
253
+ if hallucination_check(art["self_score"], score):
254
+ hallucinations.append(art["ant_id"])
255
+ score *= 0.5
256
+
257
+ state.deposit(art["path"], score)
258
+ trail_log.append({
259
+ "ant_id": art["ant_id"],
260
+ "path": art["path"],
261
+ "self_score": art["self_score"],
262
+ "verified_score": score,
263
+ "source": src,
264
+ })
265
+
266
+ # Elitist: re-load best history, deposit extra on best path
267
+ best_data = None
268
+ if paths.best.exists():
269
+ best_data = json.loads(paths.best.read_text())
270
+ current_best = max(scored, key=lambda x: x["score"]) if scored else None
271
+ if current_best:
272
+ best_art = next(a for a in artifacts if a["ant_id"] == current_best["ant_id"])
273
+ if best_data is None or current_best["score"] > best_data.get("score", -1):
274
+ best_data = {
275
+ "ant_id": current_best["ant_id"],
276
+ "iteration": args.iter,
277
+ "path": best_art["path"],
278
+ "score": current_best["score"],
279
+ "self_score": best_art["self_score"],
280
+ "candidate_solution": best_art.get("candidate_solution"),
281
+ "evidence": best_art.get("evidence", []),
282
+ "updated_at": time.time(),
283
+ }
284
+ paths.best.write_text(json.dumps(best_data, indent=2, ensure_ascii=False))
285
+ # Elite deposit
286
+ state.deposit(best_data["path"], best_data["score"])
287
+
288
+ state.clip()
289
+ state.iteration = args.iter
290
+ state.save(paths.pheromone_current)
291
+ state.save(paths.pheromone_history / f"{args.iter}.json")
292
+
293
+ # Persist trails
294
+ trails_file = paths.trails / f"{args.iter}.jsonl"
295
+ trails_file.write_text("\n".join(json.dumps(t, ensure_ascii=False) for t in trail_log))
296
+
297
+ mean_score = sum(s["score"] for s in scored) / len(scored) if scored else 0.0
298
+ best_score = best_data["score"] if best_data else 0.0
299
+ prev_best = 0.0
300
+ history_files = sorted(paths.pheromone_history.glob("*.json"))
301
+ if len(history_files) >= 2:
302
+ prev = json.loads(history_files[-2].read_text())
303
+ prev_best = prev.get("stats", {}).get("best_known", best_score)
304
+ delta = best_score - prev_best
305
+
306
+ _emit({
307
+ "status": "ok",
308
+ "command": "update",
309
+ "iteration": args.iter,
310
+ "n_ants_processed": len(scored),
311
+ "mean_score": round(mean_score, 4),
312
+ "best_score": round(best_score, 4),
313
+ "delta": round(delta, 4),
314
+ "elite_updated": current_best is not None and (best_data is None or current_best["ant_id"] == best_data["ant_id"]),
315
+ "hallucinations_flagged": hallucinations,
316
+ "stats": state.stats(),
317
+ })
318
+
319
+
320
+ # ---------------------------------------------------------------------------
321
+ # converged
322
+ # ---------------------------------------------------------------------------
323
+
324
+ def cmd_converged(args: argparse.Namespace) -> None:
325
+ paths = SessionPaths(Path(args.session))
326
+ config = json.loads(paths.config.read_text())
327
+ cv = config.get("convergence", {})
328
+
329
+ state = PheromoneState.load(paths.pheromone_current)
330
+ iteration = state.iteration
331
+
332
+ triggered = []
333
+ metrics = {
334
+ "iteration": iteration,
335
+ "entropy": state.stats()["entropy"],
336
+ "best_score": 0.0,
337
+ "mean_score": 0.0,
338
+ "iterations_since_best_change": 0,
339
+ }
340
+
341
+ if paths.best.exists():
342
+ metrics["best_score"] = json.loads(paths.best.read_text()).get("score", 0.0)
343
+
344
+ # max_iterations
345
+ max_iter = cv.get("max_iterations", 5)
346
+ if iteration >= max_iter:
347
+ triggered.append("max_iterations")
348
+
349
+ # entropy_floor
350
+ ef = cv.get("entropy_floor", {})
351
+ if ef.get("enabled", True) and metrics["entropy"] < ef.get("threshold", 0.5):
352
+ triggered.append("entropy_floor")
353
+
354
+ # target_score
355
+ ts = cv.get("target_score", {})
356
+ if ts.get("enabled", True) and metrics["best_score"] >= ts.get("value", 0.95):
357
+ triggered.append("target_score")
358
+
359
+ # stagnation
360
+ st = cv.get("stagnation", {})
361
+ if st.get("enabled", True):
362
+ patience = st.get("patience", 2)
363
+ min_delta = st.get("min_delta", 0.01)
364
+ # Use trails to compute per-iter best
365
+ trail_files = sorted(paths.trails.glob("*.jsonl"))
366
+ per_iter_best = []
367
+ for tf in trail_files:
368
+ lines = [json.loads(l) for l in tf.read_text().splitlines() if l.strip()]
369
+ if lines:
370
+ per_iter_best.append(max(l.get("verified_score", 0) for l in lines))
371
+ if len(per_iter_best) > patience:
372
+ recent = per_iter_best[-patience - 1:]
373
+ deltas = [abs(recent[i] - recent[i - 1]) for i in range(1, len(recent))]
374
+ metrics["iterations_since_best_change"] = sum(1 for d in deltas if d < min_delta)
375
+ if all(d < min_delta for d in deltas):
376
+ triggered.append("stagnation")
377
+
378
+ _emit({
379
+ "status": "ok",
380
+ "command": "converged",
381
+ "converged": len(triggered) > 0,
382
+ "iteration": iteration,
383
+ "triggered_by": triggered,
384
+ "reason": triggered[0] if triggered else "in_progress",
385
+ "metrics": metrics,
386
+ "recommendation": "ready for report" if triggered else f"continue to iteration {iteration + 1}",
387
+ })
388
+
389
+
390
+ # ---------------------------------------------------------------------------
391
+ # report
392
+ # ---------------------------------------------------------------------------
393
+
394
+ def cmd_report(args: argparse.Namespace) -> None:
395
+ paths = SessionPaths(Path(args.session))
396
+ state = PheromoneState.load(paths.pheromone_current)
397
+
398
+ best = None
399
+ if paths.best.exists():
400
+ best = json.loads(paths.best.read_text())
401
+
402
+ # Top-K trails across all iterations
403
+ all_trails = []
404
+ for tf in sorted(paths.trails.glob("*.jsonl")):
405
+ for line in tf.read_text().splitlines():
406
+ if line.strip():
407
+ all_trails.append(json.loads(line))
408
+ top_k = sorted(all_trails, key=lambda x: -x.get("verified_score", 0))[:5]
409
+
410
+ # Convergence curve
411
+ curve = []
412
+ for hf in sorted(paths.pheromone_history.glob("*.json"), key=lambda p: int(p.stem)):
413
+ snap = json.loads(hf.read_text())
414
+ curve.append({
415
+ "iteration": snap["iteration"],
416
+ "entropy": snap["stats"]["entropy"],
417
+ "tau_max": snap["stats"]["max"],
418
+ "tau_mean": snap["stats"]["mean"],
419
+ })
420
+
421
+ _emit({
422
+ "status": "ok",
423
+ "command": "report",
424
+ "best": best,
425
+ "top_k": top_k,
426
+ "convergence_curve": curve,
427
+ "final_pheromone_stats": state.stats(),
428
+ "iterations_completed": state.iteration,
429
+ })
430
+
431
+
432
+ # ---------------------------------------------------------------------------
433
+ # CLI
434
+ # ---------------------------------------------------------------------------
435
+
436
+ def build_parser() -> argparse.ArgumentParser:
437
+ p = argparse.ArgumentParser(prog="aco", description=f"ACO controller v{VERSION}")
438
+ p.add_argument("--session", required=True, help="path to session folder")
439
+ sub = p.add_subparsers(dest="command", required=True)
440
+
441
+ sub.add_parser("init", help="initialize pheromone + task-space from config")
442
+
443
+ s_select = sub.add_parser("select", help="produce N ant assignments for iteration k")
444
+ s_select.add_argument("--iter", type=int, required=True)
445
+
446
+ s_update = sub.add_parser("update", help="update pheromone from iteration artifacts")
447
+ s_update.add_argument("--iter", type=int, required=True)
448
+
449
+ sub.add_parser("converged", help="check convergence criteria")
450
+ sub.add_parser("report", help="emit full result report")
451
+
452
+ return p
453
+
454
+
455
+ def main(argv: Optional[List[str]] = None) -> None:
456
+ args = build_parser().parse_args(argv)
457
+ handlers = {
458
+ "init": cmd_init,
459
+ "select": cmd_select,
460
+ "update": cmd_update,
461
+ "converged": cmd_converged,
462
+ "report": cmd_report,
463
+ }
464
+ try:
465
+ handlers[args.command](args)
466
+ except SystemExit:
467
+ raise
468
+ except Exception as e:
469
+ _fail(1, f"{type(e).__name__}: {e}")
470
+
471
+
472
+ if __name__ == "__main__":
473
+ main()
@@ -0,0 +1,144 @@
1
+ """Pheromone matrix module - state representation, update, evaporation.
2
+
3
+ Format spec: ../specs/pheromone-schema.md
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import math
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+ from typing import Dict, List, Optional
12
+
13
+
14
+ def edge_key(a: str, b: str) -> str:
15
+ """Lexically-ordered edge key (undirected)."""
16
+ return f"{a}::{b}" if a <= b else f"{b}::{a}"
17
+
18
+
19
+ @dataclass
20
+ class PheromoneState:
21
+ iteration: int = 0
22
+ n_nodes: int = 0
23
+ matrix_type: str = "edge_weighted_sparse"
24
+ tau: Dict[str, float] = field(default_factory=dict)
25
+ node_tau: Dict[str, float] = field(default_factory=dict)
26
+ metadata: Dict[str, float] = field(default_factory=dict)
27
+
28
+ @classmethod
29
+ def initialize(cls, nodes: List[str], aco_config: dict) -> "PheromoneState":
30
+ tau_init = aco_config.get("tau_init", 1.0)
31
+ tau = {}
32
+ for i, a in enumerate(nodes):
33
+ for b in nodes[i + 1:]:
34
+ tau[edge_key(a, b)] = tau_init
35
+ return cls(
36
+ iteration=0,
37
+ n_nodes=len(nodes),
38
+ tau=tau,
39
+ node_tau={n: tau_init for n in nodes},
40
+ metadata={
41
+ "alpha": aco_config.get("alpha", 1.0),
42
+ "beta": aco_config.get("beta", 2.0),
43
+ "rho": aco_config.get("rho", 0.2),
44
+ "q": aco_config.get("q", 1.0),
45
+ "tau_init": tau_init,
46
+ "tau_min": aco_config.get("tau_min", 0.01),
47
+ "tau_max": aco_config.get("tau_max", 10.0),
48
+ },
49
+ )
50
+
51
+ def evaporate(self) -> None:
52
+ rho = self.metadata["rho"]
53
+ for k in list(self.tau.keys()):
54
+ self.tau[k] = max(self.metadata["tau_min"], (1 - rho) * self.tau[k])
55
+
56
+ def deposit(self, path: List[str], score: float) -> None:
57
+ q = self.metadata["q"]
58
+ delta = q * score
59
+ for a, b in zip(path[:-1], path[1:]):
60
+ k = edge_key(a, b)
61
+ self.tau[k] = self.tau.get(k, self.metadata["tau_init"]) + delta
62
+ for node in path:
63
+ self.node_tau[node] = self.node_tau.get(node, self.metadata["tau_init"]) + delta * 0.5
64
+
65
+ def clip(self) -> None:
66
+ lo, hi = self.metadata["tau_min"], self.metadata["tau_max"]
67
+ for k in self.tau:
68
+ self.tau[k] = max(lo, min(hi, self.tau[k]))
69
+ for n in self.node_tau:
70
+ self.node_tau[n] = max(lo, min(hi, self.node_tau[n]))
71
+
72
+ def stats(self) -> Dict[str, float]:
73
+ if not self.tau:
74
+ return {"mean": 0.0, "max": 0.0, "min": 0.0, "entropy": 0.0, "n_edges_active": 0}
75
+ vals = list(self.tau.values())
76
+ total = sum(vals)
77
+ active = [v for v in vals if v > self.metadata["tau_min"] * 1.01]
78
+ entropy = 0.0
79
+ if total > 0:
80
+ for v in vals:
81
+ p = v / total
82
+ if p > 0:
83
+ entropy -= p * math.log2(p)
84
+ return {
85
+ "mean": sum(vals) / len(vals),
86
+ "max": max(vals),
87
+ "min": min(vals),
88
+ "entropy": entropy,
89
+ "n_edges_active": len(active),
90
+ }
91
+
92
+ def to_dict(self) -> dict:
93
+ return {
94
+ "version": "1.0",
95
+ "iteration": self.iteration,
96
+ "n_nodes": self.n_nodes,
97
+ "matrix_type": self.matrix_type,
98
+ "tau": self.tau,
99
+ "node_tau": self.node_tau,
100
+ "metadata": self.metadata,
101
+ "stats": self.stats(),
102
+ }
103
+
104
+ @classmethod
105
+ def from_dict(cls, d: dict) -> "PheromoneState":
106
+ return cls(
107
+ iteration=d["iteration"],
108
+ n_nodes=d["n_nodes"],
109
+ matrix_type=d.get("matrix_type", "edge_weighted_sparse"),
110
+ tau=d.get("tau", {}),
111
+ node_tau=d.get("node_tau", {}),
112
+ metadata=d["metadata"],
113
+ )
114
+
115
+ def save(self, path: Path) -> None:
116
+ path.parent.mkdir(parents=True, exist_ok=True)
117
+ path.write_text(json.dumps(self.to_dict(), indent=2, ensure_ascii=False))
118
+
119
+ @classmethod
120
+ def load(cls, path: Path) -> "PheromoneState":
121
+ return cls.from_dict(json.loads(path.read_text()))
122
+
123
+ def select_neighbors(
124
+ self,
125
+ current: str,
126
+ candidates: List[str],
127
+ heuristic: Optional[Dict[str, float]] = None,
128
+ ) -> Dict[str, float]:
129
+ """Return probability distribution over candidates given current node."""
130
+ alpha = self.metadata["alpha"]
131
+ beta = self.metadata["beta"]
132
+ heuristic = heuristic or {}
133
+ weights = {}
134
+ for c in candidates:
135
+ if c == current:
136
+ continue
137
+ tau_val = self.tau.get(edge_key(current, c), self.metadata["tau_init"])
138
+ eta = heuristic.get(c, 1.0)
139
+ weights[c] = (tau_val ** alpha) * (eta ** beta)
140
+ total = sum(weights.values())
141
+ if total == 0:
142
+ n = len(weights)
143
+ return {c: 1.0 / n for c in weights} if n else {}
144
+ return {c: w / total for c, w in weights.items()}