livepilot 1.10.9 → 1.13.0

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 (61) hide show
  1. package/CHANGELOG.md +327 -0
  2. package/README.md +7 -7
  3. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  4. package/m4l_device/livepilot_bridge.js +1 -1
  5. package/mcp_server/__init__.py +1 -1
  6. package/mcp_server/branches/__init__.py +32 -0
  7. package/mcp_server/branches/types.py +230 -0
  8. package/mcp_server/composer/__init__.py +10 -1
  9. package/mcp_server/composer/branch_producer.py +229 -0
  10. package/mcp_server/evaluation/policy.py +129 -2
  11. package/mcp_server/experiment/engine.py +47 -11
  12. package/mcp_server/experiment/models.py +72 -7
  13. package/mcp_server/experiment/tools.py +231 -35
  14. package/mcp_server/m4l_bridge.py +488 -13
  15. package/mcp_server/memory/taste_graph.py +84 -11
  16. package/mcp_server/persistence/taste_store.py +21 -5
  17. package/mcp_server/runtime/execution_router.py +7 -0
  18. package/mcp_server/runtime/mcp_dispatch.py +32 -0
  19. package/mcp_server/runtime/remote_commands.py +54 -0
  20. package/mcp_server/runtime/session_kernel.py +46 -0
  21. package/mcp_server/runtime/tools.py +29 -3
  22. package/mcp_server/sample_engine/slice_classifier.py +169 -0
  23. package/mcp_server/server.py +11 -3
  24. package/mcp_server/synthesis_brain/__init__.py +53 -0
  25. package/mcp_server/synthesis_brain/adapters/__init__.py +34 -0
  26. package/mcp_server/synthesis_brain/adapters/analog.py +167 -0
  27. package/mcp_server/synthesis_brain/adapters/base.py +86 -0
  28. package/mcp_server/synthesis_brain/adapters/drift.py +166 -0
  29. package/mcp_server/synthesis_brain/adapters/meld.py +151 -0
  30. package/mcp_server/synthesis_brain/adapters/operator.py +169 -0
  31. package/mcp_server/synthesis_brain/adapters/wavetable.py +228 -0
  32. package/mcp_server/synthesis_brain/engine.py +91 -0
  33. package/mcp_server/synthesis_brain/models.py +121 -0
  34. package/mcp_server/synthesis_brain/timbre.py +194 -0
  35. package/mcp_server/tools/_conductor.py +144 -0
  36. package/mcp_server/tools/analyzer.py +187 -7
  37. package/mcp_server/tools/clips.py +65 -0
  38. package/mcp_server/tools/devices.py +517 -5
  39. package/mcp_server/tools/diagnostics.py +42 -0
  40. package/mcp_server/tools/follow_actions.py +202 -0
  41. package/mcp_server/tools/grooves.py +142 -0
  42. package/mcp_server/tools/miditool.py +280 -0
  43. package/mcp_server/tools/scales.py +126 -0
  44. package/mcp_server/tools/take_lanes.py +135 -0
  45. package/mcp_server/tools/tracks.py +46 -3
  46. package/mcp_server/tools/transport.py +62 -1
  47. package/mcp_server/wonder_mode/engine.py +324 -0
  48. package/mcp_server/wonder_mode/tools.py +153 -1
  49. package/package.json +2 -2
  50. package/remote_script/LivePilot/__init__.py +8 -4
  51. package/remote_script/LivePilot/clips.py +62 -0
  52. package/remote_script/LivePilot/devices.py +444 -0
  53. package/remote_script/LivePilot/diagnostics.py +52 -1
  54. package/remote_script/LivePilot/follow_actions.py +235 -0
  55. package/remote_script/LivePilot/grooves.py +185 -0
  56. package/remote_script/LivePilot/scales.py +138 -0
  57. package/remote_script/LivePilot/take_lanes.py +175 -0
  58. package/remote_script/LivePilot/tracks.py +59 -1
  59. package/remote_script/LivePilot/transport.py +90 -1
  60. package/remote_script/LivePilot/version_detect.py +9 -0
  61. package/server.json +3 -3
@@ -0,0 +1,229 @@
1
+ """Composer branch producer — emit section-hypothesis BranchSeeds.
2
+
3
+ PR11 adds a branch-native entry point alongside the existing compose()
4
+ pipeline. Instead of a single deterministic layer plan, callers can
5
+ request N distinct compositional hypotheses and audition them via
6
+ create_experiment(seeds=..., compiled_plans=...).
7
+
8
+ Design:
9
+ A composer branch is a CompositionIntent + variant_strategy. Three
10
+ canned strategies are shipped in PR11:
11
+
12
+ "canonical" — intent unchanged, layer plan uses genre defaults
13
+ "energy_shift" — intent.energy inverted around 0.5 (dense ⇄ sparse)
14
+ "layer_contrast" — one role swapped in the layer plan (e.g. bass
15
+ role replaced with pad-anchor, or percussion
16
+ stripped to emphasize melodic content)
17
+
18
+ Seeds carry source="composer". Each branch produces a pre-compiled
19
+ plan through the existing ComposerEngine.compose() pipeline so
20
+ run_experiment respects the plans without re-compiling. Later PRs
21
+ can add more strategies (key-shift, section-reorder, tempo-halftime).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import hashlib
27
+ from typing import Optional
28
+
29
+ from ..branches import BranchSeed, freeform_seed
30
+ from .prompt_parser import parse_prompt, CompositionIntent
31
+ from .layer_planner import plan_layers, plan_sections
32
+
33
+
34
+ # Strategy registry — each function takes an intent and returns (modified
35
+ # intent, distinctness_reason, novelty_label, risk_label).
36
+ def _strategy_canonical(intent: CompositionIntent):
37
+ return (
38
+ intent,
39
+ "baseline composition with genre defaults",
40
+ "safe",
41
+ "low",
42
+ )
43
+
44
+
45
+ def _strategy_energy_shift(intent: CompositionIntent):
46
+ new = CompositionIntent(
47
+ genre=intent.genre,
48
+ sub_genre=intent.sub_genre,
49
+ mood=intent.mood,
50
+ tempo=intent.tempo,
51
+ key=intent.key,
52
+ descriptors=list(intent.descriptors),
53
+ explicit_elements=list(intent.explicit_elements),
54
+ energy=round(1.0 - intent.energy, 2),
55
+ layer_count=intent.layer_count,
56
+ duration_bars=intent.duration_bars,
57
+ )
58
+ direction = "denser" if new.energy > intent.energy else "sparser"
59
+ return (
60
+ new,
61
+ f"energy shifted from {intent.energy:.1f} → {new.energy:.1f} ({direction})",
62
+ "strong",
63
+ "low",
64
+ )
65
+
66
+
67
+ def _strategy_layer_contrast(intent: CompositionIntent):
68
+ new = CompositionIntent(
69
+ genre=intent.genre,
70
+ sub_genre=intent.sub_genre,
71
+ mood=intent.mood,
72
+ tempo=intent.tempo,
73
+ key=intent.key,
74
+ descriptors=list(intent.descriptors),
75
+ # Force the layer planner to drop "bass" as an anchor role by adding
76
+ # "pad" explicitly to explicit_elements and not asking for a bass.
77
+ explicit_elements=list(intent.explicit_elements) + ["pad_anchor", "no_bass"],
78
+ energy=intent.energy,
79
+ layer_count=intent.layer_count,
80
+ duration_bars=intent.duration_bars,
81
+ )
82
+ return (
83
+ new,
84
+ "layer contrast — pad anchor instead of bass line",
85
+ "unexpected",
86
+ "medium",
87
+ )
88
+
89
+
90
+ _STRATEGIES = [
91
+ ("canonical", _strategy_canonical),
92
+ ("energy_shift", _strategy_energy_shift),
93
+ ("layer_contrast", _strategy_layer_contrast),
94
+ ]
95
+
96
+
97
+ def _short_id(prefix: str, key: str) -> str:
98
+ h = hashlib.sha256(f"{prefix}:{key}".encode()).hexdigest()[:10]
99
+ return f"{prefix}_{h}"
100
+
101
+
102
+ def propose_composer_branches(
103
+ request_text: str,
104
+ kernel: Optional[dict] = None,
105
+ count: int = 2,
106
+ search_roots: Optional[list] = None,
107
+ ) -> list[tuple[BranchSeed, dict]]:
108
+ """Emit composer-source branch seeds with pre-compiled plans.
109
+
110
+ request_text: the natural-language composition prompt.
111
+ kernel: optional SessionKernel dict — reads ``freshness`` to gate
112
+ whether high-novelty strategies (layer_contrast) are included.
113
+ count: desired number of branches (clamped to 1..len(_STRATEGIES)).
114
+ search_roots: optional list of directory paths for sample resolution,
115
+ threaded to ComposerEngine.compose().
116
+
117
+ Returns a list of (BranchSeed, compiled_plan_dict) tuples. Each plan
118
+ is a dict with {"steps": [...], "step_count": N, "summary": "..."}
119
+ compatible with run_experiment.
120
+ """
121
+ kernel = kernel or {}
122
+ freshness = float(kernel.get("freshness", 0.5) or 0.5)
123
+
124
+ intent = parse_prompt(request_text)
125
+
126
+ # Gate high-novelty strategies on freshness.
127
+ if freshness < 0.4:
128
+ strategies = [_STRATEGIES[0]] # canonical only
129
+ elif freshness < 0.7:
130
+ strategies = _STRATEGIES[:2] # canonical + energy_shift
131
+ else:
132
+ strategies = _STRATEGIES # all three
133
+
134
+ count = max(1, min(count, len(strategies)))
135
+ results: list[tuple[BranchSeed, dict]] = []
136
+
137
+ for name, strategy_fn in strategies[:count]:
138
+ try:
139
+ variant_intent, reason, novelty, risk = strategy_fn(intent)
140
+ plan = _build_section_hypothesis_plan(variant_intent, name)
141
+
142
+ seed = freeform_seed(
143
+ seed_id=_short_id(f"cmp_{name}", request_text),
144
+ hypothesis=f"Composer branch ({name}): {reason}",
145
+ source="composer",
146
+ novelty_label=novelty,
147
+ risk_label=risk,
148
+ distinctness_reason=reason,
149
+ )
150
+ results.append((seed, plan))
151
+ except Exception as exc:
152
+ # Don't let one strategy's failure kill the rest.
153
+ import logging
154
+ logging.getLogger(__name__).warning(
155
+ "composer strategy %s failed: %s", name, exc
156
+ )
157
+ continue
158
+
159
+ return results
160
+
161
+
162
+ def _build_section_hypothesis_plan(intent: CompositionIntent, strategy_name: str) -> dict:
163
+ """Build a lightweight, executable plan from an intent.
164
+
165
+ Uses the synchronous planning primitives (plan_layers, plan_sections)
166
+ to generate a scaffolding plan: set_tempo + create_midi_track per layer
167
+ with sensible names and colors. Sample resolution is deferred —
168
+ callers that want samples loaded should either hand the branch to
169
+ commit_experiment after auditioning, or re-run ComposerEngine.compose()
170
+ on the winning intent.
171
+
172
+ Returns a dict with {"steps", "step_count", "summary"}.
173
+ """
174
+ layers = plan_layers(intent)
175
+ sections = plan_sections(intent)
176
+
177
+ steps: list[dict] = []
178
+
179
+ # Step 1: tempo — only when intent.tempo is set. Remote transport
180
+ # handler takes "tempo" (not "bpm") — see transport.py:set_tempo.
181
+ if intent.tempo and intent.tempo > 0:
182
+ steps.append({
183
+ "tool": "set_tempo",
184
+ "params": {"tempo": float(intent.tempo)},
185
+ })
186
+
187
+ # Step 2: one create_midi_track per layer role — the skeleton every
188
+ # subsequent composition step builds on.
189
+ for idx, layer in enumerate(layers):
190
+ name = getattr(layer, "role", f"layer_{idx}")
191
+ steps.append({
192
+ "tool": "create_midi_track",
193
+ "params": {"name": str(name)},
194
+ })
195
+
196
+ # Step 3: one create_scene + set_scene_name per section. Remote
197
+ # create_scene handler only accepts "index" — see scenes.py:create_scene.
198
+ # Section labels land via set_scene_name after creation. step_id +
199
+ # $from_step binding resolves the new scene index so parallel branches
200
+ # with different section counts don't step on each other.
201
+ for s_idx, section in enumerate(sections):
202
+ if isinstance(section, dict):
203
+ sec_name = section.get("name", f"Section {s_idx + 1}")
204
+ else:
205
+ sec_name = f"Section {s_idx + 1}"
206
+ create_step_id = f"create_scene_{s_idx}"
207
+ steps.append({
208
+ "tool": "create_scene",
209
+ "step_id": create_step_id,
210
+ "params": {"index": -1}, # -1 ⇒ append at end
211
+ })
212
+ steps.append({
213
+ "tool": "set_scene_name",
214
+ "params": {
215
+ "scene_index": {"$from_step": create_step_id, "path": "index"},
216
+ "name": str(sec_name),
217
+ },
218
+ })
219
+
220
+ summary = (
221
+ f"{strategy_name}: {intent.genre or 'auto-genre'} @ "
222
+ f"{intent.tempo or 'auto-tempo'} bpm, energy {intent.energy:.1f} — "
223
+ f"{len(layers)} layers, {len(sections)} sections"
224
+ )
225
+ return {
226
+ "steps": steps,
227
+ "step_count": len(steps),
228
+ "summary": summary,
229
+ }
@@ -4,10 +4,24 @@ Consistent keep/undo semantics shared across sonic, composition,
4
4
  and all future evaluators.
5
5
 
6
6
  Design: EVALUATION_FABRIC_V1.md, section 8
7
+
8
+ PR7 adds ``classify_branch_outcome`` — a branch-lifecycle classifier that
9
+ maps a score (and optional hard-rule inputs) to one of three statuses:
10
+ "keep", "undo", "interesting_but_failed". The third status exists for
11
+ exploration mode: a branch that failed technical gates but surfaced a
12
+ novel idea is kept for audit and never re-applied. Protection violations
13
+ still force undo regardless of exploration mode — that's a safety
14
+ invariant, not a taste judgment.
7
15
  """
8
16
 
9
17
  from __future__ import annotations
10
18
 
19
+ from dataclasses import asdict, dataclass, field
20
+ from typing import Literal, Optional
21
+
22
+
23
+ BranchOutcomeStatus = Literal["keep", "undo", "interesting_but_failed"]
24
+
11
25
 
12
26
  def apply_hard_rules(
13
27
  goal_progress: float,
@@ -16,11 +30,17 @@ def apply_hard_rules(
16
30
  measurable_count: int,
17
31
  score: float,
18
32
  target_count: int,
33
+ defer_on_unmeasurable: bool = True,
19
34
  ) -> tuple[bool, list[str]]:
20
35
  """Enforce hard rules and return (keep_change, failure_reasons).
21
36
 
22
37
  Rules (evaluated in order):
23
- 1. All targets unmeasurable + no protection violation -> defer to agent
38
+ 1. (optional) All targets unmeasurable + no protection violation
39
+ -> defer to agent. Fires only when defer_on_unmeasurable=True
40
+ (the default). The evaluation fabric relies on this to mark
41
+ decision_mode="deferred". Score-producing evaluators (branch
42
+ lifecycle, PR7) pass defer_on_unmeasurable=False because the
43
+ score IS the judgment — no deferral needed.
24
44
  2. Protection violated -> force undo
25
45
  3. Measurable delta <= 0 when measurable targets exist -> force undo
26
46
  4. Score < 0.40 -> force undo
@@ -32,6 +52,10 @@ def apply_hard_rules(
32
52
  measurable_count: how many target dimensions were measurable
33
53
  score: composite quality score (0-1)
34
54
  target_count: total number of target dimensions
55
+ defer_on_unmeasurable: when True (default), rule 1 returns
56
+ (True, [defer message]) as soon as no measurable targets
57
+ exist. When False, rule 1 is skipped and rules 2-4 run
58
+ unconditionally.
35
59
 
36
60
  Returns:
37
61
  (keep_change, list_of_rule_failure_reasons)
@@ -39,7 +63,11 @@ def apply_hard_rules(
39
63
  failures: list[str] = []
40
64
 
41
65
  # Rule 1: all unmeasurable + no protection violation -> defer
42
- if measurable_count == 0 and not protection_violated:
66
+ if (
67
+ defer_on_unmeasurable
68
+ and measurable_count == 0
69
+ and not protection_violated
70
+ ):
43
71
  return True, [
44
72
  "No measurable target dimensions — deferring keep/undo "
45
73
  "to agent musical judgment"
@@ -65,3 +93,102 @@ def apply_hard_rules(
65
93
 
66
94
  keep_change = len(failures) == 0
67
95
  return keep_change, failures
96
+
97
+
98
+ # ── PR7 — branch-lifecycle classifier ────────────────────────────────────
99
+
100
+
101
+ @dataclass
102
+ class BranchOutcome:
103
+ """Unified branch evaluation result.
104
+
105
+ Fields:
106
+ status: terminal classification — "keep" | "undo" | "interesting_but_failed"
107
+ keep_change: True ⇒ status == "keep"; never True for the other statuses.
108
+ score: the composite score that informed the decision.
109
+ failure_reasons: human-readable list of failed hard rules (empty on keep).
110
+ note: optional explanation aimed at the user.
111
+ """
112
+
113
+ status: BranchOutcomeStatus
114
+ keep_change: bool
115
+ score: float
116
+ failure_reasons: list[str] = field(default_factory=list)
117
+ note: str = ""
118
+
119
+ def to_dict(self) -> dict:
120
+ return asdict(self)
121
+
122
+
123
+ def classify_branch_outcome(
124
+ score: float,
125
+ *,
126
+ protection_violated: bool = False,
127
+ measurable_count: int = 0,
128
+ target_count: int = 0,
129
+ goal_progress: float = 0.0,
130
+ exploration_rules: bool = False,
131
+ ) -> BranchOutcome:
132
+ """Classify a branch's terminal status from a score + optional hard-rule inputs.
133
+
134
+ Delegates to apply_hard_rules with ``defer_on_unmeasurable=False`` — a
135
+ score-producing evaluator DID make a judgment, so rule 1's deferral
136
+ path is not appropriate here. The score alone is enough to push a
137
+ branch toward undo / interesting_but_failed.
138
+
139
+ Post-processing:
140
+ - ``exploration_rules=False`` (technical safety, default):
141
+ any hard-rule failure ⇒ status="undo".
142
+ - ``exploration_rules=True`` (creative exploration):
143
+ protection violations still force undo (safety invariant);
144
+ all other failures downgrade to "interesting_but_failed".
145
+
146
+ Returns a BranchOutcome that callers can plug into branch.score /
147
+ .status / .evaluation without further interpretation.
148
+ """
149
+ keep_change, failures = apply_hard_rules(
150
+ goal_progress=goal_progress,
151
+ collateral_damage=0.0, # not threaded here — branch lifecycle doesn't compute it yet
152
+ protection_violated=protection_violated,
153
+ measurable_count=measurable_count,
154
+ score=score,
155
+ target_count=target_count,
156
+ defer_on_unmeasurable=False,
157
+ )
158
+
159
+ if keep_change:
160
+ return BranchOutcome(
161
+ status="keep",
162
+ keep_change=True,
163
+ score=score,
164
+ failure_reasons=[],
165
+ note="",
166
+ )
167
+
168
+ # Failed — decide between undo and interesting_but_failed.
169
+ protection_failure = any("protected dimension" in f for f in failures)
170
+
171
+ if exploration_rules and not protection_failure:
172
+ return BranchOutcome(
173
+ status="interesting_but_failed",
174
+ keep_change=False,
175
+ score=score,
176
+ failure_reasons=failures,
177
+ note=(
178
+ "Exploration rule: branch failed technical gates but is "
179
+ "retained for audit. Not re-applied."
180
+ ),
181
+ )
182
+
183
+ return BranchOutcome(
184
+ status="undo",
185
+ keep_change=False,
186
+ score=score,
187
+ failure_reasons=failures,
188
+ note=(
189
+ "Protection violation — branch rolled back regardless of "
190
+ "exploration mode."
191
+ if protection_failure
192
+ else "Branch rolled back per hard rules."
193
+ ),
194
+ )
@@ -22,6 +22,7 @@ import time
22
22
  from typing import Optional
23
23
 
24
24
  from .models import ExperimentSet, ExperimentBranch, BranchSnapshot
25
+ from ..branches import BranchSeed, seed_from_move_id
25
26
  import logging
26
27
 
27
28
  logger = logging.getLogger(__name__)
@@ -39,27 +40,43 @@ def _gen_id(prefix: str, seed: str) -> str:
39
40
  # ── Create experiments ───────────────────────────────────────────────────────
40
41
 
41
42
 
42
- def create_experiment(
43
+ def create_experiment_from_seeds(
43
44
  request_text: str,
44
- move_ids: list[str],
45
+ seeds: list[BranchSeed],
45
46
  kernel_id: str = "",
47
+ compiled_plans: Optional[list] = None,
46
48
  ) -> ExperimentSet:
47
- """Create an experiment set with branches for each semantic move.
49
+ """Create an experiment set from BranchSeeds (PR3 canonical path).
48
50
 
49
- Does NOT execute anything just creates the branch structures.
50
- Call run_experiment() to actually trial each branch.
51
+ seeds: one BranchSeed per desired branch. Can be any source — semantic_move,
52
+ freeform, synthesis, composer, technique.
53
+ compiled_plans: optional parallel list; when entry ``i`` is a dict, that
54
+ plan is attached to branch ``i`` (used by freeform / synthesis / composer
55
+ producers that do their own compilation). When None or entry is None,
56
+ run_experiment compiles from the seed at run time — which only succeeds
57
+ for source="semantic_move" seeds.
58
+
59
+ Does NOT execute anything — call run_experiment() to trial each branch.
51
60
  """
61
+ if compiled_plans is not None and len(compiled_plans) != len(seeds):
62
+ raise ValueError(
63
+ f"compiled_plans length ({len(compiled_plans)}) must match "
64
+ f"seeds length ({len(seeds)})"
65
+ )
66
+
52
67
  exp_id = _gen_id("exp", request_text)
53
68
  now = int(time.time() * 1000)
54
69
 
55
70
  branches = []
56
- for i, move_id in enumerate(move_ids):
57
- branch = ExperimentBranch(
58
- branch_id=_gen_id("br", f"{move_id}_{i}"),
59
- name=f"Branch {i+1}: {move_id}",
60
- move_id=move_id,
71
+ for i, seed in enumerate(seeds):
72
+ plan = compiled_plans[i] if compiled_plans else None
73
+ display = seed.move_id or (seed.hypothesis[:32] if seed.hypothesis else seed.seed_id)
74
+ branch = ExperimentBranch.from_seed(
75
+ seed=seed,
76
+ branch_id=_gen_id("br", f"{seed.seed_id}_{i}"),
77
+ name=f"Branch {i+1}: {display}",
61
78
  source_kernel_id=kernel_id,
62
- status="pending",
79
+ compiled_plan=plan,
63
80
  created_at_ms=now,
64
81
  )
65
82
  branches.append(branch)
@@ -76,6 +93,25 @@ def create_experiment(
76
93
  return experiment
77
94
 
78
95
 
96
+ def create_experiment(
97
+ request_text: str,
98
+ move_ids: list[str],
99
+ kernel_id: str = "",
100
+ ) -> ExperimentSet:
101
+ """Create an experiment set with one semantic_move branch per move_id.
102
+
103
+ Legacy API — kept for back-compat. Internally builds one BranchSeed per
104
+ move_id via seed_from_move_id and delegates to create_experiment_from_seeds.
105
+ Branch naming, ids, and lifecycle are unchanged for existing callers.
106
+ """
107
+ seeds = [seed_from_move_id(mid) for mid in move_ids]
108
+ return create_experiment_from_seeds(
109
+ request_text=request_text,
110
+ seeds=seeds,
111
+ kernel_id=kernel_id,
112
+ )
113
+
114
+
79
115
  def get_experiment(experiment_id: str) -> Optional[ExperimentSet]:
80
116
  """Get an experiment by ID."""
81
117
  return _EXPERIMENTS.get(experiment_id)
@@ -1,8 +1,15 @@
1
1
  """Experiment branch data models.
2
2
 
3
- An ExperimentBranch represents one trial of a semantic move against the
4
- current session state. Multiple branches form an experiment set that can
5
- be compared and ranked.
3
+ An ExperimentBranch represents one trial against the current session state.
4
+
5
+ Pre-PR3 every branch was tied to a semantic_move (positional required
6
+ ``move_id``). PR3 widens this: a branch may now be built from any
7
+ :class:`mcp_server.branches.BranchSeed` — semantic_move, freeform,
8
+ synthesis, composer, or technique. The ``move_id`` field is retained as
9
+ an optional convenience that mirrors ``seed.move_id`` for back-compat with
10
+ callers that read ``branch.move_id`` directly.
11
+
12
+ Multiple branches form an experiment set that can be compared and ranked.
6
13
  """
7
14
 
8
15
  from __future__ import annotations
@@ -11,6 +18,8 @@ import time
11
18
  from dataclasses import dataclass, field
12
19
  from typing import Any, Optional
13
20
 
21
+ from ..branches import BranchSeed
22
+
14
23
 
15
24
  @dataclass
16
25
  class BranchSnapshot:
@@ -37,14 +46,26 @@ class BranchSnapshot:
37
46
 
38
47
  @dataclass
39
48
  class ExperimentBranch:
40
- """One trial branch in an experiment set."""
49
+ """One trial branch in an experiment set.
50
+
51
+ move_id is retained as an optional convenience (empty for freeform /
52
+ synthesis / composer seeds) so pre-PR3 callers that read
53
+ ``branch.move_id`` directly keep working. The authoritative source of
54
+ branch intent is ``seed`` when present.
55
+ """
41
56
  branch_id: str
42
57
  name: str
43
- move_id: str
58
+ # PR3 — was a required positional; now defaults to "" for seeds whose
59
+ # source is not "semantic_move". When seed is present, move_id mirrors
60
+ # seed.move_id (populated by ExperimentBranch.from_seed).
61
+ move_id: str = ""
44
62
  source_kernel_id: str = ""
45
- status: str = "pending" # pending | running | evaluated | committed | discarded
63
+ status: str = "pending" # pending | running | evaluated | committed | discarded | interesting_but_failed
46
64
 
47
- # Compiled plan for this branch
65
+ # Compiled plan for this branch. Pre-PR3 this was always filled in at
66
+ # run_experiment time. Post-PR3, freeform / synthesis / composer producers
67
+ # MAY pre-populate it on the seed path; run_experiment respects a
68
+ # pre-existing plan and only compiles when it's None.
48
69
  compiled_plan: Optional[dict] = None
49
70
 
50
71
  # Captured snapshots
@@ -65,6 +86,44 @@ class ExperimentBranch:
65
86
  created_at_ms: int = 0
66
87
  executed_at_ms: int = 0
67
88
 
89
+ # PR3 — branch-native seed. None for legacy move-only branches built via
90
+ # the bare constructor; populated when built through from_seed() or via
91
+ # create_experiment_from_seeds.
92
+ seed: Optional[BranchSeed] = None
93
+
94
+ @classmethod
95
+ def from_seed(
96
+ cls,
97
+ seed: BranchSeed,
98
+ branch_id: str,
99
+ name: str = "",
100
+ source_kernel_id: str = "",
101
+ compiled_plan: Optional[dict] = None,
102
+ created_at_ms: int = 0,
103
+ ) -> "ExperimentBranch":
104
+ """Construct an ExperimentBranch from a BranchSeed.
105
+
106
+ ``move_id`` is mirrored from ``seed.move_id`` (empty for freeform /
107
+ synthesis / composer / technique seeds). When ``compiled_plan`` is
108
+ provided, the producer has already compiled — run_experiment will
109
+ skip compilation for this branch. When None, compilation defers to
110
+ the semantic_moves.compiler at run time and only succeeds for
111
+ source="semantic_move" seeds.
112
+ """
113
+ default_name = (
114
+ f"Branch ({seed.source}:{seed.move_id or seed.seed_id[:8]})"
115
+ )
116
+ return cls(
117
+ branch_id=branch_id,
118
+ name=name or default_name,
119
+ move_id=seed.move_id,
120
+ source_kernel_id=source_kernel_id,
121
+ status="pending",
122
+ compiled_plan=compiled_plan,
123
+ created_at_ms=created_at_ms,
124
+ seed=seed,
125
+ )
126
+
68
127
  def to_dict(self) -> dict:
69
128
  d = {
70
129
  "branch_id": self.branch_id,
@@ -87,6 +146,12 @@ class ExperimentBranch:
87
146
  d["execution_log"] = self.execution_log
88
147
  d["steps_ok"] = sum(1 for e in self.execution_log if e.get("ok"))
89
148
  d["steps_failed"] = sum(1 for e in self.execution_log if not e.get("ok"))
149
+ if self.seed is not None:
150
+ d["seed"] = self.seed.to_dict()
151
+ d["branch_source"] = self.seed.source
152
+ d["analytical_only"] = (
153
+ self.seed.analytical_only or self.compiled_plan is None
154
+ )
90
155
  return d
91
156
 
92
157