- Status: Implemented, both halves ·
lean/Fol/withFol.satisfiable_of_check,Fol.check_completeandFol.not_entails_of_checkmachine-checked, nosorry, nonative_decide, no Mathlib · checkeroo-folmodel, both targets indefaultTargetsso the CI job's barelake buildcompiles them · Rust half insrc/tptp.rs(sym,smtlib,ladr,checkfmt),src/fol_model.rs(ingestion, two front ends) andsrc/fol_solve.rs(the certifying pipeline), reachable asfol --format smtlib|ladr,fol-modelandonto_fol_model, with three CI legs over z3 and mace4 - Written: 2026-09-14
- Related: decision 0005 (a prover is an oracle, and a translation is a theorem), which this decision is the mirror of; decision 0003 (a rule is data), whose verdict discipline and whose sentence about digests are reused verbatim; decision 0002 (an inference carries a certificate)
Five automated-reasoning families were the goal. Logic programming is lean/OOCert/, description
logics are lean/Dl/ and src/tableaux.rs, first-order theorem proving is src/tptp.rs and
decision 0005. The SAT and SMT family was entirely absent: no SMT-LIB anywhere in the repository
and no solver called.
Decision 0005 settled the hard half of that family before it arrived. A superposition or CDCL(T) refutation cannot be replayed in core Lean, so an automated prover's verdict is an oracle opinion and never a certificate. Taken at face value that rules the whole family out, and taking it at face value would have been wrong.
A refutation and a model are not two answers of the same kind.
A refutation is a proof object in a calculus this repository does not implement. Checking one needs verified unification, a verified ordering, verified redundancy criteria and verified theory lemmas. That is a research project, and decision 0005 is right that nothing short of it earns the word "certified".
A model is a finite object. Checking whether a formula holds in a finite structure is decidable,
the decision procedure is a recursive Bool-valued function, and proving it agrees with the
Prop-valued satisfaction relation is an induction over eleven constructors. lean/Fol/Check.lean
is 130 lines of definitions and proof, and Fol.eval_iff is the whole of it.
So one direction of the SAT/SMT family can be CERTIFIED while the other stays an ORACLE, and the same is true of every model finder. That includes a dead toolchain's live half: Prover9 is unmaintained and its refutations are uncheckable, but Mace4 ships in the same distribution, is a finite model finder, and prints exactly the kind of object this layer can certify.
-
The satisfiability direction is certified, the refutation direction is not, and they never share a word.
Fol.satisfiable_of_checkturns an accepted structure intoSatisfiable Γ. Nothing inlean/Fol/says anything about unsatisfiability in any direction, and the driver's own docstring says so in the negative space, in the shapelean/DlMain.leanuses. -
The headline is a NON-ENTAILMENT, because that is the sentence no prover can produce.
Fol.not_entails_of_checksays that a checked model of¬φ :: Γis a machine-checked proof of¬ Entails Γ φ, under the sameEntailsthatOwlLean.adequacyis stated about. A prover answering "not a theorem" is guessing at worst and reporting a failed search at best. This is the one place where the model direction is strictly STRONGER than what an ATP can offer, rather than merely different, and it is why the layer is worth building at all. -
The producer is untrusted by construction. The checker re-evaluates the original formulas against the structure and reads nothing else. It never trusts the solver's clausification, its Skolemisation, its preprocessing or its choice of encoding. Mace4 clausifies and Skolemises, so its models interpret symbols like
f1(_)andc1thatFol.FinModelhas no slot for; dropping them on the way in is taking the reduct of its structure and needs no lemma here, because the checker re-evaluates formulas that cannot mention a symbol the translation never emitted. The driver reports the dropped names and their count so the reduct is visible rather than silent. -
Four verdict words, and the fourth is the one nobody reaches for. A report carries five fields and they are never collapsed:
"solver_verdict": "sat" | "unsat" | "unknown" what the oracle said "encoding": "unbounded" | "finite(k)" what it was asked "checker_exit": 0 | 1 | 2 | null null means never run "verdict": "model_checked" CERTIFIED. names Fol.satisfiable_of_check | "satisfiable_oracle" sat, no checkable model | "no_model_up_to_size_k" bounded search exhausted. NOT unsatisfiability | "unsatisfiable_oracle" unsat on the UNBOUNDED encoding. Never more | "unknown_oracle" timeout, incomplete, gave up "owl_reading": "not_entailed_under_unproved_translation" | nullThree rules are mechanical.
model_checkedrequireschecker_exit == 0.unsatisfiable_oraclemay be produced ONLY by a run whose emitted problem carried no cardinality constraint of any kind; any bounded run'sunsatisno_model_up_to_size_k.owl_readingis non-null only when the checker itself reportedgoal_negated_presentAND the verdict ismodel_checked, computed from what the checker read back rather than from the Rust side's intention.The first and third are no longer rules a reviewer has to enforce.
verdictis averdict::FolVerdictandowl_readingis averdict::OwlReading, and the certified variant of each carries averdict::Certifiedwhose field is private tosrc/verdict.rs. The only function that returns one isCheckerRun::accepted, the only constructor of aCheckerRunspawns the checker, andacceptedreturnsNoneon any non-zero exit. So a code path that has not runoo-folmodelcannot write either word: it is E0451, a privacy error, andcompile_faildoctests insrc/verdict.rsfail if that stops being true. The second rule is still enforced by reading, because "which question was asked" is a property of the SMT-LIB this side emitted and not of anything the checker hands back.no_model_up_to_size_kexists because the obvious wiring is wrong and takes ten minutes. A finite-domain encoding that returnsunsathas established that no model of that size exists, which is not unsatisfiability and is often not even evidence of it. The formula∀x∃y (r(x,y) ∧ x≠y)isunsatat carrier 1 andsatat carrier 2; a driver that mapped the first tounsatisfiable_oraclewould be reporting a falsehood from correct solver output. -
A solver's
satwhose model the checker REJECTS is not a statement about the ontology. Z3 can answersaton quantified UF through model-based instantiation with a candidate model thatget-valuereports incompletely. The verdict for that issatisfiable_oraclewithchecker_exit: 1and amodel_not_confirmednote. It is neverrejectedas though the ontology were at fault, and it is nevermodel_checked. -
The coverage gate is about ATTRIBUTION and says so.
Fol.coverschecks that every symbol the problem uses is declared by the model file. Soundness holds without it:FinModel's three fields are total, an undeclared symbol is still interpreted, andcheckvalidates the resulting structure from scratch. What the gate buys is that the structure certified is the structure the solver described rather than the solver's completed with defaults. It is reported at exit 1 with"reason":"undeclared_symbol"and"gate":"attribution", it is kept out ofSatisfiable, out of every theorem, and out of the soundness statement. Dressing it up as soundness would be the laundering move this repository keeps finding in other people's work and once in its own. -
The digest binds the two files, and it identifies rather than commits.
model.tsvcarries a digest of the problem the solver was given and the Lean recomputes it fromproblem.tsv. It is taken over the canonical re-serialisation of the PARSED formula list, not over the file bytes, so labels and spacing cannot forge a match and a relabelledgoal_negatedcannot digest the same. FNV-1a 64 is specified inlean/Fol/Parse.leanrather than taken from a library, becauseString.hashis an opaque extern with no specification a Rust writer could target. It is not a cryptographic hash; it exists so two implementations can be compared, not so one can be defended against someone who controls the file. That is decision 0003 item 5, in the same words, for the same reason. -
An empty problem is exit 2. Every structure satisfies the empty formula list, so a certificate over one would report
model_checkedand say nothing. An exporter bug that dropped every formula would otherwise produce a clean green run, which is the exact shape decision 0005 item 7 records happening inside this repository already, when the differential exported from a reasoned store and every conjecture became trivially entailed. -
The footprint is pinned at the MEASURED value, not at the habitual one. Every result in
lean/Fol/is[propext, Quot.sound], and several are[propext]or free of axioms entirely.Classical.choiceappears nowhere in the layer. Pinning the[propext, Classical.choice, Quot.sound]triple that the older layers carry would be looser than the truth and would stop catching anything.lean/Dl/Check.leanalready pinscheckWF_ifftighter than its neighbours, so this is the house habit rather than a new one.
This looks like duplication and it is not, so the reasons are written down here rather than left for someone to re-derive in six months and get wrong.
Dl.Sat : Interp → Concept → Name → Propevaluates at ONE free variable. First-order satisfaction needs an environmentNat → Dom. Every clause ofSat,satandsat_iffchanges signature.Dl.Sat's.ex r cquantifies overI.rext r x, a successor LIST, not over the carrier. Different quantification domain, different recursion, different well-formedness obligation.Dl.Concepthas no equality constructor.src/tptp.rsemitsForm::EqforSameAs,DifferentFrom,OneOfandHasVal.Dl.Axiomis a flat thirteen-constructor enumeration.tptp::Formis recursive withImpand arbitrary binder nesting, andTranslation::background()emits shapes with noDl.Axiomat all.- Copying
Dl.WellFormedwould be a defect rather than a shortcut.DlcarriescextInDomandrextInDombecause∃R.CENUMERATESrext, so an out-of-carrier successor would really be evaluated. Here every quantifier ranges over the carrier TYPE and atoms are only tested, so importing those clauses would makeFol.check_completereject genuine models. The well-formedness predicate is not shared; it is different mathematics. Dl.holds_iffis thirteen axiom cases. The first-order analogue is eleven formula cases with an environment threaded through. No case transfers.
Against that, lean/Dl/Count.lean, the 119-line hand-rolled pigeonhole that is the hardest file in
that directory, has no first-order counterpart at all, because first-order logic has no counting
constructor. The new library is smaller than the one it does not reuse. What IS reused is the
shape: the Interp-style record, the hash-tables-behind-closures parser idiom, the driver's
exit-code convention and its explicitly unverified diagnostic pass, the witness file's
accept/reject/non-triviality triad including its short-bare-names trick, and the #guard_msgs plus
#print axioms pinning. lean/Dl/ is the template, not the library.
- Nothing about unsatisfiability, in any direction, ever.
- The absence of a finite model implies nothing. SHIQ lacks the finite model property, so a
satisfiable ontology can have only infinite models and will never receive a certificate here.
That is a limitation of the method rather than a defect, and it is the honest counterweight to
the asymmetry the first section is built on. A bounded search that finds nothing has established
no_model_up_to_size_kand that is all. - The OWL-level reading rides on two things this layer does not prove.
OwlLean.adequacylives in the sibling project, and the Rust-to-Lean translation correspondence that decision 0005 item 2 states is pinned by tests and NOT proved. The certified verdict is about the formulas inproblem.tsv; the OWL sentence is strictly weaker and carries its own word,not_entailed_under_unproved_translation, which must never be shortened to "not entailed". - That
Fol.Form,Fol.StrucandFol.EntailsareOwlLean.FOL's is a transcription, pinned by tests. It is a far smaller claim thansrc/tptp.rs's, being about twenty lines of inductive types and their semantics rather than a translation, and the two should not be allowed to sound equally load-bearing. - The parser and the file formats are outside the theorem, exactly as in
lean/Dl/Parse.lean. A parse error is exit 2 and never a verdict in either direction. sourceandcardinality_searchare untrusted. They are what the file claims about its own provenance, echoed for a report. Nothing checks them.
A LADR variable letter in the symbol table. LADR's default convention is that a name beginning
with u, v, w, x, y or z is a VARIABLE. A mangler that turns an IRI into w0 therefore
turns a constant into a universally quantified variable. Measured on this machine with
LADR 2009-11A: the input p0(w0). -p0(k0). is echoed by Mace4 in its own CLAUSES FOR SEARCH
block as
formulas(mace4_clauses).
p0(x).
-p0(k0).
end_of_list.
and the search then runs until killed, because the mangled problem is unsatisfiable while the real
one is not. A time limit would report unknown, so the failure is silent. The same file with c0
in place of w0 is echoed unchanged and returns a two-element model at once. The mangler must
therefore never emit an initial letter in {u,v,w,x,y,z}: p0… for unary, r0… for binary,
c0… for constants, and bound variables as x0, x1, … so that they still are variables under the
same convention. The echoed clause block should be parsed and compared, not assumed.
Whitespace in a symbol. bare() in src/tptp.rs strips the angle brackets before an IRI
reaches Term::Const or P1::Cls, so the symbols are raw IRIs and the whitespace refusal in the
checker-format writer is load-bearing rather than belt and braces. A symbol with a space in it
re-parses as a different formula and one with a tab in it re-parses as a different set of fields.
The 13 September 2026 audit found exactly this truncation class silently disabling any SHACL
constraint that mentioned a typed literal. The writer must return an error rather than emit such a
line, and the digest is the second line of defence: a file that splits differently on read-back
canonicalises differently and the digest mismatches.
free was written with List.erase on the binder cases, copied from the design prototype.
List.erase removes the FIRST occurrence only, so free (∀x0. thing(x0) ∧ lit(x0)) came back as
[0] and every formula using a bound variable twice was classified as open. Nothing became
unsound, because a larger free only strengthens the hypothesis of holds_congr. What would have
happened is that check_complete_closed would have stopped applying to essentially every real
problem, and oo-folmodel would have reported closed: false and quoted the weaker rejection
sentence for ever, with no test failing. It was caught by a by decide in lean/Fol/Witness.lean
asserting that the witness problem is a set of sentences, and the fix is filter rather than
erase.
A defect in this repository's own export reporting, of the exact shape this project exists to
catch. Running fol-model --goals over
case-studies/blast-furnace-ironmaking/blast-furnace-ontology.ttl returned a MACHINE-CHECKED
countermodel for five of the nine triples the OWL-RL reasoner derives there. bf:Hanging is
declared owl:Class and also carries bf:hasSeverity bf:HighSeverity; bf:hasSeverity has domain
bf:ProcessState; rdfs2 fires and the engine derives bf:Hanging rdf:type bf:ProcessState. The
exporter, reading bf:Hanging as a class, dropped the assertion — correctly, because
OwlLean/Syntax.lean gives each entity kind its own type and Sig.Ind is disjoint from Sig.Cls,
so the punning is outside the fragment. What was wrong is that it dropped it SILENTLY:
count_out_of_fragment knew six named OWL constructs and nothing about punning, so the report said
exports_a_weaker_axiom_set: false over an export that was weaker than the graph. Fixed by
counting it as assertion on a punned entity, with the reason; the reading itself is unchanged, so
no expected string in fol_translation_correspondence_test.rs moved. Five punned assertions
reported, five certified non-entailments: the loop closes exactly.
And one in the ingestion, caught by the pipeline's own stop-the-line block on its first run.
Z3's (define-fun NAME ((args)) SORT BODY) has the RETURN SORT at index 3 and the body at 4. The
first parser took index 3 for the body and every ingestion failed with z3's model uses "Bool".
Nothing was mis-certified, because the disagreement block is what fires when a model cannot be
turned into a structure, and the verdict was satisfiable_oracle with nothing checked. It is
recorded here because the fix added a sort check whose only job is to make that message name the
symbol.
And a third, in the exit code. fol-model exited non-zero on a stop-the-line only on the
direct CLI path. src/batch.rs decided the exit code from the presence of an "error" key, and a
stop-the-line is not an error — the command ran and answered — so batch exited 0 over it. Batch
is the mode every tool in tools/ uses, so in the one place the gate has to bite it did not. Fixed
by reading the stop_the_line count as well, keyed on the field rather than on the command name so
a later command reporting the same thing inherits it. Measured after the fix: the same ontology
with a deliberately lying z3 on PATH exits 1, and with the real z3 exits 0.
- The digest still identifies rather than commits. FNV-1a is the right tool for comparing two
implementations and the wrong tool for defending against someone who controls the file. It is now
written twice, in
lean/Fol/Parse.leanand intptp::checkfmt, and the two agree on4403d8aaa0c422f7by test on one side and#guardon the other. Neither is a commitment. - The unbounded encoding's models are not ingested. A
satondeclare-sortissatisfiable_oraclewithchecker_exit: null, because Z3 answers there through model-based instantiation and prints a universe block rather than an enumeration. The bounded ladder is the route to a certificate and the unbounded probe is the route tounsatisfiable_oracle; joining them would need a reader for the universe form and a way to tell a complete model from a candidate one. - The ladder starts at 1 and stops at the first model, so the certificates are the SMALLEST
models. Measured over the shipped case studies, every one of them is satisfied at carrier 1,
because none exports an axiom forcing two distinct elements. The certificate is real and the
property is weak. A
--min-domainwould produce more interesting structures and has not been built. - The emitted file asks for
(get-model)unconditionally, so anunsatrun leaves(error "model is not available")in the solver's captured output after the status line. It is harmless —read_satis line-oriented and findsunsaton its own line first, and the exit code is not consulted for Z3 — but a reader ofz3_unbounded.outsees an error next to a correct answer. A second file without theget-modelline, or(set-option :produce-models false)on the probe, would remove it. - Three things are quadratic or repeated and none of them matters yet.
Fol.coversis O(|Γ| × |declarations|) in the Lean; the RustFolProblem::vocabulary()is rebuilt on every call; andfol_model::mace4::parse_entriesmaterialises the remaining block as aStringonce per entry, so reading a model witheentries copies O(e × block) characters. All three are fine at the measured sizes — the largest shipped fixture is 2441 formulas and the whole pipeline runs in 0.47 s — and all three would want fixing an order of magnitude up. lean/Dl/Parse.leanhas an attribution bug of its own, found while reading it for this work and NOT fixed here.Tables.toInterpsetsind := fun a => t.imap.getD a ""and its docstring claims the empty name "is in no domain". That is not guaranteed:parseModelaccepts adomainline with an empty element name, at which point"" ∈ I.dom,indInDompasses, and an individual with noindline silently denotes a real carrier element. It is a provenance defect and not an unsoundness, sinceDl.Satisfiableis existential overInterpand the interpretation produced is a genuine one, sosatisfiable_of_checkModelstill holds. The fix is to refuse an empty element name inparseModel, and it needs its own test.- Decision 0002's reference to a missing record is resolved. It cited a "decision 0004" that did not exist when this was written. One was written independently, under that number and with this title, by an agent working from the same base and unable to see this file. The two stated the same decision; this one is the accurate one and is cited from
src/,lean/and the tests, so 0002 was repointed here and the duplicate removed rather than renumbering a dozen call sites.