Deploy Pitch or Perish Space
Browse files- README.md +63 -7
- app.py +19 -0
- apps/__init__.py +1 -0
- apps/pitch/__init__.py +1 -0
- apps/pitch/app.py +91 -0
- apps/pitch/runtime.py +938 -0
- apps/pitch/static/pitch.css +460 -0
- apps/pitch/static/pitch.js +44 -0
- engine/CONTROLLER_NOTES.md +42 -0
- engine/CONVERSATION_NOTES.md +58 -0
- engine/__init__.py +2 -0
- engine/bocpd.py +144 -0
- engine/brain.py +119 -0
- engine/controller.py +321 -0
- engine/conversation.py +259 -0
- engine/live_brain.py +151 -0
- engine/probe.py +240 -0
- engine/traces.py +180 -0
- engine/viz.py +98 -0
- requirements.txt +6 -0
- static/README.md +1 -0
README.md
CHANGED
|
@@ -1,13 +1,69 @@
|
|
| 1 |
---
|
| 2 |
-
title: Pitch
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
sdk_version: 6.18.0
|
| 8 |
-
python_version: '3.
|
| 9 |
app_file: app.py
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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>τ {_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/`.
|