Skip to content

refactor(ml): move inference behind a ModelPrediction seam #174

Description

@junwen94

Context

Core carries a hand-ported copy of the QRF95 feature pipeline. ml/features.py,
ml/kdistance_features.py, ml/cgcnn.py, ml/cgcnn_graph.py,
ml/atom_features.py, and ml/metallicity.py are about 700 lines that exist in
stfc/goldilocks-ml as well, because that is where they were trained. They have
already drifted: the same feature contract is called
comp_struct_soap_lattice_metal.v1 in goldilocks-ml, and
qrf_comp_struct_soap_lattice_metal — no version — in Core's
model_registry.toml and in both published PSDI records.

model_registry.toml looks configuration-driven but is not. soap_r_cut,
element_property_preset, metallicity_graph_radius and the rest only mean
anything because Core hardcodes one pipeline that consumes them. A model with
different descriptors needs new Core code, and Core has to know that predicting
a k-mesh requires loading a CGCNN metallicity checkpoint — a training-time
implementation detail of one model's feature vector.

This blocks the models on the roadmap. Metallicity, magnetism, smearing, and
exchange-correlation will each need a model, and on the current shape each one
lands as new featurisation code inside Core.

Relationship to #168. #168 owns how asset files reach the disk: the asset
store, domain registries, install/verify, and container profiles. This issue
owns what happens after that — who owns featurisation and inference, and what
crosses the Core/ml boundary. Nothing here proposes a new acquisition path.
The two reinforce each other: model.json now carries the feature
configuration and the digest-pinned supporting artifacts, so the ML registry
entry #168 is designing shrinks to a task, a source, and a location.

Relationship to #134. Deleting goldilocks_core.ml.* removes most of what
that issue defers.

Problem

  1. Featurisation is duplicated between two repositories and has already drifted
    in the contract name.
  2. Core must know model-internal details (a metallicity checkpoint is an input
    to the k-mesh feature vector).
  3. Publishing a model requires a Core release, because the registry ships in
    Core's wheel.
  4. advisors/kdistance_advisor.py and advisors/kindex_advisor.py implement
    the same step — a predicted quantity to a mesh — two different ways.
  5. Nothing checks that an artifact and the installed code agree. A k-distance
    trained under one 2π convention and read under the other produces a
    plausible, wrong mesh silently.

Proposed approach

One prediction type, defined by Core

Core is the consumer, so Core owns the vocabulary. goldilocks-ml satisfies it
structurally and does not import Core, so there is no dependency cycle.

@dataclass(frozen=True, slots=True)
class ModelPrediction:
    parameter: str          # k_points | smearing | magnetism | xc | ...
    quantity: str           # k_distance | k_index | degauss | functional | ...
    value: float | int | bool | str | tuple[Any, ...]
    target_contract: str
    model_id: str
    confidence: float | None = None
    details: JsonDict | None = None      # recorded into Provenance.details
    warnings: tuple[str, ...] = ()       # recorded into Provenance.warnings

parameter matches the field in ParameterAdvice, so routing is a lookup, not
a branch per model. One type covers every advised parameter; a model for one
nothing covers yet adds a contract row and a resolver, not a new type.

The mesh ladder is Core's; a model picks a rung

build_kmesh_entries already enumerates every reachable mesh for a structure
and labels each with k_index, k_distance_interval, k_line_density_interval
and k_pra. That ladder is structure-determined and model-independent. One
resolver replaces both advisors:

def resolve_prediction(structure, prediction) -> KPointSelection

Two conventions must live in exactly one place there. k_distance rises as the
mesh coarsens while k_index, k_pra and k_line_density rise as it refines;
and k_distance_to_mesh uses the 2π reciprocal lattice while
mesh_to_k_line_density_interval uses the crystallographic one. Both are in
kmesh.py today and both are easy to get backwards per advisor.

The set of mesh-determining quantities is bounded by physics, unlike feature
pipelines, so Core owning it does not recreate the original problem.

A prediction is one value, never an interval

KPointSelection carries one grid, CalculationIntent has no conservatism
knob, and the study behind QRF95 reported its metrics against the median. The
interval currently survives only as prose inside provenance.reason. Choosing
which point to publish is a modelling decision; it stays with the model.

Uncertainty is demoted, not discarded. goldilocks-ml puts the interval in
details and, where a prediction is far wider than calibration led it to
expect, a sentence in warnings. Core copies both into Provenance verbatim
and branches on neither, so the judgement stays where the calibration is known.

Contracts are checked at load

load_model in goldilocks-ml already refuses an artifact whose target contract
has no defined parameter, whose trainer this build cannot serve, whose feature
schema it does not provide, whose pinned artifact digests do not match, or
whose estimator width disagrees with its own record. Each names what is
missing. An old install and a new artifact fail loudly instead of predicting
from the wrong vector.

Two upgrade paths, both leaving Core untouched

  • A retrained model on the same feature contract is a data change: point the
    registry at the new record.
  • A new feature contract requires a goldilocks-ml release, and the error
    names it. Core still does not change.

There is no third option. Shipping code inside the artifact means unpickling
arbitrary code, and a declarative feature DSL is what model_registry.toml
already attempts.

Dependencies

Core's base install already carries dscribe, matminer, pymatgen,
scikit-learn, sklearn-quantile, torch, and torch-geometric by the
decision in #32. Depending on goldilocks-ml[qrf95] replaces those direct pins
rather than adding to them, and removes the three-way manual synchronisation of
scikit-learn==1.7.2 across Core, goldilocks-ml, and the deposit manifests.
goldilocks_ml.inference imports on a base install, so ModelPrediction and
the contract table are readable without the scientific stack.

Phases

  • Define the vocabulary. Add ModelPrediction and the StructureModel
    protocol to contracts.py, plus the parameter-to-resolver table. No
    behaviour change.
  • Add the adapter path. Depend on goldilocks-ml[qrf95]; add a k-mesh
    advisor that calls load_model and resolve_prediction. Keep the
    existing path in place.
  • Verify equivalence. Run both paths over the same structures and
    confirm identical meshes before anything is deleted.
  • Unify the resolvers. Replace kdistance_advisor and kindex_advisor
    with the single ladder lookup, monotonicity handled once.
  • Delete the duplicate. Remove goldilocks_core.ml.* featurisation and
    model loading with their tests, and drop the now-unused direct
    dependencies.
  • Move the registry out of the wheel. Resolve in order: explicit path or
    GOLDILOCKS_MODEL_REGISTRY, user config, the registry shipped with
    goldilocks-ml, then Core's built-in fallback. Coordinate the file's shape
    with feat(assets): unify model and pseudopotential acquisition #168.

Acceptance criteria

  • Core contains no matminer, SOAP, or CGCNN code.
  • Core never names a metallicity checkpoint; the model's record declares it.
  • An artifact whose contract Core cannot honour fails at load, naming what
    is missing, and a regression test covers each refusal.
  • Publishing a retrained model on an existing feature contract changes no
    Python in either repository.
  • import goldilocks_core loads no goldilocks_core.ml.* module (perf: lazy import goldilocks_core (defer ml.* / scipy) #134).
  • Both paths produce identical meshes over the example structures before
    the old one is removed.
  • uv run pytest, uv run ruff check src tests, and
    uv run pre-commit run --all-files are green.

Upstream state

The goldilocks-ml side is implemented on feat/2-qrf95-training
(stfc/goldilocks-ml#9): goldilocks_ml.inference with ModelPrediction,
load_model, the predictor registry, digest-verified artifact resolution, and
the contract checks. It needs a release before Core can depend on it.

stfc/goldilocks-ml#10 tracks a licence conflict in the ported code that must
be settled before either repository redistributes it.


Written by an agent on behalf of Junwen Yin.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    architectureArchitecture and API boundary workcoreCore package pipeline, contracts, and recommendationsmlMachine-learning model integration and feature extraction

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions