Skip to main content

wm_tools/expansion/
autonomous.rs

1//! Autonomous cycle tools — spiral.report, consolidation.connect, consolidation.compress, emergence.scan, retention.prune.
2
3#![forbid(unsafe_code)]
4
5use async_trait::async_trait;
6
7use serde_json::{Value, json};
8use std::sync::Arc;
9use wm_core::{Context, EffectRow, Gana, Resource, Tool, ToolStats};
10use wm_memory::{AssociationStore, MemoryStore};
11
12pub struct SpiralReportTool {
13    tracker: Arc<std::sync::Mutex<wm_cognitive::SpiralTracker>>,
14    stats: ToolStats,
15    effects: EffectRow,
16}
17
18impl SpiralReportTool {
19    pub fn new(tracker: Arc<std::sync::Mutex<wm_cognitive::SpiralTracker>>) -> Self {
20        Self {
21            tracker,
22            stats: ToolStats::default(),
23            effects: EffectRow::pure(),
24        }
25    }
26}
27
28#[async_trait]
29impl Tool for SpiralReportTool {
30    fn name(&self) -> &str {
31        "spiral.report"
32    }
33    fn gana(&self) -> Gana {
34        Gana::Encampment
35    }
36    fn effects(&self) -> &EffectRow {
37        &self.effects
38    }
39    fn description(&self) -> &str {
40        "Report on autonomy expansion or circling (spiral direction, novelty, suspensions)"
41    }
42    async fn call(&self, _ctx: &mut Context, _args: Value) -> wm_core::Result<Value> {
43        let report = {
44            let tracker = self
45                .tracker
46                .lock()
47                .map_err(|e| wm_core::CoreError::Internal(format!("spiral tracker lock: {e}")))?;
48            tracker.report()
49        };
50        Ok(report.to_json())
51    }
52    fn stats(&self) -> &ToolStats {
53        &self.stats
54    }
55}
56
57/// `consolidation.connect` — propose typed associations for disconnected memories.
58///
59/// Runs the connect autonomous cycle, gated by Harmony Vector health score.
60/// Proposes typed associations for memories that have no incoming or outgoing
61/// links. Proposals require human review before action.
62pub struct ConsolidationConnectTool {
63    store: Arc<MemoryStore>,
64    associations: Arc<AssociationStore>,
65    spiral_tracker: Arc<std::sync::Mutex<wm_cognitive::SpiralTracker>>,
66    stats: ToolStats,
67    effects: EffectRow,
68}
69
70impl ConsolidationConnectTool {
71    pub fn new(
72        store: Arc<MemoryStore>,
73        associations: Arc<AssociationStore>,
74        spiral_tracker: Arc<std::sync::Mutex<wm_cognitive::SpiralTracker>>,
75    ) -> Self {
76        Self {
77            store,
78            associations,
79            spiral_tracker,
80            stats: ToolStats::default(),
81            effects: EffectRow {
82                // Runs an autonomous cycle: scans memory galaxies and
83                // logs the cycle record to the Substrate galaxy.
84                reads: super::common::memory_galaxy_reads(),
85                writes: vec![Resource::Galaxy("substrate".into())],
86                ..Default::default()
87            },
88        }
89    }
90}
91
92#[async_trait]
93impl Tool for ConsolidationConnectTool {
94    fn name(&self) -> &str {
95        "consolidation.connect"
96    }
97    fn gana(&self) -> Gana {
98        Gana::Encampment
99    }
100    fn effects(&self) -> &EffectRow {
101        &self.effects
102    }
103    fn description(&self) -> &str {
104        "Propose typed associations for disconnected memories (gated, human review)"
105    }
106    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
107        let health_score = args
108            .get("health_score")
109            .and_then(Value::as_f64)
110            .unwrap_or(0.8) as f32;
111
112        let mut runner = wm_cognitive::AutonomousCycleRunner::default();
113        let cycle_ctx =
114            wm_cognitive::CycleContext::new(&self.store, &self.associations, health_score);
115        let result = runner.run_cycle(wm_cognitive::CycleType::Connect, &cycle_ctx);
116
117        // Record in spiral tracker
118        if let Ok(mut tracker) = self.spiral_tracker.lock() {
119            tracker.record(&result);
120        }
121
122        Ok(json!({
123            "status": "success",
124            "cycle": result.cycle.name(),
125            "cycle_status": format!("{:?}", result.status),
126            "purpose": result.purpose,
127            "memories_scanned": result.memories_scanned,
128            "proposals_generated": result.proposals_generated,
129            "duration_ms": result.duration_ms,
130            "requires_human_review": true,
131            "notes": result.notes,
132            "connections": result.connections,
133        }))
134    }
135    fn stats(&self) -> &ToolStats {
136        &self.stats
137    }
138}
139
140/// `consolidation.compress` — propose merging semantically overlapping memories.
141///
142/// Runs the compress autonomous cycle. Finds pairs of memories with high
143/// semantic similarity and proposes merging the lower-importance one into
144/// the higher-importance one. Requires human review.
145pub struct ConsolidationCompressTool {
146    store: Arc<MemoryStore>,
147    associations: Arc<AssociationStore>,
148    spiral_tracker: Arc<std::sync::Mutex<wm_cognitive::SpiralTracker>>,
149    stats: ToolStats,
150    effects: EffectRow,
151}
152
153impl ConsolidationCompressTool {
154    pub fn new(
155        store: Arc<MemoryStore>,
156        associations: Arc<AssociationStore>,
157        spiral_tracker: Arc<std::sync::Mutex<wm_cognitive::SpiralTracker>>,
158    ) -> Self {
159        Self {
160            store,
161            associations,
162            spiral_tracker,
163            stats: ToolStats::default(),
164            effects: EffectRow {
165                // Runs an autonomous cycle: scans memory galaxies and
166                // logs the cycle record to the Substrate galaxy.
167                reads: super::common::memory_galaxy_reads(),
168                writes: vec![Resource::Galaxy("substrate".into())],
169                ..Default::default()
170            },
171        }
172    }
173}
174
175#[async_trait]
176impl Tool for ConsolidationCompressTool {
177    fn name(&self) -> &str {
178        "consolidation.compress"
179    }
180    fn gana(&self) -> Gana {
181        Gana::Encampment
182    }
183    fn effects(&self) -> &EffectRow {
184        &self.effects
185    }
186    fn description(&self) -> &str {
187        "Propose merging semantically overlapping memories (gated, human review)"
188    }
189    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
190        let health_score = args
191            .get("health_score")
192            .and_then(Value::as_f64)
193            .unwrap_or(0.8) as f32;
194
195        let mut runner = wm_cognitive::AutonomousCycleRunner::default();
196        let cycle_ctx =
197            wm_cognitive::CycleContext::new(&self.store, &self.associations, health_score);
198        let result = runner.run_cycle(wm_cognitive::CycleType::Compress, &cycle_ctx);
199
200        // Record in spiral tracker
201        if let Ok(mut tracker) = self.spiral_tracker.lock() {
202            tracker.record(&result);
203        }
204
205        Ok(json!({
206            "status": "success",
207            "cycle": result.cycle.name(),
208            "cycle_status": format!("{:?}", result.status),
209            "purpose": result.purpose,
210            "memories_scanned": result.memories_scanned,
211            "proposals_generated": result.proposals_generated,
212            "duration_ms": result.duration_ms,
213            "requires_human_review": true,
214            "notes": result.notes,
215            "compressions": result.compressions,
216        }))
217    }
218    fn stats(&self) -> &ToolStats {
219        &self.stats
220    }
221}
222
223/// `emergence.scan` — detect tag/topic emergence patterns.
224///
225/// Runs the emergence autonomous cycle. Scans all galaxies and aggregates
226/// tag frequencies to detect emerging patterns. Logged to Gnosis but does
227/// not require human review (no destructive action).
228pub struct EmergenceScanTool {
229    store: Arc<MemoryStore>,
230    associations: Arc<AssociationStore>,
231    spiral_tracker: Arc<std::sync::Mutex<wm_cognitive::SpiralTracker>>,
232    stats: ToolStats,
233    effects: EffectRow,
234}
235
236impl EmergenceScanTool {
237    pub fn new(
238        store: Arc<MemoryStore>,
239        associations: Arc<AssociationStore>,
240        spiral_tracker: Arc<std::sync::Mutex<wm_cognitive::SpiralTracker>>,
241    ) -> Self {
242        Self {
243            store,
244            associations,
245            spiral_tracker,
246            stats: ToolStats::default(),
247            effects: EffectRow {
248                // Runs an autonomous cycle: scans memory galaxies and
249                // logs the cycle record to the Substrate galaxy.
250                reads: super::common::memory_galaxy_reads(),
251                writes: vec![Resource::Galaxy("substrate".into())],
252                ..Default::default()
253            },
254        }
255    }
256}
257
258#[async_trait]
259impl Tool for EmergenceScanTool {
260    fn name(&self) -> &str {
261        "emergence.scan"
262    }
263    fn gana(&self) -> Gana {
264        Gana::Encampment
265    }
266    fn effects(&self) -> &EffectRow {
267        &self.effects
268    }
269    fn description(&self) -> &str {
270        "Detect tag/topic emergence patterns across memories (gated, logged)"
271    }
272    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
273        let health_score = args
274            .get("health_score")
275            .and_then(Value::as_f64)
276            .unwrap_or(0.8) as f32;
277
278        let mut runner = wm_cognitive::AutonomousCycleRunner::default();
279        let cycle_ctx =
280            wm_cognitive::CycleContext::new(&self.store, &self.associations, health_score);
281        let result = runner.run_cycle(wm_cognitive::CycleType::Emergence, &cycle_ctx);
282
283        // Record in spiral tracker
284        if let Ok(mut tracker) = self.spiral_tracker.lock() {
285            tracker.record(&result);
286        }
287
288        Ok(json!({
289            "status": "success",
290            "cycle": result.cycle.name(),
291            "cycle_status": format!("{:?}", result.status),
292            "purpose": result.purpose,
293            "memories_scanned": result.memories_scanned,
294            "proposals_generated": result.proposals_generated,
295            "duration_ms": result.duration_ms,
296            "requires_human_review": false,
297            "notes": result.notes,
298            "emergences": result.emergences,
299        }))
300    }
301    fn stats(&self) -> &ToolStats {
302        &self.stats
303    }
304}
305
306/// `retention.prune` — identify memories ready for forgetting.
307///
308/// Runs the prune autonomous cycle. Computes composite retention scores
309/// from importance, neuro_score, and access recency. High-importance
310/// memories require human review before any action.
311pub struct RetentionPruneTool {
312    store: Arc<MemoryStore>,
313    associations: Arc<AssociationStore>,
314    spiral_tracker: Arc<std::sync::Mutex<wm_cognitive::SpiralTracker>>,
315    stats: ToolStats,
316    effects: EffectRow,
317}
318
319impl RetentionPruneTool {
320    pub fn new(
321        store: Arc<MemoryStore>,
322        associations: Arc<AssociationStore>,
323        spiral_tracker: Arc<std::sync::Mutex<wm_cognitive::SpiralTracker>>,
324    ) -> Self {
325        Self {
326            store,
327            associations,
328            spiral_tracker,
329            stats: ToolStats::default(),
330            effects: EffectRow {
331                // Runs an autonomous cycle: scans memory galaxies and
332                // logs the cycle record to the Substrate galaxy.
333                reads: super::common::memory_galaxy_reads(),
334                writes: vec![Resource::Galaxy("substrate".into())],
335                ..Default::default()
336            },
337        }
338    }
339}
340
341#[async_trait]
342impl Tool for RetentionPruneTool {
343    fn name(&self) -> &str {
344        "retention.prune"
345    }
346    fn gana(&self) -> Gana {
347        Gana::Encampment
348    }
349    fn effects(&self) -> &EffectRow {
350        &self.effects
351    }
352    fn description(&self) -> &str {
353        "Identify memories ready for forgetting based on decay + neuro_score (gated, human review)"
354    }
355    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
356        let health_score = args
357            .get("health_score")
358            .and_then(Value::as_f64)
359            .unwrap_or(0.8) as f32;
360
361        let mut runner = wm_cognitive::AutonomousCycleRunner::default();
362        let cycle_ctx =
363            wm_cognitive::CycleContext::new(&self.store, &self.associations, health_score);
364        let result = runner.run_cycle(wm_cognitive::CycleType::Prune, &cycle_ctx);
365
366        // Record in spiral tracker
367        if let Ok(mut tracker) = self.spiral_tracker.lock() {
368            tracker.record(&result);
369        }
370
371        Ok(json!({
372            "status": "success",
373            "cycle": result.cycle.name(),
374            "cycle_status": format!("{:?}", result.status),
375            "purpose": result.purpose,
376            "memories_scanned": result.memories_scanned,
377            "proposals_generated": result.proposals_generated,
378            "duration_ms": result.duration_ms,
379            "requires_human_review": true,
380            "notes": result.notes,
381            "prunes": result.prunes,
382        }))
383    }
384    fn stats(&self) -> &ToolStats {
385        &self.stats
386    }
387}
388
389/// `sensorimotor.scan` — poll sensors, evaluate reflexes, execute commands.
390///
391/// Runs the sensorimotor autonomous cycle. Polls all registered sensors,
392/// evaluates reflex rules against current readings, and executes any triggered
393/// actuator commands. Results are logged to Gnosis and recorded in the spiral
394/// tracker. Does not require human review.
395pub struct SensorimotorScanTool {
396    store: Arc<MemoryStore>,
397    associations: Arc<AssociationStore>,
398    spiral_tracker: Arc<std::sync::Mutex<wm_cognitive::SpiralTracker>>,
399    sensorimotor_bus: Arc<std::sync::Mutex<wm_substrate::sensorimotor::SensorimotorBus>>,
400    reflex_loop: Arc<std::sync::Mutex<wm_substrate::sensorimotor::ReflexLoop>>,
401    stats: ToolStats,
402    effects: EffectRow,
403}
404
405impl SensorimotorScanTool {
406    pub fn new(
407        store: Arc<MemoryStore>,
408        associations: Arc<AssociationStore>,
409        spiral_tracker: Arc<std::sync::Mutex<wm_cognitive::SpiralTracker>>,
410        sensorimotor_bus: Arc<std::sync::Mutex<wm_substrate::sensorimotor::SensorimotorBus>>,
411        reflex_loop: Arc<std::sync::Mutex<wm_substrate::sensorimotor::ReflexLoop>>,
412    ) -> Self {
413        Self {
414            store,
415            associations,
416            spiral_tracker,
417            sensorimotor_bus,
418            reflex_loop,
419            stats: ToolStats::default(),
420            effects: EffectRow {
421                // Runs an autonomous cycle: scans memory galaxies and
422                // logs the cycle record to the Substrate galaxy.
423                reads: super::common::memory_galaxy_reads(),
424                writes: vec![Resource::Galaxy("substrate".into())],
425                ..Default::default()
426            },
427        }
428    }
429}
430
431#[async_trait]
432impl Tool for SensorimotorScanTool {
433    fn name(&self) -> &str {
434        "sensorimotor.scan"
435    }
436    fn gana(&self) -> Gana {
437        Gana::Encampment
438    }
439    fn effects(&self) -> &EffectRow {
440        &self.effects
441    }
442    fn description(&self) -> &str {
443        "Poll sensors, evaluate reflex rules, and execute triggered actuator commands (gated, logged)"
444    }
445    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
446        let health_score = args
447            .get("health_score")
448            .and_then(Value::as_f64)
449            .unwrap_or(0.8) as f32;
450
451        let mut runner = wm_cognitive::AutonomousCycleRunner::default();
452        let cycle_ctx =
453            wm_cognitive::CycleContext::new(&self.store, &self.associations, health_score)
454                .with_sensorimotor(&self.sensorimotor_bus, &self.reflex_loop);
455
456        let result = runner.run_cycle(wm_cognitive::CycleType::Sensorimotor, &cycle_ctx);
457
458        if let Ok(mut tracker) = self.spiral_tracker.lock() {
459            tracker.record(&result);
460        }
461
462        Ok(json!({
463            "status": "success",
464            "cycle": result.cycle.name(),
465            "cycle_status": format!("{:?}", result.status),
466            "purpose": result.purpose,
467            "memories_scanned": result.memories_scanned,
468            "proposals_generated": result.proposals_generated,
469            "duration_ms": result.duration_ms,
470            "requires_human_review": false,
471            "notes": result.notes,
472            "sensorimotor": result.sensorimotor,
473        }))
474    }
475    fn stats(&self) -> &ToolStats {
476        &self.stats
477    }
478}
479
480#[cfg(test)]
481mod tests {
482    use super::*;
483    use wm_core::Galaxy;
484    use wm_memory::Memory;
485
486    fn parts() -> (
487        tempfile::TempDir,
488        Arc<MemoryStore>,
489        Arc<AssociationStore>,
490        Arc<std::sync::Mutex<wm_cognitive::SpiralTracker>>,
491    ) {
492        let tmp = tempfile::tempdir().unwrap();
493        let store = Arc::new(MemoryStore::open_default(tmp.path()).unwrap());
494        let assoc = Arc::new(AssociationStore::open(store.env()).unwrap());
495        let tracker = Arc::new(std::sync::Mutex::new(wm_cognitive::SpiralTracker::default()));
496        (tmp, store, assoc, tracker)
497    }
498
499    #[tokio::test]
500    async fn spiral_report_reflects_recorded_cycles() {
501        let tracker = Arc::new(std::sync::Mutex::new(wm_cognitive::SpiralTracker::default()));
502        tracker
503            .lock()
504            .unwrap()
505            .record(&wm_cognitive::CycleResult::new(
506                wm_cognitive::CycleType::Connect,
507                wm_cognitive::CycleStatus::Completed,
508            ));
509
510        let result = SpiralReportTool::new(tracker)
511            .call(&mut Context::default(), json!({}))
512            .await
513            .unwrap();
514        assert_eq!(result["total_cycles_run"], 1);
515        assert_eq!(result["cycles"][0]["cycle"], "consolidation.connect");
516    }
517
518    #[tokio::test]
519    async fn connect_tool_plumbs_the_health_gate_before_touching_memories() {
520        let (_tmp, store, assoc, tracker) = parts();
521        let mut mem1 = Memory::new(Galaxy::Codex, "Rust algorithm data structure".into());
522        mem1.metadata.tags = vec!["rust".into(), "algorithm".into()];
523        store.put_semantic(Galaxy::Codex, &mut mem1).unwrap();
524        let mut mem2 = Memory::new(Galaxy::Codex, "Rust algorithm data method".into());
525        mem2.metadata.tags = vec!["rust".into(), "algorithm".into()];
526        store.put_semantic(Galaxy::Codex, &mut mem2).unwrap();
527
528        let tool = ConsolidationConnectTool::new(store, assoc, tracker);
529        let gated = tool
530            .call(&mut Context::default(), json!({"health_score": 0.1}))
531            .await
532            .unwrap();
533        assert_eq!(gated["cycle_status"], "SkippedHealth");
534        assert_eq!(gated["memories_scanned"], 0);
535        assert_eq!(gated["connections"].as_array().unwrap().len(), 0);
536        assert_eq!(gated["requires_human_review"], true);
537
538        let ran = tool
539            .call(&mut Context::default(), json!({"health_score": 0.9}))
540            .await
541            .unwrap();
542        assert_eq!(ran["cycle_status"], "Completed");
543        assert!(!ran["connections"].as_array().unwrap().is_empty());
544        assert_eq!(ran["requires_human_review"], true);
545    }
546
547    #[tokio::test]
548    async fn compress_tool_reports_primary_by_importance() {
549        let (_tmp, store, assoc, tracker) = parts();
550        let mut mem1 = Memory::new(Galaxy::Codex, "algorithm data structure rust".into());
551        mem1.metadata.importance = 0.8;
552        mem1.metadata.tags = vec!["rust".into(), "algorithm".into()];
553        store.put_semantic(Galaxy::Codex, &mut mem1).unwrap();
554        let mut mem2 = Memory::new(Galaxy::Codex, "algorithm data method rust".into());
555        mem2.metadata.importance = 0.3;
556        mem2.metadata.tags = vec!["rust".into(), "algorithm".into()];
557        store.put_semantic(Galaxy::Codex, &mut mem2).unwrap();
558
559        let result = ConsolidationCompressTool::new(store, assoc, tracker)
560            .call(&mut Context::default(), json!({"health_score": 0.9}))
561            .await
562            .unwrap();
563        assert_eq!(result["cycle_status"], "Completed");
564        assert_eq!(result["requires_human_review"], true);
565        assert_eq!(
566            result["compressions"][0]["primary_id"].as_str().unwrap(),
567            mem1.metadata.id.to_string()
568        );
569    }
570
571    #[tokio::test]
572    async fn emergence_tool_reports_frequent_tags_without_review() {
573        let (_tmp, store, assoc, tracker) = parts();
574        for i in 0..5 {
575            let mut mem = Memory::new(Galaxy::Codex, format!("rust memory item {i}"));
576            mem.metadata.tags = vec!["rust".into(), "memory".into()];
577            store.put_semantic(Galaxy::Codex, &mut mem).unwrap();
578        }
579
580        let result = EmergenceScanTool::new(store, assoc, tracker)
581            .call(&mut Context::default(), json!({"health_score": 0.9}))
582            .await
583            .unwrap();
584        assert_eq!(result["cycle_status"], "Completed");
585        assert_eq!(result["requires_human_review"], false);
586        let rust = result["emergences"]
587            .as_array()
588            .unwrap()
589            .iter()
590            .find(|e| e["tag"] == "rust")
591            .expect("rust emergence expected");
592        assert!(rust["frequency"].as_u64().unwrap() >= 3);
593    }
594
595    #[tokio::test]
596    async fn prune_tool_marks_low_retention_and_skips_protected() {
597        let (_tmp, store, assoc, tracker) = parts();
598        let mut low = Memory::new(Galaxy::Codex, "unimportant old memory".into())
599            .with_importance(0.05)
600            .with_neuro_score(0.05);
601        low.metadata.accessed_at = chrono::Utc::now() - chrono::Duration::days(365);
602        store.put(Galaxy::Codex, &low).unwrap();
603        let mut protected = Memory::new(Galaxy::Codex, "protected old memory".into())
604            .with_importance(0.05)
605            .with_neuro_score(0.05)
606            .with_protection(true);
607        protected.metadata.accessed_at = chrono::Utc::now() - chrono::Duration::days(365);
608        store.put(Galaxy::Codex, &protected).unwrap();
609
610        let result = RetentionPruneTool::new(store, assoc, tracker)
611            .call(&mut Context::default(), json!({"health_score": 0.9}))
612            .await
613            .unwrap();
614        assert_eq!(result["cycle_status"], "Completed");
615        let prunes = result["prunes"].as_array().unwrap();
616        let ids: Vec<&str> = prunes
617            .iter()
618            .map(|p| p["memory_id"].as_str().unwrap())
619            .collect();
620        let low_id = low.metadata.id.to_string();
621        let protected_id = protected.metadata.id.to_string();
622        assert!(ids.contains(&low_id.as_str()), "low-retention id expected");
623        assert!(
624            !ids.contains(&protected_id.as_str()),
625            "protected memory must not be pruned"
626        );
627    }
628
629    #[tokio::test]
630    async fn sensorimotor_tool_fails_closed_without_an_actuation_mask() {
631        use wm_substrate::sensorimotor::{
632            ActuatorKind, ReflexRule, SensorKind, SensorimotorBus, StubActuator, StubSensor,
633        };
634
635        let (_tmp, store, assoc, tracker) = parts();
636        let mut bus = SensorimotorBus::new(100);
637        bus.register_sensor(Box::new(StubSensor::new(
638            "test_temp",
639            SensorKind::Temperature,
640            80.0,
641        )));
642        bus.register_actuator(Box::new(StubActuator::new("fan0", ActuatorKind::Motor)));
643        let bus = Arc::new(std::sync::Mutex::new(bus));
644        let reflex = Arc::new(std::sync::Mutex::new(
645            wm_substrate::sensorimotor::ReflexLoop::new(),
646        ));
647        reflex.lock().unwrap().add_rule(ReflexRule::above(
648            "test_temp",
649            "fan0",
650            ActuatorKind::Motor,
651            50.0,
652            1.0,
653            0.0,
654        ));
655
656        let result = SensorimotorScanTool::new(store, assoc, tracker, bus, reflex)
657            .call(&mut Context::default(), json!({"health_score": 0.9}))
658            .await
659            .unwrap();
660        assert_eq!(result["cycle_status"], "Completed");
661        assert_eq!(result["requires_human_review"], false);
662        assert!(
663            result["notes"]
664                .as_str()
665                .unwrap()
666                .contains("actuation denied"),
667            "fail-closed refusal must be visible: {}",
668            result["notes"]
669        );
670        // The proposal is recorded but no command executed: the denied path
671        // must not claim a triggered reflex.
672        assert_eq!(result["proposals_generated"], 1);
673        assert_eq!(result["sensorimotor"][0]["reflex_triggered"], false);
674    }
675}