1use crate::duplex::{
20 DuplexChannel, DuplexImpulse, DuplexState, EdgeCommand, FloorVerdict, PayloadKind,
21 StreamObservation,
22};
23use crate::floor::{
24 ContentReadiness, ErlBargeConfig, ErlBargeGate, UrgencySignals, compute_urgency,
25 floor_verdict_from_erl, floor_verdict_from_erl_config,
26};
27use crate::impulse::ImpulseType;
28
29#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum MediaFrame {
38 Pcm(Vec<u8>),
40 Text(String),
42}
43
44#[derive(Debug, Clone, Copy, PartialEq)]
46pub struct EdgeState {
47 pub playback_active: bool,
49 pub erl_confidence: Option<f32>,
51 pub squelch_active: bool,
53 pub heartbeat: u64,
55}
56
57impl EdgeState {
58 pub const fn new() -> Self {
60 Self {
61 playback_active: false,
62 erl_confidence: None,
63 squelch_active: false,
64 heartbeat: 0,
65 }
66 }
67}
68
69impl Default for EdgeState {
70 fn default() -> Self {
71 Self::new()
72 }
73}
74
75#[derive(Debug, Clone, PartialEq)]
77pub enum ControlMessage {
78 EdgeUp(EdgeState),
80 Cmd(EdgeCommand),
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum EdgeReflexShadow {
94 Streaming,
96 Muted,
98 Degraded,
100}
101
102#[derive(Debug, Clone)]
108pub struct ThinEdge {
109 reflex: EdgeReflexShadow,
110 state: EdgeState,
111 tx: bool,
112 speaker: bool,
113}
114
115impl ThinEdge {
116 pub fn new() -> Self {
118 Self {
119 reflex: EdgeReflexShadow::Streaming,
120 state: EdgeState::new(),
121 tx: true,
122 speaker: true,
123 }
124 }
125
126 pub fn reflex(&self) -> EdgeReflexShadow {
128 self.reflex
129 }
130
131 pub fn edge_state(&self) -> EdgeState {
133 let mut s = self.state;
134 s.playback_active = self.speaker && self.reflex != EdgeReflexShadow::Degraded;
135 s
136 }
137
138 pub fn is_transmitting(&self) -> bool {
140 self.tx && self.reflex != EdgeReflexShadow::Degraded
141 }
142
143 pub fn observe_erl(&mut self, erl: f32) {
146 self.state.erl_confidence = Some(erl);
147 }
148
149 pub fn on_command(&mut self, cmd: &EdgeCommand) {
151 if self.reflex == EdgeReflexShadow::Degraded {
152 return;
153 }
154 match cmd {
155 EdgeCommand::Mute | EdgeCommand::Silence | EdgeCommand::Cancel => {
156 self.reflex = EdgeReflexShadow::Muted;
157 self.speaker = false;
158 }
159 EdgeCommand::Resume | EdgeCommand::Play(_) => {
160 self.reflex = EdgeReflexShadow::Streaming;
161 self.speaker = true;
162 }
163 EdgeCommand::Config => {}
164 }
165 }
166
167 pub fn on_link_loss(&mut self) {
169 self.reflex = EdgeReflexShadow::Degraded;
170 self.tx = false;
171 self.speaker = false;
172 }
173
174 pub fn on_link_restored(&mut self) {
177 self.reflex = EdgeReflexShadow::Streaming;
178 self.tx = true;
179 self.speaker = false;
180 self.state.erl_confidence = None;
181 self.state.heartbeat = self.state.heartbeat.saturating_add(1);
182 }
183
184 pub fn beat(&mut self) {
186 self.state.heartbeat = self.state.heartbeat.saturating_add(1);
187 }
188}
189
190impl Default for ThinEdge {
191 fn default() -> Self {
192 Self::new()
193 }
194}
195
196#[derive(Debug, Clone, PartialEq)]
202pub struct ObserveOutcome {
203 pub impulse: Option<DuplexImpulse>,
205 pub commands: Vec<EdgeCommand>,
207 pub duplex_state: DuplexState,
209}
210
211#[derive(Debug)]
222pub struct LocalhostDuplexSession {
223 pub edge: ThinEdge,
225 pub duplex: DuplexChannel,
227 pub erl_gate: ErlBargeGate,
229 pub media_up: Vec<MediaFrame>,
231 pub media_down: Vec<MediaFrame>,
233}
234
235impl LocalhostDuplexSession {
236 pub fn open(payload: PayloadKind) -> Self {
238 Self::open_with_erl(payload, ErlBargeConfig::default())
239 }
240
241 pub fn open_with_erl(payload: PayloadKind, erl: ErlBargeConfig) -> Self {
243 Self {
244 edge: ThinEdge::new(),
245 duplex: DuplexChannel::new(payload),
246 erl_gate: ErlBargeGate::new(erl),
247 media_up: Vec::new(),
248 media_down: Vec::new(),
249 }
250 }
251
252 pub fn edge_push_media(&mut self, frame: MediaFrame) {
254 if self.edge.is_transmitting() {
255 self.media_up.push(frame);
256 }
257 }
258
259 pub fn edge_report_state(&mut self) -> ObserveOutcome {
264 self.edge.beat();
265 let state = self.edge.edge_state();
266 if let Some(erl) = state.erl_confidence {
267 let (_imp, cmds) = self.duplex.observe(StreamObservation::Erl(erl));
268 debug_assert!(cmds.is_empty(), "Erl observation is non-transitional");
269 }
270 ObserveOutcome {
271 impulse: None,
272 commands: Vec::new(),
273 duplex_state: self.duplex.state(),
274 }
275 }
276
277 pub fn daemon_observe(&mut self, obs: StreamObservation) -> ObserveOutcome {
280 let (impulse, commands) = self.duplex.observe(obs);
281 for cmd in &commands {
282 self.edge.on_command(cmd);
283 }
284 ObserveOutcome {
285 impulse,
286 commands,
287 duplex_state: self.duplex.state(),
288 }
289 }
290
291 pub fn apply_verdict(&mut self, verdict: FloorVerdict) -> Vec<EdgeCommand> {
293 let commands = self.duplex.apply(verdict);
294 for cmd in &commands {
295 self.edge.on_command(cmd);
296 }
297 commands
298 }
299
300 pub fn score_barge_claim(impulse: &DuplexImpulse) -> f32 {
305 if impulse.kind != ImpulseType::TurnClaim {
306 return 0.0;
307 }
308 let signals = UrgencySignals {
309 semantic_relevance: 1.0, emotional_arousal: 0.5,
311 wait_time: 0.0,
312 crowd_density: 0.25,
313 content_readiness: ContentReadiness::NotStarted,
314 hard_interrupt: false,
315 erl_confidence: impulse.erl,
316 };
317 compute_urgency(&signals)
318 }
319
320 pub fn duplex_turn_claim_payload(impulse: &DuplexImpulse) -> serde_json::Value {
326 serde_json::json!({
327 "erl_confidence": impulse.erl.unwrap_or(0.0),
328 })
329 }
330
331 pub fn resolve_overlap_from_erl(&mut self, erl: Option<f32>) -> (FloorVerdict, Vec<EdgeCommand>) {
338 let verdict = floor_verdict_from_erl(erl);
339 if matches!(verdict, FloorVerdict::DenyBarge) {
340 self.erl_gate.reset();
341 }
342 let cmds = self.apply_verdict(verdict);
343 (verdict, cmds)
344 }
345
346 pub fn resolve_overlap_with_gate(
350 &mut self,
351 erl: Option<f32>,
352 ) -> (FloorVerdict, Vec<EdgeCommand>) {
353 let admitted = match erl {
354 Some(v) => self.erl_gate.observe(v),
355 None => {
356 self.erl_gate.reset();
357 false
358 }
359 };
360 let verdict = if admitted {
361 FloorVerdict::GrantUser
362 } else {
363 FloorVerdict::DenyBarge
364 };
365 let cmds = self.apply_verdict(verdict);
366 (verdict, cmds)
367 }
368
369 pub fn resolve_overlap_with_config(
371 &mut self,
372 erl: Option<f32>,
373 config: &ErlBargeConfig,
374 ) -> (FloorVerdict, Vec<EdgeCommand>) {
375 let verdict = floor_verdict_from_erl_config(erl, config);
376 let cmds = self.apply_verdict(verdict);
377 (verdict, cmds)
378 }
379}
380
381#[cfg(test)]
386mod tests {
387 use super::*;
388 use crate::floor::erl_admits_barge;
389
390 #[test]
391 fn thin_edge_has_no_forest_only_reflex() {
392 let e = ThinEdge::new();
393 assert_eq!(e.reflex(), EdgeReflexShadow::Streaming);
394 assert!(e.is_transmitting());
395 let _ = e.edge_state();
397 }
398
399 #[test]
400 fn erl_relays_into_duplex_turn_claim() {
401 let mut s = LocalhostDuplexSession::open(PayloadKind::Audio);
402 s.edge.observe_erl(0.12);
403 s.edge_report_state();
404 assert_eq!(s.duplex.last_erl(), Some(0.12));
405
406 let out = s.daemon_observe(StreamObservation::VadOnset);
408 let claim = out.impulse.expect("TurnClaim");
409 assert_eq!(claim.kind, ImpulseType::TurnClaim);
410 assert_eq!(claim.erl, Some(0.12));
411 }
412
413 #[test]
414 fn self_cancel_low_erl_denies_barge_no_mute() {
415 let mut s = LocalhostDuplexSession::open(PayloadKind::Audio);
416 let _ = s.daemon_observe(StreamObservation::VadOnset); let _ = s.daemon_observe(StreamObservation::SmartTurnEou); let _ = s.apply_verdict(FloorVerdict::GrantAgent); assert_eq!(s.duplex.state(), DuplexState::Speaking);
421
422 s.edge.observe_erl(0.08);
424 s.edge_report_state();
425 let out = s.daemon_observe(StreamObservation::VadOnset);
426 assert_eq!(out.duplex_state, DuplexState::Overlap);
427 let claim = out.impulse.expect("TurnClaim");
428 assert!(!erl_admits_barge(claim.erl.unwrap_or(0.0)));
429
430 let score = LocalhostDuplexSession::score_barge_claim(&claim);
432 assert!(score < 0.2, "low-ERL claim must be weighted down, got {score}");
433
434 let (verdict, cmds) = s.resolve_overlap_from_erl(claim.erl);
436 assert_eq!(verdict, FloorVerdict::DenyBarge);
437 assert!(
438 !cmds.iter().any(|c| matches!(
439 c,
440 EdgeCommand::Mute | EdgeCommand::Cancel
441 )),
442 "self-cancel fix: answer not cancelled"
443 );
444 assert_eq!(s.duplex.state(), DuplexState::Speaking);
445 assert_eq!(s.edge.reflex(), EdgeReflexShadow::Streaming);
446 }
447
448 #[test]
449 fn genuine_barge_high_erl_mutes_edge() {
450 let mut s = LocalhostDuplexSession::open(PayloadKind::Audio);
451 let _ = s.daemon_observe(StreamObservation::VadOnset);
452 let _ = s.daemon_observe(StreamObservation::SmartTurnEou);
453 let _ = s.apply_verdict(FloorVerdict::GrantAgent);
454 assert_eq!(s.duplex.state(), DuplexState::Speaking);
455
456 s.edge.observe_erl(0.92);
457 s.edge_report_state();
458 let out = s.daemon_observe(StreamObservation::VadOnset);
459 assert_eq!(out.duplex_state, DuplexState::Overlap);
460 let claim = out.impulse.expect("TurnClaim");
461 assert!(erl_admits_barge(claim.erl.unwrap()));
462
463 let (verdict, cmds) = s.resolve_overlap_from_erl(claim.erl);
464 assert_eq!(verdict, FloorVerdict::GrantUser);
465 assert!(
466 cmds.iter().any(|c| matches!(c, EdgeCommand::Mute)),
467 "genuine barge must Mute the edge"
468 );
469 assert_eq!(s.duplex.state(), DuplexState::Listening);
470 assert_eq!(s.edge.reflex(), EdgeReflexShadow::Muted);
471 }
472
473 #[test]
474 fn media_up_only_while_transmitting() {
475 let mut s = LocalhostDuplexSession::open(PayloadKind::Audio);
476 s.edge_push_media(MediaFrame::Pcm(vec![1, 2, 3]));
477 assert_eq!(s.media_up.len(), 1);
478 s.edge.on_link_loss();
479 s.edge_push_media(MediaFrame::Pcm(vec![4, 5, 6]));
480 assert_eq!(s.media_up.len(), 1, "Degraded edge must not TX");
481 }
482
483 #[test]
486 fn duplex_claim_payload_carries_erl() {
487 let with = DuplexImpulse {
488 kind: ImpulseType::TurnClaim,
489 erl: Some(0.88),
490 };
491 let p = LocalhostDuplexSession::duplex_turn_claim_payload(&with);
492 assert!((p["erl_confidence"].as_f64().unwrap() - 0.88).abs() < 1e-6);
493
494 let missing = DuplexImpulse {
495 kind: ImpulseType::TurnClaim,
496 erl: None,
497 };
498 let p0 = LocalhostDuplexSession::duplex_turn_claim_payload(&missing);
499 assert_eq!(p0["erl_confidence"].as_f64().unwrap(), 0.0);
500 }
501
502 #[test]
505 fn overlap_gate_hysteresis_no_thrash() {
506 let mut s = LocalhostDuplexSession::open(PayloadKind::Audio);
507 let _ = s.daemon_observe(StreamObservation::VadOnset);
508 let _ = s.daemon_observe(StreamObservation::SmartTurnEou);
509 let _ = s.apply_verdict(FloorVerdict::GrantAgent);
510 let _ = s.daemon_observe(StreamObservation::VadOnset);
512 assert_eq!(s.duplex.state(), DuplexState::Overlap);
513
514 let (v, cmds) = s.resolve_overlap_with_gate(Some(0.2));
516 assert_eq!(v, FloorVerdict::DenyBarge);
517 assert!(!cmds.iter().any(|c| matches!(c, EdgeCommand::Mute)));
518 assert_eq!(s.duplex.state(), DuplexState::Speaking);
519
520 let _ = s.daemon_observe(StreamObservation::VadOnset);
522 let (v, _) = s.resolve_overlap_with_gate(Some(0.9));
523 assert_eq!(v, FloorVerdict::GrantUser);
524 assert_eq!(s.duplex.state(), DuplexState::Listening);
525
526 assert!(s.erl_gate.observe(0.45));
528 assert!(!s.erl_gate.observe(0.2));
529 }
530
531 #[test]
533 fn custom_erl_floor_knob() {
534 let mut s = LocalhostDuplexSession::open_with_erl(
535 PayloadKind::Audio,
536 ErlBargeConfig::new(0.8, 0.1),
537 );
538 let _ = s.daemon_observe(StreamObservation::VadOnset);
539 let _ = s.daemon_observe(StreamObservation::SmartTurnEou);
540 let _ = s.apply_verdict(FloorVerdict::GrantAgent);
541 let _ = s.daemon_observe(StreamObservation::VadOnset);
542 let (v, _) = s.resolve_overlap_with_config(Some(0.7), &ErlBargeConfig::new(0.8, 0.1));
543 assert_eq!(v, FloorVerdict::DenyBarge, "0.7 < custom floor 0.8");
544 let _ = s.daemon_observe(StreamObservation::VadOnset);
546 let (v, cmds) = s.resolve_overlap_with_config(Some(0.85), &ErlBargeConfig::new(0.8, 0.1));
547 assert_eq!(v, FloorVerdict::GrantUser);
548 assert!(cmds.iter().any(|c| matches!(c, EdgeCommand::Mute)));
549 }
550}