Stability freeze round — 0.4.0#
MADDENING reaches 1.0 by stabilising surfaces incrementally across the releases that lead up to it, not by declaring the whole public API at once. This is the 0.4.0 round: it judges what this release added, applies the changes it can defend, and writes down the evidence for every call — including the calls that were not to promote anything.
It supersedes the round written on 2026-09-17 against a tree 470 commits
behind this one (branch chore/stability-freeze, commit 603fef3). What that
round found is re-measured here rather than carried over; where it is not
re-measured, this document says so instead of repeating it.
Run last in the release, deliberately: every other 2.x phase had to land first so that the surface had stopped moving underneath the judgement.
What a level costs#
Promoting a surface to stable says its signature will not change
incompatibly before the next major version. The mechanics are in
the deprecation policy; the price is:
a breaking change needs two minor releases of
DeprecationWarningand can only land in a major release;lowering the level again is itself a breaking change, so a promotion is hard to take back — which is why a level is cheap to set before the release that first ships it and expensive afterwards;
a
stableclass promises every public method and property an instance answers to, inherited ones included, except a member with a lower tag of its own.stable_api.jsonrecords 242 members behind the 17 tagged surfaces, andscripts/check_stable_signatures.pyfails on any change to any of them — distinguishing a break from a compatible widening. A new parameter with a default, added after the existing ones, is classifiedCOMPATIBLEand says so (“the snapshot being out of date, NOT a break of the contract”); a rename, a reordering, a changed default, a changed annotation or a removal isBREAKINGand asks for a major version bump. Both still fail, because the snapshot has to move either way; they fail with different messages, and it is the message that carries the verdict.
One clarification this round adopts explicitly, because the level docstrings
are ambiguous about it: between 0.4.0 and 1.0.0 a stable tag is announced
intent, not yet the contract. The snapshot guard makes every change to such
a surface visible and deliberate in the meantime; at 1.0.0 the snapshot
becomes the contract. Without that reading the
PEP 589 typing phase, which replaces bare dict annotations with
TypedDicts on exactly these surfaces, would be a breaking change on the day
it lands.
The criteria#
A surface is promoted to stable only when all of these hold:
it is covered by tests that exercise its contract rather than its implementation;
it did not change during the 0.4.0 cycle, or changed only additively;
its signature names no type we do not control, and no MADDENING type that is itself unfrozen;
something outside its own test file already depends on it, so the promise is being made in practice rather than in principle;
there is a mechanism that fails if it changes incompatibly.
experimental is the default for anything whose numbers are still being
calibrated — thresholds, safety factors, tolerances — because a changed
default is a silent break, and the policy rates that worse than a failure.
What this round changed#
before |
after this round |
|
|---|---|---|
surfaces in the report |
107 |
141 |
|
17 |
17 |
|
74 |
80 |
|
10 |
38 |
|
6 |
6 |
The right-hand column is the report as this round left it, not as it ships.
Surfaces that landed later in the cycle were tagged as they landed, so the
report has grown since; stability_report.md,
generated from the tree, is the current list and its per-level counts, and
they are not copied here to go stale. The stable set is the one number that cannot drift unannounced:
scripts/check_stable_signatures.py fails on any change to it.
Nothing was promoted to stable. That is the round’s main judgement and
§”Not promoted” gives the reason for each candidate.
Two things were tagged that carried no level at all, and a level of none is
not a weaker promise than experimental — it is the absence of a statement,
in a report whose header claims to enumerate the public API.
Six types a stable signature names (→ evolving)#
sharded_cg and sharded_gmres are stable and return SharedSolveResult.
SimulationNode is stable; boundary_input_spec() returns
BoundaryInputSpec, the flux hook returns BoundaryFluxSpec, and
uncertainty_spec() returns UncertaintySpec. GraphManager is stable;
validate_sharding() returns list[ShardingIssue] and add_coupling_group
takes a CouplingGroup. None of the six carried a tag of its own, so the
frozen half of each promise was the method and the free half was the object it
hands you.
Evidence for evolving, not stable: all six are frozen dataclasses whose
shape is settled, and a frozen dataclass gaining a field is compatible for a
reader — which is what evolving means. Against stable: three of the six
were touched during this cycle, none has usage history outside the package,
and criterion 4 is not met for any of them. Against leaving them untagged:
criterion 3 is a rule the stable set must eventually satisfy, and an
untagged type cannot satisfy or fail it.
This set was found by hand on 2026-09-17. Three days and 470 commits later it
was unchanged and one longer — DiscretizationOrder joined it when
HeatNode.discretization_order was added. That is the argument for
mechanising it, which
tests/compliance/test_stable_signatures.py::TestAStableSignatureNamesNoUntaggedType
now does: it reads the annotations of every stable signature, resolves each
name (including a forward reference nested in a subscript, and a class only a
if TYPE_CHECKING: block imports), and fails on an untagged MADDENING type or
on a name it cannot account for at all.
DiscretizationOrder is the one recorded exemption and cannot be fixed the
same way: it lives in core/compliance/metadata.py, which defines
StabilityLevel, and core/compliance/stability.py imports that, so
metadata cannot import the decorator without a cycle.
The [verify] harnesses (→ experimental, 28 surfaces)#
maddening.testing is new in 0.4.0, has a 30-name __all__ in mms.py
alone, is documented in node_authoring.md, testing_standards.md,
verification.md and adaptive_node.md, and is installed by its own extra.
It carried zero @stability tags, and nothing imports it on the way to
anything else, so it was absent from the report entirely — a documented public
module the stability contract did not mention.
Evidence for experimental: the MMS and GCI entry points are days old (9
commits on mms.py this cycle) and their public surface is largely numeric
policy — DEFAULT_SAFETY_FACTOR, CAUTIOUS_SAFETY_FACTOR,
DEFAULT_ASYMPTOTIC_ORDER_TOLERANCE, DEFAULT_STAGNATION_RTOL,
MIN_APPARENT_ORDER. Those are the thresholds a convergence verdict turns on,
they have been calibrated against the built-in nodes and nothing else, and
changing one silently changes whether a user’s node passes. experimental
(“may break in any minor release, opt-in only”) is exactly the promise that
fits an opt-in extra whose constants are still being tuned.
What is not covered: @stability decorates a class or a function, so the
nine module constants in mms.__all__ are outside the registry. The
deprecation policy already says module constants are not covered by the
signature guard; this is the first place it matters in practice, and a reader
should take the level on the surrounding functions as speaking for them.
Not promoted, and what would change that#
Surface |
Level |
Why not |
What would change it |
|---|---|---|---|
|
|
41 tests in |
A release cycle in which the token model does not change, and a decision on whether |
|
|
Same age. 40 tests, one dedicated file, and CURVE key derivation from a shared token is the same trust model with the same open residual risk (MADD-ANO-015). Criterion 4 fails: nothing outside the package uses it yet. |
Downstream use in MIME/MICROROBOTICA, and a decision on per-peer keys, which would change the signatures. |
|
|
Zero tests name it. It is exercised only through |
A direct test of the function’s own contract — which is worth writing regardless of the level. |
|
|
18 tests, but the warning text and category are the observable behaviour and neither is pinned by the signature. |
Nothing this release; it is a diagnostic, and |
|
|
The wire format (quoted |
Either the token set becomes part of the frozen contract explicitly, or it is documented as outside it. |
|
|
Methods of |
|
|
|
Best-documented addition in the set (7 doc pages) and |
The |
|
|
Zero tests name it. Exercised only through |
A direct test, or making it private and keeping |
|
|
Zero tests name it; reached only from inside |
As above. |
|
|
25 dedicated tests, and the compile-count gate is the one piece of this group with a real contract. But |
One quiet cycle. This is the strongest promotion candidate in the release for 0.5.0. |
|
|
48 tests across two files, a dedicated user guide, a real |
One cycle at its new home with the shim still in place. Promote in 0.5.0 if nothing moves. |
|
|
27 commits on |
A quiet cycle. The user-guide section exists; promotion waits on the churn, not on documentation. |
|
|
The 2026-09-17 round argued for demoting it to |
Nothing — |
|
|
Five of the six existed before 0.4.0 untagged and were tagged this cycle; |
A cycle at |
|
|
Publicly exported and well tested, but it is an unauthenticated listening socket with its own protocol, documented as trusted-clients-only and explicitly out of scope of MADD-ANO-015’s fix. Freezing its constructor freezes the shape of that exposure. |
A decision on whether it gets the same auth treatment as the ZMQ transports. That decision will change its signature. |
Three stable surfaces whose behaviour moved#
The round-3 audit diffed all 17 stable surfaces against main. Every
signature change this release is additive, which is the right answer and is
now enforced (see below). It also found three behavioural changes, which
the signature guard cannot see at all:
HeatNode(timestep=0.01, thermal_diffusivity=1.0)constructed onmainand raises on 0.4.0. Reproduced:ValueError: timestep 0.01 is unstable for this rod: the Fourier number dt*alpha/dx^2 is 1, above the 0.5 limit of the order-2 stencil.HeatNode’s defaultinitial_state()["temperature"][0]moved from 100.0 to 0.02 — measured 0.0 on the shipped defaults today.The
HeatNodeboundary-flux units changed.
Judgement: stable remains the right level for HeatNode, and these are not
violations to fix. Three reasons, in order of weight:
StabilityLevel.STABLE’s own docstring and the report’s gloss both say the contract is locked at v1.0.0, not now. Between 0.4.0 and 1.0.0 the tag is announced intent — the reading this document adopts explicitly above.All three are disclosed in the release notes, so the change is deliberate and a reader upgrading is told.
The first is a refusal replacing silent wrongness: the configuration it now rejects diverged to NaN. Turning a wrong answer into an error is the direction the deprecation policy prefers, and
scripts/check_heat_stability.pyalready gates it (138 constructions verified, 120 not statically evaluable and explicitly not checked).
What this round does record is the gap they expose. The signature guard
reports OK on all three, because none of them touches a signature. The
deprecation policy already lists behaviour, exception types, the contents of
dict-shaped arguments, instance attributes and module constants as outside the
guard — this is the first release where that exclusion cost something
concrete, and the audit found the changes by diffing two trees by hand rather
than by anything committed.
So: before 1.0.0 the stable set needs a behaviour pin as well as a
signature pin, or the tag promises a reader more than anything checks. That is
not built here — a behaviour snapshot needs a decision about what to record
(constructor acceptance? initial_state()? one step of update()?) and that
decision is a round of its own. It is the single largest thing this freeze
leaves open, and it is named here so the 0.5.0 round starts from it rather
than rediscovering it.
Still public, still untagged#
The report covers the modules this release worked on. It does not cover:
maddening.api.server.SimulationServer— the REST API’s central class, in every example, with no tag. Two functions beside it in the same file now have one, which makes the omission more visible rather than less. Its surrogate-training routes and state streams were markedexperimentallate in the cycle (ROUTE_STABILITY, registered withregister_route_stability), for hardening in 0.5.0; the class and its other routes are still untagged.maddening.CloudSession/maddening.CloudConfig— importable from the top-level package, with new fields added this release (PR 94:api_token,transport_token,container_env(),CloudConfig.ports,CloudConfig.api_port), and untagged. So is the rest ofcloud.sessionand all ofcloud.launcher.The whole of
maddening.vizandmaddening.viz.backends, but forStateRelay(experimental, with the streams that read it) — no other@stabilityoccurrence in the package, includingNetworkRelay,NetworkReceiver,CommandPublisherandCommandReceiver, which are the components MADD-ANO-015 is about.maddening.surrogates’ concrete architectures and training callbacks — the ABCsSurrogateArchitectureandSurrogateTrainerare taggedexperimental; none ofMLPDirect,FNODerivative,DeepONetDirect,EarlyStopping,ModelCheckpointor the five physics losses is.core.simulation.checkpoint’ssave_state/load_statefamily, andcore.transforms,core.schedule.
This is not an oversight in one place; it is a systematic gap outside the
modules 0.4.0 touched. It is deliberately not closed in this round — three
of those groups (viz, cloud.session, surrogates) would need a level per
surface and the evidence to justify it, which is a round of its own, and
guessing a level for forty surfaces at the end of a release is how a wrong
promise gets made at scale. The 0.5.0 round should take viz and
cloud.session together, since PR 94’s new parameters land on both.
The one exception this round did close is the set a stable signature
already named, because those were not merely untagged — they were making half
a promise that something else had already committed to.
The 2026-09-17 round’s open questions, re-checked#
Two are answered by work that landed since:
GraphManager.to_dicthas no coupling-group field. It has one now:to_dictemitscoupling_groupscarrying every field of every group.ift_linear_solve: expose the solver knobs. Done —solver,preconditioner,rtolandatolare keyword-only parameters today. The second half of that question, whether it should own its error type, is not re-checked here.
One has changed shape:
“The REST API has no
@stabilitytag anywhere.” It has two now (origin_is_same_site,warn_if_publicly_bound), both added by PR 94.SimulationServeritself still has none, so the underlying question — what the API promises about its response schema, and what it returns when a simulation diverges — stands.
Three are carried over unverified: the AdaptiveNode dtype policy under
jax_enable_x64, the silent USD mapping-weight path, and whether the adaptive
hooks should take boundary_inputs and dt. They are recorded at
603fef3:docs/developer_guide/api_freeze_proposal.md, which the annotated tag
freeze-round-2026-09-17 pins, so the citation stays reachable
(git show freeze-round-2026-09-17:docs/developer_guide/api_freeze_proposal.md)
after the branch it was written on is cleaned up. They are not restated
here, because restating a judgement taken against a tree 470 commits ago as if
it were current is the specific mistake this round was told to avoid.
What this does not decide#
The 1.0
stableset. This is one increment towards it.Anything about
viz,cloud.session,cloud.launcheror the concrete surrogate architectures.Whether
build_mappingandcount_hlo_opsshould be public at all.Module constants, instance attributes, exception types, behaviour, or the contents of dict-shaped arguments — none of which the signature guard covers. See the deprecation policy, and the section above on the three
stablesurfaces whose behaviour moved this release.What a behaviour pin for the
stableset should record. That is the largest thing left open, and 0.5.0 should start from it.