mathema badges¶
Three orthogonal measures of a codebase, each 0-100, and one picture.
Badges now come from mathema-ci:
pip install mathema-ci, then mathema-ci measure --out, which runs your
tests, scores the project and writes the badge files. The writer built into
mathema is off by default, so mathema badges and the MCP badges tool
only print a line saying this, write nothing and exit 0. Setting
MATHEMA_BADGES=on in the environment turns it back on, as every command
below assumes, and 0.6.5 removes it. The scores themselves are still
mathema's, so the rest of this page describes what mathema-ci measure
reports too.
MATHEMA_BADGES=on mathema badges [targets] # print the triangle + numbers
MATHEMA_BADGES=on mathema badges [targets] --out .mathema/badges/ # also write artifacts
MATHEMA_BADGES=on mathema badges [targets] --refresh # score every function again
Omit targets for the rootwide analogue of verify (every function the
store knows under --root).
A run reads the stores, the claims files and the library claims once,
so its cost grows with the number of functions it scores. On a terminal
it reports progress to stderr every 50 functions, with the count and the
time elapsed, and prints nothing extra when stderr is not a terminal, so
a CI log or an --out directory stays as it was. Each function's three
steps stop at the badges step cap (60 seconds unless
MATHEMA_BADGES_STEP_TIMEOUT sets another), and a function whose step
stopped is named on stderr and in the snapshot's capped list: its
implementation then counts only the lines a fresh test report covers,
and its intent or clarity is left out of that score's roll-up.
The functions are scored in --jobs N worker processes (the CPU count,
up to 4, by default; --jobs 1 scores them in the one process), and the
results are rolled up in the same order whatever N is, so the scores do
not depend on it. Each function's result is kept in
.mathema/cache/badges/, which carries its own .gitignore and is
never committed. A later run uses a kept result only when nothing that
function's result is computed from has changed:
- the function itself and everything it reaches: the functions and classes its source calls, theirs in turn, and any function a reached verified record lists among its dependencies, each by its source, its place in its file, its module docstring and the values of the module-level names it reads, with its verified record and its claims-file entry;
- for implementation, whether the test report is fresh for the function's file and which of its lines the tests ran;
- what every function shares: mathema's version and its own code, the
sampling seeds, the Python version and platform, the
MATHEMA_*environment, the installed packages and their versions, the library claims,README.md,pyproject.toml,.mathema/index.yamland.mathema/meta.
So editing one function scores that function and its callers again, and leaves every other function's result in place; a second run on an unchanged project takes seconds and gives the same numbers as a run without the cache. A result is never kept when any wall-clock cap fired while scoring it, a capped step or a proof cap inside its check, since that result depends on how loaded the machine was; such a function is scored again on every run.
The reach is what the source names. A function reached only at run
time, through a name the source does not bind (a callable looked up in
a table, passed in, or built by getattr), is not part of it, so an
edit to that function leaves its callers' kept results in place.
--refresh is the full recomputation: it scores every function again
and rewrites the cache. Deleting the directory clears the cache too.
From Python, repo_badges(cache="refresh") does the same, and
repo_badges(cache=False) neither reads nor writes the cache.
The three badges¶
- implementation (code layer): the raw line ratio from
mathema coverage, how many statements a test, a probe, or a derive proof exercises (their union). A physical ratio, so it is neither averaged nor weighted. - intent (spec layer):
docsync, how much of what each function is meant to do is explicitly specified and up to date, so intent that lives only in someone's head shows up as a gap. - clarity (behaviour layer): how much is KNOWN about a function's behaviour, scored from its VERIFIED claims. See below.
The two per-function-quality scores, intent and clarity, roll up to the repo by a centrality-weighted mean: a function the rest of the codebase depends on (by PageRank over the call graph) counts for more than a leaf helper.
The test report behind implementation¶
The test source of implementation comes from a coverage report that
already exists at the root, coverage.json first, then a native
.coverage. mathema reads it and never runs your tests, unless you ask:
mathema coverage [targets] --run-tests # re-run the tests under coverage first
mathema coverage --stamp # stamp a report produced elsewhere
A report only counts while it still describes the code. Its lines are
keyed by line number, so once a source file changes they can point at
the wrong statements. Those lines are then left out of the score and
reported as reclaimable by a re-run. Freshness is judged per source file,
in one of two ways, and mathema coverage prints which one it used:
- by content hash, when the report is stamped.
coverage.sources.jsonsits beside the report and holds the sha256 of every file it measured, plus the report's own hash. A file's lines count exactly while its content still matches. This holds anywhere the report travels: a fresh checkout, a CI artifact, a cache. A stamp written for a different report is ignored. - by file modification time, when there is no stamp. A file is stale when it was modified after the report was written. That is only reliable on the machine that ran the tests, because a checkout resets file times.
--run-tests stamps the report it produces. A report made by another
tool, for example pytest --cov in CI, needs mathema coverage --stamp
run once afterwards, in the same tree. Stamping costs a hash of each
measured file, milliseconds even for a large package.
When the tests run in parallel mode, or measure subprocesses, coverage
writes one data file per process (.coverage.<suffix>). --run-tests
combines them before exporting, so lines executed in a subprocess, such as
a test that invokes the CLI, count. A test run that fails still produces
the report: the lines every other test executed are kept.
mathema's own functions get no probe source when mathema measures itself. Checking one of them runs mathema's machinery, which may call that same function, so a probe's lines cannot be told apart from the machinery's. Test and derive lines still count.
How clarity is scored¶
Clarity is an entropy measure, in the sense Shannon gave the word: entropy
is uncertainty counted in bits, where one bit is the uncertainty of a
single fair yes-or-no question, so a fair coin carries 1 bit, a choice
among eight equally likely options carries 3 (log2 8), and an outcome you
already know carries none. Evidence that rules possibilities out lowers it,
which is exactly what a verified claim does.
Before any evidence, a function carries an a-priori uncertainty about its behaviour: which map it computes, which inputs it takes, what its output looks like, how safely it runs and where it can fail. mathema splits that uncertainty into sources, gives each source an entropy in bits, and asks how much of the total your verified claims have removed:
H0 = sum over sources s of h(s)
H_rem = sum over sources s of h(s) * (1 - r(s))
clarity = 1 - H_rem / H0
Here h(s) is the a-priori entropy of source s and r(s), between 0
and 1, is the fraction of it the strongest evidence bearing on that
source has eliminated. Clarity is 0 when nothing is known and 100 when
every source is settled by proof. It is a measure of how sharply the
function is characterised, not a pass rate: whether the claims hold is
reported separately.
Sources and their entropy¶
The sources fall into five dimensions, each a different kind of thing you can know:
| dimension | the question it answers | sources and their entropy | a claim that removes it |
|---|---|---|---|
| what it computes | which function is this, exactly? | the map, 1 bit, plus 0.4 bits per branch region | f(x) == 2*x, an equivalence, a closed form |
| its bounds & shape | what is the output's range and form? | the output envelope by return kind (a bool 0.5, a scalar 1, a sequence or mapping 1.5, a matrix 2) and its relational form (0.4 flat, 1 branched) | a bound, monotonicity, symmetry; a proof of what it computes settles these too |
| what it accepts | which inputs are in and out of bounds? | each parameter's domain (below), and 0.5 bits for the absence an Optional admits |
a domain stated on the function, a proof of what it computes, a raises or is_defined row; an absent row or policy for the absence |
| how safely it runs | does it run cleanly and deterministically? | one source per safety family that can apply: state and determinism (0.15 bits each on a visibly pure function, 0.8 otherwise), numerical stability and representation (0.2 bits, plus 0.4 per loop), missing values for a sequence input (0.4), text for each string input whose domain is not a finite set (1) | the safety families (is_state_safe, is_numerically_stable, ...); a missing policy row for missing values |
| where it can go wrong | how and where does it fail? | 0.5 bits per raise site, 2 bits per call into another function | a raises contract; for a call, the callee's own record |
A dimension that cannot apply has no sources, so it carries no entropy: a
pure, total function with nothing that can fail has nothing in where it
can go wrong to count against it. A decorator is not a call the function
makes, and a guard that states a parameter's domain (below) is charged as
that domain, not also as a branch and a raise site, so
@enforce_domain({"side": {"buy", "sell"}}) and
if side not in {"buy", "sell"}: raise ValueError(side) score the same.
What a parameter accepts¶
Each parameter's entropy is what its completed domain leaves unknown, read
from the same domain every check uses. That domain is what the function
itself states: its annotation, a guard in the body refusing every value
outside a set (if side not in {"buy", "sell"}: raise ValueError), or an
@enforce_domain(...) decorator, which takes the place of the annotation
(two numbers there are the closed interval between them, a string is read
in the domain grammar, and any other set, list or tuple is the finite set
of its members):
| what is known about the parameter | bits |
|---|---|
| nothing (no annotation, no binding) | 1.5 |
a type alone (float, str, a language admitting every string) |
1.0 |
| a language narrowed to an alphabet | 0.8 |
| a language narrowed by a predicate | 0.6 |
a refined language (L[ascii, len <= 80]) or a bounded range (@enforce_domain({"x": (0.0, 1.0)})) |
0.4 |
a finite set of k values (a Literal, a str Enum, a guard to a set, @enforce_domain to a set) |
min(0.3, log2(k) / 10) |
| any other guard in the body (its boundary is the unknown) | 1.0 |
A finite set of k values is a choice carrying log2(k) bits, scaled by
a tenth because a closed set is already most of the way to known. Every
parameter starts at the 1.5 bits of nothing known, and whatever the
function states about it (its annotation, a guard, @enforce_domain)
counts as known, so annotating a parameter or stating its domain never
lowers a score: only what the stated domain leaves is charged, and the
claims clear part of that.
Who states the domain matters. When a finite set is the function's own
domain, a proof that visits every member covers every input the function
accepts and clears the parameter entirely. When the set, or a range,
comes only from a claim's binding, the function still accepts every other
value of its type, so the claim speaks about its share of the inputs
(below) and leaves the parameter charged for the rest. For example,
for name in {"Alice", " alice ", "ALICE"}, f(name) == "alice" on
username_key(name: str) settles three strings out of unboundedly many,
which is a vanishing share, while the same proof on
username_key(name: Literal["Alice", " alice ", "ALICE"]) settles every
input. A parameter guarded in the body by anything other than a set is
charged as a guard, for its boundary, which a verified claim about what
the guard refuses clears.
How far a claim reaches¶
A claim credits what it says about a function in proportion to the share of the function's inputs it ranges over. The share is counted in the values each parameter's type can actually hold:
- a
floatholds float64 doubles, which crowd towards zero, so[0, 1]holds about 25% of them,[0, 100]about 25.2%,[0, inf)half, and[0.5, 0.6]about 0.005% (these shares are exact counts of doubles); - an
intis unbounded, so a bounded stretch of integers is a vanishing share and a half-line half, unless the function itself bounds the parameter, when the integers in its range are counted; - a finite domain counts its members, so two of three
Literalvalues are two thirds of it; - a string or any other type has unboundedly many values, so a few named values or a narrower language are a vanishing share.
A claim binding several parameters covers the product of their shares.
The share scales every dimension the claim credits: what it computes, the
envelope and shape a proof of that entails, and what the parameters
accept. Every claim also earns a tenth of what it would earn over the part
of the domain it does not cover, so a claim covering a share s is
credited s + 0.1 * (1 - s) of its whole-domain credit: a proof at three
names of a bare str earns a tenth, a claim over most of a stated domain
nearly all of it, and a claim over everything exactly all of it. That
tenth is counted once per source, so a pile of claims at single points
earns no more than one does. Claims over different parts of one
parameter's domain add up, so {"buy", "sell"} and {"hold", "skip"}
together settle a four-value Literal. The easy way to earn the full share is to state the domain on
the function, with @enforce_domain, an annotation or a guard, and then
claim over all of it.
How much a verdict removes¶
r(s) is set by the strongest verdict bearing on the source, and for a
holds by the mechanism that produced it, since a structured search leaves
less of the input space unexplored than random sampling:
| verdict | fraction removed |
|---|---|
proven |
1.0 |
holds from derive or examination |
0.90 |
holds from a semi-analytical probe (critical points, poles) |
0.85 |
a witnessed falsified |
0.85 |
holds from an algorithmic probe |
0.80 |
holds from a minimal-example probe |
0.78 |
holds from random sampling over n draws |
0.60 * (1 - 3/n) |
unknown |
0 |
A subroute with no grade of its own (derive:extensive,
probe:counterfactual) is graded as the route it refines. The random
sampling grade shrinks with few draws because n failure-free draws
leave, at 95% confidence, up to 3/n of what they sample possibly
failing; a row that records no count is graded 0.60.
A witnessed falsification removes entropy because it is knowledge: you now
know an input where the function fails. It credits only the source the
claim speaks about, since a map shown wrong at one input says nothing about
the output's envelope, its shape or what the function accepts. A [float]
companion that holds or is proven credits numerical stability for its
function, as a verified is_numerically_stable claim would; a falsified
companion credits nothing, since it records one relation the float code
breaks, not the function's stability. A safety family that only rolls
others up (is_computation_safe, is_repeatable) credits nothing itself,
since its children do, and an absent or missing policy row speaks only
about absence or missing values.
Calls¶
Every call into another function, one of your own or a library's, is a
two-bit source in where it can go wrong, and only the callee's own
record reduces it. mathema looks one level down, at the callee's settled
definedness rows (is_defined, bare or with a region, or a raises
row) and at any identity it holds over its whole domain, which gives a
value at every input: proven, the call's entropy is gone; holds removes
the fraction the table gives; a row accepted with --as trusted counts
as a holds. A callee with no such record keeps its two bits, which caps
the caller below 100. For a library function the record is the one
mathema verify writes when it adjudicates the function's
compendium rows against the installed library;
a claims file nobody has verified is testimony and moves nothing. What
the callee itself calls is the callee's own clarity, not the caller's.
Built-in and standard-library calls with no claims file are not sources.
A function nobody has claimed¶
With no verified claims, a function is scored only on what its code visibly shows. Visible purity, an unguarded signature, a domain stated on the function and the absence of hazards are facts examination establishes without a claim, and each removes part of its source, weaker than a verified claim would. A plainly pure, total helper therefore reads low but not zero, while a branchy, impure or call-heavy function floors much closer to zero.
What the score does not tell you¶
The bit values have been calibrated so that the kinds of knowledge rank against each other as they should, and ongoing refinement is likely as more repositories are scored, so a clarity of 60 against one of 70 says more about order than about distance. The score is per function and reads one level of calls, so it says nothing about how functions behave together, and a repository's clarity is a weighted mean of those per-function views rather than a measure of the system. It depends on which claims are verified: a function whose behaviour is perfectly well understood but unclaimed reads low, and the score rises when that understanding is written down and checked, not when it exists. Proof attempts run under a wall-clock cap, so on a busy machine a claim that would be proven can be recorded as holds, and the score drops with it until the claim is verified again. Some of what a function does is not seen at all, for example a guard bounding a number to a range (only a guard to a set of strings is read as the domain), or a module-level list the function appends to (it reads as pure).
Every fraction removed, every share and every credit stays between 0 and 1, so the score stays between 0 and 100. A score of 100 means every source is settled by proof over the whole domain: a single holds, a claim over part of the domain or an unsettled callee leaves something, and the score then reads 99 at most, even where rounding would say 100.
The scoring algorithm is versioned, and its version is recorded beside every score, so a number is only ever compared with one computed the same way.
The triangle¶
The three scores are the corners of a triangle: CLARITY at the top,
IMPL and INTENT the two bottom nodes. The sharp dotted outline is
the full 100/100/100 frame; inside it, each score is a dot on its spoke
from the base (all-zero) out to its corner, and the triangle those three
dots span is filled, so the filled area is the current state and the gap
to the frame is the room to grow. The fraction of the frame that area
shades is the overall number, so half the triangle shaded reads 50:
with clarity the apex height and implementation and intent the base, it
is clarity * (implementation + intent) / 2 on the fractions. It is 100
when all three are 100, and 0 whenever clarity is 0 (a flat shape) or
both base scores are, so comparing two commits' areas shows the change in
overall characterisation. A dimension under 5% is degenerate and the
shape is not drawn, only the numbers.
The standard location, and embedding in a README¶
MATHEMA_BADGES=on mathema badges --out # writes to .mathema/badges/ under --root
MATHEMA_BADGES=on mathema badges --out path/ # or an explicit directory
Bare --out writes to .mathema/badges/, the documented home under
the tracked .mathema/. Committing it means a README can embed the SVG by
its in-repo path, no external hosting:

The shields.io JSON files serve the same badges through shields' endpoint renderer, referenced by their raw URL, when separate pills are wanted instead of the one triangle.
Artifacts (--out [DIR])¶
mathema OWNS the badge directory. Every run rewrites its artifacts and
DELETES any badge-shaped file (.json, .svg, .txt, .md) it did
not write, naming each removal. That is what keeps a renamed or retired
badge from being served forever from a stale file: a diff-based CI
guard cannot notice a file nothing writes any more, but it does notice
the deletion. Other extensions, and anything in a subdirectory, are
left alone. readme-snippet.md is a paste-ready block for your own
README: the three shields, the triangle, and one line on what each
score means.
triangle.txt, the ASCII triangle: the git-diffable canonical artifact.implementation.json/intent.json/clarity.json, shields.io endpoint badges in the Tetrion Labs colours (an ink label, a deep green value) a README references by raw URL.triangle.svg, a dark-card twin of the ASCII triangle with the same layout: the dashed100/100/100frame, each score a vertex on its spoke, the triangle they span filled in the accent green, and the overall number in the top right. It carries its own background and needs no external fonts, so it reads the same on a light or a dark README.snapshot.json, the numbers (implementation, intent, clarity, overall, plusclarity_algoand the per-function rows), for CI to compute and comment the area delta between commits. When a step stopped at the badges step cap it also holdscapped, a[key, steps]pair for each such function; with nothing capped the key is absent, so a snapshot reads as it always has.