Skip to content

Commit 69fa842

Browse files
levineuwirthclaude
andcommitted
Alethe walker: multi-literal resolution
Fourth slice of the Lean-side Alethe walker. The earlier `resolution` only handled the binary ¬-elimination shape (two single-literal premises → False). This PR makes it the genuine n-ary clausal resolution cvc5 emits. **Clause machinery** (`ProofBroker/Alethe.lean`): * The walker state now carries `(proof, clause-literal-list)` per step, not just the proof — resolution needs each clause's literal list for pivot finding and `Or`-injection positions. * `negateLit` / `isNotForm` — Sexp-level literal negation (involutive: `a`,`b` complementary iff `negateLit a == b`). * `injectLit` — given a proof of `target[idx]`, build a proof of `⋁target` by the right `Or.inl`/`Or.inr` chain. * `casesClause` — case-analyse `⋁lits` with `Or.elim`, calling a per-disjunct handler. * `binaryResolve` — finds the complementary pivot pair between two clauses, computes the resolvent `R = (A∖p) ++ (B∖p)`, and builds `proof : ⋁R` as a nested `Or.elim` cascade: the pivot disjunct of A case-splits B and closes the complementary pair with `False.elim`; every non-pivot disjunct injects into `R` at its post-erasure position. **`resolution` rule**: an n-ary left-fold of `binaryResolve` over the premise list — each binary step cancels one pivot (cvc5 does not list pivots; the walker finds them). The result carries the computed resolvent; a closing step folds down to the empty clause with `proof : False`. **`or` rule fix**: now forwards the premise's proof term but swaps in the step's *flattened* literal list (`s.clause`). The Prop is unchanged (`clauseTypeOf [(or A B)]` and `clauseTypeOf [A, B]` are the same `∨`), and that re-grouping is exactly what lets `resolution` peel the individual literals. **Test** `alethe_walker_resolution_axiom_free`: a three-clause propositional refutation — `or` flattens the disjunctive assumes, `t2` resolves two 2-literal clauses into the 2-literal resolvent `B ∨ C`, `t3`/`t4` chain to the empty clause. Closes `False` **axiom-free**: the proof term is the reconstructed `Or.elim` cascade, no decision procedure, no axiom. Pinned `[]`. Honest status unchanged: real cvc5 LIA traces still interleave resolution with the equality (`cong`/`refl`/`trans`/`symm`) and boolean-cleanup (`hole`/`rare_rewrite`/`equiv_*`/`implies`/ `and_neg`) clusters — the next PRs — and fall through to omega until those land. Verified: full lean-bridge build (16695 jobs), trust gate, roundtripTest, SDK suite, Python gates — all green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0f48dec commit 69fa842

3 files changed

Lines changed: 213 additions & 70 deletions

File tree

lean-bridge/ProofBroker/Alethe.lean

Lines changed: 180 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -198,24 +198,31 @@ structure WalkerContext where
198198
vars : NameMap Expr
199199
deriving Inhabited
200200

201-
/-- Walker state: map from step id to the `Expr` proving that
202-
step's clause (a disjunction of literals — `False` for the
203-
empty clause). Built up by folding [Step.elab] over the
204-
proof's step list. -/
201+
/-- Walker state: map from step id to `(proof, clause)` — the
202+
`Expr` proving that step's clause, paired with the clause's
203+
literal list (in `Sexp` form). The literal list is needed by
204+
`resolution`: pivot finding compares literals, and the
205+
`Or`-injection that builds the resolvent proof needs each
206+
leftover literal's position. For non-resolution rules the
207+
clause list is just the step's stated `clause`; for
208+
`resolution` it is the computed resolvent (equal as a set to
209+
the stated clause; literal order follows the left-fold). -/
205210
structure WalkerState where
206-
proven : NameMap Expr
211+
proven : NameMap (Expr × List Sexp)
207212
deriving Inhabited
208213

209214
abbrev WalkerM := StateRefT WalkerState MetaM
210215

211-
private def lookupStep (id : String) : WalkerM Expr := do
216+
private def lookupStep (id : String) : WalkerM (Expr × List Sexp) := do
212217
let st ← get
213218
match st.proven.find? (Name.mkSimple id) with
214-
| some e => pure e
219+
| some pc => pure pc
215220
| none => throwError m!"alethe walker: step '{id}' not proven yet"
216221

217-
private def storeStep (id : String) (e : Expr) : WalkerM Unit :=
218-
modify fun st => { st with proven := st.proven.insert (.mkSimple id) e }
222+
private def storeStep (id : String) (e : Expr) (clause : List Sexp)
223+
: WalkerM Unit :=
224+
modify fun st =>
225+
{ st with proven := st.proven.insert (.mkSimple id) (e, clause) }
219226

220227
/-- Build a [WalkerContext] from the current goal's local
221228
context. Every named hypothesis / free var becomes an atom
@@ -353,7 +360,118 @@ partial def andOrChain (ctx : WalkerContext) (conn : Name)
353360
end
354361

355362
/- ----------------------------------------------------------------
356-
Rule elaborators (clausal layer)
363+
Clause manipulation: negation, injection, case analysis
364+
365+
A clause `(cl L1 … Ln)` is the right-associated disjunction
366+
`L1 ∨ … ∨ Ln` (empty → `False`, singleton → `L1`). These
367+
helpers build and destruct that structure as Lean proof
368+
terms; they are the substrate `resolution` is built on.
369+
---------------------------------------------------------------- -/
370+
371+
/-- Sexp-level literal negation: `(not X)` ↦ `X`, `X` ↦
372+
`(not X)`. Involutive, so two literals are complementary iff
373+
`negateLit a == b`. -/
374+
def negateLit : Sexp → Sexp
375+
| .list [.atom "not", x] => x
376+
| other => .list [.atom "not", other]
377+
378+
/-- True iff the literal is syntactically `(not _)`. Picks which
379+
side of a complementary pair carries the `Not` (i.e. is the
380+
function in the `Not p` ≡ `p → False` application). -/
381+
def isNotForm : Sexp → Bool
382+
| .list [.atom "not", _] => true
383+
| _ => false
384+
385+
/-- The clause-as-Prop of a literal list (`(cl …)` semantics:
386+
empty → `False`, singleton → the literal, n-ary → the
387+
right-associated `∨`). -/
388+
def clauseTypeOf (ctx : WalkerContext) (lits : List Sexp) : MetaM Expr :=
389+
sexpToExpr ctx (Sexp.list (.atom "cl" :: lits))
390+
391+
/-- Given a proof of `target[idx]`, build a proof of the whole
392+
clause `⋁target` via the right `Or.inl`/`Or.inr` chain. -/
393+
partial def injectLit (ctx : WalkerContext) (target : List Sexp)
394+
(idx : Nat) (litProof : Expr) : MetaM Expr := do
395+
match target, idx with
396+
| [_], 0 => pure litProof
397+
| (l :: rest), 0 => do
398+
let aTy ← sexpToExpr ctx l
399+
let bTy ← clauseTypeOf ctx rest
400+
mkAppOptM ``Or.inl #[some aTy, some bTy, some litProof]
401+
| (l :: rest), (k + 1) => do
402+
let aTy ← sexpToExpr ctx l
403+
let bTy ← clauseTypeOf ctx rest
404+
let inner ← injectLit ctx rest k litProof
405+
mkAppOptM ``Or.inr #[some aTy, some bTy, some inner]
406+
| _, _ =>
407+
throwError m!"alethe walker: injectLit index {idx} out of \
408+
range for a {target.length}-literal clause"
409+
410+
/-- Case-analyse `clauseProof : ⋁lits`. For each disjunct, call
411+
`handler idx litProof` (which must return a proof of
412+
`resultTy`); chain the cases with `Or.elim`. -/
413+
partial def casesClause (ctx : WalkerContext) (clauseProof : Expr)
414+
(lits : List Sexp) (resultTy : Expr)
415+
(handler : Nat → Expr → MetaM Expr) : MetaM Expr := do
416+
match lits with
417+
| [] => throwError "alethe walker: casesClause on an empty clause"
418+
| [_] => handler 0 clauseProof
419+
| (l :: rest) => do
420+
let lTy ← sexpToExpr ctx l
421+
let restTy ← clauseTypeOf ctx rest
422+
let lamL ← withLocalDeclD `hl lTy fun hl => do
423+
mkLambdaFVars #[hl] (← handler 0 hl)
424+
let lamR ← withLocalDeclD `hr restTy fun hr => do
425+
let body ← casesClause ctx hr rest resultTy
426+
(fun i p => handler (i + 1) p)
427+
mkLambdaFVars #[hr] body
428+
mkAppOptM ``Or.elim
429+
#[some lTy, some restTy, some resultTy,
430+
some clauseProof, some lamL, some lamR]
431+
432+
/-- Binary clausal resolution. `(eA : ⋁A)` and `(eB : ⋁B)` must
433+
contain a complementary literal pair (the pivot). Produces
434+
`(proof, R)` with `R = (A∖pivot) ++ (B∖pivot)` and
435+
`proof : ⋁R`. The proof case-splits `eA`: the pivot disjunct
436+
case-splits `eB` and closes the complementary pair with
437+
`False.elim`; every non-pivot disjunct is injected into `R`
438+
at its post-erasure position. Throws if no pivot exists. -/
439+
def binaryResolve (ctx : WalkerContext)
440+
(eA : Expr) (A : List Sexp) (eB : Expr) (B : List Sexp)
441+
: MetaM (Expr × List Sexp) := do
442+
let pivot? : Option (Nat × Nat) := Id.run do
443+
for i in [0:A.length] do
444+
for j in [0:B.length] do
445+
if negateLit A[i]! == B[j]! then
446+
return some (i, j)
447+
return none
448+
match pivot? with
449+
| none =>
450+
throwError m!"alethe walker: resolution premises share no \
451+
complementary literal — no pivot"
452+
| some (i, j) => do
453+
let aIsNot := isNotForm A[i]!
454+
let R := A.eraseIdx i ++ B.eraseIdx j
455+
let resultTy ← clauseTypeOf ctx R
456+
let aLen1 := A.length - 1
457+
let proof ← casesClause ctx eA A resultTy (fun i' hA' => do
458+
if i' == i then
459+
casesClause ctx eB B resultTy (fun j' hB' => do
460+
if j' == j then
461+
-- complementary pair: the `Not`-side applied to the
462+
-- other gives `False`, eliminated into `resultTy`.
463+
let falseProof :=
464+
if aIsNot then mkApp hA' hB' else mkApp hB' hA'
465+
mkAppOptM ``False.elim #[some resultTy, some falseProof]
466+
else
467+
let pos := aLen1 + (if j' < j then j' else j' - 1)
468+
injectLit ctx R pos hB')
469+
else
470+
injectLit ctx R (if i' < i then i' else i' - 1) hA')
471+
return (proof, R)
472+
473+
/- ----------------------------------------------------------------
474+
Rule elaborators
357475
---------------------------------------------------------------- -/
358476

359477
/-- An Alethe top-level `(assume id L)`: the proof of `L` is the
@@ -379,67 +497,56 @@ private def elabAssumeLiteral (ctx : WalkerContext) (id : String)
379497
throwError m!"alethe walker: assume '{id}' states {stmt}, \
380498
but no local hypothesis matches that type"
381499

382-
/-- `(cl)` from a `false`-rule step. Alethe's `(step _ (cl (not false)) :rule false)`
383-
is the standard premise for the final empty-cl resolution. The
384-
proof of `(cl (not false))` is `(fun (h : False) => h) : ¬False`. -/
385-
private def elabFalseStep (_ctx : WalkerContext) (s : Step) : WalkerM Expr := do
500+
/-- `(cl (not false))` from a `false`-rule step — the standard
501+
premise for the final empty-cl resolution. The proof of
502+
`¬False` (≡ `False → False`) is the identity. -/
503+
private def elabFalseStep (_ctx : WalkerContext) (s : Step)
504+
: WalkerM (Expr × List Sexp) := do
386505
match s.clause with
387506
| [.list [.atom "not", .atom "false"]] =>
388-
-- ¬False ≡ False → False ≡ id : False → False
389507
let falseExpr := mkConst ``False
390-
return .lam .anonymous falseExpr (.bvar 0) .default
508+
pure (.lam .anonymous falseExpr (.bvar 0) .default, s.clause)
391509
| _ =>
392510
throwError m!"alethe walker: 'false' rule expects clause \
393511
(cl (not false)), got {repr s.clause}"
394512

395-
/-- `or` rule: from a single premise that is the n-ary `or` of
396-
literals, produce the same clause. Alethe's `or` rule
397-
decomposes a non-clausal Prop of the form `(or L1 L2 ... Ln)`
398-
into the clause `(cl L1 L2 ... Ln)`. Logically the
399-
proposition is unchanged (clause IS disjunction at the Prop
400-
level under our `cl` encoding), so the elaborator just
401-
returns the premise expression with the same type. -/
402-
private def elabOr (s : Step) : WalkerM Expr := do
513+
/-- `or` rule: restates a single premise — whose one literal is
514+
an n-ary `(or L1 … Ln)` — as the {e clause} `(cl L1 … Ln)`.
515+
The Prop is unchanged (`clauseTypeOf [(or L1 … Ln)]` and
516+
`clauseTypeOf [L1, …, Ln]` are the same right-associated
517+
`∨`), so the elaborator forwards the premise's proof term but
518+
swaps in the step's own flattened literal list — that
519+
re-grouping is exactly what lets `resolution` peel the
520+
individual literals afterwards. -/
521+
private def elabOr (s : Step) : WalkerM (Expr × List Sexp) := do
403522
match s.premises with
404-
| some [p] => lookupStep p
523+
| some [p] => do
524+
let (proof, _) ← lookupStep p
525+
pure (proof, s.clause)
405526
| _ =>
406527
throwError m!"alethe walker: 'or' rule expects exactly one \
407528
premise, got {repr s.premises}"
408529

409-
/-- `resolution`: clausal resolution between premise clauses.
410-
Alethe's `resolution` is multi-way (resolves over multiple
411-
pivot literals in one step). M1.β implements the binary
412-
case (two premises) for clauses with one or two literals
413-
each — the most common shape in the boolean-cleanup steps
414-
that close cvc5 LIA proofs. Larger resolutions surface as
415-
`throwError`, and the omega fallback runs. -/
416-
private def elabResolution (s : Step) : WalkerM Expr := do
530+
/-- `resolution`: n-ary clausal resolution. Alethe's `resolution`
531+
is a left-fold of binary resolutions over the premise list;
532+
each binary step cancels one complementary literal pair
533+
(`binaryResolve` finds the pivot — cvc5 does not list pivots
534+
explicitly). The result `(proof, clause)` carries the
535+
computed resolvent; for a closing step the resolvent is the
536+
empty clause and the proof has type `False`. -/
537+
private def elabResolution (ctx : WalkerContext) (s : Step)
538+
: WalkerM (Expr × List Sexp) := do
417539
match s.premises with
418-
| some [p1, p2] => do
419-
let e1 ← lookupStep p1
420-
let e2 ← lookupStep p2
421-
-- For M1.β we only handle the simplest case: one premise
422-
-- has type `L`, the other has type `¬L`, and we derive
423-
-- `False`. This corresponds to the final closing step of
424-
-- many cvc5 proofs (`(step closing (cl) :rule resolution
425-
-- :premises (some_L some_neg_L))`).
426-
let t1 ← inferType e1
427-
let t2 ← inferType e2
428-
-- Try e1 : L, e2 : L → False
429-
let negT1 := mkApp (mkConst ``Not) t1
430-
if ← isDefEq t2 negT1 then
431-
return mkApp e2 e1
432-
-- Or e2 : L, e1 : L → False
433-
let negT2 := mkApp (mkConst ``Not) t2
434-
if ← isDefEq t1 negT2 then
435-
return mkApp e1 e2
436-
throwError m!"alethe walker: resolution between {t1} and {t2} \
437-
is not the simple ¬-elimination shape M1.β \
438-
handles (multi-literal clauses + pivot \
439-
selection are M1.γ scope)."
540+
| some (p0 :: rest) => do
541+
let (e0, c0) ← lookupStep p0
542+
let mut acc : Expr × List Sexp := (e0, c0)
543+
for pi in rest do
544+
let (ei, ci) ← lookupStep pi
545+
acc ← binaryResolve ctx acc.1 acc.2 ei ci
546+
return acc
440547
| _ =>
441-
throwError m!"alethe walker: 'resolution' M1.β scope handles \
442-
exactly 2 premises, got {repr s.premises}"
548+
throwError m!"alethe walker: 'resolution' needs at least one \
549+
premise, got {repr s.premises}"
443550

444551
/-- LIA-tautology leaf rules (`la_generic`, `la_mult_neg`). The
445552
step's clause is a linear-arithmetic tautology — its negation
@@ -457,7 +564,8 @@ private def elabResolution (s : Step) : WalkerM Expr := do
457564
mvar of the clause type, `falseOrByContra` into a `False`
458565
goal, then the `MetaM`-level `omega` entry over the local
459566
hypotheses. -/
460-
private def elabLiaLeaf (ctx : WalkerContext) (s : Step) : WalkerM Expr := do
567+
private def elabLiaLeaf (ctx : WalkerContext) (s : Step)
568+
: WalkerM (Expr × List Sexp) := do
461569
let clauseProp ← sexpToExpr ctx (Sexp.list (.atom "cl" :: s.clause))
462570
let mvar ← mkFreshExprSyntheticOpaqueMVar clauseProp
463571
match ← mvar.mvarId!.falseOrByContra with
@@ -466,7 +574,7 @@ private def elabLiaLeaf (ctx : WalkerContext) (s : Step) : WalkerM Expr := do
466574
gFalse.withContext do
467575
let hyps := (← getLocalHyps).toList
468576
Lean.Elab.Tactic.Omega.omega hyps gFalse
469-
instantiateMVars mvar
577+
return (← instantiateMVars mvar, s.clause)
470578

471579
/-- Elaborate a single step: dispatch on `rule` to a per-rule
472580
elaborator, store the result under the step's `id`. Unknown
@@ -477,23 +585,23 @@ private def elabLiaLeaf (ctx : WalkerContext) (s : Step) : WalkerM Expr := do
477585
top-level command (`Proof.assumes`), seeded into the walker
478586
state by `walkProof` before the step list is walked. -/
479587
def elabStep (ctx : WalkerContext) (s : Step) : WalkerM Unit := do
480-
let resultmatch s.rule with
588+
let (proof, clause)match s.rule with
481589
| "or" => elabOr s
482-
| "resolution" => elabResolution s
590+
| "resolution" => elabResolution ctx s
483591
| "false" => elabFalseStep ctx s
484592
| "la_generic" => elabLiaLeaf ctx s
485593
| "la_mult_neg" => elabLiaLeaf ctx s
486594
| other =>
487595
throwError m!"alethe walker: rule '{other}' not yet \
488596
supported (current scope: resolution / or / \
489597
false / la_generic / la_mult_neg, plus \
490-
seeded assumes. Subsequent PRs add \
491-
multi-literal resolution and the equality \
492-
(cong / refl / trans) + boolean-cleanup \
493-
(hole / rare_rewrite / equiv_* / implies / \
494-
and_neg) clusters — the omega fallback \
495-
handles full cvc5 traces in the meantime)."
496-
storeStep s.id result
598+
seeded assumes. Subsequent PRs add the \
599+
equality (cong / refl / trans / symm) and \
600+
boolean-cleanup (hole / rare_rewrite / \
601+
equiv_* / implies / and_neg) clusters — the \
602+
omega fallback handles full cvc5 traces in \
603+
the meantime)."
604+
storeStep s.id proof clause
497605

498606
/-- Walk an Alethe proof and return the `Expr` proving the final
499607
step's clause. For a closing alethe-2024 proof the last step
@@ -509,14 +617,16 @@ def walkProof (ctx : WalkerContext) (proof : Proof) : MetaM Expr := do
509617
let walk : WalkerM Expr := do
510618
for a in proof.assumes do
511619
let e ← elabAssumeLiteral ctx a.id a.literal
512-
storeStep a.id e
620+
storeStep a.id e [a.literal]
513621
proof.steps.forM (elabStep ctx)
514622
match proof.steps.getLast? with
515623
| none =>
516624
throwError m!"alethe walker: proof has no steps — nothing to \
517625
conclude (a well-formed alethe-2024 proof ends \
518626
in an empty-clause resolution step)"
519-
| some last => lookupStep last.id
627+
| some last =>
628+
let (e, _) ← lookupStep last.id
629+
pure e
520630
let (result, _) ← walk.run initial
521631
return result
522632

lean-bridge/Test/Tactic.lean

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -494,4 +494,35 @@ theorem alethe_walker_la_generic_disj (x : Int) :
494494

495495
#print axioms alethe_walker_la_generic_disj
496496

497+
/- Alethe walker — multi-literal resolution.
498+
499+
`resolution` is now n-ary: a left-fold of binary resolutions,
500+
each cancelling one complementary literal pair (the walker
501+
finds the pivot; cvc5 does not list it). Intermediate
502+
resolvents can carry several literals — the proof term is the
503+
`Or.elim` cascade `casesClause` builds, with each non-pivot
504+
literal injected into the resolvent by `injectLit`. -/
505+
506+
/-- Three-clause propositional refutation exercising the full
507+
resolution machinery: `or` flattens the disjunctive assumes
508+
into clauses; `t2` resolves two 2-literal clauses into the
509+
2-literal resolvent `B ∨ C`; `t3`/`t4` chain down to the
510+
empty clause. Closes `False` axiom-free — the proof term is
511+
the reconstructed `Or.elim` cascade, no decision procedure. -/
512+
theorem alethe_walker_resolution_axiom_free
513+
(A B C : Prop)
514+
(hAB : A ∨ B) (hAC : ¬A ∨ C) (hnB : ¬B) (hnC : ¬C) : False := by
515+
alethe_walker_test "( \
516+
(assume a0 (or A B)) \
517+
(assume a1 (or (not A) C)) \
518+
(assume a2 (not B)) \
519+
(assume a3 (not C)) \
520+
(step t0 (cl A B) :rule or :premises (a0)) \
521+
(step t1 (cl (not A) C) :rule or :premises (a1)) \
522+
(step t2 (cl B C) :rule resolution :premises (t0 t1)) \
523+
(step t3 (cl C) :rule resolution :premises (t2 a2)) \
524+
(step t4 (cl) :rule resolution :premises (t3 a3)) )"
525+
526+
#print axioms alethe_walker_resolution_axiom_free
527+
497528
end ProofBroker.Test

tools/axiom_allowlist.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@
5656
["propext", "Quot.sound"],
5757
"ProofBroker.Test.alethe_walker_la_generic_disj":
5858
["propext", "Classical.choice", "Quot.sound"],
59+
"ProofBroker.Test.alethe_walker_resolution_axiom_free":
60+
[],
5961
"ProofBroker.TestMathlib.lra_axiom_free":
6062
["propext", "Classical.choice", "Quot.sound"],
6163
"ProofBroker.TestMathlib.pb_term_case_split_axiom_free":

0 commit comments

Comments
 (0)