clawft_kernel/duplex.rs
1//! Daemon-side full-duplex conversation-floor state machine (ADR-068 D1).
2//!
3//! [`DuplexChannel`] is the **actuator** of ECC floor verdicts, never a decider of
4//! the floor. ECC (the [`TalkModeLoop`](crate::talk_loop::TalkModeLoop)) decides;
5//! this machine executes. Every ad-hoc hold that lived inline in the old
6//! half-duplex `TalkModeController` (mic-drain, prebuffer, barge grace, ack-cache)
7//! becomes one transition *rule* here, in the daemon (ADR-068 §Context thesis 1).
8//!
9//! ## The actuator/decider boundary, enforced by construction (ADR-068 D1, plan §2)
10//!
11//! The two mutators are split by the type they consume:
12//!
13//! - [`observe`](DuplexChannel::observe) takes a [`StreamObservation`] — something
14//! the daemon derived from the media stream (VAD onset, endpoint, ERL, drain,
15//! heartbeat) — and returns `(Option<DuplexImpulse>, Vec<EdgeCommand>)`. It may
16//! perform **daemon-local** transitions and emit floor-pressure impulses, but its
17//! input and output types contain **no** [`FloorVerdict`], and a
18//! [`DuplexImpulse`] can only carry an [`ImpulseType`] (`TurnClaim`/`EndOfUtterance`)
19//! — there is no `Grant` variant. So `observe` is *structurally incapable* of
20//! granting the floor.
21//! - [`apply`](DuplexChannel::apply) is the **only** method that takes a
22//! [`FloorVerdict`] (the type that carries `GrantUser`/`GrantAgent`), so it is the
23//! only path that can perform an ECC-authoritative floor transition.
24//!
25//! A reviewer verifies "the daemon never decides the floor from the stream" by
26//! reading the two signatures — not by auditing the bodies.
27//!
28//! ## Resolved D1-table ambiguity: Overlap entry vs. the ERL gate
29//!
30//! The D1 row `Speaking | onset above gate, ERL confirms → Overlap` reads, in
31//! isolation, as if a *high* ERL were required to enter `Overlap`. That is **not**
32//! how this machine implements it, because the required self-cancel regression test
33//! (plan §3) drives a *low*-ERL onset during `Speaking` and asserts `Overlap` is
34//! entered and then reverted by an ECC `DenyBarge`. The two are reconciled per the
35//! ADR's own stated mechanism (ADR-068 D1 "The self-cancel fix"): barge-in "becomes
36//! a runtime floor decision", i.e. the ERL discriminates genuine barge from AEC
37//! self-echo **at the floor read**, not at `Overlap` entry. So:
38//!
39//! - Any VAD onset during `Speaking` (the energy gate is upstream, in the edge/daemon
40//! VAD) enters the **provisional** `Overlap` and emits a `TurnClaim` carrying the
41//! last measured ERL confidence.
42//! - ECC weights a low-ERL `TurnClaim` down (`compute_urgency`, ADR-062 D4, Phase 1)
43//! and returns [`FloorVerdict::DenyBarge`] → back to `Speaking`; a genuine
44//! high-ERL onset yields [`FloorVerdict::GrantUser`] → `Listening` + cancel.
45//!
46//! This keeps the decider in ECC and makes the self-cancel fix expressible without
47//! the machine judging the barge itself. `Overlap` is explicitly provisional
48//! (ADR-068 D1 states table).
49
50use crate::impulse::ImpulseType;
51
52// ---------------------------------------------------------------------------
53// States
54// ---------------------------------------------------------------------------
55
56/// The seven floor states (ADR-068 D1 states table). The edge holds only the
57/// reflex shadow of these (see `clawft-channels` `edge_reflex`); the authoritative
58/// machine is daemon-side.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum DuplexState {
61 /// No active floor; the daemon watches the stream for onset. Mic streaming,
62 /// speaker silent.
63 Idle,
64 /// The user holds the floor; the daemon accumulates the utterance.
65 Listening,
66 /// The daemon detected EOU; it is walking the graph and rendering.
67 Thinking,
68 /// The agent holds the floor; the daemon streams TTS down. Speaker playing.
69 Speaking,
70 /// Provisional: the daemon detected onset during `Speaking`. Playback
71 /// continues pending the ECC verdict (grant → `Listening`, deny → `Speaking`).
72 Overlap,
73 /// The daemon classified a continuer ("mm-hmm"); playback continues.
74 Backchannel,
75 /// The link is lost; the edge reflex owns safety and the daemon has no view.
76 Degraded,
77}
78
79/// Which payload rides the media lane — the axis that decides which transitions
80/// are *reachable* (ADR-068 D2/D6). The machine is identical; text simply cannot
81/// reach the audio-only states.
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub enum PayloadKind {
84 /// PCM audio: every state is reachable.
85 Audio,
86 /// UTF-8 text (a terminal, GUI chat panel, or inkpad). The floor is always
87 /// granted (submit *is* the floor), so `Overlap`/`Backchannel`/barge/ERL are
88 /// structurally unreachable and the machine collapses to
89 /// `Idle → Listening → Thinking → Speaking → Idle` (ADR-068 D6).
90 Text,
91}
92
93// ---------------------------------------------------------------------------
94// Wire vocabulary
95// ---------------------------------------------------------------------------
96
97/// A media-lane payload carried by [`EdgeCommand::Play`] (ADR-068 D3): PCM when
98/// the edge offers no local codec, rendered text when it advertises local TTS.
99#[derive(Debug, Clone, PartialEq, Eq)]
100pub enum MediaPayload {
101 /// 16 kHz PCM frame bytes down to the edge speaker.
102 Pcm(Vec<u8>),
103 /// Rendered text down to a text edge.
104 Text(String),
105}
106
107/// A concrete command down the control lane to the edge (ADR-068 D3). Floor
108/// verdicts reach the edge *only* as these — the edge never sees a [`FloorVerdict`]
109/// or an impulse code.
110#[derive(Debug, Clone, PartialEq, Eq)]
111pub enum EdgeCommand {
112 /// Media down the media lane (PCM or text). Emitted by the renderer in Phase 1;
113 /// the floor machine itself does not synthesise `Play` in Phase 0.
114 Play(MediaPayload),
115 /// Silence the speaker **instantly**, without draining the playout buffer
116 /// (barge confirmed). Pairs with [`EdgeCommand::Cancel`] on a grant.
117 Mute,
118 /// Stop output (link-loss / end). Distinct from `Mute`: `Silence` is the
119 /// end-of-floor quiet, `Mute` is the mid-playback barge cut.
120 Silence,
121 /// Return the speaker to full playback — the "un-duck" of a denied barge
122 /// (ADR-068 D1 `Overlap → Speaking`). Idempotent on a thin edge that never
123 /// ducked.
124 Resume,
125 /// Cancel the in-flight TTS render (barge granted; the answer is superseded).
126 Cancel,
127 /// Session/capability configuration at the handshake (ADR-068 D2/D4). Carries
128 /// no payload in Phase 0 (no capability negotiation yet).
129 Config,
130}
131
132/// Something the daemon **derived from the media stream** (ADR-068 D3: "the daemon
133/// derives impulses from the media stream itself"). None of these can grant the
134/// floor — they are observations, not verdicts.
135#[derive(Debug, Clone, Copy, PartialEq)]
136pub enum StreamObservation {
137 /// VAD onset detected in the up-stream — a floor claim (ADR-062 D5,
138 /// `SpeechStart → TurnClaim 0x50`).
139 VadOnset,
140 /// VAD end-of-speech (a brief offset). Ends a `Backchannel` continuer.
141 VadOffset,
142 /// Smart-turn endpoint — the user's utterance is complete
143 /// (`EndOfUtterance 0x51`).
144 SmartTurnEou,
145 /// The edge reported its TTS playout has drained.
146 PlaybackDrained,
147 /// The latest echo-return-loss confidence the edge measured locally and
148 /// streamed up the control lane. A daemon-held scalar folded into the floor
149 /// read; never a transition by itself.
150 Erl(f32),
151 /// The edge heartbeat was lost — the link is down.
152 HeartbeatLost,
153 /// The link was restored and the edge re-handshook.
154 LinkRestored,
155}
156
157/// The ECC-authoritative floor verdict from the [`TalkModeLoop`](crate::talk_loop).
158/// This is the **only** type carrying a floor grant, and [`apply`](DuplexChannel::apply)
159/// is the only method that consumes it — the structural core of the actuator/decider
160/// boundary (ADR-068 D1, plan §2).
161#[derive(Debug, Clone, Copy, PartialEq, Eq)]
162pub enum FloorVerdict {
163 /// Grant the user the floor, cancelling the agent's output (`Overlap → Listening`;
164 /// TTS cancel = `Contradicts`/prune, `Mute` to the edge).
165 GrantUser,
166 /// Grant the agent the floor: readiness met (`Thinking → Speaking`), or new
167 /// committed content supersedes an ack while already `Speaking`.
168 GrantAgent,
169 /// Deny a provisional barge — low ERL confidence (`Overlap → Speaking`; un-duck).
170 /// The principled self-cancel fix.
171 DenyBarge,
172 /// Classify a mid-`Speaking` onset as a continuer — a `Continuer` cross-ref,
173 /// never a turn (`Speaking → Backchannel`, ADR-062 D5).
174 Backchannel,
175 /// The user resumes during `Thinking`; the floor shifts back (`Thinking →
176 /// Listening`).
177 TurnShift,
178}
179
180/// A floor-pressure impulse the daemon derives from the media stream and lifts onto
181/// the ECC [`ImpulseQueue`](crate::impulse::ImpulseQueue) that feeds the
182/// [`TalkModeLoop`](crate::talk_loop). Named to avoid colliding with
183/// [`crate::impulse::Impulse`]; the plan (§Phase 0) calls this the derived
184/// "Impulse". It can only carry an [`ImpulseType`] (no grant variant exists), which
185/// is what makes [`observe`](DuplexChannel::observe) unable to decide the floor.
186#[derive(Debug, Clone, PartialEq)]
187pub struct DuplexImpulse {
188 /// The impulse kind — `TurnClaim`/`EndOfUtterance` in Phase 0.
189 pub kind: ImpulseType,
190 /// The last ERL confidence the edge measured, carried so the floor read can
191 /// weight a low-ERL `TurnClaim` down (ADR-068 D1 self-cancel fix). `None` until
192 /// the edge streams an [`StreamObservation::Erl`].
193 pub erl: Option<f32>,
194}
195
196impl DuplexImpulse {
197 fn new(kind: ImpulseType, erl: Option<f32>) -> Self {
198 Self { kind, erl }
199 }
200}
201
202// ---------------------------------------------------------------------------
203// DuplexChannel
204// ---------------------------------------------------------------------------
205
206/// The daemon-side floor state machine (ADR-068 D1). One per conversation. Pure
207/// logic — no I/O, no async: it turns stream observations and ECC verdicts into a
208/// state trajectory plus concrete [`EdgeCommand`]s.
209#[derive(Debug, Clone)]
210pub struct DuplexChannel {
211 state: DuplexState,
212 payload: PayloadKind,
213 /// The last ERL confidence streamed up by the edge, folded into emitted
214 /// `TurnClaim`s so the floor read can discriminate self-echo.
215 last_erl: Option<f32>,
216}
217
218impl DuplexChannel {
219 /// A new channel in [`DuplexState::Idle`] for the given payload axis.
220 pub fn new(payload: PayloadKind) -> Self {
221 Self {
222 state: DuplexState::Idle,
223 payload,
224 last_erl: None,
225 }
226 }
227
228 /// The current floor state.
229 pub fn state(&self) -> DuplexState {
230 self.state
231 }
232
233 /// The payload axis this channel was constructed with.
234 pub fn payload(&self) -> PayloadKind {
235 self.payload
236 }
237
238 /// The last ERL confidence the edge streamed up (`None` until first observed).
239 pub fn last_erl(&self) -> Option<f32> {
240 self.last_erl
241 }
242
243 /// Apply a **daemon-local** stream observation (ADR-068 D1). Returns the
244 /// floor-pressure impulse to lift onto the ECC queue (if any) plus the edge
245 /// commands to send. Cannot grant the floor — see the module docs.
246 pub fn observe(
247 &mut self,
248 obs: StreamObservation,
249 ) -> (Option<DuplexImpulse>, Vec<EdgeCommand>) {
250 use DuplexState::*;
251 use StreamObservation::*;
252
253 match obs {
254 // Continuous ERL update from the control lane: no transition, no
255 // impulse — just refresh the scalar the next `TurnClaim` carries.
256 Erl(v) => {
257 self.last_erl = Some(v);
258 (None, Vec::new())
259 }
260
261 // An onset is always a floor claim (`SpeechStart → TurnClaim 0x50`).
262 // The state transition depends on where we are; the impulse is emitted
263 // so ECC can arbitrate (Idle: implicit sole-party grant; Speaking:
264 // provisional Overlap pending grant/deny; else: floor pressure only).
265 VadOnset => match self.state {
266 Degraded => (None, Vec::new()), // no link, no stream
267 Idle => {
268 self.state = Listening;
269 (self.turn_claim(), Vec::new())
270 }
271 Speaking if self.payload == PayloadKind::Audio => {
272 // Provisional Overlap. Playback continues — the canonical thin
273 // edge does NOT duck (the provisional-duck reflex is opt-in and
274 // off by default, ADR-068 D5), so no edge command is emitted
275 // until ECC rules. The ERL rides the claim for the floor read.
276 self.state = Overlap;
277 (self.turn_claim(), Vec::new())
278 }
279 // Listening (onset within the utterance), Thinking (a physical
280 // resume — ECC decides TurnShift), Overlap/Backchannel (already
281 // handling an onset), or Speaking-in-text (no barge): stay put, but
282 // still surface the floor claim to ECC.
283 _ => (self.turn_claim(), Vec::new()),
284 },
285
286 // Smart-turn endpoint: only meaningful while the user holds the floor.
287 SmartTurnEou => match self.state {
288 Listening => {
289 self.state = Thinking;
290 (
291 Some(DuplexImpulse::new(ImpulseType::EndOfUtterance, self.last_erl)),
292 Vec::new(),
293 )
294 }
295 _ => (None, Vec::new()),
296 },
297
298 // The edge reports playout drained: the agent's turn is over.
299 PlaybackDrained => match self.state {
300 Speaking | Overlap | Backchannel => {
301 self.state = Idle;
302 (None, Vec::new())
303 }
304 _ => (None, Vec::new()),
305 },
306
307 // A brief offset ends a continuer; playback reclaims the floor.
308 VadOffset => match self.state {
309 Backchannel => {
310 self.state = Speaking;
311 (None, Vec::new())
312 }
313 _ => (None, Vec::new()),
314 },
315
316 // Link loss fires from any state. The daemon marks stale; it emits no
317 // edge command (the link is down — the edge's own reflex owns safety,
318 // ADR-068 D5 the one self-firing rule).
319 HeartbeatLost => {
320 self.state = Degraded;
321 (None, Vec::new())
322 }
323
324 // Reconnect + resync: only meaningful out of Degraded.
325 LinkRestored => match self.state {
326 Degraded => {
327 self.state = Idle;
328 self.last_erl = None;
329 (None, Vec::new())
330 }
331 _ => (None, Vec::new()),
332 },
333 }
334 }
335
336 /// Apply an ECC floor verdict (ADR-068 D1). The **only** floor-bearing mutator:
337 /// it consumes the one type that carries a grant. Returns the edge commands the
338 /// verdict actuates.
339 pub fn apply(&mut self, verdict: FloorVerdict) -> Vec<EdgeCommand> {
340 use DuplexState::*;
341
342 match verdict {
343 // Barge granted: cancel the agent's answer and mute the speaker instantly.
344 FloorVerdict::GrantUser => match self.state {
345 Overlap => {
346 self.state = Listening;
347 vec![EdgeCommand::Mute, EdgeCommand::Cancel]
348 }
349 _ => Vec::new(),
350 },
351
352 // Barge denied (low ERL — the self-cancel fix): un-duck, keep speaking.
353 // No Mute/Cancel reaches the edge; the answer is NOT cancelled.
354 FloorVerdict::DenyBarge => match self.state {
355 Overlap => {
356 self.state = Speaking;
357 vec![EdgeCommand::Resume]
358 }
359 _ => Vec::new(),
360 },
361
362 // Agent granted the floor: readiness met (Thinking) or new content
363 // supersedes an ack (already Speaking). The renderer streams `Play`
364 // frames down the media lane; the machine emits no synthetic command.
365 FloorVerdict::GrantAgent => match self.state {
366 Thinking | Speaking => {
367 self.state = Speaking;
368 Vec::new()
369 }
370 _ => Vec::new(),
371 },
372
373 // Mid-Speaking onset classified as a continuer: playback continues.
374 // Audio-only: a text edge produces no backchannel classification, so
375 // this is structurally inert in `Text` mode and `Backchannel` stays
376 // unreachable (ADR-068 D6).
377 FloorVerdict::Backchannel if self.payload == PayloadKind::Audio => {
378 match self.state {
379 Speaking => {
380 self.state = Backchannel;
381 Vec::new()
382 }
383 _ => Vec::new(),
384 }
385 }
386 FloorVerdict::Backchannel => Vec::new(),
387
388 // The user resumes during Thinking; the floor shifts back.
389 FloorVerdict::TurnShift => match self.state {
390 Thinking => {
391 self.state = Listening;
392 Vec::new()
393 }
394 _ => Vec::new(),
395 },
396 }
397 }
398
399 /// A `TurnClaim` impulse carrying the current ERL (ADR-062 D5 / ADR-068 D1).
400 fn turn_claim(&self) -> Option<DuplexImpulse> {
401 Some(DuplexImpulse::new(ImpulseType::TurnClaim, self.last_erl))
402 }
403}
404
405// ── Tests (split to a sibling file for the <500-line rule) ────────────────────
406#[cfg(test)]
407#[path = "duplex_tests.rs"]
408mod tests;