Skip to content

Commit fbdf6f5

Browse files
committed
feat(entropy_ladder): HHTL fork ladder — orthogonal leaf residue → Friston domain fork
Reduction-to-practice of a standing idea: if the orthogonal (helix/CAM-PQ) leaf residue is strong enough, free energy forks into another domain (an HHTL shift = new exploration). Unifies four vocabularies that are one 2-axis structure: entropy×energy Quadrant ≡ Csikszentmihalyi FlowState(challenge,skill) ≡ Friston model-vs-surprise ≡ Staunen↔Wisdom. - residue_surprise(mag, noise_floor, sigma_k): maps the orthogonal residue magnitude (the prediction error the in-domain centroid codebook fails to explain) to the challenge axis [0,1]. Below the noise floor = quantization (≈0); linear ramp to saturation over sigma_k·noise_floor. Threshold provenance per I-NOISE-FLOOR-JIRAK (Berry-Esseen wrong under CAM-PQ weak dependence); the ramp is an honest proxy pending Jirak calibration, not a claimed bound. - ForkAction {Commit, DescendDeeper, ForkBasin, ForkDomain} + fork_decision(...): bands the challenge−skill delta exactly like the shipped mul::flow_state_from (Anxiety >0.2, Flow |d|<0.15, Boredom <-0.2), then HHTL depth decides descend-vs-fork. ForkDomain (mint a new classid domain = the Friston model-switch) requires BOTH leaf depth AND challenge≫skill — the operator's "strong enough AT THE LEAF" invariant. Pure functions + one enum; no new struct/layer; composes with the existing Quadrant. 5 new lib tests + 2 doctests, including a cross-check that an Anxiety/ForkDomain residue lands in the high-entropy (Staunen/Confusion) half of the shipped Quadrant. clippy clean. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 2d708ef commit fbdf6f5

1 file changed

Lines changed: 179 additions & 0 deletions

File tree

src/hpc/entropy_ladder.rs

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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)]
214334
mod 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

Comments
 (0)