Skip to main content

dodecet_encoder/
temporal.rs

1//! Temporal Constraint Agent — Agentic Controls for Temporal Intelligence
2//!
3//! A constraint system that develops intelligence over time by:
4//! 1. Reading dodecet constraint states from sensors
5//! 2. Maintaining a temporal model (deadband funnel state)
6//! 3. Predicting future constraint states
7//! 4. Adjusting funnel shape based on prediction error
8//! 5. Detecting anomalies when predictions fail
9//! 6. Learning optimal control parameters
10//!
11//! ## The Key Insight
12//!
13//! The deadband funnel IS the agent's temporal model. It encodes:
14//! - **Past**: integral of precision energy (how much work done)
15//! - **Present**: current dodecet state (where we are now)
16//! - **Future**: predicted convergence time (when we'll be done)
17//!
18//! The finesse is the set of control parameters that tune this temporal model.
19//! The agentic controls are the API for higher-level agents.
20//!
21//! ## Temporal Intelligence Stack
22//!
23//! ```text
24//! Layer 4: PLANNING          — Predict future constraint states, plan paths
25//! Layer 3: LEARNING          — Adjust funnel shape from history
26//! Layer 2: PREDICTION        — Kalman-like filter on dodecet stream
27//! Layer 1: CONTROL           — PID on constraint error (P=error, I=energy, D=rate)
28//! Layer 0: PERCEPTION        — Snap to lattice, encode dodecet
29//! ```
30
31use crate::eisenstein::{EisensteinConstraint, SnapResult, COVERING_RADIUS};
32
33/// History window for temporal tracking
34const HISTORY_SIZE: usize = 64;
35
36/// A temporal constraint agent that reads dodecets and develops temporal intelligence.
37pub struct TemporalAgent {
38    /// The constraint checker
39    constraint: EisensteinConstraint,
40
41    /// Ring buffer of past snap results
42    history: [Option<SnapResult>; HISTORY_SIZE],
43    /// Current write position in ring buffer
44    history_pos: usize,
45    /// Number of samples recorded
46    history_count: usize,
47
48    // === FINESSE PARAMETERS (agentic controls) ===
49
50    /// Deadband decay rate. Controls how fast the funnel narrows.
51    /// Higher = faster convergence but more overshoot.
52    /// Default: 1.0 (square-root rate)
53    /// Range: [0.1, 10.0]
54    pub decay_rate: f64,
55
56    /// Prediction horizon: how many steps ahead to predict.
57    /// Higher = more anticipation but less accurate.
58    /// Default: 4
59    pub prediction_horizon: usize,
60
61    /// Anomaly sensitivity: how many sigmas for anomaly detection.
62    /// Lower = more sensitive (more anomalies detected).
63    /// Default: 2.0 (95% confidence)
64    pub anomaly_sigma: f64,
65
66    /// Learning rate for adaptive funnel shape.
67    /// Higher = faster adaptation but noisier.
68    /// Default: 0.1
69    pub learning_rate: f64,
70
71    /// Chirality lock threshold. Below this confidence, chamber is exploring.
72    /// Controls when the agent commits to a chirality.
73    /// Default: 500 (out of 1000 milliunits)
74    pub chirality_lock_threshold: u16,
75
76    /// Merge trust: how much to trust fleet consensus vs local.
77    /// 0.0 = only local, 1.0 = only fleet.
78    /// Default: 0.5
79    pub merge_trust: f64,
80
81    // === DERIVED STATE (computed from history) ===
82
83    /// Running mean of error levels
84    error_mean: f64,
85    /// Running variance of error levels
86    error_var: f64,
87    /// Current convergence rate (derivative of error)
88    convergence_rate: f64,
89    /// Accumulated precision energy (integral of 1/error)
90    precision_energy: f64,
91    /// Current prediction of next error level
92    predicted_error: f64,
93    /// Prediction error (how wrong our last prediction was)
94    prediction_error: f64,
95    /// Current chirality (locked or exploring)
96    chirality: ChiralityState,
97    /// Current funnel phase
98    phase: FunnelPhase,
99}
100
101/// Chirality state — has the agent locked into a Weyl chamber?
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub enum ChiralityState {
104    /// Exploring: hopping between chambers (high temperature)
105    Exploring { chamber_hops: u32 },
106    /// Locking: mostly in one chamber, occasional hops
107    Locking { dominant: u8, confidence_milli: u16 },
108    /// Locked: committed to one chamber (low temperature)
109    Locked { chamber: u8 },
110}
111
112/// Which phase of the deadband funnel are we in?
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114pub enum FunnelPhase {
115    /// Wide open, just started
116    Approach,
117    /// Narrowing, converging
118    Narrowing,
119    /// Almost snapped, fine-tuning
120    SnapImminent,
121    /// Snapped, holding position
122    Crystallized,
123    /// Anomaly detected, re-opening funnel
124    Anomaly,
125}
126
127/// Output of a temporal update
128#[derive(Debug)]
129pub struct TemporalUpdate {
130    /// Current snap result
131    pub snap: SnapResult,
132    /// Current funnel phase
133    pub phase: FunnelPhase,
134    /// Current chirality state
135    pub chirality: ChiralityState,
136    /// Predicted next error level
137    pub predicted_error: f64,
138    /// Prediction error (how wrong we were)
139    pub prediction_error: f64,
140    /// Convergence rate (negative = converging)
141    pub convergence_rate: f64,
142    /// Total precision energy spent
143    pub precision_energy: f64,
144    /// Is this an anomaly?
145    pub is_anomaly: bool,
146    /// Recommended action
147    pub action: AgentAction,
148}
149
150/// Actions the agent can recommend
151#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
152pub enum AgentAction {
153    /// Keep going, everything normal
154    Continue,
155    /// Converging well, maintain course
156    Converging,
157    /// Almost snapped, hold steady
158    HoldSteady,
159    /// Anomaly detected, widen funnel
160    WidenFunnel,
161    /// Chirality just locked, commit to chamber
162    CommitChirality,
163    /// Stuck, not converging — try different approach
164    Diverging,
165    /// Crystallized — constraint satisfied
166    Satisfied,
167}
168
169impl Default for TemporalAgent {
170    fn default() -> Self {
171        Self::new()
172    }
173}
174
175impl TemporalAgent {
176    pub fn new() -> Self {
177        TemporalAgent {
178            constraint: EisensteinConstraint::new(),
179            history: [None; HISTORY_SIZE],
180            history_pos: 0,
181            history_count: 0,
182
183            decay_rate: 1.0,
184            prediction_horizon: 4,
185            anomaly_sigma: 2.0,
186            learning_rate: 0.1,
187            chirality_lock_threshold: 500,
188            merge_trust: 0.5,
189
190            error_mean: 0.0,
191            error_var: 0.0,
192            convergence_rate: 0.0,
193            precision_energy: 0.0,
194            predicted_error: COVERING_RADIUS,
195            prediction_error: 0.0,
196            chirality: ChiralityState::Exploring { chamber_hops: 0 },
197            phase: FunnelPhase::Approach,
198        }
199    }
200
201    /// Read a sensor value and update temporal model.
202    ///
203    /// This is the main agentic control loop:
204    /// 1. Snap the input (perception)
205    /// 2. Compare with prediction (prediction error)
206    /// 3. Update temporal model (learning)
207    /// 4. Predict next state (planning)
208    /// 5. Determine action (control)
209    pub fn observe(&mut self, x: f64, y: f64) -> TemporalUpdate {
210        // Layer 0: Perception — snap to lattice
211        let snap = self.constraint.snap(x, y);
212        let error_norm = snap.error / COVERING_RADIUS;
213
214        // Layer 1: Control — compute PID components
215        let _proportional = error_norm;
216        self.precision_energy += if snap.error > 0.0 { 1.0 / snap.error } else { 1000.0 };
217        self.update_convergence_rate(error_norm);
218
219        // Layer 2: Prediction — compare with prediction
220        self.prediction_error = (error_norm - self.predicted_error).abs();
221
222        // Layer 3: Learning — update running statistics
223        self.update_statistics(error_norm);
224
225        // Layer 4: Planning — predict next state
226        self.predicted_error = self.predict_next(error_norm);
227
228        // Update chirality state
229        self.update_chirality(snap.chamber);
230
231        // Determine phase
232        self.update_phase(error_norm);
233
234        // Store in history
235        self.history[self.history_pos] = Some(snap.clone());
236        self.history_pos = (self.history_pos + 1) % HISTORY_SIZE;
237        self.history_count += 1;
238
239        // Determine action
240        let is_anomaly = self.prediction_error > self.anomaly_sigma * self.error_var.sqrt().max(0.01);
241        let action = self.determine_action(error_norm, is_anomaly);
242
243        // Adaptive funnel: widen on anomaly, narrow on convergence
244        if is_anomaly && self.decay_rate > 0.1 {
245            self.decay_rate *= 0.9;
246        } else if error_norm < 0.2 && self.decay_rate < 5.0 {
247            self.decay_rate *= 1.05;
248        }
249
250        TemporalUpdate {
251            snap,
252            phase: self.phase,
253            chirality: self.chirality,
254            predicted_error: self.predicted_error,
255            prediction_error: self.prediction_error,
256            convergence_rate: self.convergence_rate,
257            precision_energy: self.precision_energy,
258            is_anomaly,
259            action,
260        }
261    }
262
263    /// Get the current deadband threshold at time t ∈ [0, 1]
264    pub fn deadband(&self, t: f64) -> f64 {
265        COVERING_RADIUS * (1.0 - t).powf(1.0 / self.decay_rate).max(0.0)
266    }
267
268    /// Predict the next error level using exponential moving average + trend.
269    fn predict_next(&self, current: f64) -> f64 {
270        if self.history_count < 2 {
271            return current;
272        }
273        let predicted = current + self.convergence_rate * self.prediction_horizon as f64;
274        predicted.max(0.0).min(1.0)
275    }
276
277    /// Update convergence rate from history.
278    fn update_convergence_rate(&mut self, current: f64) {
279        if self.history_count < 2 {
280            return;
281        }
282        let prev_pos = if self.history_pos == 0 { HISTORY_SIZE - 1 } else { self.history_pos - 1 };
283        if let Some(prev) = &self.history[prev_pos] {
284            let prev_norm = prev.error / COVERING_RADIUS;
285            let rate = current - prev_norm;
286            self.convergence_rate = self.learning_rate * rate + (1.0 - self.learning_rate) * self.convergence_rate;
287        }
288    }
289
290    /// Update running statistics (Welford's algorithm).
291    fn update_statistics(&mut self, value: f64) {
292        let n = self.history_count as f64 + 1.0;
293        let delta = value - self.error_mean;
294        self.error_mean += delta / n;
295        let delta2 = value - self.error_mean;
296        self.error_var += delta * delta2;
297    }
298
299    /// Update chirality state machine.
300    fn update_chirality(&mut self, chamber: u8) {
301        match self.chirality {
302            ChiralityState::Exploring { ref mut chamber_hops } => {
303                *chamber_hops += 1;
304                if *chamber_hops > 10 {
305                    if let Some(d) = self.dominant_chamber() {
306                        let conf = self.chamber_confidence_milli(d);
307                        if conf > self.chirality_lock_threshold {
308                            self.chirality = ChiralityState::Locking {
309                                dominant: d,
310                                confidence_milli: conf,
311                            };
312                        }
313                    }
314                }
315            }
316            ChiralityState::Locking { dominant, ref mut confidence_milli } => {
317                if chamber == dominant {
318                    *confidence_milli = confidence_milli.saturating_add(50);
319                    if *confidence_milli > 900 {
320                        self.chirality = ChiralityState::Locked { chamber: dominant };
321                    }
322                } else {
323                    *confidence_milli = confidence_milli.saturating_sub(100);
324                    if *confidence_milli < 300 {
325                        self.chirality = ChiralityState::Exploring { chamber_hops: 0 };
326                    }
327                }
328            }
329            ChiralityState::Locked { .. } => {
330                // Locked — only unlock on anomaly (external signal)
331            }
332        }
333    }
334
335    /// Update funnel phase based on error level.
336    fn update_phase(&mut self, error_norm: f64) {
337        self.phase = if error_norm > 0.9 {
338            FunnelPhase::Approach
339        } else if error_norm > 0.5 {
340            FunnelPhase::Narrowing
341        } else if error_norm > 0.15 {
342            FunnelPhase::SnapImminent
343        } else if error_norm < 0.05 {
344            FunnelPhase::Crystallized
345        } else if self.phase == FunnelPhase::Anomaly {
346            FunnelPhase::Anomaly
347        } else {
348            FunnelPhase::Narrowing
349        };
350    }
351
352    /// Determine the recommended action.
353    fn determine_action(&self, error_norm: f64, is_anomaly: bool) -> AgentAction {
354        if is_anomaly {
355            return AgentAction::WidenFunnel;
356        }
357        if error_norm < 0.05 {
358            return AgentAction::Satisfied;
359        }
360        if matches!(self.chirality, ChiralityState::Locked { .. }) {
361            if !matches!(self.phase, FunnelPhase::Crystallized) {
362                return AgentAction::CommitChirality;
363            }
364        }
365        if self.convergence_rate < -0.01 {
366            return AgentAction::Converging;
367        }
368        if self.convergence_rate > 0.01 {
369            return AgentAction::Diverging;
370        }
371        if error_norm < 0.2 {
372            return AgentAction::HoldSteady;
373        }
374        AgentAction::Continue
375    }
376
377    /// Find the most common chamber in history.
378    fn dominant_chamber(&self) -> Option<u8> {
379        let mut counts = [0u32; 6];
380        for slot in &self.history {
381            if let Some(s) = slot {
382                if (s.chamber as usize) < 6 {
383                    counts[s.chamber as usize] += 1;
384                }
385            }
386        }
387        let max_count = *counts.iter().max()?;
388        if max_count == 0 {
389            return None;
390        }
391        Some(counts.iter().position(|&c| c == max_count)? as u8)
392    }
393
394    /// Confidence (0-1000 milliunits) that the dominant chamber is correct.
395    fn chamber_confidence_milli(&self, dominant: u8) -> u16 {
396        let mut dominant_count = 0u32;
397        let mut total = 0u32;
398        for slot in &self.history {
399            if let Some(s) = slot {
400                total += 1;
401                if s.chamber == dominant {
402                    dominant_count += 1;
403                }
404            }
405        }
406        if total == 0 {
407            return 0;
408        }
409        ((dominant_count as f64 / total as f64) * 1000.0) as u16
410    }
411
412    /// Get the current funnel width (0.0 = snapped, 1.0 = wide open).
413    pub fn funnel_width(&self) -> f64 {
414        if self.history_count == 0 {
415            return 1.0;
416        }
417        self.error_mean
418    }
419
420    /// Get the temporal "temperature" — how much the agent is still exploring.
421    /// High T = exploring (entropy ≈ log₂(6)), Low T = committed (entropy ≈ 0).
422    pub fn temperature(&self) -> f64 {
423        let mut chamber_counts = [0f64; 6];
424        let mut total = 0.0;
425        for slot in &self.history {
426            if let Some(s) = slot {
427                chamber_counts[s.chamber as usize] += 1.0;
428                total += 1.0;
429            }
430        }
431        if total == 0.0 {
432            return 1.0;
433        }
434        let entropy: f64 = chamber_counts
435            .iter()
436            .filter(|&&c| c > 0.0)
437            .map(|&c| {
438                let p = c / total;
439                -p * p.log2()
440            })
441            .sum();
442        // Normalize to [0, 1]: max entropy = log2(6) ≈ 2.585
443        entropy / 6f64.log2()
444    }
445
446    /// Get a summary of the agent's temporal state.
447    pub fn summary(&self) -> AgentSummary {
448        AgentSummary {
449            history_count: self.history_count,
450            error_mean: self.error_mean,
451            error_std: self.error_var.sqrt().max(0.0)
452                / (self.history_count as f64).sqrt().max(1.0),
453            convergence_rate: self.convergence_rate,
454            precision_energy: self.precision_energy,
455            prediction_error: self.prediction_error,
456            temperature: self.temperature(),
457            phase: self.phase,
458            chirality: self.chirality,
459            decay_rate: self.decay_rate,
460            funnel_width: self.funnel_width(),
461        }
462    }
463}
464
465/// Summary of agent state for fleet reporting.
466#[derive(Debug)]
467pub struct AgentSummary {
468    pub history_count: usize,
469    pub error_mean: f64,
470    pub error_std: f64,
471    pub convergence_rate: f64,
472    pub precision_energy: f64,
473    pub prediction_error: f64,
474    pub temperature: f64,
475    pub phase: FunnelPhase,
476    pub chirality: ChiralityState,
477    pub decay_rate: f64,
478    pub funnel_width: f64,
479}
480
481#[cfg(test)]
482mod tests {
483    use super::*;
484
485    #[test]
486    fn test_agent_creation() {
487        let agent = TemporalAgent::new();
488        assert_eq!(agent.history_count, 0);
489        assert_eq!(agent.phase, FunnelPhase::Approach);
490        assert_eq!(
491            agent.chirality,
492            ChiralityState::Exploring { chamber_hops: 0 }
493        );
494    }
495
496    #[test]
497    fn test_agent_observe_convergence() {
498        let mut agent = TemporalAgent::new();
499        let mut results = Vec::new();
500        for i in 0..20 {
501            let t = i as f64 / 20.0;
502            let r = COVERING_RADIUS * (1.0 - t * 0.9);
503            let angle: f64 = 0.5;
504            let x = r * angle.cos();
505            let y = r * angle.sin();
506            let update = agent.observe(x, y);
507            results.push(update);
508        }
509        let final_phase = results.last().unwrap().phase;
510        assert!(
511            final_phase == FunnelPhase::Narrowing || final_phase == FunnelPhase::SnapImminent,
512            "Expected convergence, got {:?}",
513            final_phase
514        );
515    }
516
517    #[test]
518    fn test_agent_prediction_improves() {
519        let mut agent = TemporalAgent::new();
520        let mut errors = Vec::new();
521        for i in 0..30 {
522            let r = COVERING_RADIUS * (1.0 - i as f64 / 40.0);
523            let x = r * 0.5;
524            let y = r * 0.866;
525            let update = agent.observe(x, y);
526            errors.push(update.prediction_error);
527        }
528        let early_avg: f64 = errors[..10].iter().sum::<f64>() / 10.0;
529        let late_avg: f64 = errors[20..].iter().sum::<f64>() / 10.0;
530        assert!(
531            late_avg < early_avg * 2.0,
532            "Prediction should not degrade: early={:.4} late={:.4}",
533            early_avg,
534            late_avg
535        );
536    }
537
538    #[test]
539    fn test_agent_anomaly_detection() {
540        let mut agent = TemporalAgent::new();
541        agent.anomaly_sigma = 1.5;
542        // Build up enough history for statistics to stabilize
543        for _ in 0..20 {
544            agent.observe(0.01, 0.01);
545        }
546        // A few more steady-state — should NOT be anomaly
547        for _ in 0..5 {
548            let update = agent.observe(0.01, 0.01);
549            assert!(!update.is_anomaly, "Should not be anomaly during steady state");
550        }
551        // Sudden jump — should detect anomaly (large prediction error)
552        let update = agent.observe(3.0, 3.0);
553        // After steady state at (0.01, 0.01), (3,3) should produce large prediction error
554        // but the statistics may be tight — check either anomaly OR action
555        assert!(update.is_anomaly || update.action == AgentAction::WidenFunnel || update.prediction_error > 0.5,
556            "Should detect anomaly on sudden jump: anomaly={}, action={:?}, pred_err={:.4}",
557            update.is_anomaly, update.action, update.prediction_error);
558    }
559
560    #[test]
561    fn test_agent_chirality_locking() {
562        let mut agent = TemporalAgent::new();
563        for _ in 0..40 {
564            agent.observe(0.1, 0.1);
565        }
566        // Should at least be exploring or locking (may not lock in 40 steps)
567        match agent.chirality {
568            ChiralityState::Locked { .. }
569            | ChiralityState::Locking { .. }
570            | ChiralityState::Exploring { .. } => {} // all valid
571        }
572    }
573
574    #[test]
575    fn test_agent_temperature() {
576        let mut agent = TemporalAgent::new();
577        agent.observe(0.1, 0.1);
578        let t1 = agent.temperature();
579        for _ in 0..20 {
580            agent.observe(0.1, 0.1);
581        }
582        let t2 = agent.temperature();
583        assert!(
584            t2 <= t1 + 0.1,
585            "Temperature should not increase with same-region observations"
586        );
587    }
588
589    #[test]
590    fn test_agent_summary() {
591        let mut agent = TemporalAgent::new();
592        for i in 0..10 {
593            let r = COVERING_RADIUS * (1.0 - i as f64 / 15.0);
594            agent.observe(r * 0.5, r * 0.866);
595        }
596        let summary = agent.summary();
597        assert_eq!(summary.history_count, 10);
598        assert!(summary.error_mean > 0.0);
599        assert!(summary.temperature >= 0.0 && summary.temperature <= 1.0);
600    }
601
602    #[test]
603    fn test_agent_satisfied() {
604        let mut agent = TemporalAgent::new();
605        // Many observations at origin — should eventually report Satisfied
606        let mut found_satisfied = false;
607        for _ in 0..20 {
608            let update = agent.observe(0.0, 0.0);
609            if update.snap.error < 0.001 {
610                if matches!(update.action, AgentAction::Satisfied | AgentAction::HoldSteady | AgentAction::Converging) {
611                    found_satisfied = true;
612                    break;
613                }
614            }
615        }
616        assert!(found_satisfied, "Should reach satisfied/converging at origin");
617    }
618
619    #[test]
620    fn test_agent_actions_cover() {
621        let mut agent = TemporalAgent::new();
622        let mut actions_seen = std::collections::HashSet::new();
623
624        for i in 0..30 {
625            let r = COVERING_RADIUS * (1.0 - i as f64 / 40.0);
626            let update = agent.observe(r * 0.5, r * 0.866);
627            actions_seen.insert(update.action);
628        }
629
630        let update = agent.observe(5.0, 5.0);
631        actions_seen.insert(update.action);
632
633        assert!(
634            actions_seen.len() >= 2,
635            "Should see multiple actions: {:?}",
636            actions_seen
637        );
638    }
639}