Skip to content

Add typed model outputs and a declarative observable specification - #1723

Open
aacostadiaz wants to merge 12 commits into
ACEsuit:mace-reforgefrom
aacostadiaz:reforge/core-1-typed-outputs
Open

aacostadiaz wants to merge 12 commits into
ACEsuit:mace-reforgefrom
aacostadiaz:reforge/core-1-typed-outputs

Conversation

@aacostadiaz

@aacostadiaz aacostadiaz commented Sep 11, 2026 •

Copy link
Copy Markdown
Collaborator

MACEOutput replaces the dict every legacy model forward returns: six core fields plus a typed extras hatch, generic over the tensor type so mace_core imports no framework. A plain dataclass, since the values are tensors whose type this package cannot name and there is nothing to validate.

ObservableSpec makes a property a row instead of a code change. Derivatives are named d_<q>_d_<x>, with forces, stress and magforces keeping their own names. The grammar runs over declared inputs, not just positions and the cell, because magforces already needs the third. defaults/observables.yaml ships energy with its two derivatives.

All 43 keys the frozen forwards emit are classified in tests/architecture/observable_coverage.py, read out of the source by the P0-5 scan rather than listed, so a new legacy key fails the test. 22 carry a literal irreps string, 6 state it with the model parameter that sets the rest (readout count, maximum multipole order, whether an anisotropic readout is declared), 5 are derivatives, 10 are drops naming what owns them instead. Nothing is deferred: a row that cannot say what its irreps are fails the test rather than acquiring a TODO.

docs/reforge/output_surface.md records the three-layer surface: 43 + 15 + 3 = 61 names, every number re-derived by the test.

Three things the sign checks turned up, all measured on the committed anchors:

  • virials = -dE/dstrain but stress = +dE/dstrain / V. They differ by a sign, not only the volume (max|stress*V + virials| = 1.2e-35).
  • hessian is +d2E/dpos2, the negative of d(forces)/d(pos). It is a second derivative, so it gets a Drop row naming the derivative engine.
  • BEC is not another spelling of dmu_dr. LES differentiates a Berry-phase polarization built from mean-removed latent charges; the dielectric model differentiates its dipole readout. Different quantity, different unit, measured shape (n_atoms, 2, 3, 3).

Implements CORE-1 (#1555).


Note

Medium Risk
Adds foundational API and validation for all model outputs and derivatives; mistakes in sign/naming or coverage tables could misclassify legacy behavior, though extensive architecture tests mitigate this.

Overview
Introduces mace-core contract types for v1: a pydantic observable catalogue (InputSpec, ObservableSpec, DerivativeRequest, DEFAULT_CATALOGUE) with irreps grammar, d_<q>_d_<x> derivative naming, and explicit signs for renamed quantities like forces/stress; plus generic MACEOutput (six core fields + extras) to replace untyped forward dicts. mace-core now depends on numpy and pydantic and exports these types from its public API.

Docs move extension examples from observables.yaml to observables.py and register_observables. output_surface.md defines the 61-name user surface (model forward + ASE calculator + eval CLI). target_layout.md reflects the consolidated observables/ layout and config-only observable extension.

Architecture gates: observable_coverage.py classifies every legacy forward key (Spec / Derivative / Drop); test_observable_completeness.py enforces full coverage, derivative/sign consistency, core-field claims, and re-derived layer counts vs the surface doc.

Reviewed by Cursor Bugbot for commit 1cb4fed. Bugbot is set up for automated code reviews on this repo. Configure here.

The two abstractions CORE-1 asks for, and nothing else.

`mace_core.outputs.MACEOutput` replaces the dictionary every legacy model
forward returns. Six core fields plus a typed `extras` hatch, generic over the
tensor type so the same class carries torch.Tensor, jax.Array or numpy arrays
without mace_core importing any of them. A plain dataclass rather than a
pydantic model: the field values are framework tensors whose type this package
cannot name, so there is nothing for a validator to check, and the object is
built once per forward pass. The one check it does make is that an `extras` key
does not shadow a core field, because that failure is otherwise silent.

`mace_core.observables` declares a property as a row. An `ObservableSpec`
carries name, irreps, per_atom, units, normalization and a default loss weight;
an `InputSpec` declares something the model is given; a derivative of one
against the other is named by the rule `d_<q>_d_<x>`, with `forces`, `stress`
and `magforces` as the three pairs that keep a name of their own and the two
that carry a minus sign. The grammar is written over declared inputs rather
than over positions and the cell because the magnetic family already needs the
third: `magforces` is `-dE/d(magmom)`, and a grammar that knew only positions
and the cell could not express it at all.

`defaults/observables.yaml` ships energy with its position and cell
derivatives, as packaged data rather than a Python literal. The point of a
declarative spec is that a property can be added without touching code, and
that has to include the code holding the defaults.

`tests/architecture/observable_coverage.py` accounts for all 43 keys the frozen
model forwards emit. The 43 are read out of the source by the P0-5 surface
scan rather than listed, so a key added to a legacy forward fails the test
instead of passing unnoticed, and the per-atom classification and unit of each
come from the golden harness's channel declarations. Nineteen become
observables with an irreps string, six are observables whose irreps are stated
with the model-dependent part named (the number of readout layers, the maximum
multipole order, whether an anisotropic readout was declared), five are
derivatives under the rule, and ten carry a Drop row naming the mechanism that
owns them instead. Nothing is deferred to a later ticket, and a row that cannot
say what its irreps are fails the test rather than acquiring a TODO.

Every derivative row states the sign the frozen tree actually reports and the
test compares it against the rule, which turned up two things worth having in
writing. `stress` and `virials` are not the same sign: the stress is built from
the raw gradient and the virial is negated in the return statement, so
`virials = -dE/dstrain` while `stress = +dE/dstrain / V`. Measured on the
tiny_scaleshift anchor, `max|stress * V + virials|` is 1.2e-35. And `hessian`
is not a derivative row at all: it holds `+d2E/dpos2`, which is the negative of
the `d(forces)/d(pos)` the rule derives, because it is a second derivative of
the energy rather than a first derivative of the forces. The grammar is first
order by design and all three of its special cases are, so the key carries a
Drop row naming the derivative engine, the same way `edge_forces` names the
export path. Measured against a central difference of the forces,
`max|hessian[:, 0] + dF/dx|` is 1.8e-10. With that settled every derivative row
agrees with the rule, and the test asserts it with no way to annotate an
exception.

`BEC` is filed as its own observable and not as a second spelling of `dmu_dr`.
LES builds a polarization from its own mean-removed latent charges with an
epsilon factor, takes it through a Berry phase under periodic boundaries and
differentiates that; the dielectric model differentiates its `dipole` readout.
Different quantity, different gauge, different unit, and measured shape
(n_atoms, 2, 3, 3) rather than (n_atoms, 3, 3).

`docs/reforge/output_surface.md` writes down the three-layer surface the
downstream tickets are measured against: 43 model keys, 15 that exist only at
the calculator, 3 only at the evaluation CLI, 61 in the union. Every number
there is re-derived by the test, so the document cannot drift from the tree.

The target layout is updated to the shape that landed. It described a module
per observable, which is the opposite of a declarative table, and it spelled
the output type two ways.

Verified: the package suites, the architecture suite, tests/golden and
tests/unit are green, both toolchains pass, the import contracts hold and the
built wheel carries the defaults file. tests/parity does not exist yet.
@aacostadiaz aacostadiaz added the reforge MACE v1 rewrite (Reforge) work item label Sep 11, 2026
@aacostadiaz aacostadiaz linked an issue Sep 11, 2026 that may be closed by this pull request
9 tasks
Three jobs run `pytest tests` over the entire tree: the two GPU jobs in the
MPCDF pipeline, and nightly's coverage-full and durations-refresh. None of them
installs the v1 packages, and `tests/architecture` needs both trees importable.
The import contracts resolve each root package on the filesystem, and the
observable completeness test imports `mace_core`.

That fails at collection, before any marker expression can deselect it, so a
capability marker on those tests would not have helped. The GPU jobs of ACEsuit#1723
reported `ModuleNotFoundError: No module named 'mace_core'` after a suite that
was otherwise fully green.

No coverage is lost. `tests/architecture` is gated on every pull request by the
`architecture` job in ci-core.yaml, which is the one place both trees are
installed editable, and nothing in the directory is gpu-marked or worth a
coverage number.

A later suite that needs the v1 stack has to be listed on those lines too, or
be given its own job. `tests/parity` is the next one.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread packages/mace-core/src/mace_core/outputs.py
Comment thread tests/architecture/test_observable_completeness.py
A fourth job sweeps the whole tree: `backends-cpu` in ci-extensions.yaml runs
`pytest tests` with a cueq marker over everything, and installs the legacy
distribution alone. It is paths-filtered, so it skipped on this pull request
and the breakage would have surfaced on the first one touching backends, or in
the nightly that calls this workflow. It gets the same `--ignore` as the other
three. A search for whole-tree selections now finds four, and all four carry it.

The per-atom energy was renamed without the table saying so. The core field of
MACEOutput is `node_energies` and the legacy key is `node_energy`, one letter
apart, so the row specified an observable whose name missed the field it
belongs in. The consequence was live: `get("node_energy")` returned None on an
output whose `node_energies` field was filled, and `extras["node_energy"]` was
free to sit beside that field holding the same quantity, which is precisely the
dual storage the shadowing guard exists to prevent for the names it knows.

The row now records the rename, and a new test asserts that each of the six
core fields is claimed by exactly one legacy key. A field nothing claims is a
field no model fills; a field claimed twice is the same silent dual storage
from the other direction. Both would have passed unnoticed before.

The `extras` test that used `node_energy` as its benign example now uses a name
that is not a near-miss of a field, since the old one read as an endorsement of
the thing v1 renames.
The GPU jobs kept failing on `ModuleNotFoundError: No module named
'mace_core'` after the `--ignore=tests/architecture` added for exactly that.
The flag was in the wrong tree, and the reason is a deliberate property of the
bridge rather than an oversight.

For a pull request from a fork, `.github/workflows/ci-gpu-mpcdf.yaml` takes the
tested tree from the fork and the pipeline definition from the base ref, so
that a fork can change what gets tested and never what runs it on MPCDF
hardware. An `--ignore` added to `.github/gitlab/ci.yml` is therefore invisible
to the pull request that adds it, and stays invisible until it merges. The
tested tree is the only lever a fork has.

So the guard moves into the one module that needs it. `find_spec` rather than
`pytest.importorskip`, because it resolves the module without executing it: a
`mace_core` that is absent skips, while one that is installed and broken still
raises at the real import below. Verified both ways, with the module hidden
from `sys.path` and with a deliberately broken copy ahead of it on it.

The skip cannot quietly hide these tests from the job that owes them. The
`architecture` job runs `lint-imports` before the suite, and that step fails
outright when a root package is missing from the filesystem, so the install
this guard depends on cannot disappear unnoticed.

That makes the four `--ignore` flags redundant, and they are reverted. Three of
them would only ever have taken effect after merge, and all four removed
coverage from sweeps where the rest of `tests/architecture` had been running
perfectly well.
Comment thread packages/mace-core/src/mace_core/defaults/observables.yaml Outdated
Comment thread packages/mace-core/src/mace_core/observables/spec.py Outdated
The field described itself as the same derivative the stress is built from,
before the volume division. It is the negative of it: the virial is
-dE/dstrain while the stress is +dE/dstrain over the volume, so stress times
volume is minus the virial.

The inventory in this same branch already says so, with the measurement that
settles it: max|stress * V + virials| is 1.2e-35 while max|stress * V -
virials| is 6.5e-3. It was the dataclass that was left behind.

The confusion has a source worth naming. The legacy helper builds the stress
from the raw gradient and negates it into the virial only in its return
statement, so reading that function leaves the impression that the two share a
sign. A consumer that takes the docstring at its word gets a virial of the
wrong sign, which has the right magnitude and is therefore hard to notice.
The row already declared the symmetric strain: irreps 0e+2e, six components
rather than the nine entries of the cell vectors, and dimensionless units.
Only the name said cell. A derivative inherits its irreps from the input it
is taken against, so the shape stress carried was right only by accident, and
any other observable asking for that derivative read d_<q>_d_cell, which is
not what is computed.

Record what an input is while the name is being corrected. It is a leaf a
derivative can be taken against, not necessarily a field read from the data:
pos is both, strain is only the first, materialised as zeros by the
derivative engine around the model call.
Neither is a fact about a quantity. A loss weight has to change between
stages, which legacy already does with a second set of its own, so it belongs
to the loss config.

Scaling belongs to the head. A derivative has no scale of its own: it is
differentiated from what the head produces, so it follows that scaling, and a
scaling field on a derivative row has nothing to set. The head's scale can
also come from a different target, which a field on one observable cannot
express. Today `--scaling` defaults to `rms_forces_scaling`, which scales the
energy readout with the RMS of the force targets.

What is left describes the quantity: name, irreps, per_atom and units. The
declared name is what the model and loss configs key on.
The name and the sign of a derivative were in `SPECIAL_CASES`, three rows
mapping a `(quantity, input)` pair to a name and a sign. The same fact was
written three times in the tree: the machine-readable pair there, the prose in
`mace_core.units.SIGN_CONVENTIONS`, and a comment in the declarations file
saying "Named `forces`, reported as -dE/dpos" because the schema could not
express it. A comment that says what a field would say is a missing field.

It had also already drifted. The table carried a row for `("energy",
"magmom")` and no shipped catalogue declares `magmom`, so the third of its
three rows described something no data asked for.

The consequence is not tidiness. A fourth derivative with a name of its own,
torques as `-dE/d(orientation)` or a polarizability as `d(dipole)/d(field)`,
meant editing `mace_core`, which is the one thing a declarative grammar exists
to prevent. `derivatives.py` made that argument for `magforces` one level down
and then stopped one level short of applying it to itself.

So `DerivativeRequest` gains `name` and `sign`, the table goes, and what
remains is the default rule: `d_<quantity>_d_<input>` with the gradient's own
sign. The shipped catalogue now declares `forces` at -1 and `stress` at +1 as
data.

This reverses a decision the docstring stated deliberately: that letting a
declaration set them "would reintroduce the per-consumer naming this
abstraction removes". That objection is about a *consumer* inventing a name,
and it still cannot: the name is decided once, in the catalogue, and every
consumer reads it off the resolved spec. What changes is only whether the
catalogue's source is Python or YAML.

Two guards, because a free field is a field a typo silently changes:

`name` obliges `sign`. A renamed derivative inheriting +1 in silence is a model
trained on inverted forces that runs perfectly well, and nothing downstream can
see it. A sign must also be exactly +1 or -1; a scale factor is not a sign, and
the volume division that turns a strain derivative into a stress still belongs
to whatever computes the stress.

A declared name may not be spelled `d_<x>_d_<y>`. That spelling states which
quantity was differentiated, so a custom name wearing it would be asserting
something untrue.

The architecture inventory keeps its own copy of the three names on purpose,
and it is now explicit in a `name` field rather than implied by the rule. That
table is read off the frozen tree and the declarations file is authored, so
they are two sources; deriving both from one would have left the test agreeing
with itself.

`units.SIGN_CONVENTIONS` is still a third copy, in prose, which cannot be
derived. Binding it to the declarations needs `mace_core.units`, which arrives
with the configuration work and does not exist on this branch yet.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Want reviews to match your repository better? Bugbot Learning can learn team-specific rules from PR activity. A team admin can enable Learning in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit e2f728a. Configure here.

Comment thread packages/mace-core/src/mace_core/outputs.py
`__contains__` resolved an observable name through `FIELD_BY_OBSERVABLE`, so
`"energy" in output` was `True` when `total_energy` was set, while `names()`
yielded the storage field. The two accessors therefore disagreed about the same
value:

    >>> out = MACEOutput(total_energy=..., forces=...)
    >>> "energy" in out, "energy" in set(out.names())
    (True, False)

A consumer correlating `ObservableCatalogue.names()` with `MACEOutput.names()`
intersects `("energy", "forces", "stress")` against `("total_energy",
"forces")` and gets `{"forces"}`. The energy is dropped, no exception is
raised, and membership says it was there the whole time. Reported by Bugbot on
the review of this branch and reproduced before being believed.

`names()` now yields observable names, since that is the vocabulary a
declaration uses and the one a caller is correlating against. Reaching a value
goes through `get`, which accepts either spelling, and the one consumer in the
tree already did. The inverse map is derived from the forward one rather than
written out, so an entry added to one direction cannot be missing from the
other, and the new test asserts over both directions rather than over the one
alias that exists today.

`CORE_FIELD_NAMES` keeps yielding storage names, which is what it is for, and
now says so and points at the map.
`packages-lint` failed on the test added with the previous commit, and only
there: `ty` runs against the packages installed editable, and the local venv's
editable entry points at a different checkout, so the local run reported five
unrelated unresolved imports and hid this one. Reproduced in a venv built the
way the job builds it, ruff and ty pinned to the same versions.

numpy types an array's shape, so inferring `TensorT` from a 1-D energy beside a
2-D force array makes it a union of the two, and `dict` is invariant in its
value type: the `extras` literal is then unassignable to
`dict[str, <that union>]`. The construction is fine at runtime and says nothing
about the class.

The parameter is pinned to `np.ndarray` at the construction, which is the
spelling the rest of the file already uses. `ty` suggests widening `extras` to
a `Mapping` instead, and that is the wrong trade here: it would make a result
object look immutable to consumers in order to quiet a test.
The inventory renames the frozen tree's `node_energy` to `node_energies`, and
its note says why: keeping the singular "would leave `extras['node_energy']`
able to sit beside a `node_energies` field holding the same quantity". The
rename did not achieve that. `extras` accepts any key the shadowing guard does
not recognise, and the guard knows only the core fields and the one observable
alias, so the exact construction the note rules out was accepted:

    MACEOutput(node_energies=..., extras={"node_energy": ...})

Reported by Bugbot, and only half of it holds. Its first claim is that
`get("node_energy")` should find the field; that is the rename, working as
decided, and resolving it would carry both spellings after all. Its second is
the dual storage above, which is real and reproduced.

So the retired spellings are named and refused at construction, and they are
deliberately *not* resolvable: `get` still misses them and `__contains__` still
says no, with a test on each half so a later change cannot quietly turn a
refusal into an alias. Refusing it is unconditional rather than conditional on
the field being filled, because the two names are one quantity and only one of
them is this type's; the test states that so relaxing it would be a decision.
The default catalogue was a YAML file shipped as package data, with a loader
around it. A user could not reach it from a configuration: the model config
names observables, and the names resolve against the shipped file, so adding a
property meant editing a file inside the package, which is editing code by
another name.

DEFAULT_CATALOGUE builds the same catalogue from the spec objects, which are
frozen, so it is shared rather than reloaded. The validation is unchanged, and
the tests that declared rows as YAML text now declare them as the plain data a
configuration would hold. mace-core no longer depends on PyYAML.

CORE-1

This branch was successfully deployed

1 active deployment
gpu-internal — 1cb4fed4 Deployed Sep 24, 2026 by aacostadiaz via gpu-amd #600
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

reforge MACE v1 rewrite (Reforge) work item

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CORE-1 — Typed outputs + declarative observable specification

2 participants