use serde::{Deserialize, Serialize};
use super::state::*;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct AgendaId(pub u64);
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgendaIdAllocator {
next: u64,
}
impl AgendaIdAllocator {
pub fn new() -> Self {
Self { next: 1 }
}
pub fn alloc(&mut self) -> AgendaId {
let id = AgendaId(self.next);
self.next += 1;
id
}
pub fn high_water_mark(&self) -> u64 {
self.next
}
pub fn from_high_water_mark(hwm: u64) -> Self {
Self { next: hwm.max(1) }
}
}
impl Default for AgendaIdAllocator {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum UrgencyFn {
Linear {
base: f64,
slope: f64,
},
Sigmoid {
deadline: f64,
steepness: f64,
offset: f64,
},
StepAtDeadline {
deadline: f64,
buffer_secs: f64,
},
DecayIfIgnored {
initial: f64,
decay_factor: f64,
},
Constant {
value: f64,
},
}
impl UrgencyFn {
pub fn evaluate(&self, created_at: f64, now: f64, dismiss_count: u32) -> f64 {
let raw = match self {
Self::Linear { base, slope } => {
let elapsed = (now - created_at).max(0.0);
base + slope * elapsed
}
Self::Sigmoid { deadline, steepness, offset } => {
let time_until = deadline - now - offset;
1.0 / (1.0 + (steepness * time_until).exp())
}
Self::StepAtDeadline { deadline, buffer_secs } => {
if now >= deadline - buffer_secs {
1.0
} else {
0.0
}
}
Self::DecayIfIgnored { initial, decay_factor } => {
initial * decay_factor.powi(dismiss_count as i32)
}
Self::Constant { value } => *value,
};
raw.clamp(0.0, 1.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum AgendaKind {
UnresolvedQuestion,
PendingCommitment,
FollowUpNeeded,
RoutineWindowOpening,
DeadlineApproaching,
AnomalyRequiresConfirmation,
BeliefConflictNeedsResolution,
AbandonedTask,
StalledIntent,
}
impl AgendaKind {
pub fn as_str(self) -> &'static str {
match self {
Self::UnresolvedQuestion => "unresolved_question",
Self::PendingCommitment => "pending_commitment",
Self::FollowUpNeeded => "follow_up_needed",
Self::RoutineWindowOpening => "routine_window_opening",
Self::DeadlineApproaching => "deadline_approaching",
Self::AnomalyRequiresConfirmation => "anomaly_requires_confirmation",
Self::BeliefConflictNeedsResolution => "belief_conflict_needs_resolution",
Self::AbandonedTask => "abandoned_task",
Self::StalledIntent => "stalled_intent",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AgendaStatus {
Active,
Snoozed,
Resolved,
Expired,
Dismissed,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SuppressionRule {
pub description: String,
pub condition: SuppressionCondition,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum SuppressionCondition {
TimeRange { start_hour: u8, end_hour: u8 },
DuringActivity { activity: String },
HighCognitiveLoad { threshold: f64 },
SharedContext,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgendaItem {
pub id: AgendaId,
pub source_node: NodeId,
pub kind: AgendaKind,
pub created_at: f64,
pub due_at: Option<f64>,
pub urgency_fn: UrgencyFn,
pub status: AgendaStatus,
pub suppression_rules: Vec<SuppressionRule>,
pub last_surfaced_at: Option<f64>,
pub surface_count: u32,
pub max_surfaces: u8,
pub dismiss_count: u32,
pub snoozed_until: Option<f64>,
pub description: String,
}
impl AgendaItem {
pub fn current_urgency(&self, now: f64) -> f64 {
self.urgency_fn.evaluate(self.created_at, now, self.dismiss_count)
}
pub fn is_surfaceable(&self, now: f64) -> bool {
match self.status {
AgendaStatus::Active => true,
AgendaStatus::Snoozed => {
self.snoozed_until.map_or(false, |until| now >= until)
}
_ => false,
}
}
pub fn is_nagging(&self) -> bool {
self.surface_count >= self.max_surfaces as u32
}
pub fn is_suppressed(&self, hour: u8, cognitive_load: f64, is_shared: bool) -> bool {
for rule in &self.suppression_rules {
match &rule.condition {
SuppressionCondition::TimeRange { start_hour, end_hour } => {
let in_range = if start_hour > end_hour {
hour >= *start_hour || hour < *end_hour
} else {
hour >= *start_hour && hour < *end_hour
};
if in_range {
return true;
}
}
SuppressionCondition::HighCognitiveLoad { threshold } => {
if cognitive_load > *threshold {
return true;
}
}
SuppressionCondition::SharedContext => {
if is_shared {
return true;
}
}
SuppressionCondition::DuringActivity { .. } => {
}
}
}
false
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgendaConfig {
pub surface_threshold: f64,
pub default_max_surfaces: u8,
pub default_snooze_secs: f64,
pub stale_task_threshold_secs: f64,
pub stale_goal_threshold_secs: f64,
pub max_active_items: usize,
pub min_resurface_interval_secs: f64,
}
impl Default for AgendaConfig {
fn default() -> Self {
Self {
surface_threshold: 0.5,
default_max_surfaces: 5,
default_snooze_secs: 3600.0, stale_task_threshold_secs: 172800.0, stale_goal_threshold_secs: 259200.0, max_active_items: 100,
min_resurface_interval_secs: 1800.0, }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TickResult {
pub ready_to_surface: Vec<AgendaId>,
pub auto_expired: Vec<AgendaId>,
pub unsnoozed: Vec<AgendaId>,
pub active_count: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenLoopScanResult {
pub new_loops: Vec<DetectedLoop>,
pub nodes_scanned: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DetectedLoop {
pub node_id: NodeId,
pub kind: AgendaKind,
pub reason: String,
pub suggested_urgency: UrgencyFn,
pub description: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Agenda {
pub items: Vec<AgendaItem>,
pub allocator: AgendaIdAllocator,
}
impl Agenda {
pub fn new() -> Self {
Self::default()
}
pub fn add_item(
&mut self,
source_node: NodeId,
kind: AgendaKind,
urgency_fn: UrgencyFn,
due_at: Option<f64>,
description: String,
config: &AgendaConfig,
) -> AgendaId {
if self.active_count() >= config.max_active_items {
self.evict_lowest_urgency(0.0); }
let id = self.allocator.alloc();
self.items.push(AgendaItem {
id,
source_node,
kind,
created_at: 0.0, due_at,
urgency_fn,
status: AgendaStatus::Active,
suppression_rules: vec![],
last_surfaced_at: None,
surface_count: 0,
max_surfaces: config.default_max_surfaces,
dismiss_count: 0,
snoozed_until: None,
description,
});
id
}
pub fn add_item_at(
&mut self,
source_node: NodeId,
kind: AgendaKind,
urgency_fn: UrgencyFn,
due_at: Option<f64>,
description: String,
created_at: f64,
config: &AgendaConfig,
) -> AgendaId {
let id = self.add_item(source_node, kind, urgency_fn, due_at, description, config);
if let Some(item) = self.items.last_mut() {
item.created_at = created_at;
}
id
}
pub fn tick(&mut self, now: f64, config: &AgendaConfig) -> TickResult {
let mut ready = Vec::new();
let mut expired = Vec::new();
let mut unsnoozed = Vec::new();
for item in &mut self.items {
match item.status {
AgendaStatus::Active => {
if item.is_nagging() {
item.status = AgendaStatus::Expired;
expired.push(item.id);
continue;
}
if let Some(due) = item.due_at {
if now > due && item.kind != AgendaKind::DeadlineApproaching {
item.status = AgendaStatus::Expired;
expired.push(item.id);
continue;
}
}
let urgency = item.current_urgency(now);
if urgency >= config.surface_threshold {
let can_resurface = item.last_surfaced_at.map_or(true, |last| {
now - last >= config.min_resurface_interval_secs
});
if can_resurface {
ready.push(item.id);
}
}
}
AgendaStatus::Snoozed => {
if let Some(until) = item.snoozed_until {
if now >= until {
item.status = AgendaStatus::Active;
item.snoozed_until = None;
unsnoozed.push(item.id);
}
}
}
_ => {} }
}
TickResult {
ready_to_surface: ready,
auto_expired: expired,
unsnoozed,
active_count: self.active_count(),
}
}
pub fn resolve(&mut self, id: AgendaId) -> bool {
if let Some(item) = self.find_mut(id) {
item.status = AgendaStatus::Resolved;
true
} else {
false
}
}
pub fn snooze(&mut self, id: AgendaId, now: f64, duration_secs: f64) -> bool {
if let Some(item) = self.find_mut(id) {
item.status = AgendaStatus::Snoozed;
item.snoozed_until = Some(now + duration_secs);
true
} else {
false
}
}
pub fn dismiss(&mut self, id: AgendaId) -> bool {
if let Some(item) = self.find_mut(id) {
item.dismiss_count += 1;
item.status = AgendaStatus::Dismissed;
true
} else {
false
}
}
pub fn mark_surfaced(&mut self, id: AgendaId, now: f64) {
if let Some(item) = self.find_mut(id) {
item.surface_count += 1;
item.last_surfaced_at = Some(now);
}
}
pub fn get_active(&self, now: f64, limit: usize) -> Vec<&AgendaItem> {
let mut active: Vec<&AgendaItem> = self.items.iter()
.filter(|i| i.is_surfaceable(now))
.collect();
active.sort_by(|a, b| {
let ua = a.current_urgency(now);
let ub = b.current_urgency(now);
ub.partial_cmp(&ua).unwrap_or(std::cmp::Ordering::Equal)
});
active.into_iter().take(limit).collect()
}
pub fn active_count(&self) -> usize {
self.items.iter()
.filter(|i| matches!(i.status, AgendaStatus::Active | AgendaStatus::Snoozed))
.count()
}
pub fn find(&self, id: AgendaId) -> Option<&AgendaItem> {
self.items.iter().find(|i| i.id == id)
}
fn find_mut(&mut self, id: AgendaId) -> Option<&mut AgendaItem> {
self.items.iter_mut().find(|i| i.id == id)
}
pub fn items_iter(&self) -> impl Iterator<Item = &AgendaItem> {
self.items.iter()
}
pub fn has_item_for_node(&self, node_id: NodeId) -> bool {
self.items.iter().any(|i| {
i.source_node == node_id
&& matches!(i.status, AgendaStatus::Active | AgendaStatus::Snoozed)
})
}
fn evict_lowest_urgency(&mut self, now: f64) {
let mut lowest_idx = None;
let mut lowest_urgency = f64::MAX;
for (idx, item) in self.items.iter().enumerate() {
if matches!(item.status, AgendaStatus::Active) {
let u = item.current_urgency(now);
if u < lowest_urgency {
lowest_urgency = u;
lowest_idx = Some(idx);
}
}
}
if let Some(idx) = lowest_idx {
self.items[idx].status = AgendaStatus::Expired;
}
}
}
pub fn detect_open_loops(
nodes: &[&CognitiveNode],
existing_agenda: &Agenda,
now: f64,
config: &AgendaConfig,
) -> OpenLoopScanResult {
let mut new_loops = Vec::new();
for node in nodes {
if existing_agenda.has_item_for_node(node.id) {
continue;
}
match (&node.payload, node.id.kind()) {
(NodePayload::Task(task), NodeKind::Task) => {
if task.status == TaskStatus::InProgress {
let age = now - (node.attrs.last_updated_ms as f64 / 1000.0);
if age > config.stale_task_threshold_secs {
new_loops.push(DetectedLoop {
node_id: node.id,
kind: AgendaKind::AbandonedTask,
reason: format!(
"Task '{}' in progress but no activity for {:.0}h",
task.description,
age / 3600.0,
),
suggested_urgency: UrgencyFn::Linear {
base: 0.3,
slope: 0.00001, },
description: format!("Stale task: {}", task.description),
});
}
}
}
(NodePayload::Goal(goal), NodeKind::Goal) => {
if goal.status == GoalStatus::Active && goal.progress < 0.05 {
let age = now - (node.attrs.last_updated_ms as f64 / 1000.0);
if age > config.stale_goal_threshold_secs {
new_loops.push(DetectedLoop {
node_id: node.id,
kind: AgendaKind::StalledIntent,
reason: format!(
"Goal '{}' is active but has {:.0}% progress and no activity for {:.0}h",
goal.description,
goal.progress * 100.0,
age / 3600.0,
),
suggested_urgency: UrgencyFn::Linear {
base: 0.2,
slope: 0.000005,
},
description: format!("Stalled goal: {}", goal.description),
});
}
}
if let Some(deadline) = goal.deadline {
let time_until = deadline - now;
if time_until > 0.0 && time_until < 86400.0 * 3.0 {
new_loops.push(DetectedLoop {
node_id: node.id,
kind: AgendaKind::DeadlineApproaching,
reason: format!(
"Goal '{}' deadline in {:.1} hours",
goal.description,
time_until / 3600.0,
),
suggested_urgency: UrgencyFn::Sigmoid {
deadline,
steepness: 0.0001,
offset: 3600.0, },
description: format!("Deadline: {}", goal.description),
});
}
}
}
(NodePayload::Task(task), NodeKind::Task) => {
if let Some(deadline) = task.deadline {
let time_until = deadline - now;
if time_until > 0.0 && time_until < 86400.0
&& task.status != TaskStatus::Completed
&& task.status != TaskStatus::Cancelled
{
new_loops.push(DetectedLoop {
node_id: node.id,
kind: AgendaKind::DeadlineApproaching,
reason: format!(
"Task '{}' due in {:.1} hours",
task.description,
time_until / 3600.0,
),
suggested_urgency: UrgencyFn::StepAtDeadline {
deadline,
buffer_secs: 3600.0,
},
description: format!("Due soon: {}", task.description),
});
}
}
}
_ => {}
}
}
OpenLoopScanResult {
nodes_scanned: nodes.len(),
new_loops,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn default_config() -> AgendaConfig {
AgendaConfig::default()
}
fn make_goal_node(alloc: &mut NodeIdAllocator, desc: &str) -> CognitiveNode {
let id = alloc.alloc(NodeKind::Goal);
CognitiveNode::new(
id,
desc.to_string(),
NodePayload::Goal(GoalPayload {
description: desc.to_string(),
status: GoalStatus::Active,
progress: 0.0,
deadline: None,
priority: Priority::High,
parent_goal: None,
completion_criteria: "Done".to_string(),
}),
)
}
fn make_task_node(alloc: &mut NodeIdAllocator, desc: &str, status: TaskStatus) -> CognitiveNode {
let id = alloc.alloc(NodeKind::Task);
CognitiveNode::new(
id,
desc.to_string(),
NodePayload::Task(TaskPayload {
description: desc.to_string(),
status,
goal_id: None,
deadline: None,
priority: Priority::Medium,
estimated_minutes: None,
prerequisites: vec![],
}),
)
}
#[test]
fn test_linear_urgency() {
let f = UrgencyFn::Linear { base: 0.1, slope: 0.0001 };
assert!((f.evaluate(1000.0, 1000.0, 0) - 0.1).abs() < 0.001);
assert!((f.evaluate(1000.0, 4600.0, 0) - 0.46).abs() < 0.01);
let high_slope = UrgencyFn::Linear { base: 0.5, slope: 1.0 };
assert_eq!(high_slope.evaluate(0.0, 100.0, 0), 1.0);
}
#[test]
fn test_sigmoid_urgency() {
let deadline = 10000.0;
let f = UrgencyFn::Sigmoid {
deadline,
steepness: 0.001,
offset: 1000.0,
};
let far = f.evaluate(0.0, 1000.0, 0);
let near = f.evaluate(0.0, 9500.0, 0);
assert!(near > far, "urgency should increase near deadline: near={near} far={far}");
}
#[test]
fn test_step_at_deadline() {
let f = UrgencyFn::StepAtDeadline {
deadline: 10000.0,
buffer_secs: 3600.0,
};
assert_eq!(f.evaluate(0.0, 5000.0, 0), 0.0);
assert_eq!(f.evaluate(0.0, 7000.0, 0), 1.0);
assert_eq!(f.evaluate(0.0, 11000.0, 0), 1.0);
}
#[test]
fn test_decay_if_ignored() {
let f = UrgencyFn::DecayIfIgnored {
initial: 0.8,
decay_factor: 0.5,
};
assert!((f.evaluate(0.0, 0.0, 0) - 0.8).abs() < 0.001);
assert!((f.evaluate(0.0, 0.0, 1) - 0.4).abs() < 0.001);
assert!((f.evaluate(0.0, 0.0, 2) - 0.2).abs() < 0.001);
assert!((f.evaluate(0.0, 0.0, 3) - 0.1).abs() < 0.001);
}
#[test]
fn test_constant_urgency() {
let f = UrgencyFn::Constant { value: 0.7 };
assert_eq!(f.evaluate(0.0, 0.0, 0), 0.7);
assert_eq!(f.evaluate(0.0, 99999.0, 5), 0.7);
}
#[test]
fn test_add_and_retrieve() {
let mut agenda = Agenda::new();
let mut alloc = NodeIdAllocator::new();
let node = make_goal_node(&mut alloc, "Test goal");
let config = default_config();
let id = agenda.add_item_at(
node.id, AgendaKind::StalledIntent,
UrgencyFn::Constant { value: 0.6 },
None, "Test item".to_string(), 1000.0, &config,
);
assert_eq!(agenda.active_count(), 1);
let found = agenda.find(id).unwrap();
assert_eq!(found.kind, AgendaKind::StalledIntent);
assert_eq!(found.description, "Test item");
}
#[test]
fn test_resolve() {
let mut agenda = Agenda::new();
let mut alloc = NodeIdAllocator::new();
let node = make_goal_node(&mut alloc, "Goal");
let config = default_config();
let id = agenda.add_item(
node.id, AgendaKind::PendingCommitment,
UrgencyFn::Constant { value: 0.5 },
None, "Commitment".to_string(), &config,
);
assert!(agenda.resolve(id));
assert_eq!(agenda.active_count(), 0);
assert_eq!(agenda.find(id).unwrap().status, AgendaStatus::Resolved);
}
#[test]
fn test_snooze_and_unsnooze() {
let mut agenda = Agenda::new();
let mut alloc = NodeIdAllocator::new();
let node = make_goal_node(&mut alloc, "Goal");
let config = default_config();
let id = agenda.add_item_at(
node.id, AgendaKind::FollowUpNeeded,
UrgencyFn::Constant { value: 0.7 },
None, "Follow up".to_string(), 1000.0, &config,
);
agenda.snooze(id, 1000.0, 3600.0);
assert_eq!(agenda.find(id).unwrap().status, AgendaStatus::Snoozed);
let result1 = agenda.tick(2000.0, &config);
assert!(result1.unsnoozed.is_empty());
let result2 = agenda.tick(5000.0, &config);
assert!(result2.unsnoozed.contains(&id));
assert_eq!(agenda.find(id).unwrap().status, AgendaStatus::Active);
}
#[test]
fn test_dismiss_decays_urgency() {
let mut agenda = Agenda::new();
let mut alloc = NodeIdAllocator::new();
let node = make_goal_node(&mut alloc, "Goal");
let config = default_config();
let id = agenda.add_item_at(
node.id, AgendaKind::UnresolvedQuestion,
UrgencyFn::DecayIfIgnored { initial: 0.8, decay_factor: 0.5 },
None, "Question".to_string(), 1000.0, &config,
);
let u_before = agenda.find(id).unwrap().current_urgency(2000.0);
agenda.dismiss(id);
agenda.find_mut(id).unwrap().status = AgendaStatus::Active;
let u_after = agenda.find(id).unwrap().current_urgency(2000.0);
assert!(u_after < u_before, "urgency should decrease after dismissal");
}
#[test]
fn test_tick_surfaces_urgent_items() {
let mut agenda = Agenda::new();
let mut alloc = NodeIdAllocator::new();
let node = make_goal_node(&mut alloc, "Goal");
let config = default_config();
agenda.add_item_at(
node.id, AgendaKind::StalledIntent,
UrgencyFn::Constant { value: 0.3 },
None, "Low urgency".to_string(), 1000.0, &config,
);
let node2 = make_goal_node(&mut alloc, "Goal 2");
agenda.add_item_at(
node2.id, AgendaKind::DeadlineApproaching,
UrgencyFn::Constant { value: 0.8 },
None, "High urgency".to_string(), 1000.0, &config,
);
let result = agenda.tick(2000.0, &config);
assert_eq!(result.ready_to_surface.len(), 1);
assert_eq!(result.active_count, 2);
}
#[test]
fn test_anti_nag_auto_expire() {
let mut agenda = Agenda::new();
let mut alloc = NodeIdAllocator::new();
let node = make_goal_node(&mut alloc, "Goal");
let mut config = default_config();
config.default_max_surfaces = 3;
let id = agenda.add_item_at(
node.id, AgendaKind::FollowUpNeeded,
UrgencyFn::Constant { value: 0.9 },
None, "Nagging item".to_string(), 1000.0, &config,
);
for _ in 0..3 {
agenda.mark_surfaced(id, 2000.0);
}
let result = agenda.tick(3000.0, &config);
assert!(result.auto_expired.contains(&id));
assert_eq!(agenda.find(id).unwrap().status, AgendaStatus::Expired);
}
#[test]
fn test_get_active_sorted() {
let mut agenda = Agenda::new();
let mut alloc = NodeIdAllocator::new();
let config = default_config();
let n1 = make_goal_node(&mut alloc, "Low");
let n2 = make_goal_node(&mut alloc, "High");
let n3 = make_goal_node(&mut alloc, "Mid");
agenda.add_item_at(n1.id, AgendaKind::AbandonedTask,
UrgencyFn::Constant { value: 0.3 }, None, "Low".into(), 1000.0, &config);
agenda.add_item_at(n2.id, AgendaKind::DeadlineApproaching,
UrgencyFn::Constant { value: 0.9 }, None, "High".into(), 1000.0, &config);
agenda.add_item_at(n3.id, AgendaKind::FollowUpNeeded,
UrgencyFn::Constant { value: 0.6 }, None, "Mid".into(), 1000.0, &config);
let active = agenda.get_active(2000.0, 10);
assert_eq!(active.len(), 3);
assert_eq!(active[0].description, "High");
assert_eq!(active[1].description, "Mid");
assert_eq!(active[2].description, "Low");
}
#[test]
fn test_suppression_rules() {
let mut item = AgendaItem {
id: AgendaId(1),
source_node: NodeId::from_raw(0),
kind: AgendaKind::FollowUpNeeded,
created_at: 1000.0,
due_at: None,
urgency_fn: UrgencyFn::Constant { value: 0.7 },
status: AgendaStatus::Active,
suppression_rules: vec![
SuppressionRule {
description: "No during sleep".to_string(),
condition: SuppressionCondition::TimeRange {
start_hour: 22,
end_hour: 7,
},
},
SuppressionRule {
description: "Not when busy".to_string(),
condition: SuppressionCondition::HighCognitiveLoad {
threshold: 0.8,
},
},
],
last_surfaced_at: None,
surface_count: 0,
max_surfaces: 5,
dismiss_count: 0,
snoozed_until: None,
description: "Test".to_string(),
};
assert!(!item.is_suppressed(14, 0.3, false));
assert!(item.is_suppressed(23, 0.3, false));
assert!(item.is_suppressed(14, 0.9, false));
assert!(!item.is_suppressed(14, 0.3, true));
item.suppression_rules.push(SuppressionRule {
description: "Not when sharing".to_string(),
condition: SuppressionCondition::SharedContext,
});
assert!(item.is_suppressed(14, 0.3, true));
}
#[test]
fn test_detect_stale_task() {
let mut alloc = NodeIdAllocator::new();
let mut task = make_task_node(&mut alloc, "Write tests", TaskStatus::InProgress);
task.attrs.last_updated_ms = 1000;
let agenda = Agenda::new();
let config = AgendaConfig {
stale_task_threshold_secs: 3600.0, ..default_config()
};
let now = 100000.0; let nodes: Vec<&CognitiveNode> = vec![&task];
let result = detect_open_loops(&nodes, &agenda, now, &config);
assert_eq!(result.new_loops.len(), 1);
assert_eq!(result.new_loops[0].kind, AgendaKind::AbandonedTask);
}
#[test]
fn test_detect_stale_goal() {
let mut alloc = NodeIdAllocator::new();
let mut goal = make_goal_node(&mut alloc, "Learn Rust");
goal.attrs.last_updated_ms = 1000;
let agenda = Agenda::new();
let config = AgendaConfig {
stale_goal_threshold_secs: 3600.0,
..default_config()
};
let now = 100000.0;
let nodes: Vec<&CognitiveNode> = vec![&goal];
let result = detect_open_loops(&nodes, &agenda, now, &config);
assert_eq!(result.new_loops.len(), 1);
assert_eq!(result.new_loops[0].kind, AgendaKind::StalledIntent);
}
#[test]
fn test_detect_approaching_deadline() {
let mut alloc = NodeIdAllocator::new();
let id = alloc.alloc(NodeKind::Goal);
let now = 100000.0;
let deadline = now + 3600.0 * 12.0;
let goal = CognitiveNode::new(
id,
"Ship feature".to_string(),
NodePayload::Goal(GoalPayload {
description: "Ship feature".to_string(),
status: GoalStatus::Active,
progress: 0.5,
deadline: Some(deadline),
priority: Priority::Critical,
parent_goal: None,
completion_criteria: "Deployed".to_string(),
}),
);
let agenda = Agenda::new();
let config = default_config();
let nodes: Vec<&CognitiveNode> = vec![&goal];
let result = detect_open_loops(&nodes, &agenda, now, &config);
assert!(result.new_loops.iter().any(|l| l.kind == AgendaKind::DeadlineApproaching));
}
#[test]
fn test_no_duplicate_detection() {
let mut alloc = NodeIdAllocator::new();
let mut task = make_task_node(&mut alloc, "Stale task", TaskStatus::InProgress);
task.attrs.last_updated_ms = 1000;
let mut agenda = Agenda::new();
let config = AgendaConfig {
stale_task_threshold_secs: 3600.0,
..default_config()
};
agenda.add_item_at(
task.id, AgendaKind::AbandonedTask,
UrgencyFn::Constant { value: 0.5 },
None, "Already tracked".to_string(), 1000.0, &config,
);
let now = 100000.0;
let nodes: Vec<&CognitiveNode> = vec![&task];
let result = detect_open_loops(&nodes, &agenda, now, &config);
assert!(result.new_loops.is_empty());
}
#[test]
fn test_min_resurface_interval() {
let mut agenda = Agenda::new();
let mut alloc = NodeIdAllocator::new();
let node = make_goal_node(&mut alloc, "Goal");
let config = AgendaConfig {
min_resurface_interval_secs: 1800.0, ..default_config()
};
let id = agenda.add_item_at(
node.id, AgendaKind::DeadlineApproaching,
UrgencyFn::Constant { value: 0.9 },
None, "Urgent".to_string(), 1000.0, &config,
);
let result1 = agenda.tick(2000.0, &config);
assert!(result1.ready_to_surface.contains(&id));
agenda.mark_surfaced(id, 2000.0);
let result2 = agenda.tick(2600.0, &config);
assert!(!result2.ready_to_surface.contains(&id));
let result3 = agenda.tick(4100.0, &config);
assert!(result3.ready_to_surface.contains(&id));
}
}