Skip to main content

clawft_kernel/
floor.rs

1//! Conversational floor as a **scored read**, not an engine (ADR-062 D4).
2//!
3//! Each tick the Talk-Mode loop (Phase 2) reads who should hold the floor by
4//! scoring the contending candidates with [`compute_urgency`] and folding the
5//! result into a [`FloorState`]. Nothing here owns state-machine orchestration:
6//! it is a pure function of the [`ImpulseQueue`](crate::impulse::ImpulseQueue)
7//! entries (the turn signals) crossed with the live frontier
8//! ([`SessionView::live_seqs`](crate::context_graft::SessionView::live_seqs)).
9//!
10//! The weights are **constants** in v0.1 (hand-tuned, ADR-062 §D4); the Weaver
11//! learns them later (deferred backlog). A hard interrupt short-circuits the
12//! score to [`f32::MAX`] so it preempts everything and cancels generation.
13
14use crate::impulse::{Impulse, ImpulseType};
15
16// ── Urgency weights (ADR-062 D4 — sum to 1.0) ────────────────────────────────
17
18/// Weight on semantic relevance to the frontier head.
19pub const W_SEMANTIC: f32 = 0.30;
20/// Weight on the speaker's emotional arousal (VAD arousal).
21pub const W_AROUSAL: f32 = 0.15;
22/// Weight on fairness pressure (how long this candidate has waited).
23pub const W_WAIT: f32 = 0.20;
24/// Weight on crowd density (number of contending actors).
25pub const W_CROWD: f32 = 0.10;
26/// Weight on content readiness (generated > partial > not-started).
27pub const W_READINESS: f32 = 0.25;
28
29/// Crowd-density saturation point: at/above this many contenders the crowd
30/// signal reads `1.0`.
31pub const MAX_CROWD: usize = 4;
32
33/// Default ERL confidence floor for admitting a barge (ADR-068 D1 / WEFT-628).
34///
35/// A `TurnClaim` whose edge-measured echo-return-loss confidence is **below**
36/// this value is treated as self-echo (AEC leakage of the bot's own reply) and
37/// must not win the floor — the principled self-cancel fix. Exposed as a knob
38/// so Phase-1 sim data and live loud-room verification can retune it.
39///
40/// Chosen from ADR-068 Phase 0 loopback sim (self-echo samples cluster ≤0.2;
41/// genuine barge onset samples ≥0.7) with a mid-band default for headroom.
42pub const ERL_BARGE_FLOOR: f32 = 0.50;
43
44/// Default hysteresis band below [`ERL_BARGE_FLOOR`] (WEFT-615).
45///
46/// Stateful [`ErlBargeGate`] admits at `floor` and only releases once ERL
47/// drops below `floor − hysteresis` (default release = 0.40). Prevents
48/// chatter at the admit boundary when AEC residual flutters mid-playback.
49pub const ERL_BARGE_HYSTERESIS: f32 = 0.10;
50
51/// Tunable ERL barge knobs (ADR-068 D1 / WEFT-615): floor + hysteresis.
52///
53/// Barge-in is **not** a static on/off switch — a claim is admitted when
54/// measured ERL confidence clears the floor. Retune from live loud-room
55/// data without code changes.
56#[derive(Debug, Clone, Copy, PartialEq)]
57pub struct ErlBargeConfig {
58    /// Admit threshold in `[0, 1]`: `erl >= floor` → grant barge.
59    pub floor: f32,
60    /// Hysteresis band width; release at `floor − hysteresis` (clamped ≥ 0).
61    pub hysteresis: f32,
62}
63
64impl Default for ErlBargeConfig {
65    fn default() -> Self {
66        Self {
67            floor: ERL_BARGE_FLOOR,
68            hysteresis: ERL_BARGE_HYSTERESIS,
69        }
70    }
71}
72
73impl ErlBargeConfig {
74    /// Explicit knobs (tests / config plumbing).
75    pub const fn new(floor: f32, hysteresis: f32) -> Self {
76        Self { floor, hysteresis }
77    }
78
79    /// Release threshold: `max(0, floor − hysteresis)`.
80    #[inline]
81    pub fn release_floor(self) -> f32 {
82        (self.floor - self.hysteresis).max(0.0)
83    }
84
85    /// Stateless one-shot admit (no hysteresis hold).
86    #[inline]
87    pub fn admits(self, erl: f32) -> bool {
88        erl.is_finite() && erl >= self.floor
89    }
90}
91
92/// Stateful ERL barge gate with hysteresis (WEFT-615).
93///
94/// Feed successive ERL samples during provisional `Overlap` / continuous
95/// barge monitoring. Once admitted, stays admitted until ERL falls below
96/// the release floor — so boundary flutter does not thrash Mute/Resume.
97#[derive(Debug, Clone)]
98pub struct ErlBargeGate {
99    config: ErlBargeConfig,
100    admitted: bool,
101}
102
103impl ErlBargeGate {
104    /// A gate with the given knobs, starting denied (safe self-cancel default).
105    pub const fn new(config: ErlBargeConfig) -> Self {
106        Self {
107            config,
108            admitted: false,
109        }
110    }
111
112    /// Default floor + hysteresis, starting denied.
113    pub const fn with_defaults() -> Self {
114        Self::new(ErlBargeConfig {
115            floor: ERL_BARGE_FLOOR,
116            hysteresis: ERL_BARGE_HYSTERESIS,
117        })
118    }
119
120    /// Current knobs.
121    pub fn config(&self) -> ErlBargeConfig {
122        self.config
123    }
124
125    /// Whether the gate currently admits a barge.
126    pub fn is_admitted(&self) -> bool {
127        self.admitted
128    }
129
130    /// Observe one ERL sample; update hysteresis state; return admit decision.
131    pub fn observe(&mut self, erl: f32) -> bool {
132        if !erl.is_finite() {
133            self.admitted = false;
134            return false;
135        }
136        if erl >= self.config.floor {
137            self.admitted = true;
138        } else if erl < self.config.release_floor() {
139            self.admitted = false;
140        }
141        // else: hold prior state inside the hysteresis band
142        self.admitted
143    }
144
145    /// Reset to denied (e.g. on link restore / new Speaking turn).
146    pub fn reset(&mut self) {
147        self.admitted = false;
148    }
149}
150
151impl Default for ErlBargeGate {
152    fn default() -> Self {
153        Self::with_defaults()
154    }
155}
156
157/// Whether a measured ERL confidence clears the default barge floor
158/// (WEFT-628 / WEFT-615). Stateless one-shot — prefer [`ErlBargeGate`] when
159/// sampling repeatedly during playback.
160///
161/// Used by the duplex / Talk-Mode loop when translating a provisional `Overlap`
162/// into an ECC [`FloorVerdict`](crate::duplex::FloorVerdict): high ERL →
163/// `GrantUser`, low ERL → `DenyBarge`.
164#[inline]
165pub fn erl_admits_barge(erl: f32) -> bool {
166    ErlBargeConfig::default().admits(erl)
167}
168
169/// Whether an optional ERL sample admits a barge under `config` (WEFT-615).
170///
171/// `None` (no measurement yet) is **deny** — safe self-cancel default so
172/// unknown residual never cancels TTS.
173#[inline]
174pub fn erl_admits_barge_opt(erl: Option<f32>, config: &ErlBargeConfig) -> bool {
175    match erl {
176        Some(v) => config.admits(v),
177        None => false,
178    }
179}
180
181// ── Content readiness ────────────────────────────────────────────────────────
182
183/// How far along a candidate's response content is (ADR-062 D4). This is the
184/// term that ties floor-winning to speculative-generation progress.
185#[derive(Debug, Clone, Copy, PartialEq, Eq)]
186pub enum ContentReadiness {
187    /// Nothing generated yet.
188    NotStarted,
189    /// Partially generated (e.g. a streaming speculative draft in flight).
190    Partial,
191    /// Fully generated and ready to render.
192    Generated,
193}
194
195impl ContentReadiness {
196    /// Normalized score in `[0, 1]`.
197    pub fn score(self) -> f32 {
198        match self {
199            ContentReadiness::NotStarted => 0.0,
200            ContentReadiness::Partial => 0.5,
201            ContentReadiness::Generated => 1.0,
202        }
203    }
204}
205
206// ── Urgency signals ──────────────────────────────────────────────────────────
207
208/// The scored inputs (plus the hard-interrupt flag) feeding
209/// [`compute_urgency`]. Each `f32` is expected in `[0, 1]`; values are clamped.
210///
211/// ## ERL term (ADR-068 D1 / WEFT-628)
212///
213/// Acoustic barge claims carry an optional [`erl_confidence`](Self::erl_confidence)
214/// scalar streamed up by the thin edge's AEC. When present, it **multiplies** the
215/// base ADR-062 D4 score so low-ERL (self-echo) claims cannot win the floor —
216/// the principled self-cancel fix. Text / non-acoustic contenders leave this
217/// `None` and keep the unscaled base score.
218#[derive(Debug, Clone, Copy)]
219pub struct UrgencySignals {
220    /// `cosine(candidate, frontier head)` — relevance to what is being said.
221    pub semantic_relevance: f32,
222    /// VAD arousal of this actor.
223    pub emotional_arousal: f32,
224    /// Normalized fairness pressure (longer wait ⇒ higher).
225    pub wait_time: f32,
226    /// Normalized contending-actor count (see [`crowd_density`]).
227    pub crowd_density: f32,
228    /// Readiness of this candidate's content.
229    pub content_readiness: ContentReadiness,
230    /// Hard interrupt — preempt everything, cancel in-flight generation.
231    pub hard_interrupt: bool,
232    /// Optional echo-return-loss confidence from the edge AEC (ADR-068 D1).
233    ///
234    /// `Some(v)` scales the base urgency by `v.clamp(0,1)` so a low-ERL
235    /// `TurnClaim` (bot self-echo past AEC) is weighted down. `None` means
236    /// "not an acoustic barge claim" and leaves the base score unchanged.
237    pub erl_confidence: Option<f32>,
238}
239
240impl Default for UrgencySignals {
241    fn default() -> Self {
242        Self {
243            semantic_relevance: 0.0,
244            emotional_arousal: 0.0,
245            wait_time: 0.0,
246            crowd_density: 0.0,
247            content_readiness: ContentReadiness::NotStarted,
248            hard_interrupt: false,
249            erl_confidence: None,
250        }
251    }
252}
253
254/// Score a candidate's urgency for the floor (ADR-062 D4 + ADR-068 ERL term).
255///
256/// ```text
257/// base    = 0.30·semantic + 0.15·arousal + 0.20·wait + 0.10·crowd + 0.25·readiness
258/// urgency = base · erl_scale            ; erl_scale = erl.clamp(0,1) or 1.0 if None
259///         ; hard interrupt ⇒ f32::MAX
260/// ```
261pub fn compute_urgency(s: &UrgencySignals) -> f32 {
262    if s.hard_interrupt {
263        return f32::MAX;
264    }
265    let c = |v: f32| v.clamp(0.0, 1.0);
266    let base = W_SEMANTIC * c(s.semantic_relevance)
267        + W_AROUSAL * c(s.emotional_arousal)
268        + W_WAIT * c(s.wait_time)
269        + W_CROWD * c(s.crowd_density)
270        + W_READINESS * s.content_readiness.score();
271    // ADR-068 D1: low ERL confidence weights a TurnClaim down so self-echo
272    // cannot win the floor. Non-acoustic candidates leave erl_confidence None.
273    let erl_scale = match s.erl_confidence {
274        Some(erl) if erl.is_finite() => erl.clamp(0.0, 1.0),
275        _ => 1.0,
276    };
277    base * erl_scale
278}
279
280/// Map a duplex `TurnClaim` ERL into a floor verdict scaffold (WEFT-628/615).
281///
282/// The full Talk-Mode loop still owns when to *ask* this; the helper is the
283/// pure ERL branch of ADR-068 D1 so unit tests and the thin-edge loopback can
284/// prove self-cancel without the full daemon. Uses default knobs.
285#[inline]
286pub fn floor_verdict_from_erl(erl: Option<f32>) -> crate::duplex::FloorVerdict {
287    floor_verdict_from_erl_config(erl, &ErlBargeConfig::default())
288}
289
290/// Like [`floor_verdict_from_erl`] with explicit knobs (WEFT-615).
291#[inline]
292pub fn floor_verdict_from_erl_config(
293    erl: Option<f32>,
294    config: &ErlBargeConfig,
295) -> crate::duplex::FloorVerdict {
296    if erl_admits_barge_opt(erl, config) {
297        crate::duplex::FloorVerdict::GrantUser
298    } else {
299        // Low ERL, non-finite, or missing → DenyBarge (self-cancel fix).
300        crate::duplex::FloorVerdict::DenyBarge
301    }
302}
303
304/// Normalized crowd-density signal in `[0, 1]` from the number of contending
305/// actors, saturating at [`MAX_CROWD`]. Derived from the
306/// [`ImpulseQueue`](crate::impulse::ImpulseQueue) (see [`contending_count`]).
307pub fn crowd_density(contenders: usize) -> f32 {
308    if MAX_CROWD == 0 {
309        return 0.0;
310    }
311    (contenders as f32 / MAX_CROWD as f32).clamp(0.0, 1.0)
312}
313
314/// Count the floor-claiming impulses in a drained set — every
315/// [`ImpulseType::TurnClaim`] is a contender for the floor (ADR-062 D5). Feeds
316/// [`crowd_density`].
317pub fn contending_count(impulses: &[Impulse]) -> usize {
318    impulses
319        .iter()
320        .filter(|i| i.impulse_type == ImpulseType::TurnClaim)
321        .count()
322}
323
324// ── Floor state & decision ───────────────────────────────────────────────────
325
326/// A candidate contending for the floor: an identity (actor/speaker) plus its
327/// scored signals and the HLC time it began contending.
328#[derive(Debug, Clone)]
329pub struct FloorCandidate {
330    /// Actor/speaker identity (or frontier seq tag).
331    pub id: String,
332    /// HLC timestamp this candidate began contending (for `Held.since`).
333    pub since: u64,
334    /// Scored urgency inputs.
335    pub signals: UrgencySignals,
336}
337
338impl FloorCandidate {
339    /// Convenience constructor.
340    pub fn new(id: impl Into<String>, since: u64, signals: UrgencySignals) -> Self {
341        Self {
342            id: id.into(),
343            since,
344            signals,
345        }
346    }
347
348    /// This candidate's urgency score.
349    pub fn urgency(&self) -> f32 {
350        compute_urgency(&self.signals)
351    }
352}
353
354/// The status of the conversational floor (ADR-062 D4 §8.3).
355#[derive(Debug, Clone, PartialEq)]
356pub enum FloorState {
357    /// No one is contending — the floor is free.
358    Open,
359    /// One clear holder, since the given HLC time.
360    Held {
361        /// The holder's identity.
362        holder: String,
363        /// HLC time the hold began.
364        since: u64,
365    },
366    /// Several actors contending (ordered by descending urgency).
367    Contended {
368        /// Contending identities, highest urgency first.
369        candidates: Vec<String>,
370    },
371    /// Preempted/locked (e.g. a hard interrupt cancelled generation).
372    Locked {
373        /// Why the floor is locked.
374        reason: String,
375    },
376}
377
378/// The outcome of a floor read: the resulting [`FloorState`], the scored winner
379/// (if any), and the winning urgency score.
380#[derive(Debug, Clone)]
381pub struct FloorDecision {
382    /// Resulting floor status.
383    pub state: FloorState,
384    /// The winning candidate's id, if the floor is taken/contended/locked.
385    pub winner: Option<String>,
386    /// The winning urgency score (`0.0` when the floor is Open).
387    pub score: f32,
388}
389
390/// Read the floor from the current set of contending candidates (ADR-062 D4).
391///
392/// - **No candidates** → [`FloorState::Open`].
393/// - **A hard interrupt present** → [`FloorState::Locked`], the interrupting
394///   candidate wins with [`f32::MAX`] (preempt + cancel generation).
395/// - **One candidate** → [`FloorState::Held`].
396/// - **Several candidates** → [`FloorState::Contended`] (ordered by urgency),
397///   the top scorer is the winner.
398///
399/// Ties are broken by input order (earliest-listed wins), so callers should
400/// supply candidates oldest-wait-first for deterministic fairness.
401pub fn evaluate_floor(candidates: &[FloorCandidate]) -> FloorDecision {
402    if candidates.is_empty() {
403        return FloorDecision {
404            state: FloorState::Open,
405            winner: None,
406            score: 0.0,
407        };
408    }
409
410    // Hard interrupt preempts everything.
411    if let Some(c) = candidates.iter().find(|c| c.signals.hard_interrupt) {
412        return FloorDecision {
413            state: FloorState::Locked {
414                reason: format!("hard-interrupt: {}", c.id),
415            },
416            winner: Some(c.id.clone()),
417            score: f32::MAX,
418        };
419    }
420
421    // Rank by urgency (stable: first-listed wins ties).
422    let mut ranked: Vec<&FloorCandidate> = candidates.iter().collect();
423    ranked.sort_by(|a, b| {
424        b.urgency()
425            .partial_cmp(&a.urgency())
426            .unwrap_or(std::cmp::Ordering::Equal)
427    });
428    let top = ranked[0];
429
430    if candidates.len() == 1 {
431        return FloorDecision {
432            state: FloorState::Held {
433                holder: top.id.clone(),
434                since: top.since,
435            },
436            winner: Some(top.id.clone()),
437            score: top.urgency(),
438        };
439    }
440
441    FloorDecision {
442        state: FloorState::Contended {
443            candidates: ranked.iter().map(|c| c.id.clone()).collect(),
444        },
445        winner: Some(top.id.clone()),
446        score: top.urgency(),
447    }
448}
449
450// ── Tests ────────────────────────────────────────────────────────────────────
451
452#[cfg(test)]
453mod tests {
454    use super::*;
455    use crate::impulse::ImpulseQueue;
456
457    fn signals(sem: f32, aro: f32, wait: f32, crowd: f32, r: ContentReadiness) -> UrgencySignals {
458        UrgencySignals {
459            semantic_relevance: sem,
460            emotional_arousal: aro,
461            wait_time: wait,
462            crowd_density: crowd,
463            content_readiness: r,
464            hard_interrupt: false,
465            erl_confidence: None,
466        }
467    }
468
469    #[test]
470    fn urgency_matches_hand_computed() {
471        // 0.30*1 + 0.15*0.8 + 0.20*0.5 + 0.10*0.25 + 0.25*0.5
472        // = 0.30 + 0.12 + 0.10 + 0.025 + 0.125 = 0.67
473        let s = signals(1.0, 0.8, 0.5, 0.25, ContentReadiness::Partial);
474        assert!((compute_urgency(&s) - 0.67).abs() < 1e-6);
475    }
476
477    #[test]
478    fn urgency_clamps_out_of_range_inputs() {
479        // Over-range inputs clamp to 1.0; readiness Generated = 1.0.
480        let s = signals(2.0, 5.0, 9.0, 9.0, ContentReadiness::Generated);
481        // All terms saturate ⇒ sum of weights = 1.0.
482        assert!((compute_urgency(&s) - 1.0).abs() < 1e-6);
483    }
484
485    #[test]
486    fn hard_interrupt_is_max() {
487        let mut s = signals(0.0, 0.0, 0.0, 0.0, ContentReadiness::NotStarted);
488        s.hard_interrupt = true;
489        assert_eq!(compute_urgency(&s), f32::MAX);
490    }
491
492    /// WEFT-628 / ADR-068 D1: low ERL confidence scales a barge claim toward zero
493    /// so self-echo cannot win the floor.
494    #[test]
495    fn low_erl_weights_urgency_down() {
496        let mut s = signals(1.0, 1.0, 1.0, 1.0, ContentReadiness::Generated);
497        // Base without ERL saturates to 1.0.
498        assert!((compute_urgency(&s) - 1.0).abs() < 1e-6);
499
500        s.erl_confidence = Some(0.1); // self-echo territory
501        assert!((compute_urgency(&s) - 0.1).abs() < 1e-6);
502
503        s.erl_confidence = Some(0.0);
504        assert!((compute_urgency(&s) - 0.0).abs() < 1e-6);
505
506        s.erl_confidence = Some(0.9); // genuine barge
507        assert!((compute_urgency(&s) - 0.9).abs() < 1e-6);
508    }
509
510    #[test]
511    fn erl_barge_floor_gate() {
512        assert!(!erl_admits_barge(0.0));
513        assert!(!erl_admits_barge(ERL_BARGE_FLOOR - 0.01));
514        assert!(erl_admits_barge(ERL_BARGE_FLOOR));
515        assert!(erl_admits_barge(0.99));
516        assert!(!erl_admits_barge(f32::NAN));
517    }
518
519    #[test]
520    fn erl_barge_config_knobs_and_release() {
521        let cfg = ErlBargeConfig::default();
522        assert!((cfg.floor - ERL_BARGE_FLOOR).abs() < 1e-6);
523        assert!((cfg.hysteresis - ERL_BARGE_HYSTERESIS).abs() < 1e-6);
524        assert!((cfg.release_floor() - 0.40).abs() < 1e-6);
525        assert!(!erl_admits_barge_opt(None, &cfg));
526        assert!(!erl_admits_barge_opt(Some(0.2), &cfg));
527        assert!(erl_admits_barge_opt(Some(0.8), &cfg));
528    }
529
530    /// WEFT-615: hysteresis holds admit/deny across the mid-band so AEC
531    /// residual flutter does not thrash the floor verdict.
532    #[test]
533    fn erl_barge_gate_hysteresis() {
534        let mut gate = ErlBargeGate::with_defaults();
535        assert!(!gate.is_admitted());
536
537        // Self-echo territory — stay denied.
538        assert!(!gate.observe(0.15));
539        assert!(!gate.observe(0.35)); // still below release (0.40)
540
541        // Cross admit floor → grant.
542        assert!(gate.observe(0.55));
543        assert!(gate.is_admitted());
544
545        // Drop into the hysteresis band (0.40..0.50) — hold admitted.
546        assert!(gate.observe(0.45), "hold admit inside hysteresis band");
547        assert!(gate.is_admitted());
548
549        // Drop below release → deny.
550        assert!(!gate.observe(0.30));
551        assert!(!gate.is_admitted());
552
553        // Rise into band without clearing admit floor — stay denied.
554        assert!(!gate.observe(0.45), "hold deny inside band until floor");
555        assert!(gate.observe(0.50)); // re-admit at floor
556    }
557
558    #[test]
559    fn floor_verdict_from_erl_self_cancel() {
560        use crate::duplex::FloorVerdict;
561        assert_eq!(
562            floor_verdict_from_erl(Some(0.1)),
563            FloorVerdict::DenyBarge,
564            "low ERL → DenyBarge (self-cancel fix)"
565        );
566        assert_eq!(
567            floor_verdict_from_erl(Some(0.9)),
568            FloorVerdict::GrantUser,
569            "high ERL → GrantUser"
570        );
571        assert_eq!(
572            floor_verdict_from_erl(None),
573            FloorVerdict::DenyBarge,
574            "missing ERL defaults safe (deny)"
575        );
576        // Custom floor (stricter) via config knob.
577        let strict = ErlBargeConfig::new(0.8, 0.1);
578        assert_eq!(
579            floor_verdict_from_erl_config(Some(0.7), &strict),
580            FloorVerdict::DenyBarge
581        );
582        assert_eq!(
583            floor_verdict_from_erl_config(Some(0.85), &strict),
584            FloorVerdict::GrantUser
585        );
586    }
587
588    #[test]
589    fn crowd_density_saturates() {
590        assert_eq!(crowd_density(0), 0.0);
591        assert!((crowd_density(2) - 0.5).abs() < 1e-6);
592        assert_eq!(crowd_density(MAX_CROWD), 1.0);
593        assert_eq!(crowd_density(MAX_CROWD + 10), 1.0);
594    }
595
596    #[test]
597    fn empty_floor_is_open() {
598        let d = evaluate_floor(&[]);
599        assert_eq!(d.state, FloorState::Open);
600        assert!(d.winner.is_none());
601        assert_eq!(d.score, 0.0);
602    }
603
604    #[test]
605    fn single_candidate_holds_floor() {
606        let c = FloorCandidate::new(
607            "alice",
608            42,
609            signals(0.5, 0.0, 0.0, 0.0, ContentReadiness::NotStarted),
610        );
611        let d = evaluate_floor(std::slice::from_ref(&c));
612        assert_eq!(
613            d.state,
614            FloorState::Held {
615                holder: "alice".into(),
616                since: 42
617            }
618        );
619        assert_eq!(d.winner.as_deref(), Some("alice"));
620        assert!((d.score - 0.15).abs() < 1e-6); // 0.30 * 0.5
621    }
622
623    /// The scored winner among contenders matches the hand-computed ranking.
624    #[test]
625    fn contended_floor_picks_highest_urgency() {
626        // bob: 0.30*0.9 = 0.27 ; carol: 0.25*1.0 = 0.25 ; alice: 0.20*0.5 = 0.10
627        let alice = FloorCandidate::new(
628            "alice",
629            1,
630            signals(0.0, 0.0, 0.5, 0.0, ContentReadiness::NotStarted),
631        );
632        let bob = FloorCandidate::new(
633            "bob",
634            2,
635            signals(0.9, 0.0, 0.0, 0.0, ContentReadiness::NotStarted),
636        );
637        let carol = FloorCandidate::new(
638            "carol",
639            3,
640            signals(0.0, 0.0, 0.0, 0.0, ContentReadiness::Generated),
641        );
642        let d = evaluate_floor(&[alice, bob, carol]);
643        assert_eq!(d.winner.as_deref(), Some("bob"));
644        assert!((d.score - 0.27).abs() < 1e-6);
645        match d.state {
646            FloorState::Contended { candidates } => {
647                assert_eq!(candidates, vec!["bob", "carol", "alice"]);
648            }
649            other => panic!("expected Contended, got {other:?}"),
650        }
651    }
652
653    /// A hard interrupt preempts a higher-scoring ordinary candidate.
654    #[test]
655    fn hard_interrupt_locks_and_preempts() {
656        let speaker = FloorCandidate::new(
657            "speaker",
658            1,
659            signals(1.0, 1.0, 1.0, 1.0, ContentReadiness::Generated),
660        );
661        let mut barge = signals(0.0, 0.0, 0.0, 0.0, ContentReadiness::NotStarted);
662        barge.hard_interrupt = true;
663        let interrupter = FloorCandidate::new("interrupter", 2, barge);
664
665        let d = evaluate_floor(&[speaker, interrupter]);
666        assert_eq!(d.winner.as_deref(), Some("interrupter"));
667        assert_eq!(d.score, f32::MAX);
668        assert_eq!(
669            d.state,
670            FloorState::Locked {
671                reason: "hard-interrupt: interrupter".into()
672            }
673        );
674    }
675
676    /// End-to-end: the floor read operates over ImpulseQueue entries crossed
677    /// with the live frontier seqs (ADR-062 D4 inputs).
678    #[test]
679    fn floor_read_over_queue_and_frontier() {
680        // Two speakers claim the floor; queue drives crowd density.
681        let q = ImpulseQueue::new();
682        let a = [1u8; 32];
683        let b = [2u8; 32];
684        q.emit(0, a, 0, ImpulseType::TurnClaim, serde_json::json!({}), 10);
685        q.emit(0, b, 0, ImpulseType::TurnClaim, serde_json::json!({}), 20);
686        // A backchannel is NOT a floor claim.
687        q.emit(0, a, 0, ImpulseType::Backchannel, serde_json::json!({}), 15);
688
689        let drained = q.drain_ready();
690        let contenders = contending_count(&drained);
691        assert_eq!(contenders, 2);
692        let crowd = crowd_density(contenders); // 2/4 = 0.5
693
694        // Frontier seqs are the live candidate content (here: two turns).
695        let live_seqs = [101u64, 202u64];
696        let candidates: Vec<FloorCandidate> = live_seqs
697            .iter()
698            .enumerate()
699            .map(|(i, seq)| {
700                let readiness = if i == 0 {
701                    ContentReadiness::Generated
702                } else {
703                    ContentReadiness::NotStarted
704                };
705                FloorCandidate::new(
706                    format!("seq-{seq}"),
707                    *seq,
708                    signals(0.2, 0.0, 0.0, crowd, readiness),
709                )
710            })
711            .collect();
712
713        let d = evaluate_floor(&candidates);
714        // seq-101: 0.30*0.2 + 0.10*0.5 + 0.25*1.0 = 0.06 + 0.05 + 0.25 = 0.36
715        assert_eq!(d.winner.as_deref(), Some("seq-101"));
716        assert!((d.score - 0.36).abs() < 1e-6);
717    }
718}