Reasoning about theories
The core data model carries light syntactic types: an argument declaration names a base type or a defined type, and that is the whole of it. This is roughly the Souffle level of expressiveness, and it is deliberate — the core stays close to Common Logic.
The optional metatheory adds a layer with a second-order flavour without changing the core or introducing a second formalism. It works by reifying a theory into ordinary ground facts, so that rules about types are ordinary first-order rules, run by whichever solver you already use.
theory ──reflection──▶ ground facts ──┐
├──▶ solver ──▶ diagnostics
metatheory axioms (@axiom) ─────────┘
A first check
from typedlogic import Theory
from typedlogic.datamodel import And, Forall, Implies, PredicateDefinition, Term, Variable
from typedlogic.theories.metatheory import check_theory
x = Variable("x")
theory = Theory(
name="branded",
type_definitions={"PersonID": "str", "OrgID": "str"},
predicate_definitions=[
PredicateDefinition("Person", {"id": "PersonID"}),
PredicateDefinition("Org", {"id": "OrgID"}),
PredicateDefinition("Flag", {"id": "PersonID"}),
],
)
theory.add(Forall([x], Implies(And(Term("Person", x), Term("Org", x)), Term("Flag", x))))
print(check_theory(theory))
Sentences: variable 'x' is used at positions declared 'OrgID' and 'PersonID', which are not compatible
PersonID and OrgID are both strings, so no signature is individually wrong. The
problem is that one variable spans both — a property of the rule, not of any declaration,
and exactly the kind of thing a per-argument type annotation cannot express.
The three pieces
| Module | Role |
|---|---|
typedlogic.theories.metatheory.vocabulary |
Predicates describing a theory: signatures, type definitions, rule structure |
typedlogic.theories.metatheory.reflection |
Turns a Theory into ground facts of that vocabulary |
typedlogic.theories.metatheory.axioms |
The rules deriving subtyping, variable types, clashes, and stratification |
The axioms are plain @axiom functions. Nothing about them is privileged:
@axiom
def type_clash(rule: RuleID, var: VarName, left: TypeName, right: TypeName):
if VarType(rule, var, left) and VarType(rule, var, right) and -Compatible(left, right):
assert TypeClash(rule, var, left, right)
What it checks
TypeClash— a variable used at two positions whose declared types are incompatible.ConstantClash— a literal at a position whose declared type cannot hold it.UndeclaredPredicate— a rule referencing a predicate the theory never declares.UnstratifiedNegation— a predicate on a recursive cycle passing through a negation.
The last one is the useful demonstration that this is not only about sorts. Stratification is a property of the rule graph, and it falls out of the same machinery:
@axiom
def unstratified_negation(p: PredicateName):
if DependsNegatively(p, p):
assert UnstratifiedNegation(p)
Adding your own rules
Because the check is a solve, adding a rule adds a check. Write an @axiom over the
same vocabulary and pass the module in:
@dataclass(frozen=True)
class UnbrandedArgument(Fact):
predicate: PredicateName
position: int
@axiom
def flag_unbranded_string_arguments(p: PredicateName, i: int):
"""An argument declared as a bare string carries no brand to enforce."""
if ArgType(p, i, "str"):
assert UnbrandedArgument(p, i)
check_theory(theory, extra_axioms=[house_rules], extra_diagnostics=[UnbrandedArgument])
No change to the checker is needed. This is the practical payoff of keeping types as data rather than as a fixed pass: project-specific conventions become enforceable without forking the framework.
Deliberate limits
Descriptive, not prescriptive. The checker reports what it can derive; it does not require you to annotate anything. An unannotated theory is analyzed as far as its declarations allow, and no further.
Opaque types constrain nothing. A type that does not resolve to a base type — an enum
class, or a name left over from an annotation the Python introspector could not reduce,
such as Optional — is treated as compatible with everything. Reporting an
incompatibility there would be reporting the checker's own ignorance.
Reflection is partial. Constructs the vocabulary cannot describe — arithmetic
subterms, builtin comparisons, sentences with no Horn form — are skipped rather than
approximated. A clean result is therefore bounded by what was skipped, which
CheckResult.skipped makes inspectable:
result = check_theory(theory)
if result.ok and result.skipped:
print(f"clean, but {len(result.skipped)} sentences were not analyzed")
Negation: -P not not P
Inside an @axiom, -P(...) is negation as failure and not P(...) is classical
negation. They are not interchangeable. A classically negated body literal is lifted into
a head disjunction on the way to the solver, so an answer-set solver may satisfy the rule
by choosing the negated atom instead of your intended conclusion — a check written with
not reports whichever findings the chosen model happens to contain. Diagnostics need
"cannot be derived", which is what - means.
Solver support
The default is Clingo, which supports the stratified negation the axioms use. The
negation is stratified by construction: TypeClash negates Compatible, which never
depends on TypeClash.
Relation to mypy
The Python surface is checked by mypy, which is prescriptive: you declare, and code that disagrees is rejected. The metatheory is descriptive and complements it, because the properties above are invisible to mypy — it cannot see that a variable spans two incompatible argument positions of a rule, nor that a rule set recurses through negation. The two layers check different things, and neither subsumes the other.