Static Typing Policy#
MADDENING is type-checked with pyright. The check is introduced in two phases so that the annotation work can be scoped to the API surface that is actually frozen, instead of chasing whole-tree cleanliness.
Phase |
When |
What |
|---|---|---|
1 (done) |
v0.4.0 development |
|
2 (current) |
v0.4.0, after the STABLE list settled |
Annotate the surfaces tagged |
Whole-tree cleanliness is explicitly not a goal of either phase.
Running it#
# In an environment with the package and its extras installed
# (pip install -e ".[ci]" or ".[dev]" -- both pull in pyright):
pyright # plain diagnostics, exit 1 on errors
python scripts/typing_baseline.py # per-rule / per-file summary (exit 0, or 2 if the run cannot be trusted)
python scripts/typing_baseline.py --markdown --top 20
# Without installing pyright (uses the interpreter whose site-packages
# should be used for import resolution):
uvx pyright --pythonpath /path/to/.venv/bin/python
python scripts/typing_baseline.py --pyright "uvx pyright" -- --pythonpath /path/to/.venv/bin/python
scripts/typing_baseline.py runs pyright --outputjson, prints the totals,
the counts per rule and the files with the most errors, and forwards
whatever pyright wrote to stderr. Its exit status separates what pyright
found from whether pyright ran:
Exit |
Meaning |
CI |
|---|---|---|
0 |
pyright completed; no errors, or neither |
green |
1 |
pyright completed and reported errors ( |
the job fails (phase 2; under phase 1 this was |
2 |
infrastructure failure: the numbers cannot be trusted, so none are printed. The pyright command is missing; pyright exited with a code other than 0/1 (3 = |
the step fails the job |
The import-resolution guard exists because a mis-resolved interpreter does
not make the count go up: without jax/numpy most attribute and
argument errors become Unknown and disappear (measured: 395 -> 230 errors,
201 missing imports, nothing on stderr). For the same reason a
--pythonpath PYTHON given after -- is checked before pyright runs: the
path must exist and PYTHON -c "import numpy" must succeed; pyright itself
accepts a non-existent path without a word.
--json FILE summarises a stored run instead of invoking pyright (the same
checks apply to the stored document), which is how
tests/test_typing_baseline.py tests it without pyright installed; the
failure paths are tested with a fake pyright executable. CI uses it to
run pyright once and gate both tiers off the stored document, so the
two verdicts cannot disagree about what was measured.
The two-tier gate#
pyright --outputjson > pyright.json
python scripts/typing_baseline.py --json pyright.json --tier tier1
python scripts/typing_baseline.py --json pyright.json --tier tier2
--tier NAME reads typing_tiers.json at the repository root, restricts
every count to that tier’s top-level packages, and compares each
package’s error count against the ceiling committed there. Exit 1 means
at least one package is over; the verdict on stderr names it and the
excess, and prints the slack on every package that is under so a
ratchet can be tightened when it becomes free.
Ceilings are per package on purpose: a single total would let a
regression in viz hide behind an improvement in api.
The package root module is a tier of one. _package_of gives a module
directly under src/maddening its own key, so src/maddening/__init__.py
is the ceiling key __init__.py – and until 2026-09-20 that key was in
neither tier. Because --tier filters the diagnostics to the tier’s
packages before comparing, a key in no tier is not reported as
uncovered; it simply vanishes, and all three typing steps pass on any
number of errors in that file (measured: 500 synthetic errors, three
green steps). That file is where the if TYPE_CHECKING: re-exports live
and is what every from maddening import X resolves through for a
py.typed consumer, and tests/test_lazy_reexports.py is ast-based by
design – so pyright is the only thing that can see a stale name in it.
It is committed at 0, which is its measured count (pyright 1.1.414).
The file also records pyright_version and environment, and a run
under a different pyright release is an infrastructure failure (exit
2), not a gate failure: every release changes diagnostics, so the
comparison would be meaningless in either direction. Bump the
pyright== pin in the [ci] extra, re-measure, and update
typing_tiers.json and this document together – never one alone. The
environment matters just as much: installing usd-core or pyvista
resolves imports the CI environment does not, which makes real errors
visible and pushes the counts up (measured: 423 against 270 on the
same commit). typing_tiers.json names the exact set the ceilings were
taken under.
pyrightconfig.json#
JSON has no comments, so the settings are explained here.
Key |
Value |
Why |
|---|---|---|
|
|
The package only. |
|
|
Start where the signal is actionable. Measured distance to the stricter modes is given below. |
|
|
The floor in |
|
|
Matches CI; avoids platform-conditional stdlib noise. |
|
|
Optional extras ( |
|
|
Same reason, for packages that ship only stubs. |
|
|
Several dependencies ( |
|
not set |
CI installs the package and pyright into the same environment, so |
Baseline (phase 2, current)#
Measured with pyright 1.1.414 on 2026-09-20 (branch feat/pep561-phase2,
forked from release/0.4.0), against pip install -e ".[ci]" on Python
3.11 with jax==0.10.2. 141 files analysed. The typecheck job moved to
Python 3.12 on 2026-09-21, when requires-python went to >=3.12 and 3.11
stopped being installable at all. That moved nothing: the job re-measured
0 errors in tier 1 and 61 / 55 / 1 / 45 in tier 2 – every package
exactly at the ceiling it was given on 3.11 – against the same
pyright 1.1.414. typing_tiers.json records the environment the ceilings are
compared in, and the job re-measures against it on every run. The committed ceilings are in typing_tiers.json; these
are the numbers they were taken from.
errors |
warnings |
|
|---|---|---|
before this phase |
270 |
100 |
after |
165 |
23 |
tier 1 ( |
103 -> 0 |
|
tier 2 ( |
167 -> 165 |
The 77 reportUnsupportedDunderAll warnings are gone: every name behind a
lazy __getattr__ is now re-exported under if TYPE_CHECKING:. The 23
that remain are all reportMissingImports for optional extras that the
CI environment does not install (gi, pxr, pygfx, rendercanvas,
skimage, pyvista, cupy).
Per-package ceilings (tier 2)#
package |
errors |
why it is ratcheted, not cleaned |
|---|---|---|
|
61 |
matplotlib artists and figures typed |
|
55 |
optional network layers in |
|
45 |
matplotlib, pygfx, pyvista, rendercanvas – mostly absent, none with stubs |
|
1 |
|
Diagnostics by rule (after)#
severity |
rule |
count |
|---|---|---|
error |
reportOptionalMemberAccess |
106 |
error |
reportAttributeAccessIssue |
29 |
warning |
reportMissingImports |
23 |
error |
reportArgumentType |
10 |
error |
reportGeneralTypeIssues |
10 |
error |
reportCallIssue |
5 |
error |
reportOptionalOperand |
2 |
error |
reportIndexIssue |
1 |
error |
reportOperatorIssue |
1 |
error |
reportOptionalSubscript |
1 |
Top files by error count (after)#
errors |
file |
|---|---|
34 |
|
31 |
|
20 |
|
19 |
|
15 |
|
9 |
|
8 |
|
6 |
|
6 |
|
5 |
|
All 106 remaining reportOptionalMemberAccess are in tier 2, and they are
a real class of latent bug rather than noise: a renderer’s matplotlib
artist or a viewer’s _plotter is None until setup() runs, so calling
update() first is an AttributeError instead of a clear error. Cleaning
them is control-flow work, not annotation work, and is deliberately left
for a later pass (see What phase 2 did not do).
PEP 561: shipping the marker#
src/maddening/py.typed is an empty file; pyproject.toml force-includes
it into the wheel. Without it a conforming type checker must ignore
every annotation in the package, so all of the work above would be
invisible to a consumer of pip install maddening. Measured on a
throwaway project importing GraphManager and HeatNode and making four
deliberate mistakes:
checker |
without |
with |
|---|---|---|
mypy 1.x |
0 of 4 found (2 |
4 of 4 |
pyright, |
0 of 4 found |
4 of 4 |
pyright, default settings |
4 of 4 |
4 of 4 |
pyright’s default useLibraryCodeForTypes: true infers from library
source and so masks a missing marker; mypy, and any checker following
PEP 561 to the letter, does not. That is why the claim is tested against
the built artifact rather than the configuration:
tests/test_packaging_py_typed.py builds a real wheel and asserts
maddening/py.typed is a member of it, together with the two USD schema
resources that ride the same force-include mechanism.
Baseline (phase 1, historical)#
Measured with pyright 1.1.414 on 2026-09-16 (branch feat/typing-pep561,
forked from release/0.4.0), import resolution against a full dev
environment (everything in [ci] installed except gi, pygfx,
rendercanvas, skimage, fsspec, cupy). 133 files analysed.
Mode |
Errors |
Warnings |
Note |
|---|---|---|---|
basic (configured) |
395 |
94 |
the numbers below |
standard |
466 |
94 |
+69 |
strict |
11754 |
18 |
(the |
Of the 395 basic-mode errors, 33 are in 7 of the 12 modules that define
a STABLE surface (core/graph_manager.py 20, core/edge.py 3,
cloud/multigpu/iterative_solver.py 3, fmi/model_description.py 3,
cloud/multigpu/sharded_unstructured.py 2, cloud/multigpu/sharded_node.py 1,
nodes/heat.py 1; the other five STABLE modules are clean); that is the
phase-2 workload for the public contract. The tier-1 scope (core,
nodes, fmi, cloud, the module sysid.py, serialization, testing,
compliance) holds 83 of the 395 (core 51, cloud 17, nodes 5,
testing 5, fmi 4, sysid.py 1; serialization and compliance 0);
the remaining 312 are in viz (90), usd (88), api (76) and
surrogates (58), mostly reportOptionalMemberAccess /
reportAttributeAccessIssue against optional or untyped third-party
libraries (pxr, pygfx, matplotlib, fastapi extras), which is why those
packages are ratcheted rather than cleaned. (Counts re-measured by an
independent audit of the phase-1 merge against the same pyright run;
per-package figures are errors per top-level package under
src/maddening.) Decided 2026-09-16: whole-tree strictness is not the
goal, but the internals that refactors move through are, because pyright
catches broken call sites there just as it does on the public surface.
Diagnostics by rule (basic)#
severity |
rule |
count |
|---|---|---|
error |
reportAttributeAccessIssue |
135 |
error |
reportOptionalMemberAccess |
111 |
error |
reportArgumentType |
88 |
warning |
reportUnsupportedDunderAll |
76 |
error |
reportCallIssue |
22 |
warning |
reportMissingImports |
18 |
error |
reportGeneralTypeIssues |
13 |
error |
reportRedeclaration |
9 |
error |
reportIndexIssue |
5 |
error |
reportAssignmentType |
3 |
error |
reportOptionalCall |
2 |
error |
reportOptionalOperand |
2 |
error |
reportUndefinedVariable |
2 |
error |
reportOperatorIssue |
1 |
error |
reportOptionalSubscript |
1 |
error |
reportPrivateImportUsage |
1 |
What the big buckets are:
reportUnsupportedDunderAll(all 76 warnings): every one is in a package__init__.pythat lists lazily-imported names in__all__and resolves them through a PEP 562 module__getattr__(maddening,maddening.cloud,maddening.surrogates,maddening.viz, …). pyright cannot see through__getattr__; the phase-2 fix for the STABLE surface isif TYPE_CHECKING:re-exports next to the lazy table.reportOptionalMemberAccess(111) and mostreportAttributeAccessIssue(135): attribute access on values typedX | None(matplotlib artists and figures inapi/frame_renderer*.py,viz/history_viewer.py; optional network layers insurrogates/architectures/*.py) and onpxr/pyvistaobjects that have no stubs. Concentrated in viz/USD/surrogate modules, none of which is STABLE.reportUndefinedVariable(2): string annotations ("Path","subprocess.CompletedProcess") whose names are only imported inside the function body. Harmless at runtime, trivially fixed in phase 2.
Top 15 files by error count (basic)#
errors |
file |
|---|---|
53 |
src/maddening/viz/history_viewer.py |
45 |
src/maddening/usd/live_stage.py |
34 |
src/maddening/api/frame_renderer.py |
28 |
src/maddening/api/frame_renderer_3d.py |
20 |
src/maddening/core/graph_manager.py |
20 |
src/maddening/surrogates/architectures/deeponet.py |
18 |
src/maddening/usd/geometry.py |
16 |
src/maddening/usd/writer.py |
15 |
src/maddening/surrogates/architectures/fno.py |
12 |
src/maddening/viz/backends/pyvista_live.py |
11 |
src/maddening/api/vessel_renderer.py |
9 |
src/maddening/viz/backends/pygfx_viewer.py |
9 |
src/maddening/viz/usd_viewer.py |
8 |
src/maddening/core/coupling/mapping.py |
8 |
src/maddening/surrogates/architectures/mlp.py |
Annotation coverage (measured before phase 1)#
194 modules including examples, ~1498 function definitions, 57 % with a
return annotation, 235 parameters annotated as bare dict (the phase-2
TypedDict candidates: node states, edge payloads, node/graph config).
Phase 2 checklist#
Delivered on feat/pep561-phase2 (2026-09-20) except where noted.
~~Regenerate the stability report~~ – done; the STABLE list had settled before this branch started, and the report is regenerated and equality-checked in CI, so a level change in the freeze round (release sequence phase 2.9) is caught rather than silent.
Annotate tier 1 to zero and every public signature in tier 2 – done (103 -> 0; 219 public signatures across
api,usd,vizandsurrogates). TheTypedDictconversion in the original wording is deliberately not done here: it changes the representation of the data structure every node touches, and the original motivation was pytree layout and performance as much as typing. It is phase 3 item 1 and needs its own review. Where an annotation wanted one, the parameter isMapping[str, Any]/dict[str, Any]with a comment naming the shape, so phase 3 can find every site by grepping for those notes.if TYPE_CHECKING:re-exports for the lazy__getattr__tables – done, for all seven packages;tests/test_lazy_reexports.pypins it statically so it cannot rot between pyright runs, in both directions: a name in__all__that no checker can see, and a name a checker can see that the runtime lazy table has no entry for. The second one type-checks perfectly and raisesAttributeError, which with the marker shipped is the direction that reaches a consumer. ATYPE_CHECKINGimport of a symbol that does not exist is not reachable by anast-based check at all; pyright is the backstop for that, which is why every one of the seven packages – including the package root module – has to be inside a tier.src/maddening/py.typedand the hatchforce-include– done, and asserted against a built wheel rather than against the configuration (see PEP 561: shipping the marker).Blocking in two tiers – done:
continue-on-erroris gone, thetypecheckjob runs pyright once and gates each tier off the stored run, and the ceilings live intyping_tiers.json. The baseline is recorded above.
Phase 3 item 1: the PEP 589 TypedDict pass#
Delivered on typing/pep589-typed-dicts (2026-09-21). Phase 2 left 39
TypedDict candidate (phase 3) markers in 13 modules (surrogates 33,
viz 4, usd 2); none on the core graph/node path. Report the two numbers separately: 11 markers were
converted to a TypedDict, 28 were given a named alias instead.
The dividing question is whether the keys are known statically:
Converted (11 markers, 7
TypedDicts plus two private required-key bases).TrainStateandTrainMetricsinsurrogates.types;TerminalRendererConfig,TimeSeriesPlotConfig,SceneConfig+SceneObjectSpecin the viz backends;TubeConfiginviz.usd_viewer. Each key set is fixed by this package, so a checker rejects a misspelling and a wrong value type.Aliased (28 markers).
StateDict,MutableStateDict,BatchedStateDict,SpecDict,FieldValues,WeightOverrides,DerivFnandIntegratorinmaddening.surrogates.types, andNodeStateDictinmaddening.usd.live_stage. These sit on generic call sites – an architecture’sforward, a physics loss, a USD prim updater – whose keys are whichever fields the caller’s node declares.TypedDictrequires statically-known keys and cannot express that. Every such site carries a one-line note saying so, greppable asnot a TypedDict.
Two things to know before adding another one:
A
TypedDictis adictat runtime – same class, samePyTreeDef, invisible tojax.jit. This pass changed no representation and no trace count. Making one of these aNamedTuple, aregister_dataclassor anequinox.Modulewould change the pytree; that is a different piece of work.Split required from optional with a base class, not
NotRequired. Underfrom __future__ import annotations(which all three affected modules use) CPython cannot see aNotRequiredwrapper and reports every key in__required_keys__. Measured on 3.12.3; a checker gets it right either way, the runtime does not.
The pass was verified by making the checker the test: a probe calling
the converted surfaces with three misspelled keys, three wrong value
types and one invalid Literal draws 0 errors on release/0.4.0 and
7 on this branch. It also found a real defect –
MatplotlibSceneRenderer.setup() passed a scene object’s "x" straight
to patches.Circle, so the documented “or a state field name” spelling
died with ConversionError before the first frame (fixed, with
tests/viz/test_scene_renderer_object_spec.py).
What phase 2 did not do#
The PEP 589
TypedDictconversion (checklist item 2, above) – done in phase 3, above.The 106 remaining
reportOptionalMemberAccess, all in tier 2. These are control-flow changes – a matplotlib artist or a PyVista plotter that isNoneuntilsetup()– not annotations, and several are latentAttributeErrors rather than checker noise. The ratchet holds the count; lowering it is its own piece of work.Tier-1 public signatures are not all annotated. Tier 1’s contract is zero errors, which is a different property: an unannotated parameter is not an error in basic mode. 145 tier-1 public functions still have no return annotation, so a
py.typedconsumer can still reach an implicitly-Anycall there. That is the obvious next ratchet, and it is larger than it looks (779 public definitions).