diff --git a/crates/ixon/src/env.rs b/crates/ixon/src/env.rs index eb670ee6..566a41b9 100644 --- a/crates/ixon/src/env.rs +++ b/crates/ixon/src/env.rs @@ -2,7 +2,7 @@ use rustc_hash::FxHashSet; use std::collections::VecDeque; -use std::sync::Arc; +use std::sync::{Arc, OnceLock}; use ix_common::address::Address; use ix_common::env::{Name, ReducibilityHints}; @@ -14,7 +14,10 @@ use super::constant::{ }; use super::lazy::LazyConstant; use super::map::IxonMap; -use super::metadata::{ConstantMeta, ConstantMetaInfo}; +use super::metadata::{ConstantMeta, ConstantMetaInfo, NameReverseIndex}; +use super::serialize::{ + decode_window_meta, decode_window_orig_header, decode_window_orig_meta, +}; /// Metadata representation inside [`Named`]: structured, or demoted to /// its self-contained serialized form ([`ConstantMeta::put_raw`]), @@ -25,6 +28,10 @@ use super::metadata::{ConstantMeta, ConstantMetaInfo}; enum MetaRepr { Structured(Arc), Bytes(Arc<[u8]>), + /// Lazily-decoded §5 window (see [`LazyMetaWindow`]). Covers BOTH + /// metadata slots: a `Window` entry keeps `Named::original == None` + /// and routes original reads through the window too. + Window(Arc), } impl MetaRepr { @@ -41,7 +48,8 @@ impl MetaRepr { } /// Materialize. Cheap `Arc` clone for `Structured`; a fresh decode per - /// call for `Bytes` (nothing is cached — mirroring `LazyConstant`). + /// call for `Bytes` and `Window` (nothing is cached — mirroring + /// `LazyConstant`). fn decode(&self) -> Arc { match self { MetaRepr::Structured(m) => m.clone(), @@ -52,10 +60,111 @@ impl MetaRepr { .expect("Named meta bytes produced by put_raw failed to decode"), ) }, + MetaRepr::Window(w) => Arc::new(w.decode_meta()), } } } +/// One §5 named-entry window kept verbatim from the file: the +/// `meta_len`-framed bytes holding the entry's metadata plus optional +/// aux_gen original, in the *indexed* name encoding, together with the +/// file's §4 reverse index to resolve name references. Built by +/// [`Env::get_demoted_named`]'s lazy load path; decoded on demand +/// through the grammar helpers in `serialize` (the same ones +/// `get_named_indexed` parses with). +/// +/// Nothing is cached but `split` below — the structured metadata for a +/// whole env costs a large multiple of its encoding, which is exactly +/// what this repr exists to avoid holding. +pub(crate) struct LazyMetaWindow { + /// Shared §5 arena: one contiguous copy of every entry's window. + arena: Arc<[u8]>, + off: usize, + len: usize, + rev: Arc, + /// Filled by the first meta decode: the meta part's encoded length + /// and the original's address if the entry carries one. Lets + /// `has_original`/`original` skip re-parsing the meta part — the + /// muts-plan `meta()` sweep warms it for every entry before the + /// decompile passes read originals. + split: OnceLock<(usize, Option
)>, +} + +impl std::fmt::Debug for LazyMetaWindow { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("LazyMetaWindow") + .field("off", &self.off) + .field("len", &self.len) + .finish_non_exhaustive() + } +} + +impl LazyMetaWindow { + pub(crate) fn new( + arena: Arc<[u8]>, + off: usize, + len: usize, + rev: Arc, + ) -> Self { + LazyMetaWindow { arena, off, len, rev, split: OnceLock::new() } + } + + fn window(&self) -> &[u8] { + &self.arena[self.off..self.off + self.len] + } + + /// Decode the meta part, recording the split as a side effect. + /// + /// Corrupt window bytes are a panic, not an `Err`: `Named::meta` is + /// infallible by contract, and the lazy load validated only the + /// window framing — interior §5 corruption (which the eager loaders + /// would have rejected at load) surfaces here on first decode. + fn decode_meta(&self) -> ConstantMeta { + let window = self.window(); + let (meta, meta_len) = decode_window_meta(window, &self.rev) + .unwrap_or_else(|e| panic!("corrupt §5 metadata window: {e}")); + self.fill_split(window, meta_len); + meta + } + + fn fill_split(&self, window: &[u8], meta_len: usize) { + let _ = self.split.get_or_init(|| { + let orig = decode_window_orig_header(window, meta_len) + .unwrap_or_else(|e| panic!("corrupt §5 metadata window: {e}")); + (meta_len, orig.map(|(addr, _)| addr)) + }); + } + + fn split(&self) -> &(usize, Option
) { + if let Some(s) = self.split.get() { + return s; + } + let window = self.window(); + let (_, meta_len) = decode_window_meta(window, &self.rev) + .unwrap_or_else(|e| panic!("corrupt §5 metadata window: {e}")); + self.fill_split(window, meta_len); + self.split.get().expect("split just filled") + } + + fn has_original(&self) -> bool { + self.split().1.is_some() + } + + fn decode_original(&self) -> Option<(Address, ConstantMeta)> { + let s = self.split(); + let addr = s.1.clone()?; + let window = self.window(); + // Re-derive the original's offset from its header (one tag byte + + // address — cheap) rather than caching a second offset. + let (header_addr, orig_off) = decode_window_orig_header(window, s.0) + .unwrap_or_else(|e| panic!("corrupt §5 metadata window: {e}"))?; + debug_assert_eq!(header_addr, addr); + let (meta, _end) = decode_window_orig_meta(window, orig_off, &self.rev) + .unwrap_or_else(|e| panic!("corrupt §5 metadata window: {e}")); + Some((addr, meta)) + } +} + /// A named constant with metadata. #[derive(Clone, Debug)] pub struct Named { @@ -117,27 +226,59 @@ impl Named { /// The aux_gen original form, if recorded (see field docs). pub fn original(&self) -> Option<(Address, Arc)> { + if let MetaRepr::Window(w) = &self.meta { + debug_assert!(self.original.is_none()); + return w.decode_original().map(|(a, m)| (a, Arc::new(m))); + } self.original.as_ref().map(|(a, m)| (a.clone(), m.decode())) } pub fn has_original(&self) -> bool { - self.original.is_some() + match &self.meta { + MetaRepr::Window(w) => w.has_original(), + _ => self.original.is_some(), + } } /// Record the aux_gen original form. Stored in the same repr as /// `self.meta`, so demoted entries stay fully demoted. pub fn set_original(&mut self, addr: Address, meta: ConstantMeta) { + self.materialize_window(); let repr = match &self.meta { MetaRepr::Structured(_) => MetaRepr::structured(meta), - MetaRepr::Bytes(_) => MetaRepr::demoted(&meta), + MetaRepr::Bytes(_) | MetaRepr::Window(_) => MetaRepr::demoted(&meta), }; self.original = Some((addr, repr)); } pub fn clear_original(&mut self) { + self.materialize_window(); self.original = None; } + /// Collapse a lazy `Window` repr into the explicit slot form (in the + /// demoted repr — windows only exist on demoted loads) so the + /// mutating original-slot APIs above stay total. No-op otherwise. + fn materialize_window(&mut self) { + if let MetaRepr::Window(w) = &self.meta { + let meta = w.decode_meta(); + let orig = w.decode_original(); + self.meta = MetaRepr::demoted(&meta); + self.original = orig.map(|(a, m)| (a, MetaRepr::demoted(&m))); + } + } + + /// Lazy §5 construction (see [`LazyMetaWindow`]): the metadata stays + /// as the file's indexed window bytes; the `original` slot is left + /// empty and reads route through the window. + pub(crate) fn from_indexed_window( + addr: Address, + hints: Option, + window: Arc, + ) -> Self { + Named { addr, hints, meta: MetaRepr::Window(window), original: None } + } + /// Convert both metadata slots to the serialized-bytes repr. pub fn demote(&mut self) { if let MetaRepr::Structured(m) = &self.meta { @@ -250,7 +391,7 @@ pub struct LazyIndex { /// §4 positional index → name-component address, retained so §5 /// entries can be re-parsed standalone (`get_named_indexed`) without /// re-walking §4. ~32 B per name. - pub name_reverse_index: crate::metadata::NameReverseIndex, + pub name_reverse_index: NameReverseIndex, } /// The Ixon environment. diff --git a/crates/ixon/src/serialize.rs b/crates/ixon/src/serialize.rs index 16f201d7..335eefce 100644 --- a/crates/ixon/src/serialize.rs +++ b/crates/ixon/src/serialize.rs @@ -1233,7 +1233,7 @@ fn get_name_component( // Named serialization // ============================================================================ -use super::env::{AuxLayout, Named}; +use super::env::{AuxLayout, LazyMetaWindow, Named}; use super::metadata::{ ConstantMeta, NameGet, NameIndex, NamePut, NameReverseIndex, }; @@ -1372,32 +1372,92 @@ pub fn get_named_indexed( buf.len() )); } - let before = buf.len(); - let meta = ConstantMeta::get_with(buf, NameGet::Indexed(rev))?; + let (window, rest) = buf.split_at(meta_len); + *buf = rest; + // A parse failure inside the framed window (EOF before the grammar + // is done) means the header length disagrees with the content — + // surface it as the framing diagnostic, keeping the inner error. + let frame_err = |e: String| { + format!( + "get_named_indexed: metadata blob length mismatch (header says \ + {meta_len}; parse failed inside the window: {e}){PRE_NORMAL_LEVELS}" + ) + }; + let (meta, m_len) = decode_window_meta(window, rev).map_err(frame_err)?; let mut named = Named::new(addr, meta); named.set_hints(hints); - match get_u8(buf)? { - 0 => {}, - 1 => { - let orig_addr = get_address(buf)?; - let orig_meta = ConstantMeta::get_with(buf, NameGet::Indexed(rev))?; + let end = match decode_window_orig_header(window, m_len).map_err(frame_err)? { + None => m_len + 1, + Some((orig_addr, orig_off)) => { + let (orig_meta, end) = + decode_window_orig_meta(window, orig_off, rev).map_err(frame_err)?; named.set_original(orig_addr, orig_meta); + end }, - x => return Err(format!("Named.original: invalid tag {x}")), - } + }; // The header's length must frame exactly the bytes the parsers // consumed — a mismatch means a desynced, old-format, or tampered // entry. - let consumed = before - buf.len(); - if consumed != meta_len { + if end != window.len() { return Err(format!( "get_named_indexed: metadata blob length mismatch (header says \ - {meta_len}, parsed {consumed}){PRE_NORMAL_LEVELS}" + {meta_len}, parsed {end}){PRE_NORMAL_LEVELS}" )); } Ok(named) } +/// Decode the metadata part of a §5 window (the `meta_len`-framed blob +/// holding meta + optional aux_gen original). Returns the meta and its +/// encoded length within the window. The window grammar lives in these +/// three helpers — `get_named_indexed` and the lazy +/// [`crate::env::LazyMetaWindow`] both parse through them. +pub(crate) fn decode_window_meta( + window: &[u8], + rev: &NameReverseIndex, +) -> Result<(ConstantMeta, usize), String> { + let mut slice: &[u8] = window; + let meta = ConstantMeta::get_with(&mut slice, NameGet::Indexed(rev))?; + Ok((meta, window.len() - slice.len())) +} + +/// Read the original-form header right after the meta part: the +/// presence tag and, when present, the original's address. Returns the +/// address and the offset of the original's metadata in the window. +pub(crate) fn decode_window_orig_header( + window: &[u8], + meta_len: usize, +) -> Result, String> { + let mut slice: &[u8] = window + .get(meta_len..) + .ok_or_else(|| "Named.original: window truncated".to_string())?; + let before = slice.len(); + match get_u8(&mut slice)? { + 0 => Ok(None), + 1 => { + let addr = get_address(&mut slice)?; + Ok(Some((addr, meta_len + (before - slice.len())))) + }, + x => Err(format!("Named.original: invalid tag {x}")), + } +} + +/// Decode the original's metadata at `off` in the window (an offset +/// from [`decode_window_orig_header`]). Returns the meta and the +/// offset one past its end, for framing checks. +pub(crate) fn decode_window_orig_meta( + window: &[u8], + off: usize, + rev: &NameReverseIndex, +) -> Result<(ConstantMeta, usize), String> { + let mut slice: &[u8] = window + .get(off..) + .ok_or_else(|| "Named.original: window truncated".to_string())?; + let before = slice.len(); + let meta = ConstantMeta::get_with(&mut slice, NameGet::Indexed(rev))?; + Ok((meta, off + (before - slice.len()))) +} + /// Streaming cursor over an env's §5 named entries: parse one `Named` /// at a time against this file's own §4 reverse index, hand it out, /// drop it. Entries arrive in the file's canonical ascending @@ -2106,17 +2166,30 @@ impl Env { Self::get_inner(buf, false) } - /// [`Self::get`], storing each `Named`'s metadata in the demoted - /// (serialized-bytes) repr as it is parsed. The structured metadata - /// for a whole env costs a large multiple of its encoding, so + /// [`Self::get`], keeping each `Named`'s §5 metadata window as + /// verbatim bytes (one shared arena) decoded on demand instead of + /// parsing it here — see [`LazyMetaWindow`]. The structured metadata + /// for a whole env costs a large multiple of its encoding (and + /// eagerly parsing it dominated the decompile pre-phase), so /// consumers that read metadata a bounded number of times per entry - /// (decompile) use this to keep the structured residency to one - /// entry at a time instead of the whole named section. + /// (decompile, `import_ixe`) use this to keep structured residency + /// to one entry at a time and pay decode cost only for entries they + /// touch. Trade-off: interior §5 corruption that [`Self::get`] + /// rejects at load surfaces here as a panic on first decode (the + /// window *framing* is still validated at load). pub fn get_demoted_named(buf: &mut &[u8]) -> Result { Self::get_inner(buf, true) } fn get_inner(buf: &mut &[u8], demote_named: bool) -> Result { + // Per-section read attribution behind the same knobs as the write + // side (`IX_VERBOSE` / `IX_COMPILE_DBG` — see `put_file`): this + // parse is the decompile pre-phase, whose section split is + // otherwise invisible. + let verbose = std::env::var("IX_VERBOSE").is_ok() + || std::env::var("IX_COMPILE_DBG").is_ok(); + let mut sec_start = std::time::Instant::now(); + let mut sec_bytes = buf.len(); // Header: tag + stored merkle root (verified at the end against // the recomputed root; empty const sets store `zero_address()`) + // bundle fields. @@ -2131,6 +2204,16 @@ impl Env { for (addr, bytes) in read_blob_section(buf, "Env::get")? { env.blobs.insert(addr, bytes); } + if verbose { + eprintln!( + "[Env::get] section 1/6 blobs: {} entries ({} bytes) in {:.2}s", + env.blobs.len(), + sec_bytes - buf.len(), + sec_start.elapsed().as_secs_f64(), + ); + sec_start = std::time::Instant::now(); + sec_bytes = buf.len(); + } // Section 2: Consts (lazy: read length prefix, slice bytes, defer parse) let num_consts = get_u64(buf)?; @@ -2138,6 +2221,8 @@ impl Env { // and §5 named entries key their constants by index into it. let mut consts_order: Vec
= Vec::with_capacity(capped_capacity(num_consts, buf)); + let mut const_windows: Vec<(Address, &[u8])> = + Vec::with_capacity(capped_capacity(num_consts, buf)); for i in 0..num_consts { let addr = get_address(buf)?; let len = Tag0::get(buf)?.size as usize; @@ -2150,20 +2235,6 @@ impl Env { } let (bytes, rest) = buf.split_at(len); *buf = rest; - // Per-entry integrity: hash the bytes and compare with the - // stored address. The env-level merkle root over `consts.keys()` - // catches missing/extra entries but not byte-tampering of a - // constant whose key is intact; without this check, corruption - // would slip past `Env::get` and surface much later as a - // misleading parse error inside `LazyConstant::get`. - let computed = Address::hash(bytes); - if computed != addr { - return Err(format!( - "Env::get: const at idx {i} bytes hash to {} but stored under {}", - computed.hex(), - addr.hex() - )); - } // §2 order is load-bearing for §3/§5 index resolution: writers // emit ascending addresses; a permuted section would silently // re-key every hint, so reject it outright. @@ -2176,10 +2247,31 @@ impl Env { )); } consts_order.push(addr.clone()); + const_windows.push((addr, bytes)); + } + // Per-entry integrity: hash the bytes and compare with the stored + // address. The env-level merkle root over `consts.keys()` catches + // missing/extra entries but not byte-tampering of a constant whose + // key is intact; without this check, corruption would slip past + // `Env::get` and surface much later as a misleading parse error + // inside `LazyConstant::get`. Runs after the framing pass so the + // hashing goes wide (serial on the guest target). + verify_const_hashes(&const_windows)?; + for (addr, bytes) in const_windows { env .consts .insert(addr, crate::lazy::LazyConstant::from_bytes(bytes.into())); } + if verbose { + eprintln!( + "[Env::get] section 2/6 consts: {num_consts} entries ({} bytes, \ + hash-verified) in {:.2}s", + sec_bytes - buf.len(), + sec_start.elapsed().as_secs_f64(), + ); + sec_start = std::time::Instant::now(); + sec_bytes = buf.len(); + } // `main` must reference a constant actually present in the file. if let Some(m) = &env.main @@ -2189,18 +2281,36 @@ impl Env { } // Section 3: anon_hints (§2-index keyed; resolved via consts_order) - for (addr, hints) in read_hints_section( + let hints_entries = read_hints_section( buf, consts_order.len(), |i| consts_order[i].clone(), "Env::get", - )? { + )?; + let num_hints = hints_entries.len(); + for (addr, hints) in hints_entries { env.anon_hints.insert(addr, hints); } + if verbose { + eprintln!( + "[Env::get] section 3/6 hints: {num_hints} entries ({} bytes) in \ + {:.2}s", + sec_bytes - buf.len(), + sec_start.elapsed().as_secs_f64(), + ); + sec_start = std::time::Instant::now(); + sec_bytes = buf.len(); + } // Section 4: Names (build lookup table and reverse index for metadata) let num_names = get_u64(buf)?; - let mut names_lookup: FxHashMap = FxHashMap::default(); + // Pre-size the lookup: growth rehashes of a multi-million-entry + // map are a measurable slice of the parse. + let mut names_lookup: FxHashMap = + FxHashMap::with_capacity_and_hasher( + capped_capacity(num_names, buf) + 1, + Default::default(), + ); let mut name_reverse_index: NameReverseIndex = Vec::with_capacity(num_names as usize + 1); // Anonymous name is serialized first (index 0) — read it from the stream @@ -2216,31 +2326,116 @@ impl Env { names_lookup.insert(addr.clone(), name.clone()); env.names.insert(addr, name); } + if verbose { + eprintln!( + "[Env::get] section 4/6 names: {num_names} entries ({} bytes) in \ + {:.2}s", + sec_bytes - buf.len(), + sec_start.elapsed().as_secs_f64(), + ); + sec_start = std::time::Instant::now(); + sec_bytes = buf.len(); + } - // Section 5: Named (use indexed deserialization for metadata) + // Section 5: Named (indexed metadata). The structured load parses + // each entry eagerly; the demoted load keeps every entry's + // `meta_len` window verbatim in one shared arena and decodes on + // demand (`LazyMetaWindow`) — eagerly parsing all §5 metadata just + // to re-demote it was the decompile pre-phase's dominant cost. let num_named = get_u64(buf)?; - for _ in 0..num_named { - let name_idx = get_u64(buf)? as usize; - let name_addr = - name_reverse_index.get(name_idx).cloned().ok_or_else(|| { - format!( - "Env::get: §5 name index {name_idx} out of range ({} \ - names){PRE_COMPACT_KEYS}", - name_reverse_index.len() - ) + let name_reverse_index: Arc = + Arc::new(name_reverse_index); + if demote_named { + let mut arena: Vec = Vec::new(); + let mut pending: Vec<( + Name, + Address, + Option, + usize, + usize, + )> = Vec::with_capacity(capped_capacity(num_named, buf)); + for _ in 0..num_named { + let name_idx = get_u64(buf)? as usize; + let name_addr = + name_reverse_index.get(name_idx).cloned().ok_or_else(|| { + format!( + "Env::get: §5 name index {name_idx} out of range ({} \ + names){PRE_COMPACT_KEYS}", + name_reverse_index.len() + ) + })?; + // Entry header (outside the framed window), mirroring + // `get_named_indexed`; the window itself is copied unparsed — + // interior validation happens on first decode. + let const_idx = get_u64(buf)? as usize; + let addr = ConstGet::Addrs(&consts_order) + .addr(const_idx, "get_named_indexed")?; + let hints = unfuse_opt_hint(get_u64(buf)?) + .map_err(|e| format!("get_named_indexed: {e}"))?; + let meta_len = get_u64(buf)? as usize; + if buf.len() < meta_len { + return Err(format!( + "get_named_indexed: metadata blob needs {meta_len} bytes, \ + have {}{PRE_COMPACT_KEYS}", + buf.len() + )); + } + let (window, rest) = buf.split_at(meta_len); + *buf = rest; + let name = names_lookup.get(&name_addr).cloned().ok_or_else(|| { + format!("Env::get: missing name for addr {:?}", name_addr) })?; - let mut named = get_named_indexed( - buf, - &name_reverse_index, - ConstGet::Addrs(&consts_order), - )?; - if demote_named { - named.demote(); + let off = arena.len(); + arena.extend_from_slice(window); + pending.push((name, addr, hints, off, meta_len)); + } + // Freeze the arena once, then hand every entry an offset into it. + let arena: Arc<[u8]> = arena.into(); + for (name, addr, hints, off, len) in pending { + let named = Named::from_indexed_window( + addr, + hints, + Arc::new(LazyMetaWindow::new( + arena.clone(), + off, + len, + name_reverse_index.clone(), + )), + ); + env.named.insert(name, named); + } + } else { + for _ in 0..num_named { + let name_idx = get_u64(buf)? as usize; + let name_addr = + name_reverse_index.get(name_idx).cloned().ok_or_else(|| { + format!( + "Env::get: §5 name index {name_idx} out of range ({} \ + names){PRE_COMPACT_KEYS}", + name_reverse_index.len() + ) + })?; + let named = get_named_indexed( + buf, + &name_reverse_index, + ConstGet::Addrs(&consts_order), + )?; + let name = names_lookup.get(&name_addr).cloned().ok_or_else(|| { + format!("Env::get: missing name for addr {:?}", name_addr) + })?; + env.named.insert(name, named); } - let name = names_lookup.get(&name_addr).cloned().ok_or_else(|| { - format!("Env::get: missing name for addr {:?}", name_addr) - })?; - env.named.insert(name, named); + } + if verbose { + eprintln!( + "[Env::get] section 5/6 named: {num_named} entries ({} bytes) in \ + {:.2}s{}", + sec_bytes - buf.len(), + sec_start.elapsed().as_secs_f64(), + if demote_named { " (demoted at parse)" } else { "" }, + ); + sec_start = std::time::Instant::now(); + sec_bytes = buf.len(); } // Section 6: Comms @@ -2250,14 +2445,38 @@ impl Env { let comm = Comm::get(buf)?; env.comms.insert(addr, comm); } + if verbose { + eprintln!( + "[Env::get] section 6/6 comms: {num_comms} entries ({} bytes) in \ + {:.2}s", + sec_bytes - buf.len(), + sec_start.elapsed().as_secs_f64(), + ); + sec_start = std::time::Instant::now(); + } + let root_start = sec_start; // Verify the stored merkle root matches what we'd compute from // the §2 addresses (already strictly ascending — enforced above, // so `consts_order` is the sorted, duplicate-free key set). Empty // const set → expected = zero_address(). Rejects any tampering - // with the header. + // with the header. §2 enforced strictly ascending addresses, so + // `consts_order` is sorted and duplicate-free — the `_sorted` + // variant skips the internal clone+re-sort and hashes levels in + // parallel (mirroring the write side). + #[cfg(not(target_arch = "riscv64"))] + let computed_root = + merkle_root_canonical_sorted(&consts_order).unwrap_or_else(zero_address); + #[cfg(target_arch = "riscv64")] let computed_root = merkle_root_canonical(&consts_order).unwrap_or_else(zero_address); + if verbose { + eprintln!( + "[Env::get] merkle root over {} consts recomputed in {:.2}s", + consts_order.len(), + root_start.elapsed().as_secs_f64(), + ); + } if computed_root != stored_root { return Err(format!( "Env::get: merkle root mismatch (stored={}, computed={})", @@ -2949,6 +3168,41 @@ impl Env { /// up each Name via `DashMap::get` in the DFS loop). It was 22s slower on /// Mathlib because 4.7M shard-lock acquisitions dominate vs the one-time /// ~150 MB tuple-clone allocation. +/// §2 per-entry integrity for the full readers: every constant's bytes +/// must hash to the address they're stored under. Parallel on host — +/// this is a pure sweep over ~GBs of blake3 input; serial on the guest +/// target. +#[cfg(not(target_arch = "riscv64"))] +fn verify_const_hashes(windows: &[(Address, &[u8])]) -> Result<(), String> { + use rayon::prelude::*; + windows.par_iter().enumerate().try_for_each(|(i, (addr, bytes))| { + let computed = Address::hash(bytes); + if computed != *addr { + return Err(format!( + "Env::get: const at idx {i} bytes hash to {} but stored under {}", + computed.hex(), + addr.hex() + )); + } + Ok(()) + }) +} + +#[cfg(target_arch = "riscv64")] +fn verify_const_hashes(windows: &[(Address, &[u8])]) -> Result<(), String> { + for (i, (addr, bytes)) in windows.iter().enumerate() { + let computed = Address::hash(bytes); + if computed != *addr { + return Err(format!( + "Env::get: const at idx {i} bytes hash to {} but stored under {}", + computed.hex(), + addr.hex() + )); + } + } + Ok(()) +} + fn topological_sort_names( names: &crate::map::IxonMap, ) -> Vec<(Address, Name)> { @@ -2957,7 +3211,8 @@ fn topological_sort_names( use rustc_hash::FxHashSet; let mut result = Vec::with_capacity(names.len() + 1); - let mut visited: FxHashSet
= FxHashSet::default(); + let mut visited: FxHashSet
= + FxHashSet::with_capacity_and_hasher(names.len() + 1, Default::default()); // Include anonymous name first so it gets index 0 in the name index. // Arena nodes frequently reference it as a binder name. @@ -2965,28 +3220,6 @@ fn topological_sort_names( result.push((anon_addr.clone(), Name::anon())); visited.insert(anon_addr); - fn visit( - name: &Name, - visited: &mut FxHashSet
, - result: &mut Vec<(Address, Name)>, - ) { - let addr = Address::from_blake3_hash(*name.get_hash()); - if visited.contains(&addr) { - return; - } - - // Visit parent first - match name.as_data() { - NameData::Anonymous(_) => {}, - NameData::Str(parent, _, _) | NameData::Num(parent, _, _) => { - visit(parent, visited, result); - }, - } - - visited.insert(addr.clone()); - result.push((addr, name.clone())); - } - // Clone-collect entries for direct iteration (avoids 4.7M DashMap lookups // during DFS). Parallel sort uses rayon over address bytes. let mut sorted_entries: Vec<(Address, Name)> = @@ -2995,8 +3228,34 @@ fn topological_sort_names( sorted_entries.par_sort_unstable_by(|a, b| a.0.cmp(&b.0)); #[cfg(target_arch = "riscv64")] sorted_entries.sort_unstable_by(|a, b| a.0.cmp(&b.0)); + + // Parent-first emission, iteratively: walk each entry's ancestor + // chain up to the first already-visited component, then emit the + // collected suffix root-first. The emission order is identical to + // the recursive DFS this replaces — §4 order is wire bytes. As + // before, addresses are recomputed from the name hashes (never + // trusted from the map key). + let mut chain: Vec<(Address, Name)> = Vec::new(); for (_, name) in &sorted_entries { - visit(name, &mut visited, &mut result); + let mut cur_addr = Address::from_blake3_hash(*name.get_hash()); + let mut cur = name; + loop { + if visited.contains(&cur_addr) { + break; + } + chain.push((cur_addr, cur.clone())); + match cur.as_data() { + NameData::Anonymous(_) => break, + NameData::Str(parent, _, _) | NameData::Num(parent, _, _) => { + cur_addr = Address::from_blake3_hash(*parent.get_hash()); + cur = parent; + }, + } + } + for (a, n) in chain.drain(..).rev() { + visited.insert(a.clone()); + result.push((a, n)); + } } result @@ -3034,6 +3293,105 @@ mod tests { } } + #[test] + fn demoted_load_matches_structured_load() { + use crate::metadata::ConstantMetaInfo; + let mut g = Gen::new(24); + for _ in 0..8 { + let env = gen_env(&mut g); + // Plant one entry whose metadata (and original) carry name + // references — the indexed encoding the lazy windows must + // resolve through the §4 reverse index; `gen_env`'s default + // metas exercise only the framing. + let existing: Vec
= + env.named.iter().map(|e| e.value().addr.clone()).collect(); + if let Some(sample_addr) = existing.first() { + let name_addrs: Vec
= + env.names.iter().map(|e| e.key().clone()).collect(); + let rich_name = + Name::str(Name::anon(), "lazy_window_probe".to_string()); + env.names.insert( + Address::from_blake3_hash(*rich_name.get_hash()), + rich_name.clone(), + ); + let mut rich = Named::new( + sample_addr.clone(), + ConstantMeta::new(ConstantMetaInfo::Muts { + all: vec![name_addrs.clone()], + aux_layout: None, + }), + ); + rich.set_original( + sample_addr.clone(), + ConstantMeta::new(ConstantMetaInfo::Muts { + all: vec![name_addrs], + aux_layout: None, + }), + ); + rich.set_hints(Some(ReducibilityHints::Abbrev)); + env.named.insert(rich_name, rich); + } + + let mut buf = Vec::new(); + env.put(&mut buf).unwrap(); + let structured = Env::get(&mut buf.as_slice()).unwrap(); + let lazy = Env::get_demoted_named(&mut buf.as_slice()).unwrap(); + assert_eq!(structured.named.len(), lazy.named.len()); + for entry in structured.named.iter() { + let name = entry.key(); + let s = entry.value(); + let l = lazy + .named + .get(name) + .unwrap_or_else(|| panic!("lazy load dropped {name:?}")); + assert!(!l.is_meta_structured()); + assert_eq!(s.addr, l.addr); + assert_eq!(s.hints(), l.hints()); + assert_eq!(s.has_original(), l.has_original()); + assert_eq!(*s.meta(), *l.meta()); + match (s.original(), l.original()) { + (None, None) => {}, + (Some((sa, sm)), Some((la, lm))) => { + assert_eq!(sa, la); + assert_eq!(*sm, *lm); + }, + (s_orig, l_orig) => panic!( + "original mismatch: structured={:?} lazy={:?}", + s_orig.is_some(), + l_orig.is_some() + ), + } + // The mutating original-slot APIs materialize the window + // rather than losing data behind it. + let mut m = l.clone(); + m.set_original(s.addr.clone(), ConstantMeta::default()); + assert!(m.has_original()); + assert_eq!(*m.meta(), *s.meta()); + let mut c = l.clone(); + c.clear_original(); + assert!(!c.has_original()); + assert_eq!(*c.meta(), *s.meta()); + } + // The strong check: both loads must re-serialize byte-identically + // (every lazy window re-encodes through decode + + // `put_named_indexed`). Compared against the structured reload's + // bytes, not the pre-load `buf`: §4 emits the parent-closure of + // `env.names`, so a synthetic env whose multi-component names + // lack registered parents grows `names` on reload and permutes + // the §4 order — real pipeline envs register every component + // (whole-env byte roundtrips hold in CI), and the two *loads* + // must agree regardless. + let mut buf_s = Vec::new(); + structured.put(&mut buf_s).unwrap(); + let mut buf_l = Vec::new(); + lazy.put(&mut buf_l).unwrap(); + assert_eq!( + buf_s, buf_l, + "lazy and structured loads re-serialize differently" + ); + } + } + #[test] fn test_pack_bools_specific() { assert_eq!(pack_bools([true, false, true]), 0b101);