livepilot 1.9.13 → 1.9.15

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 (105) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/AGENTS.md +3 -3
  3. package/CHANGELOG.md +51 -0
  4. package/CONTRIBUTING.md +1 -1
  5. package/README.md +7 -7
  6. package/bin/livepilot.js +32 -8
  7. package/installer/install.js +21 -2
  8. package/livepilot/.Codex-plugin/plugin.json +2 -2
  9. package/livepilot/.claude-plugin/plugin.json +2 -2
  10. package/livepilot/agents/livepilot-producer/AGENT.md +243 -49
  11. package/livepilot/skills/livepilot-core/SKILL.md +81 -6
  12. package/livepilot/skills/livepilot-core/references/m4l-devices.md +2 -2
  13. package/livepilot/skills/livepilot-core/references/overview.md +3 -3
  14. package/livepilot/skills/livepilot-core/references/sound-design.md +3 -2
  15. package/livepilot/skills/livepilot-release/SKILL.md +13 -13
  16. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  17. package/m4l_device/livepilot_bridge.js +6 -3
  18. package/mcp_server/__init__.py +1 -1
  19. package/mcp_server/curves.py +11 -3
  20. package/mcp_server/evaluation/__init__.py +1 -0
  21. package/mcp_server/evaluation/fabric.py +575 -0
  22. package/mcp_server/evaluation/feature_extractors.py +84 -0
  23. package/mcp_server/evaluation/policy.py +67 -0
  24. package/mcp_server/evaluation/tools.py +53 -0
  25. package/mcp_server/memory/__init__.py +11 -2
  26. package/mcp_server/memory/anti_memory.py +78 -0
  27. package/mcp_server/memory/promotion.py +94 -0
  28. package/mcp_server/memory/session_memory.py +108 -0
  29. package/mcp_server/memory/taste_memory.py +158 -0
  30. package/mcp_server/memory/technique_store.py +2 -1
  31. package/mcp_server/memory/tools.py +112 -0
  32. package/mcp_server/mix_engine/__init__.py +1 -0
  33. package/mcp_server/mix_engine/critics.py +299 -0
  34. package/mcp_server/mix_engine/models.py +152 -0
  35. package/mcp_server/mix_engine/planner.py +103 -0
  36. package/mcp_server/mix_engine/state_builder.py +316 -0
  37. package/mcp_server/mix_engine/tools.py +214 -0
  38. package/mcp_server/performance_engine/__init__.py +1 -0
  39. package/mcp_server/performance_engine/models.py +148 -0
  40. package/mcp_server/performance_engine/planner.py +267 -0
  41. package/mcp_server/performance_engine/safety.py +162 -0
  42. package/mcp_server/performance_engine/tools.py +183 -0
  43. package/mcp_server/project_brain/__init__.py +6 -0
  44. package/mcp_server/project_brain/arrangement_graph.py +64 -0
  45. package/mcp_server/project_brain/automation_graph.py +72 -0
  46. package/mcp_server/project_brain/builder.py +123 -0
  47. package/mcp_server/project_brain/capability_graph.py +64 -0
  48. package/mcp_server/project_brain/models.py +282 -0
  49. package/mcp_server/project_brain/refresh.py +80 -0
  50. package/mcp_server/project_brain/role_graph.py +103 -0
  51. package/mcp_server/project_brain/session_graph.py +51 -0
  52. package/mcp_server/project_brain/tools.py +144 -0
  53. package/mcp_server/reference_engine/__init__.py +1 -0
  54. package/mcp_server/reference_engine/gap_analyzer.py +239 -0
  55. package/mcp_server/reference_engine/models.py +105 -0
  56. package/mcp_server/reference_engine/profile_builder.py +149 -0
  57. package/mcp_server/reference_engine/tactic_router.py +117 -0
  58. package/mcp_server/reference_engine/tools.py +235 -0
  59. package/mcp_server/runtime/__init__.py +1 -0
  60. package/mcp_server/runtime/action_ledger.py +117 -0
  61. package/mcp_server/runtime/action_ledger_models.py +84 -0
  62. package/mcp_server/runtime/action_tools.py +57 -0
  63. package/mcp_server/runtime/capability_state.py +218 -0
  64. package/mcp_server/runtime/safety_kernel.py +339 -0
  65. package/mcp_server/runtime/safety_tools.py +42 -0
  66. package/mcp_server/runtime/tools.py +64 -0
  67. package/mcp_server/server.py +23 -1
  68. package/mcp_server/sound_design/__init__.py +1 -0
  69. package/mcp_server/sound_design/critics.py +297 -0
  70. package/mcp_server/sound_design/models.py +147 -0
  71. package/mcp_server/sound_design/planner.py +104 -0
  72. package/mcp_server/sound_design/tools.py +297 -0
  73. package/mcp_server/tools/_agent_os_engine.py +947 -0
  74. package/mcp_server/tools/_composition_engine.py +1530 -0
  75. package/mcp_server/tools/_conductor.py +199 -0
  76. package/mcp_server/tools/_conductor_budgets.py +222 -0
  77. package/mcp_server/tools/_evaluation_contracts.py +91 -0
  78. package/mcp_server/tools/_form_engine.py +416 -0
  79. package/mcp_server/tools/_motif_engine.py +351 -0
  80. package/mcp_server/tools/_planner_engine.py +516 -0
  81. package/mcp_server/tools/_research_engine.py +542 -0
  82. package/mcp_server/tools/_research_provider.py +185 -0
  83. package/mcp_server/tools/_snapshot_normalizer.py +49 -0
  84. package/mcp_server/tools/agent_os.py +440 -0
  85. package/mcp_server/tools/analyzer.py +18 -0
  86. package/mcp_server/tools/automation.py +25 -10
  87. package/mcp_server/tools/composition.py +563 -0
  88. package/mcp_server/tools/motif.py +104 -0
  89. package/mcp_server/tools/planner.py +144 -0
  90. package/mcp_server/tools/research.py +223 -0
  91. package/mcp_server/tools/tracks.py +18 -3
  92. package/mcp_server/tools/transport.py +10 -2
  93. package/mcp_server/transition_engine/__init__.py +6 -0
  94. package/mcp_server/transition_engine/archetypes.py +167 -0
  95. package/mcp_server/transition_engine/critics.py +340 -0
  96. package/mcp_server/transition_engine/models.py +90 -0
  97. package/mcp_server/transition_engine/tools.py +291 -0
  98. package/mcp_server/translation_engine/__init__.py +5 -0
  99. package/mcp_server/translation_engine/critics.py +297 -0
  100. package/mcp_server/translation_engine/models.py +27 -0
  101. package/mcp_server/translation_engine/tools.py +74 -0
  102. package/package.json +2 -2
  103. package/remote_script/LivePilot/__init__.py +1 -1
  104. package/remote_script/LivePilot/arrangement.py +12 -2
  105. package/requirements.txt +1 -1
@@ -0,0 +1,199 @@
1
+ """Conductor — intelligent request routing to specialized engines.
2
+
3
+ Analyzes a natural-language production request and determines which engines
4
+ should handle it, in what order, with what priority. This is the "brain"
5
+ that connects all the specialist engines into a coherent workflow.
6
+
7
+ Zero external dependencies beyond stdlib.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from dataclasses import asdict, dataclass, field
14
+ from typing import Optional
15
+
16
+
17
+ # ── Engine Registry ──────────────────────────────────────────────────
18
+
19
+ @dataclass
20
+ class EngineRoute:
21
+ """A routing decision for a single engine."""
22
+ engine: str
23
+ priority: int # 1=primary, 2=secondary, 3=supporting
24
+ reason: str
25
+ entry_tool: str # which MCP tool to call first
26
+ follow_up_tools: list[str] = field(default_factory=list)
27
+
28
+ def to_dict(self) -> dict:
29
+ return asdict(self)
30
+
31
+
32
+ @dataclass
33
+ class ConductorPlan:
34
+ """Full routing plan for a production request."""
35
+ request: str
36
+ request_type: str # "mix", "composition", "sound_design", "transition", etc.
37
+ routes: list[EngineRoute] = field(default_factory=list)
38
+ capability_requirements: list[str] = field(default_factory=list)
39
+ notes: list[str] = field(default_factory=list)
40
+ budget: Optional[dict] = None
41
+
42
+ def to_dict(self) -> dict:
43
+ result = {
44
+ "request": self.request,
45
+ "request_type": self.request_type,
46
+ "routes": [r.to_dict() for r in self.routes],
47
+ "engine_count": len(self.routes),
48
+ "primary_engine": self.routes[0].engine if self.routes else None,
49
+ "capability_requirements": self.capability_requirements,
50
+ "notes": self.notes,
51
+ }
52
+ if self.budget is not None:
53
+ result["budget"] = self.budget
54
+ return result
55
+
56
+
57
+ # ── Request Classification ───────────────────────────────────────────
58
+
59
+ # Keyword → (engine, request_type, entry_tool, follow_up_tools)
60
+ _ROUTING_PATTERNS: list[tuple[str, str, str, str, list[str]]] = [
61
+ # Mix requests
62
+ (r"clean|mud|muddy|low.?mid|eq|equaliz", "mix_engine", "mix", "analyze_mix", ["plan_mix_move", "evaluate_mix_move"]),
63
+ (r"punch|punchy|transient|dynamics|compress", "mix_engine", "mix", "analyze_mix", ["plan_mix_move"]),
64
+ (r"wide|wider|width|stereo|narrow|mono.?compat", "mix_engine", "mix", "analyze_mix", ["plan_mix_move"]),
65
+ (r"glue|cohes|bus.?comp|mix.?bus", "mix_engine", "mix", "analyze_mix", ["plan_mix_move"]),
66
+ (r"balance|level|volume.?balanc|gain.?stag", "mix_engine", "mix", "analyze_mix", ["plan_mix_move"]),
67
+ (r"headroom|clip|peak|limit", "mix_engine", "mix", "analyze_mix", ["plan_mix_move"]),
68
+ (r"depth|dry|wet|reverb.?mix|send", "mix_engine", "mix", "analyze_mix", ["plan_mix_move"]),
69
+ (r"mask|frequency.?collis|overlap", "mix_engine", "mix", "get_masking_report", ["plan_mix_move"]),
70
+
71
+ # Composition requests
72
+ (r"arrange|arrangement|song.?structure|loop.?to.?song", "composition", "composition", "plan_arrangement", ["analyze_composition"]),
73
+ (r"section|verse|chorus|drop|intro|outro|bridge|breakdown", "composition", "composition", "analyze_composition", ["get_section_graph"]),
74
+ (r"phrase|motif|pattern|repetit|variation", "composition", "composition", "analyze_composition", ["get_motif_graph"]),
75
+ (r"tension|energy.?arc|emotional|build.?up", "composition", "composition", "get_emotional_arc", ["analyze_composition"]),
76
+ (r"form|structure|reorder|expand|compress|split|insert", "composition", "composition", "transform_section", ["analyze_composition"]),
77
+
78
+ # Sound design requests
79
+ (r"synth|patch|oscillat|timbre|timbral|wavetable|operator", "sound_design", "sound_design", "analyze_sound_design", ["plan_sound_design_move"]),
80
+ (r"haunted|lush|aggressive|warm.?pad|fat.?bass|bright.?lead", "sound_design", "sound_design", "analyze_sound_design", ["plan_sound_design_move"]),
81
+ (r"modulation|lfo|movement|evolv|texture", "sound_design", "sound_design", "get_patch_model", ["analyze_sound_design"]),
82
+ (r"layer|sub.?layer|transient.?layer|body", "sound_design", "sound_design", "analyze_sound_design", ["plan_sound_design_move"]),
83
+
84
+ # Transition requests
85
+ (r"transition|handoff|arrival|drop.?feel|feel.?earned", "transition_engine", "transition", "analyze_transition", ["plan_transition"]),
86
+ (r"smooth|seamless|boundary|crossfade", "transition_engine", "transition", "analyze_transition", ["plan_transition"]),
87
+
88
+ # Reference requests
89
+ (r"reference|sound.?like|style.?of|burial|daft.?punk|inspired.?by", "reference_engine", "reference", "build_reference_profile", ["analyze_reference_gaps", "plan_reference_moves"]),
90
+ (r"compare|match|closer.?to", "reference_engine", "reference", "build_reference_profile", ["analyze_reference_gaps"]),
91
+
92
+ # Translation requests
93
+ (r"translat|mono|phone.?speaker|small.?speaker|earbud|headphone", "translation_engine", "translation", "check_translation", ["get_translation_issues"]),
94
+ (r"harsh|bright.?hurt|sibilant|ear.?fatigue", "translation_engine", "translation", "check_translation", []),
95
+
96
+ # Performance requests
97
+ (r"live|perform|set|scene.?steer|safe.?mode|improv", "performance_engine", "performance", "get_performance_state", ["get_performance_safe_moves"]),
98
+ (r"scene.?transition|handoff.?scene|energy.?steer", "performance_engine", "performance", "plan_scene_handoff", ["get_performance_safe_moves"]),
99
+
100
+ # Research requests
101
+ (r"research|how.?to|technique|tutorial|learn", "research", "research", "research_technique", []),
102
+ (r"style.?tactic|production.?style|genre.?approach", "research", "research", "get_style_tactics", []),
103
+ ]
104
+
105
+
106
+ def classify_request(request: str) -> ConductorPlan:
107
+ """Analyze a production request and route to the right engines.
108
+
109
+ Returns a ConductorPlan with ranked engine routes and capability requirements.
110
+ """
111
+ lower = request.lower().strip()
112
+ if not lower:
113
+ return ConductorPlan(request=request, request_type="unknown",
114
+ notes=["Empty request — ask the user what they want to do"])
115
+
116
+ # Score each engine by how many patterns match
117
+ engine_scores: dict[str, dict] = {}
118
+
119
+ for pattern, engine, req_type, entry_tool, follow_ups in _ROUTING_PATTERNS:
120
+ if re.search(pattern, lower):
121
+ if engine not in engine_scores:
122
+ engine_scores[engine] = {
123
+ "score": 0, "request_type": req_type,
124
+ "entry_tool": entry_tool, "follow_ups": follow_ups,
125
+ }
126
+ engine_scores[engine]["score"] += 1
127
+
128
+ if not engine_scores:
129
+ # Default: try Agent OS core loop (general "make it better")
130
+ return ConductorPlan(
131
+ request=request,
132
+ request_type="general",
133
+ routes=[EngineRoute(
134
+ engine="agent_os",
135
+ priority=1,
136
+ reason="No specific engine matched — using core Agent OS loop",
137
+ entry_tool="build_world_model",
138
+ follow_up_tools=["evaluate_move"],
139
+ )],
140
+ capability_requirements=["session_access"],
141
+ notes=["General request — Agent OS core loop with goal vector"],
142
+ )
143
+
144
+ # Sort engines by score (most matches = primary)
145
+ sorted_engines = sorted(engine_scores.items(), key=lambda x: -x[1]["score"])
146
+
147
+ routes: list[EngineRoute] = []
148
+ for i, (engine, info) in enumerate(sorted_engines):
149
+ routes.append(EngineRoute(
150
+ engine=engine,
151
+ priority=i + 1,
152
+ reason=f"Matched {info['score']} keyword pattern(s)",
153
+ entry_tool=info["entry_tool"],
154
+ follow_up_tools=info["follow_ups"],
155
+ ))
156
+
157
+ primary_type = sorted_engines[0][1]["request_type"]
158
+
159
+ # Determine capability requirements
160
+ caps = ["session_access"]
161
+ if any(r.engine == "mix_engine" for r in routes):
162
+ caps.append("analyzer")
163
+ if any(r.engine in ("reference_engine",) for r in routes):
164
+ caps.append("offline_perception")
165
+ if any(r.engine == "performance_engine" for r in routes):
166
+ caps.append("live_performance_safe")
167
+
168
+ # Always suggest starting with Project Brain for complex multi-engine tasks
169
+ notes = []
170
+ if len(routes) > 1:
171
+ notes.append("Multi-engine task — call build_project_brain first for shared state")
172
+ if any(r.engine == "mix_engine" for r in routes):
173
+ notes.append("Mix engine works best with analyzer data — check get_capability_state")
174
+
175
+ return ConductorPlan(
176
+ request=request,
177
+ request_type=primary_type,
178
+ routes=routes,
179
+ capability_requirements=caps,
180
+ notes=notes,
181
+ )
182
+
183
+
184
+ def create_conductor_plan(
185
+ request: str,
186
+ mode: str = "improve",
187
+ aggression: float = 0.5,
188
+ ) -> ConductorPlan:
189
+ """Create a full ConductorPlan with routing + budget.
190
+
191
+ Combines classify_request (routing) with create_budget (resource limits)
192
+ into a single plan the agent can consume.
193
+ """
194
+ from . import _conductor_budgets as budgets
195
+
196
+ plan = classify_request(request)
197
+ budget = budgets.create_budget(mode=mode, aggression=aggression)
198
+ plan.budget = budget.to_dict()
199
+ return plan
@@ -0,0 +1,222 @@
1
+ """Conductor Budget System — prevents the agent from overcommitting.
2
+
3
+ Every turn maintains six resource pools: latency, risk, novelty, change,
4
+ undo, and research. Mode shapes the initial budget; spend functions enforce
5
+ limits and return (updated_budget, allowed) tuples.
6
+
7
+ Zero external dependencies beyond stdlib.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import asdict, dataclass
13
+ from typing import Tuple
14
+
15
+
16
+ # ── TurnBudget ──────────────────────────────────────────────────────
17
+
18
+ @dataclass
19
+ class TurnBudget:
20
+ """Resource pools for a single agent turn."""
21
+
22
+ # Limits
23
+ latency_ms: int = 30000 # max 30s per turn
24
+ risk_points: float = 1.0 # 0-1, how much risk left
25
+ novelty_points: float = 0.5 # 0-1, how much novelty allowed
26
+ change_count: int = 3 # max moves per turn
27
+ undo_count: int = 3 # max consecutive undos before stop
28
+ research_calls: int = 2 # max research calls per turn
29
+
30
+ # Tracking
31
+ elapsed_ms: int = 0
32
+ risk_spent: float = 0.0
33
+ novelty_spent: float = 0.0
34
+ changes_made: int = 0
35
+ undos_consecutive: int = 0
36
+ research_used: int = 0
37
+
38
+ def to_dict(self) -> dict:
39
+ return asdict(self)
40
+
41
+
42
+ # ── Mode Presets ────────────────────────────────────────────────────
43
+
44
+ # Each mode overrides specific budget fields.
45
+ # Keys map to TurnBudget field names.
46
+ _MODE_PRESETS: dict[str, dict] = {
47
+ "observe": {
48
+ "risk_points": 0.1,
49
+ "novelty_points": 0.1,
50
+ "change_count": 0,
51
+ "research_calls": 0,
52
+ "latency_ms": 15000,
53
+ },
54
+ "improve": {
55
+ # Default values — no overrides needed
56
+ },
57
+ "explore": {
58
+ "risk_points": 1.0,
59
+ "novelty_points": 1.0,
60
+ "change_count": 5,
61
+ "research_calls": 3,
62
+ "latency_ms": 45000,
63
+ },
64
+ "finish": {
65
+ "risk_points": 0.3,
66
+ "novelty_points": 0.1,
67
+ "change_count": 2,
68
+ "research_calls": 1,
69
+ "latency_ms": 20000,
70
+ },
71
+ "diagnose": {
72
+ "risk_points": 0.0,
73
+ "novelty_points": 0.0,
74
+ "change_count": 0,
75
+ "research_calls": 3,
76
+ "latency_ms": 20000,
77
+ },
78
+ "performance": {
79
+ "risk_points": 0.2,
80
+ "novelty_points": 0.1,
81
+ "change_count": 2,
82
+ "undo_count": 1,
83
+ "research_calls": 0,
84
+ "latency_ms": 10000,
85
+ },
86
+ }
87
+
88
+
89
+ # ── Budget Factory ──────────────────────────────────────────────────
90
+
91
+ def create_budget(mode: str = "improve", aggression: float = 0.5) -> TurnBudget:
92
+ """Create a TurnBudget shaped by mode and aggression.
93
+
94
+ mode: observe | improve | explore | finish | diagnose | performance
95
+ aggression: 0.0 (subtle) to 1.0 (bold) — scales risk and change limits.
96
+ """
97
+ aggression = max(0.0, min(1.0, float(aggression)))
98
+
99
+ budget = TurnBudget()
100
+
101
+ # Apply mode preset
102
+ preset = _MODE_PRESETS.get(mode, {})
103
+ for key, value in preset.items():
104
+ setattr(budget, key, value)
105
+
106
+ # Aggression scales risk_points and change_count (never below preset floor)
107
+ if mode not in ("observe", "diagnose"):
108
+ base_risk = budget.risk_points
109
+ budget.risk_points = round(base_risk * (0.5 + aggression * 0.5), 3)
110
+ base_changes = budget.change_count
111
+ budget.change_count = max(1, int(base_changes * (0.5 + aggression * 0.5)))
112
+
113
+ return budget
114
+
115
+
116
+ # ── Spend Functions ─────────────────────────────────────────────────
117
+
118
+ def spend_risk(budget: TurnBudget, amount: float) -> Tuple[TurnBudget, bool]:
119
+ """Spend risk points. Returns (updated_budget, allowed)."""
120
+ amount = max(0.0, float(amount))
121
+ remaining = budget.risk_points - budget.risk_spent
122
+ if amount > remaining + 1e-9:
123
+ return budget, False
124
+ budget.risk_spent = round(budget.risk_spent + amount, 6)
125
+ return budget, True
126
+
127
+
128
+ def spend_change(budget: TurnBudget) -> Tuple[TurnBudget, bool]:
129
+ """Record one change. Returns (updated_budget, allowed)."""
130
+ if budget.changes_made >= budget.change_count:
131
+ return budget, False
132
+ budget.changes_made += 1
133
+ # A successful change resets consecutive undo count
134
+ budget.undos_consecutive = 0
135
+ return budget, True
136
+
137
+
138
+ def record_undo(budget: TurnBudget) -> Tuple[TurnBudget, bool]:
139
+ """Record a consecutive undo. Returns False if limit exceeded (should stop)."""
140
+ budget.undos_consecutive += 1
141
+ if budget.undos_consecutive > budget.undo_count:
142
+ return budget, False
143
+ return budget, True
144
+
145
+
146
+ def spend_research(budget: TurnBudget) -> Tuple[TurnBudget, bool]:
147
+ """Spend one research call. Returns (updated_budget, allowed)."""
148
+ if budget.research_used >= budget.research_calls:
149
+ return budget, False
150
+ budget.research_used += 1
151
+ return budget, True
152
+
153
+
154
+ def spend_novelty(budget: TurnBudget, amount: float) -> Tuple[TurnBudget, bool]:
155
+ """Spend novelty points. Returns (updated_budget, allowed)."""
156
+ amount = max(0.0, float(amount))
157
+ remaining = budget.novelty_points - budget.novelty_spent
158
+ if amount > remaining + 1e-9:
159
+ return budget, False
160
+ budget.novelty_spent = round(budget.novelty_spent + amount, 6)
161
+ return budget, True
162
+
163
+
164
+ # ── Budget Queries ──────────────────────────────────────────────────
165
+
166
+ def is_budget_exhausted(budget: TurnBudget) -> bool:
167
+ """Check if any budget dimension is fully spent."""
168
+ if budget.elapsed_ms >= budget.latency_ms:
169
+ return True
170
+ if budget.risk_spent >= budget.risk_points:
171
+ return True
172
+ if budget.changes_made >= budget.change_count:
173
+ return True
174
+ if budget.undos_consecutive > budget.undo_count:
175
+ return True
176
+ if budget.novelty_spent >= budget.novelty_points:
177
+ return True
178
+ # research_used exhaustion alone doesn't exhaust the budget —
179
+ # running out of research calls just blocks further research.
180
+ return False
181
+
182
+
183
+ def get_budget_summary(budget: TurnBudget) -> dict:
184
+ """Return a human-readable summary of the current budget state."""
185
+ return {
186
+ "latency": {
187
+ "used_ms": budget.elapsed_ms,
188
+ "limit_ms": budget.latency_ms,
189
+ "remaining_ms": max(0, budget.latency_ms - budget.elapsed_ms),
190
+ "exhausted": budget.elapsed_ms >= budget.latency_ms,
191
+ },
192
+ "risk": {
193
+ "spent": round(budget.risk_spent, 3),
194
+ "limit": round(budget.risk_points, 3),
195
+ "remaining": round(max(0.0, budget.risk_points - budget.risk_spent), 3),
196
+ "exhausted": budget.risk_spent >= budget.risk_points,
197
+ },
198
+ "novelty": {
199
+ "spent": round(budget.novelty_spent, 3),
200
+ "limit": round(budget.novelty_points, 3),
201
+ "remaining": round(max(0.0, budget.novelty_points - budget.novelty_spent), 3),
202
+ "exhausted": budget.novelty_spent >= budget.novelty_points,
203
+ },
204
+ "changes": {
205
+ "made": budget.changes_made,
206
+ "limit": budget.change_count,
207
+ "remaining": max(0, budget.change_count - budget.changes_made),
208
+ "exhausted": budget.changes_made >= budget.change_count,
209
+ },
210
+ "undos": {
211
+ "consecutive": budget.undos_consecutive,
212
+ "limit": budget.undo_count,
213
+ "should_stop": budget.undos_consecutive > budget.undo_count,
214
+ },
215
+ "research": {
216
+ "used": budget.research_used,
217
+ "limit": budget.research_calls,
218
+ "remaining": max(0, budget.research_calls - budget.research_used),
219
+ "exhausted": budget.research_used >= budget.research_calls,
220
+ },
221
+ "overall_exhausted": is_budget_exhausted(budget),
222
+ }
@@ -0,0 +1,91 @@
1
+ """Evaluation Contracts — shared types for all engine evaluators.
2
+
3
+ Defines the canonical evaluation request/result types and the
4
+ authoritative registry of which quality dimensions are measurable.
5
+
6
+ All engines should produce EvaluationResult objects. The Evaluation
7
+ Fabric (Phase 1D) will consume these through a unified interface.
8
+
9
+ Design: EVALUATION_FABRIC_V1.md, section 6
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import asdict, dataclass, field
15
+ from typing import Optional
16
+
17
+
18
+ # ── Dimension Registry ───────────────────────────────────────────────
19
+
20
+ # Authoritative registry: dimensions with working spectral proxies.
21
+ # If it's not here, it's unmeasurable in current phase and the evaluator
22
+ # must report confidence=0.0 for that dimension.
23
+ MEASURABLE_DIMENSIONS: frozenset[str] = frozenset({
24
+ "brightness", "warmth", "weight", "clarity",
25
+ "density", "energy", "punch",
26
+ })
27
+
28
+ # All valid quality dimensions (measurable + unmeasurable).
29
+ ALL_DIMENSIONS: frozenset[str] = frozenset({
30
+ "energy", "punch", "weight", "density", "brightness", "warmth",
31
+ "width", "depth", "motion", "contrast", "clarity", "cohesion",
32
+ "groove", "tension", "novelty", "polish", "emotion",
33
+ })
34
+
35
+
36
+ def is_dimension_measurable(dim: str) -> bool:
37
+ """Check if a dimension has a working spectral proxy."""
38
+ return dim in MEASURABLE_DIMENSIONS
39
+
40
+
41
+ # ── Evaluation Request ───────────────────────────────────────────────
42
+
43
+ @dataclass
44
+ class EvaluationRequest:
45
+ """Canonical evaluation request — engine-agnostic.
46
+
47
+ All engines submit evaluation through this shape. The Evaluation
48
+ Fabric routes to the appropriate engine-specific evaluator.
49
+ """
50
+ engine: str
51
+ goal: dict = field(default_factory=dict)
52
+ before: dict = field(default_factory=dict)
53
+ after: dict = field(default_factory=dict)
54
+ protect: dict = field(default_factory=dict)
55
+ context: dict = field(default_factory=dict)
56
+
57
+ def to_dict(self) -> dict:
58
+ return asdict(self)
59
+
60
+
61
+ # ── Evaluation Result ────────────────────────────────────────────────
62
+
63
+ @dataclass
64
+ class EvaluationResult:
65
+ """Canonical evaluation result — all engines produce this shape.
66
+
67
+ Fields:
68
+ engine: which engine produced this result
69
+ score: 0-1 composite quality score
70
+ keep_change: should the move be kept?
71
+ goal_progress: -1 to 1, how much the goal improved
72
+ collateral_damage: 0-1, harm to protected dimensions
73
+ hard_rule_failures: list of rule names that triggered
74
+ dimension_changes: {dim: {before, after, delta}}
75
+ notes: human-readable explanation
76
+ decision_mode: "measured", "judgment", or "deferred"
77
+ memory_candidate: should this outcome be saved to memory?
78
+ """
79
+ engine: str
80
+ score: float = 0.0
81
+ keep_change: bool = True
82
+ goal_progress: float = 0.0
83
+ collateral_damage: float = 0.0
84
+ hard_rule_failures: list[str] = field(default_factory=list)
85
+ dimension_changes: dict = field(default_factory=dict)
86
+ notes: list[str] = field(default_factory=list)
87
+ decision_mode: str = "measured"
88
+ memory_candidate: bool = False
89
+
90
+ def to_dict(self) -> dict:
91
+ return asdict(self)