Source code for euromod_linking.registry
"""Method registry — the named linkage methodologies.
A *method* is a reviewed implementation that turns external-model shocks (the
canonical shock table) into a transformed EUROMOD input DataFrame plus run
parameters. Methodology lives in code; a scenario can only select a method and
supply scenario-semantics params validated against the method's own
params_schema.
Methods carry no version number. What guards against an edit to the modelling
silently serving results computed by earlier code is `code_fingerprint`, which
hashes a method's own source into the scenario fingerprint — a content-derived
identity that cannot be forgotten the way a hand-maintained version integer can.
"""
from dataclasses import dataclass, field
from typing import Callable
[docs]
class MethodLookupError(KeyError):
def __init__(self, message: str, available: list[str]):
super().__init__(message)
self.available = available
def __str__(self):
return self.args[0]
[docs]
@dataclass(frozen=True)
class MethodSpec:
"""A methodology's published contract: what it consumes, what it needs, and
what it does to the input.
``name`` is how a methodology is referred to everywhere else — in dispatch,
in a scenario's ``methodology`` pin, and in results.
"""
name: str # e.g. "lma_labour_alignment"
summary: str # one-liner for the method list
description: str # full methodology description
channels_consumed: tuple[str, ...] # shock channels this method understands
metrics_consumed: tuple[str, ...] # metrics within those channels
cell_variables: str # human description of what can define target cells
dataset_requirements: tuple[str, ...] # method's own required input columns
addon_requirements: tuple # (addon_entries, switch_entries) applied to both runs
injected_columns: tuple[str, ...] # numeric columns the method adds to the input
params_schema: dict # JSON Schema for scenario "params" (additionalProperties: false)
restructures_rows: bool = False # True when apply() adds/removes rows (e.g. household
# weight splits) — the baseline is then rebuilt on the
# same rows so the two runs stay observation-paired
min_model_release: str | None = None # earliest EUROMOD release shipping what this method
# needs, e.g. "J2.54" — advisory only, see compat.py
factory: Callable = field(repr=False, default=None) # () -> method instance
_REGISTRY: dict[str, MethodSpec] = {}
[docs]
def register(spec: MethodSpec) -> MethodSpec:
_REGISTRY[spec.name] = spec
return spec
[docs]
def available_names() -> list[str]:
return sorted(_REGISTRY)
[docs]
def resolve(name: str) -> MethodSpec:
"""The method registered under `name`."""
name = str(name or "").strip()
if name in _REGISTRY:
return _REGISTRY[name]
base, sep, _ = name.partition("@")
if sep and base.strip() in _REGISTRY:
raise MethodLookupError(
f"Unknown method {name!r}. Methods are named without a version — "
f"use {base.strip()!r}.", available_names())
raise MethodLookupError(f"Unknown method {name!r}", available_names())
[docs]
def list_specs() -> list[MethodSpec]:
return [_REGISTRY[k] for k in sorted(_REGISTRY)]
_code_fp_cache: dict[str, str] = {}
[docs]
def code_fingerprint(spec: MethodSpec) -> str:
"""Content hash of the methodology's own source.
Results are cached on the scenario fingerprint, but a scenario document does
not change when the *methodology* does — so without this, editing a
method's modelling silently serves results computed by the earlier code.
Hashing the implementation makes any code change invalidate its cached runs,
the same content-addressed discipline used for shock tables and scores.
Empty string if the source cannot be read, which leaves caching keyed on the
methodology name alone."""
if spec.name in _code_fp_cache:
return _code_fp_cache[spec.name]
import hashlib
import inspect
from pathlib import Path
fp = ""
try:
path = Path(inspect.getfile(spec.factory))
files = sorted(path.parent.rglob("*.py")) if path.name == "__init__.py" else [path]
h = hashlib.sha256()
for f in files:
h.update(f.name.encode())
h.update(f.read_bytes())
fp = h.hexdigest()[:12]
except Exception:
pass
_code_fp_cache[spec.name] = fp
return fp
[docs]
def resolve_for_channels(channels: set[str], metrics: set[str]) -> MethodSpec:
"""Dispatch: the unique registered methodology covering the given shock
channels (and metrics, where the spec constrains them). Users never select
a methodology — it is resolved from the shocks and echoed in the response;
an explicit scenario pin exists only for reproduction and for the day two
methodologies claim the same channel."""
candidates = [spec for spec in _REGISTRY.values()
if channels <= set(spec.channels_consumed)
and (not spec.metrics_consumed or metrics <= set(spec.metrics_consumed))]
if not candidates:
supported = sorted({c for s in _REGISTRY.values() for c in s.channels_consumed})
raise MethodLookupError(
f"No methodology handles shock channels {sorted(channels)}"
+ (f" with metrics {sorted(metrics)}" if metrics else "")
+ f". Supported channels: {supported}",
available_names())
if len(candidates) > 1:
names = sorted(s.name for s in candidates)
raise MethodLookupError(
f"Multiple methodologies handle channels {sorted(channels)}: {names}. "
"Pin one via the scenario's 'methodology' field.", names)
return candidates[0]