uofa-demo / src /uofa_cli /interrogate /reference_source.py
cloudronin's picture
push build context (uofa source + packs + space app)
a28ec65 verified
Raw
History Blame Contribute Delete
8.95 kB
"""Reference source β€” the pluggable boundary for *truth* SIP measures against.
Pack-shaped architecture spec Β§3a. The reference/solver is the *thing that
varies* β€” precomputed AirfRANS data, experimental data, a cached solver result,
or (downstream) a live OpenFOAM run. It must live behind a contract, not be
hardcoded, exactly as the model under test lives behind ``ModelAdapter``. This is
that contract, defined as **one whole interface now** (serve + generate) so
nothing forks between Product A (serve) and Product B (generate): Product B
*exercises more of the same interface* rather than reconciling a second one.
Two capabilities, one interface:
- **serve** β€” :meth:`ReferenceSource.reference` returns truth for inputs that
*have* answers (precomputed datasets, experimental data, cached results).
Directly analogous to ``ModelAdapter.predict`` β€” same shape, opposite source
(truth vs. prediction). This is what Product A's evidence path exercises.
- **generate** β€” :meth:`ReferenceSource.generate` produces truth for *novel*
inputs with no answer yet (run the solver). The heavier capability Product
B's mitigation loop exercises. The contract is fixed now; a working
generating source (VTK/OpenFOAM parsing, field→QoI reduction, convergence)
is the downstream solver-ingest build β€” a separate spec β€” not this one.
**Capability-detection, not a dead method** (Β§3a): a serve-only source declares
``supports_generate() β†’ False`` and does not carry a live ``generate``; the
consumer asks before invoking. A whole interface with a declared *optional*
capability, never a present-but-dead method.
**Firewall placement β€” the same for both halves** (Β§3a, decided once for the
whole interface so the Product-B *generate* half can't later be bolted on with a
different stance): a reference source feeds *truth into the measurement
comparison*, whether looked-up (*serve*) or computed (*generate*). Both halves
sit on the **reference side** of the firewall β€” they provide what SIP measures
against; they never produce verdicts, decisions, or actions. (Distinct from the
action/decision region governed by the Β§4 two-scope signing.)
SIP has NO native solver/dataset support: a user (or a premium pack) wraps their
truth source in a ``ReferenceSource`` subclass, exactly as ``adapter.py`` wraps a
model. SIP never imports VTK/OpenFOAM/airfrans; the source does.
"""
from __future__ import annotations
import importlib
import importlib.util
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Any
from uofa_cli.interrogate.loader import Reference, load_reference
_DATA_SUFFIXES = {".npz", ".json"}
class ReferenceSource(ABC):
"""One contract for truth: serve precomputed answers, optionally generate new ones.
Subclasses implement :meth:`reference` (serve). They MAY additionally support
generation by overriding :meth:`supports_generate` to return True and
implementing :meth:`generate`; a serve-only source leaves both at their
defaults (no dead method). Mirrors the ``ModelAdapter`` ABC style β€” a thin,
single-responsibility interface a pack implements, resolved by reference.
"""
@abstractmethod
def reference(self, inputs: Any = None) -> dict[str, Any]:
"""Serve truth: ``{qoi_name: reference_values}`` for the evaluation points.
The serve analog of ``ModelAdapter.predict`` β€” same ``{qoi: array}`` shape,
opposite source. ``inputs`` is accepted to mirror ``predict`` and to let a
generating source key off the points; a precomputed source already holds
the answers and may ignore it.
"""
raise NotImplementedError
def constraint_fields(self) -> dict[str, Any]:
"""Residual fields for declared physics constraints (``{constraintId: field}``).
Reference-side supporting data SIP compares against; empty when the source
carries none. Not a measurement and not a verdict.
"""
return {}
def uq_intervals(self) -> dict[str, tuple[Any, Any]]:
"""Prediction-interval bounds per QoI (``{qoi: (lower, upper)}``) for UQ coverage."""
return {}
def supports_generate(self) -> bool:
"""Whether this source can *generate* truth for novel inputs (capability-detection).
Defaults to False β€” a serve-only source. The consumer asks this before
invoking :meth:`generate`, so a serve-only source carries no dead method.
"""
return False
def generate(self, inputs: Any) -> dict[str, Any]:
"""Generate truth for *novel* inputs (run the solver). Optional capability.
Default raises β€” a serve-only source declares it does not support
generation via :meth:`supports_generate`. A generating source overrides
both. Output sits on the reference side of the firewall, identical to
:meth:`reference` (truth, never a verdict). Building a working generating
source (the solver internals) is the downstream solver-ingest spec.
"""
raise NotImplementedError(
f"{type(self).__name__} is serve-only and does not support generation. "
f"Check supports_generate() before calling generate()."
)
class FileReferenceSource(ReferenceSource):
"""Serve-only reference source backed by a precomputed ``.npz``/``.json`` file.
The Product-A evidence path's source: it serves precomputed reference values,
constraint residual fields, and UQ interval bounds (the conventions in
``loader``). It declares ``supports_generate() β†’ False`` and carries no
``generate`` β€” capability-detection, not a no-op (Β§3a). Proves the interface
end to end without any generating internals.
"""
def __init__(self, path: str | Path):
self.path = Path(path)
self._data: Reference = load_reference(self.path)
def reference(self, inputs: Any = None) -> dict[str, Any]:
return self._data.reference
def constraint_fields(self) -> dict[str, Any]:
return self._data.constraint_fields
def uq_intervals(self) -> dict[str, tuple[Any, Any]]:
return self._data.uq_intervals
def to_reference(source: ReferenceSource, inputs: Any = None) -> Reference:
"""Materialize the in-memory :class:`Reference` SIP's measurements consume.
The boundary between the pluggable source and SIP's internals: serve the
three kinds of truth through the interface, then hand SIP the data container
it already reads. Keeps the measurement methods unchanged while routing truth
through ``ReferenceSource``.
"""
return Reference(
reference=source.reference(inputs),
constraint_fields=source.constraint_fields(),
uq_intervals=source.uq_intervals(),
)
def load_reference_source(ref: str | Path) -> ReferenceSource:
"""Resolve a ``ReferenceSource`` from a data-file path or a class reference.
Accepted forms (mirrors ``adapter.load_adapter``, with a data-file shortcut):
- ``"/path/to/reference.npz"`` / ``".json"`` β†’ a :class:`FileReferenceSource`
(the precomputed serve-only source).
- ``"package.module:ClassName"`` / ``"/path/file.py:ClassName"`` β†’ a custom
(e.g. premium, possibly generating) source, imported and instantiated.
Raises ``ValueError`` if a class reference does not resolve to a
``ReferenceSource`` subclass.
"""
ref_str = str(ref)
if Path(ref_str).suffix.lower() in _DATA_SUFFIXES:
return FileReferenceSource(ref_str)
cls = _resolve_class(ref_str)
if not (isinstance(cls, type) and issubclass(cls, ReferenceSource)):
raise ValueError(
f"{ref!r} does not resolve to a ReferenceSource subclass (got {cls!r}). "
f"Subclass uofa_cli.interrogate.reference_source.ReferenceSource."
)
return cls()
def _resolve_class(ref: str):
if ":" in ref and (ref.endswith(".py") or "/" in ref.split(":", 1)[0]):
file_part, _, class_name = ref.partition(":")
path = Path(file_part).expanduser().resolve()
if not path.is_file():
raise FileNotFoundError(f"ReferenceSource file not found: {path}")
spec = importlib.util.spec_from_file_location(path.stem, path)
if spec is None or spec.loader is None:
raise ImportError(f"Could not load ReferenceSource module from {path}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return getattr(module, class_name)
module_path, _, class_name = ref.rpartition(":") if ":" in ref else ref.rpartition(".")
if not module_path:
raise ValueError(
f"ReferenceSource ref {ref!r} must be a .npz/.json path, "
f"'pkg.module:ClassName', or '/path/file.py:ClassName'."
)
module = importlib.import_module(module_path)
return getattr(module, class_name)