Hybrid logic H(@) — naming worlds¶
Plain modal logic can say “somewhere accessible, P holds” (◇P) — but it cannot say where. Hybrid logic H(@) closes that gap with two constructs that parse in the modal mode (MSFLParser(modal=True)):
a nominal
i— an atomic formula true at exactly one world, thereby naming it;the satisfaction operator
@i φ— “at the world namedi,φholds”, evaluated there no matter where you currently stand.
Together they buy things plain modal logic cannot express: asserting facts about a specific world from anywhere (@i P), asserting world equality (@i j — “the worlds named i and j are the same”), and pinning frame properties to named points ((◇i ∧ @i P) → ◇P). H(@) over K stays decidable — the undecidable ↓ binder is deliberately not in the kit (see the boundary section below).
Syntax¶
construct |
Unicode |
AST node |
meaning |
|---|---|---|---|
nominal |
|
|
true at exactly the world named |
satisfaction |
|
|
|
@i binds like the other prefix operators (¬, K_a), and nominals combine freely with every modal operator:
from unicode_fol_kit import MSFLParser, Nominal, At
mp = MSFLParser(modal=True)
mp.parse("i") # → Nominal(name='i')
mp.parse("@i P") # → At(nominal=Nominal(name='i'), formula=Atom(predicate='P', args=()))
mp.parse("◇i").to_unicode_str() # → '◇i' (some successor is the world i)
mp.parse("@i (□P → □□P)").to_unicode_str() # → '@i (□P → □□P)'
mp.parse("@i P").to_latex() # → '@_{i} P'
The nodes are ordinary kit nodes — they render, serialise, and round-trip like everything else, and At coerces a bare string for convenience:
from unicode_fol_kit import Node, Atom
p = Atom("P", [])
At("i", p) == At(Nominal("i"), p) # → True (string is coerced to a Nominal)
f = mp.parse("@i j ↔ @j i")
mp.parse(f.to_unicode_str()) == f # → True (render → reparse round-trip)
Node.from_dict(f.to_dict()) == f # → True (dict serialisation round-trip)
Evaluating with a nominal assignment¶
A KripkeModel interprets nominals through the optional nominals= mapping (name → world). Every referenced world must exist — a dangling assignment raises at construction time. A nominal is then true at exactly the world it names, and @i φ evaluates φ at that world, wherever the evaluation currently stands:
from unicode_fol_kit import KripkeModel, satisfies_modal, Atom, Nominal, At
sunny = Atom("Sunny", [])
m = KripkeModel(
worlds={"here", "there"},
relations={"alethic": {("here", "there")}},
valuation={"there": {"Sunny"}},
nominals={"i": "there"}, # the nominal i names the world "there"
)
satisfies_modal(Nominal("i"), m, "here") # → False (a nominal is true ONLY at its world)
satisfies_modal(Nominal("i"), m, "there") # → True
satisfies_modal(At("i", sunny), m, "here") # → True (@ jumps: Sunny is checked AT "there")
satisfies_modal(sunny, m, "here") # → False (… while "here" itself stays rainy)
@i i is true everywhere (the world named i is, trivially, named i), and @ composes with the modal operators — ◇i reads “some successor is the world i”:
from unicode_fol_kit import Diamond
satisfies_modal(At("i", Nominal("i")), m, "here") # → True (valid at every world)
satisfies_modal(Diamond(Nominal("i")), m, "here") # → True ("here" sees the world i)
satisfies_modal(Diamond(Nominal("i")), m, "there") # → False ("there" has no successors)
A nominal without an assignment is a modelling error, not a truth value — the evaluator raises a ValueError naming the offending nominal:
satisfies_modal(Nominal("k"), m, "here") # raises ValueError: the nominal 'k' has no world assignment …
KripkeModel({0, 1}, nominals={"i": 7}) # raises ValueError: nominal 'i' … not among the model's worlds
The standard translation: nominals as world constants¶
standard_translation maps hybrid constructs into classical FOL alongside the modal ones (Modal, temporal, epistemic & deontic logic). A nominal becomes a world equality against a dedicated constant, and @i simply re-anchors the current-world term at that constant — no quantifier is introduced:
ST(i)(w)=w = nom_iST(@i φ)(w)=ST(φ)(nom_i)
from unicode_fol_kit import standard_translation
standard_translation(mp.parse("i")).to_unicode_str() # → 'w = nom_i'
standard_translation(mp.parse("@i P")).to_unicode_str() # → 'P(nom_i)'
standard_translation(mp.parse("@i □P")).to_unicode_str() # → '∀w0 (R(nom_i, w0) → P(w0))'
standard_translation(mp.parse("◇i")).to_unicode_str() # → '∃w0 (R(w, w0) ∧ w0 = nom_i)'
The nom_ prefix keeps the generated world constants disjoint from user constants: a formula whose atoms mention a ground constant i can never collide with the nominal i — the translation of @i P(i) is P(i, nom_i), with both symbols intact.
Because a first-order constant denotes exactly one domain element, nom_i captures the “true at exactly one world” semantics of a nominal for free once the worlds are the FO domain — no extra axiom needed.
Deciding validity: hybrid_is_valid¶
hybrid_is_valid(formula, frame=…) decides hybrid-modal validity over the frame classes K / T / S4 / S5 by closing the standard translation over the current world under the frame axioms — frame_axioms → ∀w ST(φ)(w) — and asking the Z3 validity oracle. The nominal constants stay free, and first-order validity quantifies free constants universally: that is exactly “for every nominal assignment”. First-order validity is only semi-decidable in general, but H(@) over K is decidable and these translation images are small enough that Z3 settles them instantly; True is always a real proof.
The standard H(@) validities all come out true over K:
from unicode_fol_kit import hybrid_is_valid
hybrid_is_valid(mp.parse("@i i")) # → True (i holds at the world named i)
hybrid_is_valid(mp.parse("@i P ↔ ¬@i ¬P")) # → True (@ is self-dual: one target world)
hybrid_is_valid(mp.parse("@i (P → Q) → (@i P → @i Q)")) # → True (@ is a normal modality)
hybrid_is_valid(mp.parse("@i j ↔ @j i")) # → True (world equality is symmetric)
hybrid_is_valid(mp.parse("@i @j P ↔ @j P")) # → True (an @ discards any outer jump)
hybrid_is_valid(mp.parse("(i ∧ P) → @i P")) # → True (if HERE is i, P here is P at i)
hybrid_is_valid(mp.parse("(◇i ∧ @i P) → ◇P")) # → True (a successor named i witnesses ◇P)
… while the tempting non-theorems are refuted (each fails on a two-world model):
hybrid_is_valid(mp.parse("@i P → P")) # → False (P at the i-world says nothing about HERE)
hybrid_is_valid(mp.parse("i")) # → False (a nominal fails everywhere but at its world)
hybrid_is_valid(mp.parse("P → @i P")) # → False
hybrid_is_valid(mp.parse("◇i → □i")) # → False (w may see i AND a second, different world)
Frame sensitivity¶
frame= constrains the alethic relation: "K" (no conditions), "T" (reflexive), "S4" (reflexive + transitive), "S5" (reflexive + symmetric + transitive). The frame schemas keep their usual behaviour with nominals mixed in — the T schema with a nominal conjoined still needs reflexivity, and the 4 schema pinned to a named world still needs transitivity:
f = mp.parse("(□P ∧ i) → P") # the T schema, with a nominal conjoined
hybrid_is_valid(f, frame="K") # → False (a dead-end world named i refutes it)
hybrid_is_valid(f, frame="T") # → True (reflexivity delivers P at w itself)
g = mp.parse("@i (□P → □□P)") # the 4 schema AT the world named i
hybrid_is_valid(g, frame="T") # → False (reflexivity alone is not enough)
hybrid_is_valid(g, frame="S4") # → True (transitivity validates 4 — anywhere, so also at i)
On pure modal input (no nominals) hybrid_is_valid agrees with the native tableau is_modal_valid (Modal, temporal, epistemic & deontic logic) — the tests cross-check the two oracles on the standard schemas.
The honest boundary¶
The
↓binder is deliberately out of scope. Full hybrid logic adds↓x φ(“name the current world x and continue”), which makes validity undecidable; H(@) without it stays decidable. There is no↓node in the kit.The modal tableau rejects hybrid input — cleanly, never with a wrong verdict. A labelled tableau would need extra rules to honour a nominal’s name-exactly-one-world constraint (treating it as an ordinary atom would wrongly refute
@i i), sois_modal_valid,modal_decide,modal_prove,modal_countermodel, andmodal_tableau_closedall raise on nominals:
from unicode_fol_kit import is_modal_valid
is_modal_valid(mp.parse("@i P → P"))
# raises NotImplementedError: … hybrid constructs (nominals/@) are not supported
# by the modal tableau; use hybrid_is_valid or a KripkeModel.
The direct classical exporters reject too (
Nominal(…).to_z3()and friends raise): a nominal is world-relative, so the sanctioned routes into classical reasoning are the standard translation andhybrid_is_valid.
For the plain modal machinery these constructs extend — Kripke models, the standard translation, the tableau, frames — see Modal, temporal, epistemic & deontic logic; for quantified modal logic see Quantified Modal Logic.