@@ -210,6 +210,126 @@ pub fn entropy_class(h: f64) -> u8 {
210210 ( ( h * 4.0 ) as u8 ) . min ( 3 )
211211}
212212
213+ // ── HHTL fork ladder — orthogonal leaf residue → Friston domain fork ──────────
214+ //
215+ // The unification (board canon): the entropy×energy `Quadrant`, Csikszentmihalyi's
216+ // flow channel (`lance_graph_contract::mul::FlowState`), Friston free-energy
217+ // minimization, and the Staunen↔Wisdom ladder are ONE 2-axis structure. The
218+ // CHALLENGE axis is surprise = the magnitude of the orthogonal helix/CAM-PQ leaf
219+ // residue the current domain's centroid codebook fails to explain; the SKILL axis
220+ // is the in-domain codebook's remaining resolving capacity. The fork rule is the
221+ // "anxiety escape": when challenge ≫ skill *at leaf depth*, the model cannot
222+ // minimize free energy in-domain, so it switches the model — mint a new classid
223+ // domain (an HHTL shift into a new exploration). This is the reduction-to-practice
224+ // of "if the orthogonal leaf residue is strong enough, free energy forks into
225+ // another domain."
226+
227+ /// Map an orthogonal-residue magnitude to the surprise/challenge axis `[0, 1]`,
228+ /// relative to the substrate noise floor.
229+ ///
230+ /// The helix/CAM-PQ leaf residue is the component left after the assigned centroid
231+ /// (the "place") is subtracted — geometrically orthogonal to that centroid, so its
232+ /// magnitude is the prediction error the current domain fails to explain. Below the
233+ /// noise floor it is mere quantization (≈0 surprise); the excess scales linearly to
234+ /// saturation over `sigma_k · noise_floor`.
235+ ///
236+ /// **Threshold provenance (`I-NOISE-FLOOR-JIRAK`):** `noise_floor` should be the
237+ /// Berry-Esseen/Jirak weak-dependence bound (CAM-PQ contamination makes classic IID
238+ /// Berry-Esseen wrong), and `sigma_k` the σ-multiple deemed "genuinely new". The
239+ /// linear ramp here is an honest proxy pending a Jirak-derived calibration, not a
240+ /// claimed bound.
241+ ///
242+ /// # Examples
243+ /// ```
244+ /// use ndarray::hpc::entropy_ladder::residue_surprise;
245+ /// assert!(residue_surprise(0.001, 0.004, 6.0) < 1e-12); // below floor → no surprise
246+ /// assert!((residue_surprise(1.0, 0.004, 6.0) - 1.0).abs() < 1e-12); // saturated
247+ /// let mid = residue_surprise(0.016, 0.004, 6.0); // excess 0.012 over span 0.024
248+ /// assert!((mid - 0.5).abs() < 1e-9);
249+ /// ```
250+ #[ inline]
251+ pub fn residue_surprise ( residue_mag : f64 , noise_floor : f64 , sigma_k : f64 ) -> f64 {
252+ let nf = noise_floor. max ( f64:: MIN_POSITIVE ) ;
253+ let span = ( sigma_k. max ( f64:: MIN_POSITIVE ) ) * nf;
254+ let excess = ( residue_mag - nf) . max ( 0.0 ) ;
255+ ( excess / span) . clamp ( 0.0 , 1.0 )
256+ }
257+
258+ /// What to do with a leaf residue, governed by the Csikszentmihalyi flow channel
259+ /// (challenge = residue surprise, skill = in-domain codebook capacity). Mirrors
260+ /// `lance_graph_contract::mul::FlowState`, projected onto the HHTL cascade.
261+ #[ derive( Debug , Clone , Copy , PartialEq , Eq , Hash ) ]
262+ #[ repr( u8 ) ]
263+ pub enum ForkAction {
264+ /// **Boredom** — challenge ≪ skill: the domain over-explains. Commit (and the
265+ /// caller may coarsen the address).
266+ Commit = 0 ,
267+ /// **Flow/Transition with depth remaining** — challenge ≈ skill: the codebook can
268+ /// still reach the point; descend HEEL→HIP→TWIG→LEAF one tier.
269+ DescendDeeper = 1 ,
270+ /// **Flow/Transition at leaf depth** — resolvable, but no finer tier remains: a
271+ /// sibling basin within the SAME classid codebook.
272+ ForkBasin = 2 ,
273+ /// **Anxiety at leaf depth** — challenge ≫ skill and irreducible in-domain: mint a
274+ /// NEW classid domain (HHTL shift = new exploration). Friston: switch the
275+ /// generative model when free energy can't be minimized within it.
276+ ForkDomain = 3 ,
277+ }
278+
279+ /// The fork decision. `challenge = residue_surprise(residue_mag, noise_floor,
280+ /// sigma_k)`; `in_domain_skill ∈ [0,1]` is the codebook's remaining resolving
281+ /// capacity. The challenge↔skill delta is banded exactly like the shipped
282+ /// `flow_state_from` (Anxiety `>0.2`, Flow `|δ|<0.15`, Boredom `<-0.2`), then the
283+ /// HHTL depth decides descend-vs-fork:
284+ ///
285+ /// * **Boredom** → [`ForkAction::Commit`].
286+ /// * **Anxiety, depth < max** → [`ForkAction::DescendDeeper`] (apply skill at a finer
287+ /// tier before declaring the residue irreducible — the fork is a *leaf* condition).
288+ /// * **Anxiety, depth == max** → [`ForkAction::ForkDomain`] (the orthogonal leaf
289+ /// residue is strong enough: free energy forks into a new domain).
290+ /// * **Flow/Transition** → [`ForkAction::DescendDeeper`] while `depth < max`, else
291+ /// [`ForkAction::ForkBasin`].
292+ ///
293+ /// # Examples
294+ /// ```
295+ /// use ndarray::hpc::entropy_ladder::{fork_decision, ForkAction};
296+ /// // Huge residue, low in-domain skill, already at the leaf → fork to a new domain.
297+ /// let a = fork_decision(1.0, 0.1, 3, 3, 0.004, 6.0);
298+ /// assert_eq!(a, ForkAction::ForkDomain);
299+ /// // Same surprise but a coarse tier remains → descend first, don't fork yet.
300+ /// assert_eq!(fork_decision(1.0, 0.1, 1, 3, 0.004, 6.0), ForkAction::DescendDeeper);
301+ /// // Tiny residue → the domain over-explains → commit.
302+ /// assert_eq!(fork_decision(0.002, 0.5, 3, 3, 0.004, 6.0), ForkAction::Commit);
303+ /// ```
304+ pub fn fork_decision (
305+ residue_mag : f64 , in_domain_skill : f64 , depth : u8 , max_depth : u8 , noise_floor : f64 , sigma_k : f64 ,
306+ ) -> ForkAction {
307+ let challenge = residue_surprise ( residue_mag, noise_floor, sigma_k) ;
308+ let skill = in_domain_skill. clamp ( 0.0 , 1.0 ) ;
309+ let delta = challenge - skill;
310+ let at_leaf = depth >= max_depth;
311+ if delta < -0.2 {
312+ // Boredom — skill over-covers the challenge.
313+ ForkAction :: Commit
314+ } else if delta > 0.2 {
315+ // Anxiety — challenge exceeds skill. Fork only once the residue is a *leaf*
316+ // residue; otherwise a finer tier may still resolve it.
317+ if at_leaf {
318+ ForkAction :: ForkDomain
319+ } else {
320+ ForkAction :: DescendDeeper
321+ }
322+ } else {
323+ // Flow / Transition — matched. Resolve in-domain: descend if we can, else a
324+ // sibling basin in the same codebook.
325+ if at_leaf {
326+ ForkAction :: ForkBasin
327+ } else {
328+ ForkAction :: DescendDeeper
329+ }
330+ }
331+ }
332+
213333#[ cfg( test) ]
214334mod tests {
215335 use super :: * ;
@@ -271,6 +391,65 @@ mod tests {
271391 assert_eq ! ( entropy_class( 0.99 ) , 3 ) ;
272392 }
273393
394+ #[ test]
395+ fn residue_surprise_floor_ramp_saturation ( ) {
396+ // Below the noise floor → quantization only → zero surprise.
397+ assert ! ( residue_surprise( 0.003 , 0.004 , 6.0 ) < 1e-12 ) ;
398+ assert ! ( residue_surprise( 0.004 , 0.004 , 6.0 ) < 1e-12 ) ;
399+ // Linear ramp: excess / (sigma_k · nf). nf=0.004, span=0.024.
400+ assert ! ( ( residue_surprise( 0.016 , 0.004 , 6.0 ) - 0.5 ) . abs( ) < 1e-9 ) ;
401+ // Saturates at 1.
402+ assert ! ( ( residue_surprise( 0.028 , 0.004 , 6.0 ) - 1.0 ) . abs( ) < 1e-9 ) ;
403+ assert ! ( ( residue_surprise( 10.0 , 0.004 , 6.0 ) - 1.0 ) . abs( ) < 1e-9 ) ;
404+ // Monotone in residue magnitude.
405+ assert ! ( residue_surprise( 0.01 , 0.004 , 6.0 ) < residue_surprise( 0.02 , 0.004 , 6.0 ) ) ;
406+ }
407+
408+ #[ test]
409+ fn fork_ladder_four_actions ( ) {
410+ let ( nf, k) = ( 0.004 , 6.0 ) ;
411+ // Boredom: tiny residue, ample skill → commit.
412+ assert_eq ! ( fork_decision( 0.002 , 0.6 , 3 , 3 , nf, k) , ForkAction :: Commit ) ;
413+ // Anxiety at leaf: strong orthogonal leaf residue, low skill → fork domain.
414+ assert_eq ! ( fork_decision( 1.0 , 0.1 , 3 , 3 , nf, k) , ForkAction :: ForkDomain ) ;
415+ // Anxiety but a coarse tier remains → descend before forking (leaf condition).
416+ assert_eq ! ( fork_decision( 1.0 , 0.1 , 1 , 3 , nf, k) , ForkAction :: DescendDeeper ) ;
417+ // Flow at leaf (challenge ≈ skill): resolvable in-domain → sibling basin.
418+ // challenge=0.5 (residue 0.016), skill=0.5 → delta 0 → Flow.
419+ assert_eq ! ( fork_decision( 0.016 , 0.5 , 3 , 3 , nf, k) , ForkAction :: ForkBasin ) ;
420+ // Flow with depth remaining → descend.
421+ assert_eq ! ( fork_decision( 0.016 , 0.5 , 0 , 3 , nf, k) , ForkAction :: DescendDeeper ) ;
422+ }
423+
424+ #[ test]
425+ fn fork_domain_only_when_residue_is_strong_at_leaf ( ) {
426+ let ( nf, k) = ( 0.004 , 6.0 ) ;
427+ // The operator's invariant: ForkDomain requires BOTH (a) leaf depth AND
428+ // (b) a residue strong enough that challenge ≫ skill. Weaken either and the
429+ // domain must NOT fork.
430+ assert_eq ! ( fork_decision( 1.0 , 0.1 , 3 , 3 , nf, k) , ForkAction :: ForkDomain ) ;
431+ // (a) not at leaf → descend, never fork.
432+ assert_ne ! ( fork_decision( 1.0 , 0.1 , 2 , 3 , nf, k) , ForkAction :: ForkDomain ) ;
433+ // (b) skill matches the (saturated) challenge → Flow, not Anxiety → basin.
434+ assert_ne ! ( fork_decision( 1.0 , 0.9 , 3 , 3 , nf, k) , ForkAction :: ForkDomain ) ;
435+ }
436+
437+ #[ test]
438+ fn fork_anxiety_aligns_with_high_surprise_quadrant ( ) {
439+ // Cross-check the unification: an Anxiety/ForkDomain residue is high-challenge,
440+ // so on the entropy×energy plane (challenge as entropy) it lands in the
441+ // high-entropy half (Staunen at low energy / Confusion at high energy) — never
442+ // Boredom/Wisdom. This ties ForkAction to the shipped Quadrant.
443+ let challenge = residue_surprise ( 1.0 , 0.004 , 6.0 ) ; // saturated → 1.0
444+ assert ! ( challenge >= 0.5 ) ;
445+ assert_eq ! ( Quadrant :: classify( challenge, 0.1 ) , Quadrant :: Staunen ) ;
446+ assert_eq ! ( Quadrant :: classify( challenge, 0.9 ) , Quadrant :: Confusion ) ;
447+ // And a Boredom/Commit residue is low-challenge → low-entropy half.
448+ let calm = residue_surprise ( 0.002 , 0.004 , 6.0 ) ; // 0.0
449+ assert_eq ! ( Quadrant :: classify( calm, 0.1 ) , Quadrant :: Boredom ) ;
450+ assert_eq ! ( Quadrant :: classify( calm, 0.9 ) , Quadrant :: Wisdom ) ;
451+ }
452+
274453 /// Validation: entropy is a reliability proxy. Build a population of edges
275454 /// whose belief `(f, c)` is estimated from `n_obs` Bernoulli(p) draws, then
276455 /// measure each edge's empirical prediction accuracy against fresh draws.
0 commit comments