Second-order logic¶
MSFLParser(second_order=True) adds quantification over predicate variables — ∀P φ and ∃P φ, where P is an uppercase predicate symbol bound by the quantifier — with full (standard) finite-model semantics and a bounded validity / counter-model search.
Parsing: ∀P / ∃P with inferred arity¶
Select the mode with the second_order=True flag. Object quantifiers keep using lowercase variables, so ∀x is first-order and ∀P is second-order. The bound predicate variable’s arity is inferred from how it is applied in the body: monadic if applied to one argument, n-ary if applied to n, and arity 0 (propositional) if never applied. The arity is recorded on the SecondOrderQuantifier node but is not printed, since it is re-inferred on re-parse.
from unicode_fol_kit import MSFLParser
p = MSFLParser(second_order=True).parse
n = p("∀P P(x)")
type(n).__name__ # → 'SecondOrderQuantifier'
n.predicate # → 'P'
n.arity # → 1 (applied to one argument: monadic)
p("∀R R(x, y)").arity # → 2 (binary)
p("∃P (P ∧ Q)").arity # → 0 (never applied: propositional)
Arity inference in detail¶
The arity is inferred from all applications of the bound predicate in its scope. Arity-0 quantifiers are propositional (the bound variable appears alone, without arguments or as part of a Boolean combination):
# Arity-0 examples: P appears as a proposition, not applied to arguments
p("∃P P").arity # → 0 (P used as a truth value)
p("∃P (P ∧ Q)").arity # → 0 (P combined with Q, no arguments)
p("∃P (¬P → Q)").arity # → 0 (P in negation and implication)
# Arity-1 (monadic): P applied to one argument
p("∀P P(x)").arity # → 1
p("∀P (P(x) ∧ P(y))").arity # → 1 (both applications monadic)
p("∀P ∀x P(x)").arity # → 1
# Arity-2 (binary) and higher
p("∀R R(x, y)").arity # → 2 (applied to two arguments)
p("∃R (R(a, b) ∨ R(c, d))").arity # → 2
p("∀P ∃x ∃y P(x, y)").arity # → 2
The node is a frozen dataclass with the fields type ("∀" / "∃"), predicate, arity, and formula (the body). The repr shows them all; the renderers and the tree printer show the bound predicate with its arity, and to_unicode_str deliberately omits the arity so the parse round-trip stays stable.
n = p("∀P ∃x P(x)")
n.type # → '∀'
n.formula # → Quantifier(type='∃', variable=Variable(name='x'), …)
n.to_unicode_str() # → '∀P ∃x P(x)' (arity not printed)
n.to_latex() # → '\\forall P\\, \\exists x\\, P(x)'
print(n.tree_str())
# ∀ P/1
# └── ∃ x
# └── Atom: P
# └── Variable: x
# The unicode round-trip re-infers the arity, so it is stable:
p(n.to_unicode_str()).arity # → 1
Nesting and shadowing¶
Object quantifiers (lowercase variables) thread normally inside an SO binder, and SO binders nest. The arity is inferred only from applications in scope: an inner ∀P / ∃P over the same name shadows the outer one, so the descent stops there; an inner binder over a different name is descended into.
# Inner ∃P rebinds P, so the outer P's arity counts only the outer P(x): monadic.
shadow = p("∀P (P(x) → ∃P P(x, y))")
shadow.arity # → 1 (outer P: monadic)
shadow.formula.right.arity # → 2 (inner P: binary, independent binding)
# A different inner name (R) does not stop the scan for P:
p("∀P (∃R R(x, y) → P(x))").arity # → 1
# Multiple nested binders with different names:
p("∀P ∃Q ∀x (P(x) → Q(x))").arity # → 1 (P is monadic)
p("∀P ∃Q ∀x (P(x) → Q(x))").formula.arity # → 1 (Q is monadic)
# Nesting without shadowing: P and Q are quantified independently
both = p("∀P (∃Q ∀x (P(x) → Q(x)))")
both.arity # → 1 (outer P)
both.formula.arity # → 1 (inner Q)
Conflicting arities: parse-time errors¶
Applying one bound predicate at two different arities is a parse-time error, ConflictingArityError (a subclass of the parser’s ParsingError, re-exported from unicode_fol_kit.fol.msflparser):
from unicode_fol_kit.fol.msflparser import ConflictingArityError
try:
p("∀P (P(x) ∧ P(x, y))") # raises ConflictingArityError
except ConflictingArityError as e:
print(str(e)[:60])
# → SYNTAX_ERROR: Second-order predicate variable 'P' is applied
The error occurs at parse time, before evaluation, so the formula is never constructed.
First-order export unsupported¶
Second-order formulas reject the first-order export back-ends — to_z3() / to_prover9() / to_tptp() all raise NotImplementedError, because second-order quantification is not first-order and not SMT-expressible. Evaluate them with the finite-model semantics below, or hand them to a HOL prover via to_thf_so / to_isabelle_so.
n = p("∀P P(x)")
try:
n.to_z3() # raises NotImplementedError
except NotImplementedError as e:
print(str(e)[:47])
# → Second-order quantification is not first-order
try:
n.to_prover9() # raises NotImplementedError
except NotImplementedError:
pass
try:
n.to_tptp() # raises NotImplementedError
except NotImplementedError:
pass
Serialisation and round-tripping¶
Serialisation survives the second-order field, so SO ASTs round-trip through to_dict / from_dict (and therefore JSON):
from unicode_fol_kit.fol.nodes import Node
n = p("∀P P(x)")
d = n.to_dict()
d["_type"], d["predicate"], d["arity"] # → ('SecondOrderQuantifier', 'P', 1)
Node.from_dict(d) == n # → True
# Round-trip preserves equivalence
restored = Node.from_dict(d)
restored.to_unicode_str() # → '∀P P(x)'
restored.arity # → 1
Finite-model evaluation: satisfies_so / holds¶
satisfies_so(formula, structure, assignment={}, pred_binding={}) extends classical Tarskian satisfaction with ∀P / ∃P: a second-order quantifier ranges over every relation of its arity on the structure’s finite domain. holds(formula, structure) is the convenience wrapper for a closed sentence (empty assignment and empty predicate binding).
Basic evaluation: closed formulas¶
from unicode_fol_kit import MSFLParser, Structure, holds
p = MSFLParser(second_order=True).parse
universe = Structure(domain={0, 1}) # a bare 2-element domain
holds(p("∃P ∀x P(x)"), universe) # → True (take P = the whole domain)
holds(p("∀P ∃x P(x)"), universe) # → False (take P = ∅, the empty relation)
# Leibniz's identity of indiscernibles is expressible and holds:
holds(p("∀x ∀y (∀P (P(x) ↔ P(y)) → x = y)"), universe) # → True
For ∃P ∀x P(x) on a 2-element domain {0, 1}, there exists a relation (the whole domain itself, interpreted as {(0,), (1,)}), so the formula holds. For ∀P ∃x P(x), take P = ∅ (the empty relation); then ∃x P(x) is false since no element satisfies an empty predicate, refuting the outer universal.
Free object variables: assignment¶
holds is for closed sentences. When the formula has a free object variable, pass an assignment; when it has a free predicate (i.e. one not bound by a ∀P / ∃P in the formula and not given by the structure), pass a pred_binding mapping that name to a relation — a frozenset of argument tuples. Both are threaded immutably through the recursion exactly like a Tarskian assignment.
from unicode_fol_kit import satisfies_so
S = Structure(domain={0, 1})
# free object variable x bound by the assignment; free predicate P bound to {(0,)}:
satisfies_so(p("P(x)"), S, {"x": 0}, {"P": frozenset({(0,)})}) # → True
satisfies_so(p("P(x)"), S, {"x": 1}, {"P": frozenset({(0,)})}) # → False
# holds(f, s) is exactly satisfies_so(f, s, {}, {}):
holds(p("∃P ∀x P(x)"), S) == satisfies_so(p("∃P ∀x P(x)"), S, {}, {}) # → True
# Free variables in the body of a SO quantifier
f = p("∃P P(x)")
satisfies_so(f, S, {"x": 0}, {}) # → True (take P = {(0,)})
satisfies_so(f, S, {"x": 1}, {}) # → True (take P = {(1,)})
Free predicates: structure predicates¶
A free (structure-level) predicate is read from the structure’s predicates tables instead of being quantified. Tables for an arity-k≥1 predicate may be keyed by the bare name or by the (name, arity) pair; the relation is a set of tuples. The SO quantifier then ranges over every relation while the free predicate stays fixed:
# Q is a fixed monadic predicate on the domain; P is second-order-quantified.
S = Structure(domain={0, 1}, predicates={"Q": {(0,)}})
holds(p("∃P ∀x (P(x) ↔ Q(x))"), S) # → True (take P = Q)
# A binary structure relation R, with an object-level SO-free check:
SR = Structure(domain={0, 1}, predicates={("R", 2): {(0, 1), (1, 0)}})
holds(p("∀x ∀y (R(x, y) → R(y, x))"), SR) # → True (R is symmetric here)
# Multiple free predicates in the structure
S_multi = Structure(
domain={0, 1},
predicates={"P": {(0,)}, "Q": {(1,)}}
)
holds(p("∃R ∀x (R(x) ↔ (P(x) ∨ Q(x)))"), S_multi) # → True
Propositional (arity-0) quantifiers¶
Arity-0 (propositional) bound predicates quantify over a truth value: the two “relations” are ∅ (false) and {()} (true). So ∃P P is satisfiable on any non-empty domain and ∀P P never holds:
holds(p("∃P P"), Structure(domain={0})) # → True (take P = true)
holds(p("∀P P"), Structure(domain={0})) # → False (take P = false)
# Propositional quantifiers combined with object quantifiers
holds(p("∀P ∃x P(x)"), Structure(domain={0, 1})) # → False (P = false refutes)
holds(p("∃P ∀x ¬P(x)"), Structure(domain={0, 1})) # → True (P = false works)
Computational limits: MAX_RELATIONS¶
A ∀P / ∃P over an arity-k predicate on an n-element domain enumerates 2 ** (n ** k) relations — doubly exponential. This is for very small models only (a handful of elements, arity ≤ 2); past secondorder.MAX_RELATIONS (~4.2 million) the evaluator raises a ValueError rather than hang. For example, evaluating a binary ∀R over a 6-element domain would require 2 ** (6 ** 2) relations and is rejected with a clear message.
from unicode_fol_kit.semantics import secondorder
secondorder.MAX_RELATIONS # → 4194304 (2 ** 22)
big = Structure(domain=set(range(6))) # 6 elements
try:
holds(p("∀R R(x, y)"), big) # raises ValueError: 2 ** (6 ** 2) relations
except ValueError as e:
print(str(e)[:46])
# → Second-order quantifier ∀R/2 over a 6-element
# Safe combinations:
# - Arity 1, up to ~4 million elements (unrealistic)
# - Arity 2, up to ~21 elements (e.g., 21^2 = 441 < 2^22)
# - Arity 3, up to ~12 elements (e.g., 12^3 = 1728 < 2^22)
safe_1 = Structure(domain=set(range(4)))
holds(p("∀P ∀x P(x)"), safe_1) # → fine
safe_2 = Structure(domain=set(range(20)))
holds(p("∀R ∀x ∃y R(x, y)"), safe_2) # → fine
Bounded second-order search (new in 0.9.0)¶
Second-order logic has no complete proof system, and SO validity is not even semi-decidable — so there is no decision procedure and no to_tptp-style hand-off to a prover. The four search functions are instead a bounded finite-model search (the SO analogue of the first-order model finder): they enumerate finite structures interpreting the formula’s free symbols over domains 1 .. max_size, while satisfies_so ranges the SO-quantified predicates over every relation. A returned model or counter-model is genuine; “none found up to size N” is bounded evidence, not a proof.
Function |
Returns |
Meaning |
|---|---|---|
|
|
a finite structure in which |
|
|
a finite structure in which |
|
|
|
|
|
no finite counter-model found up to |
so_is_valid_finite is one-sided: True is strong evidence of second-order validity (not a proof), while False is a genuine refutation whose witness is available from so_find_countermodel.
Standard SO validities and refutations¶
from unicode_fol_kit import (
MSFLParser, holds,
so_find_model, so_find_countermodel,
so_is_satisfiable_finite, so_is_valid_finite,
)
p = MSFLParser(second_order=True).parse
# Standard second-order validities ----------------------------------------
# The complement of any predicate is definable:
so_is_valid_finite(p("∃P ∀x (P(x) ↔ ¬Q(x))"), max_size=3) # → True
# Leibniz's definition of equality (indiscernibility ⇔ identity):
so_is_valid_finite(p("∀x ∀y (∀P (P(x) ↔ P(y)) ↔ x = y)"), max_size=3) # → True
# Distribution over conjunction (Boolean lattice structure)
so_is_valid_finite(p("∀P ∀Q ∀x ((P(x) ∧ Q(x)) ↔ (P(x) ∧ Q(x)))"), max_size=2) # → True
# Not valid: "every relation is non-empty" -------------------------------
f = p("∀P ∃x P(x)")
so_is_valid_finite(f, max_size=3) # → False
cm = so_find_countermodel(f, max_size=3) # a 1-element Structure
holds(f, cm) # → False (P = ∅ refutes it)
Satisfiability vs validity¶
# Satisfiability ---------------------------------------------------------
so_is_satisfiable_finite(p("∃P ∀x P(x)"), max_size=2) # → True
# ∃P (∀x P(x) ∧ ∃x ¬P(x)) is contradictory — no finite model:
so_is_satisfiable_finite(p("∃P (∀x P(x) ∧ ∃x ¬P(x))"), max_size=3) # → False
# A formula with no model up to size 3 but perhaps larger:
unsure = p("∀P (P(a) → ∃x P(x))")
so_is_satisfiable_finite(unsure, max_size=1) # → True (1-element domain)
Finding models¶
# Find a witnessing model ------------------------------------------------
g = p("∃P ∀x (P(x) ↔ Q(x))")
m = so_find_model(g, max_size=2) # a Structure interpreting Q
holds(g, m) # → True
# Model for a property: "there is a reflexive relation"
refl = p("∃R ∀x R(x, x)")
m = so_find_model(refl, max_size=2)
print(m.domain) # → (0,) or (0, 1)
print(m.predicates) # → {} (R is SO-quantified, not stored)
The exact repr of a returned Structure depends on the search order over candidate interpretations, so verdicts and holds(...) re-checks are the stable things to assert.
Free symbols and universal closure¶
The search treats every predicate not bound by a ∀P / ∃P as a free signature symbol to be interpreted by the candidate structure, and free object variables are universally closed before the search. So ∃P P(x) (with x free) is searched as ∀x ∃P P(x) — valid, since for each x you may pick the singleton P = {(x,)}:
so_is_valid_finite(p("∃P P(x)"), max_size=2) # → True (closed over x; pick P = {(x,)})
# Multiple free variables, universally closed
so_is_valid_finite(p("∃P P(x, y)"), max_size=2) # → True (searched as ∀x ∀y ∃P P(x, y))
Adjusting max_size¶
max_size is the largest domain searched. Raising it widens the bounded evidence for so_is_valid_finite (more counter-model candidates ruled out) and widens the reach of so_is_satisfiable_finite / so_find_model:
# A countermodel may need a particular size; the witness is re-checkable:
f = p("∀P ∃x P(x)")
so_find_countermodel(f, max_size=1) is not None # → True (1-element domain suffices)
so_find_countermodel(f, max_size=5) is not None # → True (still found, smaller-first)
# Some formulas require larger domains to even have a model:
sat = p("∃P ∀x (P(x) ↔ x = a)")
so_is_satisfiable_finite(sat, max_size=1) # → True (1-element works for *any* model)
Schemas: comprehension and induction¶
Comprehension — “every first-order-definable subset exists as a predicate” — is a family of standard SO validities; each instance asserts a witnessing P extensionally equal to a definable formula:
# P = the complement of Q:
so_is_valid_finite(p("∃P ∀x (P(x) ↔ ¬Q(x))"), max_size=2) # → True
# P = Q minus S (any Boolean combination of free predicates):
so_is_valid_finite(p("∃P ∀x (P(x) ↔ (Q(x) ∧ ¬S(x)))"), max_size=2) # → True
# Binary comprehension: the complement of a free relation S:
so_is_valid_finite(p("∃R ∀x ∀y (R(x, y) ↔ ¬S(x, y))"), max_size=2) # → True
# Three-way composition: P = (Q ∨ R) ∧ ¬S
so_is_valid_finite(p("∃P ∀x (P(x) ↔ ((Q(x) ∨ R(x)) ∧ ¬S(x)))"), max_size=2) # → True
# Binary comprehension with complex condition
so_is_valid_finite(p("∃R ∀x ∀y (R(x, y) ↔ (S(x, y) ∧ ¬T(x, y)))"), max_size=2) # → True
The induction axiom¶
The second-order induction axiom — “any P containing zero and closed under succ contains everything” — is not valid over arbitrary finite structures (the free succ need not reach every element from zero), so the search finds a counter-model; it does hold in a one-element structure:
induction = p(
"∀P ((P(zero) ∧ ∀x (P(x) → P(succ(x)))) → ∀x P(x))"
)
so_is_valid_finite(induction, max_size=3) # → False (succ need not be onto)
cm = so_find_countermodel(induction, max_size=3)
holds(induction, cm) # → False
m = so_find_model(induction, max_size=1) # holds on a 1-element domain
holds(induction, m) # → True
# The reason: on a singleton {0}, succ(0) can be anything; any property including 0
# and closed under succ (for one element) includes all elements trivially.
(Note: single-letter lowercase names like f parse as object variables; a function symbol such as succ needs a multi-character lowercase name.)
End-to-end: parse → search → re-check → export¶
A complete loop — parse an SO sentence, refute it with the bounded search, re-check the witness, then hand the conjecture to a HOL prover:
from unicode_fol_kit.hol import to_thf_so
f = p("∀P ∃x P(x)") # "every relation is non-empty" — not SO-valid
verdict = so_is_valid_finite(f, max_size=3) # → False
cm = so_find_countermodel(f, max_size=3) # bounded witness
len(cm.domain) # → 1
holds(f, cm) # → False (re-check: P = ∅ refutes it)
print(to_thf_so(f).splitlines()[-1])
# → thf(goal, conjecture, ( ( ! [P: ( $i > $o )] : ( ? [X: $i] : ( P @ X ) ) ) )).
# Iterating: if you increase max_size, you check a larger search space
verdict_5 = so_is_valid_finite(f, max_size=5) # → False (still no models)
Building SO nodes directly¶
The same evaluators and search functions accept a SecondOrderQuantifier AST node built without the parser — SecondOrderQuantifier(type, predicate, arity, formula), where type is "∀" or "∃", predicate is the bound predicate name, and arity is its arity.
from unicode_fol_kit import so_is_valid_finite, SecondOrderQuantifier
from unicode_fol_kit.fol.nodes import Atom, Not, Iff, Quantifier, Variable, And
x = Variable("x")
# ∃P ∀x (P(x) ↔ ¬Q(x)) — complement-definability, built by hand
node = SecondOrderQuantifier(
"∃", "P", 1,
Quantifier("∀", x, Iff(Atom("P", [x]), Not(Atom("Q", [x])))),
)
so_is_valid_finite(node, max_size=3) # → True
Building more complex nodes¶
# ∀P ∀Q ∀x ((P(x) ∧ Q(x)) → (P(x) ∨ Q(x)))
P = Atom("P", [x])
Q = Atom("Q", [x])
inner = Quantifier(
"∀", x,
Implies(And(P, Q), Or(P, Q)) # (P ∧ Q) → (P ∨ Q)
)
q_binder = SecondOrderQuantifier("∀", "Q", 1, inner)
p_binder = SecondOrderQuantifier("∀", "P", 1, q_binder)
so_is_valid_finite(p_binder, max_size=2) # → True
SecondOrderQuantifier is also exported at the top level as unicode_fol_kit.SecondOrderQuantifier.
(SO nodes built directly bypass the parser’s arity inference, so set arity to match the body’s applications yourself; the evaluator and the exporters both trust the recorded arity.)
Exporting to a HOL prover¶
Because there is no first-order export, unicode_fol_kit.hol instead embeds an SO formula directly into a higher-order logic, where predicate quantification is native: an object variable has type $i, a predicate variable of arity k has type $i > … > $i > $o (arity 0 → $o), and each ∀P / ∃P becomes a HOL quantifier over a predicate-typed variable. Two emitters are provided — to_thf_so (TPTP THF, for Leo-III / Satallax) and to_isabelle_so (an Isabelle/HOL theory). Both only emit; they run no prover, and SO validity is not semi-decidable, so a sound prover may still fail on a valid goal.
from unicode_fol_kit.hol import to_thf_so, to_isabelle_so
f = p("∃P ∀x (P(x) ↔ ¬Q(x))") # complement-definability
print(to_thf_so(f))
# % Direct second-order -> HOL embedding (predicate quantifiers are native).
# % Standard (full) second-order semantics in a HOL prover; SOL validity is
# % NOT semi-decidable, so a sound prover may fail to close a valid goal.
# thf(q_decl, type, ( q : ( $i > $o ) )).
# thf(goal, conjecture, ( ( ? [P: ( $i > $o )] : ( ! [X: $i] : ( ( P @ X ) <=> ( ~ ( q @ X ) ) ) ) ) )).
THF export: TPTP for external provers¶
The free predicate Q becomes a declared problem symbol q; the bound P becomes a THF predicate-typed variable and is not declared. Pass conjecture=False to emit the formula as an axiom instead of a conjecture:
"axiom" in to_thf_so(f, conjecture=False) # → True
# Export multiple formulas as axioms, then a single conjecture
f1 = p("∃P ∀x (P(x) ↔ Q(x))")
f2 = p("∃R ∀x ∀y (R(x, y) ↔ S(x, y))")
f3 = p("∀P ∃x P(x)") # the query
thf1 = to_thf_so(f1, conjecture=False)
thf2 = to_thf_so(f2, conjecture=False)
thf3 = to_thf_so(f3, conjecture=True)
print(thf1.splitlines()[0]) # % comment line
print("axiom" in thf1) # → True
print("conjecture" in thf3) # → True
Isabelle/HOL export¶
to_isabelle_so produces a self-contained theory whose lemma is left oops (replace with by auto / sledgehammer); the predicate type is i ⇒ … ⇒ i ⇒ bool:
thy = to_isabelle_so(f, name="Complement")
"theory Complement" in thy # → True
'consts q :: "i' in thy # → True (free predicate Q declared as a const)
"lemma" in thy and "oops" in thy # → True
# Print a snippet
lines = thy.split('\n')
print('\n'.join(lines[:15])) # Header and early declarations
Equality and uninterpreted relations¶
In both exports equality = / ≠ is an uninterpreted relation (feq / fneq), not primitive HOL identity — add reflexivity / Leibniz axioms in the prover if you need true identity.
# Exporting a formula with equality
f_eq = p("∀x (x = x)")
thf = to_thf_so(f_eq)
print("feq" in thf) # → True (= becomes the uninterpreted feq)
# The exported problem is *not* automatically reflexive
# You would need to add an axiom: ∀x. feq(x, x)
thy_eq = to_isabelle_so(f_eq, name="Reflexivity")
print("feq" in thy_eq) # → True
Scope¶
This is second-order predicate (relation) quantification with standard semantics over finite models. Quantification over functions, third-order and up, and a complete higher-order type system are out of scope; the lambda layer already supplies higher-order terms (λP. P(x)), which you beta-reduce and lambda-eliminate before evaluation. The second_order=True mode does not combine with sorts, fuzziness, or the modal mode — the constructor rejects an unsupported combination with a ValueError. For exporting ∀P / ∃P to a higher-order prover, see unicode_fol_kit.hol (to_thf_so / to_isabelle_so), which map them to native HOL predicate quantifiers.
Combining second-order with other modes¶
# These combinations raise ValueError:
try:
MSFLParser(second_order=True, many_sorted=True) # raises ValueError
except ValueError as e:
print("unsupported combination" in str(e)) # → True
try:
MSFLParser(second_order=True, fuzzy=True) # raises ValueError
except ValueError:
pass
try:
MSFLParser(second_order=True, modal=True) # raises ValueError
except ValueError:
pass
# But first-order quantifiers, lambdas, and standard logic connectives work fine:
p = MSFLParser(second_order=True).parse
p("∀x ∃P P(x)") # ✓ mix of ∀x and ∃P
p("(λx. x)(a) → ∀P P") # ✓ lambda terms
p("∀P (P ↔ (Q ∧ R))") # ✓ Boolean connectives