antecedent.estimation

Estimation stubs.

   1"""High-level estimation entry points."""
   2
   3from __future__ import annotations
   4
   5from dataclasses import dataclass
   6from typing import Any, Literal, Mapping, Sequence
   7
   8from ._data import as_columns, as_multi_env_columns, try_as_arrow_c_columns
   9from ._native import (
  10    AnalysisResult as TemporalAnalysisResult,
  11    AteAnalysisResult,
  12    CausalUnsupportedError,
  13    PreparedAnalysis as _NativePreparedAnalysis,
  14    analyze as _analyze_temporal,
  15    analyze_ate as _analyze_ate,
  16    analyze_ate_admg as _analyze_ate_admg,
  17    analyze_ate_arrow_c as _analyze_ate_arrow_c,
  18    analyze_ate_cpdag as _analyze_ate_cpdag,
  19    analyze_ate_discover as _analyze_ate_discover,
  20    analyze_ate_many as _analyze_ate_many,
  21    analyze_ate_pag as _analyze_ate_pag,
  22    analyze_conditional as _analyze_conditional,
  23    analyze_distribution as _analyze_distribution,
  24    analyze_events as _analyze_events,
  25    analyze_mediation as _analyze_mediation,
  26    analyze_panel as _analyze_panel,
  27    analyze_panel_discover as _analyze_panel_discover,
  28    analyze_path_specific as _analyze_path_specific,
  29    analyze_temporal_discover as _analyze_temporal_discover,
  30    analyze_temporal_mediation as _analyze_temporal_mediation,
  31    analyze_temporal_pag as _analyze_temporal_pag,
  32    identify_ate as _identify_ate,
  33)
  34from .data import EventFrame, MultiEnvFrame, PanelFrame
  35from .discovery import (
  36    FCI,
  37    GES,
  38    JPCMCIPlus,
  39    LPCMCI,
  40    LiNGAM,
  41    NOTEARS,
  42    PC,
  43    PCMCI,
  44    PCMCIPlus,
  45    RFCI,
  46    RPCMCI,
  47    CiScreenedPosterior,
  48    DbnPosterior,
  49    ExactDagPosterior,
  50    OrderMcmc,
  51    StructureMcmc,
  52    cpdag_oriented_edges,
  53    discovery_to_dag,
  54    graph_posterior_map_edges,
  55)
  56from .graph import Admg, Cpdag, Dag, Pag, TemporalCpdag, TemporalDag, TemporalPag
  57from .inference import Bayesian, Frequentist
  58from .query import (
  59    AverageEffect,
  60    ConditionalEffect,
  61    Counterfactual,
  62    InterventionalDistribution,
  63    MediationEffect,
  64    PathSpecificEffect,
  65    PulseEffect,
  66    SustainedEffect,
  67    TemporalMediationEffect,
  68)
  69from .ids import Estimator, Identifier, Latency, Refute
  70
  71# Preferred name for the native temporal DTO.
  72NativeAnalysisResult = TemporalAnalysisResult
  73
  74
  75@dataclass(frozen=True)
  76class IdentificationView:
  77    status: str
  78    method: str
  79    adjustment_set: list[str]
  80    assumption_count: int
  81    derivation_step_count: int
  82
  83
  84@dataclass(frozen=True)
  85class MediationView:
  86    total: float | None
  87    direct: float | None
  88    mediated: float | None
  89
  90
  91@dataclass(frozen=True)
  92class EstimateView:
  93    ate: float
  94    se_analytic: float
  95    se_bootstrap: float | None
  96    estimator_id: str
  97    method: str
  98    overlap_ess: float | None = None
  99    overlap_propensity_min: float | None = None
 100    mediation: MediationView | None = None
 101
 102
 103@dataclass(frozen=True)
 104class ConflictSummaryView:
 105    """Applied external-prior alphas after conflict shrink."""
 106
 107    source_ids: list[str]
 108    alphas_requested: list[float]
 109    alphas_applied: list[float]
 110
 111
 112@dataclass(frozen=True)
 113class PosteriorView:
 114    effect_mean: float | None
 115    effect_sd: float | None
 116    q025: float | None
 117    q975: float | None
 118    n_draws: int | None
 119    p_below_zero: float | None
 120    backend: str | None
 121    artifact: bytes | list[int] | None = None
 122    unidentified_mass: float | None = None
 123    envelope: EffectEnvelope | None = None
 124    conflict: ConflictSummaryView | None = None
 125
 126
 127@dataclass(frozen=True)
 128class EffectEnvelope:
 129    """Mixture effect posterior over weighted graphs (PAG / graph-posterior path)."""
 130
 131    effect_mean: float | None
 132    effect_sd: float | None
 133    q025: float | None
 134    q975: float | None
 135    unidentified_mass: float
 136    n_draws: int | None
 137    backend: str | None = None
 138
 139
 140@dataclass(frozen=True)
 141class PredictiveCheckReport:
 142    """Prior or posterior predictive check summary."""
 143
 144    kind: str
 145    observed: float
 146    predictive_mean: float
 147    predictive_sd: float
 148    p_value: float
 149    n_sims: int
 150
 151
 152@dataclass(frozen=True)
 153class PriorSensitivityReport:
 154    """Prior sensitivity grid (Bayesian + ``refute="full"``).
 155
 156    Isotropic mode fills ``scales``; external prior-bank mode fills ``alphas``
 157    (multipliers on post-conflict applied α). Exactly one mode is active.
 158    """
 159
 160    scales: list[float]
 161    effect_means: list[float]
 162    effect_sds: list[float]
 163    alphas: list[float] | None = None
 164
 165
 166@dataclass(frozen=True)
 167class ValidationView:
 168    passed: bool
 169    ran: bool
 170    count: int
 171    prior_predictive: PredictiveCheckReport | None = None
 172    posterior_predictive: PredictiveCheckReport | None = None
 173    prior_sensitivity: PriorSensitivityReport | None = None
 174
 175
 176@dataclass(frozen=True)
 177class PerformanceView:
 178    plan_id: str | None = None
 179    modality: str | None = None
 180    peak_memory_bytes: int | None = None
 181    latency_mode: str | None = None
 182    wall_time_ns: int | None = None
 183    bootstrap_replicates_requested: int | None = None
 184    bootstrap_replicates_ok: int | None = None
 185    n_draws: int | None = None
 186    cancelled: bool = False
 187    early_stopped: bool = False
 188    stage_timings: dict[str, int] | None = None
 189
 190
 191@dataclass(frozen=True)
 192class PlanView:
 193    """Logical-plan summary (semantics; inspect before/after estimate)."""
 194
 195    plan_id: str
 196    modality: str | None = None
 197    discovery_algorithm: str | None = None
 198    graph_review_required: bool = False
 199    identifier: str | None = None
 200    estimator: str | None = None
 201    validation_suite: str | None = None
 202
 203
 204@dataclass(frozen=True)
 205class PhysicalPlanView:
 206    """Physical-plan highlights from prepare (layouts / threads / kernels)."""
 207
 208    plan_id: str
 209    estimated_peak_memory_bytes: int | None = None
 210    workspace_bytes: int | None = None
 211    batch_size: int | None = None
 212    worker_threads: int = 0
 213    expected_python_crossings: int = 0
 214    deterministic_reductions: bool = True
 215    kernels: str | None = None
 216
 217
 218@dataclass(frozen=True)
 219class AnalysisResult:
 220    """Nested analysis result matching the Rust facade sections."""
 221
 222    identification: IdentificationView
 223    estimate: EstimateView
 224    posterior: PosteriorView | None
 225    validation: ValidationView
 226    performance: PerformanceView
 227    diagnostics: list[str]
 228    provenance: dict[str, Any]
 229    mediation: MediationView | None = None
 230    plan: PlanView | None = None
 231    _raw: Any = None
 232    _prepared: Any = None
 233
 234    @property
 235    def effect(self) -> float:
 236        """Primary scalar effect (mediation total when present, else estimate ATE/mean)."""
 237        if self.mediation is not None and self.mediation.total is not None:
 238            return float(self.mediation.total)
 239        if self.estimate.mediation is not None and self.estimate.mediation.total is not None:
 240            return float(self.estimate.mediation.total)
 241        return self.estimate.ate
 242
 243    @property
 244    def ate(self) -> float:
 245        """Alias for :attr:`effect` (prefer ``effect`` for non-ATE queries)."""
 246        return self.effect
 247
 248    def refresh(
 249        self,
 250        data: Mapping[str, Any] | Any,
 251        *,
 252        seed: int = 1,
 253        threads: int = 1,
 254    ) -> AnalysisResult:
 255        """Re-estimate on new data via the retained prepared handle.
 256
 257        Only results from :meth:`PreparedAnalysis.estimate` / ``refresh`` support
 258        this. One-shot :func:`analyze` results raise ``TypeError``.
 259        """
 260        if self._prepared is None:
 261            raise TypeError(
 262                "AnalysisResult.refresh requires a result from PreparedAnalysis; "
 263                "use PreparedAnalysis.prepare(...) then estimate/refresh"
 264            )
 265        return self._prepared.estimate(data, seed=seed, threads=threads)
 266
 267    def refute(
 268        self,
 269        data: Mapping[str, Any] | Any,
 270        suite: Refute | Literal["placebo", "full", "cheap"] | bool | str = "placebo",
 271        *,
 272        seed: int = 1,
 273        threads: int = 1,
 274        cancel: Any | None = None,
 275    ) -> AnalysisResult:
 276        """Second-click refute via the retained prepared handle."""
 277        if self._prepared is None:
 278            raise TypeError(
 279                "AnalysisResult.refute requires a result from PreparedAnalysis; "
 280                "use PreparedAnalysis.prepare(...) then estimate"
 281            )
 282        if isinstance(suite, Refute):
 283            suite = str(suite)
 284        return self._prepared.refute(
 285            data, suite, seed=seed, threads=threads, cancel=cancel
 286        )
 287
 288
 289def _plan_from_raw(raw: Any) -> PlanView:
 290    return PlanView(
 291        plan_id=str(getattr(raw, "plan_id", "") or ""),
 292        modality=getattr(raw, "modality", None),
 293        discovery_algorithm=getattr(raw, "discovery_algorithm", None),
 294        graph_review_required=bool(getattr(raw, "graph_review_required", False)),
 295        identifier=getattr(raw, "plan_identifier", None),
 296        estimator=getattr(raw, "plan_estimator", None)
 297        or (getattr(raw, "estimator_id", None) or None),
 298        validation_suite=getattr(raw, "validation_suite", None),
 299    )
 300
 301
 302def _wrap_ate(raw: AteAnalysisResult, prepared: Any | None = None) -> AnalysisResult:
 303    def _conflict_from_raw(r: AteAnalysisResult) -> ConflictSummaryView | None:
 304        ids = getattr(r, "conflict_source_ids", None)
 305        if ids is None:
 306            return None
 307        return ConflictSummaryView(
 308            source_ids=list(ids),
 309            alphas_requested=list(r.conflict_alphas_requested),
 310            alphas_applied=list(r.conflict_alphas_applied),
 311        )
 312
 313    posterior = None
 314    if raw.posterior_n_draws is not None:
 315        mass = getattr(raw, "posterior_unidentified_mass", None)
 316        envelope = None
 317        if mass is not None and float(mass) > 0.0:
 318            envelope = EffectEnvelope(
 319                effect_mean=raw.posterior_effect_mean,
 320                effect_sd=raw.posterior_effect_sd,
 321                q025=raw.posterior_q025,
 322                q975=raw.posterior_q975,
 323                unidentified_mass=float(mass),
 324                n_draws=raw.posterior_n_draws,
 325                backend=raw.posterior_backend,
 326            )
 327        posterior = PosteriorView(
 328            effect_mean=raw.posterior_effect_mean,
 329            effect_sd=raw.posterior_effect_sd,
 330            q025=raw.posterior_q025,
 331            q975=raw.posterior_q975,
 332            n_draws=raw.posterior_n_draws,
 333            p_below_zero=raw.posterior_p_below_zero,
 334            backend=raw.posterior_backend,
 335            artifact=raw.posterior_artifact,
 336            unidentified_mass=None if mass is None else float(mass),
 337            envelope=envelope,
 338            conflict=_conflict_from_raw(raw),
 339        )
 340    prior_predictive = None
 341    if getattr(raw, "prior_ppc_p_value", None) is not None:
 342        prior_predictive = PredictiveCheckReport(
 343            kind="prior_predictive",
 344            observed=float(raw.prior_ppc_observed),
 345            predictive_mean=float(raw.prior_ppc_predictive_mean),
 346            predictive_sd=float(raw.prior_ppc_predictive_sd),
 347            p_value=float(raw.prior_ppc_p_value),
 348            n_sims=int(raw.prior_ppc_n_sims),
 349        )
 350    posterior_predictive = None
 351    if getattr(raw, "posterior_ppc_p_value", None) is not None:
 352        posterior_predictive = PredictiveCheckReport(
 353            kind="posterior_predictive",
 354            observed=float(raw.posterior_ppc_observed),
 355            predictive_mean=float(raw.posterior_ppc_predictive_mean),
 356            predictive_sd=float(raw.posterior_ppc_predictive_sd),
 357            p_value=float(raw.posterior_ppc_p_value),
 358            n_sims=int(raw.posterior_ppc_n_sims),
 359        )
 360    prior_sensitivity = None
 361    means = getattr(raw, "prior_sensitivity_means", None)
 362    if means is not None:
 363        alphas_raw = getattr(raw, "prior_sensitivity_alphas", None)
 364        scales_raw = getattr(raw, "prior_sensitivity_scales", None)
 365        prior_sensitivity = PriorSensitivityReport(
 366            scales=list(scales_raw or ()),
 367            effect_means=list(means),
 368            effect_sds=list(raw.prior_sensitivity_sds),
 369            alphas=None if alphas_raw is None else list(alphas_raw),
 370        )
 371    return AnalysisResult(
 372        identification=IdentificationView(
 373            status=raw.identification_status,
 374            method=raw.method,
 375            adjustment_set=list(raw.adjustment_set),
 376            assumption_count=raw.assumption_count,
 377            derivation_step_count=raw.derivation_step_count,
 378        ),
 379        estimate=EstimateView(
 380            ate=raw.ate,
 381            se_analytic=raw.se_analytic,
 382            se_bootstrap=raw.se_bootstrap,
 383            estimator_id=raw.estimator_id,
 384            method=raw.method,
 385            overlap_ess=raw.overlap_ess,
 386            overlap_propensity_min=raw.overlap_propensity_min,
 387        ),
 388        posterior=posterior,
 389        validation=ValidationView(
 390            passed=raw.refutation_passed,
 391            ran=raw.refutation_ran,
 392            count=raw.refutation_count,
 393            prior_predictive=prior_predictive,
 394            posterior_predictive=posterior_predictive,
 395            prior_sensitivity=prior_sensitivity,
 396        ),
 397        performance=PerformanceView(
 398            plan_id=raw.plan_id,
 399            modality=raw.modality,
 400            peak_memory_bytes=raw.peak_memory_bytes,
 401            latency_mode=getattr(raw, "latency_mode", None),
 402            wall_time_ns=getattr(raw, "wall_time_ns", None),
 403            bootstrap_replicates_requested=getattr(
 404                raw, "bootstrap_replicates_requested", None
 405            ),
 406            bootstrap_replicates_ok=getattr(raw, "bootstrap_replicates_ok", None),
 407            n_draws=getattr(raw, "n_draws_effort", None),
 408            cancelled=bool(getattr(raw, "cancelled", False)),
 409            early_stopped=bool(getattr(raw, "early_stopped", False)),
 410            stage_timings={
 411                str(k): int(v) for k, v in (getattr(raw, "stage_timings", None) or [])
 412            }
 413            or None,
 414        ),
 415        diagnostics=list(raw.diagnostics),
 416        provenance={"node_count": raw.provenance_node_count},
 417        plan=_plan_from_raw(raw),
 418        _raw=raw,
 419        _prepared=prepared,
 420    )
 421
 422
 423def _resolve_latency_budget(
 424    latency: Latency | Literal["interactive", "standard", "report"] | str | None,
 425    bootstrap: int | None,
 426    refute: bool | Refute | Literal["full", "placebo", "none", "cheap"] | str,
 427) -> tuple[int, bool | Literal["full", "placebo", "none", "cheap"]]:
 428    """Map latency tier to bootstrap/refute; explicit bootstrap wins when set."""
 429    if isinstance(latency, Latency):
 430        latency = str(latency)
 431    if isinstance(refute, Refute):
 432        refute = str(refute)
 433    if latency is None:
 434        return (50 if bootstrap is None else bootstrap, refute)  # type: ignore[return-value]
 435    key = str(latency).strip().lower()
 436    if key == "interactive":
 437        mapped_boot, mapped_refute = 0, "cheap"
 438    elif key == "standard":
 439        mapped_boot, mapped_refute = 50, True
 440    elif key == "report":
 441        mapped_boot, mapped_refute = 200, "full"
 442    else:
 443        raise ValueError(
 444            f"unknown latency={latency!r}; use interactive|standard|report"
 445        )
 446    # refute default True means "use mode mapping" when latency is set unless
 447    # the caller chose a non-default refute value.
 448    out_refute: bool | Literal["full", "placebo", "none", "cheap"]
 449    if refute is True:
 450        out_refute = mapped_refute  # type: ignore[assignment]
 451    else:
 452        out_refute = refute  # type: ignore[assignment]
 453    out_boot = mapped_boot if bootstrap is None else bootstrap
 454    return out_boot, out_refute
 455
 456
 457_STATIC_DISCOVERY = (PC, GES, LiNGAM, NOTEARS, FCI, RFCI)
 458_GRAPH_POSTERIOR_DISCOVERY = (
 459    ExactDagPosterior,
 460    OrderMcmc,
 461    StructureMcmc,
 462    CiScreenedPosterior,
 463)
 464_TEMPORAL_DISCOVERY = (PCMCI, PCMCIPlus, LPCMCI, JPCMCIPlus, RPCMCI)
 465
 466
 467def _discovery_algorithm(discovery: Any) -> dict[str, Any]:
 468    if isinstance(discovery, PCMCI):
 469        return {
 470            "algorithm": "pcmci",
 471            "max_lag": discovery.max_lag,
 472            "alpha": discovery.alpha,
 473            "fdr": discovery.fdr,
 474            "ci": discovery.ci,
 475        }
 476    if isinstance(discovery, PCMCIPlus):
 477        return {
 478            "algorithm": "pcmci_plus",
 479            "max_lag": discovery.max_lag,
 480            "alpha": discovery.alpha,
 481            "fdr": discovery.fdr,
 482            "ci": discovery.ci,
 483        }
 484    if isinstance(discovery, LPCMCI):
 485        return {
 486            "algorithm": "lpcmci",
 487            "max_lag": discovery.max_lag,
 488            "alpha": discovery.alpha,
 489            "fdr": discovery.fdr,
 490            "ci": discovery.ci,
 491        }
 492    if isinstance(discovery, JPCMCIPlus):
 493        return {
 494            "algorithm": "jpcmci_plus",
 495            "max_lag": discovery.max_lag,
 496            "alpha": discovery.alpha,
 497            "fdr": discovery.fdr,
 498            "ci": discovery.ci,
 499            "context_names": list(discovery.context_names),
 500            "include_space_dummy": discovery.include_space_dummy,
 501            "include_time_dummy": discovery.include_time_dummy,
 502            "space_dummy_ci": discovery.space_dummy_ci,
 503            "time_dummy_encoding": discovery.time_dummy_encoding,
 504            "time_dummy_ci": discovery.time_dummy_ci,
 505        }
 506    if isinstance(discovery, RPCMCI):
 507        return {
 508            "algorithm": "rpcmci",
 509            "max_lag": discovery.max_lag,
 510            "alpha": discovery.alpha,
 511            "fdr": discovery.fdr,
 512            "ci": discovery.ci,
 513        }
 514    if isinstance(discovery, PC):
 515        return {
 516            "algorithm": "pc",
 517            "alpha": discovery.alpha,
 518            "fdr": discovery.fdr,
 519            "ci": discovery.ci,
 520            "max_cond_size": discovery.max_cond_size,
 521        }
 522    if isinstance(discovery, GES):
 523        return {
 524            "algorithm": "ges",
 525            "alpha": discovery.alpha,
 526            "fdr": discovery.fdr,
 527            "ci": discovery.ci,
 528            "max_cond_size": discovery.max_cond_size,
 529        }
 530    if isinstance(discovery, LiNGAM):
 531        return {
 532            "algorithm": "lingam",
 533            "prune_threshold": discovery.prune_threshold,
 534            "max_cond_size": discovery.max_cond_size,
 535            "alpha": 0.05,
 536            "fdr": True,
 537            "ci": "parcorr",
 538        }
 539    if isinstance(discovery, NOTEARS):
 540        return {
 541            "algorithm": "notears",
 542            "lambda": discovery.l1,
 543            "threshold": discovery.threshold,
 544            "standardize": discovery.standardize,
 545            "max_cond_size": discovery.max_cond_size,
 546            "alpha": 0.05,
 547            "fdr": True,
 548            "ci": "parcorr",
 549        }
 550    if isinstance(discovery, FCI):
 551        return {
 552            "algorithm": "fci",
 553            "alpha": discovery.alpha,
 554            "fdr": discovery.fdr,
 555            "ci": discovery.ci,
 556            "max_cond_size": discovery.max_cond_size,
 557        }
 558    if isinstance(discovery, RFCI):
 559        return {
 560            "algorithm": "rfci",
 561            "alpha": discovery.alpha,
 562            "fdr": discovery.fdr,
 563            "ci": discovery.ci,
 564            "max_cond_size": discovery.max_cond_size,
 565        }
 566    if isinstance(discovery, ExactDagPosterior):
 567        return {"algorithm": "exact_dag_posterior"}
 568    if isinstance(discovery, OrderMcmc):
 569        return {
 570            "algorithm": "order_mcmc",
 571            "n_chains": discovery.n_chains,
 572            "n_warmup": discovery.n_warmup,
 573            "mcmc_draws": discovery.n_draws,
 574            "thin": discovery.thin,
 575            "require_diagnostics_gate": discovery.require_diagnostics_gate,
 576        }
 577    if isinstance(discovery, StructureMcmc):
 578        return {
 579            "algorithm": "structure_mcmc",
 580            "n_chains": discovery.n_chains,
 581            "n_warmup": discovery.n_warmup,
 582            "mcmc_draws": discovery.n_draws,
 583            "thin": discovery.thin,
 584        }
 585    if isinstance(discovery, CiScreenedPosterior):
 586        return {
 587            "algorithm": "ci_screened_posterior",
 588            "alpha": discovery.alpha,
 589            "fdr": discovery.fdr,
 590            "ci": discovery.ci,
 591            "max_cond_size": discovery.max_cond_size,
 592            "soft_weight": discovery.soft_weight,
 593            "n_chains": discovery.n_chains,
 594            "n_warmup": discovery.n_warmup,
 595            "mcmc_draws": discovery.n_draws,
 596            "thin": discovery.thin,
 597        }
 598    if isinstance(discovery, DbnPosterior):
 599        return {
 600            "algorithm": "dbn_posterior",
 601            "max_lag": discovery.max_lag,
 602            "force_mcmc": discovery.force_mcmc,
 603            "n_chains": discovery.n_chains,
 604            "n_warmup": discovery.n_warmup,
 605            "mcmc_draws": discovery.n_draws,
 606        }
 607    raise TypeError(f"unsupported discovery config: {type(discovery)!r}")
 608
 609
 610def _static_edges(
 611    graph: Dag | Cpdag | Sequence[tuple[str, str]] | None,
 612) -> list[tuple[str, str]]:
 613    if graph is None:
 614        raise ValueError("graph= is required")
 615    if isinstance(graph, Dag):
 616        return [(str(a), str(b)) for a, b in graph.edges()]
 617    if isinstance(graph, Cpdag):
 618        # PathSpecific / Interventional need a fully oriented DAG; incomplete
 619        # CPDAGs fail closed with a clear undirected-count message.
 620        return cpdag_oriented_edges(graph, require_oriented=True)
 621    return [(str(a), str(b)) for a, b in graph]  # type: ignore[misc]
 622
 623
 624def _lagged_edges(
 625    graph: TemporalDag | Sequence[tuple[str, int, str, int]] | None,
 626) -> list[tuple[str, int, str, int]]:
 627    if graph is None:
 628        raise ValueError("graph= lagged edges are required")
 629    if isinstance(graph, TemporalDag):
 630        return [
 631            (str(a), int(la), str(b), int(lb)) for a, la, b, lb in graph.edges()
 632        ]
 633    return [(str(a), int(la), str(b), int(lb)) for a, la, b, lb in graph]  # type: ignore[misc]
 634
 635
 636def _refute_requested(refute: bool | str) -> bool:
 637    """True when the caller asked for any non-empty refute suite."""
 638    if isinstance(refute, bool):
 639        return refute
 640    key = str(refute).strip().lower()
 641    return key not in ("", "none", "off", "false", "0")
 642
 643
 644def _reject_unsupported_temporal(
 645    *,
 646    inference: Frequentist | Bayesian | None,
 647    refute: bool | str,
 648    validators: Sequence[Any] | None,
 649) -> None:
 650    # Bayesian, refute, and validators are supported on series Pulse/Sustained.
 651    _ = (inference, refute, validators)
 652    return
 653
 654
 655def _bayesian_inference_kwargs(inference: Bayesian) -> dict[str, Any]:
 656    backend = str(inference.backend).strip().lower()
 657    if backend == "laplace":
 658        inference_s = "bayesian"
 659    elif backend == "conjugate":
 660        inference_s = "conjugate"
 661    elif backend == "hmc":
 662        inference_s = "hmc"
 663    else:
 664        raise ValueError(
 665            f"unknown Bayesian backend {inference.backend!r}; "
 666            "use laplace|conjugate|hmc"
 667        )
 668    kw: dict[str, Any] = {
 669        "inference": inference_s,
 670        "n_draws": inference.n_draws,
 671        "prior_scale": inference.prior_scale,
 672    }
 673    prior_from = inference.prior_from
 674    if prior_from is not None:
 675        # Local import avoids circular import with prior_bank ↔ estimation.
 676        from .prior_bank import ComposedPrior
 677
 678        if isinstance(prior_from, ComposedPrior):
 679            kw["composed_prior"] = prior_from.to_native_dict()
 680        else:
 681            kw["prior_artifact"] = bytes(prior_from)
 682    if inference.mapping is not None:
 683        kw["prior_mapping"] = inference.mapping.to_dict()
 684    return kw
 685
 686
 687def _temporal_inference_kwargs(
 688    inference: Frequentist | Bayesian | None,
 689) -> dict[str, Any]:
 690    if isinstance(inference, Bayesian):
 691        return _bayesian_inference_kwargs(inference)
 692    if isinstance(inference, Frequentist) or inference is None:
 693        return {}
 694    return {}
 695
 696
 697def _wrap_temporal(raw: TemporalAnalysisResult) -> AnalysisResult:
 698    # Mirror static ate_result_from_analysis: never claim pass when nothing ran.
 699    ran = raw.refutation_count > 0
 700    mediation = None
 701    if getattr(raw, "mediation_total", None) is not None or getattr(raw, "mediation_mediated", None) is not None:
 702        mediation = MediationView(
 703            total=getattr(raw, "mediation_total", None),
 704            direct=getattr(raw, "mediation_direct", None),
 705            mediated=getattr(raw, "mediation_mediated", None),
 706        )
 707    posterior = None
 708    if getattr(raw, "posterior_n_draws", None) is not None:
 709        mass = getattr(raw, "posterior_unidentified_mass", None)
 710        envelope = None
 711        if mass is not None and float(mass) > 0.0:
 712            envelope = EffectEnvelope(
 713                effect_mean=raw.posterior_effect_mean,
 714                effect_sd=raw.posterior_effect_sd,
 715                q025=raw.posterior_q025,
 716                q975=raw.posterior_q975,
 717                unidentified_mass=float(mass),
 718                n_draws=raw.posterior_n_draws,
 719                backend=raw.posterior_backend,
 720            )
 721        posterior = PosteriorView(
 722            effect_mean=raw.posterior_effect_mean,
 723            effect_sd=raw.posterior_effect_sd,
 724            q025=raw.posterior_q025,
 725            q975=raw.posterior_q975,
 726            n_draws=raw.posterior_n_draws,
 727            p_below_zero=raw.posterior_p_below_zero,
 728            backend=raw.posterior_backend,
 729            artifact=raw.posterior_artifact,
 730            unidentified_mass=None if mass is None else float(mass),
 731            envelope=envelope,
 732        )
 733    return AnalysisResult(
 734        identification=IdentificationView(
 735            status=raw.identification_status,
 736            method=raw.method,
 737            adjustment_set=list(getattr(raw, "adjustment_set", []) or []),
 738            assumption_count=int(getattr(raw, "assumption_count", 0) or 0),
 739            derivation_step_count=int(getattr(raw, "derivation_step_count", 0) or 0),
 740        ),
 741        estimate=EstimateView(
 742            ate=raw.ate,
 743            se_analytic=raw.se_analytic,
 744            se_bootstrap=raw.se_bootstrap,
 745            estimator_id=str(getattr(raw, "estimator_id", "") or ""),
 746            method=raw.method,
 747            mediation=mediation,
 748        ),
 749        posterior=posterior,
 750        mediation=mediation,
 751        validation=ValidationView(
 752            passed=False if not ran else True,
 753            ran=ran,
 754            count=raw.refutation_count,
 755        ),
 756        performance=PerformanceView(
 757            plan_id=raw.plan_id,
 758            modality=raw.modality,
 759            peak_memory_bytes=raw.peak_memory_bytes,
 760        ),
 761        diagnostics=list(raw.diagnostics),
 762        provenance={
 763            "node_count": raw.provenance_node_count,
 764            "worker_threads": getattr(raw, "worker_threads", None),
 765            "expected_python_crossings": getattr(raw, "expected_python_crossings", None),
 766        },
 767        plan=_plan_from_raw(raw),
 768        _raw=raw,
 769    )
 770
 771
 772
 773def _resolve_static_discovery_edges(data, discovery, accept_discovered: bool, seed: int, threads: int):
 774    """Run static discovery and return oriented DAG edge list.
 775
 776    When ``accept_discovered`` is True, incomplete CPDAG/PAG marks raise
 777    ``ValueError`` (auto-accept cannot invent orientations). When False,
 778    raises ``CausalReviewError`` with structured attrs.
 779    """
 780    from . import discovery as disc
 781    from ._native import CausalReviewError
 782
 783    def _require_oriented(result, *, kind: str, algorithm: str):
 784        try:
 785            return list(discovery_to_dag(result).edges())
 786        except ValueError as exc:
 787            pending = sum(
 788                1
 789                for e in result.graph_edges
 790                if not (
 791                    (e.at_source == "tail" and e.at_target == "arrow")
 792                    or (e.at_source == "arrow" and e.at_target == "tail")
 793                )
 794            )
 795            if accept_discovered:
 796                raise ValueError(
 797                    f"{algorithm}: accept_discovered=True but graph is incomplete "
 798                    f"({pending} non-directed marks); cannot invent orientations. {exc}"
 799                ) from exc
 800            err = CausalReviewError(
 801                "cannot execute while graph review is required"
 802            )
 803            err.kind = kind
 804            err.algorithm = algorithm
 805            err.pending_edge_count = pending
 806            err.hint = (
 807                "orient remaining edges into a Dag, or use finish_*_review / "
 808                "supply graph= edges"
 809            )
 810            err.message = str(err)
 811            raise err from exc
 812
 813    if isinstance(discovery, ExactDagPosterior):
 814        return graph_posterior_map_edges(disc.discover_exact_dag_posterior(data))
 815    if isinstance(discovery, OrderMcmc):
 816        return graph_posterior_map_edges(
 817            disc.discover_order_mcmc(
 818                data,
 819                n_warmup=discovery.n_warmup,
 820                n_draws=discovery.n_draws,
 821                seed=seed,
 822                threads=threads,
 823            )
 824        )
 825    if isinstance(discovery, StructureMcmc):
 826        return graph_posterior_map_edges(
 827            disc.discover_structure_mcmc(
 828                data,
 829                n_warmup=discovery.n_warmup,
 830                n_draws=discovery.n_draws,
 831                seed=seed,
 832                threads=threads,
 833            )
 834        )
 835    if isinstance(discovery, CiScreenedPosterior):
 836        return graph_posterior_map_edges(
 837            disc.discover_ci_screened_posterior(
 838                data,
 839                alpha=discovery.alpha,
 840                fdr=discovery.fdr,
 841                seed=seed,
 842                threads=threads,
 843            )
 844        )
 845    if isinstance(discovery, PC):
 846        result = disc.discover_pc(
 847            data, alpha=discovery.alpha, fdr=discovery.fdr, seed=seed, threads=threads
 848        )
 849        return _require_oriented(result, kind="static_cpdag", algorithm="pc")
 850    if isinstance(discovery, GES):
 851        result = disc.discover_ges(
 852            data, alpha=discovery.alpha, fdr=discovery.fdr, seed=seed, threads=threads
 853        )
 854        return _require_oriented(result, kind="static_cpdag", algorithm="ges")
 855    if isinstance(discovery, LiNGAM):
 856        result = disc.discover_lingam(data, seed=seed, threads=threads)
 857        return _require_oriented(result, kind="static_dag", algorithm="lingam")
 858    if isinstance(discovery, NOTEARS):
 859        result = disc.discover_notears(data, seed=seed, threads=threads)
 860        return _require_oriented(result, kind="static_dag", algorithm="notears")
 861    if isinstance(discovery, (FCI, RFCI)):
 862        algo = "fci" if isinstance(discovery, FCI) else "rfci"
 863        if not accept_discovered:
 864            err = CausalReviewError(
 865                "FCI/RFCI PathSpecific/Interventional queries require a fully "
 866                "oriented DAG; accept_discovered=False leaves PAG review open"
 867            )
 868            err.kind = "static_pag"
 869            err.algorithm = algo
 870            err.pending_edge_count = 0
 871            err.hint = (
 872                "orient the PAG to a Dag (or use PC/GES/LiNGAM/NOTEARS); "
 873                "PathSpecific/Interventional do not run generalized PAG adjustment"
 874            )
 875            err.message = str(err)
 876            raise err
 877        raise ValueError(
 878            f"{algo}: PathSpecific/Interventional require a fully oriented DAG; "
 879            "use PC/GES/LiNGAM/NOTEARS or supply graph= edges "
 880            "(accept_discovered cannot invent PAG orientations)"
 881        )
 882    raise TypeError(f"unsupported discovery type for path/distribution: {type(discovery)!r}")
 883
 884
 885def analyze_many(
 886    data: Mapping[str, Any] | Any,
 887    *,
 888    graph: Dag | Sequence[tuple[str, str]],
 889    queries: Sequence[AverageEffect],
 890    identifier: str | None = None,
 891    estimator: str | None = None,
 892    refute: bool | Literal["full", "placebo", "none", "cheap"] = True,
 893    seed: int = 1,
 894    bootstrap: int | None = None,
 895    threads: int = 1,
 896    latency: Literal["interactive", "standard", "report"] | None = None,
 897) -> list[AnalysisResult]:
 898    """Estimate many average effects on one shared table ingest.
 899
 900    Parameters
 901    ----------
 902    data:
 903        Column mapping / DataFrame (ingested once).
 904    graph:
 905        Static DAG or edge list shared by every query.
 906    queries:
 907        Non-empty sequence of ``AverageEffect`` queries.
 908    """
 909    if not queries:
 910        raise ValueError("analyze_many requires at least one query")
 911    if not all(isinstance(q, AverageEffect) for q in queries):
 912        raise TypeError("analyze_many currently supports AverageEffect queries only")
 913    bootstrap, refute = _resolve_latency_budget(latency, bootstrap, refute)
 914    names, columns = as_columns(data)  # type: ignore[arg-type]
 915    edges = _static_edges(graph)  # type: ignore[arg-type]
 916    specs = [
 917        (q.treatment, q.outcome, float(q.control_level), float(q.active_level))
 918        for q in queries
 919    ]
 920    kwargs: dict[str, Any] = dict(
 921        identifier=identifier,
 922        estimator=estimator,
 923        refute=refute,
 924        seed=seed,
 925        bootstrap=bootstrap,
 926        threads=threads,
 927    )
 928    if latency is not None:
 929        kwargs["latency"] = latency
 930    raws = _analyze_ate_many(names, columns, edges, specs, **kwargs)
 931    return [_wrap_ate(r) for r in raws]
 932
 933
 934@dataclass(frozen=True)
 935class IdentifyResult:
 936    """Identify-only result (no estimate)."""
 937
 938    status: str
 939    method: str
 940    adjustment_set: list[str]
 941
 942
 943def identify(
 944    *,
 945    graph: Dag | Sequence[tuple[str, str]],
 946    query: AverageEffect,
 947    names: Sequence[str] | None = None,
 948    identifier: str | Identifier | None = None,
 949) -> IdentifyResult:
 950    """Identify without estimating.
 951
 952    Pass ``names`` when ``graph`` is an edge list (variable order). With a
 953    ``Dag``, names are taken from ``graph.nodes()``.
 954    """
 955    if isinstance(identifier, Identifier):
 956        identifier = str(identifier)
 957    if isinstance(graph, Dag):
 958        node_names = list(graph.nodes())
 959        edges = list(graph.edges())
 960    else:
 961        if names is None:
 962            raise ValueError("identify(edge_list) requires names=")
 963        node_names = list(names)
 964        edges = list(graph)
 965    status, method, adjustment = _identify_ate(
 966        node_names,
 967        edges,
 968        query.treatment,
 969        query.outcome,
 970        identifier=identifier,
 971    )
 972    return IdentifyResult(status=status, method=method, adjustment_set=list(adjustment))
 973
 974
 975def analyze(
 976    data: Mapping[str, Any] | Any | Sequence[Mapping[str, Any] | Any],
 977    *,
 978    query: (
 979        AverageEffect
 980        | PulseEffect
 981        | SustainedEffect
 982        | InterventionalDistribution
 983        | PathSpecificEffect
 984        | ConditionalEffect
 985        | MediationEffect
 986        | Counterfactual
 987        | TemporalMediationEffect
 988    ),
 989    graph: (
 990        Dag
 991        | Cpdag
 992        | Pag
 993        | Admg
 994        | TemporalDag
 995        | TemporalCpdag
 996        | TemporalPag
 997        | Sequence[tuple[str, str]]
 998        | Sequence[tuple[str, int, str, int]]
 999        | None
1000    ) = None,
1001    discovery: Any | None = None,
1002    inference: Frequentist | Bayesian | None = None,
1003    identifier: str | Identifier | None = None,
1004    estimator: str | Estimator | None = None,
1005    refute: bool | Refute | Literal["full", "placebo", "none", "cheap"] = True,
1006    validators: Sequence[Any] | None = None,
1007    accept_discovered: bool = True,
1008    seed: int = 1,
1009    bootstrap: int | None = None,
1010    threads: int = 1,
1011    regimes: Sequence[int] | None = None,
1012    running_variable: str | None = None,
1013    cutoff: float | None = None,
1014    bandwidth: float | None = None,
1015    population_registry: Any | None = None,
1016    latency: Latency | Literal["interactive", "standard", "report"] | None = None,
1017    cancel: Any | None = None,
1018    on_progress: Any | None = None,
1019    on_stage: Any | None = None,
1020    return_posterior_artifact: bool = False,
1021) -> AnalysisResult:
1022    """Identify then estimate a causal effect.
1023
1024    Parameters
1025    ----------
1026    data:
1027        Mapping of column name → 1-d float array, a pandas ``DataFrame``,
1028        Arrow CDI exporters (PyArrow columns / table), or a
1029        ``antecedent.data`` frame (``EventFrame`` / ``PanelFrame`` / ``MultiEnvFrame``).
1030        For ``discovery=JPCMCIPlus(...)``, pass a sequence of environment frames
1031        or a ``MultiEnvFrame``.
1032    query:
1033        ``AverageEffect``, ``PulseEffect`` / ``SustainedEffect``,
1034        ``InterventionalDistribution``, ``PathSpecificEffect``,
1035        ``MediationEffect``, ``Counterfactual``, or ``TemporalMediationEffect``.
1036    graph:
1037        ``Dag`` / ``Cpdag`` / ``Pag`` / ``Admg`` / ``TemporalDag`` /
1038        ``TemporalCpdag`` / ``TemporalPag``, or an edge list. Lagged edges
1039        ``(from, from_lag, to, to_lag)`` are required for temporal queries
1040        without ``discovery``. Fully oriented CPDAGs run as DAGs; incomplete
1041        CPDAGs require review. ADMGs without bidirected edges coerce to DAGs;
1042        ADMGs with latents use general ID + functional effect.
1043    discovery:
1044        Static: ``PC`` / ``GES`` / ``LiNGAM`` / ``NOTEARS`` / ``FCI`` / ``RFCI``.
1045        Temporal: ``PCMCI`` / ``PCMCIPlus`` / ``LPCMCI`` / ``JPCMCIPlus`` / ``RPCMCI``.
1046        One-shot script convenience — discovery runs at compile time. For
1047        interactive / spreadsheet estimate clicks, discover once into
1048        :class:`antecedent.AcceptedGraph` (or hold a reviewed graph) and pass
1049        ``graph=`` with ``latency="interactive"`` instead. Combining
1050        ``discovery=`` with ``latency="interactive"`` raises
1051        :class:`CausalUnsupportedError`.
1052    latency:
1053        Optional compute tier (``interactive`` / ``standard`` / ``report``).
1054        Maps to known-equivalent bootstrap / refute / draws; explicit
1055        ``bootstrap=`` / ``refute=`` always win. Interactive refuses inline
1056        ``discovery=`` (artifact-first UX).
1057    cancel:
1058        Optional ``CancellationToken`` from ``antecedent._native``.
1059    on_progress:
1060        Optional ``(fraction: float, stage: str) -> None`` callback.
1061    on_stage:
1062        Optional ``(stage: str, payload: dict) -> None`` progressive stage
1063        callback (identify → estimate_point → uncertainty → validate).
1064    return_posterior_artifact:
1065        When ``True`` and inference is Bayesian, attach full posterior draw
1066        bytes on ``result.posterior.artifact`` (for download / sequential-prior
1067        hydrate). Default ``False``: UI summaries only.
1068    """
1069    if isinstance(identifier, Identifier):
1070        identifier = str(identifier)
1071    if isinstance(estimator, Estimator):
1072        estimator = str(estimator)
1073    if isinstance(latency, Latency):
1074        latency = str(latency)  # type: ignore[assignment]
1075    if isinstance(refute, Refute):
1076        refute = str(refute)  # type: ignore[assignment]
1077    inference = inference or Frequentist()
1078    bootstrap, refute = _resolve_latency_budget(latency, bootstrap, refute)
1079
1080    if discovery is not None and latency == "interactive":
1081        raise CausalUnsupportedError(
1082            "discovery= is not on the interactive estimate path; "
1083            "call discover_* once, accept into AcceptedGraph, then "
1084            "analyze(graph=..., latency='interactive')"
1085        )
1086
1087    if isinstance(query, ConditionalEffect):
1088        if isinstance(inference, Bayesian):
1089            raise TypeError("ConditionalEffect does not support inference=Bayesian(...)")
1090        if discovery is not None:
1091            raise ValueError("ConditionalEffect does not support discovery=")
1092        names, columns = as_columns(data)  # type: ignore[arg-type]
1093        edges = _static_edges(graph)  # type: ignore[arg-type]
1094        raw = _analyze_conditional(
1095            names,
1096            columns,
1097            edges,
1098            query.treatment,
1099            query.outcome,
1100            query.modifier,
1101            control_level=query.control_level,
1102            active_level=query.active_level,
1103            refute=refute,
1104            validators=list(validators) if validators is not None else None,
1105            seed=seed,
1106            bootstrap=bootstrap,
1107            threads=threads,
1108        )
1109        return _wrap_ate(raw)
1110
1111    if isinstance(query, TemporalMediationEffect):
1112        if isinstance(inference, Bayesian):
1113            raise TypeError("TemporalMediationEffect does not support inference=Bayesian(...)")
1114        if discovery is not None:
1115            raise ValueError("TemporalMediationEffect does not support discovery=")
1116        names, columns = as_columns(data)  # type: ignore[arg-type]
1117        lagged = _lagged_edges(graph)  # type: ignore[arg-type]
1118        raw = _analyze_temporal_mediation(
1119            names,
1120            columns,
1121            lagged,
1122            query.treatment,
1123            query.mediator,
1124            query.outcome,
1125            contrast=query.contrast,
1126            control_level=query.control_level,
1127            active_level=query.active_level,
1128            seed=seed,
1129            bootstrap=bootstrap,
1130            threads=threads,
1131        )
1132        return _wrap_temporal(raw)
1133
1134    if isinstance(query, MediationEffect):
1135        if discovery is not None:
1136            raise ValueError("MediationEffect does not support discovery=")
1137        edges = _static_edges(graph)  # type: ignore[arg-type]
1138        names, columns = as_columns(data)  # type: ignore[arg-type]
1139        raw = _analyze_mediation(
1140            names,
1141            columns,
1142            edges,
1143            query.treatment,
1144            query.outcome,
1145            list(query.mediators),
1146            contrast=query.contrast,
1147            control_level=query.control_level,
1148            active_level=query.active_level,
1149            refute=refute,
1150            seed=seed,
1151            bootstrap=bootstrap,
1152            threads=threads,
1153        )
1154        return _wrap_ate(raw)
1155
1156    if isinstance(query, Counterfactual):
1157        from ._native import counterfactual_ite
1158
1159        if discovery is not None:
1160            raise ValueError("Counterfactual does not support discovery=")
1161        edges = _static_edges(graph)  # type: ignore[arg-type]
1162        names, columns = as_columns(data)  # type: ignore[arg-type]
1163        ite = counterfactual_ite(
1164            names,
1165            columns,
1166            edges,
1167            query.treatment,
1168            query.outcome,
1169            query.active_level,
1170            query.control_level,
1171            seed=seed,
1172            threads=threads,
1173        )
1174        return AnalysisResult(
1175            identification=IdentificationView(
1176                status="gcm.parametric",
1177                method="counterfactual.ite",
1178                adjustment_set=[],
1179                assumption_count=0,
1180                derivation_step_count=0,
1181            ),
1182            estimate=EstimateView(
1183                ate=float(ite.mean_ite),
1184                se_analytic=float("nan"),
1185                se_bootstrap=None,
1186                estimator_id="gcm.ite",
1187                method="counterfactual.ite",
1188            ),
1189            posterior=None,
1190            validation=ValidationView(passed=False, ran=False, count=0),
1191            performance=PerformanceView(
1192                plan_id="counterfactual.ite",
1193                modality="static",
1194                peak_memory_bytes=0,
1195            ),
1196            diagnostics=[],
1197            provenance={"noise_inference": getattr(ite, "noise_inference", None)},
1198            _raw=ite,
1199        )
1200
1201    if isinstance(query, InterventionalDistribution):
1202        if discovery is not None:
1203            edges = _resolve_static_discovery_edges(
1204                data, discovery, accept_discovered, seed, threads
1205            )
1206        else:
1207            edges = _static_edges(graph)  # type: ignore[arg-type]
1208        names, columns = as_columns(data)  # type: ignore[arg-type]
1209        raw = _analyze_distribution(
1210            names,
1211            columns,
1212            edges,
1213            query.outcome,
1214            dict(query.interventions),
1215            conditioning=list(query.conditioning) or None,
1216            seed=seed,
1217            threads=threads,
1218        )
1219        return _wrap_ate(raw)
1220
1221    if isinstance(query, PathSpecificEffect):
1222        if discovery is not None:
1223            edges = _resolve_static_discovery_edges(
1224                data, discovery, accept_discovered, seed, threads
1225            )
1226        else:
1227            edges = _static_edges(graph)  # type: ignore[arg-type]
1228        names, columns = as_columns(data)  # type: ignore[arg-type]
1229        raw = _analyze_path_specific(
1230            names,
1231            columns,
1232            edges,
1233            query.treatment,
1234            query.outcome,
1235            control_level=query.control_level,
1236            active_level=query.active_level,
1237            path_nodes=list(query.path_nodes) if query.path_nodes is not None else None,
1238            max_paths=query.max_paths,
1239            max_len=query.max_len,
1240            seed=seed,
1241            bootstrap=bootstrap,
1242            threads=threads,
1243        )
1244        return _wrap_ate(raw)
1245
1246    if discovery is not None and isinstance(
1247        discovery, _STATIC_DISCOVERY + _GRAPH_POSTERIOR_DISCOVERY
1248    ):
1249        if not isinstance(query, AverageEffect):
1250            raise ValueError(
1251                f"discovery={type(discovery).__name__}(...) requires AverageEffect"
1252            )
1253        if isinstance(discovery, _GRAPH_POSTERIOR_DISCOVERY) and not isinstance(
1254            inference, Bayesian
1255        ):
1256            raise TypeError(
1257                "graph-posterior discovery requires inference=Bayesian(...) "
1258                "for effect mixture"
1259            )
1260        names, columns = as_columns(data)  # type: ignore[arg-type]
1261        cfg = _discovery_algorithm(discovery)
1262        bayes_kw: dict[str, Any] = {}
1263        if isinstance(inference, Bayesian):
1264            bayes_kw = _bayesian_inference_kwargs(inference)
1265        raw = _analyze_ate_discover(
1266            names,
1267            columns,
1268            query.treatment,
1269            query.outcome,
1270            algorithm=cfg["algorithm"],
1271            alpha=cfg.get("alpha", 0.05),
1272            fdr=cfg.get("fdr", True),
1273            max_cond_size=cfg.get("max_cond_size", 2),
1274            prune_threshold=cfg.get("prune_threshold", 0.0),
1275            l1=cfg.get("lambda", 0.1),
1276            threshold=cfg.get("threshold", 0.3),
1277            standardize=cfg.get("standardize", True),
1278            accept_discovered=accept_discovered,
1279            control_level=query.control_level,
1280            active_level=query.active_level,
1281            identifier=identifier,
1282            estimator=estimator,
1283            refute=refute,
1284            validators=list(validators) if validators is not None else None,
1285            ci=cfg.get("ci"),
1286            n_chains=cfg.get("n_chains", 2),
1287            n_warmup=cfg.get("n_warmup", 100),
1288            mcmc_draws=cfg.get("mcmc_draws", 200),
1289            thin=cfg.get("thin", 1),
1290            soft_weight=cfg.get("soft_weight", "none"),
1291            require_diagnostics_gate=cfg.get("require_diagnostics_gate", True),
1292            seed=seed,
1293            bootstrap=bootstrap,
1294            threads=threads,
1295            **bayes_kw,
1296        )
1297        return _wrap_ate(raw)
1298
1299    if discovery is not None and isinstance(query, AverageEffect):
1300        raise ValueError(
1301            "AverageEffect with discovery= requires a static algorithm "
1302            "(PC/GES/LiNGAM/NOTEARS/FCI/RFCI); temporal discovery needs "
1303            "PulseEffect/SustainedEffect"
1304        )
1305
1306    if isinstance(query, AverageEffect):
1307        bayes_kw: dict[str, Any] = {}
1308        if isinstance(inference, Bayesian):
1309            bayes_kw = _bayesian_inference_kwargs(inference)
1310        if estimator == "rd.sharp" or any(
1311            v is not None for v in (running_variable, cutoff, bandwidth)
1312        ):
1313            if running_variable is None or cutoff is None or bandwidth is None:
1314                raise ValueError(
1315                    "rd.sharp (or any RD kwargs) requires running_variable, cutoff, and bandwidth"
1316                )
1317            if estimator is None:
1318                estimator = "rd.sharp"
1319            if identifier is None:
1320                identifier = "rd.sharp"
1321        common = dict(
1322            treatment=query.treatment,
1323            outcome=query.outcome,
1324            control_level=query.control_level,
1325            active_level=query.active_level,
1326            identifier=identifier,
1327            estimator=estimator,
1328            refute=refute,
1329            validators=list(validators) if validators is not None else None,
1330            running_variable=running_variable,
1331            cutoff=cutoff,
1332            bandwidth=bandwidth,
1333            seed=seed,
1334            bootstrap=bootstrap,
1335            threads=threads,
1336            **bayes_kw,
1337        )
1338        if return_posterior_artifact:
1339            common["return_posterior_artifact"] = True
1340        if latency is not None:
1341            common["latency"] = latency
1342        if cancel is not None:
1343            common["cancel"] = cancel
1344        if on_progress is not None:
1345            common["on_progress"] = on_progress
1346        if on_stage is not None:
1347            common["on_stage"] = on_stage
1348        from .population import coerce_target_population, registry_wire
1349
1350        pop = coerce_target_population(
1351            getattr(query, "target_population", None)
1352        )
1353        preds, dists = registry_wire(population_registry)
1354        pop_kw: dict[str, Any] = {}
1355        if pop is not None:
1356            pop_kw["target_population"] = pop
1357        if preds:
1358            pop_kw["population_predicates"] = preds
1359        if dists:
1360            pop_kw["population_distributions"] = dists
1361        if pop_kw and isinstance(graph, (Pag, Cpdag, Admg)):
1362            raise ValueError(
1363                "target_population / population_registry currently require a Dag "
1364                "(or edge list); PAG/CPDAG/ADMG analyze paths do not accept them yet"
1365            )
1366        if isinstance(graph, Pag):
1367            names, columns = as_columns(data)  # type: ignore[arg-type]
1368            return _wrap_ate(_analyze_ate_pag(names, columns, graph, **common))
1369        if isinstance(graph, Cpdag):
1370            names, columns = as_columns(data)  # type: ignore[arg-type]
1371            return _wrap_ate(_analyze_ate_cpdag(names, columns, graph, **common))
1372        if isinstance(graph, Admg):
1373            names, columns = as_columns(data)  # type: ignore[arg-type]
1374            return _wrap_ate(_analyze_ate_admg(names, columns, graph, **common))
1375        edges = _static_edges(graph)  # type: ignore[arg-type]
1376        arrow = try_as_arrow_c_columns(data)
1377        ate_kwargs = dict(edges=edges, **common, **pop_kw)
1378        # Prefer Arrow CDI (zero-copy float64) when available. Population kwargs
1379        # still require the NumPy path until that surface is wired on CDI.
1380        use_arrow = arrow is not None and not pop_kw
1381        if use_arrow:
1382            names, columns = arrow
1383            raw = _analyze_ate_arrow_c(names, columns, **ate_kwargs)
1384        else:
1385            names, columns = as_columns(data)  # type: ignore[arg-type]
1386            raw = _analyze_ate(names, columns, **ate_kwargs)
1387        return _wrap_ate(raw)
1388
1389    if isinstance(query, (PulseEffect, SustainedEffect)):
1390        policy = "sustained" if isinstance(query, SustainedEffect) else "pulse"
1391        _reject_unsupported_temporal(
1392            inference=inference, refute=refute, validators=validators
1393        )
1394        bayes_kw = _temporal_inference_kwargs(inference)
1395        if isinstance(data, EventFrame):
1396            if discovery is not None:
1397                if isinstance(discovery, JPCMCIPlus):
1398                    raise TypeError(
1399                        "EventFrame does not support discovery=JPCMCIPlus(...); "
1400                        "use MultiEnvFrame or PanelFrame for multi-environment discovery"
1401                    )
1402                if isinstance(discovery, DbnPosterior):
1403                    if not isinstance(inference, Bayesian):
1404                        raise TypeError(
1405                            "EventFrame discovery=DbnPosterior(...) requires inference=Bayesian(...)"
1406                        )
1407                elif not isinstance(discovery, (PCMCI, PCMCIPlus, LPCMCI, RPCMCI)):
1408                    raise TypeError(
1409                        f"EventFrame discovery expects PCMCI/PCMCIPlus/LPCMCI/RPCMCI/DbnPosterior, "
1410                        f"got {type(discovery)!r}"
1411                    )
1412                cfg = _discovery_algorithm(discovery)
1413                raw = _analyze_events(
1414                    data.names,
1415                    data.columns,
1416                    data.event_times_ns.tolist(),
1417                    data.align_interval_ns,
1418                    [],  # discovery path ignores edges
1419                    query.treatment,
1420                    query.outcome,
1421                    treatment_lag=query.treatment_lag,
1422                    horizon_steps=query.horizon_steps,
1423                    active_level=query.active_level,
1424                    policy=policy,
1425                    **bayes_kw,
1426                    refute=refute,
1427                    validators=list(validators) if validators is not None else None,
1428                    seed=seed,
1429                    bootstrap=bootstrap,
1430                    threads=threads,
1431                    algorithm=cfg["algorithm"],
1432                    max_lag=cfg.get("max_lag", 1),
1433                    alpha=cfg.get("alpha", 0.05),
1434                    fdr=cfg.get("fdr", True),
1435                    accept_discovered=accept_discovered,
1436                    regimes=list(regimes) if regimes is not None else None,
1437                    **{
1438                        k: cfg[k]
1439                        for k in ("n_chains", "n_warmup", "mcmc_draws", "force_mcmc", "ci")
1440                        if k in cfg
1441                    },
1442                )
1443                return _wrap_temporal(raw)
1444            lagged = _lagged_edges(graph)  # type: ignore[arg-type]
1445            raw = _analyze_events(
1446                data.names,
1447                data.columns,
1448                data.event_times_ns.tolist(),
1449                data.align_interval_ns,
1450                lagged,
1451                query.treatment,
1452                query.outcome,
1453                treatment_lag=query.treatment_lag,
1454                horizon_steps=query.horizon_steps,
1455                active_level=query.active_level,
1456                policy=policy,
1457                **bayes_kw,
1458                refute=refute,
1459                validators=list(validators) if validators is not None else None,
1460                seed=seed,
1461                bootstrap=bootstrap,
1462                threads=threads,
1463            )
1464            return _wrap_temporal(raw)
1465        if isinstance(data, PanelFrame):
1466            if discovery is not None:
1467                if isinstance(discovery, JPCMCIPlus):
1468                    cfg = _discovery_algorithm(discovery)
1469                    raw = _analyze_panel_discover(
1470                        data.names,
1471                        data.unit_columns,
1472                        data.unit_ids,
1473                        query.treatment,
1474                        query.outcome,
1475                        max_lag=cfg["max_lag"],
1476                        alpha=cfg["alpha"],
1477                        fdr=cfg["fdr"],
1478                        accept_discovered=accept_discovered,
1479                        treatment_lag=query.treatment_lag,
1480                        horizon_steps=query.horizon_steps,
1481                        active_level=query.active_level,
1482                        policy=policy,
1483                        **bayes_kw,
1484                        refute=refute,
1485                        validators=list(validators) if validators is not None else None,
1486                        seed=seed,
1487                        bootstrap=bootstrap,
1488                        threads=threads,
1489                        context_names=cfg["context_names"],
1490                        include_space_dummy=cfg["include_space_dummy"],
1491                        include_time_dummy=cfg["include_time_dummy"],
1492                        space_dummy_ci=cfg["space_dummy_ci"]
1493                        in ("multivariate", "multivariate_block", "block", True),
1494                        time_dummy_encoding=cfg["time_dummy_encoding"],
1495                        time_dummy_ci=cfg["time_dummy_ci"]
1496                        in ("multivariate", "multivariate_block", "block", True),
1497                    )
1498                    return _wrap_temporal(raw)
1499                if isinstance(discovery, (PCMCI, PCMCIPlus, LPCMCI)):
1500                    cfg = _discovery_algorithm(discovery)
1501                    # Pooled-units discovery: treat panel as multi-env without JPCMCI+ context.
1502                    raw = _analyze_panel_discover(
1503                        data.names,
1504                        data.unit_columns,
1505                        data.unit_ids,
1506                        query.treatment,
1507                        query.outcome,
1508                        max_lag=cfg["max_lag"],
1509                        alpha=cfg["alpha"],
1510                        fdr=cfg["fdr"],
1511                        accept_discovered=accept_discovered,
1512                        treatment_lag=query.treatment_lag,
1513                        horizon_steps=query.horizon_steps,
1514                        active_level=query.active_level,
1515                        policy=policy,
1516                        **bayes_kw,
1517                        refute=refute,
1518                        validators=list(validators) if validators is not None else None,
1519                        seed=seed,
1520                        bootstrap=bootstrap,
1521                        threads=threads,
1522                        algorithm=cfg["algorithm"],
1523                    )
1524                    return _wrap_temporal(raw)
1525                raise TypeError(
1526                    "PanelFrame discovery supports JPCMCIPlus, PCMCI, PCMCIPlus, or LPCMCI"
1527                )
1528            lagged = _lagged_edges(graph)  # type: ignore[arg-type]
1529            raw = _analyze_panel(
1530                data.names,
1531                data.unit_columns,
1532                data.unit_ids,
1533                lagged,
1534                query.treatment,
1535                query.outcome,
1536                treatment_lag=query.treatment_lag,
1537                horizon_steps=query.horizon_steps,
1538                active_level=query.active_level,
1539                policy=policy,
1540                **bayes_kw,
1541                refute=refute,
1542                validators=list(validators) if validators is not None else None,
1543                seed=seed,
1544                bootstrap=bootstrap,
1545                threads=threads,
1546            )
1547            return _wrap_temporal(raw)
1548        if isinstance(data, MultiEnvFrame):
1549            if discovery is None or not isinstance(discovery, JPCMCIPlus):
1550                raise TypeError(
1551                    "MultiEnvFrame requires discovery=JPCMCIPlus(...)"
1552                )
1553            cfg = _discovery_algorithm(discovery)
1554            raw = _analyze_temporal_discover(
1555                data.names,
1556                data.env_columns[0],
1557                query.treatment,
1558                query.outcome,
1559                algorithm="jpcmci_plus",
1560                max_lag=cfg["max_lag"],
1561                alpha=cfg["alpha"],
1562                fdr=cfg["fdr"],
1563                accept_discovered=accept_discovered,
1564                treatment_lag=query.treatment_lag,
1565                horizon_steps=query.horizon_steps,
1566                active_level=query.active_level,
1567                policy=policy,
1568                **bayes_kw,
1569                seed=seed,
1570                bootstrap=bootstrap,
1571                threads=threads,
1572                env_columns=data.env_columns,
1573                context_names=cfg["context_names"],
1574                include_space_dummy=cfg["include_space_dummy"],
1575                include_time_dummy=cfg["include_time_dummy"],
1576                space_dummy_ci=cfg["space_dummy_ci"],
1577                time_dummy_encoding=cfg["time_dummy_encoding"],
1578                time_dummy_ci=cfg["time_dummy_ci"],
1579                ci=cfg.get("ci"),
1580            )
1581            return _wrap_temporal(raw)
1582        if discovery is not None:
1583            if isinstance(discovery, DbnPosterior):
1584                if not isinstance(inference, Bayesian):
1585                    raise TypeError(
1586                        "discovery=DbnPosterior(...) requires inference=Bayesian(...) "
1587                        "for temporal effect mixture"
1588                    )
1589                cfg = _discovery_algorithm(discovery)
1590                names, columns = as_columns(data)  # type: ignore[arg-type]
1591                raw = _analyze_temporal_discover(
1592                    names,
1593                    columns,
1594                    query.treatment,
1595                    query.outcome,
1596                    algorithm="dbn_posterior",
1597                    max_lag=cfg["max_lag"],
1598                    accept_discovered=accept_discovered,
1599                    treatment_lag=query.treatment_lag,
1600                    horizon_steps=query.horizon_steps,
1601                    active_level=query.active_level,
1602                    policy=policy,
1603                    **bayes_kw,
1604                    n_chains=cfg["n_chains"],
1605                    n_warmup=cfg["n_warmup"],
1606                    mcmc_draws=cfg["mcmc_draws"],
1607                    force_mcmc=cfg["force_mcmc"],
1608                    seed=seed,
1609                    bootstrap=bootstrap,
1610                    threads=threads,
1611                )
1612                return _wrap_temporal(raw)
1613            if not isinstance(discovery, _TEMPORAL_DISCOVERY):
1614                raise TypeError(
1615                    f"temporal discovery expects PCMCI-family or DbnPosterior, got {type(discovery)!r}"
1616                )
1617            cfg = _discovery_algorithm(discovery)
1618            algo = cfg["algorithm"]
1619            if algo == "jpcmci_plus":
1620                if not isinstance(data, Sequence) or isinstance(data, (str, bytes, Mapping)):
1621                    raise TypeError(
1622                        "discovery=JPCMCIPlus(...) requires data as a sequence of "
1623                        "environment mappings/DataFrames"
1624                    )
1625                names, env_columns = as_multi_env_columns(data)
1626                raw = _analyze_temporal_discover(
1627                    names,
1628                    env_columns[0],
1629                    query.treatment,
1630                    query.outcome,
1631                    algorithm=algo,
1632                    max_lag=cfg["max_lag"],
1633                    alpha=cfg["alpha"],
1634                    fdr=cfg["fdr"],
1635                    accept_discovered=accept_discovered,
1636                    treatment_lag=query.treatment_lag,
1637                    horizon_steps=query.horizon_steps,
1638                    active_level=query.active_level,
1639                    policy=policy,
1640                    **bayes_kw,
1641                    seed=seed,
1642                    bootstrap=bootstrap,
1643                    threads=threads,
1644                    env_columns=env_columns,
1645                    context_names=cfg["context_names"],
1646                    include_space_dummy=cfg["include_space_dummy"],
1647                    include_time_dummy=cfg["include_time_dummy"],
1648                    space_dummy_ci=cfg["space_dummy_ci"],
1649                    time_dummy_encoding=cfg["time_dummy_encoding"],
1650                    time_dummy_ci=cfg["time_dummy_ci"],
1651                    ci=cfg.get("ci"),
1652                )
1653                return _wrap_temporal(raw)
1654            if algo == "rpcmci":
1655                if regimes is None:
1656                    raise ValueError("discovery=RPCMCI(...) requires regimes=[…] labels")
1657                names, columns = as_columns(data)  # type: ignore[arg-type]
1658                raw = _analyze_temporal_discover(
1659                    names,
1660                    columns,
1661                    query.treatment,
1662                    query.outcome,
1663                    algorithm=algo,
1664                    max_lag=cfg["max_lag"],
1665                    alpha=cfg["alpha"],
1666                    fdr=cfg["fdr"],
1667                    accept_discovered=accept_discovered,
1668                    treatment_lag=query.treatment_lag,
1669                    horizon_steps=query.horizon_steps,
1670                    active_level=query.active_level,
1671                    policy=policy,
1672                    **bayes_kw,
1673                    seed=seed,
1674                    bootstrap=bootstrap,
1675                    threads=threads,
1676                    regimes=list(regimes),
1677                    ci=cfg.get("ci"),
1678                )
1679                return _wrap_temporal(raw)
1680            names, columns = as_columns(data)  # type: ignore[arg-type]
1681            raw = _analyze_temporal_discover(
1682                names,
1683                columns,
1684                query.treatment,
1685                query.outcome,
1686                algorithm=algo,
1687                max_lag=cfg["max_lag"],
1688                alpha=cfg["alpha"],
1689                fdr=cfg["fdr"],
1690                accept_discovered=accept_discovered,
1691                treatment_lag=query.treatment_lag,
1692                horizon_steps=query.horizon_steps,
1693                active_level=query.active_level,
1694                policy=policy,
1695                **bayes_kw,
1696                seed=seed,
1697                bootstrap=bootstrap,
1698                threads=threads,
1699                ci=cfg.get("ci"),
1700            )
1701            return _wrap_temporal(raw)
1702        names, columns = as_columns(data)  # type: ignore[arg-type]
1703        if isinstance(graph, TemporalPag):
1704            raw = _analyze_temporal_pag(
1705                names,
1706                columns,
1707                graph,
1708                query.treatment,
1709                query.outcome,
1710                treatment_lag=query.treatment_lag,
1711                horizon_steps=query.horizon_steps,
1712                active_level=query.active_level,
1713                policy=policy,
1714                **bayes_kw,
1715                refute=refute,
1716                validators=list(validators) if validators is not None else None,
1717                seed=seed,
1718                bootstrap=bootstrap,
1719                threads=threads,
1720            )
1721            return _wrap_temporal(raw)
1722        if isinstance(graph, TemporalCpdag):
1723            try:
1724                graph = graph.try_into_temporal_dag()
1725            except Exception as exc:  # noqa: BLE001 — surface orientation failures
1726                raise ValueError(
1727                    "TemporalCpdag has undirected/conflict marks; orient edges "
1728                    "(try_into_temporal_dag) before analyze, or use discovery review"
1729                ) from exc
1730        lagged = _lagged_edges(graph)  # type: ignore[arg-type]
1731        raw = _analyze_temporal(
1732            names,
1733            columns,
1734            lagged,
1735            query.treatment,
1736            query.outcome,
1737            treatment_lag=query.treatment_lag,
1738            horizon_steps=query.horizon_steps,
1739            active_level=query.active_level,
1740            policy=policy,
1741            **bayes_kw,
1742            refute=refute,
1743            validators=list(validators) if validators is not None else None,
1744            seed=seed,
1745            bootstrap=bootstrap,
1746            threads=threads,
1747        )
1748        return _wrap_temporal(raw)
1749
1750    raise TypeError(f"unsupported query type: {type(query)!r}")
1751
1752
1753class PreparedAnalysis:
1754    """Compile-once / re-estimate-many handle for static AverageEffect on a DAG.
1755
1756    Use for interactive sessions: prepare with a fixed graph/query/estimator,
1757    then call :meth:`estimate` or :meth:`refresh` when the table changes
1758    (same schema). Prefer this over fresh :func:`analyze` on every click.
1759    For streaming append + incremental OLS, use :class:`antecedent.CausalState`.
1760    """
1761
1762    def __init__(self, native: Any) -> None:
1763        self._native = native
1764
1765    @classmethod
1766    def prepare(
1767        cls,
1768        data: Mapping[str, Any] | Any,
1769        *,
1770        query: AverageEffect,
1771        graph: Dag | Sequence[tuple[str, str]],
1772        inference: Frequentist | Bayesian | None = None,
1773        identifier: str | Identifier | None = None,
1774        estimator: str | Estimator | None = None,
1775        refute: bool | Refute | Literal["full", "placebo", "none", "cheap"] = False,
1776        seed: int = 1,
1777        bootstrap: int | None = None,
1778        threads: int = 1,
1779        latency: Latency | Literal["interactive", "standard", "report"] | None = "interactive",
1780    ) -> PreparedAnalysis:
1781        """Compile a durable plan for static ATE on a supplied DAG."""
1782        if not isinstance(query, AverageEffect):
1783            raise TypeError("PreparedAnalysis supports AverageEffect only")
1784        inference = inference or Frequentist()
1785        if isinstance(identifier, Identifier):
1786            identifier = str(identifier)
1787        if isinstance(estimator, Estimator):
1788            estimator = str(estimator)
1789        if isinstance(latency, Latency):
1790            latency = str(latency)  # type: ignore[assignment]
1791        if isinstance(refute, Refute):
1792            refute = str(refute)  # type: ignore[assignment]
1793        bootstrap, refute = _resolve_latency_budget(latency, bootstrap, refute)
1794        names, columns = as_columns(data)  # type: ignore[arg-type]
1795        edges = _static_edges(graph)  # type: ignore[arg-type]
1796        bayes_kw: dict[str, Any] = {}
1797        if isinstance(inference, Bayesian):
1798            bayes_kw = _bayesian_inference_kwargs(inference)
1799            inference_mode = str(bayes_kw.pop("inference"))
1800        else:
1801            inference_mode = "frequentist"
1802        native = _NativePreparedAnalysis.prepare(
1803            names,
1804            columns,
1805            edges,
1806            query.treatment,
1807            query.outcome,
1808            control_level=query.control_level,
1809            active_level=query.active_level,
1810            identifier=identifier,
1811            estimator=estimator,
1812            inference=inference_mode,
1813            n_draws=int(bayes_kw.get("n_draws", 1000)),
1814            prior_scale=float(bayes_kw.get("prior_scale", 10.0)),
1815            refute=refute,
1816            seed=seed,
1817            bootstrap=bootstrap,
1818            threads=threads,
1819            latency=latency,
1820        )
1821        return cls(native)
1822
1823    @property
1824    def plan(self) -> PhysicalPlanView:
1825        """Physical-plan summary retained from prepare."""
1826        raw = self._native.plan_summary()
1827        return PhysicalPlanView(
1828            plan_id=str(raw.get("plan_id", "")),
1829            estimated_peak_memory_bytes=(
1830                int(raw["estimated_peak_memory_bytes"])
1831                if "estimated_peak_memory_bytes" in raw
1832                else None
1833            ),
1834            workspace_bytes=(
1835                int(raw["workspace_bytes"]) if "workspace_bytes" in raw else None
1836            ),
1837            batch_size=int(raw["batch_size"]) if "batch_size" in raw else None,
1838            worker_threads=int(raw.get("worker_threads", 0)),
1839            expected_python_crossings=int(raw.get("expected_python_crossings", 0)),
1840            deterministic_reductions=str(raw.get("deterministic_reductions", "true")).lower()
1841            in ("1", "true"),
1842            kernels=raw.get("kernels") or None,
1843        )
1844
1845    def estimate(
1846        self,
1847        data: Mapping[str, Any] | Any,
1848        *,
1849        seed: int = 1,
1850        threads: int = 1,
1851    ) -> AnalysisResult:
1852        """Re-estimate without recompiling (same schema as prepare)."""
1853        names, columns = as_columns(data)  # type: ignore[arg-type]
1854        raw = self._native.estimate(names, columns, seed=seed, threads=threads)
1855        return _wrap_ate(raw, prepared=self)
1856
1857    def refresh(
1858        self,
1859        data: Mapping[str, Any] | Any,
1860        *,
1861        seed: int = 1,
1862        threads: int = 1,
1863    ) -> AnalysisResult:
1864        """Replace retained data and re-estimate."""
1865        names, columns = as_columns(data)  # type: ignore[arg-type]
1866        raw = self._native.refresh(names, columns, seed=seed, threads=threads)
1867        return _wrap_ate(raw, prepared=self)
1868
1869    def refute(
1870        self,
1871        data: Mapping[str, Any] | Any,
1872        suite: Refute | Literal["placebo", "full", "cheap"] | bool | str = "placebo",
1873        *,
1874        seed: int = 1,
1875        threads: int = 1,
1876        cancel: Any | None = None,
1877    ) -> AnalysisResult:
1878        """Second-click refute against the last :meth:`estimate` / :meth:`refresh`.
1879
1880        Interactive first clicks typically use ``refute=False`` or ``cheap``;
1881        call this with ``suite="placebo"`` or ``"full"`` for the deferred suite.
1882        """
1883        if isinstance(suite, Refute):
1884            suite = str(suite)
1885        names, columns = as_columns(data)  # type: ignore[arg-type]
1886        kwargs: dict[str, Any] = dict(seed=seed, threads=threads)
1887        if cancel is not None:
1888            kwargs["cancel"] = cancel
1889        raw = self._native.refute(names, columns, suite, **kwargs)
1890        return _wrap_ate(raw, prepared=self)
1891
1892
1893__all__ = [
1894    "AnalysisResult",
1895    "ConflictSummaryView",
1896    "EstimateView",
1897    "MediationView",
1898    "IdentificationView",
1899    "IdentifyResult",
1900    "NativeAnalysisResult",
1901    "PerformanceView",
1902    "PhysicalPlanView",
1903    "PlanView",
1904    "PosteriorView",
1905    "PredictiveCheckReport",
1906    "PreparedAnalysis",
1907    "PriorSensitivityReport",
1908    "TemporalAnalysisResult",
1909    "ValidationView",
1910    "analyze",
1911    "analyze_many",
1912    "identify",
1913]
@dataclass(frozen=True)
class AnalysisResult:
219@dataclass(frozen=True)
220class AnalysisResult:
221    """Nested analysis result matching the Rust facade sections."""
222
223    identification: IdentificationView
224    estimate: EstimateView
225    posterior: PosteriorView | None
226    validation: ValidationView
227    performance: PerformanceView
228    diagnostics: list[str]
229    provenance: dict[str, Any]
230    mediation: MediationView | None = None
231    plan: PlanView | None = None
232    _raw: Any = None
233    _prepared: Any = None
234
235    @property
236    def effect(self) -> float:
237        """Primary scalar effect (mediation total when present, else estimate ATE/mean)."""
238        if self.mediation is not None and self.mediation.total is not None:
239            return float(self.mediation.total)
240        if self.estimate.mediation is not None and self.estimate.mediation.total is not None:
241            return float(self.estimate.mediation.total)
242        return self.estimate.ate
243
244    @property
245    def ate(self) -> float:
246        """Alias for :attr:`effect` (prefer ``effect`` for non-ATE queries)."""
247        return self.effect
248
249    def refresh(
250        self,
251        data: Mapping[str, Any] | Any,
252        *,
253        seed: int = 1,
254        threads: int = 1,
255    ) -> AnalysisResult:
256        """Re-estimate on new data via the retained prepared handle.
257
258        Only results from :meth:`PreparedAnalysis.estimate` / ``refresh`` support
259        this. One-shot :func:`analyze` results raise ``TypeError``.
260        """
261        if self._prepared is None:
262            raise TypeError(
263                "AnalysisResult.refresh requires a result from PreparedAnalysis; "
264                "use PreparedAnalysis.prepare(...) then estimate/refresh"
265            )
266        return self._prepared.estimate(data, seed=seed, threads=threads)
267
268    def refute(
269        self,
270        data: Mapping[str, Any] | Any,
271        suite: Refute | Literal["placebo", "full", "cheap"] | bool | str = "placebo",
272        *,
273        seed: int = 1,
274        threads: int = 1,
275        cancel: Any | None = None,
276    ) -> AnalysisResult:
277        """Second-click refute via the retained prepared handle."""
278        if self._prepared is None:
279            raise TypeError(
280                "AnalysisResult.refute requires a result from PreparedAnalysis; "
281                "use PreparedAnalysis.prepare(...) then estimate"
282            )
283        if isinstance(suite, Refute):
284            suite = str(suite)
285        return self._prepared.refute(
286            data, suite, seed=seed, threads=threads, cancel=cancel
287        )

Nested analysis result matching the Rust facade sections.

AnalysisResult( identification: IdentificationView, estimate: EstimateView, posterior: PosteriorView | None, validation: ValidationView, performance: PerformanceView, diagnostics: list[str], provenance: 'dict[str, Any]', mediation: MediationView | None = None, plan: PlanView | None = None, _raw: 'Any' = None, _prepared: 'Any' = None)
identification: IdentificationView
estimate: EstimateView
posterior: PosteriorView | None
validation: ValidationView
performance: PerformanceView
diagnostics: list[str]
provenance: 'dict[str, Any]'
mediation: MediationView | None = None
plan: PlanView | None = None
effect: float
235    @property
236    def effect(self) -> float:
237        """Primary scalar effect (mediation total when present, else estimate ATE/mean)."""
238        if self.mediation is not None and self.mediation.total is not None:
239            return float(self.mediation.total)
240        if self.estimate.mediation is not None and self.estimate.mediation.total is not None:
241            return float(self.estimate.mediation.total)
242        return self.estimate.ate

Primary scalar effect (mediation total when present, else estimate ATE/mean).

ate: float
244    @property
245    def ate(self) -> float:
246        """Alias for :attr:`effect` (prefer ``effect`` for non-ATE queries)."""
247        return self.effect

Alias for effect (prefer effect for non-ATE queries).

def refresh( self, data: 'Mapping[str, Any] | Any', *, seed: int = 1, threads: int = 1) -> AnalysisResult:
249    def refresh(
250        self,
251        data: Mapping[str, Any] | Any,
252        *,
253        seed: int = 1,
254        threads: int = 1,
255    ) -> AnalysisResult:
256        """Re-estimate on new data via the retained prepared handle.
257
258        Only results from :meth:`PreparedAnalysis.estimate` / ``refresh`` support
259        this. One-shot :func:`analyze` results raise ``TypeError``.
260        """
261        if self._prepared is None:
262            raise TypeError(
263                "AnalysisResult.refresh requires a result from PreparedAnalysis; "
264                "use PreparedAnalysis.prepare(...) then estimate/refresh"
265            )
266        return self._prepared.estimate(data, seed=seed, threads=threads)

Re-estimate on new data via the retained prepared handle.

Only results from PreparedAnalysis.estimate() / refresh support this. One-shot analyze() results raise TypeError.

def refute( self, data: 'Mapping[str, Any] | Any', suite: "Refute | Literal['placebo', 'full', 'cheap'] | bool | str" = 'placebo', *, seed: int = 1, threads: int = 1, cancel: 'Any | None' = None) -> AnalysisResult:
268    def refute(
269        self,
270        data: Mapping[str, Any] | Any,
271        suite: Refute | Literal["placebo", "full", "cheap"] | bool | str = "placebo",
272        *,
273        seed: int = 1,
274        threads: int = 1,
275        cancel: Any | None = None,
276    ) -> AnalysisResult:
277        """Second-click refute via the retained prepared handle."""
278        if self._prepared is None:
279            raise TypeError(
280                "AnalysisResult.refute requires a result from PreparedAnalysis; "
281                "use PreparedAnalysis.prepare(...) then estimate"
282            )
283        if isinstance(suite, Refute):
284            suite = str(suite)
285        return self._prepared.refute(
286            data, suite, seed=seed, threads=threads, cancel=cancel
287        )

Second-click refute via the retained prepared handle.

@dataclass(frozen=True)
class ConflictSummaryView:
104@dataclass(frozen=True)
105class ConflictSummaryView:
106    """Applied external-prior alphas after conflict shrink."""
107
108    source_ids: list[str]
109    alphas_requested: list[float]
110    alphas_applied: list[float]

Applied external-prior alphas after conflict shrink.

ConflictSummaryView( source_ids: list[str], alphas_requested: list[float], alphas_applied: list[float])
source_ids: list[str]
alphas_requested: list[float]
alphas_applied: list[float]
@dataclass(frozen=True)
class EstimateView:
 92@dataclass(frozen=True)
 93class EstimateView:
 94    ate: float
 95    se_analytic: float
 96    se_bootstrap: float | None
 97    estimator_id: str
 98    method: str
 99    overlap_ess: float | None = None
100    overlap_propensity_min: float | None = None
101    mediation: MediationView | None = None
EstimateView( ate: float, se_analytic: float, se_bootstrap: float | None, estimator_id: str, method: str, overlap_ess: float | None = None, overlap_propensity_min: float | None = None, mediation: MediationView | None = None)
ate: float
se_analytic: float
se_bootstrap: float | None
estimator_id: str
method: str
overlap_ess: float | None = None
overlap_propensity_min: float | None = None
mediation: MediationView | None = None
@dataclass(frozen=True)
class MediationView:
85@dataclass(frozen=True)
86class MediationView:
87    total: float | None
88    direct: float | None
89    mediated: float | None
MediationView(total: float | None, direct: float | None, mediated: float | None)
total: float | None
direct: float | None
mediated: float | None
@dataclass(frozen=True)
class IdentificationView:
76@dataclass(frozen=True)
77class IdentificationView:
78    status: str
79    method: str
80    adjustment_set: list[str]
81    assumption_count: int
82    derivation_step_count: int
IdentificationView( status: str, method: str, adjustment_set: list[str], assumption_count: int, derivation_step_count: int)
status: str
method: str
adjustment_set: list[str]
assumption_count: int
derivation_step_count: int
@dataclass(frozen=True)
class IdentifyResult:
935@dataclass(frozen=True)
936class IdentifyResult:
937    """Identify-only result (no estimate)."""
938
939    status: str
940    method: str
941    adjustment_set: list[str]

Identify-only result (no estimate).

IdentifyResult(status: str, method: str, adjustment_set: list[str])
status: str
method: str
adjustment_set: list[str]
NativeAnalysisResult = <class 'builtins.AnalysisResult'>
@dataclass(frozen=True)
class PerformanceView:
177@dataclass(frozen=True)
178class PerformanceView:
179    plan_id: str | None = None
180    modality: str | None = None
181    peak_memory_bytes: int | None = None
182    latency_mode: str | None = None
183    wall_time_ns: int | None = None
184    bootstrap_replicates_requested: int | None = None
185    bootstrap_replicates_ok: int | None = None
186    n_draws: int | None = None
187    cancelled: bool = False
188    early_stopped: bool = False
189    stage_timings: dict[str, int] | None = None
PerformanceView( plan_id: str | None = None, modality: str | None = None, peak_memory_bytes: int | None = None, latency_mode: str | None = None, wall_time_ns: int | None = None, bootstrap_replicates_requested: int | None = None, bootstrap_replicates_ok: int | None = None, n_draws: int | None = None, cancelled: bool = False, early_stopped: bool = False, stage_timings: dict[str, int] | None = None)
plan_id: str | None = None
modality: str | None = None
peak_memory_bytes: int | None = None
latency_mode: str | None = None
wall_time_ns: int | None = None
bootstrap_replicates_requested: int | None = None
bootstrap_replicates_ok: int | None = None
n_draws: int | None = None
cancelled: bool = False
early_stopped: bool = False
stage_timings: dict[str, int] | None = None
@dataclass(frozen=True)
class PhysicalPlanView:
205@dataclass(frozen=True)
206class PhysicalPlanView:
207    """Physical-plan highlights from prepare (layouts / threads / kernels)."""
208
209    plan_id: str
210    estimated_peak_memory_bytes: int | None = None
211    workspace_bytes: int | None = None
212    batch_size: int | None = None
213    worker_threads: int = 0
214    expected_python_crossings: int = 0
215    deterministic_reductions: bool = True
216    kernels: str | None = None

Physical-plan highlights from prepare (layouts / threads / kernels).

PhysicalPlanView( plan_id: str, estimated_peak_memory_bytes: int | None = None, workspace_bytes: int | None = None, batch_size: int | None = None, worker_threads: int = 0, expected_python_crossings: int = 0, deterministic_reductions: bool = True, kernels: str | None = None)
plan_id: str
estimated_peak_memory_bytes: int | None = None
workspace_bytes: int | None = None
batch_size: int | None = None
worker_threads: int = 0
expected_python_crossings: int = 0
deterministic_reductions: bool = True
kernels: str | None = None
@dataclass(frozen=True)
class PlanView:
192@dataclass(frozen=True)
193class PlanView:
194    """Logical-plan summary (semantics; inspect before/after estimate)."""
195
196    plan_id: str
197    modality: str | None = None
198    discovery_algorithm: str | None = None
199    graph_review_required: bool = False
200    identifier: str | None = None
201    estimator: str | None = None
202    validation_suite: str | None = None

Logical-plan summary (semantics; inspect before/after estimate).

PlanView( plan_id: str, modality: str | None = None, discovery_algorithm: str | None = None, graph_review_required: bool = False, identifier: str | None = None, estimator: str | None = None, validation_suite: str | None = None)
plan_id: str
modality: str | None = None
discovery_algorithm: str | None = None
graph_review_required: bool = False
identifier: str | None = None
estimator: str | None = None
validation_suite: str | None = None
@dataclass(frozen=True)
class PosteriorView:
113@dataclass(frozen=True)
114class PosteriorView:
115    effect_mean: float | None
116    effect_sd: float | None
117    q025: float | None
118    q975: float | None
119    n_draws: int | None
120    p_below_zero: float | None
121    backend: str | None
122    artifact: bytes | list[int] | None = None
123    unidentified_mass: float | None = None
124    envelope: EffectEnvelope | None = None
125    conflict: ConflictSummaryView | None = None
PosteriorView( effect_mean: float | None, effect_sd: float | None, q025: float | None, q975: float | None, n_draws: int | None, p_below_zero: float | None, backend: str | None, artifact: bytes | list[int] | None = None, unidentified_mass: float | None = None, envelope: 'EffectEnvelope | None' = None, conflict: ConflictSummaryView | None = None)
effect_mean: float | None
effect_sd: float | None
q025: float | None
q975: float | None
n_draws: int | None
p_below_zero: float | None
backend: str | None
artifact: bytes | list[int] | None = None
unidentified_mass: float | None = None
envelope: 'EffectEnvelope | None' = None
conflict: ConflictSummaryView | None = None
@dataclass(frozen=True)
class PredictiveCheckReport:
141@dataclass(frozen=True)
142class PredictiveCheckReport:
143    """Prior or posterior predictive check summary."""
144
145    kind: str
146    observed: float
147    predictive_mean: float
148    predictive_sd: float
149    p_value: float
150    n_sims: int

Prior or posterior predictive check summary.

PredictiveCheckReport( kind: str, observed: float, predictive_mean: float, predictive_sd: float, p_value: float, n_sims: int)
kind: str
observed: float
predictive_mean: float
predictive_sd: float
p_value: float
n_sims: int
class PreparedAnalysis:
1754class PreparedAnalysis:
1755    """Compile-once / re-estimate-many handle for static AverageEffect on a DAG.
1756
1757    Use for interactive sessions: prepare with a fixed graph/query/estimator,
1758    then call :meth:`estimate` or :meth:`refresh` when the table changes
1759    (same schema). Prefer this over fresh :func:`analyze` on every click.
1760    For streaming append + incremental OLS, use :class:`antecedent.CausalState`.
1761    """
1762
1763    def __init__(self, native: Any) -> None:
1764        self._native = native
1765
1766    @classmethod
1767    def prepare(
1768        cls,
1769        data: Mapping[str, Any] | Any,
1770        *,
1771        query: AverageEffect,
1772        graph: Dag | Sequence[tuple[str, str]],
1773        inference: Frequentist | Bayesian | None = None,
1774        identifier: str | Identifier | None = None,
1775        estimator: str | Estimator | None = None,
1776        refute: bool | Refute | Literal["full", "placebo", "none", "cheap"] = False,
1777        seed: int = 1,
1778        bootstrap: int | None = None,
1779        threads: int = 1,
1780        latency: Latency | Literal["interactive", "standard", "report"] | None = "interactive",
1781    ) -> PreparedAnalysis:
1782        """Compile a durable plan for static ATE on a supplied DAG."""
1783        if not isinstance(query, AverageEffect):
1784            raise TypeError("PreparedAnalysis supports AverageEffect only")
1785        inference = inference or Frequentist()
1786        if isinstance(identifier, Identifier):
1787            identifier = str(identifier)
1788        if isinstance(estimator, Estimator):
1789            estimator = str(estimator)
1790        if isinstance(latency, Latency):
1791            latency = str(latency)  # type: ignore[assignment]
1792        if isinstance(refute, Refute):
1793            refute = str(refute)  # type: ignore[assignment]
1794        bootstrap, refute = _resolve_latency_budget(latency, bootstrap, refute)
1795        names, columns = as_columns(data)  # type: ignore[arg-type]
1796        edges = _static_edges(graph)  # type: ignore[arg-type]
1797        bayes_kw: dict[str, Any] = {}
1798        if isinstance(inference, Bayesian):
1799            bayes_kw = _bayesian_inference_kwargs(inference)
1800            inference_mode = str(bayes_kw.pop("inference"))
1801        else:
1802            inference_mode = "frequentist"
1803        native = _NativePreparedAnalysis.prepare(
1804            names,
1805            columns,
1806            edges,
1807            query.treatment,
1808            query.outcome,
1809            control_level=query.control_level,
1810            active_level=query.active_level,
1811            identifier=identifier,
1812            estimator=estimator,
1813            inference=inference_mode,
1814            n_draws=int(bayes_kw.get("n_draws", 1000)),
1815            prior_scale=float(bayes_kw.get("prior_scale", 10.0)),
1816            refute=refute,
1817            seed=seed,
1818            bootstrap=bootstrap,
1819            threads=threads,
1820            latency=latency,
1821        )
1822        return cls(native)
1823
1824    @property
1825    def plan(self) -> PhysicalPlanView:
1826        """Physical-plan summary retained from prepare."""
1827        raw = self._native.plan_summary()
1828        return PhysicalPlanView(
1829            plan_id=str(raw.get("plan_id", "")),
1830            estimated_peak_memory_bytes=(
1831                int(raw["estimated_peak_memory_bytes"])
1832                if "estimated_peak_memory_bytes" in raw
1833                else None
1834            ),
1835            workspace_bytes=(
1836                int(raw["workspace_bytes"]) if "workspace_bytes" in raw else None
1837            ),
1838            batch_size=int(raw["batch_size"]) if "batch_size" in raw else None,
1839            worker_threads=int(raw.get("worker_threads", 0)),
1840            expected_python_crossings=int(raw.get("expected_python_crossings", 0)),
1841            deterministic_reductions=str(raw.get("deterministic_reductions", "true")).lower()
1842            in ("1", "true"),
1843            kernels=raw.get("kernels") or None,
1844        )
1845
1846    def estimate(
1847        self,
1848        data: Mapping[str, Any] | Any,
1849        *,
1850        seed: int = 1,
1851        threads: int = 1,
1852    ) -> AnalysisResult:
1853        """Re-estimate without recompiling (same schema as prepare)."""
1854        names, columns = as_columns(data)  # type: ignore[arg-type]
1855        raw = self._native.estimate(names, columns, seed=seed, threads=threads)
1856        return _wrap_ate(raw, prepared=self)
1857
1858    def refresh(
1859        self,
1860        data: Mapping[str, Any] | Any,
1861        *,
1862        seed: int = 1,
1863        threads: int = 1,
1864    ) -> AnalysisResult:
1865        """Replace retained data and re-estimate."""
1866        names, columns = as_columns(data)  # type: ignore[arg-type]
1867        raw = self._native.refresh(names, columns, seed=seed, threads=threads)
1868        return _wrap_ate(raw, prepared=self)
1869
1870    def refute(
1871        self,
1872        data: Mapping[str, Any] | Any,
1873        suite: Refute | Literal["placebo", "full", "cheap"] | bool | str = "placebo",
1874        *,
1875        seed: int = 1,
1876        threads: int = 1,
1877        cancel: Any | None = None,
1878    ) -> AnalysisResult:
1879        """Second-click refute against the last :meth:`estimate` / :meth:`refresh`.
1880
1881        Interactive first clicks typically use ``refute=False`` or ``cheap``;
1882        call this with ``suite="placebo"`` or ``"full"`` for the deferred suite.
1883        """
1884        if isinstance(suite, Refute):
1885            suite = str(suite)
1886        names, columns = as_columns(data)  # type: ignore[arg-type]
1887        kwargs: dict[str, Any] = dict(seed=seed, threads=threads)
1888        if cancel is not None:
1889            kwargs["cancel"] = cancel
1890        raw = self._native.refute(names, columns, suite, **kwargs)
1891        return _wrap_ate(raw, prepared=self)

Compile-once / re-estimate-many handle for static AverageEffect on a DAG.

Use for interactive sessions: prepare with a fixed graph/query/estimator, then call estimate() or refresh() when the table changes (same schema). Prefer this over fresh analyze() on every click. For streaming append + incremental OLS, use antecedent.CausalState.

PreparedAnalysis(native: 'Any')
1763    def __init__(self, native: Any) -> None:
1764        self._native = native
@classmethod
def prepare( cls, data: 'Mapping[str, Any] | Any', *, query: 'AverageEffect', graph: 'Dag | Sequence[tuple[str, str]]', inference: 'Frequentist | Bayesian | None' = None, identifier: 'str | Identifier | None' = None, estimator: 'str | Estimator | None' = None, refute: "bool | Refute | Literal['full', 'placebo', 'none', 'cheap']" = False, seed: int = 1, bootstrap: int | None = None, threads: int = 1, latency: "Latency | Literal['interactive', 'standard', 'report'] | None" = 'interactive') -> PreparedAnalysis:
1766    @classmethod
1767    def prepare(
1768        cls,
1769        data: Mapping[str, Any] | Any,
1770        *,
1771        query: AverageEffect,
1772        graph: Dag | Sequence[tuple[str, str]],
1773        inference: Frequentist | Bayesian | None = None,
1774        identifier: str | Identifier | None = None,
1775        estimator: str | Estimator | None = None,
1776        refute: bool | Refute | Literal["full", "placebo", "none", "cheap"] = False,
1777        seed: int = 1,
1778        bootstrap: int | None = None,
1779        threads: int = 1,
1780        latency: Latency | Literal["interactive", "standard", "report"] | None = "interactive",
1781    ) -> PreparedAnalysis:
1782        """Compile a durable plan for static ATE on a supplied DAG."""
1783        if not isinstance(query, AverageEffect):
1784            raise TypeError("PreparedAnalysis supports AverageEffect only")
1785        inference = inference or Frequentist()
1786        if isinstance(identifier, Identifier):
1787            identifier = str(identifier)
1788        if isinstance(estimator, Estimator):
1789            estimator = str(estimator)
1790        if isinstance(latency, Latency):
1791            latency = str(latency)  # type: ignore[assignment]
1792        if isinstance(refute, Refute):
1793            refute = str(refute)  # type: ignore[assignment]
1794        bootstrap, refute = _resolve_latency_budget(latency, bootstrap, refute)
1795        names, columns = as_columns(data)  # type: ignore[arg-type]
1796        edges = _static_edges(graph)  # type: ignore[arg-type]
1797        bayes_kw: dict[str, Any] = {}
1798        if isinstance(inference, Bayesian):
1799            bayes_kw = _bayesian_inference_kwargs(inference)
1800            inference_mode = str(bayes_kw.pop("inference"))
1801        else:
1802            inference_mode = "frequentist"
1803        native = _NativePreparedAnalysis.prepare(
1804            names,
1805            columns,
1806            edges,
1807            query.treatment,
1808            query.outcome,
1809            control_level=query.control_level,
1810            active_level=query.active_level,
1811            identifier=identifier,
1812            estimator=estimator,
1813            inference=inference_mode,
1814            n_draws=int(bayes_kw.get("n_draws", 1000)),
1815            prior_scale=float(bayes_kw.get("prior_scale", 10.0)),
1816            refute=refute,
1817            seed=seed,
1818            bootstrap=bootstrap,
1819            threads=threads,
1820            latency=latency,
1821        )
1822        return cls(native)

Compile a durable plan for static ATE on a supplied DAG.

plan: PhysicalPlanView
1824    @property
1825    def plan(self) -> PhysicalPlanView:
1826        """Physical-plan summary retained from prepare."""
1827        raw = self._native.plan_summary()
1828        return PhysicalPlanView(
1829            plan_id=str(raw.get("plan_id", "")),
1830            estimated_peak_memory_bytes=(
1831                int(raw["estimated_peak_memory_bytes"])
1832                if "estimated_peak_memory_bytes" in raw
1833                else None
1834            ),
1835            workspace_bytes=(
1836                int(raw["workspace_bytes"]) if "workspace_bytes" in raw else None
1837            ),
1838            batch_size=int(raw["batch_size"]) if "batch_size" in raw else None,
1839            worker_threads=int(raw.get("worker_threads", 0)),
1840            expected_python_crossings=int(raw.get("expected_python_crossings", 0)),
1841            deterministic_reductions=str(raw.get("deterministic_reductions", "true")).lower()
1842            in ("1", "true"),
1843            kernels=raw.get("kernels") or None,
1844        )

Physical-plan summary retained from prepare.

def estimate( self, data: 'Mapping[str, Any] | Any', *, seed: int = 1, threads: int = 1) -> AnalysisResult:
1846    def estimate(
1847        self,
1848        data: Mapping[str, Any] | Any,
1849        *,
1850        seed: int = 1,
1851        threads: int = 1,
1852    ) -> AnalysisResult:
1853        """Re-estimate without recompiling (same schema as prepare)."""
1854        names, columns = as_columns(data)  # type: ignore[arg-type]
1855        raw = self._native.estimate(names, columns, seed=seed, threads=threads)
1856        return _wrap_ate(raw, prepared=self)

Re-estimate without recompiling (same schema as prepare).

def refresh( self, data: 'Mapping[str, Any] | Any', *, seed: int = 1, threads: int = 1) -> AnalysisResult:
1858    def refresh(
1859        self,
1860        data: Mapping[str, Any] | Any,
1861        *,
1862        seed: int = 1,
1863        threads: int = 1,
1864    ) -> AnalysisResult:
1865        """Replace retained data and re-estimate."""
1866        names, columns = as_columns(data)  # type: ignore[arg-type]
1867        raw = self._native.refresh(names, columns, seed=seed, threads=threads)
1868        return _wrap_ate(raw, prepared=self)

Replace retained data and re-estimate.

def refute( self, data: 'Mapping[str, Any] | Any', suite: "Refute | Literal['placebo', 'full', 'cheap'] | bool | str" = 'placebo', *, seed: int = 1, threads: int = 1, cancel: 'Any | None' = None) -> AnalysisResult:
1870    def refute(
1871        self,
1872        data: Mapping[str, Any] | Any,
1873        suite: Refute | Literal["placebo", "full", "cheap"] | bool | str = "placebo",
1874        *,
1875        seed: int = 1,
1876        threads: int = 1,
1877        cancel: Any | None = None,
1878    ) -> AnalysisResult:
1879        """Second-click refute against the last :meth:`estimate` / :meth:`refresh`.
1880
1881        Interactive first clicks typically use ``refute=False`` or ``cheap``;
1882        call this with ``suite="placebo"`` or ``"full"`` for the deferred suite.
1883        """
1884        if isinstance(suite, Refute):
1885            suite = str(suite)
1886        names, columns = as_columns(data)  # type: ignore[arg-type]
1887        kwargs: dict[str, Any] = dict(seed=seed, threads=threads)
1888        if cancel is not None:
1889            kwargs["cancel"] = cancel
1890        raw = self._native.refute(names, columns, suite, **kwargs)
1891        return _wrap_ate(raw, prepared=self)

Second-click refute against the last estimate() / refresh().

Interactive first clicks typically use refute=False or cheap; call this with suite="placebo" or "full" for the deferred suite.

@dataclass(frozen=True)
class PriorSensitivityReport:
153@dataclass(frozen=True)
154class PriorSensitivityReport:
155    """Prior sensitivity grid (Bayesian + ``refute="full"``).
156
157    Isotropic mode fills ``scales``; external prior-bank mode fills ``alphas``
158    (multipliers on post-conflict applied α). Exactly one mode is active.
159    """
160
161    scales: list[float]
162    effect_means: list[float]
163    effect_sds: list[float]
164    alphas: list[float] | None = None

Prior sensitivity grid (Bayesian + refute="full").

Isotropic mode fills scales; external prior-bank mode fills alphas (multipliers on post-conflict applied α). Exactly one mode is active.

PriorSensitivityReport( scales: list[float], effect_means: list[float], effect_sds: list[float], alphas: list[float] | None = None)
scales: list[float]
effect_means: list[float]
effect_sds: list[float]
alphas: list[float] | None = None
TemporalAnalysisResult = <class 'builtins.AnalysisResult'>
@dataclass(frozen=True)
class ValidationView:
167@dataclass(frozen=True)
168class ValidationView:
169    passed: bool
170    ran: bool
171    count: int
172    prior_predictive: PredictiveCheckReport | None = None
173    posterior_predictive: PredictiveCheckReport | None = None
174    prior_sensitivity: PriorSensitivityReport | None = None
ValidationView( passed: bool, ran: bool, count: int, prior_predictive: PredictiveCheckReport | None = None, posterior_predictive: PredictiveCheckReport | None = None, prior_sensitivity: PriorSensitivityReport | None = None)
passed: bool
ran: bool
count: int
prior_predictive: PredictiveCheckReport | None = None
posterior_predictive: PredictiveCheckReport | None = None
prior_sensitivity: PriorSensitivityReport | None = None
def analyze( data: 'Mapping[str, Any] | Any | Sequence[Mapping[str, Any] | Any]', *, query: 'AverageEffect | PulseEffect | SustainedEffect | InterventionalDistribution | PathSpecificEffect | ConditionalEffect | MediationEffect | Counterfactual | TemporalMediationEffect', graph: 'Dag | Cpdag | Pag | Admg | TemporalDag | TemporalCpdag | TemporalPag | Sequence[tuple[str, str]] | Sequence[tuple[str, int, str, int]] | None' = None, discovery: 'Any | None' = None, inference: 'Frequentist | Bayesian | None' = None, identifier: 'str | Identifier | None' = None, estimator: 'str | Estimator | None' = None, refute: "bool | Refute | Literal['full', 'placebo', 'none', 'cheap']" = True, validators: 'Sequence[Any] | None' = None, accept_discovered: bool = True, seed: int = 1, bootstrap: int | None = None, threads: int = 1, regimes: 'Sequence[int] | None' = None, running_variable: str | None = None, cutoff: float | None = None, bandwidth: float | None = None, population_registry: 'Any | None' = None, latency: "Latency | Literal['interactive', 'standard', 'report'] | None" = None, cancel: 'Any | None' = None, on_progress: 'Any | None' = None, on_stage: 'Any | None' = None, return_posterior_artifact: bool = False) -> AnalysisResult:
 976def analyze(
 977    data: Mapping[str, Any] | Any | Sequence[Mapping[str, Any] | Any],
 978    *,
 979    query: (
 980        AverageEffect
 981        | PulseEffect
 982        | SustainedEffect
 983        | InterventionalDistribution
 984        | PathSpecificEffect
 985        | ConditionalEffect
 986        | MediationEffect
 987        | Counterfactual
 988        | TemporalMediationEffect
 989    ),
 990    graph: (
 991        Dag
 992        | Cpdag
 993        | Pag
 994        | Admg
 995        | TemporalDag
 996        | TemporalCpdag
 997        | TemporalPag
 998        | Sequence[tuple[str, str]]
 999        | Sequence[tuple[str, int, str, int]]
1000        | None
1001    ) = None,
1002    discovery: Any | None = None,
1003    inference: Frequentist | Bayesian | None = None,
1004    identifier: str | Identifier | None = None,
1005    estimator: str | Estimator | None = None,
1006    refute: bool | Refute | Literal["full", "placebo", "none", "cheap"] = True,
1007    validators: Sequence[Any] | None = None,
1008    accept_discovered: bool = True,
1009    seed: int = 1,
1010    bootstrap: int | None = None,
1011    threads: int = 1,
1012    regimes: Sequence[int] | None = None,
1013    running_variable: str | None = None,
1014    cutoff: float | None = None,
1015    bandwidth: float | None = None,
1016    population_registry: Any | None = None,
1017    latency: Latency | Literal["interactive", "standard", "report"] | None = None,
1018    cancel: Any | None = None,
1019    on_progress: Any | None = None,
1020    on_stage: Any | None = None,
1021    return_posterior_artifact: bool = False,
1022) -> AnalysisResult:
1023    """Identify then estimate a causal effect.
1024
1025    Parameters
1026    ----------
1027    data:
1028        Mapping of column name → 1-d float array, a pandas ``DataFrame``,
1029        Arrow CDI exporters (PyArrow columns / table), or a
1030        ``antecedent.data`` frame (``EventFrame`` / ``PanelFrame`` / ``MultiEnvFrame``).
1031        For ``discovery=JPCMCIPlus(...)``, pass a sequence of environment frames
1032        or a ``MultiEnvFrame``.
1033    query:
1034        ``AverageEffect``, ``PulseEffect`` / ``SustainedEffect``,
1035        ``InterventionalDistribution``, ``PathSpecificEffect``,
1036        ``MediationEffect``, ``Counterfactual``, or ``TemporalMediationEffect``.
1037    graph:
1038        ``Dag`` / ``Cpdag`` / ``Pag`` / ``Admg`` / ``TemporalDag`` /
1039        ``TemporalCpdag`` / ``TemporalPag``, or an edge list. Lagged edges
1040        ``(from, from_lag, to, to_lag)`` are required for temporal queries
1041        without ``discovery``. Fully oriented CPDAGs run as DAGs; incomplete
1042        CPDAGs require review. ADMGs without bidirected edges coerce to DAGs;
1043        ADMGs with latents use general ID + functional effect.
1044    discovery:
1045        Static: ``PC`` / ``GES`` / ``LiNGAM`` / ``NOTEARS`` / ``FCI`` / ``RFCI``.
1046        Temporal: ``PCMCI`` / ``PCMCIPlus`` / ``LPCMCI`` / ``JPCMCIPlus`` / ``RPCMCI``.
1047        One-shot script convenience — discovery runs at compile time. For
1048        interactive / spreadsheet estimate clicks, discover once into
1049        :class:`antecedent.AcceptedGraph` (or hold a reviewed graph) and pass
1050        ``graph=`` with ``latency="interactive"`` instead. Combining
1051        ``discovery=`` with ``latency="interactive"`` raises
1052        :class:`CausalUnsupportedError`.
1053    latency:
1054        Optional compute tier (``interactive`` / ``standard`` / ``report``).
1055        Maps to known-equivalent bootstrap / refute / draws; explicit
1056        ``bootstrap=`` / ``refute=`` always win. Interactive refuses inline
1057        ``discovery=`` (artifact-first UX).
1058    cancel:
1059        Optional ``CancellationToken`` from ``antecedent._native``.
1060    on_progress:
1061        Optional ``(fraction: float, stage: str) -> None`` callback.
1062    on_stage:
1063        Optional ``(stage: str, payload: dict) -> None`` progressive stage
1064        callback (identify → estimate_point → uncertainty → validate).
1065    return_posterior_artifact:
1066        When ``True`` and inference is Bayesian, attach full posterior draw
1067        bytes on ``result.posterior.artifact`` (for download / sequential-prior
1068        hydrate). Default ``False``: UI summaries only.
1069    """
1070    if isinstance(identifier, Identifier):
1071        identifier = str(identifier)
1072    if isinstance(estimator, Estimator):
1073        estimator = str(estimator)
1074    if isinstance(latency, Latency):
1075        latency = str(latency)  # type: ignore[assignment]
1076    if isinstance(refute, Refute):
1077        refute = str(refute)  # type: ignore[assignment]
1078    inference = inference or Frequentist()
1079    bootstrap, refute = _resolve_latency_budget(latency, bootstrap, refute)
1080
1081    if discovery is not None and latency == "interactive":
1082        raise CausalUnsupportedError(
1083            "discovery= is not on the interactive estimate path; "
1084            "call discover_* once, accept into AcceptedGraph, then "
1085            "analyze(graph=..., latency='interactive')"
1086        )
1087
1088    if isinstance(query, ConditionalEffect):
1089        if isinstance(inference, Bayesian):
1090            raise TypeError("ConditionalEffect does not support inference=Bayesian(...)")
1091        if discovery is not None:
1092            raise ValueError("ConditionalEffect does not support discovery=")
1093        names, columns = as_columns(data)  # type: ignore[arg-type]
1094        edges = _static_edges(graph)  # type: ignore[arg-type]
1095        raw = _analyze_conditional(
1096            names,
1097            columns,
1098            edges,
1099            query.treatment,
1100            query.outcome,
1101            query.modifier,
1102            control_level=query.control_level,
1103            active_level=query.active_level,
1104            refute=refute,
1105            validators=list(validators) if validators is not None else None,
1106            seed=seed,
1107            bootstrap=bootstrap,
1108            threads=threads,
1109        )
1110        return _wrap_ate(raw)
1111
1112    if isinstance(query, TemporalMediationEffect):
1113        if isinstance(inference, Bayesian):
1114            raise TypeError("TemporalMediationEffect does not support inference=Bayesian(...)")
1115        if discovery is not None:
1116            raise ValueError("TemporalMediationEffect does not support discovery=")
1117        names, columns = as_columns(data)  # type: ignore[arg-type]
1118        lagged = _lagged_edges(graph)  # type: ignore[arg-type]
1119        raw = _analyze_temporal_mediation(
1120            names,
1121            columns,
1122            lagged,
1123            query.treatment,
1124            query.mediator,
1125            query.outcome,
1126            contrast=query.contrast,
1127            control_level=query.control_level,
1128            active_level=query.active_level,
1129            seed=seed,
1130            bootstrap=bootstrap,
1131            threads=threads,
1132        )
1133        return _wrap_temporal(raw)
1134
1135    if isinstance(query, MediationEffect):
1136        if discovery is not None:
1137            raise ValueError("MediationEffect does not support discovery=")
1138        edges = _static_edges(graph)  # type: ignore[arg-type]
1139        names, columns = as_columns(data)  # type: ignore[arg-type]
1140        raw = _analyze_mediation(
1141            names,
1142            columns,
1143            edges,
1144            query.treatment,
1145            query.outcome,
1146            list(query.mediators),
1147            contrast=query.contrast,
1148            control_level=query.control_level,
1149            active_level=query.active_level,
1150            refute=refute,
1151            seed=seed,
1152            bootstrap=bootstrap,
1153            threads=threads,
1154        )
1155        return _wrap_ate(raw)
1156
1157    if isinstance(query, Counterfactual):
1158        from ._native import counterfactual_ite
1159
1160        if discovery is not None:
1161            raise ValueError("Counterfactual does not support discovery=")
1162        edges = _static_edges(graph)  # type: ignore[arg-type]
1163        names, columns = as_columns(data)  # type: ignore[arg-type]
1164        ite = counterfactual_ite(
1165            names,
1166            columns,
1167            edges,
1168            query.treatment,
1169            query.outcome,
1170            query.active_level,
1171            query.control_level,
1172            seed=seed,
1173            threads=threads,
1174        )
1175        return AnalysisResult(
1176            identification=IdentificationView(
1177                status="gcm.parametric",
1178                method="counterfactual.ite",
1179                adjustment_set=[],
1180                assumption_count=0,
1181                derivation_step_count=0,
1182            ),
1183            estimate=EstimateView(
1184                ate=float(ite.mean_ite),
1185                se_analytic=float("nan"),
1186                se_bootstrap=None,
1187                estimator_id="gcm.ite",
1188                method="counterfactual.ite",
1189            ),
1190            posterior=None,
1191            validation=ValidationView(passed=False, ran=False, count=0),
1192            performance=PerformanceView(
1193                plan_id="counterfactual.ite",
1194                modality="static",
1195                peak_memory_bytes=0,
1196            ),
1197            diagnostics=[],
1198            provenance={"noise_inference": getattr(ite, "noise_inference", None)},
1199            _raw=ite,
1200        )
1201
1202    if isinstance(query, InterventionalDistribution):
1203        if discovery is not None:
1204            edges = _resolve_static_discovery_edges(
1205                data, discovery, accept_discovered, seed, threads
1206            )
1207        else:
1208            edges = _static_edges(graph)  # type: ignore[arg-type]
1209        names, columns = as_columns(data)  # type: ignore[arg-type]
1210        raw = _analyze_distribution(
1211            names,
1212            columns,
1213            edges,
1214            query.outcome,
1215            dict(query.interventions),
1216            conditioning=list(query.conditioning) or None,
1217            seed=seed,
1218            threads=threads,
1219        )
1220        return _wrap_ate(raw)
1221
1222    if isinstance(query, PathSpecificEffect):
1223        if discovery is not None:
1224            edges = _resolve_static_discovery_edges(
1225                data, discovery, accept_discovered, seed, threads
1226            )
1227        else:
1228            edges = _static_edges(graph)  # type: ignore[arg-type]
1229        names, columns = as_columns(data)  # type: ignore[arg-type]
1230        raw = _analyze_path_specific(
1231            names,
1232            columns,
1233            edges,
1234            query.treatment,
1235            query.outcome,
1236            control_level=query.control_level,
1237            active_level=query.active_level,
1238            path_nodes=list(query.path_nodes) if query.path_nodes is not None else None,
1239            max_paths=query.max_paths,
1240            max_len=query.max_len,
1241            seed=seed,
1242            bootstrap=bootstrap,
1243            threads=threads,
1244        )
1245        return _wrap_ate(raw)
1246
1247    if discovery is not None and isinstance(
1248        discovery, _STATIC_DISCOVERY + _GRAPH_POSTERIOR_DISCOVERY
1249    ):
1250        if not isinstance(query, AverageEffect):
1251            raise ValueError(
1252                f"discovery={type(discovery).__name__}(...) requires AverageEffect"
1253            )
1254        if isinstance(discovery, _GRAPH_POSTERIOR_DISCOVERY) and not isinstance(
1255            inference, Bayesian
1256        ):
1257            raise TypeError(
1258                "graph-posterior discovery requires inference=Bayesian(...) "
1259                "for effect mixture"
1260            )
1261        names, columns = as_columns(data)  # type: ignore[arg-type]
1262        cfg = _discovery_algorithm(discovery)
1263        bayes_kw: dict[str, Any] = {}
1264        if isinstance(inference, Bayesian):
1265            bayes_kw = _bayesian_inference_kwargs(inference)
1266        raw = _analyze_ate_discover(
1267            names,
1268            columns,
1269            query.treatment,
1270            query.outcome,
1271            algorithm=cfg["algorithm"],
1272            alpha=cfg.get("alpha", 0.05),
1273            fdr=cfg.get("fdr", True),
1274            max_cond_size=cfg.get("max_cond_size", 2),
1275            prune_threshold=cfg.get("prune_threshold", 0.0),
1276            l1=cfg.get("lambda", 0.1),
1277            threshold=cfg.get("threshold", 0.3),
1278            standardize=cfg.get("standardize", True),
1279            accept_discovered=accept_discovered,
1280            control_level=query.control_level,
1281            active_level=query.active_level,
1282            identifier=identifier,
1283            estimator=estimator,
1284            refute=refute,
1285            validators=list(validators) if validators is not None else None,
1286            ci=cfg.get("ci"),
1287            n_chains=cfg.get("n_chains", 2),
1288            n_warmup=cfg.get("n_warmup", 100),
1289            mcmc_draws=cfg.get("mcmc_draws", 200),
1290            thin=cfg.get("thin", 1),
1291            soft_weight=cfg.get("soft_weight", "none"),
1292            require_diagnostics_gate=cfg.get("require_diagnostics_gate", True),
1293            seed=seed,
1294            bootstrap=bootstrap,
1295            threads=threads,
1296            **bayes_kw,
1297        )
1298        return _wrap_ate(raw)
1299
1300    if discovery is not None and isinstance(query, AverageEffect):
1301        raise ValueError(
1302            "AverageEffect with discovery= requires a static algorithm "
1303            "(PC/GES/LiNGAM/NOTEARS/FCI/RFCI); temporal discovery needs "
1304            "PulseEffect/SustainedEffect"
1305        )
1306
1307    if isinstance(query, AverageEffect):
1308        bayes_kw: dict[str, Any] = {}
1309        if isinstance(inference, Bayesian):
1310            bayes_kw = _bayesian_inference_kwargs(inference)
1311        if estimator == "rd.sharp" or any(
1312            v is not None for v in (running_variable, cutoff, bandwidth)
1313        ):
1314            if running_variable is None or cutoff is None or bandwidth is None:
1315                raise ValueError(
1316                    "rd.sharp (or any RD kwargs) requires running_variable, cutoff, and bandwidth"
1317                )
1318            if estimator is None:
1319                estimator = "rd.sharp"
1320            if identifier is None:
1321                identifier = "rd.sharp"
1322        common = dict(
1323            treatment=query.treatment,
1324            outcome=query.outcome,
1325            control_level=query.control_level,
1326            active_level=query.active_level,
1327            identifier=identifier,
1328            estimator=estimator,
1329            refute=refute,
1330            validators=list(validators) if validators is not None else None,
1331            running_variable=running_variable,
1332            cutoff=cutoff,
1333            bandwidth=bandwidth,
1334            seed=seed,
1335            bootstrap=bootstrap,
1336            threads=threads,
1337            **bayes_kw,
1338        )
1339        if return_posterior_artifact:
1340            common["return_posterior_artifact"] = True
1341        if latency is not None:
1342            common["latency"] = latency
1343        if cancel is not None:
1344            common["cancel"] = cancel
1345        if on_progress is not None:
1346            common["on_progress"] = on_progress
1347        if on_stage is not None:
1348            common["on_stage"] = on_stage
1349        from .population import coerce_target_population, registry_wire
1350
1351        pop = coerce_target_population(
1352            getattr(query, "target_population", None)
1353        )
1354        preds, dists = registry_wire(population_registry)
1355        pop_kw: dict[str, Any] = {}
1356        if pop is not None:
1357            pop_kw["target_population"] = pop
1358        if preds:
1359            pop_kw["population_predicates"] = preds
1360        if dists:
1361            pop_kw["population_distributions"] = dists
1362        if pop_kw and isinstance(graph, (Pag, Cpdag, Admg)):
1363            raise ValueError(
1364                "target_population / population_registry currently require a Dag "
1365                "(or edge list); PAG/CPDAG/ADMG analyze paths do not accept them yet"
1366            )
1367        if isinstance(graph, Pag):
1368            names, columns = as_columns(data)  # type: ignore[arg-type]
1369            return _wrap_ate(_analyze_ate_pag(names, columns, graph, **common))
1370        if isinstance(graph, Cpdag):
1371            names, columns = as_columns(data)  # type: ignore[arg-type]
1372            return _wrap_ate(_analyze_ate_cpdag(names, columns, graph, **common))
1373        if isinstance(graph, Admg):
1374            names, columns = as_columns(data)  # type: ignore[arg-type]
1375            return _wrap_ate(_analyze_ate_admg(names, columns, graph, **common))
1376        edges = _static_edges(graph)  # type: ignore[arg-type]
1377        arrow = try_as_arrow_c_columns(data)
1378        ate_kwargs = dict(edges=edges, **common, **pop_kw)
1379        # Prefer Arrow CDI (zero-copy float64) when available. Population kwargs
1380        # still require the NumPy path until that surface is wired on CDI.
1381        use_arrow = arrow is not None and not pop_kw
1382        if use_arrow:
1383            names, columns = arrow
1384            raw = _analyze_ate_arrow_c(names, columns, **ate_kwargs)
1385        else:
1386            names, columns = as_columns(data)  # type: ignore[arg-type]
1387            raw = _analyze_ate(names, columns, **ate_kwargs)
1388        return _wrap_ate(raw)
1389
1390    if isinstance(query, (PulseEffect, SustainedEffect)):
1391        policy = "sustained" if isinstance(query, SustainedEffect) else "pulse"
1392        _reject_unsupported_temporal(
1393            inference=inference, refute=refute, validators=validators
1394        )
1395        bayes_kw = _temporal_inference_kwargs(inference)
1396        if isinstance(data, EventFrame):
1397            if discovery is not None:
1398                if isinstance(discovery, JPCMCIPlus):
1399                    raise TypeError(
1400                        "EventFrame does not support discovery=JPCMCIPlus(...); "
1401                        "use MultiEnvFrame or PanelFrame for multi-environment discovery"
1402                    )
1403                if isinstance(discovery, DbnPosterior):
1404                    if not isinstance(inference, Bayesian):
1405                        raise TypeError(
1406                            "EventFrame discovery=DbnPosterior(...) requires inference=Bayesian(...)"
1407                        )
1408                elif not isinstance(discovery, (PCMCI, PCMCIPlus, LPCMCI, RPCMCI)):
1409                    raise TypeError(
1410                        f"EventFrame discovery expects PCMCI/PCMCIPlus/LPCMCI/RPCMCI/DbnPosterior, "
1411                        f"got {type(discovery)!r}"
1412                    )
1413                cfg = _discovery_algorithm(discovery)
1414                raw = _analyze_events(
1415                    data.names,
1416                    data.columns,
1417                    data.event_times_ns.tolist(),
1418                    data.align_interval_ns,
1419                    [],  # discovery path ignores edges
1420                    query.treatment,
1421                    query.outcome,
1422                    treatment_lag=query.treatment_lag,
1423                    horizon_steps=query.horizon_steps,
1424                    active_level=query.active_level,
1425                    policy=policy,
1426                    **bayes_kw,
1427                    refute=refute,
1428                    validators=list(validators) if validators is not None else None,
1429                    seed=seed,
1430                    bootstrap=bootstrap,
1431                    threads=threads,
1432                    algorithm=cfg["algorithm"],
1433                    max_lag=cfg.get("max_lag", 1),
1434                    alpha=cfg.get("alpha", 0.05),
1435                    fdr=cfg.get("fdr", True),
1436                    accept_discovered=accept_discovered,
1437                    regimes=list(regimes) if regimes is not None else None,
1438                    **{
1439                        k: cfg[k]
1440                        for k in ("n_chains", "n_warmup", "mcmc_draws", "force_mcmc", "ci")
1441                        if k in cfg
1442                    },
1443                )
1444                return _wrap_temporal(raw)
1445            lagged = _lagged_edges(graph)  # type: ignore[arg-type]
1446            raw = _analyze_events(
1447                data.names,
1448                data.columns,
1449                data.event_times_ns.tolist(),
1450                data.align_interval_ns,
1451                lagged,
1452                query.treatment,
1453                query.outcome,
1454                treatment_lag=query.treatment_lag,
1455                horizon_steps=query.horizon_steps,
1456                active_level=query.active_level,
1457                policy=policy,
1458                **bayes_kw,
1459                refute=refute,
1460                validators=list(validators) if validators is not None else None,
1461                seed=seed,
1462                bootstrap=bootstrap,
1463                threads=threads,
1464            )
1465            return _wrap_temporal(raw)
1466        if isinstance(data, PanelFrame):
1467            if discovery is not None:
1468                if isinstance(discovery, JPCMCIPlus):
1469                    cfg = _discovery_algorithm(discovery)
1470                    raw = _analyze_panel_discover(
1471                        data.names,
1472                        data.unit_columns,
1473                        data.unit_ids,
1474                        query.treatment,
1475                        query.outcome,
1476                        max_lag=cfg["max_lag"],
1477                        alpha=cfg["alpha"],
1478                        fdr=cfg["fdr"],
1479                        accept_discovered=accept_discovered,
1480                        treatment_lag=query.treatment_lag,
1481                        horizon_steps=query.horizon_steps,
1482                        active_level=query.active_level,
1483                        policy=policy,
1484                        **bayes_kw,
1485                        refute=refute,
1486                        validators=list(validators) if validators is not None else None,
1487                        seed=seed,
1488                        bootstrap=bootstrap,
1489                        threads=threads,
1490                        context_names=cfg["context_names"],
1491                        include_space_dummy=cfg["include_space_dummy"],
1492                        include_time_dummy=cfg["include_time_dummy"],
1493                        space_dummy_ci=cfg["space_dummy_ci"]
1494                        in ("multivariate", "multivariate_block", "block", True),
1495                        time_dummy_encoding=cfg["time_dummy_encoding"],
1496                        time_dummy_ci=cfg["time_dummy_ci"]
1497                        in ("multivariate", "multivariate_block", "block", True),
1498                    )
1499                    return _wrap_temporal(raw)
1500                if isinstance(discovery, (PCMCI, PCMCIPlus, LPCMCI)):
1501                    cfg = _discovery_algorithm(discovery)
1502                    # Pooled-units discovery: treat panel as multi-env without JPCMCI+ context.
1503                    raw = _analyze_panel_discover(
1504                        data.names,
1505                        data.unit_columns,
1506                        data.unit_ids,
1507                        query.treatment,
1508                        query.outcome,
1509                        max_lag=cfg["max_lag"],
1510                        alpha=cfg["alpha"],
1511                        fdr=cfg["fdr"],
1512                        accept_discovered=accept_discovered,
1513                        treatment_lag=query.treatment_lag,
1514                        horizon_steps=query.horizon_steps,
1515                        active_level=query.active_level,
1516                        policy=policy,
1517                        **bayes_kw,
1518                        refute=refute,
1519                        validators=list(validators) if validators is not None else None,
1520                        seed=seed,
1521                        bootstrap=bootstrap,
1522                        threads=threads,
1523                        algorithm=cfg["algorithm"],
1524                    )
1525                    return _wrap_temporal(raw)
1526                raise TypeError(
1527                    "PanelFrame discovery supports JPCMCIPlus, PCMCI, PCMCIPlus, or LPCMCI"
1528                )
1529            lagged = _lagged_edges(graph)  # type: ignore[arg-type]
1530            raw = _analyze_panel(
1531                data.names,
1532                data.unit_columns,
1533                data.unit_ids,
1534                lagged,
1535                query.treatment,
1536                query.outcome,
1537                treatment_lag=query.treatment_lag,
1538                horizon_steps=query.horizon_steps,
1539                active_level=query.active_level,
1540                policy=policy,
1541                **bayes_kw,
1542                refute=refute,
1543                validators=list(validators) if validators is not None else None,
1544                seed=seed,
1545                bootstrap=bootstrap,
1546                threads=threads,
1547            )
1548            return _wrap_temporal(raw)
1549        if isinstance(data, MultiEnvFrame):
1550            if discovery is None or not isinstance(discovery, JPCMCIPlus):
1551                raise TypeError(
1552                    "MultiEnvFrame requires discovery=JPCMCIPlus(...)"
1553                )
1554            cfg = _discovery_algorithm(discovery)
1555            raw = _analyze_temporal_discover(
1556                data.names,
1557                data.env_columns[0],
1558                query.treatment,
1559                query.outcome,
1560                algorithm="jpcmci_plus",
1561                max_lag=cfg["max_lag"],
1562                alpha=cfg["alpha"],
1563                fdr=cfg["fdr"],
1564                accept_discovered=accept_discovered,
1565                treatment_lag=query.treatment_lag,
1566                horizon_steps=query.horizon_steps,
1567                active_level=query.active_level,
1568                policy=policy,
1569                **bayes_kw,
1570                seed=seed,
1571                bootstrap=bootstrap,
1572                threads=threads,
1573                env_columns=data.env_columns,
1574                context_names=cfg["context_names"],
1575                include_space_dummy=cfg["include_space_dummy"],
1576                include_time_dummy=cfg["include_time_dummy"],
1577                space_dummy_ci=cfg["space_dummy_ci"],
1578                time_dummy_encoding=cfg["time_dummy_encoding"],
1579                time_dummy_ci=cfg["time_dummy_ci"],
1580                ci=cfg.get("ci"),
1581            )
1582            return _wrap_temporal(raw)
1583        if discovery is not None:
1584            if isinstance(discovery, DbnPosterior):
1585                if not isinstance(inference, Bayesian):
1586                    raise TypeError(
1587                        "discovery=DbnPosterior(...) requires inference=Bayesian(...) "
1588                        "for temporal effect mixture"
1589                    )
1590                cfg = _discovery_algorithm(discovery)
1591                names, columns = as_columns(data)  # type: ignore[arg-type]
1592                raw = _analyze_temporal_discover(
1593                    names,
1594                    columns,
1595                    query.treatment,
1596                    query.outcome,
1597                    algorithm="dbn_posterior",
1598                    max_lag=cfg["max_lag"],
1599                    accept_discovered=accept_discovered,
1600                    treatment_lag=query.treatment_lag,
1601                    horizon_steps=query.horizon_steps,
1602                    active_level=query.active_level,
1603                    policy=policy,
1604                    **bayes_kw,
1605                    n_chains=cfg["n_chains"],
1606                    n_warmup=cfg["n_warmup"],
1607                    mcmc_draws=cfg["mcmc_draws"],
1608                    force_mcmc=cfg["force_mcmc"],
1609                    seed=seed,
1610                    bootstrap=bootstrap,
1611                    threads=threads,
1612                )
1613                return _wrap_temporal(raw)
1614            if not isinstance(discovery, _TEMPORAL_DISCOVERY):
1615                raise TypeError(
1616                    f"temporal discovery expects PCMCI-family or DbnPosterior, got {type(discovery)!r}"
1617                )
1618            cfg = _discovery_algorithm(discovery)
1619            algo = cfg["algorithm"]
1620            if algo == "jpcmci_plus":
1621                if not isinstance(data, Sequence) or isinstance(data, (str, bytes, Mapping)):
1622                    raise TypeError(
1623                        "discovery=JPCMCIPlus(...) requires data as a sequence of "
1624                        "environment mappings/DataFrames"
1625                    )
1626                names, env_columns = as_multi_env_columns(data)
1627                raw = _analyze_temporal_discover(
1628                    names,
1629                    env_columns[0],
1630                    query.treatment,
1631                    query.outcome,
1632                    algorithm=algo,
1633                    max_lag=cfg["max_lag"],
1634                    alpha=cfg["alpha"],
1635                    fdr=cfg["fdr"],
1636                    accept_discovered=accept_discovered,
1637                    treatment_lag=query.treatment_lag,
1638                    horizon_steps=query.horizon_steps,
1639                    active_level=query.active_level,
1640                    policy=policy,
1641                    **bayes_kw,
1642                    seed=seed,
1643                    bootstrap=bootstrap,
1644                    threads=threads,
1645                    env_columns=env_columns,
1646                    context_names=cfg["context_names"],
1647                    include_space_dummy=cfg["include_space_dummy"],
1648                    include_time_dummy=cfg["include_time_dummy"],
1649                    space_dummy_ci=cfg["space_dummy_ci"],
1650                    time_dummy_encoding=cfg["time_dummy_encoding"],
1651                    time_dummy_ci=cfg["time_dummy_ci"],
1652                    ci=cfg.get("ci"),
1653                )
1654                return _wrap_temporal(raw)
1655            if algo == "rpcmci":
1656                if regimes is None:
1657                    raise ValueError("discovery=RPCMCI(...) requires regimes=[…] labels")
1658                names, columns = as_columns(data)  # type: ignore[arg-type]
1659                raw = _analyze_temporal_discover(
1660                    names,
1661                    columns,
1662                    query.treatment,
1663                    query.outcome,
1664                    algorithm=algo,
1665                    max_lag=cfg["max_lag"],
1666                    alpha=cfg["alpha"],
1667                    fdr=cfg["fdr"],
1668                    accept_discovered=accept_discovered,
1669                    treatment_lag=query.treatment_lag,
1670                    horizon_steps=query.horizon_steps,
1671                    active_level=query.active_level,
1672                    policy=policy,
1673                    **bayes_kw,
1674                    seed=seed,
1675                    bootstrap=bootstrap,
1676                    threads=threads,
1677                    regimes=list(regimes),
1678                    ci=cfg.get("ci"),
1679                )
1680                return _wrap_temporal(raw)
1681            names, columns = as_columns(data)  # type: ignore[arg-type]
1682            raw = _analyze_temporal_discover(
1683                names,
1684                columns,
1685                query.treatment,
1686                query.outcome,
1687                algorithm=algo,
1688                max_lag=cfg["max_lag"],
1689                alpha=cfg["alpha"],
1690                fdr=cfg["fdr"],
1691                accept_discovered=accept_discovered,
1692                treatment_lag=query.treatment_lag,
1693                horizon_steps=query.horizon_steps,
1694                active_level=query.active_level,
1695                policy=policy,
1696                **bayes_kw,
1697                seed=seed,
1698                bootstrap=bootstrap,
1699                threads=threads,
1700                ci=cfg.get("ci"),
1701            )
1702            return _wrap_temporal(raw)
1703        names, columns = as_columns(data)  # type: ignore[arg-type]
1704        if isinstance(graph, TemporalPag):
1705            raw = _analyze_temporal_pag(
1706                names,
1707                columns,
1708                graph,
1709                query.treatment,
1710                query.outcome,
1711                treatment_lag=query.treatment_lag,
1712                horizon_steps=query.horizon_steps,
1713                active_level=query.active_level,
1714                policy=policy,
1715                **bayes_kw,
1716                refute=refute,
1717                validators=list(validators) if validators is not None else None,
1718                seed=seed,
1719                bootstrap=bootstrap,
1720                threads=threads,
1721            )
1722            return _wrap_temporal(raw)
1723        if isinstance(graph, TemporalCpdag):
1724            try:
1725                graph = graph.try_into_temporal_dag()
1726            except Exception as exc:  # noqa: BLE001 — surface orientation failures
1727                raise ValueError(
1728                    "TemporalCpdag has undirected/conflict marks; orient edges "
1729                    "(try_into_temporal_dag) before analyze, or use discovery review"
1730                ) from exc
1731        lagged = _lagged_edges(graph)  # type: ignore[arg-type]
1732        raw = _analyze_temporal(
1733            names,
1734            columns,
1735            lagged,
1736            query.treatment,
1737            query.outcome,
1738            treatment_lag=query.treatment_lag,
1739            horizon_steps=query.horizon_steps,
1740            active_level=query.active_level,
1741            policy=policy,
1742            **bayes_kw,
1743            refute=refute,
1744            validators=list(validators) if validators is not None else None,
1745            seed=seed,
1746            bootstrap=bootstrap,
1747            threads=threads,
1748        )
1749        return _wrap_temporal(raw)
1750
1751    raise TypeError(f"unsupported query type: {type(query)!r}")

Identify then estimate a causal effect.

Parameters

data: Mapping of column name → 1-d float array, a pandas DataFrame, Arrow CDI exporters (PyArrow columns / table), or a antecedent.data frame (EventFrame / PanelFrame / MultiEnvFrame). For discovery=JPCMCIPlus(...), pass a sequence of environment frames or a MultiEnvFrame. query: AverageEffect, PulseEffect / SustainedEffect, InterventionalDistribution, PathSpecificEffect, MediationEffect, Counterfactual, or TemporalMediationEffect. graph: Dag / Cpdag / Pag / Admg / TemporalDag / TemporalCpdag / TemporalPag, or an edge list. Lagged edges (from, from_lag, to, to_lag) are required for temporal queries without discovery. Fully oriented CPDAGs run as DAGs; incomplete CPDAGs require review. ADMGs without bidirected edges coerce to DAGs; ADMGs with latents use general ID + functional effect. discovery: Static: PC / GES / LiNGAM / NOTEARS / FCI / RFCI. Temporal: PCMCI / PCMCIPlus / LPCMCI / JPCMCIPlus / RPCMCI. One-shot script convenience — discovery runs at compile time. For interactive / spreadsheet estimate clicks, discover once into antecedent.AcceptedGraph (or hold a reviewed graph) and pass graph= with latency="interactive" instead. Combining discovery= with latency="interactive" raises CausalUnsupportedError. latency: Optional compute tier (interactive / standard / report). Maps to known-equivalent bootstrap / refute / draws; explicit bootstrap= / refute= always win. Interactive refuses inline discovery= (artifact-first UX). cancel: Optional CancellationToken from antecedent._native. on_progress: Optional (fraction: float, stage: str) -> None callback. on_stage: Optional (stage: str, payload: dict) -> None progressive stage callback (identify → estimate_point → uncertainty → validate). return_posterior_artifact: When True and inference is Bayesian, attach full posterior draw bytes on result.posterior.artifact (for download / sequential-prior hydrate). Default False: UI summaries only.

def analyze_many( data: 'Mapping[str, Any] | Any', *, graph: 'Dag | Sequence[tuple[str, str]]', queries: 'Sequence[AverageEffect]', identifier: str | None = None, estimator: str | None = None, refute: "bool | Literal['full', 'placebo', 'none', 'cheap']" = True, seed: int = 1, bootstrap: int | None = None, threads: int = 1, latency: "Literal['interactive', 'standard', 'report'] | None" = None) -> list[AnalysisResult]:
886def analyze_many(
887    data: Mapping[str, Any] | Any,
888    *,
889    graph: Dag | Sequence[tuple[str, str]],
890    queries: Sequence[AverageEffect],
891    identifier: str | None = None,
892    estimator: str | None = None,
893    refute: bool | Literal["full", "placebo", "none", "cheap"] = True,
894    seed: int = 1,
895    bootstrap: int | None = None,
896    threads: int = 1,
897    latency: Literal["interactive", "standard", "report"] | None = None,
898) -> list[AnalysisResult]:
899    """Estimate many average effects on one shared table ingest.
900
901    Parameters
902    ----------
903    data:
904        Column mapping / DataFrame (ingested once).
905    graph:
906        Static DAG or edge list shared by every query.
907    queries:
908        Non-empty sequence of ``AverageEffect`` queries.
909    """
910    if not queries:
911        raise ValueError("analyze_many requires at least one query")
912    if not all(isinstance(q, AverageEffect) for q in queries):
913        raise TypeError("analyze_many currently supports AverageEffect queries only")
914    bootstrap, refute = _resolve_latency_budget(latency, bootstrap, refute)
915    names, columns = as_columns(data)  # type: ignore[arg-type]
916    edges = _static_edges(graph)  # type: ignore[arg-type]
917    specs = [
918        (q.treatment, q.outcome, float(q.control_level), float(q.active_level))
919        for q in queries
920    ]
921    kwargs: dict[str, Any] = dict(
922        identifier=identifier,
923        estimator=estimator,
924        refute=refute,
925        seed=seed,
926        bootstrap=bootstrap,
927        threads=threads,
928    )
929    if latency is not None:
930        kwargs["latency"] = latency
931    raws = _analyze_ate_many(names, columns, edges, specs, **kwargs)
932    return [_wrap_ate(r) for r in raws]

Estimate many average effects on one shared table ingest.

Parameters

data: Column mapping / DataFrame (ingested once). graph: Static DAG or edge list shared by every query. queries: Non-empty sequence of AverageEffect queries.

def identify( *, graph: 'Dag | Sequence[tuple[str, str]]', query: 'AverageEffect', names: 'Sequence[str] | None' = None, identifier: 'str | Identifier | None' = None) -> IdentifyResult:
944def identify(
945    *,
946    graph: Dag | Sequence[tuple[str, str]],
947    query: AverageEffect,
948    names: Sequence[str] | None = None,
949    identifier: str | Identifier | None = None,
950) -> IdentifyResult:
951    """Identify without estimating.
952
953    Pass ``names`` when ``graph`` is an edge list (variable order). With a
954    ``Dag``, names are taken from ``graph.nodes()``.
955    """
956    if isinstance(identifier, Identifier):
957        identifier = str(identifier)
958    if isinstance(graph, Dag):
959        node_names = list(graph.nodes())
960        edges = list(graph.edges())
961    else:
962        if names is None:
963            raise ValueError("identify(edge_list) requires names=")
964        node_names = list(names)
965        edges = list(graph)
966    status, method, adjustment = _identify_ate(
967        node_names,
968        edges,
969        query.treatment,
970        query.outcome,
971        identifier=identifier,
972    )
973    return IdentifyResult(status=status, method=method, adjustment_set=list(adjustment))

Identify without estimating.

Pass names when graph is an edge list (variable order). With a Dag, names are taken from graph.nodes().