livepilot 1.12.2 → 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 (34) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/README.md +3 -3
  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/memory/taste_graph.py +84 -11
  15. package/mcp_server/persistence/taste_store.py +21 -5
  16. package/mcp_server/runtime/session_kernel.py +46 -0
  17. package/mcp_server/runtime/tools.py +29 -3
  18. package/mcp_server/synthesis_brain/__init__.py +53 -0
  19. package/mcp_server/synthesis_brain/adapters/__init__.py +34 -0
  20. package/mcp_server/synthesis_brain/adapters/analog.py +167 -0
  21. package/mcp_server/synthesis_brain/adapters/base.py +86 -0
  22. package/mcp_server/synthesis_brain/adapters/drift.py +166 -0
  23. package/mcp_server/synthesis_brain/adapters/meld.py +151 -0
  24. package/mcp_server/synthesis_brain/adapters/operator.py +169 -0
  25. package/mcp_server/synthesis_brain/adapters/wavetable.py +228 -0
  26. package/mcp_server/synthesis_brain/engine.py +91 -0
  27. package/mcp_server/synthesis_brain/models.py +121 -0
  28. package/mcp_server/synthesis_brain/timbre.py +194 -0
  29. package/mcp_server/tools/_conductor.py +144 -0
  30. package/mcp_server/wonder_mode/engine.py +324 -0
  31. package/mcp_server/wonder_mode/tools.py +153 -1
  32. package/package.json +2 -2
  33. package/remote_script/LivePilot/__init__.py +1 -1
  34. package/server.json +2 -2
@@ -16,6 +16,7 @@ from typing import Optional
16
16
  from fastmcp import Context
17
17
 
18
18
  from ..server import mcp
19
+ from ..branches import BranchSeed
19
20
  from . import engine
20
21
  from .models import BranchSnapshot
21
22
  import logging
@@ -63,18 +64,71 @@ def create_experiment(
63
64
  request_text: str,
64
65
  move_ids: Optional[list] = None,
65
66
  limit: int = 3,
67
+ seeds: Optional[list] = None,
68
+ compiled_plans: Optional[list] = None,
66
69
  ) -> dict:
67
70
  """Create an experiment set to compare multiple approaches.
68
71
 
69
- If move_ids is provided, creates one branch per move.
70
- Otherwise, uses propose_next_best_move to find candidates.
72
+ Three input modes (in priority order):
71
73
 
72
- request_text: what the user wants (e.g., "make this punchier")
73
- move_ids: specific moves to try (e.g., ["make_punchier", "tighten_low_end"])
74
- limit: max branches when auto-proposing (default 3)
74
+ 1. seeds (PR3+): a list of BranchSeed dicts. Each seed becomes one branch.
75
+ compiled_plans (optional parallel list) attaches pre-compiled plans
76
+ for freeform / synthesis / composer producers. Seed dict shape:
77
+ {seed_id, source, move_id, hypothesis, protected_qualities,
78
+ affected_scope, distinctness_reason, risk_label, novelty_label,
79
+ analytical_only}
80
+ Missing fields default per BranchSeed. This is the canonical path
81
+ for producers that have already done their own selection work.
82
+
83
+ 2. move_ids: legacy path — one semantic_move seed per move_id.
84
+ Unchanged behavior; internally delegates to the seeds path.
85
+
86
+ 3. Auto-proposal: neither seeds nor move_ids provided. Scans the semantic
87
+ move registry by keyword overlap with request_text and takes the top
88
+ ``limit`` moves (default 3).
75
89
 
76
90
  Returns: experiment set with branch IDs ready for run_experiment.
77
91
  """
92
+ # ── Mode 1: seeds provided ──────────────────────────────────────────
93
+ if seeds:
94
+ rehydrated: list[BranchSeed] = []
95
+ for i, s in enumerate(seeds):
96
+ if isinstance(s, BranchSeed):
97
+ rehydrated.append(s)
98
+ elif isinstance(s, dict):
99
+ try:
100
+ rehydrated.append(BranchSeed(**s))
101
+ except TypeError as exc:
102
+ return {"error": f"seeds[{i}] invalid: {exc}"}
103
+ else:
104
+ return {
105
+ "error": (
106
+ f"seeds[{i}] must be dict or BranchSeed, "
107
+ f"got {type(s).__name__}"
108
+ )
109
+ }
110
+
111
+ if compiled_plans is not None and len(compiled_plans) != len(rehydrated):
112
+ return {
113
+ "error": (
114
+ f"compiled_plans length ({len(compiled_plans)}) must match "
115
+ f"seeds length ({len(rehydrated)})"
116
+ )
117
+ }
118
+
119
+ ableton = _get_ableton(ctx)
120
+ ableton.send_command("get_session_info")
121
+ kernel_id = f"kern_{int(time.time())}"
122
+
123
+ experiment = engine.create_experiment_from_seeds(
124
+ request_text=request_text,
125
+ seeds=rehydrated,
126
+ kernel_id=kernel_id,
127
+ compiled_plans=compiled_plans,
128
+ )
129
+ return experiment.to_dict()
130
+
131
+ # ── Mode 2/3: legacy move_ids path ──────────────────────────────────
78
132
  if not move_ids:
79
133
  # Auto-propose moves from the registry
80
134
  from ..semantic_moves import registry
@@ -119,19 +173,30 @@ def create_experiment(
119
173
  async def run_experiment(
120
174
  ctx: Context,
121
175
  experiment_id: str,
176
+ exploration_rules: bool = False,
122
177
  ) -> dict:
123
178
  """Run all pending branches in an experiment.
124
179
 
125
180
  For each branch:
126
181
  1. Compile the semantic move against current session
182
+ (skipped when branch.compiled_plan is already set — PR3+)
127
183
  2. Capture before state
128
- 3. Execute the compiled plan (through the async router — v1.10.3 truth)
184
+ 3. Execute the compiled plan (through the async router)
129
185
  4. Capture after state
130
186
  5. Undo all successful steps (revert to checkpoint)
131
- 6. Evaluate the branch
187
+ 6. Evaluate the branch and classify its outcome via evaluation.policy
132
188
  7. Record per-step results on branch.execution_log
133
189
 
134
190
  Branches run sequentially (Ableton has linear undo).
191
+
192
+ exploration_rules (PR7): when True, branches that fail technical gates
193
+ (score < 0.40, non-positive measurable delta) are classified as
194
+ "interesting_but_failed" instead of "failed" — they stay in the
195
+ experiment for audit but don't appear in the ranking. Protection
196
+ violations STILL force undo regardless of this flag — that's a safety
197
+ invariant, not a taste judgment.
198
+
199
+ Default False preserves pre-PR7 behavior exactly.
135
200
  """
136
201
  experiment = engine.get_experiment(experiment_id)
137
202
  if not experiment:
@@ -149,19 +214,54 @@ async def run_experiment(
149
214
  if branch.status != "pending":
150
215
  continue
151
216
 
152
- # Compile the move
153
- move = registry.get_move(branch.move_id)
154
- if not move:
155
- branch.status = "failed"
156
- branch.score = 0.0
157
- branch.evaluation = {"error": f"Move {branch.move_id} not found"}
158
- results.append(branch.to_dict())
159
- continue
160
-
161
- session_info = ableton.send_command("get_session_info")
162
- kernel = {"session_info": session_info, "mode": "explore"}
163
- plan = compiler.compile(move, kernel)
164
- compiled_dict = plan.to_dict()
217
+ # PR3: respect a pre-existing compiled_plan on the branch (freeform /
218
+ # synthesis / composer producers bring their own). Only compile from
219
+ # move_id when the branch arrived without a plan — which requires a
220
+ # semantic_move seed (or a legacy move-only branch).
221
+ compiled_dict = branch.compiled_plan
222
+
223
+ if compiled_dict is None:
224
+ # Analytical-only branches short-circuit — no plan to run.
225
+ # Marked with status="analytical" so ranked_branches()
226
+ # (which only surfaces "evaluated") excludes them, and
227
+ # commit_experiment refuses to re-apply them.
228
+ if branch.seed is not None and branch.seed.analytical_only:
229
+ branch.status = "analytical"
230
+ branch.score = 0.0
231
+ branch.evaluation = {
232
+ "score": 0.0,
233
+ "keep_change": False,
234
+ "status": "analytical",
235
+ "note": "analytical_only branch — no execution path",
236
+ }
237
+ results.append(branch.to_dict())
238
+ continue
239
+
240
+ if not branch.move_id:
241
+ branch.status = "failed"
242
+ branch.score = 0.0
243
+ branch.evaluation = {
244
+ "error": (
245
+ "Branch has no compiled_plan and no move_id — "
246
+ "freeform producers must pre-populate compiled_plan"
247
+ )
248
+ }
249
+ results.append(branch.to_dict())
250
+ continue
251
+
252
+ # Compile from semantic move
253
+ move = registry.get_move(branch.move_id)
254
+ if not move:
255
+ branch.status = "failed"
256
+ branch.score = 0.0
257
+ branch.evaluation = {"error": f"Move {branch.move_id} not found"}
258
+ results.append(branch.to_dict())
259
+ continue
260
+
261
+ session_info = ableton.send_command("get_session_info")
262
+ kernel = {"session_info": session_info, "mode": "explore"}
263
+ plan = compiler.compile(move, kernel)
264
+ compiled_dict = plan.to_dict()
165
265
 
166
266
  # Run the branch through the async router
167
267
  await engine.run_branch_async(
@@ -174,26 +274,78 @@ async def run_experiment(
174
274
  ctx=ctx,
175
275
  )
176
276
 
177
- # Evaluate
277
+ # Evaluate — score via the inline heuristic, then classify via
278
+ # evaluation.policy for a unified keep/undo/interesting_but_failed
279
+ # decision (PR7).
280
+ from ..evaluation.policy import classify_branch_outcome
281
+
178
282
  def eval_fn(before, after):
179
- # Simple heuristic evaluation when spectral data isn't available
283
+ # Simple heuristic evaluation when spectral data isn't available.
284
+ # protection_violated is rough — derived from whether any track
285
+ # went silent (signal lost on a track = protection violation).
180
286
  score = 0.5 # Neutral
287
+ protection_violated = False
288
+ lost_tracks = 0
289
+
181
290
  if before.get("track_meters") and after.get("track_meters"):
182
- # Check all tracks still alive
183
291
  before_alive = sum(1 for t in before["track_meters"] if t.get("level", 0) > 0)
184
292
  after_alive = sum(1 for t in after["track_meters"] if t.get("level", 0) > 0)
185
- if after_alive >= before_alive:
293
+ lost_tracks = max(0, before_alive - after_alive)
294
+ if lost_tracks == 0:
186
295
  score += 0.1
187
296
  else:
188
- score -= 0.2 # Lost a track
297
+ score -= 0.2
298
+ # A track going silent is a protection violation — always
299
+ # undo regardless of exploration mode.
300
+ protection_violated = True
189
301
 
190
302
  if before.get("spectrum") and after.get("spectrum"):
191
- # Spectral balance improvement heuristic
192
- score += 0.1 # Bonus for having spectral data
193
-
194
- return {"score": round(score, 3), "keep_change": score > 0.45}
303
+ score += 0.1 # presence-of-data bonus
304
+
305
+ score = round(score, 3)
306
+ outcome = classify_branch_outcome(
307
+ score=score,
308
+ protection_violated=protection_violated,
309
+ # Minimal hard-rule inputs — the heuristic doesn't compute
310
+ # measurable_count / goal_progress deltas. target_count=0 and
311
+ # measurable_count=0 lets rule 1 defer to score-only judgment.
312
+ measurable_count=0,
313
+ target_count=0,
314
+ goal_progress=0.0,
315
+ exploration_rules=exploration_rules,
316
+ )
317
+
318
+ return {
319
+ "score": outcome.score,
320
+ "keep_change": outcome.keep_change,
321
+ "status": outcome.status,
322
+ "failure_reasons": outcome.failure_reasons,
323
+ "note": outcome.note,
324
+ "lost_tracks": lost_tracks,
325
+ }
195
326
 
196
327
  engine.evaluate_branch(branch, eval_fn)
328
+
329
+ # Promote the classified status onto the branch. ranked_branches()
330
+ # only surfaces status="evaluated", so branches the classifier
331
+ # rejected ("undo") or retained for audit ("interesting_but_failed")
332
+ # are both correctly excluded from winner recommendations.
333
+ # Without this mapping, a branch the hard-rule classifier explicitly
334
+ # rejected could still win a ranking and be re-applied by commit.
335
+ if branch.evaluation and branch.evaluation.get("status"):
336
+ status = branch.evaluation["status"]
337
+ if status == "keep":
338
+ branch.status = "evaluated"
339
+ elif status == "interesting_but_failed":
340
+ branch.status = "interesting_but_failed"
341
+ elif status == "undo":
342
+ # Undo-classified branches had their steps rolled back by
343
+ # run_branch_async's undo pass; they must NOT be eligible
344
+ # winners. "rejected" is a terminal branch status distinct
345
+ # from "failed" (execution failed) and distinct from
346
+ # "interesting_but_failed" (exploration-mode retention).
347
+ branch.status = "rejected"
348
+
197
349
  results.append(branch.to_dict())
198
350
 
199
351
  return {
@@ -221,6 +373,30 @@ def compare_experiments(
221
373
  return {"error": f"Experiment {experiment_id} not found"}
222
374
 
223
375
  ranked = experiment.ranked_branches()
376
+
377
+ # Surface non-winning branch categories separately. None of these are
378
+ # candidates for commit — ranked_branches() filters them out — but the
379
+ # user sees what was tried.
380
+ interesting_failed = [
381
+ b for b in experiment.branches if b.status == "interesting_but_failed"
382
+ ]
383
+ rejected = [
384
+ b for b in experiment.branches if b.status == "rejected"
385
+ ]
386
+ analytical = [
387
+ b for b in experiment.branches if b.status == "analytical"
388
+ ]
389
+
390
+ def _audit_row(b):
391
+ return {
392
+ "branch_id": b.branch_id,
393
+ "name": b.name,
394
+ "move_id": b.move_id,
395
+ "score": b.score,
396
+ "summary": b.compiled_plan.get("summary", "") if b.compiled_plan else "",
397
+ "evaluation": b.evaluation,
398
+ }
399
+
224
400
  return {
225
401
  "experiment_id": experiment_id,
226
402
  "request": experiment.request_text,
@@ -228,16 +404,14 @@ def compare_experiments(
228
404
  "ranking": [
229
405
  {
230
406
  "rank": i + 1,
231
- "branch_id": b.branch_id,
232
- "name": b.name,
233
- "move_id": b.move_id,
234
- "score": b.score,
235
- "summary": b.compiled_plan.get("summary", "") if b.compiled_plan else "",
236
- "evaluation": b.evaluation,
407
+ **_audit_row(b),
237
408
  }
238
409
  for i, b in enumerate(ranked)
239
410
  ],
240
411
  "winner": ranked[0].to_dict() if ranked else None,
412
+ "interesting_but_failed": [_audit_row(b) for b in interesting_failed],
413
+ "rejected": [_audit_row(b) for b in rejected],
414
+ "analytical": [_audit_row(b) for b in analytical],
241
415
  }
242
416
 
243
417
 
@@ -258,6 +432,28 @@ async def commit_experiment(
258
432
  if not experiment:
259
433
  return {"error": f"Experiment {experiment_id} not found"}
260
434
 
435
+ # Refuse to commit branches the classifier rejected or that were
436
+ # analytical-only. Those statuses exist specifically so callers
437
+ # can't route them into re-application, and ranked_branches()
438
+ # already excludes them — so reaching commit with such a branch
439
+ # means the caller is bypassing the ranking layer.
440
+ target = experiment.get_branch(branch_id)
441
+ if target is None:
442
+ return {"error": f"Branch {branch_id} not found"}
443
+ if target.status in ("rejected", "analytical", "failed"):
444
+ return {
445
+ "error": (
446
+ f"Cannot commit branch with status '{target.status}'. "
447
+ f"'rejected' = hard-rule classifier rolled back; "
448
+ f"'analytical' = no executable plan; "
449
+ f"'failed' = zero steps applied successfully. "
450
+ f"Use compare_experiments to see eligible winners "
451
+ f"(only status='evaluated' branches are ranking candidates)."
452
+ ),
453
+ "branch_id": branch_id,
454
+ "branch_status": target.status,
455
+ }
456
+
261
457
  ableton = _get_ableton(ctx)
262
458
  bridge = ctx.lifespan_context.get("m4l")
263
459
  mcp_registry = ctx.lifespan_context.get("mcp_dispatch", {})
@@ -75,13 +75,37 @@ class TasteGraph:
75
75
  # Device preferences
76
76
  device_affinities: dict[str, DeviceAffinity] = field(default_factory=dict)
77
77
 
78
- # Novelty tolerance: 0 = very conservative, 1 = very experimental
79
- novelty_band: float = 0.5
78
+ # PR8 per-goal-mode novelty bands. Canonical source of truth.
79
+ # Keys are goal modes ("improve", "explore", or any string a caller
80
+ # supplies). novelty_band (below, as a property) reads/writes
81
+ # novelty_bands["improve"] — one storage, two access paths.
82
+ novelty_bands: dict = field(
83
+ default_factory=lambda: {"improve": 0.5, "explore": 0.5}
84
+ )
85
+
86
+ # PR8 — when True, rank_moves returns uniform taste scores (0.5) to
87
+ # bypass taste filtering during branch generation. Callers flip this
88
+ # for fresh / surprise-me mode so novelty survives to post-hoc ranking.
89
+ bypass_taste_in_generation: bool = False
80
90
 
81
91
  # Total evidence count (how many decisions informed this graph)
82
92
  evidence_count: int = 0
83
93
  last_updated_ms: int = 0
84
94
 
95
+ @property
96
+ def novelty_band(self) -> float:
97
+ """Legacy flat novelty band — mirrors novelty_bands["improve"].
98
+
99
+ Kept for back-compat with callers that set/read novelty_band
100
+ directly. Setting this property writes through to the bands dict
101
+ so there's no dual source of truth.
102
+ """
103
+ return self.novelty_bands.get("improve", 0.5)
104
+
105
+ @novelty_band.setter
106
+ def novelty_band(self, value: float) -> None:
107
+ self.novelty_bands["improve"] = float(value)
108
+
85
109
  def to_dict(self) -> dict:
86
110
  return {
87
111
  "dimension_weights": self.dimension_weights,
@@ -96,7 +120,11 @@ class TasteGraph:
96
120
  key=lambda x: -x[1].affinity,
97
121
  )[:10] # Top 10 only
98
122
  },
123
+ # novelty_band kept for legacy consumers that read it directly;
124
+ # novelty_bands is the canonical per-goal-mode shape going forward.
99
125
  "novelty_band": round(self.novelty_band, 3),
126
+ "novelty_bands": {k: round(v, 3) for k, v in self.novelty_bands.items()},
127
+ "bypass_taste_in_generation": self.bypass_taste_in_generation,
100
128
  "evidence_count": self.evidence_count,
101
129
  }
102
130
 
@@ -154,21 +182,53 @@ class TasteGraph:
154
182
  self.evidence_count += 1
155
183
  self.last_updated_ms = now
156
184
 
157
- def update_novelty_from_experiment(self, chose_bold: bool) -> None:
158
- """Shift novelty band based on experiment choices."""
185
+ def update_novelty_from_experiment(
186
+ self, chose_bold: bool, goal_mode: str = "improve",
187
+ ) -> None:
188
+ """Shift novelty band for a given goal mode based on experiment choices.
189
+
190
+ PR8: goal_mode defaults to "improve" so legacy callers land on the
191
+ same band they updated before. Pass "explore" to shift the
192
+ exploration-mode band without touching improve-mode preference.
193
+ (novelty_band is a property view over novelty_bands["improve"], so
194
+ improve-mode updates automatically surface there too.)
195
+ """
196
+ current = self.novelty_bands.get(goal_mode, 0.5)
159
197
  if chose_bold:
160
- self.novelty_band = min(1.0, self.novelty_band + 0.05)
198
+ new_val = min(1.0, current + 0.05)
161
199
  else:
162
- self.novelty_band = max(0.0, self.novelty_band - 0.05)
200
+ new_val = max(0.0, current - 0.05)
201
+ self.novelty_bands[goal_mode] = new_val
163
202
 
164
203
  # ── Ranking ──────────────────────────────────────────────────────
165
204
 
166
- def rank_moves(self, move_specs: list[dict]) -> list[dict]:
205
+ def rank_moves(
206
+ self,
207
+ move_specs: list[dict],
208
+ goal_mode: str = "improve",
209
+ ) -> list[dict]:
167
210
  """Rank a list of semantic move dicts by taste fit.
168
211
 
169
212
  Each move dict should have: move_id, family, targets, risk_level.
170
213
  Returns the same dicts with added 'taste_score' field, sorted desc.
214
+
215
+ PR8 additions:
216
+ goal_mode (str, default "improve"): which novelty band to use for
217
+ risk alignment. "improve" respects the user's conservative history;
218
+ "explore" uses the explore-mode band so past timid choices don't
219
+ punish surprise-me branch generation.
220
+ bypass_taste_in_generation (instance flag): when True, every move
221
+ scores a uniform 0.5. Used during branch generation so taste
222
+ doesn't prune novelty before the user has a chance to audition.
223
+ Ranking order is preserved from input when this flag is on.
171
224
  """
225
+ if self.bypass_taste_in_generation:
226
+ return [dict(move, taste_score=0.5) for move in move_specs]
227
+
228
+ # Read the band for the requested mode. Falls back to the improve
229
+ # band (via self.novelty_band property) when the mode is unknown.
230
+ novelty_band = self.novelty_bands.get(goal_mode, self.novelty_band)
231
+
172
232
  ranked = []
173
233
  for move in move_specs:
174
234
  taste_score = 0.5 # Neutral baseline
@@ -190,10 +250,10 @@ class TasteGraph:
190
250
  if dim in self.dimension_avoidances:
191
251
  taste_score -= 0.3
192
252
 
193
- # Novelty/risk alignment
253
+ # Novelty/risk alignment (PR8: per-mode band)
194
254
  risk = move.get("risk_level", "low")
195
255
  risk_val = {"low": 0.2, "medium": 0.5, "high": 0.8}.get(risk, 0.5)
196
- novelty_match = 1.0 - abs(risk_val - self.novelty_band)
256
+ novelty_match = 1.0 - abs(risk_val - novelty_band)
197
257
  taste_score += novelty_match * 0.1
198
258
 
199
259
  # Clamp
@@ -297,8 +357,21 @@ def build_taste_graph(
297
357
  if total > 0:
298
358
  fam.score = round((fam.kept_count - fam.undone_count) / total, 3)
299
359
 
300
- # Novelty band
301
- graph.novelty_band = persisted.get("novelty_band", 0.5)
360
+ # Novelty band — migrate from flat float if present, OR read
361
+ # per-mode dict if newer persistence format has it (PR8).
362
+ persisted_band = persisted.get("novelty_band", 0.5)
363
+ persisted_bands = persisted.get("novelty_bands")
364
+ if isinstance(persisted_bands, dict) and persisted_bands:
365
+ graph.novelty_bands = {
366
+ k: float(v) for k, v in persisted_bands.items() if isinstance(v, (int, float))
367
+ }
368
+ # Ensure both canonical keys are present with sensible defaults.
369
+ graph.novelty_bands.setdefault("improve", persisted_band)
370
+ graph.novelty_bands.setdefault("explore", persisted_band)
371
+ graph.novelty_band = graph.novelty_bands["improve"]
372
+ else:
373
+ graph.novelty_band = persisted_band
374
+ graph.novelty_bands = {"improve": persisted_band, "explore": persisted_band}
302
375
 
303
376
  # Device affinities
304
377
  for dev_name, dev_data in persisted.get("device_affinities", {}).items():
@@ -48,15 +48,29 @@ class PersistentTasteStore:
48
48
  return data
49
49
  self._store.update(_update)
50
50
 
51
- def update_novelty(self, chose_bold: bool) -> None:
52
- """Update novelty band from experiment choice."""
51
+ def update_novelty(self, chose_bold: bool, goal_mode: str = "improve") -> None:
52
+ """Update novelty band from experiment choice for a given goal mode.
53
+
54
+ PR8: goal_mode defaults to "improve" so legacy callers land on the
55
+ same band they updated before. The per-mode dict ``novelty_bands``
56
+ is maintained alongside the flat ``novelty_band`` field; the flat
57
+ field mirrors the "improve" band.
58
+ """
53
59
  def _update(data: dict) -> dict:
54
60
  data = data if data.get("version") == 1 else self._default()
55
- band = data.get("novelty_band", 0.5)
61
+ # Ensure the per-mode dict exists (migrating from legacy shape).
62
+ bands = data.get("novelty_bands")
63
+ if not isinstance(bands, dict) or not bands:
64
+ flat = data.get("novelty_band", 0.5)
65
+ bands = {"improve": flat, "explore": flat}
66
+ current = bands.get(goal_mode, 0.5)
56
67
  if chose_bold:
57
- data["novelty_band"] = min(1.0, band + 0.05)
68
+ bands[goal_mode] = min(1.0, current + 0.05)
58
69
  else:
59
- data["novelty_band"] = max(0.0, band - 0.05)
70
+ bands[goal_mode] = max(0.0, current - 0.05)
71
+ data["novelty_bands"] = bands
72
+ # Mirror the improve band onto the flat field for back-compat.
73
+ data["novelty_band"] = bands.get("improve", 0.5)
60
74
  data["evidence_count"] = data.get("evidence_count", 0) + 1
61
75
  return data
62
76
  self._store.update(_update)
@@ -114,6 +128,8 @@ class PersistentTasteStore:
114
128
  "version": 1,
115
129
  "move_outcomes": {},
116
130
  "novelty_band": 0.5,
131
+ # PR8 — per-goal-mode novelty bands; novelty_band mirrors "improve"
132
+ "novelty_bands": {"improve": 0.5, "explore": 0.5},
117
133
  "device_affinities": {},
118
134
  "anti_preferences": [],
119
135
  "dimension_weights": {},
@@ -45,6 +45,37 @@ class SessionKernel:
45
45
  recommended_engines: list = field(default_factory=list)
46
46
  recommended_workflow: str = ""
47
47
 
48
+ # ── Creative controls (PR2 — branch-native migration) ──────────────
49
+ # All optional. Producers (Wonder, synthesis_brain, composer) read these
50
+ # to bias branch generation. Pre-PR2 callers leave them at defaults and
51
+ # nothing changes. PR6 (Wonder refactor) and PR9 (synthesis_brain) start
52
+ # reading them in earnest.
53
+
54
+ # 0.0 = conservative / don't surprise me; 1.0 = surprise me.
55
+ # Distinct from aggression (which is about execution boldness).
56
+ freshness: float = 0.5
57
+
58
+ # Shorthand producer philosophy tag. The sample_engine already uses
59
+ # "surgeon" / "alchemist" (see livepilot-sample-engine); synth work
60
+ # may add "sculptor". Empty string = producer picks a default.
61
+ creativity_profile: str = ""
62
+
63
+ # Caller-asserted sacred elements. Normally sacred elements come from
64
+ # song_brain; this lets the user or a skill override. Shape matches
65
+ # song_brain.sacred_elements entries: {element_type, description, salience}.
66
+ sacred_elements: list = field(default_factory=list)
67
+
68
+ # Hints for synthesis_brain: which tracks/devices to focus on and what
69
+ # target timbre to aim for. Shape is open in PR2 and will be firmed up
70
+ # when PR9 adds the first adapters.
71
+ # {
72
+ # "track_indices": [int, ...],
73
+ # "device_paths": ["track/Wavetable", ...],
74
+ # "target_timbre": {"brightness": +0.3, "width": +0.2, ...},
75
+ # "preferred_devices": ["Wavetable", "Operator", ...],
76
+ # }
77
+ synth_hints: dict = field(default_factory=dict)
78
+
48
79
  def to_dict(self) -> dict:
49
80
  return asdict(self)
50
81
 
@@ -60,12 +91,23 @@ def build_session_kernel(
60
91
  taste_graph: Optional[dict] = None,
61
92
  anti_preferences: Optional[list] = None,
62
93
  protected_dimensions: Optional[dict] = None,
94
+ # PR2 — creative controls. All optional; legacy callers unaffected.
95
+ freshness: float = 0.5,
96
+ creativity_profile: str = "",
97
+ sacred_elements: Optional[list] = None,
98
+ synth_hints: Optional[dict] = None,
63
99
  ) -> SessionKernel:
64
100
  """Build a SessionKernel from raw data.
65
101
 
66
102
  All optional fields degrade gracefully to empty defaults.
67
103
  The kernel_id is deterministic from the core inputs so it's stable
68
104
  within the same turn context.
105
+
106
+ The PR2 creative-control fields (freshness, creativity_profile,
107
+ sacred_elements, synth_hints) are intentionally excluded from the
108
+ kernel_id hash so existing callers see no identity changes. Producers
109
+ that need these fields to influence identity can compose their own
110
+ derived id downstream.
69
111
  """
70
112
  # Deterministic kernel_id from inputs
71
113
  id_seed = json.dumps(
@@ -93,4 +135,8 @@ def build_session_kernel(
93
135
  taste_graph=taste_graph or {},
94
136
  anti_preferences=anti_preferences or [],
95
137
  protected_dimensions=protected_dimensions or {},
138
+ freshness=freshness,
139
+ creativity_profile=creativity_profile,
140
+ sacred_elements=sacred_elements or [],
141
+ synth_hints=synth_hints or {},
96
142
  )