diff --git a/src/language_reading_predictors/statistical_models/did.py b/src/language_reading_predictors/statistical_models/did.py new file mode 100644 index 00000000..fb55646f --- /dev/null +++ b/src/language_reading_predictors/statistical_models/did.py @@ -0,0 +1,342 @@ +# Copyright (c) 2026 Down Syndrome Education International and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Typed settings and a resolved run plan for the difference-in-differences family (#394 pillar 4). + +Mirrors the ITT / gain-factor / level-factor run-plan pattern (:mod:`itt`, +:mod:`gain_factors`, :mod:`level_factors`) for the waitlist-crossover +difference-in-differences (``kind="did"``) models. A model module declares its +settings; the plan is resolved and **validated before any data are loaded or an +output directory is reset**, then a single object drives data preparation, factory +construction and the ``config.json`` / ``model_recipe.md`` audit trail. This removes +the untyped ``spec.extra`` boundary (where a misspelled key silently defaulted) and +records the resolved design, estimand, causal status, analysis population and +missing-data assumption alongside every fit. + +Two designs share the family. **Binary** models fit the t1-t3 *levels* frame and +estimate the arm gap at each wave separately: ``tau_t2`` is the clean randomised t2 +difference-in-differences contrast, ``arm_gap_t3`` the post-crossover +40-vs-20-week association, and ``delta_crossover = tau_t2 - arm_gap_t3`` the +waitlist catch-up. **Dose** variants keep the P1/P2 *transition* frame because +sessions are interval exposures, carry an explicit treatment-presence term with the +``attend`` session covariate, and their session coefficient is observational, never +randomised. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import asdict, dataclass +from typing import Any + +from language_reading_predictors.statistical_models.context import ModelSpec + +# The complete, closed set of legacy ``spec.extra`` keys the DiD family understands. +# Anything else is a typo and must fail before a fit starts. +_LEGACY_KEYS = frozenset( + { + "dose", + "period_varying_dose", + "likelihood", + "outcomes", + "waves", + "periods", + "use_child_re", + "use_age", + "use_varying_delta", + } +) + +_LIKELIHOODS = frozenset({"beta_binomial", "bernoulli_offfloor"}) + + +def _tuple_of_strings(value: Any, *, name: str) -> tuple[str, ...]: + if isinstance(value, str) or not hasattr(value, "__iter__"): + raise TypeError(f"{name} must be a sequence of strings, got {value!r}") + out = tuple(value) + for item in out: + if not isinstance(item, str) or not item: + raise TypeError(f"{name} must contain non-empty strings, got {item!r}") + return out + + +def _tuple_of_ints(value: Any, *, name: str) -> tuple[int, ...]: + if isinstance(value, str) or not hasattr(value, "__iter__"): + raise TypeError(f"{name} must be a sequence of integers, got {value!r}") + out = tuple(value) + for item in out: + # bool is an int subclass but is never a valid wave/period index. + if isinstance(item, bool) or not isinstance(item, int): + raise TypeError(f"{name} must contain integers, got {item!r}") + return out + + +@dataclass(frozen=True, slots=True) +class DiDModelSettings: + """Immutable settings declared by a single difference-in-differences model module. + + Defaults encode the primary binary DiD: the t1-t3 levels frame with a child + random intercept and age, no dose term, and the Beta-Binomial working likelihood. + """ + + dose: bool = False + period_varying_dose: bool = False + likelihood: str = "beta_binomial" + outcomes: tuple[str, ...] = () + waves: tuple[int, ...] = (0, 1, 2) + periods: tuple[int, ...] = (0, 1) + use_child_re: bool = True + use_age: bool = True + use_varying_delta: bool = False + + def __post_init__(self) -> None: + object.__setattr__( + self, "outcomes", _tuple_of_strings(self.outcomes, name="outcomes") + ) + object.__setattr__(self, "waves", _tuple_of_ints(self.waves, name="waves")) + object.__setattr__(self, "periods", _tuple_of_ints(self.periods, name="periods")) + for flag in ("dose", "period_varying_dose", "use_child_re", "use_age", "use_varying_delta"): + if not isinstance(getattr(self, flag), bool): + raise TypeError(f"{flag} must be bool") + if self.period_varying_dose and not self.dose: + raise ValueError("period_varying_dose requires dose=True") + if self.likelihood not in _LIKELIHOODS: + raise ValueError( + f"likelihood must be one of {sorted(_LIKELIHOODS)}, got {self.likelihood!r}" + ) + + @classmethod + def from_legacy_extra( + cls, extra: Mapping[str, Any], *, model_id: str + ) -> DiDModelSettings: + """Strictly translate the former ``spec.extra`` dictionary boundary. + + Rejects unknown keys so a misspelling fails before data loading rather than + silently taking a default.""" + unknown = sorted(set(extra) - _LEGACY_KEYS) + if unknown: + raise ValueError( + f"{model_id}: unknown DiD setting(s): {', '.join(unknown)}. " + "Declare DiDModelSettings so misspellings fail fast." + ) + # Pass raw values through so __post_init__ is the single validation/coercion + # point; pre-coercing here (tuple(...)/bool(...)) would silently reshape + # misshaped legacy settings instead of failing fast against the strict checks. + return cls( + dose=extra.get("dose", False), + period_varying_dose=extra.get("period_varying_dose", False), + likelihood=extra.get("likelihood", "beta_binomial"), + outcomes=extra.get("outcomes", ()), + waves=extra.get("waves", (0, 1, 2)), + periods=extra.get("periods", (0, 1)), + use_child_re=extra.get("use_child_re", True), + use_age=extra.get("use_age", True), + use_varying_delta=extra.get("use_varying_delta", False), + ) + + +@dataclass(frozen=True, slots=True) +class DiDRunPlan: + """Concrete, validated instructions consumed by preparation and modelling.""" + + model_id: str + outcome_symbol: str + settings_source: str + dose: bool + period_varying: bool + likelihood: str + off_floor: bool + outcomes: tuple[str, ...] + waves: tuple[int, ...] + periods: tuple[int, ...] + use_child_re: bool + use_age: bool + use_varying_delta: bool + # Recorded audit metadata (#394 pillar 4). + design: str + estimand: str + causal_status: str + analysis_population: str + missing_data_assumption: str + + @property + def obs_node(self) -> str: + return "y_offfloor" if self.off_floor else "y_post" + + @property + def effect_term(self) -> str: + """The focal coefficient: the randomised t2 DiD contrast, or the dose term.""" + if self.period_varying: + return "mu_dose" + return "beta_dose" if self.dose else "tau_t2" + + def as_dict(self) -> dict[str, Any]: + """Return the JSON-ready run-plan contract for ``config.json``.""" + d = asdict(self) + # JSON has no tuples; keep the integer wave/period vectors as lists. + d["waves"] = list(self.waves) + d["periods"] = list(self.periods) + return d + + def prepare_kwargs(self) -> dict[str, Any]: + """Arguments for ``load_and_prepare`` from the resolved plan. + + Binary models load the t1-t3 ``levels`` panel; dose models keep the P1/P2 + transition frame (``phase_mode="all"``) and add the ``attend`` session + covariate. Both keep ``require_any_post=False`` (a child observed at only one + wave still contributes to the arm-by-wave contrasts).""" + if self.dose: + return { + "phase_mode": "all", + "outcomes": self.outcomes, + "covariates": ("attend",), + "pre_required": (), + "require_any_post": False, + } + return { + "phase_mode": "levels", + "outcomes": self.outcomes, + "require_any_post": False, + } + + def factory_kwargs(self) -> dict[str, Any]: + """Arguments for ``build_did_model`` for this plan. + + ``period_varying_dose`` receives the *resolved* ``period_varying`` (dose AND + the flag), matching the former inline behaviour.""" + return { + "outcome_symbol": self.outcome_symbol, + "waves": self.waves, + "periods": self.periods, + "use_child_re": self.use_child_re, + "use_age": self.use_age, + "dose": self.dose, + "period_varying_dose": self.period_varying, + "use_varying_delta": self.use_varying_delta, + "likelihood": self.likelihood, + } + + def recipe_markdown(self, *, title: str) -> str: + """Undergraduate-friendly explanation generated from the resolved plan.""" + waves = ", ".join(str(w) for w in self.waves) + return ( + "Note: Generated from the validated difference-in-differences run plan; " + "template drafted by an LLM-based AI tool (Claude Code/Opus 4.8).\n\n" + f"# Model recipe: {title}\n\n" + f"Model ID: `{self.model_id}`.\n\n" + f"## Design\n\n{self.design}\n\n" + f"## Estimand\n\n{self.estimand}\n\n" + f"## Causal status\n\n{self.causal_status}\n\n" + f"## Analysis population\n\n{self.analysis_population}\n\n" + f"## Missing data\n\n{self.missing_data_assumption}\n\n" + "## Terms\n\n" + f"Outcome: `{self.outcome_symbol}`. Waves: {waves}. Dose term: " + f"{self.dose} (period-varying: {self.period_varying}). Child random " + f"intercept: {self.use_child_re}. Age adjustment: {self.use_age}.\n\n" + "## Uncertainty and checks\n\n" + "The fit reports a posterior distribution; interpret it only after the " + "convergence gate and posterior-predictive checks pass. The saved " + "`config.json` contains the same resolved run plan in machine-readable " + "form.\n" + ) + + +def declared_did_settings(spec: ModelSpec) -> tuple[DiDModelSettings, str]: + """Return typed settings and their source, rejecting mixed declarations.""" + settings = spec.model_settings + if settings is not None: + if spec.extra: + raise ValueError( + f"{spec.model_id}: DiD settings cannot be split between " + "model_settings and extra" + ) + if not isinstance(settings, DiDModelSettings): + raise TypeError( + f"{spec.model_id}: kind='did' requires DiDModelSettings, got " + f"{type(settings).__name__}" + ) + return settings, "typed" + return ( + DiDModelSettings.from_legacy_extra(spec.extra, model_id=spec.model_id), + "legacy_extra", + ) + + +def resolve_did_run_plan(spec: ModelSpec) -> DiDRunPlan: + """Resolve and validate a difference-in-differences spec before any data are loaded.""" + if spec.kind != "did": + raise ValueError(f"{spec.model_id}: expected kind 'did', got {spec.kind!r}") + if not spec.outcome_symbol: + raise ValueError(f"{spec.model_id}: outcome_symbol is required for a DiD model") + + settings, source = declared_did_settings(spec) + own = spec.outcome_symbol + off_floor = settings.likelihood == "bernoulli_offfloor" + period_varying = settings.dose and settings.period_varying_dose + # The outcome loads as its own outcome when a spec does not list an explicit set. + outcomes = settings.outcomes if settings.outcomes else (own,) + + if settings.dose: + design = ( + "Waitlist-crossover dose model on the P1/P2 transition rows (sessions are " + "interval exposures): an explicit treatment-presence term with the " + "randomised arm, the shared t1 baseline and age, and sessions entered " + "centred and scaled among treated rows." + ) + estimand = ( + "The session-dose association with the outcome among treated rows " + "(period-varying if requested). Observational, not a randomised contrast." + ) + causal_status = ( + "Associational: the session-dose coefficient is observational (sessions " + "are not randomised); the randomised arm enters only as an adjuster." + ) + else: + design = ( + "Waitlist-crossover difference-in-differences on the t1-t3 levels frame: " + "the arm gap is estimated separately at each wave (tau_t2 the randomised " + "t2 contrast, arm_gap_t3 the post-crossover 40-vs-20-week association, " + "delta_crossover = tau_t2 - arm_gap_t3 the waitlist catch-up), with a " + "non-centred child random intercept." + ) + estimand = ( + "The t2 arm gap tau_t2 is the clean randomised difference-in-differences " + "contrast on the fitted available-case sample; arm_gap_t3 is a " + "post-crossover association and delta_crossover is descriptive catch-up." + ) + causal_status = ( + "tau_t2 is randomised (a t2 difference-in-differences contrast on the " + "available-case sample); arm_gap_t3, delta_crossover and any dose term " + "are latent-ability-confounded associations, never randomised effects." + ) + analysis_population = ( + "Available-case children observed across the difference-in-differences waves " + "(about 53-54 depending on outcome). The randomised interpretation applies to " + "the t2 contrast on this available-case population, not automatically the " + "complete randomised cohort." + ) + missing_data_assumption = ( + "Available-case analysis under ignorable missingness: missing outcomes and " + "covariates are assumed ignorable given the modelled covariates." + ) + + return DiDRunPlan( + model_id=spec.model_id, + outcome_symbol=own, + settings_source=source, + dose=settings.dose, + period_varying=period_varying, + likelihood=settings.likelihood, + off_floor=off_floor, + outcomes=outcomes, + waves=settings.waves, + periods=settings.periods, + use_child_re=settings.use_child_re, + use_age=settings.use_age, + use_varying_delta=settings.use_varying_delta, + design=design, + estimand=estimand, + causal_status=causal_status, + analysis_population=analysis_population, + missing_data_assumption=missing_data_assumption, + ) diff --git a/src/language_reading_predictors/statistical_models/pipeline.py b/src/language_reading_predictors/statistical_models/pipeline.py index aab60fe5..83e19d53 100644 --- a/src/language_reading_predictors/statistical_models/pipeline.py +++ b/src/language_reading_predictors/statistical_models/pipeline.py @@ -78,6 +78,9 @@ StatisticalFitContext, make_context, ) +from language_reading_predictors.statistical_models.did import ( + resolve_did_run_plan, +) from language_reading_predictors.statistical_models.environment import DOCS_DIR from language_reading_predictors.statistical_models.gain_factors import ( resolve_gain_factors_run_plan, @@ -2975,50 +2978,30 @@ def _did_analysis_contract( def fit_did(spec: ModelSpec, config: str = "dev") -> StatisticalFitContext: _require_spec(spec, "did", outcome=True) + # Resolve and validate the family contract before the context resets an output + # directory or the loader reads any data (#394 pillar 4). One plan then drives + # preparation, factory arguments, the teaching recipe and config.json. Binary + # models use the t1-t3 levels frame so the randomised t2 arm gap and the + # post-crossover t3 gap are estimated separately; dose models retain the + # transition frame because sessions are interval exposures (resolved in the plan). + plan = resolve_did_run_plan(spec) ctx = make_context(spec, config) + ctx.resolved_plan = plan + _report.write_model_recipe(ctx) - section_header("Prepare data") sym = spec.outcome_symbol - dose = bool(spec.extra.get("dose", False)) - period_varying = dose and bool(spec.extra.get("period_varying_dose", False)) - likelihood = spec.extra.get("likelihood", "beta_binomial") - off_floor = likelihood == "bernoulli_offfloor" - # Binary models use t1--t3 levels so the randomised t2 arm gap and the - # post-crossover t3 gap are estimated separately. Dose models retain the - # transition frame because sessions are interval exposures. - outcomes = tuple(spec.extra.get("outcomes", (sym,))) - covariates = ("attend",) if dose else () - if dose: - prepared = load_and_prepare( - phase_mode="all", - outcomes=outcomes, - covariates=covariates, - pre_required=(), - require_any_post=False, - ) - else: - prepared = load_and_prepare( - phase_mode="levels", - outcomes=outcomes, - require_any_post=False, - ) + dose = plan.dose + period_varying = plan.period_varying + off_floor = plan.off_floor + + section_header("Prepare data") + prepared = load_and_prepare(**plan.prepare_kwargs()) ctx.prepared = prepared _print_header(ctx) section_header("Build model") - built = _factories.build_did_model( - prepared, - outcome_symbol=sym, - waves=tuple(spec.extra.get("waves", (0, 1, 2))), - periods=tuple(spec.extra.get("periods", (0, 1))), - use_child_re=spec.extra.get("use_child_re", True), - use_age=spec.extra.get("use_age", True), - dose=dose, - period_varying_dose=period_varying, - use_varying_delta=spec.extra.get("use_varying_delta", False), - likelihood=likelihood, - ) + built = _factories.build_did_model(prepared, **plan.factory_kwargs()) _attach_built(ctx, built) _print_header(ctx) did_contract = _did_analysis_contract( @@ -3142,7 +3125,7 @@ def fit_did(spec: ModelSpec, config: str = "dev") -> StatisticalFitContext: varying_term="", row_mask=built.prepared.phase == 1, likelihood="bernoulli" if off_floor else "beta_binomial", - child_re=bool(spec.extra.get("use_child_re", True)), + child_re=plan.use_child_re, child_idx=built.prepared.child_idx, delta=ROPE_DELTA_PROB.get(sym) if off_floor else ROPE_DELTA.get(sym), population=( @@ -3187,7 +3170,7 @@ def fit_did(spec: ModelSpec, config: str = "dev") -> StatisticalFitContext: section_header("Period-resolved dose-slope summary") _write_dose_slope_summary(ctx, period_varying=True) het = None - if spec.extra.get("use_varying_delta", False): + if plan.use_varying_delta: section_header("Exploratory waitlist catch-up heterogeneity") het = _did_heterogeneity_summary(ctx.trace, ci_prob=ctx.reporting.ci_prob) pd.DataFrame([het]).to_csv( diff --git a/src/language_reading_predictors/statistical_models/reporting.py b/src/language_reading_predictors/statistical_models/reporting.py index 8fac06fe..3bb440c2 100644 --- a/src/language_reading_predictors/statistical_models/reporting.py +++ b/src/language_reading_predictors/statistical_models/reporting.py @@ -27,6 +27,10 @@ from language_reading_predictors.statistical_models.context import ( StatisticalFitContext, ) +from language_reading_predictors.statistical_models.did import ( + DiDRunPlan, + resolve_did_run_plan, +) from language_reading_predictors.statistical_models.gain_factors import ( GainFactorsRunPlan, resolve_gain_factors_run_plan, @@ -2273,12 +2277,22 @@ def _gain_factors_run_plan(context: StatisticalFitContext) -> GainFactorsRunPlan return resolve_gain_factors_run_plan(context.spec) +def _did_run_plan(context: StatisticalFitContext) -> DiDRunPlan: + """Return the DiD plan resolved before loading, or reconstruct it.""" + resolved_plan = getattr(context, "resolved_plan", None) + if isinstance(resolved_plan, DiDRunPlan): + return resolved_plan + return resolve_did_run_plan(context.spec) + + def _resolved_run_plan(context: StatisticalFitContext): - """The typed run plan for families that have one (ITT, gain factors), else None.""" + """The typed run plan for families that have one (ITT, gain / did), else None.""" if context.spec.kind == "itt": return _itt_run_plan(context) if context.spec.kind == "gain_factors": return _gain_factors_run_plan(context) + if context.spec.kind == "did": + return _did_run_plan(context) return None diff --git a/tests/statistical_models/test_did_run_plan.py b/tests/statistical_models/test_did_run_plan.py new file mode 100644 index 00000000..cf91af4a --- /dev/null +++ b/tests/statistical_models/test_did_run_plan.py @@ -0,0 +1,222 @@ +# Copyright (c) 2026 Down Syndrome Education International and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for the typed difference-in-differences settings and run plan (#394 pillar 4).""" + +from __future__ import annotations + +import glob +import importlib +import os + +import pytest + +from language_reading_predictors.statistical_models.context import ModelSpec +from language_reading_predictors.statistical_models.did import ( + DiDModelSettings, + DiDRunPlan, + resolve_did_run_plan, +) + +_META_FIELDS = ( + "design", + "estimand", + "causal_status", + "analysis_population", + "missing_data_assumption", +) + + +def _did_specs() -> list[ModelSpec]: + """Every registered difference-in-differences model's SPEC.""" + root = os.path.dirname( + importlib.import_module( + "language_reading_predictors.statistical_models.did" + ).__file__ + ) + specs: list[ModelSpec] = [] + for path in sorted(glob.glob(os.path.join(root, "lrp_rli_did_*.py"))): + mod = importlib.import_module( + "language_reading_predictors.statistical_models." + os.path.basename(path)[:-3] + ) + spec = getattr(mod, "SPEC", None) + if spec is not None and spec.kind == "did": + specs.append(spec) + return specs + + +# --- settings validation ------------------------------------------------------ + + +def test_settings_reject_unknown_likelihood(): + with pytest.raises(ValueError, match="likelihood"): + DiDModelSettings(likelihood="poisson") + + +def test_settings_reject_non_bool_dose(): + with pytest.raises(TypeError, match="dose"): + DiDModelSettings(dose=1) # type: ignore[arg-type] + + +def test_settings_reject_string_outcomes(): + with pytest.raises(TypeError, match="outcomes"): + DiDModelSettings(outcomes="W") # type: ignore[arg-type] + + +def test_settings_reject_non_int_waves(): + with pytest.raises(TypeError, match="waves"): + DiDModelSettings(waves=(0, "1")) # type: ignore[list-item] + + +def test_settings_reject_period_varying_without_dose(): + with pytest.raises(ValueError, match="period_varying_dose requires dose"): + DiDModelSettings(period_varying_dose=True) # dose defaults False + + +def test_from_legacy_extra_rejects_unknown_key(): + with pytest.raises(ValueError, match="unknown DiD setting"): + DiDModelSettings.from_legacy_extra( + {"dose": True, "doze": True}, # typo + model_id="lrp-rli-did-999", + ) + + +def test_from_legacy_extra_round_trips_known_keys(): + settings = DiDModelSettings.from_legacy_extra( + { + "dose": True, + "period_varying_dose": True, + "likelihood": "bernoulli_offfloor", + "outcomes": ("W",), + "waves": (0, 1), + "periods": (0, 1), + "use_child_re": False, + "use_age": False, + "use_varying_delta": True, + }, + model_id="lrp-rli-did-999", + ) + assert settings.dose is True + assert settings.period_varying_dose is True + assert settings.likelihood == "bernoulli_offfloor" + assert settings.outcomes == ("W",) + assert settings.waves == (0, 1) + assert settings.use_child_re is False + assert settings.use_varying_delta is True + + +# --- resolve ------------------------------------------------------------------ + + +def _spec(**extra) -> ModelSpec: + return ModelSpec( + model_id="lrp-rli-did-000", + kind="did", + title="test", + outcome_symbol="W", + extra=extra, + ) + + +def test_resolve_rejects_wrong_kind(): + spec = ModelSpec(model_id="x", kind="itt", title="t", outcome_symbol="W") + with pytest.raises(ValueError, match="expected kind 'did'"): + resolve_did_run_plan(spec) + + +def test_resolve_binary_is_levels_frame_and_tau_t2_estimand(): + plan = resolve_did_run_plan(_spec()) + assert plan.settings_source == "legacy_extra" + assert not plan.dose and not plan.period_varying and not plan.off_floor + assert plan.effect_term == "tau_t2" + assert plan.obs_node == "y_post" + assert "randomised" in plan.causal_status + assert "tau_t2" in plan.estimand + prep = plan.prepare_kwargs() + assert prep["phase_mode"] == "levels" + assert prep["outcomes"] == ("W",) # defaults to the outcome symbol + assert prep["require_any_post"] is False + assert "covariates" not in prep # binary loads no session covariate + + +def test_resolve_dose_is_transition_frame_with_attend_and_associational(): + plan = resolve_did_run_plan(_spec(dose=True)) + assert plan.dose and not plan.period_varying + assert plan.effect_term == "beta_dose" + prep = plan.prepare_kwargs() + assert prep["phase_mode"] == "all" + assert prep["covariates"] == ("attend",) + assert prep["pre_required"] == () + assert plan.causal_status.startswith("Associational") + assert "observational" in plan.estimand.lower() + + +def test_resolve_period_varying_dose_focal_term(): + plan = resolve_did_run_plan(_spec(dose=True, period_varying_dose=True)) + assert plan.period_varying + assert plan.effect_term == "mu_dose" + # The factory receives the *resolved* period_varying under period_varying_dose. + assert plan.factory_kwargs()["period_varying_dose"] is True + + +def test_resolve_off_floor_sets_bernoulli_node(): + plan = resolve_did_run_plan(_spec(likelihood="bernoulli_offfloor")) + assert plan.off_floor + assert plan.obs_node == "y_offfloor" + assert plan.factory_kwargs()["likelihood"] == "bernoulli_offfloor" + + +def test_resolve_explicit_outcomes_are_kept(): + plan = resolve_did_run_plan(_spec(outcomes=("W", "L"))) + assert plan.prepare_kwargs()["outcomes"] == ("W", "L") + + +def test_typed_settings_are_accepted_and_sourced(): + spec = ModelSpec( + model_id="lrp-rli-did-000", + kind="did", + title="test", + outcome_symbol="W", + model_settings=DiDModelSettings(dose=True), + ) + plan = resolve_did_run_plan(spec) + assert plan.settings_source == "typed" + assert plan.dose is True + + +def test_split_settings_between_typed_and_extra_is_rejected(): + spec = ModelSpec( + model_id="lrp-rli-did-000", + kind="did", + title="test", + outcome_symbol="W", + model_settings=DiDModelSettings(), + extra={"dose": True}, + ) + with pytest.raises(ValueError, match="cannot be split"): + resolve_did_run_plan(spec) + + +# --- registered-specification coverage (acceptance criterion) ----------------- + + +def test_every_registered_did_model_resolves_with_metadata(): + """Every registered DiD model resolves to a validated plan that records the + design, estimand, causal status, analysis population and missing-data + assumption (#394 pillar 4).""" + specs = _did_specs() + assert len(specs) >= 14, f"expected the full DiD suite, found {len(specs)}" + saw_dose = saw_binary = False + for spec in specs: + plan = resolve_did_run_plan(spec) + assert isinstance(plan, DiDRunPlan) + recorded = plan.as_dict() + for field in _META_FIELDS: + assert isinstance(recorded[field], str) and recorded[field], ( + f"{spec.model_id}: {field} not recorded" + ) + assert plan.prepare_kwargs()["outcomes"][0] == spec.outcome_symbol + saw_dose |= plan.dose + saw_binary |= not plan.dose + assert saw_dose, "no dose DiD model found" + assert saw_binary, "no binary DiD model found"