Skip to main content

alkahest_cas/kernel/
pool_persist.rs

1//! V1-14 — Persistent / incremental `ExprPool`.
2//!
3//! Opt-in serialization of the intern table to disk so long-running notebooks
4//! and repeated simplifications don't rebuild the pool from scratch on every
5//! process start.
6//!
7//! # Status
8//!
9//! This is the v1.0 scope: a **versioned binary file** (not a true mmap-backed
10//! arena).  `checkpoint()` writes the full node vector atomically (temp file +
11//! `rename`); `open_persistent(path)` reads it back if it exists.  Structural
12//! hashes line up by construction — the re-interned `ExprData` values hash
13//! identically, so a subsequent `pool.add([x, y])` lookup hits the rebuilt
14//! index.
15//!
16//! A true mmap/CapnProto arena with `ExprData` stored inline is tracked as a
17//! v2.0 follow-up; it requires a ground-up redesign of `ExprData` to avoid
18//! heap allocations for `Vec<ExprId>` children.
19//!
20//! # File format (v1)
21//!
22//! ```text
23//!   Magic     = "ALKP"             (4 bytes)
24//!   Version   = u32 (**4** = symbol `commutative` flag on `(tag 0)`; **3** = BigO tag 12; **2** = quantifiers 10–11; **1** = original 0–9)
25//!   Flags     = u32                 (reserved; always 0 in v1)
26//!   NodeCount = u64
27//!   Nodes     = NodeCount × TaggedNode
28//! ```
29//!
30//! Each `TaggedNode`:
31//! ```text
32//!   tag : u8
33//!     0 Symbol     -> domain:u8, [commutative:u8 if format≥4], len:u32, name
34//!     1 Integer    -> len:u32, base-10 digits (ASCII, optionally '-' prefix)
35//!     2 Rational   -> numer_len:u32, numer, denom_len:u32, denom
36//!     3 Float      -> prec:u32, len:u32, base-16 mantissa (rug to_string_radix)
37//!     4 Add        -> arity:u32, ExprId.0 (u32) × arity
38//!     5 Mul        -> arity:u32, ExprId.0 × arity
39//!     6 Pow        -> base:u32, exp:u32
40//!     7 Func       -> len:u32, name, arity:u32, ExprId.0 × arity
41//!     8 Piecewise  -> n_branches:u32, (cond:u32, val:u32) × n, default:u32
42//!     9 Predicate  -> kind:u8, arity:u32, ExprId.0 × arity
43//!     10 Forall   -> var:u32, body:u32
44//!     11 Exists   -> var:u32, body:u32
45//!     12 BigO    -> inner:u32
46//! ```
47//!
48//! File version (`Version` u32 field): **1** is the original v1.0 layout (tags 0–9 only).
49//! **2** adds tags 10–11 for quantifiers. **3** adds tag 12 for `BigO`. **4** adds
50//! `commutative: u8` after `domain` on symbol nodes (V3-2).
51//! Current writers emit version **4**; readers accept **1** … **4**.
52//!
53//! All integers are little-endian.
54
55use crate::kernel::domain::Domain;
56use crate::kernel::expr::{BigFloat, BigInt, BigRat, ExprData, ExprId, PredicateKind};
57use crate::kernel::pool::ExprPool;
58use std::fs::{self, File};
59use std::io::{self, BufReader, BufWriter, Read, Write};
60use std::path::{Path, PathBuf};
61
62const MAGIC: &[u8; 4] = b"ALKP";
63/// Oldest readable format (predicate / piecewise only).
64const POOL_FORMAT_V1: u32 = 1;
65/// Adds `Forall` / `Exists` node tags 10–11.
66const POOL_FORMAT_V2: u32 = 2;
67/// Adds `BigO` tag 12 (V2-15 series API).
68const POOL_FORMAT_V3: u32 = 3;
69/// Symbol nodes carry `commutative: u8` after `domain` (V3-2).
70const POOL_FORMAT_V4: u32 = 4;
71/// Adds `RootSum` tag 13 (algebraic-residue logarithmic part).
72const POOL_FORMAT_V5: u32 = 5;
73const POOL_FORMAT_WRITE: u32 = POOL_FORMAT_V5;
74
75// ---------------------------------------------------------------------------
76// Error
77// ---------------------------------------------------------------------------
78
79/// I/O errors from checkpoint and restore operations on `ExprPool`.
80///
81/// Codes: `E-IO-001` … `E-IO-009`.
82#[derive(Debug)]
83pub enum IoError {
84    Io(io::Error),
85    BadMagic,
86    UnsupportedVersion(u32),
87    Truncated,
88    BadUtf8,
89    BadDomain(u8),
90    BadTag(u8),
91    BadPredicateKind(u8),
92    BadNumeric(String),
93}
94
95impl std::fmt::Display for IoError {
96    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97        match self {
98            IoError::Io(e) => write!(f, "io error: {e}"),
99            IoError::BadMagic => write!(f, "not an alkahest pool file (bad magic)"),
100            IoError::UnsupportedVersion(v) => {
101                write!(
102                    f,
103                    "unsupported pool file version {v}; run `alkahest migrate-pool`"
104                )
105            }
106            IoError::Truncated => write!(f, "pool file truncated or incomplete"),
107            IoError::BadUtf8 => write!(f, "pool file contains invalid UTF-8"),
108            IoError::BadDomain(b) => write!(f, "pool file has unknown domain tag {b}"),
109            IoError::BadTag(b) => write!(f, "pool file has unknown node tag {b}"),
110            IoError::BadPredicateKind(b) => {
111                write!(f, "pool file has unknown predicate kind {b}")
112            }
113            IoError::BadNumeric(s) => write!(f, "pool file has invalid numeric: {s}"),
114        }
115    }
116}
117
118impl std::error::Error for IoError {}
119
120impl From<io::Error> for IoError {
121    fn from(e: io::Error) -> Self {
122        IoError::Io(e)
123    }
124}
125
126impl crate::errors::AlkahestError for IoError {
127    fn code(&self) -> &'static str {
128        match self {
129            IoError::Io(_) => "E-IO-001",
130            IoError::BadMagic => "E-IO-002",
131            IoError::UnsupportedVersion(_) => "E-IO-003",
132            IoError::Truncated => "E-IO-004",
133            IoError::BadUtf8 => "E-IO-005",
134            IoError::BadDomain(_) => "E-IO-006",
135            IoError::BadTag(_) => "E-IO-007",
136            IoError::BadPredicateKind(_) => "E-IO-008",
137            IoError::BadNumeric(_) => "E-IO-009",
138        }
139    }
140
141    fn remediation(&self) -> Option<&'static str> {
142        match self {
143            IoError::BadMagic => Some(
144                "file is not an alkahest pool; check the path or regenerate with ExprPool::checkpoint()",
145            ),
146            IoError::UnsupportedVersion(_) => Some(
147                "run the `alkahest migrate-pool` CLI to upgrade the file, or regenerate from source",
148            ),
149            IoError::Truncated => Some(
150                "file was truncated (likely a crash during checkpoint); rerun from source and checkpoint again",
151            ),
152            _ => None,
153        }
154    }
155}
156
157/// Deprecated alias — use [`IoError`] instead.
158#[deprecated(since = "2.0.0", note = "renamed to IoError with E-IO-* codes")]
159pub type PoolPersistError = IoError;
160
161// ---------------------------------------------------------------------------
162// Low-level binary helpers
163// ---------------------------------------------------------------------------
164
165fn write_u8(w: &mut impl Write, v: u8) -> io::Result<()> {
166    w.write_all(&[v])
167}
168fn write_u32(w: &mut impl Write, v: u32) -> io::Result<()> {
169    w.write_all(&v.to_le_bytes())
170}
171fn write_u64(w: &mut impl Write, v: u64) -> io::Result<()> {
172    w.write_all(&v.to_le_bytes())
173}
174
175fn write_str(w: &mut impl Write, s: &str) -> io::Result<()> {
176    let bytes = s.as_bytes();
177    write_u32(w, bytes.len() as u32)?;
178    w.write_all(bytes)
179}
180
181fn write_ids(w: &mut impl Write, ids: &[ExprId]) -> io::Result<()> {
182    write_u32(w, ids.len() as u32)?;
183    for id in ids {
184        write_u32(w, id.0)?;
185    }
186    Ok(())
187}
188
189/// Largest float precision, in bits, a pool file may declare.
190///
191/// `rug::Float::with_val` panics on a precision of 0 or past its own maximum,
192/// and allocates the whole mantissa up front, so a crafted `prec = u32::MAX`
193/// asks for half a gigabyte before a single digit is read.  This is the same
194/// ceiling the Python bindings put on every user-supplied precision.
195const MAX_FILE_FLOAT_PREC: u32 = 1 << 24;
196
197/// The smallest encoding of any node (`tag` + one `u32`), used to bound the
198/// declared node count by the bytes actually present.
199const MIN_NODE_BYTES: u64 = 5;
200
201/// A reader that knows how many bytes are left in the file.
202///
203/// Every length-prefixed field (a string, a child list, a piecewise branch
204/// list) is checked against the bytes remaining *before* anything is
205/// allocated for it.  Without that, a four-byte length of `0xFFFF_FFF0` in a
206/// corrupt or hostile file is a 4 GiB `vec![0; len]` — an allocation failure
207/// that aborts the process instead of returning an error.
208struct Bounded<R> {
209    inner: R,
210    remaining: u64,
211}
212
213impl<R: Read> Bounded<R> {
214    fn new(inner: R, len: u64) -> Self {
215        Bounded {
216            inner,
217            remaining: len,
218        }
219    }
220
221    /// Refuse a field that claims `count` items of `size` bytes each when the
222    /// file does not hold that many bytes.
223    fn ensure(&self, count: u64, size: u64) -> Result<(), IoError> {
224        match count.checked_mul(size) {
225            Some(n) if n <= self.remaining => Ok(()),
226            _ => Err(IoError::Truncated),
227        }
228    }
229
230    fn read_exact(&mut self, buf: &mut [u8]) -> Result<(), IoError> {
231        self.ensure(buf.len() as u64, 1)?;
232        self.inner.read_exact(buf).map_err(|_| IoError::Truncated)?;
233        self.remaining -= buf.len() as u64;
234        Ok(())
235    }
236}
237
238fn read_u8(r: &mut Bounded<impl Read>) -> Result<u8, IoError> {
239    let mut b = [0u8; 1];
240    r.read_exact(&mut b)?;
241    Ok(b[0])
242}
243
244fn read_u32(r: &mut Bounded<impl Read>) -> Result<u32, IoError> {
245    let mut b = [0u8; 4];
246    r.read_exact(&mut b)?;
247    Ok(u32::from_le_bytes(b))
248}
249
250fn read_u64(r: &mut Bounded<impl Read>) -> Result<u64, IoError> {
251    let mut b = [0u8; 8];
252    r.read_exact(&mut b)?;
253    Ok(u64::from_le_bytes(b))
254}
255
256fn read_str(r: &mut Bounded<impl Read>) -> Result<String, IoError> {
257    let len = u64::from(read_u32(r)?);
258    r.ensure(len, 1)?;
259    let mut buf = vec![0u8; len as usize];
260    r.read_exact(&mut buf)?;
261    String::from_utf8(buf).map_err(|_| IoError::BadUtf8)
262}
263
264fn read_ids(r: &mut Bounded<impl Read>) -> Result<Vec<ExprId>, IoError> {
265    let arity = u64::from(read_u32(r)?);
266    r.ensure(arity, 4)?;
267    let mut out = Vec::with_capacity(arity as usize);
268    for _ in 0..arity {
269        out.push(ExprId(read_u32(r)?));
270    }
271    Ok(out)
272}
273
274// ---------------------------------------------------------------------------
275// Domain <-> u8
276// ---------------------------------------------------------------------------
277
278fn domain_to_u8(d: &Domain) -> u8 {
279    match d {
280        Domain::Real => 0,
281        Domain::Complex => 1,
282        Domain::Integer => 2,
283        Domain::Positive => 3,
284        Domain::NonNegative => 4,
285        Domain::NonZero => 5,
286    }
287}
288
289fn u8_to_domain(b: u8) -> Result<Domain, IoError> {
290    match b {
291        0 => Ok(Domain::Real),
292        1 => Ok(Domain::Complex),
293        2 => Ok(Domain::Integer),
294        3 => Ok(Domain::Positive),
295        4 => Ok(Domain::NonNegative),
296        5 => Ok(Domain::NonZero),
297        b => Err(IoError::BadDomain(b)),
298    }
299}
300
301fn pred_to_u8(k: &PredicateKind) -> u8 {
302    // Enumerate all variants in a stable order.
303    match k {
304        PredicateKind::Eq => 0,
305        PredicateKind::Ne => 1,
306        PredicateKind::Lt => 2,
307        PredicateKind::Le => 3,
308        PredicateKind::Gt => 4,
309        PredicateKind::Ge => 5,
310        PredicateKind::And => 6,
311        PredicateKind::Or => 7,
312        PredicateKind::Not => 8,
313        PredicateKind::True => 9,
314        PredicateKind::False => 10,
315    }
316}
317
318fn u8_to_pred(b: u8) -> Result<PredicateKind, IoError> {
319    match b {
320        0 => Ok(PredicateKind::Eq),
321        1 => Ok(PredicateKind::Ne),
322        2 => Ok(PredicateKind::Lt),
323        3 => Ok(PredicateKind::Le),
324        4 => Ok(PredicateKind::Gt),
325        5 => Ok(PredicateKind::Ge),
326        6 => Ok(PredicateKind::And),
327        7 => Ok(PredicateKind::Or),
328        8 => Ok(PredicateKind::Not),
329        9 => Ok(PredicateKind::True),
330        10 => Ok(PredicateKind::False),
331        b => Err(IoError::BadPredicateKind(b)),
332    }
333}
334
335// ---------------------------------------------------------------------------
336// Node ↔ bytes
337// ---------------------------------------------------------------------------
338
339fn write_node(w: &mut impl Write, node: &ExprData) -> io::Result<()> {
340    match node {
341        ExprData::Symbol {
342            name,
343            domain,
344            commutative,
345        } => {
346            write_u8(w, 0)?;
347            write_u8(w, domain_to_u8(domain))?;
348            write_u8(w, u8::from(*commutative))?;
349            write_str(w, name)
350        }
351        ExprData::Integer(BigInt(n)) => {
352            write_u8(w, 1)?;
353            write_str(w, &n.to_string())
354        }
355        ExprData::Rational(BigRat(r)) => {
356            write_u8(w, 2)?;
357            write_str(w, &r.numer().to_string())?;
358            write_str(w, &r.denom().to_string())
359        }
360        ExprData::Float(BigFloat { inner, prec }) => {
361            write_u8(w, 3)?;
362            write_u32(w, *prec)?;
363            // rug::Float::to_string_radix(16, None) round-trips exactly.
364            write_str(w, &inner.to_string_radix(16, None))
365        }
366        ExprData::Add(children) => {
367            write_u8(w, 4)?;
368            write_ids(w, children)
369        }
370        ExprData::Mul(children) => {
371            write_u8(w, 5)?;
372            write_ids(w, children)
373        }
374        ExprData::Pow { base, exp } => {
375            write_u8(w, 6)?;
376            write_u32(w, base.0)?;
377            write_u32(w, exp.0)
378        }
379        ExprData::Func { name, args } => {
380            write_u8(w, 7)?;
381            write_str(w, name)?;
382            write_ids(w, args)
383        }
384        ExprData::Piecewise { branches, default } => {
385            write_u8(w, 8)?;
386            write_u32(w, branches.len() as u32)?;
387            for (c, v) in branches {
388                write_u32(w, c.0)?;
389                write_u32(w, v.0)?;
390            }
391            write_u32(w, default.0)
392        }
393        ExprData::Predicate { kind, args } => {
394            write_u8(w, 9)?;
395            write_u8(w, pred_to_u8(kind))?;
396            write_ids(w, args)
397        }
398        ExprData::Forall { var, body } => {
399            write_u8(w, 10)?;
400            write_u32(w, var.0)?;
401            write_u32(w, body.0)
402        }
403        ExprData::Exists { var, body } => {
404            write_u8(w, 11)?;
405            write_u32(w, var.0)?;
406            write_u32(w, body.0)
407        }
408        ExprData::BigO(inner) => {
409            write_u8(w, 12)?;
410            write_u32(w, inner.0)
411        }
412        ExprData::RootSum { poly, var, body } => {
413            write_u8(w, 13)?;
414            write_u32(w, poly.0)?;
415            write_u32(w, var.0)?;
416            write_u32(w, body.0)
417        }
418    }
419}
420
421fn read_node(r: &mut Bounded<impl Read>, format_version: u32) -> Result<ExprData, IoError> {
422    let tag = read_u8(r)?;
423    match tag {
424        0 => {
425            let domain = u8_to_domain(read_u8(r)?)?;
426            let commutative = if format_version >= POOL_FORMAT_V4 {
427                read_u8(r)? != 0
428            } else {
429                true
430            };
431            let name = read_str(r)?;
432            Ok(ExprData::Symbol {
433                name,
434                domain,
435                commutative,
436            })
437        }
438        1 => {
439            let s = read_str(r)?;
440            let n: rug::Integer = s
441                .parse()
442                .map_err(|_| IoError::BadNumeric(format!("integer: {s}")))?;
443            Ok(ExprData::Integer(BigInt(n)))
444        }
445        2 => {
446            let nstr = read_str(r)?;
447            let dstr = read_str(r)?;
448            let n: rug::Integer = nstr
449                .parse()
450                .map_err(|_| IoError::BadNumeric(format!("numer: {nstr}")))?;
451            let d: rug::Integer = dstr
452                .parse()
453                .map_err(|_| IoError::BadNumeric(format!("denom: {dstr}")))?;
454            if d == 0 {
455                // `Rational::from((n, 0))` panics.
456                return Err(IoError::BadNumeric(format!("rational {nstr}/0")));
457            }
458            Ok(ExprData::Rational(BigRat(rug::Rational::from((n, d)))))
459        }
460        3 => {
461            let prec = read_u32(r)?;
462            if prec == 0 || prec > MAX_FILE_FLOAT_PREC {
463                // `Float::with_val(0, _)` panics; a huge one allocates first.
464                return Err(IoError::BadNumeric(format!(
465                    "float precision {prec} bits is outside 1..={MAX_FILE_FLOAT_PREC}"
466                )));
467            }
468            let s = read_str(r)?;
469            let f = rug::Float::parse_radix(&s, 16)
470                .map_err(|_| IoError::BadNumeric(format!("float: {s}")))?;
471            let inner = rug::Float::with_val(prec, f);
472            Ok(ExprData::Float(BigFloat { inner, prec }))
473        }
474        4 => Ok(ExprData::Add(read_ids(r)?)),
475        5 => Ok(ExprData::Mul(read_ids(r)?)),
476        6 => {
477            let base = ExprId(read_u32(r)?);
478            let exp = ExprId(read_u32(r)?);
479            Ok(ExprData::Pow { base, exp })
480        }
481        7 => {
482            let name = read_str(r)?;
483            let args = read_ids(r)?;
484            Ok(ExprData::Func { name, args })
485        }
486        8 => {
487            let n = u64::from(read_u32(r)?);
488            r.ensure(n, 8)?;
489            let mut branches = Vec::with_capacity(n as usize);
490            for _ in 0..n {
491                let c = ExprId(read_u32(r)?);
492                let v = ExprId(read_u32(r)?);
493                branches.push((c, v));
494            }
495            let default = ExprId(read_u32(r)?);
496            Ok(ExprData::Piecewise { branches, default })
497        }
498        9 => {
499            let kind = u8_to_pred(read_u8(r)?)?;
500            let args = read_ids(r)?;
501            Ok(ExprData::Predicate { kind, args })
502        }
503        10 => {
504            if format_version < POOL_FORMAT_V2 {
505                return Err(IoError::BadTag(10));
506            }
507            let var = ExprId(read_u32(r)?);
508            let body = ExprId(read_u32(r)?);
509            Ok(ExprData::Forall { var, body })
510        }
511        11 => {
512            if format_version < POOL_FORMAT_V2 {
513                return Err(IoError::BadTag(11));
514            }
515            let var = ExprId(read_u32(r)?);
516            let body = ExprId(read_u32(r)?);
517            Ok(ExprData::Exists { var, body })
518        }
519        12 => {
520            if format_version < POOL_FORMAT_V3 {
521                return Err(IoError::BadTag(12));
522            }
523            let inner = ExprId(read_u32(r)?);
524            Ok(ExprData::BigO(inner))
525        }
526        13 => {
527            if format_version < POOL_FORMAT_V5 {
528                return Err(IoError::BadTag(13));
529            }
530            let poly = ExprId(read_u32(r)?);
531            let var = ExprId(read_u32(r)?);
532            let body = ExprId(read_u32(r)?);
533            Ok(ExprData::RootSum { poly, var, body })
534        }
535        b => Err(IoError::BadTag(b)),
536    }
537}
538
539// ---------------------------------------------------------------------------
540// Public API
541// ---------------------------------------------------------------------------
542
543/// Write the pool's full node table to `path` atomically (temp + rename).
544pub fn save_to(pool: &ExprPool, path: impl AsRef<Path>) -> Result<(), IoError> {
545    let path = path.as_ref();
546    let tmp: PathBuf = {
547        let mut p = path.to_path_buf();
548        let mut name = p
549            .file_name()
550            .map(|s| s.to_os_string())
551            .unwrap_or_else(|| std::ffi::OsString::from("pool"));
552        name.push(".tmp");
553        p.set_file_name(name);
554        p
555    };
556
557    {
558        let f = File::create(&tmp)?;
559        let mut w = BufWriter::new(f);
560
561        w.write_all(MAGIC)?;
562        write_u32(&mut w, POOL_FORMAT_WRITE)?;
563        write_u32(&mut w, 0u32)?; // flags
564
565        let count = pool.len();
566        write_u64(&mut w, count as u64)?;
567        for i in 0..count {
568            let data = pool.get(ExprId(i as u32));
569            write_node(&mut w, &data)?;
570        }
571
572        w.flush()?;
573        w.get_ref().sync_all()?;
574    }
575
576    fs::rename(&tmp, path)?;
577    Ok(())
578}
579
580/// Load a pool from `path`.  Returns `Ok(None)` if the file does not exist,
581/// so callers can use `load_or_new` semantics.
582pub fn load_from(path: impl AsRef<Path>) -> Result<Option<ExprPool>, IoError> {
583    let path = path.as_ref();
584    if !path.exists() {
585        return Ok(None);
586    }
587
588    let f = File::open(path)?;
589    let len = f.metadata()?.len();
590    let mut r = Bounded::new(BufReader::new(f), len);
591
592    let mut magic = [0u8; 4];
593    r.read_exact(&mut magic)?;
594    if &magic != MAGIC {
595        return Err(IoError::BadMagic);
596    }
597
598    let pool = ExprPool::new();
599    read_body(&mut r, &pool)?;
600    Ok(Some(pool))
601}
602
603/// Read everything after the magic — version, flags, node count, nodes —
604/// interning each node into `pool`.  Returns the file-index → pool-id table.
605///
606/// `pool` need not be empty: every child reference goes through the table,
607/// so nodes that already exist in `pool` (or collapse onto one another when
608/// canonicalised) are reused, never assumed to sit at their file index.
609fn read_body(r: &mut Bounded<impl Read>, pool: &ExprPool) -> Result<Vec<ExprId>, IoError> {
610    let version = read_u32(r)?;
611    if version != POOL_FORMAT_V1
612        && version != POOL_FORMAT_V2
613        && version != POOL_FORMAT_V3
614        && version != POOL_FORMAT_V4
615        && version != POOL_FORMAT_V5
616    {
617        return Err(IoError::UnsupportedVersion(version));
618    }
619    let _flags = read_u32(r)?;
620
621    let count = read_u64(r)?;
622    // Each node takes at least `MIN_NODE_BYTES`, so a count the rest of the
623    // file cannot hold is a truncated (or corrupt) file, refused before the
624    // loop rather than after it has run out of bytes.
625    r.ensure(count, MIN_NODE_BYTES)?;
626    if count > u64::from(u32::MAX) {
627        return Err(IoError::Io(io::Error::new(
628            io::ErrorKind::InvalidData,
629            format!("pool file declares {count} nodes, more than an ExprId can address"),
630        )));
631    }
632    let count = count as usize;
633    // File index → id in the rebuilt pool.  These are equal for a file this
634    // build wrote, but not in general: `intern` canonicalises (a `Rational`
635    // with denominator 1 becomes an `Integer`, a one-argument `Add` becomes
636    // its argument, …), so a node an older build saved in a non-canonical
637    // spelling lands on an id that already exists and every later node
638    // shifts down.  Reading children through this table keeps each reference
639    // pointing at the node the file meant, instead of silently at whichever
640    // node now occupies that index.
641    let mut ids: Vec<ExprId> = Vec::new();
642    for index in 0..count {
643        let data = read_node(r, version)?;
644        // `Add`/`Mul` go through their constructors, not bare `intern`, so a
645        // nested or unsorted node an older build saved is flattened and put
646        // in canonical order — otherwise it would sit in the pool beside the
647        // canonical node for the same sum as a second id.
648        let id = match remap_children(data, &ids, index)? {
649            ExprData::Add(args) => pool.add(args),
650            ExprData::Mul(args) => pool.mul(args),
651            other => pool.intern(other),
652        };
653        ids.push(id);
654    }
655    Ok(ids)
656}
657
658/// Rewrite every child reference in `data` (a node read at file position
659/// `index`) through `ids`, the file-index → pool-id table built so far.
660///
661/// A child must be an *earlier* node: the writer emits the pool in id order
662/// and a node is interned only after its children, so a reference at or past
663/// `index` is corrupt data, and following it would read a node that does not
664/// exist yet.
665fn remap_children(data: ExprData, ids: &[ExprId], index: usize) -> Result<ExprData, IoError> {
666    map_children(data, |c| {
667        ids.get(c.0 as usize).copied().ok_or_else(|| {
668            IoError::Io(io::Error::new(
669                io::ErrorKind::InvalidData,
670                format!(
671                    "pool file node {index} refers to node {}, which is not an earlier node",
672                    c.0
673                ),
674            ))
675        })
676    })
677}
678
679/// `data` with every child reference replaced by `f(child)`, in a fixed
680/// order (the order the file format lists them).
681fn map_children(
682    data: ExprData,
683    mut f: impl FnMut(ExprId) -> Result<ExprId, IoError>,
684) -> Result<ExprData, IoError> {
685    fn map_all(
686        v: Vec<ExprId>,
687        f: &mut impl FnMut(ExprId) -> Result<ExprId, IoError>,
688    ) -> Result<Vec<ExprId>, IoError> {
689        v.into_iter().map(f).collect()
690    }
691    Ok(match data {
692        ExprData::Symbol { .. }
693        | ExprData::Integer(_)
694        | ExprData::Rational(_)
695        | ExprData::Float(_) => data,
696        ExprData::Add(args) => ExprData::Add(map_all(args, &mut f)?),
697        ExprData::Mul(args) => ExprData::Mul(map_all(args, &mut f)?),
698        ExprData::Pow { base, exp } => ExprData::Pow {
699            base: f(base)?,
700            exp: f(exp)?,
701        },
702        ExprData::Func { name, args } => ExprData::Func {
703            name,
704            args: map_all(args, &mut f)?,
705        },
706        ExprData::Piecewise { branches, default } => ExprData::Piecewise {
707            branches: branches
708                .into_iter()
709                .map(|(c, v)| Ok((f(c)?, f(v)?)))
710                .collect::<Result<_, IoError>>()?,
711            default: f(default)?,
712        },
713        ExprData::Predicate { kind, args } => ExprData::Predicate {
714            kind,
715            args: map_all(args, &mut f)?,
716        },
717        ExprData::Forall { var, body } => ExprData::Forall {
718            var: f(var)?,
719            body: f(body)?,
720        },
721        ExprData::Exists { var, body } => ExprData::Exists {
722            var: f(var)?,
723            body: f(body)?,
724        },
725        ExprData::BigO(inner) => ExprData::BigO(f(inner)?),
726        ExprData::RootSum { poly, var, body } => ExprData::RootSum {
727            poly: f(poly)?,
728            var: f(var)?,
729            body: f(body)?,
730        },
731    })
732}
733
734// ---------------------------------------------------------------------------
735// Sub-DAG serialisation (pickling)
736// ---------------------------------------------------------------------------
737
738/// Magic of a serialised expression DAG (as opposed to a whole pool file).
739const DAG_MAGIC: &[u8; 4] = b"ALKD";
740
741/// Serialise the expressions `roots` — every node reachable from them, and
742/// nothing else in `pool` — to bytes that [`dag_from_bytes`] reads back into
743/// any pool.
744///
745/// The layout is a pool file (see the module docs) whose node list holds only
746/// the reachable nodes, children before parents, renumbered from 0; the magic
747/// is `"ALKD"` instead of `"ALKP"`, and after the nodes come `n_roots: u32`
748/// and `n_roots × u32` node indices.  This is what `pickle` uses for an
749/// `Expr`: the size is that of the expression, not of the pool it lives in.
750///
751/// Iterative, so an expression of any depth serialises.
752pub fn dag_to_bytes(pool: &ExprPool, roots: &[ExprId]) -> Vec<u8> {
753    use std::collections::HashMap;
754    // Post-order DFS with an explicit stack: a node is emitted after all of
755    // its children, so every child index in the output is an earlier one.
756    let mut index: HashMap<ExprId, u32> = HashMap::new();
757    let mut order: Vec<ExprData> = Vec::new();
758    let mut stack: Vec<(ExprId, bool)> = roots.iter().rev().map(|&r| (r, false)).collect();
759    while let Some((id, expanded)) = stack.pop() {
760        if index.contains_key(&id) {
761            continue;
762        }
763        let data = pool.get(id);
764        if expanded {
765            let local = map_children(data, |c| Ok(ExprId(index[&c])))
766                .expect("every child was emitted before its parent");
767            index.insert(id, order.len() as u32);
768            order.push(local);
769            continue;
770        }
771        stack.push((id, true));
772        let mut children = Vec::new();
773        let _ = map_children(data, |c| {
774            children.push(c);
775            Ok(c)
776        });
777        for c in children.into_iter().rev() {
778            if !index.contains_key(&c) {
779                stack.push((c, false));
780            }
781        }
782    }
783    let mut out: Vec<u8> = Vec::new();
784    out.extend_from_slice(DAG_MAGIC);
785    out.extend_from_slice(&POOL_FORMAT_WRITE.to_le_bytes());
786    out.extend_from_slice(&0u32.to_le_bytes());
787    out.extend_from_slice(&(order.len() as u64).to_le_bytes());
788    for node in &order {
789        write_node(&mut out, node).expect("writing to a Vec cannot fail");
790    }
791    out.extend_from_slice(&(roots.len() as u32).to_le_bytes());
792    for r in roots {
793        out.extend_from_slice(&index[r].to_le_bytes());
794    }
795    out
796}
797
798/// Read bytes written by [`dag_to_bytes`] into `pool`, returning the roots'
799/// ids in `pool`.  Nodes `pool` already holds are reused (hash-consing), so
800/// loading into the pool the DAG came from returns the original ids.
801///
802/// The bytes are validated exactly as a pool file is — every length bounded
803/// by the input, children strictly earlier — and a malformed input is an
804/// `IoError`, never a panic.
805pub fn dag_from_bytes(pool: &ExprPool, bytes: &[u8]) -> Result<Vec<ExprId>, IoError> {
806    let mut r = Bounded::new(bytes, bytes.len() as u64);
807    let mut magic = [0u8; 4];
808    r.read_exact(&mut magic)?;
809    if &magic != DAG_MAGIC {
810        return Err(IoError::BadMagic);
811    }
812    let ids = read_body(&mut r, pool)?;
813    let n_roots = u64::from(read_u32(&mut r)?);
814    r.ensure(n_roots, 4)?;
815    let mut roots = Vec::with_capacity(n_roots as usize);
816    for _ in 0..n_roots {
817        let i = read_u32(&mut r)?;
818        let id = ids.get(i as usize).copied().ok_or_else(|| {
819            IoError::Io(io::Error::new(
820                io::ErrorKind::InvalidData,
821                format!(
822                    "expression DAG root {i} is not one of its {} nodes",
823                    ids.len()
824                ),
825            ))
826        })?;
827        roots.push(id);
828    }
829    Ok(roots)
830}
831
832/// Load if `path` exists, else return a fresh pool.
833pub fn open_persistent(path: impl AsRef<Path>) -> Result<ExprPool, IoError> {
834    match load_from(path)? {
835        Some(p) => Ok(p),
836        None => Ok(ExprPool::new()),
837    }
838}
839
840// ---------------------------------------------------------------------------
841// ExprPool convenience methods
842// ---------------------------------------------------------------------------
843
844impl ExprPool {
845    /// V1-14 — write the current pool to `path` atomically.  Equivalent to
846    /// [`save_to`].
847    pub fn checkpoint(&self, path: impl AsRef<Path>) -> Result<(), IoError> {
848        save_to(self, path)
849    }
850
851    /// V1-14 — load a persisted pool, or return a fresh one if the file does
852    /// not exist.  Equivalent to [`open_persistent`].
853    pub fn open_persistent(path: impl AsRef<Path>) -> Result<Self, IoError> {
854        open_persistent(path)
855    }
856}
857
858// ---------------------------------------------------------------------------
859// Tests
860// ---------------------------------------------------------------------------
861
862#[cfg(test)]
863mod tests {
864    use super::*;
865    use crate::kernel::{Domain, ExprData};
866
867    /// A path no other test in this process will pick.
868    ///
869    /// The timestamp alone is not enough: on Windows the system clock ticks
870    /// about every 15 ms, so two of these tests running in parallel read the
871    /// same nanosecond count, build the same path, and then race — one sees
872    /// the other's pool (`node count must match` off by two) or its
873    /// `remove_file` (`NotFound`). The counter makes the name unique within
874    /// the process and the pid keeps it unique across processes.
875    fn tempfile() -> PathBuf {
876        static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
877        let mut p = std::env::temp_dir();
878        p.push(format!(
879            "alkahest_pool_{}_{}_{}.akp",
880            std::process::id(),
881            std::time::SystemTime::now()
882                .duration_since(std::time::UNIX_EPOCH)
883                .unwrap()
884                .as_nanos(),
885            SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
886        ));
887        p
888    }
889
890    #[test]
891    fn round_trip_small_pool() {
892        let p = ExprPool::new();
893        let x = p.symbol("x", Domain::Real);
894        let y = p.symbol("y", Domain::Positive);
895        let two = p.integer(2_i32);
896        let three_halves = p.rational(3, 2);
897        let f = p.float(1.5_f64, 53);
898        let xp = p.pow(x, two);
899        let fn_node = p.func("sin", vec![xp]);
900        let _sum = p.add(vec![fn_node, y, three_halves, f]);
901
902        let path = tempfile();
903        p.checkpoint(&path).unwrap();
904
905        let q = ExprPool::open_persistent(&path).unwrap();
906        assert_eq!(q.len(), p.len(), "node count must match");
907        for i in 0..p.len() {
908            let id = ExprId(i as u32);
909            assert_eq!(p.get(id), q.get(id), "node {i} mismatch after round-trip");
910        }
911
912        // Re-interning the same structures under q must collide with the
913        // restored IDs — this is the hash-cons stability guarantee.
914        let q_x = q.symbol("x", Domain::Real);
915        assert_eq!(q_x, x, "symbol id drifted across checkpoint");
916        let q_two = q.integer(2_i32);
917        assert_eq!(q_two, two);
918
919        let _ = fs::remove_file(&path);
920    }
921
922    #[test]
923    fn round_trip_root_sum() {
924        // RootSum nodes (format V5) must round-trip through checkpoint/restore.
925        let p = ExprPool::new();
926        let x = p.symbol("x", Domain::Real);
927        let t = p.symbol("t", Domain::Complex);
928        let poly = p.add(vec![p.pow(t, p.integer(2_i32)), p.integer(1_i32)]);
929        let body = p.mul(vec![t, p.func("log", vec![p.add(vec![x, t])])]);
930        let rs = p.root_sum(poly, t, body);
931        assert!(matches!(p.get(rs), ExprData::RootSum { .. }));
932
933        let path = tempfile();
934        p.checkpoint(&path).unwrap();
935        let q = ExprPool::open_persistent(&path).unwrap();
936        assert_eq!(q.len(), p.len());
937        for i in 0..p.len() {
938            let id = ExprId(i as u32);
939            assert_eq!(p.get(id), q.get(id), "node {i} mismatch after round-trip");
940        }
941        let _ = fs::remove_file(&path);
942    }
943
944    #[test]
945    fn bad_magic_rejected() {
946        let path = tempfile();
947        std::fs::write(&path, b"nope1234").unwrap();
948        match load_from(&path) {
949            Err(IoError::BadMagic) => {}
950            other => panic!("expected BadMagic, got {:?}", other.err()),
951        }
952        let _ = fs::remove_file(&path);
953    }
954
955    #[test]
956    fn missing_file_returns_fresh() {
957        let path = tempfile();
958        assert!(!path.exists());
959        let p = ExprPool::open_persistent(&path).unwrap();
960        assert_eq!(p.len(), 0);
961    }
962
963    #[test]
964    fn predicate_and_piecewise_round_trip() {
965        let p = ExprPool::new();
966        let x = p.symbol("x", Domain::Real);
967        let zero = p.integer(0_i32);
968        let one = p.integer(1_i32);
969        let neg_one = p.integer(-1_i32);
970        let cond = p.intern(ExprData::Predicate {
971            kind: PredicateKind::Gt,
972            args: vec![x, zero],
973        });
974        let pc = p.intern(ExprData::Piecewise {
975            branches: vec![(cond, one)],
976            default: neg_one,
977        });
978
979        let path = tempfile();
980        p.checkpoint(&path).unwrap();
981        let q = ExprPool::open_persistent(&path).unwrap();
982        assert_eq!(p.get(pc), q.get(pc));
983        let _ = fs::remove_file(&path);
984    }
985
986    /// Write `nodes` as a pool file exactly as given — no canonicalisation —
987    /// the way an older build could have.
988    fn write_raw(path: &Path, nodes: &[ExprData]) {
989        let mut w = BufWriter::new(File::create(path).unwrap());
990        w.write_all(MAGIC).unwrap();
991        write_u32(&mut w, POOL_FORMAT_WRITE).unwrap();
992        write_u32(&mut w, 0).unwrap();
993        write_u64(&mut w, nodes.len() as u64).unwrap();
994        for n in nodes {
995            write_node(&mut w, n).unwrap();
996        }
997        w.flush().unwrap();
998    }
999
1000    /// A file holding non-canonical spellings (a `Rational` with denominator
1001    /// 1 beside the `Integer` it equals, a one-argument `Add`, an unsorted
1002    /// `Add`) loads onto the canonical nodes, and every later reference still
1003    /// points at the node the file meant — not at whatever now sits at that
1004    /// index once the duplicates have collapsed.
1005    #[test]
1006    fn non_canonical_nodes_collapse_without_drift() {
1007        let x = ExprData::Symbol {
1008            name: "x".into(),
1009            domain: Domain::Real,
1010            commutative: true,
1011        };
1012        let y = ExprData::Symbol {
1013            name: "y".into(),
1014            domain: Domain::Real,
1015            commutative: true,
1016        };
1017        let nodes = vec![
1018            x,                                                       // 0
1019            ExprData::Rational(BigRat(rug::Rational::from((4, 2)))), // 1: 2/1
1020            ExprData::Integer(BigInt(rug::Integer::from(2))),        // 2: 2
1021            ExprData::Add(vec![ExprId(0)]),                          // 3: (x)
1022            y,                                                       // 4
1023            ExprData::Add(vec![ExprId(4), ExprId(3)]),               // 5: y + (x)
1024            ExprData::Pow {
1025                base: ExprId(5),
1026                exp: ExprId(2),
1027            }, // 6: (y + x)^2
1028            ExprData::Mul(vec![]),                                   // 7: empty product
1029            ExprData::Pow {
1030                base: ExprId(4),
1031                exp: ExprId(7),
1032            }, // 8: y^1
1033        ];
1034        let path = tempfile();
1035        write_raw(&path, &nodes);
1036        let q = load_from(&path).unwrap().unwrap();
1037        let _ = fs::remove_file(&path);
1038
1039        let x = q.symbol("x", Domain::Real);
1040        let y = q.symbol("y", Domain::Real);
1041        let two = q.integer(2_i32);
1042        let sum = q.add(vec![x, y]);
1043        let square = q.pow(sum, two);
1044        let y_one = q.pow(y, q.integer(1_i32));
1045        // x, 2, y, x + y, (x + y)^2, 1, y^1 — nothing else, nothing twice.
1046        assert_eq!(q.len(), 7, "a non-canonical node survived as a duplicate");
1047        for id in [x, y, two, sum, square, y_one] {
1048            assert!(
1049                (id.0 as usize) < 7,
1050                "{} was not in the loaded pool",
1051                q.display(id)
1052            );
1053        }
1054    }
1055
1056    #[test]
1057    fn a_forward_child_reference_is_refused() {
1058        let nodes = vec![ExprData::Add(vec![ExprId(0), ExprId(1)])];
1059        let path = tempfile();
1060        write_raw(&path, &nodes);
1061        let r = load_from(&path);
1062        let _ = fs::remove_file(&path);
1063        assert!(
1064            r.is_err(),
1065            "a child reference past the node itself must be refused"
1066        );
1067    }
1068
1069    #[test]
1070    fn big_o_round_trip() {
1071        let p = ExprPool::new();
1072        let x = p.symbol("x", Domain::Real);
1073        let o = p.big_o(p.pow(x, p.integer(6)));
1074        let path = tempfile();
1075        p.checkpoint(&path).unwrap();
1076        let q = ExprPool::open_persistent(&path).unwrap();
1077        assert_eq!(q.get(o), p.get(o));
1078        let _ = fs::remove_file(&path);
1079    }
1080
1081    // -- Corrupt and hostile files (audit B8/B9) ---------------------------
1082
1083    fn header(count: u64) -> Vec<u8> {
1084        let mut v = MAGIC.to_vec();
1085        v.extend_from_slice(&POOL_FORMAT_WRITE.to_le_bytes());
1086        v.extend_from_slice(&0u32.to_le_bytes());
1087        v.extend_from_slice(&count.to_le_bytes());
1088        v
1089    }
1090
1091    fn load_bytes(bytes: &[u8]) -> Result<Option<ExprPool>, IoError> {
1092        let path = tempfile();
1093        std::fs::write(&path, bytes).unwrap();
1094        let r = load_from(&path);
1095        let _ = fs::remove_file(&path);
1096        r
1097    }
1098
1099    fn le32(v: u32) -> [u8; 4] {
1100        v.to_le_bytes()
1101    }
1102
1103    /// Each of these used to abort the process (a length-prefixed field was
1104    /// allocated at its declared size before the bytes were read) or panic
1105    /// inside `rug`.  All of them must now come back as an `Err`.
1106    #[test]
1107    fn hostile_headers_are_refused_not_allocated() {
1108        let mut cases: Vec<(&str, Vec<u8>, &str)> = Vec::new();
1109        // Symbol whose name claims ~4 GiB.
1110        let mut b = header(1);
1111        b.extend_from_slice(&[0, 0, 1]);
1112        b.extend_from_slice(&le32(0xFFFF_FFF0));
1113        cases.push(("huge string length", b, "E-IO-004"));
1114        // Add whose arity claims ~4 G children.
1115        let mut b = header(1);
1116        b.push(4);
1117        b.extend_from_slice(&le32(0xFFFF_FFF0));
1118        cases.push(("huge arity", b, "E-IO-004"));
1119        // Piecewise whose branch count claims ~4 G pairs.
1120        let mut b = header(1);
1121        b.push(8);
1122        b.extend_from_slice(&le32(0xFFFF_FFF0));
1123        cases.push(("huge branch count", b, "E-IO-004"));
1124        // Node count far past what the file holds.
1125        let mut b = header(1 << 62);
1126        b.extend_from_slice(&[0, 0, 1]);
1127        b.extend_from_slice(&le32(1));
1128        b.push(b'x');
1129        cases.push(("huge node count", b, "E-IO-004"));
1130        // Rational with a zero denominator.
1131        let mut b = header(1);
1132        b.push(2);
1133        b.extend_from_slice(&le32(1));
1134        b.push(b'1');
1135        b.extend_from_slice(&le32(1));
1136        b.push(b'0');
1137        cases.push(("zero denominator", b, "E-IO-009"));
1138        // Floats at precision 0 and u32::MAX.
1139        for prec in [0u32, u32::MAX, MAX_FILE_FLOAT_PREC + 1] {
1140            let mut b = header(1);
1141            b.push(3);
1142            b.extend_from_slice(&le32(prec));
1143            b.extend_from_slice(&le32(1));
1144            b.push(b'1');
1145            cases.push(("float precision", b, "E-IO-009"));
1146        }
1147        use crate::errors::AlkahestError;
1148        for (what, bytes, code) in cases {
1149            let r = std::panic::catch_unwind(|| load_bytes(&bytes))
1150                .unwrap_or_else(|_| panic!("{what}: loading panicked"));
1151            match r {
1152                Err(e) => assert_eq!(e.code(), code, "{what}: {e}"),
1153                Ok(_) => panic!("{what}: a corrupt file loaded"),
1154            }
1155        }
1156    }
1157
1158    /// Two copies of the same node in a file must not shift every later
1159    /// reference by one: `[x, x, y, Pow(1, 2)]` is `x^y`, not `y^?`.
1160    #[test]
1161    fn duplicate_nodes_do_not_drift_later_references() {
1162        let sym = |n: &str| ExprData::Symbol {
1163            name: n.into(),
1164            domain: Domain::Real,
1165            commutative: true,
1166        };
1167        let nodes = vec![
1168            sym("x"),
1169            sym("x"),
1170            sym("y"),
1171            ExprData::Pow {
1172                base: ExprId(1),
1173                exp: ExprId(2),
1174            },
1175        ];
1176        let path = tempfile();
1177        write_raw(&path, &nodes);
1178        let q = load_from(&path).unwrap().unwrap();
1179        let _ = fs::remove_file(&path);
1180        let x = q.symbol("x", Domain::Real);
1181        let y = q.symbol("y", Domain::Real);
1182        let before = q.len();
1183        let _ = q.pow(x, y);
1184        assert_eq!(q.len(), before, "x^y was not the node the file meant");
1185        assert_eq!(q.len(), 3);
1186    }
1187
1188    /// A pool using every node tag, as bytes.
1189    fn every_tag_file() -> Vec<u8> {
1190        let p = ExprPool::new();
1191        let x = p.symbol("x", Domain::Real);
1192        let t = p.symbol("t", Domain::Complex);
1193        let big = p.integer(rug::Integer::from(rug::Integer::u_pow_u(10, 30)));
1194        let r = p.rational(-3, 7);
1195        let f = p.float(2.5, 80);
1196        let s = p.add(vec![x, big, r, f]);
1197        let m = p.mul(vec![x, t]);
1198        let pw = p.pow(s, m);
1199        let sn = p.func("sin", vec![pw]);
1200        let g = p.func("f", vec![x, t, sn]);
1201        let c = p.pred_gt(x, p.integer(0));
1202        let pc = p.piecewise(vec![(c, g)], r);
1203        let fa = p.forall(x, c);
1204        let ex = p.exists(t, fa);
1205        let o = p.big_o(p.pow(x, p.integer(3)));
1206        let poly = p.add(vec![p.pow(t, p.integer(2)), p.integer(1)]);
1207        let rs = p.root_sum(poly, t, p.func("log", vec![p.add(vec![x, t])]));
1208        let _ = p.add(vec![pc, ex, o, rs]);
1209        let path = tempfile();
1210        p.checkpoint(&path).unwrap();
1211        let bytes = std::fs::read(&path).unwrap();
1212        let _ = fs::remove_file(&path);
1213        bytes
1214    }
1215
1216    /// Load `bytes`; if it loads, print every node.  Either outcome is fine;
1217    /// a panic is not.
1218    fn load_and_touch(bytes: &[u8]) -> bool {
1219        std::panic::catch_unwind(|| {
1220            if let Ok(Some(q)) = load_bytes(bytes) {
1221                for i in 0..q.len() {
1222                    let _ = q.display(ExprId(i as u32)).to_string();
1223                }
1224            }
1225        })
1226        .is_ok()
1227    }
1228
1229    /// Every truncation and every single-bit flip of a valid file loads as
1230    /// `Ok` or `Err` — never a panic.  (The Python suite re-runs this in a
1231    /// subprocess, where an allocation abort would also be caught.)
1232    #[test]
1233    fn truncated_and_bit_flipped_files_never_panic() {
1234        let good = every_tag_file();
1235        assert!(load_bytes(&good).unwrap().is_some());
1236        let mut bad = Vec::new();
1237        for n in 0..good.len() {
1238            if !load_and_touch(&good[..n]) {
1239                bad.push(format!("truncated to {n}"));
1240            }
1241        }
1242        for i in 0..good.len() {
1243            for bit in 0..8 {
1244                let mut b = good.clone();
1245                b[i] ^= 1 << bit;
1246                if !load_and_touch(&b) {
1247                    bad.push(format!("byte {i} bit {bit}"));
1248                }
1249            }
1250        }
1251        assert!(bad.is_empty(), "panicked on: {bad:?}");
1252    }
1253
1254    // -- Sub-DAG bytes (pickling) -------------------------------------------
1255
1256    /// Structural rendering, pool-independent.
1257    fn show(pool: &ExprPool, id: ExprId) -> String {
1258        pool.display(id).to_string()
1259    }
1260
1261    #[test]
1262    fn dag_round_trips_into_a_fresh_pool_and_carries_only_what_is_reachable() {
1263        let p = ExprPool::new();
1264        let x = p.symbol("x", Domain::Real);
1265        let y = p.symbol("y", Domain::Positive);
1266        // Junk the expressions do not reach.
1267        for i in 0..200 {
1268            let _ = p.add(vec![x, p.integer(i)]);
1269        }
1270        let s = p.add(vec![x, y]);
1271        let e1 = p.pow(s, p.rational(3, 7));
1272        let e2 = p.func("sin", vec![p.mul(vec![s, p.float(0.25, 80)])]);
1273        let bytes = dag_to_bytes(&p, &[e1, e2, e1]);
1274        assert!(
1275            bytes.len() < 400,
1276            "{} bytes for two small expressions",
1277            bytes.len()
1278        );
1279
1280        let q = ExprPool::new();
1281        let got = dag_from_bytes(&q, &bytes).unwrap();
1282        assert_eq!(got.len(), 3);
1283        assert_eq!(got[0], got[2]);
1284        assert_eq!(show(&q, got[0]), show(&p, e1));
1285        assert_eq!(show(&q, got[1]), show(&p, e2));
1286        // Domains survive: y is Positive in q too.
1287        assert_eq!(q.get(q.symbol("y", Domain::Positive)), p.get(y));
1288        let qy = q.symbol("y", Domain::Positive);
1289        let qx = q.symbol("x", Domain::Real);
1290        assert_eq!(q.add(vec![qx, qy]), q.add(vec![qy, qx]));
1291        let before = q.len();
1292        let _ = q.add(vec![qx, qy]);
1293        assert_eq!(q.len(), before, "the shared x + y was not reused");
1294    }
1295
1296    #[test]
1297    fn dag_loaded_into_its_own_pool_returns_the_original_ids() {
1298        let p = ExprPool::new();
1299        let x = p.symbol("x", Domain::Real);
1300        let e = p.add(vec![p.pow(x, p.integer(2)), p.func("exp", vec![x])]);
1301        let before = p.len();
1302        let got = dag_from_bytes(&p, &dag_to_bytes(&p, &[e])).unwrap();
1303        assert_eq!(got, vec![e]);
1304        assert_eq!(p.len(), before);
1305    }
1306
1307    #[test]
1308    fn dag_of_a_very_deep_expression_serialises_iteratively() {
1309        let p = ExprPool::new();
1310        let mut e = p.symbol("x", Domain::Real);
1311        for _ in 0..200_000 {
1312            e = p.func("sin", vec![e]);
1313        }
1314        let bytes = dag_to_bytes(&p, &[e]);
1315        let q = ExprPool::new();
1316        let got = dag_from_bytes(&q, &bytes).unwrap();
1317        assert_eq!(q.len(), p.len());
1318        assert!(matches!(q.get(got[0]), ExprData::Func { .. }));
1319    }
1320
1321    #[test]
1322    fn corrupt_dag_bytes_are_errors_not_panics() {
1323        let p = ExprPool::new();
1324        let x = p.symbol("x", Domain::Real);
1325        let e = p.add(vec![p.pow(x, p.rational(1, 3)), p.float(1.5, 64), x]);
1326        let good = dag_to_bytes(&p, &[e]);
1327        let mut bad = Vec::new();
1328        let try_load = |b: &[u8]| {
1329            std::panic::catch_unwind(|| {
1330                let q = ExprPool::new();
1331                if let Ok(roots) = dag_from_bytes(&q, b) {
1332                    for r in roots {
1333                        let _ = q.display(r).to_string();
1334                    }
1335                }
1336            })
1337            .is_ok()
1338        };
1339        for n in 0..good.len() {
1340            if !try_load(&good[..n]) {
1341                bad.push(format!("truncated to {n}"));
1342            }
1343        }
1344        for i in 0..good.len() {
1345            for bit in 0..8 {
1346                let mut b = good.clone();
1347                b[i] ^= 1 << bit;
1348                if !try_load(&b) {
1349                    bad.push(format!("byte {i} bit {bit}"));
1350                }
1351            }
1352        }
1353        assert!(bad.is_empty(), "panicked on: {bad:?}");
1354        // A pool file is not a DAG and vice versa.
1355        assert!(matches!(
1356            dag_from_bytes(&p, b"ALKP"),
1357            Err(IoError::BadMagic)
1358        ));
1359    }
1360}