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.
- package/CHANGELOG.md +82 -0
- package/README.md +3 -3
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/livepilot_bridge.js +1 -1
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/branches/__init__.py +32 -0
- package/mcp_server/branches/types.py +230 -0
- package/mcp_server/composer/__init__.py +10 -1
- package/mcp_server/composer/branch_producer.py +229 -0
- package/mcp_server/evaluation/policy.py +129 -2
- package/mcp_server/experiment/engine.py +47 -11
- package/mcp_server/experiment/models.py +72 -7
- package/mcp_server/experiment/tools.py +231 -35
- package/mcp_server/memory/taste_graph.py +84 -11
- package/mcp_server/persistence/taste_store.py +21 -5
- package/mcp_server/runtime/session_kernel.py +46 -0
- package/mcp_server/runtime/tools.py +29 -3
- package/mcp_server/synthesis_brain/__init__.py +53 -0
- package/mcp_server/synthesis_brain/adapters/__init__.py +34 -0
- package/mcp_server/synthesis_brain/adapters/analog.py +167 -0
- package/mcp_server/synthesis_brain/adapters/base.py +86 -0
- package/mcp_server/synthesis_brain/adapters/drift.py +166 -0
- package/mcp_server/synthesis_brain/adapters/meld.py +151 -0
- package/mcp_server/synthesis_brain/adapters/operator.py +169 -0
- package/mcp_server/synthesis_brain/adapters/wavetable.py +228 -0
- package/mcp_server/synthesis_brain/engine.py +91 -0
- package/mcp_server/synthesis_brain/models.py +121 -0
- package/mcp_server/synthesis_brain/timbre.py +194 -0
- package/mcp_server/tools/_conductor.py +144 -0
- package/mcp_server/wonder_mode/engine.py +324 -0
- package/mcp_server/wonder_mode/tools.py +153 -1
- package/package.json +2 -2
- package/remote_script/LivePilot/__init__.py +1 -1
- 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
|
-
|
|
70
|
-
Otherwise, uses propose_next_best_move to find candidates.
|
|
72
|
+
Three input modes (in priority order):
|
|
71
73
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
|
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
|
-
#
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
79
|
-
|
|
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(
|
|
158
|
-
|
|
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
|
-
|
|
198
|
+
new_val = min(1.0, current + 0.05)
|
|
161
199
|
else:
|
|
162
|
-
|
|
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(
|
|
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 -
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
68
|
+
bands[goal_mode] = min(1.0, current + 0.05)
|
|
58
69
|
else:
|
|
59
|
-
|
|
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
|
)
|