squaredcuber commited on
Commit
bcbb8c5
·
verified ·
1 Parent(s): fc9417f

Deploy Decompress MiniCPM demo

Browse files
README.md CHANGED
@@ -1,13 +1,76 @@
1
  ---
2
  title: Decompress
3
- emoji: 📈
4
- colorFrom: purple
5
- colorTo: red
6
  sdk: gradio
7
  sdk_version: 6.18.0
8
- python_version: '3.13'
9
  app_file: app.py
10
- pinned: false
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: Decompress
3
+ emoji: 🌿
4
+ colorFrom: green
5
+ colorTo: blue
6
  sdk: gradio
7
  sdk_version: 6.18.0
8
+ python_version: '3.12'
9
  app_file: app.py
10
+ startup_duration_timeout: 45min
11
+ pinned: true
12
+ license: mit
13
+ short_description: A MiniCPM voice companion that knows when not to talk.
14
+ tags:
15
+ - gradio
16
+ - build-small-hackathon
17
+ - stress-management
18
+ - wellness
19
+ - full-duplex
20
+ - training-free
21
+ - rag
22
+ - track:backyard
23
+ - sponsor:openbmb
24
+ - sponsor:cohere
25
+ - sponsor:modal
26
+ - sponsor:openai
27
+ - achievement:offbrand
28
+ - tiny-titan
29
+ - minicpm
30
+ - cohere
31
+ models:
32
+ - openbmb/MiniCPM3-4B
33
+ - CohereLabs/cohere-transcribe-03-2026
34
+ - hexgrad/Kokoro-82M
35
+ - sentence-transformers/all-MiniLM-L6-v2
36
  ---
37
 
38
+ # Decompress
39
+
40
+ Decompress is a calm, bounded voice/text check-in companion. The point is not
41
+ just what it says; it is when it chooses not to say anything. The same
42
+ training-free when-to-speak controller from Pitch or Perish drives the timing:
43
+ MiniCPM exposes surprise, hidden-state deltas, readiness, and turn-end
44
+ probability, then the controller waits, backchannels, or responds at the pause.
45
+
46
+ ## Backyard Use
47
+
48
+ This is the practical track version of the method: a small companion for a
49
+ five-minute decompression ritual after work, before sleep, or between stressful
50
+ tasks. It gives one grounded next step and cites the source used by the RAG
51
+ retriever. It is wellness support only, not medical advice, diagnosis, therapy,
52
+ or crisis care.
53
+
54
+ ## Tiny Titan / Sponsor Stack
55
+
56
+ - **OpenBMB:** `openbmb/MiniCPM3-4B` is the load-bearing brain for NLL,
57
+ hidden states, readiness, and generated companion lines.
58
+ - **Cohere:** `CohereLabs/cohere-transcribe-03-2026` powers push-to-talk voice
59
+ input before the exact same text-streamed controller runs.
60
+ - **Modal:** MiniCPM and Cohere ASR run as protected A10G endpoints with one
61
+ warm container during judging.
62
+ - **OpenAI:** the repo history includes Codex-attributed implementation commits.
63
+ - **Small weights:** the app uses <=4B LLM/ASR models, Kokoro-82M for CPU TTS,
64
+ and all-MiniLM-L6-v2 for local RAG retrieval.
65
+
66
+ ## Sources
67
+
68
+ The shipped corpus is intentionally small and conservative. It paraphrases
69
+ open-access stress-management, CBT, breathwork, and grounding sources from WHO,
70
+ NHS, NIH NCCIH, University of Rochester Medical Center, and Scientific Reports,
71
+ with citations displayed in the UI.
72
+
73
+ ## Links
74
+
75
+ - Demo video: PLACEHOLDER - add recording link after capture.
76
+ - Social post: PLACEHOLDER - add post link after publishing.
app.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+
5
+ from apps.decompress.app import CSS, JS, build_app
6
+
7
+
8
+ demo = build_app()
9
+ demo.queue(default_concurrency_limit=4)
10
+
11
+
12
+ if __name__ == "__main__":
13
+ demo.launch(
14
+ server_name="0.0.0.0",
15
+ server_port=int(os.getenv("PORT", "7860")),
16
+ show_error=True,
17
+ css=CSS,
18
+ js=JS,
19
+ )
apps/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+
apps/decompress/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Decompress Gradio app package."""
apps/decompress/app.py ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import os
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Iterator
8
+
9
+ ROOT = Path(__file__).resolve().parents[2]
10
+ if str(ROOT) not in sys.path:
11
+ sys.path.insert(0, str(ROOT))
12
+
13
+ import gradio as gr
14
+
15
+ from apps.decompress import runtime
16
+
17
+
18
+ APP_DIR = Path(__file__).resolve().parent
19
+ CSS = (APP_DIR / "static" / "decompress.css").read_text(encoding="utf-8")
20
+ JS = (APP_DIR / "static" / "decompress.js").read_text(encoding="utf-8")
21
+
22
+
23
+ def stream_checkin(checkin_text: str, tau: float, voice_enabled: bool) -> Iterator[tuple[str, str | None, dict]]:
24
+ yield from runtime.run_checkin_stream(checkin_text, tau, enable_tts=voice_enabled)
25
+
26
+
27
+ def stream_voice_checkin(audio_clip: str | None, tau: float, voice_enabled: bool) -> Iterator[tuple[str, str | None, dict, str]]:
28
+ yield from runtime.run_voice_checkin_stream(audio_clip, tau, enable_tts=voice_enabled)
29
+
30
+
31
+ def build_app() -> gr.Blocks:
32
+ initial_tau = float(os.getenv("DECOMPRESS_TAU", "0.92"))
33
+ initial_html = (
34
+ runtime.preview_room_html(initial_tau)
35
+ if os.getenv("DECOMPRESS_PREVIEW_STATE", "0") == "1"
36
+ else runtime.initial_room_html(initial_tau)
37
+ )
38
+ with gr.Blocks(title="Decompress", fill_width=True) as demo:
39
+ room = gr.HTML(value=initial_html, elem_id="decompress-room-output")
40
+ with gr.Row(elem_id="decompress-control-row"):
41
+ checkin = gr.Textbox(
42
+ value=runtime.SAMPLE_CHECKIN,
43
+ label="What is on your mind?",
44
+ lines=6,
45
+ max_lines=10,
46
+ elem_id="decompress-input",
47
+ )
48
+ with gr.Column(elem_id="decompress-controls", scale=0):
49
+ tau = gr.Slider(
50
+ minimum=0.65,
51
+ maximum=1.45,
52
+ value=initial_tau,
53
+ step=0.05,
54
+ label="Companion timing",
55
+ elem_id="decompress-tau",
56
+ )
57
+ voice = gr.Checkbox(
58
+ value=os.getenv("DECOMPRESS_TTS", "1").lower() not in {"0", "false"},
59
+ label="Kokoro voice",
60
+ elem_id="decompress-tts-toggle",
61
+ )
62
+ run = gr.Button("Start check-in", elem_id="run-decompress", variant="primary")
63
+ mic = gr.Audio(
64
+ sources=["microphone", "upload"],
65
+ type="filepath",
66
+ format="wav",
67
+ label="Speak your check-in",
68
+ elem_id="decompress-mic",
69
+ )
70
+ voice_run = gr.Button("Use voice check-in", elem_id="run-decompress-voice", variant="secondary")
71
+ asr_text = gr.Textbox(
72
+ value="",
73
+ label="Cohere transcript",
74
+ lines=3,
75
+ max_lines=5,
76
+ interactive=False,
77
+ elem_id="decompress-transcript",
78
+ )
79
+ audio = gr.Audio(
80
+ label="Latest companion voice",
81
+ type="filepath",
82
+ autoplay=True,
83
+ interactive=False,
84
+ elem_id="decompress-audio",
85
+ )
86
+ status = gr.JSON(label="Run state", visible=False)
87
+
88
+ tau.change(fn=runtime.set_live_tau, inputs=tau, outputs=None, queue=False)
89
+ run.click(
90
+ fn=stream_checkin,
91
+ inputs=[checkin, tau, voice],
92
+ outputs=[room, audio, status],
93
+ api_name="stream_checkin",
94
+ )
95
+ voice_run.click(
96
+ fn=stream_voice_checkin,
97
+ inputs=[mic, tau, voice],
98
+ outputs=[room, audio, status, asr_text],
99
+ api_name="stream_voice_checkin",
100
+ )
101
+ return demo
102
+
103
+
104
+ def main(argv: list[str] | None = None) -> None:
105
+ parser = argparse.ArgumentParser(description="Launch the Decompress Gradio demo.")
106
+ parser.add_argument("--server-name", default=os.getenv("GRADIO_SERVER_NAME", "127.0.0.1"))
107
+ parser.add_argument("--server-port", type=int, default=int(os.getenv("GRADIO_SERVER_PORT", "7861")))
108
+ parser.add_argument("--share", action="store_true", default=os.getenv("GRADIO_SHARE", "0") == "1")
109
+ args = parser.parse_args(argv)
110
+ demo = build_app()
111
+ demo.queue(default_concurrency_limit=4)
112
+ demo.launch(
113
+ server_name=args.server_name,
114
+ server_port=args.server_port,
115
+ share=args.share,
116
+ show_error=True,
117
+ css=CSS,
118
+ js=JS,
119
+ )
120
+
121
+
122
+ if __name__ == "__main__":
123
+ main()
apps/decompress/corpus/stress_sources.json ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "id": "who-doing-what-matters",
4
+ "title": "Doing What Matters in Times of Stress",
5
+ "publisher": "World Health Organization",
6
+ "url": "https://www.who.int/publications/i/item/9789240003927",
7
+ "text": "A practical stress check-in can start by grounding attention in the present, noticing what is happening in the body and room, and then choosing one small action that fits the person's values. The guide frames these as short self-help skills for coping with adversity, not as medical treatment."
8
+ },
9
+ {
10
+ "id": "nhs-self-help-cbt",
11
+ "title": "Self-help CBT techniques",
12
+ "publisher": "NHS Every Mind Matters",
13
+ "url": "https://www.nhs.uk/every-mind-matters/mental-wellbeing-tips/self-help-cbt-techniques/",
14
+ "text": "CBT-inspired self-help treats thoughts, feelings, and behavior as linked. For everyday stress, a useful move is to notice an unhelpful thought pattern, step back from it, and break a problem into one practical next action."
15
+ },
16
+ {
17
+ "id": "nhs-thought-record",
18
+ "title": "Thought record",
19
+ "publisher": "NHS Every Mind Matters",
20
+ "url": "https://www.nhs.uk/every-mind-matters/mental-wellbeing-tips/self-help-cbt-techniques/thought-record/",
21
+ "text": "A thought record is a CBT exercise for writing down a situation, feelings, the unhelpful thought, evidence for and against it, and a more balanced alternative. In a brief companion flow, this can be simplified into: name the thought, name one fact, and choose a kinder neutral sentence."
22
+ },
23
+ {
24
+ "id": "nccih-relaxation",
25
+ "title": "Mind and Body Approaches for Stress and Anxiety: What the Science Says",
26
+ "publisher": "NIH NCCIH",
27
+ "url": "https://www.nccih.nih.gov/health/providers/digest/mind-and-body-approaches-for-stress-science",
28
+ "text": "Relaxation techniques may be helpful for stress-related symptoms and are generally considered safe for healthy people, while some people can have negative experiences. Keep suggestions gentle, optional, and brief; do not present them as treatment."
29
+ },
30
+ {
31
+ "id": "urmc-54321",
32
+ "title": "5-4-3-2-1 Coping Technique for Anxiety",
33
+ "publisher": "University of Rochester Medical Center",
34
+ "url": "https://www.urmc.rochester.edu/behavioral-health-partners/bhp-blog/april-2018/5-4-3-2-1-coping-technique-for-anxiety",
35
+ "text": "The 5-4-3-2-1 grounding exercise redirects attention to the present by naming things the person can see, feel, hear, smell, and taste. It is useful when thoughts are bouncing around and the person needs a concrete anchor."
36
+ },
37
+ {
38
+ "id": "breathwork-meta",
39
+ "title": "Effect of breathwork on stress and mental health",
40
+ "publisher": "Scientific Reports",
41
+ "url": "https://www.nature.com/articles/s41598-022-27247-y",
42
+ "text": "A meta-analysis found breathwork may help stress and mental health outcomes, with cautions about heterogeneity. In this app, breathing prompts should stay simple and optional, such as a longer exhale or one slow breath."
43
+ }
44
+ ]
apps/decompress/runtime.py ADDED
@@ -0,0 +1,1021 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import html
4
+ import json
5
+ import os
6
+ import re
7
+ import time
8
+ from dataclasses import asdict, dataclass, field, replace
9
+ from pathlib import Path
10
+ from typing import Any, Iterator
11
+
12
+ import numpy as np
13
+
14
+ from engine.brain import BrainSignals
15
+ from engine.controller import Action, ControllerConfig, ControllerTick, WhenToSpeakController
16
+ from engine.conversation import TranscriptChunk
17
+ from engine.live_brain import BrainClient, Dialogue, signals_from_raw
18
+
19
+
20
+ ROOT = Path(__file__).resolve().parents[2]
21
+ APP_DIR = Path(__file__).resolve().parent
22
+ EVAL_DIR = ROOT / "eval"
23
+ DECOMPRESS_LOG_PATH = EVAL_DIR / "decompress_conversation_log.json"
24
+ AUDIO_DIR = EVAL_DIR / "decompress_audio"
25
+ CORPUS_PATH = APP_DIR / "corpus" / "stress_sources.json"
26
+
27
+ AGENT_ID = "decompress"
28
+ DISPLAY_NAME = "Decompress"
29
+ DEFAULT_EMBEDDER = "sentence-transformers/all-MiniLM-L6-v2"
30
+ SAMPLE_CHECKIN = (
31
+ "I had a tense day at work and I keep replaying a meeting in my head. "
32
+ "My shoulders are tight, I feel behind on everything, and I just want five minutes "
33
+ "to come down before I try to sleep."
34
+ )
35
+
36
+ CALM_BACKCHANNELS = [
37
+ "Mm. Keep going.",
38
+ "I'm here with you.",
39
+ "That sounds heavy.",
40
+ "Take your time.",
41
+ ]
42
+
43
+ FALLBACK_LINES = [
44
+ "Let's slow it down: one easy breath, then name one thing you can feel.",
45
+ "Make it smaller: unclench your jaw and choose one next tiny step.",
46
+ "Park one sentence on paper, then let the next exhale be longer.",
47
+ ]
48
+
49
+ _LIVE_TAU = 0.92
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class RetrievedSource:
54
+ id: str
55
+ title: str
56
+ publisher: str
57
+ url: str
58
+ text: str
59
+ score: float
60
+
61
+
62
+ @dataclass
63
+ class DecompressStats:
64
+ backchannels: int = 0
65
+ take_floors: int = 0
66
+ interrupts: int = 0
67
+ holds: int = 0
68
+
69
+
70
+ @dataclass
71
+ class DecompressState:
72
+ tau: float
73
+ brain_mode: str
74
+ status: str = "ready"
75
+ step: int = 0
76
+ transcript: list[dict[str, Any]] = field(default_factory=list)
77
+ decision: dict[str, Any] = field(default_factory=dict)
78
+ citations: list[dict[str, Any]] = field(default_factory=list)
79
+ stats: DecompressStats = field(default_factory=DecompressStats)
80
+ tts_error: str | None = None
81
+ asr_text: str | None = None
82
+ asr_latency_ms: float | None = None
83
+ asr_model_name: str | None = None
84
+ embedding_model: str = "not-loaded"
85
+ response_latency_ms: float | None = None
86
+ model_name: str | None = None
87
+ device_name: str | None = None
88
+
89
+
90
+ class EndpointDecompressBrainClient:
91
+ """BrainClient backed by the protected MiniCPM Modal HTTPS endpoint."""
92
+
93
+ def __init__(self, endpoint_url: str | None = None, *, timeout_s: float = 240.0) -> None:
94
+ self.endpoint_url = (endpoint_url or os.environ["DECOMPRESS_BRAIN_ENDPOINT_URL"]).rstrip("/")
95
+ self.bearer_token = os.getenv("DECOMPRESS_BRAIN_ENDPOINT_TOKEN") or os.getenv("ENDPOINT_AUTH_TOKEN")
96
+ self.transcribe_endpoint_url = (
97
+ os.getenv("DECOMPRESS_TRANSCRIBE_ENDPOINT_URL") or self.endpoint_url
98
+ ).rstrip("/")
99
+ self.transcribe_bearer_token = os.getenv("DECOMPRESS_TRANSCRIBE_ENDPOINT_TOKEN") or self.bearer_token
100
+ if not self.bearer_token:
101
+ raise RuntimeError("DECOMPRESS_BRAIN_ENDPOINT_TOKEN is required for endpoint mode")
102
+ self.timeout_s = timeout_s
103
+
104
+ def step(
105
+ self,
106
+ agent_id: str,
107
+ system_prompt: str,
108
+ dialogue_so_far: Dialogue,
109
+ new_user_text: str,
110
+ silence_flag: bool,
111
+ ) -> dict[str, object]:
112
+ raw = self.step_many(
113
+ [{"agent_id": agent_id, "system_prompt": system_prompt}],
114
+ dialogue_so_far,
115
+ new_user_text,
116
+ silence_flag,
117
+ )
118
+ return dict(raw.get("results", {}).get(agent_id, {}))
119
+
120
+ def step_many(
121
+ self,
122
+ agent_payloads: list[dict[str, str]],
123
+ dialogue_so_far: Dialogue,
124
+ new_user_text: str,
125
+ silence_flag: bool,
126
+ ) -> dict[str, object]:
127
+ return self._post(
128
+ "/step_many",
129
+ {
130
+ "agent_payloads": agent_payloads,
131
+ "dialogue_so_far": dialogue_so_far,
132
+ "new_user_text": new_user_text,
133
+ "silence_flag": silence_flag,
134
+ },
135
+ )
136
+
137
+ def generate(self, agent_id: str, system_prompt: str, dialogue: Dialogue) -> dict[str, object]:
138
+ return self._post(
139
+ "/generate",
140
+ {"agent_id": agent_id, "system_prompt": system_prompt, "dialogue": dialogue},
141
+ )
142
+
143
+ def transcribe(self, audio_path: str, language: str = "en") -> dict[str, object]:
144
+ import requests
145
+
146
+ headers = self._auth_headers(self.transcribe_bearer_token)
147
+ with Path(audio_path).open("rb") as audio_file:
148
+ files = {"file": (Path(audio_path).name, audio_file, "audio/wav")}
149
+ data = {"language": language}
150
+ response = requests.post(
151
+ f"{self.transcribe_endpoint_url}/transcribe",
152
+ files=files,
153
+ data=data,
154
+ headers=headers,
155
+ timeout=max(self.timeout_s, 600.0),
156
+ )
157
+ response.raise_for_status()
158
+ return dict(response.json())
159
+
160
+ def _post(self, path: str, payload: dict[str, object]) -> dict[str, object]:
161
+ import requests
162
+
163
+ response = requests.post(
164
+ f"{self.endpoint_url}{path}",
165
+ json=payload,
166
+ headers={"Content-Type": "application/json", **self._auth_headers(self.bearer_token)},
167
+ timeout=self.timeout_s,
168
+ )
169
+ response.raise_for_status()
170
+ return dict(response.json())
171
+
172
+ @staticmethod
173
+ def _auth_headers(bearer_token: str | None) -> dict[str, str]:
174
+ if not bearer_token:
175
+ return {}
176
+ return {"Authorization": f"Bearer {bearer_token}"}
177
+
178
+
179
+ class DemoDecompressBrainClient:
180
+ """Deterministic local stand-in for tests and UI work."""
181
+
182
+ def __init__(self) -> None:
183
+ self.calls = 0
184
+
185
+ def step(
186
+ self,
187
+ agent_id: str,
188
+ system_prompt: str,
189
+ dialogue_so_far: Dialogue,
190
+ new_user_text: str,
191
+ silence_flag: bool,
192
+ ) -> dict[str, object]:
193
+ raw = self.step_many(
194
+ [{"agent_id": agent_id, "system_prompt": system_prompt}],
195
+ dialogue_so_far,
196
+ new_user_text,
197
+ silence_flag,
198
+ )
199
+ return dict(raw["results"][agent_id])
200
+
201
+ def step_many(
202
+ self,
203
+ agent_payloads: list[dict[str, str]],
204
+ dialogue_so_far: Dialogue,
205
+ new_user_text: str,
206
+ silence_flag: bool,
207
+ ) -> dict[str, object]:
208
+ del dialogue_so_far
209
+ start = time.perf_counter()
210
+ self.calls += 1
211
+ text = new_user_text.lower()
212
+ stress_words = sum(word in text for word in ["tense", "replaying", "tight", "behind", "sleep", "work"])
213
+ shift = any(word in text for word in ["but", "then", "before", "after"])
214
+ results = {}
215
+ for payload in agent_payloads:
216
+ hidden = np.zeros(10, dtype=np.float32)
217
+ hidden[0] = 1.0
218
+ hidden[(self.calls % 5) + 1] = 0.35
219
+ if shift:
220
+ hidden[7] = 1.2
221
+ if silence_flag:
222
+ hidden[8] = 1.0
223
+ hidden /= max(float(np.linalg.norm(hidden)), 1.0)
224
+ p_end = 0.94 if silence_flag else (0.68 if new_user_text.strip().endswith((".", "?", "!")) else 0.18)
225
+ results[payload["agent_id"]] = {
226
+ "ok": True,
227
+ "agent_id": payload["agent_id"],
228
+ "surprise": float(2.0 + stress_words * 0.7 + self.calls * 0.03),
229
+ "hidden": hidden.tolist(),
230
+ "readiness": 0.72 if silence_flag else 0.48,
231
+ "p_end": p_end,
232
+ "latency_ms": 3.0,
233
+ "model_name": "demo-minicpm-brain",
234
+ "device_name": "cpu-test",
235
+ }
236
+ return {
237
+ "ok": True,
238
+ "results": results,
239
+ "batch_latency_ms": (time.perf_counter() - start) * 1000.0,
240
+ "model_name": "demo-minicpm-brain",
241
+ "device_name": "cpu-test",
242
+ }
243
+
244
+ def generate(self, agent_id: str, system_prompt: str, dialogue: Dialogue) -> dict[str, object]:
245
+ del agent_id, system_prompt
246
+ text = _dialogue_text(dialogue, "")
247
+ line = fallback_line(text)
248
+ return {
249
+ "ok": True,
250
+ "agent_id": AGENT_ID,
251
+ "reply_text": line,
252
+ "raw_reply_text": line,
253
+ "reply_source": "demo",
254
+ "latency_ms": 4.0,
255
+ "model_name": "demo-minicpm-brain",
256
+ }
257
+
258
+ def transcribe(self, audio_path: str, language: str = "en") -> dict[str, object]:
259
+ del audio_path, language
260
+ return {
261
+ "ok": True,
262
+ "text": SAMPLE_CHECKIN,
263
+ "language": "en",
264
+ "latency_ms": 5.0,
265
+ "duration_s": 4.0,
266
+ "model_name": "demo-cohere-transcribe",
267
+ }
268
+
269
+
270
+ class KokoroCalmSpeaker:
271
+ """Lazy Kokoro TTS wrapper; text still works if TTS fails."""
272
+
273
+ def __init__(self, enabled: bool = True) -> None:
274
+ self.enabled = enabled
275
+ self.last_error: str | None = None
276
+ self._pipeline: Any | None = None
277
+
278
+ def synthesize(self, text: str, tag: str) -> str | None:
279
+ if not self.enabled or not text.strip():
280
+ return None
281
+ AUDIO_DIR.mkdir(parents=True, exist_ok=True)
282
+ output = AUDIO_DIR / f"{tag}_decompress.wav"
283
+ try:
284
+ os.environ.setdefault("HF_HOME", str(ROOT / ".hf-cache"))
285
+ import soundfile as sf
286
+ from kokoro import KPipeline
287
+
288
+ if self._pipeline is None:
289
+ self._pipeline = KPipeline(lang_code="a")
290
+ chunks = []
291
+ for _, _, audio in self._pipeline(text, voice=os.getenv("DECOMPRESS_VOICE", "af_heart"), speed=0.92):
292
+ chunks.append(np.asarray(audio, dtype=np.float32))
293
+ if not chunks:
294
+ raise RuntimeError("Kokoro returned no audio chunks")
295
+ waveform = np.concatenate(chunks)
296
+ sf.write(output, waveform, 24000)
297
+ self.last_error = None
298
+ return str(output)
299
+ except Exception as exc: # noqa: BLE001 - keep demo alive on CPU/TTS failures.
300
+ self.last_error = f"{type(exc).__name__}: {exc}"
301
+ return None
302
+
303
+
304
+ class DecompressRetriever:
305
+ def __init__(self, corpus_path: Path = CORPUS_PATH, model_name: str = DEFAULT_EMBEDDER) -> None:
306
+ self.corpus_path = corpus_path
307
+ self.model_name = model_name
308
+ self.docs = json.loads(corpus_path.read_text(encoding="utf-8"))
309
+ self.embedding_model = "lexical-fallback"
310
+ self._model: Any | None = None
311
+ self._embeddings: np.ndarray | None = None
312
+ self._load_error: str | None = None
313
+
314
+ def retrieve(self, query: str, *, k: int = 2) -> list[RetrievedSource]:
315
+ query = " ".join(query.strip().split()) or "stress breathing grounding"
316
+ if self._ensure_sentence_embeddings():
317
+ assert self._model is not None and self._embeddings is not None
318
+ query_vec = np.asarray(self._model.encode([query], normalize_embeddings=True), dtype=np.float32)[0]
319
+ scores = np.dot(self._embeddings, query_vec)
320
+ else:
321
+ scores = np.asarray([_lexical_score(query, doc["text"]) for doc in self.docs], dtype=np.float32)
322
+
323
+ ranked = sorted(enumerate(scores.tolist()), key=lambda item: item[1], reverse=True)[:k]
324
+ return [
325
+ RetrievedSource(
326
+ id=str(self.docs[index]["id"]),
327
+ title=str(self.docs[index]["title"]),
328
+ publisher=str(self.docs[index]["publisher"]),
329
+ url=str(self.docs[index]["url"]),
330
+ text=str(self.docs[index]["text"]),
331
+ score=float(score),
332
+ )
333
+ for index, score in ranked
334
+ ]
335
+
336
+ def _ensure_sentence_embeddings(self) -> bool:
337
+ if self._embeddings is not None:
338
+ return True
339
+ if self._load_error is not None:
340
+ return False
341
+ try:
342
+ from sentence_transformers import SentenceTransformer
343
+
344
+ os.environ.setdefault("HF_HOME", str(ROOT / ".hf-cache"))
345
+ self._model = SentenceTransformer(self.model_name)
346
+ texts = [f"{doc['title']}. {doc['text']}" for doc in self.docs]
347
+ self._embeddings = np.asarray(self._model.encode(texts, normalize_embeddings=True), dtype=np.float32)
348
+ self.embedding_model = self.model_name
349
+ return True
350
+ except Exception as exc: # noqa: BLE001 - local tests should not require the embedder.
351
+ self._load_error = f"{type(exc).__name__}: {exc}"
352
+ self.embedding_model = f"lexical-fallback ({self._load_error})"
353
+ return False
354
+
355
+
356
+ _RETRIEVER: DecompressRetriever | None = None
357
+
358
+
359
+ def get_retriever() -> DecompressRetriever:
360
+ global _RETRIEVER
361
+ if _RETRIEVER is None:
362
+ _RETRIEVER = DecompressRetriever(model_name=os.getenv("DECOMPRESS_EMBEDDER", DEFAULT_EMBEDDER))
363
+ return _RETRIEVER
364
+
365
+
366
+ def set_live_tau(value: float) -> None:
367
+ global _LIVE_TAU
368
+ _LIVE_TAU = float(value)
369
+
370
+
371
+ def chunk_checkin(text: str, *, words_per_chunk: int = 11) -> list[TranscriptChunk]:
372
+ words = re.findall(r"\S+", text.strip())
373
+ if not words:
374
+ words = re.findall(r"\S+", SAMPLE_CHECKIN)
375
+ chunks = [
376
+ TranscriptChunk(" ".join(words[index : index + words_per_chunk]), silence_flag=False)
377
+ for index in range(0, len(words), words_per_chunk)
378
+ ]
379
+ chunks = [
380
+ TranscriptChunk(chunk.text if index == len(chunks) - 1 else chunk.text.rstrip(".?!"), chunk.silence_flag)
381
+ for index, chunk in enumerate(chunks)
382
+ ]
383
+ if chunks:
384
+ chunks[-1] = TranscriptChunk(chunks[-1].text, silence_flag=True)
385
+ return chunks
386
+
387
+
388
+ def gentle_controller_config(tau: float) -> ControllerConfig:
389
+ return ControllerConfig(
390
+ tau=float(tau),
391
+ w_surprise=0.25,
392
+ w_change=0.20,
393
+ w_readiness=0.65,
394
+ w_end=1.35,
395
+ w_barge=0.0,
396
+ min_readiness=0.08,
397
+ take_floor_p_end=0.68,
398
+ interrupt_p_end_max=0.05,
399
+ backchannel_p_end_max=0.40,
400
+ backchannel_tau_fraction=0.75,
401
+ barge_tau_fraction=10.0,
402
+ turn_end_tau_discount=0.55,
403
+ refractory_steps=2,
404
+ )
405
+
406
+
407
+ def make_brain_client(mode: str | None = None) -> tuple[BrainClient, str]:
408
+ selected = (mode or os.getenv("DECOMPRESS_BRAIN", "modal")).strip().lower()
409
+ if selected in {"fake", "demo", "local"}:
410
+ return DemoDecompressBrainClient(), "demo"
411
+ return EndpointDecompressBrainClient(), "modal"
412
+
413
+
414
+ def initial_room_html(tau: float | None = None, brain_mode: str | None = None) -> str:
415
+ state = DecompressState(
416
+ tau=float(tau if tau is not None else _LIVE_TAU),
417
+ brain_mode=brain_mode or os.getenv("DECOMPRESS_BRAIN", "modal"),
418
+ status="ready",
419
+ )
420
+ state.citations = [asdict(source) for source in get_retriever().retrieve(SAMPLE_CHECKIN, k=2)]
421
+ state.embedding_model = get_retriever().embedding_model
422
+ return render_room(state)
423
+
424
+
425
+ def preview_room_html(tau: float | None = None) -> str:
426
+ latest = initial_room_html(tau, brain_mode="demo")
427
+ for latest, _, _ in run_checkin_stream(SAMPLE_CHECKIN, tau or _LIVE_TAU, brain_mode="fake", enable_tts=False, save_log=False):
428
+ pass
429
+ return latest
430
+
431
+
432
+ def run_checkin_stream(
433
+ checkin_text: str,
434
+ tau: float,
435
+ *,
436
+ brain_mode: str | None = None,
437
+ enable_tts: bool | None = None,
438
+ save_log: bool = True,
439
+ ) -> Iterator[tuple[str, str | None, dict[str, Any]]]:
440
+ set_live_tau(tau)
441
+ selected_tts = enable_tts if enable_tts is not None else os.getenv("DECOMPRESS_TTS", "1").lower() not in {"0", "false"}
442
+ state = DecompressState(tau=float(tau), brain_mode=brain_mode or os.getenv("DECOMPRESS_BRAIN", "modal"), status="connecting")
443
+ speaker = KokoroCalmSpeaker(enabled=bool(selected_tts))
444
+ yield from _safe_run_checkin_stream(checkin_text, state, speaker, save_log=save_log)
445
+
446
+
447
+ def run_voice_checkin_stream(
448
+ audio_clip: Any,
449
+ tau: float,
450
+ *,
451
+ brain_mode: str | None = None,
452
+ enable_tts: bool | None = None,
453
+ save_log: bool = True,
454
+ ) -> Iterator[tuple[str, str | None, dict[str, Any], str]]:
455
+ set_live_tau(tau)
456
+ selected_tts = enable_tts if enable_tts is not None else os.getenv("DECOMPRESS_TTS", "1").lower() not in {"0", "false"}
457
+ state = DecompressState(tau=float(tau), brain_mode=brain_mode or os.getenv("DECOMPRESS_BRAIN", "modal"), status="transcribing")
458
+ speaker = KokoroCalmSpeaker(enabled=bool(selected_tts))
459
+ state.transcript.append(
460
+ {
461
+ "kind": "system",
462
+ "speaker": "Cohere Transcribe",
463
+ "step": 0,
464
+ "text": "Listening to the recorded check-in.",
465
+ "action": "ASR",
466
+ }
467
+ )
468
+ yield render_room(state), None, _status_payload(state), ""
469
+
470
+ try:
471
+ audio_path = _audio_clip_path(audio_clip)
472
+ client, resolved_mode = make_brain_client(state.brain_mode)
473
+ state.brain_mode = resolved_mode
474
+ if not hasattr(client, "transcribe"):
475
+ raise RuntimeError("selected brain client does not support transcription")
476
+ raw_asr = client.transcribe(audio_path, language="en") # type: ignore[attr-defined]
477
+ if not raw_asr.get("ok", False):
478
+ raise RuntimeError(str(raw_asr.get("failure", "transcription failed")))
479
+ transcript = " ".join(str(raw_asr.get("text", "")).strip().split())
480
+ if not transcript:
481
+ raise RuntimeError("Cohere Transcribe returned an empty transcript")
482
+ state.asr_text = transcript
483
+ state.asr_latency_ms = float(raw_asr.get("latency_ms", 0.0) or 0.0)
484
+ state.asr_model_name = str(raw_asr.get("model_name", ""))
485
+ state.transcript.append(
486
+ {
487
+ "kind": "system",
488
+ "speaker": "Cohere transcript",
489
+ "step": 0,
490
+ "text": transcript,
491
+ "action": "ASR",
492
+ }
493
+ )
494
+ yield render_room(state), None, _status_payload(state), transcript
495
+
496
+ for html_frame, audio_path_out, status in _run_with_client(
497
+ transcript,
498
+ state,
499
+ speaker,
500
+ client,
501
+ save_log=save_log,
502
+ ):
503
+ yield html_frame, audio_path_out, status, transcript
504
+ except Exception as exc: # noqa: BLE001 - surface failures in the app shell.
505
+ state.status = "failed"
506
+ state.transcript.append(
507
+ {
508
+ "kind": "system",
509
+ "speaker": "room",
510
+ "step": state.step,
511
+ "text": f"Voice path failed: {type(exc).__name__}: {exc}",
512
+ "action": "ERROR",
513
+ }
514
+ )
515
+ yield render_room(state), None, _status_payload(state), state.asr_text or ""
516
+
517
+
518
+ def _safe_run_checkin_stream(
519
+ checkin_text: str,
520
+ state: DecompressState,
521
+ speaker: KokoroCalmSpeaker,
522
+ *,
523
+ save_log: bool,
524
+ ) -> Iterator[tuple[str, str | None, dict[str, Any]]]:
525
+ try:
526
+ client, resolved_mode = make_brain_client(state.brain_mode)
527
+ state.brain_mode = resolved_mode
528
+ yield from _run_with_client(checkin_text, state, speaker, client, save_log=save_log)
529
+ except Exception as exc: # noqa: BLE001 - keep the visible shell alive.
530
+ state.status = "failed"
531
+ state.transcript.append(
532
+ {
533
+ "kind": "system",
534
+ "speaker": "room",
535
+ "step": state.step,
536
+ "text": f"Backend failed: {type(exc).__name__}: {exc}",
537
+ "action": "ERROR",
538
+ }
539
+ )
540
+ yield render_room(state), None, _status_payload(state)
541
+
542
+
543
+ def _run_with_client(
544
+ checkin_text: str,
545
+ state: DecompressState,
546
+ speaker: KokoroCalmSpeaker,
547
+ client: BrainClient,
548
+ *,
549
+ save_log: bool,
550
+ ) -> Iterator[tuple[str, str | None, dict[str, Any]]]:
551
+ retriever = get_retriever()
552
+ controller = WhenToSpeakController([AGENT_ID], config=gentle_controller_config(state.tau))
553
+ dialogue: Dialogue = []
554
+ current_user_text = ""
555
+ events: list[dict[str, Any]] = []
556
+ chunks = chunk_checkin(checkin_text)
557
+ state.status = "listening"
558
+ state.embedding_model = retriever.embedding_model
559
+ yield render_room(state), None, _status_payload(state)
560
+
561
+ for step_index, chunk in enumerate(chunks, start=1):
562
+ state.step = step_index
563
+ state.tau = _LIVE_TAU
564
+ controller.config = gentle_controller_config(_LIVE_TAU)
565
+ query_text = _join_text(current_user_text, chunk.text)
566
+ retrieved = retriever.retrieve(query_text, k=2)
567
+ state.citations = [asdict(source) for source in retrieved]
568
+ state.embedding_model = retriever.embedding_model
569
+ system_prompt = companion_system_prompt(retrieved)
570
+
571
+ dialogue_before = _dialogue_with_current_user(dialogue, current_user_text)
572
+ raw = client.step_many(
573
+ [{"agent_id": AGENT_ID, "system_prompt": system_prompt}],
574
+ dialogue_before,
575
+ chunk.text,
576
+ chunk.silence_flag,
577
+ )
578
+ raw_result = dict(raw.get("results", {}).get(AGENT_ID, {}))
579
+ signals = signals_from_raw(raw_result)
580
+ current_user_text = _join_text(current_user_text, chunk.text)
581
+ tick = controller.tick({AGENT_ID: signals}, floor_holder="human")
582
+ state.decision = _decision_payload(tick)
583
+ state.model_name = str(raw.get("model_name", "") or raw_result.get("model_name", "") or "")
584
+ state.device_name = str(raw.get("device_name", "") or raw_result.get("device_name", "") or "")
585
+ state.transcript.append({"kind": "user", "speaker": "You", "step": step_index, "text": chunk.text})
586
+ event = _event_payload(step_index, chunk, tick, raw, raw_result)
587
+
588
+ audio_path: str | None = None
589
+ decision = tick.decisions[AGENT_ID]
590
+ if decision.action == Action.BACKCHANNEL:
591
+ line = backchannel_line(step_index, current_user_text)
592
+ state.stats.backchannels += 1
593
+ dialogue.append({"role": "assistant", "speaker": AGENT_ID, "text": line})
594
+ state.transcript.append(
595
+ {
596
+ "kind": "companion backchannel",
597
+ "speaker": DISPLAY_NAME,
598
+ "step": step_index,
599
+ "text": line,
600
+ "action": Action.BACKCHANNEL.value,
601
+ }
602
+ )
603
+ audio_path = speaker.synthesize(line, f"step{step_index:02d}_backchannel")
604
+ elif tick.winner:
605
+ if current_user_text:
606
+ dialogue.append({"role": "user", "speaker": "you", "text": current_user_text})
607
+ current_user_text = ""
608
+ generated = client.generate(AGENT_ID, system_prompt, dialogue)
609
+ line = sanitize_spoken_line(str(generated.get("reply_text", "")), dialogue)
610
+ dialogue.append({"role": "assistant", "speaker": AGENT_ID, "text": line})
611
+ state.response_latency_ms = float(generated.get("latency_ms", 0.0) or 0.0)
612
+ state.transcript.append(
613
+ {
614
+ "kind": "companion",
615
+ "speaker": DISPLAY_NAME,
616
+ "step": step_index,
617
+ "text": line,
618
+ "action": decision.action.value,
619
+ }
620
+ )
621
+ event["generated"] = dict(generated, reply_text=line)
622
+ if decision.action == Action.INTERRUPT:
623
+ state.stats.interrupts += 1
624
+ else:
625
+ state.stats.take_floors += 1
626
+ audio_path = speaker.synthesize(line, f"step{step_index:02d}_reply")
627
+ elif chunk.silence_flag:
628
+ state.stats.holds += 1
629
+
630
+ if speaker.last_error:
631
+ state.tts_error = speaker.last_error
632
+ events.append(event)
633
+ yield render_room(state), audio_path, _status_payload(state)
634
+
635
+ if current_user_text:
636
+ dialogue.append({"role": "user", "speaker": "you", "text": current_user_text})
637
+
638
+ if state.stats.take_floors == 0:
639
+ retrieved = retriever.retrieve(_dialogue_text(dialogue, ""), k=2)
640
+ state.citations = [asdict(source) for source in retrieved]
641
+ system_prompt = companion_system_prompt(retrieved)
642
+ generated = client.generate(AGENT_ID, system_prompt, dialogue)
643
+ line = sanitize_spoken_line(str(generated.get("reply_text", "")), dialogue)
644
+ dialogue.append({"role": "assistant", "speaker": AGENT_ID, "text": line})
645
+ state.stats.take_floors += 1
646
+ state.response_latency_ms = float(generated.get("latency_ms", 0.0) or 0.0)
647
+ state.transcript.append(
648
+ {
649
+ "kind": "companion",
650
+ "speaker": DISPLAY_NAME,
651
+ "step": state.step,
652
+ "text": line,
653
+ "action": Action.TAKE_FLOOR.value,
654
+ }
655
+ )
656
+ audio_path = speaker.synthesize(line, f"step{state.step:02d}_final")
657
+ yield render_room(state), audio_path, _status_payload(state)
658
+
659
+ state.status = "complete"
660
+ if save_log:
661
+ _save_decompress_log(state, dialogue, events)
662
+ yield render_room(state), None, _status_payload(state)
663
+
664
+
665
+ def companion_system_prompt(sources: list[RetrievedSource]) -> str:
666
+ source_lines = [
667
+ f"[{index}] {source.publisher}, {source.title}: {source.text}"
668
+ for index, source in enumerate(sources, start=1)
669
+ ]
670
+ return "\n".join(
671
+ [
672
+ "You are Decompress, a calm five-minute de-stress companion.",
673
+ "Use the grounding notes below for practical, non-medical suggestions.",
674
+ "Listen first. Use short backchannel-like wording if the user is still talking.",
675
+ "When the user pauses, give one gentle next step in one sentence.",
676
+ "Do not diagnose, do not prescribe, and do not imply therapy or medical care.",
677
+ "If the user may be in immediate danger, tell them to contact emergency support or a trusted person now.",
678
+ "Grounding notes:",
679
+ *source_lines,
680
+ ]
681
+ )
682
+
683
+
684
+ def render_room(state: DecompressState) -> str:
685
+ decision = state.decision or _empty_decision()
686
+ transcript = "\n".join(_render_feed_item(item) for item in state.transcript) or _empty_transcript()
687
+ citations = "\n".join(_render_citation(item) for item in state.citations) or _empty_citations()
688
+ score = min(100.0, max(0.0, float(decision.get("urge", 0.0)) / max(state.tau * 1.45, 1.0) * 100.0))
689
+ tau_pos = min(100.0, max(0.0, state.tau / max(state.tau * 1.45, 1.0) * 100.0))
690
+ action = str(decision.get("action", "SILENT"))
691
+ tts_note = f"<span class='soft-note'>TTS: {_escape(state.tts_error)}</span>" if state.tts_error else ""
692
+ return f"""
693
+ <main id="decompress-room" class="decompress-room" data-status="{_escape(state.status)}">
694
+ <section class="calm-hero">
695
+ <div class="breath-dial" aria-hidden="true"><span></span></div>
696
+ <div class="hero-copy">
697
+ <div class="kicker">DECOMPRESS</div>
698
+ <h1>A companion that waits for the right beat.</h1>
699
+ <p>MiniCPM listens through the when-to-speak controller, then answers with one grounded next step.</p>
700
+ </div>
701
+ <div class="session-chip">
702
+ <span>Brain {_escape(state.brain_mode.upper())}</span>
703
+ <span>Step {_escape(str(state.step))}</span>
704
+ <span>&tau; {_escape(f"{state.tau:.2f}")}</span>
705
+ {tts_note}
706
+ </div>
707
+ </section>
708
+ <section class="companion-grid">
709
+ <article class="companion-card {action.lower()}">
710
+ <div class="card-top">
711
+ <div class="moon-mark">D</div>
712
+ <div>
713
+ <h2>Decompress</h2>
714
+ <p>Calm timing, not assistant chatter.</p>
715
+ </div>
716
+ </div>
717
+ <div class="urge-readout">
718
+ <span>urge</span>
719
+ <strong>{float(decision.get("urge", 0.0)):.2f}</strong>
720
+ </div>
721
+ <div class="soft-meter" style="--fill:{score:.1f}%; --tau:{tau_pos:.1f}%;">
722
+ <div class="soft-fill"></div><i></i>
723
+ </div>
724
+ <div class="signal-row">
725
+ <span>{_escape(action)}</span>
726
+ <span>ready {float(decision.get("readiness", 0.0)):.2f}</span>
727
+ <span>end {float(decision.get("p_end", 0.0)):.2f}</span>
728
+ </div>
729
+ <div class="stats-line">
730
+ <span>{state.stats.backchannels} backchannels</span>
731
+ <span>{state.stats.take_floors} replies</span>
732
+ <span>{state.stats.holds} holds</span>
733
+ </div>
734
+ </article>
735
+ <article class="transcript-panel">
736
+ <div class="panel-title"><span>Check-in transcript</span><em>{_escape(state.status)}</em></div>
737
+ <div class="decompress-scroll">{transcript}</div>
738
+ </article>
739
+ <aside class="grounding-panel">
740
+ <div class="panel-title"><span>Grounded by</span><em>{_escape(state.embedding_model)}</em></div>
741
+ <div class="citation-list">{citations}</div>
742
+ <p class="disclaimer">Wellness support only. Not medical advice, diagnosis, or crisis care.</p>
743
+ </aside>
744
+ </section>
745
+ </main>
746
+ """.strip()
747
+
748
+
749
+ def sanitize_spoken_line(reply: str, dialogue: Dialogue | None = None) -> str:
750
+ candidate = re.sub(r"<think>.*?</think>", "", reply, flags=re.IGNORECASE | re.DOTALL)
751
+ candidate = candidate.replace("<think>", "").replace("</think>", "")
752
+ for line in candidate.splitlines() or [candidate]:
753
+ cleaned = _strip_prefix(line)
754
+ if _looks_spoken(cleaned):
755
+ return _truncate(cleaned)
756
+ return fallback_line(_dialogue_text(dialogue or [], ""))
757
+
758
+
759
+ def fallback_line(text: str) -> str:
760
+ lowered = text.lower()
761
+ if any(fragment in lowered for fragment in ("hurt myself", "kill myself", "suicide", "not safe")):
762
+ return "If you might be unsafe, contact emergency support or a trusted person now."
763
+ if "sleep" in lowered or "late" in lowered:
764
+ return "Park one sentence on paper, then let the next exhale be longer."
765
+ if "work" in lowered or "meeting" in lowered or "behind" in lowered:
766
+ return "Make work smaller: unclench your jaw and choose one next tiny step."
767
+ stable_index = sum(ord(char) for char in lowered) % len(FALLBACK_LINES)
768
+ return FALLBACK_LINES[stable_index]
769
+
770
+
771
+ def backchannel_line(step: int, text: str) -> str:
772
+ seed = step + len(text)
773
+ return CALM_BACKCHANNELS[seed % len(CALM_BACKCHANNELS)]
774
+
775
+
776
+ def _decision_payload(tick: ControllerTick) -> dict[str, Any]:
777
+ decision = tick.decisions[AGENT_ID]
778
+ return {
779
+ "action": decision.action.value,
780
+ "urge": decision.urge,
781
+ "z_surprise": decision.z_surprise,
782
+ "change_score": decision.change_score,
783
+ "readiness": decision.readiness,
784
+ "p_end": decision.p_end,
785
+ "hidden_delta": decision.hidden_delta,
786
+ "map_run_length": decision.map_run_length,
787
+ "winner": tick.winner == AGENT_ID,
788
+ }
789
+
790
+
791
+ def _event_payload(
792
+ step: int,
793
+ chunk: TranscriptChunk,
794
+ tick: ControllerTick,
795
+ raw: dict[str, object],
796
+ raw_result: dict[str, object],
797
+ ) -> dict[str, Any]:
798
+ return {
799
+ "step": step,
800
+ "new_user_text": chunk.text,
801
+ "silence_flag": chunk.silence_flag,
802
+ "winner": tick.winner,
803
+ "model_name": raw.get("model_name") or raw_result.get("model_name"),
804
+ "device_name": raw.get("device_name") or raw_result.get("device_name"),
805
+ "batch_latency_ms": raw.get("batch_latency_ms"),
806
+ "decision": _decision_payload(tick),
807
+ "brain_latency_ms": raw_result.get("latency_ms"),
808
+ "surprise": raw_result.get("surprise"),
809
+ }
810
+
811
+
812
+ def _empty_decision() -> dict[str, Any]:
813
+ return {
814
+ "action": Action.SILENT.value,
815
+ "urge": 0.0,
816
+ "readiness": 0.0,
817
+ "p_end": 0.0,
818
+ "change_score": 0.0,
819
+ "z_surprise": 0.0,
820
+ "winner": False,
821
+ }
822
+
823
+
824
+ def _status_payload(state: DecompressState) -> dict[str, Any]:
825
+ return {
826
+ "status": state.status,
827
+ "tau": state.tau,
828
+ "brain_mode": state.brain_mode,
829
+ "stats": asdict(state.stats),
830
+ "citations": state.citations,
831
+ "tts_error": state.tts_error,
832
+ "asr_text": state.asr_text,
833
+ "asr_latency_ms": state.asr_latency_ms,
834
+ "asr_model_name": state.asr_model_name,
835
+ "embedding_model": state.embedding_model,
836
+ "response_latency_ms": state.response_latency_ms,
837
+ "model_name": state.model_name,
838
+ "device_name": state.device_name,
839
+ }
840
+
841
+
842
+ def _save_decompress_log(state: DecompressState, dialogue: Dialogue, events: list[dict[str, Any]]) -> None:
843
+ EVAL_DIR.mkdir(parents=True, exist_ok=True)
844
+ payload = {
845
+ "brain_mode": state.brain_mode,
846
+ "tau": state.tau,
847
+ "status": state.status,
848
+ "stats": asdict(state.stats),
849
+ "citations": state.citations,
850
+ "asr_text": state.asr_text,
851
+ "asr_latency_ms": state.asr_latency_ms,
852
+ "asr_model_name": state.asr_model_name,
853
+ "embedding_model": state.embedding_model,
854
+ "response_latency_ms": state.response_latency_ms,
855
+ "model_name": state.model_name,
856
+ "device_name": state.device_name,
857
+ "transcript": state.transcript,
858
+ "dialogue": dialogue,
859
+ "events": events,
860
+ }
861
+ DECOMPRESS_LOG_PATH.write_text(json.dumps(payload, indent=2), encoding="utf-8")
862
+
863
+
864
+ def _audio_clip_path(audio_clip: Any) -> str:
865
+ if audio_clip is None:
866
+ raise ValueError("record a check-in clip before starting")
867
+ if isinstance(audio_clip, (str, os.PathLike)):
868
+ path = Path(audio_clip)
869
+ elif isinstance(audio_clip, dict):
870
+ path = Path(str(audio_clip.get("path") or audio_clip.get("name") or ""))
871
+ elif isinstance(audio_clip, (tuple, list)) and audio_clip:
872
+ path = Path(str(audio_clip[0]))
873
+ else:
874
+ raise TypeError(f"unsupported audio clip value: {type(audio_clip).__name__}")
875
+ if not path.exists():
876
+ raise FileNotFoundError(str(path))
877
+ return str(path)
878
+
879
+
880
+ def _dialogue_with_current_user(dialogue: Dialogue, current_user_text: str) -> Dialogue:
881
+ snapshot = [dict(turn) for turn in dialogue]
882
+ snapshot.append({"role": "user", "speaker": "you", "text": current_user_text})
883
+ return snapshot
884
+
885
+
886
+ def _join_text(left: str, right: str) -> str:
887
+ left = left.strip()
888
+ right = right.strip()
889
+ if not left:
890
+ return right
891
+ if not right:
892
+ return left
893
+ return f"{left} {right}"
894
+
895
+
896
+ def _dialogue_text(dialogue: Dialogue, new_user_text: str) -> str:
897
+ parts = [turn.get("text", "") for turn in dialogue]
898
+ parts.append(new_user_text)
899
+ return " ".join(parts).lower()
900
+
901
+
902
+ def _strip_prefix(line: str) -> str:
903
+ candidate = line.strip().strip("\"'")
904
+ candidate = re.sub(r"^[\-\*\d\.\)\s]+", "", candidate).strip()
905
+ for prefix in ["Decompress:", "Assistant:", "Companion:", "Sentence:", "Spoken line:"]:
906
+ if candidate.lower().startswith(prefix.lower()):
907
+ candidate = candidate[len(prefix) :].strip()
908
+ candidate = re.sub(r"^(certainly,?\s+|happy to,?\s+|let's\s+|i think\s+)", "", candidate, flags=re.IGNORECASE)
909
+ if candidate:
910
+ candidate = candidate[0].upper() + candidate[1:]
911
+ return candidate.strip().strip("\"'")
912
+
913
+
914
+ def _looks_spoken(candidate: str) -> bool:
915
+ if len(candidate) < 8:
916
+ return False
917
+ lowered = candidate.lower()
918
+ blocked = [
919
+ "the user",
920
+ "recent transcript",
921
+ "analysis",
922
+ "markdown",
923
+ "medical advice",
924
+ "diagnosis",
925
+ "as a therapist",
926
+ "decompress:",
927
+ "assistant:",
928
+ "<",
929
+ "{",
930
+ ]
931
+ if any(fragment in lowered for fragment in blocked):
932
+ return False
933
+ alpha = sum(char.isalpha() for char in candidate)
934
+ if alpha / max(len(candidate), 1) < 0.45:
935
+ return False
936
+ return len(candidate.split()) <= 28
937
+
938
+
939
+ def _truncate(candidate: str) -> str:
940
+ parts = re.split(r"(?<=[.!?;])\s+", candidate, maxsplit=1)
941
+ sentence = parts[0].strip() if parts else candidate.strip()
942
+ words = sentence.split()
943
+ if len(words) > 24:
944
+ sentence = " ".join(words[:24]).rstrip(",;:") + "."
945
+ return sentence[:220].rstrip()
946
+
947
+
948
+ def _render_feed_item(item: dict[str, Any]) -> str:
949
+ kind = str(item.get("kind", "system")).replace(" ", "-")
950
+ action = str(item.get("action", ""))
951
+ step = item.get("step", "")
952
+ speaker = _escape(str(item.get("speaker", "")))
953
+ text = _escape(str(item.get("text", "")))
954
+ return f"""
955
+ <div class="soft-feed {kind}">
956
+ <div class="feed-meta"><span>{speaker}</span><em>step {step}</em></div>
957
+ <div class="feed-text">{text}</div>
958
+ {f'<b>{_escape(action)}</b>' if action else ''}
959
+ </div>
960
+ """.strip()
961
+
962
+
963
+ def _empty_transcript() -> str:
964
+ return """
965
+ <div class="soft-feed system">
966
+ <div class="feed-meta"><span>Room</span><em>ready</em></div>
967
+ <div class="feed-text">Type or record what is on your mind. The companion will wait for the pause.</div>
968
+ </div>
969
+ """.strip()
970
+
971
+
972
+ def _render_citation(item: dict[str, Any]) -> str:
973
+ title = _escape(str(item.get("title", "")))
974
+ publisher = _escape(str(item.get("publisher", "")))
975
+ url = _escape(str(item.get("url", "")))
976
+ score = float(item.get("score", 0.0) or 0.0)
977
+ return f"""
978
+ <a class="citation-card" href="{url}" target="_blank" rel="noreferrer">
979
+ <strong>{title}</strong>
980
+ <span>{publisher}</span>
981
+ <em>match {score:.2f}</em>
982
+ </a>
983
+ """.strip()
984
+
985
+
986
+ def _empty_citations() -> str:
987
+ return "<div class='citation-empty'>Sources appear when the check-in starts.</div>"
988
+
989
+
990
+ def _lexical_score(query: str, text: str) -> float:
991
+ query_tokens = _tokens(query)
992
+ text_tokens = _tokens(text)
993
+ if not query_tokens or not text_tokens:
994
+ return 0.0
995
+ overlap = query_tokens & text_tokens
996
+ return float(len(overlap) / max(len(query_tokens), 1))
997
+
998
+
999
+ def _tokens(text: str) -> set[str]:
1000
+ stop = {
1001
+ "the",
1002
+ "and",
1003
+ "for",
1004
+ "that",
1005
+ "with",
1006
+ "you",
1007
+ "your",
1008
+ "this",
1009
+ "are",
1010
+ "was",
1011
+ "have",
1012
+ "has",
1013
+ "but",
1014
+ "not",
1015
+ "one",
1016
+ }
1017
+ return {token for token in re.findall(r"[a-z]{3,}", text.lower()) if token not in stop}
1018
+
1019
+
1020
+ def _escape(value: str) -> str:
1021
+ return html.escape(value, quote=True)
apps/decompress/static/decompress.css ADDED
@@ -0,0 +1,432 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --ink: #1f2a2c;
3
+ --paper: #f4f7f5;
4
+ --mist: #dce8e3;
5
+ --sage: #6e9b8f;
6
+ --moss: #355c55;
7
+ --blue: #8fb7c7;
8
+ --rose: #d88d84;
9
+ --plum: #59465d;
10
+ --line: rgba(31, 42, 44, 0.18);
11
+ }
12
+
13
+ body,
14
+ .gradio-container {
15
+ background:
16
+ linear-gradient(135deg, rgba(31, 42, 44, 0.035) 25%, transparent 25%) 0 0 / 34px 34px,
17
+ linear-gradient(145deg, #eef4f1 0%, #d7e5e1 46%, #edf2f6 100%) !important;
18
+ color: var(--ink) !important;
19
+ font-family: "Aptos", "Segoe UI", sans-serif !important;
20
+ }
21
+
22
+ .gradio-container {
23
+ max-width: none !important;
24
+ }
25
+
26
+ #decompress-control-row {
27
+ width: min(1180px, calc(100vw - 36px));
28
+ margin: 18px auto 28px;
29
+ align-items: stretch;
30
+ }
31
+
32
+ #decompress-input textarea,
33
+ #decompress-transcript textarea {
34
+ border: 1px solid rgba(31, 42, 44, 0.18) !important;
35
+ background: rgba(255, 255, 255, 0.68) !important;
36
+ color: var(--ink) !important;
37
+ border-radius: 6px !important;
38
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.75) !important;
39
+ }
40
+
41
+ #decompress-controls {
42
+ min-width: 280px;
43
+ }
44
+
45
+ #run-decompress,
46
+ #run-decompress-voice {
47
+ border-radius: 6px !important;
48
+ border: 0 !important;
49
+ min-height: 44px !important;
50
+ }
51
+
52
+ #run-decompress {
53
+ background: var(--moss) !important;
54
+ }
55
+
56
+ #run-decompress-voice {
57
+ background: rgba(89, 70, 93, 0.12) !important;
58
+ color: var(--plum) !important;
59
+ }
60
+
61
+ .decompress-room {
62
+ position: relative;
63
+ width: min(1180px, calc(100vw - 36px));
64
+ margin: 24px auto 0;
65
+ padding: 0;
66
+ color: var(--ink);
67
+ }
68
+
69
+ .calm-hero {
70
+ min-height: 270px;
71
+ display: grid;
72
+ grid-template-columns: minmax(170px, 230px) 1fr auto;
73
+ gap: 26px;
74
+ align-items: center;
75
+ border-bottom: 1px solid var(--line);
76
+ }
77
+
78
+ .breath-dial {
79
+ width: min(19vw, 210px);
80
+ min-width: 150px;
81
+ aspect-ratio: 1;
82
+ border-radius: 50%;
83
+ border: 1px solid rgba(53, 92, 85, 0.26);
84
+ display: grid;
85
+ place-items: center;
86
+ background:
87
+ linear-gradient(135deg, rgba(255, 255, 255, 0.72), rgba(220, 232, 227, 0.48)),
88
+ repeating-conic-gradient(from 0deg, rgba(53, 92, 85, 0.18) 0deg 3deg, transparent 3deg 12deg);
89
+ box-shadow: 0 20px 50px rgba(31, 42, 44, 0.12);
90
+ }
91
+
92
+ .breath-dial span {
93
+ width: 54%;
94
+ aspect-ratio: 1;
95
+ border-radius: 50%;
96
+ border: 1px solid rgba(216, 141, 132, 0.55);
97
+ animation: breathe 5.8s ease-in-out infinite;
98
+ background: rgba(255, 255, 255, 0.36);
99
+ }
100
+
101
+ @keyframes breathe {
102
+ 0%,
103
+ 100% {
104
+ transform: scale(0.82);
105
+ opacity: 0.62;
106
+ }
107
+ 50% {
108
+ transform: scale(1.08);
109
+ opacity: 1;
110
+ }
111
+ }
112
+
113
+ .hero-copy .kicker {
114
+ color: var(--moss);
115
+ font-size: 0.78rem;
116
+ letter-spacing: 0.22em;
117
+ font-weight: 800;
118
+ }
119
+
120
+ .hero-copy h1 {
121
+ margin: 10px 0 12px;
122
+ font-size: clamp(2.2rem, 5vw, 5.8rem);
123
+ line-height: 0.95;
124
+ letter-spacing: 0;
125
+ max-width: 760px;
126
+ color: var(--ink);
127
+ font-family: Georgia, "Times New Roman", serif;
128
+ font-weight: 500;
129
+ }
130
+
131
+ .hero-copy p {
132
+ max-width: 660px;
133
+ font-size: 1.04rem;
134
+ line-height: 1.6;
135
+ color: rgba(31, 42, 44, 0.75);
136
+ }
137
+
138
+ .session-chip {
139
+ display: grid;
140
+ gap: 8px;
141
+ align-self: start;
142
+ justify-items: end;
143
+ padding-top: 20px;
144
+ font-size: 0.78rem;
145
+ color: rgba(31, 42, 44, 0.7);
146
+ }
147
+
148
+ .session-chip span {
149
+ border: 1px solid var(--line);
150
+ border-radius: 999px;
151
+ padding: 6px 10px;
152
+ background: rgba(255, 255, 255, 0.45);
153
+ }
154
+
155
+ .soft-note {
156
+ max-width: 260px;
157
+ border-color: rgba(216, 141, 132, 0.35) !important;
158
+ }
159
+
160
+ .companion-grid {
161
+ display: grid;
162
+ grid-template-columns: 320px minmax(360px, 1fr) 300px;
163
+ gap: 18px;
164
+ margin-top: 18px;
165
+ align-items: stretch;
166
+ }
167
+
168
+ .companion-card,
169
+ .transcript-panel,
170
+ .grounding-panel {
171
+ border: 1px solid var(--line);
172
+ background: rgba(255, 255, 255, 0.48);
173
+ box-shadow: 0 18px 45px rgba(31, 42, 44, 0.08);
174
+ backdrop-filter: blur(16px);
175
+ border-radius: 8px;
176
+ }
177
+
178
+ .companion-card {
179
+ padding: 18px;
180
+ min-height: 330px;
181
+ display: flex;
182
+ flex-direction: column;
183
+ justify-content: space-between;
184
+ }
185
+
186
+ .companion-card.take_floor,
187
+ .companion-card.backchannel {
188
+ border-color: rgba(53, 92, 85, 0.5);
189
+ box-shadow: 0 18px 55px rgba(53, 92, 85, 0.2);
190
+ }
191
+
192
+ .card-top {
193
+ display: flex;
194
+ gap: 13px;
195
+ align-items: center;
196
+ }
197
+
198
+ .moon-mark {
199
+ width: 48px;
200
+ aspect-ratio: 1;
201
+ border-radius: 50%;
202
+ display: grid;
203
+ place-items: center;
204
+ background: var(--ink);
205
+ color: var(--paper);
206
+ font-family: Georgia, "Times New Roman", serif;
207
+ font-size: 1.35rem;
208
+ }
209
+
210
+ .card-top h2 {
211
+ margin: 0;
212
+ font-family: Georgia, "Times New Roman", serif;
213
+ font-weight: 500;
214
+ font-size: 1.7rem;
215
+ }
216
+
217
+ .card-top p {
218
+ margin: 2px 0 0;
219
+ color: rgba(31, 42, 44, 0.66);
220
+ }
221
+
222
+ .urge-readout {
223
+ display: flex;
224
+ justify-content: space-between;
225
+ align-items: end;
226
+ margin-top: 32px;
227
+ color: rgba(31, 42, 44, 0.68);
228
+ }
229
+
230
+ .urge-readout strong {
231
+ font-size: 2.35rem;
232
+ font-family: Georgia, "Times New Roman", serif;
233
+ color: var(--moss);
234
+ font-weight: 500;
235
+ }
236
+
237
+ .soft-meter {
238
+ height: 18px;
239
+ border: 1px solid rgba(31, 42, 44, 0.18);
240
+ border-radius: 999px;
241
+ position: relative;
242
+ overflow: hidden;
243
+ background: rgba(255, 255, 255, 0.55);
244
+ }
245
+
246
+ .soft-fill {
247
+ width: var(--fill);
248
+ height: 100%;
249
+ background: linear-gradient(90deg, var(--blue), var(--sage), var(--rose));
250
+ transition: width 360ms ease;
251
+ }
252
+
253
+ .soft-meter i {
254
+ position: absolute;
255
+ left: var(--tau);
256
+ top: -5px;
257
+ bottom: -5px;
258
+ width: 2px;
259
+ background: var(--plum);
260
+ }
261
+
262
+ .signal-row,
263
+ .stats-line {
264
+ display: flex;
265
+ flex-wrap: wrap;
266
+ gap: 7px;
267
+ }
268
+
269
+ .signal-row span,
270
+ .stats-line span {
271
+ padding: 6px 8px;
272
+ border-radius: 999px;
273
+ background: rgba(31, 42, 44, 0.06);
274
+ color: rgba(31, 42, 44, 0.68);
275
+ font-size: 0.78rem;
276
+ }
277
+
278
+ .transcript-panel,
279
+ .grounding-panel {
280
+ min-height: 330px;
281
+ display: flex;
282
+ flex-direction: column;
283
+ }
284
+
285
+ .panel-title {
286
+ height: 44px;
287
+ display: flex;
288
+ align-items: center;
289
+ justify-content: space-between;
290
+ padding: 0 14px;
291
+ border-bottom: 1px solid var(--line);
292
+ color: rgba(31, 42, 44, 0.68);
293
+ text-transform: uppercase;
294
+ font-size: 0.72rem;
295
+ letter-spacing: 0.1em;
296
+ }
297
+
298
+ .panel-title em {
299
+ font-style: normal;
300
+ text-transform: none;
301
+ letter-spacing: 0;
302
+ max-width: 180px;
303
+ overflow: hidden;
304
+ text-overflow: ellipsis;
305
+ white-space: nowrap;
306
+ }
307
+
308
+ .decompress-scroll {
309
+ flex: 1;
310
+ max-height: 390px;
311
+ overflow-y: auto;
312
+ padding: 14px;
313
+ display: flex;
314
+ flex-direction: column;
315
+ gap: 10px;
316
+ }
317
+
318
+ .soft-feed {
319
+ border-left: 3px solid rgba(31, 42, 44, 0.16);
320
+ padding: 10px 12px;
321
+ background: rgba(255, 255, 255, 0.42);
322
+ border-radius: 0 6px 6px 0;
323
+ }
324
+
325
+ .soft-feed.companion,
326
+ .soft-feed.companion-backchannel {
327
+ border-left-color: var(--moss);
328
+ background: rgba(110, 155, 143, 0.13);
329
+ }
330
+
331
+ .soft-feed.system {
332
+ border-left-color: var(--blue);
333
+ }
334
+
335
+ .feed-meta {
336
+ display: flex;
337
+ justify-content: space-between;
338
+ color: rgba(31, 42, 44, 0.58);
339
+ font-size: 0.74rem;
340
+ margin-bottom: 5px;
341
+ }
342
+
343
+ .feed-meta em {
344
+ font-style: normal;
345
+ }
346
+
347
+ .feed-text {
348
+ line-height: 1.48;
349
+ }
350
+
351
+ .soft-feed b {
352
+ display: inline-block;
353
+ margin-top: 7px;
354
+ font-size: 0.7rem;
355
+ color: var(--moss);
356
+ letter-spacing: 0.08em;
357
+ }
358
+
359
+ .citation-list {
360
+ padding: 14px;
361
+ display: grid;
362
+ gap: 10px;
363
+ }
364
+
365
+ .citation-card,
366
+ .citation-empty {
367
+ display: block;
368
+ text-decoration: none;
369
+ color: var(--ink);
370
+ padding: 12px;
371
+ border-radius: 6px;
372
+ border: 1px solid rgba(31, 42, 44, 0.13);
373
+ background: rgba(255, 255, 255, 0.38);
374
+ }
375
+
376
+ .citation-card strong {
377
+ display: block;
378
+ font-family: Georgia, "Times New Roman", serif;
379
+ font-size: 1.02rem;
380
+ font-weight: 500;
381
+ }
382
+
383
+ .citation-card span,
384
+ .citation-card em,
385
+ .citation-empty {
386
+ display: block;
387
+ margin-top: 5px;
388
+ font-size: 0.78rem;
389
+ color: rgba(31, 42, 44, 0.62);
390
+ font-style: normal;
391
+ }
392
+
393
+ .disclaimer {
394
+ margin: auto 14px 14px;
395
+ padding-top: 14px;
396
+ border-top: 1px solid var(--line);
397
+ color: rgba(31, 42, 44, 0.64);
398
+ font-size: 0.82rem;
399
+ line-height: 1.45;
400
+ }
401
+
402
+ @media (max-width: 980px) {
403
+ .calm-hero,
404
+ .companion-grid {
405
+ grid-template-columns: 1fr;
406
+ }
407
+
408
+ .session-chip {
409
+ justify-items: start;
410
+ grid-template-columns: repeat(3, max-content);
411
+ padding-top: 0;
412
+ }
413
+
414
+ .breath-dial {
415
+ width: 150px;
416
+ }
417
+ }
418
+
419
+ @media (max-width: 640px) {
420
+ .decompress-room,
421
+ #decompress-control-row {
422
+ width: min(100vw - 20px, 1180px);
423
+ }
424
+
425
+ .hero-copy h1 {
426
+ font-size: 2.4rem;
427
+ }
428
+
429
+ .session-chip {
430
+ grid-template-columns: 1fr;
431
+ }
432
+ }
apps/decompress/static/decompress.js ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ (() => {
2
+ const scrollTranscript = () => {
3
+ const pane = document.querySelector("#decompress-room .decompress-scroll");
4
+ if (pane) pane.scrollTop = pane.scrollHeight;
5
+ };
6
+
7
+ const observer = new MutationObserver(scrollTranscript);
8
+ const start = () => {
9
+ const root = document.querySelector("#decompress-room-output");
10
+ if (!root) return;
11
+ observer.observe(root, { childList: true, subtree: true });
12
+ scrollTranscript();
13
+ };
14
+
15
+ if (document.readyState === "loading") {
16
+ document.addEventListener("DOMContentLoaded", start);
17
+ } else {
18
+ start();
19
+ }
20
+ })();
engine/CONTROLLER_NOTES.md ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # WhenToSpeak Controller Notes
2
+
3
+ The controller is training-free. It consumes signals from a `Brain` interface and
4
+ never loads or calls a model itself. The live brain must provide, for each
5
+ incremental transcript update and each agent context, mean token surprise, a
6
+ last-layer hidden vector, readiness, and turn-end probability.
7
+
8
+ ## Signals
9
+
10
+ - `surprise`: mean per-token negative log-likelihood of the newly added user
11
+ tokens, teacher-forced.
12
+ - `hidden`: mean last-layer hidden-state vector for the newly added tokens.
13
+ - `readiness`: speculative reply confidence for this agent. Draft about eight
14
+ tokens and compute `readiness = 1 / (1 + mean_token_entropy)`.
15
+ - `p_end`: heuristic probability that the human turn is complete. The live loop
16
+ should combine trailing silence, sentence-final punctuation, and high EOS
17
+ probability.
18
+
19
+ ## Urge
20
+
21
+ Each agent keeps an online running mean/std of surprise and uses the current
22
+ z-score. Hidden-state cosine deltas feed Adams-MacKay BOCPD; a collapse in MAP
23
+ run-length becomes the change-point score.
24
+
25
+ ```text
26
+ U_t = w_surprise*z(surprise)
27
+ + w_change*changepoint_score
28
+ + w_readiness*readiness
29
+ + w_end*p_end
30
+ + w_barge*max(z(surprise), 0)*readiness*(1 - p_end)
31
+ ```
32
+
33
+ `tau` is the single global conversational-aggressiveness knob. Lower `tau` makes
34
+ the panel take the floor sooner; higher `tau` makes it wait.
35
+
36
+ ## Arbitration
37
+
38
+ Each tick is deterministic. Agents first classify local intent as `SILENT`,
39
+ `BACKCHANNEL`, `TAKE_FLOOR`, or `INTERRUPT`. Only the highest-urge agent above
40
+ `tau` may take the floor or interrupt. Non-winning agents may still backchannel
41
+ if their urge clears the derived backchannel threshold. A short refractory period
42
+ prevents repeated firing on adjacent ASR updates.
engine/CONVERSATION_NOTES.md ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Live Brain + Conversation Notes
2
+
3
+ This phase keeps the controller training-free and model-agnostic. The real model
4
+ lives behind `modal_app/brain_modal.py`; local code only asks for `BrainSignals`
5
+ and generated replies.
6
+
7
+ ## Flow
8
+
9
+ 1. The text stream feeds incremental word groups plus an optional silence flag.
10
+ 2. `Conversation` sends the current dialogue prefix and newest user chunk to
11
+ `LiveBrainPanel.step_all()`.
12
+ 3. Modal computes per-agent surprise, hidden vector, readiness, and `p_end`.
13
+ 4. `WhenToSpeakController` arbitrates `SILENT`, `BACKCHANNEL`, `TAKE_FLOOR`, or
14
+ `INTERRUPT`.
15
+ 5. On `TAKE_FLOOR` or `INTERRUPT`, `Conversation` calls Modal `generate()` and
16
+ splices the short investor reply into the dialogue.
17
+
18
+ The sample pitch deliberately includes a weak claim: "ten thousand stores and
19
+ zero churn after launching last week." The expected demo behavior is an investor
20
+ interrupt or floor-take near that claim, with the generated line recorded in
21
+ `eval/conversation_log.json`.
22
+
23
+ Run the real text-streamed demo with:
24
+
25
+ ```text
26
+ uv run modal run modal_app/brain_modal.py
27
+ ```
28
+
29
+ ## Modal
30
+
31
+ The live brain tries `nvidia/Llama-3.1-Nemotron-Nano-4B-v1.1` first and falls
32
+ back to `Qwen/Qwen2.5-3B-Instruct`. We keep `HF_HOME=/cache` on a Modal Volume so
33
+ weights persist across runs.
34
+
35
+ ## Recorded Demo
36
+
37
+ The committed `eval/conversation_log.json` was produced by:
38
+
39
+ ```text
40
+ uv run modal run modal_app/brain_modal.py
41
+ ```
42
+
43
+ Latest measured run: `nvidia/Llama-3.1-Nemotron-Nano-4B-v1.1` on `NVIDIA A10`.
44
+ Total wall time was 49.1 s because the run included Modal container/model startup.
45
+
46
+ At step 3 the controller interrupted the planted weak claim. The winning agent
47
+ was `ruthless_skeptic` with urge 1.47 and readiness 0.67. At step 7 the panel now
48
+ takes the floor at turn end.
49
+
50
+ ```text
51
+ Ruthless Skeptic: Zero churn after one week is not churn data.
52
+ Vision Optimist: Show cohorts, paid conversion, and retention.
53
+ ```
54
+
55
+ Generation caveat: Nemotron-Nano produced malformed text for these two `generate()`
56
+ calls, so `eval/conversation_log.json` records `reply_source: "fallback"` for both.
57
+ The timing signals and controller decisions are still real Modal/Nemotron outputs;
58
+ the fallback only guards the spoken text until the generator prompt/model is improved.
engine/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ """Training-free WhenToSpeak controller components."""
2
+
engine/bocpd.py ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ import sys
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ import numpy as np
9
+
10
+
11
+ ROOT = Path(__file__).resolve().parents[1]
12
+ DEFAULT_DUMP_PATH = ROOT / "eval" / "probe_dump.npz"
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class BocpdResult:
17
+ step: int
18
+ value: float
19
+ cp_prob: float
20
+ map_run_length: int
21
+ change_point: bool
22
+
23
+
24
+ def logsumexp(values: np.ndarray) -> float:
25
+ max_value = float(np.max(values))
26
+ if not math.isfinite(max_value):
27
+ return max_value
28
+ return max_value + math.log(float(np.sum(np.exp(values - max_value))))
29
+
30
+
31
+ def student_t_logpdf(x: float, mu: np.ndarray, kappa: np.ndarray, alpha: np.ndarray, beta: np.ndarray) -> np.ndarray:
32
+ nu = 2.0 * alpha
33
+ scale = np.sqrt(beta * (kappa + 1.0) / (alpha * kappa))
34
+ z = (x - mu) / scale
35
+ return (
36
+ np.vectorize(math.lgamma)((nu + 1.0) / 2.0)
37
+ - np.vectorize(math.lgamma)(nu / 2.0)
38
+ - 0.5 * np.log(nu * math.pi)
39
+ - np.log(scale)
40
+ - ((nu + 1.0) / 2.0) * np.log1p((z * z) / nu)
41
+ )
42
+
43
+
44
+ def update_nig(
45
+ x: float,
46
+ mu: np.ndarray,
47
+ kappa: np.ndarray,
48
+ alpha: np.ndarray,
49
+ beta: np.ndarray,
50
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
51
+ next_kappa = kappa + 1.0
52
+ next_mu = (kappa * mu + x) / next_kappa
53
+ next_alpha = alpha + 0.5
54
+ next_beta = beta + 0.5 * kappa * (x - mu) ** 2 / next_kappa
55
+ return next_mu, next_kappa, next_alpha, next_beta
56
+
57
+
58
+ def run_bocpd(
59
+ values: np.ndarray,
60
+ hazard: float = 1.0 / 50.0,
61
+ prior_mu: float = 0.0,
62
+ prior_kappa: float = 1.0e-3,
63
+ prior_alpha: float = 1.0,
64
+ prior_beta: float = 1.0,
65
+ ) -> list[BocpdResult]:
66
+ log_hazard = math.log(hazard)
67
+ log_growth_factor = math.log1p(-hazard)
68
+
69
+ log_run_probs = np.asarray([0.0], dtype=np.float64)
70
+ mu = np.asarray([prior_mu], dtype=np.float64)
71
+ kappa = np.asarray([prior_kappa], dtype=np.float64)
72
+ alpha = np.asarray([prior_alpha], dtype=np.float64)
73
+ beta = np.asarray([prior_beta], dtype=np.float64)
74
+
75
+ results: list[BocpdResult] = []
76
+ previous_map_run_length: int | None = None
77
+
78
+ for step, x_value in enumerate(values, start=1):
79
+ x = float(x_value)
80
+ predictive = student_t_logpdf(x, mu, kappa, alpha, beta)
81
+
82
+ growth_probs = log_run_probs + predictive + log_growth_factor
83
+ cp_prob = logsumexp(log_run_probs + predictive + log_hazard)
84
+ new_log_run_probs = np.concatenate(([cp_prob], growth_probs))
85
+ normalizer = logsumexp(new_log_run_probs)
86
+ new_log_run_probs -= normalizer
87
+
88
+ grown_mu, grown_kappa, grown_alpha, grown_beta = update_nig(x, mu, kappa, alpha, beta)
89
+ mu = np.concatenate(([prior_mu], grown_mu))
90
+ kappa = np.concatenate(([prior_kappa], grown_kappa))
91
+ alpha = np.concatenate(([prior_alpha], grown_alpha))
92
+ beta = np.concatenate(([prior_beta], grown_beta))
93
+ log_run_probs = new_log_run_probs
94
+
95
+ map_run_length = int(np.argmax(log_run_probs))
96
+ cp_probability = float(np.exp(log_run_probs[0]))
97
+ change_point = bool(
98
+ map_run_length == 0
99
+ or (previous_map_run_length is not None and map_run_length < previous_map_run_length)
100
+ )
101
+ results.append(
102
+ BocpdResult(
103
+ step=step,
104
+ value=x,
105
+ cp_prob=cp_probability,
106
+ map_run_length=map_run_length,
107
+ change_point=change_point,
108
+ )
109
+ )
110
+ previous_map_run_length = map_run_length
111
+
112
+ return results
113
+
114
+
115
+ def main() -> None:
116
+ dump_path = Path(sys.argv[1]) if len(sys.argv) > 1 else DEFAULT_DUMP_PATH
117
+ if not dump_path.is_absolute():
118
+ dump_path = ROOT / dump_path
119
+
120
+ if not dump_path.exists():
121
+ print(f"No probe dump found at {dump_path}")
122
+ return
123
+
124
+ dump = np.load(dump_path, allow_pickle=True)
125
+ failure = str(dump.get("failure", ""))
126
+ values = np.asarray(dump["nll_series"], dtype=np.float64)
127
+ if values.size == 0:
128
+ print("No NLL samples found; skipping BOCPD.")
129
+ if failure:
130
+ print(f"Probe failure: {failure}")
131
+ return
132
+
133
+ print("step | nll | cp_prob | map_run_length | change_point")
134
+ print("-----|-----|---------|----------------|-------------")
135
+ for result in run_bocpd(values):
136
+ flag = "YES" if result.change_point else "no"
137
+ print(
138
+ f"{result.step:>4} | {result.value:.4f} | {result.cp_prob:.4f} | "
139
+ f"{result.map_run_length:>14} | {flag}"
140
+ )
141
+
142
+
143
+ if __name__ == "__main__":
144
+ main()
engine/brain.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import Iterable, Protocol, Sequence
6
+
7
+ import numpy as np
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class BrainSignals:
12
+ """Signals emitted by one agent-context brain for one transcript update.
13
+
14
+ `readiness` is the controller-facing score for a speculative short reply:
15
+
16
+ readiness = 1 / (1 + mean_token_entropy)
17
+
18
+ A low-entropy draft means this agent has a confident next move. `p_end` is a
19
+ turn-completion heuristic: in the live loop it should combine trailing
20
+ silence, sentence-final punctuation, and/or high EOS probability.
21
+ """
22
+
23
+ surprise: float
24
+ hidden: np.ndarray
25
+ readiness: float
26
+ p_end: float
27
+
28
+ def __post_init__(self) -> None:
29
+ hidden = np.asarray(self.hidden, dtype=np.float32)
30
+ if hidden.ndim != 1:
31
+ raise ValueError("hidden must be a 1-D float32 vector")
32
+ if not 0.0 <= float(self.readiness) <= 1.0:
33
+ raise ValueError("readiness must be in [0, 1]")
34
+ if not 0.0 <= float(self.p_end) <= 1.0:
35
+ raise ValueError("p_end must be in [0, 1]")
36
+ object.__setattr__(self, "hidden", hidden)
37
+ object.__setattr__(self, "surprise", float(self.surprise))
38
+ object.__setattr__(self, "readiness", float(self.readiness))
39
+ object.__setattr__(self, "p_end", float(self.p_end))
40
+
41
+
42
+ class Brain(Protocol):
43
+ """Interface the live instrumented LLM must satisfy for one agent context."""
44
+
45
+ def next_signals(self) -> BrainSignals:
46
+ """Return signals for the newest incremental transcript update."""
47
+
48
+
49
+ class ReplayBrain:
50
+ """Deterministic `Brain` backed by precomputed signal samples."""
51
+
52
+ def __init__(self, samples: Sequence[BrainSignals | dict[str, object]], *, name: str = "replay") -> None:
53
+ self.name = name
54
+ self._samples = [coerce_signals(sample) for sample in samples]
55
+ self._index = 0
56
+
57
+ def __len__(self) -> int:
58
+ return len(self._samples)
59
+
60
+ def __iter__(self) -> Iterable[BrainSignals]:
61
+ return iter(self._samples)
62
+
63
+ def reset(self) -> None:
64
+ self._index = 0
65
+
66
+ def next_signals(self) -> BrainSignals:
67
+ if self._index >= len(self._samples):
68
+ raise StopIteration(f"ReplayBrain {self.name!r} is exhausted")
69
+ sample = self._samples[self._index]
70
+ self._index += 1
71
+ return sample
72
+
73
+ @classmethod
74
+ def from_npz(
75
+ cls,
76
+ path: str | Path,
77
+ *,
78
+ readiness: Sequence[float] | None = None,
79
+ p_end: Sequence[float] | None = None,
80
+ name: str | None = None,
81
+ ) -> "ReplayBrain":
82
+ dump = np.load(path, allow_pickle=True)
83
+ surprises = np.asarray(dump["nll_series"], dtype=np.float32)
84
+ hidden = np.asarray(dump["hidden_states"], dtype=np.float32)
85
+ if hidden.ndim != 2:
86
+ raise ValueError("hidden_states in npz must be a 2-D matrix")
87
+
88
+ n_steps = int(surprises.shape[0])
89
+ readiness_values = (
90
+ np.asarray(readiness, dtype=np.float32)
91
+ if readiness is not None
92
+ else np.linspace(0.35, 0.75, n_steps, dtype=np.float32)
93
+ )
94
+ p_end_values = np.asarray(p_end, dtype=np.float32) if p_end is not None else np.zeros(n_steps, dtype=np.float32)
95
+ if n_steps:
96
+ p_end_values[-1] = max(float(p_end_values[-1]), 0.95)
97
+
98
+ samples = [
99
+ BrainSignals(
100
+ surprise=float(surprises[index]),
101
+ hidden=hidden[index],
102
+ readiness=float(readiness_values[index]),
103
+ p_end=float(p_end_values[index]),
104
+ )
105
+ for index in range(n_steps)
106
+ ]
107
+ return cls(samples, name=name or Path(path).stem)
108
+
109
+
110
+ def coerce_signals(sample: BrainSignals | dict[str, object]) -> BrainSignals:
111
+ if isinstance(sample, BrainSignals):
112
+ return sample
113
+ return BrainSignals(
114
+ surprise=float(sample["surprise"]),
115
+ hidden=np.asarray(sample["hidden"], dtype=np.float32),
116
+ readiness=float(sample["readiness"]),
117
+ p_end=float(sample["p_end"]),
118
+ )
119
+
engine/controller.py ADDED
@@ -0,0 +1,321 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from enum import Enum
5
+ from typing import Mapping
6
+
7
+ import numpy as np
8
+
9
+ from engine.bocpd import run_bocpd
10
+ from engine.brain import BrainSignals, coerce_signals
11
+
12
+
13
+ class Action(str, Enum):
14
+ SILENT = "SILENT"
15
+ BACKCHANNEL = "BACKCHANNEL"
16
+ TAKE_FLOOR = "TAKE_FLOOR"
17
+ INTERRUPT = "INTERRUPT"
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class ControllerConfig:
22
+ w_surprise: float = 0.70
23
+ w_change: float = 1.30
24
+ w_readiness: float = 0.80
25
+ w_end: float = 1.20
26
+ w_barge: float = 0.60
27
+ negative_surprise_weight: float = 0.25
28
+ tau: float = 1.60
29
+ backchannel_tau_fraction: float = 0.70
30
+ barge_tau_fraction: float = 0.50
31
+ take_floor_p_end: float = 0.70
32
+ interrupt_p_end_max: float = 0.35
33
+ backchannel_p_end_max: float = 0.35
34
+ min_readiness: float = 0.45
35
+ refractory_steps: int = 2
36
+ surprise_z_cap: float = 3.0
37
+ change_hazard: float = 0.35
38
+ change_prior_kappa: float = 0.20
39
+ change_prior_alpha: float = 0.75
40
+ change_prior_beta: float = 0.20
41
+ change_z_cap: float = 3.0
42
+ change_z_threshold: float = 1.15
43
+ turn_end_tau_discount: float = 0.35
44
+
45
+ @property
46
+ def backchannel_tau(self) -> float:
47
+ return self.backchannel_tau_fraction * self.tau
48
+
49
+ @property
50
+ def barge_tau(self) -> float:
51
+ return self.barge_tau_fraction * self.tau
52
+
53
+
54
+ @dataclass
55
+ class RunningStats:
56
+ n: int = 0
57
+ mean: float = 0.0
58
+ m2: float = 0.0
59
+
60
+ @property
61
+ def std(self) -> float:
62
+ if self.n < 2:
63
+ return 1.0
64
+ return max((self.m2 / (self.n - 1)) ** 0.5, 1.0e-6)
65
+
66
+ def zscore(self, value: float, cap: float) -> float:
67
+ if self.n < 2:
68
+ return 0.0
69
+ z_value = (float(value) - self.mean) / self.std
70
+ return float(np.clip(z_value, -cap, cap))
71
+
72
+ def update(self, value: float) -> None:
73
+ self.n += 1
74
+ delta = float(value) - self.mean
75
+ self.mean += delta / self.n
76
+ self.m2 += delta * (float(value) - self.mean)
77
+
78
+
79
+ @dataclass
80
+ class AgentState:
81
+ surprise_stats: RunningStats = field(default_factory=RunningStats)
82
+ previous_hidden: np.ndarray | None = None
83
+ hidden_deltas: list[float] = field(default_factory=list)
84
+ hidden_delta_z: list[float] = field(default_factory=list)
85
+ hidden_delta_stats: RunningStats = field(default_factory=RunningStats)
86
+ previous_map_run_length: int | None = None
87
+ refractory_until: int = 0
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class AgentDecision:
92
+ agent_id: str
93
+ action: Action
94
+ urge: float
95
+ z_surprise: float
96
+ change_score: float
97
+ readiness: float
98
+ p_end: float
99
+ hidden_delta: float
100
+ map_run_length: int
101
+ refractory: bool
102
+
103
+
104
+ @dataclass(frozen=True)
105
+ class ControllerTick:
106
+ step: int
107
+ floor_holder: str
108
+ winner: str | None
109
+ decisions: dict[str, AgentDecision]
110
+
111
+
112
+ class WhenToSpeakController:
113
+ """Training-free multi-agent timing controller."""
114
+
115
+ def __init__(self, agent_ids: list[str], config: ControllerConfig | None = None) -> None:
116
+ if not agent_ids:
117
+ raise ValueError("agent_ids must not be empty")
118
+ self.agent_ids = list(agent_ids)
119
+ self.config = config or ControllerConfig()
120
+ self.states = {agent_id: AgentState() for agent_id in self.agent_ids}
121
+ self.step = 0
122
+ self.floor_holder = "human"
123
+
124
+ def reset(self) -> None:
125
+ self.states = {agent_id: AgentState() for agent_id in self.agent_ids}
126
+ self.step = 0
127
+ self.floor_holder = "human"
128
+
129
+ def tick(
130
+ self,
131
+ signals_by_agent: Mapping[str, BrainSignals | dict[str, object]],
132
+ *,
133
+ floor_holder: str | None = None,
134
+ ) -> ControllerTick:
135
+ self.step += 1
136
+ if floor_holder is not None:
137
+ self.floor_holder = floor_holder
138
+
139
+ scored: dict[str, AgentDecision] = {}
140
+ proposed: dict[str, Action] = {}
141
+ for agent_id in self.agent_ids:
142
+ if agent_id not in signals_by_agent:
143
+ raise KeyError(f"missing signals for agent {agent_id!r}")
144
+ signal = coerce_signals(signals_by_agent[agent_id])
145
+ decision = self._score_agent(agent_id, signal)
146
+ scored[agent_id] = decision
147
+ proposed[agent_id] = decision.action
148
+
149
+ winner = self._floor_winner(scored)
150
+ final_decisions: dict[str, AgentDecision] = {}
151
+ for agent_id, decision in scored.items():
152
+ action = decision.action
153
+ if action in {Action.TAKE_FLOOR, Action.INTERRUPT} and agent_id != winner:
154
+ action = Action.BACKCHANNEL if self._may_backchannel(decision) else Action.SILENT
155
+ final_decisions[agent_id] = AgentDecision(
156
+ agent_id=decision.agent_id,
157
+ action=action,
158
+ urge=decision.urge,
159
+ z_surprise=decision.z_surprise,
160
+ change_score=decision.change_score,
161
+ readiness=decision.readiness,
162
+ p_end=decision.p_end,
163
+ hidden_delta=decision.hidden_delta,
164
+ map_run_length=decision.map_run_length,
165
+ refractory=decision.refractory,
166
+ )
167
+
168
+ for agent_id, decision in final_decisions.items():
169
+ if decision.action in {Action.TAKE_FLOOR, Action.INTERRUPT}:
170
+ self.states[agent_id].refractory_until = self.step + self.config.refractory_steps
171
+
172
+ if winner is not None:
173
+ self.floor_holder = winner
174
+
175
+ return ControllerTick(
176
+ step=self.step,
177
+ floor_holder=self.floor_holder,
178
+ winner=winner,
179
+ decisions=final_decisions,
180
+ )
181
+
182
+ def _score_agent(self, agent_id: str, signal: BrainSignals) -> AgentDecision:
183
+ state = self.states[agent_id]
184
+ z_surprise = state.surprise_stats.zscore(signal.surprise, self.config.surprise_z_cap)
185
+ hidden_delta, change_score, map_run_length = self._change_features(state, signal.hidden)
186
+ barge = self.config.w_barge * max(z_surprise, 0.0) * signal.readiness * (1.0 - signal.p_end)
187
+ surprise_term = z_surprise if z_surprise >= 0.0 else self.config.negative_surprise_weight * z_surprise
188
+ urge = (
189
+ self.config.w_surprise * surprise_term
190
+ + self.config.w_change * change_score
191
+ + self.config.w_readiness * signal.readiness
192
+ + self.config.w_end * signal.p_end
193
+ + barge
194
+ )
195
+
196
+ refractory = self.step <= state.refractory_until
197
+ action = self._classify(urge, z_surprise, change_score, signal, refractory)
198
+
199
+ state.surprise_stats.update(signal.surprise)
200
+ state.previous_hidden = signal.hidden.astype(np.float32, copy=True)
201
+ return AgentDecision(
202
+ agent_id=agent_id,
203
+ action=action,
204
+ urge=float(urge),
205
+ z_surprise=float(z_surprise),
206
+ change_score=float(change_score),
207
+ readiness=signal.readiness,
208
+ p_end=signal.p_end,
209
+ hidden_delta=float(hidden_delta),
210
+ map_run_length=int(map_run_length),
211
+ refractory=refractory,
212
+ )
213
+
214
+ def _change_features(self, state: AgentState, hidden: np.ndarray) -> tuple[float, float, int]:
215
+ if state.previous_hidden is None:
216
+ return 0.0, 0.0, 0
217
+
218
+ hidden_delta = cosine_distance(state.previous_hidden, hidden)
219
+ delta_z = state.hidden_delta_stats.zscore(hidden_delta, self.config.change_z_cap)
220
+ state.hidden_delta_stats.update(hidden_delta)
221
+ state.hidden_deltas.append(hidden_delta)
222
+ state.hidden_delta_z.append(delta_z)
223
+ results = run_bocpd(
224
+ np.asarray(state.hidden_delta_z, dtype=np.float64),
225
+ hazard=self.config.change_hazard,
226
+ prior_kappa=self.config.change_prior_kappa,
227
+ prior_alpha=self.config.change_prior_alpha,
228
+ prior_beta=self.config.change_prior_beta,
229
+ )
230
+ latest = results[-1]
231
+ previous_map = state.previous_map_run_length
232
+ state.previous_map_run_length = latest.map_run_length
233
+ if previous_map is None:
234
+ return hidden_delta, 0.0, latest.map_run_length
235
+
236
+ collapsed = latest.map_run_length < previous_map
237
+ collapse_ratio = (previous_map - latest.map_run_length) / max(previous_map, 1)
238
+ collapse_score = max(1.0, collapse_ratio) if collapsed else 0.0
239
+ posterior_score = max(0.0, (latest.cp_prob - self.config.change_hazard) / max(1.0 - self.config.change_hazard, 1.0e-9))
240
+ z_score = max(0.0, abs(delta_z) - self.config.change_z_threshold) / max(
241
+ self.config.change_z_cap - self.config.change_z_threshold,
242
+ 1.0e-9,
243
+ )
244
+ change_score = max(collapse_score, posterior_score, z_score)
245
+ return hidden_delta, float(change_score), latest.map_run_length
246
+
247
+ def _classify(
248
+ self,
249
+ urge: float,
250
+ z_surprise: float,
251
+ change_score: float,
252
+ signal: BrainSignals,
253
+ refractory: bool,
254
+ ) -> Action:
255
+ if refractory:
256
+ return Action.SILENT
257
+
258
+ ready = signal.readiness >= self.config.min_readiness
259
+ human_has_floor = self.floor_holder == "human"
260
+ barge_signal = max(z_surprise, 0.0) * signal.readiness * (1.0 - signal.p_end)
261
+ floor_tau = self._effective_tau(signal.p_end)
262
+
263
+ if human_has_floor and ready and signal.p_end >= self.config.take_floor_p_end and urge >= floor_tau:
264
+ return Action.TAKE_FLOOR
265
+ if (
266
+ human_has_floor
267
+ and ready
268
+ and signal.p_end <= self.config.interrupt_p_end_max
269
+ and urge >= floor_tau
270
+ and barge_signal >= self.config.barge_tau
271
+ ):
272
+ return Action.INTERRUPT
273
+ if (
274
+ human_has_floor
275
+ and ready
276
+ and signal.p_end <= self.config.backchannel_p_end_max
277
+ and change_score > 0.0
278
+ and urge >= self.config.backchannel_tau
279
+ ):
280
+ return Action.BACKCHANNEL
281
+ if (
282
+ human_has_floor
283
+ and ready
284
+ and urge >= self.config.backchannel_tau
285
+ and signal.p_end <= self.config.backchannel_p_end_max
286
+ ):
287
+ return Action.BACKCHANNEL
288
+ return Action.SILENT
289
+
290
+ def _floor_winner(self, decisions: Mapping[str, AgentDecision]) -> str | None:
291
+ contenders = [
292
+ decision
293
+ for decision in decisions.values()
294
+ if decision.action in {Action.TAKE_FLOOR, Action.INTERRUPT}
295
+ and decision.urge >= self._effective_tau(decision.p_end)
296
+ ]
297
+ if not contenders:
298
+ return None
299
+ return max(contenders, key=lambda decision: (decision.urge, -self.agent_ids.index(decision.agent_id))).agent_id
300
+
301
+ def _may_backchannel(self, decision: AgentDecision) -> bool:
302
+ return (
303
+ not decision.refractory
304
+ and self.floor_holder == "human"
305
+ and decision.urge >= self.config.backchannel_tau
306
+ and decision.p_end <= self.config.backchannel_p_end_max
307
+ )
308
+
309
+ def _effective_tau(self, p_end: float) -> float:
310
+ discount = self.config.turn_end_tau_discount * float(np.clip(p_end, 0.0, 1.0))
311
+ return self.config.tau * max(0.20, 1.0 - discount)
312
+
313
+
314
+ def cosine_distance(left: np.ndarray, right: np.ndarray) -> float:
315
+ left_vec = np.asarray(left, dtype=np.float32)
316
+ right_vec = np.asarray(right, dtype=np.float32)
317
+ denom = float(np.linalg.norm(left_vec) * np.linalg.norm(right_vec))
318
+ if denom <= 1.0e-12:
319
+ return 0.0
320
+ similarity = float(np.dot(left_vec, right_vec) / denom)
321
+ return float(np.clip(1.0 - similarity, 0.0, 2.0))
engine/conversation.py ADDED
@@ -0,0 +1,259 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import time
6
+ from dataclasses import asdict, dataclass
7
+ from pathlib import Path
8
+ from typing import Any, Sequence
9
+
10
+ from engine.controller import Action, ControllerConfig, ControllerTick, WhenToSpeakController
11
+ from engine.live_brain import BrainClient, Dialogue, LiveBrainPanel, Persona
12
+
13
+
14
+ ROOT = Path(__file__).resolve().parents[1]
15
+ DEFAULT_LOG_PATH = ROOT / "eval" / "conversation_log.json"
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class TranscriptChunk:
20
+ text: str
21
+ silence_flag: bool = False
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class ConversationResult:
26
+ events: list[dict[str, Any]]
27
+ dialogue: Dialogue
28
+ personas: list[Persona]
29
+ total_latency_ms: float
30
+ model_name: str
31
+ device_name: str
32
+ generated_examples: list[dict[str, Any]]
33
+
34
+
35
+ def default_personas() -> list[Persona]:
36
+ return [
37
+ Persona(
38
+ agent_id="numbers_vc",
39
+ display_name="Numbers VC",
40
+ system_prompt=(
41
+ "You are a numbers-obsessed venture investor. Be blunt, specific, and quantitative. "
42
+ "Ask for denominators, cohorts, margins, contract evidence, and arithmetic that actually closes."
43
+ ),
44
+ ),
45
+ Persona(
46
+ agent_id="vision_optimist",
47
+ display_name="Vision Optimist",
48
+ system_prompt=(
49
+ "You are a big-vision optimist. You look for the huge version of the company, but your "
50
+ "questions are crisp and founder-facing when the story needs a missing bridge."
51
+ ),
52
+ ),
53
+ Persona(
54
+ agent_id="ruthless_skeptic",
55
+ display_name="Ruthless Skeptic",
56
+ system_prompt=(
57
+ "You are a ruthless startup skeptic. Interrupt bad claims in plain English. No pleasantries, "
58
+ "no throat-clearing, no softening. Be sharp without being long."
59
+ ),
60
+ ),
61
+ ]
62
+
63
+
64
+ def sample_pitch_stream() -> list[TranscriptChunk]:
65
+ return [
66
+ TranscriptChunk("so basically our startup helps small retailers manage inventory"),
67
+ TranscriptChunk("we connect to their point of sale and purchase orders"),
68
+ TranscriptChunk("we already have ten thousand stores and zero churn after launching last week"),
69
+ TranscriptChunk("then we predict stockouts and write reorder suggestions automatically"),
70
+ TranscriptChunk("we are converting pilots into paid contracts this month"),
71
+ TranscriptChunk("so we think this becomes the operating system for local retail"),
72
+ TranscriptChunk("that's the pitch", silence_flag=True),
73
+ ]
74
+
75
+
76
+ def demo_controller_config() -> ControllerConfig:
77
+ return ControllerConfig(
78
+ tau=0.85,
79
+ min_readiness=0.08,
80
+ w_surprise=0.85,
81
+ w_barge=0.85,
82
+ w_readiness=0.75,
83
+ w_end=1.05,
84
+ backchannel_tau_fraction=0.72,
85
+ barge_tau_fraction=0.50,
86
+ turn_end_tau_discount=0.45,
87
+ )
88
+
89
+
90
+ class Conversation:
91
+ def __init__(
92
+ self,
93
+ personas: list[Persona],
94
+ brain_panel: LiveBrainPanel,
95
+ controller: WhenToSpeakController | None = None,
96
+ ) -> None:
97
+ self.personas = personas
98
+ self.brain_panel = brain_panel
99
+ self.controller = controller or WhenToSpeakController(
100
+ brain_panel.agent_ids,
101
+ config=demo_controller_config(),
102
+ )
103
+
104
+ def run(self, stream: Sequence[TranscriptChunk]) -> ConversationResult:
105
+ started = time.perf_counter()
106
+ dialogue: Dialogue = []
107
+ current_user_text = ""
108
+ events: list[dict[str, Any]] = []
109
+ generated_examples: list[dict[str, Any]] = []
110
+
111
+ for step_index, chunk in enumerate(stream, start=1):
112
+ dialogue_before = _dialogue_with_current_user(dialogue, current_user_text)
113
+ signals = self.brain_panel.step_all(dialogue_before, chunk.text, chunk.silence_flag)
114
+ current_user_text = _join_text(current_user_text, chunk.text)
115
+ tick = self.controller.tick(signals, floor_holder="human")
116
+ event = self._event(step_index, chunk, tick)
117
+
118
+ winner = tick.winner
119
+ if winner is not None:
120
+ if current_user_text:
121
+ dialogue.append({"role": "user", "speaker": "founder", "text": current_user_text})
122
+ current_user_text = ""
123
+
124
+ generated = self.brain_panel.generate(winner, dialogue)
125
+ reply_text = str(generated.get("reply_text", ""))
126
+ if reply_text:
127
+ dialogue.append({"role": "assistant", "speaker": winner, "text": reply_text})
128
+ event["generated"] = {
129
+ "agent_id": winner,
130
+ "reply_text": reply_text,
131
+ "reply_source": generated.get("reply_source"),
132
+ "raw_reply_text": generated.get("raw_reply_text"),
133
+ "latency_ms": generated.get("latency_ms"),
134
+ "model_name": generated.get("model_name"),
135
+ }
136
+ generated_examples.append(event["generated"])
137
+
138
+ events.append(event)
139
+
140
+ if current_user_text:
141
+ dialogue.append({"role": "user", "speaker": "founder", "text": current_user_text})
142
+
143
+ raw = self.brain_panel.last_raw or {}
144
+ return ConversationResult(
145
+ events=events,
146
+ dialogue=dialogue,
147
+ personas=self.personas,
148
+ total_latency_ms=(time.perf_counter() - started) * 1000.0,
149
+ model_name=str(raw.get("model_name", "")),
150
+ device_name=str(raw.get("device_name", "")),
151
+ generated_examples=generated_examples,
152
+ )
153
+
154
+ def _event(self, step_index: int, chunk: TranscriptChunk, tick: ControllerTick) -> dict[str, Any]:
155
+ raw = self.brain_panel.last_raw or {}
156
+ decisions = {}
157
+ for agent_id, decision in tick.decisions.items():
158
+ brain_raw = self.brain_panel.last_results.get(agent_id, {})
159
+ decisions[agent_id] = {
160
+ "action": decision.action.value,
161
+ "urge": decision.urge,
162
+ "z_surprise": decision.z_surprise,
163
+ "change_score": decision.change_score,
164
+ "readiness": decision.readiness,
165
+ "p_end": decision.p_end,
166
+ "hidden_delta": decision.hidden_delta,
167
+ "map_run_length": decision.map_run_length,
168
+ "brain_latency_ms": brain_raw.get("latency_ms"),
169
+ "surprise": brain_raw.get("surprise"),
170
+ }
171
+ return {
172
+ "step": step_index,
173
+ "new_user_text": chunk.text,
174
+ "silence_flag": chunk.silence_flag,
175
+ "winner": tick.winner,
176
+ "floor_holder": tick.floor_holder,
177
+ "batch_latency_ms": raw.get("batch_latency_ms"),
178
+ "model_name": raw.get("model_name"),
179
+ "device_name": raw.get("device_name"),
180
+ "decisions": decisions,
181
+ }
182
+
183
+
184
+ def save_conversation_log(result: ConversationResult, path: str | Path = DEFAULT_LOG_PATH) -> Path:
185
+ output = Path(path)
186
+ output.parent.mkdir(parents=True, exist_ok=True)
187
+ data = {
188
+ "model_name": result.model_name,
189
+ "device_name": result.device_name,
190
+ "total_latency_ms": result.total_latency_ms,
191
+ "personas": [asdict(persona) for persona in result.personas],
192
+ "events": result.events,
193
+ "dialogue": result.dialogue,
194
+ "generated_examples": result.generated_examples,
195
+ }
196
+ output.write_text(json.dumps(data, indent=2), encoding="utf-8")
197
+ return output
198
+
199
+
200
+ def readable_log(result: ConversationResult) -> str:
201
+ persona_names = {persona.agent_id: persona.display_name for persona in result.personas}
202
+ lines = [
203
+ f"Model: {result.model_name or 'unknown'} on {result.device_name or 'unknown'}",
204
+ f"Total wall latency: {result.total_latency_ms:.1f} ms",
205
+ ]
206
+ for event in result.events:
207
+ lines.append(f"[{event['step']}] USER + {event['new_user_text']!r} silence={event['silence_flag']}")
208
+ for agent_id, decision in event["decisions"].items():
209
+ action = decision["action"]
210
+ if action == Action.SILENT.value:
211
+ continue
212
+ lines.append(
213
+ " "
214
+ f"{persona_names.get(agent_id, agent_id)} -> {action} "
215
+ f"urge={decision['urge']:.2f} readiness={decision['readiness']:.2f} "
216
+ f"p_end={decision['p_end']:.2f}"
217
+ )
218
+ if "generated" in event:
219
+ generated = event["generated"]
220
+ lines.append(f" {persona_names.get(generated['agent_id'], generated['agent_id'])}: {generated['reply_text']}")
221
+ return "\n".join(lines)
222
+
223
+
224
+ def _dialogue_with_current_user(dialogue: Dialogue, current_user_text: str) -> Dialogue:
225
+ snapshot = [dict(turn) for turn in dialogue]
226
+ snapshot.append({"role": "user", "speaker": "founder", "text": current_user_text})
227
+ return snapshot
228
+
229
+
230
+ def _join_text(left: str, right: str) -> str:
231
+ left = left.strip()
232
+ right = right.strip()
233
+ if not left:
234
+ return right
235
+ if not right:
236
+ return left
237
+ return f"{left} {right}"
238
+
239
+
240
+ def run_demo(log_path: str | Path = DEFAULT_LOG_PATH, client: BrainClient | None = None) -> ConversationResult:
241
+ personas = default_personas()
242
+ panel = LiveBrainPanel(personas, client=client)
243
+ conversation = Conversation(personas, panel)
244
+ result = conversation.run(sample_pitch_stream())
245
+ save_conversation_log(result, log_path)
246
+ print(readable_log(result))
247
+ print(f"Wrote {log_path}")
248
+ return result
249
+
250
+
251
+ def main(argv: list[str] | None = None) -> None:
252
+ parser = argparse.ArgumentParser(description="Run the text-streamed WhenToSpeak conversation demo.")
253
+ parser.add_argument("--log-path", default=str(DEFAULT_LOG_PATH))
254
+ args = parser.parse_args(argv)
255
+ run_demo(args.log_path)
256
+
257
+
258
+ if __name__ == "__main__":
259
+ main()
engine/live_brain.py ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Protocol
5
+
6
+ import numpy as np
7
+
8
+ from engine.brain import Brain, BrainSignals
9
+
10
+
11
+ Dialogue = list[dict[str, str]]
12
+
13
+
14
+ class BrainClient(Protocol):
15
+ def step(
16
+ self,
17
+ agent_id: str,
18
+ system_prompt: str,
19
+ dialogue_so_far: Dialogue,
20
+ new_user_text: str,
21
+ silence_flag: bool,
22
+ ) -> dict[str, object]:
23
+ ...
24
+
25
+ def step_many(
26
+ self,
27
+ agent_payloads: list[dict[str, str]],
28
+ dialogue_so_far: Dialogue,
29
+ new_user_text: str,
30
+ silence_flag: bool,
31
+ ) -> dict[str, object]:
32
+ ...
33
+
34
+ def generate(self, agent_id: str, system_prompt: str, dialogue: Dialogue) -> dict[str, object]:
35
+ ...
36
+
37
+
38
+ class ModalBrainClient:
39
+ """Thin lazy wrapper around `modal_app.brain_modal` remote functions."""
40
+
41
+ def __init__(self) -> None:
42
+ from modal_app import brain_modal
43
+
44
+ self._brain_modal = brain_modal
45
+
46
+ def step(
47
+ self,
48
+ agent_id: str,
49
+ system_prompt: str,
50
+ dialogue_so_far: Dialogue,
51
+ new_user_text: str,
52
+ silence_flag: bool,
53
+ ) -> dict[str, object]:
54
+ return self._brain_modal.step.remote(agent_id, system_prompt, dialogue_so_far, new_user_text, silence_flag)
55
+
56
+ def step_many(
57
+ self,
58
+ agent_payloads: list[dict[str, str]],
59
+ dialogue_so_far: Dialogue,
60
+ new_user_text: str,
61
+ silence_flag: bool,
62
+ ) -> dict[str, object]:
63
+ return self._brain_modal.step_many.remote(agent_payloads, dialogue_so_far, new_user_text, silence_flag)
64
+
65
+ def generate(self, agent_id: str, system_prompt: str, dialogue: Dialogue) -> dict[str, object]:
66
+ return self._brain_modal.generate.remote(agent_id, system_prompt, dialogue)
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class Persona:
71
+ agent_id: str
72
+ display_name: str
73
+ system_prompt: str
74
+
75
+
76
+ class LiveBrain(Brain):
77
+ """One-agent Brain implementation backed by Modal."""
78
+
79
+ def __init__(self, persona: Persona, client: BrainClient | None = None) -> None:
80
+ self.persona = persona
81
+ self.client = client or ModalBrainClient()
82
+ self.last_raw: dict[str, object] | None = None
83
+ self._latest: BrainSignals | None = None
84
+
85
+ def step(self, dialogue_so_far: Dialogue, new_user_text: str, silence_flag: bool) -> BrainSignals:
86
+ raw = self.client.step(
87
+ self.persona.agent_id,
88
+ self.persona.system_prompt,
89
+ dialogue_so_far,
90
+ new_user_text,
91
+ silence_flag,
92
+ )
93
+ self.last_raw = raw
94
+ self._latest = signals_from_raw(raw)
95
+ return self._latest
96
+
97
+ def next_signals(self) -> BrainSignals:
98
+ if self._latest is None:
99
+ raise RuntimeError("LiveBrain has no queued signals; call step() first")
100
+ return self._latest
101
+
102
+ def generate(self, dialogue: Dialogue) -> dict[str, object]:
103
+ return self.client.generate(self.persona.agent_id, self.persona.system_prompt, dialogue)
104
+
105
+
106
+ class LiveBrainPanel:
107
+ """Batched Modal brain calls for a multi-agent panel."""
108
+
109
+ def __init__(self, personas: list[Persona], client: BrainClient | None = None) -> None:
110
+ if not personas:
111
+ raise ValueError("personas must not be empty")
112
+ self.personas = personas
113
+ self.client = client or ModalBrainClient()
114
+ self.last_raw: dict[str, object] | None = None
115
+ self.last_results: dict[str, dict[str, object]] = {}
116
+
117
+ @property
118
+ def agent_ids(self) -> list[str]:
119
+ return [persona.agent_id for persona in self.personas]
120
+
121
+ def step_all(self, dialogue_so_far: Dialogue, new_user_text: str, silence_flag: bool) -> dict[str, BrainSignals]:
122
+ payloads = [
123
+ {"agent_id": persona.agent_id, "system_prompt": persona.system_prompt}
124
+ for persona in self.personas
125
+ ]
126
+ raw = self.client.step_many(payloads, dialogue_so_far, new_user_text, silence_flag)
127
+ self.last_raw = raw
128
+ results = raw.get("results", {}) if isinstance(raw, dict) else {}
129
+ self.last_results = {agent_id: dict(result) for agent_id, result in results.items()}
130
+ return {agent_id: signals_from_raw(result) for agent_id, result in self.last_results.items()}
131
+
132
+ def generate(self, agent_id: str, dialogue: Dialogue) -> dict[str, object]:
133
+ persona = self.persona(agent_id)
134
+ return self.client.generate(agent_id, persona.system_prompt, dialogue)
135
+
136
+ def persona(self, agent_id: str) -> Persona:
137
+ for persona in self.personas:
138
+ if persona.agent_id == agent_id:
139
+ return persona
140
+ raise KeyError(f"unknown persona {agent_id!r}")
141
+
142
+
143
+ def signals_from_raw(raw: dict[str, object]) -> BrainSignals:
144
+ if not raw.get("ok", False):
145
+ raise RuntimeError(str(raw.get("failure", "brain call failed")))
146
+ return BrainSignals(
147
+ surprise=float(raw["surprise"]),
148
+ hidden=np.asarray(raw["hidden"], dtype=np.float32),
149
+ readiness=float(raw["readiness"]),
150
+ p_end=float(raw["p_end"]),
151
+ )
engine/probe.py ADDED
@@ -0,0 +1,240 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import shutil
5
+ import time
6
+ from pathlib import Path
7
+
8
+ import numpy as np
9
+ import torch
10
+ import torch.nn.functional as F
11
+ from transformers import AutoModelForCausalLM, AutoTokenizer
12
+
13
+
14
+ ROOT = Path(__file__).resolve().parents[1]
15
+ EVAL_DIR = ROOT / "eval"
16
+ DUMP_PATH = EVAL_DIR / "probe_dump.npz"
17
+ CANDIDATE_MODELS = [
18
+ "nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16",
19
+ "Qwen/Qwen2.5-3B-Instruct",
20
+ "openbmb/MiniCPM3-4B",
21
+ ]
22
+ TRANSCRIPT_CHUNKS = [
23
+ "so basically",
24
+ "our startup uses",
25
+ "ai to help",
26
+ "small businesses",
27
+ "manage inventory",
28
+ "and we think",
29
+ "the market is huge",
30
+ "and honestly",
31
+ "we already have",
32
+ "like a thousand",
33
+ "users and",
34
+ "growing fast",
35
+ ]
36
+ MIN_MODEL_DOWNLOAD_FREE_BYTES = 6 * 1024**3
37
+
38
+
39
+ def configure_local_caches() -> None:
40
+ os.environ.setdefault("HF_HOME", str(ROOT / ".hf-cache"))
41
+ os.environ.setdefault("TRANSFORMERS_CACHE", str(ROOT / ".hf-cache" / "transformers"))
42
+ os.environ.setdefault("TORCH_HOME", str(ROOT / ".torch-cache"))
43
+
44
+
45
+ def cuda_summary() -> torch.device:
46
+ print(f"torch.__version__ = {torch.__version__}")
47
+ print(f"torch.version.cuda = {torch.version.cuda}")
48
+ print(f"torch.cuda.is_available() = {torch.cuda.is_available()}")
49
+ if torch.cuda.is_available():
50
+ print(f"torch.cuda.get_device_name(0) = {torch.cuda.get_device_name(0)}")
51
+ return torch.device("cuda:0")
52
+
53
+ print("LOUD CUDA FALLBACK: CUDA/Blackwell is not available in this torch environment; using CPU.")
54
+ return torch.device("cpu")
55
+
56
+
57
+ def common_prefix_len(previous: list[int], current: list[int]) -> int:
58
+ length = 0
59
+ for left, right in zip(previous, current):
60
+ if left != right:
61
+ break
62
+ length += 1
63
+ return length
64
+
65
+
66
+ def save_failure(failure: str) -> None:
67
+ EVAL_DIR.mkdir(parents=True, exist_ok=True)
68
+ np.savez(
69
+ DUMP_PATH,
70
+ nll_series=np.asarray([], dtype=np.float32),
71
+ hidden_states=np.empty((0, 0), dtype=np.float32),
72
+ update_ms=np.asarray([], dtype=np.float32),
73
+ added_text=np.asarray([], dtype=object),
74
+ model=np.asarray("", dtype=object),
75
+ device=np.asarray("cpu", dtype=object),
76
+ dtype=np.asarray("", dtype=object),
77
+ failure=np.asarray(failure, dtype=object),
78
+ )
79
+
80
+
81
+ def load_first_model(device: torch.device) -> tuple[object, object, str, float, float] | None:
82
+ free_bytes = shutil.disk_usage(ROOT).free
83
+ local_files_only = free_bytes < MIN_MODEL_DOWNLOAD_FREE_BYTES
84
+ if local_files_only:
85
+ free_gib = free_bytes / 1024**3
86
+ needed_gib = MIN_MODEL_DOWNLOAD_FREE_BYTES / 1024**3
87
+ print(
88
+ "LOUD MODEL DOWNLOAD SKIP: only "
89
+ f"{free_gib:.2f} GiB free; need at least {needed_gib:.1f} GiB to attempt these 3B/4B model downloads. "
90
+ "Trying repo-local cache only."
91
+ )
92
+
93
+ if device.type == "cuda":
94
+ dtype = torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16
95
+ else:
96
+ dtype = torch.float32
97
+
98
+ failures: list[str] = []
99
+ for model_id in CANDIDATE_MODELS:
100
+ print(f"Attempting model: {model_id}")
101
+ if device.type == "cuda":
102
+ torch.cuda.empty_cache()
103
+ torch.cuda.reset_peak_memory_stats(0)
104
+
105
+ start = time.perf_counter()
106
+ try:
107
+ tokenizer = AutoTokenizer.from_pretrained(
108
+ model_id,
109
+ trust_remote_code=True,
110
+ local_files_only=local_files_only,
111
+ )
112
+ load_kwargs = {
113
+ "trust_remote_code": True,
114
+ "torch_dtype": dtype,
115
+ "low_cpu_mem_usage": True,
116
+ "local_files_only": local_files_only,
117
+ }
118
+ if device.type == "cuda":
119
+ load_kwargs["device_map"] = {"": 0}
120
+
121
+ model = AutoModelForCausalLM.from_pretrained(model_id, **load_kwargs)
122
+ if device.type == "cpu":
123
+ model.to(device)
124
+ model.eval()
125
+
126
+ load_seconds = time.perf_counter() - start
127
+ actual_device = next(model.parameters()).device
128
+ actual_dtype = next(model.parameters()).dtype
129
+ vram_gib = 0.0
130
+ if device.type == "cuda":
131
+ torch.cuda.synchronize()
132
+ vram_gib = torch.cuda.memory_allocated(0) / 1024**3
133
+ print(
134
+ "LOADED "
135
+ f"model={model_id} device={actual_device} dtype={actual_dtype} "
136
+ f"load_seconds={load_seconds:.2f} vram_used_gib={vram_gib:.2f}"
137
+ )
138
+ return tokenizer, model, model_id, load_seconds, vram_gib
139
+ except Exception as exc: # noqa: BLE001 - spike should continue through model fallbacks.
140
+ elapsed = time.perf_counter() - start
141
+ message = f"{model_id} failed after {elapsed:.2f}s: {type(exc).__name__}: {exc}"
142
+ print(message)
143
+ failures.append(message)
144
+
145
+ failure = "No candidate model loaded. " + " | ".join(failures)
146
+ print(f"LOUD PROBE FAILURE: {failure}")
147
+ save_failure(failure)
148
+ return None
149
+
150
+
151
+ def run_updates(tokenizer: object, model: object, model_id: str) -> None:
152
+ device = next(model.parameters()).device
153
+ previous_ids: list[int] = []
154
+ prefixes: list[str] = []
155
+ running = ""
156
+ for chunk in TRANSCRIPT_CHUNKS:
157
+ running = f"{running} {chunk}".strip()
158
+ prefixes.append(running)
159
+
160
+ nll_series: list[float] = []
161
+ hidden_rows: list[np.ndarray] = []
162
+ update_ms: list[float] = []
163
+ added_text: list[str] = []
164
+
165
+ print("step | added_text | mean_NLL | hidden_dim | update_ms")
166
+ print("-----|------------|----------|------------|----------")
167
+ for step, (chunk, prefix) in enumerate(zip(TRANSCRIPT_CHUNKS, prefixes), start=1):
168
+ if device.type == "cuda":
169
+ torch.cuda.synchronize()
170
+ start = time.perf_counter()
171
+
172
+ encoded = tokenizer(prefix, return_tensors="pt", add_special_tokens=False)
173
+ current_ids = encoded["input_ids"][0].tolist()
174
+ new_start = common_prefix_len(previous_ids, current_ids)
175
+ inputs = {name: tensor.to(device) for name, tensor in encoded.items()}
176
+
177
+ with torch.inference_mode():
178
+ outputs = model(**inputs, output_hidden_states=True)
179
+
180
+ input_ids = inputs["input_ids"]
181
+ logits = outputs.logits[:, :-1, :].float()
182
+ targets = input_ids[:, 1:]
183
+ token_nll = F.cross_entropy(
184
+ logits.reshape(-1, logits.shape[-1]),
185
+ targets.reshape(-1),
186
+ reduction="none",
187
+ ).reshape(targets.shape)
188
+
189
+ nll_start = max(new_start, 1) - 1
190
+ new_nll = token_nll[0, nll_start:]
191
+ mean_nll = float(new_nll.mean().detach().cpu()) if new_nll.numel() else float("nan")
192
+
193
+ last_hidden = outputs.hidden_states[-1][0]
194
+ new_hidden = last_hidden[new_start:, :]
195
+ mean_hidden = new_hidden.float().mean(dim=0).detach().cpu()
196
+
197
+ if device.type == "cuda":
198
+ torch.cuda.synchronize()
199
+ elapsed_ms = (time.perf_counter() - start) * 1000.0
200
+
201
+ hidden_vec = mean_hidden.numpy().astype(np.float32)
202
+ nll_series.append(mean_nll)
203
+ hidden_rows.append(hidden_vec)
204
+ update_ms.append(elapsed_ms)
205
+ added_text.append(chunk)
206
+ previous_ids = current_ids
207
+
208
+ print(f"{step:>4} | {chunk} | {mean_nll:.4f} | {hidden_vec.shape[0]} | {elapsed_ms:.2f}")
209
+
210
+ EVAL_DIR.mkdir(parents=True, exist_ok=True)
211
+ hidden_matrix = np.vstack(hidden_rows).astype(np.float32)
212
+ actual_dtype = str(next(model.parameters()).dtype)
213
+ np.savez(
214
+ DUMP_PATH,
215
+ nll_series=np.asarray(nll_series, dtype=np.float32),
216
+ hidden_states=hidden_matrix,
217
+ update_ms=np.asarray(update_ms, dtype=np.float32),
218
+ added_text=np.asarray(added_text, dtype=object),
219
+ model=np.asarray(model_id, dtype=object),
220
+ device=np.asarray(str(device), dtype=object),
221
+ dtype=np.asarray(actual_dtype, dtype=object),
222
+ failure=np.asarray("", dtype=object),
223
+ )
224
+ print(f"Saved {DUMP_PATH}")
225
+
226
+
227
+ def main() -> None:
228
+ configure_local_caches()
229
+ EVAL_DIR.mkdir(parents=True, exist_ok=True)
230
+ device = cuda_summary()
231
+ loaded = load_first_model(device)
232
+ if loaded is None:
233
+ return
234
+
235
+ tokenizer, model, model_id, _load_seconds, _vram_gib = loaded
236
+ run_updates(tokenizer, model, model_id)
237
+
238
+
239
+ if __name__ == "__main__":
240
+ main()
engine/traces.py ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+ from typing import Sequence
6
+
7
+ import numpy as np
8
+
9
+ from engine.brain import BrainSignals, ReplayBrain
10
+
11
+
12
+ ROOT = Path(__file__).resolve().parents[1]
13
+ DEFAULT_MODAL_DUMP = ROOT / "eval" / "probe_dump_modal.npz"
14
+ DEFAULT_AGENTS = ("investor_a", "investor_b")
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class TraceStep:
19
+ signals_by_agent: dict[str, BrainSignals]
20
+ floor_holder: str = "human"
21
+ note: str = ""
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class SignalTrace:
26
+ name: str
27
+ steps: list[TraceStep]
28
+ expected: dict[str, object] = field(default_factory=dict)
29
+
30
+ @property
31
+ def agent_ids(self) -> list[str]:
32
+ return list(self.steps[0].signals_by_agent.keys()) if self.steps else []
33
+
34
+
35
+ def synthetic_traces() -> list[SignalTrace]:
36
+ return [
37
+ clean_monologue_trace(),
38
+ surprising_claim_trace(),
39
+ topic_shift_trace(),
40
+ rambling_pause_trace(),
41
+ ]
42
+
43
+
44
+ def load_modal_trace(path: str | Path = DEFAULT_MODAL_DUMP, agent_ids: Sequence[str] = DEFAULT_AGENTS) -> SignalTrace:
45
+ path = Path(path)
46
+ replay = ReplayBrain.from_npz(path, name="modal_probe_replay")
47
+ samples = list(replay)
48
+ steps: list[TraceStep] = []
49
+ for index, sample in enumerate(samples):
50
+ signals: dict[str, BrainSignals] = {}
51
+ for offset, agent_id in enumerate(agent_ids):
52
+ readiness = max(0.0, sample.readiness - 0.08 * offset)
53
+ signals[agent_id] = BrainSignals(
54
+ surprise=sample.surprise,
55
+ hidden=sample.hidden,
56
+ readiness=readiness,
57
+ p_end=sample.p_end,
58
+ )
59
+ steps.append(TraceStep(signals_by_agent=signals, note=f"modal step {index + 1}"))
60
+ return SignalTrace(
61
+ name="modal_probe_replay",
62
+ steps=steps,
63
+ expected={"source": str(path), "n_steps": len(steps)},
64
+ )
65
+
66
+
67
+ def all_traces() -> list[SignalTrace]:
68
+ traces = synthetic_traces()
69
+ if DEFAULT_MODAL_DUMP.exists():
70
+ traces.append(load_modal_trace(DEFAULT_MODAL_DUMP))
71
+ return traces
72
+
73
+
74
+ def clean_monologue_trace() -> SignalTrace:
75
+ base = _unit([1, 0, 0, 0, 0, 0, 0, 0])
76
+ steps = _trace_from_series(
77
+ name="clean_monologue_take_floor",
78
+ hidden_series=[_nudge(base, index, 0.01) for index in range(6)],
79
+ surprise=[2.0, 2.1, 2.0, 2.2, 2.1, 2.45],
80
+ readiness_a=[0.25, 0.30, 0.35, 0.40, 0.45, 0.78],
81
+ readiness_b=[0.20, 0.25, 0.30, 0.32, 0.35, 0.52],
82
+ p_end=[0.02, 0.03, 0.04, 0.05, 0.10, 0.95],
83
+ notes=["setup", "details", "still talking", "more context", "closing", "turn complete"],
84
+ )
85
+ return SignalTrace(name="clean_monologue_take_floor", steps=steps, expected={"take_floor_step": 6})
86
+
87
+
88
+ def surprising_claim_trace() -> SignalTrace:
89
+ base = _unit([1, 0, 0, 0, 0, 0, 0, 0])
90
+ steps = _trace_from_series(
91
+ name="surprising_claim_interrupt",
92
+ hidden_series=[_nudge(base, index, 0.01) for index in range(6)],
93
+ surprise=[2.0, 2.1, 7.3, 3.0, 2.4, 2.2],
94
+ readiness_a=[0.30, 0.42, 0.92, 0.82, 0.55, 0.45],
95
+ readiness_b=[0.25, 0.32, 0.35, 0.36, 0.38, 0.40],
96
+ p_end=[0.02, 0.04, 0.18, 0.20, 0.30, 0.55],
97
+ notes=["setup", "build", "wild claim", "continues", "settles", "handoff"],
98
+ )
99
+ return SignalTrace(name="surprising_claim_interrupt", steps=steps, expected={"interrupt_step": 3})
100
+
101
+
102
+ def topic_shift_trace() -> SignalTrace:
103
+ topic_a = _unit([1, 0, 0, 0, 0, 0, 0, 0])
104
+ topic_b = _unit([0, 1, 0, 0, 0, 0, 0, 0])
105
+ steps = _trace_from_series(
106
+ name="topic_shift_backchannel",
107
+ hidden_series=[
108
+ _nudge(topic_a, 0, 0.01),
109
+ _nudge(topic_a, 1, 0.01),
110
+ _nudge(topic_a, 2, 0.01),
111
+ _nudge(topic_a, 3, 0.01),
112
+ _nudge(topic_b, 4, 0.01),
113
+ _nudge(topic_b, 5, 0.01),
114
+ _nudge(topic_b, 6, 0.01),
115
+ ],
116
+ surprise=[2.0, 2.1, 2.0, 2.1, 2.12, 2.08, 2.06],
117
+ readiness_a=[0.25, 0.30, 0.35, 0.38, 0.50, 0.45, 0.40],
118
+ readiness_b=[0.20, 0.25, 0.28, 0.30, 0.35, 0.34, 0.34],
119
+ p_end=[0.02, 0.04, 0.06, 0.08, 0.22, 0.25, 0.28],
120
+ notes=["topic a", "topic a", "topic a", "topic a", "topic b shift", "topic b", "topic b"],
121
+ )
122
+ return SignalTrace(name="topic_shift_backchannel", steps=steps, expected={"backchannel_step": 5})
123
+
124
+
125
+ def rambling_pause_trace() -> SignalTrace:
126
+ base = _unit([0, 0, 1, 0, 0, 0, 0, 0])
127
+ steps = _trace_from_series(
128
+ name="rambling_pause_take_floor",
129
+ hidden_series=[_nudge(base, index, 0.015) for index in range(7)],
130
+ surprise=[2.1, 2.0, 2.2, 2.1, 2.0, 2.1, 2.45],
131
+ readiness_a=[0.20, 0.25, 0.30, 0.38, 0.42, 0.42, 0.80],
132
+ readiness_b=[0.18, 0.22, 0.25, 0.30, 0.32, 0.35, 0.50],
133
+ p_end=[0.02, 0.05, 0.10, 0.38, 0.42, 0.44, 0.96],
134
+ notes=["start", "ramble", "ramble", "awkward pause", "holds", "still unsure", "complete"],
135
+ )
136
+ return SignalTrace(name="rambling_pause_take_floor", steps=steps, expected={"hold_step": 4, "take_floor_step": 7})
137
+
138
+
139
+ def _trace_from_series(
140
+ *,
141
+ name: str,
142
+ hidden_series: Sequence[np.ndarray],
143
+ surprise: Sequence[float],
144
+ readiness_a: Sequence[float],
145
+ readiness_b: Sequence[float],
146
+ p_end: Sequence[float],
147
+ notes: Sequence[str],
148
+ ) -> list[TraceStep]:
149
+ steps: list[TraceStep] = []
150
+ for index, hidden in enumerate(hidden_series):
151
+ signals = {
152
+ "investor_a": BrainSignals(
153
+ surprise=surprise[index],
154
+ hidden=hidden,
155
+ readiness=readiness_a[index],
156
+ p_end=p_end[index],
157
+ ),
158
+ "investor_b": BrainSignals(
159
+ surprise=max(0.0, surprise[index] - 0.2),
160
+ hidden=hidden,
161
+ readiness=readiness_b[index],
162
+ p_end=p_end[index],
163
+ ),
164
+ }
165
+ steps.append(TraceStep(signals_by_agent=signals, note=notes[index]))
166
+ return steps
167
+
168
+
169
+ def _unit(values: Sequence[float]) -> np.ndarray:
170
+ vector = np.asarray(values, dtype=np.float32)
171
+ norm = np.linalg.norm(vector)
172
+ if norm == 0:
173
+ return vector
174
+ return vector / norm
175
+
176
+
177
+ def _nudge(base: np.ndarray, index: int, scale: float) -> np.ndarray:
178
+ vector = base.astype(np.float32, copy=True)
179
+ vector[(index % (len(vector) - 1)) + 1] += scale
180
+ return _unit(vector)
engine/viz.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import matplotlib
6
+
7
+ matplotlib.use("Agg")
8
+
9
+ import matplotlib.pyplot as plt
10
+
11
+ from engine.controller import Action, ControllerConfig, WhenToSpeakController
12
+ from engine.traces import SignalTrace, surprising_claim_trace
13
+
14
+
15
+ ROOT = Path(__file__).resolve().parents[1]
16
+ DEFAULT_OUTPUT = ROOT / "eval" / "controller_timeline.png"
17
+ ACTION_COLORS = {
18
+ Action.BACKCHANNEL: "tab:blue",
19
+ Action.TAKE_FLOOR: "tab:green",
20
+ Action.INTERRUPT: "tab:red",
21
+ }
22
+
23
+
24
+ def render_timeline(
25
+ trace: SignalTrace | None = None,
26
+ output_path: str | Path = DEFAULT_OUTPUT,
27
+ config: ControllerConfig | None = None,
28
+ ) -> Path:
29
+ trace = trace or surprising_claim_trace()
30
+ controller = WhenToSpeakController(trace.agent_ids, config=config)
31
+ ticks = [controller.tick(step.signals_by_agent, floor_holder=step.floor_holder) for step in trace.steps]
32
+
33
+ steps = [tick.step for tick in ticks]
34
+ primary = trace.agent_ids[0]
35
+ surprise = [step.signals_by_agent[primary].surprise for step in trace.steps]
36
+ readiness = [step.signals_by_agent[primary].readiness for step in trace.steps]
37
+ p_end = [step.signals_by_agent[primary].p_end for step in trace.steps]
38
+ change = [tick.decisions[primary].change_score for tick in ticks]
39
+
40
+ fig, axes = plt.subplots(4, 1, figsize=(10, 8), sharex=True)
41
+ fig.suptitle(f"WhenToSpeak controller timeline: {trace.name}", fontsize=13)
42
+
43
+ axes[0].plot(steps, surprise, marker="o", color="tab:orange", label="surprise/NLL")
44
+ axes[0].set_ylabel("surprise")
45
+ axes[0].legend(loc="upper right")
46
+
47
+ axes[1].plot(steps, change, marker="o", color="tab:purple", label="change score")
48
+ axes[1].set_ylabel("change")
49
+ axes[1].legend(loc="upper right")
50
+
51
+ axes[2].plot(steps, readiness, marker="o", color="tab:green", label="readiness")
52
+ axes[2].plot(steps, p_end, marker=".", linestyle="--", color="tab:gray", label="p_end")
53
+ axes[2].set_ylabel("probability")
54
+ axes[2].set_ylim(-0.05, 1.05)
55
+ axes[2].legend(loc="upper right")
56
+
57
+ for agent_id in trace.agent_ids:
58
+ urges = [tick.decisions[agent_id].urge for tick in ticks]
59
+ axes[3].plot(steps, urges, marker="o", label=f"{agent_id} urge")
60
+ axes[3].axhline(controller.config.tau, color="black", linestyle="--", linewidth=1, label="tau")
61
+ axes[3].set_ylabel("urge")
62
+ axes[3].set_xlabel("step")
63
+ axes[3].legend(loc="upper right")
64
+
65
+ for tick in ticks:
66
+ for agent_id, decision in tick.decisions.items():
67
+ if decision.action == Action.SILENT:
68
+ continue
69
+ color = ACTION_COLORS[decision.action]
70
+ axes[3].scatter([tick.step], [decision.urge], color=color, s=90, zorder=5)
71
+ axes[3].annotate(
72
+ decision.action.value,
73
+ (tick.step, decision.urge),
74
+ textcoords="offset points",
75
+ xytext=(0, 8),
76
+ ha="center",
77
+ fontsize=8,
78
+ color=color,
79
+ )
80
+
81
+ for axis in axes:
82
+ axis.grid(True, alpha=0.25)
83
+
84
+ output = Path(output_path)
85
+ output.parent.mkdir(parents=True, exist_ok=True)
86
+ fig.tight_layout()
87
+ fig.savefig(output, dpi=160)
88
+ plt.close(fig)
89
+ return output
90
+
91
+
92
+ def main() -> None:
93
+ output = render_timeline()
94
+ print(f"Wrote {output}")
95
+
96
+
97
+ if __name__ == "__main__":
98
+ main()
requirements.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ --extra-index-url https://download.pytorch.org/whl/cpu
2
+ gradio==6.18.0
3
+ torch==2.12.0+cpu
4
+ kokoro==0.9.4
5
+ numpy==2.4.6
6
+ requests==2.32.5
7
+ soundfile==0.14.0
8
+ sentence-transformers>=5.0.0,<6.0.0