Transforming & exporting formulas¶
Every AST node carries a uniform set of transformations: normal forms and Horn checks for classical FOL, lambda-calculus reduction, sort relativisation, a small traversal API, and round-tripping exporters/importers for LaTeX, TPTP, Prover9, SMT-LIB, and Graphviz. Most operations are methods on the node; the rest are free functions importable from unicode_fol_kit.
Normal forms¶
to_nnf(), to_pnf(), to_cnf(), and skolemize() operate on classical FOL. They accept FOL, MSFOL, MSFL, and FL inputs — sorts and Łukasiewicz operators are reduced via to_fol() first. (Lambda terms must be beta-reduced and lambda-eliminated beforehand; see below.)
from unicode_fol_kit import MSFLParser, to_nnf, to_pnf, to_cnf, to_dnf, skolemize
p = MSFLParser()
to_nnf(p.parse("P → Q")).to_unicode_str()
# → '¬P ∨ Q' (eliminates → ↔ ⊕, pushes ¬ down to atoms)
to_pnf(p.parse("∀x P(x) ∧ ∃y Q(y)")).to_unicode_str()
# → '∀v0 ∃v1 (P(v0) ∧ Q(v1))' (quantifier prefix + quantifier-free matrix)
to_cnf(p.parse("P ∨ (Q ∧ R)")).to_unicode_str()
# → '(P ∨ Q) ∧ (P ∨ R)' (conjunction of clauses)
to_dnf(p.parse("P ∧ (Q ∨ R)")).to_unicode_str()
# → '(P ∧ Q) ∨ (P ∧ R)' (disjunction of conjunctive clauses)
skolemize(p.parse("∀x ∃y Loves(x, y)")).to_unicode_str()
# → '∀v0 Loves(v0, sk0(v0))' (the existential becomes a Skolem function of x)
to_nnf/to_pnf/to_cnf/to_dnfare equivalence-preserving: the result is logically equivalent to the (classical) input.to_dnfis the dual ofto_cnf— a prenex form whose matrix is a disjunction of conjunctive clauses.skolemizeis satisfiability-preserving (not equivalence-preserving): existentials are replaced by Skolem terms over the universals in scope, and the universal prefix is retained. Bound variables are standardised apart (renamed to freshv0, v1, …); Skolem symbols are namedsk0, sk1, ….
NNF: every connective is eliminated¶
to_nnf rewrites →, ↔, and ⊕ away, collapses double negations, and pushes ¬ down to the atoms (De Morgan over ∧/∨ and over the quantifiers).
to_nnf(p.parse("¬¬P")).to_unicode_str()
# → 'P' (double negation removed)
to_nnf(p.parse("¬(P ∧ Q)")).to_unicode_str()
# → '¬P ∨ ¬Q'
to_nnf(p.parse("¬(∀x P(x) → ∃y Q(y))")).to_unicode_str()
# → '∀x P(x) ∧ ∀y ¬Q(y)'
to_nnf(p.parse("∀x (P(x) ↔ Q(x))")).to_unicode_str()
# → '∀x ((¬P(x) ∨ Q(x)) ∧ (¬Q(x) ∨ P(x)))'
to_nnf(p.parse("P ∧ Q → R ∨ S")).to_unicode_str()
# → '¬P ∨ ¬Q ∨ (R ∨ S)' (De Morgan)
to_nnf(p.parse("¬∀x P(x)")).to_unicode_str()
# → '∃x ¬P(x)' (¬∀ ≡ ∃¬)
to_nnf(p.parse("P ↔ Q")).to_unicode_str()
# → '(¬P ∨ Q) ∧ (¬Q ∨ P)' (biconditional as two implications)
to_nnf(p.parse("P ⊕ Q")).to_unicode_str()
# → '(P ∧ ¬Q) ∨ (¬P ∧ Q)' (exclusive-or expanded)
PNF: quantifiers hoisted, variables standardised apart¶
to_pnf returns a quantifier prefix over a quantifier-free NNF matrix. Bound variables are renamed to fresh v0, v1, … first, so quantifiers can hoist freely even when the source reuses a name across scopes.
to_pnf(p.parse("∀x P(x) → ∃x Q(x)")).to_unicode_str()
# → '∃v0 ∃v1 (¬P(v0) ∨ Q(v1))'
to_pnf(p.parse("∀x P(x) ∨ ∃x Q(x)")).to_unicode_str()
# → '∀v0 ∃v1 (P(v0) ∨ Q(v1))'
to_pnf(p.parse("∃x P(x) → ∀y Q(y)")).to_unicode_str()
# → '∀v0 ∀v1 (¬P(v0) ∨ Q(v1))'
to_pnf(p.parse("∀x ∃y ∀z R(x, y, z) → ∃w S(w)")).to_unicode_str()
# → '∃v0 ∀v1 ∃v2 ∃v3 (¬R(v0, v1, v2) ∨ S(v3))' (note: ∀ flips to ∃ under the → ¬, names made distinct)
CNF / DNF are idempotent¶
Re-running the pass on its own output is a no-op (the result is already in the target shape).
cnf = to_cnf(p.parse("P ∨ (Q ∧ R)"))
to_cnf(cnf).to_unicode_str() == cnf.to_unicode_str() # → True
Skolemization: constants vs. functions¶
An existential with no universal in scope becomes a Skolem constant; one under universals becomes a Skolem function of exactly those universals.
skolemize(p.parse("∃y P(y)")).to_unicode_str()
# → 'P(sk0)' (no universals → Skolem constant)
skolemize(p.parse("∀x ∀y ∃z R(x, y, z)")).to_unicode_str()
# → '∀v0 ∀v1 R(v0, v1, sk0(v0, v1))'
skolemize(p.parse("∃x P(x) ∧ ∃y Q(y)")).to_unicode_str()
# → 'P(sk0) ∧ Q(sk1)'
skolemize(p.parse("∃x (∀y P(x, y) ∧ ∃z Q(x, z))")).to_unicode_str()
# → '∀v1 (P(sk0, v1) ∧ Q(sk0, sk1(v1)))' (z depends on both universals)
Tseitin CNF (equisatisfiable)¶
to_tseitin_cnf() produces an equisatisfiable CNF using the Tseitin/definitional encoding: it introduces fresh auxiliary atoms (ts0, ts1, …) for compound subformulas, so the result grows linearly instead of risking the exponential blow-up of the distributive to_cnf. It is not logically equivalent to the input (the auxiliaries are existentially fresh), but the input is satisfiable iff its Tseitin CNF is. It operates on quantifier-free (propositional / ground) formulas and raises ValueError on quantified input.
from unicode_fol_kit import MSFLParser, to_tseitin_cnf, is_satisfiable
p = MSFLParser()
phi = p.parse("(P ∨ Q) ∧ (¬P ∨ R)")
is_satisfiable(phi) == is_satisfiable(to_tseitin_cnf(phi))
# → True (equisatisfiable; the Tseitin form adds atoms ts0, ts1, ts2)
# The fresh auxiliaries appear alongside the originals:
sorted({a.predicate for a in to_tseitin_cnf(phi).atoms()})
# → ['P', 'Q', 'R', 'ts0', 'ts1', 'ts2']
# Equisatisfiability is preserved on an UNSAT input too:
is_satisfiable(p.parse("P ∧ ¬P")) == is_satisfiable(to_tseitin_cnf(p.parse("P ∧ ¬P")))
# → True (both sides False)
Quantified input is rejected — a single 0-ary auxiliary cannot track a subformula whose truth value varies over the domain:
to_tseitin_cnf(p.parse("∀x P(x)")) # raises ValueError: only quantifier-free input
Horn check¶
is_horn() reports whether a formula’s clausal form consists of Horn clauses — each clause has at most one positive literal. The formula is skolemised, its universal prefix dropped, and the matrix put into CNF before the clauses are checked.
from unicode_fol_kit import MSFLParser, is_horn
p = MSFLParser()
is_horn(p.parse("∀x (Body(x) → Head(x))")) # → True (definite clause)
is_horn(p.parse("P → (Q ∧ R)")) # → True (splits into two Horn clauses)
is_horn(p.parse("P → (Q ∨ R)")) # → False (clause has two positive literals)
is_horn(p.parse("P(a)")) # → True (a unit fact is a Horn clause)
is_horn(p.parse("¬P ∨ ¬Q")) # → True (a goal clause: zero positive literals)
is_horn(p.parse("(P → Q) ∧ (Q → R)")) # → True
is_horn(p.parse("¬Bird(x) ∨ Flies(x)")) # → True
is_horn(p.parse("P ∨ Q")) # → False
is_horn(p.parse("(P → Q) ∧ (R ∨ S)")) # → False
Sort relativisation: to_fol()¶
to_fol() performs a two-phase reduction: it first lowers Łukasiewicz operators to classical ones (to_msfol()), then eliminates sort annotations via relativisation — a sorted ∀x:S φ becomes ∀x (S(x) → φ).
from unicode_fol_kit import MSFLParser, to_fol
p = MSFLParser(many_sorted=True, fuzzy=True)
formula = p.parse("∀x:Human (P(x) ∧ ¬Q(x))")
to_fol(formula).to_unicode_str()
# → '∀x (Human(x) → P(x) ∧ ¬Q(x))'
An existential sort relativises with a conjunction, not an implication — ∃x:S φ becomes ∃x (S(x) ∧ φ):
to_fol(p.parse("∃x:Human P(x)")).to_unicode_str()
# → '∃x (Human(x) ∧ P(x))'
to_fol(p.parse("∀x:Human ∃y:Animal Likes(x, y)")).to_unicode_str()
# → '∀x (Human(x) → ∃y (Animal(y) ∧ Likes(x, y)))'
to_fol(p.parse("∀x:Agent (∃y:Agent Knows(x, y) ∨ Alone(x))")).to_unicode_str()
# → '∀x (Agent(x) → (∃y (Agent(y) ∧ Knows(x, y)) ∨ Alone(x)))'
A sorted constant is written alice:Human (the constant name needs at least two letters, otherwise it lexes as a variable). to_fol drops the annotation to a plain constant; pass include_sort_facts=True to conjoin the membership atoms it implies at the top level:
from unicode_fol_kit import Atom, SortedConstant
knows = Atom("Knows", [SortedConstant("alice", "Human"),
SortedConstant("bob", "Human")])
knows.to_unicode_str()
# → 'Knows(alice:Human, bob:Human)'
to_fol(knows).to_unicode_str()
# → 'Knows(alice, bob)' (annotation stripped)
to_fol(knows, include_sort_facts=True).to_unicode_str()
# → 'Human(alice) ∧ Human(bob) ∧ Knows(alice, bob)' (membership facts conjoined)
Note
This is a classical (Boolean) projection, not a fuzzy-preserving translation. to_msfol() maps both the strong (⊗/⊕) and the weak (∧/∨) Łukasiewicz connectives to the same classical And/Or. On crisp truth values {0, 1} the operators coincide, so the reduction is sound as the two-valued projection — but the genuinely many-valued content is discarded. To compute the real-valued Łukasiewicz degree, use fuzzy_evaluate() or the fuzzy Z3 solver instead.
The normal-form functions above call to_fol() internally, so they accept sorted and fuzzy input directly — e.g. you can skolemize a sorted formula:
from unicode_fol_kit import skolemize
skolemize(p.parse("∀x:Human ∃y:Human Loves(x, y)")).to_unicode_str()
# → '∀v0 (Human(v0) → Human(sk0(v0)) ∧ Loves(v0, sk0(v0)))'
Lambda-calculus operations¶
Every parser mode supports lambda abstraction and application, and parse() applies scope resolution automatically. The reduction functions are free functions on the AST.
Free variables and substitution¶
free_variables(term) returns the set of variables free in a term; the result is a mixed set that may contain both Variable (logical) and LambdaVar (lambda-bound) objects. substitute(term, var, replacement) performs a capture-avoiding substitution.
from unicode_fol_kit import MSFLParser, free_variables, substitute, Variable, Constant
p = MSFLParser()
free_variables(p.parse("λP. P(x)"))
# → {Variable(name='x')} (x is free; P is lambda-bound and does not appear)
free_variables(p.parse("R(x, y) ∧ ∃y Q(y)"))
# → {Variable(name='x'), Variable(name='y')} (the leftmost y is free; the ∃-bound y is not)
substitute(p.parse("P(x)"), Variable("x"), Constant("a")).to_unicode_str()
# → 'P(a)'
Substitution is capture-avoiding: replacing into a formula whose binder would capture the incoming variable renames the binder first.
substitute(p.parse("∀x R(x, y)"), Variable("y"), Variable("x")).to_unicode_str()
# → '∀x_0 R(x_0, x)' (the bound x is renamed so the substituted x stays free)
Beta-, eta-, and beta-eta-reduction¶
beta_reduce reduces to beta-normal form using a normal-order (leftmost-outermost) strategy with full capture-avoiding substitution; it raises ReductionLimitError after 10 000 steps if the term does not normalise. eta_reduce does a single bottom-up pass contracting λp. f(p) → f when p is not free in f. beta_eta_normalize alternates the two to fixpoint (eta-reduction can expose fresh beta-redexes).
from unicode_fol_kit import (
MSFLParser, beta_reduce, eta_reduce, beta_eta_normalize,
LambdaVar, Lambda, Application, Atom, Variable,
)
p = MSFLParser()
beta_reduce(p.parse("(λP. P(x))(λy. Q(y))")).to_unicode_str()
# → 'Q(x)'
# η: λp. f(p) → f (returns the inner Atom node, not the Lambda)
f = Atom("P", [Variable("x")])
eta_reduce(Lambda(LambdaVar("p"), Application(f, LambdaVar("p")))) == f
# → True
beta_reduce(p.parse("(λx. (λy. P(x, y))(a))(b)")).to_unicode_str()
# → 'P(b, a)'
# Higher-order: a predicate parameter F is applied to an argument in the body.
beta_reduce(p.parse("(λF. F(x))(λy. Q(y))")).to_unicode_str()
# → 'Q(x)'
# Partial application: applying only the first of two parameters.
partially = beta_reduce(p.parse("(λx. λy. R(x, y))(a)"))
partially.to_unicode_str()
# → 'λy. R(a, y)'
A curried application reduces one argument at a time; spell currying with nested parentheses, ((g)(c))(d):
beta_reduce(p.parse("((λx. λy. R(x, y))(c))(d)")).to_unicode_str()
# → 'R(c, d)'
beta_reduce(p.parse("((λP. λQ. P ∧ Q)(A))(B)")).to_unicode_str()
# → 'A ∧ B'
A non-normalising term (e.g. the classic Ω = (λx. x x)(λx. x x), built directly since the parser rejects self-application) raises ReductionLimitError rather than looping forever:
from unicode_fol_kit import ReductionLimitError
omega_half = Lambda(LambdaVar("x"), Application(LambdaVar("x"), LambdaVar("x")))
omega = Application(omega_half, omega_half)
beta_reduce(omega) # raises ReductionLimitError after the step limit
Single-step reduction¶
beta_reduce_step(node) contracts exactly one leftmost-outermost redex and returns (new_node, reduced); reduced is False (and the node is returned unchanged) once it is in beta-normal form.
from unicode_fol_kit import beta_reduce_step
term = p.parse("(λP. λx. P(x))(Q)")
step1, did = beta_reduce_step(term)
did # → True
step1.to_unicode_str() # → 'λx. (Q)(x)' (one outer redex contracted)
step2, did2 = beta_reduce_step(step1)
did2 # → False (already in beta-normal form)
Lambda elimination and reduction trace¶
eliminate_lambdas() beta-eta-normalises a term and verifies the result is lambda-free, so it can be fed to the exporters or the normal-form functions (which otherwise reject lambda nodes). A term that is stuck or only partially applied (no further redex but lambdas remain) raises ValueError. reduce_trace() returns the step-by-step reduction sequence, and has_lambdas() tests for residual lambda nodes.
from unicode_fol_kit import MSFLParser, eliminate_lambdas, reduce_trace, has_lambdas
p = MSFLParser()
term = p.parse("(λP. P(x))(Q)")
has_lambdas(term) # → True
reduced = eliminate_lambdas(term)
reduced.to_unicode_str() # → 'Q(x)' (an Atom)
has_lambdas(reduced) # → False
reduced.to_tptp() # → 'q(X)' (now exportable)
steps = reduce_trace(p.parse("(λP. λx. P(x))(Q)"))
len(steps) # → 2 (original, …, normal form)
[s.to_unicode_str() for s in steps]
# → ['(λP. λx. (P)(x))(Q)', 'λx. (Q)(x)']
A term with a free higher-order variable applied to arguments is stuck — no redex remains, yet a lambda construct survives — so eliminate_lambdas raises ValueError:
from unicode_fol_kit import Application, LambdaVar, Variable
eliminate_lambdas(Application(LambdaVar("g"), Variable("x"))) # raises ValueError: not lambda-free
reduce_trace honours a limit and raises ReductionLimitError for a non-normalising term (the Ω from above):
omega_half = Lambda(LambdaVar("x"), Application(LambdaVar("x"), LambdaVar("x")))
omega = Application(omega_half, omega_half)
reduce_trace(omega, limit=5) # raises ReductionLimitError: exceeded 5 steps
Note
eliminate_lambdas fully resolves an Application of an atom to a term (yielding Q(x)); the raw beta_eta_normalize leaves it as an unresolved Application rendered (Q)(x). Use eliminate_lambdas when you need a lambda-free AST ready for export or normal forms.
Traversal and inspection¶
Every node exposes a small traversal API.
from unicode_fol_kit import MSFLParser, Atom
f = MSFLParser().parse("∀x (Human(x) → Mortal(x))")
list(f.walk()) # pre-order: every node and descendant
f.subformulas() # every sub-node that is a formula (terms excluded)
f.atoms() # → [Atom("Human", …), Atom("Mortal", …)]
f.variables() # → {Variable("x")} (free + bound logical variables)
f.count() # → 7 total node count
f.count(Atom) # → 2 nodes of a given type
f.depth() # → 4 tree height (a leaf has depth 1)
subformulas() excludes atomic terms (Variable, Constant, Number, Function, …) — only the formula-valued nodes are returned, in pre-order:
[s.to_unicode_str() for s in f.subformulas()]
# → ['∀x (Human(x) → Mortal(x))', 'Human(x) → Mortal(x)', 'Human(x)', 'Mortal(x)']
[a.to_unicode_str() for a in f.atoms()]
# → ['Human(x)', 'Mortal(x)']
map_children(fn) is the single point of structural recursion: it rebuilds a node, applying fn to each immediate child. The identity rebuild is a deep copy; substituting a child is how the reduction passes are built.
# More traversal examples
f_complex = p.parse("∀x ((P(x) ∧ Q(x)) ∨ (R(x) → S(x)))")
from unicode_fol_kit import And, Or, Implies
f_complex.count() # → 11
f_complex.count(Atom) # → 4
f_complex.count(And) # → 1
f_complex.count(Or) # → 1
f_complex.count(Implies) # → 1
# Listing all atoms with their arguments
f_relational = p.parse("∀x ∀y (Knows(x, y) ∧ ¬Likes(y, x))")
atoms_by_arity = {}
for atom in f_relational.atoms():
arity = len(atom.args)
if arity not in atoms_by_arity:
atoms_by_arity[arity] = []
atoms_by_arity[arity].append(atom.predicate)
# → {2: ['Knows', 'Likes']}
from unicode_fol_kit import Not
g = MSFLParser().parse(“¬(P ∧ Q)”) g.map_children(lambda c: c).to_unicode_str() # → ‘¬(P ∧ Q)’ (structural copy)
## Export
The `to_*` exporters are methods on every node and use the same precedence-aware parenthesisation as `to_unicode_str()`.
```python
f = MSFLParser().parse("∀x (Human(x) → Mortal(x))")
f.to_prover9() # → '(all x (Human(x) -> Mortal(x)))'
f.to_tptp() # → '(![X]: (human(X) => mortal(X)))'
f.to_latex() # → '\\forall x\\, (Human(x) \\rightarrow Mortal(x))'
f.to_dict() # JSON-serialisable dict
# Export with comparisons
comp_formula = p.parse("x < 5 ∧ y ≥ 0")
comp_formula.to_unicode_str()
# → 'x < 5 ∧ y ≥ 0'
comp_formula.to_tptp()
# → '($less(X,5) & $greatereq(Y,0))'
comp_formula.to_prover9()
# → '((X < 5) & (Y >= 0))'
to_latex() renders sorts as \forall x{:}\mathrm{Human}\, and the strong Łukasiewicz operators as \otimes / \oplus; symbol and predicate names are emitted verbatim. TPTP lowercases predicates and uppercases variables per its convention. Second-order formulas reject to_z3 / to_prover9 / to_tptp — round-trip those (and modal / fuzzy) through LaTeX or JSON instead.
# Sorts, modal operators, and strong Łukasiewicz connectives in LaTeX:
MSFLParser(many_sorted=True).parse("∀x:Human P(x)").to_latex()
# → '\\forall x{:}\\mathrm{Human}\\, P(x)'
MSFLParser(modal=True).parse("□P → ◇Q").to_latex()
# → '\\Box P \\rightarrow \\Diamond Q'
MSFLParser(fuzzy=True).parse("P ⊗ Q").to_latex()
# → 'P \\otimes Q'
A second-order formula has no TPTP/Prover9/Z3 image and raises NotImplementedError (use LaTeX or JSON for it):
so = MSFLParser(second_order=True).parse("∀P P(x)")
so.to_tptp() # raises NotImplementedError (no first-order image)
JSON round-trip¶
to_dict() produces a JSON-serialisable dict tagged with _type; the static Node.from_dict() reconstructs the AST. This preserves every node family (modal, second-order, fuzzy, lambda), so it is the portable serialisation of choice.
import json
from unicode_fol_kit import Node
f = MSFLParser().parse("∀x (Human(x) → Mortal(x))")
blob = json.dumps(f.to_dict()) # store / send the string
Node.from_dict(json.loads(blob)) == f # → True (exact AST recovered)
Graphviz¶
to_dot() renders the AST as a Graphviz DOT digraph, mirroring the tree_str() view (the quantifier’s bound variable is folded into its node label). Pipe the output to dot -Tpng to render an image.
print(f.to_dot())
# digraph AST {
# node [shape=box];
# n0 [label="∀ x"];
# n1 [label="→"];
# ...
The full output for the formula above (every node and edge, terms included) is:
print(f.to_dot())
# digraph AST {
# node [shape=box];
# n0 [label="∀ x"];
# n1 [label="→"];
# n2 [label="Atom: Human"];
# n3 [label="Variable: x"];
# n2 -> n3;
# n1 -> n2;
# n4 [label="Atom: Mortal"];
# n5 [label="Variable: x"];
# n4 -> n5;
# n1 -> n4;
# n0 -> n1;
# }
Import¶
The exporters have inverses, so formulas written for the standard tools can be read back into the AST. The importers cover classical FOL — the format the external tools speak. Modal, second-order, and fuzzy formulas round-trip instead through LaTeX or JSON, which preserve every node family.
LaTeX¶
parse_latex() is the inverse of to_latex(): it translates LaTeX commands to the Unicode surface syntax (latex_to_unicode()), then parses. It accepts the exact output of to_latex() as well as common hand-written synonyms (\neg/\lnot, \wedge/\land, \vee/\lor, \to/\rightarrow, …). It takes the same mode flags as MSFLParser.
from unicode_fol_kit import MSFLParser, parse_latex, latex_to_unicode
latex_to_unicode(r"\forall x (P(x) \to Q(x))")
# → '∀ x (P(x) → Q(x))' (spacing preserved; the parser ignores it)
latex_to_unicode(r"P \lor \lnot Q")
# → 'P ∨ ¬ Q' (hand-written \lor / \lnot synonyms accepted)
# Round-trip a sorted formula:
ast = MSFLParser(many_sorted=True).parse("∀x:Human P(x)")
parse_latex(ast.to_latex(), many_sorted=True) == ast # → True
# Modal and second-order round-trip via LaTeX:
m = MSFLParser(modal=True).parse("□P → ◇Q")
parse_latex(m.to_latex(), modal=True) == m # → True
s = MSFLParser(second_order=True).parse("∀P P(x)")
parse_latex(s.to_latex(), second_order=True) == s # → True
Hand-written c_-constants need an escaped underscore (c\_zero or c_{zero}), since a bare _ is LaTeX subscript.
TPTP, Prover9, SMT-LIB¶
from unicode_fol_kit import parse_tptp, parse_tptp_formula, parse_prover9, parse_smtlib
# TPTP: one bare FOF/CNF formula, or a whole problem file
parse_tptp_formula("![X]: (man(X) => mortal(X))").to_unicode_str()
# → '∀x (Man(x) → Mortal(x))'
problem = parse_tptp("""
fof(ax, axiom, ![X]: (man(X) => mortal(X))).
fof(hyp, hypothesis, man(socrates)).
fof(g, conjecture, mortal(socrates)).
""")
[f.role for f in problem]
# → ['axiom', 'hypothesis', 'conjecture']
# Prover9 / LADR — one formula, or a whole input file
parse_prover9("(all X (man(X) -> mortal(X)))").to_unicode_str()
# → '∀x (man(x) → mortal(x))'
from unicode_fol_kit import parse_prover9_problem
recs = parse_prover9_problem("""
formulas(assumptions).
all X (man(X) -> mortal(X)).
man(socrates).
end_of_list.
formulas(goals).
mortal(socrates).
end_of_list.
""")
[(r.role, r.formula.to_unicode_str()) for r in recs]
# → [('assumptions', '∀x (man(x) → mortal(x))'),
# ('assumptions', 'man(socrates)'),
# ('goals', 'mortal(socrates)')]
# SMT-LIB2 text (parsed via Z3's own parser)
[a.to_unicode_str() for a in parse_smtlib("(declare-fun x () Int) (assert (< x (+ x 1)))")]
# → ['x < x + 1']
TPTP —
parse_tptp_formula(s)reads one FOF/CNF formula;parse_tptp(text)reads a whole problem into a list ofTptpFormula(name, role, formula)records;load_tptp(path)reads a.p/.tptpfile.%and/* */comments are ignored. TPTP lowercases predicates, so a predicate is capitalised on import (man→Man); single-quoted atoms ('http___example_org_Thing', the form OWL→FOL dumps use for IRIs) are read with the quotes stripped and\'/\\unescaped;$true/$falseimport as opaque atoms; the typedtff/thfdialects andincludedirectives are out of scope.Prover9 —
parse_prover9(s)reads a single Prover9/LADR formula (a trailing.is accepted);parse_prover9_problem(text)/load_prover9(path)read a whole input file —set/clear/assigndirectives (recognised and skipped),formulas(LIST). … end_of_list.blocks, and bare top-level formulas — into a list ofProver9Formula(role, formula)records (the list name is the role;""for a bare formula;op(...)declarations are out of scope). It followsset(prolog_style_variables)— uppercase/underscore-initial names are variables — matchingto_prover9()’s output, and is case-preserving.Z3 / SMT-LIB —
from_z3(expr)turns az3.ExprRefback into the AST;parse_smtlib(text)/load_smtlib(path)parse SMT-LIB2 and convert every assertion. The conversion is meaning-preserving, not structure-preserving: Z3 maps variables/constants/numbers onto one uninterpreted sort, so a free variable comes back as aConstant(only bound variables survive asVariable);A == Breads asIffon Booleans and=on individuals.
The TPTP and Prover9 readers map the comparison and arithmetic operators back to their glyph atoms/functions, and TPTP’s = / != / dollar-words too:
parse_tptp_formula("X = Y").to_unicode_str() # → 'x = y'
parse_tptp_formula("X != Y").to_unicode_str() # → 'x ≠ y'
parse_tptp_formula("$less(x, y)").to_unicode_str() # → 'x < y'
parse_tptp_formula("p($sum(x, y))").to_unicode_str() # → 'P(x + y)'
parse_prover9("all X (X >= 0 -> p(X)).").to_unicode_str()
# → '∀x (x ≥ 0 → p(x))'
A single-quoted TPTP atom (an IRI) imports with the quotes stripped; the resulting name is not a legal MSFLParser token, so sanitize it before re-parsing (see below):
f = parse_tptp("fof(a, axiom, (![X]: ('http___ex_org_Thing'(X)))).")[0].formula
f.to_unicode_str()
# → '∀x Http___ex_org_Thing(x)' (underscores: not re-parseable as-is)
On the SMT-LIB / Z3 side, = over Booleans reads as Iff, and a free Z3 symbol comes back as a Constant:
[a.to_unicode_str() for a in
parse_smtlib("(declare-const P Bool)(declare-const Q Bool)(assert (= P Q))")]
# → ['P ↔ Q'] (Boolean = is a biconditional)
from unicode_fol_kit import MSFLParser, from_z3
g = MSFLParser().parse("P(x) ∧ Q(x)")
from_z3(g.to_z3()).to_unicode_str()
# → 'P(x) ∧ Q(x)' (note: a free x round-trips through to_z3 as a Constant)
A classical formula survives a full export → re-import through TPTP unchanged (modulo the predicate-case convention):
classical = MSFLParser().parse("∀x (Man(x) → Mortal(x))")
parse_tptp_formula(classical.to_tptp()).to_unicode_str()
# → '∀x (Man(x) → Mortal(x))'
Round-trippable names for imported formulas¶
An external problem file can carry symbol names that the AST accepts but the
MSFLParser lexer does not — IRIs with underscores and mixed case (an OWL→FOL TPTP
dump renders 'http___example_org_Thing' as the predicate Http___example_org_Thing),
single-letter or digit-bearing constants, and so on. Rendering such a node then yields a
string that does not re-parse. sanitize_names(node) (and sanitize_all(nodes) for a
whole problem) rewrites every symbol to a legal token so the render round-trips, leaving
already-legal names untouched and returning a NameMapping that recovers the originals:
from unicode_fol_kit import MSFLParser, parse_tptp, sanitize_names
f = parse_tptp("fof(a, axiom, (![X]: ('http___ex_org_Thing'(X)))).")[0].formula
f.to_unicode_str()
# → '∀x Http___ex_org_Thing(x)' (underscores: NOT a legal predicate token)
s, names = sanitize_names(f)
s.to_unicode_str()
# → '∀x HttpexorgThing(x)' (a legal predicate token)
MSFLParser().parse(s.to_unicode_str()) == s # → True (now it round-trips)
names.reverse()[s.formula.predicate] # → 'Http___ex_org_Thing' (recover the IRI)
Pass the returned NameMapping back in (sanitize_names(node, names)) — or use
sanitize_all — to keep the same IRI mapped to the same token across every formula of a
problem. Predicates become [A-Z]…, functions multi-letter lowercase, constants keep a
legal bare name or take the c_… form, and variables keep [a-z][0-9]* or map to
v0/v1/….
Each symbol class is rewritten to its own legal shape. A single-letter constant takes the
explicit c_… form (so it does not collapse to a variable on re-parse), and a function
that is too short to be a NAME is padded:
from unicode_fol_kit import Atom, Constant, Function, Variable
g = Atom("Likes", [Constant("a"), Function("f1", [Variable("x")])])
s, _ = sanitize_names(g)
s.to_unicode_str()
# → 'Likes(c_a, f1fn(x))' (constant a → c_a; one-letter function f1 → f1fn)
MSFLParser().parse(s.to_unicode_str()) == s # → True
A clean classical formula already uses legal tokens, so it passes through unchanged:
clean = MSFLParser().parse("∀x (Human(x) → Mortal(x))")
sanitize_names(clean)[0] == clean # → True (nothing to rewrite)
End-to-end: import → sanitize → render → re-parse¶
A complete pipeline for an OWL→FOL TPTP dump: read the problem, sanitize the whole batch
with one shared NameMapping (so each IRI maps to the same token everywhere), render to
Unicode, and re-parse — every formula round-trips, and the mapping recovers the IRIs.
from unicode_fol_kit import MSFLParser, parse_tptp, sanitize_all
tptp_text = """
fof(sub, axiom, ![X]: ('ex_Cat'(X) => 'ex_Animal'(X))).
fof(fact, hypothesis, 'ex_Cat'(felix)).
fof(goal, conjecture, 'ex_Animal'(felix)).
"""
records = parse_tptp(tptp_text) # import
formulas = [r.formula for r in records]
sanitized, names = sanitize_all(formulas) # one shared mapping
rendered = [s.to_unicode_str() for s in sanitized] # render
rendered
# → ['∀x (ExCat(x) → ExAnimal(x))', 'ExCat(felix)', 'ExAnimal(felix)']
p = MSFLParser()
reparsed = [p.parse(r) for r in rendered] # re-parse
all(rp == s for rp, s in zip(reparsed, sanitized)) # → True (lossless round-trip)
names.reverse()
# → {'ExCat': 'Ex_Cat', 'ExAnimal': 'Ex_Animal', 'felix': 'felix', 'x': 'x'}