squaredcuber commited on
Commit
45bfccb
·
verified ·
1 Parent(s): b0755f2

Deploy Pitch or Perish Space

Browse files
README.md CHANGED
@@ -1,13 +1,69 @@
1
  ---
2
- title: Pitch Or Perish
3
- emoji: 🔥
4
- colorFrom: yellow
5
- colorTo: pink
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: Pitch or Perish
3
+ emoji: 🧨
4
+ colorFrom: red
5
+ colorTo: yellow
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 live pitch room where small-model timing is the game.
14
+ tags:
15
+ - gradio
16
+ - build-small-hackathon
17
+ - startup
18
+ - pitch-practice
19
+ - full-duplex
20
+ - training-free
21
+ - track:wood
22
+ - sponsor:nvidia
23
+ - sponsor:modal
24
+ - sponsor:openai
25
+ - achievement:offbrand
26
+ - tiny-titan
27
+ - best-demo
28
+ - nemotron
29
+ - cohere
30
+ models:
31
+ - nvidia/Llama-3.1-Nemotron-Nano-4B-v1.1
32
+ - Qwen/Qwen2.5-3B-Instruct
33
+ - hexgrad/Kokoro-82M
34
+ - CohereLabs/cohere-transcribe-03-2026
35
  ---
36
 
37
+ # Pitch or Perish
38
+
39
+ Pitch or Perish is a tense boardroom demo where the user pastes a startup
40
+ pitch and a three-investor AI panel reacts live. The game is not just what the
41
+ panel says, but when it says it: the investors interrupt weak claims,
42
+ backchannel during shifts, hold awkward pauses, and answer at turn end.
43
+
44
+ The timing policy is training-free. It reads a small model's own predictive
45
+ surprise, hidden-state change signal, readiness, and turn-end probability, then
46
+ arbitrates the floor across three investor personas without a supervised
47
+ turn-taking head.
48
+
49
+ ## Tiny Titan Story
50
+
51
+ The live brain is `nvidia/Llama-3.1-Nemotron-Nano-4B-v1.1` with
52
+ `Qwen/Qwen2.5-3B-Instruct` fallback; Kokoro-82M handles local CPU TTS. The
53
+ interactive app is built around <=4B / small-weight models, with Modal hosting
54
+ the GPU brain and the Space running the Gradio interface plus Kokoro.
55
+
56
+ ## Sponsor Stack
57
+
58
+ - **NVIDIA:** Nemotron Nano is the instrumented brain that exposes NLL,
59
+ hidden states, readiness, and short investor replies.
60
+ - **Modal:** the brain runs as a protected persistent Modal web endpoint with
61
+ one warm A10G container for judging.
62
+ - **OpenAI:** the repo history includes Codex-attributed commits.
63
+ - **Cohere:** Cohere Transcribe is the planned ASR layer for the next voice-in
64
+ phase; this submission keeps input text-streamed for reliability.
65
+
66
+ ## Links
67
+
68
+ - Demo video: PLACEHOLDER — add recording link after capture.
69
+ - 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.pitch.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/pitch/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+
apps/pitch/app.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.pitch import runtime
16
+
17
+
18
+ APP_DIR = Path(__file__).resolve().parent
19
+ CSS = (APP_DIR / "static" / "pitch.css").read_text(encoding="utf-8")
20
+ JS = (APP_DIR / "static" / "pitch.js").read_text(encoding="utf-8")
21
+
22
+
23
+ def stream_pitch(pitch_text: str, tau: float, voice_enabled: bool) -> Iterator[tuple[str, str | None, dict]]:
24
+ yield from runtime.run_pitch_stream(pitch_text, tau, enable_tts=voice_enabled)
25
+
26
+
27
+ def build_app() -> gr.Blocks:
28
+ initial_tau = float(os.getenv("PITCH_TAU", "0.85"))
29
+ initial_html = (
30
+ runtime.preview_room_html(initial_tau)
31
+ if os.getenv("PITCH_PREVIEW_STATE", "0") == "1"
32
+ else runtime.initial_room_html(initial_tau)
33
+ )
34
+ with gr.Blocks(title="Pitch or Perish", fill_width=True) as demo:
35
+ room = gr.HTML(
36
+ value=initial_html,
37
+ elem_id="pitch-room-output",
38
+ )
39
+ with gr.Row(elem_id="pitch-control-row"):
40
+ pitch = gr.Textbox(
41
+ value=runtime.SAMPLE_PITCH,
42
+ label="Founder pitch",
43
+ lines=7,
44
+ elem_id="pitch-input",
45
+ max_lines=12,
46
+ )
47
+ with gr.Column(elem_id="pitch-controls", scale=0):
48
+ tau = gr.Slider(
49
+ minimum=0.55,
50
+ maximum=1.75,
51
+ value=initial_tau,
52
+ step=0.05,
53
+ label="Panel aggressiveness",
54
+ elem_id="tau-slider",
55
+ )
56
+ voice = gr.Checkbox(value=os.getenv("PITCH_TTS", "1").lower() not in {"0", "false"}, label="Kokoro voice")
57
+ run = gr.Button("Face the panel", elem_id="run-pitch", variant="primary")
58
+ audio = gr.Audio(
59
+ label="Latest investor voice",
60
+ type="filepath",
61
+ autoplay=True,
62
+ interactive=False,
63
+ elem_id="pitch-audio",
64
+ )
65
+ status = gr.JSON(label="Run state", visible=False)
66
+
67
+ tau.change(fn=runtime.set_live_tau, inputs=tau, outputs=None, queue=False)
68
+ run.click(fn=stream_pitch, inputs=[pitch, tau, voice], outputs=[room, audio, status])
69
+ return demo
70
+
71
+
72
+ def main(argv: list[str] | None = None) -> None:
73
+ parser = argparse.ArgumentParser(description="Launch the Pitch or Perish Gradio demo.")
74
+ parser.add_argument("--server-name", default=os.getenv("GRADIO_SERVER_NAME", "127.0.0.1"))
75
+ parser.add_argument("--server-port", type=int, default=int(os.getenv("GRADIO_SERVER_PORT", "7860")))
76
+ parser.add_argument("--share", action="store_true", default=os.getenv("GRADIO_SHARE", "0") == "1")
77
+ args = parser.parse_args(argv)
78
+ demo = build_app()
79
+ demo.queue(default_concurrency_limit=4)
80
+ demo.launch(
81
+ server_name=args.server_name,
82
+ server_port=args.server_port,
83
+ share=args.share,
84
+ show_error=True,
85
+ css=CSS,
86
+ js=JS,
87
+ )
88
+
89
+
90
+ if __name__ == "__main__":
91
+ main()
apps/pitch/runtime.py ADDED
@@ -0,0 +1,938 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.controller import Action, ControllerConfig, ControllerTick, WhenToSpeakController
15
+ from engine.conversation import TranscriptChunk, default_personas, demo_controller_config
16
+ from engine.live_brain import BrainClient, Dialogue, LiveBrainPanel, Persona
17
+
18
+
19
+ ROOT = Path(__file__).resolve().parents[2]
20
+ EVAL_DIR = ROOT / "eval"
21
+ PITCH_LOG_PATH = EVAL_DIR / "pitch_conversation_log.json"
22
+ AUDIO_DIR = EVAL_DIR / "pitch_audio"
23
+ MODAL_APP_NAME = "whentospeak-live-brain"
24
+
25
+ SAMPLE_PITCH = (
26
+ "so basically our startup helps small retailers manage inventory. "
27
+ "we connect to their point of sale and purchase orders. "
28
+ "we already have ten thousand stores and zero churn after launching last week. "
29
+ "then we predict stockouts and write reorder suggestions automatically. "
30
+ "we are converting pilots into paid contracts this month. "
31
+ "so we think this becomes the operating system for local retail. "
32
+ "that's the pitch."
33
+ )
34
+
35
+ AGENT_META: dict[str, dict[str, str]] = {
36
+ "numbers_vc": {
37
+ "name": "Numbers VC",
38
+ "role": "Cohorts or it did not happen.",
39
+ "avatar": "N",
40
+ "color": "#47d18c",
41
+ "voice": "am_adam",
42
+ },
43
+ "vision_optimist": {
44
+ "name": "Vision Optimist",
45
+ "role": "Wants the giant outcome.",
46
+ "avatar": "V",
47
+ "color": "#f6c85f",
48
+ "voice": "af_sarah",
49
+ },
50
+ "ruthless_skeptic": {
51
+ "name": "Ruthless Skeptic",
52
+ "role": "Cuts weak claims in half.",
53
+ "avatar": "S",
54
+ "color": "#ff5a5f",
55
+ "voice": "am_eric",
56
+ },
57
+ }
58
+
59
+ PUNCHY_FALLBACKS: dict[str, list[str]] = {
60
+ "numbers_vc": [
61
+ "Show cohorts, not adjectives.",
62
+ "Give me paid conversion and retention.",
63
+ "That claim needs denominators.",
64
+ ],
65
+ "vision_optimist": [
66
+ "The vision is big; the bridge is missing.",
67
+ "Make the wedge sharper.",
68
+ "I can see the upside, but prove the path.",
69
+ ],
70
+ "ruthless_skeptic": [
71
+ "Zero churn after one week is not churn data.",
72
+ "That is not evidence; that is a slogan.",
73
+ "Stop there. The timeline breaks the claim.",
74
+ ],
75
+ }
76
+
77
+ BACKCHANNELS: dict[str, str] = {
78
+ "numbers_vc": "Need the denominator.",
79
+ "vision_optimist": "There is a bigger version there.",
80
+ "ruthless_skeptic": "That sounded too clean.",
81
+ }
82
+
83
+ _LIVE_TAU = 0.85
84
+
85
+
86
+ @dataclass
87
+ class PitchStats:
88
+ interruptions: int = 0
89
+ backchannels: int = 0
90
+ awkward_silences: int = 0
91
+ take_floors: int = 0
92
+ interrupted_by: dict[str, int] = field(default_factory=lambda: {agent_id: 0 for agent_id in AGENT_META})
93
+
94
+
95
+ @dataclass
96
+ class RoomState:
97
+ tau: float
98
+ brain_mode: str
99
+ transcript: list[dict[str, Any]] = field(default_factory=list)
100
+ decisions: dict[str, dict[str, Any]] = field(default_factory=dict)
101
+ stats: PitchStats = field(default_factory=PitchStats)
102
+ step: int = 0
103
+ status: str = "ready"
104
+ score: str | None = None
105
+ survival: str | None = None
106
+ tts_error: str | None = None
107
+
108
+
109
+ class DeployedModalBrainClient:
110
+ """BrainClient backed by deployed Modal functions."""
111
+
112
+ def __init__(self, app_name: str = MODAL_APP_NAME) -> None:
113
+ import modal
114
+
115
+ self._step_many = modal.Function.from_name(app_name, "step_many")
116
+ self._generate = modal.Function.from_name(app_name, "generate")
117
+
118
+ def step(
119
+ self,
120
+ agent_id: str,
121
+ system_prompt: str,
122
+ dialogue_so_far: Dialogue,
123
+ new_user_text: str,
124
+ silence_flag: bool,
125
+ ) -> dict[str, object]:
126
+ payloads = [{"agent_id": agent_id, "system_prompt": system_prompt}]
127
+ raw = self.step_many(payloads, dialogue_so_far, new_user_text, silence_flag)
128
+ return dict(raw.get("results", {}).get(agent_id, {}))
129
+
130
+ def step_many(
131
+ self,
132
+ agent_payloads: list[dict[str, str]],
133
+ dialogue_so_far: Dialogue,
134
+ new_user_text: str,
135
+ silence_flag: bool,
136
+ ) -> dict[str, object]:
137
+ return self._step_many.remote(agent_payloads, dialogue_so_far, new_user_text, silence_flag)
138
+
139
+ def generate(self, agent_id: str, system_prompt: str, dialogue: Dialogue) -> dict[str, object]:
140
+ return self._generate.remote(agent_id, system_prompt, dialogue)
141
+
142
+
143
+ class EndpointBrainClient:
144
+ """BrainClient backed by a protected Modal HTTPS endpoint."""
145
+
146
+ def __init__(
147
+ self,
148
+ endpoint_url: str | None = None,
149
+ token_id: str | None = None,
150
+ token_secret: str | None = None,
151
+ *,
152
+ timeout_s: float = 240.0,
153
+ ) -> None:
154
+ self.endpoint_url = (endpoint_url or os.environ["MODAL_BRAIN_ENDPOINT_URL"]).rstrip("/")
155
+ self.bearer_token = os.getenv("MODAL_BRAIN_ENDPOINT_TOKEN") or os.getenv("ENDPOINT_AUTH_TOKEN")
156
+ self.token_id = token_id or os.getenv("MODAL_PROXY_AUTH_TOKEN_ID") or os.getenv("MODAL_KEY")
157
+ self.token_secret = token_secret or os.getenv("MODAL_PROXY_AUTH_TOKEN_SECRET") or os.getenv("MODAL_SECRET")
158
+ if not self.bearer_token and (not self.token_id or not self.token_secret):
159
+ raise RuntimeError(
160
+ "Modal endpoint mode needs MODAL_BRAIN_ENDPOINT_TOKEN or "
161
+ "MODAL_PROXY_AUTH_TOKEN_ID/MODAL_PROXY_AUTH_TOKEN_SECRET"
162
+ )
163
+ self.timeout_s = timeout_s
164
+
165
+ def step(
166
+ self,
167
+ agent_id: str,
168
+ system_prompt: str,
169
+ dialogue_so_far: Dialogue,
170
+ new_user_text: str,
171
+ silence_flag: bool,
172
+ ) -> dict[str, object]:
173
+ payloads = [{"agent_id": agent_id, "system_prompt": system_prompt}]
174
+ raw = self.step_many(payloads, dialogue_so_far, new_user_text, silence_flag)
175
+ return dict(raw.get("results", {}).get(agent_id, {}))
176
+
177
+ def step_many(
178
+ self,
179
+ agent_payloads: list[dict[str, str]],
180
+ dialogue_so_far: Dialogue,
181
+ new_user_text: str,
182
+ silence_flag: bool,
183
+ ) -> dict[str, object]:
184
+ return self._post(
185
+ "/step_many",
186
+ {
187
+ "agent_payloads": agent_payloads,
188
+ "dialogue_so_far": dialogue_so_far,
189
+ "new_user_text": new_user_text,
190
+ "silence_flag": silence_flag,
191
+ },
192
+ )
193
+
194
+ def generate(self, agent_id: str, system_prompt: str, dialogue: Dialogue) -> dict[str, object]:
195
+ return self._post(
196
+ "/generate",
197
+ {"agent_id": agent_id, "system_prompt": system_prompt, "dialogue": dialogue},
198
+ )
199
+
200
+ def _post(self, path: str, payload: dict[str, object]) -> dict[str, object]:
201
+ import requests
202
+
203
+ headers = {"Content-Type": "application/json"}
204
+ if self.bearer_token:
205
+ headers["Authorization"] = f"Bearer {self.bearer_token}"
206
+ else:
207
+ headers["Modal-Key"] = str(self.token_id)
208
+ headers["Modal-Secret"] = str(self.token_secret)
209
+
210
+ response = requests.post(f"{self.endpoint_url}{path}", json=payload, headers=headers, timeout=self.timeout_s)
211
+ response.raise_for_status()
212
+ return dict(response.json())
213
+
214
+
215
+ class DemoPitchBrainClient:
216
+ """Deterministic local stand-in for UI work when Modal is unavailable."""
217
+
218
+ def __init__(self) -> None:
219
+ self.calls = 0
220
+
221
+ def step(
222
+ self,
223
+ agent_id: str,
224
+ system_prompt: str,
225
+ dialogue_so_far: Dialogue,
226
+ new_user_text: str,
227
+ silence_flag: bool,
228
+ ) -> dict[str, object]:
229
+ payload = [{"agent_id": agent_id, "system_prompt": system_prompt}]
230
+ return dict(self.step_many(payload, dialogue_so_far, new_user_text, silence_flag)["results"][agent_id])
231
+
232
+ def step_many(
233
+ self,
234
+ agent_payloads: list[dict[str, str]],
235
+ dialogue_so_far: Dialogue,
236
+ new_user_text: str,
237
+ silence_flag: bool,
238
+ ) -> dict[str, object]:
239
+ start = time.perf_counter()
240
+ self.calls += 1
241
+ new_lower = new_user_text.lower()
242
+ weak_claim = _has_weak_claim(new_lower)
243
+ numeric_claim = bool(re.search(r"\b(\d+|ten thousand|zero|million|billion)\b", new_lower))
244
+ topic_shift = any(fragment in new_lower for fragment in ("but", "however", "pivot", "instead"))
245
+ results: dict[str, dict[str, object]] = {}
246
+ for payload in agent_payloads:
247
+ agent_id = payload["agent_id"]
248
+ results[agent_id] = self._result(
249
+ agent_id,
250
+ weak_claim=weak_claim,
251
+ numeric_claim=numeric_claim,
252
+ topic_shift=topic_shift,
253
+ new_user_text=new_user_text,
254
+ silence_flag=silence_flag,
255
+ )
256
+ return {
257
+ "ok": True,
258
+ "results": results,
259
+ "batch_latency_ms": (time.perf_counter() - start) * 1000.0,
260
+ "model_name": "demo-deterministic-brain",
261
+ "device_name": "cpu",
262
+ }
263
+
264
+ def generate(self, agent_id: str, system_prompt: str, dialogue: Dialogue) -> dict[str, object]:
265
+ del system_prompt
266
+ text = _dialogue_text(dialogue, "")
267
+ line = _fallback_line(agent_id, text)
268
+ return {
269
+ "ok": True,
270
+ "agent_id": agent_id,
271
+ "reply_text": line,
272
+ "raw_reply_text": line,
273
+ "reply_source": "demo",
274
+ "latency_ms": 3.0,
275
+ "model_name": "demo-deterministic-brain",
276
+ }
277
+
278
+ def _result(
279
+ self,
280
+ agent_id: str,
281
+ *,
282
+ weak_claim: bool,
283
+ numeric_claim: bool,
284
+ topic_shift: bool,
285
+ new_user_text: str,
286
+ silence_flag: bool,
287
+ ) -> dict[str, object]:
288
+ base = 2.1 + 0.08 * self.calls
289
+ surprise = base + (4.8 if weak_claim else 0.0) + (0.7 if topic_shift else 0.0)
290
+ if agent_id == "numbers_vc" and numeric_claim:
291
+ readiness = 0.74
292
+ elif agent_id == "ruthless_skeptic" and weak_claim:
293
+ readiness = 0.93
294
+ elif agent_id == "vision_optimist" and not weak_claim:
295
+ readiness = 0.62
296
+ else:
297
+ readiness = 0.36
298
+ if silence_flag:
299
+ p_end = 0.95
300
+ elif new_user_text.strip().endswith((".", "?", "!")):
301
+ p_end = 0.72
302
+ else:
303
+ p_end = 0.18
304
+
305
+ hidden = self._hidden(agent_id, weak_claim=weak_claim, topic_shift=topic_shift, silence_flag=silence_flag)
306
+ return {
307
+ "ok": True,
308
+ "agent_id": agent_id,
309
+ "surprise": float(surprise),
310
+ "hidden": hidden.tolist(),
311
+ "readiness": float(readiness),
312
+ "p_end": float(p_end),
313
+ "latency_ms": 4.0,
314
+ }
315
+
316
+ def _hidden(self, agent_id: str, *, weak_claim: bool, topic_shift: bool, silence_flag: bool) -> np.ndarray:
317
+ index = list(AGENT_META).index(agent_id)
318
+ vec = np.zeros(12, dtype=np.float32)
319
+ vec[index] = 1.0
320
+ vec[(self.calls + index) % vec.size] += 0.35
321
+ if weak_claim:
322
+ vec[6] += 1.8
323
+ if topic_shift:
324
+ vec[7] += 1.2
325
+ if silence_flag:
326
+ vec[8] += 0.9
327
+ norm = np.linalg.norm(vec)
328
+ return vec / norm if norm else vec
329
+
330
+
331
+ class KokoroSpeaker:
332
+ """Lazy Kokoro TTS wrapper; importing Kokoro is intentionally delayed."""
333
+
334
+ def __init__(self, enabled: bool = True) -> None:
335
+ self.enabled = enabled
336
+ self.last_error: str | None = None
337
+ self._pipeline: Any | None = None
338
+
339
+ def synthesize(self, text: str, agent_id: str, tag: str) -> str | None:
340
+ if not self.enabled or not text.strip():
341
+ return None
342
+ AUDIO_DIR.mkdir(parents=True, exist_ok=True)
343
+ output = AUDIO_DIR / f"{tag}_{agent_id}.wav"
344
+ try:
345
+ os.environ.setdefault("HF_HOME", str(ROOT / ".hf-cache"))
346
+ import soundfile as sf
347
+ from kokoro import KPipeline
348
+
349
+ if self._pipeline is None:
350
+ self._pipeline = KPipeline(lang_code="a")
351
+ voice = AGENT_META.get(agent_id, {}).get("voice", "af_heart")
352
+ chunks = []
353
+ for _, _, audio in self._pipeline(text, voice=voice, speed=1.04):
354
+ chunks.append(np.asarray(audio, dtype=np.float32))
355
+ if not chunks:
356
+ raise RuntimeError("Kokoro returned no audio chunks")
357
+ waveform = np.concatenate(chunks)
358
+ sf.write(output, waveform, 24000)
359
+ self.last_error = None
360
+ return str(output)
361
+ except Exception as exc: # noqa: BLE001 - app should keep the text demo alive.
362
+ self.last_error = f"{type(exc).__name__}: {exc}"
363
+ return None
364
+
365
+
366
+ def set_live_tau(value: float) -> None:
367
+ global _LIVE_TAU
368
+ _LIVE_TAU = float(value)
369
+
370
+
371
+ def chunk_pitch(text: str, *, words_per_chunk: int = 9) -> list[TranscriptChunk]:
372
+ words = re.findall(r"\S+", text.strip())
373
+ if not words:
374
+ words = re.findall(r"\S+", SAMPLE_PITCH)
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 controller_config(tau: float) -> ControllerConfig:
389
+ return replace(demo_controller_config(), tau=float(tau))
390
+
391
+
392
+ def make_brain_client(mode: str | None = None) -> tuple[BrainClient, str]:
393
+ selected = (mode or os.getenv("PITCH_BRAIN", "modal")).strip().lower()
394
+ if selected in {"fake", "demo", "local"}:
395
+ return DemoPitchBrainClient(), "demo"
396
+ if selected in {"http", "endpoint", "space", "modal-http"} or (selected == "modal" and os.getenv("MODAL_BRAIN_ENDPOINT_URL")):
397
+ return EndpointBrainClient(), "modal"
398
+ return DeployedModalBrainClient(), "modal"
399
+
400
+
401
+ def initial_room_html(tau: float | None = None, brain_mode: str | None = None) -> str:
402
+ selected_tau = float(tau if tau is not None else _LIVE_TAU)
403
+ selected_mode = brain_mode or os.getenv("PITCH_BRAIN", "modal")
404
+ state = RoomState(tau=selected_tau, brain_mode=selected_mode, status="ready")
405
+ return render_room(state)
406
+
407
+
408
+ def preview_room_html(tau: float | None = None) -> str:
409
+ """Render a deterministic post-run room for headless screenshots."""
410
+
411
+ selected_tau = float(tau if tau is not None else _LIVE_TAU)
412
+ latest = initial_room_html(selected_tau, brain_mode="demo")
413
+ for latest, _, _ in run_pitch_stream(SAMPLE_PITCH, selected_tau, brain_mode="fake", enable_tts=False, save_log=False):
414
+ pass
415
+ return latest
416
+
417
+
418
+ def run_pitch_stream(
419
+ pitch_text: str,
420
+ tau: float,
421
+ *,
422
+ brain_mode: str | None = None,
423
+ enable_tts: bool | None = None,
424
+ save_log: bool = True,
425
+ ) -> Iterator[tuple[str, str | None, dict[str, Any]]]:
426
+ set_live_tau(tau)
427
+ selected_tts = enable_tts if enable_tts is not None else os.getenv("PITCH_TTS", "1").lower() not in {"0", "false"}
428
+ state = RoomState(tau=float(tau), brain_mode=brain_mode or os.getenv("PITCH_BRAIN", "modal"), status="connecting")
429
+ speaker = KokoroSpeaker(enabled=bool(selected_tts))
430
+ yield from _safe_run_pitch_stream(pitch_text, state, speaker, save_log=save_log)
431
+
432
+
433
+ def _safe_run_pitch_stream(
434
+ pitch_text: str,
435
+ state: RoomState,
436
+ speaker: KokoroSpeaker,
437
+ *,
438
+ save_log: bool,
439
+ ) -> Iterator[tuple[str, str | None, dict[str, Any]]]:
440
+ try:
441
+ client, resolved_mode = make_brain_client(state.brain_mode)
442
+ state.brain_mode = resolved_mode
443
+ yield from _run_with_client(pitch_text, state, speaker, client, save_log=save_log)
444
+ except Exception as exc: # noqa: BLE001 - report failures inside the demo shell.
445
+ state.status = "failed"
446
+ state.transcript.append(
447
+ {
448
+ "kind": "system",
449
+ "step": state.step,
450
+ "speaker": "room",
451
+ "text": f"Backend failed: {type(exc).__name__}: {exc}",
452
+ "action": "ERROR",
453
+ }
454
+ )
455
+ yield render_room(state), None, _status_payload(state)
456
+
457
+
458
+ def _run_with_client(
459
+ pitch_text: str,
460
+ state: RoomState,
461
+ speaker: KokoroSpeaker,
462
+ client: BrainClient,
463
+ *,
464
+ save_log: bool,
465
+ ) -> Iterator[tuple[str, str | None, dict[str, Any]]]:
466
+ personas = _pitch_personas()
467
+ panel = LiveBrainPanel(personas, client=client)
468
+ controller = WhenToSpeakController(panel.agent_ids, config=controller_config(state.tau))
469
+ dialogue: Dialogue = []
470
+ current_user_text = ""
471
+ events: list[dict[str, Any]] = []
472
+ chunks = chunk_pitch(pitch_text)
473
+
474
+ state.status = "streaming"
475
+ yield render_room(state), None, _status_payload(state)
476
+
477
+ for step_index, chunk in enumerate(chunks, start=1):
478
+ state.step = step_index
479
+ state.tau = _LIVE_TAU
480
+ controller.config = controller_config(_LIVE_TAU)
481
+ dialogue_before = _dialogue_with_current_user(dialogue, current_user_text)
482
+ signals = panel.step_all(dialogue_before, chunk.text, chunk.silence_flag)
483
+ current_user_text = _join_text(current_user_text, chunk.text)
484
+ tick = controller.tick(signals, floor_holder="human")
485
+ state.decisions = _decision_payloads(tick)
486
+ state.transcript.append({"kind": "founder", "speaker": "Founder", "step": step_index, "text": chunk.text})
487
+
488
+ event = _event_payload(step_index, chunk, tick, panel)
489
+ audio_path: str | None = None
490
+ audio_tag = f"step{step_index:02d}"
491
+ _record_backchannels(state, tick, step_index, dialogue)
492
+
493
+ if tick.winner:
494
+ if current_user_text:
495
+ dialogue.append({"role": "user", "speaker": "founder", "text": current_user_text})
496
+ current_user_text = ""
497
+ generated = panel.generate(tick.winner, dialogue)
498
+ line = sanitize_spoken_line(str(generated.get("reply_text", "")), tick.winner, dialogue)
499
+ action = tick.decisions[tick.winner].action
500
+ dialogue.append({"role": "assistant", "speaker": tick.winner, "text": line})
501
+ state.transcript.append(
502
+ {
503
+ "kind": "investor",
504
+ "speaker": _agent_name(tick.winner),
505
+ "agent_id": tick.winner,
506
+ "step": step_index,
507
+ "text": line,
508
+ "action": action.value,
509
+ }
510
+ )
511
+ event["generated"] = dict(generated, reply_text=line)
512
+ if action == Action.INTERRUPT:
513
+ state.stats.interruptions += 1
514
+ state.stats.interrupted_by[tick.winner] = state.stats.interrupted_by.get(tick.winner, 0) + 1
515
+ elif action == Action.TAKE_FLOOR:
516
+ state.stats.take_floors += 1
517
+ audio_path = speaker.synthesize(line, tick.winner, audio_tag)
518
+
519
+ if chunk.silence_flag and not tick.winner:
520
+ state.stats.awkward_silences += 1
521
+
522
+ if speaker.last_error:
523
+ state.tts_error = speaker.last_error
524
+ events.append(event)
525
+ yield render_room(state), audio_path, _status_payload(state)
526
+
527
+ if current_user_text:
528
+ dialogue.append({"role": "user", "speaker": "founder", "text": current_user_text})
529
+
530
+ state.status = "verdict"
531
+ verdict_dialogue = [
532
+ *dialogue,
533
+ {
534
+ "role": "user",
535
+ "speaker": "host",
536
+ "text": "Final verdict: one short sentence. Say fund, pass, or come back with numbers.",
537
+ },
538
+ ]
539
+ for persona in personas:
540
+ state.step += 1
541
+ generated = panel.generate(persona.agent_id, verdict_dialogue)
542
+ line = sanitize_spoken_line(str(generated.get("reply_text", "")), persona.agent_id, verdict_dialogue)
543
+ dialogue.append({"role": "assistant", "speaker": persona.agent_id, "text": line})
544
+ verdict_dialogue.append({"role": "assistant", "speaker": persona.agent_id, "text": line})
545
+ state.transcript.append(
546
+ {
547
+ "kind": "verdict",
548
+ "speaker": persona.display_name,
549
+ "agent_id": persona.agent_id,
550
+ "step": state.step,
551
+ "text": line,
552
+ "action": "VERDICT",
553
+ }
554
+ )
555
+ audio_path = speaker.synthesize(line, persona.agent_id, f"verdict{state.step:02d}")
556
+ if speaker.last_error:
557
+ state.tts_error = speaker.last_error
558
+ yield render_room(state), audio_path, _status_payload(state)
559
+
560
+ score, survival = final_score(state.stats)
561
+ state.score = score
562
+ state.survival = survival
563
+ state.status = "complete"
564
+ state.transcript.append({"kind": "score", "speaker": "Panel", "step": state.step, "text": score, "action": "SCORE"})
565
+ if save_log:
566
+ _save_pitch_log(state, dialogue, events)
567
+ yield render_room(state), None, _status_payload(state)
568
+
569
+
570
+ def render_room(state: RoomState) -> str:
571
+ decisions = state.decisions or {agent_id: _empty_decision(agent_id) for agent_id in AGENT_META}
572
+ cards = "\n".join(_render_agent_card(agent_id, decisions.get(agent_id, _empty_decision(agent_id)), state) for agent_id in AGENT_META)
573
+ transcript = "\n".join(_render_feed_item(item) for item in state.transcript) or _empty_transcript()
574
+ mode_class = "modal" if state.brain_mode == "modal" else "demo"
575
+ tts_note = f"<span class='tts-note'>TTS: {_escape(state.tts_error)}</span>" if state.tts_error else ""
576
+ score_html = _render_score(state)
577
+ return f"""
578
+ <div id="pitch-room" class="pitch-room" data-status="{_escape(state.status)}">
579
+ <div class="room-vignette"></div>
580
+ <header class="pitch-topbar">
581
+ <div>
582
+ <div class="kicker">PITCH OR PERISH</div>
583
+ <h1>Boardroom timing is the gameplay.</h1>
584
+ </div>
585
+ <div class="room-ledger">
586
+ <span class="backend-pill {mode_class}">Brain: {_escape(state.brain_mode.upper())}</span>
587
+ <span>Step {_escape(str(state.step))}</span>
588
+ <span>&tau; {_escape(f"{state.tau:.2f}")}</span>
589
+ {tts_note}
590
+ </div>
591
+ </header>
592
+ <section class="panel-rail">{cards}</section>
593
+ <section class="table-zone">
594
+ <div class="table-edge"></div>
595
+ <div class="transcript-shell">
596
+ <div class="transcript-title">
597
+ <span>Live transcript</span>
598
+ <span>{_escape(state.status)}</span>
599
+ </div>
600
+ <div class="transcript-scroll">{transcript}</div>
601
+ </div>
602
+ <aside class="scoreboard">
603
+ <div class="score-row"><span>Interruptions drawn</span><b>{state.stats.interruptions}</b></div>
604
+ <div class="score-row"><span>Backchannels</span><b>{state.stats.backchannels}</b></div>
605
+ <div class="score-row"><span>Awkward silences</span><b>{state.stats.awkward_silences}</b></div>
606
+ <div class="score-row"><span>Most brutal</span><b>{_escape(_most_brutal(state.stats))}</b></div>
607
+ {score_html}
608
+ </aside>
609
+ </section>
610
+ </div>
611
+ """.strip()
612
+
613
+
614
+ def sanitize_spoken_line(reply: str, agent_id: str, dialogue: Dialogue | None = None) -> str:
615
+ candidate = re.sub(r"<think>.*?</think>", "", reply, flags=re.IGNORECASE | re.DOTALL)
616
+ candidate = candidate.replace("<think>", "").replace("</think>", "")
617
+ for line in candidate.splitlines() or [candidate]:
618
+ cleaned = _strip_prefix(line, agent_id)
619
+ if _looks_spoken(cleaned):
620
+ return _truncate(cleaned)
621
+ return _fallback_line(agent_id, _dialogue_text(dialogue or [], ""))
622
+
623
+
624
+ def final_score(stats: PitchStats) -> tuple[str, str]:
625
+ most = _most_brutal(stats)
626
+ if stats.interruptions >= 2:
627
+ score = "PASS"
628
+ survival = f"Survival: {stats.interruptions} interruptions. Zero-churn claim drew fire; {most} controlled the room."
629
+ elif stats.interruptions == 1 or stats.awkward_silences:
630
+ score = "COME BACK WITH NUMBERS"
631
+ survival = f"Survival: {stats.interruptions} interruption, {stats.backchannels} backchannels."
632
+ else:
633
+ score = "FUNDED"
634
+ survival = "Survival: clean room, no fatal timing breaks."
635
+ return score, survival
636
+
637
+
638
+ def _pitch_personas() -> list[Persona]:
639
+ personas = default_personas()
640
+ overrides = {
641
+ "numbers_vc": (
642
+ "You are a numbers-obsessed VC. Speak in short, hard-edged sentences. "
643
+ "Demand cohorts, denominators, CAC, retention, and payback. No pleasantries."
644
+ ),
645
+ "vision_optimist": (
646
+ "You are a big-vision optimist. You want the giant outcome, but you only buy crisp wedges. "
647
+ "Speak in one vivid sentence. No filler."
648
+ ),
649
+ "ruthless_skeptic": (
650
+ "You are a ruthless startup skeptic. Interrupt impossible claims immediately. "
651
+ "Short, sharp, plain English. Never soften with certainly or happy to."
652
+ ),
653
+ }
654
+ return [
655
+ Persona(persona.agent_id, persona.display_name, overrides.get(persona.agent_id, persona.system_prompt))
656
+ for persona in personas
657
+ ]
658
+
659
+
660
+ def _record_backchannels(state: RoomState, tick: ControllerTick, step: int, dialogue: Dialogue) -> None:
661
+ for agent_id, decision in tick.decisions.items():
662
+ if decision.action != Action.BACKCHANNEL:
663
+ continue
664
+ text = BACKCHANNELS.get(agent_id, "Mm.")
665
+ state.stats.backchannels += 1
666
+ state.transcript.append(
667
+ {
668
+ "kind": "backchannel",
669
+ "speaker": _agent_name(agent_id),
670
+ "agent_id": agent_id,
671
+ "step": step,
672
+ "text": text,
673
+ "action": Action.BACKCHANNEL.value,
674
+ }
675
+ )
676
+ dialogue.append({"role": "assistant", "speaker": agent_id, "text": text})
677
+
678
+
679
+ def _decision_payloads(tick: ControllerTick) -> dict[str, dict[str, Any]]:
680
+ payloads: dict[str, dict[str, Any]] = {}
681
+ for agent_id, decision in tick.decisions.items():
682
+ payloads[agent_id] = {
683
+ "action": decision.action.value,
684
+ "urge": decision.urge,
685
+ "readiness": decision.readiness,
686
+ "p_end": decision.p_end,
687
+ "change_score": decision.change_score,
688
+ "z_surprise": decision.z_surprise,
689
+ "winner": tick.winner == agent_id,
690
+ }
691
+ return payloads
692
+
693
+
694
+ def _event_payload(step: int, chunk: TranscriptChunk, tick: ControllerTick, panel: LiveBrainPanel) -> dict[str, Any]:
695
+ return {
696
+ "step": step,
697
+ "new_user_text": chunk.text,
698
+ "silence_flag": chunk.silence_flag,
699
+ "winner": tick.winner,
700
+ "model_name": (panel.last_raw or {}).get("model_name"),
701
+ "device_name": (panel.last_raw or {}).get("device_name"),
702
+ "batch_latency_ms": (panel.last_raw or {}).get("batch_latency_ms"),
703
+ "decisions": _decision_payloads(tick),
704
+ }
705
+
706
+
707
+ def _render_agent_card(agent_id: str, decision: dict[str, Any], state: RoomState) -> str:
708
+ meta = AGENT_META[agent_id]
709
+ urge = float(decision.get("urge", 0.0))
710
+ meter_max = max(2.2, state.tau * 1.6)
711
+ fill = max(0.0, min(100.0, urge / meter_max * 100.0))
712
+ tau_position = max(0.0, min(100.0, state.tau / meter_max * 100.0))
713
+ action = str(decision.get("action", "SILENT"))
714
+ active = "holding-floor" if decision.get("winner") else ""
715
+ flash = "flash-interrupt" if action == Action.INTERRUPT.value else ""
716
+ return f"""
717
+ <article class="investor-card {active} {flash}" style="--agent-color:{meta['color']}; --urge-fill:{fill:.1f}%; --tau-pos:{tau_position:.1f}%;">
718
+ <div class="card-head">
719
+ <div class="avatar">{_escape(meta['avatar'])}</div>
720
+ <div>
721
+ <h2>{_escape(meta['name'])}</h2>
722
+ <p>{_escape(meta['role'])}</p>
723
+ </div>
724
+ </div>
725
+ <div class="urge-row">
726
+ <span>urge</span>
727
+ <strong>{urge:.2f}</strong>
728
+ </div>
729
+ <div class="urge-meter"><div class="urge-fill"></div><i class="tau-line"></i></div>
730
+ <div class="signal-strip">
731
+ <span>{_escape(action)}</span>
732
+ <span>ready {float(decision.get('readiness', 0.0)):.2f}</span>
733
+ <span>end {float(decision.get('p_end', 0.0)):.2f}</span>
734
+ </div>
735
+ </article>
736
+ """.strip()
737
+
738
+
739
+ def _render_feed_item(item: dict[str, Any]) -> str:
740
+ kind = str(item.get("kind", "system"))
741
+ action = str(item.get("action", ""))
742
+ step = item.get("step", "")
743
+ speaker = _escape(str(item.get("speaker", "")))
744
+ text = _escape(str(item.get("text", "")))
745
+ label = f"<span>{speaker}</span><em>step {step}</em>"
746
+ return f"""
747
+ <div class="feed-item {kind}">
748
+ <div class="feed-meta">{label}</div>
749
+ <div class="feed-text">{text}</div>
750
+ {f'<b class="action-badge">{_escape(action)}</b>' if action else ''}
751
+ </div>
752
+ """.strip()
753
+
754
+
755
+ def _empty_transcript() -> str:
756
+ return """
757
+ <div class="feed-item system">
758
+ <div class="feed-meta"><span>Room</span><em>armed</em></div>
759
+ <div class="feed-text">Paste the pitch, hit the room, and watch the panel timing.</div>
760
+ </div>
761
+ """.strip()
762
+
763
+
764
+ def _render_score(state: RoomState) -> str:
765
+ if not state.score:
766
+ return ""
767
+ return f"""
768
+ <div class="final-score">
769
+ <small>Final score</small>
770
+ <strong>{_escape(state.score)}</strong>
771
+ <p>{_escape(state.survival or '')}</p>
772
+ </div>
773
+ """.strip()
774
+
775
+
776
+ def _empty_decision(agent_id: str) -> dict[str, Any]:
777
+ del agent_id
778
+ return {
779
+ "action": Action.SILENT.value,
780
+ "urge": 0.0,
781
+ "readiness": 0.0,
782
+ "p_end": 0.0,
783
+ "change_score": 0.0,
784
+ "z_surprise": 0.0,
785
+ "winner": False,
786
+ }
787
+
788
+
789
+ def _status_payload(state: RoomState) -> dict[str, Any]:
790
+ return {
791
+ "status": state.status,
792
+ "tau": state.tau,
793
+ "brain_mode": state.brain_mode,
794
+ "stats": asdict(state.stats),
795
+ "score": state.score,
796
+ "survival": state.survival,
797
+ "tts_error": state.tts_error,
798
+ }
799
+
800
+
801
+ def _save_pitch_log(state: RoomState, dialogue: Dialogue, events: list[dict[str, Any]]) -> None:
802
+ EVAL_DIR.mkdir(parents=True, exist_ok=True)
803
+ payload = {
804
+ "brain_mode": state.brain_mode,
805
+ "tau": state.tau,
806
+ "stats": asdict(state.stats),
807
+ "score": state.score,
808
+ "survival": state.survival,
809
+ "transcript": state.transcript,
810
+ "dialogue": dialogue,
811
+ "events": events,
812
+ }
813
+ PITCH_LOG_PATH.write_text(json.dumps(payload, indent=2), encoding="utf-8")
814
+
815
+
816
+ def _dialogue_with_current_user(dialogue: Dialogue, current_user_text: str) -> Dialogue:
817
+ snapshot = [dict(turn) for turn in dialogue]
818
+ snapshot.append({"role": "user", "speaker": "founder", "text": current_user_text})
819
+ return snapshot
820
+
821
+
822
+ def _join_text(left: str, right: str) -> str:
823
+ left = left.strip()
824
+ right = right.strip()
825
+ if not left:
826
+ return right
827
+ if not right:
828
+ return left
829
+ return f"{left} {right}"
830
+
831
+
832
+ def _dialogue_text(dialogue: Dialogue, new_user_text: str) -> str:
833
+ parts = [turn.get("text", "") for turn in dialogue]
834
+ parts.append(new_user_text)
835
+ return " ".join(parts).lower()
836
+
837
+
838
+ def _has_weak_claim(text: str) -> bool:
839
+ weak_fragments = [
840
+ "zero churn",
841
+ "churn after launching last week",
842
+ "after launching last week",
843
+ "launched last week",
844
+ "after one week",
845
+ "no churn",
846
+ "guaranteed",
847
+ "nobody can compete",
848
+ ]
849
+ return any(fragment in text for fragment in weak_fragments)
850
+
851
+
852
+ def _fallback_line(agent_id: str, text: str) -> str:
853
+ if "zero churn" in text and ("last week" in text or "one week" in text):
854
+ return "Zero churn after one week is not churn data."
855
+ if "ten thousand" in text and "stores" in text and agent_id == "numbers_vc":
856
+ return "Show the store list and paid cohorts."
857
+ if "that's the pitch" in text or "thats the pitch" in text:
858
+ if agent_id == "vision_optimist":
859
+ return "The ambition is real; the proof still has holes."
860
+ if agent_id == "numbers_vc":
861
+ return "Come back with paid retention and margins."
862
+ return "Pass until the impossible claims become evidence."
863
+ options = PUNCHY_FALLBACKS.get(agent_id, ["That claim needs proof."])
864
+ stable_index = sum(ord(char) for char in f"{text}:{agent_id}") % len(options)
865
+ return options[stable_index]
866
+
867
+
868
+ def _strip_prefix(line: str, agent_id: str) -> str:
869
+ candidate = line.strip().strip("\"'")
870
+ candidate = re.sub(r"^[\-\*\d\.\)\s]+", "", candidate).strip()
871
+ prefixes = [
872
+ f"{agent_id}:",
873
+ _agent_name(agent_id) + ":",
874
+ "Assistant:",
875
+ "Investor:",
876
+ "VC:",
877
+ "Sentence:",
878
+ "Spoken investor sentence:",
879
+ ]
880
+ for prefix in prefixes:
881
+ if candidate.lower().startswith(prefix.lower()):
882
+ candidate = candidate[len(prefix) :].strip()
883
+ candidate = re.sub(r"^(certainly,?\s+|happy to,?\s+|let's\s+|i think\s+)", "", candidate, flags=re.IGNORECASE)
884
+ if candidate:
885
+ candidate = candidate[0].upper() + candidate[1:]
886
+ return candidate.strip().strip("\"'")
887
+
888
+
889
+ def _looks_spoken(candidate: str) -> bool:
890
+ if len(candidate) < 10:
891
+ return False
892
+ lowered = candidate.lower()
893
+ blocked = [
894
+ "the user",
895
+ "the founder",
896
+ "recent transcript",
897
+ "analysis",
898
+ "markdown",
899
+ "persona:",
900
+ "investor:",
901
+ "assistant:",
902
+ "<",
903
+ "{",
904
+ ]
905
+ if any(fragment in lowered for fragment in blocked):
906
+ return False
907
+ alpha = sum(char.isalpha() for char in candidate)
908
+ if alpha / max(len(candidate), 1) < 0.45:
909
+ return False
910
+ words = [word.strip(".,;:!?()[]{}\"'").lower() for word in candidate.split()]
911
+ words = [word for word in words if word]
912
+ return len(words) <= 24
913
+
914
+
915
+ def _truncate(candidate: str) -> str:
916
+ parts = re.split(r"(?<=[.!?;])\s+", candidate, maxsplit=1)
917
+ sentence = parts[0].strip() if parts else candidate.strip()
918
+ words = sentence.split()
919
+ if len(words) > 16:
920
+ sentence = " ".join(words[:16]).rstrip(",;:") + "."
921
+ return sentence[:220].rstrip()
922
+
923
+
924
+ def _most_brutal(stats: PitchStats) -> str:
925
+ if not stats.interrupted_by:
926
+ return "none"
927
+ agent_id, count = max(stats.interrupted_by.items(), key=lambda item: (item[1], item[0]))
928
+ if count <= 0:
929
+ return "none"
930
+ return AGENT_META.get(agent_id, {}).get("name", agent_id)
931
+
932
+
933
+ def _agent_name(agent_id: str) -> str:
934
+ return AGENT_META.get(agent_id, {}).get("name", agent_id)
935
+
936
+
937
+ def _escape(value: str) -> str:
938
+ return html.escape(value, quote=True)
apps/pitch/static/pitch.css ADDED
@@ -0,0 +1,460 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ color-scheme: dark;
3
+ --pitch-bg: #080807;
4
+ --pitch-panel: #12110f;
5
+ --pitch-panel-2: #191712;
6
+ --pitch-ink: #f4efe4;
7
+ --pitch-muted: #a8a096;
8
+ --pitch-line: rgba(244, 239, 228, 0.14);
9
+ --pitch-brass: #c99a3e;
10
+ --pitch-red: #ff5a5f;
11
+ --pitch-green: #47d18c;
12
+ }
13
+
14
+ .gradio-container {
15
+ max-width: none !important;
16
+ background: var(--pitch-bg) !important;
17
+ color: var(--pitch-ink) !important;
18
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif !important;
19
+ }
20
+
21
+ .contain,
22
+ .wrap,
23
+ #component-0 {
24
+ background: transparent !important;
25
+ }
26
+
27
+ #pitch-room-output {
28
+ margin: 0 auto;
29
+ }
30
+
31
+ .pitch-room {
32
+ position: relative;
33
+ overflow: hidden;
34
+ min-height: 650px;
35
+ padding: 24px;
36
+ border: 1px solid rgba(201, 154, 62, 0.25);
37
+ border-radius: 8px;
38
+ background:
39
+ linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0) 24%),
40
+ linear-gradient(135deg, #11100e 0%, #080807 48%, #17120e 100%);
41
+ box-shadow: 0 24px 80px rgba(0, 0, 0, 0.55);
42
+ }
43
+
44
+ .room-vignette {
45
+ position: absolute;
46
+ inset: 0;
47
+ pointer-events: none;
48
+ background:
49
+ linear-gradient(90deg, rgba(0, 0, 0, 0.48), transparent 20%, transparent 80%, rgba(0, 0, 0, 0.48)),
50
+ repeating-linear-gradient(90deg, rgba(255,255,255,0.025) 0 1px, transparent 1px 92px);
51
+ }
52
+
53
+ .pitch-topbar,
54
+ .panel-rail,
55
+ .table-zone {
56
+ position: relative;
57
+ z-index: 1;
58
+ }
59
+
60
+ .pitch-topbar {
61
+ display: flex;
62
+ align-items: flex-start;
63
+ justify-content: space-between;
64
+ gap: 18px;
65
+ margin-bottom: 18px;
66
+ }
67
+
68
+ .kicker {
69
+ color: var(--pitch-brass);
70
+ font-size: 12px;
71
+ font-weight: 800;
72
+ letter-spacing: 0.18em;
73
+ }
74
+
75
+ .pitch-topbar h1 {
76
+ margin: 5px 0 0;
77
+ color: var(--pitch-ink);
78
+ font-size: 30px;
79
+ line-height: 1.05;
80
+ letter-spacing: 0;
81
+ }
82
+
83
+ .room-ledger {
84
+ display: flex;
85
+ flex-wrap: wrap;
86
+ justify-content: flex-end;
87
+ gap: 8px;
88
+ max-width: 520px;
89
+ color: var(--pitch-muted);
90
+ font-size: 12px;
91
+ }
92
+
93
+ .room-ledger span {
94
+ padding: 7px 9px;
95
+ border: 1px solid var(--pitch-line);
96
+ border-radius: 6px;
97
+ background: rgba(0, 0, 0, 0.24);
98
+ }
99
+
100
+ .backend-pill.modal {
101
+ color: var(--pitch-green);
102
+ }
103
+
104
+ .backend-pill.demo {
105
+ color: var(--pitch-brass);
106
+ }
107
+
108
+ .tts-note {
109
+ color: var(--pitch-red) !important;
110
+ }
111
+
112
+ .panel-rail {
113
+ display: grid;
114
+ grid-template-columns: repeat(3, minmax(0, 1fr));
115
+ gap: 14px;
116
+ }
117
+
118
+ .investor-card {
119
+ min-width: 0;
120
+ padding: 15px;
121
+ border: 1px solid rgba(244, 239, 228, 0.13);
122
+ border-top: 3px solid var(--agent-color);
123
+ border-radius: 8px;
124
+ background:
125
+ linear-gradient(180deg, rgba(255,255,255,0.055), rgba(255,255,255,0.012)),
126
+ var(--pitch-panel);
127
+ box-shadow: inset 0 0 0 1px rgba(0,0,0,0.24), 0 16px 40px rgba(0, 0, 0, 0.28);
128
+ transition: border-color 180ms ease, transform 180ms ease, box-shadow 180ms ease;
129
+ }
130
+
131
+ .investor-card.holding-floor {
132
+ border-color: color-mix(in srgb, var(--agent-color), white 24%);
133
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--agent-color), transparent 35%), 0 18px 54px rgba(0, 0, 0, 0.42);
134
+ transform: translateY(-2px);
135
+ }
136
+
137
+ .investor-card.flash-interrupt {
138
+ animation: interrupt-flash 720ms ease-out 1;
139
+ }
140
+
141
+ @keyframes interrupt-flash {
142
+ 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--agent-color), transparent 35%); }
143
+ 40% { box-shadow: 0 0 0 5px color-mix(in srgb, var(--agent-color), transparent 45%), 0 0 38px color-mix(in srgb, var(--agent-color), transparent 72%); }
144
+ 100% { box-shadow: 0 16px 40px rgba(0, 0, 0, 0.28); }
145
+ }
146
+
147
+ .card-head {
148
+ display: grid;
149
+ grid-template-columns: 42px minmax(0, 1fr);
150
+ gap: 11px;
151
+ align-items: center;
152
+ }
153
+
154
+ .avatar {
155
+ display: grid;
156
+ width: 42px;
157
+ height: 42px;
158
+ place-items: center;
159
+ border: 1px solid color-mix(in srgb, var(--agent-color), white 24%);
160
+ border-radius: 6px;
161
+ color: #050504;
162
+ background: var(--agent-color);
163
+ font-weight: 900;
164
+ font-size: 20px;
165
+ }
166
+
167
+ .card-head h2 {
168
+ margin: 0;
169
+ overflow-wrap: anywhere;
170
+ color: var(--pitch-ink);
171
+ font-size: 18px;
172
+ line-height: 1.05;
173
+ }
174
+
175
+ .card-head p {
176
+ margin: 5px 0 0;
177
+ color: var(--pitch-muted);
178
+ font-size: 12px;
179
+ line-height: 1.25;
180
+ }
181
+
182
+ .urge-row,
183
+ .signal-strip {
184
+ display: flex;
185
+ align-items: center;
186
+ justify-content: space-between;
187
+ gap: 9px;
188
+ }
189
+
190
+ .urge-row {
191
+ margin-top: 15px;
192
+ color: var(--pitch-muted);
193
+ font-size: 12px;
194
+ text-transform: uppercase;
195
+ }
196
+
197
+ .urge-row strong {
198
+ color: var(--pitch-ink);
199
+ font-size: 16px;
200
+ }
201
+
202
+ .urge-meter {
203
+ position: relative;
204
+ height: 13px;
205
+ margin-top: 7px;
206
+ overflow: hidden;
207
+ border: 1px solid rgba(244, 239, 228, 0.16);
208
+ border-radius: 999px;
209
+ background: rgba(0, 0, 0, 0.35);
210
+ }
211
+
212
+ .urge-fill {
213
+ width: var(--urge-fill);
214
+ height: 100%;
215
+ border-radius: 999px;
216
+ background: linear-gradient(90deg, color-mix(in srgb, var(--agent-color), black 18%), var(--agent-color));
217
+ transition: width 260ms ease;
218
+ }
219
+
220
+ .tau-line {
221
+ position: absolute;
222
+ top: -3px;
223
+ bottom: -3px;
224
+ left: var(--tau-pos);
225
+ width: 2px;
226
+ background: var(--pitch-ink);
227
+ box-shadow: 0 0 0 1px rgba(0,0,0,0.7);
228
+ }
229
+
230
+ .signal-strip {
231
+ flex-wrap: wrap;
232
+ margin-top: 10px;
233
+ color: var(--pitch-muted);
234
+ font-size: 11px;
235
+ }
236
+
237
+ .signal-strip span:first-child {
238
+ color: var(--agent-color);
239
+ font-weight: 800;
240
+ }
241
+
242
+ .table-zone {
243
+ display: grid;
244
+ grid-template-columns: minmax(0, 1fr) 300px;
245
+ gap: 16px;
246
+ margin-top: 18px;
247
+ }
248
+
249
+ .table-edge {
250
+ position: absolute;
251
+ left: -24px;
252
+ right: -24px;
253
+ bottom: -120px;
254
+ height: 250px;
255
+ border-top: 1px solid rgba(201, 154, 62, 0.26);
256
+ background:
257
+ linear-gradient(180deg, rgba(201, 154, 62, 0.12), rgba(0,0,0,0.1)),
258
+ repeating-linear-gradient(90deg, rgba(255,255,255,0.03) 0 1px, transparent 1px 110px),
259
+ #17100a;
260
+ transform: perspective(800px) rotateX(54deg);
261
+ transform-origin: top center;
262
+ z-index: -1;
263
+ }
264
+
265
+ .transcript-shell,
266
+ .scoreboard {
267
+ border: 1px solid var(--pitch-line);
268
+ border-radius: 8px;
269
+ background: rgba(8, 8, 7, 0.72);
270
+ backdrop-filter: blur(10px);
271
+ }
272
+
273
+ .transcript-title {
274
+ display: flex;
275
+ justify-content: space-between;
276
+ gap: 12px;
277
+ padding: 12px 14px;
278
+ border-bottom: 1px solid var(--pitch-line);
279
+ color: var(--pitch-muted);
280
+ font-size: 12px;
281
+ font-weight: 800;
282
+ text-transform: uppercase;
283
+ }
284
+
285
+ .transcript-scroll {
286
+ display: flex;
287
+ flex-direction: column;
288
+ gap: 10px;
289
+ max-height: 320px;
290
+ overflow-y: auto;
291
+ padding: 14px;
292
+ scroll-behavior: smooth;
293
+ }
294
+
295
+ .feed-item {
296
+ position: relative;
297
+ padding: 11px 12px;
298
+ border-left: 3px solid rgba(244, 239, 228, 0.16);
299
+ border-radius: 6px;
300
+ background: rgba(255, 255, 255, 0.045);
301
+ }
302
+
303
+ .feed-item.founder {
304
+ border-color: #d7d1c3;
305
+ }
306
+
307
+ .feed-item.investor,
308
+ .feed-item.verdict {
309
+ border-color: var(--pitch-red);
310
+ background: rgba(255, 90, 95, 0.08);
311
+ }
312
+
313
+ .feed-item.backchannel {
314
+ border-color: var(--pitch-brass);
315
+ background: rgba(201, 154, 62, 0.08);
316
+ }
317
+
318
+ .feed-item.score {
319
+ border-color: var(--pitch-green);
320
+ }
321
+
322
+ .feed-meta {
323
+ display: flex;
324
+ justify-content: space-between;
325
+ gap: 12px;
326
+ color: var(--pitch-muted);
327
+ font-size: 11px;
328
+ text-transform: uppercase;
329
+ }
330
+
331
+ .feed-meta span {
332
+ color: var(--pitch-ink);
333
+ font-weight: 800;
334
+ }
335
+
336
+ .feed-text {
337
+ margin-top: 5px;
338
+ color: var(--pitch-ink);
339
+ font-size: 15px;
340
+ line-height: 1.38;
341
+ }
342
+
343
+ .action-badge {
344
+ display: inline-flex;
345
+ margin-top: 8px;
346
+ padding: 3px 7px;
347
+ border: 1px solid currentColor;
348
+ border-radius: 999px;
349
+ color: var(--pitch-brass);
350
+ font-size: 10px;
351
+ letter-spacing: 0.08em;
352
+ }
353
+
354
+ .scoreboard {
355
+ padding: 14px;
356
+ }
357
+
358
+ .score-row {
359
+ display: flex;
360
+ justify-content: space-between;
361
+ gap: 10px;
362
+ padding: 11px 0;
363
+ border-bottom: 1px solid var(--pitch-line);
364
+ color: var(--pitch-muted);
365
+ font-size: 13px;
366
+ }
367
+
368
+ .score-row b {
369
+ color: var(--pitch-ink);
370
+ text-align: right;
371
+ }
372
+
373
+ .final-score {
374
+ margin-top: 14px;
375
+ padding: 13px;
376
+ border: 1px solid rgba(201, 154, 62, 0.32);
377
+ border-radius: 6px;
378
+ background: rgba(201, 154, 62, 0.10);
379
+ }
380
+
381
+ .final-score small {
382
+ color: var(--pitch-muted);
383
+ text-transform: uppercase;
384
+ font-weight: 800;
385
+ }
386
+
387
+ .final-score strong {
388
+ display: block;
389
+ margin-top: 4px;
390
+ color: var(--pitch-brass);
391
+ font-size: 21px;
392
+ }
393
+
394
+ .final-score p {
395
+ margin: 8px 0 0;
396
+ color: var(--pitch-ink);
397
+ font-size: 13px;
398
+ line-height: 1.35;
399
+ }
400
+
401
+ #pitch-control-row {
402
+ max-width: 1260px;
403
+ margin: 14px auto 0;
404
+ align-items: stretch;
405
+ }
406
+
407
+ #pitch-input textarea,
408
+ #pitch-controls,
409
+ #pitch-audio {
410
+ border-color: rgba(244, 239, 228, 0.16) !important;
411
+ background: #11100e !important;
412
+ color: var(--pitch-ink) !important;
413
+ }
414
+
415
+ #pitch-controls {
416
+ min-width: 280px;
417
+ padding: 13px;
418
+ border: 1px solid rgba(244, 239, 228, 0.16);
419
+ border-radius: 8px;
420
+ }
421
+
422
+ #run-pitch {
423
+ border: 1px solid rgba(255, 90, 95, 0.7) !important;
424
+ background: #ff5a5f !important;
425
+ color: #080807 !important;
426
+ font-weight: 900 !important;
427
+ }
428
+
429
+ @media (max-width: 980px) {
430
+ .panel-rail,
431
+ .table-zone {
432
+ grid-template-columns: 1fr;
433
+ }
434
+
435
+ .pitch-topbar {
436
+ flex-direction: column;
437
+ }
438
+
439
+ .room-ledger {
440
+ justify-content: flex-start;
441
+ }
442
+ }
443
+
444
+ @media (max-width: 640px) {
445
+ .pitch-room {
446
+ padding: 16px;
447
+ }
448
+
449
+ .pitch-topbar h1 {
450
+ font-size: 24px;
451
+ }
452
+
453
+ .panel-rail {
454
+ gap: 10px;
455
+ }
456
+
457
+ .transcript-scroll {
458
+ max-height: 360px;
459
+ }
460
+ }
apps/pitch/static/pitch.js ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ () => {
2
+ const keepTranscriptPinned = () => {
3
+ document.querySelectorAll(".transcript-scroll").forEach((node) => {
4
+ node.scrollTop = node.scrollHeight;
5
+ });
6
+ };
7
+
8
+ const observer = new MutationObserver(() => {
9
+ keepTranscriptPinned();
10
+ document.querySelectorAll(".investor-card.flash-interrupt").forEach((node) => {
11
+ node.classList.remove("flash-replay");
12
+ void node.offsetWidth;
13
+ node.classList.add("flash-replay");
14
+ });
15
+ });
16
+
17
+ const autorun = () => {
18
+ const params = new URLSearchParams(window.location.search);
19
+ if (!params.has("autorun")) {
20
+ return;
21
+ }
22
+ let tries = 0;
23
+ const timer = window.setInterval(() => {
24
+ tries += 1;
25
+ const runButton = document.querySelector("#run-pitch button") || document.querySelector("#run-pitch");
26
+ const room = document.querySelector("#pitch-room");
27
+ const status = room ? room.dataset.status : "";
28
+ if (status && status !== "ready") {
29
+ window.clearInterval(timer);
30
+ return;
31
+ }
32
+ if (runButton && tries > 8) {
33
+ runButton.click();
34
+ }
35
+ if (tries > 80) {
36
+ window.clearInterval(timer);
37
+ }
38
+ }, 500);
39
+ };
40
+
41
+ observer.observe(document.body, { childList: true, subtree: true });
42
+ keepTranscriptPinned();
43
+ autorun();
44
+ };
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,6 @@
 
 
 
 
 
 
 
1
+ gradio==6.18.0
2
+ kokoro==0.9.4
3
+ modal==1.5.0
4
+ numpy==2.4.6
5
+ requests==2.32.5
6
+ soundfile==0.14.0
static/README.md ADDED
@@ -0,0 +1 @@
 
 
1
+ Static assets for the Space live under `apps/pitch/static/`.