livepilot 1.17.1 → 1.17.3

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 CHANGED
@@ -1,5 +1,188 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.17.3 — Truth-gap remediation, for real (April 23 2026)
4
+
5
+ ### Fixed
6
+
7
+ - **`iterate_toward_goal` now inspects `commit_fn` return value** (P1,
8
+ `mcp_server/tools/_agent_os_engine/iteration.py`): prior to this release
9
+ the iteration loop awaited the commit callback and dropped the return
10
+ value on the floor, then unconditionally returned `status="committed"`.
11
+ If the underlying `commit_branch_async` applied zero steps or partially
12
+ succeeded, the iteration result claimed success — the exact bug pattern
13
+ the release was meant to fix elsewhere. New `_classify_commit_result()`
14
+ helper maps known payload shapes to three statuses: `"committed"` (clean),
15
+ `"committed_with_errors"` (steps_ok > 0 AND steps_failed > 0), and
16
+ `"commit_failed"` (committed=False, ok=False, status="failed", or
17
+ steps_ok == 0). Both sync and async cores now zero out
18
+ `committed_experiment_id`/`committed_branch_id` when the commit truly
19
+ failed, and surface the raw commit payload on `IterationResult.commit_result`.
20
+ - **Preview Studio commit-before-execute ordering** (P1,
21
+ `mcp_server/preview_studio/tools.py`): `commit_preview_variant()` called
22
+ `engine.commit_variant()` BEFORE `execute_plan_steps_async` ran. That
23
+ flipped `preview_set.status = "committed"` and `committed_variant_id`
24
+ up front, so when every execution step failed the response correctly
25
+ said `committed: false / status: "failed"` but the stored state still
26
+ said the opposite. Wonder lifecycle advance also fired regardless.
27
+ Reorder: execute first, then flip state only when `steps_ok > 0`.
28
+ Zero-success path now returns honestly and leaves `preview_set` and
29
+ WonderSession untouched. Partial-success stays a legitimate commit
30
+ with `status="committed_with_errors"`.
31
+ - **`get_session_kernel` propagates web + flucoma probe results** (P2,
32
+ `mcp_server/runtime/tools.py`): the kernel builder called
33
+ `build_capability_state(...)` with only session/analyzer/memory params,
34
+ so `web_ok` and `flucoma_ok` silently defaulted to `False`. Meanwhile
35
+ `get_capability_state()` correctly probed both. Planners that read
36
+ the kernel (the documented orchestration entrypoint) stayed on
37
+ degraded paths even when probes would have reported available. Fix:
38
+ call `_probe_web()` + `_probe_flucoma()` inside `get_session_kernel`
39
+ and pass through.
40
+
41
+ ### Added
42
+
43
+ - **10 new tests** covering the three truth-gap classes:
44
+ - `test_iterate_toward_goal.py`: 4 tests for commit inspection
45
+ (failed commit, partial commit, timeout commit_best, back-compat
46
+ clean success).
47
+ - `test_preview_studio_truth_gap.py`: 3 tests for
48
+ executable-variant-fails paths (all-steps-fail preserves state,
49
+ Wonder not advanced, partial-success honest commit).
50
+ - `test_runtime_capability_probes.py`: 3 tests for kernel
51
+ propagation (web probe → kernel, flucoma probe → kernel,
52
+ both-unavailable back-compat).
53
+ - **`IterationResult.commit_result`** — the raw commit_fn payload,
54
+ surfaced on the returned dict whenever a commit was attempted.
55
+ Callers can inspect `result["commit_result"]["steps_failed"]`,
56
+ `result["commit_result"]["error"]`, etc.
57
+
58
+ This release closes what the post-v1.17.2 review correctly flagged:
59
+ the feature we shipped to "close the evaluation loop" had a truth-gap
60
+ at the innermost step. 2712 → 2722 tests pass.
61
+
62
+ ## 1.17.2 — iterate_toward_goal + preview-studio truth-gap (April 23 2026)
63
+
64
+ ### Added
65
+
66
+ - **`iterate_toward_goal` MCP tool** (`mcp_server/tools/agent_os.py`,
67
+ `mcp_server/tools/_agent_os_engine/iteration.py`): closes the outer
68
+ evaluation loop. Given a compiled `GoalVector` and a list of candidate
69
+ move sets, runs up to N experiments sequentially. Each iteration
70
+ creates an experiment, runs all branches (with per-branch
71
+ apply-snapshot-undo already handled by the existing experiment engine),
72
+ scores the top branch against the goal, and either commits (score ≥
73
+ threshold) or discards and tries the next candidate set. On timeout,
74
+ commits the best-so-far (`on_timeout="commit_best"`, default) or
75
+ commits nothing (`on_timeout="discard_on_timeout"`). Per-branch undo
76
+ stays inside `run_experiment` — this loop never issues a raw undo.
77
+ Tool count: 426 → 427.
78
+
79
+ Engine ships as both a pure-sync `iterate_toward_goal_engine` (for
80
+ tests with in-memory fakes) and `iterate_toward_goal_engine_async`
81
+ (for the live MCP wrapper with coroutine callbacks); the sync entry
82
+ auto-detects coroutine callbacks and dispatches accordingly. Covered
83
+ by 11 tests in `tests/test_iterate_toward_goal.py` spanning happy
84
+ path, exhaustion + commit-best, exhaustion + discard, no candidates,
85
+ no-winner iterations, max_iterations capping, async coroutine
86
+ callbacks, and MCP registration.
87
+
88
+ This is the P0 item from the v1.17.1 review gap-analysis between
89
+ "tool orchestration" and "agentic optimization" — the create /
90
+ run / compare / commit primitives existed but nothing drove them
91
+ toward a scalar goal. `iterate_toward_goal` is that driver.
92
+
93
+ ### Fixed
94
+
95
+ - **Preview Studio truth-gap** (`mcp_server/preview_studio/engine.py`,
96
+ `mcp_server/preview_studio/tools.py`): two compounding bugs made the
97
+ system lie about committed state.
98
+ 1. `compare_variants()` scored every variant without filtering for
99
+ `status="blocked"` or missing `compiled_plan`. A blocked /
100
+ analytical-only variant could win the recommendation even with a
101
+ higher taste_fit than the only executable option. Fix: partition
102
+ variants into executable vs analytical, score only the executable
103
+ list, surface the analytical bucket on a new `analytical_candidates`
104
+ field for introspection. `recommended` stays a bare string (or
105
+ `None` when no executable variant exists) so no API shape breaks.
106
+ 2. `commit_preview_variant()` called `engine.commit_variant()` — which
107
+ flips `preview_set.status = "committed"` and discards every sibling
108
+ variant — BEFORE checking whether the chosen variant had a compiled
109
+ plan. Analytical-only picks therefore got recorded as committed
110
+ with `committed=False` in the response and the preview set's
111
+ in-memory state said the opposite. Wonder lifecycle also advanced
112
+ to `resolved`. Fix: short-circuit analytical/blocked picks at the
113
+ top of the handler, return `{committed: False, reason:
114
+ "analytical_only" | "blocked", ...}`, leave `preview_set.status`
115
+ untouched, and gate Wonder lifecycle hooks behind the executable
116
+ branch. New regressions in `tests/test_preview_studio_truth_gap.py`
117
+ lock all four scenarios (A1-A4 from the remediation plan).
118
+ - **Runtime capability probes stop lying about `web` and `flucoma`**
119
+ (`mcp_server/runtime/tools.py`, `mcp_server/runtime/capability_state.py`):
120
+ `get_capability_state` previously hardcoded `web_ok=False` and never
121
+ emitted a `flucoma` domain at all, causing `route_request` to pick
122
+ degraded research/perception paths on machines where those
123
+ capabilities were actually available. `_probe_web()` now runs a
124
+ 500 ms HEAD request to `https://api.github.com` using stdlib
125
+ `urllib.request` (no new dependency); `_probe_flucoma()` uses
126
+ `importlib.util.find_spec("flucoma")` with safe exception swallowing.
127
+ The `flucoma` domain is now emitted unconditionally so consumers can
128
+ distinguish "probed and missing" from "not probed yet".
129
+ - **`build_song_brain` flags degraded responses**
130
+ (`mcp_server/song_brain/tools.py`): When `get_session_info` fails,
131
+ the tool injected `{tempo: 120.0, track_count: 0}` and returned a
132
+ polished SongBrain with no indication the inputs were synthesized.
133
+ The fallback is preserved for backward compatibility but the
134
+ response now carries a top-level `degradation` payload
135
+ (`{is_degraded, reasons, substituted_fields}`) so callers can branch
136
+ on synthesized vs real data.
137
+ - **`create_preview_set` flags the empty-kernel fallback**
138
+ (`mcp_server/preview_studio/engine.py`,
139
+ `mcp_server/preview_studio/models.py`): When the caller omits a real
140
+ session kernel, `create_preview_set` synthesizes an empty-but-valid
141
+ shape so compilers degrade to no-op steps. `PreviewSet` now carries a
142
+ `degradation` field that is marked
143
+ `is_degraded=True, reasons=["empty_kernel_fallback"]` whenever that
144
+ substitution fires, so downstream consumers can tell a synthesized
145
+ compile from a kernel-backed one.
146
+
147
+ ### Added
148
+
149
+ - **`DegradationInfo` dataclass** (`mcp_server/runtime/degradation.py`):
150
+ New shared payload that engines attach to their responses whenever
151
+ they substitute fallback data. Three fields:
152
+ `is_degraded: bool`, `reasons: list[str]`, `substituted_fields: list[str]`.
153
+ Intentionally minimal and import-safe so any engine can adopt it
154
+ without circular-import risk. Wired into `song_brain` and
155
+ `preview_studio`; other engines will adopt it as audits surface more
156
+ silent-fallback paths.
157
+ - **`flucoma` capability domain** now emitted by
158
+ `build_capability_state` alongside `session_access`, `analyzer`,
159
+ `memory`, `web`, and `research`. Matches the existing
160
+ `CapabilityDomain` schema.
161
+
162
+ ### Changed
163
+
164
+ - **`capability-modes.md` reference doc rewritten to match the actual
165
+ response shape** (`livepilot/skills/livepilot-evaluation/references/capability-modes.md`).
166
+ The old example JSON described a flat
167
+ `{mode, analyzer_connected, bridge_version, spectral_cache_age_ms, flucoma_available, session_connected}`
168
+ shape that hasn't matched `get_capability_state` for releases. The
169
+ new section documents the nested `capability_state.domains.<name>`
170
+ structure, explicit per-domain and per-field definitions, and
171
+ explicitly scopes the `web` domain as *"server-side outbound HTTP
172
+ capability; does NOT imply curated research corpora are installed"*.
173
+
174
+ ### Tests
175
+
176
+ - `tests/test_preview_studio_truth_gap.py` — 5 tests locking the four
177
+ A1-A4 scenarios from the remediation plan.
178
+ - `tests/test_runtime_capability_probes.py` — 6 tests covering the
179
+ web probe (true/false/exception-swallow) and the flucoma probe
180
+ (emitted-when-importable, emitted-when-missing, find_spec-backed).
181
+ - `tests/test_degradation_signalling.py` — 8 tests covering the
182
+ `DegradationInfo` dataclass defaults, `song_brain` degradation on
183
+ session failure, and `preview_studio` degradation on empty-kernel
184
+ fallback.
185
+
3
186
  ## 1.17.1 — Splice auto-reconnect + Codex installer fix (April 23 2026)
4
187
 
5
188
  Two bug fixes discovered in a parallel worktree hours after v1.17.0
package/README.md CHANGED
@@ -17,7 +17,7 @@
17
17
 
18
18
  <p align="center">
19
19
  An agentic production system for Ableton Live 12.<br>
20
- 426 tools. 52 domains. Device atlas. Plan-aware Splice integration. Auto-composition. Spectral perception. Technique memory. Drum-rack pad builder. Live dead-device detection.
20
+ 427 tools. 52 domains. Device atlas. Plan-aware Splice integration. Auto-composition. Spectral perception. Technique memory. Drum-rack pad builder. Live dead-device detection.
21
21
  </p>
22
22
 
23
23
  <br>
@@ -80,7 +80,7 @@ Most MCP servers are tool collections — they execute commands. LivePilot is an
80
80
  │ └─────────────────┼──────────────────┘ │
81
81
  │ ▼ │
82
82
  │ ┌─────────────────┐ │
83
- │ │ 426 MCP Tools │ │
83
+ │ │ 427 MCP Tools │ │
84
84
  │ │ 52 domains │ │
85
85
  │ └────────┬────────┘ │
86
86
  │ │ │
@@ -121,7 +121,7 @@ Most MCP servers are tool collections — they execute commands. LivePilot is an
121
121
 
122
122
  ## The Intelligence Layer
123
123
 
124
- 12 engines sit on top of the 426 tools. They give the AI musical judgment, not just musical execution.
124
+ 12 engines sit on top of the 427 tools. They give the AI musical judgment, not just musical execution.
125
125
 
126
126
  ### SongBrain — What the Song Is
127
127
 
@@ -173,7 +173,7 @@ Every engine follows: **measure before → act → measure after → compare**.
173
173
 
174
174
  ## Tools
175
175
 
176
- 426 tools across 52 domains. Highlights below — [full catalog here](docs/manual/tool-catalog.md).
176
+ 427 tools across 52 domains. Highlights below — [full catalog here](docs/manual/tool-catalog.md).
177
177
 
178
178
  <br>
179
179
 
@@ -208,7 +208,8 @@ The M4L Analyzer sits on the master track. UDP 9880 carries spectral data to the
208
208
  > Most tools work without the analyzer — it adds 32 spectral/analyzer tools (frequency, loudness, perception) and closes the feedback loop.
209
209
 
210
210
  ```
211
- SPECTRAL ─────── 8-band frequency decomposition (sub → air)
211
+ SPECTRAL ─────── 9-band frequency decomposition (sub_low → air)
212
+ sub_low (20-60 Hz) split off so kick fundamentals don't hide inside sub
212
213
  true RMS / peak metering
213
214
  Krumhansl-Schmuckler key detection
214
215
 
@@ -361,7 +362,7 @@ The V2 intelligence layer. These tools analyze, diagnose, plan, evaluate, and le
361
362
  | Creative Constraints | 5 | constraint activation, reference-inspired variants |
362
363
  | Preview Studio | 5 | variant creation, preview rendering, comparison, commit |
363
364
 
364
- > **[View all 426 tools →](docs/manual/tool-catalog.md)**
365
+ > **[View all 427 tools →](docs/manual/tool-catalog.md)**
365
366
 
366
367
  <br>
367
368
 
@@ -588,7 +589,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for architecture details, code guidelines
588
589
 
589
590
  | Document | What's inside |
590
591
  |----------|---------------|
591
- | [Manual](docs/manual/index.md) | Complete reference: architecture, all 426 tools, workflows |
592
+ | [Manual](docs/manual/index.md) | Complete reference: architecture, all 427 tools, workflows |
592
593
  | [Intelligence Layer](docs/manual/intelligence.md) | How the 12 engines connect — conductor, moves, preview, evaluation |
593
594
  | [Device Atlas](docs/manual/device-atlas.md) | 1305 devices indexed — search, suggest, chain building |
594
595
  | [Samples & Slicing](docs/manual/samples.md) | 3-source search, fitness critics, slice workflows |
@@ -32,27 +32,31 @@ We tap the audio for analysis without affecting the pass-through.
32
32
  4. Add object: `[*~ 0.5]` (scale to prevent clipping)
33
33
  5. Connect: `[+~]` outlet → `[*~ 0.5]` inlet
34
34
 
35
- ## Step 4: 8-Band Spectrum Analysis
36
-
37
- 1. Add object: `[fffb~ 8]` (fast 8-band filter bank)
38
- 2. Connect: `[*~ 0.5]` outlet `[fffb~ 8]` inlet
39
- 3. Set `fffb~` frequencies in Inspector or via message:
40
- - Band 1: 40 Hz (sub)
41
- - Band 2: 130 Hz (low)
42
- - Band 3: 350 Hz (low-mid)
43
- - Band 4: 1000 Hz (mid)
44
- - Band 5: 3000 Hz (high-mid)
45
- - Band 6: 6000 Hz (high)
46
- - Band 7: 10000 Hz (presence)
47
- - Band 8: 16000 Hz (air)
48
-
49
- To set: add `[loadmess 40 130 350 1000 3000 6000 10000 16000]` → `[fffb~ 8]` right inlet
50
-
51
- 4. For each of the 8 outlets of `[fffb~ 8]`:
35
+ ## Step 4: 9-Band Spectrum Analysis
36
+
37
+ (v1.16+ layout. Pre-v1.16 devices used `[fffb~ 8]`; the server still accepts
38
+ 8-band payloads for backward compatibility, but new builds should use 9.)
39
+
40
+ 1. Add object: `[fffb~ 9]` (fast 9-band filter bank)
41
+ 2. Connect: `[*~ 0.5]` outlet → `[fffb~ 9]` inlet
42
+ 3. Set `fffb~` center frequencies in Inspector or via message:
43
+ - Band 1: 35 Hz (sub_low) — kick fundamentals, Villalobos subs
44
+ - Band 2: 85 Hz (sub) — 808s, sub-bass body
45
+ - Band 3: 175 Hz (low) — bass body, warmth
46
+ - Band 4: 350 Hz (low_mid) — mud zone
47
+ - Band 5: 700 Hz (mid) — vocal presence, snare body
48
+ - Band 6: 1400 Hz (high_mid) — consonants, pick attack
49
+ - Band 7: 2800 Hz (high) — presence, intelligibility
50
+ - Band 8: 5600 Hz (presence) — cymbal definition
51
+ - Band 9: 12000 Hz (air) — shimmer, sparkle
52
+
53
+ To set: add `[loadmess 35. 85. 175. 350. 700. 1400. 2800. 5600. 12000.]` → `[fffb~ 9]` right inlet
54
+
55
+ 4. For each of the 9 outlets of `[fffb~ 9]`:
52
56
  - Add `[abs~]` (rectify to positive)
53
57
  - Add `[snapshot~ 200]` (sample at 5 Hz)
54
58
 
55
- 5. Add `[pack f f f f f f f f]` and connect all 8 `[snapshot~]` outlets to it
59
+ 5. Add `[pack f f f f f f f f f]` and connect all 9 `[snapshot~]` outlets to it
56
60
  6. Add `[prepend /spectrum]` → connect from `[pack]`
57
61
  7. Add `[udpsend 127.0.0.1 9880]` → connect from `[prepend]`
58
62
 
@@ -131,7 +135,7 @@ We tap the audio for analysis without affecting the pass-through.
131
135
 
132
136
  1. Drop `LivePilot Analyzer` on the **master track**
133
137
  2. Play some audio
134
- 3. In Claude Code, run: `get_master_spectrum` — should return 8 band values
138
+ 3. In Claude Code, run: `get_master_spectrum` — should return 9 band values (v1.16+) or 8 values (pre-v1.16 .amxd)
135
139
  4. Run: `get_master_rms` — should return RMS and peak
136
140
  5. After 8+ bars: `get_detected_key` — should return key and scale
137
141
 
@@ -143,7 +147,7 @@ We tap the audio for analysis without affecting the pass-through.
143
147
  │ │
144
148
  plugin~ ──┤──L+R──► plugout~ (pass-through) │
145
149
  │ │
146
- │──L+R──► +~ ──► *~ 0.5 ──┬──► fffb~ 8 ──► UDP │
150
+ │──L+R──► +~ ──► *~ 0.5 ──┬──► fffb~ 9 ──► UDP │
147
151
  │ ├──► peakamp~ ──► UDP │
148
152
  │ ├──► average~ ──► UDP │
149
153
  │ └──► sigmund~ ──► JS │
Binary file
@@ -95,7 +95,7 @@ function anything() {
95
95
  function dispatch(cmd, args) {
96
96
  switch(cmd) {
97
97
  case "ping":
98
- send_response({"ok": true, "version": "1.17.1"});
98
+ send_response({"ok": true, "version": "1.17.3"});
99
99
  break;
100
100
  case "get_params":
101
101
  cmd_get_params(args);
@@ -1,2 +1,2 @@
1
1
  """LivePilot MCP Server — bridges MCP protocol to Ableton Live."""
2
- __version__ = "1.17.1"
2
+ __version__ = "1.17.3"
@@ -471,7 +471,8 @@ class SpectralReceiver(asyncio.DatagramProtocol):
471
471
  """Receives OSC-formatted UDP packets from the M4L device.
472
472
 
473
473
  OSC messages:
474
- /spectrum f f f f f f f f — 8-band spectrum
474
+ /spectrum f f f f f f f f [f] — 8 or 9 band spectrum
475
+ (9 = v1.16+ with sub_low; 8 = legacy)
475
476
  /peak f — peak level
476
477
  /rms f — RMS level
477
478
  /pitch f f — MIDI note, amplitude
@@ -11,6 +11,7 @@ import json
11
11
  import time
12
12
  from typing import Optional
13
13
 
14
+ from ..runtime.degradation import DegradationInfo
14
15
  from .models import PreviewSet, PreviewVariant
15
16
 
16
17
 
@@ -52,7 +53,10 @@ def create_preview_set(
52
53
  kernel: the live session kernel (track topology + device chains). Compilers
53
54
  resolve targets from it — without it, variants degrade into no-ops or
54
55
  generic reads. Callers that have a `ctx` should fetch a real kernel
55
- via runtime.tools.get_session_kernel(ctx).
56
+ via runtime.tools.get_session_kernel(ctx). When omitted the engine
57
+ synthesizes an empty-but-valid kernel (see ``_build_triptych``) and
58
+ flags the resulting PreviewSet with ``degradation.is_degraded=True``
59
+ so callers can tell a synthesized compile from a real one.
56
60
  """
57
61
  set_id = _compute_set_id(request_text, kernel_id)
58
62
  now = int(time.time() * 1000)
@@ -61,6 +65,18 @@ def create_preview_set(
61
65
  song_brain = song_brain or {}
62
66
  taste_graph = taste_graph or {}
63
67
 
68
+ # Degradation bookkeeping — if the caller didn't supply a kernel the
69
+ # compiler receives a synthesized one (see engine.py line 128 area)
70
+ # and every variant is scored against that synthetic topology.
71
+ if kernel:
72
+ degradation = DegradationInfo()
73
+ else:
74
+ degradation = DegradationInfo(
75
+ is_degraded=True,
76
+ reasons=["empty_kernel_fallback"],
77
+ substituted_fields=["compile_kernel"],
78
+ )
79
+
64
80
  if strategy == "creative_triptych":
65
81
  variants = _build_triptych(
66
82
  request_text, moves, song_brain, taste_graph, set_id, now, kernel,
@@ -79,6 +95,7 @@ def create_preview_set(
79
95
  source_kernel_id=kernel_id,
80
96
  variants=variants,
81
97
  created_at_ms=now,
98
+ degradation=degradation,
82
99
  )
83
100
  store_preview_set(ps)
84
101
  return ps
@@ -258,31 +275,66 @@ def _build_binary(
258
275
  # ── Comparison ────────────────────────────────────────────────────
259
276
 
260
277
 
278
+ _NON_EXECUTABLE_STATUSES = {"blocked", "failed"}
279
+
280
+
281
+ def _is_executable(variant: PreviewVariant) -> bool:
282
+ """A variant is executable when it has a compiled plan AND its status
283
+ hasn't been flagged as blocked/failed upstream.
284
+
285
+ The compiled plan may be a non-empty list of steps OR a dict with a
286
+ non-empty ``steps`` key — both shapes exist in the wild.
287
+ """
288
+ if variant.status in _NON_EXECUTABLE_STATUSES:
289
+ return False
290
+ plan = variant.compiled_plan
291
+ if plan is None:
292
+ return False
293
+ if isinstance(plan, list):
294
+ return len(plan) > 0
295
+ if isinstance(plan, dict):
296
+ return len(plan.get("steps") or []) > 0
297
+ # Any other truthy shape is treated as executable; falsy as not.
298
+ return bool(plan)
299
+
300
+
261
301
  def compare_variants(
262
302
  preview_set: PreviewSet,
263
303
  criteria: Optional[dict] = None,
264
304
  ) -> dict:
265
- """Compare variants within a preview set and rank them."""
305
+ """Compare variants within a preview set and rank them.
306
+
307
+ Truth-gap fix (PR-A): variants that are blocked/failed OR lack a
308
+ compiled_plan are partitioned out of the scored ranking. They appear
309
+ in ``analytical_candidates`` (just their variant_ids) and ALSO stay
310
+ in ``rankings`` at the bottom for introspection, but they can never
311
+ populate ``recommended``. When no executable variant exists,
312
+ ``recommended`` is ``None`` so callers can surface a clear message
313
+ instead of silently committing a no-op.
314
+ """
266
315
  criteria = criteria or {}
267
316
  weight_taste = criteria.get("taste_weight", 0.3)
268
317
  weight_novelty = criteria.get("novelty_weight", 0.2)
269
318
  weight_identity = criteria.get("identity_weight", 0.5)
270
319
 
271
- rankings = []
320
+ executable: list[PreviewVariant] = []
321
+ analytical: list[PreviewVariant] = []
272
322
  for v in preview_set.variants:
273
- # Score components
323
+ (executable if _is_executable(v) else analytical).append(v)
324
+
325
+ def _score(v: PreviewVariant) -> float:
274
326
  taste_score = v.taste_fit
275
327
  novelty_score = 1.0 - abs(v.novelty_level - 0.5) * 2 # bell curve around 0.5
276
328
  identity_score = _identity_effect_score(v.identity_effect)
277
-
278
329
  composite = (
279
330
  taste_score * weight_taste
280
331
  + novelty_score * weight_novelty
281
332
  + identity_score * weight_identity
282
333
  )
283
- v.score = round(composite, 3)
334
+ return round(composite, 3)
284
335
 
285
- rankings.append({
336
+ def _row(v: PreviewVariant) -> dict:
337
+ return {
286
338
  "variant_id": v.variant_id,
287
339
  "label": v.label,
288
340
  "score": v.score,
@@ -292,13 +344,35 @@ def compare_variants(
292
344
  "summary": v.intent,
293
345
  "what_preserved": v.what_preserved,
294
346
  "why_it_matters": v.why_it_matters,
295
- })
296
-
297
- rankings.sort(key=lambda r: r["score"], reverse=True)
347
+ "status": v.status,
348
+ }
349
+
350
+ executable_rows: list[dict] = []
351
+ for v in executable:
352
+ v.score = _score(v)
353
+ executable_rows.append(_row(v))
354
+ executable_rows.sort(key=lambda r: r["score"], reverse=True)
355
+
356
+ # Analytical variants still get a score computed so introspection
357
+ # shows the same shape, but they're appended AFTER the sorted
358
+ # executables so they can never land at position 0.
359
+ analytical_rows: list[dict] = []
360
+ for v in analytical:
361
+ v.score = _score(v)
362
+ analytical_rows.append(_row(v))
363
+
364
+ rankings = executable_rows + analytical_rows
365
+
366
+ recommended: Optional[str]
367
+ if executable_rows:
368
+ recommended = executable_rows[0]["variant_id"]
369
+ else:
370
+ recommended = None
298
371
 
299
372
  comparison = {
300
373
  "rankings": rankings,
301
- "recommended": rankings[0]["variant_id"] if rankings else "",
374
+ "recommended": recommended,
375
+ "analytical_candidates": [v.variant_id for v in analytical],
302
376
  "criteria_used": {
303
377
  "taste_weight": weight_taste,
304
378
  "novelty_weight": weight_novelty,
@@ -6,6 +6,8 @@ import time
6
6
  from dataclasses import asdict, dataclass, field
7
7
  from typing import Optional
8
8
 
9
+ from ..runtime.degradation import DegradationInfo
10
+
9
11
 
10
12
  @dataclass
11
13
  class PreviewVariant:
@@ -59,6 +61,11 @@ class PreviewSet:
59
61
  committed_variant_id: str = ""
60
62
  status: str = "pending" # pending, compared, committed, discarded
61
63
  created_at_ms: int = field(default_factory=lambda: int(time.time() * 1000))
64
+ # Degradation signalling — set when the engine substituted a fallback
65
+ # (e.g. an empty-but-valid kernel) during variant compilation. Callers
66
+ # can inspect .degradation.is_degraded to tell synthesized preview
67
+ # topology apart from a real kernel-backed compile.
68
+ degradation: DegradationInfo = field(default_factory=DegradationInfo)
62
69
 
63
70
  def to_dict(self) -> dict:
64
71
  return {
@@ -71,4 +78,5 @@ class PreviewSet:
71
78
  "committed_variant_id": self.committed_variant_id,
72
79
  "status": self.status,
73
80
  "variant_count": len(self.variants),
81
+ "degradation": self.degradation.to_dict(),
74
82
  }