Quantified Modal Logic¶
Combining the modalities with ∀x / ∃x gives quantified modal logic (QML), where validity turns on how the individual domain varies between worlds. unicode-fol-kit handles QML both semantically (a KripkeModel with per-world domains and an actualist satisfies_modal) and via two shallow embeddings in the Benzmüller style — a first-order one decided by Z3, and a higher-order one exported as TPTP THF.
The kit gives you three views of the same logic, and they are designed to agree:
Tool |
What it is |
Verdict |
|---|---|---|
|
brute-force evaluator over an explicit finite model |
ground truth + countermodels |
|
first-order shallow embedding → Z3 |
sound, bounded-incomplete |
|
higher-order shallow embedding → external prover |
sound (you run the prover) |
Building modal formulas as nodes¶
Every example below builds the AST directly from unicode_fol_kit, so no parser is involved. A modal formula mixes the modal nodes Box / Diamond with the ordinary first-order nodes Quantifier / Atom / Variable. The Barcan formula ◇∃x A(x) → ∃x ◇A(x) and its converse are the standard litmus tests for the domain regime, and the kit exports them ready-made as BARCAN / CONVERSE_BARCAN:
from unicode_fol_kit import (
Box, Diamond, Quantifier, Atom, Variable, Implies,
BARCAN, CONVERSE_BARCAN,
)
x = Variable("x")
A = lambda t: Atom("A", [t])
# Barcan formula: ◇∃x A(x) → ∃x ◇A(x)
bf = Implies(Diamond(Quantifier("∃", x, A(x))),
Quantifier("∃", x, Diamond(A(x))))
# Converse Barcan: ∃x ◇A(x) → ◇∃x A(x)
cbf = Implies(Quantifier("∃", x, Diamond(A(x))),
Diamond(Quantifier("∃", x, A(x))))
bf == BARCAN # → True
cbf == CONVERSE_BARCAN # → True
Quantifier(type, variable, formula) takes "∃" / "exists" (or "∀" / "forall") as its type; the bound variable is a Variable. Box and Diamond each wrap a single subformula.
You rarely have to spell the AST out by hand: MSFLParser(modal=True) parses the Unicode surface form, quantifiers and modalities together, and produces exactly the same node. Parse it and feed it straight into the validity checker:
from unicode_fol_kit import MSFLParser, qml_is_valid
mp = MSFLParser(modal=True)
parsed = mp.parse("◇∃x A(x) → ∃x ◇A(x)")
parsed == BARCAN # → True
qml_is_valid(parsed, mode="constant") # → True
Parsing real-world scenarios¶
Beyond the canonical Barcan tests, you can parse natural multi-quantifier and multi-predicate formulas:
# "Someone must necessarily satisfy a property"
f1 = mp.parse("∃x □P(x)")
f1.to_unicode_str() # → '∃x □P(x)'
# "A property must hold of everyone"
f2 = mp.parse("□∀x Q(x)")
f2.to_unicode_str() # → '□∀x Q(x)'
# "Everyone can possibly know something"
f3 = mp.parse("∀x ◇∃y Knows(x, y)")
f3.to_unicode_str() # → '∀x ◇∃y Knows(x, y)'
# "If something is necessary, it can't be avoidable"
f4 = mp.parse("□P(a) → ¬◇¬P(a)")
f4.to_unicode_str() # → '□P(a) → ¬◇¬P(a)'
# "Someone who exists possibly satisfies a relational property"
f5 = mp.parse("∃x (∃y Loves(x, y) ∧ ◇Happy(x))")
f5.to_unicode_str() # → '∃x (∃y Loves(x, y) ∧ ◇Happy(x))'
Comparing modal quantifier orders¶
The order of quantifiers matters crucially. Swapping ∃x and □ can change validity:
# Existential inside the box: some specific thing is necessarily P
ex_box = Quantifier("∃", x, Box(A(x)))
# Box around existential: necessarily, there exists something that is P
box_ex = Box(Quantifier("∃", x, A(x)))
# Compare their validity across regimes
qml_is_valid(ex_box, mode="constant") # → True
qml_is_valid(box_ex, mode="constant") # → True
qml_is_valid(ex_box, mode="increasing") # → True (existing objects stay)
qml_is_valid(box_ex, mode="increasing") # → False (new objects might appear)
# Their equivalence depends on the regime
qml_equivalent(ex_box, box_ex, mode="constant") # → True
qml_equivalent(ex_box, box_ex, mode="increasing") # → False
There is a second Barcan pair phrased with □/∀ rather than ◇/∃ — the box Barcan ∀x □A(x) → □∀x A(x) and box Converse Barcan □∀x A(x) → ∀x □A(x). They are the contrapositive duals of BARCAN / CONVERSE_BARCAN, so the regime correspondence is mirrored (box-BF ⇔ decreasing, box-CBF ⇔ increasing). You build them the same way:
from unicode_fol_kit import qml_is_valid
box_bf = Implies(Quantifier("∀", x, Box(A(x))),
Box(Quantifier("∀", x, A(x)))) # ∀x □A(x) → □∀x A(x)
box_cbf = Implies(Box(Quantifier("∀", x, A(x))),
Quantifier("∀", x, Box(A(x)))) # □∀x A(x) → ∀x □A(x)
qml_is_valid(box_bf, mode="decreasing") # → True (box-BF ⇔ decreasing)
qml_is_valid(box_bf, mode="increasing") # → False
qml_is_valid(box_cbf, mode="increasing") # → True (box-CBF ⇔ increasing)
qml_is_valid(box_cbf, mode="decreasing") # → False
qml_is_valid(box_bf, mode="constant") # → True (constant ⇒ both)
qml_is_valid(box_cbf, mode="constant") # → True
Semantics: per-world domains and actualist quantifiers¶
Build a KripkeModel with per-world object domains — domains={w: {...}} for a varying domain, or domain={...} for a single constant domain shared by every world. satisfies_modal(φ, model, world) then interprets ∀x / ∃x actualistically: at a world w they range over D_w, the objects that exist at that world. This is the ground truth against which the embeddings are cross-checked.
model.domain_at(w) shows the live domain of a world; the constant-domain shorthand domain={...} simply assigns that same set to every world:
from unicode_fol_kit.semantics.kripke import KripkeModel
varying = KripkeModel(worlds={0, 1}, domains={0: {"a"}, 1: {"a", "b"}})
constant = KripkeModel(worlds={0, 1}, domain={"a", "b"})
sorted(varying.domain_at(0)) # → ['a']
sorted(varying.domain_at(1)) # → ['a', 'b']
sorted(constant.domain_at(0)) # → ['a', 'b']
sorted(constant.domain_at(1)) # → ['a', 'b']
Building models with valuations¶
Attach a valuation (truth assignment for atoms) to make concrete evaluations:
from unicode_fol_kit import satisfies_modal, Variable, Atom, Quantifier
from unicode_fol_kit.semantics.kripke import KripkeModel
x = Variable("x")
A = lambda t: Atom("A", [t])
# Model where only world 1 has A(b)
m = KripkeModel(
worlds={0, 1},
relations={"alethic": {(0, 1)}},
domains={0: {"a"}, 1: {"a", "b"}},
valuation={0: {"A(a)"}, 1: {"A(a)", "A(b)"}}
)
# Check what's true at each world
from unicode_fol_kit import Constant
a = Constant("a")
b = Constant("b")
satisfies_modal(A(a), m, 0) # → True (A(a) holds at world 0)
satisfies_modal(A(b), m, 0) # → False (b does not exist at world 0)
satisfies_modal(A(b), m, 1) # → True (A(b) holds at world 1)
# Existential quantifiers range over the actualist domain
# At world 0, ∃x A(x) is true because A(a) is true and a ∈ D_0
satisfies_modal(Quantifier("∃", x, A(x)), m, 0) # → True
# At world 1, both a and b witness the existential
satisfies_modal(Quantifier("∃", x, A(x)), m, 1) # → True
Multi-world, multi-predicate models¶
Build realistic scenarios with multiple predicates and connectivity:
# A three-world model with growing domain
model = KripkeModel(
worlds={0, 1, 2},
relations={"alethic": {(0, 1), (1, 2), (0, 2)}}, # path 0→1→2, plus shortcut
domains={0: {"alice"}, 1: {"alice", "bob"}, 2: {"alice", "bob", "charlie"}},
valuation={
0: {"Person(alice)", "Happy(alice)"},
1: {"Person(alice)", "Person(bob)", "Happy(bob)"},
2: {"Person(alice)", "Person(bob)", "Person(charlie)", "Happy(charlie)"}
}
)
Person = lambda t: Atom("Person", [t])
Happy = lambda t: Atom("Happy", [t])
# At world 0, everyone who exists is happy
satisfies_modal(
Quantifier("∀", x, Implies(Person(x), Happy(x))),
model, 0
) # → True
# But "everyone" in world 0 is just alice
satisfies_modal(Quantifier("∀", x, Person(x)), model, 0) # → True (only alice exists)
# At world 1, not everyone is necessarily happy yet
satisfies_modal(
Quantifier("∀", x, Implies(Person(x), Happy(x))),
model, 1
) # → True (alice and bob are both happy here)
# "It's possible that everyone is a person" — true at 0, since worlds 1,2 have all
satisfies_modal(
Diamond(Quantifier("∀", x, Person(x))),
model, 0
) # → True
A model built without domains is the purely propositional fragment of Modal, temporal, epistemic & deontic logic; asking it to evaluate an object quantifier is an error rather than a silent default:
from unicode_fol_kit import satisfies_modal, Quantifier, Variable, Atom
x = Variable("x")
prop_only = KripkeModel(worlds={0}, valuation={0: {"A(a)"}})
satisfies_modal(Quantifier("∃", x, Atom("A", [x])), prop_only, 0)
# raises ValueError: this Kripke model has no object domains …
Barcan fails when domains grow¶
The Barcan formula is valid under constant domains but fails when domains grow, because an object can appear only in a successor world:
from unicode_fol_kit import BARCAN, satisfies_modal
from unicode_fol_kit.semantics.kripke import KripkeModel
rel = {"alethic": {(0, 1)}} # world 0 sees world 1
constant = KripkeModel(
worlds={0, 1}, relations=rel,
valuation={1: {"A(b)"}}, domain={"a", "b"},
)
increasing = KripkeModel(
worlds={0, 1}, relations=rel,
valuation={1: {"A(b)"}}, domains={0: {"a"}, 1: {"a", "b"}},
)
satisfies_modal(BARCAN, constant, 0) # → True
satisfies_modal(BARCAN, increasing, 0) # → False
Under increasing, b exists only at world 1: ◇∃x A(x) holds at world 0 (a b with A(b) is reachable), but ∃x ◇A(x) fails because no object in D_0 possibly satisfies A. You can watch the two antecedent/consequent halves come apart directly:
ant = Diamond(Quantifier("∃", x, Atom("A", [x]))) # ◇∃x A(x)
con = Quantifier("∃", x, Diamond(Atom("A", [x]))) # ∃x ◇A(x)
satisfies_modal(ant, increasing, 0) # → True (a b with A(b) is reachable)
satisfies_modal(con, increasing, 0) # → False (no object in D_0 possibly has A)
This asymmetry shows why Barcan breaks: the antecedent escapes the domain restriction by quantifying within the modal operator, while the consequent must quantify over D_0.
satisfies_modal is also the way to obtain a definite countermodel — it is a complete brute-force oracle over the explicit model.
Converse Barcan fails when domains shrink¶
The Converse Barcan is the mirror image: it holds under constant domains but fails when domains shrink along the accessibility relation. Put b in D_0 with A(b) true at the successor world 1, but drop b from D_1:
from unicode_fol_kit import CONVERSE_BARCAN
decreasing = KripkeModel(
worlds={0, 1}, relations=rel,
valuation={1: {"A(b)"}}, domains={0: {"a", "b"}, 1: {"a"}},
)
satisfies_modal(CONVERSE_BARCAN, decreasing, 0) # → False
satisfies_modal(BARCAN, decreasing, 0) # → True (BF survives shrinking)
Here ∃x ◇A(x) is true at world 0 (the object b, which exists at 0, has A at the reachable world 1), but ◇∃x A(x) is false: the only successor is world 1, whose actualist domain D_1 = {a} contains no A-witness because b no longer exists there.
ant = Quantifier("∃", x, Diamond(Atom("A", [x]))) # ∃x ◇A(x)
con = Diamond(Quantifier("∃", x, Atom("A", [x]))) # ◇∃x A(x)
satisfies_modal(ant, decreasing, 0) # → True
satisfies_modal(con, decreasing, 0) # → False
So satisfies_modal gives you both halves of the correspondence by hand-built example: BF breaks on growth, CBF breaks on shrinkage, and a constant domain (neither growing nor shrinking) validates both.
Varying domains: BF and CBF both fail¶
When domains can move arbitrarily (neither growing nor shrinking), neither Barcan formula holds:
# Domain oscillates: D_0 = {a}, D_1 = {b} (completely disjoint)
oscillating = KripkeModel(
worlds={0, 1}, relations={"alethic": {(0, 1), (1, 0)}},
domains={0: {"a"}, 1: {"b"}},
valuation={0: {"A(a)"}, 1: {"A(b)"}}
)
satisfies_modal(BARCAN, oscillating, 0) # → False (breaks on growth)
satisfies_modal(CONVERSE_BARCAN, oscillating, 0) # → False (breaks on shrinkage)
(A) First-order shallow embedding → Z3¶
qml_translate rewrites a modal formula into classical FOL — quantifiers over worlds for the modalities, and an existence predicate E relativising the actualist object quantifiers — and qml_is_valid(φ, mode, frame) decides validity with Z3. The mode is the domain regime ("constant", "increasing" / "cumulative", "decreasing", "varying", "possibilist") and frame ∈ {K, T, S4, S5, KD, KD45, B, S4.2, S4.3}.
Seeing the translation¶
qml_translate(φ, mode, world="w") is the rewrite itself — a plain classical-FOL Node you can render. Each atom gets the current world appended as a last argument, ◇ becomes a guarded ∃ over accessible worlds, and □ a guarded ∀:
from unicode_fol_kit.fol.qml import qml_translate
qml_translate(Box(A(x)), mode="constant").to_unicode_str()
# → '∀_w0 (World(_w0) ∧ R(w, _w0) → A(x, _w0))'
qml_translate(Diamond(A(x)), mode="constant").to_unicode_str()
# → '∃_w0 (World(_w0) ∧ R(w, _w0) ∧ A(x, _w0))'
More complex formulas show how structure is preserved:
# Nested: □(∃x A(x))
nested = Box(Quantifier("∃", x, A(x)))
qml_translate(nested, mode="constant").to_unicode_str()
# → '∀_w0 (World(_w0) ∧ R(w, _w0) → ∃x (Object(x) → A(x, _w0)))'
# With box: □∀x A(x)
forall_box = Box(Quantifier("∀", x, A(x)))
qml_translate(forall_box, mode="constant").to_unicode_str()
# → '∀_w0 (World(_w0) ∧ R(w, _w0) → ∀x (Object(x) → A(x, _w0)))'
The domain regime shows up in how the object quantifiers are guarded. Under a constant / possibilist mode ∀x is unrelativised (just typed Object(x)); under an actualist mode (increasing / decreasing / varying) it is additionally guarded by the existence predicate E(x, w) (“x exists at world w”):
forall = Quantifier("∀", x, A(x))
qml_translate(forall, mode="constant").to_unicode_str()
# → '∀x (Object(x) → A(x, w))'
qml_translate(forall, mode="increasing").to_unicode_str()
# → '∀x (Object(x) ∧ E(x, w) → A(x, w))'
Compare existential quantifiers:
exists = Quantifier("∃", x, A(x))
qml_translate(exists, mode="constant").to_unicode_str()
# → '∃x (Object(x) ∧ A(x, w))'
qml_translate(exists, mode="increasing").to_unicode_str()
# → '∃x (Object(x) ∧ E(x, w) ∧ A(x, w))'
The background axioms (sort typing, frame conditions, the existence-axiom that defines the regime) are exposed separately as qml_axioms(mode, frame); qml_is_valid checks ⋀axioms → ∀w (World(w) → ST(φ, w)):
from unicode_fol_kit.fol.qml import qml_axioms
ax = qml_axioms(mode="increasing", frame="S4")
len(ax) # → 9
ax[0].to_unicode_str() # → '∀t ¬(World(t) ∧ Object(t))' (worlds and objects are disjoint)
# Inspect the axioms for different regimes
ax_const = qml_axioms(mode="constant", frame="K")
ax_incr = qml_axioms(mode="increasing", frame="K")
ax_decr = qml_axioms(mode="decreasing", frame="K")
len(ax_const), len(ax_incr), len(ax_decr) # → (7, 7, 7) (same base count)
# Check if increasing mode has domain constraints
any("cumulative" in str(ax) for ax in ax_incr) # → True
any("decreasing" in str(ax) for ax in ax_decr) # → True
Deciding validity per regime¶
The core decision procedure: test a formula under all domain regimes:
from unicode_fol_kit import qml_is_valid, qml_equivalent, BARCAN, CONVERSE_BARCAN
qml_is_valid(BARCAN, mode="constant") # → True
qml_is_valid(BARCAN, mode="increasing") # → False
qml_is_valid(BARCAN, mode="decreasing") # → True
qml_is_valid(CONVERSE_BARCAN, mode="increasing") # → True
qml_is_valid(CONVERSE_BARCAN, mode="decreasing") # → False
qml_is_valid(BARCAN, mode="varying") # → False
The full correspondence (verified against the Kripke enumerator) is: BF ⇔ decreasing domains, CBF ⇔ increasing, constant ⇔ both, and varying ⇔ neither. The cumulative alias is increasing, and possibilist is constant (every individual exists everywhere):
qml_is_valid(BARCAN, mode="cumulative") # → False (alias of "increasing")
qml_is_valid(BARCAN, mode="possibilist") # → True (alias of "constant")
qml_is_valid(CONVERSE_BARCAN, mode="constant") # → True
qml_is_valid(CONVERSE_BARCAN, mode="varying") # → False
This reproduces, decision-procedure-style, exactly the by-hand verdicts from satisfies_modal above.
Testing mixed quantifiers¶
Quantifier nesting shows regime-dependent effects:
# Existential outside box: "there exists someone for whom P is necessary"
ex_nec = Quantifier("∃", x, Box(A(x)))
# Box outside existential: "it is necessary that someone exists with P"
nec_ex = Box(Quantifier("∃", x, A(x)))
# Under constant domains, these are equivalent
qml_is_valid(ex_nec, mode="constant") # → True
qml_is_valid(nec_ex, mode="constant") # → True
qml_equivalent(ex_nec, nec_ex, mode="constant") # → True
# Under increasing domains, they diverge
qml_is_valid(ex_nec, mode="increasing") # → True (a fixed object)
qml_is_valid(nec_ex, mode="increasing") # → False (new objects appear)
qml_equivalent(ex_nec, nec_ex, mode="increasing") # → False
Adding a frame system¶
The frame argument fixes the alethic accessibility relation (defaulting to the minimal K). The Barcan correspondence is a domain phenomenon, so it is robust across frames — BF stays valid under constant domains whether the frame is K, S4, or S5:
qml_is_valid(BARCAN, mode="constant", frame="K") # → True
qml_is_valid(BARCAN, mode="constant", frame="S4") # → True
qml_is_valid(BARCAN, mode="constant", frame="S5") # → True
qml_is_valid(CONVERSE_BARCAN, mode="increasing", frame="S5") # → True
Frame schemata (as opposed to domain effects) do depend on the system. The classic propositional ones decide through this embedding too, because Z3 sees their first-order frame conditions:
p = Atom("P", [])
qml_is_valid(Implies(Box(p), p), frame="T") # → True (T: reflexive)
qml_is_valid(Implies(Box(p), p), frame="K") # → False
qml_is_valid(Implies(Box(p), Box(Box(p))), frame="S4") # → True (4: transitive)
qml_is_valid(Implies(Diamond(p), Box(Diamond(p))), frame="S5") # → True (5: euclidean)
qml_is_valid(Implies(Diamond(p), Box(Diamond(p))), frame="S4") # → False
Test the deontic frame (seriality):
# KD: serial frame (every world has a successor)
qml_is_valid(Implies(Diamond(p), Implies(Box(Not(p)), Diamond(p))), frame="KD") # → True
qml_equivalent¶
qml_equivalent(left, right, mode, frame) is just qml_is_valid of the biconditional. Since both BF and CBF are valid under constant domains, they are QML-equivalent there — but not under a one-sided regime:
qml_equivalent(BARCAN, CONVERSE_BARCAN, mode="constant") # → True
qml_equivalent(BARCAN, CONVERSE_BARCAN, mode="constant", frame="S4") # → True
qml_equivalent(BARCAN, CONVERSE_BARCAN, mode="increasing") # → False (only CBF holds)
qml_equivalent(BARCAN, CONVERSE_BARCAN, mode="decreasing") # → False (only BF holds)
Test complex equivalences:
# These two are equivalent under constant domains
f1 = Implies(Box(A(x)), Diamond(A(x)))
f2 = Diamond(A(x))
qml_equivalent(f1, f2, mode="constant") # → False (f1 is actually too strong)
# A simpler test: are quantifiers commutative under all regimes?
y = Variable("y")
B = lambda t: Atom("B", [t])
# ∃x ∃y A(x) ∧ B(y) vs ∃y ∃x A(x) ∧ B(y)
both_exist = Quantifier("∃", x, Quantifier("∃", y, And(A(x), B(y))))
both_exist_flip = Quantifier("∃", y, Quantifier("∃", x, And(A(x), B(y))))
qml_equivalent(both_exist, both_exist_flip, mode="constant") # → True
qml_equivalent(both_exist, both_exist_flip, mode="increasing") # → True
Soundness caveat and error modes¶
This embedding is sound but bounded-incomplete: first-order modal logic is undecidable, so a False may mean “Z3 did not prove validity within the bound” rather than “definitely invalid” — use satisfies_modal on an explicit model for a guaranteed countermodel. The API also fails loudly on bad inputs rather than guessing:
qml_is_valid(BARCAN, mode="weird") # raises ValueError: unknown mode 'weird'
qml_is_valid(BARCAN, frame="ZZ") # raises ValueError: unknown frame 'ZZ'
qml_is_valid(BARCAN, frame="GL") # raises NotImplementedError: GL is not first-order definable
GL (Gödel–Löb provability) is transitive + converse-well-founded, which is not first-order definable, so the Z3 path rejects it; reach it only through the higher-order exporters below.
Inspect error messages closely:
from unicode_fol_kit import qml_is_valid
try:
qml_is_valid(BARCAN, mode="unknown_mode")
except ValueError as e:
print(f"Error: {e}") # Shows the allowed modes
(B) Higher-order shallow embedding → TPTP THF¶
to_thf_modal(φ, mode, frame) emits a complete Benzmüller-style TPTP THF problem for an external higher-order prover (Leo-III, Satallax). A modal proposition is a function mu > $o (world → bool); the modalities are λ-lifted quantifiers over the accessibility relation r, and object quantifiers are existsAt-guarded (actualist). The frame and domain regime are encoded as axioms.
from unicode_fol_kit import to_thf_modal, BARCAN
thf = to_thf_modal(BARCAN, mode="constant", frame="S5")
type(thf) # → <class 'str'>
thf.splitlines()[0]
# → "% Shallow embedding of a quantified modal formula (mode=constant, frame=S5)."
"thf(mbox," in thf # → True (lifted operators mbox/mdia/mforall/mexists are defined)
The emitted file is a full, self-contained problem: the type of worlds mu, the lifted operator definitions, the frame axioms, the existsAt domain axiom for the regime, and the conjecture mvalid @ ⟨formula⟩. You can see those pieces by grepping the lines:
thf_T = to_thf_modal(Implies(Box(Atom("P", [])), Atom("P", [])), mode="constant", frame="T")
[l for l in thf_T.splitlines() if l.startswith("thf(refl")][0]
# → 'thf(refl, axiom, ( ! [W: mu] : ( r @ W @ W ) )).'
[l for l in thf_T.splitlines() if l.startswith("thf(const_dom")][0]
# → 'thf(const_dom, axiom, ( ! [X: $i, W: mu] : ( existsAt @ X @ W ) )).'
[l for l in thf_T.splitlines() if l.startswith("thf(goal")][0][:45]
# → 'thf(goal, conjecture, ( mvalid @ ( mimplies @'
Inspecting domain axioms¶
Different regimes emit different existsAt constraints. Check what axiom is in a THF problem:
# Constant domain: everyone exists everywhere
thf_const = to_thf_modal(CONVERSE_BARCAN, mode="constant", frame="K")
"! [X: $i, W: mu] : ( existsAt @ X @ W )" in thf_const # → True
# Increasing domain: objects only appear, never disappear
thf_incr = to_thf_modal(CONVERSE_BARCAN, mode="increasing", frame="K")
"cumulative_dom" in thf_incr # → True
# Decreasing domain: objects disappear, never appear
thf_decr = to_thf_modal(BARCAN, mode="decreasing", frame="K")
"decreasing_dom" in thf_decr # → True
# Varying domain: no constraint on existsAt
thf_var = to_thf_modal(BARCAN, mode="varying", frame="K")
any(d in thf_var for d in ("const_dom", "cumulative_dom", "decreasing_dom")) # → False
The domain regime selects which existsAt axiom is emitted. constant (and its alias possibilist) asserts every individual exists at every world; increasing / decreasing assert the monotone existence axioms; varying emits none of them (no constraint on how the domain moves):
"cumulative_dom" in to_thf_modal(CONVERSE_BARCAN, mode="increasing", frame="S4") # → True
"decreasing_dom" in to_thf_modal(BARCAN, mode="decreasing", frame="S4") # → True
thf_v = to_thf_modal(BARCAN, mode="varying", frame="K")
any(d in thf_v for d in ("const_dom", "cumulative_dom", "decreasing_dom")) # → False
Unlike the Z3 path, the THF exporter accepts frame="GL": it emits the Löb schema as a higher-order axiom for the prover (HOL can express converse-well-foundedness where first-order logic cannot):
loeb = Implies(Box(Implies(Box(Atom("P", [])), Atom("P", []))), Box(Atom("P", [])))
"thf(loeb," in to_thf_modal(loeb, frame="GL") # → True
Generate THF for more complex formulas:
# Combine quantifiers and modalities
f = Quantifier("∀", x, Implies(Atom("Person", [x]), Diamond(Atom("Happy", [x]))))
thf_person = to_thf_modal(f, mode="constant", frame="S5")
# Check that the formula appears in the THF
"mforall" in thf_person # → True
"mdia" in thf_person # → True
The function emits the problem (like the other to_* exporters); it does not run a prover in-process. The conjecture comes out a Theorem for the prover exactly when the formula is QML-valid under the given regime.
A loadable Isabelle/HOL theory¶
to_isabelle_modal(φ, mode, frame) is the Isabelle counterpart — a complete, loadable theory … begin … end with every lifted operator defined and a genuine lemma to discharge:
from unicode_fol_kit import to_isabelle_modal
iz = to_isabelle_modal(BARCAN, mode="constant", frame="S5")
iz.splitlines()[0] # → 'theory ModalEmbedding'
"lemma" in iz # → True
Inspect the Isabelle output structure:
iz_lines = iz.splitlines()
print(iz_lines[0]) # → 'theory ModalEmbedding'
print(iz_lines[-3:]) # → end / proof lines
"imports HOL.Equiv_Relations" in iz # → True (imports standard HOL libraries)
This covers the alethic □/◇ fragment; for the full modal family (epistemic / doxastic / deontic / temporal) and the additional exporter options, see Higher-order proving: Isabelle / THF exporters.
End-to-end: parse → decide → cross-check → export¶
A complete pass over one formula, exercising all three views. Take the box Converse Barcan □∀x A(x) → ∀x □A(x), decide it under each regime with Z3, confirm the increasing-domain verdict on an explicit model, then export a prover problem:
from unicode_fol_kit import MSFLParser, qml_is_valid, to_thf_modal, satisfies_modal
from unicode_fol_kit.semantics.kripke import KripkeModel
mp = MSFLParser(modal=True)
phi = mp.parse("□∀x A(x) → ∀x □A(x)") # box Converse Barcan ⇔ increasing domains
# 1. decision procedure (Z3) across the regimes
qml_is_valid(phi, mode="increasing") # → True
qml_is_valid(phi, mode="decreasing") # → False
qml_is_valid(phi, mode="constant") # → True
# 2. cross-check the True verdict semantically on a constant-domain S5 model
S5 = {"alethic": {(0, 0), (0, 1), (1, 0), (1, 1)}}
model = KripkeModel(
worlds={0, 1}, relations=S5,
valuation={0: {"A(a)", "A(b)"}, 1: {"A(a)", "A(b)"}},
domain={"a", "b"},
)
satisfies_modal(phi, model, 0) # → True
# 3. export a higher-order problem for an external prover
problem = to_thf_modal(phi, mode="increasing", frame="S5")
problem.splitlines()[0]
# → "% Shallow embedding of a quantified modal formula (mode=increasing, frame=S5)."
The three steps agree by construction — that triangulation between the brute-force evaluator, the Z3 embedding, and the exported HOL problem is the whole point of carrying three views of QML.
Comparing all three approaches on a custom formula¶
Test a non-canonical formula across all pathways:
# Parse a realistic formula: "Something can necessarily know something"
custom = mp.parse("∃x ∃y □Knows(x, y)")
# Path 1: Z3 decision (all regimes)
for mode in ["constant", "increasing", "decreasing", "varying"]:
result = qml_is_valid(custom, mode=mode)
print(f"{mode:12}: {result}")
# → constant : False
# increasing : False
# decreasing : False
# varying : False
# Path 2: Semantics — build a model where it's true
knows_model = KripkeModel(
worlds={0, 1},
relations={"alethic": {(0, 1)}},
domain={"alice", "bob"},
valuation={1: {"Knows(alice, bob)"}}
)
x = Variable("x")
y = Variable("y")
Knows = lambda a, b: Atom("Knows", [a, b])
formula = Quantifier("∃", x, Quantifier("∃", y, Box(Knows(x, y))))
satisfies_modal(formula, knows_model, 0) # → True (alice and bob in world 1)
# Path 3: Export to THF for external verification
thf_custom = to_thf_modal(custom, mode="constant", frame="S5")
len(thf_custom.splitlines()) # → (large problem, many axiom lines)
"mforall" in thf_custom # → True (existentials become mforall in the embedding)