Skip to content

Authoring claims

Four surfaces, one precedence order (lowest to highest, a same-named claim from a higher surface overrides a lower one), and one grammar they all share.

The claim grammar

Claims are written over the generic function name f and the function's own parameter names, in a typeable superset of ordinary math:

f(-x) == -f(x)                      # odd
f(x)^2 >= 0                         # ^ is power; == / <= / >= are the relations
f(x) ≤ f(y)                         # Unicode ≤ ≥ − · × π accepted, normalized
for x in [0, 1], f(x) <= 1          # domain quantifier
raises(f(x), ValueError)            # partiality: asserts the call raises
f(x) == g(x)                        # relate two implementations (funcs={"g": other_fn})

Shorthand: |x|/||x||/⌊x⌋/⌈x⌉ for abs/norm/floor/ceil, each wrapping any expression, so |x + y - f(x, y)| is the absolute value of the whole difference; !=/≈ as their own relations. Every claim renders to LaTeX exactly (mathema.grammar.to_latex), going through sympy so it's mathematically faithful, not typographically literal.

A domain quantifier's range only ever adds a sign assumption (positive/nonnegative) for the derive route's proof, never a substitution, for x in [0, 5], ... proves things true for every x in that range, it doesn't pick one. The one exception is a degenerate, single-point range (for c in [0, 0], ..., lower bound equal to upper bound): that genuinely does pin c to the literal 0 before the claim is decided, since there's only one value left to assume anything about. To check a claim at any other specific value, pass it as a literal argument to f(...) directly (f(a, b, 0)) rather than declaring a domain for it, ordinary symbolic substitution, unrelated to the domain mechanism.

Three more forms, derive route only, that turn ordinary claims into calculus, PDE, and (mechanically) SDE claims:

d(f(t, x), t) == d(f(t, x), x, x)   # partial derivatives; this is the heat equation
lim(f(x)/x, x, 0) == 1              # limits (point: a constant, or oo / -oo)
integrate(f(x, mu, sigma), x, -oo, oo) == 1   # definite/indefinite integrals
Sum(f(i), i, 0, n) == n*(n+1)/2     # indexed sums/products

Shorter spellings for a single-parameter derivative, when there's no variable to name at all: d(<expr>) (the argument's own single free name is inferred) and prime notation, f'(x)/f''(x)/... (one apostrophe per differentiation):

d(f(x)) == 2*x        # f has one parameter, x, same as d(f(x), x)
f'(x) == 2*x           # same claim again
f''(x) == 2            # d(f(x), x, x)

The traditional df/dx fraction spelling is also accepted, but only inside a d(...)/∂(...) call (d and ∂ are interchangeable throughout this sugar), where the intent (differentiate) is unambiguous even though the variable isn't; d(f(x)/dx) is the same claim once more. A bare, unscoped df/dx anywhere else in a law is never read this way: at the text level it's ordinary division by a variable that happens to be named dx, with no calling context to disambiguate it. The variable guessed from a fraction's denominator is checked against the function's own real parameters once they're known. A genuine collision (gibbs_free_energy(dh, t, ds), a real parameter literally named dh) is skipped:misspecified rather than silently guessed wrong, with a note naming the unambiguous d(expr, h) form to use instead. A ∂-marked denominator name is never ambiguous this way (∂ can't appear in a real Python identifier at all), so ∂(f(dh,t,ds)/∂h) == -ds never needs that check.

A mixed or higher-order denominator concatenates one marker per variable, each with its own optional exponent (superscript digits or ^<n>, matching how \frac{\partial^3 f}{\partial x^2 \partial y} is conventionally written):

d(f(x,y)/dx^2dy) == 0       # d(f(x,y), x, x, y), order 2 in x, order 1 in y
∂(f(x,y)/∂x²∂y) == 0        # the same claim, curly and superscript

The call's own opening symbol may carry a restated total order (d^3(...)/d³(...)), purely decorative unless it disagrees with what the denominator's own exponents sum to, in which case the whole call is left unexpanded as a likely typo:

d^3(f(x,y)/dx^2dy) == 0     # 2 + 1 == 3, consistent

An evaluation marker, @{v=val, ...} (or the word at instead of @, either with optional whitespace around it), states a claim at a specific point rather than across a whole domain: d(<expr>, <vars...>)@{v=val, ...} differentiates first, then substitutes each named variable (an extremum/critical-point claim; "the derivative is exactly zero here", not just "nonnegative everywhere"). Composes with every other d(...) spelling above:

d(f(v0, theta, g), theta)@{theta=pi/4} == 0       # range is maximized at 45°
d(f(v0, theta, g)/dtheta) at {theta=pi/4} == 0    # same claim again
d(f(x)/dx^2)@{x=1} == 6                           # differentiates twice, then substitutes
f'(x) at {x=1} == 3                               # prime notation, one free variable

Prime notation takes its variable from the call's single free name, so it only reads on a one-variable call. f'(v0, theta, g) has three, and claim() refuses it with an InvalidConjecture that names the explicit spelling, d(f(v0, theta, g), <var>).

integrate(<expr>, <var>)|_{a}^{b} (matching LaTeX's own \big|_a^b convention) is pure sugar for the already-existing bounded 4-argument form. A bound needs braces only when it contains its own space or comma, a plain token, however long, doesn't:

integrate(f(x, mu, sigma), x)|_{-oo}^{oo} == 1    # same as the 4-arg form above
integrate(f(x), x)|_0^oo == oo                    # a bound needs no braces on its own

∫ and the word integral are synonyms for integrate, and a bracket-free form drops the parens entirely, chaining one bound-bar per differential (each variable may itself be omitted when the expression has exactly one free name):

integral f(x)|_0^1 == 1/2                         # var omitted, no parens
integral f(x,y) dx|_0^1 dy|_0^2 == 1              # sympy's own multi-bound integrate()

A bracket-free form of lim drops the parens: lim f(x), x -> a, or lim f(x) as x -> a ("as" reads the way this is conventionally spoken, and is a pure synonym for the comma). Unlike d/integrate, lim's variable is always named explicitly; lim f(x) -> a reads ambiguously without it, so that shorter spelling isn't accepted.

raises(...) only ever proves an explicit raise reachable through a domain-conditioned branch, never an exception implicit in an arithmetic operation (1/(1+r) genuinely raising ZeroDivisionError at r = -1, with no if guard anywhere). It also needs the domain declared on the guard's own parameter, not just a literal call argument that happens to trigger it, branch pruning evaluates the guard condition against the function's own declared parameter domains, before any f(...) call-site substitution even happens:

def f(a, b, c):
    if a == 0:
        raise ValueError("a must be nonzero")
    return b / a + c
raises(f(a, b, 0), ValueError)              # falsified, a's own domain isn't declared, so a sampled a != 0 returns
for a in [0, 0], raises(f(a, b, c), ValueError)   # proven

Set-notation domains: exclusion, union, type refinement, missing values

A quantifier binding leans on ordinary set notation, and accepts several spellings of each operator, all canonicalize to the same domain internally, so pick whichever reads best:

for x in [0, 1], ...            # membership: in / ∈ / \in / \elem
for x ∈ (-oo, 0]: ...           # same operator, different spelling

Exclusion (\ set-minus, or the exclude={...} keyword form) removes specific points; a pole, say, from an otherwise ordinary interval:

for r in [-1, 1] \ {1}, ...                    # excludes the single point r=1
for r in [-1, 1], exclude={1}, ...             # same thing, keyword form

Union (| or ∪) combines two or more pieces into one domain, for a genuinely piecewise range:

for x in [-10, -1) | (1, 10], ...              # two disjoint intervals
for x in [-10, -1) | (1, 10] \ {5}, ...        # a union, with a point excluded from it

Type refinement (⊂, or the text spellings \sub/\subset) narrows a domain to integers or naturals; omitted, a real (R) domain is assumed, matching how an unrestricted parameter already needs no domain entry at all:

for n in [0, 10] ⊂ Z, ...                      # a bounded integer domain
for n in Z, ...                                # bare, unbounded, still a stated type

Missing values

A sequence-typed parameter (a list/array/Series, not a scalar) can contain a missing value, None, NaN, or another library's own null sentinel. Whether that's allowed is governed by one rule, applied the same way regardless of which library produced the vector: a missing value is allowed unless the domain excludes it with \ {∅}. Stating a type does not change this.

for x in [0, 100], ...              # missing allowed
for x in [0, 100] ⊂ Z, ...          # missing allowed
for x in [0, 100] \ {∅}, ...        # missing excluded
for x in [0, 100] ⊂ Z \ {∅}, ...    # missing excluded

The missing-value sentinel is ∅, or the ASCII spellings missing/NA/nan. Writing ∪ {∅} (or ∪ {missing}) states the default explicitly and changes nothing.

Detection is dependency-free: None, a Python/numpy/pandas float NaN (all ordinary IEEE-754 under the hood, caught by one self-inequality check with no import of any of them), and a small, extensible set of other known missing-sentinel type names (pandas' pd.NA/pd.NaT, neither of which is NaN-like), never a hard dependency on any one data-science library.

Input stays terse, nothing above is required beyond an ordinary interval, but every place mathema renders a domain back (a proof sketch's ∀ x ∈ ... clause, enforce_domain()'s own violation message) always states the resolved missing-value policy explicitly, via the same ∪ {∅} / \ {∅} notation, never left for a reader to infer from what's absent:

∀ x ∈ [0.0, 100.0] ⊂ ℝ ∪ {∅}     # from [0, 100]: missing allowed
∀ x ∈ [0, 100] ⊂ ℤ ∪ {∅}         # from [0, 100] ⊂ Z: missing allowed
∀ x ∈ [0, 100] ⊂ ℤ \ {∅}         # from [0, 100] ⊂ Z \ {∅}: missing excluded

enforce_domain() (and, in strict=True probing, the domain_enforced prober) checks a sequence argument element by element against its declared domain, including this missing-value policy, an out-of-bounds or unexpectedly-missing element is rejected the same way a scalar argument outside its own domain already is.

Calling the derive route directly

A bare string claim leaves the route to the cascade (mathema.claims.claim(law) defaults to best: derive where the function lifts, seeded sampling otherwise, and the record names whichever mechanism actually decided). To ask for a proof first, pass route="derive":

import mathema

r = mathema.check(my_function,
                  claims=[mathema.claims.claim("f(-x) == -f(x)", route="derive")])

mathema.check() accepts a pre-built claim object in claims= exactly as it accepts a string, nothing else about the call changes. To call the derive route standalone, without check()'s own built-in-law probing:

results = mathema.claims.check_conjectures(
    my_function, [mathema.claims.claim("f(-x) == -f(x)", route="derive")])

Either way, a decided proof comes back proven, or falsified with a witness. A claim the derive route cannot decide falls back to probing rather than stopping at unknown, so it can still come back holds, and Probe.route then names probe, the mechanism that actually decided. The derive diagnosis stays on the record, and covers two different things: a genuinely unliftable function, or one the derive route lifted fine but the specific claim stayed undecided. Probe.meta["mathema.derive_status"] ("unliftable" or "undecided") tells the two apart programmatically, rather than parsing .note/ .sketch text.

Case-split retry for an undecided claim

When the ordinary single-context proof comes back undecided, the derive route tries once more: if the function's own body has a pole, a stationary point, or a domain-transition point (a sqrt/log/abs argument crossing zero) strictly inside the declared domain, it splits the domain there and re-proves the claim on each piece separately. This is strictly a retry, never a first attempt, an already-decidable proof pays no extra cost. A claim over abs(x - 2) - (x - 2) >= 0 on x in [-5, 8] is a real example: undecided in one shot, proven once split at the kink x = 2, with the sketch stating the split point explicitly. A pole is excluded from both pieces and never separately checked, since the function is undefined there; the sketch says so rather than silently treating it as covered.

mathema.critical_points(fn) exposes the same detection directly: every pole, stationary point, inflection point, and domain-transition point mathema can find analytically in fn's own body, as a list of {"kind", "variable", "at", ...} dicts. Never raises, an unliftable function returns [].

extensive: widening the analysis at real, opt-in cost

mathema.check(fn, ..., extensive=True) (also accepted by probe() and check_conjectures() directly) widens the critical-point analysis behind sampling hints, is_pole_safe[...], and the case-split fallback to also consider a fold/dot/sum-lifted function, not just a directly liftable one. This can make a partially-liftable function's own sampling genuinely hybrid, part real symbolic resolution, part empirical, not just the same analysis run slower. Off by default everywhere; results are cached by the function's form, so the cost, when paid, is paid at most once per distinct function shape per process.

On the derive route, extensive is a strategy ladder, not just a wider budget: exact real-root isolation (Sturm), adaptive interval refinement over the domain box, change-of-variable substitutions (t = sqrt(x)/erf(x)/tanh(x), atan compactification for unbounded claims), residue contour evaluation for trigonometric integrals, and finally a widened single-shot retry of the base procedure. With the optional mathema[smt] extra installed, an nlsat rung joins after interval refinement: z3's nonlinear-real-arithmetic procedure decides polynomial/rational sign questions (radicals encoded exactly, Abs/Min/Max as if-then-else) completely, a proof names the oracle in the sketch, and a candidate counterexample is re-confirmed by exact arithmetic before any disproof is reported. Without the extra the ladder simply skips the rung. Each rung runs under the extensive wall-clock budget, which also reaches the lowering-time limit/antiderivative evaluation inside law parsing.

The two wall-clock budgets are environment-tunable for a whole process, the same read-once-at-import pattern as MATHEMA_UNICODE: MATHEMA_FAST_TIMEOUT (default 3 seconds) and MATHEMA_EXTENSIVE_TIMEOUT (default 15 seconds), whole seconds; the knob for a claim whose computation is long but finite.

is_pole_safe(param) / is_builtin_safe(param)

Two family-derive-only predicates (route="derive" always, neither has a probe-route meaning), suggested automatically wherever they apply: is_pole_safe(param) asks whether param's declared domain provably excludes every pole mathema can find in the function's own body (including gamma/loggamma's own pole at every non-positive integer of their argument, not just an ordinary denominator's finite root set); is_builtin_safe(param) asks whether param's declared domain is safe for every restricted-domain math function it's actually passed to (factorial/sqrt/log/asin/acos/gamma/lgamma, sympy's own symbolic generalization is often wider than the real function's own accepted domain, e.g. factorial generalizing continuously via gamma while math.factorial itself only accepts a non-negative integer). Both report proven/falsified, an exact symbolic check, not a sampled one, or skipped:unsure when genuinely undecidable, never a guess. Only suggested for a parameter where it's actually relevant (a real pole found, or a real restricted call using that parameter), the same always-surfaced-when-relevant spirit as the rest of the safety family. Whether the code REJECTS out-of-domain input is the declared excluded_outside_domain(p) claim, stated explicitly, opted into with the excluding keyword, or auto-declared by @enforce_domain (the decorator that makes it true).

mathema.suggest_claims(fn)

Suggests candidate claims for fn without checking any of them: monotonicity, affine-ness, and convexity/concavity per real scalar parameter (each already expressible via the d(...) sugar above, including d(f(x)/dx^2) for a second derivative), plus a raises(...) claim per parameter guarded by an if ...: raise ExcType(...), the stateless cluster (is_deterministic/is_state_safe, plus is_reproducible where randomness is structurally detected), and the gated safety predicates. Suggested routes are best (the cascade decides at check time); safety predicates always adjudicate on the examine route whatever was declared; they are facts about the implementation. A suggestion may still come back unliftable/undecided when actually checked; that's expected for a declared, unverified claim. Battery keywords are the terse way to opt in: check(fn, claims=["defined", "excluding", "stable", "stateless"]), each expands to the structurally relevant members (see the notebook's safety-family section).

mathema.check(fn, claims=mathema.suggest_claims(fn))   # use directly
mathema.suggest_claims(fn, write=True)                 # or persist as declared

write=True appends to claims/<key>.claims.yaml, merging by claim name with anything already declared there rather than overwriting it.

Exploring a function symbolically

Beyond checking a specific claim, lift(), the same function the derive route uses internally, is directly callable to see what a function reduces to symbolically, useful when developing a claim interactively (a notebook, a REPL) rather than running the whole check:

import mathema

facts = mathema.analyze_source(my_function)
lifted = mathema.lift_symbolic(my_function, facts)
lifted.expr        # a real sympy.Expr (or tuple of them, for a tuple return)

lifted.unicode/lifted.latex are the same rendered forms a claim failure message shows; mathema.grammar.to_canonical(lifted.expr) renders it in mathema's own claim-grammar spelling (abs(x), max(x, y)) rather than sympy's own (Abs(x), Max(x, y)), the same text a claim law would actually be written in. If lift() returns None, mathema.inventory.derivability_report(my_function) explains why (the same diagnosis mathema audit's blocked detail uses), a loop, a branch, recursion, or a non-scalar parameter, with a specific reason rather than a generic refusal. inventory is a submodule, not re-exported at the top level: import mathema.inventory first.

1. Inferred from types (lowest precedence)

Automatic, no claim actually typed, via typing.Annotated or a docstring shorthand:

from typing import Annotated
from mathema.types import Probability, Shape

def bayes_update(prior: Annotated[float, Probability],
                 likelihood: Annotated[float, Probability]) -> Annotated[float, Probability]:
    ...   # prior/likelihood/return all auto-sampled inside [0, 1]

def matmul(a: Annotated[list, Shape("m", "n")],
          b: Annotated[list, Shape("n", "p")]) -> Annotated[list, Shape("m", "p")]:
    ...   # auto-checks: real output shape matches (m, p) for whatever m/n/p got sampled

The signature is the one place a type belongs: there is no docstring spelling for markers (an earlier Types: block was removed as superfluous to the typing system). A bound for a name the signature can't carry, a fold's own accumulator or item variable; goes in the claim that needs it, as a let name be [lo, hi] binding. An Annotated hint always wins on a name collision.

2. Docstring Claims: block

def f(x):
    """Claims:
        odd: f(-x) == -f(x)
        nonneg [derive]: f(x) >= 0
    """

name [route]: statement per line, route optional (probe/derive, defaults to best, the cascade decides, same as a bare claims-file row; a tag transfers through conflict resolution only when you wrote one). The block ends at the first line that dedents back to column zero. Google/Napoleon-style header (one line ending in :), not a numpydoc dash-underlined section; a claims block is list-like, one claim per line, not prose.

3. Decorator

@mathema.claims_decorator("f(-x) == -f(x)")
def f(x):
    ...

Structured data, parsed at decoration time, a typo in the law string surfaces immediately, as an exception right at import. On a name collision with a docstring claim, the decorator wins: structured-and- eagerly-checked beats freeform-and-silently-stale.

4. A claims file on disk

*.claims.yaml, claims/*.yaml, claimspec.yaml, anywhere in the tree. Several files can declare claims for the same function, even in different grammars; they merge by claim name, not whole-file replace.

# claims/softmax.claims.yaml
functions.softmax:
  claims:
    - name: sums_to_one
      statement: "sum(f(scores)) == 1"
      route: probe

A hand-written file is the most deliberate, most reviewable place to override a claim, so it always wins over anything the function itself declares.

A claim with no name: is named from its statement, with its relation as a word: f(x) >= 0 is f_x_ge_0 and f(x) <= 0 is f_x_le_0. The name keys the claim's pins, locks and verified row, so it never depends on where the claim sits in a list. Two different claims that would take the same name (two domains of one law, say) are refused, asking for an explicit name: on each.

Claims files are the declared layer, and every field in one is a field mathema reads. A field it does not know is refused (exit 2, one line naming the file, the key and the field), with a "did you mean" when it is a near miss of a real one. Annotations have fields of their own:

Field Where Holds
note: on a claim free text
meta: on a claim or the entry structured data
references: on the entry links (title, url, via)
meta: {concepts: [...]} on the entry tags
functions.softmax:
  meta: {concepts: [probability]}
  claims:
    - name: sums_to_one
      statement: "sum(f(scores)) == 1"
      route: probe
      note: "agreed with the modelling team; see the design doc"

Prefer note: to a YAML # comment. Some commands rewrite a claims file from its parsed form (docsync --yes resolving a conflict, an acceptance retiring a claim), and a comment does not survive that; a note: is part of the claim and persists through every rewrite. docsync warns before it rewrites a file that has comments.

Experiment before you declare

Try a spelling before it reaches an authoring surface. Both of these adjudicate a claim against the live function and write nothing to the store:

mathema check mypkg.mod.fn --claim "for x in [0, 5], f(x) >= 0"
mathema.check(fn, claims=["for x in [0, 5], f(x) >= 0"])

This matters because a DECLARED claim that falsifies is permanent evidence. The verified layer never silently shrinks: deleting the claim from your claims file does not remove it, because the record repopulates it by name, so a failed experiment leaves a falsification that only a human decision clears (fix the code, accept it --as discovery, or supersede it). That is deliberate, it is what stops an inconvenient result from being quietly deleted, but it means the cheap place to be wrong is check, not a claims file. verify says so the first time a claim falsifies on its first adjudication.

All four funnel into the same shape

Every surface resolves through spec.declare(), so nothing downstream cares which surface a claim came from. mathema.write_spec()'s worked example below shows claims from three different sources adjudicated together with zero manual wiring, for this softmax:

import math
from typing import Annotated

import mathema
from mathema.types import Shape


def softmax(scores: Annotated[list, Shape("n")]) -> Annotated[list, Shape("n")]:
    """Normalised exponentials of a list of scores.

    Claims:
        sums_to_one: sum(f(scores)) == 1
    """
    top = max(scores)
    exps = [math.exp(s - top) for s in scores]
    total = sum(exps)
    return [e / total for e in exps]
>>> mathema.write_spec(softmax, root='.')
mathema.Record(softmax) · source, no side effects · form 7982b776d687
  holds   shape: shape(softmax(scores)) == ('n',), for shared dims ['n'] (n=32)
  holds   is_deterministic: f(scores) = f(scores) (n=192)
  holds   is_state_safe: f(scores) = f(scores) (n=48)
  holds   is_numerically_stable: let g = mathema.f.finite_no_error, g(f, scores) = 1 (n=192)
  holds   preserves_length: dim(f(scores), 0) = dim(scores, 0) (n=192)
  FALSIFY is_permutation_of_input: sorted(f(scores)) = sorted(scores)
           counterexample ([0, 6.12225]): [0.0021887084924676944, 0.9978112915075322] vs [0.0, 6.122252531363742]
  holds   preserves_type: type(f(scores)) = type(scores) (n=192)
  FALSIFY is_sorted_output: is_sorted_output(f(scores))
           counterexample ([4.86304, 8.4521, -9.06059, -3.61645]): output [0.02688154996295693, 0.9731128430407592, 2.412672431510259e-08, 5.582869559580238e-06] fails is_sorted_output
  holds   sums_to_one: sum(f(scores)) = 1 (n=192)

shape came from the Annotated[list, Shape("n")] hints, sums_to_one came from the docstring Claims: block, and the rest are mathema's built-in battery: every function gets the determinism, state and stability probes, and a list-in, list-out function also gets the sequence laws. Two of those rightly falsify, because softmax neither permutes nor sorts its input.

Shape markers and their shorthand

A nested-list parameter's dimensions are declared with a Shape marker, and Vec/Mat are shorthand for the common spellings:

from mathema.types import Vec, Mat

def matvec(a: Mat("m", "n"), x: Vec("n")) -> Vec("m"):
    return [sum(a[i][j] * x[j] for j in range(len(x)))
            for i in range(len(a))]

Vec("n") is exactly Annotated[list, Shape("n")] and Mat is the same factory under a name that reads better for two dimensions. A dimension name shared across parameters (n above) is drawn to one size every trial, so the arguments are conformable, and each name is a first-class symbol a claim can reference in a premise or a law over its dimensions.

A claim may also state a parameter's space in its own domain (for a in R^(p,q), ...). Where a claim and a marker both describe the same parameter, the marker is authoritative on RANK: a claim that gives it a different number of axes is a conflict, skipped with the reason. A different NAME at the same axis is not a conflict, it aliases the claim's name to the signature's, so the two are one dimension.