"""
Result containers for the de Chaisemartin-D'Haultfoeuille (dCDH) estimator.

This module contains ``ChaisemartinDHaultfoeuilleResults`` and
``DCDHBootstrapResults`` dataclasses produced by the
``ChaisemartinDHaultfoeuille`` (alias ``DCDH``) estimator. The dCDH
estimator is the only modern DiD estimator in the library that handles
non-absorbing (reversible) treatments. Phase 1 ships the contemporaneous-
switch case ``DID_M`` (= ``DID_1`` of the dynamic companion paper).

References
----------
- de Chaisemartin, C. & D'Haultfoeuille, X. (2020). Two-Way Fixed Effects
  Estimators with Heterogeneous Treatment Effects. *American Economic
  Review*, 110(9), 2964-2996.
- de Chaisemartin, C. & D'Haultfoeuille, X. (2022, revised July 2023).
  Difference-in-Differences Estimators of Intertemporal Treatment Effects.
  NBER Working Paper 29873.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple

if TYPE_CHECKING:
    from diff_diff.honest_did import HonestDiDResults

import numpy as np
import pandas as pd

from diff_diff.results import _get_significance_stars

__all__ = [
    "ChaisemartinDHaultfoeuilleResults",
    "DCDHBootstrapResults",
]


@dataclass
class DCDHBootstrapResults:
    """
    Results from ChaisemartinDHaultfoeuille (dCDH) multiplier bootstrap inference.

    The bootstrap is a library extension beyond the dCDH papers, which
    propose only the analytical cohort-recentered plug-in variance from
    Web Appendix Section 3.7.3 of the dynamic companion paper. Provided
    for consistency with CallawaySantAnna / ImputationDiD / TwoStageDiD.

    Per-target SE / CI / p-value are populated for the three scalar
    dCDH estimands implemented in Phase 1: overall (``DID_M``), joiners
    (``DID_+``), and leavers (``DID_-``). When a target is not available
    in the underlying data (e.g., no leavers), the matching fields are
    ``None``.

    **Phase 1 per-period placebo (L_max=None) bootstrap is NOT computed.**
    The dynamic companion paper Section 3.7.3 derives the cohort-recentered
    analytical variance for ``DID_l`` only, not for the per-period
    ``DID_M^pl``. The ``placebo_se`` / ``placebo_ci`` / ``placebo_p_value``
    fields below remain ``None`` for Phase 1. Multi-horizon placebos
    (``L_max >= 1``) have valid SE via ``placebo_horizon_ses`` - this is
    a library extension applying the same IF/variance structure to the
    placebo estimand (see REGISTRY.md dynamic placebo SE Note).

    Attributes
    ----------
    n_bootstrap : int
        Number of bootstrap iterations.
    weight_type : str
        Type of bootstrap weights: ``"rademacher"``, ``"mammen"``, or
        ``"webb"``.
    alpha : float
        Significance level used for confidence intervals.
    overall_se : float
        Bootstrap standard error for ``DID_M``.
    overall_ci : tuple of float
        Bootstrap confidence interval for ``DID_M``.
    overall_p_value : float
        Bootstrap p-value for ``DID_M``.
    joiners_se : float, optional
        Bootstrap SE for joiners-only ``DID_+`` (``None`` if no joiners).
    joiners_ci : tuple of float, optional
        Bootstrap CI for joiners-only ``DID_+``.
    joiners_p_value : float, optional
        Bootstrap p-value for joiners-only ``DID_+``.
    leavers_se : float, optional
        Bootstrap SE for leavers-only ``DID_-`` (``None`` if no leavers).
    leavers_ci : tuple of float, optional
        Bootstrap CI for leavers-only ``DID_-``.
    leavers_p_value : float, optional
        Bootstrap p-value for leavers-only ``DID_-``.
    placebo_se : float, optional
        ``None`` for the Phase 1 single-period placebo (``L_max=None``).
        Multi-horizon placebo bootstrap SE is on
        ``placebo_horizon_ses``.
    placebo_ci : tuple of float, optional
        ``None`` for single-period placebo. See ``placebo_horizon_cis``.
    placebo_p_value : float, optional
        ``None`` for single-period placebo. See
        ``placebo_horizon_p_values``.
    bootstrap_distribution : np.ndarray, optional
        Full bootstrap distribution of the overall ``DID_M`` estimator
        (shape: ``(n_bootstrap,)``). Stored for advanced diagnostics;
        suppressed from ``__repr__``.
    """

    n_bootstrap: int
    weight_type: str
    alpha: float
    overall_se: float
    overall_ci: Tuple[float, float]
    overall_p_value: float
    joiners_se: Optional[float] = None
    joiners_ci: Optional[Tuple[float, float]] = None
    joiners_p_value: Optional[float] = None
    leavers_se: Optional[float] = None
    leavers_ci: Optional[Tuple[float, float]] = None
    leavers_p_value: Optional[float] = None
    placebo_se: Optional[float] = None
    placebo_ci: Optional[Tuple[float, float]] = None
    placebo_p_value: Optional[float] = None
    bootstrap_distribution: Optional[np.ndarray] = field(default=None, repr=False)

    # --- Phase 2: per-horizon bootstrap ---
    event_study_ses: Optional[Dict[int, float]] = field(default=None, repr=False)
    event_study_cis: Optional[Dict[int, Tuple[float, float]]] = field(default=None, repr=False)
    event_study_p_values: Optional[Dict[int, float]] = field(default=None, repr=False)
    placebo_horizon_ses: Optional[Dict[int, float]] = field(default=None, repr=False)
    placebo_horizon_cis: Optional[Dict[int, Tuple[float, float]]] = field(default=None, repr=False)
    placebo_horizon_p_values: Optional[Dict[int, float]] = field(default=None, repr=False)
    cband_crit_value: Optional[float] = None

    # --- Phase 3: per-path bootstrap (by_path) ---
    # Keyed by path tuple -> horizon -> scalar/pair. Populated only when
    # by_path + n_bootstrap > 0 is active; `None` otherwise. Percentile
    # CI + percentile p-value per library Round-10 convention; caller
    # (fit()) propagates these to path_effects[path]["horizons"][l]
    # directly and computes a SE-derived t-stat via `safe_inference`.
    path_ses: Optional[Dict[Tuple[int, ...], Dict[int, float]]] = field(default=None, repr=False)
    path_cis: Optional[Dict[Tuple[int, ...], Dict[int, Tuple[float, float]]]] = field(
        default=None, repr=False
    )
    path_p_values: Optional[Dict[Tuple[int, ...], Dict[int, float]]] = field(
        default=None, repr=False
    )

    # --- Phase 3: per-path placebo bootstrap (by_path + placebo) ---
    # Same shape and library convention as path_ses / path_cis /
    # path_p_values, but for backward placebo lags (l = 1..L_max). Keyed
    # by **positive** int internally; the propagation block in fit()
    # writes them to path_placebo_event_study[path][-l] (negative key)
    # to match the placebo_event_study convention. Populated only when
    # by_path + placebo + n_bootstrap > 0 is active; `None` otherwise.
    path_placebo_ses: Optional[Dict[Tuple[int, ...], Dict[int, float]]] = field(
        default=None, repr=False
    )
    path_placebo_cis: Optional[Dict[Tuple[int, ...], Dict[int, Tuple[float, float]]]] = field(
        default=None, repr=False
    )
    path_placebo_p_values: Optional[Dict[Tuple[int, ...], Dict[int, float]]] = field(
        default=None, repr=False
    )

    # --- Phase 3: per-path joint sup-t critical values (by_path + n_bootstrap > 0) ---
    # Per-path sup-t simultaneous-band critical value `c_p =
    # quantile(max_l |t_l|, 1-alpha)` from a fresh shared-weights
    # multiplier-bootstrap draw per path. Naming parity with the OVERALL
    # `cband_crit_value` scalar at line 131 (singular -> plural since one
    # crit per path). Gates: a path appears only when (>=2 valid horizons
    # with finite bootstrap SE > 0) AND (a strict majority — more than
    # 50% — of sup-t draws are finite); paths failing either gate are
    # absent from the dict. `None` when bootstrap didn't run; empty dict
    # when ran but no path passed both gates.
    path_cband_crit_values: Optional[Dict[Tuple[int, ...], float]] = field(default=None, repr=False)
    path_cband_n_valid_horizons: Optional[Dict[Tuple[int, ...], int]] = field(
        default=None, repr=False
    )


@dataclass
class ChaisemartinDHaultfoeuilleResults:
    """
    Results from de Chaisemartin-D'Haultfoeuille (dCDH) Phase 1 estimation.

    Phase 1 ships the contemporaneous-switch estimator ``DID_M`` (= ``DID_1``
    at horizon ``l = 1`` of the dynamic companion paper) plus the joiners-
    only / leavers-only views, the single-lag placebo ``DID_M^pl``, and
    optionally the TWFE decomposition diagnostic (per-cell weights,
    fraction negative, ``sigma_fe``).

    Notes
    -----
    The analytical confidence interval is **conservative** under
    Assumption 8 (independent groups) of the dynamic companion paper, and
    exact only under iid sampling. This is documented as a deliberate
    deviation from "default nominal coverage" in the methodology registry.

    For binary treatment in Phase 1, multi-switch groups (i.e., groups
    that switch treatment more than once) are dropped before estimation
    when ``drop_larger_lower=True`` (the default), matching the R
    ``DIDmultiplegtDYN`` reference. The number of dropped groups is
    exposed via ``n_groups_dropped_crossers``.

    **Inference-method switch when bootstrap is enabled.** The
    ``overall_p_value`` / ``overall_conf_int`` (and joiners/leavers
    analogues) fields are populated by *normal-theory* inference from
    the cohort-recentered analytical SE when ``n_bootstrap=0`` (the
    default). When ``n_bootstrap > 0``, the same fields are populated
    by *percentile-based bootstrap inference* from the multiplier
    bootstrap distribution computed by ``_compute_dcdh_bootstrap()``.
    The t-stat (``overall_t_stat``, etc.) is computed from the SE in
    both cases, since percentile bootstrap does not define an
    alternative t-stat semantic. ``event_study_effects[1]``,
    ``summary()``, ``to_dataframe()``, ``is_significant``, and
    ``significance_stars`` all read from these top-level fields and
    therefore reflect the bootstrap inference automatically. The
    single-period placebo (``L_max=None``) still has NaN bootstrap
    fields; multi-horizon placebos (``L_max >= 1``) have valid
    bootstrap SE/CI/p via ``placebo_horizon_ses/cis/p_values``.
    See the methodology registry
    ``Note (bootstrap inference surface)`` for the full contract and
    library precedent.

    Attributes
    ----------
    overall_att : float
        ``DID_M = DID_1``: the contemporaneous-switch dCDH point estimate.
    overall_se : float
        Standard error of ``DID_M``.
    overall_t_stat : float
    overall_p_value : float
    overall_conf_int : tuple of float
    joiners_att : float
        ``DID_+``: the joiners-only contribution. ``NaN`` when
        ``joiners_available`` is False.
    joiners_se : float
    joiners_t_stat : float
    joiners_p_value : float
    joiners_conf_int : tuple of float
    n_joiner_cells : int
        Total number of joiner switching ``(g, t)`` cells across all
        periods. Each cell counted once. Equals
        ``sum_t (#{g : D_{g,t-1}=0, D_{g,t}=1})``.
    n_joiner_obs : int
        Total raw observation count across joiner cells, summing
        ``n_gt`` over the same set of cells. For balanced
        one-observation-per-cell panels this equals ``n_joiner_cells``;
        for individual-level inputs with multiple observations per
        ``(g, t)`` it can be larger.
    joiners_available : bool
        ``True`` if at least one joiner switching cell exists.
    leavers_att : float
        ``DID_-``: the leavers-only contribution. ``NaN`` when
        ``leavers_available`` is False.
    leavers_se : float
    leavers_t_stat : float
    leavers_p_value : float
    leavers_conf_int : tuple of float
    n_leaver_cells : int
        Total number of leaver switching ``(g, t)`` cells (mirror of
        ``n_joiner_cells``).
    n_leaver_obs : int
        Total raw observation count across leaver cells (mirror of
        ``n_joiner_obs``).
    leavers_available : bool
    placebo_effect : float
        ``DID_M^pl``: the single-lag placebo. ``NaN`` when
        ``placebo_available`` is False.
    placebo_se : float
    placebo_t_stat : float
    placebo_p_value : float
    placebo_conf_int : tuple of float
    placebo_available : bool
        ``True`` when ``T >= 3`` and at least one qualifying placebo cell
        exists.
    per_period_effects : dict
        Per-period decomposition. Keys are period values; each value is a
        dict with the following keys:

        - ``"did_plus_t"`` (float): joiner effect at this period
          (``0.0`` if no joiners or A11 violation)
        - ``"did_minus_t"`` (float): leaver effect at this period
        - ``"n_10_t"`` (int): joiner cell count
        - ``"n_01_t"`` (int): leaver cell count
        - ``"n_00_t"`` (int): stable-untreated cell count
        - ``"n_11_t"`` (int): stable-treated cell count
        - ``"did_plus_t_a11_zeroed"`` (bool): True when joiners exist but
          no stable-untreated controls (Assumption 11 violation, period
          contributes 0 to numerator with non-zero weight in denominator)
        - ``"did_minus_t_a11_zeroed"`` (bool): mirror for leavers
    twfe_weights : pd.DataFrame, optional
        Per-cell TWFE decomposition weights from Theorem 1 of de
        Chaisemartin & D'Haultfoeuille (2020). Columns: ``group``,
        ``time``, ``weight``. Computed on the **FULL pre-filter cell
        sample** passed by the user (the same input the standalone
        :func:`twowayfeweights` function uses) — NOT the post-filter
        estimation sample described by ``overall_att`` and
        ``groups``. When ``fit()`` drops groups via the ragged-panel
        or ``drop_larger_lower`` filters, ``results.twfe_*`` and
        ``results.overall_att`` describe different samples and a
        ``UserWarning`` is emitted; see REGISTRY.md
        ``ChaisemartinDHaultfoeuille`` ``Note (TWFE diagnostic
        sample contract)`` for the rationale. Only populated when
        ``twfe_diagnostic=True``.
    twfe_fraction_negative : float, optional
        Fraction of treated-cell weights that are negative. ``> 0`` is
        the diagnostic for the heterogeneous-treatment-effect bias of
        the plain TWFE estimator on the **FULL pre-filter cell sample**
        (NOT the post-filter estimation sample). See the
        ``twfe_weights`` docstring above for the sample contract.
    twfe_sigma_fe : float, optional
        Smallest standard deviation of per-cell treatment effects that
        could flip the sign of the plain TWFE estimator (Corollary 1 of
        the AER 2020 paper). Computed on the **FULL pre-filter cell
        sample**.
    twfe_beta_fe : float, optional
        The plain TWFE coefficient computed on the **FULL pre-filter
        cell sample**, for comparison with ``overall_att``. Note that
        the two are computed on different samples when ``fit()``
        filters drop groups — see the ``twfe_weights`` docstring above
        for the sample contract.
    groups : list
        Group identifiers in the post-filter sample.
    time_periods : list
        Time periods in the panel.
    n_obs : int
        Total observations after filtering.
    n_treated_obs : int
        Treated observations in the post-filter sample.
    n_switcher_cells : int
        When ``L_max=None``: number of switching ``(g, t)`` cells
        (``N_S = sum_t (n_10_t + n_01_t)``). When ``L_max >= 1``:
        number of eligible switcher groups at horizon 1 (``N_1``).
        Previously this field always held the cell count; for
        ``L_max >= 1`` it was repurposed to hold the per-group count
        that matches the ``DID_1`` estimand. Originally equals
        once regardless of how many original observations fed into it.
        This is the ``N_S`` denominator of ``DID_M`` under the library's
        equal-cell weighting convention (cell counts, not within-cell
        observation sums). The AER 2020 paper's Equation 3 defines
        ``N_{d,d',t} = sum_g N_{g,t}`` (observation sums); the
        library's choice is a documented deviation - see
        ``docs/methodology/REGISTRY.md`` ``## ChaisemartinDHaultfoeuille``
        L517 for the full Note.
    n_cohorts : int
        Distinct cohorts ``(D_{g,1}, F_g, S_g)`` after filtering.
    n_groups_dropped_crossers : int
        Number of groups dropped because they were multi-switch (matches
        R's ``drop_larger_lower=TRUE`` behavior). ``0`` when
        ``drop_larger_lower=False`` or no crossers exist.
    n_groups_dropped_singleton_baseline : int
        Number of groups whose baseline ``D_{g,1}`` is unique in the
        post-drop panel (footnote 15 of the dynamic paper). They are
        excluded from the cohort-recentered VARIANCE computation only —
        they remain in the point-estimate sample as period-based stable
        controls (see REGISTRY.md ``ChaisemartinDHaultfoeuille`` for the
        period-vs-cohort deviation that makes this distinction matter).
    n_groups_dropped_never_switching : int
        Number of groups with ``S_g = 0`` (never switched). **Reported
        for backwards compatibility only.** Per the Round 2 full
        influence-function fix, never-switching groups are NOT excluded
        from the variance: they contribute via their stable-control
        roles in the per-period IF formula. The field name retains
        "dropped" for API stability but no actual exclusion happens.
    alpha : float
        Significance level used for confidence intervals.
    event_study_effects : dict, optional
        Populated with horizon ``1`` when ``L_max=None``, or horizons
        ``1..L_max`` when ``L_max >= 1``. When ``L_max >= 1``, uses the
        per-group ``DID_{g,l}`` path; when ``L_max=None``, uses the
        per-period ``DID_M`` path.
    normalized_effects : dict, optional
        Normalized estimator ``DID^n_l``. Populated when ``L_max >= 1``.
    cost_benefit_delta : dict, optional
        Cost-benefit aggregate ``delta``. Populated when ``L_max >= 2``.
    sup_t_bands : dict, optional
        Sup-t simultaneous confidence-band metadata for the OVERALL
        event-study surface. Holds ``{"crit_value": float, "alpha":
        float, "n_bootstrap": int, "method": str}``. Populated when
        ``n_bootstrap > 0`` AND there are at least 2 valid horizons
        with finite bootstrap SE > 0 AND a strict majority (more than
        50%) of sup-t draws are finite. The band itself is written
        per-horizon as
        ``cband_conf_int`` on ``event_study_effects[l]``. ``None``
        otherwise. Python-only library extension; R
        ``did_multiplegt_dyn`` provides no joint / sup-t bands.
    covariate_residuals : pd.DataFrame, optional
        ``DID^X`` first-stage diagnostics: per-baseline ``theta_hat``,
        ``n_obs``, and ``r_squared``. Populated when ``controls`` is set.
    linear_trends_effects : dict, optional
        Cumulated ``DID^{fd}`` level effects ``delta^{fd}_l``. Keyed by
        horizon. Populated when ``trends_linear=True``.
    heterogeneity_effects : dict, optional
        Per-horizon heterogeneity test results ``beta^{het}_l``.
        Populated when ``heterogeneity`` is set.
    design2_effects : dict, optional
        Design-2 switch-in/switch-out descriptive summary. Populated
        when ``design2=True``.
    path_effects : dict, optional
        Per-path event-study effects keyed by observed treatment
        trajectory (tuple of int). Populated when ``by_path`` is a
        positive int OR ``paths_of_interest`` is a list of int tuples
        at estimator construction. Each entry holds
        ``{"n_groups": int, "frequency_rank": int,
        "horizons": {l: {"effect", "se", "t_stat", "p_value",
        "conf_int", "n_obs"}}}`` for ``l = 1..L_max``. Under
        ``paths_of_interest``, dict-insertion order matches the user-
        specified path order; ``frequency_rank`` is the within-
        selected-paths rank by descending observed-group count
        (decoupled from iteration order).
    path_placebo_event_study : dict, optional
        Per-path backward-horizon placebos ``DID^{pl}_{path, l}`` for
        ``l = 1..L_max``, keyed by observed treatment trajectory (tuple
        of int). Inner dict keys are **negative** ints (``-l`` for lag
        ``l``) to mirror the ``placebo_event_study`` convention so a
        unified ``{**path_effects[p]["horizons"],
        **path_placebo_event_study[p]}`` view is well-formed across
        forward and backward horizons. Each inner entry holds
        ``{"effect", "se", "t_stat", "p_value", "conf_int", "n_obs"}``.
        Populated when (``by_path`` is a positive int OR
        ``paths_of_interest`` is set) AND ``placebo=True`` AND
        ``L_max >= 1``. Empty-state contract mirrors ``path_effects``:
        ``None`` when ``by_path / paths_of_interest + placebo`` was
        not requested; ``{}`` when requested but no observed path has
        a complete window ``[F_g-1, F_g-1+L_max]`` within the
        panel (the same regime where ``path_effects`` returns ``{}``,
        with the same ``UserWarning`` at fit-time). Downstream callers
        should distinguish the two states. Inherits the cross-path
        cohort-sharing SE deviation from R documented for
        ``path_effects``. See REGISTRY.md
        ``Note (Phase 3 by_path ...)`` → "Per-path placebos".
    path_heterogeneity_effects : dict, optional
        Per-path heterogeneity test results (Web Appendix Section 1.5,
        Lemma 7) when ``heterogeneity`` is set AND (``by_path=k`` or
        ``paths_of_interest=[(...), ...]``) is set. Inner dict keyed by
        horizon directly (no ``"horizons"`` wrapper); each entry holds
        ``{"beta", "se", "t_stat", "p_value", "conf_int", "n_obs"}``,
        where ``beta`` is the heterogeneity coefficient on the path-
        restricted switcher subsample - plain OLS on the non-survey
        path, WLS-on-pweights under ``survey_design``. Cohort
        dummies in the design matrix absorb baseline by construction.
        Empty-state contract mirrors ``path_effects``: ``None`` when not
        requested; ``{}`` when requested but no path has eligible
        switchers. Mirrors R ``did_multiplegt_dyn(..., by_path,
        predict_het)`` per-by_level dispatch. See REGISTRY.md
        ``Note (Phase 3 by_path ...)`` → "Per-path heterogeneity testing".
    path_cumulated_event_study : dict, optional
        Per-path cumulated level effects ``delta_{path, l} =
        sum_{l'=1..l} DID^{fd}_{path, l'}`` for ``l = 1..L_max``,
        keyed by observed treatment trajectory (tuple of int). Inner
        dict is keyed by horizon directly (no ``"horizons"`` wrapper);
        each entry holds ``{"effect", "se", "t_stat", "p_value",
        "conf_int", "n_obs"}``. Populated when (``by_path`` is a
        positive int OR ``paths_of_interest`` is set) AND
        ``trends_linear=True`` AND ``L_max >= 1``; ``None`` otherwise. Mirrors the global ``linear_trends_effects``
        cumulation: SE on the cumulated layer is the conservative
        upper bound (sum of per-horizon component SEs from
        ``path_effects[path]["horizons"][l]["se"]``, NaN-consistent).
        Built AFTER bootstrap propagation so the cumulated SE / t / p
        / CI are derived from the FINAL post-bootstrap per-horizon
        SEs when ``n_bootstrap > 0``. Surfaced as ``cumulated_effect``
        / ``cumulated_se`` columns on
        ``to_dataframe(level="by_path")`` (always-present, NaN-when-
        None) and as a per-path "Cumulated Level Effects" sub-section
        in ``summary()``. See REGISTRY.md ``Note (Phase 3 by_path
        ...)`` → "Per-path linear-trends DID^{fd}".
    path_sup_t_bands : dict, optional
        Per-path joint sup-t simultaneous-band metadata, keyed by
        observed treatment trajectory (tuple of int). Each entry holds
        ``{"crit_value": float, "alpha": float, "n_bootstrap": int,
        "method": str, "n_valid_horizons": int}``. Populated when
        (``by_path`` is a positive int OR ``paths_of_interest`` is
        set) AND ``n_bootstrap > 0``. The
        band itself is applied per-horizon as ``cband_conf_int`` on
        ``path_effects[path]["horizons"][l]`` and rendered as
        ``cband_lower`` / ``cband_upper`` columns on
        ``to_dataframe(level="by_path")``. Empty-state contract:
        ``None`` when not requested (no bootstrap, or both ``by_path``
        and ``paths_of_interest`` are ``None``); ``{}`` when requested
        but no path passed both gates (``>=2``
        valid horizons with finite bootstrap SE ``> 0`` AND a strict
        majority — more than 50% — of finite sup-t draws). Bands
        cover joint inference WITHIN a
        single path across horizons; they do NOT provide simultaneous
        coverage across paths. Inherits the cross-path cohort-sharing
        SE deviation from R documented for ``path_effects`` (the
        bootstrap SE used as the t-stat denominator carries the same
        deviation). Python-only library extension; R
        ``did_multiplegt_dyn`` provides no joint / sup-t bands at any
        surface. See REGISTRY.md ``Note (Phase 3 by_path per-path
        joint sup-t bands)``.
    honest_did_results : HonestDiDResults, optional
        HonestDiD sensitivity analysis bounds (Rambachan & Roth 2023).
        Populated when ``honest_did=True`` in ``fit()`` or by calling
        ``compute_honest_did(results)`` post-hoc. Contains identified
        set bounds, robust confidence intervals, and breakdown analysis.
    survey_metadata : Any, optional
        Populated when ``fit(..., survey_design=sd)`` is called; ``None``
        otherwise. Carries the resolved survey design summary
        (``weight_type``, strata/PSU counts, ``df_survey``, weight range,
        and replicate-method info when applicable). ``df_survey`` is
        threaded into survey-aware inference (t-distribution at all
        analytical surfaces) and consumed by ``compute_honest_did()`` to
        produce survey-aware critical values.
    bootstrap_results : DCDHBootstrapResults, optional
        Bootstrap inference results when ``n_bootstrap > 0``.
    """

    # --- Core: DID_M aggregate ---
    overall_att: float
    overall_se: float
    overall_t_stat: float
    overall_p_value: float
    overall_conf_int: Tuple[float, float]

    # --- Joiners-only view (DID_+) ---
    joiners_att: float
    joiners_se: float
    joiners_t_stat: float
    joiners_p_value: float
    joiners_conf_int: Tuple[float, float]
    n_joiner_cells: int
    n_joiner_obs: int
    joiners_available: bool

    # --- Leavers-only view (DID_-) ---
    leavers_att: float
    leavers_se: float
    leavers_t_stat: float
    leavers_p_value: float
    leavers_conf_int: Tuple[float, float]
    n_leaver_cells: int
    n_leaver_obs: int
    leavers_available: bool

    # --- Placebo (DID_M^pl) ---
    placebo_effect: float
    placebo_se: float
    placebo_t_stat: float
    placebo_p_value: float
    placebo_conf_int: Tuple[float, float]
    placebo_available: bool

    # --- Per-period decomposition ---
    per_period_effects: Dict[Any, Dict[str, Any]]

    # --- Metadata ---
    groups: List[Any]
    time_periods: List[Any]
    n_obs: int
    n_treated_obs: int
    n_switcher_cells: int
    n_cohorts: int
    n_groups_dropped_crossers: int
    n_groups_dropped_singleton_baseline: int
    n_groups_dropped_never_switching: int

    # --- Event study (Phase 2: multi-horizon) ---
    # Populated with {l: {effect, se, t_stat, p_value, conf_int, n_obs}}.
    # Phase 1 (L_max=None): single entry {1: {...}} mirroring overall_att.
    # Phase 2 (L_max>=2): entries for l = 1, ..., L_max.
    event_study_effects: Optional[Dict[int, Dict[str, Any]]] = None
    L_max: Optional[int] = None
    # Dynamic placebos DID^{pl}_l with negative horizon keys.
    # None in Phase 1; populated as {-1: {...}, -2: {...}} in Phase 2.
    placebo_event_study: Optional[Dict[int, Dict[str, Any]]] = field(default=None, repr=False)

    # --- TWFE decomposition diagnostic (Theorem 1 of AER 2020) ---
    twfe_weights: Optional[pd.DataFrame] = field(default=None, repr=False)
    twfe_fraction_negative: Optional[float] = None
    twfe_sigma_fe: Optional[float] = None
    twfe_beta_fe: Optional[float] = None

    alpha: float = 0.05

    # --- Forward-compat placeholders (always None in Phase 1) ---
    normalized_effects: Optional[Dict[int, Dict[str, Any]]] = field(default=None, repr=False)
    cost_benefit_delta: Optional[Dict[str, Any]] = field(default=None, repr=False)
    sup_t_bands: Optional[Dict[str, Any]] = field(default=None, repr=False)
    covariate_residuals: Optional[pd.DataFrame] = field(default=None, repr=False)
    linear_trends_effects: Optional[Dict[int, Dict[str, Any]]] = field(default=None, repr=False)
    # PR #347 R9 P1: persist the fit-time ``trends_linear`` flag
    # explicitly. The previous approach of inferring the flag from
    # ``linear_trends_effects is not None`` broke on the empty-
    # horizon case — the estimator sets ``linear_trends_effects=None``
    # when the cumulated dict is empty but still unconditionally
    # NaN-s ``overall_att`` for ``trends_linear=True`` with
    # ``L_max >= 2``. BR/DR dispatch on this flag (via
    # ``describe_target_parameter``) to route the no-scalar-by-design
    # headline correctly even when the horizon surface is empty.
    trends_linear: Optional[bool] = None
    heterogeneity_effects: Optional[Dict[int, Dict[str, Any]]] = field(default=None, repr=False)
    design2_effects: Optional[Dict[str, Any]] = field(default=None, repr=False)
    path_effects: Optional[Dict[Tuple[int, ...], Dict[str, Any]]] = field(default=None, repr=False)
    # Per-path backward-horizon placebos. Inner dict keys are NEGATIVE
    # ints (-l for lag l) to match `placebo_event_study`'s convention,
    # so a unified `{**path_effects[p]["horizons"],
    # **path_placebo_event_study[p]}` view is well-formed across both
    # forward and backward horizons within a single path.
    path_placebo_event_study: Optional[Dict[Tuple[int, ...], Dict[int, Dict[str, Any]]]] = field(
        default=None, repr=False
    )
    # Per-path heterogeneity test (Web Appendix Section 1.5, Lemma 7)
    # under `by_path` / `paths_of_interest`. Inner dict keyed by horizon
    # directly: `{path: {l: {beta, se, t_stat, p_value, conf_int, n_obs}}}`.
    # Mirrors the simpler `path_placebo_event_study` shape — no metadata
    # wrapper because frequency_rank / n_groups already live on
    # `path_effects[path]` for the same path. Empty-state contract
    # mirrors `path_effects`: None when not requested (no `heterogeneity`
    # kwarg or no `by_path` / `paths_of_interest` selector); `{}` when
    # requested but no path is observed.
    path_heterogeneity_effects: Optional[Dict[Tuple[int, ...], Dict[int, Dict[str, Any]]]] = field(
        default=None, repr=False
    )
    # Per-path cumulated event study (level effects under `trends_linear`
    # = True). `path_effects[path]["horizons"][l]` surfaces raw
    # `DID^{fd}_l` per path; this field surfaces the cumulated level
    # effect `delta_l = sum_{l'=1..l} DID^{fd}_{path, l'}` per (path,
    # horizon), mirroring the global `linear_trends_effects` cumulation
    # for non-by_path fits. Inner dict keyed by horizon directly (no
    # `horizons` wrapper). `None` when not requested (`by_path is None`
    # or `trends_linear=False`); `{}` is impossible (the field follows
    # `path_effects` so a populated `path_effects` plus `trends_linear`
    # always populates this). SE on the cumulated layer is the
    # conservative upper bound (sum of per-horizon component SEs,
    # NaN-consistent), matching the global `linear_trends_effects`
    # convention.
    path_cumulated_event_study: Optional[Dict[Tuple[int, ...], Dict[int, Dict[str, Any]]]] = field(
        default=None, repr=False
    )
    # Per-path joint sup-t simultaneous-band metadata. Keyed by path
    # tuple; each entry holds `{"crit_value", "alpha", "n_bootstrap",
    # "method", "n_valid_horizons"}`. Populated when EITHER `by_path` is
    # a positive int OR `paths_of_interest` is non-empty AND
    # `n_bootstrap > 0`. The joint band itself is written per-horizon as
    # `cband_conf_int` on `path_effects[path]["horizons"][l]` (mirrors
    # the OVERALL `event_study_effects[l]["cband_conf_int"]` pattern
    # populated alongside the bootstrap propagation in
    # `chaisemartin_dhaultfoeuille.py::fit`). Empty-state contract:
    # `None` when not requested (no bootstrap, or both `by_path` and
    # `paths_of_interest` are `None`); `{}` when requested but no path
    # passed both gates (>=2 valid horizons AND a strict majority — more
    # than 50% — of finite sup-t draws). The bands cover joint inference
    # WITHIN a single path across horizons; they do NOT provide
    # simultaneous coverage across paths.
    path_sup_t_bands: Optional[Dict[Tuple[int, ...], Dict[str, Any]]] = field(
        default=None, repr=False
    )
    honest_did_results: Optional["HonestDiDResults"] = field(default=None, repr=False)

    # --- Repr-suppressed metadata ---
    survey_metadata: Optional[Any] = field(default=None, repr=False)
    bootstrap_results: Optional[DCDHBootstrapResults] = field(default=None, repr=False)
    _estimator_ref: Optional[Any] = field(default=None, repr=False)

    # ------------------------------------------------------------------
    # Repr / properties
    # ------------------------------------------------------------------

    def _has_trends_linear(self) -> bool:
        """Return whether ``trends_linear=True`` was active on this fit.

        PR #347 R9/R10: prefer the persisted fit-time flag. Fall back
        to ``linear_trends_effects is not None`` for legacy result
        objects that predate the persisted field. The inference
        fallback is correct on populated-surface fits but fails on
        empty-surface fits (``trends_linear=True``, ``L_max>=2`` with
        no estimable horizons) because ``linear_trends_effects`` is
        set to ``None`` in that case; the persisted flag handles it.
        """
        persisted = self.trends_linear
        if isinstance(persisted, bool):
            return persisted
        return self.linear_trends_effects is not None

    def _horizon_label(self, h) -> str:
        """Return per-horizon estimand label for event study rows."""
        has_controls = self.covariate_residuals is not None
        has_trends = self._has_trends_linear()
        if has_controls and has_trends:
            return f"DID^{{X,fd}}_{h}"
        elif has_controls:
            return f"DID^X_{h}"
        elif has_trends:
            return f"DID^{{fd}}_{h}"
        return f"DID_{h}"

    def _estimand_label(self) -> str:
        """Return the estimand label based on active features."""
        has_controls = self.covariate_residuals is not None
        has_trends = self._has_trends_linear()

        # When trends_linear + L_max>=2, overall is NaN (no aggregate).
        # Label reflects that per-horizon effects are in
        # linear_trends_effects — UNLESS that surface is also empty
        # (empty-horizon subcase; PR #347 R13 P1). In the
        # empty-surface subcase we do not direct users to a
        # nonexistent dict; we name the empty state instead.
        if has_trends and self.L_max is not None and self.L_max >= 2:
            base_label = "DID^{X,fd}_l" if has_controls else "DID^{fd}_l"
            if self.linear_trends_effects is None:
                return f"{base_label} (no cumulated level effects survived estimation)"
            return f"{base_label} (see linear_trends_effects)"

        if self.L_max is not None and self.L_max >= 2:
            base = "delta"
        elif self.L_max is not None and self.L_max == 1:
            base = "DID_1"
        else:
            base = "DID_M"

        if has_controls and has_trends:
            suffix = "^{X,fd}"
        elif has_controls:
            suffix = "^X"
        elif has_trends:
            suffix = "^{fd}"
        else:
            suffix = ""

        # For delta, suffix goes after: delta^X, delta^{fd}
        if base == "delta" and suffix:
            return f"delta{suffix}"
        # For DID variants, suffix goes on DID: DID^X_1, DID^{fd}_M
        if suffix:
            did_part = base.split("_")[0]  # "DID"
            sub_part = base.split("_")[1] if "_" in base else ""
            return f"{did_part}{suffix}_{sub_part}" if sub_part else f"{did_part}{suffix}"
        return base

    # --- Inference-field aliases (balance/external-adapter compatibility) ---
    @property
    def att(self) -> float:
        return self.overall_att

    @property
    def se(self) -> float:
        return self.overall_se

    @property
    def conf_int(self) -> Tuple[float, float]:
        return self.overall_conf_int

    @property
    def p_value(self) -> float:
        return self.overall_p_value

    @property
    def t_stat(self) -> float:
        return self.overall_t_stat

    def __repr__(self) -> str:
        """Concise string representation."""
        sig = _get_significance_stars(self.overall_p_value)
        label = self._estimand_label()
        return (
            f"ChaisemartinDHaultfoeuilleResults("
            f"{label}={self.overall_att:.4f}{sig}, "
            f"SE={self.overall_se:.4f}, "
            f"n_groups={len(self.groups)}, "
            f"n_switcher_cells={self.n_switcher_cells})"
        )

    @property
    def coef_var(self) -> float:
        """SE / abs(DID_M); NaN when DID_M is 0 or SE non-finite."""
        if not (np.isfinite(self.overall_se) and self.overall_se >= 0):
            return np.nan
        if not np.isfinite(self.overall_att) or self.overall_att == 0:
            return np.nan
        return self.overall_se / abs(self.overall_att)

    @property
    def is_significant(self) -> bool:
        """True iff overall ``DID_M`` p-value is below ``alpha``."""
        return bool(self.overall_p_value < self.alpha)

    @property
    def significance_stars(self) -> str:
        """Significance stars for the overall ``DID_M``."""
        return _get_significance_stars(self.overall_p_value)

    # ------------------------------------------------------------------
    # Summary
    # ------------------------------------------------------------------

    def summary(self, alpha: Optional[float] = None) -> str:
        """
        Generate a formatted summary of dCDH estimation results.

        Parameters
        ----------
        alpha : float, optional
            Significance level for the confidence interval header. Defaults
            to ``self.alpha``.

        Returns
        -------
        str
            Formatted multi-block summary including overall ``DID_M``,
            joiners-only / leavers-only views, the placebo, the TWFE
            decomposition diagnostic, and a footer of significance codes.
        """
        alpha = alpha or self.alpha
        conf_level = int((1 - alpha) * 100)
        width = 85
        sep = "=" * width
        thin = "-" * width
        header_row = (
            f"{'Parameter':<15} {'Estimate':>12} {'Std. Err.':>12} "
            f"{'t-stat':>10} {'P>|t|':>10} {'Sig.':>6}"
        )

        lines = [
            sep,
            "de Chaisemartin-D'Haultfoeuille (dCDH) Estimator Results".center(width),
            sep,
            "",
            f"{'Total observations:':<35} {self.n_obs:>10}",
            f"{'Treated observations:':<35} {self.n_treated_obs:>10}",
            (
                f"{'Eligible switchers (N_1):':<35} {self.n_switcher_cells:>10}"
                if self.L_max is not None and self.L_max >= 1
                else f"{'Switcher cells (N_S):':<35} {self.n_switcher_cells:>10}"
            ),
            f"{'Groups (post-filter):':<35} {len(self.groups):>10}",
            f"{'Cohorts:':<35} {self.n_cohorts:>10}",
            f"{'Time periods:':<35} {len(self.time_periods):>10}",
            "",
        ]

        # Filter counts (only show if any drops/exclusions happened).
        # After Round 2, never-switching groups participate in the variance
        # via stable-control roles and are NOT dropped — their count is
        # reported here for backwards compatibility only.
        if (
            self.n_groups_dropped_crossers
            + self.n_groups_dropped_singleton_baseline
            + self.n_groups_dropped_never_switching
            > 0
        ):
            lines.extend(
                [
                    "Group filter / metadata counts:",
                    f"{'  Multi-switch (dropped):':<42} " f"{self.n_groups_dropped_crossers:>10}",
                    f"{'  Singleton baseline (variance only):':<42} "
                    f"{self.n_groups_dropped_singleton_baseline:>10}",
                    f"{'  Never-switching (reported, not dropped):':<42} "
                    f"{self.n_groups_dropped_never_switching:>10}",
                    "",
                ]
            )

        # --- Overall ---
        has_controls = self.covariate_residuals is not None
        has_trends = self._has_trends_linear()
        adj_tag = ""
        if has_controls and has_trends:
            adj_tag = " (Covariate-and-Trend-Adjusted)"
        elif has_controls:
            adj_tag = " (Covariate-Adjusted)"
        elif has_trends:
            adj_tag = " (Trend-Adjusted)"

        if self.L_max is not None and self.L_max >= 2:
            if has_trends:
                overall_label = f"Overall (N/A under trends_linear){adj_tag}"
            else:
                overall_label = f"Cost-Benefit Delta{adj_tag}"
            overall_row_label = self._estimand_label()
        elif self.L_max is not None and self.L_max == 1:
            overall_label = f"Per-Group ATT at Horizon 1{adj_tag}"
            overall_row_label = self._estimand_label()
        else:
            overall_label = f"DID_M (Contemporaneous-Switch ATT){adj_tag}"
            overall_row_label = self._estimand_label()
        lines.extend(
            [
                thin,
                overall_label.center(width),
                thin,
                header_row,
                thin,
                _format_inference_row(
                    overall_row_label,
                    self.overall_att,
                    self.overall_se,
                    self.overall_t_stat,
                    self.overall_p_value,
                ),
                thin,
                "",
                f"{conf_level}% Confidence Interval: "
                f"[{_fmt_float(self.overall_conf_int[0])}, "
                f"{_fmt_float(self.overall_conf_int[1])}]",
            ]
        )

        cv = self.coef_var
        if np.isfinite(cv):
            cv_label = f"CV (SE/abs({overall_row_label})):"
            lines.append(f"{cv_label:<25} {cv:>10.4f}")

        lines.append("")
        is_delta = (
            self.L_max is not None and self.L_max >= 2 and self.cost_benefit_delta is not None
        )
        # Footer labeling is keyed off what the displayed fields actually
        # are — with the bootstrap-contract NaN-on-invalid fix on the fit()
        # side, ``self.overall_se`` is NaN when the bootstrap produced
        # non-finite output, so the "multiplier-bootstrap percentile
        # inference" claim correctly fires only when the displayed overall
        # SE / p-value / CI were actually populated from finite bootstrap
        # output. The event-study fallback branch also checks that at
        # least one horizon has a finite SE before claiming bootstrap was
        # "used for event-study horizon inference" — otherwise every
        # bootstrap inference field is NaN and we fall through to the
        # "bootstrap attempted but invalid" note. The `any_finite_*`
        # predicate below expands the check to cover joiners / leavers /
        # path_effects too: by_path zeros switcher contributions for non-
        # path groups while keeping controls intact, so a per-path
        # bootstrap target can produce a finite SE even when the overall
        # / event-study bootstrap is degenerate (e.g., a reversible panel
        # where the overall mix of joiners + leavers produces a zero
        # centered IF while individual paths do not). Without this
        # broader predicate, the footer would falsely claim "produced
        # non-finite SE on every target" while a finite per-path
        # bootstrap SE sits in the rendered output below.
        event_study_has_finite_bootstrap_se = self.event_study_effects is not None and any(
            np.isfinite(entry.get("se", np.nan)) for entry in self.event_study_effects.values()
        )
        joiners_has_finite_bootstrap_se = self.joiners_se is not None and np.isfinite(
            self.joiners_se
        )
        leavers_has_finite_bootstrap_se = self.leavers_se is not None and np.isfinite(
            self.leavers_se
        )
        path_effects_has_finite_bootstrap_se = self.path_effects is not None and any(
            np.isfinite(h.get("se", np.nan))
            for entry in self.path_effects.values()
            for h in entry.get("horizons", {}).values()
        )
        path_placebo_has_finite_bootstrap_se = self.path_placebo_event_study is not None and any(
            np.isfinite(h.get("se", np.nan))
            for entry in self.path_placebo_event_study.values()
            for h in entry.values()
        )
        path_sup_t_has_finite_crit = self.path_sup_t_bands is not None and any(
            np.isfinite(v.get("crit_value", np.nan)) for v in self.path_sup_t_bands.values()
        )
        any_finite_bootstrap_inference = (
            np.isfinite(self.overall_se)
            or event_study_has_finite_bootstrap_se
            or joiners_has_finite_bootstrap_se
            or leavers_has_finite_bootstrap_se
            or path_effects_has_finite_bootstrap_se
            or path_placebo_has_finite_bootstrap_se
            or path_sup_t_has_finite_crit
        )
        if self.bootstrap_results is not None and np.isfinite(self.overall_se) and not is_delta:
            lines.append("Note: p-value and CI are multiplier-bootstrap percentile inference")
            lines.append(
                f"      ({self.bootstrap_results.n_bootstrap} iterations, "
                f"{self.bootstrap_results.weight_type} weights)."
            )
        elif (
            self.bootstrap_results is not None and is_delta and event_study_has_finite_bootstrap_se
        ):
            lines.append(
                f"Note: delta SE is delta-method (normal-theory) from per-horizon "
                f"bootstrap SEs ({self.bootstrap_results.n_bootstrap} iterations)."
            )
        elif self.bootstrap_results is not None and event_study_has_finite_bootstrap_se:
            lines.append(
                f"Note: bootstrap ({self.bootstrap_results.n_bootstrap} iterations) "
                f"used for event-study horizon inference."
            )
        elif self.bootstrap_results is not None and any_finite_bootstrap_inference:
            # Overall / event-study degenerated but joiners / leavers /
            # path_effects still have finite bootstrap SE. Point the reader
            # at the targets that succeeded rather than claiming a blanket
            # failure.
            live_targets = []
            if joiners_has_finite_bootstrap_se:
                live_targets.append("joiners")
            if leavers_has_finite_bootstrap_se:
                live_targets.append("leavers")
            if path_effects_has_finite_bootstrap_se:
                live_targets.append("per-path")
            if path_placebo_has_finite_bootstrap_se:
                live_targets.append("per-path placebo")
            if path_sup_t_has_finite_crit:
                live_targets.append("per-path sup-t")
            lines.append(
                f"Note: bootstrap ({self.bootstrap_results.n_bootstrap} iterations) "
                f"produced non-finite SE on the overall/event-study target; "
                f"{', '.join(live_targets)} bootstrap inference is populated."
            )
        elif self.bootstrap_results is not None:
            lines.append(
                f"Note: bootstrap ({self.bootstrap_results.n_bootstrap} iterations) "
                f"was requested but produced non-finite SE on every target; all "
                f"inference fields are NaN-consistent per the bootstrap contract."
            )
        else:
            lines.append(
                "Note: dCDH analytical CI is conservative under Assumption 8"
                " (independent groups);"
            )
            lines.append("      exact under iid sampling.")
        lines.append("")

        # --- Joiners and leavers ---
        lines.extend(
            [
                thin,
                "Decomposition: Joiners (DID_+) and Leavers (DID_-)".center(width),
                thin,
                header_row,
                thin,
            ]
        )

        if self.joiners_available:
            lines.append(
                _format_inference_row(
                    "DID_+",
                    self.joiners_att,
                    self.joiners_se,
                    self.joiners_t_stat,
                    self.joiners_p_value,
                )
            )
            lines.append(
                f"  ({self.n_joiner_cells} joiner cells, " f"{self.n_joiner_obs} observations)"
            )
        else:
            lines.append(
                f"{'DID_+':<15} {'(no joiners)':>12} " f"{'':>12} {'':>10} {'':>10} {'':>6}"
            )

        if self.leavers_available:
            lines.append(
                _format_inference_row(
                    "DID_-",
                    self.leavers_att,
                    self.leavers_se,
                    self.leavers_t_stat,
                    self.leavers_p_value,
                )
            )
            lines.append(
                f"  ({self.n_leaver_cells} leaver cells, " f"{self.n_leaver_obs} observations)"
            )
        else:
            lines.append(
                f"{'DID_-':<15} {'(no leavers)':>12} " f"{'':>12} {'':>10} {'':>10} {'':>6}"
            )

        lines.extend([thin, ""])

        # --- Placebo ---
        if self.placebo_available:
            lines.extend(
                [
                    thin,
                    "Single-Lag Placebo (DID_M^pl)".center(width),
                    thin,
                    header_row,
                    thin,
                    _format_inference_row(
                        "DID_M^pl",
                        self.placebo_effect,
                        self.placebo_se,
                        self.placebo_t_stat,
                        self.placebo_p_value,
                    ),
                    thin,
                    "Under parallel trends, the placebo should be ~0.",
                    "",
                ]
            )
        else:
            lines.extend(
                [
                    thin,
                    "Placebo not available (T < 3 or no qualifying cells)".center(width),
                    thin,
                    "",
                ]
            )

        # --- Event study table (L_max >= 1) ---
        if self.L_max is not None and self.L_max >= 1 and self.event_study_effects:
            lines.extend(
                [
                    thin,
                    f"Event Study ({self._horizon_label('l')}, l = 1..{self.L_max})".center(width),
                    thin,
                    header_row,
                    thin,
                ]
            )
            for l_h in sorted(self.event_study_effects.keys()):
                entry = self.event_study_effects[l_h]
                lines.append(
                    _format_inference_row(
                        self._horizon_label(l_h),
                        entry["effect"],
                        entry["se"],
                        entry["t_stat"],
                        entry["p_value"],
                    )
                )
            lines.extend([thin])

            # Sup-t bands note
            if self.sup_t_bands is not None:
                crit = self.sup_t_bands["crit_value"]
                lines.append(
                    f"Sup-t critical value: {crit:.4f} " f"(simultaneous {conf_level}% bands)"
                )

            # Cost-benefit delta
            if self.cost_benefit_delta is not None:
                delta = self.cost_benefit_delta.get("delta", float("nan"))
                lines.extend(
                    [
                        "",
                        f"{'Cost-benefit delta:':<35} {_fmt_float(delta):>10}",
                    ]
                )
                if self.cost_benefit_delta.get("has_leavers", False):
                    dj = self.cost_benefit_delta.get("delta_joiners", float("nan"))
                    dl = self.cost_benefit_delta.get("delta_leavers", float("nan"))
                    lines.append(
                        f"  (Assumption 7 violated: joiners={_fmt_float(dj)}, "
                        f"leavers={_fmt_float(dl)})"
                    )

            # Dynamic placebos
            if self.placebo_event_study:
                lines.extend(
                    [
                        "",
                        f"{'Placebos:':<15}",
                    ]
                )
                for h in sorted(self.placebo_event_study.keys()):
                    entry = self.placebo_event_study[h]
                    eff = _fmt_float(entry["effect"])
                    n_pl = entry["n_obs"]
                    lines.append(f"  DID^pl_{abs(h)}: {eff:>10}  (N={n_pl})")

            lines.extend([""])

        # --- Phase 3 extension blocks (factored into helpers) ---
        self._render_covariate_section(lines, width, thin)
        self._render_linear_trends_section(lines, width, thin, header_row)
        self._render_heterogeneity_section(lines, width, thin)
        self._render_design2_section(lines, width, thin)
        self._render_path_effects_section(lines, width, thin, header_row)
        self._render_honest_did_section(lines, width, thin)

        # --- TWFE diagnostic ---
        if self.twfe_beta_fe is not None:
            lines.extend(
                [
                    thin,
                    "TWFE Decomposition Diagnostic (Theorem 1, AER 2020)".center(width),
                    thin,
                    f"{'Plain TWFE coefficient:':<35} {_fmt_float(self.twfe_beta_fe):>10}",
                ]
            )
            if self.twfe_fraction_negative is not None:
                lines.append(
                    f"{'Fraction of negative weights:':<35} "
                    f"{self.twfe_fraction_negative:>10.4f}"
                )
            if self.twfe_sigma_fe is not None and np.isfinite(self.twfe_sigma_fe):
                lines.append(
                    f"{'Sigma_fe (sign-flip threshold):':<35} " f"{self.twfe_sigma_fe:>10.4f}"
                )
            lines.extend(
                [
                    "",
                    "A positive fraction of negative weights signals that the plain",
                    "TWFE coefficient may have the wrong sign under heterogeneous",
                    "treatment effects. Sigma_fe is the smallest cell-level effect",
                    "standard deviation that could flip the sign of TWFE.",
                    thin,
                    "",
                ]
            )

        lines.extend(
            [
                "Signif. codes: '***' 0.001, '**' 0.01, '*' 0.05, '.' 0.1",
                sep,
            ]
        )

        return "\n".join(lines)

    def print_summary(self, alpha: Optional[float] = None) -> None:
        """Print the formatted summary to stdout."""
        print(self.summary(alpha))

    # ------------------------------------------------------------------
    # Summary section helpers (Phase 3 blocks)
    # ------------------------------------------------------------------

    def _render_covariate_section(self, lines: List[str], width: int, thin: str) -> None:
        if self.covariate_residuals is None:
            return
        cov_df = self.covariate_residuals
        control_names = sorted(cov_df["covariate"].unique())
        n_baselines = cov_df["baseline_treatment"].nunique()
        failed = int((cov_df.groupby("baseline_treatment")["theta_hat"].first().isna()).sum())
        lines.extend(
            [
                thin,
                "Covariate Adjustment (DID^X) Diagnostics".center(width),
                thin,
                f"{'Controls:':<35} {', '.join(control_names):>10}",
                f"{'Baselines residualized:':<35} {n_baselines:>10}",
                f"{'Failed strata:':<35} {failed:>10}",
                thin,
                "",
            ]
        )

    def _render_linear_trends_section(
        self, lines: List[str], width: int, thin: str, header_row: str
    ) -> None:
        if self.linear_trends_effects is None:
            return
        lines.extend(
            [
                thin,
                "Cumulated Level Effects (DID^{fd}, trends_linear)".center(width),
                thin,
                header_row,
                thin,
            ]
        )
        for l_h in sorted(self.linear_trends_effects.keys()):
            entry = self.linear_trends_effects[l_h]
            lines.append(
                _format_inference_row(
                    f"Level_{l_h}",
                    entry["effect"],
                    entry["se"],
                    entry["t_stat"],
                    entry["p_value"],
                )
            )
        lines.extend([thin, ""])

    def _render_heterogeneity_section(self, lines: List[str], width: int, thin: str) -> None:
        if self.heterogeneity_effects is None:
            return
        lines.extend(
            [
                thin,
                "Heterogeneity Test (Section 1.5, partial)".center(width),
                thin,
                f"{'Horizon':<15} {'beta^het':>12} {'Std. Err.':>12} "
                f"{'t-stat':>10} {'P>|t|':>10} {'Sig.':>6}",
                thin,
            ]
        )
        for l_h in sorted(self.heterogeneity_effects.keys()):
            entry = self.heterogeneity_effects[l_h]
            lines.append(
                _format_inference_row(
                    f"l={l_h}",
                    entry["beta"],
                    entry["se"],
                    entry["t_stat"],
                    entry["p_value"],
                )
            )
        lines.extend(
            [
                thin,
                "Note: Per-horizon regressions only (no joint F-test). "
                "Negative l = placebo (backward) horizon when "
                "placebo=True. Under survey_design, only forward "
                "horizons are computed (backward-horizon survey "
                "heterogeneity is deferred — see REGISTRY note).",
                "",
            ]
        )

    def _render_design2_section(self, lines: List[str], width: int, thin: str) -> None:
        if self.design2_effects is None:
            return
        d2 = self.design2_effects
        si = d2.get("switch_in", {})
        so = d2.get("switch_out", {})
        lines.extend(
            [
                thin,
                "Design-2: Switch-In / Switch-Out (Section 1.6)".center(width),
                thin,
                f"{'Join-then-leave groups:':<35} {d2.get('n_design2_groups', 0):>10}",
                f"{'Switch-in effect (mean):':<35} "
                f"{_fmt_float(si.get('mean_effect', float('nan'))):>10}"
                f"  (N={si.get('n_groups', 0)})",
                f"{'Switch-out effect (mean):':<35} "
                f"{_fmt_float(so.get('mean_effect', float('nan'))):>10}"
                f"  (N={so.get('n_groups', 0)})",
                thin,
                "",
            ]
        )

    def _render_path_effects_section(
        self, lines: List[str], width: int, thin: str, header_row: str
    ) -> None:
        # Distinguish "by_path not requested" (None) from "requested but
        # empty" ({}). On empty, render a notice so the user sees the
        # feature was active but produced no rows.
        if self.path_effects is None:
            return
        if not self.path_effects:
            # Distinguish the two empty causes for paths_of_interest
            # users (every requested path unobserved) from by_path=k
            # users (no panel path has a complete window).
            poi = getattr(self._estimator_ref, "paths_of_interest", None)
            if poi is not None:
                detail_lines = [
                    "  Every path in paths_of_interest was unobserved or had a window outside L_max+1.",
                    "  (See per-path 'zero observed groups' UserWarnings emitted at fit().)",
                ]
            else:
                detail_lines = [
                    "  No observed paths have a complete [F_g-1, F_g-1+L_max] window.",
                    "  (See UserWarning emitted at fit(); by_path was a no-op on this panel.)",
                ]
            lines.extend(
                [
                    thin,
                    "Treatment-Path Disaggregation".center(width),
                    thin,
                    *detail_lines,
                    thin,
                    "",
                ]
            )
            return
        lines.extend(
            [
                thin,
                "Treatment-Path Disaggregation".center(width),
                thin,
            ]
        )
        # Iterate in path_effects insertion order so summary preserves
        # the user-specified path order under `paths_of_interest`. Under
        # `by_path=k`, insertion order matches descending frequency_rank
        # (the enumeration sorts by count), so the rendering is identical.
        for path in self.path_effects.keys():
            entry = self.path_effects[path]
            rank = entry["frequency_rank"]
            n_groups = entry["n_groups"]
            path_label = f"Path {path}"
            lines.extend(
                [
                    f"  Rank #{rank}: {path_label}  (n_groups={n_groups})",
                    header_row,
                    thin,
                ]
            )
            horizons = entry.get("horizons", {})
            # Backward placebo lags first (negative-keyed), then
            # positive event-study horizons. Skips silently when
            # path_placebo_event_study is None or this path lacks an
            # entry.
            placebo_horizons = (
                self.path_placebo_event_study.get(path, {})
                if self.path_placebo_event_study is not None
                else {}
            )
            for lag_key in sorted(placebo_horizons.keys()):
                ph = placebo_horizons[lag_key]
                lines.append(
                    _format_inference_row(
                        f"  l={lag_key}",
                        ph["effect"],
                        ph["se"],
                        ph["t_stat"],
                        ph["p_value"],
                    )
                )
            for l_h in sorted(horizons.keys()):
                h = horizons[l_h]
                lines.append(
                    _format_inference_row(
                        f"  l={l_h}",
                        h["effect"],
                        h["se"],
                        h["t_stat"],
                        h["p_value"],
                    )
                )
            # Per-path cumulated level effects (under trends_linear).
            # Mirrors the global linear_trends_effects rendering inside
            # the per-path block: appears as a labeled sub-block right
            # after the per-horizon DID^{fd}_l rows. Skip silently when
            # path_cumulated_event_study is None or this path lacks an
            # entry (the latter shouldn't happen, but kept as a guard).
            if (
                self.path_cumulated_event_study is not None
                and path in self.path_cumulated_event_study
            ):
                cum_horizons = self.path_cumulated_event_study[path]
                if cum_horizons:
                    lines.append("  Cumulated Level Effects (DID^{fd}, trends_linear):")
                    for l_h in sorted(cum_horizons.keys()):
                        ce = cum_horizons[l_h]
                        lines.append(
                            _format_inference_row(
                                f"  Level_{l_h}",
                                ce["effect"],
                                ce["se"],
                                ce["t_stat"],
                                ce["p_value"],
                            )
                        )
            # Per-path heterogeneity rows (under heterogeneity=col).
            # Mirrors the global `_render_heterogeneity_section` block
            # but scoped to this path. Skip silently when
            # path_heterogeneity_effects is None or this path lacks an
            # entry (e.g., when `heterogeneity` was not requested).
            if (
                self.path_heterogeneity_effects is not None
                and path in self.path_heterogeneity_effects
            ):
                het_horizons = self.path_heterogeneity_effects[path]
                if het_horizons:
                    lines.append("  Heterogeneity Test (Section 1.5, partial):")
                    for l_h in sorted(het_horizons.keys()):
                        het = het_horizons[l_h]
                        lines.append(
                            _format_inference_row(
                                f"  l={l_h}",
                                het["beta"],
                                het["se"],
                                het["t_stat"],
                                het["p_value"],
                            )
                        )
            # Per-path joint sup-t critical value (when populated).
            # Mirrors the OVERALL sup-t crit print at line ~1019.
            if self.path_sup_t_bands is not None and path in self.path_sup_t_bands:
                crit_p = self.path_sup_t_bands[path].get("crit_value", np.nan)
                if np.isfinite(crit_p):
                    conf_level = int((1 - self.alpha) * 100)
                    lines.append(
                        f"  Sup-t critical value: {crit_p:.4f} "
                        f"(simultaneous {conf_level}% bands)"
                    )
            lines.extend([thin])
        lines.extend([""])

    def _render_honest_did_section(self, lines: List[str], width: int, thin: str) -> None:
        if self.honest_did_results is None:
            return
        hd = self.honest_did_results
        method_label = hd.method.replace("_", " ").title()
        m_val = hd.M
        sig_label = "Yes" if hd.is_significant else "No"
        conf_pct = int((1 - hd.alpha) * 100)
        lines.extend(
            [
                thin,
                "HonestDiD Sensitivity (Rambachan-Roth 2023)".center(width),
                thin,
                f"{'Method:':<35} {method_label} (M={_fmt_float(m_val)})",
                f"{'Target:':<35} {hd.target_label}",
            ]
        )
        if hd.post_periods_used is not None:
            lines.append(f"{'Post horizons used:':<35} {hd.post_periods_used}")
        if hd.pre_periods_used is not None:
            lines.append(f"{'Pre horizons used:':<35} {hd.pre_periods_used}")
        lines.extend(
            [
                f"{'Original estimate:':<35} {_fmt_float(hd.original_estimate):>10}",
                f"{'Identified set:':<35} " f"[{_fmt_float(hd.lb)}, {_fmt_float(hd.ub)}]",
                f"{'Robust ' + str(conf_pct) + '% CI:':<35} "
                f"[{_fmt_float(hd.ci_lb)}, {_fmt_float(hd.ci_ub)}]",
                f"{'Significant at ' + str(int(hd.alpha * 100)) + '%:':<35} " f"{sig_label:>10}",
                thin,
                "",
            ]
        )

    # ------------------------------------------------------------------
    # to_dataframe
    # ------------------------------------------------------------------

    def to_dataframe(self, level: str = "overall") -> pd.DataFrame:
        """
        Convert results to a DataFrame at the requested level of aggregation.

        Parameters
        ----------
        level : str, default="overall"
            One of:

            - ``"overall"``: single-row table with the overall estimand
              (``DID_M`` when ``L_max=None``, ``DID_1`` when ``L_max=1``,
              ``delta`` when ``L_max >= 2``).
            - ``"joiners_leavers"``: up to three rows for the overall,
              ``DID_+``, and ``DID_-`` (binary panels only).
            - ``"per_period"``: one row per time period with
              ``did_plus_t``, ``did_minus_t``, switching cell counts, and
              the A11-zeroed flags.
            - ``"event_study"``: one row per horizon (positive and
              negative/placebo), including a reference period at
              horizon 0. Available when ``L_max >= 1``.
            - ``"normalized"``: one row per horizon for the normalized
              effects ``DID^n_l``. Available when ``L_max >= 1``.
            - ``"twfe_weights"``: per-(group, time) TWFE decomposition
              weights table. Only available when ``twfe_diagnostic=True``
              was passed to ``fit()``.
            - ``"heterogeneity"``: one row per horizon for the
              heterogeneity test ``beta^{het}_l``. Available when
              ``heterogeneity`` is passed to ``fit()``.
            - ``"linear_trends"``: one row per horizon for the
              cumulated trend-adjusted level effects ``delta^{fd}_l``.
              Available when ``trends_linear=True``.
            - ``"design2"``: Design-2 switch-in/switch-out descriptive
              summary. Available when ``design2=True``.
            - ``"by_path"``: one row per (path, horizon) when either
              ``by_path=k`` or ``paths_of_interest=[(...), ...]`` was
              passed to the estimator. Columns:
              ``path``, ``frequency_rank``, ``n_groups``, ``horizon``,
              ``effect``, ``se``, ``t_stat``, ``p_value``,
              ``conf_int_lower``, ``conf_int_upper``, ``n_obs``,
              ``cband_lower``, ``cband_upper``, ``cumulated_effect``,
              ``cumulated_se``, ``het_beta``, ``het_se``,
              ``het_t_stat``, ``het_p_value``, ``het_conf_int_lower``,
              ``het_conf_int_upper``. The ``horizon`` column takes
              negative ints for placebo rows when ``placebo=True``. The
              ``cband_*`` columns mirror the OVERALL
              ``level="event_study"`` schema (joint sup-t simultaneous
              bands); they are populated for positive-horizon rows of
              paths with a finite per-path sup-t crit (``n_bootstrap >
              0``) and NaN otherwise (placebo rows, unbanded paths, or
              the requested-but-empty fallback DataFrame). The
              ``cumulated_*`` columns mirror the global
              ``linear_trends_effects`` cumulation; populated for
              positive-horizon rows when ``trends_linear=True`` is
              also set, NaN for placebo rows or non-trends_linear fits
              (always-present, NaN-when-None — same convention as
              ``cband_*``). The ``het_*`` columns surface the per-path
              heterogeneity coefficient (Web Appendix Section 1.5,
              Lemma 7) when ``heterogeneity="<col>"`` is also set.
              Populated for positive-horizon (forward) rows whenever
              heterogeneity is requested, AND for negative-horizon
              (placebo) rows when ``placebo=True`` is also set
              (post-2026-05-15: per-path placebo predict_het R-parity
              against ``did_multiplegt_dyn(by_path, predict_het, placebo)``).
              NaN for non-heterogeneity fits / the requested-but-empty
              fallback DataFrame, AND for placebo rows under
              ``survey_design`` (forward-only fallback — backward-horizon
              survey predict_het is deferred until the pre-period cell
              allocator is derived; a ``UserWarning`` fires at fit-time
              when ``survey_design + placebo + heterogeneity`` are
              co-set). Always-present, NaN-when-None — same convention
              as ``cband_*`` and ``cumulated_*``.

        Returns
        -------
        pd.DataFrame
        """
        if level == "overall":
            return pd.DataFrame(
                [
                    {
                        "estimand": self._estimand_label(),
                        "effect": self.overall_att,
                        "se": self.overall_se,
                        "t_stat": self.overall_t_stat,
                        "p_value": self.overall_p_value,
                        "conf_int_lower": self.overall_conf_int[0],
                        "conf_int_upper": self.overall_conf_int[1],
                    }
                ]
            )

        elif level == "joiners_leavers":
            # Two separate count columns so each has consistent units
            # across all rows:
            #   n_cells: total switching cells (each (g, t) cell counted once)
            #   n_obs:   actual observation count summed over the same cells
            #            (equals n_cells on balanced 1-obs-per-cell panels;
            #            larger on individual-level inputs with multiple
            #            observations per cell).
            # For the DID_M row, both quantities use the overall switching
            # cell set: n_cells = sum of joiner + leaver cells, and n_obs
            # is the same sum of raw observation counts.
            overall_est_label = self._estimand_label()
            rows = [
                {
                    "estimand": overall_est_label,
                    "effect": self.overall_att,
                    "se": self.overall_se,
                    "t_stat": self.overall_t_stat,
                    "p_value": self.overall_p_value,
                    "conf_int_lower": self.overall_conf_int[0],
                    "conf_int_upper": self.overall_conf_int[1],
                    "n_cells": self.n_switcher_cells,
                    "n_obs": (
                        self.n_treated_obs
                        if not self.joiners_available and not self.leavers_available
                        else self.n_joiner_obs + self.n_leaver_obs
                    ),
                    "available": True,
                },
                {
                    "estimand": "DID_+",
                    "effect": self.joiners_att,
                    "se": self.joiners_se,
                    "t_stat": self.joiners_t_stat,
                    "p_value": self.joiners_p_value,
                    "conf_int_lower": self.joiners_conf_int[0],
                    "conf_int_upper": self.joiners_conf_int[1],
                    "n_cells": self.n_joiner_cells,
                    "n_obs": self.n_joiner_obs,
                    "available": self.joiners_available,
                },
                {
                    "estimand": "DID_-",
                    "effect": self.leavers_att,
                    "se": self.leavers_se,
                    "t_stat": self.leavers_t_stat,
                    "p_value": self.leavers_p_value,
                    "conf_int_lower": self.leavers_conf_int[0],
                    "conf_int_upper": self.leavers_conf_int[1],
                    "n_cells": self.n_leaver_cells,
                    "n_obs": self.n_leaver_obs,
                    "available": self.leavers_available,
                },
            ]
            return pd.DataFrame(rows)

        elif level == "per_period":
            if not self.per_period_effects:
                # Empty per-period table — return DataFrame with the
                # canonical column order so downstream code can rely on it.
                return pd.DataFrame(
                    {
                        "period": pd.Series(dtype="int64"),
                        "did_plus_t": pd.Series(dtype="float64"),
                        "did_minus_t": pd.Series(dtype="float64"),
                        "n_10_t": pd.Series(dtype="int64"),
                        "n_01_t": pd.Series(dtype="int64"),
                        "n_00_t": pd.Series(dtype="int64"),
                        "n_11_t": pd.Series(dtype="int64"),
                        "did_plus_t_a11_zeroed": pd.Series(dtype="bool"),
                        "did_minus_t_a11_zeroed": pd.Series(dtype="bool"),
                    }
                )
            rows = []
            for t in sorted(self.per_period_effects.keys()):
                cell = self.per_period_effects[t]
                rows.append({"period": t, **cell})
            return pd.DataFrame(rows)

        elif level == "event_study":
            rows = []
            # Placebo horizons (negative keys)
            if self.placebo_event_study:
                for h in sorted(self.placebo_event_study.keys()):
                    entry = self.placebo_event_study[h]
                    cband = entry.get("cband_conf_int", (np.nan, np.nan))
                    rows.append(
                        {
                            "horizon": h,
                            "estimand": f"DID^pl_{abs(h)}",
                            "effect": entry["effect"],
                            "se": entry["se"],
                            "t_stat": entry["t_stat"],
                            "p_value": entry["p_value"],
                            "conf_int_lower": entry["conf_int"][0],
                            "conf_int_upper": entry["conf_int"][1],
                            "n_obs": entry["n_obs"],
                            "cband_lower": cband[0] if cband else np.nan,
                            "cband_upper": cband[1] if cband else np.nan,
                        }
                    )
            # Reference period (horizon 0)
            rows.append(
                {
                    "horizon": 0,
                    "estimand": "ref",
                    "effect": 0.0,
                    "se": np.nan,
                    "t_stat": np.nan,
                    "p_value": np.nan,
                    "conf_int_lower": np.nan,
                    "conf_int_upper": np.nan,
                    "n_obs": 0,
                    "cband_lower": np.nan,
                    "cband_upper": np.nan,
                }
            )
            # Positive horizons
            if self.event_study_effects:
                for h in sorted(self.event_study_effects.keys()):
                    entry = self.event_study_effects[h]
                    cband = entry.get("cband_conf_int", (np.nan, np.nan))
                    rows.append(
                        {
                            "horizon": h,
                            "estimand": self._horizon_label(h),
                            "effect": entry["effect"],
                            "se": entry["se"],
                            "t_stat": entry["t_stat"],
                            "p_value": entry["p_value"],
                            "conf_int_lower": entry["conf_int"][0],
                            "conf_int_upper": entry["conf_int"][1],
                            "n_obs": entry["n_obs"],
                            "cband_lower": cband[0] if cband else np.nan,
                            "cband_upper": cband[1] if cband else np.nan,
                        }
                    )
            return pd.DataFrame(rows)

        elif level == "normalized":
            if not self.normalized_effects:
                raise ValueError("Normalized effects not computed. Pass L_max >= 1 to fit().")
            rows = []
            for h in sorted(self.normalized_effects.keys()):
                entry = self.normalized_effects[h]
                rows.append(
                    {
                        "horizon": h,
                        "estimand": f"DID^n_{h}",
                        "effect": entry["effect"],
                        "se": entry["se"],
                        "t_stat": entry["t_stat"],
                        "p_value": entry["p_value"],
                        "conf_int_lower": entry["conf_int"][0],
                        "conf_int_upper": entry["conf_int"][1],
                        "denominator": entry["denominator"],
                    }
                )
            return pd.DataFrame(rows)

        elif level == "twfe_weights":
            if self.twfe_weights is None:
                raise ValueError(
                    "TWFE decomposition weights not computed. Pass "
                    "twfe_diagnostic=True (the default) to ChaisemartinDHaultfoeuille()."
                )
            return self.twfe_weights.copy()

        elif level == "heterogeneity":
            if self.heterogeneity_effects is None:
                raise ValueError(
                    "Heterogeneity test results not available. Pass "
                    "heterogeneity='column_name' to fit()."
                )
            rows = []
            for h, data in sorted(self.heterogeneity_effects.items()):
                rows.append({"horizon": h, **data})
            return pd.DataFrame(rows)

        elif level == "linear_trends":
            if self.linear_trends_effects is None:
                # PR #347 R12 P1: distinguish the "trends_linear was
                # not requested" case from the "trends_linear was
                # requested but no horizons survived" case. Telling
                # a user who already passed ``trends_linear=True``
                # to pass it again is a dead-end.
                if self._has_trends_linear():
                    return pd.DataFrame(
                        columns=[
                            "horizon",
                            "effect",
                            "se",
                            "t_stat",
                            "p_value",
                            "conf_int",
                        ]
                    )
                raise ValueError(
                    "Linear trends effects not available. Pass trends_linear=True to fit()."
                )
            rows = []
            for h, data in sorted(self.linear_trends_effects.items()):
                rows.append({"horizon": h, **data})
            return pd.DataFrame(rows)

        elif level == "design2":
            if self.design2_effects is None:
                raise ValueError(
                    "Design-2 effects not available. Pass "
                    "design2=True with drop_larger_lower=False to fit()."
                )
            return pd.DataFrame([self.design2_effects])

        elif level == "by_path":
            # Distinguish "not requested" from "requested but empty" so the
            # caller who passed by_path=k isn't told to "pass by_path=k".
            # Mirrors the linear_trends pattern above.
            if self.path_effects is None:
                raise ValueError(
                    "Path effects not available. Pass by_path=k "
                    "(positive int) or paths_of_interest=[(...), ...] "
                    "to ChaisemartinDHaultfoeuille(drop_larger_lower=False, "
                    "by_path=k) (or paths_of_interest=...) and L_max >= 1 "
                    "to fit()."
                )
            if not self.path_effects:
                return pd.DataFrame(
                    columns=[
                        "path",
                        "frequency_rank",
                        "n_groups",
                        "horizon",
                        "effect",
                        "se",
                        "t_stat",
                        "p_value",
                        "conf_int_lower",
                        "conf_int_upper",
                        "n_obs",
                        "cband_lower",
                        "cband_upper",
                        "cumulated_effect",
                        "cumulated_se",
                        "het_beta",
                        "het_se",
                        "het_t_stat",
                        "het_p_value",
                        "het_conf_int_lower",
                        "het_conf_int_upper",
                    ]
                )
            rows = []
            # Iterate in path_effects insertion order so the long-format
            # table preserves the user-specified path order under
            # `paths_of_interest`. Under `by_path=k`, insertion order
            # matches descending frequency_rank, so output is identical.
            for path in self.path_effects.keys():
                entry = self.path_effects[path]
                rank = entry["frequency_rank"]
                n_groups = entry["n_groups"]
                horizons = entry.get("horizons", {})
                # Backward placebo lags first (negative-keyed), then
                # positive event-study horizons. Both placebo and
                # event-study rows are emitted in a single
                # `level="by_path"` table so callers see the full
                # forward+backward inference per path.
                placebo_horizons = (
                    self.path_placebo_event_study.get(path, {})
                    if self.path_placebo_event_study is not None
                    else {}
                )
                # Per-path cumulated entries (under trends_linear). Always-
                # present, NaN-when-None mirrors the cband_* convention.
                path_cumulated = (
                    self.path_cumulated_event_study.get(path, {})
                    if self.path_cumulated_event_study is not None
                    else {}
                )
                # Per-path heterogeneity entries (under heterogeneity=col).
                # Always-present het_* columns, NaN when not requested or
                # when the path's per-horizon entry is missing.
                path_het = (
                    self.path_heterogeneity_effects.get(path, {})
                    if self.path_heterogeneity_effects is not None
                    else {}
                )
                for lag_key in sorted(placebo_horizons.keys()):
                    ph_entry = placebo_horizons[lag_key]
                    # Placebos do not get joint sup-t bands in this
                    # release (only positive event-study horizons do —
                    # mirrors OVERALL placebo / event-study sup-t
                    # convention). Emit NaN cband columns for schema
                    # parity with the OVERALL level="event_study" table.
                    # Placebo + cumulated is also NaN: there is no per-
                    # path placebo cumulation surface (placebo under
                    # trends_lin returns RAW per-horizon values per R).
                    ph_cband = ph_entry.get("cband_conf_int", (np.nan, np.nan))
                    # Per-path placebo heterogeneity (TODO #422). R-
                    # verified: did_multiplegt_dyn(..., by_path,
                    # predict_het, placebo) emits per-path predict_het
                    # rows on backward (negative) horizons. Negative
                    # `lag_key` indexes into `path_het` to look up the
                    # placebo het entry; absent key (placebo > 0 but
                    # this (path, lag) is rank-deficient or has < 3
                    # eligible groups) -> NaN columns.
                    ph_het_entry = path_het.get(lag_key, {}) if path_het else {}
                    ph_het_ci = ph_het_entry.get("conf_int", (np.nan, np.nan))
                    rows.append(
                        {
                            "path": path,
                            "frequency_rank": rank,
                            "n_groups": n_groups,
                            "horizon": lag_key,
                            "effect": ph_entry["effect"],
                            "se": ph_entry["se"],
                            "t_stat": ph_entry["t_stat"],
                            "p_value": ph_entry["p_value"],
                            "conf_int_lower": ph_entry["conf_int"][0],
                            "conf_int_upper": ph_entry["conf_int"][1],
                            "n_obs": ph_entry["n_obs"],
                            "cband_lower": ph_cband[0] if ph_cband else np.nan,
                            "cband_upper": ph_cband[1] if ph_cband else np.nan,
                            "cumulated_effect": np.nan,
                            "cumulated_se": np.nan,
                            "het_beta": ph_het_entry.get("beta", np.nan),
                            "het_se": ph_het_entry.get("se", np.nan),
                            "het_t_stat": ph_het_entry.get("t_stat", np.nan),
                            "het_p_value": ph_het_entry.get("p_value", np.nan),
                            "het_conf_int_lower": ph_het_ci[0] if ph_het_ci else np.nan,
                            "het_conf_int_upper": ph_het_ci[1] if ph_het_ci else np.nan,
                        }
                    )
                for l_h in sorted(horizons.keys()):
                    h_entry = horizons[l_h]
                    # Per-path joint sup-t band (when populated) mirrors
                    # OVERALL `level="event_study"` cband emission. Absent
                    # key / missing path entry -> NaN columns. Pinned at
                    # `TestByPathSupTBands::test_path_sup_t_to_dataframe_emits_cband_columns`.
                    h_cband = h_entry.get("cband_conf_int", (np.nan, np.nan))
                    cum_entry = path_cumulated.get(l_h, {})
                    het_entry = path_het.get(l_h, {}) if path_het else {}
                    het_ci = het_entry.get("conf_int", (np.nan, np.nan))
                    rows.append(
                        {
                            "path": path,
                            "frequency_rank": rank,
                            "n_groups": n_groups,
                            "horizon": l_h,
                            "effect": h_entry["effect"],
                            "se": h_entry["se"],
                            "t_stat": h_entry["t_stat"],
                            "p_value": h_entry["p_value"],
                            "conf_int_lower": h_entry["conf_int"][0],
                            "conf_int_upper": h_entry["conf_int"][1],
                            "n_obs": h_entry["n_obs"],
                            "cband_lower": h_cband[0] if h_cband else np.nan,
                            "cband_upper": h_cband[1] if h_cband else np.nan,
                            "cumulated_effect": cum_entry.get("effect", np.nan),
                            "cumulated_se": cum_entry.get("se", np.nan),
                            # Per-path heterogeneity (Wave 5 #11). Always-
                            # present, NaN when not requested or when the
                            # entry is missing (mirrors cband_*/cumulated_*
                            # convention).
                            "het_beta": het_entry.get("beta", np.nan),
                            "het_se": het_entry.get("se", np.nan),
                            "het_t_stat": het_entry.get("t_stat", np.nan),
                            "het_p_value": het_entry.get("p_value", np.nan),
                            "het_conf_int_lower": het_ci[0] if het_ci else np.nan,
                            "het_conf_int_upper": het_ci[1] if het_ci else np.nan,
                        }
                    )
            return pd.DataFrame(rows)

        else:
            raise ValueError(
                f"Unknown level: {level!r}. Use 'overall', 'joiners_leavers', "
                f"'per_period', 'event_study', 'normalized', 'twfe_weights', "
                f"'heterogeneity', 'linear_trends', 'design2', or 'by_path'."
            )


# =============================================================================
# Internal formatting helpers
# =============================================================================


def _fmt_float(x: float) -> str:
    """Format a float; render NaN/Inf as the string 'NaN'/'Inf'."""
    if not np.isfinite(x):
        return "NaN" if np.isnan(x) else ("Inf" if x > 0 else "-Inf")
    return f"{x:.4f}"


def _format_inference_row(
    label: str,
    effect: float,
    se: float,
    t_stat: float,
    p_value: float,
) -> str:
    """Format a single inference row for the summary table."""
    e_str = f"{_fmt_float(effect):>12}"
    s_str = f"{_fmt_float(se):>12}"
    t_str = f"{t_stat:>10.3f}" if np.isfinite(t_stat) else f"{'NaN':>10}"
    p_str = f"{p_value:>10.4f}" if np.isfinite(p_value) else f"{'NaN':>10}"
    sig = _get_significance_stars(p_value) if np.isfinite(p_value) else ""
    return f"{label:<15} {e_str} {s_str} {t_str} {p_str} {sig:>6}"
