use crate::event::{CoreId, WorkspaceEvent};
use std::time::{Duration, Instant};
pub const DEFAULT_HALF_LIFE: Duration = Duration::from_secs(5);
#[derive(Debug, Clone)]
pub struct SpotlightEntry {
pub core: CoreId,
pub winning_event_type: crate::event::EventType,
pub salience: crate::salience::Salience,
pub timestamp: Instant,
pub candidates: usize,
}
impl SpotlightEntry {
#[must_use]
pub fn strength(&self, half_life: Duration) -> f32 {
let age = self.timestamp.elapsed();
let base = self.salience.composite();
if age.is_zero() {
return base;
}
let half_life_secs = half_life.as_secs_f64();
if half_life_secs <= 0.0 {
return 0.0;
}
let age_secs = age.as_secs_f64();
let decay = 0.5_f64.powf(age_secs / half_life_secs) as f32;
base * decay
}
#[must_use]
pub fn age(&self) -> Duration {
self.timestamp.elapsed()
}
}
pub struct Spotlight {
current: Option<SpotlightEntry>,
half_life: Duration,
transfer_count: u64,
arbitration_count: u64,
core_holds: std::collections::HashMap<CoreId, u64>,
}
impl std::fmt::Debug for Spotlight {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Spotlight")
.field("current", &self.current)
.field("half_life", &self.half_life)
.field("transfer_count", &self.transfer_count)
.field("arbitration_count", &self.arbitration_count)
.finish_non_exhaustive()
}
}
impl Default for Spotlight {
fn default() -> Self {
Self::new(DEFAULT_HALF_LIFE)
}
}
impl Spotlight {
#[must_use]
pub fn new(half_life: Duration) -> Self {
Self {
current: None,
half_life,
transfer_count: 0,
arbitration_count: 0,
core_holds: std::collections::HashMap::new(),
}
}
#[must_use]
pub const fn current(&self) -> Option<&SpotlightEntry> {
self.current.as_ref()
}
#[must_use]
pub fn strength(&self) -> f32 {
self.current
.as_ref()
.map_or(0.0, |e| e.strength(self.half_life))
}
#[must_use]
pub fn current_core(&self) -> Option<CoreId> {
self.current.as_ref().map(|e| e.core)
}
#[must_use]
pub const fn transfer_count(&self) -> u64 {
self.transfer_count
}
#[must_use]
pub const fn arbitration_count(&self) -> u64 {
self.arbitration_count
}
#[must_use]
pub fn core_hold_count(&self, core: CoreId) -> u64 {
self.core_holds.get(&core).copied().unwrap_or(0)
}
pub fn arbitrate(&mut self, event: &WorkspaceEvent) -> bool {
self.arbitration_count += 1;
let should_transfer = match &self.current {
None => true,
Some(current) => {
let current_strength = current.strength(self.half_life);
let event_salience = event.composite_salience();
if event.should_preempt() {
true
} else {
event_salience > current_strength
}
}
};
if should_transfer {
let prev_core = self.current.as_ref().map(|e| e.core);
self.current = Some(SpotlightEntry {
core: event.core,
winning_event_type: event.event_type,
salience: event.salience,
timestamp: Instant::now(),
candidates: 1,
});
self.transfer_count += 1;
*self.core_holds.entry(event.core).or_insert(0) += 1;
if let Some(prev) = prev_core {
if prev != event.core {
tracing::debug!(
from = %prev,
to = %event.core,
salience = event.composite_salience(),
"spotlight transferred"
);
}
}
true
} else {
false
}
}
pub fn arbitrate_batch(&mut self, events: &[WorkspaceEvent]) -> Option<usize> {
if events.is_empty() {
return None;
}
self.arbitration_count += 1;
let mut best_idx = 0;
let mut best_salience = events[0].composite_salience();
for (i, event) in events.iter().enumerate().skip(1) {
let s = event.composite_salience();
if s > best_salience {
best_salience = s;
best_idx = i;
}
}
let should_transfer = match &self.current {
None => true,
Some(current) => {
let current_strength = current.strength(self.half_life);
events[best_idx].should_preempt() || best_salience > current_strength
}
};
if should_transfer {
let mut entry = SpotlightEntry {
core: events[best_idx].core,
winning_event_type: events[best_idx].event_type,
salience: events[best_idx].salience,
timestamp: Instant::now(),
candidates: events.len(),
};
entry.candidates = events.len();
self.current = Some(entry);
self.transfer_count += 1;
*self.core_holds.entry(events[best_idx].core).or_insert(0) += 1;
Some(best_idx)
} else {
None
}
}
pub const fn clear(&mut self) {
self.current = None;
}
pub const fn set_half_life(&mut self, half_life: Duration) {
self.half_life = half_life;
}
#[must_use]
pub const fn half_life(&self) -> Duration {
self.half_life
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::salience::Salience;
fn make_event(core: CoreId, salience: f32) -> WorkspaceEvent {
WorkspaceEvent::new(
core,
crate::event::EventType::AttentionRequest,
Salience::new(salience, salience, salience),
serde_json::json!({}),
)
}
#[test]
fn spotlight_starts_empty() {
let sp = Spotlight::default();
assert!(sp.current().is_none());
assert_eq!(sp.strength(), 0.0);
}
#[test]
fn arbitrate_first_event_wins() {
let mut sp = Spotlight::default();
let event = make_event(CoreId::Citta, 0.5);
assert!(sp.arbitrate(&event));
assert_eq!(sp.current_core(), Some(CoreId::Citta));
}
#[test]
fn arbitrate_higher_salience_wins() {
let mut sp = Spotlight::default();
let low = make_event(CoreId::Citta, 0.3);
assert!(sp.arbitrate(&low));
let high = make_event(CoreId::Dream, 0.9);
assert!(sp.arbitrate(&high));
assert_eq!(sp.current_core(), Some(CoreId::Dream));
}
#[test]
fn arbitrate_lower_salience_loses() {
let mut sp = Spotlight::default();
let high = make_event(CoreId::Citta, 0.9);
assert!(sp.arbitrate(&high));
let low = make_event(CoreId::Dream, 0.1);
assert!(!sp.arbitrate(&low));
assert_eq!(sp.current_core(), Some(CoreId::Citta));
}
#[test]
fn preempt_high_salience() {
let mut sp = Spotlight::default();
let strong = make_event(CoreId::Citta, 0.9);
assert!(sp.arbitrate(&strong));
let preempt = make_event(CoreId::Reflex, 0.95);
assert!(preempt.should_preempt());
assert!(sp.arbitrate(&preempt));
assert_eq!(sp.current_core(), Some(CoreId::Reflex));
}
#[test]
fn spotlight_decay() {
let mut sp = Spotlight::new(Duration::from_millis(50));
let event = make_event(CoreId::Citta, 0.8);
assert!(sp.arbitrate(&event));
let s1 = sp.strength();
assert!(s1 > 0.48 && s1 < 0.53, "initial strength: {s1}");
let start = std::time::Instant::now();
std::thread::sleep(Duration::from_millis(50));
let slept_ms = start.elapsed().as_secs_f64() * 1000.0;
let s2 = sp.strength();
assert!(s2 < s1, "strength should decay: {s2} vs {s1}");
let expected = s1 * 0.5_f32.powf((slept_ms / 50.0) as f32);
assert!(
(s2 - expected).abs() < 0.25 * s1,
"strength after {slept_ms:.1}ms: {s2}, expected ~{expected}"
);
}
#[test]
fn decayed_spotlight_can_be_overtaken() {
let mut sp = Spotlight::new(Duration::from_millis(20));
let high = make_event(CoreId::Citta, 0.8);
assert!(sp.arbitrate(&high));
std::thread::sleep(Duration::from_millis(60));
let low = make_event(CoreId::Dream, 0.5);
assert!(sp.arbitrate(&low));
assert_eq!(sp.current_core(), Some(CoreId::Dream));
}
#[test]
fn arbitrate_batch() {
let mut sp = Spotlight::default();
let events = vec![
make_event(CoreId::Citta, 0.3),
make_event(CoreId::Dream, 0.7),
make_event(CoreId::Reflex, 0.5),
];
let winner = sp.arbitrate_batch(&events);
assert_eq!(winner, Some(1)); assert_eq!(sp.current_core(), Some(CoreId::Dream));
}
#[test]
fn arbitrate_batch_empty() {
let mut sp = Spotlight::default();
assert!(sp.arbitrate_batch(&[]).is_none());
}
#[test]
fn transfer_count() {
let mut sp = Spotlight::default();
let e1 = make_event(CoreId::Citta, 0.5);
let e2 = make_event(CoreId::Dream, 0.7);
let e3 = make_event(CoreId::Reflex, 0.9);
assert!(sp.arbitrate(&e1));
assert!(sp.arbitrate(&e2));
assert!(sp.arbitrate(&e3));
assert_eq!(sp.transfer_count(), 3);
}
#[test]
fn core_hold_count() {
let mut sp = Spotlight::default();
let e1 = make_event(CoreId::Citta, 0.5);
let e2 = make_event(CoreId::Dream, 0.7);
let e3 = make_event(CoreId::Citta, 0.8);
assert!(sp.arbitrate(&e1));
assert!(sp.arbitrate(&e2));
assert!(sp.arbitrate(&e3));
assert_eq!(sp.core_hold_count(CoreId::Citta), 2);
assert_eq!(sp.core_hold_count(CoreId::Dream), 1);
}
#[test]
fn clear_spotlight() {
let mut sp = Spotlight::default();
let event = make_event(CoreId::Citta, 0.5);
assert!(sp.arbitrate(&event));
assert!(sp.current().is_some());
sp.clear();
assert!(sp.current().is_none());
}
#[test]
fn spotlight_entry_strength() {
let entry = SpotlightEntry {
core: CoreId::Citta,
winning_event_type: crate::event::EventType::AttentionRequest,
salience: Salience::new(0.8, 0.8, 0.8),
timestamp: Instant::now(),
candidates: 3,
};
let s = entry.strength(Duration::from_secs(5));
assert!(s > 0.49 && s < 0.52, "expected ~0.512, got {s}");
}
#[test]
fn set_half_life() {
let mut sp = Spotlight::default();
assert_eq!(sp.half_life(), DEFAULT_HALF_LIFE);
sp.set_half_life(Duration::from_secs(10));
assert_eq!(sp.half_life(), Duration::from_secs(10));
}
}