@@ -301,9 +301,12 @@ impl Ctu {
301301 ///
302302 /// # Panics
303303 ///
304- /// Panics if `tier == 0`. The cascade tiers are 1-indexed (L1..L4).
304+ /// Panics if `tier` is outside `1..=4`. The cascade tiers are 1-indexed
305+ /// L1..L4; values ≥ 5 are rejected because they would let invalid tier
306+ /// metadata enter the codec state (P2 codex review on PR #170 line 306).
305307 pub fn new_skip ( block_row : u16 , block_col : u16 , tier : u8 , basin_idx : u16 ) -> Self {
306- let tier = NonZeroU16 :: new ( tier as u16 ) . expect ( "Ctu::new_skip: tier must be 1..=4" ) ;
308+ assert ! ( ( 1 ..=4 ) . contains( & tier) , "Ctu::new_skip: tier must be in 1..=4 (got {tier})" ) ;
309+ let tier = NonZeroU16 :: new ( tier as u16 ) . expect ( "post-assert tier > 0" ) ;
307310 let mut arena = CtuArena :: new ( ) ;
308311 arena. push ( CtuPartition :: Leaf ( LeafCu :: skip ( basin_idx) ) ) ;
309312 Self {
@@ -315,18 +318,51 @@ impl Ctu {
315318 }
316319 }
317320
321+ /// Walk the arena from root to find the depth at which `target` lives.
322+ ///
323+ /// Returns `None` if `target` is not reachable from `NodeIdx::ROOT`
324+ /// (e.g. orphaned by a prior `merge` — see the GC note on `merge`).
325+ /// Used by `split` to enforce `MAX_SPLIT_DEPTH` against the actual
326+ /// tree state rather than a caller-supplied claim (P2 codex review
327+ /// on PR #170 line 333).
328+ ///
329+ /// Complexity: O(N) where N ≤ [`MAX_QUAD_TREE_NODES`] = 85.
330+ pub fn depth_of ( & self , target : NodeIdx ) -> Option < u8 > {
331+ if target == NodeIdx :: ROOT {
332+ return Some ( 0 ) ;
333+ }
334+ let mut frontier: Vec < ( NodeIdx , u8 ) > = vec ! [ ( NodeIdx :: ROOT , 0 ) ] ;
335+ while let Some ( ( node, d) ) = frontier. pop ( ) {
336+ if let CtuPartition :: Split ( children) = self . arena . get ( node) {
337+ for & c in children {
338+ if c == target {
339+ return Some ( d + 1 ) ;
340+ }
341+ frontier. push ( ( c, d + 1 ) ) ;
342+ }
343+ }
344+ }
345+ None
346+ }
347+
318348 /// Split the leaf at `node_idx` into four child leaves, all inheriting
319349 /// the parent's basin (and `Skip` mode by default).
320350 ///
321351 /// Returns the new `[NW, NE, SW, SE]` child indices on success.
322352 ///
323353 /// # Depth limit
324354 ///
325- /// Splits past [`MAX_SPLIT_DEPTH`] return [`MaxSplitDepthReached`].
326- /// The current_depth argument is the caller's claim about how deep
327- /// `node_idx` already is; pass `0` when splitting the root and
328- /// increment for each recursion level.
329- pub fn split ( & mut self , node_idx : NodeIdx , current_depth : u8 ) -> Result < [ NodeIdx ; 4 ] , SplitError > {
355+ /// The depth of `node_idx` is computed from the tree itself via
356+ /// [`Self::depth_of`], not from caller input — passing a stale claim
357+ /// can no longer trigger the arena overflow `assert!` (P2 codex
358+ /// review on PR #170 line 333). Splits past [`MAX_SPLIT_DEPTH`]
359+ /// return [`SplitError::MaxSplitDepthReached`]; targets unreachable
360+ /// from `NodeIdx::ROOT` (e.g. orphaned post-merge) return
361+ /// [`SplitError::NodeNotReachable`].
362+ pub fn split ( & mut self , node_idx : NodeIdx ) -> Result < [ NodeIdx ; 4 ] , SplitError > {
363+ let current_depth = self
364+ . depth_of ( node_idx)
365+ . ok_or ( SplitError :: NodeNotReachable ) ?;
330366 if current_depth >= MAX_SPLIT_DEPTH {
331367 return Err ( SplitError :: MaxSplitDepthReached ( MaxSplitDepthReached {
332368 depth : current_depth,
@@ -365,12 +401,19 @@ impl Ctu {
365401 }
366402
367403 /// Merge a 4-way split back into a single leaf, IF all four children
368- /// are themselves leaves with identical `mode` and `basin_idx`.
404+ /// are themselves leaves with **identical `LeafCu` payloads** (same
405+ /// `mode`, `basin_idx`, **and** per-mode payload — `delta`,
406+ /// `merge_dir`, or `escape_idx` as appropriate).
369407 ///
370- /// The merged leaf takes the basin and (typically `Skip`) mode of the
371- /// children. Heterogeneous children (different modes / basins) are
372- /// rejected with [`MergeError::ChildrenDiverge`]; non-leaf children
373- /// are rejected with [`MergeError::ChildNotLeaf`].
408+ /// The merged leaf takes the (now-unique) `LeafCu` of the children.
409+ /// Heterogeneous children (any field differs) are rejected with
410+ /// [`MergeError::ChildrenDiverge`]; non-leaf children are rejected
411+ /// with [`MergeError::ChildNotLeaf`].
412+ ///
413+ /// The full-payload equality is the P1 codex fix on PR #170 line
414+ /// 393: the prior implementation compared only `mode` + `basin_idx`,
415+ /// which silently dropped per-mode payload when the four children
416+ /// carried different `delta` / `merge_dir` / `escape_idx` values.
374417 ///
375418 /// Note: this method does NOT compact the arena — the child nodes
376419 /// remain allocated and orphaned. A full GC pass (out of scope for
@@ -389,7 +432,11 @@ impl Ctu {
389432 match merged {
390433 None => merged = Some ( leaf) ,
391434 Some ( prev) => {
392- if prev. mode != leaf. mode || prev. basin_idx != leaf. basin_idx {
435+ // Full LeafCu equality (mode + basin_idx + per-mode
436+ // payload). `PartialEq` on LeafCu compares every
437+ // field; any divergence in delta / merge_dir /
438+ // escape_idx therefore surfaces as ChildrenDiverge.
439+ if prev != leaf {
393440 return Err ( MergeError :: ChildrenDiverge ) ;
394441 }
395442 }
@@ -414,6 +461,10 @@ pub enum SplitError {
414461 /// The target node was already a 4-way split, not a leaf. Splits
415462 /// only operate on leaves; recurse into the children to split deeper.
416463 NotALeaf ,
464+ /// The target node is not reachable from `NodeIdx::ROOT` (e.g. an
465+ /// orphaned node left behind by a prior `merge`). Returned by
466+ /// [`Ctu::split`] when [`Ctu::depth_of`] can't locate the target.
467+ NodeNotReachable ,
417468 /// The split would push the quad-tree past [`MAX_SPLIT_DEPTH`].
418469 MaxSplitDepthReached ( MaxSplitDepthReached ) ,
419470}
@@ -474,7 +525,7 @@ mod tests {
474525 #[ test]
475526 fn split_root_yields_four_children ( ) {
476527 let mut ctu = Ctu :: new_skip ( 0 , 0 , 1 , 7 ) ;
477- let children = ctu. split ( NodeIdx :: ROOT , 0 ) . expect ( "split should succeed" ) ;
528+ let children = ctu. split ( NodeIdx :: ROOT ) . expect ( "split should succeed" ) ;
478529 assert_eq ! ( children. len( ) , 4 ) ;
479530 assert_eq ! ( ctu. split_depth, 1 ) ;
480531 assert_eq ! ( ctu. arena. len( ) , 5 ) ; // root + 4 children
@@ -496,32 +547,52 @@ mod tests {
496547
497548 #[ test]
498549 fn split_at_max_depth_rejects ( ) {
550+ // Navigate the tree down to a depth-3 leaf, then try to split it.
551+ // Depth is computed from the tree (P2 codex fix), so callers can't
552+ // bypass the cap with a stale depth claim.
499553 let mut ctu = Ctu :: new_skip ( 0 , 0 , 1 , 0 ) ;
500- let err = ctu. split ( NodeIdx :: ROOT , MAX_SPLIT_DEPTH ) . unwrap_err ( ) ;
554+ let l1 = ctu. split ( NodeIdx :: ROOT ) . unwrap ( ) [ 0 ] ;
555+ let l2 = ctu. split ( l1) . unwrap ( ) [ 0 ] ;
556+ let l3 = ctu. split ( l2) . unwrap ( ) [ 0 ] ;
557+ assert_eq ! ( ctu. depth_of( l3) , Some ( 3 ) ) ;
558+ let err = ctu. split ( l3) . unwrap_err ( ) ;
501559 match err {
502560 SplitError :: MaxSplitDepthReached ( info) => {
503561 assert_eq ! ( info. depth, MAX_SPLIT_DEPTH ) ;
504562 assert_eq ! ( info. cap, MAX_SPLIT_DEPTH ) ;
505563 }
506564 _ => panic ! ( "expected MaxSplitDepthReached, got {err:?}" ) ,
507565 }
508- // Arena untouched
509- assert_eq ! ( ctu. arena. len( ) , 1 ) ;
510- assert_eq ! ( ctu. split_depth, 0 ) ;
566+ assert_eq ! ( ctu. split_depth, MAX_SPLIT_DEPTH ) ;
567+ }
568+
569+ #[ test]
570+ fn split_unreachable_node_rejects ( ) {
571+ // A node still inside the arena but not reachable from root (e.g.
572+ // orphaned post-merge) yields NodeNotReachable instead of panicking
573+ // on the arena overflow. P2 codex fix.
574+ let mut ctu = Ctu :: new_skip ( 0 , 0 , 1 , 0 ) ;
575+ let children = ctu. split ( NodeIdx :: ROOT ) . unwrap ( ) ;
576+ // Merge collapses root back to a leaf — the four child entries
577+ // remain in the arena but are no longer linked from root.
578+ ctu. merge ( NodeIdx :: ROOT ) . unwrap ( ) ;
579+ let orphan = children[ 0 ] ;
580+ let err = ctu. split ( orphan) . unwrap_err ( ) ;
581+ assert_eq ! ( err, SplitError :: NodeNotReachable ) ;
511582 }
512583
513584 #[ test]
514585 fn split_already_split_node_rejects ( ) {
515586 let mut ctu = Ctu :: new_skip ( 0 , 0 , 1 , 0 ) ;
516- ctu. split ( NodeIdx :: ROOT , 0 ) . unwrap ( ) ;
517- let err = ctu. split ( NodeIdx :: ROOT , 0 ) . unwrap_err ( ) ;
587+ ctu. split ( NodeIdx :: ROOT ) . unwrap ( ) ;
588+ let err = ctu. split ( NodeIdx :: ROOT ) . unwrap_err ( ) ;
518589 assert_eq ! ( err, SplitError :: NotALeaf ) ;
519590 }
520591
521592 #[ test]
522593 fn merge_homogeneous_children_collapses ( ) {
523594 let mut ctu = Ctu :: new_skip ( 0 , 0 , 1 , 13 ) ;
524- ctu. split ( NodeIdx :: ROOT , 0 ) . unwrap ( ) ;
595+ ctu. split ( NodeIdx :: ROOT ) . unwrap ( ) ;
525596 ctu. merge ( NodeIdx :: ROOT ) . expect ( "homogeneous merge ok" ) ;
526597 match ctu. arena . get ( NodeIdx :: ROOT ) {
527598 CtuPartition :: Leaf ( leaf) => {
@@ -535,7 +606,7 @@ mod tests {
535606 #[ test]
536607 fn merge_heterogeneous_children_rejects ( ) {
537608 let mut ctu = Ctu :: new_skip ( 0 , 0 , 1 , 5 ) ;
538- let children = ctu. split ( NodeIdx :: ROOT , 0 ) . unwrap ( ) ;
609+ let children = ctu. split ( NodeIdx :: ROOT ) . unwrap ( ) ;
539610 // Swap one child to a different mode
540611 * ctu. arena . get_mut ( children[ 2 ] ) = CtuPartition :: Leaf ( LeafCu :: delta ( 5 , 7 ) ) ;
541612 let err = ctu. merge ( NodeIdx :: ROOT ) . unwrap_err ( ) ;
@@ -545,9 +616,9 @@ mod tests {
545616 #[ test]
546617 fn merge_split_child_rejects ( ) {
547618 let mut ctu = Ctu :: new_skip ( 0 , 0 , 1 , 0 ) ;
548- let children = ctu. split ( NodeIdx :: ROOT , 0 ) . unwrap ( ) ;
619+ let children = ctu. split ( NodeIdx :: ROOT ) . unwrap ( ) ;
549620 // Re-split one of the children
550- ctu. split ( children[ 0 ] , 1 ) . unwrap ( ) ;
621+ ctu. split ( children[ 0 ] ) . unwrap ( ) ;
551622 let err = ctu. merge ( NodeIdx :: ROOT ) . unwrap_err ( ) ;
552623 assert_eq ! ( err, MergeError :: ChildNotLeaf ) ;
553624 }
@@ -585,25 +656,98 @@ mod tests {
585656 #[ test]
586657 fn arena_capacity_bound_85 ( ) {
587658 // Fully split to depth 3 and verify total node count = 85.
659+ // depth 0 → 1 node; +4 = 5 (d1); +16 = 21 (d2); +64 = 85 (d3).
660+ // depth_of is consulted internally per split, so this exercises
661+ // both the depth-cap check and the arena cap together.
588662 let mut ctu = Ctu :: new_skip ( 0 , 0 , 1 , 0 ) ;
589- // depth 0 → 1 node
590- // depth 1 → +4 = 5
591- // depth 2 → +16 = 21
592- // depth 3 → +64 = 85
593- fn recursive_split ( ctu : & mut Ctu , node : NodeIdx , depth : u8 ) {
594- if depth >= MAX_SPLIT_DEPTH {
663+ fn recursive_split ( ctu : & mut Ctu , node : NodeIdx ) {
664+ let d = ctu. depth_of ( node) . expect ( "reachable" ) ;
665+ if d >= MAX_SPLIT_DEPTH {
595666 return ;
596667 }
597- let children = ctu. split ( node, depth ) . expect ( "split ok" ) ;
668+ let children = ctu. split ( node) . expect ( "split ok" ) ;
598669 for & c in & children {
599- recursive_split ( ctu, c, depth + 1 ) ;
670+ recursive_split ( ctu, c) ;
600671 }
601672 }
602- recursive_split ( & mut ctu, NodeIdx :: ROOT , 0 ) ;
673+ recursive_split ( & mut ctu, NodeIdx :: ROOT ) ;
603674 assert_eq ! ( ctu. arena. len( ) , MAX_QUAD_TREE_NODES ) ;
604675 assert_eq ! ( ctu. split_depth, MAX_SPLIT_DEPTH ) ;
605676 }
606677
678+ #[ test]
679+ #[ should_panic( expected = "tier must be in 1..=4" ) ]
680+ fn new_skip_rejects_tier_5 ( ) {
681+ // P2 codex fix: tier > 4 panics at construction instead of
682+ // silently entering the codec state. Pinning the message text
683+ // ensures the assert remains explicit.
684+ let _ = Ctu :: new_skip ( 0 , 0 , 5 , 0 ) ;
685+ }
686+
687+ #[ test]
688+ #[ should_panic( expected = "tier must be in 1..=4" ) ]
689+ fn new_skip_rejects_tier_0 ( ) {
690+ let _ = Ctu :: new_skip ( 0 , 0 , 0 , 0 ) ;
691+ }
692+
693+ #[ test]
694+ fn merge_diverging_delta_payloads_rejects ( ) {
695+ // P1 codex fix: prior implementation compared only mode +
696+ // basin_idx. Now Delta children with different δ values are
697+ // rejected; the previously-silent payload loss is gone.
698+ let mut ctu = Ctu :: new_skip ( 0 , 0 , 1 , 9 ) ;
699+ let children = ctu. split ( NodeIdx :: ROOT ) . unwrap ( ) ;
700+ // Same mode (Delta) and basin_idx, but different δ values.
701+ * ctu. arena . get_mut ( children[ 0 ] ) = CtuPartition :: Leaf ( LeafCu :: delta ( 9 , 0x11 ) ) ;
702+ * ctu. arena . get_mut ( children[ 1 ] ) = CtuPartition :: Leaf ( LeafCu :: delta ( 9 , 0x22 ) ) ;
703+ * ctu. arena . get_mut ( children[ 2 ] ) = CtuPartition :: Leaf ( LeafCu :: delta ( 9 , 0x33 ) ) ;
704+ * ctu. arena . get_mut ( children[ 3 ] ) = CtuPartition :: Leaf ( LeafCu :: delta ( 9 , 0x44 ) ) ;
705+ let err = ctu. merge ( NodeIdx :: ROOT ) . unwrap_err ( ) ;
706+ assert_eq ! ( err, MergeError :: ChildrenDiverge ) ;
707+ }
708+
709+ #[ test]
710+ fn merge_diverging_merge_dirs_rejects ( ) {
711+ let mut ctu = Ctu :: new_skip ( 0 , 0 , 1 , 5 ) ;
712+ let children = ctu. split ( NodeIdx :: ROOT ) . unwrap ( ) ;
713+ * ctu. arena . get_mut ( children[ 0 ] ) = CtuPartition :: Leaf ( LeafCu :: merge ( 5 , MergeDir :: North ) ) ;
714+ * ctu. arena . get_mut ( children[ 1 ] ) = CtuPartition :: Leaf ( LeafCu :: merge ( 5 , MergeDir :: East ) ) ;
715+ * ctu. arena . get_mut ( children[ 2 ] ) = CtuPartition :: Leaf ( LeafCu :: merge ( 5 , MergeDir :: West ) ) ;
716+ * ctu. arena . get_mut ( children[ 3 ] ) = CtuPartition :: Leaf ( LeafCu :: merge ( 5 , MergeDir :: South ) ) ;
717+ assert_eq ! ( ctu. merge( NodeIdx :: ROOT ) . unwrap_err( ) , MergeError :: ChildrenDiverge ) ;
718+ }
719+
720+ #[ test]
721+ fn merge_diverging_escape_idx_rejects ( ) {
722+ let mut ctu = Ctu :: new_skip ( 0 , 0 , 1 , 1 ) ;
723+ let children = ctu. split ( NodeIdx :: ROOT ) . unwrap ( ) ;
724+ * ctu. arena . get_mut ( children[ 0 ] ) = CtuPartition :: Leaf ( LeafCu :: escape ( 1 , 100 ) ) ;
725+ * ctu. arena . get_mut ( children[ 1 ] ) = CtuPartition :: Leaf ( LeafCu :: escape ( 1 , 101 ) ) ;
726+ * ctu. arena . get_mut ( children[ 2 ] ) = CtuPartition :: Leaf ( LeafCu :: escape ( 1 , 102 ) ) ;
727+ * ctu. arena . get_mut ( children[ 3 ] ) = CtuPartition :: Leaf ( LeafCu :: escape ( 1 , 103 ) ) ;
728+ assert_eq ! ( ctu. merge( NodeIdx :: ROOT ) . unwrap_err( ) , MergeError :: ChildrenDiverge ) ;
729+ }
730+
731+ #[ test]
732+ fn merge_identical_delta_payloads_collapses ( ) {
733+ // Symmetric to the divergence tests above: identical Delta
734+ // children DO merge, preserving the unified payload.
735+ let mut ctu = Ctu :: new_skip ( 0 , 0 , 1 , 3 ) ;
736+ let children = ctu. split ( NodeIdx :: ROOT ) . unwrap ( ) ;
737+ for & c in & children {
738+ * ctu. arena . get_mut ( c) = CtuPartition :: Leaf ( LeafCu :: delta ( 3 , 0x77 ) ) ;
739+ }
740+ ctu. merge ( NodeIdx :: ROOT ) . expect ( "identical-delta merge ok" ) ;
741+ match ctu. arena . get ( NodeIdx :: ROOT ) {
742+ CtuPartition :: Leaf ( leaf) => {
743+ assert_eq ! ( leaf. mode, CellMode :: Delta ) ;
744+ assert_eq ! ( leaf. basin_idx, 3 ) ;
745+ assert_eq ! ( leaf. delta, Some ( 0x77 ) ) ;
746+ }
747+ CtuPartition :: Split ( _) => panic ! ( "should be merged" ) ,
748+ }
749+ }
750+
607751 #[ test]
608752 fn cell_mode_discriminants_match_wire_codes ( ) {
609753 assert_eq ! ( CellMode :: Skip as u8 , 0b00 ) ;
0 commit comments