Skip to content

The mathema docstring

A second, opinionated docstring convention, stricter than the loose quality checklist mathema audit's quality column scores. The idea: a function's own docstring becomes the complete authoring surface for intent, domain, and claims, no separate YAML file or decorator required to get real, checked evidence. In ema.py:

from typing import Annotated

from mathema.types import Probability


def ema(x: list, alpha: Annotated[float, Probability]) -> float:
    """Exponentially weighted moving average.

    Intent:
        Blends each new value with the running mean.

    Claims:
        bounded: for x in [0, 1]^n, alpha in [0, 1], f(x, alpha) <= 1
    """
    y = x[0]
    for v in x[1:]:
        y = alpha * v + (1 - alpha) * y
    return y

mathema.check(ema, claims=[]) checks exactly the claims the docstring declares, and x in [0, 1]^n samples lists of any length whose entries lie in the unit interval:

import mathema
from ema import ema

print(mathema.check(ema, claims=[]))
mathema.Record(ema) · source, no side effects · form 0a80d14e175f
  holds   bounded: for x in [0.0, 1.0]^n:float|missing, alpha in [0.0, 1.0]:float|missing, f(x, alpha) <= 1 (n=160)

This is additive, a docstring with none of these sections behaves exactly as before, and every existing authoring surface (a claims file, @claims_decorator, Annotated type hints) still works unchanged.

The sections

Section What it declares
Intent: one or two sentences of prose, what the function computes
Claims: ordinary claims, see Authoring claims
Notes: free-form limitations or design rationale, optional, see below
Concepts: (or Tags:) plain concept tags, comma- or newline-separated, optional, see below
Analysis: / Evidence: / Policy: / References: (or Refs:) role-labeled links, optional, see below

All five are Google-style blocks (a bare Header: line, body indented underneath, ending at the first line that dedents back to column zero), the same scanning shape Claims: already used.

Concepts are TAGS: plain tokens naming what the function is about, normalized to kebab form (Great Circle becomes great-circle). Tags: is an accepted spelling for the same thing; internally everything is a concept. They land in the record's own concepts field and, per the CDD spec's meta example, in meta.concepts (the flat union) with the full provenance beside it under mathema.concept_sources, your declared tags never mixed indistinguishably with the mechanism-derived ones (what the proof machinery itself touched: a fold lift tags summation, an nlsat proof tags polynomial-arithmetic) or the inferred keyword hints. Entirely optional and never scored.

References are LINKS, each labeled with the role its section states: References:/Refs: for citations, Analysis: for working notes (a notebook documenting a model assumption, say), Evidence: and Policy: for exactly what they sound like. One - Title: URL line per link (numpydoc .. [1] entries and bare DOIs also parse), landing in the record's references list with via carrying the role.

def power_uncertainty(x, sigma_x, n):
    """First-order uncertainty of x**n.

    Concepts:
        error-propagation, metrology

    Analysis:
        - Assumption notebook: https://nb.example.com/gum.ipynb
    """

Both are also authorable from the declared spec: an entry-level meta: {concepts: [...]} and references: list beside the claims, and a per-claim meta: {concepts: [...]} that passes through to the recorded claim untouched, exactly as the spec's pass-through rule requires.

Notes:

Prose, same shape as Intent:, for anything worth recording that isn't itself intent, a domain, or a claim, a known limitation, why a particular approach was chosen, a caveat about the evidence. Never scored: an absent Notes: section costs nothing, unlike an absent Intent:. Written through to the verified record's meta.notes field (mathema.write_spec(fn)), record-schema.md's namespaced extension slot, nothing else currently lives there.

def half(x: float) -> float:
    """Halves x.

    Notes:
        Only tested for finite inputs. NaN handling is unspecified.
    """
    return x / 2

Inline # note: comments (any capitalisation, # Note:, # NOTE:) feed the same channel: every one in the function's body, plus the comment lines immediately following it, joins the docstring's own Notes: prose in meta.notes, deduped so a comment restating what the docstring already says never double-appends. The structured form (each note with its file line) is on Facts.comment_notes for anything that wants provenance rather than prose.

def scale(x: float) -> float:
    # note: precision loss above 1e15.
    return 2.0 * x

Where a domain goes (there is no Domain: block)

A domain is stated in the claim that needs it, as that claim's own quantifier:

"for alpha in (0, 1], f(x, alpha) <= max(x)"

For an ordinary parameter, an Annotated marker in the signature (Probability, Positive, Nonnegative) states a bound once for every claim. The signature is the one place a type belongs; there is no docstring Types: block either.

A Domain: docstring block used to sit beside these. It was removed: measured at zero real uses across a 313-file corpus, against 96% of claims carrying an inline quantifier, and it was a second declaration of the same fact that nothing ever adjudicated, free to disagree with the claims beneath it.

One thing it could express that a signature marker cannot: a bound on a name with no signature slot at all, such as a fold's accumulator or a loop item. State that with a claim-level let:

"let total be [0, 1e6], for x in [0, 10], f(x) >= total"

Symbol coverage

Every real parameter, plus every for/comprehension loop variable, is expected to appear literally somewhere in the docstring. This isn't mainly about readability; it's what feeds the derive route: a claim-level let declaring a bound for a fold's accumulator or item name gives the prover a sign/range assumption to work with that it otherwise has none of. An ordinary scratch local (a running total's starting value, an indexing-only loop counter) is exempt, only names whose range could plausibly matter to a proof are required.

def total(xs: list) -> float:
    """Sums a sequence.

    Claims:
        nonneg: for xs in [0, ...], f(xs) >= 0
    """
    t = 0.0        # ok: t is never checked for coverage
    for v in xs:   # v is checked; it's a loop variable
        t += v
    return t

Checking a docstring against the schema

>>> import mathema
>>> from mathema.docstring import parse_mathema_docstring
>>> parsed = parse_mathema_docstring(ema)
>>> parsed.score, parsed.applicable
(2, 3)
>>> parsed.conforms
False
>>> parsed.errors
["'v' used but not documented"]

score/applicable follow the same "N/M" pattern as mathema.docstring_report(); conforms requires a perfect score and zero structural errors (a Claims: header with nothing valid parsed underneath it, or an undocumented name, as here: a real authoring mistake, not just an incomplete docstring). ema's own docstring never mentions v, the loop variable, anywhere in its text, so symbol coverage genuinely fails for this example; that's the deliberate point, not an oversight.

docstring_sync(): how well the docstring tracks the code

parse_mathema_docstring() above checks the docstring's own internal shape, is Intent: there, does Claims: parse. It has no opinion on whether what's declared still matches the function it's attached to. docstring_sync() (mathema.docstring.docstring_sync) is the metric that does: it wraps parse_mathema_docstring() (as .parsed) and adds dimensions that compare the docstring against the function's actual structure; its branches, the exceptions it raises, its parameters and internal loop variables, the functions it calls, and (when one exists) its verified spec record. A docstring can satisfy every check above and still drift out of sync with the code as the code changes around it; this is the metric meant to catch that.

>>> from mathema.docstring import docstring_sync, docstring_sync_checklist
>>> sync = docstring_sync(ema)
>>> sync.score, sync.applicable
(8, 10)
>>> print("\n".join(docstring_sync_checklist(sync)))
✓ Intent: present
✓ intent concise (8 words)
✓ Claims: block in sync (names known, no surface conflicts)
✓ Claims: present (1 parsed)
· claims {11 | 1 | -} floor | actual | expected (not scored)
✓ domain declared (1/1)
✗ domain enforced
✗ symbol coverage (2/3)
✓ params typed (2/2)
✓ return typed
docsync 89% (how much of what the function does is surfaced as context)
! 'v' used but not documented

What each dimension checks:

Dimension Scored? What it compares
Intent / Claims / symbol coverage yes same as parse_mathema_docstring(), folded in as-is
claims {floor | actual | expected} no the floor is one claim per relevant claim family per target (inventory.claim_floor()), the actual is what the function carries, and the expected, how many a function of this shape typically carries; needs a corpus and reads -. Informational only, never subtracted from score
domain declared / enforced yes declared: a bound exists (a signature Annotated marker, or the quantifier of a claim in the docstring's Claims:) for each real scalar/int parameter; enforced: the function is wrapped in authoring.enforce_domain(), checked at runtime, not just documented
raises declared yes each exception the function's body can actually raise, covered by a raises(f(x), ExcType) claim, a prose Raises: mention, or a guard raise ENFORCING a declared domain (the declaration plus its own boundary check states the raise condition formally), counted once however many apply
params / return typed yes every real parameter and the return value, each backed by some type information (an annotation, an Annotated marker, or a bound from a claim's quantifier)
callees doc quality / docsync yes two points per direct callee: it documents itself (docstring plus a documented return), and its own layers are in sync (Claims: names known, record form fresh), a caller's docsync genuinely contains its callees' docsync, one hop deep and cycle-safe

domain_enforced is only applicable when at least one domain is declared in the first place (enforcing a domain nobody wrote down isn't meaningful); ema declares alpha's domain via its Annotated[float, Probability] hint but never wraps itself in enforce_domain(), hence the ✗. The "claims" line is deliberately never a checkmark; it's a hint, not a requirement. The floor is a real lower bound (a function carrying zero claims is always under it), but it is a floor and not a target: carrying more than it asks for is not an overrun, and there is nothing to divide by until a corpus can say what a function of this shape typically carries.

Run this across a whole codebase with mathema audit: docsync is an ordinary analysis in the grid by default (--exclude docsync to skip it, like any analysis). The wide table adds one compact docsync column (N/M, from docstring_sync()); mathema audit --docs additionally prints a second, separate table underneath the loose docs checklist grid, not appended to the same row, since the two measure different things:

mathema audit ema --docs
quality:
key     || has_docstring | has_summary | params | returns | raises | quality_ratio | claims   | concepts/tags
ema.ema || yes           | yes         | 2/2    | no      | -      | 4/5           | 1 parsed | -

4/5 docstring quality criteria met (1 function).

docsync:
key     || intent | notes | claims   | {min_expected|actual|est_applicable} || domain_declared | enforced || raises_declared || params_typed | return_typed || callees_doc_quality | callees_docsync || sync_score
ema.ema || yes    | -     | 1 parsed | {11 | 1 | -}                         || 1/1             | no       || -               || 2/2          | yes          || -                   | -               || 89%

mean docsync 89% (how much of what each function does is surfaced as context) (1 function).

domain_declared/enforced sit together in one group, and raises_declared in its own single-column group, the || marks a group boundary, | a column boundary within one, same convention the loose docs table already uses. the claims column is {min_expected|actual|est_applicable}: the floor for ema is 11 (one claim per claim family relevant to a function of its shape), so its one parsed claim sits under it, and expected reads - until a corpus can say what a function of this shape typically carries.

The docsync percentage

One 0-100 number per function: how much of what the function actually does is surfaced as context a developer can read, intent at every level, domains, raise conditions, types, and the same for the immediate call surface. Deliberately NOT proveability: whether the claims themselves are enough is one dimension the score doesn't own. The claim floor stays a separate flag instead, the claims triplet cell renders red when the actual count sits under the floor and amber once it clears it, never green, since without a corpus nobody can say the count is enough. Weighted and renormalized over whichever components apply to the function at hand:

Component Weight
Claims: block in sync with declared∪verified 12
intent present / concise / in sync 16 / 5 / 5
module intent present / system intent present 5 / 3
domain declared 15
raises declared (a guard enforcing a declared domain counts) 12
params / return typed 10 / 4
callees documented / callees docsync 5 / 8

Conciseness is a ramp, not a cliff: full credit through 40 words, fading linearly to zero by 80. Domain enforcement has no separate weight; it shows up through the raise-coverage rule, where a guard enforcing a declared domain covers its own exception. callees docsync is the mean of the direct callees' own percentages, each computed shallow (without THEIR callee dims) so a call cycle can't loop and a hop only ever counts once, a caller's docsync genuinely contains its callees'. The percentage is what the audit grid's docsync column, the compact/MCP docsync value, and the sync_score column all report.

Writing it back from a verified record

Once a function has been checked and recorded (mathema.write_spec(fn)), its Claims: (and Intent:, if the docstring doesn't already state one) can be regenerated from what was actually verified, only claims that held or were proven are written back, each tagged [derive] when the verdict came from the derive route:

from mathema.docstring import render_docstring

record = mathema.write_spec(ema, domain={"alpha": (0.0, 1.0)})
print(render_docstring(ema))
Exponentially weighted moving average.

Intent:
    Blends each new value with the running mean.

Claims:
    bounded: for x in [0.0, 1.0]^n:float|missing, alpha in [0.0, 1.0]:float|missing, f(x, alpha) <= 1

render_docstring() returns text only; it never writes to the .py file. A domain gets no block of its own: each claim carries its region in its quantifier, written back with the claim, and a bound from a signature marker or a domain= argument stays where it was stated, so there is nothing separate to write back for it.

Creating a docstring that isn't there yet

The other direction of the same loop: a function with no docstring at all can have one generated from its declared and verified layers. A claims entry may carry an intent: field alongside its claims: list (it rides through the merge like any other field); that intent, plus the entry's claims (verified held/proven claims preferred, declared claims otherwise), is enough to write a complete docstring, summary line, Intent: block, Claims: block:

mypkg.funcs.bare:
  intent: Passes x through unchanged.
  claims:
    - name: identity
      statement: "f(x) == x"
      route: derive

docstring.generate_docstring(fn) returns the proposed text (None when a docstring already exists, generation only ever creates, a present docstring is the human source of truth) and docstring.write_docstring(fn, text) inserts it into the source file, refusing outright if one is present. The CLI face is mathema docsync <target>: functions with a docstring get their sync checklist, functions without get the proposal printed, and --write offers each insertion behind a per-function y/N prompt, never a silent write, same posture as mathema describe --issue.

Intent above the function

Intent: is the load-bearing marker, and it isn't only a per-function one: a module docstring can carry its own Intent: block (or its first line stands in), and a project README can state codebase-level intent under an ## Intent heading or an Intent: block. docstring.intent_context(fn) reports all three levels, each tagged with the SHAPE the intent was authored in: explicit for the paragraph following an Intent: marker (or a marked README section), implicit when the first paragraph stands in. Shape is not rung: every stated intent sits at the declared rung until a human accepts it (mathema accept <key> --intent), which is what writes meta["mathema.intent_provenance"]: documented to the verified record; intent is its own function-level section of the record, never a claim. docstring_sync()'s checklist shows the hierarchy as an unscored context line. Context is reported, never inherited: a function with no intent of its own is still a gap, whatever its module or README says; the point is to see where intent is missing, not to paper over it.

Evidence rungs and the docsync verb

Any stated intent; the explicit Intent: block included; sits at the declared rung; documented is earned only through mathema accept <key> --intent (a human act, bound to signature + raises + the text). Claim NAMES in a Claims: block are the keys that join the declared layer; the statement prose is yours to humanize; the record is the truth, mathema docsync reports drift, and writing missing names into a block is the explicit --write-docstrings opt-in. Inline #tag spellings anywhere in the docstring author concepts. See docsync.