Source code for mmm_framework.estimands.capabilities

"""Model capability detection for estimand gating.

:func:`model_capabilities` returns a ``set[str]`` of capability flags by **cheap
duck-typing** — no graph build, no posterior-predictive sampling. An
:class:`~mmm_framework.estimands.spec.Estimand` declares
``required_capabilities``; the evaluator returns an ``unsupported`` result when
they are not met (so a non-MMM model auto-filters MMM-only estimands rather than
crashing).

Capabilities are plain strings; parameterized ones use ``"NS:arg"`` (e.g.
``"HAS_LATENT:awareness"``) so a typed need — a future CFA's
``"HAS_FACTOR_LOADINGS"`` — is gateable without a schema change.
"""

from __future__ import annotations

from typing import Any

# Canonical flag names (string constants so callers don't typo them).
HAS_CHANNELS = "HAS_CHANNELS"
HAS_CONTRIBUTIONS = "HAS_CONTRIBUTIONS"
HAS_CONTRIBUTION_DETERMINISTIC = "HAS_CONTRIBUTION_DETERMINISTIC"
IN_GRAPH_RESPONSE_CURVE = "IN_GRAPH_RESPONSE_CURVE"
HAS_LATENT = "HAS_LATENT"  # parameterized: f"HAS_LATENT:{name}"


def _posterior(model: Any) -> Any | None:
    """The posterior group, or None when the model is unfitted/traceless."""
    trace = getattr(model, "_trace", None) or getattr(model, "trace", None)
    if trace is None:
        return None
    return getattr(trace, "posterior", None)


[docs] def model_capabilities(model: Any) -> set[str]: """Capability flags for ``model`` (safe on an unfitted model). Parameters ---------- model: Any object duck-typing the MMM surface (``channel_names``, optionally a fitted ``_trace``). """ caps: set[str] = set() channels = getattr(model, "channel_names", None) if channels: caps.add(HAS_CHANNELS) if hasattr(model, "compute_counterfactual_contributions"): caps.add(HAS_CONTRIBUTIONS) if getattr(model, "use_parametric_adstock", False): caps.add(IN_GRAPH_RESPONSE_CURVE) posterior = _posterior(model) if posterior is not None: try: var_names = set(posterior.data_vars) # xarray Dataset except Exception: # noqa: BLE001 var_names = set() if "channel_contributions" in var_names or any( v.startswith(("contribution_", "channel_contribution_")) for v in var_names ): caps.add(HAS_CONTRIBUTION_DETERMINISTIC) # Expose every posterior data var as a gateable latent so a declared # estimand can require e.g. "HAS_LATENT:awareness". for v in var_names: caps.add(f"{HAS_LATENT}:{v}") return caps
[docs] def missing_capabilities(required: list[str], available: set[str]) -> list[str]: """The subset of ``required`` not present in ``available`` (order-preserving).""" return [c for c in required if c not in available]
__all__ = [ "HAS_CHANNELS", "HAS_CONTRIBUTIONS", "HAS_CONTRIBUTION_DETERMINISTIC", "IN_GRAPH_RESPONSE_CURVE", "HAS_LATENT", "model_capabilities", "missing_capabilities", ]