@@ -210,6 +210,134 @@ 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+ // Guard the span AFTER the multiply: `MIN_POSITIVE * MIN_POSITIVE` underflows to
254+ // 0.0, so a degenerate (zero/negative) calibration would make `excess / span`
255+ // evaluate `0.0 / 0.0 = NaN` and defeat the `fork_decision` bands. Floor the
256+ // product itself so the result is always finite and in `[0, 1]`.
257+ let span = ( sigma_k. max ( f64:: MIN_POSITIVE ) * nf) . max ( f64:: MIN_POSITIVE ) ;
258+ let excess = ( residue_mag - nf) . max ( 0.0 ) ;
259+ ( excess / span) . clamp ( 0.0 , 1.0 )
260+ }
261+
262+ /// What to do with a leaf residue, governed by the Csikszentmihalyi flow channel
263+ /// (challenge = residue surprise, skill = in-domain codebook capacity). Mirrors
264+ /// `lance_graph_contract::mul::FlowState`, projected onto the HHTL cascade.
265+ #[ derive( Debug , Clone , Copy , PartialEq , Eq , Hash ) ]
266+ #[ repr( u8 ) ]
267+ pub enum ForkAction {
268+ /// **Boredom** — challenge ≪ skill: the domain over-explains. Commit (and the
269+ /// caller may coarsen the address).
270+ Commit = 0 ,
271+ /// **Flow/Transition with depth remaining** — challenge ≈ skill: the codebook can
272+ /// still reach the point; descend HEEL→HIP→TWIG→LEAF one tier.
273+ DescendDeeper = 1 ,
274+ /// **Flow/Transition at leaf depth** — resolvable, but no finer tier remains: a
275+ /// sibling basin within the SAME classid codebook.
276+ ForkBasin = 2 ,
277+ /// **Anxiety at leaf depth** — challenge ≫ skill and irreducible in-domain: mint a
278+ /// NEW classid domain (HHTL shift = new exploration). Friston: switch the
279+ /// generative model when free energy can't be minimized within it.
280+ ForkDomain = 3 ,
281+ }
282+
283+ /// The fork decision. `challenge = residue_surprise(residue_mag, noise_floor,
284+ /// sigma_k)`; `in_domain_skill ∈ [0,1]` is the codebook's remaining resolving
285+ /// capacity. The challenge↔skill delta `δ` is banded on the shipped
286+ /// `flow_state_from` boundaries — **Anxiety `δ>0.2`** and **Boredom `δ<-0.2`** —
287+ /// then HHTL depth decides descend-vs-fork. The matched middle (`-0.2 ≤ δ ≤ 0.2`,
288+ /// which `flow_state_from` further splits into Flow `|δ|<0.15` and Transition) is
289+ /// uniformly "resolvable in-domain" here, so this ladder collapses Flow+Transition
290+ /// into one branch — the Flow/Transition distinction does not change the action.
291+ ///
292+ /// * **Boredom** (`δ<-0.2`) → [`ForkAction::Commit`].
293+ /// * **Anxiety** (`δ>0.2`), `depth < max` → [`ForkAction::DescendDeeper`] (apply skill
294+ /// at a finer tier before declaring the residue irreducible — fork is a *leaf*
295+ /// condition).
296+ /// * **Anxiety** (`δ>0.2`), `depth == max` → [`ForkAction::ForkDomain`] (the orthogonal
297+ /// leaf residue is strong enough: free energy forks into a new domain).
298+ /// * **Flow/Transition** (`|δ|≤0.2`) → [`ForkAction::DescendDeeper`] while
299+ /// `depth < max`, else [`ForkAction::ForkBasin`].
300+ ///
301+ /// # Examples
302+ /// ```
303+ /// use ndarray::hpc::entropy_ladder::{fork_decision, ForkAction};
304+ /// // Huge residue, low in-domain skill, already at the leaf → fork to a new domain.
305+ /// let a = fork_decision(1.0, 0.1, 3, 3, 0.004, 6.0);
306+ /// assert_eq!(a, ForkAction::ForkDomain);
307+ /// // Same surprise but a coarse tier remains → descend first, don't fork yet.
308+ /// assert_eq!(fork_decision(1.0, 0.1, 1, 3, 0.004, 6.0), ForkAction::DescendDeeper);
309+ /// // Tiny residue → the domain over-explains → commit.
310+ /// assert_eq!(fork_decision(0.002, 0.5, 3, 3, 0.004, 6.0), ForkAction::Commit);
311+ /// ```
312+ pub fn fork_decision (
313+ residue_mag : f64 , in_domain_skill : f64 , depth : u8 , max_depth : u8 , noise_floor : f64 , sigma_k : f64 ,
314+ ) -> ForkAction {
315+ let challenge = residue_surprise ( residue_mag, noise_floor, sigma_k) ;
316+ let skill = in_domain_skill. clamp ( 0.0 , 1.0 ) ;
317+ let delta = challenge - skill;
318+ let at_leaf = depth >= max_depth;
319+ if delta < -0.2 {
320+ // Boredom — skill over-covers the challenge.
321+ ForkAction :: Commit
322+ } else if delta > 0.2 {
323+ // Anxiety — challenge exceeds skill. Fork only once the residue is a *leaf*
324+ // residue; otherwise a finer tier may still resolve it.
325+ if at_leaf {
326+ ForkAction :: ForkDomain
327+ } else {
328+ ForkAction :: DescendDeeper
329+ }
330+ } else {
331+ // Flow / Transition — matched. Resolve in-domain: descend if we can, else a
332+ // sibling basin in the same codebook.
333+ if at_leaf {
334+ ForkAction :: ForkBasin
335+ } else {
336+ ForkAction :: DescendDeeper
337+ }
338+ }
339+ }
340+
213341#[ cfg( test) ]
214342mod tests {
215343 use super :: * ;
@@ -271,6 +399,80 @@ mod tests {
271399 assert_eq ! ( entropy_class( 0.99 ) , 3 ) ;
272400 }
273401
402+ #[ test]
403+ fn residue_surprise_floor_ramp_saturation ( ) {
404+ // Below the noise floor → quantization only → zero surprise.
405+ assert ! ( residue_surprise( 0.003 , 0.004 , 6.0 ) < 1e-12 ) ;
406+ assert ! ( residue_surprise( 0.004 , 0.004 , 6.0 ) < 1e-12 ) ;
407+ // Linear ramp: excess / (sigma_k · nf). nf=0.004, span=0.024.
408+ assert ! ( ( residue_surprise( 0.016 , 0.004 , 6.0 ) - 0.5 ) . abs( ) < 1e-9 ) ;
409+ // Saturates at 1.
410+ assert ! ( ( residue_surprise( 0.028 , 0.004 , 6.0 ) - 1.0 ) . abs( ) < 1e-9 ) ;
411+ assert ! ( ( residue_surprise( 10.0 , 0.004 , 6.0 ) - 1.0 ) . abs( ) < 1e-9 ) ;
412+ // Monotone in residue magnitude.
413+ assert ! ( residue_surprise( 0.01 , 0.004 , 6.0 ) < residue_surprise( 0.02 , 0.004 , 6.0 ) ) ;
414+ }
415+
416+ #[ test]
417+ fn residue_surprise_degenerate_calibration_is_finite ( ) {
418+ // Codex #221: zero/negative calibration must not yield NaN (MIN_POSITIVE²
419+ // underflows to 0.0 → the span guard must run AFTER the multiply). Every
420+ // result stays finite and in [0, 1], so fork_decision's bands stay valid.
421+ for & ( mag, nf, k) in & [ ( 0.0 , 0.0 , 0.0 ) , ( 0.5 , 0.0 , 0.0 ) , ( 0.0 , -1.0 , -1.0 ) , ( 1.0 , 0.0 , 6.0 ) , ( 0.0 , 0.004 , 0.0 ) ]
422+ {
423+ let s = residue_surprise ( mag, nf, k) ;
424+ assert ! ( s. is_finite( ) && ( 0.0 ..=1.0 ) . contains( & s) , "got {s} for ({mag},{nf},{k})" ) ;
425+ }
426+ // And the downstream decision still lands in a real band (not the NaN path).
427+ let a = fork_decision ( 0.0 , 0.5 , 3 , 3 , 0.0 , 0.0 ) ;
428+ assert ! ( matches!( a, ForkAction :: Commit | ForkAction :: ForkBasin | ForkAction :: DescendDeeper ) ) ;
429+ }
430+
431+ #[ test]
432+ fn fork_ladder_four_actions ( ) {
433+ let ( nf, k) = ( 0.004 , 6.0 ) ;
434+ // Boredom: tiny residue, ample skill → commit.
435+ assert_eq ! ( fork_decision( 0.002 , 0.6 , 3 , 3 , nf, k) , ForkAction :: Commit ) ;
436+ // Anxiety at leaf: strong orthogonal leaf residue, low skill → fork domain.
437+ assert_eq ! ( fork_decision( 1.0 , 0.1 , 3 , 3 , nf, k) , ForkAction :: ForkDomain ) ;
438+ // Anxiety but a coarse tier remains → descend before forking (leaf condition).
439+ assert_eq ! ( fork_decision( 1.0 , 0.1 , 1 , 3 , nf, k) , ForkAction :: DescendDeeper ) ;
440+ // Flow at leaf (challenge ≈ skill): resolvable in-domain → sibling basin.
441+ // challenge=0.5 (residue 0.016), skill=0.5 → delta 0 → Flow.
442+ assert_eq ! ( fork_decision( 0.016 , 0.5 , 3 , 3 , nf, k) , ForkAction :: ForkBasin ) ;
443+ // Flow with depth remaining → descend.
444+ assert_eq ! ( fork_decision( 0.016 , 0.5 , 0 , 3 , nf, k) , ForkAction :: DescendDeeper ) ;
445+ }
446+
447+ #[ test]
448+ fn fork_domain_only_when_residue_is_strong_at_leaf ( ) {
449+ let ( nf, k) = ( 0.004 , 6.0 ) ;
450+ // The operator's invariant: ForkDomain requires BOTH (a) leaf depth AND
451+ // (b) a residue strong enough that challenge ≫ skill. Weaken either and the
452+ // domain must NOT fork.
453+ assert_eq ! ( fork_decision( 1.0 , 0.1 , 3 , 3 , nf, k) , ForkAction :: ForkDomain ) ;
454+ // (a) not at leaf → descend, never fork.
455+ assert_ne ! ( fork_decision( 1.0 , 0.1 , 2 , 3 , nf, k) , ForkAction :: ForkDomain ) ;
456+ // (b) skill matches the (saturated) challenge → Flow, not Anxiety → basin.
457+ assert_ne ! ( fork_decision( 1.0 , 0.9 , 3 , 3 , nf, k) , ForkAction :: ForkDomain ) ;
458+ }
459+
460+ #[ test]
461+ fn fork_anxiety_aligns_with_high_surprise_quadrant ( ) {
462+ // Cross-check the unification: an Anxiety/ForkDomain residue is high-challenge,
463+ // so on the entropy×energy plane (challenge as entropy) it lands in the
464+ // high-entropy half (Staunen at low energy / Confusion at high energy) — never
465+ // Boredom/Wisdom. This ties ForkAction to the shipped Quadrant.
466+ let challenge = residue_surprise ( 1.0 , 0.004 , 6.0 ) ; // saturated → 1.0
467+ assert ! ( challenge >= 0.5 ) ;
468+ assert_eq ! ( Quadrant :: classify( challenge, 0.1 ) , Quadrant :: Staunen ) ;
469+ assert_eq ! ( Quadrant :: classify( challenge, 0.9 ) , Quadrant :: Confusion ) ;
470+ // And a Boredom/Commit residue is low-challenge → low-entropy half.
471+ let calm = residue_surprise ( 0.002 , 0.004 , 6.0 ) ; // 0.0
472+ assert_eq ! ( Quadrant :: classify( calm, 0.1 ) , Quadrant :: Boredom ) ;
473+ assert_eq ! ( Quadrant :: classify( calm, 0.9 ) , Quadrant :: Wisdom ) ;
474+ }
475+
274476 /// Validation: entropy is a reliability proxy. Build a population of edges
275477 /// whose belief `(f, c)` is estimated from `n_obs` Bernoulli(p) draws, then
276478 /// measure each edge's empirical prediction accuracy against fresh draws.
0 commit comments