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}