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}