Skip to content

StatsPAI 1.13.1 — Stability tiers + external-validity dossier + cold-start surgery

Choose a tag to compare

@brycewang-stanford brycewang-stanford released this 05 May 23:07

Headline

Stability tiers, external-validity dossier, and cold-start surgery in a
single release. Every FunctionSpec now carries a stability field
plus per-function limitations, surfaced through
sp.describe_function, sp.help, sp.list_functions(stability=...),
the statspai list CLI, and the LLM-facing sp.function_schema
description; sp.recommend / sp.causal / sp.paper default to
dropping experimental / deprecated entries unless
allow_experimental=True is passed — closing a path where an agent
could silently land on a frontier MVP. Eight high-impact estimators
(aipw, aggte, pretrends_test, sensitivity_rr, mccrary_test,
oster_bounds, wild_cluster_bootstrap, rd_honest) are upgraded
from auto-registered stubs to hand-written specs with full assumption
/ failure-mode / alternative metadata. A weak-instrument preflight
gate in sp.preflight(data, "ivreg", formula=...) raises a structured
warning row when the first-stage F falls below the Staiger–Stock
(1997) or Stock–Yogo (2005) thresholds, and sp.recommend(... design='iv') adaptively reorders LIML / AR ahead of 2SLS on weak
first stages. A 36-module R parity harness, 21-module Stata parity
harness, 4-dataset original-paper replay (Card 1995, Callaway–Sant'Anna
mpdta, Abadie Basque, LaLonde NSW + PSID-1), Track-C performance
harness (HDFE / CS-DiD / SCM / DML log-log scaling), B=1000
Monte-Carlo coverage run, and a 900-trial CausalAgentBench prompt
suite all ship under tests/r_parity/, tests/stata_parity/,
tests/orig_parity/, tests/perf/, and tests/agent_bench/ with
paired R/Python/Stata drivers, JSON results, and 3-way Markdown +
LaTeX parity tables suitable for direct paper inclusion. A new
sp.validation_report() / sp.coverage_matrix() /
sp.reproduce_jss_tables() meta-API summarises the live registry,
materialises the parity / coverage / agent-bench artifacts as JSON,
and optionally re-runs the harnesses end-to-end so referees can
verify StatsPAI's external-validity claims without leaving Python.
Cold-start surgery in three steps brings sklearn submodules pulled by
import statspai from 245 to 0 (statspai.forest lazy-loaded —
Step 1B; 18 estimator files import sklearn lazily inside function
bodies — Step 1C; HAL TMLE classes drop sklearn class inheritance —
Step 1D), pinned by a new
test_sklearn_budget_ceiling_on_bare_import_statspai contract. The
workflow / paper orchestration layer replaces silent except: pass
paths with WorkflowDegradedWarning + structured degradations
records on the result object, so optional-stage failures surface in
PaperDraft.to_dict() and the rendered Pipeline notes section
instead of disappearing. sp.principal_strat(instrument=...) ships a
proper Angrist-Imbens-Rubin Wald-LATE estimator (the kwarg was
previously stubbed); sp.hal_tmle(variant='projection') keeps its
NotImplementedError but now points at a written-out RFC
(docs/rfc/hal_tmle_projection.md) instead of raising in silence.
Lazy-loading of optional families via __getattr__ keeps import statspai fast without breaking same-name function/subpackage
collisions (bartik, deepiv, proximal, …) — pinned by a
late-bind / post-import-shadow contract test and a committed
__init__.pyi stub generator so IDE / mypy see lazy-loaded names. A
latent Callaway–Sant'Anna REG inference scaling bug — discovered
because the parity harness flagged it — is fixed in
did/callaway_santanna.py.

Added

  • Weak-instrument preflight gate in sp.preflight(data, "ivreg", formula=...). The new first_stage_strength check parses the
    Wilkinson IV formula, runs the first-stage OLS, and emits a
    warning row when the partial F-statistic falls below either the
    Staiger–Stock (1997) rule of thumb (F < 10, "very weak") or the
    Stock–Yogo (2005) 10% maximum-size critical value of 16.38 for
    one endog / one instrument (F < 16.38, "weak"). The warning
    payload includes structured recovery_hints pointing at
    method='liml', inference='ar', and sp.anderson_rubin_ci(...)
    so an LLM agent can branch on the typed envelope without parsing
    prose. Closes the §5.3 robustness DGP follow-up: Track B's
    test_iv_weak_instrument_undercoverage documents that 2SLS+HC1
    under-covers at the 0.88 level on a pi=0.10 first stage; the
    preflight now flags this before the user pays for the 2SLS fit.

  • Adaptive IV ranking in sp.recommend(... design='iv'). When
    the live first-stage F is below 10 the recommendation list is
    reordered: LIML moves to the top, an Anderson–Rubin row is
    inserted, and the 2SLS row is annotated with
    very_weak_iv=True plus a rationale that explains the
    HC1-coverage failure mode. When F is in [10, 16.38) the order
    stays 2SLS → LIML but the rationales reference the
    Stock–Yogo threshold. The 2SLS row now also carries
    first_stage_F as a numeric field so downstream tooling can
    consume it without regex.

  • Preflight / recommend tests. tests/test_preflight.py adds
    TestIVFirstStageStrength (6 cases covering strong / weak /
    borderline / non-IV / missing-columns / JSON-safe payloads).
    tests/test_smart_workflow.py::TestRecommend adds two cases
    pinning the 2SLS-first ordering on strong instruments and the
    LIML-first / AR-included ordering on weak instruments.

  • R parity harness — 36 paired R/Python modules. New
    tests/r_parity/ ships 36 paired scripts (one R, one Python) that
    replay the same DGPs through fixest / did / csdid / gsynth /
    MatchIt / DoubleML / rdrobust / Synth / lme4 / plm /
    frontier / MR-PRESSO / lavaan / mediation / WeightIt /
    cobalt / mlogit / nlme / ordinal / … and StatsPAI's matching
    estimator. Each module emits <id>_R.json and <id>_py.json;
    compare.py produces a 3-way parity table (parity_table.md /
    .tex / parity_table_3way.md / .tex) tightened with a small
    ID-column + longtable layout for direct paper inclusion. Two
    parallel tracks — "orig" (canonical-dataset replays) and "perf"
    (timing under matched DGPs) — share the same compare-tooling.

  • Stata parity harness — 21-module StatsPAI ↔ Stata 3-way
    compare.
    New tests/stata_parity/ ships 21 paired
    .do/.py scripts covering reghdfe, xtreg, csdid, did_imputation,
    synth, synth_runner, ivreg2, xtivreg, rdrobust,
    psmatch2, teffects, xtfrontier, mixed / melogit /
    mepoisson, bayes, gmm, boottest, plus the Stata→Python
    translator round-trip. Drivers write Stata results to JSON via the
    stata-mcp stata_do tool; Python drivers run StatsPAI through
    import statspai as sp; compare_stata.py joins on (module, estimator, statistic) and emits parity_table_stata.md /
    parity_table_stata.tex plus the 3-way StatsPAI ↔ R ↔ Stata table.

  • Canonical-dataset original-paper replays. New
    tests/orig_parity/ adds 4 module pairs that replay each paper's
    headline number bit-equal to the published value: Card (1995)
    returns-to-schooling on wooldridge::card; Callaway–Sant'Anna
    (2021) staggered DiD on did::mpdta (the package's vendored
    minimum-wage panel); Abadie–Diamond–Hainmueller Basque on
    Synth::basque; LaLonde (1986) NSW on MatchIt::lalonde plus a
    4b sub-module on causalsens::lalonde.psid (true Dehejia–Wahba
    NSW + PSID-1, 2675 obs) where sp.regress + sp.psm recover the
    published −15,205 to relative tolerance 1.5e-05. Drivers + bundled
    data + JSON results + parity_table_orig.md are all committed.

  • Track-C performance harness — log-log timing. New
    tests/perf/ adds matched R/Python timing runs for HDFE
    (fixest::feols vs sp.feols), CS-DiD (did::att_gt vs
    sp.callaway_santanna), SCM (Synth::synth vs sp.synth), and
    DML (DoubleML::DoubleMLPLR vs sp.dml) at log-spaced N. Drivers
    plus compare_perf.py produce perf_table.md / .tex and a
    track_c_loglog.{pdf,png} log-log scaling figure.

  • Coverage Monte Carlo at B=1000. New
    tests/coverage_monte_carlo/run_b1000.py measures 95% CI coverage
    for OLS (0.952), 2×2 DiD (0.955), and strong-Z IV (0.962) — all
    inside the 99% Wilson band [0.935, 0.967] around nominal 0.95. The
    full slow pytest tests/coverage_monte_carlo/ -m slow sweep at
    B=1000 also passes 8/8 (753.51 s). Frozen run lives at
    results_b1000/coverage_b1000.json; previous fast-track results
    remain at the default B=200.

  • CausalAgentBench scaffolding (mock-mode shipped, API run gated).
    New tests/agent_bench/ ships a 50-prompt × L1/L2/L3 difficulty
    × 6 cells × 3 reps = 900-trial agent bench with a deterministic
    mock-LLM runner (runners/mock_llm.py), a frozen OSF
    pre-registration protocol (prompts/_protocol.md), and a grader
    (runners/grader.py) emitting an H1–H5 directional results table.
    Mock dry-run completes in <1 s and produces
    results/headline.md + results/scores.csv + results/trials.jsonl;
    the production --api flag is one switch away once the OSF
    pre-registration and API budget clear.

  • sp.validation_report / sp.coverage_matrix /
    sp.reproduce_jss_tables — JSS-grade validation meta-API.
    New
    src/statspai/validation.py (863 LOC, 58-test battery in
    tests/test_jss_validation_api.py) exposes three top-level
    functions for the paper-submission audit trail:
    sp.validation_report() summarises the live sp.registry plus
    the materialised parity / coverage / agent-bench artifacts as a
    structured ValidationReport (registry.total_functions,
    evidence.r_parity_modules, evidence.stata_parity_modules,
    evidence.coverage_b1000, evidence.agent_bench_trials, …) with a
    one-paragraph .summary() and full JSON .to_dict();
    sp.coverage_matrix() enumerates every reference-implementation
    parity claim with its expected tolerance, observed gap, and the
    driver script that produced the JSON; sp.reproduce_jss_tables()
    returns a ReproductionResult enumerating the exact Rscript,
    python, pytest, and xelatex commands needed to regenerate
    every table — and, when called with dry_run=False, executes them
    in dependency order with timing + return codes recorded per
    step. The default mode is metadata-only (no R / Stata / LaTeX
    required), so import statspai as sp; print(sp.validation_report().summary())
    works on a stock pip install statspai==1.13.1 install.

  • 8 high-impact estimators upgraded from auto-registered to
    hand-written FunctionSpec.
    aipw, aggte, pretrends_test,
    sensitivity_rr, mccrary_test, oster_bounds,
    wild_cluster_bootstrap, and rd_honest now ship with full
    agent-native metadata: 2–4 assumptions per spec, 2 failure modes
    with recovery hints, ranked alternatives, typical_n_min, vetted
    references with paper.bib bib keys, and full enum-validated
    ParamSpecs. Previously these were auto-registered with only the
    first docstring line and inferred parameter types — agents calling
    sp.describe_function('aipw') could not see the doubly-robust
    guarantee, the propensity overlap requirement, or the alternatives
    to fall back on. Hand-written count moves from 203 to 211; auto-
    registered drops from 768 to 760. (Step H of v1.13 stability
    roadmap.)

  • sp.principal_strat(instrument=...) — encouragement-design AIR /
    Wald LATE.
    The previously-stubbed instrument= parameter now
    routes to a proper estimator (Angrist-Imbens-Rubin 1996 §4): given
    binary instrument Z, treatment D, post-treatment stratum S,
    and outcome Y, under random Z + monotonicity D(1)>=D(0) +
    exclusion + SUTVA, the function reports two Wald LATEs among
    Z-compliers — τ_Y for the effect of D on the outcome and τ_S
    for the effect of D on the post-treatment stratum variable —
    plus the complier share π_C(Z), all with bootstrap SE/CI. A
    RuntimeWarning is emitted when the first stage degenerates or points
    in the wrong direction for the supplied instrument coding.
    method= is ignored on this path because identification comes from
    Z, not from the post-treatment stratum decomposition. The
    limitations entry is rewritten: the only remaining gap on this
    path is always-survivor SACE under encouragement design (Mealli &
    Pacini 2013, partial identification). Seven new tests in
    tests/test_principal_strat.py.

  • sp.hal_tmle(variant='projection') RFC + sharper error. Rather
    than ship an unverified port of the Li-Qiu-Wang-vdL (2025) §3.2
    Riesz-projection step (the v1.11.x code path was a no-op on the
    point estimate — see CHANGELOG), v1.13 keeps the
    NotImplementedError and adds docs/rfc/hal_tmle_projection.md
    with the full implementation roadmap and the parity-test gates that
    must clear before the variant can be promoted to stable. The
    runtime exception message now points at the RFC and asks reporters
    to file an issue with the publication's headline number they'd like
    to match — so the next maintainer to pick this up has a clear
    target. Registry limitations entry updated with the RFC link.

  • Smart layer respects FunctionSpec.stability. sp.recommend(...),
    sp.causal(...), and sp.paper(...) now accept an
    allow_experimental: bool = False flag (default agent-safe). When
    False, recommendations whose backing function is registered as
    stability='experimental' (or 'deprecated') are dropped from
    the ranked output and the workflow's warnings /
    pipeline_notes records what was filtered. Pass True to
    include frontier MVPs (e.g. did_multiplegt_dyn,
    text_treatment_effect). This closes a gap where an LLM agent
    asking sp.causal(df, ...) for a publication-grade analysis could
    silently land on a frontier MVP just because the recommender
    ranked it first. Tests in tests/test_smart_stability_gating.py.

  • Stability reverse-audit script. scripts/stability_audit.py
    cross-checks every stability='stable' claim in the registry
    against parity-test coverage in tests/reference_parity/ and
    tests/external_parity/. Splits the catalogue into hand-written
    vs. auto-registered specs (the latter having been silently
    classified stable by default) and reports the count of unbacked
    claims in each bucket. --check mode is CI-friendly and fails when
    the unbacked-handwritten count exceeds a loose floor (currently
    220) — bumping the floor requires editing the script as a
    deliberate quality signal. Does NOT auto-downgrade; the call to
    flip a function from stable to experimental belongs to a
    maintainer who has read the code. Tests in
    tests/test_stability_audit.py. The audit fixed a registry bug
    along the way: auto-registered specs were never tagged _auto=True
    on the FunctionSpec instance, so describe_function error hints
    and the audit itself couldn't distinguish them from hand-written
    entries; that's now fixed via object.__setattr__ inside
    _auto_spec_from_callable.

  • Runtime consistency tests for FunctionSpec.limitations. Each
    limitations entry on a FunctionSpec is now structurally audited
    by tests/test_limitations_consistency.py so the registry's
    parity-grade-with-known-gaps claims cannot drift away from runtime
    behaviour: every entry must (a) use vetted vocabulary and (b) be
    classified as either runtime-testable (a curated map calls the
    function with the unimplemented value and asserts the documented
    exception) or descriptively-soft (silent fallback / caveat,
    whitelisted in LIMITATIONS_DESCRIPTIVE_ONLY). Adding a new
    limitation without classifying it now fails CI. Caught one drift
    bug in this pass: the cgroup='nevertreated' + panel=False
    limitation was attached to wooldridge_did, but only the etwfe
    alias exposes those parameters — moved to etwfe and surfaced the
    missing cgroup ParamSpec to the schema.

  • Test-coverage battery for the four worst-covered files +
    parity-grade smoke battery across did/synth/rd/iv/tmle/bayes.

    The v1.12.x audit flagged six causal-family modules at low
    statement coverage (did 14.7%, synth 12.9%, rd 16.9%,
    iv 18.0%, tmle 14.8%, bayes 14.1%) with four files entirely
    unexercised: wooldridge_did.py, did_imputation.py,
    synth/report.py, workflow/paper.py. Five new test files raise
    per-file coverage to synth/report.py 4% → 81%, wooldridge_did.py
    76% → 93%, did_imputation.py 85% → 99%, workflow/paper.py 66% →
    86%
    and add a 30-test cross-family smoke battery
    (tests/test_low_cov_battery.py) that exercises every headline
    estimator's CI/SE/point-estimate contract:

    • tests/test_synth_report.py (25 tests) — full text/markdown/LaTeX
      SCM report renderer + every sensitivity sub-block + the LaTeX
      escape table.
    • tests/test_wooldridge_did_branches.py (31 tests) — Bacon + dCDH
      decomposition, repeated-CS / never-only / xvar dispatch branches,
      every etwfe validation guard, all four etwfe_emfx
      aggregations including include_leads=True.
    • tests/test_did_imputation_branches.py (14 tests) — every
      ValueError guard, the controls + horizon event-study path with
      pre-trend chi-squared test, and the _cluster_se_horizon
      N_k == 0 short-circuit.
    • tests/test_paper_branches.py (31 tests) — every YAML/TeX/MD
      helper, all four to_qmd rendering branches (single vs.
      multi-format, author / bibliography / csl), to_docx fallback
      when python-docx is missing, write() extension dispatch, and
      the _render_dag_section text + mermaid branches.
  • CausalResult.summary() accepts both event-study column
    conventions.
    The shared summary() previously hard-coded
    (relative_time, att) and crashed with KeyError: 'relative_time'
    on wooldridge_did / etwfe results, which carry the
    (rel_time, estimate) schema instead. The renderer now auto-detects
    whichever pair is present and silently skips the event-study block
    when neither is — every existing caller keeps its formatting and
    the wooldridge family no longer crashes a user's .summary() call.
    Regression-pinned by test_wooldridge_did_summary_renders_event_study.

  • Stability tiers and per-function limitations (parity-grade vs.
    frontier-grade visibility).
    Every FunctionSpec now carries a
    stability field ("stable" / "experimental" / "deprecated",
    exposed as sp.STABILITY_TIERS) and a limitations list that
    enumerates partial-implementation gaps inside otherwise stable
    functions (e.g. hal_tmle(variant='projection'),
    principal_strat(instrument=...), rdrobust(weights=...)). The
    fields flow through sp.describe_function, sp.agent_card,
    sp.function_schema (description prefix + Known limitations:
    suffix so LLM tool-callers see the gap before calling),
    sp.list_functions(stability=...), sp.agent_cards(stability=...),
    the STABILITY block in sp.help(), the per-function detail in
    sp.help('<name>'), and a new statspai list --stability ... CLI
    flag. This closes a layering gap where users (and agents) could not
    tell which functions are numerically aligned and signature-locked
    vs. which are MVP / RFC-tracked frontier work, and where specific
    unimplemented variants were only discoverable by triggering
    NotImplementedError mid-pipeline. Initial tagging covers the three
    causal_text / did_multiplegt_dyn experimental entries plus
    variant-level limitations on hal_tmle, principal_strat,
    rdrobust, callaway_santanna, wooldridge_did,
    network_exposure, and continuous_did. See the new
    docs/guides/stability.md for the contract and promotion path.

Changed

  • Cold-start: lazy-load statspai.forest (Step 1B). import statspai previously chained
    from .forest.causal_forest import CausalForest, causal_forest plus
    three sibling eager imports for forest_inference /
    multi_arm_forest / iv_forest at module load, transitively pulling
    ~245 sklearn.* submodules into sys.modules (~270 ms cumulative on
    cold cache) for every session — even ones that never touch
    heterogeneous-effect forests. The four eager lines are removed; the
    ten public leaves (CausalForest, causal_forest,
    calibration_test, test_calibration, rate, honest_variance,
    multi_arm_forest, MultiArmForestResult, iv_forest,
    IVForestResult) now resolve via _LAZY_ATTRS keyed to dotted
    submodule paths (e.g. forest.causal_forest) and fault in on first
    sp.<name> access. forest does not collide with a top-level function
    (no sp.forest callable export) so the standard lazy path is safe;
    sp.causal's callable shim and the statspai.causal deprecation
    shim continue to work unchanged. Pinned by three new contracts in
    tests/test_late_bind_contracts.pyimport statspai must not
    pre-load any statspai.forest.* submodule (subprocess-isolated to
    avoid sys.modules pollution that would corrupt downstream
    isinstance checks); each of the 10 forest leaves must resolve to a
    callable on first access; and a downstream
    from statspai.forest.causal_forest import CausalForest must not
    re-shadow sp.causal_forest to the leaf module via Python's
    post-import attribute binding. Other sklearn-eager paths
    (did/overlap_did, metalearners/*, policy_learning/*,
    synth/cluster, plus ~7 conflict-prone same-name modules pinned eager
    for the late-bind contract) still pull sklearn on bare import; those
    are tracked separately for Step 1C and do not block this lazy-forest
    win.

  • Cold-start: drop sklearn class inheritance from HAL estimators
    (Step 1D).
    HALRegressor / HALClassifier in
    tmle/hal_tmle.py previously subclassed
    sklearn.base.BaseEstimator plus a Mixin, which pulled ~39
    sklearn.* submodules into sys.modules at module-load time —
    the only remaining sklearn footprint after Steps 1B/1C lazy-loaded
    forest and the 18 estimator files. The inheritance is gratuitous
    here: super_learner.fit only needs sklearn.base.clone(learner)
    (which is duck-typed — get_params(deep=False) + cls(**params)
    reconstruction) plus .fit / .predict / .predict_proba; no
    code path calls .score(...), is_classifier(...), or
    is_regressor(...) on the HAL classes. Replaced the inheritance
    with a minimal _BaseHAL providing the get_params /
    set_params / __repr__ slice that clone() actually
    consumes (introspection via inspect.signature(self.__init__),
    with object identity preserved so sklearn's post-clone
    param1 is param2 sanity check passes). _estimator_type = "regressor" / "classifier" class attributes keep
    sklearn.base.is_regressor / is_classifier returning True for
    any future external caller. After Step 1D, import statspai pulls
    zero sklearn submodules — full 245 → 0 — and the
    test_sklearn_budget_ceiling_on_bare_import_statspai contract is
    tightened from <= 50 to <= 0 to pin the floor. 152 tests
    across test_hal_tmle / test_tmle / test_late_bind_contracts
    / test_low_cov_battery / test_metalearners pass cleanly.

  • Cold-start: lazy-import sklearn across 18 estimator files (Step
    1C).
    Building on Step 1B, every remaining top-level
    from sklearn.X import Y in
    did/overlap_did.py, metalearners/{auto_cate,metalearners,auto_cate_tuned}.py,
    policy_learning/{policy_tree,ope}.py, synth/cluster.py,
    proximal/pci_regression.py, bcf/{bcf,longitudinal}.py,
    tmle/{tmle,super_learner,ltmle,ltmle_survival}.py,
    dose_response/gps.py, multi_treatment/multi_ipw.py,
    mediation/four_way.py, and interference/orthogonal.py was moved
    inside the function bodies that actually use it. BaseEstimator
    type annotations were converted to string-literal form under
    if TYPE_CHECKING: so inspect.signature / Pyright / mypy still
    resolve them without forcing sklearn.base at module load. Several
    long-standing dead imports were dropped (BaseEstimator /
    is_classifier / cross_val_predict in
    metalearners/metalearners.py; LinearRegression in
    proximal/pci_regression.py; BaseEstimator / clone /
    GradientBoostingClassifier in multi_treatment/multi_ipw.py;
    etc.). After Step 1B + 1C, import statspai pulls 39 sklearn
    submodules instead of 245 — a 5.3× reduction. The 39 are
    sklearn.base plus its mandatory deps, pulled by tmle/hal_tmle.py
    whose HALRegressor(BaseEstimator, RegressorMixin) /
    HALClassifier(BaseEstimator, ClassifierMixin) need sklearn at
    class-definition time; refactoring that inheritance hierarchy is
    out of scope. Pinned by a new
    test_sklearn_budget_ceiling_on_bare_import_statspai contract in
    tests/test_late_bind_contracts.py (≤ 50 ceiling, ~39 floor + 11
    slack for sklearn-version drift) running in a subprocess so the
    cold-state measurement does not perturb other tests' sys.modules.
    248 tests across the 18 affected modules (metalearners /
    metalearner_frontiers / auto_cate / auto_cate_tuned / overlap_did /
    tmle / hal_tmle / proximal / proximal_frontiers / bcf_longitudinal /
    bcf_ordinal / conformal_bcf_bunching_mc / policy_learning / mediation
    / mediation_sensitivity / interference_extensions / late_bind_contracts
    / causal_forest_grf / forest_inference / ope_cevae / ope_extensions /
    cluster_rct) pass cleanly.

  • README lead aligned with the agent-native + parity-validated
    positioning.
    README.md and README_CN.md both foreground "first
    agent-native Python platform" and surface R / Stata parity
    validation in the lead paragraph (was previously buried in the Task
    View comparison section further down).

  • sp.recommend() now defaults to an agent-safe stability gate:
    recommendations whose registry entry is marked
    stability='experimental' or stability='deprecated' are dropped
    unless the caller passes allow_experimental=True. The filter keeps
    backward compatibility for unknown custom recommendation entries,
    records dropped names in RecommendationResult.warnings, and is
    forwarded through sp.causal(..., allow_experimental=...) and
    sp.paper(..., allow_experimental=...) so higher-level workflows
    cannot silently land on frontier MVP estimators.

  • Hardened the workflow/paper orchestration layer so optional failures
    no longer disappear silently. sp.causal(...).run(full=True) now
    records optional-stage failures (compare_estimators,
    sensitivity_panel, cate) in workflow.pipeline_notes, and
    sp.causal(...).report(fmt='markdown') renders those notes in a
    dedicated section instead of silently dropping the context.

  • sp.paper(...) now constructs its internal CausalWorkflow with
    auto_run=False and advances stages exactly once. This removes the
    prior double-execution path where the workflow could fully auto-run
    before paper() manually re-ran diagnose/recommend/estimate
    (and sometimes robustness) again.

  • PaperDraft now surfaces orchestration degradations directly in a
    Pipeline notes section and includes degradations in to_dict(),
    so missing DAG/citation/provenance/section-rendering steps are
    visible in the artifact itself rather than only via warnings.

Fixed

  • ⚠️ Correctness — sp.callaway_santanna(method='reg') inference.
    The Callaway–Sant'Anna outcome-regression (REG) path produced
    influence-function standard errors that were inconsistent with the
    IPW / DR variants because the control-regression uncertainty was
    not propagated and the per-cohort scaling in the influence-function
    aggregation was off by the cohort-size weighting. Coverage
    simulations under the mpdta DGP were running ~88% (nominal 95%)
    on method='reg', while 'ipw' and 'dr' were inside the 99%
    Wilson band. The fix tightens the REG influence-function scaling
    and explicitly adds the control-regression contribution; the
    parity table at tests/r_parity/results/parity_table_3way.md and
    the coverage frame at tests/coverage_monte_carlo/FINDINGS.md are
    refreshed accordingly. Regression-pinned by
    tests/reference_parity/test_did_parity.py and the new
    tests/r_parity/04_csdid.py driver. Re-run any v1.10–v1.13
    Callaway–Sant'Anna analyses that used method='reg'
    ;
    'ipw' and 'dr' are unchanged.

  • isinstance(res, sp.OPEResult) no longer false-negative on results
    from sp.ope.*.
    During the lazy-load refactor of optional families
    the eager re-export path that used to bind sp.OPEResult to
    statspai.ope.estimators.OPEResult was dropped, so sp.OPEResult
    silently resolved to a parallel class defined in
    statspai.policy_learning.ope — and isinstance(sp.ope.ips(...), sp.OPEResult) flipped from True (v1.12.2) to False. The eager
    from .policy_learning import ... OPEResult is removed so
    sp.OPEResult falls through to the lazy _register_lazy("ope", "OPEResult", ...) table, restoring v1.12.2 class identity.
    Regression-pinned by tests/test_ope_cevae.py::test_ips_close_to_true_value.

  • Hand-written registry specs for aggte and principal_strat now
    exactly match their callable signatures (na_rm, alpha, seed),
    with a regression test guarding the new v1.13 hand-written upgrades
    against future signature drift.

  • The natural-language sp.paper(data, question, ..., include_robustness=False)
    path no longer runs or renders the robustness section implicitly via
    sp.causal auto-run side effects.

  • paper_from_question() now carries its collected degradation records
    into the returned PaperDraft, so late provenance/citation/DAG
    failures remain inspectable after draft construction.

  • Top-level statspai.__all__ is now de-duplicated in order-preserving
    fashion, reducing public-surface drift between the import namespace
    and registry/help tooling.

  • The top-level function-first API now survives the sp.iv bootstrap
    path for same-name families like bartik and deepiv. The root
    package eagerly rebinds the 14 function/subpackage collisions
    (proximal, principal_strat, bartik, bridge, causal_impact,
    bcf, bunching, deepiv, dose_response, frontier,
    interference, msm, multi_treatment, tmle) while
    statspai.iv lazy-loads its optional bartik / deepiv
    re-exports, so sp.bartik(...) / sp.deepiv(...) stay callable
    instead of degenerating into bare module objects after import order
    changes.

  • smart.assumptions, smart.brief, smart.identification,
    smart.sensitivity, and smart.verify now lazy-import
    workflow._degradation only inside failure paths. That removes a
    premature workflow/__init__ import during import statspai,
    which had reintroduced partially initialized top-level symbols and
    made the lazy API order-sensitive.

  • Added a committed src/statspai/__init__.pyi generator and pinned it
    with a regression test so IDE/type-checker visibility tracks the live
    runtime namespace. The stub generator now skips exported constants
    during leaf scanning and correctly types STABILITY_TIERS as
    frozenset[str], avoiding duplicate/conflicting declarations.

  • Pinned the two binding hazards introduced by the lazy-load refactor
    with 21 explicit contracts in tests/test_late_bind_contracts.py:
    the five late-bind aliases re-bound by _article_aliases
    (mediation, policy_tree, dml, matrix_completion,
    causal_discovery) plus the sp.iv callable dispatcher must each
    remain callable rather than degenerating to a module on import
    re-order; and the 14 function/subpackage collisions
    (proximal, principal_strat, bridge, bcf, bunching,
    dose_response, multi_treatment, causal_impact, frontier,
    interference, tmle, msm, deepiv, bartik) must survive a
    downstream from statspai.X import Y without the auto-bound submodule
    silently re-shadowing the function. Closes the residual gap left by
    Codex's lazy-load refactor and Claude Code's same-name eager-rebind
    follow-up.