Skip to main content

oxiz_proof/
replay.rs

1//! Proof log replay and offline verification.
2//!
3//! `ProofReplayer` reads a binary proof log produced by `ProofLogger` and
4//! re-executes every step, verifying that:
5//!
6//! 1. All premise references point to already-seen nodes.
7//! 2. The binary format is well-formed.
8//! 3. The proof is structurally consistent (no forward references, valid UTF-8
9//!    strings, etc.).
10//!
11//! Semantic soundness checking (i.e. confirming that a rule application is
12//! logically valid) is delegated to `crate::checker::ProofChecker` and is
13//! performed on the reconstructed in-memory `Proof` object.
14
15use std::collections::HashMap;
16use std::fs::File;
17use std::io::{self, BufReader, Read};
18use std::path::Path;
19
20use thiserror::Error;
21
22use crate::proof::{Proof, ProofNodeId, ProofStep};
23
24// Re-use the same constants as logging.rs without a public dependency:
25const MAGIC: &[u8; 8] = b"OXIZPROF";
26const FORMAT_VERSION: u32 = 1;
27const KIND_AXIOM: u8 = 0;
28const KIND_INFERENCE: u8 = 1;
29
30/// Errors that can occur while replaying a proof log.
31#[derive(Error, Debug)]
32pub enum ProofError {
33    /// Underlying I/O failure.
34    #[error("I/O error: {0}")]
35    Io(#[from] io::Error),
36
37    /// File did not begin with the expected magic bytes.
38    #[error("invalid file magic; expected OXIZPROF, got {found:?}")]
39    InvalidMagic {
40        /// Bytes that were actually read.
41        found: [u8; 8],
42    },
43
44    /// The log file was written by an incompatible format version.
45    #[error(
46        "unsupported format version {version}; this reader supports version {}",
47        FORMAT_VERSION
48    )]
49    UnsupportedVersion {
50        /// Version found in the file.
51        version: u32,
52    },
53
54    /// An unknown record kind byte was encountered.
55    #[error("unknown record kind {0:#x}")]
56    UnknownKind(u8),
57
58    /// A premise ID referenced a node that has not been seen yet.
59    #[error(
60        "forward reference: node {referencing} references premise {missing} which has not been defined"
61    )]
62    ForwardReference {
63        /// Node that contains the invalid reference.
64        referencing: u32,
65        /// Node ID that has not been defined yet.
66        missing: u32,
67    },
68
69    /// A byte slice could not be decoded as UTF-8.
70    #[error("invalid UTF-8 in proof step: {0}")]
71    InvalidUtf8(String),
72
73    /// The replay produced zero steps (empty or truncated file).
74    #[error("proof log is empty or truncated after the header")]
75    EmptyLog,
76
77    /// A length-prefixed field had an unreasonably large value (guards OOM).
78    #[error("field length {0} exceeds the safety limit")]
79    FieldTooLarge(u32),
80}
81
82/// Result type for replay operations.
83pub type ReplayResult<T> = Result<T, ProofError>;
84
85/// Maximum allowed byte length for a single string field (64 MiB).
86const MAX_FIELD_BYTES: u32 = 64 * 1024 * 1024;
87
88/// Maximum number of premises per inference step (guards against OOM loops).
89const MAX_PREMISES: u32 = 1_000_000;
90
91/// Maximum number of arguments per inference step.
92const MAX_ARGS: u32 = 1_000;
93
94/// The result of a proof replay/verification run.
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub enum VerificationResult {
97    /// The proof log was replayed successfully and all structural checks passed.
98    Valid,
99    /// A structural or semantic error was found.
100    Invalid(String),
101    /// The proof was structurally sound but did not contain a final conclusion;
102    /// semantic verification could not be completed.
103    Incomplete,
104}
105
106impl VerificationResult {
107    /// Return `true` if the result is `Valid`.
108    #[must_use]
109    pub fn is_valid(&self) -> bool {
110        matches!(self, Self::Valid)
111    }
112
113    /// Return the reason string if the result is `Invalid`.
114    #[must_use]
115    pub fn invalid_reason(&self) -> Option<&str> {
116        match self {
117            Self::Invalid(reason) => Some(reason.as_str()),
118            _ => None,
119        }
120    }
121}
122
123impl std::fmt::Display for VerificationResult {
124    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125        match self {
126            Self::Valid => write!(f, "valid"),
127            Self::Invalid(reason) => write!(f, "invalid: {}", reason),
128            Self::Incomplete => write!(f, "incomplete"),
129        }
130    }
131}
132
133/// Statistics collected during a replay run.
134#[derive(Debug, Clone, Default)]
135pub struct ReplayStats {
136    /// Total number of records processed.
137    pub total_steps: u64,
138    /// Number of `Axiom` records.
139    pub axiom_count: u64,
140    /// Number of `Inference` records.
141    pub inference_count: u64,
142    /// Total number of premise references across all inference steps.
143    pub total_premises: u64,
144}
145
146/// Reads and verifies a proof log produced by `ProofLogger`.
147///
148/// # Examples
149///
150/// ```no_run
151/// use std::path::Path;
152/// use oxiz_proof::replay::{ProofReplayer, VerificationResult};
153///
154/// let result = ProofReplayer::replay_from_file(Path::new("/tmp/proof.oxizlog"))
155///     .expect("replay failed");
156///
157/// match result {
158///     VerificationResult::Valid => println!("Proof is valid"),
159///     VerificationResult::Invalid(reason) => eprintln!("Invalid: {}", reason),
160///     VerificationResult::Incomplete => println!("Proof is incomplete"),
161/// }
162/// ```
163pub struct ProofReplayer {
164    /// The reconstructed proof DAG.
165    proof: Proof,
166    /// Mapping from log node IDs to in-memory `ProofNodeId`.
167    id_map: HashMap<u32, ProofNodeId>,
168    /// Statistics from the last replay.
169    stats: ReplayStats,
170}
171
172impl ProofReplayer {
173    /// Create a new, empty replayer.
174    #[must_use]
175    pub fn new() -> Self {
176        Self {
177            proof: Proof::new(),
178            id_map: HashMap::new(),
179            stats: ReplayStats::default(),
180        }
181    }
182
183    /// Read, replay, and structurally verify a proof log file.
184    ///
185    /// This is the primary entry point.  It opens `path`, reads the header,
186    /// then processes every record in sequence, rebuilding the proof DAG and
187    /// checking that all references are backward-pointing.
188    ///
189    /// Returns `Ok(VerificationResult)` even when the proof is logically
190    /// incomplete or invalid — the caller can inspect the variant to determine
191    /// the outcome.  Only hard I/O or format errors are returned as `Err`.
192    pub fn replay_from_file(path: &Path) -> ReplayResult<VerificationResult> {
193        let mut replayer = Self::new();
194        replayer.replay(path)
195    }
196
197    /// Replay a proof log and return the verification result.
198    ///
199    /// Mutates `self`, accumulating the proof and stats.
200    pub fn replay(&mut self, path: &Path) -> ReplayResult<VerificationResult> {
201        let file = File::open(path)?;
202        let mut reader = BufReader::new(file);
203
204        // Read and validate header
205        read_header(&mut reader)?;
206
207        // Read records until EOF
208        let mut steps_read = 0u64;
209        loop {
210            match read_record(&mut reader) {
211                Ok(Some(record)) => {
212                    self.incorporate_record(record)?;
213                    steps_read += 1;
214                }
215                Ok(None) => break, // clean EOF
216                Err(ProofError::Io(ref e)) if e.kind() == io::ErrorKind::UnexpectedEof => {
217                    if steps_read == 0 {
218                        return Err(ProofError::EmptyLog);
219                    }
220                    // Truncated file after at least one valid record.
221                    return Ok(VerificationResult::Incomplete);
222                }
223                Err(e) => return Err(e),
224            }
225        }
226
227        if steps_read == 0 {
228            return Err(ProofError::EmptyLog);
229        }
230
231        // Run structural validation on the reconstructed proof.
232        Ok(self.assess())
233    }
234
235    /// Consume `self` and return the reconstructed `Proof`.
236    #[must_use]
237    pub fn into_proof(self) -> Proof {
238        self.proof
239    }
240
241    /// Return a reference to the reconstructed `Proof`.
242    #[must_use]
243    pub fn proof(&self) -> &Proof {
244        &self.proof
245    }
246
247    /// Return replay statistics from the most recent `replay()` call.
248    #[must_use]
249    pub fn stats(&self) -> &ReplayStats {
250        &self.stats
251    }
252
253    // ────────────────────────────── internals ────────────────────────────────
254
255    /// Add a decoded log record to the in-memory proof DAG.
256    fn incorporate_record(&mut self, record: LogRecord) -> ReplayResult<()> {
257        let log_id = record.log_id;
258
259        let mem_id = match record.step {
260            ProofStep::Axiom { ref conclusion } => {
261                self.stats.axiom_count += 1;
262                self.proof.add_axiom(conclusion.clone())
263            }
264            ProofStep::Inference {
265                ref rule,
266                ref premises,
267                ref conclusion,
268                ref args,
269            } => {
270                self.stats.inference_count += 1;
271                self.stats.total_premises += premises.len() as u64;
272
273                // Translate log-level premise IDs → in-memory IDs.
274                // premises are stored as ProofNodeId, whose inner u32 is the log ID.
275                let mut mem_premises = Vec::with_capacity(premises.len());
276                for p_node_id in premises.iter() {
277                    let p_log_id = p_node_id.0;
278                    let mem_premise = self.id_map.get(&p_log_id).copied().ok_or(
279                        ProofError::ForwardReference {
280                            referencing: log_id,
281                            missing: p_log_id,
282                        },
283                    )?;
284                    mem_premises.push(mem_premise);
285                }
286
287                self.proof.add_inference_with_args(
288                    rule.clone(),
289                    mem_premises,
290                    args.iter().cloned().collect(),
291                    conclusion.clone(),
292                )
293            }
294        };
295
296        self.stats.total_steps += 1;
297        self.id_map.insert(log_id, mem_id);
298        Ok(())
299    }
300
301    /// Assess the overall validity of the reconstructed proof.
302    fn assess(&self) -> VerificationResult {
303        let stats = self.proof.stats();
304        if stats.total_nodes == 0 {
305            return VerificationResult::Incomplete;
306        }
307        // A proof with at least one node and no forward-reference errors is valid.
308        VerificationResult::Valid
309    }
310}
311
312impl Default for ProofReplayer {
313    fn default() -> Self {
314        Self::new()
315    }
316}
317
318// ──────────────────────────── binary decoding ────────────────────────────────
319
320/// An in-memory representation of one decoded log record.
321struct LogRecord {
322    /// The node ID recorded in the log.
323    log_id: u32,
324    /// The proof step.
325    step: ProofStep,
326}
327
328/// Read and validate the 32-byte file header.
329fn read_header<R: Read>(r: &mut R) -> ReplayResult<()> {
330    let mut magic = [0u8; 8];
331    r.read_exact(&mut magic)?;
332    if &magic != MAGIC {
333        return Err(ProofError::InvalidMagic { found: magic });
334    }
335
336    let version = read_u32(r)?;
337    if version != FORMAT_VERSION {
338        return Err(ProofError::UnsupportedVersion { version });
339    }
340
341    // flags (reserved, skip 4 bytes)
342    let _flags = read_u32(r)?;
343
344    // 16 reserved bytes
345    let mut reserved = [0u8; 16];
346    r.read_exact(&mut reserved)?;
347
348    Ok(())
349}
350
351/// Read one record from the log, returning `None` on clean EOF.
352fn read_record<R: Read>(r: &mut R) -> ReplayResult<Option<LogRecord>> {
353    // Attempt to read the kind byte; a clean EOF here is expected.
354    let mut kind_buf = [0u8; 1];
355    match r.read(&mut kind_buf) {
356        Ok(0) => return Ok(None),
357        Ok(_) => {}
358        Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => return Ok(None),
359        Err(e) => return Err(ProofError::Io(e)),
360    }
361    let kind = kind_buf[0];
362
363    let log_id = read_u32(r)?;
364    let conclusion = read_string(r)?;
365
366    let step = match kind {
367        KIND_AXIOM => ProofStep::Axiom { conclusion },
368        KIND_INFERENCE => {
369            let rule = read_string(r)?;
370
371            let premise_count = read_u32(r)?;
372            if premise_count > MAX_PREMISES {
373                return Err(ProofError::FieldTooLarge(premise_count));
374            }
375            let mut premises = smallvec::SmallVec::<[ProofNodeId; 4]>::new();
376            for _ in 0..premise_count {
377                premises.push(ProofNodeId(read_u32(r)?));
378            }
379
380            let arg_count = read_u32(r)?;
381            if arg_count > MAX_ARGS {
382                return Err(ProofError::FieldTooLarge(arg_count));
383            }
384            let mut args = smallvec::SmallVec::<[String; 2]>::new();
385            for _ in 0..arg_count {
386                args.push(read_string(r)?);
387            }
388
389            ProofStep::Inference {
390                rule,
391                premises,
392                conclusion,
393                args,
394            }
395        }
396        other => return Err(ProofError::UnknownKind(other)),
397    };
398
399    Ok(Some(LogRecord { log_id, step }))
400}
401
402/// Read a 4-byte little-endian u32.
403fn read_u32<R: Read>(r: &mut R) -> ReplayResult<u32> {
404    let mut buf = [0u8; 4];
405    r.read_exact(&mut buf)?;
406    Ok(u32::from_le_bytes(buf))
407}
408
409/// Read a length-prefixed UTF-8 string.
410fn read_string<R: Read>(r: &mut R) -> ReplayResult<String> {
411    let len = read_u32(r)?;
412    if len > MAX_FIELD_BYTES {
413        return Err(ProofError::FieldTooLarge(len));
414    }
415    let mut buf = vec![0u8; len as usize];
416    r.read_exact(&mut buf)?;
417    String::from_utf8(buf).map_err(|e| ProofError::InvalidUtf8(e.to_string()))
418}
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423    use crate::logging::ProofLogger;
424    use crate::proof::{ProofNodeId, ProofStep};
425    use smallvec::SmallVec;
426
427    fn temp_path(name: &str) -> std::path::PathBuf {
428        std::env::temp_dir().join(format!("oxiz_replay_{}.oxizlog", name))
429    }
430
431    #[test]
432    fn test_roundtrip_axiom() {
433        let path = temp_path("roundtrip_axiom");
434        let _ = std::fs::remove_file(&path);
435
436        {
437            let mut logger = ProofLogger::create(&path).expect("create");
438            let step = ProofStep::Axiom {
439                conclusion: "(= x 0)".to_string(),
440            };
441            logger.log_step(ProofNodeId(0), &step).expect("log");
442            logger.close().expect("close");
443        }
444
445        let result = ProofReplayer::replay_from_file(&path).expect("replay");
446        assert_eq!(result, VerificationResult::Valid);
447
448        let _ = std::fs::remove_file(&path);
449    }
450
451    #[test]
452    fn test_roundtrip_inference_chain() {
453        let path = temp_path("roundtrip_chain");
454        let _ = std::fs::remove_file(&path);
455
456        {
457            let mut logger = ProofLogger::create(&path).expect("create");
458
459            // Step 0: axiom  p
460            logger
461                .log_step(
462                    ProofNodeId(0),
463                    &ProofStep::Axiom {
464                        conclusion: "p".to_string(),
465                    },
466                )
467                .expect("log");
468
469            // Step 1: axiom  p → q
470            logger
471                .log_step(
472                    ProofNodeId(1),
473                    &ProofStep::Axiom {
474                        conclusion: "(=> p q)".to_string(),
475                    },
476                )
477                .expect("log");
478
479            // Step 2: modus ponens  p, p→q  ⊢  q
480            logger
481                .log_step(
482                    ProofNodeId(2),
483                    &ProofStep::Inference {
484                        rule: "mp".to_string(),
485                        premises: SmallVec::from_vec(vec![ProofNodeId(0), ProofNodeId(1)]),
486                        conclusion: "q".to_string(),
487                        args: SmallVec::new(),
488                    },
489                )
490                .expect("log");
491
492            logger.close().expect("close");
493        }
494
495        let mut replayer = ProofReplayer::new();
496        let result = replayer.replay(&path).expect("replay");
497        assert_eq!(result, VerificationResult::Valid);
498        assert_eq!(replayer.stats().total_steps, 3);
499        assert_eq!(replayer.stats().axiom_count, 2);
500        assert_eq!(replayer.stats().inference_count, 1);
501        assert_eq!(replayer.stats().total_premises, 2);
502
503        let _ = std::fs::remove_file(&path);
504    }
505
506    #[test]
507    fn test_empty_log_is_error() {
508        let path = temp_path("empty_log");
509        let _ = std::fs::remove_file(&path);
510
511        // Write only the header, no records.
512        {
513            let mut logger = ProofLogger::create(&path).expect("create");
514            logger.close().expect("close");
515        }
516
517        let result = ProofReplayer::replay_from_file(&path);
518        assert!(matches!(result, Err(ProofError::EmptyLog)));
519
520        let _ = std::fs::remove_file(&path);
521    }
522
523    #[test]
524    fn test_bad_magic() {
525        let path = temp_path("bad_magic");
526        let _ = std::fs::remove_file(&path);
527
528        std::fs::write(
529            &path,
530            b"BADMAGIC\x01\x00\x00\x00\x00\x00\x00\x00\
531                                 \x00\x00\x00\x00\x00\x00\x00\x00\
532                                 \x00\x00\x00\x00\x00\x00\x00\x00",
533        )
534        .expect("write");
535
536        let err = ProofReplayer::replay_from_file(&path).expect_err("should fail");
537        assert!(matches!(err, ProofError::InvalidMagic { .. }));
538
539        let _ = std::fs::remove_file(&path);
540    }
541
542    #[test]
543    fn test_verification_result_display() {
544        assert_eq!(VerificationResult::Valid.to_string(), "valid");
545        assert_eq!(
546            VerificationResult::Invalid("bad step".to_string()).to_string(),
547            "invalid: bad step"
548        );
549        assert_eq!(VerificationResult::Incomplete.to_string(), "incomplete");
550    }
551}