Skip to main content

katgpt_types/
temporal.rs

1//! Temporal Derivative Kernel — Dual Fast/Slow Surprise Signal (Plan 277).
2//!
3//! Distilled from O'Reilly 2026, "This is how the Neocortex Learns"
4//! ([arXiv:2606.08720](https://arxiv.org/abs/2606.08720)). Research note:
5//! [`katgpt-rs/.research/435_Temporal_Derivative_Kernel_Neocortical_Learning.md`](../.research/435_Temporal_Derivative_Kernel_Neocortical_Learning.md).
6//!
7//! Turns any streaming latent scalar (or fixed-size vector) into a signed
8//! "surprise" signal — the implicit prediction-error channel the neocortex
9//! uses for credit assignment, computed locally from a signal's own time
10//! series with no external target and no backprop.
11//!
12//! ## Why
13//!
14//! Every EMA currently in the codebase is a *single* integrator
15//! (`simd_fused_decay_write`, `evolve_belief`, etc.). The dual
16//! `(I_fast − I_slow)` band-pass derivative is the smallest missing
17//! primitive that upgrades four existing pillars:
18//!
19//! - **belief companion** — `evolve_belief` tracks *what is*; derivative tracks
20//!   *how fast it is changing*.
21//! - **δ-Mem write gate** — writes only on surprising events.
22//! - **Collapse detector fusion** — prediction-derivative collapse is
23//!   orthogonal to entropy collapse.
24//! - **Intrinsic curiosity** — `sigmoid(β · surprise_norm())` is a zero-cost
25//!   curiosity signal that needs no Solver.
26//!
27//! ## Latent vs raw boundary
28//!
29//! Operates on latent state; emits a bounded scalar (`surprise_norm`) that
30//! may sync as a raw summary statistic. Full N-dim derivative vector stays
31//! local per-entity.
32//!
33//! ## Sigmoid, never softmax
34//!
35//! Per `AGENTS.md`: the bridge projection [`sigmoid_surprise_gate`] uses
36//! sigmoid. Softmax over a single scalar is meaningless.
37//!
38//! ## Co-extraction provenance (Plan 338 Phase 2)
39//!
40//! Promoted from `katgpt-core::temporal_deriv` (Plan 277) so that the
41//! `katgpt-sense` crate can consume the kernel via the leaf only, breaking
42//! the katgpt-core cycle. katgpt-core re-exports this via
43//! `katgpt_core::temporal_deriv::*` (bit-for-bit path preserved). Tests stay
44//! in katgpt-core (they exercise the kernel through the re-export).
45
46#![allow(clippy::needless_range_loop)]
47
48use crate::simd::{fast_sigmoid, simd_dist_sq, simd_dot_f32, simd_fused_decay_write};
49
50/// Dual fast/slow EMA temporal-derivative kernel.
51///
52/// `fast` tracks the signal with a short time constant; `slow` with a long
53/// one. Their difference `(fast − slow)` is a signed band-pass derivative
54/// that spikes on change and decays to zero when the signal is stationary
55/// — the canonical neocortical prediction-error signal.
56///
57/// Generic over `N` (the signal dimension). Fixed-size array, zero
58/// allocation, suitable for embedding inside per-NPC state structs.
59///
60/// # Invariants
61///
62/// - `0 < alpha_slow < alpha_fast <= 1` (validated at construction).
63/// - State arrays are zero-initialized on [`new`](Self::new); use
64///   [`with_initial`](Self::with_initial) for warm starts / snapshot
65///   restore.
66/// - All operations are branch-free and in-place; safe to call from hot
67///   paths at any tier (plasma → cold).
68#[derive(Clone, Debug)]
69pub struct TemporalDerivativeKernel<const N: usize> {
70    /// Fast EMA — short time constant.
71    pub fast: [f32; N],
72    /// Slow EMA — long time constant.
73    pub slow: [f32; N],
74    /// Fast EMA coefficient. `new_fast = (1 − α_f) · old_fast + α_f · signal`.
75    pub alpha_fast: f32,
76    /// Slow EMA coefficient. `new_slow = (1 − α_s) · old_slow + α_s · signal`.
77    pub alpha_slow: f32,
78}
79
80impl<const N: usize> TemporalDerivativeKernel<N> {
81    /// Construct a zero-initialized kernel.
82    ///
83    /// Validates `0 < alpha_slow < alpha_fast <= 1`. Panics in debug,
84    /// clamps in release (paper's ~10× ratio is the canonical default;
85    /// e.g. `alpha_fast=0.3, alpha_slow=0.03`).
86    #[inline]
87    pub fn new(alpha_fast: f32, alpha_slow: f32) -> Self {
88        validate_alphas(alpha_fast, alpha_slow);
89        Self {
90            fast: [0.0; N],
91            slow: [0.0; N],
92            alpha_fast,
93            alpha_slow,
94        }
95    }
96
97    /// Construct with initial EMA state — for warm starts or snapshot
98    /// restore.
99    #[inline]
100    pub fn with_initial(fast: [f32; N], slow: [f32; N], alpha_fast: f32, alpha_slow: f32) -> Self {
101        validate_alphas(alpha_fast, alpha_slow);
102        Self {
103            fast,
104            slow,
105            alpha_fast,
106            alpha_slow,
107        }
108    }
109
110    /// Observe one signal sample; update both EMAs and return the
111    /// per-dim signed surprise vector `(fast − slow)`.
112    ///
113    /// Branch-free, no allocations. Reuses [`simd_fused_decay_write`] when
114    /// the `simd`-implied path is available (always-on in this crate — the
115    /// kernel dispatches to NEON/AVX2/scalar inside `simd`).
116    ///
117    /// Paper reference: O'Reilly 2026 §Implementational — the CaMKII/DAPK1
118    /// kinase cascade maps onto the `(fast − slow)` difference; we compute
119    /// it algebraically rather than biochemically.
120    #[inline]
121    pub fn observe(&mut self, signal: &[f32; N]) -> [f32; N] {
122        // Two EMA passes via the shared SIMD fused-decay-write kernel.
123        // Layout: new = decay * old + write * src  →  matches
124        //   (1 − α) · old + α · src  with decay=(1−α), write=α.
125        simd_fused_decay_write(
126            &mut self.fast,
127            1.0 - self.alpha_fast,
128            signal,
129            self.alpha_fast,
130        );
131        simd_fused_decay_write(
132            &mut self.slow,
133            1.0 - self.alpha_slow,
134            signal,
135            self.alpha_slow,
136        );
137
138        // Output: fast − slow (band-pass derivative).
139        let mut out = [0.0f32; N];
140        for i in 0..N {
141            out[i] = self.fast[i] - self.slow[i];
142        }
143        out
144    }
145
146    /// SIMD-optimized observe (alias — the default [`observe`](Self::observe)
147    /// already routes through [`simd_fused_decay_write`]).
148    ///
149    /// Kept as a distinct entry point so callers that want to make the
150    /// SIMD path explicit in their code can do so, and so that future
151    /// wider-SIMD specializations have a natural home.
152    #[inline]
153    pub fn observe_simd(&mut self, signal: &[f32; N]) -> [f32; N] {
154        self.observe(signal)
155    }
156
157    /// L2 norm of the current `(fast − slow)` derivative.
158    ///
159    /// Uses [`simd_dot_f32`] for the inner reduction when `N >= 4`.
160    /// Bounded scalar in `[0, ∞)`; typical operating range `[0, 1]` after
161    /// normalization.
162    #[inline]
163    pub fn surprise_norm(&self) -> f32 {
164        // Direct squared-distance between fast and slow — avoids materializing
165        // an intermediate `diff` buffer (saves N stack writes + N reads) and
166        // fuses the subtract+FMA into a single SIMD pass. Numerically
167        // bit-identical to the previous two-step form because both lower to
168        // `d = a - b; acc = d.mul_add(d, acc)` (single rounding on the square).
169        let sq = simd_dist_sq(&self.fast, &self.slow, N);
170        // Guard against negative zero from FMA contraction.
171        sq.max(0.0).sqrt()
172    }
173
174    /// Write `(fast − slow)` into a caller-provided buffer — zero-alloc
175    /// read path for consumers that already own a scratch buffer.
176    #[inline]
177    pub fn derivative_slice(&self, out: &mut [f32; N]) {
178        for i in 0..N {
179            out[i] = self.fast[i] - self.slow[i];
180        }
181    }
182
183    /// Fill both EMA arrays with zero — for entity respawn / session
184    /// restart.
185    #[inline]
186    pub fn reset(&mut self) {
187        self.fast = [0.0; N];
188        self.slow = [0.0; N];
189    }
190}
191
192impl<const N: usize> Default for TemporalDerivativeKernel<N> {
193    /// Default: paper's ~10× ratio — `alpha_fast=0.3, alpha_slow=0.03`.
194    #[inline]
195    fn default() -> Self {
196        Self::new(0.3, 0.03)
197    }
198}
199
200/// Bridge helper: project a derivative vector onto a single bounded scalar
201/// via `sigmoid(β · ‖derivative‖₂)`.
202///
203/// Canonical downstream projection per `AGENTS.md` latent→raw bridge rules.
204/// **Never softmax** (single-scalar softmax is meaningless; sigmoid gives a
205/// proper inject/skip probability).
206///
207/// `beta` is the inverse-temperature: large `beta` → sharp threshold,
208/// small `beta` → soft gate. Typical operating value `beta ∈ [1, 10]`.
209#[inline]
210pub fn sigmoid_surprise_gate(derivative: &[f32], beta: f32) -> f32 {
211    debug_assert!(
212        beta.is_finite(),
213        "sigmoid_surprise_gate: beta must be finite"
214    );
215    let sq = simd_dot_f32(derivative, derivative, derivative.len()).max(0.0);
216    let norm = sq.sqrt();
217    fast_sigmoid(beta * norm)
218}
219
220/// Validate `0 < alpha_slow < alpha_fast <= 1`. Debug panic on violation;
221/// release clamp.
222#[inline]
223fn validate_alphas(alpha_fast: f32, alpha_slow: f32) {
224    debug_assert!(
225        alpha_slow > 0.0 && alpha_fast > alpha_slow && alpha_fast <= 1.0,
226        "TemporalDerivativeKernel: require 0 < alpha_slow < alpha_fast <= 1, got fast={}, slow={}",
227        alpha_fast,
228        alpha_slow
229    );
230    // No-op in release: caller is responsible. Documented in rustdoc.
231    let _ = (alpha_fast, alpha_slow);
232}