LM decision-tree recovery on known DGPs

Does the specification-test decision tree land on the model that generated the data? Each scenario simulates from a known DGP, runs the tree from one or more starting models, and records the terminal recommendation.

This is evidence about the implementation, not a guide to using it — for that see How to run Bayesian LM specification tests.

import warnings

import libpysal
import numpy as np
import pandas as pd

from neighbayes.models import OLS, SAR, SEM, SLX

warnings.filterwarnings("ignore", category=FutureWarning)
warnings.filterwarnings("ignore", category=UserWarning)
import geopandas as gpd
from libpysal.graph import Graph
from libpysal.weights import Rook

# Generate data under H0: no spatial effects
np.random.seed(42)

# Load Columbus dataset for spatial weights
columbus_path = libpysal.examples.get_path("columbus.shp")
gdf = gpd.read_file(columbus_path)

# Create a Graph (modern libpysal API) for neighbayes models
# Row-standardize the graph so spatial models work correctly
g = Graph.build_contiguity(gdf, rook=True).transform("r")
n = g.n

# Legacy W for spreg comparison
w_spreg = Rook.from_shapefile(columbus_path)
w_spreg.transform = "r"

# Get sparse and dense W matrices
W_sparse = g.sparse.tocsr().astype(np.float64)
W_dense = np.array(W_sparse.todense())

# Design matrix
k = 3
X = np.column_stack([np.ones(n), np.random.normal(size=(n, k - 1))])
beta_true = np.array([1.0, 2.0, -1.5])
y = X @ beta_true + np.random.normal(scale=1.0, size=n)

print(f"W shape: {W_sparse.shape}, nnz: {W_sparse.nnz}")
W shape: (49, 49), nnz: 200

Cross-sectional recovery

Simulate from each cross-sectional DGP, start the decision tree from several plausible models, and record where it lands.

from neighbayes.dgp.cross_sectional import (
    simulate_ols,
    simulate_sar,
    simulate_sdem,
    simulate_sdm,
    simulate_sem,
    simulate_slx,
)
from neighbayes.dgp.utils import rook_grid_weights

ALPHA = 0.05
SAMPLE_KW = dict(draws=600, tune=600, chains=2, random_seed=7, progressbar=False)

# 12x12 rook grid (n=144), large enough for stable LM tests but quick to fit.
N_SIDE = 12
W_dense_grid, W_graph = rook_grid_weights(N_SIDE)

beta = np.array([1.0, 2.0])
beta1 = np.array([1.0, 2.0])
beta2 = np.array([1.5])  # WX coefficient — large so LM-WX detects it

COMMON = dict(W=W_graph, seed=42, sigma=1.0)

scenarios = {
    "OLS": simulate_ols(beta=beta, **COMMON),
    "SAR": simulate_sar(rho=0.6, beta=beta, **COMMON),
    "SEM": simulate_sem(lam=0.6, beta=beta, **COMMON),
    "SLX": simulate_slx(beta1=beta1, beta2=beta2, **COMMON),
    "SDM": simulate_sdm(rho=0.5, beta1=beta1, beta2=beta2, **COMMON),
    "SDEM": simulate_sdem(lam=0.5, beta1=beta1, beta2=beta2, **COMMON),
}

for name, d in scenarios.items():
    print(
        f"{name:5s}  n={len(d['y']):3d}  X.shape={d['X'].shape}  "
        f"true params: {list(d['params_true'].keys())}"
    )
OLS    n=144  X.shape=(144, 2)  true params: ['beta', 'sigma']
SAR    n=144  X.shape=(144, 2)  true params: ['rho', 'beta', 'sigma']
SEM    n=144  X.shape=(144, 2)  true params: ['lam', 'beta', 'sigma']
SLX    n=144  X.shape=(144, 2)  true params: ['beta1', 'beta2', 'sigma']
SDM    n=144  X.shape=(144, 2)  true params: ['rho', 'beta1', 'beta2', 'sigma']
SDEM   n=144  X.shape=(144, 2)  true params: ['lam', 'beta1', 'beta2', 'sigma']
def to_frame(X):
    """Wrap design matrix in a DataFrame with intercept + x1, x2, ... names."""
    cols = ["intercept"] + [f"x{i}" for i in range(1, X.shape[1])]
    return pd.DataFrame(X, columns=cols)


def fit_start(start_cls, sim, **extra):
    """Fit a starting model on a simulation dict from neighbayes.dgp."""
    Xf = to_frame(sim["X"])
    yf = sim["y"]
    model = start_cls(y=yf, X=Xf, W=W_graph, logdet_method="eigenvalue", **extra)
    model.fit(**SAMPLE_KW)
    return model


def diagnose(model, alpha=ALPHA):
    """Return (recommended_model, diagnostics_df)."""
    diag = model.spatial_diagnostics()
    rec = model.spatial_diagnostics_decision(alpha=alpha, format="model")
    return rec, diag
experiments = [
    ("OLS", OLS, "OLS"),
    ("SAR", OLS, "SAR"),
    ("SEM", OLS, "SEM"),
    ("SLX", SLX, "SLX"),
    ("SDM", SAR, "SDM"),
    ("SDM", SLX, "SDM"),
    ("SDEM", SEM, "SDEM"),
    ("SDEM", SLX, "SDEM"),
]

results = []
fitted = {}
for dgp_name, start_cls, expected in experiments:
    sim = scenarios[dgp_name]
    model = fit_start(start_cls, sim)
    rec, diag = diagnose(model)
    fitted[(dgp_name, start_cls.__name__)] = (model, diag)
    results.append(
        {
            "DGP": dgp_name,
            "Starting model": start_cls.__name__,
            "Recommended": rec,
            "Expected": expected,
            "Match": "yes" if rec == expected else "no",
        }
    )

summary = pd.DataFrame(results)
summary
Initializing NUTS using jitter+adapt_diag...
Multiprocess sampling (2 chains in 2 jobs)
NUTS: [beta, sigma2]
Sampling 2 chains for 600 tune and 600 draw iterations (1_200 + 1_200 draws total) took 5 seconds.
We recommend running at least 4 chains for robust computation of convergence diagnostics
/home/runner/micromamba/envs/test/lib/python3.14/site-packages/tqdm/auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html
  from .autonotebook import tqdm as notebook_tqdm
Initializing NUTS using jitter+adapt_diag...
Multiprocess sampling (2 chains in 2 jobs)
NUTS: [beta, sigma2]
Sampling 2 chains for 600 tune and 600 draw iterations (1_200 + 1_200 draws total) took 5 seconds.
We recommend running at least 4 chains for robust computation of convergence diagnostics
Initializing NUTS using jitter+adapt_diag...
Multiprocess sampling (2 chains in 2 jobs)
NUTS: [beta, sigma2]
Sampling 2 chains for 600 tune and 600 draw iterations (1_200 + 1_200 draws total) took 5 seconds.
We recommend running at least 4 chains for robust computation of convergence diagnostics
Initializing NUTS using jitter+adapt_diag...
Multiprocess sampling (2 chains in 2 jobs)
NUTS: [beta, sigma2]
Sampling 2 chains for 600 tune and 600 draw iterations (1_200 + 1_200 draws total) took 5 seconds.
We recommend running at least 4 chains for robust computation of convergence diagnostics
/home/runner/micromamba/envs/test/lib/python3.14/site-packages/neighbayes/_logdet/_jax.py:188: ComplexWarning: Casting complex values to real discards the imaginary part
  W_arr = np.asarray(W, dtype=np.float64)
Initializing NUTS using jitter+adapt_diag...
Multiprocess sampling (2 chains in 2 jobs)
NUTS: [beta, sigma2]
Sampling 2 chains for 600 tune and 600 draw iterations (1_200 + 1_200 draws total) took 5 seconds.
We recommend running at least 4 chains for robust computation of convergence diagnostics
/home/runner/micromamba/envs/test/lib/python3.14/site-packages/neighbayes/_logdet/_jax.py:188: ComplexWarning: Casting complex values to real discards the imaginary part
  W_arr = np.asarray(W, dtype=np.float64)
Initializing NUTS using jitter+adapt_diag...
Multiprocess sampling (2 chains in 2 jobs)
NUTS: [beta, sigma2]
Sampling 2 chains for 600 tune and 600 draw iterations (1_200 + 1_200 draws total) took 5 seconds.
We recommend running at least 4 chains for robust computation of convergence diagnostics
DGP Starting model Recommended Expected Match
0 OLS OLS OLS OLS yes
1 SAR OLS SAR SAR yes
2 SEM OLS SEM SEM yes
3 SLX SLX SLX SLX yes
4 SDM SAR SARAR SDM no
5 SDM SLX SDM SDM yes
6 SDEM SEM SDEM SDEM yes
7 SDEM SLX SDEM SDEM yes
for (dgp_name, start_name), (_, diag) in fitted.items():
    rec = summary.query("DGP == @dgp_name and `Starting model` == @start_name").iloc[0]
    print(
        f"\n=== DGP={dgp_name} | start={start_name} | "
        f"recommended={rec['Recommended']} ({rec['Match']} expected {rec['Expected']}) ==="
    )
    print(diag[["statistic", "df", "p_value"]].round(4).to_string())
=== DGP=OLS | start=OLS | recommended=OLS (yes expected OLS) ===
                    statistic  df  p_value
test                                      
LM-Lag                 0.9421   1   0.3317
LM-Error               0.1455   1   0.7029
LM-SDM-Joint           2.7057   2   0.2585
LM-SLX-Error-Joint     1.0512   2   0.5912
Robust-LM-Lag          0.8982   1   0.3433
Robust-LM-Error        0.5391   1   0.4628

=== DGP=SAR | start=OLS | recommended=SAR (yes expected SAR) ===
                    statistic  df  p_value
test                                      
LM-Lag                61.1045   1   0.0000
LM-Error              25.4791   1   0.0000
LM-SDM-Joint          64.2955   2   0.0000
LM-SLX-Error-Joint    60.2682   2   0.0000
Robust-LM-Lag         36.3646   1   0.0000
Robust-LM-Error        0.0580   1   0.8097

=== DGP=SEM | start=OLS | recommended=SEM (yes expected SEM) ===
                    statistic  df  p_value
test                                      
LM-Lag                 7.7141   1   0.0055
LM-Error              28.8128   1   0.0000
LM-SDM-Joint          30.0061   2   0.0000
LM-SLX-Error-Joint    30.1095   2   0.0000
Robust-LM-Lag          1.3070   1   0.2529
Robust-LM-Error       22.4407   1   0.0000

=== DGP=SLX | start=SLX | recommended=SLX (yes expected SLX) ===
                      statistic  df  p_value
test                                        
LM-Lag                   3.0257   1   0.0820
LM-Error                 0.1117   1   0.7382
Robust-LM-Lag-SDM        1.0495   1   0.3056
Robust-LM-Error-SDEM     0.9400   1   0.3323

=== DGP=SDM | start=SAR | recommended=SARAR (no expected SDM) ===
                 statistic  df  p_value
test                                   
LM-Error            4.3588   1   0.0368
LM-WX              12.6550   1   0.0004
Robust-LM-WX        1.5853   1   0.2080
Robust-LM-Error     7.8158   1   0.0052

=== DGP=SDM | start=SLX | recommended=SDM (yes expected SDM) ===
                      statistic  df  p_value
test                                        
LM-Lag                  30.5240   1   0.0000
LM-Error                17.7450   1   0.0000
Robust-LM-Lag-SDM        9.5178   1   0.0020
Robust-LM-Error-SDEM     0.2169   1   0.6414

=== DGP=SDEM | start=SEM | recommended=SDEM (yes expected SDEM) ===
               statistic  df  p_value
test                                 
LM-Lag           69.7847   1   0.0000
LM-WX            44.2379   1   0.0000
Robust-LM-Lag     0.6776   1   0.4104
Robust-LM-WX     24.9730   1   0.0000

=== DGP=SDEM | start=SLX | recommended=SDEM (yes expected SDEM) ===
                      statistic  df  p_value
test                                        
LM-Lag                  12.0932   1   0.0005
LM-Error                16.2227   1   0.0001
Robust-LM-Lag-SDM        1.3550   1   0.2444
Robust-LM-Error-SDEM     6.6657   1   0.0098
# ASCII rendering of the full decision tree with the traversed path highlighted.
model_sdm_from_slx, _ = fitted[("SDM", "SLX")]
print(model_sdm_from_slx.spatial_diagnostics_decision(alpha=ALPHA, format="ascii"))
LM-Lag *  (p=0.0000, alpha=0.05)
├── <sig> LM-Error *  (p=0.0000, alpha=0.05)
│   ├── <sig> Robust-LM-Lag-SDM *  (p=0.0020, alpha=0.05)
│   │   ├── <sig> Robust-LM-Error-SDEM *  (p=0.6414, alpha=0.05)
│   │   │   ├── <sig> Robust-LM-Lag-SDM p <= Robust-LM-Error-SDEM p
│   │   │   │   ├── [SDM]
│   │   │   │   └── [SDEM]
│   │   │   └── [SDM] * ← SELECTED
│   │   └── <not sig> Robust-LM-Error-SDEM
│   │       ├── [SDEM]
│   │       └── [SLX]
│   └── <not sig> Robust-LM-Lag-SDM
│       ├── [SDM]
│       └── [SLX]
└── <not sig> LM-Error
    ├── <sig> Robust-LM-Error-SDEM
    │   ├── [SDEM]
    │   └── [SLX]
    └── [SLX]

Recovery findings. OLS, SAR, SEM, and SLX scenarios are recovered cleanly from their natural starting models. The SDM and SDEM scenarios are not recovered when starting from SAR/SEM/SLX: the tree escalates to SARAR (from SAR/SEM) or MANSAR (from SLX) because both the lag and the error / WX channels register significant simultaneously. This is the expected behaviour of the Koley & Bera tree — treat SARAR/MANSAR as a flag to fit both SDM and SDEM and compare with bayes_factor_compare_models.

Other things to keep in mind:

  • These experiments use strong-signal parameters; weaker spatial dependence (\(\rho \approx 0.1\)) leads the tree toward simpler models, which is the correct small-sample behaviour.

  • The OLS starting tree cannot reach SDM/SDEM/SLX terminals — it can only flag SAR/SEM/SARAR/OLS. Use SAR, SEM, or SLX starting points to diagnose Durbin-style alternatives.

  • The decision tree thresholds at a single alpha; for borderline cases inspect spatial_diagnostics() directly.

Panel recovery

The same sweep over the fixed-effects panel DGPs.

from neighbayes.dgp.panel_fe import (
    simulate_panel_ols_fe,
    simulate_panel_sar_fe,
    simulate_panel_sdem_fe,
    simulate_panel_sdm_fe,
    simulate_panel_sem_fe,
    simulate_panel_slx_fe,
)
from neighbayes.models import (
    OLSPanelFE,
    SARPanelFE,
    SEMPanelFE,
    SLXPanelFE,
)

PANEL_N_SIDE = 10
_, W_panel_graph = rook_grid_weights(PANEL_N_SIDE)
PANEL_N = PANEL_N_SIDE * PANEL_N_SIDE
PANEL_T = 5
PANEL_COMMON = dict(N=PANEL_N, T=PANEL_T, W=W_panel_graph, seed=42, sigma=1.0)
PANEL_SAMPLE_KW = dict(draws=400, tune=400, chains=2, random_seed=7, progressbar=False)

panel_scenarios = {
    "OLS": simulate_panel_ols_fe(beta=beta, **PANEL_COMMON),
    "SAR": simulate_panel_sar_fe(rho=0.5, beta=beta, **PANEL_COMMON),
    "SEM": simulate_panel_sem_fe(lam=0.5, beta=beta, **PANEL_COMMON),
    "SLX": simulate_panel_slx_fe(beta1=beta1, beta2=beta2, **PANEL_COMMON),
    "SDM": simulate_panel_sdm_fe(rho=0.4, beta1=beta1, beta2=beta2, **PANEL_COMMON),
    "SDEM": simulate_panel_sdem_fe(lam=0.4, beta1=beta1, beta2=beta2, **PANEL_COMMON),
}
def fit_panel_start(start_cls, sim):
    Xf = to_frame(sim["X"])
    m = start_cls(
        y=sim["y"],
        X=Xf,
        W=W_panel_graph,
        N=PANEL_N,
        T=PANEL_T,
        effects=3,  # two-way fixed effects
    )
    m.fit(**PANEL_SAMPLE_KW)
    return m


panel_experiments = [
    ("OLS", OLSPanelFE, "OLSPanelFE"),
    ("SAR", OLSPanelFE, "SARPanelFE"),
    ("SEM", OLSPanelFE, "SEMPanelFE"),
    ("SLX", SLXPanelFE, "SLXPanelFE"),
    ("SDM", SARPanelFE, "SDMPanelFE"),
    ("SDM", SLXPanelFE, "SDMPanelFE"),
    ("SDEM", SEMPanelFE, "SDEMPanelFE"),
    ("SDEM", SLXPanelFE, "SDEMPanelFE"),
]

panel_results = []
panel_fitted = {}
for dgp_name, start_cls, expected in panel_experiments:
    m = fit_panel_start(start_cls, panel_scenarios[dgp_name])
    diag = m.spatial_diagnostics()
    rec = m.spatial_diagnostics_decision(alpha=ALPHA, format="model")
    panel_fitted[(dgp_name, start_cls.__name__)] = (m, diag)
    panel_results.append(
        {
            "DGP": dgp_name,
            "Starting model": start_cls.__name__,
            "Recommended": rec,
            "Expected": expected,
            "Match": "yes" if rec == expected else "no",
        }
    )

panel_summary = pd.DataFrame(panel_results)
panel_summary
Initializing NUTS using jitter+adapt_diag...
Multiprocess sampling (2 chains in 2 jobs)
NUTS: [beta, sigma2]
Sampling 2 chains for 400 tune and 400 draw iterations (800 + 800 draws total) took 5 seconds.
We recommend running at least 4 chains for robust computation of convergence diagnostics
Initializing NUTS using jitter+adapt_diag...
Multiprocess sampling (2 chains in 2 jobs)
NUTS: [beta, sigma2]
Sampling 2 chains for 400 tune and 400 draw iterations (800 + 800 draws total) took 5 seconds.
We recommend running at least 4 chains for robust computation of convergence diagnostics
Initializing NUTS using jitter+adapt_diag...
Multiprocess sampling (2 chains in 2 jobs)
NUTS: [beta, sigma2]
Sampling 2 chains for 400 tune and 400 draw iterations (800 + 800 draws total) took 5 seconds.
We recommend running at least 4 chains for robust computation of convergence diagnostics
Initializing NUTS using jitter+adapt_diag...
Multiprocess sampling (2 chains in 2 jobs)
NUTS: [beta, sigma2]
Sampling 2 chains for 400 tune and 400 draw iterations (800 + 800 draws total) took 5 seconds.
We recommend running at least 4 chains for robust computation of convergence diagnostics
The rhat statistic is larger than 1.01 for some parameters. This indicates problems during sampling. See https://arxiv.org/abs/1903.08008 for details
/home/runner/micromamba/envs/test/lib/python3.14/site-packages/neighbayes/_logdet/_jax.py:188: ComplexWarning: Casting complex values to real discards the imaginary part
  W_arr = np.asarray(W, dtype=np.float64)
Initializing NUTS using jitter+adapt_diag...
Multiprocess sampling (2 chains in 2 jobs)
NUTS: [beta, sigma2]
Sampling 2 chains for 400 tune and 400 draw iterations (800 + 800 draws total) took 5 seconds.
We recommend running at least 4 chains for robust computation of convergence diagnostics
/home/runner/micromamba/envs/test/lib/python3.14/site-packages/neighbayes/_logdet/_jax.py:188: ComplexWarning: Casting complex values to real discards the imaginary part
  W_arr = np.asarray(W, dtype=np.float64)
Initializing NUTS using jitter+adapt_diag...
Multiprocess sampling (2 chains in 2 jobs)
NUTS: [beta, sigma2]
Sampling 2 chains for 400 tune and 400 draw iterations (800 + 800 draws total) took 5 seconds.
We recommend running at least 4 chains for robust computation of convergence diagnostics
The rhat statistic is larger than 1.01 for some parameters. This indicates problems during sampling. See https://arxiv.org/abs/1903.08008 for details
DGP Starting model Recommended Expected Match
0 OLS OLSPanelFE OLSPanelFE OLSPanelFE yes
1 SAR OLSPanelFE SARPanelFE SARPanelFE yes
2 SEM OLSPanelFE SEMPanelFE SEMPanelFE yes
3 SLX SLXPanelFE SLXPanelFE SLXPanelFE yes
4 SDM SARPanelFE SDMPanelFE SDMPanelFE yes
5 SDM SLXPanelFE SDMPanelFE SDMPanelFE yes
6 SDEM SEMPanelFE SDEMPanelFE SDEMPanelFE yes
7 SDEM SLXPanelFE SDEMPanelFE SDEMPanelFE yes
for (dgp_name, start_name), (_, diag) in panel_fitted.items():
    rec = panel_summary.query(
        "DGP == @dgp_name and `Starting model` == @start_name"
    ).iloc[0]
    print(
        f"\n=== DGP={dgp_name} | start={start_name} | "
        f"recommended={rec['Recommended']} ({rec['Match']} expected {rec['Expected']}) ==="
    )
    print(diag[["statistic", "df", "p_value"]].round(4).to_string())
=== DGP=OLS | start=OLSPanelFE | recommended=OLSPanelFE (yes expected OLSPanelFE) ===
                          statistic  df  p_value
test                                            
Panel-LM-Lag                 0.3659   1   0.5453
Panel-LM-Error               0.0028   1   0.9577
Panel-LM-SDM-Joint           0.5264   2   0.7686
Panel-LM-SLX-Error-Joint     0.5278   2   0.7681
Panel-Robust-LM-Lag          0.5128   1   0.4739
Panel-Robust-LM-Error        0.1525   1   0.6962

=== DGP=SAR | start=OLSPanelFE | recommended=SARPanelFE (yes expected SARPanelFE) ===
                          statistic  df  p_value
test                                            
Panel-LM-Lag               158.1953   1   0.0000
Panel-LM-Error              50.2981   1   0.0000
Panel-LM-SDM-Joint         159.3719   2   0.0000
Panel-LM-SLX-Error-Joint   159.6871   2   0.0000
Panel-Robust-LM-Lag        110.7468   1   0.0000
Panel-Robust-LM-Error        1.1803   1   0.2773

=== DGP=SEM | start=OLSPanelFE | recommended=SEMPanelFE (yes expected SEMPanelFE) ===
                          statistic  df  p_value
test                                            
Panel-LM-Lag                16.9363   1   0.0000
Panel-LM-Error              60.3938   1   0.0000
Panel-LM-SDM-Joint          61.1549   2   0.0000
Panel-LM-SLX-Error-Joint    61.2138   2   0.0000
Panel-Robust-LM-Lag          0.8092   1   0.3684
Panel-Robust-LM-Error       44.6549   1   0.0000

=== DGP=SLX | start=SLXPanelFE | recommended=SLXPanelFE (yes expected SLXPanelFE) ===
                            statistic  df  p_value
test                                              
Panel-LM-Lag                   1.8591   1   0.1727
Panel-LM-Error                 0.0058   1   0.9394
Panel-Robust-LM-Lag-SDM        7.4781   1   0.0062
Panel-Robust-LM-Error-SDEM     5.6128   1   0.0178

=== DGP=SDM | start=SARPanelFE | recommended=SDMPanelFE (yes expected SDMPanelFE) ===
                    statistic  df  p_value
test                                      
Panel-LM-Error      1163.0711   1      0.0
Panel-LM-WX           52.4066   1      0.0
Panel-Robust-LM-WX    48.1504   1      0.0

=== DGP=SDM | start=SLXPanelFE | recommended=SDMPanelFE (yes expected SDMPanelFE) ===
                            statistic  df  p_value
test                                              
Panel-LM-Lag                  57.0450   1   0.0000
Panel-LM-Error                21.9613   1   0.0000
Panel-Robust-LM-Lag-SDM       37.4341   1   0.0000
Panel-Robust-LM-Error-SDEM     2.2783   1   0.1312

=== DGP=SDEM | start=SEMPanelFE | recommended=SDEMPanelFE (yes expected SDEMPanelFE) ===
              statistic  df  p_value
test                                
Panel-LM-Lag   211.9758   1      0.0
Panel-LM-WX    189.2647   1      0.0

=== DGP=SDEM | start=SLXPanelFE | recommended=SDEMPanelFE (yes expected SDEMPanelFE) ===
                            statistic  df  p_value
test                                              
Panel-LM-Lag                  23.8309   1   0.0000
Panel-LM-Error                34.2883   1   0.0000
Panel-Robust-LM-Lag-SDM        9.2329   1   0.0024
Panel-Robust-LM-Error-SDEM    19.8298   1   0.0000

Recovery findings (panel). All six panel DGPs are recovered correctly. The redesigned _panel_sar_spec / _panel_sem_spec / _panel_slx_spec decision trees disambiguate Durbin-family alternatives by checking the robust LM-WX channel from a SAR fit, the LM-WX channel from a SEM fit, and — from an SLX start — by tie-breaking the joint Lag-SDM / Error-SDEM signal with the panel_lag_sdm_pval_le_error_sdem_pval predicate (smaller p wins).