Skip to content

torchmodal

Differentiable Modal Logic for PyTorch

torchmodal makes the modal operators โ–ก (necessity) and โ™ข (possibility) into trainable neural layers over a Kripke structure: every truth value is an interval [L, U] that provably brackets the crisp modal-logic answer, and the accessibility relation between worlds can be learned by gradient descent instead of being specified.

๐Ÿ“ฃ Oral presentation at NeSy 2026 โ€” the 20th Conference on Neurosymbolic Learning and Reasoning. Modal Logic Neural Networks, Antonin Sulc (Lawrence Berkeley National Laboratory) and Noor Naddour (The University of Queensland). PMLR vol. 284. Read the paper โ†’

A classic epistemic puzzle, in ten lines

Three children have muddy foreheads. Each sees the others but not themselves. Their father says "at least one of you is muddy", then asks repeatedly whether anyone knows. Nobody does โ€” until the third round, when all three suddenly do. Nothing new is ever observed; the only information is that nobody else could answer.

import torch
from itertools import product
from torchmodal import functional as F

worlds = list(product([0, 1], repeat=3))          # 3 children, muddy or not
here = worlds.index((1, 1, 1))                    # all three really are muddy

for rnd in range(3):                              # each round rules out more worlds
    alive = torch.tensor([float(sum(w) > rnd) for w in worlds])
    # child 0 sees the others but not itself:
    A = torch.tensor([[float(w[1:] == v[1:]) for v in worlds] for w in worlds])
    muddy = torch.tensor([[float(w[0])] * 2 for w in worlds])
    L, U = F.necessity(muddy, A * alive, tau=0.05)[here]
    print(f"round {rnd + 1}: child 0 knows it is muddy -> [{L:.3f}, {U:.3f}]")
round 1: child 0 knows it is muddy -> [0.000, 0.000]
round 2: child 0 knows it is muddy -> [0.000, 0.000]
round 3: child 0 knows it is muddy -> [0.920, 1.000]

The textbook answer โ€” no, no, yes โ€” falls out of the modal operator alone, and every interval contains it. See examples/muddy_children.py for the full version, which checks all three children and asserts soundness at every round.

What makes this different

learnable relation between worlds sound bounds on the crisp answer native โ–ก / โ™ข
torchmodal (MLNN) โœ… learned A_ฮธ โœ… L โ‰ค crisp โ‰ค U, gap ฯ„ยทH(w) โœ…
LNN (Riegel et al. 2020) โ€” no world structure โœ… [L, U] bounds โ€” propositional
LTN (Serafini & Garcez 2016) โ€” โ€” point-valued โ€” โˆ€/โˆƒ over domains
DeepProbLog (Manhaeve et al. 2018) โ€” fixed program โ€” exact probabilities โ€”
Semantic Loss (Xu et al. 2018) โ€” โ€” scalar penalty โ€” propositional
Scallop (Li et al. 2023) โ€” fixed Datalog ~ provenance-dependent โ€”
SATNet (Wang et al. 2019) โœ… learned MAXSAT โ€” no bounds โ€”
STLCG (Leung et al. 2023) โ€” fixed time axis โ€” point-valued ~ temporal only

The combination in the first row is what is unusual: other systems either fix the relational structure and reason exactly over it, or learn structure without bracketing anything. A SemanticLoss baseline ships in this package so the comparison can be run rather than argued โ€” see examples/baseline_comparison.py.

Soundness is a property you can check

Every operator's docstring states which crisp operator it bounds, in which direction, and what the gap is. The gap is not a hand-wave โ€” it is an exact, computable quantity:

from torchmodal import functional as F

F.box_width_entropy(A, bounds, tau=0.1)   # ฯ„ยทH(w): the exact width one โ–ก level adds

This is conv_pool(x, -x) - smooth_min(x) identically (verified to 8.9e-16 in float64), it is bounded by ฯ„ยทlog n, and it tells you three things at once: how loose this โ–ก is, how many levels you can nest before the bound floors (k* = โŒˆ1/(ฯ„ยทHฬ„)โŒ‰), and how large a contradiction can hide from L_contra without producing any gradient.

Ask for a precision, not a temperature

Because the width is exactly computable, it can be inverted. Rather than guessing ฯ„ and finding out afterwards how wrong the answer might be, state what you can tolerate:

box = F.necessity(bounds, A, precision=0.05)   # bracket at most 0.05 wide โ€” guaranteed
tau = F.auto_tau(A, target_width=0.05)         # or just get the temperature

The returned temperature is always safe โ€” the realised width never exceeds the target. Pass prop_bounds= to auto_tau for the exact mode, which bisects on the true width and returns the largest ฯ„ that still meets it: 2.4ร—โ€“3.7ร— larger than the closed form on a random 12-world frame, and a larger ฯ„ means better-conditioned gradients.

Is it satisfied, or just vacuous?

Every โ–ก-built quantity is maximal on the empty relation โ€” an agent that sees nothing vacuously knows everything. So a specification written only in โ–ก has a global optimum that satisfies every axiom and coordinates nothing, and an โ„“โ‚ sparsity penalty pushes toward that optimum rather than against it.

from torchmodal.diagnostics import vacuity_report

vacuity_report(lambda A: F.necessity(phi, A)[:, 0], A)
# {'observed_value': 0.10, 'vacuous_value': 0.82, 'vacuous': True,
#  'direction': 'maximal_when_empty', ...}

Find the silently-dead term in your neurosymbolic loss

The characteristic failure of a differentiable logic is not an exception โ€” it is a term pinned to 0 or 1 whose gradient has vanished. It raises nothing; it just stops contributing while everything else keeps training.

from torchmodal.diagnostics import gradient_health

report = gradient_health(lambda: my_modal_term(A), {"A": A})
report["healthy"]   # False
report["issues"]    # ["term 'output.L' is dead: pinned at the floor (0.0)
                    #   with no gradient to any parameter"]

gradient_health splits [L, U] bounds into their two endpoints โ€” the dead state of a box neuron is L = 0 with U = 1, which neither column reveals on its own โ€” and attributes gradients per endpoint. assert_has_signal(...) is the raising variant for tests. No other neurosymbolic library ships one.

Batched

necessity, possibility and box_width_entropy take any number of leading batch dimensions โ€” (B, |W|, 2) bounds against (B, |W|, |W|) relations โ€” so training over a dataset of Kripke models needs no Python loop. Results are bit-identical to looping.

Installation

pip install torchmodal

See the API reference, the limitations for the measured caveats, and examples.