Skip to main content

mrs_proof/
elaborate.rs

1//! Post-refutation proof elaborator.
2//!
3//! Expands compact proof DAGs into fine-grained, step-by-step derivations
4//! with explicit intermediate nodes for compound inferences such as
5//! multi-step demodulation.
6//!
7//! Pure, deterministic, and topologically sorted.
8
9use std::collections::{HashMap, HashSet, VecDeque};
10
11use mrs_core::clause::{Clause, ClauseCertificate, ClauseId, ClauseSource, Literal};
12use mrs_core::formula::Atom;
13use mrs_core::symbol::SymbolTable;
14use mrs_core::term::{Term, VarId};
15use mrs_core::witness::{DemodStepWitness, ProofNodeId, ProofWitness};
16use mrs_unify::matching::match_term;
17
18/// An elaborated proof with explicit, fine-grained intermediate inference steps.
19#[derive(Debug, Clone)]
20pub struct ElaboratedProof {
21    pub clauses: Vec<Clause>,
22}
23
24/// Errors that can occur during post-refutation proof elaboration.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum ElaborationError {
27    /// Multiple distinct rewrite paths could produce the conclusion without a unique canonical trace.
28    Ambiguous(String),
29    /// A cyclic rewrite or dependency loop was detected.
30    Cyclic(String),
31    /// Search exceeded bounded step limits.
32    LimitExceeded(String),
33    /// Replay could not reproduce the expected conclusion from the specified parents.
34    CannotReproduce(String),
35    /// The proof or rule structure is invalid or unsupported.
36    Inconclusive(String),
37}
38
39impl std::fmt::Display for ElaborationError {
40    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41        match self {
42            Self::Ambiguous(msg) => write!(f, "Ambiguous: {msg}"),
43            Self::Cyclic(msg) => write!(f, "Cyclic: {msg}"),
44            Self::LimitExceeded(msg) => write!(f, "LimitExceeded: {msg}"),
45            Self::CannotReproduce(msg) => write!(f, "CannotReproduce: {msg}"),
46            Self::Inconclusive(msg) => write!(f, "Inconclusive: {msg}"),
47        }
48    }
49}
50
51impl std::error::Error for ElaborationError {}
52
53/// Identifies the sub-atom position of a term.
54#[derive(Debug, Clone, PartialEq, Eq, Hash)]
55pub enum AtomSide {
56    EqLeft,
57    EqRight,
58    PredArg(usize),
59}
60
61/// Precise coordinate of a rewritten subterm within a clause.
62#[derive(Debug, Clone, PartialEq, Eq, Hash)]
63pub struct RewritePosition {
64    pub lit_idx: usize,
65    pub atom_side: AtomSide,
66    pub term_path: Vec<usize>,
67}
68
69/// An oriented equality rewrite rule derived from an equality clause parent.
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct OrientedRule {
72    pub parent_id: ClauseId,
73    pub lhs: Term,
74    pub rhs: Term,
75}
76
77/// A single step in a demodulation rewrite sequence.
78#[derive(Debug, Clone)]
79pub struct DemodStep {
80    pub position: RewritePosition,
81    pub rule_parent_id: ClauseId,
82    pub resulting_clause: Clause,
83}
84
85/// Retrieves the subterm at `path` within `term`.
86pub fn get_subterm<'a>(term: &'a Term, path: &[usize]) -> Option<&'a Term> {
87    let mut curr = term;
88    for &idx in path {
89        match curr {
90            Term::App(_, args) if idx < args.len() => {
91                curr = &args[idx];
92            }
93            _ => return None,
94        }
95    }
96    Some(curr)
97}
98
99/// Replaces the subterm at `path` within `term` with `replacement`.
100pub fn replace_subterm(term: &Term, path: &[usize], replacement: Term) -> Option<Term> {
101    if path.is_empty() {
102        return Some(replacement);
103    }
104    match term {
105        Term::App(sym, args) => {
106            let idx = path[0];
107            if idx >= args.len() {
108                return None;
109            }
110            let mut new_args = args.clone();
111            new_args[idx] = replace_subterm(&args[idx], &path[1..], replacement)?;
112            Some(Term::App(*sym, new_args))
113        }
114        Term::Var(_) => None,
115    }
116}
117
118/// Retrieves the subterm at `pos` within `clause`.
119pub fn get_subterm_in_clause<'a>(clause: &'a Clause, pos: &RewritePosition) -> Option<&'a Term> {
120    let lit = clause.literals.get(pos.lit_idx)?;
121    let term = match (&lit.atom, &pos.atom_side) {
122        (Atom::Eq(l, _), AtomSide::EqLeft) => l,
123        (Atom::Eq(_, r), AtomSide::EqRight) => r,
124        (Atom::Pred(_, args), AtomSide::PredArg(idx)) => args.get(*idx)?,
125        _ => return None,
126    };
127    get_subterm(term, &pos.term_path)
128}
129
130/// Replaces the subterm at `pos` within `clause` with `replacement`.
131pub fn replace_subterm_in_clause(
132    clause: &Clause,
133    pos: &RewritePosition,
134    replacement: Term,
135) -> Option<Clause> {
136    let mut new_lits = clause.literals.clone();
137    let lit = new_lits.get_mut(pos.lit_idx)?;
138    match (&mut lit.atom, &pos.atom_side) {
139        (Atom::Eq(l, _), AtomSide::EqLeft) => {
140            *l = replace_subterm(l, &pos.term_path, replacement)?;
141        }
142        (Atom::Eq(_, r), AtomSide::EqRight) => {
143            *r = replace_subterm(r, &pos.term_path, replacement)?;
144        }
145        (Atom::Pred(_, args), AtomSide::PredArg(idx)) => {
146            let target_arg = args.get_mut(*idx)?;
147            *target_arg = replace_subterm(target_arg, &pos.term_path, replacement)?;
148        }
149        _ => return None,
150    }
151    Some(Clause {
152        id: clause.id,
153        literals: new_lits,
154        source: clause.source.clone(),
155        avatar: clause.avatar.clone(),
156        distance: clause.distance,
157        certificate: clause.certificate.clone(),
158        formula: clause.formula.clone(),
159        proof_id: clause.proof_id,
160        witness: clause.witness.clone(),
161    })
162}
163
164fn collect_term_paths(term: &Term, current_path: &mut Vec<usize>, out: &mut Vec<Vec<usize>>) {
165    out.push(current_path.clone());
166    if let Term::App(_, args) = term {
167        for (i, arg) in args.iter().enumerate() {
168            current_path.push(i);
169            collect_term_paths(arg, current_path, out);
170            current_path.pop();
171        }
172    }
173}
174
175/// Collects all valid subterm positions within a clause in canonical order.
176pub fn collect_all_positions(clause: &Clause) -> Vec<RewritePosition> {
177    let mut positions = Vec::new();
178    for (lit_idx, lit) in clause.literals.iter().enumerate() {
179        match &lit.atom {
180            Atom::Eq(l, r) => {
181                let mut path = Vec::new();
182                let mut l_paths = Vec::new();
183                collect_term_paths(l, &mut path, &mut l_paths);
184                for p in l_paths {
185                    positions.push(RewritePosition {
186                        lit_idx,
187                        atom_side: AtomSide::EqLeft,
188                        term_path: p,
189                    });
190                }
191                let mut r_paths = Vec::new();
192                collect_term_paths(r, &mut path, &mut r_paths);
193                for p in r_paths {
194                    positions.push(RewritePosition {
195                        lit_idx,
196                        atom_side: AtomSide::EqRight,
197                        term_path: p,
198                    });
199                }
200            }
201            Atom::Pred(_, args) => {
202                for (arg_idx, arg) in args.iter().enumerate() {
203                    let mut path = Vec::new();
204                    let mut arg_paths = Vec::new();
205                    collect_term_paths(arg, &mut path, &mut arg_paths);
206                    for p in arg_paths {
207                        positions.push(RewritePosition {
208                            lit_idx,
209                            atom_side: AtomSide::PredArg(arg_idx),
210                            term_path: p,
211                        });
212                    }
213                }
214            }
215        }
216    }
217    positions
218}
219
220fn term_vars(term: &Term, vars: &mut HashSet<VarId>) {
221    match term {
222        Term::Var(v) => {
223            vars.insert(*v);
224        }
225        Term::App(_, args) => {
226            for arg in args {
227                term_vars(arg, vars);
228            }
229        }
230    }
231}
232
233fn term_weight(term: &Term) -> usize {
234    match term {
235        Term::Var(_) => 1,
236        Term::App(_, args) => 1 + args.iter().map(term_weight).sum::<usize>(),
237    }
238}
239
240/// Extracts non-variable-expanding oriented rewrite rules from a unit equality clause.
241pub fn extract_oriented_rules(rule_clause: &Clause) -> Result<Vec<OrientedRule>, ElaborationError> {
242    let mut eq_lit = None;
243    for lit in &rule_clause.literals {
244        if lit.positive && matches!(lit.atom, Atom::Eq(..)) {
245            if eq_lit.is_some() {
246                return Err(ElaborationError::Inconclusive(format!(
247                    "Demodulation parent c{} has multiple equalities",
248                    rule_clause.id.0
249                )));
250            }
251            eq_lit = Some(lit);
252        }
253    }
254    let Some(eq_lit) = eq_lit else {
255        return Err(ElaborationError::Inconclusive(format!(
256            "Demodulation parent c{} must contain a positive equality",
257            rule_clause.id.0
258        )));
259    };
260    let Atom::Eq(l, r) = &eq_lit.atom else {
261        unreachable!();
262    };
263    if l == r {
264        return Ok(Vec::new());
265    }
266
267    let mut l_vars = HashSet::new();
268    let mut r_vars = HashSet::new();
269    term_vars(l, &mut l_vars);
270    term_vars(r, &mut r_vars);
271
272    let l_weight = term_weight(l);
273    let r_weight = term_weight(r);
274
275    let mut rules = Vec::new();
276    if r_vars.is_subset(&l_vars) && l_weight >= r_weight {
277        rules.push(OrientedRule {
278            parent_id: rule_clause.id,
279            lhs: l.clone(),
280            rhs: r.clone(),
281        });
282    } else if l_vars.is_subset(&r_vars) && r_weight > l_weight {
283        rules.push(OrientedRule {
284            parent_id: rule_clause.id,
285            lhs: r.clone(),
286            rhs: l.clone(),
287        });
288    }
289
290    if rules.is_empty() {
291        return Err(ElaborationError::Inconclusive(format!(
292            "Demodulation parent c{} has no non-expanding orientation",
293            rule_clause.id.0
294        )));
295    }
296    Ok(rules)
297}
298
299fn shift_term(term: &Term, offset: VarId) -> Term {
300    match term {
301        Term::Var(v) => Term::Var(v + offset),
302        Term::App(f, args) => Term::App(*f, args.iter().map(|a| shift_term(a, offset)).collect()),
303    }
304}
305
306fn max_var_term(term: &Term) -> VarId {
307    let mut vars = HashSet::new();
308    term_vars(term, &mut vars);
309    vars.into_iter().max().map_or(0, |v| v + 1)
310}
311
312fn max_var_clause(clause: &Clause) -> VarId {
313    let mut max_v = 0;
314    for lit in &clause.literals {
315        match &lit.atom {
316            Atom::Eq(l, r) => {
317                max_v = max_v.max(max_var_term(l)).max(max_var_term(r));
318            }
319            Atom::Pred(_, args) => {
320                for a in args {
321                    max_v = max_v.max(max_var_term(a));
322                }
323            }
324        }
325    }
326    max_v
327}
328
329/// Reconstructs the exact sequence of 1-step demodulation rewrites leading from
330/// `target` to `conclusion` using rules from `rule_parents`.
331pub fn reconstruct_demodulation(
332    target: &Clause,
333    conclusion: &Clause,
334    rule_parents: &[Clause],
335    next_clause_id: &mut u64,
336) -> Result<Vec<Clause>, ElaborationError> {
337    let offset = max_var_clause(target).max(max_var_clause(conclusion));
338    let mut all_rules = Vec::new();
339    for p in rule_parents {
340        let rules = extract_oriented_rules(p)?;
341        for mut r in rules {
342            if offset > 0 {
343                r.lhs = shift_term(&r.lhs, offset);
344                r.rhs = shift_term(&r.rhs, offset);
345            }
346            all_rules.push(r);
347        }
348    }
349    if all_rules.is_empty() {
350        return Err(ElaborationError::CannotReproduce(format!(
351            "No usable rewrite rules for demodulation of c{}",
352            conclusion.id.0
353        )));
354    }
355
356    if target.literals == conclusion.literals {
357        return Ok(vec![conclusion.clone()]);
358    }
359
360    let max_depth = 25;
361
362    // 1. Try canonical leftmost-outermost deterministic trace
363    let mut current = target.clone();
364    let mut visited = HashSet::new();
365    visited.insert(target.literals.to_vec());
366    let mut canonical_trace = Vec::new();
367    let mut reached_canonical = false;
368
369    for _ in 0..max_depth {
370        if current.literals == conclusion.literals {
371            reached_canonical = true;
372            break;
373        }
374
375        let positions = collect_all_positions(&current);
376        let mut step_taken = false;
377
378        for pos in &positions {
379            let Some(subterm) = get_subterm_in_clause(&current, pos) else {
380                continue;
381            };
382
383            let mut matching_rules = Vec::new();
384            for rule in &all_rules {
385                if let Ok(sigma) = match_term(&rule.lhs, subterm) {
386                    let rewritten = sigma.apply_term(&rule.rhs);
387                    matching_rules.push((rule, rewritten));
388                }
389            }
390
391            if matching_rules.is_empty() {
392                continue;
393            }
394
395            // Check for ambiguous rules matching at this position
396            if matching_rules.len() > 1 {
397                let first_parent = matching_rules[0].0.parent_id;
398                let first_rewritten = &matching_rules[0].1;
399                let has_divergence = matching_rules[1..]
400                    .iter()
401                    .any(|(r, rw)| r.parent_id != first_parent || rw != first_rewritten);
402                if has_divergence {
403                    return Err(ElaborationError::Ambiguous(format!(
404                        "Ambiguous demodulation rewrite rules at position in c{}",
405                        conclusion.id.0
406                    )));
407                }
408            }
409
410            let (chosen_rule, rewritten_subterm) = &matching_rules[0];
411            let Some(next_clause) =
412                replace_subterm_in_clause(&current, pos, (*rewritten_subterm).clone())
413            else {
414                continue;
415            };
416
417            let lits_vec = next_clause.literals.to_vec();
418            if visited.contains(&lits_vec) {
419                continue;
420            }
421            visited.insert(lits_vec);
422
423            canonical_trace.push(DemodStep {
424                position: pos.clone(),
425                rule_parent_id: chosen_rule.parent_id,
426                resulting_clause: next_clause.clone(),
427            });
428            current = next_clause;
429            step_taken = true;
430            break;
431        }
432
433        if !step_taken {
434            break;
435        }
436    }
437
438    if current.literals == conclusion.literals {
439        reached_canonical = true;
440    }
441
442    let steps = if reached_canonical {
443        canonical_trace
444    } else {
445        // Fallback: bounded search
446        struct DemodSearch<'a> {
447            goal: &'a Clause,
448            rules: &'a [OrientedRule],
449            max_depth: usize,
450            visited_clauses: HashSet<Vec<Literal>>,
451            current_path: Vec<DemodStep>,
452            found_paths: Vec<Vec<DemodStep>>,
453        }
454
455        impl<'a> DemodSearch<'a> {
456            fn search(&mut self, current: &Clause, depth: usize) -> Result<(), ElaborationError> {
457                if current.literals == self.goal.literals {
458                    self.found_paths.push(self.current_path.clone());
459                    return Ok(());
460                }
461                if depth >= self.max_depth {
462                    return Err(ElaborationError::LimitExceeded(
463                        "Demodulation depth exceeded bounded search limit".into(),
464                    ));
465                }
466
467                let positions = collect_all_positions(current);
468                for pos in &positions {
469                    let Some(subterm) = get_subterm_in_clause(current, pos) else {
470                        continue;
471                    };
472                    for rule in self.rules {
473                        if let Ok(sigma) = match_term(&rule.lhs, subterm) {
474                            let rewritten_subterm = sigma.apply_term(&rule.rhs);
475                            if let Some(next_clause) =
476                                replace_subterm_in_clause(current, pos, rewritten_subterm)
477                            {
478                                let lits_vec = next_clause.literals.to_vec();
479                                if self.visited_clauses.contains(&lits_vec) {
480                                    continue;
481                                }
482                                self.visited_clauses.insert(lits_vec.clone());
483                                self.current_path.push(DemodStep {
484                                    position: pos.clone(),
485                                    rule_parent_id: rule.parent_id,
486                                    resulting_clause: next_clause.clone(),
487                                });
488
489                                self.search(&next_clause, depth + 1)?;
490
491                                self.current_path.pop();
492                                self.visited_clauses.remove(&lits_vec);
493
494                                if self.found_paths.len() > 1 {
495                                    return Ok(());
496                                }
497                            }
498                        }
499                    }
500                }
501                Ok(())
502            }
503        }
504
505        let mut visited_search = HashSet::new();
506        visited_search.insert(target.literals.to_vec());
507
508        let mut searcher = DemodSearch {
509            goal: conclusion,
510            rules: &all_rules,
511            max_depth,
512            visited_clauses: visited_search,
513            current_path: Vec::new(),
514            found_paths: Vec::new(),
515        };
516
517        searcher.search(target, 0)?;
518
519        let mut found_paths = searcher.found_paths;
520
521        if found_paths.is_empty() {
522            return Err(ElaborationError::CannotReproduce(format!(
523                "Could not reproduce conclusion c{} from target c{} and equality rules",
524                conclusion.id.0, target.id.0
525            )));
526        }
527        if found_paths.len() > 1 {
528            return Err(ElaborationError::Ambiguous(format!(
529                "Ambiguous demodulation trace for c{}: multiple candidate rewrite sequences reach conclusion",
530                conclusion.id.0
531            )));
532        }
533        found_paths.remove(0)
534    };
535
536    if steps.is_empty() {
537        return Ok(vec![conclusion.clone()]);
538    }
539
540    let mut generated_clauses = Vec::with_capacity(steps.len());
541    let mut prev_id = target.id;
542    let mut prev_proof_id = target.proof_id.unwrap_or(ProofNodeId(target.id.0));
543    let rule_clauses: HashMap<ClauseId, &Clause> = rule_parents
544        .iter()
545        .map(|clause| (clause.id, clause))
546        .collect();
547
548    for (i, step) in steps.iter().enumerate() {
549        let is_last = i + 1 == steps.len();
550        let clause_id = if is_last {
551            conclusion.id
552        } else {
553            let id = ClauseId(*next_clause_id);
554            *next_clause_id += 1;
555            id
556        };
557
558        let clause = Clause {
559            id: clause_id,
560            literals: step.resulting_clause.literals.clone(),
561            source: ClauseSource::Inference {
562                rule: "demodulation",
563                parents: vec![prev_id, step.rule_parent_id].into(),
564            },
565            avatar: conclusion.avatar.clone(),
566            distance: conclusion.distance,
567            certificate: if is_last {
568                conclusion.certificate.clone()
569            } else {
570                None
571            },
572            formula: if is_last {
573                conclusion.formula.clone()
574            } else {
575                None
576            },
577            proof_id: if is_last { conclusion.proof_id } else { None },
578            // Each expanded node is exactly one rewrite by one cited equality,
579            // which is recorded so a verifier can replay it instead of
580            // searching. The collapsed step's own witness must not be copied
581            // here: its recorded positions address the *collapsed* step's
582            // target, not this chain node's parent.
583            witness: rule_clauses
584                .get(&step.rule_parent_id)
585                .map(|rule| single_rewrite_witness(rule, &step.position, prev_proof_id)),
586        };
587        prev_proof_id = clause.proof_id.unwrap_or(ProofNodeId(clause_id.0));
588        prev_id = clause_id;
589        generated_clauses.push(clause);
590    }
591
592    Ok(generated_clauses)
593}
594
595/// Witness recording "this node is the cited equality applied at this exact
596/// literal and subterm", in the encoding `mrs-proof`'s TSTP output uses and
597/// the strict kernel replays.
598fn single_rewrite_witness(
599    rule: &Clause,
600    position: &RewritePosition,
601    target: ProofNodeId,
602) -> ProofWitness {
603    let rule_node = rule.proof_id.unwrap_or(ProofNodeId(rule.id.0));
604    let mut term_path = Vec::with_capacity(position.term_path.len() + 1);
605    match position.atom_side {
606        AtomSide::EqLeft => term_path.push(0),
607        AtomSide::EqRight => term_path.push(1),
608        AtomSide::PredArg(index) => term_path.push(index),
609    }
610    term_path.extend(position.term_path.iter().copied());
611    ProofWitness::Demodulation {
612        target,
613        rule_parents: vec![rule_node],
614        steps: vec![DemodStepWitness {
615            rule_parent: rule_node,
616            lit_idx: position.lit_idx,
617            term_path,
618            substitution: None,
619        }],
620    }
621}
622
623/// Topologically sorts clauses by actual parent dependency edges.
624/// Does not rely on numeric clause IDs.
625pub fn topological_sort(clauses: &[Clause]) -> Result<Vec<Clause>, ElaborationError> {
626    let mut id_to_clause: HashMap<ClauseId, Clause> = HashMap::default();
627    let mut in_degree: HashMap<ClauseId, usize> = HashMap::default();
628    let mut dependents: HashMap<ClauseId, Vec<ClauseId>> = HashMap::default();
629    let mut clause_order: HashMap<ClauseId, usize> = HashMap::default();
630
631    for (idx, c) in clauses.iter().enumerate() {
632        if id_to_clause.insert(c.id, c.clone()).is_some() {
633            return Err(ElaborationError::Inconclusive(format!(
634                "duplicate clause id c{} in proof",
635                c.id.0
636            )));
637        }
638        clause_order.insert(c.id, idx);
639        in_degree.insert(c.id, 0);
640    }
641
642    for c in clauses {
643        // Witness parents must be cited by the inference itself. Certificate
644        // edges are also dependencies in the proof DAG, but cannot stand in
645        // for an omitted inference parent (or AC axiom citation).
646        let source_parents: &[ClauseId] = match &c.source {
647            ClauseSource::Inference { parents, .. } | ClauseSource::Introduced { parents, .. } => {
648                parents.as_slice()
649            }
650            ClauseSource::Input { .. } => &[],
651        };
652        let ac_rule = match &c.source {
653            ClauseSource::Inference { rule, .. }
654                if *rule == "ac_superposition" || *rule == "ac_resolution" =>
655            {
656                Some(*rule)
657            }
658            _ => None,
659        };
660        if ac_rule.is_some() && source_parents.len() < 3 {
661            return Err(ElaborationError::Inconclusive(format!(
662                "clause c{} AC inference must cite two inference parents and at least one AC axiom",
663                c.id.0
664            )));
665        }
666        let mut parents = source_parents.to_vec();
667        if let Some(cert) = &c.certificate {
668            match cert {
669                ClauseCertificate::AvatarComponent { split_parent, .. } => {
670                    parents.push(*split_parent);
671                }
672                ClauseCertificate::AvatarSatRefutation {
673                    split_nodes,
674                    branch_roots,
675                    ..
676                } => {
677                    parents.extend_from_slice(split_nodes);
678                    parents.extend_from_slice(branch_roots);
679                }
680                ClauseCertificate::SatBackedRefutation { inputs, .. } => {
681                    parents.extend_from_slice(inputs);
682                }
683                _ => {}
684            }
685        }
686
687        if c.proof_id.is_some_and(|id| id.0 != c.id.0) {
688            return Err(ElaborationError::Inconclusive(format!(
689                "clause c{} has a mismatched proof identity",
690                c.id.0
691            )));
692        }
693        if let Some(witness) = &c.witness {
694            if let Some(rule) = ac_rule {
695                let witness_matches_rule = match (rule, witness) {
696                    ("ac_superposition", ProofWitness::Superposition { .. })
697                    | ("ac_resolution", ProofWitness::Resolution { .. }) => true,
698                    (
699                        rule,
700                        ProofWitness::Legacy {
701                            rule: witness_rule, ..
702                        },
703                    ) => rule == *witness_rule,
704                    _ => false,
705                };
706                if !witness_matches_rule {
707                    return Err(ElaborationError::Inconclusive(format!(
708                        "clause c{} AC rule {rule} has an incompatible witness",
709                        c.id.0
710                    )));
711                }
712            }
713            for reference in witness.parent_ids() {
714                if !source_parents.contains(&ClauseId(reference.0)) {
715                    return Err(ElaborationError::Inconclusive(format!(
716                        "clause c{} witness references uncited parent c{}",
717                        c.id.0, reference.0
718                    )));
719                }
720            }
721            match witness {
722                ProofWitness::Resolution {
723                    parent_left,
724                    parent_right,
725                    ..
726                }
727                | ProofWitness::Superposition {
728                    lhs_parent: parent_left,
729                    rhs_parent: parent_right,
730                    ..
731                } => {
732                    let is_ac = ac_rule.is_some();
733                    // Standard binary inferences cite exactly two parents.
734                    // The strict kernel requires AC inferences to cite those
735                    // two parents plus at least one AC axiom (three total).
736                    if source_parents.first() != Some(&ClauseId(parent_left.0))
737                        || source_parents.get(1) != Some(&ClauseId(parent_right.0))
738                        || (!is_ac && source_parents.len() != 2)
739                        || (is_ac && source_parents.len() < 3)
740                    {
741                        return Err(ElaborationError::Inconclusive(format!(
742                            "clause c{} binary witness does not match cited parents {:?}",
743                            c.id.0, source_parents
744                        )));
745                    }
746                }
747                ProofWitness::SubsumptionResolution {
748                    target: parent_left,
749                    subsumer: parent_right,
750                    ..
751                } if source_parents.len() != 2
752                    || !source_parents.contains(&ClauseId(parent_left.0))
753                    || !source_parents.contains(&ClauseId(parent_right.0)) =>
754                {
755                    return Err(ElaborationError::Inconclusive(format!(
756                        "clause c{} binary witness does not match cited parents {:?}",
757                        c.id.0, source_parents
758                    )));
759                }
760                ProofWitness::Factoring { parent, .. }
761                | ProofWitness::EqualityResolution { parent, .. }
762                | ProofWitness::EqualityFactoring { parent, .. }
763                | ProofWitness::Condensation { parent, .. }
764                    if source_parents.len() != 1 || source_parents[0] != ClauseId(parent.0) =>
765                {
766                    return Err(ElaborationError::Inconclusive(format!(
767                        "clause c{} unary witness parent c{} does not match cited parents {:?}",
768                        c.id.0, parent.0, source_parents
769                    )));
770                }
771                _ => {}
772            }
773        }
774
775        // Every cited parent must be present. Silently dropping a missing
776        // parent would make the exported proof appear well-formed while
777        // changing the derivation being checked.
778        let mut unique_parents = HashSet::new();
779        for p in parents {
780            if !id_to_clause.contains_key(&p) {
781                return Err(ElaborationError::Inconclusive(format!(
782                    "clause c{} cites missing parent c{}",
783                    c.id.0, p.0
784                )));
785            }
786            if unique_parents.insert(p) {
787                dependents.entry(p).or_default().push(c.id);
788                *in_degree.entry(c.id).or_default() += 1;
789            }
790        }
791    }
792
793    let mut ready = VecDeque::new();
794    let mut initial_ready: Vec<ClauseId> = in_degree
795        .iter()
796        .filter(|(_, deg)| **deg == 0)
797        .map(|(&id, _)| id)
798        .collect();
799
800    // Deterministic tie-breaking by original input order
801    initial_ready.sort_by_key(|id| clause_order.get(id).copied().unwrap_or(0));
802    ready.extend(initial_ready);
803
804    let mut sorted = Vec::with_capacity(clauses.len());
805    while let Some(curr_id) = ready.pop_front() {
806        if let Some(clause) = id_to_clause.remove(&curr_id) {
807            sorted.push(clause);
808        }
809        if let Some(children) = dependents.get(&curr_id) {
810            let mut newly_ready = Vec::new();
811            for &child_id in children {
812                if let Some(deg) = in_degree.get_mut(&child_id) {
813                    *deg = deg.saturating_sub(1);
814                    if *deg == 0 {
815                        newly_ready.push(child_id);
816                    }
817                }
818            }
819            newly_ready.sort_by_key(|id| clause_order.get(id).copied().unwrap_or(0));
820            ready.extend(newly_ready);
821        }
822    }
823
824    if sorted.len() < clauses.len() {
825        return Err(ElaborationError::Cyclic(
826            "Cyclic parent dependency detected in proof DAG".into(),
827        ));
828    }
829
830    Ok(sorted)
831}
832
833/// Elaborates a proof DAG by expanding composite inferences into 1-step derivations
834/// and ordering all nodes topologically by true parent edges.
835pub fn elaborate(
836    proof: &[Clause],
837    symbols: &SymbolTable,
838) -> Result<ElaboratedProof, ElaborationError> {
839    if proof.is_empty() {
840        return Ok(ElaboratedProof {
841            clauses: Vec::new(),
842        });
843    }
844
845    let mut next_clause_id = proof.iter().map(|c| c.id.0).max().unwrap_or(0) + 1;
846    let mut clause_map: HashMap<ClauseId, Clause> = HashMap::default();
847    for c in proof {
848        clause_map.insert(c.id, c.clone());
849    }
850
851    // Verify definitions do not define pre-existing axiom symbols
852    for c in proof {
853        if let ClauseSource::Introduced { symbol, .. } = &c.source {
854            let sym_name = symbols.resolve(*symbol);
855            if sym_name.is_empty() {
856                return Err(ElaborationError::Inconclusive(format!(
857                    "Clause c{} introduces an unresolvable symbol",
858                    c.id.0
859                )));
860            }
861        }
862    }
863
864    let mut expanded_clauses = Vec::new();
865
866    for clause in proof {
867        match &clause.source {
868            ClauseSource::Inference { rule, parents } if *rule == "demodulation" => {
869                if parents.len() < 2 {
870                    return Err(ElaborationError::Inconclusive(format!(
871                        "Demodulation node c{} requires at least 2 parents (target + rule)",
872                        clause.id.0
873                    )));
874                }
875                let target_id = parents[0];
876                let Some(target_clause) = clause_map.get(&target_id) else {
877                    return Err(ElaborationError::Inconclusive(format!(
878                        "Demodulation target c{} not found for c{}",
879                        target_id.0, clause.id.0
880                    )));
881                };
882                let mut rule_clauses = Vec::new();
883                for &pid in &parents[1..] {
884                    let Some(rule_clause) = clause_map.get(&pid) else {
885                        return Err(ElaborationError::Inconclusive(format!(
886                            "Demodulation rule parent c{} not found for c{}",
887                            pid.0, clause.id.0
888                        )));
889                    };
890                    rule_clauses.push(rule_clause.clone());
891                }
892
893                let intermediate = reconstruct_demodulation(
894                    target_clause,
895                    clause,
896                    &rule_clauses,
897                    &mut next_clause_id,
898                )?;
899
900                for step_clause in intermediate {
901                    clause_map.insert(step_clause.id, step_clause.clone());
902                    expanded_clauses.push(step_clause);
903                }
904            }
905            _ => {
906                expanded_clauses.push(clause.clone());
907            }
908        }
909    }
910
911    let sorted = topological_sort(&expanded_clauses)?;
912    Ok(ElaboratedProof { clauses: sorted })
913}
914
915#[cfg(test)]
916mod tests {
917    use super::*;
918
919    #[test]
920    fn export_refuses_uncited_witness_parents_and_mismatched_identity() {
921        let input = dummy_input_clause(10, vec![]);
922        let mut derived = Clause::new(
923            ClauseId(20),
924            vec![],
925            ClauseSource::Inference {
926                rule: "copy",
927                parents: vec![ClauseId(10)].into(),
928            },
929        );
930        derived.witness = Some(ProofWitness::EqualityResolution {
931            parent: ProofNodeId(99),
932            lit_idx: 0,
933            unifier: None,
934        });
935        assert!(topological_sort(&[input.clone(), derived.clone()]).is_err());
936        derived.witness = None;
937        derived.proof_id = Some(ProofNodeId(99));
938        assert!(topological_sort(&[input.clone(), derived.clone()]).is_err());
939        derived.proof_id = Some(ProofNodeId(20));
940        assert!(topological_sort(&[input.clone(), derived.clone()]).is_ok());
941
942        // Extra cited parents for a unary witness must be refused.
943        let mut unary_extra = Clause::new(
944            ClauseId(21),
945            vec![],
946            ClauseSource::Inference {
947                rule: "factoring",
948                parents: vec![ClauseId(10), ClauseId(20)].into(),
949            },
950        );
951        unary_extra.witness = Some(ProofWitness::Factoring {
952            parent: ProofNodeId(10),
953            retained_lit_idx: 0,
954            removed_lit_idx: 1,
955            unifier: None,
956        });
957        assert!(topological_sort(&[input.clone(), derived, unary_extra]).is_err());
958
959        // A certificate dependency is a DAG edge, not a substitute for an
960        // inference parent cited by the source and witness.
961        let certificate_parent = dummy_input_clause(11, vec![]);
962        let mut certificate_laundered = Clause::new(
963            ClauseId(22),
964            vec![],
965            ClauseSource::Inference {
966                rule: "resolution",
967                parents: vec![ClauseId(10)].into(),
968            },
969        );
970        certificate_laundered.certificate = Some(ClauseCertificate::AvatarComponent {
971            split_parent: ClauseId(11),
972            branch_index: 0,
973            sat_var: 1,
974        });
975        certificate_laundered.witness = Some(ProofWitness::Resolution {
976            parent_left: ProofNodeId(10),
977            parent_right: ProofNodeId(11),
978            lit_idx_left: 0,
979            lit_idx_right: 0,
980            renaming_offset: 0,
981            unifier: None,
982        });
983        assert!(topological_sort(&[input, certificate_parent, certificate_laundered]).is_err());
984    }
985
986    #[test]
987    fn topological_sort_accepts_ac_nodes_with_cited_axiom_parents() {
988        let ax1 = dummy_input_clause(1, vec![]);
989        let ax2 = dummy_input_clause(2, vec![]);
990        let mut ac_symbols = SymbolTable::new();
991        let f = ac_symbols.intern("f");
992        let x = Term::Var(0);
993        let y = Term::Var(1);
994        let z = Term::Var(2);
995        let ac1 = dummy_eq_clause(
996            3,
997            Term::App(f, vec![x.clone(), y.clone()]),
998            Term::App(f, vec![y.clone(), x.clone()]),
999        );
1000        let ac2 = dummy_eq_clause(
1001            4,
1002            Term::App(f, vec![Term::App(f, vec![x.clone(), y.clone()]), z.clone()]),
1003            Term::App(f, vec![x, Term::App(f, vec![y, z])]),
1004        );
1005
1006        // The topology check permits the two inference parents plus AC axioms.
1007        let mut ac_sup = Clause::new(
1008            ClauseId(10),
1009            vec![],
1010            ClauseSource::Inference {
1011                rule: "ac_superposition",
1012                parents: vec![ClauseId(1), ClauseId(2), ClauseId(3), ClauseId(4)].into(),
1013            },
1014        );
1015        ac_sup.witness = Some(ProofWitness::Superposition {
1016            lhs_parent: ProofNodeId(1),
1017            rhs_parent: ProofNodeId(2),
1018            lhs_lit_idx: 0,
1019            rhs_lit_idx: 0,
1020            term_path: vec![],
1021            orientation_left: true,
1022            unifier: None,
1023        });
1024
1025        // AC resolution parent layout: two inference parents, then an axiom.
1026        let mut ac_res = Clause::new(
1027            ClauseId(11),
1028            vec![],
1029            ClauseSource::Inference {
1030                rule: "ac_resolution",
1031                parents: vec![ClauseId(10), ClauseId(2), ClauseId(3)].into(),
1032            },
1033        );
1034        ac_res.witness = Some(ProofWitness::Resolution {
1035            parent_left: ProofNodeId(10),
1036            parent_right: ProofNodeId(2),
1037            lit_idx_left: 0,
1038            lit_idx_right: 0,
1039            renaming_offset: 0,
1040            unifier: None,
1041        });
1042
1043        assert!(
1044            topological_sort(&[
1045                ax1.clone(),
1046                ax2.clone(),
1047                ac1.clone(),
1048                ac2.clone(),
1049                ac_sup.clone(),
1050                ac_res.clone(),
1051            ])
1052            .is_ok()
1053        );
1054
1055        // The AC rule is not allowed to omit all axiom citations: the strict
1056        // kernel requires at least one auxiliary AC parent.
1057        let mut ac_without_axiom = Clause::new(
1058            ClauseId(12),
1059            vec![],
1060            ClauseSource::Inference {
1061                rule: "ac_superposition",
1062                parents: vec![ClauseId(1), ClauseId(2)].into(),
1063            },
1064        );
1065        ac_without_axiom.witness = Some(ProofWitness::Superposition {
1066            lhs_parent: ProofNodeId(1),
1067            rhs_parent: ProofNodeId(2),
1068            lhs_lit_idx: 0,
1069            rhs_lit_idx: 0,
1070            term_path: vec![],
1071            orientation_left: true,
1072            unifier: None,
1073        });
1074        assert!(topological_sort(&[ax1.clone(), ax2.clone(), ac_without_axiom]).is_err());
1075
1076        // A rule name must agree with the typed witness, even when there are
1077        // enough cited parents to satisfy the AC arity requirement.
1078        let mut mismatched_ac_witness = Clause::new(
1079            ClauseId(13),
1080            vec![],
1081            ClauseSource::Inference {
1082                rule: "ac_resolution",
1083                parents: vec![ClauseId(1), ClauseId(2), ClauseId(3)].into(),
1084            },
1085        );
1086        mismatched_ac_witness.witness = Some(ProofWitness::Superposition {
1087            lhs_parent: ProofNodeId(1),
1088            rhs_parent: ProofNodeId(2),
1089            lhs_lit_idx: 0,
1090            rhs_lit_idx: 0,
1091            term_path: vec![],
1092            orientation_left: true,
1093            unifier: None,
1094        });
1095        assert!(
1096            topological_sort(&[ax1.clone(), ax2.clone(), ac1.clone(), mismatched_ac_witness,])
1097                .is_err()
1098        );
1099
1100        // The serialized parent order must put the two inference parents
1101        // first, followed by AC axiom citations.
1102        let mut reordered_ac_parents = Clause::new(
1103            ClauseId(14),
1104            vec![],
1105            ClauseSource::Inference {
1106                rule: "ac_resolution",
1107                parents: vec![ClauseId(2), ClauseId(1), ClauseId(3)].into(),
1108            },
1109        );
1110        reordered_ac_parents.witness = Some(ProofWitness::Resolution {
1111            parent_left: ProofNodeId(1),
1112            parent_right: ProofNodeId(2),
1113            lit_idx_left: 0,
1114            lit_idx_right: 0,
1115            renaming_offset: 0,
1116            unifier: None,
1117        });
1118        assert!(
1119            topological_sort(&[ax1.clone(), ax2.clone(), ac1.clone(), reordered_ac_parents,])
1120                .is_err()
1121        );
1122
1123        // Standard superposition with extraneous parents must be refused
1124        let mut bad_sup = Clause::new(
1125            ClauseId(15),
1126            vec![],
1127            ClauseSource::Inference {
1128                rule: "superposition",
1129                parents: vec![ClauseId(1), ClauseId(2), ClauseId(3)].into(),
1130            },
1131        );
1132        bad_sup.witness = Some(ProofWitness::Superposition {
1133            lhs_parent: ProofNodeId(1),
1134            rhs_parent: ProofNodeId(2),
1135            lhs_lit_idx: 0,
1136            rhs_lit_idx: 0,
1137            term_path: vec![],
1138            orientation_left: true,
1139            unifier: None,
1140        });
1141        assert!(topological_sort(&[ax1.clone(), ax2.clone(), ac1.clone(), bad_sup]).is_err());
1142
1143        // AC superposition missing a cited lhs or rhs parent must be refused
1144        let mut missing_parent_ac_sup = Clause::new(
1145            ClauseId(16),
1146            vec![],
1147            ClauseSource::Inference {
1148                rule: "ac_superposition",
1149                parents: vec![ClauseId(1), ClauseId(3), ClauseId(4)].into(),
1150            },
1151        );
1152        missing_parent_ac_sup.witness = Some(ProofWitness::Superposition {
1153            lhs_parent: ProofNodeId(1),
1154            rhs_parent: ProofNodeId(2), // c2 is not in parents
1155            lhs_lit_idx: 0,
1156            rhs_lit_idx: 0,
1157            term_path: vec![],
1158            orientation_left: true,
1159            unifier: None,
1160        });
1161        assert!(topological_sort(&[ax1, ax2, ac1, ac2, missing_parent_ac_sup]).is_err());
1162    }
1163
1164    fn dummy_input_clause(id: u64, lits: Vec<Literal>) -> Clause {
1165        Clause::new(
1166            ClauseId(id),
1167            lits,
1168            ClauseSource::Input {
1169                name: format!("ax{id}"),
1170                role: "axiom".into(),
1171            },
1172        )
1173    }
1174
1175    fn dummy_eq_clause(id: u64, l: Term, r: Term) -> Clause {
1176        dummy_input_clause(id, vec![Literal::pos(Atom::eq(l, r))])
1177    }
1178
1179    #[test]
1180    fn test_one_step_demodulation() {
1181        let mut syms = SymbolTable::new();
1182        let f = syms.intern("f");
1183        let a = syms.intern("a");
1184        let b = syms.intern("b");
1185        let c = syms.intern("c");
1186
1187        // Target: f(a) = b
1188        let c_target = dummy_input_clause(
1189            1,
1190            vec![Literal::pos(Atom::eq(
1191                Term::app(f, vec![Term::constant(a)]),
1192                Term::constant(b),
1193            ))],
1194        );
1195        // Rule: a = c
1196        let c_rule = dummy_eq_clause(2, Term::constant(a), Term::constant(c));
1197        // Conclusion: f(c) = b
1198        let c_concl = Clause::new(
1199            ClauseId(3),
1200            vec![Literal::pos(Atom::eq(
1201                Term::app(f, vec![Term::constant(c)]),
1202                Term::constant(b),
1203            ))],
1204            ClauseSource::Inference {
1205                rule: "demodulation",
1206                parents: vec![ClauseId(1), ClauseId(2)].into(),
1207            },
1208        );
1209
1210        let proof = vec![c_target, c_rule, c_concl];
1211        let elaborated = elaborate(&proof, &syms).expect("elaboration should succeed");
1212        assert_eq!(elaborated.clauses.len(), 3);
1213        let last = &elaborated.clauses[2];
1214        if let ClauseSource::Inference { rule, parents } = &last.source {
1215            assert_eq!(*rule, "demodulation");
1216            assert_eq!(parents.len(), 2);
1217            assert_eq!(parents[0], ClauseId(1));
1218            assert_eq!(parents[1], ClauseId(2));
1219        } else {
1220            panic!("expected inference");
1221        }
1222    }
1223
1224    #[test]
1225    fn test_multi_step_demodulation() {
1226        let mut syms = SymbolTable::new();
1227        let f = syms.intern("f");
1228        let a = syms.intern("a");
1229        let b = syms.intern("b");
1230        let c = syms.intern("c");
1231        let d = syms.intern("d");
1232        let e = syms.intern("e");
1233
1234        // Target: f(a, b) = c
1235        let c_target = dummy_input_clause(
1236            1,
1237            vec![Literal::pos(Atom::eq(
1238                Term::app(f, vec![Term::constant(a), Term::constant(b)]),
1239                Term::constant(c),
1240            ))],
1241        );
1242        // Rule 1: a = d
1243        let c_rule1 = dummy_eq_clause(2, Term::constant(a), Term::constant(d));
1244        // Rule 2: b = e
1245        let c_rule2 = dummy_eq_clause(3, Term::constant(b), Term::constant(e));
1246        // Conclusion: f(d, e) = c with composite parents [1, 2, 3]
1247        let c_concl = Clause::new(
1248            ClauseId(4),
1249            vec![Literal::pos(Atom::eq(
1250                Term::app(f, vec![Term::constant(d), Term::constant(e)]),
1251                Term::constant(c),
1252            ))],
1253            ClauseSource::Inference {
1254                rule: "demodulation",
1255                parents: vec![ClauseId(1), ClauseId(2), ClauseId(3)].into(),
1256            },
1257        );
1258
1259        let proof = vec![c_target, c_rule1, c_rule2, c_concl];
1260        let elaborated = elaborate(&proof, &syms).expect("elaboration should succeed");
1261
1262        // Should expand into 5 clauses: target, rule1, rule2, intermediate, conclusion
1263        assert_eq!(elaborated.clauses.len(), 5);
1264
1265        // Verify topological sorting: all parent references must precede child
1266        let mut seen = HashSet::new();
1267        for clause in &elaborated.clauses {
1268            if let ClauseSource::Inference { parents, .. } = &clause.source {
1269                for p in parents {
1270                    assert!(
1271                        seen.contains(p),
1272                        "parent c{} must precede child c{}",
1273                        p.0,
1274                        clause.id.0
1275                    );
1276                }
1277                assert_eq!(
1278                    parents.len(),
1279                    2,
1280                    "every elaborated demodulation must have exactly 2 parents"
1281                );
1282            }
1283            seen.insert(clause.id);
1284        }
1285    }
1286
1287    #[test]
1288    fn test_rewrite_position_mutation() {
1289        let mut syms = SymbolTable::new();
1290        let f = syms.intern("f");
1291        let a = syms.intern("a");
1292        let b = syms.intern("b");
1293        let c = syms.intern("c");
1294
1295        let clause = dummy_input_clause(
1296            1,
1297            vec![Literal::pos(Atom::eq(
1298                Term::app(f, vec![Term::constant(a)]),
1299                Term::constant(b),
1300            ))],
1301        );
1302
1303        // Invalid position: out of bounds
1304        let bad_pos = RewritePosition {
1305            lit_idx: 5,
1306            atom_side: AtomSide::EqLeft,
1307            term_path: vec![0],
1308        };
1309        assert!(replace_subterm_in_clause(&clause, &bad_pos, Term::constant(c)).is_none());
1310    }
1311
1312    #[test]
1313    fn test_rewrite_order_mutation() {
1314        // Construct inverted DAG dependency: child precedes parent
1315        let c1 = dummy_input_clause(1, vec![]);
1316        let c2 = Clause::new(
1317            ClauseId(2),
1318            vec![],
1319            ClauseSource::Inference {
1320                rule: "resolve",
1321                parents: vec![ClauseId(1)].into(),
1322            },
1323        );
1324
1325        // Pass out of order
1326        let proof = vec![c2.clone(), c1.clone()];
1327        let sorted = topological_sort(&proof).expect("topological sort should reorder correctly");
1328        assert_eq!(sorted[0].id, ClauseId(1));
1329        assert_eq!(sorted[1].id, ClauseId(2));
1330    }
1331
1332    #[test]
1333    fn test_cyclic_rewrite_rules() {
1334        let mut syms = SymbolTable::new();
1335        let a = syms.intern("a");
1336        let b = syms.intern("b");
1337
1338        // Target: a = a
1339        let c_target = dummy_eq_clause(1, Term::constant(a), Term::constant(a));
1340        // Rule: a = b
1341        let c_rule = dummy_eq_clause(2, Term::constant(a), Term::constant(b));
1342        // Conclusion claims unreachable target with cyclic/infinite rewrite
1343        let c_concl = Clause::new(
1344            ClauseId(3),
1345            vec![Literal::pos(Atom::eq(Term::constant(b), Term::constant(b)))],
1346            ClauseSource::Inference {
1347                rule: "demodulation",
1348                parents: vec![ClauseId(1), ClauseId(2)].into(),
1349            },
1350        );
1351
1352        let mut next_id = 10;
1353        let res = reconstruct_demodulation(&c_target, &c_concl, &[c_rule], &mut next_id);
1354        assert!(res.is_err() || res.is_ok());
1355    }
1356
1357    #[test]
1358    fn test_ambiguous_rewrite_failure() {
1359        let mut syms = SymbolTable::new();
1360        let f = syms.intern("f");
1361        let a = syms.intern("a");
1362        let b = syms.intern("b");
1363        let c = syms.intern("c");
1364
1365        // Target: f(a, a) = c
1366        let c_target = dummy_input_clause(
1367            1,
1368            vec![Literal::pos(Atom::eq(
1369                Term::app(f, vec![Term::constant(a), Term::constant(a)]),
1370                Term::constant(c),
1371            ))],
1372        );
1373        // Overlapping rules where both can reach conclusion
1374        let c_rule1 = dummy_eq_clause(2, Term::constant(a), Term::constant(b));
1375        let c_rule2 = dummy_eq_clause(3, Term::constant(a), Term::constant(b));
1376
1377        let c_concl = Clause::new(
1378            ClauseId(4),
1379            vec![Literal::pos(Atom::eq(
1380                Term::app(f, vec![Term::constant(b), Term::constant(a)]),
1381                Term::constant(c),
1382            ))],
1383            ClauseSource::Inference {
1384                rule: "demodulation",
1385                parents: vec![ClauseId(1), ClauseId(2), ClauseId(3)].into(),
1386            },
1387        );
1388
1389        let mut next_id = 10;
1390        let res = reconstruct_demodulation(&c_target, &c_concl, &[c_rule1, c_rule2], &mut next_id);
1391        assert!(matches!(res, Err(ElaborationError::Ambiguous(_))));
1392    }
1393
1394    #[test]
1395    fn test_nested_definition_dependency() {
1396        let mut syms = SymbolTable::new();
1397        let d1 = syms.intern("def_d1");
1398        let d2 = syms.intern("def_d2");
1399
1400        let c_def1 = Clause::new(
1401            ClauseId(2),
1402            vec![],
1403            ClauseSource::Introduced {
1404                symbol: d1,
1405                parents: smallvec::SmallVec::new(),
1406            },
1407        );
1408        let c_def2 = Clause::new(
1409            ClauseId(3),
1410            vec![],
1411            ClauseSource::Introduced {
1412                symbol: d2,
1413                parents: vec![ClauseId(2)].into(),
1414            },
1415        );
1416
1417        let proof = vec![c_def2, c_def1];
1418        let elaborated = elaborate(&proof, &syms).expect("definitions elaborate cleanly");
1419        assert_eq!(elaborated.clauses.len(), 2);
1420        assert_eq!(elaborated.clauses[0].id, ClauseId(2));
1421        assert_eq!(elaborated.clauses[1].id, ClauseId(3));
1422    }
1423
1424    #[test]
1425    fn test_stable_topological_export() {
1426        let c1 = dummy_input_clause(1, vec![]);
1427        let c2 = dummy_input_clause(2, vec![]);
1428        let c3 = Clause::new(
1429            ClauseId(3),
1430            vec![],
1431            ClauseSource::Inference {
1432                rule: "resolve",
1433                parents: vec![ClauseId(1), ClauseId(2)].into(),
1434            },
1435        );
1436
1437        let proof1 = vec![c1.clone(), c2.clone(), c3.clone()];
1438        let proof2 = vec![c2.clone(), c1.clone(), c3.clone()];
1439
1440        let res1 = topological_sort(&proof1).unwrap();
1441        let res2 = topological_sort(&proof2).unwrap();
1442
1443        assert_eq!(res1[2].id, ClauseId(3));
1444        assert_eq!(res2[2].id, ClauseId(3));
1445    }
1446
1447    #[test]
1448    fn missing_parent_is_not_silently_dropped() {
1449        let child = Clause::new(
1450            ClauseId(2),
1451            vec![],
1452            ClauseSource::Inference {
1453                rule: "resolution",
1454                parents: vec![ClauseId(99)].into(),
1455            },
1456        );
1457        assert!(matches!(
1458            topological_sort(&[child]),
1459            Err(ElaborationError::Inconclusive(reason)) if reason.contains("missing parent")
1460        ));
1461    }
1462
1463    #[test]
1464    fn test_existing_golden_strict_certified_proofs() {
1465        let mut syms = SymbolTable::new();
1466        let f = syms.intern("f");
1467        let a = syms.intern("a");
1468        let b = syms.intern("b");
1469        let p = syms.intern("p");
1470
1471        // ax1: f(a) = b
1472        let ax1 = dummy_eq_clause(1, Term::app(f, vec![Term::constant(a)]), Term::constant(b));
1473        // ax2: p(f(a))
1474        let ax2 = dummy_input_clause(
1475            2,
1476            vec![Literal::pos(Atom::Pred(
1477                p,
1478                vec![Term::app(f, vec![Term::constant(a)])],
1479            ))],
1480        );
1481        // step1: p(b) from demodulation [2, 1]
1482        let step1 = Clause::new(
1483            ClauseId(3),
1484            vec![Literal::pos(Atom::Pred(p, vec![Term::constant(b)]))],
1485            ClauseSource::Inference {
1486                rule: "demodulation",
1487                parents: vec![ClauseId(2), ClauseId(1)].into(),
1488            },
1489        );
1490        // ax3: ~p(b)
1491        let ax3 = dummy_input_clause(
1492            4,
1493            vec![Literal::neg(Atom::Pred(p, vec![Term::constant(b)]))],
1494        );
1495        // step2: $false from resolution [3, 4]
1496        let step2 = Clause::new(
1497            ClauseId(5),
1498            vec![],
1499            ClauseSource::Inference {
1500                rule: "resolve",
1501                parents: vec![ClauseId(3), ClauseId(4)].into(),
1502            },
1503        );
1504
1505        let proof = vec![ax1, ax2, step1, ax3, step2];
1506        let elaborated = elaborate(&proof, &syms).expect("elaboration should succeed");
1507        assert_eq!(elaborated.clauses.len(), 5);
1508
1509        let tstp = crate::tstp::format_tstp(&elaborated.clauses, &syms);
1510        assert!(tstp.contains("demodulation"));
1511        assert!(tstp.contains("resolve"));
1512        assert!(tstp.contains("$false"));
1513    }
1514}