Skip to content

Commit 273a9c8

Browse files
oflatt-claudeoflattclaude
authored
Proofs for containers read/constructed in rule bodies (Eval step) (#9)
* Add validators for container reader primitives Give vec-get/vec-length/vec-contains, map-get/map-length/map-contains, and multiset-length/multiset-contains validators so the proof checker can recompute them when these primitives are used to read a matched container in a rule body. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Prove container side conditions with an Eval marker A container built by a primitive in a rule body (e.g. (vec-of e)) has no anchored term-proof. Treat it as a side condition: - Proof normal form lifts a container-producing primitive out of any constructor into its own `(= v (prim ...))` binding, so every container primitive is a standalone side condition. - The proof of a container side condition is a contentless `Eval` marker. - The checker re-evaluates each container side condition against the rule body with the rule's typed primitive validator (`check_side_condition`), binding its output for later facts and checking comparisons — it never inspects the marker. It also rejects rule bodies not in proof normal form (a primitive with a constructor argument, or a container primitive used outside a side condition). A container side condition has no carryable proof, so it cannot be used in an action; such a rule is rejected (ContainerCreatedInQueryUsedInAction). Covered by container-proofs.egg across plain/proof/proof_testing modes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Oliver Flatt <oflatt@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 9a37d27 commit 273a9c8

14 files changed

Lines changed: 632 additions & 74 deletions

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
- Expose `Read::table_size(name)` and `Read::table_sizes()` so read-capable primitives can inspect row counts without raw execution-state access, while avoiding an all-table scan when only one table is needed.
1818
- **`:naive` and `:unsafe-seminaive` rule options** (mutually exclusive). Both compile a rule under the permissive `Read`/`Full` contexts so its RHS can read the database (read-primitives and function-table lookups). `:naive` matches the whole database every iteration; `:unsafe-seminaive` keeps seminaive (delta) matching, which is faster but **unsafe** — an RHS read observes the database mid-iteration, so results can depend on evaluation order. `:unsafe-seminaive` is rejected by the term/proof encoding.
1919
- **Name-indexed e-graph access from primitives and `rust_rule` callbacks (#745, #751).** New `Read` / `Write` capability traits on the state wrappers let primitive bodies and rule callbacks read/write tables by name (`fs.lookup`, `fs.set`, `fs.add`, `fs.union`, `fs.function_entries`, `fs.constructor_enodes`, etc.) instead of through raw `FunctionId` + `&[Value]`; `EGraph::update(|fs| ...)` gives the same surface outside a rule, and `EGraph::function_entries` / `EGraph::constructor_enodes` expose the table scans directly at the top level. Misuse (wrong subtype, wrong arity, unknown table) surfaces as `Error::ApiError`.
20-
- **Container support in the term/proof encoding.** Programs using container sorts (`Vec`, `Set`, `Map`, `MultiSet`, `Pair`) now work under the term/proof encoding (previously rejected). Two user-visible extraction changes: container terms extract in a deterministic, reproducible order rather than value-id order, and maps extract in a flat `(map-of k0 v0 …)` form (new `map-of` constructor) instead of nested `map-insert`s. See `src/proofs/proof_encoding.md`.
20+
- **Container support in the term/proof encoding.** Programs using container sorts (`Vec`, `Set`, `Map`, `MultiSet`, `Pair`) now work under the term/proof encoding (previously rejected), including containers read (`vec-get`, `map-get`, …) or constructed (`vec-of`, `set-of`, …) in a rule body. A container built in the body is a *side condition* with no carryable proof: it is marked with an `Eval` proof step and re-evaluated against the typed rule when checked, so it can be read or matched in the query but not carried into an action (that is rejected). Two user-visible extraction changes: container terms extract in a deterministic, reproducible order rather than value-id order, and maps extract in a flat `(map-of k0 v0 …)` form (new `map-of` constructor) instead of nested `map-insert`s. See `src/proofs/proof_encoding.md`.
2121

2222
## [2.0.0] - 2026-02-11
2323

src/proofs/proof_checker.rs

Lines changed: 181 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,25 @@ use crate::{
2020
};
2121
use thiserror::Error;
2222

23+
/// A side condition is a rule-body fact that is just a container-producing
24+
/// primitive applied to bound variables — `(= v (vec-of e))`, `(= (set-of a)
25+
/// (set-of b))`, etc. Its proof is a bare [`Justification::Eval`] marker and it
26+
/// is verified by re-evaluation in [`ProofStore::check_side_condition`] rather
27+
/// than by matching a premise proposition.
28+
pub(super) fn is_container_side_condition(fact: &ResolvedFact) -> bool {
29+
fn is_container_primitive(expr: &ResolvedExpr) -> bool {
30+
matches!(
31+
expr,
32+
ResolvedExpr::Call(_, ResolvedCall::Primitive(p), _)
33+
if p.output().is_eq_container_sort()
34+
)
35+
}
36+
match fact {
37+
ResolvedFact::Eq(_, lhs, rhs) => is_container_primitive(lhs) || is_container_primitive(rhs),
38+
ResolvedFact::Fact(expr) => is_container_primitive(expr),
39+
}
40+
}
41+
2342
/// Result of processing actions: terms bound to variables and propositions
2443
#[derive(Debug, Clone)]
2544
pub(crate) struct ActionContext {
@@ -448,6 +467,30 @@ pub enum ProofCheckErrorKind {
448467
proof_rhs: TermId,
449468
lhs_ok: bool,
450469
},
470+
/// Eval marker appeared outside a container side condition
471+
#[error("Proof {proof_id}: Eval marker used outside a container side condition")]
472+
EvalOutsideSideCondition { proof_id: ProofId },
473+
/// A container side condition's two sides evaluate to different containers
474+
#[error("Rule '{rule_name}': side condition {fact} does not hold ({lhs:?} != {rhs:?})")]
475+
SideConditionMismatch {
476+
rule_name: String,
477+
fact: String,
478+
lhs: TermId,
479+
rhs: TermId,
480+
},
481+
/// A container side condition has no determined side to evaluate
482+
#[error("Rule '{rule_name}': side condition {fact} has no bound side to evaluate")]
483+
SideConditionUnbound { rule_name: String, fact: String },
484+
/// Not in proof normal form: a primitive has a constructor/function argument
485+
#[error(
486+
"Rule '{rule_name}': primitive argument {arg} is not a variable, literal, or primitive (not in proof normal form)"
487+
)]
488+
PrimitiveNonNormalArg { rule_name: String, arg: String },
489+
/// Not in proof normal form: a container primitive is not a side condition
490+
#[error(
491+
"Rule '{rule_name}': container primitive {prim} appears nested rather than as a side condition (not in proof normal form)"
492+
)]
493+
ContainerPrimitiveNotSideCondition { rule_name: String, prim: String },
451494
}
452495

453496
/// Context needed for proof checking
@@ -580,35 +623,33 @@ impl ProofStore {
580623
.into());
581624
}
582625

583-
// Check each premise proof
584-
let mut premise_propositions = Vec::new();
585-
for &premise_id in premise_proofs {
586-
let prop = self.check_proof_with_context(premise_id, program, ctx)?;
587-
premise_propositions.push(prop);
588-
}
589-
590-
let substitution_with_globals = ctx
626+
let mut working_subst = ctx
591627
.global_bindings
592628
.iter()
593629
.map(|(k, v)| (k.clone(), *v))
594630
.chain(substitution.iter().map(|(k, v)| (k.clone(), *v)))
595631
.collect::<HashMap<_, _>>();
596632

597-
// Verify that premises match the rule body under the substitution
598-
for (fact, prop) in rule.body.iter().zip(premise_propositions.iter()) {
599-
self.check_fact_matches_proposition(
600-
fact,
601-
prop,
602-
&substitution_with_globals,
603-
name,
604-
)?;
633+
// Verify each premise in order. A container side condition carries
634+
// only an `Eval` marker, so re-evaluate it here with the rule's
635+
// typed validator — this binds its output into the substitution for
636+
// later facts. Every other fact is matched against its premise
637+
// proposition.
638+
for (fact, &premise_id) in rule.body.iter().zip(premise_proofs.iter()) {
639+
self.assert_body_proof_normal_form(fact, name)?;
640+
if is_container_side_condition(fact) {
641+
self.check_side_condition(fact, &mut working_subst, name)?;
642+
} else {
643+
let prop = self.check_proof_with_context(premise_id, program, ctx)?;
644+
self.check_fact_matches_proposition(fact, &prop, &working_subst, name)?;
645+
}
605646
}
606647

607648
// Verify that the conclusion matches what the rule produces
608649
self.check_rule_produces_equality(
609650
rule,
610651
substitution,
611-
&substitution_with_globals,
652+
&working_subst,
612653
proof.proposition(),
613654
name,
614655
)?;
@@ -872,6 +913,14 @@ impl ProofStore {
872913
}
873914
Ok(Proposition::new(proof.lhs(), proof.rhs()))
874915
}
916+
917+
Justification::Eval => {
918+
// The `Eval` marker is the proof of a container side condition and
919+
// is checked by re-evaluation in `check_side_condition` (driven by
920+
// the rule body), never on its own. Reaching it here means it
921+
// appeared outside a side condition, which is malformed.
922+
Err(ProofCheckErrorKind::EvalOutsideSideCondition { proof_id }.into())
923+
}
875924
};
876925

877926
// Cache the result
@@ -882,7 +931,121 @@ impl ProofStore {
882931
result
883932
}
884933

885-
/// Check that a fact matches a proposition under a substitution
934+
/// Check a container side condition by re-evaluating it against the rule
935+
/// body, rather than against a premise proposition. An unbound side is the
936+
/// side condition's output and is bound; otherwise both sides must evaluate
937+
/// to the same container. Extends `subst` with any bound output.
938+
fn check_side_condition(
939+
&mut self,
940+
fact: &ResolvedFact,
941+
subst: &mut HashMap<String, TermId>,
942+
rule_name: &str,
943+
) -> Result<(), ProofCheckError> {
944+
let ResolvedFact::Eq(_, lhs, rhs) = fact else {
945+
// `is_container_side_condition` only flags `Eq` facts.
946+
return Ok(());
947+
};
948+
let lhs_val = self.eval_side(lhs, subst, rule_name)?;
949+
let rhs_val = self.eval_side(rhs, subst, rule_name)?;
950+
match (lhs, lhs_val, rhs, rhs_val) {
951+
// One side is an unbound variable: it is the side condition's output.
952+
(ResolvedExpr::Var(_, v), None, _, Some(val))
953+
| (_, Some(val), ResolvedExpr::Var(_, v), None) => {
954+
subst.insert(v.name.clone(), val);
955+
Ok(())
956+
}
957+
// Both sides determined: they must be the same container.
958+
(_, Some(l), _, Some(r)) => {
959+
if l != r {
960+
return Err(ProofCheckErrorKind::SideConditionMismatch {
961+
rule_name: rule_name.to_string(),
962+
fact: format!("{fact}"),
963+
lhs: l,
964+
rhs: r,
965+
}
966+
.into());
967+
}
968+
Ok(())
969+
}
970+
_ => Err(ProofCheckErrorKind::SideConditionUnbound {
971+
rule_name: rule_name.to_string(),
972+
fact: format!("{fact}"),
973+
}
974+
.into()),
975+
}
976+
}
977+
978+
/// Evaluate one side of a side condition: an unbound variable yields `None`
979+
/// (it is an output to bind), anything else is evaluated with the rule's
980+
/// typed primitive validators.
981+
fn eval_side(
982+
&mut self,
983+
expr: &ResolvedExpr,
984+
subst: &HashMap<String, TermId>,
985+
rule_name: &str,
986+
) -> Result<Option<TermId>, ProofCheckError> {
987+
match expr {
988+
ResolvedExpr::Var(_, v) => Ok(subst.get(&v.name).copied()),
989+
_ => {
990+
let (term, _) = eval_expr_with_subst(rule_name, expr, &mut self.term_dag, subst)?;
991+
Ok(Some(term))
992+
}
993+
}
994+
}
995+
996+
/// Reject a rule-body fact that isn't in proof normal form: a primitive must
997+
/// not have a constructor/function argument, and a container-producing
998+
/// primitive must not appear nested (it must be its own side condition).
999+
fn assert_body_proof_normal_form(
1000+
&self,
1001+
fact: &ResolvedFact,
1002+
rule_name: &str,
1003+
) -> Result<(), ProofCheckError> {
1004+
fn check(expr: &ResolvedExpr, rule_name: &str) -> Result<(), ProofCheckError> {
1005+
let ResolvedExpr::Call(_, head, args) = expr else {
1006+
return Ok(());
1007+
};
1008+
for arg in args {
1009+
match head {
1010+
ResolvedCall::Primitive(_)
1011+
if matches!(arg, ResolvedExpr::Call(_, ResolvedCall::Func(_), _)) =>
1012+
{
1013+
return Err(ProofCheckErrorKind::PrimitiveNonNormalArg {
1014+
rule_name: rule_name.to_string(),
1015+
arg: format!("{arg}"),
1016+
}
1017+
.into());
1018+
}
1019+
ResolvedCall::Func(_)
1020+
if matches!(
1021+
arg,
1022+
ResolvedExpr::Call(_, ResolvedCall::Primitive(p), _)
1023+
if p.output().is_eq_container_sort()
1024+
) =>
1025+
{
1026+
return Err(ProofCheckErrorKind::ContainerPrimitiveNotSideCondition {
1027+
rule_name: rule_name.to_string(),
1028+
prim: format!("{arg}"),
1029+
}
1030+
.into());
1031+
}
1032+
_ => {}
1033+
}
1034+
check(arg, rule_name)?;
1035+
}
1036+
Ok(())
1037+
}
1038+
match fact {
1039+
ResolvedFact::Eq(_, lhs, rhs) => {
1040+
check(lhs, rule_name)?;
1041+
check(rhs, rule_name)?;
1042+
}
1043+
ResolvedFact::Fact(expr) => check(expr, rule_name)?,
1044+
}
1045+
Ok(())
1046+
}
1047+
1048+
/// Check that a fact matches a proposition under a substitution
8861049
fn check_fact_matches_proposition(
8871050
&mut self,
8881051
fact: &ResolvedFact,

src/proofs/proof_encoding.rs

Lines changed: 38 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -866,13 +866,34 @@ impl<'a> ProofInstrumentor<'a> {
866866
}
867867
}
868868
ResolvedFact::Eq(_span, left_expr, right_expr) => {
869-
let (v1, p1) = self.instrument_fact_expr(left_expr, res, action_lookups);
870-
let (v2, p2) = self.instrument_fact_expr(right_expr, res, action_lookups);
871-
res.push(format!("(= {v1} {v2})"));
872-
let sym = &self.proof_names().eq_sym_constructor;
873-
let trans = &self.proof_names().eq_trans_constructor;
869+
let is_container_prim = |e: &ResolvedExpr| {
870+
matches!(
871+
e,
872+
ResolvedExpr::Call(_, ResolvedCall::Primitive(p), _)
873+
if p.output().is_eq_container_sort()
874+
)
875+
};
876+
// A container side condition: a fact that builds a container with
877+
// a primitive (`(= xs (vec-of e))`, `(= (set-of a) (set-of b))`).
878+
// The container has no carryable proof — emit just the `Eval`
879+
// marker and the query bindings; the checker re-evaluates the side
880+
// condition (see `check_side_condition`).
881+
if is_container_prim(left_expr) || is_container_prim(right_expr) {
882+
// A container side condition: emit the fact as-is so the
883+
// e-graph computes the container (its arguments are already
884+
// bound). Its proof is the `Eval` marker, checked by
885+
// re-evaluation (see `check_side_condition`).
886+
res.push(fact.to_string());
887+
format!("({})", self.proof_names().eval_constructor)
888+
} else {
889+
let (v1, p1) = self.instrument_fact_expr(left_expr, res, action_lookups);
890+
let (v2, p2) = self.instrument_fact_expr(right_expr, res, action_lookups);
891+
res.push(format!("(= {v1} {v2})"));
892+
let sym = &self.proof_names().eq_sym_constructor;
893+
let trans = &self.proof_names().eq_trans_constructor;
874894

875-
format!("({trans} ({sym} {p1}) {p2})",)
895+
format!("({trans} ({sym} {p1}) {p2})",)
896+
}
876897
}
877898
ResolvedFact::Fact(generic_expr) => {
878899
let (_, proof) = self.instrument_fact_expr(generic_expr, res, action_lookups);
@@ -1001,14 +1022,17 @@ impl<'a> ProofInstrumentor<'a> {
10011022

10021023
let proof = if !self.proofs_enabled() {
10031024
"()".to_string()
1004-
} else if specialized_primitive.output().is_eq_sort()
1005-
|| specialized_primitive.output().is_eq_container_sort()
1006-
{
1007-
// An eq-sort/eq-container result is an `App`, not a
1008-
// literal, so a reflexive `Fiat` would be rejected by
1009-
// the checker. Anchor it on the sort's term-proof
1010-
// table (the `<CSort>Proof` reflexive proof for
1011-
// containers), emitted as an action lookup.
1025+
} else if specialized_primitive.output().is_eq_container_sort() {
1026+
// A container computed in the query/rule body has no
1027+
// carryable proof. It only ever appears in a container
1028+
// side condition, whose proof is the `Eval` marker
1029+
// emitted at the fact level (see `instrument_fact`);
1030+
// this per-expression proof is unused.
1031+
"()".to_string()
1032+
} else if specialized_primitive.output().is_eq_sort() {
1033+
// An eq-sort (datatype) result is an existing anchored
1034+
// term (e.g. an identity primitive returning its
1035+
// input); reuse its term-proof, fetched in the action.
10121036
let term_proof_name =
10131037
self.term_proof_name(specialized_primitive.output().name());
10141038
let fresh_proof = self.fresh_var();

0 commit comments

Comments
 (0)