livepilot 1.27.0 → 1.27.2

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 (66) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/README.md +26 -7
  3. package/bin/livepilot.js +86 -23
  4. package/installer/install.js +21 -12
  5. package/livepilot/.Codex-plugin/plugin.json +1 -1
  6. package/livepilot/.claude-plugin/plugin.json +1 -1
  7. package/livepilot/skills/livepilot-core/SKILL.md +8 -8
  8. package/livepilot/skills/livepilot-core/references/overview.md +2 -2
  9. package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
  10. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  11. package/m4l_device/livepilot_bridge.js +48 -11
  12. package/mcp_server/__init__.py +1 -1
  13. package/mcp_server/atlas/__init__.py +20 -1
  14. package/mcp_server/atlas/tools.py +83 -22
  15. package/mcp_server/composer/fast/brief_builder.py +6 -2
  16. package/mcp_server/composer/full/apply.py +9 -0
  17. package/mcp_server/composer/full/layer_planner.py +16 -6
  18. package/mcp_server/composer/tools.py +12 -6
  19. package/mcp_server/connection.py +2 -1
  20. package/mcp_server/creative_constraints/engine.py +46 -0
  21. package/mcp_server/experiment/engine.py +10 -3
  22. package/mcp_server/grader/client.py +30 -2
  23. package/mcp_server/grader/tools.py +8 -2
  24. package/mcp_server/hook_hunter/analyzer.py +15 -2
  25. package/mcp_server/m4l_bridge.py +145 -54
  26. package/mcp_server/memory/taste_graph.py +16 -0
  27. package/mcp_server/memory/technique_store.py +23 -3
  28. package/mcp_server/mix_engine/state_builder.py +4 -2
  29. package/mcp_server/musical_intelligence/detectors.py +11 -2
  30. package/mcp_server/musical_intelligence/tools.py +15 -2
  31. package/mcp_server/preview_studio/engine.py +30 -1
  32. package/mcp_server/project_brain/tools.py +56 -52
  33. package/mcp_server/reference_engine/tools.py +22 -2
  34. package/mcp_server/runtime/execution_router.py +6 -0
  35. package/mcp_server/runtime/live_version.py +27 -8
  36. package/mcp_server/runtime/mcp_dispatch.py +22 -0
  37. package/mcp_server/runtime/remote_commands.py +9 -4
  38. package/mcp_server/runtime/safety_kernel.py +11 -0
  39. package/mcp_server/sample_engine/critics.py +7 -3
  40. package/mcp_server/sample_engine/slice_workflow.py +3 -2
  41. package/mcp_server/sample_engine/tools.py +53 -21
  42. package/mcp_server/server.py +1 -1
  43. package/mcp_server/song_brain/builder.py +17 -1
  44. package/mcp_server/sound_design/tools.py +1 -1
  45. package/mcp_server/splice_client/http_bridge.py +43 -1
  46. package/mcp_server/splice_client/models.py +7 -3
  47. package/mcp_server/synthesis_brain/adapters/analog.py +13 -1
  48. package/mcp_server/synthesis_brain/adapters/operator.py +5 -2
  49. package/mcp_server/synthesis_brain/adapters/wavetable.py +4 -4
  50. package/mcp_server/tools/_composition_engine/models.py +6 -0
  51. package/mcp_server/tools/_composition_engine/sections.py +4 -0
  52. package/mcp_server/tools/clips.py +5 -4
  53. package/mcp_server/tools/composition.py +5 -1
  54. package/mcp_server/tools/midi_io.py +40 -1
  55. package/mcp_server/tools/transport.py +1 -1
  56. package/mcp_server/user_corpus/plugin_engine/detector.py +66 -11
  57. package/mcp_server/user_corpus/runner.py +7 -1
  58. package/mcp_server/user_corpus/scanners/amxd.py +24 -13
  59. package/mcp_server/user_corpus/tools.py +45 -9
  60. package/mcp_server/wonder_mode/tools.py +66 -27
  61. package/package.json +1 -1
  62. package/remote_script/LivePilot/__init__.py +21 -8
  63. package/remote_script/LivePilot/server.py +23 -3
  64. package/remote_script/LivePilot/tracks.py +11 -5
  65. package/requirements.txt +1 -1
  66. package/server.json +2 -2
@@ -30,6 +30,7 @@ import socket
30
30
  import struct
31
31
  import threading
32
32
  import time
33
+ import uuid
33
34
  from typing import Any, Callable, Optional
34
35
 
35
36
 
@@ -477,7 +478,10 @@ class SpectralReceiver(asyncio.DatagramProtocol):
477
478
  /rms f — RMS level
478
479
  /pitch f f — MIDI note, amplitude
479
480
  /response s — base64-encoded JSON (single packet)
480
- /response_chunk i i s — chunked response (index, total, data)
481
+ /response_chunk [s] i i s — chunked response. New builds prefix the
482
+ per-response request id so chunks of
483
+ different commands never share a bucket;
484
+ legacy builds omit it: (index, total, data)
481
485
  """
482
486
 
483
487
  # Band names keyed by how many bands the .amxd emits. 8 bands is the v1.x
@@ -497,7 +501,14 @@ class SpectralReceiver(asyncio.DatagramProtocol):
497
501
  self._chunks: dict[str, dict] = {} # Reassembly buffer for chunked responses
498
502
  self._chunk_times: dict[str, float] = {} # Monotonic timestamp per chunk sequence
499
503
  self._chunk_id = 0
504
+ self._chunk_key: Optional[str] = None # Key of the single active reassembly bucket
500
505
  self._response_callback: Optional[asyncio.Future] = None
506
+ self._response_request_id: Optional[str] = None
507
+ # Set True the first time a response arrives carrying a request id.
508
+ # Once the analyzer build is known to stamp ids, a response that
509
+ # arrives with NO id (or a mismatched one) while a future is live is a
510
+ # stale straggler and must be dropped — see _handle_response.
511
+ self._seen_request_id = False
501
512
  self._capture_future: Optional[asyncio.Future] = None
502
513
  self._miditool_handler: Optional[Callable[[str, dict, list], None]] = None
503
514
 
@@ -652,7 +663,16 @@ class SpectralReceiver(asyncio.DatagramProtocol):
652
663
  self._handle_response(str(args[0]))
653
664
 
654
665
  elif address == "/response_chunk" and len(args) >= 3:
655
- self._handle_chunk(int(args[0]), int(args[1]), str(args[2]))
666
+ # New builds prefix the request id (string): (rid, index, total, data).
667
+ # Legacy builds send (index, total, data). Distinguish by the type
668
+ # of the first arg — OSC ints decode to int, the id to a str.
669
+ if len(args) >= 4 and isinstance(args[0], str):
670
+ self._handle_chunk(
671
+ int(args[1]), int(args[2]), str(args[3]),
672
+ request_id=str(args[0]),
673
+ )
674
+ else:
675
+ self._handle_chunk(int(args[0]), int(args[1]), str(args[2]))
656
676
 
657
677
  elif address == "/miditool/request" and len(args) >= 1:
658
678
  self._handle_miditool_request(str(args[0]))
@@ -664,24 +684,52 @@ class SpectralReceiver(asyncio.DatagramProtocol):
664
684
  def _handle_response(self, encoded: str) -> None:
665
685
  """Decode a single-packet base64 response.
666
686
 
667
- Resolves _response_callback exactly once, then clears it. Without the
668
- clear, a second late packet could overwrite a future belonging to a
669
- different in-flight command. The protocol has no request id yet
670
- (livepilot_bridge.js:666 emits bare /response), so correlation relies
671
- on the single-command-in-flight invariant enforced by M4LBridge._cmd_lock
672
- plus this one-shot clear.
687
+ Updated analyzer JS echoes ``_livepilot_request_id`` on every response
688
+ (single-packet here, and on the chunk header for chunked responses).
689
+ Correlation rules, once this device is known to stamp ids:
690
+ * a response whose id does NOT match the in-flight future is dropped;
691
+ * a response with NO id at all is also dropped — it is a stale
692
+ straggler (e.g. a batched read that outlived its timeout), not a
693
+ pre-request-id build.
694
+ Pre-request-id builds (which never stamp an id) stay supported: until
695
+ the first id is ever seen, no-id responses are accepted.
673
696
  """
674
697
  try:
675
698
  # URL-safe base64 decode (- and _ instead of + and /)
676
699
  padded = encoded + "=" * (-len(encoded) % 4)
677
700
  decoded = base64.urlsafe_b64decode(padded).decode('utf-8')
678
701
  result = _normalize_bridge_payload(json.loads(decoded))
702
+ response_request_id = None
703
+ if isinstance(result, dict):
704
+ response_request_id = result.pop("_livepilot_request_id", None)
705
+
706
+ if response_request_id is not None:
707
+ # This analyzer build stamps request ids — remember it so a
708
+ # later no-id straggler is recognised as stale rather than
709
+ # mistaken for an old build that never sends ids.
710
+ self._seen_request_id = True
711
+
679
712
  cb = self._response_callback
713
+ expected_request_id = self._response_request_id
714
+ if cb and not cb.done() and expected_request_id is not None:
715
+ mismatched = (
716
+ response_request_id is not None
717
+ and str(response_request_id) != str(expected_request_id)
718
+ )
719
+ missing_but_expected = (
720
+ response_request_id is None and self._seen_request_id
721
+ )
722
+ if mismatched or missing_but_expected:
723
+ # Stale/uncorrelated reply — drop it and keep waiting for
724
+ # the response that actually matches this command.
725
+ return
726
+
680
727
  if cb and not cb.done():
681
728
  cb.set_result(result)
682
729
  # Clear regardless — either we consumed it, or it was already
683
730
  # done/abandoned. Future packets with no owner get dropped.
684
731
  self._response_callback = None
732
+ self._response_request_id = None
685
733
  except Exception as exc:
686
734
  import sys
687
735
  print(f"LivePilot: failed to decode bridge response: {exc}", file=sys.stderr)
@@ -717,52 +765,62 @@ class SpectralReceiver(asyncio.DatagramProtocol):
717
765
  import sys
718
766
  print(f"LivePilot: miditool handler error: {exc}", file=sys.stderr)
719
767
 
720
- def _handle_chunk(self, index: int, total: int, encoded: str) -> None:
768
+ def _handle_chunk(
769
+ self,
770
+ index: int,
771
+ total: int,
772
+ encoded: str,
773
+ request_id: Optional[str] = None,
774
+ ) -> None:
721
775
  """Reassemble chunked responses.
722
776
 
723
- The previous implementation incremented ``_chunk_id`` only when
724
- ``index == 0`` and assumed the first chunk always arrived first.
725
- Under UDP reordering (rare on loopback but possible under system
726
- load), a chunk with ``index > 0`` arriving before ``index 0`` would
727
- be dropped into the PREVIOUS sequence's bucket silently corrupting
728
- that earlier response's payload.
729
-
730
- Until the wire protocol adds an explicit sequence id, the safer
731
- behavior is: if we see an out-of-order first-chunk (``index > 0``
732
- with no open bucket), start a fresh bucket but log a warning. That
733
- way we never poison a prior sequence, and the problem surfaces in
734
- logs if it happens.
777
+ New analyzer builds stamp the per-response request id on every
778
+ ``/response_chunk`` header, so chunks from different commands land in
779
+ SEPARATE buckets and can never be interleaved even if a stale chunk
780
+ from a timed-out command arrives mid-flight. Legacy builds omit the id
781
+ (``request_id`` None/""); they fall back to a single rolling bucket,
782
+ which is safe because ``send_command`` serialises on ``_cmd_lock``.
783
+
784
+ Hardening (v1.27.2): an index outside ``[0, total)`` is dropped a
785
+ duplicate or malformed packet must never count toward completion and
786
+ reassembly only fires once EVERY index ``0..total-1`` is present. The
787
+ old ``len(parts) == total`` check could KeyError on a duplicate or
788
+ out-of-range index, silently losing the response and forcing a full
789
+ timeout.
735
790
  """
736
- if index == 0:
737
- self._chunk_id += 1
738
- key = str(self._chunk_id)
739
- self._chunks[key] = {"parts": {}, "total": total}
740
- self._chunk_times[key] = time.monotonic()
741
- else:
742
- key = str(self._chunk_id)
743
- if key not in self._chunks:
744
- # Out-of-order arrival. Start a new bucket rather than append
745
- # to the previous sequence's parts — that's the corruption
746
- # path. Log once so it's diagnosable.
747
- import sys
748
- print(
749
- f"LivePilot: chunk index={index}/{total} arrived before "
750
- f"index=0 — starting fresh bucket. UDP reordering on "
751
- f"loopback suggests system load.",
752
- file=sys.stderr,
753
- )
754
- self._chunk_id += 1
755
- key = str(self._chunk_id)
791
+ # Drop chunks that cannot belong to a well-formed response.
792
+ if total <= 0 or index < 0 or index >= total:
793
+ return
794
+
795
+ if request_id:
796
+ # Collision-free per-response bucket.
797
+ key = f"rid:{request_id}"
798
+ active = self._chunks.get(key)
799
+ if active is None or active["total"] != total:
756
800
  self._chunks[key] = {"parts": {}, "total": total}
757
801
  self._chunk_times[key] = time.monotonic()
802
+ self._chunk_key = key
803
+ else:
804
+ # Legacy single-active-bucket model (no id on the wire). A chunk
805
+ # with a different `total` means a new response started (e.g. the
806
+ # previous one timed out without ever completing) — evict + reopen.
807
+ active = self._chunks.get(self._chunk_key)
808
+ if active is None or active["total"] != total:
809
+ if active is not None:
810
+ self._chunks.pop(self._chunk_key, None)
811
+ self._chunk_times.pop(self._chunk_key, None)
812
+ self._chunk_id += 1
813
+ self._chunk_key = str(self._chunk_id)
814
+ self._chunks[self._chunk_key] = {"parts": {}, "total": total}
815
+ self._chunk_times[self._chunk_key] = time.monotonic()
816
+ key = self._chunk_key
758
817
 
759
- self._chunks[key]["parts"][index] = encoded
818
+ parts = self._chunks[key]["parts"]
819
+ parts[index] = encoded
760
820
 
761
- if len(self._chunks[key]["parts"]) == total:
762
- # All chunks received — reassemble
763
- full = ""
764
- for i in range(total):
765
- full += self._chunks[key]["parts"][i]
821
+ if len(parts) == total and all(i in parts for i in range(total)):
822
+ # Every chunk present — reassemble in order.
823
+ full = "".join(parts[i] for i in range(total))
766
824
  del self._chunks[key]
767
825
  self._chunk_times.pop(key, None)
768
826
  self._handle_response(full)
@@ -782,13 +840,22 @@ class SpectralReceiver(asyncio.DatagramProtocol):
782
840
  result = _normalize_bridge_payload(json.loads(decoded))
783
841
  if self._capture_future and not self._capture_future.done():
784
842
  self._capture_future.set_result(result)
843
+ # Clear the reference so a completed future isn't retained until the
844
+ # next capture, and so send_capture's done()-guarded cancel has no
845
+ # stale object to consider.
846
+ self._capture_future = None
785
847
  except Exception as exc:
786
848
  import sys
787
849
  print(f"LivePilot: failed to decode capture response: {exc}", file=sys.stderr)
788
850
 
789
- def set_response_future(self, future: asyncio.Future) -> None:
851
+ def set_response_future(
852
+ self,
853
+ future: Optional[asyncio.Future],
854
+ request_id: Optional[str] = None,
855
+ ) -> None:
790
856
  """Set a future to be resolved with the next response."""
791
857
  self._response_callback = future
858
+ self._response_request_id = request_id if future is not None else None
792
859
 
793
860
  def set_capture_future(self, future: asyncio.Future) -> None:
794
861
  """Set a future to be resolved when a capture_complete OSC arrives."""
@@ -812,6 +879,12 @@ class M4LBridge:
812
879
  self.receiver = receiver
813
880
  self.miditool_cache = miditool_cache
814
881
  self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
882
+ # Non-blocking so a momentarily-full send buffer never stalls the
883
+ # asyncio event loop. The miditool response path sends from inside a
884
+ # DatagramProtocol callback that runs ON the loop thread — a blocking
885
+ # sendto there would freeze every pending coroutine. UDP loopback sends
886
+ # essentially never block; _safe_sendto guards the rare case anyway.
887
+ self._sock.setblocking(False)
815
888
  self._m4l_addr = ("127.0.0.1", 9881)
816
889
  self._cmd_lock: Optional[asyncio.Lock] = None
817
890
  # BUG-audit-C1: send_capture uses _capture_future, which is
@@ -827,6 +900,22 @@ class M4LBridge:
827
900
  if self.receiver.miditool_cache is None and miditool_cache is not None:
828
901
  self.receiver.miditool_cache = miditool_cache
829
902
 
903
+ def _safe_sendto(self, data: bytes) -> None:
904
+ """Send a UDP packet without ever blocking the event loop.
905
+
906
+ The socket is non-blocking; if the OS send buffer is momentarily full
907
+ (vanishingly rare on loopback) sendto raises BlockingIOError. We drop
908
+ the packet rather than stall — the caller's timeout/retry handles it.
909
+ """
910
+ try:
911
+ self._sock.sendto(data, self._m4l_addr)
912
+ except BlockingIOError:
913
+ import sys
914
+ print(
915
+ "LivePilot: M4L UDP send buffer full — packet dropped",
916
+ file=sys.stderr,
917
+ )
918
+
830
919
  def _dispatch_miditool_request(
831
920
  self, request_id: str, context: dict, notes: list,
832
921
  ) -> None:
@@ -871,7 +960,7 @@ class M4LBridge:
871
960
  """
872
961
  payload = {"tool_name": tool_name or "", "params": params or {}}
873
962
  osc = self._build_osc("miditool/config", (json.dumps(payload),))
874
- self._sock.sendto(osc, self._m4l_addr)
963
+ self._safe_sendto(osc)
875
964
 
876
965
  def send_miditool_response(self, request_id: str, notes: list) -> None:
877
966
  """Send transformed notes back to the JS bridge.
@@ -881,7 +970,7 @@ class M4LBridge:
881
970
  """
882
971
  payload = {"request_id": str(request_id or ""), "notes": list(notes or [])}
883
972
  osc = self._build_osc("miditool/response", (json.dumps(payload),))
884
- self._sock.sendto(osc, self._m4l_addr)
973
+ self._safe_sendto(osc)
885
974
 
886
975
  async def send_command(self, command: str, *args: Any, timeout: float = 5.0) -> dict:
887
976
  """Send an OSC command to the M4L device and wait for the response."""
@@ -907,12 +996,14 @@ class M4LBridge:
907
996
  # Create a future for the response
908
997
  loop = asyncio.get_running_loop()
909
998
  future = loop.create_future()
910
- self.receiver.set_response_future(future)
999
+ request_id = uuid.uuid4().hex
1000
+ self.receiver.set_response_future(future, request_id=request_id)
911
1001
 
912
1002
  # Build and send OSC message (no leading / — Max udpreceive
913
1003
  # passes messagename with / intact to JS, breaking dispatch)
914
- osc_data = self._build_osc(command, args)
915
- self._sock.sendto(osc_data, self._m4l_addr)
1004
+ request_arg = f"__livepilot_request_id:{request_id}"
1005
+ osc_data = self._build_osc(command, (*args, request_arg))
1006
+ self._safe_sendto(osc_data)
916
1007
 
917
1008
  # Wait for response with timeout
918
1009
  try:
@@ -960,7 +1051,7 @@ class M4LBridge:
960
1051
  self.receiver.set_capture_future(future)
961
1052
 
962
1053
  osc_data = self._build_osc(command, args)
963
- self._sock.sendto(osc_data, self._m4l_addr)
1054
+ self._safe_sendto(osc_data)
964
1055
 
965
1056
  try:
966
1057
  result = await asyncio.wait_for(future, timeout=timeout)
@@ -182,6 +182,14 @@ class TasteGraph:
182
182
  self.evidence_count += 1
183
183
  self.last_updated_ms = now
184
184
 
185
+ # Write-back to persistent store
186
+ if self._persistent_store is not None:
187
+ try:
188
+ self._persistent_store.record_device_use(device_name, positive)
189
+ except Exception as exc:
190
+ logger.debug("record_device_use failed: %s", exc)
191
+ pass # persistence is best-effort
192
+
185
193
  def update_novelty_from_experiment(
186
194
  self, chose_bold: bool, goal_mode: str = "improve",
187
195
  ) -> None:
@@ -200,6 +208,14 @@ class TasteGraph:
200
208
  new_val = max(0.0, current - 0.05)
201
209
  self.novelty_bands[goal_mode] = new_val
202
210
 
211
+ # Write-back to persistent store
212
+ if self._persistent_store is not None:
213
+ try:
214
+ self._persistent_store.update_novelty(chose_bold, goal_mode)
215
+ except Exception as exc:
216
+ logger.debug("update_novelty failed: %s", exc)
217
+ pass # persistence is best-effort
218
+
203
219
  # ── Ranking ──────────────────────────────────────────────────────
204
220
 
205
221
  def rank_moves(
@@ -31,6 +31,19 @@ class TechniqueStore:
31
31
  self._lock = threading.Lock()
32
32
  self._initialized = False
33
33
  self._data: dict = {"version": 1, "techniques": []}
34
+ # Signature (mtime_ns, size) of the file as we last loaded it. Lets
35
+ # multiple TechniqueStore instances pointing at the same file pick up
36
+ # each other's writes (reload-on-read) instead of caching stale data
37
+ # for the life of the process.
38
+ self._loaded_sig: Optional[tuple] = None
39
+
40
+ def _file_signature(self) -> Optional[tuple]:
41
+ """Return (mtime_ns, size) of the backing file, or None if absent."""
42
+ try:
43
+ st = self._file.stat()
44
+ except OSError:
45
+ return None
46
+ return (st.st_mtime_ns, st.st_size)
34
47
 
35
48
  def _ensure_initialized(self) -> None:
36
49
  """Lazily create directory and load data on first access.
@@ -40,12 +53,14 @@ class TechniqueStore:
40
53
  Thread-safe: uses double-checked locking to prevent concurrent
41
54
  callers from racing on initialization.
42
55
  """
43
- if self._initialized:
56
+ # Fast path: already initialized AND the file on disk has not changed
57
+ # since we last loaded it (no other instance has written).
58
+ if self._initialized and self._file_signature() == self._loaded_sig:
44
59
  return
45
60
  with self._lock:
46
61
  # Double-check after acquiring lock — another thread may have
47
- # initialized while we were waiting.
48
- if self._initialized:
62
+ # (re)loaded while we were waiting.
63
+ if self._initialized and self._file_signature() == self._loaded_sig:
49
64
  return
50
65
  try:
51
66
  self._base_dir.mkdir(parents=True, exist_ok=True)
@@ -58,10 +73,12 @@ class TechniqueStore:
58
73
  try:
59
74
  with open(self._file, "r") as f:
60
75
  self._data = json.load(f)
76
+ self._loaded_sig = self._file_signature()
61
77
  except (json.JSONDecodeError, ValueError):
62
78
  corrupt = self._file.with_suffix(".json.corrupt")
63
79
  self._file.rename(corrupt)
64
80
  self._data = {"version": 1, "techniques": []}
81
+ self._loaded_sig = None
65
82
  else:
66
83
  self._data = {"version": 1, "techniques": []}
67
84
  self._flush()
@@ -77,6 +94,9 @@ class TechniqueStore:
77
94
  f.flush()
78
95
  os.fsync(f.fileno())
79
96
  os.replace(str(tmp), str(self._file))
97
+ # Record the signature of our own write so this instance does not
98
+ # needlessly reload the data it already holds in memory.
99
+ self._loaded_sig = self._file_signature()
80
100
 
81
101
  # ── public API ───────────────────────────────────────────────
82
102
 
@@ -241,8 +241,10 @@ def build_dynamics_state(
241
241
 
242
242
  crest = 20 * math.log10(max(peak_linear, 1e-10) / max(rms_linear, 1e-10))
243
243
 
244
- # Over-compressed when crest factor < 6 dB (flat dynamics)
245
- over_compressed = crest < 6.0
244
+ # Over-compressed band is 3-6 dB crest. Below 3 dB the signal is so flat
245
+ # that the dynamics critic should report the stronger `flat_dynamics`
246
+ # issue instead, which only fires when over_compressed is False.
247
+ over_compressed = 3.0 <= crest < 6.0
246
248
 
247
249
  # Headroom = distance from peak to 0 dBFS
248
250
  if peak_linear > 0:
@@ -134,9 +134,15 @@ def detect_repetition_fatigue(
134
134
  staleness = min(1.0, (reuse_count / total - 1) * 0.3) if total else 0
135
135
  report.section_staleness[name] = round(max(0, staleness), 3)
136
136
 
137
- # Overall fatigue level
137
+ # Overall fatigue level — saturating combine (probabilistic OR) so that
138
+ # adding more issues never *reduces* fatigue. A plain mean would let a
139
+ # cluster of low-severity issues dilute a single serious one.
138
140
  if report.issues:
139
- report.fatigue_level = min(1.0, sum(i["severity"] for i in report.issues) / max(1, len(report.issues)))
141
+ remaining_freshness = 1.0
142
+ for issue in report.issues:
143
+ severity = max(0.0, min(1.0, issue["severity"]))
144
+ remaining_freshness *= (1.0 - severity)
145
+ report.fatigue_level = min(1.0, 1.0 - remaining_freshness)
140
146
 
141
147
  # Recommendations
142
148
  if report.fatigue_level > 0.5:
@@ -337,6 +343,9 @@ def infer_section_purposes(
337
343
  if position < 0.15 and density < 0.5:
338
344
  purpose = "setup"
339
345
  confidence = 0.7
346
+ elif density >= 0.8 and density_delta >= 0:
347
+ purpose = "payoff"
348
+ confidence = 0.65
340
349
  elif density_delta > 0.2:
341
350
  purpose = "tension"
342
351
  confidence = 0.6
@@ -215,8 +215,21 @@ def compare_phrase_renders(
215
215
 
216
216
  critiques = []
217
217
  for path in file_paths:
218
- # Try to get cached analysis or run fresh
219
- critique = phrase_critic.analyze_phrase(target=target)
218
+ # Run offline analysis per file so each render gets a real critique
219
+ loudness_data = None
220
+ spectrum_data = None
221
+ try:
222
+ from ..tools._perception_engine import compute_loudness
223
+ loudness_data = compute_loudness(path, detail="full")
224
+ except Exception as exc:
225
+ logger.debug("compare_phrase_renders loudness failed for %s: %s", path, exc)
226
+ try:
227
+ from ..tools._perception_engine import compute_spectral
228
+ spectrum_data = compute_spectral(path)
229
+ except Exception as exc:
230
+ logger.debug("compare_phrase_renders spectral failed for %s: %s", path, exc)
231
+
232
+ critique = phrase_critic.analyze_phrase(loudness_data, spectrum_data, target)
220
233
  critique.render_id = path.split("/")[-1] if isinstance(path, str) and "/" in path else str(path)
221
234
  critiques.append(critique)
222
235
 
@@ -33,6 +33,35 @@ def store_preview_set(ps: PreviewSet) -> None:
33
33
  del _preview_sets[oldest_key]
34
34
 
35
35
 
36
+ # Statuses that represent work a user (or caller) has already invested in:
37
+ # a compared ranking or a committed pick. A fresh request that hashes to the
38
+ # same set_id must NOT clobber these — it branches to a distinct id instead.
39
+ _PROTECTED_STATUSES = {"committed", "compared"}
40
+
41
+
42
+ def _resolve_set_id(base_id: str) -> str:
43
+ """Return a set_id that won't clobber a protected (committed/compared) set.
44
+
45
+ The base_id is a deterministic hash of request_text + kernel_id, so a
46
+ re-request reuses it by design. That reuse is fine while the existing set
47
+ is still 'pending'/'discarded' (nothing of value to lose). But if the
48
+ existing set under base_id has been compared or committed, overwriting it
49
+ would silently drop its rankings / committed pick. In that case we branch
50
+ to a distinct, still-deterministic id (base_id + a monotonic suffix) so the
51
+ protected set survives and the new set gets its own slot.
52
+ """
53
+ existing = _preview_sets.get(base_id)
54
+ if existing is None or existing.status not in _PROTECTED_STATUSES:
55
+ return base_id
56
+ suffix = 2
57
+ while True:
58
+ candidate = f"{base_id}_b{suffix}"
59
+ occupant = _preview_sets.get(candidate)
60
+ if occupant is None or occupant.status not in _PROTECTED_STATUSES:
61
+ return candidate
62
+ suffix += 1
63
+
64
+
36
65
  # ── Creation ──────────────────────────────────────────────────────
37
66
 
38
67
 
@@ -58,7 +87,7 @@ def create_preview_set(
58
87
  flags the resulting PreviewSet with ``degradation.is_degraded=True``
59
88
  so callers can tell a synthesized compile from a real one.
60
89
  """
61
- set_id = _compute_set_id(request_text, kernel_id)
90
+ set_id = _resolve_set_id(_compute_set_id(request_text, kernel_id))
62
91
  now = int(time.time() * 1000)
63
92
 
64
93
  moves = available_moves or []