Skip to content

Hardening: dense meet operand fix, a stacked-borrows UB fix, and two guards#45

Open
MesTTo wants to merge 5 commits into
Adam-Vandervorst:masterfrom
MesTTo:pr/pathmap-hardening
Open

Hardening: dense meet operand fix, a stacked-borrows UB fix, and two guards#45
MesTTo wants to merge 5 commits into
Adam-Vandervorst:masterfrom
MesTTo:pr/pathmap-hardening

Conversation

@MesTTo

@MesTTo MesTTo commented Jun 23, 2026

Copy link
Copy Markdown

Summary

Five small, independent correctness commits, each proven against a stock
master reproduction before the fix:

  • Fix composite meet/join operand selection in DenseByteNode — the
    differential added in this commit fails 2/4 on stock master
    (combine_algebraic_results was copying self unconditionally in a case
    where the counter side held the correct operand); with the fix it's 4/4.
  • Fix stacked-borrows UB in zipper_merge_n_mono via safe
    get_disjoint_mut
    — miri (stacked borrows) aborts on stock master at
    the raw-pointer loop in with_k; replacing it with the stable (≥1.86)
    get_disjoint_mut is miri-clean.
  • Reject malformed serialized offsets — stock master panics on
    deliberately corrupted offset bytes during deserialization; this converts
    the failure into a proper Err.
  • Guard line-list node layout — a compile-time size assert pins
    LineListNode<[u8;1024]> at 64 bytes under slim_ptrs on x86_64. Only that
    configuration is asserted: building not(slim_ptrs) currently fails (26
    errors, one root cause — TrieNodeODRc's Arc-backed variant has no
    empty-sentinel representation, so new_empty/is_empty/make_unique/==
    don't exist there). That's the open question the TaggedNodeRefMut::EmptyNode
    GOAT note already flags; happy to take a pass at it once there's a
    preferred sentinel design.
  • Re-enable the tree_serde_2 test — verified passing on this tree; the
    #[ignore] was stale.

Test plan

  • cargo test --release (663/0/0, up from the 660/0/1 baseline)
  • cargo test --release --test pathmap_algebra_differential (4/4)
  • cargo miri test --release over the touched zipper_algebra tests (120/120)
  • cargo test --release --doc (7/0)

@MesTTo MesTTo marked this pull request as ready for review June 23, 2026 10:23
@adamv-symbolica

Copy link
Copy Markdown

The changes to LineListNode seem to hint at a bug-fix, can you give an explanation of the bug? The test for it is quite impenetrable to me.

@MesTTo

MesTTo commented Jun 23, 2026

Copy link
Copy Markdown
Author

The LineListNode change is not fixing a newly observed runtime failure by itself... It is guarding an existing layout invariant... LineListNode is written around a fixed in-memory layout. The key byte capacity is chosen so the node stays at 64 bytes with slim_ptrs on x86_64, and 48 bytes without slim_ptrs because of the DynBox header. If that size changes, the node can stop matching the assumptions behind KEY_BYTES_CNT and the cache-line/list-node packing. The old test only checked this at runtime when that test was selected; the new const assertion makes layout drift a compile-time failure. the test is not meant to prove a semantic transformation; it is just the serialised form of the malformed-offset case

Comment thread src/dense_byte_node.rs
Comment on lines 701 to +1881
@@ -1850,7 +1854,12 @@ impl<V: Clone + Send + Sync, A: Allocator, Cf: CoFree<V=V, A=A>, OtherCf: CoFree
if new_mask > 0 {
AlgebraicResult::Identity(new_mask)
} else {
AlgebraicResult::Element(Self::new(None, self.val().cloned()))
let val = if val_mask & SELF_IDENT > 0 {
self.val().cloned()
} else {
other.val().cloned()
};
AlgebraicResult::Element(Self::new(None, val))
}
},
(AlgebraicResult::Identity(rec_mask), AlgebraicResult::None) => {
@@ -1864,7 +1873,12 @@ impl<V: Clone + Send + Sync, A: Allocator, Cf: CoFree<V=V, A=A>, OtherCf: CoFree
if new_mask > 0 {
AlgebraicResult::Identity(new_mask)
} else {
AlgebraicResult::Element(Self::new(self.rec().cloned(), None))
let rec = if rec_mask & SELF_IDENT > 0 {
self.rec().cloned()
} else {
other.rec().cloned()
};
AlgebraicResult::Element(Self::new(rec, None))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry I meant DenseNode -- i.e. the motivation behind these.

@MesTTo

MesTTo commented Jun 24, 2026

Copy link
Copy Markdown
Author

ah right, denseNode not linelist... that's the actual fix, sorry.

it's in combine_algebraic_results. when you combine two cofrees and one half (rec or val) comes back as Identity(mask), the mask says which operand the result equals: SELF_IDENT for self, COUNTER_IDENT for other. once the combined cofree isn't identical to either operand (new_mask == 0) it builds a fresh Element, and for the surviving half the old code just cloned self.val() / self.rec().

that's only right when the surviving half is identity-to-self. in a commutative op the half can be identity-to-other instead (SELF_IDENT cleared, COUNTER_IDENT set), and then cloning self's half drops the wrong operand's value/child into the node. so the fix reads the bit: mask & SELF_IDENT > 0 takes self, else takes other. same for the rec arm.

the giveaway is the (rec_el, val_el) arm right below... it already picks the operand by index (0 => self, 1 => other), the two identity-collapse arms just weren't doing the same. this makes them consistent.

the line 698 hunk in node_get_payloads is the same accounting on the read side: a 1-byte value request landing on a cofree that still has a rec link wasn't flagging that the rec half is there.

no change for the self-identity case, it only diverges when the surviving half matched other.

MesTTo and others added 5 commits July 5, 2026 03:59
combine_algebraic_results copied the left operand unconditionally in the two
mixed value-only / rec-only branches; node_get_payloads treated an exact
value-only request as exhaustive even when the CoFree held an unrequested onward
link. Both could assemble a result from the wrong child or let a left-only chain
survive an intersection. Select the operand by the SELF_IDENT mask, and mark the
value-only enumeration non-exhaustive when an onward link is present.

A 512-seed dual-distributivity differential vs a BTreeSet oracle (+ the seed-44
meet-associativity regression) failed 83 seeds before, 0 after. MORK's
atom_intersection and DNF meet hit this on prefix-nested unit-keyed data.

(cherry picked from commit 2619d9fba5b51e77c5a37d715f3b9faedac834ba)
with_k built K raw pointers with xs.as_mut_ptr().add(idx) inside a loop; each
as_mut_ptr() re-borrowed the slice and invalidated the pointers from earlier
iterations, so the later &mut *p retag hit a tag no longer in the borrow stack
(Miri UB, test_almost_identical_paths_n). Replace with slice::get_disjoint_mut
over the distinct bitmask indices: safe, no unsafe, same result, negligible cost
for small K. Removes an unsafe block; Miri-clean.

Verified: cargo +nightly miri test on this exact tree aborts at the claimed
line (with_k's ptrs.map(|p| &mut *p)) before this fix, and is clean (120/120
zipper_algebra tests) after it.
deserialize_file indexed into the deserialized-records vec by offsets read
straight from the serialized file, so a malformed forward or out-of-range
offset panicked (index out of bounds) instead of returning an error. Bound
every offset lookup with .get().ok_or_else(...) so malformed input is
rejected, not a panic.

Verified: on this exact tree, a crafted forward-offset fixture
("P x...1x...2" with an empty records vec) panics at serialization.rs:820
without this fix; with it, deserialize_file returns Err("...offset out of
bounds") and both new regression tests pass.
Pins LineListNode<[u8;1024]> to 64 bytes under slim_ptrs on x86_64 (excluding
miri, which pads for its own bookkeeping) with a compile-time size assert, so a
layout change that grows the node is caught at build time instead of surfacing
as a memory regression later.

Only the slim_ptrs arm is asserted, for a concrete reason: the not(slim_ptrs)
TrieNodeODRc has no empty-sentinel representation, so the shared call sites'
new_empty/is_empty/make_unique/== do not exist there and that configuration
does not compile (26 errors, all one root cause). Giving the Arc-backed variant
a sentinel is the open design flagged in the TaggedNodeRefMut EmptyNode note
(the allocator prevents a free empty sentinel); once that lands, the second
size assert follows.
The test passes on this tree (verified via -- --ignored before removing the
attribute), so the ignore is stale: the round-trip it covers works, and keeping
it ignored just forfeits the regression coverage. If tree serialization still
has known gaps elsewhere, this pins the part that does work.
@MesTTo MesTTo force-pushed the pr/pathmap-hardening branch from a3c01de to 064b320 Compare July 4, 2026 18:19
@MesTTo MesTTo changed the title Harden PathMap algebra and serialized layouts Hardening: dense meet operand fix, a stacked-borrows UB fix, and two guards Jul 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants