Skip to content

Commit fd585e6

Browse files
committed
fix(pr-x12): three codex findings on PR #170
P1 — merge() loses payload info (line 393): Prior implementation compared only `mode` + `basin_idx`, so four Delta children with different δ values (or Merge with different merge_dir, Escape with different escape_idx) collapsed into ONE leaf — the diff was silently dropped. Now compares full LeafCu via PartialEq, surfacing any divergence as ChildrenDiverge. New tests: - merge_diverging_delta_payloads_rejects - merge_diverging_merge_dirs_rejects - merge_diverging_escape_idx_rejects - merge_identical_delta_payloads_collapses (symmetric positive) P2 — Ctu::new_skip accepts tier > 4 (line 306): Constructor docstring promised `1..=4` but only checked `tier != 0`. Added explicit `assert!((1..=4).contains(&tier), …)` so values 5..=255 panic at construction instead of polluting downstream state. New tests: - new_skip_rejects_tier_5 #[should_panic] - new_skip_rejects_tier_0 #[should_panic] P2 — split() trusts caller-supplied depth (line 333): Prior signature took `current_depth: u8` from the caller; a wrong value let descendants split past MAX_SPLIT_DEPTH and hit the arena overflow `assert!` panic instead of returning MaxSplitDepthReached. Removed the `current_depth` parameter; `split()` now computes the depth from the tree itself via a new public `Ctu::depth_of(target)` helper (BFS from root, O(N) where N ≤ 85). Targets unreachable from ROOT (orphans left behind by merge) return the new `SplitError::NodeNotReachable` variant. API change: pub fn split(node, depth) → pub fn split(node) All in-tree callers (5 inline tests + the recursive_split helper in arena_capacity_bound_85) updated. `current_depth` was never wired into a public consumer, so this is a pre-1.0 breaking change with zero out-of-tree fallout. New tests: - split_unreachable_node_rejects (orphan after merge → NodeNotReachable) - split_at_max_depth_rejects (rewritten: navigate to depth-3 leaf via 3 real splits, then verify the 4th fails with the computed depth in the error) Verified: cargo test -p ndarray --features codec --lib hpc::codec 20 passed cargo fmt --check clean cargo clippy -p ndarray --features codec -- -D warnings clean cargo clippy --features approx,serde,rayon -- -D warnings clean Resolves PR #170 review threads r3272585024, r3272585033, r3272585037.
1 parent df963f8 commit fd585e6

1 file changed

Lines changed: 177 additions & 33 deletions

File tree

src/hpc/codec/ctu.rs

Lines changed: 177 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)