Skip to main content

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;