diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,7 +5,37 @@
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
 and this project adheres to [PVP](https://pvp.haskell.org/) versioning.
 
-## [Unreleased]
+## [0.2.0.0] - 2026-07-18
+
+### Added (NUTS streaming callback for live MCMC progress)
+- `Hanalyze.MCMC.NUTS.nutsStream` — new sampler entry point taking a
+  per-iteration callback `(SampleEvent -> IO ())`. Each event reports
+  iteration index, burn-in flag, current sample (constrained), Hamiltonian
+  energy, divergence / accept flags, and current step size.
+- `Hanalyze.MCMC.NUTS.SampleEvent` — exported record carrying the above.
+- Existing `nuts` is now a thin wrapper over `nutsStream` with a no-op
+  callback (API and behaviour unchanged).
+- Use case: downstream apps (e.g. a live-dashboard frontend) can push live
+  trace plots / R-hat / ESS over WebSocket / SSE without modifying NUTS
+  internals.
+
+### Added
+- Large-scale sync from the private development fork: ~105 new modules
+  covering a substantially expanded HBM Bayesian inference engine (NUTS
+  performance work validated against posteriordb benchmarks, a library of
+  analytic-gradient primitives), a Formula DSL (R-style model formulas), DOE
+  Custom Design, survival analysis (AFT, competing risks), LiNGAM causal
+  discovery, and ML additions (SVM, gradient boosting, and related models).
+- `Hanalyze.*.Plot` integration layer (`plot-integration` cabal flag) bridging
+  analysis outputs to the `hgg` plotting library's `VisualSpec`.
+- `essBulk` — arviz-compatible bulk effective sample size diagnostic.
+
+### Changed
+- The `dataframe` dependency is now consumed via qualified submodule imports
+  (`DX`/`DXC`/`DXD`) instead of the monolithic re-export, reducing coupling
+  to internal `dataframe` layout.
+- Documentation (`docs/`) and benchmark suite (`bench/`) substantially
+  expanded alongside the above modules.
 
 ## [0.1.0.1] - 2026-05-20
 
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -2,10 +2,10 @@
 
 > 🌐 **English** | [日本語](README.ja.md)
 
-[![License: BSD-3](https://img.shields.io/badge/License-BSD%203--Clause-blue.svg)](LICENSE)
+[![License: BSD-3](https://img.shields.io/badge/License-BSD%203--Clause-blue.svg)](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/LICENSE)
 [![GHC](https://img.shields.io/badge/GHC-9.6.7-blueviolet.svg)](https://www.haskell.org/ghc/)
 
-**hanalyze** is a Haskell-native statistical engineering toolkit: regression, GLMM, Bayesian inference (HMC/NUTS/Gibbs/ADVI), Gaussian processes, design of experiments, multi-objective optimisation, and HTML reporting integrated under one API.
+**hanalyze** is a Haskell-native statistical engineering toolkit: regression, GLMM, Bayesian inference (HMC/NUTS/Gibbs/ADVI/SMC), Gaussian processes, machine learning (SVM / gradient boosting / neural networks), survival analysis (KM / Cox / AFT / competing risks), time series (ARIMA / GARCH / state space), causal discovery (LiNGAM) and treatment-effect estimation, design of experiments (classical + custom optimal design), multi-objective optimisation, native plotting, and HTML reporting integrated under one API.
 Core modelling and optimisation logic is implemented in Haskell, with numerical linear algebra delegated to hmatrix/BLAS/LAPACK. **No R/Stan/Python bridge required**.
 Benchmarks (see below) show competitive accuracy with Python/R references in the tested cases. Performance varies by domain: optimisation and small-to-medium MCMC workloads are often faster in these benchmarks, while large-scale ML/GLM workloads are currently slower than sklearn.
 
@@ -15,102 +15,199 @@
 
 - **Haskell-native**: types catch many dtype/API mismatches; shape checks happen at runtime where needed
 - **Algorithms in Haskell, BLAS for numerics**: hmatrix/BLAS/LAPACK powers linear algebra; no R/Stan/Python bridge
+- **Native plotting**: 90+ documented figure types through the [hgg](https://github.com/frenzieddoll/hgg) grammar-of-graphics integration (`plot-integration` flag) — pure-Haskell SVG output, no browser required (see [Gallery](#gallery))
 - **HTML reporting**: MathJax/Mermaid + Vega-Lite visualisations in one call; PNG/SVG export available for supported plots
 - **Dirty-data defence**: 8 warning codes + auto-sniff (delim/header/encoding) + cleaning DSL
 - **Hackage `dataframe`**: Polars-like DataFrame used directly; CSV native, Parquet/JSON support through `dataframe`
 
 ---
 
+## Gallery
+
+Every figure below (and 90+ more across [`docs/`](https://github.com/frenzieddoll/hanalyze/tree/v0.2.0.0/docs/)) is generated straight
+from analysis results via the hgg integration — pure Haskell, SVG out.
+
+| | |
+|:--:|:--:|
+| ![Linear regression with CI band](https://raw.githubusercontent.com/frenzieddoll/hanalyze/v0.2.0.0/docs/images/lm-scatter-ci.svg)<br>Linear regression — fit + 95% CI ([docs](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/regression/01-lm.md)) | ![HBM MCMC dashboard](https://raw.githubusercontent.com/frenzieddoll/hanalyze/v0.2.0.0/docs/images/hbm-dashboard.svg)<br>Bayesian MCMC dashboard — trace / density / R̂ / ESS ([docs](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/bayesian/viz-diagnostics.md)) |
+| ![Gaussian process mean and credible band](https://raw.githubusercontent.com/frenzieddoll/hanalyze/v0.2.0.0/docs/images/gp-mean-ci.svg)<br>Gaussian process — mean + credible band ([docs](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/regression/04-gp.md)) | ![Kernel SVM decision boundary](https://raw.githubusercontent.com/frenzieddoll/hanalyze/v0.2.0.0/docs/images/svm-rbf-boundary.svg)<br>Kernel SVM (RBF) — decision boundary + support vectors ([docs](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/ml/usage-ml-extensions.md)) |
+| ![DOE prediction profiler](https://raw.githubusercontent.com/frenzieddoll/hanalyze/v0.2.0.0/docs/images/doe-profiler.svg)<br>DOE prediction profiler — response vs each factor + CI ([docs](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/api-guide/09-doe.md)) | ![RSM 3D response surface](https://raw.githubusercontent.com/frenzieddoll/hanalyze/v0.2.0.0/docs/images/rsm-surface-3d.svg)<br>RSM response surface (3D) ([docs](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/doe/01-doe.md)) |
+| ![DirectLiNGAM causal DAG](https://raw.githubusercontent.com/frenzieddoll/hanalyze/v0.2.0.0/docs/images/lingam-dag.svg)<br>DirectLiNGAM causal discovery — estimated DAG ([docs](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/api-guide/08-causal.md)) | ![Kaplan-Meier survival curves](https://raw.githubusercontent.com/frenzieddoll/hanalyze/v0.2.0.0/docs/images/km-survival.svg)<br>Kaplan-Meier survival curves ([docs](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/regression/10-survival.md)) |
+| ![Time-series forecast](https://raw.githubusercontent.com/frenzieddoll/hanalyze/v0.2.0.0/docs/images/ts-forecast.svg)<br>Time-series forecast ([docs](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/regression/09-timeseries.md)) | ![k-means clusters with 95% ellipses](https://raw.githubusercontent.com/frenzieddoll/hanalyze/v0.2.0.0/docs/images/kmeans-ellipse.svg)<br>k-means clusters + 95% ellipses ([docs](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/stat/05-cluster.md)) |
+
+---
+
 ## Capabilities
 
 Features grouped by category. Each capability links to a usage doc and (where relevant) a theory doc.
+The full API reference lives in [`docs/api-guide/`](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/api-guide/README.md) (12 chapters).
 
 ### Statistical inference (`Hanalyze.Stat.*`)
 
 | Feature | Module | Usage | Theory |
 |---|---|---|---|
-| 12 hypothesis tests (t/χ²/ANOVA/Wilcoxon/KS/Shapiro/Levene/Bartlett/...) | `Hanalyze.Stat.Test` | [stat/01-test.md](docs/stat/01-test.md) | — |
-| Multiple-testing correction (Bonferroni/Holm/BH/BY) | `Hanalyze.Stat.MultipleTesting` | [stat/06-multipletesting.md](docs/stat/06-multipletesting.md) | — |
-| Bootstrap CI / permutation tests | `Hanalyze.Stat.Bootstrap` | [stat/07-bootstrap.md](docs/stat/07-bootstrap.md) | — |
-| Effect size + power analysis (Cohen's d/η²/Cramér V/n estimation) | `Hanalyze.Stat.Effect` | [stat/09-effect.md](docs/stat/09-effect.md) | — |
-| Cross-validation (k-fold/stratified/LOO) + Grid search | `Hanalyze.Stat.CV` | [stat/04-cv.md](docs/stat/04-cv.md) | — |
+| 12 hypothesis tests (t/χ²/ANOVA/Wilcoxon/KS/Shapiro/Levene/Bartlett/...) | `Hanalyze.Stat.Test` | [stat/01-test.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/stat/01-test.md) | — |
+| Multiple-testing correction (Bonferroni/Holm/BH/BY) | `Hanalyze.Stat.MultipleTesting` | [stat/06-multipletesting.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/stat/06-multipletesting.md) | — |
+| Bootstrap CI / permutation tests | `Hanalyze.Stat.Bootstrap` | [stat/07-bootstrap.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/stat/07-bootstrap.md) | — |
+| Effect size + power analysis (Cohen's d/η²/Cramér V/n estimation) | `Hanalyze.Stat.Effect` | [stat/09-effect.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/stat/09-effect.md) | — |
+| Cross-validation (k-fold/stratified/LOO) + Grid search | `Hanalyze.Stat.CV` | [stat/04-cv.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/stat/04-cv.md) | — |
 
 ### Regression (`Hanalyze.Model.*`)
 
 | Feature | Module | Usage | Theory |
 |---|---|---|---|
-| Linear regression (LM) + inference stats (SE/t/p, F, AIC/BIC, leverage, Cook's) | `Hanalyze.Model.LM` / `Hanalyze.Model.LM.Diagnostics` | [regression/01-lm.md](docs/regression/01-lm.md) | [principles/lm.md](docs/principles/lm.md) |
-| GLM (Binomial / Poisson / Gaussian) | `Hanalyze.Model.GLM` | [regression/02-glm.md](docs/regression/02-glm.md) | [principles/glm.md](docs/principles/glm.md) |
-| GLMM / mixed-effects model (LME) | `Hanalyze.Model.GLMM` | [regression/03-glmm.md](docs/regression/03-glmm.md) | [principles/glmm.md](docs/principles/glmm.md) |
-| Spline regression (B-spline / NaturalCubic) | `Hanalyze.Model.Spline` | [regression/04-spline.md](docs/regression/04-spline.md) | [regression/theory-regression-extensions.md](docs/regression/theory-regression-extensions.md) |
-| Kernel regression (NW / Kernel Ridge) + multi-D inputs | `Hanalyze.Model.Kernel` | [regression/04-kernel.md](docs/regression/04-kernel.md) | same |
-| Regularised (Ridge / Lasso / ElasticNet) | `Hanalyze.Model.Regularized` | [regression/04-regularized.md](docs/regression/04-regularized.md) | same |
-| Gaussian process (RBF / Matérn / Periodic + ARD + multi-input) | `Hanalyze.Model.GP` | [regression/04-gp.md](docs/regression/04-gp.md) | [principles/gp.md](docs/principles/gp.md) |
-| Random Fourier Features (large-scale GP approximation) | `Hanalyze.Model.RFF` | [regression/04-rff.md](docs/regression/04-rff.md) | [regression/theory-regression-extensions.md](docs/regression/theory-regression-extensions.md) |
-| Multivariate regression / Multi-output GP | `Hanalyze.Model.{Multivariate,MultiGP,MultiOutput}` | [regression/05-multivariate.md](docs/regression/05-multivariate.md) | [regression/theory-multivariate.md](docs/regression/theory-multivariate.md) |
-| Quantile regression | `Hanalyze.Model.Quantile` | [regression/06-quantile.md](docs/regression/06-quantile.md) | [regression/theory-regression-extensions.md](docs/regression/theory-regression-extensions.md) |
-| Generalized additive model (GAM) | `Hanalyze.Model.GAM` | [regression/06-gam.md](docs/regression/06-gam.md) | same |
-| Random forest (regression) | `Hanalyze.Model.RandomForest` | [regression/06-randomforest.md](docs/regression/06-randomforest.md) | same |
-| Multi-output regression + interactive HTML | `Hanalyze.Model.MultiOutput` | [regression/07-multireg.md](docs/regression/07-multireg.md) | [regression/theory-multivariate.md](docs/regression/theory-multivariate.md) |
+| Formula DSL (declare models as `"y x = b0 + b1*x + bg ! group"` or R `"y ~ x + C(g)"`; `ModelFrame` / `designMatrixF` / `fitLMF` + missing policy / contrast `C(g, Sum)` / WLS `fitWLSF` / nonlinear `fitNLS` / random effects `(1+x|g)` via `fitMixedLME`/`fitMixedGLMM`) | `Hanalyze.Model.Formula` / `.Frame` / `.Design` / `.RFormula` / `.Nonlinear` / `.Mixed` | [regression/11-formula-dsl.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/regression/11-formula-dsl.md) | — |
+| Linear regression (LM) + inference stats (SE/t/p, F, AIC/BIC, leverage, Cook's) | `Hanalyze.Model.LM` / `Hanalyze.Model.LM.Diagnostics` | [regression/01-lm.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/regression/01-lm.md) | [principles/lm.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/principles/lm.md) |
+| GLM (Binomial / Poisson / Gaussian) | `Hanalyze.Model.GLM` | [regression/02-glm.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/regression/02-glm.md) | [principles/glm.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/principles/glm.md) |
+| GLMM / mixed-effects model (LME) | `Hanalyze.Model.GLMM` | [regression/03-glmm.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/regression/03-glmm.md) | [principles/glmm.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/principles/glmm.md) |
+| Spline regression (B-spline / NaturalCubic) | `Hanalyze.Model.Spline` | [regression/04-spline.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/regression/04-spline.md) | [regression/theory-regression-extensions.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/regression/theory-regression-extensions.md) |
+| Kernel regression (NW / Kernel Ridge) + multi-D inputs | `Hanalyze.Model.Kernel` | [regression/04-kernel.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/regression/04-kernel.md) | same |
+| Regularised (Ridge / Lasso / ElasticNet) | `Hanalyze.Model.Regularized` | [regression/04-regularized.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/regression/04-regularized.md) | same |
+| Robust regression (Huber / Tukey biweight M-estimators, IRLS) | `Hanalyze.Model.Robust` | [regression/usage-regularized-advanced.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/regression/usage-regularized-advanced.md) | — |
+| Gaussian process (RBF / Matérn / Periodic + ARD + multi-input) | `Hanalyze.Model.GP` | [regression/04-gp.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/regression/04-gp.md) | [principles/gp.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/principles/gp.md) |
+| Random Fourier Features (large-scale GP approximation) | `Hanalyze.Model.RFF` | [regression/04-rff.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/regression/04-rff.md) | [regression/theory-regression-extensions.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/regression/theory-regression-extensions.md) |
+| Multivariate regression / Multi-output GP | `Hanalyze.Model.{Multivariate,MultiGP,MultiOutput}` | [regression/05-multivariate.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/regression/05-multivariate.md) | [regression/theory-multivariate.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/regression/theory-multivariate.md) |
+| Quantile regression | `Hanalyze.Model.Quantile` | [regression/06-quantile.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/regression/06-quantile.md) | [regression/theory-regression-extensions.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/regression/theory-regression-extensions.md) |
+| Generalized additive model (GAM) | `Hanalyze.Model.GAM` | [regression/06-gam.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/regression/06-gam.md) | same |
+| Random forest (regression) | `Hanalyze.Model.RandomForest` | [regression/06-randomforest.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/regression/06-randomforest.md) | same |
+| Multi-output regression + interactive HTML | `Hanalyze.Model.MultiOutput` | [regression/07-multireg.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/regression/07-multireg.md) | [regression/theory-multivariate.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/regression/theory-multivariate.md) |
+| Partial Least Squares (PLS) regression — NIPALS + VIP + CV component selection | `Hanalyze.Model.PLS` | — | — |
+| Linear / Quadratic Discriminant Analysis (LDA / QDA) | `Hanalyze.Model.Discriminant` | — | — |
+| Gauge R&R (Measurement System Analysis, ANOVA-based crossed / nested) | `Hanalyze.Design.GaugeRR` | — | — |
 
 ### Machine learning (`Hanalyze.Model.*` / `Hanalyze.Stat.*`)
 
 | Feature | Module | Usage | Theory |
 |---|---|---|---|
-| PCA + cumulative variance + standardisation | `Hanalyze.Model.PCA` | [stat/02-pca.md](docs/stat/02-pca.md) | — |
-| Clustering (K-means + k-means++ + silhouette) | `Hanalyze.Model.Cluster` | [stat/05-cluster.md](docs/stat/05-cluster.md) | — |
-| Decision tree (CART classifier) | `Hanalyze.Model.DecisionTree` | [regression/08-decisiontree.md](docs/regression/08-decisiontree.md) | — |
-| Time series (ARIMA / Holt-Winters / STL / ACF / PACF) | `Hanalyze.Model.TimeSeries` | [regression/09-timeseries.md](docs/regression/09-timeseries.md) | — |
-| Survival analysis (Kaplan-Meier / Nelson-Aalen / Log-rank / Cox PH) | `Hanalyze.Model.Survival` | [regression/10-survival.md](docs/regression/10-survival.md) | — |
-| Classification metrics (Confusion / AUC / F1 / MCC / log-loss / Brier) | `Hanalyze.Stat.ClassMetrics` | [stat/03-classmetrics.md](docs/stat/03-classmetrics.md) | — |
-| Model interpretation (Permutation imp / PDP / ICE) | `Hanalyze.Stat.Interpret` | [stat/13-interpret.md](docs/stat/13-interpret.md) | — |
+| PCA + cumulative variance + standardisation | `Hanalyze.Model.PCA` | [stat/02-pca.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/stat/02-pca.md) | — |
+| Clustering (K-means + k-means++ + silhouette) | `Hanalyze.Model.Cluster` | [stat/05-cluster.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/stat/05-cluster.md) | — |
+| Decision tree (CART classifier) | `Hanalyze.Model.DecisionTree` | [regression/08-decisiontree.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/regression/08-decisiontree.md) | — |
+| Kernel SVM (C-SVC, SMO dual solver) + CV hyperparameter tuning | `Hanalyze.Model.SVM` | [ml/usage-ml-extensions.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/ml/usage-ml-extensions.md) | — |
+| Gradient boosting (regression + binary classification) | `Hanalyze.Model.GradientBoosting` | [ml/usage-ml-extensions.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/ml/usage-ml-extensions.md) | — |
+| k-NN / Naive Bayes (Gaussian + Multinomial) / MLP neural network (mini-batch SGD + Adam) | `Hanalyze.Model.{KNN,NaiveBayes,NeuralNetwork}` | [ml/usage-ml-extensions.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/ml/usage-ml-extensions.md) + [api-guide/05-ml.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/api-guide/05-ml.md) | — |
+| Random forest classifier (+ permutation importance) | `Hanalyze.Model.RandomForestClassifier` | [api-guide/05-ml.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/api-guide/05-ml.md) | — |
+| MDS (classical / Sammon) | `Hanalyze.Model.MDS` | [ml/usage-ml-extensions.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/ml/usage-ml-extensions.md) | — |
+| Hierarchical clustering (agglomerative + dendrogram) | `Hanalyze.Model.HierarchicalCluster` | [stat/05-cluster.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/stat/05-cluster.md) | — |
+| Latent class analysis (EM) + graphical-lasso correlation network | `Hanalyze.Model.LatentClassAnalysis` / `Hanalyze.Stat.CorrelationNetwork` | [stat/usage-misc-stat.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/stat/usage-misc-stat.md) | — |
+| Functional data analysis (basis smoothing + FPCA) | `Hanalyze.Model.FDA` | [fda/usage-fda.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/fda/usage-fda.md) | — |
+| Time series (ARIMA / Holt-Winters / STL / ACF / PACF) | `Hanalyze.Model.TimeSeries` | [regression/09-timeseries.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/regression/09-timeseries.md) | — |
+| GARCH(1,1) volatility / linear-Gaussian state space (Kalman filter + RTS smoother) / VAR(p) | `Hanalyze.Model.{GARCH,StateSpace,VAR}` | [timeseries/usage-ts-surv-advanced.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/timeseries/usage-ts-surv-advanced.md) | — |
+| Survival analysis (Kaplan-Meier / Nelson-Aalen / Log-rank / Cox PH) | `Hanalyze.Model.Survival` | [regression/10-survival.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/regression/10-survival.md) | — |
+| Parametric survival (AFT) + competing risks (CIF) | `Hanalyze.Model.{AFT,CompetingRisks}` | [api-guide/07-survival.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/api-guide/07-survival.md) | — |
+| Classification metrics (Confusion / AUC / F1 / MCC / log-loss / Brier) | `Hanalyze.Stat.ClassMetrics` | [stat/03-classmetrics.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/stat/03-classmetrics.md) | — |
+| Model interpretation (Permutation imp / PDP / ICE) | `Hanalyze.Stat.Interpret` | [stat/13-interpret.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/stat/13-interpret.md) | — |
+| SPC control charts (X̄-R / I-MR / p / np / c / u) + Western Electric / Nelson 8-rule sets | `Hanalyze.Stat.SPC` | — | — |
+| Weibull MLE (censored / uncensored) + B_p life + Wald CI | `Hanalyze.Model.Weibull` | — | — |
+| Accelerated-life models (Arrhenius / Eyring / Inverse Power Law) | `Hanalyze.Model.Reliability` | — | — |
+| NSGA-II all-fronts (rank ≥ 1 alternatives) + per-generation progress callback | `Hanalyze.Optim.NSGA` | — | — |
+| Good vs Bad parallel comparison (Welch t + Cohen's d ranking) | `Hanalyze.Stat.GroupComparison` | — | — |
+| Hotelling T² (1-/2-sample) + one-way MANOVA (Wilks' Λ + Rao F) | `Hanalyze.Stat.Test` | — | — |
+| Lasso/Ridge/ElasticNet λ auto-selection via k-fold CV + 1-SE rule | `Hanalyze.Model.Regularized` | — | — |
+| D-optimal Augment Design (sequential addition with fixed existing rows) | `Hanalyze.Design.Optimal` | — | — |
+| Space-filling designs (LHS / Maximin LHS / Halton) | `Hanalyze.Design.SpaceFilling` | — | — |
+| Definitive Screening Design (k=4 verified, others structural) | `Hanalyze.Design.DSD` | — | — |
+| Mixture design (Simplex Lattice / Simplex Centroid) | `Hanalyze.Design.Mixture` | — | — |
+| Sequential RSM (steepest ascent + next CCD placement) | `Hanalyze.Design.Sequential` | — | — |
 
+### Causal inference (`Hanalyze.Model.LiNGAM.*` / `Hanalyze.Stat.Causal.*`)
+
+| Feature | Module | Usage | Theory |
+|---|---|---|---|
+| LiNGAM causal discovery (DirectLiNGAM / ICA-LiNGAM / Pairwise / VAR-LiNGAM / MultiGroup / ParceLiNGAM + bootstrap edge confidence) | `Hanalyze.Model.LiNGAM.*` | [api-guide/08-causal.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/api-guide/08-causal.md) | — |
+| Treatment effects (propensity score / IPW / doubly robust AIPW / CATE S-T-X meta-learners) | `Hanalyze.Stat.Causal.*` | [causal/usage-causal.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/causal/usage-causal.md) | — |
+
 ### Bayesian (`Hanalyze.MCMC.*` / `Hanalyze.Stat.*` / `Hanalyze.Model.HBM`)
 
 | Feature | Module | Usage | Theory |
 |---|---|---|---|
-| 27 probability distributions (Truncated/Censored/MvNormal/LKJ/Multinomial/...) | `Hanalyze.Stat.Distribution` | [bayesian/01-distributions.md](docs/bayesian/01-distributions.md) | [bayesian/theory-distributions.md](docs/bayesian/theory-distributions.md) |
-| Probabilistic model DSL (HBM polymorphic free monad, incl. `deterministic` / `dataNamed`) | `Hanalyze.Model.HBM` | [bayesian/02-probabilistic-model.md](docs/bayesian/02-probabilistic-model.md) | [principles/hbm.md](docs/principles/hbm.md) |
-| MCMC samplers (MH / HMC / NUTS / Slice) | `Hanalyze.MCMC.{MH,HMC,NUTS,Slice}` | [bayesian/03-mcmc-samplers.md](docs/bayesian/03-mcmc-samplers.md) | [bayesian/theory-mcmc.md](docs/bayesian/theory-mcmc.md) / [theory-hmc-nuts.md](docs/bayesian/theory-hmc-nuts.md) |
-| Gibbs sampling (auto-conjugate detection + hybrid) | `Hanalyze.MCMC.Gibbs` | [bayesian/04-gibbs.md](docs/bayesian/04-gibbs.md) | [bayesian/theory-mcmc.md](docs/bayesian/theory-mcmc.md) |
-| Variational inference (ADVI mean-field Adam) | `Hanalyze.Stat.VI` | [bayesian/05-vi.md](docs/bayesian/05-vi.md) | [bayesian/theory-advanced.md](docs/bayesian/theory-advanced.md) |
-| Model comparison (WAIC / PSIS-LOO / Pseudo-BMA) | `Hanalyze.Stat.ModelSelect` | [bayesian/06-model-comparison.md](docs/bayesian/06-model-comparison.md) | [bayesian/theory-bayesian-basics.md](docs/bayesian/theory-bayesian-basics.md) |
-| Posterior predictive checks; selected PyMC-style modelling features | `Hanalyze.Stat.PosteriorPredictive` | [02-pymc-comparison.md](docs/02-pymc-comparison.md) | — |
+| 27 probability distributions (Truncated/Censored/MvNormal/LKJ/Multinomial/...) | `Hanalyze.Stat.Distribution` | [bayesian/01-distributions.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/bayesian/01-distributions.md) | [bayesian/theory-distributions.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/bayesian/theory-distributions.md) |
+| Probabilistic model DSL (HBM polymorphic free monad, incl. `deterministic` / `dataNamed`) | `Hanalyze.Model.HBM` | [bayesian/02-probabilistic-model.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/bayesian/02-probabilistic-model.md) | [principles/hbm.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/principles/hbm.md) |
+| MCMC samplers (MH / HMC / NUTS / Slice / tempered SMC) | `Hanalyze.MCMC.{MH,HMC,NUTS,Slice,SMC}` | [bayesian/03-mcmc-samplers.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/bayesian/03-mcmc-samplers.md) | [bayesian/theory-mcmc.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/bayesian/theory-mcmc.md) / [theory-hmc-nuts.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/bayesian/theory-hmc-nuts.md) |
+| Sampling progress display (aggregate one-liner; IO verb `df \|->! spec`, bit-identical to the pure verb) | `Hanalyze.MCMC.Progress` | [io/04-fit-api.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/io/04-fit-api.md) | — |
+| Gibbs sampling (auto-conjugate detection + hybrid) | `Hanalyze.MCMC.Gibbs` | [bayesian/04-gibbs.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/bayesian/04-gibbs.md) | [bayesian/theory-mcmc.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/bayesian/theory-mcmc.md) |
+| Variational inference (ADVI mean-field Adam) | `Hanalyze.Stat.VI` | [bayesian/05-vi.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/bayesian/05-vi.md) | [bayesian/theory-advanced.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/bayesian/theory-advanced.md) |
+| Model comparison (WAIC / PSIS-LOO / Pseudo-BMA) | `Hanalyze.Stat.ModelSelect` | [bayesian/06-model-comparison.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/bayesian/06-model-comparison.md) | [bayesian/theory-bayesian-basics.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/bayesian/theory-bayesian-basics.md) |
+| Posterior predictive checks; selected PyMC-style modelling features | `Hanalyze.Stat.PosteriorPredictive` | [02-pymc-comparison.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/02-pymc-comparison.md) | — |
+| Marginal likelihood (bridge sampling) / Bayes factors / Bayesian model averaging | `Hanalyze.Stat.{BridgeSampling,BayesFactor,BayesianModelAveraging}` | — | — |
+| Bayesian A/B test (mean difference via NUTS + ROPE/HDI decision) | `Hanalyze.MCMC.BayesianTest` | — | — |
+| Chain diagnostics (R̂, ESS incl. arviz-compatible `essBulk`, HDI, BFMI, rank histogram, KDE, autocorrelation) | `Hanalyze.Stat.MCMC` | [bayesian/viz-diagnostics.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/bayesian/viz-diagnostics.md) | — |
 
 ### Optimisation (`Hanalyze.Optim.*`)
 
 | Feature | Module | Usage | Theory |
 |---|---|---|---|
-| Single-obj (gradient): NM / L-BFGS / Brent | `Hanalyze.Optim.NelderMead`<br>`Hanalyze.Optim.LBFGS`<br>`Hanalyze.Optim.LineSearch` | [optim/01-singleobj.md](docs/optim/01-singleobj.md) | [optim/theory-singleobj.md](docs/optim/theory-singleobj.md) |
-| Single-obj (evolutionary): DE / CMA-ES / SA / PSO | `Hanalyze.Optim.DifferentialEvolution`<br>`Hanalyze.Optim.CMAES`<br>`Hanalyze.Optim.SimulatedAnnealing`<br>`Hanalyze.Optim.ParticleSwarm` | [optim/01-singleobj.md](docs/optim/01-singleobj.md) | [optim/theory-singleobj.md](docs/optim/theory-singleobj.md) |
-| Multi-objective (NSGA-II + Pareto) | `Hanalyze.Optim.{NSGA,Pareto}` | [optim/02-multi-objective.md](docs/optim/02-multi-objective.md) | [optim/theory-pareto-moo.md](docs/optim/theory-pareto-moo.md) |
-| Acquisition functions (EHVI / ParEGO / EI / LCB / PI) | `Hanalyze.Optim.Acquisition` | [optim/02-multi-objective.md](docs/optim/02-multi-objective.md) | [optim/theory-bayesopt.md](docs/optim/theory-bayesopt.md) |
-| Bayesian optimisation (BO + GP-Hedge + analytic gradient) | `Hanalyze.Optim.BayesOpt` | [optim/01-singleobj.md](docs/optim/01-singleobj.md) | [optim/theory-bayesopt.md](docs/optim/theory-bayesopt.md) |
-| Algorithm selection guide | — | [optim/03-algorithm-guide.md](docs/optim/03-algorithm-guide.md) | — |
+| Single-obj (gradient): NM / L-BFGS / Brent | `Hanalyze.Optim.NelderMead`<br>`Hanalyze.Optim.LBFGS`<br>`Hanalyze.Optim.LineSearch` | [optim/01-singleobj.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/optim/01-singleobj.md) | [optim/theory-singleobj.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/optim/theory-singleobj.md) |
+| Single-obj (evolutionary): DE / CMA-ES / SA / PSO | `Hanalyze.Optim.DifferentialEvolution`<br>`Hanalyze.Optim.CMAES`<br>`Hanalyze.Optim.SimulatedAnnealing`<br>`Hanalyze.Optim.ParticleSwarm` | [optim/01-singleobj.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/optim/01-singleobj.md) | [optim/theory-singleobj.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/optim/theory-singleobj.md) |
+| Multi-objective (NSGA-II + Pareto) | `Hanalyze.Optim.{NSGA,Pareto}` | [optim/02-multi-objective.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/optim/02-multi-objective.md) | [optim/theory-pareto-moo.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/optim/theory-pareto-moo.md) |
+| Acquisition functions (EHVI / ParEGO / EI / LCB / PI) | `Hanalyze.Optim.Acquisition` | [optim/02-multi-objective.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/optim/02-multi-objective.md) | [optim/theory-bayesopt.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/optim/theory-bayesopt.md) |
+| Bayesian optimisation (BO + GP-Hedge + analytic gradient) | `Hanalyze.Optim.BayesOpt` | [optim/01-singleobj.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/optim/01-singleobj.md) | [optim/theory-bayesopt.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/optim/theory-bayesopt.md) |
+| Algorithm selection guide | — | [optim/03-algorithm-guide.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/optim/03-algorithm-guide.md) | — |
 
 ### Design of experiments (`Hanalyze.Design.*`)
 
 | Feature | Module | Usage | Theory |
 |---|---|---|---|
-| DoE (Factorial / Block / Mixed / RSM / Optimal / Power / Quality) | `Hanalyze.Design.{Factorial,Block,Mixed,RSM,Optimal,Power,Quality,MultiRSM,Anova}` | [doe/01-doe.md](docs/doe/01-doe.md) | [doe/theory-doe.md](docs/doe/theory-doe.md) |
-| Orthogonal arrays (L4/L8/L9/L12/L16/L18) + Taguchi (S/N + inner/outer) + process capability (Cp/Cpk) | `Hanalyze.Design.{Orthogonal,Taguchi,Quality}` | [doe/02-orthogonal-taguchi.md](docs/doe/02-orthogonal-taguchi.md) | [doe/theory-doe.md](docs/doe/theory-doe.md) |
+| DoE (Factorial / Block / Mixed / RSM / Optimal / Power / Quality) | `Hanalyze.Design.{Factorial,Block,Mixed,RSM,Optimal,Power,Quality,MultiRSM,Anova}` | [doe/01-doe.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/doe/01-doe.md) | [doe/theory-doe.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/doe/theory-doe.md) |
+| Orthogonal arrays (L4/L8/L9/L12/L16/L18) + Taguchi (S/N + inner/outer) + process capability (Cp/Cpk) | `Hanalyze.Design.{Orthogonal,Taguchi,Quality}` | [doe/02-orthogonal-taguchi.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/doe/02-orthogonal-taguchi.md) | [doe/theory-doe.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/doe/theory-doe.md) |
+| Custom optimal design (coordinate exchange + modified Fedorov: D/A/G/I criteria, Bayesian D, linear constraints, split-plot, augment menus, design comparison via efficiency/FDS/alias) | `Hanalyze.Design.Custom.*` | [doe/usage-custom-design.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/doe/usage-custom-design.md) + [manual](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/doe/manual-custom-design.md) | — |
+| DOE workflow layer (R-style interactive `Design` object over the low-level design functions) | `Hanalyze.Design.Workflow` | [api-guide/09-doe.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/api-guide/09-doe.md) | — |
 
 ### Visualisation (`Hanalyze.Viz.*`)
 
 | Feature | Module | Usage |
 |---|---|---|
-| Scatter / bar / histograms / MCMC diagnostics / GP plot / Pareto plot | `Hanalyze.Viz.{Scatter,Bar,Histogram,MCMC,GP,Pareto,ModelGraph,Taguchi}` | [visualization/01-visualization.md](docs/visualization/01-visualization.md) |
-| Integrated HTML report (MathJax + Mermaid + interactive) | `Hanalyze.Viz.ReportBuilder` | [visualization/02-report-builder.md](docs/visualization/02-report-builder.md) |
+| Scatter / bar / histograms / MCMC diagnostics / GP plot / Pareto plot | `Hanalyze.Viz.{Scatter,Bar,Histogram,MCMC,GP,Pareto,ModelGraph,Taguchi}` | [visualization/01-visualization.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/visualization/01-visualization.md) |
+| Integrated HTML report (MathJax + Mermaid + interactive) | `Hanalyze.Viz.ReportBuilder` | [visualization/02-report-builder.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/visualization/02-report-builder.md) |
+| Unified fit-and-plot operator `df \|-> spec` (one entry point across LM/GLM/GAM/GP/HBM/... specs) + plot-free coefficient diagnostics | `Hanalyze.Fit` / `Hanalyze.Diagnostics` | [io/04-fit-api.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/io/04-fit-api.md) |
+| **hgg integration** (experimental): `toPlot`/`Plottable` overlays a fitted model (LM line+CI / GP mean+credible band) on the layer grammar; `module Hanalyze` quickstart entry. Flag-gated (`plot-integration`, default off). | `Hanalyze.Plot` + `module Hanalyze` | [visualization/03-plot-integration.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/visualization/03-plot-integration.md) |
+| **HBM ModelGraph (3 routes)**: Mermaid HTML / Graphviz DOT / direct SVG via hgg | `Hanalyze.Viz.{ModelGraph,ModelGraphDot}` + `Hgg.Plot.Bridge.Analyze` | see "ModelGraph — 3 routes" below |
 
+#### ModelGraph — 3 routes
+
+There are three ways to visualise the DAG of an HBM model; pick by use case:
+
+| Route | Module | Output / Deps | When to use |
+|---|---|---|---|
+| **Mermaid HTML** | `Hanalyze.Viz.ModelGraph.renderModelGraph` | `.html` + Mermaid CDN script | GitHub / GitLab READMEs, notebook attachments — auto-rendered on GitHub |
+| **Graphviz DOT** | `Hanalyze.Viz.ModelGraphDot.renderModelGraphDot` | `.dot` text + `dot` CLI (install required) | graphviz ecosystem interop (xdot / gephi / `dot -Tpng`), fine-grained directives (`rank=same` / `constraint=false` etc) |
+| **hgg direct** | `Hgg.Plot.Bridge.Analyze.renderModelGraphSVG` ([hgg-analyze-bridge](https://github.com/frenzieddoll/hgg)) | `.svg` (**zero deps**, pure Haskell) | production app embedding, offline batch, fast rendering of large DAGs |
+
+All three routes take the same `Hanalyze.Model.HBM.ModelGraph` as input. Layout
+quality vs dependency trade-off:
+
+- Mermaid: lightweight, but no offline rendering
+- Graphviz DOT: best layout quality, but requires the `dot` CLI
+- hgg: intermediate quality (roughly 70-80% of graphviz dot, pure Haskell); the only option when zero dependencies are required
+
+Code example (with `hgg-analyze-bridge` added as a dependency):
+
+```haskell
+import qualified Hanalyze.Viz.ModelGraph    as Mermaid
+import qualified Hanalyze.Viz.ModelGraphDot as Dot
+import qualified Data.Text.IO               as TIO
+import           Hgg.Plot.Bridge.Analyze (renderModelGraphSVG)
+import           Hanalyze.Model.HBM          (buildModelGraph)
+
+main = do
+  let mg = buildModelGraph myHBM
+  Mermaid.renderModelGraph "out/dag.html" "My HBM" mg            -- Route 1
+  TIO.writeFile "out/dag.dot" (Dot.renderModelGraphDot mg)       -- Route 2
+  renderModelGraphSVG     "out/dag.svg"  "My HBM" mg             -- Route 3
+```
+
+Note: for standard plots, hgg also ships native PNG (Rasterific) and PDF
+backends. For the ModelGraph SVG route, convert via `rsvg-convert` / `inkscape`
+when PNG / PDF is needed.
+
 ### Data I/O (`Hanalyze.DataIO.*`)
 
 | Feature | Module | Usage |
 |---|---|---|
-| CSV/TSV/SSV (cassava) + Parquet/JSON (Hackage `dataframe`) | `Hanalyze.DataIO.{CSV,External,Convert}` | [io/01-dirty-data.md](docs/io/01-dirty-data.md) |
-| Dirty-data defence (W001-W008 warnings + auto-sniff + clean DSL) | `Hanalyze.DataIO.{Health,Sniff,Clean,Log}` | [io/01-dirty-data.md](docs/io/01-dirty-data.md) |
-| Reshape (pivot_wider / one-hot / lag-lead / rolling window) | `Hanalyze.DataIO.Reshape` | [io/02-reshape.md](docs/io/02-reshape.md) |
-| Preprocessing (impute / groupBy / derived columns / melt) | `Hanalyze.DataIO.Preprocess` | [io/01-dirty-data.md](docs/io/01-dirty-data.md) |
-| Long-form regrid (`regridLong`) | `Hanalyze.DataIO.Preprocess` + `Hanalyze.Stat.Interpolate` | [io/03-regrid.md](docs/io/03-regrid.md) |
+| CSV/TSV/SSV (cassava) + Parquet/JSON (Hackage `dataframe`) | `Hanalyze.DataIO.{CSV,External,Convert}` | [io/01-dirty-data.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/io/01-dirty-data.md) |
+| Dirty-data defence (W001-W008 warnings + auto-sniff + clean DSL) | `Hanalyze.DataIO.{Health,Sniff,Clean,Log}` | [io/01-dirty-data.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/io/01-dirty-data.md) |
+| Reshape (pivot_wider / one-hot / lag-lead / rolling window) | `Hanalyze.DataIO.Reshape` | [io/02-reshape.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/io/02-reshape.md) |
+| Preprocessing (impute / groupBy / derived columns / melt) | `Hanalyze.DataIO.Preprocess` | [io/01-dirty-data.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/io/01-dirty-data.md) |
+| Long-form regrid (`regridLong`) | `Hanalyze.DataIO.Preprocess` + `Hanalyze.Stat.Interpolate` | [io/03-regrid.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/io/03-regrid.md) |
 
 ---
 
@@ -136,7 +233,7 @@
 ### 30 seconds via Haskell API
 
 ```haskell
-import qualified Stat.Test as ST
+import qualified Hanalyze.Stat.Test as ST
 import qualified Numeric.LinearAlgebra as LA
 
 main = do
@@ -147,8 +244,13 @@
   -- (0.012, Just ("Cohen's d", -1.85))
 ```
 
-See [docs/01-quickstart.md](docs/01-quickstart.md) for a fuller introduction.
+A single `import Hanalyze` re-exports the core entry points (linear / GLM models,
+descriptive stats, tests, effect sizes, distributions, plotting helpers and CSV
+I/O) for quick exploration; reach for the individual `Hanalyze.Model.*` /
+`Hanalyze.Stat.*` modules when you need their full surface.
 
+See [docs/01-quickstart.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/01-quickstart.md) for a fuller introduction.
+
 ---
 
 ## CLI
@@ -169,13 +271,13 @@
 hanalyze clean <file> --rule ...  dirty-data cleaning
 ```
 
-For per-command flags, run `hanalyze <cmd> --help` or see [docs/01-quickstart.md](docs/01-quickstart.md).
+For per-command flags, run `hanalyze <cmd> --help` or see [docs/01-quickstart.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/01-quickstart.md).
 
 ---
 
 ## Examples / demos
 
-`demo/` contains many demos (60+ as of this release). Highlights:
+`demo/` contains many demos (76 as of this release). Highlights:
 
 | Demo | Summary |
 |---|---|
@@ -188,7 +290,7 @@
 | `demo/bayesian/SimpsonParadoxDemo.hs` | Disentangle Simpson's paradox via hierarchical model |
 | `demo/io/DirtyDataDemo.hs` | Auto-defend against 19 dirty CSV variants |
 
-Run: `dist-newstyle/build/x86_64-linux/ghc-9.6.7/hanalyze-0.1.0.0/x/<demo-name>/build/<demo-name>/<demo-name>`.
+Run: `dist-newstyle/build/x86_64-linux/ghc-9.6.7/hanalyze-0.2.0.0/x/<demo-name>/build/<demo-name>/<demo-name>`.
 
 ---
 
@@ -244,7 +346,7 @@
 | **Multi-output regression / Regrid** | MultiLM 2.3× faster than sklearn; `regridLong` 20× faster than a hand-written pandas+scipy synthesis. |
 | **Visualisation** | Vega-Lite specs via hvega (grammar-of-graphics-style); HTML reports built-in. |
 
-See [docs/comparison/python-r.md](docs/comparison/python-r.md) for the feature map, and [bench/results/SUMMARY.md](bench/results/SUMMARY.md) for numbers.
+See [docs/comparison/python-r.md](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/docs/comparison/python-r.md) for the feature map, and [bench/results/SUMMARY.md](bench/results/SUMMARY.md) for numbers.
 
 ---
 
@@ -253,6 +355,8 @@
 Selected results from `bench/results/SUMMARY.md`. Each entry is a single
 benchmark configuration; absolute objective values depend on iteration
 counts, seeds, and tolerances — see the SUMMARY for full conditions.
+NUTS is additionally validated against posteriordb reference posteriors
+(see [bench/posteriordb/](bench/posteriordb/)).
 
 - **NUTS 8-schools** (warmup 500, samples 1000): hanalyze 1492 ms with ESS(mu) 839 vs blackjax 530 ms / ESS 810 in this run
 - **Holt-Winters seasonal n=500 p=12**: hanalyze 0.19 ms vs statsmodels MLE 96 ms in this run (note: hanalyze uses fixed α=0.3 closed-form; statsmodels does MLE)
@@ -292,8 +396,8 @@
 ## Roadmap & API stability
 
 - **Stable** (API expected to remain backward-compatible within minor versions): `Hanalyze.DataIO.*`, `Hanalyze.Stat.{Test, Bootstrap, MultipleTesting, ClassMetrics, CV, Effect, Distribution}`, `Hanalyze.Model.{LM, GLM, Spline, Regularized, RandomForest, DecisionTree, TimeSeries, Survival, GAM}`, `Hanalyze.Optim.{NelderMead, LBFGS, DifferentialEvolution, CMAES, NSGA, BayesOpt, SimulatedAnnealing, ParticleSwarm}`, `Hanalyze.Design.*`, `Hanalyze.Viz.{Scatter, Bar, Histogram}`.
-- **Experimental** (API may evolve): `Hanalyze.Model.HBM` DSL, `Hanalyze.MCMC.NUTS` (mass-matrix adaptation is opt-in), `Hanalyze.Stat.VI` (ADVI), `Hanalyze.Model.{GP, RFF, GPRobust, GLMM}`, `Hanalyze.Viz.ReportBuilder`. Behaviour is benchmarked but type signatures may shift.
-- **Future direction**: a unified top-level `Hanalyze.*` re-export layer, a Pipeline-style `Unfitted → Fitted` API, and a backend-abstraction typeclass for swapping hmatrix/Massiv/Accelerate are under consideration but not on a fixed schedule.
+- **Experimental** (API may evolve): `Hanalyze.Model.HBM` DSL, `Hanalyze.MCMC.NUTS` (mass-matrix adaptation is opt-in), `Hanalyze.Stat.VI` (ADVI), `Hanalyze.Model.{GP, RFF, GPRobust, GLMM}`, `Hanalyze.Model.{SVM, GradientBoosting, NeuralNetwork}`, `Hanalyze.Model.LiNGAM.*`, `Hanalyze.Design.Custom.*`, the `df |-> spec` fit operator (`Hanalyze.Fit`), the hgg integration (`plot-integration` flag), `Hanalyze.Viz.ReportBuilder`. Behaviour is benchmarked but type signatures may shift.
+- **Future direction**: a backend-abstraction typeclass for swapping hmatrix/Massiv/Accelerate is under consideration but not on a fixed schedule. (The unified top-level re-export layer and the fit-operator API planned earlier landed in 0.2.0.0 as `module Hanalyze` and `Hanalyze.Fit`.)
 
 ---
 
@@ -302,22 +406,23 @@
 ```
 src/
   DataIO/      — CSV/JSON/Parquet IO + health checks + sniff + clean DSL + reshape (9 mods)
-  Stat/        — tests/distributions/interpolation/effect/CV/bootstrap/interpret etc. (21 mods)
-  Model/       — LM/GLM/GLMM/Spline/Kernel/GP/RFF/HBM/PCA/Cluster/Tree/TS/Survival (23 mods)
+  Stat/        — tests/distributions/effect/CV/bootstrap/interpret/causal/MCMC diagnostics (33 mods)
+  Model/       — LM/GLM/GLMM/GP/HBM/SVM/GBM/NN/Cluster/TS/Survival/LiNGAM/FDA etc. (75 mods)
   Optim/       — single-obj (NM/LBFGS/DE/CMAES/SA/PSO) + multi-obj (NSGA/BO/Pareto) (18 mods)
-  Design/      — Factorial/Block/RSM/Optimal/Orthogonal/Taguchi (11 mods)
-  Viz/         — Vega-Lite-based visualisation + ReportBuilder (15 mods)
-  MCMC/        — MH/HMC/NUTS/Gibbs/Slice (6 mods)
+  Design/      — Factorial/Block/RSM/Orthogonal/Taguchi + Custom optimal design (30 mods)
+  Viz/         — Vega-Lite-based visualisation + ReportBuilder (19 mods)
+  MCMC/        — MH/HMC/NUTS/Gibbs/Slice/SMC + progress (9 mods)
+  Math/ Data/ Plot/ + Fit/Diagnostics — numeric kernels, data helpers, hgg integration, fit operator
 ```
 
-As of this release: 103 modules, 238 tests.
+As of this release: 212 modules, ~1,390 test examples.
 
 ---
 
 ## Build
 
 ```bash
-cabal build all                  # library + all executables (60+ demos)
+cabal build all                  # library + all executables (76 demos)
 cabal test                       # hspec test suite
 cabal repl                       # interactive REPL
 ```
@@ -360,7 +465,7 @@
 
 ## License
 
-BSD-3-Clause License — see [LICENSE](LICENSE).
+BSD-3-Clause License — see [LICENSE](https://github.com/frenzieddoll/hanalyze/blob/v0.2.0.0/LICENSE).
 
 ## Author
 
diff --git a/app/Main.hs b/app/Main.hs
--- a/app/Main.hs
+++ b/app/Main.hs
@@ -12,7 +12,9 @@
 import qualified Hanalyze.Stat.Interpolate  as Interp
 import qualified Hanalyze.Stat.AdaptiveGrid as AG
 import Text.Read (readMaybe)
-import qualified DataFrame                    as DX
+import qualified DataFrame.Internal.DataFrame  as DX
+import qualified DataFrame.Operations.Core     as DX
+import qualified DataFrame.IO.CSV              as DX
 import qualified DataFrame.Internal.Column    as DXC
 import qualified DataFrame.Internal.DataFrame as DXD
 import Hanalyze.DataIO.Convert     (getDoubleVec, getTextVec, getMaybeTextVec)
@@ -41,7 +43,7 @@
 import qualified Hanalyze.Viz.ModelGraph
 import qualified Graphics.Vega.VegaLite as VL
 import Graphics.Vega.VegaLite (VegaLite, VLProperty, VLSpec)
-import qualified Hanalyze.Model.Kernel as Kern
+import qualified Hanalyze.Model.KernelRegression as Kern
 import qualified Hanalyze.Model.MultiLM as MLM
 import qualified Hanalyze.Model.Regularized as Reg
 import qualified Hanalyze.Model.GAM as GAM
@@ -2597,7 +2599,7 @@
           ++ " (unknown method)"
 
 -- | Multi-input Kernel Ridge (Phase K5) — fit and report training metrics.
--- 多次元 X (n×p) を取り、Hanalyze.Model.Kernel.kernelRidgeMV で fit。
+-- 多次元 X (n×p) を取り、Hanalyze.Model.KernelRegression.kernelRidgeMV で fit。
 -- 予測図は生成しない (多次元のため)、R² と RMSE をログ出力。
 runKernelMVKR
   :: [T.Text] -> T.Text -> [V.Vector Double] -> V.Vector Double
@@ -3568,7 +3570,7 @@
       let n     = V.length yVec
           rows  = [ [ xv V.! i | xv <- xVecs ] | i <- [0 .. n - 1] ]
           ys    = V.toList yVec
-          cfg   = RF.defaultRFConfig
+          cfg   = RF.defaultRandomForest
                     { RF.rfTrees      = roTrees opts
                     , RF.rfMaxDepth   = roMaxDepth opts
                     , RF.rfMinSamples = roMinSamples opts
diff --git a/bench/haskell/BenchCustomDesign.hs b/bench/haskell/BenchCustomDesign.hs
new file mode 100644
--- /dev/null
+++ b/bench/haskell/BenchCustomDesign.hs
@@ -0,0 +1,574 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+-- | Phase 27: Custom Design (JMP 同等性) 検証ベンチ。
+--
+-- 文献例題と JMP 公式 example の **参照値 (criterion / D-eff)** を golden CSV
+-- として保持し、 hanalyze 実装の出力との比較を deterministic seed 固定で
+-- 実行する。
+--
+-- 入力 (golden):  @bench/custom-design/golden/<example>.csv@
+--   - reference 設計行列 (whole_plot, factor1, factor2, ... header)
+--   - 値は論文記載値そのまま (Jones-Goos (2012) Table 2/4 等)
+--
+-- 出力 (results): @bench/custom-design/results/golden-comparison.csv@
+--   - schema: @example,metric,hanalyze_value,reference_value,ratio,tolerance,pass@
+--
+-- ## 実装メモ: split-plot D-criterion の重複実装
+--
+-- Phase 25 で SplitPlot.evalCritSP は internal (非 public)。 bench から
+-- 直接呼べないので、 同じ M⁻¹ 構築ロジックをここに重複実装している。
+-- 仕様変更時は src/Hanalyze/Design/Custom/SplitPlot.hs と本ファイル両方を
+-- 更新すること (簡易 REML criterion: critValueM(DOpt, chol(X' M⁻¹ X)) =
+-- -det(X' M⁻¹ X))。
+module Main where
+
+import qualified Data.ByteString            as BS
+import qualified Data.ByteString.Lazy.Char8 as BL
+import           Data.Csv                   (HasHeader (..), decode)
+import qualified Data.Text                  as T
+import qualified Data.Vector                as V
+import qualified Data.Vector.Storable       as VS
+import qualified Numeric.LinearAlgebra      as LA
+import           System.Directory           (createDirectoryIfMissing,
+                                             doesFileExist)
+import           System.IO                  (BufferMode (..), IOMode (..),
+                                             hPutStrLn, hSetBuffering, withFile)
+import           Text.Printf                (printf)
+
+import qualified Hanalyze.Design.Custom.Bayesian     as CB
+import qualified Hanalyze.Design.Custom.Constraint   as CC
+import qualified Hanalyze.Design.Custom.Coordinate   as CX
+import qualified Hanalyze.Design.Custom.Factor       as CF
+import qualified Hanalyze.Design.Custom.Model        as CM
+import qualified Hanalyze.Design.Custom.RegionMoment as RM
+import qualified Hanalyze.Design.Custom.SplitPlot    as SP
+import qualified Hanalyze.Design.Optimal             as OPT
+
+-- ===========================================================================
+-- 比較結果 row (results/golden-comparison.csv の schema)
+-- ===========================================================================
+
+-- | 1 (example, metric) ペアの比較結果。
+data GoldenRow = GoldenRow
+  { grExample   :: String
+  , grMetric    :: String
+  , grhanalyze   :: Double
+  , grReference :: Double
+  , grTolerance :: Double
+  } deriving Show
+
+grRatio :: GoldenRow -> Double
+grRatio r
+  | grReference r == 0 = 0 / 0
+  | otherwise          = grhanalyze r / grReference r
+
+grPass :: GoldenRow -> Bool
+grPass r =
+  let ratio = grRatio r
+  in  not (isNaN ratio) && abs (ratio - 1) <= grTolerance r
+
+writeGoldenRows :: FilePath -> [GoldenRow] -> IO ()
+writeGoldenRows path rows = withFile path WriteMode $ \h -> do
+  hSetBuffering h LineBuffering
+  hPutStrLn h "example,metric,hanalyze_value,reference_value,ratio,tolerance,pass"
+  mapM_ (\r -> hPutStrLn h
+          (printf "%s,%s,%.10g,%.10g,%.10g,%.6g,%s"
+            (grExample r) (grMetric r)
+            (grhanalyze r) (grReference r)
+            (grRatio r) (grTolerance r)
+            (if grPass r then "true" else "false" :: String))) rows
+
+-- ===========================================================================
+-- 文献参照値 CSV の読み込み
+-- ===========================================================================
+
+-- | 設計行列 CSV (header: whole_plot,x1,x2,...) を読み、
+-- (raw matrix, whole-plot indicator) を返す。
+readDesignCSV
+  :: FilePath
+  -> IO (Either String (LA.Matrix Double, VS.Vector Int, Int))
+readDesignCSV path = do
+  exists <- doesFileExist path
+  if not exists
+    then pure (Left ("design file not found: " ++ path))
+    else do
+      bytes <- BL.fromStrict <$> BS.readFile path
+      case decode HasHeader bytes :: Either String (V.Vector (V.Vector Double)) of
+        Left  err -> pure (Left ("decode " ++ path ++ ": " ++ err))
+        Right rs
+          | V.null rs -> pure (Left ("empty CSV: " ++ path))
+          | otherwise ->
+              let nCols = V.length (rs V.! 0)
+                  nRows = V.length rs
+                  -- col 0 = whole_plot id (1-based in CSV → 0-based internal)
+                  wpIds = VS.fromList
+                            [ round (rs V.! i V.! 0) - 1
+                            | i <- [0 .. nRows - 1] ]
+                  rawMat = LA.fromLists
+                             [ [ rs V.! i V.! j | j <- [1 .. nCols - 1] ]
+                             | i <- [0 .. nRows - 1] ]
+                  nWP   = maximum (VS.toList wpIds) + 1
+              in  pure (Right (rawMat, wpIds, nWP))
+
+-- ===========================================================================
+-- Split-Plot D-criterion 評価 (SplitPlot.evalCritSP の重複実装)
+-- ===========================================================================
+
+-- | M⁻¹ を block-diagonal で構築。 各 WP block の diag = 1 - η/(1 + η n_w)、
+-- off-diag = -η/(1 + η n_w)。 SplitPlot.buildMInv と同じロジック。
+buildMInv :: Int -> Double -> VS.Vector Int -> Int -> LA.Matrix Double
+buildMInv n eta wpId nWP =
+  let wpSizes = [ length [ i | i <- [0 .. n - 1], wpId VS.! i == w ]
+                | w <- [0 .. nWP - 1] ]
+      entry i j
+        | wpId VS.! i /= wpId VS.! j = 0
+        | otherwise =
+            let w   = wpId VS.! i
+                nwD = fromIntegral (wpSizes !! w) :: Double
+                off = - eta / (1 + eta * nwD)
+            in if i == j then 1 + off else off
+  in (n LA.>< n) [ entry i j | i <- [0 .. n - 1], j <- [0 .. n - 1] ]
+
+-- | det(X' M⁻¹ X) を返す。 模型展開 (intercept + main + interaction + quadratic)
+-- は expandDesignMatrix に委譲。
+--
+-- 返り値 = SplitPlot criterion 値の絶対値 (positive)。
+-- hanalyze の `spdGEFFEst` は -det(X' M⁻¹ X)、 ここでは +det(X' M⁻¹ X)
+-- を返すので、 比較時に sign 注意。
+splitPlotDDet
+  :: [CF.Factor]
+  -> CM.Model
+  -> Double                 -- ^ η
+  -> VS.Vector Int          -- ^ WP id per row
+  -> Int                    -- ^ nWP
+  -> LA.Matrix Double       -- ^ raw design matrix
+  -> Either String Double
+splitPlotDDet factors model eta wpId nWP raw =
+  case CM.expandDesignMatrix factors model raw of
+    Left e  -> Left (T.unpack e)
+    Right x ->
+      let n    = LA.rows x
+          mInv = buildMInv n eta wpId nWP
+          xtmx = LA.tr x LA.<> (mInv LA.<> x)
+      in  Right (LA.det xtmx)
+
+-- ===========================================================================
+-- Jones-Goos (2012) Table 2 比較: 20-run Split-Plot
+-- ===========================================================================
+
+-- 共通仕様: 1 WP 因子 w + 1 SP 因子 s、 連続 [-1, 1]、 full quadratic、
+-- 4 WP × 5 SP runs、 η = 1。
+jonesGoosTable2Spec :: CX.CustomDesignSpec
+jonesGoosTable2Spec = CX.CustomDesignSpec
+  { CX.cdsFactors =
+      [ CF.Factor "w" (CF.Continuous (-1) 1) CF.HardToChange
+      , CF.Factor "s" (CF.Continuous (-1) 1) CF.Controllable
+      ]
+  , CX.cdsModel =
+      CM.Model
+        [ CM.TIntercept
+        , CM.TMain "w", CM.TMain "s"
+        , CM.TInter ["w","s"]
+        , CM.TPower "w" 2, CM.TPower "s" 2
+        ]
+        CM.NCoded
+  , CX.cdsConstraints = []
+  , CX.cdsNRuns       = 20
+  , CX.cdsCriterion   = OPT.DOpt
+  , CX.cdsBudget      = CX.defaultBudget
+  , CX.cdsSeed        = Just 42
+  , CX.cdsInitial     = Nothing
+
+  , CX.cdsDJConvention = False
+  }
+
+benchJonesGoosTable2 :: IO [GoldenRow]
+benchJonesGoosTable2 = do
+  let factors = CX.cdsFactors jonesGoosTable2Spec
+      model   = CX.cdsModel   jonesGoosTable2Spec
+      eta     = 1.0           -- ^ Jones-Goos (2012) Table 3 η=1 列に対応
+      example = "jones-goos-2012-table2-splitplot-20run"
+      pathD   = "bench/custom-design/golden/jones-goos-2012-table2-dopt-design.csv"
+
+  -- 参照値 (Jones-Goos D-opt design) を読み込み、 D-criterion を計算
+  ref <- readDesignCSV pathD
+  case ref of
+    Left err -> do
+      putStrLn ("[skip] " ++ example ++ ": " ++ err)
+      pure []
+    Right (refRaw, refWpId, refNWP) ->
+      case splitPlotDDet factors model eta refWpId refNWP refRaw of
+        Left err -> do
+          putStrLn ("[skip] " ++ example ++ ": ref det failed: " ++ err)
+          pure []
+        Right refDet -> do
+          -- hanalyze で同じ仕様の D-opt を生成
+          let cfg = SP.SplitPlotConfig
+                { SP.spcNWhole = 4, SP.spcVarRatio = eta, SP.spcNStrip = Nothing }
+          oursE <- SP.generateSplitPlot jonesGoosTable2Spec cfg
+          case oursE of
+            Left err -> do
+              putStrLn ("[skip] " ++ example ++ ": hanalyze gen failed: "
+                        ++ T.unpack err)
+              pure []
+            Right ours -> do
+              -- hanalyze 側の D-criterion = -spdGEFFEst (sign 反転)
+              -- (spdGEFFEst = -det(X' M⁻¹ X))
+              let oursDet = - SP.spdGEFFEst ours
+                  -- D-efficiency = (det_ours / det_ref)^(1/p)、
+                  -- p = 模型項数 = 6 (Intercept, w, s, ws, w², s²)
+                  pTerms  = 6 :: Int
+                  invP    = 1.0 / fromIntegral pTerms
+                  dEffRaw = oursDet / refDet
+                  dEffPth = if dEffRaw <= 0 then 0
+                              else dEffRaw ** invP
+              putStrLn $ printf "  refDet=%.6g oursDet=%.6g  D-eff (raw)=%.4f  D-eff (pth root)=%.4f"
+                refDet oursDet dEffRaw dEffPth
+              pure
+                [ GoldenRow
+                    { grExample   = example
+                    , grMetric    = "D-criterion-ratio-raw"
+                    , grhanalyze   = oursDet
+                    , grReference = refDet
+                    , grTolerance = 0.02
+                    }
+                , GoldenRow
+                    { grExample   = example
+                    , grMetric    = "D-efficiency-pth-root"
+                    , grhanalyze   = dEffPth
+                    , grReference = 1.0
+                    , grTolerance = 0.02
+                    }
+                ]
+
+-- ===========================================================================
+-- DuMouchel-Jones (1994) Example 3 "Both" 比較
+-- ===========================================================================
+--
+-- 一次根拠: DuMouchel & Jones (1994) "A Simple Bayesian Modification of
+-- D-Optimal Designs", Technometrics 36(1):37-47、 §3.3 Example 3、 Table 1
+-- (page 41) "Both" 列。
+--
+-- 仕様: 4 連続因子 A/B/C/D ∈ {-1, 0, 1}、 n=9、 primary p=5 (intercept + 4 main)、
+-- potential q=10 (4 squares + 6 2-factor interactions)、 τ=1。
+-- 「Both」 設計 = 8-run resolution IV 2^(4-1) FF (I = ABCD) + 1 centerpoint。
+
+dumouchelJonesEx3Factors :: [CF.Factor]
+dumouchelJonesEx3Factors =
+  [ CF.Factor n (CF.Continuous (-1) 1) CF.Controllable
+  | n <- ["A", "B", "C", "D"]
+  ]
+
+-- | primary + potential を一括 expand する model。
+-- DuMouchel-Jones 1994 §3.3 では「Both」 列の potential は q=10 (4 squares + 6 2fi)。
+-- primary は p=5 (intercept + 4 main effects)。
+dumouchelJonesEx3Model :: CM.Model
+dumouchelJonesEx3Model = CM.Model
+  ( [CM.TIntercept]
+    ++ [CM.TMain n          | n <- ["A","B","C","D"]]
+    ++ [CM.TPower n 2       | n <- ["A","B","C","D"]]
+    ++ [CM.TInter [a, b]    | (a, b) <- [("A","B"),("A","C"),("A","D")
+                                        ,("B","C"),("B","D"),("C","D")]]
+  ) CM.NCoded
+
+-- | DJ Example 3 候補集合: {-1, 0, 1}^4 = 81 点 (paper §3.3、 dbCxStepGrid=3)。
+dumouchelJonesEx3Candidate :: LA.Matrix Double
+dumouchelJonesEx3Candidate = LA.fromLists
+  [ [a, b, c, d] | a <- vs, b <- vs, c <- vs, d <- vs ]
+  where vs = [-1, 0, 1] :: [Double]
+
+benchDuMouchelJonesEx3Both :: IO [GoldenRow]
+benchDuMouchelJonesEx3Both = do
+  let factors = dumouchelJonesEx3Factors
+      model   = dumouchelJonesEx3Model
+      tau2    = 1.0
+      kPrior  = CB.priorPrecisionDefault factors model tau2
+      example = "dumouchel-jones-1994-example3-both"
+      pathRef = "bench/custom-design/golden/dumouchel-jones-1994-example3-both.csv"
+      cand    = dumouchelJonesEx3Candidate
+
+  -- 文献設計を読み込み (CSV: A,B,C,D の 9 行)
+  refE <- readPlainDesignCSV pathRef
+  case refE of
+    Left err -> do
+      putStrLn ("[skip] " ++ example ++ ": " ++ err)
+      pure []
+    Right refRaw -> case CB.djFitTransform factors model cand of
+      Left e -> do
+        putStrLn ("[skip] " ++ example ++ ": DJ transform fit failed: "
+                  ++ T.unpack e)
+        pure []
+      Right djT -> do
+        -- 参照: expand → DJ transform → det(X_t' X_t + K)
+        let refDet =
+              case CM.expandDesignMatrix factors model refRaw of
+                Left e  -> error ("ref expand failed: " ++ T.unpack e)
+                Right x -> CB.bayesianDValueM kPrior (CB.djApplyTransform djT x)
+
+        -- hanalyze で同じ仕様 + BayesianD K で 9-run 設計を生成。
+        -- 注意: coordinateExchange は DJ 規約適用前の生 X で BayesianD を
+        -- 最適化する (28-12 では coordinateExchange への自動適用は未対応)。
+        -- 生成後の設計に対し DJ 変換を適用して det 比較する。
+        let spec = CX.CustomDesignSpec
+              { CX.cdsFactors     = factors
+              , CX.cdsModel       = model
+              , CX.cdsConstraints = []
+              , CX.cdsNRuns       = 9
+              , CX.cdsCriterion   = OPT.BayesianD (CB.precisionToMatrix kPrior)
+              , CX.cdsBudget      = CX.defaultBudget
+                  { CX.dbCxStepGrid = 3   -- ^ {-1, 0, 1} で論文と同じ候補集合
+                  , CX.dbRestarts   = 10  -- ^ 4 因子で multi-start を確保
+                  }
+              , CX.cdsSeed        = Just 42
+              , CX.cdsInitial     = Nothing
+
+              , CX.cdsDJConvention = True   -- ^ Phase 28-12 auto DJ 規約適用
+              }
+        oursE <- CX.coordinateExchange spec
+        case oursE of
+          Left err -> do
+            putStrLn ("[skip] " ++ example ++ ": hanalyze gen failed: "
+                      ++ T.unpack err)
+            pure []
+          Right cd -> do
+            let oursDet =
+                  case CM.expandDesignMatrix factors model (CX.cdMatrix cd) of
+                    Left e  -> error ("ours expand failed: " ++ T.unpack e)
+                    Right x -> CB.bayesianDValueM kPrior (CB.djApplyTransform djT x)
+                pTerms  = 1 + 4 + 4 + 6 :: Int  -- intercept + main + sq + 2fi = 15
+                invP    = 1.0 / fromIntegral pTerms
+                dEffRaw = if refDet <= 0 then 0 else oursDet / refDet
+                dEffPth = if dEffRaw <= 0 then 0 else dEffRaw ** invP
+            putStrLn $ printf "  [DJ §2.2 規約適用後] refDet=%.6g oursDet=%.6g  D-eff (raw)=%.4f  D-eff (pth root)=%.4f"
+              refDet oursDet dEffRaw dEffPth
+            pure
+              [ GoldenRow
+                  -- Phase 28-12 auto DJ 適用後: hanalyze coordinateExchange は
+                  -- DJ 変換後の det を直接最適化、 raw ratio が 1.0 近傍で収束する
+                  -- ことを確認 (tolerance 0.02)
+                  { grExample   = example
+                  , grMetric    = "BayesianD-criterion-ratio-raw-DJ"
+                  , grhanalyze   = oursDet
+                  , grReference = refDet
+                  , grTolerance = 0.02
+                  }
+              , GoldenRow
+                  { grExample   = example
+                  , grMetric    = "BayesianD-efficiency-pth-root-DJ"
+                  , grhanalyze   = dEffPth
+                  , grReference = 1.0
+                  , grTolerance = 0.02
+                  }
+              ]
+
+-- | 設計行列 CSV (header: x1,x2,...) を 1 つの Matrix Double として読む
+-- (WP indicator を含まない平 raw 形式)。
+readPlainDesignCSV :: FilePath -> IO (Either String (LA.Matrix Double))
+readPlainDesignCSV path = do
+  exists <- doesFileExist path
+  if not exists
+    then pure (Left ("design file not found: " ++ path))
+    else do
+      bytes <- BL.fromStrict <$> BS.readFile path
+      case decode HasHeader bytes :: Either String (V.Vector (V.Vector Double)) of
+        Left  err -> pure (Left ("decode " ++ path ++ ": " ++ err))
+        Right rs
+          | V.null rs -> pure (Left ("empty CSV: " ++ path))
+          | otherwise ->
+              let nRows = V.length rs
+                  nCols = V.length (rs V.! 0)
+                  mat = LA.fromLists
+                          [ [ rs V.! i V.! j | j <- [0 .. nCols - 1] ]
+                          | i <- [0 .. nRows - 1] ]
+              in  pure (Right mat)
+
+-- ===========================================================================
+-- Phase 28-4d: JMP RSM Constraints + Categorical (18-run I-opt) 比較
+-- ===========================================================================
+--
+-- 一次根拠: JMP 12 「Design of Experiments Example: A Response Surface Design
+-- with Constraints and a Categorical Factor」 PDF (JMP community sample-data
+-- attachment、 公開資料)。
+--
+-- 仕様:
+--   * 因子: Time ∈ [500, 560] (coded [-1, 1])、 Temperature ∈ [350, 750]
+--     (coded [-1, 1])、 Catalyst ∈ {A, B, C}
+--   * 模型: RSM (intercept + main + 2fi + 連続因子の x²)
+--     - p = 1 + 3 (main: Time/Temp/Cat = 1+1+2) + 5 (2fi: T·Temp/T·Cat/Temp·Cat
+--       = 1+2+2) + 2 (T²/Temp²) = 12
+--   * 制約: Conditional (Catalyst = B → Temp_coded ≥ -0.75)
+--           Conditional (Catalyst = C → Temp_coded ≤ +0.5)
+--           (元: B→Temp≥400 / C→Temp≤650、 coded 換算)
+--   * JMP setup: I-opt criterion、 seed=654321、 starts=1000、 18-run
+--
+-- 比較 metric: IOptRegion criterion (= trace((X'X)⁻¹ · M_R))
+--   * Phase 28-4c 後: 制約 region 込みの **MC 版 M_R** (Halton N=10000) を使用、
+--     厳密な constrained-region I-criterion で比較
+--   * hanalyze 側: 同一 spec + 同一制約で coordinateExchange を回し、
+--     I-opt criterion 値を比較。 coordinateExchange も内部で同じ MC M_R に
+--     基づいて IOpt を最適化する (resolveIOptRegion の自動 MC fallback)。
+--     ratio ≤ 1 = hanalyze が JMP 参照より同じ M_R 評価で劣らない
+
+jmpRsmFactors :: [CF.Factor]
+jmpRsmFactors =
+  [ CF.Factor "Time"        (CF.Continuous (-1) 1) CF.Controllable
+  , CF.Factor "Temperature" (CF.Continuous (-1) 1) CF.Controllable
+  , CF.Factor "Catalyst"    (CF.Categorical ["A","B","C"]) CF.Controllable
+  ]
+
+jmpRsmModel :: CM.Model
+jmpRsmModel = CM.Model
+  [ CM.TIntercept
+  , CM.TMain "Time", CM.TMain "Temperature", CM.TMain "Catalyst"
+  , CM.TInter ["Time","Temperature"]
+  , CM.TInter ["Time","Catalyst"]
+  , CM.TInter ["Temperature","Catalyst"]
+  , CM.TPower "Time" 2
+  , CM.TPower "Temperature" 2
+  ]
+  CM.NCoded
+
+jmpRsmConstraints :: [CC.Constraint]
+jmpRsmConstraints =
+  [ CC.Conditional (CC.GuardEq "Catalyst" (CC.FVText "B"))
+      [ CC.LinearIneq [("Temperature", 1)] CC.CGeq (-0.75) ]
+  , CC.Conditional (CC.GuardEq "Catalyst" (CC.FVText "C"))
+      [ CC.LinearIneq [("Temperature", 1)] CC.CLeq 0.5 ]
+  ]
+
+-- | Time/Temperature/Catalyst の raw 値を coded matrix に変換。
+-- Time: (t - 530)/30、 Temperature: (T - 550)/200、 Catalyst: A/B/C → 0/1/2。
+codeJmpRsmRow :: [String] -> Either String [Double]
+codeJmpRsmRow xs = case xs of
+  [_runIdx, tStr, tempStr, catStr] -> do
+    t    <- readD tStr
+    temp <- readD tempStr
+    cat  <- case dropWhile (== ' ') catStr of
+              ('A':_) -> Right 0
+              ('B':_) -> Right 1
+              ('C':_) -> Right 2
+              _       -> Left ("unknown Catalyst level: " ++ catStr)
+    pure [(t - 530) / 30, (temp - 550) / 200, fromIntegral (cat :: Int)]
+  _ -> Left ("expected 4 columns, got " ++ show (length xs))
+  where
+    readD s = case reads (filter (/= ' ') s) :: [(Double, String)] of
+      [(d, "")] -> Right d
+      _         -> Left ("cannot parse Double: " ++ s)
+
+readJmpRsmCSV :: FilePath -> IO (Either String (LA.Matrix Double))
+readJmpRsmCSV path = do
+  exists <- doesFileExist path
+  if not exists
+    then pure (Left ("design file not found: " ++ path))
+    else do
+      txt <- readFile path
+      let allLines = lines txt
+      case allLines of
+        [] -> pure (Left ("empty CSV: " ++ path))
+        (_hdr : rows) -> do
+          let parsed = traverse (codeJmpRsmRow . splitComma) rows
+          case parsed of
+            Left  e -> pure (Left e)
+            Right rs -> pure (Right (LA.fromLists rs))
+  where
+    splitComma = foldr step [[]]
+    step ',' acc      = [] : acc
+    step c   (h : t)  = (c : h) : t
+    step _   []       = []  -- unreachable (acc starts with [[]])
+
+benchJmpRsmConstraints :: IO [GoldenRow]
+benchJmpRsmConstraints = do
+  let factors = jmpRsmFactors
+      model   = jmpRsmModel
+      cons    = jmpRsmConstraints
+      example = "jmp-rsm-constraints-categorical-18run"
+      pathRef = "bench/custom-design/golden/jmp-rsm-constraints-categorical-design.csv"
+
+  refE <- readJmpRsmCSV pathRef
+  case refE of
+    Left err -> do
+      putStrLn ("[skip] " ++ example ++ ": " ++ err)
+      pure []
+    Right refRaw -> case RM.regionMomentMatrixMC 10000 factors model cons of
+      Left e -> do
+        putStrLn ("[skip] " ++ example ++ ": M_R (MC) failed: " ++ T.unpack e)
+        pure []
+      Right mR -> case CM.expandDesignMatrix factors model refRaw of
+        Left e -> do
+          putStrLn ("[skip] " ++ example ++ ": ref expand failed: " ++ T.unpack e)
+          pure []
+        Right refX -> do
+          let refI = RM.iValueRegionM mR refX
+          putStrLn $ printf "  ref design IOptRegion = %.6g" refI
+
+          -- hanalyze 側: 同 spec + 同制約 で coordinateExchange
+          let spec = CX.CustomDesignSpec
+                { CX.cdsFactors     = factors
+                , CX.cdsModel       = model
+                , CX.cdsConstraints = cons
+                , CX.cdsNRuns       = 18
+                , CX.cdsCriterion   = OPT.IOpt
+                , CX.cdsBudget      = CX.defaultBudget
+                , CX.cdsSeed        = Just 654321
+                , CX.cdsInitial     = Nothing
+
+                , CX.cdsDJConvention = False
+                }
+          oursE <- CX.coordinateExchange spec
+          case oursE of
+            Left e -> do
+              putStrLn ("[skip] " ++ example ++ ": hanalyze gen failed: "
+                        ++ T.unpack e)
+              pure []
+            Right ours -> case CM.expandDesignMatrix factors model (CX.cdMatrix ours) of
+              Left e -> do
+                putStrLn ("[skip] " ++ example ++ ": hanalyze expand failed: "
+                          ++ T.unpack e)
+                pure []
+              Right oursX -> do
+                let oursI = RM.iValueRegionM mR oursX
+                    ratio = if refI <= 0 || isInfinite oursI then 0/0
+                              else oursI / refI
+                putStrLn $ printf "  hanalyze IOptRegion = %.6g  ratio=%.4f"
+                  oursI ratio
+                pure
+                  [ GoldenRow
+                      { grExample   = example
+                      , grMetric    = "IOptRegion-criterion-ratio"
+                      , grhanalyze   = oursI
+                      , grReference = refI
+                        -- hanalyze が JMP より大きく劣らない (≤ 5% 増) を pass 基準。
+                        -- 制約条件が analytic M_R で無視されるため、 厳密同等は
+                        -- 期待できない (Phase 28-4c MC fallback で改善)
+                      , grTolerance = 0.05
+                      }
+                  ]
+
+-- ===========================================================================
+-- Main
+-- ===========================================================================
+
+main :: IO ()
+main = do
+  createDirectoryIfMissing True "bench/custom-design/golden"
+  createDirectoryIfMissing True "bench/custom-design/results"
+
+  putStrLn "=== Phase 27-2: Jones-Goos (2012) Table 2 (20-run Split-Plot) ==="
+  rowsT2 <- benchJonesGoosTable2
+
+  putStrLn ""
+  putStrLn "=== Phase 27-3: DuMouchel-Jones (1994) Example 3 \"Both\" (9-run Bayesian-D) ==="
+  rowsDJ <- benchDuMouchelJonesEx3Both
+
+  putStrLn ""
+  putStrLn "=== Phase 28-4d: JMP RSM Constraints + Categorical (18-run I-opt) ==="
+  rowsRSM <- benchJmpRsmConstraints
+
+  let allRows = rowsT2 ++ rowsDJ ++ rowsRSM
+  writeGoldenRows "bench/custom-design/results/golden-comparison.csv" allRows
+
+  let nPass = length (filter grPass allRows)
+      nTot  = length allRows
+  putStrLn ""
+  putStrLn (printf "✓ %d/%d metrics pass、 結果: bench/custom-design/results/golden-comparison.csv"
+             nPass nTot)
diff --git a/bench/haskell/BenchFormulaRef.hs b/bench/haskell/BenchFormulaRef.hs
new file mode 100644
--- /dev/null
+++ b/bench/haskell/BenchFormulaRef.hs
@@ -0,0 +1,137 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+-- | Phase 47 A5: Formula DSL の Haskell 参照値生成器。
+--   bench/python/bench_formula.py が statsmodels / scipy と突合するための ŷ/R²/係数を
+--   実際の Hanalyze 実装で計算し formula_haskell_ref.json に書き出す (再現可能化)。
+--
+--   実行: cabal run formula-ref-gen   (cwd は repo root を想定、 bench/python/ に書く)
+module Main (main) where
+
+import           Data.List              (intercalate)
+import           Data.Text              (Text)
+import qualified Data.Text              as T
+import qualified DataFrame.Internal.Column    as DX
+import qualified DataFrame.Internal.DataFrame  as DX
+import qualified Numeric.LinearAlgebra  as LA
+
+import qualified Hanalyze.Model.Core              as Core
+import           Hanalyze.Model.Formula.RFormula  (parseModel)
+import           Hanalyze.Model.Formula.Design    (fitLMF, fitWLSF, defaultWLS,
+                                                   WLSConfig (..))
+import           Hanalyze.Model.Formula.Nonlinear (fitNLS, nlsParams)
+import           Hanalyze.Model.Formula.Mixed     (fitMixedLME)
+import           Hanalyze.Model.GLMM              (GLMMResultRE (..))
+
+-- ============================================================================
+-- データ (Python 側と完全一致させる)
+-- ============================================================================
+
+dfOLS :: DX.DataFrame
+dfOLS = DX.fromNamedColumns
+  [ ("y", DX.fromList ([10,20,30,40,50,60,12,22,34,44,52,62] :: [Double]))
+  , ("g", DX.fromList (["A","A","B","B","C","C","A","A","B","B","C","C"] :: [Text]))
+  , ("t", DX.fromList (["P","Q","P","Q","P","Q","P","Q","P","Q","P","Q"] :: [Text]))
+  , ("x", DX.fromList ([1,2,3,4,5,6,1,2,3,4,5,6] :: [Double]))
+  ]
+
+olsFormulas :: [Text]
+olsFormulas =
+  [ "y ~ x"
+  , "y ~ C(g) * C(t)"
+  , "y ~ C(g) + C(g):x"
+  , "y ~ x + I(x**2)"
+  , "y ~ C(g, Sum)"                  -- A2 contrast (ŷ は treatment と不変 → statsmodels 一致)
+  , "y ~ C(g, Sum) + C(g, Sum):x"
+  ]
+
+dfWLS :: DX.DataFrame
+dfWLS = DX.fromNamedColumns
+  [ ("y", DX.fromList ([2.1,3.9,6.2,7.8,10.1,12.2,13.8,16.1] :: [Double]))
+  , ("x", DX.fromList ([1,2,3,4,5,6,7,8] :: [Double]))
+  , ("w", DX.fromList ([1,1,2,2,3,3,4,4] :: [Double]))
+  ]
+
+-- Phase 48: mixed-effects (random intercept + slope) 突合用データ。
+-- 4 群 × 5 obs、 群ごとに切片・傾きが異なる線形 (固定平均 β≈[2,3])。
+dfMixed :: DX.DataFrame
+dfMixed = DX.fromNamedColumns
+  [ ("y", DX.fromList ([ 3.01,6.49,10.01,13.49,17.00
+                       , 1.01,3.49, 6.01, 8.49,11.00
+                       , 2.51,5.19, 7.91,10.59,13.30
+                       , 1.51,4.79, 8.11,11.39,14.70 ] :: [Double]))
+  , ("x", DX.fromList (concat (replicate 4 [0,1,2,3,4]) :: [Double]))
+  , ("g", DX.fromList (concatMap (replicate 5) (["A","B","C","D"] :: [Text])))
+  ]
+
+nlsXs :: [Double]
+nlsXs = [0,0.5,1,1.5,2,2.5,3,3.5,4,4.5,5]
+
+dfNLS :: DX.DataFrame
+dfNLS = DX.fromNamedColumns
+  [ ("y", DX.fromList (map (\x -> 3.0 * exp (negate 0.5 * x)) nlsXs))   -- a=3 b=0.5
+  , ("x", DX.fromList nlsXs)
+  ]
+
+-- ============================================================================
+-- JSON (手書き・依存最小)
+-- ============================================================================
+
+jstr :: String -> String
+jstr s = "\"" ++ s ++ "\""
+
+jarr :: [Double] -> String
+jarr xs = "[" ++ intercalate ", " (map show xs) ++ "]"
+
+main :: IO ()
+main = do
+  olsEntries <- mapM olsEntry olsFormulas
+  wlsJson    <- pure wlsEntry
+  nlsJson    <- pure nlsEntry
+  mixedJson  <- pure mixedEntry
+  let body = intercalate ",\n  " (olsEntries ++ [wlsJson, nlsJson, mixedJson])
+      json = "{\n  " ++ body ++ "\n}\n"
+  writeFile "bench/python/formula_haskell_ref.json" json
+  putStrLn ("formula_haskell_ref.json 生成: OLS " ++ show (length olsEntries)
+            ++ " + __wls__ + __nls__ + __mixed__")
+
+olsEntry :: Text -> IO String
+olsEntry f =
+  case parseModel f >>= \fm -> fitLMF fm dfOLS of
+    Left e        -> error ("OLS " ++ T.unpack f ++ ": " ++ e)
+    Right (fr, _) ->
+      pure $ jstr (T.unpack f) ++ ": {\"r2\": " ++ show (Core.rSquared1 fr)
+             ++ ", \"yhat\": " ++ jarr (Core.fittedList fr) ++ "}"
+
+-- WLS 係数 (parameterization 同一ゆえ係数も突合可)。
+wlsEntry :: String
+wlsEntry =
+  case parseModel "y ~ x" >>= \fm -> fitWLSF defaultWLS { wcWeights = Just "w" } fm dfWLS of
+    Left e        -> error ("WLS: " ++ e)
+    Right (fr, _) ->
+      jstr "__wls__" ++ ": {\"coef\": " ++ jarr (LA.toList (Core.coefficientsV fr)) ++ "}"
+
+-- NLS パラメータ。
+nlsEntry :: String
+nlsEntry =
+  case parseModel "y x = a * exp(-b * x)" of
+    Left e   -> error ("NLS parse: " ++ e)
+    Right fm -> case fitNLS fm dfNLS [("a",1),("b",1)] of
+      Left e  -> error ("NLS: " ++ e)
+      Right r -> let pm = nlsParams r
+                     val k = maybe (0/0) id (lookup k pm)
+                 in jstr "__nls__" ++ ": {\"a\": " ++ show (val "a")
+                    ++ ", \"b\": " ++ show (val "b") ++ "}"
+
+-- 混合効果 (random intercept + slope) の β / G (2×2) / σ²。
+-- 突合先 = statsmodels smf.mixedlm(..., re_formula="~x").fit(reml=False) (ML)。
+mixedEntry :: String
+mixedEntry =
+  case fitMixedLME "y ~ x + (1+x|g)" dfMixed of
+    Left e         -> error ("mixed: " ++ e)
+    Right (res, _) ->
+      let beta = LA.toList (Core.coefficientsV (reFixed res))
+          g    = LA.toLists (reRandCov res)        -- [[g00,g01],[g10,g11]]
+          flatG = concat g
+      in jstr "__mixed__" ++ ": {\"beta\": " ++ jarr beta
+         ++ ", \"cov_re\": " ++ jarr flatG
+         ++ ", \"sigma2\": " ++ show (reResidVar res) ++ "}"
diff --git a/bench/haskell/BenchHBM54a.hs b/bench/haskell/BenchHBM54a.hs
new file mode 100644
--- /dev/null
+++ b/bench/haskell/BenchHBM54a.hs
@@ -0,0 +1,231 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RankNTypes #-}
+{-# OPTIONS_GHC -fno-full-laziness -fno-cse #-}
+
+-- | Phase 54.4a per-call 勾配ベンチ (推測するな計測せよ)。
+--
+-- 54.4a で gradADU をハイブリッド化した (Gaussian-恒等リンク ObserveLM ブロックの
+-- 観測尤度勾配を自作 vector-op tape で計算・他は ad)。 その「実速度」 を、 同一の
+-- 階層 Gaussian モデル (M2: random intercept) を 2 通りにエンコードして比較する:
+--
+--   scalar = glmmRandomIntercept (per-obs scalar observe) → gradADU は全体 ad
+--   vecLM  = 同型を observeLM で表現 (群効果を設計行列の指示列に畳む)
+--            → gradADU はハイブリッド (ObserveLM 部を vec-tape)
+--
+-- NUTS は 1 draw あたり leapfrog ごとに gradADU を多数回呼ぶので、 per-call の
+-- gradADU 単価がそのまま per-draw コストの支配項。 ここでは per-call を直接測る
+-- (NUTS の分散・固定費を排した最もクリーンな比較)。 各サイズで中心差分一致も確認。
+module Main where
+
+import           Control.Monad   (forM, forM_)
+import qualified Data.Map.Strict as Map
+import qualified Data.Text       as T
+import qualified Data.Vector     as V
+import qualified System.Random.MWC               as MWC
+import           System.Random.MWC.Distributions (standard)
+import           Text.Printf     (printf)
+
+import           Hanalyze.Model.HBM
+                   ( Distribution (..), ModelP, LMFamily (..), REff (..)
+                   , sample, observe, observeLMR
+                   , sampleNames, getTransforms, gradADU, compileGradU
+                   , logJointUnconstrained )
+import           Hanalyze.Stat.Distribution (Transform)
+import           Hanalyze.MCMC.NUTS         (NUTSConfig (..), defaultNUTSConfig, nuts)
+import           Hanalyze.MCMC.Core         (Chain, chainTotal)
+
+import           BenchUtil (timeitIO)
+
+-- ---------------------------------------------------------------------------
+-- 決定的データ (BenchHBMScaling.genM2 と同型)
+-- ---------------------------------------------------------------------------
+
+normals :: Int -> Int -> IO [Double]
+normals seed k = do
+  g <- MWC.initialize (V.singleton (fromIntegral seed))
+  mapM (const (standard g)) [1 .. k]
+
+-- | nG 群 × perG。 xRows=[[1,x]]、 gids、 ys を返す。
+genM2 :: Int -> Int -> IO ([[Double]], [Int], [Double])
+genM2 nG perG = do
+  let n = nG * perG
+      (b0, b1, tauU, s) = (1.0, 0.8, 1.5, 1.0) :: (Double, Double, Double, Double)
+  xz <- normals 21 n
+  ez <- normals 22 n
+  uz <- normals 23 nG
+  let us   = map (* tauU) uz
+      gids = [ i `div` perG | i <- [0 .. n - 1] ]
+      xs   = map (* 2.0) xz
+      ys   = [ b0 + b1 * x + (us !! g) + s * e | (x, g, e) <- zip3 xs gids ez ]
+      xRows = [ [1.0, x] | x <- xs ]
+  pure (xRows, gids, ys)
+
+-- ---------------------------------------------------------------------------
+-- 2 通りのエンコード
+-- ---------------------------------------------------------------------------
+
+-- | prior 部のみ (observe 無し)。 gradADU は ObserveLM 無しゆえ全体 `ad`、
+--   = vec 経路の priorGrad 部 (prior+jacobian) の単体コスト計測用 (54.4c 内訳)。
+m2PriorOnly :: [[Double]] -> [Int] -> [Double] -> ModelP ()
+m2PriorOnly xRows gids _ys = do
+  let p  = if null xRows then 0 else length (head xRows)
+      nG = if null gids then 0 else maximum gids + 1
+  _   <- mapM (\k -> sample (T.pack ("beta_" ++ show k)) (Normal 0 5)) [0 .. p - 1]
+  tau <- sample "tau_u" (HalfNormal 5)
+  _   <- mapM (\j -> sample (T.pack ("u_" ++ show j)) (Normal 0 tau)) [0 .. nG - 1]
+  _   <- sample "sigma" (Exponential 1)
+  pure ()
+
+-- | scalar 経路 (per-obs observe を手書き)。 全体が `ad` で微分される基準。
+--   latent 宣言・順序は m2VecLM と完全一致 (beta_0,beta_1,tau_u,u_*,sigma)。
+m2Scalar :: [[Double]] -> [Int] -> [Double] -> ModelP ()
+m2Scalar xRows gids ys = do
+  let p  = if null xRows then 0 else length (head xRows)
+      nG = if null gids then 0 else maximum gids + 1
+  betas <- mapM (\k -> sample (T.pack ("beta_" ++ show k)) (Normal 0 5)) [0 .. p - 1]
+  tau   <- sample "tau_u" (HalfNormal 5)
+  us    <- mapM (\j -> sample (T.pack ("u_" ++ show j)) (Normal 0 tau)) [0 .. nG - 1]
+  s     <- sample "sigma" (Exponential 1)
+  forM_ (zip3 [0 :: Int ..] (zip3 xRows gids ys) (repeat ())) $ \(i, (xr, g, y), _) ->
+    let eta = sum (zipWith (\b x -> b * realToFrac x) betas xr) + us !! g
+    in observe (T.pack ("y_" ++ show i)) (Normal eta s) [y]
+
+-- | vec 経路 (observeLMR)。 固定効果 β は密設計行列、 群効果 u_j は gather
+--   (REff) で疎に表現する。 prior 宣言は scalar 版と完全に同一 (同じ分布・同じ
+--   順序) ゆえ logJoint/gradADU は一致する。
+m2VecLM :: [[Double]] -> [Int] -> [Double] -> ModelP ()
+m2VecLM xRows gids ys = do
+  let p  = if null xRows then 0 else length (head xRows)
+      nG = if null gids then 0 else maximum gids + 1
+      betaNames = [ T.pack ("beta_" ++ show k) | k <- [0 .. p - 1] ]
+      uNames    = [ T.pack ("u_" ++ show j)    | j <- [0 .. nG - 1] ]
+  _   <- forM [0 .. p - 1] $ \k -> sample (betaNames !! k) (Normal 0 5)
+  tau <- sample "tau_u" (HalfNormal 5)
+  _   <- forM [0 .. nG - 1] $ \j -> sample (uNames !! j) (Normal 0 tau)
+  _   <- sample "sigma" (Exponential 1)
+  observeLMR "y" betaNames xRows [REff uNames gids Nothing] (LMGaussian "sigma") ys
+
+-- | 54.4c 経路: m2VecLM と latent 宣言・観測は完全同一で、 REff に prior スケール
+--   名 @Just "tau_u"@ を載せた版。 これにより compileGradU が u-prior 勾配を解析的
+--   (O(nG) の素な Double) に計算し、 u_j Sample を ad walk から除外する。
+--   m2VecLM (prior を ad) と数値は一致 (test で担保)・per-call で prior の O(nG) ad
+--   が消えるぶん速くなるはず (計測で確認)。
+m2VecLMAna :: [[Double]] -> [Int] -> [Double] -> ModelP ()
+m2VecLMAna xRows gids ys = do
+  let p  = if null xRows then 0 else length (head xRows)
+      nG = if null gids then 0 else maximum gids + 1
+      betaNames = [ T.pack ("beta_" ++ show k) | k <- [0 .. p - 1] ]
+      uNames    = [ T.pack ("u_" ++ show j)    | j <- [0 .. nG - 1] ]
+  _   <- forM [0 .. p - 1] $ \k -> sample (betaNames !! k) (Normal 0 5)
+  tau <- sample "tau_u" (HalfNormal 5)
+  _   <- forM [0 .. nG - 1] $ \j -> sample (uNames !! j) (Normal 0 tau)
+  _   <- sample "sigma" (Exponential 1)
+  observeLMR "y" betaNames xRows [REff uNames gids (Just "tau_u")] (LMGaussian "sigma") ys
+
+-- ---------------------------------------------------------------------------
+-- 計測補助
+-- ---------------------------------------------------------------------------
+
+-- | 真値近傍の unconstrained 初期点 (β/u は identity、 tau_u/sigma は log)。
+initU :: [T.Text] -> [Double]
+initU names =
+  [ case n of
+      "tau_u" -> log 1.5
+      "sigma" -> log 1.0
+      _       -> 0.1
+  | n <- names ]
+
+centralDiff :: ([Double] -> Double) -> [Double] -> [Double]
+centralDiff f ps =
+  [ let h = 1e-6 * (abs (ps !! j) + 1e-3)
+    in (f (bump j h) - f (bump j (-h))) / (2 * h)
+  | j <- [0 .. length ps - 1] ]
+  where bump j d = [ if k == j then p + d else p | (k, p) <- zip [0 ..] ps ]
+
+relErr :: [Double] -> [Double] -> Double
+relErr a b = maximum [ abs (x - y) / (abs y + 1e-6) | (x, y) <- zip a b ]
+
+-- | gradADU の per-call median 時間 (ms)。 静的部分を毎回再構築 (54.4a 経路)。
+--   index で入力を微小摂動し CSE を防ぐ。
+timeGrad :: ModelP () -> [T.Text] -> [Transform] -> [Double] -> IO Double
+timeGrad m names trans us = do
+  (ms, _) <- timeitIO 50 (sum . map abs)
+               (\i -> let us' = [ u + fromIntegral i * 1e-12 | u <- us ]
+                      in pure (gradADU m names trans us'))
+  pure ms
+
+-- | compileGradU で静的部分を **1 度だけ**前処理しクロージャを 50 回再利用した
+--   per-call median 時間 (ms) (54.4b 経路・NUTS と同じ使い方)。
+timeGradCompiled :: ModelP () -> [T.Text] -> [Transform] -> [Double] -> IO Double
+timeGradCompiled m names trans us = do
+  let cl = compileGradU m names trans     -- 静的前処理は 1 度だけ
+  (ms, _) <- timeitIO 50 (sum . map abs)
+               (\i -> let us' = [ u + fromIntegral i * 1e-12 | u <- us ]
+                      in pure (cl us'))
+  pure ms
+
+-- ---------------------------------------------------------------------------
+
+main :: IO ()
+main = do
+  putStrLn "=== Phase 54.4a per-call 勾配ベンチ (scalar=全ad vs vecLM=ハイブリッド) ===\n"
+  putStrLn "対象: 階層 Gaussian (M2 random intercept)。 obs/群=12。"
+  putStrLn "gradADU 1 回の median 時間 (ms・50 reps)。 sc=scalar(全ad)・vl=vecLM(vec-tape)。\n"
+  putStrLn "vlc=vecLM compiled(54.4b・prior ad)・vla=同 compiled(54.4c・prior 解析)・vlc/vla=54.4c 短縮率。"
+  printf "%4s %4s %5s | %9s %9s | %8s\n"
+    ("nG"::String) ("p"::String) ("n"::String)
+    ("vlc(ms)"::String) ("vla(ms)"::String) ("vlc/vla"::String)
+  forM_ [2, 4, 8, 16, 32] $ \nG -> do
+    (xRows, gids, ys) <- genM2 nG 12
+    -- ModelP は rank-N 多相エイリアスゆえ let 束縛せず各 rank-N 消費箇所へ直接渡す。
+    let names = sampleNames (m2VecLM xRows gids ys)
+        tmap  = getTransforms (m2VecLM xRows gids ys)
+        trans = [ tmap Map.! n | n <- names ]
+        us    = initU names
+        p     = length (head xRows)
+        n     = length ys
+        -- 正しさ: 解析 prior 経路 (54.4c) が ad 経路・中心差分と一致 (relErr)。
+        gSc = gradADU (m2Scalar    xRows gids ys) names trans us
+        gVa = gradADU (m2VecLMAna  xRows gids ys) names trans us
+        cd  = centralDiff (\vs -> logJointUnconstrained (m2VecLM xRows gids ys) names trans
+                                    (Map.fromList (zip names vs))) us
+        e   = max (relErr gVa cd) (relErr gVa gSc)
+    printf "  (relErr 54.4c vs ad/中心差分 nG=%d: %.2e)\n" nG e
+    tVlc <- timeGradCompiled (m2VecLM    xRows gids ys) names trans us
+    tVla <- timeGradCompiled (m2VecLMAna xRows gids ys) names trans us
+    printf "%4d %4d %5d | %9.4f %9.4f | %8s\n"
+      nG p n tVlc tVla
+      (printf "x%.2f" (tVlc / tVla) :: String)
+
+  -- per-draw NUTS wall-time (per-call とは別。 NUTS 統合後の実速度)。
+  putStrLn "\n=== per-draw NUTS wall-time (warmup 300 + 300 draws・3 reps median) ==="
+  putStrLn "sc=scalar(全ad)・vl=vecLM(54.4b prior ad)・vla=vecLM(54.4c prior 解析)。"
+  printf "%4s %5s | %11s %11s %11s | %8s %8s\n"
+    ("nG"::String) ("n"::String)
+    ("sc(ms/dr)"::String) ("vl(ms/dr)"::String) ("vla(ms/dr)"::String)
+    ("sc/vla"::String) ("vl/vla"::String)
+  forM_ [8, 32] $ \nG -> do
+    (xRows, gids, ys) <- genM2 nG 12
+    let n     = length ys
+        nGn   = nG
+        initP = Map.fromList $
+          [ ("beta_0", 1.0), ("beta_1", 0.8), ("tau_u", 1.5), ("sigma", 1.0) ]
+          ++ [ (T.pack ("u_" ++ show j), 0.0) | j <- [0 .. nGn - 1] ]
+        cfg = defaultNUTSConfig
+          { nutsIterations = 300, nutsBurnIn = 300, nutsStepSize = 0.1
+          , nutsMaxDepth = 10, nutsAdaptStepSize = True
+          , nutsTargetAccept = 0.8, nutsAdaptMass = True }
+        runWith :: ModelP () -> Int -> IO Chain
+        runWith mdl i = do
+          g <- MWC.initialize (V.singleton (fromIntegral (42 + i)))
+          nuts mdl cfg initP g
+        probe ch = fromIntegral (chainTotal ch)
+    (msSc, _)  <- timeitIO 3 probe (runWith (m2Scalar    xRows gids ys))
+    (msVl, _)  <- timeitIO 3 probe (runWith (m2VecLM     xRows gids ys))
+    (msVla, _) <- timeitIO 3 probe (runWith (m2VecLMAna  xRows gids ys))
+    -- 総 wall-time を draw 数 (300) で割って per-draw に正規化。
+    let perDraw t = t / 300.0
+    printf "%4d %5d | %11.4f %11.4f %11.4f | %8s %8s\n"
+      nG n (perDraw msSc) (perDraw msVl) (perDraw msVla)
+      (printf "x%.1f" (msSc / msVla) :: String)
+      (printf "x%.2f" (msVl / msVla) :: String)
diff --git a/bench/haskell/BenchHBMADModes.hs b/bench/haskell/BenchHBMADModes.hs
new file mode 100644
--- /dev/null
+++ b/bench/haskell/BenchHBMADModes.hs
@@ -0,0 +1,127 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RankNTypes #-}
+{-# OPTIONS_GHC -fno-full-laziness -fno-cse #-}
+-- | HBM 勾配の AD モード比較 (Phase 53 追加調査)。
+--
+-- forward が低次元で速く高次元で O(p) 悪化、 generic reverse が逆 (tape
+-- オーバヘッドで低次元が遅い) と判明したため、 ad の 4 モードを直接突合し
+-- 「低次元も高次元も両立する単一モードが無いか」 を計測する:
+--
+--   * Numeric.AD.Mode.Forward        (前進・O(p))
+--   * Numeric.AD.Mode.Reverse        (逆・generic・tape boxing 有)
+--   * Numeric.AD.Mode.Reverse.Double (逆・Double 特化・boxing 回避)
+--   * Numeric.AD.Mode.Kahn           (逆・reflection-free)
+--
+-- 各モードで `logJointUnconstrained` の勾配 (= gradADU と同一計算) を計時。
+module Main where
+
+import           Control.Monad                    (forM_)
+import qualified Data.Map.Strict                  as Map
+import qualified Data.Text                        as T
+import qualified Data.Vector                      as V
+import qualified System.Random.MWC                as MWC
+import           System.Random.MWC.Distributions  (standard)
+import           Text.Printf                      (printf)
+
+import qualified Numeric.AD.Mode.Forward          as Fwd
+import qualified Numeric.AD.Mode.Reverse          as Rev
+import qualified Numeric.AD.Mode.Reverse.Double   as RevD
+import qualified Numeric.AD.Mode.Kahn             as Kahn
+
+import           Hanalyze.Model.HBM
+  ( Distribution (..), ModelP, sample, observe
+  , glmmRandomIntercept, GlmmFamily (..)
+  , sampleNames, getTransforms, logJointUnconstrained )
+import           Hanalyze.Stat.Distribution       (Transform)
+
+import           BenchUtil
+
+-- ---------------------------------------------------------------------------
+-- モデル
+-- ---------------------------------------------------------------------------
+
+m1Model :: [Double] -> [Double] -> ModelP ()
+m1Model xs ys = do
+  a <- sample "a"     (Normal 0 10)
+  b <- sample "b"     (Normal 0 10)
+  s <- sample "sigma" (Exponential 1)
+  forM_ (zip3 [0 :: Int ..] xs ys) $ \(i, x, y) ->
+    observe (T.pack ("y_" ++ show i)) (Normal (a + b * realToFrac x) s) [y]
+
+m2Model :: [[Double]] -> [Int] -> [Double] -> ModelP ()
+m2Model xRows gids ys = glmmRandomIntercept GlmmGaussian xRows gids ys
+
+normals :: Int -> Int -> IO [Double]
+normals seed k = do
+  g <- MWC.initialize (V.singleton (fromIntegral seed))
+  mapM (const (standard g)) [1 .. k]
+
+genM1Data :: Int -> IO ([Double], [Double])
+genM1Data n = do
+  xz <- normals 11 n
+  ez <- normals 12 n
+  let xs = map (* 2.0) xz
+      ys = zipWith (\x e -> 2.0 + 1.5 * x + e) xs ez
+  return (xs, ys)
+
+genM2Data :: Int -> Int -> IO ([[Double]], [Int], [Double])
+genM2Data nG perG = do
+  let n = nG * perG
+  xz <- normals 21 n
+  ez <- normals 22 n
+  uz <- normals 23 nG
+  let us'  = map (* 1.5) uz
+      gids = [ i `div` perG | i <- [0 .. n - 1] ]
+      xs   = map (* 2.0) xz
+      ys   = [ 1.0 + 0.8 * x + (us' !! g) + e | (x, g, e) <- zip3 xs gids ez ]
+      xRows = [ [1.0, x] | x <- xs ]
+  return (xRows, gids, ys)
+
+-- ---------------------------------------------------------------------------
+-- 各モードの勾配 (logJointUnconstrained の grad = gradADU と同一計算)
+-- ---------------------------------------------------------------------------
+
+-- f :: 多相 numeric で us → Map に詰め直し logJointUnconstrained を評価。
+mkF :: (Floating a, Ord a)
+    => ModelP () -> [T.Text] -> [Transform] -> [a] -> a
+mkF m names trans us =
+  logJointUnconstrained m names trans (Map.fromList (zip names us))
+
+gradFwd, gradRev, gradRevD, gradKahn
+  :: ModelP () -> [T.Text] -> [Transform] -> [Double] -> [Double]
+gradFwd  m names trans = Fwd.grad  (mkF m names trans)
+gradRev  m names trans = Rev.grad  (mkF m names trans)
+gradRevD m names trans = RevD.grad (mkF m names trans)
+gradKahn m names trans = Kahn.grad (mkF m names trans)
+
+-- ---------------------------------------------------------------------------
+-- 計時: 1 モデルについて 4 モードの per-grad ナノ秒を出す
+-- ---------------------------------------------------------------------------
+
+profileModel :: String -> ModelP () -> IO ()
+profileModel tag m = do
+  let names = sampleNames m
+      p     = length names
+      tmap  = getTransforms m
+      trans = [ Map.findWithDefault err n tmap | n <- names ]
+      err   = error "transform missing"
+      us0   = take p (cycle [0.1, -0.2, 0.15, 0.05, -0.1, 0.2, 0.3, 0.0])
+  (fMs,  _) <- timeitIO 30 (sum . map abs) (\_ -> pure (gradFwd  m names trans us0))
+  (rMs,  _) <- timeitIO 30 (sum . map abs) (\_ -> pure (gradRev  m names trans us0))
+  (rdMs, _) <- timeitIO 30 (sum . map abs) (\_ -> pure (gradRevD m names trans us0))
+  (kMs,  _) <- timeitIO 30 (sum . map abs) (\_ -> pure (gradKahn m names trans us0))
+  printf "%-16s p=%-3d | fwd=%8.4f | rev=%8.4f | revDouble=%8.4f | kahn=%8.4f ms\n"
+    tag p fMs rMs rdMs kMs
+
+main :: IO ()
+main = do
+  putStrLn "=== Phase 53: AD モード別 1 勾配時間 (ms) ==="
+  putStrLn "forward=O(p) / reverse=generic / revDouble=Double特化 / kahn=reflection-free\n"
+
+  (x1, y1) <- genM1Data 100
+  profileModel "M1_pooled(100)" (m1Model x1 y1)
+
+  putStrLn "\n--- M2 群数↑ で p↑ (obs/群=12) ---"
+  forM_ [2, 4, 8, 16, 32] $ \nG -> do
+    (xr, g, y) <- genM2Data nG 12
+    profileModel (printf "M2_g%d" nG) (m2Model xr g y)
diff --git a/bench/haskell/BenchHBMDist.hs b/bench/haskell/BenchHBMDist.hs
new file mode 100644
--- /dev/null
+++ b/bench/haskell/BenchHBMDist.hs
@@ -0,0 +1,277 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RankNTypes #-}
+{-# OPTIONS_GHC -fno-full-laziness -fno-cse #-}
+-- | Phase 56.6 bench: 観測分布ごとの per-call 勾配 A/B (bench-hbm-het の一般化)。
+--
+-- 56.3-56.5 で IR 吸収された各分布の canonical 回帰形 (n=100・test/Spec.hs の
+-- synthVecIR test と同形) について、 同一 unconstrained 全勾配を
+--
+--   (a) HBM.gradADU      — 実経路 (IR 吸収・NUTS が払う値)
+--   (b) RevD.grad (walk) — 旧 fallback 相当 (モデル全体を ad で毎回 walk)
+--
+-- で計測し 1 表にする。 各モデルは計測前に synthVecIR = Just (吸収) と
+-- 相対誤差 (IR vs walk) を確認してから走らせる。
+--
+-- ⚠ ここで出るのは **per-call の改善倍率のみ** (per-draw への波及は M 系
+-- bench でしか測れない・M9_negbin 以外は未計測)。 「PyMC 同等」 等の比較は
+-- この表からは言えない。
+--
+-- 引数: 無し=全 family / family 名の列挙 (例: @negbin gamma@) = その family のみ。
+-- 結果: bench/results/haskell/hbm_dist_grad_ab.csv
+module Main where
+
+import           Control.Monad                  (forM, unless)
+import qualified Data.Map.Strict                as Map
+import qualified Data.Text                      as T
+import           System.Environment             (getArgs)
+import           Text.Printf                    (printf)
+
+import qualified Numeric.AD.Mode.Reverse.Double as RevD
+
+import           Hanalyze.Model.HBM             (Distribution (..), Model,
+                                                 ModelP,
+                                                 sample, observe, gradADU,
+                                                 sampleNames, getTransforms,
+                                                 logJoint, invTransformF,
+                                                 logJacF, synthVecIR)
+
+import           BenchUtil                      (BenchRow (..), timeitIO,
+                                                 writeRows)
+
+-- ===========================================================================
+-- 決定的データ (乱数不要の固定系列・n=100)
+-- ===========================================================================
+
+nObs :: Int
+nObs = 100
+
+-- 説明変数: [-1, 1) 等間隔グリッド。
+xsD :: [Double]
+xsD = [ 2 * fromIntegral i / fromIntegral nObs - 1 | i <- [0 .. nObs - 1] ]
+
+-- 決定的 pseudo-noise (sin 系列・[-1,1])。
+wiggle :: [Double]
+wiggle = [ sin (fromIntegral (7 * i :: Int)) | i <- [0 .. nObs - 1] ]
+
+-- 実数値 (非有界): 位置-尺度系 (Gauss/StudentT/Cauchy/Logistic/Gumbel/LogN の log 前)。
+ysReal :: [Double]
+ysReal = [ 1.2 + 0.5 * x + 0.4 * w | (x, w) <- zip xsD wiggle ]
+
+-- 正値: Expo/Weibull/LogNormal/Gamma。
+ysPos :: [Double]
+ysPos = [ exp (0.3 * x + 0.5 * w) | (x, w) <- zip xsD wiggle ]
+
+-- (0,1): Beta。
+ysUnit :: [Double]
+ysUnit = [ 0.5 + 0.35 * w | w <- wiggle ]
+
+-- 非負整数 count: Poisson/NegBin。
+ysCount :: [Double]
+ysCount = [ fromIntegral (max 0 (round (2 + 1.5 * x + 1.2 * w) :: Int))
+          | (x, w) <- zip xsD wiggle ]
+
+-- 0/1: Bernoulli。
+ysBin01 :: [Double]
+ysBin01 = [ if w > 0 then 1 else 0 | w <- wiggle ]
+
+-- 0..10: Binomial(10)。
+ysBinom :: [Double]
+ysBinom = [ fromIntegral (min 10 (max 0 (round (5 + 3 * x + 2 * w) :: Int)))
+          | (x, w) <- zip xsD wiggle ]
+
+-- 1..: Geometric (試行回数パラメタ化)。
+ysGeom :: [Double]
+ysGeom = [ fromIntegral (max 1 (round (2 + 1.5 * w) :: Int)) | w <- wiggle ]
+
+-- ===========================================================================
+-- canonical 回帰形 (test/Spec.hs の synthVecIR test と同形・n=100 化)
+-- ===========================================================================
+
+-- | per-obs 手書きの共通骨格: 観測分布だけ差し替える (x は AD 型に持ち上げて渡す)。
+perObs :: (Floating a, Ord a)
+       => (a -> Distribution a) -> [Double] -> Model a ()
+perObs mk ys =
+  mapM_ (\(i, (x, y)) ->
+           observe (T.pack ("y_" ++ show (i :: Int)))
+             (mk (realToFrac x)) [y])
+        (zip [0 ..] (zip xsD ys))
+
+invLogit :: Floating a => a -> a
+invLogit e = 1 / (1 + exp (negate e))
+
+-- | family 名 + モデル + unconstrained 初期点 (sampleNames 順)。
+--   ModelP は rank-2 ゆえタプル格納不可 → data でラップ (Phase 51 の既知罠)。
+data Fam = Fam String (ModelP ()) [Double]
+
+mGauss, mPois, mBern, mStudentT, mCauchy, mLogistic, mGumbel,
+  mExpo, mWeibull, mLogNormal, mGamma, mBeta, mBinomial, mGeometric,
+  mNegBin :: ModelP ()
+
+mGauss = do
+  a <- sample "a" (Normal 0 10)
+  b <- sample "b" (Normal 0 10)
+  s <- sample "sigma" (Exponential 1)
+  perObs (\x -> Normal (a + b * x) s) ysReal
+
+mPois = do
+  a <- sample "a" (Normal 0 5)
+  b <- sample "b" (Normal 0 5)
+  perObs (\x -> Poisson (exp (a + b * x))) ysCount
+
+mBern = do
+  a <- sample "a" (Normal 0 5)
+  b <- sample "b" (Normal 0 5)
+  perObs (\x -> Bernoulli (invLogit (a + b * x))) ysBin01
+
+-- 56.3 (ν=SC 定数)
+mStudentT = do
+  a <- sample "a" (Normal 0 10)
+  b <- sample "b" (Normal 0 10)
+  s <- sample "sigma" (Exponential 1)
+  perObs (\x -> StudentT 4 (a + b * x) s) ysReal
+
+mCauchy = do
+  a <- sample "a" (Normal 0 10)
+  b <- sample "b" (Normal 0 10)
+  g <- sample "gamma" (Exponential 1)
+  perObs (\x -> Cauchy (a + b * x) g) ysReal
+
+mLogistic = do
+  a <- sample "a" (Normal 0 10)
+  b <- sample "b" (Normal 0 10)
+  s <- sample "s" (Exponential 1)
+  perObs (\x -> Logistic (a + b * x) s) ysReal
+
+mGumbel = do
+  a <- sample "a" (Normal 0 10)
+  b <- sample "b" (Normal 0 10)
+  be <- sample "beta" (Exponential 1)
+  perObs (\x -> Gumbel (a + b * x) be) ysReal
+
+-- 56.4 (rate=exp(η))
+mExpo = do
+  a <- sample "a" (Normal 0 5)
+  b <- sample "b" (Normal 0 5)
+  perObs (\x -> Exponential (exp (a + b * x))) ysPos
+
+-- 56.4 (k latent, λ=exp(η))
+mWeibull = do
+  k <- sample "k" (Exponential 1)
+  a <- sample "a" (Normal 0 5)
+  b <- sample "b" (Normal 0 5)
+  perObs (\x -> Weibull k (exp (a + b * x))) ysPos
+
+-- 56.4 (Gaussian ノード再利用)
+mLogNormal = do
+  a <- sample "a" (Normal 0 5)
+  b <- sample "b" (Normal 0 5)
+  s <- sample "sigma" (Exponential 1)
+  perObs (\x -> LogNormal (a + b * x) s) ysPos
+
+-- 56.4 (α latent, rate=exp(η))
+mGamma = do
+  al <- sample "alpha" (Exponential 1)
+  a  <- sample "a" (Normal 0 5)
+  b  <- sample "b" (Normal 0 5)
+  perObs (\x -> Gamma al (exp (a + b * x))) ysPos
+
+-- 56.4 (α=μφ, β=(1-μ)φ・φ は整数回避 = lgamma FD 罠の慣例)
+mBeta = do
+  a  <- sample "a" (Normal 0 5)
+  b  <- sample "b" (Normal 0 5)
+  ph <- sample "phi" (Exponential 0.5)
+  perObs (\x -> let mu = invLogit (a + b * x)
+                  in Beta (mu * ph) ((1 - mu) * ph)) ysUnit
+
+-- 56.5 (n=10 定数)
+mBinomial = do
+  a <- sample "a" (Normal 0 5)
+  b <- sample "b" (Normal 0 5)
+  perObs (\x -> Binomial 10 (invLogit (a + b * x))) ysBinom
+
+-- 56.5 (p=invLogit(η))
+mGeometric = do
+  a <- sample "a" (Normal 0 5)
+  b <- sample "b" (Normal 0 5)
+  perObs (\x -> Geometric (invLogit (a + b * x))) ysGeom
+
+-- 56.5 (μ=exp(η), α latent)
+mNegBin = do
+  a  <- sample "a" (Normal 0 5)
+  b  <- sample "b" (Normal 0 5)
+  al <- sample "alpha" (Exponential 0.5)
+  perObs (\x -> NegativeBinomial (exp (a + b * x)) al) ysCount
+
+families :: [Fam]
+families =
+  [ Fam "gauss"     mGauss     [1.0, 0.4, log 0.8]
+  , Fam "pois"      mPois      [0.3, 0.4]
+  , Fam "bern"      mBern      [0.2, 0.5]
+  , Fam "studentt"  mStudentT  [1.2, 0.4, log 0.6]
+  , Fam "cauchy"    mCauchy    [1.2, 0.4, log 0.5]
+  , Fam "logistic"  mLogistic  [1.2, 0.4, log 0.5]
+  , Fam "gumbel"    mGumbel    [1.2, 0.4, log 0.6]
+  , Fam "expo"      mExpo      [0.2, -0.3]
+  , Fam "weibull"   mWeibull   [log 1.3, 0.2, -0.3]
+  , Fam "lognormal" mLogNormal [0.1, 0.3, log 0.7]
+  , Fam "gamma"     mGamma     [log 1.6, 0.2, -0.3]
+  , Fam "beta"      mBeta      [0.3, -0.2, log 3.3]
+  , Fam "binomial"  mBinomial  [0.2, 0.6]
+  , Fam "geometric" mGeometric [-0.2, 0.5]
+  , Fam "negbin"    mNegBin    [0.4, 0.3, log 1.7]
+  ]
+
+-- ===========================================================================
+-- 計測 (bench-hbm-het と同一手順)
+-- ===========================================================================
+
+-- | 1 family の per-call A/B。 計測前に (1) IR 吸収を確認 (吸収されなければ
+--   FALLBACK 行として速度比 1 を返す) (2) 相対誤差を検証する。
+runFamily :: Fam -> IO BenchRow
+runFamily (Fam tag mdl uvs) = do
+  let names = sampleNames mdl
+      tmap  = getTransforms mdl
+      trans = [ tmap Map.! nm | nm <- names ]
+      absorbed = case synthVecIR mdl of
+                   Just (_, fams, _) -> null fams   -- 残余 family 無し = 全吸収
+                   Nothing           -> False
+      gIR = gradADU mdl names trans
+      gAD uv = RevD.grad
+                 (\uv' -> logJoint mdl
+                            (Map.fromList
+                               (zip names (zipWith invTransformF trans uv')))
+                          + sum (zipWith logJacF trans uv'))
+                 uv
+      relErr = maximum [ abs (x - y) / (1 + abs y)
+                       | (x, y) <- zip (gIR uvs) (gAD uvs) ]
+  unless (relErr < 1e-8) $
+    fail (tag ++ ": relErr IR vs ad-full = " ++ show relErr ++ " (>1e-8)")
+  -- 1 計測 = batch 回の勾配呼出 (µs 級なので)。 入力を毎回微小摂動して
+  -- CSE/共有を防ぐ (1e-12 は数値に実質影響しない)。
+  let batch = 1000 :: Int
+      runBatch g i = pure $! sum
+        [ sum (g (map (+ (1e-12 * fromIntegral (i * batch + j))) uvs))
+        | j <- [1 .. batch] ]
+  (msIR, _) <- timeitIO 7 id (runBatch gIR)
+  (msAD, _) <- timeitIO 7 id (runBatch gAD)
+  let pcIR = msIR / fromIntegral batch
+      pcAD = msAD / fromIntegral batch
+      sp   = pcAD / pcIR
+  printf "%-10s %s  IR %.5f ms/call  walk %.5f ms/call  x%.1f  (relErr %.1e)\n"
+    tag (if absorbed then "[IR]      " else "[FALLBACK]" :: String)
+    pcIR pcAD sp relErr
+  return (BenchRow "haskell" "hbm_dist" tag pcIR pcAD sp
+            (printf ("absorbed=%s relErr=%.2e n=%d batch=%d "
+                     ++ "per-call only (per-draw 波及は未計測)")
+               (show absorbed) relErr nObs batch))
+
+main :: IO ()
+main = do
+  args <- getArgs
+  let sel = if null args then families
+            else [ f | f@(Fam tag _ _) <- families, tag `elem` args ]
+  putStrLn "family     path        per-call gradient A/B (IR 吸収 vs 全体 ad walk)"
+  rows <- forM sel runFamily
+  writeRows "bench/results/haskell/hbm_dist_grad_ab.csv" rows
+  putStrLn $ "wrote " ++ show (length rows)
+          ++ " rows → bench/results/haskell/hbm_dist_grad_ab.csv"
diff --git a/bench/haskell/BenchHBMFuseSpike.hs b/bench/haskell/BenchHBMFuseSpike.hs
new file mode 100644
--- /dev/null
+++ b/bench/haskell/BenchHBMFuseSpike.hs
@@ -0,0 +1,370 @@
+{-# LANGUAGE BangPatterns #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# OPTIONS_GHC -fno-full-laziness -fno-cse #-}
+-- | Phase 85.3a: vecIR 融合方式の feasibility spike (計測先行・推測するな計測せよ)。
+--
+-- 85.1 で radon per-eval ~103µs の ~86% が forward+backward の解釈実行と確定した。
+-- 本実装 (IR 融合) に入る前に、 synthetic な elementwise 連鎖 (n=919・二項 7 op =
+-- radon の VIBin 48 本×平均 390 セルと同じ per-cell 構造) で 4 方式を実測し、
+-- 融合の利得上限と採用方式を決める:
+--
+--   [A] 現行 forwardArena 相当: 命令ごと全ベクトル走査 + @sBinF op@ の
+--       unknown-call ディスパッチ (IR.hs:1367 の実形)
+--   [B] A + op 特化ループ: 命令ごと走査は同じだが case op で直接演算
+--       (backward VIBin と同形) — unknown-call/boxing の寄与を分離
+--   [C] 完全融合 1-pass (手書き): 全連鎖を 1 ループで registers 評価 =
+--       compile-time codegen の理論天井 (解釈では到達不能)
+--   [D] 要素ごと解釈融合: 要素 j ごとに opcode 列を内側ループで dispatch =
+--       「fused interpreter」 方式の現実値 (arena traffic ゼロだが
+--       per-node dispatch が要素ごとに走る)
+--
+-- backward (勾配) 側は [A'] 現行 gradVecIRGo 相当 (forward arena + adj arena +
+-- op 特化・現行 backward は既に特化済) と [C'] 完全融合 1-pass (forward 再計算 +
+-- registers 逆伝播) の 2 点で床と天井を測る。
+--
+-- ★この spike は HBM 本体を一切いじらない独立実験。 勾配は全方式で突合し
+--   max|Δ| を表示する (正しさの担保)。
+module Main where
+
+import           Control.Monad                    (forM_, when)
+import           Control.Monad.ST                 (ST, runST)
+import qualified Data.Vector                      as BV
+import qualified Data.Vector.Storable             as VS
+import qualified Data.Vector.Storable.Mutable     as VSM
+import qualified Data.Vector.Unboxed              as VU
+import           Text.Printf                      (printf)
+
+import           Hanalyze.Model.HBM.IR            (SBin (..), sBinF)
+
+import           BenchUtil                        (timeitTastyIO)
+
+-- ---------------------------------------------------------------------------
+-- 設定: acc_0 = x0、 acc_t = acc_{t-1} ⊕_t x_t (t=1..7)、 obj = Σ_j acc_7[j]
+-- ---------------------------------------------------------------------------
+
+nObs :: Int
+nObs = 919
+
+nOps :: Int
+nOps = 7
+
+ops :: [SBin]
+ops = [SAddO, SMulO, SSubO, SDivO, SAddO, SMulO, SSubO]
+
+-- | 入力 8 本 (値域 [0.5, 1.5] = div 破綻なし・決定的)。
+mkInputs :: Int -> BV.Vector (VS.Vector Double)
+mkInputs salt = BV.fromList
+  [ VS.generate nObs $ \j ->
+      0.5 + fromIntegral ((j * 31 + t * 17 + salt) `mod` 1000) / 1000.0
+  | t <- [0 .. nOps] ]
+
+-- ---------------------------------------------------------------------------
+-- forward 4 方式
+-- ---------------------------------------------------------------------------
+
+-- | [A] 現行 forwardArena 相当: slot ごと全走査 + unknown-call。
+fwA :: BV.Vector (VS.Vector Double) -> Double
+fwA xs = runST $ do
+  ar <- VSM.unsafeNew ((nOps + 1) * nObs)
+  let x0 = xs BV.! 0
+      copy0 !j | j >= nObs = pure ()
+               | otherwise = do
+                   VSM.unsafeWrite ar j (x0 `VS.unsafeIndex` j)
+                   copy0 (j + 1)
+  copy0 0
+  forM_ [1 .. nOps] $ \t -> do
+    let f  = sBinF (ops !! (t - 1))
+        xt = xs BV.! t
+        oPrev = (t - 1) * nObs
+        oCur  = t * nObs
+        go !j | j >= nObs = pure ()
+              | otherwise = do
+                  a <- VSM.unsafeRead ar (oPrev + j)
+                  VSM.unsafeWrite ar (oCur + j) (f a (xt `VS.unsafeIndex` j))
+                  go (j + 1)
+    go 0
+  let oL = nOps * nObs
+      sumGo !acc !j | j >= nObs = pure acc
+                    | otherwise = do
+                        v <- VSM.unsafeRead ar (oL + j)
+                        sumGo (acc + v) (j + 1)
+  sumGo 0 0
+
+-- | [B] A + op 特化ループ (case op で直接演算・backward VIBin と同形)。
+fwB :: BV.Vector (VS.Vector Double) -> Double
+fwB xs = runST $ do
+  ar <- VSM.unsafeNew ((nOps + 1) * nObs)
+  let x0 = xs BV.! 0
+      copy0 !j | j >= nObs = pure ()
+               | otherwise = do
+                   VSM.unsafeWrite ar j (x0 `VS.unsafeIndex` j)
+                   copy0 (j + 1)
+  copy0 0
+  forM_ [1 .. nOps] $ \t -> do
+    let xt = xs BV.! t
+        oPrev = (t - 1) * nObs
+        oCur  = t * nObs
+        loopWith f =
+          let go !j | j >= nObs = pure ()
+                    | otherwise = do
+                        a <- VSM.unsafeRead ar (oPrev + j)
+                        VSM.unsafeWrite ar (oCur + j)
+                          (f a (xt `VS.unsafeIndex` j))
+                        go (j + 1)
+          in go 0
+    case ops !! (t - 1) of
+      SAddO -> loopWith (+)
+      SSubO -> loopWith (-)
+      SMulO -> loopWith (*)
+      SDivO -> loopWith (/)
+  let oL = nOps * nObs
+      sumGo !acc !j | j >= nObs = pure acc
+                    | otherwise = do
+                        v <- VSM.unsafeRead ar (oL + j)
+                        sumGo (acc + v) (j + 1)
+  sumGo 0 0
+
+-- | [C] 完全融合 1-pass (手書き・registers のみ) = codegen の理論天井。
+fwC :: BV.Vector (VS.Vector Double) -> Double
+fwC xs =
+  let x0 = xs BV.! 0
+      x1 = xs BV.! 1
+      x2 = xs BV.! 2
+      x3 = xs BV.! 3
+      x4 = xs BV.! 4
+      x5 = xs BV.! 5
+      x6 = xs BV.! 6
+      x7 = xs BV.! 7
+      go !acc !j
+        | j >= nObs = acc
+        | otherwise =
+            let v1 = x0 `VS.unsafeIndex` j + x1 `VS.unsafeIndex` j
+                v2 = v1 * x2 `VS.unsafeIndex` j
+                v3 = v2 - x3 `VS.unsafeIndex` j
+                v4 = v3 / x4 `VS.unsafeIndex` j
+                v5 = v4 + x5 `VS.unsafeIndex` j
+                v6 = v5 * x6 `VS.unsafeIndex` j
+                v7 = v6 - x7 `VS.unsafeIndex` j
+            in go (acc + v7) (j + 1)
+  in go 0 0
+
+-- | [D] 要素ごと解釈融合: opcode 列を要素ごとに内側 dispatch (arena なし)。
+fwD :: VU.Vector Int -> BV.Vector (VS.Vector Double) -> Double
+fwD opCodes xs =
+  let go !acc !j
+        | j >= nObs = acc
+        | otherwise =
+            let inner !v !t
+                  | t > nOps = v
+                  | otherwise =
+                      let xtj = (xs BV.! t) `VS.unsafeIndex` j
+                          v'  = case opCodes `VU.unsafeIndex` (t - 1) of
+                                  0 -> v + xtj
+                                  1 -> v - xtj
+                                  2 -> v * xtj
+                                  _ -> v / xtj
+                      in inner v' (t + 1)
+                v0 = (xs BV.! 0) `VS.unsafeIndex` j
+            in go (acc + inner v0 1) (j + 1)
+  in go 0 0
+
+opCode :: SBin -> Int
+opCode SAddO = 0
+opCode SSubO = 1
+opCode SMulO = 2
+opCode SDivO = 3
+
+-- ---------------------------------------------------------------------------
+-- backward 2 方式 (勾配 = ∂obj/∂x_t 全要素・fw+bw 込みの per-eval)
+-- ---------------------------------------------------------------------------
+
+-- | [A'] 現行 gradVecIRGo 相当: forward arena + adj arena (replicate 0) +
+--   op 特化 backward。 勾配は gxs ((nOps+1)*nObs) へ加算。
+gradA :: BV.Vector (VS.Vector Double) -> VS.Vector Double
+gradA xs = runST $ do
+  -- forward (B と同じ特化形: 現行 backward は特化済なので forward も B 形で公平に)
+  ar <- VSM.unsafeNew ((nOps + 1) * nObs)
+  let x0 = xs BV.! 0
+      copy0 !j | j >= nObs = pure ()
+               | otherwise = do
+                   VSM.unsafeWrite ar j (x0 `VS.unsafeIndex` j)
+                   copy0 (j + 1)
+  copy0 0
+  forM_ [1 .. nOps] $ \t -> do
+    let xt = xs BV.! t
+        oPrev = (t - 1) * nObs
+        oCur  = t * nObs
+        loopWith f =
+          let go !j | j >= nObs = pure ()
+                    | otherwise = do
+                        a <- VSM.unsafeRead ar (oPrev + j)
+                        VSM.unsafeWrite ar (oCur + j)
+                          (f a (xt `VS.unsafeIndex` j))
+                        go (j + 1)
+          in go 0
+    case ops !! (t - 1) of
+      SAddO -> loopWith (+)
+      SSubO -> loopWith (-)
+      SMulO -> loopWith (*)
+      SDivO -> loopWith (/)
+  -- backward
+  adj <- VSM.replicate ((nOps + 1) * nObs) 0
+  gxs <- VSM.replicate ((nOps + 1) * nObs) 0
+  -- obj = Σ acc_7 → adj_7 = 1
+  let oL = nOps * nObs
+      init1 !j | j >= nObs = pure ()
+               | otherwise = VSM.unsafeWrite adj (oL + j) 1 >> init1 (j + 1)
+  init1 0
+  forM_ [nOps, nOps - 1 .. 1] $ \t -> do
+    let xt = xs BV.! t
+        oPrev = (t - 1) * nObs
+        oCur  = t * nObs
+        gOff  = t * nObs
+    case ops !! (t - 1) of
+      SAddO ->
+        let go !j | j >= nObs = pure ()
+                  | otherwise = do
+                      a <- VSM.unsafeRead adj (oCur + j)
+                      VSM.unsafeModify adj (+ a) (oPrev + j)
+                      VSM.unsafeModify gxs (+ a) (gOff + j)
+                      go (j + 1)
+        in go 0
+      SSubO ->
+        let go !j | j >= nObs = pure ()
+                  | otherwise = do
+                      a <- VSM.unsafeRead adj (oCur + j)
+                      VSM.unsafeModify adj (+ a) (oPrev + j)
+                      VSM.unsafeModify gxs (subtract a) (gOff + j)
+                      go (j + 1)
+        in go 0
+      SMulO ->
+        let go !j | j >= nObs = pure ()
+                  | otherwise = do
+                      a  <- VSM.unsafeRead adj (oCur + j)
+                      vp <- VSM.unsafeRead ar (oPrev + j)
+                      VSM.unsafeModify adj (+ (a * xt `VS.unsafeIndex` j)) (oPrev + j)
+                      VSM.unsafeModify gxs (+ (a * vp)) (gOff + j)
+                      go (j + 1)
+        in go 0
+      SDivO ->
+        let go !j | j >= nObs = pure ()
+                  | otherwise = do
+                      a  <- VSM.unsafeRead adj (oCur + j)
+                      vp <- VSM.unsafeRead ar (oPrev + j)
+                      let x = xt `VS.unsafeIndex` j
+                      VSM.unsafeModify adj (+ (a / x)) (oPrev + j)
+                      VSM.unsafeModify gxs (+ (negate (a * vp / (x * x)))) (gOff + j)
+                      go (j + 1)
+        in go 0
+  -- acc_0 = x0 → gx_0 = adj_0
+  let fin !j | j >= nObs = pure ()
+             | otherwise = do
+                 a <- VSM.unsafeRead adj j
+                 VSM.unsafeModify gxs (+ a) j
+                 fin (j + 1)
+  fin 0
+  VS.unsafeFreeze gxs
+
+-- | [C'] 完全融合 1-pass: 要素ごとに forward re-計算 + registers 逆伝播。
+gradC :: BV.Vector (VS.Vector Double) -> VS.Vector Double
+gradC xs = runST $ do
+  gxs <- VSM.replicate ((nOps + 1) * nObs) 0
+  let x0 = xs BV.! 0
+      x1 = xs BV.! 1
+      x2 = xs BV.! 2
+      x3 = xs BV.! 3
+      x4 = xs BV.! 4
+      x5 = xs BV.! 5
+      x6 = xs BV.! 6
+      x7 = xs BV.! 7
+      go !j
+        | j >= nObs = pure ()
+        | otherwise = do
+            let i0 = x0 `VS.unsafeIndex` j
+                i1 = x1 `VS.unsafeIndex` j
+                i2 = x2 `VS.unsafeIndex` j
+                i3 = x3 `VS.unsafeIndex` j
+                i4 = x4 `VS.unsafeIndex` j
+                i5 = x5 `VS.unsafeIndex` j
+                i6 = x6 `VS.unsafeIndex` j
+                i7 = x7 `VS.unsafeIndex` j
+                v1 = i0 + i1
+                v2 = v1 * i2
+                v3 = v2 - i3
+                v4 = v3 / i4
+                v5 = v4 + i5
+                v6 = v5 * i6
+                -- v7 = v6 - i7 (obj 側は Σ ゆえ adj_7 = 1)
+                a7 = 1 :: Double
+                a6 = a7
+                a5 = a6 * i6
+                a4 = a5
+                a3 = a4 / i4
+                a2 = a3
+                a1 = a2 * i2
+                a0 = a1
+            VSM.unsafeWrite gxs (7 * nObs + j) (negate a7)
+            VSM.unsafeWrite gxs (6 * nObs + j) (a6 * v5)
+            VSM.unsafeWrite gxs (5 * nObs + j) a5
+            VSM.unsafeWrite gxs (4 * nObs + j) (negate (a4 * v3 / (i4 * i4)))
+            VSM.unsafeWrite gxs (3 * nObs + j) (negate a3)
+            VSM.unsafeWrite gxs (2 * nObs + j) (a2 * v1)
+            VSM.unsafeWrite gxs (1 * nObs + j) a1
+            VSM.unsafeWrite gxs (0 * nObs + j) a0
+            go (j + 1)
+  go 0
+  VS.unsafeFreeze gxs
+
+-- ---------------------------------------------------------------------------
+
+main :: IO ()
+main = do
+  putStrLn "=== Phase 85.3a: vecIR 融合方式 spike (n=919・二項 7 op 連鎖) ==="
+  printf "セル数 (bin cells) = %d (radon VIBin 18744 セルの縮小相似形)\n\n"
+    (nOps * nObs)
+  let opCodes = VU.fromList (map opCode ops)
+
+  -- 正しさ突合 (A' vs C')
+  let xs0 = mkInputs 0
+      gA  = gradA xs0
+      gC  = gradC xs0
+      dMax = VS.maximum (VS.zipWith (\a b -> abs (a - b)) gA gC)
+      vA = fwA xs0
+      vC = fwC xs0
+      vD = fwD opCodes xs0
+  printf "突合: fwA=%.6f fwC=%.6f fwD=%.6f  grad max|Δ|=%.3e\n\n" vA vC vD dMax
+  when (dMax > 1e-12) $ error "gradA と gradC が不一致"
+
+  -- forward 4 方式
+  putStrLn "--- forward per-eval (µs・ns/セル) ---"
+  let cells = fromIntegral (nOps * nObs) :: Double
+      report tag tMs = printf "  %-34s %8.2f µs  %6.2f ns/セル\n"
+        (tag :: String) (tMs * 1000) (tMs * 1e6 / cells)
+  (tA, _) <- timeitTastyIO id (\i -> pure $! fwA (mkInputsCached i))
+  report "[A] 現行 (arena+unknown-call)" tA
+  (tB, _) <- timeitTastyIO id (\i -> pure $! fwB (mkInputsCached i))
+  report "[B] arena+op特化ループ" tB
+  (tC, _) <- timeitTastyIO id (\i -> pure $! fwC (mkInputsCached i))
+  report "[C] 完全融合1-pass (天井)" tC
+  (tD, _) <- timeitTastyIO id (\i -> pure $! fwD opCodes (mkInputsCached i))
+  report "[D] 要素ごと解釈融合" tD
+
+  -- backward (fw+bw)
+  putStrLn "\n--- gradient (fw+bw) per-eval (µs・ns/セル) ---"
+  (tGA, _) <- timeitTastyIO id
+    (\i -> pure $! VS.unsafeIndex (gradA (mkInputsCached i)) (i `mod` nObs))
+  report "[A'] 現行 (2 arena+op特化bw)" tGA
+  (tGC, _) <- timeitTastyIO id
+    (\i -> pure $! VS.unsafeIndex (gradC (mkInputsCached i)) (i `mod` nObs))
+  report "[C'] 完全融合1-pass (天井)" tGC
+
+  printf "\n倍率: fw A/C=%.1f×  A/B=%.2f×  A/D=%.1f×  grad A'/C'=%.1f×\n"
+    (tA / tC) (tA / tB) (tA / tD) (tGA / tGC)
+
+-- | 入力 16 変種を事前生成 (CSE 回避・生成コストを計測に混ぜない)。
+inputsPool :: BV.Vector (BV.Vector (VS.Vector Double))
+inputsPool = BV.fromList [ mkInputs s | s <- [0 .. 15] ]
+{-# NOINLINE inputsPool #-}
+
+mkInputsCached :: Int -> BV.Vector (VS.Vector Double)
+mkInputsCached i = inputsPool BV.! (i `mod` 16)
diff --git a/bench/haskell/BenchHBMHet.hs b/bench/haskell/BenchHBMHet.hs
new file mode 100644
--- /dev/null
+++ b/bench/haskell/BenchHBMHet.hs
@@ -0,0 +1,81 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RankNTypes #-}
+{-# OPTIONS_GHC -fno-full-laziness -fno-cse #-}
+-- | Phase 55.3 小 bench: heteroscedastic モデルの per-call 勾配 A/B。
+--
+--   mHet: y_i ~ N(a, exp(g0 + g1·z_i))   (n=100, θ=3・σ が行依存の式)
+--
+-- 55.3 で σ 位置が「単一 latent」 → 任意 SExp に拡張され、 このモデルは
+-- ベクトル式 IR に吸収されるようになった (旧 = σ 検出不能で全体 ad fallback)。
+-- 比較 2 通り (同一 unconstrained 全勾配・相対誤差検証後に計測):
+--
+--   (a) HBM.gradADU      — 実経路 (55.3 後 = IR 吸収・NUTS が払う値)
+--   (b) RevD.grad (walk) — 旧 fallback 相当 (モデル全体を ad で毎回 walk)
+--
+-- per-draw への波及は M 系 bench (55.5) で測る。 ここは勾配カーネル単体。
+module Main where
+
+import           Control.Monad                  (forM_)
+import qualified Data.Map.Strict                as Map
+import qualified Data.Text                      as T
+import           Text.Printf                    (printf)
+
+import qualified Numeric.AD.Mode.Reverse.Double as RevD
+
+import           Hanalyze.Model.HBM             (Distribution (..), ModelP,
+                                                 sample, observe, gradADU,
+                                                 sampleNames, getTransforms,
+                                                 logJoint, invTransformF,
+                                                 logJacF)
+
+import           BenchUtil                      (timeitIO)
+
+nHet :: Int
+nHet = 100
+
+-- 決定的データ (DGP は等間隔 z + 線形 y・乱数不要の固定系列)。
+zsHet, ysHet :: [Double]
+zsHet = [ fromIntegral i / fromIntegral nHet * 2 - 1 | i <- [0 .. nHet - 1] ]
+ysHet = [ 1.2 + 0.1 * z | z <- zsHet ]
+
+mHet :: ModelP ()
+mHet = do
+  a  <- sample "a"  (Normal 0 10)
+  g0 <- sample "g0" (Normal 0 2)
+  g1 <- sample "g1" (Normal 0 2)
+  forM_ (zip3 [0 :: Int ..] zsHet ysHet) $ \(i, z, y) ->
+    observe (T.pack ("y_" ++ show i))
+      (Normal a (exp (g0 + g1 * realToFrac z))) [y]
+
+main :: IO ()
+main = do
+  let names = sampleNames mHet
+      tmap  = getTransforms mHet
+      trans = [ tmap Map.! nm | nm <- names ]
+      uvs   = [0.9, -0.3, 0.4]
+      -- (a) 実経路 (55.3 後 = IR 吸収)
+      gIR = gradADU mHet names trans
+      -- (b) 旧 fallback 相当: モデル全体を ad で walk (compileGradUV の
+      --     synthVecIR Nothing 分岐 gradFull と同形)
+      gAD uv = RevD.grad
+                 (\uv' -> logJoint mHet
+                            (Map.fromList
+                               (zip names (zipWith invTransformF trans uv')))
+                          + sum (zipWith logJacF trans uv'))
+                 uv
+      relErr = maximum [ abs (x - y) / (1 + abs y)
+                       | (x, y) <- zip (gIR uvs) (gAD uvs) ]
+  printf "relErr IR vs ad-full = %.2e\n" relErr
+  -- 1 計測 = batch 回の勾配呼出 (µs 級なので)。 入力を毎回微小摂動して
+  -- CSE/共有を防ぐ (1e-12 は数値に実質影響しない)。
+  let batch = 1000 :: Int
+      runBatch g i = pure $! sum
+        [ sum (g (map (+ (1e-12 * fromIntegral (i * batch + j))) uvs))
+        | j <- [1 .. batch] ]
+  (msIR, _) <- timeitIO 7 id (runBatch gIR)
+  (msAD, _) <- timeitIO 7 id (runBatch gAD)
+  let pcIR = msIR / fromIntegral batch
+      pcAD = msAD / fromIntegral batch
+  printf "gradADU (IR 吸収・実経路): %.5f ms/call\n" pcIR
+  printf "RevD walk (旧 fallback) : %.5f ms/call\n" pcAD
+  printf "speedup x%.1f\n" (pcAD / pcIR)
diff --git a/bench/haskell/BenchHBMProfile.hs b/bench/haskell/BenchHBMProfile.hs
new file mode 100644
--- /dev/null
+++ b/bench/haskell/BenchHBMProfile.hs
@@ -0,0 +1,132 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RankNTypes #-}
+{-# OPTIONS_GHC -fno-full-laziness -fno-cse #-}
+-- | HBM 勾配評価ボトルネック診断 (Phase 53)。
+--
+-- 仮説 (一次根拠: @HBM.hs:146@ が @Numeric.AD.Mode.Forward.grad@ を使用・
+-- ad-4.5.6 ソースが「reverse mode より O(n) 遅い」と明記) =
+-- **前進モード AD ゆえ勾配 1 本が latent 数 p 回の関数評価を要する**。
+--
+-- 本ベンチで決定的に確認する:
+--   (1) 1 勾配 (gradADU) / 1 log-joint (logJoint) の時間比 ≈ p か
+--   (2) p (群数で latent を増やす) を振ったとき gradADU 時間が p に線形か
+--   (3) per-eval が観測数 N_obs に対してどう伸びるか
+--
+-- これが真なら最適化方針 = reverse-mode AD への切替で勾配を O(1) sweep 化。
+module Main where
+
+import           Control.Monad                    (forM_)
+import qualified Data.Map.Strict                  as Map
+import qualified Data.Text                        as T
+import qualified Data.Vector                      as V
+import qualified System.Random.MWC                as MWC
+import           System.Random.MWC.Distributions  (standard)
+import           Text.Printf                      (printf)
+
+import           Hanalyze.Model.HBM
+  ( Distribution (..), ModelP, sample, observe
+  , glmmRandomIntercept, GlmmFamily (..)
+  , sampleNames, getTransforms, gradADU, logJoint )
+import           Hanalyze.Stat.Distribution       (fromUnconstrained)
+
+import           BenchUtil
+
+-- ---------------------------------------------------------------------------
+-- モデル
+-- ---------------------------------------------------------------------------
+
+m1Model :: [Double] -> [Double] -> ModelP ()
+m1Model xs ys = do
+  a <- sample "a"     (Normal 0 10)
+  b <- sample "b"     (Normal 0 10)
+  s <- sample "sigma" (Exponential 1)
+  forM_ (zip3 [0 :: Int ..] xs ys) $ \(i, x, y) ->
+    observe (T.pack ("y_" ++ show i)) (Normal (a + b * realToFrac x) s) [y]
+
+m2Model :: [[Double]] -> [Int] -> [Double] -> ModelP ()
+m2Model xRows gids ys = glmmRandomIntercept GlmmGaussian xRows gids ys
+
+-- ---------------------------------------------------------------------------
+-- データ生成 (決定的・CSV 不要・内部診断用)
+-- ---------------------------------------------------------------------------
+
+normals :: Int -> Int -> IO [Double]
+normals seed k = do
+  g <- MWC.initialize (V.singleton (fromIntegral seed))
+  mapM (const (standard g)) [1 .. k]
+
+genM1Data :: Int -> IO ([Double], [Double])
+genM1Data n = do
+  xz <- normals 11 n
+  ez <- normals 12 n
+  let xs = map (* 2.0) xz
+      ys = zipWith (\x e -> 2.0 + 1.5 * x + e) xs ez
+  return (xs, ys)
+
+-- | nG 群 × perG 観測の random-intercept データ。
+genM2Data :: Int -> Int -> IO ([[Double]], [Int], [Double])
+genM2Data nG perG = do
+  let n = nG * perG
+  xz <- normals 21 n
+  ez <- normals 22 n
+  uz <- normals 23 nG
+  let us   = map (* 1.5) uz
+      gids = [ i `div` perG | i <- [0 .. n - 1] ]
+      xs   = map (* 2.0) xz
+      ys   = [ 1.0 + 0.8 * x + (us !! g) + e | (x, g, e) <- zip3 xs gids ez ]
+      xRows = [ [1.0, x] | x <- xs ]
+  return (xRows, gids, ys)
+
+-- ---------------------------------------------------------------------------
+-- 計時ヘルパ: 1 モデルについて logJoint と gradADU の per-eval を測る
+-- ---------------------------------------------------------------------------
+
+-- | unconstrained 初期点 (latent 全 0 = 制約空間でも有限) を作り、
+--   logJoint(Double) 1 回と gradADU 1 回の per-eval ナノ秒を返す。
+profileModel :: String -> ModelP () -> IO ()
+profileModel tag m = do
+  let names = sampleNames m
+      p     = length names
+      tmap  = getTransforms m
+      trans = [ Map.findWithDefault err n tmap | n <- names ]
+      err   = error "transform missing"
+      us0   = replicate p (0.0 :: Double)
+      -- logJoint を constrained 空間で評価するための params
+      paramsC = Map.fromList
+        [ (n, fromUnconstrained t u) | (n, t, u) <- zip3 names trans us0 ]
+
+  -- logJoint 1 回 (Double・前進walk 1 本)
+  (ljMs, _) <- timeitIO 50 id (\_ -> pure (logJoint m paramsC))
+  -- gradADU 1 回 (前進モード grad = p sweep のはず)
+  (gMs, _)  <- timeitIO 50 (sum . map abs)
+                 (\_ -> pure (gradADU m names trans us0))
+
+  let ratio = gMs / max 1e-12 ljMs
+  printf "%-18s p=%-3d | logJoint=%8.4f ms | gradADU=%9.4f ms | ratio=%6.2f (≈p?)\n"
+    tag p ljMs gMs ratio
+
+-- ---------------------------------------------------------------------------
+
+main :: IO ()
+main = do
+  putStrLn "=== Phase 53: HBM 勾配ボトルネック診断 ==="
+  putStrLn "仮説: gradADU/logJoint 比 ≈ p なら前進モード AD が O(p) ボトルネック\n"
+
+  -- (1) M1 / M2 の比較
+  (x1, y1) <- genM1Data 100
+  (xr, g2, y2) <- genM2Data 8 12
+  putStrLn "--- (1) M1 (pooled) vs M2 (random intercept) ---"
+  profileModel "M1_pooled(100obs)" (m1Model x1 y1)
+  profileModel "M2_ranint(8grp)"   (m2Model xr g2 y2)
+
+  -- (2) 群数を振って p を増やす → gradADU が p に線形か
+  putStrLn "\n--- (2) 群数↑ で latent p↑: gradADU が p に線形か (obs/群=12 固定) ---"
+  forM_ [2, 4, 8, 16, 32] $ \nG -> do
+    (xr', g', y') <- genM2Data nG 12
+    profileModel (printf "M2_g%d" nG) (m2Model xr' g' y')
+
+  -- (3) 観測数を振る (p=3 固定の M1) → per-eval が N_obs に線形か
+  putStrLn "\n--- (3) 観測数↑ (M1 p=3 固定): logJoint/gradADU が N_obs に線形か ---"
+  forM_ [50, 100, 200, 400, 800] $ \nObs -> do
+    (xs, ys) <- genM1Data nObs
+    profileModel (printf "M1_n%d" nObs) (m1Model xs ys)
diff --git a/bench/haskell/BenchHBMScaling.hs b/bench/haskell/BenchHBMScaling.hs
new file mode 100644
--- /dev/null
+++ b/bench/haskell/BenchHBMScaling.hs
@@ -0,0 +1,709 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RankNTypes #-}
+{-# OPTIONS_GHC -fno-full-laziness -fno-cse #-}
+-- | HBM サンプラ性能スケーリングベンチ (hanalyze NUTS vs PyMC)。
+--
+-- 目的: HBM (NUTS) の wall-time が post-warmup サンプル数 @iter@ に対して
+-- 線形 (O(iter)) に伸びるか、 また per-sample 単価が PyMC と比べてどうかを
+-- 計測する。 warmup を固定し本サンプル数だけ掃くことで
+--   total = (warmup 固定費) + (1 サンプル単価) * iter
+-- の線形フィットで切片 (固定費) と傾き (単価) を分離する。
+--
+-- モデル階層 (簡単→複雑):
+--   M1 pooled 単回帰        y_i ~ N(a + b·x_i, σ)
+--   M2 階層 random intercept y_ij ~ N(β0 + β1·x_ij + u_{g(i)}, σ),
+--                            u_j ~ N(0, τ_u)
+--   M3 階層 random intercept+slope (Phase 54.8 後拡張)
+--                            y_ij ~ N(β0 + β1·x + u_g + v_g·x, σ)
+--   M4 多変量 X pooled       y_i ~ N(β0 + Σ_{k=1..10} β_k·x_ik, σ)
+--   M5 パラメタ非線形        y_i ~ N(a·exp(-b·x_i) + c, σ)
+--   M6 組合せ (階層×非線形)   y_ij ~ N(a_g·exp(-b·x_ij), σ), a_g ~ N(μ_a, τ_a)
+--   M7 Poisson 回帰 (Phase 55) y_i ~ Poisson(exp(a + b·x_i))
+--   M8 logistic 回帰 (Phase 55) y_i ~ Bernoulli(invLogit(a + b·x_i))
+--   M9 NegBin 回帰 (Phase 56.6) y_i ~ NegBin(exp(a + b·x_i), α)
+--
+-- M3-M9 は **per-obs scalar observe の手書き** (汎用 authoring) で組む:
+-- M3/M4 は affine ゆえ Phase 54.8 の自動 ObserveLM 合成が乗り、 M5/M6 は
+-- パラメタ非線形ゆえ合成不可 = walk+ad fallback。 M7/M8 は非 Gaussian 観測
+-- ゆえ高速経路対象外 (Phase 55 改善前 baseline)。 M9 は 56.5 の IR 吸収
+-- (lgamma 項含む) の per-draw 効果測定用。 「汎用に書いてどこまで
+-- 速いか」 を正直に測る構成。
+--
+-- データは決定的 DGP で生成し @bench/data/hbm_m{1..9}.csv@ に書き出す
+-- (Python 側 @bench_hbm_scaling.py@ が同じ CSV を読む = 公平比較)。
+-- 結果は unified BenchRow CSV @bench/results/haskell/hbm_scaling.csv@ へ。
+-- 引数: 無し=M1-M8 全部 / @glm@=M7-M9 のみ (hbm_scaling_glm.csv) /
+-- @m5-long@/@m7-long@/@m8-long@/@m9-long@=延長 grid (hbm_scaling_<m>_long.csv)。
+module Main where
+
+import           Control.Monad                    (forM_)
+import           System.Environment               (getArgs)
+import qualified Data.Map.Strict                  as Map
+import qualified Data.Text                        as T
+import qualified Data.Vector                      as V
+import qualified System.Random.MWC                as MWC
+import           System.Random.MWC.Distributions  (standard)
+import           Text.Printf                      (printf)
+import           System.IO                        (withFile, IOMode (..),
+                                                   hPutStrLn, hSetBuffering,
+                                                   BufferMode (..))
+
+import           Hanalyze.Model.HBM               (Distribution (..), ModelP,
+                                                   sample, observe, sampleDist,
+                                                   glmmRandomIntercept,
+                                                   GlmmFamily (..))
+-- Distribution(..) は HalfCauchy 等 全コンストラクタを含む (eight schools で使用)。
+import           Hanalyze.MCMC.Core               (Chain, chainAccepted,
+                                                   chainTreeDepths,
+                                                   chainTotal, chainVals,
+                                                   posteriorMean)
+import           Hanalyze.MCMC.NUTS               (NUTSConfig (..),
+                                                   defaultNUTSConfig, nuts)
+import           Hanalyze.Stat.MCMC               (ess)
+import           Hanalyze.Fit                     (designHBMProgram)
+
+import           BenchUtil
+
+-- ---------------------------------------------------------------------------
+-- 共通設定
+-- ---------------------------------------------------------------------------
+
+iterGrid :: [Int]
+iterGrid = [50, 100, 200, 400, 800, 1600]
+
+warmupFixed :: Int
+warmupFixed = 500
+
+timingReps :: Int
+timingReps = 5
+
+-- 真値 (DGP)
+m1True :: (Double, Double, Double)   -- (a, b, sigma)
+m1True = (2.0, 1.5, 1.0)
+
+m2True :: (Double, Double, Double, Double)  -- (beta0, beta1, tau_u, sigma)
+m2True = (1.0, 0.8, 1.5, 1.0)
+
+nM1 :: Int
+nM1 = 100
+
+nGroupsM2, perGroupM2 :: Int
+nGroupsM2  = 8
+perGroupM2 = 12     -- 計 96 観測
+
+-- M3: (beta0, beta1, tau_u, tau_v, sigma)。 群構成は M2 と同じ 8×12。
+m3True :: (Double, Double, Double, Double, Double)
+m3True = (1.0, 0.8, 1.0, 0.5, 1.0)
+
+-- M4: 多変量 X (p=10 + intercept)。 (intercept:betas, sigma)。
+m4BetaTrue :: [Double]
+m4BetaTrue = [1.0, 1.2, -0.8, 0.5, 0.3, -0.2, 0.7, -0.4, 0.6, -0.5, 0.1]
+
+m4SigmaTrue :: Double
+m4SigmaTrue = 1.0
+
+nM4, pM4 :: Int
+nM4 = 200
+pM4 = 10
+
+-- M5: y = a·exp(-b·x) + c。 (a, b, c, sigma)。
+m5True :: (Double, Double, Double, Double)
+m5True = (2.5, 1.2, 0.5, 0.3)
+
+nM5 :: Int
+nM5 = 100
+
+-- M6: y_ij = a_g·exp(-b·x) (a_g ~ N(mu_a, tau_a))。 (mu_a, tau_a, b, sigma)。
+m6True :: (Double, Double, Double, Double)
+m6True = (2.0, 0.5, 1.0, 0.3)
+
+-- M7: y ~ Poisson(exp(a + b·x))。 (a, b)。 x ~ N(0,1) で λ ∈ おおよそ
+-- [0.1, 20] (Knuth サンプラ・logFactorial とも十分な範囲)。
+m7True :: (Double, Double)
+m7True = (0.5, 0.8)
+
+nM7 :: Int
+nM7 = 100
+
+-- M8: y ~ Bernoulli(invLogit(a + b·x))。 (a, b)。
+m8True :: (Double, Double)
+m8True = (0.3, 1.2)
+
+nM8 :: Int
+nM8 = 100
+
+-- M9: y ~ NegBin(exp(a + b·x), α)。 (a, b, alpha)。 x ~ N(0,1) で
+-- μ ∈ おおよそ [0.15, 18] (M7 と同レンジ)。 α は過分散が見える 1.5
+-- (整数回避は lgammaApprox 境界 FD 罠の慣例に合わせる・56.1 記録)。
+m9True :: (Double, Double, Double)
+m9True = (0.5, 0.8, 1.5)
+
+nM9 :: Int
+nM9 = 100
+
+-- ---------------------------------------------------------------------------
+-- 決定的データ生成
+-- ---------------------------------------------------------------------------
+
+-- | seed 固定の N(0,1) 列。
+normals :: Int -> Int -> IO [Double]
+normals seed k = do
+  g <- MWC.initialize (V.singleton (fromIntegral seed))
+  mapM (const (standard g)) [1 .. k]
+
+-- | M1: x ~ N(0,2), y = a + b·x + N(0,σ)。 (x, y) を返し CSV も書く。
+genM1 :: IO ([Double], [Double])
+genM1 = do
+  let (a, b, s) = m1True
+  xz <- normals 11 nM1
+  ez <- normals 12 nM1
+  let xs = map (* 2.0) xz
+      ys = zipWith (\x e -> a + b * x + s * e) xs ez
+  writeCsv "bench/data/hbm_m1.csv" "x0,y"
+    [ printf "%.6f,%.6f" x y | (x, y) <- zip xs ys ]
+  return (xs, ys)
+
+-- | M2: 8 群 × 12。 x ~ N(0,2), u_g ~ N(0,τ_u), y = β0+β1·x+u_g+N(0,σ)。
+--   (xRows [[1,x]], gids, ys) を返し CSV (x0,group,y) も書く。
+genM2 :: IO ([[Double]], [Int], [Double])
+genM2 = do
+  let (b0, b1, tauU, s) = m2True
+      n = nGroupsM2 * perGroupM2
+  xz <- normals 21 n
+  ez <- normals 22 n
+  uz <- normals 23 nGroupsM2
+  let us   = map (* tauU) uz
+      gids = [ i `div` perGroupM2 | i <- [0 .. n - 1] ]
+      xs   = map (* 2.0) xz
+      ys   = [ b0 + b1 * x + (us !! g) + s * e
+             | (x, g, e) <- zip3 xs gids ez ]
+      xRows = [ [1.0, x] | x <- xs ]
+  writeCsv "bench/data/hbm_m2.csv" "x0,group,y"
+    [ printf "%.6f,%d,%.6f" x g y | (x, g, y) <- zip3 xs gids ys ]
+  return (xRows, gids, ys)
+
+-- | M3: 8 群 × 12。 y = β0 + β1·x + u_g + v_g·x + N(0,σ)。
+--   (xs, gids, ys) を返し CSV (x0,group,y) も書く。
+genM3 :: IO ([Double], [Int], [Double])
+genM3 = do
+  let (b0, b1, tauU, tauV, s) = m3True
+      n = nGroupsM2 * perGroupM2
+  xz <- normals 31 n
+  ez <- normals 32 n
+  uz <- normals 33 nGroupsM2
+  vz <- normals 34 nGroupsM2
+  let us   = map (* tauU) uz
+      vs   = map (* tauV) vz
+      gids = [ i `div` perGroupM2 | i <- [0 .. n - 1] ]
+      xs   = map (* 2.0) xz
+      ys   = [ b0 + b1 * x + (us !! g) + (vs !! g) * x + s * e
+             | (x, g, e) <- zip3 xs gids ez ]
+  writeCsv "bench/data/hbm_m3.csv" "x0,group,y"
+    [ printf "%.6f,%d,%.6f" x g y | (x, g, y) <- zip3 xs gids ys ]
+  return (xs, gids, ys)
+
+-- | M4: n=200, p=10。 y = β0 + Σ β_k·x_k + N(0,σ)。
+--   (xRows (p 列・intercept 含まず), ys) を返し CSV (x0..x9,y) も書く。
+genM4 :: IO ([[Double]], [Double])
+genM4 = do
+  xz <- normals 41 (nM4 * pM4)
+  ez <- normals 42 nM4
+  let xRows = [ take pM4 (drop (i * pM4) xz) | i <- [0 .. nM4 - 1] ]
+      (b0 : bks) = m4BetaTrue
+      ys = [ b0 + sum (zipWith (*) bks xr) + m4SigmaTrue * e
+           | (xr, e) <- zip xRows ez ]
+      hdr = concat [ "x" ++ show k ++ "," | k <- [0 .. pM4 - 1] ] ++ "y"
+  writeCsv "bench/data/hbm_m4.csv" hdr
+    [ concat [ printf "%.6f," x | x <- xr ] ++ printf "%.6f" y
+    | (xr, y) <- zip xRows ys ]
+  return (xRows, ys)
+
+-- | M5: x = [0,3) 等間隔グリッド。 y = a·exp(-b·x) + c + N(0,σ)。
+genM5 :: IO ([Double], [Double])
+genM5 = do
+  let (a, b, c, s) = m5True
+  ez <- normals 51 nM5
+  let xs = [ 3.0 * (fromIntegral i + 0.5) / fromIntegral nM5
+           | i <- [0 .. nM5 - 1] ]
+      ys = [ a * exp (negate b * x) + c + s * e | (x, e) <- zip xs ez ]
+  writeCsv "bench/data/hbm_m5.csv" "x0,y"
+    [ printf "%.6f,%.6f" x y | (x, y) <- zip xs ys ]
+  return (xs, ys)
+
+-- | M6: 8 群 × 12・x は群内 [0,3) グリッド。 y = a_g·exp(-b·x) + N(0,σ)。
+genM6 :: IO ([Double], [Int], [Double])
+genM6 = do
+  let (muA, tauA, b, s) = m6True
+      n = nGroupsM2 * perGroupM2
+  ez <- normals 61 n
+  az <- normals 62 nGroupsM2
+  let as   = [ muA + tauA * z | z <- az ]
+      gids = [ i `div` perGroupM2 | i <- [0 .. n - 1] ]
+      xs   = [ 3.0 * (fromIntegral (i `mod` perGroupM2) + 0.5)
+                   / fromIntegral perGroupM2
+             | i <- [0 .. n - 1] ]
+      ys   = [ (as !! g) * exp (negate b * x) + s * e
+             | (x, g, e) <- zip3 xs gids ez ]
+  writeCsv "bench/data/hbm_m6.csv" "x0,group,y"
+    [ printf "%.6f,%d,%.6f" x g y | (x, g, y) <- zip3 xs gids ys ]
+  return (xs, gids, ys)
+
+-- | M7: x ~ N(0,1)。 y ~ Poisson(exp(a + b·x)) (sampleDist で決定的生成)。
+genM7 :: IO ([Double], [Double])
+genM7 = do
+  let (a, b) = m7True
+  xs <- normals 71 nM7
+  g  <- MWC.initialize (V.singleton 72)
+  ys <- mapM (\x -> sampleDist (Poisson (exp (a + b * x))) g) xs
+  writeCsv "bench/data/hbm_m7.csv" "x0,y"
+    [ printf "%.6f,%.0f" x y | (x, y) <- zip xs ys ]
+  return (xs, ys)
+
+-- | M8: x ~ N(0,1)。 y ~ Bernoulli(invLogit(a + b·x))。
+genM8 :: IO ([Double], [Double])
+genM8 = do
+  let (a, b) = m8True
+  xs <- normals 81 nM8
+  g  <- MWC.initialize (V.singleton 82)
+  ys <- mapM (\x -> sampleDist
+                      (Bernoulli (1 / (1 + exp (negate (a + b * x))))) g) xs
+  writeCsv "bench/data/hbm_m8.csv" "x0,y"
+    [ printf "%.6f,%.0f" x y | (x, y) <- zip xs ys ]
+  return (xs, ys)
+
+-- | M9: x ~ N(0,1)。 y ~ NegBin(exp(a + b·x), α) (sampleDist で決定的生成)。
+genM9 :: IO ([Double], [Double])
+genM9 = do
+  let (a, b, al) = m9True
+  xs <- normals 91 nM9
+  g  <- MWC.initialize (V.singleton 92)
+  ys <- mapM (\x -> sampleDist
+                      (NegativeBinomial (exp (a + b * x)) al) g) xs
+  writeCsv "bench/data/hbm_m9.csv" "x0,y"
+    [ printf "%.6f,%.0f" x y | (x, y) <- zip xs ys ]
+  return (xs, ys)
+
+writeCsv :: FilePath -> String -> [String] -> IO ()
+writeCsv path hdr rows = withFile path WriteMode $ \h -> do
+  hSetBuffering h LineBuffering
+  hPutStrLn h hdr
+  mapM_ (hPutStrLn h) rows
+
+-- | Radon 生 CSV を読む (Python 側と同一ファイル)。 列 =
+--   county(str), county_idx(int), floor(0/1), log_radon, log_uranium。
+--   返り値 = (designX=[[1,floor,uranium]], county_idx, floor 列, log_radon)。
+readRadon :: IO ([[Double]], [Int], [Double], [Double])
+readRadon = do
+  txt <- readFile "bench/data/radon.csv"
+  let recs = map parseRow (drop 1 (lines txt))
+      parseRow ln = case splitComma ln of
+        (_c : ci : fl : lr : lu : _) ->
+          (read ci :: Int, read fl :: Double, read lr :: Double, read lu :: Double)
+        _ -> error ("readRadon: 列不足 " ++ ln)
+      cidx    = [ c | (c, _, _, _) <- recs ]
+      floors  = [ f | (_, f, _, _) <- recs ]
+      ys      = [ y | (_, _, y, _) <- recs ]
+      designX = [ [1.0, f, u] | (_, f, _, u) <- recs ]
+  return (designX, cidx, floors, ys)
+
+-- | 単純な comma split (Data.List.Split 非依存)。
+splitComma :: String -> [String]
+splitComma s = case break (== ',') s of
+  (a, ',' : rest) -> a : splitComma rest
+  (a, _)          -> [a]
+
+-- ---------------------------------------------------------------------------
+-- モデル定義
+-- ---------------------------------------------------------------------------
+
+-- | M1 pooled 単回帰。 Distribution はスカラ専用ゆえ観測は 1 点ずつ展開。
+m1Model :: [Double] -> [Double] -> ModelP ()
+m1Model xs ys = do
+  a <- sample "a"     (Normal 0 10)
+  b <- sample "b"     (Normal 0 10)
+  s <- sample "sigma" (Exponential 1)
+  forM_ (zip3 [0 :: Int ..] xs ys) $ \(i, x, y) ->
+    observe (T.pack ("y_" ++ show i)) (Normal (a + b * realToFrac x) s) [y]
+
+-- | M2 階層 random intercept。 既存 helper をそのまま使う
+--   (latent: beta_0, beta_1, tau_u, u_0..u_{nG-1}, sigma)。
+m2Model :: [[Double]] -> [Int] -> [Double] -> ModelP ()
+m2Model xRows gids ys = glmmRandomIntercept GlmmGaussian xRows gids ys
+
+-- | M3 階層 random intercept+slope (per-obs 手書き)。 u_g は係数 1 ゆえ
+--   54.8 合成で REff gather、 v_g は係数 x ゆえ dense 列 + prior は
+--   residual ad walk に残る (中間ケース)。
+m3Model :: [Double] -> [Int] -> [Double] -> ModelP ()
+m3Model xs gids ys = do
+  let nG = if null gids then 0 else maximum gids + 1
+  b0 <- sample "beta_0" (Normal 0 5)
+  b1 <- sample "beta_1" (Normal 0 5)
+  tu <- sample "tau_u"  (HalfNormal 5)
+  tv <- sample "tau_v"  (HalfNormal 5)
+  us <- mapM (\j -> sample (T.pack ("u_" ++ show j)) (Normal 0 tu)) [0 .. nG - 1]
+  vs <- mapM (\j -> sample (T.pack ("v_" ++ show j)) (Normal 0 tv)) [0 .. nG - 1]
+  s  <- sample "sigma" (Exponential 1)
+  forM_ (zip3 [0 :: Int ..] (zip xs gids) ys) $ \(i, (x, g), y) ->
+    observe (T.pack ("y_" ++ show i))
+      (Normal (b0 + b1 * realToFrac x + us !! g + (vs !! g) * realToFrac x) s) [y]
+
+-- | M4 多変量 X pooled (per-obs 手書き)。 全 affine ゆえ 54.8 合成で
+--   全 latent が dense β 列 + 定数 prior = 完全解析経路。
+m4Model :: [[Double]] -> [Double] -> ModelP ()
+m4Model xRows ys = do
+  bs <- mapM (\k -> sample (T.pack ("beta_" ++ show k)) (Normal 0 5))
+             [0 .. pM4]
+  s  <- sample "sigma" (Exponential 1)
+  let (b0 : bks) = bs
+  forM_ (zip3 [0 :: Int ..] xRows ys) $ \(i, xr, y) ->
+    observe (T.pack ("y_" ++ show i))
+      (Normal (b0 + sum (zipWith (\b x -> b * realToFrac x) bks xr)) s) [y]
+
+-- | M5 パラメタ非線形 (per-obs 手書き)。 μ = a·exp(-b·x) + c は非 affine ゆえ
+--   54.8 合成不可 → walk + ad fallback (汎用経路の弱点を正直に測る)。
+m5Model :: [Double] -> [Double] -> ModelP ()
+m5Model xs ys = do
+  a <- sample "a" (Normal 0 10)
+  b <- sample "b" (HalfNormal 2)
+  c <- sample "c" (Normal 0 10)
+  s <- sample "sigma" (Exponential 1)
+  forM_ (zip3 [0 :: Int ..] xs ys) $ \(i, x, y) ->
+    observe (T.pack ("y_" ++ show i))
+      (Normal (a * exp (negate b * realToFrac x) + c) s) [y]
+
+-- | M6 階層 × 非線形 (per-obs 手書き)。 a_g·exp(-b·x) も非 affine → fallback。
+m6Model :: [Double] -> [Int] -> [Double] -> ModelP ()
+m6Model xs gids ys = do
+  let nG = if null gids then 0 else maximum gids + 1
+  muA  <- sample "mu_a"  (Normal 0 10)
+  tauA <- sample "tau_a" (HalfNormal 2)
+  as   <- mapM (\j -> sample (T.pack ("a_" ++ show j)) (Normal muA tauA))
+               [0 .. nG - 1]
+  b    <- sample "b" (HalfNormal 2)
+  s    <- sample "sigma" (Exponential 1)
+  forM_ (zip3 [0 :: Int ..] (zip xs gids) ys) $ \(i, (x, g), y) ->
+    observe (T.pack ("y_" ++ show i))
+      (Normal ((as !! g) * exp (negate b * realToFrac x)) s) [y]
+
+-- | M7 Poisson 回帰 (per-obs 手書き・log link)。
+--   ★旧コメント「非Gaussian観測ゆえ高速経路対象外」は古い情報 (Phase 89 で
+--   訂正): `VGPois` 族対応が後に追加され、現在は vecIR (a) 経路に乗る
+--   (`synthVecIR` = Just・`bench/posteriordb/glm-poisson` で実測確認)。
+m7Model :: [Double] -> [Double] -> ModelP ()
+m7Model xs ys = do
+  a <- sample "a" (Normal 0 5)
+  b <- sample "b" (Normal 0 5)
+  forM_ (zip3 [0 :: Int ..] xs ys) $ \(i, x, y) ->
+    observe (T.pack ("y_" ++ show i))
+      (Poisson (exp (a + b * realToFrac x))) [y]
+
+-- | M8 logistic 回帰 (per-obs 手書き・logit link)。 M7 と同じく fallback。
+m8Model :: [Double] -> [Double] -> ModelP ()
+m8Model xs ys = do
+  a <- sample "a" (Normal 0 5)
+  b <- sample "b" (Normal 0 5)
+  forM_ (zip3 [0 :: Int ..] xs ys) $ \(i, x, y) ->
+    observe (T.pack ("y_" ++ show i))
+      (Bernoulli (1 / (1 + exp (negate (a + b * realToFrac x))))) [y]
+
+-- | M9 NegBin 回帰 (per-obs 手書き・log link・α latent)。 Phase 56.5 で
+--   IR 吸収 (lgammaΓ(k+α) は SLgammaO elementwise・Γ(k+1) は compile 時定数)。
+m9Model :: [Double] -> [Double] -> ModelP ()
+m9Model xs ys = do
+  a  <- sample "a"     (Normal 0 5)
+  b  <- sample "b"     (Normal 0 5)
+  al <- sample "alpha" (Exponential 0.5)
+  forM_ (zip3 [0 :: Int ..] xs ys) $ \(i, x, y) ->
+    observe (T.pack ("y_" ++ show i))
+      (NegativeBinomial (exp (a + b * realToFrac x)) al) [y]
+
+-- | Radon 相関 varying intercept+slope (flagship・Phase 84)。 固定効果 =
+--   (Intercept)+floor+uranium、 ランダム効果 = county 群の相関 (切片+floor 傾き)。
+--   designHBMProgram の相関 RE branch (非中心化・LKJ・Phase 80.2b) に載る。
+--   Python 側 bench_radon と同一 prior・同一データ (radon.csv)。
+radonModel :: [[Double]] -> [Int] -> [Double] -> [Double] -> ModelP ()
+radonModel designX cidx floorCol ys =
+  designHBMProgram designX ["(Intercept)", "floor", "uranium"]
+                   [(cidx, nCounties, [floorCol])] ys
+  where nCounties = if null cidx then 0 else maximum cidx + 1
+
+-- | Eight Schools (精度エッジ・Phase 84)。 古典的階層正規・funnel の定番。
+--   非中心化パラメタ化: θ_j = μ + τ·θ̃_j (θ̃_j ~ N(0,1))・観測 SE σ_j は既知。
+--   μ~N(0,5)・τ~HalfCauchy(5)。 Python 側 bench_eightschools と同一 prior・データ。
+--   主役 = τ (funnel の首・サンプラ品質が出る)。
+eightSchoolsModel :: ModelP ()
+eightSchoolsModel = do
+  let ys     = [28, 8, -3, 7, -1, 1, 18, 12] :: [Double]
+      sigmas = [15, 10, 16, 11, 9, 11, 10, 18] :: [Double]
+  mu  <- sample "mu"  (Normal 0 5)
+  tau <- sample "tau" (HalfCauchy 5)
+  tts <- mapM (\j -> sample (T.pack ("theta_t_" ++ show j)) (Normal 0 1)) [0 .. 7]
+  forM_ (zip3 [0 :: Int ..] (zip ys sigmas) tts) $ \(i, (y, s), tt) ->
+    observe (T.pack ("y_" ++ show i)) (Normal (mu + tau * tt) (realToFrac s)) [y]
+
+-- ---------------------------------------------------------------------------
+-- NUTS 実行 (warmup 固定・iter 可変)
+-- ---------------------------------------------------------------------------
+
+mkConfig :: Int -> NUTSConfig
+mkConfig iters = defaultNUTSConfig
+  { nutsIterations    = iters
+  , nutsBurnIn        = warmupFixed
+  , nutsStepSize      = 0.1
+  , nutsMaxDepth      = 10
+  , nutsAdaptStepSize = True
+  , nutsTargetAccept  = 0.8
+  , nutsAdaptMass     = True
+  }
+
+acceptRate :: Chain -> Double
+acceptRate ch =
+  fromIntegral (chainAccepted ch) / max 1 (fromIntegral (chainTotal ch))
+
+probeChain :: [T.Text] -> Chain -> Double
+probeChain names ch =
+  sum [ maybe 0 id (posteriorMean p ch) | p <- names ]
+
+-- | 1 (モデル, iter) について timingReps 回計測 (index-seed で CSE 回避)、
+--   返り値の chain (seed 42) から ESS/posterior mean を取る。
+runBench
+  :: String              -- ^ モデル名タグ (M1_pooled / M2_ranint)
+  -> ModelP ()           -- ^ モデル (init は別途・seed は gen 側)
+  -> Map.Map T.Text Double  -- ^ init params
+  -> [T.Text]               -- ^ probe 用全パラメタ名
+  -> T.Text                 -- ^ 主役パラメタ (ESS 報告対象: slope)
+  -> Int                    -- ^ iter
+  -> IO BenchRow
+runBench = runBenchReps timingReps
+
+-- | 'runBench' の reps 可変版。 重いモデル (radon 等) は少ない reps で回す。
+runBenchReps
+  :: Int -> String -> ModelP () -> Map.Map T.Text Double
+  -> [T.Text] -> T.Text -> Int -> IO BenchRow
+runBenchReps reps tag mdl initP allNames keyParam iters = do
+  let cfg = mkConfig iters
+      run :: Int -> IO Chain
+      run i = do
+        g <- MWC.initialize (V.singleton (fromIntegral (42 + i)))
+        nuts mdl cfg initP g
+  (ms, ch) <- timeitIO reps (probeChain allNames) run
+  let keyEss   = ess (chainVals keyParam ch)
+      keyMean  = maybe 0 id (posteriorMean keyParam ch)
+      acc      = acceptRate ch
+      essPerSec = keyEss / max 1e-9 (ms / 1000.0)
+      name     = tag ++ "_iter" ++ show iters
+      -- Phase 85.3: per-draw tree depth の平均 (PyMC の tree_depth と直接比較)
+      depths   = chainTreeDepths ch
+      meanDepth = if null depths then 0
+                  else fromIntegral (sum depths)
+                       / fromIntegral (length depths) :: Double
+      extra    = printf ("iter=%d warmup=%d key=%s ess=%.1f ess_per_sec=%.2f "
+                         ++ "accept=%.3f tree_depth=%.2f time_ms=%.1f")
+                   iters warmupFixed (T.unpack keyParam)
+                   keyEss essPerSec acc meanDepth ms
+  return (BenchRow "haskell" "hbm_scaling" name ms keyMean keyEss extra)
+
+-- ---------------------------------------------------------------------------
+
+main :: IO ()
+main = do
+  args <- getArgs
+  case args of
+    ["m5-long"] -> do
+      (x5, y5) <- genM5
+      mainLong "M5_nonlin" (m5Model x5 y5)
+        (Map.fromList [("a", 2.5), ("b", 1.2), ("c", 0.5), ("sigma", 0.3)])
+        ["a", "b", "c", "sigma"] "m5"
+    ["m7-long"] -> do
+      (x7, y7) <- genM7
+      mainLong "M7_pois" (m7Model x7 y7)
+        (Map.fromList [("a", 0.5), ("b", 0.8)]) ["a", "b"] "m7"
+    ["m8-long"] -> do
+      (x8, y8) <- genM8
+      mainLong "M8_logit" (m8Model x8 y8)
+        (Map.fromList [("a", 0.3), ("b", 1.2)]) ["a", "b"] "m8"
+    ["m9-long"] -> do
+      (x9, y9) <- genM9
+      mainLong "M9_negbin" (m9Model x9 y9)
+        (Map.fromList [("a", 0.5), ("b", 0.8), ("alpha", 1.5)])
+        ["a", "b", "alpha"] "m9"
+    ["glm"]          -> mainGlm
+    ["radon"]        -> mainRadon
+    ["radon1600"]    -> mainRadon1600
+    ["eightschools"] -> mainEight
+    _                -> mainAll
+
+-- | Eight Schools を通常 grid で掃く (引数 @eightschools@)。 小モデルゆえ軽い。
+mainEight :: IO ()
+mainEight = do
+  let eightInit = Map.fromList [("mu", 4.0), ("tau", 3.0)]
+      allNames  = ["mu", "tau"]
+  rows <- mapM
+    (runBench "eightschools" eightSchoolsModel eightInit allNames "tau")
+    iterGrid
+  writeRows "bench/results/haskell/hbm_scaling_eightschools.csv" rows
+  putStrLn $ "wrote " ++ show (length rows)
+          ++ " rows → bench/results/haskell/hbm_scaling_eightschools.csv"
+
+-- | Radon (flagship) を通常 grid で掃く (引数 @radon@)。 別 CSV
+--   @hbm_scaling_radon.csv@ へ (M 系 CSV を上書きしない)。 相関 RE の
+--   正値制約 latent (sigma/tau/LKJ の Beta) だけ init を与え、 z/beta は 0 既定。
+-- radon は 919 obs・相関 RE で 1 サンプルあたり deep tree (~max depth 10) ゆえ
+-- 重い。 reps=2・短め grid で回す (Python 側 radonGrid と一致させる)。
+radonGrid :: [Int]
+radonGrid = [50, 100, 200, 400]
+
+radonReps :: Int
+radonReps = 2
+
+mainRadon :: IO ()
+mainRadon = do
+  (designX, cidx, floorCol, ys) <- readRadon
+  let radonInit = Map.fromList
+        [ ("(Intercept)", 1.3), ("floor", -0.6), ("uranium", 0.7)
+        , ("sigma", 0.7)
+        , ("tau_g0_0", 0.5), ("tau_g0_1", 0.3), ("Lcorr_g0_u1_0", 0.5) ]
+      allNames = ["(Intercept)", "floor", "uranium", "sigma"]
+  rows <- mapM
+    (\it -> do
+        r <- runBenchReps radonReps "radon"
+               (radonModel designX cidx floorCol ys)
+               radonInit allNames "floor" it
+        putStrLn $ "  radon iter=" ++ show it ++ ": "
+                 ++ show (round (brTimeMs r) :: Int) ++ " ms"
+        pure r)
+    radonGrid
+  writeRows "bench/results/haskell/hbm_scaling_radon.csv" rows
+  putStrLn $ "wrote " ++ show (length rows)
+          ++ " rows → bench/results/haskell/hbm_scaling_radon.csv"
+
+-- | Phase 88 追補: radon を実運用規模 iter=1600 単点で計測する (引数
+--   @radon1600@)。 iter400 は短めの grid で「有利なベンチ」になりうる
+--   (Phase 87 で iter400→1600 だけで hanalyze 対 PyMC-C 比が 0.80×→
+--   0.49-0.68× に動いた前例あり)。 別 CSV @hbm_scaling_radon1600.csv@ へ。
+mainRadon1600 :: IO ()
+mainRadon1600 = do
+  (designX, cidx, floorCol, ys) <- readRadon
+  let radonInit = Map.fromList
+        [ ("(Intercept)", 1.3), ("floor", -0.6), ("uranium", 0.7)
+        , ("sigma", 0.7)
+        , ("tau_g0_0", 0.5), ("tau_g0_1", 0.3), ("Lcorr_g0_u1_0", 0.5) ]
+      allNames = ["(Intercept)", "floor", "uranium", "sigma"]
+  r <- runBenchReps radonReps "radon"
+         (radonModel designX cidx floorCol ys)
+         radonInit allNames "floor" 1600
+  putStrLn $ "  radon iter=1600: " ++ show (round (brTimeMs r) :: Int) ++ " ms"
+  writeRows "bench/results/haskell/hbm_scaling_radon1600.csv" [r]
+  putStrLn "wrote 1 rows → bench/results/haskell/hbm_scaling_radon1600.csv"
+
+-- | 1 モデルだけを延長 grid で掃く (引数 @m5-long@/@m7-long@/@m8-long@)。
+--
+-- 通常 grid (50-1600) では PyMC 側 total が固定費 (compile+tune ~2s) に支配され
+-- per-draw 線形フィットが R² ~0.13 と不定のままだった (M5・54.11)。 iter を
+-- 25600 まで延ばし draw 部分を固定費より大きくして傾きを確定する (Python 側 =
+-- @bench_hbm_scaling.py <m>-long@・同 grid)。 結果は別 CSV
+-- (@hbm_scaling_<m>_long.csv@) へ (通常 bench の CSV は上書きしない)。
+iterGridLong :: [Int]
+iterGridLong = [400, 800, 1600, 3200, 6400, 12800, 25600]
+
+mainLong
+  :: String -> ModelP () -> Map.Map T.Text Double -> [T.Text] -> String -> IO ()
+mainLong tag mdl initP names short = do
+  rows <- mapM (runBench tag mdl initP names "b") iterGridLong
+  let out = "bench/results/haskell/hbm_scaling_" ++ short ++ "_long.csv"
+  writeRows out rows
+  putStrLn $ "wrote " ++ show (length rows) ++ " rows → " ++ out
+
+-- | M7-M9 (GLM 系) だけ通常 grid で掃く (引数 @glm@・Phase 55.1 baseline 用、
+--   M9 は Phase 56.6 追加)。 M1-M6 の既存 CSV を上書きしないよう別 CSV へ。
+mainGlm :: IO ()
+mainGlm = do
+  (x7, y7) <- genM7
+  (x8, y8) <- genM8
+  (x9, y9) <- genM9
+  m7Rows <- mapM
+    (runBench "M7_pois" (m7Model x7 y7)
+       (Map.fromList [("a", 0.5), ("b", 0.8)]) ["a", "b"] "b")
+    iterGrid
+  m8Rows <- mapM
+    (runBench "M8_logit" (m8Model x8 y8)
+       (Map.fromList [("a", 0.3), ("b", 1.2)]) ["a", "b"] "b")
+    iterGrid
+  m9Rows <- mapM
+    (runBench "M9_negbin" (m9Model x9 y9)
+       (Map.fromList [("a", 0.5), ("b", 0.8), ("alpha", 1.5)])
+       ["a", "b", "alpha"] "b")
+    iterGrid
+  let rows = m7Rows ++ m8Rows ++ m9Rows
+  writeRows "bench/results/haskell/hbm_scaling_glm.csv" rows
+  putStrLn $ "wrote " ++ show (length rows)
+          ++ " rows → bench/results/haskell/hbm_scaling_glm.csv"
+
+mainAll :: IO ()
+mainAll = do
+  (x1, y1)          <- genM1
+  (xRows, gids, y2) <- genM2
+  (x3, g3, y3)      <- genM3
+  (xR4, y4)         <- genM4
+  (x5, y5)          <- genM5
+  (x6, g6, y6)      <- genM6
+  (x7, y7)          <- genM7
+  (x8, y8)          <- genM8
+
+  let groupNames pre = [ T.pack (pre ++ show j) | j <- [0 .. nGroupsM2 - 1] ]
+      m1Init = Map.fromList [("a", 2.0), ("b", 1.5), ("sigma", 1.0)]
+      m2Init = Map.fromList $
+        [ ("beta_0", 1.0), ("beta_1", 0.8), ("tau_u", 1.5), ("sigma", 1.0) ]
+        ++ [ (u, 0.0) | u <- groupNames "u_" ]
+      m3Init = Map.fromList $
+        [ ("beta_0", 1.0), ("beta_1", 0.8), ("tau_u", 1.0), ("tau_v", 0.5)
+        , ("sigma", 1.0) ]
+        ++ [ (u, 0.0) | u <- groupNames "u_" ++ groupNames "v_" ]
+      m4Init = Map.fromList $
+        [ (T.pack ("beta_" ++ show k), 0.0) | k <- [0 .. pM4] ]
+        ++ [("sigma", 1.0)]
+      m5Init = Map.fromList
+        [("a", 2.5), ("b", 1.2), ("c", 0.5), ("sigma", 0.3)]
+      m6Init = Map.fromList $
+        [ ("mu_a", 2.0), ("tau_a", 0.5), ("b", 1.0), ("sigma", 0.3) ]
+        ++ [ (a, 2.0) | a <- groupNames "a_" ]
+      m1AllNames = ["a", "b", "sigma"]
+      m2AllNames = ["beta_0", "beta_1", "tau_u", "sigma"] ++ groupNames "u_"
+      m3AllNames = ["beta_0", "beta_1", "tau_u", "tau_v", "sigma"]
+                   ++ groupNames "u_" ++ groupNames "v_"
+      m4AllNames = [ T.pack ("beta_" ++ show k) | k <- [0 .. pM4] ] ++ ["sigma"]
+      m5AllNames = ["a", "b", "c", "sigma"]
+      m6AllNames = ["mu_a", "tau_a", "b", "sigma"] ++ groupNames "a_"
+
+  m1Rows <- mapM
+    (runBench "M1_pooled" (m1Model x1 y1) m1Init m1AllNames "b")
+    iterGrid
+  m2Rows <- mapM
+    (runBench "M2_ranint" (m2Model xRows gids y2) m2Init m2AllNames "beta_1")
+    iterGrid
+  m3Rows <- mapM
+    (runBench "M3_ranslope" (m3Model x3 g3 y3) m3Init m3AllNames "beta_1")
+    iterGrid
+  m4Rows <- mapM
+    (runBench "M4_multix" (m4Model xR4 y4) m4Init m4AllNames "beta_1")
+    iterGrid
+  m5Rows <- mapM
+    (runBench "M5_nonlin" (m5Model x5 y5) m5Init m5AllNames "b")
+    iterGrid
+  m6Rows <- mapM
+    (runBench "M6_hier_nonlin" (m6Model x6 g6 y6) m6Init m6AllNames "b")
+    iterGrid
+  m7Rows <- mapM
+    (runBench "M7_pois" (m7Model x7 y7)
+       (Map.fromList [("a", 0.5), ("b", 0.8)]) ["a", "b"] "b")
+    iterGrid
+  m8Rows <- mapM
+    (runBench "M8_logit" (m8Model x8 y8)
+       (Map.fromList [("a", 0.3), ("b", 1.2)]) ["a", "b"] "b")
+    iterGrid
+
+  let rows = m1Rows ++ m2Rows ++ m3Rows ++ m4Rows ++ m5Rows ++ m6Rows
+          ++ m7Rows ++ m8Rows
+  writeRows "bench/results/haskell/hbm_scaling.csv" rows
+  putStrLn $ "wrote " ++ show (length rows)
+          ++ " rows → bench/results/haskell/hbm_scaling.csv"
diff --git a/bench/haskell/BenchHBMVecADSpike.hs b/bench/haskell/BenchHBMVecADSpike.hs
new file mode 100644
--- /dev/null
+++ b/bench/haskell/BenchHBMVecADSpike.hs
@@ -0,0 +1,560 @@
+{-# LANGUAGE BangPatterns #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RankNTypes #-}
+{-# OPTIONS_GHC -fno-full-laziness -fno-cse #-}
+-- GHC 9.6.7 の exitification パスが本モジュールで simplifier panic (completeCall)
+-- を起こすため無効化。 4 手法とも同一フラグでコンパイルされるので比較は不変。
+{-# OPTIONS_GHC -fno-exitification #-}
+-- | Phase 54 専用ベクトル化 AD feasibility spike (計測先行・推測するな計測せよ)。
+--
+-- 「numpyro に階層モデルで追いつくには `ad` のボックススカラ tape を、 Storable
+-- 配列上の tape-free なベクトル化 reverse-mode に置換する必要がある」 という
+-- 仮説を、 **本実装の前に**実測で検証する小実験。
+--
+-- 対象 = 階層 Gaussian (random intercept GLMM、 BenchHBMADModes の M2 と同型):
+--   η_i = Σ_k β_k X_ik + u_{g(i)},  y_i ~ Normal(η_i, σ)
+--   prior: β_k~N(0,5)、 τ_u~HalfNormal(5)、 u_j~N(0,τ_u)、 σ~Exp(1)
+--   unconstrained: β/u は identity、 τ_u/σ は log 変換 (PositiveT、 jacobian +u)。
+--
+-- 勾配を 2 通りで計算し per-grad 時間を比較:
+--   (a) `Numeric.AD.Mode.Reverse.Double.grad` — 現状の方式 (スカラ tape)。
+--   (b) 手書きベクトル化解析勾配 — Storable/Unboxed 配列上の reduction のみ。
+--       これは tape を一切作らない = ベクトル化 reverse-mode AD の **時間の下限**
+--       (汎用エンジンはこれより遅いが、 ここが (a) を桁で上回らなければ専用 AD を
+--        作っても勝てない、 という feasibility の天井判定に使う)。
+--
+-- (b) の正しさは中心差分 (同じ logp) で検証してから時間を測る。
+module Main where
+
+import           Control.Monad                  (forM_, when)
+import           Control.Monad.ST               (ST, runST)
+import           Data.Array.ST                  (STArray, newArray, readArray,
+                                                 writeArray)
+import           Data.List                      (foldl')
+import           Data.STRef                     (STRef, modifySTRef', newSTRef,
+                                                 readSTRef, writeSTRef)
+import qualified Data.Vector.Storable           as VS
+import qualified Data.Vector.Unboxed            as VU
+import           Text.Printf                    (printf)
+
+import qualified Numeric.AD.Mode.Reverse.Double as RevD
+
+import           Numeric.Backprop               (BVar, Reifies, W, auto, gradBP,
+                                                 liftOp1, liftOp2, op1, op2)
+
+import           BenchUtil                      (timeitIO)
+
+-- ---------------------------------------------------------------------------
+-- 問題サイズと合成データ
+-- ---------------------------------------------------------------------------
+
+data Prob = Prob
+  { pP      :: !Int          -- 固定効果数
+  , pNG     :: !Int          -- 群数
+  , pXRows  :: ![[Double]]   -- design X (n × p)
+  , pGids   :: ![Int]        -- group id (length n)
+  , pYs     :: ![Double]     -- 観測 (length n)
+  }
+
+-- BenchHBMADModes.genM2Data と同型の決定的データ。
+genProb :: Int -> Int -> Prob
+genProb nG perG =
+  let n    = nG * perG
+      xz   = [ sin (0.7 * fromIntegral i) | i <- [0 .. n - 1] ]
+      ez   = [ 0.3 * cos (1.3 * fromIntegral i) | i <- [0 .. n - 1] ]
+      uz   = [ 0.9 * sin (2.1 * fromIntegral j) | j <- [0 .. nG - 1] ]
+      gids = [ i `div` perG | i <- [0 .. n - 1] ]
+      xs   = map (* 2.0) xz
+      ys   = [ 1.0 + 0.8 * x + (uz !! g) + e
+             | (x, g, e) <- zip3 xs gids ez ]
+      xRows = [ [1.0, x] | x <- xs ]
+  in Prob { pP = 2, pNG = nG, pXRows = xRows, pGids = gids, pYs = ys }
+
+-- パラメタ θ のレイアウト: [β_0..β_{p-1}, logτ, u_0..u_{nG-1}, logσ]
+paramLen :: Prob -> Int
+paramLen pr = pP pr + 1 + pNG pr + 1
+
+-- 真値近傍の初期 θ。
+theta0 :: Prob -> [Double]
+theta0 pr =
+  let p  = pP pr; nG = pNG pr
+  in replicate p 0.5 ++ [log 1.2] ++ replicate nG 0.1 ++ [log 0.35]
+
+-- ---------------------------------------------------------------------------
+-- (a) 多相 logp (ad で grad する対象)
+-- ---------------------------------------------------------------------------
+
+logN :: Floating a => a -> a -> a -> a
+logN x m s = -0.5 * log (2 * pi) - log s - 0.5 * ((x - m) / s) ^ (2 :: Int)
+
+logHalfNormal :: Floating a => a -> a -> a
+logHalfNormal x s = 0.5 * log (2 / pi) - log s - 0.5 * (x / s) ^ (2 :: Int)
+
+logp :: forall a. Floating a => Prob -> [a] -> a
+logp pr theta =
+  let p   = pP pr; nG = pNG pr
+      b   = take p theta
+      logTau = theta !! p
+      us  = take nG (drop (p + 1) theta)
+      logSig = theta !! (p + 1 + nG)
+      tau = exp logTau
+      sig = exp logSig
+      priorB   = sum [ logN bk 0 5 | bk <- b ]
+      priorTau = logHalfNormal tau 5 + logTau          -- + jacobian (log 変換)
+      priorU   = sum [ logN uj 0 tau | uj <- us ]
+      priorSig = negate sig + logSig                   -- logExp(σ;1)=-σ + jacobian
+      etas = [ sum (zipWith (\bk x -> bk * realToFrac x) b xr) + (us !! g)
+             | (xr, g) <- zip (pXRows pr) (pGids pr) ]
+      loglik = sum [ logN (realToFrac y) eta sig | (eta, y) <- zip etas (pYs pr) ]
+  in priorB + priorTau + priorU + priorSig + loglik
+
+gradAD :: Prob -> [Double] -> [Double]
+gradAD pr = RevD.grad (logp pr)
+
+-- ---------------------------------------------------------------------------
+-- (b) 手書きベクトル化解析勾配 (Storable/Unboxed 配列・tape なし)
+-- ---------------------------------------------------------------------------
+
+-- 事前計算: 列ごとの X (length n の VS が p 本)、 group id (VU)。
+data Compiled = Compiled
+  { cP     :: !Int
+  , cNG    :: !Int
+  , cN     :: !Int
+  , cXCols :: ![VS.Vector Double]  -- p 本、 各 length n
+  , cGids  :: !(VU.Vector Int)
+  , cYs    :: !(VS.Vector Double)
+  }
+
+compile :: Prob -> Compiled
+compile pr =
+  let p = pP pr; nG = pNG pr; n = length (pYs pr)
+      xcols = [ VS.fromList [ row !! k | row <- pXRows pr ] | k <- [0 .. p - 1] ]
+  in Compiled p nG n xcols (VU.fromList (pGids pr)) (VS.fromList (pYs pr))
+
+gradVec :: Compiled -> [Double] -> [Double]
+gradVec c theta =
+  let p = cP c; nG = cNG c; n = cN c
+      b   = take p theta
+      logTau = theta !! p
+      us  = take nG (drop (p + 1) theta)
+      logSig = theta !! (p + 1 + nG)
+      tau = exp logTau
+      sig = exp logSig
+      uv  = VS.fromList us
+      -- η = Xβ + u[g]  (length n、 VS)
+      xb  = foldl' (\acc (col, bk) -> VS.zipWith (+) acc (VS.map (* bk) col))
+                   (VS.replicate n 0) (zip (cXCols c) b)
+      ug  = VS.generate n (\i -> uv VS.! (cGids c VU.! i))
+      eta = VS.zipWith (+) xb ug
+      r   = VS.zipWith (-) (cYs c) eta            -- 残差 y - η
+      sig2 = sig * sig
+      -- ∂/∂β_k = -β_k/25 + (1/σ²) Σ_i r_i X_ik
+      gB  = [ negate bk / 25 + VS.sum (VS.zipWith (*) col r) / sig2
+            | (col, bk) <- zip (cXCols c) b ]
+      -- ∂/∂u_j = -u_j/τ² + (1/σ²) Σ_{i:g_i=j} r_i  (scatter-add で O(n))
+      rGroup = VU.accumulate (+) (VU.replicate nG 0)
+                 (VU.zip (cGids c) (VU.convert r :: VU.Vector Double))
+      gU  = [ negate (us !! j) / (tau * tau) + (rGroup VU.! j) / sig2
+            | j <- [0 .. nG - 1] ]
+      sumU2 = VS.sum (VS.map (\x -> x * x) uv)
+      sumR2 = VS.sum (VS.map (\x -> x * x) r)
+      -- logHalfNormal(τ;5) の scale は定数 5 ゆえ τ 由来は -τ²/25 のみ。
+      -- + log 変換 jacobian (+1) + Σ_j logN(u_j;0,τ) の -nG + Σu²/τ²。
+      gLogTau = 1 - fromIntegral nG - (tau * tau) / 25 + sumU2 / (tau * tau)
+      gLogSig = negate sig + 1 - fromIntegral n + sumR2 / sig2
+  in gB ++ [gLogTau] ++ gU ++ [gLogSig]
+
+-- ---------------------------------------------------------------------------
+-- (案A) backprop ライブラリによる汎用ベクトル化 reverse-mode AD
+--
+-- theta を 1 本の Storable Vector とみなし backprop で grad する。 ベクトル演算
+-- (scale/add/sub/gather/dot/sum) は liftOp で随伴を手書きするので tape は
+-- 「ベクトル演算 1 個 = 1 ノード」 になる (= 狙い)。 chain rule と tape の所有は
+-- backprop が担う。 スカラ演算 (exp/log/+/*) は BVar の Num/Floating で書ける。
+-- ※随伴は (案B) と共有 → 案A/案B の差は「tape をライブラリが持つか自前か」に純化。
+-- ---------------------------------------------------------------------------
+
+-- 全長 L の theta から要素 i を取り出す。 随伴は e_i*dy (長さ L)。
+idxV :: Reifies s W => Int -> Int -> BVar s (VS.Vector Double) -> BVar s Double
+idxV l i = liftOp1 $ op1 $ \v ->
+  (v VS.! i, \dy -> VS.generate l (\j -> if j == i then dy else 0))
+
+-- 全長 L の theta から [off, off+len) を切り出す。 随伴は zeros L に散布。
+sliceV :: Reifies s W
+       => Int -> Int -> Int -> BVar s (VS.Vector Double) -> BVar s (VS.Vector Double)
+sliceV l off len = liftOp1 $ op1 $ \v ->
+  ( VS.slice off len v
+  , \dy -> VS.generate l (\j -> if j >= off && j < off + len then dy VS.! (j - off) else 0) )
+
+-- scalar * vector。 ∂scalar = dy·v、 ∂v = scalar*dy。
+scaleV :: Reifies s W => BVar s Double -> BVar s (VS.Vector Double) -> BVar s (VS.Vector Double)
+scaleV = liftOp2 $ op2 $ \k v ->
+  (VS.map (* k) v, \dy -> (VS.sum (VS.zipWith (*) dy v), VS.map (* k) dy))
+
+vaddV :: Reifies s W => BVar s (VS.Vector Double) -> BVar s (VS.Vector Double) -> BVar s (VS.Vector Double)
+vaddV = liftOp2 $ op2 $ \a b -> (VS.zipWith (+) a b, \dy -> (dy, dy))
+
+vsubV :: Reifies s W => BVar s (VS.Vector Double) -> BVar s (VS.Vector Double) -> BVar s (VS.Vector Double)
+vsubV = liftOp2 $ op2 $ \a b -> (VS.zipWith (-) a b, \dy -> (dy, VS.map negate dy))
+
+-- 内積。 ∂a = dy*b、 ∂b = dy*a (a·a なら勾配は 2a を backprop の和算で得る)。
+dotV :: Reifies s W => BVar s (VS.Vector Double) -> BVar s (VS.Vector Double) -> BVar s Double
+dotV = liftOp2 $ op2 $ \a b ->
+  (VS.sum (VS.zipWith (*) a b), \dy -> (VS.map (* dy) b, VS.map (* dy) a))
+
+-- u[gids] gather (gids/nG は定数)。 随伴は scatter-add。
+gatherV :: Reifies s W => VU.Vector Int -> Int -> BVar s (VS.Vector Double) -> BVar s (VS.Vector Double)
+gatherV gids nG = liftOp1 $ op1 $ \u ->
+  ( VS.generate (VU.length gids) (\i -> u VS.! (gids VU.! i))
+  , \dy -> VS.convert $
+      VU.accumulate (+) (VU.replicate nG 0)
+        (VU.zip gids (VU.convert dy :: VU.Vector Double)) )
+
+logpBP :: forall s. Reifies s W => Compiled -> BVar s (VS.Vector Double) -> BVar s Double
+logpBP c theta =
+  let p = cP c; nG = cNG c; n = cN c
+      l = p + 1 + nG + 1
+      bVec   = sliceV l 0 p theta
+      logTau = idxV l p theta
+      uVec   = sliceV l (p + 1) nG theta
+      logSig = idxV l (p + 1 + nG) theta
+      tau = exp logTau
+      sig = exp logSig
+      -- Xβ = Σ_k β_k * col_k  (β_k は bVec の第 k 要素)
+      colC k = auto (cXCols c !! k)
+      xb = foldl' (\acc k -> vaddV acc (scaleV (idxV p k bVec) (colC k)))
+                  (auto (VS.replicate n 0)) [0 .. p - 1]
+      ug  = gatherV (cGids c) nG uVec
+      eta = vaddV xb ug
+      r   = vsubV (auto (cYs c)) eta
+      nD  = fromIntegral n
+      pD  = fromIntegral p
+      ngD = fromIntegral nG
+      sumB2 = dotV bVec bVec
+      sumU2 = dotV uVec uVec
+      sumR2 = dotV r r
+      priorB   = negate (0.5 * pD * log (2 * pi)) - pD * log 5 - sumB2 / (2 * 25)
+      priorTau = 0.5 * log (2 / pi) - log 5 - tau * tau / (2 * 25) + logTau
+      priorU   = negate (0.5 * ngD * log (2 * pi)) - ngD * log tau - sumU2 / (2 * tau * tau)
+      priorSig = negate sig + logSig
+      loglik   = negate (0.5 * nD * log (2 * pi)) - nD * log sig - sumR2 / (2 * sig * sig)
+  in priorB + priorTau + priorU + priorSig + loglik
+
+gradBackprop :: Compiled -> [Double] -> [Double]
+gradBackprop c theta = VS.toList $ gradBP (logpBP c) (VS.fromList theta)
+
+-- ---------------------------------------------------------------------------
+-- (案B) 自作・最小 reverse-mode AD (vector-op tape)
+--
+-- forward で「ベクトル演算ごとにノードを発番」 し、 各ノードの随伴更新クロージャを
+-- 逆順リストに積む (= 自前 Wengert tape)。 backward で出力に 1 を seed し、 逆位相順
+-- (= 発番の逆順 = prepend したリストの先頭から) にクロージャを replay して入力 (theta
+-- leaf) の随伴を得る。 随伴の式は案A の liftOp と同一 → 差は「tape 所有が自前か否か」。
+-- スカラは長さ 1 の VS で随伴を持ち、 ノード随伴は単一の mutable 配列に統一格納する。
+-- ---------------------------------------------------------------------------
+
+-- reverse-mode の値ハンドル: ノード id + primal (scalar / vector)。
+data Rval = RScal !Int !Double | RVec !Int !(VS.Vector Double)
+
+ridOf :: Rval -> Int
+ridOf (RScal i _) = i
+ridOf (RVec  i _) = i
+
+type Adj s = STArray s Int (VS.Vector Double)
+
+-- 発番カウンタ + backward クロージャ列 (prepend = 発番の逆順)。
+data Ctx s = Ctx !(STRef s Int) !(STRef s [Adj s -> ST s ()])
+
+fresh :: Ctx s -> ST s Int
+fresh (Ctx cnt _) = do
+  n <- readSTRef cnt
+  writeSTRef cnt (n + 1)
+  pure n
+
+record :: Ctx s -> (Adj s -> ST s ()) -> ST s ()
+record (Ctx _ bw) f = modifySTRef' bw (f :)
+
+-- 随伴の加算 (空 = ゼロ扱い)。
+bumpA :: Adj s -> Int -> VS.Vector Double -> ST s ()
+bumpA adj i contrib = do
+  cur <- readArray adj i
+  writeArray adj i (if VS.null cur then contrib else VS.zipWith (+) cur contrib)
+
+readAdjS :: Adj s -> Int -> ST s Double
+readAdjS adj i = do
+  v <- readArray adj i
+  pure (if VS.null v then 0 else v VS.! 0)
+
+-- leaf (theta)。 backward 無し・勾配は最終的にこの随伴を読む。
+inputVec :: Ctx s -> VS.Vector Double -> ST s Rval
+inputVec ctx v = do
+  i <- fresh ctx
+  pure (RVec i v)
+
+-- 全長 l の vec から要素 i を取り出す (scalar 化)。
+idxHR :: Ctx s -> Int -> Int -> Rval -> ST s Rval
+idxHR ctx l i (RVec vid v) = do
+  o <- fresh ctx
+  record ctx $ \adj -> do
+    g <- readAdjS adj o
+    when (g /= 0) $ bumpA adj vid (VS.generate l (\j -> if j == i then g else 0))
+  pure (RScal o (v VS.! i))
+idxHR _ _ _ _ = error "idxHR: scalar input"
+
+-- 全長 l の vec から [off, off+len) を切り出す。
+sliceHR :: Ctx s -> Int -> Int -> Int -> Rval -> ST s Rval
+sliceHR ctx l off len (RVec vid v) = do
+  o <- fresh ctx
+  record ctx $ \adj -> do
+    dy <- readArray adj o
+    when (not (VS.null dy)) $
+      bumpA adj vid (VS.generate l (\j -> if j >= off && j < off + len then dy VS.! (j - off) else 0))
+  pure (RVec o (VS.slice off len v))
+sliceHR _ _ _ _ _ = error "sliceHR: scalar input"
+
+-- scalar * vector。
+scaleHR :: Ctx s -> Rval -> Rval -> ST s Rval
+scaleHR ctx (RScal kid k) (RVec vid v) = do
+  o <- fresh ctx
+  record ctx $ \adj -> do
+    dy <- readArray adj o
+    when (not (VS.null dy)) $ do
+      bumpA adj kid (VS.singleton (VS.sum (VS.zipWith (*) dy v)))
+      bumpA adj vid (VS.map (* k) dy)
+  pure (RVec o (VS.map (* k) v))
+scaleHR _ _ _ = error "scaleHR: shape"
+
+vaddHR :: Ctx s -> Rval -> Rval -> ST s Rval
+vaddHR ctx (RVec aid a) (RVec bid b) = do
+  o <- fresh ctx
+  record ctx $ \adj -> do
+    dy <- readArray adj o
+    when (not (VS.null dy)) $ do
+      bumpA adj aid dy
+      bumpA adj bid dy
+  pure (RVec o (VS.zipWith (+) a b))
+vaddHR _ _ _ = error "vaddHR: shape"
+
+vsubHR :: Ctx s -> Rval -> Rval -> ST s Rval
+vsubHR ctx (RVec aid a) (RVec bid b) = do
+  o <- fresh ctx
+  record ctx $ \adj -> do
+    dy <- readArray adj o
+    when (not (VS.null dy)) $ do
+      bumpA adj aid dy
+      bumpA adj bid (VS.map negate dy)
+  pure (RVec o (VS.zipWith (-) a b))
+vsubHR _ _ _ = error "vsubHR: shape"
+
+dotHR :: Ctx s -> Rval -> Rval -> ST s Rval
+dotHR ctx (RVec aid a) (RVec bid b) = do
+  o <- fresh ctx
+  record ctx $ \adj -> do
+    g <- readAdjS adj o
+    when (g /= 0) $ do
+      bumpA adj aid (VS.map (* g) b)
+      bumpA adj bid (VS.map (* g) a)
+  pure (RScal o (VS.sum (VS.zipWith (*) a b)))
+dotHR _ _ _ = error "dotHR: shape"
+
+-- u[gids] gather (gids/nG 定数)。
+gatherHR :: Ctx s -> VU.Vector Int -> Int -> Rval -> ST s Rval
+gatherHR ctx gids nG (RVec uid u) = do
+  let n = VU.length gids
+  o <- fresh ctx
+  record ctx $ \adj -> do
+    dy <- readArray adj o
+    when (not (VS.null dy)) $
+      bumpA adj uid (VS.convert $
+        VU.accumulate (+) (VU.replicate nG 0) (VU.zip gids (VU.convert dy :: VU.Vector Double)))
+  pure (RVec o (VS.generate n (\i -> u VS.! (gids VU.! i))))
+gatherHR _ _ _ _ = error "gatherHR: shape"
+
+-- scalar 演算群。
+cstS :: Ctx s -> Double -> ST s Rval
+cstS ctx x = do { i <- fresh ctx; pure (RScal i x) }
+
+binS :: Ctx s -> (Double -> Double -> Double) -> (Double -> Double -> (Double, Double))
+     -> Rval -> Rval -> ST s Rval
+binS ctx f df (RScal aid a) (RScal bid b) = do
+  o <- fresh ctx
+  record ctx $ \adj -> do
+    g <- readAdjS adj o
+    when (g /= 0) $ do
+      let (da, db) = df a b
+      bumpA adj aid (VS.singleton (g * da))
+      bumpA adj bid (VS.singleton (g * db))
+  pure (RScal o (f a b))
+binS _ _ _ _ _ = error "binS: scalar expected"
+
+addS, mulS, subS :: Ctx s -> Rval -> Rval -> ST s Rval
+addS ctx = binS ctx (+) (\_ _ -> (1, 1))
+subS ctx = binS ctx (-) (\_ _ -> (1, -1))
+mulS ctx = binS ctx (*) (\a b -> (b, a))
+
+unS :: Ctx s -> (Double -> Double) -> (Double -> Double) -> Rval -> ST s Rval
+unS ctx f df (RScal aid a) = do
+  o <- fresh ctx
+  record ctx $ \adj -> do
+    g <- readAdjS adj o
+    when (g /= 0) $ bumpA adj aid (VS.singleton (g * df a))
+  pure (RScal o (f a))
+unS _ _ _ _ = error "unS: scalar expected"
+
+expS, logS :: Ctx s -> Rval -> ST s Rval
+expS ctx = unS ctx exp exp
+logS ctx = unS ctx log (\a -> 1 / a)
+
+mulConstS, addConstS :: Ctx s -> Double -> Rval -> ST s Rval
+mulConstS ctx c = unS ctx (* c) (const c)
+addConstS ctx c = unS ctx (+ c) (const 1)
+
+-- 案B logp (logpBP と同じ式を B エンジンで構築)。 戻りは出力ノード。
+logpHR :: Ctx s -> Compiled -> Rval -> ST s Rval
+logpHR ctx c theta = do
+  let p = cP c; nG = cNG c; n = cN c
+      l = p + 1 + nG + 1
+  bVec   <- sliceHR ctx l 0 p theta
+  logTau <- idxHR ctx l p theta
+  uVec   <- sliceHR ctx l (p + 1) nG theta
+  logSig <- idxHR ctx l (p + 1 + nG) theta
+  tau <- expS ctx logTau
+  sig <- expS ctx logSig
+  -- Xβ = Σ_k β_k * col_k
+  xb <- do
+    cols <- mapM (\k -> do
+                    bk  <- idxHR ctx p k bVec
+                    col <- constVecM ctx (cXCols c !! k)
+                    scaleHR ctx bk col) [0 .. p - 1]
+    foldM1 (vaddHR ctx) cols
+  ug  <- gatherHR ctx (cGids c) nG uVec
+  eta <- vaddHR ctx xb ug
+  yC  <- constVecM ctx (cYs c)
+  r   <- vsubHR ctx yC eta
+  sumB2 <- dotHR ctx bVec bVec
+  sumU2 <- dotHR ctx uVec uVec
+  sumR2 <- dotHR ctx r r
+  let nD = fromIntegral n; pD = fromIntegral p; ngD = fromIntegral nG
+  -- priorB = constB - sumB2/50
+  priorB <- do { t <- mulConstS ctx (-1 / (2 * 25)) sumB2
+               ; addConstS ctx (negate (0.5 * pD * log (2 * pi)) - pD * log 5) t }
+  -- priorTau = constT - tau²/50 + logTau
+  priorTau <- do
+    tau2  <- mulS ctx tau tau
+    t1    <- mulConstS ctx (-1 / (2 * 25)) tau2
+    t2    <- addS ctx t1 logTau
+    addConstS ctx (0.5 * log (2 / pi) - log 5) t2
+  -- priorU = constU - nG*log tau - sumU2/(2τ²)
+  priorU <- do
+    lt    <- logS ctx tau
+    a1    <- mulConstS ctx (negate ngD) lt
+    tau2  <- mulS ctx tau tau
+    inv   <- mulConstS ctx (-0.5) =<< divByS ctx sumU2 tau2
+    s     <- addS ctx a1 inv
+    addConstS ctx (negate (0.5 * ngD * log (2 * pi))) s
+  -- priorSig = -sig + logSig
+  priorSig <- do { ns <- mulConstS ctx (-1) sig; addS ctx ns logSig }
+  -- loglik = constL - n*log sig - sumR2/(2σ²)
+  loglik <- do
+    ls    <- logS ctx sig
+    a1    <- mulConstS ctx (negate nD) ls
+    sig2  <- mulS ctx sig sig
+    inv   <- mulConstS ctx (-0.5) =<< divByS ctx sumR2 sig2
+    s     <- addS ctx a1 inv
+    addConstS ctx (negate (0.5 * nD * log (2 * pi))) s
+  -- 総和
+  s1 <- addS ctx priorB priorTau
+  s2 <- addS ctx s1 priorU
+  s3 <- addS ctx s2 priorSig
+  addS ctx s3 loglik
+  where
+    foldM1 _ []       = error "foldM1: empty"
+    foldM1 _ [x]      = pure x
+    foldM1 g (x:y:xs) = g x y >>= \z -> foldM1 g (z : xs)
+
+-- 定数ベクトルノード (backward 無し)。
+constVecM :: Ctx s -> VS.Vector Double -> ST s Rval
+constVecM ctx v = do { i <- fresh ctx; pure (RVec i v) }
+
+-- scalar 除算 (a/b)。
+divByS :: Ctx s -> Rval -> Rval -> ST s Rval
+divByS ctx = binS ctx (/) (\a b -> (1 / b, negate a / (b * b)))
+
+gradHandroll :: Compiled -> [Double] -> [Double]
+gradHandroll c theta = runST $ do
+  cnt <- newSTRef 0
+  bw  <- newSTRef []
+  let ctx = Ctx cnt bw
+  th  <- inputVec ctx (VS.fromList theta)
+  out <- logpHR ctx c th
+  n   <- readSTRef cnt
+  adj <- newArray (0, n - 1) VS.empty
+  writeArray adj (ridOf out) (VS.singleton 1)
+  closures <- readSTRef bw
+  mapM_ ($ adj) closures
+  g <- readArray adj (ridOf th)
+  pure (if VS.null g then replicate (length theta) 0 else VS.toList g)
+
+-- ---------------------------------------------------------------------------
+-- 中心差分 (検証用)
+-- ---------------------------------------------------------------------------
+
+centralDiff :: ([Double] -> Double) -> [Double] -> [Double]
+centralDiff f ps =
+  [ let h = 1e-6 * (abs (ps !! j) + 1e-3)
+    in (f (bump j h) - f (bump j (-h))) / (2 * h)
+  | j <- [0 .. length ps - 1] ]
+  where bump j d = [ if k == j then p + d else p | (k, p) <- zip [0 ..] ps ]
+
+relErr :: [Double] -> [Double] -> Double
+relErr a b = maximum [ abs (x - y) / (abs y + 1e-6) | (x, y) <- zip a b ]
+
+-- ---------------------------------------------------------------------------
+-- main
+-- ---------------------------------------------------------------------------
+
+main :: IO ()
+main = do
+  putStrLn "=== Phase 54 専用ベクトル化 AD feasibility spike ===\n"
+  putStrLn "対象: 階層 Gaussian (random intercept、 M2 同型)。 obs/群=12。"
+  putStrLn "(a) ad Reverse.Double.grad  vs  (b) 手書きベクトル化解析勾配 (tape なし)\n"
+
+  putStrLn "--- (検証) 各勾配 vs 中心差分 (rel err) ---"
+  forM_ [2, 8, 32] $ \nG -> do
+    let pr  = genProb nG 12
+        c   = compile pr
+        t0  = theta0 pr
+        cd  = centralDiff (logp pr) t0
+        eV  = relErr (gradVec c t0) cd
+        eBP = relErr (gradBackprop c t0) cd
+        eHR = relErr (gradHandroll c t0) cd
+    printf "nG=%-3d p=%-3d | vec=%.3e | backprop=%.3e | handroll=%.3e\n"
+      nG (paramLen pr) eV eBP eHR
+
+  putStrLn "\n--- (デバッグ) nG=2 の成分比較 [央差 / ad / vec] ---"
+  let prD = genProb 2 12
+      cD  = compile prD
+      t0D = theta0 prD
+      cd  = centralDiff (logp prD) t0D
+      ga  = gradAD prD t0D
+      gv  = gradVec cD t0D
+  forM_ (zip3 [0 :: Int ..] (zip3 cd ga gv) (theta0 prD)) $ \(j, (a, b, v), _) ->
+    printf "  θ%-2d | cd=%10.4f | ad=%10.4f | vec=%10.4f\n" j a b v
+
+  putStrLn "\n--- per-grad 時間 (ms・median of 50) ---"
+  putStrLn "ad=現行スカラtape / vec=解析勾配(下限) / bp=backprop(案A) / hr=自作tape(案B)\n"
+  forM_ [2, 4, 8, 16, 32] $ \nG -> do
+    let pr = genProb nG 12
+        c  = compile pr
+        t0 = theta0 pr
+        probe = sum . map abs
+    (tA, _)  <- timeitIO 50 probe (\_ -> pure (gradAD pr t0))
+    (tB, _)  <- timeitIO 50 probe (\_ -> pure (gradVec c t0))
+    (tC, _)  <- timeitIO 50 probe (\_ -> pure (gradBackprop c t0))
+    (tD, _)  <- timeitIO 50 probe (\_ -> pure (gradHandroll c t0))
+    printf "nG=%-3d p=%-3d n=%-4d | ad=%7.4f | vec=%7.4f | bp=%7.4f | hr=%7.4f | ad/bp ×%.1f | ad/hr ×%.1f | hr/vec ×%.1f\n"
+      nG (paramLen pr) (nG * 12) tA tB tC tD (tA / tC) (tA / tD) (tD / tB)
+
+  putStrLn "\n(vec=tape-free 解析勾配=汎用ベクトル化 AD の時間下限。"
+  putStrLn " ad/bp・ad/hr = 案A・案B が現行 ad を何倍速くするか (判断ゲート: ≥5× で 54.4 本実装へ)。"
+  putStrLn " hr/vec = 案B が下限からどれだけ離れているか = 自前 tape のオーバヘッド)"
diff --git a/bench/haskell/BenchHBMVecIRProf.hs b/bench/haskell/BenchHBMVecIRProf.hs
new file mode 100644
--- /dev/null
+++ b/bench/haskell/BenchHBMVecIRProf.hs
@@ -0,0 +1,283 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RankNTypes #-}
+{-# OPTIONS_GHC -fno-full-laziness -fno-cse #-}
+-- | Phase 85.1: radon 相関モデルの gradVecIR per-eval 内訳プロファイル。
+--
+-- Phase 84 ベンチで radon (919 obs 相関階層) の per-eval が ~160µs =
+-- XLA (numpyro) 比 ~5-10× 遅いと確定した。 本ベンチはその 160µs の内訳を
+-- **成分分解で実測**する (推測するな計測せよ):
+--
+--   full compileGradUV closure per-eval
+--   = pc 変換 (unconstrained→constrained・VS.generate)
+--   + gradVecIR
+--       = forwardArena (arena 確保 'VSM.unsafeNew' + forward 解釈)
+--       + guard 検査 ('arenaGuardsOK')
+--       + backward ('gradVecIRGo' = adj 確保 'VSM.replicate' + 逆伝播)
+--   + 残差 prior の ad ('mPriorGrad'・radon で残るかも本ベンチで確定)
+--   + chain rule (constrained→unconstrained・list ベース)
+--
+-- 各成分は下位経路 (forwardArena 単独・alloc 単独 等) を直接呼んで計測し、
+-- 直接測れない成分 (guard・backward 純分) は差分で推定する。 併せて
+-- 命令列 mix (命令種別 × 本数 × セル数) を静的に出す = forward/backward の
+-- どこが重いか (gather / elementwise / Σ) の見当を付ける。
+--
+-- 実行: taskset -c 0 で 1 コア固定 (Phase 84 と同条件)。
+--   cabal run bench-hbm-vecir-prof --project-file=cabal.project.plot -f benches
+module Main where
+
+import           Control.Monad                    (when)
+import           Control.Monad.ST                 (runST)
+import qualified Data.Map.Strict                  as Map
+import qualified Data.Set                         as Set
+import qualified Data.Text                        as T
+import qualified Data.Vector                      as BV
+import qualified Data.Vector.Storable             as VS
+import qualified Data.Vector.Storable.Mutable     as VSM
+import qualified Data.Vector.Unboxed              as VU
+import           Numeric.AD.Mode.Reverse.Double   (grad)
+import           Text.Printf                      (printf)
+
+import           Hanalyze.Model.HBM               (ModelP, sampleNames)
+import           Hanalyze.Fit                     (designHBMProgram)
+import           Hanalyze.Stat.Distribution       (Transform)
+import qualified Hanalyze.Model.HBM.Gradient      as G
+import qualified Hanalyze.Model.HBM.IR            as IR
+
+import           BenchUtil                        (timeitTastyIO)
+
+-- ---------------------------------------------------------------------------
+-- radon モデル (BenchHBMScaling と同一定義・同一 CSV)
+-- ---------------------------------------------------------------------------
+
+-- | Radon 生 CSV を読む (BenchHBMScaling.readRadon と同一)。
+readRadon :: IO ([[Double]], [Int], [Double], [Double])
+readRadon = do
+  txt <- readFile "bench/data/radon.csv"
+  let recs = map parseRow (drop 1 (lines txt))
+      parseRow ln = case splitComma ln of
+        (_c : ci : fl : lr : lu : _) ->
+          (read ci :: Int, read fl :: Double, read lr :: Double, read lu :: Double)
+        _ -> error ("readRadon: 列不足 " ++ ln)
+      cidx    = [ c | (c, _, _, _) <- recs ]
+      floors  = [ f | (_, f, _, _) <- recs ]
+      ys      = [ y | (_, _, y, _) <- recs ]
+      designX = [ [1.0, f, u] | (_, f, _, u) <- recs ]
+  return (designX, cidx, floors, ys)
+
+splitComma :: String -> [String]
+splitComma s = case break (== ',') s of
+  (a, ',' : rest) -> a : splitComma rest
+  (a, _)          -> [a]
+
+-- | Radon 相関 varying intercept+slope (BenchHBMScaling.radonModel と同一)。
+radonModel :: [[Double]] -> [Int] -> [Double] -> [Double] -> ModelP ()
+radonModel designX cidx floorCol ys =
+  designHBMProgram designX ["(Intercept)", "floor", "uranium"]
+                   [(cidx, nCounties, [floorCol])] ys
+  where nCounties = if null cidx then 0 else maximum cidx + 1
+
+-- ---------------------------------------------------------------------------
+-- 残差 prior の ad (compileGradUV の mPriorGrad と同一構成)
+-- ---------------------------------------------------------------------------
+
+-- | 'G.compileGradUV' 内部の @grad (fExcl exclNames)@ を同一式で再現する
+-- (radon の per-eval に残差 ad が乗っている場合、 その単独コストを測る)。
+residGrad
+  :: [[Double]] -> [Int] -> [Double] -> [Double]
+  -> [T.Text] -> [Transform] -> Set.Set T.Text
+  -> [Double] -> [Double]
+residGrad dX cidx fl ys names trans excl = grad f
+  where
+    f us =
+      let paramsC = Map.fromList
+            [ (n, G.invTransformF t u) | (n, t, u) <- zip3 names trans us ]
+          logJac  = sum [ G.logJacF t u | (t, u) <- zip trans us ]
+      in G.logJointExclBlocks excl (radonModel dX cidx fl ys) paramsC + logJac
+
+-- ---------------------------------------------------------------------------
+-- 命令列 mix (静的)
+-- ---------------------------------------------------------------------------
+
+-- | (命令種別, 本数, 総セル数 = Σ max 1 len)。 forward/backward の作業量の
+-- 静的な見当 (gather / elementwise / Σ の比率)。
+instrMix :: IR.VecProgram -> [(String, Int, Int)]
+instrMix prog =
+  let instrs = BV.toList (IR.vpInstrs prog)
+      lens   = VU.toList (IR.vpLen prog)
+      keyOf ins = case ins of
+        IR.VIK{}     -> "VIK   (スカラ定数)"
+        IR.VIKV{}    -> "VIKV  (ベクトル定数)"
+        IR.VILeafS{} -> "VILeafS (scalar leaf)"
+        IR.VILeafV{} -> "VILeafV (vector leaf)"
+        IR.VIGath{}  -> "VIGath (gather)"
+        IR.VIUn{}    -> "VIUn  (elementwise 単項)"
+        IR.VIBin{}   -> "VIBin (elementwise 二項)"
+        IR.VISum{}   -> "VISum (Σ 縮約)"
+        IR.VIAxpy{}  -> "VIAxpy (a+s·v 融合)"
+        IR.VIAxpyC{} -> "VIAxpyC (a+s·const 融合)"
+        IR.VISumSqD{} -> "VISumSqD (Σ(x−m)² 融合)"
+        IR.VISumSqC{} -> "VISumSqC (Σ(c−m)² 融合)"
+        IR.VIMulG{}   -> "VIMulG (s·gather 融合)"
+        IR.VIAxpyG{}  -> "VIAxpyG (a+s·gather 融合)"
+        IR.VIMulVC{}  -> "VIMulVC (s·v⊙c 融合)"
+        IR.VISumSqC2{} -> "VISumSqC2 (Σ(c−m1−m2)² 融合)"
+      accum m (ins, l) =
+        Map.insertWith (\(c1, e1) (c2, e2) -> (c1 + c2, e1 + e2))
+          (keyOf ins) (1 :: Int, max 1 l) m
+      mixed = foldl accum Map.empty (zip instrs lens)
+  in [ (k, c, e) | (k, (c, e)) <- Map.toList mixed ]
+
+-- ---------------------------------------------------------------------------
+
+usOf :: Double -> Double
+usOf ms = ms * 1000
+
+main :: IO ()
+main = do
+  putStrLn "=== Phase 85.1: radon gradVecIR per-eval 内訳プロファイル ==="
+  (dX, cidx, fl, ys) <- readRadon
+  let m :: ModelP ()
+      m = radonModel dX cidx fl ys
+      names  = sampleNames m
+      trans  = [ Map.findWithDefault (error "transform missing") n tmap
+               | n <- names ]
+      tmap   = G.getTransforms m
+      nP     = length names
+      nObs   = length ys
+
+  -- (a) vecIR 経路の compile (compileGradUV の IR branch と同一手順)
+  let (gbs, _) = G.gaussLMBlocksAuto m
+  when (not (null gbs)) $
+    putStrLn "★注意: gaussLMBlocksAuto が非空 = radon は IR branch でなく hybrid branch"
+  case IR.synthVecIR m of
+    Nothing -> error "synthVecIR = Nothing (radon が vecIR に乗っていない)"
+    Just (gs, fams, sObs) -> do
+      let ixOf   = Map.fromList (zip names [0 :: Int ..])
+          cvi    = IR.compileVecIR ixOf gs fams
+          prog   = IR.cvProg cvi
+          famSet = Set.fromList (concat [ ms | (ms, _, _) <- fams ])
+          cps    = G.constPriorsOf m famSet
+          exclNames = sObs `Set.union` famSet
+                      `Set.union` Set.fromList (map fst cps)
+          noResid = G.residualFreeOfDensity exclNames m
+          transB  = BV.fromList trans
+          sz      = IR.vpSize prog
+          objOff  = IR.vpOff prog `VU.unsafeIndex` IR.vpObj prog
+
+      -- ---- 静的サマリ ----
+      printf "obs=%d  nP=%d  instrs=%d  vpSize(arena セル)=%d  guards=%d\n"
+        nObs nP (BV.length (IR.vpInstrs prog)) sz (length (IR.vpGuards prog))
+      printf "residual ad (mPriorGrad): %s  (constPriors=%d, excl=%d/%d)\n"
+        (if noResid then "なし (noResid)" else "★あり = per-eval に ad が乗る" :: String)
+        (length cps) (Set.size exclNames) nP
+      putStrLn "\n--- 命令列 mix (種別 / 本数 / 総セル数) ---"
+      mapM_ (\(k, c, e) -> printf "  %-24s %5d 本  %8d セル\n" k c e)
+            (instrMix prog)
+
+      -- 85.3-ii: 実命令列 dump (superinstruction パターン選定用)
+      putStrLn "\n--- 命令列 listing (slot: 命令 [len]) ---"
+      BV.imapM_ (\i ins -> do
+        let l = IR.vpLen prog `VU.unsafeIndex` i
+            s = case ins of
+                  IR.VIK v        -> printf "VIK %.4g" v :: String
+                  IR.VIKV v       -> printf "VIKV (n=%d)" (VS.length v)
+                  IR.VILeafS p    -> printf "VILeafS p%d" p
+                  IR.VILeafV p    -> printf "VILeafV p%d" p
+                  IR.VIGath p _ n -> printf "VIGath p%d (n=%d)" p n
+                  IR.VIUn o x     -> printf "VIUn %s s%d" (show o) x
+                  IR.VIBin o x y  -> printf "VIBin %s s%d s%d" (show o) x y
+                  IR.VISum x      -> printf "VISum s%d" x
+                  IR.VIAxpy a sc v  -> printf "VIAxpy s%d s%d s%d" a sc v
+                  IR.VIAxpyC a sc c ->
+                    printf "VIAxpyC s%d s%d (n=%d)" a sc (VS.length c)
+                  IR.VISumSqD x m -> printf "VISumSqD s%d s%d" x m
+                  IR.VISumSqC c m ->
+                    printf "VISumSqC (n=%d) s%d" (VS.length c) m
+                  IR.VIMulG sc p _ n ->
+                    printf "VIMulG s%d gath(p%d,n=%d)" sc p n
+                  IR.VIAxpyG a sc p _ n ->
+                    printf "VIAxpyG s%d s%d gath(p%d,n=%d)" a sc p n
+                  IR.VIMulVC sc v c ->
+                    printf "VIMulVC s%d s%d (n=%d)" sc v (VS.length c)
+                  IR.VISumSqC2 c m1 m2 ->
+                    printf "VISumSqC2 (n=%d) s%d s%d" (VS.length c) m1 m2
+        printf "  s%-3d [%4d] %s\n" i l s) (IR.vpInstrs prog)
+      printf "  obj=s%d  guards=%s\n" (IR.vpObj prog)
+        (show [ sl | (_, sl) <- IR.vpGuards prog ])
+
+      -- ---- 計測点 (16 変種で CSE 回避・guard 域内) ----
+      let uvs = BV.fromList
+            [ VS.generate nP (\k -> 1e-3 * fromIntegral ((j * 31 + k) `mod` 17))
+            | j <- [0 :: Int .. 15] ]
+          pcOf uv = VS.generate nP $ \i ->
+            G.invTransformF (transB BV.! i) (uv `VS.unsafeIndex` i)
+          pcs = BV.map pcOf uvs
+          uvAt i = uvs BV.! (i `mod` 16)
+          pcAt i = pcs BV.! (i `mod` 16)
+      -- pcs を先に強制 (計測に混ぜない)
+      mapM_ (\pc -> pure $! VS.sum pc) (BV.toList pcs)
+      let v0 = IR.vecIRValue cvi (pcAt 0)
+      printf "\nvecIRValue @probe = %.6f (有限であること)\n" v0
+
+      -- ---- per-eval 計測 (tasty-bench 適応・µs) ----
+      let gv = G.compileGradUV m names trans
+      (tFull, _) <- timeitTastyIO id $ \i ->
+        pure $! VS.unsafeIndex (gv (uvAt i)) (i `mod` nP)
+      (tPc, _) <- timeitTastyIO id $ \i ->
+        pure $! VS.unsafeIndex (pcOf (uvAt i)) (i `mod` nP)
+      (tValue, _) <- timeitTastyIO id $ \i ->
+        pure $! IR.vecIRValue cvi (pcAt i)
+      (tGradIR, _) <- timeitTastyIO id $ \i ->
+        pure $! runST (do
+          mg <- VSM.replicate nP 0
+          ok <- IR.gradVecIR cvi (pcAt i) mg
+          if ok then VSM.unsafeRead mg (i `mod` nP) else pure (0 / 0))
+      (tForward, _) <- timeitTastyIO id $ \i ->
+        pure $! runST (do
+          ar <- IR.forwardArena cvi (pcAt i)
+          VSM.unsafeRead ar objOff)
+      (tFwGuard, _) <- timeitTastyIO id $ \i ->
+        pure $! runST (do
+          ar <- IR.forwardArena cvi (pcAt i)
+          ok <- IR.arenaGuardsOK prog ar
+          if ok then VSM.unsafeRead ar objOff else pure (0 / 0))
+      (tAllocFw, _) <- timeitTastyIO id $ \i ->
+        pure $! runST (do
+          ar <- VSM.unsafeNew sz
+          VSM.unsafeWrite ar 0 (fromIntegral i :: Double)
+          VSM.unsafeRead ar 0)
+      (tAllocAdj, _) <- timeitTastyIO id $ \i ->
+        pure $! runST (do
+          adj <- VSM.replicate sz (0 :: Double)
+          VSM.unsafeWrite adj 0 (fromIntegral i)
+          VSM.unsafeRead adj (sz - 1))
+      tResid <- if noResid
+        then pure Nothing
+        else do
+          let rg = residGrad dX cidx fl ys names trans exclNames
+          (t, _) <- timeitTastyIO id $ \i ->
+            pure $! sum (rg (VS.toList (uvAt i)))
+          pure (Just t)
+
+      -- ---- 内訳表 (µs・差分成分は推定) ----
+      let us = usOf
+          fwInterp  = tForward - tAllocFw
+          guardT    = tFwGuard - tForward
+          backward  = tGradIR - tFwGuard - tAllocAdj
+          accounted = tPc + tGradIR + maybe 0 id tResid
+          chainRest = tFull - accounted
+          pct t = 100 * t / tFull
+      putStrLn "\n--- per-eval 内訳 (µs・full 比 %) ---"
+      printf "full compileGradUV closure      : %9.2f µs (100.0%%)\n" (us tFull)
+      printf "├ pc 変換 (VS.generate)         : %9.2f µs (%5.1f%%)\n" (us tPc) (pct tPc)
+      printf "├ gradVecIR (fw+guard+bw)       : %9.2f µs (%5.1f%%)\n" (us tGradIR) (pct tGradIR)
+      printf "│  ├ forward arena 確保 (New)   : %9.2f µs (%5.1f%%)\n" (us tAllocFw) (pct tAllocFw)
+      printf "│  ├ forward 解釈 (差分)        : %9.2f µs (%5.1f%%)\n" (us fwInterp) (pct fwInterp)
+      printf "│  ├ guard 検査 (差分)          : %9.2f µs (%5.1f%%)\n" (us guardT) (pct guardT)
+      printf "│  ├ adj arena 確保 (replicate) : %9.2f µs (%5.1f%%)\n" (us tAllocAdj) (pct tAllocAdj)
+      printf "│  └ backward 逆伝播 (差分)     : %9.2f µs (%5.1f%%)\n" (us backward) (pct backward)
+      case tResid of
+        Nothing -> printf "├ 残差 prior ad                 : なし (noResid)\n"
+        Just t  -> printf "├ 残差 prior ad (grad fExcl)    : %9.2f µs (%5.1f%%)\n" (us t) (pct t)
+      printf "└ chain rule + 残り (差分)      : %9.2f µs (%5.1f%%)\n" (us chainRest) (pct chainRest)
+      printf "(参考) vecIRValue (値のみ)      : %9.2f µs (%5.1f%%)\n" (us tValue) (pct tValue)
diff --git a/bench/haskell/BenchHBMVecIRSpike.hs b/bench/haskell/BenchHBMVecIRSpike.hs
new file mode 100644
--- /dev/null
+++ b/bench/haskell/BenchHBMVecIRSpike.hs
@@ -0,0 +1,353 @@
+{-# LANGUAGE BangPatterns #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RankNTypes #-}
+{-# OPTIONS_GHC -fno-full-laziness -fno-cse #-}
+-- | Phase 54.11 spike: 非線形 μ の「ベクトル式 IR」 feasibility (計測先行)。
+--
+-- 54.9 の prof で M5/M6 (非 affine μ) の負けは「per-obs スカラ AD」 帰着が
+-- ~90% (logDensityObs ~52% + μ の AD 演算 ~25% + tape 管理 ~12%) と確定した。
+-- 54.11 本実装 = 「ベクトル式 IR を構築する追跡 interpreter」 の前に、
+-- **手組みの vec-tape (VecAD + 54.11 追加の elementwise op)** で M5/M6 の
+-- 勾配カーネルがどこまで速いかを実測し、 ゲート (実経路 `gradADU` 比 ≥3×)
+-- を判定する。
+--
+--   M5: μ_i = a·exp(-b·x_i) + c,  y_i ~ N(μ_i, σ)        (n=100, θ=4)
+--   M6: μ_i = a_{g(i)}·exp(-b·x_i), a_g ~ N(μ_a, τ_a)    (n=96, nG=8, θ=12)
+--
+-- 比較 3 通り (全て同一の unconstrained 全勾配・中心差分/相互で検証後に計測):
+--   (a)  RevD.grad (多相 logp 直書き)   — スカラ tape の下限 (walk 無し)
+--   (a') HBM.gradADU (per-obs 手書き)   — 実経路 (Free walk + ad fallback) = NUTS が払う値
+--   (b)  VecAD 手組み tape              — ベクトル式 IR 化の到達見込み (per-call 構築込み)
+--
+-- ⚠ (b) は手組み = IR 追跡 interpreter のオーバヘッドを含まない楽観側。
+-- 「PyMC 同等」 とは言わない。 ゲート判定にのみ使う。
+module Main where
+
+import           Control.Monad                  (forM_)
+import           Control.Monad.ST               (ST)
+import           Data.List                      (foldl')
+import qualified Data.Map.Strict                as Map
+import qualified Data.Text                      as T
+import qualified Data.Vector                    as V
+import qualified Data.Vector.Storable           as VS
+import qualified Data.Vector.Unboxed            as VU
+import qualified System.Random.MWC              as MWC
+import           System.Random.MWC.Distributions (standard)
+import           Text.Printf                    (printf)
+
+import qualified Numeric.AD.Mode.Reverse.Double as RevD
+
+import           Hanalyze.Model.HBM             (Distribution (..), ModelP,
+                                                 sample, observe, gradADU,
+                                                 sampleNames, getTransforms)
+import           Hanalyze.Model.HBM.VecAD
+
+import           BenchUtil                      (timeitIO)
+
+-- ---------------------------------------------------------------------------
+-- データ (BenchHBMScaling と同一 DGP・seed)
+-- ---------------------------------------------------------------------------
+
+normals :: Int -> Int -> IO [Double]
+normals seed k = do
+  g <- MWC.initialize (V.singleton (fromIntegral seed))
+  mapM (const (standard g)) [1 .. k]
+
+nM5 :: Int
+nM5 = 100
+
+genM5 :: IO ([Double], [Double])
+genM5 = do
+  let (a, b, c, s) = (2.5, 1.2, 0.5, 0.3)
+  ez <- normals 51 nM5
+  let xs = [ 3.0 * (fromIntegral i + 0.5) / fromIntegral nM5
+           | i <- [0 .. nM5 - 1] ]
+      ys = [ a * exp (negate b * x) + c + s * e | (x, e) <- zip xs ez ]
+  return (xs, ys)
+
+nGroups, perGroup :: Int
+nGroups  = 8
+perGroup = 12
+
+genM6 :: IO ([Double], [Int], [Double])
+genM6 = do
+  let (muA, tauA, b, s) = (2.0, 0.5, 1.0, 0.3)
+      n = nGroups * perGroup
+  ez <- normals 61 n
+  az <- normals 62 nGroups
+  let as   = [ muA + tauA * z | z <- az ]
+      gids = [ i `div` perGroup | i <- [0 .. n - 1] ]
+      xs   = [ 3.0 * (fromIntegral (i `mod` perGroup) + 0.5)
+                   / fromIntegral perGroup
+             | i <- [0 .. n - 1] ]
+      ys   = [ (as !! g) * exp (negate b * x) + s * e
+             | (x, g, e) <- zip3 xs gids ez ]
+  return (xs, gids, ys)
+
+-- ---------------------------------------------------------------------------
+-- モデル (BenchHBMScaling と同一・(a') gradADU 用)
+-- ---------------------------------------------------------------------------
+
+m5Model :: [Double] -> [Double] -> ModelP ()
+m5Model xs ys = do
+  a <- sample "a" (Normal 0 10)
+  b <- sample "b" (HalfNormal 2)
+  c <- sample "c" (Normal 0 10)
+  s <- sample "sigma" (Exponential 1)
+  forM_ (zip3 [0 :: Int ..] xs ys) $ \(i, x, y) ->
+    observe (T.pack ("y_" ++ show i))
+      (Normal (a * exp (negate b * realToFrac x) + c) s) [y]
+
+m6Model :: [Double] -> [Int] -> [Double] -> ModelP ()
+m6Model xs gids ys = do
+  let nG = if null gids then 0 else maximum gids + 1
+  muA  <- sample "mu_a"  (Normal 0 10)
+  tauA <- sample "tau_a" (HalfNormal 2)
+  as   <- mapM (\j -> sample (T.pack ("a_" ++ show j)) (Normal muA tauA))
+               [0 .. nG - 1]
+  b    <- sample "b" (HalfNormal 2)
+  s    <- sample "sigma" (Exponential 1)
+  forM_ (zip3 [0 :: Int ..] (zip xs gids) ys) $ \(i, (x, g), y) ->
+    observe (T.pack ("y_" ++ show i))
+      (Normal ((as !! g) * exp (negate b * realToFrac x)) s) [y]
+
+-- ---------------------------------------------------------------------------
+-- (a) 多相 logp 直書き (RevD.grad の対象・unconstrained 全勾配)
+-- ---------------------------------------------------------------------------
+
+logN :: Floating a => a -> a -> a -> a
+logN x m s = -0.5 * log (2 * pi) - log s - 0.5 * ((x - m) / s) ^ (2 :: Int)
+
+logHalfNormal :: Floating a => a -> a -> a
+logHalfNormal x s = 0.5 * log (2 / pi) - log s - 0.5 * (x / s) ^ (2 :: Int)
+
+-- θ = [a, log b, c, log σ] (sampleNames 順・b/σ は PositiveT)。
+logp5 :: forall a. Floating a => [Double] -> [Double] -> [a] -> a
+logp5 xs ys [ua, ub, uc, us] =
+  let b   = exp ub
+      sig = exp us
+      pri = logN ua 0 10 + logHalfNormal b 2 + ub
+            + logN uc 0 10 + (negate sig + us)
+      ll  = sum [ logN (realToFrac y) (ua * exp (negate b * realToFrac x) + uc) sig
+                | (x, y) <- zip xs ys ]
+  in pri + ll
+logp5 _ _ _ = error "logp5: θ shape"
+
+-- θ = [μ_a, log τ_a, a_0..a_{nG-1}, log b, log σ]。
+logp6 :: forall a. Floating a => [Double] -> [Int] -> [Double] -> [a] -> a
+logp6 xs gids ys theta =
+  let nG  = nGroups
+      ma  = theta !! 0
+      ut  = theta !! 1
+      as  = take nG (drop 2 theta)
+      ub  = theta !! (2 + nG)
+      us  = theta !! (3 + nG)
+      tau = exp ut
+      b   = exp ub
+      sig = exp us
+      pri = logN ma 0 10 + logHalfNormal tau 2 + ut
+            + sum [ logN aj ma tau | aj <- as ]
+            + logHalfNormal b 2 + ub + (negate sig + us)
+      ll  = sum [ logN (realToFrac y) ((as !! g) * exp (negate b * realToFrac x)) sig
+                | (x, g, y) <- zip3 xs gids ys ]
+  in pri + ll
+
+-- ---------------------------------------------------------------------------
+-- (b) VecAD 手組み tape (per-call 構築込み)
+-- ---------------------------------------------------------------------------
+
+negS :: Ctx s -> Rval -> ST s Rval
+negS ctx = mulConstS ctx (-1)
+
+-- | M5 の unconstrained 全勾配 (θ=4)。
+gradVec5 :: VS.Vector Double -> VS.Vector Double -> [Double] -> [Double]
+gradVec5 xC yC [ua0, ub0, uc0, us0] =
+  let n  = VS.length xC
+      gs = runTape $ \ctx -> do
+        ua <- inputScal ctx ua0
+        ub <- inputScal ctx ub0
+        uc <- inputScal ctx uc0
+        us <- inputScal ctx us0
+        b   <- expS ctx ub
+        sig <- expS ctx us
+        xs  <- constVec ctx xC
+        ys  <- constVec ctx yC
+        nb  <- negS ctx b
+        t1  <- scaleHR ctx nb xs            -- -b·x
+        t2  <- vexpHR ctx t1                -- exp(-b·x)
+        t3  <- scaleHR ctx ua t2            -- a·exp(-b·x)
+        mu  <- bcastAddHR ctx uc t3         -- + c
+        r   <- vsubHR ctx ys mu
+        sr2 <- dotHR ctx r r
+        -- loglik = -n/2·log2π - n·logσ - sr2/(2σ²)   (logσ = us)
+        s2  <- mulS ctx sig sig
+        den <- mulConstS ctx 2 s2
+        q   <- divByS ctx sr2 den
+        nls <- mulConstS ctx (fromIntegral n) us
+        ll0 <- addS ctx nls q               -- n·logσ + sr2/(2σ²)
+        ll  <- mulConstS ctx (-1) ll0
+        -- priors: a,c ~ N(0,10); b ~ HalfNormal 2 (+jac ub); σ ~ Exp 1 (+jac us)
+        aa  <- mulS ctx ua ua
+        pa  <- mulConstS ctx (negate (1 / 200)) aa
+        cc  <- mulS ctx uc uc
+        pc  <- mulConstS ctx (negate (1 / 200)) cc
+        bb  <- mulS ctx b b
+        pb0 <- mulConstS ctx (negate (1 / 8)) bb
+        pb  <- addS ctx pb0 ub
+        nsg <- negS ctx sig
+        ps  <- addS ctx nsg us
+        tot <- foldAddS ctx [ll, pa, pc, pb, ps]
+        pure (tot, [ua, ub, uc, us])
+  in map VS.head gs
+gradVec5 _ _ _ = error "gradVec5: θ shape"
+
+-- | M6 の unconstrained 全勾配 (θ = 4 + nG)。
+gradVec6 :: VS.Vector Double -> VU.Vector Int -> VS.Vector Double
+         -> [Double] -> [Double]
+gradVec6 xC gids yC theta =
+  let n   = VS.length xC
+      nG  = nGroups
+      ma0 = theta !! 0
+      ut0 = theta !! 1
+      as0 = VS.fromList (take nG (drop 2 theta))
+      ub0 = theta !! (2 + nG)
+      us0 = theta !! (3 + nG)
+      gs = runTape $ \ctx -> do
+        ma  <- inputScal ctx ma0
+        ut  <- inputScal ctx ut0
+        av  <- inputVec ctx as0
+        ub  <- inputScal ctx ub0
+        us  <- inputScal ctx us0
+        tau <- expS ctx ut
+        b   <- expS ctx ub
+        sig <- expS ctx us
+        xs  <- constVec ctx xC
+        ys  <- constVec ctx yC
+        nb  <- negS ctx b
+        t1  <- scaleHR ctx nb xs
+        t2  <- vexpHR ctx t1                -- exp(-b·x)
+        ag  <- gatherHR ctx gids nG av      -- a_{g(i)}
+        mu  <- hadamardHR ctx ag t2         -- a_g·exp(-b·x)
+        r   <- vsubHR ctx ys mu
+        sr2 <- dotHR ctx r r
+        s2  <- mulS ctx sig sig
+        den <- mulConstS ctx 2 s2
+        q   <- divByS ctx sr2 den
+        nls <- mulConstS ctx (fromIntegral n) us
+        ll0 <- addS ctx nls q
+        ll  <- mulConstS ctx (-1) ll0
+        -- prior a_j ~ N(μ_a, τ): -nG·logτ - Σ(a_j-μ_a)²/(2τ²)   (logτ = ut)
+        zsC <- constVec ctx (VS.replicate nG 0)
+        mab <- bcastAddHR ctx ma zsC        -- μ_a broadcast (長さ nG)
+        ra  <- vsubHR ctx av mab
+        sra <- dotHR ctx ra ra
+        t2a <- mulS ctx tau tau
+        dna <- mulConstS ctx 2 t2a
+        qa  <- divByS ctx sra dna
+        nlt <- mulConstS ctx (fromIntegral nG) ut
+        pa0 <- addS ctx nlt qa
+        pa  <- mulConstS ctx (-1) pa0
+        -- μ_a ~ N(0,10); τ_a ~ HalfNormal 2 (+jac ut); b ~ HalfNormal 2 (+jac ub);
+        -- σ ~ Exp 1 (+jac us)
+        mm  <- mulS ctx ma ma
+        pm  <- mulConstS ctx (negate (1 / 200)) mm
+        tt  <- mulS ctx tau tau
+        pt0 <- mulConstS ctx (negate (1 / 8)) tt
+        pt  <- addS ctx pt0 ut
+        bb  <- mulS ctx b b
+        pb0 <- mulConstS ctx (negate (1 / 8)) bb
+        pb  <- addS ctx pb0 ub
+        nsg <- negS ctx sig
+        ps  <- addS ctx nsg us
+        tot <- foldAddS ctx [ll, pa, pm, pt, pb, ps]
+        pure (tot, [ma, ut, av, ub, us])
+  in case gs of
+       [gma, gut, gav, gub, gus] ->
+         VS.head gma : VS.head gut : VS.toList gav ++ [VS.head gub, VS.head gus]
+       _ -> error "gradVec6: leaf shape"
+
+-- | スカラノード列を addS で畳む。
+foldAddS :: Ctx s -> [Rval] -> ST s Rval
+foldAddS _   []       = error "foldAddS: empty"
+foldAddS _   [x]      = pure x
+foldAddS ctx (x:y:xs) = addS ctx x y >>= \z -> foldAddS ctx (z : xs)
+
+-- ---------------------------------------------------------------------------
+-- 検証 + 計測
+-- ---------------------------------------------------------------------------
+
+closeVec :: Double -> [Double] -> [Double] -> Bool
+closeVec tol u v =
+  length u == length v
+  && and [ abs (a - b) <= tol * (1 + max (abs a) (abs b)) | (a, b) <- zip u v ]
+
+centralDiff :: ([Double] -> Double) -> [Double] -> [Double]
+centralDiff f th =
+  [ (f (bump i h) - f (bump i (negate h))) / (2 * h) | i <- [0 .. length th - 1] ]
+  where
+    h = 1e-5
+    bump i d = [ if j == i then t + d else t | (j, t) <- zip [0 ..] th ]
+
+-- K 回の勾配呼出を 1 計測にまとめる (1 call ~0.1ms 級のタイマ分解能対策)。
+benchGrad :: String -> Int -> ([Double] -> [Double]) -> [Double] -> IO Double
+benchGrad tag k f th0 = do
+  let run i = pure $! foldl' (\ !acc j ->
+                 let th = [ t + 1e-9 * fromIntegral (i + j) | t <- th0 ]
+                 in acc + sum (f th)) 0 [1 .. k]
+  (ms, _) <- timeitIO 7 id run
+  let per = ms / fromIntegral k
+  printf "  %-28s %8.4f ms/grad (%d calls median)\n" tag per k
+  pure per
+
+main :: IO ()
+main = do
+  putStrLn "== Phase 54.11 spike: 非線形 μ の vec-tape (手組み IR) =="
+  (x5, y5)     <- genM5
+  (x6, g6, y6) <- genM6
+
+  -- ---- M5 ----
+  let m5 :: ModelP ()
+      m5 = m5Model x5 y5
+      n5names = sampleNames m5
+      n5trans = [ getTransforms m5 Map.! nm | nm <- n5names ]
+      th5  = [0.8, log 0.9, 0.3, log 0.4]
+      x5C  = VS.fromList x5
+      y5C  = VS.fromList y5
+      gAd5  = RevD.grad (logp5 x5 y5) th5
+      gAdu5 = gradADU m5 n5names n5trans th5
+      gVec5 = gradVec5 x5C y5C th5
+      gCd5  = centralDiff (logp5 x5 y5) th5
+  putStrLn "M5 検証 (RevD / gradADU / vec-tape / 中心差分):"
+  printf "  RevD vs gradADU: %s\n" (show (closeVec 1e-9 gAd5 gAdu5))
+  printf "  vec  vs RevD:    %s\n" (show (closeVec 1e-9 gVec5 gAd5))
+  printf "  vec  vs 中心差分: %s\n" (show (closeVec 1e-4 gVec5 gCd5))
+  putStrLn "M5 計測:"
+  pa5  <- benchGrad "(a)  RevD.grad (logp 直書き)" 200 (RevD.grad (logp5 x5 y5)) th5
+  pa5' <- benchGrad "(a') gradADU (実経路 walk+ad)" 200 (gradADU m5 n5names n5trans) th5
+  pb5  <- benchGrad "(b)  vec-tape 手組み" 200 (gradVec5 x5C y5C) th5
+  printf "  → (a')/(b) = %.1fx / (a)/(b) = %.1fx (ゲート ≥3×)\n\n"
+    (pa5' / pb5) (pa5 / pb5)
+
+  -- ---- M6 ----
+  let m6 :: ModelP ()
+      m6 = m6Model x6 g6 y6
+      n6names = sampleNames m6
+      n6trans = [ getTransforms m6 Map.! nm | nm <- n6names ]
+      th6  = [1.5, log 0.6] ++ replicate nGroups 1.8 ++ [log 0.9, log 0.4]
+      x6C  = VS.fromList x6
+      y6C  = VS.fromList y6
+      g6U  = VU.fromList g6
+      gAd6  = RevD.grad (logp6 x6 g6 y6) th6
+      gAdu6 = gradADU m6 n6names n6trans th6
+      gVec6 = gradVec6 x6C g6U y6C th6
+      gCd6  = centralDiff (logp6 x6 g6 y6) th6
+  putStrLn "M6 検証 (RevD / gradADU / vec-tape / 中心差分):"
+  printf "  RevD vs gradADU: %s\n" (show (closeVec 1e-9 gAd6 gAdu6))
+  printf "  vec  vs RevD:    %s\n" (show (closeVec 1e-9 gVec6 gAd6))
+  printf "  vec  vs 中心差分: %s\n" (show (closeVec 1e-4 gVec6 gCd6))
+  putStrLn "M6 計測:"
+  pa6  <- benchGrad "(a)  RevD.grad (logp 直書き)" 200 (RevD.grad (logp6 x6 g6 y6)) th6
+  pa6' <- benchGrad "(a') gradADU (実経路 walk+ad)" 200 (gradADU m6 n6names n6trans) th6
+  pb6  <- benchGrad "(b)  vec-tape 手組み" 200 (gradVec6 x6C g6U y6C) th6
+  printf "  → (a')/(b) = %.1fx / (a)/(b) = %.1fx (ゲート ≥3×)\n"
+    (pa6' / pb6) (pa6 / pb6)
diff --git a/bench/haskell/BenchHBMVecSpike.hs b/bench/haskell/BenchHBMVecSpike.hs
new file mode 100644
--- /dev/null
+++ b/bench/haskell/BenchHBMVecSpike.hs
@@ -0,0 +1,177 @@
+{-# LANGUAGE BangPatterns #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RankNTypes #-}
+{-# OPTIONS_GHC -fno-full-laziness -fno-cse #-}
+-- | Phase 54.0 feasibility spike (計測先行・推測するな計測せよ)。
+--
+-- Phase 54 の本実装に入る前に、 2 つの不確実点を実測で確かめる小実験:
+--
+--   (Q1) AD over Vector の勾配保存:
+--        `Numeric.AD.Mode.Reverse.Double.grad` が、 観測尤度を
+--        ① list 内包 (現状の obsLogSum 形)・② 非ボックス Vector 上の fold・
+--        ③ 十分統計量による fused 閉形式 (O(1)) で書いた log-density に対し、
+--        いずれも **中心差分と一致する勾配** を返すか。
+--        → 一致すれば 54.2 (観測尤度ベクトル化) の AD 前提が成立。
+--
+--   (Q2) ベクトル化/融合の per-grad 改善率:
+--        ①②③ の 1 勾配あたり時間を `timeitIO` で計測し、 scalar 版 (①) 比の
+--        改善率を出す。 ③ が桁で速ければ 54.2 の「vector-mean observe →
+--        fused 配列 log-density」 の利得見込みを定量化できる。
+--
+-- 対象は Gaussian 線形回帰 y_i ~ Normal(a + b x_i, σ) (= m1Model 相当)。
+-- σ は unconstrained u = log σ で持ち、 prior は a,b~Normal(0,10)・σ~Exp(1)
+-- (HBM の logJointUnconstrained と同じ「prior + jacobian + 観測和」 構造)。
+--
+-- ★この spike は HBM 本体を一切いじらない。 観測和の 3 表現が AD で同値かつ
+--   どれだけ速いかだけを切り出して測る独立実験。
+module Main where
+
+import           Control.Monad                  (forM_)
+import qualified Data.Vector.Storable           as VS
+import           Text.Printf                    (printf)
+
+import qualified Numeric.AD.Mode.Reverse.Double as RevD
+
+import           BenchUtil                      (timeitIO)
+
+-- ---------------------------------------------------------------------------
+-- 対数尤度 3 表現 (param = [a, b, u], σ = exp u)
+-- ---------------------------------------------------------------------------
+
+-- 共通の prior + jacobian (3 表現で同一)。
+logPriorPart :: Floating a => a -> a -> a -> a
+logPriorPart a b u =
+  let s          = exp u
+      lnNormal mu sig x = -0.5 * log (2 * pi) - log sig
+                          - 0.5 * ((x - mu) / sig) ^ (2 :: Int)
+      priorA     = lnNormal 0 10 a
+      priorB     = lnNormal 0 10 b
+      -- σ ~ Exponential 1: logDensity = log 1 - 1*σ = -σ。 jacobian dσ/du = σ → +u
+      priorSigma = (-s) + u
+  in priorA + priorB + priorSigma
+
+-- 観測の 1 項 (Normal): -0.5 log(2π) - log s - 0.5 ((y - μ)/s)^2
+obsTerm :: Floating a => a -> a -> a -> Double -> Double -> a
+obsTerm a b s x y =
+  let mu = a + b * realToFrac x
+  in -0.5 * log (2 * pi) - log s
+     - 0.5 * ((realToFrac y - mu) / s) ^ (2 :: Int)
+{-# INLINE obsTerm #-}
+
+-- ① scalar list 内包 (現状 obsLogSum と同じ形)。
+logLikScalar :: Floating a => [Double] -> [Double] -> [a] -> a
+logLikScalar xs ys ps =
+  let (a : b : u : _) = ps
+      s = exp u
+  in logPriorPart a b u
+     + sum [ obsTerm a b s x y | (x, y) <- zip xs ys ]
+
+-- ② 非ボックス Storable Vector 上の手動 fold (list alloc を排除)。
+--    データは VS.Vector Double (unboxed)、 累算器のみ AD スカラ (boxed)。
+logLikVec :: Floating a => VS.Vector Double -> VS.Vector Double -> [a] -> a
+logLikVec xs ys ps =
+  let (a : b : u : _) = ps
+      s = exp u
+      n = VS.length xs
+      go !acc i
+        | i >= n    = acc
+        | otherwise = go (acc + obsTerm a b s (xs `VS.unsafeIndex` i)
+                                              (ys `VS.unsafeIndex` i)) (i + 1)
+  in logPriorPart a b u + go 0 0
+
+-- ③ 十分統計量による fused 閉形式 (O(1) per eval)。
+--    Σ_i (y_i - a - b x_i)^2 = Syy - 2a Sy - 2b Sxy + n a^2 + 2ab Sx + b^2 Sxx
+--    の 6 つの和は Double 定数として 1 回だけ前計算 → eval は a,b,s の多項式。
+data SuffStat = SuffStat
+  { ssN :: !Double, ssSx :: !Double, ssSy :: !Double
+  , ssSxx :: !Double, ssSxy :: !Double, ssSyy :: !Double }
+
+mkSuffStat :: [Double] -> [Double] -> SuffStat
+mkSuffStat xs ys = SuffStat
+  { ssN   = fromIntegral (length xs)
+  , ssSx  = sum xs
+  , ssSy  = sum ys
+  , ssSxx = sum (map (\x -> x * x) xs)
+  , ssSxy = sum (zipWith (*) xs ys)
+  , ssSyy = sum (map (\y -> y * y) ys)
+  }
+
+logLikFused :: Floating a => SuffStat -> [a] -> a
+logLikFused ss ps =
+  let (a : b : u : _) = ps
+      s  = exp u
+      n  = realToFrac (ssN ss)
+      sx = realToFrac (ssSx ss); sy = realToFrac (ssSy ss)
+      sxx = realToFrac (ssSxx ss); sxy = realToFrac (ssSxy ss)
+      syy = realToFrac (ssSyy ss)
+      -- Σ resid^2 を展開した閉形式
+      sse = syy - 2 * a * sy - 2 * b * sxy
+            + n * a * a + 2 * a * b * sx + b * b * sxx
+      obsSum = n * (-0.5 * log (2 * pi) - log s) - 0.5 / (s * s) * sse
+  in logPriorPart a b u + obsSum
+
+-- ---------------------------------------------------------------------------
+-- 中心差分 (ground truth)
+-- ---------------------------------------------------------------------------
+
+centralDiff :: ([Double] -> Double) -> [Double] -> [Double]
+centralDiff f ps =
+  [ let h    = 1e-6 * (abs (ps !! j) + 1e-3)
+        plus = f (bump j h)
+        minu = f (bump j (-h))
+    in (plus - minu) / (2 * h)
+  | j <- [0 .. length ps - 1] ]
+  where bump j d = [ if k == j then p + d else p | (k, p) <- zip [0 ..] ps ]
+
+relErr :: [Double] -> [Double] -> Double
+relErr g1 g2 = maximum
+  [ abs (x - y) / (abs y + 1e-8) | (x, y) <- zip g1 g2 ]
+
+-- ---------------------------------------------------------------------------
+-- データ生成 (BenchHBMADModes と同形)
+-- ---------------------------------------------------------------------------
+
+genData :: Int -> ([Double], [Double])
+genData n =
+  let xs = [ 2.0 * sin (0.7 * fromIntegral i) | i <- [0 .. n - 1] ]
+      ys = [ 2.0 + 1.5 * x + 0.3 * cos (1.3 * fromIntegral i)
+           | (i, x) <- zip [0 :: Int ..] xs ]
+  in (xs, ys)
+
+-- ---------------------------------------------------------------------------
+-- main
+-- ---------------------------------------------------------------------------
+
+main :: IO ()
+main = do
+  putStrLn "=== Phase 54.0 feasibility spike: 観測尤度ベクトル化 × Reverse.Double ===\n"
+  let ps0 = [1.8, 1.4, log 0.35]   -- [a, b, u=log σ] (真値近傍)
+
+  putStrLn "--- (Q1) 勾配の数値一致 (RevD.grad vs 中心差分・rel err) ---"
+  forM_ [50, 200, 1000] $ \n -> do
+    let (xs, ys) = genData n
+        xv = VS.fromList xs; yv = VS.fromList ys
+        ss = mkSuffStat xs ys
+        gScalar = RevD.grad (logLikScalar xs ys) ps0
+        gVec    = RevD.grad (logLikVec xv yv)    ps0
+        gFused  = RevD.grad (logLikFused ss)     ps0
+        gCD     = centralDiff (logLikScalar xs ys) ps0
+    printf "n=%-5d | scalar=%.3e | vec=%.3e | fused=%.3e (各 vs 中心差分)\n"
+      n (relErr gScalar gCD) (relErr gVec gCD) (relErr gFused gCD)
+    -- AD 同士の一致も確認 (3 表現が同一勾配か)
+    printf "         | vec-vs-scalar=%.3e | fused-vs-scalar=%.3e (AD 同士)\n"
+      (relErr gVec gScalar) (relErr gFused gScalar)
+
+  putStrLn "\n--- (Q2) per-grad 時間 (ms・median of 50) と scalar 比 ---"
+  forM_ [50, 200, 1000, 5000] $ \n -> do
+    let (xs, ys) = genData n
+        xv = VS.fromList xs; yv = VS.fromList ys
+        ss = mkSuffStat xs ys
+        probe = sum . map abs
+    (tS, _) <- timeitIO 50 probe (\_ -> pure (RevD.grad (logLikScalar xs ys) ps0))
+    (tV, _) <- timeitIO 50 probe (\_ -> pure (RevD.grad (logLikVec xv yv)    ps0))
+    (tF, _) <- timeitIO 50 probe (\_ -> pure (RevD.grad (logLikFused ss)     ps0))
+    printf "n=%-5d | scalar=%8.4f | vec=%8.4f (×%.2f) | fused=%8.4f (×%.1f)\n"
+      n tS tV (tS / tV) tF (tS / tF)
+
+  putStrLn "\n(×N = scalar 比の速度向上。 fused は O(1) ゆえ n 増で差が拡大する想定)"
diff --git a/bench/haskell/BenchKernel.hs b/bench/haskell/BenchKernel.hs
--- a/bench/haskell/BenchKernel.hs
+++ b/bench/haskell/BenchKernel.hs
@@ -8,7 +8,7 @@
 import qualified System.Random.MWC       as MWC
 import qualified Data.Vector             as V
 
-import qualified Hanalyze.Model.Kernel            as Kn
+import qualified Hanalyze.Model.KernelRegression            as Kn
 import qualified Hanalyze.Model.GP                as GP
 import qualified Hanalyze.Model.RFF               as RFF
 import qualified Hanalyze.Model.GPRobust          as GPR
diff --git a/bench/haskell/BenchM2Iso.hs b/bench/haskell/BenchM2Iso.hs
new file mode 100644
--- /dev/null
+++ b/bench/haskell/BenchM2Iso.hs
@@ -0,0 +1,89 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RankNTypes #-}
+{-# OPTIONS_GHC -fno-full-laziness -fno-cse #-}
+-- | Phase 85.5: M2 (random intercept) 単独 NUTS の A/B 計測ドライバ。
+--
+-- Phase 85.3 の回帰ガードで M2 の wall が Phase 84 基準比 ~1.5× 悪化して
+-- 3 run 再現した (M3-M8 は非回帰・per-eval A/B は +10-18% のみ・iter 増で
+-- 悪化率上昇 = GC/heap の疑い)。 本ドライバは BenchHBMScaling の M2 を
+-- データ・init・config 込みで単独再現し、 `-rtsopts` 付きでビルドして
+-- `+RTS -s` の alloc / GC / MUT 時間を旧 lib (worktree) と直接比較する。
+--
+--   cabal run bench-m2-iso -f benches -- 1600 +RTS -s
+module Main where
+
+import           Control.Monad                    (forM_)
+import qualified Data.Map.Strict                  as Map
+import qualified Data.Text                        as T
+import qualified Data.Vector                      as V
+import qualified System.Random.MWC                as MWC
+import           System.Random.MWC.Distributions  (standard)
+import           System.Environment               (getArgs)
+import           Text.Printf                      (printf)
+
+import           Hanalyze.Model.HBM               (ModelP, glmmRandomIntercept,
+                                                   GlmmFamily (..))
+import           Hanalyze.MCMC.NUTS               (NUTSConfig (..),
+                                                   defaultNUTSConfig, nuts)
+import           Hanalyze.MCMC.Core               (Chain, posteriorMean)
+
+import           BenchUtil                        (timeitIO)
+
+-- BenchHBMScaling と同一の M2 DGP (8 群 × 12 = 96 obs・決定的)
+nGroups, perGroup :: Int
+nGroups  = 8
+perGroup = 12
+
+normals :: Int -> Int -> IO [Double]
+normals seed k = do
+  g <- MWC.initialize (V.singleton (fromIntegral seed))
+  mapM (const (standard g)) [1 .. k]
+
+genM2Data :: IO ([[Double]], [Int], [Double])
+genM2Data = do
+  let (b0, b1, tauU, s) = (1.0, 0.8, 1.5, 1.0)
+      n = nGroups * perGroup
+  xz <- normals 21 n
+  ez <- normals 22 n
+  uz <- normals 23 nGroups
+  let us   = map (* tauU) uz
+      gids = [ i `div` perGroup | i <- [0 .. n - 1] ]
+      xs   = map (* 2.0) xz
+      ys   = [ b0 + b1 * x + (us !! g) + s * e
+             | (x, g, e) <- zip3 xs gids ez ]
+      xRows = [ [1.0, x] | x <- xs ]
+  return (xRows, gids, ys)
+
+mkConfig :: Int -> NUTSConfig
+mkConfig iters = defaultNUTSConfig
+  { nutsIterations    = iters
+  , nutsBurnIn        = 500
+  , nutsStepSize      = 0.1
+  , nutsMaxDepth      = 10
+  , nutsAdaptStepSize = True
+  , nutsTargetAccept  = 0.8
+  , nutsAdaptMass     = True
+  }
+
+main :: IO ()
+main = do
+  args <- getArgs
+  let iters = case args of
+        (a : _) -> read a
+        _       -> 1600
+  (xr, gids, ys) <- genM2Data
+  let m2 :: ModelP ()
+      m2 = glmmRandomIntercept GlmmGaussian xr gids ys
+      names = ["beta_0", "beta_1", "tau_u", "sigma"]
+              ++ [ T.pack ("u_" ++ show j) | j <- [0 .. nGroups - 1] ]
+      initP = Map.fromList $
+        [ ("beta_0", 1.0), ("beta_1", 0.8), ("tau_u", 1.5), ("sigma", 1.0) ]
+        ++ [ (T.pack ("u_" ++ show j), 0.0) | j <- [0 .. nGroups - 1] ]
+      probe ch = sum [ maybe 0 id (posteriorMean p ch) | p <- names ]
+      run :: Int -> IO Chain
+      run i = do
+        g <- MWC.initialize (V.singleton (fromIntegral (42 + i)))
+        nuts m2 (mkConfig iters) initP g
+  (ms, ch) <- timeitIO 5 probe run
+  printf "M2_iso iter=%d warmup=500 reps=5 median=%.1f ms  beta_1=%.4f\n"
+    iters ms (maybe 0 id (posteriorMean "beta_1" ch))
diff --git a/bench/haskell/BenchMemAggregate.hs b/bench/haskell/BenchMemAggregate.hs
--- a/bench/haskell/BenchMemAggregate.hs
+++ b/bench/haskell/BenchMemAggregate.hs
@@ -10,7 +10,9 @@
 
 import qualified Data.Text                as T
 import qualified Data.Vector              as V
-import qualified DataFrame                as DX
+import qualified DataFrame.Internal.Column    as DX
+import qualified DataFrame.Internal.DataFrame  as DX
+import qualified DataFrame.Operations.Core     as DX
 import           Data.Time.Clock          (getCurrentTime, diffUTCTime)
 import           System.Environment       (getArgs)
 import           System.IO                (hSetBuffering, BufferMode (..), stdout)
diff --git a/bench/haskell/BenchPhase17.hs b/bench/haskell/BenchPhase17.hs
new file mode 100644
--- /dev/null
+++ b/bench/haskell/BenchPhase17.hs
@@ -0,0 +1,408 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# OPTIONS_GHC -fno-full-laziness -fno-cse -Wno-incomplete-uni-patterns #-}
+-- | Phase 1-7 機能 (Spotfire/JMP gap) の Python/R 比較ベンチ (Haskell 側)。
+--
+-- 比較先:
+--   * Weibull MLE          → scipy.stats.weibull_min.fit
+--   * MANOVA (Wilks Λ)     → 自前 numpy 実装 (Haskell と同 algorithm)
+--   * Hotelling T²         → 自前 numpy 実装
+--   * Lasso/Ridge λ CV     → sklearn.linear_model.{LassoCV,RidgeCV}
+--   * SpaceFilling LHS     → scipy.stats.qmc.LatinHypercube
+--   * SpaceFilling Halton  → scipy.stats.qmc.Halton
+--   * Augment Design       → Python 直接等価なし (Haskell-only 計測)
+--   * DSD (Jones-Nachtsheim) → Python 直接等価なし (Haskell-only 計測)
+--   * SPC X̄-R              → Python 直接等価なし (Haskell-only 計測)
+--
+-- 共通入力 CSV は本ファイルで生成 (Halton 列で再現性確保) し
+-- @bench/data/@ に書き出す。 Python 側は同じ CSV を読む。
+--
+-- 出力: @bench/results/haskell/phase17.csv@ (unified BenchRow schema)。
+module Main where
+
+import qualified Data.Text               as T
+import qualified Data.Vector             as V
+import qualified Numeric.LinearAlgebra   as LA
+import qualified System.Random.MWC       as MWC
+import           System.Directory        (createDirectoryIfMissing)
+import           System.IO               (withFile, IOMode (..), hPutStrLn)
+import           Text.Printf             (printf)
+
+import qualified Hanalyze.Model.Weibull         as Wei
+import qualified Hanalyze.Model.Regularized     as Reg
+import qualified Hanalyze.Design.Optimal        as Opt
+import qualified Hanalyze.Design.SpaceFilling   as SF
+import qualified Hanalyze.Design.DSD            as DSD
+import qualified Hanalyze.Stat.SPC              as SPC
+import qualified Hanalyze.Stat.QuasiRandom      as QR
+import qualified Hanalyze.Stat.Test             as ST
+
+import           BenchUtil
+
+-- ===========================================================================
+-- 共通 helper
+-- ===========================================================================
+
+-- | Halton 1D 列で (0, 1) 上の n 個の deterministic uniform 値。
+haltonU1 :: Int -> Int -> [Double]
+haltonU1 prime n = [ QR.radicalInverse prime i | i <- [1 .. n] ]
+
+-- ===========================================================================
+-- Weibull MLE
+-- ===========================================================================
+
+weibullSamples :: Int -> Double -> Double -> [Double]
+weibullSamples n k lam =
+  [ lam * (negate (log (1 - u))) ** (1 / k)
+  | u <- haltonU1 2 n
+  ]
+
+writeWeibullCSV :: FilePath -> [Double] -> IO ()
+writeWeibullCSV path xs = withFile path WriteMode $ \h -> do
+  hPutStrLn h "time"
+  mapM_ (hPutStrLn h . printf "%.10g") xs
+
+{-# NOINLINE fitWeibullPhantom #-}
+fitWeibullPhantom :: Int -> V.Vector Double -> Either T.Text Wei.WeibullFit
+fitWeibullPhantom _ = Wei.fitWeibullMLE
+
+benchWeibullMLE :: Int -> Double -> Double -> IO BenchRow
+benchWeibullMLE n trueK trueLam = do
+  let xs   = weibullSamples n trueK trueLam
+      vec  = V.fromList xs
+      name = "WeibullMLE_n" ++ show n
+  (medMs, result) <- timeitIO 7 forceFit (\i -> pure (fitWeibullPhantom i vec))
+  let (kErr, lamErr) = case result of
+        Right f -> ( abs (Wei.wfShape f - trueK) / trueK
+                   , abs (Wei.wfScale f - trueLam) / trueLam )
+        Left _  -> (1/0, 1/0)
+  pure BenchRow
+    { brSystem = "haskell", brSuite = "phase17", brName = name
+    , brTimeMs = medMs, brAccMain = kErr, brAccAux = lamErr
+    , brExtra = printf "trueK=%g trueLam=%g" trueK trueLam
+    }
+  where
+    forceFit (Right f) = Wei.wfShape f + Wei.wfScale f
+    forceFit (Left _)  = 0
+
+-- ===========================================================================
+-- MANOVA
+-- ===========================================================================
+
+generateManovaGroups :: Int -> Double -> [LA.Matrix Double]
+generateManovaGroups nPerGroup groupShift =
+  let centers = [(0, 0), (groupShift, 0), (0, groupShift)]
+      mkGroup (cx, cy) i0 =
+        LA.fromLists
+          [ [ cx + 2 * (QR.radicalInverse 2 (i0 + i) - 0.5)
+            , cy + 2 * (QR.radicalInverse 3 (i0 + i) - 0.5)
+            ]
+          | i <- [1 .. nPerGroup]
+          ]
+  in [ mkGroup c (g * 1000) | (g, c) <- zip [0..] centers ]
+
+writeManovaCSV :: FilePath -> [LA.Matrix Double] -> IO ()
+writeManovaCSV path groups = withFile path WriteMode $ \h -> do
+  hPutStrLn h "group,x1,x2"
+  let rows =
+        [ (g, x1, x2)
+        | (g, mat) <- zip [0 :: Int ..] groups
+        , row <- LA.toLists mat
+        , let [x1, x2] = row
+        ]
+  mapM_ (\(g, x1, x2) -> hPutStrLn h (printf "%d,%.10g,%.10g" g x1 x2)) rows
+
+{-# NOINLINE manovaPhantom #-}
+manovaPhantom :: Int -> [LA.Matrix Double] -> ST.TestResult
+manovaPhantom _ = ST.manova
+
+benchManova :: Int -> Double -> IO BenchRow
+benchManova nPerGroup groupShift = do
+  let groups = generateManovaGroups nPerGroup groupShift
+      name   = "MANOVA_3grp_n" ++ show nPerGroup
+  (medMs, result) <- timeitIO 7 forceTR (\i -> pure (manovaPhantom i groups))
+  let wilks = case ST.trEffect result of
+        Just (_, w) -> w
+        Nothing     -> 1/0
+  pure BenchRow
+    { brSystem = "haskell", brSuite = "phase17", brName = name
+    , brTimeMs = medMs, brAccMain = wilks, brAccAux = ST.trPValue result
+    , brExtra = printf "groupShift=%g 2vars" groupShift
+    }
+  where
+    forceTR tr = ST.trStatistic tr + ST.trPValue tr
+
+-- ===========================================================================
+-- Hotelling T² (1-sample)
+-- ===========================================================================
+
+-- | n × 2 の deterministic データ、 中心が (shift, shift)。
+generateHotellingData :: Int -> Double -> LA.Matrix Double
+generateHotellingData n shift =
+  LA.fromLists
+    [ [ shift + 2 * (QR.radicalInverse 2 i - 0.5)
+      , shift + 2 * (QR.radicalInverse 3 i - 0.5)
+      ]
+    | i <- [1 .. n]
+    ]
+
+writeHotellingCSV :: FilePath -> LA.Matrix Double -> IO ()
+writeHotellingCSV path mat = withFile path WriteMode $ \h -> do
+  hPutStrLn h "x1,x2"
+  mapM_ (\[x1, x2] -> hPutStrLn h (printf "%.10g,%.10g" x1 x2)) (LA.toLists mat)
+
+{-# NOINLINE hotellingPhantom #-}
+hotellingPhantom :: Int -> LA.Matrix Double -> LA.Vector Double -> ST.TestResult
+hotellingPhantom _ = ST.hotellingsT2
+
+benchHotelling :: Int -> Double -> IO BenchRow
+benchHotelling n shift = do
+  let mat = generateHotellingData n shift
+      mu0 = LA.fromList [0.0, 0.0]
+      name = "HotellingT2_n" ++ show n
+  (medMs, result) <- timeitIO 7 forceTR (\i -> pure (hotellingPhantom i mat mu0))
+  let t2 = case ST.trEffect result of
+        Just (_, w) -> w
+        Nothing     -> 1/0
+  pure BenchRow
+    { brSystem = "haskell", brSuite = "phase17", brName = name
+    , brTimeMs = medMs, brAccMain = t2, brAccAux = ST.trPValue result
+    , brExtra = printf "shift=%g 2vars mu0=(0,0)" shift
+    }
+  where
+    forceTR tr = ST.trStatistic tr + ST.trPValue tr
+
+-- ===========================================================================
+-- Lasso / Ridge λ CV
+-- ===========================================================================
+
+-- | LM-like data: y = X β_true + ε、 X は Halton 多次元、 ε は Halton-Box-Muller。
+generateLMData :: Int -> Int -> ([Double], LA.Matrix Double, LA.Vector Double)
+generateLMData n p =
+  let baseTrue = [2.0, 1.0, 0.5] ++ replicate (max 0 (p - 3)) 0.0
+      betaTrue = take p baseTrue
+      -- design matrix via Halton p-D
+      xMat = LA.fromLists
+        [ [ 2 * QR.radicalInverse (primes !! (j - 1)) i - 1
+          | j <- [1 .. p]
+          ]
+        | i <- [1 .. n]
+        ]
+      -- noise via Box-Muller from Halton (bases 11, 13)
+      noises =
+        [ let u1 = QR.radicalInverse 11 i
+              u2 = QR.radicalInverse 13 i
+          in 0.1 * sqrt (-2 * log (max 1e-10 u1)) * cos (2 * pi * u2)
+        | i <- [1 .. n]
+        ]
+      yVec = (xMat LA.#> LA.fromList betaTrue) + LA.fromList noises
+  in (betaTrue, xMat, yVec)
+  where
+    primes :: [Int]
+    primes = [ 2,  3,  5,  7, 11, 13, 17, 19, 23, 29
+             , 31, 37, 41, 43, 47, 53, 59, 61, 67, 71
+             , 73, 79, 83, 89, 97,101,103,107,109,113
+             ]
+
+writeLMCSV :: FilePath -> Int -> LA.Matrix Double -> LA.Vector Double -> IO ()
+writeLMCSV path p xMat yVec = withFile path WriteMode $ \h -> do
+  hPutStrLn h (intercalate "," ([ "x" ++ show j | j <- [1 .. p] ] ++ ["y"]))
+  let n = LA.rows xMat
+  mapM_ (\i ->
+            let xRow = LA.toLists (xMat LA.? [i]) !! 0
+                y    = yVec LA.! i
+                cells = [ printf "%.10g" x | x <- xRow ] ++ [ printf "%.10g" y ]
+            in hPutStrLn h (intercalate "," cells))
+        [0 .. n - 1]
+  where
+    intercalate sep = foldr1 (\a b -> a ++ sep ++ b)
+
+lambdaGrid :: [Double]
+lambdaGrid = [0.001, 0.01, 0.05, 0.1, 0.5, 1.0, 5.0]
+
+benchRegCV :: Reg.PenaltyKind -> String -> Int -> Int -> IO BenchRow
+benchRegCV kind tag n p = do
+  let (_betaTrue, xMat, yVec) = generateLMData n p
+      name = tag ++ "CV_n" ++ show n ++ "_p" ++ show p
+  -- selectLambdaCV is IO で MWC.GenIO 要なので、 fresh gen を使う
+  gen <- MWC.create
+  (medMs, sel) <- timeitIO 5 (\s -> Reg.lsBestLambda s)
+                              (\_ -> Reg.selectLambdaCV 5 kind lambdaGrid xMat yVec gen)
+  pure BenchRow
+    { brSystem = "haskell", brSuite = "phase17", brName = name
+    , brTimeMs = medMs, brAccMain = Reg.lsBestLambda sel
+    , brAccAux = Reg.lsOneSeLambda sel
+    , brExtra = printf "grid=%d folds=5" (length lambdaGrid)
+    }
+
+-- ===========================================================================
+-- SpaceFilling LHS / Halton
+-- ===========================================================================
+
+benchLHS :: Int -> Int -> IO BenchRow
+benchLHS n d = do
+  let name = "LHS_n" ++ show n ++ "_d" ++ show d
+  (medMs, sfd) <- timeitIO 5 (\s -> SF.sfdMinDist s)
+                              (\_ -> do
+                                  gen <- MWC.create
+                                  SF.latinHypercube n d gen)
+  pure BenchRow
+    { brSystem = "haskell", brSuite = "phase17", brName = name
+    , brTimeMs = medMs, brAccMain = SF.sfdMinDist sfd
+    , brAccAux = 0
+    , brExtra = "method=LHS"
+    }
+
+benchHalton :: Int -> Int -> IO BenchRow
+benchHalton n d = do
+  let name = "Halton_n" ++ show n ++ "_d" ++ show d
+      sfd  = SF.haltonDesign n d
+  (medMs, sfd2) <- timeitIO 5 (\s -> SF.sfdMinDist s)
+                               (\_ -> pure sfd)
+  let _ = sfd2
+  pure BenchRow
+    { brSystem = "haskell", brSuite = "phase17", brName = name
+    , brTimeMs = medMs, brAccMain = SF.sfdMinDist sfd
+    , brAccAux = 0
+    , brExtra = "method=Halton"
+    }
+
+-- ===========================================================================
+-- Augment Design (Haskell-only、 Python 等価なし)
+-- ===========================================================================
+
+benchAugment :: Int -> IO BenchRow
+benchAugment nNew = do
+  let cands = Opt.quadraticCandidates 2 3  -- 9 候補
+      existing =
+        [ [1, -1, -1, 1, 1,  1]
+        , [1,  1, -1, 1, 1, -1]
+        , [1, -1,  1, 1, 1, -1]
+        , [1,  0,  0, 0, 0,  0]
+        ]
+      name = "Augment_existing4_add" ++ show nNew
+  (medMs, res) <- timeitIO 7 (\r -> Opt.arFinalCrit r)
+                              (\_ -> pure (Opt.augmentDesign Opt.DOpt existing nNew cands 42))
+  pure BenchRow
+    { brSystem = "haskell", brSuite = "phase17", brName = name
+    , brTimeMs = medMs, brAccMain = Opt.arFinalCrit res
+    , brAccAux = Opt.arInitialCrit res
+    , brExtra = "no_python_equivalent"
+    }
+
+-- ===========================================================================
+-- DSD (Haskell-only)
+-- ===========================================================================
+
+benchDSD :: Int -> IO BenchRow
+benchDSD k = do
+  let name = "DSD_k" ++ show k
+  (medMs, res) <- timeitIO 7
+        (\eth -> case eth of
+            Right r -> fromIntegral (DSD.dsdNRuns r)
+            Left  _ -> 0)
+        (\_ -> pure (DSD.dsdDesign k))
+  let nRuns = case res of
+        Right r -> DSD.dsdNRuns r
+        Left  _ -> 0
+      hasOpt = case res of
+        Right r -> if DSD.dsdHasOptimal r then 1 else 0
+        Left  _ -> 0 :: Int
+  pure BenchRow
+    { brSystem = "haskell", brSuite = "phase17", brName = name
+    , brTimeMs = medMs, brAccMain = fromIntegral nRuns
+    , brAccAux = fromIntegral hasOpt
+    , brExtra = "no_python_equivalent"
+    }
+
+-- ===========================================================================
+-- SPC X̄-R (Haskell-only)
+-- ===========================================================================
+
+benchSPC :: Int -> IO BenchRow
+benchSPC nSubgroups = do
+  let subs = V.fromList
+        [ V.fromList
+            [ 10 + 0.5 * (QR.radicalInverse 2 (g * 5 + j) - 0.5)
+            | j <- [1 .. 5]
+            ]
+        | g <- [1 .. nSubgroups]
+        ]
+      name = "SPC_XR_subgroups" ++ show nSubgroups
+  (medMs, result) <- timeitIO 7 forceR (\_ -> pure (SPC.fitSPC SPC.XR (SPC.VarSubgroups subs)))
+  let center = case result of
+        Right (ch:_) -> SPC.spcCenter ch
+        _            -> 0
+  pure BenchRow
+    { brSystem = "haskell", brSuite = "phase17", brName = name
+    , brTimeMs = medMs, brAccMain = center
+    , brAccAux = 0
+    , brExtra = "no_python_equivalent subgroupSize=5"
+    }
+  where
+    forceR (Right (ch:_)) = SPC.spcCenter ch
+    forceR _              = 0
+
+-- ===========================================================================
+-- Main
+-- ===========================================================================
+
+main :: IO ()
+main = do
+  createDirectoryIfMissing True "bench/data"
+  createDirectoryIfMissing True "bench/results/haskell"
+
+  -- Weibull MLE
+  let trueK   = 2.0
+      trueLam = 10.0
+  mapM_ (\n ->
+            writeWeibullCSV (printf "bench/data/weibull_n%d.csv" n)
+                            (weibullSamples n trueK trueLam))
+        [100, 1000, 10000 :: Int]
+  weibullRows <- mapM (\n -> benchWeibullMLE n trueK trueLam)
+                      [100, 1000, 10000 :: Int]
+
+  -- MANOVA
+  let groupShift = 1.5
+  mapM_ (\nPer -> do
+            let groups = generateManovaGroups nPer groupShift
+            writeManovaCSV (printf "bench/data/manova_3grp_n%d.csv" nPer) groups)
+        [30, 100, 500 :: Int]
+  manovaRows <- mapM (\nPer -> benchManova nPer groupShift)
+                     [30, 100, 500 :: Int]
+
+  -- Hotelling T²
+  mapM_ (\n -> do
+            let mat = generateHotellingData n 0.5
+            writeHotellingCSV (printf "bench/data/hotelling_n%d.csv" n) mat)
+        [50, 200, 1000 :: Int]
+  hotellingRows <- mapM (\n -> benchHotelling n 0.5) [50, 200, 1000 :: Int]
+
+  -- Lasso / Ridge CV
+  mapM_ (\(n, p) -> do
+            let (_, xMat, yVec) = generateLMData n p
+            writeLMCSV (printf "bench/data/lm_n%d_p%d.csv" n p) p xMat yVec)
+        [(200, 10), (500, 20)]
+  lassoRows <- mapM (\(n, p) -> benchRegCV Reg.KindLasso "Lasso" n p)
+                    [(200, 10), (500, 20)]
+  ridgeRows <- mapM (\(n, p) -> benchRegCV Reg.KindRidge "Ridge" n p)
+                    [(200, 10), (500, 20)]
+
+  -- SpaceFilling
+  lhsRows    <- mapM (\(n, d) -> benchLHS n d) [(50, 2), (200, 3)]
+  haltonRows <- mapM (\(n, d) -> benchHalton n d) [(50, 2), (200, 3)]
+
+  -- Augment Design
+  augmentRows <- mapM benchAugment [2, 3, 4 :: Int]
+
+  -- DSD
+  dsdRows <- mapM benchDSD [4, 6, 8, 10 :: Int]
+
+  -- SPC
+  spcRows <- mapM benchSPC [10, 30, 100 :: Int]
+
+  writeRows "bench/results/haskell/phase17.csv"
+            (weibullRows ++ manovaRows ++ hotellingRows
+             ++ lassoRows ++ ridgeRows
+             ++ lhsRows ++ haltonRows
+             ++ augmentRows ++ dsdRows ++ spcRows)
+  putStrLn "✓ bench/results/haskell/phase17.csv written"
diff --git a/bench/haskell/BenchProfile.hs b/bench/haskell/BenchProfile.hs
--- a/bench/haskell/BenchProfile.hs
+++ b/bench/haskell/BenchProfile.hs
@@ -31,7 +31,7 @@
 import           Hanalyze.Model.GLM               (Family (..), LinkFn (..), fitGLMFull)
 import qualified Hanalyze.Model.Regularized       as Reg
 import           Hanalyze.Model.Regularized       (Penalty (..), rfBeta)
-import qualified Hanalyze.Model.Kernel            as Kn
+import qualified Hanalyze.Model.KernelRegression            as Kn
 import qualified Hanalyze.Stat.KernelDist         as KD
 
 import           BenchUtil               (readCsvXY)
diff --git a/bench/haskell/BenchRegrid.hs b/bench/haskell/BenchRegrid.hs
--- a/bench/haskell/BenchRegrid.hs
+++ b/bench/haskell/BenchRegrid.hs
@@ -9,7 +9,7 @@
 -- 出力: bench/results/haskell/regrid.csv
 module Main where
 
-import qualified DataFrame                  as DX
+import qualified DataFrame.Operations.Core     as DX
 import qualified Hanalyze.DataIO.Preprocess          as Pre
 import qualified Hanalyze.Stat.Interpolate           as IL
 import qualified Hanalyze.Stat.AdaptiveGrid          as AG
diff --git a/bench/haskell/BenchTasty.hs b/bench/haskell/BenchTasty.hs
--- a/bench/haskell/BenchTasty.hs
+++ b/bench/haskell/BenchTasty.hs
@@ -20,7 +20,7 @@
 import           Hanalyze.Model.GLM               (Family (..), LinkFn (..), fitGLMFull)
 import qualified Hanalyze.Model.Regularized       as Reg
 import           Hanalyze.Model.Regularized       (Penalty (..), rfBeta)
-import qualified Hanalyze.Model.Kernel            as Kn
+import qualified Hanalyze.Model.KernelRegression            as Kn
 import qualified Hanalyze.Stat.Cholesky           as Chol
 import qualified Hanalyze.Stat.KernelDist         as KD
 import           Data.Maybe              (fromMaybe)
diff --git a/bench/haskell/BenchTier12.hs b/bench/haskell/BenchTier12.hs
new file mode 100644
--- /dev/null
+++ b/bench/haskell/BenchTier12.hs
@@ -0,0 +1,563 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# OPTIONS_GHC -fno-full-laziness -fno-cse -Wno-incomplete-uni-patterns #-}
+-- | Phase 9-16 (Tier 1 + Tier 2) 機能の Python 比較ベンチ (Haskell 側)。
+--
+-- Python 比較対象あり:
+--   * PLS            → sklearn.cross_decomposition.PLSRegression
+--   * LDA / QDA      → sklearn.discriminant_analysis.{Linear,Quadratic}DA
+--   * HCluster Ward  → scipy.cluster.hierarchy.linkage(method='ward')
+--   * Friedman       → scipy.stats.friedmanchisquare
+--   * RFClassifier   → sklearn.ensemble.RandomForestClassifier
+--   * MLPRegressor   → sklearn.neural_network.MLPRegressor
+--
+-- Haskell-only (Python 直接等価なし or 重い依存):
+--   * TOST、 AFT (lifelines は重)、 EWMA / CUSUM、 GaugeRR、
+--     ProcessCapability 非正規、 DoE 診断、 I/E-optimal、 Kalman (statsmodels
+--     入れない方針)、 Fit Y by X (wrapper)
+--
+-- 共通入力 CSV は本ファイルで Halton 列から生成し bench/data/tier12_*.csv に
+-- 書き出す。 Python 側は同じ CSV を読む。
+module Main where
+
+import qualified Data.Text               as T
+import qualified Data.Vector             as V
+import qualified Data.Vector.Unboxed     as VU
+import qualified Numeric.LinearAlgebra   as LA
+import qualified System.Random.MWC       as MWC
+import           System.Directory        (createDirectoryIfMissing)
+import           System.IO               (withFile, IOMode (..), hPutStrLn)
+import           Text.Printf             (printf)
+
+import qualified Hanalyze.Model.PLS                    as PLS
+import qualified Hanalyze.Model.Discriminant           as Disc
+import qualified Hanalyze.Model.HierarchicalCluster    as HC
+import qualified Hanalyze.Model.AFT                    as AFT
+import qualified Hanalyze.Model.RandomForestClassifier as RFC
+import qualified Hanalyze.Model.NeuralNetwork          as NN
+import qualified Hanalyze.Model.StateSpace             as SS
+import qualified Hanalyze.Design.GaugeRR               as GRR
+import qualified Hanalyze.Design.Quality               as Quality
+import qualified Hanalyze.Design.Diagnostics           as DDiag
+import qualified Hanalyze.Design.Optimal               as Opt
+import qualified Hanalyze.Stat.SPC                     as SPC
+import qualified Hanalyze.Stat.Test                    as ST
+import qualified Hanalyze.Stat.QuasiRandom             as QR
+import qualified Hanalyze.Model.Weibull                as Wei
+
+import           BenchUtil
+
+-- ===========================================================================
+-- 共通 helper
+-- ===========================================================================
+
+-- 決定的 N(0,1)風列 (Box-Muller via Halton)
+gaussianHalton :: Int -> Int -> Int -> [Double]
+gaussianHalton primeU primeV n =
+  let us = [ QR.radicalInverse primeU i | i <- [1 .. n] ]
+      vs = [ QR.radicalInverse primeV i | i <- [1 .. n] ]
+  in zipWith (\u v -> sqrt (-2 * log (u + 1e-12)) * cos (2 * pi * v)) us vs
+
+writeMatrixCSV :: FilePath -> [String] -> [[Double]] -> IO ()
+writeMatrixCSV path header rows = withFile path WriteMode $ \h -> do
+  hPutStrLn h (commaJoin header)
+  mapM_ (\r -> hPutStrLn h (commaJoin (map (printf "%.10g") r))) rows
+  where
+    commaJoin :: [String] -> String
+    commaJoin = foldr1 (\a b -> a ++ "," ++ b)
+
+-- ===========================================================================
+-- PLS
+-- ===========================================================================
+
+-- y = x1 + 0.5*x2 (+ noise),  p = 10 (8 ノイズ列)
+plsData :: Int -> Int -> (LA.Matrix Double, LA.Vector Double)
+plsData n p =
+  let cols = [ LA.fromList (gaussianHalton (primeAt j) (primeAt (j + 1)) n)
+             | j <- [0 .. p - 1] ]
+      x    = LA.fromColumns cols
+      x0   = LA.flatten (x LA.¿ [0])
+      x1   = LA.flatten (x LA.¿ [1])
+      eps  = LA.fromList (gaussianHalton 19 23 n)
+      y    = x0 + LA.scale 0.5 x1 + LA.scale 0.1 eps
+  in (x, y)
+  where
+    primes :: [Int]
+    primes = [2,3,5,7,11,13,17,19,23,29,31,37,41,43]
+    primeAt :: Int -> Int
+    primeAt k = primes !! (k `mod` length primes)
+
+writePLSCSV :: FilePath -> LA.Matrix Double -> LA.Vector Double -> IO ()
+writePLSCSV path x y =
+  let p = LA.cols x
+      header = [ "x" ++ show j | j <- [0 .. p - 1] ] ++ ["y"]
+      rows = [ [ LA.atIndex x (i, j) | j <- [0 .. p - 1] ]
+                 ++ [LA.atIndex y i]
+             | i <- [0 .. LA.rows x - 1] ]
+  in writeMatrixCSV path header rows
+
+{-# NOINLINE plsPhantom #-}
+plsPhantom :: Int -> LA.Matrix Double -> LA.Vector Double -> Either String PLS.PLSFit
+plsPhantom _ x y = case PLS.fitPLS1 (PLS.defaultPLSConfig { PLS.plsN_Components = 3 }) x y of
+  Left e  -> Left (show e)
+  Right f -> Right f
+
+benchPLS :: Int -> Int -> IO BenchRow
+benchPLS n p = do
+  let (x, y) = plsData n p
+  (medMs, res) <- timeitIO 5 forceR (\i -> pure (plsPhantom i x y))
+  let nrmse = case res of
+        Right f ->
+          let yhat = PLS.predictPLS1 f x
+              d = yhat - y
+          in sqrt (LA.sumElements (d * d) / fromIntegral n)
+                / (LA.maxElement y - LA.minElement y + 1e-12)
+        Left _  -> 1/0
+  pure BenchRow
+    { brSystem = "haskell", brSuite = "tier12"
+    , brName = printf "PLS_n%d_p%d" n p
+    , brTimeMs = medMs, brAccMain = nrmse, brAccAux = 0
+    , brExtra = "k=3"
+    }
+  where
+    forceR (Right f) = LA.atIndex (PLS.plsCoef f) (0, 0)
+    forceR _         = 0
+
+-- ===========================================================================
+-- LDA / QDA
+-- ===========================================================================
+
+ldaData :: Int -> Int -> Int -> (LA.Matrix Double, V.Vector Int)
+ldaData nPerClass p k =
+  let totN = nPerClass * k
+      base = [ LA.fromList (gaussianHalton (primeAt j) (primeAt (j + 1)) totN)
+             | j <- [0 .. p - 1] ]
+      x0   = LA.fromColumns base
+      labels = V.fromList (concat [ replicate nPerClass c | c <- [0 .. k - 1] ])
+      -- shift by class on first 2 dims
+      shift i =
+        let c = labels V.! i
+        in LA.fromList ([fromIntegral c * 3, fromIntegral c * 2]
+                        ++ replicate (p - 2) 0)
+      shifted = LA.fromRows
+        [ LA.flatten (x0 LA.? [i]) + shift i | i <- [0 .. totN - 1] ]
+  in (shifted, labels)
+  where
+    primes :: [Int]
+    primes = [2,3,5,7,11,13,17,19,23,29]
+    primeAt :: Int -> Int
+    primeAt j = primes !! (j `mod` length primes)
+
+writeLDACSV :: FilePath -> LA.Matrix Double -> V.Vector Int -> IO ()
+writeLDACSV path x y =
+  let p = LA.cols x
+      n = LA.rows x
+      header = [ "x" ++ show j | j <- [0 .. p - 1] ] ++ ["class"]
+      rows = [ [ LA.atIndex x (i, j) | j <- [0 .. p - 1] ]
+                 ++ [fromIntegral (y V.! i)]
+             | i <- [0 .. n - 1] ]
+  in writeMatrixCSV path header rows
+
+{-# NOINLINE ldaPhantom #-}
+ldaPhantom :: Int -> LA.Matrix Double -> V.Vector Int -> Disc.DiscriminantFit
+ldaPhantom _ x y = case Disc.fitLDA x y of
+  Right f -> f
+  Left _  -> error "LDA fit failed"
+
+{-# NOINLINE qdaPhantom #-}
+qdaPhantom :: Int -> LA.Matrix Double -> V.Vector Int -> Disc.DiscriminantFit
+qdaPhantom _ x y = case Disc.fitQDA x y of
+  Right f -> f
+  Left _  -> error "QDA fit failed"
+
+benchLDA :: Int -> Int -> Int -> IO BenchRow
+benchLDA nPerClass p k = do
+  let (x, y) = ldaData nPerClass p k
+  (medMs, fit) <- timeitIO 5 (\f -> LA.atIndex (Disc.dfPriors f) 0)
+                              (\i -> pure (ldaPhantom i x y))
+  let (preds, _) = Disc.predictDiscriminant fit x
+      correct = length [ () | i <- [0 .. V.length y - 1], preds V.! i == y V.! i ]
+      acc = fromIntegral correct / fromIntegral (V.length y)
+  pure BenchRow
+    { brSystem = "haskell", brSuite = "tier12"
+    , brName = printf "LDA_n%d_p%d_k%d" (nPerClass * k) p k
+    , brTimeMs = medMs, brAccMain = acc, brAccAux = 0
+    , brExtra = ""
+    }
+
+benchQDA :: Int -> Int -> Int -> IO BenchRow
+benchQDA nPerClass p k = do
+  let (x, y) = ldaData nPerClass p k
+  (medMs, fit) <- timeitIO 5 (\f -> LA.atIndex (Disc.dfPriors f) 0)
+                              (\i -> pure (qdaPhantom i x y))
+  let (preds, _) = Disc.predictDiscriminant fit x
+      correct = length [ () | i <- [0 .. V.length y - 1], preds V.! i == y V.! i ]
+      acc = fromIntegral correct / fromIntegral (V.length y)
+  pure BenchRow
+    { brSystem = "haskell", brSuite = "tier12"
+    , brName = printf "QDA_n%d_p%d_k%d" (nPerClass * k) p k
+    , brTimeMs = medMs, brAccMain = acc, brAccAux = 0
+    , brExtra = ""
+    }
+
+-- ===========================================================================
+-- Hierarchical Cluster Ward
+-- ===========================================================================
+
+hcData :: Int -> LA.Matrix Double
+hcData n = fst (plsData n 5)
+
+writeHCData :: FilePath -> LA.Matrix Double -> IO ()
+writeHCData path x =
+  let p = LA.cols x
+      n = LA.rows x
+      header = [ "x" ++ show j | j <- [0 .. p - 1] ]
+      rows = [ [ LA.atIndex x (i, j) | j <- [0 .. p - 1] ]
+             | i <- [0 .. n - 1] ]
+  in writeMatrixCSV path header rows
+
+{-# NOINLINE hcPhantom #-}
+hcPhantom :: Int -> LA.Matrix Double -> HC.HClusterFit
+hcPhantom _ = HC.fitHierarchical HC.Ward
+
+benchHC :: Int -> IO BenchRow
+benchHC n = do
+  let x = hcData n
+  (medMs, fit) <- timeitIO 3 (\f -> head (HC.hcHeights f))
+                              (\i -> pure (hcPhantom i x))
+  pure BenchRow
+    { brSystem = "haskell", brSuite = "tier12"
+    , brName = printf "HClusterWard_n%d" n
+    , brTimeMs = medMs
+    , brAccMain = last (HC.hcHeights fit)
+    , brAccAux = 0
+    , brExtra = ""
+    }
+
+-- ===========================================================================
+-- Friedman
+-- ===========================================================================
+
+friedmanData :: Int -> LA.Matrix Double
+friedmanData nBlocks =
+  let xs = [ [ fromIntegral b + fromIntegral t * 0.5
+                 + (gaussianHalton (2 + t) (3 + t) nBlocks !! b) * 0.1
+             | t <- [0 .. 2] ]
+           | b <- [0 .. nBlocks - 1] ]
+  in LA.fromLists xs
+
+writeFriedmanCSV :: FilePath -> LA.Matrix Double -> IO ()
+writeFriedmanCSV path m =
+  let header = [ "t0", "t1", "t2" ]
+      rows = [ [ LA.atIndex m (i, j) | j <- [0 .. 2] ]
+             | i <- [0 .. LA.rows m - 1] ]
+  in writeMatrixCSV path header rows
+
+{-# NOINLINE friedmanPhantom #-}
+friedmanPhantom :: Int -> LA.Matrix Double -> ST.TestResult
+friedmanPhantom _ = ST.friedmanTest
+
+benchFriedman :: Int -> IO BenchRow
+benchFriedman n = do
+  let m = friedmanData n
+  (medMs, tr) <- timeitIO 7 ST.trStatistic (\i -> pure (friedmanPhantom i m))
+  pure BenchRow
+    { brSystem = "haskell", brSuite = "tier12"
+    , brName = printf "Friedman_n%d" n
+    , brTimeMs = medMs
+    , brAccMain = ST.trStatistic tr
+    , brAccAux = ST.trPValue tr
+    , brExtra = ""
+    }
+
+-- ===========================================================================
+-- RF Classifier
+-- ===========================================================================
+
+benchRFC :: Int -> Int -> Int -> IO BenchRow
+benchRFC n p k = do
+  gen <- MWC.create
+  let (x, y) = ldaData (n `div` k) p k
+      yU = VU.fromList (V.toList y)
+  (medMs, fit) <- timeitIO 3 RFC.rfcOOBError
+                              (\_ -> RFC.fitRFClassifier
+                                       (RFC.defaultRFCConfig { RFC.rfcNTrees = 50 })
+                                       x yU gen)
+  pure BenchRow
+    { brSystem = "haskell", brSuite = "tier12"
+    , brName = printf "RFC_n%d_p%d_k%d" n p k
+    , brTimeMs = medMs
+    , brAccMain = 1 - RFC.rfcOOBError fit
+    , brAccAux = 0
+    , brExtra = "trees=50"
+    }
+
+-- ===========================================================================
+-- MLP Regressor
+-- ===========================================================================
+
+benchMLP :: Int -> Int -> IO BenchRow
+benchMLP n p = do
+  gen <- MWC.create
+  let (x, y) = plsData n p
+      cfg = NN.defaultMLP
+              { NN.mlpHidden = [16]
+              , NN.mlpEpochs = 100
+              , NN.mlpBatch  = 16
+              , NN.mlpLR     = 0.01
+              }
+  (medMs, fit) <- timeitIO 3 (\f -> last (NN.mlpLossHist f))
+                              (\_ -> NN.fitMLPRegressor cfg x y gen)
+  let preds = LA.flatten (NN.predictMLP fit x)
+      d = preds - y
+      mse = LA.sumElements (d * d) / fromIntegral n
+  pure BenchRow
+    { brSystem = "haskell", brSuite = "tier12"
+    , brName = printf "MLPRegressor_n%d_p%d" n p
+    , brTimeMs = medMs
+    , brAccMain = mse
+    , brAccAux = 0
+    , brExtra = "hidden=16 epochs=100"
+    }
+
+-- ===========================================================================
+-- Haskell-only benches
+-- ===========================================================================
+
+benchTOST :: Int -> IO BenchRow
+benchTOST n = do
+  let xs = LA.fromList (take n (gaussianHalton 2 3 (n * 2)))
+      ys = LA.fromList (take n (drop n (gaussianHalton 2 3 (n * 2))))
+      delta = 0.5
+  (medMs, tr) <- timeitIO 7 ST.trPValue
+                              (\_ -> pure (ST.tostWelch xs ys delta))
+  pure BenchRow
+    { brSystem = "haskell", brSuite = "tier12"
+    , brName = printf "TOSTWelch_n%d" n
+    , brTimeMs = medMs
+    , brAccMain = ST.trPValue tr
+    , brAccAux = ST.trStatistic tr
+    , brExtra = "no_python_equivalent"
+    }
+
+benchAFT :: Int -> IO BenchRow
+benchAFT n = do
+  let ts = LA.fromList [ exp (1 + (gaussianHalton 5 7 n !! i) * 0.3)
+                       | i <- [0 .. n - 1] ]
+      delta_ = V.fromList (replicate n True)
+      x1 = LA.fromColumns [LA.fromList (replicate n 1.0)]
+  (medMs, r) <- timeitIO 3 (\_ -> 1.0)
+                            (\_ -> AFT.fitAFT AFT.AFTLogNormal x1 ts delta_)
+  let sc = case r of
+        Right f -> AFT.aftScale f
+        Left _  -> 1/0
+  pure BenchRow
+    { brSystem = "haskell", brSuite = "tier12"
+    , brName = printf "AFTLogNormal_n%d" n
+    , brTimeMs = medMs
+    , brAccMain = sc
+    , brAccAux = 0
+    , brExtra = "no_python_equivalent"
+    }
+
+{-# NOINLINE ewmaPhantom #-}
+ewmaPhantom :: Int -> V.Vector Double -> Either T.Text [SPC.SPCChartResult]
+ewmaPhantom _ xs = SPC.fitSPC SPC.EWMAChart (SPC.EWMAInput xs 0.2 3.0 0 1.0)
+
+benchEWMA :: Int -> IO BenchRow
+benchEWMA n = do
+  let xs = LA.fromList (gaussianHalton 11 13 n)
+      xsV = V.fromList (LA.toList xs)
+  (medMs, res) <- timeitIO 7 forceEWMA (\i -> pure (ewmaPhantom i xsV))
+  let lastZ = case res of
+        Right (ch:_) -> V.last (SPC.spcPoints ch)
+        _            -> 0
+  pure BenchRow
+    { brSystem = "haskell", brSuite = "tier12"
+    , brName = printf "EWMA_n%d" n
+    , brTimeMs = medMs, brAccMain = lastZ, brAccAux = 0
+    , brExtra = "no_python_equivalent"
+    }
+  where
+    forceEWMA (Right (ch:_)) = V.sum (SPC.spcPoints ch)
+    forceEWMA _              = 0
+
+{-# NOINLINE cusumPhantom #-}
+cusumPhantom :: Int -> V.Vector Double -> Either T.Text [SPC.SPCChartResult]
+cusumPhantom _ xs = SPC.fitSPC SPC.CUSUMChart (SPC.CUSUMInput xs 0 1.0 0.5 4.0)
+
+benchCUSUM :: Int -> IO BenchRow
+benchCUSUM n = do
+  let xs = LA.fromList (gaussianHalton 11 13 n)
+      xsV = V.fromList (LA.toList xs)
+  (medMs, res) <- timeitIO 7 forceCUSUM (\i -> pure (cusumPhantom i xsV))
+  let lastC = case res of
+        Right (cp:_) -> V.last (SPC.spcPoints cp)
+        _            -> 0
+  pure BenchRow
+    { brSystem = "haskell", brSuite = "tier12"
+    , brName = printf "CUSUM_n%d" n
+    , brTimeMs = medMs, brAccMain = lastC, brAccAux = 0
+    , brExtra = "no_python_equivalent"
+    }
+  where
+    forceCUSUM (Right (cp:_)) = V.sum (SPC.spcPoints cp)
+    forceCUSUM _              = 0
+
+{-# NOINLINE gaugeRRPhantom #-}
+gaugeRRPhantom :: Int -> V.Vector Int -> V.Vector Int -> V.Vector Double
+               -> Either T.Text GRR.GaugeRRResult
+gaugeRRPhantom _ ops parts ys = GRR.gaugeRRCrossed ops parts ys
+
+benchGaugeRR :: IO BenchRow
+benchGaugeRR = do
+  let parts = V.fromList (concat (replicate 9 [0, 1, 2]))
+      ops   = V.fromList (concatMap (\o -> replicate 9 o) [0, 1, 2])
+      ys    = V.fromList
+        [ fromIntegral (parts V.! i) * 2
+          + fromIntegral (ops V.! i) * 0.1
+          + (gaussianHalton 17 19 27 !! i) * 0.05
+        | i <- [0 .. 26] ]
+  (medMs, res) <- timeitIO 7 forceGRR
+                              (\i -> pure (gaugeRRPhantom i ops parts ys))
+  let pctGRR = case res of
+        Right r -> GRR.grrPctGRR r
+        _       -> 0
+  pure BenchRow
+    { brSystem = "haskell", brSuite = "tier12"
+    , brName = "GaugeRRCrossed_3p3o3r"
+    , brTimeMs = medMs, brAccMain = pctGRR, brAccAux = 0
+    , brExtra = "no_python_equivalent"
+    }
+  where
+    forceGRR (Right r) = GRR.grrTotalVar r + GRR.grrPartVar r
+    forceGRR _         = 0
+
+benchProcessCapWeibull :: IO BenchRow
+benchProcessCapWeibull = do
+  let wf = Wei.WeibullFit 2.0 100.0 0 0 0 (0, 0, 0)
+  (medMs, cap) <- timeitIO 100 Quality.capCp
+    (\_ -> pure (Quality.processCapabilityWeibull wf 10 300))
+  pure BenchRow
+    { brSystem = "haskell", brSuite = "tier12"
+    , brName = "ProcCapWeibull"
+    , brTimeMs = medMs, brAccMain = Quality.capCp cap, brAccAux = Quality.capCpk cap
+    , brExtra = "no_python_equivalent"
+    }
+
+benchDoEDiag :: IO BenchRow
+benchDoEDiag = do
+  let x = LA.fromLists
+            [ [1, x1, x2, x1 * x2, x1 * x1, x2 * x2]
+            | x1 <- [-1, 0, 1], x2 <- [-1, 0, 1] ]
+  (medMs, dd) <- timeitIO 30 DDiag.ddDEff
+                              (\_ -> pure (DDiag.diagnostics x))
+  pure BenchRow
+    { brSystem = "haskell", brSuite = "tier12"
+    , brName = "DoEDiagnostics_n9p6"
+    , brTimeMs = medMs
+    , brAccMain = DDiag.ddDEff dd
+    , brAccAux = DDiag.ddAEff dd
+    , brExtra = "no_python_equivalent"
+    }
+
+{-# NOINLINE optimalPhantom #-}
+optimalPhantom :: Int -> Opt.OptCriterion -> [[Double]] -> Int -> ([Int], [[Double]])
+optimalPhantom _ crit cands seed = Opt.optimalDesign crit cands 6 seed
+
+benchIEOptimal :: Opt.OptCriterion -> String -> IO BenchRow
+benchIEOptimal crit label = do
+  let cands = [ [1, x1, x2, x1 * x2] | x1 <- [-1, 0, 1], x2 <- [-1, 0, 1] ]
+  (medMs, (idxs, _)) <- timeitIO 5 forceOpt
+    (\i -> pure (optimalPhantom i crit cands (42 + i)))
+  pure BenchRow
+    { brSystem = "haskell", brSuite = "tier12"
+    , brName = label ++ "_n9_6"
+    , brTimeMs = medMs
+    , brAccMain = fromIntegral (sum idxs)
+    , brAccAux = 0
+    , brExtra = "no_python_equivalent"
+    }
+  where
+    forceOpt (idxs, _) = fromIntegral (sum idxs)
+
+benchKalman :: Int -> IO BenchRow
+benchKalman tT = do
+  let obs = LA.fromLists
+              [ [ fromIntegral i * 0.1 + (gaussianHalton 5 7 tT !! i) * 0.1
+                | i <- [0 .. tT - 1] ] ]
+      ssm = SS.StateSpaceModel
+        { SS.ssF  = LA.fromLists [[1]]
+        , SS.ssH  = LA.fromLists [[1]]
+        , SS.ssQ  = LA.fromLists [[0.01]]
+        , SS.ssR  = LA.fromLists [[0.1]]
+        , SS.ssX0 = LA.fromList  [0]
+        , SS.ssP0 = LA.fromLists [[1.0]]
+        }
+  (medMs, kr) <- timeitIO 5 SS.krLogLik
+                              (\_ -> pure (SS.kalmanFilter ssm obs))
+  pure BenchRow
+    { brSystem = "haskell", brSuite = "tier12"
+    , brName = printf "KalmanFilter_T%d" tT
+    , brTimeMs = medMs
+    , brAccMain = SS.krLogLik kr
+    , brAccAux = 0
+    , brExtra = "no_python_equivalent"
+    }
+
+-- ===========================================================================
+-- Main
+-- ===========================================================================
+
+main :: IO ()
+main = do
+  createDirectoryIfMissing True "bench/data"
+  createDirectoryIfMissing True "bench/results/haskell"
+
+  -- 共通 CSV を書き出し (Python 側が読む)
+  let plsParams = [(100, 10), (500, 10)]
+  mapM_ (\(n, p) -> do
+            let (x, y) = plsData n p
+            writePLSCSV (printf "bench/data/tier12_pls_n%d_p%d.csv" n p) x y)
+        plsParams
+
+  let ldaParams = [(30, 5, 3), (100, 5, 3)]
+  mapM_ (\(nc, p, k) -> do
+            let (x, y) = ldaData nc p k
+            writeLDACSV (printf "bench/data/tier12_lda_n%d_p%d_k%d.csv" (nc * k) p k)
+                        x y)
+        ldaParams
+
+  mapM_ (\n -> writeFriedmanCSV (printf "bench/data/tier12_friedman_n%d.csv" n)
+                                 (friedmanData n))
+        [10, 30, 100 :: Int]
+
+  mapM_ (\n -> writeHCData (printf "bench/data/tier12_hc_n%d.csv" n) (hcData n))
+        [20, 50 :: Int]
+
+  plsRows  <- mapM (\(n, p) -> benchPLS n p) plsParams
+  ldaRows  <- mapM (\(nc, p, k) -> benchLDA nc p k) ldaParams
+  qdaRows  <- mapM (\(nc, p, k) -> benchQDA nc p k) ldaParams
+  hcRows   <- mapM benchHC [20, 50 :: Int]
+  friRows  <- mapM benchFriedman [10, 30, 100 :: Int]
+  -- RFC は LDA と同じ data CSV を使うので n_total = nc*k を渡す
+  rfcRows  <- mapM (\(nc, p, k) -> benchRFC (nc * k) p k) ldaParams
+  mlpRows  <- mapM (\(n, p) -> benchMLP n p) plsParams
+  -- Haskell-only
+  tostRows <- mapM benchTOST [50, 200 :: Int]
+  aftRows  <- mapM benchAFT [50, 200 :: Int]
+  ewmaRows <- mapM benchEWMA [100, 500 :: Int]
+  cusRows  <- mapM benchCUSUM [100, 500 :: Int]
+  grrRow   <- benchGaugeRR
+  pcRow    <- benchProcessCapWeibull
+  diagRow  <- benchDoEDiag
+  iOptRow  <- benchIEOptimal Opt.IOpt "IOptimal"
+  eOptRow  <- benchIEOptimal Opt.EOpt "EOptimal"
+  kfRows   <- mapM benchKalman [50, 200 :: Int]
+
+  writeRows "bench/results/haskell/tier12.csv"
+    (concat [ plsRows, ldaRows, qdaRows, hcRows, friRows
+            , rfcRows, mlpRows
+            , tostRows, aftRows, ewmaRows, cusRows
+            , [grrRow, pcRow, diagRow, iOptRow, eOptRow]
+            , kfRows ])
+  putStrLn "✓ bench/results/haskell/tier12.csv written"
diff --git a/bench/haskell/BenchWarmupProf.hs b/bench/haskell/BenchWarmupProf.hs
new file mode 100644
--- /dev/null
+++ b/bench/haskell/BenchWarmupProf.hs
@@ -0,0 +1,140 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RankNTypes #-}
+{-# OPTIONS_GHC -fno-full-laziness -fno-cse #-}
+-- | Phase 85.6a: warmup 固定費の内訳プロファイル (radon)。
+--
+-- radon の wall は warmup 500 draw の固定費 (~3.3s = 6.6ms/draw) が支配し、
+-- 本サンプリング (~1.4ms/draw) の ~5 倍/draw。 本ドライバは 'nutsStream' の
+-- per-iteration callback ('seTreeDepth' = Phase 85.6 追加) で warmup 中の
+-- tree depth / ε の推移を採取し、 固定費の主因 (適応初期の深い tree?) を
+-- 確定する。 leapfrog 数 ≈ 2^depth で勾配評価回数を近似する。
+--
+--   cabal run bench-warmup-prof --project-file=cabal.project.plot -f benches
+module Main where
+
+import           Data.IORef                       (modifyIORef', newIORef,
+                                                   readIORef)
+import qualified Data.Map.Strict                  as Map
+import qualified Data.Text                        as T
+import qualified Data.Vector                      as V
+import qualified System.Random.MWC                as MWC
+import           System.Environment               (getArgs)
+import           Text.Printf                      (printf)
+
+import           Hanalyze.Model.HBM               (ModelP)
+import           Hanalyze.Fit                     (designHBMProgram)
+import           Hanalyze.MCMC.NUTS               (NUTSConfig (..),
+                                                   SampleEvent (..),
+                                                   defaultNUTSConfig,
+                                                   nutsStream)
+
+-- ---------------------------------------------------------------------------
+-- radon モデル (BenchHBMScaling と同一)
+-- ---------------------------------------------------------------------------
+
+readRadon :: IO ([[Double]], [Int], [Double], [Double])
+readRadon = do
+  txt <- readFile "bench/data/radon.csv"
+  let recs = map parseRow (drop 1 (lines txt))
+      parseRow ln = case splitComma ln of
+        (_c : ci : fl : lr : lu : _) ->
+          (read ci :: Int, read fl :: Double, read lr :: Double, read lu :: Double)
+        _ -> error ("readRadon: 列不足 " ++ ln)
+      cidx    = [ c | (c, _, _, _) <- recs ]
+      floors  = [ f | (_, f, _, _) <- recs ]
+      ys      = [ y | (_, _, y, _) <- recs ]
+      designX = [ [1.0, f, u] | (_, f, _, u) <- recs ]
+  return (designX, cidx, floors, ys)
+
+splitComma :: String -> [String]
+splitComma s = case break (== ',') s of
+  (a, ',' : rest) -> a : splitComma rest
+  (a, _)          -> [a]
+
+radonModel :: [[Double]] -> [Int] -> [Double] -> [Double] -> ModelP ()
+radonModel designX cidx floorCol ys =
+  designHBMProgram designX ["(Intercept)", "floor", "uranium"]
+                   [(cidx, nCounties, [floorCol])] ys
+  where nCounties = if null cidx then 0 else maximum cidx + 1
+
+warmupN, sampleN :: Int
+warmupN = 500
+sampleN = 100  -- 既定。 第 2 引数で上書き可 (Phase 87.2 profiling 用)
+
+mkConfig :: Int -> NUTSConfig
+mkConfig nSamp = defaultNUTSConfig
+  { nutsIterations    = nSamp
+  , nutsBurnIn        = warmupN
+  , nutsStepSize      = 0.1
+  , nutsMaxDepth      = 10
+  , nutsAdaptStepSize = True
+  , nutsTargetAccept  = 0.8
+  , nutsAdaptMass     = True
+  }
+
+main :: IO ()
+main = do
+  -- Phase 86 着手時計測: seed 分散を見るため第 1 引数で seed 指定可 (既定 42)。
+  args <- getArgs
+  let seed = case args of { (s : _) -> read s; [] -> 42 } :: Int
+      nSamp = case args of { (_ : n : _) -> read n; _ -> sampleN } :: Int
+  printf "=== Phase 85.6a: radon warmup 内訳プロファイル (seed=%d) ===\n" seed
+  (dX, cidx, fl, ys) <- readRadon
+  let m :: ModelP ()
+      m = radonModel dX cidx fl ys
+      initP = Map.fromList
+        [ ("(Intercept)", 1.3), ("floor", -0.6), ("uranium", 0.7)
+        , ("sigma", 0.7)
+        , ("tau_g0_0", 0.5), ("tau_g0_1", 0.3), ("Lcorr_g0_u1_0", 0.5) ]
+  evRef <- newIORef ([] :: [(Int, Bool, Int, Double, Double)])
+  g <- MWC.initialize (V.singleton (fromIntegral seed))
+  _ <- nutsStream m (mkConfig nSamp) initP g $ \ev ->
+    modifyIORef' evRef
+      ((seIter ev, seIsBurnIn ev, seTreeDepth ev, seStepSize ev, seAcceptStat ev) :)
+  evs <- fmap reverse (readIORef evRef)
+
+  let leap d = (2 :: Int) ^ d
+      warm = [ e | e@(_, True,  _, _, _) <- evs ]
+      samp = [ e | e@(_, False, _, _, _) <- evs ]
+      -- Stan windows (W=500): init buffer 75・windows 75-450・term 450-500
+      seg lo hi = [ (d, eps, al) | (i, _, d, eps, al) <- warm, i >= lo, i < hi ]
+      segs = [ ("init buf   [  0, 75)", seg 0 75)
+             , ("window 25  [ 75,100)", seg 75 100)
+             , ("window 50  [100,150)", seg 100 150)
+             , ("window 100 [150,250)", seg 150 250)
+             , ("window 200 [250,450)", seg 250 450)
+             , ("term buf   [450,500)", seg 450 500) ]
+      stats xs =
+        let ds = [ d | (d, _, _) <- xs ]
+            n  = max 1 (length ds)
+            meanD = fromIntegral (sum ds) / fromIntegral n :: Double
+            lps   = sum (map leap ds)
+            epsL  = case xs of { [] -> 0 / 0; _ -> (\(_, e, _) -> e) (last xs) }
+            meanA = sum [ a | (_, _, a) <- xs ] / fromIntegral n
+        in (meanD, maximum (0 : ds), lps, epsL, meanA)
+  putStrLn "\n--- warmup 区間別 (depth 平均 / 最大 / leapfrog 総数 / 区間末ε / 平均α) ---"
+  mapM_ (\(tag, xs) ->
+    let (md, mx, lp, eps, ma) = stats xs
+    in printf "  %-22s depth=%5.2f max=%2d  leapfrogs=%7d  ε=%.4g  α=%.3f\n"
+         (tag :: String) md mx lp eps ma) segs
+
+  let (mdS, mxS, lpS, epsS, maS) = stats [ (d, e, a) | (_, _, d, e, a) <- samp ]
+      lpW = sum [ leap d | (_, _, d, _, _) <- warm ]
+  printf "\n--- sampling %d draw: depth=%.2f max=%d leapfrogs=%d ε̄=%.4g α=%.3f ---\n"
+    nSamp mdS mxS lpS epsS maS
+  printf "\nleapfrog 総数: warmup=%d (%.1f/draw)  sampling=%d (%.1f/draw)\n"
+    lpW (fromIntegral lpW / fromIntegral warmupN :: Double)
+    lpS (fromIntegral lpS / fromIntegral nSamp :: Double)
+  printf "warmup 支配率 (grad 評価ベース) = %.1f%%\n"
+    (100 * fromIntegral lpW / fromIntegral (lpW + lpS) :: Double)
+  -- ε 推移の先頭 20 draw (初期 ε=0.1 の適否)
+  putStrLn "\n--- 先頭 20 draw の (depth, ε, α) ---"
+  mapM_ (\(i, _, d, eps, al) -> printf "  iter=%3d depth=%2d ε=%.5f α=%.3f\n" i d eps al)
+        (take 20 evs)
+  -- Phase 87.1: term buffer の DA 収束 trace (最終 window 末 450 の再較正 anchor
+  -- から ε がどう動き、 ε̄ がどこに着地するかの診断)。
+  putStrLn "\n--- term buffer [445,500) + sampling 先頭 5 の (depth, ε, α) ---"
+  mapM_ (\(i, b, d, eps, al) ->
+          printf "  iter=%3d %s depth=%2d ε=%.5f α=%.3f\n"
+            i (if b then "W" else "S" :: String) d eps al)
+        [ e | e@(i, _, _, _, _) <- evs, i >= 445, i < 505 ]
diff --git a/bench/haskell/ProfNUTS.hs b/bench/haskell/ProfNUTS.hs
new file mode 100644
--- /dev/null
+++ b/bench/haskell/ProfNUTS.hs
@@ -0,0 +1,245 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RankNTypes #-}
+-- | NUTS コストセンタ・プロファイル用の最小実行体 (Phase 53 追加調査)。
+--
+-- 引数でモデルを選び 1 chain (warmup 500 + 800 draws) を走らせ、
+-- @+RTS -p@ で `prof-nuts.prof` を吐かせ per-draw ボトルネックを局在化する:
+--   m2 (既定) = 階層 random intercept (glmm helper・高速経路の代表)
+--   m3        = random intercept+slope per-obs 手書き (中間: u は REff 昇格・
+--               v は dense 列 + v-prior が residual ad walk 残留)
+--   m5        = パラメタ非線形 a·exp(-b·x)+c per-obs 手書き (54.8 合成不可 =
+--               walk + ad fallback。 Phase 54.9 の本命)
+--   m6        = 階層 × 非線形 a_g·exp(-b·x) per-obs 手書き (M5 と同型 fallback・
+--               差分 = 階層 prior。 54.9 では差分確認のみ)
+--   m7        = Poisson 回帰 exp(a+b·x) per-obs 手書き (非 Gaussian 観測 =
+--               高速経路対象外。 Phase 55.1 の支配項確定用)
+--   m8        = logistic 回帰 invLogit(a+b·x) per-obs 手書き (同上)
+-- モデル定義・DGP は `BenchHBMScaling.hs` と同一 (seed も同じ・CSV は書かない)。
+module Main where
+
+import           Control.Monad                    (forM_)
+import qualified Data.Map.Strict                  as Map
+import qualified Data.Text                        as T
+import qualified Data.Vector                      as V
+import           System.Environment               (getArgs)
+import qualified System.Random.MWC                as MWC
+import           System.Random.MWC.Distributions  (standard)
+
+import           Hanalyze.Model.HBM               (Distribution (..), ModelP,
+                                                   sample, observe, sampleDist,
+                                                   glmmRandomIntercept,
+                                                   GlmmFamily (..))
+import           Hanalyze.MCMC.Core               (chainVals, posteriorMean)
+import           Hanalyze.MCMC.NUTS               (NUTSConfig (..),
+                                                   defaultNUTSConfig, nuts)
+
+-- ---------------------------------------------------------------------------
+-- DGP (BenchHBMScaling.hs と同一 seed・同一式)
+-- ---------------------------------------------------------------------------
+
+normals :: Int -> Int -> IO [Double]
+normals seed k = do
+  g <- MWC.initialize (V.singleton (fromIntegral seed))
+  mapM (const (standard g)) [1 .. k]
+
+nGroups, perGroup :: Int
+nGroups  = 8
+perGroup = 12
+
+genM2 :: IO ([[Double]], [Int], [Double])
+genM2 = do
+  let n = nGroups * perGroup
+  xz <- normals 21 n
+  ez <- normals 22 n
+  uz <- normals 23 nGroups
+  let us   = map (* 1.5) uz
+      gids = [ i `div` perGroup | i <- [0 .. n - 1] ]
+      xs   = map (* 2.0) xz
+      ys   = [ 1.0 + 0.8 * x + (us !! g) + e | (x, g, e) <- zip3 xs gids ez ]
+      xRows = [ [1.0, x] | x <- xs ]
+  return (xRows, gids, ys)
+
+genM3 :: IO ([Double], [Int], [Double])
+genM3 = do
+  let (b0, b1, tauU, tauV, s) = (1.0, 0.8, 1.0, 0.5, 1.0)
+      n = nGroups * perGroup
+  xz <- normals 31 n
+  ez <- normals 32 n
+  uz <- normals 33 nGroups
+  vz <- normals 34 nGroups
+  let us   = map (* tauU) uz
+      vs   = map (* tauV) vz
+      gids = [ i `div` perGroup | i <- [0 .. n - 1] ]
+      xs   = map (* 2.0) xz
+      ys   = [ b0 + b1 * x + (us !! g) + (vs !! g) * x + s * e
+             | (x, g, e) <- zip3 xs gids ez ]
+  return (xs, gids, ys)
+
+genM5 :: IO ([Double], [Double])
+genM5 = do
+  let (a, b, c, s) = (2.5, 1.2, 0.5, 0.3)
+      nM5 = 100 :: Int
+  ez <- normals 51 nM5
+  let xs = [ 3.0 * (fromIntegral i + 0.5) / fromIntegral nM5
+           | i <- [0 .. nM5 - 1] ]
+      ys = [ a * exp (negate b * x) + c + s * e | (x, e) <- zip xs ez ]
+  return (xs, ys)
+
+genM6 :: IO ([Double], [Int], [Double])
+genM6 = do
+  let (muA, tauA, b, s) = (2.0, 0.5, 1.0, 0.3)
+      n = nGroups * perGroup
+  ez <- normals 61 n
+  az <- normals 62 nGroups
+  let as   = [ muA + tauA * z | z <- az ]
+      gids = [ i `div` perGroup | i <- [0 .. n - 1] ]
+      xs   = [ 3.0 * (fromIntegral (i `mod` perGroup) + 0.5)
+                   / fromIntegral perGroup
+             | i <- [0 .. n - 1] ]
+      ys   = [ (as !! g) * exp (negate b * x) + s * e
+             | (x, g, e) <- zip3 xs gids ez ]
+  return (xs, gids, ys)
+
+genM7 :: IO ([Double], [Double])
+genM7 = do
+  let (a, b) = (0.5, 0.8)
+      nM7 = 100 :: Int
+  xs <- normals 71 nM7
+  g  <- MWC.initialize (V.singleton 72)
+  ys <- mapM (\x -> sampleDist (Poisson (exp (a + b * x))) g) xs
+  return (xs, ys)
+
+genM8 :: IO ([Double], [Double])
+genM8 = do
+  let (a, b) = (0.3, 1.2)
+      nM8 = 100 :: Int
+  xs <- normals 81 nM8
+  g  <- MWC.initialize (V.singleton 82)
+  ys <- mapM (\x -> sampleDist
+                      (Bernoulli (1 / (1 + exp (negate (a + b * x))))) g) xs
+  return (xs, ys)
+
+-- ---------------------------------------------------------------------------
+-- モデル定義 (BenchHBMScaling.hs と同一)
+-- ---------------------------------------------------------------------------
+
+m2Model :: [[Double]] -> [Int] -> [Double] -> ModelP ()
+m2Model xRows gids ys = glmmRandomIntercept GlmmGaussian xRows gids ys
+
+m3Model :: [Double] -> [Int] -> [Double] -> ModelP ()
+m3Model xs gids ys = do
+  let nG = if null gids then 0 else maximum gids + 1
+  b0 <- sample "beta_0" (Normal 0 5)
+  b1 <- sample "beta_1" (Normal 0 5)
+  tu <- sample "tau_u"  (HalfNormal 5)
+  tv <- sample "tau_v"  (HalfNormal 5)
+  us <- mapM (\j -> sample (T.pack ("u_" ++ show j)) (Normal 0 tu)) [0 .. nG - 1]
+  vs <- mapM (\j -> sample (T.pack ("v_" ++ show j)) (Normal 0 tv)) [0 .. nG - 1]
+  s  <- sample "sigma" (Exponential 1)
+  forM_ (zip3 [0 :: Int ..] (zip xs gids) ys) $ \(i, (x, g), y) ->
+    observe (T.pack ("y_" ++ show i))
+      (Normal (b0 + b1 * realToFrac x + us !! g + (vs !! g) * realToFrac x) s) [y]
+
+m6Model :: [Double] -> [Int] -> [Double] -> ModelP ()
+m6Model xs gids ys = do
+  let nG = if null gids then 0 else maximum gids + 1
+  muA  <- sample "mu_a"  (Normal 0 10)
+  tauA <- sample "tau_a" (HalfNormal 2)
+  as   <- mapM (\j -> sample (T.pack ("a_" ++ show j)) (Normal muA tauA))
+               [0 .. nG - 1]
+  b    <- sample "b" (HalfNormal 2)
+  s    <- sample "sigma" (Exponential 1)
+  forM_ (zip3 [0 :: Int ..] (zip xs gids) ys) $ \(i, (x, g), y) ->
+    observe (T.pack ("y_" ++ show i))
+      (Normal ((as !! g) * exp (negate b * realToFrac x)) s) [y]
+
+m5Model :: [Double] -> [Double] -> ModelP ()
+m5Model xs ys = do
+  a <- sample "a" (Normal 0 10)
+  b <- sample "b" (HalfNormal 2)
+  c <- sample "c" (Normal 0 10)
+  s <- sample "sigma" (Exponential 1)
+  forM_ (zip3 [0 :: Int ..] xs ys) $ \(i, x, y) ->
+    observe (T.pack ("y_" ++ show i))
+      (Normal (a * exp (negate b * realToFrac x) + c) s) [y]
+
+m7Model :: [Double] -> [Double] -> ModelP ()
+m7Model xs ys = do
+  a <- sample "a" (Normal 0 5)
+  b <- sample "b" (Normal 0 5)
+  forM_ (zip3 [0 :: Int ..] xs ys) $ \(i, x, y) ->
+    observe (T.pack ("y_" ++ show i))
+      (Poisson (exp (a + b * realToFrac x))) [y]
+
+m8Model :: [Double] -> [Double] -> ModelP ()
+m8Model xs ys = do
+  a <- sample "a" (Normal 0 5)
+  b <- sample "b" (Normal 0 5)
+  forM_ (zip3 [0 :: Int ..] xs ys) $ \(i, x, y) ->
+    observe (T.pack ("y_" ++ show i))
+      (Bernoulli (1 / (1 + exp (negate (a + b * realToFrac x))))) [y]
+
+-- ---------------------------------------------------------------------------
+-- 実行 (warmup 500 + 800 draws・seed 42・BenchHBMScaling の mkConfig と同一)
+-- ---------------------------------------------------------------------------
+
+profConfig :: NUTSConfig
+profConfig = defaultNUTSConfig
+  { nutsIterations = 800, nutsBurnIn = 500
+  , nutsStepSize = 0.1, nutsMaxDepth = 10
+  , nutsAdaptStepSize = True, nutsTargetAccept = 0.8, nutsAdaptMass = True }
+
+groupNames :: String -> [T.Text]
+groupNames pre = [ T.pack (pre ++ show j) | j <- [0 .. nGroups - 1] ]
+
+runProf :: ModelP () -> Map.Map T.Text Double -> [T.Text] -> IO ()
+runProf mdl initP names = do
+  gen <- MWC.initialize (V.singleton 42)
+  ch <- nuts mdl profConfig initP gen
+  -- 全 chain を force (プロファイル対象を確実に評価)
+  let s = sum [ maybe 0 id (posteriorMean p ch) | p <- names ]
+          + sum (map (\p -> sum (chainVals p ch)) names) * 0
+  print (s :: Double)
+
+main :: IO ()
+main = do
+  args <- getArgs
+  let which = case args of { (w : _) -> w; [] -> "m2" }
+  case which of
+    "m3" -> do
+      (xs, gids, ys) <- genM3
+      let initP = Map.fromList $
+            [ ("beta_0", 1.0), ("beta_1", 0.8), ("tau_u", 1.0), ("tau_v", 0.5)
+            , ("sigma", 1.0) ]
+            ++ [ (u, 0.0) | u <- groupNames "u_" ++ groupNames "v_" ]
+          names = ["beta_0", "beta_1", "tau_u", "tau_v", "sigma"]
+                  ++ groupNames "u_" ++ groupNames "v_"
+      runProf (m3Model xs gids ys) initP names
+    "m6" -> do
+      (xs, gids, ys) <- genM6
+      let initP = Map.fromList $
+            [ ("mu_a", 2.0), ("tau_a", 0.5), ("b", 1.0), ("sigma", 0.3) ]
+            ++ [ (a, 2.0) | a <- groupNames "a_" ]
+          names = ["mu_a", "tau_a", "b", "sigma"] ++ groupNames "a_"
+      runProf (m6Model xs gids ys) initP names
+    "m5" -> do
+      (xs, ys) <- genM5
+      let initP = Map.fromList
+            [("a", 2.5), ("b", 1.2), ("c", 0.5), ("sigma", 0.3)]
+          names = ["a", "b", "c", "sigma"]
+      runProf (m5Model xs ys) initP names
+    "m7" -> do
+      (xs, ys) <- genM7
+      runProf (m7Model xs ys)
+        (Map.fromList [("a", 0.5), ("b", 0.8)]) ["a", "b"]
+    "m8" -> do
+      (xs, ys) <- genM8
+      runProf (m8Model xs ys)
+        (Map.fromList [("a", 0.3), ("b", 1.2)]) ["a", "b"]
+    _ -> do
+      (xRows, gids, ys) <- genM2
+      let initP = Map.fromList $
+            [ ("beta_0", 1.0), ("beta_1", 0.8), ("tau_u", 1.5), ("sigma", 1.0) ]
+            ++ [ (u, 0.0) | u <- groupNames "u_" ]
+          names = ["beta_0", "beta_1", "tau_u", "sigma"] ++ groupNames "u_"
+      runProf (m2Model xRows gids ys) initP names
diff --git a/bench/posteriordb/01-glm-poisson/Model.hs b/bench/posteriordb/01-glm-poisson/Model.hs
new file mode 100644
--- /dev/null
+++ b/bench/posteriordb/01-glm-poisson/Model.hs
@@ -0,0 +1,103 @@
+{-# LANGUAGE OverloadedStrings #-}
+-- | GLM_Poisson_Data-GLM_Poisson_model (posteriordb) — hanalyze (ModelP) 実装。
+--
+-- Phase 89: posteriordb 横断ベンチマーク。BPA (Kery & Schaub 2011, Ch.3) の
+-- 個体数カウントデータ (n=40年) を3次多項式 Poisson 回帰でモデル化する。
+-- Stan 原典の "暗黙の一様事前分布" (bounded, no explicit prior) を
+-- 'Uniform' distribution で忠実に移植する。
+--
+-- 高レベル API (`df |-> hbm`) を使用: データは 'dataNamedX'/'dataNamedObs' で
+-- df から束縛し、 反復は 'plateForM_' で書く (docs/api-guide/03-bayesian-hbm.md
+-- の規約どおり)。 診断図は hgg の 'dashboardFullOf' (構造 DAG /
+-- forest / PPC / energy の 2x2 + param ごと [事後分布|trace]) を 1 枚の PNG
+-- (rasterific backend) として出力する (PyMC 側の合成ダッシュボード
+-- `_common.make_pymc_dashboard` と対)。 chain 数等の設定は PyMC 側
+-- (`model.py`) と同じくコード中の定数 (= 4) で揃える (CLI 引数化しない)。
+--
+-- reference_posterior_name = null (posteriordb に公式 reference posterior 無し)。
+--
+-- ビルド: cabal build --project-file=cabal.project.plot posteriordb-glm-poisson
+module Main (main) where
+
+import Data.Aeson (FromJSON (..), withObject, (.:), eitherDecodeFileStrict)
+import qualified Data.Text as T
+import qualified Data.Vector as V
+
+import Hanalyze.Model.HBM (ModelP, Distribution (..), sample, observe,
+                                    dataNamedX, dataNamedObs, plateForM_)
+import Hanalyze.Model.HBM (gradPathLabel)
+import Hanalyze.Plot (hbmModelSpec)
+import Hanalyze.Plot (HBMConfig (..), defaultHBM, hbm, (|->),
+                              dashboardFullOf, hbmChainsR)
+import Hgg.Plot.Spec (ColData (..))
+import Hgg.Plot.Frame (BoundPlot, (|>>))
+import Hgg.Plot.Backend.Rasterific (savePNGBound)
+
+import Common (summarize, printSummary)
+
+-- | posteriordb の @GLM_Poisson_Data.json@ 形状 ({"year":[...], "C":[...], "n":40})。
+data GlmPoissonData = GlmPoissonData
+  { year :: [Double]
+  , c    :: [Int]
+  }
+
+instance FromJSON GlmPoissonData where
+  parseJSON = withObject "GlmPoissonData" $ \v ->
+    GlmPoissonData <$> v .: "year" <*> v .: "C"
+
+noDf :: [(T.Text, ColData)]
+noDf = []
+
+dataPath :: FilePath
+dataPath = "bench/posteriordb/01-glm-poisson/data/GLM_Poisson_Data.json"
+
+figuresDir :: FilePath
+figuresDir = "bench/posteriordb/01-glm-poisson/figures"
+
+readData :: IO ([Double], [Int])
+readData = do
+  d <- either fail pure =<< eitherDecodeFileStrict dataPath
+  pure (year d, c d)
+
+-- | 3次多項式 Poisson 回帰 (Stan 原典と同一構造)。 データは df 経由で束縛
+-- ('dataNamedX'/'dataNamedObs')・反復は 'plateForM_' (docs/api-guide 規約)。
+glmPoissonModel :: ModelP ()
+glmPoissonModel = do
+  alpha <- sample "alpha" (Uniform (-20) 20)
+  beta1 <- sample "beta1" (Uniform (-10) 10)
+  beta2 <- sample "beta2" (Uniform (-10) 10)
+  beta3 <- sample "beta3" (Uniform (-10) 10)
+  years <- dataNamedX   "year" []
+  cs    <- dataNamedObs "C"    []
+  plateForM_ "obs" (zip years cs) $ \(y, c) ->
+    let y2 = y * y
+        y3 = y2 * y
+        logLambda = alpha + beta1 * y + beta2 * y2 + beta3 * y3
+    in observe "C" (Poisson (exp logLambda)) [c]
+
+main :: IO ()
+main = do
+  (years, cs) <- readData
+  let df = [ ("year", NumData (V.fromList years))
+           , ("C",    NumData (V.fromList (map fromIntegral cs)))
+           ] :: [(T.Text, ColData)]
+      -- PyMC 側 (model.py) と同じ設定を定数で揃える (draws/tune=1000×4chain)。
+      cfg = defaultHBM { hbmChains = 4, hbmSamples = 1000
+                        , hbmWarmup = 1000, hbmSeed = Just 1 }
+      m = df |-> hbm cfg glmPoissonModel
+
+  -- Phase 96 A2: 勾配経路 = compileGradUV が実際に選ぶ経路 (束縛済
+  -- hbmModelSpec で判定・Phase 91 A4 と同型)。
+  putStrLn $ "勾配経路 = " ++ gradPathLabel (hbmModelSpec m)
+
+  -- 診断図 (hgg・PNG・SVG は param 数×draw 数でファイルが重くなる
+  -- ため rasterific backend を使う)。 dashboardFullOf 1 枚 (構造+推定+PPC+
+  -- 健全性の 2x2 + param ごと [事後分布|trace])。 PyMC 側の
+  -- py_dashboard_full.png と対 (trace は dashboardFullOf に必ず含まれる)。
+  -- figures/ は事前に用意されている前提 (git 管理下・新規モデル作成時に
+  -- 1 度だけ作る) — 実行のたびに作り直さない。
+  savePNGBound (figuresDir ++ "/hs_dashboard_full.png") $
+    (noDf |>> dashboardFullOf m "C" :: BoundPlot)
+
+  -- 事後要約 (az.summary 相当・Common.summarize)。
+  printSummary $ summarize ["alpha", "beta1", "beta2", "beta3"] (hbmChainsR m)
diff --git a/bench/posteriordb/02-dogs/Model.hs b/bench/posteriordb/02-dogs/Model.hs
new file mode 100644
--- /dev/null
+++ b/bench/posteriordb/02-dogs/Model.hs
@@ -0,0 +1,122 @@
+{-# LANGUAGE OverloadedStrings #-}
+-- | dogs-dogs (posteriordb) — hanalyze (ModelP) 実装。
+--
+-- Phase 89: posteriordb 横断ベンチマーク。Solomon & Wynne (1953) の犬の
+-- 回避学習実験 (30 匹 × 25 試行、Gelman & Hill 2006 Ch.24 の ARM 本例) を、
+-- 累積の回避/被ショック回数を共変量とするロジスティック回帰でモデル化する。
+-- Stan 原典の transformed parameters (n_avoid/n_shock) は beta 非依存の
+-- 純粋な y (観測データ) の累積和なので、サンプリング前の前処理として
+-- 1 度だけ計算する (Stan は反復ごとに再計算するが数学的には同一)。
+--
+-- 高レベル API (`df |-> hbm`) を使用: データは 'dataNamedX'/'dataNamedObs' で
+-- df から束縛し、 反復は 'plateForM_' で書く (docs/api-guide/03-bayesian-hbm.md
+-- の規約どおり)。 診断図は hgg の 'dashboardFullOf' を 1 枚の PNG
+-- (rasterific backend) として出力する。 chain 数等の設定は PyMC 側
+-- (`model.py`) と同じくコード中の定数 (= 4) で揃える (CLI 引数化しない)。
+--
+-- reference_posterior_name = null (posteriordb に公式 reference posterior 無し)。
+--
+-- ビルド: cabal build --project-file=cabal.project.plot posteriordb-dogs
+module Main (main) where
+
+import Data.Aeson (FromJSON (..), withObject, (.:), eitherDecodeFileStrict)
+import Data.List (intercalate)
+import qualified Data.Text as T
+import qualified Data.Vector as V
+
+import Hanalyze.Model.HBM (ModelP, Distribution (..), sample, observe,
+                                    dataNamedX, dataNamedObs, plateForM_)
+import Hanalyze.MCMC.Core (Chain, chainVals)
+import Hanalyze.Model.HBM (gradPathLabel)
+import Hanalyze.Plot (hbmModelSpec)
+import Hanalyze.Plot (HBMConfig (..), defaultHBM, hbm, (|->),
+                              dashboardFullOf, hbmChainsR)
+import Hgg.Plot.Spec (ColData (..))
+import Hgg.Plot.Frame (BoundPlot, (|>>))
+import Hgg.Plot.Backend.Rasterific (savePNGBound)
+
+import Common (summarize, printSummary)
+
+-- | posteriordb の @dogs_data.json@ 形状 ({"n_dogs":30,"n_trials":25,"y":[[...]]})。
+data DogsData = DogsData
+  { yMatrix :: [[Int]]  -- ^ 30匹 × 25試行の 0/1 (0=回避成功, 1=ショック)。
+  }
+
+instance FromJSON DogsData where
+  parseJSON = withObject "DogsData" $ \v ->
+    DogsData <$> v .: "y"
+
+noDf :: [(T.Text, ColData)]
+noDf = []
+
+dataPath :: FilePath
+dataPath = "bench/posteriordb/02-dogs/data/dogs_data.json"
+
+figuresDir :: FilePath
+figuresDir = "bench/posteriordb/02-dogs/figures"
+
+readData :: IO [[Int]]
+readData = do
+  d <- either fail pure =<< eitherDecodeFileStrict dataPath
+  pure (yMatrix d)
+
+-- | 1匹分の回避/被ショック累積回数 (beta 非依存・y のみに依存する前処理)。
+-- @n_avoid[0] = n_shock[0] = 0@、以降は前試行までの累積。
+cumulativeCounts :: [Int] -> ([Double], [Double])
+cumulativeCounts ys =
+  ( scanl (\a y -> a + 1 - fromIntegral y) 0 (init ys)
+  , scanl (\s y -> s + fromIntegral y) 0 (init ys)
+  )
+
+-- | 累積回避/被ショック回数を共変量とするロジスティック回帰 (Stan 原典と
+-- 同一構造)。 データは df 経由で束縛 ('dataNamedX'/'dataNamedObs')・
+-- 反復は 'plateForM_' (docs/api-guide 規約)。
+dogsModel :: ModelP ()
+dogsModel = do
+  beta1 <- sample "beta1" (Normal 0 100)
+  beta2 <- sample "beta2" (Normal 0 100)
+  beta3 <- sample "beta3" (Normal 0 100)
+  navoid <- dataNamedX   "n_avoid" []
+  nshock <- dataNamedX   "n_shock" []
+  ys     <- dataNamedObs "y"       []
+  plateForM_ "obs" (zip3 navoid nshock ys) $ \(na, ns, yi) ->
+    let logitP = beta1 + beta2 * na + beta3 * ns
+        p      = 1 / (1 + exp (negate logitP))
+    in observe "y" (Bernoulli p) [yi]
+
+main :: IO ()
+main = do
+  rows <- readData
+  let (avoidRows, shockRows) = unzip (map cumulativeCounts rows)
+      yFlat     = map fromIntegral (concat rows) :: [Double]
+      avoidFlat = concat avoidRows
+      shockFlat = concat shockRows
+      df = [ ("n_avoid", NumData (V.fromList avoidFlat))
+           , ("n_shock", NumData (V.fromList shockFlat))
+           , ("y",       NumData (V.fromList yFlat))
+           ] :: [(T.Text, ColData)]
+      -- PyMC 側 (model.py) と同じ設定を定数で揃える (draws/tune=1000×4chain)。
+      cfg = defaultHBM { hbmChains = 4, hbmSamples = 1000
+                        , hbmWarmup = 1000, hbmSeed = Just 1 }
+      m = df |-> hbm cfg dogsModel
+      pars = ["beta1", "beta2", "beta3"]
+
+  -- Phase 96 A2: 勾配経路 = compileGradUV が実際に選ぶ経路 (束縛済
+  -- hbmModelSpec で判定・Phase 91 A4 と同型)。
+  putStrLn $ "勾配経路 = " ++ gradPathLabel (hbmModelSpec m)
+
+  printSummary $ summarize pars (hbmChainsR m)
+  writeDrawsCSV "bench/posteriordb/02-dogs/hs_draws.csv" pars (hbmChainsR m)
+
+  savePNGBound (figuresDir ++ "/hs_dashboard_full.png") $
+    (noDf |>> dashboardFullOf m "y" :: BoundPlot)
+
+-- | arviz 独立検証用の生 draw ダンプ (chain,draw,<param>...) — hanalyze 自身の
+-- 診断コードは使わず、外部 (arviz) で bulk/tail ESS・R-hat を再計算するため。
+writeDrawsCSV :: FilePath -> [T.Text] -> [Chain] -> IO ()
+writeDrawsCSV path pars chains = writeFile path (unlines (header : rows))
+  where
+    header = intercalate "," ("chain" : "draw" : map T.unpack pars)
+    rows = [ intercalate "," (show ci : show di : [ show (chainVals p ch !! di) | p <- pars ])
+           | (ci, ch) <- zip [0 :: Int ..] chains
+           , di <- [0 .. length (chainVals (head pars) ch) - 1] ]
diff --git a/bench/posteriordb/03-garch11/Model.hs b/bench/posteriordb/03-garch11/Model.hs
new file mode 100644
--- /dev/null
+++ b/bench/posteriordb/03-garch11/Model.hs
@@ -0,0 +1,119 @@
+{-# LANGUAGE OverloadedStrings #-}
+-- | garch-garch11 (posteriordb) — hanalyze (ModelP) 実装。
+--
+-- Phase 89: posteriordb 横断ベンチマーク。GARCH(1,1) 時系列モデル (T=200)。
+-- 分散 sigma[t] がパラメータ依存の逐次再帰 (sigma[t-1] から計算) という、
+-- これまでの2モデル (GLM-Poisson・dogs) とは異なる構造。dogs の累積和は
+-- observed data のみに依存する前処理だったが、本モデルの再帰は
+-- alpha0/alpha1/beta1/mu という **サンプリング対象のパラメータに依存**
+-- するため前処理不可 (毎回の勾配評価で再計算)。 'synthVecIR' がこの
+-- パラメータ依存再帰にも対応することを事前に小規模トイモデルで実測確認済み
+-- (`cabal repl` + 'synthVecIR' 直接呼び出し・`Just` を確認)。
+--
+-- mu/alpha0 は Stan の暗黙improper flat prior (無制約 real / 下限のみ) を
+-- そのまま移植できない (hanalyze の Uniform は有限区間が必要)。実用上の
+-- proper prior (mu~Normal(0,10)・alpha0~HalfNormal(5)) で代替する
+-- (README「既知の課題」に記載)。alpha1/beta1 は Stan 原典の有界一様事前
+-- 分布 (beta1 の上限が alpha1 に依存する動的境界) を忠実に移植する。
+--
+-- 高レベル API (`df |-> hbm`) を使用: データは 'dataNamedObs' で df から
+-- 束縛する (説明変数無し・時系列そのもの)。 診断図は hgg の
+-- 'dashboardFullOf' を 1 枚の PNG (rasterific backend) として出力する。
+-- chain 数等の設定は PyMC 側 (`model.py`) と同じくコード中の定数 (= 4) で
+-- 揃える (CLI 引数化しない)。
+--
+-- reference_posterior_name = "garch-garch11" — posteriordb に公式 reference
+-- posterior あり (hanalyze vs PyMC vs 公式referenceの3者比較が可能)。
+--
+-- ビルド: cabal build --project-file=cabal.project.plot posteriordb-garch11
+module Main (main) where
+
+import Data.Aeson (FromJSON (..), withObject, (.:), eitherDecodeFileStrict)
+import Data.List (intercalate)
+import qualified Data.Text as T
+import qualified Data.Vector as V
+
+import Hanalyze.Model.HBM (ModelP, Distribution (..), sample, observe,
+                                    dataNamedObs, plateForM_)
+import Hanalyze.MCMC.Core (Chain, chainVals)
+import Hanalyze.Model.HBM (gradPathLabel)
+import Hanalyze.Plot (hbmModelSpec)
+import Hanalyze.Plot (HBMConfig (..), defaultHBM, hbm, (|->),
+                              dashboardFullOf, hbmChainsR)
+import Hgg.Plot.Spec (ColData (..))
+import Hgg.Plot.Frame (BoundPlot, (|>>))
+import Hgg.Plot.Backend.Rasterific (savePNGBound)
+
+import Common (summarize, printSummary)
+
+-- | posteriordb の @garch_data.json@ 形状 ({"T":200,"y":[...],"sigma1":0.5})。
+data GarchData = GarchData
+  { yTS    :: [Double]
+  , sigma1 :: Double  -- ^ sigma[1] の固定初期値 (データの一部)。
+  }
+
+instance FromJSON GarchData where
+  parseJSON = withObject "GarchData" $ \v ->
+    GarchData <$> v .: "y" <*> v .: "sigma1"
+
+noDf :: [(T.Text, ColData)]
+noDf = []
+
+dataPath :: FilePath
+dataPath = "bench/posteriordb/03-garch11/data/garch_data.json"
+
+figuresDir :: FilePath
+figuresDir = "bench/posteriordb/03-garch11/figures"
+
+readData :: IO ([Double], Double)
+readData = do
+  d <- either fail pure =<< eitherDecodeFileStrict dataPath
+  pure (yTS d, sigma1 d)
+
+-- | GARCH(1,1): sigma[t] はパラメータ依存の逐次再帰 (Stan 原典と同一構造)。
+-- @sigma[1] = sigma1@ (データの固定初期値)、@sigma[t] = sqrt(alpha0 +
+-- alpha1*(y[t-1]-mu)^2 + beta1*sigma[t-1]^2)@。 データは df 経由で束縛
+-- ('dataNamedObs')・観測は 'plateForM_' (docs/api-guide 規約)。
+garch11Model :: Double -> ModelP ()
+garch11Model s1 = do
+  mu     <- sample "mu"     (Normal 0 10)
+  alpha0 <- sample "alpha0" (HalfNormal 5)
+  alpha1 <- sample "alpha1" (Uniform 0 1)
+  beta1  <- sample "beta1"  (Uniform 0 (1 - alpha1))
+  ys <- dataNamedObs "y" []
+  let sigmas = scanl (\sPrev yPrev ->
+                         sqrt (alpha0 + alpha1 * (realToFrac yPrev - mu) ^ (2 :: Int)
+                               + beta1 * sPrev ^ (2 :: Int)))
+                      (realToFrac s1) (init ys)
+  plateForM_ "obs" (zip sigmas ys) $ \(s, yi) ->
+    observe "y" (Normal mu s) [yi]
+
+main :: IO ()
+main = do
+  (ys, s1) <- readData
+  let df = [ ("y", NumData (V.fromList ys)) ] :: [(T.Text, ColData)]
+      -- PyMC 側 (model.py) と同じ設定を定数で揃える (draws/tune=1000×4chain)。
+      cfg = defaultHBM { hbmChains = 4, hbmSamples = 1000
+                        , hbmWarmup = 1000, hbmSeed = Just 1 }
+      m = df |-> hbm cfg (garch11Model s1)
+      pars = ["mu", "alpha0", "alpha1", "beta1"]
+
+  -- Phase 96 A2: 勾配経路 = compileGradUV が実際に選ぶ経路 (束縛済
+  -- hbmModelSpec で判定・Phase 91 A4 と同型)。
+  putStrLn $ "勾配経路 = " ++ gradPathLabel (hbmModelSpec m)
+
+  printSummary $ summarize pars (hbmChainsR m)
+  writeDrawsCSV "bench/posteriordb/03-garch11/hs_draws.csv" pars (hbmChainsR m)
+
+  savePNGBound (figuresDir ++ "/hs_dashboard_full.png") $
+    (noDf |>> dashboardFullOf m "y" :: BoundPlot)
+
+-- | arviz 独立検証用の生 draw ダンプ (chain,draw,<param>...) — hanalyze 自身の
+-- 診断コードは使わず、外部 (arviz) で bulk/tail ESS・R-hat を再計算するため。
+writeDrawsCSV :: FilePath -> [T.Text] -> [Chain] -> IO ()
+writeDrawsCSV path pars chains = writeFile path (unlines (header : rows))
+  where
+    header = intercalate "," ("chain" : "draw" : map T.unpack pars)
+    rows = [ intercalate "," (show ci : show di : [ show (chainVals p ch !! di) | p <- pars ])
+           | (ci, ch) <- zip [0 :: Int ..] chains
+           , di <- [0 .. length (chainVals (head pars) ch) - 1] ]
diff --git a/bench/posteriordb/04-low-dim-gauss-mix/Model.hs b/bench/posteriordb/04-low-dim-gauss-mix/Model.hs
new file mode 100644
--- /dev/null
+++ b/bench/posteriordb/04-low-dim-gauss-mix/Model.hs
@@ -0,0 +1,118 @@
+{-# LANGUAGE OverloadedStrings #-}
+-- | low_dim_gauss_mix-low_dim_gauss_mix (posteriordb) — hanalyze (ModelP) 実装。
+--
+-- Phase 89/90: posteriordb 横断ベンチマーク + Phase 90 A3 (vecIR ギャップ
+-- 解消: 04-low-dim-gauss-mix = 2成分Normal混合)。
+--
+-- Stan 原典 (posteriordb `models/stan/low_dim_gauss_mix.stan`。 N=1000):
+--   parameters { ordered[2] mu; array[2] real<lower=0> sigma;
+--                real<lower=0,upper=1> theta; }
+--   model {
+--     sigma ~ normal(0, 2); mu ~ normal(0, 2); theta ~ beta(5, 5);
+--     for (n in 1:N)
+--       target += log_mix(theta, normal_lpdf(y[n]|mu[1],sigma[1]),
+--                                 normal_lpdf(y[n]|mu[2],sigma[2]));
+--   }
+--
+-- reference_posterior_name = "low_dim_gauss_mix-low_dim_gauss_mix"
+-- (posteriordb に公式 reference posterior あり・3者比較可能)。
+--
+-- Phase 90 A3: `Mixture [w1,w2] [Normal mu1 sg1, Normal mu2 sg2]` を新規
+-- vecIR family (`VGMixNorm2`) で高速経路に載せた (`IR.hs`)。Stan の
+-- `ordered[2] mu` 制約 (label switching 回避) は hanalyze 側に対応する
+-- 順序制約プリミティブが無いため実装せず、mu1<mu2 を確認できた場合のみ
+-- 素直に採用する (後述「既知の課題」参照)。
+module Main (main) where
+
+import Data.Aeson (FromJSON (..), withObject, (.:), eitherDecodeFileStrict)
+import qualified Data.Text as T
+import qualified Data.Vector as V
+
+import Hanalyze.Model.HBM (ModelP, Distribution (..), sample, observe,
+                                    dataNamedObs, plateForM_, withData)
+import Hanalyze.Model.HBM (gradPathLabel)
+import Hanalyze.Plot (hbmModelSpec)
+import Hanalyze.Plot (HBMConfig (..), defaultHBM, hbm, (|->),
+                              dashboardFullOf, hbmChainsR)
+import Hanalyze.MCMC.Core (Chain (..))
+import Hgg.Plot.Spec (ColData (..))
+import Hgg.Plot.Frame (BoundPlot, (|>>))
+import Hgg.Plot.Backend.Rasterific (savePNGBound)
+import qualified Data.Map.Strict as Map
+
+import Common (summarize, printSummary, timeSamplingMs)
+import Text.Printf (printf)
+
+-- | posteriordb の @low_dim_gauss_mix.json@ 形状 ({"N":1000, "y":[...]})。
+newtype MixData = MixData { y :: [Double] }
+
+instance FromJSON MixData where
+  parseJSON = withObject "MixData" $ \v -> MixData <$> v .: "y"
+
+noDf :: [(T.Text, ColData)]
+noDf = []
+
+dataPath :: FilePath
+dataPath = "bench/posteriordb/04-low-dim-gauss-mix/data/low_dim_gauss_mix.json"
+
+figuresDir :: FilePath
+figuresDir = "bench/posteriordb/04-low-dim-gauss-mix/figures"
+
+readData :: IO [Double]
+readData = do
+  d <- either fail pure =<< eitherDecodeFileStrict dataPath
+  pure (y d)
+
+-- | 2成分 Normal 混合 (Phase 90 A3 で追加した `VGMixNorm2` vecIR family を使用)。
+lowDimGaussMixModel :: ModelP ()
+lowDimGaussMixModel = do
+  mu1    <- sample "mu1"    (Normal 0 2)
+  mu2    <- sample "mu2"    (Normal 0 2)
+  sigma1 <- sample "sigma1" (HalfNormal 2)
+  sigma2 <- sample "sigma2" (HalfNormal 2)
+  theta  <- sample "theta"  (Beta 5 5)
+  ys     <- dataNamedObs "y" []
+  plateForM_ "obs" ys $ \yi ->
+    observe "y" (Mixture [theta, 1 - theta]
+                          [Normal mu1 sigma1, Normal mu2 sigma2]) [yi]
+
+-- | ラベルスイッチング補正 (Stan 原典の `ordered[2] mu` 制約に相当)。
+-- 2成分混合は mu1/mu2 を入れ替えても尤度が不変 (非識別) なので、posterior
+-- draw ごとに mu1 > mu2 なら (mu1,sigma1,theta) <-> (mu2,sigma2,1-theta) を
+-- 入れ替えて mu1 < mu2 に正規化する。 hanalyze は各 chain が個別の順序に
+-- 収束しやすく (chain 内 ESS は健全・chain 間で符号が割れて R-hat が数十に
+-- 跳ねる)、 この後処理で PyMC/公式referenceと比較可能な要約になる。
+orderedChains :: [Chain] -> [Chain]
+orderedChains = map orderChain
+  where
+    orderChain c = c { chainSamples = map orderDraw (chainSamples c) }
+    orderDraw ps = case (Map.lookup "mu1" ps, Map.lookup "mu2" ps) of
+      (Just m1, Just m2) | m1 > m2 -> swapDraw ps
+      _                            -> ps
+    swapDraw ps = Map.union (Map.fromList
+      [ ("mu1", ps Map.! "mu2"), ("mu2", ps Map.! "mu1")
+      , ("sigma1", ps Map.! "sigma2"), ("sigma2", ps Map.! "sigma1")
+      , ("theta", 1 - ps Map.! "theta")
+      ]) ps
+
+main :: IO ()
+main = do
+  ys <- readData
+  let df = [ ("y", NumData (V.fromList ys)) ] :: [(T.Text, ColData)]
+      -- PyMC 側 (model.py) と同じ設定を定数で揃える。
+      cfg = defaultHBM { hbmChains = 4, hbmSamples = 1000
+                        , hbmWarmup = 1000, hbmSeed = Just 1 }
+      m = df |-> hbm cfg lowDimGaussMixModel
+
+  -- 勾配経路 = compileGradUV が実際に選ぶ経路 (束縛済 hbmModelSpec で判定・
+  -- Phase 91 A4: 生モデルを synthVecIR に渡すと data 空で誤表示するため差替)。
+  putStrLn $ "勾配経路 = " ++ gradPathLabel (hbmModelSpec m)
+
+  (_, samplingMs) <- timeSamplingMs (hbmChainsR m)
+  printf "sampling wall = %.1f ms (draws only, no dashboard/startup)\n" samplingMs
+
+  savePNGBound (figuresDir ++ "/hs_dashboard_full.png") $
+    (noDf |>> dashboardFullOf m "y" :: BoundPlot)
+
+  printSummary $ summarize ["mu1", "mu2", "sigma1", "sigma2", "theta"]
+                           (orderedChains (hbmChainsR m))
diff --git a/bench/posteriordb/05-mh/Model.hs b/bench/posteriordb/05-mh/Model.hs
new file mode 100644
--- /dev/null
+++ b/bench/posteriordb/05-mh/Model.hs
@@ -0,0 +1,145 @@
+{-# LANGUAGE OverloadedStrings #-}
+-- | Mh_data-Mh_model (posteriordb) — hanalyze (ModelP) 実装。
+--
+-- Phase 89/90: posteriordb 横断ベンチマーク + Phase 90 A3 (vecIR ギャップ
+-- 解消: 05-mh = capture-recapture・ZeroInflatedBinomial + 個体ごとの
+-- ランダム効果)。
+--
+-- Stan 原典 (posteriordb `models/stan/Mh_model.stan`。 BPA本 Ch.6・
+-- M=385個体・T=5回のサンプリング機会):
+--   parameters { real<lower=0,upper=1> omega; real<lower=0,upper=1> mean_p;
+--                real<lower=0,upper=5> sigma; vector[M] eps_raw; }
+--   transformed parameters { vector[M] eps = logit(mean_p) + sigma*eps_raw; }
+--   model {
+--     eps_raw ~ normal(0, 1);
+--     for (i in 1:M) {
+--       if (y[i] > 0)
+--         target += bernoulli_lpmf(1|omega) + binomial_logit_lpmf(y[i]|T,eps[i]);
+--       else
+--         target += log_sum_exp(bernoulli_lpmf(1|omega)+binomial_logit_lpmf(0|T,eps[i]),
+--                                bernoulli_lpmf(0|omega));
+--     }
+--   }
+--
+-- 05-mh/README.md (Phase 89) で代数的に導出済みのとおり、この尤度は
+-- hanalyze の `ZeroInflatedBinomial n ψ p` (ψ=1-omega, p=invlogit(eps)) と
+-- 厳密に一致する。 reference_posterior_name = null (2者比較のみ)。
+--
+-- Phase 90 A3: `SDZIBinom`/`VGZIBinom`/`VOZIBinom` (新設 vecIR family) を
+-- 使用。 eps_i (個体ごとのランダム効果) は `plateI` で M 個の latent を
+-- 宣言し、 族 gather (`!!`) 経由で観測式に取り込む (`docs/api-guide` の
+-- eight-schools 型パターンと同型)。 トイモデルで `synthVecIR` = `Just` を
+-- 実測確認済み (個体ごとの logit-link random effect を含む形でも通る)。
+module Main (main) where
+
+import Data.Aeson (FromJSON (..), withObject, (.:), eitherDecodeFileStrict)
+import qualified Data.Text as T
+import qualified Data.Vector as V
+
+import Hanalyze.Model.HBM (ModelP, Distribution (..), sample, observe,
+                                    dataNamedObs, plateI, plateForM_, (.#), withData)
+import Hanalyze.Model.HBM (gradPathLabel)
+import Hanalyze.Plot (hbmModelSpec)
+import Hanalyze.Plot (HBMConfig (..), defaultHBM, hbm, (|->),
+                              dashboardOf, hbmChainsR)
+import Hgg.Plot.Spec (ColData (..))
+import Hgg.Plot.Frame (BoundPlot, (|>>))
+import Hgg.Plot.Backend.Rasterific (savePNGBound)
+
+import Common (summarize, printSummary, timeSamplingMs)
+import Control.Monad (unless)
+import Data.List (group, sort)
+import System.Environment (getArgs)
+import Text.Printf (printf)
+
+import Hanalyze.MCMC.Core (chainTreeDepths)
+
+-- | posteriordb の @Mh_data.json@ 形状 ({"M":385, "T":5, "y":[...]})。
+data MhData = MhData { yObs :: [Double], tOccasions :: Int }
+
+instance FromJSON MhData where
+  parseJSON = withObject "MhData" $ \v ->
+    MhData <$> v .: "y" <*> v .: "T"
+
+noDf :: [(T.Text, ColData)]
+noDf = []
+
+dataPath :: FilePath
+dataPath = "bench/posteriordb/05-mh/data/Mh_data.json"
+
+figuresDir :: FilePath
+figuresDir = "bench/posteriordb/05-mh/figures"
+
+readData :: IO ([Double], Int)
+readData = do
+  d <- either fail pure =<< eitherDecodeFileStrict dataPath
+  pure (yObs d, tOccasions d)
+
+-- | capture-recapture (Mh モデル)。 T (サンプリング機会数) はデータ由来の
+-- 固定定数として closure で渡す (ctor 引数化・GLM-Poisson 等と同じ流儀)。
+mhModel :: Int -> ModelP ()
+mhModel t = do
+  -- Stan 原典は omega/mean_p ~ Uniform(0,1) (暗黙一様事前分布) だが、
+  -- hanalyze の `Uniform` は制約変換が現状 unconstrained 扱い
+  -- (`01-glm-poisson/README.md` に既知の課題として記載済) のため、
+  -- vecIR probe (2点評価) で mean_p が (0,1) 域外の値を取り
+  -- `log(meanP/(1-meanP))` が NaN 化して誤フォールバックする実害が
+  -- M=385 (個体ごとの random effect) という多latentモデルで発覚した。
+  -- Beta(1,1) は Uniform(0,1) と数学的に同一の分布で、hanalyze では
+  -- 実際に (0,1) へ写す変換を持つため確率的に等価かつ probe 安全。
+  omega  <- sample "omega"  (Beta 1 1)
+  meanP  <- sample "mean_p" (Beta 1 1)
+  sigma  <- sample "sigma"  (Uniform 0 5)
+  ys     <- dataNamedObs "y" []
+  epsRaws <- plateI "ind" (length ys) $ \i -> sample ("eps_raw" .# i) (Normal 0 1)
+  let logitMeanP = log (meanP / (1 - meanP))
+  plateForM_ "obs" (zip [0 ..] ys) $ \(i, yi) ->
+    let eps = logitMeanP + sigma * (epsRaws !! i)
+        p   = 1 / (1 + exp (negate eps))
+    in observe "y" (ZeroInflatedBinomial t (1 - omega) p) [yi]
+
+main :: IO ()
+main = do
+  (ys, t) <- readData
+  -- Phase 96 A1: `prof` 引数で図出力 skip (Phase 102 A1 の dugongs/radon と
+  -- 同型。Rasterific が cost centre を汚さないため)。サンプリング設定は
+  -- 本番のまま据え置く。
+  args <- getArgs
+  let profRun = elem "prof" args
+  let df = [ ("y", NumData (V.fromList ys)) ] :: [(T.Text, ColData)]
+      -- PyMC 側 (model.py) と同じ設定を定数で揃える。
+      -- Phase 96 A5: M=I 期間 (init buffer + 第1 window) の deep tree 抑制。
+      -- 無効時は init 期が 61.9 steps/iter (avg depth≈6・ε 中央値 0.107 vs
+      -- 収束後 0.242 の鋸歯) で warmup の 32% を浪費する。Just 4 で warmup
+      -- evals −25〜28% (116-121k → 85-91k)・seed 1/2/3 で posterior 統計一致・
+      -- ess/s 分散も縮小 (実測 root: experiments/phase96-mh-reconfirm/)。
+      cfg = defaultHBM { hbmChains = 4, hbmSamples = 1000
+                        , hbmWarmup = 1000, hbmSeed = Just 1
+                        , hbmWarmupInitMaxDepth = Just 4 }
+      m = df |-> hbm cfg (mhModel t)
+
+  -- 勾配経路 = compileGradUV が実際に選ぶ経路 (束縛済 hbmModelSpec で判定・
+  -- Phase 91 A4: 生モデルを synthVecIR に渡すと data 空で誤表示するため差替)。
+  putStrLn $ "勾配経路 = " ++ gradPathLabel (hbmModelSpec m)
+
+  (_, samplingMs) <- timeSamplingMs (hbmChainsR m)
+  printf "sampling wall = %.1f ms (draws only, no dashboard/startup)\n" samplingMs
+
+  -- Phase 96 A4: draws 区間の軌道長を nutpie の n_steps(draws) と同一区間で
+  -- 突合するための tree depth 統計 (leapfrog 数 ≈ 2^depth − 1)。
+  let depths = concatMap chainTreeDepths (hbmChainsR m)
+      hist   = map (\g -> (head g, length g)) (group (sort depths))
+      nLeap  = sum [ (2 :: Int) ^ d - 1 | d <- depths ]
+  printf "tree depth (draws): hist=%s  leapfrog~=%d  steps/draw~=%.1f\n"
+         (show hist) nLeap
+         (fromIntegral nLeap / fromIntegral (max 1 (length depths)) :: Double)
+
+  -- M=385 個体分の eps_raw latent を含むため 'dashboardFullOf' (全 param の
+  -- [事後分布|trace] グリッド) は 52MB 超の巨大画像になり非実用的 (実測)。
+  -- 健全性 2x2 パネル (DAG/forest/PPC/energy) のみの 'dashboardOf' を使う
+  -- (forest には全 latent が出るが 1 行/param のコンパクト表示なので実用的)。
+  unless profRun $
+    savePNGBound (figuresDir ++ "/hs_dashboard_full.png") $
+      (noDf |>> dashboardOf m "y" :: BoundPlot)
+
+  printSummary $ summarize ["omega", "mean_p", "sigma"] (hbmChainsR m)
diff --git a/bench/posteriordb/06-irt-2pl/Model.hs b/bench/posteriordb/06-irt-2pl/Model.hs
new file mode 100644
--- /dev/null
+++ b/bench/posteriordb/06-irt-2pl/Model.hs
@@ -0,0 +1,116 @@
+{-# LANGUAGE OverloadedStrings #-}
+-- | irt_2pl-irt_2pl (posteriordb) — hanalyze (ModelP) 実装。
+--
+-- Phase 89/90: posteriordb 横断ベンチマーク + Phase 90 A5 (vecIR ギャップ
+-- 解消: 06-irt-2pl = 独立2ラテント配列を跨ぐ乗算項 (a[i]*(theta[j]-b[i])))。
+--
+-- Stan 原典 (posteriordb `models/stan/irt_2pl.stan`。 I=20項目×J=100人):
+--   parameters { real<lower=0> sigma_theta; vector[J] theta;
+--                real<lower=0> sigma_a; vector<lower=0>[I] a;
+--                real mu_b; real<lower=0> sigma_b; vector[I] b; }
+--   model {
+--     sigma_theta ~ cauchy(0,2); theta ~ normal(0, sigma_theta);
+--     sigma_a ~ cauchy(0,2); a ~ lognormal(0, sigma_a);
+--     mu_b ~ normal(0,5); sigma_b ~ cauchy(0,2); b ~ normal(mu_b, sigma_b);
+--     for (i in 1:I) y[i] ~ bernoulli_logit(a[i] * (theta - b[i]));
+--   }
+--
+-- reference_posterior_name = null (posteriordb に公式 reference 無し・
+-- hanalyze vs PyMC の2者比較のみ。 06-irt-2pl/README.md の旧記述「あり」は
+-- Phase 89 時点の誤りで Phase 90 A5 で実測訂正した)。
+--
+-- Phase 90 A5: 06-irt-2pl は Phase 89 で「分類未確定」のまま保留されて
+-- いたが、A1 実測調査で真因が「独立2ラテント配列を跨ぐ積」自体ではなく
+-- `IR.hs` の `famOf` が family absorb (prior のベクトル化) を
+-- Normal(m,τ) 構造限定にしていたこと (`a` の LogNormal 事前分布が family
+-- 不成立で group ごと丸ごと Nothing になっていた) と判明。A5 で `tryGroup`
+-- を「family absorb 失敗は fams から除外するだけ・likelihood 側の吸収は
+-- 継続」という fault-tolerant 設計に修正し解消した (`theta`/`b` は
+-- Normal 階層事前分布で family absorb、`a` の LogNormal 事前分布は
+-- 既存の `constPriorsOf`/残差 AD 経路にフォールバック)。
+module Main (main) where
+
+import Data.Aeson (FromJSON (..), withObject, (.:), eitherDecodeFileStrict)
+import qualified Data.Text as T
+import qualified Data.Vector as V
+
+import Hanalyze.Model.HBM (ModelP, Distribution (..), sample, observe,
+                                    dataNamedObs, plateI, plateForM_, (.#), withData)
+import Hanalyze.Model.HBM (gradPathLabel)
+import Hanalyze.Plot (hbmModelSpec)
+import Hanalyze.Plot (HBMConfig (..), defaultHBM, hbm, (|->),
+                              dashboardOf, hbmChainsR)
+import Hgg.Plot.Spec (ColData (..))
+import Hgg.Plot.Frame (BoundPlot, (|>>))
+import Hgg.Plot.Backend.Rasterific (savePNGBound)
+
+import Common (summarize, printSummary, timeSamplingMs)
+import Text.Printf (printf)
+
+-- | posteriordb の @irt_2pl.json@ 形状 ({"I":20, "J":100, "y":[[0/1,...]×20]})。
+data IrtData = IrtData { yMat :: [[Double]] }
+
+instance FromJSON IrtData where
+  parseJSON = withObject "IrtData" $ \v -> IrtData <$> v .: "y"
+
+noDf :: [(T.Text, ColData)]
+noDf = []
+
+dataPath :: FilePath
+dataPath = "bench/posteriordb/06-irt-2pl/data/irt_2pl.json"
+
+figuresDir :: FilePath
+figuresDir = "bench/posteriordb/06-irt-2pl/figures"
+
+readData :: IO [[Double]]
+readData = do
+  d <- either fail pure =<< eitherDecodeFileStrict dataPath
+  pure (yMat d)
+
+-- | IRT 2PLモデル。theta (J人) / a,b (I項目) はそれぞれ族 gather
+-- (`plateI` + `!!`) で宣言し、観測は (item,person) の全ペアで
+-- `plateForM_` する (2000観測・`y`は行優先でフラット化して束縛)。
+irt2plModel :: Int -> Int -> ModelP ()
+irt2plModel nI nJ = do
+  sigmaTheta <- sample "sigma_theta" (HalfCauchy 2)
+  thetas <- plateI "person" nJ $ \j -> sample ("theta" .# j) (Normal 0 sigmaTheta)
+  sigmaA <- sample "sigma_a" (HalfCauchy 2)
+  as <- plateI "item" nI $ \i -> sample ("a" .# i) (LogNormal 0 sigmaA)
+  muB <- sample "mu_b" (Normal 0 5)
+  sigmaB <- sample "sigma_b" (HalfCauchy 2)
+  bs <- plateI "item2" nI $ \i -> sample ("b" .# i) (Normal muB sigmaB)
+  ys <- dataNamedObs "y" []
+  let pairs = [ (i, j) | i <- [0 .. nI - 1], j <- [0 .. nJ - 1] ]
+  plateForM_ "obs" (zip pairs ys) $ \((i, j), yij) ->
+    let logit = (as !! i) * ((thetas !! j) - (bs !! i))
+    in observe "y" (Bernoulli (1 / (1 + exp (negate logit)))) [yij]
+
+main :: IO ()
+main = do
+  rows <- readData
+  let nI = length rows
+      nJ = length (head rows)
+      ysFlat = concat rows   -- 行優先 (item-major) フラット化・Model.hs の pairs と対応
+      df = [ ("y", NumData (V.fromList ysFlat)) ] :: [(T.Text, ColData)]
+      -- PyMC 側 (model.py) と同じ設定を定数で揃える。
+      cfg = defaultHBM { hbmChains = 4, hbmSamples = 1000
+                        , hbmWarmup = 1000, hbmSeed = Just 1
+                        -- Phase 105 A2: warmup init buffer (M=I 期) の深掘り抑制。
+                        -- seed 1/2/3 で wall −1.8〜−11.7%・ESS/s(mu_b) 全 seed 改善
+                        -- (05-mh Phase 96 A5 と同型・実測 root = experiments/phase105-*)
+                        , hbmWarmupInitMaxDepth = Just 4 }
+      m = df |-> hbm cfg (irt2plModel nI nJ)
+
+  -- 勾配経路 = compileGradUV が実際に選ぶ経路 (束縛済 hbmModelSpec で判定・
+  -- Phase 91 A4: 生モデルを synthVecIR に渡すと data 空で誤表示するため差替)。
+  putStrLn $ "勾配経路 = " ++ gradPathLabel (hbmModelSpec m)
+
+  (_, samplingMs) <- timeSamplingMs (hbmChainsR m)
+  printf "sampling wall = %.1f ms (draws only, no dashboard/startup)\n" samplingMs
+
+  -- theta(100)+a(20)+b(20) = 140 latent。dashboardFullOf は大規模化するため
+  -- 05-mh と同じ理由で dashboardOf (健全性パネルのみ) を使う。
+  savePNGBound (figuresDir ++ "/hs_dashboard_full.png") $
+    (noDf |>> dashboardOf m "y" :: BoundPlot)
+
+  printSummary $ summarize ["sigma_theta", "sigma_a", "mu_b", "sigma_b"] (hbmChainsR m)
diff --git a/bench/posteriordb/07-gp-regr/Model.hs b/bench/posteriordb/07-gp-regr/Model.hs
new file mode 100644
--- /dev/null
+++ b/bench/posteriordb/07-gp-regr/Model.hs
@@ -0,0 +1,136 @@
+{-# LANGUAGE OverloadedStrings #-}
+-- | gp_pois_regr-gp_regr (posteriordb) — hanalyze (ModelP) 実装。
+--
+-- Phase 89/90: posteriordb 横断ベンチマーク + Phase 90 A2 (vecIR ギャップ
+-- 解消: 07-gp-regr = GP カーネル + Cholesky分解)。
+--
+-- Stan 原典 (posteriordb `models/stan/gp_regr.stan` — data_name は
+-- `gp_pois_regr` だが実際に走るのは `gp_regr` モデル・Gaussian 尤度):
+--   parameters { real<lower=0> rho; real<lower=0> alpha; real<lower=0> sigma; }
+--   model {
+--     matrix[N,N] cov = gp_exp_quad_cov(x, alpha, rho)
+--                       + diag_matrix(rep_vector(sigma, N));
+--     matrix[N,N] L_cov = cholesky_decompose(cov);
+--     rho ~ gamma(25, 4); alpha ~ normal(0, 2); sigma ~ normal(0, 1);
+--     y ~ multi_normal_cholesky(rep_vector(0, N), L_cov);
+--   }
+--
+-- reference_posterior_name = "gp_pois_regr-gp_regr" (posteriordb に公式
+-- reference posterior あり・3者比較可能)。
+--
+-- Phase 90 A2: `Hanalyze.Model.HBM.gpExpQuadCov` (Model.hs 追加) で
+-- 共分散行列を構築し、既存の `MvNormal` distribution (`obsLogSum` が AD対応
+-- 'mvNormalLogDensity' = 'choleskyL' 経由) にそのまま渡す。GP 専用の新しい
+-- distribution 型は不要 — 密行列は vecIR に構造的に載らない (Phase 90 A1
+-- 調査で判定済み) が、legacy walk+ad 経路でそのまま動く。
+module Main (main) where
+
+import Data.Aeson (FromJSON (..), withObject, (.:), eitherDecodeFileStrict)
+import qualified Data.Text as T
+import qualified Data.Vector as V
+import System.Environment (getArgs)
+
+import Hanalyze.Model.HBM (ModelP, Distribution (..), sample, observeMV,
+                                    dataNamedX, dataNamedObs)
+import Hanalyze.Model.HBM (gradPathLabel)
+import Hanalyze.Plot (hbmModelSpec)
+import Hanalyze.Plot (HBMConfig (..), defaultHBM, hbm, (|->),
+                              dashboardFullOf, hbmChainsR)
+import Hgg.Plot.Spec (ColData (..))
+import Hgg.Plot.Frame (BoundPlot, (|>>))
+import Hgg.Plot.Backend.Rasterific (savePNGBound)
+
+import Text.Printf (printf)
+
+import Common (summarize, printSummary, timeSamplingMs)
+
+-- | posteriordb の @gp_pois_regr.json@ 形状 ({"N":11, "x":[...], "y":[...], "k":[...]})。
+-- `gp_regr` モデルが使うのは x/y のみ (k は gp_pois_regr モデル専用・未使用)。
+data GpRegrData = GpRegrData
+  { x :: [Double]
+  , y :: [Double]
+  }
+
+instance FromJSON GpRegrData where
+  parseJSON = withObject "GpRegrData" $ \v ->
+    GpRegrData <$> v .: "x" <*> v .: "y"
+
+noDf :: [(T.Text, ColData)]
+noDf = []
+
+dataPath :: FilePath
+dataPath = "bench/posteriordb/07-gp-regr/data/gp_pois_regr.json"
+
+figuresDir :: FilePath
+figuresDir = "bench/posteriordb/07-gp-regr/figures"
+
+readData :: IO ([Double], [Double])
+readData = do
+  d <- either fail pure =<< eitherDecodeFileStrict dataPath
+  pure (x d, y d)
+
+-- | Phase 95 A4: N-scaling ベンチ用の合成データ (PyMC scaling script と同一式)。
+-- x = linspace(-10,10,N)・y = 2 + sin(x/2) + 0.3 cos(3x) (決定的・GP 相当の滑らかさ)。
+syntheticData :: Int -> ([Double], [Double])
+syntheticData n = (xs, ys)
+  where
+    xs = [ -10 + 20 * fromIntegral i / fromIntegral (n - 1) | i <- [0 .. n - 1] ]
+    ys = [ 2 + sin (xi / 2) + 0.3 * cos (3 * xi) | xi <- xs ]
+
+-- | GP 回帰 (RBF カーネル + Gaussian 尤度)。Phase 95 B-dsl: カーネル役割
+-- (x/α/ρ/σ) を型で明示保持する 'MvNormalGpRBF' で 1 個の N 次元同時観測として
+-- 尤度評価する (Stan の multi_normal_cholesky と等価・zero mean)。共分散
+-- Σ = α² exp(-0.5 d²/ρ²) + (1e-10 + σ)·I は Distribution 側が内部構築し、勾配は
+-- 閉形式随伴 ('gpRBFAnalyticVG'・Cholesky を AD tape に載せない) で高速評価される。
+gpRegrModel :: ModelP ()
+gpRegrModel = do
+  rho   <- sample "rho"   (Gamma 25 4)
+  alpha <- sample "alpha" (HalfNormal 2)
+  sigma <- sample "sigma" (HalfNormal 1)
+  xs <- dataNamedX   "x" []
+  ys <- dataNamedObs "y" []
+  observeMV "y" (MvNormalGpRBF xs alpha rho sigma) [ys]
+
+main :: IO ()
+main = do
+  args <- getArgs
+  case args of
+    -- Phase 95 A4: `scale <N>` = 合成データで N-scaling ベンチ (図出力なし・
+    -- sampling wall と posterior 平均のみ・PyMC scaling script と対比)。
+    ("scale" : nStr : _) -> do
+      let n = read nStr :: Int
+          (xs, ys) = syntheticData n
+          df = [ ("x", NumData (V.fromList xs)), ("y", NumData (V.fromList ys)) ]
+               :: [(T.Text, ColData)]
+          cfg = defaultHBM { hbmChains = 4, hbmSamples = 1000
+                            , hbmWarmup = 1000, hbmSeed = Just 1 }
+          m = df |-> hbm cfg gpRegrModel
+      -- Phase 96 A2: 勾配経路 (束縛済 hbmModelSpec で判定・Phase 91 A4 と同型)。
+      putStrLn $ "勾配経路 = " ++ gradPathLabel (hbmModelSpec m)
+      (_, samplingMs) <- timeSamplingMs (hbmChainsR m)
+      printf "N=%d  sampling wall = %.1f ms (draws only)\n" n samplingMs
+      printSummary $ summarize ["rho", "alpha", "sigma"] (hbmChainsR m)
+    _ -> do
+      (xs, ys) <- readData
+      let df = [ ("x", NumData (V.fromList xs))
+               , ("y", NumData (V.fromList ys))
+               ] :: [(T.Text, ColData)]
+          -- PyMC 側 (model.py) と同じ設定を定数で揃える。
+          cfg = defaultHBM { hbmChains = 4, hbmSamples = 1000
+                            , hbmWarmup = 1000, hbmSeed = Just 1 }
+          m = df |-> hbm cfg gpRegrModel
+
+      -- Phase 96 A2: 勾配経路 (束縛済 hbmModelSpec で判定・Phase 91 A4 と同型)。
+      putStrLn $ "勾配経路 = " ++ gradPathLabel (hbmModelSpec m)
+
+      -- サンプリング**のみ**の壁時計 (PyMC run_pymc_matrix.py の t0=perf_counter();
+      -- pm.sample() と対応させる・Common.timeSamplingMs 参照)。dashboardFullOf 等
+      -- 後続処理は hbmChainsR の thunk が既に強制済みのものを再利用するので、
+      -- 二重計算にはならない。
+      (_, samplingMs) <- timeSamplingMs (hbmChainsR m)
+      printf "sampling wall = %.1f ms (draws only, no dashboard/startup)\n" samplingMs
+
+      savePNGBound (figuresDir ++ "/hs_dashboard_full.png") $
+        (noDf |>> dashboardFullOf m "y" :: BoundPlot)
+
+      printSummary $ summarize ["rho", "alpha", "sigma"] (hbmChainsR m)
diff --git a/bench/posteriordb/09-eight-schools/Model.hs b/bench/posteriordb/09-eight-schools/Model.hs
new file mode 100644
--- /dev/null
+++ b/bench/posteriordb/09-eight-schools/Model.hs
@@ -0,0 +1,110 @@
+{-# LANGUAGE OverloadedStrings #-}
+-- | eight_schools-eight_schools_noncentered (posteriordb) — hanalyze (ModelP) 実装。
+--
+-- Phase 89: posteriordb 横断ベンチマーク。階層モデルの正準例 8-schools
+-- (Rubin 1981・8校の補習授業効果) を non-centered パラメタ化で実装する
+-- (`docs/api-guide/03-bayesian-hbm.md` §階層モデルの例に準拠)。
+--
+-- Stan 原典 (posteriordb `models/stan/eight_schools_noncentered.stan`):
+--   parameters { vector[J] theta_trans; real mu; real<lower=0> tau; }
+--   transformed parameters { theta = theta_trans * tau + mu; }
+--   model {
+--     theta_trans ~ normal(0, 1);
+--     y ~ normal(theta, sigma);   // sigma は既知データ (観測誤差)
+--     mu ~ normal(0, 5);
+--     tau ~ cauchy(0, 5);
+--   }
+--
+-- reference_posterior_name = "eight_schools-eight_schools_noncentered"
+-- (posteriordb に公式 reference posterior あり・hanalyze vs PyMC vs 公式referenceの
+-- 3者比較が可能)。
+--
+-- 高レベル API (`df |-> hbm`) を使用: y/sigma は 'dataNamedObs'/'dataNamedX' で
+-- df から束縛し、 学校ごとの潜在変数 eta は 'plateI' (index 版・結果保持) で
+-- 8個宣言してから 'plateForM_' で観測に束ねる (api-guide の gather パターン)。
+--
+-- ビルド: cabal build --project-file=cabal.project.plot posteriordb-eight-schools
+module Main (main) where
+
+import Data.Aeson (FromJSON (..), withObject, (.:), eitherDecodeFileStrict)
+import qualified Data.Text as T
+import qualified Data.Vector as V
+
+import Hanalyze.Model.HBM (ModelP, Distribution (..), sample, observe,
+                                    dataNamedX, dataNamedObs, plateI, plateForM_,
+                                    (.#))
+import Hanalyze.Model.HBM (gradPathLabel)
+import Hanalyze.Plot (hbmModelSpec)
+import Hanalyze.Plot (HBMConfig (..), defaultHBM, hbm, (|->),
+                              dashboardFullOf, hbmChainsR)
+import Hgg.Plot.Spec (ColData (..))
+import Hgg.Plot.Frame (BoundPlot, (|>>))
+import Hgg.Plot.Backend.Rasterific (savePNGBound)
+
+import Text.Printf (printf)
+
+import Common (summarize, printSummary, timeSamplingMs)
+
+-- | posteriordb の @eight_schools.json@ 形状 ({"J":8, "y":[...], "sigma":[...]})。
+data EightSchoolsData = EightSchoolsData
+  { y     :: [Double]
+  , sigma :: [Double]
+  }
+
+instance FromJSON EightSchoolsData where
+  parseJSON = withObject "EightSchoolsData" $ \v ->
+    EightSchoolsData <$> v .: "y" <*> v .: "sigma"
+
+noDf :: [(T.Text, ColData)]
+noDf = []
+
+dataPath :: FilePath
+dataPath = "bench/posteriordb/09-eight-schools/data/eight_schools.json"
+
+figuresDir :: FilePath
+figuresDir = "bench/posteriordb/09-eight-schools/figures"
+
+readData :: IO ([Double], [Double])
+readData = do
+  d <- either fail pure =<< eitherDecodeFileStrict dataPath
+  pure (y d, sigma d)
+
+-- | Non-centered 階層モデル: eta_j ~ Normal(0,1)・theta_j = mu + tau*eta_j
+-- (`docs/api-guide/03-bayesian-hbm.md` の eightSchools 例と同型・sigma は
+-- 観測ごとに既知の定数 (Stan 原典の `sigma` データ) なので dataNamedX で束縛する)。
+eightSchoolsModel :: ModelP ()
+eightSchoolsModel = do
+  mu  <- sample "mu"  (Normal 0 5)
+  tau <- sample "tau" (HalfCauchy 5)
+  sigmas <- dataNamedX   "sigma" []
+  ys     <- dataNamedObs "y"     []
+  etas <- plateI "school" 8 $ \j -> sample ("eta" .# j) (Normal 0 1)
+  plateForM_ "school_obs" (zip3 [0 ..] sigmas ys) $ \(j, sg, yi) ->
+    observe ("y" .# j) (Normal (mu + tau * etas !! j) sg) [yi]
+
+main :: IO ()
+main = do
+  (ys, sigmas) <- readData
+  let df = [ ("y",     NumData (V.fromList ys))
+           , ("sigma", NumData (V.fromList sigmas))
+           ] :: [(T.Text, ColData)]
+      -- PyMC 側 (model.py) と同じ設定を定数で揃える。
+      cfg = defaultHBM { hbmChains = 4, hbmSamples = 1000
+                        , hbmWarmup = 1000, hbmSeed = Just 1 }
+      m = df |-> hbm cfg eightSchoolsModel
+
+  -- Phase 96 A2: 勾配経路 = compileGradUV が実際に選ぶ経路 (束縛済
+  -- hbmModelSpec で判定・Phase 91 A4 と同型)。
+  putStrLn $ "勾配経路 = " ++ gradPathLabel (hbmModelSpec m)
+
+  -- サンプリング**のみ**の壁時計 (PyMC run_pymc_matrix.py の t0=perf_counter();
+  -- pm.sample() と対応させる・Common.timeSamplingMs 参照)。dashboardFullOf 等
+  -- 後続処理は hbmChainsR の thunk が既に強制済みのものを再利用するので、
+  -- 二重計算にはならない。
+  (_, samplingMs) <- timeSamplingMs (hbmChainsR m)
+  printf "sampling wall = %.1f ms (draws only, no dashboard/startup)\n" samplingMs
+
+  savePNGBound (figuresDir ++ "/hs_dashboard_full.png") $
+    (noDf |>> dashboardFullOf m "y" :: BoundPlot)
+
+  printSummary $ summarize ["mu", "tau"] (hbmChainsR m)
diff --git a/bench/posteriordb/10-rats/Model.hs b/bench/posteriordb/10-rats/Model.hs
new file mode 100644
--- /dev/null
+++ b/bench/posteriordb/10-rats/Model.hs
@@ -0,0 +1,136 @@
+{-# LANGUAGE OverloadedStrings #-}
+-- | rats_data-rats_model (posteriordb) — hanalyze (ModelP) 実装。
+--
+-- Phase 89: posteriordb 横断ベンチマーク。BUGS 古典例「ラットの成長曲線」
+-- (30匹 × 5時点の体重・縦断的階層線形回帰)。ラットごとに独立な切片
+-- alpha[i]・傾き beta[i] を持ち、両方とも部分プーリングされる
+-- (mu_alpha/sigma_alpha, mu_beta/sigma_beta)。
+--
+-- Stan 原典 (posteriordb `models/stan/rats_model.stan`。"Model simplified"
+-- 版・sigma_y/sigma_alpha/sigma_beta は真に improper flat prior):
+--   parameters { array[N] real alpha; array[N] real beta;
+--                real mu_alpha; real mu_beta;
+--                real<lower=0> sigma_y; real<lower=0> sigma_alpha; real<lower=0> sigma_beta; }
+--   model {
+--     mu_alpha ~ normal(0, 100); mu_beta ~ normal(0, 100);
+--     alpha ~ normal(mu_alpha, sigma_alpha); beta ~ normal(mu_beta, sigma_beta);
+--     y[n] ~ normal(alpha[rat[n]] + beta[rat[n]] * (x[n] - xbar), sigma_y);
+--   }
+--
+-- improper flat prior (下限0のみ・上限なし) は hanalyze に表現できないため、
+-- HalfCauchy(25) (09-eight-schools の tau ~ HalfCauchy(5) と同じ流儀の
+-- 緩い weakly-informative prior) に置換する。
+--
+-- ★実測で判明した罠: 当初 Uniform(0,100) で試したところ、全 warmup で
+-- acceptanceRate=0.0・chainEnergy=Infinity 全 draw で発散し、事後平均が
+-- 全パラメータで厳密に 0.0000 に凍りつく現象が発生した。原因は
+-- `Distribution.hs:309-310` の既知の制約:
+-- 「Uniform の真の制約変換 (logit-on-(lo,hi)) は現状未実装・unconstrained
+-- 扱い」。unconstrained 初期値は raw=0 だが、これが Uniform(lo,hi) では
+-- 変換なしにそのまま値 0 として使われる ("下限ちょうど" ではなく
+-- "変換前 0" が直接使われる)。sigma_y のような Normal 尤度の SD に
+-- Uniform(0,X) を直接使うと、初期値 sigma_y=0 で
+-- `Normal(mu, 0)` が退化し log-density が -Infinity になり、HMC が
+-- 初手から発散し続けて一切回復しない (01-glm-poisson の alpha/beta の
+-- ような「内部の有界パラメータ」なら raw=0 は無害だが、SD パラメータでは
+-- 致命的)。`HalfCauchy`/`HalfNormal` は `PositiveT` 変換 (exp 系) を持ち
+-- raw=0 が安全な内点にマップされるため、この罠を回避できる
+-- (実測: acceptanceRate 0.0 → 0.9 に復帰・確認は cabal repl トイモデルで
+-- 実施)。PyMC 側 (model.py) も同じ HalfCauchy(25) に揃える (両者比較可能
+-- にするため・Stan 原典の改変幅は同一なので公平性は保たれる)。
+--
+-- reference_posterior_name = null (posteriordb に公式 reference 無し・2者比較のみ)。
+--
+-- ラット番号 (rat[]) は df 列ではなく、Mh モデルの T と同じ流儀でモデル関数へ
+-- 直接 closure 引数として渡す (df|->hbm の対象データではなく構造情報)。
+-- ラットごとの alpha[i]/beta[i] latent は eight-schools 型 plateI + gather
+-- パターンで宣言する。
+--
+-- ビルド: cabal build --project-file=cabal.project.plot posteriordb-rats
+module Main (main) where
+
+import Data.Aeson (FromJSON (..), withObject, (.:), eitherDecodeFileStrict)
+import qualified Data.Text as T
+import qualified Data.Vector as V
+import Text.Printf (printf)
+
+import Hanalyze.Model.HBM (ModelP, Distribution (..), sample, observe,
+                                    dataNamedX, dataNamedObs, plateI, plateForM_, (.#))
+import Hanalyze.Model.HBM (gradPathLabel)
+import Hanalyze.Plot (hbmModelSpec)
+import Hanalyze.Plot (HBMConfig (..), defaultHBM, hbm, (|->),
+                              dashboardOf, hbmChainsR)
+import Hgg.Plot.Spec (ColData (..))
+import Hgg.Plot.Frame (BoundPlot, (|>>))
+import Hgg.Plot.Backend.Rasterific (savePNGBound)
+
+import Common (summarize, printSummary, timeSamplingMs)
+
+-- | posteriordb の @rats_data.json@ 形状
+-- ({"N":30, "Npts":150, "rat":[...], "x":[...], "y":[...], "xbar":22})。
+data RatsData = RatsData
+  { nRats :: Int
+  , rat   :: [Int]
+  , xArr  :: [Double]
+  , yArr  :: [Double]
+  , xbar  :: Double
+  }
+
+instance FromJSON RatsData where
+  parseJSON = withObject "RatsData" $ \v ->
+    RatsData <$> v .: "N" <*> v .: "rat" <*> v .: "x" <*> v .: "y" <*> v .: "xbar"
+
+noDf :: [(T.Text, ColData)]
+noDf = []
+
+dataPath :: FilePath
+dataPath = "bench/posteriordb/10-rats/data/rats_data.json"
+
+figuresDir :: FilePath
+figuresDir = "bench/posteriordb/10-rats/figures"
+
+readData :: IO RatsData
+readData = either fail pure =<< eitherDecodeFileStrict dataPath
+
+-- | 縦断的階層線形回帰 (ラットごとの切片/傾き)。ratIdx (1始まり)・xbar は
+-- データ由来の固定構造として closure で渡す (Mh モデルの T と同じ流儀)。
+ratsModel :: Int -> [Int] -> Double -> ModelP ()
+ratsModel n ratIdx center = do
+  muAlpha    <- sample "mu_alpha"    (Normal 0 100)
+  muBeta     <- sample "mu_beta"     (Normal 0 100)
+  sigmaY     <- sample "sigma_y"     (HalfCauchy 25)
+  sigmaAlpha <- sample "sigma_alpha" (HalfCauchy 25)
+  sigmaBeta  <- sample "sigma_beta"  (HalfCauchy 25)
+  alphas <- plateI "rat_alpha" n $ \i -> sample ("alpha" .# i) (Normal muAlpha sigmaAlpha)
+  betas  <- plateI "rat_beta"  n $ \i -> sample ("beta"  .# i) (Normal muBeta  sigmaBeta)
+  xs <- dataNamedX   "x" []
+  ys <- dataNamedObs "y" []
+  let centerA = realToFrac center
+  plateForM_ "obs" (zip3 ratIdx xs ys) $ \(r, xi, yi) ->
+    let mu = (alphas !! (r - 1)) + (betas !! (r - 1)) * (xi - centerA)
+    in observe "y" (Normal mu sigmaY) [yi]
+
+main :: IO ()
+main = do
+  d <- readData
+  let df = [ ("x", NumData (V.fromList (xArr d)))
+           , ("y", NumData (V.fromList (yArr d)))
+           ] :: [(T.Text, ColData)]
+      -- PyMC 側 (model.py) と同じ設定を定数で揃える。
+      cfg = defaultHBM { hbmChains = 4, hbmSamples = 1000
+                        , hbmWarmup = 1000, hbmSeed = Just 1 }
+      m = df |-> hbm cfg (ratsModel (nRats d) (rat d) (xbar d))
+
+  -- 勾配経路 = compileGradUV が実際に選ぶ経路 (束縛済 hbmModelSpec で判定・
+  -- Phase 91 A4: 生モデルを synthVecIR に渡すと data 空で誤表示するため差替)。
+  putStrLn $ "勾配経路 = " ++ gradPathLabel (hbmModelSpec m)
+
+  (_, samplingMs) <- timeSamplingMs (hbmChainsR m)
+  printf "sampling wall = %.1f ms (draws only, no dashboard/startup)\n" samplingMs
+
+  -- N=30 匹分の alpha/beta latent を含むため 'dashboardFullOf' は肥大化する
+  -- (05-mh と同じ判断)。健全性 2x2 パネル (DAG/forest/PPC/energy) のみ。
+  savePNGBound (figuresDir ++ "/hs_dashboard_full.png") $
+    (noDf |>> dashboardOf m "y" :: BoundPlot)
+
+  printSummary $ summarize ["mu_alpha", "mu_beta", "sigma_y", "sigma_alpha", "sigma_beta"] (hbmChainsR m)
diff --git a/bench/posteriordb/11-seeds/Model.hs b/bench/posteriordb/11-seeds/Model.hs
new file mode 100644
--- /dev/null
+++ b/bench/posteriordb/11-seeds/Model.hs
@@ -0,0 +1,126 @@
+{-# LANGUAGE OverloadedStrings #-}
+-- | seeds_data-seeds_model (posteriordb) — hanalyze (ModelP) 実装。
+--
+-- Phase 89: posteriordb 横断ベンチマーク。BUGS 古典例「種子発芽実験」
+-- (Crowder 1978・I=21プレート・2種の種子×2種の根の抽出物の2x2要因計画+
+-- overdispersion 用のプレートごとランダム切片)。
+--
+-- Stan 原典 (posteriordb `models/stan/seeds_model.stan`):
+--   parameters { real alpha0,alpha1,alpha2,alpha12; real<lower=0> tau;
+--                vector[I] b; }
+--   transformed parameters { sigma = 1/sqrt(tau); }
+--   model {
+--     alpha0,alpha1,alpha2,alpha12 ~ normal(0, 1000);
+--     tau ~ gamma(1e-3, 1e-3);
+--     b ~ normal(0, sigma);
+--     n ~ binomial_logit(N, alpha0 + alpha1*x1 + alpha2*x2 + alpha12*x1*x2 + b);
+--   }
+--
+-- hanalyze に `binomial_logit` 相当は無いため `Binomial N p` +
+-- `p = invlogit(eta)` へ手動展開する (05-mh の ZeroInflatedBinomial と
+-- 同じ流儀)。`tau ~ Gamma(1e-3,1e-3)` は hanalyze の `Gamma` が `PositiveT`
+-- 変換 (exp系) を持つため 10-rats で踏んだ「Uniform を SD に使う罠」は
+-- 発生しない (既知の一様事前 sd パラメータ発散パターン)。
+--
+-- reference_posterior_name = null (posteriordb に公式 reference 無し・2者比較のみ)。
+--
+-- N (試行数、プレートごとに既知の固定整数) は df 列ではなく、Mh モデルの T
+-- と同じ流儀でモデル関数へ直接 closure 引数として渡す。
+--
+-- ビルド: cabal build --project-file=cabal.project.plot posteriordb-seeds
+module Main (main) where
+
+import Data.Aeson (FromJSON (..), withObject, (.:), eitherDecodeFileStrict)
+import Data.List (zip4)
+import qualified Data.Text as T
+import qualified Data.Vector as V
+import Text.Printf (printf)
+
+import Hanalyze.Model.HBM (ModelP, Distribution (..), sample, observe,
+                                    dataNamedX, dataNamedObs, plateI, plateForM_, (.#))
+import Hanalyze.Model.HBM (gradPathLabel)
+import Hanalyze.Plot (hbmModelSpec)
+import Hanalyze.Plot (HBMConfig (..), defaultHBM, hbm, (|->),
+                              dashboardFullOf, hbmChainsR)
+import Hgg.Plot.Spec (ColData (..))
+import Hgg.Plot.Frame (BoundPlot, (|>>))
+import Hgg.Plot.Backend.Rasterific (savePNGBound)
+
+import Common (summarize, printSummary, timeSamplingMs)
+
+-- | posteriordb の @seeds_data.json@ 形状
+-- ({"I":21, "n":[...], "N":[...], "x1":[...], "x2":[...]})。
+data SeedsData = SeedsData
+  { nI   :: Int
+  , nObs :: [Int]
+  , nCap :: [Int]
+  , x1v  :: [Double]
+  , x2v  :: [Double]
+  }
+
+instance FromJSON SeedsData where
+  parseJSON = withObject "SeedsData" $ \v ->
+    SeedsData <$> v .: "I" <*> v .: "n" <*> v .: "N" <*> v .: "x1" <*> v .: "x2"
+
+noDf :: [(T.Text, ColData)]
+noDf = []
+
+dataPath :: FilePath
+dataPath = "bench/posteriordb/11-seeds/data/seeds_data.json"
+
+figuresDir :: FilePath
+figuresDir = "bench/posteriordb/11-seeds/figures"
+
+readData :: IO SeedsData
+readData = either fail pure =<< eitherDecodeFileStrict dataPath
+
+-- | 2x2要因計画のロジスティック回帰 + プレートごとランダム切片。
+-- i (プレート数)・trials (試行数 N、プレートごとの固定整数) は
+-- データ由来の固定構造として closure で渡す (Mh モデルの T と同じ流儀)。
+--
+-- Phase 94 A4-2: **非中心化** (non-centered)。 Stan 原典は centered
+-- (@b_k ~ Normal(0, σ)@) だが、 σ=1/√τ が小さい領域に funnel の首ができ、
+-- chain が凍結して tau ESS を潰す (§A4-1 で実測: centered は 1/4 chain 崩壊・
+-- ess(tau)=11)。 @z_k ~ Normal(0,1)@ + @b_k = z_k·σ@ に置換すると z が τ と
+-- decouple し首が消える (Stan/PyMC の hierarchical 定番)。 事後は同一。
+-- 非中心化後: 崩壊 0・ess(tau)=837 (§A4-2)。
+seedsModel :: Int -> [Int] -> ModelP ()
+seedsModel i trials = do
+  alpha0  <- sample "alpha0"  (Normal 0 1000)
+  alpha1  <- sample "alpha1"  (Normal 0 1000)
+  alpha2  <- sample "alpha2"  (Normal 0 1000)
+  alpha12 <- sample "alpha12" (Normal 0 1000)
+  tau     <- sample "tau"     (Gamma 1.0e-3 1.0e-3)
+  let sigma = 1 / sqrt tau
+  zs <- plateI "plate" i $ \k -> sample ("z" .# k) (Normal 0 1)   -- 非中心化 latent (b_k = z_k·σ)
+  x1s <- dataNamedX   "x1" []
+  x2s <- dataNamedX   "x2" []
+  ns  <- dataNamedObs "n"  []
+  plateForM_ "obs" (zip [0 :: Int ..] (zip4 trials x1s x2s ns)) $ \(k, (capK, x1k, x2k, nk)) ->
+    let eta = alpha0 + alpha1 * x1k + alpha2 * x2k + alpha12 * x1k * x2k
+                + (zs !! k) * sigma
+        p   = 1 / (1 + exp (negate eta))
+    in observe "n" (Binomial capK p) [nk]
+
+main :: IO ()
+main = do
+  d <- readData
+  let df = [ ("x1", NumData (V.fromList (x1v d)))
+           , ("x2", NumData (V.fromList (x2v d)))
+           , ("n",  NumData (V.fromList (map fromIntegral (nObs d))))
+           ] :: [(T.Text, ColData)]
+      cfg = defaultHBM { hbmChains = 4, hbmSamples = 1000
+                        , hbmWarmup = 1000, hbmSeed = Just 1 }
+      m = df |-> hbm cfg (seedsModel (nI d) (nCap d))
+
+  -- 勾配経路 = compileGradUV が実際に選ぶ経路 (束縛済 hbmModelSpec で判定・
+  -- Phase 91 A4: 生モデルを synthVecIR に渡すと data 空で誤表示するため差替)。
+  putStrLn $ "勾配経路 = " ++ gradPathLabel (hbmModelSpec m)
+
+  (_, samplingMs) <- timeSamplingMs (hbmChainsR m)
+  printf "sampling wall = %.1f ms (draws only, no dashboard/startup)\n" samplingMs
+
+  savePNGBound (figuresDir ++ "/hs_dashboard_full.png") $
+    (noDf |>> dashboardFullOf m "n" :: BoundPlot)
+
+  printSummary $ summarize ["alpha0", "alpha1", "alpha2", "alpha12", "tau"] (hbmChainsR m)
diff --git a/bench/posteriordb/12-ark/Model.hs b/bench/posteriordb/12-ark/Model.hs
new file mode 100644
--- /dev/null
+++ b/bench/posteriordb/12-ark/Model.hs
@@ -0,0 +1,114 @@
+{-# LANGUAGE OverloadedStrings #-}
+-- | arK-arK (posteriordb) — hanalyze (ModelP) 実装。
+--
+-- Phase 89: posteriordb 横断ベンチマーク。AR(K) (K次自己回帰) 時系列モデル
+-- (K=5・T=200)。GARCH (03-garch11) と異なり **分散ではなく平均のみが過去に
+-- 依存する**ため、全ての y は既知データであり、モデルとしては「K個のラグ
+-- 特徴量を使った静的な線形回帰」に帰着する (潜在変数間の自己参照的な
+-- 再帰は存在しない)。
+--
+-- Stan 原典 (posteriordb `models/stan/arK.stan`):
+--   parameters { real alpha; array[K] real beta; real<lower=0> sigma; }
+--   model {
+--     alpha ~ normal(0, 10); beta ~ normal(0, 10); sigma ~ cauchy(0, 2.5);
+--     for (t in (K+1):T) {
+--       mu = alpha + sum_{k=1}^{K} beta[k]*y[t-k];
+--       y[t] ~ normal(mu, sigma);
+--     }
+--   }
+--
+-- `sigma ~ cauchy(0, 2.5)` (下限0の半コーシー) は hanalyze の `HalfCauchy`
+-- (`PositiveT` 変換) にそのまま対応する (10-rats の Uniform-SD 罠に
+-- 該当しない・09-eight-schools の tau と同型)。
+--
+-- **reference_posterior_name = "arK-arK"** (posteriordb に公式 reference
+-- あり・hanalyze vs PyMC vs 公式referenceの3者比較が可能)。
+--
+-- K 個のラグ特徴量 (@lag1@..@lagK@) を Haskell 側で事前計算し、df 列として
+-- 束縛する (dataNamedX を K 回呼ぶ)。
+--
+-- ビルド: cabal build --project-file=cabal.project.plot posteriordb-ark
+module Main (main) where
+
+import Data.Aeson (FromJSON (..), withObject, (.:), eitherDecodeFileStrict)
+import qualified Data.Text as T
+import qualified Data.Vector as V
+import Text.Printf (printf)
+
+import Hanalyze.Model.HBM (ModelP, Distribution (..), sample, observe,
+                                    dataNamedX, dataNamedObs, plateI, plateForM_, (.#),
+                                    gradPathLabel)
+import Hanalyze.Plot (HBMConfig (..), defaultHBM, hbm, (|->),
+                              dashboardFullOf, hbmChainsR, hbmModelSpec)
+import Hgg.Plot.Spec (ColData (..))
+import Hgg.Plot.Frame (BoundPlot, (|>>))
+import Hgg.Plot.Backend.Rasterific (savePNGBound)
+
+import Common (summarize, printSummary, timeSamplingMs)
+
+-- | posteriordb の @arK.json@ 形状 ({"K":5, "T":200, "y":[...]})。
+data ArKData = ArKData { kLag :: Int, tLen :: Int, yArr :: [Double] }
+
+instance FromJSON ArKData where
+  parseJSON = withObject "ArKData" $ \v ->
+    ArKData <$> v .: "K" <*> v .: "T" <*> v .: "y"
+
+noDf :: [(T.Text, ColData)]
+noDf = []
+
+dataPath :: FilePath
+dataPath = "bench/posteriordb/12-ark/data/arK.json"
+
+figuresDir :: FilePath
+figuresDir = "bench/posteriordb/12-ark/figures"
+
+readData :: IO ArKData
+readData = either fail pure =<< eitherDecodeFileStrict dataPath
+
+-- | ラグ特徴量 (t=K..T-1 の各観測に対し @y[t-1]..y[t-K]@) と目的変数
+-- (@y[K]..y[T-1]@) を事前計算する。0始まりのインデックス。
+lagDesign :: Int -> [Double] -> ([[Double]], [Double])
+lagDesign k ys =
+  let yv = V.fromList ys
+      n  = V.length yv
+      obsIdx = [k .. n - 1]
+      targets = [ yv V.! t | t <- obsIdx ]
+      lags = [ [ yv V.! (t - lg) | t <- obsIdx ] | lg <- [1 .. k] ]  -- lags!!(lg-1)
+  in (lags, targets)
+
+-- | AR(K) 静的線形回帰 (ラグ特徴量は全て既知データ)。
+arKModel :: Int -> ModelP ()
+arKModel k = do
+  alpha <- sample "alpha" (Normal 0 10)
+  betas <- plateI "beta" k $ \j -> sample ("beta" .# (j + 1)) (Normal 0 10)
+  sigma <- sample "sigma" (HalfCauchy 2.5)
+  lagCols <- mapM (\lg -> dataNamedX (T.pack ("lag" ++ show lg)) []) [1 .. k]
+  ys <- dataNamedObs "y_obs" []
+  plateForM_ "obs" (zip [0 ..] ys) $ \(i, yi) ->
+    let mu = alpha + sum [ (betas !! (lg - 1)) * ((lagCols !! (lg - 1)) !! i) | lg <- [1 .. k] ]
+    in observe "y_obs" (Normal mu sigma) [yi]
+
+main :: IO ()
+main = do
+  d <- readData
+  let (lags, targets) = lagDesign (kLag d) (yArr d)
+      lagDf = [ (T.pack ("lag" ++ show lg), NumData (V.fromList col))
+              | (lg, col) <- zip [1 :: Int ..] lags ]
+      df = ("y_obs", NumData (V.fromList targets)) : lagDf
+      cfg = defaultHBM { hbmChains = 4, hbmSamples = 1000
+                        , hbmWarmup = 1000, hbmSeed = Just 1 }
+      m = df |-> hbm cfg (arKModel (kLag d))
+
+  -- 勾配経路 = compileGradUV が実際に選ぶ経路 (束縛済モデルで判定)。
+  -- Phase 91 A4: AR(K) は静的ラグ線形回帰 = Gaussian LM 閉形式ブロックに吸収。
+  -- ★生モデルを synthVecIR に渡すと data 空で Nothing と誤表示するため
+  --   'hbmModelSpec m' (df 束縛済) を 'gradPathLabel' に渡す。
+  putStrLn $ "勾配経路 = " ++ gradPathLabel (hbmModelSpec m)
+
+  (_, samplingMs) <- timeSamplingMs (hbmChainsR m)
+  printf "sampling wall = %.1f ms (draws only, no dashboard/startup)\n" samplingMs
+
+  savePNGBound (figuresDir ++ "/hs_dashboard_full.png") $
+    (noDf |>> dashboardFullOf m "y_obs" :: BoundPlot)
+
+  printSummary $ summarize ["alpha", "beta_1", "beta_2", "beta_3", "beta_4", "beta_5", "sigma"] (hbmChainsR m)
diff --git a/bench/posteriordb/13-traffic-accident-nyc/Model.hs b/bench/posteriordb/13-traffic-accident-nyc/Model.hs
new file mode 100644
--- /dev/null
+++ b/bench/posteriordb/13-traffic-accident-nyc/Model.hs
@@ -0,0 +1,260 @@
+{-# LANGUAGE OverloadedStrings #-}
+-- | traffic_accident_nyc-bym2_offset_only (posteriordb) — hanalyze (ModelP) 実装。
+--
+-- Phase 89: posteriordb 横断ベンチマーク。BYM2 空間疫学モデル (Morris et al.
+-- 2019) — NYC 交通事故データ (N=1921地域・N_edges=5461隣接ペア)。ICAR
+-- (intrinsic conditional autoregressive) 事前分布を「隣接ペアごとの
+-- 差分ペナルティ」(`target += -0.5*dot_self(phi[node1]-phi[node2])`) で
+-- 表現する Stan の標準的な BYM2 実装 (N×N 精度行列の陽な構築/逆行列計算を
+-- 回避する定石)。
+--
+-- Stan 原典 (posteriordb `models/stan/bym2_offset_only.stan`):
+--   parameters { real beta0; real<lower=0> sigma; real<lower=0,upper=1> rho;
+--                vector[N] theta; vector[N] phi; }
+--   transformed parameters {
+--     convolved_re = sqrt(1-rho)*theta + sqrt(rho/scaling_factor)*phi;
+--   }
+--   model {
+--     y ~ poisson_log(log_E + beta0 + convolved_re*sigma);
+--     target += -0.5 * dot_self(phi[node1] - phi[node2]);  -- ICAR pairwise
+--     beta0 ~ normal(0,1); theta ~ normal(0,1); sigma ~ normal(0,1);
+--     rho ~ beta(0.5,0.5);
+--     sum(phi) ~ normal(0, 0.001*N);                        -- soft sum-to-zero
+--   }
+--
+-- ★`phi` は Stan 原典で **固有の (marginal) prior を持たない** (`theta`とは
+-- 対照的)。ICAR ペナルティ + ソフトゼロ和制約のみが phi の情報源であり、
+-- 事実上「improper flat」を前提にしている。hanalyze には improper flat
+-- distribution が無いため、他モデル (01-glm-poisson の Uniform 箱等) と
+-- 同じ流儀で **`Normal 0 1000` という極めて diffuse な近似**を phi 自身の
+-- 周辺成分に与える (ICAR ペナルティが実質的に支配的なので事後への影響は
+-- 無視できる想定・要実測確認)。
+--
+-- `target += ...` の非標準尤度項は `potential` (PyMC `pm.Potential` 相当)
+-- で表現する。ソフトゼロ和制約 (`sum(phi) ~ normal(...)`) は
+-- `logDensity (Normal 0 sd) (sum phis)` を `potential` に渡す形で実装する。
+--
+-- reference_posterior_name = null (posteriordb に公式 reference 無し・2者比較のみ)。
+--
+-- ★N=1921 (theta+phi=3842 latent) + N_edges=5461 という大規模モデル。
+-- Phase 90 A10-1 実測 (2026-07-11) により: Poisson 尤度 + theta/phi 族は
+-- **vecIR に吸収済み (synthVecIR = Just)**。 ただし `potential` 2 項
+-- (icar / sum_zero) は vecIR 非対応で残差 ad に落ち、 これが勾配 1 回
+-- ~0.13s の 93% を占める (potential 無し対照 = 0.009s)。 詳細は
+-- specification/phases/phase-90-vecir-gap-extensions.md §A10-1。
+--
+-- ビルド: cabal build --project-file=cabal.project.plot posteriordb-bym2
+module Main (main) where
+
+import Data.Aeson (FromJSON (..), withObject, (.:), eitherDecodeFileStrict)
+import qualified Data.Map.Strict as Map
+import qualified Data.Set as Set
+import qualified Data.Text as T
+import qualified Data.Vector as BV
+import qualified Data.Vector as V
+import qualified Data.Vector.Unboxed as VU
+import System.Environment (getArgs)
+import System.Exit (exitSuccess)
+import System.IO (BufferMode (..), hSetBuffering, stdout)
+import Text.Printf (printf)
+
+import Hanalyze.Model.HBM (ModelP, Distribution (..), sample, observe,
+                                    dataNamedX, dataNamedObs, plateI, plateForM_,
+                                    potential, logDensity, (.#), sampleNames)
+import qualified Hanalyze.Model.HBM.Gradient as G
+import qualified Hanalyze.Model.HBM.IR as IR
+import Hanalyze.Model.HBM (gradPathLabel)
+import Hanalyze.Plot (hbmModelSpec)
+import Hanalyze.Plot (HBMConfig (..), defaultHBM, hbm, (|->),
+                              dashboardOf, hbmChainsR)
+import Hgg.Plot.Spec (ColData (..))
+import Hgg.Plot.Frame (BoundPlot, (|>>))
+import Hgg.Plot.Backend.Rasterific (savePNGBound)
+
+import Common (summarize, printSummary, timeSamplingMs)
+
+-- | posteriordb の @traffic_accident_nyc.json@ 形状
+-- ({"N":1921, "N_edges":5461, "node1":[...], "node2":[...], "y":[...],
+--   "E":[...], "scaling_factor":0.7137})。
+data TrafficData = TrafficData
+  { nAreas   :: Int
+  , nEdges   :: Int
+  , node1v   :: [Int]
+  , node2v   :: [Int]
+  , yObs     :: [Int]
+  , eOffset  :: [Double]
+  , scalingF :: Double
+  }
+
+instance FromJSON TrafficData where
+  parseJSON = withObject "TrafficData" $ \v ->
+    TrafficData <$> v .: "N" <*> v .: "N_edges" <*> v .: "node1" <*> v .: "node2"
+                <*> v .: "y" <*> v .: "E" <*> v .: "scaling_factor"
+
+noDf :: [(T.Text, ColData)]
+noDf = []
+
+dataPath :: FilePath
+dataPath = "bench/posteriordb/13-traffic-accident-nyc/data/traffic_accident_nyc.json"
+
+figuresDir :: FilePath
+figuresDir = "bench/posteriordb/13-traffic-accident-nyc/figures"
+
+readData :: IO TrafficData
+readData = either fail pure =<< eitherDecodeFileStrict dataPath
+
+-- | BYM2 空間疫学モデル。edges (0始まりに変換済のnode1/node2ペア)・
+-- scalingFactor はデータ由来の固定構造として closure で渡す。
+--
+-- ★Phase 90 A10-1: data list は引数で直接束縛する (`df |->` は同名同値の
+-- 束縛なので挙動不変)。 旧実装は `dataNamedX "log_E" []` (空 list) だったため
+-- `main` の `synthVecIR` 診断が「観測行 0 → Nothing」 を印字してしまい、
+-- 実サンプラ (df 束縛済) が vecIR = Just で走っている事実を隠していた。
+bym2Model :: Int -> [(Int, Int)] -> Double -> [Double] -> [Double] -> ModelP ()
+bym2Model n edges scalingFactor logEsIn ysIn = do
+  beta0 <- sample "beta0" (Normal 0 1)
+  sigma <- sample "sigma" (HalfNormal 1)
+  rho   <- sample "rho"   (Beta 0.5 0.5)
+  thetas <- plateI "theta" n $ \i -> sample ("theta" .# i) (Normal 0 1)
+  phis   <- plateI "phi"   n $ \i -> sample ("phi"   .# i) (Normal 0 1000)
+  logEs <- dataNamedX   "log_E" logEsIn
+  ys    <- dataNamedObs "y"     ysIn
+  let scalingA = realToFrac scalingFactor
+      convolved i = sqrt (1 - rho) * (thetas !! i) + sqrt (rho / scalingA) * (phis !! i)
+  plateForM_ "obs" (zip3 [0 ..] logEs ys) $ \(i, logEi, yi) ->
+    let eta = logEi + beta0 + convolved i * sigma
+    in observe "y" (Poisson (exp eta)) [yi]
+  -- ICAR ペア差分ペナルティ (Stan の `target += -0.5*dot_self(phi[n1]-phi[n2])`)。
+  let icarPenalty = negate 0.5 * sum
+        [ (phis !! a - phis !! b) * (phis !! a - phis !! b) | (a, b) <- edges ]
+  potential "icar" icarPenalty
+  -- ソフトゼロ和制約 (`sum(phi) ~ normal(0, 0.001*N)`)。
+  let sdSumZero = realToFrac (0.001 * fromIntegral n :: Double)
+  potential "sum_zero" (logDensity (Normal 0 sdSumZero) (sum phis))
+
+-- ===========================================================================
+-- Phase 99 A2: vecIR arena 命令列の静的 dump (ICAR の融合度を実測)
+-- ===========================================================================
+
+-- | @synthVecIR@ → @compileVecIR@ でコンパイルし、命令種別の内訳と長さ分布を
+-- 出力する。ICAR (5461 edge の二次形式) が融合ベクトル op か scalar 展開かを
+-- 目視判定するための静的解析 (BenchHBMVecIRProf.instrMix と同型)。
+dumpIR :: ModelP () -> IO ()
+dumpIR m = do
+  let names = sampleNames m
+      nP    = length names
+  putStrLn $ "sampleNames nP = " ++ show nP
+  case IR.synthVecIR m of
+    Nothing -> putStrLn "synthVecIR = Nothing (vecIR に乗っていない)"
+    Just (gs, fams, sObs) -> do
+      let ixOf = Map.fromList (zip names [0 :: Int ..])
+          cvi  = IR.compileVecIR ixOf gs fams
+          prog = IR.cvProg cvi
+          instrs = BV.toList (IR.vpInstrs prog)
+          lens   = VU.toList (IR.vpLen prog)
+          keyOf ins = case ins of
+            IR.VIK{}       -> "VIK   (スカラ定数)"
+            IR.VIKV{}      -> "VIKV  (ベクトル定数)"
+            IR.VILeafS{}   -> "VILeafS (scalar leaf)"
+            IR.VILeafV{}   -> "VILeafV (vector leaf)"
+            IR.VIGath{}    -> "VIGath (gather)"
+            IR.VIUn{}      -> "VIUn  (elementwise 単項)"
+            IR.VIBin{}     -> "VIBin (elementwise 二項)"
+            IR.VISum{}     -> "VISum (Σ 縮約)"
+            IR.VIAxpy{}    -> "VIAxpy (a+s·v 融合)"
+            IR.VIAxpyC{}   -> "VIAxpyC (a+s·const 融合)"
+            IR.VISumSqD{}  -> "VISumSqD (Σ(x−m)² 融合)"
+            IR.VISumSqC{}  -> "VISumSqC (Σ(c−m)² 融合)"
+            IR.VIMulG{}    -> "VIMulG (s·gather 融合)"
+            IR.VIAxpyG{}   -> "VIAxpyG (a+s·gather 融合)"
+            IR.VIMulVC{}   -> "VIMulVC (s·v⊙c 融合)"
+            IR.VISumSqC2{} -> "VISumSqC2 (Σ(c−m1−m2)² 融合)"
+            IR.VISumSqDGG{} -> "VISumSqDGG (Σ(gath−gath)² 融合 = ICAR)"
+          accum mp (ins, l) =
+            Map.insertWith (\(c1, e1) (c2, e2) -> (c1 + c2, e1 + e2))
+              (keyOf ins) (1 :: Int, max 1 l) mp
+          mixed = [ (k, c, e) | (k, (c, e)) <- Map.toList (foldl accum Map.empty (zip instrs lens)) ]
+                    :: [(String, Int, Int)]
+          famSet = Set.fromList (concat [ ms | (ms, _, _) <- fams ])
+          cps    = G.constPriorsOf m famSet
+          exclNames = sObs `Set.union` famSet
+                      `Set.union` Set.fromList (map fst cps)
+          noResid = G.residualFreeOfDensity exclNames m
+      printf "instrs=%d  vpSize(arena セル)=%d  guards=%d\n"
+        (BV.length (IR.vpInstrs prog)) (IR.vpSize prog)
+        (length (IR.vpGuards prog))
+      printf "residual ad (mPriorGrad): %s  (constPriors=%d, excl=%d/%d)\n"
+        (if noResid then "なし (noResid)" else "★あり = per-eval に ad が乗る" :: String)
+        (length cps) (Set.size exclNames) nP
+      putStrLn "\n--- 命令列 mix (種別 / 本数 / 総セル数) ---"
+      mapM_ (\(k, c, e) -> printf "  %-28s %5d 本  %8d セル\n" k c e) mixed
+
+main :: IO ()
+main = do
+  hSetBuffering stdout NoBuffering
+  d0 <- readData
+  -- ★A9 プローブ用の一時トランケーション: 引数に N を与えると先頭 N 地域 +
+  --   両端点が N 未満の edge だけに縮小する (スケーリング実測用)。
+  args <- getArgs
+  let d = case args of
+            (nStr:_) | [(nn, "")] <- reads nStr ->
+              let keep = [ (a, b) | (a, b) <- zip (node1v d0) (node2v d0)
+                                  , a <= nn, b <= nn ]
+              in d0 { nAreas = nn, nEdges = length keep
+                    , node1v = map fst keep, node2v = map snd keep
+                    , yObs = take nn (yObs d0), eOffset = take nn (eOffset d0) }
+            _ -> d0
+  putStrLn $ "N = " ++ show (nAreas d) ++ ", N_edges = " ++ show (nEdges d)
+  let n = nAreas d
+      edges = [ (a - 1, b - 1) | (a, b) <- zip (node1v d) (node2v d) ]  -- 0始まりに変換
+      logEArr = map log (eOffset d)
+      df = [ ("log_E", NumData (V.fromList logEArr))
+           , ("y",     NumData (V.fromList (map fromIntegral (yObs d))))
+           ] :: [(T.Text, ColData)]
+  let ysD = map fromIntegral (yObs d)
+
+  -- ★Phase 99 A2: `dumpir` = vecIR arena の静的命令列を dump し、ICAR ペア差分
+  --   二次形式が (a) 融合 op (VIGath+VISumSqD 等) か (b) 5461 個の scalar op に
+  --   展開されているかを実測する (A2a の prize サイズ判定・推測するな計測せよ)。
+  case args of
+    ["dumpir"] -> do
+      let rawM :: ModelP ()
+          rawM = bym2Model n edges (scalingF d) logEArr ysD
+      dumpIR rawM
+      exitSuccess
+    _ -> pure ()
+
+  -- ★N=1921・N_edges=5461 の大規模モデル。本番計測 (4chain×warmup1000+
+  -- draws1000) の前に、まず縮小設定 (1chain×warmup3+draws3) でタイミング
+  -- プローブを行い、フル run の所要時間を見積もってから判断すること
+  -- (03-garch11/08-hudson-lynx-hare と同じ「保留判断」の慎重さで臨む)。
+  -- probe モード: 引数 <N> = 縮小 cfg (1chain・3+3) / <N> <warmup> <draws>
+  -- = 1chain で指定サイズ (Phase 90 A11 のプロファイリング run 用)。
+  let cfg = case args of
+        (_:wStr:dStr:_)
+          | [(w, "")] <- reads wStr, [(dd, "")] <- reads dStr ->
+              defaultHBM { hbmChains = 1, hbmSamples = dd
+                         , hbmWarmup = w, hbmSeed = Just 1 }
+        (_:_) -> defaultHBM { hbmChains = 1, hbmSamples = 3
+                            , hbmWarmup = 3, hbmSeed = Just 1 }
+        []    -> defaultHBM { hbmChains = 4, hbmSamples = 1000
+                            , hbmWarmup = 1000, hbmSeed = Just 1 }
+      m = df |-> hbm cfg (bym2Model n edges (scalingF d) logEArr ysD)
+
+  -- 勾配経路 = compileGradUV が実際に選ぶ経路 (束縛済 hbmModelSpec で判定・
+  -- Phase 91 A4: BYM2 は vecIR に吸収済。旧診断は生モデルを synthVecIR に渡し
+  -- 観測 0 行 → Nothing と誤表示していた ので hbmModelSpec m に差替)。
+  putStrLn $ "勾配経路 = " ++ gradPathLabel (hbmModelSpec m)
+
+  (_, samplingMs) <- timeSamplingMs (hbmChainsR m)
+  printf "sampling wall = %.1f ms (draws only, no dashboard/startup)\n" samplingMs
+
+  -- N=1921×2 latent のため dashboardFullOf は非実用サイズ (05-mh と同じ
+  -- 判断)。健全性2x2パネル (DAG/forest/PPC/energy) のみ。
+  -- (A9 プローブのトランケーション時は figure を汚さないためスキップ)
+  case args of
+    (_:_) -> pure ()
+    []    -> savePNGBound (figuresDir ++ "/hs_dashboard_full.png") $
+               (noDf |>> dashboardOf m "y" :: BoundPlot)
+
+  printSummary $ summarize ["beta0", "sigma", "rho"] (hbmChainsR m)
diff --git a/bench/posteriordb/14-hmm-example/Model.hs b/bench/posteriordb/14-hmm-example/Model.hs
new file mode 100644
--- /dev/null
+++ b/bench/posteriordb/14-hmm-example/Model.hs
@@ -0,0 +1,193 @@
+{-# LANGUAGE OverloadedStrings #-}
+-- | hmm_example-hmm_example (posteriordb) — hanalyze (ModelP) 実装。
+--
+-- Phase 89: posteriordb 横断ベンチマーク。単純な隠れマルコフモデル
+-- (K=2状態・N=100観測・1次元Gaussian放出)。離散潜在状態は NUTS で直接
+-- サンプリングできないため、Stan 原典どおり forward algorithm で状態列を
+-- 周辺化 (marginalize) した対数尤度を使う。
+--
+-- Stan 原典 (posteriordb `models/stan/hmm_example.stan`):
+--   parameters { simplex[K] theta1; simplex[K] theta2; positive_ordered[K] mu; }
+--   model {
+--     mu[1] ~ normal(3,1); mu[2] ~ normal(10,1);
+--     // forward algorithm (状態列の周辺化)
+--     gamma[1,k] = normal_lpdf(y[1]|mu[k],1);  -- pi0 項なし (暗黙一様)
+--     gamma[t,k] = log_sum_exp_j(gamma[t-1,j] + log(theta[j,k])) + normal_lpdf(y[t]|mu[k],1);
+--     target += log_sum_exp(gamma[N]);
+--   }
+--
+-- hanalyze には既に `hmmForwardLogLik` (log-space forward recursion・
+-- Rabiner 1989) と `dirichlet` helper が実装済み (Phase 39-A4)。
+-- `theta1`/`theta2` (simplex・Stan原典は暗黙一様事前分布) は
+-- `dirichlet name [1,1]` (= Dirichlet(1,1) = simplex上一様) で移植する。
+-- `pi0` は Stan 原典に明示的な項が無い (`gamma[1,k]` に log π_0 が加算
+-- されない) ため、`hmmForwardLogLik` の pi0 引数には `replicate k 1`
+-- (= log 1 = 0、実質「項なし」と等価) を渡す。
+--
+-- ★`positive_ordered[K]` (mu[1] < mu[2] の順序制約) に対応する分布は
+-- hanalyze に無い。★実測で判明: 順序制約なしで `mu_1 ~ Normal(3,1)`・
+-- `mu_2 ~ Normal(10,1)` を独立にサンプリングしたところ、chain間で
+-- ラベルスイッチング (mu_1/mu_2 の意味がchainごとに入れ替わる) が発生し
+-- r_hat が 17台という壊滅的な値になった (両事前分布が7σ以上離れていても、
+-- 制約が全く無いと exchangeability により初期値次第でどちらのラベル
+-- 付けにも収束しうる)。
+--
+-- 解決: `mu_2 = mu_1 + gap` (gap>0) という**加算的な順序制約**を導入し、
+-- gap 自身の sample 事前分布 (HalfNormal) の寄与を `potential` で正確に
+-- 打ち消して `Normal(10,1)` の寄与に置き換える (数学的に厳密・近似では
+-- ない): 総 log-density 寄与 = [HalfNormal(gap) の寄与] + [potential] =
+-- logDensity(HalfNormal, gap) + (logDensity(Normal 10 1, mu2) −
+-- logDensity(HalfNormal, gap)) = logDensity(Normal 10 1, mu2)。
+-- Stan の `positive_ordered` 変換のヤコビアンは加算シフトのため 1
+-- (寄与なし) なので、この構成は Stan 原典と数学的に等価。
+--
+-- **reference_posterior_name = "hmm_example-hmm_example"** (posteriordb
+-- に公式 reference あり・hanalyze vs PyMC vs 公式referenceの3者比較可能)。
+--
+-- ビルド: cabal build --project-file=cabal.project.plot posteriordb-hmm
+module Main (main) where
+
+import Control.Monad (unless)
+import Data.Aeson (FromJSON (..), withObject, (.:), eitherDecodeFileStrict)
+import Data.List (group, intercalate, sort, transpose)
+import qualified Data.Text as T
+import qualified Data.Vector as V
+import System.Environment (getArgs)
+import Text.Printf (printf)
+
+import Hanalyze.Model.HBM (ModelP, Distribution (..), sample, dataNamedX,
+                                    dirichlet, potential, logDensity, observeMV,
+                                    deterministic, augmentChainWithDeterministic)
+import Hanalyze.Model.HBM (gradPathLabel)
+import Hanalyze.Plot (hbmModelSpec)
+import Hanalyze.Plot (HBMConfig (..), defaultHBM, hbm, (|->),
+                              dashboardFullOf, hbmChainsR)
+import Hgg.Plot.Spec (ColData (..))
+import Hgg.Plot.Frame (BoundPlot, (|>>))
+import Hgg.Plot.Backend.Rasterific (savePNGBound)
+
+import Hanalyze.MCMC.Core (Chain, chainAccepted, chainDivergences,
+                                    chainTotal, chainTreeDepths, chainVals)
+
+import Common (summarize, printSummary, timeSamplingMs)
+
+-- | posteriordb の @hmm_example.json@ 形状 ({"N":100, "K":2, "y":[...]})。
+data HmmData = HmmData { nObsHmm :: Int, kStates :: Int, yArr :: [Double] }
+
+instance FromJSON HmmData where
+  parseJSON = withObject "HmmData" $ \v ->
+    HmmData <$> v .: "N" <*> v .: "K" <*> v .: "y"
+
+noDf :: [(T.Text, ColData)]
+noDf = []
+
+dataPath :: FilePath
+dataPath = "bench/posteriordb/14-hmm-example/data/hmm_example.json"
+
+figuresDir :: FilePath
+figuresDir = "bench/posteriordb/14-hmm-example/figures"
+
+readData :: IO HmmData
+readData = either fail pure =<< eitherDecodeFileStrict dataPath
+
+-- | K=2 状態 HMM (forward algorithm で状態列を周辺化)。
+--
+-- Phase 92 A2: 尤度を @potential (hmmForwardLogLik ...)@ から構造化 primitive
+-- 'HmmForwardNormal' + 'observeMV' へ移行 (密度は同値・'obsLogSum' が同じ
+-- forward recursion を呼ぶ)。 役割 (π_0/遷移行/emission 平均/σ) が型で見える
+-- ため、 勾配コンパイラが forward-backward の閉形式随伴 ('hmmAnalyticVG'・
+-- AD tape ゼロ) を選べる。 dataNamedX "y" は dashboard の実データ参照用に残す。
+hmmModel :: Int -> [Double] -> ModelP ()
+hmmModel k ysRaw = do
+  mu1 <- sample "mu_1" (Normal 3 1)
+  gap <- sample "gap"  (HalfNormal 5)
+  mu2 <- deterministic "mu_2" (mu1 + gap)
+  potential "mu2_prior" (logDensity (Normal 10 1) mu2 - logDensity (HalfNormal 5) gap)
+  theta1 <- dirichlet "theta1" (replicate k 1)
+  theta2 <- dirichlet "theta2" (replicate k 1)
+  _ys <- dataNamedX "y" []
+  let mus   = [mu1, mu2]
+      trans = [theta1, theta2]
+      pi0   = replicate k 1
+  observeMV "y_seq" (HmmForwardNormal pi0 trans mus 1) [ysRaw]
+
+main :: IO ()
+main = do
+  d <- readData
+  -- Phase 92 A1d: `reduced` 引数で prof 用縮小 run (1chain・warmup200+draws200・
+  -- 図出力 skip = Rasterific が cost centre を汚さないため)。既定は本番設定。
+  args <- getArgs
+  -- Phase 92 ess/draw 調査: `seed N` で乱数 seed を差し替え可能に (ess 推定の
+  -- seed 感度確認用)。既定 = 1 (従来記録と bit 一致)。
+  let reduced = elem "reduced" args
+      seedArg = case dropWhile (/= "seed") args of
+                  (_ : s : _) -> read s
+                  _           -> 1  -- hbmSeed は Word32
+  let df = [ ("y", NumData (V.fromList (yArr d))) ] :: [(T.Text, ColData)]
+      cfg | reduced   = defaultHBM { hbmChains = 1, hbmSamples = 200
+                                    , hbmWarmup = 200, hbmSeed = Just seedArg }
+          | otherwise = defaultHBM { hbmChains = 4, hbmSamples = 1000
+                                    , hbmWarmup = 1000, hbmSeed = Just seedArg }
+      m = df |-> hbm cfg (hmmModel (kStates d) (yArr d))
+
+  -- 勾配経路 = compileGradUV が実際に選ぶ経路 (束縛済 hbmModelSpec で判定・
+  -- Phase 91 A4: 生モデルを synthVecIR に渡すと data 空で誤表示するため差替)。
+  putStrLn $ "勾配経路 = " ++ gradPathLabel (hbmModelSpec m)
+
+  (_, samplingMs) <- timeSamplingMs (hbmChainsR m)
+  printf "sampling wall = %.1f ms (draws only, no dashboard/startup)\n" samplingMs
+
+  -- seed≠1 の ess 感度 run では確定図 (seed1 前提) を上書きしない。
+  unless (reduced || seedArg /= 1) $
+    savePNGBound (figuresDir ++ "/hs_dashboard_full.png") $
+      (noDf |>> dashboardFullOf m "y" :: BoundPlot)
+
+  -- theta1_*/theta2_*/mu_2 は deterministic (dirichlet の棒折り変換・
+  -- mu2の加算的順序制約) のため、summarize の前に augmentChainWithDeterministic
+  -- で Chain へ注入する (無いと chainVals が空リストを返し NaN/ess=0 になる)。
+  let chainsAug = map (augmentChainWithDeterministic (hmmModel (kStates d) (yArr d))) (hbmChainsR m)
+  printSummary $ summarize ["mu_1", "mu_2", "gap", "theta1_0", "theta1_1", "theta2_0", "theta2_1"] chainsAug
+
+  -- Phase 92 ess/draw 調査: per-draw NUTS 診断 (nutpie sample_stats との
+  -- 突き合わせ用)。depth は post-warmup・draw 順 (Core.chainTreeDepths)。
+  -- accept は burn-in 込みの粗い受理率 (Chain には per-draw accept-stat が
+  -- 無いため参考値)。leapfrog/draw ≈ 2^depth。
+  putStrLn "\n== per-chain NUTS diagnostics (depth = post-warmup) =="
+  printDiagnostics (hbmChainsR m)
+
+  -- 全 chain の post-warmup draw を CSV 化し、Python 側 (arviz) で PyMC と
+  -- 同一指標 (rank-normalized ess_bulk・4 chain) の ESS を計算する。
+  -- Common.summarize の ess は chain 0 のみ + Geyer IMSE (tau 下限 1 クランプで
+  -- n 頭打ち) のため nutpie の ess_bulk と直接比較できない。
+  writeDrawsCSV "bench/posteriordb/14-hmm-example/hmm_draws_postwarmup.csv"
+    ["mu_1", "mu_2", "gap", "theta1_0", "theta1_1", "theta2_0", "theta2_1"]
+    chainsAug
+
+printDiagnostics :: [Chain] -> IO ()
+printDiagnostics chains = do
+  mapM_ printOne (zip [0 :: Int ..] chains)
+  let allDepths = concatMap chainTreeDepths chains
+      histo = map (\g -> (head g, length g)) . group . sort $ allDepths
+  putStrLn $ "depth histogram (all chains): "
+          ++ intercalate ", " [ show dep ++ ":" ++ show c | (dep, c) <- histo ]
+  where
+    printOne (i, ch) = do
+      let ds    = chainTreeDepths ch
+          nd    = fromIntegral (length ds) :: Double
+          meanD = fromIntegral (sum ds) / nd
+          leap  = sum [ 2 ^ dep | dep <- ds ] :: Int
+          acc   = fromIntegral (chainAccepted ch) / fromIntegral (chainTotal ch) :: Double
+      printf "chain %d: mean_depth=%.2f  est_leapfrog/draw=%.1f  div=%d  accept(incl-warmup)=%.3f\n"
+        i meanD (fromIntegral leap / nd :: Double) (length (chainDivergences ch)) acc
+
+writeDrawsCSV :: FilePath -> [T.Text] -> [Chain] -> IO ()
+writeDrawsCSV path pars chains = do
+  let rows = concat
+        [ [ show ci ++ "," ++ show di ++ ","
+              ++ intercalate "," (map (printf "%.17g") vals)
+          | (di, vals) <- zip [0 :: Int ..] (transpose cols) ]
+        | (ci, ch) <- zip [0 :: Int ..] chains
+        , let cols = [ chainVals p ch | p <- pars ] ]
+      header = "chain,draw," ++ intercalate "," (map T.unpack pars)
+  writeFile path (unlines (header : rows))
+  putStrLn $ "draws CSV -> " ++ path
diff --git a/bench/posteriordb/15-dugongs/Model.hs b/bench/posteriordb/15-dugongs/Model.hs
new file mode 100644
--- /dev/null
+++ b/bench/posteriordb/15-dugongs/Model.hs
@@ -0,0 +1,137 @@
+{-# LANGUAGE OverloadedStrings #-}
+-- | dugongs_data-dugongs_model (posteriordb) — hanalyze (ModelP) 実装。
+--
+-- Phase 89: posteriordb 横断ベンチマーク。BUGS 古典例「ジュゴンの成長曲線」
+-- (N=27頭・体長 Y と年齢 x の非線形漸近成長曲線回帰)。
+--
+-- Stan 原典 (posteriordb `models/stan/dugongs_model.stan`):
+--   parameters {
+--     real alpha; real beta;
+--     real<lower=.5,upper=1> lambda;
+--     real<lower=0> tau;
+--   }
+--   transformed parameters { sigma = 1/sqrt(tau); U3 = logit(lambda); }
+--   model {
+--     m[i] = alpha - beta * pow(lambda, x[i]);
+--     Y ~ normal(m, sigma);
+--     alpha ~ normal(0,1000); beta ~ normal(0,1000);
+--     lambda ~ uniform(.5,1); tau ~ gamma(.0001,.0001);
+--   }
+--
+-- 高レベル API (`df |-> hbm`) を使用。 sigma/U3 は log-density に寄与しない
+-- transformed parameters なので `deterministic` (PyMC Deterministic 相当) で
+-- 事後サンプルに注入する (14-hmm-example と同じパターン)。
+--
+-- ★実測で踏んだ罠: `lambda ~ Uniform(.5,1)` をそのまま `sample` すると
+-- 10-rats で確認済みの罠が新形態で再現した — hanalyze の Uniform は
+-- unconstrained 扱い (Distribution.hs:309-310) で unconstrained 初期値
+-- raw=0 がそのまま lambda=0 になる (Uniform(lo,hi) は raw をそのまま
+-- 値として使う・変換なし)。 lambda=0 は `Uniform(.5,1)` の台の外なので
+-- 初手から `logDensity = -Infinity` となり、全 4 chain・全 warmup で HMC
+-- 提案が拒否され続けて `alpha=beta=lambda=0.0000・tau=sigma=1.0000` に
+-- 完全凍結する現象を実機で確認した (`ess=1000`・`r_hat=NA` は分散ゼロの
+-- 兆候)。解決: `lambda ~ Uniform(.5,1)` を「`u ~ Beta(1,1)`
+-- (= Uniform(0,1) と同一分布・`UnitIntervalT` 変換で真に (0,1) に収まる
+-- 安全な初期値を持つ) → `lambda = 0.5 + 0.5*u`」というアフィン再パラメタ化
+-- に置換 (Jacobian は定数 0.5 で HMC の相対密度に影響しないため厳密に
+-- 等価)。 14-hmm-example の順序制約 (加算シフト+potential) と同系統の
+-- 「unconstrained分布の代わりに真に制約された分布から affine 変換する」
+-- 対処法。
+--
+-- reference_posterior_name = null (posteriordb に公式 reference posterior 無し)。
+--
+-- ビルド: cabal build --project-file=cabal.project.plot posteriordb-dugongs
+module Main (main) where
+
+import Control.Monad (unless)
+import Data.Aeson (FromJSON (..), withObject, (.:), eitherDecodeFileStrict)
+import qualified Data.Text as T
+import qualified Data.Vector as V
+import System.Environment (getArgs)
+import Text.Printf (printf)
+
+import Hanalyze.Model.HBM (ModelP, Distribution (..), sample, observe,
+                                    dataNamedX, dataNamedObs, plateForM_,
+                                    deterministic, augmentChainWithDeterministic)
+import Hanalyze.Model.HBM (gradPathLabel)
+import Hanalyze.Plot (hbmModelSpec)
+import Hanalyze.Plot (HBMConfig (..), defaultHBM, hbm, (|->),
+                              dashboardFullOf, hbmChainsR)
+import Hgg.Plot.Spec (ColData (..))
+import Hgg.Plot.Frame (BoundPlot, (|>>))
+import Hgg.Plot.Backend.Rasterific (savePNGBound)
+
+import Common (summarize, printSummary, timeSamplingMs)
+
+-- | posteriordb の @dugongs_data.json@ 形状 ({"Y":[...], "x":[...], "N":27})。
+data DugongsData = DugongsData
+  { dugongsY :: [Double]
+  , dugongsX :: [Double]
+  }
+
+instance FromJSON DugongsData where
+  parseJSON = withObject "DugongsData" $ \v ->
+    DugongsData <$> v .: "Y" <*> v .: "x"
+
+noDf :: [(T.Text, ColData)]
+noDf = []
+
+dataPath :: FilePath
+dataPath = "bench/posteriordb/15-dugongs/data/dugongs_data.json"
+
+figuresDir :: FilePath
+figuresDir = "bench/posteriordb/15-dugongs/figures"
+
+readData :: IO ([Double], [Double])
+readData = do
+  d <- either fail pure =<< eitherDecodeFileStrict dataPath
+  pure (dugongsY d, dugongsX d)
+
+-- | 非線形漸近成長曲線回帰 (Stan 原典と同一構造)。
+dugongsModel :: ModelP ()
+dugongsModel = do
+  alpha  <- sample "alpha"  (Normal 0 1000)
+  beta   <- sample "beta"   (Normal 0 1000)
+  u      <- sample "u"      (Beta 1 1)
+  lambda <- deterministic "lambda" (0.5 + 0.5 * u)
+  tau    <- sample "tau"    (Gamma 0.0001 0.0001)
+  sigma  <- deterministic "sigma" (1 / sqrt tau)
+  _      <- deterministic "U3" (log (lambda / (1 - lambda)))
+  xs <- dataNamedX   "x" []
+  ys <- dataNamedObs "Y" []
+  plateForM_ "obs" (zip xs ys) $ \(xi, yi) ->
+    observe "Y" (Normal (alpha - beta * (lambda ** xi)) sigma) [yi]
+
+main :: IO ()
+main = do
+  (ys, xs) <- readData
+  -- Phase 102 A1: `prof` 引数で図出力 skip (Rasterific が cost centre を
+  -- 汚さないため)。本モデルは full でも sampling ~325ms と小さく、hmm の
+  -- `reduced` (1chain 縮小) では prof tick が不足するためサンプリング設定は
+  -- 本番のまま据え置く。
+  args <- getArgs
+  let profRun = elem "prof" args
+  let df = [ ("x", NumData (V.fromList xs))
+           , ("Y", NumData (V.fromList ys))
+           ] :: [(T.Text, ColData)]
+      -- PyMC 側 (model.py) と同じ設定を定数で揃える。
+      cfg = defaultHBM { hbmChains = 4, hbmSamples = 1000
+                        , hbmWarmup = 1000, hbmSeed = Just 1 }
+      m = df |-> hbm cfg dugongsModel
+
+  -- 勾配経路 = compileGradUV が実際に選ぶ経路 (束縛済 hbmModelSpec で判定・
+  -- Phase 91 A4: 生モデルを synthVecIR に渡すと data 空で誤表示するため差替)。
+  putStrLn $ "勾配経路 = " ++ gradPathLabel (hbmModelSpec m)
+
+  (_, samplingMs) <- timeSamplingMs (hbmChainsR m)
+  printf "sampling wall = %.1f ms (draws only, no dashboard/startup)\n" samplingMs
+
+  unless profRun $
+    savePNGBound (figuresDir ++ "/hs_dashboard_full.png") $
+      (noDf |>> dashboardFullOf m "Y" :: BoundPlot)
+
+  -- sigma/U3 は deterministic (log-density に寄与しない transformed
+  -- parameters) のため、summarize の前に augmentChainWithDeterministic で
+  -- Chain へ注入する (14-hmm-example と同じ理由)。
+  let chainsAug = map (augmentChainWithDeterministic dugongsModel) (hbmChainsR m)
+  printSummary $ summarize ["alpha", "beta", "lambda", "tau", "sigma"] chainsAug
diff --git a/bench/posteriordb/16-lda/Model.hs b/bench/posteriordb/16-lda/Model.hs
new file mode 100644
--- /dev/null
+++ b/bench/posteriordb/16-lda/Model.hs
@@ -0,0 +1,150 @@
+{-# LANGUAGE OverloadedStrings #-}
+-- | three_men1-ldaK2 (posteriordb) — hanalyze (ModelP) 実装。
+--
+-- Phase 89: posteriordb 横断ベンチマーク。LDAトピックモデル (K=2固定・
+-- V=249語彙・M=6文書・N=4999語インスタンス)。離散潜在トピック割当を
+-- 周辺化 (collapsed) した対数尤度を使う (Stan 原典と同型)。
+--
+-- Stan 原典 (posteriordb `models/stan/ldaK2.stan`):
+--   transformed data { int K=2; vector[K] alpha=[1,1]; vector[V] beta=[1,...,1]; }
+--   parameters { array[M] simplex[K] theta; array[K] simplex[V] phi; }
+--   model {
+--     theta[m] ~ dirichlet(alpha); phi[k] ~ dirichlet(beta);
+--     for (n in 1:N) {
+--       gamma[k] = log(theta[doc[n],k]) + log(phi[k,w[n]]);
+--       target += log_sum_exp(gamma);
+--     }
+--   }
+--
+-- hanalyze の 'dirichlet' helper (stick-breaking・Phase 39-A4) を使い
+-- theta[m]/phi[k] を simplex latent として作る。周辺化尤度は
+-- 'Hanalyze.Model.HBM.Util.logSumExpA' (K-way log-sum-exp) を
+-- 'potential' で加算する (14-hmm-example の forward algorithm と同系統の
+-- 「observe を使わず potential で尤度を直書きする」パターン)。
+--
+-- ★スケールへの配慮: V=249 の 'dirichlet' は内部で stick-breaking の
+-- O(V) 演算 (scanl/scanr + (!!)) を行うため呼び出しコストが V に対して
+-- 効くが K=2 回のみ (V に対して 1 回・K に対する繰り返しではない)。
+-- 尤度ループ (N=4999) 側の theta/phi 参照は 'dirichlet' が返す素の Haskell
+-- リストのまま `!!` で引くと O(V) の索引コストが 4999 回積み上がるため、
+-- 'Data.Vector' に変換してから索引する (O(1))。 w/doc (posteriordb は
+-- 1-based) は微分対象ではない構造的な添字データなので、`df`/`dataNamedX`
+-- 経由の束縛ではなく素の @[Int]@ をクロージャで直接渡す (05-mh の @T@ と
+-- 同じ流儀)。
+--
+-- ★posteriordbの keywords に "multimodal" とある: トピックは交換可能
+-- (a priori に θ/φ のラベルに区別が無い) なため、chain ごとに異なる
+-- トピックラベルへ収束するラベルスイッチングが起き得る (04-low-dim-gauss-mix
+-- と同種の既知の課題)。 14-hmm-example のような加算的順序制約は θ/φ が
+-- 同時に入れ替わる構造のため単純には適用できず、本モデルでは対処しない
+-- (現象が出たら記録するに留める)。
+--
+-- reference_posterior_name = null (posteriordb に公式 reference posterior 無し)。
+--
+-- ★実測で判明: 本番設定 (4chain・warmup1000+draws1000) は 30分timeoutで
+-- 完走せず (2026-07-11)。中規模probe (1chain・warmup100+draws100=200
+-- iteration) は 325.1秒で完走・値は正常収束 (r_hat≈0.99-1.00・NaN無し)
+-- だったため実装自体にバグは無いが、~510次元 (M*(K-1)+K*(V-1)=6+496) の
+-- legacy walk+ad (vecIR非対応) では 1 iteration あたり平均 1.6秒
+-- (単純外挿で本番 2000 iteration/chain ≈ 54分) かかり、13-traffic-accident-nyc
+-- (N=1921×2 latent) と同様に実用外の規模と判断・保留 (user 確認済)。
+-- 詳細は `16-lda/README.md` 参照。
+--
+-- ビルド: cabal build --project-file=cabal.project.plot posteriordb-lda
+module Main (main) where
+
+import Data.Aeson (FromJSON (..), withObject, (.:), eitherDecodeFileStrict)
+import qualified Data.Text as T
+import qualified Data.Vector as V
+import Text.Printf (printf)
+
+import Hanalyze.Model.HBM (ModelP, potential, plateI, plateForM_,
+                                    dirichlet, (.#), augmentChainWithDeterministic)
+import Hanalyze.Model.HBM.Util (logSumExpA)
+import Hanalyze.Model.HBM (gradPathLabel)
+import Hanalyze.Plot (hbmModelSpec)
+import Hanalyze.Plot (HBMConfig (..), defaultHBM, hbm, (|->),
+                              dashboardOf, hbmChainsR)
+import Hgg.Plot.Spec (ColData (..))
+import Hgg.Plot.Frame (BoundPlot, (|>>))
+import Hgg.Plot.Backend.Rasterific (savePNGBound)
+
+import Common (summarize, printSummary, timeSamplingMs)
+
+-- | posteriordb の @three_men1.json@ 形状
+-- ({"V":249, "M":6, "N":4999, "w":[...], "doc":[...]}・1-based 添字)。
+data LdaData = LdaData
+  { ldaV   :: Int
+  , ldaM   :: Int
+  , ldaW   :: [Int]
+  , ldaDoc :: [Int]
+  }
+
+instance FromJSON LdaData where
+  parseJSON = withObject "LdaData" $ \v ->
+    LdaData <$> v .: "V" <*> v .: "M" <*> v .: "w" <*> v .: "doc"
+
+noDf :: [(T.Text, ColData)]
+noDf = []
+
+kTopics :: Int
+kTopics = 2
+
+dataPath :: FilePath
+dataPath = "bench/posteriordb/16-lda/data/three_men1.json"
+
+figuresDir :: FilePath
+figuresDir = "bench/posteriordb/16-lda/figures"
+
+readData :: IO LdaData
+readData = either fail pure =<< eitherDecodeFileStrict dataPath
+
+-- | LDA (K=2固定・周辺化尤度)。 vVoc/mDocs/ws/docs はデータ由来の固定定数
+-- として closure で渡す (05-mh の @T@ と同じ流儀・w/doc は微分対象ではない
+-- ため df 経由で束縛しない)。
+ldaModel :: Int -> Int -> [Int] -> [Int] -> ModelP ()
+ldaModel vVoc mDocs ws docs = do
+  thetaLists <- plateI "doc"   mDocs   $ \i -> dirichlet ("theta" .# i) (replicate kTopics 1)
+  phiLists   <- plateI "topic" kTopics $ \k -> dirichlet ("phi"   .# k) (replicate vVoc 1)
+  let thetaV = V.fromList (map V.fromList thetaLists)  -- M x K (O(1) 索引)
+      phiV   = V.fromList (map V.fromList phiLists)     -- K x V (O(1) 索引)
+  plateForM_ "obs" (zip docs ws) $ \(d, w) ->
+    let gammas = [ log ((thetaV V.! (d - 1)) V.! kk) + log ((phiV V.! kk) V.! (w - 1))
+                 | kk <- [0 .. kTopics - 1] ]
+    in potential "lda_loglik" (logSumExpA gammas)
+
+main :: IO ()
+main = do
+  d <- readData
+  let vVoc  = ldaV d
+      mDocs = ldaM d
+      ws    = ldaW d
+      docs  = ldaDoc d
+      -- ダッシュボード用の名目 obs 列 (14-hmm-example と同様、実際の尤度は
+      -- potential で直書きするため観測ノードは存在しない・PPCパネルは空)。
+      df = [ ("w", NumData (V.fromList (map fromIntegral ws))) ] :: [(T.Text, ColData)]
+      cfg = defaultHBM { hbmChains = 4, hbmSamples = 1000
+                        , hbmWarmup = 1000, hbmSeed = Just 1 }
+      model :: ModelP ()
+      model = ldaModel vVoc mDocs ws docs
+      m = df |-> hbm cfg model
+
+  -- 勾配経路 = compileGradUV が実際に選ぶ経路 (束縛済 hbmModelSpec で判定・
+  -- Phase 91 A4: 生モデルを synthVecIR に渡すと data 空で誤表示するため差替)。
+  putStrLn $ "勾配経路 = " ++ gradPathLabel (hbmModelSpec m)
+
+  (_, samplingMs) <- timeSamplingMs (hbmChainsR m)
+  printf "sampling wall = %.1f ms (draws only, no dashboard/startup)\n" samplingMs
+
+  -- M*K + K*V = 12+498 = 510 latent (deterministic込み) と大規模なため、
+  -- 05-mh/10-rats と同様 dashboardFullOf でなく dashboardOf (健全性2x2パネル
+  -- のみ・forestに全latentがコンパクト表示) を使う。
+  savePNGBound (figuresDir ++ "/hs_dashboard_full.png") $
+    (noDf |>> dashboardOf m "w" :: BoundPlot)
+
+  -- theta_i_j は dirichlet の deterministic (stick-breaking) のため
+  -- augmentChainWithDeterministic で Chain へ注入してから summarize する。
+  let chainsAug = map (augmentChainWithDeterministic model) (hbmChainsR m)
+      thetaNames = [ "theta_" <> T.pack (show i) <> "_" <> T.pack (show k)
+                   | i <- [0 .. mDocs - 1], k <- [0 .. kTopics - 1] ]
+  printSummary $ summarize thetaNames chainsAug
diff --git a/bench/posteriordb/17-nes/Model.hs b/bench/posteriordb/17-nes/Model.hs
new file mode 100644
--- /dev/null
+++ b/bench/posteriordb/17-nes/Model.hs
@@ -0,0 +1,149 @@
+{-# LANGUAGE OverloadedStrings #-}
+-- | nes1972-nes (posteriordb) — hanalyze (ModelP) 実装。
+--
+-- Phase 89: posteriordb 横断ベンチマーク。ARM本 (Gelman & Hill 2006) Ch.4
+-- の政党支持度回帰 (National Election Studies 1972年調査・N=1330)。
+-- 9変数の線形回帰 (イデオロギー・人種・年齢層3ダミー・教育・性別・収入)。
+--
+-- Stan 原典 (posteriordb `models/stan/nes.stan`):
+--   transformed data {
+--     age30_44[n] = age_discrete[n]==2; age45_64[n] = age_discrete[n]==3;
+--     age65up[n]  = age_discrete[n]==4;  // 年齢層ファクタをダミー化
+--   }
+--   parameters { vector[9] beta; real<lower=0> sigma; }
+--   model {
+--     partyid7 ~ normal(beta[1] + beta[2]*real_ideo + beta[3]*race_adj
+--                      + beta[4]*age30_44 + beta[5]*age45_64 + beta[6]*age65up
+--                      + beta[7]*educ1 + beta[8]*gender + beta[9]*income, sigma);
+--   }
+--
+-- Stan 原典に明示的な prior 行は無い (暗黙の flat/improper prior)。
+-- 01-glm-poisson/10-rats と同じ流儀で diffuse な代替を与える:
+-- beta_i ~ Normal(0,1000) (回帰係数・UnconstrainedT なので unconstrained
+-- 初期値問題なし)・sigma ~ HalfCauchy(25) (10-rats で確立した「Uniform(0,X)
+-- 境界外初期値の罠」を回避する SD 事前分布・PositiveT変換)。
+--
+-- age30_44/age45_64/age65up は Stan の transformed data 相当として
+-- readData 後に Haskell 側でダミー化する (df に個別列として渡す)。
+-- N=1330×9変数の標準的な線形回帰は vecIR 高速経路が期待できる規模
+-- (01-glm-poisson と同型の構造)。
+--
+-- reference_posterior_name = "nes1972-nes" (posteriordb に公式 reference
+-- あり・hanalyze vs PyMC vs 公式reference の3者比較可能)。
+--
+-- ビルド: cabal build --project-file=cabal.project.plot posteriordb-nes
+module Main (main) where
+
+import Data.Aeson (FromJSON (..), withObject, (.:), eitherDecodeFileStrict)
+import qualified Data.Text as T
+import qualified Data.Vector as V
+import Text.Printf (printf)
+
+import Hanalyze.Model.HBM (ModelP, Distribution (..), sample, observe,
+                                    dataNamedX, dataNamedObs, plateI_,
+                                    gradPathLabel)
+import Hanalyze.Plot (HBMConfig (..), defaultHBM, hbm, (|->),
+                              dashboardFullOf, hbmChainsR, hbmModelSpec)
+import Hgg.Plot.Spec (ColData (..))
+import Hgg.Plot.Frame (BoundPlot, (|>>))
+import Hgg.Plot.Backend.Rasterific (savePNGBound)
+
+import Common (summarize, printSummary, timeSamplingMs)
+
+-- | posteriordb の @nes1972.json@ 形状
+-- ({"N":1330,"partyid7":[...],"real_ideo":[...],"race_adj":[...],
+--   "educ1":[...],"gender":[...],"income":[...],"age_discrete":[...]})。
+data NesRaw = NesRaw
+  { partyid7Raw    :: [Double]
+  , realIdeoRaw    :: [Double]
+  , raceAdjRaw     :: [Double]
+  , educ1Raw       :: [Double]
+  , genderRaw      :: [Double]
+  , incomeRaw      :: [Double]
+  , ageDiscreteRaw :: [Int]
+  }
+
+instance FromJSON NesRaw where
+  parseJSON = withObject "NesRaw" $ \v ->
+    NesRaw <$> v .: "partyid7" <*> v .: "real_ideo" <*> v .: "race_adj"
+           <*> v .: "educ1" <*> v .: "gender" <*> v .: "income"
+           <*> v .: "age_discrete"
+
+noDf :: [(T.Text, ColData)]
+noDf = []
+
+dataPath :: FilePath
+dataPath = "bench/posteriordb/17-nes/data/nes1972.json"
+
+figuresDir :: FilePath
+figuresDir = "bench/posteriordb/17-nes/figures"
+
+readData :: IO NesRaw
+readData = either fail pure =<< eitherDecodeFileStrict dataPath
+
+-- | 9変数線形回帰 (Stan 原典と同一構造)。 データは df 経由で束縛
+-- ('dataNamedX'/'dataNamedObs')・O(1) 索引のため 'Data.Vector' に変換して
+-- から 'plateI_' で反復する。
+nesModel :: ModelP ()
+nesModel = do
+  b1 <- sample "beta1" (Normal 0 1000)
+  b2 <- sample "beta2" (Normal 0 1000)
+  b3 <- sample "beta3" (Normal 0 1000)
+  b4 <- sample "beta4" (Normal 0 1000)
+  b5 <- sample "beta5" (Normal 0 1000)
+  b6 <- sample "beta6" (Normal 0 1000)
+  b7 <- sample "beta7" (Normal 0 1000)
+  b8 <- sample "beta8" (Normal 0 1000)
+  b9 <- sample "beta9" (Normal 0 1000)
+  sigma <- sample "sigma" (HalfCauchy 25)
+  ideoV  <- V.fromList <$> dataNamedX "real_ideo" []
+  raceV  <- V.fromList <$> dataNamedX "race_adj"  []
+  a3044V <- V.fromList <$> dataNamedX "age30_44"  []
+  a4564V <- V.fromList <$> dataNamedX "age45_64"  []
+  a65upV <- V.fromList <$> dataNamedX "age65up"   []
+  educV  <- V.fromList <$> dataNamedX "educ1"     []
+  gendV  <- V.fromList <$> dataNamedX "gender"    []
+  incV   <- V.fromList <$> dataNamedX "income"    []
+  ysV    <- V.fromList <$> dataNamedObs "partyid7" []
+  plateI_ "obs" (V.length ysV) $ \i ->
+    let mu = b1 + b2 * (ideoV V.! i) + b3 * (raceV V.! i)
+           + b4 * (a3044V V.! i) + b5 * (a4564V V.! i) + b6 * (a65upV V.! i)
+           + b7 * (educV V.! i) + b8 * (gendV V.! i) + b9 * (incV V.! i)
+    in observe "partyid7" (Normal mu sigma) [ysV V.! i]
+
+main :: IO ()
+main = do
+  d <- readData
+  let age3044 = [ if a == (2 :: Int) then 1 else 0 | a <- ageDiscreteRaw d ] :: [Double]
+      age4564 = [ if a == (3 :: Int) then 1 else 0 | a <- ageDiscreteRaw d ] :: [Double]
+      age65up = [ if a == (4 :: Int) then 1 else 0 | a <- ageDiscreteRaw d ] :: [Double]
+      df = [ ("real_ideo", NumData (V.fromList (realIdeoRaw d)))
+           , ("race_adj",  NumData (V.fromList (raceAdjRaw d)))
+           , ("age30_44",  NumData (V.fromList age3044))
+           , ("age45_64",  NumData (V.fromList age4564))
+           , ("age65up",   NumData (V.fromList age65up))
+           , ("educ1",     NumData (V.fromList (educ1Raw d)))
+           , ("gender",    NumData (V.fromList (genderRaw d)))
+           , ("income",    NumData (V.fromList (incomeRaw d)))
+           , ("partyid7",  NumData (V.fromList (partyid7Raw d)))
+           ] :: [(T.Text, ColData)]
+      -- PyMC 側 (model.py) と同じ設定を定数で揃える。
+      cfg = defaultHBM { hbmChains = 4, hbmSamples = 1000
+                        , hbmWarmup = 1000, hbmSeed = Just 1 }
+      m = df |-> hbm cfg nesModel
+
+  -- 勾配経路 = compileGradUV が実際に選ぶ経路 (束縛済モデルで判定)。
+  -- Phase 91 A4: 9変数線形回帰は Gaussian LM 閉形式ブロックに吸収される。
+  -- ★生の nesModel を synthVecIR に渡すと data 空で Nothing と誤表示するため
+  --   'hbmModelSpec m' (df 束縛済) を 'gradPathLabel' に渡す。
+  putStrLn $ "勾配経路 = " ++ gradPathLabel (hbmModelSpec m)
+
+  (_, samplingMs) <- timeSamplingMs (hbmChainsR m)
+  printf "sampling wall = %.1f ms (draws only, no dashboard/startup)\n" samplingMs
+
+  savePNGBound (figuresDir ++ "/hs_dashboard_full.png") $
+    (noDf |>> dashboardFullOf m "partyid7" :: BoundPlot)
+
+  printSummary $ summarize
+    ["beta1", "beta2", "beta3", "beta4", "beta5", "beta6", "beta7", "beta8", "beta9", "sigma"]
+    (hbmChainsR m)
diff --git a/bench/posteriordb/18-loss-curves/Model.hs b/bench/posteriordb/18-loss-curves/Model.hs
new file mode 100644
--- /dev/null
+++ b/bench/posteriordb/18-loss-curves/Model.hs
@@ -0,0 +1,127 @@
+{-# LANGUAGE OverloadedStrings #-}
+-- | loss_curves-losscurve_sislob (posteriordb) — hanalyze (ModelP) 実装。
+--
+-- Phase 89: posteriordb 横断ベンチマーク。保険数理の損失三角形
+-- (loss reserving)。n_cohort=10 (契約年度)・n_time=10 (経過年)・
+-- n_data=55 (= 10+9+...+1・下三角が未観測の古典的三角形構造)。
+-- Weibull 型成長曲線 (`growthmodel_id=1`、データで確認済み) で
+-- 損失の発展パターンをモデル化する。
+--
+-- Stan 原典 (posteriordb `models/stan/losscurve_sislob.stan`):
+--   gf[t] = 1 - exp(-(t/theta)^omega)                      // growth_factor_weibull
+--   lm[i] = LR[cohort_id[i]] * premium[cohort_id[i]] * gf[t_idx[i]]
+--   loss[i] ~ normal(lm[i], loss_sd*premium[cohort_id[i]])
+--   mu_LR ~ normal(0,0.5); sd_LR ~ lognormal(0,0.5); LR ~ lognormal(mu_LR,sd_LR)
+--   loss_sd ~ lognormal(0,0.7); omega/theta ~ lognormal(0,0.5)
+--
+-- 全 prior が LogNormal/Normal (unconstrained or PositiveT) のため、
+-- 01-glm-poisson/10-rats 系列で確立した「Uniform境界外初期値の罠」は
+-- 該当しない (LogNormalはPositiveT変換を持つ・安全)。
+--
+-- reference_posterior_name = null (posteriordb に公式 reference posterior 無し)。
+--
+-- ビルド: cabal build --project-file=cabal.project.plot posteriordb-loss-curves
+module Main (main) where
+
+import Data.Aeson (FromJSON (..), withObject, (.:), eitherDecodeFileStrict)
+import qualified Data.Text as T
+import qualified Data.Vector as V
+import Text.Printf (printf)
+
+import Hanalyze.Model.HBM (ModelP, Distribution (..), sample, observe,
+                                    dataNamedX, dataNamedObs, plateI, plateForM_, (.#))
+import Hanalyze.Model.HBM (gradPathLabel)
+import Hanalyze.Plot (hbmModelSpec)
+import Hanalyze.Plot (HBMConfig (..), defaultHBM, hbm, (|->),
+                              dashboardFullOf, hbmChainsR)
+import Hgg.Plot.Spec (ColData (..))
+import Hgg.Plot.Frame (BoundPlot, (|>>))
+import Hgg.Plot.Backend.Rasterific (savePNGBound)
+
+import Common (summarize, printSummary, timeSamplingMs)
+
+-- | posteriordb の @loss_curves.json@ 形状
+-- ({"n_data":55,"n_time":10,"n_cohort":10,"cohort_id":[...],"t_idx":[...],
+--   "t_value":[...],"premium":[...],"loss":[...]}・1-based 添字)。
+data LossData = LossData
+  { nCohort  :: Int
+  , nTime    :: Int
+  , cohortId :: [Int]
+  , tIdx     :: [Int]
+  , tValue   :: [Double]
+  , premium  :: [Double]
+  , loss     :: [Double]
+  }
+
+instance FromJSON LossData where
+  parseJSON = withObject "LossData" $ \v ->
+    LossData <$> v .: "n_cohort" <*> v .: "n_time" <*> v .: "cohort_id"
+             <*> v .: "t_idx" <*> v .: "t_value" <*> v .: "premium" <*> v .: "loss"
+
+noDf :: [(T.Text, ColData)]
+noDf = []
+
+dataPath :: FilePath
+dataPath = "bench/posteriordb/18-loss-curves/data/loss_curves.json"
+
+figuresDir :: FilePath
+figuresDir = "bench/posteriordb/18-loss-curves/figures"
+
+readData :: IO LossData
+readData = either fail pure =<< eitherDecodeFileStrict dataPath
+
+-- | Weibull成長曲線による損失三角形モデル (Stan 原典と同一構造・
+-- growthmodel_id=1 固定)。 premium/t_value はコホート/経過年ごとの
+-- 固定定数として df 経由で束縛 ('dataNamedX')・cohort_id/t_idx は
+-- 微分対象ではない構造的添字なので素の @[Int]@ をクロージャで直接渡す
+-- (05-mh/16-lda と同じ流儀)。
+lossModel :: Int -> [Int] -> [Int] -> ModelP ()
+lossModel nT cids tidxs = do
+  omega  <- sample "omega"   (LogNormal 0 0.5)
+  theta  <- sample "theta"   (LogNormal 0 0.5)
+  muLR   <- sample "mu_LR"   (Normal 0 0.5)
+  sdLR   <- sample "sd_LR"   (LogNormal 0 0.5)
+  lrs    <- plateI "cohort" 10 $ \i -> sample ("LR" .# i) (LogNormal muLR sdLR)
+  lossSd <- sample "loss_sd" (LogNormal 0 0.7)
+  tValues  <- dataNamedX   "t_value" []
+  premiums <- dataNamedX   "premium" []
+  losses   <- dataNamedObs "loss"    []
+  let tValueV  = V.fromList tValues
+      premiumV = V.fromList premiums
+      lrV      = V.fromList lrs
+      gfV = V.generate nT $ \i ->
+              let tv = tValueV V.! i
+              in 1 - exp (negate ((tv / theta) ** omega))
+  plateForM_ "obs" (zip3 cids tidxs losses) $ \(cid, tidx, lossVal) ->
+    let lm = (lrV V.! (cid - 1)) * (premiumV V.! (cid - 1)) * (gfV V.! (tidx - 1))
+        sd = lossSd * (premiumV V.! (cid - 1))
+    in observe "loss" (Normal lm sd) [lossVal]
+
+main :: IO ()
+main = do
+  d <- readData
+  let df = [ ("t_value", NumData (V.fromList (tValue d)))
+           , ("premium", NumData (V.fromList (premium d)))
+           , ("loss",    NumData (V.fromList (loss d)))
+           ] :: [(T.Text, ColData)]
+      -- PyMC 側 (model.py) と同じ設定を定数で揃える。
+      cfg = defaultHBM { hbmChains = 4, hbmSamples = 1000
+                        , hbmWarmup = 1000, hbmSeed = Just 1 }
+      model :: ModelP ()
+      model = lossModel (nTime d) (cohortId d) (tIdx d)
+      m = df |-> hbm cfg model
+
+  -- 勾配経路 = compileGradUV が実際に選ぶ経路 (束縛済 hbmModelSpec で判定・
+  -- Phase 91 A4: 生モデルを synthVecIR に渡すと data 空で誤表示するため差替)。
+  putStrLn $ "勾配経路 = " ++ gradPathLabel (hbmModelSpec m)
+
+  (_, samplingMs) <- timeSamplingMs (hbmChainsR m)
+  printf "sampling wall = %.1f ms (draws only, no dashboard/startup)\n" samplingMs
+
+  savePNGBound (figuresDir ++ "/hs_dashboard_full.png") $
+    (noDf |>> dashboardFullOf m "loss" :: BoundPlot)
+
+  printSummary $ summarize
+    (["omega", "theta", "mu_LR", "sd_LR", "loss_sd"] ++
+     [ "LR_" <> T.pack (show i) | i <- [0 .. nCohort d - 1] ])
+    (hbmChainsR m)
diff --git a/bench/posteriordb/19-surgical/Model.hs b/bench/posteriordb/19-surgical/Model.hs
new file mode 100644
--- /dev/null
+++ b/bench/posteriordb/19-surgical/Model.hs
@@ -0,0 +1,93 @@
+{-# LANGUAGE OverloadedStrings #-}
+-- | surgical_data-surgical_model (posteriordb) — hanalyze (ModelP) 実装。
+--
+-- Phase 89: posteriordb 横断ベンチマーク。BUGS 古典例「12病院の心臓手術
+-- 死亡率」(N=12病院・階層二項ロジット・共変量なしの最も単純な階層形)。
+--
+-- Stan 原典 (posteriordb `models/stan/surgical_model.stan`):
+--   mu ~ normal(0,1000); sigmasq ~ inv_gamma(0.001,0.001); sigma=sqrt(sigmasq);
+--   b[i] ~ normal(mu, sigma);  r[i] ~ binomial_logit(n[i], b[i]);
+--
+-- hanalyze の `Binomial n p` は確率パラメータ直接指定 (logit link 無し) の
+-- ため、05-mh と同じく `p = invlogit(b)` を手計算してから渡す。
+-- `n[i]` (病院ごとの手術数) は微分対象ではない構造的定数なので closure で
+-- 直接渡す (05-mh の @T@ と同じ流儀)。
+--
+-- reference_posterior_name = null (posteriordb に公式 reference posterior 無し)。
+--
+-- ビルド: cabal build --project-file=cabal.project.plot posteriordb-surgical
+module Main (main) where
+
+import Data.Aeson (FromJSON (..), withObject, (.:), eitherDecodeFileStrict)
+import qualified Data.Text as T
+import qualified Data.Vector as V
+import Text.Printf (printf)
+
+import Hanalyze.Model.HBM (ModelP, Distribution (..), sample, observe,
+                                    dataNamedObs, plateI, plateForM_, (.#),
+                                    deterministic, augmentChainWithDeterministic)
+import Hanalyze.Model.HBM (gradPathLabel)
+import Hanalyze.Plot (hbmModelSpec)
+import Hanalyze.Plot (HBMConfig (..), defaultHBM, hbm, (|->),
+                              dashboardFullOf, hbmChainsR)
+import Hgg.Plot.Spec (ColData (..))
+import Hgg.Plot.Frame (BoundPlot, (|>>))
+import Hgg.Plot.Backend.Rasterific (savePNGBound)
+
+import Common (summarize, printSummary, timeSamplingMs)
+
+-- | posteriordb の @surgical_data.json@ 形状 ({"N":12,"n":[...],"r":[...]})。
+data SurgicalData = SurgicalData { nOps :: [Int], rDeaths :: [Int] }
+
+instance FromJSON SurgicalData where
+  parseJSON = withObject "SurgicalData" $ \v ->
+    SurgicalData <$> v .: "n" <*> v .: "r"
+
+noDf :: [(T.Text, ColData)]
+noDf = []
+
+dataPath :: FilePath
+dataPath = "bench/posteriordb/19-surgical/data/surgical_data.json"
+
+figuresDir :: FilePath
+figuresDir = "bench/posteriordb/19-surgical/figures"
+
+readData :: IO SurgicalData
+readData = either fail pure =<< eitherDecodeFileStrict dataPath
+
+-- | 病院ごとの階層二項ロジット (Stan 原典と同一構造)。
+surgicalModel :: [Int] -> ModelP ()
+surgicalModel ns = do
+  mu      <- sample "mu"      (Normal 0 1000)
+  sigmasq <- sample "sigmasq" (InverseGamma 0.001 0.001)
+  sigma   <- deterministic "sigma" (sqrt sigmasq)
+  bs      <- plateI "hosp" (length ns) $ \i -> sample ("b" .# i) (Normal mu sigma)
+  _       <- deterministic "pop_mean" (1 / (1 + exp (negate mu)))
+  rs      <- dataNamedObs "r" []
+  plateForM_ "obs" (zip3 ns bs rs) $ \(n, b, rVal) ->
+    let p = 1 / (1 + exp (negate b))
+    in observe "r" (Binomial n p) [rVal]
+
+main :: IO ()
+main = do
+  d <- readData
+  let df = [ ("r", NumData (V.fromList (map fromIntegral (rDeaths d)))) ] :: [(T.Text, ColData)]
+      cfg = defaultHBM { hbmChains = 4, hbmSamples = 1000
+                        , hbmWarmup = 1000, hbmSeed = Just 1 }
+      model :: ModelP ()
+      model = surgicalModel (nOps d)
+      m = df |-> hbm cfg model
+
+  -- 勾配経路 = compileGradUV が実際に選ぶ経路 (束縛済 hbmModelSpec で判定・
+  -- Phase 91 A4: 生モデルを synthVecIR に渡すと data 空で誤表示するため差替)。
+  putStrLn $ "勾配経路 = " ++ gradPathLabel (hbmModelSpec m)
+
+  (_, samplingMs) <- timeSamplingMs (hbmChainsR m)
+  printf "sampling wall = %.1f ms (draws only, no dashboard/startup)\n" samplingMs
+
+  savePNGBound (figuresDir ++ "/hs_dashboard_full.png") $
+    (noDf |>> dashboardFullOf m "r" :: BoundPlot)
+
+  let chainsAug = map (augmentChainWithDeterministic model) (hbmChainsR m)
+      bNames = [ "b_" <> T.pack (show i) | i <- [0 .. length (nOps d) - 1] ]
+  printSummary $ summarize (["mu", "sigma", "pop_mean"] ++ bNames) chainsAug
diff --git a/bench/posteriordb/20-bones/Model.hs b/bench/posteriordb/20-bones/Model.hs
new file mode 100644
--- /dev/null
+++ b/bench/posteriordb/20-bones/Model.hs
@@ -0,0 +1,111 @@
+{-# LANGUAGE OverloadedStrings #-}
+-- | bones_data-bones_model (posteriordb) — hanalyze (ModelP) 実装。
+--
+-- Phase 89: posteriordb 横断ベンチマーク。骨年齢の graded response IRT
+-- モデル (BUGS 古典例)。nChild=13人の子供・nInd=34項目 (骨のX線指標)。
+-- 各項目の困難度カットポイント (gamma) と識別力 (delta) は**固定データ**
+-- (未サンプル)・各子供の能力 theta のみが latent。
+--
+-- Stan 原典 (posteriordb `models/stan/bones_model.stan`):
+--   theta[i] ~ normal(0, 36);
+--   for each i,j:
+--     Q[i,j,k] = inv_logit(delta[j]*(theta[i]-gamma[j,k]))  for k=1..(ncat[j]-1)
+--     p[i,j,1] = 1-Q[i,j,1]; p[i,j,k] = Q[i,j,k-1]-Q[i,j,k]; p[i,j,ncat[j]] = Q[i,j,ncat[j]-1]
+--     if grade[i,j] != -1: target += log(p[i,j,grade[i,j]])   // 欠測はスキップ
+--
+-- 尤度は `observe` を使わず `potential` で直書きする (14-hmm-example/
+-- 16-lda と同系統)。 difficulty/discrimination はデータなので
+-- Haskell 側では単なる @[Double]@ として closure で渡す。
+--
+-- reference_posterior_name = null (posteriordb に公式 reference posterior 無し)。
+--
+-- ビルド: cabal build --project-file=cabal.project.plot posteriordb-bones
+module Main (main) where
+
+import Data.Aeson (FromJSON (..), withObject, (.:), eitherDecodeFileStrict)
+import qualified Data.Text as T
+import qualified Data.Vector as V
+import Text.Printf (printf)
+
+import Hanalyze.Model.HBM (ModelP, Distribution (..), sample, observeMV,
+                                    plateI, (.#))
+import Hanalyze.Model.HBM (gradPathLabel)
+import Hanalyze.Plot (hbmModelSpec)
+import Hanalyze.Plot (HBMConfig (..), defaultHBM, hbm, (|->),
+                              dashboardOf, hbmChainsR)
+import Hgg.Plot.Spec (ColData (..))
+import Hgg.Plot.Frame (BoundPlot, (|>>))
+import Hgg.Plot.Backend.Rasterific (savePNGBound)
+
+import Common (summarize, printSummary, timeSamplingMs)
+
+-- | posteriordb の @bones_data.json@ 形状 ({"ncat":[...34...],
+-- "nChild":13,"nInd":34,"grade":[[13x34]],"delta":[...34...],
+-- "gamma":[[34x4]]}・grade の @-1@ は欠測)。
+data BonesData = BonesData
+  { bnCat   :: [Int]
+  , bnGrade :: [[Int]]
+  , bnDelta :: [Double]
+  , bnGamma :: [[Double]]
+  }
+
+instance FromJSON BonesData where
+  parseJSON = withObject "BonesData" $ \v ->
+    BonesData <$> v .: "ncat" <*> v .: "grade" <*> v .: "delta" <*> v .: "gamma"
+
+noDf :: [(T.Text, ColData)]
+noDf = []
+
+dataPath :: FilePath
+dataPath = "bench/posteriordb/20-bones/data/bones_data.json"
+
+figuresDir :: FilePath
+figuresDir = "bench/posteriordb/20-bones/figures"
+
+readData :: IO BonesData
+readData = either fail pure =<< eitherDecodeFileStrict dataPath
+
+-- | graded response IRT モデル (Stan 原典と同一構造)。
+--
+-- Phase 101 A3: 尤度を @logCatProb + potential@ 直書きから構造化 primitive
+-- 'GradedResponseIrt' + 'observeMV' へ移行 (密度は同値・'obsLogSum' が同じ
+-- Q/p 構成を呼ぶ)。 θ_i のみが latent なため、勾配コンパイラが解析勾配
+-- ('gradedIrtAnalyticVG'・dQ/dθ = δ·Q(1−Q) の隣接差・AD tape ゼロ) を選べる。
+-- grade 行列は行優先 flatten して 1 観測で渡す (−1 = 欠測は skip)。
+bonesModel :: [Int] -> [Double] -> [[Double]] -> [[Int]] -> ModelP ()
+bonesModel ncats deltas gammas grades = do
+  thetas <- plateI "child" (length grades) $ \i -> sample ("theta" .# i) (Normal 0 36)
+  observeMV "grades" (GradedResponseIrt thetas ncats deltas gammas)
+            [map fromIntegral (concat grades)]
+
+main :: IO ()
+main = do
+  d <- readData
+  let nChild = length (bnGrade d)
+      -- ダッシュボード用の名目 obs 列 (14-hmm-example/16-lda と同様、
+      -- 実際の尤度は potential で直書きするため観測ノードは存在しない・
+      -- PPCパネルは空)。項目1 (indicator 0) の全児童分のgradeを使う。
+      grade1 = [ fromIntegral (row !! 0) | row <- bnGrade d ] :: [Double]
+      df = [ ("grade1", NumData (V.fromList grade1)) ] :: [(T.Text, ColData)]
+      cfg = defaultHBM { hbmChains = 4, hbmSamples = 1000
+                        , hbmWarmup = 1000, hbmSeed = Just 1 }
+      model :: ModelP ()
+      model = bonesModel (bnCat d) (bnDelta d) (bnGamma d) (bnGrade d)
+      m = df |-> hbm cfg model
+
+  -- 勾配経路 = compileGradUV が実際に選ぶ経路 (束縛済 hbmModelSpec で判定・
+  -- Phase 91 A4: 生モデルを synthVecIR に渡すと data 空で誤表示するため差替)。
+  putStrLn $ "勾配経路 = " ++ gradPathLabel (hbmModelSpec m)
+
+  (_, samplingMs) <- timeSamplingMs (hbmChainsR m)
+  printf "sampling wall = %.1f ms (draws only, no dashboard/startup)\n" samplingMs
+
+  -- nChild=13の小規模モデルのため dashboardFullOf でも問題ないが、
+  -- potential のみで尤度を構成 (PPCパネルは空) するため 05-mh/16-lda と
+  -- 同様 dashboardOf (健全性2x2パネルのみ) を使う。
+  savePNGBound (figuresDir ++ "/hs_dashboard_full.png") $
+    (noDf |>> dashboardOf m "grade1" :: BoundPlot)
+
+  printSummary $ summarize
+    [ "theta_" <> T.pack (show i) | i <- [0 .. nChild - 1] ]
+    (hbmChainsR m)
diff --git a/bench/posteriordb/21-radon/Model.hs b/bench/posteriordb/21-radon/Model.hs
new file mode 100644
--- /dev/null
+++ b/bench/posteriordb/21-radon/Model.hs
@@ -0,0 +1,144 @@
+{-# LANGUAGE OverloadedStrings #-}
+-- | radon_mn-radon_hierarchical_intercept_noncentered (posteriordb) —
+-- hanalyze (ModelP) 実装。
+--
+-- Phase 89: posteriordb 横断ベンチマーク。Gelman ラドン多水準回帰の
+-- 古典例 (mc-stan.org radon case study)。ミネソタ州 J=85郡・N=919家屋の
+-- 屋内ラドン濃度回帰 (郡ごとの varying intercept + 固定傾き2本、
+-- non-centered パラメタ化)。
+--
+-- Stan 原典 (posteriordb `models/stan/radon_hierarchical_intercept_noncentered.stan`):
+--   parameters { vector[J] alpha_raw; vector[2] beta; real mu_alpha;
+--                real<lower=0> sigma_alpha; real<lower=0> sigma_y; }
+--   transformed parameters { alpha = mu_alpha + sigma_alpha * alpha_raw; }
+--   model {
+--     sigma_alpha ~ normal(0,1); sigma_y ~ normal(0,1);
+--     mu_alpha ~ normal(0,10); beta ~ normal(0,10); alpha_raw ~ normal(0,1);
+--     for (n in 1:N) {
+--       muj[n] = alpha[county_idx[n]] + log_uppm[n]*beta[1];
+--       mu[n] = muj[n] + floor_measure[n]*beta[2];
+--       log_radon[n] ~ normal(mu[n], sigma_y);
+--     }
+--   }
+--
+-- `sigma_alpha`/`sigma_y` の `<lower=0>` + `Normal(0,1)` prior は
+-- half-normal と数学的に等価なので `HalfNormal 1` で移植 (09-eight-schools
+-- 等と同じ流儀)。`alpha[j]` は `deterministic` (mu_alpha+sigma_alpha*
+-- alpha_raw[j]) で登録し `Data.Vector` 経由で O(1) 索引する
+-- (`county_idx` は1-based構造添字なのでclosureで直接渡す・05-mh/16-lda
+-- と同じ流儀)。単一階層 (alphaのみ) なので10-ratsの「二重階層」DSL
+-- ギャップは該当しない — eight-schools/seedsと同型の構造でvecIR成功が
+-- 見込める規模 (J=85・N=919)。
+--
+-- reference_posterior_name = null (posteriordb に公式 reference posterior 無し)。
+--
+-- ビルド: cabal build --project-file=cabal.project.plot posteriordb-radon
+module Main (main) where
+
+import Control.Monad (unless)
+import Data.Aeson (FromJSON (..), withObject, (.:), eitherDecodeFileStrict)
+import qualified Data.Text as T
+import qualified Data.Vector as V
+import System.Environment (getArgs)
+import Text.Printf (printf)
+
+import Hanalyze.Model.HBM (ModelP, Distribution (..), sample, observe,
+                                    dataNamedX, dataNamedObs, plateI, plateForM_,
+                                    (.#), deterministic, augmentChainWithDeterministic)
+import Hanalyze.Model.HBM (gradPathLabel)
+import Hanalyze.Plot (hbmModelSpec)
+import Hanalyze.Plot (HBMConfig (..), defaultHBM, hbm, (|->),
+                              dashboardOf, hbmChainsR)
+import Hgg.Plot.Spec (ColData (..))
+import Hgg.Plot.Frame (BoundPlot, (|>>))
+import Hgg.Plot.Backend.Rasterific (savePNGBound)
+
+import Common (summarize, printSummary, timeSamplingMs)
+
+-- | posteriordb の @radon_mn.json@ 形状 ({"N":919,"J":85,
+-- "floor_measure":[...],"log_radon":[...],"log_uppm":[...],
+-- "county_idx":[...]}・county_idx は 1-based)。
+data RadonData = RadonData
+  { rdJ            :: Int
+  , rdCountyIdx    :: [Int]
+  , rdFloorMeasure :: [Double]
+  , rdLogRadon     :: [Double]
+  , rdLogUppm      :: [Double]
+  }
+
+instance FromJSON RadonData where
+  parseJSON = withObject "RadonData" $ \v ->
+    RadonData <$> v .: "J" <*> v .: "county_idx" <*> v .: "floor_measure"
+              <*> v .: "log_radon" <*> v .: "log_uppm"
+
+noDf :: [(T.Text, ColData)]
+noDf = []
+
+dataPath :: FilePath
+dataPath = "bench/posteriordb/21-radon/data/radon_mn.json"
+
+figuresDir :: FilePath
+figuresDir = "bench/posteriordb/21-radon/figures"
+
+readData :: IO RadonData
+readData = either fail pure =<< eitherDecodeFileStrict dataPath
+
+-- | 郡ごとの varying intercept (non-centered) + 固定傾き2本の階層回帰
+-- (Stan 原典と同一構造)。 @countyIdx@ は微分対象ではない構造的添字
+-- (1-based) なので closure で直接渡す。
+radonModel :: Int -> [Int] -> ModelP ()
+radonModel nCounty countyIdx = do
+  muAlpha    <- sample "mu_alpha"    (Normal 0 10)
+  sigmaAlpha <- sample "sigma_alpha" (HalfNormal 1)
+  sigmaY     <- sample "sigma_y"     (HalfNormal 1)
+  beta1      <- sample "beta1" (Normal 0 10)
+  beta2      <- sample "beta2" (Normal 0 10)
+  alphaRaws  <- plateI "county" nCounty $ \j -> sample ("alpha_raw" .# j) (Normal 0 1)
+  alphas     <- mapM (\(j, ar) -> deterministic ("alpha" .# j) (muAlpha + sigmaAlpha * ar))
+                      (zip [0 :: Int ..] alphaRaws)
+  let alphaV = V.fromList alphas
+  logUppm      <- dataNamedX   "log_uppm"      []
+  floorMeasure <- dataNamedX   "floor_measure" []
+  ys           <- dataNamedObs "log_radon"     []
+  plateForM_ "obs" (zip4' countyIdx logUppm floorMeasure ys) $ \(cid, uppm, floorM, yVal) ->
+    let mu = (alphaV V.! (cid - 1)) + uppm * beta1 + floorM * beta2
+    in observe "log_radon" (Normal mu sigmaY) [yVal]
+  where
+    zip4' (a : as) (b : bs) (c : cs) (d : ds) = (a, b, c, d) : zip4' as bs cs ds
+    zip4' _ _ _ _ = []
+
+main :: IO ()
+main = do
+  d <- readData
+  -- Phase 102 A1: `prof` 引数で図出力 skip (15-dugongs と同じ理由。
+  -- サンプリング設定は本番のまま)。
+  args <- getArgs
+  let profRun = elem "prof" args
+  let df = [ ("log_uppm",      NumData (V.fromList (rdLogUppm d)))
+           , ("floor_measure", NumData (V.fromList (rdFloorMeasure d)))
+           , ("log_radon",     NumData (V.fromList (rdLogRadon d)))
+           ] :: [(T.Text, ColData)]
+      -- PyMC 側 (model.py) と同じ設定を定数で揃える。
+      cfg = defaultHBM { hbmChains = 4, hbmSamples = 1000
+                        , hbmWarmup = 1000, hbmSeed = Just 1 }
+      model :: ModelP ()
+      model = radonModel (rdJ d) (rdCountyIdx d)
+      m = df |-> hbm cfg model
+
+  -- 勾配経路 = compileGradUV が実際に選ぶ経路 (束縛済 hbmModelSpec で判定・
+  -- Phase 91 A4: 生モデルを synthVecIR に渡すと data 空で誤表示するため差替)。
+  putStrLn $ "勾配経路 = " ++ gradPathLabel (hbmModelSpec m)
+
+  (_, samplingMs) <- timeSamplingMs (hbmChainsR m)
+  printf "sampling wall = %.1f ms (draws only, no dashboard/startup)\n" samplingMs
+
+  -- J=85郡分のalpha latentを含むため dashboardFullOf ではなく dashboardOf
+  -- (健全性2x2パネルのみ・05-mh/10-ratsと同じ判断)。
+  unless profRun $
+    savePNGBound (figuresDir ++ "/hs_dashboard_full.png") $
+      (noDf |>> dashboardOf m "log_radon" :: BoundPlot)
+
+  let chainsAug = map (augmentChainWithDeterministic model) (hbmChainsR m)
+  printSummary $ summarize
+    ["mu_alpha", "sigma_alpha", "sigma_y", "beta1", "beta2"]
+    chainsAug
diff --git a/bench/posteriordb/22-arma/Model.hs b/bench/posteriordb/22-arma/Model.hs
new file mode 100644
--- /dev/null
+++ b/bench/posteriordb/22-arma/Model.hs
@@ -0,0 +1,95 @@
+{-# LANGUAGE OverloadedStrings #-}
+-- | arma-arma11 (posteriordb) — hanalyze (ModelP) 実装。
+--
+-- Phase 89: posteriordb 横断ベンチマーク。ARMA(1,1) 時系列 (T=200)。
+-- ★新ファミリ: AR成分とMA成分を併せ持つ時系列 — 12-ark (純AR)・03-garch11
+-- (再帰的分散) とは異なる構造。
+--
+-- Stan 原典 (posteriordb `models/stan/arma11.stan`):
+--   mu ~ normal(0,10); phi ~ normal(0,2); theta ~ normal(0,2);
+--   sigma ~ cauchy(0,2.5);
+--   nu[1] = mu + phi*mu; err[1] = y[1]-nu[1];       // err[0]=0 とみなす
+--   for (t in 2:T) { nu[t] = mu+phi*y[t-1]+theta*err[t-1]; err[t]=y[t]-nu[t]; }
+--   err ~ normal(0, sigma);
+--
+-- err[t] が err[t-1] に依存する逐次再帰 (14-hmm-example の forward
+-- algorithmと同系統)。Phase 101 A2: 尤度を `mapAccumL + potential` 直書きから
+-- 構造化 primitive 'ArmaNormal' + 'observeMV' へ移行 (密度は同値・'obsLogSum'
+-- が同じ err 再帰を呼ぶ)。役割 (μ/φ/θ/σ) が型で見えるため、勾配コンパイラが
+-- 逆向き随伴の閉形式 ('armaAnalyticVG'・AD tape ゼロ) を選べる。
+--
+-- reference_posterior_name = "arma-arma11" (posteriordb に公式 reference
+-- あり・hanalyze vs PyMC vs 公式reference の3者比較可能)。
+--
+-- ビルド: cabal build --project-file=cabal.project.plot posteriordb-arma
+module Main (main) where
+
+import Data.Aeson (FromJSON (..), withObject, (.:), eitherDecodeFileStrict)
+import qualified Data.Text as T
+import qualified Data.Vector as V
+import Text.Printf (printf)
+
+import Hanalyze.Model.HBM (ModelP, Distribution (..), sample, observeMV,
+                                    dataNamedX)
+import Hanalyze.Model.HBM (gradPathLabel)
+import Hanalyze.Plot (hbmModelSpec)
+import Hanalyze.Plot (HBMConfig (..), defaultHBM, hbm, (|->),
+                              dashboardFullOf, hbmChainsR)
+import Hgg.Plot.Spec (ColData (..))
+import Hgg.Plot.Frame (BoundPlot, (|>>))
+import Hgg.Plot.Backend.Rasterific (savePNGBound)
+
+import Common (summarize, printSummary, timeSamplingMs)
+
+-- | posteriordb の @arma.json@ 形状 ({"T":200,"y":[...]})。
+data ArmaData = ArmaData { arT :: Int, arY :: [Double] }
+
+instance FromJSON ArmaData where
+  parseJSON = withObject "ArmaData" $ \v ->
+    ArmaData <$> v .: "T" <*> v .: "y"
+
+noDf :: [(T.Text, ColData)]
+noDf = []
+
+dataPath :: FilePath
+dataPath = "bench/posteriordb/22-arma/data/arma.json"
+
+figuresDir :: FilePath
+figuresDir = "bench/posteriordb/22-arma/figures"
+
+readData :: IO ArmaData
+readData = either fail pure =<< eitherDecodeFileStrict dataPath
+
+-- | ARMA(1,1) (Stan 原典と同一構造)。
+--
+-- Phase 101 A2: 尤度を 'ArmaNormal' + 'observeMV' で渡す (err 再帰は
+-- 'obsLogSum' 側の同値実装)。 dataNamedX "y" は dashboard の実データ参照用に
+-- 残す。
+armaModel :: [Double] -> ModelP ()
+armaModel ysRaw = do
+  mu    <- sample "mu"    (Normal 0 10)
+  phi   <- sample "phi"   (Normal 0 2)
+  theta <- sample "theta" (Normal 0 2)
+  sigma <- sample "sigma" (HalfCauchy 2.5)
+  _ys <- dataNamedX "y" []
+  observeMV "y_seq" (ArmaNormal mu phi theta sigma) [ysRaw]
+
+main :: IO ()
+main = do
+  d <- readData
+  let df = [ ("y", NumData (V.fromList (arY d))) ] :: [(T.Text, ColData)]
+      cfg = defaultHBM { hbmChains = 4, hbmSamples = 1000
+                        , hbmWarmup = 1000, hbmSeed = Just 1 }
+      m = df |-> hbm cfg (armaModel (arY d))
+
+  -- 勾配経路 = compileGradUV が実際に選ぶ経路 (束縛済 hbmModelSpec で判定・
+  -- Phase 91 A4: 生モデルを synthVecIR に渡すと data 空で誤表示するため差替)。
+  putStrLn $ "勾配経路 = " ++ gradPathLabel (hbmModelSpec m)
+
+  (_, samplingMs) <- timeSamplingMs (hbmChainsR m)
+  printf "sampling wall = %.1f ms (draws only, no dashboard/startup)\n" samplingMs
+
+  savePNGBound (figuresDir ++ "/hs_dashboard_full.png") $
+    (noDf |>> dashboardFullOf m "y" :: BoundPlot)
+
+  printSummary $ summarize ["mu", "phi", "theta", "sigma"] (hbmChainsR m)
diff --git a/bench/posteriordb/Common.hs b/bench/posteriordb/Common.hs
new file mode 100644
--- /dev/null
+++ b/bench/posteriordb/Common.hs
@@ -0,0 +1,88 @@
+{-# LANGUAGE OverloadedStrings #-}
+-- | Phase 89 posteriordb 横断ベンチマーク: 全モデルで使い回す Haskell 側
+-- 共有ユーティリティ (Python 側 @_common.py@ と対)。
+--
+-- 'summarize' は @arviz.summary@ の簡易な代替 (mean / sd / 94% HDI / ESS /
+-- R-hat / MCSE を 1 表にまとめる)。
+--
+-- ★Phase 92 B4: ESS 列を arviz 互換の rank-normalized 多 chain **ess_bulk**
+-- ('Hanalyze.Stat.MCMC.essBulk') に切替え、mean/sd/HDI も全 chain
+-- プールで計算する (= @az.summary@ と同じ土俵)。旧版は chain 0 のみ +
+-- Geyer IMSE ('ess'・tau 下限 1 クランプで n 頭打ち) で、PyMC 側の
+-- ess_bulk と直接比較できない指標非対称の原因だった (hmm で 766.7 vs
+-- 実際は 3143 = 4.1 倍の過小表示・詳細 = phase-92 md B4)。
+module Common
+  ( ParamSummary (..)
+  , summarize
+  , printSummary
+  , timeSamplingMs
+  ) where
+
+import Control.DeepSeq (NFData, force)
+import Control.Exception (evaluate)
+import Data.Time.Clock (getCurrentTime, diffUTCTime)
+import qualified Data.Text as T
+import Text.Printf (printf)
+
+import Hanalyze.MCMC.Core (Chain, chainVals)
+import Hanalyze.Stat.MCMC (essBulk, rhat, hdi)
+
+data ParamSummary = ParamSummary
+  { psName :: T.Text
+  , psMean :: Double
+  , psSd   :: Double
+  , psHdiLo, psHdiHi :: Double  -- ^ 94% HDI (全 chain プールの post-warmup draw)。
+  , psEss  :: Double            -- ^ 'essBulk' (arviz 互換 rank-normalized・全 chain)。
+  , psRhat :: Maybe Double      -- ^ 全 chain の split-R-hat (chain ≥2 が必要)。
+  , psMcseMean :: Double        -- ^ 事後平均の モンテカルロ標準誤差 = sd/√ess。
+  }
+
+-- | パラメータごとの要約統計 ('az.summary' 相当・全 chain プール)。
+summarize :: [T.Text] -> [Chain] -> [ParamSummary]
+summarize pars chains = map summarize1 pars
+  where
+    summarize1 p =
+      let allVals = map (chainVals p) chains
+          pooled  = concat allVals
+          n       = fromIntegral (length pooled) :: Double
+          mean_   = sum pooled / n
+          sd_     = sqrt (sum [ (x - mean_) ^ (2 :: Int) | x <- pooled ] / (n - 1))
+          (lo, hi) = hdi 0.94 pooled
+          essV    = essBulk allVals
+      in ParamSummary
+           { psName = p, psMean = mean_, psSd = sd_
+           , psHdiLo = lo, psHdiHi = hi, psEss = essV
+           , psRhat = rhat allVals
+           , psMcseMean = sd_ / sqrt essV
+           }
+
+-- | 表として整形して標準出力へ。
+printSummary :: [ParamSummary] -> IO ()
+printSummary ps = do
+  printf "%-10s %9s %9s %17s %9s %8s %9s\n"
+    ("param" :: String) ("mean" :: String) ("sd" :: String)
+    ("hdi_3%..hdi_97%" :: String) ("ess_bulk" :: String) ("r_hat" :: String)
+    ("mcse_mean" :: String)
+  mapM_ printRow ps
+  where
+    printRow p = printf "%-10s %9.4f %9.4f [%6.3f, %6.3f] %9.1f %8s %9.5f\n"
+      (T.unpack (psName p)) (psMean p) (psSd p) (psHdiLo p) (psHdiHi p)
+      (psEss p) (maybe "NA" (printf "%.4f") (psRhat p) :: String) (psMcseMean p)
+
+-- | サンプリング**のみ**の壁時計 (ms) を計測する。PyMC 側マトリクス
+-- (@run_pymc_matrix.py@) が @t0 = time.perf_counter(); pm.sample(...)@ で
+-- サンプリングだけを計測するのに対応させるための共通部品 (2026-07-11
+-- 追加・09-eight-schools/07-gp-regr で「GHC起動+コンパイル試行+
+-- dashboardFullOf の PNG 生成を含むプロセス全体」を計測してしまい PyMC 側
+-- と比較不能だった反省から)。
+--
+-- @action@ は @df |-> hbm cfg model@ で得た @HBMModel@ の @hbmChainsR@ 等、
+-- 遅延評価で未確定のサンプリング結果を渡す。'force' (deepseq) で完全評価
+-- してから時刻差を取るので、遅延サンクの一部だけ強制されて計測が不正確に
+-- なることはない。戻り値は @(結果, 経過ms)@。
+timeSamplingMs :: NFData a => a -> IO (a, Double)
+timeSamplingMs result = do
+  t0 <- getCurrentTime
+  r  <- evaluate (force result)
+  t1 <- getCurrentTime
+  pure (r, realToFrac (diffUTCTime t1 t0) * 1000)
diff --git a/demo/Demo.hs b/demo/Demo.hs
--- a/demo/Demo.hs
+++ b/demo/Demo.hs
@@ -1,7 +1,8 @@
 {-# LANGUAGE OverloadedStrings #-}
 module Main where
 
-import qualified DataFrame as DX
+import qualified DataFrame.Internal.Column    as DX
+import qualified DataFrame.Internal.DataFrame  as DX
 import Hanalyze.Model.GLMM
 import Hanalyze.Model.Core (coeffList, rSquared1, fittedList)
 import Hanalyze.Model.LM   (multiPolyDesignMatrix, fitLMVec)
diff --git a/demo/bayesian/GibbsHBMDemo.hs b/demo/bayesian/GibbsHBMDemo.hs
--- a/demo/bayesian/GibbsHBMDemo.hs
+++ b/demo/bayesian/GibbsHBMDemo.hs
@@ -21,7 +21,7 @@
 -- import Hanalyze.Stat.Distribution (Distribution (..)) -- now from Hanalyze.Model.HBM
 import Hanalyze.MCMC.Core   (Chain (..), chainVals, posteriorMean, posteriorSD)
 import Hanalyze.MCMC.Gibbs  (GibbsConfig (..), defaultGibbsConfig,
-                    gibbsFromModel, gibbsMH)
+                    gibbsFromModel, gibbsMH, GibbsUpdate)
 
 -- ---------------------------------------------------------------------------
 -- モデル定義
@@ -85,7 +85,7 @@
 
   -- ── Model 1: Gamma-Poisson ──────────────────────────────────────────────
   let trueL  = 4.0 :: Double
-      (gpUpdates, gpMH) = gibbsFromModel pModel
+      (gpUpdates, gpMH) = gibbsFromModel pModel :: ([GibbsUpdate IO], [Text])
 
   putStrLn "\n=== Model 1: Gamma(2,1) + Poisson(λ) ==="
   printf "  検出: Gibbs=%d ブロック, MH=%d パラメータ\n"
@@ -96,7 +96,7 @@
 
   -- ── Model 2: Beta-Binomial ──────────────────────────────────────────────
   let trueP   = 0.7 :: Double
-      (bbUpdates, bbMH) = gibbsFromModel bModel
+      (bbUpdates, bbMH) = gibbsFromModel bModel :: ([GibbsUpdate IO], [Text])
 
   putStrLn "\n=== Model 2: Beta(2,2) + Binomial(1,p) ==="
   printf "  検出: Gibbs=%d ブロック, MH=%d パラメータ\n"
@@ -108,7 +108,7 @@
   -- ── Model 3: Normal + Exponential (混合) ────────────────────────────────
   let trueMu  = 2.0 :: Double
       trueSig = 1.5 :: Double
-      (nnUpdates, nnMH) = gibbsFromModel nModel
+      (nnUpdates, nnMH) = gibbsFromModel nModel :: ([GibbsUpdate IO], [Text])
 
   putStrLn "\n=== Model 3: Normal(0,10) + Exponential(1) [混合モード] ==="
   printf "  検出: Gibbs=%d ブロック (mu), MH=%d パラメータ (sigma)\n"
diff --git a/demo/bayesian/HBMRandomSlopeDemo.hs b/demo/bayesian/HBMRandomSlopeDemo.hs
--- a/demo/bayesian/HBMRandomSlopeDemo.hs
+++ b/demo/bayesian/HBMRandomSlopeDemo.hs
@@ -25,7 +25,8 @@
 import Text.Printf (printf)
 import System.Random.MWC (createSystemRandom)
 
-import qualified DataFrame                    as DX
+import qualified DataFrame.Internal.Column    as DX
+import qualified DataFrame.Internal.DataFrame  as DX
 import qualified DataFrame.Internal.DataFrame as DXD
 
 import Hanalyze.MCMC.Core (Chain (..), chainVals, posteriorMean, posteriorSD,
diff --git a/demo/bayesian/HBMRegressionDemo.hs b/demo/bayesian/HBMRegressionDemo.hs
--- a/demo/bayesian/HBMRegressionDemo.hs
+++ b/demo/bayesian/HBMRegressionDemo.hs
@@ -22,7 +22,8 @@
 import Text.Printf (printf)
 import System.Random.MWC (createSystemRandom)
 
-import qualified DataFrame                    as DX
+import qualified DataFrame.Internal.Column    as DX
+import qualified DataFrame.Internal.DataFrame  as DX
 import qualified DataFrame.Internal.DataFrame as DXD
 import Hanalyze.MCMC.Core (Chain (..), chainVals, posteriorMean, posteriorSD,
                   posteriorQuantile)
diff --git a/demo/bayesian/Phase37A0VerifyDemo.hs b/demo/bayesian/Phase37A0VerifyDemo.hs
new file mode 100644
--- /dev/null
+++ b/demo/bayesian/Phase37A0VerifyDemo.hs
@@ -0,0 +1,277 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RankNTypes #-}
+-- | Phase 37-A0 doc 拡充の検証用 demo。
+--
+-- docs/bayesian/02-probabilistic-model.ja.md の追加節
+-- (形式 A / B / C / random slope / multi-level / crossed / prior choice)
+-- に載せる sample code をそのまま入れて、 build + 小規模 NUTS で実行可能性を
+-- 確認する。 doc に貼る code はここから写経する。
+--
+-- 各モデルは独立に小さい NUTS で 1 回まわす (50 iter / 25 burn) ので、 doc に
+-- 載せる code が "本当にコンパイル + 実行できる" ことの証拠になる。
+module Main where
+
+import           Control.Monad             (forM, forM_)
+import qualified Data.Map.Strict           as Map
+import           Data.Maybe                (fromMaybe)
+import qualified Data.Text                 as T
+import           System.Random.MWC         (createSystemRandom)
+import           Text.Printf               (printf)
+
+import           Hanalyze.MCMC.Core        (acceptanceRate, posteriorMean)
+import           Hanalyze.MCMC.NUTS        (NUTSConfig (..), defaultNUTSConfig,
+                                            nuts)
+import           Hanalyze.Model.HBM        (Distribution (..), ModelP,
+                                            indexed, nonCenteredNormal,
+                                            observe, sample)
+
+-- ===========================================================================
+-- 形式 A: 群ごとデータが分かれている (現 Pattern 5)
+-- ===========================================================================
+
+-- | μ ~ Normal(0, 10), τ ~ HalfNormal(5),
+--   θ_j ~ Normal(μ, τ),  y_ij ~ Normal(θ_j, σ=1)
+schoolModelA :: [[Double]] -> ModelP ()
+schoolModelA groupData = do
+  mu  <- sample "mu"  (Normal 0 10)
+  tau <- sample "tau" (HalfNormal 5)
+  forM_ (zip [1 :: Int ..] groupData) $ \(j, ys) -> do
+    theta <- sample (indexed "theta" j) (Normal mu tau)
+    observe (indexed "y" j) (Normal theta 1) ys
+
+groupDataA :: [[Double]]
+groupDataA =
+  [ [ 1.1, 0.8, 1.3, 1.0 ]
+  , [ 4.9, 5.2, 4.7, 5.1 ]
+  , [ 9.0, 8.7, 9.3, 8.9 ]
+  ]
+
+initA :: Map.Map T.Text Double
+initA = Map.fromList
+  [ ("mu", 5), ("tau", 3)
+  , ("theta_1", 1), ("theta_2", 5), ("theta_3", 9) ]
+
+-- ===========================================================================
+-- 形式 B: long-format (各観測が gid を持つ)
+-- ===========================================================================
+
+-- | gid と y の縦持ち data を受け、 per-group θ を先に forM で全部展開してから
+--   観測する。 同じ gid を持つ観測を集めて 1 度に observe するのが効率的。
+schoolModelB :: [Int] -> [Double] -> ModelP ()
+schoolModelB gids ys = do
+  let nG = maximum gids + 1
+  mu  <- sample "mu"  (Normal 0 10)
+  tau <- sample "tau" (HalfNormal 5)
+  thetas <- forM [0 .. nG - 1] $ \j ->
+    sample (indexed "theta" j) (Normal mu tau)
+  forM_ [0 .. nG - 1] $ \j -> do
+    let ysG = [y | (g, y) <- zip gids ys, g == j]
+    observe (indexed "y" j)
+            (Normal (thetas !! j) 1) ysG
+
+gidsB :: [Int]
+gidsB = [0,0,0,0, 1,1,1,1, 2,2,2,2]
+
+ysB :: [Double]
+ysB = [1.1, 0.8, 1.3, 1.0,  4.9, 5.2, 4.7, 5.1,  9.0, 8.7, 9.3, 8.9]
+
+initB :: Map.Map T.Text Double
+initB = initA
+
+-- ===========================================================================
+-- 形式 C: non-centered グループ (funnel 回避)
+-- ===========================================================================
+
+-- | θ_j ~ Normal(μ, τ) を θ_j_raw ~ Normal(0,1), θ_j = μ + τ·θ_j_raw に書き直す。
+--   `nonCenteredNormal` がその差し替えを 1 行で提供する。 latent 名は
+--   "theta_j_raw" になり、 推論後 augmentChainWithDeterministic で θ_j を復元
+--   できる (doc 中では derived 部は割愛しても OK)。
+schoolModelC :: [[Double]] -> ModelP ()
+schoolModelC groupData = do
+  mu  <- sample "mu"  (Normal 0 10)
+  tau <- sample "tau" (HalfNormal 5)
+  forM_ (zip [1 :: Int ..] groupData) $ \(j, ys) -> do
+    theta <- nonCenteredNormal (indexed "theta" j) mu tau
+    observe (indexed "y" j) (Normal theta 1) ys
+
+initC :: Map.Map T.Text Double
+initC = Map.fromList
+  [ ("mu", 5), ("tau", 3)
+  , ("theta_1_raw", 0), ("theta_2_raw", 0), ("theta_3_raw", 0) ]
+
+-- ===========================================================================
+-- random slope (α_j, β_j 両方を階層化)
+-- ===========================================================================
+
+-- | y_ij ~ Normal(α_j + β_j · x_ij, σ),
+--   α_j ~ Normal(μ_α, τ_α),  β_j ~ Normal(μ_β, τ_β)。
+--   入力は (x, y) のグループごとのペアリスト。
+randomSlope :: [[(Double, Double)]] -> ModelP ()
+randomSlope groupData = do
+  muA  <- sample "mu_alpha"    (Normal 0 10)
+  tauA <- sample "tau_alpha"   (HalfNormal 5)
+  muB  <- sample "mu_beta"     (Normal 0 5)
+  tauB <- sample "tau_beta"    (HalfNormal 5)
+  sig  <- sample "sigma"       (Exponential 1)
+  forM_ (zip [1 :: Int ..] groupData) $ \(j, pts) -> do
+    alpha <- sample (indexed "alpha" j) (Normal muA tauA)
+    beta  <- sample (T.pack ("beta_"  ++ show j)) (Normal muB tauB)
+    forM_ pts $ \(x, y) ->
+      observe (indexed "y" j)
+              (Normal (alpha + beta * realToFrac x) sig) [y]
+
+rsData :: [[(Double, Double)]]
+rsData =
+  [ zip [0.5, 1.0, 1.5, 2.0] [1.6, 1.2, 0.8, 0.4]
+  , zip [0.5, 1.0, 1.5, 2.0] [4.85, 4.70, 4.55, 4.40]
+  , zip [0.5, 1.0, 1.5, 2.0] [8.10, 8.20, 8.30, 8.40]
+  ]
+
+initRS :: Map.Map T.Text Double
+initRS = Map.fromList
+  [ ("mu_alpha", 5), ("tau_alpha", 3)
+  , ("mu_beta", 0),  ("tau_beta", 1)
+  , ("sigma", 0.3)
+  , ("alpha_1", 2), ("alpha_2", 5), ("alpha_3", 8)
+  , ("beta_1", -0.8), ("beta_2", -0.3), ("beta_3", 0.2) ]
+
+-- ===========================================================================
+-- multi-level (3-level nested): district → school → students
+-- ===========================================================================
+
+-- | 地区 d 内に学校 (d,s) があり、 その中に生徒 (d, s, i) がいる。
+--   μ ← Normal(0, 10)
+--   τ_d ← HalfNormal(5)               (地区間 SD)
+--   τ_s ← HalfNormal(5)               (学校間 SD、 地区共通)
+--   δ_d ← Normal(μ, τ_d)              (地区効果)
+--   θ_{d,s} ← Normal(δ_d, τ_s)        (学校効果)
+--   y_{d,s,i} ← Normal(θ_{d,s}, 1)
+--
+--   入力: 各地区につき、 学校ごとの観測リストのリスト。
+multiLevel :: [[[Double]]] -> ModelP ()
+multiLevel byDistrict = do
+  mu  <- sample "mu"    (Normal 0 10)
+  tD  <- sample "tau_d" (HalfNormal 5)
+  tS  <- sample "tau_s" (HalfNormal 5)
+  forM_ (zip [1 :: Int ..] byDistrict) $ \(d, schools) -> do
+    delta <- sample (indexed "delta" d) (Normal mu tD)
+    forM_ (zip [1 :: Int ..] schools) $ \(s, ys) -> do
+      theta <- sample (T.pack (concat ["theta_", show d, "_", show s]))
+                      (Normal delta tS)
+      observe (T.pack (concat ["y_", show d, "_", show s]))
+              (Normal theta 1) ys
+
+mlData :: [[[Double]]]
+mlData =
+  [ [ [1.1, 0.8, 1.3], [1.5, 1.2, 1.7] ]   -- 地区 1 に学校 2 つ
+  , [ [4.9, 5.2, 4.7], [5.5, 5.3, 5.7] ]   -- 地区 2 に学校 2 つ
+  , [ [8.9, 9.0, 8.7], [8.4, 8.6, 8.5] ]   -- 地区 3 に学校 2 つ
+  ]
+
+initML :: Map.Map T.Text Double
+initML = Map.fromList $
+  [ ("mu", 5), ("tau_d", 3), ("tau_s", 1)
+  , ("delta_1", 1), ("delta_2", 5), ("delta_3", 9) ]
+  ++ [ (T.pack (concat ["theta_", show d, "_", show s]), v)
+     | (d, v) <- [(1::Int, 1.3), (2, 5.4), (3, 8.7)]
+     , s <- [1 :: Int .. 2] ]
+
+-- ===========================================================================
+-- crossed random effects: school × year
+-- ===========================================================================
+
+-- | 学校 s と年度 t が **交差** (どの (s, t) ペアでも観測される)。
+--   α_s ← Normal(μ_α, τ_α)
+--   γ_t ← Normal(0, τ_γ)
+--   y_{s,t,i} ← Normal(α_s + γ_t, σ)
+--
+--   入力: [(sid, tid, y)] の long-format。
+crossed :: Int -> Int -> [(Int, Int, Double)] -> ModelP ()
+crossed nS nT obs = do
+  muA <- sample "mu_alpha" (Normal 0 10)
+  tA  <- sample "tau_a"    (HalfNormal 5)
+  tG  <- sample "tau_g"    (HalfNormal 5)
+  sig <- sample "sigma"    (Exponential 1)
+  alphas <- forM [0 .. nS - 1] $ \s ->
+    sample (indexed "alpha" s) (Normal muA tA)
+  gammas <- forM [0 .. nT - 1] $ \t ->
+    sample (indexed "gamma" t) (Normal 0 tG)
+  forM_ obs $ \(s, t, y) ->
+    observe (T.pack (concat ["y_", show s, "_", show t]))
+            (Normal (alphas !! s + gammas !! t) sig) [y]
+
+crossedObs :: [(Int, Int, Double)]
+crossedObs =
+  [ (0,0, 1.0), (0,1, 1.3), (0,2, 0.9)
+  , (1,0, 4.8), (1,1, 5.2), (1,2, 5.0)
+  , (2,0, 8.7), (2,1, 9.1), (2,2, 8.9)
+  ]
+
+initCrossed :: Map.Map T.Text Double
+initCrossed = Map.fromList
+  [ ("mu_alpha", 5), ("tau_a", 3), ("tau_g", 0.3), ("sigma", 0.3)
+  , ("alpha_0", 1), ("alpha_1", 5), ("alpha_2", 9)
+  , ("gamma_0", 0), ("gamma_1", 0), ("gamma_2", 0) ]
+
+-- ===========================================================================
+-- prior choice: HalfNormal vs HalfCauchy on τ
+-- ===========================================================================
+
+-- | 弱情報事前 HalfNormal(5) 版 (Gelman 2006 推奨)。
+priorHalfNormal :: [[Double]] -> ModelP ()
+priorHalfNormal = schoolModelA  -- HalfNormal(5) を使う実装は schoolModelA と同じ
+
+-- | 重い裾 HalfCauchy(2.5) 版。
+priorHalfCauchy :: [[Double]] -> ModelP ()
+priorHalfCauchy groupData = do
+  mu  <- sample "mu"  (Normal 0 10)
+  tau <- sample "tau" (HalfCauchy 2.5)
+  forM_ (zip [1 :: Int ..] groupData) $ \(j, ys) -> do
+    theta <- sample (indexed "theta" j) (Normal mu tau)
+    observe (indexed "y" j) (Normal theta 1) ys
+
+-- ===========================================================================
+-- 検証 runner
+-- ===========================================================================
+
+verify
+  :: T.Text
+  -> ModelP ()
+  -> Map.Map T.Text Double
+  -> [T.Text]   -- ^ 確認したい posterior mean パラメータ
+  -> IO ()
+verify label model initP showVars = do
+  gen <- createSystemRandom
+  ch  <- nuts model smallCfg initP gen
+  printf "[%s] accept=%.2f  " (T.unpack label) (acceptanceRate ch)
+  forM_ showVars $ \v ->
+    printf " %s=%.2f" (T.unpack v) (fromMaybe (0 :: Double) (posteriorMean v ch))
+  putStrLn ""
+  where
+    smallCfg = defaultNUTSConfig
+      { nutsIterations = 100
+      , nutsBurnIn     = 50
+      , nutsStepSize   = 0.05
+      , nutsMaxDepth   = 6
+      }
+
+main :: IO ()
+main = do
+  putStrLn "═══ Phase 37 A0 sample code verification ═══"
+  verify "A (per-group data)"   (schoolModelA  groupDataA) initA
+         ["mu", "tau", "theta_1", "theta_2", "theta_3"]
+  verify "B (long-format)"      (schoolModelB  gidsB ysB)  initB
+         ["mu", "tau", "theta_1"]
+  verify "C (non-centered)"     (schoolModelC  groupDataA) initC
+         ["mu", "tau"]
+  verify "random slope"         (randomSlope   rsData)     initRS
+         ["mu_alpha", "mu_beta", "beta_1", "beta_3"]
+  verify "multi-level (3-lvl)"  (multiLevel    mlData)     initML
+         ["mu", "tau_d", "tau_s", "delta_1", "delta_3"]
+  verify "crossed (S × T)"      (crossed 3 3 crossedObs)   initCrossed
+         ["mu_alpha", "alpha_0", "alpha_2", "gamma_1"]
+  verify "prior HalfNormal(5)"  (priorHalfNormal groupDataA) initA
+         ["mu", "tau"]
+  verify "prior HalfCauchy(2.5)" (priorHalfCauchy groupDataA) initA
+         ["mu", "tau"]
+  putStrLn "═══ all 8 models compiled + ran ═══"
diff --git a/demo/bayesian/PlateNotationDemo.hs b/demo/bayesian/PlateNotationDemo.hs
new file mode 100644
--- /dev/null
+++ b/demo/bayesian/PlateNotationDemo.hs
@@ -0,0 +1,95 @@
+{-# LANGUAGE OverloadedStrings #-}
+-- | Phase 40 plate 記法のデモ。 8-schools + nested 多レベルモデルの
+-- mermaid HTML と graphviz DOT を出力する。
+--
+-- 実行:
+--
+-- > cabal run plate-notation-demo
+--
+-- 生成物 (demo-output/ 下):
+--
+-- - @8schools.html@   ブラウザで開くと mermaid plate (subgraph 囲い) で表示
+-- - @8schools.dot@    @dot -Tpng 8schools.dot -o 8schools.png@ で PNG 化
+-- - @multilevel.html@ nested plate (school × student)
+-- - @multilevel.dot@  nested cluster
+module Main where
+
+import Control.Monad (forM_, forM)
+import qualified Data.Text as T
+import qualified Data.Text.IO as TIO
+import System.Directory (createDirectoryIfMissing)
+
+import qualified Hanalyze.Model.HBM as HBM
+import qualified Hanalyze.Viz.ModelGraph as VMG
+import qualified Hanalyze.Viz.ModelGraphDot as VMGD
+
+-- ---------------------------------------------------------------------------
+-- モデル 1: 8-schools (Gelman et al.)
+-- ---------------------------------------------------------------------------
+
+eightSchools :: HBM.ModelP ()
+eightSchools = do
+  mu  <- HBM.sample "mu"  (HBM.Normal 0 5)
+  tau <- HBM.sample "tau" (HBM.HalfCauchy 5)
+  _ <- HBM.plate "school" 8 $ forM [0..7 :: Int] $ \j -> do
+    eta <- HBM.sample ("eta_" <> T.pack (show j)) (HBM.Normal 0 1)
+    HBM.observe ("y_" <> T.pack (show j))
+                (HBM.Normal (mu + tau * eta) 1)
+                [realToFrac j]
+  return ()
+
+-- ---------------------------------------------------------------------------
+-- モデル 2: nested multi-level (school × student)
+-- ---------------------------------------------------------------------------
+
+multilevel :: HBM.ModelP ()
+multilevel = do
+  mu  <- HBM.sample "mu" (HBM.Normal 0 5)
+  tau <- HBM.sample "tau" (HBM.HalfNormal 1)
+  _ <- HBM.plate "school" 3 $ forM_ [0..2 :: Int] $ \j -> do
+    theta <- HBM.sample ("theta_" <> T.pack (show j))
+                        (HBM.Normal mu tau)
+    _ <- HBM.plate "student" 2 $ forM_ [0..1 :: Int] $ \i ->
+           HBM.observe ("y_" <> T.pack (show j) <> "_" <> T.pack (show i))
+                       (HBM.Normal theta 1)
+                       [realToFrac (j * 2 + i)]
+    return ()
+  return ()
+
+-- ---------------------------------------------------------------------------
+-- main
+-- ---------------------------------------------------------------------------
+
+main :: IO ()
+main = do
+  createDirectoryIfMissing True "demo-output"
+  -- 1. 8-schools (expanded = N 個列挙)
+  let g1   = HBM.buildModelGraph eightSchools
+      g1c  = HBM.collapseIndexedPlateNodes g1   -- PyMC 同等の集約
+  VMG.renderModelGraph "demo-output/8schools-expanded.html"
+    "8 schools - expanded (8 個列挙)" g1
+  VMG.renderModelGraph "demo-output/8schools-collapsed.html"
+    "8 schools - collapsed (PyMC 同等)" g1c
+  VMGD.writeModelGraphDot "demo-output/8schools-expanded.dot"  g1
+  VMGD.writeModelGraphDot "demo-output/8schools-collapsed.dot" g1c
+  TIO.putStrLn "[1] 8-schools:"
+  TIO.putStrLn "    展開 (Phase 40 旧):"
+  TIO.putStrLn "    - demo-output/8schools-expanded.html / .dot"
+  TIO.putStrLn "    集約 (Phase 40-A8 = PyMC 同等):"
+  TIO.putStrLn "    - demo-output/8schools-collapsed.html / .dot"
+  TIO.putStrLn $ "    mgPlates = " <> T.pack (show (HBM.mgPlates g1))
+  TIO.putStrLn $ "    集約後ノード数 = " <> T.pack (show (length (HBM.mgNodes g1c)))
+  -- 2. nested multilevel
+  let g2  = HBM.buildModelGraph multilevel
+      g2c = HBM.collapseIndexedPlateNodes g2
+  VMG.renderModelGraph "demo-output/multilevel-expanded.html"
+    "school × student - expanded" g2
+  VMG.renderModelGraph "demo-output/multilevel-collapsed.html"
+    "school × student - collapsed (PyMC 同等)" g2c
+  VMGD.writeModelGraphDot "demo-output/multilevel-expanded.dot"  g2
+  VMGD.writeModelGraphDot "demo-output/multilevel-collapsed.dot" g2c
+  TIO.putStrLn "[2] nested multi-level:"
+  TIO.putStrLn "    展開:    demo-output/multilevel-expanded.html / .dot"
+  TIO.putStrLn "    集約:    demo-output/multilevel-collapsed.html / .dot"
+  TIO.putStrLn $ "    mgPlates = " <> T.pack (show (HBM.mgPlates g2))
+  TIO.putStrLn $ "    集約後ノード数 = " <> T.pack (show (length (HBM.mgNodes g2c)))
diff --git a/demo/bayesian/PyMCStatusDemo.hs b/demo/bayesian/PyMCStatusDemo.hs
--- a/demo/bayesian/PyMCStatusDemo.hs
+++ b/demo/bayesian/PyMCStatusDemo.hs
@@ -17,50 +17,60 @@
 -- ---------------------------------------------------------------------------
 
 -- (カテゴリ, 実装済 ✅, 部分実装 🚧, 未実装 ❌)
--- Phase A-J まで完了後の最新数値。
+-- Phase 29 完了 + Phase 37 計画反映 (2026-05-30 更新)。
+-- A2 (連続 7) + A3 (離散 6) + A4 (多変量 3) = 16 の分布が Phase 37 計画上で
+-- 未実装、 これを missing にカウントする (旧 Bound 1 も含めて 17)。
 statusByCategory :: [(Text, Int, Int, Int)]
 statusByCategory =
   [ -- 分布: Base12 + (Mixture, Truncated, Censored, MvNormal, Dirichlet,
     --        LKJ, Multinomial, NegBinom, ZIP, ZIB, InvGamma, Weibull,
-    --        Pareto, BetaBinom, VonMises) - 27; 残り Wishart, MvT, Bound = 3
-    ("分布",          27, 0, 3 )
-  , -- サンプラー: NUTS/HMC/MH/Gibbs/ADVI/Slice = 6; Full-ADVI/SMC/NormFlow = 3
-    ("サンプラー",     6, 0, 3 )
-  , -- 事後 Workflow: PPC/PriorPC/Potential/set_data/Deterministic = 5
-    ("事後 Workflow",  5, 0, 1 )  -- 多PPC など 1 件残
-  , -- 可視化: trace/posterior/pair/acf/forest/energy/BFMI/HDI-trace
-    --        /rank/pp_check/summary/divergence-overlay = 12; 残 1
-    ("可視化・診断",   12, 0, 1 )
-  , -- モデル比較: WAIC/LOO/compareModels = 3; ベイズファクター 1
-    ("モデル比較",     3, 0, 1 )
+    --        Pareto, BetaBinom, VonMises) = 27 ✅
+    -- + Phase 37-A2 (4) + A3 (5) + A4 (2) ✅ → 38
+    -- 残 Phase 37 計画: A2 残 (3) + A3 残 (1) + A4 残 Wishart (1) + Bound (1) = 6 ❌
+    ("分布",          38, 0,  6)
+  , -- サンプラー: NUTS/HMC/MH/Gibbs/Slice/ADVI/SMC/Full-rank ADVI = 8 ✅
+    -- 残: 正規化フロー (Stretch) = 1 ❌
+    ("サンプラー",     8, 0, 1 )
+  , -- 事後 Workflow: PPC/PriorPC/Potential/set_data/Deterministic = 5 ✅
+    ("事後 Workflow",  5, 0, 0 )
+  , -- 可視化・診断: trace/posterior/pair/acf/forest/energy/BFMI/HDI-trace
+    --              /rank/ppc/summary/divergence-overlay/ESS-R̂表 = 13 ✅
+    --              A7 はナビ整理のみで実装 gap なし
+    ("可視化・診断",   13, 0, 0 )
+  , -- モデル比較: WAIC/LOO/Pseudo-BMA/真BMA/BayesFactor (Bridge) = 5 ✅
+    ("モデル比較",     5, 0, 0 )
   , -- プリミティブ: 階層/ランダム切片・傾き/Mixture/Trunc/Censored/Potential
-    --              /Deterministic/non-centered/AR/MvN-latent/Dirichlet/LKJ = 12
-    ("プリミティブ",   12, 1, 3 )  -- GP部分; ODE/BNN/state-space-extended
+    --              /Deterministic/non-centered/AR/MvN-latent/Dirichlet/LKJ = 12 ✅
+    -- + Phase 37-A6: glmmRandomIntercept ✅ → 13
+    -- 残 Phase 39: hmmLatent / dpStickBreaking = 2 ❌
+    -- Stretch: ODE 尤度 / Bayes NN = 2 ❌
+    ("プリミティブ",   13, 1, 4 )  -- GP 部分; hmm/dp + ODE/BNN
   ]
 
--- 完了したフェーズ
+-- 完了したフェーズ (Phase 29 まで)
 addedThisBranch :: [(Text, Text)]
 addedThisBranch =
-  [ ("Phase A", "pm.Potential プリミティブ")
-  , ("Phase B", "pm.Mixture (log-sum-exp)")
-  , ("Phase C", "Truncated / Censored")
-  , ("Phase D", "MvNormal 観測専用")
-  , ("Phase E", "Energy plot / BFMI")
-  , ("Phase F", "5 つの可視化基盤 (Summary/HDI-Trace/Rank/PPC/Divergence)")
-  , ("Phase G", "6 つの主要機能 (Deterministic/Dir/non-centered/Div/set_data/MvN-latent)")
-  , ("Phase H", "6 件の補完 (NB/Multinomial/ZIP/LKJ/withData多相/Hanalyze.Stat.Summary 切出)")
-  , ("Phase I", "5 つの新規分布 (InvGamma/Weibull/Pareto/BetaBin/VonMises)")
-  , ("Phase J", "LKJ K=3 / AR(1) / Slice sampler")
+  [ ("Phase A-J",   "本ブランチ初期: 27 分布 + サンプラー基盤 + 5 viz")
+  , ("Phase 29-A1", "SMC (annealing + bridge 経路)")
+  , ("Phase 29-A2", "Bridge Sampling + Bayes Factor")
+  , ("Phase 29-A3", "真の BMA (周辺尤度ベース)")
+  , ("Phase 37-A0", "HBM 書き方 doc (グループ別 3 形式 + multi-level + crossed)")
+  , ("Phase 37-A1", "本 PyMC 比較 doc を Phase 29 反映 + 残 gap 確定")
+  , ("Phase 37-A2", "連続分布 +4: SkewNormal / Logistic / Gumbel / AsymmetricLaplace")
+  , ("Phase 37-A3", "離散分布 +5: OrderedLogistic / DiscreteUniform / Geometric / HyperGeometric / ZeroInflatedNegativeBinomial")
+  , ("Phase 37-A4", "多変量分布 +2: MvStudentT / DirichletMultinomial (Wishart 後回し)")
+  , ("Phase 37-A5", "Full-rank ADVI (q = N(μ, LLᵀ)、 viCovU で L 出力)")
+  , ("Phase 37-A6", "glmmRandomIntercept helper (Gaussian/Binomial/Poisson)")
   ]
 
--- 残課題 (Stretch)
+-- 残課題 (Phase 37 計画分 + Stretch)
 todoStretch :: [Text]
 todoStretch =
-  [ "Wishart / Multivariate-t (LKJ で代替推奨)"
-  , "Full-rank ADVI / Normalizing flows / SMC"
-  , "ODE 尤度 (Runge-Kutta + AD、研究レベル)"
-  , "ベイズ NN (隠れ層、研究レベル)"
-  , "ベイズファクター / 周辺尤度 (重要度サンプリング系)"
+  [ "[A2 残] 連続分布 3 (低優先): Triangular/Kumaraswamy/Rice"
+  , "[A3 残] 離散 1 (低優先): DiscreteWeibull"
+  , "[A4 残] 多変量 1: Wishart (LKJ 代替可、 後回し)"
+  , "[Phase 39 候補] 残 helper: hmmLatent / dpStickBreaking (Phase 37 から繰越)"
+  , "[Stretch] 正規化フロー / ODE 尤度 / ベイズ NN (研究レベル、 別 Phase)"
   ]
 
 -- ---------------------------------------------------------------------------
diff --git a/demo/bayesian/SimpsonParadoxDemo.hs b/demo/bayesian/SimpsonParadoxDemo.hs
--- a/demo/bayesian/SimpsonParadoxDemo.hs
+++ b/demo/bayesian/SimpsonParadoxDemo.hs
@@ -20,7 +20,8 @@
 import Text.Printf (printf)
 import System.Random.MWC (createSystemRandom)
 
-import qualified DataFrame                    as DX
+import qualified DataFrame.Internal.Column    as DX
+import qualified DataFrame.Internal.DataFrame  as DX
 import qualified DataFrame.Internal.DataFrame as DXD
 import Hanalyze.Model.Core    (Band (..), coefficientsV)
 import Hanalyze.Model.LM      (fitPolyWithSmooth, SmoothFit (..), polyDesignMatrix)
diff --git a/demo/doe-optim/CISImplantWorkflowDemo.hs b/demo/doe-optim/CISImplantWorkflowDemo.hs
new file mode 100644
--- /dev/null
+++ b/demo/doe-optim/CISImplantWorkflowDemo.hs
@@ -0,0 +1,395 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE BangPatterns      #-}
+-- | CMOS Image Sensor (CIS) PD implant 工程の workflow デモ。
+--
+-- マニュアル `docs/manual/semiconductor-design-workflow.md` の付録 B 相当を、
+-- ユーザ実用ケース (3 因子 + tilt 離散 + CIS 応答) で動作可能形にしたもの。
+--
+-- ## モチーフ
+--
+-- CMOS Image Sensor のフォトダイオード (PD) implant 工程を題材とし、 注入条件
+-- (dose、 energy、 tilt) が画素特性に与える影響を Custom Design で評価する。
+--
+-- 3 因子:
+--
+--   * dose   (1e13 .. 5e13 cm^-2、 5 水準)
+--   * energy (5 .. 50 keV、 5 水準)
+--   * tilt   (0 / 7 / 15 / 30 deg、 装置制約で 4 水準離散)
+--
+-- 3 応答:
+--
+--   * defect  (画素欠陥カウント、 数万オーダの自然数、 Poisson GLM)
+--   * fwc     (Full Well Capacity [e-]、 連続、 二次 RSM、 maximize)
+--   * dark    (Dark Current [pA/cm^2]、 連続 log-scale、 LM、 minimize)
+--
+-- ## フロー
+--
+-- 1. Custom Design I-optimal で 23 runs を生成
+-- 2. AddCenter 2 で強制中心行を追加 → 計 25 runs (1 ロット枠)
+-- 3. 合成 Sim (本来は実機 / TCAD) で 3 応答を測定
+-- 4. defect → Poisson GLM (Log link)、 fwc → RSM 二次、 dark → log-LM
+-- 5. Desirability で多目的統合スコアを評価、 最適条件を特定
+--
+-- ## 数値合成
+--
+-- 各応答は **dose / energy / tilt の物理直感に沿った合成関数** + 小さい
+-- 確定的揺らぎ (run 番号由来) で生成する。 実機データ取得を模した骨組み。
+module Main where
+
+import qualified Data.Text                          as T
+import qualified Numeric.LinearAlgebra              as LA
+import           Text.Printf                        (printf)
+
+import qualified Hanalyze.Design.Custom.Factor      as DF
+import qualified Hanalyze.Design.Custom.Model       as DM
+import qualified Hanalyze.Design.Custom.Coordinate  as DC
+import qualified Hanalyze.Design.Custom.Augment     as DA
+import qualified Hanalyze.Design.Optimal            as DO
+import qualified Hanalyze.Design.RSM                as RSM
+import qualified Hanalyze.Model.Core                as Core
+import qualified Hanalyze.Model.LM                  as LM
+import qualified Hanalyze.Model.GLM                 as GLM
+import qualified Hanalyze.Optim.Desirability        as Des
+import qualified Hanalyze.Model.LiNGAM.Direct       as LNG
+import qualified Hanalyze.Model.DAG                 as DAG
+import qualified Data.Text.IO                       as TIO
+import qualified Data.Vector                        as V
+import           System.Directory                   (createDirectoryIfMissing)
+
+-- ===========================================================================
+-- 因子定義
+-- ===========================================================================
+
+doseLo, doseHi :: Double
+doseLo = 1e13
+doseHi = 5e13
+
+energyLo, energyHi :: Double
+energyLo = 5
+energyHi = 50
+
+tiltLevels :: [Double]
+tiltLevels = [0, 7, 15, 30]
+
+factors :: [DF.Factor]
+factors =
+  [ DF.Factor "dose"   (DF.Continuous   doseLo   doseHi)   DF.Controllable
+  , DF.Factor "energy" (DF.Continuous   energyLo energyHi) DF.Controllable
+  , DF.Factor "tilt"   (DF.DiscreteNum  tiltLevels)        DF.Controllable
+  ]
+
+-- | 二次モデル: main + 2-way interactions + pure quadratic
+--   (10 項、 23 runs で十分推定可能)
+quadModel :: DM.Model
+quadModel = DM.Model
+  { DM.mTerms =
+      [ DM.TIntercept
+      , DM.TMain "dose"
+      , DM.TMain "energy"
+      , DM.TMain "tilt"
+      , DM.TInter ["dose", "energy"]
+      , DM.TInter ["dose", "tilt"]
+      , DM.TInter ["energy", "tilt"]
+      , DM.TPower "dose"   2
+      , DM.TPower "energy" 2
+      , DM.TPower "tilt"   2
+      ]
+  , DM.mNorm = DM.NCoded
+  }
+
+-- ===========================================================================
+-- 合成応答 (synthetic ground truth)
+-- ===========================================================================
+--
+-- 物理直感ベースの合成関数:
+--   * defect: 高 dose で増、 高 energy で増、 tilt 中央で最小 (チャネリング)
+--             → Poisson λ ≈ exp(10 .. 11) 程度 → 数万カウント
+--   * fwc:    energy で増、 dose 中央で極大、 tilt 弱影響
+--   * dark:   高 dose で増、 高 energy で増 (損傷)、 tilt 中央で最小
+--             → log-scale で扱う
+
+codedDose :: Double -> Double
+codedDose x = 2 * (x - (doseLo + doseHi) / 2) / (doseHi - doseLo)
+
+codedEnergy :: Double -> Double
+codedEnergy x = 2 * (x - (energyLo + energyHi) / 2) / (energyHi - energyLo)
+
+codedTilt :: Double -> Double
+codedTilt x = (x - 13) / 17    -- tilt 範囲 0..30 を ~[-0.76, 1] にざっくり
+
+-- | 引数は **既に coded された値** (dose / energy ∈ [-1,1])、 ただし
+--   tilt は raw 値 (DiscreteNum 因子はライブラリの内部表現も raw)。
+--   ここで tilt のみ coded に変換する。
+syntheticResp :: (Double, Double, Double) -> (Int, Double, Double)
+syntheticResp (dC, eC, tiltRaw) =
+  let !tC = codedTilt tiltRaw
+      -- defect: Poisson λ。 中心 ~exp(10.5) ≈ 36300
+      !logLam = 10.5 + 0.8*dC + 0.4*eC + 0.30*tC*tC - 0.20*dC*eC
+      !lam    = exp logLam
+      !defect = round lam :: Int
+      -- fwc (e-): 中心 ~12000、 energy で増、 dose 中央極大 (dose^2 で減少)
+      !fwc = 12000 + 1000*eC - 800*dC*dC - 200*tC + 50*dC*eC
+      -- dark (pA/cm^2): log-scale
+      !logDark = -1.0 + 0.5*dC + 0.3*eC + 0.4*tC*tC
+      !dark    = exp logDark
+  in (defect, fwc, dark)
+
+-- ===========================================================================
+-- ヘルパ
+-- ===========================================================================
+
+-- | 設計行列各行から (dose, energy, tilt) を取り出す
+rowToFactors :: LA.Matrix Double -> Int -> (Double, Double, Double)
+rowToFactors m i =
+  ( LA.atIndex m (i, 0)
+  , LA.atIndex m (i, 1)
+  , LA.atIndex m (i, 2)
+  )
+
+-- | coded 値の行列に変換 (analysis 用)。
+--   ライブラリの cdMatrix: Continuous は既に coded ±1、 DiscreteNum (tilt) は raw。
+--   ここで tilt のみ codedTilt に通す。
+toCodedMatrix :: LA.Matrix Double -> LA.Matrix Double
+toCodedMatrix m =
+  let n   = LA.rows m
+      dC  = LA.fromList [ LA.atIndex m (i,0)               | i <- [0..n-1] ]
+      eC  = LA.fromList [ LA.atIndex m (i,1)               | i <- [0..n-1] ]
+      tC  = LA.fromList [ codedTilt (LA.atIndex m (i,2))   | i <- [0..n-1] ]
+  in LA.fromColumns [dC, eC, tC]
+
+-- | coded dose/energy を raw 単位に戻す (表示用)
+rawDose :: Double -> Double
+rawDose c = (doseLo + doseHi) / 2 + c * (doseHi - doseLo) / 2
+
+rawEnergy :: Double -> Double
+rawEnergy c = (energyLo + energyHi) / 2 + c * (energyHi - energyLo) / 2
+
+-- | 二次モデル設計行列を coded 行列から構築 (intercept + 3 main + 3 inter + 3 quad)
+buildQuadDesign :: LA.Matrix Double -> LA.Matrix Double
+buildQuadDesign xCoded =
+  let n = LA.rows xCoded
+      d = LA.flatten (xCoded LA.¿ [0])
+      e = LA.flatten (xCoded LA.¿ [1])
+      t = LA.flatten (xCoded LA.¿ [2])
+      ones = LA.fromList (replicate n 1)
+  in LA.fromColumns
+       [ ones
+       , d, e, t
+       , d * e, d * t, e * t
+       , d * d, e * e, t * t
+       ]
+
+quadTermLabels :: [String]
+quadTermLabels =
+  [ "intercept"
+  , "dose", "energy", "tilt"
+  , "dose*energy", "dose*tilt", "energy*tilt"
+  , "dose^2", "energy^2", "tilt^2"
+  ]
+
+-- ===========================================================================
+-- main
+-- ===========================================================================
+
+main :: IO ()
+main = do
+  let bar = replicate 75 '='
+  putStrLn bar
+  putStrLn "  CMOS Image Sensor PD implant workflow demo"
+  putStrLn "  3 因子 (dose / energy / tilt 離散) × 25 runs (23 + 2 center)"
+  putStrLn bar
+  putStrLn ""
+
+  -- ── 1. Custom Design I-optimal 23 runs ──
+  putStrLn "[1] Custom Design I-optimal で 23 runs を生成中 ..."
+  let spec = DC.CustomDesignSpec
+        { DC.cdsFactors      = factors
+        , DC.cdsModel        = quadModel
+        , DC.cdsConstraints  = []
+        , DC.cdsNRuns        = 23
+        , DC.cdsCriterion    = DO.IOpt
+        , DC.cdsBudget       = DC.defaultBudget
+        , DC.cdsSeed         = Just 20260530
+        , DC.cdsInitial      = Nothing
+        , DC.cdsDJConvention = False
+        }
+  eDesign <- DC.coordinateExchange spec
+  case eDesign of
+    Left err -> putStrLn ("  FAIL: " ++ T.unpack err)
+    Right cd -> do
+      let base    = DC.cdMatrix cd
+          report  = DC.cdReport cd
+      printf "  ✓ runs=%d, restarts=%d, conv=%s, crit=%.6g\n"
+        (LA.rows base) (DC.crRestarts report)
+        (show (DC.crConverged report)) (DC.crCriterionValue report)
+      putStrLn ""
+
+      -- ── 2. AddCenter 2 で 25 runs に ──
+      putStrLn "[2] AddCenter 2 で強制中心 2 行を追加 → 計 25 runs"
+      let specWithBase = spec { DC.cdsInitial = Just base }
+      eAug <- DA.augmentMenu specWithBase (DA.AddCenter 2)
+      case eAug of
+        Left err  -> putStrLn ("  FAIL: " ++ T.unpack err)
+        Right amr -> do
+          let full = DA.amrMatrix amr
+          printf "  ✓ 最終 runs=%d (= %d + center %d)\n"
+            (LA.rows full) (LA.rows base) (DA.amrAdded amr)
+          putStrLn ""
+
+          -- ── 3. 合成 Sim で応答取得 ──
+          putStrLn "[3] 合成 Sim による応答取得 (defect / fwc / dark)"
+          let n = LA.rows full
+              triples = [ syntheticResp (rowToFactors full i)
+                        | i <- [0..n-1] ]
+              defects = [ d | (d, _, _) <- triples ]
+              fwcs    = [ f | (_, f, _) <- triples ]
+              darks   = [ k | (_, _, k) <- triples ]
+          printf "  defect: min=%d  max=%d  mean=%.0f\n"
+            (minimum defects) (maximum defects)
+            (fromIntegral (sum defects) / fromIntegral n :: Double)
+          printf "  fwc:    min=%.0f  max=%.0f  mean=%.1f\n"
+            (minimum fwcs) (maximum fwcs) (sum fwcs / fromIntegral n)
+          printf "  dark:   min=%.3g  max=%.3g  mean=%.3g\n"
+            (minimum darks) (maximum darks) (sum darks / fromIntegral n)
+          putStrLn ""
+
+          -- ── 4. 解析 ──
+          let xCoded    = toCodedMatrix full
+              xQuad     = buildQuadDesign xCoded
+              yDefect   = LA.fromList (map fromIntegral defects)
+              yFwc      = LA.fromList fwcs
+              yLogDark  = LA.fromList (map log darks)
+
+          -- 4a. defect: Poisson GLM (LogLink)
+          putStrLn "[4a] defect → Poisson GLM (LogLink)"
+          let (glmRes, _glmCov) = GLM.fitGLMFull GLM.Poisson GLM.Log xQuad yDefect
+          printFitCoefs quadTermLabels glmRes
+          putStrLn ""
+
+          -- 4b. fwc: 二次 RSM
+          putStrLn "[4b] fwc → 二次 RSM (canonical analysis)"
+          let qFit = RSM.fitQuadratic (LA.toLists xCoded) (LA.toList yFwc)
+              (xStar, yStar, eigs) = RSM.optimumPoint qFit
+          printf "  推定極値座標 (coded): %s\n" (show xStar)
+          printf "  そこでの fwc: %.3g e-\n" yStar
+          printf "  eigenvalues: %s\n" (show eigs)
+          let nearZero = any (\v -> abs v < 1e-6) eigs
+          if nearZero
+            then putStrLn "  (注: 1 つの eigenvalue が ~0 → quadratic に効かない\n\
+                          \   軸あり。 fwc 合成式が dose のみ quadratic、 energy/tilt\n\
+                          \   は線形であることを canonical analysis が正しく示している)"
+            else pure ()
+          let curvature :: String
+              curvature
+                | all (> 0) eigs = "局所極小 (応答最小)"
+                | all (< 0) eigs = "局所極大 (応答最大)"
+                | otherwise      = "鞍点 (mixed sign)"
+          printf "  → %s\n" curvature
+          putStrLn ""
+
+          -- 4c. dark: log-LM (log-scale 応答に対する線形モデル)
+          putStrLn "[4c] dark (log-scale) → LM"
+          let lmFit = LM.fitLMVec xQuad yLogDark
+          printFitCoefs quadTermLabels lmFit
+          putStrLn ""
+
+          -- ── 5. Desirability で多目的統合スコア ──
+          putStrLn "[5] Desirability で多目的統合スコア"
+          --   defect: minimize、 上限 50000、 目標 10000
+          --   fwc:    maximize、 下限 10000、 目標 14000
+          --   dark:   minimize、 上限 5.0、 目標 0.5
+          -- 閾値は実データ範囲を踏まえ動的に設定 (デモ用)
+          let defMin = fromIntegral (minimum defects) :: Double
+              defMax = fromIntegral (maximum defects) :: Double
+              fwcMin = minimum fwcs
+              fwcMax = maximum fwcs
+              darkMin = minimum darks
+              darkMax = maximum darks
+              dTypes =
+                [ Des.Minimize defMax  defMin
+                , Des.Maximize fwcMin  fwcMax
+                , Des.Minimize darkMax darkMin
+                ]
+              scorePerRun =
+                [ Des.overallDesirability dTypes
+                    [ fromIntegral (defects !! i)
+                    , fwcs !! i
+                    , darks !! i
+                    ]
+                | i <- [0..n-1]
+                ]
+              bestIdx = argmax scorePerRun
+              bestRow = rowToFactors full bestIdx
+          printf "  best run idx = %d (score = %.4f)\n"
+            bestIdx (scorePerRun !! bestIdx)
+          let (bdC, beC, btR) = bestRow
+          printf "  best 条件 (raw):  dose=%.2e  energy=%.2f keV  tilt=%.1f deg\n"
+            (rawDose bdC) (rawEnergy beC) btR
+          printf "  best 条件 (coded): dose=%+.3f  energy=%+.3f  tilt(raw)=%.1f\n"
+            bdC beC btR
+          let (bd, bf, bk) = syntheticResp bestRow
+          printf "  best 応答: defect=%d  fwc=%.0f  dark=%.3g\n" bd bf bk
+          putStrLn ""
+
+          -- ── 6. LiNGAM 因果探索 (3 応答間の因果構造を観測データから推定) ──
+          putStrLn "[6] LiNGAM 因果探索 (defect / fwc / dark 間の因果構造)"
+          -- 3 応答を縦に並べた n × 3 行列を組む。 dark は log-scale。
+          let respMat = LA.fromColumns
+                [ LA.fromList (map fromIntegral defects)
+                , yFwc
+                , yLogDark
+                ]
+              lingamFit = LNG.fitDirectLiNGAM LNG.defaultDirectLiNGAMConfig respMat
+              respLabels = V.fromList
+                [ T.pack "defect", T.pack "fwc", T.pack "log_dark" ]
+              dag = DAG.withNames respLabels
+                      (LNG.dlDAG LNG.defaultDirectLiNGAMConfig lingamFit)
+          printf "  causal order: %s\n" (show (LNG.dlOrder lingamFit))
+          putStrLn "  推定 B 行列 (係数):"
+          let b = LNG.dlB lingamFit
+              rows = [ (i, j, LA.atIndex b (i, j))
+                     | i <- [0..2], j <- [0..2], i /= j
+                     , abs (LA.atIndex b (i, j)) > 0.05 ]
+          mapM_ (\(i, j, w) ->
+                  printf "    %s ← %s (%+.3f)\n"
+                    (T.unpack (DAG.dagNodeName dag i))
+                    (T.unpack (DAG.dagNodeName dag j))
+                    w) rows
+          putStrLn ""
+          printf "  DAG acyclic? %s\n" (show (DAG.isAcyclic dag))
+          printf "  topological sort: %s\n"
+            (case DAG.topoSort dag of
+               Just ord -> show ord ++ " ("
+                          ++ unwords [T.unpack (DAG.dagNodeName dag i) | i <- ord]
+                          ++ ")"
+               Nothing  -> "(循環あり)")
+          putStrLn ""
+
+          -- ── 7. DOT エクスポート (Graphviz で可視化) ──
+          putStrLn "[7] DOT エクスポート"
+          createDirectoryIfMissing True "demo-output"
+          let dotPath = "demo-output/cis-implant-dag.dot"
+              dotText = DAG.toDOT dag
+          TIO.writeFile dotPath dotText
+          printf "  → %s に出力 (graphviz: dot -Tpng %s -o dag.png)\n"
+            dotPath dotPath
+          putStrLn ""
+
+          putStrLn bar
+          putStrLn "  CIS implant workflow demo 完了"
+          putStrLn bar
+
+-- | 係数ベクトルを項ラベル付きで表示。 単一応答 FitResult (q=1) を仮定し
+--   coefficients の 1 列目を取り出す。
+printFitCoefs :: [String] -> Core.FitResult -> IO ()
+printFitCoefs labels res = do
+  let !beta = Core.coefficients res
+      cs    = if LA.cols beta > 0
+                then LA.toList (LA.flatten (beta LA.¿ [0]))
+                else []
+  mapM_ (\(lbl, c) -> printf "  %-14s %+12.4g\n" lbl c)
+        (zip labels cs)
+
+argmax :: Ord a => [a] -> Int
+argmax xs = snd $ foldr1 (\a b -> if fst a >= fst b then a else b)
+                         (zip xs [0..])
diff --git a/demo/doe/ImplantSequentialDemo.hs b/demo/doe/ImplantSequentialDemo.hs
new file mode 100644
--- /dev/null
+++ b/demo/doe/ImplantSequentialDemo.hs
@@ -0,0 +1,365 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE PartialTypeSignatures #-}
+{-# OPTIONS_GHC -Wno-partial-type-signatures #-}
+-- | 逐次 DOE デモ: 半導体インプラ条件の最適化 (30 因子スクリーニング → 試作 RSM)。
+--
+-- ストーリー (実務の逐次 DOE を再現):
+--   * 動かせる因子 = 10 インプラ工程 × { dose (連続) / energy (連続) / tilt (2 水準) }
+--     = **30 因子**。 前タイプ recipe (= ref・中心条件) では新タイプの **spec 未達**。
+--   * 真の応答はスパース: 10 工程中 **3 工程だけ活性**、 各活性工程で dose×energy /
+--     dose×tilt の **within-implant 交互作用** (= 物理的交絡) が強い。 残り 7 工程は inert。
+--   * **Phase 1 (sim スクリーニング)**: 30 因子は D-最適座標交換だと 1 設計 ~160 秒で非現実的
+--     (実測)。 → **DSD (Definitive Screening Design・61 run)** を使う。 61×31 の主効果
+--     モデル行列は rank 31・条件数 2.86 = ほぼ直交で主効果を疎に同定できる (実測)。
+--     → 効果 Pareto で活性工程を絞り込む。
+--   * **Phase 2 (試作 RSM)**: 絞り込んだ支配 2 工程の dose/energy = 4 因子で 2 次応答曲面。
+--     4 lot = 4 block・各 lot に **センター 2 枚必須** (runsheet に付加)。 D-最適 (座標交換・
+--     小因子なら数秒) で設計 → RSM fit → 停留点で最適条件 → **spec 達成**を数値で示す。
+--
+-- 出力図 (demo-output/doe/・git ignore):
+--   * implant-screening-pareto.svg  — Phase 1 効果 Pareto (どの工程が効くか)
+--   * implant-rsm-contour.svg        — Phase 2 応答曲面 contour + ref/最適点
+--   * implant-rsm-profiler.svg       — Phase 2 予測プロファイラ (絞り込み因子・CI 帯)
+module Main where
+
+import qualified Numeric.LinearAlgebra as LA
+import qualified Data.Text as T
+import           Data.Text (Text)
+import           Data.List (sortBy, nub)
+import           Data.Ord (comparing, Down (..))
+import           Text.Printf (printf)
+import           System.Directory (createDirectoryIfMissing)
+
+import           Hanalyze.Plot
+                   ( customDesign, customSpec, contFactor, quadratic, blocked
+                   , designTable, designModel, profiler, contourOf
+                   , rsmAnalysis, RSMReport (..), RSMNature (..)
+                   , Design (..), toPlot, (|->) )
+import           Hanalyze.Design.Workflow (CustomSpec (..))
+import           Hanalyze.Design.DSD (dsdDesign, DSDResult (..))
+import           Hgg.Plot.Spec
+                   ( ColData (..), layer, bar, scatterPoints, Point2 (..)
+                   , inline, inlineCat, colorBy, color
+                   , title, subtitle, xLabel, yLabel, width, height
+                   , xAxis, axisRotate
+                   , theme, ThemeName (..) )
+import           Hgg.Plot.Frame ((|>>))
+import           Hgg.Plot.Color (fromHex)
+import           Hgg.Plot.Backend.SVG (saveSVGBound)
+
+-- ===========================================================================
+-- Phase 0: 因子・真の応答 (ground truth)・ref・spec
+-- ===========================================================================
+
+nImplant :: Int
+nImplant = 10
+
+-- 因子 index j (0..29): implant = j `div` 3、 role = j `mod` 3 (0=dose,1=energy,2=tilt)。
+implantOf, roleOf :: Int -> Int
+implantOf j = j `div` 3
+roleOf    j = j `mod` 3
+
+-- 因子の自然単位レンジ。 dose = ions/cm²、 energy = keV、 tilt = 2 水準 {0,7} deg。
+factorRange :: Int -> (Double, Double)
+factorRange j = case roleOf j of
+  0 -> (1.0e14, 5.0e14)   -- dose
+  1 -> (10, 100)          -- energy (keV)
+  _ -> (0, 7)             -- tilt (deg・2 水準)
+
+-- coded c∈[-1,1] → 自然単位 (中心 0 が範囲中点)。
+toNat :: Int -> Double -> Double
+toNat j c = let (lo, hi) = factorRange j in lo + (c + 1) / 2 * (hi - lo)
+
+-- 因子名 (compact ラベル): "I3 dose" 等 (implant は 1-based 表示)。
+roleName :: Int -> Text
+roleName j = case roleOf j of { 0 -> "dose"; 1 -> "energy"; _ -> "tilt" }
+
+factorLabel :: Int -> Text
+factorLabel j = "I" <> T.pack (show (implantOf j + 1)) <> " " <> roleName j
+
+-- formula / 因子名で使う安全な変数名 (空白なし): "I3_dose"。
+factorVar :: Int -> Text
+factorVar j = "I" <> T.pack (show (implantOf j + 1)) <> "_" <> roleName j
+
+-- --- スパースな真の応答 (性能指数 P、 高いほど良い) --------------------------
+-- 活性工程 = implant index {2, 5, 8} (= 1-based の 3・6・9)。 各活性工程で
+-- dose/energy 主効果 + within-implant 2FI (dose×energy, dose×tilt) + 負の 2 次
+-- (⇒ 内点に最大)。 tilt は小さな主効果。 他 7 工程は inert (係数 0)。
+
+-- (implant index, bDose, bEnergy, bTilt, bDoseEnergy, bDoseTilt, qDose, qEnergy)
+activeImplants :: [(Int, Double, Double, Double, Double, Double, Double, Double)]
+activeImplants =
+  [ (2, 8.0, 6.0, 1.5, 3.0, 2.0, 8.0, 6.0)   -- 支配工程 (I3)
+  , (5, 5.0, 4.0, 1.0, 2.0, 1.0, 5.0, 4.0)   -- 中位工程 (I6)
+  , (8, 3.0, 2.5, 0.5, 1.0, 0.0, 2.5, 2.0)   -- 弱い工程 (I9)
+  ]
+
+baseP :: Double
+baseP = 50.0
+
+-- 真の応答 g(coded 30 次元)。 coded 値を直接使う (screening 係数と同単位)。
+gCoded :: [Double] -> Double
+gCoded x = baseP + sum (map contrib activeImplants)
+  where
+    contrib (i, bd, be, bt, bde, bdt, qd, qe) =
+      let xd = x !! (3*i);  xe = x !! (3*i + 1);  xt = x !! (3*i + 2)
+      in bd*xd + be*xe + bt*xt + bde*xd*xe + bdt*xd*xt - qd*xd*xd - qe*xe*xe
+
+-- ref 条件 = 前タイプ recipe = 全因子 coded 0 (中心条件)。
+refCoded :: [Double]
+refCoded = replicate (3 * nImplant) 0
+
+-- spec: 性能指数 P >= specP。 ref (= baseP=50) は未達。
+specP :: Double
+specP = 60.0
+
+-- ===========================================================================
+-- 決定的 PRNG → Gaussian (Box-Muller) — RSMSampleSizeDemo と同型
+-- ===========================================================================
+
+lcg :: Int -> Int
+lcg s = (1103515245 * s + 12345) `mod` 2147483648
+
+u01 :: Int -> Double
+u01 s = fromIntegral s / 2147483648
+
+gaussians :: Int -> [Double]
+gaussians seed = go (lcg seed)
+  where
+    go s = let s1 = lcg s; s2 = lcg s1
+               u1 = max 1e-12 (u01 s1); u2 = u01 s2
+           in sqrt (-2 * log u1) * cos (2 * pi * u2) : go s2
+
+noiseSd :: Double
+noiseSd = 0.8
+
+-- OLS: beta = pinv(X) y。
+ols :: [[Double]] -> [Double] -> [Double]
+ols xs ys = LA.toList (LA.flatten (LA.pinv (LA.fromLists xs) LA.<> LA.asColumn (LA.fromList ys)))
+
+-- ===========================================================================
+-- Phase 1: DSD スクリーニング (61 run)
+-- ===========================================================================
+
+-- 効果 (因子 index, |係数|, 係数) を降順で返す + DSD 情報。
+screening :: (Int, Int, Bool, [(Int, Double, Double)])
+screening =
+  let dsd       = either (error . T.unpack) id (dsdDesign (3 * nImplant))
+      rows      = LA.toLists (dsdMatrix dsd)                 -- 61 × 30 (coded {-1,0,1})
+      ys        = [ gCoded r + noiseSd * z
+                  | (r, z) <- zip rows (gaussians 83001) ]
+      xs        = [ 1 : r | r <- rows ]                      -- 主効果モデル [1 | 30]
+      beta      = ols xs ys
+      effects   = [ (j, abs (beta !! (j+1)), beta !! (j+1)) | j <- [0 .. 3*nImplant - 1] ]
+      ranked    = sortBy (comparing (Down . (\(_,a,_) -> a))) effects
+  in (dsdNRuns dsd, LA.rank (LA.fromLists xs), dsdHasOptimal dsd, ranked)
+
+-- 活性因子 index の集合 (ground truth 由来・図の色分け用)。
+activeFactorIdxs :: [Int]
+activeFactorIdxs =
+  concat [ [3*i, 3*i+1, 3*i+2] | (i,_,_,_,_,_,_,_) <- activeImplants ]
+
+-- Phase 1 図: 効果 Pareto (top 15・活性/inert 色分け)。
+screeningFigure :: [(Int, Double, Double)] -> IO ()
+screeningFigure ranked = do
+  let topN   = 15
+      top    = take topN ranked
+      -- bar の categorical 軸はラベルをアルファベット順に並べる (hgg に
+      -- xCatOrder 未実装)。 Pareto は降順必須ゆえ、 順位をゼロ埋め前置して
+      -- アルファベット順 = |効果| 降順に一致させる ("01 I3 dose" 等)。
+      labels = [ T.pack (printf "%02d " k) <> factorLabel j
+               | (k, (j,_,_)) <- zip [1 :: Int ..] top ]
+      vals   = [ a | (_,a,_) <- top ]
+      cats   = [ if j `elem` activeFactorIdxs then "活性 (真に効く)" else "inert (ノイズ)"
+               | (j,_,_) <- top ] :: [Text]
+      spec'  = layer ( bar (inlineCat labels) (inline vals)
+                     <> colorBy (inlineCat cats) )
+                <> title    "Phase 1: 効果 Pareto (DSD 61 run で 30 因子スクリーニング)"
+                <> subtitle "|主効果係数| 降順 top 15。 活性 3 工程の dose/energy が上位に立つ"
+                <> xLabel   "因子 (I<工程> <パラメタ>)"
+                <> yLabel   "|効果| (coded 単位)"
+                <> xAxis (axisRotate 90)     -- 横軸ラベルを y 軸タイトルと同じ向き (CCW 90°・下→上読み)
+                <> width 720 <> height 460
+                <> theme ThemeGrey
+      noDf   = [] :: [(Text, ColData)]
+  createDirectoryIfMissing True "demo-output/doe"
+  saveSVGBound "demo-output/doe/implant-screening-pareto.svg" (noDf |>> spec')
+  putStrLn "  → wrote demo-output/doe/implant-screening-pareto.svg"
+
+-- ===========================================================================
+-- Phase 2: 試作 RSM (blocked 4 lot・center 2/lot・D-最適)
+-- ===========================================================================
+
+-- coded → 自然単位の逆 (自然 → coded)。 stationary(自然) を coded に戻し true P を評価。
+toCoded :: Int -> Double -> Double
+toCoded j nat = let (lo, hi) = factorRange j in 2 * (nat - lo) / (hi - lo) - 1
+
+nLot :: Int
+nLot = 4
+
+centerPerLot :: Int
+centerPerLot = 2
+
+-- screening から RSM に carry する因子を導出:
+--   * dose/energy で |効果| > 2.0 の工程 = 「支配工程」 → その dose/energy を carry。
+-- 非 carry で |効果| > 0.35 の因子 (= 活性 tilt) は screening の符号方向に固定 (背景条件)。
+selectedImplants :: [(Int, Double, Double)] -> [Int]
+selectedImplants ranked =
+  nub [ implantOf j | (j, a, _) <- ranked, roleOf j /= 2, a > 2.0 ]
+
+carriedIdxs :: [(Int, Double, Double)] -> [Int]
+carriedIdxs ranked =
+  concat [ [3*i, 3*i + 1] | i <- selectedImplants ranked ]  -- 各支配工程の dose, energy
+
+-- 背景 coded (非 carry 因子): 活性 tilt 等は screening 符号方向に固定、 他は ref(0)。
+backgroundCoded :: [(Int, Double, Double)] -> [Int] -> [Double]
+backgroundCoded ranked carried =
+  [ bgAt j | j <- [0 .. 3*nImplant - 1] ]
+  where
+    effOf j = head ([ c | (k, _, c) <- ranked, k == j ] ++ [0])
+    bgAt j | j `elem` carried        = 0                       -- carry は design が上書き
+           | abs (effOf j) > 0.35    = signum (effOf j)        -- 有意非 carry (tilt) を固定
+           | otherwise               = 0                       -- inert は ref
+
+-- Phase 2 本体。 screening ranked を受け、 設計 → sim → RSM fit → 最適条件を返す。
+data RSMOut = RSMOut
+  { roCarried   :: ![Int]              -- carry した因子 index
+  , roNFree     :: !Int                -- 自由 run 数 (block D-最適)
+  , roNCenter   :: !Int                -- center 数 (2/lot × 4)
+  , roReport    :: !RSMReport          -- rsmAnalysis 結果
+  , roTruePOpt  :: !Double             -- 見つけた最適条件での「真の」P
+  , roNames     :: ![Text]             -- carry 因子の safe 変数名
+  }
+
+runPhase2 :: [(Int, Double, Double)] -> (RSMOut, IO ())
+runPhase2 ranked =
+  let carried  = carriedIdxs ranked
+      names    = map factorVar carried
+      facs     = [ contFactor (factorVar j) (factorRange j) | j <- carried ]
+      nFree    = 44
+      plan     = customDesign ((customSpec facs (quadratic names) nFree 83002)
+                                 { csStructure = blocked nLot })
+      -- 設計の coded 行 (自由 run) + center (2/lot = 8 行・全 0)。
+      codedFree   = dsCoded plan
+      nCenter     = nLot * centerPerLot
+      codedCenter = replicate nCenter (replicate (length carried) 0)
+      codedAug    = codedFree ++ codedCenter
+      -- 自然単位 runsheet (図の data frame 用)。
+      tbl      = designTable plan
+      colOf c  = maybe (error (T.unpack c)) id (lookup c tbl)
+      mids     = [ toNat j 0 | j <- carried ]                     -- center の自然値 = 中点
+      rowsAug  = [ (nm, colOf nm ++ replicate nCenter mid)
+                 | (nm, mid) <- zip names mids ]
+      -- sim 応答 (真の応答 + noise)。 coded 行を full-30 に埋め込み。
+      bg       = backgroundCoded ranked carried
+      idxPos   = zip carried [0 :: Int ..]
+      embed r6 = [ maybe (bg !! j) (r6 !!) (lookup j idxPos) | j <- [0 .. 3*nImplant - 1] ]
+      ysAug    = [ gCoded (embed r6) + noiseSd * z
+                 | (r6, z) <- zip codedAug (gaussians 83003) ]
+      -- RSM 解析 (coded augmented を持つ Design で停留点を自然単位へ)。
+      report   = rsmAnalysis (plan { dsCoded = codedAug }) ysAug
+      -- 見つけた最適条件 (自然) を coded に戻し、 背景に埋め込んで「真の」P を評価。
+      optCoded = [ maybe 0 (\pos -> toCoded (carried !! pos)
+                                       (maybe 0 id (lookup (factorVar (carried !! pos))
+                                                           (rsmStationary report))))
+                          (lookup j idxPos)
+                 | j <- [0 .. 3*nImplant - 1] ]
+      -- 背景 + carry 最適 を合成した full coded で真の P。
+      optFull  = [ if j `elem` carried then optCoded !! j else bg !! j
+                 | j <- [0 .. 3*nImplant - 1] ]
+      truePOpt = gCoded optFull
+      -- 図 (contour + profiler)。 designModel は data frame で fit (plan は formula のみ)。
+      model    = (("P", ysAug) : rowsAug) |-> designModel plan "P"
+      figs     = do
+        createDirectoryIfMissing True "demo-output/doe"
+        let noDf = [] :: [(Text, ColData)]
+            -- 支配 2 因子 (I3 dose × I3 energy) で contour。
+            v1 = names !! 0; v2 = names !! 1
+            refD = toNat (carried !! 0) 0; refE = toNat (carried !! 1) 0
+            optD = maybe refD id (lookup v1 (rsmStationary report))
+            optE = maybe refE id (lookup v2 (rsmStationary report))
+            contourSpec =
+              (noDf |>> ( contourOf model v1 v2
+                          <> layer (scatterPoints [Point2 refD refE] <> color (fromHex "#333333"))
+                          <> layer (scatterPoints [Point2 optD optE] <> color (fromHex "#d73027"))
+                          <> title    "Phase 2: 応答曲面 contour (支配工程 I3 dose × energy)"
+                          <> subtitle "灰=ref(中心・spec未達) / 赤=RSM 最適点。 他因子は中央値固定"
+                          <> xLabel (factorLabel (carried !! 0))
+                          <> yLabel (factorLabel (carried !! 1))
+                          <> theme ThemeGrey ))
+            profSpec =
+              (noDf |>> ( toPlot (profiler [("P", model)] names)
+                          <> title "Phase 2: 予測プロファイラ (絞り込み 6 因子・95% CI 帯)"
+                          <> width 900 <> height 360
+                          <> theme ThemeGrey ))
+        saveSVGBound "demo-output/doe/implant-rsm-contour.svg" contourSpec
+        putStrLn "  → wrote demo-output/doe/implant-rsm-contour.svg"
+        saveSVGBound "demo-output/doe/implant-rsm-profiler.svg" profSpec
+        putStrLn "  → wrote demo-output/doe/implant-rsm-profiler.svg"
+  in ( RSMOut carried nFree nCenter report truePOpt names
+     , figs )
+
+-- ===========================================================================
+-- main (Phase 0 + Phase 1 + Phase 2)
+-- ===========================================================================
+
+main :: IO ()
+main = do
+  putStrLn "═══════════════════════════════════════════════════════════════"
+  putStrLn "  逐次 DOE デモ: 半導体インプラ条件最適化 (30 因子 → 試作 RSM)"
+  putStrLn "═══════════════════════════════════════════════════════════════"
+  printf "  因子: 10 工程 × {dose, energy, tilt} = %d 因子\n" (3 * nImplant)
+  printf "  真の活性工程 = I3 / I6 / I9 (残り 7 工程は inert)\n"
+  printf "  ref (中心条件) の性能指数 P = %.1f、 spec = P >= %.1f → ref は未達\n\n"
+         (gCoded refCoded) specP
+
+  putStrLn "── Phase 1: DSD スクリーニング ──────────────────────────────"
+  let (nRun, rnk, hasOpt, ranked) = screening
+  printf "  DSD: %d run・主効果モデル行列 rank = %d (= 31 なら全主効果推定可)・%s\n"
+         nRun rnk (if hasOpt then "verified" else "structural 近似" :: String)
+  putStrLn "  効果 Pareto (top 10):"
+  printf "  %-10s | %8s | %s\n" ("因子" :: String) ("|効果|" :: String) ("活性?" :: String)
+  putStrLn "  -----------|----------|------"
+  mapM_ (\(j, a, _) ->
+           printf "  %-10s | %8.3f | %s\n" (T.unpack (factorLabel j)) a
+                  (if j `elem` activeFactorIdxs then "●" else "" :: String))
+        (take 10 ranked)
+  putStrLn ""
+  screeningFigure ranked
+
+  putStrLn ""
+  putStrLn "── Phase 2: 試作 RSM (絞り込み → blocked D-最適 → 最適条件) ──"
+  let (out, figs) = runPhase2 ranked
+      sel  = selectedImplants ranked
+      rep  = roReport out
+  printf "  絞り込み: 支配工程 = %s → carry 因子 (dose/energy) = %d 個\n"
+         (unwords [ "I" ++ show (i+1) | i <- sel ]) (length (roCarried out))
+  printf "  試作設計: %d lot × (自由 %d + center %d) = %d 枚 (D-最適・block=lot)\n"
+         nLot (roNFree out `div` nLot) centerPerLot
+         (roNFree out + roNCenter out)
+  printf "  RSM fit: R² = %.3f・停留点の性質 = %s・領域内 = %s\n"
+         (rsmR2 rep)
+         (case rsmNature rep of RMaximum -> "極大"; RMinimum -> "極小"; RSaddle -> "鞍点" :: String)
+         (if rsmInRegion rep then "yes" else "no (外挿)" :: String)
+  putStrLn "  最適条件 (自然単位):"
+  mapM_ (\(nm, v) -> printf "    %-10s = %s\n" (T.unpack nm) (fmtNat nm v))
+        (rsmStationary rep)
+  putStrLn ""
+  printf "  ── ストーリー closure ──────────────────────\n"
+  printf "  ref (前タイプ・中心条件)   P = %6.2f  → spec %.0f %s\n"
+         (gCoded refCoded) specP (verdict (gCoded refCoded))
+  printf "  RSM 予測 (最適条件)         P = %6.2f\n" (rsmPredicted rep)
+  printf "  真の応答 (最適条件で検証)   P = %6.2f  → spec %.0f %s\n"
+         (roTruePOpt out) specP (verdict (roTruePOpt out))
+  putStrLn ""
+  figs
+  putStrLn ""
+  putStrLn "  完了: 3 図を demo-output/doe/ に出力。"
+  where
+    verdict p = if p >= specP then "達成 ✓" else "未達 ✗" :: String
+    -- 自然単位の見やすい整形 (dose は指数、 他は小数)。
+    fmtNat :: Text -> Double -> String
+    fmtNat nm v
+      | "_dose" `T.isSuffixOf` nm = printf "%.2e ions/cm²" v
+      | "_energy" `T.isSuffixOf` nm = printf "%.1f keV" v
+      | otherwise = printf "%.2f" v
diff --git a/demo/doe/RSMSampleSizeDemo.hs b/demo/doe/RSMSampleSizeDemo.hs
new file mode 100644
--- /dev/null
+++ b/demo/doe/RSMSampleSizeDemo.hs
@@ -0,0 +1,199 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE PartialTypeSignatures #-}
+{-# OPTIONS_GHC -Wno-partial-type-signatures #-}
+-- | DOE demo: RSM の予測精度 vs 実験数 (= 必要実験数の見極め)。
+--
+-- 温度 (3 水準・Num)・エネルギー (3 水準・Num)・角度 (連続・Cont) の
+-- 二次応答曲面を対象に:
+--   1. @customDesign@ (二次モデル) で n-run の D-最適設計を作る
+--   2. 既知の真の曲面 + Gaussian noise で応答を sim
+--   3. 二次 OLS を fit
+--   4. 独立テスト集合で 真の曲面 vs 予測 の RMSE を測る (設計シード × noise 反復で平均)
+--   5. n を振り、 hgg で「RMSE vs n」の折れ線 (noise sd 参照線つき) を SVG 出力
+--
+-- 真の曲面には二次モデルで表せない @0.8·angle³@ を混ぜてあり、 n を増やしても
+-- 消えない bias 床を作る (= 実務の「これ以上は実験でなくモデルを増やせ」を再現)。
+module Main where
+
+import qualified Data.Vector as V
+import qualified Numeric.LinearAlgebra as LA
+import qualified Data.Text as T
+import           Data.Text (Text)
+import           Text.Printf (printf)
+import           System.Directory (createDirectoryIfMissing)
+
+import           Hanalyze.Plot
+                   ( customDesign, customSpec, numFactor, contFactor, quadratic, designFrame
+                   , designTable, designModel, profiler, toPlot, (|->) )
+import           Hanalyze.DataIO.Convert (getDoubleVec)
+import           Hgg.Plot.Spec
+                   ( ColData (..), layer, linePoints, scatterPoints, Point2 (..)
+                   , color, markWidth
+                   , refHorizontal, title, subtitle, xLabel, yLabel, width, height
+                   , theme, ThemeName (..) )
+import           Hgg.Plot.Frame ((|>>))
+import           Hgg.Plot.Color (fromHex)
+import           Hgg.Plot.Backend.SVG (saveSVG, saveSVGBound)
+
+-- === 真の応答曲面とモデル ==================================================
+
+-- 正規化座標 (各 ~[-1,1])。
+ut, ue, ua :: Double -> Double
+ut t = (t - 165) / 15
+ue e = (e - 20) / 10
+ua a = (a - 45) / 45
+
+-- 真の曲面 = 二次 + 0.8·angle³ (二次モデルでは捉えられない bias 源)。
+gTrue :: Double -> Double -> Double -> Double
+gTrue t e a =
+  let x = ut t; y = ue e; z = ua a
+  in 50 + 8*x + 5*y + 6*z - 4*x*x - 3*y*y - 5*z*z + 2*x*y + 1.5*x*z - 1*y*z
+       + 0.8*z*z*z
+
+-- 二次モデルの特徴ベクトル (10 項: 切片 + 主 3 + 二乗 3 + 交互 3)。
+feat :: Double -> Double -> Double -> [Double]
+feat t e a = let x = ut t; y = ue e; z = ua a
+             in [1, x, y, z, x*x, y*y, z*z, x*y, x*z, y*z]
+
+noiseSd :: Double
+noiseSd = 1.5
+
+-- === 決定的 PRNG → Gaussian (Box-Muller) ==================================
+
+lcg :: Int -> Int
+lcg s = (1103515245 * s + 12345) `mod` 2147483648
+
+u01 :: Int -> Double
+u01 s = fromIntegral s / 2147483648
+
+gaussians :: Int -> [Double]
+gaussians seed = go (lcg seed)
+  where
+    go s = let s1 = lcg s; s2 = lcg s1
+               u1 = max 1e-12 (u01 s1); u2 = u01 s2
+           in sqrt (-2 * log u1) * cos (2 * pi * u2) : go s2
+
+-- === 設計・fit・評価 ======================================================
+
+-- 温度/エネルギー = 3 水準 Num、 角度 = 連続。 二次モデルで D-最適設計。
+design n dseed = customDesign (customSpec
+  [ numFactor "temp"   [150, 165, 180]
+  , numFactor "energy" [10, 20, 30]
+  , contFactor "angle" (0, 90) ]
+  (quadratic ["temp", "energy", "angle"]) n dseed)
+
+-- 設計 (designFrame) から (temp, energy, angle) 実値の行を取り出す。
+rowsOf :: Int -> Int -> [(Double, Double, Double)]
+rowsOf n dseed =
+  let df = designFrame (design n dseed)
+      col :: Text -> [Double]
+      col c = maybe (error (T.unpack c)) V.toList (getDoubleVec c df)
+  in zip3 (col "temp") (col "energy") (col "angle")
+
+-- 固定・独立なテスト集合 (2000 点): 離散 temp/energy + 連続 angle。
+testSet :: [(Double, Double, Double)]
+testSet = take 2000
+  [ ([150,165,180] !! i, [10,20,30] !! j, 90 * u01 (lcg (lcg (7919*k + 13))))
+  | (i, j, k) <- zip3 (cycle [0,1,2,1,0,2,2,0,1]) (cycle [1,2,0,2,1,0,1,2,0]) [1 ..] ]
+
+-- OLS: beta = pinv(X) y。
+ols :: [[Double]] -> [Double] -> [Double]
+ols xs ys = LA.toList (LA.flatten (LA.pinv (LA.fromLists xs) LA.<> LA.asColumn (LA.fromList ys)))
+
+-- 1 fit (設計シード dseed・noise 反復 rep) の テスト RMSE。
+rmseFor :: Int -> Int -> Int -> Double
+rmseFor n dseed rep =
+  let rws  = rowsOf n dseed
+      xs   = [ feat t e a | (t, e, a) <- rws ]
+      ys   = [ gTrue t e a + noiseSd * z
+             | ((t, e, a), z) <- zip rws (gaussians (n*1000 + dseed*37 + rep)) ]
+      beta = ols xs ys
+      errs = [ sum (zipWith (*) (feat t e a) beta) - gTrue t e a | (t, e, a) <- testSet ]
+  in sqrt (sum (map (^ (2 :: Int)) errs) / fromIntegral (length errs))
+
+-- 各 n を 6 設計シード × 6 noise 反復 = 36 fit で平均。
+meanRmse :: Int -> Double
+meanRmse n = sum [ rmseFor n ds rp | ds <- [1..6], rp <- [1..6] ] / 36
+
+-- === 効果プロット (CI 帯) : n による信頼区間の変化 =======================
+
+-- 応答 y を design frame に載せて二次モデルを当てはめ、 profiler 用モデルを返す。
+--   @(("y", ys) : designTable plan) |-> designModel plan "y"@ が慣用形。
+--   各 n は別々の設計・データ → 別々のモデル (データはモデル内に同梱される)。
+modelN :: Int -> Int -> _
+modelN n dseed =
+  let plan   = design n dseed
+      rs     = designTable plan
+      colD :: Text -> [Double]
+      colD c = maybe (error (T.unpack c)) id (lookup c rs)
+      ts = colD "temp"; es = colD "energy"; as = colD "angle"
+      ys = [ gTrue t e a + noiseSd * z
+           | (t, e, a, z) <- zip4 ts es as (gaussians (n*1000 + 1)) ]
+  in (("y", ys) : rs) |-> designModel plan "y"
+  where
+    zip4 (a:as') (b:bs) (c:cs) (d:ds) = (a, b, c, d) : zip4 as' bs cs ds
+    zip4 _ _ _ _                      = []
+
+-- 効果プロット図: 横 = 因子・縦 = 応答、 1 行 3 列 (因子) を n ごとに縦積み。
+-- **profiler** が予測線 + 95% CI 帯 + 実測散布を (応答×因子) で自動描画する (JMP 流)。
+-- 行 = models リスト (= n)、 列 = 因子。 各 n は別モデルで、 データはモデル内に同梱
+-- されるため 束ねは空 df でよい。 n=10 は係数10個=飽和 (df=0) だが、 CI 帯計算の
+-- df<=0 ガード (Phase 82・LM.hs) により例外を出さず帯が線に潰れる (= CI 不能を素直に表現)。
+effectGridFigure :: IO ()
+effectGridFigure = do
+  let dseed = 20260708
+      spec  = toPlot (profiler [ ("n=10", modelN 10 dseed)
+                               , ("n=20", modelN 20 dseed)
+                               , ("n=40", modelN 40 dseed) ]
+                               ["temp", "energy", "angle"])
+                <> title "RSM 効果プロット: n で信頼区間がどう変わるか (行=n・列=因子)"
+                <> width 780 <> height 720       -- 3×3 grid (意図的な非既定サイズ)
+                <> theme ThemeGrey
+      noDf = [] :: [(Text, ColData)]             -- profiler のデータはモデル内・束ねは空
+  createDirectoryIfMissing True "demo-output/doe"
+  saveSVGBound "demo-output/doe/rsm-effects-by-n.svg" (noDf |>> spec)
+  putStrLn "  → wrote demo-output/doe/rsm-effects-by-n.svg"
+
+-- === main ================================================================
+
+main :: IO ()
+main = do
+  putStrLn "═══════════════════════════════════════════════════════════════"
+  putStrLn "  RSM 予測精度 vs 実験数 (温度3水準 × エネルギー3水準 × 角度連続)"
+  putStrLn "═══════════════════════════════════════════════════════════════"
+  printf "  真の曲面 = 二次 + 0.8·angle³ (bias 源), noise sd = %.2f\n" noiseSd
+  printf "  各 n を 6 設計シード × 6 noise 反復 = 36 fit で平均、 テスト %d 点\n\n"
+         (length testSet)
+  let ns    = [10, 12, 14, 16, 20, 24, 30, 40, 60, 80]
+      rmses = [ (n, meanRmse n) | n <- ns ]
+  printf "  %4s | %8s | %8s | %s\n" ("n" :: String) ("RMSE" :: String)
+         ("noise比" :: String) ("前点比 改善%" :: String)
+  putStrLn "  -----|----------|----------|-----------"
+  mapM_ (\((n, r), prev) ->
+           let imp = case prev of
+                       Nothing         -> ""
+                       Just (_, pr)    -> printf "%.1f%%" (100 * (pr - r) / pr) :: String
+           in printf "  %4d | %8.4f | %8.2f | %s\n" n r (r / noiseSd) imp)
+        (zip rmses (Nothing : map Just rmses))
+  putStrLn ""
+
+  -- === hgg で RMSE vs n を描画 ===
+  createDirectoryIfMissing True "demo-output/doe"
+  let pts   = [ Point2 (fromIntegral n) r | (n, r) <- rmses ]
+      curve = fromHex "#2c7fb8"
+      spec  = layer (linePoints pts <> color curve <> markWidth 2.5)
+           <> layer (scatterPoints pts <> color curve)
+           <> refHorizontal noiseSd                     -- noise sd の水平参照線
+           <> title    "RSM 予測精度 vs 実験数"
+           <> subtitle "参照線 = noise sd (1 測定の誤差)。 下回れば曲面が生データより正確"
+           <> xLabel   "実験数 n"
+           <> yLabel   "予測 RMSE (真の曲面 vs 予測)"
+           -- サイズは指定せず既定 (468×288pt = 624×384px・README 図と同寸) に合わせる
+           <> theme ThemeGrey
+  saveSVG "demo-output/doe/rsm-samplesize.svg" spec
+  putStrLn "  → wrote demo-output/doe/rsm-samplesize.svg"
+
+  -- === 効果プロット (n=10/20/40 で CI 帯がどう変わるか) ===
+  putStrLn ""
+  putStrLn "  効果プロット (行=n · 列=因子・CI 帯) を生成..."
+  effectGridFigure
diff --git a/demo/io/DirtyDataDemo.hs b/demo/io/DirtyDataDemo.hs
--- a/demo/io/DirtyDataDemo.hs
+++ b/demo/io/DirtyDataDemo.hs
@@ -30,7 +30,8 @@
 import Data.List (sort)
 import Text.Printf (printf)
 
-import qualified DataFrame     as DX
+import qualified DataFrame.Internal.DataFrame  as DX
+import qualified DataFrame.Operations.Core     as DX
 import qualified Hanalyze.DataIO.CSV    as CSV
 import qualified Hanalyze.DataIO.Log    as Log
 
diff --git a/demo/io/ExternalIODemo.hs b/demo/io/ExternalIODemo.hs
--- a/demo/io/ExternalIODemo.hs
+++ b/demo/io/ExternalIODemo.hs
@@ -9,7 +9,8 @@
 
 import Hanalyze.DataIO.CSV         (loadCSV)
 import Hanalyze.DataIO.Preprocess  (countMissing, imputeMean)
-import qualified DataFrame                 as DX
+import qualified DataFrame.Internal.DataFrame  as DX
+import qualified DataFrame.Operations.Core     as DX
 import qualified DataFrame.Internal.DataFrame as DXD
 import qualified DataFrame.Internal.Column as DXC
 import Text.Printf        (printf)
diff --git a/demo/io/PotentialGen.hs b/demo/io/PotentialGen.hs
deleted file mode 100644
--- a/demo/io/PotentialGen.hs
+++ /dev/null
@@ -1,206 +0,0 @@
--- | 半導体イオン注入後の **静電ポテンシャル** プロファイル風ダミーデータ。
--- git 管理外 (確認用)。
---
--- 物理直観 (簡易) — Dose 依存をサブ線形に変更:
---
---   V(z; E, D) = surface(z) - implant(z; E, D)
---
---     surface(z)        = +3.5 · exp(-z / L_surf)               [V]    表面 BC
---     implant(z; E, D)  = K · (D/D_ref)^α · exp(-(z-Rp)²/(2σ²)) [V]    注入井戸
---
---     Rp(E)  = 1.5 · E^0.7  [nm]    投影飛程 (B in Si 近似)
---     σ(E)   = 0.4 · Rp     [nm]
---     L_surf = 30           [nm]
---     K      = 8.0          [V]   D=D_ref で井戸深さ ≒ 8 V
---     D_ref  = 10           [/1e12 cm⁻²]   reference dose (base)
---     α      = 0.26              D 2 倍で y 変化 ~1.20 倍 (= 20% 増)
---
--- Dose 範囲 (ユーザー要望):
---   base = 10 (= 1e13 cm⁻²) を中心に ±20%、5 levels: {8, 9, 10, 11, 12}
---
--- 条件数: E 固定 × 5 doses = 5。各条件 100 z 点 (0..200 nm) 欠損なし。
--- 出力 2 種:
---   data/io/potential_long.csv  (long: name,energy,dose,z,y) — 既存互換
---   data/io/potential_wide.csv  (wide: dose, y_z001, ..., y_z100) — 多出力用
-module Main where
-
-import Text.Printf (printf)
-import System.Random.MWC (createSystemRandom, GenIO, uniformR)
-import qualified System.Random.MWC.Distributions as MWCD
-import Data.List (sort, intercalate)
-import System.Environment (getArgs)
-import Data.IORef (IORef, newIORef, readIORef, writeIORef)
-import System.IO.Unsafe (unsafePerformIO)
-
-unwordsBy :: String -> [String] -> String
-unwordsBy = intercalate
-
--- | E は固定 (中央値)。
-fixedEnergy :: Double
-fixedEnergy = 100  -- keV
-
-energies :: [Double]
-energies = [fixedEnergy]
-
--- | Dose は 1e12 cm⁻² で正規化された値。base=10 (= 1e13)、6.0..14.0 を 21 水準。
-doses :: [Double]
-doses = [ 6.0 + 0.4 * fromIntegral i | i <- [0 .. 20 :: Int] ]
-
-zRange :: (Double, Double)
-zRange = (0, 200)
-
-zPoints :: Int
-zPoints = 100
-
--- | jagged モードで欠損率と jitter 倍率を上げる。
-{-# NOINLINE jaggedRef #-}
-jaggedRef :: IORef Bool
-jaggedRef = unsafePerformIO (newIORef False)
-
-isJagged :: Bool
-isJagged = unsafePerformIO (readIORef jaggedRef)
-
-missRate :: Double
-missRate = if isJagged then 0.20 else 0.0
-
-jitterFactor :: Double
-jitterFactor = if isJagged then 2.5 else 0.3
-
--- 物理係数
-projectedRange :: Double -> Double
-projectedRange e = 1.5 * (e ** 0.7)
-
-straggle :: Double -> Double
-straggle e = 0.4 * projectedRange e
-
-surfaceL :: Double
-surfaceL = 30.0
-
--- D=D_ref でほぼ井戸深さ 8 V になるよう K を設定
-implantK :: Double
-implantK = 8.0
-
--- reference dose (base)。±20% 範囲の中心。
-doseRef :: Double
-doseRef = 10.0
-
--- Dose 依存指数: D が 2 倍 → 振幅が 2^α 倍。α=0.26 なら 1.20 倍。
-doseAlpha :: Double
-doseAlpha = 0.26
-
-surfaceV :: Double -> Double
-surfaceV z = 3.5 * exp (negate z / surfaceL)
-
-implantWell :: Double -> Double -> Double -> Double
-implantWell e d z =
-  let rp = projectedRange e
-      sg = straggle e
-      amp = implantK * ((d / doseRef) ** doseAlpha)
-  in amp * exp (negate ((z - rp) ** 2) / (2 * sg * sg))
-
-potentialAt :: Double -> Double -> Double -> Double
-potentialAt e d z = surfaceV z - implantWell e d z
-
-condName :: Int -> Double -> Double -> String
-condName i e d = printf "c%02d_E%g_D%g" i e d
-
--- | 共通固定 z grid (wide-form を作るため jitter なし)。
-zGrid :: [Double]
-zGrid =
-  let (zlo, zhi) = zRange
-      step = (zhi - zlo) / fromIntegral (zPoints - 1)
-  in [ zlo + fromIntegral i * step | i <- [0 .. zPoints - 1] ]
-
-main :: IO ()
-main = do
-  args <- getArgs
-  let jagged = "--jagged" `elem` args || "jagged" `elem` args
-  writeIORef jaggedRef jagged
-  gen <- createSystemRandom
-  let conds =
-        [ (condName i e d, e, d)
-        | (i, (e, d)) <- zip [1..] [(e, d) | e <- energies, d <- doses]
-        ]
-  -- wide-form 用: 各 dose で同じ z grid 上の y を観測 (ノイズ込)
-  wideMatrix <- mapM (\(_, e, d) ->
-                       mapM (\z -> do
-                                eps <- MWCD.normal 0 0.1 gen
-                                return (potentialAt e d z + eps))
-                            zGrid)
-                     conds
-  let wideHeader =
-        "dose," ++
-        unwordsBy "," [ printf "y_z%03d" (i :: Int) | i <- [1 .. zPoints] ] ++
-        "\n"
-      wideBody =
-        concat
-          [ printf "%g,%s\n" d
-              (unwordsBy "," [ printf "%.4f" v | v <- ys ])
-          | ((_, _, d), ys) <- zip conds wideMatrix
-          ]
-      wideOut = "data/io/potential_wide.csv"
-  writeFile wideOut (wideHeader ++ wideBody)
-  putStrLn $ "Wrote " ++ wideOut ++ "  (" ++ show (length conds)
-             ++ " rows × " ++ show (zPoints + 1) ++ " cols)"
-
-  -- long-form (既存互換 / jagged モードでは別ファイル)
-  rows <- mapM (genRows gen) conds
-  let header = "name,energy,dose,z,y\n"
-      body   = concat rows
-      out    = if jagged then "data/io/potential_long_jagged.csv"
-                         else "data/io/potential_long.csv"
-      keptN  = length (lines body)
-  writeFile out (header ++ body)
-  putStrLn $ "Wrote " ++ out
-  putStrLn $ "Conditions: " ++ show (length conds)
-  putStrLn $ "z grid:     " ++ show zPoints ++ " points spanning "
-             ++ show (fst zRange) ++ ".." ++ show (snd zRange) ++ " nm"
-  putStrLn $ "Total cells: " ++ show (length conds * zPoints)
-  putStrLn $ "After ~" ++ show (round (missRate * 100) :: Int)
-             ++ "% drop:   " ++ show keptN ++ " rows"
-  putStrLn ""
-  putStrLn "Dose 依存の確認 (E=100 keV, base D=10 を中心):"
-  mapM_ (\d -> do
-           let zRp = projectedRange 100
-               vWell = implantK * ((d / doseRef) ** doseAlpha)
-               vAt   = potentialAt 100 d zRp
-           printf "  D=%5.1f  振幅 = %5.2f V  V(Rp) = %+5.2f V\n"
-                  d vWell vAt)
-        doses
-  printf "  → D 2 倍の比較: D=8 → D=16 想定で振幅比 = %.3f (= 2^%.2f)\n"
-         ((16.0 / doseRef) ** doseAlpha
-          / (8.0 / doseRef) ** doseAlpha)
-         doseAlpha
-  putStrLn ""
-  putStrLn "条件サマリ:"
-  mapM_ (\(lbl, e, d) -> do
-           let zs = [0, 1 .. 200]
-               vs = map (potentialAt e d) zs
-               vmin = minimum vs
-               vmax = maximum vs
-           printf "  %-15s E=%5.0f  D=%5.1f  Rp=%6.1f  σ=%5.1f  V∈[%+5.2f,%+5.2f]\n"
-             lbl e d (projectedRange e) (straggle e) vmin vmax)
-        conds
-
-genRows :: GenIO -> (String, Double, Double) -> IO String
-genRows gen (label, e, d) = do
-  let (zlo, zhi) = zRange
-      baseStep = (zhi - zlo) / fromIntegral (zPoints - 1)
-      jitter   = baseStep * jitterFactor
-  zsRaw <- mapM (\i -> do
-                    let zBase = zlo + fromIntegral i * baseStep
-                    j <- uniformR (-jitter, jitter) gen
-                    return (max zlo (min zhi (zBase + j))))
-                [0 :: Int .. zPoints - 1]
-  let zsSorted = sort zsRaw
-  fmap concat $ mapM (mkRow gen label e d) zsSorted
-
-mkRow :: GenIO -> String -> Double -> Double -> Double -> IO String
-mkRow gen label e d z = do
-  drop' <- uniformR (0, 1 :: Double) gen
-  if drop' < missRate
-    then return ""
-    else do
-      eps <- MWCD.normal 0 0.1 gen
-      let v = potentialAt e d z + eps
-      return (printf "%s,%g,%g,%.4f,%.4f\n" label e d z v)
diff --git a/demo/io/PreprocessDemo.hs b/demo/io/PreprocessDemo.hs
--- a/demo/io/PreprocessDemo.hs
+++ b/demo/io/PreprocessDemo.hs
@@ -11,7 +11,9 @@
 import qualified Data.Map.Strict as Map
 import qualified Data.Text as T
 
-import qualified DataFrame                    as DX
+import qualified DataFrame.Internal.DataFrame  as DX
+import qualified DataFrame.Operators           as DX
+import qualified DataFrame.Operations.Core     as DX
 import qualified DataFrame.Internal.DataFrame as DXD
 import Hanalyze.DataIO.CSV         (loadCSV)
 import Hanalyze.DataIO.Preprocess
diff --git a/demo/io/RegridBenchDemo.hs b/demo/io/RegridBenchDemo.hs
--- a/demo/io/RegridBenchDemo.hs
+++ b/demo/io/RegridBenchDemo.hs
@@ -18,7 +18,8 @@
 import           Control.Monad         (forM)
 import           Data.List             (sort)
 
-import qualified DataFrame             as DX
+import qualified DataFrame.Internal.Column    as DX
+import qualified DataFrame.Internal.DataFrame  as DX
 import qualified Hanalyze.DataIO.Preprocess     as Pp
 import qualified Hanalyze.Stat.Interpolate      as Interp
 import qualified Hanalyze.Stat.AdaptiveGrid     as AG
diff --git a/demo/regression/AnalysisCompareDemo.hs b/demo/regression/AnalysisCompareDemo.hs
--- a/demo/regression/AnalysisCompareDemo.hs
+++ b/demo/regression/AnalysisCompareDemo.hs
@@ -14,7 +14,9 @@
 import System.Random.MWC (createSystemRandom)
 import Text.Printf (printf)
 
-import qualified DataFrame                    as DX
+import qualified DataFrame.Internal.Column    as DX
+import qualified DataFrame.Internal.DataFrame  as DX
+import qualified DataFrame.Operations.Core     as DX
 import qualified DataFrame.Internal.DataFrame as DXD
 import Hanalyze.DataIO.Convert      (getDoubleVec, getTextVec)
 import Hanalyze.DataIO.CSV          (loadAuto)
diff --git a/hanalyze.cabal b/hanalyze.cabal
--- a/hanalyze.cabal
+++ b/hanalyze.cabal
@@ -1,1459 +1,2691 @@
 cabal-version: 3.0
 name:          hanalyze
-version:       0.1.0.1
-synopsis:      A general-purpose statistical analysis, optimization and visualization toolkit
-description:
-    @hanalyze@ is a self-contained Haskell toolkit for classical regression
-    (LM, GLM, GLMM, splines, kernels, GP, RFF), Bayesian modeling
-    (HBM DSL with MH, HMC, NUTS, Gibbs, ADVI), design of experiments
-    (full/fractional factorial, RSM, D-optimal, orthogonal arrays, Taguchi),
-    optimization (Nelder-Mead, L-BFGS, DE, CMA-ES, NSGA-II, Bayesian
-    optimization, augmented Lagrangian), and Vega-Lite-based visualization
-    with HTML / PNG / SVG output.
-    .
-    All algorithms are implemented natively in Haskell — no R / Stan / Python
-    bridges. Data interchange uses the @dataframe@ package as a first-class
-    citizen.
-    .
-    A unified @hanalyze@ command-line interface exposes the most common
-    workflows (@regress@, @info@, @hist@, @doe@, @taguchi@, @ridge@,
-    @kernel@, @spline@, @multireg@, @clean@, @melt@, @regrid@, ...).
-homepage:      https://github.com/frenzieddoll/hanalyze
-bug-reports:   https://github.com/frenzieddoll/hanalyze/issues
-license:       BSD-3-Clause
-license-file:  LICENSE
-author:        Toshiaki Honda
-maintainer:    frenzieddoll@gmail.com
-copyright:     2026 Toshiaki Honda
-category:      Math, Statistics, Numeric, Machine Learning
-build-type:    Simple
-tested-with:   GHC == 9.6.7
-extra-doc-files:
-    README.md
-    CHANGELOG.md
-
-extra-source-files:
-    data/dirty/*.csv
-    data/distributions/*.csv
-    data/io/*.csv
-    data/readme/*.csv
-    data/regression/*.csv
-
-source-repository head
-  type:     git
-  location: https://github.com/frenzieddoll/hanalyze.git
-
-common warnings
-  ghc-options: -Wall -Wcompat -Widentities -Wredundant-constraints
-
-common opt
-  ghc-options: -O2 -funbox-strict-fields
-
-library
-  import:           warnings, opt
-  hs-source-dirs:   src
-  default-language: GHC2021
-  exposed-modules:
-    Hanalyze.DataIO.CSV
-    Hanalyze.DataIO.Preprocess
-    Hanalyze.DataIO.External
-    Hanalyze.DataIO.Convert
-    Hanalyze.DataIO.Log
-    Hanalyze.DataIO.Health
-    Hanalyze.DataIO.Sniff
-    Hanalyze.DataIO.Clean
-    Hanalyze.DataIO.Reshape
-    Hanalyze.Viz.Core
-    Hanalyze.Viz.PlotConfig
-    Hanalyze.Viz.PlotData
-    Hanalyze.Viz.PlotData.DataFrame
-    Hanalyze.Viz.Scatter
-    Hanalyze.Viz.Histogram
-    Hanalyze.Viz.Bar
-    Hanalyze.Model.Core
-    Hanalyze.Model.LM
-    Hanalyze.Model.LM.Diagnostics
-    Hanalyze.Model.GLM
-    Hanalyze.Model.GLMM
-    Hanalyze.Model.Spline
-    Hanalyze.Model.Kernel
-    Hanalyze.Model.Regularized
-    Hanalyze.Model.RFF
-    Hanalyze.Model.GPRobust
-    Hanalyze.Model.Quantile
-    Hanalyze.Model.GAM
-    Hanalyze.Model.RandomForest
-    Hanalyze.Model.MultiLM
-    Hanalyze.Model.Multivariate
-    Hanalyze.Model.MultiGP
-    Hanalyze.Model.MultiOutput
-    Hanalyze.Model.PCA
-    Hanalyze.Model.Cluster
-    Hanalyze.Model.DecisionTree
-    Hanalyze.Model.TimeSeries
-    Hanalyze.Model.Survival
-    Hanalyze.Design.Factorial
-    Hanalyze.Design.Block
-    Hanalyze.Design.Mixed
-    Hanalyze.Design.Anova
-    Hanalyze.Design.Power
-    Hanalyze.Design.Quality
-    Hanalyze.Design.RSM
-    Hanalyze.Design.Optimal
-    Hanalyze.Design.MultiRSM
-    Hanalyze.Design.Orthogonal
-    Hanalyze.Design.Taguchi
-    Hanalyze.Optim.Desirability
-    Hanalyze.Model.HBM
-    Hanalyze.MCMC.Core
-    Hanalyze.MCMC.MH
-    Hanalyze.MCMC.HMC
-    Hanalyze.MCMC.NUTS
-    Hanalyze.MCMC.Gibbs
-    Hanalyze.MCMC.Slice
-    Hanalyze.Stat.Distribution
-    Hanalyze.Stat.Standardize
-    Hanalyze.Stat.NumberFormat
-    Hanalyze.Stat.MCMC
-    Hanalyze.Stat.ModelSelect
-    Hanalyze.Stat.AD
-    Hanalyze.Stat.VI
-    Hanalyze.Stat.PosteriorPredictive
-    Hanalyze.Stat.Summary
-    Hanalyze.Stat.Interpolate
-    Hanalyze.Stat.AdaptiveGrid
-    Hanalyze.Stat.KernelDist
-    Hanalyze.Stat.Cholesky
-    Hanalyze.Stat.QuasiRandom
-    Hanalyze.Stat.Test
-    Hanalyze.Stat.ClassMetrics
-    Hanalyze.Stat.CV
-    Hanalyze.Stat.MultipleTesting
-    Hanalyze.Stat.Bootstrap
-    Hanalyze.Stat.Effect
-    Hanalyze.Stat.Interpret
-    Hanalyze.Optim.Adam
-    Hanalyze.Optim.GradAscent
-    Hanalyze.Optim.Numeric
-    Hanalyze.Optim.Common
-    Hanalyze.Optim.NelderMead
-    Hanalyze.Optim.LBFGS
-    Hanalyze.Optim.LineSearch
-    Hanalyze.Optim.DifferentialEvolution
-    Hanalyze.Optim.CMAES
-    Hanalyze.Optim.CMAESFull
-    Hanalyze.Optim.SimulatedAnnealing
-    Hanalyze.Optim.ParticleSwarm
-    Hanalyze.Optim.Constrained
-    Hanalyze.Optim.NSGA
-    Hanalyze.Optim.Pareto
-    Hanalyze.Optim.Acquisition
-    Hanalyze.Optim.BayesOpt
-    Hanalyze.Viz.MCMC
-    Hanalyze.Viz.ModelGraph
-    Hanalyze.Viz.Report
-    Hanalyze.Model.GP
-    Hanalyze.Viz.GP
-    Hanalyze.Viz.GPReport
-    Hanalyze.Viz.Assets
-    Hanalyze.Viz.AnalysisReport
-    Hanalyze.Viz.Pareto
-    Hanalyze.Viz.Taguchi
-    Hanalyze.Viz.ReportBuilder
-    Hanalyze.Viz.ReportInstances
-  build-depends:
-      base                 >= 4.14 && < 5
-    , async                >= 2.2  && < 2.3
-    , bytestring           >= 0.11 && < 0.13
-    , cassava              >= 0.5  && < 0.6
-    , containers           >= 0.6  && < 0.8
-    , filepath             >= 1.4  && < 1.6
-    , hmatrix              >= 0.20 && < 0.22
-    , hvega                >= 0.12 && < 0.13
-    , mwc-random           >= 0.15 && < 0.16
-    , process              >= 1.6  && < 1.8
-    , statistics           >= 0.16 && < 0.17
-    , text                 >= 1.2  && < 2.2
-    , aeson                >= 2.0  && < 2.3
-    , directory            >= 1.3  && < 1.4
-    , temporary            >= 1.3  && < 1.4
-    , unordered-containers >= 0.2  && < 0.3
-    , ad                   >= 4.4  && < 4.6
-    , vector               >= 0.12 && < 0.14
-    , dataframe            >= 1.3  && < 2
-    , deepseq              >= 1.4  && < 1.6
-    , massiv               >= 1.0  && < 1.1
-    , parallel             >= 3.2  && < 3.3
-    , vector-algorithms    >= 0.9  && < 0.10
-
-executable hanalyze
-  import:           warnings, opt
-  main-is:          Main.hs
-  hs-source-dirs:   app
-  default-language: GHC2021
-  build-depends:
-      base       >= 4.14 && < 5
-    , hanalyze
-    , text       >= 1.2  && < 2.2
-    , vector     >= 0.12 && < 0.14
-    , hmatrix    >= 0.20 && < 0.22
-    , mwc-random >= 0.15 && < 0.16
-    , containers >= 0.6  && < 0.8
-    , filepath   >= 1.4  && < 1.6
-    , hvega      >= 0.12 && < 0.13
-    , dataframe  >= 1.3  && < 2
-    , time       >= 1.11 && < 1.13
-
-executable glmm-demo
-  import:           warnings, opt
-  main-is:          Demo.hs
-  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
-  default-language: GHC2021
-  build-depends:
-      base     >= 4.14 && < 5
-    , hanalyze
-    , vector   >= 0.12 && < 0.14
-    , hmatrix  >= 0.20 && < 0.22
-    , text     >= 1.2  && < 2.2
-    , dataframe  >= 1.3  && < 2
-
-executable hbm-example
-  import:           warnings, opt
-  main-is:          HBMExample.hs
-  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
-  default-language: GHC2021
-  build-depends:
-      base       >= 4.14 && < 5
-    , hanalyze
-    , text       >= 1.2  && < 2.2
-    , containers >= 0.6  && < 0.8
-    , mwc-random >= 0.15 && < 0.16
-
-executable test-hmc-nuts
-  import:           warnings, opt
-  main-is:          TestHMCNUTS.hs
-  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
-  default-language: GHC2021
-  build-depends:
-      base       >= 4.14 && < 5
-    , hanalyze
-    , text       >= 1.2  && < 2.2
-    , containers >= 0.6  && < 0.8
-    , mwc-random >= 0.15 && < 0.16
-
-executable bench-mcmc
-  import:           warnings, opt
-  main-is:          BenchMCMC.hs
-  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
-  default-language: GHC2021
-  build-depends:
-      base       >= 4.14 && < 5
-    , hanalyze
-    , text       >= 1.2  && < 2.2
-    , containers >= 0.6  && < 0.8
-    , mwc-random >= 0.15 && < 0.16
-    , time       >= 1.9  && < 1.15
-
-executable vi-demo
-  import:           warnings, opt
-  main-is:          VIDemo.hs
-  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
-  default-language: GHC2021
-  build-depends:
-      base       >= 4.14 && < 5
-    , hanalyze
-    , text       >= 1.2  && < 2.2
-    , containers >= 0.6  && < 0.8
-    , mwc-random >= 0.15 && < 0.16
-    , time       >= 1.9  && < 1.15
-
-executable gibbs-demo
-  import:           warnings, opt
-  main-is:          GibbsDemo.hs
-  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
-  default-language: GHC2021
-  build-depends:
-      base       >= 4.14 && < 5
-    , hanalyze
-    , text       >= 1.2  && < 2.2
-    , containers >= 0.6  && < 0.8
-    , mwc-random >= 0.15 && < 0.16
-    , time       >= 1.9  && < 1.15
-
-executable potential-gen
-  import:           warnings, opt
-  main-is:          PotentialGen.hs
-  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
-  default-language: GHC2021
-  build-depends:
-      base       >= 4.14 && < 5
-    , hanalyze
-    , mwc-random >= 0.15 && < 0.16
-
-executable regrid-bench-demo
-  import:           warnings, opt
-  main-is:          RegridBenchDemo.hs
-  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
-  default-language: GHC2021
-  build-depends:
-      base       >= 4.14 && < 5
-    , hanalyze
-    , dataframe  >= 1.3  && < 2
-    , mwc-random >= 0.15 && < 0.16
-    , text       >= 1.2  && < 2.2
-
-executable bar-demo
-  import:           warnings, opt
-  main-is:          BarDemo.hs
-  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
-  default-language: GHC2021
-  build-depends:
-      base       >= 4.14 && < 5
-    , hanalyze
-    , text       >= 1.2  && < 2.2
-
-executable clinical-trial
-  import:           warnings, opt
-  main-is:          ClinicalTrial.hs
-  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
-  default-language: GHC2021
-  build-depends:
-      base       >= 4.14 && < 5
-    , hanalyze
-    , text       >= 1.2  && < 2.2
-    , containers >= 0.6  && < 0.8
-    , mwc-random >= 0.15 && < 0.16
-
-executable gp-demo
-  import:           warnings, opt
-  main-is:          GPDemo.hs
-  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
-  default-language: GHC2021
-  build-depends:
-      base     >= 4.14 && < 5
-    , hanalyze
-
-executable preprocess-demo
-  import:           warnings, opt
-  main-is:          PreprocessDemo.hs
-  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
-  default-language: GHC2021
-  build-depends:
-      base       >= 4.14 && < 5
-    , hanalyze
-    , text       >= 1.2  && < 2.2
-    , containers >= 0.6  && < 0.8
-    , vector     >= 0.12 && < 0.14
-    , dataframe  >= 1.3  && < 2
-
-executable dirty-data-demo
-  import:           warnings, opt
-  main-is:          DirtyDataDemo.hs
-  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
-  default-language: GHC2021
-  build-depends:
-      base       >= 4.14 && < 5
-    , hanalyze
-    , text       >= 1.2  && < 2.2
-    , dataframe  >= 1.3  && < 2
-
-executable external-io-demo
-  import:           warnings, opt
-  main-is:          ExternalIODemo.hs
-  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
-  default-language: GHC2021
-  build-depends:
-      base       >= 4.14 && < 5
-    , hanalyze
-    , text       >= 1.2  && < 2.2
-    , vector     >= 0.12 && < 0.14
-    , dataframe  >= 1.3  && < 2
-
-executable analysis-compare-demo
-  import:           warnings, opt
-  main-is:          AnalysisCompareDemo.hs
-  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
-  default-language: GHC2021
-  build-depends:
-      base       >= 4.14 && < 5
-    , hanalyze
-    , text       >= 1.2  && < 2.2
-    , vector     >= 0.12 && < 0.14
-    , hmatrix    >= 0.20 && < 0.22
-    , mwc-random >= 0.15 && < 0.16
-    , containers >= 0.6  && < 0.8
-    , dataframe  >= 1.3  && < 2
-
-executable new-sections-demo
-  import:           warnings, opt
-  main-is:          NewSectionsDemo.hs
-  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
-  default-language: GHC2021
-  build-depends:
-      base       >= 4.14 && < 5
-    , hanalyze
-    , text       >= 1.2  && < 2.2
-    , vector     >= 0.12 && < 0.14
-    , hmatrix    >= 0.20 && < 0.22
-    , mwc-random >= 0.15 && < 0.16
-
-executable robust-gp-demo
-  import:           warnings, opt
-  main-is:          RobustGPDemo.hs
-  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
-  default-language: GHC2021
-  build-depends:
-      base       >= 4.14 && < 5
-    , hanalyze
-
-executable rff-demo
-  import:           warnings, opt
-  main-is:          RFFDemo.hs
-  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
-  default-language: GHC2021
-  build-depends:
-      base       >= 4.14 && < 5
-    , hanalyze
-    , hmatrix    >= 0.20 && < 0.21
-    , vector     >= 0.12 && < 0.14
-    , mwc-random >= 0.15 && < 0.16
-    , time       >= 1.9  && < 1.13
-
-executable gibbs-hbm-demo
-  import:           warnings, opt
-  main-is:          GibbsHBMDemo.hs
-  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
-  default-language: GHC2021
-  build-depends:
-      base       >= 4.14 && < 5
-    , hanalyze
-    , text       >= 1.2  && < 2.2
-    , containers >= 0.6  && < 0.8
-    , mwc-random >= 0.15 && < 0.16
-
-executable newdistribs-demo
-  import:           warnings, opt
-  main-is:          NewDistribsDemo.hs
-  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
-  default-language: GHC2021
-  build-depends:
-      base       >= 4.14 && < 5
-    , hanalyze
-    , text       >= 1.2  && < 2.2
-    , containers >= 0.6  && < 0.8
-    , mwc-random >= 0.15 && < 0.16
-
-executable regularized-demo
-  import:           warnings, opt
-  main-is:          RegularizedDemo.hs
-  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
-  default-language: GHC2021
-  build-depends:
-      base       >= 4.14 && < 5
-    , hanalyze
-    , vector     >= 0.12 && < 0.14
-    , hmatrix    >= 0.20 && < 0.22
-    , mwc-random >= 0.15 && < 0.16
-
-executable optimaldoe-demo
-  import:           warnings, opt
-  main-is:          OptimalDOEDemo.hs
-  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
-  default-language: GHC2021
-  build-depends:
-      base       >= 4.14 && < 5
-    , hanalyze
-    , text       >= 1.2  && < 2.2
-
-executable pareto-smoke
-  import:           warnings, opt
-  main-is:          ParetoSmokeDemo.hs
-  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
-  default-language: GHC2021
-  build-depends:
-      base       >= 4.14 && < 5
-    , hanalyze
-
-executable materials-moo-demo
-  import:           warnings, opt
-  main-is:          MaterialsMOODemo.hs
-  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
-  default-language: GHC2021
-  build-depends:
-      base       >= 4.14 && < 5
-    , hanalyze
-    , vector     >= 0.12 && < 0.14
-    , hmatrix    >= 0.20 && < 0.22
-    , mwc-random >= 0.15 && < 0.16
-
-executable bayesopt-demo
-  import:           warnings, opt
-  main-is:          BayesOptDemo.hs
-  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
-  default-language: GHC2021
-  build-depends:
-      base       >= 4.14 && < 5
-    , hanalyze
-    , mwc-random >= 0.15 && < 0.16
-
-executable multirsm-demo
-  import:           warnings, opt
-  main-is:          MultiRSMDemo.hs
-  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
-  default-language: GHC2021
-  build-depends:
-      base       >= 4.14 && < 5
-    , hanalyze
-    , hmatrix    >= 0.20 && < 0.22
-
-executable multivariate-demo
-  import:           warnings, opt
-  main-is:          MultivariateDemo.hs
-  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
-  default-language: GHC2021
-  build-depends:
-      base       >= 4.14 && < 5
-    , hanalyze
-    , hmatrix    >= 0.20 && < 0.22
-    , mwc-random >= 0.15 && < 0.16
-
-executable multilm-demo
-  import:           warnings, opt
-  main-is:          MultiLMDemo.hs
-  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
-  default-language: GHC2021
-  build-depends:
-      base       >= 4.14 && < 5
-    , hanalyze
-    , hmatrix    >= 0.20 && < 0.22
-    , mwc-random >= 0.15 && < 0.16
-
-executable nsga-demo
-  import:           warnings, opt
-  main-is:          NSGADemo.hs
-  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
-  default-language: GHC2021
-  build-depends:
-      base       >= 4.14 && < 5
-    , hanalyze
-    , mwc-random >= 0.15 && < 0.16
-    , text       >= 1.2  && < 2.2
-    , vector     >= 0.13 && < 0.14
-
-executable nsga-smoke
-  import:           warnings, opt
-  main-is:          NSGASmokeDemo.hs
-  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
-  default-language: GHC2021
-  build-depends:
-      base       >= 4.14 && < 5
-    , hanalyze
-    , mwc-random >= 0.15 && < 0.16
-
-executable rsm-demo
-  import:           warnings, opt
-  main-is:          RSMDemo.hs
-  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
-  default-language: GHC2021
-  build-depends:
-      base       >= 4.14 && < 5
-    , hanalyze
-    , text       >= 1.2  && < 2.2
-    , hmatrix    >= 0.20 && < 0.22
-    , mwc-random >= 0.15 && < 0.16
-
-executable doe-demo
-  import:           warnings, opt
-  main-is:          DOEDemo.hs
-  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
-  default-language: GHC2021
-  build-depends:
-      base       >= 4.14 && < 5
-    , hanalyze
-    , text       >= 1.2  && < 2.2
-
-executable kernel-demo
-  import:           warnings, opt
-  main-is:          KernelDemo.hs
-  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
-  default-language: GHC2021
-  build-depends:
-      base       >= 4.14 && < 5
-    , hanalyze
-    , vector     >= 0.12 && < 0.14
-    , hvega      >= 0.12 && < 0.13
-    , mwc-random >= 0.15 && < 0.16
-
-executable spline-demo
-  import:           warnings, opt
-  main-is:          SplineDemo.hs
-  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
-  default-language: GHC2021
-  build-depends:
-      base       >= 4.14 && < 5
-    , hanalyze
-    , vector     >= 0.12 && < 0.14
-    , hmatrix    >= 0.20 && < 0.22
-    , hvega      >= 0.12 && < 0.13
-    , mwc-random >= 0.15 && < 0.16
-
-executable integrated-demo
-  import:           warnings, opt
-  main-is:          IntegratedDemo.hs
-  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
-  default-language: GHC2021
-  build-depends:
-      base       >= 4.14 && < 5
-    , hanalyze
-    , text       >= 1.2  && < 2.2
-    , containers >= 0.6  && < 0.8
-    , mwc-random >= 0.15 && < 0.16
-
-executable slice-demo
-  import:           warnings, opt
-  main-is:          SliceDemo.hs
-  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
-  default-language: GHC2021
-  build-depends:
-      base       >= 4.14 && < 5
-    , hanalyze
-    , text       >= 1.2  && < 2.2
-    , containers >= 0.6  && < 0.8
-    , mwc-random >= 0.15 && < 0.16
-
-executable ar1-demo
-  import:           warnings, opt
-  main-is:          AR1Demo.hs
-  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
-  default-language: GHC2021
-  build-depends:
-      base       >= 4.14 && < 5
-    , hanalyze
-    , text       >= 1.2  && < 2.2
-    , containers >= 0.6  && < 0.8
-    , mwc-random >= 0.15 && < 0.16
-
-executable lkj3d-demo
-  import:           warnings, opt
-  main-is:          LKJ3DDemo.hs
-  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
-  default-language: GHC2021
-  build-depends:
-      base       >= 4.14 && < 5
-    , hanalyze
-    , text       >= 1.2  && < 2.2
-    , containers >= 0.6  && < 0.8
-    , mwc-random >= 0.15 && < 0.16
-
-executable lkj-demo
-  import:           warnings, opt
-  main-is:          LKJDemo.hs
-  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
-  default-language: GHC2021
-  build-depends:
-      base       >= 4.14 && < 5
-    , hanalyze
-    , text       >= 1.2  && < 2.2
-    , containers >= 0.6  && < 0.8
-    , mwc-random >= 0.15 && < 0.16
-
-executable zeroinflated-demo
-  import:           warnings, opt
-  main-is:          ZeroInflatedDemo.hs
-  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
-  default-language: GHC2021
-  build-depends:
-      base       >= 4.14 && < 5
-    , hanalyze
-    , text       >= 1.2  && < 2.2
-    , containers >= 0.6  && < 0.8
-    , mwc-random >= 0.15 && < 0.16
-
-executable multinomial-demo
-  import:           warnings, opt
-  main-is:          MultinomialDemo.hs
-  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
-  default-language: GHC2021
-  build-depends:
-      base       >= 4.14 && < 5
-    , hanalyze
-    , text       >= 1.2  && < 2.2
-    , containers >= 0.6  && < 0.8
-    , mwc-random >= 0.15 && < 0.16
-
-executable negbinom-demo
-  import:           warnings, opt
-  main-is:          NegBinomDemo.hs
-  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
-  default-language: GHC2021
-  build-depends:
-      base       >= 4.14 && < 5
-    , hanalyze
-    , text       >= 1.2  && < 2.2
-    , containers >= 0.6  && < 0.8
-    , mwc-random >= 0.15 && < 0.16
-
-executable mvnormal-latent-demo
-  import:           warnings, opt
-  main-is:          MvNormalLatentDemo.hs
-  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
-  default-language: GHC2021
-  build-depends:
-      base       >= 4.14 && < 5
-    , hanalyze
-    , text       >= 1.2  && < 2.2
-    , containers >= 0.6  && < 0.8
-    , mwc-random >= 0.15 && < 0.16
-
-executable setdata-demo
-  import:           warnings, opt
-  main-is:          SetDataDemo.hs
-  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
-  default-language: GHC2021
-  build-depends:
-      base       >= 4.14 && < 5
-    , hanalyze
-    , text       >= 1.2  && < 2.2
-    , containers >= 0.6  && < 0.8
-    , mwc-random >= 0.15 && < 0.16
-
-executable dirichlet-demo
-  import:           warnings, opt
-  main-is:          DirichletDemo.hs
-  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
-  default-language: GHC2021
-  build-depends:
-      base       >= 4.14 && < 5
-    , hanalyze
-    , text       >= 1.2  && < 2.2
-    , containers >= 0.6  && < 0.8
-    , mwc-random >= 0.15 && < 0.16
-
-executable noncentered-demo
-  import:           warnings, opt
-  main-is:          NonCenteredDemo.hs
-  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
-  default-language: GHC2021
-  build-depends:
-      base       >= 4.14 && < 5
-    , hanalyze
-    , text       >= 1.2  && < 2.2
-    , containers >= 0.6  && < 0.8
-    , mwc-random >= 0.15 && < 0.16
-
-executable deterministic-demo
-  import:           warnings, opt
-  main-is:          DeterministicDemo.hs
-  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
-  default-language: GHC2021
-  build-depends:
-      base       >= 4.14 && < 5
-    , hanalyze
-    , text       >= 1.2  && < 2.2
-    , containers >= 0.6  && < 0.8
-    , mwc-random >= 0.15 && < 0.16
-
-executable summary-demo
-  import:           warnings, opt
-  main-is:          SummaryDemo.hs
-  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
-  default-language: GHC2021
-  build-depends:
-      base       >= 4.14 && < 5
-    , hanalyze
-    , text       >= 1.2  && < 2.2
-    , containers >= 0.6  && < 0.8
-    , mwc-random >= 0.15 && < 0.16
-
-executable pymc-status-demo
-  import:           warnings, opt
-  main-is:          PyMCStatusDemo.hs
-  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
-  default-language: GHC2021
-  build-depends:
-      base       >= 4.14 && < 5
-    , hanalyze
-    , text       >= 1.2  && < 2.2
-
-executable energy-demo
-  import:           warnings, opt
-  main-is:          EnergyDemo.hs
-  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
-  default-language: GHC2021
-  build-depends:
-      base       >= 4.14 && < 5
-    , hanalyze
-    , text       >= 1.2  && < 2.2
-    , containers >= 0.6  && < 0.8
-    , mwc-random >= 0.15 && < 0.16
-
-executable mvnormal-demo
-  import:           warnings, opt
-  main-is:          MvNormalDemo.hs
-  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
-  default-language: GHC2021
-  build-depends:
-      base       >= 4.14 && < 5
-    , hanalyze
-    , text       >= 1.2  && < 2.2
-    , containers >= 0.6  && < 0.8
-    , mwc-random >= 0.15 && < 0.16
-
-executable cdf-test
-  import:           warnings, opt
-  main-is:          CDFTestDemo.hs
-  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
-  default-language: GHC2021
-  build-depends:
-      base       >= 4.14 && < 5
-    , hanalyze
-
-executable trunc-censor-demo
-  import:           warnings, opt
-  main-is:          TruncCensorDemo.hs
-  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
-  default-language: GHC2021
-  build-depends:
-      base       >= 4.14 && < 5
-    , hanalyze
-    , text       >= 1.2  && < 2.2
-    , containers >= 0.6  && < 0.8
-    , mwc-random >= 0.15 && < 0.16
-
-executable mixture-demo
-  import:           warnings, opt
-  main-is:          MixtureDemo.hs
-  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
-  default-language: GHC2021
-  build-depends:
-      base       >= 4.14 && < 5
-    , hanalyze
-    , text       >= 1.2  && < 2.2
-    , containers >= 0.6  && < 0.8
-    , mwc-random >= 0.15 && < 0.16
-
-executable potential-demo
-  import:           warnings, opt
-  main-is:          PotentialDemo.hs
-  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
-  default-language: GHC2021
-  build-depends:
-      base       >= 4.14 && < 5
-    , hanalyze
-    , text       >= 1.2  && < 2.2
-    , containers >= 0.6  && < 0.8
-    , mwc-random >= 0.15 && < 0.16
-
-executable potential-multiout-demo
-  import:           warnings, opt
-  main-is:          PotentialMultiOut.hs
-  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
-  default-language: GHC2021
-  build-depends:
-      base       >= 4.14 && < 5
-    , hanalyze
-    , text       >= 1.2  && < 2.2
-    , vector     >= 0.12 && < 0.14
-    , hmatrix    >= 0.20 && < 0.22
-
-executable potential-multikr-demo
-  import:           warnings, opt
-  main-is:          PotentialMultiKR.hs
-  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
-  default-language: GHC2021
-  build-depends:
-      base       >= 4.14 && < 5
-    , hanalyze
-    , text       >= 1.2  && < 2.2
-    , vector     >= 0.12 && < 0.14
-    , hmatrix    >= 0.20 && < 0.22
-
-executable single-opt-bench-demo
-  import:           warnings, opt
-  main-is:          SingleOptBench.hs
-  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
-  default-language: GHC2021
-  build-depends:
-      base       >= 4.14 && < 5
-    , hanalyze
-    , text       >= 1.2  && < 2.2
-    , mwc-random >= 0.15 && < 0.16
-    , hvega      >= 0.12 && < 0.13
-
-executable forest-compare
-  import:           warnings, opt
-  main-is:          ForestCompareDemo.hs
-  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
-  default-language: GHC2021
-  build-depends:
-      base       >= 4.14 && < 5
-    , hanalyze
-    , text       >= 1.2  && < 2.2
-    , containers >= 0.6  && < 0.8
-    , mwc-random >= 0.15 && < 0.16
-
-executable ppc-demo
-  import:           warnings, opt
-  main-is:          PPCDemo.hs
-  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
-  default-language: GHC2021
-  build-depends:
-      base       >= 4.14 && < 5
-    , hanalyze
-    , text       >= 1.2  && < 2.2
-    , containers >= 0.6  && < 0.8
-    , mwc-random >= 0.15 && < 0.16
-
-executable discrete-obs-demo
-  import:           warnings, opt
-  main-is:          DiscreteObsDemo.hs
-  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
-  default-language: GHC2021
-  build-depends:
-      base       >= 4.14 && < 5
-    , hanalyze
-    , text       >= 1.2  && < 2.2
-    , containers >= 0.6  && < 0.8
-    , mwc-random >= 0.15 && < 0.16
-
-executable new-distrib-demo
-  import:           warnings, opt
-  main-is:          NewDistribDemo.hs
-  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
-  default-language: GHC2021
-  build-depends:
-      base       >= 4.14 && < 5
-    , hanalyze
-    , text       >= 1.2  && < 2.2
-    , containers >= 0.6  && < 0.8
-    , mwc-random >= 0.15 && < 0.16
-
-executable hbm-random-slope
-  import:           warnings, opt
-  main-is:          HBMRandomSlopeDemo.hs
-  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
-  default-language: GHC2021
-  build-depends:
-      base       >= 4.14 && < 5
-    , hanalyze
-    , text       >= 1.2  && < 2.2
-    , containers >= 0.6  && < 0.8
-    , mwc-random >= 0.15 && < 0.16
-    , vector     >= 0.12 && < 0.14
-    , dataframe  >= 1.3  && < 2
-
-executable simpson-paradox
-  import:           warnings, opt
-  main-is:          SimpsonParadoxDemo.hs
-  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
-  default-language: GHC2021
-  build-depends:
-      base       >= 4.14 && < 5
-    , hanalyze
-    , text       >= 1.2  && < 2.2
-    , containers >= 0.6  && < 0.8
-    , mwc-random >= 0.15 && < 0.16
-    , vector     >= 0.12 && < 0.14
-    , hmatrix    >= 0.20 && < 0.22
-    , dataframe  >= 1.3  && < 2
-
-executable hbm-regression
-  import:           warnings, opt
-  main-is:          HBMRegressionDemo.hs
-  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
-  default-language: GHC2021
-  build-depends:
-      base       >= 4.14 && < 5
-    , hanalyze
-    , text       >= 1.2  && < 2.2
-    , containers >= 0.6  && < 0.8
-    , mwc-random >= 0.15 && < 0.16
-    , vector     >= 0.12 && < 0.14
-    , dataframe  >= 1.3  && < 2
-
--- Bench data generator: produces deterministic CSVs that both the Haskell
--- benchmarks and the Python comparison scripts read. (See bench/README.md.)
-executable bench-data-gen
-  import:           warnings, opt
-  main-is:          BenchDataGen.hs
-  hs-source-dirs:   bench/haskell
-  default-language: GHC2021
-  build-depends:
-      base                 >= 4.14 && < 5
-    , directory            >= 1.3  && < 1.4
-    , hmatrix              >= 0.20 && < 0.22
-    , mwc-random           >= 0.15 && < 0.16
-    , vector               >= 0.12 && < 0.14
-
--- Bayesian optimization bench (B5): Branin / Hartmann6.
-executable bench-bo
-  import:           warnings, opt
-  main-is:          BenchBO.hs
-  other-modules:    BenchUtil
-  hs-source-dirs:   bench/haskell
-  default-language: GHC2021
-  build-depends:
-      base                 >= 4.14 && < 5
-    , bytestring           >= 0.11 && < 0.13
-    , cassava              >= 0.5  && < 0.6
-    , hanalyze
-    , hmatrix              >= 0.20 && < 0.22
-    , mwc-random           >= 0.15 && < 0.16
-    , text                 >= 1.2  && < 2.2
-    , time                 >= 1.9  && < 1.13
-    , vector               >= 0.12 && < 0.14
-    , tasty-bench          >= 0.3  && < 0.5
-    , tasty                >= 1.4  && < 1.6
-
--- Standalone bench: hmatrix vs massiv on pairwise squared distance
--- (F4 evaluation, F5 multi-core Par evaluation).
-executable bench-massiv
-  import:           warnings, opt
-  main-is:          BenchMassiv.hs
-  hs-source-dirs:   bench/haskell
-  default-language: GHC2021
-  ghc-options:      -threaded -rtsopts -with-rtsopts=-N
-  build-depends:
-      base                 >= 4.14 && < 5
-    , hanalyze
-    , hmatrix              >= 0.20 && < 0.22
-    , massiv               >= 1.0  && < 1.1
-    , time                 >= 1.9  && < 1.13
-    , deepseq              >= 1.4  && < 1.6
-
--- Multi-objective optimization bench (B4): NSGA-II on ZDT/DTLZ.
-executable bench-mo
-  import:           warnings, opt
-  main-is:          BenchMO.hs
-  other-modules:    BenchUtil
-  hs-source-dirs:   bench/haskell
-  default-language: GHC2021
-  build-depends:
-      base                 >= 4.14 && < 5
-    , bytestring           >= 0.11 && < 0.13
-    , cassava              >= 0.5  && < 0.6
-    , hanalyze
-    , hmatrix              >= 0.20 && < 0.22
-    , mwc-random           >= 0.15 && < 0.16
-    , text                 >= 1.2  && < 2.2
-    , time                 >= 1.9  && < 1.13
-    , vector               >= 0.12 && < 0.14
-    , tasty-bench          >= 0.3  && < 0.5
-    , tasty                >= 1.4  && < 1.6
-
--- Standalone investigation: P37 Cheng-BB Beta sampler vs 2-Gamma + division.
-executable bench-beta-isolate
-  import:           warnings, opt
-  main-is:          BenchBetaIsolate.hs
-  hs-source-dirs:   bench/haskell
-  default-language: GHC2021
-  build-depends:
-      base                 >= 4.14 && < 5
-    , hanalyze
-    , mwc-random           >= 0.15 && < 0.16
-    , time                 >= 1.9  && < 1.13
-    , vector               >= 0.12 && < 0.14
-
--- Standalone investigation: P40 Bootstrap resampling hot-path
--- (uniformVector batch + GEMV row-sum vs naive index-loop).
-executable bench-bootstrap-isolate
-  import:           warnings, opt
-  main-is:          BenchBootstrapIsolate.hs
-  hs-source-dirs:   bench/haskell
-  default-language: GHC2021
-  build-depends:
-      base                 >= 4.14 && < 5
-    , hmatrix              >= 0.20 && < 0.22
-    , mwc-random           >= 0.15 && < 0.16
-    , time                 >= 1.9  && < 1.13
-    , vector               >= 0.12 && < 0.14
-
--- Single-objective optimization bench (B3).
-executable bench-optim
-  import:           warnings, opt
-  main-is:          BenchOptim.hs
-  other-modules:    BenchUtil
-  hs-source-dirs:   bench/haskell
-  default-language: GHC2021
-  build-depends:
-      base                 >= 4.14 && < 5
-    , bytestring           >= 0.11 && < 0.13
-    , cassava              >= 0.5  && < 0.6
-    , hanalyze
-    , hmatrix              >= 0.20 && < 0.22
-    , mwc-random           >= 0.15 && < 0.16
-    , text                 >= 1.2  && < 2.2
-    , time                 >= 1.9  && < 1.13
-    , vector               >= 0.12 && < 0.14
-    , tasty-bench          >= 0.3  && < 0.5
-    , tasty                >= 1.4  && < 1.6
-
--- OOM regression bench (Phase 11b): RFF.medianPairwiseDist + rbfKernelMat.
-executable bench-rff-oom
-  import:           warnings, opt
-  main-is:          BenchRFFOOM.hs
-  hs-source-dirs:   bench/haskell
-  default-language: GHC2021
-  ghc-options:      -rtsopts
-  build-depends:
-      base                 >= 4.14 && < 5
-    , hanalyze
-    , hmatrix              >= 0.20 && < 0.22
-    , time                 >= 1.9  && < 1.13
-
-executable bench-mem-vi
-  import:           warnings, opt
-  main-is:          BenchMemVI.hs
-  hs-source-dirs:   bench/haskell
-  default-language: GHC2021
-  ghc-options:      -rtsopts
-  build-depends:
-      base                 >= 4.14 && < 5
-    , hanalyze
-    , containers           >= 0.6  && < 0.8
-    , text                 >= 1.2  && < 2.2
-    , time                 >= 1.9  && < 1.13
-    , mwc-random           >= 0.15 && < 0.16
-
-executable bench-mem-aggregate
-  import:           warnings, opt
-  main-is:          BenchMemAggregate.hs
-  hs-source-dirs:   bench/haskell
-  default-language: GHC2021
-  ghc-options:      -rtsopts
-  build-depends:
-      base                 >= 4.14 && < 5
-    , hanalyze
-    , dataframe            >= 0.3  && < 2
-    , text                 >= 1.2  && < 2.2
-    , time                 >= 1.9  && < 1.13
-    , vector               >= 0.13 && < 0.14
-
-executable bench-mem-nsga2
-  import:           warnings, opt
-  main-is:          BenchMemNSGA.hs
-  hs-source-dirs:   bench/haskell
-  default-language: GHC2021
-  ghc-options:      -rtsopts
-  build-depends:
-      base                 >= 4.14 && < 5
-    , hanalyze
-    , time                 >= 1.9  && < 1.13
-    , mwc-random           >= 0.15 && < 0.16
-
-executable bench-mem-bo
-  import:           warnings, opt
-  main-is:          BenchMemBO.hs
-  hs-source-dirs:   bench/haskell
-  default-language: GHC2021
-  ghc-options:      -rtsopts
-  build-depends:
-      base                 >= 4.14 && < 5
-    , hanalyze
-    , time                 >= 1.9  && < 1.13
-    , mwc-random           >= 0.15 && < 0.16
-
-executable bench-mem-mcmc
-  import:           warnings, opt
-  main-is:          BenchMemMCMC.hs
-  hs-source-dirs:   bench/haskell
-  default-language: GHC2021
-  ghc-options:      -rtsopts
-  build-depends:
-      base                 >= 4.14 && < 5
-    , hanalyze
-    , containers           >= 0.6  && < 0.8
-    , text                 >= 1.2  && < 2.2
-    , time                 >= 1.9  && < 1.13
-    , mwc-random           >= 0.15 && < 0.16
-
--- Kernel / GP bench (B2): KR / NW / RFF / GP / GPRobust.
-executable bench-kernel
-  import:           warnings, opt
-  main-is:          BenchKernel.hs
-  other-modules:    BenchUtil
-  hs-source-dirs:   bench/haskell
-  default-language: GHC2021
-  build-depends:
-      base                 >= 4.14 && < 5
-    , bytestring           >= 0.11 && < 0.13
-    , cassava              >= 0.5  && < 0.6
-    , hanalyze
-    , hmatrix              >= 0.20 && < 0.22
-    , mwc-random           >= 0.15 && < 0.16
-    , text                 >= 1.2  && < 2.2
-    , time                 >= 1.9  && < 1.13
-    , vector               >= 0.12 && < 0.14
-    , tasty-bench          >= 0.3  && < 0.5
-    , tasty                >= 1.4  && < 1.6
-
--- ML bench (B6): PCA / KMeans / DecisionTree / RandomForest.
-executable bench-ml
-  import:           warnings, opt
-  main-is:          BenchML.hs
-  other-modules:    BenchUtil
-  hs-source-dirs:   bench/haskell
-  default-language: GHC2021
-  build-depends:
-      base                 >= 4.14 && < 5
-    , bytestring           >= 0.11 && < 0.13
-    , cassava              >= 0.5  && < 0.6
-    , hanalyze
-    , hmatrix              >= 0.20 && < 0.22
-    , mwc-random           >= 0.15 && < 0.16
-    , text                 >= 1.2  && < 2.2
-    , time                 >= 1.9  && < 1.13
-    , vector               >= 0.12 && < 0.14
-    , tasty-bench          >= 0.3  && < 0.5
-    , tasty                >= 1.4  && < 1.6
-
--- Survival / TimeSeries bench (B8): ARIMA / Cox / KM / Quantile / GAM / Spline.
-executable bench-survts
-  import:           warnings, opt
-  main-is:          BenchSurvTS.hs
-  other-modules:    BenchUtil
-  hs-source-dirs:   bench/haskell
-  default-language: GHC2021
-  build-depends:
-      base                 >= 4.14 && < 5
-    , bytestring           >= 0.11 && < 0.13
-    , cassava              >= 0.5  && < 0.6
-    , hanalyze
-    , hmatrix              >= 0.20 && < 0.22
-    , random               >= 1.2  && < 1.4
-    , text                 >= 1.2  && < 2.2
-    , time                 >= 1.9  && < 1.13
-    , vector               >= 0.12 && < 0.14
-    , tasty-bench          >= 0.3  && < 0.5
-    , tasty                >= 1.4  && < 1.6
-
--- MCMC diagnostic (B10b): explore why hanalyze NUTS ESS is poor.
-executable bench-mcmc-diag
-  import:           warnings, opt
-  main-is:          BenchMCMCDiag.hs
-  other-modules:    BenchUtil
-  hs-source-dirs:   bench/haskell
-  default-language: GHC2021
-  build-depends:
-      base                 >= 4.14 && < 5
-    , bytestring           >= 0.11 && < 0.13
-    , cassava              >= 0.5  && < 0.6
-    , containers           >= 0.6  && < 0.8
-    , hanalyze
-    , hmatrix              >= 0.20 && < 0.22
-    , mwc-random           >= 0.15 && < 0.16
-    , text                 >= 1.2  && < 2.2
-    , time                 >= 1.9  && < 1.13
-    , vector               >= 0.12 && < 0.14
-    , tasty-bench          >= 0.3  && < 0.5
-    , tasty                >= 1.4  && < 1.6
-
--- MCMC bench (B7): HMC / NUTS on hierarchical normal model.
-executable bench-mcmc-b7
-  import:           warnings, opt
-  main-is:          BenchMCMCB7.hs
-  other-modules:    BenchUtil
-  hs-source-dirs:   bench/haskell
-  default-language: GHC2021
-  build-depends:
-      base                 >= 4.14 && < 5
-    , bytestring           >= 0.11 && < 0.13
-    , cassava              >= 0.5  && < 0.6
-    , containers           >= 0.6  && < 0.8
-    , hanalyze
-    , hmatrix              >= 0.20 && < 0.22
-    , mwc-random           >= 0.15 && < 0.16
-    , text                 >= 1.2  && < 2.2
-    , time                 >= 1.9  && < 1.13
-    , vector               >= 0.12 && < 0.14
-    , tasty-bench          >= 0.3  && < 0.5
-    , tasty                >= 1.4  && < 1.6
-
--- B7 残: Gibbs / ADVI / WAIC.
-executable bench-mcmc-extras
-  import:           warnings, opt
-  main-is:          BenchMCMCExtras.hs
-  other-modules:    BenchUtil
-  hs-source-dirs:   bench/haskell
-  default-language: GHC2021
-  build-depends:
-      base                 >= 4.14 && < 5
-    , bytestring           >= 0.11 && < 0.13
-    , cassava              >= 0.5  && < 0.6
-    , containers           >= 0.6  && < 0.8
-    , hanalyze
-    , hmatrix              >= 0.20 && < 0.22
-    , mwc-random           >= 0.15 && < 0.16
-    , text                 >= 1.2  && < 2.2
-    , time                 >= 1.9  && < 1.13
-    , vector               >= 0.12 && < 0.14
-    , tasty-bench          >= 0.3  && < 0.5
-    , tasty                >= 1.4  && < 1.6
-
--- B13 Regrid: regridLong on a jagged long-form fixture.
-executable bench-regrid
-  import:           warnings, opt
-  main-is:          BenchRegrid.hs
-  other-modules:    BenchUtil
-  hs-source-dirs:   bench/haskell
-  default-language: GHC2021
-  build-depends:
-      base                 >= 4.14 && < 5
-    , bytestring           >= 0.11 && < 0.13
-    , cassava              >= 0.5  && < 0.6
-    , dataframe            >= 0.4
-    , hanalyze
-    , hmatrix              >= 0.20 && < 0.22
-    , time                 >= 1.9  && < 1.13
-    , vector               >= 0.12 && < 0.14
-    , tasty-bench          >= 0.3  && < 0.5
-    , tasty                >= 1.4  && < 1.6
-
--- B12 Multi-output: MultiLM / MultiGP.
-executable bench-multi-output
-  import:           warnings, opt
-  main-is:          BenchMultiOutput.hs
-  other-modules:    BenchUtil
-  hs-source-dirs:   bench/haskell
-  default-language: GHC2021
-  build-depends:
-      base                 >= 4.14 && < 5
-    , bytestring           >= 0.11 && < 0.13
-    , cassava              >= 0.5  && < 0.6
-    , hanalyze
-    , hmatrix              >= 0.20 && < 0.22
-    , time                 >= 1.9  && < 1.13
-    , vector               >= 0.12 && < 0.14
-    , tasty-bench          >= 0.3  && < 0.5
-    , tasty                >= 1.4  && < 1.6
-
--- B10 Stat util: Bootstrap / t-test / KS / MW / BH / Halton / AUC / k-fold.
-executable bench-stat-util
-  import:           warnings, opt
-  main-is:          BenchStatUtil.hs
-  other-modules:    BenchUtil
-  hs-source-dirs:   bench/haskell
-  default-language: GHC2021
-  build-depends:
-      base                 >= 4.14 && < 5
-    , bytestring           >= 0.11 && < 0.13
-    , cassava              >= 0.5  && < 0.6
-    , hanalyze
-    , hmatrix              >= 0.20 && < 0.22
-    , mwc-random           >= 0.15 && < 0.16
-    , time                 >= 1.9  && < 1.13
-    , vector               >= 0.12 && < 0.14
-    , tasty-bench          >= 0.3  && < 0.5
-    , tasty                >= 1.4  && < 1.6
-
--- B9 Optim+: Constrained / Adam / CMAESFull.
-executable bench-optim-plus
-  import:           warnings, opt
-  main-is:          BenchOptimPlus.hs
-  other-modules:    BenchUtil
-  hs-source-dirs:   bench/haskell
-  default-language: GHC2021
-  build-depends:
-      base                 >= 4.14 && < 5
-    , bytestring           >= 0.11 && < 0.13
-    , cassava              >= 0.5  && < 0.6
-    , hanalyze
-    , hmatrix              >= 0.20 && < 0.22
-    , mwc-random           >= 0.15 && < 0.16
-    , time                 >= 1.9  && < 1.13
-    , vector               >= 0.12 && < 0.14
-    , tasty-bench          >= 0.3  && < 0.5
-    , tasty                >= 1.4  && < 1.6
-
--- B8 残: Holt-Winters / GAM / Spline 補間.
-executable bench-ts-extras
-  import:           warnings, opt
-  main-is:          BenchTSExtras.hs
-  other-modules:    BenchUtil
-  hs-source-dirs:   bench/haskell
-  default-language: GHC2021
-  build-depends:
-      base                 >= 4.14 && < 5
-    , bytestring           >= 0.11 && < 0.13
-    , cassava              >= 0.5  && < 0.6
-    , hanalyze
-    , hmatrix              >= 0.20 && < 0.22
-    , time                 >= 1.9  && < 1.13
-    , vector               >= 0.12 && < 0.14
-    , tasty-bench          >= 0.3  && < 0.5
-    , tasty                >= 1.4  && < 1.6
-
--- Regression bench (B1): LM / GLM / GLMM / Ridge / Lasso / ElasticNet.
-executable bench-regression
-  import:           warnings, opt
-  main-is:          BenchRegression.hs
-  other-modules:    BenchUtil
-  hs-source-dirs:   bench/haskell
-  default-language: GHC2021
-  build-depends:
-      base                 >= 4.14 && < 5
-    , bytestring           >= 0.11 && < 0.13
-    , cassava              >= 0.5  && < 0.6
-    , hanalyze
-    , hmatrix              >= 0.20 && < 0.22
-    , text                 >= 1.2  && < 2.2
-    , time                 >= 1.9  && < 1.13
-    , vector               >= 0.12 && < 0.14
-    , tasty-bench          >= 0.3  && < 0.5
-    , tasty                >= 1.4  && < 1.6
-
-executable bench-profile
-  import:           warnings, opt
-  main-is:          BenchProfile.hs
-  other-modules:    BenchUtil
-  hs-source-dirs:   bench/haskell
-  default-language: GHC2021
-  ghc-options:      -rtsopts "-with-rtsopts=-T"
-  build-depends:
-      base                 >= 4.14 && < 5
-    , bytestring           >= 0.11 && < 0.13
-    , cassava              >= 0.5  && < 0.6
-    , deepseq              >= 1.4  && < 1.6
-    , hanalyze
-    , hmatrix              >= 0.20 && < 0.22
-    , text                 >= 1.2  && < 2.2
-    , time                 >= 1.9  && < 1.13
-    , vector               >= 0.12 && < 0.14
-    , tasty-bench          >= 0.3  && < 0.5
-    , tasty                >= 1.4  && < 1.6
-
-executable bench-tasty
-  import:           warnings, opt
-  main-is:          BenchTasty.hs
-  other-modules:    BenchUtil
-  hs-source-dirs:   bench/haskell
-  default-language: GHC2021
-  build-depends:
-      base                 >= 4.14 && < 5
-    , bytestring           >= 0.11 && < 0.13
-    , cassava              >= 0.5  && < 0.6
-    , hanalyze
-    , hmatrix              >= 0.20 && < 0.22
-    , tasty-bench          >= 0.3  && < 0.5
-    , tasty                >= 1.4  && < 1.6
-    , text                 >= 1.2  && < 2.2
-    , time                 >= 1.9  && < 1.13
-    , vector               >= 0.12 && < 0.14
-
-test-suite hanalyze-test
-  import:           warnings, opt
-  type:             exitcode-stdio-1.0
-  main-is:          Spec.hs
-  hs-source-dirs:   test
-  default-language: GHC2021
-  build-depends:
-      base       >= 4.14 && < 5
-    , hanalyze
-    , hspec      >= 2.10 && < 2.12
-    , vector     >= 0.12 && < 0.14
-    , mwc-random >= 0.15 && < 0.16
-    , text       >= 1.2  && < 2.2
-    , dataframe  >= 1.3  && < 2
-    , temporary  >= 1.3  && < 1.4
-    , bytestring >= 0.11 && < 0.13
-    , hmatrix    >= 0.20 && < 0.22
+version:       0.2.0.0
+synopsis:      A general-purpose statistical analysis, optimization and visualization toolkit
+description:
+    @hanalyze@ is a self-contained Haskell toolkit for classical regression
+    (LM, GLM, GLMM, splines, kernels, GP, RFF), Bayesian modeling
+    (HBM DSL with MH, HMC, NUTS, Gibbs, ADVI), design of experiments
+    (full/fractional factorial, RSM, D-optimal, orthogonal arrays, Taguchi),
+    optimization (Nelder-Mead, L-BFGS, DE, CMA-ES, NSGA-II, Bayesian
+    optimization, augmented Lagrangian), and Vega-Lite-based visualization
+    with HTML / PNG / SVG output.
+    .
+    All algorithms are implemented natively in Haskell — no R / Stan / Python
+    bridges. Data interchange uses the @dataframe@ package as a first-class
+    citizen.
+    .
+    A unified @hanalyze@ command-line interface exposes the most common
+    workflows (@regress@, @info@, @hist@, @doe@, @taguchi@, @ridge@,
+    @kernel@, @spline@, @multireg@, @clean@, @melt@, @regrid@, ...).
+homepage:      https://github.com/frenzieddoll/hanalyze
+bug-reports:   https://github.com/frenzieddoll/hanalyze/issues
+license:       BSD-3-Clause
+license-file:  LICENSE
+author:        Toshiaki Honda
+maintainer:    frenzieddoll@gmail.com
+copyright:     2026 Aelysce Project (Toshiaki Honda)
+category:      Math, Statistics, Numeric, Machine Learning
+build-type:    Simple
+tested-with:   GHC == 9.6.7
+extra-doc-files:
+    README.md
+    CHANGELOG.md
+
+extra-source-files:
+    data/dirty/*.csv
+    data/distributions/*.csv
+    data/io/*.csv
+    data/readme/*.csv
+    data/regression/*.csv
+
+source-repository head
+  type:     git
+  location: https://github.com/frenzieddoll/hanalyze.git
+
+flag plot-integration
+  description: hgg 連携 (Hanalyze.Plot = toPlot/Plottable) を有効化する。
+               on にすると hgg-core/-svg に依存する (= sibling repo 必須)。
+               既定 off で upstream hanalyze は plot 非依存・standalone build を維持。
+  default:     False
+  manual:      True
+
+flag demos
+  description: デモ/例 executable 群 (*-demo / *-smoke / hbm-example 等) を build する。
+               既定 off で default build は library + hanalyze のみ (高速)。
+               有効化: cabal build -f demos / cabal run -f demos <name>。
+  default:     False
+  manual:      True
+
+flag benches
+  description: ベンチ executable 群 (bench-* / *-bench-demo / bench-data-gen) を build する。
+               既定 off で bench の重い依存 (tasty-bench 等) も default build から外す。
+               有効化: cabal build -f benches / cabal run -f benches <name>。
+  default:     False
+  manual:      True
+
+common warnings
+  ghc-options: -Wall -Wcompat -Widentities -Wredundant-constraints
+
+common opt
+  ghc-options: -O2 -funbox-strict-fields
+
+-- demo/bench executable をフラグ配下に置くゲート (import: …, demo-gate で適用)。
+-- フラグ off のとき buildable: False = default build から外れる。
+common demo-gate
+  if !flag(demos)
+    buildable: False
+
+common bench-gate
+  if !flag(benches)
+    buildable: False
+
+library
+  import:           warnings, opt
+  hs-source-dirs:   src
+  default-language: GHC2021
+  exposed-modules:
+    Hanalyze
+    Hanalyze.Data.ColumnSource
+    Hanalyze.Data.Factor
+    Hanalyze.Data.Strings
+    Hanalyze.Data.Transform
+    Hanalyze.Data.Wrangle
+    Hanalyze.DataIO.CSV
+    Hanalyze.DataIO.Preprocess
+    Hanalyze.DataIO.External
+    Hanalyze.DataIO.Convert
+    Hanalyze.DataIO.Log
+    Hanalyze.DataIO.Health
+    Hanalyze.DataIO.Sniff
+    Hanalyze.DataIO.Clean
+    Hanalyze.DataIO.Reshape
+    Hanalyze.Viz.Core
+    Hanalyze.Viz.PlotConfig
+    Hanalyze.Viz.PlotData
+    Hanalyze.Viz.PlotData.DataFrame
+    Hanalyze.Viz.Scatter
+    Hanalyze.Viz.Histogram
+    Hanalyze.Viz.Bar
+    Hanalyze.Model.Core
+    Hanalyze.Model.Wrappers
+    Hanalyze.Diagnostics
+    Hanalyze.Fit
+    Hanalyze.Model.LM
+    Hanalyze.Model.LM.Diagnostics
+    Hanalyze.Model.Formula
+    Hanalyze.Model.Formula.Frame
+    Hanalyze.Model.Formula.Design
+    Hanalyze.Model.Formula.RFormula
+    Hanalyze.Model.Formula.Nonlinear
+    Hanalyze.Model.Formula.Mixed
+    Hanalyze.Model.GLM
+    Hanalyze.Model.GLMM
+    Hanalyze.Model.Spline
+    Hanalyze.Model.Kernel
+    Hanalyze.Model.KernelRegression
+    Hanalyze.Model.Regularized
+    Hanalyze.Model.RFF
+    Hanalyze.Model.GPRobust
+    Hanalyze.Model.DAG
+    Hanalyze.Model.LiNGAM.Direct
+    Hanalyze.Model.LiNGAM.Bootstrap
+    Hanalyze.Model.LiNGAM.Pairwise
+    Hanalyze.Model.LiNGAM.ICA
+    Hanalyze.Model.LiNGAM.VAR
+    Hanalyze.Model.LiNGAM.MultiGroup
+    Hanalyze.Model.LiNGAM.Parce
+    Hanalyze.Math.ICA
+    Hanalyze.Math.Hungarian
+    Hanalyze.Math.HSIC
+    Hanalyze.Model.Quantile
+    Hanalyze.Model.GAM
+    Hanalyze.Model.RandomForest
+    Hanalyze.Model.MultiLM
+    Hanalyze.Model.Multivariate
+    Hanalyze.Model.MultiGP
+    Hanalyze.Model.MultiOutput
+    Hanalyze.Model.PCA
+    Hanalyze.Model.MDS
+    Hanalyze.Model.Cluster
+    Hanalyze.Model.DecisionTree
+    Hanalyze.Model.PartialDependence
+    Hanalyze.Model.TimeSeries
+    Hanalyze.Model.Survival
+    Hanalyze.Model.Weibull
+    Hanalyze.Model.Reliability
+    Hanalyze.Model.PLS
+    Hanalyze.Model.Discriminant
+    Hanalyze.Model.HierarchicalCluster
+    Hanalyze.Model.AFT
+    Hanalyze.Model.RandomForestClassifier
+    Hanalyze.Model.FitYByX
+    Hanalyze.Model.StateSpace
+    Hanalyze.Model.NeuralNetwork
+    Hanalyze.Design.GaugeRR
+    Hanalyze.Design.Diagnostics
+    Hanalyze.Design.SpaceFilling
+    Hanalyze.Design.DSD
+    Hanalyze.Design.Mixture
+    Hanalyze.Design.Sequential
+    Hanalyze.Design.Factorial
+    Hanalyze.Design.Block
+    Hanalyze.Design.Mixed
+    Hanalyze.Design.Anova
+    Hanalyze.Design.Power
+    Hanalyze.Design.Quality
+    Hanalyze.Design.RSM
+    Hanalyze.Design.Workflow
+    Hanalyze.Design.Optimal
+    Hanalyze.Design.Constraint
+    Hanalyze.Design.Custom.Factor
+    Hanalyze.Design.Custom.Model
+    Hanalyze.Design.Custom.Constraint
+    Hanalyze.Design.Custom.Coordinate
+    Hanalyze.Design.Custom.Compare
+    Hanalyze.Design.Custom.Power
+    Hanalyze.Design.Custom.Augment
+    Hanalyze.Design.Custom.SplitPlot
+    Hanalyze.Design.Custom.Structured
+    Hanalyze.Design.Custom.Bayesian
+    Hanalyze.Design.Custom.RegionMoment
+    Hanalyze.MCMC.SMC
+    Hanalyze.Stat.BridgeSampling
+    Hanalyze.Stat.BayesFactor
+    Hanalyze.Stat.BayesianModelAveraging
+    Hanalyze.Stat.Causal.PropensityScore
+    Hanalyze.Stat.Causal.IPW
+    Hanalyze.Stat.Causal.DoublyRobust
+    Hanalyze.Stat.Causal.CATE
+    Hanalyze.Model.RegularizedAdvanced
+    Hanalyze.Model.Robust
+    Hanalyze.Stat.CorrelationNetwork
+    Hanalyze.Model.LatentClassAnalysis
+    Hanalyze.Model.FDA
+    Hanalyze.Model.GradientBoosting
+    Hanalyze.Model.SVM
+    Hanalyze.Stat.MDS
+    Hanalyze.Model.KNN
+    Hanalyze.Model.NaiveBayes
+    Hanalyze.Model.GARCH
+    Hanalyze.Model.VAR
+    Hanalyze.Model.CompetingRisks
+    Hanalyze.Model.ReliabilityBlockDiagram
+    Hanalyze.Design.MultiRSM
+    Hanalyze.Design.Orthogonal
+    Hanalyze.Design.Taguchi
+    Hanalyze.Optim.Desirability
+    Hanalyze.Model.HBM
+    Hanalyze.Model.HBM.Ast
+    Hanalyze.Model.HBM.Distribution
+    Hanalyze.Model.HBM.Eval
+    Hanalyze.Model.HBM.Gradient
+    Hanalyze.Model.HBM.IR
+    Hanalyze.Model.HBM.Interp
+    Hanalyze.Model.HBM.Model
+    Hanalyze.Model.HBM.Sampling
+    Hanalyze.Model.HBM.Track
+    Hanalyze.Model.HBM.Util
+    Hanalyze.Model.HBM.VecAD
+    Hanalyze.MCMC.Core
+    Hanalyze.MCMC.MH
+    Hanalyze.MCMC.HMC
+    Hanalyze.MCMC.NUTS
+    Hanalyze.MCMC.Progress
+    Hanalyze.MCMC.BayesianTest
+    Hanalyze.MCMC.Gibbs
+    Hanalyze.MCMC.Slice
+    Hanalyze.Stat.Descriptive
+    Hanalyze.Stat.Distribution
+    Hanalyze.Stat.Standardize
+    Hanalyze.Stat.NumberFormat
+    Hanalyze.Stat.MCMC
+    Hanalyze.Stat.ModelSelect
+    Hanalyze.Stat.AD
+    Hanalyze.Stat.VI
+    Hanalyze.Stat.PosteriorPredictive
+    Hanalyze.Stat.Summary
+    Hanalyze.Stat.Interpolate
+    Hanalyze.Stat.AdaptiveGrid
+    Hanalyze.Stat.KernelDist
+    Hanalyze.Stat.Cholesky
+    Hanalyze.Stat.QuasiRandom
+    Hanalyze.Stat.Test
+    Hanalyze.Stat.ClassMetrics
+    Hanalyze.Stat.CV
+    Hanalyze.Stat.MultipleTesting
+    Hanalyze.Stat.Bootstrap
+    Hanalyze.Stat.Effect
+    Hanalyze.Stat.Interpret
+    Hanalyze.Stat.SPC
+    Hanalyze.Stat.GroupComparison
+    Hanalyze.Optim.Adam
+    Hanalyze.Optim.GradAscent
+    Hanalyze.Optim.Numeric
+    Hanalyze.Optim.Common
+    Hanalyze.Optim.NelderMead
+    Hanalyze.Optim.LBFGS
+    Hanalyze.Optim.LineSearch
+    Hanalyze.Optim.DifferentialEvolution
+    Hanalyze.Optim.CMAES
+    Hanalyze.Optim.CMAESFull
+    Hanalyze.Optim.SimulatedAnnealing
+    Hanalyze.Optim.ParticleSwarm
+    Hanalyze.Optim.Constrained
+    Hanalyze.Optim.NSGA
+    Hanalyze.Optim.Pareto
+    Hanalyze.Optim.Acquisition
+    Hanalyze.Optim.BayesOpt
+    Hanalyze.Viz.MCMC
+    Hanalyze.Viz.ModelGraph
+    Hanalyze.Viz.ModelGraphDot
+    Hanalyze.Viz.Report
+    Hanalyze.Model.GP
+    Hanalyze.Viz.GP
+    Hanalyze.Viz.GPReport
+    Hanalyze.Viz.Assets
+    Hanalyze.Viz.AnalysisReport
+    Hanalyze.Viz.Pareto
+    Hanalyze.Viz.Taguchi
+    Hanalyze.Viz.ReportBuilder
+    Hanalyze.Viz.ReportInstances
+  build-depends:
+      base                 >= 4.14 && < 5
+    , array                >= 0.5  && < 0.6
+    , async                >= 2.2  && < 2.3
+    , bytestring           >= 0.11 && < 0.13
+    , cassava              >= 0.5  && < 0.6
+    , containers           >= 0.6  && < 0.8
+    , filepath             >= 1.4  && < 1.6
+    , hmatrix              >= 0.20 && < 0.22
+    , hvega                >= 0.12 && < 0.13
+    , mwc-random           >= 0.15 && < 0.16
+    , primitive            >= 0.7  && < 0.10
+    , deepseq              >= 1.4  && < 1.6
+    , parallel             >= 3.2  && < 3.3
+    , process              >= 1.6  && < 1.8
+    , statistics           >= 0.16 && < 0.17
+    , text                 >= 1.2  && < 2.2
+    , aeson                >= 2.0  && < 2.3
+    , directory            >= 1.3  && < 1.4
+    , temporary            >= 1.3  && < 1.4
+    , unordered-containers >= 0.2  && < 0.3
+    , ad                   >= 4.4  && < 4.6
+    -- Phase 92 B3: logDensityRD (AD 定数正規化項の畳み込み) の Reifies 制約用
+    -- (ad の依存 closure 内・新規 install なし)
+    , reflection           >= 2.1  && < 2.2
+    , vector               >= 0.12 && < 0.14
+    , dataframe-core        ^>= 1.1
+    , dataframe-operations  >= 1.1.1 && < 1.2
+    , dataframe-csv         ^>= 1.0.2
+    , dataframe-json        ^>= 1.0
+    , dataframe-parquet     ^>= 1.1
+    , deepseq              >= 1.4  && < 1.6
+    , massiv               >= 1.0  && < 1.1
+    , parallel             >= 3.2  && < 3.3
+    , vector-algorithms    >= 0.9  && < 0.10
+    , megaparsec           >= 9.0  && < 9.7
+    , parser-combinators   >= 1.3  && < 1.4
+    , unicode-transforms   >= 0.4  && < 0.5
+    , regex-tdfa           >= 1.3  && < 1.4
+    , regex-base           >= 0.94 && < 0.95
+
+  -- hgg 連携 (flag plot-integration 配下・upstream 非 portable)。
+  -- 既定 off では Hanalyze.Plot を build せず plot 依存も持たない (standalone 維持)。
+  if flag(plot-integration)
+    exposed-modules:
+        Hanalyze.Plot
+        Hanalyze.Plot.Core
+        Hanalyze.Plot.Linear
+        Hanalyze.Plot.Smooth
+        Hanalyze.Plot.Robust
+        Hanalyze.Plot.Bayes
+        Hanalyze.Plot.ML
+        Hanalyze.Plot.Wrappers
+    build-depends:
+        hgg-core
+      , hgg-svg
+      , hgg-3d
+      , hgg-custom
+
+-- NOTE (public sync 2026-07-18): hanalyze-plot-test / phase104-probe-prof /
+-- plot-integration-demo は fork 側の demo-plot/ test-plot/ experiments/ 配下
+-- を参照するが、 これらのディレクトリは同期対象外 (private plot 連携リポジトリ
+-- 側の開発物)。 plot-integration flag は残すが、 上記 3 stanza は hs-source-dirs
+-- が存在しないため省いている。
+
+-- Phase 82: DOE demo — RSM 予測精度 vs 実験数 (必要実験数の見極め)。
+--   hgg で RMSE vs n の折れ線 (noise sd 参照線つき) を SVG 出力。
+executable doe-rsm-samplesize-demo
+  import:           warnings
+  main-is:          RSMSampleSizeDemo.hs
+  hs-source-dirs:   demo/doe
+  default-language: GHC2021
+  if flag(plot-integration)
+    build-depends:
+        base
+      , text
+      , vector
+      , hmatrix
+      , directory
+      , hanalyze
+      , hgg-core
+      , hgg-frame
+      , hgg-svg
+  else
+    buildable: False
+
+-- Phase 83: 逐次DOE デモ (半導体インプラ最適化・30因子スクリーニング→試作RSM)。
+executable doe-implant-sequential-demo
+  import:           warnings
+  main-is:          ImplantSequentialDemo.hs
+  hs-source-dirs:   demo/doe
+  default-language: GHC2021
+  if flag(plot-integration)
+    build-depends:
+        base
+      , text
+      , vector
+      , hmatrix
+      , directory
+      , hanalyze
+      , hgg-core
+      , hgg-frame
+      , hgg-svg
+  else
+    buildable: False
+
+executable hanalyze
+  import:           warnings, opt
+  main-is:          Main.hs
+  hs-source-dirs:   app
+  default-language: GHC2021
+  build-depends:
+      base       >= 4.14 && < 5
+    , hanalyze
+    , text       >= 1.2  && < 2.2
+    , vector     >= 0.12 && < 0.14
+    , hmatrix    >= 0.20 && < 0.22
+    , mwc-random >= 0.15 && < 0.16
+    , containers >= 0.6  && < 0.8
+    , filepath   >= 1.4  && < 1.6
+    , hvega      >= 0.12 && < 0.13
+    , dataframe-core        ^>= 1.1
+    , dataframe-operations  >= 1.1.1 && < 1.2
+    , dataframe-csv         ^>= 1.0.2
+    , time       >= 1.11 && < 1.13
+
+executable glmm-demo
+  import:           warnings, opt, demo-gate
+  main-is:          Demo.hs
+  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
+  default-language: GHC2021
+  build-depends:
+      base     >= 4.14 && < 5
+    , hanalyze
+    , vector   >= 0.12 && < 0.14
+    , hmatrix  >= 0.20 && < 0.22
+    , text     >= 1.2  && < 2.2
+    , dataframe-core        ^>= 1.1
+
+executable hbm-example
+  import:           warnings, opt, demo-gate
+  main-is:          HBMExample.hs
+  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
+  default-language: GHC2021
+  build-depends:
+      base       >= 4.14 && < 5
+    , hanalyze
+    , text       >= 1.2  && < 2.2
+    , containers >= 0.6  && < 0.8
+    , mwc-random >= 0.15 && < 0.16
+
+executable phase37-a0-verify
+  import:           warnings, opt, demo-gate
+  main-is:          Phase37A0VerifyDemo.hs
+  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
+  default-language: GHC2021
+  build-depends:
+      base       >= 4.14 && < 5
+    , hanalyze
+    , text       >= 1.2  && < 2.2
+    , containers >= 0.6  && < 0.8
+    , mwc-random >= 0.15 && < 0.16
+
+executable plate-notation-demo
+  import:           warnings, opt, demo-gate
+  main-is:          PlateNotationDemo.hs
+  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
+  default-language: GHC2021
+  build-depends:
+      base       >= 4.14 && < 5
+    , hanalyze
+    , text       >= 1.2  && < 2.2
+    , directory  >= 1.3  && < 1.4
+
+executable test-hmc-nuts
+  import:           warnings, opt, demo-gate
+  main-is:          TestHMCNUTS.hs
+  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
+  default-language: GHC2021
+  build-depends:
+      base       >= 4.14 && < 5
+    , hanalyze
+    , text       >= 1.2  && < 2.2
+    , containers >= 0.6  && < 0.8
+    , mwc-random >= 0.15 && < 0.16
+
+executable bench-mcmc
+  import:           warnings, opt, bench-gate
+  main-is:          BenchMCMC.hs
+  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
+  default-language: GHC2021
+  build-depends:
+      base       >= 4.14 && < 5
+    , hanalyze
+    , text       >= 1.2  && < 2.2
+    , containers >= 0.6  && < 0.8
+    , mwc-random >= 0.15 && < 0.16
+    , time       >= 1.9  && < 1.15
+
+executable vi-demo
+  import:           warnings, opt, demo-gate
+  main-is:          VIDemo.hs
+  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
+  default-language: GHC2021
+  build-depends:
+      base       >= 4.14 && < 5
+    , hanalyze
+    , text       >= 1.2  && < 2.2
+    , containers >= 0.6  && < 0.8
+    , mwc-random >= 0.15 && < 0.16
+    , time       >= 1.9  && < 1.15
+
+executable gibbs-demo
+  import:           warnings, opt, demo-gate
+  main-is:          GibbsDemo.hs
+  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
+  default-language: GHC2021
+  build-depends:
+      base       >= 4.14 && < 5
+    , hanalyze
+    , text       >= 1.2  && < 2.2
+    , containers >= 0.6  && < 0.8
+    , mwc-random >= 0.15 && < 0.16
+    , time       >= 1.9  && < 1.15
+
+executable regrid-bench-demo
+  import:           warnings, opt, bench-gate
+  main-is:          RegridBenchDemo.hs
+  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
+  default-language: GHC2021
+  build-depends:
+      base       >= 4.14 && < 5
+    , hanalyze
+    , dataframe-core        ^>= 1.1
+    , mwc-random >= 0.15 && < 0.16
+    , text       >= 1.2  && < 2.2
+
+executable bar-demo
+  import:           warnings, opt, demo-gate
+  main-is:          BarDemo.hs
+  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
+  default-language: GHC2021
+  build-depends:
+      base       >= 4.14 && < 5
+    , hanalyze
+    , text       >= 1.2  && < 2.2
+
+executable clinical-trial
+  import:           warnings, opt, demo-gate
+  main-is:          ClinicalTrial.hs
+  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
+  default-language: GHC2021
+  build-depends:
+      base       >= 4.14 && < 5
+    , hanalyze
+    , text       >= 1.2  && < 2.2
+    , containers >= 0.6  && < 0.8
+    , mwc-random >= 0.15 && < 0.16
+
+executable gp-demo
+  import:           warnings, opt, demo-gate
+  main-is:          GPDemo.hs
+  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
+  default-language: GHC2021
+  build-depends:
+      base     >= 4.14 && < 5
+    , hanalyze
+
+executable preprocess-demo
+  import:           warnings, opt, demo-gate
+  main-is:          PreprocessDemo.hs
+  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
+  default-language: GHC2021
+  build-depends:
+      base       >= 4.14 && < 5
+    , hanalyze
+    , text       >= 1.2  && < 2.2
+    , containers >= 0.6  && < 0.8
+    , vector     >= 0.12 && < 0.14
+    , dataframe-core        ^>= 1.1
+    , dataframe-operations  >= 1.1.1 && < 1.2
+
+executable dirty-data-demo
+  import:           warnings, opt, demo-gate
+  main-is:          DirtyDataDemo.hs
+  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
+  default-language: GHC2021
+  build-depends:
+      base       >= 4.14 && < 5
+    , hanalyze
+    , text       >= 1.2  && < 2.2
+    , dataframe-core        ^>= 1.1
+    , dataframe-operations  >= 1.1.1 && < 1.2
+
+executable external-io-demo
+  import:           warnings, opt, demo-gate
+  main-is:          ExternalIODemo.hs
+  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
+  default-language: GHC2021
+  build-depends:
+      base       >= 4.14 && < 5
+    , hanalyze
+    , text       >= 1.2  && < 2.2
+    , vector     >= 0.12 && < 0.14
+    , dataframe-core        ^>= 1.1
+    , dataframe-operations  >= 1.1.1 && < 1.2
+
+executable analysis-compare-demo
+  import:           warnings, opt, demo-gate
+  main-is:          AnalysisCompareDemo.hs
+  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
+  default-language: GHC2021
+  build-depends:
+      base       >= 4.14 && < 5
+    , hanalyze
+    , text       >= 1.2  && < 2.2
+    , vector     >= 0.12 && < 0.14
+    , hmatrix    >= 0.20 && < 0.22
+    , mwc-random >= 0.15 && < 0.16
+    , containers >= 0.6  && < 0.8
+    , dataframe-core        ^>= 1.1
+    , dataframe-operations  >= 1.1.1 && < 1.2
+
+executable new-sections-demo
+  import:           warnings, opt, demo-gate
+  main-is:          NewSectionsDemo.hs
+  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
+  default-language: GHC2021
+  build-depends:
+      base       >= 4.14 && < 5
+    , hanalyze
+    , text       >= 1.2  && < 2.2
+    , vector     >= 0.12 && < 0.14
+    , hmatrix    >= 0.20 && < 0.22
+    , mwc-random >= 0.15 && < 0.16
+
+executable robust-gp-demo
+  import:           warnings, opt, demo-gate
+  main-is:          RobustGPDemo.hs
+  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
+  default-language: GHC2021
+  build-depends:
+      base       >= 4.14 && < 5
+    , hanalyze
+
+executable rff-demo
+  import:           warnings, opt, demo-gate
+  main-is:          RFFDemo.hs
+  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
+  default-language: GHC2021
+  build-depends:
+      base       >= 4.14 && < 5
+    , hanalyze
+    , hmatrix    >= 0.20 && < 0.21
+    , vector     >= 0.12 && < 0.14
+    , mwc-random >= 0.15 && < 0.16
+    , time       >= 1.9  && < 1.13
+
+executable gibbs-hbm-demo
+  import:           warnings, opt, demo-gate
+  main-is:          GibbsHBMDemo.hs
+  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
+  default-language: GHC2021
+  build-depends:
+      base       >= 4.14 && < 5
+    , hanalyze
+    , text       >= 1.2  && < 2.2
+    , containers >= 0.6  && < 0.8
+    , mwc-random >= 0.15 && < 0.16
+
+executable newdistribs-demo
+  import:           warnings, opt, demo-gate
+  main-is:          NewDistribsDemo.hs
+  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
+  default-language: GHC2021
+  build-depends:
+      base       >= 4.14 && < 5
+    , hanalyze
+    , text       >= 1.2  && < 2.2
+    , containers >= 0.6  && < 0.8
+    , mwc-random >= 0.15 && < 0.16
+
+executable regularized-demo
+  import:           warnings, opt, demo-gate
+  main-is:          RegularizedDemo.hs
+  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
+  default-language: GHC2021
+  build-depends:
+      base       >= 4.14 && < 5
+    , hanalyze
+    , vector     >= 0.12 && < 0.14
+    , hmatrix    >= 0.20 && < 0.22
+    , mwc-random >= 0.15 && < 0.16
+
+executable optimaldoe-demo
+  import:           warnings, opt, demo-gate
+  main-is:          OptimalDOEDemo.hs
+  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
+  default-language: GHC2021
+  build-depends:
+      base       >= 4.14 && < 5
+    , hanalyze
+    , text       >= 1.2  && < 2.2
+
+executable pareto-smoke
+  import:           warnings, opt, demo-gate
+  main-is:          ParetoSmokeDemo.hs
+  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
+  default-language: GHC2021
+  build-depends:
+      base       >= 4.14 && < 5
+    , hanalyze
+
+executable materials-moo-demo
+  import:           warnings, opt, demo-gate
+  main-is:          MaterialsMOODemo.hs
+  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
+  default-language: GHC2021
+  build-depends:
+      base       >= 4.14 && < 5
+    , hanalyze
+    , vector     >= 0.12 && < 0.14
+    , hmatrix    >= 0.20 && < 0.22
+    , mwc-random >= 0.15 && < 0.16
+
+executable bayesopt-demo
+  import:           warnings, opt, demo-gate
+  main-is:          BayesOptDemo.hs
+  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
+  default-language: GHC2021
+  build-depends:
+      base       >= 4.14 && < 5
+    , hanalyze
+    , mwc-random >= 0.15 && < 0.16
+
+executable multirsm-demo
+  import:           warnings, opt, demo-gate
+  main-is:          MultiRSMDemo.hs
+  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
+  default-language: GHC2021
+  build-depends:
+      base       >= 4.14 && < 5
+    , hanalyze
+    , hmatrix    >= 0.20 && < 0.22
+
+executable multivariate-demo
+  import:           warnings, opt, demo-gate
+  main-is:          MultivariateDemo.hs
+  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
+  default-language: GHC2021
+  build-depends:
+      base       >= 4.14 && < 5
+    , hanalyze
+    , hmatrix    >= 0.20 && < 0.22
+    , mwc-random >= 0.15 && < 0.16
+
+executable multilm-demo
+  import:           warnings, opt, demo-gate
+  main-is:          MultiLMDemo.hs
+  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
+  default-language: GHC2021
+  build-depends:
+      base       >= 4.14 && < 5
+    , hanalyze
+    , hmatrix    >= 0.20 && < 0.22
+    , mwc-random >= 0.15 && < 0.16
+
+executable nsga-demo
+  import:           warnings, opt, demo-gate
+  main-is:          NSGADemo.hs
+  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
+  default-language: GHC2021
+  build-depends:
+      base       >= 4.14 && < 5
+    , hanalyze
+    , mwc-random >= 0.15 && < 0.16
+    , text       >= 1.2  && < 2.2
+    , vector     >= 0.13 && < 0.14
+
+executable nsga-smoke
+  import:           warnings, opt, demo-gate
+  main-is:          NSGASmokeDemo.hs
+  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
+  default-language: GHC2021
+  build-depends:
+      base       >= 4.14 && < 5
+    , hanalyze
+    , mwc-random >= 0.15 && < 0.16
+
+executable rsm-demo
+  import:           warnings, opt, demo-gate
+  main-is:          RSMDemo.hs
+  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
+  default-language: GHC2021
+  build-depends:
+      base       >= 4.14 && < 5
+    , hanalyze
+    , text       >= 1.2  && < 2.2
+    , hmatrix    >= 0.20 && < 0.22
+    , mwc-random >= 0.15 && < 0.16
+
+executable doe-demo
+  import:           warnings, opt, demo-gate
+  main-is:          DOEDemo.hs
+  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
+  default-language: GHC2021
+  build-depends:
+      base       >= 4.14 && < 5
+    , hanalyze
+    , text       >= 1.2  && < 2.2
+
+executable cis-implant-workflow-demo
+  import:           warnings, opt, demo-gate
+  main-is:          CISImplantWorkflowDemo.hs
+  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
+  default-language: GHC2021
+  build-depends:
+      base            >= 4.14 && < 5
+    , hanalyze
+    , text            >= 1.2  && < 2.2
+    , hmatrix         >= 0.20
+    , vector          >= 0.12 && < 0.14
+    , directory       >= 1.3
+
+executable kernel-demo
+  import:           warnings, opt, demo-gate
+  main-is:          KernelDemo.hs
+  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
+  default-language: GHC2021
+  build-depends:
+      base       >= 4.14 && < 5
+    , hanalyze
+    , vector     >= 0.12 && < 0.14
+    , hvega      >= 0.12 && < 0.13
+    , mwc-random >= 0.15 && < 0.16
+
+executable spline-demo
+  import:           warnings, opt, demo-gate
+  main-is:          SplineDemo.hs
+  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
+  default-language: GHC2021
+  build-depends:
+      base       >= 4.14 && < 5
+    , hanalyze
+    , vector     >= 0.12 && < 0.14
+    , hmatrix    >= 0.20 && < 0.22
+    , hvega      >= 0.12 && < 0.13
+    , mwc-random >= 0.15 && < 0.16
+
+executable integrated-demo
+  import:           warnings, opt, demo-gate
+  main-is:          IntegratedDemo.hs
+  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
+  default-language: GHC2021
+  build-depends:
+      base       >= 4.14 && < 5
+    , hanalyze
+    , text       >= 1.2  && < 2.2
+    , containers >= 0.6  && < 0.8
+    , mwc-random >= 0.15 && < 0.16
+
+executable slice-demo
+  import:           warnings, opt, demo-gate
+  main-is:          SliceDemo.hs
+  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
+  default-language: GHC2021
+  build-depends:
+      base       >= 4.14 && < 5
+    , hanalyze
+    , text       >= 1.2  && < 2.2
+    , containers >= 0.6  && < 0.8
+    , mwc-random >= 0.15 && < 0.16
+
+executable ar1-demo
+  import:           warnings, opt, demo-gate
+  main-is:          AR1Demo.hs
+  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
+  default-language: GHC2021
+  build-depends:
+      base       >= 4.14 && < 5
+    , hanalyze
+    , text       >= 1.2  && < 2.2
+    , containers >= 0.6  && < 0.8
+    , mwc-random >= 0.15 && < 0.16
+
+executable lkj3d-demo
+  import:           warnings, opt, demo-gate
+  main-is:          LKJ3DDemo.hs
+  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
+  default-language: GHC2021
+  build-depends:
+      base       >= 4.14 && < 5
+    , hanalyze
+    , text       >= 1.2  && < 2.2
+    , containers >= 0.6  && < 0.8
+    , mwc-random >= 0.15 && < 0.16
+
+executable lkj-demo
+  import:           warnings, opt, demo-gate
+  main-is:          LKJDemo.hs
+  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
+  default-language: GHC2021
+  build-depends:
+      base       >= 4.14 && < 5
+    , hanalyze
+    , text       >= 1.2  && < 2.2
+    , containers >= 0.6  && < 0.8
+    , mwc-random >= 0.15 && < 0.16
+
+executable zeroinflated-demo
+  import:           warnings, opt, demo-gate
+  main-is:          ZeroInflatedDemo.hs
+  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
+  default-language: GHC2021
+  build-depends:
+      base       >= 4.14 && < 5
+    , hanalyze
+    , text       >= 1.2  && < 2.2
+    , containers >= 0.6  && < 0.8
+    , mwc-random >= 0.15 && < 0.16
+
+executable multinomial-demo
+  import:           warnings, opt, demo-gate
+  main-is:          MultinomialDemo.hs
+  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
+  default-language: GHC2021
+  build-depends:
+      base       >= 4.14 && < 5
+    , hanalyze
+    , text       >= 1.2  && < 2.2
+    , containers >= 0.6  && < 0.8
+    , mwc-random >= 0.15 && < 0.16
+
+executable negbinom-demo
+  import:           warnings, opt, demo-gate
+  main-is:          NegBinomDemo.hs
+  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
+  default-language: GHC2021
+  build-depends:
+      base       >= 4.14 && < 5
+    , hanalyze
+    , text       >= 1.2  && < 2.2
+    , containers >= 0.6  && < 0.8
+    , mwc-random >= 0.15 && < 0.16
+
+executable mvnormal-latent-demo
+  import:           warnings, opt, demo-gate
+  main-is:          MvNormalLatentDemo.hs
+  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
+  default-language: GHC2021
+  build-depends:
+      base       >= 4.14 && < 5
+    , hanalyze
+    , text       >= 1.2  && < 2.2
+    , containers >= 0.6  && < 0.8
+    , mwc-random >= 0.15 && < 0.16
+
+executable setdata-demo
+  import:           warnings, opt, demo-gate
+  main-is:          SetDataDemo.hs
+  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
+  default-language: GHC2021
+  build-depends:
+      base       >= 4.14 && < 5
+    , hanalyze
+    , text       >= 1.2  && < 2.2
+    , containers >= 0.6  && < 0.8
+    , mwc-random >= 0.15 && < 0.16
+
+executable dirichlet-demo
+  import:           warnings, opt, demo-gate
+  main-is:          DirichletDemo.hs
+  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
+  default-language: GHC2021
+  build-depends:
+      base       >= 4.14 && < 5
+    , hanalyze
+    , text       >= 1.2  && < 2.2
+    , containers >= 0.6  && < 0.8
+    , mwc-random >= 0.15 && < 0.16
+
+executable noncentered-demo
+  import:           warnings, opt, demo-gate
+  main-is:          NonCenteredDemo.hs
+  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
+  default-language: GHC2021
+  build-depends:
+      base       >= 4.14 && < 5
+    , hanalyze
+    , text       >= 1.2  && < 2.2
+    , containers >= 0.6  && < 0.8
+    , mwc-random >= 0.15 && < 0.16
+
+executable deterministic-demo
+  import:           warnings, opt, demo-gate
+  main-is:          DeterministicDemo.hs
+  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
+  default-language: GHC2021
+  build-depends:
+      base       >= 4.14 && < 5
+    , hanalyze
+    , text       >= 1.2  && < 2.2
+    , containers >= 0.6  && < 0.8
+    , mwc-random >= 0.15 && < 0.16
+
+executable summary-demo
+  import:           warnings, opt, demo-gate
+  main-is:          SummaryDemo.hs
+  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
+  default-language: GHC2021
+  build-depends:
+      base       >= 4.14 && < 5
+    , hanalyze
+    , text       >= 1.2  && < 2.2
+    , containers >= 0.6  && < 0.8
+    , mwc-random >= 0.15 && < 0.16
+
+executable pymc-status-demo
+  import:           warnings, opt, demo-gate
+  main-is:          PyMCStatusDemo.hs
+  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
+  default-language: GHC2021
+  build-depends:
+      base       >= 4.14 && < 5
+    , hanalyze
+    , text       >= 1.2  && < 2.2
+
+executable energy-demo
+  import:           warnings, opt, demo-gate
+  main-is:          EnergyDemo.hs
+  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
+  default-language: GHC2021
+  build-depends:
+      base       >= 4.14 && < 5
+    , hanalyze
+    , text       >= 1.2  && < 2.2
+    , containers >= 0.6  && < 0.8
+    , mwc-random >= 0.15 && < 0.16
+
+executable mvnormal-demo
+  import:           warnings, opt, demo-gate
+  main-is:          MvNormalDemo.hs
+  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
+  default-language: GHC2021
+  build-depends:
+      base       >= 4.14 && < 5
+    , hanalyze
+    , text       >= 1.2  && < 2.2
+    , containers >= 0.6  && < 0.8
+    , mwc-random >= 0.15 && < 0.16
+
+executable cdf-test
+  import:           warnings, opt, demo-gate
+  main-is:          CDFTestDemo.hs
+  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
+  default-language: GHC2021
+  build-depends:
+      base       >= 4.14 && < 5
+    , hanalyze
+
+executable trunc-censor-demo
+  import:           warnings, opt, demo-gate
+  main-is:          TruncCensorDemo.hs
+  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
+  default-language: GHC2021
+  build-depends:
+      base       >= 4.14 && < 5
+    , hanalyze
+    , text       >= 1.2  && < 2.2
+    , containers >= 0.6  && < 0.8
+    , mwc-random >= 0.15 && < 0.16
+
+executable mixture-demo
+  import:           warnings, opt, demo-gate
+  main-is:          MixtureDemo.hs
+  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
+  default-language: GHC2021
+  build-depends:
+      base       >= 4.14 && < 5
+    , hanalyze
+    , text       >= 1.2  && < 2.2
+    , containers >= 0.6  && < 0.8
+    , mwc-random >= 0.15 && < 0.16
+
+executable potential-demo
+  import:           warnings, opt, demo-gate
+  main-is:          PotentialDemo.hs
+  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
+  default-language: GHC2021
+  build-depends:
+      base       >= 4.14 && < 5
+    , hanalyze
+    , text       >= 1.2  && < 2.2
+    , containers >= 0.6  && < 0.8
+    , mwc-random >= 0.15 && < 0.16
+
+executable potential-multiout-demo
+  import:           warnings, opt, demo-gate
+  main-is:          PotentialMultiOut.hs
+  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
+  default-language: GHC2021
+  build-depends:
+      base       >= 4.14 && < 5
+    , hanalyze
+    , text       >= 1.2  && < 2.2
+    , vector     >= 0.12 && < 0.14
+    , hmatrix    >= 0.20 && < 0.22
+
+executable potential-multikr-demo
+  import:           warnings, opt, demo-gate
+  main-is:          PotentialMultiKR.hs
+  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
+  default-language: GHC2021
+  build-depends:
+      base       >= 4.14 && < 5
+    , hanalyze
+    , text       >= 1.2  && < 2.2
+    , vector     >= 0.12 && < 0.14
+    , hmatrix    >= 0.20 && < 0.22
+
+executable single-opt-bench-demo
+  import:           warnings, opt, bench-gate
+  main-is:          SingleOptBench.hs
+  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
+  default-language: GHC2021
+  build-depends:
+      base       >= 4.14 && < 5
+    , hanalyze
+    , text       >= 1.2  && < 2.2
+    , mwc-random >= 0.15 && < 0.16
+    , hvega      >= 0.12 && < 0.13
+
+executable forest-compare
+  import:           warnings, opt, demo-gate
+  main-is:          ForestCompareDemo.hs
+  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
+  default-language: GHC2021
+  build-depends:
+      base       >= 4.14 && < 5
+    , hanalyze
+    , text       >= 1.2  && < 2.2
+    , containers >= 0.6  && < 0.8
+    , mwc-random >= 0.15 && < 0.16
+
+executable ppc-demo
+  import:           warnings, opt, demo-gate
+  main-is:          PPCDemo.hs
+  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
+  default-language: GHC2021
+  build-depends:
+      base       >= 4.14 && < 5
+    , hanalyze
+    , text       >= 1.2  && < 2.2
+    , containers >= 0.6  && < 0.8
+    , mwc-random >= 0.15 && < 0.16
+
+executable discrete-obs-demo
+  import:           warnings, opt, demo-gate
+  main-is:          DiscreteObsDemo.hs
+  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
+  default-language: GHC2021
+  build-depends:
+      base       >= 4.14 && < 5
+    , hanalyze
+    , text       >= 1.2  && < 2.2
+    , containers >= 0.6  && < 0.8
+    , mwc-random >= 0.15 && < 0.16
+
+executable new-distrib-demo
+  import:           warnings, opt, demo-gate
+  main-is:          NewDistribDemo.hs
+  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
+  default-language: GHC2021
+  build-depends:
+      base       >= 4.14 && < 5
+    , hanalyze
+    , text       >= 1.2  && < 2.2
+    , containers >= 0.6  && < 0.8
+    , mwc-random >= 0.15 && < 0.16
+
+executable hbm-random-slope
+  import:           warnings, opt, demo-gate
+  main-is:          HBMRandomSlopeDemo.hs
+  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
+  default-language: GHC2021
+  build-depends:
+      base       >= 4.14 && < 5
+    , hanalyze
+    , text       >= 1.2  && < 2.2
+    , containers >= 0.6  && < 0.8
+    , mwc-random >= 0.15 && < 0.16
+    , vector     >= 0.12 && < 0.14
+    , dataframe-core        ^>= 1.1
+
+executable simpson-paradox
+  import:           warnings, opt, demo-gate
+  main-is:          SimpsonParadoxDemo.hs
+  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
+  default-language: GHC2021
+  build-depends:
+      base       >= 4.14 && < 5
+    , hanalyze
+    , text       >= 1.2  && < 2.2
+    , containers >= 0.6  && < 0.8
+    , mwc-random >= 0.15 && < 0.16
+    , vector     >= 0.12 && < 0.14
+    , hmatrix    >= 0.20 && < 0.22
+    , dataframe-core        ^>= 1.1
+
+executable hbm-regression
+  import:           warnings, opt, demo-gate
+  main-is:          HBMRegressionDemo.hs
+  hs-source-dirs:   demo demo/regression demo/doe-optim demo/bayesian demo/visualization demo/io
+  default-language: GHC2021
+  build-depends:
+      base       >= 4.14 && < 5
+    , hanalyze
+    , text       >= 1.2  && < 2.2
+    , containers >= 0.6  && < 0.8
+    , mwc-random >= 0.15 && < 0.16
+    , vector     >= 0.12 && < 0.14
+    , dataframe-core        ^>= 1.1
+
+-- Bench data generator: produces deterministic CSVs that both the Haskell
+-- benchmarks and the Python comparison scripts read. (See bench/README.md.)
+executable bench-data-gen
+  import:           warnings, opt, bench-gate
+  main-is:          BenchDataGen.hs
+  hs-source-dirs:   bench/haskell
+  default-language: GHC2021
+  build-depends:
+      base                 >= 4.14 && < 5
+    , directory            >= 1.3  && < 1.4
+    , hmatrix              >= 0.20 && < 0.22
+    , mwc-random           >= 0.15 && < 0.16
+    , vector               >= 0.12 && < 0.14
+
+-- Phase 47 A5: Formula DSL の Haskell 参照値生成器 (statsmodels/scipy 突合用)。
+executable formula-ref-gen
+  import:           warnings, opt, demo-gate
+  main-is:          BenchFormulaRef.hs
+  hs-source-dirs:   bench/haskell
+  default-language: GHC2021
+  build-depends:
+      base                 >= 4.14 && < 5
+    , hanalyze
+    , dataframe-core        ^>= 1.1
+    , hmatrix              >= 0.20 && < 0.22
+    , text                 >= 1.2  && < 2.2
+    , vector               >= 0.12 && < 0.14
+
+-- Bayesian optimization bench (B5): Branin / Hartmann6.
+executable bench-bo
+  import:           warnings, opt, bench-gate
+  main-is:          BenchBO.hs
+  other-modules:    BenchUtil
+  hs-source-dirs:   bench/haskell
+  default-language: GHC2021
+  build-depends:
+      base                 >= 4.14 && < 5
+    , bytestring           >= 0.11 && < 0.13
+    , cassava              >= 0.5  && < 0.6
+    , hanalyze
+    , hmatrix              >= 0.20 && < 0.22
+    , mwc-random           >= 0.15 && < 0.16
+    , text                 >= 1.2  && < 2.2
+    , time                 >= 1.9  && < 1.13
+    , vector               >= 0.12 && < 0.14
+    , tasty-bench          >= 0.3  && < 0.5
+    , tasty                >= 1.4  && < 1.6
+
+-- Phase 1-7 (Spotfire/JMP gap) features の Python/R 比較ベンチ。
+-- 共通入力 CSV を bench/data/ に生成 + Haskell 側時間と精度を計測。
+executable bench-phase17
+  import:           warnings, opt, bench-gate
+  main-is:          BenchPhase17.hs
+  other-modules:    BenchUtil
+  hs-source-dirs:   bench/haskell
+  default-language: GHC2021
+  build-depends:
+      base                 >= 4.14 && < 5
+    , bytestring           >= 0.11 && < 0.13
+    , cassava              >= 0.5  && < 0.6
+    , hanalyze
+    , directory            >= 1.3  && < 1.4
+    , hmatrix              >= 0.20 && < 0.22
+    , mwc-random           >= 0.15 && < 0.16
+    , text                 >= 1.2  && < 2.2
+    , time                 >= 1.9  && < 1.13
+    , vector               >= 0.12 && < 0.14
+    , tasty-bench          >= 0.3  && < 0.5
+    , tasty                >= 1.4  && < 1.6
+
+-- Phase 27: JMP 同等性検証ベンチ。 文献例題 + JMP 公式 example の参照値と
+-- hanalyze 実装出力を golden CSV で比較する。 deterministic seed 固定。
+executable bench-custom-design
+  import:           warnings, opt, bench-gate
+  main-is:          BenchCustomDesign.hs
+  hs-source-dirs:   bench/haskell
+  default-language: GHC2021
+  build-depends:
+      base                 >= 4.14 && < 5
+    , bytestring           >= 0.11 && < 0.13
+    , cassava              >= 0.5  && < 0.6
+    , hanalyze
+    , directory            >= 1.3  && < 1.4
+    , hmatrix              >= 0.20 && < 0.22
+    , text                 >= 1.2  && < 2.2
+    , vector               >= 0.12 && < 0.14
+
+executable bench-tier12
+  import:           warnings, opt, bench-gate
+  main-is:          BenchTier12.hs
+  other-modules:    BenchUtil
+  hs-source-dirs:   bench/haskell
+  default-language: GHC2021
+  build-depends:
+      base                 >= 4.14 && < 5
+    , bytestring           >= 0.11 && < 0.13
+    , cassava              >= 0.5  && < 0.6
+    , hanalyze
+    , directory            >= 1.3  && < 1.4
+    , hmatrix              >= 0.20 && < 0.22
+    , mwc-random           >= 0.15 && < 0.16
+    , text                 >= 1.2  && < 2.2
+    , time                 >= 1.9  && < 1.13
+    , vector               >= 0.12 && < 0.14
+    , tasty-bench          >= 0.3  && < 0.5
+    , tasty                >= 1.4  && < 1.6
+
+-- Standalone bench: hmatrix vs massiv on pairwise squared distance
+-- (F4 evaluation, F5 multi-core Par evaluation).
+executable bench-massiv
+  import:           warnings, opt, bench-gate
+  main-is:          BenchMassiv.hs
+  hs-source-dirs:   bench/haskell
+  default-language: GHC2021
+  ghc-options:      -threaded -rtsopts -with-rtsopts=-N
+  build-depends:
+      base                 >= 4.14 && < 5
+    , hanalyze
+    , hmatrix              >= 0.20 && < 0.22
+    , massiv               >= 1.0  && < 1.1
+    , time                 >= 1.9  && < 1.13
+    , deepseq              >= 1.4  && < 1.6
+
+-- Multi-objective optimization bench (B4): NSGA-II on ZDT/DTLZ.
+executable bench-mo
+  import:           warnings, opt, bench-gate
+  main-is:          BenchMO.hs
+  other-modules:    BenchUtil
+  hs-source-dirs:   bench/haskell
+  default-language: GHC2021
+  build-depends:
+      base                 >= 4.14 && < 5
+    , bytestring           >= 0.11 && < 0.13
+    , cassava              >= 0.5  && < 0.6
+    , hanalyze
+    , hmatrix              >= 0.20 && < 0.22
+    , mwc-random           >= 0.15 && < 0.16
+    , text                 >= 1.2  && < 2.2
+    , time                 >= 1.9  && < 1.13
+    , vector               >= 0.12 && < 0.14
+    , tasty-bench          >= 0.3  && < 0.5
+    , tasty                >= 1.4  && < 1.6
+
+-- Standalone investigation: P37 Cheng-BB Beta sampler vs 2-Gamma + division.
+executable bench-beta-isolate
+  import:           warnings, opt, bench-gate
+  main-is:          BenchBetaIsolate.hs
+  hs-source-dirs:   bench/haskell
+  default-language: GHC2021
+  build-depends:
+      base                 >= 4.14 && < 5
+    , hanalyze
+    , mwc-random           >= 0.15 && < 0.16
+    , time                 >= 1.9  && < 1.13
+    , vector               >= 0.12 && < 0.14
+
+-- Standalone investigation: P40 Bootstrap resampling hot-path
+-- (uniformVector batch + GEMV row-sum vs naive index-loop).
+executable bench-bootstrap-isolate
+  import:           warnings, opt, bench-gate
+  main-is:          BenchBootstrapIsolate.hs
+  hs-source-dirs:   bench/haskell
+  default-language: GHC2021
+  build-depends:
+      base                 >= 4.14 && < 5
+    , hmatrix              >= 0.20 && < 0.22
+    , mwc-random           >= 0.15 && < 0.16
+    , time                 >= 1.9  && < 1.13
+    , vector               >= 0.12 && < 0.14
+
+-- Single-objective optimization bench (B3).
+executable bench-optim
+  import:           warnings, opt, bench-gate
+  main-is:          BenchOptim.hs
+  other-modules:    BenchUtil
+  hs-source-dirs:   bench/haskell
+  default-language: GHC2021
+  build-depends:
+      base                 >= 4.14 && < 5
+    , bytestring           >= 0.11 && < 0.13
+    , cassava              >= 0.5  && < 0.6
+    , hanalyze
+    , hmatrix              >= 0.20 && < 0.22
+    , mwc-random           >= 0.15 && < 0.16
+    , text                 >= 1.2  && < 2.2
+    , time                 >= 1.9  && < 1.13
+    , vector               >= 0.12 && < 0.14
+    , tasty-bench          >= 0.3  && < 0.5
+    , tasty                >= 1.4  && < 1.6
+
+-- OOM regression bench (Phase 11b): RFF.medianPairwiseDist + rbfKernelMat.
+executable bench-rff-oom
+  import:           warnings, opt, bench-gate
+  main-is:          BenchRFFOOM.hs
+  hs-source-dirs:   bench/haskell
+  default-language: GHC2021
+  ghc-options:      -rtsopts
+  build-depends:
+      base                 >= 4.14 && < 5
+    , hanalyze
+    , hmatrix              >= 0.20 && < 0.22
+    , time                 >= 1.9  && < 1.13
+
+executable bench-mem-vi
+  import:           warnings, opt, bench-gate
+  main-is:          BenchMemVI.hs
+  hs-source-dirs:   bench/haskell
+  default-language: GHC2021
+  ghc-options:      -rtsopts
+  build-depends:
+      base                 >= 4.14 && < 5
+    , hanalyze
+    , containers           >= 0.6  && < 0.8
+    , text                 >= 1.2  && < 2.2
+    , time                 >= 1.9  && < 1.13
+    , mwc-random           >= 0.15 && < 0.16
+
+executable bench-mem-aggregate
+  import:           warnings, opt, bench-gate
+  main-is:          BenchMemAggregate.hs
+  hs-source-dirs:   bench/haskell
+  default-language: GHC2021
+  ghc-options:      -rtsopts
+  build-depends:
+      base                 >= 4.14 && < 5
+    , hanalyze
+    , dataframe-core        ^>= 1.1
+    , dataframe-operations  >= 1.1.1 && < 1.2
+    , text                 >= 1.2  && < 2.2
+    , time                 >= 1.9  && < 1.13
+    , vector               >= 0.13 && < 0.14
+
+executable bench-mem-nsga2
+  import:           warnings, opt, bench-gate
+  main-is:          BenchMemNSGA.hs
+  hs-source-dirs:   bench/haskell
+  default-language: GHC2021
+  ghc-options:      -rtsopts
+  build-depends:
+      base                 >= 4.14 && < 5
+    , hanalyze
+    , time                 >= 1.9  && < 1.13
+    , mwc-random           >= 0.15 && < 0.16
+
+executable bench-mem-bo
+  import:           warnings, opt, bench-gate
+  main-is:          BenchMemBO.hs
+  hs-source-dirs:   bench/haskell
+  default-language: GHC2021
+  ghc-options:      -rtsopts
+  build-depends:
+      base                 >= 4.14 && < 5
+    , hanalyze
+    , time                 >= 1.9  && < 1.13
+    , mwc-random           >= 0.15 && < 0.16
+
+executable bench-mem-mcmc
+  import:           warnings, opt, bench-gate
+  main-is:          BenchMemMCMC.hs
+  hs-source-dirs:   bench/haskell
+  default-language: GHC2021
+  ghc-options:      -rtsopts
+  build-depends:
+      base                 >= 4.14 && < 5
+    , hanalyze
+    , containers           >= 0.6  && < 0.8
+    , text                 >= 1.2  && < 2.2
+    , time                 >= 1.9  && < 1.13
+    , mwc-random           >= 0.15 && < 0.16
+
+-- Kernel / GP bench (B2): KR / NW / RFF / GP / GPRobust.
+executable bench-kernel
+  import:           warnings, opt, bench-gate
+  main-is:          BenchKernel.hs
+  other-modules:    BenchUtil
+  hs-source-dirs:   bench/haskell
+  default-language: GHC2021
+  build-depends:
+      base                 >= 4.14 && < 5
+    , bytestring           >= 0.11 && < 0.13
+    , cassava              >= 0.5  && < 0.6
+    , hanalyze
+    , hmatrix              >= 0.20 && < 0.22
+    , mwc-random           >= 0.15 && < 0.16
+    , text                 >= 1.2  && < 2.2
+    , time                 >= 1.9  && < 1.13
+    , vector               >= 0.12 && < 0.14
+    , tasty-bench          >= 0.3  && < 0.5
+    , tasty                >= 1.4  && < 1.6
+
+-- ML bench (B6): PCA / KMeans / DecisionTree / RandomForest.
+executable bench-ml
+  import:           warnings, opt, bench-gate
+  main-is:          BenchML.hs
+  other-modules:    BenchUtil
+  hs-source-dirs:   bench/haskell
+  default-language: GHC2021
+  build-depends:
+      base                 >= 4.14 && < 5
+    , bytestring           >= 0.11 && < 0.13
+    , cassava              >= 0.5  && < 0.6
+    , hanalyze
+    , hmatrix              >= 0.20 && < 0.22
+    , mwc-random           >= 0.15 && < 0.16
+    , text                 >= 1.2  && < 2.2
+    , time                 >= 1.9  && < 1.13
+    , vector               >= 0.12 && < 0.14
+    , tasty-bench          >= 0.3  && < 0.5
+    , tasty                >= 1.4  && < 1.6
+
+-- Survival / TimeSeries bench (B8): ARIMA / Cox / KM / Quantile / GAM / Spline.
+executable bench-survts
+  import:           warnings, opt, bench-gate
+  main-is:          BenchSurvTS.hs
+  other-modules:    BenchUtil
+  hs-source-dirs:   bench/haskell
+  default-language: GHC2021
+  build-depends:
+      base                 >= 4.14 && < 5
+    , bytestring           >= 0.11 && < 0.13
+    , cassava              >= 0.5  && < 0.6
+    , hanalyze
+    , hmatrix              >= 0.20 && < 0.22
+    , random               >= 1.2  && < 1.4
+    , text                 >= 1.2  && < 2.2
+    , time                 >= 1.9  && < 1.13
+    , vector               >= 0.12 && < 0.14
+    , tasty-bench          >= 0.3  && < 0.5
+    , tasty                >= 1.4  && < 1.6
+
+-- MCMC diagnostic (B10b): explore why hanalyze NUTS ESS is poor.
+executable bench-mcmc-diag
+  import:           warnings, opt, bench-gate
+  main-is:          BenchMCMCDiag.hs
+  other-modules:    BenchUtil
+  hs-source-dirs:   bench/haskell
+  default-language: GHC2021
+  build-depends:
+      base                 >= 4.14 && < 5
+    , bytestring           >= 0.11 && < 0.13
+    , cassava              >= 0.5  && < 0.6
+    , containers           >= 0.6  && < 0.8
+    , hanalyze
+    , hmatrix              >= 0.20 && < 0.22
+    , mwc-random           >= 0.15 && < 0.16
+    , text                 >= 1.2  && < 2.2
+    , time                 >= 1.9  && < 1.13
+    , vector               >= 0.12 && < 0.14
+    , tasty-bench          >= 0.3  && < 0.5
+    , tasty                >= 1.4  && < 1.6
+
+-- MCMC bench (B7): HMC / NUTS on hierarchical normal model.
+executable bench-mcmc-b7
+  import:           warnings, opt, bench-gate
+  main-is:          BenchMCMCB7.hs
+  other-modules:    BenchUtil
+  hs-source-dirs:   bench/haskell
+  default-language: GHC2021
+  build-depends:
+      base                 >= 4.14 && < 5
+    , bytestring           >= 0.11 && < 0.13
+    , cassava              >= 0.5  && < 0.6
+    , containers           >= 0.6  && < 0.8
+    , hanalyze
+    , hmatrix              >= 0.20 && < 0.22
+    , mwc-random           >= 0.15 && < 0.16
+    , text                 >= 1.2  && < 2.2
+    , time                 >= 1.9  && < 1.13
+    , vector               >= 0.12 && < 0.14
+    , tasty-bench          >= 0.3  && < 0.5
+    , tasty                >= 1.4  && < 1.6
+
+-- HBM サンプラ性能スケーリング (NUTS vs PyMC・iter 掃き)。
+executable bench-hbm-scaling
+  import:           warnings, opt, bench-gate
+  main-is:          BenchHBMScaling.hs
+  other-modules:    BenchUtil
+  hs-source-dirs:   bench/haskell
+  default-language: GHC2021
+  build-depends:
+      base                 >= 4.14 && < 5
+    , bytestring           >= 0.11 && < 0.13
+    , cassava              >= 0.5  && < 0.6
+    , containers           >= 0.6  && < 0.8
+    , hanalyze
+    , hmatrix              >= 0.20 && < 0.22
+    , mwc-random           >= 0.15 && < 0.16
+    , text                 >= 1.2  && < 2.2
+    , time                 >= 1.9  && < 1.13
+    , vector               >= 0.12 && < 0.14
+    , tasty-bench          >= 0.3  && < 0.5
+    , tasty                >= 1.4  && < 1.6
+
+-- Phase 54.4a: ハイブリッド gradADU (vec-tape ObserveLM) per-call 計測。
+executable bench-hbm-54a
+  import:           warnings, opt, bench-gate
+  main-is:          BenchHBM54a.hs
+  other-modules:    BenchUtil
+  hs-source-dirs:   bench/haskell
+  default-language: GHC2021
+  build-depends:
+      base                 >= 4.14 && < 5
+    , bytestring           >= 0.11 && < 0.13
+    , cassava              >= 0.5  && < 0.6
+    , containers           >= 0.6  && < 0.8
+    , hanalyze
+    , hmatrix              >= 0.20 && < 0.22
+    , mwc-random           >= 0.15 && < 0.16
+    , text                 >= 1.2  && < 2.2
+    , time                 >= 1.9  && < 1.13
+    , vector               >= 0.12 && < 0.14
+    , tasty-bench          >= 0.3  && < 0.5
+    , tasty                >= 1.4  && < 1.6
+
+-- Phase 53: HBM 勾配ボトルネック診断 (forward vs reverse AD)。
+executable bench-hbm-profile
+  import:           warnings, opt, bench-gate
+  main-is:          BenchHBMProfile.hs
+  other-modules:    BenchUtil
+  hs-source-dirs:   bench/haskell
+  default-language: GHC2021
+  build-depends:
+      base                 >= 4.14 && < 5
+    , bytestring           >= 0.11 && < 0.13
+    , cassava              >= 0.5  && < 0.6
+    , containers           >= 0.6  && < 0.8
+    , hanalyze
+    , hmatrix              >= 0.20 && < 0.22
+    , mwc-random           >= 0.15 && < 0.16
+    , text                 >= 1.2  && < 2.2
+    , time                 >= 1.9  && < 1.13
+    , vector               >= 0.12 && < 0.14
+    , tasty-bench          >= 0.3  && < 0.5
+    , tasty                >= 1.4  && < 1.6
+
+-- Phase 85.1: radon 相関モデルの gradVecIR per-eval 内訳プロファイル。
+executable bench-hbm-vecir-prof
+  import:           warnings, opt, bench-gate
+  main-is:          BenchHBMVecIRProf.hs
+  other-modules:    BenchUtil
+  hs-source-dirs:   bench/haskell
+  default-language: GHC2021
+  build-depends:
+      base                 >= 4.14 && < 5
+    , ad                   >= 4.4  && < 4.6
+    , bytestring           >= 0.11 && < 0.13
+    , cassava              >= 0.5  && < 0.6
+    , containers           >= 0.6  && < 0.8
+    , hanalyze
+    , hmatrix              >= 0.20 && < 0.22
+    , mwc-random           >= 0.15 && < 0.16
+    , text                 >= 1.2  && < 2.2
+    , time                 >= 1.9  && < 1.13
+    , vector               >= 0.12 && < 0.14
+    , tasty-bench          >= 0.3  && < 0.5
+    , tasty                >= 1.4  && < 1.6
+
+-- Phase 85.3a: vecIR 融合方式の feasibility spike (synthetic chain)。
+executable bench-hbm-fuse-spike
+  import:           warnings, opt, bench-gate
+  main-is:          BenchHBMFuseSpike.hs
+  other-modules:    BenchUtil
+  hs-source-dirs:   bench/haskell
+  default-language: GHC2021
+  build-depends:
+      base                 >= 4.14 && < 5
+    , bytestring           >= 0.11 && < 0.13
+    , cassava              >= 0.5  && < 0.6
+    , containers           >= 0.6  && < 0.8
+    , hanalyze
+    , hmatrix              >= 0.20 && < 0.22
+    , mwc-random           >= 0.15 && < 0.16
+    , text                 >= 1.2  && < 2.2
+    , time                 >= 1.9  && < 1.13
+    , vector               >= 0.12 && < 0.14
+    , tasty-bench          >= 0.3  && < 0.5
+    , tasty                >= 1.4  && < 1.6
+
+-- Phase 85.5: M2 単独 NUTS の A/B 計測 (+RTS -s で alloc/GC 突合・rtsopts 付)。
+executable bench-m2-iso
+  import:           warnings, opt, bench-gate
+  main-is:          BenchM2Iso.hs
+  other-modules:    BenchUtil
+  hs-source-dirs:   bench/haskell
+  default-language: GHC2021
+  ghc-options:      -rtsopts
+  build-depends:
+      base                 >= 4.14 && < 5
+    , bytestring           >= 0.11 && < 0.13
+    , cassava              >= 0.5  && < 0.6
+    , containers           >= 0.6  && < 0.8
+    , hanalyze
+    , hmatrix              >= 0.20 && < 0.22
+    , mwc-random           >= 0.15 && < 0.16
+    , text                 >= 1.2  && < 2.2
+    , time                 >= 1.9  && < 1.13
+    , vector               >= 0.12 && < 0.14
+    , tasty-bench          >= 0.3  && < 0.5
+    , tasty                >= 1.4  && < 1.6
+
+-- Phase 85.6a: warmup 固定費の内訳プロファイル (radon)。
+executable bench-warmup-prof
+  import:           warnings, opt, bench-gate
+  main-is:          BenchWarmupProf.hs
+  other-modules:    BenchUtil
+  hs-source-dirs:   bench/haskell
+  default-language: GHC2021
+  build-depends:
+      base                 >= 4.14 && < 5
+    , bytestring           >= 0.11 && < 0.13
+    , cassava              >= 0.5  && < 0.6
+    , containers           >= 0.6  && < 0.8
+    , hanalyze
+    , hmatrix              >= 0.20 && < 0.22
+    , mwc-random           >= 0.15 && < 0.16
+    , text                 >= 1.2  && < 2.2
+    , time                 >= 1.9  && < 1.13
+    , vector               >= 0.12 && < 0.14
+    , tasty-bench          >= 0.3  && < 0.5
+    , tasty                >= 1.4  && < 1.6
+
+-- Phase 89: posteriordb 横断ベンチマーク・モデル別実行体 (1 モデル = 1 exe)。
+-- hgg (dashboardFullOf の PNG 出力) を使うため plot-integration
+-- flag 配下 (`cabal build --project-file=cabal.project.plot`)。
+executable posteriordb-glm-poisson
+  import:           warnings
+  main-is:          Model.hs
+  other-modules:    Common
+  hs-source-dirs:   bench/posteriordb/01-glm-poisson, bench/posteriordb
+  default-language: GHC2021
+  ghc-options:      -rtsopts
+  if flag(plot-integration)
+    build-depends:
+        base
+      , aeson
+      , text
+      , vector
+      , hanalyze
+      , hgg-core
+      , hgg-frame
+      , hgg-rasterific
+      , time
+      , deepseq
+  else
+    buildable: False
+
+executable posteriordb-dogs
+  import:           warnings
+  main-is:          Model.hs
+  other-modules:    Common
+  hs-source-dirs:   bench/posteriordb/02-dogs, bench/posteriordb
+  default-language: GHC2021
+  ghc-options:      -rtsopts
+  if flag(plot-integration)
+    build-depends:
+        base
+      , aeson
+      , text
+      , vector
+      , hanalyze
+      , hgg-core
+      , hgg-frame
+      , hgg-rasterific
+      , time
+      , deepseq
+  else
+    buildable: False
+
+executable posteriordb-garch11
+  import:           warnings
+  main-is:          Model.hs
+  other-modules:    Common
+  hs-source-dirs:   bench/posteriordb/03-garch11, bench/posteriordb
+  default-language: GHC2021
+  ghc-options:      -rtsopts
+  if flag(plot-integration)
+    build-depends:
+        base
+      , aeson
+      , text
+      , vector
+      , hanalyze
+      , hgg-core
+      , hgg-frame
+      , hgg-rasterific
+      , time
+      , deepseq
+  else
+    buildable: False
+
+executable posteriordb-eight-schools
+  import:           warnings
+  main-is:          Model.hs
+  other-modules:    Common
+  hs-source-dirs:   bench/posteriordb/09-eight-schools, bench/posteriordb
+  default-language: GHC2021
+  ghc-options:      -rtsopts
+  if flag(plot-integration)
+    build-depends:
+        base
+      , aeson
+      , text
+      , vector
+      , hanalyze
+      , hgg-core
+      , hgg-frame
+      , hgg-rasterific
+      , time
+      , deepseq
+  else
+    buildable: False
+
+executable posteriordb-gp-regr
+  import:           warnings
+  main-is:          Model.hs
+  other-modules:    Common
+  hs-source-dirs:   bench/posteriordb/07-gp-regr, bench/posteriordb
+  default-language: GHC2021
+  ghc-options:      -rtsopts
+  if flag(plot-integration)
+    build-depends:
+        base
+      , aeson
+      , text
+      , vector
+      , hanalyze
+      , hgg-core
+      , hgg-frame
+      , hgg-rasterific
+      , time
+      , deepseq
+  else
+    buildable: False
+
+executable posteriordb-low-dim-gauss-mix
+  import:           warnings
+  main-is:          Model.hs
+  other-modules:    Common
+  hs-source-dirs:   bench/posteriordb/04-low-dim-gauss-mix, bench/posteriordb
+  default-language: GHC2021
+  ghc-options:      -rtsopts
+  if flag(plot-integration)
+    build-depends:
+        base
+      , aeson
+      , text
+      , vector
+      , containers
+      , hanalyze
+      , hgg-core
+      , hgg-frame
+      , hgg-rasterific
+      , time
+      , deepseq
+  else
+    buildable: False
+
+executable posteriordb-mh
+  import:           warnings
+  main-is:          Model.hs
+  other-modules:    Common
+  hs-source-dirs:   bench/posteriordb/05-mh, bench/posteriordb
+  default-language: GHC2021
+  ghc-options:      -rtsopts
+  if flag(plot-integration)
+    build-depends:
+        base
+      , aeson
+      , text
+      , vector
+      , hanalyze
+      , hgg-core
+      , hgg-frame
+      , hgg-rasterific
+      , time
+      , deepseq
+  else
+    buildable: False
+
+executable posteriordb-irt-2pl
+  import:           warnings
+  main-is:          Model.hs
+  other-modules:    Common
+  hs-source-dirs:   bench/posteriordb/06-irt-2pl, bench/posteriordb
+  default-language: GHC2021
+  ghc-options:      -rtsopts
+  if flag(plot-integration)
+    build-depends:
+        base
+      , aeson
+      , text
+      , vector
+      , hanalyze
+      , hgg-core
+      , hgg-frame
+      , hgg-rasterific
+      , time
+      , deepseq
+  else
+    buildable: False
+
+executable posteriordb-rats
+  import:           warnings
+  main-is:          Model.hs
+  other-modules:    Common
+  hs-source-dirs:   bench/posteriordb/10-rats, bench/posteriordb
+  default-language: GHC2021
+  ghc-options:      -rtsopts
+  if flag(plot-integration)
+    build-depends:
+        base
+      , aeson
+      , text
+      , vector
+      , hanalyze
+      , hgg-core
+      , hgg-frame
+      , hgg-rasterific
+      , time
+      , deepseq
+  else
+    buildable: False
+
+executable posteriordb-seeds
+  import:           warnings
+  main-is:          Model.hs
+  other-modules:    Common
+  hs-source-dirs:   bench/posteriordb/11-seeds, bench/posteriordb
+  default-language: GHC2021
+  ghc-options:      -rtsopts
+  if flag(plot-integration)
+    build-depends:
+        base
+      , aeson
+      , text
+      , vector
+      , hanalyze
+      , hgg-core
+      , hgg-frame
+      , hgg-rasterific
+      , time
+      , deepseq
+  else
+    buildable: False
+
+executable posteriordb-ark
+  import:           warnings
+  main-is:          Model.hs
+  other-modules:    Common
+  hs-source-dirs:   bench/posteriordb/12-ark, bench/posteriordb
+  default-language: GHC2021
+  ghc-options:      -rtsopts
+  if flag(plot-integration)
+    build-depends:
+        base
+      , aeson
+      , text
+      , vector
+      , hanalyze
+      , hgg-core
+      , hgg-frame
+      , hgg-rasterific
+      , time
+      , deepseq
+  else
+    buildable: False
+
+executable posteriordb-bym2
+  import:           warnings
+  main-is:          Model.hs
+  other-modules:    Common
+  hs-source-dirs:   bench/posteriordb/13-traffic-accident-nyc, bench/posteriordb
+  default-language: GHC2021
+  ghc-options:      -rtsopts
+  if flag(plot-integration)
+    build-depends:
+        base
+      , aeson
+      , containers
+      , text
+      , vector
+      , hanalyze
+      , hgg-core
+      , hgg-frame
+      , hgg-rasterific
+      , time
+      , deepseq
+  else
+    buildable: False
+
+executable posteriordb-hmm
+  import:           warnings
+  main-is:          Model.hs
+  other-modules:    Common
+  hs-source-dirs:   bench/posteriordb/14-hmm-example, bench/posteriordb
+  default-language: GHC2021
+  ghc-options:      -rtsopts
+  if flag(plot-integration)
+    build-depends:
+        base
+      , aeson
+      , text
+      , vector
+      , hanalyze
+      , hgg-core
+      , hgg-frame
+      , hgg-rasterific
+      , time
+      , deepseq
+  else
+    buildable: False
+
+executable posteriordb-dugongs
+  import:           warnings
+  main-is:          Model.hs
+  other-modules:    Common
+  hs-source-dirs:   bench/posteriordb/15-dugongs, bench/posteriordb
+  default-language: GHC2021
+  ghc-options:      -rtsopts
+  if flag(plot-integration)
+    build-depends:
+        base
+      , aeson
+      , text
+      , vector
+      , hanalyze
+      , hgg-core
+      , hgg-frame
+      , hgg-rasterific
+      , time
+      , deepseq
+  else
+    buildable: False
+
+executable posteriordb-lda
+  import:           warnings
+  main-is:          Model.hs
+  other-modules:    Common
+  hs-source-dirs:   bench/posteriordb/16-lda, bench/posteriordb
+  default-language: GHC2021
+  ghc-options:      -rtsopts
+  if flag(plot-integration)
+    build-depends:
+        base
+      , aeson
+      , text
+      , vector
+      , hanalyze
+      , hgg-core
+      , hgg-frame
+      , hgg-rasterific
+      , time
+      , deepseq
+  else
+    buildable: False
+
+executable posteriordb-nes
+  import:           warnings
+  main-is:          Model.hs
+  other-modules:    Common
+  hs-source-dirs:   bench/posteriordb/17-nes, bench/posteriordb
+  default-language: GHC2021
+  ghc-options:      -rtsopts
+  if flag(plot-integration)
+    build-depends:
+        base
+      , aeson
+      , text
+      , vector
+      , hanalyze
+      , hgg-core
+      , hgg-frame
+      , hgg-rasterific
+      , time
+      , deepseq
+  else
+    buildable: False
+
+executable posteriordb-loss-curves
+  import:           warnings
+  main-is:          Model.hs
+  other-modules:    Common
+  hs-source-dirs:   bench/posteriordb/18-loss-curves, bench/posteriordb
+  default-language: GHC2021
+  ghc-options:      -rtsopts
+  if flag(plot-integration)
+    build-depends:
+        base
+      , aeson
+      , text
+      , vector
+      , hanalyze
+      , hgg-core
+      , hgg-frame
+      , hgg-rasterific
+      , time
+      , deepseq
+  else
+    buildable: False
+
+executable posteriordb-surgical
+  import:           warnings
+  main-is:          Model.hs
+  other-modules:    Common
+  hs-source-dirs:   bench/posteriordb/19-surgical, bench/posteriordb
+  default-language: GHC2021
+  ghc-options:      -rtsopts
+  if flag(plot-integration)
+    build-depends:
+        base
+      , aeson
+      , text
+      , vector
+      , hanalyze
+      , hgg-core
+      , hgg-frame
+      , hgg-rasterific
+      , time
+      , deepseq
+  else
+    buildable: False
+
+executable posteriordb-bones
+  import:           warnings
+  main-is:          Model.hs
+  other-modules:    Common
+  hs-source-dirs:   bench/posteriordb/20-bones, bench/posteriordb
+  default-language: GHC2021
+  ghc-options:      -rtsopts
+  if flag(plot-integration)
+    build-depends:
+        base
+      , aeson
+      , text
+      , vector
+      , hanalyze
+      , hgg-core
+      , hgg-frame
+      , hgg-rasterific
+      , time
+      , deepseq
+  else
+    buildable: False
+
+executable posteriordb-radon
+  import:           warnings
+  main-is:          Model.hs
+  other-modules:    Common
+  hs-source-dirs:   bench/posteriordb/21-radon, bench/posteriordb
+  default-language: GHC2021
+  ghc-options:      -rtsopts
+  if flag(plot-integration)
+    build-depends:
+        base
+      , aeson
+      , text
+      , vector
+      , hanalyze
+      , hgg-core
+      , hgg-frame
+      , hgg-rasterific
+      , time
+      , deepseq
+  else
+    buildable: False
+
+executable posteriordb-arma
+  import:           warnings
+  main-is:          Model.hs
+  other-modules:    Common
+  hs-source-dirs:   bench/posteriordb/22-arma, bench/posteriordb
+  default-language: GHC2021
+  ghc-options:      -rtsopts
+  if flag(plot-integration)
+    build-depends:
+        base
+      , aeson
+      , text
+      , vector
+      , hanalyze
+      , hgg-core
+      , hgg-frame
+      , hgg-rasterific
+      , time
+      , deepseq
+  else
+    buildable: False
+
+-- Phase 53 追加調査: NUTS コストセンタ・プロファイル用 (+RTS -p)。
+executable prof-nuts
+  import:           warnings, opt, bench-gate
+  main-is:          ProfNUTS.hs
+  hs-source-dirs:   bench/haskell
+  default-language: GHC2021
+  ghc-options:      -rtsopts
+  build-depends:
+      base                 >= 4.14 && < 5
+    , containers           >= 0.6  && < 0.8
+    , hanalyze
+    , mwc-random           >= 0.15 && < 0.16
+    , text                 >= 1.2  && < 2.2
+    , vector               >= 0.12 && < 0.14
+
+-- Phase 55.3: heteroscedastic σ 式モデルの per-call 勾配 A/B (IR 吸収 vs 旧 fallback)。
+-- Phase 56.6: 観測分布ごとの per-call 勾配 A/B (bench-hbm-het の一般化)。
+executable bench-hbm-dist
+  import:           warnings, opt, bench-gate
+  main-is:          BenchHBMDist.hs
+  other-modules:    BenchUtil
+  hs-source-dirs:   bench/haskell
+  default-language: GHC2021
+  build-depends:
+      base                 >= 4.14 && < 5
+    , bytestring           >= 0.11 && < 0.13
+    , cassava              >= 0.5  && < 0.6
+    , containers           >= 0.6  && < 0.8
+    , hanalyze
+    , ad                   >= 4.4  && < 4.6
+    , hmatrix              >= 0.20 && < 0.22
+    , mwc-random           >= 0.15 && < 0.16
+    , text                 >= 1.2  && < 2.2
+    , time                 >= 1.9  && < 1.13
+    , vector               >= 0.12 && < 0.14
+    , tasty-bench          >= 0.3  && < 0.5
+    , tasty                >= 1.4  && < 1.6
+
+executable bench-hbm-het
+  import:           warnings, opt, bench-gate
+  main-is:          BenchHBMHet.hs
+  other-modules:    BenchUtil
+  hs-source-dirs:   bench/haskell
+  default-language: GHC2021
+  build-depends:
+      base                 >= 4.14 && < 5
+    , bytestring           >= 0.11 && < 0.13
+    , cassava              >= 0.5  && < 0.6
+    , containers           >= 0.6  && < 0.8
+    , hanalyze
+    , ad                   >= 4.4  && < 4.6
+    , hmatrix              >= 0.20 && < 0.22
+    , mwc-random           >= 0.15 && < 0.16
+    , text                 >= 1.2  && < 2.2
+    , time                 >= 1.9  && < 1.13
+    , vector               >= 0.12 && < 0.14
+    , tasty-bench          >= 0.3  && < 0.5
+    , tasty                >= 1.4  && < 1.6
+
+-- Phase 54.11 spike: 非線形 μ の vec-tape (手組みベクトル式 IR) ゲート判定。
+executable bench-hbm-vecir
+  import:           warnings, opt, bench-gate
+  main-is:          BenchHBMVecIRSpike.hs
+  other-modules:    BenchUtil
+  hs-source-dirs:   bench/haskell
+  default-language: GHC2021
+  build-depends:
+      base                 >= 4.14 && < 5
+    , bytestring           >= 0.11 && < 0.13
+    , cassava              >= 0.5  && < 0.6
+    , containers           >= 0.6  && < 0.8
+    , hanalyze
+    , ad                   >= 4.4  && < 4.6
+    , hmatrix              >= 0.20 && < 0.22
+    , mwc-random           >= 0.15 && < 0.16
+    , text                 >= 1.2  && < 2.2
+    , time                 >= 1.9  && < 1.13
+    , vector               >= 0.12 && < 0.14
+    , tasty-bench          >= 0.3  && < 0.5
+    , tasty                >= 1.4  && < 1.6
+
+-- Phase 53 追加調査: AD モード比較 (forward/reverse/reverse.double/kahn)。
+executable bench-hbm-admodes
+  import:           warnings, opt, bench-gate
+  main-is:          BenchHBMADModes.hs
+  other-modules:    BenchUtil
+  hs-source-dirs:   bench/haskell
+  default-language: GHC2021
+  build-depends:
+      base                 >= 4.14 && < 5
+    , bytestring           >= 0.11 && < 0.13
+    , cassava              >= 0.5  && < 0.6
+    , containers           >= 0.6  && < 0.8
+    , hanalyze
+    , ad                   >= 4.4  && < 4.6
+    , hmatrix              >= 0.20 && < 0.22
+    , mwc-random           >= 0.15 && < 0.16
+    , text                 >= 1.2  && < 2.2
+    , time                 >= 1.9  && < 1.13
+    , vector               >= 0.12 && < 0.14
+    , tasty-bench          >= 0.3  && < 0.5
+    , tasty                >= 1.4  && < 1.6
+
+-- Phase 54.0 feasibility spike: 観測尤度ベクトル化 × Reverse.Double 勾配保存/per-grad 計測。
+executable bench-hbm-vecspike
+  import:           warnings, opt, bench-gate
+  main-is:          BenchHBMVecSpike.hs
+  other-modules:    BenchUtil
+  hs-source-dirs:   bench/haskell
+  default-language: GHC2021
+  build-depends:
+      base                 >= 4.14 && < 5
+    , bytestring           >= 0.11 && < 0.13
+    , cassava              >= 0.5  && < 0.6
+    , containers           >= 0.6  && < 0.8
+    , hanalyze
+    , ad                   >= 4.4  && < 4.6
+    , hmatrix              >= 0.20 && < 0.22
+    , mwc-random           >= 0.15 && < 0.16
+    , text                 >= 1.2  && < 2.2
+    , time                 >= 1.9  && < 1.13
+    , vector               >= 0.12 && < 0.14
+    , tasty-bench          >= 0.3  && < 0.5
+    , tasty                >= 1.4  && < 1.6
+
+-- Phase 54 専用ベクトル化 AD feasibility spike: ad vs 手書きベクトル化解析勾配。
+executable bench-hbm-vecad
+  import:           warnings, opt, bench-gate
+  main-is:          BenchHBMVecADSpike.hs
+  other-modules:    BenchUtil
+  hs-source-dirs:   bench/haskell
+  default-language: GHC2021
+  build-depends:
+      base                 >= 4.14 && < 5
+    , bytestring           >= 0.11 && < 0.13
+    , cassava              >= 0.5  && < 0.6
+    , containers           >= 0.6  && < 0.8
+    , hanalyze
+    , ad                   >= 4.4  && < 4.6
+    , array                >= 0.5  && < 0.6
+    , backprop             >= 0.2  && < 0.3
+    , hmatrix              >= 0.20 && < 0.22
+    , mwc-random           >= 0.15 && < 0.16
+    , text                 >= 1.2  && < 2.2
+    , time                 >= 1.9  && < 1.13
+    , vector               >= 0.12 && < 0.14
+    , tasty-bench          >= 0.3  && < 0.5
+    , tasty                >= 1.4  && < 1.6
+
+-- B7 残: Gibbs / ADVI / WAIC.
+executable bench-mcmc-extras
+  import:           warnings, opt, bench-gate
+  main-is:          BenchMCMCExtras.hs
+  other-modules:    BenchUtil
+  hs-source-dirs:   bench/haskell
+  default-language: GHC2021
+  build-depends:
+      base                 >= 4.14 && < 5
+    , bytestring           >= 0.11 && < 0.13
+    , cassava              >= 0.5  && < 0.6
+    , containers           >= 0.6  && < 0.8
+    , hanalyze
+    , hmatrix              >= 0.20 && < 0.22
+    , mwc-random           >= 0.15 && < 0.16
+    , text                 >= 1.2  && < 2.2
+    , time                 >= 1.9  && < 1.13
+    , vector               >= 0.12 && < 0.14
+    , tasty-bench          >= 0.3  && < 0.5
+    , tasty                >= 1.4  && < 1.6
+
+-- B13 Regrid: regridLong on a jagged long-form fixture.
+executable bench-regrid
+  import:           warnings, opt, bench-gate
+  main-is:          BenchRegrid.hs
+  other-modules:    BenchUtil
+  hs-source-dirs:   bench/haskell
+  default-language: GHC2021
+  build-depends:
+      base                 >= 4.14 && < 5
+    , bytestring           >= 0.11 && < 0.13
+    , cassava              >= 0.5  && < 0.6
+    , dataframe-operations  >= 1.1.1 && < 1.2
+    , hanalyze
+    , hmatrix              >= 0.20 && < 0.22
+    , time                 >= 1.9  && < 1.13
+    , vector               >= 0.12 && < 0.14
+    , tasty-bench          >= 0.3  && < 0.5
+    , tasty                >= 1.4  && < 1.6
+
+-- B12 Multi-output: MultiLM / MultiGP.
+executable bench-multi-output
+  import:           warnings, opt, bench-gate
+  main-is:          BenchMultiOutput.hs
+  other-modules:    BenchUtil
+  hs-source-dirs:   bench/haskell
+  default-language: GHC2021
+  build-depends:
+      base                 >= 4.14 && < 5
+    , bytestring           >= 0.11 && < 0.13
+    , cassava              >= 0.5  && < 0.6
+    , hanalyze
+    , hmatrix              >= 0.20 && < 0.22
+    , time                 >= 1.9  && < 1.13
+    , vector               >= 0.12 && < 0.14
+    , tasty-bench          >= 0.3  && < 0.5
+    , tasty                >= 1.4  && < 1.6
+
+-- B10 Stat util: Bootstrap / t-test / KS / MW / BH / Halton / AUC / k-fold.
+executable bench-stat-util
+  import:           warnings, opt, bench-gate
+  main-is:          BenchStatUtil.hs
+  other-modules:    BenchUtil
+  hs-source-dirs:   bench/haskell
+  default-language: GHC2021
+  build-depends:
+      base                 >= 4.14 && < 5
+    , bytestring           >= 0.11 && < 0.13
+    , cassava              >= 0.5  && < 0.6
+    , hanalyze
+    , hmatrix              >= 0.20 && < 0.22
+    , mwc-random           >= 0.15 && < 0.16
+    , time                 >= 1.9  && < 1.13
+    , vector               >= 0.12 && < 0.14
+    , tasty-bench          >= 0.3  && < 0.5
+    , tasty                >= 1.4  && < 1.6
+
+-- B9 Optim+: Constrained / Adam / CMAESFull.
+executable bench-optim-plus
+  import:           warnings, opt, bench-gate
+  main-is:          BenchOptimPlus.hs
+  other-modules:    BenchUtil
+  hs-source-dirs:   bench/haskell
+  default-language: GHC2021
+  build-depends:
+      base                 >= 4.14 && < 5
+    , bytestring           >= 0.11 && < 0.13
+    , cassava              >= 0.5  && < 0.6
+    , hanalyze
+    , hmatrix              >= 0.20 && < 0.22
+    , mwc-random           >= 0.15 && < 0.16
+    , time                 >= 1.9  && < 1.13
+    , vector               >= 0.12 && < 0.14
+    , tasty-bench          >= 0.3  && < 0.5
+    , tasty                >= 1.4  && < 1.6
+
+-- B8 残: Holt-Winters / GAM / Spline 補間.
+executable bench-ts-extras
+  import:           warnings, opt, bench-gate
+  main-is:          BenchTSExtras.hs
+  other-modules:    BenchUtil
+  hs-source-dirs:   bench/haskell
+  default-language: GHC2021
+  build-depends:
+      base                 >= 4.14 && < 5
+    , bytestring           >= 0.11 && < 0.13
+    , cassava              >= 0.5  && < 0.6
+    , hanalyze
+    , hmatrix              >= 0.20 && < 0.22
+    , time                 >= 1.9  && < 1.13
+    , vector               >= 0.12 && < 0.14
+    , tasty-bench          >= 0.3  && < 0.5
+    , tasty                >= 1.4  && < 1.6
+
+-- Regression bench (B1): LM / GLM / GLMM / Ridge / Lasso / ElasticNet.
+executable bench-regression
+  import:           warnings, opt, bench-gate
+  main-is:          BenchRegression.hs
+  other-modules:    BenchUtil
+  hs-source-dirs:   bench/haskell
+  default-language: GHC2021
+  build-depends:
+      base                 >= 4.14 && < 5
+    , bytestring           >= 0.11 && < 0.13
+    , cassava              >= 0.5  && < 0.6
+    , hanalyze
+    , hmatrix              >= 0.20 && < 0.22
+    , text                 >= 1.2  && < 2.2
+    , time                 >= 1.9  && < 1.13
+    , vector               >= 0.12 && < 0.14
+    , tasty-bench          >= 0.3  && < 0.5
+    , tasty                >= 1.4  && < 1.6
+
+executable bench-profile
+  import:           warnings, opt, bench-gate
+  main-is:          BenchProfile.hs
+  other-modules:    BenchUtil
+  hs-source-dirs:   bench/haskell
+  default-language: GHC2021
+  ghc-options:      -rtsopts "-with-rtsopts=-T"
+  build-depends:
+      base                 >= 4.14 && < 5
+    , bytestring           >= 0.11 && < 0.13
+    , cassava              >= 0.5  && < 0.6
+    , deepseq              >= 1.4  && < 1.6
+    , hanalyze
+    , hmatrix              >= 0.20 && < 0.22
+    , text                 >= 1.2  && < 2.2
+    , time                 >= 1.9  && < 1.13
+    , vector               >= 0.12 && < 0.14
+    , tasty-bench          >= 0.3  && < 0.5
+    , tasty                >= 1.4  && < 1.6
+
+executable bench-tasty
+  import:           warnings, opt, bench-gate
+  main-is:          BenchTasty.hs
+  other-modules:    BenchUtil
+  hs-source-dirs:   bench/haskell
+  default-language: GHC2021
+  build-depends:
+      base                 >= 4.14 && < 5
+    , bytestring           >= 0.11 && < 0.13
+    , cassava              >= 0.5  && < 0.6
+    , hanalyze
+    , hmatrix              >= 0.20 && < 0.22
+    , tasty-bench          >= 0.3  && < 0.5
+    , tasty                >= 1.4  && < 1.6
+    , text                 >= 1.2  && < 2.2
+    , time                 >= 1.9  && < 1.13
+    , vector               >= 0.12 && < 0.14
+
+test-suite hanalyze-test
+  import:           warnings, opt
+  type:             exitcode-stdio-1.0
+  main-is:          Spec.hs
+  hs-source-dirs:   test
+  default-language: GHC2021
+  -- WorkflowSpec の一部診断テスト (tracesOf / MultiVarModel 事後予測帯) は plot 連携層
+  -- 依存ゆえ CPP (#ifdef PLOT_INTEGRATION) で囲む。 flag on のときだけ define し compile。
+  if flag(plot-integration)
+    cpp-options: -DPLOT_INTEGRATION
+  build-depends:
+      base       >= 4.14 && < 5
+    , hanalyze
+    , hspec      >= 2.10 && < 2.12
+    , QuickCheck >= 2.14 && < 2.16
+    , vector     >= 0.12 && < 0.14
+    , mwc-random >= 0.15 && < 0.16
+    , text       >= 1.2  && < 2.2
+    , dataframe-core        ^>= 1.1
+    , dataframe-operations  >= 1.1.1 && < 1.2
+    , temporary  >= 1.3  && < 1.4
+    , bytestring >= 0.11 && < 0.13
+    , hmatrix    >= 0.20 && < 0.22
+    , containers >= 0.6  && < 0.8
+    , ad         >= 4.4  && < 4.6
+  build-tool-depends:
+    hspec-discover:hspec-discover >= 2.10 && < 2.12
+  -- per-module Spec 群 (hspec-discover が Spec.hs から自動収集)。
+  -- SpecHelper は共通 helper / orphan Arbitrary instance。
+  other-modules:
+      Hanalyze.Data.FactorSpec
+    , Hanalyze.Data.StringsSpec
+    , Hanalyze.Data.TransformSpec
+    , Hanalyze.Data.WrangleSpec
+    , Hanalyze.DataIO.CSVSpec
+    , Hanalyze.DataIO.ConvertSpec
+    , Hanalyze.DataIO.HealthSpec
+    , Hanalyze.DataIO.LogSpec
+    , Hanalyze.DataIO.PreprocessSpec
+    , Hanalyze.DataIO.ReshapeSpec
+    , Hanalyze.Design.ConstraintSpec
+    , Hanalyze.Design.Custom.AugmentSpec
+    , Hanalyze.Design.Custom.BayesianSpec
+    , Hanalyze.Design.Custom.CompareSpec
+    , Hanalyze.Design.Custom.CoordinateSpec
+    , Hanalyze.Design.Custom.FactorSpec
+    , Hanalyze.Design.Custom.GoldenSpec
+    , Hanalyze.Design.Custom.PowerSpec
+    , Hanalyze.Design.Custom.SplitPlotSpec
+    , Hanalyze.Design.DSDSpec
+    , Hanalyze.Design.DiagnosticsSpec
+    , Hanalyze.Design.GaugeRRSpec
+    , Hanalyze.Design.MixtureSpec
+    , Hanalyze.Design.OptimalSpec
+    , Hanalyze.Design.OrthogonalSpec
+    , Hanalyze.Design.QualitySpec
+    , Hanalyze.Design.SequentialSpec
+    , Hanalyze.Design.SpaceFillingSpec
+    , Hanalyze.Design.TaguchiSpec
+    , Hanalyze.Design.WorkflowSpec
+    , Hanalyze.MCMC.BayesianTestSpec
+    , Hanalyze.MCMC.NUTSSpec
+    , Hanalyze.MCMC.ProgressSpec
+    , Hanalyze.MCMC.SMCSpec
+    , Hanalyze.MCMC.SamplersPureSpec
+    , Hanalyze.Math.HSICSpec
+    , Hanalyze.Math.HungarianSpec
+    , Hanalyze.Model.AFTSpec
+    , Hanalyze.Model.ClusterSpec
+    , Hanalyze.Model.CompetingRisksSpec
+    , Hanalyze.Model.DAGSpec
+    , Hanalyze.Model.DecisionTreeSpec
+    , Hanalyze.Model.PartialDependenceSpec
+    , Hanalyze.Model.DiscriminantSpec
+    , Hanalyze.Model.FDASpec
+    , Hanalyze.Model.FitYByXSpec
+    , Hanalyze.Model.Formula.ContrastSpec
+    , Hanalyze.Model.Formula.DesignSpec
+    , Hanalyze.Model.Formula.FrameSpec
+    , Hanalyze.Model.Formula.MixedSpec
+    , Hanalyze.Model.Formula.NonlinearSpec
+    , Hanalyze.Model.Formula.RFormulaSpec
+    , Hanalyze.Model.Formula.WLSSpec
+    , Hanalyze.Model.FormulaSpec
+    , Hanalyze.Model.GARCHSpec
+    , Hanalyze.Model.GLMMSpec
+    , Hanalyze.Model.GLMSpec
+    , Hanalyze.Model.GPRobustSpec
+    , Hanalyze.Model.GradientBoostingSpec
+    , Hanalyze.Model.HBM.InterpSpec
+    , Hanalyze.Model.HBM.LogpSpec
+    , Hanalyze.Model.HBMSpec
+    , Hanalyze.Model.HBMSummarySpec
+    , Hanalyze.Model.HierarchicalClusterSpec
+    , Hanalyze.Model.KNNSpec
+    , Hanalyze.Model.KernelSpec
+    , Hanalyze.Model.SVMSpec
+    , Hanalyze.Model.LM.DiagnosticsSpec
+    , Hanalyze.Model.LatentClassAnalysisSpec
+    , Hanalyze.Model.LiNGAM.BootstrapSpec
+    , Hanalyze.Model.LiNGAM.DirectSpec
+    , Hanalyze.Model.LiNGAM.ICASpec
+    , Hanalyze.Model.LiNGAM.MultiGroupSpec
+    , Hanalyze.Model.LiNGAM.PairwiseSpec
+    , Hanalyze.Model.LiNGAM.ParceSpec
+    , Hanalyze.Model.LiNGAM.VARSpec
+    , Hanalyze.Model.MultiOutputSpec
+    , Hanalyze.Model.NaiveBayesSpec
+    , Hanalyze.Model.NeuralNetworkSpec
+    , Hanalyze.Model.PCASpec
+    , Hanalyze.Model.PLSSpec
+    , Hanalyze.Model.RFFSpec
+    , Hanalyze.Model.RandomForestClassifierSpec
+    , Hanalyze.Model.RegularizedAdvanced.AdaptiveLassoSpec
+    , Hanalyze.Model.RegularizedAdvanced.GroupLassoSpec
+    , Hanalyze.Model.RegularizedAdvanced.MCPSpec
+    , Hanalyze.Model.RegularizedAdvanced.SCADSpec
+    , Hanalyze.Model.RegularizedSpec
+    , Hanalyze.Model.ReliabilityBlockDiagramSpec
+    , Hanalyze.Model.ReliabilitySpec
+    , Hanalyze.Model.RobustSpec
+    , Hanalyze.Model.StateSpaceSpec
+    , Hanalyze.Model.SurvivalSpec
+    , Hanalyze.Model.TimeSeriesSpec
+    , Hanalyze.Model.VARSpec
+    , Hanalyze.Model.WeibullSpec
+    , Hanalyze.Optim.BayesOptSpec
+    , Hanalyze.Optim.CMAESSpec
+    , Hanalyze.Optim.CommonSpec
+    , Hanalyze.Optim.ConstrainedSpec
+    , Hanalyze.Optim.DifferentialEvolutionSpec
+    , Hanalyze.Optim.LBFGSSpec
+    , Hanalyze.Optim.LineSearchSpec
+    , Hanalyze.Optim.NSGASpec
+    , Hanalyze.Optim.NelderMeadSpec
+    , Hanalyze.Optim.ParticleSwarmSpec
+    , Hanalyze.Optim.SimulatedAnnealingSpec
+    , Hanalyze.Stat.AdaptiveGridSpec
+    , Hanalyze.Stat.BayesFactorSpec
+    , Hanalyze.Stat.BayesianModelAveragingSpec
+    , Hanalyze.Stat.BootstrapSpec
+    , Hanalyze.Stat.BridgeSamplingSpec
+    , Hanalyze.Stat.CVSpec
+    , Hanalyze.Stat.Causal.CATESpec
+    , Hanalyze.Stat.Causal.DoublyRobustSpec
+    , Hanalyze.Stat.Causal.IPWSpec
+    , Hanalyze.Stat.Causal.PropensityScoreSpec
+    , Hanalyze.Stat.CholeskySpec
+    , Hanalyze.Stat.ClassMetricsSpec
+    , Hanalyze.Stat.CorrelationNetworkSpec
+    , Hanalyze.Stat.DescriptiveSpec
+    , Hanalyze.Stat.EffectSpec
+    , Hanalyze.Stat.GroupComparisonSpec
+    , Hanalyze.Stat.InterpolateSpec
+    , Hanalyze.Stat.InterpretSpec
+    , Hanalyze.Stat.KernelDistSpec
+    , Hanalyze.Stat.MCMCSpec
+    , Hanalyze.Stat.MDSSpec
+    , Hanalyze.Stat.MultipleTestingSpec
+    , Hanalyze.Stat.NumberFormatSpec
+    , Hanalyze.Stat.QuasiRandomSpec
+    , Hanalyze.Stat.SPCSpec
+    , Hanalyze.Stat.StandardizeSpec
+    , Hanalyze.Stat.SummarySpec
+    , Hanalyze.Stat.TestSpec
+    , Hanalyze.Stat.VISpec
+    , Hanalyze.Viz.ModelGraphSpec
+    , Hanalyze.Viz.ReportBuilderSpec
+    , SpecHelper
diff --git a/src/Hanalyze.hs b/src/Hanalyze.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze.hs
@@ -0,0 +1,62 @@
+-- |
+-- Module      : Hanalyze
+-- Description : モデル fit・統計・可視化・CSV I/O をまとめた quickstart 用 umbrella module
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Hanalyze の quickstart 出入口 (umbrella module)。
+--
+-- 最初に触れる中核 (モデル fit・基本統計・可視化・CSV I/O) を 1 つの
+-- @import Hanalyze@ で揃える窓口。 個別機能は各サブモジュール
+-- (@Hanalyze.Model.*@ / @Hanalyze.Stat.*@ / @Hanalyze.Viz.*@) を直接 import する。
+--
+-- 方針 (Phase 46 / plot Phase 15 §A5):
+--   * ここは **plot 非依存・portable** (re-export のみ、 flag 不要)。
+--     plot 連携 (@toPlot@ / @Plottable@) は @flag plot-integration@ 配下の
+--     @Hanalyze.Plot@ に分離してあり、 本 umbrella には含めない。
+--   * Formula DSL は本 Phase 対象外 (ロードマップ B 段)。
+module Hanalyze
+  ( -- * モデル fit の共有核 / 能力別 protocol
+    module Hanalyze.Model.Core
+    -- * 線形 / 一般化線形モデル
+  , module Hanalyze.Model.LM
+  , module Hanalyze.Model.GLM
+    -- * 記述統計・検定・効果量・分布
+  , module Hanalyze.Stat.Summary
+  , module Hanalyze.Stat.Test
+  , module Hanalyze.Stat.Effect
+  , module Hanalyze.Stat.Distribution
+    -- * 可視化 (散布図 / 棒 / ヒストグラム)
+  , module Hanalyze.Viz.Core
+  , module Hanalyze.Viz.Scatter
+  , module Hanalyze.Viz.Bar
+  , module Hanalyze.Viz.Histogram
+    -- * データ入力
+  , module Hanalyze.DataIO.CSV
+    -- * HBM 事後要約 (Phase 103: fit 結果 → 要約表 / DataFrame の一発 API)
+  , hbmSummaryNames
+  , hbmSummary
+  , printHBMSummary
+  , hbmSummaryDf
+  , hbmDrawsDf
+  ) where
+
+-- ===
+
+import Hanalyze.Model.Core
+import Hanalyze.Model.LM
+import Hanalyze.Model.GLM
+import Hanalyze.Stat.Summary
+import Hanalyze.Stat.Test
+import Hanalyze.Stat.Effect
+-- Binomial / Poisson は GLM の 'Family' と名前衝突するため、 umbrella では
+-- GLM 側を優先 (quickstart は @fitGLM Poisson ...@ が典型)。 'Distribution' の
+-- 同名コンストラクタが要る場合は @Hanalyze.Stat.Distribution@ を直接 import する。
+import Hanalyze.Stat.Distribution hiding (Binomial, Poisson)
+import Hanalyze.Viz.Core
+import Hanalyze.Viz.Scatter
+import Hanalyze.Viz.Bar
+import Hanalyze.Viz.Histogram
+import Hanalyze.DataIO.CSV
+import Hanalyze.Model.Wrappers (hbmSummaryNames, hbmSummary, printHBMSummary,
+                                       hbmSummaryDf, hbmDrawsDf)
diff --git a/src/Hanalyze/Data/ColumnSource.hs b/src/Hanalyze/Data/ColumnSource.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Data/ColumnSource.hs
@@ -0,0 +1,77 @@
+{-# LANGUAGE FlexibleInstances #-}
+{-# LANGUAGE OverloadedStrings #-}
+-- |
+-- Module      : Hanalyze.Data.ColumnSource
+-- Description : 列名 → 数値列を引ける「データ源」の最小抽象型クラス (plot 非依存)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- 列名 → 数値列 を引ける「データ源」 の最小抽象。
+--
+-- モデル学習の入口 (Phase 51 の @df |-> spec@) を、 データ表現
+-- (@[(Text,[Double])]@ / @Map Text [Double]@ / Hackage @DataFrame@ /
+-- plot @ColData@) から疎結合にするための型クラス。 数値列の取得と
+-- 列名列挙の 2 メソッドのみを持ち、 factor/NA の解釈は上位
+-- (Phase 47 formula 経路) に委ねる。
+--
+-- このモジュールは **plot 非依存 (portable)**。 plot 専用の
+-- @[(Text, ColData)]@ instance は flag @plot-integration@ 配下
+-- (@Hanalyze.Plot@) に隔離する。
+module Hanalyze.Data.ColumnSource
+  ( ColumnSource (..)
+  ) where
+
+import           Data.Map.Strict (Map)
+import qualified Data.Map.Strict as Map
+import           Data.Text       (Text)
+import qualified Data.Vector     as V
+import qualified DataFrame.Internal.Column    as DX
+import qualified DataFrame.Internal.DataFrame  as DX
+
+import           Hanalyze.DataIO.Convert (getDoubleVec)
+
+-- ===========================================================================
+-- 型クラス
+-- ===========================================================================
+
+-- | 列名で数値列を引けるデータ源。
+--
+-- * 'lookupCol' は **数値列**のみを返す (factor 列は formula 経路が
+--   contrast 展開するため、 ここでは数値列の素取得に限る)。
+-- * 'columnNames' は欠落検出 (要求列が無い) のための全列名列挙。
+class ColumnSource d where
+  -- | 列名 → 数値列 (無ければ 'Nothing')。
+  lookupCol   :: Text -> d -> Maybe [Double]
+  -- | 全列名。
+  columnNames :: d -> [Text]
+  -- | データ源全体を Hackage @DataFrame@ に変換 (formula 経路 = Phase 47 の
+  --   @MissingPolicy@\/contrast\/応答列判定で ModelFrame に変換するため)。
+  --
+  --   既定は **数値列のみから再構築** (assoc\/Map など数値源で正しい)。
+  --   'DX.DataFrame' instance は 'id' で上書きし factor\/NA を温存する
+  --   (formula 多変量の canonical 経路)。
+  toFrame :: d -> DX.DataFrame
+  toFrame d = DX.fromNamedColumns
+    [ (n, DX.fromList vs)
+    | n <- columnNames d, Just vs <- [lookupCol n d] ]
+
+-- ===========================================================================
+-- core instance (portable)
+-- ===========================================================================
+
+-- | HBM の既存入力 (列名 assoc) と同型。
+instance ColumnSource [(Text, [Double])] where
+  lookupCol n = lookup n
+  columnNames = map fst
+
+-- | 'Map' 版。
+instance ColumnSource (Map Text [Double]) where
+  lookupCol   = Map.lookup
+  columnNames = Map.keys
+
+-- | Hackage @dataframe@ (analyze formula 経路と同じ df)。
+--   数値変換は 'getDoubleVec' に委譲 (formula 経路と同じ判定)。
+instance ColumnSource DX.DataFrame where
+  lookupCol n df = V.toList <$> getDoubleVec n df
+  columnNames    = DX.columnNames
+  toFrame        = id   -- factor/NA を温存 (formula 経路の canonical)
diff --git a/src/Hanalyze/Data/Factor.hs b/src/Hanalyze/Data/Factor.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Data/Factor.hs
@@ -0,0 +1,346 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+-- |
+-- Module      : Hanalyze.Data.Factor
+-- Description : forcats 流の因子 (factor) 型と水準操作 (fct_* 相当)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- forcats 流の因子 (factor) 型操作 (Phase 28 Ch16 "Factors")。
+--
+-- R の `factor` を **水準 (levels) の順序付きリスト + 各観測の水準コード**として
+-- 表す 'Factor' 型と、 forcats の `fct_*` 相当を純粋関数として公開する
+-- ('Data.Strings' / 'Data.Transform' と同列の `Data/` 純粋抽象)。
+--
+-- === なぜ専用型か
+-- ただの @[Text]@ と違い factor は (1) 水準の**意味的順序** (アルファベット順とは別)、
+-- (2) データに現れない水準の保持、 (3) 整数コード化、 を持つ。 forcats の `fct_*` は
+-- この水準順序や中身を操作する関数群で、 順序概念のない @[Text]@ には無い。
+--
+-- === HBM の Column.Factor との違い
+-- 'Hanalyze.Model.HBM' の内部 @Column = Numeric | Factor@ は NUTS に渡す
+-- 観測列の内部表現。 本 'Factor' は **データ整形ドメイン**の公開型で責務が別 (独立実装)。
+--
+-- === コード規約
+-- 'facCodes' は **0 始まり** (@facLevels !! code@ で復元)。 欠損 (R の @\<NA\>@) は
+-- コード @-1@ で表す ('naCode')。
+module Hanalyze.Data.Factor
+  ( -- * 型
+    Factor (..)
+  , naCode
+    -- * 生成 (factor / fct / ordered)
+  , factor
+  , factorWith
+  , fct
+  , ordered
+    -- * 参照 (levels / as.character / count)
+  , levels
+  , isOrdered
+  , asTexts
+  , asTextsMaybe
+  , fctCount
+    -- * 順序操作 (16.4 forcats fct_reorder 系)
+  , fctReorder
+  , fctRelevel
+  , fctReorder2
+  , fctInfreq
+  , fctRev
+    -- * 水準操作 (16.5 forcats fct_recode / fct_collapse / fct_lump 系)
+  , fctRecode
+  , fctCollapse
+  , fctLumpN
+  , fctLumpMin
+  , fctLumpProp
+  , fctLumpLowfreq
+  ) where
+
+import           Data.List (foldl', sort, sortBy, elemIndex)
+import           Data.Maybe (mapMaybe)
+import           Data.Ord (comparing, Down (..))
+import qualified Data.Map.Strict as M
+import           Data.Text (Text)
+import qualified Data.Vector as V
+import qualified Data.Vector.Unboxed as VU
+
+-- === 型 =====================================================================
+
+-- | 因子。 水準ラベル + 各観測の水準コード (0 始まり・NA は 'naCode')。
+data Factor = Factor
+  { facLevels  :: [Text]        -- ^ 水準ラベル (定義順)
+  , facCodes   :: VU.Vector Int -- ^ 各観測の水準コード (0 始まり・NA = 'naCode')
+  , facOrdered :: Bool          -- ^ 順序付き因子か (R `ordered()`)
+  } deriving (Eq, Show)
+
+-- | 欠損コード (R の @NA_integer_@ 相当)。
+naCode :: Int
+naCode = -1
+
+-- === 生成 ===================================================================
+
+-- | @factor xs@ : R の `factor()` 既定。 水準 = 値の **ソート済 unique**。
+factor :: [Text] -> Factor
+factor xs = factorWith (sortUnique xs) xs
+
+-- | @factorWith lvls xs@ : 水準を明示。 @lvls@ に無い値は NA ('naCode')。
+factorWith :: [Text] -> [Text] -> Factor
+factorWith lvls xs = Factor lvls (VU.fromList (map enc xs)) False
+  where
+    idx   = M.fromList (zip lvls [0 ..])
+    enc x = M.findWithDefault naCode x idx
+
+-- | @fct xs@ : forcats の `fct()`。 水準 = 値の **出現順 unique** (factor() と違い sort しない)。
+fct :: [Text] -> Factor
+fct xs = factorWith (nubKeepOrder xs) xs
+
+-- | @ordered lvls xs@ : 順序付き因子 (R `ordered()`)。 水準間に @<@ 順序を持つ。
+ordered :: [Text] -> [Text] -> Factor
+ordered lvls xs = (factorWith lvls xs) { facOrdered = True }
+
+-- === 参照 ===================================================================
+
+-- | 水準ラベル (定義順)。
+levels :: Factor -> [Text]
+levels = facLevels
+
+-- | 順序付き因子か。
+isOrdered :: Factor -> Bool
+isOrdered = facOrdered
+
+-- | @as.character()@ 相当。 各観測をラベルへ。 NA は @""@。
+asTexts :: Factor -> [Text]
+asTexts f = map (maybe "" id) (asTextsMaybe f)
+
+-- | NA を 'Nothing' で残す版。
+asTextsMaybe :: Factor -> [Maybe Text]
+asTextsMaybe f = map lab (VU.toList (facCodes f))
+  where
+    v = V.fromList (facLevels f)
+    lab c | c < 0 || c >= V.length v = Nothing
+          | otherwise                = Just (v V.! c)
+
+-- | dplyr `count()` 相当。 **水準順**に (ラベル, 出現数)。 0 件水準も含む。 NA は除外。
+fctCount :: Factor -> [(Text, Int)]
+fctCount f = [ (lv, M.findWithDefault 0 i cmap) | (lv, i) <- zip (facLevels f) [0 ..] ]
+  where
+    cmap = foldl' (\m c -> if c < 0 then m else M.insertWith (+) c 1 m)
+                  M.empty (VU.toList (facCodes f))
+
+-- === 順序操作 (16.4) ========================================================
+--
+-- いずれも **facLevels の順序を入れ替え、 facCodes を旧 code → 新 code に
+-- 再マッピング**して実装する ('applyLevelOrder')。 観測の中身 (どの水準か) は
+-- 不変で、 水準の**並び順**だけが変わる (forcats `fct_reorder` 系と同義)。
+
+-- | forcats `fct_reorder(f, x, .fun)`。 各水準に属する @x@ 値へ集約関数 @fun@
+-- (R4DS の既定は @median@) を適用し、 その値の**昇順**に水準を並べ替える。
+-- @x@ 中の @NaN@ は NA とみなし集約前に除去。 値が無い水準は末尾。
+fctReorder :: ([Double] -> Double) -> Factor -> [Double] -> Factor
+fctReorder fun f xs = applyLevelOrder order f
+  where
+    n     = length (facLevels f)
+    grp   = groupByCode n (facCodes f) xs
+    key i = case filter (not . isNaN) (grp V.! i) of
+              [] -> 1 / 0          -- 値なし → +Inf で末尾へ
+              vs -> fun vs
+    order = sortBy (comparing key) [0 .. n - 1]
+
+-- | forcats `fct_relevel(f, ...)`。 指定した水準を (指定順で) **先頭**へ移し、
+-- 残りは元の相対順序を保つ。 存在しない水準名は無視する。
+fctRelevel :: [Text] -> Factor -> Factor
+fctRelevel front f = applyLevelOrder order f
+  where
+    lvs      = facLevels f
+    frontIx  = mapMaybe (`elemIndex` lvs) front
+    rest     = [ i | i <- [0 .. length lvs - 1], i `notElem` frontIx ]
+    order    = frontIx ++ rest
+
+-- | forcats `fct_reorder2(f, x, y)` (既定 @last2@)。 各水準で **最大の @x@ に
+-- 対応する @y@** を取り、 その値の**降順**に水準を並べ替える (凡例順を線の
+-- 右端の高さに合わせる用途)。 @x@ 同値は後着 (入力順で後ろ) を採用。
+fctReorder2 :: Factor -> [Double] -> [Double] -> Factor
+fctReorder2 f xs ys = applyLevelOrder order f
+  where
+    n     = length (facLevels f)
+    grp   = groupPairs n (facCodes f) xs ys
+    key i = case grp V.! i of
+              [] -> -1 / 0         -- 値なし → -Inf で降順末尾へ
+              ps -> snd (foldl1 (\a b -> if fst b >= fst a then b else a) ps)
+    order = sortBy (comparing (Down . key)) [0 .. n - 1]
+
+-- | forcats `fct_infreq(f)`。 出現**頻度の降順**に水準を並べ替える。
+-- 同頻度は元の水準順を保つ (安定)。 NA は計数対象外。
+fctInfreq :: Factor -> Factor
+fctInfreq f = applyLevelOrder order f
+  where
+    n      = length (facLevels f)
+    counts = [ M.findWithDefault 0 i cmap | i <- [0 .. n - 1] ] :: [Int]
+    cmap   = foldl' (\m c -> if c < 0 then m else M.insertWith (+) c 1 m)
+                    M.empty (VU.toList (facCodes f))
+    order  = sortBy (comparing (Down . (countArr V.!))) [0 .. n - 1]
+    countArr = V.fromList counts
+
+-- | forcats `fct_rev(f)`。 水準を**逆順**にする。
+fctRev :: Factor -> Factor
+fctRev f = applyLevelOrder (reverse [0 .. length (facLevels f) - 1]) f
+
+-- === 水準操作 (16.5) ========================================================
+--
+-- 水準**ラベルそのもの**を付け替える/併合する操作。 ラベルを付け替えた結果
+-- 同名になった水準は 1 つに畳む ('relabelMerge')。 lump 系は余りを @"Other"@ に
+-- まとめ、 forcats と同じく @"Other"@ を**末尾水準**に置く。
+
+-- | 余り水準のまとめ先ラベル (forcats `other_level` 既定)。
+otherLevel :: Text
+otherLevel = "Other"
+
+-- | forcats `fct_recode(f, new = "old", ...)`。 水準ラベルを改名する。
+-- 引数は @(新, 旧)@ の対。 複数の旧を同じ新に向ければ**併合**される。
+-- 言及されない水準はそのまま。 水準の相対順序は保つ。
+fctRecode :: [(Text, Text)] -> Factor -> Factor
+fctRecode pairs f = relabelMerge newLabels f
+  where
+    m         = M.fromList [ (old, new) | (new, old) <- pairs ]
+    newLabels = [ M.findWithDefault lv lv m | lv <- facLevels f ]
+
+-- | forcats `fct_collapse(f, new = c("o1","o2"), ...)`。 複数水準を 1 つへ併合。
+-- 引数は @(新, [旧...])@。 言及されない水準はそのまま (`other_level` は非対応)。
+fctCollapse :: [(Text, [Text])] -> Factor -> Factor
+fctCollapse groups f = relabelMerge newLabels f
+  where
+    m         = M.fromList [ (old, new) | (new, olds) <- groups, old <- olds ]
+    newLabels = [ M.findWithDefault lv lv m | lv <- facLevels f ]
+
+-- | forcats `fct_lump_n(f, n)`。 頻度上位 @n@ 水準を残し、 他を @"Other"@ へ。
+-- @n@ 負なら頻度**下位** @|n|@ を残す。 同頻度のタイは両方残す (上位 n 側)。
+fctLumpN :: Int -> Factor -> Factor
+fctLumpN n f
+  | m == 0    = f
+  | n == 0    = lumpBy (const True) f
+  | n > 0     = lumpBy (\i -> cnts !! i < thrTop) f
+  | otherwise = lumpBy (\i -> cnts !! i > thrBot) f
+  where
+    cnts   = levelCounts f
+    m      = length cnts
+    thrTop | n >= m    = minimum cnts                      -- 全水準保持
+           | otherwise = sortBy (comparing Down) cnts !! (n - 1)
+    k      = negate n
+    thrBot | k >= m    = maximum cnts                      -- 全水準保持
+           | otherwise = sort cnts !! (k - 1)
+
+-- | forcats `fct_lump_min(f, min)`。 出現回数 @< min@ の水準を @"Other"@ へ (strict)。
+fctLumpMin :: Int -> Factor -> Factor
+fctLumpMin k f = lumpBy (\i -> cnts !! i < k) f
+  where cnts = levelCounts f
+
+-- | forcats `fct_lump_prop(f, prop)`。 出現割合 @< prop@ の水準を @"Other"@ へ。
+fctLumpProp :: Double -> Factor -> Factor
+fctLumpProp p f = lumpBy (\i -> fromIntegral (cnts !! i) < p * fromIntegral total) f
+  where
+    cnts  = levelCounts f
+    total = sum cnts
+
+-- | forcats `fct_lump_lowfreq(f)`。 低頻度水準を、 @"Other"@ が**最小水準のまま**で
+-- いられる範囲で併合する。 頻度降順に並べ、 ある水準が「それより低頻度な水準の
+-- 合計」 を上回った時点で、 以降を @"Other"@ へまとめる (forcats `lump_cutoff` 同型)。
+fctLumpLowfreq :: Factor -> Factor
+fctLumpLowfreq f
+  | m == 0    = f
+  | otherwise = lumpBy (\i -> (cnts !! i, i) `elem` lumpedKeys) f
+  where
+    cnts       = levelCounts f
+    m          = length cnts
+    -- (頻度, 水準index) を頻度降順 (タイは index 昇順) に
+    descKeys   = sortBy (comparing (\(c, i) -> (Down c, i))) (zip cnts [0 ..])
+    cut        = cutoff (map fst descKeys)          -- 保持する個数 (cut..末尾を lump)
+    lumpedKeys = drop cut descKeys
+
+-- | forcats `lump_cutoff`: 降順 count 列で、 「自分 > 残り (より低頻度) の合計」 と
+-- なる最初の位置 @i@ を見つけ、 @i+1@ (= 0 始まりで @i@ 番目まで保持) を返す。
+-- 該当無しなら全保持 (= 長さ)。
+cutoff :: [Int] -> Int
+cutoff xs = go 0 (sum xs) xs
+  where
+    go i _    []       = i
+    go i left (c : cs)
+      | c > left - c   = i + 1
+      | otherwise      = go (i + 1) (left - c) cs
+
+-- === 内部 helper ============================================================
+
+-- | 水準ラベルを @newLabels@ (旧水準 index 順・長さ = 旧水準数) に付け替え、
+-- 同名になった水準を 1 つに畳む。 新水準順は新ラベルの**初出順**。 NA は不変。
+relabelMerge :: [Text] -> Factor -> Factor
+relabelMerge newLabels f =
+  Factor newLevels (VU.map remap (facCodes f)) (facOrdered f)
+  where
+    newLevels = nubKeepOrder newLabels
+    pos       = M.fromList (zip newLevels [0 ..])
+    o2nVec    = V.fromList [ pos M.! lab | lab <- newLabels ]
+    remap c | c < 0 || c >= V.length o2nVec = c
+            | otherwise                     = o2nVec V.! c
+
+-- | 指定水準を**末尾**へ移す (存在しなければ不変)。 'applyLevelOrder' を再利用。
+moveLevelToEnd :: Text -> Factor -> Factor
+moveLevelToEnd lv f = case elemIndex lv (facLevels f) of
+  Nothing -> f
+  Just i  -> applyLevelOrder ([ j | j <- [0 .. n - 1], j /= i ] ++ [i]) f
+  where n = length (facLevels f)
+
+-- | 水準 index で lump 判定し、 余りを 'otherLevel' へ併合 (末尾に置く)。
+-- lump 対象が無ければ不変。
+lumpBy :: (Int -> Bool) -> Factor -> Factor
+lumpBy isLump f
+  | not (or lumpFlags) = f
+  | otherwise          = moveLevelToEnd otherLevel (relabelMerge newLabels f)
+  where
+    lumpFlags = map isLump [0 .. length (facLevels f) - 1]
+    newLabels = [ if isLump i then otherLevel else lv
+                | (i, lv) <- zip [0 ..] (facLevels f) ]
+
+-- | 各水準 (定義順) の出現回数。 NA は除外。
+levelCounts :: Factor -> [Int]
+levelCounts f = [ M.findWithDefault 0 i cmap | i <- [0 .. length (facLevels f) - 1] ]
+  where
+    cmap = foldl' (\m c -> if c < 0 then m else M.insertWith (+) c 1 m)
+                  M.empty (VU.toList (facCodes f))
+
+-- | @applyLevelOrder order f@ : @order@ は新しい並びを表す**旧 index のリスト**
+-- (@order !! p@ = 新位置 @p@ に来る旧水準 index)。 facLevels を並べ替え、
+-- facCodes を旧 code → 新 code に再マップする。 NA ('naCode') はそのまま。
+applyLevelOrder :: [Int] -> Factor -> Factor
+applyLevelOrder order f = f
+  { facLevels = map (lvs V.!) order
+  , facCodes  = VU.map remap (facCodes f)
+  }
+  where
+    lvs   = V.fromList (facLevels f)
+    n     = length (facLevels f)
+    -- 旧 index → 新 index
+    o2n   = VU.replicate n (-1) VU.// [ (old, new) | (new, old) <- zip [0 ..] order ]
+    remap c | c < 0 || c >= n = c
+            | otherwise       = o2n VU.! c
+
+-- | 水準コードで @x@ 値をグループ化 (長さ n・入力順保持・NA/範囲外は除外)。
+groupByCode :: Int -> VU.Vector Int -> [Double] -> V.Vector [Double]
+groupByCode n codes xs =
+  V.map reverse $ V.accum (flip (:)) (V.replicate n [])
+    [ (c, x) | (c, x) <- zip (VU.toList codes) xs, c >= 0, c < n ]
+
+-- | 水準コードで @(x, y)@ 対をグループ化 (長さ n・入力順保持・NA/範囲外は除外)。
+groupPairs :: Int -> VU.Vector Int -> [Double] -> [Double] -> V.Vector [(Double, Double)]
+groupPairs n codes xs ys =
+  V.map reverse $ V.accum (flip (:)) (V.replicate n [])
+    [ (c, (x, y)) | (c, x, y) <- zip3 (VU.toList codes) xs ys, c >= 0, c < n ]
+
+-- | ソート済 unique (Map のキー集合 = 昇順 unique)。
+sortUnique :: [Text] -> [Text]
+sortUnique = M.keys . M.fromList . map (\x -> (x, ()))
+
+-- | 出現順を保った unique。
+nubKeepOrder :: [Text] -> [Text]
+nubKeepOrder = go M.empty
+  where
+    go _ [] = []
+    go seen (x : xs)
+      | x `M.member` seen = go seen xs
+      | otherwise         = x : go (M.insert x () seen) xs
diff --git a/src/Hanalyze/Data/Strings.hs b/src/Hanalyze/Data/Strings.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Data/Strings.hs
@@ -0,0 +1,582 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+-- |
+-- Module      : Hanalyze.Data.Strings
+-- Description : stringr 流の Text 純粋操作 (str_* 相当・行/列展開含む)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- stringr 流の文字列操作 (Phase 28 Ch14 "Strings")。
+--
+-- R4DS Ch14 で扱う `str_*` 関数を **純粋な `Text` 操作**として公開する
+-- ('Data.Transform' と同列の `Data/` 純粋抽象)。 DataFrame 行/列展開を伴う
+-- `separate_*` は別途 (本モジュール下部・要 DataFrame)。
+--
+-- === recycling / NA
+-- `str_c` 相当は tidyverse の **recycling 規則** (長さ 1 か n) に従う。 NA 伝播は
+-- 'Maybe' 版 ('strCMaybe') で表す (R の `NA` は `Nothing`)。
+--
+-- === locale
+-- 'strToUpper' / 'strSort' は **既定 locale** (Unicode コードポイント順・en 相当)。
+-- R4DS §14.6.3 の locale 依存 (Czech の "ch"・Turkish の dotless i 等) は ICU が要るため
+-- 本モジュールでは扱わず、 tutorial 側で「概念のみ」 honest に注記する。
+module Hanalyze.Data.Strings
+  ( -- * 長さ / 部分取り出し (str_length / str_sub)
+    strLength
+  , strSub
+    -- * 連結 (str_c / str_flatten / str_glue)
+  , strC
+  , strCMaybe
+  , strFlatten
+  , strGlue
+    -- * 大文字化 / ソート (str_to_upper / str_sort)
+  , strToUpper
+  , strSort
+    -- * 文字比較 / encoding (str_equal / charToRaw・§14.6)
+  , strEqual
+  , charToRaw
+    -- * 行展開 (separate_longer・DataFrame)
+  , separateLongerDelim
+  , separateLongerPosition
+    -- * 列分割 (separate_wider・DataFrame)
+  , TooFew (..)
+  , TooMany (..)
+  , separateWiderDelim
+  , separateWiderDelimWith
+  , separateWiderPosition
+  , separateWiderPositionWith
+    -- * 正規表現 (§15 Regular expressions・regex-tdfa)
+  , strDetect
+  , strDetectWith
+  , strCount
+  , strSubset
+  , strWhich
+  , strExtract
+  , strExtractAll
+  , strMatch
+  , strReplace
+  , strReplaceAll
+  , strRemove
+  , strRemoveAll
+  , strSplit
+  , strLocate
+  , strEscape
+  , separateWiderRegex
+  ) where
+
+import           Data.Array  (elems)
+import           Data.List   (sort, transpose)
+import           Data.Maybe  (isJust)
+import           Data.Word   (Word8)
+import qualified Data.ByteString as BS
+import           Data.Text   (Text)
+import qualified Data.Text   as T
+import qualified Data.Text.Encoding as TE
+import           Data.Text.Normalize (NormalizationMode (NFC), normalize)
+import qualified Data.Vector as V
+import qualified Data.Vector.Unboxed as VU
+import qualified DataFrame.Internal.Column    as DF
+import qualified DataFrame.Internal.DataFrame  as DF
+import qualified DataFrame.Operations.Subset   as DF
+import qualified DataFrame.Internal.Column  as DFC
+import qualified DataFrame.Operations.Subset as DFS (rowsAtIndices)
+import           Text.Regex.TDFA            (Regex, CompOption (..), ExecOption (..))
+import qualified Text.Regex.TDFA            as RE
+
+import           Hanalyze.DataIO.Convert (getMaybeTextVec)
+
+-- ===========================================================================
+-- 長さ / 部分取り出し
+-- ===========================================================================
+
+-- | 文字数 (= stringr @str_length@・@T.length@)。 コードポイント単位。
+strLength :: Text -> Int
+strLength = T.length
+
+-- | 部分文字列 (= @str_sub(string, start, end)@)。 **1 始まり・両端含む**。
+--   負の index は末尾から (@-1@ = 最終文字)。 範囲外は内側にクリップ。
+--   例: @strSub 1 3 "Apple" == "App"@・@strSub (-3) (-1) "Apple" == "ple"@。
+strSub :: Int -> Int -> Text -> Text
+strSub start end t =
+  let n = T.length t
+      norm i | i < 0     = n + i + 1   -- -1 → n (1 始まり)
+             | otherwise = i
+      s = max 1 (norm start)
+      e = min n (norm end)
+  in if s > e then "" else T.take (e - s + 1) (T.drop (s - 1) t)
+
+-- ===========================================================================
+-- 連結
+-- ===========================================================================
+
+-- | ベクトル連結 (= @str_c(...)@)。 各列を **recycling 規則** (長さ 1 か n) で
+--   揃え、 行ごとに連結する。 リテラルは長さ 1 の列 (@["x"]@) として渡す。
+--   例: @strC [["Hello "], names, ["!"]]@。 NA 伝播版は 'strCMaybe'。
+strC :: [[Text]] -> [Text]
+strC [] = []
+strC cols = map T.concat (recycleCols cols)
+
+-- | 'strC' の NA 伝播版 (= @str_c@ の R 既定)。 行内に 'Nothing' があれば結果も
+--   'Nothing' (R の `NA` 伝播)。 リテラルは @[Just "x"]@。
+strCMaybe :: [[Maybe Text]] -> [Maybe Text]
+strCMaybe [] = []
+strCMaybe cols =
+  [ if any (== Nothing) row then Nothing else Just (T.concat [x | Just x <- row])
+  | row <- recycleCols cols ]
+
+-- | 文字ベクトル→単一文字列 (= @str_flatten(x, collapse)@)。 @T.intercalate@。
+--   例: @strFlatten ", " ["a","b","c"] == "a, b, c"@。
+strFlatten :: Text -> [Text] -> Text
+strFlatten = T.intercalate
+
+-- | テンプレート補間 (= @str_glue@)。 @"{key}"@ を @env@ の列で置換 (recycling)。
+--   例: @strGlue "Hello {name}!" [("name", names)]@。 未知 key は error。
+strGlue :: Text -> [(Text, [Text])] -> [Text]
+strGlue tmpl env =
+  let n    = maximum (1 : map (length . snd) env)
+      segs = parseGlue tmpl
+      val key i = case lookup key env of
+        Just col
+          | length col == 1 -> head col
+          | i < length col  -> col !! i
+          | otherwise       -> error "strGlue: 列長が不揃い (recycling 不能)"
+        Nothing -> error ("strGlue: 未知の {" ++ T.unpack key ++ "}")
+  in [ T.concat [ either id (\k -> val k i) s | s <- segs ] | i <- [0 .. n - 1] ]
+
+-- ===========================================================================
+-- 大文字化 / ソート
+-- ===========================================================================
+
+-- | 大文字化 (= @str_to_upper@・既定 locale)。 @T.toUpper@。
+strToUpper :: Text -> Text
+strToUpper = T.toUpper
+
+-- | 昇順ソート (= @str_sort@・既定 locale = Unicode コードポイント順)。
+strSort :: [Text] -> [Text]
+strSort = sort
+
+-- ===========================================================================
+-- 文字比較 / encoding (§14.6 Non-English Text)
+-- ===========================================================================
+
+-- | 見た目が同じ文字の等価判定 (= @str_equal@・§14.6.2)。 アクセント付き文字は
+--   合成済 (@"\xfc"@ = ü) と 基底+結合 (@"u\x308"@) で**符号列が違っても見た目は同じ**。
+--   両者を **NFC 正規化**してから比較するため等価になる
+--   (例: @strEqual "\xfc" "u\x308" == True@・素の @==@ では False)。
+strEqual :: Text -> Text -> Bool
+strEqual a b = normalize NFC a == normalize NFC b
+
+-- | 文字列の **UTF-8 バイト列** (= R @charToRaw@・§14.6.1)。 各バイトを 'Word8' で
+--   返す (R は 16 進表示)。 例: @charToRaw "Hadley" == [0x48,0x61,0x64,0x6c,0x65,0x79]@。
+charToRaw :: Text -> [Word8]
+charToRaw = BS.unpack . TE.encodeUtf8
+
+-- ===========================================================================
+-- 行展開 (separate_longer・DataFrame)
+-- ===========================================================================
+--
+-- @separate_longer_*@ は 1 行を **複数行**に展開する (tidyr §14.4.1)。 対象列の
+-- 各セルを分割し、 piece 数だけその行を複製 (他列はそのまま複製) して、 対象列を
+-- flatten した piece で差し替える。 NA (Nothing) は分割せず 1 行のまま保持。
+
+-- | 区切り文字で行展開 (= @separate_longer_delim(df, col, delim)@)。
+--   例: 列 @x@ の @"a,b,c"@ → 3 行 (@"a"@/@"b"@/@"c"@)、 他列は複製。
+separateLongerDelim :: Text -> Text -> DF.DataFrame -> DF.DataFrame
+separateLongerDelim col delim =
+  separateLongerWith col $ \mv -> case mv of
+    Nothing -> [Nothing]
+    Just t  -> map Just (T.splitOn delim t)
+
+-- | 固定幅で行展開 (= @separate_longer_position(df, col, width)@)。
+--   各セルを先頭から @width@ 文字ずつの塊に分割して行展開する。
+--   例: @width=1@ で @"131"@ → 3 行 (@"1"@/@"3"@/@"1"@)。
+separateLongerPosition :: Text -> Int -> DF.DataFrame -> DF.DataFrame
+separateLongerPosition col width
+  | width <= 0 = error "separateLongerPosition: width は正でなければならない"
+  | otherwise  =
+      separateLongerWith col $ \mv -> case mv of
+        Nothing -> [Nothing]
+        Just t  | T.null t  -> [Just ""]            -- 空セルは 1 空行を保持
+                | otherwise -> map Just (T.chunksOf width t)
+
+-- | 行展開の核: 対象列を Text 読みし、 各行を @split@ で pieces 化。 piece 数で
+--   全列を 'rowsAtIndices' 複製し、 対象列を flatten した piece に差し替える。
+separateLongerWith
+  :: Text -> (Maybe Text -> [Maybe Text]) -> DF.DataFrame -> DF.DataFrame
+separateLongerWith col split df =
+  case getMaybeTextVec col df of
+    Nothing  -> error ("separateLonger: 列 " ++ T.unpack col
+                        ++ " が見つからない、 または Text 列でない")
+    Just vec ->
+      let piecesPerRow = map split (V.toList vec)                 -- [[Maybe Text]]
+          idxs = concat [ replicate (length ps) i
+                        | (i, ps) <- zip [0 ..] piecesPerRow ]
+          flat = concat piecesPerRow
+          expanded = DFS.rowsAtIndices (VU.fromList idxs) df
+      in DF.insertColumn col (buildTextCol flat) expanded
+
+-- | @[Maybe Text]@ → Column。 全要素 Just なら素の Text 列、 NA 混在なら Maybe 列。
+buildTextCol :: [Maybe Text] -> DFC.Column
+buildTextCol xs
+  | all isJust xs = DF.fromList [ t | Just t <- xs ]
+  | otherwise     = DF.fromList xs
+
+-- ===========================================================================
+-- 列分割 (separate_wider・DataFrame)
+-- ===========================================================================
+--
+-- @separate_wider_*@ は 1 セルを **複数列**に分割する (tidyr §14.4.2・行数不変)。
+-- piece 数と新列名の数が合わないときの方針を 'TooFew' / 'TooMany' で指定する
+-- (§14.4.3 の @too_few@ / @too_many@)。
+
+-- | piece が **足りない**ときの方針 (= @too_few@)。
+data TooFew
+  = AlignStart    -- ^ 不足分を右側に NA で埋める (= @"align_start"@)。
+  | AlignEnd      -- ^ 不足分を左側に NA で埋める (= @"align_end"@)。
+  | TooFewError   -- ^ 不足があれば error (= @"error"@・既定)。
+  | TooFewDebug   -- ^ align_start で埋めつつ診断列を付与 (= @"debug"@)。
+  deriving (Eq, Show)
+
+-- | piece が **多すぎる**ときの方針 (= @too_many@)。
+data TooMany
+  = DropExtra     -- ^ 余剰 piece を捨てる (= @"drop"@)。
+  | MergeExtra    -- ^ 余剰を最終列に区切り文字で再結合 (= @"merge"@)。
+  | TooManyError  -- ^ 余剰があれば error (= @"error"@・既定)。
+  | TooManyDebug  -- ^ drop で埋めつつ診断列 (余剰を remainder) を付与 (= @"debug"@)。
+  deriving (Eq, Show)
+
+-- | 区切り文字で列分割 (= @separate_wider_delim(df, col, delim, names)@・厳密)。
+--   piece 数と @names@ 数が不一致なら error。 @names@ の 'Nothing' はその piece を
+--   捨てる (= R の @NA@・§14.4.2)。
+separateWiderDelim
+  :: Text -> Text -> [Maybe Text] -> DF.DataFrame -> DF.DataFrame
+separateWiderDelim col delim names =
+  separateWiderDelimWith col delim names TooFewError TooManyError
+
+-- | 'separateWiderDelim' の方針指定版 (§14.4.3 の @too_few@ / @too_many@)。
+separateWiderDelimWith
+  :: Text -> Text -> [Maybe Text] -> TooFew -> TooMany -> DF.DataFrame -> DF.DataFrame
+separateWiderDelimWith col delim names tf tm =
+  separateWiderImpl col names tf tm (T.splitOn delim) (T.intercalate delim)
+
+-- | 固定幅で列分割 (= @separate_wider_position(df, col, widths)@・厳密)。
+--   @widths@ = @[(列名, 文字数)]@。 文字列長が総幅と一致しなければ error。
+separateWiderPosition
+  :: Text -> [(Text, Int)] -> DF.DataFrame -> DF.DataFrame
+separateWiderPosition col widths =
+  separateWiderPositionWith col widths TooFewError TooManyError
+
+-- | 'separateWiderPosition' の方針指定版。 文字列が総幅より短ければ @too_few@、
+--   長ければ余り (remainder) を @too_many@ で処理する。
+separateWiderPositionWith
+  :: Text -> [(Text, Int)] -> TooFew -> TooMany -> DF.DataFrame -> DF.DataFrame
+separateWiderPositionWith col widths tf tm df =
+  let names = map (Just . fst) widths
+      -- 幅順に切り出す。 文字列が尽きたら打ち切り (→ piece 不足 = too_few)。
+      -- 総幅を超える余りは最後に「余剰 piece」として 1 個付ける (→ too_many)。
+      chop t =
+        let go [] rest = ([], rest)
+            go (w : ws) rest
+              | T.null rest = ([], rest)
+              | otherwise   = let (h, r)   = T.splitAt w rest
+                                  (hs, r') = go ws r
+                              in (h : hs, r')
+            (pieces, remainder) = go (map snd widths) t
+        in if T.null remainder then pieces else pieces ++ [remainder]
+  in separateWiderImpl col names tf tm chop T.concat df
+
+-- | 列分割の核実装。 @split@ で各セルを piece 化し、 'TooFew' / 'TooMany' で
+--   ちょうど @length names@ スロットに整える。 'TooFewDebug' / 'TooManyDebug' の
+--   とき診断列 @{col}_ok@ / @{col}_pieces@ / @{col}_remainder@ を付ける。
+separateWiderImpl
+  :: Text -> [Maybe Text] -> TooFew -> TooMany
+  -> (Text -> [Text]) -> ([Text] -> Text)
+  -> DF.DataFrame -> DF.DataFrame
+separateWiderImpl col names tf tm split rejoin df =
+  case getMaybeTextVec col df of
+    Nothing  -> error ("separateWider: 列 " ++ T.unpack col
+                        ++ " が見つからない、 または Text 列でない")
+    Just vec ->
+      let cells    = V.toList vec
+          rows     = map (resolveRow . fmap split) cells
+          -- スロット行列を列向きに転置 (各新列 = 全行のその位置)。
+          slotCols = transpose [ s | (s, _, _) <- rows ]            -- [[Maybe Text]] (列×行)
+          oks      = [ ok | (_, ok, _) <- rows ]
+          rems     = [ r  | (_, _,  r) <- rows ]
+          pieceCnt = map (maybe 0 (length . split)) cells
+          debug    = tf == TooFewDebug || tm == TooManyDebug
+          -- names と slotCols を突き合わせ、 Just name の列だけ採用。
+          namedCols = [ (nm, buildTextCol scol)
+                      | (Just nm, scol) <- zip names slotCols ]
+          diagCols  = [ (col <> "_ok",        DF.fromList oks)
+                      , (col <> "_pieces",    DF.fromList pieceCnt)
+                      , (col <> "_remainder", DF.fromList rems) ]
+          base      = DF.exclude [col] df
+          insertAll = foldl (\d (nm, c) -> DF.insertColumn nm c d)
+          withNamed = insertAll base namedCols
+      in if debug then insertAll withNamed diagCols else withNamed
+  where
+    n0 = length names
+    -- 1 行を (スロット [Maybe Text]・ok・remainder) に解決。
+    -- remainder は診断列用で、 余剰なし行は "" (R の @{col}_remainder@ 準拠)。
+    resolveRow :: Maybe [Text] -> ([Maybe Text], Bool, Text)
+    resolveRow Nothing       = (replicate n0 Nothing, True, "")  -- NA 入力は全 NA
+    resolveRow (Just pieces) =
+      let k = length pieces
+      in if k == n0
+           then (map Just pieces, True, "")
+           else if k < n0
+             then case tf of
+               TooFewError -> error ("separateWider: piece が不足 (" ++ show k
+                                      ++ " < " ++ show n0 ++ ") col=" ++ T.unpack col)
+               AlignEnd    -> (replicate (n0 - k) Nothing ++ map Just pieces, False, "")
+               _           -> (map Just pieces ++ replicate (n0 - k) Nothing, False, "")
+                              -- AlignStart / TooFewDebug
+             else case tm of   -- k > n0
+               TooManyError -> error ("separateWider: piece が過多 (" ++ show k
+                                       ++ " > " ++ show n0 ++ ") col=" ++ T.unpack col)
+               MergeExtra   ->
+                 let (keep, extra) = splitAt (n0 - 1) pieces
+                 in (map Just keep ++ [Just (rejoin extra)], False, "")
+               _            ->  -- DropExtra / TooManyDebug
+                 let (keep, extra) = splitAt n0 pieces
+                 in (map Just keep, False, rejoin extra)
+
+-- ===========================================================================
+-- 内部
+-- ===========================================================================
+
+-- | 列群を recycling 規則 (長さ 1 か n) で n 行に揃え、 行ごとの列リストに転置。
+recycleCols :: [[a]] -> [[a]]
+recycleCols cols =
+  let n = maximum (map length cols)
+      recy c
+        | length c == n = c
+        | length c == 1 = replicate n (head c)
+        | otherwise     = error "str_c/glue: 列長は 1 か n でなければならない (recycling)"
+  in if n == 0 then [] else transpose (map recy cols)
+
+-- | @"a {x} b {y}"@ → @[Left "a ", Right "x", Left " b ", Right "y"]@。
+--   @{{@ / @}}@ はリテラルの @{@ / @}@ にエスケープ (glue 同様)。
+parseGlue :: Text -> [Either Text Text]
+parseGlue = go
+  where
+    go t
+      | T.null t  = []
+      | otherwise =
+          let (lit, rest) = T.break (== '{') t
+          in case T.uncons rest of
+               Nothing -> prependLit lit []
+               Just ('{', r1)
+                 | Just ('{', r2) <- T.uncons r1 ->   -- "{{" → リテラル '{'
+                     prependLit (lit <> "{") (go r2)
+                 | otherwise ->
+                     let (key, r2) = T.break (== '}') r1
+                     in case T.uncons r2 of
+                          Just ('}', r3) ->
+                            prependLit lit (Right (T.strip key) : go r3)
+                          _ -> error "strGlue: 閉じない { がある"
+               _ -> [Left lit]
+
+    -- リテラル segment では @}}@ を @}@ に畳む (glue の閉じ波括弧エスケープ。
+    -- @{{@ → @{@ は break 側で処理済み)。
+    prependLit l0 xs = let l = T.replace "}}" "}" l0 in [Left l | not (T.null l)] ++ xs
+
+-- ===========================================================================
+-- 正規表現 (§15 Regular expressions・regex-tdfa POSIX ERE)
+-- ===========================================================================
+--
+-- regex-tdfa は **POSIX ERE** ゆえ PCRE ショートハンド @\\d@ @\\s@ @\\w@ を解さない
+-- (実測 2026-06-19)。 本モジュールは R(stringr) 流のパターンをそのまま使えるよう、
+-- @\\d \\D \\s \\S \\w \\W@ を 'translateShorthand' で **POSIX クラスに変換**してから
+-- tdfa に渡す。 単語境界 @\\b@ は tdfa が直接対応。 ★後方参照 @\\1@ は POSIX に無く
+-- **非対応** (tutorial 側で「概念のみ」 honest 注記)。
+--
+-- 引数順は **pattern 先・string 後** (stringr は string 先だが、 Haskell では
+-- @map (strDetect pat) xs@ / @filter (strDetect pat) xs@ と部分適用しやすいため)。
+
+-- | PCRE ショートハンド (@\\d \\D \\s \\S \\w \\W@) を POSIX クラスに変換する。
+--   文字クラス @[...]@ の内外で展開形が違う (外: @[[:digit:]]@・内: @[:digit:]@)。
+--   @\\\\@ (literal backslash) や他のエスケープ (@\\.@ @\\b@ @\\1@ 等) はそのまま通す。
+translateShorthand :: Text -> Text
+translateShorthand = T.pack . go False . T.unpack
+  where
+    go _     []            = []
+    go inCls ('\\':c:rest)
+      | Just body <- lookup c shorthands = wrap inCls body ++ go inCls rest
+      | otherwise                        = '\\' : c : go inCls rest   -- \. \\ \b \1 等はそのまま
+    go _     ('[':rest)    = '[' : go True  rest
+    go _     (']':rest)    = ']' : go False rest
+    go inCls (c:rest)      = c   : go inCls rest
+
+    shorthands =
+      [ ('d', "[:digit:]"),  ('D', "^[:digit:]")
+      , ('s', "[:space:]"),  ('S', "^[:space:]")
+      , ('w', "[:alnum:]_"), ('W', "^[:alnum:]_") ]
+    -- クラス外は @[ ... ]@ で囲む (否定 ^... はクラス否定 [^...] に)。 クラス内は
+    -- 中身だけ ([:digit:] 等)。 クラス内の否定 (\D 等) は POSIX で表現不能ゆえ近似 (caret 落とし)。
+    wrap True  body          = stripCaret body
+    wrap False ('^':body)    = "[^" ++ body ++ "]"
+    wrap False body          = "[" ++ body ++ "]"
+    stripCaret ('^':b) = b
+    stripCaret b       = b
+
+-- | パターン (ショートハンド変換済) を tdfa 'Regex' に compile。
+--   @ci@ = ignore_case (§15.5 の @regex(ignore_case = TRUE)@)。
+--   @^@ @$@ は **文字列全体**の先頭/末尾 (R 既定・single line = multiline False)。
+mkRegex :: Bool -> Text -> Regex
+mkRegex ci pat =
+  RE.makeRegexOpts comp RE.defaultExecOpt (T.unpack (translateShorthand pat))
+  where
+    comp = RE.defaultCompOpt { caseSensitive = not ci, multiline = False }
+
+-- | マッチ配列 (whole + groups) を @[(text, offset, len)]@ に。 offset<0 = 不参加グループ。
+matchElems :: RE.MatchText String -> [(String, Int, Int)]
+matchElems arr = [ (g, o, l) | (g, (o, l)) <- elems arr ]
+
+-- | パターンにマッチするか (= @str_detect(string, pattern)@・§15.3.1)。
+strDetect :: Text -> Text -> Bool
+strDetect = strDetectWith False
+
+-- | 'strDetect' の ignore_case 指定版 (§15.5)。 @strDetectWith True pat s@ で大小無視。
+strDetectWith :: Bool -> Text -> Text -> Bool
+strDetectWith ci pat s = RE.matchTest (mkRegex ci pat) (T.unpack s)
+
+-- | マッチ回数 (= @str_count(string, pattern)@・§15.3.2)。
+strCount :: Text -> Text -> Int
+strCount pat s = RE.matchCount (mkRegex False pat) (T.unpack s)
+
+-- | マッチした要素だけ残す (= @str_subset(x, pattern)@)。
+strSubset :: Text -> [Text] -> [Text]
+strSubset pat = filter (strDetect pat)
+
+-- | マッチした要素の位置 (= @str_which@・**1 始まり**)。
+strWhich :: Text -> [Text] -> [Int]
+strWhich pat xs = [ i | (i, x) <- zip [1 ..] xs, strDetect pat x ]
+
+-- | 最初のマッチを取り出す (= @str_extract(string, pattern)@)。 無マッチは 'Nothing'。
+strExtract :: Text -> Text -> Maybe Text
+strExtract pat s =
+  case RE.matchOnceText (mkRegex False pat) (T.unpack s) of
+    Just (_, arr, _) | ((m, _, _) : _) <- matchElems arr -> Just (T.pack m)
+    _                                                    -> Nothing
+
+-- | すべてのマッチを取り出す (= @str_extract_all@)。
+strExtractAll :: Text -> Text -> [Text]
+strExtractAll pat s =
+  [ T.pack m
+  | arr <- RE.matchAllText (mkRegex False pat) (T.unpack s)
+  , ((m, _, _) : _) <- [matchElems arr] ]
+
+-- | 最初のマッチの **whole + capture groups** (= @str_match@)。 不参加グループ = 'Nothing'。
+--   先頭が whole match、 以降が @()@ グループ。 無マッチは @[]@。
+strMatch :: Text -> Text -> [Maybe Text]
+strMatch pat s =
+  case RE.matchOnceText (mkRegex False pat) (T.unpack s) of
+    Just (_, arr, _) -> [ if o < 0 then Nothing else Just (T.pack g)
+                        | (g, o, _) <- matchElems arr ]
+    Nothing          -> []
+
+-- | 最初のマッチを置換 (= @str_replace(string, pattern, replacement)@・§15.3.3)。
+--   replacement 内の @\\1@..@\\9@ は capture group 参照、 @\\\\@ はリテラル @\\@。
+strReplace :: Text -> Text -> Text -> Text
+strReplace = replaceImpl False
+
+-- | すべてのマッチを置換 (= @str_replace_all@)。
+strReplaceAll :: Text -> Text -> Text -> Text
+strReplaceAll = replaceImpl True
+
+replaceImpl :: Bool -> Text -> Text -> Text -> Text
+replaceImpl global pat rep s =
+  let rx      = mkRegex False pat
+      str     = T.unpack s
+      ms      = (if global then id else take 1) (RE.matchAllText rx str)
+      repl    = T.unpack rep
+      go cur [] = drop cur str
+      go cur (arr : rest) =
+        case matchElems arr of
+          gs@((_, o, l) : _) ->
+            take (o - cur) (drop cur str) ++ expandRep repl gs ++ go (o + l) rest
+          [] -> go cur rest
+  in T.pack (go 0 ms)
+
+-- | replacement の @\\n@ を group n に展開 (@\\0@=whole・@\\\\@=リテラル @\\@)。
+expandRep :: String -> [(String, Int, Int)] -> String
+expandRep r groups = ex r
+  where
+    ex []                = []
+    ex ('\\' : d : rest)
+      | d >= '0' && d <= '9' = grp (fromEnum d - fromEnum '0') ++ ex rest
+      | d == '\\'            = '\\' : ex rest
+      | otherwise           = d : ex rest
+    ex (c : rest)          = c : ex rest
+    grp i = case drop i groups of
+              ((g, o, _) : _) | o >= 0 -> g
+              _                        -> ""
+
+-- | 最初のマッチを削除 (= @str_remove@ = @str_replace(., pattern, "")@)。
+strRemove :: Text -> Text -> Text
+strRemove pat = strReplace pat ""
+
+-- | すべてのマッチを削除 (= @str_remove_all@)。
+strRemoveAll :: Text -> Text -> Text
+strRemoveAll pat = strReplaceAll pat ""
+
+-- | パターンで分割 (= @str_split(string, pattern)@)。 マッチ部分を区切りとして除く。
+strSplit :: Text -> Text -> [Text]
+strSplit pat s =
+  let rx  = mkRegex False pat
+      str = T.unpack s
+      ms  = RE.matchAllText rx str
+      go cur []          = [drop cur str]
+      go cur (arr : rest) =
+        case matchElems arr of
+          ((_, o, l) : _) -> take (o - cur) (drop cur str) : go (o + l) rest
+          []              -> go cur rest
+  in map T.pack (go 0 ms)
+
+-- | 最初のマッチの位置 @(start, end)@ (= @str_locate@・**1 始まり・両端含む**)。 無マッチ = 'Nothing'。
+strLocate :: Text -> Text -> Maybe (Int, Int)
+strLocate pat s =
+  case RE.matchOnceText (mkRegex False pat) (T.unpack s) of
+    Just (_, arr, _) | ((_, o, l) : _) <- matchElems arr, o >= 0 -> Just (o + 1, o + l)
+    _                                                            -> Nothing
+
+-- | 正規表現メタ文字をエスケープ (= @str_escape@・§15.6・リテラル文字列からパターンを作る用)。
+strEscape :: Text -> Text
+strEscape = T.concatMap esc
+  where
+    esc c | c `elem` metas = T.pack ['\\', c]
+          | otherwise      = T.singleton c
+    metas = ".^$|()[]{}*+?\\" :: String
+
+-- | 名前付きグループで列に分割 (= @separate_wider_regex(df, col, patterns)@・§15.3.4)。
+--   @specs@ = @[(Just 列名 | Nothing, 部分パターン)]@。 各部分パターンを順に capture group 化し、
+--   セルを文字列全体マッチ (@^...$@) して各 group を対応列へ。 'Nothing' の group は捨てる
+--   (= R の名無し)。 ★各部分パターンは **内部に capturing group を持たない前提**
+--   (持つと group index がずれる・R4DS の例は単純パターンのみ)。
+separateWiderRegex :: Text -> [(Maybe Text, Text)] -> DF.DataFrame -> DF.DataFrame
+separateWiderRegex col specs df =
+  case getMaybeTextVec col df of
+    Nothing  -> error ("separateWiderRegex: 列 " ++ T.unpack col
+                        ++ " が見つからない、 または Text 列でない")
+    Just vec ->
+      let pat   = "^" <> T.concat [ "(" <> p <> ")" | (_, p) <- specs ] <> "$"
+          rx    = mkRegex False pat
+          names = map fst specs
+          nslot = length specs
+          rowGroups mv = case mv of
+            Nothing -> replicate nslot Nothing
+            Just t  -> case RE.matchOnceText rx (T.unpack t) of
+              Just (_, arr, _) ->
+                let grps = drop 1 (matchElems arr)   -- whole match を除く
+                in take nslot
+                     ([ if o < 0 then Nothing else Just (T.pack g) | (g, o, _) <- grps ]
+                       ++ repeat Nothing)
+              Nothing -> error ("separateWiderRegex: パターン不一致 col=" ++ T.unpack col
+                                 ++ " value=" ++ show t)
+          rows     = map rowGroups (V.toList vec)
+          slotCols = transpose rows
+          named    = [ (nm, buildTextCol scol) | (Just nm, scol) <- zip names slotCols ]
+          base     = DF.exclude [col] df
+      in foldl (\d (nm, c) -> DF.insertColumn nm c d) base named
diff --git a/src/Hanalyze/Data/Transform.hs b/src/Hanalyze/Data/Transform.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Data/Transform.hs
@@ -0,0 +1,169 @@
+{-# LANGUAGE ScopedTypeVariables #-}
+-- |
+-- Module      : Hanalyze.Data.Transform
+-- Description : dplyr 流の順位・オフセット・累積・区間化を純粋な [a] -> [b] として提供
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- dplyr 流の順序付き/窓関数的なベクトル変換 (Phase 66)。
+--
+-- R4DS Ch13 "Numbers" で扱う順位・オフセット・累積・区間化・連続識別子を、
+-- **純粋な `[a] -> [b]`** として公開する。 統計 ('Stat.Descriptive') でも IO/DataFrame
+-- ('DataIO') でもなく、 純粋データ抽象の `Data/` 名前空間に置く ('Data.ColumnSource'
+-- の隣)。 DataFrame 直結解析 API (Phase 67) の @mutate@ もこれを呼ぶ。
+--
+-- === NA
+-- 順位関数は NA を含むベクトル用に @*NA@ 変種 (@[Maybe a] -> [Maybe b]@・dplyr の
+-- @na.last="keep"@ と同じく Nothing は Nothing を保ち、 残りを順位付け) を併設する。
+--
+-- === desc
+-- 降順順位は別関数を設けず 'Data.Ord.Down' を被せる (@minRank (map Down xs)@・
+-- NA 付きは @minRankNA (map (fmap Down) xs)@)。
+module Hanalyze.Data.Transform
+  ( -- * 順位 (dplyr ranking)
+    minRank, denseRank, rowNumber
+  , percentRank, cumeDist
+    -- * 順位 (NA 保持変種)
+  , minRankNA, denseRankNA, rowNumberNA
+  , percentRankNA, cumeDistNA
+    -- * オフセット
+  , lag, lead
+    -- * 累積
+  , cumsum, cumprod, cummin, cummax, cummean
+    -- * 区間化
+  , cut, cutLabels
+    -- * 連続識別子
+  , consecutiveId
+  ) where
+
+import           Data.List       (sortOn)
+import qualified Data.Map.Strict as M
+import qualified Data.Set        as S
+
+-- ===========================================================================
+-- 順位
+-- ===========================================================================
+
+-- | 最小順位法 (dplyr @min_rank@・tie は最小を共有し次を飛ばす: 1,2,2,4)。
+--   各値の順位 = 1 + (厳密に小さい要素数)。
+minRank :: Ord a => [a] -> [Int]
+minRank xs = map ((rm M.!) ) xs
+  where
+    sorted = sortOn id xs
+    -- 値 → 最初の出現 index (= 厳密に小さい要素数)。1 始まりにして格納。
+    rm = M.fromListWith min [ (v, i + 1) | (i, v) <- zip [0 ..] sorted ]
+
+-- | 密順位法 (dplyr @dense_rank@・tie で番号を飛ばさない: 1,2,2,3)。
+--   各値の順位 = その値以下の **相異なる値の個数**。
+denseRank :: Ord a => [a] -> [Int]
+denseRank xs = map (dm M.!) xs
+  where
+    distinct = S.toAscList (S.fromList xs)
+    dm = M.fromList (zip distinct [1 :: Int ..])
+
+-- | 行番号 (dplyr @row_number@・tie も出現順で一意: 1,2,3,4)。
+rowNumber :: Ord a => [a] -> [Int]
+rowNumber xs = map (rm M.!) [0 .. n - 1]
+  where
+    n     = length xs
+    order = map fst (sortOn snd (zip [0 :: Int ..] xs))  -- (origIdx, value) を value, origIdx 昇順
+    rm    = M.fromList (zip order [1 :: Int ..])
+
+-- | パーセント順位 (dplyr @percent_rank@ = (minRank - 1)/(n - 1))。
+percentRank :: Ord a => [a] -> [Double]
+percentRank xs
+  | n <= 1    = map (const 0) xs
+  | otherwise = [ fromIntegral (r - 1) / fromIntegral (n - 1) | r <- minRank xs ]
+  where n = length xs
+
+-- | 累積分布 (dplyr @cume_dist@ = (≤x の個数)/n)。
+cumeDist :: Ord a => [a] -> [Double]
+cumeDist xs = map (\x -> fromIntegral (cm M.! x) / fromIntegral n) xs
+  where
+    n      = length xs
+    sorted = sortOn id xs
+    -- 値 → その値の最後の出現 index + 1 (= ≤ その値の個数)。
+    cm = M.fromListWith max [ (v, i + 1) | (i, v) <- zip [0 ..] sorted ]
+
+-- --- NA 保持変種 -----------------------------------------------------------
+
+-- | 非 NA だけを @f@ で順位付けし、 NA (Nothing) は Nothing のまま位置を保つ。
+onJusts :: forall a b. ([a] -> [b]) -> [Maybe a] -> [Maybe b]
+onJusts f xs =
+  let idxVals = [ (i, a) | (i, Just a) <- zip [0 ..] xs ]
+      ranked  = f (map snd idxVals)
+      m       = M.fromList (zip (map fst idxVals) ranked)
+  in [ M.lookup i m | i <- [0 .. length xs - 1] ]
+
+minRankNA     :: Ord a => [Maybe a] -> [Maybe Int]
+minRankNA      = onJusts minRank
+denseRankNA   :: Ord a => [Maybe a] -> [Maybe Int]
+denseRankNA    = onJusts denseRank
+rowNumberNA   :: Ord a => [Maybe a] -> [Maybe Int]
+rowNumberNA    = onJusts rowNumber
+percentRankNA :: Ord a => [Maybe a] -> [Maybe Double]
+percentRankNA  = onJusts percentRank
+cumeDistNA    :: Ord a => [Maybe a] -> [Maybe Double]
+cumeDistNA     = onJusts cumeDist
+
+-- ===========================================================================
+-- オフセット
+-- ===========================================================================
+
+-- | @lag n d xs@: 各値を n 個後ろへずらし、 先頭 n 個を default @d@ で埋める
+--   (dplyr @lag(x, n, default)@・既定 R は NA)。 入力と同長。
+lag :: Int -> a -> [a] -> [a]
+lag n d xs = take (length xs) (replicate n d ++ xs)
+
+-- | @lead n d xs@: 各値を n 個前へずらし、 末尾 n 個を default @d@ で埋める。
+lead :: Int -> a -> [a] -> [a]
+lead n d xs = take (length xs) (drop n xs ++ repeat d)
+
+-- ===========================================================================
+-- 累積
+-- ===========================================================================
+
+cumsum  :: Num a => [a] -> [a]
+cumsum   = scanl1 (+)
+cumprod :: Num a => [a] -> [a]
+cumprod  = scanl1 (*)
+cummin  :: Ord a => [a] -> [a]
+cummin   = scanl1 min
+cummax  :: Ord a => [a] -> [a]
+cummax   = scanl1 max
+
+-- | 累積平均 (dplyr @cummean@・i 番目 = 先頭 i 個の平均)。
+cummean :: [Double] -> [Double]
+cummean xs = zipWith (\s i -> s / fromIntegral i) (scanl1 (+) xs) [1 :: Int ..]
+
+-- ===========================================================================
+-- 区間化 (base R cut・既定 right = TRUE → (a, b])
+-- ===========================================================================
+
+-- | @cut breaks xs@: 各値が属する bin の index (1 始まり) を返す。 境界は昇順前提・
+--   区間は @(lo, hi]@ (right=TRUE)・範囲外は Nothing (= R の NA)。
+cut :: [Double] -> [Double] -> [Maybe Int]
+cut breaks = map binOf
+  where
+    intervals = zip [1 :: Int ..] (zip breaks (drop 1 breaks))
+    binOf x = case [ i | (i, (lo, hi)) <- intervals, x > lo, x <= hi ] of
+                (i : _) -> Just i
+                []      -> Nothing
+
+-- | ラベル付き 'cut' (@labels@ は @breaks - 1@ 個)。
+cutLabels :: [b] -> [Double] -> [Double] -> [Maybe b]
+cutLabels labels breaks = map (fmap (labels !!) . fmap (subtract 1)) . cut breaks
+
+-- ===========================================================================
+-- 連続識別子 (dplyr consecutive_id・値が変わるたび +1)
+-- ===========================================================================
+
+consecutiveId :: Eq a => [a] -> [Int]
+consecutiveId = go Nothing 0
+  where
+    go _ _ [] = []
+    go prev cur (y : ys) =
+      let cur' = case prev of
+                   Just p | p == y -> cur
+                   _               -> cur + 1
+      in cur' : go (Just y) cur' ys
diff --git a/src/Hanalyze/Data/Wrangle.hs b/src/Hanalyze/Data/Wrangle.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Data/Wrangle.hs
@@ -0,0 +1,255 @@
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE FlexibleInstances   #-}
+{-# LANGUAGE OverloadedStrings   #-}
+-- |
+-- Module      : Hanalyze.Data.Wrangle
+-- Description : DataFrame 直結の dplyr 風 summarise/mutate/groupBy 動詞
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- DataFrame 直結の解析動詞 (Phase 67)。
+--
+-- hgg の @df |>> layer (scatter "x" "y")@ と対称に、 DataFrame を直接
+-- データ源として dplyr の @summarise@ / @mutate@ / @group_by@ 相当を
+-- 「列名参照 + パイプライン + DataFrame in/out」 で書ける薄層。
+--
+-- 数値ロジックは 'Hanalyze.Stat.Descriptive' (Phase 65) と
+-- 'Hanalyze.Data.Transform' (Phase 66) に委譲し、 本モジュールは
+-- **列名解決 + 群化 + DataFrame 組み立て + NA 処理**のみを担う。
+--
+-- @
+-- import DataFrame.Operators ((|>))
+-- df |> summarise [ "mean" =: meanOf "dep_delay", "q95" =: quantileOf 0.95 "dep_delay", "n" =: nOf ]
+-- df |> mutate    [ "z" =: zscoreOf "x", "rank" =: minRankOf "dep_delay" ]
+-- df |> groupBy ["year","month","day"] |> summarise [ "mean" =: meanOf "dep_delay" ]
+-- @
+--
+-- 集約子は既定で **NA 除去 (na.rm=TRUE 相当)**。 数値列のみ対象 (factor は群キー
+-- としてのみ扱う)。 群の並びは dplyr 同様キー昇順。
+--
+-- v1 の制限 (判断申し送り): 入力は Hackage @DataFrame@ 限定 ('ColumnSource' 全多相化は
+-- 後続)。 grouped @mutate@ は未対応 (ungrouped のみ)。
+module Hanalyze.Data.Wrangle
+  ( -- * 動詞
+    summarise, Summarisable
+  , mutate
+  , groupBy, Grouped
+    -- * 命名
+  , (=:)
+    -- * 集約子 (列名 → スカラ)
+  , Agg
+  , meanOf, medianOf, sdOf, varOf, iqrOf, quantileOf, sumOf, minOf, maxOf
+  , nOf, nDistinctOf
+    -- * 列式 (mutate・列名 → 列)
+  , ColExpr
+  , zscoreOf, minRankOf, denseRankOf, lagOf, leadOf, cumsumOf
+  ) where
+
+import           Data.Maybe               (catMaybes, isJust)
+import           Data.List                (nub, transpose)
+import qualified Data.Map.Strict          as M
+import qualified Data.Vector              as V
+import           Data.Text                (Text)
+import qualified DataFrame.Internal.Column    as DF
+import qualified DataFrame.Internal.DataFrame  as DF
+import qualified DataFrame.Operations.Core     as DF
+import qualified DataFrame.Internal.Column as DFC
+
+import           Hanalyze.DataIO.Convert    (getMaybeTextVec)
+import           Hanalyze.DataIO.Preprocess (readMaybeDoubleColumn)
+import qualified Hanalyze.Stat.Descriptive  as D
+import qualified Hanalyze.Data.Transform    as T
+
+-- ===========================================================================
+-- 内部表現
+-- ===========================================================================
+
+-- | 1 群分のデータ (行数 + 数値列の name→値・NA 保持・行整列)。
+data Group = Group
+  { gSize :: !Int
+  , gNum  :: !(M.Map Text [Maybe Double])
+  }
+
+-- | 集約結果のセル (数値列 or 整数列)。
+data Cell = CD !Double | CI !Int
+
+-- | 群キーの 1 要素 (数値 or 文字列)。Ord は KNum<KTxt・KNum は数値順。
+data KeyVal = KNum !Double | KTxt !Text deriving (Eq, Ord)
+
+-- | 集約子: 群 → セル。
+newtype Agg = Agg (Group -> Cell)
+
+-- | 列式: 群 → 新しい列 (NA 保持)。
+newtype ColExpr = ColExpr (Group -> [Maybe Double])
+
+-- | 結果列名 + 操作 を結ぶ。
+(=:) :: Text -> a -> (Text, a)
+(=:) = (,)
+infixr 0 =:
+
+-- ===========================================================================
+-- 集約子 (na.rm = TRUE 既定)
+-- ===========================================================================
+
+col1 :: Text -> ([Double] -> Double) -> Agg
+col1 c f = Agg (\g -> CD (f (catMaybes (M.findWithDefault [] c (gNum g)))))
+
+meanOf, medianOf, sdOf, varOf, iqrOf, sumOf, minOf, maxOf :: Text -> Agg
+meanOf   c = col1 c D.meanL
+medianOf c = col1 c D.medianL
+sdOf     c = col1 c D.sdL
+varOf    c = col1 c D.varianceL
+iqrOf    c = col1 c D.iqrL
+sumOf    c = col1 c sum
+minOf    c = col1 c minimum
+maxOf    c = col1 c maximum
+
+quantileOf :: Double -> Text -> Agg
+quantileOf p c = col1 c (D.quantileL p)
+
+-- | 行数 (= R @n()@)。
+nOf :: Agg
+nOf = Agg (CI . gSize)
+
+-- | 相異なる非 NA 値の個数 (= R @n_distinct()@)。
+nDistinctOf :: Text -> Agg
+nDistinctOf c = Agg (\g -> CI (length (nub (catMaybes (M.findWithDefault [] c (gNum g))))))
+
+-- ===========================================================================
+-- 列式 (mutate)
+-- ===========================================================================
+
+colE :: Text -> ([Maybe Double] -> [Maybe Double]) -> ColExpr
+colE c f = ColExpr (\g -> f (M.findWithDefault [] c (gNum g)))
+
+-- | Z スコア (x - mean) / sd。 NA は NA のまま。
+zscoreOf :: Text -> ColExpr
+zscoreOf c = colE c $ \xs ->
+  let ys = catMaybes xs; m = D.meanL ys; s = D.sdL ys
+  in map (fmap (\x -> (x - m) / s)) xs
+
+-- | 最小順位 (dplyr min_rank・NA 保持)。
+minRankOf :: Text -> ColExpr
+minRankOf c = colE c (map (fmap fromIntegral) . T.minRankNA)
+
+-- | 密順位 (dplyr dense_rank・NA 保持)。
+denseRankOf :: Text -> ColExpr
+denseRankOf c = colE c (map (fmap fromIntegral) . T.denseRankNA)
+
+-- | n 個ラグ (先頭を NA 埋め)。
+lagOf :: Int -> Text -> ColExpr
+lagOf n c = colE c (T.lag n Nothing)
+
+-- | n 個リード (末尾を NA 埋め)。
+leadOf :: Int -> Text -> ColExpr
+leadOf n c = colE c (T.lead n Nothing)
+
+-- | 累積和 (NA は以降へ伝播 = R cumsum)。
+cumsumOf :: Text -> ColExpr
+cumsumOf c = colE c scan
+  where scan []       = []
+        scan (x : xs) = scanl (\acc y -> (+) <$> acc <*> y) x xs
+
+-- ===========================================================================
+-- 列抽出・群化
+-- ===========================================================================
+
+nrows :: DF.DataFrame -> Int
+nrows = fst . DF.dimensions
+
+-- | 数値列をすべて NA 保持・行整列で取り出す。
+numColumns :: DF.DataFrame -> [(Text, V.Vector (Maybe Double))]
+numColumns df =
+  [ (n, V.fromList ms) | n <- DF.columnNames df, Just ms <- [readMaybeDoubleColumn n df] ]
+
+-- | 群キー列を 'KeyVal' 列として取り出す (数値優先・無理なら Text)。
+keyColumn :: Text -> DF.DataFrame -> [KeyVal]
+keyColumn name df =
+  -- 数値読みが「全 NA」 なら実体は Text 列ゆえ Text 抽出に倒す
+  -- (readMaybeDoubleColumn は Text 列を Just [Nothing..] で返す罠の回避)。
+  case readMaybeDoubleColumn name df of
+    Just ms | any isJust ms -> map (maybe (KNum (0/0)) KNum) ms
+    _ -> case getMaybeTextVec name df of
+      Just v  -> map (maybe (KTxt "NA") KTxt) (V.toList v)
+      Nothing -> replicate (nrows df) (KTxt "NA")
+
+-- | 各行の群キー tuple。
+rowKeys :: [Text] -> DF.DataFrame -> [[KeyVal]]
+rowKeys keys df = transpose (map (`keyColumn` df) keys)
+
+-- | キー昇順の群 (キー tuple, 行 index 群)。
+groupRows :: [Text] -> DF.DataFrame -> [([KeyVal], [Int])]
+groupRows keys df =
+  M.toAscList (M.fromListWith (flip (++)) (zip (rowKeys keys df) (map (: []) [0 ..])))
+
+mkGroup :: [(Text, V.Vector (Maybe Double))] -> [Int] -> Group
+mkGroup ncols idxs = Group
+  { gSize = length idxs
+  , gNum  = M.fromList [ (n, [ v V.! i | i <- idxs ]) | (n, v) <- ncols ]
+  }
+
+-- ===========================================================================
+-- セル / キー列 → DataFrame Column
+-- ===========================================================================
+
+buildCol :: [Cell] -> DFC.Column
+buildCol cells
+  | all isCI cells = DF.fromList [ i | CI i <- cells ]
+  | otherwise      = DF.fromList (map toD cells)
+  where
+    isCI (CI _) = True
+    isCI _      = False
+    toD (CD x)  = x
+    toD (CI i)  = fromIntegral i
+
+buildKeyCol :: [KeyVal] -> DFC.Column
+buildKeyCol ks
+  | all isTxt ks                  = DF.fromList [ t | KTxt t <- ks ]
+  | all isWholeNum ks             = DF.fromList [ round d :: Int | KNum d <- ks ]
+  | otherwise                     = DF.fromList [ d | KNum d <- ks ]
+  where
+    isTxt (KTxt _) = True
+    isTxt _        = False
+    isWholeNum (KNum d) = not (isNaN d) && d == fromIntegral (round d :: Int)
+    isWholeNum _        = False
+
+-- ===========================================================================
+-- 動詞
+-- ===========================================================================
+
+-- | 群化された DataFrame (キー列名を保持)。
+data Grouped = Grouped ![Text] !DF.DataFrame
+
+-- | dplyr @group_by@。
+groupBy :: [Text] -> DF.DataFrame -> Grouped
+groupBy = Grouped
+
+-- | @summarise@ は DataFrame (= 1 群) でも 'Grouped' でも使える。
+class Summarisable g where
+  summarise :: [(Text, Agg)] -> g -> DF.DataFrame
+
+instance Summarisable DF.DataFrame where
+  summarise aggs df =
+    let g = Group { gSize = nrows df
+                  , gNum  = M.fromList (map (fmap V.toList) (numColumns df)) }
+    in DF.fromNamedColumns [ (rn, buildCol [runA a g]) | (rn, a) <- aggs ]
+
+instance Summarisable Grouped where
+  summarise aggs (Grouped keys df) =
+    let ncols = numColumns df
+        grps  = [ (kt, mkGroup ncols idxs) | (kt, idxs) <- groupRows keys df ]
+        keyCols = [ (kn, buildKeyCol [ kt !! j | (kt, _) <- grps ])
+                  | (j, kn) <- zip [0 ..] keys ]
+        resCols = [ (rn, buildCol [ runA a g | (_, g) <- grps ])
+                  | (rn, a) <- aggs ]
+    in DF.fromNamedColumns (keyCols ++ resCols)
+
+runA :: Agg -> Group -> Cell
+runA (Agg f) = f
+
+-- | dplyr @mutate@ (ungrouped・元列を温存して新列を右端に足す)。
+mutate :: [(Text, ColExpr)] -> DF.DataFrame -> DF.DataFrame
+mutate exprs df =
+  let g = Group { gSize = nrows df
+                , gNum  = M.fromList (map (fmap V.toList) (numColumns df)) }
+  in foldl (\d (nm, ColExpr f) -> DF.insertVector nm (V.fromList (f g)) d) df exprs
diff --git a/src/Hanalyze/DataIO/CSV.hs b/src/Hanalyze/DataIO/CSV.hs
--- a/src/Hanalyze/DataIO/CSV.hs
+++ b/src/Hanalyze/DataIO/CSV.hs
@@ -1,5 +1,11 @@
 {-# LANGUAGE OverloadedStrings #-}
--- | CSV / TSV / SSV loaders that return Hackage @dataframe@'s
+-- |
+-- Module      : Hanalyze.DataIO.CSV
+-- Description : CSV / TSV / SSV を Hackage dataframe の DataFrame として読み込むローダ群
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- CSV / TSV / SSV loaders that return Hackage @dataframe@'s
 -- 'DataFrame.Internal.DataFrame.DataFrame' directly.
 --
 --   * CSV / TSV — delegated to Hackage's 'DX.readCsv' / 'DX.readTsv'
@@ -24,7 +30,9 @@
   , ParseError
   ) where
 
-import qualified DataFrame                    as DX
+import qualified DataFrame.Internal.Column    as DX
+import qualified DataFrame.Internal.DataFrame  as DX
+import qualified DataFrame.IO.CSV              as DX
 import qualified DataFrame.IO.CSV             as DXIO
 import qualified DataFrame.Internal.DataFrame as DXD
 
diff --git a/src/Hanalyze/DataIO/Clean.hs b/src/Hanalyze/DataIO/Clean.hs
--- a/src/Hanalyze/DataIO/Clean.hs
+++ b/src/Hanalyze/DataIO/Clean.hs
@@ -1,7 +1,13 @@
 {-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE TypeApplications #-}
--- | Column-level cleaning DSL.
+-- |
+-- Module      : Hanalyze.DataIO.Clean
+-- Description : Health check の警告を数値化ルールへ変換する列単位クリーニング DSL
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
 --
+-- Column-level cleaning DSL.
+--
 -- Health checks ('Hanalyze.DataIO.Health') only emit warnings for columns
 -- containing currency symbols, thousands separators, units, or alternate
 -- decimal points. This module turns each warning into an explicit rule
@@ -45,7 +51,9 @@
   , fillBlankNames
   ) where
 
-import qualified DataFrame                    as DX
+import qualified DataFrame.Internal.Column    as DX
+import qualified DataFrame.Internal.DataFrame  as DX
+import qualified DataFrame.Operations.Core     as DX
 import qualified DataFrame.Internal.DataFrame as DXD
 import qualified DataFrame.Internal.Column    as DXC
 
diff --git a/src/Hanalyze/DataIO/Convert.hs b/src/Hanalyze/DataIO/Convert.hs
--- a/src/Hanalyze/DataIO/Convert.hs
+++ b/src/Hanalyze/DataIO/Convert.hs
@@ -1,11 +1,17 @@
 {-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE TypeApplications #-}
 {-# LANGUAGE ScopedTypeVariables #-}
--- | Safe extraction of numeric / Text vectors from a Hackage @dataframe@
+-- |
+-- Module      : Hanalyze.DataIO.Convert
+-- Description : Hackage dataframe から数値/Text 列を安全に Vector へ抽出する変換層
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Safe extraction of numeric / Text vectors from a Hackage @dataframe@
 -- ('DXD.DataFrame'). Used widely across @Model.*@ and @Viz.*@.
 --
---   * 'getDoubleVec' — normalize Double / Int / Maybe Double / Maybe Int /
---     Text columns to @V.Vector Double@. Text values are parsed; if any
+--   * 'getDoubleVec' — normalize Double / Int / Integer / Maybe Double /
+--     Maybe Int / Maybe Integer / Text columns to @V.Vector Double@. Text values are parsed; if any
 --     missing slot is present (null bitmap or NA string), returns
 --     'Nothing' so model fits cannot crash on missing data.
 --   * 'getTextVec'   — extract a Text column. Returns 'Nothing' on type
@@ -16,7 +22,8 @@
   , getMaybeTextVec
   ) where
 
-import qualified DataFrame                    as DX
+import qualified DataFrame.Operators           as DX
+import qualified DataFrame.Operations.Core     as DX
 import qualified DataFrame.Internal.Column    as DXC
 import qualified DataFrame.Internal.DataFrame as DXD
 
diff --git a/src/Hanalyze/DataIO/External.hs b/src/Hanalyze/DataIO/External.hs
--- a/src/Hanalyze/DataIO/External.hs
+++ b/src/Hanalyze/DataIO/External.hs
@@ -1,5 +1,11 @@
 {-# LANGUAGE OverloadedStrings #-}
--- | External data-format loaders (Parquet / JSON) via the Hackage
+-- |
+-- Module      : Hanalyze.DataIO.External
+-- Description : Hackage dataframe 経由の Parquet / JSON 外部フォーマットローダ
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- External data-format loaders (Parquet / JSON) via the Hackage
 -- @dataframe@ library.
 --
 -- Returns Hackage's 'DataFrame.Internal.DataFrame.DataFrame' directly.
@@ -9,7 +15,7 @@
   , loadJSON
   ) where
 
-import qualified DataFrame                    as DX
+import qualified DataFrame.IO.Parquet          as DX
 import qualified DataFrame.Internal.DataFrame as DXD
 import qualified DataFrame.IO.JSON            as DXJ
 
diff --git a/src/Hanalyze/DataIO/Health.hs b/src/Hanalyze/DataIO/Health.hs
--- a/src/Hanalyze/DataIO/Health.hs
+++ b/src/Hanalyze/DataIO/Health.hs
@@ -1,5 +1,11 @@
 {-# LANGUAGE OverloadedStrings #-}
--- | DataFrame health check. Surfaces the "looks suspicious" patterns that
+-- |
+-- Module      : Hanalyze.DataIO.Health
+-- Description : 読み込み済み DataFrame の疑わしいパターンを警告コード (W001〜W008) として検出
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- DataFrame health check. Surfaces the "looks suspicious" patterns that
 -- can hide in a successfully-loaded DataFrame, as warning codes.
 --
 -- Codes detected:
@@ -39,7 +45,8 @@
   , detectRagged
   ) where
 
-import qualified DataFrame                    as DX
+import qualified DataFrame.Internal.DataFrame  as DX
+import qualified DataFrame.Operations.Core     as DX
 import qualified DataFrame.Internal.Column    as DXC
 import qualified DataFrame.Internal.DataFrame as DXD
 
diff --git a/src/Hanalyze/DataIO/Log.hs b/src/Hanalyze/DataIO/Log.hs
--- a/src/Hanalyze/DataIO/Log.hs
+++ b/src/Hanalyze/DataIO/Log.hs
@@ -1,5 +1,11 @@
 {-# LANGUAGE OverloadedStrings #-}
--- | Structured warning / informational messaging shared by data loaders
+-- |
+-- Module      : Hanalyze.DataIO.Log
+-- Description : データローダ / 前処理が共有する構造化警告・情報メッセージ (LogEntry/LogReport)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Structured warning / informational messaging shared by data loaders
 -- and preprocessing.
 --
 --   * 'LogEntry'        — a single message (severity / code / body / hint).
diff --git a/src/Hanalyze/DataIO/Preprocess.hs b/src/Hanalyze/DataIO/Preprocess.hs
--- a/src/Hanalyze/DataIO/Preprocess.hs
+++ b/src/Hanalyze/DataIO/Preprocess.hs
@@ -2,8 +2,14 @@
 {-# LANGUAGE TypeApplications #-}
 {-# LANGUAGE ScopedTypeVariables #-}
 {-# LANGUAGE AllowAmbiguousTypes #-}
--- | Data-preprocessing helpers built on Hackage's @dataframe@.
+-- |
+-- Module      : Hanalyze.DataIO.Preprocess
+-- Description : Hackage dataframe 上の前処理ヘルパ (欠損検出/除去/補完・列選択・派生列)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
 --
+-- Data-preprocessing helpers built on Hackage's @dataframe@.
+--
 -- All operations consume and produce 'DXD.DataFrame'.
 --
 --   * Missing-value detection, removal, and imputation
@@ -63,7 +69,11 @@
   , regridLong
   ) where
 
-import qualified DataFrame                    as DX
+import qualified DataFrame.Internal.Column    as DX
+import qualified DataFrame.Internal.DataFrame  as DX
+import qualified DataFrame.Operators           as DX
+import qualified DataFrame.Operations.Core     as DX
+import qualified DataFrame.Operations.Subset   as DX
 import qualified DataFrame.Internal.Column    as DXC
 import qualified DataFrame.Internal.DataFrame as DXD
 import qualified DataFrame.Internal.Types     as DXT
@@ -299,13 +309,20 @@
             Just xs -> Just (map Just xs)
             Nothing -> case tryColumnAsList @Int name df of
               Just xs -> Just (map (Just . fromIntegral) xs)
-              Nothing -> case tryColumnAsList @Text name df of
-                Just xs -> Just
-                  [ if isNAString t
-                      then Nothing
-                      else readMaybe (T.unpack t)
-                  | t <- xs ]
-                Nothing -> Nothing
+              -- Phase 60.3: Integer 列の黙殺根治 ([[numericcols-integer-silent-drop]])。
+              -- 判定列に Integer / Maybe Integer が無く、 df |-> hbm の group 列が
+              -- Integer 型だと dataNamed が黙って空になっていた。
+              Nothing -> case tryColumnAsList @Integer name df of
+                Just xs -> Just (map (Just . fromIntegral) xs)
+                Nothing -> case tryColumnAsList @(Maybe Integer) name df of
+                  Just xs -> Just (map (fmap fromIntegral) xs)
+                  Nothing -> case tryColumnAsList @Text name df of
+                    Just xs -> Just
+                      [ if isNAString t
+                          then Nothing
+                          else readMaybe (T.unpack t)
+                      | t <- xs ]
+                    Nothing -> Nothing
 
 -- | Convert a Text column into a Double column. Returns 'Nothing' if
 -- any cell is missing or fails to parse.
diff --git a/src/Hanalyze/DataIO/Reshape.hs b/src/Hanalyze/DataIO/Reshape.hs
--- a/src/Hanalyze/DataIO/Reshape.hs
+++ b/src/Hanalyze/DataIO/Reshape.hs
@@ -1,5 +1,11 @@
 {-# LANGUAGE OverloadedStrings #-}
--- | Data-frame reshaping helpers that are missing in Hackage
+-- |
+-- Module      : Hanalyze.DataIO.Reshape
+-- Description : Hackage dataframe に無い reshape 操作 (pivotWider・oneHot・lag/lead・rolling)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Data-frame reshaping helpers that are missing in Hackage
 -- @dataframe@:
 --
 --   * 'pivotWider' — long → wide reshape (inverse of @meltLonger@).
@@ -22,7 +28,9 @@
 
 import qualified Data.Text             as T
 import qualified Data.Vector           as V
-import qualified DataFrame             as DX
+import qualified DataFrame.Internal.Column    as DX
+import qualified DataFrame.Internal.DataFrame  as DX
+import qualified DataFrame.Operations.Subset   as DX
 import qualified DataFrame.Internal.DataFrame as DXD
 import qualified Hanalyze.DataIO.Convert        as Conv
 import           Data.Maybe            (fromMaybe)
diff --git a/src/Hanalyze/DataIO/Sniff.hs b/src/Hanalyze/DataIO/Sniff.hs
--- a/src/Hanalyze/DataIO/Sniff.hs
+++ b/src/Hanalyze/DataIO/Sniff.hs
@@ -1,5 +1,11 @@
 {-# LANGUAGE OverloadedStrings #-}
--- | Auto-detect a CSV's delimiter, comment lines, presence of header,
+-- |
+-- Module      : Hanalyze.DataIO.Sniff
+-- Description : CSV の delimiter・comment・header 有無・NA 候補を先頭 8KB から推測する
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Auto-detect a CSV's delimiter, comment lines, presence of header,
 -- and NA candidates by inspecting the first 8 KB. While @LoadOpts@ lets
 -- the user state these explicitly, this module adds a layer that guesses
 -- when nothing is specified.
diff --git a/src/Hanalyze/Design/Anova.hs b/src/Hanalyze/Design/Anova.hs
--- a/src/Hanalyze/Design/Anova.hs
+++ b/src/Hanalyze/Design/Anova.hs
@@ -1,5 +1,11 @@
 {-# LANGUAGE OverloadedStrings #-}
--- | ANOVA / ANCOVA tables.
+-- |
+-- Module      : Hanalyze.Design.Anova
+-- Description : 一元配置・二元配置 ANOVA/ANCOVA 表の算出 (F 値・p 値・η² 効果量)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- ANOVA / ANCOVA tables.
 --
 -- Computes one-way and two-way analysis of variance, reporting F values,
 -- p values, and the @η²@ effect size.
diff --git a/src/Hanalyze/Design/Block.hs b/src/Hanalyze/Design/Block.hs
--- a/src/Hanalyze/Design/Block.hs
+++ b/src/Hanalyze/Design/Block.hs
@@ -1,5 +1,11 @@
 {-# LANGUAGE OverloadedStrings #-}
--- | Block designs: Latin squares and randomized complete block designs.
+-- |
+-- Module      : Hanalyze.Design.Block
+-- Description : ブロック計画 (ラテン方格・グレコラテン方格・乱塊法) の生成
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Block designs: Latin squares and randomized complete block designs.
 --
 --   * 'latinSquare'        — @n × n@ Latin square (efficient arrangement
 --     of @n@ treatments).
diff --git a/src/Hanalyze/Design/Constraint.hs b/src/Hanalyze/Design/Constraint.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Design/Constraint.hs
@@ -0,0 +1,93 @@
+{-# LANGUAGE OverloadedStrings #-}
+-- |
+-- Module      : Hanalyze.Design.Constraint
+-- Description : DoE 古典側の設計制約 (線形不等式・禁止行の組合せ) によるフィルタ / 検証
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- DoE 古典側の設計制約 (Phase 23-b)。
+--
+-- 候補集合ベースの 'Hanalyze.Design.Optimal' に渡す前のフィルタ用途、
+-- および手動構築した設計行列の事後検証用途を想定。
+--
+-- ADT は最小 2 種:
+--
+--   * 'LinearConstraint' coeffs rel rhs — 線形不等式 / 等式
+--     @sum_i (coeffs[i] * x[i]) `rel` rhs@
+--   * 'ForbiddenCombination' values — 厳密に一致する row を禁止
+--     (浮動小数比較は 'forbiddenTolerance' = 1e-9 で許容)
+--
+-- 条件付 (If-then) 制約は本モジュールでは扱わない (Custom Design spec 専有、
+-- spec/hanalyze-doe-custom-design-spec.md §2.3 / §9 参照)。
+--
+-- spec: doe-spec v0.2 §2.8 / §3.12。
+module Hanalyze.Design.Constraint
+  ( ConstraintRel (..)
+  , DesignConstraint (..)
+  , checkRow
+  , checkDesign
+  , filterCandidates
+  , forbiddenTolerance
+  ) where
+
+import qualified Numeric.LinearAlgebra as LA
+
+-- ===========================================================================
+-- 型
+-- ===========================================================================
+
+-- | 線形制約の関係子。
+data ConstraintRel = CLeq | CEq | CGeq
+  deriving (Eq, Show)
+
+-- | 設計行列に対する制約。
+data DesignConstraint
+  = LinearConstraint     ![Double] !ConstraintRel !Double
+    -- ^ @sum_i (coeffs[i] * x[i]) `rel` rhs@。 coeffs の長さは row の
+    --   次元と一致する必要 ('checkRow' は不一致を即 False として弾く)
+  | ForbiddenCombination ![Double]
+    -- ^ row がこの値と (許容誤差 'forbiddenTolerance' で) 一致したら違反。
+  deriving (Eq, Show)
+
+-- | 'ForbiddenCombination' の浮動小数比較に用いる許容誤差。
+forbiddenTolerance :: Double
+forbiddenTolerance = 1e-9
+
+-- ===========================================================================
+-- 公開 API
+-- ===========================================================================
+
+-- | 1 row が全制約を満たすか。 制約違反 (= 不可) なら 'False'。
+checkRow :: [DesignConstraint] -> [Double] -> Bool
+checkRow cs row = all (rowSatisfies row) cs
+
+-- | 設計行列 (= 各 row が 1 試行) の制約違反 row index を返す。
+-- row 数 0 のときは空 list。
+checkDesign :: [DesignConstraint] -> LA.Matrix Double -> [Int]
+checkDesign cs m =
+  let rows = LA.toLists m
+  in [ i | (i, r) <- zip [0 ..] rows, not (checkRow cs r) ]
+
+-- | 候補集合から制約違反 row を除去する helper。 'Hanalyze.Design.Optimal'
+-- の入力候補を作る前に挟む想定。 順序は保持。
+filterCandidates :: [DesignConstraint] -> [[Double]] -> [[Double]]
+filterCandidates cs = filter (checkRow cs)
+
+-- ===========================================================================
+-- 内部
+-- ===========================================================================
+
+-- | 1 row が単一制約を満たすか判定。
+rowSatisfies :: [Double] -> DesignConstraint -> Bool
+rowSatisfies row (LinearConstraint coeffs rel rhs)
+  | length coeffs /= length row = False
+  | otherwise =
+      let lhs = sum (zipWith (*) coeffs row)
+      in case rel of
+           CLeq -> lhs <= rhs + forbiddenTolerance
+           CEq  -> abs (lhs - rhs) <= forbiddenTolerance
+           CGeq -> lhs >= rhs - forbiddenTolerance
+rowSatisfies row (ForbiddenCombination vals)
+  | length vals /= length row = True   -- 次元不一致 = forbidden ではない
+  | otherwise =
+      not (and (zipWith (\a b -> abs (a - b) <= forbiddenTolerance) row vals))
diff --git a/src/Hanalyze/Design/Custom/Augment.hs b/src/Hanalyze/Design/Custom/Augment.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Design/Custom/Augment.hs
@@ -0,0 +1,383 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE BangPatterns      #-}
+-- |
+-- Module      : Hanalyze.Design.Custom.Augment
+-- Description : Custom Design の Augment 5 メニュー (Replicate/AddCenter/AddAxial/AddRuns/Foldover)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Custom Design の Augment 5 メニュー (Phase 25-6/7/8)。
+--
+-- spec: doe-custom-design-spec v0.1.1 §2.6 / §3。
+-- 参考: JMP "Augment Design" platform。
+--
+-- ## 5 メニュー
+--
+--   * 'Replicate n'   : 既存 design を n 回複製
+--   * 'AddCenter  n'  : 中心点 (全連続因子 = 0、 categorical は ref level) を n 行追加
+--   * 'AddAxial   α'  : 1 因子だけを ±α、 他を 0 にした axial 点を全連続因子で追加
+--                       (= 2 * #continuous-factors 行)
+--   * 'AddRuns    n'  : 既存 augmentDesign (古典 Fedorov 交換) で N 行追加
+--   * 'Foldover   k'  : 既存 design の sign-flipped 行を全部追加 (Full)、
+--                       または指定因子のみ flip (Partial)
+--
+-- ## 制限 (Phase 25-8 暫定)
+--
+--   * 'cdsInitial' が 'Nothing' の場合は 'Left' (既存 design 必須)
+--   * AddCenter / AddAxial は連続因子のみ。 categorical 列は ref index 0 を使う
+--   * Foldover は 2 水準連続因子のみ正しく動作。 categorical はそのまま (flip しない)
+--   * AddAxial は coded space ([-1, 1]) 想定、 raw range を考慮しない
+module Hanalyze.Design.Custom.Augment
+  ( AugmentMenu (..)
+  , FoldoverKind (..)
+  , AugmentMenuResult (..)
+  , augmentMenu
+  ) where
+
+import           Data.Text                (Text)
+import qualified Data.Text                as T
+import qualified Numeric.LinearAlgebra    as LA
+
+import           Hanalyze.Design.Custom.Factor
+import           Hanalyze.Design.Custom.Coordinate
+                   (CustomDesignSpec (..))
+import qualified Hanalyze.Design.Optimal  as Opt
+
+-- ---------------------------------------------------------------------------
+-- 型
+-- ---------------------------------------------------------------------------
+
+data AugmentMenu
+  = Replicate !Int
+  | AddCenter !Int
+  | AddAxial  !Double !Bool
+    -- ^ axial 点。 第 2 引数 @rawUnits@ が False のとき (= 既定) は coded
+    -- @[-1, 1]@ 空間で center 0 + ±α (NCoded モデル想定)。 True のとき raw
+    -- 単位で center (lo+hi)/2 ± α·(hi-lo)/2 (Phase 28-10 で追加)。 raw 形式の
+    -- 既存設計に直接 ±α coded 相当の axial 点を入れたいケースに使う
+  | AddRuns   !Int
+  | Foldover  !FoldoverKind
+  deriving (Show, Eq)
+
+data FoldoverKind
+  = FullFoldover
+  | PartialFoldover ![Text]  -- ^ flip する因子名のリスト
+  | CategoricalSwap ![(Text, [(Text, Text)])]
+    -- ^ Phase 28-7: categorical 因子の level swap mapping。 各エントリ
+    -- @(factor_name, [(old_level, new_level), ...])@ に対し、 既存設計の
+    -- 該当列の level を mapping で置換した行を追加。 連続因子の符号 flip は
+    -- 行わない (CategoricalSwap は categorical 専用)。 mapping に現れない
+    -- level はそのまま (自分自身に map)
+  deriving (Show, Eq)
+
+data AugmentMenuResult = AugmentMenuResult
+  { amrMatrix :: !(LA.Matrix Double)
+    -- ^ 増補後の design (existing + added)
+  , amrAdded  :: !Int
+    -- ^ 追加された行数
+  , amrMethod :: !Text
+    -- ^ "Replicate" / "AddCenter" / etc.
+  } deriving (Show)
+
+-- ---------------------------------------------------------------------------
+-- 公開 API
+-- ---------------------------------------------------------------------------
+
+augmentMenu :: CustomDesignSpec -> AugmentMenu -> IO (Either Text AugmentMenuResult)
+augmentMenu spec menu =
+  case cdsInitial spec of
+    Nothing -> pure (Left (T.pack "augmentMenu: cdsInitial is required"))
+    Just existing ->
+      case menu of
+        Replicate k    -> pure (augmentReplicate existing k)
+        AddCenter k    -> pure (augmentAddCenter (cdsFactors spec) existing k)
+        AddAxial alpha rawUnits ->
+          pure (augmentAddAxial (cdsFactors spec) existing alpha rawUnits)
+        AddRuns k      -> pure (augmentAddRuns spec existing k)
+        Foldover kind  -> pure (augmentFoldover (cdsFactors spec) existing kind)
+
+-- ---------------------------------------------------------------------------
+-- Replicate
+-- ---------------------------------------------------------------------------
+
+augmentReplicate :: LA.Matrix Double -> Int -> Either Text AugmentMenuResult
+augmentReplicate existing k
+  | k < 1 = Left (T.pack "Replicate: k must be >= 1")
+  | otherwise =
+      let !rows = LA.toRows existing
+          !reps = concat (replicate k rows)
+          !added = LA.fromRows reps
+          !full = LA.fromRows (rows ++ reps)
+      in Right AugmentMenuResult
+           { amrMatrix = full
+           , amrAdded  = LA.rows added
+           , amrMethod = T.pack "Replicate"
+           }
+
+-- ---------------------------------------------------------------------------
+-- AddCenter
+-- ---------------------------------------------------------------------------
+
+-- | 中心点: 連続因子は 0、 categorical は level index 0 (= reference)。
+augmentAddCenter
+  :: [Factor]
+  -> LA.Matrix Double
+  -> Int
+  -> Either Text AugmentMenuResult
+augmentAddCenter factors existing k
+  | k < 1 = Left (T.pack "AddCenter: k must be >= 1")
+  | LA.cols existing /= length factors =
+      Left (T.pack "AddCenter: existing column count ≠ #factors")
+  | otherwise =
+      let !centerRow = LA.fromList (map factorCenter factors)
+          !added = LA.fromRows (replicate k centerRow)
+          !full  = existing LA.=== added
+      in Right AugmentMenuResult
+           { amrMatrix = full
+           , amrAdded  = k
+           , amrMethod = T.pack "AddCenter"
+           }
+
+factorCenter :: Factor -> Double
+factorCenter f = case fKind f of
+  Continuous  _ _ -> 0
+  DiscreteNum xs  -> case xs of
+                       []      -> 0
+                       (h:_)   -> sum xs / fromIntegral (length xs)
+                         where _ = h
+  Mixture lo hi   -> (lo + hi) / 2
+  Categorical _   -> 0
+  Ordinal     _   -> 0
+
+-- ---------------------------------------------------------------------------
+-- AddAxial
+-- ---------------------------------------------------------------------------
+
+-- | axial / star 点: 各連続因子について、 その因子だけを +α / -α、
+-- 他を 0 (中心) にした 2 点ずつを追加。
+--
+-- @rawUnits@ False: coded 空間 (center 0 + ±α) で生成。 NCoded モデルで
+--   raw 行列が既に coded されている前提。
+-- @rawUnits@ True: 因子の (lo, hi) を使って center (lo+hi)/2 + ±α·(hi-lo)/2
+--   で生成 (raw 単位、 coded ±α 相当の位置)。 Continuous / DiscreteNum /
+--   Mixture でそれぞれの range を解釈する。
+augmentAddAxial
+  :: [Factor]
+  -> LA.Matrix Double
+  -> Double
+  -> Bool
+  -> Either Text AugmentMenuResult
+augmentAddAxial factors existing alpha rawUnits
+  | alpha <= 0 = Left (T.pack "AddAxial: alpha must be > 0")
+  | LA.cols existing /= length factors =
+      Left (T.pack "AddAxial: existing column count ≠ #factors")
+  | otherwise =
+      let !contIxs =
+            [ i | (i, f) <- zip [0 ..] factors, factorIsContinuous f ]
+      in if null contIxs
+           then Left (T.pack "AddAxial: no continuous factors to augment")
+           else
+             let !centers = if rawUnits
+                              then map factorCenterRaw factors
+                              else map factorCenter factors
+                 axialOffset i = if rawUnits
+                                   then alpha * factorHalfRange (factors !! i)
+                                   else alpha
+                 !rows =
+                   [ LA.fromList
+                       [ if j == i then (centers !! j) + sgn * axialOffset i
+                                   else centers !! j
+                       | j <- [0 .. length factors - 1]
+                       ]
+                   | i <- contIxs, sgn <- [1, -1]
+                   ]
+                 !added = LA.fromRows rows
+                 !full  = existing LA.=== added
+             in Right AugmentMenuResult
+                  { amrMatrix = full
+                  , amrAdded  = length rows
+                  , amrMethod = T.pack "AddAxial"
+                  }
+
+-- | 因子の半幅 = (hi - lo) / 2 (Continuous / DiscreteNum / Mixture)。
+-- raw 単位 axial の scale factor として使う。 Categorical / Ordinal は意味を
+-- 持たないため 0 を返す (caller 側 contIxs で除外済の想定)。
+factorHalfRange :: Factor -> Double
+factorHalfRange f = case fKind f of
+  Continuous  lo hi -> (hi - lo) / 2
+  DiscreteNum xs    -> case xs of
+                         [] -> 0
+                         _  -> (maximum xs - minimum xs) / 2
+  Mixture     lo hi -> (hi - lo) / 2
+  _                 -> 0
+
+-- | 因子の raw 単位での中心 = (lo + hi) / 2 (Phase 28-10 AddAxial rawUnits=True 用)。
+-- 'factorCenter' は coded 空間想定 (Continuous → 0)、 これは raw 空間。
+factorCenterRaw :: Factor -> Double
+factorCenterRaw f = case fKind f of
+  Continuous  lo hi -> (lo + hi) / 2
+  DiscreteNum xs    -> case xs of
+                         [] -> 0
+                         _  -> (maximum xs + minimum xs) / 2
+  Mixture     lo hi -> (lo + hi) / 2
+  _                 -> 0
+
+-- ---------------------------------------------------------------------------
+-- AddRuns (既存 augmentDesign を wrap)
+-- ---------------------------------------------------------------------------
+
+-- | AddRuns: 既存 'Hanalyze.Design.Optimal.augmentDesign' を使い、
+-- 候補集合は連続因子は ±1 grid、 categorical は全 level の cartesian product。
+-- 候補集合サイズが大きくなりすぎる場合 (例 2^20 等) は呼び出し側で nRuns を
+-- 抑制すること。
+augmentAddRuns
+  :: CustomDesignSpec
+  -> LA.Matrix Double
+  -> Int
+  -> Either Text AugmentMenuResult
+augmentAddRuns spec existing k
+  | k < 1 = Left (T.pack "AddRuns: k must be >= 1")
+  | otherwise =
+      let factors  = cdsFactors spec
+          cands    = candidateRows factors
+          existRow = LA.toLists existing
+          seed     = case cdsSeed spec of Just s -> s; Nothing -> 0
+          arRes    = Opt.augmentDesign (cdsCriterion spec) existRow k cands seed
+      in if length (Opt.arNewRows arRes) /= k
+           then Left (T.pack
+             ("AddRuns: failed to add " <> show k <> " rows (candidates may be too few)"))
+           else
+             let !added = LA.fromLists (Opt.arNewRows arRes)
+                 !full  = existing LA.=== added
+             in Right AugmentMenuResult
+                  { amrMatrix = full
+                  , amrAdded  = k
+                  , amrMethod = T.pack "AddRuns"
+                  }
+
+-- | 候補集合: 連続因子は ±1、 categorical / ordinal は全 level、 DiscreteNum は xs、
+-- Mixture は [lo, hi] の 2 点 とする (簡略化、 Phase 25-7 で grid 拡張可)。
+candidateRows :: [Factor] -> [[Double]]
+candidateRows = cart . map factorCandidates
+  where
+    factorCandidates f = case fKind f of
+      Continuous _ _    -> [-1, 1]
+      DiscreteNum xs    -> xs
+      Mixture lo hi     -> [lo, hi]
+      Categorical xs    -> [fromIntegral i | i <- [0 .. length xs - 1]]
+      Ordinal     xs    -> [fromIntegral i | i <- [0 .. length xs - 1]]
+    cart :: [[Double]] -> [[Double]]
+    cart [] = [[]]
+    cart (xs:xss) =
+      [ x : ys | x <- xs, ys <- cart xss ]
+
+-- ---------------------------------------------------------------------------
+-- Foldover
+-- ---------------------------------------------------------------------------
+
+-- | Foldover: 既存 design の符号反転行を追加。
+-- Full: 全因子の符号を flip。
+-- Partial [names]: 指定因子のみ flip。
+-- categorical 列は flip しない (符号の概念が無い)。
+augmentFoldover
+  :: [Factor]
+  -> LA.Matrix Double
+  -> FoldoverKind
+  -> Either Text AugmentMenuResult
+augmentFoldover factors existing kind
+  | LA.cols existing /= length factors =
+      Left (T.pack "Foldover: existing column count ≠ #factors")
+  | otherwise = case kind of
+      CategoricalSwap entries -> applyCatSwap factors existing entries
+      _ ->
+        let !names = map fName factors
+            !flipIdxs = case kind of
+              FullFoldover -> [ i | (i, f) <- zip [0 ..] factors, factorIsContinuous f ]
+              PartialFoldover ns ->
+                [ i | (i, f) <- zip [0 ..] factors
+                , factorIsContinuous f, fName f `elem` ns
+                ]
+              CategoricalSwap _ -> []  -- 上で処理済 (到達不可)
+        in if null flipIdxs && case kind of { FullFoldover -> False; _ -> True }
+             then Left (T.pack "Foldover: no factors to flip (check factor names)")
+             else
+               let !nE = LA.rows existing
+                   !p  = LA.cols existing
+                   !rows = LA.toLists existing
+                   !flipped =
+                     [ [ if j `elem` flipIdxs then negate (r !! j) else r !! j
+                       | j <- [0 .. p - 1] ]
+                     | r <- rows ]
+                   !added = LA.fromLists flipped
+                   !full  = existing LA.=== added
+                   _ = names  -- 未使用警告対策
+               in Right AugmentMenuResult
+                    { amrMatrix = full
+                    , amrAdded  = nE
+                    , amrMethod = case kind of
+                        FullFoldover     -> T.pack "Foldover/Full"
+                        PartialFoldover _ -> T.pack "Foldover/Partial"
+                        CategoricalSwap _ -> T.pack "Foldover/CatSwap"  -- 到達不可
+                    }
+
+-- | Phase 28-7: categorical level swap foldover。 各エントリ
+-- @(factor_name, [(old, new), ...])@ について、 該当列の level index 値を
+-- old → new mapping で置換する (raw 値は level index Double として保持)。
+applyCatSwap
+  :: [Factor]
+  -> LA.Matrix Double
+  -> [(Text, [(Text, Text)])]
+  -> Either Text AugmentMenuResult
+applyCatSwap factors existing entries
+  | null entries = Left (T.pack "Foldover/CatSwap: empty mapping list")
+  | otherwise = do
+      perCol <- traverse (resolveSwap factors) entries
+      let nE   = LA.rows existing
+          p    = LA.cols existing
+          rows = LA.toLists existing
+          swapAt j v = case lookup j perCol of
+            Nothing -> v
+            Just m  -> case lookup (round v :: Int) m of
+              Just newIx -> fromIntegral newIx
+              Nothing    -> v
+          newRows = [ [ swapAt j (r !! j) | j <- [0 .. p - 1] ] | r <- rows ]
+          added = LA.fromLists newRows
+          full  = existing LA.=== added
+      pure AugmentMenuResult
+        { amrMatrix = full
+        , amrAdded  = nE
+        , amrMethod = T.pack "Foldover/CatSwap"
+        }
+
+-- | factor 名 + level 名 mapping を、 列 index + level index mapping に解決。
+resolveSwap
+  :: [Factor]
+  -> (Text, [(Text, Text)])
+  -> Either Text (Int, [(Int, Int)])
+resolveSwap factors (fn, pairs) =
+  case lookupWithIdx fn factors of
+    Nothing -> Left (T.pack ("Foldover/CatSwap: factor not found: " <> T.unpack fn))
+    Just (i, f) -> case fKind f of
+      Categorical xs -> Right (i, mkPairs xs)
+      Ordinal     xs -> Right (i, mkPairs xs)
+      _ -> Left (T.pack
+            ("Foldover/CatSwap: factor " <> T.unpack fn <> " is not categorical/ordinal"))
+  where
+    mkPairs xs = [ (idx old, idx new) | (old, new) <- pairs
+                                      , idx old >= 0, idx new >= 0 ]
+      where
+        idx t = case lookup t (zip (map fst (zip xs [(0::Int)..])) [0..]) of
+          Just k  -> k
+          Nothing -> case elemIxOf t xs of Just k -> k; Nothing -> -1
+    elemIxOf t = go 0
+      where
+        go _ [] = Nothing
+        go k (x:xs') | t == x = Just k
+                     | otherwise = go (k + 1) xs'
+    lookupWithIdx :: Text -> [Factor] -> Maybe (Int, Factor)
+    lookupWithIdx n fs = go 0 fs
+      where
+        go _ [] = Nothing
+        go k (g:gs)
+          | fName g == n = Just (k, g)
+          | otherwise    = go (k + 1) gs
diff --git a/src/Hanalyze/Design/Custom/Bayesian.hs b/src/Hanalyze/Design/Custom/Bayesian.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Design/Custom/Bayesian.hs
@@ -0,0 +1,151 @@
+{-# LANGUAGE OverloadedStrings #-}
+-- |
+-- Module      : Hanalyze.Design.Custom.Bayesian
+-- Description : Bayesian D-optimality (DuMouchel-Jones 1994) の事前精度行列ヘルパ
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Bayesian D-optimality (DuMouchel-Jones 1994) のヘルパ (Phase 26)。
+--
+-- spec: doe-custom-design-spec v0.1.1 §2.7。
+-- 参考: DuMouchel & Jones (1994) "A Simple Bayesian Modification of D-Optimal
+-- Designs to Reduce Dependence on an Assumed Model", Technometrics 36:37-47。
+--
+-- ## 概念
+--
+-- 通常の D-opt は @det(XᵀX)@ を最大化する。 Bayesian D-opt は事前情報 (= 興味の
+-- 薄い高次項に対する事前分布) を K (prior precision matrix) で表現し、
+-- @det(XᵀX + K)@ を最大化する。
+--
+-- K の典型構造 (DuMouchel-Jones):
+--
+--   * 主効果 / intercept: 興味あり → K_jj = 0 (= 事前情報無し)
+--   * 2 因子交互作用 / 二乗項: 興味薄 → K_jj = τ² (= τ² の事前精度で「ほぼ 0」 と仮定)
+--   * 非対角は 0
+--
+-- τ² は 「effect が 1σ_error 程度になる確信度」 から決まる、 既定 1.0 で開始して
+-- 設計者が調整する慣例。
+--
+-- ## 使い方
+--
+-- @
+-- import Hanalyze.Design.Custom.Bayesian
+-- import Hanalyze.Design.Optimal (OptCriterion (..))
+--
+-- let k = priorPrecisionDefault factors model 1.0
+--     spec = ... { cdsCriterion = BayesianD (precisionToMatrix k) }
+-- @
+module Hanalyze.Design.Custom.Bayesian
+  ( PriorPrecision (..)
+  , precisionToMatrix
+  , priorPrecisionDefault
+  , priorPrecisionFromTerms
+  , bayesianDValueM
+    -- * DuMouchel-Jones §2.2 規約 (Phase 28-12、 RegionMoment 再export)
+  , DJTransform (..)
+  , djFitTransform
+  , djApplyTransform
+  , djTransformColumns
+  ) where
+
+import qualified Numeric.LinearAlgebra    as LA
+
+import           Hanalyze.Design.Custom.Factor
+import           Hanalyze.Design.Custom.Model
+import           Hanalyze.Design.Custom.Power (termColumnIndices, termName)
+import           Hanalyze.Design.Custom.RegionMoment
+                   ( DJTransform (..), djFitTransform, djApplyTransform
+                   , djTransformColumns )
+
+-- | Prior precision matrix のラッパ。 対角優位を想定するが、 一般 p × p 行列を
+-- 受け入れる (DuMouchel-Jones 1994 の対角構造は最も一般的だが、 ユーザが
+-- 任意の K を持ち込むのも妨げない)。
+newtype PriorPrecision = PriorPrecision (LA.Matrix Double)
+  deriving (Show)
+
+-- | 内部 matrix を [[Double]] として取得 (OptCriterion.BayesianD への引き渡し用)。
+precisionToMatrix :: PriorPrecision -> [[Double]]
+precisionToMatrix (PriorPrecision m) = LA.toLists m
+
+-- | DuMouchel-Jones の既定プリセット:
+--
+--   * intercept / 主効果: K_jj = 0
+--   * 2fi (`TInter` len 2) / 二乗 (`TPower`) / nested: K_jj = τ²
+--   * categorical 主効果 (K-1 列): K_jj = 0 (主効果扱い)
+--
+-- 非対角は全て 0。 expand 後の列順 = `expandDesignMatrix` の出力順 と一致。
+priorPrecisionDefault :: [Factor] -> Model -> Double -> PriorPrecision
+priorPrecisionDefault factors model tau2 =
+  priorPrecisionFromTerms factors model (defaultClassifier tau2)
+
+-- | 各 term に対する K_jj 値を返す classifier 経由で K を構築する一般版。
+-- ユーザが「自分の問題では二乗だけ τ²、 2fi は 0」 などのカスタム classifier を
+-- 渡せる。
+priorPrecisionFromTerms
+  :: [Factor]
+  -> Model
+  -> (ModelTerm -> Double)  -- ^ term ごとの K_jj 値
+  -> PriorPrecision
+priorPrecisionFromTerms factors model classifyKjj =
+  let pairs   = termColumnIndices factors model
+      nameMap = [ (termName t, classifyKjj t) | t <- mTerms model ]
+      pTotal  = case pairs of
+        [] -> 0
+        _  -> 1 + maximum (concatMap snd pairs)
+      diag = [ kjjForCol pairs nameMap j | j <- [0 .. pTotal - 1] ]
+  in PriorPrecision (LA.diagl diag)
+
+kjjForCol :: [(t, [Int])] -> [(t, Double)] -> Int -> Double
+kjjForCol pairs nameMap col =
+  case [ v | ((_, cols), (_, v)) <- zip pairs nameMap, col `elem` cols ] of
+    (v:_) -> v
+    []    -> 0
+
+-- | DuMouchel-Jones 既定の classifier。
+defaultClassifier :: Double -> ModelTerm -> Double
+defaultClassifier _    TIntercept     = 0
+defaultClassifier _    (TMain _)      = 0
+defaultClassifier tau2 (TInter ns)
+  | length ns >= 2 = tau2
+  | otherwise      = 0
+defaultClassifier tau2 (TPower _ k)
+  | k >= 2 = tau2
+  | otherwise = 0
+defaultClassifier tau2 (TNested _ _) = tau2
+
+-- | Bayesian D-criterion (Matrix-native): @det(XᵀX + K)@ そのもの (符号なし)。
+-- K の次元が X の列数と不一致なら 0。
+bayesianDValueM :: PriorPrecision -> LA.Matrix Double -> Double
+bayesianDValueM (PriorPrecision km) x =
+  let p = LA.cols x
+  in if LA.rows km /= p || LA.cols km /= p
+       then 0
+       else LA.det (LA.tr x LA.<> x + km)
+
+-- ---------------------------------------------------------------------------
+-- DuMouchel-Jones §2.2 規約 (Phase 28-12) — 実装は Custom.RegionMoment.hs に
+-- 移動 (Coordinate ↔ Bayesian の module cycle 回避のため)。 本 module からは
+-- 再 export のみ。
+-- ---------------------------------------------------------------------------
+--
+-- DJ (1994) §2.2 (Technometrics 36:39) は、 prior τ² が「effect size 1σ_error」
+-- と等価に解釈されるよう、 potential terms (TInter len≥2 / TPower k≥2 /
+-- TNested) に以下の変換を要求する:
+--
+--   1. **centering**: 候補集合上で平均を引く (subtract mean over candidate)
+--   2. **primary との直交化**: candidate 上の primary 列 (TIntercept / TMain /
+--      TInter len 1) に LS regress して残差を取る
+--   3. **range = 1 への正規化**: 直交化後の値の (max - min) で割る
+--
+-- paper §2.2 末尾の例 (primary {1, x}、 candidate {-1, -0.5, 0, 0.5, 1} 5 水準):
+--
+--   * x² → z₁ = x² − 0.5 (mean(x²)=0.5、 primary 直交、 range=1)
+--   * x³ → z₂ = (x³ − 0.85x)/0.6 (E[x⁴]/E[x²]=0.85、 range=0.6)
+--
+-- 同一 K = diag(0..0, τ²..τ²) を当てた det(X'X + K) が paper の値と一致する
+-- ためにはこの規約が必要。 'priorPrecisionDefault' は K のみを構築するので、
+-- ユーザは expand 後に 'djTransformColumns' で列変換を適用してから
+-- 'bayesianDValueM' を呼ぶ。 coordinateExchange への自動適用は未対応 (Phase 28-12
+-- 範囲外)。
+
+-- (実装は Custom.RegionMoment.hs を参照。 本 module からは re-export のみ)
diff --git a/src/Hanalyze/Design/Custom/Compare.hs b/src/Hanalyze/Design/Custom/Compare.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Design/Custom/Compare.hs
@@ -0,0 +1,394 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE BangPatterns      #-}
+-- |
+-- Module      : Hanalyze.Design.Custom.Compare
+-- Description : Custom Design 群の post-hoc 比較 (D/A/G/I efficiency・FDS・alias norm)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Custom Design の Design Comparison / FDS (Phase 24-7)。
+--
+-- spec: doe-custom-design-spec v0.1.1 §2.8 / §3.5。
+--
+-- JMP "Design Evaluation" 相当の post-hoc 比較関数。 生成済の 'CustomDesign'
+-- (複数) を受け取り、 D/A/G/I efficiency、 FDS (Fraction of Design Space)
+-- 分布、 alias matrix の Frobenius norm を計算する。
+--
+-- ## 設計判断
+--
+--   * **pure 関数** (`IO` 不要): FDS の点サンプリングは Halton 列で deterministic、
+--     reproducibility は seed 不要。
+--   * **efficiency 算出**: D/A/G eff は 'Hanalyze.Design.Diagnostics.diagnostics'
+--     を再利用。 I-eff は Phase 28-4b 以降 'regionMomentMatrixAnalytic' 経由の
+--     region 積分版 (連続 U[-1,1] + Categorical 等確率 で M_R を解析構築、
+--     I-eff = @1 / (n · trace((X'X)⁻¹ · M_R))@)。 Mixture を含む等で M_R 構築
+--     失敗時は旧 self-moment 近似 (= @1/p@) に fallback。
+--   * **FDS 規定**: N=500 点を Halton で抽出、 各因子型ごとに [0, 1] → 因子値に
+--     マップ (Continuous は [-1, 1]、 DiscreteNum / Mixture / Categorical /
+--     Ordinal はそれぞれ自然なマッピング)、 expand 後の予測分散 v = x'(X'X)⁻¹x
+--     を昇順 sort して返す (JMP plot で x = 累積分率、 y = v)。
+--   * **alias norm の範囲**: Phase 24-7 では **連続因子 × 連続因子の 2fi で
+--     model に含まれていない組合せ** のみを Z に含める (categorical absent
+--     interaction や TPower 2 などは将来 commit で拡張)。
+--
+-- ## 既知の制限 (将来 commit で拡張候補)
+--
+--   * alias matrix の Z 構築範囲 (現状 連続 × 連続 2fi のみ、 Phase 28-6 候補)
+--   * FDS の region 定義 (現状全因子 を独立 uniform、 制約付きの場合は
+--     rejection sampling 必要、 Phase 28-4c 候補)
+--   * I-eff の Mixture / 制約付き region (現状 fallback、 Phase 28-9 / 28-4c 候補)
+module Hanalyze.Design.Custom.Compare
+  ( DesignComparison (..)
+  , compareDesigns
+    -- * 内部 helper (test 用)
+  , fdsVector
+  , aliasNormOf
+    -- * Compound criterion (Phase 26-6)
+  , normalizeCompoundWeights
+    -- * I-efficiency region 積分 (Phase 28-4a、 RegionMoment 再export)
+  , regionMomentMatrixAnalytic
+  , iValueRegionM
+    -- * Compound 幾何平均 + 各 criterion の efficiency (Phase 28-9)
+  , compoundGeometric
+  , dEfficiency
+  , aEfficiency
+    -- * 多変量 Cp との統合 (Phase 28-8)
+  , DesignComparisonExt (..)
+  , compareDesignsWithResponses
+  ) where
+
+import           Data.List                (sort)
+import           Data.Text                (Text)
+import qualified Numeric.LinearAlgebra    as LA
+
+import           Hanalyze.Design.Custom.Factor
+import           Hanalyze.Design.Custom.Model
+import           Hanalyze.Design.Custom.Coordinate
+                   (CustomDesign (..), CustomDesignReport (..))
+import           Hanalyze.Design.Custom.RegionMoment
+                   (regionMomentMatrixAnalytic, iValueRegionM)
+import           Hanalyze.Design.Diagnostics
+                   (DesignDiagnostics (..), diagnostics)
+import           Hanalyze.Design.Optimal           (OptCriterion (..))
+import qualified Hanalyze.Design.Quality           as Q
+import qualified Hanalyze.Stat.QuasiRandom         as QR
+
+-- ---------------------------------------------------------------------------
+-- 型
+-- ---------------------------------------------------------------------------
+
+-- | 複数 Custom Design の比較結果。
+data DesignComparison = DesignComparison
+  { dcDesigns   :: ![(Text, CustomDesign)]
+  , dcEffTable  :: !(LA.Matrix Double)
+    -- ^ 行: 設計 (`dcDesigns` 順)、 列: D / A / G / I efficiency
+  , dcFDS       :: ![(Text, LA.Vector Double)]
+    -- ^ 設計ごとの FDS sorted vector (長さ = N_FDS、 既定 500)
+  , dcAliasNorm :: ![(Text, Double)]
+    -- ^ 設計ごとの alias matrix Frobenius norm
+  } deriving (Show)
+
+-- ---------------------------------------------------------------------------
+-- 公開 API
+-- ---------------------------------------------------------------------------
+
+nFDS :: Int
+nFDS = 500
+
+-- | Custom Design 群を比較。 全 4 列の efficiency + FDS + alias norm を集約。
+compareDesigns :: [(Text, CustomDesign)] -> DesignComparison
+compareDesigns named =
+  let effRows = map (designEffs . snd) named
+      effTable
+        | null effRows = (0 LA.>< 4) []
+        | otherwise    = LA.fromLists effRows
+      fds     = [ (nm, fdsVector cd) | (nm, cd) <- named ]
+      aliases = [ (nm, aliasNormOf cd) | (nm, cd) <- named ]
+  in DesignComparison
+       { dcDesigns   = named
+       , dcEffTable  = effTable
+       , dcFDS       = fds
+       , dcAliasNorm = aliases
+       }
+
+-- ---------------------------------------------------------------------------
+-- efficiency
+-- ---------------------------------------------------------------------------
+
+-- | 1 設計に対する [D-eff, A-eff, G-eff, I-eff]。 expand 失敗時は 4 個の 0。
+--
+-- D-eff は Phase 28-5 以降 'CustomDesignReport.crCriterion' に応じて分岐:
+--   * 'BayesianD k': @D-eff = (det(X'X + K) / n^p)^(1/p)@ (Bayesian D-criterion)
+--   * その他: 古典 D-criterion @det(X'X)@ ベース
+--
+-- I-eff は Phase 28-4b 以降 region moment matrix 版:
+-- @I-eff = 1 / (n · trace((X'X)⁻¹ · M_R))@、 @M_R@ は
+-- 'regionMomentMatrixAnalytic' で構築。 Mixture を含む等で M_R 構築に
+-- 失敗した場合は旧 self-moment 近似 (= @1/p@、 設計に依らず定数) に fallback。
+designEffs :: CustomDesign -> [Double]
+designEffs cd =
+  case expandDesignMatrix (cdFactors cd) (cdModel cd) (cdMatrix cd) of
+    Left _  -> [0, 0, 0, 0]
+    Right x ->
+      let d        = diagnostics x
+          n        = LA.rows x
+          p        = LA.cols x
+          nD       = fromIntegral n :: Double
+          pD       = fromIntegral p :: Double
+          dEffBayes k =
+            let km = LA.fromLists k
+            in if LA.rows km /= p || LA.cols km /= p
+                 then ddDEff d
+                 else
+                   let det = LA.det (LA.tr x LA.<> x + km)
+                   in if det <= 0 || nD == 0 then 0
+                        else (det / (nD ** pD)) ** (1 / pD)
+          dEff = case crCriterion (cdReport cd) of
+            BayesianD k -> dEffBayes k
+            _           -> ddDEff d
+          iEffReg  = case regionMomentMatrixAnalytic (cdFactors cd) (cdModel cd) of
+            Right mR
+              | LA.rows mR == LA.cols x ->
+                  let t  = iValueRegionM mR x
+                  in if isInfinite t || t <= 0 then 0 else 1 / (nD * t)
+            _ -> ddIEff d
+      in [dEff, ddAEff d, ddGEff d, iEffReg]
+
+-- ---------------------------------------------------------------------------
+-- FDS (Fraction of Design Space)
+-- ---------------------------------------------------------------------------
+
+-- | FDS vector: Halton で region から N_FDS 点を抽出、 各点の予測分散
+-- v = x'(X'X)⁻¹x を昇順 sort して返す。 expand 失敗時は空 Vector。
+--
+-- region の取り方 (Phase 24-7 暫定):
+--   * Continuous (lo, hi)  : [-1, 1] (NCoded、 lo/hi 情報は無視)
+--   * DiscreteNum xs       : xs から uniform 抽出
+--   * Mixture lo hi        : [lo, hi]
+--   * Categorical / Ordinal: 0..K-1 から uniform 抽出
+fdsVector :: CustomDesign -> LA.Vector Double
+fdsVector cd =
+  case expandDesignMatrix (cdFactors cd) (cdModel cd) (cdMatrix cd) of
+    Left _  -> LA.fromList []
+    Right x ->
+      let p   = LA.cols x
+          xtx = LA.tr x LA.<> x
+          d   = LA.det xtx
+      in if abs d < 1e-12
+           then LA.fromList []
+           else
+             let inv     = LA.inv xtx
+                 factors = cdFactors cd
+                 model   = cdModel cd
+                 nF      = length factors
+                 halton  = QR.haltonMatrix nFDS nF  -- N × nF in [0, 1]
+                 rawRows = LA.fromRows
+                   [ LA.fromList
+                       [ mapU01ToFactor (factors !! j)
+                                        (halton `LA.atIndex` (i, j))
+                       | j <- [0 .. nF - 1] ]
+                   | i <- [0 .. nFDS - 1] ]
+             in case expandDesignMatrix factors model rawRows of
+                  Left _  -> LA.fromList []
+                  Right xSamp ->
+                    let !vs = [ let xi = LA.flatten (xSamp LA.? [i])
+                                in xi `LA.dot` (inv LA.#> xi)
+                              | i <- [0 .. LA.rows xSamp - 1] ]
+                        _ = p  -- 未使用警告対策、 dimension は後の commit で使う
+                    in LA.fromList (sort vs)
+
+-- | Halton 1 次元値 u ∈ [0, 1] を 1 因子の raw 値に写像。
+-- Categorical / Ordinal は floor(u * K) で level index に量子化。
+mapU01ToFactor :: Factor -> Double -> Double
+mapU01ToFactor f u = case fKind f of
+  Continuous _ _ -> -1 + 2 * u                       -- [-1, 1]
+  DiscreteNum xs ->
+    let k = length xs
+    in if k <= 0 then 0
+                 else xs !! min (k - 1) (floor (u * fromIntegral k))
+  Mixture lo hi  -> lo + (hi - lo) * u
+  Categorical xs ->
+    let k = length xs
+    in if k <= 0 then 0
+                 else fromIntegral (min (k - 1) (floor (u * fromIntegral k)))
+  Ordinal xs     ->
+    let k = length xs
+    in if k <= 0 then 0
+                 else fromIntegral (min (k - 1) (floor (u * fromIntegral k)))
+
+-- ---------------------------------------------------------------------------
+-- alias norm
+-- ---------------------------------------------------------------------------
+
+-- | alias matrix の Frobenius norm。 Phase 28-6 で Z 範囲を拡張:
+--
+--   * 連続 × 連続 2fi (Phase 24-7、 元実装)
+--   * **Categorical × 連続 2fi** (Phase 28-6 追加)
+--   * **Categorical × Categorical 2fi** (Phase 28-6 追加)
+--   * **連続因子の TPower k=2 (二乗項)** (Phase 28-6 追加)
+--
+-- すべて model に **含まれていない** ものだけを Z に追加。 Z が空なら 0。
+aliasNormOf :: CustomDesign -> Double
+aliasNormOf cd =
+  let factors      = cdFactors cd
+      model        = cdModel cd
+      raw          = cdMatrix cd
+      allNames     = map fName factors
+      contNames    = [ fName f | f <- factors, factorIsContinuous f ]
+      existing2fi  =
+        [ canonInter ns | TInter ns <- mTerms model, length ns == 2 ]
+      existingPow  =
+        [ (n, k) | TPower n k <- mTerms model ]
+      -- 2fi candidates: 全因子の組合せ (連続 × 連続、 cat × 連続、 cat × cat)
+      pair2fiCands =
+        [ TInter (canonInter [a, b])
+        | a <- allNames, b <- allNames, a < b
+        , canonInter [a, b] `notElem` existing2fi
+        ]
+      -- 連続因子の二乗項 (k=2)
+      powCands =
+        [ TPower n 2
+        | n <- contNames, (n, 2) `notElem` existingPow
+        ]
+      zTerms = pair2fiCands ++ powCands
+  in if null zTerms
+       then 0
+       else
+         case (expandDesignMatrix factors model raw,
+               expandDesignMatrix factors (Model zTerms (mNorm model)) raw) of
+           (Right x, Right z) ->
+             let xtx = LA.tr x LA.<> x
+                 d   = LA.det xtx
+             in if abs d < 1e-12 then 0 / 0
+                  else
+                    let a = LA.inv xtx LA.<> LA.tr x LA.<> z
+                    in sqrt (LA.sumElements (LA.cmap (** 2) a))
+           _ -> 0 / 0
+
+-- | 2fi のペアを正規化 (順序非依存にするため sort)。
+canonInter :: [Text] -> [Text]
+canonInter = sort
+
+-- ---------------------------------------------------------------------------
+-- Compound 重み正規化 (Phase 26-6)
+-- ---------------------------------------------------------------------------
+
+-- | Compound criterion の重みを合計 1 に正規化。 負の重みは 0 に丸める
+-- (criterion の min 方向と矛盾するため)。 入力は @[(weight, OptCriterion)]@、
+-- 出力は同形で重み正規化済。 重み合計 ≤ 0 の場合は元の入力をそのまま返す
+-- (= no-op、 ユーザ責任)。
+--
+-- 使い方:
+--
+-- @
+-- import qualified Hanalyze.Design.Optimal as Opt
+-- let ws  = [(0.7, Opt.DOpt), (0.5, Opt.AOpt), (-0.1, Opt.IOpt)]
+--     ws' = normalizeCompoundWeights ws
+-- -- ws' = [(0.583, DOpt), (0.417, AOpt), (0, IOpt)]
+-- @
+normalizeCompoundWeights
+  :: [(Double, a)] -> [(Double, a)]
+normalizeCompoundWeights pairs =
+  let clamped = [ (max 0 w, c) | (w, c) <- pairs ]
+      total   = sum (map fst clamped)
+  in if total <= 0
+       then pairs
+       else [ (w / total, c) | (w, c) <- clamped ]
+
+-- ---------------------------------------------------------------------------
+-- Compound 幾何平均 + efficiency 正規化 (Phase 28-9)
+-- ---------------------------------------------------------------------------
+
+-- | 重み付き幾何平均 = @exp(Σ w_i · log eff_i / Σ w_i)@。 各 efficiency が
+-- [0, 1] 範囲の正規化済値であることを前提に「合成効率」 を返す (alphabetic
+-- criterion の geometric variant、 JMP の Compound criterion で利用される)。
+--
+--   * 重みは正数を仮定 (負は 0 にクランプ)、 重み合計 0 のときは 0 を返す
+--   * 任意の eff が ≤ 0 のときは 0 を返す (log が ∞)、 線形 Compound (= no-op
+--     合算) と挙動を揃える
+--
+-- 使い方:
+--
+-- @
+-- let effD = dEfficiency x
+--     effA = aEfficiency x
+--     comp = compoundGeometric [(0.7, effD), (0.3, effA)]
+-- @
+compoundGeometric :: [(Double, Double)] -> Double
+compoundGeometric pairs
+  | any ((<= 0) . snd) clamped = 0
+  | totalW <= 0                = 0
+  | otherwise                  =
+      exp (sum [ w * log e | (w, e) <- clamped ] / totalW)
+  where
+    clamped = [ (max 0 w, e) | (w, e) <- pairs ]
+    totalW  = sum (map fst clamped)
+
+-- | D-efficiency @= (det(X'X) / n^p)^(1/p)@ ([0, ∞) 値、 reference D-opt で 1)。
+-- singular なら 0。
+dEfficiency :: LA.Matrix Double -> Double
+dEfficiency x
+  | LA.rows x == 0 || LA.cols x == 0 = 0
+  | otherwise =
+      let n  = fromIntegral (LA.rows x) :: Double
+          p  = fromIntegral (LA.cols x) :: Double
+          d  = LA.det (LA.tr x LA.<> x)
+      in if d <= 0 then 0
+                   else (d / (n ** p)) ** (1 / p)
+
+-- | A-efficiency @= p / (n · trace((X'X)⁻¹))@ ([0, ∞)、 reference A-opt で 1)。
+-- singular なら 0。
+aEfficiency :: LA.Matrix Double -> Double
+aEfficiency x
+  | LA.rows x == 0 || LA.cols x == 0 = 0
+  | otherwise =
+      let n   = fromIntegral (LA.rows x) :: Double
+          p   = fromIntegral (LA.cols x) :: Double
+          xtx = LA.tr x LA.<> x
+          dd  = LA.det xtx
+      in if abs dd < 1e-12 then 0
+           else
+             let tr = LA.sumElements (LA.takeDiag (LA.inv xtx))
+             in if tr <= 0 then 0 else p / (n * tr)
+
+
+-- ---------------------------------------------------------------------------
+-- 多変量 Cp の Compare 統合 (Phase 28-8)
+-- ---------------------------------------------------------------------------
+
+-- | 'DesignComparison' を拡張、 design ごとに観測 response (実験結果 y) と
+-- spec bounds から計算した多変量 process capability を追加。
+data DesignComparisonExt = DesignComparisonExt
+  { dceBase     :: !DesignComparison
+  , dceMCp      :: ![(Text, Either Text Double)]
+    -- ^ design ごとの MCp (Wang-Hubele-Lawrence 体積比)
+  , dceMCpk     :: ![(Text, Either Text Double)]
+    -- ^ design ごとの MCpk (中心オフセット penalty 含む)
+  , dceInSpec   :: ![(Text, Either Text Double)]
+    -- ^ spec box 内包率 (実測)
+  } deriving (Show)
+
+-- | Compare に多変量 response 評価を追加。 各エントリ
+-- @(name, design, responses, specs)@:
+--   * @responses@ は n × p 観測行列 (n = 設計の行数、 p = 応答数)
+--   * @specs@ は各応答の @(LSL, USL)@ を列順に
+--
+-- @processCapabilityMultivariate@ が Left を返した場合 (singular cov など)
+-- は @dceMCp@ / @dceMCpk@ / @dceInSpec@ の該当エントリに Left を保持する。
+compareDesignsWithResponses
+  :: [(Text, CustomDesign, LA.Matrix Double, [(Double, Double)])]
+  -> DesignComparisonExt
+compareDesignsWithResponses tuples =
+  let base = compareDesigns [ (nm, cd) | (nm, cd, _, _) <- tuples ]
+      results =
+        [ (nm, Q.processCapabilityMultivariate y specs)
+        | (nm, _, y, specs) <- tuples ]
+      mcp     = [ (nm, fmap Q.mcMCp r)        | (nm, r) <- results ]
+      mcpk    = [ (nm, fmap Q.mcMCpk r)       | (nm, r) <- results ]
+      inSpec  = [ (nm, fmap Q.mcInSpecRate r) | (nm, r) <- results ]
+  in DesignComparisonExt
+       { dceBase   = base
+       , dceMCp    = mcp
+       , dceMCpk   = mcpk
+       , dceInSpec = inSpec
+       }
diff --git a/src/Hanalyze/Design/Custom/Constraint.hs b/src/Hanalyze/Design/Custom/Constraint.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Design/Custom/Constraint.hs
@@ -0,0 +1,113 @@
+{-# LANGUAGE OverloadedStrings #-}
+-- |
+-- Module      : Hanalyze.Design.Custom.Constraint
+-- Description : Custom Design の Constraint 内部正規化形 (Coordinate Exchange 用の候補行フィルタ ADT)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Custom Design の Constraint 内部正規化形 (Phase 24-1 skeleton)。
+--
+-- spec: doe-custom-design-spec v0.1.1 §2.3 / §9.3。
+--
+-- **本 skeleton では「内部 ADT (正規化済形)」 のみを定義する**。
+-- ∀LIC∃Code 表面構文 (= @RawConstraint@ newtype around @ExprRep@) と
+-- @normalize :: RawConstraint -> Constraint@ は、 DSL frontend パッケージ
+-- (現状 @フロントエンド app のバックエンド/@ にある) との依存関係を整理してから着手する
+-- (= Phase 24 後続 commit 候補)。
+--
+-- 現在の利用想定: Coordinate Exchange アルゴリズム (本 Phase 後続 commit)
+-- が ADT を inspect して候補 grid を事前 filter するための内部表現。
+module Hanalyze.Design.Custom.Constraint
+  ( ConstraintRel (..)
+  , FactorValue (..)
+  , ConstraintGuard (..)
+  , Constraint (..)
+  , checkRowAgainst
+  , compileRowFromFactors
+  ) where
+
+import           Data.Text (Text)
+import qualified Data.Map.Strict as M
+
+-- | 線形制約の関係子 (Custom 側、 古典 Hanalyze.Design.Constraint と
+-- 表現は同じだが名前空間を分けて使う)。
+data ConstraintRel = CLeq | CEq | CGeq
+  deriving (Eq, Show)
+
+-- | カテゴリ / 数値の混在値 (Forbidden に使う)。
+data FactorValue
+  = FVDouble !Double
+  | FVText   !Text
+  deriving (Eq, Show)
+
+-- | 条件付制約のガード (AND/OR/単項、 NOT は v0.2 検討)。
+data ConstraintGuard
+  = GuardEq  !Text !FactorValue
+  | GuardLeq !Text !Double
+  | GuardGeq !Text !Double
+  | GuardAnd ![ConstraintGuard]
+  | GuardOr  ![ConstraintGuard]
+  deriving (Eq, Show)
+
+-- | Custom Design 内部の正規化済 Constraint。
+--
+-- 連続因子 (因子名で参照) の半空間 / 等式 / カテゴリ列の forbidden /
+-- 条件付 / 範囲上書きを覆う。 表面 ∀LIC∃Code Expr からの正規化失敗時の
+-- @Generic@ (= ExprRep 抱え込み) は本 skeleton では未対応 (DSL frontend
+-- 依存解決後に追加)。
+data Constraint
+  = LinearIneq  ![(Text, Double)] !ConstraintRel !Double
+    -- ^ @sum_i (coef_i * x_{name_i}) `rel` rhs@ 連続因子のみ参照可
+  | Forbidden   ![(Text, FactorValue)]
+    -- ^ 全項が一致する row を禁止 (AND)
+  | Conditional !ConstraintGuard ![Constraint]
+    -- ^ ガード成立時のみ inner 制約を活性化
+  | RangeBound  !Text !Double !Double
+    -- ^ 範囲上書き (低、 高)
+  deriving (Eq, Show)
+
+-- | 1 row (= 因子名 → 値の Map) に対する制約評価。
+-- skeleton では Categorical 因子は Text 値で照合、 連続因子は Double で照合。
+-- 値が見つからない / 型不一致は **その制約を 'False' (= 違反) と判定**。
+checkRowAgainst :: M.Map Text FactorValue -> Constraint -> Bool
+checkRowAgainst row (LinearIneq coefs rel rhs) =
+  let lookupNum k = case M.lookup k row of
+                      Just (FVDouble x) -> Just x
+                      _                 -> Nothing
+      ms = traverse (\(n, c) -> fmap (c *) (lookupNum n)) coefs
+  in case ms of
+       Nothing -> False
+       Just xs ->
+         let lhs = sum xs
+         in case rel of
+              CLeq -> lhs <= rhs + 1e-9
+              CEq  -> abs (lhs - rhs) <= 1e-9
+              CGeq -> lhs >= rhs - 1e-9
+checkRowAgainst row (Forbidden vs) =
+  not (all (\(n, v) -> M.lookup n row == Just v) vs)
+checkRowAgainst row (Conditional guard cs) =
+  if evalGuard row guard
+    then all (checkRowAgainst row) cs
+    else True
+checkRowAgainst row (RangeBound n lo hi) =
+  case M.lookup n row of
+    Just (FVDouble x) -> lo - 1e-9 <= x && x <= hi + 1e-9
+    _                  -> False
+
+-- | ガード評価。
+evalGuard :: M.Map Text FactorValue -> ConstraintGuard -> Bool
+evalGuard row (GuardEq  n v)   = M.lookup n row == Just v
+evalGuard row (GuardLeq n c)   = case M.lookup n row of
+                                   Just (FVDouble x) -> x <= c + 1e-9
+                                   _                 -> False
+evalGuard row (GuardGeq n c)   = case M.lookup n row of
+                                   Just (FVDouble x) -> x >= c - 1e-9
+                                   _                 -> False
+evalGuard row (GuardAnd gs)    = all (evalGuard row) gs
+evalGuard row (GuardOr  gs)    = any (evalGuard row) gs
+
+-- | ヘルパ: 因子名リストと 1 row 値リスト (= Double のみの場合) から Map に変換。
+-- Custom Design Core が coordinate exchange の inner loop で使う想定。
+compileRowFromFactors :: [Text] -> [Double] -> M.Map Text FactorValue
+compileRowFromFactors names values =
+  M.fromList (zip names (map FVDouble values))
diff --git a/src/Hanalyze/Design/Custom/Coordinate.hs b/src/Hanalyze/Design/Custom/Coordinate.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Design/Custom/Coordinate.hs
@@ -0,0 +1,618 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE BangPatterns      #-}
+-- |
+-- Module      : Hanalyze.Design.Custom.Coordinate
+-- Description : Custom Design の Coordinate Exchange + Modified Fedorov hybrid アルゴリズム
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Custom Design の Coordinate Exchange + Modified Fedorov hybrid (Phase 24-4)。
+--
+-- spec: doe-custom-design-spec v0.1.1 §2.4 / §3.6。
+-- 参考: Meyer & Nachtsheim (1995) "The Coordinate-Exchange Algorithm for
+-- Constructing Exact Optimal Experimental Designs", Technometrics 37:60-69。
+--
+-- ## アーキテクチャ (24-4)
+--
+-- 「連続因子は coordinate exchange、 categorical 因子は Modified Fedorov
+-- (候補集合 = 全 level)」 を **因子ごとに探索 grid を切り替える** ことで
+-- 1 つの outer loop に統合した。 spec §2.4 で言う「hybrid」 は実質
+-- per-column grid の選び分けに帰着する。
+--
+-- 因子ごとの grid (NCoded 想定):
+--   * Continuous  : linspace [-1, 1] (長さ dbCxStepGrid、 既定 21)
+--   * DiscreteNum : ユーザ指定の離散水準 (そのまま)
+--   * Mixture     : linspace [lo, hi] (長さ dbCxStepGrid、 制約は 24-5 で別途)
+--   * Categorical : [0, 1, ..., K-1] (level index、 expand 側で treatment coding)
+--   * Ordinal     : 同上
+--
+-- ## 本 commit (24-5) のスコープ
+--
+--   * 制約 (`cdsConstraints` = LinearIneq / Forbidden / Conditional / RangeBound) を
+--     **per-grid-point filter** として統合: 各 cell 候補値について、 変更後の row が
+--     全制約を満たさなければ +∞ 評価 (= 採用されない)。
+--   * 初期 randomInit は **rejection sampling** で row 単位に制約を満たすまで再抽選
+--     (1 row あたり 200 回上限、 越えたら Left)。
+--   * `cdsInitial` は **無視** (24-augment phase で対応)。
+--   * 全 'OptCriterion' (DOpt/AOpt/IOpt/EOpt/GOpt/Compound) を Matrix-native で評価。
+--     IOpt の moment matrix は self-moment (= A-criterion と同方向の近似、
+--     既存 `Hanalyze.Design.Optimal.iValueWithSelf` と整合)。
+--
+-- ## 設計指針
+--
+--   * 内部 loop は hmatrix Matrix / Vector で完結 (list 化禁止、 Phase 17 教訓)。
+--   * outer multi-start / iter loop は `IO` で IORef 更新。
+--   * 各 grid 点での criterion 評価は `expandDesignMatrix` + `critValueM`。
+--   * 初期解は grid 上で uniform random 抽出 (再現性は `cdsSeed`)。
+module Hanalyze.Design.Custom.Coordinate
+  ( -- * 入力型
+    CustomDesignSpec (..)
+  , DesignBudget (..)
+  , defaultBudget
+    -- * 結果型
+  , CustomDesign (..)
+  , CustomDesignReport (..)
+    -- * アルゴリズム
+  , coordinateExchange
+  , coordinateExchangePure
+    -- * seed / gen helper (SplitPlot 等が再利用)
+  , mkGen
+  , mkGenSeed
+  , defaultPureSeed
+    -- * 内部 helper (test 用 / Structured 再利用)
+  , critValueM
+  , gridForBudget
+  , factorGrid
+  , rowFeasible
+  ) where
+
+import           Control.Monad             (forM_, when)
+import           Control.Monad.Primitive   (PrimMonad, PrimState)
+import           Control.Monad.ST          (runST)
+import           Data.Maybe                (fromMaybe)
+import           Data.Primitive.MutVar
+import           Data.Text                 (Text)
+import qualified Data.Text                 as T
+import qualified Numeric.LinearAlgebra     as LA
+import qualified System.Random.MWC         as MWC
+import qualified Data.Vector               as V
+import qualified Data.Vector.Unboxed       as VU
+import qualified Data.Map.Strict           as M
+
+import           Hanalyze.Design.Custom.Factor
+import           Hanalyze.Design.Custom.Model
+import           Hanalyze.Design.Custom.Constraint
+                   (Constraint, FactorValue (..), checkRowAgainst)
+import qualified Hanalyze.Design.Custom.RegionMoment as RM
+import           Hanalyze.Design.Custom.RegionMoment (resolveIOptRegion)
+import           Hanalyze.Design.Optimal        (OptCriterion (..))
+
+-- ---------------------------------------------------------------------------
+-- 入力型
+-- ---------------------------------------------------------------------------
+
+-- | Custom Design 生成の仕様。 spec §2.4。
+data CustomDesignSpec = CustomDesignSpec
+  { cdsFactors     :: ![Factor]
+  , cdsModel       :: !Model
+  , cdsConstraints :: ![Constraint]
+    -- ^ Phase 24-3 では未使用 (24-5 で grid filter として統合)。
+  , cdsNRuns       :: !Int
+  , cdsCriterion   :: !OptCriterion
+  , cdsBudget      :: !DesignBudget
+  , cdsSeed        :: !(Maybe Int)
+  , cdsInitial     :: !(Maybe (LA.Matrix Double))
+    -- ^ Augment 用、 24-3 では未使用。
+  , cdsDJConvention :: !Bool
+    -- ^ Phase 28-12 自動: True かつ criterion が BayesianD を含むとき、
+    -- 候補集合 (factor grid の cartesian product) から DuMouchel-Jones §2.2
+    -- 規約 ('Custom.Bayesian.djFitTransform') を fit し、 内部 criterion 評価
+    -- の expand 後に 'djApplyTransform' を適用してから 'critValueM' に渡す。
+    -- 'cdMatrix' は raw 表現のまま保存、 'cdReport.crCriterionValue' は
+    -- 変換後 X 上の det を示す。 paper §3.3 と同じ意味の最適化が走る。
+  } deriving (Show)
+
+-- | 探索バジェット。 spec §2.4。
+data DesignBudget = DesignBudget
+  { dbMaxIter    :: !Int     -- ^ outer iteration 上限 (改善なしで break)
+  , dbRestarts   :: !Int     -- ^ multi-start 数
+  , dbTol        :: !Double  -- ^ outer 収束判定の相対改善閾値
+  , dbCxStepGrid :: !Int     -- ^ 連続因子 grid 点数 (既定 21)
+  } deriving (Show)
+
+-- | spec §2.4 既定値 + JMP デフォルト互換 (21 grid)。
+defaultBudget :: DesignBudget
+defaultBudget = DesignBudget
+  { dbMaxIter    = 50
+  , dbRestarts   = 5
+  , dbTol        = 1e-6
+  , dbCxStepGrid = 21
+  }
+
+-- ---------------------------------------------------------------------------
+-- 結果型
+-- ---------------------------------------------------------------------------
+
+data CustomDesign = CustomDesign
+  { cdMatrix  :: !(LA.Matrix Double)   -- ^ 因子 raw 値行列 (nRuns × #factors)
+  , cdFactors :: ![Factor]
+  , cdModel   :: !Model
+  , cdReport  :: !CustomDesignReport
+  } deriving (Show)
+
+data CustomDesignReport = CustomDesignReport
+  { crCriterion      :: !OptCriterion
+  , crCriterionValue :: !Double     -- ^ 最小化方向の値 (DOpt なら −det)
+  , crIterations     :: !Int        -- ^ best restart で要した outer iter 数
+  , crRestarts       :: !Int        -- ^ 実行した restart 数
+  , crConverged      :: !Bool       -- ^ best restart が maxIter 前に収束したか
+  , crSeed           :: !(Maybe Int)
+  } deriving (Show)
+
+-- ---------------------------------------------------------------------------
+-- 公開 API
+-- ---------------------------------------------------------------------------
+
+-- | Coordinate Exchange + Modified Fedorov hybrid による Custom Design 生成。
+--
+-- 失敗ケース:
+--   * 因子が空 / nRuns < 1
+--   * Categorical / Ordinal 因子で水準数 0 (Phase 24-1 expandDesignMatrix と整合)
+--   * モデルが categorical を参照しているが Phase 24-2 の制限に該当
+--   * 'TNested' をモデルに含む (Phase 24-1 から未対応)
+--   * dbRestarts < 1 / dbCxStepGrid < 2
+-- | seed 由来の gen を作って 'coordinateExchangeWith' を IO で走らせる薄い wrapper。
+-- 'cdsSeed' が 'Nothing' の場合のみ entropy 依存 (非決定的)。
+-- Phase 78.M: seed 決定的な純粋版が要るなら 'coordinateExchangePure' を使う。
+coordinateExchange :: CustomDesignSpec -> IO (Either Text CustomDesign)
+coordinateExchange spec = do
+  gen <- mkGen (cdsSeed spec)
+  coordinateExchangeWith spec gen
+
+-- | seed 決定的な純粋版 (Phase 78.M)。'runST' で MWC gen + MutVar を閉じ込め、
+-- IO 無しで 'CustomDesign' を返す。'cdsSeed' が 'Nothing' なら
+-- 'defaultPureSeed' を用いて全域関数にする (同 spec → 常に同結果)。
+-- 同一 seed なら 'coordinateExchange' (IO) とビット一致する。
+coordinateExchangePure :: CustomDesignSpec -> Either Text CustomDesign
+coordinateExchangePure spec = runST $ do
+  gen <- mkGenSeed (fromMaybe defaultPureSeed (cdsSeed spec))
+  coordinateExchangeWith spec gen
+
+-- | 座標交換本体 (PrimMonad 一般化)。IO / ST どちらでも走る。gen は呼び出し側が
+-- seed から用意する ('coordinateExchange' = IO entropy 可 / 'coordinateExchangePure'
+-- = ST seed 必須)。アルゴリズムは gen の生成源に依らず同 seed → 同結果。
+coordinateExchangeWith
+  :: PrimMonad m
+  => CustomDesignSpec -> MWC.Gen (PrimState m) -> m (Either Text CustomDesign)
+coordinateExchangeWith spec gen
+  | null (cdsFactors spec) =
+      pure (Left (T.pack "coordinateExchange: empty factor list"))
+  | cdsNRuns spec < 1 =
+      pure (Left (T.pack "coordinateExchange: nRuns must be >= 1"))
+  | dbRestarts (cdsBudget spec) < 1 =
+      pure (Left (T.pack "coordinateExchange: dbRestarts must be >= 1"))
+  | dbCxStepGrid (cdsBudget spec) < 2 =
+      pure (Left (T.pack "coordinateExchange: dbCxStepGrid must be >= 2"))
+  | otherwise = do
+      let !n        = cdsNRuns spec
+          !budget   = cdsBudget spec
+          !critIn   = cdsCriterion spec
+          !model    = cdsModel spec
+          !factors  = cdsFactors spec
+          !cons     = cdsConstraints spec
+          !grids    = map (factorGrid budget) factors
+      let prep = do
+            () <- maybe (Right ()) Left (validateGrids factors grids)
+            crit <- resolveIOptRegion factors model cons critIn
+            mDJ  <- fitDJTransformIfRequested spec factors model grids crit
+            Right (crit, mDJ)
+      case prep of
+        Left e -> pure (Left e)
+        Right (crit, mDJ) -> do
+          let dummy = LA.fromColumns
+                [ LA.konst (VU.head g) n | g <- grids ]
+          case expandDesignMatrix factors model dummy of
+            Left e  -> pure (Left (T.pack "coordinateExchange: model invalid — " <> e))
+            Right _ -> do
+              bestRef <- newMutVar Nothing
+              initErrRef <- newMutVar Nothing
+              forM_ [1 .. dbRestarts budget] $ \_ -> do
+                mInit <- randomInit factors cons n grids gen
+                case mInit of
+                  Left e -> writeMutVar initErrRef (Just e)
+                  Right init0 -> do
+                    (finalM, finalC, iters, conv) <-
+                      runExchange factors model crit mDJ cons budget grids init0
+                    modifyMutVar' bestRef $ \mb -> case mb of
+                      Nothing -> Just (finalM, finalC, iters, conv)
+                      Just (_, c0, _, _)
+                        | finalC < c0 -> Just (finalM, finalC, iters, conv)
+                        | otherwise   -> mb
+              mb <- readMutVar bestRef
+              case mb of
+                Just (m, c, iters, conv) -> pure $ Right CustomDesign
+                  { cdMatrix  = m
+                  , cdFactors = factors
+                  , cdModel   = model
+                  , cdReport  = CustomDesignReport
+                      { crCriterion      = critIn
+                      , crCriterionValue = c
+                      , crIterations     = iters
+                      , crRestarts       = dbRestarts budget
+                      , crConverged      = conv
+                      , crSeed           = cdsSeed spec
+                      }
+                  }
+                Nothing -> do
+                  initErr <- readMutVar initErrRef
+                  pure (Left (case initErr of
+                    Just e  -> e
+                    Nothing -> T.pack "coordinateExchange: no restart produced a design"))
+
+-- | 全因子の grid が非空である事を確認 (Categorical 0 level、 DiscreteNum 空 等を弾く)。
+validateGrids :: [Factor] -> [VU.Vector Double] -> Maybe Text
+validateGrids fs gs = go (zip fs gs)
+  where
+    go [] = Nothing
+    go ((f, g):rest)
+      | VU.length g < 1 = Just (T.pack
+          ("coordinateExchange: factor " <> T.unpack (fName f)
+           <> " has empty search grid (categorical with 0 levels?)"))
+      | otherwise = go rest
+
+-- ---------------------------------------------------------------------------
+-- アルゴリズム内部
+-- ---------------------------------------------------------------------------
+
+-- | 1 restart 分の coordinate exchange / Modified Fedorov 混合 loop を走らせる。
+-- 戻り値: (最終 raw matrix, 最終 criterion 値, 要した outer iter, 収束フラグ)。
+runExchange
+  :: PrimMonad m
+  => [Factor]
+  -> Model
+  -> OptCriterion
+  -> Maybe RM.DJTransform      -- ^ Phase 28-12: 自動 DJ 規約変換
+  -> [Constraint]              -- ^ 制約 (per-grid-point filter)
+  -> DesignBudget
+  -> [VU.Vector Double]        -- ^ 因子ごとの探索 grid (列順)
+  -> LA.Matrix Double          -- ^ 初期 raw matrix (n × p)
+  -> m (LA.Matrix Double, Double, Int, Bool)
+runExchange factors model crit mDJ cons budget grids init0 = do
+  matRef    <- newMutVar init0
+  critRef   <- newMutVar (evalCrit factors model crit mDJ init0)
+  iterRef   <- newMutVar 0
+  convRef   <- newMutVar False
+  let !n        = LA.rows init0
+      !p        = LA.cols init0
+      gridsV    = V.fromList grids
+      gridLensV = V.fromList (map VU.length grids)
+  let loopOuter !it
+        | it > dbMaxIter budget = pure ()
+        | otherwise = do
+            beforeC <- readMutVar critRef
+            forM_ [0 .. n - 1] $ \i ->
+              forM_ [0 .. p - 1] $ \j -> do
+                curMat <- readMutVar matRef
+                curC   <- readMutVar critRef
+                let oldV    = curMat `LA.atIndex` (i, j)
+                    !g      = gridsV V.! j
+                    !gl     = gridLensV V.! j
+                (bestV, bestC) <-
+                  searchBestOnGrid factors model crit mDJ cons curMat i j g gl oldV curC
+                when (bestC < curC) $ do
+                  let !newMat = setEntry curMat i j bestV
+                  writeMutVar matRef  newMat
+                  writeMutVar critRef bestC
+            afterC <- readMutVar critRef
+            writeMutVar iterRef it
+            let !rel = relImprovement beforeC afterC
+            if rel <= dbTol budget
+              then writeMutVar convRef True
+              else loopOuter (it + 1)
+  loopOuter 1
+  finalM    <- readMutVar matRef
+  finalC    <- readMutVar critRef
+  finalIter <- readMutVar iterRef
+  conv      <- readMutVar convRef
+  -- p は randomInit が決定論的に正しい次元を返すので冗長検査は省く
+  _ <- pure (n, p)
+  pure (finalM, finalC, finalIter, conv)
+
+-- | 1 セル (i, j) について grid 上を線形走査、 制約を満たす範囲で
+-- criterion 最小の (v, c) を返す。 制約違反 grid 点は scoring 段階で +∞ 扱い
+-- (= 採用されない)。
+searchBestOnGrid
+  :: PrimMonad m
+  => [Factor]
+  -> Model
+  -> OptCriterion
+  -> Maybe RM.DJTransform
+  -> [Constraint]
+  -> LA.Matrix Double
+  -> Int -> Int
+  -> VU.Vector Double
+  -> Int
+  -> Double               -- ^ 現状値 (oldV)
+  -> Double               -- ^ 現状の criterion
+  -> m (Double, Double)
+searchBestOnGrid factors model crit mDJ cons mat i j grid gridLen oldV oldC = do
+  bestRef <- newMutVar (oldV, oldC)
+  let curRow = LA.flatten (LA.subMatrix (i, 0) (1, LA.cols mat) mat)
+  forM_ [0 .. gridLen - 1] $ \k -> do
+    let !v = grid VU.! k
+        !proposedRow = replaceVecAt curRow j v
+    when (rowFeasible factors cons proposedRow) $ do
+      let !candMat = setEntry mat i j v
+          !c = evalCrit factors model crit mDJ candMat
+      modifyMutVar' bestRef $ \cur@(_, bc) -> if c < bc then (v, c) else cur
+  readMutVar bestRef
+
+-- | raw matrix → design matrix → (optional) DJ 変換 → criterion 値 (最小化方向)。
+-- expandDesignMatrix が `Left` を返したら +∞ を返す (= 採用されない)。
+evalCrit :: [Factor] -> Model -> OptCriterion -> Maybe RM.DJTransform
+         -> LA.Matrix Double -> Double
+evalCrit factors model crit mDJ raw =
+  case expandDesignMatrix factors model raw of
+    Left _  -> 1 / 0
+    Right x ->
+      let xT = case mDJ of
+            Nothing -> x
+            Just t  -> RM.djApplyTransform t x
+      in critValueM crit xT
+
+-- ---------------------------------------------------------------------------
+-- criterion (Matrix-native、 list 化禁止)
+-- ---------------------------------------------------------------------------
+
+-- | OptCriterion の Matrix 版。 全 criterion を /minimize/ 方向で返す
+-- (`Hanalyze.Design.Optimal.critValue` と整合)。 X は expand 済設計行列。
+critValueM :: OptCriterion -> LA.Matrix Double -> Double
+critValueM DOpt       x = - dValueM x
+critValueM AOpt       x = aValueM x
+critValueM IOpt       x = iValueSelfM x
+critValueM EOpt       x = eValueM x
+critValueM GOpt       x = gValueM x
+critValueM (Compound ws) x =
+  sum [ w * critValueM c x | (w, c) <- ws ]
+critValueM (BayesianD k) x =
+  let p  = LA.cols x
+      km = LA.fromLists k
+  in if LA.rows km /= p || LA.cols km /= p
+       then 1 / 0
+       else - LA.det (LA.tr x LA.<> x + km)
+critValueM (IOptRegion mr) x =
+  let p   = LA.cols x
+      mrM = LA.fromLists mr
+  in if LA.rows mrM /= p || LA.cols mrM /= p
+       then 1 / 0
+       else iValueRegionMatrix mrM x
+
+-- | region moment matrix を直接 Matrix で受け取る I-criterion (内部用)。
+-- 'Compare.iValueRegionM' と同義だが、 Coordinate からの import 循環回避の
+-- ため重複定義。
+iValueRegionMatrix :: LA.Matrix Double -> LA.Matrix Double -> Double
+iValueRegionMatrix mrM x
+  | LA.rows x == 0 = 1 / 0
+  | otherwise =
+      let xtx = LA.tr x LA.<> x
+          d   = LA.det xtx
+      in if abs d < 1e-12 then 1 / 0
+           else LA.sumElements (LA.takeDiag (LA.inv xtx LA.<> mrM))
+
+dValueM :: LA.Matrix Double -> Double
+dValueM x
+  | LA.rows x == 0 = 0
+  | otherwise = LA.det (LA.tr x LA.<> x)
+
+aValueM :: LA.Matrix Double -> Double
+aValueM x
+  | LA.rows x == 0 = 1 / 0
+  | otherwise =
+      let xtx = LA.tr x LA.<> x
+          d   = LA.det xtx
+      in if abs d < 1e-12 then 1 / 0
+           else LA.sumElements (LA.takeDiag (LA.inv xtx))
+
+-- | I-criterion の self-moment 版 (`Optimal.iValueWithSelf` と同義)。
+iValueSelfM :: LA.Matrix Double -> Double
+iValueSelfM x
+  | LA.rows x == 0 = 1 / 0
+  | otherwise =
+      let xtx = LA.tr x LA.<> x
+          d   = LA.det xtx
+      in if abs d < 1e-12 then 1 / 0
+           else
+             let inv    = LA.inv xtx
+                 moment = LA.scale (1 / fromIntegral (LA.rows x)) xtx
+             in LA.sumElements (LA.takeDiag (inv LA.<> moment))
+
+eValueM :: LA.Matrix Double -> Double
+eValueM x
+  | LA.rows x == 0 = 1 / 0
+  | otherwise =
+      let xtx = LA.tr x LA.<> x
+          eigs = LA.toList (LA.eigenvaluesSH (LA.sym xtx))
+      in if null eigs then 1 / 0 else - minimum eigs
+
+gValueM :: LA.Matrix Double -> Double
+gValueM x
+  | LA.rows x == 0 = 1 / 0
+  | otherwise =
+      let xtx = LA.tr x LA.<> x
+          d   = LA.det xtx
+      in if abs d < 1e-12 then 1 / 0
+           else
+             let inv = LA.inv xtx
+                 h   = x LA.<> inv LA.<> LA.tr x
+                 dia = LA.toList (LA.takeDiag h)
+             in if null dia then 1 / 0 else maximum dia
+
+-- ---------------------------------------------------------------------------
+-- 補助
+-- ---------------------------------------------------------------------------
+
+-- | [-1, 1] の等間隔 grid (NCoded 連続因子の既定)。
+gridForBudget :: DesignBudget -> VU.Vector Double
+gridForBudget b =
+  let !k  = dbCxStepGrid b
+      !km = fromIntegral (k - 1) :: Double
+  in VU.generate k (\i -> -1 + 2 * fromIntegral i / km)
+
+-- | 因子ごとの探索 grid (Phase 24-4)。 raw matrix の値表現規約
+-- (`Hanalyze.Design.Custom.Model` のモジュール doc 参照) と整合する点を返す。
+--
+-- * Continuous (lo, hi)  : linspace [-1, 1] (NCoded、 dbCxStepGrid 点)
+-- * DiscreteNum xs       : xs そのまま
+-- * Mixture (lo, hi)     : linspace [lo, hi] (dbCxStepGrid 点、 制約は 24-5 で別途)
+-- * Categorical / Ordinal: [0, 1, ..., K-1] (level index、 expand 側で treatment coding)
+factorGrid :: DesignBudget -> Factor -> VU.Vector Double
+factorGrid b f = case fKind f of
+  Continuous _ _    -> gridForBudget b
+  DiscreteNum xs    -> VU.fromList xs
+  Mixture lo hi     -> linspaceVU lo hi (dbCxStepGrid b)
+  Categorical xs    -> VU.fromList (map fromIntegral [0 .. length xs - 1])
+  Ordinal     xs    -> VU.fromList (map fromIntegral [0 .. length xs - 1])
+
+-- | 任意区間の等間隔 grid (k 点)。 k <= 1 は単一中央値を返す。
+linspaceVU :: Double -> Double -> Int -> VU.Vector Double
+linspaceVU lo hi k
+  | k <= 1    = VU.singleton ((lo + hi) / 2)
+  | otherwise = VU.generate k
+      (\i -> lo + (hi - lo) * fromIntegral i / fromIntegral (k - 1))
+
+-- | 初期 raw matrix を rejection sampling で構築 (n × p)。
+-- 各 row を制約満足するまで再抽選 (1 row あたり 200 回まで)。
+-- 200 回試して失敗した row があれば 'Left'。
+randomInit
+  :: PrimMonad m
+  => [Factor]
+  -> [Constraint]
+  -> Int
+  -> [VU.Vector Double]
+  -> MWC.Gen (PrimState m)
+  -> m (Either Text (LA.Matrix Double))
+randomInit factors cons n grids gen = do
+  let p = length grids
+      gridsV = V.fromList grids
+      maxTries = 200 :: Int
+      drawRow = do
+        vs <- mapM (\j -> do
+                       let g = gridsV V.! j
+                           gl = VU.length g
+                       k <- MWC.uniformR (0, gl - 1) gen
+                       pure (g VU.! k)) [0 .. p - 1]
+        pure (LA.fromList vs)
+      tryRow t
+        | t > maxTries = pure Nothing
+        | otherwise = do
+            r <- drawRow
+            if rowFeasible factors cons r
+              then pure (Just r)
+              else tryRow (t + 1)
+  rowsR <- mapM (\_ -> tryRow 1) [1 .. n]
+  case sequence rowsR of
+    Just rs -> pure (Right (LA.fromRows rs))
+    Nothing -> pure (Left (T.pack
+      ("randomInit: failed to find feasible row within "
+       <> show maxTries <> " rejection-sampling tries — "
+       <> "constraints may be infeasible or too tight")))
+
+-- | row vector (length p) の j 番目を v に置換した新 vector。
+replaceVecAt :: LA.Vector Double -> Int -> Double -> LA.Vector Double
+replaceVecAt v j x =
+  LA.fromList [if k == j then x else v `LA.atIndex` k | k <- [0 .. LA.size v - 1]]
+
+-- | row (raw Vector) が全制約を満たすかを評価。
+-- Categorical / Ordinal 列は level index → 因子の level 名 ('FVText') に変換、
+-- 連続系は 'FVDouble' に変換して 'checkRowAgainst' に渡す。
+rowFeasible :: [Factor] -> [Constraint] -> LA.Vector Double -> Bool
+rowFeasible _ [] _ = True
+rowFeasible factors cons row =
+  let m = buildRowFV factors row
+  in all (checkRowAgainst m) cons
+
+-- | raw 値 vector (列順 = factors 順) を 因子名 → FactorValue Map に変換。
+-- Categorical / Ordinal は level index を level 名 ('FVText') に変換、
+-- 非整数 / 範囲外 index は安全のため 'FVDouble' のまま (rowFeasible で
+-- 不一致 → 制約違反 として扱われる、 expandDesignMatrix が別途 Left を返す)。
+buildRowFV :: [Factor] -> LA.Vector Double -> M.Map Text FactorValue
+buildRowFV factors row =
+  M.fromList
+    [ (fName f, toFV (fKind f) (row `LA.atIndex` i))
+    | (i, f) <- zip [0 ..] factors
+    ]
+  where
+    toFV (Categorical xs) x = catIndexToFV xs x
+    toFV (Ordinal     xs) x = catIndexToFV xs x
+    toFV _                x = FVDouble x
+
+    catIndexToFV :: [Text] -> Double -> FactorValue
+    catIndexToFV xs x =
+      let xi = round x :: Int
+          delta = abs (x - fromIntegral xi)
+      in if delta < 1e-9 && xi >= 0 && xi < length xs
+           then FVText (xs !! xi)
+           else FVDouble x  -- 不正値 → 文字列 level に一致しない = 不一致
+
+-- | accum で 1 セルだけ置換した新 matrix を返す。
+-- 注意: hmatrix `LA.accum` の combining fn は @f new old@ の順 (= 第 1 引数が
+-- リストの値、 第 2 引数が現行値)。 'const' で「リストの値で置換」 を意味する。
+setEntry :: LA.Matrix Double -> Int -> Int -> Double -> LA.Matrix Double
+setEntry m i j v = LA.accum m const [((i, j), v)]
+
+-- | 相対改善 = (before − after) / |before| (前後とも最小化方向の criterion 値)。
+-- 値が小さい (≤ dbTol) ほど「改善が止まった」 と解釈、 outer loop で break。
+relImprovement :: Double -> Double -> Double
+relImprovement before after
+  | abs before < 1e-12 = before - after
+  | otherwise          = (before - after) / abs before
+
+-- | seed から MWC.Gen を作る (IO)。 Nothing なら entropy 由来 (非決定的)。
+mkGen :: Maybe Int -> IO MWC.GenIO
+mkGen Nothing  = MWC.createSystemRandom
+mkGen (Just s) = mkGenSeed s
+
+-- | seed から MWC.Gen を作る (PrimMonad 一般化・決定的)。IO / ST 両対応。
+mkGenSeed :: PrimMonad m => Int -> m (MWC.Gen (PrimState m))
+mkGenSeed s = MWC.initialize (VU.fromList [fromIntegral s])
+
+-- | 純粋版 'coordinateExchangePure' で 'cdsSeed' が 'Nothing' のときに使う既定 seed。
+-- 純粋 = 全域である必要があるため固定値を用いる (同 spec → 常に同結果)。
+defaultPureSeed :: Int
+defaultPureSeed = 0x5EED
+
+-- ---------------------------------------------------------------------------
+-- Phase 28-12 自動 DJ 規約変換
+-- ---------------------------------------------------------------------------
+
+-- | criterion 木に BayesianD が含まれているか。
+critContainsBayesianD :: OptCriterion -> Bool
+critContainsBayesianD (BayesianD _)  = True
+critContainsBayesianD (Compound ws)  = any (critContainsBayesianD . snd) ws
+critContainsBayesianD _              = False
+
+-- | 因子 grid から候補集合 (cartesian product) の raw matrix を構築。
+candidateFromGrids :: [VU.Vector Double] -> LA.Matrix Double
+candidateFromGrids gs =
+  let lists = map VU.toList gs
+      rows  = sequence lists   -- cartesian product
+  in if null rows then (0 LA.>< length gs) []
+                  else LA.fromLists rows
+
+-- | spec の `cdsDJConvention` が True かつ criterion に BayesianD を含むときのみ
+-- 候補集合から 'DJTransform' を fit する。 それ以外は @Right Nothing@。
+fitDJTransformIfRequested
+  :: CustomDesignSpec
+  -> [Factor]
+  -> Model
+  -> [VU.Vector Double]
+  -> OptCriterion
+  -> Either Text (Maybe RM.DJTransform)
+fitDJTransformIfRequested spec fs model grids crit
+  | not (cdsDJConvention spec)        = Right Nothing
+  | not (critContainsBayesianD crit)  = Right Nothing
+  | otherwise =
+      let cand = candidateFromGrids grids
+      in case RM.djFitTransform fs model cand of
+           Left e  -> Left e
+           Right t -> Right (Just t)
diff --git a/src/Hanalyze/Design/Custom/Factor.hs b/src/Hanalyze/Design/Custom/Factor.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Design/Custom/Factor.hs
@@ -0,0 +1,71 @@
+{-# LANGUAGE OverloadedStrings #-}
+-- |
+-- Module      : Hanalyze.Design.Custom.Factor
+-- Description : Custom Design の Factor 定義 (Role × Kind の直交軸による因子型)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Custom Design の Factor 定義 (Phase 24-1 skeleton)。
+--
+-- spec: doe-custom-design-spec v0.1.1 §2.1 / §3.1。
+--
+-- 「コントロール性 (Role)」 × 「水準型 (Kind)」 の直交軸で 1 型に集約。
+-- HardToChange フラグが split-plot を駆動 (Phase 25 で実装)。
+module Hanalyze.Design.Custom.Factor
+  ( FactorRole (..)
+  , FactorKind (..)
+  , Factor (..)
+  , factorIsContinuous
+  , factorDimension
+  ) where
+
+import Data.Text (Text)
+
+-- | 因子の運用上の役割。
+data FactorRole
+  = Controllable      -- ^ 通常因子
+  | HardToChange      -- ^ Whole-plot 因子 (split-plot 駆動)
+  | VeryHardToChange  -- ^ Strip-plot 駆動
+  | Blocking          -- ^ 既知ブロック
+  | Covariate         -- ^ 共変量 (測定可だが操作不可)
+  | Constant          -- ^ 固定 (設計には現れず記録のみ)
+  | Uncontrolled      -- ^ ノイズ (Taguchi outer array 由来)
+  deriving (Eq, Show)
+
+-- | 因子の水準型。
+data FactorKind
+  = Continuous   !Double !Double         -- ^ (low, high)、 coded ±1 への正規化対象
+  | DiscreteNum  ![Double]               -- ^ 離散水準 (順序あり)
+  | Categorical  ![Text]                 -- ^ 順序なしカテゴリ
+  | Ordinal      ![Text]                 -- ^ 順序ありカテゴリ
+  | Mixture      !Double !Double         -- ^ 混合比制約下の (lower, upper)
+  deriving (Eq, Show)
+
+-- | Factor = 名前 + 水準型 + 役割。
+data Factor = Factor
+  { fName :: !Text
+  , fKind :: !FactorKind
+  , fRole :: !FactorRole
+  } deriving (Eq, Show)
+
+-- | 連続系 (Continuous / DiscreteNum / Mixture) かどうか。
+-- 設計行列の展開時に、 categorical 因子の treatment coding 分岐に使う。
+factorIsContinuous :: Factor -> Bool
+factorIsContinuous f = case fKind f of
+  Continuous  _ _ -> True
+  DiscreteNum _   -> True
+  Mixture     _ _ -> True
+  Categorical _   -> False
+  Ordinal     _   -> False
+
+-- | Factor の「設計行列に占める列数」 概算 (skeleton 段階の単純実装)。
+-- - 連続系: 1
+-- - Categorical / Ordinal: (水準数 − 1)  ※reference coding
+-- 0 水準 (空 Categorical) は 0 列 (実装側で warn 推奨)。
+factorDimension :: Factor -> Int
+factorDimension f = case fKind f of
+  Continuous  _ _   -> 1
+  DiscreteNum _     -> 1
+  Mixture     _ _   -> 1
+  Categorical xs    -> max 0 (length xs - 1)
+  Ordinal     xs    -> max 0 (length xs - 1)
diff --git a/src/Hanalyze/Design/Custom/Model.hs b/src/Hanalyze/Design/Custom/Model.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Design/Custom/Model.hs
@@ -0,0 +1,241 @@
+{-# LANGUAGE OverloadedStrings #-}
+-- |
+-- Module      : Hanalyze.Design.Custom.Model
+-- Description : Custom Design の Model 定義と設計行列展開 (項 ADT → treatment coding)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Custom Design の Model 定義 + 設計行列展開 (Phase 24-2)。
+--
+-- spec: doe-custom-design-spec v0.1.1 §2.2 / §3.1。
+--
+-- ## raw matrix の Categorical 表現規約 (重要、 型安全ではない)
+--
+-- `expandDesignMatrix` の入力 `Matrix Double` における Categorical / Ordinal
+-- 因子の列は **level index 0..K-1 を Double で保持** する。
+-- expandDesignMatrix は reference (treatment) coding で K-1 列に展開、
+-- 参照水準 = index 0。
+--
+-- `Matrix Double` は連続値も index も同じ型なので、 0.5 のような小数や
+-- 範囲外 index を **型では防げない**。 検出は runtime check (`Left Text`)。
+-- 王道再設計 (R `model.matrix` / patsy 流の型分離) は phase-plan の
+-- Phase 27 候補に登録済。 詳細は specification/phases/phase-24-custom-design-core.md。
+--
+-- ## 未対応 (Phase 24 v0.2 候補)
+--
+--   * `mNorm` は ADT として持つが現状 'NCoded' は identity、 'NUnit' / 'NRaw' は
+--     呼び出し側で適切な値を渡す前提
+--   * `TNested` / `TCustom` (`Left` を返す)
+--   * `TPower` を Categorical 因子に適用するのは無意味 (indicator^k = indicator)
+--     なので `Left`
+module Hanalyze.Design.Custom.Model
+  ( ParamNormalize (..)
+  , ModelTerm (..)
+  , Model (..)
+  , expandDesignMatrix
+  , modelNumColumns
+  ) where
+
+import           Data.Text (Text)
+import qualified Data.Text as T
+import           Data.List (elemIndex)
+import qualified Numeric.LinearAlgebra as LA
+
+import           Hanalyze.Design.Custom.Factor
+
+-- | 因子値の正規化方針。
+data ParamNormalize
+  = NCoded   -- ^ coded units (連続因子は @[-1, 1]@ に既に変換済前提)
+  | NUnit    -- ^ unit cube (@[0, 1]@) 想定
+  | NRaw     -- ^ raw 単位 (= 何も変換しない)
+  deriving (Eq, Show)
+
+-- | モデル項。
+data ModelTerm
+  = TIntercept                     -- ^ 切片 (全 1 列)
+  | TMain   !Text                  -- ^ 主効果 (因子名)
+  | TInter  ![Text]                -- ^ 交互作用 (k 因子)
+  | TPower  !Text !Int             -- ^ @x^k@ (k ≥ 2 を想定、 連続因子のみ)
+  | TNested !Text !Text            -- ^ @A within B@ (未対応)
+  deriving (Eq, Show)
+
+-- | モデル = 項リスト + 正規化方針。
+data Model = Model
+  { mTerms :: ![ModelTerm]
+  , mNorm  :: !ParamNormalize
+  } deriving (Eq, Show)
+
+-- | モデル全体が設計行列に占める列数 (Categorical 因子の K-1 展開を考慮)。
+-- Categorical 因子参照中の TMain / TInter / TPower は factorDimension を使う。
+modelNumColumns :: [Factor] -> Model -> Int
+modelNumColumns factors m = sum (map termWidth (mTerms m))
+  where
+    findF n = lookup n [(fName f, f) | f <- factors]
+    dim n   = maybe 1 factorDimension (findF n)
+    termWidth t = case t of
+      TIntercept    -> 1
+      TMain n       -> dim n
+      TInter ns     -> product (map dim ns)
+      TPower _ _    -> 1
+      TNested a b   -> levelsOf b * dim a   -- Phase 28-1: K_B × (K_A - 1) cols
+    levelsOf n = case lookup n [(fName f, f) | f <- factors] of
+      Just f -> case fKind f of
+        Categorical xs -> length xs
+        Ordinal     xs -> length xs
+        _              -> 0
+      Nothing -> 0
+
+-- | 因子の raw 値行列 (n × p_factors) からモデル設計行列 (n × p_terms) を展開。
+--
+-- 入力 @raw@ の列順は @factors@ の順序と一致する前提。
+-- Categorical / Ordinal 因子の列は **level index 0..K-1 を Double で保持**
+-- する規約 (上記モジュール doc 参照)。
+--
+-- 失敗を返すケース:
+--   * @TNested@ を含む
+--   * 参照される因子名が見つからない
+--   * Categorical の raw 値が非整数 / 範囲外
+--   * @TPower@ を Categorical 因子に適用
+--   * 因子行列の列数が @factors@ の長さと一致しない
+expandDesignMatrix
+  :: [Factor]
+  -> Model
+  -> LA.Matrix Double            -- ^ 因子 raw 値 (n × p_factors)
+  -> Either Text (LA.Matrix Double)
+expandDesignMatrix factors model raw
+  | LA.cols raw /= length factors =
+      Left (T.pack "expandDesignMatrix: raw column count ≠ #factors")
+  | otherwise = do
+      colss <- mapM (termColumns factors raw) (mTerms model)
+      pure (LA.fromColumns (concat colss))
+
+-- | 単一項を 0 個以上の列に変換 (Categorical の TMain は K-1 列、
+-- Categorical × Categorical の TInter はクロス積で (K1-1)(K2-1) 列等)。
+termColumns
+  :: [Factor]
+  -> LA.Matrix Double
+  -> ModelTerm
+  -> Either Text [LA.Vector Double]
+termColumns _ raw TIntercept =
+  Right [LA.fromList (replicate (LA.rows raw) 1.0)]
+termColumns factors raw (TMain name) =
+  factorColumns factors raw name
+termColumns factors raw (TInter names)
+  | null names = Left (T.pack "TInter with no factor names is invalid")
+  | otherwise = do
+      colGroups <- mapM (factorColumns factors raw) names
+      -- 各因子の列群を cartesian product で elementwise 積。
+      Right (foldr1 crossMultiply colGroups)
+termColumns factors raw (TPower name k)
+  | k < 2     = Left (T.pack ("TPower: k must be >= 2 (got " <> show k <> ")"))
+  | otherwise = do
+      f <- findFactor factors name
+      if factorIsContinuous f
+        then do
+          v <- numericFactorVector factors raw name
+          Right [LA.cmap (** fromIntegral k) v]
+        else Left (T.pack
+               ("TPower on categorical/ordinal factor " <> T.unpack name
+                <> " is degenerate (indicator^k = indicator)"))
+termColumns factors raw (TNested aName bName) = do
+  (aIdx, fA) <- findFactorWithIndex factors aName
+  (bIdx, fB) <- findFactorWithIndex factors bName
+  let kindCat fk = case fk of
+        Categorical xs -> Just xs
+        Ordinal     xs -> Just xs
+        _              -> Nothing
+  case (kindCat (fKind fA), kindCat (fKind fB)) of
+    (Just aXs, Just bXs) -> do
+      let aCol = LA.flatten (LA.subMatrix (0, aIdx) (LA.rows raw, 1) raw)
+          bCol = LA.flatten (LA.subMatrix (0, bIdx) (LA.rows raw, 1) raw)
+      aIxs <- traverse (validateLevelIndex aName (length aXs)) (LA.toList aCol)
+      bIxs <- traverse (validateLevelIndex bName (length bXs)) (LA.toList bCol)
+      let kB = length bXs
+          kA = length aXs
+          n  = LA.rows raw
+          mkCol bLvl aLvl = LA.fromList
+            [ if (bIxs !! i) == bLvl && (aIxs !! i) == aLvl then 1.0 else 0.0
+            | i <- [0 .. n - 1] ]
+      -- 列順: outer = B level (0..K_B-1)、 inner = A level (1..K_A-1) (treatment coding)
+      Right [ mkCol b a | b <- [0 .. kB - 1], a <- [1 .. kA - 1] ]
+    _ ->
+      Left (T.pack
+        ("TNested " <> T.unpack aName <> " within " <> T.unpack bName
+         <> ": both factors must be Categorical/Ordinal (Phase 28-1 制限)"))
+
+-- | 2 つの列群を elementwise 積で cartesian-product 化。
+-- 結果列数 = length xs * length ys。
+crossMultiply :: [LA.Vector Double] -> [LA.Vector Double] -> [LA.Vector Double]
+crossMultiply xs ys = [x * y | x <- xs, y <- ys]
+  -- Vector の Num instance は elementwise
+
+-- | 因子名 → 設計行列に挿入する列群。
+-- 連続系: 単一列 (raw そのまま)。
+-- Categorical / Ordinal: treatment coding で K-1 列 (reference = index 0)。
+factorColumns
+  :: [Factor]
+  -> LA.Matrix Double
+  -> Text
+  -> Either Text [LA.Vector Double]
+factorColumns factors raw name = do
+  (i, f) <- findFactorWithIndex factors name
+  let col = LA.flatten (LA.subMatrix (0, i) (LA.rows raw, 1) raw)
+  case fKind f of
+    Continuous  _ _ -> Right [col]
+    DiscreteNum _   -> Right [col]
+    Mixture     _ _ -> Right [col]
+    Categorical xs  -> treatmentCoding name (length xs) col
+    Ordinal     xs  -> treatmentCoding name (length xs) col
+
+-- | reference (treatment) coding。 K 水準なら K-1 列、 reference = index 0。
+-- 列 k (1-based: 1..K-1) の値 = 1 if raw == k else 0。
+treatmentCoding
+  :: Text                           -- ^ 因子名 (エラーメッセージ用)
+  -> Int                            -- ^ 水準数 K
+  -> LA.Vector Double               -- ^ raw 列 (level index を Double で)
+  -> Either Text [LA.Vector Double]
+treatmentCoding name k col
+  | k <= 0 = Left (T.pack
+               ("factor " <> T.unpack name <> ": categorical with 0 levels"))
+  | k == 1 = Right []  -- 1 水準は constant、 列なし
+  | otherwise = do
+      idxs <- traverse (validateLevelIndex name k) (LA.toList col)
+      let mkCol lvl = LA.fromList
+            [ if i == lvl then 1.0 else 0.0 | i <- idxs ]
+      Right [mkCol lvl | lvl <- [1 .. k - 1]]
+
+-- | level index validation: 整数値かつ [0, K-1] 範囲内。
+validateLevelIndex :: Text -> Int -> Double -> Either Text Int
+validateLevelIndex name k x =
+  let xi = round x :: Int
+      delta = abs (x - fromIntegral xi)
+  in if delta > 1e-9
+       then Left (T.pack
+              ("factor " <> T.unpack name
+               <> ": categorical raw value " <> show x
+               <> " is not an integer level index"))
+       else if xi < 0 || xi >= k
+              then Left (T.pack
+                     ("factor " <> T.unpack name
+                      <> ": level index " <> show xi
+                      <> " out of range [0," <> show (k - 1) <> "]"))
+              else Right xi
+
+-- | 連続因子の生の列 (TPower 用に分離した helper)。
+numericFactorVector
+  :: [Factor]
+  -> LA.Matrix Double
+  -> Text
+  -> Either Text (LA.Vector Double)
+numericFactorVector factors raw name = do
+  (i, _) <- findFactorWithIndex factors name
+  Right (LA.flatten (LA.subMatrix (0, i) (LA.rows raw, 1) raw))
+
+findFactor :: [Factor] -> Text -> Either Text Factor
+findFactor factors name = snd <$> findFactorWithIndex factors name
+
+findFactorWithIndex :: [Factor] -> Text -> Either Text (Int, Factor)
+findFactorWithIndex factors name =
+  case elemIndex name (map fName factors) of
+    Nothing -> Left (T.pack ("factor not found: " <> T.unpack name))
+    Just i  -> Right (i, factors !! i)
diff --git a/src/Hanalyze/Design/Custom/Power.hs b/src/Hanalyze/Design/Custom/Power.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Design/Custom/Power.hs
@@ -0,0 +1,174 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE BangPatterns      #-}
+-- |
+-- Module      : Hanalyze.Design.Custom.Power
+-- Description : Custom Design の設計行列から model term ごとの検出力を直接算出
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Custom Design の設計行列ベース Power Analysis (Phase 24-8)。
+--
+-- spec: doe-custom-design-spec v0.1.1 §2.8 / §3.5。
+--
+-- 既存の 'Hanalyze.Design.Power' は ANOVA effect size (Cohen's f) ベースだが、
+-- 本モジュールは **生成済の Custom Design の設計行列 X から各 model term の
+-- noncentrality λ を直接算出** し、 noncentral F 分布の正規近似で power を返す。
+--
+-- ## アルゴリズム
+--
+-- 1. 設計行列 X を `expandDesignMatrix` で取得 (n × p)。 \(M = X^T X\) の
+--    逆行列を 1 回計算。
+-- 2. 各 model term について、 expand 出力のどの列を占めるかを `termColumns`
+--    で特定 (Categorical TMain は K-1 列に展開されるので複数列になる)。
+-- 3. effect size β (ユーザ入力) と σ (事前推定) から noncentrality:
+--
+--    \( \lambda = \frac{1}{\sigma^2} \sum_{j \in \mathrm{cols}} \frac{\beta^2}{(X^T X)^{-1}_{jj}} \)
+--
+--    これは「全 term 列に同じ true coefficient β が乗る」 + 「直交近似
+--    (block-diagonal Σ_J)」 という単純化の下で正確。 非直交ケースでは過大評価
+--    気味の近似値となる。 改善 (block-inverse 版) は将来 commit。
+-- 4. F 検定 (df1 = #term cols, df2 = n - p) の critical value を 1 - α で取得、
+--    Patnaik / 正規近似で `power = 1 - Φ((fCrit*df1 - (df1 + λ)) / sqrt(2(df1 + 2λ)))`。
+--    既存 'Hanalyze.Design.Power.powerOneWayAnova' と同手法。
+--
+-- ## term 名 (ユーザが指定する @[(Text, Double)]@ のキー)
+--
+--   * @TIntercept@         → @"(Intercept)"@
+--   * @TMain "x1"@         → @"x1"@
+--   * @TInter ["x1","x2"]@ → @"x1:x2"@ (因子順は元 ADT 通り、 sort しない)
+--   * @TPower "x1" k@      → @"x1^k"@
+--   * @TNested a b@        → @"a(b)"@
+--
+-- 該当 term が見つからない場合は 'dpPower = 0' で返す (warning なし、
+-- スコア用途のため Left にはしない)。
+module Hanalyze.Design.Custom.Power
+  ( DesignPower (..)
+  , designPower
+  , termName
+  , termColumnIndices
+  ) where
+
+import           Data.Text                (Text)
+import qualified Data.Text                as T
+import qualified Numeric.LinearAlgebra    as LA
+import qualified Statistics.Distribution                as SD
+import qualified Statistics.Distribution.FDistribution  as FD
+import qualified Statistics.Distribution.Normal         as NormalD
+
+import           Hanalyze.Design.Custom.Factor
+import           Hanalyze.Design.Custom.Model
+import           Hanalyze.Design.Custom.Coordinate (CustomDesign (..))
+
+-- ---------------------------------------------------------------------------
+-- 型
+-- ---------------------------------------------------------------------------
+
+data DesignPower = DesignPower
+  { dpTerm   :: !Text
+  , dpEffect :: !Double
+  , dpAlpha  :: !Double
+  , dpPower  :: !Double
+  } deriving (Show, Eq)
+
+-- ---------------------------------------------------------------------------
+-- 公開 API
+-- ---------------------------------------------------------------------------
+
+-- | 各 term の power を算出。 expand 失敗時は全 term で @dpPower = 0@。
+designPower
+  :: CustomDesign
+  -> Double                  -- ^ σ の事前推定
+  -> [(Text, Double)]        -- ^ 各 term の effect size β
+  -> Double                  -- ^ alpha
+  -> [DesignPower]
+designPower cd sigma effects alpha =
+  case expandDesignMatrix (cdFactors cd) (cdModel cd) (cdMatrix cd) of
+    Left _ ->
+      [ DesignPower nm eff alpha 0 | (nm, eff) <- effects ]
+    Right x ->
+      let !n   = LA.rows x
+          !p   = LA.cols x
+          xtx  = LA.tr x LA.<> x
+          d    = LA.det xtx
+      in if abs d < 1e-12 || n - p < 1 || sigma <= 0
+           then [ DesignPower nm eff alpha 0 | (nm, eff) <- effects ]
+           else
+             let inv     = LA.inv xtx
+                 !diag   = [ inv `LA.atIndex` (j, j) | j <- [0 .. p - 1] ]
+                 termMap = termColumnIndices (cdFactors cd) (cdModel cd)
+             in [ powerFor termMap diag n p sigma alpha nm eff
+                | (nm, eff) <- effects ]
+
+-- | 1 term の power を算出。 該当 term が無ければ power = 0。
+powerFor
+  :: [(Text, [Int])]
+  -> [Double]      -- ^ (X'X)⁻¹ の対角 (column j)
+  -> Int           -- ^ n
+  -> Int           -- ^ p (model total columns)
+  -> Double        -- ^ sigma
+  -> Double        -- ^ alpha
+  -> Text          -- ^ term name
+  -> Double        -- ^ effect size β
+  -> DesignPower
+powerFor termMap diag n p sigma alpha nm eff =
+  case lookup nm termMap of
+    Nothing -> DesignPower nm eff alpha 0
+    Just []  -> DesignPower nm eff alpha 0
+    Just cols ->
+      let !df1 = length cols
+          !df2 = n - p
+          -- noncentrality λ = (β/σ)² · sum_j 1 / (X'X)⁻¹_{jj}
+          --   = sum_j β² / (σ² · (X'X)⁻¹_{jj})
+          !ncp = sum
+            [ (eff * eff) / (sigma * sigma * diag !! j) | j <- cols ]
+          fCrit = SD.quantile (FD.fDistribution df1 df2) (1 - alpha)
+          -- noncentral F の chi² 正規近似 (powerOneWayAnova と同手法)
+          mean1 = fromIntegral df1 + ncp
+          var1  = 2 * (fromIntegral df1 + 2 * ncp)
+          z     = (fCrit * fromIntegral df1 - mean1) / sqrt var1
+          !pw   = 1 - SD.cumulative (NormalD.normalDistr 0 1) z
+      in DesignPower nm eff alpha pw
+
+-- ---------------------------------------------------------------------------
+-- term 名 + column index の対応
+-- ---------------------------------------------------------------------------
+
+-- | term ADT を canonical 名 (Text) に変換。
+termName :: ModelTerm -> Text
+termName TIntercept     = T.pack "(Intercept)"
+termName (TMain nm)     = nm
+termName (TInter ns)    = T.intercalate (T.pack ":") ns
+termName (TPower nm k)  = nm <> T.pack "^" <> T.pack (show k)
+termName (TNested a b)  = a <> T.pack "(" <> b <> T.pack ")"
+
+-- | 各 model term の expand 後 column indices (term 名でルックアップ可)。
+-- expandDesignMatrix の列順 (= mTerms 順) と整合。
+-- Categorical TMain は K-1 列、 TInter は cartesian product 数の列を占める。
+termColumnIndices :: [Factor] -> Model -> [(Text, [Int])]
+termColumnIndices factors model = snd (go (mTerms model) 0 [])
+  where
+    go :: [ModelTerm] -> Int -> [(Text, [Int])] -> (Int, [(Text, [Int])])
+    go [] off acc = (off, reverse acc)
+    go (t:ts) off acc =
+      let w = termWidthOf factors t
+          cols = [off .. off + w - 1]
+      in go ts (off + w) ((termName t, cols) : acc)
+
+-- | 単一 term の column width (modelNumColumns の per-term 版)。
+termWidthOf :: [Factor] -> ModelTerm -> Int
+termWidthOf factors t = case t of
+  TIntercept -> 1
+  TMain n    -> dim n
+  TInter ns  -> product (map dim ns)
+  TPower _ _ -> 1
+  TNested a b -> levelsOf b * dim a  -- Phase 28-1: K_B × (K_A - 1)
+  where
+    dim n = case lookup n [(fName f, f) | f <- factors] of
+      Just f  -> factorDimension f
+      Nothing -> 1
+    levelsOf n = case lookup n [(fName f, f) | f <- factors] of
+      Just f -> case fKind f of
+        Categorical xs -> length xs
+        Ordinal     xs -> length xs
+        _              -> 0
+      Nothing -> 0
diff --git a/src/Hanalyze/Design/Custom/RegionMoment.hs b/src/Hanalyze/Design/Custom/RegionMoment.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Design/Custom/RegionMoment.hs
@@ -0,0 +1,434 @@
+{-# LANGUAGE OverloadedStrings #-}
+-- |
+-- Module      : Hanalyze.Design.Custom.RegionMoment
+-- Description : Custom Design の region moment matrix (I-criterion 用の region 積分)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Custom Design の region moment matrix (Phase 28-4)。
+--
+-- JMP 同等 I-criterion を実装するための region 積分 M_R を解析的に構築する。
+--
+-- @
+--   I(X) = ∫_R f(z)' (X'X)⁻¹ f(z) dz / vol(R)
+--        = trace( (X'X)⁻¹ · M_R )
+--   M_R = ∫_R f(z) f(z)' dz / vol(R)
+-- @
+--
+-- ## region 規約 (JMP 既定と整合)
+--
+--   * Continuous: coded @z ∈ U[-1, 1]@ 独立 (raw range は coded 後の前提で無視)
+--   * DiscreteNum xs: xs から等確率に抽出 (有限サポート)
+--   * Categorical / Ordinal (K 水準): 等確率
+--   * Mixture: 非対応 (Phase 28-4a スコープ外、 簡plex 上の積分は 28-9/28-10
+--     候補。 'regionMomentMatrixAnalytic' は Left を返す)
+--
+-- 「Compare / Coordinate のどちらからも import される」 ため、 'CustomDesign'
+-- には依存しない (Factor + Model + Optimal のみ依存)。
+module Hanalyze.Design.Custom.RegionMoment
+  ( regionMomentMatrixAnalytic
+  , regionMomentMatrixMC
+  , iValueRegionM
+  , resolveIOptRegion
+    -- * DuMouchel-Jones §2.2 column transform (Phase 28-12)
+  , DJTransform (..)
+  , djFitTransform
+  , djApplyTransform
+  , djTransformColumns
+  ) where
+
+import           Data.List                (elemIndex)
+import qualified Data.Map.Strict          as M
+import           Data.Text                (Text)
+import qualified Data.Text                as T
+import qualified Numeric.LinearAlgebra    as LA
+
+import           Hanalyze.Design.Custom.Factor
+import           Hanalyze.Design.Custom.Model
+import           Hanalyze.Design.Custom.Constraint
+                   (Constraint, FactorValue (..), checkRowAgainst)
+import           Hanalyze.Design.Optimal       (OptCriterion (..))
+import qualified Hanalyze.Stat.QuasiRandom as QR
+
+-- ---------------------------------------------------------------------------
+-- 列構造記述
+-- ---------------------------------------------------------------------------
+
+-- | 1 因子分の expand 後寄与。 連続因子なら指数 k (≥ 1)、 categorical/ordinal
+-- なら treatment coding の level index l (1..K-1)。
+data FactorContrib
+  = ContPow  !Int   -- ^ @z_i^k@、 k ≥ 1
+  | CatLevel !Int   -- ^ indicator at level l (1..K-1)
+  deriving (Eq, Show)
+
+-- | expand 後 1 列の構造記述: 因子 index → 寄与。 map に無い因子は「無寄与 = 1」。
+type ColDesc = M.Map Int FactorContrib
+
+-- | 因子と Model から expand 後の各列の構造記述を 'expandDesignMatrix' と同順で生成。
+-- Mixture / TNested は Left。
+columnDescriptors :: [Factor] -> Model -> Either Text [ColDesc]
+columnDescriptors fs model =
+  concat <$> traverse (termDescriptors fs) (mTerms model)
+
+termDescriptors :: [Factor] -> ModelTerm -> Either Text [ColDesc]
+termDescriptors _  TIntercept     = Right [M.empty]
+termDescriptors fs (TMain n)      = mainDescs fs n
+termDescriptors fs (TPower n k)
+  | k < 2     = Left (T.pack ("regionMomentMatrixAnalytic: TPower k must be >= 2 (got " <> show k <> ")"))
+  | otherwise = do
+      (i, f) <- findFactorIdx fs n
+      case fKind f of
+        Continuous  _ _ -> Right [M.singleton i (ContPow k)]
+        DiscreteNum _   -> Right [M.singleton i (ContPow k)]
+        Mixture     _ _ -> Left (T.pack ("regionMomentMatrixAnalytic: Mixture factor " <> T.unpack n <> " not supported (Phase 28-4a)"))
+        _               -> Left (T.pack ("regionMomentMatrixAnalytic: TPower on categorical/ordinal factor " <> T.unpack n))
+termDescriptors fs (TInter ns)
+  | null ns   = Left (T.pack "regionMomentMatrixAnalytic: TInter with no factor names")
+  | otherwise = foldr1 crossDesc <$> traverse (mainDescs fs) ns
+termDescriptors _ (TNested _ _) =
+  Left (T.pack "regionMomentMatrixAnalytic: TNested not supported (Phase 28-1 候補)")
+
+-- | 主効果 (= TMain) 相当の寄与記述。 連続 → 1 個 (ContPow 1)、
+-- Categorical K 水準 → K-1 個 (CatLevel 1..K-1)、 Mixture → Left。
+mainDescs :: [Factor] -> Text -> Either Text [ColDesc]
+mainDescs fs n = do
+  (i, f) <- findFactorIdx fs n
+  case fKind f of
+    Continuous  _ _ -> Right [M.singleton i (ContPow 1)]
+    DiscreteNum _   -> Right [M.singleton i (ContPow 1)]
+    Mixture     _ _ -> Left (T.pack ("regionMomentMatrixAnalytic: Mixture factor " <> T.unpack n <> " not supported (Phase 28-4a)"))
+    Categorical xs  -> Right [M.singleton i (CatLevel l) | l <- [1 .. length xs - 1]]
+    Ordinal     xs  -> Right [M.singleton i (CatLevel l) | l <- [1 .. length xs - 1]]
+
+crossDesc :: [ColDesc] -> [ColDesc] -> [ColDesc]
+crossDesc xs ys = [M.unionWith mergeContrib x y | x <- xs, y <- ys]
+  where
+    mergeContrib (ContPow a) (ContPow b) = ContPow (a + b)
+    mergeContrib a           _           = a
+
+findFactorIdx :: [Factor] -> Text -> Either Text (Int, Factor)
+findFactorIdx fs n = case elemIndex n (map fName fs) of
+  Nothing -> Left (T.pack ("regionMomentMatrixAnalytic: factor not found: " <> T.unpack n))
+  Just i  -> Right (i, fs !! i)
+
+-- ---------------------------------------------------------------------------
+-- 解析積分 + I-criterion
+-- ---------------------------------------------------------------------------
+
+-- | region moment matrix を解析的に構築。 列順は 'expandDesignMatrix' と一致。
+-- Mixture / TNested を含むモデルは Left。 categorical 1 水準等で列数 0 のときは 0×0。
+regionMomentMatrixAnalytic
+  :: [Factor] -> Model -> Either Text (LA.Matrix Double)
+regionMomentMatrixAnalytic fs model = do
+  cols <- columnDescriptors fs model
+  let p = length cols
+  if p == 0
+    then Right ((0 LA.>< 0) [])
+    else
+      let fsArr = zip [0 :: Int ..] fs
+          ent i j =
+            let ca = cols !! i; cb = cols !! j
+            in product
+                 [ expectFactorProduct (fKind f) (M.lookup k ca) (M.lookup k cb)
+                 | (k, f) <- fsArr ]
+      in Right (LA.fromLists
+                  [ [ ent i j | j <- [0 .. p - 1] ] | i <- [0 .. p - 1] ])
+
+-- | 単一因子分の期待値 @E[part_a(z) · part_b(z)]@。
+expectFactorProduct
+  :: FactorKind -> Maybe FactorContrib -> Maybe FactorContrib -> Double
+expectFactorProduct kind ma mb = case kind of
+  Continuous _ _ -> contMomentPM1 (contPow ma + contPow mb)
+  DiscreteNum xs ->
+    let s = contPow ma + contPow mb
+        n = length xs
+    in if n == 0 then 0
+                 else sum (map (^^ s) xs) / fromIntegral n
+  Categorical xs -> catExp (length xs) (catLvl ma) (catLvl mb)
+  Ordinal     xs -> catExp (length xs) (catLvl ma) (catLvl mb)
+  Mixture _ _    -> 0 / 0
+  where
+    contPow Nothing             = 0
+    contPow (Just (ContPow k))  = k
+    contPow (Just (CatLevel _)) = 0
+    catLvl Nothing              = Nothing
+    catLvl (Just (CatLevel l))  = Just l
+    catLvl (Just (ContPow _))   = Nothing
+
+contMomentPM1 :: Int -> Double
+contMomentPM1 p
+  | odd p     = 0
+  | otherwise = 1 / fromIntegral (p + 1)
+
+catExp :: Int -> Maybe Int -> Maybe Int -> Double
+catExp _ Nothing  Nothing            = 1
+catExp k (Just _) Nothing            = 1 / fromIntegral k
+catExp k Nothing  (Just _)           = 1 / fromIntegral k
+catExp k (Just la) (Just lb)
+  | la == lb                         = 1 / fromIntegral k
+  | otherwise                        = 0
+
+-- ---------------------------------------------------------------------------
+-- MC 版 (Phase 28-4c): 制約有り / Mixture / 非 polynomial model 用
+-- ---------------------------------------------------------------------------
+--
+-- Halton quasi-random sequence で region から N 点抽出 (deterministic、 seed 不要)、
+-- 'Custom.Constraint.checkRowAgainst' で制約 region 内のみ採用。 採用率が低い
+-- 場合 maxAttempts (= 10×N) で打ち切り、 採用数 < N/10 のとき Left。 採用された
+-- raw rows を expand → @M_R = X^T X / N_accepted@ で構築。
+--
+-- 規約 (analytic と共通):
+--   * Continuous (lo, hi): coded @z ∈ U[-1, 1]@ 独立 (raw range 無視)
+--   * DiscreteNum xs: xs から等確率に抽出
+--   * Mixture (lo, hi): @[lo, hi]@ uniform (Halton 1 次元 → 線形写像)
+--   * Categorical / Ordinal (K 水準): 等確率
+
+regionMomentMatrixMC
+  :: Int              -- ^ 希望サンプル数 N (採用後、 採用率次第で短くなる場合あり)
+  -> [Factor]
+  -> Model
+  -> [Constraint]     -- ^ rejection sampling filter
+  -> Either Text (LA.Matrix Double)
+regionMomentMatrixMC nWant fs model cons
+  | nWant < 1 = Left (T.pack "regionMomentMatrixMC: N must be >= 1")
+  | null fs   = Left (T.pack "regionMomentMatrixMC: empty factor list")
+  | otherwise =
+      let nF          = length fs
+          maxAttempts = nWant * 10   -- 採用率 10% 想定の安全係数
+          halton      = QR.haltonMatrix maxAttempts nF
+          rawAll      =
+            [ [ mapU01ToFactorLocal (fs !! j) (halton `LA.atIndex` (i, j))
+              | j <- [0 .. nF - 1] ]
+            | i <- [0 .. maxAttempts - 1] ]
+          accepted = take nWant
+                     [ row | row <- rawAll, rowFeasibleLocal fs cons row ]
+          nAcc = length accepted
+      in if nAcc < max 1 (nWant `div` 10)
+           then Left (T.pack ("regionMomentMatrixMC: too few accepted samples ("
+                              <> show nAcc <> "/" <> show nWant <> "); 制約 region が極端に狭い可能性"))
+           else case expandDesignMatrix fs model (LA.fromLists accepted) of
+                  Left e  -> Left e
+                  Right x ->
+                    let nAccD = fromIntegral nAcc :: Double
+                    in Right (LA.scale (1 / nAccD) (LA.tr x LA.<> x))
+
+-- | Halton 1 次元値 u ∈ [0, 1] を 1 因子の raw 値に写像
+-- ('Custom.Compare.mapU01ToFactor' と同等、 module cycle 回避で再実装)。
+mapU01ToFactorLocal :: Factor -> Double -> Double
+mapU01ToFactorLocal f u = case fKind f of
+  Continuous _ _ -> -1 + 2 * u
+  DiscreteNum xs ->
+    let k = length xs
+    in if k <= 0 then 0
+                 else xs !! min (k - 1) (floor (u * fromIntegral k))
+  Mixture lo hi  -> lo + (hi - lo) * u
+  Categorical xs ->
+    let k = length xs
+    in if k <= 0 then 0
+                 else fromIntegral (min (k - 1) (floor (u * fromIntegral k)))
+  Ordinal xs     ->
+    let k = length xs
+    in if k <= 0 then 0
+                 else fromIntegral (min (k - 1) (floor (u * fromIntegral k)))
+
+-- | 1 row が全制約を満たすか
+-- ('Custom.Coordinate.rowFeasible' と同等、 module cycle 回避で再実装)。
+rowFeasibleLocal :: [Factor] -> [Constraint] -> [Double] -> Bool
+rowFeasibleLocal fs cons row =
+  let mkFV f x = case fKind f of
+        Categorical xs -> catIdx xs x
+        Ordinal     xs -> catIdx xs x
+        _              -> FVDouble x
+      catIdx xs x =
+        let xi = round x :: Int
+            d  = abs (x - fromIntegral xi)
+        in if d < 1e-9 && xi >= 0 && xi < length xs
+             then FVText (xs !! xi)
+             else FVDouble x
+      rowMap = M.fromList
+        [ (fName f, mkFV f v) | (f, v) <- zip fs row ]
+  in all (checkRowAgainst rowMap) cons
+
+-- | region moment matrix を用いた I-criterion: @trace((X'X)⁻¹ · M_R)@。
+-- 設計行列が rank-deficient (det(X'X) ≈ 0) なら ∞ を返す (minimize 方向)。
+iValueRegionM :: LA.Matrix Double -> LA.Matrix Double -> Double
+iValueRegionM mR x
+  | LA.rows x == 0 = 1 / 0
+  | otherwise =
+      let xtx = LA.tr x LA.<> x
+          d   = LA.det xtx
+      in if abs d < 1e-12 then 1 / 0
+           else LA.sumElements (LA.takeDiag (LA.inv xtx LA.<> mR))
+
+-- ---------------------------------------------------------------------------
+-- IOpt → IOptRegion 解決 (Phase 28-4b)
+-- ---------------------------------------------------------------------------
+
+-- | OptCriterion 木を走査し、 'IOpt' を 'IOptRegion mR' に置換する。
+-- 'IOptRegion' は once-only に「凍結された M_R」 を持つので、 'Compound' で
+-- 入れ子になっていても 1 回 M_R を作って全 IOpt を共有して置換する。
+--
+-- Phase 28-4c: 制約有り (cons 非空) または Mixture 因子を含むとき、
+-- 'regionMomentMatrixAnalytic' は Left を返すため自動で
+-- 'regionMomentMatrixMC' (Halton quasi-random、 N=10000) に fallback する。
+-- IOpt を含まない criterion は M_R 構築をスキップして Right で原型を返す。
+resolveIOptRegion
+  :: [Factor] -> Model -> [Constraint] -> OptCriterion
+  -> Either Text OptCriterion
+resolveIOptRegion fs model cons crit
+  | not (containsIOpt crit) = Right crit
+  | otherwise = do
+      mR <- buildMR
+      let mrRows = LA.toLists mR
+      pure (rewriteCrit mrRows crit)
+  where
+    needsMC = not (null cons) || any isMixture fs || containsTNested model
+    isMixture f = case fKind f of
+      Mixture _ _ -> True
+      _           -> False
+    containsTNested m = any isTN (mTerms m)
+    isTN (TNested _ _) = True
+    isTN _             = False
+
+    buildMR
+      | needsMC = case regionMomentMatrixMC 10000 fs model cons of
+          Right m -> Right m
+          Left  e ->
+            -- MC が失敗したら analytic を最後の砦として試す (制約無視で粗い近似)
+            case regionMomentMatrixAnalytic fs model of
+              Right m -> Right m
+              Left _  -> Left e
+      | otherwise = regionMomentMatrixAnalytic fs model
+
+    containsIOpt IOpt              = True
+    containsIOpt (Compound ws)     = any (containsIOpt . snd) ws
+    containsIOpt _                 = False
+
+    rewriteCrit mrRows IOpt          = IOptRegion mrRows
+    rewriteCrit mrRows (Compound ws) =
+      Compound [ (w, rewriteCrit mrRows c) | (w, c) <- ws ]
+    rewriteCrit _      c             = c
+
+-- ---------------------------------------------------------------------------
+-- DuMouchel-Jones §2.2 column transform (Phase 28-12)
+-- ---------------------------------------------------------------------------
+--
+-- 詳細は doc: src/Hanalyze/Design/Custom/Bayesian.hs (Phase 28-12 section)。
+-- Power.termColumnIndices に依存しないよう、 列 index 列挙を本 module 内に
+-- 再実装している (Coordinate ↔ Bayesian の module cycle 回避)。
+
+data DJTransform = DJTransform
+  { djtPrimaryIdx   :: ![Int]
+  , djtPotentialIdx :: ![Int]
+  , djtMeanQ        :: !(LA.Vector Double)
+  , djtBetaPQ       :: !(LA.Matrix Double)
+  , djtScaleQ       :: !(LA.Vector Double)
+  } deriving (Show)
+
+isPotentialTerm :: ModelTerm -> Bool
+isPotentialTerm TIntercept     = False
+isPotentialTerm (TMain _)      = False
+isPotentialTerm (TInter ns)    = length ns >= 2
+isPotentialTerm (TPower _ k)   = k >= 2
+isPotentialTerm (TNested _ _)  = True
+
+-- | 各 term の expand 後 column index 範囲 (Power.termColumnIndices の再実装)。
+termColumnIndicesLocal :: [Factor] -> Model -> [(ModelTerm, [Int])]
+termColumnIndicesLocal fs model = go (mTerms model) 0
+  where
+    go [] _ = []
+    go (t:ts) off =
+      let w = termWidth t
+          cols = [off .. off + w - 1]
+      in (t, cols) : go ts (off + w)
+    termWidth t = case t of
+      TIntercept    -> 1
+      TMain n       -> dim n
+      TInter ns     -> product (map dim ns)
+      TPower _ _    -> 1
+      TNested a b   -> levelsOf b * dim a   -- Phase 28-1
+    dim n = case lookup n [(fName f, f) | f <- fs] of
+      Just f  -> factorDimension f
+      Nothing -> 1
+    levelsOf n = case lookup n [(fName f, f) | f <- fs] of
+      Just f -> case fKind f of
+        Categorical xs -> length xs
+        Ordinal     xs -> length xs
+        _              -> 0
+      Nothing -> 0
+
+djFitTransform
+  :: [Factor] -> Model -> LA.Matrix Double -> Either Text DJTransform
+djFitTransform fs model cand = do
+  xCand <- expandDesignMatrix fs model cand
+  let pairs = termColumnIndicesLocal fs model
+      primaryIdx   = concat [ cols | (t, cols) <- pairs, not (isPotentialTerm t) ]
+      potentialIdx = concat [ cols | (t, cols) <- pairs, isPotentialTerm t ]
+      nC = LA.rows xCand
+      nCD = fromIntegral nC :: Double
+      q = length potentialIdx
+  if q == 0
+    then pure DJTransform
+           { djtPrimaryIdx   = primaryIdx
+           , djtPotentialIdx = []
+           , djtMeanQ        = LA.fromList []
+           , djtBetaPQ       = (0 LA.>< 0) []
+           , djtScaleQ       = LA.fromList []
+           }
+    else do
+      let xP = if null primaryIdx then (nC LA.>< 0) [] else xCand LA.¿ primaryIdx
+          xQ = xCand LA.¿ potentialIdx
+          meanRow = LA.fromList
+            [ LA.sumElements (LA.flatten (xQ LA.¿ [j])) / nCD
+            | j <- [0 .. q - 1] ]
+          ones    = LA.konst 1 nC :: LA.Vector Double
+          xQc = xQ - LA.outer ones meanRow
+          betas = if null primaryIdx
+                    then (0 LA.>< q) []
+                    else
+                      let xtxP = LA.tr xP LA.<> xP
+                          d = LA.det xtxP
+                      in if abs d < 1e-12
+                           then LA.konst 0 (LA.cols xP, q)
+                           else LA.inv xtxP LA.<> LA.tr xP LA.<> xQc
+          xQo = if null primaryIdx then xQc else xQc - xP LA.<> betas
+          rangesL =
+            [ let v = LA.flatten (xQo LA.¿ [j])
+              in LA.maxElement v - LA.minElement v
+            | j <- [0 .. q - 1] ]
+      pure DJTransform
+        { djtPrimaryIdx   = primaryIdx
+        , djtPotentialIdx = potentialIdx
+        , djtMeanQ        = meanRow
+        , djtBetaPQ       = betas
+        , djtScaleQ       = LA.fromList rangesL
+        }
+
+djApplyTransform :: DJTransform -> LA.Matrix Double -> LA.Matrix Double
+djApplyTransform t x
+  | null (djtPotentialIdx t) = x
+  | otherwise =
+      let n   = LA.rows x
+          pIdx = djtPrimaryIdx t
+          qIdx = djtPotentialIdx t
+          xP   = if null pIdx then (n LA.>< 0) [] else x LA.¿ pIdx
+          xQ   = x LA.¿ qIdx
+          ones = LA.konst 1 n :: LA.Vector Double
+          xQc  = xQ - LA.outer ones (djtMeanQ t)
+          xQo  = if null pIdx then xQc else xQc - xP LA.<> djtBetaPQ t
+          invR = LA.cmap (\r -> if abs r < 1e-12 then 1 else 1 / r) (djtScaleQ t)
+          xQf  = xQo LA.<> LA.diag invR
+          q    = length qIdx
+          col k = LA.flatten (xQf LA.¿ [k])
+          potMap  = zip qIdx [0 .. q - 1]
+          pickCol i = case lookup i potMap of
+            Just k  -> col k
+            Nothing -> LA.flatten (x LA.¿ [i])
+      in LA.fromColumns [ pickCol i | i <- [0 .. LA.cols x - 1] ]
+
+djTransformColumns
+  :: [Factor] -> Model -> LA.Matrix Double -> LA.Matrix Double
+  -> Either Text (LA.Matrix Double)
+djTransformColumns fs model cand x = do
+  t <- djFitTransform fs model cand
+  pure (djApplyTransform t x)
diff --git a/src/Hanalyze/Design/Custom/SplitPlot.hs b/src/Hanalyze/Design/Custom/SplitPlot.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Design/Custom/SplitPlot.hs
@@ -0,0 +1,473 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE BangPatterns      #-}
+-- |
+-- Module      : Hanalyze.Design.Custom.SplitPlot
+-- Description : Custom Design の Split-Plot 生成 (役割駆動の REML D-optimal 交換、内部 legacy)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Custom Design の Split-Plot 生成 (Phase 25-3/4)。
+--
+-- ★Phase 79 以降 **内部 legacy**: 製品パス (高レベル @customDesign@ + @Structure@) は役割非依存の
+--   構造駆動エンジン @Design.Custom.Structured@ を使う。 本モジュール (役割 @fRole@ 駆動) は
+--   bench-custom-design の 3 エンジン比較 + Jones-Goos 低レベル golden の証跡として温存する
+--   (新規機能は Structured 側へ。 M⁻¹ / GLS 基準の math は両者で数値一致)。
+--
+-- spec: doe-custom-design-spec v0.1.1 §2.5 / §3.6。
+-- 参考: Goos & Vandebroek (2003) "D-Optimal Split-Plot Designs", J Quality Tech 35:1-15。
+--
+-- ## モデル (簡易 REML)
+--
+--   y_ij = X_ij β + b_i + ε_ij
+--
+-- ここで b_i ~ N(0, σ²_WP) は whole-plot 効果、 ε_ij ~ N(0, σ²) は run-level error。
+-- 分散比 η = σ²_WP / σ² がユーザ指定 (既定 1.0、 spec §2.5 で議論)。
+--
+-- 観測ベクトル全体の分散構造:
+--
+--   V = σ² (I + η · Z Zᵀ)
+--
+-- ここで Z は whole-plot indicator matrix (n × n_WP)。
+-- REML information matrix:
+--
+--   I_β = (1/σ²) · Xᵀ M⁻¹ X、   M = I + η · Z Zᵀ
+--
+-- D-optimality は max det(Xᵀ M⁻¹ X)。 σ² は定数倍なので criterion に影響しない。
+--
+-- ## 本 commit (25-3/4) のスコープ
+--
+--   * 連続因子の whole-plot のみ対応 (categorical WP は 25-5 stub で Left)
+--   * Coordinate exchange を whole-plot 単位 / sub-plot 単位の 2 段に分けて適用:
+--     - WP 因子: 1 WP 内では同値、 列を WP indicator 構造で更新
+--     - SP 因子: 各 run 単位で coordinate exchange (= 通常)
+--   * η はユーザ指定 (`spcVarRatio`)、 既定 1.0
+--   * strip-plot (VeryHardToChange) は未対応 (Left)
+module Hanalyze.Design.Custom.SplitPlot
+  ( SplitPlotConfig (..)
+  , defaultSplitPlotConfig
+  , SplitPlotDesign (..)
+  , generateSplitPlot
+  , generateSplitPlotPure
+    -- * 内部 helper (test 用)
+  , whichRoleIsWP
+  , wholePlotIndicator
+  ) where
+
+import           Control.Monad             (forM_, when)
+import           Control.Monad.Primitive   (PrimMonad, PrimState)
+import           Control.Monad.ST          (runST)
+import           Data.Maybe                (fromMaybe)
+import           Data.Primitive.MutVar
+import           Data.Text                 (Text)
+import qualified Data.Text                 as T
+import qualified Numeric.LinearAlgebra     as LA
+import qualified System.Random.MWC         as MWC
+import qualified Data.Vector.Unboxed       as VU
+import qualified Data.Vector               as V
+import qualified Data.Vector.Storable      as VS
+
+import           Hanalyze.Design.Custom.Factor
+import           Hanalyze.Design.Custom.Model
+import           Hanalyze.Design.Custom.Coordinate
+                   (CustomDesignSpec (..), DesignBudget (..)
+                   , factorGrid, critValueM
+                   , mkGen, mkGenSeed, defaultPureSeed)
+import           Hanalyze.Design.Optimal   (OptCriterion (..))
+
+-- ---------------------------------------------------------------------------
+-- 型
+-- ---------------------------------------------------------------------------
+
+data SplitPlotConfig = SplitPlotConfig
+  { spcNWhole    :: !Int     -- ^ whole-plot 数 (必須、 spec §2.5 でユーザ指定強制)
+  , spcVarRatio  :: !Double  -- ^ η = σ²_WP / σ² (既定 1.0)
+  , spcNStrip    :: !(Maybe Int)
+    -- ^ Phase 28-2: strip-plot 構造の strip 数 (Just nStrip)。
+    -- 'VeryHardToChange' role 因子は strip 内で constant。 Nothing なら
+    -- 通常の split-plot。 n = nWP × nStrip を満たす必要 (= 行配置: row i は
+    -- wp = i div nStrip、 strip = i mod nStrip)
+  } deriving (Show)
+
+defaultSplitPlotConfig :: Int -> SplitPlotConfig
+defaultSplitPlotConfig nWP = SplitPlotConfig nWP 1.0 Nothing
+
+data SplitPlotDesign = SplitPlotDesign
+  { spdMatrix      :: !(LA.Matrix Double)
+  , spdWholePlotId :: !(VS.Vector Int)         -- ^ 各行の WP ID (0..nWP-1)
+  , spdSubPlotId   :: !(Maybe (VS.Vector Int))
+    -- ^ Phase 28-2: strip-plot 時の strip ID (Just)、 通常 split-plot は Nothing
+  , spdNWhole      :: !Int
+  , spdGEFFEst     :: !Double                  -- ^ 推定 Generalized Estimating Function 値
+    -- ^ ≒ - det(I_β) の最小化値 (DOpt のみ意味あり、 他 criterion は critValueM 経由)
+  } deriving (Show)
+
+-- ---------------------------------------------------------------------------
+-- 公開 API
+-- ---------------------------------------------------------------------------
+
+-- | seed 由来の gen を作って 'generateSplitPlotWith' を IO で走らせる薄い wrapper。
+-- 'cdsSeed' が 'Nothing' の場合のみ entropy 依存 (非決定的)。
+-- Phase 78.M: seed 決定的な純粋版は 'generateSplitPlotPure'。
+generateSplitPlot
+  :: CustomDesignSpec
+  -> SplitPlotConfig
+  -> IO (Either Text SplitPlotDesign)
+generateSplitPlot spec cfg = do
+  gen <- mkGen (cdsSeed spec)
+  generateSplitPlotWith spec cfg gen
+
+-- | seed 決定的な純粋版 (Phase 78.M)。'runST' で MWC gen + MutVar を閉じ込め、
+-- IO 無しで 'SplitPlotDesign' を返す。'cdsSeed' が 'Nothing' なら 'defaultPureSeed'
+-- を用いて全域にする。同一 seed なら 'generateSplitPlot' (IO) とビット一致する。
+generateSplitPlotPure
+  :: CustomDesignSpec
+  -> SplitPlotConfig
+  -> Either Text SplitPlotDesign
+generateSplitPlotPure spec cfg = runST $ do
+  gen <- mkGenSeed (fromMaybe defaultPureSeed (cdsSeed spec))
+  generateSplitPlotWith spec cfg gen
+
+-- | split-plot 生成本体 (PrimMonad 一般化)。IO / ST どちらでも走る。
+generateSplitPlotWith
+  :: PrimMonad m
+  => CustomDesignSpec
+  -> SplitPlotConfig
+  -> MWC.Gen (PrimState m)
+  -> m (Either Text SplitPlotDesign)
+generateSplitPlotWith spec cfg gen
+  | spcNWhole cfg < 1 =
+      pure (Left (T.pack "generateSplitPlot: spcNWhole must be >= 1"))
+  | cdsNRuns spec < spcNWhole cfg =
+      pure (Left (T.pack "generateSplitPlot: nRuns must be >= spcNWhole"))
+  -- Phase 28-2: VeryHardToChange (strip-plot) を対応。 spcNStrip = Just nStrip
+  -- が必要 + n = nWP × nStrip を満たすこと
+  | any ((== VeryHardToChange) . fRole) (cdsFactors spec)
+    && case spcNStrip cfg of Nothing -> True; _ -> False =
+      pure (Left (T.pack
+        "generateSplitPlot: VeryHardToChange factor present but spcNStrip not set"))
+  -- Phase 28-3: Categorical/Ordinal whole-plot 因子も対応 (factorGrid が level
+  -- index を返すため、 randomInitSP/runExchangeSP の WP loop でそのまま機能する)
+  | spcVarRatio cfg < 0 =
+      pure (Left (T.pack "generateSplitPlot: spcVarRatio (η) must be >= 0"))
+  | case spcNStrip cfg of
+      Just s -> s < 1 || s * spcNWhole cfg /= cdsNRuns spec
+      Nothing -> False =
+      pure (Left (T.pack
+        "generateSplitPlot: spcNStrip × spcNWhole must equal nRuns (strip-plot grid)"))
+  | otherwise = do
+      let !factors  = cdsFactors spec
+          !n        = cdsNRuns spec
+          !nWP      = spcNWhole cfg
+          !eta      = spcVarRatio cfg
+          !budget   = cdsBudget spec
+          !crit     = cdsCriterion spec
+          !model    = cdsModel spec
+          !wpIxs    = whichRoleIsWP factors
+          !stripIxs = whichRoleIsStrip factors
+          !wpId     = wholePlotIndicator n nWP
+          !mStripId = case spcNStrip cfg of
+            Just nStrip -> Just (stripPlotIndicator n nStrip)
+            Nothing     -> Nothing
+      if null wpIxs && null stripIxs
+        then pure (Left (T.pack
+          "generateSplitPlot: no HardToChange/VeryHardToChange factor found"))
+        else do
+          bestRef <- newMutVar Nothing
+          forM_ [1 .. dbRestarts budget] $ \_ -> do
+            init0 <- randomInitSPStrip factors wpIxs stripIxs wpId mStripId n budget gen
+            (finalM, finalC) <- runExchangeSP factors model crit budget eta wpIxs stripIxs wpId mStripId init0
+            modifyMutVar' bestRef $ \mb -> case mb of
+              Nothing -> Just (finalM, finalC)
+              Just (_, c0) | finalC < c0 -> Just (finalM, finalC)
+                           | otherwise   -> mb
+          mb <- readMutVar bestRef
+          case mb of
+            Nothing -> pure (Left (T.pack "generateSplitPlot: no restart produced a design"))
+            Just (m, c) -> pure $ Right SplitPlotDesign
+              { spdMatrix      = m
+              , spdWholePlotId = wpId
+              , spdSubPlotId   = mStripId
+              , spdNWhole      = nWP
+              , spdGEFFEst     = c
+              }
+
+-- ---------------------------------------------------------------------------
+-- WP indicator / role helper
+-- ---------------------------------------------------------------------------
+
+-- | HardToChange factor の column index リスト (whole-plot 因子)。
+whichRoleIsWP :: [Factor] -> [Int]
+whichRoleIsWP fs = [ i | (i, f) <- zip [0 ..] fs, fRole f == HardToChange ]
+
+-- | Phase 28-2: VeryHardToChange factor の column index リスト (strip 因子)。
+whichRoleIsStrip :: [Factor] -> [Int]
+whichRoleIsStrip fs = [ i | (i, f) <- zip [0 ..] fs, fRole f == VeryHardToChange ]
+
+-- | n 行を nWP に均等割り当てした WP indicator (0..nWP-1)。
+-- 余りは最初のいくつかの WP に追加で振る。
+wholePlotIndicator :: Int -> Int -> VS.Vector Int
+wholePlotIndicator n nWP =
+  let base  = n `div` nWP
+      extra = n `mod` nWP
+      sizes = [ if i < extra then base + 1 else base | i <- [0 .. nWP - 1] ]
+      ids   = concat [ replicate s i | (i, s) <- zip [0 ..] sizes ]
+  in VS.fromList ids
+
+-- | Phase 28-2: strip indicator (0..nStrip-1)。 row i → i `mod` nStrip。
+-- WP grouping (= i `div` nStrip) と直交する partitioning を実現する。
+-- n = nWP × nStrip の前提 (generateSplitPlot の guard で確認済)
+stripPlotIndicator :: Int -> Int -> VS.Vector Int
+stripPlotIndicator n nStrip =
+  VS.fromList [ i `mod` nStrip | i <- [0 .. n - 1] ]
+
+-- ---------------------------------------------------------------------------
+-- 初期化 (split-plot 構造を保つ)
+-- ---------------------------------------------------------------------------
+
+-- | 初期 raw matrix。 WP 因子は WP ごとに 1 値、 strip 因子は strip ごとに
+-- 1 値、 SP 因子は run ごとに 1 値。
+randomInitSPStrip
+  :: PrimMonad m
+  => [Factor]
+  -> [Int]               -- ^ WP 因子 column index
+  -> [Int]               -- ^ strip 因子 column index (Phase 28-2)
+  -> VS.Vector Int       -- ^ WP id per row
+  -> Maybe (VS.Vector Int)  -- ^ strip id per row (Phase 28-2)
+  -> Int                 -- ^ n
+  -> DesignBudget
+  -> MWC.Gen (PrimState m)
+  -> m (LA.Matrix Double)
+randomInitSPStrip factors wpIxs stripIxs wpId mStripId n budget gen = do
+  let p    = length factors
+      nWP  = if VS.null wpId then 0 else 1 + VS.maximum wpId
+      nStrp = case mStripId of
+        Just s | not (VS.null s) -> 1 + VS.maximum s
+        _ -> 0
+  cols <- mapM
+    (\j -> do
+       let g = factorGrid budget (factors !! j)
+           gl = VU.length g
+       if j `elem` wpIxs
+         then do
+           wpVals <- VU.replicateM nWP $ do
+             k <- MWC.uniformR (0, gl - 1) gen
+             pure (g VU.! k)
+           pure $ LA.fromList
+             [ wpVals VU.! (wpId VS.! i) | i <- [0 .. n - 1] ]
+         else if j `elem` stripIxs
+           then case mStripId of
+             Nothing -> pure (LA.konst 0 n)  -- 不可達: guard 済
+             Just stripId -> do
+               stripVals <- VU.replicateM nStrp $ do
+                 k <- MWC.uniformR (0, gl - 1) gen
+                 pure (g VU.! k)
+               pure $ LA.fromList
+                 [ stripVals VU.! (stripId VS.! i) | i <- [0 .. n - 1] ]
+           else do
+             vs <- VU.replicateM n $ do
+               k <- MWC.uniformR (0, gl - 1) gen
+               pure (g VU.! k)
+             pure (LA.fromList (VU.toList vs))
+    ) [0 .. p - 1]
+  pure (LA.fromColumns cols)
+
+-- ---------------------------------------------------------------------------
+-- Coordinate exchange (split-plot 構造保持)
+-- ---------------------------------------------------------------------------
+
+runExchangeSP
+  :: PrimMonad m
+  => [Factor]
+  -> Model
+  -> OptCriterion
+  -> DesignBudget
+  -> Double                      -- ^ η
+  -> [Int]                       -- ^ WP factor indices
+  -> [Int]                       -- ^ strip factor indices (Phase 28-2)
+  -> VS.Vector Int               -- ^ wpId
+  -> Maybe (VS.Vector Int)       -- ^ stripId (Phase 28-2)
+  -> LA.Matrix Double
+  -> m (LA.Matrix Double, Double)
+runExchangeSP factors model crit budget eta wpIxs stripIxs wpId mStripId init0 = do
+  matRef  <- newMutVar init0
+  critRef <- newMutVar (evalCritSP factors model crit eta wpId mStripId init0)
+  let !n         = LA.rows init0
+      !p         = LA.cols init0
+      gridsV     = V.fromList (map (factorGrid budget) factors)
+      nWP        = if VS.null wpId then 0 else 1 + VS.maximum wpId
+      nStrp      = case mStripId of
+        Just s | not (VS.null s) -> 1 + VS.maximum s
+        _ -> 0
+      isWPidx j  = j `elem` wpIxs
+      isStripIdx j = j `elem` stripIxs
+  let loopOuter !it
+        | it > dbMaxIter budget = pure ()
+        | otherwise = do
+            beforeC <- readMutVar critRef
+            -- SP 因子: 通常の per-row × per-column 走査
+            forM_ [0 .. n - 1] $ \i ->
+              forM_ [0 .. p - 1] $ \j ->
+                when (not (isWPidx j) && not (isStripIdx j)) $ do
+                  curMat <- readMutVar matRef
+                  curC   <- readMutVar critRef
+                  let g  = gridsV V.! j
+                      gl = VU.length g
+                  bestRef <- newMutVar (curMat `LA.atIndex` (i, j), curC)
+                  forM_ [0 .. gl - 1] $ \k -> do
+                    let !v = g VU.! k
+                        !cand = setEntry curMat i j v
+                        !c = evalCritSP factors model crit eta wpId mStripId cand
+                    modifyMutVar' bestRef $ \cur@(_, bc) ->
+                      if c < bc then (v, c) else cur
+                  (bv, bc) <- readMutVar bestRef
+                  when (bc < curC) $ do
+                    writeMutVar matRef  (setEntry curMat i j bv)
+                    writeMutVar critRef bc
+            -- WP 因子: 各 WP × 各 WP-column 走査、 WP 内全 row に同値書き込み
+            forM_ [0 .. nWP - 1] $ \w ->
+              forM_ wpIxs $ \j -> do
+                curMat <- readMutVar matRef
+                curC   <- readMutVar critRef
+                let g  = gridsV V.! j
+                    gl = VU.length g
+                    runsInWP = [ i | i <- [0 .. n - 1], wpId VS.! i == w ]
+                    oldV = if null runsInWP then 0
+                             else curMat `LA.atIndex` (head runsInWP, j)
+                bestRef <- newMutVar (oldV, curC)
+                forM_ [0 .. gl - 1] $ \k -> do
+                  let !v = g VU.! k
+                      !cand = setColumnInRows curMat runsInWP j v
+                      !c = evalCritSP factors model crit eta wpId mStripId cand
+                  modifyMutVar' bestRef $ \cur@(_, bc) ->
+                    if c < bc then (v, c) else cur
+                (bv, bc) <- readMutVar bestRef
+                when (bc < curC) $ do
+                  writeMutVar matRef  (setColumnInRows curMat runsInWP j bv)
+                  writeMutVar critRef bc
+            -- Phase 28-2: strip 因子: 各 strip × 各 strip-column 走査、
+            -- strip 内全 row に同値書き込み
+            case mStripId of
+              Just stripId -> forM_ [0 .. nStrp - 1] $ \s ->
+                forM_ stripIxs $ \j -> do
+                  curMat <- readMutVar matRef
+                  curC   <- readMutVar critRef
+                  let g  = gridsV V.! j
+                      gl = VU.length g
+                      runsInStrip = [ i | i <- [0 .. n - 1], stripId VS.! i == s ]
+                      oldV = if null runsInStrip then 0
+                               else curMat `LA.atIndex` (head runsInStrip, j)
+                  bestRef <- newMutVar (oldV, curC)
+                  forM_ [0 .. gl - 1] $ \k -> do
+                    let !v = g VU.! k
+                        !cand = setColumnInRows curMat runsInStrip j v
+                        !c = evalCritSP factors model crit eta wpId mStripId cand
+                    modifyMutVar' bestRef $ \cur@(_, bc) ->
+                      if c < bc then (v, c) else cur
+                  (bv, bc) <- readMutVar bestRef
+                  when (bc < curC) $ do
+                    writeMutVar matRef  (setColumnInRows curMat runsInStrip j bv)
+                    writeMutVar critRef bc
+              Nothing -> pure ()
+            afterC <- readMutVar critRef
+            let rel = if abs beforeC < 1e-12
+                        then beforeC - afterC
+                        else (beforeC - afterC) / abs beforeC
+            when (rel > dbTol budget) (loopOuter (it + 1))
+  loopOuter 1
+  finalM <- readMutVar matRef
+  finalC <- readMutVar critRef
+  pure (finalM, finalC)
+
+-- | REML criterion: critValueM を X' M⁻¹ X 経由で評価。
+-- DOpt の場合 det(X' M⁻¹ X) を最大化 (= criterion 最小化)。
+-- M = I + η · Z Zᵀ。 nWP=n (= completely randomized) なら M=(1+η)I、
+-- η=0 なら M=I (= 通常 D-opt)。
+evalCritSP
+  :: [Factor]
+  -> Model
+  -> OptCriterion
+  -> Double
+  -> VS.Vector Int               -- ^ wpId
+  -> Maybe (VS.Vector Int)       -- ^ stripId (Phase 28-2)
+  -> LA.Matrix Double
+  -> Double
+evalCritSP factors model crit eta wpId mStripId raw =
+  case expandDesignMatrix factors model raw of
+    Left _  -> 1 / 0
+    Right x ->
+      let !n  = LA.rows x
+          mInv = case mStripId of
+            Nothing ->
+              -- 通常 split-plot: M = I + η · Z_WP Z_WPᵀ、 block-diagonal
+              let nWP = if VS.null wpId then 0 else 1 + VS.maximum wpId
+                  wpSizes = [ length [ i | i <- [0 .. n - 1], wpId VS.! i == w ] | w <- [0 .. nWP - 1] ]
+              in buildMInv n eta wpSizes wpId nWP
+            Just stripId ->
+              -- Phase 28-2 strip-plot: M = I + η · (Z_WP Z_WPᵀ + Z_Strip Z_Stripᵀ)
+              -- block-diagonal にならないので数値 inv で対応
+              buildMInvStrip n eta wpId stripId
+          xtmx = LA.tr x LA.<> (mInv LA.<> x)
+      in critValueM crit (chol xtmx)
+      -- 注: critValueM は X (の expand 後) を受け取る前提。 ここで X' M⁻¹ X を
+      -- そのまま渡したいので、 X' M⁻¹ X = (M^{-1/2} X)' (M^{-1/2} X) となる行列
+      -- X̃ = chol((M⁻¹)) X を作って渡す方が自然。 chol が無いので簡略化:
+      -- critValueM の DOpt は det(X'X) = det((M⁻¹) X) ... hm complicated。
+      -- ここでは「critValueM をそのまま使うため X̃ = M⁻¹ X として渡し、
+      -- DOpt の det(X̃'X̃) = det(X' M⁻¹ M⁻¹ X)」 になり厳密に Goos-Vandebroek の
+      -- I_β = X' M⁻¹ X と一致しない。 Phase 25 簡易版として許容、 docs で明記。
+
+-- | Phase 28-2 strip-plot 用 M⁻¹。 M = I + η · (Z_WP Z_WPᵀ + Z_Strip Z_Stripᵀ)
+-- を直接構築し numerical inverse。 strip-plot の covariance は block-diagonal
+-- にならない (WP と strip の交差で indicator が重なる) ため、 split-plot 用の
+-- 解析的 block inverse は使えない。 n は通常 ≤ 100 で inv は十分高速。
+buildMInvStrip :: Int -> Double -> VS.Vector Int -> VS.Vector Int -> LA.Matrix Double
+buildMInvStrip n eta wpId stripId =
+  let mEntry i j =
+        let wpEq    = if wpId VS.! i == wpId VS.! j then eta else 0
+            stripEq = if stripId VS.! i == stripId VS.! j then eta else 0
+            diag    = if i == j then 1 else 0
+        in diag + wpEq + stripEq
+      mMat = (n LA.>< n) [ mEntry i j | i <- [0 .. n - 1], j <- [0 .. n - 1] ]
+      mD   = LA.det mMat
+  in if abs mD < 1e-12
+       then LA.ident n   -- safety fallback
+       else LA.inv mMat
+
+-- | M⁻¹ を構築 (block-diagonal、 各 WP block で計算)。
+buildMInv :: Int -> Double -> [Int] -> VS.Vector Int -> Int -> LA.Matrix Double
+buildMInv n eta wpSizes wpId _nWP =
+  let buildEntry i j
+        | wpId VS.! i /= wpId VS.! j = 0
+        | otherwise =
+            let w   = wpId VS.! i
+                nw  = wpSizes !! w
+                nwD = fromIntegral nw :: Double
+                d   = 1.0
+                offDiag = - eta / (1 + eta * nwD)
+            in if i == j
+                 then d + offDiag   -- diag of inverse: 1 - η/(1+η nw)
+                 else offDiag
+  in (n LA.>< n) [ buildEntry i j | i <- [0 .. n - 1], j <- [0 .. n - 1] ]
+
+-- | 安全な X̃ を返す: X̃ として「(X' M⁻¹ X) の chol 下三角」 を渡せば
+-- det(X̃' X̃) = det(X' M⁻¹ X) になる。
+--
+-- Phase 27-2 fix: 非 PD 時は LA.chol が IO 例外を投げて bench / 検証が
+-- 落ちるため、 mbChol で safe 化。 失敗時は zero matrix を返し、
+-- critValueM DOpt = -det(0 · 0') = 0 を経由して候補が rejection される。
+chol :: LA.Matrix Double -> LA.Matrix Double
+chol m =
+  let !sym = LA.sym m
+  in case LA.mbChol sym of
+       Just u  -> LA.tr u
+       Nothing -> LA.konst 0 (LA.rows m, LA.cols m)
+
+-- ---------------------------------------------------------------------------
+-- matrix utility
+-- ---------------------------------------------------------------------------
+
+setEntry :: LA.Matrix Double -> Int -> Int -> Double -> LA.Matrix Double
+setEntry m i j v = LA.accum m const [((i, j), v)]
+
+setColumnInRows :: LA.Matrix Double -> [Int] -> Int -> Double -> LA.Matrix Double
+setColumnInRows m rows j v = LA.accum m const [((i, j), v) | i <- rows]
diff --git a/src/Hanalyze/Design/Custom/Structured.hs b/src/Hanalyze/Design/Custom/Structured.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Design/Custom/Structured.hs
@@ -0,0 +1,303 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE BangPatterns      #-}
+-- |
+-- Module      : Hanalyze.Design.Custom.Structured
+-- Description : 役割 (fRole) 非依存の構造駆動座標交換エンジン (cells + M⁻¹ による GLS 最適化)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- 構造駆動の座標交換エンジン (Phase 79.2)。
+--
+--   Phase 78.M の split-plot 専用エンジン ('Custom.SplitPlot') を **役割 (fRole) 非依存**に
+--   一般化したもの。 実験のランダム化/階層構造を、
+--
+--     * 各因子列がどの行集合で一定か = **cells** ('gpCells')
+--     * 観測の共分散 M の逆行列 = **M⁻¹** ('gpMInv')
+--
+--   の 2 つに落とした 'GroupingPlan' で受け取り、
+--
+--     * 群単位ムーブ: 因子 j の cell (= 同値であるべき行集合) 内の全行を一度に書き換える
+--     * GLS 基準: @critValueM crit (chol (Xᵀ M⁻¹ X))@ (検証済・Jones-Goos golden 一致)
+--
+--   で解く。 CRD (per-row cell・M=I) は 'Custom.Coordinate' の高速路にそのまま委譲するので、
+--   本エンジンは SplitPlot / StripPlot / Blocked (= 非自明な群/共分散) 専用。
+--
+--   ★基準式の根拠: @xtmx = Xᵀ M⁻¹ X@、 @L = chol xtmx@ (L Lᵀ = xtmx) を 'critValueM' に渡すと
+--   DOpt で @det(Lᵀ L) = det(xtmx) = det(Xᵀ M⁻¹ X)@ = 厳密な REML 情報量 (Goos-Vandebroek 2003)。
+--   M⁻¹ の白色化 X̃ = L⁻¹X でも等価だが、 既存 SplitPlot エンジンで文献値一致を確認済の
+--   この式を踏襲する。
+module Hanalyze.Design.Custom.Structured
+  ( -- * 入力
+    GroupingPlan (..)
+    -- * 共分散
+  , buildMInvFromGroups
+    -- * アルゴリズム
+  , structuredExchangePure
+  ) where
+
+import           Control.Monad             (forM, forM_, when)
+import           Control.Monad.Primitive   (PrimMonad, PrimState)
+import           Control.Monad.ST          (runST)
+import           Data.Maybe                (fromMaybe)
+import           Data.Primitive.MutVar
+import           Data.Text                 (Text)
+import qualified Data.Text                 as T
+import qualified Numeric.LinearAlgebra     as LA
+import qualified System.Random.MWC         as MWC
+import qualified Data.Vector               as V
+import qualified Data.Vector.Unboxed       as VU
+import qualified Data.Vector.Storable      as VS
+
+import           Hanalyze.Design.Custom.Factor  (Factor)
+import           Hanalyze.Design.Custom.Model   (Model, expandDesignMatrix)
+import           Hanalyze.Design.Custom.Constraint (Constraint)
+import           Hanalyze.Design.Custom.Coordinate
+                   ( CustomDesignSpec (..), DesignBudget (..)
+                   , factorGrid, critValueM, rowFeasible
+                   , mkGenSeed, defaultPureSeed )
+import           Hanalyze.Design.Optimal        (OptCriterion (..))
+
+-- ---------------------------------------------------------------------------
+-- 入力型
+-- ---------------------------------------------------------------------------
+
+-- | 'Structure' をエンジン内部表現にコンパイルしたもの (Workflow が構築)。
+data GroupingPlan = GroupingPlan
+  { gpCells :: ![[[Int]]]
+    -- ^ 列 j → その列の値を共有すべき行集合 (cells) の分割。 全 cell の和集合 = @[0..n-1]@。
+    --   CRD 因子 = @[[0],[1],…,[n-1]]@ (per-row)、 whole-plot 因子 = 各 WP の行集合。
+  , gpMInv  :: !(LA.Matrix Double)
+    -- ^ n×n の GLS 重み M⁻¹ (@M = I + Σ η_g Z_g Z_gᵀ@)。 CRD なら I。
+  } deriving (Show)
+
+-- ---------------------------------------------------------------------------
+-- 共分散 M⁻¹
+-- ---------------------------------------------------------------------------
+
+-- | @M = I + Σ_g η_g Z_g Z_gᵀ@ の逆行列を dense に構築 (n ≤ ~100 前提で数値 inv)。
+--   各群 @(η_g, ids_g)@ は「分散比 η_g と各行の群 ID」。 SplitPlot = 1 群、
+--   StripPlot = 2 群 (WP と strip の交差)、 Blocked = 1 群。 block-diagonal に限らないので
+--   一様に dense inv で扱う (解析 block 逆と数値的に一致)。 特異なら安全側で単位行列。
+buildMInvFromGroups :: Int -> [(Double, VS.Vector Int)] -> LA.Matrix Double
+buildMInvFromGroups n groups =
+  let mEntry i j =
+        (if i == j then 1 else 0)
+          + sum [ if ids VS.! i == ids VS.! j then eta else 0 | (eta, ids) <- groups ]
+      mMat = (n LA.>< n) [ mEntry i j | i <- [0 .. n - 1], j <- [0 .. n - 1] ]
+  in if abs (LA.det mMat) < 1e-12 then LA.ident n else LA.inv mMat
+
+-- ---------------------------------------------------------------------------
+-- アルゴリズム (seed 決定的・pure)
+-- ---------------------------------------------------------------------------
+
+-- | 構造駆動の座標交換 (pure・seed 決定的)。 'GroupingPlan' の cells で群単位ムーブ、
+--   M⁻¹ で GLS 基準を評価する。 戻り値 = (raw 設計行列, 最小化方向の基準値)。
+--   'cdsSeed' が 'Nothing' なら 'defaultPureSeed'。 制約は各ムーブ候補で 'rowFeasible'
+--   (影響行ごと) を課す。
+structuredExchangePure
+  :: CustomDesignSpec -> GroupingPlan -> Either Text (LA.Matrix Double, Double)
+structuredExchangePure spec gplan
+  | null (cdsFactors spec)         = Left "structuredExchange: empty factor list"
+  | cdsNRuns spec < 1              = Left "structuredExchange: nRuns must be >= 1"
+  | dbRestarts (cdsBudget spec) < 1 = Left "structuredExchange: dbRestarts must be >= 1"
+  | length (gpCells gplan) /= length (cdsFactors spec) =
+      Left "structuredExchange: gpCells length must equal factor count"
+  | LA.rows (gpMInv gplan) /= cdsNRuns spec =
+      Left "structuredExchange: gpMInv dimension must equal nRuns"
+  | otherwise = runST $ do
+      gen <- mkGenSeed (fromMaybe defaultPureSeed (cdsSeed spec))
+      let !factors = cdsFactors spec
+          !model   = cdsModel spec
+          !crit    = cdsCriterion spec
+          !cons    = cdsConstraints spec
+          !budget  = cdsBudget spec
+          !n       = cdsNRuns spec
+          !mInv    = gpMInv gplan
+          !cells   = gpCells gplan
+          !grids   = map (factorGrid budget) factors
+      bestRef <- newMutVar Nothing
+      forM_ [1 .. dbRestarts budget] $ \_ -> do
+        -- 制約なしは高速な単純抽選 (既存挙動)、 制約ありは実行可能な初期解を棄却サンプリング。
+        mInit <- if null cons
+                   then Just <$> randomInitG grids cells n gen
+                   else randomInitGFeasible factors cons grids cells n gen
+        case mInit of
+          Nothing    -> pure ()   -- この restart は実行可能初期解を引けず (次 restart へ)
+          Just init0 -> do
+            (finalM, finalC) <-
+              runExchangeG factors model crit cons budget mInv grids cells init0
+            modifyMutVar' bestRef $ \mb -> case mb of
+              Nothing -> Just (finalM, finalC)
+              Just (_, c0) | finalC < c0 -> Just (finalM, finalC)
+                           | otherwise   -> mb
+      mb <- readMutVar bestRef
+      pure $ case mb of
+        Nothing     -> Left "structuredExchange: 実行可能な初期解が得られませんでした (制約が厳しすぎる可能性)"
+        Just (m, c) -> Right (m, c)
+
+-- | 初期 raw matrix。 各列は cell ごとに 1 つの grid 値を抽選し、 cell 内全行へ同値で置く。
+randomInitG
+  :: PrimMonad m
+  => [VU.Vector Double] -> [[[Int]]] -> Int -> MWC.Gen (PrimState m)
+  -> m (LA.Matrix Double)
+randomInitG grids cells n gen = do
+  let gridsV = V.fromList grids
+  cols <- mapM
+    (\(j, cellsOfCol) -> do
+        let g  = gridsV V.! j
+            gl = VU.length g
+        -- cell ごとに 1 値、 cell 内全行に配る
+        vals <- mapM (\rows -> do
+                        k <- MWC.uniformR (0, gl - 1) gen
+                        pure (rows, g VU.! k)) cellsOfCol
+        let assign = [ (i, v) | (rows, v) <- vals, i <- rows ]
+        pure (LA.fromList [ lookupRow i assign | i <- [0 .. n - 1] ]))
+    (zip [0 ..] cells)
+  pure (LA.fromColumns cols)
+  where
+    lookupRow i assign = case lookup i assign of
+      Just v  -> v
+      Nothing -> 0   -- cells が [0..n-1] を被覆する前提 (不達)
+
+-- | 制約下で実行可能な初期 raw matrix を棄却サンプリングで構築 (Phase 79.5)。
+--   群構造を保つため 2 段階で引く:
+--
+--     1. **群 (grouped) 列** (cell が n 未満 = whole-plot / strip 因子) を cell ごとに 1 値抽選。
+--        群内の行はこの値を共有する (= 階層構造の保持)。
+--     2. **各行**について、 per-row 列 (sub-plot 因子) を棄却サンプリングし、 群列の固定値と
+--        合わせた行全体が全制約を満たすまで再抽選 (行あたり 200 回上限)。
+--
+--   ある行が群固定値の下でどうしても実行可能にできなければ、 群値ごと引き直す (外側 50 回上限)。
+--   全て失敗すれば 'Nothing' (制約が厳しすぎる)。 群列固定 → per-row 探索の順で、
+--   whole-plot 因子が群内一定かつ制約満足を両立させる。
+randomInitGFeasible
+  :: PrimMonad m
+  => [Factor] -> [Constraint] -> [VU.Vector Double] -> [[[Int]]] -> Int
+  -> MWC.Gen (PrimState m) -> m (Maybe (LA.Matrix Double))
+randomInitGFeasible factors cons grids cells n gen = tryOuter maxOuter
+  where
+    maxOuter = 50 :: Int
+    maxRow   = 200 :: Int
+    gridsV   = V.fromList grids
+    p        = length grids
+    isGrouped j = length (cells !! j) < n   -- cell 数 < n → 群 (共有) 列
+
+    tryOuter 0 = pure Nothing
+    tryOuter t = do
+      -- 1. 群列の値を cell ごとに抽選 → 各群列 j の「行 → 値」 (Just)、 per-row 列は Nothing
+      grouped <- forM [0 .. p - 1] $ \j ->
+        if isGrouped j
+          then do
+            let g  = gridsV V.! j
+                gl = VU.length g
+            cellVals <- forM (cells !! j) $ \rows -> do
+              k <- MWC.uniformR (0, gl - 1) gen
+              pure (rows, g VU.! k)
+            let rowVal = VU.generate n
+                  (\i -> head [ v | (rs, v) <- cellVals, i `elem` rs ])
+            pure (Just rowVal)
+          else pure Nothing
+      -- 2. 各行を棄却サンプリング (群列は固定・per-row 列を引く)
+      mRows <- forM [0 .. n - 1] $ \i -> drawRow grouped i maxRow
+      case sequence mRows of
+        Just rs -> pure (Just (LA.fromRows rs))
+        Nothing -> tryOuter (t - 1)   -- どこかの行が詰んだ → 群値ごと引き直し
+
+    drawRow _       _ 0  = pure Nothing
+    drawRow grouped i tr = do
+      vs <- forM [0 .. p - 1] $ \j ->
+        case grouped !! j of
+          Just rowVal -> pure (rowVal VU.! i)     -- 群列: 固定値
+          Nothing     -> do                        -- per-row 列: 抽選
+            let g  = gridsV V.! j
+                gl = VU.length g
+            k <- MWC.uniformR (0, gl - 1) gen
+            pure (g VU.! k)
+      let row = LA.fromList vs
+      if rowFeasible factors cons row
+        then pure (Just row)
+        else drawRow grouped i (tr - 1)
+
+-- | 1 restart 分の群単位 coordinate exchange。 列ごと・cell ごとに grid を走査し、
+--   制約 (影響行) を満たす範囲で基準最小の値を cell 内全行へ書き込む。
+runExchangeG
+  :: PrimMonad m
+  => [Factor] -> Model -> OptCriterion -> [Constraint] -> DesignBudget
+  -> LA.Matrix Double            -- ^ M⁻¹
+  -> [VU.Vector Double]          -- ^ 因子ごとの grid
+  -> [[[Int]]]                   -- ^ 列ごとの cells
+  -> LA.Matrix Double            -- ^ 初期 raw
+  -> m (LA.Matrix Double, Double)
+runExchangeG factors model crit cons budget mInv grids cells init0 = do
+  matRef  <- newMutVar init0
+  critRef <- newMutVar (evalCritG factors model crit mInv init0)
+  let gridsV = V.fromList grids
+      cellsV = V.fromList cells
+      !p     = length grids
+  let loopOuter !it
+        | it > dbMaxIter budget = pure ()
+        | otherwise = do
+            beforeC <- readMutVar critRef
+            forM_ [0 .. p - 1] $ \j ->
+              forM_ (cellsV V.! j) $ \rows -> do
+                curMat <- readMutVar matRef
+                curC   <- readMutVar critRef
+                let g    = gridsV V.! j
+                    gl   = VU.length g
+                    oldV = if null rows then 0
+                             else curMat `LA.atIndex` (head rows, j)
+                bestRef <- newMutVar (oldV, curC)
+                forM_ [0 .. gl - 1] $ \k -> do
+                  let !v = g VU.! k
+                  when (cellFeasible factors cons curMat rows j v) $ do
+                    let !cand = setColumnInRows curMat rows j v
+                        !c    = evalCritG factors model crit mInv cand
+                    modifyMutVar' bestRef $ \cur@(_, bc) ->
+                      if c < bc then (v, c) else cur
+                (bv, bc) <- readMutVar bestRef
+                when (bc < curC) $ do
+                  writeMutVar matRef  (setColumnInRows curMat rows j bv)
+                  writeMutVar critRef bc
+            afterC <- readMutVar critRef
+            let rel = if abs beforeC < 1e-12
+                        then beforeC - afterC
+                        else (beforeC - afterC) / abs beforeC
+            when (rel > dbTol budget) (loopOuter (it + 1))
+  loopOuter 1
+  finalM <- readMutVar matRef
+  finalC <- readMutVar critRef
+  pure (finalM, finalC)
+
+-- | cell 内全行を列 j = v にしたとき、 影響する全行が制約を満たすか。
+--   cell 内の各行は他列の値が異なり得るので行ごとに判定する。
+cellFeasible :: [Factor] -> [Constraint] -> LA.Matrix Double -> [Int] -> Int -> Double -> Bool
+cellFeasible _ [] _ _ _ _ = True
+cellFeasible factors cons mat rows j v =
+  all (\i -> rowFeasible factors cons (replaceVecAt (rowVec i) j v)) rows
+  where rowVec i = LA.flatten (LA.subMatrix (i, 0) (1, LA.cols mat) mat)
+
+-- | raw matrix → design matrix → GLS 基準値 (最小化方向)。 expand 失敗は +∞。
+evalCritG :: [Factor] -> Model -> OptCriterion -> LA.Matrix Double -> LA.Matrix Double -> Double
+evalCritG factors model crit mInv raw =
+  case expandDesignMatrix factors model raw of
+    Left _  -> 1 / 0
+    Right x ->
+      let !xtmx = LA.tr x LA.<> (mInv LA.<> x)   -- Xᵀ M⁻¹ X
+      in critValueM crit (chol xtmx)
+
+-- | @chol m@ = L (下三角、 L Lᵀ = sym m)。 非 PD 時は 0 行列 (基準が候補を棄却)。
+chol :: LA.Matrix Double -> LA.Matrix Double
+chol m = case LA.mbChol (LA.sym m) of
+  Just u  -> LA.tr u
+  Nothing -> LA.konst 0 (LA.rows m, LA.cols m)
+
+-- ---------------------------------------------------------------------------
+-- matrix / vector utility
+-- ---------------------------------------------------------------------------
+
+setColumnInRows :: LA.Matrix Double -> [Int] -> Int -> Double -> LA.Matrix Double
+setColumnInRows m rows j v = LA.accum m const [ ((i, j), v) | i <- rows ]
+
+replaceVecAt :: LA.Vector Double -> Int -> Double -> LA.Vector Double
+replaceVecAt v j x =
+  LA.fromList [ if k == j then x else v `LA.atIndex` k | k <- [0 .. LA.size v - 1] ]
diff --git a/src/Hanalyze/Design/DSD.hs b/src/Hanalyze/Design/DSD.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Design/DSD.hs
@@ -0,0 +1,124 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE BangPatterns #-}
+-- |
+-- Module      : Hanalyze.Design.DSD
+-- Description : Definitive Screening Design (Jones-Nachtsheim 2011) の 2k+1 run 生成
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Definitive Screening Design (Jones-Nachtsheim 2011)。
+--
+-- k 連続因子について **2k + 1 runs** で主効果 + 二次効果 + 一部の 2 因子
+-- 交互作用を識別できる効率的スクリーニング計画。
+--
+-- 構成:
+--
+--   * 1 行目: 中心点 @[0, 0, ..., 0]@
+--   * 2..k+1 行目: 各 row i は position i に 0 を持ち、 他は ±1
+--   * k+2..2k+1 行目: 上記の foldover (= 各行の符号反転)
+--
+-- 本初版は k = 4 を **Jones-Nachtsheim Table 1 の conference matrix** で
+-- 構築 (verified DSD)。 他の k は Hadamard-like 構造で近似 (= 構造的 DSD)。
+-- 厳密な conference-matrix DSD の追加は将来 Phase。
+module Hanalyze.Design.DSD
+  ( DSDResult (..)
+  , dsdDesign
+  ) where
+
+import qualified Data.Bits             as B
+import qualified Numeric.LinearAlgebra as LA
+import           Data.Text             (Text)
+import qualified Data.Text             as T
+
+-- ===========================================================================
+-- 型
+-- ===========================================================================
+
+-- | DSD の結果。
+data DSDResult = DSDResult
+  { dsdMatrix     :: !(LA.Matrix Double)
+    -- ^ @(2k + 1) × k@ 行列。 各要素は @{-1, 0, +1}@。
+  , dsdNFactors   :: !Int       -- ^ 因子数 k
+  , dsdNRuns      :: !Int       -- ^ 実験数 2k + 1
+  , dsdHasOptimal :: !Bool
+    -- ^ @True@ = Jones-Nachtsheim Table の conference matrix 由来 (verified DSD)、
+    --   @False@ = Hadamard-like 構造で近似 (structural DSD)
+  } deriving (Show)
+
+-- ===========================================================================
+-- 公開関数
+-- ===========================================================================
+
+-- | DSD を生成。
+--
+-- k = 4 のみ verified (Jones-Nachtsheim 2011 Table 1)。
+-- k ≥ 2 の他値は Hadamard-like 構造の structural DSD (`dsdHasOptimal = False`)。
+-- k < 2 は @Left@。
+dsdDesign :: Int -> Either Text DSDResult
+dsdDesign k
+  | k < 2 = Left (T.pack ("dsdDesign: need k >= 2, got k=" <> show k))
+  | k == 4 = Right (verifiedDSD k confC4)
+  | otherwise = Right (structuralDSD k)
+
+-- ===========================================================================
+-- 内部: verified DSD (conference matrix 由来)
+-- ===========================================================================
+
+-- | C_4: 4 次の conference matrix。 Jones-Nachtsheim 2011 Table 1 第 1 行。
+--   不変条件: 対角 0、 非対角 ±1、 @C · Cᵀ = (n-1) I@。
+confC4 :: [[Double]]
+confC4 =
+  [ [ 0,  1,  1,  1]
+  , [ 1,  0,  1, -1]
+  , [ 1, -1,  0,  1]
+  , [ 1,  1, -1,  0]
+  ]
+
+-- | 与えた conference matrix から DSD を構築:
+--   row 0 = center、 rows 1..k = C 各行、 rows k+1..2k = -C 各行。
+verifiedDSD :: Int -> [[Double]] -> DSDResult
+verifiedDSD k cMat =
+  let center  = replicate k 0
+      posRows = cMat
+      negRows = map (map negate) cMat
+      allRows = center : posRows ++ negRows
+      mat     = LA.fromLists allRows
+  in DSDResult
+       { dsdMatrix     = mat
+       , dsdNFactors   = k
+       , dsdNRuns      = 2 * k + 1
+       , dsdHasOptimal = True
+       }
+
+-- ===========================================================================
+-- 内部: structural DSD (Hadamard-like、 conference matrix 無しの近似)
+-- ===========================================================================
+
+-- | k != 4 の場合の近似 DSD。 構造 (2k+1 runs、 各 row に 1 個の 0) は
+-- 満たすが、 conference matrix 性質 (`C · Cᵀ = (n-1) I`) は保証しない。
+--
+-- ±1 パターンは Sylvester-Hadamard 風: row i の position j (j != i) について
+-- @sign = (-1)^popCount(i .&. j)@。
+structuralDSD :: Int -> DSDResult
+structuralDSD k =
+  let posRows = [ [ if j + 1 == i then 0  -- position i (1-origin in row) gets 0
+                    else hadamardSign i (j + 1)
+                  | j <- [0 .. k - 1]
+                  ]
+                | i <- [1 .. k]
+                ]
+      negRows = map (map negate) posRows
+      center  = replicate k 0
+      mat     = LA.fromLists (center : posRows ++ negRows)
+  in DSDResult
+       { dsdMatrix     = mat
+       , dsdNFactors   = k
+       , dsdNRuns      = 2 * k + 1
+       , dsdHasOptimal = False
+       }
+
+-- | Sylvester-Hadamard 符号: @(-1)^popCount(i AND j)@。
+hadamardSign :: Int -> Int -> Double
+hadamardSign i j
+  | even (B.popCount (i B..&. j)) =  1
+  | otherwise                     = -1
diff --git a/src/Hanalyze/Design/Diagnostics.hs b/src/Hanalyze/Design/Diagnostics.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Design/Diagnostics.hs
@@ -0,0 +1,155 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE BangPatterns #-}
+-- |
+-- Module      : Hanalyze.Design.Diagnostics
+-- Description : DoE 設計診断 (Alias Matrix / VIF / D-A-G-I efficiency の一括算出)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- DoE 設計診断: Alias Matrix / VIF / D-A-G-I efficiency。
+--
+-- 設計行列 @X@ (n × p) に対して、 multicollinearity と最適性指標を一括算出。
+--
+-- ===  efficiency 指標
+--
+--   * D-efficiency = (|XᵀX| / n^p)^{1/p}
+--   * A-efficiency = p / trace((XᵀX/n)⁻¹)
+--   * G-efficiency = p / max_i (n · x_iᵀ (XᵀX)⁻¹ x_i)
+--   * I-efficiency = 1 / (n · trace((XᵀX)⁻¹ · M))、
+--     M = 1/n · XᵀX (= self-moment 近似版)
+--
+-- ===  VIF
+--
+-- 各列 j について、 「j 以外の列で j を回帰」 した R² を用いて
+-- @VIF_j = 1 / (1 − R²_j)@。 切片を含む X を想定し、 切片列 (= 全 1) は
+-- スキップ。
+--
+-- ===  Alias Matrix
+--
+-- @A = (XᵀX)⁻¹ Xᵀ Z@、 ここで Z は 「設計に入れていない交絡項」 のモデル
+-- 行列。 ここでは Z を Optional 引数として取り、 未指定の場合は
+-- アライアス対象が無いとして空行列を返す。
+module Hanalyze.Design.Diagnostics
+  ( DesignDiagnostics (..)
+  , diagnostics
+  , diagnosticsWithAlias
+  , vifVector
+  , aliasMatrix
+  ) where
+
+import qualified Numeric.LinearAlgebra as LA
+
+-- ===========================================================================
+-- 型
+-- ===========================================================================
+
+data DesignDiagnostics = DesignDiagnostics
+  { ddVIF         :: !(LA.Vector Double)
+  , ddDEff        :: !Double
+  , ddAEff        :: !Double
+  , ddGEff        :: !Double
+  , ddIEff        :: !Double
+  , ddAliasMatrix :: !(LA.Matrix Double)
+  } deriving (Show)
+
+-- ===========================================================================
+-- 公開 API
+-- ===========================================================================
+
+-- | Alias を含めない簡易版 (Z = 空)。
+diagnostics :: LA.Matrix Double -> DesignDiagnostics
+diagnostics x =
+  let dd = computeDiagnostics x
+  in dd { ddAliasMatrix = LA.fromLists [[]] }
+
+-- | Z (交絡対象モデル行列) 込みの完全版。 Z の行数は X と一致する必要がある。
+diagnosticsWithAlias :: LA.Matrix Double -> LA.Matrix Double -> DesignDiagnostics
+diagnosticsWithAlias x z =
+  let dd = computeDiagnostics x
+      a  = aliasMatrix x z
+  in dd { ddAliasMatrix = a }
+
+-- | VIF を各列について返す (全 1 列は VIF = 1)。
+vifVector :: LA.Matrix Double -> LA.Vector Double
+vifVector x =
+  let p = LA.cols x
+  in LA.fromList [ vifForCol x j | j <- [0 .. p - 1] ]
+
+-- | Alias matrix A = (XᵀX)⁻¹ Xᵀ Z。
+aliasMatrix :: LA.Matrix Double -> LA.Matrix Double -> LA.Matrix Double
+aliasMatrix x z =
+  let xtx = LA.tr x LA.<> x
+      d   = LA.det xtx
+  in if abs d < 1e-12
+       then LA.fromLists [[]]
+       else LA.inv xtx LA.<> LA.tr x LA.<> z
+
+-- ===========================================================================
+-- 内部
+-- ===========================================================================
+
+computeDiagnostics :: LA.Matrix Double -> DesignDiagnostics
+computeDiagnostics x =
+  let n   = LA.rows x
+      p   = LA.cols x
+      nD  = fromIntegral n :: Double
+      pD  = fromIntegral p :: Double
+      xtx = LA.tr x LA.<> x
+      d   = LA.det xtx
+      singular = abs d < 1e-12
+      inv = if singular then LA.ident p else LA.inv xtx
+      -- D-efficiency: (|XᵀX| / n^p)^{1/p}  (clamped to ≥ 0)
+      dEff = if singular || d <= 0 then 0
+                else (d / (nD ** pD)) ** (1 / pD)
+      -- A-efficiency: p / trace((XᵀX/n)⁻¹) = p · n / trace((XᵀX)⁻¹)... wait
+      -- (XᵀX / n)⁻¹ = n · (XᵀX)⁻¹  なので trace((XᵀX/n)⁻¹) = n · trace((XᵀX)⁻¹)
+      -- → A-eff = p / (n · trace((XᵀX)⁻¹))
+      trInv = sum [ inv `LA.atIndex` (i, i) | i <- [0 .. p - 1] ]
+      aEff  = if singular || trInv == 0 then 0
+                else pD / (nD * trInv)
+      -- G-efficiency: p / max_i (n · h_ii)、 h_ii = x_iᵀ (XᵀX)⁻¹ x_i
+      hMax = if singular then 1
+               else maximum
+                      [ let xi = LA.flatten (x LA.? [i])
+                            v  = inv LA.#> xi
+                        in xi `LA.dot` v
+                      | i <- [0 .. n - 1] ]
+      gEff = if hMax == 0 then 0 else pD / (nD * hMax)
+      -- I-efficiency 近似 (self-moment)
+      iEff = if singular then 0
+               else
+                 let m = LA.scale (1 / nD) xtx
+                     t = LA.sumElements (LA.takeDiag (inv LA.<> m))
+                 in if t == 0 then 0 else 1 / (nD * t)
+  in DesignDiagnostics
+       { ddVIF         = vifVector x
+       , ddDEff        = dEff
+       , ddAEff        = aEff
+       , ddGEff        = gEff
+       , ddIEff        = iEff
+       , ddAliasMatrix = LA.fromLists [[]]
+       }
+
+-- | 列 j の VIF。 全 1 列 (切片) は 1 を返す。
+vifForCol :: LA.Matrix Double -> Int -> Double
+vifForCol x j =
+  let col = LA.flatten (x LA.¿ [j])
+      isConst = let c0 = LA.atIndex col 0
+                in LA.maxElement (LA.cmap (\v -> abs (v - c0)) col) < 1e-12
+  in if isConst then 1
+       else
+         let p       = LA.cols x
+             others  = [ k | k <- [0 .. p - 1], k /= j ]
+             xOthers = x LA.¿ others
+             yj      = col
+             xtx     = LA.tr xOthers LA.<> xOthers
+             d       = LA.det xtx
+         in if abs d < 1e-12 then 1 / 0
+              else
+                let beta = LA.inv xtx LA.#> (LA.tr xOthers LA.#> yj)
+                    yhat = xOthers LA.#> beta
+                    yBar = LA.sumElements yj / fromIntegral (LA.size yj)
+                    ssR  = LA.sumElements ((yj - yhat) ^ (2 :: Int))
+                    ssT  = LA.sumElements ((yj - LA.scalar yBar) ^ (2 :: Int))
+                    r2   = if ssT == 0 then 0 else 1 - ssR / ssT
+                in if r2 >= 1 then 1 / 0 else 1 / (1 - r2)
diff --git a/src/Hanalyze/Design/Factorial.hs b/src/Hanalyze/Design/Factorial.hs
--- a/src/Hanalyze/Design/Factorial.hs
+++ b/src/Hanalyze/Design/Factorial.hs
@@ -1,5 +1,11 @@
 {-# LANGUAGE OverloadedStrings #-}
--- | Factorial designs.
+-- |
+-- Module      : Hanalyze.Design.Factorial
+-- Description : 要因計画 (完全/2 水準/3 水準/一部実施/混合水準) の生成
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Factorial designs.
 --
 --   * 'fullFactorial'        — full factorial with @k@ factors each at
 --     @levels[i]@ levels.
diff --git a/src/Hanalyze/Design/GaugeRR.hs b/src/Hanalyze/Design/GaugeRR.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Design/GaugeRR.hs
@@ -0,0 +1,209 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE BangPatterns #-}
+-- |
+-- Module      : Hanalyze.Design.GaugeRR
+-- Description : Gauge R&R — 測定システム分析 (MSA) の分散分解 (crossed / nested ANOVA 法)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Gauge R&R — Measurement System Analysis の分散分解。
+--
+-- 製造工程で「測定値のばらつき」 を
+--
+--   * 部品本来のばらつき (σ²_part)
+--   * 操作者 / 装置間のばらつき (σ²_reproducibility)
+--   * 測定の再現性 (σ²_repeatability)
+--
+-- に分解する。 AIAG MSA Manual 4th ed. に準拠した ANOVA 法。
+--
+-- Crossed (全操作者 が 全部品 を測定) と Nested (操作者が部品ごとに異なる) を
+-- 別 API で提供。 hmatrix Vector 演算で完結。
+module Hanalyze.Design.GaugeRR
+  ( GaugeRRResult (..)
+  , gaugeRRCrossed
+  , gaugeRRNested
+  ) where
+
+import qualified Data.Vector           as V
+import qualified Data.Map.Strict       as Map
+import           Data.List             (nub, sort)
+import           Data.Text             (Text)
+import qualified Data.Text             as T
+
+-- ===========================================================================
+-- 型
+-- ===========================================================================
+
+-- | Gauge R&R の分散分解結果。
+data GaugeRRResult = GaugeRRResult
+  { grrPartVar       :: !Double  -- ^ σ²_part (部品間)
+  , grrReproducVar   :: !Double  -- ^ σ²_reproducibility (操作者間)
+  , grrRepeatVar     :: !Double  -- ^ σ²_repeatability (繰り返し)
+  , grrTotalVar      :: !Double  -- ^ σ²_total = part + reproducibility + repeatability
+  , grrPctRepeat     :: !Double  -- ^ % of total = repeat / total × 100
+  , grrPctReproduc   :: !Double
+  , grrPctGRR        :: !Double  -- ^ % (repeat + reproducibility) / total × 100
+  , grrPctPart       :: !Double
+  , grrNumDistinct   :: !Double
+    -- ^ ndc = 1.41 · (σ_part / σ_GRR)。 ≥ 5 が望ましい (AIAG)
+  } deriving (Show)
+
+-- ===========================================================================
+-- 公開関数
+-- ===========================================================================
+
+-- | Crossed Gauge R&R: 全操作者が全部品を測定 (operator × part 直交)。
+--
+-- ANOVA 分散分解:
+--
+-- > SS_part         = n_op · n_rep · Σ (μ̂_part_i − μ̂_grand)²
+-- > SS_operator     = n_part · n_rep · Σ (μ̂_op_j − μ̂_grand)²
+-- > SS_interaction  = n_rep · Σ Σ (μ̂_ij − μ̂_part_i − μ̂_op_j + μ̂_grand)²
+-- > SS_error        = Σ (y_ijk − μ̂_ij)²
+--
+-- σ² 推定 (期待値式から):
+--
+-- > σ²_repeatability   = MS_error
+-- > σ²_interaction     = max(0, (MS_int - MS_error) / n_rep)
+-- > σ²_reproducibility = max(0, (MS_op - MS_int) / (n_part · n_rep)) + σ²_interaction
+-- > σ²_part            = max(0, (MS_part - MS_int) / (n_op · n_rep))
+gaugeRRCrossed
+  :: V.Vector Int       -- ^ 操作者 ID (length n)
+  -> V.Vector Int       -- ^ 部品 ID (length n)
+  -> V.Vector Double    -- ^ 測定値 (length n)
+  -> Either Text GaugeRRResult
+gaugeRRCrossed ops parts ys
+  | V.length ops /= V.length parts || V.length ops /= V.length ys =
+      Left "gaugeRRCrossed: input vectors differ in length"
+  | V.null ys =
+      Left "gaugeRRCrossed: empty input"
+  | V.length opIds < 2 || V.length partIds < 2 =
+      Left "gaugeRRCrossed: need ≥ 2 operators and ≥ 2 parts"
+  | otherwise =
+      let !n     = V.length ys
+          !nOp   = V.length opIds
+          !nPart = V.length partIds
+          -- replicate per cell (assume balanced design)
+          !nRep  = n `div` (nOp * nPart)
+      in if nRep < 2
+           then Left (T.pack ("gaugeRRCrossed: need ≥ 2 replicates per cell (got "
+                              <> show nRep <> ")"))
+           else Right (decomposeCrossed nOp nPart nRep ys ops parts opIds partIds)
+  where
+    opIds   = V.fromList (sort (nub (V.toList ops)))
+    partIds = V.fromList (sort (nub (V.toList parts)))
+
+-- | Nested Gauge R&R: 操作者が部品ごとに異なる (operator within part)。
+--
+-- 簡略版: operator effect を completely random として扱い、
+-- repeatability + (operator/part)-nested の 2 段分解。
+gaugeRRNested
+  :: V.Vector Int
+  -> V.Vector Int
+  -> V.Vector Double
+  -> Either Text GaugeRRResult
+gaugeRRNested ops parts ys
+  | V.length ops /= V.length parts || V.length ops /= V.length ys =
+      Left "gaugeRRNested: input vectors differ in length"
+  | V.null ys = Left "gaugeRRNested: empty input"
+  | otherwise =
+      -- nested: 部品ごとに operator が異なるので、 reproducibility = SS(operator within part)
+      Right (decomposeNested ys ops parts)
+
+-- ===========================================================================
+-- Crossed 分解
+-- ===========================================================================
+
+decomposeCrossed
+  :: Int -> Int -> Int
+  -> V.Vector Double -> V.Vector Int -> V.Vector Int
+  -> V.Vector Int -> V.Vector Int
+  -> GaugeRRResult
+decomposeCrossed nOp nPart nRep ys ops parts _opIds _partIds =
+  let !n     = V.length ys
+      nD     = fromIntegral n     :: Double
+      nOpD   = fromIntegral nOp   :: Double
+      nPartD = fromIntegral nPart :: Double
+      nRepD  = fromIntegral nRep  :: Double
+      !grand = V.sum ys / nD
+      -- (part, op) → list of y's
+      cellMap :: Map.Map (Int, Int) [Double]
+      cellMap = foldr
+        (\(k, y) m ->
+            Map.insertWith (++) (parts V.! k, ops V.! k) [y] m)
+        Map.empty
+        (zip [0 .. n - 1] (V.toList ys))
+      cellMean (pi_, oi) =
+        let cellY = Map.findWithDefault [] (pi_, oi) cellMap
+        in if null cellY then 0 else sum cellY / fromIntegral (length cellY)
+      partMeans = Map.fromListWith (+)
+        [ (parts V.! k, ys V.! k / (nOpD * nRepD)) | k <- [0 .. n - 1] ]
+      opMeans   = Map.fromListWith (+)
+        [ (ops V.! k, ys V.! k / (nPartD * nRepD)) | k <- [0 .. n - 1] ]
+      pmean p_  = Map.findWithDefault 0 p_ partMeans
+      omean o_  = Map.findWithDefault 0 o_ opMeans
+      uniqParts = Map.keys partMeans
+      uniqOps   = Map.keys opMeans
+      ssPart = nOpD * nRepD * sum [ (pmean p_ - grand) ** 2 | p_ <- uniqParts ]
+      ssOp   = nPartD * nRepD * sum [ (omean o_ - grand) ** 2 | o_ <- uniqOps ]
+      ssInt  = nRepD * sum
+        [ (cellMean (p_, o_) - pmean p_ - omean o_ + grand) ** 2
+        | p_ <- uniqParts, o_ <- uniqOps ]
+      ssError = sum
+        [ (ys V.! k - cellMean (parts V.! k, ops V.! k)) ** 2
+        | k <- [0 .. n - 1] ]
+      dfPart  = nPartD - 1
+      dfOp    = nOpD - 1
+      dfInt   = (nPartD - 1) * (nOpD - 1)
+      dfError = nD - nOpD * nPartD
+      msPart  = if dfPart  > 0 then ssPart / dfPart   else 0
+      msOp    = if dfOp    > 0 then ssOp / dfOp       else 0
+      msInt   = if dfInt   > 0 then ssInt / dfInt     else 0
+      msError = if dfError > 0 then ssError / dfError else 0
+      sigRepeat       = msError
+      sigInt          = max 0 ((msInt - msError) / nRepD)
+      sigReproducOnly = max 0 ((msOp - msInt) / (nPartD * nRepD))
+      sigReproduc     = sigReproducOnly + sigInt
+      sigPart         = max 0 ((msPart - msInt) / (nOpD * nRepD))
+      sigTotal        = sigRepeat + sigReproduc + sigPart
+      pct s           = if sigTotal > 0 then s / sigTotal * 100 else 0
+      sigGRR          = sigRepeat + sigReproduc
+      ndc             = if sigGRR > 0
+                          then 1.41 * sqrt (sigPart / sigGRR)
+                          else 0
+  in GaugeRRResult
+       { grrPartVar     = sigPart
+       , grrReproducVar = sigReproduc
+       , grrRepeatVar   = sigRepeat
+       , grrTotalVar    = sigTotal
+       , grrPctRepeat   = pct sigRepeat
+       , grrPctReproduc = pct sigReproduc
+       , grrPctGRR      = pct sigGRR
+       , grrPctPart     = pct sigPart
+       , grrNumDistinct = ndc
+       }
+
+-- ===========================================================================
+-- Nested 分解 (簡略版)
+-- ===========================================================================
+
+decomposeNested :: V.Vector Double -> V.Vector Int -> V.Vector Int -> GaugeRRResult
+decomposeNested ys _ops _parts =
+  -- 簡略: total variance を repeatability + part に分けるのみ
+  -- (operator-within-part は part に含めて扱う)
+  let n = V.length ys
+      grand = V.sum ys / fromIntegral n
+      ssTotal = V.sum (V.map (\y -> (y - grand) ** 2) ys)
+      sigTotal = ssTotal / fromIntegral (max 1 (n - 1))
+  in GaugeRRResult
+       { grrPartVar     = sigTotal * 0.5  -- 暫定
+       , grrReproducVar = sigTotal * 0.1
+       , grrRepeatVar   = sigTotal * 0.4
+       , grrTotalVar    = sigTotal
+       , grrPctRepeat   = 40
+       , grrPctReproduc = 10
+       , grrPctGRR      = 50
+       , grrPctPart     = 50
+       , grrNumDistinct = 1.41
+       }
+-- 注: nested は将来 Phase で本実装。 現状は API のみ。
diff --git a/src/Hanalyze/Design/Mixed.hs b/src/Hanalyze/Design/Mixed.hs
--- a/src/Hanalyze/Design/Mixed.hs
+++ b/src/Hanalyze/Design/Mixed.hs
@@ -1,5 +1,11 @@
 {-# LANGUAGE OverloadedStrings #-}
--- | Mixed-level designs.
+-- |
+-- Module      : Hanalyze.Design.Mixed
+-- Description : 因子ごとに異なる水準数を持つ混合水準計画の生成
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Mixed-level designs.
 --
 -- Designs in which factors have different numbers of levels. An extension
 -- of @Hanalyze.Design.Factorial.mixedFactorial@ that accepts an explicit list of
diff --git a/src/Hanalyze/Design/Mixture.hs b/src/Hanalyze/Design/Mixture.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Design/Mixture.hs
@@ -0,0 +1,138 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE BangPatterns #-}
+-- |
+-- Module      : Hanalyze.Design.Mixture
+-- Description : 配合計画 (Mixture Design) — 成分比合計 = 1 制約下の Simplex Lattice / Centroid
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- 配合計画 (Mixture Design) — 成分比の合計が常に 1 となる制約下の DoE。
+--
+-- 材料 / 化学プロセス向け。 各実験点は @[x_1, ..., x_m]@ で
+-- @x_i ≥ 0@、 @Σ x_i = 1@ を満たす。
+--
+-- 提供方式:
+--
+--   * 'SimplexLattice' @d@ — 各成分が @{0, 1/d, ..., d/d}@ から値を取り、 合計が
+--     1 になる全組合せ。 点数 = @C(m+d−1, d)@
+--   * 'SimplexCentroid' — 1 ≤ k ≤ m について、 任意 k 成分を均等に @1/k@、 他は 0。
+--     点数 = @2^m − 1@
+--
+-- 制約付き Extreme Vertices design は将来 Phase で追加予定。
+module Hanalyze.Design.Mixture
+  ( MixtureDesignType (..)
+  , MixtureResult (..)
+  , mixtureDesign
+  ) where
+
+import qualified Numeric.LinearAlgebra as LA
+import           Data.Text             (Text)
+import qualified Data.Text             as T
+
+-- ===========================================================================
+-- 型
+-- ===========================================================================
+
+-- | Mixture design の種別。
+data MixtureDesignType
+  = SimplexLattice !Int  -- ^ 次数 d。 各成分は @{0, 1/d, ..., 1}@ のいずれかの値
+  | SimplexCentroid      -- ^ 2^m - 1 点 (頂点 + 辺中点 + ... + 全体重心)
+  deriving (Show, Eq)
+
+-- | Mixture design の結果。
+data MixtureResult = MixtureResult
+  { mdMatrix      :: !(LA.Matrix Double)
+    -- ^ @nRuns × m@ 行列。 各行の合計 = 1、 各要素 ∈ @[0, 1]@
+  , mdNComponents :: !Int               -- ^ m (成分数)
+  , mdNRuns       :: !Int               -- ^ 実験数
+  , mdType        :: !MixtureDesignType -- ^ 入力の種別を保持
+  } deriving (Show)
+
+-- ===========================================================================
+-- 公開関数
+-- ===========================================================================
+
+-- | Mixture design を生成。
+--
+-- 失敗条件:
+--
+--   * 成分数 m < 2 → 'Left'
+--   * SimplexLattice の次数 d < 1 → 'Left'
+mixtureDesign :: MixtureDesignType -> Int -> Either Text MixtureResult
+mixtureDesign typ m
+  | m < 2 = Left (T.pack ("mixtureDesign: need m >= 2 components, got m=" <> show m))
+  | otherwise = case typ of
+      SimplexLattice d
+        | d < 1 -> Left (T.pack ("mixtureDesign SimplexLattice: need d >= 1, got d=" <> show d))
+        | otherwise ->
+            let pts = simplexLatticePoints m d
+                mat = LA.fromLists pts
+            in Right MixtureResult
+                 { mdMatrix      = mat
+                 , mdNComponents = m
+                 , mdNRuns       = length pts
+                 , mdType        = typ
+                 }
+      SimplexCentroid ->
+        let pts = simplexCentroidPoints m
+            mat = LA.fromLists pts
+        in Right MixtureResult
+             { mdMatrix      = mat
+             , mdNComponents = m
+             , mdNRuns       = length pts
+             , mdType        = typ
+             }
+
+-- ===========================================================================
+-- 内部: Simplex Lattice
+-- ===========================================================================
+
+-- | Simplex Lattice {m, d} の全点。
+--
+-- 非負整数 m-tuple (n_1, ..., n_m) で sum = d を満たす全組合せを列挙し、
+-- 各点を (n_1/d, ..., n_m/d) に正規化。
+simplexLatticePoints :: Int -> Int -> [[Double]]
+simplexLatticePoints m d =
+  let intTuples = compositions m d
+      scale = 1 / fromIntegral d :: Double
+  in [ map ((scale *) . fromIntegral) t | t <- intTuples ]
+
+-- | 非負整数 m-tuple (n_1, ..., n_m) で sum = total を満たすもの全列挙。
+compositions :: Int -> Int -> [[Int]]
+compositions 0 0     = [[]]
+compositions 0 _     = []
+compositions m total =
+  [ k : rest
+  | k <- [0 .. total]
+  , rest <- compositions (m - 1) (total - k)
+  ]
+
+-- ===========================================================================
+-- 内部: Simplex Centroid
+-- ===========================================================================
+
+-- | Simplex Centroid (m components) の全点。
+--
+-- 1 ≤ k ≤ m について、 m 成分から任意の k 個を選び、 その k 成分だけ @1/k@、
+-- 他は 0 とする点を作る。 合計 @2^m - 1@ 点。
+simplexCentroidPoints :: Int -> [[Double]]
+simplexCentroidPoints m =
+  [ centroidPointFromSubset m subset
+  | k <- [1 .. m]
+  , subset <- choose [0 .. m - 1] k
+  ]
+
+-- | サブセット (= component の index list、 size = k) から centroid 点を構築。
+--   各位置 i が subset に含まれていれば @1/k@、 含まれていなければ 0。
+centroidPointFromSubset :: Int -> [Int] -> [Double]
+centroidPointFromSubset m subset =
+  let k    = length subset
+      val  = 1 / fromIntegral k :: Double
+  in [ if i `elem` subset then val else 0 | i <- [0 .. m - 1] ]
+
+-- | 長さ k の組合せを全列挙。 順序は lexicographic。
+choose :: [a] -> Int -> [[a]]
+choose _      0 = [[]]
+choose []     _ = []
+choose (x:xs) k =
+  map (x :) (choose xs (k - 1)) ++ choose xs k
diff --git a/src/Hanalyze/Design/MultiRSM.hs b/src/Hanalyze/Design/MultiRSM.hs
--- a/src/Hanalyze/Design/MultiRSM.hs
+++ b/src/Hanalyze/Design/MultiRSM.hs
@@ -1,5 +1,11 @@
 {-# LANGUAGE OverloadedStrings #-}
--- | Multi-response Response Surface Methodology.
+-- |
+-- Module      : Hanalyze.Design.MultiRSM
+-- Description : 複数応答同時の Response Surface Methodology (各応答の二次モデル並列 fit + 極値解析)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Multi-response Response Surface Methodology.
 --
 -- Fits a quadratic model to each response @y_j@ and performs the extremum
 -- analysis @q@ times in parallel. As a starting point for multi-objective
diff --git a/src/Hanalyze/Design/Optimal.hs b/src/Hanalyze/Design/Optimal.hs
--- a/src/Hanalyze/Design/Optimal.hs
+++ b/src/Hanalyze/Design/Optimal.hs
@@ -1,6 +1,12 @@
 {-# LANGUAGE OverloadedStrings #-}
--- | Optimal designs: D-optimal and A-optimal.
+-- |
+-- Module      : Hanalyze.Design.Optimal
+-- Description : 最適計画 (D/A/I/E/G-optimal) — Fedorov 交換法による候補集合からの選択・拡張
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
 --
+-- Optimal designs: D-optimal and A-optimal.
+--
 -- Selects a subset of @n@ runs from a candidate set, maximizing /
 -- minimizing a criterion based on the information matrix @XᵀX@.
 --
@@ -16,10 +22,16 @@
   ( OptCriterion (..)
   , dOptimal
   , aOptimal
+  , iOptimal
+  , eOptimal
+  , gOptimal
   , optimalDesign
   , candidateGrid
   , quadraticCandidates
   , pseudoShuffle
+    -- * Augment Design (Phase 5、 request/160)
+  , AugmentResult (..)
+  , augmentDesign
   ) where
 
 import Data.List (foldl')
@@ -29,6 +41,38 @@
 data OptCriterion
   = DOpt   -- ^ D-optimal: maximize @det(XᵀX)@.
   | AOpt   -- ^ A-optimal: minimize @trace((XᵀX)⁻¹)@.
+  | IOpt   -- ^ I-optimal: minimize average prediction variance, approximated
+           --   by @trace((XᵀX)⁻¹ · M_moment)@. ここでは M_moment を全候補
+           --   から推定した moment matrix @candᵀ cand / n_cand@ とする。
+  | EOpt   -- ^ E-optimal: minimize the maximum eigenvalue of @(XᵀX)⁻¹@、
+           --   = maximize the minimum eigenvalue of @XᵀX@。
+  | GOpt   -- ^ G-optimal (self approximation): minimize the maximum leverage
+           --   @max_i (H_ii)@ where @H = X (XᵀX)⁻¹ Xᵀ@。
+           --   候補集合に依存しない self-G 定義 (= 設計自身の hat 対角の最大)。
+           --   厳密な G-optimal (候補空間全体の max prediction variance) は
+           --   Custom Design spec 側で扱う。 spec: doe-spec v0.2 §2.9。
+  | Compound ![(Double, OptCriterion)]
+           -- ^ Compound (alphabetic) criterion: 各 inner criterion を
+           --   /minimize/ 方向に揃えた 'critValue' の重み付き和。
+           --   重みは正数を仮定 (合計 1 への正規化はユーザ側責任)。
+           --   ネストした @Compound@ も許容 (展開して評価)。
+           --   注意: inner criterion 同士のスケールはユーザが責任を持って
+           --   揃える (例: D 0.7 + I 0.3 は両方を efficiency 形に正規化
+           --   してから渡す)。 v0.2 では正規化ヘルパは未提供、 v0.3+ 候補。
+           --   spec: doe-spec v0.2 §2.9。
+  | BayesianD ![[Double]]
+           -- ^ Bayesian D-optimality (DuMouchel-Jones 1994):
+           --   maximize @det(XᵀX + K)@、 K = prior precision matrix (p × p)。
+           --   K = 0 行列で classic D に縮退。 spec: doe-custom-design-spec v0.1.1 §2.7。
+           --   K は @[[Double]]@ (Show / Eq 要件のため)、 expand 後の列数と一致必須。
+  | IOptRegion ![[Double]]
+           -- ^ I-optimal (region 積分版、 Phase 28-4):
+           --   minimize @trace((XᵀX)⁻¹ · M_R)@、 M_R = region moment matrix
+           --   @∫_R f(z)f(z)' dz / vol(R)@ (p × p)。 旧 'IOpt' は self-moment
+           --   近似で @= p/n@ に縮退するため設計に依らず無意味、 region 版で差し替えた。
+           --   M_R は @[[Double]]@ (Show / Eq 要件のため)、 expand 後の列数と一致必須。
+           --   Custom Design 内では 'Hanalyze.Design.Custom.Compare.regionMomentMatrixAnalytic'
+           --   が連続 U[-1,1] + Categorical 等確率規約で M_R を構築する。
   deriving (Show, Eq)
 
 -- ---------------------------------------------------------------------------
@@ -65,7 +109,87 @@
 critValue :: OptCriterion -> [[Double]] -> Double
 critValue DOpt rows = -dValue rows  -- 最小化問題に統一
 critValue AOpt rows =  aValue rows
+critValue IOpt rows = iValueWithSelf rows
+critValue EOpt rows = eValue rows
+critValue GOpt rows = gValue rows
+critValue (Compound ws) rows =
+  sum [ w * critValue c rows | (w, c) <- ws ]
+critValue (BayesianD k) rows = -bayesianDValue k rows
+critValue (IOptRegion mr) rows = iValueRegion mr rows
 
+-- | I-criterion (region 積分版): @trace((XᵀX)⁻¹ · M_R)@ を返す (minimize 方向)。
+-- @M_R@ の次元が X の列数と不一致 / X が rank-deficient なら @∞@ を返す。
+iValueRegion :: [[Double]] -> [[Double]] -> Double
+iValueRegion mr rows
+  | null rows = 1 / 0
+  | otherwise =
+      let m   = LA.fromLists rows
+          p   = LA.cols m
+          mrM = LA.fromLists mr
+          xtx = LA.tr m LA.<> m
+          d   = LA.det xtx
+      in if LA.rows mrM /= p || LA.cols mrM /= p || abs d < 1e-12
+           then 1 / 0
+           else LA.sumElements (LA.takeDiag (LA.inv xtx LA.<> mrM))
+
+-- | Bayesian D-criterion value: @det(XᵀX + K)@。
+-- K の次元が X の列数と不一致なら 0 を返す (= 採用されない)。
+bayesianDValue :: [[Double]] -> [[Double]] -> Double
+bayesianDValue k rows
+  | null rows = 0
+  | otherwise =
+      let m  = LA.fromLists rows
+          p  = LA.cols m
+          km = LA.fromLists k
+      in if LA.rows km /= p || LA.cols km /= p
+           then 0
+           else LA.det (LA.tr m LA.<> m + km)
+
+-- | I-criterion with self moment: trace((XᵀX)⁻¹ · (XᵀX) / n) = p / n。
+-- 簡略実装として trace((XᵀX)⁻¹) を返す (A-criterion と同等の方向性)。
+-- 真の I-optimal は外部 moment matrix が必要だが、 ここでは候補集合と
+-- 同分布を仮定して self-moment で代用する近似版。
+iValueWithSelf :: [[Double]] -> Double
+iValueWithSelf rows
+  | null rows = 1 / 0
+  | otherwise =
+      let m   = LA.fromLists rows
+          xtx = LA.tr m LA.<> m
+          d   = LA.det xtx
+      in if abs d < 1e-12 then 1 / 0
+           else
+             let inv     = LA.inv xtx
+                 moment  = LA.scale (1 / fromIntegral (length rows)) xtx
+             in LA.sumElements (LA.takeDiag (inv LA.<> moment))
+
+-- | G-criterion value (self approximation): max leverage of @H = X (XᵀX)⁻¹ Xᵀ@
+-- の対角の最大値。 既に「小さい方が良い」 方向 (= max leverage が小さい設計が
+-- 望ましい) なので符号反転なし。
+gValue :: [[Double]] -> Double
+gValue rows
+  | null rows = 1 / 0
+  | otherwise =
+      let m   = LA.fromLists rows
+          xtx = LA.tr m LA.<> m
+          d   = LA.det xtx
+      in if abs d < 1e-12 then 1 / 0
+           else
+             let inv = LA.inv xtx
+                 h   = m LA.<> inv LA.<> LA.tr m
+                 dia = LA.toList (LA.takeDiag h)
+             in if null dia then 1 / 0 else maximum dia
+
+-- | E-criterion value: − (minimum eigenvalue of XᵀX)。
+-- 最小化方向に統一するため負号。
+eValue :: [[Double]] -> Double
+eValue rows
+  | null rows = 1 / 0
+  | otherwise =
+      let m   = LA.fromLists rows
+          xtx = LA.tr m LA.<> m
+          eigs = LA.toList (LA.eigenvaluesSH (LA.trustSym xtx))
+      in if null eigs then 1 / 0 else - minimum eigs
+
 -- ---------------------------------------------------------------------------
 -- Fedorov 交換アルゴリズム
 -- ---------------------------------------------------------------------------
@@ -78,17 +202,22 @@
               -> Int                 -- ^ Seed for the initial selection.
               -> ([Int], [[Double]]) -- ^ Selected candidate indices and
                                      --   the resulting design matrix.
-optimalDesign crit cands n seed =
-  let nC      = length cands
-      initIdx = take n (pseudoShuffle seed [0 .. nC - 1])
+optimalDesign crit cands n seed
+  | n <= 0 || nC == 0 = ([], [])
+  | otherwise =
+  let -- ★点の反復を許す exact design。 候補を循環させて必ず n 点の初期選択を作る
+      --   (@n > nC@ でも頭打ちにならない)。 @n <= nC@ なら @take n shuffled@ に一致し従来と同じ。
+      initIdx = take n (cycle (pseudoShuffle seed [0 .. nC - 1]))
       design  = map (cands !!) initIdx
-      -- 改善する交換が無くなるまで反復
+      -- 改善する交換が無くなるまで反復。 追加候補 @j@ は @current@ に既にあってもよい
+      --   (= 同一候補点の反復を許す)。 反復が criterion を悪化させる (@n <= nC@ で distinct が
+      --   最適な) 場合は @newC < bestC@ が成り立たず不採用ゆえ、 従来の distinct 結果は不変。
       improve current currentCrit =
         let pairs =
               [ (i, j)
               | i <- [0 .. n - 1]   -- 取り除く index (current の中で)
-              , j <- [0 .. nC - 1]  -- 追加候補 (cands の中で)
-              , j `notElem` current ]
+              , j <- [0 .. nC - 1]  -- 追加候補 (cands の中で・反復可)
+              ]
             tryEach (bestIdx, bestC) (i, j) =
               let swapped = take i bestIdx ++ [j] ++ drop (i + 1) bestIdx
                   newDes  = map (cands !!) swapped
@@ -102,6 +231,8 @@
       initC = critValue crit design
       (finalIdx, _) = improve initIdx initC
   in (finalIdx, map (cands !!) finalIdx)
+  where
+    nC = length cands
 
 -- | Build a D-optimal design (specialization of 'optimalDesign').
 dOptimal :: [[Double]] -> Int -> Int -> ([Int], [[Double]])
@@ -111,6 +242,19 @@
 aOptimal :: [[Double]] -> Int -> Int -> ([Int], [[Double]])
 aOptimal = optimalDesign AOpt
 
+-- | Build an I-optimal design (specialization of 'optimalDesign').
+iOptimal :: [[Double]] -> Int -> Int -> ([Int], [[Double]])
+iOptimal = optimalDesign IOpt
+
+-- | Build an E-optimal design (specialization of 'optimalDesign').
+eOptimal :: [[Double]] -> Int -> Int -> ([Int], [[Double]])
+eOptimal = optimalDesign EOpt
+
+-- | Build a G-optimal design (self approximation、 specialization of
+-- 'optimalDesign')。 spec: doe-spec v0.2 §2.9 / §3.6。
+gOptimal :: [[Double]] -> Int -> Int -> ([Int], [[Double]])
+gOptimal = optimalDesign GOpt
+
 -- ---------------------------------------------------------------------------
 -- 候補集合の生成
 -- ---------------------------------------------------------------------------
@@ -161,3 +305,99 @@
       ++ [p]
       ++ sortByKey [q | q <- ps, fst q > fst p]
 
+
+-- ===========================================================================
+-- Augment Design (Phase 5、 request/160)
+-- ===========================================================================
+
+-- | 'augmentDesign' の結果。
+data AugmentResult = AugmentResult
+  { arNewIndices  :: ![Int]
+    -- ^ 候補集合から選ばれた追加点の index リスト (長さ = 要求した N)
+  , arNewRows     :: ![[Double]]
+    -- ^ 追加点の実値 (= map (cands !!) arNewIndices)
+  , arFullDesign  :: ![[Double]]
+    -- ^ 完成 design 行列 (existing ++ new、 元の existing 順序を保つ)
+  , arInitialCrit :: !Double
+    -- ^ existing 単独の criterion 値 (D-opt なら |XᵀX|; n < p 等で singular なら 0)
+  , arFinalCrit   :: !Double
+    -- ^ 完成 design の criterion 値
+  } deriving (Show)
+
+-- | 既存 design に N 行追加するための D-opt / A-opt 最適化。
+--
+-- 既存行は固定 (swap されない)。 候補集合から N 個を選び、
+-- 完成 design (= existing ++ new) の criterion を最大化する Fedorov 交換を行う。
+--
+-- アルゴリズム:
+--
+-- 1. seed-based pseudoShuffle で候補集合から N 個を初期選択
+-- 2. 「現在の追加行 i ↔ 未選択候補 j」 の全ペアを試行
+-- 3. swap した完成 design の criterion が改善するなら採用
+-- 4. 1 sweep で改善が無くなるまで反復
+--
+-- 失敗: N ≤ 0 や候補数 < N の場合は AugmentResult { arNewIndices = [], ... }
+-- (= 空の追加) を返す。
+augmentDesign
+  :: OptCriterion
+  -> [[Double]]            -- existing rows (固定)
+  -> Int                   -- N (追加する行数)
+  -> [[Double]]            -- candidate set
+  -> Int                   -- seed
+  -> AugmentResult
+augmentDesign crit existing n cands seed
+  | n <= 0 || nC < n =
+      AugmentResult
+        { arNewIndices  = []
+        , arNewRows     = []
+        , arFullDesign  = existing
+        , arInitialCrit = safeCrit crit existing
+        , arFinalCrit   = safeCrit crit existing
+        }
+  | otherwise =
+      let initIdx = take n (pseudoShuffle seed [0 .. nC - 1])
+          initial = combine initIdx
+          initC   = critValue crit initial
+          improve current currentC =
+            let pairs =
+                  [ (i, j)
+                  | i <- [0 .. n - 1]
+                  , j <- [0 .. nC - 1]
+                  , j `notElem` current
+                  ]
+                tryEach (bestIdx, bestC) (i, j) =
+                  let swapped = take i bestIdx ++ [j] ++ drop (i + 1) bestIdx
+                      newC    = critValue crit (combine swapped)
+                  in if newC < bestC then (swapped, newC) else (bestIdx, bestC)
+                (improved, improvedC) =
+                  foldl' tryEach (current, currentC) pairs
+            in if improvedC < currentC
+                 then improve improved improvedC
+                 else (improved, currentC)
+          (finalIdx, _) = improve initIdx initC
+          newRows       = map (cands !!) finalIdx
+      in AugmentResult
+           { arNewIndices  = finalIdx
+           , arNewRows     = newRows
+           , arFullDesign  = existing ++ newRows
+           , arInitialCrit = safeCrit crit existing
+           , arFinalCrit   = safeCrit crit (existing ++ newRows)
+           }
+  where
+    nC = length cands
+    combine idx = existing ++ map (cands !!) idx
+
+-- | criterion を「比較用 sign」 でなく、 実際の表示値 (D-opt は |XᵀX|、
+--   A-opt は trace((XᵀX)⁻¹)) で返すヘルパ。 D-opt は singular で 0、 A-opt は ∞
+--   になりうるので、 numeric guard を入れる。
+safeCrit :: OptCriterion -> [[Double]] -> Double
+safeCrit _    []   = 0
+safeCrit DOpt rows = dValue rows
+safeCrit AOpt rows = aValue rows
+safeCrit IOpt rows = iValueWithSelf rows
+safeCrit EOpt rows = eValue rows
+safeCrit GOpt rows = gValue rows
+safeCrit (Compound ws) rows =
+  sum [ w * safeCrit c rows | (w, c) <- ws ]
+safeCrit (BayesianD k) rows = bayesianDValue k rows
+safeCrit (IOptRegion mr) rows = iValueRegion mr rows
diff --git a/src/Hanalyze/Design/Orthogonal.hs b/src/Hanalyze/Design/Orthogonal.hs
--- a/src/Hanalyze/Design/Orthogonal.hs
+++ b/src/Hanalyze/Design/Orthogonal.hs
@@ -1,6 +1,12 @@
 {-# LANGUAGE OverloadedStrings #-}
--- | Orthogonal arrays (Taguchi-style @Lₙ@ tables).
+-- |
+-- Module      : Hanalyze.Design.Orthogonal
+-- Description : 直交表 (Taguchi 流 @Lₙ@ 表) の標準表・因子割付・出力レンダリング
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
 --
+-- Orthogonal arrays (Taguchi-style @Lₙ@ tables).
+--
 --   * 'OA'              — orthogonal-array representation (name / run
 --     count / factor count / column levels / body).
 --   * 'standardArrays'  — standard tables L4 / L8 / L9 / L12 / L16 / L18.
@@ -24,6 +30,7 @@
   , l12
   , l16
   , l18
+  , l27
   , standardArrays
   , lookupOA
   , listArrays
@@ -151,6 +158,27 @@
   , [2,3,3,2,1,2,3,1]
   ]
 
+-- | L27(3¹³) — 27 runs, up to 13 three-level factors.
+--
+-- GF(3) 上の線形形式で生成する (手写しの転記ミスを避ける)。 27 run を
+-- @(a,b,c) ∈ {0,1,2}³@ で添字し、 13 列は 3 次元 GF(3) の相異なる 1 次元部分空間
+-- を代表する係数ベクトル (先頭非零 = 1 に正規化) で @α·a+β·b+γ·c mod 3@ を取る。
+-- 相異なる 1 次元部分空間ゆえ任意の 2 列は強度 2 直交 (各水準組が均等出現)。
+-- 列順は標準タグチ L27 の配置 (col1,2 = 基本、 3,4 = その交互作用、 5 = 第3基本…)。
+l27 :: OA
+l27 = OA "L27(3^13)" 27 13 (replicate 13 3) table
+  where
+    coeffs :: [(Int, Int, Int)]
+    coeffs =
+      [ (1,0,0), (0,1,0), (1,1,0), (1,2,0), (0,0,1), (1,0,1), (1,0,2)
+      , (0,1,1), (0,1,2), (1,1,1), (1,1,2), (1,2,1), (1,2,2) ]
+    table =
+      [ [ 1 + ((al*a + be*b + ga*c) `mod` 3) | (al, be, ga) <- coeffs ]
+      | r <- [0 .. 26 :: Int]
+      , let a = r `div` 9
+            b = (r `div` 3) `mod` 3
+            c = r `mod` 3 ]
+
 -- ---------------------------------------------------------------------------
 -- 2 水準系の生成
 -- ---------------------------------------------------------------------------
@@ -191,7 +219,7 @@
 
 -- | The standard arrays bundled with the library.
 standardArrays :: [OA]
-standardArrays = [l4, l8, l9, l12, l16, l18]
+standardArrays = [l4, l8, l9, l12, l16, l18, l27]
 
 -- | Look up a standard array by short name (e.g. @\"L9\"@).
 lookupOA :: Text -> Maybe OA
@@ -202,6 +230,7 @@
   "L12" -> Just l12
   "L16" -> Just l16
   "L18" -> Just l18
+  "L27" -> Just l27
   _     -> Nothing
 
 -- | List of available orthogonal arrays (used by CLI @doe list@).
diff --git a/src/Hanalyze/Design/Power.hs b/src/Hanalyze/Design/Power.hs
--- a/src/Hanalyze/Design/Power.hs
+++ b/src/Hanalyze/Design/Power.hs
@@ -1,5 +1,11 @@
 {-# LANGUAGE OverloadedStrings #-}
--- | Power analysis: sample-size determination and power computation.
+-- |
+-- Module      : Hanalyze.Design.Power
+-- Description : 検出力分析 — サンプルサイズ決定と検出力計算 (t 検定・ANOVA・比率検定)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Power analysis: sample-size determination and power computation.
 --
 -- Main functions:
 --
diff --git a/src/Hanalyze/Design/Quality.hs b/src/Hanalyze/Design/Quality.hs
--- a/src/Hanalyze/Design/Quality.hs
+++ b/src/Hanalyze/Design/Quality.hs
@@ -1,6 +1,12 @@
 {-# LANGUAGE OverloadedStrings #-}
--- | Quality criteria for evaluating designs.
+-- |
+-- Module      : Hanalyze.Design.Quality
+-- Description : 計画評価指標 (直交性・D/A-efficiency・VIF) と工程能力指数 (Cp/Cpk 等) の算出
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
 --
+-- Quality criteria for evaluating designs.
+--
 --   * 'isOrthogonal'       — are the design columns orthogonal? (i.e.
 --     @XᵀX@ diagonal).
 --   * 'orthogonalityScore' — numeric orthogonality score in @[0, 1]@.
@@ -22,9 +28,23 @@
   , processCapability
   , processCapabilityUpper
   , processCapabilityLower
+  , processCapabilityWeibull
+  , processCapabilityLogNormal
+  , processCapabilityGamma
+    -- * Process capability — unified non-normal entry (Phase 23-c)
+  , NonNormalFit (..)
+  , processCapabilityNonNormal
+    -- * 多変量 Process Capability (Phase 23-d)
+  , MultivariateCapability (..)
+  , processCapabilityMultivariate
   ) where
 
+import           Data.Text                       (Text)
 import qualified Numeric.LinearAlgebra as LA
+import qualified Statistics.Distribution         as SD
+import qualified Statistics.Distribution.Normal  as Normal
+import qualified Statistics.Distribution.Gamma   as Gamma
+import           Hanalyze.Model.Weibull          (WeibullFit (..))
 
 -- | True iff the design matrix @X@ is orthogonal (i.e. @XᵀX@ is
 -- diagonal up to tolerance @ε@).
@@ -166,6 +186,184 @@
   let (mu, sd) = meanSd xs
       cpk      = if sd == 0 then 0 else (mu - lsl) / (3 * sd)
   in Capability cpk cpk mu sd
+
+-- | Process Capability for **Weibull-distributed** characteristics.
+--
+-- 非正規分布の場合、 6σ では裾を過小評価する。 ISO 22514 / AIAG 推奨の
+-- パーセンタイル法:
+--
+-- > Cp  = (USL − LSL) / (P_{0.99865} − P_{0.00135})
+-- > Cpk = min( (USL − median) / (P_{0.99865} − median),
+-- >            (median − LSL) / (median − P_{0.00135}) )
+--
+-- Weibull quantile: @F⁻¹(p) = λ · (−log(1 − p))^{1/k}@
+processCapabilityWeibull
+  :: WeibullFit
+  -> Double            -- ^ LSL
+  -> Double            -- ^ USL
+  -> Capability
+processCapabilityWeibull wf lsl usl =
+  let k   = wfShape wf
+      lam = wfScale wf
+      q p = lam * ((-log (1 - p)) ** (1 / k))
+      pLo  = q 0.00135
+      pHi  = q 0.99865
+      med  = q 0.5
+      spread = pHi - pLo
+      cp   = if spread == 0 then 0 else (usl - lsl) / spread
+      cpkU = if pHi == med then 0 else (usl - med) / (pHi - med)
+      cpkL = if med == pLo then 0 else (med - lsl) / (med - pLo)
+      cpk  = min cpkU cpkL
+  in Capability cp cpk med spread
+
+-- | Process Capability for **LogNormal-distributed** characteristics.
+--   引数は log-scale の μ, σ (ln X ~ Normal(μ, σ²))。
+--
+-- > X_p = exp(μ + σ · z_p)
+processCapabilityLogNormal
+  :: Double            -- ^ μ (log scale mean)
+  -> Double            -- ^ σ (log scale sd)
+  -> Double            -- ^ LSL
+  -> Double            -- ^ USL
+  -> Capability
+processCapabilityLogNormal mu sigma lsl usl =
+  let zHi = SD.quantile Normal.standard 0.99865
+      zLo = SD.quantile Normal.standard 0.00135
+      pHi = exp (mu + sigma * zHi)
+      pLo = exp (mu + sigma * zLo)
+      med = exp mu
+      spread = pHi - pLo
+      cp   = if spread == 0 then 0 else (usl - lsl) / spread
+      cpkU = if pHi == med then 0 else (usl - med) / (pHi - med)
+      cpkL = if med == pLo then 0 else (med - lsl) / (med - pLo)
+      cpk  = min cpkU cpkL
+  in Capability cp cpk med spread
+
+-- | Process Capability for **Gamma-distributed** characteristics (Phase 23-c)。
+--   shape (= k) と scale (= θ) を引数に取る (statistics-0.16 の @gammaDistr@ と同表記)。
+--   rate β = 1 / θ を使うユーザは scale = 1/β で渡す。
+--
+--   分位点法 (ISO 22514) で Cp / Cpk を算出:
+--
+--   > Cp  = (USL − LSL) / (P_{0.99865} − P_{0.00135})
+--   > Cpk = min( (USL − median) / (P_{0.99865} − median),
+--   >            (median − LSL) / (median − P_{0.00135}) )
+processCapabilityGamma
+  :: Double            -- ^ shape (k > 0)
+  -> Double            -- ^ scale (θ > 0)
+  -> Double            -- ^ LSL
+  -> Double            -- ^ USL
+  -> Capability
+processCapabilityGamma shape scale lsl usl =
+  let d   = Gamma.gammaDistr shape scale
+      pLo = SD.quantile d 0.00135
+      pHi = SD.quantile d 0.99865
+      med = SD.quantile d 0.5
+      spread = pHi - pLo
+      cp   = if spread == 0 then 0 else (usl - lsl) / spread
+      cpkU = if pHi == med then 0 else (usl - med) / (pHi - med)
+      cpkL = if med == pLo then 0 else (med - lsl) / (med - pLo)
+      cpk  = min cpkU cpkL
+  in Capability cp cpk med spread
+
+-- | 非正規 Cp の統一エントリ用 ADT (Phase 23-c)。 spec: doe-spec v0.2 §3.13。
+data NonNormalFit
+  = NNFWeibull   !WeibullFit       -- ^ Weibull MLE 結果
+  | NNFLogNormal !Double !Double    -- ^ log-scale μ, σ
+  | NNFGamma     !Double !Double    -- ^ shape, scale
+  deriving (Show)
+
+-- | 非正規分布 fit の type tag で Weibull / LogNormal / Gamma を dispatch。
+-- 個別関数 (@processCapabilityWeibull@ 等) と等価、 ADT で取り回したいケース用。
+processCapabilityNonNormal
+  :: NonNormalFit
+  -> Double          -- ^ LSL
+  -> Double          -- ^ USL
+  -> Capability
+processCapabilityNonNormal (NNFWeibull   wf)         = processCapabilityWeibull   wf
+processCapabilityNonNormal (NNFLogNormal mu sigma)   = processCapabilityLogNormal mu sigma
+processCapabilityNonNormal (NNFGamma     k  scale)   = processCapabilityGamma     k  scale
+
+-- ---------------------------------------------------------------------------
+-- 多変量 Process Capability (Phase 23-d、 spec: doe-spec v0.2 §2.10 / §3.13)
+-- ---------------------------------------------------------------------------
+
+-- | 多変量 Process Capability の結果。
+--
+-- @mcMCp@ は Wang-Hubele-Lawrence (1994) 風の体積比ベース:
+--
+-- > MCp = (det(Σ_T) / det(Σ))^(1/(2p))
+--
+-- ここで Σ_T = diag(((USL_i − LSL_i) / 6)²) (= 各軸 6σ 相当の理想分散)、
+-- Σ は標本共分散、 p は変数数。 単変量 Cp の自然な多変量拡張。
+--
+-- @mcMCpk@ は中心オフセット penalty を乗じた値:
+--
+-- > MCpk = MCp · max(0, 1 − sqrt(T²) / 3)
+-- > T²   = (μ_data − μ_T)' Σ⁻¹ (μ_data − μ_T)
+-- > μ_T  = (LSL + USL) / 2
+--
+-- @mcInSpecRate@ は spec box (per-variable LSL/USL) の内包率 (実測)。
+data MultivariateCapability = MultivariateCapability
+  { mcNVars       :: !Int
+  , mcMean        :: !(LA.Vector Double)
+  , mcCov         :: !(LA.Matrix Double)
+  , mcMCp         :: !Double
+  , mcMCpk        :: !Double
+  , mcInSpecRate  :: !Double
+  } deriving (Show)
+
+-- | 多変量 Cp 計算。 入力 @data@ は n 行 × p 列の観測行列。
+-- @specs@ は各変数の (LSL, USL) を **列順** に与える。
+--
+-- @Left@ を返すケース:
+--
+--   * @specs@ の長さが列数と一致しない
+--   * n < 2 (共分散が定義されない)
+--   * 共分散が singular (= det ≈ 0)
+processCapabilityMultivariate
+  :: LA.Matrix Double
+  -> [(Double, Double)]
+  -> Either Text MultivariateCapability
+processCapabilityMultivariate dat specs
+  | p == 0                = Left "processCapabilityMultivariate: empty data (0 columns)"
+  | length specs /= p     = Left "processCapabilityMultivariate: specs length ≠ #columns"
+  | n < 2                 = Left "processCapabilityMultivariate: need at least 2 observations"
+  | any (\(lo, hi) -> hi <= lo) specs =
+      Left "processCapabilityMultivariate: each USL must be > LSL"
+  | abs detSigma < 1e-12  = Left "processCapabilityMultivariate: covariance is singular"
+  | otherwise =
+      Right MultivariateCapability
+        { mcNVars      = p
+        , mcMean       = mu
+        , mcCov        = sigma
+        , mcMCp        = mcp
+        , mcMCpk       = mcpk
+        , mcInSpecRate = inSpec
+        }
+  where
+    n         = LA.rows dat
+    p         = LA.cols dat
+    mu        = LA.scale (1 / fromIntegral n) (LA.fromList [LA.sumElements (col j) | j <- [0 .. p - 1]])
+    col j     = LA.flatten (LA.subMatrix (0, j) (n, 1) dat)
+    centered  = LA.fromRows [ LA.fromList [(dat `LA.atIndex` (i, j)) - (mu `LA.atIndex` j) | j <- [0 .. p - 1]] | i <- [0 .. n - 1] ]
+    sigma     = LA.scale (1 / fromIntegral (n - 1)) (LA.tr centered LA.<> centered)
+    detSigma  = LA.det sigma
+    sigmaT    = LA.diagl [ ((hi - lo) / 6) ** 2 | (lo, hi) <- specs ]
+    detSigmaT = LA.det sigmaT
+    pD        = fromIntegral p :: Double
+    mcp       = (detSigmaT / detSigma) ** (1 / (2 * pD))
+    muT       = LA.fromList [ (lo + hi) / 2 | (lo, hi) <- specs ]
+    diff      = mu - muT
+    invSigma  = LA.inv sigma
+    t2        = diff LA.<.> (invSigma LA.#> diff)
+    penalty   = max 0 (1 - sqrt (max 0 t2) / 3)
+    mcpk      = mcp * penalty
+    inSpec    =
+      let rowsXs = LA.toLists dat
+          inside r = and [ lo <= x && x <= hi | (x, (lo, hi)) <- zip r specs ]
+          k = length (filter inside rowsXs)
+      in fromIntegral k / fromIntegral n
 
 -- | Sample mean and unbiased standard deviation.
 meanSd :: LA.Vector Double -> (Double, Double)
diff --git a/src/Hanalyze/Design/RSM.hs b/src/Hanalyze/Design/RSM.hs
--- a/src/Hanalyze/Design/RSM.hs
+++ b/src/Hanalyze/Design/RSM.hs
@@ -1,6 +1,12 @@
 {-# LANGUAGE OverloadedStrings #-}
--- | Response Surface Methodology (RSM).
+-- |
+-- Module      : Hanalyze.Design.RSM
+-- Description : 応答曲面法 (RSM) — CCD/Box-Behnken 計画・二次モデル fit・極値の解析解
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
 --
+-- Response Surface Methodology (RSM).
+--
 --   * 'centralComposite' — central composite design (CCD): @2^k@ factorial
 --     + axial points + center points.
 --   * 'boxBehnken'       — Box-Behnken design: @k@ three-level factors
@@ -20,8 +26,11 @@
   , QuadFit (..)
   , fitQuadratic
   , optimumPoint
+  , quadBMatrix
+  , canonicalAnalysis
   ) where
 
+import Data.List (sortOn)
 import Data.Text (Text)
 import qualified Data.Text as T
 import qualified Numeric.LinearAlgebra as LA
@@ -177,16 +186,8 @@
       bMain = take k (drop 1 beta)
       bSq   = take k (drop (1 + k) beta)
       bInt  = drop (1 + 2 * k) beta
-      -- B 行列: 対角は β_sq、非対角は β_int / 2 (対称化)
-      bMat = LA.fromLists
-        [ [ if i == j then bSq !! i
-            else
-              let (lo, hi) = if i < j then (i, j) else (j, i)
-                  idx = pairIndex k lo hi
-              in (bInt !! idx) / 2
-          | j <- [0 .. k - 1] ]
-        | i <- [0 .. k - 1] ]
-      bVec = LA.fromList bMain
+      bMat  = quadBMatrix fit
+      bVec  = LA.fromList bMain
       xStar = LA.toList (LA.scale (-0.5) (LA.inv bMat LA.#> bVec))
       yStar = b0
             + sum (zipWith (*) bMain xStar)
@@ -198,3 +199,31 @@
   where
     -- (i, j) ペア (i < j) の β_int 配列内のインデックス
     pairIndex n i j = sum [n - 1 - p | p <- [0 .. i - 1]] + (j - i - 1)
+
+-- | 二次モデルの @B@ 行列 (@ŷ = b₀ + bᵀx + xᵀ B x@ の 2 次係数)。 対角は β_sq、
+--   非対角は β_int/2 で対称化。 canonical 解析 / 停留点計算の共通部品。
+quadBMatrix :: QuadFit -> LA.Matrix Double
+quadBMatrix fit =
+  let k     = qfK fit
+      beta  = LA.toList (qfBeta fit)
+      bSq   = take k (drop (1 + k) beta)
+      bInt  = drop (1 + 2 * k) beta
+  in LA.fromLists
+       [ [ if i == j then bSq !! i
+           else
+             let (lo, hi) = if i < j then (i, j) else (j, i)
+                 idx = pairIndex k lo hi
+             in (bInt !! idx) / 2
+         | j <- [0 .. k - 1] ]
+       | i <- [0 .. k - 1] ]
+  where pairIndex n i j = sum [n - 1 - p | p <- [0 .. i - 1]] + (j - i - 1)
+
+-- | Canonical 解析。 @B@ 行列の固有分解を返す (固有値, 固有ベクトル) のペア列。
+--   固有値の符号で応答曲面の性質が読める (全負=極大 / 全正=極小 / 混在=鞍点)、
+--   固有ベクトルは canonical 軸 (停留点周りで応答が最も急/緩に動く coded 方向)。
+--   ペアは固有値の昇順。 単位はモデルを当てた座標系 (通常 coded)。
+canonicalAnalysis :: QuadFit -> [(Double, [Double])]
+canonicalAnalysis fit =
+  let (vals, vecs) = LA.eigSH (LA.sym (quadBMatrix fit))
+      pairs = zip (LA.toList vals) (map LA.toList (LA.toColumns vecs))
+  in sortOn fst pairs
diff --git a/src/Hanalyze/Design/Sequential.hs b/src/Hanalyze/Design/Sequential.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Design/Sequential.hs
@@ -0,0 +1,126 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE BangPatterns #-}
+-- |
+-- Module      : Hanalyze.Design.Sequential
+-- Description : 逐次的応答曲面法 (Sequential RSM) — 最急上昇 path と sequential CCD 配置
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Sequential RSM (逐次的応答曲面法)。
+--
+-- 「初期 design → fit → steepest ascent path → 新中心 → 次 design」 の逐次
+-- 最適化ワークフローを支える helper モジュール。 数値の重い部分は
+-- @Hanalyze.Design.RSM@ の `fitQuadratic` / `optimumPoint` に委ね、 本モジュール
+-- は steepest-ascent path 生成と sequential CCD 配置のみを提供する。
+module Hanalyze.Design.Sequential
+  ( -- * Steepest Ascent
+    SteepestAscentResult (..)
+  , steepestAscent
+  , steepestAscentFromQuad
+    -- * Sequential CCD
+  , SequentialCCDResult (..)
+  , sequentialCCD
+  ) where
+
+import qualified Numeric.LinearAlgebra as LA
+
+import qualified Hanalyze.Design.RSM   as RSM
+
+-- ===========================================================================
+-- Steepest Ascent
+-- ===========================================================================
+
+-- | 最急上昇 / 最急下降 path の結果。
+data SteepestAscentResult = SteepestAscentResult
+  { sarDirection  :: !(LA.Vector Double)
+    -- ^ 単位ベクトル化された steepest 方向 (length k)
+  , sarStepPoints :: ![[Double]]
+    -- ^ 試行点系列 (length nSteps + 1、 先頭 = center)
+  , sarMaximize   :: !Bool
+    -- ^ True = ascent、 False = descent
+  } deriving (Show)
+
+-- | 第一階係数 @b = [b_1, ..., b_k]@ から steepest ascent path を生成。
+--
+-- 方向ベクトル:
+--
+--   * @maximize = True@ なら @+b / |b|@
+--   * @maximize = False@ なら @-b / |b|@
+--
+-- path: @[center, center + step·d, center + 2·step·d, ..., center + nSteps·step·d]@
+--
+-- @|b| = 0@ や @k = 0@ の場合は方向 0、 全 point = center を返す。
+steepestAscent
+  :: Bool          -- ^ True = ascent、 False = descent
+  -> [Double]      -- ^ center (k 次元)
+  -> [Double]      -- ^ first-order coefficients @b_1..b_k@
+  -> Double        -- ^ step size (原座標スケール、 > 0 推奨)
+  -> Int           -- ^ 試行点数 (= path 長 = nSteps + 1)
+  -> SteepestAscentResult
+steepestAscent maximize center bCoefs stepSize nSteps =
+  let k       = length center
+      bVec    = LA.fromList bCoefs
+      cVec    = LA.fromList center
+      normB   = sqrt (LA.sumElements (bVec * bVec))
+      dirRaw  = if normB > 0
+                  then LA.scale ((if maximize then 1 else -1) / normB) bVec
+                  else LA.fromList (replicate k 0)
+      points  = [ LA.toList (cVec + LA.scale (fromIntegral i * stepSize) dirRaw)
+                | i <- [0 .. max 0 nSteps]
+                ]
+  in SteepestAscentResult
+       { sarDirection  = dirRaw
+       , sarStepPoints = points
+       , sarMaximize   = maximize
+       }
+
+-- | 'RSM.QuadFit' から第一階係数を抽出して steepest ascent。
+--
+-- `QuadFit` の `qfBeta` レイアウトは @[b0, β_main, β_sq, β_int]@ なので、
+-- 主効果 @β_main = b_1..b_k@ を取り出す。
+steepestAscentFromQuad
+  :: Bool                 -- ^ maximize?
+  -> [Double]             -- ^ center
+  -> RSM.QuadFit
+  -> Double               -- ^ step size
+  -> Int                  -- ^ nSteps
+  -> SteepestAscentResult
+steepestAscentFromQuad maximize center fit stepSize nSteps =
+  let k     = RSM.qfK fit
+      beta  = LA.toList (RSM.qfBeta fit)
+      bMain = take k (drop 1 beta)
+  in steepestAscent maximize center bMain stepSize nSteps
+
+-- ===========================================================================
+-- Sequential CCD
+-- ===========================================================================
+
+-- | 次の CCD を新しい中心で配置した結果。
+data SequentialCCDResult = SequentialCCDResult
+  { sccdCenter :: ![Double]      -- ^ 新しい design center (原座標)
+  , sccdSpan   :: !Double        -- ^ 片側スパン (coded -1 ~ +1 が原座標で center ± span)
+  , sccdCoded  :: ![[Double]]    -- ^ coded units (-α..+α) の design
+  , sccdReal   :: ![[Double]]    -- ^ 原座標の design (= center + span · coded)
+  } deriving (Show)
+
+-- | 新中心と span で次の CCD を配置。
+--
+-- 内部で @Hanalyze.Design.RSM.centralComposite@ を呼び、 結果を新中心に
+-- 平行移動 + スケーリングする。 coded units と原座標の両方を返すので、
+-- canvas frontend で「coded で fit、 原座標で表示」 が一発で出来る。
+sequentialCCD
+  :: [Double]            -- ^ 新中心 (k 次元)
+  -> Double              -- ^ 片側 span (> 0)
+  -> Int                 -- ^ 因子数 k
+  -> RSM.CCDType         -- ^ CCD 種別 (Circumscribed / Inscribed / FaceCentered)
+  -> Int                 -- ^ center replications
+  -> SequentialCCDResult
+sequentialCCD center span_ k ccdT centerReps =
+  let coded = RSM.centralComposite k ccdT centerReps
+      real_ = [ zipWith (\c x -> c + span_ * x) center row | row <- coded ]
+  in SequentialCCDResult
+       { sccdCenter = center
+       , sccdSpan   = span_
+       , sccdCoded  = coded
+       , sccdReal   = real_
+       }
diff --git a/src/Hanalyze/Design/SpaceFilling.hs b/src/Hanalyze/Design/SpaceFilling.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Design/SpaceFilling.hs
@@ -0,0 +1,185 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE BangPatterns #-}
+-- |
+-- Module      : Hanalyze.Design.SpaceFilling
+-- Description : 空間充填計画 (Latin Hypercube / Maximin LHS / Halton) — コンピュータ実験・surrogate モデル用 DoE
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- 空間充填計画 (Space-Filling Designs) — コンピュータ実験 / surrogate
+-- モデル用の DoE。
+--
+-- 提供する方式:
+--
+--   * 'latinHypercube' — Latin Hypercube Sampling (stratified random)
+--   * 'latinHypercubeMaximin' — Maximin LHS (点間最小距離を最大化する局所探索)
+--   * 'haltonDesign' — Halton 低偏差列 (決定的、 再現性高)
+--
+-- 出力は全て @[0, 1)^d@ 上の点。 ユーザは bounds スケーリングを後で行う
+-- (`Hanalyze.Stat.QuasiRandom.lhsSamplesIn` 等を参考に)。
+module Hanalyze.Design.SpaceFilling
+  ( SpaceFillingDesign (..)
+  , latinHypercube
+  , latinHypercubeMaximin
+  , haltonDesign
+    -- * 品質指標
+  , designMinDistance
+  ) where
+
+import           Control.Monad             (forM_, when)
+import           Data.IORef                (newIORef, readIORef, writeIORef, modifyIORef')
+import qualified Numeric.LinearAlgebra     as LA
+import           Data.Text                 (Text)
+import qualified System.Random.MWC         as MWC
+
+import qualified Hanalyze.Stat.QuasiRandom as QR
+
+-- ===========================================================================
+-- 型
+-- ===========================================================================
+
+-- | 空間充填計画の結果。
+data SpaceFillingDesign = SpaceFillingDesign
+  { sfdMatrix  :: !(LA.Matrix Double)  -- ^ n × d、 @[0, 1)^d@ 上の点
+  , sfdNPoints :: !Int                 -- ^ 行数 n
+  , sfdNDims   :: !Int                 -- ^ 列数 d
+  , sfdMinDist :: !Double              -- ^ 点間最小ユークリッド距離 (大きい方が良い)
+  , sfdMethod  :: !Text                -- ^ "LHS" / "MaximinLHS" / "Halton"
+  } deriving (Show)
+
+-- ===========================================================================
+-- 公開関数
+-- ===========================================================================
+
+-- | Latin Hypercube Sampling — 各次元のセル @[i/n, (i+1)/n)@ を 1 度ずつ
+-- ランダム順序で埋める。 iid uniform より初期被覆良。
+latinHypercube :: Int            -- ^ 点数 n
+               -> Int            -- ^ 次元 d
+               -> MWC.GenIO
+               -> IO SpaceFillingDesign
+latinHypercube n d gen
+  | n < 1 || d < 1 = pure SpaceFillingDesign
+      { sfdMatrix  = (0 LA.>< 0) []
+      , sfdNPoints = 0
+      , sfdNDims   = 0
+      , sfdMinDist = 0
+      , sfdMethod  = "LHS"
+      }
+  | otherwise = do
+      pts <- QR.lhsSamples n d gen
+      let mat = LA.fromLists pts
+      pure SpaceFillingDesign
+        { sfdMatrix  = mat
+        , sfdNPoints = n
+        , sfdNDims   = d
+        , sfdMinDist = designMinDistance mat
+        , sfdMethod  = "LHS"
+        }
+
+-- | Maximin LHS — 初期 LHS から始めて、 ランダム (列, 行ペア) で値交換を試行、
+-- 点間最小距離が改善するなら採用、 を @nTries@ 回 (= 全試行回数) 反復。
+--
+-- 結果は **LHS の stratification 性質を保ったまま** 距離を最大化したもの。
+-- @nTries = 1000@ 程度で実用的な改善が得られる (n, d による)。
+latinHypercubeMaximin :: Int            -- ^ 点数 n
+                     -> Int            -- ^ 次元 d
+                     -> Int            -- ^ 試行回数 (= swap 候補数の上限)
+                     -> MWC.GenIO
+                     -> IO SpaceFillingDesign
+latinHypercubeMaximin n d nTries gen
+  | n < 2 || d < 1 = do
+      -- 1 点しかなければ swap 不能、 通常 LHS を返す
+      lhs <- latinHypercube n d gen
+      pure lhs { sfdMethod = "MaximinLHS" }
+  | otherwise = do
+      initPts  <- QR.lhsSamples n d gen
+      matRef   <- newIORef (LA.fromLists initPts)
+      distRef  <- do
+        let m0 = LA.fromLists initPts
+        newIORef (designMinDistance m0)
+      forM_ [1 .. nTries] $ \_ -> do
+        -- ランダムに 1 列 k 選び、 その列の 2 行 i, j を swap
+        k <- MWC.uniformR (0, d - 1) gen
+        i <- MWC.uniformR (0, n - 1) gen
+        j <- MWC.uniformR (0, n - 1) gen
+        when (i /= j) $ do
+          curMat   <- readIORef matRef
+          let newMat  = swapEntries curMat i j k
+              newDist = designMinDistance newMat
+          curDist <- readIORef distRef
+          when (newDist > curDist) $ do
+            writeIORef matRef  newMat
+            writeIORef distRef newDist
+      finalMat  <- readIORef matRef
+      finalDist <- readIORef distRef
+      pure SpaceFillingDesign
+        { sfdMatrix  = finalMat
+        , sfdNPoints = n
+        , sfdNDims   = d
+        , sfdMinDist = finalDist
+        , sfdMethod  = "MaximinLHS"
+        }
+
+-- | Halton 低偏差列ベースの決定的 design。 同じ @(n, d)@ で必ず同じ点集合を
+-- 返す (再現性目的)。
+haltonDesign :: Int          -- ^ 点数 n
+             -> Int          -- ^ 次元 d
+             -> SpaceFillingDesign
+haltonDesign n d
+  | n < 1 || d < 1 = SpaceFillingDesign
+      { sfdMatrix  = (0 LA.>< 0) []
+      , sfdNPoints = 0
+      , sfdNDims   = 0
+      , sfdMinDist = 0
+      , sfdMethod  = "Halton"
+      }
+  | otherwise =
+      let mat = QR.haltonMatrix n d
+      in SpaceFillingDesign
+           { sfdMatrix  = mat
+           , sfdNPoints = n
+           , sfdNDims   = d
+           , sfdMinDist = designMinDistance mat
+           , sfdMethod  = "Halton"
+           }
+
+-- ===========================================================================
+-- 品質指標
+-- ===========================================================================
+
+-- | 点間ユークリッド距離の最小値。 空 design (行数 < 2) では 0。
+designMinDistance :: LA.Matrix Double -> Double
+designMinDistance mat
+  | LA.rows mat < 2 = 0
+  | otherwise =
+      let n   = LA.rows mat
+          rs  = LA.toRows mat
+          pairs = [ (i, j) | i <- [0 .. n - 2], j <- [i + 1 .. n - 1] ]
+          dist (i, j) =
+            let di = rs !! i
+                dj = rs !! j
+                v  = di - dj
+            in sqrt (LA.sumElements (v * v))
+      in minimum (map dist pairs)
+
+-- ===========================================================================
+-- 内部 helper
+-- ===========================================================================
+
+-- | Matrix の (i, k) 要素と (j, k) 要素を入れ替えた新しい Matrix。
+swapEntries :: LA.Matrix Double -> Int -> Int -> Int -> LA.Matrix Double
+swapEntries mat i j k =
+  let nR = LA.rows mat
+      nC = LA.cols mat
+      a  = LA.atIndex mat (i, k)
+      b  = LA.atIndex mat (j, k)
+      rows = LA.toLists mat
+      update r idx newVal =
+        take k r ++ [newVal] ++ drop (k + 1) r
+      _ = (nR, nC)  -- silence
+  in LA.fromLists
+       [ if rIdx == i then update (rows !! rIdx) k b
+         else if rIdx == j then update (rows !! rIdx) k a
+         else rows !! rIdx
+       | rIdx <- [0 .. nR - 1]
+       ]
diff --git a/src/Hanalyze/Design/Taguchi.hs b/src/Hanalyze/Design/Taguchi.hs
--- a/src/Hanalyze/Design/Taguchi.hs
+++ b/src/Hanalyze/Design/Taguchi.hs
@@ -1,5 +1,11 @@
 {-# LANGUAGE OverloadedStrings #-}
--- | The Taguchi method — an analytical layer that extends orthogonal
+-- |
+-- Module      : Hanalyze.Design.Taguchi
+-- Description : タグチメソッド — SN 比・内側/外側配置・要因効果によるロバスト設計解析
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- The Taguchi method — an analytical layer that extends orthogonal
 -- arrays ('Hanalyze.Design.Orthogonal') for robust design.
 --
 -- Main building blocks:
diff --git a/src/Hanalyze/Design/Workflow.hs b/src/Hanalyze/Design/Workflow.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Design/Workflow.hs
@@ -0,0 +1,1465 @@
+{-# LANGUAGE OverloadedStrings #-}
+-- |
+-- Module      : Hanalyze.Design.Workflow
+-- Description : DOE ワークフロー層 — 低レベル設計関数を設計オブジェクト Design に束ねる R 流の対話的入口
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- DOE ワークフロー層 (Phase 78) — 散在する低レベル設計関数 (`Design.Factorial`/`RSM` 等・
+--   生 @[[Double]]@ 返し) を **設計オブジェクト `Design`** に束ね、 R 流の対話的ワークフローに
+--   載せる玄関。
+--
+--   * `factorialDesign` / `centralCompositeDesign` — 因子 (名前 + 実値の下限/上限) から `Design` を作る
+--     pure コンストラクタ。 `Design` は **coded 設計行列 + モデル formula の含意**を運ぶ。
+--   * `designTable` — 実行用の **runsheet** (uncoded 実値・因子名列 + run 番号) を出す。
+--     戻り値 @[(Text,[Double])]@ は 'ColumnSource' ゆえそのまま @df |->@ にも載る。
+--   * `designFormula` — 設計種別からモデル formula を生成 (要因計画 = 全交互作用
+--     @y ~ x1 * x2 * …@、 RSM = 2 次 @y ~ x1 + x2 + x1:x2 + I(x1^2) + I(x2^2)@)。
+--
+--   解析 (`designModel`) は `Hanalyze.Fit` 側 (formula → 既存 LM 当てはめ)。
+--   ★coded/uncoded の要点: fit を coded でやるか uncoded でやるかは**予測に影響しない**
+--   (同一項の LM は再パラメータ化・予測/R²/profiler は同値)。 だから fit は uncoded (自然単位)
+--   のまま — 係数がそのまま実単位で読める。 coding が実質的に効くのは**スケール依存な最適化幾何**
+--   (停留点方向・canonical 軸・steepest ascent 方向) だけ。 そこは `rsmAnalysis` /
+--   `steepestAscentNatural` が内部で coded の計量を使い、 結果を**自然単位で報告**する
+--   (Phase 78.G-d)。 runsheet は一貫して実験者向けの uncoded 実値。
+module Hanalyze.Design.Workflow
+  ( -- * 設計オブジェクト
+    DesignFactor (..)
+  , FactorKind (..)
+  , FactorScale (..)
+  , DesignKind (..)
+  , Design (..)
+    -- * 因子の smart constructor (連続 / 数値順序 / カテゴリ)
+  , contFactor
+  , contFactorLog
+  , numFactor
+  , catFactor
+    -- * コンストラクタ (pure)
+  , factorialDesign
+  , centralCompositeDesign
+  , boxBehnkenDesign
+    -- * 一部実施要因 (run 削減) — Phase 78.G
+  , Resolution (..)
+  , resNum
+  , fractionalDesign
+  , fractionalDesignGen
+  , fractionalDesignInter
+  , fractionalDesignGenInter
+  , fractionalCatalog
+  , fracResolution
+  , aliasStructure
+    -- * Taguchi 直交表 (2 水準スクリーニング) — Phase 78.G-a
+  , OATable (..)
+  , taguchiDesign
+  , taguchiDesignOA
+    -- * 最適計画 (D/A/I/E/G-最適・カスタム formula) — Phase 78.G-b1
+  , OptCriterion (..)
+  , optimalDesign
+  , optimalDesignWith
+  , optimalDesignLevels
+    -- ** モデル指定 効果 DSL (Formula の糖衣)
+  , mainEffects
+  , twoWay
+  , quadratic
+    -- ** 効果 DSL / Formula → Custom.Model 変換 (Phase 78.M M3)
+  , formulaToCustomModel
+    -- * 完全カスタムデザインエンジン (pure・座標交換 / 階層構造 / 制約) — Phase 79
+  , CustomSpec (..)
+  , customSpec
+  , customDesign
+  , Structure (..)
+  , splitPlot
+  , stripPlot
+  , blocked
+  , Constraint (..)
+  , ConstraintRel (..)
+  , ConstraintGuard (..)
+  , FactorValue (..)
+    -- ** 自然単位の制約 (推奨・Phase 82)
+  , NatConstraint (..)
+  , natLeq
+  , natGeq
+  , natEq
+  , natForbid
+    -- * 取り出し
+  , designFactorNames
+  , designTable
+  , designFrame
+  , designFrameRound
+  , designFormula
+    -- * 応答曲面 解析 (自然単位で報告) — Phase 78.G-d
+  , RSMNature (..)
+  , RSMReport (..)
+  , rsmAnalysis
+  , steepestAscentNatural
+    -- * 設計の保存 / DataFrame からの復元 — Phase 78.K
+  , saveDesign
+  , planFromFrame
+  ) where
+
+import           Data.List (sort, subsequences, (\\), foldl1', nub, minimumBy, elemIndex, transpose, find)
+import           Data.Ord (comparing)
+import           Data.Text (Text)
+import qualified Data.Text as T
+import qualified Data.Vector as V
+import qualified DataFrame.Internal.Column    as DX
+import qualified DataFrame.Internal.DataFrame  as DX
+import qualified DataFrame.IO.CSV as DXIO
+import qualified Numeric.LinearAlgebra as LA
+
+import           Hanalyze.DataIO.Convert (getDoubleVec, getTextVec)
+
+import           Hanalyze.Design.Custom.Constraint
+                   (Constraint (..), ConstraintRel (..), ConstraintGuard (..), FactorValue (..))
+import qualified Hanalyze.Design.Custom.Factor as CF
+import qualified Hanalyze.Design.Custom.Model  as CMd
+import qualified Hanalyze.Design.Custom.Coordinate as CX
+import qualified Hanalyze.Design.Custom.Structured as ST
+import qualified Data.Vector.Storable as VS
+import           Hanalyze.Design.Factorial (fullFactorial, fractionalFactorial)
+import           Hanalyze.Design.RSM
+                   ( centralCompositeRotatable, boxBehnken
+                   , QuadFit (..), fitQuadratic, optimumPoint, canonicalAnalysis )
+import           Hanalyze.Design.Sequential
+                   (SteepestAscentResult (..), steepestAscentFromQuad)
+import           Hanalyze.Design.Orthogonal
+                   (OA (..), l4, l8, l9, l12, l16, l18, l27)
+import           Hanalyze.Design.Optimal   (OptCriterion (..))
+import qualified Hanalyze.Design.Optimal as OPT
+import           Hanalyze.Model.Formula
+                   (Formula (..), Term (..), BinOp (..), prettyFormula)
+import           Hanalyze.Model.Formula.RFormula (parseRFormula)
+import           Hanalyze.Model.Formula.Frame    (modelFrame)
+import           Hanalyze.Model.Formula.Design   (designMatrixF)
+
+-- ---------------------------------------------------------------------------
+-- 型
+-- ---------------------------------------------------------------------------
+
+-- | DOE 因子 (Phase 78.G-b2)。 **識別子** ('dfName') と **性質** ('dfKind') を分離し、
+--   accessor は全て total (partial field を作らない)。 構築は smart constructor
+--   'contFactor' / 'numFactor' / 'catFactor' で行う。
+--
+--   Phase 79: 因子は純粋に因子であり、 どの因子がどの階層 (whole-plot / block) に属するかは
+--   因子ではなく 'CustomSpec' の 'Structure' が **名前で** 持つ (役割 'dfRole' は撤去)。
+data DesignFactor = DesignFactor
+  { dfName :: !Text          -- ^ 因子名 (runsheet の列名・formula の項)
+  , dfKind :: !FactorKind    -- ^ 連続 ('Cont') / 数値順序 ('Num') / カテゴリ ('Cat')
+  } deriving (Show, Eq)
+
+-- | 連続因子の**スケール** (Phase 82.3)。 coded @[-1,1]@ 軸を自然単位へどう写すか。
+--
+--   * 'SLinear' — 線形。 @nat = center + coded·half@ (既定)。
+--   * 'SLog'    — 対数 (幾何)。 @nat = 10^(logCenter + coded·logHalf)@。 桁が大きく違う
+--     因子 (触媒濃度 0.01〜10 等) の水準・中心点を幾何的に等間隔にする。 @lo, hi > 0@ 必須。
+data FactorScale = SLinear | SLog
+  deriving (Show, Eq)
+
+-- | 因子の性質。 因子1つは連続・数値順序・カテゴリの**いずれか一つ**で、 混在不正状態は表現不能。
+--
+--   * 'Cont' — 2 端点連続 + スケール ('FactorScale')。 coded @-1@ = 下限、 @+1@ = 上限。
+--     線形なら uncoded 実値 = @center + coded·halfRange@、 対数なら幾何 (下記 'FactorScale')。
+--     Taguchi では 2 水準列に載る。
+--   * 'Num'  — **数値順序水準リスト** (Phase 78.G-a2)。 3 水準以上の連続量 (温度 150/165/180 等) を
+--     順序付き実値で持つ。 coded は水準リストの位置 index (@0,1,2,…@)、 runsheet/designFrame では
+--     **実水準値** (Double) に戻る。 formula は **直交多項式** @opoly(name, 水準数−1)@
+--     (linear+quadratic…) で載り、 実測間隔で直交分解する (等間隔前提を置かない)。
+--   * 'Cat'  — カテゴリ (順序なし) 水準名リスト。 coded は位置 index、 runsheet では水準名 (Text)。
+--     formula は主効果名 (engine が contrast 展開)。
+data FactorKind
+  = Cont !Double !Double !FactorScale  -- ^ 連続因子 (下限, 上限, スケール)
+  | Num  ![Double]        -- ^ 数値順序因子 (順序付き水準値リスト) → opoly
+  | Cat  ![Text]          -- ^ カテゴリ因子 (水準名リスト) → contrast
+  deriving (Show, Eq)
+
+-- | 連続因子の smart constructor (線形スケール)。 @contFactor "temp" (150, 180)@。
+contFactor :: Text -> (Double, Double) -> DesignFactor
+contFactor n (lo, hi) = DesignFactor n (Cont lo hi SLinear)
+
+-- | **対数スケール**連続因子の smart constructor (Phase 82.3)。 @contFactorLog "conc" (0.01, 10)@。
+--   coded 軸は従来通り @[-1,1]@ だが、 自然単位へは幾何的 (@10^…@) に写す。 水準・中心点が
+--   幾何等間隔になり、 桁の異なる因子を扱える。 @lo, hi > 0@ 必須 (負/零は log 不能)。
+contFactorLog :: Text -> (Double, Double) -> DesignFactor
+contFactorLog n (lo, hi) = DesignFactor n (Cont lo hi SLog)
+
+-- | 数値順序因子の smart constructor (Phase 78.G-a2)。 @numFactor "temp" [150, 165, 180]@。
+--   3 水準以上の連続量を Taguchi 3 水準表 (L9/L18/L27) に載せ、 実測間隔の直交多項式
+--   (@opoly@) で linear+quadratic 分解する。 実水準値をそのまま渡す (等間隔でなくてよい)。
+numFactor :: Text -> [Double] -> DesignFactor
+numFactor n levels = DesignFactor n (Num levels)
+
+-- | カテゴリ因子の smart constructor。 @catFactor "catalyst" ["A", "B", "C"]@。
+catFactor :: Text -> [Text] -> DesignFactor
+catFactor n levels = DesignFactor n (Cat levels)
+
+-- | 設計種別 (モデル formula の含意を決める)。
+data DesignKind
+  = KFactorial   -- ^ 要因計画 → 全交互作用モデル
+  | KRSM         -- ^ 応答曲面 → 2 次モデル
+  | KFractional  -- ^ 一部実施要因 → **主効果のみ** (交互作用は交絡ゆえ主効果限定)
+  | KFracInter ![[Int]]
+      -- ^ 一部実施要因 (**交互作用込み**・'fractionalDesignInter')。 generator を保持し、
+      --   'designFormula' が主効果 + **主効果と交絡しない 2 因子交互作用の代表** (交絡群ごと 1 個) を
+      --   生成する。 交絡構造は 'aliasStructure' で確認できる。
+  | KCustom !Formula
+      -- ^ 最適計画 ('optimalDesign') → モデル formula を**焼き込む**。 応答は placeholder
+      --   ('formResponse') を持ち、 'designModel'/'designFormula' で実応答名に差し替わる。
+  | KStructured ![(Text, [Int])] !Formula
+      -- ^ 完全カスタムデザイン ('customDesign'・Phase 79)。 **群列** (@[(群列名, 各 run の群 ID)]@ の
+      --   リスト) + 焼き込み formula を保持する。 CRD = @[]@、 SplitPlot = @[("wholePlot", ids)]@、
+      --   StripPlot = @[("wholePlot", wpIds), ("strip", stripIds)]@、 Blocked = @[("block", ids)]@。
+      --   'designFrame' は各群列を **Text ラベル** (@wp0…@ / @strip0…@ / @blk0…@) で追加し、
+      --   'designModelHBM' @[ranIntercept 群列名, …]@ が階層効果として当てられる (round-trip)。
+      --   固定効果 formula は 'KCustom' 同様 'designFormula' で応答名に差し替わる。
+  deriving (Show, Eq)
+
+-- | 設計オブジェクト = 因子 + coded 設計行列 (各行 = 1 run・列 = 因子) + 種別。
+data Design = Design
+  { dsFactors :: ![DesignFactor]
+  , dsCoded   :: ![[Double]]      -- ^ coded 座標 (±1 / ±α / 0)
+  , dsKind    :: !DesignKind
+  } deriving (Show)
+
+-- ---------------------------------------------------------------------------
+-- Phase 79: 完全カスタムデザインエンジン (Structure / CustomSpec)
+-- ---------------------------------------------------------------------------
+
+-- | 実験のランダム化 / 階層構造 = 共分散 @M@ を決める。 総称 (v1 はエンジンが 4 種を実装)。
+--   どの因子がどの層に属するかは因子 ('DesignFactor') ではなく **この構造が名前で持つ**。
+--   'CustomSpec' の 'csStructure' に載る (既定 'CRD')。
+--
+--   * 'CRD' — 完全ランダム化 (@M = I@)。 既定。 座標交換 D-最適 (per-cell ムーブ)。
+--   * 'SplitPlot' — whole-plot 因子が群内で一定 (@M = I + η·Z Zᵀ@)。 群単位ムーブ。
+--   * 'StripPlot' — whole-plot × strip の直交 2 階層 (@M = I + ηW·Z_W Z_Wᵀ + ηS·Z_S Z_Sᵀ@)。
+--   * 'Blocked' — ランダムブロック (@M = I + η·Z_B Z_Bᵀ@)。 全因子がブロック内で自由。
+--
+--   v1 未実装の構造 (多段ネスト・複数交差 RE) は 'customDesign' が
+--   @unsupported structure@ で error になる (総称ゆえコンストラクタ追加のみで拡張できる)。
+data Structure
+  = CRD
+      -- ^ 完全ランダム化 (@M = I@)。 既定。
+  | SplitPlot
+      { spWhole   :: ![Text]    -- ^ whole-plot 因子名 (群内で一定)
+      , spNWhole  :: !Int       -- ^ whole-plot 数
+      , spEta     :: !Double    -- ^ η = σ²_WP / σ² (既定 1.0)
+      , spColName :: !Text      -- ^ designFrame に出す群列名 (既定 "wholePlot")
+      }
+  | StripPlot
+      { stWhole    :: ![Text], stNWhole :: !Int, stEtaW :: !Double, stWholeCol :: !Text
+      , stStrip    :: ![Text], stNStrip :: !Int, stEtaS :: !Double, stStripCol :: !Text
+      }
+      -- ^ whole-plot × strip の直交 2 階層。
+  | Blocked
+      { blkNBlocks :: !Int, blkEta :: !Double, blkColName :: !Text }
+      -- ^ ランダムブロック。 全因子がブロック内で自由 (block は run 割付のみ)。
+  deriving (Eq, Show)
+
+-- | 'SplitPlot' の smart constructor。 η = 1.0・群列名 = @"wholePlot"@ を既定にする。
+--   @splitPlot ["temp"] 4@ = temp を whole-plot 因子、 whole-plot 数 4。
+splitPlot :: [Text] -> Int -> Structure
+splitPlot whole nWhole = SplitPlot whole nWhole 1.0 "wholePlot"
+
+-- | 'StripPlot' の smart constructor。 ηW = ηS = 1.0・群列名 = @"wholePlot"@ / @"strip"@ を既定に。
+--   @stripPlot ["A"] 3 ["B"] 4@ = A が whole-plot (3 群) × B が strip (4 群)。
+stripPlot :: [Text] -> Int -> [Text] -> Int -> Structure
+stripPlot whole nWhole strip nStrip =
+  StripPlot whole nWhole 1.0 "wholePlot" strip nStrip 1.0 "strip"
+
+-- | 'Blocked' の smart constructor。 η = 1.0・群列名 = @"block"@ を既定にする。
+--   @blocked 3@ = 3 ランダムブロック。
+blocked :: Int -> Structure
+blocked nBlocks = Blocked nBlocks 1.0 "block"
+
+-- | **完全カスタムデザインの仕様** (Phase 79)。 因子 × 固定効果モデル × run 数 × seed に、
+--   最適化基準 ('csCriterion')・制約 ('csConstraints')・階層構造 ('csStructure') を載せた
+--   1 本のスペックレコード。 'customSpec' で既定 (DOpt・制約なし・CRD) を作り、 レコード更新で
+--   criterion / constraints / structure を足す。 唯一の生成入口 'customDesign' に渡す。
+data CustomSpec = CustomSpec
+  { csFactors     :: ![DesignFactor]  -- ^ 因子 ('contFactor' / 'catFactor' / 'numFactor')
+  , csFormula     :: !Formula         -- ^ 固定効果モデル (効果 DSL / 'parseRFormula')
+  , csNRuns       :: !Int             -- ^ run 数 n
+  , csSeed        :: !Int             -- ^ seed (決定的 pure)
+  , csCriterion   :: !OptCriterion    -- ^ 最適化基準 (既定 'DOpt')
+  , csConstraints :: ![Constraint]    -- ^ 低レベル制約 (既定 []・**coded 単位**・エスケープハッチ)
+  , csNatConstraints :: ![NatConstraint]
+      -- ^ **自然単位の制約** (既定 []・Phase 82・推奨 API)。 実単位で書き ('natLeq' 等)、
+      --   'customDesign' 入口で coded の 'csConstraints' へ正規化・合流する。
+  , csStructure   :: !Structure       -- ^ 階層構造 (既定 'CRD')
+  } deriving (Show)
+
+-- | 'CustomSpec' の smart constructor。 既定 = DOpt・制約なし・CRD。 レコード更新で
+--   @{ csCriterion = … }@ / @{ csNatConstraints = … }@ / @{ csStructure = … }@ を足す。
+--   @customSpec factors formula nRuns seed@。
+customSpec :: [DesignFactor] -> Formula -> Int -> Int -> CustomSpec
+customSpec fs fml n seed = CustomSpec
+  { csFactors = fs, csFormula = fml, csNRuns = n, csSeed = seed
+  , csCriterion = DOpt, csConstraints = [], csNatConstraints = []
+  , csStructure = CRD }
+
+-- ---------------------------------------------------------------------------
+-- Phase 82: 自然単位の制約 (公開 API) → coded 内部制約への正規化
+-- ---------------------------------------------------------------------------
+
+-- | **自然単位の制約** (Phase 82・公開 API)。 因子を**実単位**で参照する
+--   (@temp <= 160@ 等)。 'customDesign' 入口で因子の coded↔natural 情報を使って
+--   内部 'Constraint' (coded) へ正規化される。 これにより ユーザは coded @[-1,1]@ や
+--   水準 index を意識せず、 実験の言葉 (実温度・実流量) で制約を書ける。
+--
+--   * 'natLeq' / 'natGeq' / 'natEq' — 連続因子の線形不等式/等式 (実単位係数)。
+--     @Σ aᵢ·x_natᵢ  rel  b@。 **離散数値 ('Num')** も**単一項** (@a·temp <= 160@ 等)
+--     なら参照可で、 閾値を満たさない水準を除外する糖衣に展開する (Phase 82.2)。
+--     カテゴリ ('Cat') は順序を持たないため拒否 ('Left'、 'natForbid' を使う)。
+--   * 'natForbid' — 禁止組合せ。 カテゴリは水準名 ('FVText')、 離散数値/連続は
+--     実値 ('FVDouble') で指定 (内部で index / coded へ変換)。
+data NatConstraint
+  = NatLinear ![(Text, Double)] !ConstraintRel !Double
+    -- ^ @Σ aᵢ·x_natᵢ `rel` rhs@ (連続因子のみ)
+  | NatForbid ![(Text, FactorValue)]
+    -- ^ 全項が一致する row を禁止 (実単位/水準名で指定)
+  deriving (Eq, Show)
+
+-- | @Σ aᵢ·x_natᵢ ≤ b@ (連続因子・実単位)。
+natLeq :: [(Text, Double)] -> Double -> NatConstraint
+natLeq coefs b = NatLinear coefs CLeq b
+
+-- | @Σ aᵢ·x_natᵢ ≥ b@ (連続因子・実単位)。
+natGeq :: [(Text, Double)] -> Double -> NatConstraint
+natGeq coefs b = NatLinear coefs CGeq b
+
+-- | @Σ aᵢ·x_natᵢ = b@ (連続因子・実単位)。 grid 解像度に注意。
+natEq :: [(Text, Double)] -> Double -> NatConstraint
+natEq coefs b = NatLinear coefs CEq b
+
+-- | 禁止組合せ (実単位/水準名)。 @natForbid [("catalyst", FVText \"A\"), ("temp", FVDouble 180)]@。
+natForbid :: [(Text, FactorValue)] -> NatConstraint
+natForbid = NatForbid
+
+-- | 因子名で 'DesignFactor' を引く (制約正規化用)。
+lookupDF :: [DesignFactor] -> Text -> Either Text DesignFactor
+lookupDF fs nm =
+  maybe (Left ("未知の因子 '" <> nm <> "' が制約に現れました")) Right
+        (find ((== nm) . dfName) fs)
+
+-- | 自然単位の 'NatConstraint' を因子情報を使って coded 内部 'Constraint' へ正規化。
+--
+--   * 線形スケール連続因子 (coded −1=lo/+1=hi/中心=平均、 @nat = center + coded·half@):
+--     @Σ aᵢ·natᵢ ≤ b ⟺ Σ (aᵢ·halfᵢ)·codedᵢ ≤ b − Σ aᵢ·centerᵢ@。 half>0 ゆえ関係子不変。
+--   * **対数スケール**連続因子: @a·nat = a·10^(…)@ は coded について非線形なので、 線形結合に
+--     混ぜられない。 **単一因子の境界** (@temp <= X@ 等) のみ許可し、 閾値を @codeCont@ で
+--     coded 境界へ写す (係数が負なら関係子を反転)。 混在は 'Left'。
+--   * 禁止組合せ: カテゴリは水準名そのまま (buildRowFV が index→名前に戻すため)、
+--     離散数値は実値→水準 index、 連続は実値→coded ('codeCont'・線形/対数を分岐)。
+normalizeNat :: [DesignFactor] -> NatConstraint -> Either Text [Constraint]
+normalizeNat fs (NatLinear coefs rel rhs) = do
+    terms <- traverse resolve coefs                 -- (name, coef, factor)
+    let numTerms = [ (nm, a, lvs) | (nm, a, f) <- terms, Num lvs <- [dfKind f] ]
+        catNames = [ nm | (nm, _, f) <- terms, Cat _ <- [dfKind f] ]
+    case (catNames, numTerms) of
+      (nm : _, _) ->
+        Left ("自然単位の線形制約はカテゴリ因子 '" <> nm
+              <> "' を参照できません (順序を持たないため)。 natForbid を使ってください")
+      (_, (nm, a, lvs) : rest)
+        | not (null rest) || length terms /= 1 ->
+            Left ("離散数値因子 '" <> nm <> "' を含む自然単位制約は単一項 (a·" <> nm
+                  <> " rel b) でのみ書けます (許容水準の除外へ展開するため)。"
+                  <> " 他因子との線形結合は不可")
+        | otherwise -> numFilter nm a lvs
+      (_, []) ->                                    -- 全項が連続 (線形/対数)
+        let logTerms = filter (\(_, _, f) -> isLog f) terms
+        in case logTerms of
+             []                               -> Right [linearCombo terms]
+             [(nm, a, f)] | length terms == 1 -> (: []) <$> singleLogBound nm a f
+             _ -> Left ("自然単位の線形結合に対数スケール因子は混ぜられません (非線形)。"
+                        <> " 対数因子は単一因子の境界 (temp<=X 等) でのみ書けます")
+  where
+    resolve (nm, a) = (\f -> (nm, a, f)) <$> lookupDF fs nm
+    isLog f = case dfKind f of Cont _ _ SLog -> True; _ -> False
+    -- 単一 Num 因子の実値閾値 → 満たさない水準を Forbidden で除外 (Phase 82.2)。
+    --   @a·level rel rhs@ を各水準で判定し、 満たさない水準の index を禁止する。
+    --   半空間ではなく水準除外になる (順序 Num は index 尺度・doc 明記)。
+    numFilter nm a lvs =
+      let bad = [ i | (i, lv) <- zip [0 :: Int ..] lvs, not (relHolds rel (a * lv) rhs) ]
+      in if length bad == length lvs
+           then Left ("離散数値因子 '" <> nm <> "' の制約 " <> tshowD a <> "·" <> nm
+                      <> " " <> relSym rel <> " " <> tshowD rhs
+                      <> " を満たす水準がありません (水準 " <> T.pack (show lvs) <> ")")
+           else Right [ Forbidden [(nm, FVDouble (fromIntegral i))] | i <- bad ]
+    relHolds CLeq x r = x <= r + 1e-9
+    relHolds CEq  x r = abs (x - r) <= 1e-9
+    relHolds CGeq x r = x >= r - 1e-9
+    -- 全項が線形スケール連続: Σaᵢ·natᵢ rel rhs → coded の LinearIneq へ
+    linearCombo terms' =
+      let part (nm, a, f) = case dfKind f of
+            Cont lo hi _ -> let half = (hi - lo) / 2; center = (lo + hi) / 2
+                            in ((nm, a * half), a * center)
+            _            -> ((nm, 0), 0)      -- 連続のみ到達
+          ps    = map part terms'
+          shift = sum (map snd ps)
+      in LinearIneq (map fst ps) rel (rhs - shift)
+    -- 単一対数因子の境界: a·nat rel rhs → nat rel' (rhs/a) → codeCont で coded 境界
+    singleLogBound nm a f
+      | a == 0    = Left ("対数因子 '" <> nm <> "' の係数が 0 です")
+      | thr <= 0  = Left ("対数因子 '" <> nm <> "' の自然単位境界 " <> T.pack (show thr)
+                          <> " が非正です (log 不能)。 正の閾値で指定してください")
+      | otherwise = Right (LinearIneq [(nm, 1)] rel' (codeCont f thr))
+      where
+        thr  = rhs / a
+        rel' = if a > 0 then rel else flipRel rel
+    flipRel CLeq = CGeq
+    flipRel CGeq = CLeq
+    flipRel CEq  = CEq
+normalizeNat fs (NatForbid vs) = (\c -> [Forbidden c]) <$> traverse conv vs
+  where
+    conv (nm, v) = do
+      f <- lookupDF fs nm
+      case (dfKind f, v) of
+        (Cat _, FVText _)   -> Right (nm, v)   -- 水準名はそのまま
+        (Cat _, FVDouble _) ->
+          Left ("カテゴリ因子 '" <> nm <> "' の禁止値は水準名 (FVText) で指定してください")
+        (Num levels, FVDouble x) ->
+          case elemIndex x levels of
+            Just i  -> Right (nm, FVDouble (fromIntegral i))
+            Nothing -> Left ("離散数値因子 '" <> nm <> "' に水準 "
+                             <> T.pack (show x) <> " はありません")
+        (Cont _ _ _, FVDouble x) -> Right (nm, FVDouble (codeCont f x))
+        _ -> Left ("因子 '" <> nm <> "' の禁止値の型が不正です")
+
+-- | 'CustomSpec' の有効な coded 制約 = 低レベル 'csConstraints' + 正規化した
+--   'csNatConstraints'。 正規化失敗は 'error' (customDesign の既存パターンに合わせる)。
+effectiveConstraints :: CustomSpec -> [Constraint]
+effectiveConstraints cs =
+  case traverse (normalizeNat (csFactors cs)) (csNatConstraints cs) of
+    Left e   -> error ("customDesign: " <> T.unpack e)
+    Right ns -> csConstraints cs ++ concat ns
+
+-- | 座標交換が **実行不能** (feasible な初期解が得られない等) で 'Left' を返したとき、
+--   エラーに「有効な制約 (実単位)」と「因子の範囲」を添えて原因追跡を助ける (Phase 82.2)。
+--   実行不能系でないメッセージ (引数不正等) はそのまま。 制約が空なら添えない。
+enrichInfeasError :: CustomSpec -> Text -> Text
+enrichInfeasError cs e
+  | isFeasErr && hasCons =
+      base <> "\n  有効な制約 (実単位):" <> T.concat (map ("\n    - " <>) items)
+           <> "\n  因子の範囲:" <> T.concat (map ("\n    - " <>) ranges)
+  | otherwise = base
+  where
+    base      = "customDesign: " <> e
+    isFeasErr = any (`T.isInfixOf` e) ["feasible", "infeasible", "too tight", "初期解"]
+    natC      = csNatConstraints cs
+    lowC      = csConstraints cs
+    hasCons   = not (null natC) || not (null lowC)
+    items     = map renderNatC natC ++ map ((<> "  (coded 単位)") . renderLowC) lowC
+    ranges    = [ dfName f <> " ∈ " <> renderRange f | f <- csFactors cs ]
+
+-- | 自然単位 'NatConstraint' を人間可読な文字列に (エラー添付用)。
+renderNatC :: NatConstraint -> Text
+renderNatC (NatLinear coefs rel rhs) =
+  T.intercalate " + " (map (\(nm, a) -> tshowD a <> "·" <> nm) coefs)
+    <> " " <> relSym rel <> " " <> tshowD rhs
+renderNatC (NatForbid vs) =
+  "禁止: " <> T.intercalate ", " (map (\(nm, v) -> nm <> "=" <> renderFV v) vs)
+
+-- | 低レベル coded 'Constraint' の要約 (エラー添付用・完全網羅でなく主要 2 種)。
+renderLowC :: Constraint -> Text
+renderLowC (LinearIneq coefs rel rhs) =
+  T.intercalate " + " (map (\(nm, a) -> tshowD a <> "·" <> nm) coefs)
+    <> " " <> relSym rel <> " " <> tshowD rhs
+renderLowC (RangeBound nm lo hi) = nm <> " ∈ [" <> tshowD lo <> ", " <> tshowD hi <> "]"
+renderLowC (Forbidden vs) =
+  "禁止: " <> T.intercalate ", " (map (\(nm, v) -> nm <> "=" <> renderFV v) vs)
+renderLowC (Conditional _ _) = "条件付制約"
+
+renderFV :: FactorValue -> Text
+renderFV (FVDouble x) = tshowD x
+renderFV (FVText t)   = t
+
+relSym :: ConstraintRel -> Text
+relSym CLeq = "≤"
+relSym CGeq = "≥"
+relSym CEq  = "="
+
+-- | 因子の値域を実単位で (連続 = 範囲・対数注記、 数値順序 = 水準列、 カテゴリ = 水準名)。
+renderRange :: DesignFactor -> Text
+renderRange f = case dfKind f of
+  Cont lo hi sc -> "[" <> tshowD lo <> ", " <> tshowD hi <> "]"
+                     <> (case sc of SLog -> " (log)"; SLinear -> "")
+  Num levels    -> T.pack (show levels)
+  Cat levels    -> "{" <> T.intercalate ", " levels <> "}"
+
+tshowD :: Double -> Text
+tshowD = T.pack . show
+
+-- ---------------------------------------------------------------------------
+-- コンストラクタ
+-- ---------------------------------------------------------------------------
+
+-- | 因子の coded 水準集合。 連続 = @{-1,+1}@ (2 水準)、 カテゴリ = 水準 index @{0,1,…,m-1}@。
+--   完全要因の列挙 ('fullFactorial') と 最適計画の候補格子に使う。
+factorLevelsCoded :: DesignFactor -> [Double]
+factorLevelsCoded f = case dfKind f of
+  Cont _ _ _ -> [-1, 1]
+  Num levels -> map fromIntegral [0 .. length levels - 1]
+  Cat levels -> map fromIntegral [0 .. length levels - 1]
+
+-- | 全因子が連続であることを要求する (rsm/boxBehnken/taguchi 等・連続専用設計)。
+--   カテゴリが混じれば呼び名付きで error。
+requireContinuous :: String -> [DesignFactor] -> [DesignFactor]
+requireContinuous who fs =
+  case [ dfName f | f <- fs, isCat (dfKind f) ] of
+    []   -> fs
+    cats -> error
+      (who ++ ": カテゴリ因子 " ++ show (map T.unpack cats)
+        ++ " は扱えません (この設計は連続因子のみ・カテゴリは factorialDesign/optimalDesign へ)")
+  where isCat (Cat _) = True
+        isCat _       = False
+
+-- | 全カテゴリ因子が 2 水準 (binary) であることを要求する (fractional・v1 は binary のみ)。
+--   3 水準以上の 'Cat' は呼び名付きで error。 連続因子は素通り。
+requireBinaryCats :: String -> [DesignFactor] -> [DesignFactor]
+requireBinaryCats who fs =
+  case [ dfName f | f <- fs, overTwo (dfKind f) ] of
+    []  -> fs
+    bad -> error
+      (who ++ ": カテゴリ因子 " ++ show (map T.unpack bad)
+        ++ " は 2 水準 (binary) のみ対応です (3 水準以上は factorialDesign/optimalDesign か G-a2 の L9/L18 へ)")
+  where overTwo (Cat ls) = length ls /= 2
+        overTwo _         = False
+
+-- | ±1 coded 設計 (fractional/taguchi) のカテゴリ列を水準 index に写す。 binary 前提
+--   (@-1@ → 水準0、 @+1@ → 水準1)。 連続列は ±1 のまま (直交/平衡構造を保つ)。
+codeCatColumns :: [DesignFactor] -> [[Double]] -> [[Double]]
+codeCatColumns fs = map (zipWith recode fs)
+  where recode f v = case dfKind f of
+          Cat _ -> if v < 0 then 0 else 1
+          _     -> v
+
+-- | 2 水準 完全要因計画。 @factorialDesign [contFactor "temp" (150,180), contFactor "time" (10,20)]@
+--   → 2^k run (全交互作用モデル)。 カテゴリ因子を混ぜると各因子の水準を総当り
+--   (連続=2 水準・カテゴリ=m 水準) した完全要因になる (例: 連続1×カテゴリ3水準 = 2×3=6 run)。
+factorialDesign :: [DesignFactor] -> Design
+factorialDesign fs =
+  Design fs (fullFactorial (map factorLevelsCoded fs)) KFactorial
+
+-- | 応答曲面計画 (回転可能 中心複合計画 CCD)。
+--   @centralCompositeDesign [contFactor "temp" (150,180), contFactor "time" (10,20)]@ → 2^k factorial + 2k 軸点
+--   + 中心点 (2 次モデル)。 中心点数は k (最低 1)。 ★連続因子のみ (±α 軸点ゆえカテゴリ不可)。
+centralCompositeDesign :: [DesignFactor] -> Design
+centralCompositeDesign fs0 =
+  let fs = requireContinuous "centralCompositeDesign" fs0
+      k  = length fs
+  in Design fs (centralCompositeRotatable k (max 1 k)) KRSM
+
+-- | Box-Behnken 応答曲面計画 (**k = 3, 4, 5 のみ**)。 CCD より run が少なく、 **極端な軸点
+--   (±α) を持たない** (各点は立方体の辺の中点・因子は @-1,0,+1@ の 3 水準に収まる) 3 水準 RSM。
+--   2 次モデル ('KRSM') を含意。 因子数が 3〜5 でないと下層が error (数学的制約)。 ★連続因子のみ。
+--   @boxBehnkenDesign [contFactor "t" (150,180), contFactor "p" (1,3), contFactor "c" (5,15)]@。
+boxBehnkenDesign :: [DesignFactor] -> Design
+boxBehnkenDesign fs0 =
+  let fs = requireContinuous "boxBehnkenDesign" fs0
+      k  = length fs
+  in Design fs (boxBehnken k (max 1 k)) KRSM
+
+-- ---------------------------------------------------------------------------
+-- 一部実施要因 (fractional factorial) — Phase 78.G
+--
+-- 完全要因 2^k は run 数が指数増するので、 交互作用の一部を主効果と交絡させて
+-- **2^(k-p) に減らす** (直交表と等価)。 交絡の重さは **解像度 (resolution)** で測る:
+--   * Res III … 主効果と 2 因子交互作用が交絡 (最も攻めた削減)。
+--   * Res IV  … 主効果は 2 因子交互作用と交絡しないが、 2 因子交互作用同士が交絡。
+--   * Res V+  … 主効果・2 因子交互作用が (ほぼ) 独立。
+-- generator (追加因子の定義関係) は **最小交絡 (minimum aberration)** の標準表
+-- (Montgomery Table 8-14 / NIST) を 'fractionalCatalog' に持つ。 k = 3〜11, 15 (16/32-run)。
+-- ---------------------------------------------------------------------------
+
+-- | 設計の解像度 (defining word の最短長)。 数字は @'resNum'@。
+data Resolution = ResIII | ResIV | ResV | ResVI | ResVII
+  deriving (Show, Eq, Ord, Enum, Bounded)
+
+resNum :: Resolution -> Int
+resNum r = fromEnum r + 3
+
+-- | 最小交絡 2 水準一部実施の標準カタログ (k → @[(runs, resolution, generators)]@)。
+--   generator は追加因子を基底因子の**積**で定義する 1-based index リスト
+--   (例: @[[1,2]]@ = 「追加因子 = 基底1×基底2」 = C=AB)。 出典 = Montgomery Table 8-14 /
+--   NIST e-Handbook (§5.3.3.4.7)。 各行の resolution は 'fracResolution' で test 検証済 (誤り混入ガード)。
+--   k=8〜15 は NIST 収録の 16/32-run 設計 (Phase 78.G-c 拡張)。 NIST が非収録の帯 (16-run k=12〜14・
+--   32-run k=12〜16) は本カタログも持たない (該当 k はより多 run の設計 or k=15/31 飽和を使う)。
+fractionalCatalog :: Int -> [(Int, Resolution, [[Int]])]
+fractionalCatalog k = case k of
+  3  -> [ (4,  ResIII, [[1,2]]) ]                                   -- C=AB
+  4  -> [ (8,  ResIV,  [[1,2,3]]) ]                                 -- D=ABC
+  5  -> [ (16, ResV,   [[1,2,3,4]])                                 -- E=ABCD
+        , (8,  ResIII, [[1,2],[1,3]]) ]                             -- D=AB, E=AC
+  6  -> [ (32, ResVI,  [[1,2,3,4,5]])                               -- F=ABCDE
+        , (16, ResIV,  [[1,2,3],[2,3,4]])                           -- E=ABC, F=BCD
+        , (8,  ResIII, [[1,2],[1,3],[2,3]]) ]                       -- D=AB, E=AC, F=BC
+  7  -> [ (64, ResVII, [[1,2,3,4,5,6]])                             -- G=ABCDEF
+        , (16, ResIV,  [[1,2,3],[2,3,4],[1,3,4]])                   -- E=ABC, F=BCD, G=ACD
+        , (8,  ResIII, [[1,2],[1,3],[2,3],[1,2,3]]) ]               -- D=AB, E=AC, F=BC, G=ABC
+  -- ↓ NIST 標準表 (16/32-run)。 基底は 16-run=ABCD(4)・32-run=ABCDE(5)。
+  8  -> [ (16, ResIV,  [[2,3,4],[1,3,4],[1,2,3],[1,2,4]]) ]         -- 2^(8-4): E=BCD,F=ACD,G=ABC,H=ABD
+  9  -> [ (32, ResIV,  [[2,3,4,5],[1,3,4,5],[1,2,4,5],[1,2,3,5]])   -- 2^(9-4): F=BCDE,G=ACDE,H=ABDE,J=ABCE
+        , (16, ResIII, [[1,2,3],[2,3,4],[1,3,4],[1,2,4],[1,2,3,4]]) ]  -- 2^(9-5): E=ABC,F=BCD,G=ACD,H=ABD,J=ABCD
+  10 -> [ (32, ResIV,  [[1,2,3,4],[1,2,3,5],[1,2,4,5],[1,3,4,5],[2,3,4,5]])  -- 2^(10-5)
+        , (16, ResIII, [[1,2,3],[2,3,4],[1,3,4],[1,2,4],[1,2,3,4],[1,2]]) ]  -- 2^(10-6): …,J=ABCD,K=AB
+  11 -> [ (32, ResIV,  [[1,2,3],[2,3,4],[3,4,5],[1,3,4],[1,4,5],[2,4,5]])    -- 2^(11-6)
+        , (16, ResIII, [[1,2,3],[2,3,4],[1,3,4],[1,2,4],[1,2,3,4],[1,2],[1,3]]) ]  -- 2^(11-7)
+  15 -> [ (16, ResIII, [[1,2],[1,3],[1,4],[2,3],[2,4],[3,4]         -- 2^(15-11) 飽和: 全 15 列
+                       ,[1,2,3],[1,2,4],[1,3,4],[2,3,4],[1,2,3,4]]) ]
+  _  -> []
+
+-- | generator 集合から**解像度** (数字) を計算する。 各 generator が定める defining word
+--   (追加因子 ∪ 基底集合) の生成する部分群 (全 XOR 組合せ) の**最短語長**。 = 設計の resolution。
+--   'fractionalCatalog' の resolution ラベルを test で照合するのに使う (= 表の自己検証)。
+fracResolution :: Int -> [[Int]] -> Int
+fracResolution k gens =
+  case definingSubgroup k gens of
+    []     -> k
+    combos -> minimum (map length combos)
+
+-- | defining word の対称差 (mod-2 積 = 語の XOR)。
+symDiffW :: [Int] -> [Int] -> [Int]
+symDiffW a b = sort ((a \\ b) ++ (b \\ a))
+
+-- | 各 generator が定める defining word (追加因子 ∪ 基底集合)。 generator i (1-based) は
+--   因子 (kBase+i) を追加する (kBase = k − generator 数)。 defining word = 基底集合 ∪ {kBase+i}。
+definingWords :: Int -> [[Int]] -> [[Int]]
+definingWords k gens =
+  let kBase = k - length gens
+  in [ sort (gen ++ [kBase + i]) | (i, gen) <- zip [1 ..] gens ]
+
+-- | defining 部分群の**非恒等元** (全 defining word の非空部分集合の XOR)。 defining relation
+--   @I = …@ の右辺集合。 交絡 (alias) と解像度 ('fracResolution') はこの群で決まる。
+definingSubgroup :: Int -> [[Int]] -> [[Int]]
+definingSubgroup k gens =
+  [ foldl1' symDiffW ws | ws <- tail (subsequences (definingWords k gens)) ]
+
+-- | 効果 (因子 index 集合) の **alias 剰余類** = @{ effect XOR w | w ∈ 部分群 ∪ {恒等} }@。
+--   効果自身を含む。 同じ剰余類に入る効果同士は設計上区別できない (交絡)。
+aliasCoset :: Int -> [[Int]] -> [Int] -> [[Int]]
+aliasCoset k gens effect =
+  nub (sort [ symDiffW effect w | w <- [] : definingSubgroup k gens ])
+
+-- | 主効果と交絡しない 2 因子交互作用の**代表** (交絡群ごと 1 個)。 各 2FI の alias 剰余類が
+--   主効果語 (長さ1) を含めば群ごと除外、 含まなければ未代表の群から 1 個を採る。 結果は満ランクで
+--   主効果を不バイアスに保つ (Res V=全 2FI・Res IV=群ごと 1 個・Res III=主効果非交絡の 2FI のみ)。
+clearTwoFactorInteractions :: Int -> [[Int]] -> [[Int]]
+clearTwoFactorInteractions k gens = go [] twoFIs
+  where
+    twoFIs = [ [i, j] | i <- [1 .. k], j <- [i + 1 .. k] ]
+    go _    []       = []
+    go seen (t : ts)
+      | any (`elem` seen) coset     = go seen ts               -- 代表済みの交絡群
+      | any ((== 1) . length) coset = go (coset ++ seen) ts     -- 主効果と交絡 → 群ごと除外
+      | otherwise                   = t : go (coset ++ seen) ts
+      where coset = aliasCoset k gens t
+
+-- | 効果 (因子 index 集合) を @":"@ 連結ラベル (@"a"@ / @"a:b"@) に。 因子名は 'dsFactors' 順。
+effectLabel :: [Text] -> [Int] -> Text
+effectLabel names is = T.intercalate ":" [ names !! (i - 1) | i <- is ]
+
+-- | 一部実施 (交互作用版・'KFracInter') の **alias 構造**。 主効果と 2 因子交互作用について、
+--   各効果と交絡する他効果 (同じ剰余類の残り) をラベルで返す。 他の設計種別では空。
+--   @lookup "a:b" (aliasStructure plan)@ で「@a:b@ は何と交絡するか」を引ける。
+aliasStructure :: Design -> [(Text, [Text])]
+aliasStructure (Design fs _ kind) = case kind of
+  KFracInter gens ->
+    let k     = length fs
+        names = map dfName fs
+        effs  = [ [i] | i <- [1 .. k] ]
+                ++ [ [i, j] | i <- [1 .. k], j <- [i + 1 .. k] ]
+    in [ ( effectLabel names e
+         , [ effectLabel names a
+           | a <- aliasCoset k gens e, a /= e, not (null a) ] )
+       | e <- effs ]
+  _ -> []
+
+-- | 一部実施要因計画 (**解像度で自動選択**・k = 3〜7)。 指定解像度**以上**を満たす中で
+--   **最小 run 数**の最小交絡設計を選ぶ (削減を最大化しつつ要求解像度を確保)。 該当なしは error。
+--   @fractionalDesign [("a",(0,1)),…,("g",(0,1))] ResIII@。 formula は**主効果のみ** (交互作用は交絡)。
+fractionalDesign :: [DesignFactor] -> Resolution -> Design
+fractionalDesign specs res =
+  let fs   = requireBinaryCats "fractionalDesign" specs
+      k    = length fs
+      cands = [ e | e@(_, r, _) <- fractionalCatalog k, r >= res ]
+  in case cands of
+       [] -> error
+         ("fractionalDesign: k=" ++ show k ++ " で resolution >= " ++ show res
+           ++ " の標準設計がありません (k=3〜11,15・利用可能: "
+           ++ show [ (n, r) | (n, r, _) <- fractionalCatalog k ] ++ ")")
+       _  -> let (_, _, gens) = minimumBy' (\(n1,_,_) (n2,_,_) -> compare n1 n2) cands
+             in Design fs (codeCatColumns fs (fractionalFactorial k gens)) KFractional
+
+-- | 一部実施要因計画 (**generator 明示**・玄人向け escape hatch)。 generator は追加因子を
+--   基底因子の積で定義する 1-based index リスト (例: @[[1,2,3]]@ = D=ABC)。 追加因子数 = @length gens@、
+--   run 数 = @2^(k - length gens)@。 formula は**主効果のみ**。
+fractionalDesignGen :: [DesignFactor] -> [[Int]] -> Design
+fractionalDesignGen specs gens =
+  let fs = requireBinaryCats "fractionalDesignGen" specs
+      k  = length fs
+  in Design fs (codeCatColumns fs (fractionalFactorial k gens)) KFractional
+
+-- | 一部実施要因計画 (**交互作用込み**・解像度自動)。 設計点は 'fractionalDesign' と同一だが、
+--   'designFormula' が主効果に加え**主効果と交絡しない 2 因子交互作用の代表** (交絡群ごと 1 個) を
+--   含める ('KFracInter')。 交絡構造は 'aliasStructure' で確認できる。 該当解像度なしは error。
+--   @fractionalDesignInter [contFactor "a" (0,1), …] ResV@ (Res V なら全 2FI が独立に載る)。
+fractionalDesignInter :: [DesignFactor] -> Resolution -> Design
+fractionalDesignInter specs res =
+  let fs    = requireBinaryCats "fractionalDesignInter" specs
+      k     = length fs
+      cands = [ e | e@(_, r, _) <- fractionalCatalog k, r >= res ]
+  in case cands of
+       [] -> error
+         ("fractionalDesignInter: k=" ++ show k ++ " で resolution >= " ++ show res
+           ++ " の標準設計がありません (k=3〜11,15・利用可能: "
+           ++ show [ (n, r) | (n, r, _) <- fractionalCatalog k ] ++ ")")
+       _  -> let (_, _, gens) = minimumBy' (\(n1,_,_) (n2,_,_) -> compare n1 n2) cands
+             in Design fs (codeCatColumns fs (fractionalFactorial k gens)) (KFracInter gens)
+
+-- | 一部実施要因計画 (**交互作用込み**・generator 明示)。 'fractionalDesignGen' の交互作用版。
+--   @fractionalDesignGenInter [contFactor "a" (0,1), …] [[1,2,3]]@ (D=ABC の Res IV)。
+fractionalDesignGenInter :: [DesignFactor] -> [[Int]] -> Design
+fractionalDesignGenInter specs gens =
+  let fs = requireBinaryCats "fractionalDesignGenInter" specs
+      k  = length fs
+  in Design fs (codeCatColumns fs (fractionalFactorial k gens)) (KFracInter gens)
+
+-- | 小さな minimumBy (Data.List.minimumBy 相当・依存を増やさない)。
+minimumBy' :: (a -> a -> Ordering) -> [a] -> a
+minimumBy' cmp = foldl1' (\x y -> if cmp x y == GT then y else x)
+
+-- ---------------------------------------------------------------------------
+-- Taguchi 直交表 (orthogonal array) — Phase 78.G-a
+--
+-- 一部実施要因 ('fractionalDesign') と同じ「run を減らして主効果を推定する」目的だが、
+-- **Taguchi の Lₙ 直交表**を土台にする枠組み。 v1 は **2 水準表のみ** (L4/L8/L12/L16)。
+--   * L4(2³) 4 run … 〜3 因子   * L8(2⁷) 8 run … 〜7 因子
+--   * L12(2¹¹) 12 run … 〜11 因子 (★Plackett-Burman・主効果スクリーニングの定番)
+--   * L16(2¹⁵) 16 run … 〜15 因子
+-- L8/L16 は fractional と数学的に等価だが、 「因子を直交表の列に割り当てる」 Taguchi の
+-- framing で透過的に扱えるようにする。 ★目玉は fractional に無い **L12 (11 因子/12 run)**。
+--
+-- ★Phase 78.G-a2: **3 水準/混合水準表 (L9/L18/L27)** と **数値順序 ('Num') / カテゴリ ('Cat')**
+-- 因子に拡張。 各因子の要求水準数 (Cont=2・Num/Cat=水準数) を表の列水準 ('oaLevels') に
+-- **貪欲に突合**して割り当てる (混合表 L18=2¹×3⁷ では 2 水準因子を 2 水準列へ、 3 水準因子を
+-- 3 水準列へ)。 割当先列の code を各因子の coded へ写す:
+--   * Cont … code 1→@-1@ / 2→@+1@ (2 水準)。 uncode で実値へ。
+--   * Num/Cat … code c → 水準 index @c-1@。 designFrame で実値/水準名へ、 Num は formula で
+--     直交多項式 (opoly) に、 Cat は engine の contrast に展開。
+-- formula は 'fractionalDesign' と同じ**主効果のみ** ('KFractional')。 designModel は共通経路。
+-- ---------------------------------------------------------------------------
+
+-- | 自動選択が run 数の昇順に舐める直交表 (2 水準 + 3 水準/混合)。
+taguchiOAs :: [OA]
+taguchiOAs = [l4, l8, l9, l12, l16, l18, l27]   -- runs: 4,8,9,12,16,18,27
+
+-- | 因子が要求する水準数 (Cont=2・Num/Cat=水準リスト長)。 直交表の列水準への突合に使う。
+factorLevelCount :: DesignFactor -> Int
+factorLevelCount f = case dfKind f of
+  Cont _ _ _ -> 2
+  Num levels -> length levels
+  Cat levels -> length levels
+
+-- | 因子を直交表の列に貪欲割当 (各因子の要求水準数に一致する未使用列を順に取る)。
+--   成功なら各因子の割当先**列 index**、 一致列が尽きたら 'Nothing' (混合水準の突合失敗)。
+assignOAColumns :: OA -> [DesignFactor] -> Maybe [Int]
+assignOAColumns oa = go (zip [0 ..] (oaLevels oa))
+  where
+    go _     []       = Just []
+    go avail (f : fs) =
+      case break (\(_, lvl) -> lvl == factorLevelCount f) avail of
+        (_,   [])              -> Nothing
+        (pre, (col, _) : post) -> (col :) <$> go (pre ++ post) fs
+
+-- | 割当先列の 1-based level code を各因子の coded へ。 Cont は code 1→@-1@/2→@+1@、
+--   Num/Cat は水準 index @c-1@。
+oaToCodedCols :: OA -> [Int] -> [DesignFactor] -> [[Double]]
+oaToCodedCols oa cols fs =
+  [ [ codeFor f (row !! col) | (col, f) <- zip cols fs ] | row <- oaTable oa ]
+  where
+    codeFor f code = case dfKind f of
+      Cont _ _ _ -> if code == 1 then -1 else 1
+      _        -> fromIntegral (code - 1)   -- Num/Cat: 水準 index
+
+-- | Taguchi 直交表計画 (**最小 OA 自動選択**・2/3 水準・混合)。 各因子の要求水準数 (連続=2・
+--   'numFactor'/'catFactor' は水準数) に一致する列を持つ最小 run 表 (L4/L8/L9/L12/L16/L18/L27
+--   の run 昇順) を選び、 因子を列へ割り当てる。 該当表なしは error。 formula は
+--   'fractionalDesign' と同じ**主効果のみ** ('KFractional'・Num は 'opoly')。
+--   @taguchiDesign [contFactor "a" (0,1), …]@ (11 連続) → L12、
+--   @taguchiDesign [catFactor "x" ["A","B","C"], …]@ (3 水準) → L9、
+--   @taguchiDesign [contFactor "p" (0,1), catFactor "q" ["a","b","c"], …]@ (混合) → L18。
+taguchiDesign :: [DesignFactor] -> Design
+taguchiDesign specs =
+  case [ (oa, cols) | oa <- taguchiOAs, Just cols <- [assignOAColumns oa specs] ] of
+    []              -> error
+      ("taguchiDesign: 因子の水準構成 " ++ show (map factorLevelCount specs)
+        ++ " を収容できる標準直交表がありません "
+        ++ "(利用可能: L4/L8/L12/L16 = 2 水準、 L9/L18(混合)/L27 = 3 水準)")
+    ((oa, cols) : _) -> Design specs (oaToCodedCols oa cols specs) KFractional
+
+-- | 標準直交表の識別子 ('taguchiDesignOA' で表を明示するための**列挙型**)。
+--   文字列でなく型で表を指定するので、 打ち間違い (@\"L10\"@ 等) は**コンパイル時に弾かれる**。
+--   2 水準表 = 'L4'/'L8'/'L12'/'L16'、 3 水準表 = 'L9'/'L27'、 混合水準表 = 'L18' (2^1×3^7)。
+data OATable = L4 | L8 | L9 | L12 | L16 | L18 | L27
+  deriving (Show, Eq, Ord, Enum, Bounded)
+
+-- | 'OATable' → 低レベル 'OA' 定義。
+oaTableToOA :: OATable -> OA
+oaTableToOA t = case t of
+  L4 -> l4; L8 -> l8; L9 -> l9; L12 -> l12; L16 -> l16; L18 -> l18; L27 -> l27
+
+-- | Taguchi 直交表計画 (**直交表を型で明示**・fractional の generator 版と対称の escape hatch)。
+--   表は 'OATable' の列挙値 ('L4'/'L8'/'L9'/'L12'/'L16'/'L18'/'L27') で渡すので、 未知の表名は
+--   型検査で弾かれる (文字列指定の実行時 error が無い)。 因子の水準数が表の列に割り当てられない
+--   場合のみ error。
+--   @taguchiDesignOA L12 specs@ で run 数を意図的に選ぶ、 @taguchiDesignOA L9 cats@ で 3 水準表を明示。
+taguchiDesignOA :: OATable -> [DesignFactor] -> Design
+taguchiDesignOA table specs =
+  let oa = oaTableToOA table
+  in case assignOAColumns oa specs of
+    Nothing   -> error
+      ("taguchiDesignOA: " ++ show table ++ " (列水準 " ++ show (oaLevels oa)
+        ++ ") に因子の水準構成 " ++ show (map factorLevelCount specs) ++ " を割り当てられません")
+    Just cols -> Design specs (oaToCodedCols oa cols specs) KFractional
+
+-- ---------------------------------------------------------------------------
+-- 最適計画 (optimal design) — Phase 78.G-b1
+--
+-- 標準の格子計画 (factorial/RSM) と違い、 **モデル formula** と **run 数 n** を先に決め、
+-- 候補点集合から情報行列 XᵀX の基準 (D/A/I/E/G) を最適化する n 点を選ぶ (Fedorov 交換)。
+-- run 数が制約されている・非標準のモデル項を当てたい・候補領域が不規則、 等で使う。
+--
+-- 実装は既存部品の**接着**で、 新規の数値アルゴは無い:
+--   1. 因子 specs から coded 候補グリッド 'candidateGrid' (各因子 [-1,1] の等間隔 levels 水準)。
+--   2. グリッド各点を DataFrame 化し 'modelFrame'+'designMatrixF' で **formula の設計行列 X 行**へ展開。
+--   3. 低レベル 'OPT.optimalDesign' (Fedorov) で n 行を選択 → 選択 index。
+--   4. 選ばれた候補の**因子座標**を 'dsCoded' に、 formula を 'KCustom' に焼き込む。
+-- 以降は 'designTable'/'designFrame'/'designModel' が factorial 等と同じ共通経路で処理する。
+--
+-- モデルは 'Formula' に一本化 (二重管理回避)。 入口は 2 つ:
+--   * 効果 DSL 'mainEffects'/'twoWay'/'quadratic' (対話向け・型付き)。
+--   * 文字列 RHS を 'parseRFormula' で 'Formula' に (アプリ/外部から組み立て)。
+-- ★連続因子のみ (v1)。 カテゴリ因子は 'DesignFactor' の水準リスト化 (型手術) を伴う G-b2。
+-- ---------------------------------------------------------------------------
+
+-- | 主効果のみモデル @y ~ x1 + x2 + …@ を作る効果 DSL。 応答は placeholder (@_y@)。
+--   'optimalDesign' のモデル引数に渡す。
+mainEffects :: [Text] -> Formula
+mainEffects names = effectFormula (T.intercalate " + " names)
+
+-- | 主効果 + 全 2 因子交互作用モデル @y ~ x1 + x2 + x1:x2 + …@ を作る効果 DSL。
+twoWay :: [Text] -> Formula
+twoWay names = effectFormula (T.intercalate " + " (names ++ twoWayTerms names))
+
+-- | 主効果 + 2 因子交互作用 + 2 次項モデル @y ~ … + I(x1^2) + …@ (RSM 相当) を作る効果 DSL。
+--   2 次項を含むので 'optimalDesign' の既定候補水準は 3 になる。
+quadratic :: [Text] -> Formula
+quadratic names =
+  effectFormula (T.intercalate " + " (names ++ twoWayTerms names ++ squareTerms names))
+
+-- | 全 2 因子交互作用の項 (@a:b@) を名前順に。
+twoWayTerms :: [Text] -> [Text]
+twoWayTerms names =
+  [ a <> ":" <> b | (i, a) <- zip [0 :: Int ..] names, b <- drop (i + 1) names ]
+
+-- | 各因子の 2 次項 (@I(x^2)@)。
+squareTerms :: [Text] -> [Text]
+squareTerms names = [ "I(" <> n <> "^2)" | n <- names ]
+
+-- ---------------------------------------------------------------------------
+-- Phase 78.M M3: Formula (効果 DSL) → Custom.Model ([ModelTerm]) 変換
+-- ---------------------------------------------------------------------------
+
+-- | 効果 DSL / 'Formula' を Custom Design 層の 'CMd.Model' ('mainEffects'/'twoWay'/
+--   'quadratic' 相当の @[ModelTerm]@) に変換する。 高レベル生成 API ('customDesign'・M4)
+--   が座標交換 ('coordinateExchangePure') に渡すモデルを組み立てる橋渡し。
+--
+--   効果 DSL の formula は 'parseRFormula' が各項に係数パラメータ @_pN@ を挿入した
+--   積和 (例 @_p0 + _p1·a + _p3·(a·b) + _p4·a^2@) になる。 加法分解した各項の
+--   **因子名を参照する葉だけ**を残して分類する (与えた @facNames@ に含まれる 'Ref' が因子・
+--   それ以外の 'Ref' は係数パラメータとして無視):
+--
+--     * 因子葉が 0 個 (係数のみ)          → 'CMd.TIntercept'
+--     * 単一 @Ref x@                        → 'CMd.TMain' x
+--     * 単一 @Bin Pow (Ref x) (Lit k)@      → 'CMd.TPower' x (round k)
+--     * 複数の @Ref@ の積                    → 'CMd.TInter' [x, y, …]
+--
+--   正規化は 'CMd.NCoded' (optimalDesign と同じ coded 規約)。 基底関数 (@opoly@/@bspline@)
+--   や未対応構造は @Left@ を返す (M3 スコープ = 主効果 / 2 因子交互作用 / 冪)。
+formulaToCustomModel :: [Text] -> Formula -> Either Text CMd.Model
+formulaToCustomModel facNames (Formula _ _ rhs) = do
+  terms <- mapM termToModelTerm (flattenAddW rhs)
+  Right (CMd.Model terms CMd.NCoded)
+  where
+    isFacRef (Ref x) = x `elem` facNames
+    isFacRef _       = False
+    -- 係数パラメータ葉 = facNames に無い裸の 'Ref' (parseRFormula が挿入する @_pN@)。
+    isParamLeaf (Ref x) = x `notElem` facNames
+    isParamLeaf _       = False
+    -- 積を葉分解し、 係数パラメータを除いた「中核」葉で項を分類する。
+    -- 中核が空 = 係数のみ = 切片。 それ以外は因子参照 (主効果/交互作用/冪)。
+    -- 中核に因子を参照しない葉 (基底関数 'App' 等) が残れば未対応 → Left。
+    termToModelTerm t =
+      case filter (not . isParamLeaf) (mulLeavesW t) of
+        []        -> Right CMd.TIntercept
+        [Ref x] | x `elem` facNames -> Right (CMd.TMain x)
+        [Bin Pow (Ref x) (Lit k)]
+          | x `elem` facNames && k >= 2 -> Right (CMd.TPower x (round k))
+        ls | not (null ls) && all isFacRef ls ->
+               Right (CMd.TInter [ x | Ref x <- ls ])
+        _ -> Left (T.pack
+               ("formulaToCustomModel: 未対応の項 (基底関数や非線形項は Custom.Model に"
+                <> " 変換できません・主効果/交互作用/冪のみ対応): " <> show t))
+
+-- | 加法項へ分解 (符号は係数に吸収されるので Add/Sub/Neg 同一視)。
+--   'Formula.Design.flattenAdd' と同義だが import 循環回避のため局所定義。
+flattenAddW :: Term -> [Term]
+flattenAddW (Bin Add a b) = flattenAddW a ++ flattenAddW b
+flattenAddW (Bin Sub a b) = flattenAddW a ++ flattenAddW b
+flattenAddW (Neg a)       = flattenAddW a
+flattenAddW t             = [t]
+
+-- | 乗法葉へ分解。 'Formula.Design.mulLeaves' と同義 (局所定義)。
+mulLeavesW :: Term -> [Term]
+mulLeavesW (Bin Mul a b) = mulLeavesW a ++ mulLeavesW b
+mulLeavesW (Neg a)       = mulLeavesW a
+mulLeavesW t             = [t]
+
+-- ---------------------------------------------------------------------------
+-- Phase 79: 高レベル生成 API (pure・座標交換 / 階層構造 / 制約)
+-- ---------------------------------------------------------------------------
+
+-- | 高レベル 'DesignFactor' を Custom Design 層の 'CF.Factor' に変換する。
+--   連続 = coded [-1,1] 前提の 'CF.Continuous'、 カテゴリ = 'CF.Categorical' (水準 index)。
+--   数値順序 ('Num') は **水準 index を grid とする** 'CF.DiscreteNum' @[0..k-1]@ に写す
+--   (Phase 78.M M4-b)。 これで座標交換の出力 (cdMatrix) が水準 index になり、 'dsCoded' の
+--   Num 規約 ('numLevelAt' = index → 実水準値) と一致する。 ★交換は index 尺度 (等間隔) で
+--   D-最適化する (実測不等間隔の直交多項式 opoly は Custom.Model 非対応ゆえ、 モデル項は
+--   ユーザ formula の I(x^2) 等がそのまま使われる)。
+--   ★役割 ('CF.fRole') は Phase 79 で高レベルから撤去したので一律 'CF.Controllable'
+--   (階層は因子ではなく 'Structure' が持つ。 fRole の去就は 79.2 の M-construction 移植時に判断)。
+toCustomFactor :: DesignFactor -> CF.Factor
+toCustomFactor f = CF.Factor (dfName f) (kindC (dfKind f)) CF.Controllable
+  where
+    kindC (Cont lo hi _) = CF.Continuous lo hi
+    kindC (Cat ls)     = CF.Categorical ls
+    kindC (Num levels) = CF.DiscreteNum (map fromIntegral [0 .. length levels - 1])
+
+-- | 因子 + formula から Custom Design の 'CX.CustomDesignSpec' を組み立てる共通処理 (M4)。
+--   モデルは 'formulaToCustomModel' で [ModelTerm] 化 (失敗は error)。 最適化基準 @crit@ と
+--   制約 @cons@ を受け取り、 budget は既定。 座標交換 / split-plot 生成の両方が使う。
+toCustomSpec :: OptCriterion -> [Constraint]
+             -> [DesignFactor] -> Formula -> Int -> Int -> CX.CustomDesignSpec
+toCustomSpec crit cons fs fml n seed =
+  case formulaToCustomModel (map dfName fs) fml of
+    Left e      -> error ("customDesign: モデル変換に失敗: " <> T.unpack e)
+    Right model -> CX.CustomDesignSpec
+      { CX.cdsFactors      = map toCustomFactor fs
+      , CX.cdsModel        = model
+      , CX.cdsConstraints  = cons
+      , CX.cdsNRuns        = n
+      , CX.cdsCriterion    = crit
+      , CX.cdsBudget       = CX.defaultBudget
+      , CX.cdsSeed         = Just seed
+      , CX.cdsInitial      = Nothing
+      , CX.cdsDJConvention = False
+      }
+
+-- | **完全カスタムデザインの生成** (pure・Phase 79)。 唯一の生成入口。 'CustomSpec' 1 本
+--   (因子 × 固定効果モデル × run 数 × seed × 基準 × 制約 × 'Structure') を受け取り、
+--   構造に応じた座標交換 (M / 制約 / 群単位ムーブ) を解いて 'Design' ('KStructured') に包む。
+--   **seed 決定的**な純粋関数 (同 seed → 同結果)。
+--
+--   > -- CRD (最小)
+--   > let plan = customDesign (customSpec fs (quadratic ["x1","x2"]) 12 42)
+--   >
+--   > -- 制約つき CRD
+--   > customDesign (customSpec fs fml 8 42)
+--   >   { csConstraints = [LinearIneq [("x1",1),("x2",1)] CLeq 0.5] }
+--   >
+--   > -- split-plot (temp が whole-plot) + 制約
+--   > customDesign (customSpec fs (twoWay ["temp","rate"]) 8 50)
+--   >   { csStructure = splitPlot ["temp"] 4, csConstraints = [RangeBound "rate" (-0.5) 1] }
+--
+--   ★制約の因子参照は **coded 単位** ([-1,1]) で書く (座標交換が coded 空間で解くため)。
+--   v1 未実装の構造は @unsupported structure@ で error。
+customDesign :: CustomSpec -> Design
+customDesign cs = case csStructure cs of
+  CRD ->
+    -- CRD は M=I・per-cell の高速路 ('coordinateExchangePure') へそのまま委譲 (ビット一致)。
+    let fs   = csFactors cs
+        spec = toCustomSpec (csCriterion cs) (effectiveConstraints cs) fs
+                            (csFormula cs) (csNRuns cs) (csSeed cs)
+    in case CX.coordinateExchangePure spec of
+         Left e   -> error (T.unpack (enrichInfeasError cs e))
+         Right cd -> Design fs (LA.toLists (CX.cdMatrix cd)) (KStructured [] (csFormula cs))
+  structure ->
+    -- 非自明な構造 (SplitPlot/StripPlot/Blocked) は Structure を GroupingPlan に
+    -- コンパイルし、 構造駆動エンジン ('structuredExchangePure') で群単位ムーブ + GLS 基準を解く。
+    let fs = csFactors cs
+    in case buildGrouping fs (csNRuns cs) structure of
+         Left e -> error ("customDesign: " <> T.unpack e)
+         Right (gplan, groups) ->
+           let spec = toCustomSpec (csCriterion cs) (effectiveConstraints cs) fs
+                                   (csFormula cs) (csNRuns cs) (csSeed cs)
+           in case ST.structuredExchangePure spec gplan of
+                Left e       -> error (T.unpack (enrichInfeasError cs e))
+                Right (m, _) -> Design fs (LA.toLists m) (KStructured groups (csFormula cs))
+
+-- | 'Structure' を構造駆動エンジンの 'GroupingPlan' (列ごと cells + M⁻¹) と
+--   出力用群列 @[(群列名, 各 run の群 ID)]@ にコンパイルする (Phase 79)。 CRD は
+--   別処理 ('customDesign' が高速路へ委譲) ゆえここでは扱わない。 v1 未実装構造は
+--   @unsupported structure@ で 'Left'。
+buildGrouping :: [DesignFactor] -> Int -> Structure
+              -> Either Text (ST.GroupingPlan, [(Text, [Int])])
+buildGrouping fs n structure = case structure of
+  CRD -> Left "buildGrouping: CRD は構造エンジンを使わない (内部エラー)"
+  SplitPlot whole nWhole eta col
+    | nWhole < 1 -> Left "whole-plot 数 (spNWhole) は 1 以上が必要"
+    | n < nWhole -> Left "run 数 n は whole-plot 数 (spNWhole) 以上が必要"
+    | null whole -> Left "split-plot に whole-plot 因子名 (spWhole) が空"
+    | not (all (`elem` names) whole) ->
+        Left ("whole-plot 因子名 "
+               <> T.pack (show (map T.unpack (filter (`notElem` names) whole)))
+               <> " が因子リストに無い")
+    | otherwise ->
+        let ids    = balancedGroupIds n nWhole
+            idsV   = VS.fromList ids
+            wpCols = [ j | (j, f) <- zip [0 :: Int ..] fs, dfName f `elem` whole ]
+            cells  = [ if j `elem` wpCols then groupCells ids nWhole else perRowCells n
+                     | j <- [0 .. length fs - 1] ]
+            mInv   = ST.buildMInvFromGroups n [(eta, idsV)]
+        in Right (ST.GroupingPlan cells mInv, [(col, ids)])
+  StripPlot whole nWhole etaW wCol strip nStrip etaS sCol
+    | nWhole < 1 || nStrip < 1 -> Left "strip-plot の whole-plot 数 / strip 数は 1 以上が必要"
+    | n /= nWhole * nStrip ->
+        Left ("strip-plot は n = stNWhole × stNStrip が必要 (n=" <> tshowW n
+               <> ", " <> tshowW nWhole <> "×" <> tshowW nStrip <> "=" <> tshowW (nWhole * nStrip) <> ")")
+    | null whole || null strip -> Left "strip-plot に whole-plot / strip 因子名が空"
+    | not (all (`elem` names) (whole ++ strip)) ->
+        Left ("strip-plot 因子名 "
+               <> T.pack (show (map T.unpack (filter (`notElem` names) (whole ++ strip))))
+               <> " が因子リストに無い")
+    | not (null (filter (`elem` strip) whole)) ->
+        Left "同一因子を whole-plot と strip の両方に指定できません"
+    | otherwise ->
+        let wpIds    = balancedGroupIds n nWhole          -- 行 i → i `div` nStrip
+            stripIds = [ i `mod` nStrip | i <- [0 .. n - 1] ]
+            wpCols   = [ j | (j, f) <- zip [0 :: Int ..] fs, dfName f `elem` whole ]
+            stCols   = [ j | (j, f) <- zip [0 :: Int ..] fs, dfName f `elem` strip ]
+            cells    = [ if j `elem` wpCols then groupCells wpIds nWhole
+                         else if j `elem` stCols then groupCells stripIds nStrip
+                         else perRowCells n
+                       | j <- [0 .. length fs - 1] ]
+            mInv     = ST.buildMInvFromGroups n
+                         [(etaW, VS.fromList wpIds), (etaS, VS.fromList stripIds)]
+        in Right (ST.GroupingPlan cells mInv, [(wCol, wpIds), (sCol, stripIds)])
+  Blocked nBlocks eta col
+    | nBlocks < 1 -> Left "ブロック数 (blkNBlocks) は 1 以上が必要"
+    | n < nBlocks -> Left "run 数 n はブロック数 (blkNBlocks) 以上が必要"
+    | otherwise ->
+        -- ランダムブロック: 全因子はブロック内で自由 (per-row cell)。 block は run 割付のみで
+        -- M = I + η·Z_B Z_Bᵀ に効く。 grouped 列は無い。
+        let ids   = balancedGroupIds n nBlocks
+            cells = replicate (length fs) (perRowCells n)
+            mInv  = ST.buildMInvFromGroups n [(eta, VS.fromList ids)]
+        in Right (ST.GroupingPlan cells mInv, [(col, ids)])
+  where names  = map dfName fs
+        tshowW = T.pack . show
+
+-- | n 行を k 群に均等割り当て (余りは先頭群に +1)。 各行 → 群 ID (0..k-1)。
+balancedGroupIds :: Int -> Int -> [Int]
+balancedGroupIds n k =
+  let base  = n `div` k
+      extra = n `mod` k
+      sizes = [ if i < extra then base + 1 else base | i <- [0 .. k - 1] ]
+  in concat [ replicate s i | (i, s) <- zip [0 ..] sizes ]
+
+-- | 群 ID リストから「同群の行集合」 の cells を作る (grouped 列用)。
+groupCells :: [Int] -> Int -> [[Int]]
+groupCells ids k = [ [ i | (i, g) <- zip [0 ..] ids, g == w ] | w <- [0 .. k - 1] ]
+
+-- | per-row cells (各行が独立の cell・CRD 因子 / sub-plot 因子用)。
+perRowCells :: Int -> [[Int]]
+perRowCells n = [ [i] | i <- [0 .. n - 1] ]
+
+-- | RHS 文字列 (R 構文) を placeholder 応答 @_y@ 付きで 'parseRFormula' に通す糖衣。
+--   効果 DSL は別モデル表現を作らず 'Formula' を組み立てるだけ (二重管理回避)。
+effectFormula :: Text -> Formula
+effectFormula rhs =
+  either (\e -> error ("effect DSL: formula parse error: " ++ e)) id
+         (parseRFormula ("_y ~ " <> rhs))
+
+-- | formula の RHS に 2 次以上の冪 (@Bin Pow@) が含まれるか。 候補水準の既定値
+--   (2 次項があれば 3 水準・無ければ 2 水準) を決めるのに使う。
+formulaHasPower :: Formula -> Bool
+formulaHasPower (Formula _ _ rhs) = go rhs
+  where
+    go (Bin Pow _ _) = True
+    go (Bin _ a b)   = go a || go b
+    go (Neg a)       = go a
+    go (Index a b)   = go a || go b
+    go (App _ as)    = any go as
+    go _             = False
+
+-- | D-最適計画 (既定基準 = 'DOpt'・seed = 42・候補水準は自動)。 @n@ = run 数 (必須)。
+--   @optimalDesign [contFactor "t" (150,180), contFactor "p" (1,5)] (quadratic ["t","p"]) 10@。
+--   候補水準は formula が 2 次項を含めば 3、 他は 2 (明示は 'optimalDesignLevels')。 カテゴリ因子
+--   ('catFactor') は候補格子で全水準を展開し、 設計行列で contrast 展開される。
+--   @n@ が formula のパラメータ数 @p@ 未満だと error (情報行列が特異)。
+optimalDesign :: [DesignFactor]              -- ^ 因子 ('contFactor' / 'catFactor')
+              -> Formula                     -- ^ モデル formula (効果 DSL / 'parseRFormula')
+              -> Int                         -- ^ run 数 n
+              -> Design
+optimalDesign = optimalDesignWith DOpt Nothing 42
+
+-- | 候補水準を明示する D-最適計画 (基準 = 'DOpt'・seed = 42)。 各連続因子 [-1,1] を @levels@
+--   水準に離散化した格子を候補集合にする (カテゴリ因子は水準数固定なので @levels@ の影響を受けない)。
+optimalDesignLevels :: Int                          -- ^ 候補格子の水準数 (各連続因子)
+                    -> [DesignFactor]
+                    -> Formula
+                    -> Int
+                    -> Design
+optimalDesignLevels levels = optimalDesignWith DOpt (Just levels) 42
+
+-- | 最適計画 (フル制御)。 基準 'OptCriterion' (D/A/I/E/G/Compound/BayesianD)、 候補水準
+--   (@Nothing@ = 自動)、 seed を明示。 @n@ = run 数 (必須・@n >= p@ 検査あり)。
+optimalDesignWith :: OptCriterion              -- ^ 最適化基準
+                  -> Maybe Int                 -- ^ 候補格子の水準数 (Nothing = 自動)
+                  -> Int                       -- ^ seed (初期選択)
+                  -> [DesignFactor]
+                  -> Formula
+                  -> Int                       -- ^ run 数 n
+                  -> Design
+optimalDesignWith crit mLevels seed fs fml n =
+  let lv   = maybe (if formulaHasPower fml then 3 else 2) id mLevels
+      grid = fullFactorial (map (factorCandidateLevels lv) fs)
+  in case candidateXRows fml fs grid of
+       Left err -> error ("optimalDesign: 設計行列の構築に失敗: " ++ err)
+       Right xRows ->
+         let p = if null xRows then 0 else length (head xRows)
+         in if n < p
+              then error
+                ("optimalDesign: run 数 n=" ++ show n
+                  ++ " がモデルのパラメータ数 p=" ++ show p
+                  ++ " 未満です (n >= p が必要・情報行列が特異になる)")
+              else let (idx, _) = OPT.optimalDesign crit xRows n seed
+                   in Design fs (map (grid !!) idx) (KCustom fml)
+
+-- | 因子の候補水準集合 (最適計画のグリッド)。 連続 = @[-1,1]@ を @lv@ 等間隔 (candidateGrid 相当)、
+--   カテゴリ = 水準 index @{0,…,m-1}@ (水準数固定・@lv@ 無関係)。 全連続なら 'candidateGrid' と一致。
+factorCandidateLevels :: Int -> DesignFactor -> [Double]
+factorCandidateLevels lv f = case dfKind f of
+  Cont _ _ _ -> evenSpaced lv
+  Num levels -> map fromIntegral [0 .. length levels - 1]
+  Cat levels -> map fromIntegral [0 .. length levels - 1]
+  where
+    evenSpaced n
+      | n <= 1    = [0]
+      | otherwise = [ -1 + 2 * fromIntegral i / fromIntegral (n - 1)
+                    | i <- [0 .. n - 1] :: [Int] ]
+
+-- | 候補グリッド (coded 因子座標) を formula の設計行列 X 行 (@[[Double]]@) に展開する。
+--   連続因子は coded 値のまま数値項に、 カテゴリ因子は水準名 (Text) 列にして contrast 展開させる。
+--   応答は placeholder 列を 0 埋め (設計行列は RHS のみに依存し応答値は無関係)。
+candidateXRows :: Formula -> [DesignFactor] -> [[Double]] -> Either String [[Double]]
+candidateXRows fml fs grid = do
+  mf     <- modelFrame fml (candidateFrame fml fs grid)
+  (x, _) <- designMatrixF fml mf
+  pure (LA.toLists x)
+
+-- | 候補グリッド (coded 座標行) を DataFrame 化 (応答 placeholder 列 + 各因子列)。
+--   連続 = coded Double 列、 カテゴリ = 水準名 Text 列 ('modelFrame' で factor 扱い)。
+candidateFrame :: Formula -> [DesignFactor] -> [[Double]] -> DX.DataFrame
+candidateFrame fml fs grid =
+  DX.fromNamedColumns $
+    (formResponse fml, DX.fromList (replicate (length grid) (0 :: Double)))
+      : [ factorFrameColumn Nothing False f [ row !! j | row <- grid ]
+        | (j, f) <- zip [0 :: Int ..] fs ]
+
+-- ---------------------------------------------------------------------------
+-- 取り出し
+-- ---------------------------------------------------------------------------
+
+designFactorNames :: Design -> [Text]
+designFactorNames = map dfName . dsFactors
+
+-- | 実行用 runsheet (uncoded 実値・**連続因子のみ**)。 先頭に @run@ 番号列、 続いて各因子の
+--   実値列。 戻り値は 'ColumnSource' なので @designTable plan |-> …@ / 表示に使える。
+--   ★カテゴリ因子を含む設計では数値 runsheet に水準名を出せないので **error** となる
+--   ('designFrame' を使うこと・Text 列を持つ整形表を返す)。
+designTable :: Design -> [(Text, [Double])]
+designTable (Design fs coded _) =
+  case [ dfName f | f <- fs, isCat (dfKind f) ] of
+    (c : _) -> error
+      ("designTable: カテゴリ因子 " ++ T.unpack c
+        ++ " は数値 runsheet に出せません。 designFrame を使ってください "
+        ++ "(Text 列を持つ整形表 DataFrame を返します)")
+    []      ->
+      ("run", map fromIntegral [1 .. length coded])
+        : [ (dfName f, [ tableCell f (row !! j) | row <- coded ])
+          | (j, f) <- zip [0 ..] fs ]
+  where
+    isCat (Cat _) = True
+    isCat _       = False
+    -- Cont は uncoded 実値、 Num は水準 index → 実水準値。 Cat は上でガード済。
+    tableCell f v = case dfKind f of
+      Num levels -> numLevelAt levels v
+      _          -> uncodeCont f v
+
+-- | runsheet を **整形表** (Hackage @DataFrame@) にする。 連続因子は uncoded 実値の Double 列、
+--   **カテゴリ因子は水準名の Text 列** として直接構築する (数値 'designTable' 経由でなく列ごと)。
+--   DataFrame は 'ColumnSource' ゆえ @designFrame plan |-> …@ もそのまま通り、 fit 側は
+--   Text 列を contrast 展開する。 @print (designFrame plan)@ で型付き ASCII テーブルを確認できる。
+designFrame :: Design -> DX.DataFrame
+designFrame (Design fs coded kind) =
+  DX.fromNamedColumns $
+    ("run", DX.fromList (map fromIntegral [1 .. length coded] :: [Double]))
+      : [ factorFrameColumn Nothing True f [ row !! j | row <- coded ]
+        | (j, f) <- zip [0 :: Int ..] fs ]
+      ++ splitGroupColumns kind
+
+-- | 実験者に渡す runsheet を **小数第 @nd@ 位に丸めて**返す ('designFrame' の桁数調整版)。
+--   応答曲面 (CCD) の軸点 (±α) など無理数由来の長い小数 (@143.78679656440357@) を
+--   @designFrameRound 2 plan@ で @143.79@ 等に整える。 連続 / 数値順序因子の実値列だけを丸め、
+--   run 番号 / カテゴリ (Text) / 群列はそのまま。 ★丸めた値がそのまま runsheet の値になる
+--   (実験は丸めた水準で行う想定)。 fit にそのまま載せてよい ('designFrame' 同様 'ColumnSource')。
+--
+--   > print (designFrameRound 2 (centralCompositeDesign [contFactor "temp" (150,180), …]))
+designFrameRound :: Int -> Design -> DX.DataFrame
+designFrameRound nd (Design fs coded kind) =
+  DX.fromNamedColumns $
+    ("run", DX.fromList (map fromIntegral [1 .. length coded] :: [Double]))
+      : [ factorFrameColumn (Just nd) True f [ row !! j | row <- coded ]
+        | (j, f) <- zip [0 :: Int ..] fs ]
+      ++ splitGroupColumns kind
+
+-- | 完全カスタムデザインの群列を Text ラベルで作る。 各群列は @<列名>0, <列名>1, …@
+--   のラベル (例: @wholePlot0@ / @strip0@ / @block0@)。 CRD (群列なし) や他種別は空。
+--   designModelHBM の grouping 列は getTextVec で読まれる (Text 必須) ため Text 化する。
+splitGroupColumns :: DesignKind -> [(Text, DX.Column)]
+splitGroupColumns (KStructured groups _) =
+  [ (col, DX.fromList (map (\i -> col <> T.pack (show i)) ids :: [Text]))
+  | (col, ids) <- groups ]
+splitGroupColumns _ = []
+
+-- | 因子 1 列を coded 座標列から DataFrame 列に。 連続因子は Double 列 (@uncodeC@=True なら
+--   uncoded 実値へ・'designFrame' 用、 False なら coded のまま・'candidateFrame' 用)、
+--   カテゴリ因子は水準 index を 'Cat' リストで引いた**水準名 Text 列** ('modelFrame' で factor 扱い)。
+--   @mRound = Just nd@ なら Double 列 (連続 / 数値順序) を小数第 @nd@ 位に丸める
+--   ('designFrameRound' 用)。 カテゴリ Text 列は丸めない。
+factorFrameColumn :: Maybe Int -> Bool -> DesignFactor -> [Double] -> (Text, DX.Column)
+factorFrameColumn mRound uncodeC f coded = case dfKind f of
+  Cont _ _ _ -> (dfName f, DX.fromList (map (rnd . contVal) coded))
+  Num levels -> (dfName f, DX.fromList (map (rnd . numLevelAt levels) coded))  -- 水準 index → 実値 Double
+  Cat levels -> (dfName f, DX.fromList (map (levelAt levels) coded :: [Text]))
+  where
+    contVal = if uncodeC then uncodeCont f else id
+    rnd     = maybe id roundTo mRound
+    levelAt levels v =
+      let i = round v
+      in if i >= 0 && i < length levels then levels !! i else "?"
+
+-- | 小数第 @n@ 位への丸め (負値・整数もそのまま)。 @roundTo 2 143.78679 = 143.79@。
+roundTo :: Int -> Double -> Double
+roundTo n x = fromIntegral (round (x * m) :: Integer) / m
+  where m = 10 ^^ n
+
+-- | 数値順序因子の coded (水準 index) を実水準値へ。 範囲外は NaN (呼び元は index を保証)。
+numLevelAt :: [Double] -> Double -> Double
+numLevelAt levels v =
+  let i = round v
+  in if i >= 0 && i < length levels then levels !! i else 0/0
+
+-- | 連続因子の coded 値 @c@ を uncoded 実値へ。 線形は @center + c·half@、 対数は
+--   @10^(logCenter + c·logHalf)@ (幾何)。 カテゴリ因子で呼ぶと error (呼び元が連続に
+--   限定して使う・'designTable' はガード済)。
+uncodeCont :: DesignFactor -> Double -> Double
+uncodeCont f c = case dfKind f of
+  Cont lo hi SLinear -> (lo + hi) / 2 + c * (hi - lo) / 2
+  Cont lo hi SLog    ->
+    let llo = logBase 10 lo; lhi = logBase 10 hi
+    in 10 ** ((llo + lhi) / 2 + c * (lhi - llo) / 2)
+  Num _      -> error ("uncodeCont: " ++ T.unpack (dfName f) ++ " は数値順序因子です (numLevelAt を使う)")
+  Cat _      -> error ("uncodeCont: " ++ T.unpack (dfName f) ++ " はカテゴリ因子です")
+
+-- | 連続因子の uncoded 実値 @x@ を coded 値へ ('uncodeCont' の逆)。 線形は @(x−center)/half@、
+--   対数は @(log10 x − logCenter)/logHalf@。 対数因子で @x <= 0@ は NaN (呼び元が正値を保証)。
+--   カテゴリ/数値順序因子で呼ぶと error。
+codeCont :: DesignFactor -> Double -> Double
+codeCont f x = case dfKind f of
+  Cont lo hi SLinear ->
+    let half = (hi - lo) / 2
+    in if half == 0 then 0 else (x - (lo + hi) / 2) / half
+  Cont lo hi SLog    ->
+    let llo = logBase 10 lo; lhi = logBase 10 hi
+        lhalf = (lhi - llo) / 2
+    in if lhalf == 0 then 0 else (logBase 10 x - (llo + lhi) / 2) / lhalf
+  Num _ -> error ("codeCont: " ++ T.unpack (dfName f) ++ " は数値順序因子です")
+  Cat _ -> error ("codeCont: " ++ T.unpack (dfName f) ++ " はカテゴリ因子です")
+
+-- ---------------------------------------------------------------------------
+-- 設計の保存 / DataFrame からの復元 (Phase 78.K)
+-- ---------------------------------------------------------------------------
+
+-- | 設計の **runsheet** ('designFrame') を CSV に書き出す。 実験者に渡す runsheet
+--   (uncoded 実値・run 番号列・カテゴリは水準名) がそのまま保存される。
+--
+--   > saveDesign "runsheet.csv" plan
+saveDesign :: FilePath -> Design -> IO ()
+saveDesign path = DXIO.writeCsv path . designFrame
+
+-- | **DataFrame から設計 ('Design') を復元**する。 因子 (名前 + 種類 + 範囲/水準) と
+--   モデル formula (効果 DSL 'mainEffects' / 'quadratic' 等 or 'parseRFormula') を明示し、
+--   @df@ の各因子列を coded 化して 'KCustom' 設計に包む。 CSV から読んだ runsheet を
+--   解析ワークフロー (@df |-> designModel plan "y"@ / 'rsmAnalysis') に載せ直すのに使う。
+--
+--   > let plan = planFromFrame [contFactor "temp" (150,180), contFactor "time" (10,20)]
+--   >                          (quadratic ["temp","time"]) loadedDf
+--   > filledDf |-> designModel plan "y"
+--
+--   ★fit は formula + df だけで動く ('designModel') が、 'rsmAnalysis' /
+--   'steepestAscentNatural' は coded 幾何を使うので、 因子の範囲/水準を正しく渡すこと。
+--   因子列が @df@ に無い / 型が合わない場合は error。
+planFromFrame :: [DesignFactor] -> Formula -> DX.DataFrame -> Design
+planFromFrame fs fml df =
+  let cols  = map (`factorCodedColumn` df) fs   -- 各因子の coded 列 (行順)
+      coded = transpose cols                    -- 行 = run、 列 = 因子
+  in Design fs coded (KCustom fml)
+
+-- | 因子 1 列を @df@ から取り出し coded 座標列にする。 連続 = @(x−center)/half@、
+--   数値順序 = 最近傍水準の index、 カテゴリ = 水準名の index (designFrame の逆変換)。
+factorCodedColumn :: DesignFactor -> DX.DataFrame -> [Double]
+factorCodedColumn f df = case dfKind f of
+  Cont _ _ _ -> [ codeCont f x | x <- doubles ]   -- 線形/対数は codeCont が分岐
+  Num levels -> [ fromIntegral (nearestIndex levels x) | x <- doubles ]
+  Cat levels -> [ fromIntegral (catIndex levels t)     | t <- texts ]
+  where
+    nm = dfName f
+    doubles = case getDoubleVec nm df of
+      Just v  -> V.toList v
+      Nothing -> error ("planFromFrame: 数値因子列 '" <> T.unpack nm <> "' が df に無い / 数値でない")
+    texts = case getTextVec nm df of
+      Just v  -> V.toList v
+      Nothing -> error ("planFromFrame: カテゴリ因子列 '" <> T.unpack nm <> "' が df に無い / Text でない")
+    nearestIndex levels x =
+      fst (minimumBy (comparing (\(_, l) -> abs (l - x))) (zip [0 :: Int ..] levels))
+    catIndex levels t = case elemIndex t levels of
+      Just i  -> i
+      Nothing -> error
+        ("planFromFrame: カテゴリ因子 '" <> T.unpack nm <> "' に水準 '" <> T.unpack t
+          <> "' が定義されていません (catFactor の水準: " <> show (map T.unpack levels) <> ")")
+
+-- | 設計種別からモデル formula 文字列を生成 (@y@ = 応答列名)。
+--   要因計画 = 全交互作用 (@y ~ x1 * x2 * …@)、 RSM = 2 次
+--   (@y ~ x1 + x2 + x1:x2 + I(x1^2) + I(x2^2)@)、 一部実施 = **主効果のみ**
+--   (@y ~ x1 + x2 + …@・交互作用は交絡ゆえ v1 は含めない)。
+--   最適計画 ('KCustom') は焼き込んだ formula の応答を @y@ に差し替えて返す
+--   (native 正規形。 'multiLMModel' は @~@ の有無で R/独自 front-end を自動判別する)。
+designFormula :: Design -> Text -> Text
+designFormula (Design fs _ kind) y =
+  let names = map dfName fs
+      k     = length names
+      inter = [ (names !! i) <> ":" <> (names !! j)
+              | i <- [0 .. k - 1], j <- [i + 1 .. k - 1] ]
+      sq    = [ "I(" <> n <> "^2)" | n <- names ]
+      -- 主効果項: 数値順序因子 ('Num') は直交多項式 opoly(name, 水準数−1)
+      -- (linear+quadratic…・実測間隔で直交)、 連続/カテゴリは主効果名 (Cat は engine が contrast 展開)。
+      mainTerm f = case dfKind f of
+        Num levels -> "opoly(" <> dfName f <> "," <> tshow (length levels - 1) <> ")"
+        _          -> dfName f
+  in case kind of
+    KCustom fml       -> prettyFormula (fml { formResponse = y })
+    KStructured _ fml -> prettyFormula (fml { formResponse = y })  -- 固定効果は KCustom 同様
+    KFactorial  -> y <> " ~ " <> T.intercalate " * " names
+    KRSM        -> y <> " ~ " <> T.intercalate " + " (names ++ inter ++ sq)
+    KFractional -> y <> " ~ " <> T.intercalate " + " (map mainTerm fs)  -- 主効果のみ
+    -- 主効果 + 主効果と交絡しない 2FI の代表 (交絡群ごと 1 個)。
+    KFracInter gens ->
+      let reps = [ (names !! (i - 1)) <> ":" <> (names !! (j - 1))
+                 | [i, j] <- clearTwoFactorInteractions k gens ]
+      in y <> " ~ " <> T.intercalate " + " (map mainTerm fs ++ reps)
+  where tshow = T.pack . show
+
+-- ---------------------------------------------------------------------------
+-- 応答曲面 解析 (自然単位で報告) — Phase 78.G-d
+-- ---------------------------------------------------------------------------
+--
+-- R rsm の要点は「fit を coded 空間でやる」ことではない (coded/uncoded fit は予測が
+-- 同一・単なる便宜)。 本質は **runsheet を自然単位で発行し、 スケール依存な最適化幾何
+-- (停留点 / canonical / steepest ascent) だけ内部で coded の計量を使い、 結果を自然単位で
+-- 報告する** ワークフロー。 ここではその報告レイヤを与える。
+--
+--   * fit そのものは 'designModel' が自然単位で行う (係数がそのまま実単位で読める)。
+--   * 一方、 停留点の方向・canonical 軸・steepest ascent 方向は計量依存なので、
+--     設計が保持する coded 行列 ('Design' の coded ±1/±α) で二次モデルを当て
+--     ('fitQuadratic')、 幾何を coded で解いてから 'uncodeCont' で自然単位へ decode する。
+
+-- | 応答曲面の停留点の性質 (canonical 固有値の符号で判定)。
+data RSMNature = RMaximum | RMinimum | RSaddle
+  deriving (Show, Eq)
+
+-- | 応答曲面 解析レポート。 停留点・予測は**自然単位**、 canonical 方向は coded 座標
+--   (設計の実験範囲を単位に取った軸) で報告する。
+data RSMReport = RSMReport
+  { rsmStationary :: ![(Text, Double)]
+    -- ^ 停留点 (因子名 → **自然単位**の値)。
+  , rsmPredicted  :: !Double
+    -- ^ 停留点での予測応答。
+  , rsmNature     :: !RSMNature
+    -- ^ 極大 / 極小 / 鞍点。
+  , rsmInRegion   :: !Bool
+    -- ^ 停留点が実験領域 (全因子 coded @|x| <= 1@) の内側か。 外なら外挿。
+  , rsmCanonical  :: ![(Double, [Double])]
+    -- ^ canonical: (固有値, coded 方向ベクトル) を固有値昇順で。 固有値の大きさ =
+    --   その方向の曲率 (負=下に凸で応答が落ちる方向 / 正=上に凸)。
+  , rsmR2         :: !Double
+    -- ^ 当てた二次モデルの R²。
+  } deriving (Show)
+
+-- | 設計 + 応答 @ys@ から応答曲面を解析し、 停留点・性質・canonical・R² を返す。
+--   二次モデルを設計の coded 行列で当て ('fitQuadratic')、 停留点を自然単位へ decode。
+--   **連続因子 (RSM 系設計) 専用** — カテゴリを含めば呼び名付きで error。
+--   @ys@ は runsheet ('designTable' / 'designFrame') と同じ run 順の応答値。
+rsmAnalysis :: Design -> [Double] -> RSMReport
+rsmAnalysis (Design fs0 coded _) ys =
+  let fs        = requireContinuous "rsmAnalysis" fs0  -- 連続専用ガード (error を強制)
+      qf        = fitQuadratic coded ys
+      (xC, yC, _) = optimumPoint qf
+      canon     = canonicalAnalysis qf
+      eigs      = map fst canon
+      nature
+        | all (< 0) eigs = RMaximum
+        | all (> 0) eigs = RMinimum
+        | otherwise      = RSaddle
+      inRegion  = all (\x -> abs x <= 1 + 1e-9) xC
+      stationary = [ (dfName f, uncodeCont f x) | (f, x) <- zip fs xC ]
+  in RSMReport
+       { rsmStationary = stationary
+       , rsmPredicted  = yC
+       , rsmNature     = nature
+       , rsmInRegion   = inRegion
+       , rsmCanonical  = canon
+       , rsmR2         = qfR2 qf
+       }
+
+-- | 自然単位の steepest ascent / descent 経路。 一次係数の勾配方向は計量依存なので、
+--   設計の coded 行列で当てた coefs で coded 空間の方向を取り (= 各因子の実験範囲を
+--   単位にした scale 不変な方向)、 生成した各点を 'uncodeCont' で自然単位へ decode する。
+--   実験者はこの自然単位の系列をそのまま次の試行に使える。
+--   @step@ は **coded スケール**の 1 歩幅 (例 0.5)、 @nSteps@ は歩数 (path 長 = nSteps+1)。
+--   **連続因子専用**。
+steepestAscentNatural
+  :: Bool                 -- ^ True = ascent (最大化) / False = descent
+  -> Design
+  -> [Double]             -- ^ 応答 @ys@ (run 順)
+  -> Double               -- ^ step (coded スケール)
+  -> Int                  -- ^ nSteps
+  -> [[(Text, Double)]]   -- ^ 経路。 各点 = [(因子名, 自然単位値)]、 先頭 = 設計中心
+steepestAscentNatural maximize (Design fs0 coded _) ys step nSteps =
+  let fs   = requireContinuous "steepestAscentNatural" fs0
+      qf   = fitQuadratic coded ys
+      k    = length fs
+      sar  = steepestAscentFromQuad maximize (replicate k 0) qf step nSteps
+  in [ [ (dfName f, uncodeCont f x) | (f, x) <- zip fs row ]
+     | row <- sarStepPoints sar ]
diff --git a/src/Hanalyze/Diagnostics.hs b/src/Hanalyze/Diagnostics.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Diagnostics.hs
@@ -0,0 +1,548 @@
+{-# LANGUAGE OverloadedStrings #-}
+-- |
+-- Module      : Hanalyze.Diagnostics
+-- Description : plot 非依存の回帰モデル係数診断 (点予測・係数要約・bootstrap・平滑項 F 検定)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- 回帰モデルの係数診断 (plot 非依存層)。
+--
+-- fit 済モデルを「数値として」 使う細粒度 API: 点予測・係数ベクトル・係数要約。
+-- 'Hanalyze.Plot' (cabal flag @plot-integration@ 配下) が描画
+-- ('VisualSpec' 化) を担うのに対し、 本モジュールは **hgg に依存しない**
+-- 係数統計 (t\/z・p 値・95% CI) のみを切り出したもの。 非ゲート (常時 build) なので
+-- 'df |-> spec' / 'coefSummary' が plot フラグ無しで使える。
+-- 'Hanalyze.Plot' は本モジュールを import し従来の名前で再 export する。
+module Hanalyze.Diagnostics
+  ( -- * モデル API 層 (描画と独立: predict / describe / coefficients)
+    Coef (..)
+  , ModelAPI (..)
+  , coefSummaryFromCov
+  , lmCoefCov
+    -- * 統一係数サマリ (t\/z・p 値・95% CI)
+  , CoefRow (..)
+  , HasCoefSummary (..)
+  , coefRowsLM
+  , coefRowsZ
+  , designCoefNames
+    -- * bootstrap 係数サマリ (quantile / penalized)
+  , HasCoefBoot (..)
+  , coefSummaryBoot
+  , bootCoefRows
+  , resampleRows
+    -- * 平滑項単位の近似有意性 (mgcv 流 edf + 近似 F) — Phase 72.2
+  , TermRow (..)
+  , HasTermSummary (..)
+  , termSummary
+  , gamTermRows
+    -- * 統一玄関 (.summary() 風) — Phase 72.3
+  , ModelReport (..)
+  , HasReport (..)
+  , modelReport
+  , showReport
+  ) where
+
+import           Data.List             (sort)
+import           Data.Text             (Text)
+import qualified Data.Text             as T
+import qualified Data.Vector           as V
+import           Data.Word             (Word32)
+import           Numeric               (showFFloat)
+import           Control.Monad         (replicateM)
+import           Control.Monad.ST      (runST)
+import           System.Random.MWC     (initialize, uniformR)
+import qualified Numeric.LinearAlgebra as LA
+
+import qualified Statistics.Distribution        as SD
+import           Statistics.Distribution.Normal (standard)
+import qualified Statistics.Distribution.FDistribution as FD
+
+import           Hanalyze.Model.Wrappers
+import           Hanalyze.Model.Core   (FitResult, coefficientsV, residualsV, fittedV)
+import           Hanalyze.Model.GAM    (GAMFit (..))
+import           Hanalyze.Model.Spline (SplineFit (..))
+import           Hanalyze.Model.GLM    (linkFnOf)
+import           Hanalyze.Model.GP     (GPResult)
+import           Hanalyze.Model.LM.Diagnostics (CoefStats (..), lmCoefStats, ciTValue)
+import           Hanalyze.Model.LM     (designMatrix)
+import           Hanalyze.Model.Quantile (QRFit (..), fitQuantile)
+import           Hanalyze.Model.Robust (RobustFit (..), robustCovBeta)
+import           Hanalyze.Model.Weibull (quantileNormal)
+import           Hanalyze.Model.Formula (Formula (..))
+
+-- ===========================================================================
+-- モデル API 層 (描画と独立: predict / describe / coefficients)
+--
+-- 'Plottable' (図にする) とは別の細粒度 class (god class 回避、 Core.hs §2.3 方針)。
+-- fit 済モデルを「数値として」 使う: 点予測・係数・要約。 Phase 16 §3 D。
+-- ===========================================================================
+
+-- | 係数 1 つの要約 (名前・推定値・標準誤差・95% Wald CI)。
+data Coef = Coef
+  { coefName  :: Text
+  , coefValue :: Double
+  , coefSE    :: Double
+  , coefCI    :: (Double, Double)
+  } deriving (Show, Eq)
+
+-- | fit 済モデルを描画と独立に使う細粒度 API。
+class ModelAPI m where
+  -- | 係数ベクトル (intercept 含む)。
+  modelCoefficients :: m -> [Double]
+  -- | 単一説明変数 x での点予測 (μ スケール。 GLM は逆リンク後)。
+  predictPoint      :: m -> Double -> Double
+  -- | 各係数の要約 (推定値 + SE + 95% Wald CI)。
+  describeModel     :: m -> [Coef]
+
+-- | 係数共分散 Cov と β から要約を作る (SE = √diag, 95% CI = β ± 1.96·SE)。
+coefSummaryFromCov :: LA.Matrix Double -> LA.Vector Double -> [Text] -> [Coef]
+coefSummaryFromCov cov beta names =
+  [ Coef nm b se (b - 1.96 * se, b + 1.96 * se)
+  | (nm, b, se) <- zip3 names (LA.toList beta)
+                       (map (sqrt . max 0) (LA.toList (LA.takeDiag cov))) ]
+
+-- | LM の係数共分散 = σ̂²·(XᵀX)⁻¹  (σ̂² = RSS/(n−p))。
+lmCoefCov :: LA.Matrix Double -> FitResult -> LA.Matrix Double
+lmCoefCov x res =
+  let r      = residualsV res
+      n      = LA.rows x
+      p      = LA.cols x
+      sigma2 = (r LA.<.> r) / fromIntegral (max 1 (n - p))
+  in LA.scale sigma2 (LA.inv (LA.tr x LA.<> x))
+
+instance ModelAPI LMModel where
+  modelCoefficients = LA.toList . coefficientsV . lmResult
+  predictPoint m x  =
+    let b = coefficientsV (lmResult m)
+    in LA.atIndex b 0 + (if LA.size b > 1 then LA.atIndex b 1 * x else 0)
+  describeModel m   =
+    coefSummaryFromCov (lmCoefCov (lmDesign m) (lmResult m))
+                       (coefficientsV (lmResult m)) ["(Intercept)", "x"]
+
+instance ModelAPI GLMModel where
+  modelCoefficients = LA.toList . coefficientsV . glmResult
+  predictPoint m x  =
+    let b            = coefficientsV (glmResult m)
+        (_, gInv, _) = linkFnOf (glmLink m)
+        eta          = LA.atIndex b 0 + (if LA.size b > 1 then LA.atIndex b 1 * x else 0)
+    in gInv eta
+  describeModel m   =
+    coefSummaryFromCov (glmSigma m) (coefficientsV (glmResult m)) ["(Intercept)", "x"]
+
+-- ===========================================================================
+-- 統一係数サマリ (CoefRow / HasCoefSummary) — Phase 70.D
+--
+-- 'Coef' (推定値 + SE + 95% CI) では回帰の係数表に不足する **検定統計量 (t / z) と
+-- p 値** を加えた 1 行型。 OLS 系 (LM / 重回帰 / WLS) は t 分布 (df = n − p)、
+-- GLM / RLM は正規 (z) で推論する (= statsmodels @OLS.summary()@ は t、 @GLM@ /
+-- @RLM@ は z。 ★statsmodels 突合済 Phase 70.D)。
+-- ===========================================================================
+
+-- | 統一係数サマリの 1 行。 検定統計量 'crStat' は OLS 系で t 値、 GLM\/RLM で z 値。
+data CoefRow = CoefRow
+  { crTerm     :: !Text              -- ^ 係数名 (@\"(Intercept)\"@ / 変数名)。
+  , crEstimate :: !Double            -- ^ 点推定 β̂。
+  , crStdErr   :: !Double            -- ^ 標準誤差 SE。
+  , crStat     :: !Double            -- ^ Wald 統計量 β̂\/SE (t or z)。
+  , crPValue   :: !Double            -- ^ 両側 p 値。
+  , crCI95     :: !(Double, Double)  -- ^ 95% 信頼区間。
+  } deriving (Show, Eq)
+
+-- | fit 済モデルの統一係数サマリ。 @coefSummary model@ で全係数の表を得る。
+class HasCoefSummary m where
+  coefSummary :: m -> [CoefRow]
+
+-- | OLS 経路の係数行 (t 分布・df = n − p)。 'lmCoefStats' (SE\/t\/p) を再利用し、
+--   95% CI は @β̂ ± t_{0.975, df}·SE@。 WLS は √w スケール設計を渡せば正しい。
+coefRowsLM :: [Text] -> LA.Matrix Double -> FitResult -> [CoefRow]
+coefRowsLM names x res =
+  let stats = lmCoefStats x res
+      betas = LA.toList (coefficientsV res)
+      df    = LA.rows x - LA.cols x
+      tc    = ciTValue 0.95 df
+  in [ CoefRow nm b (csSE s) (csTValue s) (csPValue s)
+               (b - tc * csSE s, b + tc * csSE s)
+     | (nm, b, s) <- zip3 names betas stats ]
+
+-- | z 経路の係数行 (正規・GLM\/RLM)。 共分散 Cov から SE = √diag、 z = β̂\/SE、
+--   両側 p = @2·(1 − Φ(|z|))@、 95% CI = @β̂ ± z_{0.975}·SE@。
+coefRowsZ :: [Text] -> LA.Vector Double -> LA.Matrix Double -> [CoefRow]
+coefRowsZ names beta cov =
+  let ses = map (sqrt . max 0) (LA.toList (LA.takeDiag cov))
+      zc  = quantileNormal 0.975
+  in [ CoefRow nm b se z (2 * SD.complCumulative standard (abs z))
+               (b - zc * se, b + zc * se)
+     | (nm, b, se) <- zip3 names (LA.toList beta) ses
+     , let z = if se == 0 then 0 else b / se ]
+
+-- | 設計列数に合わせた係数名 (@\"(Intercept)\" : 変数名@)。 加法数値モデル
+--   ('additiveFormula' \/ 単純 @y ~ x1 + x2@) では列数が @1 + |dvars|@ と一致するので
+--   変数名をそのまま使う。 factor\/交互作用で列数が増える場合は総称名へフォールバック。
+designCoefNames :: Int -> [Text] -> [Text]
+designCoefNames p dvars
+  | length dvars == p - 1 = "(Intercept)" : dvars
+  | otherwise             = "(Intercept)" : [ "x" <> T.pack (show i) | i <- [1 .. p - 1] ]
+
+instance HasCoefSummary LMModel where
+  coefSummary m = coefRowsLM ["(Intercept)", "x"] (lmDesign m) (lmResult m)
+
+instance HasCoefSummary MultiLMModel where
+  coefSummary m =
+    coefRowsLM (designCoefNames (LA.cols (mlmDesign m)) (formDataVars (mlmFormula m)))
+               (mlmDesign m) (mlmResult m)
+
+instance HasCoefSummary WeightedLMModel where
+  coefSummary m =
+    let inner = wlmInner m
+    in coefRowsLM ["(Intercept)", "x"] (lmDesign inner) (lmResult inner)
+
+instance HasCoefSummary GLMModel where
+  coefSummary m =
+    coefRowsZ ["(Intercept)", "x"] (coefficientsV (glmResult m)) (glmSigma m)
+
+instance HasCoefSummary MultiGLMModel where
+  coefSummary m =
+    coefRowsZ (designCoefNames (LA.cols (mglmSigma m)) (formDataVars (mglmFormula m)))
+              (coefficientsV (mglmResult m)) (mglmSigma m)
+
+instance HasCoefSummary RobustModel where
+  coefSummary m =
+    let fit = rmFit m
+        xd  = designMatrix (V.fromList (LA.toList (rmXraw m)))
+        cov = robustCovBeta (rfEstimator fit) (rfScale fit) (rfResiduals fit) xd
+    in coefRowsZ ["(Intercept)", "x"] (rfCoef fit) cov
+
+instance HasCoefSummary MultiRobustModel where
+  coefSummary m =
+    let fit = mrmFit m
+        cov = robustCovBeta (rfEstimator fit) (rfScale fit) (rfResiduals fit) (mrmDesign m)
+    in coefRowsZ (designCoefNames (LA.cols (mrmDesign m)) (formDataVars (mrmFormula m)))
+                 (rfCoef fit) cov
+
+-- ===========================================================================
+-- bootstrap 係数サマリ (HasCoefBoot) — Phase 72.1
+--
+-- 解析的 SE を持たないモデル (分位点回帰) や、 罰則化で解析 SE が定義し難い
+-- モデル (Lasso / Ridge 等) に対し、 **case (行) bootstrap** で係数の
+-- 不確実性を要約する。 返り値は 'coefSummary' と同型の '[CoefRow]' だが、
+--
+--   * 'crStdErr' = B 回再標本化した係数の標本 SD
+--   * 'crStat'   = β̂ \/ SE_boot
+--   * 'crPValue' = percentile 法の両側 p 値 (符号反転割合の 2 倍)
+--   * 'crCI95'   = percentile 区間 (numpy.percentile type-7 線形補間)
+--
+-- seed 固定 + ST + MWC なので **純粋・再現可能**。
+-- ===========================================================================
+
+-- | bootstrap 係数サマリを持つモデル。 @coefSummaryBoot seed B model@ で
+--   B 回の case bootstrap による係数表を得る。
+class HasCoefBoot m where
+  -- | @coefSummaryBoot seed B model@: 乱数 seed と replicate 回数 B からサマリ。
+  coefSummaryBoot :: Word32 -> Int -> m -> [CoefRow]
+
+-- | seed → B → n から、 B 個の「@n@ 個の @[0, n)@ 一様乱数 index リスト」 を作る。
+--   case (行) bootstrap の再標本化 index。 ST + MWC で純粋・seed 固定で再現可能。
+resampleRows :: Word32 -> Int -> Int -> [[Int]]
+resampleRows seed b n
+  | n <= 0 || b <= 0 = []
+  | otherwise = runST $ do
+      g <- initialize (V.singleton seed)
+      replicateM b (replicateM n (uniformR (0, n - 1) g))
+
+-- | numpy.percentile (type-7・線形補間) 互換。 @q@ は 0〜100。
+percentileT7 :: [Double] -> Double -> Double
+percentileT7 xs q =
+  let sorted = sort xs
+      n      = length sorted
+  in case n of
+       0 -> 0 / 0   -- 空は NaN (呼び元は非空を保証)
+       1 -> head sorted
+       _ -> let rank = q / 100 * fromIntegral (n - 1)
+                lo   = floor rank :: Int
+                hi   = min (n - 1) (lo + 1)
+                frac = rank - fromIntegral lo
+            in (sorted !! lo) * (1 - frac) + (sorted !! hi) * frac
+
+clamp01 :: Double -> Double
+clamp01 = max 0 . min 1
+
+-- | (係数名, 点推定 β̂, B 個の replicate β) から係数表を作る。
+--   各係数 j について SE = 標本 SD (n−1 除算・要素 1 未満は 0)、
+--   p 値 = @clamp01 (2 · min(#{<0}\/B, #{>0}\/B))@、 CI = percentile [2.5, 97.5]。
+bootCoefRows
+  :: [Text]      -- ^ 係数名。
+  -> [Double]    -- ^ 点推定 β̂ (長さ = 係数数)。
+  -> [[Double]]  -- ^ B 個の replicate β (各長さ = 係数数)。
+  -> [CoefRow]
+bootCoefRows names point reps =
+  [ let ests   = [ rep !! j | rep <- reps ]
+        bN     = length ests
+        meanE  = sum ests / fromIntegral (max 1 bN)
+        var    = if bN < 2 then 0
+                 else sum [ (e - meanE) ^ (2 :: Int) | e <- ests ]
+                        / fromIntegral (bN - 1)
+        se     = sqrt var
+        stat   = if se == 0 then 0 else est / se
+        ci     = if null ests then (est, est)
+                 else (percentileT7 ests 2.5, percentileT7 ests 97.5)
+        nNeg   = length (filter (< 0) ests)
+        nPos   = length (filter (> 0) ests)
+        pv     = if bN == 0 then 1
+                 else clamp01 (2 * min (fromIntegral nNeg / fromIntegral bN)
+                                       (fromIntegral nPos / fromIntegral bN))
+    in CoefRow nm est se stat pv ci
+  | (j, nm, est) <- zip3 [0 ..] names point ]
+
+-- τ ラベルを @ (τ=0.50) @ 形式で作る。
+tauLabel :: Double -> Text
+tauLabel tau = " (τ=" <> T.pack (showFFloat (Just 2) tau "") <> ")"
+
+-- QuantileModel 系の共通 bootstrap: 設計行列 X (intercept 列込み)・応答 y・
+-- τ リスト・係数名 (intercept 込み) から、 τ ごとに行 (係数 × τ) を並べた表。
+quantileBootRows
+  :: Word32 -> Int
+  -> LA.Matrix Double          -- ^ 設計行列 X (= [1, x..])。
+  -> LA.Vector Double          -- ^ 応答 y。
+  -> [Double]                  -- ^ τ リスト。
+  -> [(Double, LA.Vector Double)]  -- ^ (τ, 点推定 qfBeta)。
+  -> [Text]                    -- ^ 係数名 (intercept 込み)。
+  -> [CoefRow]
+quantileBootRows seed b xMat y taus pointBetas names =
+  let n        = LA.rows xMat
+      idxSets  = resampleRows seed b n
+      -- 各 replicate で τ ごとの β を計算: replBetas !! r !! tIdx = [β...]
+      replBetas =
+        [ let xr = xMat LA.? idxs
+              yr = LA.fromList [ y `LA.atIndex` i | i <- idxs ]
+          in [ LA.toList (qfBeta (fitQuantile t xr yr)) | t <- taus ]
+        | idxs <- idxSets ]
+  in concat
+       [ let repsForTau = [ rb !! tIdx | rb <- replBetas ]
+             pt         = LA.toList beta
+             namesTau   = [ nm <> tauLabel tau | nm <- names ]
+         in bootCoefRows namesTau pt repsForTau
+       | (tIdx, (tau, beta)) <- zip [0 ..] pointBetas ]
+
+instance HasCoefBoot QuantileModel where
+  coefSummaryBoot seed b m =
+    let taus  = map fst (qmFits m)
+        x     = qmXraw m
+        xMat  = designMatrix (V.fromList (LA.toList x))
+        -- y を head fit の qfYHat + qfResid から復元。
+        (_, fit0) = head (qmFits m)
+        y     = qfYHat fit0 + qfResid fit0
+        pts   = [ (t, qfBeta f) | (t, f) <- qmFits m ]
+    in quantileBootRows seed b xMat y taus pts ["(Intercept)", "x"]
+
+instance HasCoefBoot MultiQuantileModel where
+  coefSummaryBoot seed b m =
+    let taus  = mqmTaus m
+        xMat  = mqmX m
+        (_, fit0) = head (mqmFits m)
+        y     = qfYHat fit0 + qfResid fit0
+        pts   = [ (t, qfBeta f) | (t, f) <- mqmFits m ]
+        names = "(Intercept)" : mqmNames m
+    in quantileBootRows seed b xMat y taus pts names
+
+-- ===========================================================================
+-- 平滑項単位の近似有意性 (TermRow / HasTermSummary) — Phase 72.2
+--
+-- 回帰の係数表 ('CoefRow') が「基底係数 1 つ 1 つ」 を並べるのに対し、 平滑項
+-- (GAM / spline の @s(x)@) は **項全体** の有意性を見たい。 mgcv @summary.gam@
+-- は項ごとに **有効自由度 edf** と **近似 F 検定** (Wood 2013 の rank-r 擬似逆
+-- Wald 統計量) を報告する。 本セクションはそれに倣う。
+--
+--   * GAM (罰則付き ridge) は **近似**: 項 j の edf = sub-trace、 F は
+--     rank-r 擬似逆 Wald。 r = round(edf_j) を 1..m_j にクランプ。
+--   * Spline (罰則なし OLS) は **厳密** nested F (曲線 vs 定数)。
+-- ===========================================================================
+
+-- | 平滑項 1 つの近似有意性。 'teEdf' は有効自由度、 'teStat' は近似 F、
+--   'tePValue' は上側 @F(r, dfRes)@ の確率。
+--   注: フィールド prefix は @te@ (term)。 @tr@ は 'Hanalyze.Stat.Test'
+--   の @TestResult@ が占有しており、 plot umbrella での再 export 衝突を避けるため。
+data TermRow = TermRow
+  { teTerm   :: !Text    -- ^ 平滑項名 (@\"s(x)\"@ / @\"s(<name>)\"@)。
+  , teEdf    :: !Double  -- ^ 有効自由度 edf。
+  , teStat   :: !Double  -- ^ 近似 F 統計量。
+  , tePValue :: !Double  -- ^ 上側確率 (近似 p 値)。
+  } deriving (Show, Eq)
+
+-- | fit 済の平滑モデルの「項単位」 近似有意性。 @termSummary model@ で各平滑項の
+--   edf + 近似 F + p 値の表を得る。
+class HasTermSummary m where
+  termSummary :: m -> [TermRow]
+
+-- | GAM の項単位サマリ (mgcv 流 edf + rank-r 擬似逆 Wald 近似 F)。
+--   名前は呼び出し側が与える (@length = length gamBetas@)。
+--
+--   設計列レイアウト: intercept=0、 項 j は @starts!!j .. starts!!j+mSizes!!j-1@。
+--   ★基底の再評価は不要 — 'GAMFit' に格納済の @gamBetas \/ gamCov \/ gamEdf \/
+--   gamLambda \/ gamResid@ のみから算出する。
+gamTermRows :: GAMFit -> [Text] -> [TermRow]
+gamTermRows fit names =
+  let resid  = gamResid fit
+      n      = LA.size resid
+      rss    = resid LA.<.> resid
+      dfRes  = fromIntegral n - gamEdf fit
+      phi    = if dfRes > 1e-9 then rss / dfRes else rss
+      cov    = gamCov fit                     -- Vβ = (XᵀX+λP)⁻¹·φ̂
+      p      = LA.rows cov
+      lhsInv = LA.scale (1 / phi) cov         -- (XᵀX+λP)⁻¹
+      lhs    = LA.scale phi (LA.inv cov)      -- XᵀX+λP
+      pen    = LA.diag (LA.fromList (0 : replicate (p - 1) (gamLambda fit)))
+      xtx    = lhs - pen                       -- XᵀX
+      fMat   = lhsInv LA.<> xtx                -- edf 行列 (XᵀX+λP)⁻¹ XᵀX
+      fDiag  = LA.toList (LA.takeDiag fMat)
+      betas  = gamBetas fit
+      mSizes = map LA.size betas
+      starts = scanl (+) 1 mSizes              -- intercept は index 0
+      dfResI = max 1 (round dfRes) :: Int
+  in [ termRowFromBlock nm (betas !! j) vBlock edfJ dfResI
+     | (j, nm) <- zip [0 ..] names
+     , let cols  = [ starts !! j .. starts !! j + mSizes !! j - 1 ]
+           edfJ  = sum [ fDiag !! k | k <- cols ]
+           vBlock = (cov LA.¿ cols) LA.? cols ]   -- cols×cols 共分散ブロック
+
+-- | 1 項の rank-r 擬似逆 Wald 統計量から 'TermRow' を作る (mgcv Wood 2013 流)。
+--   r = clamp (round edf) 1 (dim β_j)。 V_j を対称固有分解し上位 r 固有対で
+--   Vr⁻ = Σ_{i≤r} (u_i u_iᵀ)/λ_i。 Tr = β_jᵀ Vr⁻ β_j、 F = Tr / r。
+termRowFromBlock :: Text -> LA.Vector Double -> LA.Matrix Double -> Double -> Int -> TermRow
+termRowFromBlock nm beta vBlock edf dfResI =
+  let mj   = LA.size beta
+      r    = max 1 (min mj (round edf))
+      (evals, evecs) = LA.eigSH (LA.trustSym vBlock)  -- 降順固有値・列が固有ベクトル
+      evalL = LA.toList evals
+      cols  = LA.toColumns evecs
+      -- 上位 r 固有対で擬似逆 Vr⁻ = Σ (u uᵀ)/λ  (λ≤0 は除外)
+      vrInv = sum [ LA.scale (1 / lam) (LA.outer u u)
+                  | (i, (lam, u)) <- zip [0 :: Int ..] (zip evalL cols)
+                  , i < r, lam > 1e-12 ]
+      vrInvM = if null [ () | (i, lam) <- zip [0 ..] evalL, i < r, lam > 1e-12 ]
+                 then LA.konst 0 (mj, mj)
+                 else vrInv
+      tr    = beta LA.<.> (vrInvM LA.#> beta)
+      fstat = if r > 0 then tr / fromIntegral r else 0
+      pv    = SD.complCumulative (FD.fDistribution r dfResI) fstat
+  in TermRow nm edf fstat pv
+
+instance HasTermSummary GAMModel where
+  -- gamModel は名前を持たないので "s(x)" 固定 (項は 1 つ)。
+  termSummary m = gamTermRows (gamFit m) ["s(x)"]
+
+instance HasTermSummary GAMModelN where
+  termSummary m =
+    gamTermRows (gamNFit m) (map (\nm -> "s(" <> nm <> ")") (gamNNames m))
+
+instance HasTermSummary SplineModel where
+  -- 罰則なし OLS ゆえ厳密 nested F (spline 曲線 vs 定数)。 基底は intercept 列を
+  -- 含む (B-spline は partition-of-unity で定数を張る・自然3次は明示の "1" 列)
+  -- ので、 定数 null モデルは 1 パラメータ。 df_num = p1−1、 dfRes = n−p1。
+  termSummary m =
+    let fit   = splFit m
+        res   = sfResult fit
+        resid = residualsV res
+        yhat  = fittedV res
+        y     = yhat + resid            -- 観測値の復元 (y = ŷ + r)
+        n     = LA.size resid
+        rss1  = resid LA.<.> resid
+        yMean = LA.sumElements y / fromIntegral (max 1 n)
+        rss0  = LA.sumElements (LA.cmap (\v -> (v - yMean) ^ (2 :: Int)) y)
+        p1    = LA.size (sfBeta fit)
+        dfNum = max 1 (p1 - 1)
+        dfRes = max 1 (n - p1)
+        fstat = if rss1 <= 1e-300 || dfRes <= 0 then 0
+                else ((rss0 - rss1) / fromIntegral dfNum)
+                       / (rss1 / fromIntegral dfRes)
+        pv    = SD.complCumulative (FD.fDistribution dfNum dfRes) fstat
+    in [ TermRow "s(x)" (fromIntegral dfNum) fstat pv ]
+
+-- ===========================================================================
+-- 統一玄関 (.summary() 風) — Phase 72.3
+--
+-- 既存の 'coefSummary' (Wald・'HasCoefSummary')・'coefSummaryBoot' (bootstrap・
+-- 'HasCoefBoot')・'termSummary' (項有意性・'HasTermSummary') を **1 つのタグ付き
+-- 直和** 'ModelReport' でラップし、 モデル型ごとに適切な診断へディスパッチする
+-- 玄関。 statsmodels の @.summary()@ に相当する「とりあえずこれを呼べば要約が出る」
+-- 入口を提供する (どの診断が該当するかをモデル型側が知っている)。
+-- ===========================================================================
+
+-- | モデル要約レポート。 係数表 (Wald も bootstrap も同じ箱) / 平滑項有意性 /
+--   該当なし (理由文付き) のタグ付き直和。
+data ModelReport
+  = CoefReport [CoefRow]   -- ^ 係数表 (Wald 'coefSummary' か bootstrap 'coefSummaryBoot')。
+  | TermReport [TermRow]   -- ^ GAM\/spline の平滑項有意性 'termSummary'。
+  | NoReport   Text        -- ^ 係数診断が非該当 (理由文)。
+  deriving (Show, Eq)
+
+-- | fit 済モデルの統一要約玄関。 @modelReport model@ で型に応じた 'ModelReport' を得る。
+class HasReport m where
+  modelReport :: m -> ModelReport
+
+-- 4 桁固定の数値整形 (符号付き)。
+fmt4 :: Double -> String
+fmt4 x = showFFloat (Just 4) x ""
+
+-- 右詰めパディング (width 12)。
+padR :: Int -> String -> Text
+padR w s = T.pack (replicate (max 0 (w - length s)) ' ' <> s)
+
+-- 左詰めパディング (width w)。
+padL :: Int -> String -> Text
+padL w s = T.pack (s <> replicate (max 0 (w - length s)) ' ')
+
+-- | 'ModelReport' を @.summary()@ 風のテキスト表へ整形する。
+--
+--   * 'CoefReport': ヘッダ @term \/ estimate \/ std.err \/ stat \/ p.value \/ [2.5%, 97.5%]@
+--     + 各 'CoefRow' を固定幅で整列 (4 桁)。
+--   * 'TermReport': ヘッダ @term \/ edf \/ F \/ p.value@ + 各 'TermRow'。
+--   * 'NoReport': 理由文をそのまま返す。
+showReport :: ModelReport -> Text
+showReport (NoReport msg) = msg
+showReport (CoefReport rows) =
+  T.unlines (header : map row rows)
+  where
+    header = padL 20 "term" <> padR 12 "estimate" <> padR 12 "std.err"
+               <> padR 12 "stat" <> padR 12 "p.value" <> "   " <> "[2.5%, 97.5%]"
+    row (CoefRow tm est se st pv (lo, hi)) =
+      padL 20 (T.unpack tm) <> padR 12 (fmt4 est) <> padR 12 (fmt4 se)
+        <> padR 12 (fmt4 st) <> padR 12 (fmt4 pv)
+        <> "   [" <> T.pack (fmt4 lo) <> ", " <> T.pack (fmt4 hi) <> "]"
+showReport (TermReport rows) =
+  T.unlines (header : map row rows)
+  where
+    header = padL 20 "term" <> padR 12 "edf" <> padR 12 "F" <> padR 12 "p.value"
+    row (TermRow tm edf st pv) =
+      padL 20 (T.unpack tm) <> padR 12 (fmt4 edf) <> padR 12 (fmt4 st)
+        <> padR 12 (fmt4 pv)
+
+-- --- ディスパッチ instance ---------------------------------------------------
+
+-- Wald 係数表 (HasCoefSummary 経由)
+instance HasReport LMModel        where modelReport m = CoefReport (coefSummary m)
+instance HasReport MultiLMModel   where modelReport m = CoefReport (coefSummary m)
+instance HasReport WeightedLMModel where modelReport m = CoefReport (coefSummary m)
+instance HasReport GLMModel       where modelReport m = CoefReport (coefSummary m)
+instance HasReport MultiGLMModel  where modelReport m = CoefReport (coefSummary m)
+instance HasReport RobustModel    where modelReport m = CoefReport (coefSummary m)
+instance HasReport MultiRobustModel where modelReport m = CoefReport (coefSummary m)
+
+-- bootstrap 係数表 (HasCoefBoot 経由・既定 seed=42 / B=2000)
+instance HasReport QuantileModel where
+  modelReport m = CoefReport (coefSummaryBoot 42 2000 m)
+instance HasReport MultiQuantileModel where
+  modelReport m = CoefReport (coefSummaryBoot 42 2000 m)
+
+-- 平滑項有意性 (HasTermSummary 経由)
+instance HasReport GAMModel    where modelReport m = TermReport (termSummary m)
+instance HasReport GAMModelN   where modelReport m = TermReport (termSummary m)
+instance HasReport SplineModel where modelReport m = TermReport (termSummary m)
+
+-- GP 系: 線形係数を持たない → 非該当
+gpNoReport :: ModelReport
+gpNoReport = NoReport
+  "ガウス過程は線形係数を持たない (ハイパーパラメータのみ)。係数診断は非該当。"
+
+instance HasReport GPResult     where modelReport _ = gpNoReport
+instance HasReport GPRegModel   where modelReport _ = gpNoReport
+instance HasReport GPRegModelN  where modelReport _ = gpNoReport
diff --git a/src/Hanalyze/Fit.hs b/src/Hanalyze/Fit.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Fit.hs
@@ -0,0 +1,1867 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RankNTypes #-}
+{-# LANGUAGE TypeFamilies #-}
+{-# LANGUAGE FlexibleContexts #-}
+{-# LANGUAGE FlexibleInstances #-}
+{-# LANGUAGE UndecidableInstances #-}
+-- |
+-- Module      : Hanalyze.Fit
+-- Description : plot 非依存の df |-> spec 統一 fit 演算子と各種モデル spec 型
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- データ源 → モデル学習層 (= @df |-> spec@ の plot 非依存部分)。
+--
+-- 'Hanalyze.Plot' から **図 ('VisualSpec') に依存しない**「データ源から
+-- モデルを当てはめる」部分 (各 @*Spec@ 型・'Fit' instance・'(|->)' / '(|->!)'
+-- 演算子・それらの smart ctor) をここへ切り出した非ゲートモジュール (常時 build)。
+-- これにより @df |-> lm "x" "y"@ 等が cabal flag @plot-integration@ を on にせず
+-- とも使える (Phase 71.3)。
+--
+-- ⚠ 本モジュールは 'Hgg.Plot' を一切 import しない (= 図に依存する toPlot /
+-- Plottable instance 等は 'Hanalyze.Plot' 側に残す)。 'Fit' クラス本体・
+-- ラッパ型・reqColV/reqColsM 等は 'Hanalyze.Model.Wrappers' から取り込む。
+module Hanalyze.Fit
+  ( -- * df |-> spec 統一 fit 演算子 (Phase 51)
+    (|->)
+  , (|->!)
+    -- * 二変量近道 spec (列名 2 つ) — Phase 51.2
+  , LMSpec (..)
+  , lm
+  , GLMSpec (..)
+  , glm
+  , SplineSpec (..)
+  , spline
+  , RobustSpec (..)
+  , rlm
+  , QuantileSpec (..)
+  , rq
+    -- * 重回帰 spec (列名リスト・formula 不要) — Phase 70.D
+  , LMMultiSpec (..)
+  , lmMulti
+  , GLMMultiSpec (..)
+  , glmMulti
+  , RobustMultiSpec (..)
+  , rlmMulti
+  , QuantileMultiSpec (..)
+  , rqMulti
+    -- * formula 多変量 spec (R 流) — Phase 51.3
+  , LMFormulaSpec (..)
+  , lmF
+  , GLMFormulaSpec (..)
+  , glmF
+  , GLMMFormulaSpec (..)
+  , glmmF
+    -- * DOE ワークフロー (Phase 78・設計オブジェクト + designModel)
+  , Design (..)
+  , DesignFactor (..)
+  , FactorKind (..)
+  , FactorScale (..)
+  , DesignKind (..)
+  , contFactor
+  , contFactorLog
+  , numFactor
+  , catFactor
+  , CustomSpec (..)
+  , customSpec
+  , customDesign
+  , Structure (..)
+  , splitPlot
+  , stripPlot
+  , blocked
+  , Constraint (..)
+  , ConstraintRel (..)
+  , ConstraintGuard (..)
+  , FactorValue (..)
+  , NatConstraint (..)
+  , natLeq
+  , natGeq
+  , natEq
+  , natForbid
+  , formulaToCustomModel
+  , factorialDesign
+  , centralCompositeDesign
+  , boxBehnkenDesign
+  , Resolution (..)
+  , resNum
+  , fractionalDesign
+  , fractionalDesignGen
+  , fractionalDesignInter
+  , fractionalDesignGenInter
+  , fractionalCatalog
+  , fracResolution
+  , aliasStructure
+  , OATable (..)
+  , taguchiDesign
+  , taguchiDesignOA
+  , OptCriterion (..)
+  , optimalDesign
+  , optimalDesignWith
+  , optimalDesignLevels
+  , mainEffects
+  , twoWay
+  , quadratic
+  , designTable
+  , designFrame
+  , designFrameRound
+  , designFactorNames
+  , designFormula
+  , RSMNature (..)
+  , RSMReport (..)
+  , rsmAnalysis
+  , steepestAscentNatural
+  , saveDesign
+  , planFromFrame
+  , DesignModelSpec (..)
+  , designModel
+  , DesignModelGPSpec (..)
+  , designModelGP
+  , ranIntercept
+  , ranSlope
+  , designHBMProgram
+  , DesignHBMFit (..)
+  , DesignModelHBMSpec (..)
+  , designModelHBM
+    -- * 多出力 (複数応答) fit コンビネータ — Phase 78.F
+  , MultiOutputSpec (..)
+  , multiOutput
+  , modelFor
+    -- * 重み付き最小二乗 (WLS) spec — Phase 52.A6
+  , WeightedLMSpec (..)
+  , weighted
+  , weightedR2
+    -- * 透過標準化ラッパ (自動逆変換) — Phase 70.3 項目 C
+  , StandardizedSpec (..)
+  , standardized
+  , standardizedY
+    -- * 群別フィット spec — Phase 52.A4
+  , GroupedSpec (..)
+  , grouped
+  , groupModels
+  , groupLabels
+    -- * GAM 高レベル spec (基底一般化 + GCV) — Phase 70.6 F3
+  , GAMConfig (..)
+  , defaultGAMConfig
+  , GAMSpec (..)
+  , gam
+  , gamMulti
+    -- * カーネル法 (GP / KRR / RFF) spec — Phase 70.5 項目 E
+  , GPConfig (..)
+  , defaultGP
+  , GPSpec
+  , gp
+  , GPMultiSpec
+  , gpMulti
+    -- * 罰則付き回帰 統合 spec — Phase 70.7 項目 G
+  , LambdaStrat (..)
+  , RegConfig (..)
+  , defaultRidge
+  , defaultLasso
+  , RegSpec
+  , regularized
+  , regularizedMulti
+  , ridge
+  , ridgeMulti
+  , lasso
+  , lassoMulti
+  , elasticNet
+  , elasticNetMulti
+    -- * 行列入力モデルの高レベル spec — Phase 70.A
+  , PCASpec (..)
+  , pca
+    -- * MDS (多次元尺度構成法) — Phase 75.21
+  , MDSSpec (..)
+  , mds
+  , MDSConfig (..)
+  , MDSMethod (..)
+  , defaultMDS
+  , PLSSpec (..)
+  , pls
+  , LDASpec (..)
+  , lda
+  , CCASpec (..)
+  , ccaOf
+    -- * 教師あり ML 分類器/回帰器 spec — Phase 70.A
+  , GBRSpec (..)
+  , gbmReg
+  , GBCSpec (..)
+  , gbmCls
+  , DTSpec (..)
+  , decisionTree
+  , KNNCSpec (..)
+  , knnCls
+  , KNNRSpec (..)
+  , knnReg
+  , NBSpec (..)
+  , naiveBayes
+    -- * seed 純粋化した RNG モデル spec (KMeans / RandomForest) — Phase 70.A
+  , KMeansSpec (..)
+  , kmeans
+  , RFSpec (..)
+  , randomForestReg
+    -- * 因果探索 LiNGAM (高レベル df|-> ・Phase 77)
+  , DirectLiNGAMSpec (..)
+  , directLingam
+  , ParceLiNGAMSpec (..)
+  , parceLingam
+  , MultiGroupLiNGAMSpec (..)
+  , multiGroupLingam
+  , VARLiNGAMSpec (..)
+  , varLingam
+  , PairwiseLiNGAMSpec (..)
+  , pairwiseLingam
+  , BootstrapLiNGAMSpec (..)
+  , bootstrapLingam
+  , ICALiNGAMSpec (..)
+  , icaLingam
+  , CorrelationSpec (..)
+  , correlationOf
+  , CorrelationGraph (..)
+  , RFCSpec (..)
+  , randomForestCls
+    -- * SVM / 古典 MLP 高レベル spec (純粋・df |->) — Phase 75.9
+  , MLPClsSpec (..)
+  , mlpCls
+  , MLPRegSpec (..)
+  , mlpReg
+  , SVMSpec (..)
+  , svmCls
+    -- * HBM spec — Phase 51.4
+  , HBMSpec
+  , hbm
+  ) where
+
+import           Data.List             (elemIndex, nub, sort, sortBy, transpose)
+import qualified Data.Map.Strict       as Map
+import           Data.Maybe            (fromMaybe)
+import           Data.Ord              (comparing)
+import           Data.Word             (Word32)
+import qualified Data.Vector           as V
+import qualified Data.Vector.Unboxed    as VU
+import           System.Random.MWC     (initialize)
+import           Control.Monad.ST      (runST)
+import qualified Numeric.LinearAlgebra as LA
+
+import           Data.Text             (Text)
+import qualified Data.Text             as T
+
+import           Hanalyze.Data.ColumnSource     (ColumnSource (..))
+import           Hanalyze.DataIO.Convert        (getTextVec)
+import           Hanalyze.DataIO.Preprocess     (dropMissingRows)
+import           Hanalyze.Model.Formula         (Formula (..))
+import           Hanalyze.Model.Formula.Mixed  (fitMixedLME, RandomSpec (..))
+import           Hanalyze.Model.Formula.Frame   (ModelFrame (..), modelFrame)
+import           Hanalyze.Model.Formula.Design  (designMatrixF, responseVec)
+import           Hanalyze.Model.Formula.RFormula (parseModel)
+import           Hanalyze.Model.GLMM           (GLMMResultRE (..), buildGroups)
+import           Hanalyze.MCMC.Core             (chainVals)
+
+import           Hanalyze.Model.Wrappers
+import           Hanalyze.Diagnostics
+import           Hanalyze.Model.Core   (coefficientsV)
+import           Hanalyze.Model.GLM    ( Family (..), LinkFn (..) )
+import           Hanalyze.Model.GP     (GPResult (..), Kernel (..), GPParams (..))
+import qualified Hanalyze.Model.GP     as GP
+import qualified Hanalyze.Model.RFF    as RFF
+import qualified Debug.Trace                  as Trace
+import           Hanalyze.Model.LM     ( designMatrix, fitLMVec )
+import           Hanalyze.Model.Spline ( SplineKind (..) )
+import           Hanalyze.Model.GAM    ( fitGAMAuto
+                                              , GAMBasis (..), GAMLambda (..) )
+import           Hanalyze.Model.Robust ( RobustEstimator )
+import           Hanalyze.Stat.CorrelationNetwork (CorrelationGraph (..), correlationMatrix)
+import           Hanalyze.Design.Workflow (Design (..), DesignFactor (..), FactorKind (..)
+                                       , DesignKind (..), FactorScale (..), contFactor, contFactorLog, numFactor, catFactor
+                                       , CustomSpec (..), customSpec, customDesign
+                                       , Structure (..), splitPlot, stripPlot, blocked
+                                       , Constraint (..), ConstraintRel (..), ConstraintGuard (..), FactorValue (..)
+                                       , NatConstraint (..), natLeq, natGeq, natEq, natForbid
+                                       , formulaToCustomModel
+                                       , factorialDesign, centralCompositeDesign, boxBehnkenDesign
+                                       , Resolution (..), resNum, fractionalDesign, fractionalDesignGen
+                                       , fractionalDesignInter, fractionalDesignGenInter
+                                       , fractionalCatalog, fracResolution, aliasStructure
+                                       , OATable (..), taguchiDesign, taguchiDesignOA
+                                       , OptCriterion (..), optimalDesign, optimalDesignWith, optimalDesignLevels
+                                       , mainEffects, twoWay, quadratic
+                                       , designTable, designFrame, designFrameRound
+                                       , designFactorNames, designFormula
+                                       , RSMNature (..), RSMReport (..), rsmAnalysis, steepestAscentNatural
+                                       , saveDesign, planFromFrame)
+-- 低レベル 'Hanalyze.Model.PCA.pca' は高レベル spec 'pca' (= PCASpec) と
+-- 同名衝突するため qualified import で回避する (Phase 75.16)。
+import           Hanalyze.Model.PCA     (PCAStandardize (..))
+import qualified Hanalyze.Model.PCA     as PCALow
+import           Hanalyze.Stat.Standardize
+                   ( Standardizer (..), fitStandardizer, applyStandardizer
+                   , applyStandardizerCol )
+import           Hanalyze.Model.RandomForest (RandomForest (..), RFConfig (..), fitRFVPure)
+import           Hanalyze.Model.LiNGAM.Direct (DirectLiNGAMConfig (..), DirectLiNGAMFit (..)
+                                       , fitDirectLiNGAM)
+import           Hanalyze.Model.LiNGAM.Parce (ParceConfig, ParceFit (..), fitParceLiNGAM)
+import           Hanalyze.Model.LiNGAM.MultiGroup (MultiGroupConfig, MultiGroupFit (..)
+                                       , fitMultiGroupLiNGAM)
+import           Hanalyze.Model.LiNGAM.VAR (VARLiNGAMConfig, VARLiNGAMFit (..), fitVARLiNGAM)
+import           Hanalyze.Model.LiNGAM.Pairwise (PairwiseResult (..), pairwiseLiNGAM)
+import           Hanalyze.Model.LiNGAM.Bootstrap (BootstrapConfig, BootstrapResult (..)
+                                       , fitBootstrapLiNGAMPure)
+import           Hanalyze.Model.LiNGAM.ICA (ICALiNGAMConfig, ICALiNGAMFit (..), fitICALiNGAMPure)
+import           Hanalyze.Model.RandomForestClassifier (RFClassifierFit (..), RFCConfig (..), fitRFClassifierPure)
+import           Hanalyze.Model.GradientBoosting (GBRegressor (..), GBClassifier (..)
+                                       , fitGBRegressor, fitGBClassifier, GBConfig (..))
+import           Hanalyze.Model.DecisionTree (DTree (..), DTFit (..), fitDTV, DTConfig (..))
+import           Hanalyze.Model.Discriminant (DiscriminantFit (..), fitLDA)
+import           Hanalyze.Model.Multivariate (cca, CCAFit (..))
+import           Hanalyze.Model.NaiveBayes (NBModel (..), GaussianNB (..), fitGNB)
+import           Hanalyze.Model.KNN (KNNClassifier (..), KNNRegressor (..), fitKNNC, fitKNNR)
+import           Hanalyze.Model.SVM (SVMConfig (..), SVMMulti (..)
+                                       , fitSVMMulti
+                                       , SVMHyper (..)
+                                       , SVMTuneGrid (..), tuneSVM)
+import           Hanalyze.Model.NeuralNetwork (MLPConfig (..), MLPFit (..)
+                                       , fitMLPClassifierPure, fitMLPRegressorPure)
+import           Hanalyze.Model.PLS (PLSFit (..), fitPLS, PLSConfig (..))
+import           Hanalyze.Model.Cluster (KMeansResult (..)
+                                       , KMeansConfig (..), kMeansPure)
+import           Hanalyze.Model.Quantile (fitQuantile)
+import           Hanalyze.Model.Regularized (RegFit (..),
+                                           fitRidge, fitLasso, fitElasticNet)
+import qualified Hanalyze.Model.RegularizedAdvanced as RA
+import qualified Hanalyze.Stat.CV          as HCV
+import           Hanalyze.Model.PCA     (PCAResult (..))
+import           Hanalyze.Model.MDS     (MDSConfig (..), MDSMethod (..)
+                                               , defaultMDS, MDSResult, runMDS)
+import           Hanalyze.Model.HBM     (ModelP, dataSlots, dataIxSlots,
+                                                 Distribution (Normal, HalfNormal),
+                                                 sample, observe, reNormal, at, observeLMR,
+                                                 lkjCorrCholesky, deterministic,
+                                                 plateI, plateI_, plateForM_, (.#),
+                                                 REff (..),
+                                                 LMFamily (LMGaussian))
+
+-- ---------------------------------------------------------------------------
+-- GAM 高レベル (df|->) — Phase 70.6 F3
+--
+-- 'gamModel' は単変量・B-spline 固定・λ 手動だった。 'gam' / 'gamMulti' は **基底を選べ**
+-- (B-spline / 自然3次 / 多項 / Fourier / RBF)、 **λ を GCV 自動選択**でき、 'lm'/'lmMulti'
+-- と対称な命名で受ける (@df |-> gam cfg "x" "y"@ / @df |-> gamMulti cfg ["x1","x2"] "y"@)。 描画は第1予測子
+-- 軸の平滑曲線 (多予測子では他を訓練平均に固定した偏依存曲線・band 非提供は GAM 共通)。
+-- ---------------------------------------------------------------------------
+
+-- | GAM の設定。 全項共通の基底 'gcBasis' と λ 方略 'gcLambda'。
+data GAMConfig = GAMConfig
+  { gcBasis  :: GAMBasis   -- ^ 各平滑項に使う基底 (全項共通)。
+  , gcLambda :: GAMLambda  -- ^ ridge λ の決め方 ('FixedL' / 'GCV')。
+  } deriving (Show)
+
+-- | 既定設定: 3 次 B-spline・内部 6 ノット・λ は GCV 自動選択。
+defaultGAMConfig :: GAMConfig
+defaultGAMConfig = GAMConfig (BSplineB 3 6) GCV
+
+-- | GAM spec (単/多予測子)。 @gam cfg "x" "y"@ または @gamMulti cfg ["x1","x2"] "y"@。
+data GAMSpec = GAMSpec !GAMConfig ![Text] !Text
+
+-- | @gam cfg xCol yCol@ — 単一予測子 GAM ('lm' と対)。
+gam :: GAMConfig -> Text -> Text -> GAMSpec
+gam cfg xn yn = GAMSpec cfg [xn] yn
+
+-- | @gamMulti cfg xCols yCol@ — 多予測子 GAM ('lmMulti' と対)。 第1予測子を描画軸にする。
+gamMulti :: GAMConfig -> [Text] -> Text -> GAMSpec
+gamMulti = GAMSpec
+
+instance Fit GAMSpec where
+  type Fitted GAMSpec = GAMModelN
+  fitEither (GAMSpec cfg xns yn) d = do
+    xs <- mapM (`reqColV` d) xns
+    y  <- reqColV yn d
+    let xss   = map (V.fromList . LA.toList) xs
+        yv    = V.fromList (LA.toList y)
+        bases = [ gcBasis cfg | _ <- xns ]
+        fit   = fitGAMAuto bases (gcLambda cfg) xss yv
+    pure GAMModelN { gamNFit = fit, gamNXraws = xs, gamNNames = xns }
+
+-- ===========================================================================
+-- df |-> spec 統一 fit API (Phase 51)
+-- ===========================================================================
+--
+-- 任意のデータ源 ('ColumnSource') から spec が指すモデルを学習する単一の動詞。
+-- 新規の数値核は無く、 既存の fit 関数 (lmModel / glmModel / …) を列抽出で
+-- 配線するだけの薄いエルゴノミック層。
+--
+-- * 二変量近道 (列名2つ): @lm "x" "y"@ / @glm fam link "x" "y"@ 等 (本節・51.2)。
+-- * formula 多変量 (R 流): @lmF "y ~ x1 + x2"@ 等 (Phase 51.3)。
+-- * HBM (手書き model): @hbm cfg model@ (Phase 51.4)。
+
+-- | @df |-> spec = fitWith spec df@。 plot の @|>>@ と同列 (@infixl 1@)。
+infixl 1 |->
+(|->) :: (ColumnSource d, Fit spec) => d -> spec -> Fitted spec
+d |-> spec = fitWith spec d
+
+-- | @df |->! spec = fitIO spec df@ (進捗つき IO 学習動詞・Phase 61.4)。
+-- 純粋動詞 '(|->)' との違いは**副作用 (進捗表示) の有無が動詞の選択に現れる**
+-- こと: HBM のような重い学習は @|->!@ で stderr に進捗 1 行が出る。
+-- 結果は同 cfg の @|->@ と**ビット一致** (seed 規約共有)。
+infixl 1 |->!
+(|->!) :: (ColumnSource d, Fit spec) => d -> spec -> IO (Fitted spec)
+d |->! spec = fitIO spec d
+
+-- --- 二変量近道 spec (列名 x, y) — Phase 51.2 -------------------------------
+
+-- | 単回帰 spec。 @lm "x" "y"@。
+data LMSpec       = LMSpec       !Text !Text
+-- | 二変量 GLM spec。 @glm fam link "x" "y"@。
+data GLMSpec      = GLMSpec      !Family !LinkFn !Text !Text
+-- | 二変量 spline spec。 @spline kind knots "x" "y"@。
+data SplineSpec   = SplineSpec   !SplineKind ![Double] !Text !Text
+-- | 二変量 robust spec。 @robust est "x" "y"@。
+data RobustSpec   = RobustSpec   !RobustEstimator !Text !Text
+-- | 分位点回帰 spec。 @quantile taus "x" "y"@。
+data QuantileSpec = QuantileSpec ![Double] !Text !Text
+
+-- | @lm xCol yCol@ — 単回帰 ('LMModel') を当てはめる spec。
+lm :: Text -> Text -> LMSpec
+lm = LMSpec
+
+-- | @glm fam link xCol yCol@ — 二変量 GLM ('GLMModel') の spec。
+glm :: Family -> LinkFn -> Text -> Text -> GLMSpec
+glm = GLMSpec
+
+-- | @spline kind knots xCol yCol@ — spline 回帰 ('SplineModel') の spec。
+spline :: SplineKind -> [Double] -> Text -> Text -> SplineSpec
+spline = SplineSpec
+
+-- | @rlm est xCol yCol@ — ロバスト回帰 ('RobustModel') の spec (R @MASS::rlm@)。
+rlm :: RobustEstimator -> Text -> Text -> RobustSpec
+rlm = RobustSpec
+
+-- | @rq taus xCol yCol@ — 分位点回帰 ('QuantileModel') の spec (R @quantreg::rq@)。
+rq :: [Double] -> Text -> Text -> QuantileSpec
+rq = QuantileSpec
+
+instance Fit LMSpec where
+  type Fitted LMSpec = LMModel
+  fitEither (LMSpec xn yn) d = lmModel <$> reqColV xn d <*> reqColV yn d
+  predictorCols (LMSpec xn _) = [xn]
+  responseCol   (LMSpec _ yn) = Just yn
+
+
+-- --- 重み付き最小二乗 (WLS) spec — Phase 52.A6 -----------------------------
+--
+-- WLS は各観測に重み @wᵢ@ を付けて @Σ wᵢ(yᵢ − ŷᵢ)²@ を最小化する (不等分散・
+-- 観測の信頼度差に。 ggplot @geom_smooth(method=lm, aes(weight=w))@ 相当)。
+-- 数値核 = √w スケール OLS: @X_w = diag(√w)·X@, @y_w = diag(√w)·y@ を OLS すると
+-- β̂ = (XᵀWX)⁻¹XᵀWy・s² = Σ wᵢ(yᵢ−x̂ᵢ)²/(n−p) が得られる。
+--
+-- ★描画の整合: grid 評価 ('svGrid' → 'confidenceBandAt') は評価点を**非スケール**
+-- @[1, gx]@ で渡すので、 設計行列を**スケール済** ('lmDesign'=X_w) にしておけば
+-- @se = t·√(s²·x₀ᵀ(XᵀWX)⁻¹x₀)@ ＝ 正しい WLS pointwise CI が元 x スケールで出る。
+-- 一方 'LMModel' の素の 'toPlot' (訓練点経路 'confidenceBand') は評価点がスケール済
+-- 行になり中心も se も √wᵢ 倍に壊れる。 ゆえに専用ラッパ 'WeightedLMModel' を設け
+-- 'toPlot' を **grid 経路 ('statModel')** に固定する (= 元データ散布図と整合)。
+
+-- | 重み付き最小二乗 spec。 @weighted "w" (lm "x" "y")@ (重みは **df の列名**・全て @≥ 0@)。
+data WeightedLMSpec = WeightedLMSpec !Text !LMSpec
+
+-- | @weighted wCol (lm xCol yCol)@ — 重み**列** @wCol@ で WLS を当てはめる spec ラッパ。
+--   重みは x / y と同一 'ColumnSource' の列名で受ける (statsmodels @wls(…, weights=col)@ /
+--   R @weights=@ と同型)。 現状 'LMSpec' 専用 (WLS は LM 固有。 GLM の重みは別軸ゆえ対象外)。
+weighted :: Text -> LMSpec -> WeightedLMSpec
+weighted = WeightedLMSpec
+
+instance Fit WeightedLMSpec where
+  type Fitted WeightedLMSpec = WeightedLMModel
+  fitEither (WeightedLMSpec wn (LMSpec xn yn)) d = do
+    xs <- reqColV xn d
+    ys <- reqColV yn d
+    wv <- reqColV wn d
+    let n  = LA.size xs
+        ws = LA.toList wv
+    if length ws /= n
+      then Left ("weighted: 重み列 " <> show wn <> " の長さ " <> show (length ws)
+                  <> " が観測数 " <> show n <> " と一致しません")
+      else if any (< 0) ws
+        then Left "weighted: 重みに負値が含まれます (全て ≥ 0 が必要)"
+        else
+          let sw  = map sqrt ws                                       -- √w (長さ n)
+              dm0 = designMatrix (V.fromList (LA.toList xs))          -- [1, x] 非スケール
+              dmW = LA.fromRows (zipWith LA.scale sw (LA.toRows dm0)) -- diag(√w)·X
+              yW  = LA.fromList (zipWith (*) sw (LA.toList ys))       -- √w·y
+              res = fitLMVec dmW yW
+          in Right WeightedLMModel
+               { wlmInner   = LMModel { lmDesign = dmW, lmResult = res, lmXraw = xs }
+               , wlmWeights = ws
+               , wlmY       = LA.toList ys
+               }
+
+-- | 重み付き R²。 statsmodels WLS @rsquared@ と一致: @1 − Σwᵢ(yᵢ−ŷᵢ)² / Σwᵢ(yᵢ−ȳ_w)²@
+--   (中心化は重み付き平均 @ȳ_w = Σwᵢyᵢ/Σwᵢ@)。 ★スケール空間の素朴な R² とは中心化が
+--   異なる (= 内側 LM の 'svCoefR2' をそのまま使うと不一致になる罠)。
+weightedR2 :: [Double] -> [Double] -> [Double] -> Double
+weightedR2 ws ys yhats =
+  let sw   = sum ws
+      ybar = sum (zipWith (*) ws ys) / sw
+      ssr  = sum (zipWith3 (\w y yh -> w * (y - yh) ^ (2 :: Int)) ws ys yhats)
+      tss  = sum (zipWith  (\w y    -> w * (y - ybar) ^ (2 :: Int)) ws ys)
+  in 1 - ssr / tss
+
+-- --- 透過標準化ラッパ (自動逆変換) — Phase 70.3 項目 C ----------------------
+--
+-- スケール敏感なモデル (とくに距離ベース kNN) は予測子の z-score 標準化が要る。
+-- 'standardized' / 'standardizedY' は spec を**透過的に**ラップし、 学習は標準化空間で
+-- 行いつつ図・予測は元スケールへ自動逆変換する (tidymodels @step_normalize@ /
+-- sklearn @Pipeline@ 相当)。 標準化対象列は spec 自身が 'predictorCols' / 'responseCol'
+-- で返すので**列名を二重に書かない**。 内部標準化済 (GP/Reg/PCA/PLS) や木系は
+-- @predictorCols = []@ ゆえラッパが 'Left' で誤用を弾く (= 二重標準化バグ回避)。
+
+-- | spec を透過標準化でラップする (@Bool@ = 応答 y も標準化するか)。
+--   構成子は直接使わず 'standardized' / 'standardizedY' から作る。
+data StandardizedSpec spec = StandardizedSpec !Bool !spec
+
+-- | @standardized spec@ — **予測子列のみ** z-score 標準化して内側 @spec@ を学習し、
+--   描画・予測は**元スケール**で返す透過ラッパ。 距離ベース kNN
+--   (@knnReg@ / @knnCls@) が本命。 標準化対象列は spec の 'predictorCols' から
+--   取得する (列名を二重に書かない)。 被せる意味の無い spec
+--   (内部標準化済 GP\/Reg\/PCA\/PLS・スケール不変な木系) は @predictorCols = []@
+--   ゆえ 'fitEither' が 'Left' で誤用を弾く。
+--
+--   @df |-> standardized (knnReg 5 [\"x1\",\"x2\"] \"y\")@
+standardized :: spec -> StandardizedSpec spec
+standardized = StandardizedSpec False
+
+-- | @standardizedY spec@ — 予測子 **+ 応答 y** を標準化する版 (多目的最適化向け・
+--   opt-in)。 y 列名は spec の 'responseCol' から取得し、 予測・図は元スケールへ
+--   逆変換する。 応答がクラスラベル / family・link 拘束で標準化不可な spec
+--   (@responseCol = Nothing@: 分類・GLM) に付けると 'fitEither' が 'Left' (誤用ガード)。
+standardizedY :: spec -> StandardizedSpec spec
+standardizedY = StandardizedSpec True
+
+instance Fit spec => Fit (StandardizedSpec spec) where
+  type Fitted (StandardizedSpec spec) = StandardizedModel (Fitted spec)
+  fitEither (StandardizedSpec stdY inner) d =
+    fitStd (predictorCols inner) (responseCol inner) stdY inner d
+  -- 入れ子ラップは更なる標準化対象を持たない (二重ラップ無効化)。
+  predictorCols _ = []
+  responseCol   _ = Nothing
+
+-- | 透過標準化の本体: 予測子列 (と opt-in で応答 y) を z-score 標準化した派生
+--   'ColumnSource' を作り、 内側 spec をそれに当てはめる。 逆変換用の (μ,σ) を保持。
+--   失敗 (空 predictorCols / 標準化不能応答への @standardizedY@ / 列欠落) は 'Left'。
+fitStd :: (ColumnSource d, Fit spec)
+       => [Text]       -- ^ 標準化対象の予測子列 (spec の 'predictorCols')。
+       -> Maybe Text   -- ^ 応答列 (spec の 'responseCol')。 散布用 + y 標準化に使う。
+       -> Bool         -- ^ y も標準化するか ('standardizedY' なら True)。
+       -> spec
+       -> d
+       -> Either String (StandardizedModel (Fitted spec))
+fitStd predCols mRespCol stdY inner d
+  | null predCols =
+      Left "standardized: この spec は標準化対象の予測子列を持ちません \
+           \(predictorCols = [])。 内部標準化済 (GP/Reg/PCA/PLS) か木系 (スケール不変) \
+           \ゆえ透過標準化は無意味です。"
+  | stdY, Nothing <- mRespCol =
+      Left "standardizedY: この spec は標準化可能な応答列を持ちません \
+           \(responseCol = Nothing)。 分類 (クラスラベル) や GLM (family/link 拘束) の \
+           \応答は標準化できません。 X のみ標準化する standardized を使ってください。"
+  | otherwise = do
+      xm <- reqColsM predCols d                       -- n × p (予測子行列)
+      let xStdr = fitStandardizer xm
+          xmZ   = applyStandardizer xStdr xm
+          predZ = zip predCols (map LA.toList (LA.toColumns xmZ))
+      (yStd, yOverride) <-
+        if stdY
+          then case mRespCol of
+                 Nothing -> Left "fitStd: internal — stdY without responseCol"  -- ガード済で到達せず
+                 Just yc -> do
+                   yv <- reqColV yc d
+                   let ys         = LA.toList yv
+                       (muY, sdY) = meanSdSafe ys
+                   Right (Just (muY, sdY), [(yc, [ (v - muY) / sdY | v <- ys ])])
+          else Right (Nothing, [])
+      -- 派生 ColumnSource: 元の全数値列を assoc 化し、 標準化列で同名上書き。
+      let derived = overrideAssoc (numericCols d) (predZ ++ yOverride)
+      innerM <- fitEither inner derived
+      -- 単変量散布用に元スケール (x,y) を保持 (予測子 1 列 + 応答列ありの時のみ)。
+      let mTrain = case (predCols, mRespCol) of
+                     ([xc], Just yc)
+                       | Just xs <- lookupCol xc d
+                       , Just ys <- lookupCol yc d -> Just (xs, ys)
+                     _ -> Nothing
+      Right StandardizedModel { smInner = innerM, smXStd = xStdr
+                              , smYStd = yStd,   smTrain = mTrain }
+
+-- | assoc を別 assoc で同名上書き (上書きキーは元の位置・値だけ差替え、 元に
+--   無いキーは末尾追加)。 透過標準化の派生 'ColumnSource' 生成に使う。
+overrideAssoc :: [(Text, [Double])] -> [(Text, [Double])] -> [(Text, [Double])]
+overrideAssoc base ovr =
+  let ovrMap   = Map.fromList ovr
+      baseKeys = map fst base
+  in [ (k, Map.findWithDefault v k ovrMap) | (k, v) <- base ]
+     ++ [ kv | kv@(k, _) <- ovr, k `notElem` baseKeys ]
+
+-- | 標本平均と (n-1) 標準偏差。 σ≈0 (定数列) は 1 に丸めて 0 割を回避
+--   ('Stat.Standardize' の 'fitStandardizer' と同流儀)。
+meanSdSafe :: [Double] -> (Double, Double)
+meanSdSafe xs =
+  let n = length xs
+  in if n == 0 then (0, 1)
+     else let mu = sum xs / fromIntegral n
+          in if n == 1 then (mu, 1)
+             else let var = sum [ (x - mu) ^ (2 :: Int) | x <- xs ] / fromIntegral (n - 1)
+                      sd0 = sqrt var
+                  in (mu, if sd0 < 1e-12 then 1 else sd0)
+
+instance Fit GLMSpec where
+  type Fitted GLMSpec = GLMModel
+  fitEither (GLMSpec fam lnk xn yn) d =
+    glmModel fam lnk <$> reqColV xn d <*> reqColV yn d
+  predictorCols (GLMSpec _ _ xn _) = [xn]
+  -- responseCol = Nothing (既定)。 GLM の応答は family/link でスケールが拘束される
+  -- (count\/binary\/正値) ため標準化は不正。 X 標準化 ('standardized') のみ許す。
+
+instance Fit SplineSpec where
+  type Fitted SplineSpec = SplineModel
+  fitEither (SplineSpec kind knots xn yn) d =
+    splineModel kind knots <$> reqColV xn d <*> reqColV yn d
+  predictorCols (SplineSpec _ _ xn _) = [xn]
+  responseCol   (SplineSpec _ _ _ yn) = Just yn
+
+instance Fit RobustSpec where
+  type Fitted RobustSpec = RobustModel
+  fitEither (RobustSpec est xn yn) d =
+    robustModel est <$> reqColV xn d <*> reqColV yn d
+  predictorCols (RobustSpec _ xn _) = [xn]
+  responseCol   (RobustSpec _ _ yn) = Just yn
+
+instance Fit QuantileSpec where
+  type Fitted QuantileSpec = QuantileModel
+  fitEither (QuantileSpec taus xn yn) d =
+    quantileModel taus <$> reqColV xn d <*> reqColV yn d
+  predictorCols (QuantileSpec _ xn _) = [xn]
+  responseCol   (QuantileSpec _ _ yn) = Just yn
+
+-- ===========================================================================
+-- カーネル法ファミリ統合 — GP / KRR / RFF を 1 spec @gp@ に — Phase 70.5 項目 E
+--
+-- 4 つの実装は 2 軸で尽くされる: {厳密 (Gram) | 近似 (RFF)} × {分布あり (GP) |
+-- 分布なし (Ridge 点)}。 本体は GP であり、 KRR の予測 ≡ GP 事後平均 (λ = σ_n²・
+-- mean のみ)・RFF は GP の低ランク近似。 ゆえに内部カーネル評価を GP.hs に一本化し、
+-- 4 象限を 'GPMethod' の明示コンストラクタで選ぶ。 'lm'/'lmMulti' 規約と対称な動詞
+-- 'gp'/'gpMulti' (gpMulti は E3)。
+-- ===========================================================================
+
+-- | 'gp' / 'gpMulti' の設定。 カーネル種 × 象限 × ハイパラ方略。
+data GPConfig = GPConfig
+  { gpcKernel :: !Kernel        -- ^ RBF / Matern52 / Periodic (GP.hs 由来・新型は作らない)。
+  , gpcMethod :: !GPMethod      -- ^ 象限 + (RFF 時) 近似次元・seed。
+  , gpcHyper  :: !HyperStrategy -- ^ ハイパラの決め方 + 固定時の値。
+  }
+
+-- | 既定: 厳密 GP・RBF・周辺尤度自動。 @df |-> gp defaultGP "x" "y"@。
+defaultGP :: GPConfig
+defaultGP = GPConfig RBF Gp AutoMarginalLik
+
+-- | 二変量カーネル回帰 spec。 @gp cfg "x" "y"@ ('lm' と対称)。
+data GPSpec = GPSpec !GPConfig !Text !Text
+
+-- | @gp cfg xCol yCol@ — 統合カーネル回帰 ('GPRegModel') を当てはめる spec。
+gp :: GPConfig -> Text -> Text -> GPSpec
+gp = GPSpec
+
+-- | RFF 非対応カーネルのフォールバック通知 (RFF は定常カーネル限定 = Bochner)。
+-- Periodic は定常だがスペクトル密度未実装、 Linear/Poly は非定常ゆえ RFF 不可。
+gpNonRffMsg :: Kernel -> String
+gpNonRffMsg ker =
+  "[gp] " <> show ker <> " カーネルは RFF 近似不可"
+  <> " (Periodic はスペクトル密度未実装・Linear/Poly は非定常 = Bochner)。"
+  <> " 厳密象限へフォールバックします。"
+
+-- | RFF 近似象限 + RFF 非対応カーネル (Periodic/Linear/Poly) は厳密象限へ
+-- フォールバック ('log' 通知)。 定常カーネル (RBF/Matern52) はそのまま。
+gpResolveMethod :: Kernel -> GPMethod -> GPMethod
+gpResolveMethod ker (GpRff  _ _) | not (gpRffSupported ker) = Trace.trace (gpNonRffMsg ker) Gp
+gpResolveMethod ker (KrrRff _ _) | not (gpRffSupported ker) = Trace.trace (gpNonRffMsg ker) Krr
+gpResolveMethod _   m            = m
+
+-- | RFF (Random Fourier Features) が近似可能なカーネルか (= 定常 + スペクトル密度実装済)。
+gpRffSupported :: Kernel -> Bool
+gpRffSupported RBF      = True
+gpRffSupported Matern52 = True
+gpRffSupported _        = False   -- Periodic/Linear/Poly
+
+-- | ハイパラ方略を解決して 'GPParams' を得る。
+gpResolveHyper :: HyperStrategy -> Kernel -> [Double] -> [Double] -> GPParams
+gpResolveHyper (FixedHyper p)  _   _  _  = p
+gpResolveHyper AutoMarginalLik ker xs ys = GP.optimizeGP ker xs ys (GP.initParamsFromData xs ys)
+gpResolveHyper AutoCV          ker xs ys = GP.autoCVHyperGP ker xs ys
+
+-- | 象限ごとの予測子を組む。 渡される @method@ は Periodic フォールバック後なので、
+-- RFF 象限 (GpRff/RidgeRff) に来るカーネルは RBF / Matern52 に限られる。
+gpBuildPredict
+  :: GPMethod -> Kernel -> GPParams -> [Double] -> [Double]
+  -> ([Double] -> ([Double], Maybe [Double]))
+gpBuildPredict method ker params xs ys = case method of
+  Gp  -> \q -> let r = GP.fitGP (GP.GPModel ker params) xs ys q
+               in (gpMean r, Just (gpVar r))
+  Krr -> \q -> let r = GP.fitGP (GP.GPModel ker params) xs ys q
+               in (gpMean r, Nothing)                 -- KRR ≡ GP 事後平均・帯は捨てる
+  GpRff dDim seed ->
+    let feats = gpSampleFeats ker dDim seed
+        fit   = RFF.rffGP feats xs ys (sqrt (max 1e-12 (gpNoiseVar params)))
+    in \q -> let ps = RFF.predictRFFGP fit q
+             in (map fst ps, Just (map snd ps))
+  KrrRff dDim seed ->
+    let feats = gpSampleFeats ker dDim seed
+        fit   = RFF.rffRidge feats xs ys (max 1e-12 (gpNoiseVar params))
+    in \q -> (RFF.predictRFFRidge fit q, Nothing)
+  where
+    ell = gpLengthScale params
+    sf  = sqrt (max 1e-12 (gpSignalVar params))
+    gpSampleFeats Matern52 dDim seed = RFF.sampleRFFMatern52Pure dDim ell sf seed
+    gpSampleFeats _        dDim seed = RFF.sampleRFFRBFPure      dDim ell sf seed
+
+instance Fit GPSpec where
+  type Fitted GPSpec = GPRegModel
+  fitEither (GPSpec cfg xn yn) d = do
+    xv <- reqColV xn d
+    yv <- reqColV yn d
+    let xs     = LA.toList xv
+        ys     = LA.toList yv
+        ker    = gpcKernel cfg
+        method = gpResolveMethod ker (gpcMethod cfg)
+        params = gpResolveHyper (gpcHyper cfg) ker xs ys
+    Right GPRegModel
+      { gprMethod  = method
+      , gprKernel  = ker
+      , gprParams  = params
+      , gprXraw    = xv
+      , gprY       = yv
+      , gprPredict = gpBuildPredict method ker params xs ys
+      }
+
+
+-- ---------------------------------------------------------------------------
+-- 多変量 gpMulti (E3)。 'gamMulti' / 'GAMModelN' と同型 = 第1予測子を描画軸に、
+-- 他予測子を訓練平均に固定した偏依存曲線 ('SingleVarModel' として扱う)。 4 象限の
+-- MV 実装: Gp/Ridge=fitGPMV (Ridge は mean のみ)、 GpRff=rffGPMV、 RidgeRff=rffRidgeMV。
+-- ---------------------------------------------------------------------------
+
+-- | 多変量カーネル回帰 spec。 @gpMulti cfg ["x1","x2"] "y"@ ('lmMulti' と対称)。
+data GPMultiSpec = GPMultiSpec !GPConfig ![Text] !Text
+
+-- | @gpMulti cfg xCols yCol@ — 多予測子カーネル回帰 ('lmMulti' と対)。 第1予測子を描画軸に。
+gpMulti :: GPConfig -> [Text] -> Text -> GPMultiSpec
+gpMulti = GPMultiSpec
+
+-- | MV ハイパラ方略を解決 ('gpResolveHyper' の行列版)。
+gpResolveHyperMV :: HyperStrategy -> Kernel -> LA.Matrix Double -> LA.Vector Double -> GPParams
+gpResolveHyperMV (FixedHyper p)  _   _ _ = p
+gpResolveHyperMV AutoMarginalLik ker x y = GP.optimizeGPMV ker x y (GP.initParamsFromDataMV x y)
+gpResolveHyperMV AutoCV          ker x y = GP.autoCVHyperGPMV ker x y
+
+-- | 象限ごとの MV 予測子。 渡される @method@ は Periodic フォールバック後。
+gpBuildPredictMV
+  :: GPMethod -> Kernel -> GPParams -> LA.Matrix Double -> [Double]
+  -> (LA.Matrix Double -> ([Double], Maybe [Double]))
+gpBuildPredictMV method ker params trainX ys = case method of
+  Gp  -> \tx -> let r = GP.fitGPMV (GP.GPModel ker params) trainX yV tx
+                in (LA.toList (GP.gpmvMean r), Just (LA.toList (GP.gpmvVar r)))
+  Krr -> \tx -> let r = GP.fitGPMV (GP.GPModel ker params) trainX yV tx
+                in (LA.toList (GP.gpmvMean r), Nothing)
+  GpRff dDim seed ->
+    let feats = featsMV dDim seed
+        fit   = RFF.rffGPMV feats trainX ys (sqrt (max 1e-12 (gpNoiseVar params)))
+    in \tx -> let ps = RFF.predictRFFGPMV fit tx in (map fst ps, Just (map snd ps))
+  KrrRff dDim seed ->
+    let feats = featsMV dDim seed
+        fit   = RFF.rffRidgeMV feats trainX ys (max 1e-12 (gpNoiseVar params))
+    in \tx -> (RFF.predictRFFRidgeMV fit tx, Nothing)
+  where
+    yV  = LA.fromList ys
+    pP  = LA.cols trainX
+    ell = gpLengthScale params
+    sf  = sqrt (max 1e-12 (gpSignalVar params))
+    featsMV dDim seed = case ker of
+      Matern52 -> RFF.sampleRFFMatern52MVPure pP dDim ell sf seed
+      _        -> RFF.sampleRFFRBFMVPure      pP dDim ell sf seed
+
+instance Fit GPMultiSpec where
+  type Fitted GPMultiSpec = GPRegModelN
+  fitEither (GPMultiSpec cfg xns yn) d = do
+    xs <- mapM (`reqColV` d) xns
+    yv <- reqColV yn d
+    let trainX = LA.fromColumns xs        -- n × p
+        ys     = LA.toList yv
+        ker    = gpcKernel cfg
+        method = gpResolveMethod ker (gpcMethod cfg)
+        params = gpResolveHyperMV (gpcHyper cfg) ker trainX yv
+    pure GPRegModelN
+      { gprnMethod  = method
+      , gprnKernel  = ker
+      , gprnParams  = params
+      , gprnXraws   = xs
+      , gprnNames   = xns
+      , gprnYraw    = yv
+      , gprnPredict = gpBuildPredictMV method ker params trainX ys
+      }
+
+-- ===========================================================================
+-- 罰則付き回帰の高レベル df|-> 化 — Phase 70.7 項目 G (G1)
+--
+-- Ridge / Lasso / Elastic Net / MCP / SCAD / Adaptive Lasso / Group Lasso を
+-- 1 spec `regularized` (+近道 `ridge`/`lasso`/`elasticNet`) に統合。`lmMulti` と対称な
+-- 多予測子のみ (罰則回帰は本質的に多特徴ゆえ単回帰版は作らない)。λ は `FixedLambda` /
+-- `LambdaLOOCV` (Ridge 専用閉形式) / `LambdaCV` / `LambdaCV1SE` (k-fold・seed 純粋)。
+-- X は内部で標準化し係数を元スケールに戻す (MCP/SCAD の非凸 CD が標準化を要件とするため
+-- ・glmnet 慣例)。 [[selectLambdaCV]] の純粋化 (`selectLambdaCVPure`/`HCV.kFold` PrimMonad 化) を土台にする。
+-- ===========================================================================
+
+-- | λ の決め方 (GP の 'HyperStrategy' と同型)。
+data LambdaStrat
+  = FixedLambda !Double        -- ^ 固定 λ。
+  | LambdaLOOCV                -- ^ 閉形式 LOOCV (★Ridge 等の線形平滑器専用・他は Left)。
+  | LambdaCV    !Int !Word32   -- ^ k-fold CV (k, seed)・best (CV-MSE 最小)。
+  | LambdaCV1SE !Int !Word32   -- ^ k-fold CV + 1-SE rule (R glmnet 既定推奨)。
+  deriving (Eq, Show)
+
+-- | 罰則回帰の設定。
+data RegConfig = RegConfig { rcMethod :: !RegMethod, rcLambda :: !LambdaStrat }
+
+-- | 既定 (CV で λ 選択・5-fold・seed 42)。
+defaultRidge, defaultLasso :: RegConfig
+defaultRidge = RegConfig Ridge (LambdaCV 5 42)
+defaultLasso = RegConfig Lasso (LambdaCV 5 42)
+
+-- | 罰則回帰 spec。 @regularized cfg ["x1","x2"] "y"@。
+data RegSpec = RegSpec !RegConfig ![Text] !Text
+
+-- | @regularized cfg xCols yCol@。 多予測子のみ。
+regularized, regularizedMulti :: RegConfig -> [Text] -> Text -> RegSpec
+regularized      = RegSpec
+regularizedMulti = RegSpec               -- 同一関数の別名 (Q4)
+
+-- | 近道 (λ 方略は既定 CV)。 bare = 多変量、 @*Multi@ は同一関数の別名。
+ridge, ridgeMulti, lasso, lassoMulti :: [Text] -> Text -> RegSpec
+ridge      = RegSpec defaultRidge
+ridgeMulti = ridge
+lasso      = RegSpec defaultLasso
+lassoMulti = lasso
+
+-- | Elastic Net 近道 (α 指定・λ 方略は既定 CV)。
+elasticNet, elasticNetMulti :: Double -> [Text] -> Text -> RegSpec
+elasticNet a    = RegSpec (RegConfig (ElasticNet a) (LambdaCV 5 42))
+elasticNetMulti = elasticNet
+
+-- 列を平均0・分散1 (population sd・guard 1e-12) に標準化。 (Xstd, means, sds)。
+regStandardize :: LA.Matrix Double -> (LA.Matrix Double, [Double], [Double])
+regStandardize x =
+  let n     = fromIntegral (LA.rows x)
+      cols  = LA.toColumns x
+      means = [ LA.sumElements c / n | c <- cols ]
+      sds   = [ max 1e-12 (sqrt (sum [ (v - m) ^ (2 :: Int) | v <- LA.toList c ] / n))
+              | (c, m) <- zip cols means ]
+      stdCol c m s = LA.cmap (\v -> (v - m) / s) c
+  in (LA.fromColumns (zipWith3 stdCol cols means sds), means, sds)
+
+-- log 等間隔 λ grid。
+regLogspace :: Double -> Double -> Int -> [Double]
+regLogspace lo hi k
+  | k <= 1    = [lo]
+  | otherwise = [ exp (log lo + (log hi - log lo) * fromIntegral i / fromIntegral (k - 1))
+                | i <- [0 .. k - 1] ]
+
+-- データ駆動 λ grid (標準化 X・中心化 y 前提)。
+regLambdaGrid :: RegMethod -> LA.Matrix Double -> LA.Vector Double -> [Double]
+regLambdaGrid method x y =
+  let n     = fromIntegral (LA.rows x)
+      lmaxL = max 1e-8 (maximum [ abs (LA.dot c y) | c <- LA.toColumns x ] / n)
+  in case method of
+       Ridge -> regLogspace (n * 1e-3) (n * 1e2)   50   -- ridge は XᵀX スケール (1/n 無し)
+       _     -> regLogspace (lmaxL * 1e-3) lmaxL    50
+
+-- 群 ID (列順) → fitGroupLasso の [[列 index]] パーティション (群の初出順)。
+regGroupsToPartition :: [Int] -> Int -> [[Int]]
+regGroupsToPartition gids p =
+  let distinct []       = []
+      distinct (g : gs) = g : distinct (filter (/= g) gs)
+  in [ [ j | (j, g) <- zip [0 .. p - 1] gids, g == gid ] | gid <- distinct gids ]
+
+-- Adaptive Lasso = 列リスケール法。 penalty λΣwⱼ|βⱼ| ⇔ 列 Xⱼ を 1/wⱼ 倍 → Lasso → βⱼ = β'ⱼ/wⱼ。
+regAdaptiveLasso :: Double -> Double -> LA.Matrix Double -> LA.Vector Double -> RegFit
+regAdaptiveLasso gamma lam x y =
+  let w    = RA.adaptiveWeightsFromOLS gamma x y
+      xr   = LA.fromColumns (zipWith (\c wj -> LA.scale (1 / wj) c)
+                               (LA.toColumns x) (LA.toList w))
+      f    = fitLasso lam xr y 1000 1e-4
+      bAdj = LA.fromList (zipWith (/) (LA.toList (rfBeta f)) (LA.toList w))
+      yh   = x LA.#> bAdj
+  in f { rfBeta = bAdj, rfYHat = yh, rfResid = y - yh }
+
+-- 1 つの RegMethod を λ で当てはめる (標準化 X・中心化 y 前提)。
+regFitAt :: RegMethod -> Double -> LA.Matrix Double -> LA.Vector Double -> RegFit
+regFitAt method lam x y = case method of
+  Ridge            -> fitRidge lam x y
+  Lasso            -> fitLasso lam x y 1000 1e-4
+  ElasticNet a     -> fitElasticNet (a * lam) ((1 - a) * lam) x y 1000 1e-4
+  MCP g            -> RA.fitMCP lam g x y 1000 1e-4
+  SCAD a           -> RA.fitSCAD lam a x y 1000 1e-4
+  AdaptiveLasso g  -> regAdaptiveLasso g lam x y
+  GroupLasso gids  -> RA.fitGroupLasso lam (regGroupsToPartition gids (LA.cols x)) x y 1000 1e-4
+
+-- generic k-fold CV (全 RegMethod 対応・pure 'HCV.kFold' を seed で回す)。 (bestλ, 1seλ, λ grid, mean MSE)。
+regKFoldCV :: RegMethod -> Int -> Word32 -> [Double] -> LA.Matrix Double -> LA.Vector Double
+           -> (Double, Double, [Double], [Double])
+regKFoldCV method k seed lams x y =
+  let folds = runST (initialize (V.singleton seed) >>= HCV.kFold k (LA.rows x))
+      sub idx = ( x LA.? idx
+                , LA.fromList [ y `LA.atIndex` i | i <- idx ] )
+      foldMSE lam (tr, te) =
+        let (xTr, yTr) = sub tr
+            (xTe, yTe) = sub te
+            fit = regFitAt method lam xTr yTr
+            r   = yTe - (xTe LA.#> rfBeta fit)
+        in LA.sumElements (LA.cmap (^ (2 :: Int)) r) / fromIntegral (max 1 (length te))
+      perLam lam =
+        let scores = [ foldMSE lam f | f@(_, te) <- folds, not (null te) ]
+            nF = fromIntegral (max 1 (length scores))
+            m  = sum scores / nF
+            v  = sum [ (s - m) ^ (2 :: Int) | s <- scores ] / max 1 (nF - 1)
+        in (lam, m, sqrt (v / nF))
+      stats = map perLam lams
+      pick a@(_, ma, _) b@(_, mb, _) = if ma <= mb then a else b
+      (bestL, bestM, bestSE) = foldr1 pick stats
+      thr   = bestM + bestSE
+      oneSe = maximum (bestL : [ lam | (lam, m, _) <- stats, m <= thr ])
+  in (bestL, oneSe, lams, [ m | (_, m, _) <- stats ])
+
+-- λ 方略を解決して (選択 λ, 標準化空間 fit, CV/LOOCV パス) を返す。
+regResolve :: RegConfig -> LA.Matrix Double -> LA.Vector Double
+           -> Either String (Double, RegFit, Maybe ([Double], [Double]))
+regResolve (RegConfig method strat) x y = case strat of
+  FixedLambda lam -> Right (lam, regFitAt method lam x y, Nothing)
+  LambdaLOOCV -> case method of
+    Ridge ->
+      let lams   = regLambdaGrid Ridge x y
+          scores = [ RFF.loocvFromPhi x y lam | lam <- lams ]
+          best   = fst (foldr1 (\a b -> if snd a <= snd b then a else b) (zip lams scores))
+      in Right (best, regFitAt Ridge best x y, Just (lams, scores))
+    _ -> Left "LambdaLOOCV は線形平滑器 (Ridge) 専用です。 L1/非凸は LambdaCV を使ってください"
+  LambdaCV k seed ->
+    let lams = regLambdaGrid method x y
+        (best, _, gl, ms) = regKFoldCV method k seed lams x y
+    in Right (best, regFitAt method best x y, Just (gl, ms))
+  LambdaCV1SE k seed ->
+    let lams = regLambdaGrid method x y
+        (_, one, gl, ms) = regKFoldCV method k seed lams x y
+    in Right (one, regFitAt method one x y, Just (gl, ms))
+
+instance Fit RegSpec where
+  type Fitted RegSpec = RegModel
+  fitEither (RegSpec cfg xns yn) d = do
+    xRaw <- reqColsM xns d
+    yv   <- reqColV yn d
+    case rcMethod cfg of
+      GroupLasso gids | length gids /= LA.cols xRaw ->
+        Left ("GroupLasso: 群 ID の長さ " <> show (length gids)
+              <> " が列数 " <> show (LA.cols xRaw) <> " と一致しません")
+      _ -> Right ()
+    let (xStd, means, sds) = regStandardize xRaw
+        n    = fromIntegral (LA.rows xRaw)
+        ybar = LA.sumElements yv / n
+        yC   = LA.cmap (subtract ybar) yv
+    (lam, fitStd, mbCV) <- regResolve cfg xStd yC
+    let betaOrig  = zipWith (/) (LA.toList (rfBeta fitStd)) sds   -- βⱼ = β̃ⱼ / sⱼ
+        intercept = ybar - sum (zipWith (*) betaOrig means)
+    Right RegModel
+      { rmgMethod    = rcMethod cfg
+      , rmgNames     = xns
+      , rmgLambda    = lam
+      , rmgIntercept = intercept
+      , rmgCoefs     = betaOrig
+      , rmgFitStd    = fitStd
+      , rmgCVPath    = mbCV
+      , rmgXraw      = xRaw
+      , rmgYraw      = yv
+      }
+
+-- | 罰則化回帰 ('RegModel') の case (行) bootstrap 係数サマリ。
+--
+-- 各 replicate で行を再標本化し、 **λ は full-fit で選択済の 'rmgLambda' に固定**
+-- (CV を再実行しない) して 'regFitAt' で refit、 元スケール係数を集めて
+-- 'bootCoefRows' で要約する。 標準化・中心化は replicate ごとに再計算する。
+--
+-- ⚠ これは **penalized の bootstrap percentile 区間であって有意性検定ではない**。
+-- Lasso 等の変数選択を伴う罰則化では post-selection inference が別問題として
+-- 存在し、 単純な percentile 区間はカバレッジを保証しない (selection の不確実性は
+-- λ 固定 bootstrap だけでは捉えきれない)。 探索的な不確実性目安として使うこと。
+instance HasCoefBoot RegModel where
+  coefSummaryBoot seed b m =
+    let xRaw    = rmgXraw m
+        yv      = rmgYraw m
+        n       = LA.rows xRaw
+        idxSets = resampleRows seed b n
+        repBeta idxs =
+          let xr            = xRaw LA.? idxs
+              yr            = LA.fromList [ yv `LA.atIndex` i | i <- idxs ]
+              (xStd, _, sds) = regStandardize xr
+              ybar          = LA.sumElements yr / fromIntegral (max 1 n)
+              yC            = LA.cmap (subtract ybar) yr
+              fitStd        = regFitAt (rmgMethod m) (rmgLambda m) xStd yC
+          in zipWith (/) (LA.toList (rfBeta fitStd)) sds
+        reps = map repBeta idxSets
+    in bootCoefRows (rmgNames m) (rmgCoefs m) reps
+
+-- | 罰則化回帰 ('RegModel') の統一要約玄関。
+--
+-- ⚠ これは bootstrap percentile (seed=42 / B=2000) であって**有意性検定ではない**
+-- (post-selection inference は別問題。 'HasCoefBoot' RegModel の注記参照)。
+-- 'HasCoefBoot' RegModel が Fit 層にあるため、 'HasReport' instance もここに置く。
+instance HasReport RegModel where
+  modelReport m = CoefReport (coefSummaryBoot 42 2000 m)
+
+-- --- 行列入力モデルの高レベル化 spec (列名リスト) — Phase 70.A -------------
+--
+-- PCA / PLS など「行列入力」モデルも 'df |-> spec' で当てられるように、 列名リストを
+-- 取る spec + 'Fit' instance を設ける ('reqColsM' で列名 → 行列)。 行列直叩き
+-- ('pca' / 'fitPLS') は低レベル退避。 結果型 ('PCAResult' / 'PLSFit') は既存のまま。
+
+-- | PCA spec。 @pca std mK ["x1","x2",…]@ (std = 標準化方針・mK = 保持成分数)。
+data PCASpec = PCASpec !PCAStandardize !(Maybe Int) ![Text]
+
+-- | @pca std mK cols@ — 列 @cols@ で PCA ('PCAResult') を当てる spec。
+--   @df |-> pca CenterScale (Just 3) ["x1","x2","x3"]@。
+pca :: PCAStandardize -> Maybe Int -> [Text] -> PCASpec
+pca = PCASpec
+
+instance Fit PCASpec where
+  type Fitted PCASpec = PCAResult
+  fitEither (PCASpec std mK cols) d = PCALow.pca std mK <$> reqColsM cols d
+
+-- Phase 75.21: MDS (多次元尺度構成法) 高レベル spec。 教師なし変換ゆえ PCA と同様
+-- @df |->@ に乗り、 **モデル型 'MDSResult'** (PCAResult 同格) を返す。 群色付けのため
+-- 'toFrame' で **元データ (factor 温存)** を保持する (数値源なら数値列を再構築)。
+data MDSSpec = MDSSpec !MDSConfig ![Text]
+
+-- | @mds cfg cols@ — 列 @cols@ で MDS ('MDSResult') を当てる spec。
+--   @m = df |-> mds defaultMDS ["x1","x2","x3"]@ → @noDf |>> toPlot m@ (単色散布)。
+--   Sammon は @mds defaultMDS { mdsMethod = MDSSammon } cols@。
+mds :: MDSConfig -> [Text] -> MDSSpec
+mds = MDSSpec
+
+instance Fit MDSSpec where
+  type Fitted MDSSpec = MDSResult
+  fitEither (MDSSpec cfg cols) d = runMDS cfg (toFrame d) cols
+
+-- | PLS spec。 @pls cfg ["x1","x2"] ["y1","y2"]@ (X 列・Y 列を分けて指定)。
+data PLSSpec = PLSSpec !PLSConfig ![Text] ![Text]
+
+-- | @pls cfg xcols ycols@ — X 列・Y 列で PLS ('PLSFit') を当てる spec。
+--   @df |-> pls defaultPLS ["x1","x2"] ["y1","y2"]@。
+pls :: PLSConfig -> [Text] -> [Text] -> PLSSpec
+pls = PLSSpec
+
+instance Fit PLSSpec where
+  type Fitted PLSSpec = PLSFit
+  fitEither (PLSSpec cfg xcols ycols) d = do
+    x <- reqColsM xcols d
+    y <- reqColsM ycols d
+    either (Left . T.unpack) Right (fitPLS cfg x y)
+
+-- | 判別分析 (LDA) spec。 @lda ["x1","x2"] "class"@ (特徴列 + クラス列・クラスは整数化)。
+data LDASpec = LDASpec ![Text] !Text
+
+-- | @lda featureCols classCol@ — LDA ('DiscriminantFit') を当てる spec。
+--   クラス列は数値を 'round' で整数ラベル化する。
+lda :: [Text] -> Text -> LDASpec
+lda = LDASpec
+
+instance Fit LDASpec where
+  type Fitted LDASpec = DiscriminantFit
+  fitEither (LDASpec feats clsCol) d = do
+    x  <- reqColsM feats d
+    yv <- reqColV clsCol d
+    let yInt = V.fromList (map round (LA.toList yv)) :: V.Vector Int
+    either (Left . T.unpack) Right (fitLDA x yInt)
+  predictorCols (LDASpec feats _) = feats   -- 特徴標準化は可 (応答=クラスゆえ y は標準化せず)
+
+-- | 正準相関分析 (CCA) spec。 @ccaOf ["x1","x2"] ["y1","y2"]@ (2 ブロックの列)。
+data CCASpec = CCASpec ![Text] ![Text]
+
+-- | @ccaOf xcols ycols@ — CCA ('CCAFit') を当てる spec (CCAFit は現状 toPlot 非対象)。
+ccaOf :: [Text] -> [Text] -> CCASpec
+ccaOf = CCASpec
+
+instance Fit CCASpec where
+  type Fitted CCASpec = CCAFit
+  fitEither (CCASpec xcols ycols) d = cca <$> reqColsM xcols d <*> reqColsM ycols d
+
+-- | ラベル列を整数 ('round') の 'VU.Vector Int' で引く (分類器の y)。
+reqLabelI :: ColumnSource d => Text -> d -> Either String (VU.Vector Int)
+reqLabelI n d = VU.fromList . map round . LA.toList <$> reqColV n d
+
+-- | ラベル列を 'VU.Vector Double' で引く (回帰の y)。
+reqLabelD :: ColumnSource d => Text -> d -> Either String (VU.Vector Double)
+reqLabelD n d = VU.fromList . LA.toList <$> reqColV n d
+
+-- --- 教師あり ML 分類器/回帰器の高レベル化 spec (特徴列 + ラベル列) — Phase 70.A ---
+--
+-- いずれも純粋 fit (RNG なし)。 特徴は 'reqColsM' で行列化、 ラベルは整数 / 実数で引く。
+
+-- | 勾配ブースティング回帰 spec。 @gbmReg cfg ["x1","x2"] "y"@。
+data GBRSpec = GBRSpec !GBConfig ![Text] !Text
+-- | @gbmReg cfg featCols yCol@ — GBM 回帰 ('GBRegressor')。
+gbmReg :: GBConfig -> [Text] -> Text -> GBRSpec
+gbmReg = GBRSpec
+instance Fit GBRSpec where
+  type Fitted GBRSpec = GBRegressor
+  fitEither (GBRSpec cfg feats yc) d = fitGBRegressor cfg <$> reqColsM feats d <*> reqLabelD yc d
+
+-- | 勾配ブースティング分類 spec。 @gbmCls cfg ["x1","x2"] "cls"@ (ラベルは {0,1})。
+data GBCSpec = GBCSpec !GBConfig ![Text] !Text
+-- | @gbmCls cfg featCols clsCol@ — GBM 分類 ('GBClassifier')。
+gbmCls :: GBConfig -> [Text] -> Text -> GBCSpec
+gbmCls = GBCSpec
+instance Fit GBCSpec where
+  type Fitted GBCSpec = GBClassifier
+  fitEither (GBCSpec cfg feats cc) d = fitGBClassifier cfg <$> reqColsM feats d <*> reqLabelI cc d
+
+-- | 決定木 spec。 @decisionTree cfg ["x1","x2"] "cls"@。
+data DTSpec = DTSpec !DTConfig ![Text] !Text
+-- | @decisionTree cfg featCols clsCol@ — 決定木 ('DTFit'・行列版 'fitDTV')。 fit 時に
+--   実列名 (feats) とクラス列の levels を載せて返すので 'treePlot'/'printRpart' が
+--   名前を手渡し不要 ('RFClassifierFit' と同型)。 クラス列は factor(text)/数値の両対応。
+decisionTree :: DTConfig -> [Text] -> Text -> DTSpec
+decisionTree = DTSpec
+instance Fit DTSpec where
+  type Fitted DTSpec = DTFit
+  fitEither (DTSpec cfg feats cc) d = do
+    x            <- reqColsM feats d
+    (y, classes) <- reqLabelWithLevels cc d
+    pure (DTFit (fitDTV cfg x y) feats classes)
+
+-- | k-NN 分類 spec。 @knnCls 5 ["x1","x2"] "cls"@。
+data KNNCSpec = KNNCSpec !Int ![Text] !Text
+-- | @knnCls k featCols clsCol@ — k-NN 分類 ('KNNClassifier')。
+knnCls :: Int -> [Text] -> Text -> KNNCSpec
+knnCls = KNNCSpec
+instance Fit KNNCSpec where
+  type Fitted KNNCSpec = KNNClassifier
+  -- fit 時にクラス列の levels を注入 (confusion/凡例でクラス名表示・factor/数値両対応)。
+  fitEither (KNNCSpec k feats cc) d = do
+    x            <- reqColsM feats d
+    (y, classes) <- reqLabelWithLevels cc d
+    pure ((fitKNNC k x y) { knnCClassNames = classes })
+  predictorCols (KNNCSpec _ feats _) = feats   -- ★距離ベース・標準化の本命
+  -- responseCol = Nothing (既定)。 分類の応答はクラスラベルゆえ標準化しない。
+
+-- | k-NN 回帰 spec。 @knnReg 5 ["x1","x2"] "y"@。
+data KNNRSpec = KNNRSpec !Int ![Text] !Text
+-- | @knnReg k featCols yCol@ — k-NN 回帰 ('KNNRegressor')。
+knnReg :: Int -> [Text] -> Text -> KNNRSpec
+knnReg = KNNRSpec
+instance Fit KNNRSpec where
+  type Fitted KNNRSpec = KNNRegressor
+  fitEither (KNNRSpec k feats yc) d = fitKNNR k <$> reqColsM feats d <*> reqLabelD yc d
+  predictorCols (KNNRSpec _ feats _) = feats   -- ★距離ベース・標準化の本命
+  responseCol   (KNNRSpec _ _ yc)    = Just yc
+
+-- | Naive Bayes (Gaussian) spec。 @naiveBayes ["x1","x2"] "cls"@ (連続特徴)。
+data NBSpec = NBSpec ![Text] !Text
+-- | @naiveBayes featCols clsCol@ — Gaussian NB ('NBModel' = 'NBGaussian')。
+naiveBayes :: [Text] -> Text -> NBSpec
+naiveBayes = NBSpec
+instance Fit NBSpec where
+  type Fitted NBSpec = NBModel
+  fitEither (NBSpec feats cc) d = do
+    x            <- reqColsM feats d
+    (y, classes) <- reqLabelWithLevels cc d
+    pure (NBGaussian ((fitGNB x y) { gnbClassNames = classes }))
+  predictorCols (NBSpec feats _) = feats   -- 特徴標準化は可 (応答=クラスゆえ y は標準化せず)
+
+-- --- seed 純粋化した RNG モデル spec (KMeans / RandomForest) — Phase 70.A ---
+--
+-- KMeans / RandomForest は RNG を使う ('MWC.GenIO -> IO')。 'df |->' (純粋 'fitEither')
+-- に載せるため、 サンプラ本体を seed 純粋化 (Phase 50 と同方針) した変種
+-- 'kMeansPure' / 'fitRFVPure' を呼ぶ。 spec は @seed :: Word32@ を取り、 同 seed →
+-- ビット同一の決定的結果を返す。
+
+-- | KMeans クラスタリング spec。 @kmeans cfg seed ["x1","x2"]@ (cfg = k 等・seed = 乱数種)。
+data KMeansSpec = KMeansSpec !KMeansConfig !Word32 ![Text]
+-- | @kmeans cfg seed featCols@ — KMeans ('KMeansResult'・toPlot = centroid 散布)。
+--   @df |-> kmeans (defaultKMeans 3) 42 ["x1","x2"]@。
+kmeans :: KMeansConfig -> Word32 -> [Text] -> KMeansSpec
+kmeans = KMeansSpec
+instance Fit KMeansSpec where
+  type Fitted KMeansSpec = KMeansResult
+  fitEither (KMeansSpec cfg seed cols) d = (\x -> kMeansPure cfg x seed) <$> reqColsM cols d
+
+-- | ランダムフォレスト回帰 spec。 @randomForestReg cfg seed ["x1","x2"] "y"@ (cfg = 木数等・seed = 乱数種)。
+data RFSpec = RFSpec !RFConfig !Word32 ![Text] !Text
+-- | @randomForestReg cfg seed featCols yCol@ — RF 回帰 ('RandomForest'・toPlot = 特徴重要度 bar)。
+--   分類 'randomForestCls' と対の命名 (無印 randomForest は廃止)。
+--   @df |-> randomForestReg defaultRandomForest 42 ["x1","x2"] "y"@。
+randomForestReg :: RFConfig -> Word32 -> [Text] -> Text -> RFSpec
+randomForestReg = RFSpec
+instance Fit RFSpec where
+  type Fitted RFSpec = RandomForest
+  -- fit 時に手元にある実列名 (feats) をモデルに載せる (df|-> 経路の責務)。
+  fitEither (RFSpec cfg seed feats yc) d =
+    (\x y -> (fitRFVPure cfg x y seed) { rfFeatureNames = feats })
+      <$> reqColsM feats d <*> reqLabelD yc d
+
+-- | DirectLiNGAM (因果探索) の高レベル spec (Phase 77.A)。 @directLingam cfg cols@ =
+--   列名で n×p 行列を組み因果構造を推定する (教師なし・y 無し)。 結果は変数名を添えた
+--   'LiNGAMFitted' で、 @toPlot@ が**実変数名の DAG** を描く (低レベルは @x0..@)。
+--   @df |-> directLingam defaultDirectLiNGAMConfig ["smoking","tar","cancer"]@。
+data DirectLiNGAMSpec = DirectLiNGAMSpec !DirectLiNGAMConfig ![Text]
+
+directLingam :: DirectLiNGAMConfig -> [Text] -> DirectLiNGAMSpec
+directLingam = DirectLiNGAMSpec
+
+instance Fit DirectLiNGAMSpec where
+  type Fitted DirectLiNGAMSpec = LiNGAMFitted DirectLiNGAMFit
+  fitEither (DirectLiNGAMSpec cfg cols) d =
+    (\x -> LiNGAMFitted (fitDirectLiNGAM cfg x) cols) <$> reqColsM cols d
+
+-- | ParceLiNGAM (bottom-up sink 探索・潜在交絡に頑健) の高レベル spec (Phase 77.B)。
+--   @df |-> parceLingam defaultParceConfig cols@。 DAG は Direct と同型 (名前付き)。
+data ParceLiNGAMSpec = ParceLiNGAMSpec !ParceConfig ![Text]
+
+parceLingam :: ParceConfig -> [Text] -> ParceLiNGAMSpec
+parceLingam = ParceLiNGAMSpec
+
+instance Fit ParceLiNGAMSpec where
+  type Fitted ParceLiNGAMSpec = LiNGAMFitted ParceFit
+  fitEither (ParceLiNGAMSpec cfg cols) d =
+    (\x -> LiNGAMFitted (fitParceLiNGAM cfg x) cols) <$> reqColsM cols d
+
+-- | MultiGroupLiNGAM (複数群で共通 DAG 構造・Shimizu 2012) の高レベル spec (Phase 77.B)。
+--   @df |-> multiGroupLingam defaultMultiGroupConfig cols groupCol@。 @groupCol@ = **数値の
+--   群コード列**で行を分割し各群を fit する。 DAG は多数決の共通構造 (名前付き)。
+data MultiGroupLiNGAMSpec = MultiGroupLiNGAMSpec !MultiGroupConfig ![Text] !Text
+
+multiGroupLingam :: MultiGroupConfig -> [Text] -> Text -> MultiGroupLiNGAMSpec
+multiGroupLingam = MultiGroupLiNGAMSpec
+
+instance Fit MultiGroupLiNGAMSpec where
+  type Fitted MultiGroupLiNGAMSpec = LiNGAMFitted MultiGroupFit
+  fitEither (MultiGroupLiNGAMSpec cfg cols gcol) d = do
+    x <- reqColsM cols d
+    g <- reqColV gcol d
+    let codes    = LA.toList g
+        distinct = nub codes                          -- 群ラベル (出現順)
+        rowsFor gc = [ i | (i, c) <- zip [0 ..] codes, c == gc ]
+        groups   = [ x LA.? rowsFor gc | gc <- distinct ]
+    if null distinct
+      then Left "multiGroupLingam: group 列が空です"
+      else Right (LiNGAMFitted (fitMultiGroupLiNGAM cfg groups) cols)
+
+-- | VARLiNGAM (時系列因果・同時刻 + 時間ラグ) の高レベル spec (Phase 77.B)。
+--   @df |-> varLingam defaultVARLiNGAMConfig cols@。 行 = 時刻順。 DAG は同時刻辺 + ラグ辺
+--   (@x_j[t-l] → x_i[t]@) の**時間ラグ DAG**。
+data VARLiNGAMSpec = VARLiNGAMSpec !VARLiNGAMConfig ![Text]
+
+varLingam :: VARLiNGAMConfig -> [Text] -> VARLiNGAMSpec
+varLingam = VARLiNGAMSpec
+
+instance Fit VARLiNGAMSpec where
+  type Fitted VARLiNGAMSpec = LiNGAMFitted VARLiNGAMFit
+  fitEither (VARLiNGAMSpec cfg cols) d =
+    (\x -> LiNGAMFitted (fitVARLiNGAM cfg x) cols) <$> reqColsM cols d
+
+-- | PairwiseLiNGAM (2 変数の因果向き判定) の高レベル spec (Phase 77.B)。
+--   @df |-> pairwiseLingam 0.0 "x" "y"@ (thr = |score| 閾値・0 で符号のみ)。 DAG でなく
+--   2 ノード + 検出向きの矢印 (Inconclusive は無向)。
+data PairwiseLiNGAMSpec = PairwiseLiNGAMSpec !Double !Text !Text
+
+pairwiseLingam :: Double -> Text -> Text -> PairwiseLiNGAMSpec
+pairwiseLingam = PairwiseLiNGAMSpec
+
+instance Fit PairwiseLiNGAMSpec where
+  type Fitted PairwiseLiNGAMSpec = LiNGAMFitted PairwiseResult
+  fitEither (PairwiseLiNGAMSpec thr xc yc) d =
+    (\x y -> LiNGAMFitted (pairwiseLiNGAM thr x y) [xc, yc])
+      <$> reqColV xc d <*> reqColV yc d
+
+-- | BootstrapLiNGAM (エッジ確信度) の高レベル spec (Phase 77.C)。 @df |-> bootstrapLingam cfg cols@。
+--   B 回リサンプルして各エッジの出現確率を出す (seed 純粋・決定的)。 toPlot = 確信度 DAG。
+data BootstrapLiNGAMSpec = BootstrapLiNGAMSpec !BootstrapConfig ![Text]
+
+bootstrapLingam :: BootstrapConfig -> [Text] -> BootstrapLiNGAMSpec
+bootstrapLingam = BootstrapLiNGAMSpec
+
+instance Fit BootstrapLiNGAMSpec where
+  type Fitted BootstrapLiNGAMSpec = LiNGAMFitted BootstrapResult
+  fitEither (BootstrapLiNGAMSpec cfg cols) d =
+    (\x -> LiNGAMFitted (fitBootstrapLiNGAMPure cfg x) cols) <$> reqColsM cols d
+
+-- | ICA-LiNGAM (Shimizu 2006・FastICA + Hungarian) の高レベル spec (Phase 77.C)。
+--   @df |-> icaLingam cfg cols@ (seed 純粋・決定的)。 DAG は名前付き (ilAdjacency)。
+data ICALiNGAMSpec = ICALiNGAMSpec !ICALiNGAMConfig ![Text]
+
+icaLingam :: ICALiNGAMConfig -> [Text] -> ICALiNGAMSpec
+icaLingam = ICALiNGAMSpec
+
+instance Fit ICALiNGAMSpec where
+  type Fitted ICALiNGAMSpec = LiNGAMFitted ICALiNGAMFit
+  fitEither (ICALiNGAMSpec cfg cols) d =
+    (\x -> LiNGAMFitted (fitICALiNGAMPure cfg x) cols) <$> reqColsM cols d
+
+-- | Pearson 相関ネットワーク (Phase 77)。 @df |-> correlationOf thr cols@ で相関行列を求め、
+--   @toPlot@ で @|r| > thr@ の対を辺にしたグラフを描く (因果でなく**周辺相関**)。 LiNGAM DAG
+--   と対比すると、 相関は間接・交絡も辺にして過剰に密になり、 LiNGAM が直接因果に削減するのが分かる。
+data CorrelationSpec = CorrelationSpec !Double ![Text]
+
+correlationOf :: Double -> [Text] -> CorrelationSpec
+correlationOf = CorrelationSpec
+
+instance Fit CorrelationSpec where
+  type Fitted CorrelationSpec = CorrelationGraph
+  fitEither (CorrelationSpec thr cols) d =
+    (\x -> CorrelationGraph (correlationMatrix x) cols thr) <$> reqColsM cols d
+
+-- | ランダムフォレスト分類 spec。 @randomForestCls cfg seed ["x1","x2"] "cls"@ (cfg = 木数等・seed = 乱数種)。
+data RFCSpec = RFCSpec !RFCConfig !Word32 ![Text] !Text
+-- | @randomForestCls cfg seed featCols clsCol@ — RF 分類 ('RFClassifierFit'・toPlot = 重要度 2 パネル
+--   permutation/gini)。 回帰 'randomForestReg' と対の命名 (旧名 rfClassifier)。
+--   @df |-> randomForestCls defaultRFCConfig 42 ["x1","x2"] "cls"@。
+randomForestCls :: RFCConfig -> Word32 -> [Text] -> Text -> RFCSpec
+randomForestCls = RFCSpec
+instance Fit RFCSpec where
+  type Fitted RFCSpec = RFClassifierFit
+  -- 回帰 RFSpec と同方針: fit 時の実列名 (feats) をモデルへ。 seed 純粋版で df|-> に載せる。
+  fitEither (RFCSpec cfg seed feats cc) d =
+    (\x y -> (fitRFClassifierPure cfg x y seed) { rfcFeatureNames = feats })
+      <$> reqColsM feats d <*> reqLabelI cc d
+  -- 木ベースはスケール不変ゆえ predictorCols は設けない (標準化不要)。
+
+-- Phase 75.12: カーネル SVM 分類 (多クラス one-vs-rest・SMO は乱数不使用ゆえ純粋) 高レベル spec。
+data SVMSpec = SVMSpec !SVMConfig ![Text] !Text
+-- | @svmCls cfg featCols clsCol@ — カーネル SVM 分類 ('SVMMulti'・RBF/poly で
+--   非線形境界・真の SV)。 決定境界/SV 可視化は 'decisionBoundaryOf'/'svmSupportVectorsOf'。
+--   ハイパラ調整は @svmHyper@ ('SVMConfig') に畳む (GP と同型): 'SVMFixed' で固定 fit、
+--   'SVMTuneCV' grid で k-fold CV 探索 → 最良ハイパラで再学習 (別動詞は作らない)。
+svmCls :: SVMConfig -> [Text] -> Text -> SVMSpec
+svmCls = SVMSpec
+instance Fit SVMSpec where
+  type Fitted SVMSpec = SVMMulti
+  fitEither (SVMSpec cfg feats clsCol) d = do
+    x <- reqColsM feats d
+    (y, classes) <- reqLabelWithLevels clsCol d
+    let cfg' = case svmHyper cfg of
+                 SVMFixed       -> cfg
+                 SVMTuneCV grid -> fst (tuneSVM cfg grid x y)
+    pure ((fitSVMMulti cfg' x y) { svmmClassNames = classes })
+
+-- Phase 75.9: 古典 MLP 分類 (seed 純粋 'fitMLPClassifierPure') 高レベル spec。
+data MLPClsSpec = MLPClsSpec !MLPConfig !Word32 ![Text] !Text
+-- | @mlpCls cfg seed featCols clsCol@ — 古典 MLP 分類 ('MLPFit')。 決定境界/混同行列対応。
+mlpCls :: MLPConfig -> Word32 -> [Text] -> Text -> MLPClsSpec
+mlpCls = MLPClsSpec
+instance Fit MLPClsSpec where
+  type Fitted MLPClsSpec = MLPFit
+  fitEither (MLPClsSpec cfg seed feats clsCol) d = do
+    x            <- reqColsM feats d
+    (y, classes) <- reqLabelWithLevels clsCol d
+    pure ((fitMLPClassifierPure cfg x y seed) { mlpClassNames = classes })
+
+-- Phase 75.9: 古典 MLP 回帰 (seed 純粋 'fitMLPRegressorPure') 高レベル spec。
+data MLPRegSpec = MLPRegSpec !MLPConfig !Word32 ![Text] !Text
+-- | @mlpReg cfg seed featCols yCol@ — 古典 MLP 回帰 ('MLPFit'・応答は実数列)。
+mlpReg :: MLPConfig -> Word32 -> [Text] -> Text -> MLPRegSpec
+mlpReg = MLPRegSpec
+instance Fit MLPRegSpec where
+  type Fitted MLPRegSpec = MLPFit
+  fitEither (MLPRegSpec cfg seed feats yc) d =
+    (\x y -> fitMLPRegressorPure cfg x y seed) <$> reqColsM feats d <*> reqColV yc d
+
+
+-- --- 群別フィット spec (群別フィット) — Phase 52.A4 ------------------------
+--
+-- 群カテゴリ列で行を分割し、 各群に同じ spec を当てはめて N 個のモデルを得る。
+-- ★HBM 整合 = fit 結果 ('GroupedFit') が各群の 'Fitted spec' を保持し、
+-- 'groupModels' で取り出せる (各群 'LMModel' 等に既存診断 = 'Hanalyze.Model.LM.Diagnostics'
+-- がそのまま適用でき、 群間で傾きが本当に違うかを診断できる)。 描画は 'Plottable'
+-- で N 曲線を群色 + 凡例 ('ColorByCol' + 'scaleColorManual') で重畳する (A3 機構の一般化)。
+-- 群列は factor (文字) でも数値でも可 (factor は 'toFrame'+'getTextVec'、 数値は
+-- 'lookupCol' を show でラベル化)。 主経路 = 二変量近道、 formula spec も副で許す。
+
+-- | spec を「群別フィット」 でラップする。 @grouped "g" (lm "x" "y")@。
+--   既存 spec ('lm'\/'glm'\/'spline'\/'robust'\/'quantile'、 formula も可) は不変。
+data GroupedSpec spec = GroupedSpec !Text spec
+
+-- | @grouped "g" spec@ — 群列 @g@ で行を分け別々に @spec@ を当てはめる spec ラッパ。
+grouped :: Text -> spec -> GroupedSpec spec
+grouped = GroupedSpec
+
+-- | 各群の (ラベル, fit 済みモデル) を取り出す (hbmDraws\/forestOf 流アクセサ)。
+--   各群モデルに既存の診断 (例 LM なら 'Hanalyze.Model.LM.Diagnostics') がそのまま使える。
+groupModels :: GroupedFit spec -> [(Text, Fitted spec)]
+groupModels = gfGroups
+
+-- | 群ラベルの一覧 (出現順)。
+groupLabels :: GroupedFit spec -> [Text]
+groupLabels = map fst . gfGroups
+
+instance Fit spec => Fit (GroupedSpec spec) where
+  type Fitted (GroupedSpec spec) = GroupedFit spec
+  fitEither (GroupedSpec gcol sp) d = do
+    keys <- groupKeysCS gcol d
+    let cols     = numericCols d                       -- 数値列 (x/y 含む) 全部
+        ordered  = nub keys                            -- 群ラベル (出現順)
+        rowsOf k = [ i | (i, k') <- zip [0 :: Int ..] keys, k' == k ]
+        subOf ix = [ (n, [ vs !! i | i <- ix ]) | (n, vs) <- cols ]  -- 行抽出した sub 源
+    fits <- mapM (\k -> (,) k <$> fitEither sp (subOf (rowsOf k))) ordered
+    pure (GroupedFit fits)
+
+-- | 群列を行ごとのラベル ('Text') 列に正規化する。 factor (文字) 列は
+--   'toFrame'+'getTextVec'、 数値列は 'lookupCol' を show でラベル化。
+groupKeysCS :: ColumnSource d => Text -> d -> Either String [Text]
+groupKeysCS gcol d =
+  case getTextVec gcol (toFrame d) of
+    Just ts -> Right (V.toList ts)
+    Nothing -> case lookupCol gcol d of
+      Just vs -> Right (map showGroupLabel vs)
+      Nothing -> Left ("grouped: 群列が見つかりません: " <> T.unpack gcol)
+
+-- | クラス列を (整数ラベル列 0..K-1, levels 名) に正規化する。 factor(text) 列は
+--   'toFrame'+'getTextVec' で **辞書順 unique** を levels とし、 数値列は round して
+--   **昇順 unique** を levels とする (それぞれ show)。 いずれも各行を levels 中の
+--   index (0..K-1) に符号化するので、 @levels !! label@ でクラス名が引ける。
+--   ('reqLabelI' の levels 付き版・'treePlot' 等でクラス名を出すのに使う。)
+reqLabelWithLevels :: ColumnSource d => Text -> d -> Either String (VU.Vector Int, [Text])
+reqLabelWithLevels n d =
+  case getTextVec n (toFrame d) of
+    Just ts ->
+      let labels = V.toList ts
+          levels = sort (nub labels)                     -- factor は辞書順
+          ix x   = fromMaybe 0 (elemIndex x levels)
+      in Right (VU.fromList (map ix labels), levels)
+    Nothing -> case lookupCol n d of
+      Just vs ->
+        let ints   = map round vs :: [Int]
+            levels = sort (nub ints)                     -- 数値は昇順
+            ix x   = fromMaybe 0 (elemIndex x levels)
+        in Right (VU.fromList (map ix ints), map (T.pack . show) levels)
+      Nothing -> Left ("label 列が見つかりません: " <> T.unpack n)
+
+-- | 数値群ラベルの表示 (整数値は小数点を出さない)。
+showGroupLabel :: Double -> Text
+showGroupLabel v
+  | fromIntegral (round v :: Integer) == v = T.pack (show (round v :: Integer))
+  | otherwise                              = T.pack (show v)
+
+-- --- formula 多変量 spec (R 流) — Phase 51.3 -------------------------------
+--
+-- 二変量近道と違い、 formula 文字列で多変量を表す (R の @lm(y ~ x1 + x2, df)@)。
+-- 既存 'multiLMModel' / 'multiGLMModel' / 'fitMixedLME' を配線するだけ。
+-- データ源は 'toFrame' で 'DX.DataFrame' に変換し **Phase 47 経路**
+-- (MissingPolicy / contrast / 応答列判定) を通す (再実装しない)。 'DX.DataFrame'
+-- 源は 'toFrame'=id ゆえ factor/NA を温存。
+
+-- | formula LM spec。 @lmF "y ~ x1 + x2"@。
+data LMFormulaSpec   = LMFormulaSpec   !Text
+-- | formula GLM spec。 @glmF fam link "y ~ x1 + x2"@。
+data GLMFormulaSpec  = GLMFormulaSpec  !Family !LinkFn !Text
+-- | formula 混合モデル spec (random effects)。 @glmmF "y ~ x + (1|g)"@。
+data GLMMFormulaSpec = GLMMFormulaSpec !Text
+
+-- | @lmF "y ~ x1 + x2"@ — formula 多変量 LM ('MultiLMModel') の spec。
+lmF :: Text -> LMFormulaSpec
+lmF = LMFormulaSpec
+
+-- | @glmF fam link "y ~ x1 + x2"@ — formula 多変量 GLM ('MultiGLMModel') の spec。
+glmF :: Family -> LinkFn -> Text -> GLMFormulaSpec
+glmF = GLMFormulaSpec
+
+-- | @glmmF "y ~ x + (1|g)"@ — 線形混合モデル (Phase 48 'fitMixedLME') の spec。
+--   返り型は @('GLMMResultRE', [Text])@ (固定効果係数名つき)。
+glmmF :: Text -> GLMMFormulaSpec
+glmmF = GLMMFormulaSpec
+
+-- | DOE 設計 (`Design`) の解析 spec (Phase 78.B)。 設計が含意する formula
+--   (要因計画=全交互作用 / RSM=2 次) で **LM を当てる** (`MultiLMModel`)。 同じ @plan@ を
+--   sim データ・実物データに使い回せる (formula は plan 由来・データは任意)。
+--   @filledDf |-> designModel plan "y"@。
+data DesignModelSpec = DesignModelSpec !Design !Text
+
+designModel :: Design -> Text -> DesignModelSpec
+designModel = DesignModelSpec
+
+instance Fit DesignModelSpec where
+  type Fitted DesignModelSpec = MultiLMModel
+  fitEither (DesignModelSpec plan y) d = multiLMModel (designFormula plan y) (toFrame d)
+
+-- | DOE 設計 (`Design`) の **GP/RFF 解析** spec (Phase 78.G-e)。`designModel` (LM) の
+--   **非 LM 版**で、 plan の因子名で `gpMulti` を当てる (formula 不要 = kernel が非線形を
+--   吸収するので 2 次項展開が要らない)。 同じ @plan@ を sim/実物データに使い回せる
+--   (`designModel` と対称)。 結果 (`GPRegModelN`) は `MultiVarModel` ゆえ profiler/contour に
+--   **GP 事後予測帯**を出せる。 ★**連続因子専用** — 非連続因子 (`Num`/`Cat`) 混在は error
+--   (GP kernel はカテゴリ距離を扱えない・`centralCompositeDesign` と同方針)。
+--   @filledDf |-> designModelGP defaultGP plan "y"@。
+data DesignModelGPSpec = DesignModelGPSpec !GPConfig !Design !Text
+
+designModelGP :: GPConfig -> Design -> Text -> DesignModelGPSpec
+designModelGP = DesignModelGPSpec
+
+instance Fit DesignModelGPSpec where
+  type Fitted DesignModelGPSpec = GPRegModelN
+  fitEither (DesignModelGPSpec cfg plan y) d =
+    case [ dfName f | f <- dsFactors plan, notCont (dfKind f) ] of
+      []  -> fitEither (GPMultiSpec cfg (map dfName (dsFactors plan)) y) d
+      bad -> Left ("designModelGP: 連続因子専用 (GP kernel は非連続因子を扱えない)。"
+                   <> " 非連続因子: " <> show bad)
+    where notCont (Cont _ _ _) = False
+          notCont _            = True
+
+-- | 型付きランダム効果項 (Phase 78.G-f)。lme4 の @(1|g)@ / @(1+s|g)@ を型で表す。
+--   文字列 formula を経由せず 'designModelHBM' に渡す。
+ranIntercept :: Text -> RandomSpec
+ranIntercept g = RandomSpec True [] g
+
+ranSlope :: [Text] -> Text -> RandomSpec
+ranSlope slopes g = RandomSpec True slopes g
+
+-- | 前処理済みランダム効果 (Phase 78.G-f / G-f2)。
+--   @(群 idx (post-drop 行ごと), 群数, 傾き共変量列)@。 傾き列が空 = 切片のみ
+--   (@(1|g)@)、 非空 = 相関ランダム傾き (@(1+s|g)@) で各列が観測ごとの共変量値
+--   (長さ n・designX/ys と同じ post-drop 行整合)。
+type PreparedRE = ([Int], Int, [[Double]])
+
+-- | DOE 階層モデルの手書き 'ModelP' (Phase 78.G-f・核心 / G-f2 で相関傾きを高速化)。
+--   固定効果 = designX·β (β に弱情報 prior)、 観測ノイズ = σ。
+--   ★HBM に formula 文字列を食わせる経路は無い (Fit.hs の方針) ため手書きで組む。
+--
+--   ランダム効果は 2 経路:
+--   * **切片のみ** (全 RE の傾き列が空): 'reNormal' + 'observeLMR' の解析勾配 REff 経路
+--     (観測は affine = compiled 高速経路)。
+--   * **相関ランダム傾き** (いずれかの RE に傾き列): lme4 @(1+s|g)@。 Phase 80.2b で
+--     **非中心化** 化。 群成分 raw latent @z_g^c ~ N(0,1)@、 スケール @τ_c ~ HalfNormal@、
+--     相関 @Lcorr = LKJ Cholesky@ から @b_g^c = τ_c·Σ_{j≤c} Lcorr[c][j]·z_g^j@ を組み、
+--     観測 μ = β·X + Σ_c b_g^c·x_c を per-obs scalar 'observe' に載せる。 μ は τ·z / L·z の
+--     latent×latent 積を含む非 affine ゆえ (b) 閉形式には載らないが、 'synthVecIR' が
+--     (a) source-to-source AD (vecIR) に載せる (Phase 80.2a spike で per-eval 5×・funnel
+--     消滅を実測)。 centered の @potential@ 相関 prior + funnel (160×) は撤去。
+designHBMProgram :: [[Double]] -> [Text] -> [PreparedRE] -> [Double] -> ModelP ()
+designHBMProgram designX betaNames res ys
+  | all (\(_, _, sc) -> null sc) res = do
+      -- === 切片のみ: 解析勾配 REff 高速経路 ===
+      mapM_ (\nm -> sample nm (Normal 0 10)) betaNames
+      _sigma <- sample "sigma" (HalfNormal 5)
+      reffs  <- mapM mkInterceptRE (zip [0 ..] res)
+      observeLMR "y" betaNames designX reffs (LMGaussian "sigma") ys
+  | otherwise = do
+      -- === 相関ランダム傾き (非中心化): μ に τ·L·z (latent×latent) → (a) vecIR ===
+      -- Phase 80.2b: centered (potential 相関 prior + observeLMR + funnel 160×) を撤去し、
+      -- 非中心化 b_g^c = τ_c·Σ_{j≤c} Lcorr[c][j]·z_g^j を per-obs scalar 'observe' に載せる。
+      -- μ が latent×latent 非 affine ゆえ (b) 閉形式には載らないが、 'synthVecIR' が
+      -- (a) source-to-source AD に載せる (Phase 80.2a spike で per-eval 5×・funnel 消滅を実測)。
+      betas <- mapM (\nm -> sample nm (Normal 0 10)) betaNames
+      sigma <- sample "sigma" (HalfNormal 5)
+      -- 各 RE 群の per-obs 寄与関数 (i → Σ_c b_{g(i)}^c · w_c(i))。
+      contribs <- mapM (uncurry correlatedRE) (zip [0 ..] res)
+      let nObs   = length ys
+          muAt i = sum (zipWith (\b x -> b * realToFrac x) betas (designX !! i))
+                   + sum [ c i | c <- contribs ]
+      mapM_ (\i -> observe ("y_" <> T.pack (show i)) (Normal (muAt i) sigma) [ys !! i])
+            [0 .. nObs - 1]
+  where
+    -- 切片のみ RE (解析勾配): reNormal + at。
+    mkInterceptRE (gi :: Int, (idxRow, nG, _)) = do
+      let base = "u_g" <> T.pack (show gi)
+      tau <- sample ("tau_" <> base) (HalfNormal 5)
+      u   <- reNormal base nG ("tau_" <> base) tau
+      pure (u `at` idxRow)
+
+    -- 相関 RE (非中心化): 群成分 raw latent z_g^c ~ N(0,1) (成分ごと 1D plate = family)、
+    -- スケール τ_c ~ HalfNormal、 相関 Cholesky Lcorr = LKJ。 群効果は
+    -- b_g^c = τ_c·Σ_{j≤c} Lcorr[c][j]·z_g^j = (diag(τ)·Lcorr·z)_c を deterministic で記録。
+    -- 返り値 = per-obs 寄与関数 @i → Σ_c b_{g(i)}^c · w_c(i)@ (w_0=1 / w_c=slope列)。
+    correlatedRE (gi :: Int) (idxRow, nG, slopeCols) = do
+      let k       = 1 + length slopeCols
+          tag     = "g" <> T.pack (show gi)
+          znm c g = "z_" <> tag <> "_" <> T.pack (show c) <> "_" <> T.pack (show g)
+          bnm c g = "b_" <> tag <> "_" <> T.pack (show c) <> "_" <> T.pack (show g)
+      -- 成分 c ごとに群 raw latent の 1D plate: zByComp !! c !! g = z_g^c (family 単位)。
+      zByComp <- mapM (\c -> plateI ("z_" <> tag <> "_" <> T.pack (show c)) nG
+                               (\g -> sample (znm c g) (Normal 0 1)))
+                      [0 .. k - 1]
+      taus  <- mapM (\c -> sample ("tau_" <> tag <> "_" <> T.pack (show c)) (HalfNormal 5))
+                    [0 .. k - 1]
+      lcorr <- lkjCorrCholesky ("Lcorr_" <> tag) k 2.0
+      -- b_g^c = τ_c·Σ_{j≤c} Lcorr[c][j]·z_g^j を deterministic で記録 (chain 出力用) + 使用。
+      bByComp <- mapM (\c ->
+                    mapM (\g ->
+                      deterministic (bnm c g)
+                        (taus !! c * sum [ (lcorr !! c !! j) * ((zByComp !! j) !! g)
+                                         | j <- [0 .. c] ]))
+                      [0 .. nG - 1])
+                  [0 .. k - 1]
+      -- per-obs 寄与: 重み c=0→1 / c>=1→slope 列 (post-drop 行整合)。
+      let wOf c i | c == 0    = 1
+                  | otherwise = realToFrac ((slopeCols !! (c - 1)) !! i)
+      pure $ \i -> let g = idxRow !! i
+                   in sum [ ((bByComp !! c) !! g) * wOf c i | c <- [0 .. k - 1] ]
+
+-- | DOE 設計の **階層ベイズ (mixed-effects)** fit spec (Phase 78.G-f)。 固定効果 =
+--   'designFormula' (factorial=交互作用 / RSM=2次) を LM 経路 (`modelFrame`/`designMatrixF`)
+--   で設計行列化し、 ランダム効果 = 'RandomSpec' の群 (v1 = random intercept のみ) を
+--   'designHBMProgram' で手書き 'ModelP' に組み、 'hbm' (NUTS) で学習する。 事後 draw を
+--   'DesignHBMFit' に格納する。 同じ @plan@ を sim/実物データに使い回せる
+--   ('designModel' / 'designModelGP' と対称)。
+--   @filledDf |-> designModelHBM defaultHBM plan [ranIntercept "lot"] "y"@。
+data DesignHBMFit = DesignHBMFit
+  { dhfFormula    :: !Formula      -- ^ 固定効果 formula ('designFormula' plan y の parse 結果)。
+  , dhfBetaNames  :: ![Text]       -- ^ 固定効果係数名 (設計列順)。
+  , dhfBetaDraws  :: ![[Double]]   -- ^ draws × p ('dhfBetaNames' 列順)。
+  , dhfSigmaDraws :: ![Double]     -- ^ 観測ノイズ σ の事後 draw。
+  , dhfFrame      :: !ModelFrame   -- ^ 訓練 frame (mvFrame 用)。
+  , dhfModel      :: !HBMModel     -- ^ 学習済 HBM 本体。 診断抽出子 ('dagOf' / 'tracesOf' /
+                                   --   'ppcOf' / 'energyOf' 等) に @dhfModel fit@ で渡せる。
+  }
+
+data DesignModelHBMSpec = DesignModelHBMSpec !HBMConfig !Design ![RandomSpec] !Text
+
+designModelHBM :: HBMConfig -> Design -> [RandomSpec] -> Text -> DesignModelHBMSpec
+designModelHBM = DesignModelHBMSpec
+
+instance Fit DesignModelHBMSpec where
+  type Fitted DesignModelHBMSpec = DesignHBMFit
+  fitEither (DesignModelHBMSpec cfg plan res y) d = do
+    fml               <- parseModel (designFormula plan y)
+    let df       = toFrame d
+        -- 'modelFrame' (DropRows) は formula 関与列 (resp:dvars) に NA を含む行を listwise
+        -- 削除する。 群 idx (prepRE) は raw df からでなく、 designX/ys と同じ post-drop 行
+        -- 集合 (dfClean) から作る必要がある (でないと NA 行がある場合に群 idx が行ずれし、
+        -- observeLMR へ渡す群インデックスが誤対応する)。 dropMissingRows は modelFrame 内部
+        -- (modelFrameWith) と同一関数・同一 involved 列なので、 落とす行集合・順序が一致する。
+        involved = formResponse fml : formDataVars fml
+        dfClean  = dropMissingRows involved df
+    mf                <- modelFrame fml df
+    (xMat, betaNames) <- designMatrixF fml mf
+    yv                <- responseVec mf
+    let designX = map LA.toList (LA.toRows xMat)
+        ys      = V.toList yv
+    resPrepared <- mapM (prepRE dfClean) res
+    let prog :: ModelP ()
+        prog      = designHBMProgram designX betaNames resPrepared ys
+        model     = d |-> hbm cfg prog :: HBMModel
+        betaDraws = [ concatMap (chainVals nm) (hbmChainsR model) | nm <- betaNames ]
+    pure DesignHBMFit
+      { dhfFormula    = fml
+      , dhfBetaNames  = betaNames
+      , dhfBetaDraws  = transpose betaDraws
+      , dhfSigmaDraws = concatMap (chainVals "sigma") (hbmChainsR model)
+      , dhfFrame      = mf
+      , dhfModel      = model
+      }
+    where
+      prepRE frame (RandomSpec _ slopes g) = case getTextVec g frame of
+        Nothing -> Left ("designModelHBM: grouping 列 '" <> T.unpack g <> "' が見つかりません")
+        Just gv -> do
+          let (labels, idx, _) = buildGroups gv
+          -- 傾き共変量列 (post-drop 行整合 = 群列と同じ dfClean 由来)。 空なら切片のみ。
+          slopeCols <- mapM (getSlopeCol frame) slopes
+          Right (V.toList idx, V.length labels, slopeCols)
+      getSlopeCol frame s = case lookupCol s frame of
+        Just col -> Right col
+        Nothing  -> Left ("designModelHBM: 傾き共変量列 '" <> T.unpack s
+                          <> "' が見つかりません (数値列である必要があります)")
+
+-- | 多出力 (複数応答) fit コンビネータ (Phase 78.F)。 応答名のリストと **応答名から spec を作る
+--   関数**を受け、 各応答を同じデータ源で当てはめて @[(応答名, Fitted spec)]@ を返す。
+--   @designModel plan@ が既にカレー化 (@Text -> DesignModelSpec@) なので接着剤なしで slot に嵌る:
+--
+--   > let model = filledDf |-> multiOutput ["strength","yield"] (designModel plan)
+--   >     -- model :: [(Text, MultiLMModel)]
+--
+--   designModel 専用でなく汎用 (@multiOutput ys (lmF . mkFormula)@ 等も可)。 結果は profiler
+--   ('Hanalyze.Plot.ML.profiler') が「行=応答 × 列=因子」 のグリッドに描ける。
+data MultiOutputSpec spec = MultiOutputSpec ![Text] (Text -> spec)
+
+-- | @multiOutput responseNames mkSpec@ — 各応答名に @mkSpec@ を適用して当てはめる。
+multiOutput :: [Text] -> (Text -> spec) -> MultiOutputSpec spec
+multiOutput = MultiOutputSpec
+
+-- | @multiOutput@ の結果 @[(応答名, model)]@ から**応答名でモデルを 1 つ取り出す**。 単一応答の
+--   可視化 (@contourOf@ / @surfaceOf@) に multiOutput の結果を渡すとき @snd (head model)@ の代わりに使う。
+--
+--   > let models = filledDf |-> multiOutput ["strength","yield"] (designModel plan)
+--   > noDf |>> contourOf (modelFor "strength" models) "temp" "time"
+--
+--   応答名が無ければ利用可能な名前を添えて error (対話で気付ける)。
+modelFor :: Text -> [(Text, m)] -> m
+modelFor r models = case lookup r models of
+  Just m  -> m
+  Nothing -> error
+    ("modelFor: 応答 '" <> T.unpack r <> "' が見つかりません。 利用可能: "
+      <> show (map (T.unpack . fst) models))
+
+instance Fit spec => Fit (MultiOutputSpec spec) where
+  type Fitted (MultiOutputSpec spec) = [(Text, Fitted spec)]
+  fitEither (MultiOutputSpec ys mk) d =
+    traverse (\y -> (,) y <$> fitEither (mk y) d) ys
+
+instance Fit LMFormulaSpec where
+  type Fitted LMFormulaSpec = MultiLMModel
+  fitEither (LMFormulaSpec fml) d = multiLMModel fml (toFrame d)
+
+instance Fit GLMFormulaSpec where
+  type Fitted GLMFormulaSpec = MultiGLMModel
+  fitEither (GLMFormulaSpec fam lnk fml) d = multiGLMModel fam lnk fml (toFrame d)
+
+instance Fit GLMMFormulaSpec where
+  type Fitted GLMMFormulaSpec = (GLMMResultRE, [Text])
+  fitEither (GLMMFormulaSpec fml) d = fitMixedLME fml (toFrame d)
+
+-- --- 重回帰 spec (列名リスト・formula 不要) — Phase 70.D --------------------
+--
+-- 重回帰 = 説明変数の**列名リスト**で多変量回帰を当てる (formula 文字列を書かない)。
+-- 内部は 'additiveFormula' で設計行列 @[1, x1,…,xp]@ を直接合成し、 既存の
+-- 'multiLMModelF' / 'multiGLMModelF' / 'multiRobustModelF' を配線する。 返り値は
+-- effect plot ('statModelMulti' / along / holdAt / byVar) と係数サマリ
+-- ('coefSummary') の両方が即使える。 'lmF' / 'glmF' は formula 糖衣として併存。
+
+-- | 重回帰 (多変量 LM) spec。 @lmMulti [\"x1\",\"x2\",\"x3\"] \"y\"@。
+data LMMultiSpec     = LMMultiSpec     ![Text] !Text
+-- | 重回帰 (多変量 GLM) spec。 @glmMulti fam link [\"x1\",\"x2\"] \"y\"@。
+data GLMMultiSpec    = GLMMultiSpec    !Family !LinkFn ![Text] !Text
+-- | 重回帰 (多変量ロバスト) spec。 @robustMulti est [\"x1\",\"x2\"] \"y\"@。
+data RobustMultiSpec = RobustMultiSpec !RobustEstimator ![Text] !Text
+
+-- | @lmMulti predCols yCol@ — 列名リストで多変量線形回帰 ('MultiLMModel')。
+--   @df |-> lmMulti [\"age\",\"bmi\",\"bp\"] \"y\"@。
+lmMulti :: [Text] -> Text -> LMMultiSpec
+lmMulti = LMMultiSpec
+
+-- | @glmMulti fam link predCols yCol@ — 列名リストで多変量 GLM ('MultiGLMModel')。
+glmMulti :: Family -> LinkFn -> [Text] -> Text -> GLMMultiSpec
+glmMulti = GLMMultiSpec
+
+-- | @rlmMulti est predCols yCol@ — 列名リストで多変量ロバスト回帰 ('MultiRobustModel')。
+rlmMulti :: RobustEstimator -> [Text] -> Text -> RobustMultiSpec
+rlmMulti = RobustMultiSpec
+
+instance Fit LMMultiSpec where
+  type Fitted LMMultiSpec = MultiLMModel
+  fitEither (LMMultiSpec xs y) d = multiLMModelF (additiveFormula y xs) (toFrame d)
+  predictorCols (LMMultiSpec xs _) = xs
+  responseCol   (LMMultiSpec _ y)  = Just y
+
+instance Fit GLMMultiSpec where
+  type Fitted GLMMultiSpec = MultiGLMModel
+  fitEither (GLMMultiSpec fam lnk xs y) d =
+    multiGLMModelF fam lnk (additiveFormula y xs) (toFrame d)
+  predictorCols (GLMMultiSpec _ _ xs _) = xs
+  -- responseCol = Nothing (既定)。 GLM 応答は family/link 拘束ゆえ標準化不可。
+
+instance Fit RobustMultiSpec where
+  type Fitted RobustMultiSpec = MultiRobustModel
+  fitEither (RobustMultiSpec est xs y) d =
+    multiRobustModelF est (additiveFormula y xs) (toFrame d)
+  predictorCols (RobustMultiSpec _ xs _) = xs
+  responseCol   (RobustMultiSpec _ _ y)  = Just y
+
+-- | 多変量 (重回帰) 分位点回帰 spec。 @quantileMulti [0.1,0.5,0.9] ["x1","x2"] "y"@。
+--   単変量 'quantile' の多予測子版 (各 τ を設計行列 @[1,x₁..xₚ]@ に当てる・statsmodels
+--   @QuantReg@ 多予測子と同型)。 予測子は数値列を 'reqColsM' で直接行列化する。
+data QuantileMultiSpec = QuantileMultiSpec ![Double] ![Text] !Text
+
+-- | @rqMulti taus predCols yCol@ — 多変量分位点回帰 ('MultiQuantileModel')。
+rqMulti :: [Double] -> [Text] -> Text -> QuantileMultiSpec
+rqMulti = QuantileMultiSpec
+
+instance Fit QuantileMultiSpec where
+  type Fitted QuantileMultiSpec = MultiQuantileModel
+  fitEither (QuantileMultiSpec taus xs yn) d = do
+    xm <- reqColsM xs d                                   -- n × p (予測子)
+    yv <- reqColV yn d
+    let nR   = LA.rows xm
+        dm   = LA.fromColumns (LA.konst 1 nR : LA.toColumns xm)  -- [1, x₁..xₚ]
+        fits = [ (t, fitQuantile t dm yv) | t <- taus ]
+    Right MultiQuantileModel { mqmTaus = taus, mqmNames = xs, mqmFits = fits, mqmX = dm }
+  predictorCols (QuantileMultiSpec _ xs _) = xs
+  responseCol   (QuantileMultiSpec _ _ yn) = Just yn
+
+-- --- HBM spec + データ散布図 — Phase 51.4 ----------------------------------
+--
+-- HBM は formula を取らず手書き 'ModelP' を学習する (brms 風 formula→HBM は別 Phase)。
+-- spec は既存 'hbmModelPure' (純粋・seed 決定的) を配線するだけ。 データ源の
+-- 数値列をすべて取り出し列名 assoc にして渡す (HBM 側 'dataNamed' 名と突合)。
+
+-- | HBM spec。 @hbm cfg model@ (設定 + 手書き確率プログラム)。
+data HBMSpec = HBMSpec HBMConfig (ModelP ())
+
+-- | @hbm cfg model@ — HBM ('HBMModel') を学習する spec。 cfg の seed で決定的。
+hbm :: HBMConfig -> ModelP () -> HBMSpec
+hbm = HBMSpec
+
+-- | データ源の数値列をすべて列名 assoc に取り出す (HBM の入力形)。
+numericCols :: ColumnSource d => d -> [(Text, [Double])]
+numericCols d = [ (n, vs) | n <- columnNames d, Just vs <- [lookupCol n d] ]
+
+instance Fit HBMSpec where
+  type Fitted HBMSpec = HBMModel
+  -- Phase 60.3: 空 placeholder slot の突合を loud error 化 (旧: 黙って空 [] の
+  -- まま学習が走り gids=[] 等の不可解な失敗になっていた)。 DataIx slot は
+  -- Int/Integer 列直結 + Text factor 列の sort 順自動コード化。
+  fitEither (HBMSpec cfg model) d = do
+    checkDataSlots model d
+    (ixCols, levels) <- resolveIxSlots model d
+    Right (hbmModelPureWith cfg model (numericCols d) ixCols levels)
+  -- Phase 61.4: '(|->!)' 経路 = 同じ列解決 + 進捗表示つき IO 学習。
+  -- seed 規約は pure 経路と共有ゆえ結果はビット一致 (test 固定)。
+  fitIO (HBMSpec cfg model) d =
+    case (do checkDataSlots model d; resolveIxSlots model d) of
+      Left err              -> ioError (userError err)
+      Right (ixCols, levels) ->
+        hbmModelIOWith cfg model (numericCols d) ixCols levels
+
+-- | 'dataNamed' slot の突合検査 (Phase 60.3): **空 placeholder** (@dataNamed n []@)
+-- なのに対応する数値列が無ければ loud error。 実値入り placeholder の列欠落は
+-- default 続行 (データ直書きの正当パターンを壊さない)。
+checkDataSlots :: ColumnSource d => ModelP () -> d -> Either String ()
+checkDataSlots model d =
+  case [ n | (n, True) <- dataSlots model
+           , Nothing <- [lookupCol n d] ] of
+    [] -> Right ()
+    ns -> Left ("HBM: 空 placeholder の dataNamed slot に対応する数値列が"
+                <> "ありません: " <> T.unpack (T.intercalate ", " ns)
+                <> " (利用可能列: "
+                <> T.unpack (T.intercalate ", " (columnNames d))
+                <> ")。 Integer/Text 数値文字列は許容、 factor 列は dataNamedIx で")
+
+-- | 'dataNamedIx' slot を列から解決する (Phase 60.3)。
+--   * Text factor 列 → **sort 順** (辞書順) levels に 0.. コード化
+--     (R @factor()@ / pandas parity・行順 shuffle に不変)
+--   * 数値列 (Int / Integer / 整数値の Double) → @round@ で [Int]
+--   * 空 placeholder で列なし / 非整数値 → loud error ('Left')
+--   * 実値入り placeholder で列なし → default 続行 (bind しない)
+resolveIxSlots :: ColumnSource d => ModelP () -> d
+               -> Either String ([(Text, [Int])], [(Text, [Text])])
+resolveIxSlots model d = do
+  rs <- mapM resolve (dataIxSlots model)
+  pure ( [ (n, is) | (n, Just is, _) <- rs ]
+       , [ (n, ls) | (n, _, Just ls) <- rs ] )
+  where
+    fr = toFrame d
+    resolve (n, isEmpty) = case getTextVec n fr of
+      Just tv ->
+        let vals   = V.toList tv
+            levels = sort (nub vals)
+            code t = fromMaybe 0 (elemIndex t levels)   -- levels 由来ゆえ必ず命中
+        in Right (n, Just (map code vals), Just levels)
+      Nothing -> case lookupCol n d of
+        Just vs
+          | all (\v -> fromIntegral (round v :: Int) == v) vs ->
+              Right (n, Just (map round vs), Nothing)
+          | otherwise ->
+              Left ("HBM: dataNamedIx slot " <> T.unpack n
+                    <> " の列に非整数値があります (離散 index 専用)")
+        Nothing
+          | isEmpty ->
+              Left ("HBM: 空 placeholder の dataNamedIx slot に対応する列が"
+                    <> "ありません: " <> T.unpack n
+                    <> " (利用可能列: "
+                    <> T.unpack (T.intercalate ", " (columnNames d)) <> ")")
+          | otherwise -> Right (n, Nothing, Nothing)
+
diff --git a/src/Hanalyze/MCMC/BayesianTest.hs b/src/Hanalyze/MCMC/BayesianTest.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/MCMC/BayesianTest.hs
@@ -0,0 +1,227 @@
+-- |
+-- Module      : Hanalyze.MCMC.BayesianTest
+-- Description : Bayesian A/B test — 2 群間の平均差を NUTS でサンプルし ROPE/HDI で判定
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Bayesian A/B test helper — 2 群間の平均差を NUTS でサンプル、
+-- ROPE / HDI に基づき決定。
+--
+-- Spotfire 風 "Good vs Bad" の Bayesian 版。 既存の頻度論版
+-- ('Hanalyze.Stat.GroupComparison.goodVsBad') が Welch t + Cohen's d で
+-- 並列比較するのに対し、 本モジュールは **2 群の平均差の posterior** を
+-- 得て、 HDI (highest density interval) + ROPE (region of practical
+-- equivalence) で意思決定する。
+--
+-- モデル:
+--
+-- @
+-- μ_A    ~ Normal(0, priorScale)
+-- μ_B    ~ Normal(0, priorScale)
+-- σ_A    ~ HalfNormal(sigmaScale)
+-- σ_B    ~ HalfNormal(sigmaScale)
+-- y_A    ~ Normal(μ_A, σ_A)
+-- y_B    ~ Normal(μ_B, σ_B)
+-- diff   = μ_B - μ_A
+-- @
+--
+-- 決定ルール (`ROPEDecision lo hi`):
+--
+--   * HDI が ROPE [lo, hi] と **重ならず HDI 全体が ROPE の外** → 'RejectH0'
+--   * HDI が ROPE 内に **完全に含まれる** → 'AcceptH0'
+--   * それ以外 → 'Inconclusive'
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE BangPatterns #-}
+{-# LANGUAGE RankNTypes #-}
+module Hanalyze.MCMC.BayesianTest
+  ( -- * 入力
+    BayesianABConfig (..)
+  , DecisionRule (..)
+  , defaultBayesianABConfig
+    -- * 出力
+  , BayesianABResult (..)
+  , ABDecision (..)
+    -- * 実行
+  , bayesianAB
+    -- * 補助
+  , highestDensityInterval
+  ) where
+
+import qualified Data.Map.Strict       as Map
+import           Data.List             (sort)
+import qualified System.Random.MWC     as MWC
+
+import qualified Hanalyze.MCMC.Core    as MC
+import qualified Hanalyze.MCMC.NUTS    as NUTS
+import qualified Hanalyze.Model.HBM    as HBM
+
+-- ===========================================================================
+-- 型
+-- ===========================================================================
+
+-- | 意思決定ルール。
+data DecisionRule
+  = HDIOnly                     -- ^ HDI を計算するのみ、 自動判定しない
+  | ROPEDecision !Double !Double
+    -- ^ @ROPEDecision lo hi@ で「実用上 0 と区別不能な区間 @[lo, hi]@」 を指定
+  deriving (Show, Eq)
+
+-- | A/B 試験の入力設定。
+data BayesianABConfig = BayesianABConfig
+  { babCredible   :: !Double         -- ^ HDI の信頼水準 (例 0.95)
+  , babRule       :: !DecisionRule
+  , babPriorScale :: !Double         -- ^ μ_A, μ_B の prior σ (default 10)
+  , babSigmaScale :: !Double         -- ^ HalfNormal σ の scale (default 5)
+  , babNUTS       :: !NUTS.NUTSConfig
+  } deriving (Show)
+
+defaultBayesianABConfig :: BayesianABConfig
+defaultBayesianABConfig = BayesianABConfig
+  { babCredible   = 0.95
+  , babRule       = HDIOnly
+  , babPriorScale = 10.0
+  , babSigmaScale = 5.0
+  , babNUTS       = NUTS.defaultNUTSConfig
+                      { NUTS.nutsIterations = 1000
+                      , NUTS.nutsBurnIn     = 500
+                      }
+  }
+
+-- | 自動判定の結果。
+data ABDecision
+  = AcceptH0       -- ^ HDI が ROPE 内 → 「実用上 0」 と判定
+  | RejectH0       -- ^ HDI が ROPE の外 → 「明確に差がある」 と判定
+  | Inconclusive   -- ^ HDI が ROPE と部分的に重なる → 「データ不足」
+  | NoRuleApplied  -- ^ 'HDIOnly' 指定で判定なし
+  deriving (Show, Eq)
+
+-- | A/B 試験の出力。
+data BayesianABResult = BayesianABResult
+  { babPosteriorDiff :: ![Double]
+    -- ^ 平均差 (μ_B − μ_A) の post-burn-in サンプル
+  , babMeanDiff      :: !Double
+    -- ^ posterior mean (μ_B − μ_A)
+  , babHDI           :: !(Double, Double)
+    -- ^ @babCredible@ 信頼水準の HDI
+  , babDecision      :: !ABDecision
+  , babProbDiffPos   :: !Double
+    -- ^ @P(μ_B > μ_A)@ の posterior 確率
+  , babChain         :: !MC.Chain
+    -- ^ 生 chain (μ_A / μ_B / σ_A / σ_B / diff の post-burn-in サンプル)
+  } deriving (Show)
+
+-- ===========================================================================
+-- 公開関数
+-- ===========================================================================
+
+-- | 2 群のデータから Bayesian A/B 試験を実行。
+--
+-- 内部で HBM モデルを組み立て、 NUTS で posterior をサンプル、
+-- 平均差の HDI と決定を返す。
+--
+-- 失敗条件: いずれかの群が空 → @error@ (canvas backend では事前に検査)。
+bayesianAB
+  :: BayesianABConfig
+  -> [Double]         -- ^ 群 A の観測値
+  -> [Double]         -- ^ 群 B の観測値
+  -> MWC.GenIO
+  -> IO BayesianABResult
+bayesianAB cfg ysA ysB gen
+  | null ysA || null ysB =
+      error "Hanalyze.MCMC.BayesianTest.bayesianAB: both groups must be non-empty"
+  | otherwise = do
+      let priorScale_ = babPriorScale cfg
+          sigmaScale_ = babSigmaScale cfg
+          model :: HBM.ModelP ()
+          model = do
+            muA <- HBM.sample "mu_a"    (HBM.Normal 0 (realToFrac priorScale_))
+            muB <- HBM.sample "mu_b"    (HBM.Normal 0 (realToFrac priorScale_))
+            sA  <- HBM.sample "sigma_a" (HBM.HalfNormal (realToFrac sigmaScale_))
+            sB  <- HBM.sample "sigma_b" (HBM.HalfNormal (realToFrac sigmaScale_))
+            HBM.observe "ya" (HBM.Normal muA sA) ysA
+            HBM.observe "yb" (HBM.Normal muB sB) ysB
+            _   <- HBM.deterministic "diff" (muB - muA)
+            pure ()
+          initParams = Map.fromList
+            [ ("mu_a", mean ysA)
+            , ("mu_b", mean ysB)
+            , ("sigma_a", max 0.1 (stddev ysA))
+            , ("sigma_b", max 0.1 (stddev ysB))
+            ]
+      rawChain <- NUTS.nuts model (babNUTS cfg) initParams gen
+      -- deterministic 値 "diff" は raw chain に入っていないため augment で注入
+      let chain = HBM.augmentChainWithDeterministic model rawChain
+          diffs = MC.chainVals "diff" chain
+          n     = length diffs
+          mu    = if n == 0 then 0 else sum diffs / fromIntegral n
+          hdi   = highestDensityInterval (babCredible cfg) diffs
+          probP = if n == 0
+                    then 0
+                    else fromIntegral (length (filter (> 0) diffs))
+                       / fromIntegral n
+          decision = case babRule cfg of
+            HDIOnly -> NoRuleApplied
+            ROPEDecision lo hi -> classifyROPE hdi lo hi
+      pure BayesianABResult
+        { babPosteriorDiff = diffs
+        , babMeanDiff      = mu
+        , babHDI           = hdi
+        , babDecision      = decision
+        , babProbDiffPos   = probP
+        , babChain         = chain
+        }
+
+-- ===========================================================================
+-- 補助
+-- ===========================================================================
+
+-- | サンプル列の **highest density interval (HDI)**。
+--
+-- ソート後、 窓幅 @floor(n · level)@ で全 sliding window を試し、
+-- 最も狭い窓を返す。 unimodal な posterior では HDI = 最短連続区間。
+--
+-- @level ∈ (0, 1)@、 例: 0.95 で 95% HDI。
+highestDensityInterval :: Double -> [Double] -> (Double, Double)
+highestDensityInterval level xs
+  | null xs = (0, 0)
+  | level <= 0 || level >= 1 = error "HDI: level must be in (0, 1)"
+  | otherwise =
+      let sorted = sort xs
+          n      = length sorted
+          k      = max 1 (floor (fromIntegral n * level :: Double))
+          -- 全 sliding windows (start = 0 .. n-k)
+          arr    = case sorted of
+                     [] -> []
+                     _  -> sorted
+          windows = [ (arr !! i, arr !! (i + k - 1))
+                    | i <- [0 .. n - k] ]
+          -- 最も狭い窓
+          best   = head $ foldr keepNarrower [head windows] (tail windows)
+      in best
+  where
+    keepNarrower w (b:_) =
+      if (snd w - fst w) < (snd b - fst b) then [w] else [b]
+    keepNarrower w []    = [w]
+
+-- | HDI と ROPE [lo, hi] から ABDecision を分類。
+classifyROPE :: (Double, Double) -> Double -> Double -> ABDecision
+classifyROPE (hdiLo, hdiHi) ropeLo ropeHi
+  | hdiHi < ropeLo || hdiLo > ropeHi = RejectH0       -- HDI 全体が ROPE 外
+  | hdiLo >= ropeLo && hdiHi <= ropeHi = AcceptH0     -- HDI 全体が ROPE 内
+  | otherwise = Inconclusive                          -- 部分重複
+
+-- ===========================================================================
+-- 統計 helper
+-- ===========================================================================
+
+mean :: [Double] -> Double
+mean [] = 0
+mean xs = sum xs / fromIntegral (length xs)
+
+stddev :: [Double] -> Double
+stddev xs
+  | length xs < 2 = 1
+  | otherwise =
+      let n = fromIntegral (length xs) :: Double
+          m = mean xs
+      in sqrt (sum [ (x - m) ** 2 | x <- xs ] / (n - 1))
diff --git a/src/Hanalyze/MCMC/Core.hs b/src/Hanalyze/MCMC/Core.hs
--- a/src/Hanalyze/MCMC/Core.hs
+++ b/src/Hanalyze/MCMC/Core.hs
@@ -1,8 +1,14 @@
-{-# LANGUAGE OverloadedStrings #-}
--- | Common MCMC types and posterior statistics.
+-- |
+-- Module      : Hanalyze.MCMC.Core
+-- Description : MCMC 共通の Chain 型と事後統計量 (mean/SD/分位点)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
 --
+-- Common MCMC types and posterior statistics.
+--
 -- Sampler-agnostic: this is the foundation when @MCMC.*@ is used as a
 -- standalone sampling library.
+{-# LANGUAGE OverloadedStrings #-}
 module Hanalyze.MCMC.Core
   ( -- * チェーン型
     Chain (..)
@@ -21,7 +27,9 @@
 import Data.Text (Text)
 import Data.Word (Word32)
 import qualified Data.Vector as V
-import System.Random.MWC (GenIO, uniform, initialize)
+import System.Random.MWC (Gen, GenIO, uniform, initialize)
+import Control.Monad.Primitive (PrimMonad, PrimState)
+import Control.DeepSeq (NFData (..))
 
 -- ---------------------------------------------------------------------------
 -- Chain
@@ -41,8 +49,18 @@
     --   transition (post-burn-in). Following Stan, the criterion is
     --   @|H_proposal − H_initial| > 1000@. Many divergences signal a
     --   pathological posterior that needs reparameterization.
+  , chainTreeDepths :: [Int]
+    -- ^ Phase 85.3: NUTS の per-draw tree depth (実行された doubling 回数・
+    --   post-burn-in・draw 順)。 leapfrog 数 ≈ 2^depth ゆえ per-draw コストの
+    --   診断に使う (PyMC の tree_depth 相当)。 NUTS 以外のサンプラは []。
   } deriving (Show)
 
+-- | Phase 50: 純粋 multi-chain (`nutsChainsPure`) で @parList rdeepseq@ により
+-- chain 横断を spark 並列評価するため、 'Chain' を完全評価できるようにする。
+instance NFData Chain where
+  rnf (Chain s a t e d td) =
+    rnf s `seq` rnf a `seq` rnf t `seq` rnf e `seq` rnf d `seq` rnf td
+
 -- ---------------------------------------------------------------------------
 -- Summary statistics
 -- ---------------------------------------------------------------------------
@@ -89,9 +107,12 @@
 -- Utility
 -- ---------------------------------------------------------------------------
 
--- | Spawn an independent child 'GenIO' seeded from a parent generator.
+-- | Spawn an independent child generator seeded from a parent generator.
 -- Used to give each parallel chain a different seed.
-spawnGen :: GenIO -> IO GenIO
+--
+-- Phase 50: 'PrimMonad' に一般化 (既存 IO 呼出は @m=IO@ で不変)。 これにより
+-- @ST s@ でも同じ種まきができ、 純粋な multi-chain (runST + seed) に使える。
+spawnGen :: PrimMonad m => Gen (PrimState m) -> m (Gen (PrimState m))
 spawnGen base = do
-  seed <- uniform base :: IO Word32
-  initialize (V.singleton seed)
+  seed <- uniform base
+  initialize (V.singleton (seed :: Word32))
diff --git a/src/Hanalyze/MCMC/Gibbs.hs b/src/Hanalyze/MCMC/Gibbs.hs
--- a/src/Hanalyze/MCMC/Gibbs.hs
+++ b/src/Hanalyze/MCMC/Gibbs.hs
@@ -1,11 +1,17 @@
-{-# LANGUAGE OverloadedStrings #-}
-{-# LANGUAGE RankNTypes #-}
--- | Gibbs sampler — analytic full-conditional sampling for conjugate priors.
+-- |
+-- Module      : Hanalyze.MCMC.Gibbs
+-- Description : 共役事前分布向け Gibbs sampler (解析的フル条件付きサンプリング)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
 --
+-- Gibbs sampler — analytic full-conditional sampling for conjugate priors.
+--
 -- Each 'GibbsUpdate' draws a single parameter directly from its full
 -- conditional distribution, so no Metropolis rejection step is needed and
 -- every sample is accepted. When non-conjugate parameters are mixed in,
 -- combine with Metropolis-Hastings ('gibbsMH').
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RankNTypes #-}
 module Hanalyze.MCMC.Gibbs
   ( -- * 共役アップデートブロック
     GibbsUpdate
@@ -19,23 +25,33 @@
   , gibbs
   , gibbsBetaBinomial
   , gibbsChains
+  , gibbsPure
+  , gibbsChainsPure
+  , gibbsBetaBinomialPure
     -- * HBM-DSL integration: conjugacy auto-detection
   , gibbsFromModel
     -- * Hybrid Gibbs+MH sampler
   , gibbsMH
   , gibbsMHChains
+  , gibbsMHPure
+  , gibbsMHChainsPure
   ) where
 
 import Control.Concurrent.Async (mapConcurrently)
 import Control.Monad (foldM, replicateM, when)
-import Data.IORef
+import Control.Monad.Primitive (PrimMonad, PrimState)
+import Control.Monad.ST (runST)
+import Control.Parallel.Strategies (parList, rdeepseq, using)
+import Data.Primitive.MutVar
 import Data.List (nub)
 import Data.Maybe (listToMaybe)
+import Data.Word (Word32)
 import qualified Data.Map.Strict as Map
 import Data.Map.Strict (Map)
 import Data.Text (Text)
+import qualified Data.Vector as V
 import qualified Data.Vector.Storable as VS
-import System.Random.MWC (GenIO, uniform)
+import System.Random.MWC (Gen, GenIO, uniform, initialize)
 import System.Random.MWC.Distributions (gamma, normal)
 
 import Hanalyze.MCMC.Core (Chain (..), spawnGen)
@@ -50,7 +66,11 @@
 -- | A Gibbs update block. Receives the current parameter set and returns
 -- a single fresh @(name, value)@ sampled from the assigned parameter's
 -- full conditional distribution.
-type GibbsUpdate = Params -> GenIO -> IO (Text, Double)
+--
+-- Phase 50: monad パラメタ化 (@m@) で 'IO' でも @ST s@ でも走らせる
+-- (純粋 Gibbs に必要)。 rank-N alias にすると @Maybe@/list 構築が impredicative に
+-- なるため、 alias を kind @* -> *@ にして関数側 ('gibbsFromModel' 等) を多相にする。
+type GibbsUpdate m = Params -> Gen (PrimState m) -> m (Text, Double)
 
 -- ---------------------------------------------------------------------------
 -- 共役アップデート (モデル非依存)
@@ -59,7 +79,7 @@
 -- | Conjugate update for a Normal prior × Normal likelihood with known
 -- @σ@.
 normalNormal
-  :: Text -> Double -> Double -> [Double] -> Double -> GibbsUpdate
+  :: PrimMonad m => Text -> Double -> Double -> [Double] -> Double -> GibbsUpdate m
 normalNormal paramName mu0 sig0 ys sigLik _ps gen = do
   let n        = fromIntegral (length ys) :: Double
       ybar     = if n == 0 then 0 else sum ys / n
@@ -73,7 +93,7 @@
 
 -- | Conjugate update for a Beta prior × Binomial likelihood.
 betaBinomial
-  :: Text -> Double -> Double -> Int -> Int -> GibbsUpdate
+  :: PrimMonad m => Text -> Double -> Double -> Int -> Int -> GibbsUpdate m
 betaBinomial paramName alpha0 beta0 n k _ps gen = do
   val <- sampleBeta (alpha0 + fromIntegral k)
                     (beta0  + fromIntegral (n - k))
@@ -83,7 +103,7 @@
 -- | Conjugate update for a Gamma prior × Poisson likelihood
 -- (rate parameterization).
 gammaPoisson
-  :: Text -> Double -> Double -> [Double] -> GibbsUpdate
+  :: PrimMonad m => Text -> Double -> Double -> [Double] -> GibbsUpdate m
 gammaPoisson paramName alpha0 beta0 ys _ps gen = do
   let n     = fromIntegral (length ys) :: Double
       aPost = alpha0 + sum ys
@@ -93,7 +113,7 @@
 
 -- | Sample @Beta(a, b)@. Implemented as @X / (X + Y)@ with
 -- @X ~ Gamma(a)@, @Y ~ Gamma(b)@, since @mwc-random@ has no Beta sampler.
-sampleBeta :: Double -> Double -> GenIO -> IO Double
+sampleBeta :: PrimMonad m => Double -> Double -> Gen (PrimState m) -> m Double
 sampleBeta a b gen
   | a > 1 && b > 1 = sampleBetaBB a b gen   -- Cheng's BB, much faster
   | otherwise      = sampleBetaGamma a b gen
@@ -103,7 +123,7 @@
 -- Used when the Cheng-BB precondition @a, b > 1@ is violated; the BB
 -- algorithm's @λ = √((α − 2) / (2 a b − α))@ becomes imaginary at
 -- @a, b ≤ 1@ so a different branch (BC) would be required there.
-sampleBetaGamma :: Double -> Double -> GenIO -> IO Double
+sampleBetaGamma :: PrimMonad m => Double -> Double -> Gen (PrimState m) -> m Double
 sampleBetaGamma a b gen = do
   x <- gamma a 1 gen
   y <- gamma b 1 gen
@@ -121,7 +141,7 @@
 --
 -- Reference: Cheng (1978), "Generating Beta variates with non-integral
 -- shape parameters", CACM 21(4):317-322. Algorithm BB on p. 319.
-sampleBetaBB :: Double -> Double -> GenIO -> IO Double
+sampleBetaBB :: forall m. PrimMonad m => Double -> Double -> Gen (PrimState m) -> m Double
 sampleBetaBB a b gen = do
   let !alpha    = a + b
       !beta_    = sqrt ((alpha - 2) / (2 * a * b - alpha))
@@ -130,8 +150,8 @@
       !log5     = log 5
       !logAlpha = log alpha
   let loop = do
-        u1 <- uniform gen :: IO Double
-        u2 <- uniform gen :: IO Double
+        u1 <- uniform gen :: m Double
+        u2 <- uniform gen :: m Double
         let !v = beta_ * log (u1 / (1 - u1))
             !w = a * exp v
             !z = u1 * u1 * u2
@@ -173,12 +193,12 @@
 -- | Apply each update in @updates@ once per iteration, in order. Every
 -- Gibbs step is accepted by construction, so @chainAccepted@ equals
 -- @(length updates) × iterations@.
-gibbs :: [GibbsUpdate] -> GibbsConfig -> Params -> GenIO -> IO Chain
+gibbs :: PrimMonad m => [GibbsUpdate m] -> GibbsConfig -> Params -> Gen (PrimState m) -> m Chain
 gibbs updates cfg initP gen = do
   let total = gibbsBurnIn cfg + gibbsIterations cfg
       nUpd  = length updates
-  samplesRef  <- newIORef []
-  acceptedRef <- newIORef (0 :: Int)
+  samplesRef  <- newMutVar []
+  acceptedRef <- newMutVar (0 :: Int)
   let step current = foldM applyOne current updates
         where
           applyOne ps upd = do
@@ -187,19 +207,20 @@
   let loop 0 current = return current
       loop i current = do
         next <- step current
-        modifyIORef' acceptedRef (+ nUpd)
+        modifyMutVar' acceptedRef (+ nUpd)
         when (i <= gibbsIterations cfg) $
-          modifyIORef' samplesRef (next :)
+          modifyMutVar' samplesRef (next :)
         loop (i - 1) next
   _ <- loop total initP
-  samples  <- fmap reverse (readIORef samplesRef)
-  accepted <- readIORef acceptedRef
+  samples  <- fmap reverse (readMutVar samplesRef)
+  accepted <- readMutVar acceptedRef
   return Chain
     { chainSamples  = samples
     , chainAccepted = accepted
     , chainTotal    = total * nUpd
     , chainEnergy   = []
     , chainDivergences = []
+    , chainTreeDepths  = []
     }
 
 -- | Specialised Beta-Binomial conjugate sampler. Equivalent to
@@ -214,9 +235,9 @@
 --
 --   * @Map.insert paramName val ps@ — fresh Map allocation every iter
 --     (size-1 Map but still a tree node + Text key + boxed Double)
---   * @modifyIORef' acceptedRef (+ nUpd)@ — counter that's exactly
+--   * @modifyMutVar' acceptedRef (+ nUpd)@ — counter that's exactly
 --     @total@ at the end (every Gibbs step is unconditionally accepted)
---   * @modifyIORef' samplesRef (next :)@ + final @reverse@ — list cons
+--   * @modifyMutVar' samplesRef (next :)@ + final @reverse@ — list cons
 --     of every kept iteration plus a 10000-element reverse
 --   * @foldM applyOne current updates@ — closure construction even
 --     though the update list has length 1
@@ -231,14 +252,15 @@
 -- numpy.random.beta(a, b, size=10000) does the same thing in C with
 -- SIMD; this brings the Haskell side to within ~2× of it without FFI.
 gibbsBetaBinomial
-  :: Text     -- ^ Parameter name (= sample-Map key).
+  :: PrimMonad m
+  => Text     -- ^ Parameter name (= sample-Map key).
   -> Double   -- ^ Beta prior @α@.
   -> Double   -- ^ Beta prior @β@.
   -> Int      -- ^ Binomial @n@.
   -> Int      -- ^ Observed successes @k@.
   -> GibbsConfig
-  -> GenIO
-  -> IO Chain
+  -> Gen (PrimState m)
+  -> m Chain
 gibbsBetaBinomial paramName alpha0 beta0 n k cfg gen = do
   let !total = gibbsBurnIn cfg + gibbsIterations cfg
       !keep  = gibbsIterations cfg
@@ -256,10 +278,11 @@
     , chainTotal       = total
     , chainEnergy      = []
     , chainDivergences = []
+    , chainTreeDepths  = []
     }
 
 -- | Run 'gibbs' on @numChains@ parallel chains.
-gibbsChains :: [GibbsUpdate] -> GibbsConfig -> Int -> Params -> GenIO -> IO [Chain]
+gibbsChains :: [GibbsUpdate IO] -> GibbsConfig -> Int -> Params -> GenIO -> IO [Chain]
 gibbsChains updates cfg numChains initP baseGen = do
   gens <- replicateM numChains (spawnGen baseGen)
   mapConcurrently (\g -> gibbs updates cfg initP g) gens
@@ -287,6 +310,7 @@
 distParams (Truncated _ _ _)  = []  -- 共役検出対象外
 distParams (Censored  _ _ _)  = []  -- 共役検出対象外
 distParams MvNormal{}         = []  -- 共役検出対象外 (観測専用)
+distParams MvNormalChol{}     = []  -- 共役検出対象外 (観測専用)
 distParams (NegativeBinomial mu a) = [mu, a]
 distParams (Multinomial _ ps)      = ps
 distParams (ZeroInflatedPoisson psi lam)  = [psi, lam]
@@ -325,7 +349,7 @@
 --
 -- Returns @(updates, remaining)@: the synthesised updates and the names
 -- of parameters that still need an MH step.
-gibbsFromModel :: ModelP r -> ([GibbsUpdate], [Text])
+gibbsFromModel :: forall r m. PrimMonad m => ModelP r -> ([GibbsUpdate m], [Text])
 gibbsFromModel m =
   let nodes    = collectNodes m
       latNames = [ nodeName n | n <- nodes, nodeKind n == LatentN ]
@@ -378,12 +402,13 @@
 -- ---------------------------------------------------------------------------
 
 hybridStep
-  :: [GibbsUpdate]
+  :: PrimMonad m
+  => [GibbsUpdate m]
   -> [Text]
   -> Map Text Double
   -> ModelP r
-  -> Params -> GenIO
-  -> IO (Params, Bool)
+  -> Params -> Gen (PrimState m)
+  -> m (Params, Bool)
 hybridStep gibbsUpds mhNames mhSteps model current gen = do
   afterGibbs <- foldM (\ps upd -> do
     (name, val) <- upd ps gen
@@ -404,33 +429,35 @@
 -- | Hybrid sampler: Gibbs-update conjugate parameters and use Random-Walk
 -- Metropolis on the rest.
 gibbsMH
-  :: ModelP r
+  :: PrimMonad m
+  => ModelP r
   -> GibbsConfig
   -> Map Text Double   -- ^ MH step size per non-conjugate parameter.
   -> Params
-  -> GenIO
-  -> IO Chain
+  -> Gen (PrimState m)
+  -> m Chain
 gibbsMH model cfg mhSteps initP gen = do
   let (gibbsUpds, mhNames) = gibbsFromModel model
       total = gibbsBurnIn cfg + gibbsIterations cfg
-  samplesRef  <- newIORef []
-  acceptedRef <- newIORef (0 :: Int)
+  samplesRef  <- newMutVar []
+  acceptedRef <- newMutVar (0 :: Int)
   let loop 0 current = return current
       loop i current = do
         (next, acc) <- hybridStep gibbsUpds mhNames mhSteps model current gen
-        when acc $ modifyIORef' acceptedRef (+1)
+        when acc $ modifyMutVar' acceptedRef (+1)
         when (i <= gibbsIterations cfg) $
-          modifyIORef' samplesRef (next :)
+          modifyMutVar' samplesRef (next :)
         loop (i - 1) next
   _ <- loop total initP
-  samples  <- fmap reverse (readIORef samplesRef)
-  accepted <- readIORef acceptedRef
+  samples  <- fmap reverse (readMutVar samplesRef)
+  accepted <- readMutVar acceptedRef
   return Chain
     { chainSamples  = samples
     , chainAccepted = accepted
     , chainTotal    = total
     , chainEnergy   = []
     , chainDivergences = []
+    , chainTreeDepths  = []
     }
 
 gibbsMHChains
@@ -444,3 +471,50 @@
 gibbsMHChains model cfg mhSteps numChains initP baseGen = do
   gens <- replicateM numChains (spawnGen baseGen)
   mapConcurrently (\g -> gibbsMH model cfg mhSteps initP g) gens
+
+-- ---------------------------------------------------------------------------
+-- Phase 50: 純粋 (ST + seed) ラッパ
+-- ---------------------------------------------------------------------------
+
+-- | 純粋・決定的な hybrid Gibbs+MH (モデルから共役 update を内部導出)。 seed → 確定 Chain。
+gibbsMHPure :: ModelP r -> GibbsConfig -> Map Text Double -> Params -> Word32 -> Chain
+gibbsMHPure model cfg mhSteps initP seed =
+  runST (initialize (V.singleton seed) >>= gibbsMH model cfg mhSteps initP)
+
+-- | 純粋・決定的な multi-chain hybrid Gibbs+MH。 子 seed を純粋導出し @parList rdeepseq@ で並列。
+gibbsMHChainsPure :: ModelP r -> GibbsConfig -> Map Text Double -> Int -> Params -> Word32 -> [Chain]
+gibbsMHChainsPure model cfg mhSteps numChains initP seed =
+  let childSeeds :: [Word32]
+      childSeeds = runST $ do
+        g <- initialize (V.singleton seed)
+        replicateM numChains (uniform g)
+      chains = [ gibbsMHPure model cfg mhSteps initP s | s <- childSeeds ]
+  in chains `using` parList rdeepseq
+
+-- | 純粋・決定的な Beta-Binomial 共役 Gibbs (seed → 確定 Chain)。
+gibbsBetaBinomialPure
+  :: Text -> Double -> Double -> Int -> Int -> GibbsConfig -> Word32 -> Chain
+gibbsBetaBinomialPure paramName alpha0 beta0 n k cfg seed =
+  runST (initialize (V.singleton seed)
+           >>= gibbsBetaBinomial paramName alpha0 beta0 n k cfg)
+
+-- | 純粋・決定的な汎用 Gibbs (seed → 確定 Chain)。 update 群は **rank-N**
+-- (@forall m. PrimMonad m => [GibbsUpdate m]@) で渡す = リストリテラルを直接渡せば
+-- 多相のまま通る (@let updates = …@ で束縛すると単相化するので注意・直接渡しが楽)。
+gibbsPure
+  :: (forall m. PrimMonad m => [GibbsUpdate m])
+  -> GibbsConfig -> Params -> Word32 -> Chain
+gibbsPure updates cfg initP seed =
+  runST (initialize (V.singleton seed) >>= gibbs updates cfg initP)
+
+-- | 純粋・決定的な汎用 multi-chain Gibbs。 子 seed を純粋導出し @parList rdeepseq@ で並列。
+gibbsChainsPure
+  :: (forall m. PrimMonad m => [GibbsUpdate m])
+  -> GibbsConfig -> Int -> Params -> Word32 -> [Chain]
+gibbsChainsPure updates cfg numChains initP seed =
+  let childSeeds :: [Word32]
+      childSeeds = runST $ do
+        g <- initialize (V.singleton seed)
+        replicateM numChains (uniform g)
+      chains = [ gibbsPure updates cfg initP s | s <- childSeeds ]
+  in chains `using` parList rdeepseq
diff --git a/src/Hanalyze/MCMC/HMC.hs b/src/Hanalyze/MCMC/HMC.hs
--- a/src/Hanalyze/MCMC/HMC.hs
+++ b/src/Hanalyze/MCMC/HMC.hs
@@ -1,10 +1,13 @@
-{-# LANGUAGE StrictData #-}
-{-# LANGUAGE OverloadedStrings #-}
-{-# LANGUAGE RankNTypes #-}
--- | Hamiltonian Monte Carlo (HMC) sampler.
+-- |
+-- Module      : Hanalyze.MCMC.HMC
+-- Description : Hamiltonian Monte Carlo (HMC) サンプラー
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
 --
+-- Hamiltonian Monte Carlo (HMC) sampler.
+--
 -- Computes exact gradients of polymorphic 'Hanalyze.Model.HBM' models ('ModelP') via
--- 'Numeric.AD.Mode.Forward'. Constrained parameters (@PositiveT@,
+-- 'Numeric.AD.Mode.Reverse.Double' (Phase 53). Constrained parameters (@PositiveT@,
 -- @UnitIntervalT@) are detected automatically from the prior distribution.
 --
 -- @
@@ -19,6 +22,9 @@
 --
 -- chain <- hmc myModel defaultHMCConfig (Map.fromList [("mu",0),("sigma",1)]) gen
 -- @
+{-# LANGUAGE StrictData #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RankNTypes #-}
 module Hanalyze.MCMC.HMC
   ( -- * Configuration
     HMCConfig (..)
@@ -39,16 +45,23 @@
     -- * Sampler
   , hmc
   , hmcChains
+  , hmcPure
+  , hmcChainsPure
   ) where
 
 import Control.Concurrent.Async (mapConcurrently)
 import Control.Monad (forM, replicateM, when)
-import Data.IORef
+import Control.Monad.Primitive (PrimMonad, PrimState)
+import Control.Monad.ST (runST)
+import Control.Parallel.Strategies (parList, rdeepseq, using)
+import Data.Primitive.MutVar
+import Data.Word (Word32)
+import qualified Data.Vector as V
 import qualified Data.Map.Strict as Map
 import Data.Map.Strict (Map)
 import Data.Text (Text)
 import qualified Data.Vector.Storable         as VS
-import System.Random.MWC (GenIO, uniform)
+import System.Random.MWC (Gen, GenIO, uniform, initialize)
 import System.Random.MWC.Distributions (standard)
 
 import Hanalyze.Model.HBM (ModelP, Params, sampleNames, getTransforms,
@@ -211,15 +224,19 @@
 leapfrogWithMVS gradFn mInv eps steps theta0 r0 = go steps theta0 r0
   where
     !halfEps = eps * 0.5
+    -- Phase 54.7b: SCC で勾配呼出 (= compiled カーネル) と VS 更新を分離計測。
     go !n theta r
       | n <= 0    = (theta, r)
       | otherwise =
-          let g      = gradFn theta
-              rHalf  = VS.zipWith (\ri gi -> ri - halfEps * gi) r g
-              theta' = VS.zipWith3 (\ti m_inv ri -> ti + eps * m_inv * ri)
+          let g      = {-# SCC "leapfrog_grad1" #-} gradFn theta
+              rHalf  = {-# SCC "leapfrog_vec_rhalf" #-}
+                       VS.zipWith (\ri gi -> ri - halfEps * gi) r g
+              theta' = {-# SCC "leapfrog_vec_pos" #-}
+                       VS.zipWith3 (\ti m_inv ri -> ti + eps * m_inv * ri)
                                    theta mInv rHalf
-              g'     = gradFn theta'
-              r'     = VS.zipWith (\ri gi -> ri - halfEps * gi) rHalf g'
+              g'     = {-# SCC "leapfrog_grad2" #-} gradFn theta'
+              r'     = {-# SCC "leapfrog_vec_rfull" #-}
+                       VS.zipWith (\ri gi -> ri - halfEps * gi) rHalf g'
           in go (n - 1) theta' r'
 
 -- ---------------------------------------------------------------------------
@@ -228,10 +245,10 @@
 
 -- | HMC sampler for a polymorphic HBM model ('ModelP').
 --
--- Uses AD gradients ('Numeric.AD.Mode.Forward'), so it is more accurate
+-- Uses AD gradients ('Numeric.AD.Mode.Reverse.Double'), so it is more accurate
 -- and faster than numeric differentiation. Constraint transforms are
 -- detected automatically from the priors via 'getTransforms'.
-hmc :: ModelP r -> HMCConfig -> Params -> GenIO -> IO Chain
+hmc :: PrimMonad m => ModelP r -> HMCConfig -> Params -> Gen (PrimState m) -> m Chain
 hmc m cfg initC gen = do
   let names      = sampleNames m
       trMap      = getTransforms m
@@ -253,9 +270,9 @@
         let xs = [Map.findWithDefault 0 n paramsU | n <- ns]
         in map negate (gradADU m names transList xs)
 
-  samplesRef  <- newIORef []
-  energyRef   <- newIORef ([] :: [Double])
-  acceptedRef <- newIORef (0 :: Int)
+  samplesRef  <- newMutVar []
+  energyRef   <- newMutVar ([] :: [Double])
+  acceptedRef <- newMutVar (0 :: Int)
 
   let step currentU = do
         r <- forM names (\_ -> standard gen)
@@ -268,7 +285,7 @@
                      - (logJU currentU  - kinetic r)
         u <- uniform gen
         nextU <- if log (u :: Double) < logAlpha
-          then do modifyIORef' acceptedRef (+1); return proposedU
+          then do modifyMutVar' acceptedRef (+1); return proposedU
           else return currentU
         return (nextU, h0)
 
@@ -280,20 +297,21 @@
       loop i currentU = do
         (nextU, h0) <- step currentU
         when (i <= hmcIterations cfg) $ do
-          modifyIORef' samplesRef (toConstrained nextU :)
-          modifyIORef' energyRef  (h0 :)
+          modifyMutVar' samplesRef (toConstrained nextU :)
+          modifyMutVar' energyRef  (h0 :)
         loop (i - 1) nextU
 
   _ <- loop total initU
-  samples  <- fmap reverse (readIORef samplesRef)
-  energies <- fmap reverse (readIORef energyRef)
-  accepted <- readIORef acceptedRef
+  samples  <- fmap reverse (readMutVar samplesRef)
+  energies <- fmap reverse (readMutVar energyRef)
+  accepted <- readMutVar acceptedRef
   return Chain
     { chainSamples  = samples
     , chainAccepted = accepted
     , chainTotal    = total
     , chainEnergy   = energies
     , chainDivergences = []
+    , chainTreeDepths  = []
     }
 
 -- | Run 'hmc' on @numChains@ parallel chains (use @+RTS -N@ for CPU
@@ -302,3 +320,18 @@
 hmcChains m cfg numChains initC baseGen = do
   gens <- replicateM numChains (spawnGen baseGen)
   mapConcurrently (\g -> hmc m cfg initC g) gens
+
+-- | Phase 50: 純粋・決定的な HMC (seed → 確定 Chain)。
+hmcPure :: ModelP r -> HMCConfig -> Params -> Word32 -> Chain
+hmcPure m cfg initC seed =
+  runST (initialize (V.singleton seed) >>= hmc m cfg initC)
+
+-- | Phase 50: 純粋・決定的な multi-chain HMC。 子 seed を純粋導出し @parList rdeepseq@ で並列。
+hmcChainsPure :: ModelP r -> HMCConfig -> Int -> Params -> Word32 -> [Chain]
+hmcChainsPure m cfg numChains initC seed =
+  let childSeeds :: [Word32]
+      childSeeds = runST $ do
+        g <- initialize (V.singleton seed)
+        replicateM numChains (uniform g)
+      chains = [ hmcPure m cfg initC s | s <- childSeeds ]
+  in chains `using` parList rdeepseq
diff --git a/src/Hanalyze/MCMC/MH.hs b/src/Hanalyze/MCMC/MH.hs
--- a/src/Hanalyze/MCMC/MH.hs
+++ b/src/Hanalyze/MCMC/MH.hs
@@ -1,23 +1,36 @@
-{-# LANGUAGE OverloadedStrings #-}
-{-# LANGUAGE RankNTypes #-}
--- | Random-Walk Metropolis-Hastings sampler.
+-- |
+-- Module      : Hanalyze.MCMC.MH
+-- Description : Random-Walk Metropolis-Hastings サンプラー
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
 --
+-- Random-Walk Metropolis-Hastings sampler.
+--
 -- Tune the per-parameter step sizes ('mcmcStepSizes') so the acceptance rate
 -- lands in the 20-50% range. Pair 'Hanalyze.MCMC.Core.Chain' with
 -- 'Hanalyze.Viz.Report.renderReport' to produce diagnostic plots.
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RankNTypes #-}
 module Hanalyze.MCMC.MH
   ( MCMCConfig (..)
   , defaultMCMCConfig
   , metropolis
   , metropolisChains
+  , metropolisPure
+  , metropolisChainsPure
   ) where
 
 import Control.Concurrent.Async (mapConcurrently)
 import Control.Monad (forM, replicateM)
-import Data.IORef
+import Control.Monad.Primitive (PrimMonad, PrimState)
+import Control.Monad.ST (runST)
+import Control.Parallel.Strategies (parList, rdeepseq, using)
+import Data.Primitive.MutVar
+import Data.Word (Word32)
 import qualified Data.Map.Strict as Map
+import qualified Data.Vector as V
 import Data.Text (Text)
-import System.Random.MWC (GenIO, uniform)
+import System.Random.MWC (Gen, GenIO, uniform, initialize)
 import System.Random.MWC.Distributions (normal)
 
 import Hanalyze.Model.HBM (ModelP, Params, logJoint, sampleNames)
@@ -49,14 +62,14 @@
 
 -- | Run Random-Walk Metropolis. Uses a joint proposal that updates all
 -- latent variables simultaneously.
-metropolis :: ModelP r -> MCMCConfig -> Params -> GenIO -> IO Chain
+metropolis :: PrimMonad m => ModelP r -> MCMCConfig -> Params -> Gen (PrimState m) -> m Chain
 metropolis model cfg init_ gen = do
   let names = sampleNames model
       total = mcmcBurnIn cfg + mcmcIterations cfg
       steps = mcmcStepSizes cfg
 
-  samplesRef  <- newIORef []
-  acceptedRef <- newIORef (0 :: Int)
+  samplesRef  <- newMutVar []
+  acceptedRef <- newMutVar (0 :: Int)
 
   let step current = do
         proposed <- fmap Map.fromList $ forM names $ \n -> do
@@ -67,7 +80,7 @@
         let logA = logJoint model proposed - logJoint model current
         u <- uniform gen
         if log (u :: Double) < logA
-          then do modifyIORef' acceptedRef (+1)
+          then do modifyMutVar' acceptedRef (+1)
                   return proposed
           else return current
 
@@ -75,19 +88,20 @@
       loop i current = do
         next <- step current
         if i <= mcmcIterations cfg
-          then modifyIORef' samplesRef (next :)
+          then modifyMutVar' samplesRef (next :)
           else return ()
         loop (i - 1) next
 
   _ <- loop total init_
-  samples  <- fmap reverse (readIORef samplesRef)
-  accepted <- readIORef acceptedRef
+  samples  <- fmap reverse (readMutVar samplesRef)
+  accepted <- readMutVar acceptedRef
   return Chain
     { chainSamples  = samples
     , chainAccepted = accepted
     , chainTotal    = total
     , chainEnergy   = []
     , chainDivergences = []
+    , chainTreeDepths  = []
     }
 
 -- | Run 'metropolis' on @numChains@ parallel chains, each with an
@@ -96,3 +110,19 @@
 metropolisChains model cfg numChains initP baseGen = do
   gens <- replicateM numChains (spawnGen baseGen)
   mapConcurrently (\g -> metropolis model cfg initP g) gens
+
+-- | Phase 50: 純粋・決定的な Metropolis (seed → 確定 Chain・IO 不要)。
+metropolisPure :: ModelP r -> MCMCConfig -> Params -> Word32 -> Chain
+metropolisPure model cfg initP seed =
+  runST (initialize (V.singleton seed) >>= metropolis model cfg initP)
+
+-- | Phase 50: 純粋・決定的な multi-chain Metropolis。 親 seed から子 seed を純粋導出し
+-- 各 chain 別 'runST' → @parList rdeepseq@ で chain 横断を並列評価 (決定性は seed 由来)。
+metropolisChainsPure :: ModelP r -> MCMCConfig -> Int -> Params -> Word32 -> [Chain]
+metropolisChainsPure model cfg numChains initP seed =
+  let childSeeds :: [Word32]
+      childSeeds = runST $ do
+        g <- initialize (V.singleton seed)
+        replicateM numChains (uniform g)
+      chains = [ metropolisPure model cfg initP s | s <- childSeeds ]
+  in chains `using` parList rdeepseq
diff --git a/src/Hanalyze/MCMC/NUTS.hs b/src/Hanalyze/MCMC/NUTS.hs
--- a/src/Hanalyze/MCMC/NUTS.hs
+++ b/src/Hanalyze/MCMC/NUTS.hs
@@ -1,11 +1,15 @@
-{-# LANGUAGE StrictData #-}
-{-# LANGUAGE OverloadedStrings #-}
-{-# LANGUAGE RankNTypes #-}
--- | No-U-Turn Sampler (NUTS).
+-- |
+-- Module      : Hanalyze.MCMC.NUTS
+-- Description : No-U-Turn Sampler (NUTS) — Hoffman & Gelman (2014) Algorithm 3 実装
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
 --
+-- No-U-Turn Sampler (NUTS).
+--
 -- Implements Hoffman & Gelman (2014) Algorithm 3, with Nesterov dual
 -- averaging for step-size adaptation (Stan's strategy). Gradients are
--- exact, computed via 'Numeric.AD.Mode.Forward'.
+-- exact, computed via 'Numeric.AD.Mode.Reverse.Double' (Phase 53: reverse モードで
+-- 勾配を latent 数非依存の ~1 sweep に。 旧 forward は O(p) だった).
 --
 -- Constrained parameters (@PositiveT@, @UnitIntervalT@) are detected
 -- automatically from the prior distribution.
@@ -17,25 +21,39 @@
 -- chain <- nuts myModel defaultNUTSConfig
 --                (Map.fromList [("mu",0),("sigma",1)]) gen
 -- @
+{-# LANGUAGE StrictData #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RankNTypes #-}
 module Hanalyze.MCMC.NUTS
   ( NUTSConfig (..)
   , defaultNUTSConfig
   , nuts
+  , nutsStream
   , nutsChains
+  , nutsPure
+  , nutsChainsPure
+  , nutsChainsStream
+  , chainSeeds
+  , SampleEvent (..)
   ) where
 
 import Control.Concurrent.Async (mapConcurrently)
 import Control.Monad (foldM, replicateM, when)
-import Data.IORef
+import Control.Monad.ST (ST, runST)
+import Control.Parallel.Strategies (parList, rdeepseq, using)
+import Data.Primitive.MutVar
+import Data.Word (Word32)
 import qualified Data.Map.Strict as Map
+import qualified Data.Vector as V
 import qualified Data.Vector.Storable as VS
-import System.Random.MWC (GenIO, uniform)
+import System.Random.MWC (Gen, GenIO, uniform, initialize)
+import Control.Monad.Primitive (PrimMonad, PrimState, RealWorld)
 import System.Random.MWC.Distributions (standard)
 
 import Hanalyze.MCMC.Core (Chain (..), spawnGen)
 import Hanalyze.MCMC.HMC  (kineticMVS, leapfrogWithMVS)
 import Hanalyze.Model.HBM (ModelP, Params, sampleNames, getTransforms,
-                  logJointUnconstrained, gradADU)
+                  compileGradUV, compileGradValUVM, compileLogPUV)
 import Hanalyze.Stat.Distribution (toUnconstrained, fromUnconstrained)
 
 -- ---------------------------------------------------------------------------
@@ -44,12 +62,34 @@
 
 -- | NUTS configuration.
 data NUTSConfig = NUTSConfig
-  { nutsIterations    :: Int     -- ^ Total iterations (burn-in included).
+  { nutsIterations    :: Int     -- ^ Post-burn-in draws to keep (the loop runs
+                                 --   @nutsBurnIn + nutsIterations@ total).
   , nutsBurnIn        :: Int     -- ^ Burn-in iterations to discard.
   , nutsStepSize      :: Double  -- ^ Initial leapfrog step size @ε@.
   , nutsMaxDepth      :: Int     -- ^ Maximum tree depth (typically 10).
   , nutsAdaptStepSize :: Bool    -- ^ Enable Nesterov dual-averaging step-size adaptation.
   , nutsTargetAccept  :: Double  -- ^ Target acceptance rate (0.8 typical, 0.95 for hard problems).
+  , nutsWarmupInitMaxDepth :: Maybe Int
+                                 -- ^ Phase 85.6: 質量行列の**初回更新前** (init
+                                 --   buffer + 第 1 window・M=I 期間) に適用する
+                                 --   tree depth 上限 (opt-in・既定 'Nothing' =
+                                 --   無効)。 M=I では幾何が合わず dual averaging
+                                 --   の ε 鋸歯で depth 7-10 の木を掘り radon 実測
+                                 --   で warmup leapfrog の 68% を浪費するため、
+                                 --   'Just 6' 等で抑制できる。 ただし参照実装
+                                 --   (Stan/PyMC) に無いヒューリスティックゆえ
+                                 --   既定 OFF — 原理側の対策は 'nutsInitEpsSearch'。
+                                 --   'nutsAdaptMass' が False のときは不適用。
+  , nutsInitEpsSearch :: Bool    -- ^ Phase 85.6c/86: Stan (Hoffman–Gelman
+                                 --   Algorithm 4) の ε 倍加探索を (i) サンプリング
+                                 --   開始前と (ii) 質量行列の各 window 末更新直後
+                                 --   (Phase 86・Stan adapt_diag_e_nuts の
+                                 --   init_stepsize+restart と同順) に行う (既定
+                                 --   True)。 DA anchor (μ = log 10ε) が幾何と
+                                 --   乖離すると ε が鋸歯振動して深い木を掘るため、
+                                 --   ε を 1 step leapfrog の受容率 ~1/2 になる値へ
+                                 --   都度較正する (Stan と同じ標準機構)。
+                                 --   'nutsAdaptStepSize' が True のときのみ有効。
   , nutsAdaptMass     :: Bool    -- ^ Enable diagonal mass-matrix adaptation (B11).
                                  --   Stan-style multi-window: init buffer (15% /
                                  --   ≥75 iter, M=I) → doubling windows
@@ -58,9 +98,17 @@
                                  --   (10% / ≥50 iter, M frozen, ε converges).
                                  --   Recommended for posteriors with strongly
                                  --   varying scales across parameters.
+  , nutsInitJitter    :: Double  -- ^ Phase 94 A4-2: 各 chain の初期位置 (unconstrained)
+                                 --   に加える一様 jitter 半幅 (PyMC jitter+adapt_diag
+                                 --   相当)。 chain ごとに独立に @U(-j, +j)@ を各成分へ
+                                 --   加算し、 funnel 首での whole-chain 崩壊 (全 chain
+                                 --   同一 init 由来) を減らす。 @0@ = 無操作 (= 従来
+                                 --   挙動・単一 chain 再現性テスト非影響)。 多 chain
+                                 --   経路 ('hbmNutsConfig') で 1.0 を設定。
   } deriving (Show)
 
--- | Default NUTS configuration: 2000 iterations, 500 burn-in, @ε = 0.1@,
+-- | Default NUTS configuration: 2000 post-burn-in draws, 500 burn-in
+-- (2500 total), @ε = 0.1@,
 -- max depth 10, dual averaging enabled, target acceptance 0.8,
 -- diagonal mass-matrix adaptation off (opt-in via 'nutsAdaptMass').
 defaultNUTSConfig :: NUTSConfig
@@ -71,7 +119,10 @@
   , nutsMaxDepth      = 10
   , nutsAdaptStepSize = True
   , nutsTargetAccept  = 0.8
+  , nutsWarmupInitMaxDepth = Nothing
+  , nutsInitEpsSearch = True
   , nutsAdaptMass     = False
+  , nutsInitJitter    = 0.0
   }
 
 -- ---------------------------------------------------------------------------
@@ -97,6 +148,49 @@
   , daM         = 0
   }
 
+-- | Phase 85.6c/86: Stan (@base_hmc::init_stepsize@) 準拠の ε 探索。
+-- 与えられた ε を起点に、 1 step leapfrog の受容比が 0.8 を跨ぐまで倍加/半減
+-- する (運動量は試行ごとに再サンプル)。 dual averaging の anchor μ = log(10 ε₀)
+-- が幾何に合った値になり、 ε 鋸歯振動 (radon で depth 7-10 の深掘り) を防ぐ。
+-- ★Hoffman–Gelman 2014 Alg.4 (起点 1.0・閾値 1/2・運動量 1 本固定) でなく
+-- Stan 実装 (起点 = 現在 ε・閾値 0.8・毎試行再サンプル) に合わせる —
+-- window 末の再較正 (Phase 86) では既適応の ε 近傍から保守的に探す必要がある
+-- (起点 1.0/閾値 0.5 は radon 実測で新 M 下の trajectory が支えない大きな ε を
+-- 返し、 次 window の深掘りを招いた)。 非有限 (発散) は比 −∞ 扱い = 半減方向。
+-- 反復と ε は安全側に有界。
+findReasonableEpsilon
+  :: PrimMonad m
+  => (VS.Vector Double -> VS.Vector Double)   -- ^ gradFn (−∇ logπ・NUTS と同じ向き)
+  -> (VS.Vector Double -> Double)             -- ^ logπ (unconstrained)
+  -> VS.Vector Double                          -- ^ M⁻¹ 対角
+  -> Double                                    -- ^ 探索起点 ε (現在の nominal ε)
+  -> VS.Vector Double                          -- ^ 初期位置 θ (unconstrained)
+  -> Gen (PrimState m)
+  -> m Double
+findReasonableEpsilon gradFn logPiFn mInv eps0 theta gen = do
+    dH0 <- trial epsInit
+    let dir = if dH0 > thresh then 1 else -1 :: Int
+    loop dir epsInit (50 :: Int)
+  where
+    epsInit = max 1e-10 (min 1e7 eps0)
+    thresh  = log 0.8
+    -- 1 step leapfrog の log 受容比 (Stan と同じく運動量を都度引き直す)。
+    trial eps = do
+      r0 <- sampleMomentum mInv gen
+      let h0        = negate (logPiFn theta) + kineticMVS mInv r0
+          (th', r') = leapfrogWithMVS gradFn mInv eps 1 theta r0
+          h'        = negate (logPiFn th') + kineticMVS mInv r'
+      pure (if isNaN h' || isInfinite h' then (-1) / 0 else h0 - h')
+    loop dir !eps !k
+      | k <= 0 = pure eps
+      | otherwise = do
+          dH <- trial eps
+          let keepGoing = if dir == 1 then dH > thresh else dH < thresh
+              eps'      = if dir == 1 then eps * 2 else eps / 2
+          if not keepGoing then pure eps
+          else if eps' > 1e7 || eps' < 1e-10 then pure eps
+          else loop dir eps' (k - 1)
+
 -- | Apply one dual-averaging update given the target acceptance @δ@ and
 -- the observed acceptance statistic @α@ for the iteration.
 updateDualAvg :: Double -> Double -> DualAvgState -> DualAvgState
@@ -126,13 +220,24 @@
 data NUTSTree = NUTSTree
   { ntThMinus :: VS.Vector Double
   , ntRMinus  :: VS.Vector Double
+  , ntGMinus  :: VS.Vector Double
+    -- ^ Phase 87.2b: minus 端点の ∇U = −∇logπ (leapfrog 勾配キャッシュ)。
+    --   同方向の次の葉が始点勾配を再計算せずに済む (Stan の z_.g と同じ)。
   , ntThPlus  :: VS.Vector Double
   , ntRPlus   :: VS.Vector Double
+  , ntGPlus   :: VS.Vector Double
+    -- ^ Phase 87.2b: plus 端点の ∇U (同上)。
   , ntThPrime :: VS.Vector Double
   , ntN       :: Int
   , ntS       :: Bool
   , ntDiv     :: Bool
     -- ^ サブツリー中で divergent (|ΔH| > deltaMax) が発生したか
+  , ntASum    :: !Double
+    -- ^ Phase 87.2: Σ min(1, exp(H0 − H_leaf)) — Stan の accept_stat 蓄積。
+    --   dual averaging はこの平均 ᾱ を学習する (旧: 1-step probe = 毎 draw
+    --   余分な leapfrog+エネルギー評価を払う非標準の独自実装だった)。
+  , ntANum    :: !Int
+    -- ^ Phase 87.2: ᾱ の分母 (サブツリーの葉数・棄却葉も含む)。
   }
 
 deltaMax :: Double
@@ -140,20 +245,30 @@
 
 -- | U-turn check on Storable Vectors. @(θ⁺ − θ⁻) · r⁻ < 0@ or
 -- @(θ⁺ − θ⁻) · r⁺ < 0@ ⇒ trajectory has begun to retrace itself.
+--
+-- Phase 90 A11-4①: 旧実装は @delta@ の共有 binding で stream fusion が切れ
+-- delta ベクトルを毎回実体化していた (prof 実測: nuts_uturn が総 alloc の
+-- 23.5%)。 2 つの内積を単一パス・確保なしで融合する。 加算順序は旧
+-- 'VS.sum' (左畳み込み) と同一 = ビット同一。
 uTurnVS
   :: VS.Vector Double -> VS.Vector Double
   -> VS.Vector Double -> VS.Vector Double -> Bool
-uTurnVS thMinus rMinus thPlus rPlus =
-  let delta = VS.zipWith (-) thPlus thMinus
-      d1    = VS.sum (VS.zipWith (*) delta rMinus)
-      d2    = VS.sum (VS.zipWith (*) delta rPlus)
-  in d1 < 0 || d2 < 0
+uTurnVS thMinus rMinus thPlus rPlus = go 0 0 0
+  where
+    !n = VS.length thMinus
+    go !d1 !d2 !j
+      | j >= n    = d1 < 0 || d2 < 0
+      | otherwise =
+          let d = thPlus `VS.unsafeIndex` j - thMinus `VS.unsafeIndex` j
+          in go (d1 + d * (rMinus `VS.unsafeIndex` j))
+                (d2 + d * (rPlus  `VS.unsafeIndex` j))
+                (j + 1)
 {-# INLINE uTurnVS #-}
 
 -- | Sample momentum @r ~ N(0, M)@ from the diagonal mass matrix
 -- represented as @M⁻¹@. Per coordinate: @r_i = z / sqrt(M⁻¹_i)@,
 -- @z ~ N(0,1)@. Storable-vector tight loop, no list allocation.
-sampleMomentum :: VS.Vector Double -> GenIO -> IO (VS.Vector Double)
+sampleMomentum :: PrimMonad m => VS.Vector Double -> Gen (PrimState m) -> m (VS.Vector Double)
 sampleMomentum mInv gen = do
   let n = VS.length mInv
   VS.generateM n $ \i -> do
@@ -166,67 +281,160 @@
 -- ---------------------------------------------------------------------------
 
 buildTree
-  :: (VS.Vector Double -> VS.Vector Double)   -- ^ Gradient (negated grad of log π).
-  -> (VS.Vector Double -> Double)             -- ^ Log target density.
+  :: forall m. PrimMonad m
+  => (VS.Vector Double -> m (Double, VS.Vector Double))
+     -- ^ 融合評価 (Phase 87.2b): θ ↦ (logπ(θ), ∇U(θ) = −∇logπ(θ))。 Phase 90
+     --   A11-4①: chain 閉包に確保した arena/adj を再利用するため monadic。
   -> VS.Vector Double                         -- ^ Diagonal M⁻¹.
   -> Double                                   -- ^ Step size @ε@.
   -> VS.Vector Double                         -- ^ Position.
   -> VS.Vector Double                         -- ^ Momentum.
+  -> VS.Vector Double                         -- ^ ∇U at position (キャッシュ)。
   -> Double                                   -- ^ @log u@ slice.
+  -> Double                                   -- ^ 初期エネルギー @H0@ (ᾱ 用)。
   -> Int                                      -- ^ Direction (±1).
   -> Int                                      -- ^ Recursion depth.
-  -> GenIO
-  -> IO NUTSTree
-buildTree gradFn logPiFn mInv eps theta r logU dir depth gen
+  -> Gen (PrimState m)
+  -> m NUTSTree
+-- Phase 54.7b: PrimMonad 多相 (Phase 50) は SPECIALIZE が無いと dictionary 渡しで
+-- mwc の uniform/standard が unbox されない (prof 実測で RNG 系 11%/alloc 25%)。
+-- IO / ST の両具体型に特殊化して Phase 50 以前の機械語品質に戻す。
+{-# SPECIALIZE buildTree
+  :: (VS.Vector Double -> IO (Double, VS.Vector Double))
+  -> VS.Vector Double -> Double -> VS.Vector Double -> VS.Vector Double
+  -> VS.Vector Double
+  -> Double -> Double -> Int -> Int -> Gen RealWorld -> IO NUTSTree #-}
+{-# SPECIALIZE buildTree
+  :: (VS.Vector Double -> ST s (Double, VS.Vector Double))
+  -> VS.Vector Double -> Double -> VS.Vector Double -> VS.Vector Double
+  -> VS.Vector Double
+  -> Double -> Double -> Int -> Int -> Gen s -> ST s NUTSTree #-}
+buildTree gradValU mInv eps theta r gU logU h0 dir depth gen
   | depth == 0 = do
-      let (theta', r') = leapfrogWithMVS gradFn mInv
-                            (fromIntegral dir * eps) 1 theta r
-          h'  = -(logPiFn theta') + kineticMVS mInv r'
+      -- Phase 87.2b: 1-step leapfrog を融合評価でインライン化。 始点勾配は
+      -- 端点キャッシュ (gU) を使い、 終点は (logπ, ∇U) を 1 回の融合評価で
+      -- 取得 (旧: 葉ごとに grad 2 回 + logπ 1 回 = 始点勾配の再計算と
+      -- エネルギー用 forward の重複を払っていた)。
+      let !epsD    = fromIntegral dir * eps
+          !halfEps = 0.5 * epsD
+          rHalf  = {-# SCC "nuts_leapfrog_kick1" #-}
+                   VS.zipWith (\ri gi -> ri - halfEps * gi) r gU
+          theta' = {-# SCC "nuts_leapfrog_drift" #-}
+                   VS.zipWith3 (\ti m_inv ri -> ti + epsD * m_inv * ri)
+                               theta mInv rHalf
+      (v', g') <- {-# SCC "nuts_gradval" #-} gradValU theta'
+      let r'     = {-# SCC "nuts_leapfrog_kick2" #-}
+                   VS.zipWith (\ri gi -> ri - halfEps * gi) rHalf g'
+          h'  = {-# SCC "nuts_energy" #-} (negate v' + kineticMVS mInv r')
           n'  = if logU <= -h' then 1 else 0
           s'  = logU < deltaMax - h'
           divergent = not s'
+          -- Phase 87.2: Stan の accept_stat = min(1, exp(H0 − H')) を葉ごとに
+          -- 蓄積 (非有限は 0 = 棄却扱い)。
+          a'  = let d = h0 - h'
+                in if isNaN d then 0 else min 1 (exp (min 0 d))
       return NUTSTree
-        { ntThMinus = theta', ntRMinus = r'
-        , ntThPlus  = theta', ntRPlus  = r'
+        { ntThMinus = theta', ntRMinus = r', ntGMinus = g'
+        , ntThPlus  = theta', ntRPlus  = r', ntGPlus  = g'
         , ntThPrime = theta', ntN = n', ntS = s'
         , ntDiv = divergent
+        , ntASum = a', ntANum = 1
         }
   | otherwise = do
-      t1 <- buildTree gradFn logPiFn mInv eps theta r logU dir (depth - 1) gen
+      t1 <- buildTree gradValU mInv eps theta r gU logU h0 dir (depth - 1) gen
       if not (ntS t1) then return t1
       else do
-        let (th0, r0) = if dir == -1
-              then (ntThMinus t1, ntRMinus t1)
-              else (ntThPlus  t1, ntRPlus  t1)
-        t2 <- buildTree gradFn logPiFn mInv eps th0 r0 logU dir (depth - 1) gen
+        let (th0, r0, g0) = if dir == -1
+              then (ntThMinus t1, ntRMinus t1, ntGMinus t1)
+              else (ntThPlus  t1, ntRPlus  t1, ntGPlus  t1)
+        t2 <- buildTree gradValU mInv eps th0 r0 g0 logU h0 dir (depth - 1) gen
         let n1 = ntN t1; n2 = ntN t2
         thPrime' <-
           if n1 == 0 then return (ntThPrime t2)
           else if n2 == 0 then return (ntThPrime t1)
           else do
-            u <- uniform gen :: IO Double
+            u <- {-# SCC "nuts_rng_uniform" #-} (uniform gen :: m Double)
             return $ if u < min 1.0 (fromIntegral n2 / fromIntegral n1)
                      then ntThPrime t2
                      else ntThPrime t1
-        let (minus', rMinus', plus', rPlus') = if dir == -1
-              then (ntThMinus t2, ntRMinus t2, ntThPlus t1, ntRPlus t1)
-              else (ntThMinus t1, ntRMinus t1, ntThPlus t2, ntRPlus t2)
-            s' = ntS t2 && not (uTurnVS minus' rMinus' plus' rPlus')
+        let (minus', rMinus', gMinus', plus', rPlus', gPlus') = if dir == -1
+              then (ntThMinus t2, ntRMinus t2, ntGMinus t2,
+                    ntThPlus t1, ntRPlus t1, ntGPlus t1)
+              else (ntThMinus t1, ntRMinus t1, ntGMinus t1,
+                    ntThPlus t2, ntRPlus t2, ntGPlus t2)
+            s' = ntS t2 && not ({-# SCC "nuts_uturn" #-} uTurnVS minus' rMinus' plus' rPlus')
         return NUTSTree
-          { ntThMinus = minus', ntRMinus = rMinus'
-          , ntThPlus  = plus',  ntRPlus  = rPlus'
+          { ntThMinus = minus', ntRMinus = rMinus', ntGMinus = gMinus'
+          , ntThPlus  = plus',  ntRPlus  = rPlus',  ntGPlus  = gPlus'
           , ntThPrime = thPrime', ntN = n1 + n2, ntS = s'
           , ntDiv = ntDiv t1 || ntDiv t2
+          , ntASum = ntASum t1 + ntASum t2
+          , ntANum = ntANum t1 + ntANum t2
           }
 
 -- ---------------------------------------------------------------------------
+-- Streaming hook
+-- ---------------------------------------------------------------------------
+
+-- | Per-iteration sample event emitted by 'nutsStream'.
+--
+-- Used by callers that want to observe MCMC progress as it happens
+-- (e.g. live trace plots, real-time R-hat / ESS updates over the wire).
+-- The callback receives one event per iteration of the outer loop,
+-- including burn-in iterations (distinguished by 'seIsBurnIn').
+--
+-- The 'seParams' values are in the **constrained** parameter space,
+-- matching the convention used in 'chainSamples'. Burn-in events are
+-- /not/ included in 'chainSamples', but are still streamed via the
+-- callback so the UI can show warmup progress and adaptation.
+data SampleEvent = SampleEvent
+  { seIter      :: !Int      -- ^ 0-based iteration index (burn-in inclusive).
+                              --   Ranges over @[0 .. nutsBurnIn + nutsIterations - 1]@.
+  , seIsBurnIn  :: !Bool     -- ^ True if @seIter < nutsBurnIn@.
+  , seParams    :: !Params   -- ^ Current sample (constrained space).
+  , seEnergy    :: !Double   -- ^ Hamiltonian H0 at the start of this iteration.
+  , seDivergent :: !Bool     -- ^ Whether this iteration's trajectory diverged.
+  , seAccepted  :: !Bool     -- ^ Whether the proposal was accepted
+                              --   (@proposedU /= currentU@).
+  , seStepSize  :: !Double   -- ^ Current ε (after this iteration's adaptation).
+  , seTreeDepth :: !Int      -- ^ Phase 85.6: この draw で実行された doubling 回数
+                              --   (leapfrog 数 ≈ 2^depth・warmup 固定費の診断用)。
+  , seAcceptStat :: !Double  -- ^ Phase 87.1: この draw の mean accept-stat α
+                              --   (dual averaging が target と比較する統計・
+                              --   'seAccepted' の bool とは別物)。ε̄ 収束診断用。
+  }
+
+-- ---------------------------------------------------------------------------
 -- NUTS サンプラー
 -- ---------------------------------------------------------------------------
 
 -- | NUTS sampler for a polymorphic HBM model ('ModelP').
 -- 軌道長は U-Turn 判定で自動決定。
-nuts :: ModelP r -> NUTSConfig -> Params -> GenIO -> IO Chain
-nuts m cfg initC gen = do
+--
+-- This is a thin wrapper around 'nutsStream' with a no-op callback.
+-- Use 'nutsStream' directly if you want per-iteration progress
+-- (e.g. for live UI updates over a WebSocket / SSE channel).
+nuts :: PrimMonad m => ModelP r -> NUTSConfig -> Params -> Gen (PrimState m) -> m Chain
+{-# SPECIALIZE nuts :: ModelP r -> NUTSConfig -> Params -> Gen RealWorld -> IO Chain #-}
+{-# SPECIALIZE nuts :: ModelP r -> NUTSConfig -> Params -> Gen s -> ST s Chain #-}
+nuts m cfg initC gen = nutsStream m cfg initC gen (\_ -> pure ())
+
+-- | NUTS sampler with a per-iteration callback. Identical to 'nuts'
+-- semantically; in addition, calls @onSample event@ once per outer
+-- loop iteration (burn-in inclusive). The callback runs synchronously
+-- inside the sampler loop, so it should return quickly (push events to
+-- a queue rather than do IO of unbounded latency).
+nutsStream :: forall r m. PrimMonad m
+           => ModelP r -> NUTSConfig -> Params -> Gen (PrimState m)
+           -> (SampleEvent -> m ())
+           -> m Chain
+{-# SPECIALIZE nutsStream
+  :: ModelP r -> NUTSConfig -> Params -> Gen RealWorld
+  -> (SampleEvent -> IO ()) -> IO Chain #-}
+{-# SPECIALIZE nutsStream
+  :: ModelP r -> NUTSConfig -> Params -> Gen s
+  -> (SampleEvent -> ST s ()) -> ST s Chain #-}
+nutsStream m cfg initC gen onSample = do
   let names      = sampleNames m
       trMap      = getTransforms m
       transList  = [Map.findWithDefault errT n trMap | n <- names]
@@ -235,43 +443,64 @@
       -- Initial unconstrained position as a Storable Vector. The hot
       -- loop never touches 'Params' (= Map); we only convert at the
       -- boundary to record samples.
-      initUV :: VS.Vector Double
-      initUV = VS.fromList
+      initUV0 :: VS.Vector Double
+      initUV0 = VS.fromList
         [ toUnconstrained t (Map.findWithDefault 0 n initC)
         | (n, t) <- zip names transList ]
 
       total   = nutsBurnIn cfg + nutsIterations cfg
       doAdapt = nutsAdaptStepSize cfg && nutsBurnIn cfg > 0
 
-      -- Vector-native log target density. Builds the @Params@ map only
-      -- once per call (the upstream 'logJoint' API still wants a Map).
+      -- Vector-native log target density. Phase 54.4d/54.6: エネルギー評価も
+      -- 'compileLogPUV' で静的部分 (名前→index 解決込み) を 1 度だけ前処理した
+      -- compiled closure を全 tree node で再利用する (旧: 毎回
+      -- 'logJointUnconstrained' の Free walk + per-obs スカラ logDensityObs)。
       logPiFn :: VS.Vector Double -> Double
-      logPiFn uv =
-        let xs = VS.toList uv
-            paramsU = Map.fromList (zip names xs)
-        in logJointUnconstrained m names transList paramsU
+      logPiFn = compileLogPUV m names transList
 
-      -- Vector-native gradient. 'gradADU' already takes a list, so the
-      -- wrapping is essentially a Storable ↔ list pair (n_params is
-      -- small so the conversion cost is negligible — the dominant
-      -- expense is the AD pass itself).
+      -- Vector-native gradient. Phase 54.4b/54.6: モデル構造は draw 間で不変
+      -- ゆえ 'compileGradUV' で静的部分を **1 度だけ**前処理し、 返った
+      -- vector-native クロージャを全 leapfrog で再利用する (VS↔list 変換なし)。
+      gradV :: VS.Vector Double -> VS.Vector Double
+      gradV = compileGradUV m names transList
       gradFn :: VS.Vector Double -> VS.Vector Double
-      gradFn uv =
-        let xs = VS.toList uv
-            gs = gradADU m names transList xs
-        in VS.fromList (map negate gs)
+      gradFn uv = VS.map negate (gradV uv)
 
       toConstrained :: VS.Vector Double -> Params
       toConstrained uv = Map.fromList
         [ (n, fromUnconstrained t (uv `VS.unsafeIndex` i))
         | (i, (n, t)) <- zip [0..] (zip names transList) ]
 
-  samplesRef    <- newIORef []
-  energyRef     <- newIORef ([] :: [Double])
-  divergenceRef <- newIORef ([] :: [Int])
-  acceptedRef   <- newIORef (0 :: Int)
-  daRef         <- newIORef (initDualAvg (nutsStepSize cfg))
+  -- Phase 87.2b: 値+勾配の融合評価 (JAX value_and_grad 相当)。 tree の葉が
+  -- leapfrog 最終勾配とエネルギーを同一点で二重評価していた重複を除去。
+  -- Phase 90 A11-4①: 'compileGradValUVM' は forward/随伴 arena を **この chain
+  -- 閉包生成時に 1 度だけ**確保して全 leapfrog で再利用する (per-call 34k×2
+  -- セル確保 + GC churn を除去)。 chain ごとに別 'nutsStream' 呼出 = 別バッファ
+  -- ゆえ chain 横断並列 ('nutsChainsPure'/'nutsChainsStream') と非干渉。
+  -- Phase 94 A4-2: 各 chain の初期位置に一様 jitter (funnel 首の whole-chain 崩壊対策)。
+  -- j=0 なら initUV0 をそのまま (従来挙動)。 gen は chain 固有ゆえ chain ごと独立。
+  initUV <- let j = nutsInitJitter cfg
+            in if j <= 0 then pure initUV0
+               else VS.mapM (\x -> do u <- uniform gen
+                                      pure (x + (u * 2 - 1) * j)) initUV0
+  gradValV <- compileGradValUVM m names transList
+  let gradValU :: VS.Vector Double -> m (Double, VS.Vector Double)
+      gradValU uv = do
+        (v, g) <- gradValV uv
+        pure (v, VS.map negate g)
 
+  samplesRef    <- newMutVar []
+  energyRef     <- newMutVar ([] :: [Double])
+  divergenceRef <- newMutVar ([] :: [Int])
+  depthRef      <- newMutVar ([] :: [Int])   -- Phase 85.3: per-draw tree depth
+  acceptedRef   <- newMutVar (0 :: Int)
+  -- Phase 85.6c: 初期 ε の較正 (Stan Algorithm 4・doAdapt 時のみ)。
+  eps0 <- if doAdapt && nutsInitEpsSearch cfg
+            then findReasonableEpsilon gradFn logPiFn
+                   (VS.replicate (length names) 1.0) (nutsStepSize cfg) initUV gen
+            else pure (nutsStepSize cfg)
+  daRef         <- newMutVar (initDualAvg eps0)
+
   -- B11: Stan-style multi-window diagonal mass-matrix adaptation.
   --
   -- Schedule (warmup W):
@@ -285,118 +514,194 @@
       adaptM        = nutsAdaptMass cfg && nutsBurnIn cfg > 0
       (windowEnds, initBuf, _termBuf) = stanWindows (nutsBurnIn cfg)
       windowPhaseEnd = if null windowEnds then 0 else last windowEnds
-  mInvRef     <- newIORef (VS.replicate nParams 1.0)
-  welfordRef  <- newIORef (emptyWelford nParams)
+  mInvRef     <- newMutVar (VS.replicate nParams 1.0)
+  welfordRef  <- newMutVar (emptyWelford nParams)
 
-  let step :: VS.Vector Double -> Double -> VS.Vector Double
-           -> IO (VS.Vector Double, Double, Double, Bool)
-      step mInv eps currentU = do
+  let step :: VS.Vector Double -> Double -> Int -> VS.Vector Double
+           -> m (VS.Vector Double, Double, Double, Bool, Int)
+      step mInv eps maxDep currentU = do
         -- r ~ N(0, M)  ⇔  r_i = sqrt(M_ii) * z = z / sqrt(M⁻¹_ii)
-        r0 <- sampleMomentum mInv gen
-        u0 <- uniform gen :: IO Double
-        let h0   = -(logPiFn currentU) + kineticMVS mInv r0
+        r0 <- {-# SCC "nuts_sampleMomentum" #-} sampleMomentum mInv gen
+        u0 <- {-# SCC "nuts_rng_uniform" #-} (uniform gen :: m Double)
+        -- Phase 87.2b: 始点の (logπ, ∇U) を融合評価 1 回で取得。 値は H0 に、
+        -- 勾配は両方向の最初の葉の始点キャッシュに使う。
+        (v0, gU0) <- {-# SCC "nuts_gradval0" #-} gradValU currentU
+        let h0   = negate v0 + kineticMVS mInv r0
             logU = log u0 - h0
         let tree0 = NUTSTree
-              { ntThMinus = currentU, ntRMinus = r0
-              , ntThPlus  = currentU, ntRPlus  = r0
+              { ntThMinus = currentU, ntRMinus = r0, ntGMinus = gU0
+              , ntThPlus  = currentU, ntRPlus  = r0, ntGPlus  = gU0
               , ntThPrime = currentU, ntN = 1, ntS = True
               , ntDiv = False
+              , ntASum = 0, ntANum = 0
               }
         let doubleTree tree j =
               if not (ntS tree) then return tree
               else do
-                u <- uniform gen :: IO Double
+                u <- {-# SCC "nuts_rng_uniform" #-} (uniform gen :: m Double)
                 let dir = if u < 0.5 then -1 else 1 :: Int
-                    (th0, r0') = if dir == -1
-                      then (ntThMinus tree, ntRMinus tree)
-                      else (ntThPlus  tree, ntRPlus  tree)
-                subtree <- buildTree gradFn logPiFn mInv eps th0 r0' logU dir j gen
+                    (th0, r0', g0') = if dir == -1
+                      then (ntThMinus tree, ntRMinus tree, ntGMinus tree)
+                      else (ntThPlus  tree, ntRPlus  tree, ntGPlus  tree)
+                subtree <- {-# SCC "nuts_buildTree" #-}
+                  buildTree gradValU mInv eps th0 r0' g0' logU h0 dir j gen
                 let n1 = ntN tree; n2 = ntN subtree
                 thPrime' <-
                   if not (ntS subtree) || n2 == 0
                   then return (ntThPrime tree)
                   else do
-                    u2 <- uniform gen :: IO Double
+                    u2 <- {-# SCC "nuts_rng_uniform" #-} (uniform gen :: m Double)
                     return $ if u2 < min 1.0 (fromIntegral n2 / fromIntegral n1)
                              then ntThPrime subtree
                              else ntThPrime tree
-                let (minus', rMinus', plus', rPlus') = if dir == -1
-                      then (ntThMinus subtree, ntRMinus subtree,
-                            ntThPlus  tree,    ntRPlus  tree)
-                      else (ntThMinus tree,    ntRMinus tree,
-                            ntThPlus  subtree, ntRPlus  subtree)
-                    s' = ntS subtree && not (uTurnVS minus' rMinus' plus' rPlus')
+                let (minus', rMinus', gMinus', plus', rPlus', gPlus') = if dir == -1
+                      then (ntThMinus subtree, ntRMinus subtree, ntGMinus subtree,
+                            ntThPlus  tree,    ntRPlus  tree,    ntGPlus  tree)
+                      else (ntThMinus tree,    ntRMinus tree,    ntGMinus tree,
+                            ntThPlus  subtree, ntRPlus  subtree, ntGPlus  subtree)
+                    s' = ntS subtree && not ({-# SCC "nuts_uturn" #-} uTurnVS minus' rMinus' plus' rPlus')
                 return NUTSTree
-                  { ntThMinus = minus', ntRMinus = rMinus'
-                  , ntThPlus  = plus',  ntRPlus  = rPlus'
+                  { ntThMinus = minus', ntRMinus = rMinus', ntGMinus = gMinus'
+                  , ntThPlus  = plus',  ntRPlus  = rPlus',  ntGPlus  = gPlus'
                   , ntThPrime = thPrime', ntN = n1 + n2, ntS = s'
                   , ntDiv = ntDiv tree || ntDiv subtree
+                  , ntASum = ntASum tree + ntASum subtree
+                  , ntANum = ntANum tree + ntANum subtree
                   }
-        finalTree <- foldM doubleTree tree0 [0 .. nutsMaxDepth cfg - 1]
-        let proposedU        = ntThPrime finalTree
-            (thetaOne, rOne) = leapfrogWithMVS gradFn mInv eps 1 currentU r0
-            hOne             = -(logPiFn thetaOne) + kineticMVS mInv rOne
-            alpha            = min 1.0 (exp (h0 - hOne))
-        when (proposedU /= currentU) $ modifyIORef' acceptedRef (+1)
-        return (proposedU, alpha, h0, ntDiv finalTree)
+        -- Phase 85.3: 実行された doubling 回数 = tree depth (PyMC の
+        -- tree_depth 相当・leapfrog 数 ≈ 2^depth) を数える。
+        let doubleTreeD (tree, !dep) j =
+              if not (ntS tree) then return (tree, dep)
+              else do
+                t' <- doubleTree tree j
+                return (t', dep + 1 :: Int)
+        (finalTree, treeDepth) <-
+          foldM doubleTreeD (tree0, 0) [0 .. maxDep - 1]
+        -- Phase 87.2: alpha は Stan の accept_stat = tree 全葉の
+        -- min(1, exp(H0−H')) 平均 (buildTree で蓄積)。 旧 1-step probe
+        -- (毎 draw 余分な leapfrog + エネルギー評価・非標準の独自実装) を廃止。
+        let proposedU = ntThPrime finalTree
+            alpha     = if ntANum finalTree > 0
+                          then ntASum finalTree / fromIntegral (ntANum finalTree)
+                          else 0
+        when (proposedU /= currentU) $ modifyMutVar' acceptedRef (+1)
+        return (proposedU, alpha, h0, ntDiv finalTree, treeDepth)
 
   let loop 0 currentU _eps = return currentU
       loop i currentU eps = do
-        mInv <- readIORef mInvRef
-        (nextU, alpha, h0, divergent) <- step mInv eps currentU
+        mInv <- readMutVar mInvRef
         let isBurnIn   = i > nutsIterations cfg
             -- iteration index from start (1-based); total counts down.
             iterIdx    = total - i + 1
+            -- Phase 85.6: M 初回更新前 (M=I) は深い木を掘らない (init 期の
+            -- draw は捨てる区間・radon で warmup leapfrog の 68% を占めた)。
+            firstMUpd  = case windowEnds of { (w : _) -> w; [] -> 0 }
+            maxDep
+              | adaptM && isBurnIn && iterIdx <= firstMUpd
+              , Just cap <- nutsWarmupInitMaxDepth cfg =
+                  min (nutsMaxDepth cfg) cap
+              | otherwise = nutsMaxDepth cfg
             -- Inside the window phase: collect samples for Welford.
             inWindowPhase = adaptM && isBurnIn
                             && iterIdx > initBuf
                             && iterIdx <= windowPhaseEnd
             -- This iteration ends a window: update M, restart DA.
             isWindowEnd   = adaptM && isBurnIn && iterIdx `elem` windowEnds
+        (nextU, alpha, h0, divergent, treeDepth) <- step mInv eps maxDep currentU
         when inWindowPhase $
-          modifyIORef' welfordRef (\w -> welfordAddVS w nextU)
-        when isWindowEnd $ do
-          w <- readIORef welfordRef
-          when (wN w >= 5) $ do  -- need a few samples to be meaningful
-            writeIORef mInvRef (welfordMInvVS w)
-            -- Restart dual averaging anchored at the current ε; it
-            -- needs to re-converge under the new geometry.
-            writeIORef daRef (initDualAvg eps)
-          -- Reset Welford for the next window (window-local variance).
-          writeIORef welfordRef (emptyWelford nParams)
-        eps' <- if doAdapt && isBurnIn
+          modifyMutVar' welfordRef (\w -> {-# SCC "nuts_welford" #-} welfordAddVS w nextU)
+        -- Phase 86: window 末に M を更新したら、 Stan (adapt_diag_e_nuts の
+        -- init_stepsize + restart) と同じく**新 metric の下で ε を再較正**
+        -- (Algorithm 4) して DA を restart する。 旧実装は鋸歯振動中の瞬間値
+        -- ε を anchor (μ = log 10ε) にしており、 M 更新直後に ε が幾何と桁で
+        -- 乖離すると次 window 丸ごと深掘りする (radon seed=1 実測で window
+        -- [150,250) が depth 9.9・101k leapfrog = warmup 全体の 76%)。
+        recalEps <- if isWindowEnd
           then do
-            da <- readIORef daRef
-            let da' = updateDualAvg (nutsTargetAccept cfg) alpha da
-            writeIORef daRef da'
-            return (exp (daLogEps da'))
-          else do
-            da <- readIORef daRef
-            let epsBar = if doAdapt && not isBurnIn && i == nutsIterations cfg
-                         then exp (daLogEpsBar da)
-                         else eps
-            return epsBar
+            w <- readMutVar welfordRef
+            -- Reset Welford for the next window (window-local variance).
+            writeMutVar welfordRef (emptyWelford nParams)
+            if wN w >= 5  -- need a few samples to be meaningful
+              then do
+                let mInv' = welfordMInvVS w
+                writeMutVar mInvRef mInv'
+                if doAdapt && nutsInitEpsSearch cfg
+                  then
+                    -- Phase 87.1: **最終 window 末 (term buffer 直前) は restart
+                    -- しない** (M のみ更新・DA 継続 = PyMC の連続 DA と同じ挙動)。
+                    -- restart すると DA が m=1 の暴れ期からやり直しになり、 50
+                    -- draw の term buffer では ε̄ が鋸歯の暴れを拾って過小に着地
+                    -- する (radon 実測: ε 振動 0.035-1.37・ε̄=0.18・sampling α
+                    -- 0.95/depth 5。 PyMC は restart なしで振動 0.14-0.50・
+                    -- ε 0.23-0.34・depth 4/α 0.80-0.88)。 中間 window 末の
+                    -- recal+restart (Phase 86・爆発対策) は維持 — この時点まで
+                    -- に M はほぼ収束しており継続 DA の ε がそのまま通用する。
+                    if iterIdx == windowPhaseEnd
+                      then pure Nothing
+                      else do
+                        epsNew <- findReasonableEpsilon gradFn logPiFn mInv' eps nextU gen
+                        writeMutVar daRef (initDualAvg epsNew)
+                        pure (Just epsNew)
+                  else do
+                    -- 旧挙動 (opt-out 時): 現 ε anchor で restart。
+                    writeMutVar daRef (initDualAvg eps)
+                    pure Nothing
+              else pure Nothing
+          else pure Nothing
+        eps' <- case recalEps of
+          -- Stan と同じく restart 直後はこの draw の accept 統計を学習しない
+          -- (旧 metric 下の α で較正済 anchor を汚さない)。
+          Just epsNew -> pure epsNew
+          Nothing
+            | doAdapt && isBurnIn -> do
+                da <- readMutVar daRef
+                let da' = {-# SCC "nuts_dualavg" #-} updateDualAvg (nutsTargetAccept cfg) alpha da
+                writeMutVar daRef da'
+                return (exp (daLogEps da'))
+            | otherwise -> do
+                da <- readMutVar daRef
+                let epsBar = if doAdapt && not isBurnIn && i == nutsIterations cfg
+                             then exp (daLogEpsBar da)
+                             else eps
+                return epsBar
+        let nextParams = {-# SCC "nuts_toConstrained" #-} toConstrained nextU
         if not isBurnIn
           then do
-            modifyIORef' samplesRef (toConstrained nextU :)
-            modifyIORef' energyRef  (h0 :)
+            modifyMutVar' samplesRef (nextParams :)
+            modifyMutVar' energyRef  (h0 :)
+            modifyMutVar' depthRef   (treeDepth :)
             when divergent $
-              modifyIORef' divergenceRef
+              modifyMutVar' divergenceRef
                 ((nutsIterations cfg - i) :)
           else return ()
+        -- Phase 9.1a: per-iteration callback for streaming UIs.
+        -- 0-based iter index running 0 .. total-1; isBurnIn for first nutsBurnIn.
+        onSample SampleEvent
+          { seIter      = total - i
+          , seIsBurnIn  = isBurnIn
+          , seParams    = nextParams
+          , seEnergy    = h0
+          , seDivergent = divergent
+          , seAccepted  = nextU /= currentU
+          , seStepSize  = eps'
+          , seTreeDepth = treeDepth
+          , seAcceptStat = alpha
+          }
         loop (i - 1) nextU eps'
 
-  _ <- loop total initUV (nutsStepSize cfg)
-  samples  <- fmap reverse (readIORef samplesRef)
-  energies <- fmap reverse (readIORef energyRef)
-  divs     <- fmap reverse (readIORef divergenceRef)
-  accepted <- readIORef acceptedRef
+  _ <- loop total initUV eps0
+  samples  <- fmap reverse (readMutVar samplesRef)
+  energies <- fmap reverse (readMutVar energyRef)
+  divs     <- fmap reverse (readMutVar divergenceRef)
+  depths   <- fmap reverse (readMutVar depthRef)
+  accepted <- readMutVar acceptedRef
   return Chain
     { chainSamples     = samples
     , chainAccepted    = accepted
     , chainTotal       = total
     , chainEnergy      = energies
     , chainDivergences = divs
+    , chainTreeDepths  = depths
     }
 
 -- ---------------------------------------------------------------------------
@@ -492,3 +797,50 @@
 nutsChains m cfg numChains initC baseGen = do
   gens <- replicateM numChains (spawnGen baseGen)
   mapConcurrently (\g -> nuts m cfg initC g) gens
+
+-- ---------------------------------------------------------------------------
+-- Phase 50: 純粋 (ST + seed) ラッパ
+--
+-- 'nuts' を 'ST' で走らせ 'runST' で閉じることで、 **seed → 確定 'Chain'** の
+-- 純粋関数にする (同 seed → ビット同一・IO 不要)。 mwc は 'PrimMonad' 汎用ゆえ
+-- ロジックは 50.2 で一般化した 'nuts' をそのまま使う。
+-- ---------------------------------------------------------------------------
+
+-- | 純粋・決定的な単一 NUTS chain。 同じ @seed@ なら必ず同じ 'Chain' を返す。
+nutsPure :: ModelP r -> NUTSConfig -> Params -> Word32 -> Chain
+nutsPure m cfg initC seed =
+  runST (initialize (V.singleton seed) >>= nuts m cfg initC)
+
+-- | 親 @seed@ から chain ごとの child seed 列を純粋に導出する (Phase 61.1 で
+-- 'nutsChainsPure' から抽出)。 pure 経路と IO 経路 ('nutsChainsStream') が
+-- **同じ seed 列**を共有することで両経路のビット一致を保証する (複製すると drift)。
+chainSeeds :: Word32 -> Int -> [Word32]
+chainSeeds seed numChains = runST $ do
+  g <- initialize (V.singleton seed)
+  replicateM numChains (uniform g)
+
+-- | 純粋・決定的な multi-chain。 親 @seed@ から子 seed を純粋に導出 (各 chain は
+-- 別 'runST') し、 chain 横断を @parList rdeepseq@ で**最初から**並列評価する
+-- (純粋性と並列性は直交。 @+RTS -N@ でマルチコア。 結果は spark/コア数に依らずビット同一)。
+nutsChainsPure :: ModelP r -> NUTSConfig -> Int -> Params -> Word32 -> [Chain]
+nutsChainsPure m cfg numChains initC seed =
+  let chains = [ nutsPure m cfg initC s | s <- chainSeeds seed numChains ]
+  in chains `using` parList rdeepseq
+
+-- | 'nutsChainsPure' の IO 版 (Phase 61.1): 同じ child seed 規約
+-- ('chainSeeds') で chain ごとに 'nutsStream' を回し、 chain index 付き
+-- callback で進捗を観測できるようにする。 chain 横断は 'mapConcurrently'
+-- (既存 'nutsChains' と同様・実 OS スレッド並列には @-threaded +RTS -N@)。
+--
+-- mwc の 'PrimMonad' 汎用性 + Phase 50 で実証済の ST/IO ビット同一により、
+-- no-op callback なら結果は @nutsChainsPure m cfg n initC seed@ と
+-- **ビット一致**する (回帰テストで固定)。
+nutsChainsStream :: ModelP r -> NUTSConfig -> Int -> Params -> Word32
+                 -> (Int -> SampleEvent -> IO ())
+                 -> IO [Chain]
+nutsChainsStream m cfg numChains initC seed onSample =
+  mapConcurrently
+    (\(i, s) -> do
+        g <- initialize (V.singleton s)
+        nutsStream m cfg initC g (onSample i))
+    (zip [0 ..] (chainSeeds seed numChains))
diff --git a/src/Hanalyze/MCMC/Progress.hs b/src/Hanalyze/MCMC/Progress.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/MCMC/Progress.hs
@@ -0,0 +1,160 @@
+-- |
+-- Module      : Hanalyze.MCMC.Progress
+-- Description : MCMC サンプリングの進捗表示 (全 chain 集計を stderr に描画)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- MCMC サンプリングの進捗表示 (Phase 61.2)。
+--
+-- 'Hanalyze.MCMC.NUTS.nutsChainsStream' の chain index 付き callback に
+-- 接続して、 全 chain 集計の進捗 1 行を stderr に描画する:
+--
+-- > chains 2/4 done | draw 3400/8000 (warmup) | div 12 | 380.0 it/s
+--
+-- 設計 (phase-61 計画の柱):
+--
+-- * 表示は「現在の chain」 でなく**全 chain 集計** (chain は mapConcurrently
+--   並列で同時進行するため「現在」 が無い)。
+-- * callback はサンプラループ内で**同期実行**される ('nutsStream' doc 明記)
+--   ので、 描画はカウンタ先行の間引き (全体の ~0.5% 刻み) を通過した時だけ
+--   時刻取得 + 描画する。 ホットパスに乗るのはカウンタ更新のみ。
+-- * TTY (対話端末) では @\\r@ 上書きの 1 行、 非 TTY (CI ログ等) では
+--   10% 刻みの行出力。
+-- * 並列 chain からの stderr 競合は 'MVar' の単一描画権で回避
+--   (取れなければ描画 skip = 次の間引き通過で追いつく)。
+{-# LANGUAGE StrictData #-}
+{-# LANGUAGE OverloadedStrings #-}
+module Hanalyze.MCMC.Progress
+  ( ProgressSnapshot (..)
+  , formatProgress
+  , newProgressRenderer
+  ) where
+
+import Control.Concurrent.MVar (newMVar, tryTakeMVar, putMVar)
+import Control.Monad (when)
+import Data.IORef (IORef, newIORef, readIORef, atomicModifyIORef')
+import Data.Text (Text)
+import qualified Data.IntMap.Strict as IM
+import qualified Data.Text as T
+import qualified Data.Text.IO as TIO
+import GHC.Clock (getMonotonicTime)
+import Numeric (showFFloat)
+import System.IO (stderr, hIsTerminalDevice, hFlush)
+
+import Hanalyze.MCMC.NUTS (SampleEvent (..))
+
+-- ===========================================================================
+-- スナップショット + 純粋フォーマッタ
+-- ===========================================================================
+
+-- | 全 chain 集計の進捗スナップショット (描画と独立な純粋データ)。
+data ProgressSnapshot = ProgressSnapshot
+  { psChains      :: Int     -- ^ 総 chain 数。
+  , psChainsDone  :: Int     -- ^ 完了 chain 数。
+  , psDraw        :: Int     -- ^ 全 chain 合算の消化 iteration 数 (burn-in 込み)。
+  , psTotal       :: Int     -- ^ 全 chain 合算の総 iteration 数。
+  , psWarmup      :: Bool    -- ^ いずれかの chain が warmup (burn-in) 中か。
+  , psDivergent   :: Int     -- ^ divergence 累計 (全 chain)。
+  , psItersPerSec :: Double  -- ^ 開始からの平均スループット (iteration/s)。
+  } deriving (Show, Eq)
+
+-- | 進捗 1 行の純粋フォーマッタ。 例:
+--
+-- @
+-- formatProgress (ProgressSnapshot 4 2 3400 8000 True 12 380.0)
+--   == "chains 2\/4 done | draw 3400\/8000 (warmup) | div 12 | 380.0 it\/s"
+-- @
+formatProgress :: ProgressSnapshot -> Text
+formatProgress ps = T.intercalate " | "
+  [ "chains " <> tshow (psChainsDone ps) <> "/" <> tshow (psChains ps) <> " done"
+  , "draw " <> tshow (psDraw ps) <> "/" <> tshow (psTotal ps)
+      <> (if psWarmup ps then " (warmup)" else "")
+  , "div " <> tshow (psDivergent ps)
+  , T.pack (showFFloat (Just 1) (psItersPerSec ps) "") <> " it/s"
+  ]
+  where tshow = T.pack . show
+
+-- ===========================================================================
+-- stderr レンダラ
+-- ===========================================================================
+
+-- | レンダラ内部の可変状態 (chain ごとの消化数 / warmup フラグ / div 累計)。
+data RState = RState
+  { rsDraws :: !(IM.IntMap Int)   -- ^ chain index → 消化 iteration 数。
+  , rsWarm  :: !(IM.IntMap Bool)  -- ^ chain index → 直近 event が burn-in か。
+  , rsDiv   :: !Int               -- ^ divergence 累計。
+  }
+
+-- | stderr 進捗レンダラを作る。 返り値 = (chain index 付き callback, 終了処理)。
+--
+-- 終了処理は最終スナップショットを描画して行を閉じる (TTY では改行を補う)。
+-- 'Hanalyze.MCMC.NUTS.nutsChainsStream' に渡す想定:
+--
+-- @
+-- (onSample, finish) <- newProgressRenderer chains (burnIn + iters)
+-- chains <- nutsChainsStream m cfg chains initC seed onSample
+-- finish
+-- @
+newProgressRenderer :: Int   -- ^ 総 chain 数
+                    -> Int   -- ^ chain あたりの総 iteration 数 (burn-in 込み)
+                    -> IO (Int -> SampleEvent -> IO (), IO ())
+newProgressRenderer nChains perChain = do
+  isTTY    <- hIsTerminalDevice stderr
+  t0       <- getMonotonicTime
+  stRef    <- newIORef (RState IM.empty IM.empty 0)
+  lastPct  <- newIORef (-1 :: Int)   -- 非 TTY の 10% 刻み判定
+  drawLock <- newMVar ()             -- 単一描画権
+  let totalAll = nChains * perChain
+      stride   = max 1 (totalAll `div` 200)   -- ~0.5% 刻みで描画候補
+
+      snapshot :: RState -> Double -> ProgressSnapshot
+      snapshot st now =
+        let drawn = sum (IM.elems (rsDraws st))
+            done  = IM.size (IM.filter (>= perChain) (rsDraws st))
+            warm  = or (IM.elems (rsWarm st))
+            dt    = max 1e-9 (now - t0)
+        in ProgressSnapshot
+             { psChains = nChains, psChainsDone = done
+             , psDraw = drawn, psTotal = totalAll
+             , psWarmup = warm, psDivergent = rsDiv st
+             , psItersPerSec = fromIntegral drawn / dt
+             }
+
+      -- 描画権が取れた時だけ描画 (競合時は skip・次の間引きで追いつく)。
+      render :: Bool -> IO ()
+      render final = do
+        got <- tryTakeMVar drawLock
+        case got of
+          Nothing -> pure ()
+          Just () -> do
+            st  <- readIORef stRef
+            now <- getMonotonicTime
+            let snap = snapshot st now
+                line = formatProgress snap
+            if isTTY
+              then do
+                TIO.hPutStr stderr ("\r" <> line)
+                when final (TIO.hPutStr stderr "\n")
+                hFlush stderr
+              else do
+                -- 非 TTY: 10% 境界を跨いだ時 (or 終了時) だけ 1 行出す。
+                let pct10 = (10 * psDraw snap) `div` max 1 totalAll
+                prev <- readIORef lastPct
+                when (pct10 > prev || final) $ do
+                  atomicModifyIORef' lastPct (\p -> (max p pct10, ()))
+                  TIO.hPutStrLn stderr line
+                  hFlush stderr
+            putMVar drawLock ()
+
+      onSample :: Int -> SampleEvent -> IO ()
+      onSample i ev = do
+        n <- atomicModifyIORef' stRef $ \st ->
+          let st' = RState
+                { rsDraws = IM.insertWith (+) i 1 (rsDraws st)
+                , rsWarm  = IM.insert i (seIsBurnIn ev) (rsWarm st)
+                , rsDiv   = rsDiv st + (if seDivergent ev then 1 else 0)
+                }
+          in (st', sum (IM.elems (rsDraws st')))
+        when (n `mod` stride == 0) (render False)
+
+  pure (onSample, render True)
diff --git a/src/Hanalyze/MCMC/SMC.hs b/src/Hanalyze/MCMC/SMC.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/MCMC/SMC.hs
@@ -0,0 +1,266 @@
+-- |
+-- Module      : Hanalyze.MCMC.SMC
+-- Description : Tempered target による Sequential Monte Carlo (SMC) サンプラー
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Sequential Monte Carlo (SMC) sampler with tempered targets.
+--
+-- Implements a particle-based sampler that bridges from a broad initial
+-- distribution to the full posterior @π(θ) ∝ p(θ) · L(θ)@ via a sequence
+-- of intermediate targets @π_t(θ) ∝ p(θ) · L(θ)^β_t@, where
+-- @β_0 = 0 → β_T = 1@.
+--
+-- Reference: Del Moral, Doucet, Jasra (2006) "Sequential Monte Carlo
+-- samplers". JRSSB 68:411-436.
+--
+-- ## アルゴリズム概要 (Phase 29-A1)
+--
+-- 1. **Init**: N 個の粒子を @init_@ を中心とする広い Gaussian cloud から
+--    サンプル (= 近似 prior)
+-- 2. **Tempering loop** (t = 1..T):
+--    a. **Weight**: 重み更新 @w_i ∝ exp((β_t − β_{t-1}) · logL(θ_i))@
+--    b. **log marginal contribution**: @log(mean w_i)@ を累積
+--    c. **Resample**: ESS = @(Σw)² / Σw²@ が閾値以下なら systematic resampling
+--    d. **Move**: 各粒子に対し K 回の MH 移動 (target = π_t、 random walk
+--       proposal)
+-- 3. **Output**: 最終粒子集合を 'Chain' として返す + log marginal likelihood
+--    の推定値
+--
+-- ## NUTS / MH との位置付け
+--
+-- SMC の advantage:
+--
+--   * 並列性が高い (粒子間は独立、 移動が並列化可能)
+--   * 多峰分布で chain がはまりにくい (= temperature annealing)
+--   * **log marginal likelihood の副産物推定**: Bridge Sampling より
+--     軽量で取れる (= Bayes Factor / BMA の前処理に使える)
+--
+-- SMC の disadvantage:
+--
+--   * 単峰分布なら NUTS の方が effective sample size / 時間 で有利
+--   * temperature schedule の選択が結果に影響
+--
+-- Phase 29-A2 Bridge Sampling は本 SMC の log marginal 推定の **独立な
+-- 検証手段** として使う (両者で 5% 以内一致なら確からしい)。
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RankNTypes        #-}
+{-# LANGUAGE BangPatterns      #-}
+module Hanalyze.MCMC.SMC
+  ( SMCConfig (..)
+  , defaultSMCConfig
+  , SMCResult (..)
+  , smc
+  , smcPure
+  ) where
+
+import           Control.Monad             (forM, replicateM, foldM)
+import           Control.Monad.Primitive   (PrimMonad, PrimState)
+import           Control.Monad.ST          (runST)
+import qualified Data.Map.Strict           as Map
+import           Data.List                 (sort)
+import           Data.Text                 (Text)
+import           Data.Word                 (Word32)
+import qualified Data.Vector               as V
+import qualified Data.Vector.Unboxed       as VU
+import           System.Random.MWC         (Gen, uniform, initialize)
+import           System.Random.MWC.Distributions (normal)
+
+import           Hanalyze.Model.HBM        (ModelP, Params, logPrior, logLikelihood, sampleNames)
+import           Hanalyze.MCMC.Core        (Chain (..))
+
+-- ---------------------------------------------------------------------------
+-- Configuration
+-- ---------------------------------------------------------------------------
+
+-- | SMC configuration.
+data SMCConfig = SMCConfig
+  { smcNParticles   :: !Int     -- ^ N: 粒子数 (典型 500-2000)
+  , smcNSteps       :: !Int     -- ^ T: temperature step 数 (典型 10-50)
+  , smcMHIterations :: !Int     -- ^ K: 各 temperature 内の MH 移動 回数 (典型 5-20)
+  , smcMHStepSize   :: !(Map.Map Text Double)  -- ^ Random walk MH の per-param std
+  , smcInitJitter   :: !Double  -- ^ 初期粒子を init_ から散らす Gaussian σ (typical 2-5)
+  , smcESSThreshold :: !Double  -- ^ 0..1、 ESS < N · threshold で resample (typical 0.5)
+  } deriving (Show)
+
+-- | Default: N=500、 T=20、 K=10、 step=0.5、 jitter σ=3、 ESS threshold=0.5。
+defaultSMCConfig :: [Text] -> SMCConfig
+defaultSMCConfig names = SMCConfig
+  { smcNParticles   = 500
+  , smcNSteps       = 20
+  , smcMHIterations = 10
+  , smcMHStepSize   = Map.fromList [(n, 0.5) | n <- names]
+  , smcInitJitter   = 3.0
+  , smcESSThreshold = 0.5
+  }
+
+-- | SMC の結果。 粒子を Chain 形に詰めた posterior 推定 + log marginal +
+-- temperature step ごとの ESS 履歴。
+--
+-- **重要 (Phase 29-A1)**: 'smcLogMarginal' は **初期粒子が prior から
+-- サンプルされていることを仮定** した推定値。 本実装は init_ を中心とする
+-- jittered Gaussian から初期粒子を作るため、 prior が広いと bias する。
+-- 厳密な log marginal が必要な場合は Phase 29-A2 'Hanalyze.Stat.BridgeSampling.bridgeSampling'
+-- を使用すること (SMC chain を入力に独立に推定する)。 SMC の primary 用途は
+-- **多峰 posterior の効率的なサンプリング**。
+data SMCResult = SMCResult
+  { smcChain        :: !Chain
+  , smcLogMarginal  :: !Double
+  , smcESSHistory   :: ![Double]
+  } deriving (Show)
+
+-- ---------------------------------------------------------------------------
+-- 公開 API
+-- ---------------------------------------------------------------------------
+
+-- | SMC を実行。 'init_' を中心に initial particles を散らし、 linear
+-- temperature schedule (β_t = t/T) で posterior に温めていく。
+smc :: forall r m. PrimMonad m => ModelP r -> SMCConfig -> Params -> Gen (PrimState m) -> m SMCResult
+smc model cfg init_ gen = do
+  let n      = smcNParticles cfg
+      tT     = smcNSteps cfg
+      names  = sampleNames model
+      steps  = smcMHStepSize cfg
+      jitter = smcInitJitter cfg
+  -- 1. Init particles: init_ + N(0, jitter · stepSizes_i)
+  particles0 <- replicateM n (jitterInit names jitter steps init_ gen)
+  let betas = [ fromIntegral t / fromIntegral tT | t <- [0 .. tT] ]   -- [0, 1/T, .., 1]
+      betaSteps = zip betas (tail betas)                              -- [(β_{t-1}, β_t)]
+
+  -- 2. Tempering loop
+  (finalParticles, logMarg, essHist) <-
+    foldM (stepTemper model steps (smcMHIterations cfg) (smcESSThreshold cfg) gen n)
+          (particles0, 0.0 :: Double, [])
+          betaSteps
+
+  let accepted = chainAcceptedAcc (length finalParticles * tT * smcMHIterations cfg)
+      total    = length finalParticles * tT * smcMHIterations cfg
+  pure SMCResult
+    { smcChain = Chain
+        { chainSamples     = finalParticles
+        , chainAccepted    = accepted
+        , chainTotal       = total
+        , chainEnergy      = []
+        , chainDivergences = []
+        , chainTreeDepths  = []
+        }
+    , smcLogMarginal = logMarg
+    , smcESSHistory  = reverse essHist
+    }
+  where
+    -- 受理数は本実装では追跡しない (= 0 を入れて acceptanceRate は意味なし)
+    chainAcceptedAcc _ = 0
+
+-- | Phase 50: 純粋・決定的な SMC (seed → 確定 SMCResult・IO 不要)。 'smc' の ST/seed 版。
+smcPure :: ModelP r -> SMCConfig -> Params -> Word32 -> SMCResult
+smcPure model cfg initP seed =
+  runST (initialize (V.singleton seed) >>= smc model cfg initP)
+
+-- | 1 ステップの tempering:
+--   * 重み計算 + log marginal 累積
+--   * ESS 判定して resample
+--   * K 回の MH 移動 (target = π_t = p(θ) · L(θ)^β_t)
+stepTemper
+  :: forall r m. PrimMonad m => ModelP r
+  -> Map.Map Text Double         -- ^ step sizes
+  -> Int                         -- ^ K
+  -> Double                      -- ^ ESS threshold
+  -> Gen (PrimState m)
+  -> Int                         -- ^ N (元の粒子数、 resample で N keep)
+  -> ([Params], Double, [Double]) -- ^ (粒子、 累積 log marginal、 ESS 履歴)
+  -> (Double, Double)            -- ^ (β_{t-1}, β_t)
+  -> m ([Params], Double, [Double])
+stepTemper model steps k essThr gen n (particles, logMarg, essHist) (b0, b1) = do
+  let dbeta   = b1 - b0
+      logLs   = map (logLikelihood model) particles
+      logWs   = map (dbeta *) logLs               -- log incremental weights
+      logSumW = logSumExp logWs
+      logMean = logSumW - log (fromIntegral (length particles))
+      ws      = map (\lw -> exp (lw - logSumW)) logWs   -- normalized weights
+      ess     = if sum (map (** 2) ws) == 0 then 0
+                  else 1 / sum (map (** 2) ws)
+      logMarg' = logMarg + logMean
+
+  -- Resample if ESS < threshold · N
+  resampled <-
+    if ess < essThr * fromIntegral n
+      then systematicResample particles ws n gen
+      else pure particles
+
+  -- Move with K MH iterations
+  moved <- moveK model steps b1 k resampled gen
+  pure (moved, logMarg', ess : essHist)
+
+-- | systematic resampling (= particle filter standard)。
+systematicResample
+  :: forall m. PrimMonad m => [Params] -> [Double] -> Int -> Gen (PrimState m) -> m [Params]
+systematicResample particles ws n gen = do
+  u0 <- uniform gen :: m Double
+  let total = sum ws
+      ws' = map (/ total) ws  -- normalize
+      cdf = scanl1 (+) ws'
+      ps  = [ (fromIntegral i + u0) / fromIntegral n | i <- [0 .. n - 1] ]
+      pick p = pickAt p cdf particles
+  pure (map pick ps)
+  where
+    pickAt p (c : cs) (x : xs)
+      | p <= c    = x
+      | otherwise = pickAt p cs xs
+    pickAt _ _ (x : _) = x  -- fallback (numeric edge)
+    pickAt _ _ []      = error "systematicResample: empty particle list"
+
+-- | K 回の Random Walk MH 移動。 target は @log π_t = logPrior + β · logLik@。
+moveK
+  :: forall r m. PrimMonad m => ModelP r
+  -> Map.Map Text Double
+  -> Double          -- ^ β
+  -> Int
+  -> [Params]
+  -> Gen (PrimState m)
+  -> m [Params]
+moveK model steps beta k particles gen =
+  mapM (mhKSteps model steps beta k gen) particles
+
+mhKSteps
+  :: forall r m. PrimMonad m => ModelP r
+  -> Map.Map Text Double
+  -> Double
+  -> Int
+  -> Gen (PrimState m)
+  -> Params
+  -> m Params
+mhKSteps model steps beta k gen p0 = go k p0
+  where
+    target p = logPrior model p + beta * logLikelihood model p
+    go 0 p = pure p
+    go i p = do
+      let names = Map.keys p
+      proposed <- fmap Map.fromList $ forM names $ \n -> do
+        let s   = Map.findWithDefault 1.0 n steps
+            cur = Map.findWithDefault 0.0 n p
+        eps <- normal 0 s gen
+        pure (n, cur + eps)
+      let logA = target proposed - target p
+      u <- uniform gen :: m Double
+      let !next = if log u < logA then proposed else p
+      go (i - 1) next
+
+-- ---------------------------------------------------------------------------
+-- Helpers
+-- ---------------------------------------------------------------------------
+
+jitterInit
+  :: forall m. PrimMonad m => [Text] -> Double -> Map.Map Text Double -> Params -> Gen (PrimState m) -> m Params
+jitterInit names jitter steps init_ gen =
+  fmap Map.fromList $ forM names $ \n -> do
+    let s   = jitter * Map.findWithDefault 1.0 n steps
+        cur = Map.findWithDefault 0.0 n init_
+    eps <- normal 0 s gen
+    pure (n, cur + eps)
+
+-- | Numerically stable log-sum-exp.
+logSumExp :: [Double] -> Double
+logSumExp [] = -1 / 0
+logSumExp xs =
+  let m = maximum xs
+  in m + log (sum [ exp (x - m) | x <- xs ])
diff --git a/src/Hanalyze/MCMC/Slice.hs b/src/Hanalyze/MCMC/Slice.hs
--- a/src/Hanalyze/MCMC/Slice.hs
+++ b/src/Hanalyze/MCMC/Slice.hs
@@ -1,6 +1,10 @@
-{-# LANGUAGE OverloadedStrings #-}
-{-# LANGUAGE RankNTypes #-}
--- | Slice sampler (Neal 2003) — a univariate method with no acceptance-rate
+-- |
+-- Module      : Hanalyze.MCMC.Slice
+-- Description : Slice sampler (Neal 2003) — 受理率調整不要な単変量サンプリング法
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Slice sampler (Neal 2003) — a univariate method with no acceptance-rate
 -- tuning.
 --
 -- Each iteration:
@@ -13,20 +17,29 @@
 -- One iteration is a Gibbs-style sweep over every coordinate. Like
 -- HMC/NUTS no gradient is required, but each sweep involves many
 -- log-density evaluations.
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RankNTypes #-}
 module Hanalyze.MCMC.Slice
   ( SliceConfig (..)
   , defaultSliceConfig
   , slice
   , sliceChains
+  , slicePure
+  , sliceChainsPure
   ) where
 
 import Control.Concurrent.Async (mapConcurrently)
 import Control.Monad (forM, replicateM, when)
-import Data.IORef
+import Control.Monad.Primitive (PrimMonad, PrimState)
+import Control.Monad.ST (runST)
+import Control.Parallel.Strategies (parList, rdeepseq, using)
+import Data.Primitive.MutVar
+import Data.Word (Word32)
 import qualified Data.Map.Strict as Map
+import qualified Data.Vector as V
 import Data.Map.Strict (Map)
 import Data.Text (Text)
-import System.Random.MWC (GenIO, uniform)
+import System.Random.MWC (Gen, GenIO, uniform, initialize)
 import System.Random.MWC.Distributions (exponential)
 
 import Hanalyze.Model.HBM (ModelP, Params, logJoint, sampleNames)
@@ -54,7 +67,7 @@
 
 -- | Run the slice sampler. One iteration updates every coordinate in
 -- turn (Gibbs-style sweep).
-slice :: ModelP r -> SliceConfig -> Params -> GenIO -> IO Chain
+slice :: forall r m. PrimMonad m => ModelP r -> SliceConfig -> Params -> Gen (PrimState m) -> m Chain
 slice model cfg init_ gen = do
   let names    = sampleNames model
       total    = sliceBurnIn cfg + sliceIterations cfg
@@ -64,11 +77,11 @@
       logP :: Params -> Double
       logP = logJoint model
 
-  samplesRef  <- newIORef []
-  acceptedRef <- newIORef (0 :: Int)
+  samplesRef  <- newMutVar []
+  acceptedRef <- newMutVar (0 :: Int)
 
   -- 1 coordinate 更新 (slice sampling on one axis)
-  let updateOne :: Text -> Params -> IO Params
+  let updateOne :: Text -> Params -> m Params
       updateOne nm cur = do
         let w   = Map.findWithDefault 1.0 nm widths
             x0  = Map.findWithDefault 0.0 nm cur
@@ -102,7 +115,7 @@
                     then shrink xNew r
                     else shrink l xNew
         xNew <- shrink l1 r1
-        modifyIORef' acceptedRef (+1)
+        modifyMutVar' acceptedRef (+1)
         return (Map.insert nm xNew cur)
 
   let sweep current = foldr (\_ _ -> id) id [] `seq`
@@ -116,18 +129,19 @@
       loop i current = do
         next <- sweep current
         when (i <= sliceIterations cfg) $
-          modifyIORef' samplesRef (next :)
+          modifyMutVar' samplesRef (next :)
         loop (i - 1) next
 
   _ <- loop total init_
-  samples  <- fmap reverse (readIORef samplesRef)
-  accepted <- readIORef acceptedRef
+  samples  <- fmap reverse (readMutVar samplesRef)
+  accepted <- readMutVar acceptedRef
   return Chain
     { chainSamples     = samples
     , chainAccepted    = accepted
     , chainTotal       = total * length names
     , chainEnergy      = []
     , chainDivergences = []
+    , chainTreeDepths  = []
     }
 
 -- | Run 'slice' on @numChains@ parallel chains.
@@ -135,3 +149,18 @@
 sliceChains model cfg numChains initP baseGen = do
   gens <- replicateM numChains (spawnGen baseGen)
   mapConcurrently (\g -> slice model cfg initP g) gens
+
+-- | Phase 50: 純粋・決定的な slice sampler (seed → 確定 Chain)。
+slicePure :: ModelP r -> SliceConfig -> Params -> Word32 -> Chain
+slicePure model cfg initP seed =
+  runST (initialize (V.singleton seed) >>= slice model cfg initP)
+
+-- | Phase 50: 純粋・決定的な multi-chain slice。 子 seed を純粋導出し @parList rdeepseq@ で並列。
+sliceChainsPure :: ModelP r -> SliceConfig -> Int -> Params -> Word32 -> [Chain]
+sliceChainsPure model cfg numChains initP seed =
+  let childSeeds :: [Word32]
+      childSeeds = runST $ do
+        g <- initialize (V.singleton seed)
+        replicateM numChains (uniform g)
+      chains = [ slicePure model cfg initP s | s <- childSeeds ]
+  in chains `using` parList rdeepseq
diff --git a/src/Hanalyze/Math/HSIC.hs b/src/Hanalyze/Math/HSIC.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Math/HSIC.hs
@@ -0,0 +1,121 @@
+{-# LANGUAGE BangPatterns #-}
+-- |
+-- Module      : Hanalyze.Math.HSIC
+-- Description : Hilbert-Schmidt Independence Criterion による kernel 法ベースの独立性検定統計量
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Hilbert-Schmidt Independence Criterion (HSIC、 Gretton et al. 2005)。
+--
+-- ## モチベーション
+--
+-- 確率変数 X, Y の独立性を測る kernel 法ベースの統計量。 線形相関や
+-- partial correlation と違い、 非線形依存も検出できる。 LiNGAM 系統
+-- (特に ParceLiNGAM bottom-up 探索) で「残差と他変数の独立性」 を判定する
+-- 中核ツール。
+--
+-- ## 統計量 (biased empirical estimator)
+--
+-- > HSIC_b(X, Y) = (1 / n²) · tr(K_X · H · K_Y · H)
+--
+-- ここで K_X[i,j] = k(x_i, x_j) は RBF kernel、 H = I − (1/n) · 1 1ᵀ は
+-- 中心化行列。 X ⊥ Y の下で HSIC_b → 0、 強依存で正値。
+--
+-- ## bandwidth の決め方
+--
+-- median heuristic: σ = median(‖x_i − x_j‖) (i ≠ j、 サンプル間距離の中央値)。
+-- cdt15/lingam を含む慣用設定で、 サンプル数のオーダー依存が小さく robust。
+--
+-- ## 集約 (ParceLiNGAM での使い方)
+--
+-- 多次元 X (列が変数) と単変量残差 R の依存判定は、 各列 X_i ごとに
+-- HSIC(X_i, R) を計算して **総和 (= aggregate)** を取る。 cdt15/lingam の
+-- 内部実装は Fisher 法で p 値を合成するが、 v0.2 では p 値を使わず統計量の
+-- 総和で相対比較する (実用上は relative scoring が機能する)。
+--
+-- ## リファレンス
+--
+-- Gretton et al. (2005) "Measuring statistical dependence with Hilbert-Schmidt
+-- norms", ALT 2005. cdt15/lingam の `lingam/hsic.py`。
+module Hanalyze.Math.HSIC
+  ( hsicBiased
+  , hsicRBF
+  , medianBandwidth
+  , hsicAggregate
+  ) where
+
+import qualified Numeric.LinearAlgebra      as LA
+import qualified Hanalyze.Stat.KernelDist   as KD
+import           Data.List                  (sort)
+
+-- ===========================================================================
+-- カーネル行列構築
+-- ===========================================================================
+
+-- | RBF (Gaussian) カーネル行列 K[i, j] = exp(−‖x_i − x_j‖² / (2σ²))。
+--   入力 @x@ は @n × p@ (行がサンプル、 列が変数)。
+rbfKernelMatrix :: Double -> LA.Matrix Double -> LA.Matrix Double
+rbfKernelMatrix sigma x =
+  let !twoSig2 = 2 * sigma * sigma
+      !d2      = KD.pairwiseSqDist x
+  in LA.cmap (\v -> exp (negate v / twoSig2)) d2
+
+-- | サンプル間距離の中央値 (median heuristic for kernel bandwidth)。
+--   対角 (距離 0) は除外し、 上三角の値だけを集めて中央値を取る。
+--   退化 (median = 0) の場合は 1.0 にフォールバック。
+medianBandwidth :: LA.Matrix Double -> Double
+medianBandwidth x =
+  let !d2    = KD.pairwiseSqDist x
+      !n     = LA.rows d2
+      vals   = [ LA.atIndex d2 (i, j)
+               | i <- [0 .. n - 1], j <- [i + 1 .. n - 1] ]
+      sorted = sort vals
+      med    = case sorted of
+                 [] -> 1.0
+                 _  -> let !m = length sorted `div` 2
+                       in sorted !! m
+      sig    = sqrt (max med 1.0e-12)
+  in if sig > 0 then sig else 1.0
+
+-- ===========================================================================
+-- HSIC 統計量
+-- ===========================================================================
+
+-- | biased empirical HSIC を K, L から計算: (1/n²) · tr(K_c · L_c)。
+--   K_c = H K H、 L_c = H L H、 H = I − (1/n) · 1 1ᵀ。
+--   ※ tr(K_c L_c) = tr(K_c L) (中心化の冪等性により) なので片側中心化で済む。
+hsicWithKernels :: LA.Matrix Double -> LA.Matrix Double -> Double
+hsicWithKernels k l =
+  let !n     = LA.rows k
+      !nD    = fromIntegral n
+      !h     = LA.ident n - LA.scale (1.0 / nD)
+                   (LA.konst 1.0 (n, n))
+      !kc    = h LA.<> k LA.<> h
+      !prod  = kc LA.<> l
+      !tr    = sum [ LA.atIndex prod (i, i) | i <- [0 .. n - 1] ]
+  in tr / (nD * nD)
+
+-- | RBF kernel + median bandwidth で biased HSIC を計算。
+--   入力 @x@, @y@ は @n × p@ / @n × q@ (行が共通サンプル、 列が変数)。
+hsicRBF :: LA.Matrix Double -> LA.Matrix Double -> Double
+hsicRBF x y =
+  let !sx = medianBandwidth x
+      !sy = medianBandwidth y
+      !k  = rbfKernelMatrix sx x
+      !l  = rbfKernelMatrix sy y
+  in hsicWithKernels k l
+
+-- | bias HSIC を @hsicRBF@ で計算する公開エイリアス。
+hsicBiased :: LA.Matrix Double -> LA.Matrix Double -> Double
+hsicBiased = hsicRBF
+
+-- | 多次元 @X@ (n × p) と単変量 @r@ (長さ n) の依存度を、
+--   各列ごとの HSIC を **総和** して集約する。 ParceLiNGAM bottom-up の
+--   exogenous 判定に使う (cdt15/lingam の Fisher 法と同趣旨、 ただし p 値
+--   合成ではなく統計量の総和)。
+hsicAggregate :: LA.Matrix Double -> LA.Vector Double -> Double
+hsicAggregate x r =
+  let !p    = LA.cols x
+      !rMat = LA.asColumn r
+  in sum [ hsicRBF (LA.asColumn (LA.flatten (x LA.¿ [j]))) rMat
+         | j <- [0 .. p - 1] ]
diff --git a/src/Hanalyze/Math/Hungarian.hs b/src/Hanalyze/Math/Hungarian.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Math/Hungarian.hs
@@ -0,0 +1,132 @@
+{-# LANGUAGE BangPatterns #-}
+-- |
+-- Module      : Hanalyze.Math.Hungarian
+-- Description : Hungarian (Kuhn-Munkres) 法による正方割当問題の最小コスト解 (O(n³))
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Hungarian (Kuhn-Munkres) アルゴリズムによる正方割当問題の最小コスト解。
+--
+-- ## 入出力
+--
+-- 入力: コスト行列 C (n × n、 各成分は実数、 inf 不可)。
+-- 出力: 行 i に割当てる列 j からなる長さ n のベクトル @assignment[i] = j@。
+-- 目的: Σᵢ C[i, assignment[i]] を最小化、 かつ assignment が **全単射**。
+--
+-- ## 実装
+--
+-- e-maxx の "Hungarian algorithm in O(V³)" 系統 (Jonker-Volgenant の
+-- shortest augmenting path 方式)。 双対変数 u, v と potential を保持して
+-- 1 行ずつ augment する。 ST + mutable Vector で内部状態を管理し、 純関数
+-- 'hungarianMin' として API 公開する。
+--
+-- ## 用途
+--
+-- ICA-LiNGAM (Shimizu 2006) の行/列順列下三角化で、 W 行列の対角成分絶対値
+-- を最大化する割当を求めるのに使う。 コスト C[i, j] = 1 / (|W[i, j]| + ε)
+-- で 'hungarianMin' を呼ぶと、 グリーディと違って大域最適解が得られる。
+-- p > 10 でグリーディが劣化するケースを救う。
+--
+-- ## 計算量
+--
+-- O(n³)。 n ≤ 200 程度では実用上問題なし (測定: n=100 で数十 ms オーダー、
+-- 計測値ではなく目安)。
+module Hanalyze.Math.Hungarian
+  ( hungarianMin
+  ) where
+
+import           Control.Monad               (forM_, unless, when)
+import           Control.Monad.ST            (ST, runST)
+import           Data.STRef
+import qualified Data.Vector.Unboxed         as VU
+import qualified Data.Vector.Unboxed.Mutable as MV
+import qualified Numeric.LinearAlgebra       as LA
+
+-- ===========================================================================
+-- 公開 API
+-- ===========================================================================
+
+-- | 正方コスト行列 C (n × n) に対する最小コスト割当。
+--   戻り値 @v@ は @v VU.! i = j@ で「行 i が列 j に割当てられる」 意味。
+hungarianMin :: LA.Matrix Double -> VU.Vector Int
+hungarianMin cost
+  | n == 0    = VU.empty
+  | otherwise = runST (runHungarian n cost)
+  where
+    n = LA.rows cost
+
+-- ===========================================================================
+-- 内部実装 (ST monad、 1-indexed の慣例で size n+1 配列を確保)
+-- ===========================================================================
+
+runHungarian :: Int -> LA.Matrix Double -> ST s (VU.Vector Int)
+runHungarian n cost = do
+  let !inf = 1.0e300 :: Double
+  u   <- MV.replicate (n + 1) (0 :: Double)
+  v   <- MV.replicate (n + 1) (0 :: Double)
+  p   <- MV.replicate (n + 1) (0 :: Int)     -- p[j] = 列 j に割当てた行
+  way <- MV.replicate (n + 1) (0 :: Int)
+
+  forM_ [1 .. n] $ \i -> do
+    MV.write p 0 i
+    j0Ref <- newSTRef (0 :: Int)
+    minv  <- MV.replicate (n + 1) inf
+    used  <- MV.replicate (n + 1) False
+
+    let -- shortest-path-tree 拡張 1 ステップ
+        step = do
+          j0 <- readSTRef j0Ref
+          MV.write used j0 True
+          i0 <- MV.read p j0
+          deltaRef <- newSTRef inf
+          j1Ref    <- newSTRef (0 :: Int)
+          forM_ [1 .. n] $ \j -> do
+            isU <- MV.read used j
+            unless isU $ do
+              ui0 <- MV.read u i0
+              vj  <- MV.read v j
+              let !cur = LA.atIndex cost (i0 - 1, j - 1) - ui0 - vj
+              mj <- MV.read minv j
+              when (cur < mj) $ do
+                MV.write minv j cur
+                MV.write way  j j0
+              mj' <- MV.read minv j
+              d   <- readSTRef deltaRef
+              when (mj' < d) $ do
+                writeSTRef deltaRef mj'
+                writeSTRef j1Ref j
+          delta <- readSTRef deltaRef
+          forM_ [0 .. n] $ \j -> do
+            isU <- MV.read used j
+            if isU
+              then do
+                pj <- MV.read p j
+                upj <- MV.read u pj
+                MV.write u pj (upj + delta)
+                vj <- MV.read v j
+                MV.write v j (vj - delta)
+              else do
+                mj <- MV.read minv j
+                MV.write minv j (mj - delta)
+          j1 <- readSTRef j1Ref
+          writeSTRef j0Ref j1
+          pj1 <- MV.read p j1
+          when (pj1 /= 0) step
+    step
+
+    -- augmenting path に沿って割当を更新
+    let aug = do
+          j0 <- readSTRef j0Ref
+          j1 <- MV.read way j0
+          pj1 <- MV.read p j1
+          MV.write p j0 pj1
+          writeSTRef j0Ref j1
+          when (j1 /= 0) aug
+    aug
+
+  -- 結果ベクトルを構築: assignment[i-1] = j-1 (p[j] = i ⇒ row i → col j)
+  result <- MV.replicate n (0 :: Int)
+  forM_ [1 .. n] $ \j -> do
+    pj <- MV.read p j
+    when (pj >= 1) $ MV.write result (pj - 1) (j - 1)
+  VU.freeze result
diff --git a/src/Hanalyze/Math/ICA.hs b/src/Hanalyze/Math/ICA.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Math/ICA.hs
@@ -0,0 +1,180 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE BangPatterns      #-}
+-- |
+-- Module      : Hanalyze.Math.ICA
+-- Description : FastICA (Hyvärinen 1999) による独立成分分析 (whitening + fixed-point iteration)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- FastICA (Hyvärinen 1999) による独立成分分析。
+--
+-- 観測 X = A · S (n_samples × p)、 S が互いに独立な非ガウシアン成分のとき、
+-- A を推定して S = A⁻¹ · X を抽出する。 ICA-LiNGAM (Shimizu 2006) の前段
+-- および信号分離一般に使う。
+--
+-- ## アルゴリズム
+--
+-- 1. **Centering**: X の各列を中心化
+-- 2. **Whitening**: X の covariance を eigen 分解して
+--    @Z = E · D^(-1/2) · Eᵀ · X@ を作る (Z の cov = I)
+-- 3. **Fixed-point iteration** (per component): 任意の w から始めて
+--    @w⁺ = E[Z · g(wᵀZ)] - E[g'(wᵀZ)] · w@、 正規化、 直交化 (デフレーション)、
+--    収束 (|wᵀwᵒˡᵈ| ≈ 1) まで繰返し
+-- 4. **回収**: 全成分の row 構成 W に対し、 S = W · Z、 A = pinv(W) (whitened
+--    座標から元座標への戻し変換は別途)
+--
+-- non-linearity g としては logcosh (Hyvärinen 標準) を採用:
+-- g(u) = tanh(a·u)、 g'(u) = a·(1 - tanh²(a·u))、 a = 1.0
+--
+-- ## 出力
+--
+-- 'ICAResult' は分離行列 W (p × p, whitened 座標)、 mixing 行列 A (元座標、
+-- W · whiten から逆算)、 推定独立成分 S (n × p)、 収束情報を持つ。
+module Hanalyze.Math.ICA
+  ( ICAConfig (..)
+  , ICAResult (..)
+  , defaultICAConfig
+  , fitICA
+  , fitICAGen
+  , fitICAPure
+  ) where
+
+import qualified Numeric.LinearAlgebra as LA
+import qualified Data.Vector           as V
+import qualified System.Random.MWC     as MWC
+import           Control.Monad         (forM_, when)
+import           Control.Monad.Primitive (PrimMonad, PrimState)
+import           Control.Monad.ST      (runST)
+import           Data.Primitive.MutVar (newMutVar, readMutVar, writeMutVar)
+import           System.Random.MWC.Distributions (standard)
+
+-- ===========================================================================
+-- 設定
+-- ===========================================================================
+
+data ICAConfig = ICAConfig
+  { icaMaxIter   :: !Int
+  , icaTol       :: !Double
+  , icaNumComp   :: !(Maybe Int)
+    -- ^ 抽出する成分数。 'Nothing' で全成分 (= p)
+  , icaSeed      :: !(Maybe Int)
+  } deriving (Show)
+
+defaultICAConfig :: ICAConfig
+defaultICAConfig = ICAConfig
+  { icaMaxIter = 200
+  , icaTol     = 1e-4
+  , icaNumComp = Nothing
+  , icaSeed    = Just 12345
+  }
+
+data ICAResult = ICAResult
+  { icaW           :: !(LA.Matrix Double)
+    -- ^ whitened 空間での分離行列 (p × p)
+  , icaA           :: !(LA.Matrix Double)
+    -- ^ 元 X 空間における推定 mixing 行列。 X ≈ S · Aᵀ + mean
+  , icaUnmixing    :: !(LA.Matrix Double)
+    -- ^ 元 X 空間における分離行列 (S = (X - mean) · unmixingᵀ)
+  , icaS           :: !(LA.Matrix Double)
+    -- ^ 推定独立成分 (n × k)
+  , icaMean        :: !(LA.Vector Double)
+    -- ^ 列平均 (centering 用)
+  , icaConverged   :: !Bool
+  , icaIterations  :: !Int
+  } deriving (Show)
+
+-- ===========================================================================
+-- 主実装
+-- ===========================================================================
+
+-- | FastICA 本体 (Phase 77.C で PrimMonad 一般化)。 Gen を受け取り ST/IO いずれでも動く
+--   (IORef→MutVar)。 'fitICA' (IO) / 'fitICAPure' (ST・seed) が gen を作って呼ぶ。
+fitICAGen :: PrimMonad m => ICAConfig -> LA.Matrix Double -> MWC.Gen (PrimState m) -> m ICAResult
+fitICAGen cfg x gen = do
+  let !n  = LA.rows x
+      !p  = LA.cols x
+      !k  = maybe p id (icaNumComp cfg)
+      -- centering
+      means = LA.fromList
+                [ LA.sumElements (x LA.¿ [j]) / fromIntegral n
+                | j <- [0 .. p - 1] ]
+      meanMat = LA.fromRows (replicate n means)
+      xc      = x - meanMat
+      -- whitening: Z = E D^(-1/2) Eᵀ · Xᵀ をしたいが、 hmatrix は行ベクトル
+      -- 規約なので、 共分散行列を求めて eigen 分解する
+      cov     = (LA.tr xc LA.<> xc) / fromIntegral n
+      (d, e)  = LA.eigSH (LA.trustSym cov)
+      -- d : Vector Double, e : Matrix Double (columns are eigenvectors)
+      dInvSqrt = LA.cmap (\v -> if v > 1e-12 then 1 / sqrt v else 0) d
+      whitenMat = e LA.<> LA.diag dInvSqrt LA.<> LA.tr e   -- (p × p)
+      z         = xc LA.<> LA.tr whitenMat                -- (n × p)
+  -- FastICA loop (deflation) — p × p の分離行列 W を 1 行ずつ確定。 gen は引数。
+  wRowsRef <- newMutVar ([] :: [LA.Vector Double])
+  itersRef <- newMutVar (0 :: Int)
+  convRef  <- newMutVar True
+  forM_ [0 .. k - 1] $ \_compIdx -> do
+    -- 初期 w を gauss 乱数で
+    w0Raw <- V.replicateM p (standard gen)
+    let w0 = LA.fromList (V.toList w0Raw)
+    wsExisting <- readMutVar wRowsRef
+    -- 既存成分への直交化
+    let w0Ortho = deflate wsExisting w0
+        w0Norm  = LA.scale (1 / LA.norm_2 w0Ortho) w0Ortho
+    -- fixed point iteration
+    wRef <- newMutVar w0Norm
+    convergedThisRef <- newMutVar False
+    forM_ [1 .. icaMaxIter cfg] $ \iter -> do
+      wOld <- readMutVar wRef
+      isC  <- readMutVar convergedThisRef
+      when (not isC) $ do
+        let wu     = z LA.#> wOld          -- (n,)
+            gWu    = LA.cmap tanh wu
+            gpWu   = LA.cmap (\v -> 1 - tanh v ** 2) wu
+            wNew0  = LA.tr z LA.#> gWu / LA.scalar (fromIntegral n)
+                       - LA.scale (LA.sumElements gpWu / fromIntegral n) wOld
+            wDef   = deflate wsExisting wNew0
+            wNew   = LA.scale (1 / LA.norm_2 wDef) wDef
+            !diff  = abs (abs (wNew `LA.dot` wOld) - 1)
+        writeMutVar wRef wNew
+        writeMutVar itersRef iter
+        when (diff < icaTol cfg) $ writeMutVar convergedThisRef True
+    finalConv <- readMutVar convergedThisRef
+    when (not finalConv) $ writeMutVar convRef False
+    wFinal <- readMutVar wRef
+    writeMutVar wRowsRef (wsExisting ++ [wFinal])
+  ws <- readMutVar wRowsRef
+  let !wMat = LA.fromRows ws                    -- (k × p)、 whitened 空間
+      !sMat = z LA.<> LA.tr wMat                -- (n × k)、 独立成分
+      -- 元 X 空間: unmixing = wMat · whitenMat (k × p)
+      !unmixing = wMat LA.<> whitenMat
+      -- mixing = pseudo-inverse of unmixing  (p × k)
+      !mixing   = LA.pinv unmixing
+  iters <- readMutVar itersRef
+  conv  <- readMutVar convRef
+  pure ICAResult
+    { icaW           = wMat
+    , icaA           = mixing
+    , icaUnmixing    = unmixing
+    , icaS           = sMat
+    , icaMean        = means
+    , icaConverged   = conv
+    , icaIterations  = iters
+    }
+  where
+    deflate :: [LA.Vector Double] -> LA.Vector Double -> LA.Vector Double
+    deflate ws w = foldl (\acc wi -> acc - LA.scale (acc `LA.dot` wi) wi) w ws
+
+-- | FastICA (IO)。 'icaSeed' が 'Just' なら決定的、 'Nothing' で system random。
+fitICA :: ICAConfig -> LA.Matrix Double -> IO ICAResult
+fitICA cfg x = do
+  gen <- case icaSeed cfg of
+    Just s  -> MWC.initialize (V.fromList [fromIntegral s])
+    Nothing -> MWC.createSystemRandom
+  fitICAGen cfg x gen
+
+-- | FastICA の **seed 純粋版** (Phase 77.C・@df |->@ 用)。 'icaSeed' (既定 12345・'Nothing' は
+--   12345 fallback) で 'runST'+MWC。 同 seed で IO 版とビット一致 (乱数列は monad 非依存)。
+fitICAPure :: ICAConfig -> LA.Matrix Double -> ICAResult
+fitICAPure cfg x = runST $ do
+  gen <- MWC.initialize (V.fromList [fromIntegral (maybe 12345 id (icaSeed cfg))])
+  fitICAGen cfg x gen
diff --git a/src/Hanalyze/Model/AFT.hs b/src/Hanalyze/Model/AFT.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Model/AFT.hs
@@ -0,0 +1,212 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE BangPatterns #-}
+-- |
+-- Module      : Hanalyze.Model.AFT
+-- Description : Accelerated Failure Time (AFT) パラメトリック生存モデル
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Accelerated Failure Time (AFT) parametric survival model.
+--
+-- AFT は寿命 T の対数を共変量の線形関数として表現する:
+--
+-- @
+-- log T_i = X_i β + σ ε_i
+-- @
+--
+-- ε の分布で family が決まる:
+--
+--   * 'AFTWeibull'    : ε ~ Gumbel  (生存解析の Weibull AFT)
+--   * 'AFTLogNormal'  : ε ~ Normal(0, 1)
+--   * 'AFTLogLogistic': ε ~ Logistic(0, 1)
+--   * 'AFTExponential': Weibull with σ = 1 を固定
+--
+-- 右側打ち切り (right censoring) 対応。 推定は対数尤度の最大化を
+-- Nelder-Mead で行う (純粋関数のため runIdentity 経由)。
+--
+-- API:
+--
+-- > fitAFT     :: AFTDistribution -> Matrix Double -> Vector Double
+-- >            -> Vector Bool -> IO (Either Text AFTFit)
+-- > predictAFT :: AFTFit -> Matrix Double -> Vector Double  -- 期待寿命
+module Hanalyze.Model.AFT
+  ( AFTDistribution (..)
+  , AFTFit (..)
+  , fitAFT
+  , predictAFT
+  , logS          -- ^ 標準化誤差 z の log 生存関数 (= 生存曲線描画に使用・Phase 68 A5)
+  ) where
+
+import qualified Data.Vector                       as V
+import qualified Numeric.LinearAlgebra             as LA
+import           Data.Text                         (Text)
+import qualified Data.Text                         as T
+import qualified Statistics.Distribution           as SD
+import qualified Statistics.Distribution.Normal    as ND
+
+import           Hanalyze.Optim.NelderMead         (runNelderMeadWith, defaultNMConfig,
+                                                    NMConfig (..))
+import           Hanalyze.Optim.Common             (OptimResult (..), StopCriteria (..))
+
+-- ===========================================================================
+-- 型
+-- ===========================================================================
+
+data AFTDistribution
+  = AFTWeibull
+  | AFTLogNormal
+  | AFTLogLogistic
+  | AFTExponential
+  deriving (Show, Eq)
+
+data AFTFit = AFTFit
+  { aftBeta         :: !(LA.Vector Double)
+  , aftScale        :: !Double            -- ^ scale parameter σ
+  , aftLogLik       :: !Double
+  , aftDistribution :: !AFTDistribution
+  , aftIters        :: !Int
+  } deriving (Show)
+
+-- ===========================================================================
+-- fit
+-- ===========================================================================
+
+-- | AFT モデルを MLE で fit する。
+--   X: n × p 共変量、 t: n 観測時間 (> 0)、 delta: n failure indicator
+--   (True = 観測、 False = 右側打ち切り)。
+fitAFT
+  :: AFTDistribution
+  -> LA.Matrix Double
+  -> LA.Vector Double
+  -> V.Vector Bool
+  -> IO (Either Text AFTFit)
+fitAFT dist x t delta
+  | LA.rows x /= LA.size t || LA.rows x /= V.length delta =
+      pure (Left "fitAFT: input dimensions mismatch")
+  | LA.size t == 0 =
+      pure (Left "fitAFT: empty input")
+  | V.any (<= 0) (V.fromList (LA.toList t)) =
+      pure (Left "fitAFT: t must be > 0")
+  | otherwise = do
+      let p   = LA.cols x
+          -- intercept-only start: β_0 = mean(log t), β_j = 0 (j ≥ 1)
+          logT = LA.cmap log t
+          beta0 =
+            let mu = LA.sumElements logT / fromIntegral (LA.size logT)
+            in if p == 0
+                 then []
+                 else mu : replicate (p - 1) 0
+          -- log σ を最後に追加 (Exponential では 0 固定)
+          x0 = case dist of
+                 AFTExponential -> beta0
+                 _              -> beta0 ++ [0]   -- log σ = 0  → σ = 1 として開始
+          obj params =
+            let (betaPart, logSigma) = case dist of
+                  AFTExponential -> (params, 0)
+                  _              -> (init params, last params)
+                sigma = exp logSigma
+                betaV = LA.fromList betaPart
+            in negate (logLikAFT dist x t delta betaV sigma)
+          cfg = defaultNMConfig
+            { nmStop = StopCriteria
+                { stMaxIter = 2000
+                , stTolFun  = 1e-8
+                , stTolX    = 1e-8
+                }
+            }
+      res <- runNelderMeadWith cfg obj x0
+      let xs = orBest res
+          (betaPart, sigma) = case dist of
+            AFTExponential -> (xs, 1)
+            _              -> (init xs, exp (last xs))
+          betaV = LA.fromList betaPart
+          ll = logLikAFT dist x t delta betaV sigma
+      pure (Right AFTFit
+              { aftBeta         = betaV
+              , aftScale        = sigma
+              , aftLogLik       = ll
+              , aftDistribution = dist
+              , aftIters        = orIters res
+              })
+
+-- | 期待寿命の予測 E[T | X] = exp(X β + σ² / 2) -- log-normal の場合
+--   Weibull AFT: E[T] = exp(X β) · Γ(1 + σ)
+--   LogLogistic: E[T] = exp(X β) · π σ / sin(π σ) (σ < 1)
+--   Exponential: E[T] = exp(X β)
+predictAFT :: AFTFit -> LA.Matrix Double -> LA.Vector Double
+predictAFT fit xNew =
+  let linPred = xNew LA.#> aftBeta fit
+      sigma   = aftScale fit
+      adjust  = case aftDistribution fit of
+        AFTWeibull     -> gammaApprox (1 + sigma)
+        AFTLogNormal   -> exp (sigma * sigma / 2)
+        AFTLogLogistic ->
+          if sigma < 1 && sigma > 0
+            then pi * sigma / sin (pi * sigma)
+            else 1 / 0   -- 平均が発散
+        AFTExponential -> 1
+  in LA.cmap (\lp -> exp lp * adjust) linPred
+
+-- ===========================================================================
+-- 内部 helpers
+-- ===========================================================================
+
+-- | 対数尤度。 censored は log S(t)、 observed は log f(t)。
+logLikAFT
+  :: AFTDistribution
+  -> LA.Matrix Double -> LA.Vector Double -> V.Vector Bool
+  -> LA.Vector Double -> Double
+  -> Double
+logLikAFT dist x t delta beta sigma
+  | sigma <= 0 = -1e15
+  | otherwise =
+      let n = LA.rows x
+          eta = x LA.#> beta             -- length n
+          logT = LA.cmap log t           -- length n
+          zs = LA.cmap (/ sigma) (logT - eta)
+      in sum
+           [ let z   = LA.atIndex zs i
+                 lt  = LA.atIndex logT i
+                 obs = delta V.! i
+             in if obs
+                  then logPDF dist sigma lt z
+                  else logS  dist z
+           | i <- [0 .. n - 1] ]
+
+-- | log f(t)  =  log f_ε(z) − log σ − log t
+logPDF :: AFTDistribution -> Double -> Double -> Double -> Double
+logPDF dist sigma logT z =
+  let body = case dist of
+        AFTWeibull     -> z - exp z
+        AFTExponential -> z - exp z
+        AFTLogNormal   -> -0.5 * z * z - 0.5 * log (2 * pi)
+        AFTLogLogistic -> z - 2 * log1p (exp z)
+  in body - log (max 1e-300 sigma) - logT
+
+-- | log S(t)  =  log S_ε(z)
+logS :: AFTDistribution -> Double -> Double
+logS dist z = case dist of
+  AFTWeibull     -> -exp z
+  AFTExponential -> -exp z
+  AFTLogNormal   -> log (max 1e-300 (1 - SD.cumulative ND.standard z))
+  AFTLogLogistic -> -log1p (exp z)
+
+log1p :: Double -> Double
+log1p x
+  | abs x < 1e-4 = x - x * x / 2 + x * x * x / 3
+  | otherwise    = log (1 + x)
+
+-- | Stirling 近似による Γ(x) (x > 0)。 AFT の平均補正で使うだけなので簡易版。
+gammaApprox :: Double -> Double
+gammaApprox x
+  | x <= 0 = 1 / 0
+  | x < 1  = gammaApprox (x + 1) / x
+  | otherwise =
+      let n = floor (x - 1) :: Int
+          frac = x - fromIntegral n - 1
+          base = gammaStirling (1 + frac)
+      in base * fromIntegral (product [1 .. n])
+  where
+    gammaStirling y =
+      sqrt (2 * pi / y) * (y / exp 1) ** y
+      * (1 + 1/(12*y) + 1/(288*y*y))
diff --git a/src/Hanalyze/Model/Cluster.hs b/src/Hanalyze/Model/Cluster.hs
--- a/src/Hanalyze/Model/Cluster.hs
+++ b/src/Hanalyze/Model/Cluster.hs
@@ -1,7 +1,13 @@
 {-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE BangPatterns #-}
--- | Clustering algorithms.
+-- |
+-- Module      : Hanalyze.Model.Cluster
+-- Description : クラスタリングアルゴリズム (k-means / silhouette / inertia)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
 --
+-- Clustering algorithms.
+--
 -- Implements:
 --
 --   * 'kMeans' (Lloyd / Forgy / k-means++ initialisation, multi-restart)
@@ -14,8 +20,9 @@
     KMeansConfig (..)
   , KMeansInit (..)
   , KMeansResult (..)
-  , defaultKMeansConfig
+  , defaultKMeans
   , kMeans
+  , kMeansPure
     -- * Quality metrics
   , silhouette
   , inertia
@@ -27,7 +34,8 @@
 import qualified Numeric.LinearAlgebra        as LA
 import qualified Hanalyze.Stat.KernelDist              as KD
 import qualified System.Random.MWC            as MWC
-import           Control.Monad                (forM_)
+import           Control.Monad                (forM_, foldM)
+import           Control.Monad.Primitive      (PrimMonad, PrimState)
 import           Control.Monad.ST             (ST, runST)
 import qualified Data.Vector                  as V
 import qualified Data.Vector.Mutable          as VM
@@ -35,9 +43,9 @@
 import qualified Data.Vector.Unboxed.Mutable  as MVU
 import qualified Data.Vector.Storable         as VS
 import qualified Data.Vector.Storable.Mutable as VSM
-import           Data.IORef
 import           Data.List                    (minimumBy)
 import           Data.Ord                     (comparing)
+import           Data.Word                    (Word32)
 
 -- ---------------------------------------------------------------------------
 -- K-means
@@ -59,8 +67,8 @@
   } deriving (Show, Eq)
 
 -- | Default: k-means++, 300 iters, tol 1e-4, 10 restarts.
-defaultKMeansConfig :: Int -> KMeansConfig
-defaultKMeansConfig k = KMeansConfig
+defaultKMeans :: Int -> KMeansConfig
+defaultKMeans k = KMeansConfig
   { kmK        = k
   , kmInit     = KMeansPlus
   , kmMaxIter  = 300
@@ -79,17 +87,34 @@
 
 -- | Fit K-means; runs 'kmRestarts' independent restarts and keeps
 -- the lowest-inertia solution.
+--
+-- IO ラッパ。 ロジックは 'PrimMonad' 汎用の 'kMeansM' (mwc は 'PrimMonad'
+-- 汎用ゆえ ST/IO で同コードを共有) をそのまま IO に特殊化したもの。
 kMeans :: KMeansConfig -> LA.Matrix Double -> MWC.GenIO -> IO KMeansResult
-kMeans cfg x gen = do
-  results <- mapM (\_ -> kMeansSingleRun cfg x gen) [1 .. kmRestarts cfg]
+kMeans = kMeansM
+
+-- | 純粋・決定的な K-means。 同じ @seed@ なら必ず同じ 'KMeansResult' を返す
+-- (同 seed → ビット同一・IO 不要)。 'kMeansM' を 'ST' で走らせ 'runST' で
+-- 閉じる ([[phase-50-mcmc-purification-status]] の 'nutsPure' と同方針)。
+kMeansPure :: KMeansConfig -> LA.Matrix Double -> Word32 -> KMeansResult
+kMeansPure cfg x seed =
+  runST (MWC.initialize (V.singleton seed) >>= kMeansM cfg x)
+
+-- | 'PrimMonad' 汎用の K-means 本体。 'kMeans' (IO) / 'kMeansPure' (ST) が共有。
+kMeansM :: PrimMonad m
+        => KMeansConfig -> LA.Matrix Double -> MWC.Gen (PrimState m)
+        -> m KMeansResult
+kMeansM cfg x gen = do
+  results <- mapM (\_ -> kMeansSingleRunM cfg x gen) [1 .. kmRestarts cfg]
   pure (minimumBy (comparing kmrInertia) results)
 
-kMeansSingleRun :: KMeansConfig -> LA.Matrix Double -> MWC.GenIO
-                -> IO KMeansResult
-kMeansSingleRun cfg x gen = do
+kMeansSingleRunM :: PrimMonad m
+                 => KMeansConfig -> LA.Matrix Double -> MWC.Gen (PrimState m)
+                 -> m KMeansResult
+kMeansSingleRunM cfg x gen = do
   initC <- case kmInit cfg of
-    Forgy      -> forgyInit (kmK cfg) x gen
-    KMeansPlus -> kmppInit (kmK cfg) x gen
+    Forgy      -> forgyInitM (kmK cfg) x gen
+    KMeansPlus -> kmppInitM (kmK cfg) x gen
   -- Hot loop: keep labels as 'VU.Vector Int' to avoid the per-iteration
   -- list↔Vector roundtrip the previous version paid via 'assignLabels'
   -- + 'updateCentroids' on @[Int]@.
@@ -113,11 +138,13 @@
     }
 
 -- | Forgy initialisation: pick k random rows.
-forgyInit :: Int -> LA.Matrix Double -> MWC.GenIO -> IO (LA.Matrix Double)
-forgyInit k x gen = do
+forgyInitM :: PrimMonad m
+           => Int -> LA.Matrix Double -> MWC.Gen (PrimState m)
+           -> m (LA.Matrix Double)
+forgyInitM k x gen = do
   let n     = LA.rows x
       xRowsV = V.fromList (LA.toRows x)   -- O(1) row access
-  idxs <- pickKDistinct k n gen
+  idxs <- pickKDistinctM k n gen
   pure (LA.fromRows [xRowsV V.! i | i <- idxs])
 
 -- | k-means++ initialisation: 1st centroid uniform random, subsequent
@@ -134,8 +161,10 @@
 -- The fused-BLAS form below uses pre-computed row sq-norms and a
 -- single matrix-vector multiply per centroid — O(np) work for the
 -- whole sweep instead of O(n) per row.
-kmppInit :: Int -> LA.Matrix Double -> MWC.GenIO -> IO (LA.Matrix Double)
-kmppInit k x gen = do
+kmppInitM :: PrimMonad m
+          => Int -> LA.Matrix Double -> MWC.Gen (PrimState m)
+          -> m (LA.Matrix Double)
+kmppInitM k x gen = do
   let n        = LA.rows x
       -- Pre-compute row squared norms once: ‖x_i‖² for all rows
       -- (length-n vector via @(X ⊙ X) · 1@).
@@ -143,16 +172,12 @@
 
   -- Pick the first centroid.
   i0 <- MWC.uniformR (0, n - 1) gen
-  -- Row index list of chosen centroids (kept as Int indices, not row
-  -- vectors, to avoid forming a boxed list of slices).
-  centroidIdx <- newIORef [i0]
   -- bestDist[i] = ‖x_i − x_{i0}‖²  in BLAS form:
   --   = ‖x_i‖² + ‖x_{i0}‖² − 2 x_iᵀ x_{i0}
   -- via @cross = X · x_{i0}@ (one GEMV), reusing 'normsX'.
   let initBest = sqDistsToRow x normsX i0
-  bestRef <- newIORef initBest
 
-  let pickWeighted total bdv =
+      pickWeighted total bdv =
         if total <= 0
           then pure 0
           else do
@@ -167,21 +192,21 @@
                         else go nxt (i + 1)
             go 0 0
 
-  forM_ [2 .. k] $ \_ -> do
-    bd <- readIORef bestRef
-    let !total = VS.sum bd
-    pickIdx <- pickWeighted total bd
-    -- One GEMV → length-n @sq dist to new centroid@; element-wise
-    -- min with @bestDist@ in a single Storable Vector pass.
-    let !newDist = sqDistsToRow x normsX pickIdx
-        !updated = VS.zipWith min bd newDist
-    writeIORef bestRef updated
-    modifyIORef' centroidIdx (pickIdx :)
+      -- IORef を foldM で純粋に畳む (純粋化のため・乱数列順は不変ゆえ
+      -- 旧 IORef 版とビット同一)。 state = (bestDist, 逆順 centroid idx)。
+      step (bd, acc) _ = do
+        let !total = VS.sum bd
+        pickIdx <- pickWeighted total bd
+        -- One GEMV → length-n @sq dist to new centroid@; element-wise
+        -- min with @bestDist@ in a single Storable Vector pass.
+        let !newDist = sqDistsToRow x normsX pickIdx
+            !updated = VS.zipWith min bd newDist
+        pure (updated, pickIdx : acc)
 
-  idxs <- readIORef centroidIdx
+  (_, idxsRev) <- foldM step (initBest, [i0]) [2 .. k]
   -- Build the @k × p@ centroid matrix from row indices in one shot.
   let xRowsV = V.fromList (LA.toRows x)
-  pure (LA.fromRows [xRowsV V.! i | i <- reverse idxs])
+  pure (LA.fromRows [xRowsV V.! i | i <- reverse idxsRev])
 
 -- | Squared distance from every row of @X@ (n × p) to @X[i, :]@,
 -- via the BLAS identity
@@ -202,8 +227,9 @@
   in LA.cmap (max 0) d   -- numerical-noise floor at 0
 
 -- | Pick k distinct indices in [0, n) via Fisher-Yates partial.
-pickKDistinct :: Int -> Int -> MWC.GenIO -> IO [Int]
-pickKDistinct k n gen = do
+pickKDistinctM :: PrimMonad m
+               => Int -> Int -> MWC.Gen (PrimState m) -> m [Int]
+pickKDistinctM k n gen = do
   v <- V.thaw (V.fromList [0 .. n - 1])
   forM_ [0 .. min k n - 1] $ \i -> do
     j <- MWC.uniformR (i, n - 1) gen
diff --git a/src/Hanalyze/Model/CompetingRisks.hs b/src/Hanalyze/Model/CompetingRisks.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Model/CompetingRisks.hs
@@ -0,0 +1,114 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE BangPatterns #-}
+-- |
+-- Module      : Hanalyze.Model.CompetingRisks
+-- Description : 競合リスク生存解析 (累積発生関数 CIF 推定)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Competing-risks survival analysis.
+--
+-- Extends 'Hanalyze.Model.Survival' to settings with multiple, mutually
+-- exclusive failure causes. Implements the non-parametric Cumulative
+-- Incidence Function (CIF) estimator (Kalbfleisch & Prentice 1980):
+--
+-- @
+--   F̂_k(t) = Σ_{t_i ≤ t}  Ŝ(t_i⁻) · (d_{k,i} / n_i)
+-- @
+--
+-- where @Ŝ@ is the overall Kaplan-Meier survival treating *any* cause as
+-- an event, @d_{k,i}@ is the number of failures from cause @k@ at time
+-- @t_i@, and @n_i@ is the size of the risk set just before @t_i@.
+--
+-- The naïve approach of taking @1 - KM@ on cause-specific data ignores
+-- competing events and biases the cumulative incidence upward; this
+-- estimator is the canonical correction.
+--
+-- @
+-- import Hanalyze.Model.CompetingRisks
+--
+-- let samples = [ CRSample 1.2 1, CRSample 2.5 2, CRSample 3.0 0, … ]
+--     fit     = fitCompetingRisks samples
+-- @
+--
+-- == Implemented
+--
+--   * 'fitCompetingRisks' (per-cause CIF on the distinct event grid)
+module Hanalyze.Model.CompetingRisks
+  ( CRSample (..)
+  , CRFit (..)
+  , fitCompetingRisks
+  ) where
+
+import qualified Numeric.LinearAlgebra as LA
+import           Data.List             (sort, nub, sortBy)
+import           Data.Ord              (comparing)
+
+-- ---------------------------------------------------------------------------
+-- Types
+-- ---------------------------------------------------------------------------
+
+-- | A single observation with cause-of-failure indicator.
+-- @crCause = 0@ ↔ right-censored, @crCause ≥ 1@ ↔ failure from that cause.
+data CRSample = CRSample
+  { crTime  :: !Double
+  , crCause :: !Int
+  } deriving (Show, Eq)
+
+-- | Fitted competing-risks estimator: cumulative incidence per cause,
+-- evaluated on the distinct event times (causes 1, …, K combined).
+data CRFit = CRFit
+  { crfCauses          :: ![Int]                       -- ^ Cause labels (sorted).
+  , crfTimes           :: !(LA.Vector Double)          -- ^ Distinct event times.
+  , crfCIF             :: ![(Int, LA.Vector Double)]   -- ^ Per-cause CIF values
+                                                       --   on @crfTimes@.
+  , crfOverallSurvival :: !(LA.Vector Double)          -- ^ Overall KM survival
+                                                       --   on @crfTimes@.
+  } deriving (Show)
+
+-- ---------------------------------------------------------------------------
+-- Fitting
+-- ---------------------------------------------------------------------------
+
+-- | Estimate the cumulative incidence function for each observed cause.
+-- Inputs need not be sorted; ties at the same time are handled jointly.
+fitCompetingRisks :: [CRSample] -> CRFit
+fitCompetingRisks samples =
+  let sorted     = sortBy (comparing crTime) samples
+      causes     = sort (nub [ c | CRSample _ c <- sorted, c > 0 ])
+      eventTimes = sort (nub [ crTime s | s <- sorted, crCause s > 0 ])
+      -- Number at risk just before time t: #{s | crTime s >= t}.
+      atRisk t   = length [ s | s <- sorted, crTime s >= t ]
+      atTime t   = [ s | s <- sorted, crTime s == t ]
+      -- Per-event-time row: (S(t⁻) before update, n at risk, total d, per-cause d)
+      step !sPrev t =
+        let here       = atTime t
+            events     = [ c | CRSample _ c <- here, c > 0 ]
+            dTot       = length events
+            n          = atRisk t
+            sNew       = sPrev * (1 - fromIntegral dTot / fromIntegral n)
+            incs       = [ ( k
+                           , sPrev * fromIntegral (length [ c | c <- events, c == k ])
+                                       / fromIntegral n )
+                         | k <- causes ]
+        in (sNew, incs)
+      walk _      []       = ([], [])
+      walk !sPrev (t : ts) =
+        let (sNew, incs)  = step sPrev t
+            (ss, incss)   = walk sNew ts
+        in (sNew : ss, incs : incss)
+      (survList, incList) = walk 1.0 eventTimes
+      sVec     = LA.fromList survList
+      -- Cumulate increments per cause across the event-time grid.
+      cumulate inc = scanl1 (+) inc
+      cifByCause k =
+        let perTimeInc = [ snd (head [ (k', v) | (k', v) <- row, k' == k ])
+                         | row <- incList ]
+        in LA.fromList (cumulate perTimeInc)
+      cifs = [ (k, cifByCause k) | k <- causes ]
+  in CRFit
+       { crfCauses          = causes
+       , crfTimes           = LA.fromList eventTimes
+       , crfCIF             = cifs
+       , crfOverallSurvival = sVec
+       }
diff --git a/src/Hanalyze/Model/Core.hs b/src/Hanalyze/Model/Core.hs
--- a/src/Hanalyze/Model/Core.hs
+++ b/src/Hanalyze/Model/Core.hs
@@ -1,6 +1,12 @@
 {-# LANGUAGE OverloadedStrings #-}
--- | Result type and 'Model' class shared by every regression model.
+-- |
+-- Module      : Hanalyze.Model.Core
+-- Description : 全回帰モデル共通の Result 型と Model 型クラス
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
 --
+-- Result type and 'Model' class shared by every regression model.
+--
 -- For multi-output support, the principal fields of 'FitResult' are
 -- generalized to @Matrix Double@ (@n × q@) or @Vector Double@ (@q@-vector).
 -- Single-output (@q = 1@) models can keep using the convenience accessors
@@ -13,6 +19,8 @@
 module Hanalyze.Model.Core
   ( FitResult (..)
   , Model (..)
+  , PredictiveModel (..)
+  , ResidualModel (..)
   , Band (..)
     -- * Vec / Scalar accessors (for @q = 1@)
   , coefficientsV
@@ -128,3 +136,41 @@
           -> LA.Matrix Double  -- ^ Coefficients @β@ of shape @p × q@.
           -> LA.Matrix Double  -- ^ Test input @X_new@ of shape @m × p@.
           -> LA.Matrix Double  -- ^ Predictions @ŷ@ of shape @m × q@.
+
+-- ---------------------------------------------------------------------------
+-- 能力別 protocol (Phase 46 / plot Phase 15 = analyze 統合 A 先行)
+--
+-- モデルの「能力」 を細粒度 class に割り、 持てる能力だけ instance を生やす
+-- (spec §2.3 = god class を避ける)。 数値核は hmatrix で完結 (list 操作で書かない)。
+-- これらは plot 非依存 = hanalyze-portable (toPlot/Plottable は別途 Hanalyze.Plot)。
+-- ===========================================================================
+
+-- | 残差を取り出せるフィット結果。 'toPlot' の残差診断図 (残差 vs fitted / QQ)
+-- が要求する最小能力。
+class ResidualModel r where
+  -- | 残差ベクトル (単出力 @q = 1@ を想定。 多出力は 'residualsCol' を使う)。
+  residualsOf :: r -> LA.Vector Double
+
+-- | 新しい入力に対し予測できるフィット結果。 'toPlot' の回帰線・予測 band が
+-- 要求する最小能力。
+--
+-- ⚠ 既定の意味は **線形予測子** @η = X_new · β@ (列 = 各応答)。 LM では平均応答に
+-- 一致するが、 GLM の平均応答 @μ = g⁻¹(η)@ には逆リンクが要る (モデルタグ依存)
+-- ため、 GLM は 'Model' の 'predict' を使うこと。 本 class は線形スケールの予測を
+-- 与える低レベル能力と位置づける。
+class PredictiveModel r where
+  -- | @X_new (m×p)@ に対する線形予測子 @ŷ = X_new · β (m×q)@。
+  predictAt :: r -> LA.Matrix Double -> LA.Matrix Double
+
+-- ---------------------------------------------------------------------------
+-- FitResult instances
+--
+-- 'FitResult' は LM / GLM / GLMM が共有する数値核 (= 1 instance で 3 モデルを覆う)。
+-- ===========================================================================
+
+instance ResidualModel FitResult where
+  residualsOf = residualsV
+
+instance PredictiveModel FitResult where
+  -- ŷ = X_new · β  (β = coefficients、 線形予測子)
+  predictAt res xNew = xNew LA.<> coefficients res
diff --git a/src/Hanalyze/Model/DAG.hs b/src/Hanalyze/Model/DAG.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Model/DAG.hs
@@ -0,0 +1,239 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE BangPatterns      #-}
+-- |
+-- Module      : Hanalyze.Model.DAG
+-- Description : DAG (有向非巡回グラフ) の共通表現 (重み付き隣接行列)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Directed Acyclic Graph (DAG) の共通表現。
+--
+-- 因果探索 (LiNGAM 系) / 将来の SEM / Bayesian Network の出力型を統一する。
+-- 内部表現は **重み付き隣接行列** で、 hmatrix の線形代数操作との親和性を保つ。
+--
+-- ## 規約
+--
+-- 重み行列 W (p × p) の要素 W[i, j] は **エッジ j → i の重み** を表す。
+-- これは構造方程式 X_i = Σ_j W[i, j] · X_j + e_i に対応する自然な向きで、
+-- LiNGAM の B 行列と完全一致する。 W[i, i] = 0 (self-loop 禁止)。
+--
+-- ## DAG 判定
+--
+-- 'isAcyclic' は W の非零パターンから到達可能性を見て循環を検出する。
+-- 浮動小数閾値の影響を避けるため、 判定は 'dagW' の **絶対値 > 0** マスク
+-- に対して実施。 ノイズで小さな非零が出る場合は事前に 'pruneByThreshold'
+-- でクリーンナップする。
+module Hanalyze.Model.DAG
+  ( DAG (..)
+  , Edge (..)
+  -- 構築
+  , mkDAG
+  , fromAdjacency
+  , fromBMatrix
+  , withNames
+  -- 操作
+  , pruneByThreshold
+  -- 問合せ
+  , dagEdges
+  , dagParents
+  , dagChildren
+  , dagNodeName
+  , topoSort
+  , isAcyclic
+  , dagReachable
+  -- 出力
+  , toDOT
+  ) where
+
+import qualified Data.Set              as S
+import qualified Data.Text             as T
+import qualified Data.Vector           as V
+import qualified Numeric.LinearAlgebra as LA
+import           Data.Text             (Text)
+import           Data.List             (foldl')
+
+-- ===========================================================================
+-- 型
+-- ===========================================================================
+
+data DAG = DAG
+  { dagN     :: !Int
+    -- ^ ノード数
+  , dagNames :: !(Maybe (V.Vector Text))
+    -- ^ ノード名 (任意)。 'Nothing' なら "x0".."x(n-1)" を使う
+  , dagW     :: !(LA.Matrix Double)
+    -- ^ 重み付き隣接行列 (p × p)。 W[i, j] = エッジ j → i の重み
+  } deriving (Show)
+
+data Edge = Edge
+  { edgeFrom   :: !Int
+  , edgeTo     :: !Int
+  , edgeWeight :: !Double
+  } deriving (Show, Eq)
+
+-- ===========================================================================
+-- 構築
+-- ===========================================================================
+
+-- | 重み付き隣接行列から DAG を作る。 ノード数は W の行数。 W が
+--   p × p でない場合は呼出側のバグ (here で error)。
+mkDAG :: LA.Matrix Double -> DAG
+mkDAG w
+  | LA.rows w /= LA.cols w =
+      error "Hanalyze.Model.DAG.mkDAG: W は p × p 正方行列でなければならない"
+  | otherwise = DAG
+      { dagN     = LA.rows w
+      , dagNames = Nothing
+      , dagW     = w
+      }
+
+-- | 0/1 隣接行列から DAG。 重みはエッジ存在を 1 として保持。
+fromAdjacency :: LA.Matrix Double -> DAG
+fromAdjacency = mkDAG
+
+-- | LiNGAM B 行列 + threshold から DAG を構築。 |B[i, j]| ≤ thr の
+--   エッジは刈り取る。 対角要素は常に 0。
+fromBMatrix :: Double -> LA.Matrix Double -> DAG
+fromBMatrix thr b = mkDAG (pruned b)
+  where
+    pruned m =
+      let p = LA.rows m
+          f i j
+            | i == j                          = 0
+            | abs (LA.atIndex m (i, j)) <= thr = 0
+            | otherwise                       = LA.atIndex m (i, j)
+      in LA.build (p, p) (\i j -> f (round i) (round j) :: Double)
+
+-- | ノード名を付与する (length 不一致は呼出側のバグ)。
+withNames :: V.Vector Text -> DAG -> DAG
+withNames ns g
+  | V.length ns /= dagN g =
+      error "Hanalyze.Model.DAG.withNames: ノード数と名前数が不一致"
+  | otherwise = g { dagNames = Just ns }
+
+-- ===========================================================================
+-- 操作
+-- ===========================================================================
+
+-- | |W[i, j]| ≤ thr のエッジを 0 に。 自己ループは常に 0。
+pruneByThreshold :: Double -> DAG -> DAG
+pruneByThreshold thr g = g { dagW = pruned }
+  where
+    p = dagN g
+    f i j
+      | i == j                                = 0
+      | abs (LA.atIndex (dagW g) (i, j)) <= thr = 0
+      | otherwise                             = LA.atIndex (dagW g) (i, j)
+    pruned = LA.build (p, p) (\i j -> f (round i) (round j) :: Double)
+
+-- ===========================================================================
+-- 問合せ
+-- ===========================================================================
+
+-- | 全エッジを (from, to, weight) のリストで返す (非零重みのみ)。
+dagEdges :: DAG -> [Edge]
+dagEdges g =
+  let p = dagN g
+      w = dagW g
+  in [ Edge j i (LA.atIndex w (i, j))
+     | i <- [0 .. p - 1]
+     , j <- [0 .. p - 1]
+     , i /= j
+     , LA.atIndex w (i, j) /= 0
+     ]
+
+-- | ノード i に直接影響を与えるノード集合 (W[i, j] ≠ 0 となる j のリスト)。
+dagParents :: DAG -> Int -> [Int]
+dagParents g i =
+  [ j | j <- [0 .. dagN g - 1]
+      , j /= i
+      , LA.atIndex (dagW g) (i, j) /= 0 ]
+
+-- | ノード i から直接影響を受けるノード集合 (W[k, i] ≠ 0 となる k のリスト)。
+dagChildren :: DAG -> Int -> [Int]
+dagChildren g i =
+  [ k | k <- [0 .. dagN g - 1]
+      , k /= i
+      , LA.atIndex (dagW g) (k, i) /= 0 ]
+
+-- | ノード名取得 ('dagNames' が Nothing なら "x{idx}")。
+dagNodeName :: DAG -> Int -> Text
+dagNodeName g i = case dagNames g of
+  Just ns | i >= 0 && i < V.length ns -> ns V.! i
+  _                                   -> T.pack ("x" <> show i)
+
+-- | 到達可能性: from から to へ DAG エッジを辿って到達可能か。
+dagReachable :: DAG -> Int -> Int -> Bool
+dagReachable g from to = go S.empty [from]
+  where
+    go _    []     = False
+    go seen (x:xs)
+      | x == to               = True
+      | x `S.member` seen     = go seen xs
+      | otherwise             =
+          let !seen' = S.insert x seen
+              kids   = dagChildren g x
+          in go seen' (kids ++ xs)
+
+-- | 循環を含まないか。 全ノード対 (i, j) について 「j から i へ到達可能か
+--   つ i → j のエッジが存在する」 ならば循環。
+isAcyclic :: DAG -> Bool
+isAcyclic g =
+  let !p = dagN g
+      cyclePair i j =
+            i /= j
+        &&  LA.atIndex (dagW g) (j, i) /= 0
+        &&  dagReachable g j i
+  in not $ or [ cyclePair i j | i <- [0 .. p - 1], j <- [0 .. p - 1] ]
+
+-- | topological sort: 根 (parents なし) から葉までの並び。
+--   循環を検出した場合は 'Nothing'。 Kahn のアルゴリズム (Pure 版)。
+topoSort :: DAG -> Maybe [Int]
+topoSort g =
+  let !p     = dagN g
+      inDeg0 = V.fromList [ length (dagParents g i) | i <- [0 .. p - 1] ]
+      go acc inDeg remaining
+        | null remaining = Just (reverse acc)
+        | otherwise =
+            case findRoot remaining inDeg of
+              Nothing -> Nothing   -- 循環
+              Just r  ->
+                let kids   = dagChildren g r
+                    inDegN = V.imap
+                      (\idx v -> if idx `elem` kids then v - 1 else v)
+                      inDeg
+                in go (r : acc) inDegN (filter (/= r) remaining)
+  in go [] inDeg0 [0 .. p - 1]
+  where
+    findRoot xs inDeg =
+      case filter (\i -> (inDeg V.! i) == 0) xs of
+        []    -> Nothing
+        (h:_) -> Just h
+
+-- ===========================================================================
+-- 出力
+-- ===========================================================================
+
+-- | Graphviz DOT 形式で出力。 シェル経由で
+--   @echo "..." | dot -Tpng -o dag.png@ で可視化可能。
+toDOT :: DAG -> Text
+toDOT g =
+  let header = T.pack "digraph G {\n  rankdir=LR;\n"
+      footer = T.pack "}\n"
+      nodes  = T.concat
+        [ T.pack "  " <> sanitize (dagNodeName g i)
+          <> T.pack " [label=\"" <> dagNodeName g i <> T.pack "\"];\n"
+        | i <- [0 .. dagN g - 1] ]
+      edges  = T.concat
+        [ T.pack "  " <> sanitize (dagNodeName g (edgeFrom e))
+          <> T.pack " -> " <> sanitize (dagNodeName g (edgeTo e))
+          <> T.pack " [label=\""
+          <> T.pack (showWeight (edgeWeight e))
+          <> T.pack "\"];\n"
+        | e <- dagEdges g ]
+  in header <> nodes <> edges <> footer
+  where
+    sanitize = T.replace (T.pack " ") (T.pack "_")
+             . T.replace (T.pack "-") (T.pack "_")
+    showWeight w = let r = round (w * 1000) :: Int
+                   in show (fromIntegral r / 1000 :: Double)
diff --git a/src/Hanalyze/Model/DecisionTree.hs b/src/Hanalyze/Model/DecisionTree.hs
--- a/src/Hanalyze/Model/DecisionTree.hs
+++ b/src/Hanalyze/Model/DecisionTree.hs
@@ -1,7 +1,13 @@
 {-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE BangPatterns #-}
--- | Decision tree classifier (CART, classification).
+-- |
+-- Module      : Hanalyze.Model.DecisionTree
+-- Description : 決定木分類器 (CART, classification)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
 --
+-- Decision tree classifier (CART, classification).
+--
 -- Pairs with the existing regression-oriented 'Hanalyze.Model.RandomForest';
 -- this module focuses on classification. Splits use Gini impurity as
 -- the criterion (matches sklearn default).
@@ -9,7 +15,7 @@
 -- @
 -- import Hanalyze.Model.DecisionTree
 --
--- let cfg  = defaultDTConfig
+-- let cfg  = defaultDecisionTree
 --     tree = fitDT cfg xs ys           -- xs :: [[Double]], ys :: [Int]
 --     yhat = map (predictDT tree) xs
 -- @
@@ -24,13 +30,17 @@
 module Hanalyze.Model.DecisionTree
   ( -- * Tree types
     DTree (..)
+  , DTFit (..)
   , DTConfig (..)
-  , defaultDTConfig
+  , defaultDecisionTree
     -- * Fit / predict
   , fitDT
   , fitDTV
   , predictDT
   , predictDTProbs
+    -- * Text export (R @print.rpart@ 相当)
+  , printRpart
+  , printRpartRaw
     -- * Helpers
   , giniImpurity
   ) where
@@ -43,25 +53,48 @@
 import qualified Numeric.LinearAlgebra       as LA
 import           Control.Monad.ST            (runST)
 import           Data.List                   (foldl')
+import           Data.Text                   (Text)
+import qualified Data.Text                   as T
+import           Numeric                     (showFFloat)
 
 -- ---------------------------------------------------------------------------
 -- Types
 -- ---------------------------------------------------------------------------
 
 -- | Classification decision tree node.
+-- | 決定木。 Phase 75.23 で各ノードに **サンプル数 n / gini 不純度 / クラス分布 /
+-- 多数決クラス** を保持するよう拡張 (rpart.plot / sklearn plot_tree 水準の樹形図・
+-- ルールテキスト出力のため)。 予測 (predict) の数値は不変。
 data DTree
   = DLeaf
-      { dlClassProbs :: !(Map.Map Int Double)
-      , dlMajority   :: !Int
+      { dlClassProbs :: !(Map.Map Int Double)  -- ^ クラス割合。
+      , dlMajority   :: !Int                   -- ^ 多数決クラス (予測)。
+      , dlN          :: !Int                   -- ^ このノードのサンプル数。
+      , dlImpurity   :: !Double                -- ^ gini 不純度。
       }
   | DNode
       { dnFeature :: !Int
       , dnThr     :: !Double
       , dnLeft    :: !DTree
       , dnRight   :: !DTree
+      , dnN        :: !Int                     -- ^ このノードのサンプル数。
+      , dnImpurity :: !Double                  -- ^ 分割前の gini 不純度。
+      , dnProbs    :: !(Map.Map Int Double)    -- ^ 分割前のクラス割合。
+      , dnMajority :: !Int                     -- ^ 分割前の多数決クラス。
       }
   deriving (Show)
 
+-- | 学習済み決定木 + 表示メタ (特徴量名・クラス名)。 高レベル @df |-> decisionTree@
+--   ('Hanalyze.Fit') が fit 時に手元の実列名とクラス列の levels を載せて返す
+--   ('RandomForestClassifier.RFClassifierFit' と同型のラッパ)。 これにより 'treePlot' /
+--   'printRpart' は名前を手渡しせず @DTFit@ 一つで済む。 クラス番号 (0..K-1) は
+--   @dtClassNames !! k@ で名前が引ける。
+data DTFit = DTFit
+  { dtTree         :: !DTree    -- ^ 学習済み木。
+  , dtFeatureNames :: ![Text]   -- ^ 特徴量名 (fit に使った列順)。
+  , dtClassNames   :: ![Text]   -- ^ クラス名 (label 0..K-1 に対応する levels)。
+  } deriving (Show)
+
 -- | Decision tree configuration.
 data DTConfig = DTConfig
   { dtMaxDepth        :: !(Maybe Int)
@@ -72,8 +105,8 @@
 
 -- | Defaults (sklearn-compatible): unlimited depth, min split 2,
 -- min leaf 1, min impurity 0.
-defaultDTConfig :: DTConfig
-defaultDTConfig = DTConfig
+defaultDecisionTree :: DTConfig
+defaultDecisionTree = DTConfig
   { dtMaxDepth        = Nothing
   , dtMinSamplesSplit = 2
   , dtMinSamplesLeaf  = 1
@@ -96,7 +129,7 @@
 -- | Backwards-compatible list-based fit.
 fitDT :: DTConfig -> [[Double]] -> [Int] -> DTree
 fitDT cfg xs ys
-  | null xs   = DLeaf Map.empty 0
+  | null xs   = DLeaf Map.empty 0 0 0
   | otherwise = fitDTV cfg (LA.fromLists xs) (VU.fromList ys)
 
 -- ---------------------------------------------------------------------------
@@ -114,17 +147,16 @@
   let !nIdx     = VU.length idx
       !sublabs  = VU.map (y VU.!) idx
       !probs    = classProbsV sublabs
-      !majority = case sortByValDescV probs of
-                    ((c, _) : _) -> c
-                    []           -> 0
-      leaf      = DLeaf probs majority
+      !gini     = giniFromCounts probs
+      !majority = argMaxClass probs
+      leaf      = DLeaf probs majority nIdx gini
 
       depthLimit = case dtMaxDepth cfg of
                      Just d  -> depth >= d
                      Nothing -> False
       stop = depthLimit
           || nIdx < dtMinSamplesSplit cfg
-          || giniFromCounts probs < dtMinImpurity cfg
+          || gini < dtMinImpurity cfg
           || allSameV sublabs
   in if stop
        then leaf
@@ -140,6 +172,10 @@
                        , dnThr     = thr
                        , dnLeft    = buildNodeV cfg x y lIdx (depth + 1)
                        , dnRight   = buildNodeV cfg x y rIdx (depth + 1)
+                       , dnN        = nIdx
+                       , dnImpurity = gini
+                       , dnProbs    = probs
+                       , dnMajority = majority
                        }
 
 -- | Partition row indices by a feature threshold.
@@ -185,14 +221,22 @@
                       Map.empty ys
   in 1 - foldl' (\acc c -> acc + (c / n) ^ (2 :: Int)) 0 (Map.elems counts)
 
-sortByValDescV :: Map.Map Int Double -> [(Int, Double)]
-sortByValDescV =
-  -- Map.toList is ascending key; we want descending by value.
-  reverse . sortByVal . Map.toList
+-- | 多数決 (予測) クラス = 確率最大のクラス。 同点は **最小クラス index** を選ぶ
+--   (rpart / sklearn 慣例)。 'Map.toList' は昇順 key なので、 @foldl'@ で「厳密に
+--   大きい確率でだけ更新」すれば先勝ち = 最小 index の同点タイブレークになる。
+--
+--   ⚠ 旧 @sortByValDescV@ は名前に反して昇順を返し (@reverse . 降順ソート@)、
+--   @head@ が **最小確率クラス (argmin)** を拾っていた。 深さ無制限で葉が純粋な間は
+--   露見しないが、 depth/min_samples で止まった混在葉で予測が少数派に化ける実バグ
+--   だった (Phase 75.26 で樹形図を目視して発覚・修正)。
+argMaxClass :: Map.Map Int Double -> Int
+argMaxClass m = case Map.toList m of
+  []       -> 0
+  (x : xs) -> fst (foldl' better x xs)
   where
-    sortByVal = foldr ins []
-    ins p []     = [p]
-    ins p (q:qs) = if snd p > snd q then p : q : qs else q : ins p qs
+    better acc@(_, av) cur@(_, cv)
+      | cv > av   = cur   -- 厳密に大きい確率のときだけ更新。
+      | otherwise = acc   -- 同点は据置き = 昇順 key で先に来た小さい index が勝つ。
 
 -- ---------------------------------------------------------------------------
 -- Best split: per-feature O(n log n) sweep with running counts
@@ -324,17 +368,116 @@
 
 -- | Predict the majority class label for one sample.
 predictDT :: DTree -> [Double] -> Int
-predictDT (DLeaf _ m) _ = m
-predictDT (DNode i thr l r) x
+predictDT DLeaf{dlMajority = m} _ = m
+predictDT DNode{dnFeature = i, dnThr = thr, dnLeft = l, dnRight = r} x
   | x !! i <= thr = predictDT l x
   | otherwise     = predictDT r x
 
 -- | Predict class probabilities for one sample.
 predictDTProbs :: DTree -> [Double] -> Map.Map Int Double
-predictDTProbs (DLeaf p _) _ = p
-predictDTProbs (DNode i thr l r) x
+predictDTProbs DLeaf{dlClassProbs = p} _ = p
+predictDTProbs DNode{dnFeature = i, dnThr = thr, dnLeft = l, dnRight = r} x
   | x !! i <= thr = predictDTProbs l x
   | otherwise     = predictDTProbs r x
+
+-- ---------------------------------------------------------------------------
+-- Text export (R print.rpart 相当)
+-- ---------------------------------------------------------------------------
+
+-- | 決定木のルールを R @print.rpart@ 形式のテキストで出力する。
+--
+-- R の rpart オブジェクトを @print@ したときと同じ体裁:
+--
+-- @
+-- n= &lt;total&gt;
+--
+-- node), split, n, loss, yval, (yprob)
+--       * denotes terminal node
+--
+-- 1) root 12 8 setosa (0.3333 0.3333 0.3333)
+--   2) petal_width< 0.80 4 0 setosa (1.0000 0.0000 0.0000) *
+--   3) petal_width>=0.80 8 4 versicolor (0.0000 0.5000 0.5000)
+--     6) petal_width< 1.65 4 0 versicolor (0.0000 1.0000 0.0000) *
+--     7) petal_width>=1.65 4 0 virginica (0.0000 0.0000 1.0000) *
+-- @
+--
+-- 各行 = @&lt;node#&gt;) &lt;split&gt; &lt;n&gt; &lt;loss&gt; &lt;yval&gt; (&lt;yprob…&gt;) [*]@。
+-- ノード番号は R 同様 root=1・子は @2k@/@2k+1@。 @loss@ = 誤分類数
+-- (n − 多数決クラス件数)、 @yval@ = 予測クラス、 @yprob@ = 木に現れる全クラスの
+-- 確率 (クラス index 昇順)、 @*@ = 終端 (葉)。 分岐は R の固定幅表記に忠実に
+-- 左 = @name&lt; thr@ (≤・条件成立)、 右 = @name&gt;=thr@ とする (dtreeToDag と同じ
+-- 左 ≤ / 右 > 慣例)。
+--
+-- 第 1 引数 = 特徴量名、 第 2 引数 = クラス名 (yval に使う factor 水準)。
+-- いずれも index に対し長さ不足・空文字なら @f{i}@ / 生の整数へフォールバックする
+-- (行列 fit で名無しの木でも動く)。 75.23 で各ノードに載せた n / gini / クラス分布
+-- から純粋計算し、 予測 (predict) の数値には非依存。
+-- | 高レベル版 — 'DTFit' からノード規則テキストを出す ('df |-> decisionTree' の返り値
+--   をそのまま渡せる)。 名前を手渡ししたい低レベルは 'printRpartRaw'。
+printRpart :: DTFit -> Text
+printRpart (DTFit tree feats classes) = printRpartRaw feats classes tree
+
+-- | 行列 fit 用の低レベル版 — 特徴量名・クラス名を明示的に渡す。
+printRpartRaw :: [Text] -> [Text] -> DTree -> Text
+printRpartRaw featNames classNames tree =
+  T.intercalate "\n" (header ++ go 1 0 "root" tree)
+  where
+    classes = Map.keys (labelSet tree)          -- 木に現れる全クラス (昇順)。
+    header =
+      [ "n= " <> tShow (nodeN tree)
+      , ""
+      , "node), split, n, loss, yval, (yprob)"
+      , "      * denotes terminal node"
+      , "" ]
+
+    go :: Int -> Int -> Text -> DTree -> [Text]
+    go num d split node =
+      let n     = nodeN node
+          probs = nodeProbs node
+          maj   = nodeMajority node
+          loss  = n - round (Map.findWithDefault 0 maj probs * fromIntegral n) :: Int
+          yprob = "(" <> T.intercalate " "
+                    [ fmt4 (Map.findWithDefault 0 c probs) | c <- classes ] <> ")"
+          term  = case node of DLeaf{} -> " *"; _ -> ""
+          line  = T.concat (replicate d "  ") <> tShow num <> ") " <> split
+                    <> " " <> tShow n <> " " <> tShow loss <> " " <> classLabel maj
+                    <> " " <> yprob <> term
+      in case node of
+           DLeaf{}                -> [line]
+           DNode f thr l r _ _ _ _ ->
+             let fn = featName f
+                 lb = fn <> "< "  <> fmt2 thr
+                 rb = fn <> ">="  <> fmt2 thr
+             in line : go (2 * num) (d + 1) lb l ++ go (2 * num + 1) (d + 1) rb r
+
+    featName i  = pick i featNames  ("f" <> tShow i)
+    classLabel i = pick i classNames (tShow i)
+    pick i xs dflt = case drop i xs of
+      (nm : _) | not (T.null nm) -> nm
+      _                          -> dflt
+
+    tShow  = T.pack . show
+    fmt2 x = T.pack (showFFloat (Just 2) x "")
+    fmt4 x = T.pack (showFFloat (Just 4) x "")
+
+-- | 木に現れる全クラス label を集めた集合 (値は () のダミー)。 'Map.keys' で昇順。
+labelSet :: DTree -> Map.Map Int ()
+labelSet (DLeaf p m _ _)          = Map.insert m () (() <$ p)
+labelSet (DNode _ _ l r _ _ p m)  =
+  Map.unions [Map.insert m () (() <$ p), labelSet l, labelSet r]
+
+-- ノードアクセサ (葉 / 分岐 共通)。
+nodeN :: DTree -> Int
+nodeN (DLeaf _ _ n _)         = n
+nodeN (DNode _ _ _ _ n _ _ _) = n
+
+nodeProbs :: DTree -> Map.Map Int Double
+nodeProbs (DLeaf p _ _ _)         = p
+nodeProbs (DNode _ _ _ _ _ _ p _) = p
+
+nodeMajority :: DTree -> Int
+nodeMajority (DLeaf _ m _ _)         = m
+nodeMajority (DNode _ _ _ _ _ _ _ m) = m
 
 -- Silence unused-import warning for V (keeps import slot for future
 -- variants without re-touching imports).
diff --git a/src/Hanalyze/Model/Discriminant.hs b/src/Hanalyze/Model/Discriminant.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Model/Discriminant.hs
@@ -0,0 +1,226 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE BangPatterns #-}
+-- |
+-- Module      : Hanalyze.Model.Discriminant
+-- Description : 判別分析 (Linear / Quadratic Discriminant Analysis)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- 判別分析 (Linear / Quadratic Discriminant Analysis)。
+--
+-- 連続説明変数で複数クラスを判別する古典的手法。
+--
+--   * 'LDA': 全クラスで共分散行列を共通 (pooled) と仮定 → 線形決定境界
+--   * 'QDA': クラスごとに共分散行列が異なる → 二次決定境界
+--
+-- 予測は class-conditional 密度 × prior の対数 (log-posterior) を比較。
+-- 数値安定化のため Cholesky 分解経由で log-determinant + Mahalanobis 距離を
+-- 計算する。 hmatrix Vector / Matrix 演算で完結 (list 化禁止)。
+module Hanalyze.Model.Discriminant
+  ( DiscriminantMethod (..)
+  , DiscriminantFit (..)
+  , fitLDA
+  , fitQDA
+  , predictDiscriminant
+  ) where
+
+import qualified Data.Vector           as V
+import qualified Numeric.LinearAlgebra as LA
+import           Data.List             (nub, sort)
+import           Data.Text             (Text)
+import qualified Data.Text             as T
+
+-- ===========================================================================
+-- 型
+-- ===========================================================================
+
+data DiscriminantMethod = LDA | QDA deriving (Show, Eq)
+
+data DiscriminantFit = DiscriminantFit
+  { dfMeans       :: !(LA.Matrix Double)
+    -- ^ K × p、 各クラスの平均ベクトル
+  , dfCovariance  :: !(LA.Matrix Double)
+    -- ^ LDA: pooled covariance (p × p)、 QDA: 空 (使わず、 dfCovariances を見る)
+  , dfCovariances :: ![LA.Matrix Double]
+    -- ^ QDA: クラス別 covariance (K matrices)、 LDA: 空
+  , dfPriors      :: !(LA.Vector Double)
+    -- ^ クラス事前確率 (length K、 sum = 1)
+  , dfClasses     :: !(LA.Vector Double)
+    -- ^ クラス label (sorted、 length K、 Int を Double で保持)
+  , dfMethod      :: !DiscriminantMethod
+  } deriving (Show)
+
+-- ===========================================================================
+-- 公開関数
+-- ===========================================================================
+
+-- | LDA fit: pooled covariance、 線形判別。
+fitLDA :: LA.Matrix Double  -- ^ X (n × p)
+       -> V.Vector Int      -- ^ y (n)、 整数クラスラベル
+       -> Either Text DiscriminantFit
+fitLDA x y
+  | LA.rows x /= V.length y =
+      Left "fitLDA: X rows and y length mismatch"
+  | LA.rows x < 2 =
+      Left "fitLDA: need at least 2 observations"
+  | length classIds < 2 =
+      Left "fitLDA: need at least 2 distinct classes"
+  | otherwise =
+      let (means, sigmaP, priors) = pooledStats x y classIds
+      in Right DiscriminantFit
+           { dfMeans       = means
+           , dfCovariance  = sigmaP
+           , dfCovariances = []
+           , dfPriors      = priors
+           , dfClasses     = LA.fromList (map fromIntegral classIds)
+           , dfMethod      = LDA
+           }
+  where
+    classIds = sort (nub (V.toList y))
+
+-- | QDA fit: クラス別 covariance。
+fitQDA :: LA.Matrix Double -> V.Vector Int -> Either Text DiscriminantFit
+fitQDA x y
+  | LA.rows x /= V.length y =
+      Left "fitQDA: X rows and y length mismatch"
+  | LA.rows x < 2 =
+      Left "fitQDA: need at least 2 observations"
+  | length classIds < 2 =
+      Left "fitQDA: need at least 2 distinct classes"
+  | minimum classCounts < LA.cols x + 1 =
+      Left (T.pack ("fitQDA: each class needs ≥ p+1 = "
+                    <> show (LA.cols x + 1) <> " observations (got min "
+                    <> show (minimum classCounts) <> ")"))
+  | otherwise =
+      let (means, covs, priors) = perClassStats x y classIds
+      in Right DiscriminantFit
+           { dfMeans       = means
+           , dfCovariance  = LA.fromLists [[]]
+           , dfCovariances = covs
+           , dfPriors      = priors
+           , dfClasses     = LA.fromList (map fromIntegral classIds)
+           , dfMethod      = QDA
+           }
+  where
+    classIds = sort (nub (V.toList y))
+    classCounts = [length [i | i <- [0 .. V.length y - 1], y V.! i == c]
+                  | c <- classIds]
+
+-- | 予測。 返り値 = (予測ラベル長 m, posterior 行列 m × K)。
+predictDiscriminant
+  :: DiscriminantFit
+  -> LA.Matrix Double      -- ^ X_new (m × p)
+  -> (V.Vector Int, LA.Matrix Double)
+predictDiscriminant fit xNew =
+  let m = LA.rows xNew
+      k = LA.size (dfPriors fit)
+      classLabels = LA.toList (dfClasses fit)
+      -- 各サンプル × 各クラスの log-posterior を計算
+      logPostMat = LA.fromLists
+        [ [ logPosterior fit (LA.flatten (xNew LA.? [i])) j
+          | j <- [0 .. k - 1] ]
+        | i <- [0 .. m - 1] ]
+      -- 各行で argmax → ラベル予測
+      predLabels = V.fromList
+        [ let row = LA.toList (logPostMat LA.! i)
+              maxIdx = snd (maximum (zip row [0 ..]))
+          in round (classLabels !! maxIdx :: Double) :: Int
+        | i <- [0 .. m - 1] ]
+      -- posterior = exp(log-post) / Σ exp(log-post) (各行で normalize)
+      posteriorMat = LA.fromLists
+        [ let row = LA.toList (logPostMat LA.! i)
+              maxLP = maximum row
+              expRow = map (\x -> exp (x - maxLP)) row
+              s = sum expRow
+          in if s > 0 then map (/ s) expRow else expRow
+        | i <- [0 .. m - 1] ]
+  in (predLabels, posteriorMat)
+
+-- ===========================================================================
+-- 内部 helper
+-- ===========================================================================
+
+-- | log p(class=j) + log f(x | class=j)
+--   - LDA: − 0.5 (x − μ_j)ᵀ Σ_p⁻¹ (x − μ_j) + log π_j  (定数項を省略)
+--   - QDA: − 0.5 log |Σ_j| − 0.5 (x − μ_j)ᵀ Σ_j⁻¹ (x − μ_j) + log π_j
+logPosterior :: DiscriminantFit -> LA.Vector Double -> Int -> Double
+logPosterior fit x j =
+  let mu_j = LA.flatten (dfMeans fit LA.? [j])
+      diff = x - mu_j
+      logPi = log (LA.atIndex (dfPriors fit) j)
+  in case dfMethod fit of
+       LDA ->
+         let sigInvDiff = case LA.linearSolve (dfCovariance fit)
+                                              (LA.asColumn diff) of
+               Just m  -> LA.flatten m
+               Nothing -> diff  -- singular fallback
+             mahal = LA.sumElements (diff * sigInvDiff)
+         in -0.5 * mahal + logPi
+       QDA ->
+         let sigma_j = dfCovariances fit !! j
+             logDet = log (max 1e-300 (LA.det sigma_j))
+             sigInvDiff = case LA.linearSolve sigma_j (LA.asColumn diff) of
+               Just m  -> LA.flatten m
+               Nothing -> diff
+             mahal = LA.sumElements (diff * sigInvDiff)
+         in -0.5 * logDet - 0.5 * mahal + logPi
+
+-- | 各クラスの平均と pooled covariance + prior を計算。
+pooledStats
+  :: LA.Matrix Double -> V.Vector Int -> [Int]
+  -> (LA.Matrix Double, LA.Matrix Double, LA.Vector Double)
+pooledStats x y classIds =
+  let n  = LA.rows x
+      p  = LA.cols x
+      nD = fromIntegral n :: Double
+      classRows c = [i | i <- [0 .. n - 1], y V.! i == c]
+      classN c = fromIntegral (length (classRows c)) :: Double
+      means = LA.fromRows
+        [ let rs = classRows c
+              xc = x LA.? rs
+              n_c = fromIntegral (length rs) :: Double
+              colSum j = LA.sumElements (xc LA.¿ [j])
+          in LA.fromList [ colSum j / n_c | j <- [0 .. p - 1] ]
+        | c <- classIds ]
+      -- pooled covariance: Σ_p = Σ_c (n_c - 1) S_c / (n - K)
+      sigmaP =
+        let k = length classIds
+            sumS = foldr (+) (LA.konst 0 (p, p))
+              [ let rs = classRows c
+                    xc = x LA.? rs
+                    mu = LA.flatten (means LA.? [idx])
+                    centered = xc - LA.fromRows (replicate (length rs) mu)
+                in LA.tr centered LA.<> centered  -- (n_c - 1) S_c
+              | (idx, c) <- zip [0 ..] classIds ]
+        in LA.scale (1 / fromIntegral (n - k)) sumS
+      priors = LA.fromList [ classN c / nD | c <- classIds ]
+  in (means, sigmaP, priors)
+
+-- | クラス別 mean + cov + prior。
+perClassStats
+  :: LA.Matrix Double -> V.Vector Int -> [Int]
+  -> (LA.Matrix Double, [LA.Matrix Double], LA.Vector Double)
+perClassStats x y classIds =
+  let n  = LA.rows x
+      p  = LA.cols x
+      nD = fromIntegral n :: Double
+      classRows c = [i | i <- [0 .. n - 1], y V.! i == c]
+      means = LA.fromRows
+        [ let rs = classRows c
+              xc = x LA.? rs
+              n_c = fromIntegral (length rs) :: Double
+              colSum j = LA.sumElements (xc LA.¿ [j])
+          in LA.fromList [ colSum j / n_c | j <- [0 .. p - 1] ]
+        | c <- classIds ]
+      covs =
+        [ let rs = classRows c
+              xc = x LA.? rs
+              n_c = fromIntegral (length rs) :: Double
+              mu = LA.flatten (means LA.? [idx])
+              centered = xc - LA.fromRows (replicate (length rs) mu)
+          in LA.scale (1 / (n_c - 1)) (LA.tr centered LA.<> centered)
+        | (idx, c) <- zip [0 ..] classIds ]
+      priors = LA.fromList
+        [ fromIntegral (length (classRows c)) / nD | c <- classIds ]
+      _ = p  -- silence
+  in (means, covs, priors)
diff --git a/src/Hanalyze/Model/FDA.hs b/src/Hanalyze/Model/FDA.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Model/FDA.hs
@@ -0,0 +1,240 @@
+-- |
+-- Module      : Hanalyze.Model.FDA
+-- Description : 関数データ解析 (Functional Data Analysis, FDA)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Functional Data Analysis (FDA) (Phase 33)。
+--
+-- センサ / プロセス時系列を **1 観測 = 1 関数**として扱う Ramsay-Silverman
+-- FDA の基礎機能。 個別の生時系列ではなく、 関数空間上の主成分 / 回帰を
+-- 直接扱う。
+--
+-- ## 構成
+--
+-- - 'smoothBasis': 各サンプルを B-spline basis + 二階差分 (P-spline) penalty
+--   で smooth fit → 'FunctionalSample' (basis 係数表現)
+-- - 'functionalPCA': basis 係数行列の covariance に PCA、 関数主成分
+-- - 'fLM': functional linear regression @y_i = α + ∫ x_i(t) β(t) dt + ε@
+--
+-- 既存 'Hanalyze.Model.Spline' の `bsplineBasis` を basis 生成として再利用。
+-- Fourier basis は将来拡張 (Phase 33 範囲外)。
+--
+-- Reference: Ramsay & Silverman (2005) "Functional Data Analysis" 2nd ed.
+-- Eilers-Marx (1996) "Flexible smoothing with B-splines and penalties" —
+-- P-spline 二階差分 penalty。
+module Hanalyze.Model.FDA
+  ( Basis (..)
+  , FunctionalSample (..)
+  , smoothBasis
+  , evalFunctional
+    -- * FPCA
+  , FunctionalPCA (..)
+  , functionalPCA
+    -- * Functional Linear Regression
+  , FLMResult (..)
+  , fLM
+  ) where
+
+import qualified Numeric.LinearAlgebra        as LA
+import qualified Data.Vector                  as V
+import qualified Hanalyze.Model.Spline        as Sp
+
+-- ---------------------------------------------------------------------------
+-- 基底
+-- ---------------------------------------------------------------------------
+
+-- | basis 種別。 現在は B-spline のみ実装、 Fourier は将来拡張。
+data Basis
+  = BSpline !Int ![Double]   -- ^ (degree, interior knots、 境界含む)
+  deriving (Show)
+
+-- | smooth した関数表現 (basis 係数 + 元 grid)。
+data FunctionalSample = FunctionalSample
+  { fsCoef  :: !(LA.Vector Double)   -- ^ basis 係数
+  , fsBasis :: !Basis
+  , fsGrid  :: !(LA.Vector Double)   -- ^ 元の時間 grid (eval 用)
+  } deriving (Show)
+
+-- ---------------------------------------------------------------------------
+-- 33-A1: smoothBasis (P-spline)
+-- ---------------------------------------------------------------------------
+
+-- | 複数サンプルを basis + roughness penalty で smooth fit。
+--
+-- 解: @c = (BᵀB + λ DᵀD)⁻¹ Bᵀy@ (= P-spline、 D は二階差分作用素)。
+-- @λ → 0@ で interpolate、 @λ → ∞@ で over-smooth (≈ 一次関数)。
+--
+-- 入力 @y@ は @n_samples × n_grid@、 各行が 1 サンプル。
+smoothBasis
+  :: Basis              -- ^ basis (B-spline)
+  -> Double             -- ^ roughness penalty @λ@
+  -> LA.Vector Double   -- ^ 時間 grid @t@ (長さ @n_grid@)
+  -> LA.Matrix Double   -- ^ 観測 @y@ (@n_samples × n_grid@)
+  -> [FunctionalSample]
+smoothBasis basis@(BSpline deg intKnots) lambda tGrid yMat =
+  let tV  = V.fromList (LA.toList tGrid)
+      bMat = Sp.bsplineBasis deg intKnots tV   -- n_grid × d
+      d   = LA.cols bMat
+      btb = LA.tr bMat LA.<> bMat
+      penalty = diff2Penalty d
+      reg = btb + LA.scale lambda penalty
+      -- 各行 (= 1 サンプル) について解く: c = (BᵀB+λΩ)⁻¹ Bᵀy_i
+      btY = LA.tr bMat LA.<> LA.tr yMat        -- d × n_samples
+      cMat = reg LA.<\> btY                     -- d × n_samples
+      n = LA.rows yMat
+  in [ FunctionalSample
+         { fsCoef  = LA.flatten (cMat LA.¿ [i])
+         , fsBasis = basis
+         , fsGrid  = tGrid
+         }
+     | i <- [0 .. n - 1] ]
+
+-- | smooth した関数を任意 grid で評価。
+evalFunctional :: FunctionalSample -> LA.Vector Double -> LA.Vector Double
+evalFunctional fs tNew =
+  case fsBasis fs of
+    BSpline deg intKnots ->
+      let tV = V.fromList (LA.toList tNew)
+          bM = Sp.bsplineBasis deg intKnots tV
+      in bM LA.#> fsCoef fs
+
+-- | 二階差分 penalty 行列 @DᵀD@ (= 連続二階微分の量を有限差分で近似)。
+-- @D@ は @(d-2) × d@、 @D_{i,j} = 1 if j=i、 -2 if j=i+1、 1 if j=i+2@。
+diff2Penalty :: Int -> LA.Matrix Double
+diff2Penalty d
+  | d <= 2    = LA.konst 0 (d, d)
+  | otherwise =
+      let dM = LA.fromLists
+            [ [ if j == i then 1
+                else if j == i + 1 then -2
+                else if j == i + 2 then 1
+                else 0
+              | j <- [0 .. d - 1] ]
+            | i <- [0 .. d - 3] ]
+      in LA.tr dM LA.<> dM
+
+-- ---------------------------------------------------------------------------
+-- 33-A2: Functional PCA
+-- ---------------------------------------------------------------------------
+
+data FunctionalPCA = FunctionalPCA
+  { fpcaScores      :: !(LA.Matrix Double)   -- ^ n × K (各サンプルの主成分得点)
+  , fpcaEigenfn     :: !(LA.Matrix Double)   -- ^ K × n_grid (主成分関数を grid 上で評価)
+  , fpcaEigenvalues :: !(LA.Vector Double)   -- ^ length K (降順)
+  , fpcaMeanFn      :: !(LA.Vector Double)   -- ^ length n_grid (平均関数)
+  } deriving (Show)
+
+-- | basis 係数行列の covariance に PCA。 簡略実装として basis 係数空間で
+-- PCA を行い、 主成分関数を grid 上で評価して返す (= basis が直交近似で
+-- ある前提)。 厳密版は basis mass matrix @J = ∫ B B^T@ で重み付き SVD が
+-- 必要だが、 B-spline + dense grid なら直交近似で十分実用に耐える。
+functionalPCA
+  :: Int                  -- ^ 主成分数 K
+  -> [FunctionalSample]
+  -> FunctionalPCA
+functionalPCA k samples =
+  let cMat = LA.fromColumns (map fsCoef samples)  -- d × n
+      n    = LA.cols cMat
+      d    = LA.rows cMat
+      mu   = LA.scale (1 / fromIntegral n)
+               (cMat LA.#> LA.konst 1 n)
+      cCentered = cMat - LA.asColumn mu  -- d × n
+      cov = LA.scale (1 / fromIntegral (max 1 (n - 1)))
+              (cCentered LA.<> LA.tr cCentered)  -- d × d
+      (eigVals, eigVecs) = LA.eigSH (LA.trustSym cov)
+      -- hmatrix eigSH は降順で返す
+      kEff = min k d
+      topVecs = eigVecs LA.¿ [0 .. kEff - 1]    -- d × K
+      topVals = LA.subVector 0 kEff eigVals
+      -- score: K × n、 各列 = 係数空間での座標
+      scoresT = LA.tr topVecs LA.<> cCentered
+      -- 主成分関数を grid 上で評価
+      sampleBasis = fsBasis (head samples)
+      tGrid = fsGrid (head samples)
+      eigFn = case sampleBasis of
+        BSpline deg intKnots ->
+          let bM = Sp.bsplineBasis deg intKnots
+                     (V.fromList (LA.toList tGrid))   -- n_grid × d
+          in LA.tr (bM LA.<> topVecs)  -- K × n_grid
+      meanFn = case sampleBasis of
+        BSpline deg intKnots ->
+          let bM = Sp.bsplineBasis deg intKnots
+                     (V.fromList (LA.toList tGrid))
+          in bM LA.#> mu
+  in FunctionalPCA
+       { fpcaScores      = LA.tr scoresT
+       , fpcaEigenfn     = eigFn
+       , fpcaEigenvalues = topVals
+       , fpcaMeanFn      = meanFn
+       }
+
+-- ---------------------------------------------------------------------------
+-- 33-A3: Functional Linear Regression
+-- ---------------------------------------------------------------------------
+
+data FLMResult = FLMResult
+  { flmAlpha  :: !Double                  -- ^ intercept
+  , flmBetaFn :: !(LA.Vector Double)      -- ^ β(t) を共通 grid 上で評価
+  , flmFitted :: !(LA.Vector Double)      -- ^ ŷ_i (length n)
+  , flmR2     :: !Double
+  } deriving (Show)
+
+-- | Functional linear regression: @y_i = α + ∫ x_i(t) β(t) dt + ε@.
+--
+-- @β(t)@ を同じ basis で展開: @β(t) = B(t)^T γ@。 すると
+-- @∫ x_i(t) β(t) dt = c_i^T J γ@ ここで @J = ∫ B(t) B(t)^T dt@ (mass matrix)。
+-- 設計行列 @[1, c_i^T J]@ で OLS + 任意の roughness penalty。
+--
+-- mass matrix @J@ は trapezoidal 積分で近似:
+-- @J ≈ Δt · B^T diag(w) B@ where @w@ は等間隔積分重み (端点 0.5、 内点 1)。
+fLM
+  :: [FunctionalSample]   -- ^ X_i(t)
+  -> LA.Vector Double     -- ^ y (n samples)
+  -> Double               -- ^ λ (β(t) の二階差分 penalty)
+  -> FLMResult
+fLM samples y lambda =
+  let sample0 = head samples
+      basis@(BSpline deg intKnots) = fsBasis sample0
+      tGrid = fsGrid sample0
+      tV    = V.fromList (LA.toList tGrid)
+      bM    = Sp.bsplineBasis deg intKnots tV
+      nGrid = LA.size tGrid
+      -- trapezoidal 重み
+      dt    = if nGrid >= 2
+                then (LA.atIndex tGrid (nGrid - 1) - LA.atIndex tGrid 0)
+                       / fromIntegral (nGrid - 1)
+                else 1
+      wVec  = LA.fromList
+                ([0.5] ++ replicate (max 0 (nGrid - 2)) 1.0 ++ [0.5])
+      wScaled = LA.scale dt wVec
+      -- mass matrix J = B^T diag(w) B (d × d)
+      jMat  = LA.tr bM LA.<> (LA.asColumn wScaled * bM)
+      -- 設計行列: 各 i 行 = [1, c_i^T J] (length 1 + d)
+      cMat  = LA.fromRows (map fsCoef samples)    -- n × d
+      ciJ   = cMat LA.<> jMat                     -- n × d
+      n     = LA.rows cMat
+      xDes  = LA.fromColumns
+                (LA.konst 1 n : LA.toColumns ciJ)  -- n × (1 + d)
+      -- penalty: intercept は 0、 γ には二階差分 penalty
+      d     = LA.cols cMat
+      pen   = diff2Penalty d
+      penFull = LA.diagBlock [LA.scalar 0, LA.scale lambda pen]
+      reg   = LA.tr xDes LA.<> xDes + penFull
+      xty   = LA.tr xDes LA.#> y
+      coefs = LA.flatten (reg LA.<\> LA.asColumn xty)
+      alpha = LA.atIndex coefs 0
+      gamma = LA.subVector 1 d coefs
+      yHat  = xDes LA.#> coefs
+      resid = y - yHat
+      yMean = LA.sumElements y / fromIntegral n
+      ssTot = LA.sumElements ((y - LA.scalar yMean) ^ (2 :: Int))
+      ssRes = LA.sumElements (resid ^ (2 :: Int))
+      r2    = if ssTot == 0 then 0 else 1 - ssRes / ssTot
+      betaFn = bM LA.#> gamma
+  in FLMResult
+       { flmAlpha  = alpha
+       , flmBetaFn = betaFn
+       , flmFitted = yHat
+       , flmR2     = r2
+       }
diff --git a/src/Hanalyze/Model/FitYByX.hs b/src/Hanalyze/Model/FitYByX.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Model/FitYByX.hs
@@ -0,0 +1,116 @@
+{-# LANGUAGE OverloadedStrings #-}
+-- |
+-- Module      : Hanalyze.Model.FitYByX
+-- Description : JMP "Fit Y by X" platform 相当の自動 dispatch wrapper
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- JMP \"Fit Y by X\" platform 相当の wrapper。
+--
+-- X / Y それぞれが連続 (Continuous) か カテゴリ (Categorical) かで
+-- 適切な解析を自動 dispatch する:
+--
+-- @
+--   X \\ Y  | Continuous           | Categorical
+--   --------+----------------------+---------------------
+--   Cont    | 単回帰 (LM)          | logistic GLM
+--   Cat     | one-way ANOVA        | chi-square independence
+-- @
+--
+-- canvas frontend で 「変数 2 つドラッグ → 自動分析」 を支える backend wrapper。
+module Hanalyze.Model.FitYByX
+  ( VarType (..)
+  , FitYByXResult (..)
+  , fitYByX
+  ) where
+
+import qualified Data.Vector             as V
+import qualified Numeric.LinearAlgebra   as LA
+import           Data.List               (nub, sort)
+import           Data.Text               (Text)
+
+import qualified Hanalyze.Model.Core     as Core
+import qualified Hanalyze.Model.LM       as LM
+import qualified Hanalyze.Model.GLM      as GLM
+import qualified Hanalyze.Stat.Test      as ST
+
+-- ===========================================================================
+-- 型
+-- ===========================================================================
+
+data VarType
+  = Continuous
+  | Categorical
+  deriving (Show, Eq)
+
+data FitYByXResult
+  = FitContCont !Core.FitResult
+    -- ^ 単回帰: y = β₀ + β₁ x
+  | FitCatCont  !ST.TestResult ![Double]
+    -- ^ one-way ANOVA + group means (group order = sort.nub of x)
+  | FitContCat  !Core.FitResult
+    -- ^ logistic GLM: P(Y=1) = sigmoid(β₀ + β₁ x)
+  | FitCatCat   !ST.TestResult
+    -- ^ chi-square independence
+  deriving (Show)
+
+-- ===========================================================================
+-- 公開 API
+-- ===========================================================================
+
+-- | X / Y の型に応じて適切な解析を dispatch する。
+--   入力は両方とも Double Vector。 Categorical の場合は整数値を Double 化
+--   して渡す前提 (例: 0, 1, 2, ...)。
+fitYByX
+  :: VarType -> VarType
+  -> LA.Vector Double      -- ^ X
+  -> LA.Vector Double      -- ^ Y
+  -> Either Text FitYByXResult
+fitYByX xt yt x y
+  | LA.size x /= LA.size y =
+      Left "fitYByX: X and Y must have the same length"
+  | LA.size x < 2 =
+      Left "fitYByX: need at least 2 observations"
+  | otherwise = case (xt, yt) of
+      (Continuous, Continuous) ->
+        let xMat = LA.fromColumns [LA.fromList (replicate (LA.size x) 1), x]
+        in Right (FitContCont (LM.fitLMVec xMat y))
+
+      (Categorical, Continuous) ->
+        let levels = sort (nub (LA.toList x))
+            groups = [ LA.fromList
+                        [ LA.atIndex y i
+                        | i <- [0 .. LA.size x - 1]
+                        , LA.atIndex x i == lvl ]
+                     | lvl <- levels ]
+            tr     = ST.anovaOneWay groups
+            means  = [ LA.sumElements g / fromIntegral (LA.size g)
+                     | g <- groups ]
+        in if any ((< 1) . LA.size) groups
+             then Left "fitYByX (cat × cont): some groups are empty"
+             else Right (FitCatCont tr means)
+
+      (Continuous, Categorical) ->
+        -- Y must be binary 0/1 for logistic
+        let ys = LA.toList y
+        in if not (all (\v -> v == 0 || v == 1) ys)
+             then Left "fitYByX (cont × cat): Y must be binary 0/1 for logistic GLM"
+             else
+               let xMat = LA.fromColumns
+                            [LA.fromList (replicate (LA.size x) 1), x]
+               in Right (FitContCat (GLM.fitGLM GLM.Binomial xMat y))
+
+      (Categorical, Categorical) ->
+        let xLevels = sort (nub (LA.toList x))
+            yLevels = sort (nub (LA.toList y))
+            cell xl yl = fromIntegral $ length
+              [ () | i <- [0 .. LA.size x - 1]
+                   , LA.atIndex x i == xl
+                   , LA.atIndex y i == yl ]
+            tbl = LA.fromLists
+              [ [ cell xl yl | yl <- yLevels ] | xl <- xLevels ]
+        in if length xLevels < 2 || length yLevels < 2
+             then Left "fitYByX (cat × cat): need at least 2 levels per axis"
+             else Right (FitCatCat (ST.chiSquareIndep tbl))
+  where
+    _ = V.length :: V.Vector Int -> Int  -- silence warn
diff --git a/src/Hanalyze/Model/Formula.hs b/src/Hanalyze/Model/Formula.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Model/Formula.hs
@@ -0,0 +1,211 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+-- |
+-- Module      : Hanalyze.Model.Formula
+-- Description : Formula DSL 正本 front-end (独自・明示係数構文) の parser と AST
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Formula DSL — 正本 front-end (独自・明示係数構文) の parser と AST。
+--
+--   このモジュールの責務は「文字列 → 構文木 (Formula AST)」 のみ。
+--   AST が真の正本で、 R/patsy front-end (A18) も同じ AST に落とす。
+--   意味論的分類 (Ref がデータ変数かパラメータか・factor 添字・基底展開) は
+--   data と突合する後段 (A16 ModelFrame / A17 designMatrixF) に委ねる。
+--   ゆえに本モジュールは plot 非依存・portable (upstream hanalyze cherry-pick 候補)。
+--
+--   構文 (例): @"y x group = b0 + b1*x + b2*log x + bg ! group"@
+--     - 左辺 @y x group@ で 応答=y / データ変数=x,group を宣言。
+--     - 右辺の自由名 (左辺に無い名前) = 推定パラメータ。
+--     - @+@ @-@ @*@ @/@ @^@ は常に本物の算術 (R formula の「項追加」 ではない)。
+--     - 添字 @bg ! group@ = 係数ベクトル × factor 水準 (@!@ は Haskell 正規の添字演算子)。
+--     - 交互作用は型で分解: 連続×連続 @b*x*z@ / factor×連続 @bg ! group * x@ /
+--       factor×factor @b ! x ! z@ (@!@ 連鎖 = 2 次元添字)。
+--     - 適用 @log x@ / @exp(-b*x)@ / @bspline(x,k)@ (空白並置・括弧引数どちらも App)。
+module Hanalyze.Model.Formula
+  ( -- * AST (真の正本)
+    Formula (..)
+  , Term (..)
+  , BinOp (..)
+    -- * Parse (正本 front-end = 独自構文)
+  , parseFormula
+    -- * Pretty (round-trip 検証用・正規形)
+  , prettyFormula
+  , prettyTerm
+  ) where
+
+import           Control.Monad.Combinators.Expr (Operator (..), makeExprParser)
+import           Data.Text                      (Text)
+import qualified Data.Text                      as T
+import           Data.Void                      (Void)
+import           Text.Megaparsec
+import           Text.Megaparsec.Char           (alphaNumChar, char, letterChar,
+                                                 space1)
+import qualified Text.Megaparsec.Char.Lexer     as L
+
+-- ============================================================================
+-- AST — parse 結果の構文木 (意味論的分類は後段)
+-- ============================================================================
+
+-- | 二項算術演算子 (すべて本物の算術)。
+data BinOp = Add | Sub | Mul | Div | Pow
+  deriving (Eq, Show)
+
+-- | 右辺の式木。 Ref がデータ変数かパラメータかは 'Formula' の LHS 宣言で決まる。
+data Term
+  = Lit Double          -- ^ 数値リテラル (非負。 負号は 'Neg' が担う)
+  | Ref Text            -- ^ 識別子参照 (x / b1 / group)
+  | App Text [Term]     -- ^ 関数適用 log x / exp(-b*x) / bspline(x,k)
+  | Index Term Term     -- ^ 添字 bg ! group (連鎖 b!x!z = Index (Index (Ref b) (Ref x)) (Ref z))
+  | Neg Term            -- ^ 単項マイナス -x
+  | Bin BinOp Term Term -- ^ 二項算術
+  deriving (Eq, Show)
+
+-- | formula 全体。 左辺で応答 + データ変数を宣言、 右辺が式。
+data Formula = Formula
+  { formResponse :: Text   -- ^ 応答変数 y
+  , formDataVars :: [Text] -- ^ データ変数宣言 (x, group, …)。 右辺の自由名でこれに無い名前 = パラメータ
+  , formRHS      :: Term   -- ^ 右辺式
+  }
+  deriving (Eq, Show)
+
+-- ============================================================================
+-- Parser (megaparsec) — 字句 / 優先順位 / formula 全体
+-- ============================================================================
+
+type Parser = Parsec Void Text
+
+-- | 空白消費 (コメントは持たない)。
+sc :: Parser ()
+sc = L.space space1 empty empty
+
+lexeme :: Parser a -> Parser a
+lexeme = L.lexeme sc
+
+symbol :: Text -> Parser Text
+symbol = L.symbol sc
+
+-- | 識別子: 英字/_ 始まり、 英数/_ 継続。
+identifier :: Parser Text
+identifier = lexeme $ do
+  c  <- letterChar <|> char '_'
+  cs <- many (alphaNumChar <|> char '_')
+  pure (T.pack (c : cs))
+
+-- | 数値リテラル (非負)。 float 優先 (0.5)、 無ければ整数 (2)。
+number :: Parser Double
+number = lexeme (try L.float <|> (fromIntegral <$> (L.decimal :: Parser Integer)))
+
+-- | 括弧でくくった部分式 (grouping)。
+parens :: Parser a -> Parser a
+parens = between (symbol "(") (symbol ")")
+
+-- | atom = 数値 | 括弧グループ | 識別子参照。
+pAtom :: Parser Term
+pAtom =
+      (Lit <$> number)
+  <|> parens pExpr
+  <|> (Ref <$> identifier)
+
+-- | 適用項。 識別子の直後に
+--     - 括弧引数 @f(a, b, …)@ が来れば多引数 App、
+--     - 空白並置 atom @log x@ が来れば単/多引数 App、
+--   どちらも無ければただの atom。
+pApp :: Parser Term
+pApp = do
+  h <- pAtom
+  case h of
+    Ref f -> do
+      mcall <- optional (parens (pExpr `sepBy1` symbol ","))
+      case mcall of
+        Just args -> pure (App f args)          -- f(a, b)
+        Nothing   -> do
+          xs <- many pAtom                       -- log x (空白並置)
+          pure (if null xs then h else App f xs)
+    _ -> pure h
+
+-- | 式 (優先順位付き)。 高→低: @!@ 添字 > @^@ > 単項@-@ > @* /@ > @+ -@。
+pExpr :: Parser Term
+pExpr = makeExprParser pApp opTable
+
+opTable :: [[Operator Parser Term]]
+opTable =
+  [ [ InfixL (Index       <$ symbol "!") ]                  -- 添字 (左結合・最高位)
+  , [ InfixR (Bin Pow     <$ symbol "^") ]                  -- べき (右結合)
+  , [ Prefix (Neg         <$ symbol "-") ]                  -- 単項マイナス (^ より下)
+  , [ InfixL (Bin Mul     <$ symbol "*")
+    , InfixL (Bin Div     <$ symbol "/") ]
+  , [ InfixL (Bin Add     <$ symbol "+")
+    , InfixL (Bin Sub     <$ symbol "-") ]
+  ]
+
+-- | formula 全体: @LHS変数列 = RHS式@。
+pFormula :: Parser Formula
+pFormula = do
+  sc
+  vars <- some identifier
+  _    <- symbol "="
+  rhs  <- pExpr
+  eof
+  case vars of
+    (y : ds) -> pure (Formula y ds rhs)
+    []       -> fail "左辺に応答変数がありません"
+
+-- | 文字列 → 'Formula'。 失敗時は人間可読なエラーメッセージ。
+parseFormula :: Text -> Either String Formula
+parseFormula t =
+  case parse pFormula "<formula>" t of
+    Left err -> Left (errorBundlePretty err)
+    Right f  -> Right f
+
+-- ============================================================================
+-- Pretty — round-trip の正規形 (App は常に括弧形式で曖昧性ゼロ)
+-- ============================================================================
+
+-- | 'Formula' を正規形文字列に。 @parseFormula (prettyFormula f) == Right f@ を満たす。
+prettyFormula :: Formula -> Text
+prettyFormula (Formula y ds rhs) =
+  T.unwords (y : ds) <> " = " <> prettyTerm rhs
+
+-- | 右辺式を正規形に (優先順位に応じ最小限の括弧)。
+prettyTerm :: Term -> Text
+prettyTerm = go 0
+  where
+    -- prec: 親文脈の結合度。 子の演算子優先度が親より緩ければ括弧。
+    go :: Int -> Term -> Text
+    go _ (Lit d)     = prettyNum d
+    go _ (Ref x)     = x
+    go _ (App f as)  = f <> "(" <> T.intercalate ", " (map (go 0) as) <> ")"
+    go p (Index a b) = paren (p > 6) (go 6 a <> " ! " <> go 7 b)
+    -- operand は prec 5 で描く: 連続前置 (Neg (Neg …) = "-(-…)") も括弧化され parse 可能に。
+    go p (Neg a)     = paren (p > 4) ("-" <> go 5 a)
+    go p (Bin op a b) =
+      let pr = binPrec op
+          (lp, rp) = case op of
+            Pow -> (pr + 1, pr)        -- 右結合
+            _   -> (pr, pr + 1)        -- 左結合
+      in paren (p > pr) (go lp a <> " " <> binSym op <> " " <> go rp b)
+
+    paren True  s = "(" <> s <> ")"
+    paren False s = s
+
+binPrec :: BinOp -> Int
+binPrec Add = 1
+binPrec Sub = 1
+binPrec Mul = 2
+binPrec Div = 2
+binPrec Pow = 5
+
+binSym :: BinOp -> Text
+binSym Add = "+"
+binSym Sub = "-"
+binSym Mul = "*"
+binSym Div = "/"
+binSym Pow = "^"
+
+-- | 整数値は小数点無しで (round-trip 安定)。
+prettyNum :: Double -> Text
+prettyNum d
+  | d == fromIntegral n = T.pack (show n)
+  | otherwise           = T.pack (show d)
+  where n = round d :: Integer
diff --git a/src/Hanalyze/Model/Formula/Design.hs b/src/Hanalyze/Model/Formula/Design.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Model/Formula/Design.hs
@@ -0,0 +1,428 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+-- |
+-- Module      : Hanalyze.Model.Formula.Design
+-- Description : Formula DSL の設計行列組み立て (designMatrixF) + 線形性/識別性検出
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Formula DSL — designMatrixF + 線形性検出 + 識別性 (A17)。
+--   'ModelFrame' から OLS 用の設計行列を組み立て、 線形モデルなら 'fitLMF' で fit する。
+--
+--   ★中核の考え方:
+--     - 右辺を加法項に分解し、 各項を乗法葉 (param / factor 添字 / data 式) に分類。
+--     - **線形 OLS では parameter 名自体は fit に効かない** (各設計列に 1 係数が付くだけ)。
+--       param 名が効くのは ① 報告 ② 非線形検出。 → param が data 式の内側に現れたら
+--       「非線形 (OLS 不可)」 として Left を返す = 線形性検出を兼ねる。
+--     - factor は **使われ方 (! 添字)** で展開 ('ModelFrame' が既に判定済)。 識別性は
+--       treatment contrast: 切片があれば参照水準 (=第1水準, 昇順先頭) を drop して満ランク化。
+--     - 交互作用は専用演算子を持たず、 連続×連続=積・factor×連続=水準別列・factor×factor=
+--       添字連鎖の grid 展開、 として加法項ごとに独立に列生成。
+--
+--   ★検証原理 (parameterization 不変): ŷ と R² は contrast の取り方に依らない。
+--     飽和 factor×factor の ŷ = セル平均、 という Python 非依存オラクルで正しさを確認できる。
+--
+--   spline/poly 基底展開 (@bs ! bspline(x,k)@) は本 sub では未対応 (明示エラー)。 後続で配線。
+module Hanalyze.Model.Formula.Design
+  ( designMatrixF
+  , fitLMF
+  , responseVec
+  , linearityCheck
+    -- * Contrast coding (A2)
+  , ContrastCoding (..)
+  , contrastMatrix
+  , parseContrast
+    -- * weights / offset = WLS (A3)
+  , WLSConfig (..)
+  , defaultWLS
+  , fitWLSF
+  ) where
+
+import           Data.Maybe              (catMaybes, isNothing)
+import           Data.Text               (Text)
+import qualified Data.Text               as T
+import qualified Data.Vector             as V
+import qualified Numeric.LinearAlgebra   as LA
+
+import           Hanalyze.DataIO.Convert    (getDoubleVec)
+import           Hanalyze.DataIO.Preprocess (dropMissingRows)
+import           Hanalyze.Model.Core     (FitResult)
+import           Hanalyze.Model.LM       (fitLM)
+import           Hanalyze.Model.Spline   (bsplineBasis, quantileKnots)
+import           Hanalyze.Model.Formula  (BinOp (..), Formula (..), Term (..),
+                                          prettyTerm)
+import           Hanalyze.Model.Formula.Frame
+import qualified DataFrame.Internal.DataFrame  as DX
+
+-- ============================================================================
+-- 加法 / 乗法への分解
+-- ============================================================================
+
+-- | 加法項に分解。 符号 (Sub/Neg) は係数に吸収され ŷ に効かないので Add 扱い。
+flattenAdd :: Term -> [Term]
+flattenAdd (Bin Add a b) = flattenAdd a ++ flattenAdd b
+flattenAdd (Bin Sub a b) = flattenAdd a ++ flattenAdd b
+flattenAdd (Neg a)       = flattenAdd a
+flattenAdd t             = [t]
+
+-- | 乗法葉に分解。
+mulLeaves :: Term -> [Term]
+mulLeaves (Bin Mul a b) = mulLeaves a ++ mulLeaves b
+mulLeaves (Neg a)       = mulLeaves a
+mulLeaves t             = [t]
+
+-- | Index spine: 入れ子添字を (base項, [添字項]) に。 base が Ref でなければ Nothing。
+indexSpine :: Term -> Maybe (Term, [Term])
+indexSpine (Index a b) = do (base, ixs) <- indexSpine a; pure (base, ixs ++ [b])
+indexSpine t           = Just (t, [])
+
+-- ============================================================================
+-- 乗法葉の分類
+-- ============================================================================
+
+data Leaf
+  = LParam Text                       -- ^ パラメータ単独 (係数。 OLS 列は持たない)
+  | LFactor [(Text, ContrastCoding)]  -- ^ factor 添字 + contrast (1 個=主効果 / 複数=交互作用)
+  | LBasis Text [Term]                -- ^ 基底展開 (bs ! bspline(x,n) / bp ! poly(x,n))
+  | LData Term                        -- ^ データ式 (連続変数・Lit・単項関数・算術)
+
+-- | 基底関数名 (! の右に App として現れたら factor でなく基底展開)。
+basisFns :: [Text]
+basisFns = ["poly", "opoly", "bspline"]
+
+classify :: ModelFrame -> Term -> Either String Leaf
+classify mf leaf =
+  case indexSpine leaf of
+    Just (Ref _, [App f args]) | f `elem` basisFns -> Right (LBasis f args)
+    Just (Ref _, ixs@(_:_))                        -> LFactor <$> mapM ixName ixs
+    _ -> case leaf of
+      Ref x
+        | x `elem` mfParams mf -> Right (LParam x)
+        -- 裸の factor = 主効果 (R 意味論 A17b: @y ~ … + g@ の @g@ が factor 列なら treatment
+        --   contrast の主効果列。 @!@ 添字版 @bg!g@ と同一の LFactor に落とす)。
+        | isFactor x           -> Right (LFactor [(x, Treatment)])
+      _ -> Right (LData leaf)
+  where
+    -- 添字 → (factor 名, contrast)。 @Ref g@ = 無注釈 treatment、
+    -- @C(g, coding)@ = contrast 注釈、 @C(g)@ = treatment。
+    ixName (Ref x)
+      | isFactor x = Right (x, Treatment)
+      | otherwise  = Left $ "添字 '" <> T.unpack x <> "' は factor でなければなりません"
+    ixName (App "C" (Ref x : rest))
+      | isFactor x = (\c -> (x, c)) <$> codingOf rest
+      | otherwise  = Left $ "C(...) の '" <> T.unpack x <> "' は factor でなければなりません"
+    ixName (App f _) = Left $ "基底 '" <> T.unpack f
+                              <> "' は factor 添字と混在できません (基底項は単独で)"
+    ixName _         = Left "添字は変数名でなければなりません"
+    codingOf []            = Right Treatment
+    codingOf (Ref c : _)   = parseContrast c
+    codingOf _             = Left "C(g, coding) の coding は名前でなければなりません"
+    isFactor x = case lookup x (mfRoles mf) of
+                   Just (RoleFactor _ _) -> True
+                   _                     -> False
+
+-- ============================================================================
+-- データ式の評価 (パラメータが内側に出たら非線形)
+-- ============================================================================
+
+evalData :: ModelFrame -> Term -> Either String (V.Vector Double)
+evalData mf t = case t of
+  Lit d -> Right (V.replicate n d)
+  Ref x -> case lookup x (mfRoles mf) of
+    Just (RoleContinuous v) -> Right v
+    Just (RoleResponse _)   -> Left $ "応答 '" <> T.unpack x <> "' をデータ式に使えません"
+    Just (RoleFactor _ _)   -> Left $ "factor '" <> T.unpack x
+                                       <> "' は ! で添字してください"
+    Nothing
+      | x `elem` mfParams mf -> Left $ "非線形: パラメータ '" <> T.unpack x
+                                        <> "' がデータ式の内側に現れます (線形モデルでありません)"
+      | otherwise            -> Left $ "未知の変数 '" <> T.unpack x <> "'"
+  Neg a -> V.map negate <$> evalData mf a
+  App f [a]
+    | Just fn <- lookup f unaryFns -> V.map fn <$> evalData mf a
+  App f _ -> Left $ "未対応の関数 '" <> T.unpack f
+                     <> "' (A17 は log/exp/sqrt/sin/cos/tan/abs の単項のみ)"
+  Bin op a b -> V.zipWith (binFn op) <$> evalData mf a <*> evalData mf b
+  Index _ _  -> Left "添字項はデータ式に直接置けません (係数として扱われます)"
+  where n = mfNRows mf
+
+unaryFns :: [(Text, Double -> Double)]
+unaryFns =
+  [ ("log", log), ("exp", exp), ("sqrt", sqrt)
+  , ("sin", sin), ("cos", cos), ("tan", tan), ("abs", abs) ]
+
+binFn :: BinOp -> (Double -> Double -> Double)
+binFn Add = (+)
+binFn Sub = (-)
+binFn Mul = (*)
+binFn Div = (/)
+binFn Pow = (**)
+
+-- ============================================================================
+-- 加法項 → 設計列
+-- ============================================================================
+
+-- | 切片項か (data も factor も無く param のみ → 1 の列)。
+isInterceptTerm :: ModelFrame -> Term -> Bool
+isInterceptTerm mf term =
+  case mapM (classify mf) (mulLeaves term) of
+    Right leaves -> not (null leaves)
+                 && all isParam leaves
+    _ -> False
+  where isParam (LParam _) = True
+        isParam _          = False
+
+-- | 加法項 1 つの設計列群 (列ラベル, 列ベクトル)。
+termColumns :: Bool -> ModelFrame -> Term -> Either String [(Text, V.Vector Double)]
+termColumns hasInt mf term = do
+  leaves <- mapM (classify mf) (mulLeaves term)
+  let factorNames = concat [ fs | LFactor fs <- leaves ]
+      dataLeaves  = [ d | LData d <- leaves ]
+      basisLeaves = [ (f, a) | LBasis f a <- leaves ]
+  case basisLeaves of
+    [(f, a)]
+      | null factorNames && null dataLeaves -> basisColumns hasInt mf f a
+      | otherwise -> Left "基底項は単独で記述してください (factor/データ式との積は未対応)"
+    (_ : _ : _) -> Left "1 項に複数の基底は未対応"
+    [] -> do
+      dataVec <- case dataLeaves of
+                   [] -> Right (V.replicate (mfNRows mf) 1)
+                   ts -> foldr1 (V.zipWith (*)) <$> mapM (evalData mf) ts
+      let dataLabel | null dataLeaves = Nothing
+                    | otherwise       = Just (T.intercalate "*" (map prettyTerm dataLeaves))
+      case factorNames of
+        [] -> Right [ (prettyTerm term, dataVec) ]
+        fs -> factorColumns hasInt mf fs dataVec dataLabel
+
+-- | 基底展開列。
+--   - @poly(x,n)@ = x¹..xⁿ (n 列・定数なし。 切片は b0 が担う → polyDesignMatrix と同 span)。
+--   - @bspline(x,n)@ = degree-3 clamped B-spline、 knots = quantileKnots n x
+--     (= fitSpline (BSpline 3) (quantileKnots n x) と同一基底)。 既定 degree=3、
+--     @bspline(x,n,k)@ で degree 指定可。 B-spline 基底は partition of unity ゆえ切片と
+--     共線 → 切片併用時 (hasInt) は先頭基底列を drop して満ランク化 (R splines::bs 既定と同様)。
+basisColumns :: Bool -> ModelFrame -> Text -> [Term]
+             -> Either String [(Text, V.Vector Double)]
+basisColumns hasInt mf fname args = case (fname, args) of
+  ("poly", [xe, Lit nd]) -> do
+    xv <- evalData mf xe
+    let deg = round nd :: Int
+    pure [ (lbl xe ("^" <> tshow j), V.map (^ j) xv) | j <- [1 .. deg] ]
+  -- opoly(x,n) = 実測値の直交多項式 (R poly 既定・raw=FALSE と同 span)。
+  --   Vandermonde [1, x, …, xⁿ] を QR 直交化し、 定数列を落とした 1..n 列を返す。
+  --   raw poly と違い列が相互直交 (不等間隔でも linear ⊥ quadratic) ゆえ効果検定が独立。
+  --   ŷ は raw poly と同一 (span 不変・parameterization のみ差)。
+  ("opoly", [xe, Lit nd]) -> do
+    xv <- evalData mf xe
+    let deg    = round nd :: Int
+        xs     = V.toList xv
+        vand   = LA.fromLists [ [ x ^ p | p <- [0 .. deg] ] | x <- xs ]
+        (q, _) = LA.qr vand
+        qcols  = take deg (drop 1 (LA.toColumns q))  -- 定数列を除いた orthogonal 基底 (1..deg)
+    pure [ (lbl xe ("^" <> tshow j), V.fromList (LA.toList c))
+         | (j, c) <- zip [1 :: Int ..] qcols ]
+  ("bspline", [xe, Lit nk])          -> bspl xe (round nk) 3
+  ("bspline", [xe, Lit nk, Lit kk])  -> bspl xe (round nk) (round kk)
+  _ -> Left $ "基底 '" <> T.unpack fname
+              <> "' の引数形が不正 (poly(x,n) / bspline(x,n) / bspline(x,n,k))"
+  where
+    bspl xe nKnots deg = do
+      xv <- evalData mf xe
+      let mat     = bsplineBasis deg (quantileKnots nKnots xv) xv
+          colsAll = map (V.fromList . LA.toList) (LA.toColumns mat)
+          cols    = if hasInt then drop 1 colsAll else colsAll
+      pure [ (lbl xe ("_" <> tshow j), c) | (j, c) <- zip [(1 :: Int) ..] cols ]
+    lbl xe suf = fname <> "(" <> prettyTerm xe <> ")" <> suf
+    tshow      = T.pack . show
+
+-- | factor (1 個=主効果 / 複数=交互作用) を contrast 符号化で展開 (A2 一般化)。
+--   ★各 factor の **contrast 行列 C** (k×m) で行を符号化する。 交互作用列は factor ごとの
+--   contrast 列の **Kronecker 積** (各行で contrast 値の積) を取り、 data ベクトルを掛ける。
+--   ★符号化の縮約は **指示列のとき (dataLabel == Nothing) のみ**: 指示列は合計が切片 (1s)
+--   と共線ゆえ contrast 行列 (k×(k-1)) で 1 列落として満ランク化する。 一方 factor×連続
+--   (dataLabel == Just、 masked データ列) は切片と共線でない → **full coding (k×k 単位行列)**
+--   = 全水準保持で per-level の傾きを持つ (Phase 46 の masked 列罠を踏襲。 落とすと参照群の
+--   傾きが 0 固定で自由度を失う = statsmodels の C(g):x と不一致)。 full coding では単位行列
+--   ゆえ contrast の選択は ŷ に影響しない (= parameterization 不変)。
+factorColumns :: Bool -> ModelFrame -> [(Text, ContrastCoding)] -> V.Vector Double -> Maybe Text
+              -> Either String [(Text, V.Vector Double)]
+factorColumns hasInt mf fcs dataVec dataLabel = do
+  facs <- mapM getFac fcs
+  let reduced = hasInt && isNothing dataLabel
+      facCols = [ factorContrastCols reduced f | f <- facs ]  -- factor ごとの [(列ラベル, 行ベクトル)]
+      combos  = cartesian facCols                              -- 交互作用 = 列の直積
+  pure [ mkCol picks | picks <- combos ]
+  where
+    getFac (name, coding) = case lookup name (mfRoles mf) of
+      Just (RoleFactor lev idx) -> Right (name, lev, idx, coding)
+      _ -> Left $ "factor '" <> T.unpack name <> "' が ModelFrame にありません"
+    mkCol picks =
+      let prodVec = foldr1 (V.zipWith (*)) (map snd picks)   -- 各 factor の contrast 値の積
+          col     = V.zipWith (*) prodVec dataVec
+          lbl     = T.intercalate ":" (map fst picks ++ maybe [] (: []) dataLabel)
+      in (lbl, col)
+
+-- | 1 factor の contrast 列群。 reduced=True で contrast 行列 (k×(k-1))、 False で
+--   full coding (k×k 単位行列 = 指示変数)。 各列は行ごとの contrast 値ベクトル。
+factorContrastCols :: Bool -> (Text, [Text], V.Vector Int, ContrastCoding)
+                   -> [(Text, V.Vector Double)]
+factorContrastCols reduced (nm, lev, idx, coding) =
+  let k      = length lev
+      cmat   = if reduced then contrastMatrix coding k else LA.ident k
+      m      = LA.cols cmat
+      colVec j = V.map (\l -> cmat `LA.atIndex` (l, j)) idx
+      lbl j
+        | not reduced         = nm <> "=" <> (lev !! j)              -- full = 水準名 (指示)
+        | coding == Treatment = nm <> "=" <> (lev !! (j + 1))        -- 参照 (水準0) を除く
+        | otherwise           = nm <> "[" <> codingTag coding <> "." <> tshow j <> "]"
+  in [ (lbl j, colVec j) | j <- [0 .. m - 1] ]
+  where tshow = T.pack . show
+
+cartesian :: [[a]] -> [[a]]
+cartesian []       = [[]]
+cartesian (xs:rest) = [ x : r | x <- xs, r <- cartesian rest ]
+
+-- ============================================================================
+-- Contrast coding (A2)
+-- ============================================================================
+
+-- | factor 符号化方式。 切片併用時に満ランク化する contrast。
+data ContrastCoding
+  = Treatment                    -- ^ 参照水準 (昇順先頭) を 0 に、 他を指示 (既定・R 既定 contr.treatment)
+  | Sum                          -- ^ sum-to-zero (最終水準 = −Σ others、 R contr.sum)
+  | Helmert                      -- ^ 各水準 vs それ以前の平均 (R contr.helmert)
+  | Polynomial                   -- ^ ordered factor 用の直交多項式 (R contr.poly)
+  | CustomContrast (LA.Matrix Double)  -- ^ ユーザ指定の k×(k-1) contrast 行列
+  deriving (Eq, Show)
+
+-- | contrast 名 (C(g, name) の name) を解釈。
+parseContrast :: Text -> Either String ContrastCoding
+parseContrast t = case T.toLower t of
+  "treatment" -> Right Treatment
+  "sum"        -> Right Sum
+  "helmert"    -> Right Helmert
+  "poly"       -> Right Polynomial
+  "polynomial" -> Right Polynomial
+  _ -> Left $ "未知の contrast '" <> T.unpack t
+              <> "' (Treatment/Sum/Helmert/Polynomial)"
+
+-- | 列ラベル用の短いタグ。
+codingTag :: ContrastCoding -> Text
+codingTag Treatment          = "T"
+codingTag Sum                = "S"
+codingTag Helmert            = "H"
+codingTag Polynomial         = "P"
+codingTag (CustomContrast _) = "C"
+
+-- | k 水準の contrast 行列 (k×(k-1))。 切片併用時の満ランク符号化。
+--   行 = 水準 (昇順 index)、 列 = contrast。 行 l の値が水準 l の設計行寄与。
+contrastMatrix :: ContrastCoding -> Int -> LA.Matrix Double
+contrastMatrix coding k = case coding of
+  Treatment ->
+    LA.fromLists [ [ if l == j + 1 then 1 else 0 | j <- [0 .. k - 2] ] | l <- [0 .. k - 1] ]
+  Sum ->
+    LA.fromLists [ sumRow l | l <- [0 .. k - 1] ]
+  Helmert ->
+    LA.fromLists [ [ helmert l j | j <- [0 .. k - 2] ] | l <- [0 .. k - 1] ]
+  Polynomial       -> polyContrast k
+  CustomContrast m -> m
+  where
+    sumRow l | l == k - 1 = replicate (k - 1) (-1)
+             | otherwise  = [ if l == j then 1 else 0 | j <- [0 .. k - 2] ]
+    helmert l j | l <= j      = -1
+                | l == j + 1  = fromIntegral (j + 1)
+                | otherwise   = 0
+
+-- | 直交多項式 contrast (k×(k-1))。 中心化水準スコアの Vandermonde を QR 分解し
+--   定数列を落とした直交基底 (R contr.poly と同 span。 符号差は ŷ 不変ゆえ無害)。
+polyContrast :: Int -> LA.Matrix Double
+polyContrast k =
+  let xs    = map fromIntegral [1 .. k] :: [Double]
+      xbar  = sum xs / fromIntegral k
+      vand  = LA.fromLists [ [ (x - xbar) ^ p | p <- [0 .. k - 1] ] | x <- xs ]
+      (q, _) = LA.qr vand
+  in LA.fromColumns (drop 1 (LA.toColumns q))
+
+-- ============================================================================
+-- designMatrixF / fitLMF / linearityCheck
+-- ============================================================================
+
+-- | 'Formula' + 'ModelFrame' → 設計行列 (n×p) と列ラベル。 非線形なら Left。
+designMatrixF :: Formula -> ModelFrame -> Either String (LA.Matrix Double, [Text])
+designMatrixF (Formula _ _ rhs) mf = do
+  let terms  = flattenAdd rhs
+      hasInt = any (isInterceptTerm mf) terms
+  colss <- mapM (termColumns hasInt mf) terms
+  let cols   = concat colss
+      labels = map fst cols
+  if null cols
+    then Left "空のモデル (設計列がありません)"
+    else Right ( LA.fromColumns (map (LA.fromList . V.toList . snd) cols)
+               , labels )
+
+-- | 線形モデルを OLS で fit。 設計列ラベルも返す。 非線形なら Left。
+fitLMF :: Formula -> DX.DataFrame -> Either String (FitResult, [Text])
+fitLMF f df = do
+  mf            <- modelFrame f df
+  (x, labels)   <- designMatrixF f mf
+  yv            <- responseVec mf
+  let y = LA.asColumn (LA.fromList (V.toList yv))
+  Right (fitLM x y, labels)
+
+-- | 応答ベクトル取り出し。
+responseVec :: ModelFrame -> Either String (V.Vector Double)
+responseVec mf = case mfRoles mf of
+  ((_, RoleResponse v) : _) -> Right v
+  _                         -> Left "ModelFrame に応答列がありません"
+
+-- ============================================================================
+-- weights / offset = WLS (A3)
+-- ============================================================================
+
+-- | 重み付き最小二乗 + offset の設定。 statsmodels @smf.wls(formula, data, weights=…)@
+--   に倣い、 weights/offset は **列名で渡す** (R でも weights は formula 外)。
+data WLSConfig = WLSConfig
+  { wcWeights :: Maybe Text  -- ^ 重み列名 (WLS。 'Nothing' = 等重み OLS)
+  , wcOffset  :: Maybe Text  -- ^ offset 列名 (η への固定加算。 線形では @y* = y − offset@ を fit)
+  }
+  deriving (Eq, Show)
+
+-- | 既定 (重みなし・offset なし = OLS、 'fitLMF' と等価)。
+defaultWLS :: WLSConfig
+defaultWLS = WLSConfig Nothing Nothing
+
+-- | weights / offset 付きで線形モデルを fit。
+--
+--   ★行整列: 'modelFrame' は欠損 policy で行を落とし得るので、 weights/offset 列が frame と
+--   ずれないよう **formula 関与列 ∪ weights ∪ offset をまとめて 'dropMissingRows'** してから
+--   frame を組み、 weights/offset も同じ DataFrame から取り出す。
+--   ★WLS = @√w@ で X/y を行スケール (@X' = diag(√w) X@, @y' = √w ⊙ y@) し OLS に帰着。
+--   ★offset = η への固定加算ゆえ線形では @y − offset@ を解けばよい (GLM offset は別経路・未対応)。
+fitWLSF :: WLSConfig -> Formula -> DX.DataFrame -> Either String (FitResult, [Text])
+fitWLSF cfg f@(Formula resp dvars _) df0 = do
+  let extra = catMaybes [wcWeights cfg, wcOffset cfg]
+      df    = dropMissingRows (resp : dvars ++ extra) df0  -- 整列のため一括 drop
+  mf          <- modelFrame f df
+  (x, labels) <- designMatrixF f mf
+  yv0         <- responseVec mf
+  yv <- case wcOffset cfg of
+          Nothing -> Right yv0
+          Just oc -> do ov <- col df oc; Right (V.zipWith (-) yv0 ov)
+  case wcWeights cfg of
+    Nothing -> Right (fitLM x (asCol yv), labels)
+    Just wc -> do
+      wv <- col df wc
+      let swv = LA.fromList (map sqrt (V.toList wv))            -- √w
+          xw  = LA.fromColumns [ swv * c | c <- LA.toColumns x ] -- diag(√w) X
+          yw  = swv * LA.fromList (V.toList yv)                  -- √w ⊙ y
+      Right (fitLM xw (LA.asColumn yw), labels)
+  where
+    col d name = maybe (Left $ "WLS 列 '" <> T.unpack name <> "' が数値列として見つかりません")
+                       Right (getDoubleVec name d)
+    asCol v = LA.asColumn (LA.fromList (V.toList v))
+
+-- | 線形性チェック (designMatrixF が通れば線形)。 メッセージ付き Either。
+linearityCheck :: Formula -> DX.DataFrame -> Either String ()
+linearityCheck f df = do
+  mf <- modelFrame f df
+  _  <- designMatrixF f mf
+  Right ()
diff --git a/src/Hanalyze/Model/Formula/Frame.hs b/src/Hanalyze/Model/Formula/Frame.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Model/Formula/Frame.hs
@@ -0,0 +1,234 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+-- |
+-- Module      : Hanalyze.Model.Formula.Frame
+-- Description : Formula DSL の ModelFrame (変数役割割り当て + パラメータ分離)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Formula DSL — ModelFrame (A16)。 'Formula' AST + 'DataFrame' を突合し、
+--   各名前に役割 (応答 / 連続データ変数 / factor) を割り当て、 推定パラメータを分離する。
+--
+--   ★設計の要点 (実測で確定): 「factor かどうか」 は **列の型ではなく formula 内の
+--   使われ方** で決まる。 すなわち @bg ! group@ のように Index の右オペランドに現れた
+--   データ変数を factor とみなす (numeric コードの factor も拾える)。 算術中にのみ現れる
+--   データ変数は連続。 左辺で宣言されていない右辺の自由名 = 推定パラメータ。
+--
+--   基底展開 (@bs ! bspline(x,k)@) の設計行列化や係数ベクトル長の確定は A17
+--   ('designMatrixF') に委ねる。 本モジュールは「役割の割り当てとパラメータ抽出」 まで。
+--   DataFrame 依存ゆえ Formula.hs (純 AST) とは分離 (portable 区分は維持)。
+module Hanalyze.Model.Formula.Frame
+  ( VarRole (..)
+  , ModelFrame (..)
+  , MissingPolicy (..)
+  , ImputeKind (..)
+  , modelFrame
+  , modelFrameWith
+    -- * 内部 (テスト用に公開)
+  , refNames
+  , indexedVars
+  ) where
+
+import           Control.Applicative    ((<|>))
+import           Data.List              (foldl', nub, sort)
+import qualified Data.Map.Strict        as Map
+import           Data.Text              (Text)
+import qualified Data.Text              as T
+import qualified Data.Vector            as V
+import qualified DataFrame.Internal.DataFrame  as DX
+
+import           Hanalyze.DataIO.Convert    (getDoubleVec, getTextVec)
+import           Hanalyze.DataIO.Preprocess (Value (..), countMissing, deriveText,
+                                             dropMissingRows, imputeMean,
+                                             imputeMedian, isNAString)
+import           Hanalyze.Model.Formula  (Formula (..), Term (..))
+
+-- ============================================================================
+-- 役割付き列と ModelFrame
+-- ============================================================================
+
+-- | データ変数 (応答含む) の役割。
+data VarRole
+  = RoleResponse   (V.Vector Double)        -- ^ 応答 y (数値)
+  | RoleContinuous (V.Vector Double)        -- ^ 連続説明変数 (数値)
+  | RoleFactor     [Text] (V.Vector Int)    -- ^ factor: 水準ラベル (昇順) + 行ごとの水準 index
+  deriving (Eq, Show)
+
+-- | AST + data を突合した結果。
+data ModelFrame = ModelFrame
+  { mfRoles  :: [(Text, VarRole)]  -- ^ 応答 + データ変数 → 役割 (応答が先頭、 以降は宣言順)
+  , mfParams :: [Text]             -- ^ 推定パラメータ (右辺自由名 − データ変数、 出現順)
+  , mfNRows  :: Int                -- ^ 行数 (応答列の長さ)
+  }
+  deriving (Eq, Show)
+
+-- | 欠損値の扱い方。 NA 検出・除去・補完は ModelFrame の **単一責務点** (spec §2.2)。
+--   policy で整形した DataFrame を 'buildFrame' に通すことで、 各 fit 関数に
+--   NA 検出を散らさず一元化する。
+data MissingPolicy
+  = DropRows           -- ^ NA を含む行を全関与列から除外 (listwise deletion、 既定・後方互換)。
+  | Pairwise           -- ^ 線形 OLS では設計行列が成立しないので DropRows に縮退する
+                       --   (相関等の別用途のために policy 値としては保持。 'fitLMF' 等は警告)。
+  | Impute ImputeKind  -- ^ 連続説明変数を平均/中央値で補完。 応答・factor の NA は
+                       --   別 policy 併用が要る (Impute では埋めない)。
+  | TreatAsCategory    -- ^ factor 列の NA を独立水準 @"<NA>"@ として扱う。
+  | ErrorOnMissing     -- ^ 関与列に NA があれば 'Left' (列名 + 件数つき)。
+  deriving (Eq, Show)
+
+-- | 'Impute' の補完方式。
+data ImputeKind = ImputeMean | ImputeMedian
+  deriving (Eq, Show)
+
+-- ============================================================================
+-- 解析ヘルパ (AST 走査)
+-- ============================================================================
+
+-- | 右辺に現れる全 Ref 名 (出現順、 重複あり)。
+--   contrast 注釈 @C(g, Sum)@ は **factor 名 g のみ** を拾う (coding 名 "Sum" は
+--   推定パラメータでもデータ変数でもないので除外)。
+refNames :: Term -> [Text]
+refNames t = case t of
+  Ref x               -> [x]
+  Lit _               -> []
+  App "C" (Ref x : _) -> [x]                   -- contrast 注釈: factor 名のみ
+  App _ as            -> concatMap refNames as -- 関数名 (App の Text) はパラメータでない
+  Index a b           -> refNames a ++ refNames b
+  Neg a               -> refNames a
+  Bin _ a b           -> refNames a ++ refNames b
+
+-- | Index の右オペランドに factor として現れた名前 (= factor 候補)。
+--   右が @Ref g@ (無注釈 = treatment) または @C(g, coding)@ (contrast 注釈) なら g を拾う。
+--   右が基底展開 App (bspline / poly 等) の場合は factor でない (A17 が扱う) ので拾わない。
+indexedVars :: Term -> [Text]
+indexedVars = nub . go
+  where
+    go t = case t of
+      Index a b -> rightRef b ++ go a ++ go b
+      App _ as  -> concatMap go as
+      Neg a     -> go a
+      Bin _ a b -> go a ++ go b
+      _         -> []
+    rightRef (Ref x)               = [x]
+    rightRef (App "C" (Ref x : _)) = [x]       -- C(g, coding) → factor g
+    rightRef _                     = []
+
+-- ============================================================================
+-- modelFrame
+-- ============================================================================
+
+-- | 既定 policy ('DropRows') で 'ModelFrame' を構築する (後方互換: NA 無しデータでは不変)。
+modelFrame :: Formula -> DX.DataFrame -> Either String ModelFrame
+modelFrame = modelFrameWith DropRows
+
+-- | 欠損 'MissingPolicy' を指定して 'ModelFrame' を構築する。
+--   policy で整形した DataFrame を 'buildFrame' に通す (NA 検出・除去・補完を一元化)。
+modelFrameWith :: MissingPolicy -> Formula -> DX.DataFrame -> Either String ModelFrame
+modelFrameWith policy fml@(Formula resp dvars rhs) df = do
+  let involved = resp : dvars
+      factors  = filter (`elem` dvars) (indexedVars rhs)
+      conts    = filter (`notElem` factors) dvars       -- 連続説明変数 (factor 以外)
+      naOf d c = maybe 0 id (lookup c (countMissing d))  -- 列 c の NA 件数
+  df' <- case policy of
+    DropRows -> Right (dropMissingRows involved df)
+    Pairwise -> Right (dropMissingRows involved df)  -- 単一 frame では DropRows と同義
+    ErrorOnMissing ->
+      let bad = [ (T.unpack c, naOf df c) | c <- involved, naOf df c > 0 ]
+      in if null bad then Right df
+         else Left $ "ErrorOnMissing: 欠損のある関与列 " <> show bad
+    Impute kind -> do
+      df1 <- imputeCols kind conts df
+      let stillBad = [ T.unpack c | c <- resp : factors, naOf df1 c > 0 ]
+      if null stillBad then Right df1
+        else Left $ "Impute は連続説明変数のみ補完します。 応答/factor の欠損 "
+                    <> show stillBad <> " は DropRows か TreatAsCategory を併用してください"
+    TreatAsCategory ->
+      let df1      = foldl' (flip naToCategory) df factors
+          stillBad = [ T.unpack c | c <- resp : conts, naOf df1 c > 0 ]
+      in if null stillBad then Right df1
+         else Left $ "TreatAsCategory は factor 列のみ扱います。 応答/連続の欠損 "
+                     <> show stillBad <> " は DropRows か Impute を併用してください"
+  buildFrame fml df'
+
+-- | 連続列群を平均/中央値で補完。 数値列でなければ 'Left'。
+imputeCols :: ImputeKind -> [Text] -> DX.DataFrame -> Either String DX.DataFrame
+imputeCols kind = go
+  where
+    impute1 c = case kind of { ImputeMean -> imputeMean c; ImputeMedian -> imputeMedian c }
+    go []     d = Right d
+    go (c:cs) d = case impute1 c d of
+      Just d' -> go cs d'
+      Nothing -> Left $ "連続変数 '" <> T.unpack c <> "' を数値列として補完できません"
+
+-- | factor 列の NA を独立水準 @"<NA>"@ に置換した Text 列で上書きする。
+--   非 NA 値は 'showNum' で文字列化 ('columnAsText' の数値→文字列と同形)。
+naToCategory :: Text -> DX.DataFrame -> DX.DataFrame
+naToCategory c = deriveText c toLbl
+  where
+    toLbl row = case Map.lookup c row of
+      Just (VText t) | not (isNAString t) -> t
+      Just (VNum d)                       -> T.pack (showNum d)
+      _                                   -> "<NA>"
+
+-- | 'Formula' と (policy 適用済) 'DataFrame' を突合して 'ModelFrame' を構築する。
+buildFrame :: Formula -> DX.DataFrame -> Either String ModelFrame
+buildFrame (Formula resp dvars rhs) df = do
+  -- 応答列 (数値必須)
+  yv <- maybe (Left $ "応答変数 '" <> T.unpack resp <> "' が数値列として見つかりません")
+              Right (getDoubleVec resp df)
+  let n        = V.length yv
+      indexed  = filter (`elem` dvars) (indexedVars rhs)
+      -- R 意味論 (A17b): @!@ 添字が無くても **非数値 (Text) 列は factor** として扱う
+      --   (character→factor 自動判定)。 数値列は連続のまま (numeric-coded factor は従来どおり
+      --   @!@ 添字必須) なので、 従来 error だった「Text 列を裸で置いた」場合だけが factor 化する。
+      autoFac  = [ v | v <- dvars, v `notElem` indexed, nonNumericText v ]
+      factors  = indexed ++ autoFac
+      params   = refNames rhs `minus` (resp : dvars)
+      nonNumericText v = case getDoubleVec v df of
+                           Just _  -> False
+                           Nothing -> case getTextVec v df of
+                                        Just _  -> True
+                                        Nothing -> False
+  -- 各データ変数の役割を解決
+  varRoles <- mapM (resolveVar factors df) dvars
+  pure ModelFrame
+    { mfRoles  = (resp, RoleResponse yv) : zip dvars varRoles
+    , mfParams = params
+    , mfNRows  = n
+    }
+
+-- | データ変数 1 つを役割に解決する。 factors に含まれれば factor、 さもなくば連続。
+resolveVar :: [Text] -> DX.DataFrame -> Text -> Either String VarRole
+resolveVar factors df name
+  | name `elem` factors = factorRole name df
+  | otherwise           =
+      maybe (Left $ "連続変数 '" <> T.unpack name <> "' が数値列として見つかりません")
+            (Right . RoleContinuous) (getDoubleVec name df)
+
+-- | factor 列を水準ラベル (昇順) + 行ごとの水準 index に。
+--   text 列を優先、 無ければ数値列を文字列化 (numeric コードの factor)。
+factorRole :: Text -> DX.DataFrame -> Either String VarRole
+factorRole name df =
+  case columnAsText name df of
+    Nothing  -> Left $ "factor 変数 '" <> T.unpack name <> "' が列として見つかりません"
+    Just col ->
+      let levels = sort (nub (V.toList col))           -- 昇順 = treatment contrast の参照=第1水準
+          idxOf v = length (takeWhile (/= v) levels)    -- levels 内の位置
+          idx    = V.map idxOf col
+      in Right (RoleFactor levels idx)
+
+-- | 列を [Text] 表現で取得 (factor 水準列挙用)。 text 列優先、 無ければ数値を文字列化。
+columnAsText :: Text -> DX.DataFrame -> Maybe (V.Vector Text)
+columnAsText name df =
+      getTextVec name df
+  <|> (V.map (T.pack . showNum) <$> getDoubleVec name df)
+
+-- | 数値を factor 水準ラベル用に文字列化 (整数は小数点なし)。
+showNum :: Double -> String
+showNum d
+  | d == fromIntegral i = show i
+  | otherwise           = show d
+  where i = round d :: Integer
+
+-- | リスト差 (左の出現順を保ち、 右に含まれる要素を除く)。
+minus :: Eq a => [a] -> [a] -> [a]
+minus xs ys = foldl' (\acc x -> if x `elem` ys || x `elem` acc then acc else acc ++ [x]) [] xs
diff --git a/src/Hanalyze/Model/Formula/Mixed.hs b/src/Hanalyze/Model/Formula/Mixed.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Model/Formula/Mixed.hs
@@ -0,0 +1,230 @@
+-- |
+-- Module      : Hanalyze.Model.Formula.Mixed
+-- Description : Formula DSL の混合効果モデル (random effect) 接続層
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Formula DSL — 混合効果モデル (random effect) の接続層 (Phase 48)。
+--
+--   lme4 流の @(1|g)@ / @(x|g)@ / @(1+x|g)@ を Formula DSL に追加し、
+--   'Hanalyze.Model.GLMM' の一般ランダム効果フィット ('fitLMEGeneral' /
+--   'fitGLMMGeneral') へ route する。
+--
+--   ★設計判断 (Phase 48): random 項を AST の 'Term' 構成子として持たせず、
+--   **字句プリパスで @(…|g)@ ブロックを抽出** する方式を採る。 理由は
+--   'Term' に構成子を足すと 'Hanalyze.Model.Formula' 系 5 モジュールの網羅
+--   pattern match が全て破壊されるため (計画 phase-48 のリスク注記)。 本方式なら
+--   'Term'/'Formula' は不変で、 固定効果は既存の 'parseModel'/'designMatrixF'
+--   経路をそのまま使え、 random 項の解釈は本モジュールに閉じる。
+--
+--   frequentist GLMM ゆえ random 効果に prior 宣言は不要 (分散 G は推定対象)。
+module Hanalyze.Model.Formula.Mixed
+  ( RandomSpec (..)
+  , extractRandom
+  , fitMixedF
+  , fitMixedLME
+  , fitMixedGLMM
+  ) where
+
+import           Control.Monad           (unless, when)
+import           Data.Char               (isSpace)
+import           Data.List               (intercalate)
+import           Data.Text               (Text)
+import qualified Data.Text               as T
+import qualified Data.Vector             as V
+import qualified Numeric.LinearAlgebra   as LA
+
+import qualified DataFrame.Internal.DataFrame as DXD
+import           Hanalyze.DataIO.Convert      (getDoubleVec, getTextVec)
+import           Hanalyze.DataIO.Preprocess   (dropMissingRows)
+import           Hanalyze.Model.Formula       (Formula (..))
+import           Hanalyze.Model.Formula.Design (designMatrixF, responseVec)
+import           Hanalyze.Model.Formula.Frame  (modelFrame)
+import           Hanalyze.Model.Formula.RFormula (parseModel)
+import           Hanalyze.Model.GLM           (Family (..), LinkFn (..))
+import           Hanalyze.Model.GLMM          (GLMMResultRE, buildGroups,
+                                               fitGLMMGeneral, fitLMEGeneral)
+
+-- ============================================================================
+-- random 項の表現
+-- ============================================================================
+
+-- | 1 つの @(…|g)@ ブロックの解釈結果。
+--   例: @(1+x|g)@ → @RandomSpec True ["x"] "g"@ / @(0+x|g)@ → @RandomSpec False ["x"] "g"@.
+data RandomSpec = RandomSpec
+  { rsIntercept :: Bool    -- ^ random intercept を含むか (@1@ あり or 既定 True、 @0@/@-1@ で抑制)
+  , rsSlopes    :: [Text]  -- ^ random slope の変数名 (左辺の @1@/@0@/@-1@ 以外)
+  , rsGroup     :: Text    -- ^ grouping 変数名 (@|@ の右)
+  } deriving (Eq, Show)
+
+-- ============================================================================
+-- 字句プリパス: (…|g) ブロックの抽出
+-- ============================================================================
+
+-- | formula 文字列から random 項 @(…|g)@ を抽出し、 (固定効果 formula, [RandomSpec])
+--   を返す。 LHS (@~@ or @=@) は保持し、 RHS から random ブロックを取り除く。
+--
+--   * R 構文: @"y ~ x + (1+x|g)"@ → (@"y ~ x"@, [RandomSpec True ["x"] "g"])
+--   * 独自構文: @"y x = b0 + b1*x + (1|g)"@ → (@"y x = b0 + b1*x"@, [RandomSpec True [] "g"])
+--
+--   固定効果側に項が残らない場合 (例 @"y ~ (1|g)"@) は intercept @"1"@ を補う。
+extractRandom :: Text -> Either String (Text, [RandomSpec])
+extractRandom t =
+  let s = T.unpack t
+      (lhs, sep, rhs) = splitLHS s
+  in do
+       tokens <- pure (splitTopPlus rhs)
+       (fixedToks, specStrs) <- partitionTokens tokens
+       specs <- mapM parseBlock specStrs
+       let fixedRHS = case map trimStr (filter (not . all isSpace) fixedToks) of
+                        [] -> "1"
+                        ts -> intercalate " + " ts
+           fixedFormula = case sep of
+                            "" -> fixedRHS                       -- LHS 無し (RHS のみ)
+                            _  -> trimStr lhs ++ " " ++ sep ++ " " ++ fixedRHS
+       Right (T.pack fixedFormula, specs)
+
+-- | LHS と RHS を @~@ (R) または @=@ (独自) で分割。 区切りが無ければ ("", "", whole)。
+splitLHS :: String -> (String, String, String)
+splitLHS s
+  | Just (l, r) <- breakTop '~' s = (l, "~", r)
+  | Just (l, r) <- breakTop '=' s = (l, "=", r)
+  | otherwise                     = ("", "", s)
+
+-- | top-level (括弧外) の最初の区切り文字で 1 回分割。
+breakTop :: Char -> String -> Maybe (String, String)
+breakTop target = go (0 :: Int) []
+  where
+    go _ _   [] = Nothing
+    go d acc (c:cs)
+      | c == '('            = go (d+1) (c:acc) cs
+      | c == ')'            = go (d-1) (c:acc) cs
+      | c == target && d == 0 = Just (reverse acc, cs)
+      | otherwise           = go d (c:acc) cs
+
+-- | top-level の @+@ で分割 (括弧内の @+@ は分割しない)。
+splitTopPlus :: String -> [String]
+splitTopPlus = go (0 :: Int) [] []
+  where
+    go _ cur acc [] = reverse (reverse cur : acc)
+    go d cur acc (c:cs)
+      | c == '('            = go (d+1) (c:cur) acc cs
+      | c == ')'            = go (d-1) (c:cur) acc cs
+      | c == '+' && d == 0  = go d [] (reverse cur : acc) cs
+      | otherwise           = go d (c:cur) acc cs
+
+-- | 各トークンを固定効果トークンか random ブロック (中身) に振り分ける。
+--   random ブロック = trim 後 @(…)@ で囲まれ、 内部 top-level に @|@ を持つもの。
+partitionTokens :: [String] -> Either String ([String], [String])
+partitionTokens = go [] []
+  where
+    go fixed rand [] = Right (reverse fixed, reverse rand)
+    go fixed rand (tok:rest) =
+      case asRandomBlock (trimStr tok) of
+        Just inner -> go fixed (inner : rand) rest
+        Nothing    -> go (tok : fixed) rand rest
+
+-- | トークンが @(…|…)@ なら内部文字列を返す。
+asRandomBlock :: String -> Maybe String
+asRandomBlock tok =
+  case tok of
+    ('(':rest) | not (null rest), last rest == ')' ->
+      let inner = init rest
+      in if hasTopPipe inner then Just inner else Nothing
+    _ -> Nothing
+
+-- | top-level に @|@ を含むか。
+hasTopPipe :: String -> Bool
+hasTopPipe = go (0 :: Int)
+  where
+    go _ [] = False
+    go d (c:cs)
+      | c == '('          = go (d+1) cs
+      | c == ')'          = go (d-1) cs
+      | c == '|' && d == 0 = True
+      | otherwise         = go d cs
+
+-- | @"1 + x | g"@ → 'RandomSpec'。
+parseBlock :: String -> Either String RandomSpec
+parseBlock inner =
+  case breakTop '|' inner of
+    Nothing       -> Left "random ブロックに '|' がありません"
+    Just (lhs, rhs) ->
+      let grp   = trimStr rhs
+          terms = map trimStr (splitTopPlus lhs)
+          isSup t = t == "0" || t == "-1"
+          isOne t = t == "1"
+          hasSup  = any isSup terms
+          slopes  = [ T.pack t | t <- terms, not (isSup t), not (isOne t), not (null t) ]
+      in if null grp
+           then Left "random ブロックの grouping 変数 (| の右) が空です"
+           else Right RandomSpec
+                  { rsIntercept = not hasSup           -- 0/-1 が無ければ intercept あり
+                  , rsSlopes    = slopes
+                  , rsGroup     = T.pack grp
+                  }
+
+trimStr :: String -> String
+trimStr = f . f where f = reverse . dropWhile isSpace
+
+-- ============================================================================
+-- route 入口: 固定/random を分離し GLMM 一般フィットへ
+-- ============================================================================
+
+-- | 混合効果モデルを DataFrame からフィットする。 @Nothing@ = Gaussian LME
+--   ('fitLMEGeneral')、 @Just (family, link)@ = 非 Gaussian GLMM ('fitGLMMGeneral')。
+--   戻り値は (結果, 固定効果係数名)。
+--
+--   ★現状は **単一 grouping factor** のみ対応 ((1|g) / (x|g) / (1+x|g))。 複数の
+--   @(…|g1) + (…|g2)@ は block-diagonal Z が要るため未対応 (明示エラー)。
+--
+--   TODO (Phase 48 follow-up):
+--     * 複数 grouping factor @(…|g1) + (…|g2)@ — 群ごと Z ブロックを block-diagonal に
+--       積み、 fitLMEGeneral/fitGLMMGeneral を multi-grouping 一般化する。
+--     * GLMM offset (Poisson log-exposure 等) — 現状は線形 offset のみ ('fitWLSF')。
+--     * REML 推定 — 現状の EM/Laplace は ML。 REML は固定効果 df 補正付き。
+fitMixedF
+  :: Maybe (Family, LinkFn)
+  -> Text -> DXD.DataFrame
+  -> Either String (GLMMResultRE, [Text])
+fitMixedF mfam formulaText df0 = do
+  (fixedText, specs) <- extractRandom formulaText
+  spec <- case specs of
+            [s] -> Right s
+            []  -> Left "random effect 項 (…|g) がありません (固定効果のみなら fitLMF を使用)"
+            _   -> Left "複数の grouping factor は未対応 (単一の (…|g) のみ)"
+  f@(Formula resp dvars _) <- parseModel fixedText
+  let slopeVars = rsSlopes spec
+      grp       = rsGroup spec
+      -- 行整列: fixedWLF と同じく formula 関与列 ∪ slope ∪ group を一括 drop
+      df        = dropMissingRows (resp : dvars ++ slopeVars ++ [grp]) df0
+  mf          <- modelFrame f df
+  (x, labels) <- designMatrixF f mf
+  yv          <- responseVec mf
+  let n = V.length yv
+  slopeCols <- mapM (\v ->
+                  maybe (Left $ "random slope 列 '" <> T.unpack v <> "' が数値列として見つかりません")
+                        Right (getDoubleVec v df)) slopeVars
+  let interceptCol = [ V.replicate n 1.0 | rsIntercept spec ]
+      zCols        = interceptCol ++ slopeCols
+  when (null zCols) $ Left "random effect の設計列が空です ((0|g) のみは不可)"
+  unless (all ((== n) . V.length) zCols) $
+    Left "random slope 列の長さが応答と一致しません"
+  gv <- maybe (Left $ "grouping 列 '" <> T.unpack grp <> "' が見つかりません")
+              Right (getTextVec grp df)
+  let z = LA.fromColumns (map (LA.fromList . V.toList) zCols)
+      y = LA.fromList (V.toList yv)
+      (glabels, idx, _sizes) = buildGroups gv
+      res = case mfam of
+              Nothing          -> fitLMEGeneral x z y idx glabels
+              Just (fam, link) -> fitGLMMGeneral fam link x z y idx glabels
+  Right (res, labels)
+
+-- | Gaussian 線形混合効果モデル (LME)。 @fitMixedLME "y ~ x + (1+x|g)" df@。
+fitMixedLME :: Text -> DXD.DataFrame -> Either String (GLMMResultRE, [Text])
+fitMixedLME = fitMixedF Nothing
+
+-- | 非 Gaussian GLMM。 @fitMixedGLMM Binomial Logit "y ~ x + (1|g)" df@。
+fitMixedGLMM :: Family -> LinkFn -> Text -> DXD.DataFrame
+             -> Either String (GLMMResultRE, [Text])
+fitMixedGLMM fam link = fitMixedF (Just (fam, link))
diff --git a/src/Hanalyze/Model/Formula/Nonlinear.hs b/src/Hanalyze/Model/Formula/Nonlinear.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Model/Formula/Nonlinear.hs
@@ -0,0 +1,111 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+-- |
+-- Module      : Hanalyze.Model.Formula.Nonlinear
+-- Description : Formula DSL の非線形最小二乗 (NLS) fit
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Formula DSL — 非線形最小二乗 (NLS、 A4)。
+--   現状 @a*exp(-b*x)@ のように **パラメータがデータ式の内側に現れる式** ('designMatrixF'
+--   は線形でないとして 'Left') を、 parse 済 AST を評価関数化して既存の最適化器
+--   ('Hanalyze.Optim.NelderMead') で SSR を最小化し fit する。
+--
+--   ★考え方: 線形 OLS と違い param 名が ŷ に効く。 @evalNL@ が「params 表 + ModelFrame」 から
+--   右辺式を **行ごとの ŷ ベクトル** に評価する (param は定数、 連続データ変数は列ベクトル)。
+--   目的関数 @SSR(θ) = Σ(y − ŷ(θ))²@ を Nelder-Mead で最小化。
+--   ★初期値はユーザ必須 (NLS は初期値依存)。 factor 添字は非対応 (線形側で扱う)。
+--   ★最適化器は IO を返すが決定論的ゆえ 'unsafePerformIO' で pure 化 (Convert.hs 同方針)。
+--
+--   plot 非依存・portable。
+module Hanalyze.Model.Formula.Nonlinear
+  ( NLSResult (..)
+  , fitNLS
+  , evalNL
+  ) where
+
+import           Data.Text               (Text)
+import qualified Data.Text               as T
+import qualified Data.Vector             as V
+import           System.IO.Unsafe        (unsafePerformIO)
+
+import           Hanalyze.Model.Formula  (BinOp (..), Formula (..), Term (..))
+import           Hanalyze.Model.Formula.Frame
+import           Hanalyze.Optim.Common    (OptimResult (..))
+import           Hanalyze.Optim.NelderMead (runNelderMead)
+import qualified DataFrame.Internal.DataFrame  as DX
+
+-- | 非線形 fit の結果。
+data NLSResult = NLSResult
+  { nlsParams    :: [(Text, Double)]   -- ^ 推定パラメータ (名前つき)
+  , nlsFitted    :: V.Vector Double    -- ^ ŷ
+  , nlsResidual  :: V.Vector Double    -- ^ y − ŷ
+  , nlsSSR       :: Double             -- ^ 残差平方和
+  , nlsConverged :: Bool               -- ^ 最適化器が許容誤差で停止したか
+  }
+  deriving (Eq, Show)
+
+-- | 右辺式を **行ごとの値ベクトル** に評価する。 params は表から定数、 連続データ変数は
+--   ModelFrame の列、 factor / 応答は 'Left'。 (線形の 'evalData' と違い param を許す。)
+evalNL :: [(Text, Double)] -> ModelFrame -> Term -> Either String (V.Vector Double)
+evalNL pm mf = go
+  where
+    n = mfNRows mf
+    go t = case t of
+      Lit d -> Right (V.replicate n d)
+      Ref x -> case lookup x (mfRoles mf) of
+        Just (RoleContinuous v) -> Right v
+        Just (RoleResponse _)   -> Left $ "応答 '" <> T.unpack x <> "' をデータ式に使えません"
+        Just (RoleFactor _ _)   -> Left $ "非線形フィットは factor '" <> T.unpack x
+                                           <> "' を扱えません"
+        Nothing -> case lookup x pm of
+          Just d  -> Right (V.replicate n d)
+          Nothing -> Left $ "未知の変数 '" <> T.unpack x <> "'"
+      Neg a -> V.map negate <$> go a
+      App f [a] | Just fn <- lookup f unaryFns -> V.map fn <$> go a
+      App f _   -> Left $ "未対応の関数 '" <> T.unpack f
+                           <> "' (log/exp/sqrt/sin/cos/tan/abs の単項のみ)"
+      Bin op a b -> V.zipWith (binFn op) <$> go a <*> go b
+      Index _ _  -> Left "非線形フィットは factor 添字を扱えません"
+
+unaryFns :: [(Text, Double -> Double)]
+unaryFns =
+  [ ("log", log), ("exp", exp), ("sqrt", sqrt)
+  , ("sin", sin), ("cos", cos), ("tan", tan), ("abs", abs) ]
+
+binFn :: BinOp -> (Double -> Double -> Double)
+binFn Add = (+)
+binFn Sub = (-)
+binFn Mul = (*)
+binFn Div = (/)
+binFn Pow = (**)
+
+-- | 非線形最小二乗。 @inits@ = 各パラメータの初期値 (mfParams を網羅する必要がある)。
+--   SSR を Nelder-Mead で最小化する。 不正値 (NaN) を出すパラメータ域は +∞ で罰する。
+fitNLS :: Formula -> DX.DataFrame -> [(Text, Double)] -> Either String NLSResult
+fitNLS f@(Formula _ _ rhs) df inits = do
+  mf <- modelFrame f df
+  yv <- case mfRoles mf of
+          ((_, RoleResponse v) : _) -> Right v
+          _                         -> Left "ModelFrame に応答列がありません"
+  let pnames  = map fst inits
+      missing = filter (`notElem` pnames) (mfParams mf)
+  if not (null missing)
+    then Left $ "初期値が無いパラメータ: " <> show (map T.unpack missing)
+    else do
+      _ <- evalNL inits mf rhs                       -- 評価可能性を先に検証
+      let sse yhat = V.sum (V.map (\e -> e * e) (V.zipWith (-) yv yhat))
+          ssrAt vals = case evalNL (zip pnames vals) mf rhs of
+                         Right yhat -> let s = sse yhat in if isNaN s then 1 / 0 else s
+                         Left _     -> 1 / 0
+          res  = unsafePerformIO (runNelderMead ssrAt (map snd inits))
+          pm   = zip pnames (orBest res)
+      yhat <- evalNL pm mf rhs
+      let resid = V.zipWith (-) yv yhat
+      Right NLSResult
+        { nlsParams    = pm
+        , nlsFitted    = yhat
+        , nlsResidual  = resid
+        , nlsSSR       = sse yhat
+        , nlsConverged = orConverged res
+        }
diff --git a/src/Hanalyze/Model/Formula/RFormula.hs b/src/Hanalyze/Model/Formula/RFormula.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Model/Formula/RFormula.hs
@@ -0,0 +1,268 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+-- |
+-- Module      : Hanalyze.Model.Formula.RFormula
+-- Description : Formula DSL の R/patsy 互換 front-end (@y ~ x + C(g)@ 構文)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Formula DSL — R/patsy front-end (A18)。 @y ~ x + C(g)@ 形式を **同じ 'Formula' AST**
+--   に落とす (サブ front-end)。 正本は独自構文 (A15)、 本モジュールは互換・オラクル用途。
+--
+--   ★dispatch: 文字列に @~@ が含まれれば R、 無ければ独自 ('parseModel')。 @~@ と @=@ は
+--   字句的に分離ゆえ曖昧性ゼロ。
+--
+--   ★R formula 意味論 → 我々の AST:
+--     - @~@ で 応答 / 予測子 を分離。 予測子は @+@ 区切り (これは「項追加」、 算術でない)。
+--     - 暗黙の切片あり。 @-1@ / @0@ で切片除去。
+--     - 連続変数 @x@ → @b*x@ (本物の積)。 ★categorical は **@C(g)@** で明示
+--       (patsy 同様。 data 無しで parse するため列型推論はしない)。
+--     - @a:b@ = 交互作用のみ、 @a*b@ = @a + b + a:b@ (crossing)。
+--     - @I(expr)@ = 算術 (@x**2@/@x^2@ 等)、 @log(x)@ = 関数変換、 @poly(x,n)@/@bs(x,n)@ = 基底。
+--   ★パラメータ名は合成 (@_p0,_p1,…@)。 線形 OLS では係数名は fit に無関係ゆえ問題なし。
+--   ★data 変数は RHS に現れた変数名 (合成パラメータ以外) を収集。
+--
+--   plot 非依存・portable (AST のみ依存)。
+module Hanalyze.Model.Formula.RFormula
+  ( parseRFormula
+  , parseModel
+  ) where
+
+import           Control.Monad.Combinators.Expr (Operator (..), makeExprParser)
+import           Data.List                      (isPrefixOf, nub, subsequences)
+import           Data.Text                      (Text)
+import qualified Data.Text                      as T
+import           Data.Void                      (Void)
+import           Text.Megaparsec
+import           Text.Megaparsec.Char           (alphaNumChar, char, letterChar,
+                                                 space1)
+import qualified Text.Megaparsec.Char.Lexer     as L
+
+import           Hanalyze.Model.Formula          (BinOp (..), Formula (..),
+                                                  Term (..), parseFormula)
+
+-- ============================================================================
+-- dispatch
+-- ============================================================================
+
+-- | front-end 自動判別: @~@ を含めば R、 さもなくば独自構文。
+parseModel :: Text -> Either String Formula
+parseModel t
+  | T.any (== '~') t = parseRFormula t
+  | otherwise        = parseFormula t
+
+-- ============================================================================
+-- 字句
+-- ============================================================================
+
+type P = Parsec Void Text
+
+sc :: P ()
+sc = L.space space1 empty empty
+
+lexeme :: P a -> P a
+lexeme = L.lexeme sc
+
+symbol :: Text -> P Text
+symbol = L.symbol sc
+
+ident :: P Text
+ident = lexeme $ do
+  c  <- letterChar <|> char '_'
+  cs <- many (alphaNumChar <|> char '_' <|> char '.')
+  pure (T.pack (c : cs))
+
+intLit :: P Int
+intLit = lexeme (L.signed (pure ()) L.decimal)
+
+numLit :: P Double
+numLit = lexeme (try (L.signed (pure ()) L.float)
+                 <|> (fromIntegral <$> L.signed (pure ()) (L.decimal :: P Integer)))
+
+parens :: P a -> P a
+parens = between (symbol "(") (symbol ")")
+
+-- ============================================================================
+-- 中間表現 (R 項)
+-- ============================================================================
+
+-- | R 項の因子。
+data RFactor
+  = RVar  Text             -- ^ 連続変数 x
+  | RCat  Text (Maybe Text) -- ^ C(g) / C(g, Sum) categorical (+ contrast 名)
+  | RFun  Text Term        -- ^ log(x) 等の関数変換 (1 引数)
+  | RI    Term        -- ^ I(expr) 算術
+  | RPoly Text Int    -- ^ poly(x, n)   生べき (x¹..xⁿ)
+  | ROPoly Text Int   -- ^ opoly(x, n)  実測値の直交多項式 (R poly 既定と同じ)
+  | RBs   Text Int    -- ^ bs(x, n)
+
+-- | R 項: 数値 (0/1) か、 因子の積 (hasStar=True なら crossing 展開)。
+data RComp = RNum Int | RProd Bool [RFactor]
+
+-- ============================================================================
+-- パーサ
+-- ============================================================================
+
+-- | @lhs ~ rhs@。
+pRFormula :: P Formula
+pRFormula = do
+  sc
+  lhs   <- ident
+  _     <- symbol "~"
+  comps <- pRHS
+  eof
+  buildFormula lhs comps
+
+-- | RHS = 符号付き項の並び。 戻り値 = (符号, 項)。
+pRHS :: P [(Int, RComp)]
+pRHS = do
+  s0 <- option 1 sign
+  c0 <- pComp
+  rest <- many ((,) <$> sign <*> pComp)
+  pure ((s0, c0) : rest)
+  where sign = (1 <$ symbol "+") <|> ((-1) <$ symbol "-")
+
+-- | 1 項 (数値 or 因子の積)。
+pComp :: P RComp
+pComp =
+      try (RNum <$> lexeme L.decimal)
+  <|> pProduct
+
+-- | 因子を @*@ / @:@ で結んだ積。 @*@ が 1 つでもあれば crossing。
+pProduct :: P RComp
+pProduct = do
+  f0 <- pFactor
+  rest <- many ((,) <$> ((True <$ symbol "*") <|> (False <$ symbol ":")) <*> pFactor)
+  let hasStar = any fst rest
+      facs    = f0 : map snd rest
+  pure (RProd hasStar facs)
+
+pFactor :: P RFactor
+pFactor =
+      try (symbol "C" *> parens pCatArgs)
+  <|> try (RI    <$> (symbol "I"  *> parens pArith))
+  <|> try (ROPoly <$> (symbol "opoly" *> symbol "(" *> ident) <*> (symbol "," *> intLit <* symbol ")"))
+  <|> try (RPoly <$> (symbol "poly" *> symbol "(" *> ident) <*> (symbol "," *> intLit <* symbol ")"))
+  <|> try (RBs   <$> (symbol "bs"   *> symbol "(" *> ident) <*> (symbol "," *> intLit <* symbol ")"))
+  <|> try pFunOrVar
+
+-- | @C(g)@ / @C(g, Sum)@ の中身: factor 名 + 省略可能な contrast 名。
+pCatArgs :: P RFactor
+pCatArgs = do
+  g     <- ident
+  mcode <- optional (symbol "," *> ident)
+  pure (RCat g mcode)
+
+-- | @log(x)@ のような関数変換、 または裸の変数。
+pFunOrVar :: P RFactor
+pFunOrVar = do
+  nm <- ident
+  margs <- optional (parens pArith)
+  pure $ case margs of
+    Just a  -> RFun nm a
+    Nothing -> RVar nm
+
+-- | I(...) 内の算術式 (@+ - * / ^ **@・関数適用・括弧)。
+pArith :: P Term
+pArith = makeExprParser pArithApp
+  [ [ InfixR (Bin Pow <$ (symbol "**" <|> symbol "^")) ]
+  , [ Prefix (Neg     <$ symbol "-") ]
+  , [ InfixL (Bin Mul <$ symbol "*"), InfixL (Bin Div <$ symbol "/") ]
+  , [ InfixL (Bin Add <$ symbol "+"), InfixL (Bin Sub <$ symbol "-") ]
+  ]
+
+pArithApp :: P Term
+pArithApp = do
+  h <- pArithAtom
+  case h of
+    Ref f -> do
+      margs <- optional (parens (pArith `sepBy1` symbol ","))
+      pure $ maybe h (App f) margs
+    _ -> pure h
+
+pArithAtom :: P Term
+pArithAtom =
+      (Lit <$> numLit)
+  <|> parens pArith
+  <|> (Ref <$> ident)
+
+-- ============================================================================
+-- 構築 (中間表現 → Formula AST)
+-- ============================================================================
+
+buildFormula :: Text -> [(Int, RComp)] -> P Formula
+buildFormula lhs comps = do
+  let removeInt = any (\(s, c) -> case c of
+                         RNum 0 -> s == 1            -- + 0
+                         RNum 1 -> s == (-1)         -- - 1
+                         _      -> False) comps
+      prods = [ p | (_, RProd star fs) <- comps, p <- expand star fs ]
+      terms = (if removeInt then [] else [const1]) ++ map prodToTerm prods
+  if null terms
+    then fail "R formula: 項がありません"
+    else do
+      let named   = zipWith (\i mk -> mk (synth i)) [0 :: Int ..] terms
+          rhs     = foldr1 (Bin Add) named
+          dvars   = nub (filter (not . isSynth) (refNamesT rhs))
+      pure (Formula lhs dvars rhs)
+  where
+    synth i  = T.pack ("_p" ++ show i)
+    const1 p = Ref p                                  -- 切片 (定数項)
+
+-- | crossing 展開: @*@ なら全非空部分集合 (R の a*b = a + b + a:b)、 @:@ なら単一交互作用。
+--   列の順序は fit (ŷ) に無関係ゆえ 'subsequences' の順序で可。
+expand :: Bool -> [RFactor] -> [[RFactor]]
+expand False fs = [fs]
+expand True  fs = filter (not . null) (subsequences fs)
+
+-- | 1 つの積 (因子リスト) → パラメータ名を取って Term を作る関数。
+prodToTerm :: [RFactor] -> (Text -> Term)
+prodToTerm facs p =
+  let cats   = [ (nm, mc) | RCat nm mc <- facs ]
+      polys  = [ (nm, n) | RPoly nm n <- facs ]
+      opolys = [ (nm, n) | ROPoly nm n <- facs ]
+      bss    = [ (nm, n) | RBs   nm n <- facs ]
+      datums = concatMap factorData facs
+  in case (polys, opolys, bss) of
+       ((nm, n) : _, _, _) -> Index (Ref p) (App "poly"    [Ref nm, Lit (fromIntegral n)])
+       (_, (nm, n) : _, _) -> Index (Ref p) (App "opoly"   [Ref nm, Lit (fromIntegral n)])
+       (_, _, (nm, n) : _) -> Index (Ref p) (App "bspline" [Ref nm, Lit (fromIntegral n)])
+       _ ->
+         let base = foldl (\acc (nm, mc) -> Index acc (catTerm nm mc)) (Ref p) cats
+         in case datums of
+              []     -> base                          -- 切片 or 純 factor
+              (d:ds) -> Bin Mul base (foldl (Bin Mul) d ds)
+
+-- | categorical 添字項を AST に: @C(g)@ → @Ref g@ (無注釈 treatment)、
+--   @C(g, Sum)@ → @App "C" [Ref g, Ref Sum]@ (contrast 注釈・正本 AST と同形)。
+catTerm :: Text -> Maybe Text -> Term
+catTerm nm Nothing  = Ref nm
+catTerm nm (Just c) = App "C" [Ref nm, Ref c]
+
+-- | 因子のデータ式部分 (連続/関数/I)。 factor/basis はここに出さない。
+factorData :: RFactor -> [Term]
+factorData (RVar x)   = [Ref x]
+factorData (RFun f a) = [App f [a]]
+factorData (RI t)     = [t]
+factorData _          = []
+
+-- | 合成パラメータ名か。
+isSynth :: Text -> Bool
+isSynth n = "_p" `isPrefixOf` T.unpack n
+
+-- | Term 中の Ref 名 (data 変数収集用)。
+refNamesT :: Term -> [Text]
+refNamesT t = case t of
+  Ref x               -> [x]
+  Lit _               -> []
+  App "C" (Ref x : _) -> [x]                     -- contrast 注釈: factor 名のみ (coding 名は除外)
+  App _ as            -> concatMap refNamesT as
+  Index a b           -> refNamesT a ++ refNamesT b
+  Neg a               -> refNamesT a
+  Bin _ a b           -> refNamesT a ++ refNamesT b
+
+-- | 文字列 → 'Formula' (R front-end)。
+parseRFormula :: Text -> Either String Formula
+parseRFormula txt = case parse pRFormula "<r-formula>" txt of
+  Left e  -> Left (errorBundlePretty e)
+  Right f -> Right f
diff --git a/src/Hanalyze/Model/GAM.hs b/src/Hanalyze/Model/GAM.hs
--- a/src/Hanalyze/Model/GAM.hs
+++ b/src/Hanalyze/Model/GAM.hs
@@ -1,78 +1,201 @@
 {-# LANGUAGE OverloadedStrings #-}
--- | Generalized Additive Model (GAM).
+-- |
+-- Module      : Hanalyze.Model.GAM
+-- Description : 一般化加法モデル (Generalized Additive Model, GAM)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
 --
+-- Generalized Additive Model (GAM).
+--
 -- @y = β₀ + Σ_j s_j(x_j) + ε@ where each smooth term @s_j(x_j) = B_j(x_j) γ_j@
--- uses a B-spline basis.
+-- is **linear in its coefficients** for *any* basis @B_j@ (B-spline / natural
+-- cubic / polynomial / Fourier / RBF). The basis is therefore abstracted as
+-- 'GAMBasis' (Phase 70.6 F1); the fit, predict, and per-component paths all
+-- dispatch on the realized basis ('BasisRealized') learned from the training
+-- @x@, so prediction at new points rebuilds the *same* basis matrix.
 --
 -- Design:
 --
---   * For each predictor @x_j@, build a B-spline basis @B_j@ (@n × m_j@).
+--   * For each predictor @x_j@, build a basis matrix @B_j@ (@n × m_j@) per
+--     'GAMBasis'.
 --   * Stack into a single design matrix
 --     @X = [1 | B_1 | B_2 | ... | B_p]@ (@1 + Σ m_j@ columns).
 --   * Ridge-regularized OLS:
---     @β = (XᵀX + λ I)⁻¹ Xᵀ y@. The same @λ@ stabilizes every spline
---     basis (smoothness regularization).
+--     @β = (XᵀX + λ P)⁻¹ Xᵀ y@ with @P = diag(0,1,…,1)@ (intercept免除).
+--     The same @λ@ stabilizes every basis (smoothness regularization).
+--   * @λ@ may be fixed ('FixedL') or chosen by GCV ('GCV') — Phase 70.6 F2.
 --   * Prediction: the per-feature contribution @s_j(x_j)@ can be extracted
 --     individually for visualization of each factor's effect.
 --
--- 注: 識別性のため、各 spline 基底は中央化 (列平均を引く) する。
+-- 注: 識別性のため、各基底は中央化 (列平均を引く) する。
 -- これで β₀ は y の平均、s_j は変動成分のみを表す。
 module Hanalyze.Model.GAM
-  ( GAMFit (..)
+  ( -- * 基底の抽象化 (Phase 70.6 F1)
+    GAMBasis (..)
+  , BasisRealized (..)
+  , GAMLambda (..)
+    -- * フィット結果
+  , GAMFit (..)
+    -- * フィット
   , fitGAM
+  , fitGAMWith
+  , fitGAMAuto
+    -- * 予測
   , predictGAM
+  , predictGAMSE
   , predictGAMComponent
   ) where
 
 import qualified Data.Vector as V
 import qualified Numeric.LinearAlgebra as LA
-import Hanalyze.Model.Spline (bsplineBasis)
+import Hanalyze.Model.Spline (bsplineBasis, naturalSplineBasis, equalSpacedKnots)
 
 -- ---------------------------------------------------------------------------
+-- 基底の抽象化
+-- ---------------------------------------------------------------------------
+
+-- | 平滑項 @s_j(x_j)@ の基底の種類 (係数について線形なものを列挙)。
+--   各々 @x → 基底行列 (n × m)@ を与える。
+data GAMBasis
+  = BSplineB Int Int   -- ^ @BSplineB degree nKnots@: degree 次 B-spline (内部ノット @nKnots@)。
+  | NaturalCubicB Int  -- ^ @NaturalCubicB nKnots@: 自然3次回帰スプライン (内部ノット @nKnots@)。
+  | PolyB Int          -- ^ @PolyB degree@: 直交化なしの多項式 (@[t,t²,…,t^degree]@・@t∈[-1,1]@ にスケール)。
+  | FourierB Int       -- ^ @FourierB nHarmonics@: Fourier 基底 (@sin/cos@ を @nHarmonics@ 次まで)。
+  | RBFB Int Double    -- ^ @RBFB nCenters bandwidthRel@: ガウス RBF (等間隔中心・帯域 = 中心間隔×bandwidthRel)。
+  deriving (Show, Eq)
+
+-- | 学習済み基底。 訓練 @x@ から決まる具体パラメタ (ノット/中心/レンジ) を保持し、
+--   任意の新 @x@ に対し同一の基底行列を再構築できる ('evalBasis')。
+data BasisRealized
+  = RBSpline Int [Double]      -- ^ degree, 内部ノット列。
+  | RNaturalCubic [Double]     -- ^ ノット列。
+  | RPoly Int Double Double    -- ^ degree, xmin, xmax (@t = 2(x−lo)/(hi−lo)−1@ にスケール)。
+  | RFourier Int Double Double -- ^ nHarmonics, xmin, period (@t = (x−lo)/period@)。
+  | RRBF [Double] Double       -- ^ 中心列, 帯域 (絶対値)。
+  deriving (Show)
+
+-- | @λ@ の決め方。 'FixedL' は固定値、 'GCV' は一般化交差検証で 1 次元探索 (Phase 70.6 F2)。
+data GAMLambda
+  = FixedL Double  -- ^ 固定 @λ@ (@0@ で罰則なし)。
+  | GCV            -- ^ GCV @λ* = argmin_λ n·RSS(λ)/(n−edf(λ))²@ を log グリッド探索。
+  deriving (Show, Eq)
+
+-- | 'GAMBasis' を訓練 @x@ で実体化する。
+realizeBasis :: GAMBasis -> V.Vector Double -> BasisRealized
+realizeBasis b xs =
+  let lo = if V.null xs then 0 else V.minimum xs
+      hi = if V.null xs then 1 else V.maximum xs
+  in case b of
+       BSplineB deg nK     -> RBSpline deg (equalSpacedKnots (nK + 2) lo hi)
+       -- 自然3次は基底に ≥3 ノット必要 (端2 + 内部)。 等間隔で nK+2 点 (両端含む)。
+       NaturalCubicB nK    -> RNaturalCubic (equalSpacedKnots (max 3 (nK + 2)) lo hi)
+       PolyB deg           -> RPoly (max 1 deg) lo hi
+       FourierB h          -> RFourier (max 1 h) lo (let p = hi - lo in if p <= 0 then 1 else p)
+       RBFB c bwRel        ->
+         let nc      = max 2 c
+             centers = equalSpacedKnots nc lo hi
+             spacing = if nc < 2 then 1 else (hi - lo) / fromIntegral (nc - 1)
+             bw      = (if spacing <= 0 then 1 else spacing) * (if bwRel <= 0 then 1 else bwRel)
+         in RRBF centers bw
+
+-- | 学習済み基底で新 @x@ の基底行列 (@n × m@・**未中央化**) を作る。
+evalBasis :: BasisRealized -> V.Vector Double -> LA.Matrix Double
+evalBasis br xs = case br of
+  RBSpline deg knots -> bsplineBasis deg knots xs
+  -- naturalSplineBasis は先頭に定数列を含む → GAM は別途切片を持つので落とす。
+  RNaturalCubic knots ->
+    let m = naturalSplineBasis knots xs
+    in if LA.cols m <= 1 then m else m LA.?? (LA.All, LA.Drop 1)
+  RPoly deg lo hi ->
+    let denom = hi - lo
+        t x   = if denom <= 0 then 0 else 2 * (x - lo) / denom - 1
+        row x = [ t x ^^ k | k <- [1 .. deg] ]
+    in LA.fromLists [ row x | x <- V.toList xs ]
+  RFourier h lo period ->
+    let t x   = (x - lo) / period
+        row x = concat [ [ sin (2 * pi * fromIntegral k * t x)
+                         , cos (2 * pi * fromIntegral k * t x) ]
+                       | k <- [1 .. h] ]
+    in LA.fromLists [ row x | x <- V.toList xs ]
+  RRBF centers bw ->
+    let row x = [ exp (negate 0.5 * ((x - c) / bw) ^ (2 :: Int)) | c <- centers ]
+    in LA.fromLists [ row x | x <- V.toList xs ]
+
+-- ---------------------------------------------------------------------------
 -- 型
 -- ---------------------------------------------------------------------------
 
 -- | GAM fit result.
 data GAMFit = GAMFit
-  { gamDegree    :: Int                  -- ^ B-spline degree.
-  , gamKnots     :: [[Double]]           -- ^ Per-feature interior knots.
+  { gamDegree    :: Int                  -- ^ (後方互換) 先頭 B-spline 項の degree。非 B-spline は 0。
+  , gamKnots     :: [[Double]]           -- ^ (後方互換) 項ごとのノット列。ノットを持たない基底は @[]@。
+  , gamBases     :: [BasisRealized]      -- ^ ★評価の正典: 項ごとの学習済み基底。
   , gamBetas     :: [LA.Vector Double]   -- ^ Per-feature spline coefficients @γ_j@.
   , gamColMeans  :: [LA.Vector Double]   -- ^ Per-feature column means of @B_j@ (for centering).
   , gamIntercept :: Double               -- ^ Intercept @β₀@.
   , gamYHat      :: LA.Vector Double     -- ^ Fitted values.
   , gamResid     :: LA.Vector Double     -- ^ Residuals.
   , gamR2        :: Double               -- ^ R².
-  , gamLambda    :: Double               -- ^ Ridge penalty @λ@ used.
+  , gamLambda    :: Double               -- ^ Ridge penalty @λ@ used (GCV のときは選ばれた値)。
+  , gamEdf       :: Double               -- ^ 有効自由度 @tr(S_λ)@ (GCV 用)。
+  , gamCov       :: LA.Matrix Double     -- ^ 係数共分散 @Vβ = (XᵀX+λP)⁻¹·φ̂@
+                                         --   (mgcv 流 Bayesian CI 用・@φ̂ = RSS/(n−edf)@)。
   } deriving (Show)
 
 -- ---------------------------------------------------------------------------
 -- フィット
 -- ---------------------------------------------------------------------------
 
--- | Fit a GAM.
+-- | Fit a GAM (B-spline 基底固定の薄ラッパ・後方互換)。
 fitGAM :: Int                    -- ^ B-spline degree (3 = cubic recommended).
        -> Int                    -- ^ Number of interior knots (e.g. 5).
        -> Double                 -- ^ Ridge penalty @λ@ (0 disables regularization).
        -> [V.Vector Double]      -- ^ Predictors @[x₁, x₂, …]@.
        -> V.Vector Double        -- ^ Response @y@.
        -> GAMFit
-fitGAM degree nKnots lambda xss y =
-  let n         = V.length y
-      -- 各列のノット (両端含めて nKnots+2 点、等間隔)
-      mkKnots xs =
-        let lo = V.minimum xs
-            hi = V.maximum xs
-            -- 両端 + 内部 nKnots 点 = nKnots + 2 個 (B-spline には clamping)
-            step = (hi - lo) / fromIntegral (nKnots + 1)
-        in [ lo + fromIntegral i * step | i <- [0 .. nKnots + 1] ]
-      knotsList = map mkKnots xss
+fitGAM degree nKnots lambda xss =
+  fitGAMWith [ BSplineB degree nKnots | _ <- xss ] lambda xss
 
+-- | Fit a GAM with per-term基底を明示 + 固定 @λ@ (Phase 70.6 F1)。
+fitGAMWith :: [GAMBasis]          -- ^ 項ごとの基底 (長さ = 予測子数)。
+           -> Double              -- ^ Ridge penalty @λ@.
+           -> [V.Vector Double]   -- ^ Predictors.
+           -> V.Vector Double     -- ^ Response @y@.
+           -> GAMFit
+fitGAMWith bases lambda xss y =
+  let realized = zipWith realizeBasis bases xss
+  in fitCore realized lambda xss y
+
+-- | Fit a GAM choosing @λ@ via 'GAMLambda' (FixedL / GCV) (Phase 70.6 F2)。
+fitGAMAuto :: [GAMBasis] -> GAMLambda -> [V.Vector Double] -> V.Vector Double -> GAMFit
+fitGAMAuto bases lam xss y =
+  let realized = zipWith realizeBasis bases xss
+  in case lam of
+       FixedL l -> fitCore realized l xss y
+       GCV      ->
+         let grid = [ 10 ** e | e <- [(-4.0), (-3.5) .. 4.0 :: Double] ]
+             score l = gamGCV (fitCore realized l xss y)
+             best = snd (minimum [ (score l, l) | l <- grid ])
+         in fitCore realized best xss y
+
+-- | GCV 値 @n·RSS/(n−edf)²@ (小さいほど良い)。
+gamGCV :: GAMFit -> Double
+gamGCV fit =
+  let n   = fromIntegral (LA.size (gamResid fit)) :: Double
+      rss = LA.sumElements (LA.cmap (^ (2 :: Int)) (gamResid fit))
+      den = n - gamEdf fit
+  in if den <= 1e-9 then 1/0 else n * rss / (den * den)
+
+-- | 学習済み基底列 + 固定 @λ@ で最小二乗を解く中核。
+fitCore :: [BasisRealized] -> Double -> [V.Vector Double] -> V.Vector Double -> GAMFit
+fitCore realized lambda xss y =
+  let n         = V.length y
       -- 各 B_j (n × m_j) を構築 + 列平均で中央化
-      basisRaw = zipWith (\k xs -> bsplineBasis degree k xs) knotsList xss
-      colMeans = [ LA.fromList
-                     [ LA.sumElements (LA.flatten (b LA.¿ [j])) / fromIntegral n
-                     | j <- [0 .. LA.cols b - 1] ]
-                 | b <- basisRaw ]
+      basisRaw  = zipWith evalBasis realized xss
+      colMeans  = [ LA.fromList
+                      [ LA.sumElements (LA.flatten (b LA.¿ [j])) / fromIntegral n
+                      | j <- [0 .. LA.cols b - 1] ]
+                  | b <- basisRaw ]
       basisCent = zipWith centerCols basisRaw colMeans
 
       -- 統合計画行列 X = [1 | B_1 | B_2 | ...]
@@ -83,10 +206,15 @@
 
       -- Ridge: β = (XᵀX + λ I')⁻¹ Xᵀ y  (intercept 列はペナルティ免除)
       pen  = LA.diag (LA.fromList (0 : replicate (p - 1) lambda))
-      xtx  = LA.tr x LA.<> x + pen
+      xtx  = LA.tr x LA.<> x
+      lhs  = xtx + pen
+      lhsInv = LA.inv lhs                -- (XᵀX+λP)⁻¹ (edf と Vβ で共用)
       xty  = LA.tr x LA.#> yLA
-      beta = LA.flatten (xtx LA.<\> LA.asColumn xty)
+      beta = lhsInv LA.#> xty
 
+      -- 有効自由度 edf = tr(S_λ) = tr((XᵀX+λP)⁻¹ XᵀX)
+      edf  = sumDiag (lhsInv LA.<> xtx)
+
       -- intercept = β[0]、各特徴の γ_j を切り出す
       mSizes = [ LA.cols b | b <- basisRaw ]
       starts = scanl (+) 1 mSizes        -- intercept は 0
@@ -100,9 +228,14 @@
       tss   = LA.sumElements (LA.cmap (\v -> (v - yMean) ^ (2 :: Int)) yLA)
       rss   = LA.sumElements (LA.cmap (^ (2 :: Int)) resid)
       r2    = if tss < 1e-12 then 0 else 1 - rss / tss
+      -- CI 用係数共分散 Vβ = (XᵀX+λP)⁻¹·φ̂ (mgcv 流 Bayesian・φ̂ = RSS/(n−edf))。
+      dfRes = fromIntegral n - edf
+      phi   = if dfRes > 1e-9 then rss / dfRes else rss
+      cov   = LA.scale phi lhsInv
   in GAMFit
-       { gamDegree    = degree
-       , gamKnots     = knotsList
+       { gamDegree    = case realized of { (RBSpline d _ : _) -> d; _ -> 0 }
+       , gamKnots     = map knotsOf realized
+       , gamBases     = realized
        , gamBetas     = betas
        , gamColMeans  = colMeans
        , gamIntercept = intercept
@@ -110,6 +243,8 @@
        , gamResid     = resid
        , gamR2        = r2
        , gamLambda    = lambda
+       , gamEdf       = edf
+       , gamCov       = cov
        }
   where
     -- 列平均を引いて中央化
@@ -119,6 +254,12 @@
           centered = zipWith (\c muVal -> LA.cmap (\v -> v - muVal) c)
                        cols (LA.toList mu)
       in LA.fromColumns centered
+    sumDiag :: LA.Matrix Double -> Double
+    sumDiag = LA.sumElements . LA.takeDiag
+    knotsOf :: BasisRealized -> [Double]
+    knotsOf (RBSpline _ k)     = k
+    knotsOf (RNaturalCubic k)  = k
+    knotsOf _                  = []
 
 -- ---------------------------------------------------------------------------
 -- 予測
@@ -128,35 +269,61 @@
 predictGAM :: GAMFit -> [V.Vector Double] -> V.Vector Double
 predictGAM fit xss =
   let n = if null xss then 0 else V.length (head xss)
-      contributions = zipWith3 (componentVec fit)
-                        [0 .. length xss - 1]
-                        xss
-                        (gamColMeans fit)
+      contributions = zipWith4 componentVec
+                        (gamBases fit) (gamBetas fit) (gamColMeans fit) xss
       total = foldl' (V.zipWith (+)) (V.replicate n (gamIntercept fit))
                 contributions
   in total
   where
     foldl' f z [] = z
-    foldl' f z (x:xs) = let !z' = f z x in foldl' f z' xs
-    componentVec :: GAMFit -> Int -> V.Vector Double -> LA.Vector Double
-                 -> V.Vector Double
-    componentVec g j xs mu =
-      let b      = bsplineBasis (gamDegree g) (gamKnots g !! j) xs
-          gamma  = gamBetas g !! j
+    foldl' f z (a:as) = let !z' = f z a in foldl' f z' as
+    componentVec :: BasisRealized -> LA.Vector Double -> LA.Vector Double
+                 -> V.Vector Double -> V.Vector Double
+    componentVec br gamma mu xs =
+      let b      = evalBasis br xs
           n'     = LA.rows b
           ys     = b LA.#> gamma
           shiftV = LA.dot mu gamma
       in V.fromList [ ys LA.! i - shiftV | i <- [0 .. n' - 1] ]
 
+-- | Predict + 各評価点の **pointwise standard error** を返す (CI 帯用)。
+--
+--   評価点設計行列 @Xeval = [1 | (B_j − colMean_j) | …]@ を fit と同じ中央化で組み、
+--   @se_i = √(b_i Vβ b_iᵀ)@ ('gamCov' = @Vβ@)。 中心 @μ̂@ は 'predictGAM' と一致する。
+--   信頼水準 → 臨界値 (t) の掛け算は呼び出し側 (描画層) が行う。
+predictGAMSE :: GAMFit -> [V.Vector Double] -> (V.Vector Double, V.Vector Double)
+predictGAMSE fit xss =
+  let nEval     = if null xss then 0 else V.length (head xss)
+      mu        = predictGAM fit xss
+      basisRaw  = zipWith evalBasis (gamBases fit) xss
+      basisCent = zipWith subtractColMeans basisRaw (gamColMeans fit)
+      ones      = LA.asColumn (LA.konst 1 nEval)
+      xEval     = foldl1 (LA.|||) (ones : basisCent)      -- nEval × p
+      m1        = xEval LA.<> gamCov fit                  -- nEval × p
+      varVec    = [ LA.dot rM rX | (rM, rX) <- zip (LA.toRows m1) (LA.toRows xEval) ]
+      se        = map (sqrt . max 0) varVec
+  in (mu, V.fromList se)
+
+-- | 各列から学習時の列平均を引く (評価点を fit と同じ中央化にする)。
+subtractColMeans :: LA.Matrix Double -> LA.Vector Double -> LA.Matrix Double
+subtractColMeans m mu =
+  LA.fromColumns (zipWith (\c muVal -> LA.cmap (subtract muVal) c)
+                          (LA.toColumns m) (LA.toList mu))
+
 -- | The contribution @s_j(x)@ from feature @j@ only (without the intercept).
 predictGAMComponent :: GAMFit -> Int -> V.Vector Double -> V.Vector Double
 predictGAMComponent fit j xs
   | j < 0 || j >= length (gamBetas fit) = V.empty
   | otherwise =
-      let b      = bsplineBasis (gamDegree fit) (gamKnots fit !! j) xs
+      let b      = evalBasis (gamBases fit !! j) xs
           gamma  = gamBetas fit !! j
           mu     = gamColMeans fit !! j
           ys     = b LA.#> gamma
           shiftV = LA.dot mu gamma
           n      = LA.rows b
       in V.fromList [ ys LA.! i - shiftV | i <- [0 .. n - 1] ]
+
+-- 4-引数 zipWith (base に無いので局所定義)。
+zipWith4 :: (a -> b -> c -> d -> e) -> [a] -> [b] -> [c] -> [d] -> [e]
+zipWith4 f (a:as) (b:bs) (c:cs) (d:ds) = f a b c d : zipWith4 f as bs cs ds
+zipWith4 _ _ _ _ _ = []
diff --git a/src/Hanalyze/Model/GARCH.hs b/src/Hanalyze/Model/GARCH.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Model/GARCH.hs
@@ -0,0 +1,222 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE BangPatterns #-}
+-- |
+-- Module      : Hanalyze.Model.GARCH
+-- Description : GARCH(1,1) 条件付き分散モデル (Generalized AutoRegressive Conditional Heteroskedasticity)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- GARCH(1,1) — Generalized AutoRegressive Conditional Heteroskedasticity.
+--
+-- Bollerslev (1986). Models time-varying conditional variance for a
+-- (de-meaned) return series:
+--
+-- @
+--   y_t       = μ + ε_t,   ε_t = σ_t · z_t,   z_t ~ N(0, 1)
+--   σ²_t      = ω + α · ε²_{t-1} + β · σ²_{t-1}
+-- @
+--
+-- Constraints: @ω > 0, α ≥ 0, β ≥ 0, α + β < 1@ (stationarity).
+--
+-- Estimation by quasi-MLE under Gaussian innovations, optimized with
+-- L-BFGS using numeric gradients. The constraints are enforced via a
+-- reparametrization (softplus for ω, a stick-breaking sigmoid pair for
+-- α and β capped at @0.999@).
+--
+-- @
+-- import Hanalyze.Model.GARCH
+--
+-- let fit = fitGARCH ys                    -- GARCH(1,1) on the series
+--     vh  = forecastGARCH fit 10           -- 10-step ahead σ² forecast
+-- @
+--
+-- == Implemented
+--
+--   * 'fitGARCH' (GARCH(1,1) Gaussian QMLE)
+--   * 'forecastGARCH' (h-step-ahead conditional variance)
+module Hanalyze.Model.GARCH
+  ( GARCHFit (..)
+  , fitGARCH
+  , forecastGARCH
+  ) where
+
+import qualified Numeric.LinearAlgebra      as LA
+import qualified Hanalyze.Optim.LBFGS       as LBFGS
+import qualified Hanalyze.Optim.Common      as OC
+import           System.IO.Unsafe           (unsafePerformIO)
+
+-- ---------------------------------------------------------------------------
+-- Types
+-- ---------------------------------------------------------------------------
+
+-- | Fitted GARCH(1,1) model.
+data GARCHFit = GARCHFit
+  { gOmega      :: !Double            -- ^ Unconditional variance offset @ω@.
+  , gAlpha      :: !Double            -- ^ ARCH coefficient @α@.
+  , gBeta       :: !Double            -- ^ GARCH coefficient @β@.
+  , gMu         :: !Double            -- ^ Mean of @y_t@.
+  , gSigma2     :: !(LA.Vector Double) -- ^ In-sample conditional variance @σ²_t@.
+  , gResiduals  :: !(LA.Vector Double) -- ^ In-sample residuals @ε_t = y_t - μ@.
+  , gLogLik     :: !Double            -- ^ Maximized Gaussian log-likelihood.
+  } deriving (Show)
+
+-- ---------------------------------------------------------------------------
+-- Reparametrization helpers
+-- ---------------------------------------------------------------------------
+
+softplus :: Double -> Double
+softplus x
+  | x >  50 = x
+  | x < -50 = exp x
+  | otherwise = log1p (exp x)
+  where log1p z = log (1 + z)
+
+sigmoid :: Double -> Double
+sigmoid x
+  | x >  500 = 1
+  | x < -500 = 0
+  | otherwise = 1 / (1 + exp (-x))
+
+-- | Map unconstrained @(θω, θα, θβ)@ to @(ω, α, β)@.
+unpackParams :: Double -> Double -> Double -> (Double, Double, Double)
+unpackParams t0 t1 t2 =
+  let w   = softplus t0
+      s   = sigmoid t1                -- total persistence ∈ (0, 1)
+      sab = s * 0.999                 -- α + β strictly < 1
+      r   = sigmoid t2                -- α-share of total ∈ (0, 1)
+      a   = sab * r
+      b   = sab * (1 - r)
+  in (w, a, b)
+
+-- Inverse of 'unpackParams' for warm-starting from a feasible point.
+packParams :: Double -> Double -> Double -> (Double, Double, Double)
+packParams w a b =
+  let !sab = a + b
+      !t0  = invSoftplus w
+      !t1  = invSigmoid (sab / 0.999)
+      !r   = if sab > 0 then a / sab else 0.5
+      !t2  = invSigmoid r
+  in (t0, t1, t2)
+  where
+    invSoftplus y
+      | y > 50    = y
+      | otherwise = log (exp y - 1)
+    invSigmoid p =
+      let pc = min 0.99999 (max 1e-5 p)
+      in log (pc / (1 - pc))
+
+-- ---------------------------------------------------------------------------
+-- Recursion
+-- ---------------------------------------------------------------------------
+
+-- | Run the GARCH(1,1) σ² recursion. @σ²_0@ is initialized to the sample
+-- variance of @ε@ (a standard QMLE starting choice; alternatives such as
+-- the unconditional variance @ω/(1-α-β)@ are equivalent in the limit).
+recurseSigma2
+  :: Double            -- ^ ω.
+  -> Double            -- ^ α.
+  -> Double            -- ^ β.
+  -> LA.Vector Double  -- ^ ε.
+  -> LA.Vector Double  -- ^ σ² of same length as ε.
+recurseSigma2 !w !a !b eps =
+  let n      = LA.size eps
+      var0   = LA.dot eps eps / fromIntegral n
+      sig0   = max 1e-12 var0
+      step !s2Prev !ePrev = w + a * ePrev * ePrev + b * s2Prev
+      go !i !s2Prev acc
+        | i >= n   = reverse acc
+        | otherwise =
+            let !s2 = if i == 0
+                       then sig0
+                       else step s2Prev (LA.atIndex eps (i - 1))
+            in go (i + 1) s2 (s2 : acc)
+  in LA.fromList (go 0 0 [])
+
+-- | Negative Gaussian log-likelihood (to be minimized).
+negLL
+  :: LA.Vector Double  -- ^ ε.
+  -> Double            -- ^ ω.
+  -> Double            -- ^ α.
+  -> Double            -- ^ β.
+  -> Double
+negLL eps w a b =
+  let s2 = recurseSigma2 w a b eps
+      n  = LA.size eps
+      ll = sum [ let s = max 1e-12 (LA.atIndex s2 i)
+                     e = LA.atIndex eps i
+                 in log (2 * pi * s) + e * e / s
+               | i <- [0 .. n - 1] ]
+  in 0.5 * ll
+
+-- ---------------------------------------------------------------------------
+-- Fitting
+-- ---------------------------------------------------------------------------
+
+-- | Fit a GARCH(1,1) model to @y@ by Gaussian QMLE. The mean @μ@ is
+-- estimated as the sample mean; ω/α/β are jointly optimized by L-BFGS
+-- with numeric gradients in an unconstrained reparametrization.
+--
+-- Starting values: @α = 0.05@, @β = 0.90@, @ω = (1 - α - β) · Var(ε)@
+-- (so that the unconditional variance matches the sample variance).
+fitGARCH :: LA.Vector Double -> GARCHFit
+fitGARCH y =
+  let n     = LA.size y
+      mu    = LA.sumElements y / fromIntegral n
+      eps   = y - LA.scalar mu
+      var0  = LA.dot eps eps / fromIntegral n
+      a0    = 0.05
+      b0    = 0.90
+      w0    = max 1e-8 ((1 - a0 - b0) * var0)
+      (t00, t10, t20) = packParams w0 a0 b0
+      objL [t0, t1, t2] =
+        let (w, a, b) = unpackParams t0 t1 t2
+        in negLL eps w a b
+      objL _ = error "fitGARCH: expected 3 parameters"
+      cfg   = LBFGS.defaultLBFGSConfig
+      res   = unsafePerformIO (LBFGS.runLBFGSNumeric cfg objL [t00, t10, t20])
+      [t0, t1, t2] = OC.orBest res
+      (w, a, b) = unpackParams t0 t1 t2
+      s2    = recurseSigma2 w a b eps
+  in GARCHFit
+       { gOmega     = w
+       , gAlpha     = a
+       , gBeta      = b
+       , gMu        = mu
+       , gSigma2    = s2
+       , gResiduals = eps
+       , gLogLik    = negate (OC.orValue res)
+       }
+
+-- ---------------------------------------------------------------------------
+-- Forecasting
+-- ---------------------------------------------------------------------------
+
+-- | @h@-step-ahead conditional variance forecast. The recursion is
+--
+-- @
+--   σ²_{T+1} = ω + α · ε²_T + β · σ²_T
+--   σ²_{T+k} = ω + (α + β) · σ²_{T+k-1}    (k ≥ 2)
+-- @
+--
+-- so that the forecast converges to the unconditional variance
+-- @ω / (1 - α - β)@.
+forecastGARCH :: GARCHFit -> Int -> LA.Vector Double
+forecastGARCH fit h
+  | h <= 0    = LA.fromList []
+  | otherwise =
+      let w   = gOmega fit
+          a   = gAlpha fit
+          b   = gBeta fit
+          s2  = gSigma2 fit
+          eps = gResiduals fit
+          n   = LA.size s2
+          sT  = LA.atIndex s2 (n - 1)
+          eT  = LA.atIndex eps (n - 1)
+          s1  = w + a * eT * eT + b * sT
+          go !k !prev
+            | k > h     = []
+            | k == 1    = s1 : go 2 s1
+            | otherwise =
+                let !nxt = w + (a + b) * prev
+                in nxt : go (k + 1) nxt
+      in LA.fromList (go 1 0)
diff --git a/src/Hanalyze/Model/GLM.hs b/src/Hanalyze/Model/GLM.hs
--- a/src/Hanalyze/Model/GLM.hs
+++ b/src/Hanalyze/Model/GLM.hs
@@ -1,7 +1,13 @@
 {-# LANGUAGE StrictData #-}
 {-# LANGUAGE OverloadedStrings #-}
--- | Generalized Linear Models fit by Iteratively Reweighted Least Squares.
+-- |
+-- Module      : Hanalyze.Model.GLM
+-- Description : IRLS による一般化線形モデル (Generalized Linear Models)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
 --
+-- Generalized Linear Models fit by Iteratively Reweighted Least Squares.
+--
 -- Provides Gaussian, Binomial and Poisson families with identity, log,
 -- logit and sqrt link functions. 'runIRLS' returns both a 'FitResult' and
 -- the inverse Fisher information @(XᵀWX)⁻¹@ used for standard errors and
@@ -380,19 +386,24 @@
     -- lagged from the standard ll(β_{k+1}) vs ll(β_k) form, which is
     -- equivalent in steady state and avoids any extra μ pass in the
     -- inner loop.
-    (betaFinal, muFinal) = converge maxIter beta0 (glmLogLik family y (muOf beta0))
+    (betaFinal, muFinal) = converge maxIter True beta0 (glmLogLik family y (muOf beta0))
 
-    converge 0 beta _  = (beta, muOf beta)
-    converge n beta llP =
+    -- ★初回反復だけ dLL 判定を無効化する: 'irlsStep' が返す @llHere@ は入力 β での
+    -- @ll(β_k)@ なので、 初回は seed @llP = ll(β0)@ と一致し @dLL = 0 < tol@ で
+    -- IRLS が 1 ステップで早期停止してしまう (= 28d1feb7 の per-iter ll 再利用
+    -- リライトで混入した回帰)。 dB (β-norm) 判定は初回も正しいので残し、 dLL は
+    -- 2 反復目以降 @ll(β_k) vs ll(β_{k-1})@ が揃ってから使う。
+    converge 0 _     beta _  = (beta, muOf beta)
+    converge n first beta llP =
       let (betaNew, _muHere, llHere) = step beta
       in if any notFinite (LA.toList betaNew)
          then (beta, muOf beta)          -- divergence; keep last good β
          else
            let dB  = LA.norm_2 (betaNew - beta)
                dLL = abs (llHere - llP) / max (abs llP) 1
-           in if dB < tol || dLL < tol
+           in if dB < tol || (not first && dLL < tol)
                 then (betaNew, muOf betaNew)   -- final μ pass once
-                else converge (n - 1) betaNew llHere
+                else converge (n - 1) False betaNew llHere
 
     notFinite b = isNaN b || isInfinite b
 
@@ -510,18 +521,24 @@
           PI level ->
             -- Gaussian only: add s²·1 term to CI variance
             let dfStat = fromIntegral (n - p) :: Double
-                s2     = let resV = residualsV res
-                         in (resV `LA.dot` resV) / dfStat
-                tVal   = quantile (studentT dfStat) ((1 + level) / 2)
-                xtxi   = LA.inv (LA.tr dm LA.<> dm)
-                halfW xi = tVal * sqrt (s2 * (1 + xi `LA.dot` (xtxi LA.#> xi)))
                 etaL  = LA.toList etaG
-                lowers = zipWith (\eta xi -> gInv (eta - halfW xi)) etaL gRows
-                uppers = zipWith (\eta xi -> gInv (eta + halfW xi)) etaL gRows
-            in SmoothFit (V.toList xGrid) yGrid lowers uppers True
+            -- df<=0 (飽和) は s²=0/0・studentT が例外 → 帯を線に潰す (lo=hi=ĝ⁻¹(η))。
+            in if dfStat <= 0
+                 then SmoothFit (V.toList xGrid) yGrid (map gInv etaL) (map gInv etaL) True
+                 else
+                   let s2     = let resV = residualsV res
+                                in (resV `LA.dot` resV) / dfStat
+                       tVal   = quantile (studentT dfStat) ((1 + level) / 2)
+                       xtxi   = LA.inv (LA.tr dm LA.<> dm)
+                       halfW xi = tVal * sqrt (s2 * (1 + xi `LA.dot` (xtxi LA.#> xi)))
+                       lowers = zipWith (\eta xi -> gInv (eta - halfW xi)) etaL gRows
+                       uppers = zipWith (\eta xi -> gInv (eta + halfW xi)) etaL gRows
+                   in SmoothFit (V.toList xGrid) yGrid lowers uppers True
 
       ciQuantile level = case family of
-        Gaussian -> quantile (studentT (fromIntegral (n - p))) ((1 + level) / 2)
+        -- 飽和 (df=n-p<=0) は studentT が例外 → 分位点 0 = CI 幅ゼロ (帯を線に潰す)。
+        Gaussian | n - p <= 0 -> 0
+                 | otherwise  -> quantile (studentT (fromIntegral (n - p))) ((1 + level) / 2)
         _        -> quantile (normalDistr 0 1) ((1 + level) / 2)
 
   return (res, mSmooth)
diff --git a/src/Hanalyze/Model/GLMM.hs b/src/Hanalyze/Model/GLMM.hs
--- a/src/Hanalyze/Model/GLMM.hs
+++ b/src/Hanalyze/Model/GLMM.hs
@@ -1,16 +1,32 @@
--- | Linear and generalized linear mixed-effects models.
+-- |
+-- Module      : Hanalyze.Model.GLMM
+-- Description : 線形/一般化線形混合効果モデル (random intercept/slope)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
 --
--- 'fitLME' fits a Gaussian linear mixed-effects model via exact EM.
--- 'fitGLMM' fits a non-Gaussian GLMM via Laplace approximation. Both
--- support per-group random intercepts and slopes. The multi-output
--- variants ('fitLMEMulti', 'fitGLMMMulti') run the algorithm independently
--- per response column.
+-- Linear and generalized linear mixed-effects models.
+--
+-- 'fitLME' / 'fitGLMM' fit a __random-intercept__ mixed model: a single
+-- scalar random effect per group (variance @σ²_u@, scalar BLUP @û_j@).
+-- 'fitLME' is Gaussian via exact EM; 'fitGLMM' is non-Gaussian via Laplace.
+--
+-- 'fitLMEGeneral' / 'fitGLMMGeneral' (Phase 48) generalise to __vector
+-- random effects__ (random intercept + slopes): a per-group design block
+-- @Z_j@ with an @r×r@ covariance matrix @G@ and a vector BLUP @b̂_j@. With
+-- @r = 1@ (intercept only) they reduce exactly to 'fitLME' / 'fitGLMM'.
+--
+-- The multi-output variants ('fitLMEMulti', 'fitGLMMMulti') run the
+-- random-intercept algorithm independently per response column.
 module Hanalyze.Model.GLMM
   ( GLMMResult (..)
   , fitLME
   , fitGLMM
   , fitLMEDataFrame
   , fitGLMMDataFrame
+    -- * General random effects (intercept + slope; Phase 48)
+  , GLMMResultRE (..)
+  , fitLMEGeneral
+  , fitGLMMGeneral
     -- * Multi-output (per-column EM/Laplace; Family/Link shared)
   , GLMMResultMulti (..)
   , fitLMEMulti
@@ -18,6 +34,8 @@
     -- * Standard errors (request/100)
   , glmmFixedSE
   , glmmBLUPSE
+    -- * Group helper (shared with Formula.Mixed)
+  , buildGroups
   ) where
 
 import qualified DataFrame.Internal.DataFrame as DXD
@@ -54,6 +72,27 @@
                                      --   approximation otherwise).
   } deriving (Show)
 
+-- | Fit result for a __general__ mixed model with vector random effects
+--   (random intercept + slopes), Phase 48.
+--
+--   * LME (Gaussian):     @y_j = X_j β + Z_j b_j + ε_j@, @b_j ~ N(0, G)@,
+--     @ε_i ~ N(0, σ²)@, where @Z_j@ is the per-group random-effect design
+--     block (@n_j × r@) and @G@ is the @r×r@ random-effect covariance.
+--   * GLMM (non-Gaussian): @g(E[y|b]) = X_j β + Z_j b_j@, @b_j ~ N(0, G)@.
+--
+--   With @r = 1@ and an intercept-only @Z@ this reduces exactly to the
+--   scalar 'GLMMResult' (@reRandCov = [[σ²_u]]@, @reBLUPs@ a single column).
+data GLMMResultRE = GLMMResultRE
+  { reFixed    :: FitResult        -- ^ Fixed-effect fit (β, conditional
+                                   --   fitted values, residuals, R²).
+  , reRandCov  :: LA.Matrix Double -- ^ Random-effect covariance @G@ (@r×r@).
+  , reResidVar :: Double           -- ^ Residual variance @σ²@ (1.0 for
+                                   --   non-Gaussian families).
+  , reBLUPs    :: LA.Matrix Double -- ^ BLUPs @b̂@ as a @q×r@ matrix (row j =
+                                   --   group j, aligned with 'reGroups').
+  , reGroups   :: V.Vector Text    -- ^ Sorted unique group labels (length q).
+  } deriving (Show)
+
 -- ---------------------------------------------------------------------------
 -- Group helpers (shared by LME and GLMM)
 -- ---------------------------------------------------------------------------
@@ -159,6 +198,120 @@
   in GLMMResult fitRes su2F s2F uF labels icc
 
 -- ---------------------------------------------------------------------------
+-- General random effects (intercept + slope): vector EM for Gaussian LME
+-- ---------------------------------------------------------------------------
+
+-- | Fit a Gaussian LME with __vector__ random effects via EM (ML), Phase 48.
+--
+-- Per group @j@ the model is @y_j = X_j β + Z_j b_j + ε_j@ with
+-- @b_j ~ N(0, G)@ (@G@ is @r×r@) and @ε ~ N(0, σ²I)@. The @Z@ argument holds
+-- the raw random-effect design columns (usually a sub-block of @X@, e.g. the
+-- intercept column plus the slope column for @(1+x|g)@); rows align with @X@.
+--
+-- EM (Laird-Ware), each step given @(β, G, σ²)@:
+--
+--   * E-step (per group, @r×r@): @P_j = (G⁻¹ + Z_jᵀZ_j/σ²)⁻¹@,
+--     @b̂_j = P_j Z_jᵀ r_j / σ²@ with @r_j = y_j − X_j β@.
+--   * M-step: @β = (XᵀX)⁻¹Xᵀ(y − Zb̂)@,
+--     @G = (1/q) Σ_j (P_j + b̂_j b̂_jᵀ)@,
+--     @σ² = (1/n)[Σ‖y_j − X_j β − Z_j b̂_j‖² + Σ tr(Z_jᵀZ_j P_j)]@.
+--
+-- With @r = 1@ and an intercept-only @Z@ this reproduces 'fitLME' exactly.
+-- All linear algebra is hmatrix-native (no list-based fallbacks).
+--
+-- TODO (Phase 48 follow-up): this is ML; a REML variant would correct the
+-- variance estimates for the fixed-effect degrees of freedom.
+fitLMEGeneral
+  :: LA.Matrix Double  -- ^ X (fixed-effect design, must include intercept)
+  -> LA.Matrix Double  -- ^ Z (random-effect design, @n × r@; rows align with X)
+  -> LA.Vector Double  -- ^ y
+  -> V.Vector Int      -- ^ per-observation group index (0-based)
+  -> V.Vector Text     -- ^ sorted group labels (length q)
+  -> GLMMResultRE
+fitLMEGeneral x z y idx labels =
+  let n       = LA.rows x
+      q       = V.length labels
+      r       = LA.cols z
+      members = precompMembers idx q n
+      zRows   = V.fromList (LA.toRows z)         -- O(1) per-row access for scatter
+
+      -- per-group X_j, Z_j, y_j (and Z_jᵀZ_j) precomputed once
+      groupBlk j =
+        let mem = members V.! j
+            xj  = x LA.? mem
+            zj  = z LA.? mem
+            yj  = LA.fromList [ y `LA.atIndex` i | i <- mem ]
+            ztz = LA.tr zj LA.<> zj
+        in (xj, zj, yj, ztz)
+      blocks = V.fromList [ groupBlk j | j <- [0..q-1] ]
+
+      -- initial values: OLS fixed fit, residual variance split intercept/resid
+      beta0 = LA.flatten (x LA.<\> LA.asColumn y)
+      yMean = LA.sumElements y / fromIntegral n
+      yDev  = y - LA.konst yMean n
+      ssTot = yDev `LA.dot` yDev
+      varY  = ssTot / fromIntegral n
+      g0    = LA.scale (varY / 2) (LA.ident r)
+      s20   = varY / 2
+
+      -- scatter (Zb̂)_i = Z_i · b̂_{g(i)}
+      scatterZb bhats =
+        LA.fromList [ (zRows V.! i) `LA.dot` (bhats V.! (idx V.! i)) | i <- [0..n-1] ]
+
+      emStep (beta, gMat, s2) =
+        let gInv  = LA.inv gMat
+            -- E-step: posterior cov P_j and mean b̂_j per group
+            pbs   = V.map (\(xj, zj, yj, ztz) ->
+                      let rj  = yj - xj LA.#> beta
+                          pj  = LA.inv (gInv + LA.scale (1/s2) ztz)
+                          bj  = LA.scale (1/s2) (pj LA.#> (LA.tr zj LA.#> rj))
+                      in (pj, bj, ztz)) blocks
+            bhats = V.map (\(_, bj, _) -> bj) pbs
+            zb    = scatterZb bhats
+            -- M-step β
+            betaN = LA.flatten (x LA.<\> LA.asColumn (y - zb))
+            -- M-step G = (1/q) Σ (P_j + b̂_j b̂_jᵀ)
+            gAcc  = V.foldl' (\acc (pj, bj, _) -> acc + pj + LA.outer bj bj)
+                             (LA.konst 0 (r, r)) pbs
+            gN    = LA.scale (1 / fromIntegral q) gAcc
+            -- M-step σ²: conditional residuals (using updated β) + trace term
+            zbN   = scatterZb bhats
+            r1    = y - x LA.#> betaN - zbN
+            trc   = V.sum (V.map (\(pj, _, ztz) -> LA.sumElements (ztz * pj)) pbs)
+            s2N   = max 1e-10 $ (r1 `LA.dot` r1 + trc) / fromIntegral n
+        in (betaN, gN, s2N)
+
+      converge 0 st            = st
+      converge k st@(b, gM, s) =
+        let st'@(b', gM', s') = emStep st
+        in if    LA.norm_2 (b' - b)            < emTol
+              && LA.norm_2 (LA.flatten (gM' - gM)) < emTol
+              && abs (s' - s)                   < emTol
+           then st'
+           else converge (k-1) st'
+
+      (betaF, gF, s2F) = converge maxEmIter (beta0, g0, s20)
+
+      -- final BLUPs and conditional fit
+      gInvF  = LA.inv gF
+      bhatsF = V.map (\(xj, zj, yj, ztz) ->
+                 let rj = yj - xj LA.#> betaF
+                     pj = LA.inv (gInvF + LA.scale (1/s2F) ztz)
+                 in LA.scale (1/s2F) (pj LA.#> (LA.tr zj LA.#> rj))) blocks
+      zbF     = scatterZb bhatsF
+      fittedV = x LA.#> betaF + zbF
+      residV  = y - fittedV
+      ssResF  = residV `LA.dot` residV
+      r2      = if ssTot == 0 then 1.0 else 1.0 - ssResF / ssTot
+      fitRes  = FitResult (LA.asColumn betaF)
+                          (LA.asColumn fittedV)
+                          (LA.asColumn residV)
+                          (LA.fromList [r2])
+      blupMat = LA.fromRows (V.toList bhatsF)   -- q×r
+
+  in GLMMResultRE fitRes gF s2F blupMat labels
+
+-- ---------------------------------------------------------------------------
 -- Laplace approximation for non-Gaussian GLMM
 -- ---------------------------------------------------------------------------
 
@@ -362,6 +515,151 @@
                             (LA.fromList [r2])
 
   in GLMMResult fitRes su2F 1.0 uF labels icc
+
+-- ---------------------------------------------------------------------------
+-- General random effects (intercept + slope): vector Laplace for GLMM
+-- ---------------------------------------------------------------------------
+
+-- | Multivariate inner Newton-Raphson: find the conditional mode @b̂_j@ of one
+-- group and return @(b̂_j, P_j)@ where @P_j = (Σ_i w_i z_i z_iᵀ + G⁻¹)⁻¹@ is
+-- the Laplace posterior covariance at the mode.
+--
+-- Maximises @Q_j(b) = Σ_i log p(y_i | g⁻¹(η_i + z_iᵀ b)) − ½ bᵀ G⁻¹ b@.
+-- Newton step solves @H δ = grad@ with
+-- @grad = Σ_i s_i z_i − G⁻¹ b@, @H = Σ_i w_i z_i z_iᵀ + G⁻¹@.
+nrOneGroupVec
+  :: Family -> LinkFn
+  -> LA.Matrix Double    -- ^ G⁻¹ (r×r)
+  -> [LA.Vector Double]  -- ^ z_i rows for this group (each length r)
+  -> [Double]            -- ^ etaFixed_i = (X_i β)
+  -> [Double]            -- ^ y_i
+  -> LA.Vector Double    -- ^ initial b (length r)
+  -> (LA.Vector Double, LA.Matrix Double)
+nrOneGroupVec family link gInv zs etaFixed ys = go maxNRIter
+  where
+    clamp = glmmClampMu family
+    gInvL = glmmInvLink link
+    r     = LA.rows gInv
+
+    -- negative Hessian (= posterior precision) at b: Σ_i w_i z_i z_iᵀ + G⁻¹
+    hessAt b =
+      let etas = zipWith (\z ef -> ef + z `LA.dot` b) zs etaFixed
+          mus  = map (clamp . gInvL) etas
+          ws   = map (glmmWeight family link) mus
+      in foldr (\(w, z) acc -> acc + LA.scale w (LA.outer z z)) gInv (zip ws zs)
+
+    go 0 b = (b, LA.inv (hessAt b))
+    go k b =
+      let etas  = zipWith (\z ef -> ef + z `LA.dot` b) zs etaFixed
+          mus   = map (clamp . gInvL) etas
+          ss    = zipWith (glmmScore family link) ys mus
+          ws    = map (glmmWeight family link) mus
+          grad  = foldr (\(s, z) acc -> acc + LA.scale s z) (LA.konst 0 r) (zip ss zs)
+                    - (gInv LA.#> b)
+          hess  = foldr (\(w, z) acc -> acc + LA.scale w (LA.outer z z)) gInv (zip ws zs)
+          delta = LA.flatten (hess LA.<\> LA.asColumn grad)
+          b'    = b + delta
+      in if LA.norm_2 delta < nrTol then (b', LA.inv hess) else go (k-1) b'
+
+-- | Fit a non-Gaussian GLMM with __vector__ random effects via Laplace
+-- approximation (Phase 48). Per group @j@: @g(E[y|b]) = X_j β + Z_j b_j@,
+-- @b_j ~ N(0, G)@ (@G@ is @r×r@). Outer loop: multivariate NR for the modes
+-- @b̂_j@ (with Laplace posterior cov @P_j@), one IRLS step for @β@ (random
+-- effects as offset), and an EM update @G = (1/q) Σ_j (P_j + b̂_j b̂_jᵀ)@.
+--
+-- With @r = 1@ and an intercept-only @Z@ this matches 'fitGLMM'. Supports the
+-- same families/links as 'fitGLMM' (Binomial/Logit, Poisson/Log).
+fitGLMMGeneral
+  :: Family -> LinkFn
+  -> LA.Matrix Double  -- ^ X (fixed-effect design, must include intercept)
+  -> LA.Matrix Double  -- ^ Z (random-effect design, @n × r@; rows align with X)
+  -> LA.Vector Double  -- ^ y
+  -> V.Vector Int      -- ^ per-observation group index (0-based)
+  -> V.Vector Text     -- ^ sorted group labels (length q)
+  -> GLMMResultRE
+fitGLMMGeneral family link x z y idx labels =
+  let n       = LA.rows x
+      p       = LA.cols x
+      q       = V.length labels
+      r       = LA.cols z
+      members = precompMembers idx q n
+      zRows   = V.fromList (LA.toRows z)
+      yV      = V.fromList (LA.toList y)
+
+      groupZs = V.fromList [ [ zRows V.! i | i <- members V.! j ] | j <- [0..q-1] ]
+      groupYs = V.fromList [ [ yV    V.! i | i <- members V.! j ] | j <- [0..q-1] ]
+
+      clamp = glmmClampMu family
+      gInvL = glmmInvLink link
+      gD    = glmmLinkDeriv link
+
+      yMean = LA.sumElements y / fromIntegral n
+      ySafe = case family of
+                Binomial -> max 1e-6 (min (1-1e-6) yMean)
+                Poisson  -> max 1e-6 yMean
+                Gaussian -> yMean
+      beta0 = LA.fromList (glmmFwdLink link ySafe : replicate (p - 1) 0.0)
+      b0    = V.replicate q (LA.konst 0 r)
+      yDev  = y - LA.konst yMean n
+      su2_0 = max 1e-4 ((yDev `LA.dot` yDev) / fromIntegral n / 2)
+      g0    = LA.scale su2_0 (LA.ident r)
+
+      scatterZb bs =
+        LA.fromList [ (zRows V.! i) `LA.dot` (bs V.! (idx V.! i)) | i <- [0..n-1] ]
+
+      step (beta, gMat, bs) =
+        let gInv      = LA.inv gMat
+            xBeta     = x LA.#> beta
+            etaFixedV = V.fromList (LA.toList xBeta)
+            results   = V.fromList
+                          [ nrOneGroupVec family link gInv (groupZs V.! j)
+                              [ etaFixedV V.! i | i <- members V.! j ]
+                              (groupYs V.! j)
+                              (bs V.! j)
+                          | j <- [0..q-1] ]
+            bsNew = V.map fst results
+            pjs   = V.map snd results
+            -- IRLS β with random offset Zb̂ held fixed
+            zb     = scatterZb bsNew
+            etaF   = xBeta + zb
+            musV   = V.map (clamp . gInvL) (V.fromList (LA.toList etaF))
+            wsV    = V.map (glmmWeight family link) musV
+            xBetaV = V.fromList (LA.toList xBeta)
+            zAdjV  = V.zipWith3 (\yi mui xbi -> (yi - mui) * gD mui + xbi) yV musV xBetaV
+            sqrtW  = LA.diag (LA.fromList . V.toList $ V.map sqrt wsV)
+            zAdj   = LA.fromList (V.toList zAdjV)
+            betaN  = LA.flatten $ (sqrtW LA.<> x) LA.<\> LA.asColumn (sqrtW LA.#> zAdj)
+            -- EM update G = (1/q) Σ (P_j + b̂_j b̂_jᵀ)
+            gAcc   = V.foldl' (\acc (pj, bj) -> acc + pj + LA.outer bj bj)
+                              (LA.konst 0 (r, r)) (V.zip pjs bsNew)
+            gN     = LA.scale (1 / fromIntegral q) gAcc
+        in (betaN, gN, bsNew)
+
+      bsDiff a b = V.sum (V.zipWith (\u v -> LA.norm_2 (u - v)) a b)
+      converge 0 st                = st
+      converge k st@(beta, gM, bs) =
+        let st'@(beta', gM', bs') = step st
+        in if    LA.norm_2 (beta' - beta)                  < glmmTol
+              && LA.norm_2 (LA.flatten (gM' - gM))          < glmmTol
+              && bsDiff bs' bs                              < glmmTol
+           then st'
+           else converge (k-1) st'
+
+      (betaF, gF, bsF) = converge maxGLMMIter (beta0, g0, b0)
+
+      zbF     = scatterZb bsF
+      fittedV = LA.cmap (clamp . gInvL) (x LA.#> betaF + zbF)
+      residV  = y - fittedV
+      ssTot   = yDev `LA.dot` yDev
+      ssRes   = residV `LA.dot` residV
+      r2      = if ssTot == 0 then 1.0 else 1.0 - ssRes / ssTot
+      fitRes  = FitResult (LA.asColumn betaF)
+                          (LA.asColumn fittedV)
+                          (LA.asColumn residV)
+                          (LA.fromList [r2])
+      blupMat = LA.fromRows (V.toList bsF)
+
+  in GLMMResultRE fitRes gF 1.0 blupMat labels
 
 -- ---------------------------------------------------------------------------
 -- DataFrame-level API
diff --git a/src/Hanalyze/Model/GP.hs b/src/Hanalyze/Model/GP.hs
--- a/src/Hanalyze/Model/GP.hs
+++ b/src/Hanalyze/Model/GP.hs
@@ -1,7 +1,13 @@
 {-# LANGUAGE StrictData #-}
 {-# LANGUAGE OverloadedStrings #-}
--- | Gaussian-process regression.
+-- |
+-- Module      : Hanalyze.Model.GP
+-- Description : ガウス過程回帰 (Gaussian-process regression)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
 --
+-- Gaussian-process regression.
+--
 -- Pick a kernel, fit it to training data and obtain the posterior
 -- predictive at arbitrary test points. Hyperparameters can be tuned
 -- automatically by maximizing the log marginal likelihood.
@@ -21,12 +27,15 @@
 -- -- gpMean res, gpLower res, gpUpper res で結果を取得
 -- @
 module Hanalyze.Model.GP
-  ( -- * カーネル型
+  ( -- * カーネル型 (re-export from "Hanalyze.Model.Kernel")
     Kernel (..)
   , kernelName
+  , KernelParams (..)
+  , defaultKernelParams
     -- * Hyperparameters
   , GPParams (..)
   , defaultGPParams
+  , gpKernelParams
   , initParamsFromData
   , initParamsFromDataMV
     -- * Model and result
@@ -34,12 +43,16 @@
   , GPResult (..)
     -- * Kernel computation
   , kernelFn
+  , kEvalMV
   , buildKernelMatrix
     -- * Inference
   , logMarginalLikelihood
   , fitGP
   , fitGPMulti
   , optimizeGP
+  , gramLOOCV
+  , autoCVHyperGP
+  , autoCVHyperGPMV
     -- * Data for interactive prediction
   , GPPredData (..)
   , gpPredData
@@ -54,7 +67,6 @@
   , optimizeGPMVCached
   ) where
 
-import Data.Text (Text)
 import qualified Numeric.LinearAlgebra as LA
 import qualified Hanalyze.Optim.LBFGS as LBFGS
 import qualified Hanalyze.Optim.Common as OC
@@ -64,31 +76,26 @@
 import qualified Data.Vector.Storable.Mutable as VSM
 import           Control.Monad.ST             (runST)
 import           System.IO.Unsafe             (unsafePerformIO)
+-- 共有カーネル語彙は 'Model.Kernel' (Phase 75.18 で分離)。 GP は後方互換のため
+-- 'Kernel'/'KernelParams'/評価関数を re-export する。
+import           Hanalyze.Model.Kernel
+                   ( Kernel (..), kernelName, KernelParams (..), defaultKernelParams
+                   , kernelFn, buildKernelMatrix, applyKernel, kernelOfParams
+                   , ardScaleXY, buildKernelMatrixMV, kEvalMV )
 
 -- ---------------------------------------------------------------------------
 -- Types
 -- ---------------------------------------------------------------------------
-
--- | GP kernel kind.
-data Kernel
-  = RBF
-    -- ^ Squared exponential: @k(x,x') = σ_f² exp(−r²/(2ℓ²))@.
-    --   Best for smooth functions; the most commonly used kernel.
-  | Matern52
-    -- ^ Matérn 5/2: @k(x,x') = σ_f²(1+√5 r/ℓ+5r²/(3ℓ²)) exp(−√5 r/ℓ)@.
-    --   Slightly rougher than RBF; common in physical systems.
-  | Periodic
-    -- ^ Periodic: @k(x,x') = σ_f² exp(−2 sin²(π r/p)/ℓ²)@.
-    --   For periodic patterns; set 'gpPeriod' appropriately.
-  deriving (Show, Eq)
-
--- | Display name of a kernel.
-kernelName :: Kernel -> Text
-kernelName RBF       = "RBF"
-kernelName Matern52  = "Mat\xe9rn 5/2"
-kernelName Periodic  = "Periodic"
+--
+-- NB: 'Kernel' / 'kernelName' / 'KernelParams' と評価関数群は Phase 75.18 で
+-- 'Hanalyze.Model.Kernel' へ分離。 GP は後方互換のため re-export する
+-- (上の import 参照)。
 
--- | GP hyperparameters.
+-- | GP hyperparameters (= 'KernelParams' + 観測ノイズ σ_n²)。
+--
+-- カーネル系フィールド (ℓ / σ_f² / period / ARD) は 'gpKernelParams' で
+-- 'KernelParams' へ射影でき、 カーネル評価関数 ('kernelFn' / 'kEvalMV' /
+-- 'buildKernelMatrix' 等) はその 'KernelParams' を取る。
 data GPParams = GPParams
   { gpLengthScale  :: Double
     -- ^ Isotropic length scale @ℓ@; larger means smoother. Used unless
@@ -113,6 +120,17 @@
 defaultGPParams :: GPParams
 defaultGPParams = GPParams 1.0 1.0 0.1 1.0 Nothing
 
+-- | Project the kernel hyperparameters of a 'GPParams' onto a
+-- 'KernelParams' (drops the observation noise σ_n²). カーネル評価関数へ
+-- 渡す際に使う。
+gpKernelParams :: GPParams -> KernelParams
+gpKernelParams p = KernelParams
+  { kpLengthScale  = gpLengthScale p
+  , kpSignalVar    = gpSignalVar p
+  , kpPeriod       = gpPeriod p
+  , kpLengthScales = gpLengthScales p
+  }
+
 -- | Build a sensible initial 'GPParams' from data statistics, suitable
 -- as a starting point for optimization.
 initParamsFromData :: [Double] -> [Double] -> GPParams
@@ -171,56 +189,6 @@
   } deriving (Show)
 
 -- ---------------------------------------------------------------------------
--- Kernel
--- ---------------------------------------------------------------------------
-
--- | Evaluate the kernel function @k(x, x')@.
-kernelFn :: Kernel -> GPParams -> Double -> Double -> Double
-kernelFn RBF p x x' =
-  let d = x - x'
-      l = gpLengthScale p
-  in gpSignalVar p * exp (-(d * d) / (2 * l * l))
-kernelFn Matern52 p x x' =
-  let d = abs (x - x')
-      l = gpLengthScale p
-      s = sqrt 5 * d / l
-  in gpSignalVar p * (1 + s + s * s / 3) * exp (-s)
-kernelFn Periodic p x x' =
-  let d = abs (x - x')
-      l = gpLengthScale p
-      s = sin (pi * d / gpPeriod p)
-  in gpSignalVar p * exp (-2 * s * s / (l * l))
-
--- | Build the kernel matrix @K(xs, xs')@ of shape @|xs| × |xs'|@.
---
--- Phase 11b (2026-05-14): rewritten to fill a flat 'Storable.Vector'
--- via @runST + MVector@ instead of materialising the @|xs|·|xs'|@
--- lazy @[Double]@ list that the previous @(n><m) [..]@ form created.
--- The thunk-heavy list cost ~30 MB at @n=768@ purely in cons cells
--- (one allocation per kernel call) and pressured GC; the strict
--- buffer is a single allocation. 'kernelFn' itself is unchanged so
--- 'Periodic' (signed-difference dependent) keeps working.
-buildKernelMatrix :: Kernel -> GPParams -> [Double] -> [Double] -> LA.Matrix Double
-buildKernelMatrix ker p xs xs' =
-  let xv = VS.fromList xs
-      yv = VS.fromList xs'
-      n  = VS.length xv
-      m  = VS.length yv
-      out = runST $ do
-        v <- VSM.unsafeNew (n * m)
-        let go !i !j
-              | i >= n    = pure ()
-              | j >= m    = go (i + 1) 0
-              | otherwise = do
-                  let xi = VS.unsafeIndex xv i
-                      yj = VS.unsafeIndex yv j
-                  VSM.unsafeWrite v (i * m + j) (kernelFn ker p xi yj)
-                  go i (j + 1)
-        go 0 0
-        VS.unsafeFreeze v
-  in LA.reshape m out
-
--- ---------------------------------------------------------------------------
 -- Inference
 -- ---------------------------------------------------------------------------
 
@@ -228,7 +196,7 @@
 noiseKernel :: Kernel -> GPParams -> [Double] -> LA.Matrix Double
 noiseKernel ker p xs =
   let n      = length xs
-      k      = buildKernelMatrix ker p xs xs
+      k      = buildKernelMatrix ker (gpKernelParams p) xs xs
       jitter = max (gpNoiseVar p) 1e-6
   in k `LA.add` LA.scale jitter (LA.ident n)
 
@@ -295,14 +263,14 @@
   let ker    = gpKernel model
       params = gpParams model
       ky     = noiseKernel ker params trainX
-      kStar  = buildKernelMatrix ker params testX trainX  -- (m × n)
+      kStar  = buildKernelMatrix ker (gpKernelParams params) testX trainX  -- (m × n)
       -- α = Ky⁻¹ Y via SPD Cholesky (n × q)
       alpha  = Chol.cholSolveJitter ky trainY
       meanMt = kStar LA.<> alpha                          -- (m × q)
       -- v = Ky⁻¹ K_*ᵀ via the same Cholesky factor (n × m).
       -- Then var_i = k(x*_i, x*_i) − K_*[i,:] · v[:,i].
       v       = Chol.cholSolveJitter ky (LA.tr kStar)
-      diagKss = [kernelFn ker params x x | x <- testX]
+      diagKss = [kernelFn ker (gpKernelParams params) x x | x <- testX]
       -- F1: vectorise diag(kStar · v).
       kStarDotV = LA.toList (KD.diagAB kStar v)
       varList   = zipWith (\d kv -> max 0 (d - kv)) diagKss kStarDotV
@@ -346,6 +314,93 @@
     obj u = logMarginalLikelihood trainX trainY ker (toParams u)
 
 -- ---------------------------------------------------------------------------
+-- LOOCV hyperparameter selection (exact / Gram path) — Phase 70.5 項目 E
+-- ---------------------------------------------------------------------------
+
+-- | Leave-one-out CV (PRESS) for exact kernel-ridge / GP-mean prediction
+-- from a /noiseless/ Gram matrix @K@. Closed form
+-- @PRESS = (1/n) Σ ((yᵢ − ŷᵢ)/(1 − Hᵢᵢ))²@ with @H = K (K + λI)⁻¹@ and
+-- @ŷ = H y@ (no @n@-fold refit). This is the Gram-space analogue of
+-- 'Hanalyze.Model.RFF.loocvFromPhi' (identical PRESS algebra, but
+-- in the @n@-dim Gram space instead of the @D@-dim RFF feature space).
+-- KRR ≡ GP posterior mean with @λ = σ_n²@, so the same routine selects
+-- @λ@ for both the @Ridge@ and @Gp@ quadrants of the unified @gp@ spec.
+gramLOOCV :: LA.Matrix Double   -- ^ Noiseless Gram matrix @K@ (@n × n@).
+          -> LA.Vector Double   -- ^ Targets @y@ (length @n@).
+          -> Double             -- ^ Ridge penalty @λ@ (= @σ_n²@).
+          -> Double
+gramLOOCV k y lam =
+  let n         = LA.rows k
+      regK      = addToDiag lam k                 -- K + λI (SPD)
+      -- H = K (K+λI)⁻¹ = (regK⁻¹ K)ᵀ (K, regK symmetric). Solve once.
+      h         = LA.tr (regK LA.<\> k)
+      yhat      = h LA.#> y
+      hDiag     = LA.takeDiag h
+      oneMinusH = LA.cmap (\hh -> max 1e-12 (1 - hh)) hDiag
+      resid     = y - yhat
+      ratios    = zipWith (/) (LA.toList resid) (LA.toList oneMinusH)
+  in sum [ r * r | r <- ratios ] / fromIntegral (max 1 n)
+
+-- | Pick GP/KRR hyperparameters by minimizing leave-one-out CV (PRESS)
+-- over a log-spaced @(ℓ, λ)@ grid. @σ_f@ is fixed at @std(y)@ (mirroring
+-- 'Hanalyze.Model.RFF.gridSearchLOOCVRBFMV', where @σ_f@ and @λ@
+-- are degenerate and @λ@ absorbs the scale). Returns 'GPParams' with the
+-- selected @ℓ*@, @σ_f² = std(y)²@ and @σ_n² = λ*@ (KRR ≡ GP mean with
+-- @λ = σ_n²@). Used by the @AutoCV@ 'HyperStrategy' for the exact
+-- ('Gp'/'Ridge') quadrants.
+autoCVHyperGP :: Kernel -> [Double] -> [Double] -> GPParams
+autoCVHyperGP ker xs ys =
+  let p0      = initParamsFromData xs ys
+      yStd    = max 1e-9 (sqrt (varOfList ys))
+      ell0    = gpLengthScale p0
+      ellGrid = logSpaceList (ell0 * 0.1)   (ell0 * 10) 10
+      lamGrid = logSpaceList (yStd * 1e-6)  (yStd * 10) 20
+      yV      = LA.fromList ys
+      score ell lam =
+        let pk = p0 { gpLengthScale = ell, gpSignalVar = yStd * yStd }
+            k  = buildKernelMatrix ker (gpKernelParams pk) xs xs
+        in gramLOOCV k yV lam
+      cands = [ (ell, lam, score ell lam) | ell <- ellGrid, lam <- lamGrid ]
+      (bEll, bLam, _) =
+        foldr1 (\a@(_,_,sa) b@(_,_,sb) -> if sa <= sb then a else b) cands
+  in p0 { gpLengthScale = bEll, gpSignalVar = yStd * yStd, gpNoiseVar = bLam }
+
+-- | Multi-input analogue of 'autoCVHyperGP'. Same log-spaced @(ℓ, λ)@
+-- Gram-LOOCV search but builds the kernel from an @n × p@ training
+-- matrix via 'buildKernelMatrixMV' (isotropic; ℓ shared across inputs).
+autoCVHyperGPMV :: Kernel -> LA.Matrix Double -> LA.Vector Double -> GPParams
+autoCVHyperGPMV ker trainX y =
+  let p0      = initParamsFromDataMV trainX y
+      yStd    = max 1e-9 (sqrt (varOfList (LA.toList y)))
+      ell0    = gpLengthScale p0
+      ellGrid = logSpaceList (ell0 * 0.1)  (ell0 * 10) 8
+      lamGrid = logSpaceList (yStd * 1e-6) (yStd * 10) 16
+      score ell lam =
+        let pk = p0 { gpLengthScale = ell, gpSignalVar = yStd * yStd }
+            k  = buildKernelMatrixMV ker (gpKernelParams pk) trainX trainX
+        in gramLOOCV k y lam
+      cands = [ (ell, lam, score ell lam) | ell <- ellGrid, lam <- lamGrid ]
+      (bEll, bLam, _) =
+        foldr1 (\a@(_,_,sa) b@(_,_,sb) -> if sa <= sb then a else b) cands
+  in p0 { gpLengthScale = bEll, gpSignalVar = yStd * yStd, gpNoiseVar = bLam }
+
+-- | Population variance of a list (LOOCV σ_f init).
+varOfList :: [Double] -> Double
+varOfList zs =
+  let n = fromIntegral (length zs)
+      m = sum zs / n
+  in if n <= 0 then 0 else sum [ (z - m) ^ (2 :: Int) | z <- zs ] / n
+
+-- | @n@ points log-spaced in @[lo, hi]@ (inclusive). @lo,hi > 0@.
+logSpaceList :: Double -> Double -> Int -> [Double]
+logSpaceList lo hi n
+  | n <= 1    = [lo]
+  | otherwise = [ exp (logLo + (logHi - logLo) * fromIntegral i / fromIntegral (n - 1))
+                | i <- [0 .. n - 1] ]
+  where logLo = log lo
+        logHi = log hi
+
+-- ---------------------------------------------------------------------------
 -- Interactive prediction data (for Hanalyze.Viz.GPReport)
 -- ---------------------------------------------------------------------------
 
@@ -363,7 +418,7 @@
   let ker    = gpKernel model
       params = gpParams model
       n      = length trainX
-      k      = buildKernelMatrix ker params trainX trainX
+      k      = buildKernelMatrix ker (gpKernelParams params) trainX trainX
       jitter = max (gpNoiseVar params) 1e-6
       ky     = addToDiag jitter k
       -- SPD: solve via Cholesky rather than 'LA.inv'. Equivalent to
@@ -399,69 +454,6 @@
   , gpmvUpper :: LA.Vector Double  -- ^ @mean + 2σ@.
   } deriving (Show)
 
--- | Apply the kernel function to an @m × n@ matrix of squared distances.
--- This is the per-element work that follows BLAS distance computation.
--- F2: per-element kernel evaluation via massiv's @A.map@. Measured
--- 1.7× faster than @LA.cmap@ on 2000×2000 matrices because
--- 'A.computeAs' produces a fused tight loop while @LA.cmap@ pays
--- per-element function-call overhead. Bigger payoff at large @n@
--- (kernel matrices for Kernel/GP are the main hot path).
-applyKernel :: Kernel -> GPParams -> LA.Matrix Double -> LA.Matrix Double
-applyKernel RBF p d2 =
-  let l2 = gpLengthScale p ** 2
-      sf = gpSignalVar p
-  in KD.mapMatrix (\s -> sf * exp (- s / (2 * l2))) d2
-applyKernel Matern52 p d2 =
-  let l  = gpLengthScale p
-      sf = gpSignalVar p
-  in KD.mapMatrix (\s -> let r = sqrt (max 0 s)
-                             u = sqrt 5 * r / l
-                         in sf * (1 + u + u * u / 3) * exp (- u)) d2
-applyKernel Periodic p d2 =
-  let l  = gpLengthScale p
-      sf = gpSignalVar p
-      pr = gpPeriod p
-  in KD.mapMatrix (\s -> let r = sqrt (max 0 s)
-                             ss = sin (pi * r / pr)
-                         in sf * exp (- 2 * ss * ss / (l * l))) d2
-
--- mapMatrix / mapVector は 'Hanalyze.Stat.KernelDist' に集約 (KD.mapMatrix)。
-
--- | Apply ARD scaling to (X, X') if 'gpLengthScales' is 'Just'. Returns
--- the (possibly rescaled) matrices and a 'GPParams' with @ℓ = 1@ so
--- that 'applyKernel' divides by 1 (the per-dim ℓ_d already absorbed
--- into the column scaling). 'Nothing' = isotropic, returns inputs and
--- params unchanged. The 'Periodic' kernel does not support ARD.
-ardScaleXY
-  :: Kernel -> GPParams -> LA.Matrix Double -> LA.Matrix Double
-  -> (LA.Matrix Double, LA.Matrix Double, GPParams)
-ardScaleXY Periodic p x y = (x, y, p)
-ardScaleXY _        p x y = case gpLengthScales p of
-  Nothing -> (x, y, p)
-  Just ls ->
-    let p_     = LA.cols x
-        lsExt  = if LA.size ls == p_
-                   then ls
-                   else LA.konst (gpLengthScale p) p_  -- safety fallback
-        invL   = LA.cmap (1 /) lsExt                 -- 1 / ℓ_d
-        scaleCols m = m LA.<> LA.diag invL
-        x'     = scaleCols x
-        y'     = scaleCols y
-        p'     = p { gpLengthScale = 1.0 }
-    in (x', y', p')
-
--- | Build the kernel matrix @K(X, X')@ of shape @|X| × |X'|@ from
--- multi-input matrices. @X@ is @n × p@; @X'@ is @m × p@.
---
--- When 'gpLengthScales' is 'Just', uses ARD: each input dimension is
--- scaled by @1 / ℓ_d@ before computing pairwise squared distances.
-buildKernelMatrixMV
-  :: Kernel -> GPParams -> LA.Matrix Double -> LA.Matrix Double
-  -> LA.Matrix Double
-buildKernelMatrixMV ker p x x' =
-  let (xs, ys, p') = ardScaleXY ker p x x'
-  in applyKernel ker p' (KD.pairwiseSqDistXY xs ys)
-
 -- | Add a scalar @c@ to the diagonal of a square matrix in one pass.
 --
 -- Replaces the @M + c·I@ pattern (which allocates a fresh @n × n@
@@ -492,33 +484,6 @@
         VS.unsafeFreeze v
   in LA.reshape n out
 
--- | Specialized kernel function for a fixed parameter set, returning a
--- monomorphic @Double -> Double@ that GHC can inline tightly into
--- @mkNoiseKernelFromD2@s inner loop.
-{-# INLINE kernelOfParams #-}
-kernelOfParams :: Kernel -> GPParams -> (Double -> Double)
-kernelOfParams RBF p =
-  let !l2 = gpLengthScale p ** 2
-      !sf = gpSignalVar p
-      !inv2L2 = 1 / (2 * l2)
-  in \s -> sf * exp (- s * inv2L2)
-kernelOfParams Matern52 p =
-  let !l  = gpLengthScale p
-      !sf = gpSignalVar p
-      !invL = sqrt 5 / l
-  in \s -> let r = sqrt (max 0 s)
-               u = invL * r
-           in sf * (1 + u + u * u / 3) * exp (- u)
-kernelOfParams Periodic p =
-  let !l  = gpLengthScale p
-      !sf = gpSignalVar p
-      !pr = gpPeriod p
-      !invL2 = 1 / (l * l)
-      !invPr = pi / pr
-  in \s -> let r  = sqrt (max 0 s)
-               ss = sin (invPr * r)
-           in sf * exp (- 2 * ss * ss * invL2)
-
 -- | Build the noise-augmented kernel matrix @K + jitter·I@ in a single
 -- pass over the squared-distance matrix.
 --
@@ -531,7 +496,7 @@
 -- 35.3% of @optimizeGPMV@; halving its allocation footprint translates
 -- to a measurable wall-time reduction in the LBFGS hot loop.
 mkNoiseKernelFromD2
-  :: Kernel -> GPParams -> Double -> LA.Matrix Double -> LA.Matrix Double
+  :: Kernel -> KernelParams -> Double -> LA.Matrix Double -> LA.Matrix Double
 mkNoiseKernelFromD2 ker p jitter d2 =
   let n     = LA.rows d2
       flatD = LA.flatten d2
@@ -557,7 +522,7 @@
 -- single @n²@ pass rather than two.
 noiseKernelMV :: Kernel -> GPParams -> LA.Matrix Double -> LA.Matrix Double
 noiseKernelMV ker p x =
-  let (xs, _, p') = ardScaleXY ker p x x
+  let (xs, _, p') = ardScaleXY ker (gpKernelParams p) x x
       d2          = KD.pairwiseSqDist xs
       jitter      = max (gpNoiseVar p) 1e-6
   in mkNoiseKernelFromD2 ker p' jitter d2
@@ -571,7 +536,7 @@
   :: Kernel -> GPParams -> LA.Matrix Double -> LA.Matrix Double
 noiseKernelMVCached ker p d2 =
   let jitter = max (gpNoiseVar p) 1e-6
-  in mkNoiseKernelFromD2 ker p jitter d2
+  in mkNoiseKernelFromD2 ker (gpKernelParams p) jitter d2
 
 -- | D-cached version of 'logMarginalLikelihoodMV' — accepts a
 -- pre-computed @D = pairwiseSqDist trainX@ instead of recomputing it
@@ -659,7 +624,7 @@
   let ker    = gpKernel model
       params = gpParams model
       ky     = noiseKernelMV ker params trainX
-      kStar  = buildKernelMatrixMV ker params testX trainX -- m × n
+      kStar  = buildKernelMatrixMV ker (gpKernelParams params) testX trainX -- m × n
       -- α = Ky⁻¹ Y via SPD Cholesky (reused for v below by passing both
       -- right-hand sides through the same factorization).
       rhs    = trainY LA.||| LA.tr kStar           -- n × (q + m)
diff --git a/src/Hanalyze/Model/GPRobust.hs b/src/Hanalyze/Model/GPRobust.hs
--- a/src/Hanalyze/Model/GPRobust.hs
+++ b/src/Hanalyze/Model/GPRobust.hs
@@ -1,6 +1,12 @@
 {-# LANGUAGE OverloadedStrings #-}
--- | Robust GP (heavy-tailed observation likelihoods).
+-- |
+-- Module      : Hanalyze.Model.GPRobust
+-- Description : ロバストガウス過程 (重尾観測尤度: Student-t / Cauchy)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
 --
+-- Robust GP (heavy-tailed observation likelihoods).
+--
 -- A closed-form Gaussian-likelihood GP is sensitive to outliers. This
 -- module replaces the observation likelihood with Student-t or Cauchy and
 -- iterates an IRLS-style scheme (a stable variant of variational EM /
@@ -47,6 +53,7 @@
 import Hanalyze.Model.GP
   ( Kernel
   , GPParams (..)
+  , gpKernelParams
   , kernelFn
   , buildKernelMatrix
   , buildKernelMatrixMV
@@ -116,7 +123,7 @@
   -> RobustGPFit
 fitGPRobust ker params lik trainX trainY =
   let n         = length trainX
-      kMatrix   = buildKernelMatrix ker params trainX trainX  -- K (n×n)
+      kMatrix   = buildKernelMatrix ker (gpKernelParams params) trainX trainX  -- K (n×n)
       yV        = LA.fromList trainY
       sigEff2   = likelihoodScale2 lik
       -- 1 反復: f, w を更新
@@ -179,10 +186,10 @@
   let ker     = rgpKernel fit
       params  = rgpParams fit
       trainX  = rgpTrainX fit
-      kStar   = buildKernelMatrix ker params testX trainX     -- (m, n)
+      kStar   = buildKernelMatrix ker (gpKernelParams params) testX trainX     -- (m, n)
       means   = LA.toList (kStar LA.#> rgpAlpha fit)
       kyInv   = rgpKyInv fit
-      diagKss = [ kernelFn ker params x x | x <- testX ]
+      diagKss = [ kernelFn ker (gpKernelParams params) x x | x <- testX ]
       ws      = kStar LA.<> kyInv                              -- (m, n)
       -- F1: vectorise per-row dots.
       rowDots = LA.toList (KD.rowDotsAB kStar ws)
@@ -256,7 +263,7 @@
   -> RobustGPFitMV
 fitGPRobustMV ker params lik trainX yV =
   let n         = LA.rows trainX
-      kMatrix   = buildKernelMatrixMV ker params trainX trainX
+      kMatrix   = buildKernelMatrixMV ker (gpKernelParams params) trainX trainX
       sigEff2   = likelihoodScale2 lik
       step (f, w, _iter) =
         let r          = LA.toList (yV - f)
@@ -311,7 +318,7 @@
   let ker     = rgpmvKernel fit
       params  = rgpmvParams fit
       trainX  = rgpmvTrainX fit
-      kStar   = buildKernelMatrixMV ker params testX trainX  -- m × n
+      kStar   = buildKernelMatrixMV ker (gpKernelParams params) testX trainX  -- m × n
       means   = kStar LA.#> rgpmvAlpha fit
       kyInv   = rgpmvKyInv fit
       sf      = gpSignalVar params
diff --git a/src/Hanalyze/Model/GradientBoosting.hs b/src/Hanalyze/Model/GradientBoosting.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Model/GradientBoosting.hs
@@ -0,0 +1,176 @@
+{-# LANGUAGE BangPatterns #-}
+-- |
+-- Module      : Hanalyze.Model.GradientBoosting
+-- Description : 勾配ブースティング (Gradient Boosting Machine、 回帰 + 二値分類)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Gradient Boosting Machine (回帰 + 二値分類).
+--
+-- 弱学習器は 'Hanalyze.Model.RandomForest' の回帰木 ('RF.Tree' /
+-- 'RF.buildTreeV') を流用 (bootstrap 無 + mtry = d で full-data /
+-- 全特徴を使う通常の GBM 木に縮約)。
+--
+-- @
+-- import qualified Hanalyze.Model.GradientBoosting as GB
+-- gb <- GB.fitGBRegressor GB.defaultGBM x y
+-- let yhat = GB.predictGBR gb x
+-- @
+--
+-- 損失:
+--
+--   * 回帰: 二乗誤差 (negative gradient = 残差)
+--   * 分類 (binary): log-loss (negative gradient = y - sigmoid(F))
+module Hanalyze.Model.GradientBoosting
+  ( GBConfig (..)
+  , defaultGBM
+  , GBRegressor (..)
+  , GBClassifier (..)
+  , fitGBRegressor
+  , fitGBClassifier
+  , predictGBR
+  , predictGBRRow
+  , predictGBC
+  , predictGBCProbs
+  ) where
+
+import qualified Data.Vector.Unboxed   as VU
+import qualified Numeric.LinearAlgebra as LA
+import qualified Hanalyze.Model.RandomForest as RF
+
+-- ---------------------------------------------------------------------------
+-- Config
+-- ---------------------------------------------------------------------------
+
+-- | GBM 設定。
+data GBConfig = GBConfig
+  { gbNRounds    :: !Int     -- ^ ブースティング回数 M。
+  , gbMaxDepth   :: !Int     -- ^ 各弱学習器の最大深さ (典型 3-5)。
+  , gbMinSamples :: !Int     -- ^ 葉最小サンプル数。
+  , gbLearnRate  :: !Double  -- ^ 学習率 η (typ 0.1)。
+  } deriving (Show)
+
+defaultGBM :: GBConfig
+defaultGBM = GBConfig
+  { gbNRounds    = 100
+  , gbMaxDepth   = 3
+  , gbMinSamples = 2
+  , gbLearnRate  = 0.1
+  }
+
+-- | 弱学習器設定 (full-data / 全特徴利用、 木の深さは gbMaxDepth)。
+weakRFCfg :: Int -> GBConfig -> RF.RFConfig
+weakRFCfg d cfg = RF.RFConfig
+  { RF.rfTrees      = 1
+  , RF.rfMaxDepth   = gbMaxDepth cfg
+  , RF.rfMinSamples = gbMinSamples cfg
+  , RF.rfMtry       = Just d
+  , RF.rfBootstrap  = False
+  }
+
+-- ---------------------------------------------------------------------------
+-- Regressor
+-- ---------------------------------------------------------------------------
+
+-- | 回帰 GBM。 予測 = init + η · Σ tree_m(x).
+data GBRegressor = GBRegressor
+  { gbrInit  :: !Double
+  , gbrTrees :: ![RF.Tree]
+  , gbrLR    :: !Double
+  } deriving (Show)
+
+fitGBRegressor :: GBConfig
+               -> LA.Matrix Double   -- ^ X (n × d)
+               -> VU.Vector Double   -- ^ y (n)
+               -> GBRegressor
+fitGBRegressor cfg x y =
+  let !n     = VU.length y
+      !d     = LA.cols x
+      !cfgW  = weakRFCfg d cfg
+      !lr    = gbLearnRate cfg
+      !f0    = VU.sum y / fromIntegral n
+      !preds0 = VU.replicate n f0
+      idx    = VU.enumFromN 0 n
+
+      step (!preds, !trees) _ =
+        let !res = VU.zipWith (-) y preds
+            !t   = RF.buildTreeV cfgW x res idx 0
+            !upd = VU.map (\i -> lr * RF.predictTree t (rowList x i))
+                          (VU.enumFromN 0 n)
+            !preds' = VU.zipWith (+) preds upd
+        in (preds', t : trees)
+
+      (_, treesRev) = foldl step (preds0, []) [1 .. gbNRounds cfg]
+  in GBRegressor f0 (reverse treesRev) lr
+
+-- | 1 行を [Double] 化 (predictTree のための一時変換)。
+rowList :: LA.Matrix Double -> Int -> [Double]
+rowList x i = LA.toList (LA.flatten (x LA.? [i]))
+
+-- | 1 サンプルの予測。
+predictGBRRow :: GBRegressor -> [Double] -> Double
+predictGBRRow gb xs =
+  gbrInit gb
+    + gbrLR gb * sum [ RF.predictTree t xs | t <- gbrTrees gb ]
+
+-- | 行列入力に対する予測 (n).
+predictGBR :: GBRegressor -> LA.Matrix Double -> VU.Vector Double
+predictGBR gb x =
+  let !n = LA.rows x
+  in VU.generate n (\i -> predictGBRRow gb (rowList x i))
+
+-- ---------------------------------------------------------------------------
+-- Classifier (binary)
+-- ---------------------------------------------------------------------------
+
+-- | 二値分類 GBM (logit + log-loss)。 ラベルは 0/1。
+data GBClassifier = GBClassifier
+  { gbcInit  :: !Double          -- ^ logit(p̂_0)
+  , gbcTrees :: ![RF.Tree]
+  , gbcLR    :: !Double
+  } deriving (Show)
+
+sigmoid :: Double -> Double
+sigmoid z = 1 / (1 + exp (negate z))
+
+clamp :: Double -> Double -> Double -> Double
+clamp lo hi v = max lo (min hi v)
+
+fitGBClassifier :: GBConfig
+                -> LA.Matrix Double   -- ^ X (n × d)
+                -> VU.Vector Int      -- ^ y ∈ {0,1} (n)
+                -> GBClassifier
+fitGBClassifier cfg x y =
+  let !n    = VU.length y
+      !d    = LA.cols x
+      !cfgW = weakRFCfg d cfg
+      !lr   = gbLearnRate cfg
+      !yD   = VU.map fromIntegral y :: VU.Vector Double
+      !p0   = clamp 1e-6 (1 - 1e-6) (VU.sum yD / fromIntegral n)
+      !f0   = log (p0 / (1 - p0))
+      !logits0 = VU.replicate n f0
+      idx   = VU.enumFromN 0 n
+
+      step (!logits, !trees) _ =
+        let !grad = VU.zipWith (\yi z -> yi - sigmoid z) yD logits
+            !t    = RF.buildTreeV cfgW x grad idx 0
+            !upd  = VU.map (\i -> lr * RF.predictTree t (rowList x i))
+                           (VU.enumFromN 0 n)
+            !logits' = VU.zipWith (+) logits upd
+        in (logits', t : trees)
+
+      (_, treesRev) = foldl step (logits0, []) [1 .. gbNRounds cfg]
+  in GBClassifier f0 (reverse treesRev) lr
+
+-- | クラス確率 p(y=1 | x) を返す。
+predictGBCProbs :: GBClassifier -> LA.Matrix Double -> VU.Vector Double
+predictGBCProbs gb x =
+  let !n = LA.rows x
+      logit xs = gbcInit gb
+                   + gbcLR gb * sum [ RF.predictTree t xs | t <- gbcTrees gb ]
+  in VU.generate n (\i -> sigmoid (logit (rowList x i)))
+
+-- | クラス予測 (閾値 0.5)。
+predictGBC :: GBClassifier -> LA.Matrix Double -> VU.Vector Int
+predictGBC gb x =
+  VU.map (\p -> if p >= 0.5 then 1 else 0) (predictGBCProbs gb x)
diff --git a/src/Hanalyze/Model/HBM.hs b/src/Hanalyze/Model/HBM.hs
--- a/src/Hanalyze/Model/HBM.hs
+++ b/src/Hanalyze/Model/HBM.hs
@@ -1,1909 +1,214 @@
-{-# LANGUAGE RankNTypes #-}
-{-# LANGUAGE DeriveFunctor #-}
-{-# LANGUAGE FlexibleInstances #-}
-{-# LANGUAGE OverloadedStrings #-}
-{-# LANGUAGE ScopedTypeVariables #-}
-{-# LANGUAGE ImpredicativeTypes #-}
--- | Polymorphic Hierarchical Bayesian Model (HBM) DSL.
---
--- A free-monad embedded language for probabilistic programs. The
--- continuation type is left polymorphic so that a single model term can
--- be reinterpreted as:
---
---   * a structural inspector (parameter / observation graph),
---   * a log-joint density,
---   * an automatically-differentiated log-joint
---     (via @Numeric.AD.Mode.Forward@),
---   * a dependency tracker (the 'Track' interpretation, used by
---     @Hanalyze.Viz.ModelGraph@ to build a Mermaid DAG).
---
--- See @docs/bayesian/02-probabilistic-model.md@ for an extended
--- introduction.
---
--- @
--- data ModelF a next
---   = Sample  Text (Distribution a) (a -> next)
---   | Observe Text (Distribution a) [Double] next
---   deriving Functor
--- @
---
--- ユーザーは @forall a. (Floating a, Ord a) => Model a r@ という
--- 「型に多相なモデル」を一度だけ書き、解釈時に @a@ を選ぶことで
--- 同じモデルから複数の解釈 (サンプリング・log joint・AD 勾配・依存抽出)
--- を取り出せる。
---
--- == 使い方
---
--- @
--- import Hanalyze.Model.HBM
---
--- myModel :: ModelP ()
--- myModel = do
---   mu    <- sample "mu"    (Normal 0 10)
---   sigma <- sample "sigma" (Exponential 1)
---   observe "y" (Normal mu sigma) [1.5, 2.0, 1.8]
---
--- -- 異なる解釈:
--- logVal = logJoint myModel (Map.fromList [("mu",1),("sigma",2)])  -- 数値評価
--- gVec   = gradAD myModel ["mu","sigma"] [1, 2]                    -- AD 勾配
--- deps   = extractDeps myModel                                      -- 依存関係
--- @
-module Hanalyze.Model.HBM
-  ( -- * Polymorphic distributions
-    Distribution (..)
-  , distName
-  , logDensity
-  , logDensityObs
-  , sampleDist
-  , distCDF
-  , logCDF
-  , logSF
-    -- * Polymorphic model DSL
-  , Free (..)
-  , liftF
-  , ModelF (..)
-  , Model
-  , ModelP
-  , sample
-  , observe
-  , observeMV
-  , observeColumns
-  , potential
-  , deterministic
-  , runDeterministics
-  , augmentChainWithDeterministic
-  , nonCenteredNormal
-  , dirichlet
-  , dataNamed
-  , withData
-  , mvNormalLatent
-  , mvNormalLogDensity
-  , multinomialLogDensity
-  , lkjCorrCholesky
-  , ar1Latent
-    -- * Structural inspection
-  , Node (..)
-  , NodeKind (..)
-  , collectNodes
-  , sampleNames
-  , extractDeps
-    -- * Type aliases
-  , Params
-    -- * Interpreters
-  , logJoint
-  , logPrior
-  , logLikelihood
-  , perObsLogLiks
-  , runObserveDists
-  , priorList
-  , describeModel
-    -- * Model graph (visualization)
-  , ModelGraph (..)
-  , buildModelGraph
-    -- * AD gradient
-  , gradAD
-  , gradADU
-    -- * Constraint transforms (for HMC)
-  , getTransforms
-  , logJointUnconstrained
-  , invTransformF
-  , logJacF
-    -- * Dependency-tracking interpretation
-  , Track (..)
-  , trackVar
-  , trackConst
-  ) where
-
-import qualified Data.Map.Strict as Map
-import Data.Map.Strict (Map)
-import qualified Data.Set as Set
-import Data.Set (Set)
-import Data.Text (Text)
-import qualified Data.Text as T
-import Numeric.AD.Mode.Forward (grad)
-import qualified System.Random.MWC as MWCBase
-import qualified System.Random.MWC.Distributions as MWC
-import System.Random.MWC (GenIO)
-
-import Hanalyze.Stat.Distribution (Transform (..))
-import Hanalyze.MCMC.Core (Chain (..))
-
--- ---------------------------------------------------------------------------
--- @Free@ monad (再実装。Hanalyze.Model.HBM のものとは型が違うので別途定義)
--- ---------------------------------------------------------------------------
-
-data Free f a = Pure a | Free (f (Free f a))
-
-instance Functor f => Functor (Free f) where
-  fmap g (Pure a) = Pure (g a)
-  fmap g (Free x) = Free (fmap (fmap g) x)
-
-instance Functor f => Applicative (Free f) where
-  pure = Pure
-  Pure g <*> x  = fmap g x
-  Free fg <*> x = Free (fmap (<*> x) fg)
-
-instance Functor f => Monad (Free f) where
-  return = pure
-  Pure a >>= g = g a
-  Free x >>= g = Free (fmap (>>= g) x)
-
-liftF :: Functor f => f a -> Free f a
-liftF fa = Free (fmap Pure fa)
-
--- ---------------------------------------------------------------------------
--- 多相分布
--- ---------------------------------------------------------------------------
-
--- | A probability distribution polymorphic in its value type @a@.
---
--- @a@ ranges over @Double@ (sampling and density), @Reverse s Double@
--- (AD-based gradient), @Track@ (dependency tracking) and so on.
-data Distribution a
-  = Normal      a a       -- ^ Normal(μ, σ)
-  | Exponential a         -- ^ Exp(rate)
-  | Gamma       a a       -- ^ Gamma(shape, rate)
-  | Beta        a a       -- ^ Beta(α, β)
-  | Poisson     a         -- ^ Poisson(λ)
-  | Binomial    Int a     -- ^ Binomial(n, p)
-  | Uniform     a a       -- ^ Uniform(low, high)
-  | StudentT    a a a     -- ^ StudentT(ν degrees of freedom, μ location, σ scale)
-  | Cauchy      a a       -- ^ Cauchy(x₀ location, γ scale)
-  | HalfNormal  a         -- ^ HalfNormal(σ) — support: x ≥ 0
-  | HalfCauchy  a         -- ^ HalfCauchy(γ scale) — support: x ≥ 0
-  | LogNormal   a a       -- ^ LogNormal(μ log-mean, σ log-sd) — support: x > 0
-  | Bernoulli   a         -- ^ Bernoulli(p) — observed: 0 or 1
-  | Categorical [a]       -- ^ Categorical(probs) — observed: 0..K-1
-  | Mixture [a] [Distribution a]
-    -- ^ @Mixture(weights, components)@ —
-    --   @log p(x) = logSumExp(log w_k + log p_k(x))@.
-    --   Weights need only be positive; they are auto-normalized.
-  | Truncated (Distribution a) (Maybe a) (Maybe a)
-    -- ^ @Truncated(d, lo, hi)@: restrict the support of @d@ to
-    --   @[lo, hi]@. Out-of-range observations get @-∞@.
-    --   'Nothing' bounds mean @-∞ / +∞@. Only base distributions with a
-    --   CDF (Normal / Exponential / LogNormal / Uniform) are supported.
-  | Censored  (Distribution a) (Maybe a) (Maybe a)
-    -- ^ @Censored(d, lo, hi)@: censor @y ≤ lo@ on the left and
-    --   @y ≥ hi@ on the right. When @y_i@ equals a threshold the CDF/SF
-    --   is used. Useful for Tobit-style models. Only CDF-supporting
-    --   base distributions.
-  | MvNormal [a] [[a]]
-    -- ^ @MvNormal(μ, Σ)@: multivariate normal (observation-only).
-    --   @μ@ is a length-@k@ mean vector, @Σ@ is the @k×k@
-    --   symmetric-positive-definite covariance. Pass @k@-vector
-    --   observations through 'observeMV'. Density is computed via
-    --   Cholesky. /Not supported/ as a latent ('sample' returns 0
-    --   density).
-  | NegativeBinomial a a
-    -- ^ @NegativeBinomial(μ, α)@ (PyMC parameterization).
-    --   @mean = μ@, @var = μ + μ²/α@ (Poisson in the limit
-    --   @α → ∞@). Likelihood for over-dispersed count data;
-    --   observations are non-negative integers.
-  | Multinomial Int [a]
-    -- ^ @Multinomial(n, [p_0, …, p_{K-1}])@ (observation-only).
-    --   @n@ is the trial count and @p@ the probability vector.
-    --   Observations are @K@-dimensional count vectors summing to @n@,
-    --   passed via 'observeMV'.
-  | ZeroInflatedPoisson a a
-    -- ^ @ZeroInflatedPoisson(ψ, λ)@: zero-inflated Poisson.
-    --   @ψ ∈ [0, 1]@ is the structural-zero probability.
-    --   @P(0) = ψ + (1-ψ) e^{-λ}@,
-    --   @P(k>0) = (1-ψ) λ^k e^{-λ} / k!@.
-  | ZeroInflatedBinomial Int a a
-    -- ^ @ZeroInflatedBinomial(n, ψ, p)@: zero-inflated binomial.
-    --   @P(0) = ψ + (1-ψ) (1-p)^n@,
-    --   @P(k>0) = (1-ψ) C(n,k) p^k (1-p)^{n-k}@.
-  | InverseGamma a a
-    -- ^ @InverseGamma(α, β)@. Support @x > 0@. If
-    --   @X ~ InverseGamma(α, β)@ then @1/X ~ Gamma(α, β)@ (rate
-    --   parameterization). Common conjugate prior on variance
-    --   (@mean = β/(α−1)@, finite when @α > 1@).
-  | Weibull a a
-    -- ^ @Weibull(k shape, λ scale)@: a standard survival distribution.
-    --   Support @x > 0@. @pdf = (k/λ) (x/λ)^{k-1} exp(-(x/λ)^k)@.
-    --   With @k = 1@ this is @Exponential(rate = 1/λ)@.
-  | Pareto a a
-    -- ^ @Pareto(α shape, x_m scale)@: heavy-tailed power law.
-    --   Support @x ≥ x_m > 0@. @pdf = α x_m^α / x^{α+1}@.
-    --   Mean @= α x_m / (α-1)@ when @α > 1@.
-  | BetaBinomial Int a a
-    -- ^ @BetaBinomial(n, α, β)@ overdispersed binomial
-    --   (observation-only).
-    --   @P(k) = C(n, k) B(k+α, n-k+β) / B(α, β)@. With @α = β = 1@
-    --   this is uniform on @{0, …, n}@; large @α/β@ tends to a
-    --   binomial.
-  | VonMises a a
-    -- ^ @VonMises(μ location, κ concentration)@: distribution on the
-    --   circle @(-π, π]@.
-    --   @pdf = exp(κ cos(x − μ)) / (2π I_0(κ))@.
-    --   @κ → 0@ approaches uniform; @κ → ∞@ approaches
-    --   @Normal(μ, 1/√κ)@.
-  deriving (Show, Functor)
-
--- | Display name of a distribution constructor (e.g. @\"Normal\"@).
-distName :: Distribution a -> Text
-distName Normal{}      = "Normal"
-distName Exponential{} = "Exponential"
-distName Gamma{}       = "Gamma"
-distName Beta{}        = "Beta"
-distName Poisson{}     = "Poisson"
-distName Binomial{}    = "Binomial"
-distName Uniform{}     = "Uniform"
-distName StudentT{}    = "StudentT"
-distName Cauchy{}      = "Cauchy"
-distName HalfNormal{}  = "HalfNormal"
-distName HalfCauchy{}  = "HalfCauchy"
-distName LogNormal{}   = "LogNormal"
-distName Bernoulli{}   = "Bernoulli"
-distName Categorical{} = "Categorical"
-distName Mixture{}     = "Mixture"
-distName Truncated{}   = "Truncated"
-distName Censored{}    = "Censored"
-distName MvNormal{}    = "MvNormal"
-distName NegativeBinomial{} = "NegativeBinomial"
-distName Multinomial{}          = "Multinomial"
-distName ZeroInflatedPoisson{}  = "ZeroInflatedPoisson"
-distName ZeroInflatedBinomial{} = "ZeroInflatedBinomial"
-distName InverseGamma{}         = "InverseGamma"
-distName Weibull{}              = "Weibull"
-distName Pareto{}               = "Pareto"
-distName BetaBinomial{}         = "BetaBinomial"
-distName VonMises{}             = "VonMises"
-
--- | Log prior density at a sample value of type @a@.
-logDensity :: (Floating a, Ord a) => Distribution a -> a -> a
-logDensity (Normal mu sig) x
-  | sig <= 0  = negInf
-  | otherwise = -0.5 * log (2 * pi) - log sig
-              - 0.5 * ((x - mu) / sig) ^ (2::Int)
-logDensity (Exponential rate) x
-  | x < 0 || rate <= 0 = negInf
-  | otherwise          = log rate - rate * x
-logDensity (Gamma shape rate) x
-  | x <= 0 || shape <= 0 || rate <= 0 = negInf
-  | otherwise =
-      (shape - 1) * log x - rate * x
-      + shape * log rate - lgammaApprox shape
-logDensity (Beta alpha beta) x
-  | x <= 0 || x >= 1 || alpha <= 0 || beta <= 0 = negInf
-  | otherwise =
-      (alpha - 1) * log x + (beta - 1) * log (1 - x)
-      - (lgammaApprox alpha + lgammaApprox beta - lgammaApprox (alpha + beta))
-logDensity (Poisson lam) x
-  | lam <= 0 = negInf
-  | x  < 0   = negInf
-  | otherwise =
-      -- x はサンプル値なので連続として扱う (整数化はしない)
-      x * log lam - lam
-logDensity (Binomial _ p) _
-  | p <= 0 || p >= 1 = negInf
-  | otherwise        = 0  -- サンプル時は使わない (構造のみ)
-logDensity (Uniform lo hi) x
-  | hi <= lo            = negInf
-  | x  < lo || x  > hi  = negInf
-  | otherwise           = -log (hi - lo)
-logDensity (StudentT df mu sig) x
-  | df <= 0 || sig <= 0 = negInf
-  | otherwise =
-      let z = (x - mu) / sig
-      in lgammaApprox ((df + 1) / 2)
-       - lgammaApprox (df / 2)
-       - 0.5 * log (df * pi)
-       - log sig
-       - ((df + 1) / 2) * log (1 + z * z / df)
-logDensity (Cauchy loc sc) x
-  | sc <= 0   = negInf
-  | otherwise =
-      let z = (x - loc) / sc
-      in -log pi - log sc - log (1 + z * z)
-logDensity (HalfNormal sig) x
-  | sig <= 0 = negInf
-  | x < 0    = negInf
-  | otherwise =
-      0.5 * log 2 - 0.5 * log pi - log sig
-      - 0.5 * (x / sig) ^ (2::Int)
-logDensity (HalfCauchy sc) x
-  | sc <= 0 = negInf
-  | x < 0   = negInf
-  | otherwise =
-      log 2 - log pi - log sc - log (1 + (x / sc) ^ (2::Int))
-logDensity (LogNormal mu sig) x
-  | sig <= 0 = negInf
-  | x  <= 0  = negInf
-  | otherwise =
-      let lx = log x
-      in -0.5 * log (2 * pi) - log sig - lx
-         - 0.5 * ((lx - mu) / sig) ^ (2::Int)
-logDensity (Bernoulli p) _
-  | p <= 0 || p >= 1 = negInf
-  | otherwise        = 0  -- 構造のみ (離散なので連続 prior 評価には使わない)
-logDensity (Categorical _) _ = 0  -- 同上
-logDensity (Mixture ws comps) x
-  | null ws || length ws /= length comps = negInf
-  | otherwise =
-      let total      = sum ws
-          logTotal   = log total
-          -- log(w_k / Σw) + log p_k(x)
-          logTerms   = zipWith (\w d -> log w - logTotal + logDensity d x) ws comps
-      in logSumExpA logTerms
-logDensity (Truncated d mLo mHi) x =
-  -- 範囲外なら 0 (=> log で −∞)
-  let outOfRange = case (mLo, mHi) of
-        (Just lo, _      ) | x < lo  -> True
-        (_,       Just hi) | x > hi  -> True
-        _                            -> False
-  in if outOfRange
-       then negInf
-       else logDensity d x - logCDFInterval d mLo mHi
-logDensity (Censored d _ _) x =
-  -- prior 評価では通常の密度を使う (打ち切りは観測時のみ意味を持つ)
-  logDensity d x
-logDensity MvNormal{} _ = 0  -- observation-only: latent としては使わない
-logDensity Multinomial{} _ = 0  -- observation-only
-logDensity (InverseGamma alpha beta) x
-  | alpha <= 0 || beta <= 0 || x <= 0 = negInf
-  | otherwise =
-      alpha * log beta - lgammaApprox alpha
-      - (alpha + 1) * log x - beta / x
-logDensity (Weibull kShape lam) x
-  | kShape <= 0 || lam <= 0 || x <= 0 = negInf
-  | otherwise =
-      log kShape - log lam
-      + (kShape - 1) * (log x - log lam)
-      - (x / lam) ** kShape
-logDensity (Pareto alpha xm) x
-  | alpha <= 0 || xm <= 0 || x < xm = negInf
-  | otherwise =
-      log alpha + alpha * log xm - (alpha + 1) * log x
-logDensity BetaBinomial{} _ = 0  -- 観測専用 (離散)
-logDensity (VonMises mu kappa) x
-  | kappa <= 0 = negInf
-  | otherwise =
-      kappa * cos (x - mu)
-      - log (2 * pi)
-      - logBesselI0 kappa
-logDensity (ZeroInflatedPoisson psi lam) x
-  | psi < 0 || psi > 1 || lam <= 0 || x < 0 = negInf
-  | x == 0 =
-      -- log(ψ + (1-ψ) e^{-λ})
-      logSumExpA [log psi, log (1 - psi) - lam]
-  | otherwise =
-      -- log(1-ψ) + Poisson logpmf
-      log (1 - psi) + x * log lam - lam - lgammaApprox (x + 1)
-logDensity (ZeroInflatedBinomial n psi p) x
-  | psi < 0 || psi > 1 || p <= 0 || p >= 1 || x < 0 = negInf
-  | otherwise =
-      let nA   = realToFrac (fromIntegral n :: Double)
-          -- log(C(n,k)) = lgamma(n+1) - lgamma(k+1) - lgamma(n-k+1) (多相)
-          logC = lgammaApprox (nA + 1)
-               - lgammaApprox (x + 1)
-               - lgammaApprox (nA - x + 1)
-      in if x == 0
-           then logSumExpA [log psi
-                           , log (1 - psi) + nA * log (1 - p)]
-           else log (1 - psi)
-                + logC + x * log p + (nA - x) * log (1 - p)
-logDensity (NegativeBinomial mu alpha) x
-  | mu <= 0 || alpha <= 0 || x < 0 = negInf
-  | otherwise =
-      let p = alpha / (alpha + mu)        -- success prob
-      in lgammaApprox (x + alpha)
-       - lgammaApprox alpha
-       - lgammaApprox (x + 1)
-       + alpha * log p
-       + x * log (1 - p)
-
--- | Log likelihood density at an observation (a fixed @Double@).
--- Observations are passed as @[Double]@, so this uses only the
--- @Floating a@ constraint.
-logDensityObs :: forall a. (Floating a, Ord a) => Distribution a -> Double -> a
-logDensityObs (Normal mu sig) y
-  | sig <= 0  = negInf
-  | otherwise =
-      let yA = realToFrac y :: a
-      in -0.5 * log (2 * pi) - log sig - 0.5 * ((yA - mu) / sig) ^ (2::Int)
-logDensityObs (Exponential rate) y
-  | y < 0      = negInf
-  | rate <= 0  = negInf
-  | otherwise  = log rate - rate * (realToFrac y :: a)
-logDensityObs (Gamma shape rate) y
-  | y <= 0     = negInf
-  | shape <= 0 || rate <= 0 = negInf
-  | otherwise  =
-      let yA = realToFrac y :: a
-      in (shape - 1) * log yA - rate * yA
-         + shape * log rate - lgammaApprox shape
-logDensityObs (Beta alpha beta) y
-  | y <= 0 || y >= 1 || alpha <= 0 || beta <= 0 = negInf
-  | otherwise =
-      let yA = realToFrac y :: a
-      in (alpha - 1) * log yA + (beta - 1) * log (1 - yA)
-         - (lgammaApprox alpha + lgammaApprox beta - lgammaApprox (alpha + beta))
-logDensityObs (Poisson lam) y
-  | lam <= 0 = negInf
-  | y < 0    = negInf
-  | otherwise =
-      let kA   = realToFrac y :: a
-          kInt = round y :: Int
-          logFactK = realToFrac (logFactorial kInt) :: a
-      in kA * log lam - lam - logFactK
-logDensityObs (Binomial n p) y
-  | p <= 0 || p >= 1 = negInf
-  | otherwise =
-      let k    = round y :: Int
-          kA   = realToFrac y :: a
-          nA   = realToFrac (fromIntegral n :: Double) :: a
-          logC = realToFrac (logBinomCoeff n k) :: a
-      in logC + kA * log p + (nA - kA) * log (1 - p)
-logDensityObs (Uniform lo hi) y
-  | hi <= lo  = negInf
-  | otherwise =
-      let yA = realToFrac y :: a
-      in if yA < lo || yA > hi then negInf else -log (hi - lo)
-logDensityObs (StudentT df mu sig) y
-  | df <= 0 || sig <= 0 = negInf
-  | otherwise =
-      let yA = realToFrac y :: a
-          z  = (yA - mu) / sig
-      in lgammaApprox ((df + 1) / 2)
-       - lgammaApprox (df / 2)
-       - 0.5 * log (df * pi)
-       - log sig
-       - ((df + 1) / 2) * log (1 + z * z / df)
-logDensityObs (Cauchy loc sc) y
-  | sc <= 0   = negInf
-  | otherwise =
-      let yA = realToFrac y :: a
-          z  = (yA - loc) / sc
-      in -log pi - log sc - log (1 + z * z)
-logDensityObs (HalfNormal sig) y
-  | sig <= 0 = negInf
-  | y  < 0   = negInf
-  | otherwise =
-      let yA = realToFrac y :: a
-      in 0.5 * log 2 - 0.5 * log pi - log sig
-       - 0.5 * (yA / sig) ^ (2::Int)
-logDensityObs (HalfCauchy sc) y
-  | sc <= 0 = negInf
-  | y  < 0  = negInf
-  | otherwise =
-      let yA = realToFrac y :: a
-      in log 2 - log pi - log sc - log (1 + (yA / sc) ^ (2::Int))
-logDensityObs (LogNormal mu sig) y
-  | sig <= 0 = negInf
-  | y  <= 0  = negInf
-  | otherwise =
-      let yA = realToFrac y :: a
-          lx = log yA
-      in -0.5 * log (2 * pi) - log sig - lx
-       - 0.5 * ((lx - mu) / sig) ^ (2::Int)
-logDensityObs (Bernoulli p) y
-  | p <= 0 || p >= 1 = negInf
-  | otherwise =
-      let k = round y :: Int
-      in case k of
-           1 -> log p
-           0 -> log (1 - p)
-           _ -> negInf
-logDensityObs (Categorical probs) y =
-  let k    = round y :: Int
-      n    = length probs
-  in if k < 0 || k >= n
-       then negInf
-       else
-         -- log p_k - log(Σ p_i)  (probs を正規化)
-         let pk     = probs !! k
-             total  = sum probs
-         in if pk <= 0 || total <= 0
-              then negInf
-              else log pk - log total
-logDensityObs (Mixture ws comps) y
-  | null ws || length ws /= length comps = negInf
-  | otherwise =
-      let total    = sum ws
-          logTotal = log total
-          logTerms = zipWith (\w d -> log w - logTotal + logDensityObs d y) ws comps
-      in logSumExpA logTerms
-logDensityObs (Truncated d mLo mHi) y =
-  let yA = realToFrac y :: a
-      outOfRange = case (mLo, mHi) of
-        (Just lo, _      ) | yA < lo  -> True
-        (_,       Just hi) | yA > hi  -> True
-        _                             -> False
-  in if outOfRange
-       then negInf
-       else logDensityObs d y - logCDFInterval d mLo mHi
-logDensityObs (Censored d mLo mHi) y =
-  -- 観測値 y が境界 lo / hi に等しい場合は左/右打ち切り尤度
-  let yA = realToFrac y :: a
-      eps = 1e-9 :: a
-      isAt v target = abs (v - target) < eps
-  in case (mLo, mHi) of
-       (Just lo, _) | yA <= lo || isAt yA lo -> logCDF d lo                -- 左打ち切り
-       (_, Just hi) | yA >= hi || isAt yA hi -> logSF  d hi                -- 右打ち切り
-       _                                     -> logDensityObs d y          -- 通常観測
-logDensityObs MvNormal{} _ = 0
-  -- スカラー観測経路では使わない (chunk して 'mvNormalLogDensity' を呼ぶ obsLogSum 経由)
-logDensityObs Multinomial{} _ = 0
-  -- スカラー観測経路では使わない (k 次元 chunk で multinomialLogDensity を呼ぶ)
-logDensityObs (InverseGamma alpha beta) y
-  | alpha <= 0 || beta <= 0 || y <= 0 = negInf
-  | otherwise =
-      let yA = realToFrac y :: a
-      in alpha * log beta - lgammaApprox alpha
-       - (alpha + 1) * log yA - beta / yA
-logDensityObs (Weibull kShape lam) y
-  | kShape <= 0 || lam <= 0 || y <= 0 = negInf
-  | otherwise =
-      let yA = realToFrac y :: a
-      in log kShape - log lam
-       + (kShape - 1) * (log yA - log lam)
-       - (yA / lam) ** kShape
-logDensityObs (Pareto alpha xm) y
-  | alpha <= 0 || xm <= 0 = negInf
-  | otherwise =
-      let yA = realToFrac y :: a
-      in if yA < xm
-           then negInf
-           else log alpha + alpha * log xm - (alpha + 1) * log yA
-logDensityObs (BetaBinomial n alpha beta) y
-  | alpha <= 0 || beta <= 0 || y < 0 = negInf
-  | otherwise =
-      let yA   = realToFrac y :: a
-          nA   = realToFrac (fromIntegral n :: Double) :: a
-          k    = round y :: Int
-          logC = realToFrac (logBinomCoeff n k) :: a
-      in logC
-       + lgammaApprox (yA + alpha)
-       + lgammaApprox (nA - yA + beta)
-       - lgammaApprox (nA + alpha + beta)
-       - (lgammaApprox alpha + lgammaApprox beta - lgammaApprox (alpha + beta))
-logDensityObs (VonMises mu kappa) y
-  | kappa <= 0 = negInf
-  | otherwise =
-      let yA = realToFrac y :: a
-      in kappa * cos (yA - mu) - log (2 * pi) - logBesselI0 kappa
-logDensityObs (ZeroInflatedPoisson psi lam) y
-  | psi < 0 || psi > 1 || lam <= 0 || y < 0 = negInf
-  | y == 0 =
-      logSumExpA [log psi, log (1 - psi) - lam]
-  | otherwise =
-      let kA       = realToFrac y :: a
-          kInt     = round y :: Int
-          logFactK = realToFrac (logFactorial kInt) :: a
-      in log (1 - psi) + kA * log lam - lam - logFactK
-logDensityObs (ZeroInflatedBinomial n psi p) y
-  | psi < 0 || psi > 1 || p <= 0 || p >= 1 || y < 0 = negInf
-  | otherwise =
-      let kA   = realToFrac y :: a
-          k    = round y :: Int
-          nA   = realToFrac (fromIntegral n :: Double) :: a
-          logC = realToFrac (logBinomCoeff n k) :: a
-      in if y == 0
-           then logSumExpA [log psi
-                           , log (1 - psi) + nA * log (1 - p)]
-           else log (1 - psi)
-                + logC + kA * log p + (nA - kA) * log (1 - p)
-logDensityObs (NegativeBinomial mu alpha) y
-  | mu <= 0 || alpha <= 0 || y < 0 = negInf
-  | otherwise =
-      let kA = realToFrac y :: a
-          p  = alpha / (alpha + mu)
-      in lgammaApprox (kA + alpha)
-       - lgammaApprox alpha
-       - lgammaApprox (kA + 1)
-       + alpha * log p
-       + kA * log (1 - p)
-
--- | Sum of log likelihoods over a list of observations. For ordinary
--- distributions one observation contributes one scalar log-density.
--- For 'MvNormal' (which expects @k@-vectors), the flattened @[Double]@
--- is chunked into length-@k@ groups before evaluation.
-obsLogSum :: forall a. (Floating a, Ord a) => Distribution a -> [Double] -> a
-obsLogSum (MvNormal mu cov) ys =
-  let k       = length mu
-      chunks  = chunksOf k ys
-  in sum [ mvNormalLogDensity mu cov (map realToFrac yv :: [a])
-         | yv <- chunks ]
-obsLogSum (Multinomial n probs) ys =
-  let k      = length probs
-      chunks = chunksOf k ys
-  in sum [ multinomialLogDensity n probs yv | yv <- chunks ]
-obsLogSum d ys = sum [ logDensityObs d y | y <- ys ]
-
--- | Log probability of a single multinomial observation (a @K@-vector
--- of counts).
---   log P(k_1, …, k_K) = log n!/Π k_i! + Σ k_i log p_i
-multinomialLogDensity :: forall a. (Floating a, Ord a)
-                      => Int -> [a] -> [Double] -> a
-multinomialLogDensity n probs counts
-  | length probs /= length counts = negInf
-  | sum (map round counts :: [Int]) /= n = negInf
-  | any (< 0) counts                = negInf
-  | any (\p -> p <= 0) probs        = negInf
-  | otherwise =
-      let logFactN = realToFrac (logFactorial n) :: a
-          logFactSum = sum [ realToFrac (logFactorial (round c :: Int)) :: a
-                           | c <- counts ]
-          dotPart = sum (zipWith (\c p -> realToFrac c * log p) counts probs)
-      in logFactN - logFactSum + dotPart
-
--- | Log density of an 'MvNormal' at a single @k@-vector observation.
---   log p(y) = -k/2 log(2π) - 0.5 log|Σ| - 0.5 (y-μ)ᵀ Σ⁻¹ (y-μ)
---   Σ⁻¹ と log|Σ| は Cholesky 分解 Σ = L Lᵀ から計算。
-mvNormalLogDensity :: forall a. (Floating a, Ord a) => [a] -> [[a]] -> [a] -> a
-mvNormalLogDensity mu cov yObs
-  | length mu == 0           = 0
-  | length yObs /= length mu = negInf
-  | otherwise =
-      case choleskyL cov of
-        Nothing -> negInf
-        Just l  ->
-          let k      = length mu
-              kA     = fromIntegral k :: a
-              d      = zipWith (-) yObs mu
-              z      = forwardSub l d           -- L z = d
-              quad   = sum (map (\zi -> zi * zi) z)
-              logDet = 2 * sum [ log ((l !! i) !! i) | i <- [0 .. k - 1] ]
-          in -0.5 * kA * log (2 * pi) - 0.5 * logDet - 0.5 * quad
-
--- | リストを長さ @n@ ごとに分割。最後が短ければそのまま (本実装では使わない想定)。
-chunksOf :: Int -> [a] -> [[a]]
-chunksOf _ [] = []
-chunksOf n xs = let (h, t) = splitAt n xs in h : chunksOf n t
-
--- | 対称正定値行列 Σ の Cholesky 下三角分解 L (Σ = L Lᵀ)。
--- 行列は行リスト @[[a]]@ で、l[i] は長さ @i+1@ の下三角行 ([L[i][0]..L[i][i]])。
--- 対角が非正になれば @Nothing@。
-choleskyL :: forall a. (Floating a, Ord a) => [[a]] -> Maybe [[a]]
-choleskyL a0 =
-  let n = length a0
-      step :: Int -> [[a]] -> Maybe [[a]]
-      step i prev
-        | i == n = Just prev
-        | otherwise =
-            let row = a0 !! i
-                buildCol :: Int -> [a] -> Maybe [a]
-                buildCol j cur
-                  | j > i  = Just cur
-                  | j == i =
-                      let s  = sum (map (\v -> v * v) cur)
-                          d2 = (row !! i) - s
-                      in if d2 <= 0
-                           then Nothing
-                           else buildCol (j + 1) (cur ++ [sqrt d2])
-                  | otherwise =
-                      let lj  = prev !! j           -- 長さ j+1
-                          s   = sum (zipWith (*) cur lj)
-                          ljj = lj !! j
-                      in if ljj == 0
-                           then Nothing
-                           else buildCol (j + 1) (cur ++ [((row !! j) - s) / ljj])
-            in case buildCol 0 [] of
-                 Nothing -> Nothing
-                 Just nr -> step (i + 1) (prev ++ [nr])
-  in step 0 []
-
--- | 下三角系 L z = b の前進代入 (L は @choleskyL@ 形式、長さ各 i+1)。
-forwardSub :: forall a. Floating a => [[a]] -> [a] -> [a]
-forwardSub l b =
-  let n   = length b
-      go :: Int -> [a] -> [a]
-      go i acc
-        | i == n = acc
-        | otherwise =
-            let lrow = l !! i              -- 長さ i+1
-                lii  = lrow !! i
-                lpre = take i lrow         -- L[i][0..i-1]
-                bi   = b !! i
-                s    = sum (zipWith (*) lpre acc)
-                zi   = (bi - s) / lii
-            in go (i + 1) (acc ++ [zi])
-  in go 0 []
-
-negInf :: Floating a => a
-negInf = -1/0
-
--- | 多相 log-sum-exp。AD でも Track でも使えるよう Floating + Ord で書く。
--- @logSumExpA xs = log (Σ exp x)@ を最大値シフトで安定化。
-logSumExpA :: (Floating a, Ord a) => [a] -> a
-logSumExpA []  = negInf
-logSumExpA [x] = x
-logSumExpA xs  =
-  let m = maximum xs
-  in m + log (sum (map (\x -> exp (x - m)) xs))
-
--- ---------------------------------------------------------------------------
--- 多相 CDF / log-CDF (Truncated / Censored 用)
--- ---------------------------------------------------------------------------
-
--- | 多相 erf 近似 (Abramowitz & Stegun 7.1.26)。誤差 < 1.5e-7。
--- AD でも Track でも動く。
-erfA :: (Floating a, Ord a) => a -> a
-erfA x =
-  let p   = 0.3275911
-      a1  = 0.254829592
-      a2  = -0.284496736
-      a3  = 1.421413741
-      a4  = -1.453152027
-      a5  = 1.061405429
-      sgn = if x < 0 then -1 else 1
-      ax  = abs x
-      t   = 1 / (1 + p * ax)
-      poly = a1*t + a2*t*t + a3*t*t*t + a4*t*t*t*t + a5*t*t*t*t*t
-  in sgn * (1 - poly * exp (- ax * ax))
-
--- | 標準正規 CDF Φ(x)。
-phiCdfA :: (Floating a, Ord a) => a -> a
-phiCdfA x = 0.5 * (1 + erfA (x / sqrt 2))
-
--- | CDF @F(x) = P(Y ≤ x)@ of a 'Distribution'. Returns 'Nothing' for
--- distributions that do not have a closed-form CDF in this library.
-distCDF :: (Floating a, Ord a) => Distribution a -> a -> Maybe a
-distCDF (Normal mu sig) x
-  | sig <= 0  = Nothing
-  | otherwise = Just (phiCdfA ((x - mu) / sig))
-distCDF (Exponential rate) x
-  | rate <= 0 = Nothing
-  | x <= 0    = Just 0
-  | otherwise = Just (1 - exp (-rate * x))
-distCDF (LogNormal mu sig) x
-  | sig <= 0 || x <= 0 = Nothing
-  | otherwise = Just (phiCdfA ((log x - mu) / sig))
-distCDF (Uniform lo hi) x
-  | hi <= lo  = Nothing
-  | x <= lo   = Just 0
-  | x >= hi   = Just 1
-  | otherwise = Just ((x - lo) / (hi - lo))
-distCDF (HalfNormal sig) x
-  | sig <= 0 = Nothing
-  | x <= 0   = Just 0
-  | otherwise = Just (erfA (x / (sig * sqrt 2)))
-distCDF (HalfCauchy sc) x
-  | sc <= 0 = Nothing
-  | x <= 0  = Just 0
-  | otherwise = Just (2 * atan (x / sc) / pi)
-distCDF (Cauchy loc sc) x
-  | sc <= 0   = Nothing
-  | otherwise = Just (0.5 + atan ((x - loc) / sc) / pi)
-distCDF (Gamma shape rate) x
-  | shape <= 0 || rate <= 0 = Nothing
-  | x <= 0                  = Just 0
-  | otherwise               = Just (incGammaPA shape (rate * x))
-distCDF (Beta a b) x
-  | a <= 0 || b <= 0 = Nothing
-  | x <= 0           = Just 0
-  | x >= 1           = Just 1
-  | otherwise        = Just (incBetaA x a b)
-distCDF (StudentT df mu sig) x
-  | df <= 0 || sig <= 0 = Nothing
-  | otherwise =
-      let z     = (x - mu) / sig
-          -- F_t(z; df) = 1 - 0.5 * I(df/(df+z²); df/2, 1/2)   (z >= 0)
-          --            =     0.5 * I(df/(df+z²); df/2, 1/2)   (z <  0)
-          ratio = df / (df + z * z)
-          ix    = incBetaA ratio (df / 2) 0.5
-      in Just (if z >= 0 then 1 - 0.5 * ix else 0.5 * ix)
-distCDF _ _ = Nothing  -- 他の分布 (離散・Mixture・Truncated 内の Truncated 等) は未対応
-
--- | @log F(x)@. Computed as @log(F)@ directly to avoid loss of
--- precision near the tails where @F@ approaches 0 or 1.
-logCDF :: (Floating a, Ord a) => Distribution a -> a -> a
-logCDF d x = case distCDF d x of
-  Nothing -> negInf
-  Just c | c <= 0    -> negInf
-         | c >= 1    -> 0
-         | otherwise -> log c
-
--- | Log of the right-tail survival function @log(1 − F(x))@.
-logSF :: (Floating a, Ord a) => Distribution a -> a -> a
-logSF d x = case distCDF d x of
-  Nothing -> negInf
-  Just c | c <= 0    -> 0
-         | c >= 1    -> negInf
-         | otherwise -> log (1 - c)
-
--- ---------------------------------------------------------------------------
--- 不完全ガンマ関数 P(a, x) = γ(a, x) / Γ(a)  (Numerical Recipes 6.2)
--- ---------------------------------------------------------------------------
-
--- | 正則化された下側不完全ガンマ関数 P(a, x) = γ(a, x) / Γ(a) ∈ [0, 1]。
--- これは Gamma(shape=a, rate=1) の CDF F(x)。
-incGammaPA :: (Floating a, Ord a) => a -> a -> a
-incGammaPA a x
-  | x <= 0 || a <= 0 = 0
-  | x < a + 1        = igammSer a x          -- 級数展開で P(a,x)
-  | otherwise        = 1 - igammCF a x        -- 連分数で Q(a,x)、P = 1 - Q
-
--- 級数展開: P(a, x) = e^{-x} x^a / Γ(a) * Σ x^n / (a(a+1)...(a+n))
-igammSer :: forall a. (Floating a, Ord a) => a -> a -> a
-igammSer a x = sumSer * exp (-x + a * log x - lgammaApprox a)
-  where
-    -- 反復: term_{n+1} = term_n * x / (a + n + 1)
-    sumSer = go (0 :: Int) (1 / a) (1 / a)
-    eps :: a
-    eps    = 1e-13
-    maxIt  = 200 :: Int
-    go n term acc
-      | n >= maxIt           = acc
-      | abs term < abs acc * eps = acc
-      | otherwise =
-          let n'    = n + 1
-              term' = term * x / (a + fromIntegral n')
-              acc'  = acc + term'
-          in go n' term' acc'
-
--- 連分数 (Lentz 法): Q(a, x) = e^{-x} x^a / Γ(a) * CF
--- CF = 1/(x+1-a - 1·(1-a)/(x+3-a - 2·(2-a)/(...))
-igammCF :: forall a. (Floating a, Ord a) => a -> a -> a
-igammCF a x = exp (-x + a * log x - lgammaApprox a) * h
-  where
-    fpmin, eps :: a
-    fpmin = 1e-300
-    eps   = 1e-13
-    maxIt = 200 :: Int
-    -- modified Lentz's method
-    b0    = x + 1 - a
-    c0    = 1 / fpmin
-    d0    = 1 / b0
-    h     = goCF (1 :: Int) b0 c0 d0 d0
-    goCF i b c d hh
-      | i > maxIt              = hh
-      | abs (del - 1) < eps    = hh'
-      | otherwise              = goCF (i + 1) b' c'' d''' hh'
-      where
-        an   = -fromIntegral i * (fromIntegral i - a)
-        b'   = b + 2
-        d'   = b' + an * d
-        d''  = if abs d' < fpmin then fpmin else d'
-        c'   = b' + an / c
-        c''  = if abs c' < fpmin then fpmin else c'
-        d''' = 1 / d''
-        del  = d''' * c''
-        hh'  = hh * del
-    _ = c0  -- 未使用ダミー (修正された Lentz 法の起動値: 別経路)
-
--- ---------------------------------------------------------------------------
--- 正則化された不完全ベータ関数 I_x(a, b) = B(x; a, b) / B(a, b)
--- ---------------------------------------------------------------------------
-
--- | 正則化された不完全ベータ関数 I_x(a, b) ∈ [0, 1]。
--- これは Beta(a, b) の CDF F(x)。
--- StudentT の CDF にも内部で使用。
-incBetaA :: (Floating a, Ord a) => a -> a -> a -> a
-incBetaA x a b
-  | x <= 0    = 0
-  | x >= 1    = 1
-  | otherwise =
-      -- 対数ベータ正規化定数
-      let bt = exp ( lgammaApprox (a + b)
-                   - lgammaApprox a
-                   - lgammaApprox b
-                   + a * log x
-                   + b * log (1 - x))
-      in if x < (a + 1) / (a + b + 2)
-           then bt * betaCFA x a b / a
-           else 1 - bt * betaCFA (1 - x) b a / b
-
--- 連分数 (modified Lentz, Numerical Recipes §6.4)
-betaCFA :: forall a. (Floating a, Ord a) => a -> a -> a -> a
-betaCFA x a b = iterate' (1 :: Int) 1 d0 h0
-  where
-    fpmin, eps :: a
-    fpmin = 1e-300
-    eps   = 1e-13
-    maxIt = 200 :: Int
-    qab = a + b
-    qap = a + 1
-    qam = a - 1
-    capLent v = if abs v < fpmin then fpmin else v
-    d0 = 1 / capLent (1 - qab * x / qap)
-    h0 = d0
-
-    iterate' m c d h
-      | m > maxIt          = h
-      | abs (del - 1) < eps = hO
-      | otherwise          = iterate' (m + 1) cO dO hO
-      where
-        mD  = fromIntegral m :: a
-        -- 偶数項: aa_2m = m(b-m)x / ((qam+2m)(a+2m))
-        aaE = mD * (b - mD) * x / ((qam + 2 * mD) * (a + 2 * mD))
-        dE  = 1 / capLent (1 + aaE * d)
-        cE  = capLent (1 + aaE / c)
-        hE  = h * dE * cE
-        -- 奇数項: aa_2m+1 = -(a+m)(qab+m)x / ((a+2m)(qap+2m))
-        aaO = -(a + mD) * (qab + mD) * x / ((a + 2 * mD) * (qap + 2 * mD))
-        dO  = 1 / capLent (1 + aaO * dE)
-        cO  = capLent (1 + aaO / cE)
-        del = dO * cO
-        hO  = hE * del
-
--- | log(F(hi) − F(lo)) — Truncated の正規化定数。
-logCDFInterval :: (Floating a, Ord a) => Distribution a -> Maybe a -> Maybe a -> a
-logCDFInterval d mLo mHi = case (mLo, mHi) of
-  (Nothing, Nothing) -> 0  -- log(1)
-  (Just lo, Nothing) -> logSF d lo
-  (Nothing, Just hi) -> logCDF d hi
-  (Just lo, Just hi) ->
-    case (distCDF d lo, distCDF d hi) of
-      (Just cl, Just ch)
-        | ch <= cl  -> negInf
-        | otherwise -> log (ch - cl)
-      _ -> negInf
-
--- ---------------------------------------------------------------------------
--- 分布からのサンプリング (事前/事後予測用)
--- ---------------------------------------------------------------------------
-
--- | Draw a single sample from a 'Distribution Double'.
--- 事前予測サンプリング、事後予測サンプリング、観測値の生成に使う。
---
--- mwc-random が直接提供しない分布はここで実装する (Cauchy, HalfCauchy, etc.)。
-sampleDist :: Distribution Double -> GenIO -> IO Double
-sampleDist (Normal mu sig) gen = MWC.normal mu sig gen
-sampleDist (Exponential rate) gen = do
-  u <- MWCBase.uniform gen :: IO Double
-  return (-log u / rate)
-sampleDist (Gamma shape rate) gen =
-  -- mwc-random の gamma は scale パラメタ化なので 1/rate を渡す
-  MWC.gamma shape (1 / rate) gen
-sampleDist (Beta a b) gen = do
-  x <- MWC.gamma a 1 gen
-  y <- MWC.gamma b 1 gen
-  return (x / (x + y))
-sampleDist (Poisson lam) gen = samplePoissonKnuth lam gen
-sampleDist (Binomial n p) gen = do
-  -- n 回のベルヌーイ試行
-  let go 0 acc = return acc
-      go k acc = do
-        u <- MWCBase.uniform gen :: IO Double
-        go (k - 1) (if u < p then acc + 1 else acc)
-  fmap fromIntegral (go n (0 :: Int))
-sampleDist (Uniform lo hi) gen = do
-  u <- MWCBase.uniform gen :: IO Double
-  return (lo + u * (hi - lo))
-sampleDist (StudentT df mu sig) gen = do
-  -- t = mu + sig * Normal(0,1) / sqrt(Chi2(df) / df)
-  z    <- MWC.standard gen
-  chi2 <- MWC.gamma (df / 2) 2 gen   -- Chi2(df) = Gamma(df/2, scale=2)
-  return (mu + sig * z / sqrt (chi2 / df))
-sampleDist (Cauchy loc sc) gen = do
-  u <- MWCBase.uniform gen :: IO Double
-  return (loc + sc * tan (pi * (u - 0.5)))
-sampleDist (HalfNormal sig) gen = do
-  z <- MWC.standard gen
-  return (abs (sig * z))
-sampleDist (HalfCauchy sc) gen = do
-  u <- MWCBase.uniform gen :: IO Double
-  return (sc * abs (tan (pi * (u - 0.5))))
-sampleDist (LogNormal mu sig) gen = do
-  z <- MWC.standard gen
-  return (exp (mu + sig * z))
-sampleDist (Bernoulli p) gen = do
-  u <- MWCBase.uniform gen :: IO Double
-  return (if u < p then 1.0 else 0.0)
-sampleDist (Categorical probs) gen = do
-  u <- MWCBase.uniform gen :: IO Double
-  let total = sum probs
-      go _   []     = fromIntegral (length probs - 1)
-      go acc (p:ps) =
-        let acc' = acc + p / total
-        in if u < acc' then 0 else 1 + go acc' ps
-  return (go 0 probs)
-sampleDist (Mixture ws comps) gen
-  | null ws || length ws /= length comps = return (0/0)  -- NaN: 不正
-  | otherwise = do
-      -- 1) 重みに比例して成分 k を選ぶ
-      u <- MWCBase.uniform gen :: IO Double
-      let total = sum ws
-          pickIdx _ [] = length ws - 1
-          pickIdx acc (w:rest) =
-            let acc' = acc + w / total
-            in if u < acc' then 0 else 1 + pickIdx acc' rest
-          k = pickIdx 0 ws
-      -- 2) 選んだ成分からサンプリング
-      sampleDist (comps !! k) gen
-sampleDist (Truncated d mLo mHi) gen =
-  -- 単純なリジェクション・サンプリング (範囲が極めて狭いと収束遅い)
-  let inRange y = case (mLo, mHi) of
-        (Just lo, _      ) | y < lo  -> False
-        (_,       Just hi) | y > hi  -> False
-        _                            -> True
-      tryOnce maxAttempts
-        | maxAttempts <= 0 = return (0/0)  -- 諦め
-        | otherwise = do
-            y <- sampleDist d gen
-            if inRange y then return y else tryOnce (maxAttempts - 1)
-  in tryOnce (10000 :: Int)
-sampleDist MvNormal{} _ =
-  error "MvNormal: observation-only — 'sample' 経由でのドローは未対応"
-sampleDist Multinomial{} _ =
-  error "Multinomial: observation-only — 'sample' 経由でのドローは未対応"
-sampleDist (InverseGamma alpha beta) gen = do
-  -- 1 / Gamma(α, rate=β) = 1 / Gamma(α, scale=1/β)
-  y <- MWC.gamma alpha (1 / beta) gen
-  return (1 / y)
-sampleDist (Weibull kShape lam) gen = do
-  -- 逆 CDF 法: x = λ (-log(1-u))^(1/k)
-  u <- MWCBase.uniform gen :: IO Double
-  return (lam * ((-log (1 - u)) ** (1 / kShape)))
-sampleDist (Pareto alpha xm) gen = do
-  -- 逆 CDF 法: x = x_m / u^(1/α)
-  u <- MWCBase.uniform gen :: IO Double
-  return (xm / (u ** (1 / alpha)))
-sampleDist (BetaBinomial n alpha beta) gen = do
-  -- p ~ Beta(α, β); k ~ Binomial(n, p)
-  p <- sampleDist (Beta alpha beta) gen
-  sampleDist (Binomial n p) gen
-sampleDist (VonMises mu kappa) gen = do
-  -- Best-Fisher の rejection sampler
-  let a = 1 + sqrt (1 + 4 * kappa * kappa)
-      b = (a - sqrt (2 * a)) / (2 * kappa)
-      r = (1 + b * b) / (2 * b)
-      tryOnce = do
-        u1 <- MWCBase.uniform gen :: IO Double
-        let z = cos (pi * u1)
-            f = (1 + r * z) / (r + z)
-            c = kappa * (r - f)
-        u2 <- MWCBase.uniform gen :: IO Double
-        if c * (2 - c) - u2 > 0 || log (c / u2) + 1 - c >= 0
-          then do
-            u3 <- MWCBase.uniform gen :: IO Double
-            let sign = if u3 - 0.5 < 0 then (-1.0) else 1.0
-            return (mu + sign * acos f)
-          else tryOnce
-  tryOnce
-sampleDist (ZeroInflatedPoisson psi lam) gen = do
-  u <- MWCBase.uniform gen :: IO Double
-  if u < psi
-    then return 0
-    else samplePoissonKnuth lam gen
-sampleDist (ZeroInflatedBinomial n psi p) gen = do
-  u <- MWCBase.uniform gen :: IO Double
-  if u < psi
-    then return 0
-    else sampleDist (Binomial n p) gen
-sampleDist (NegativeBinomial mu alpha) gen = do
-  -- Gamma-Poisson mixture: λ ~ Gamma(α, β=α/μ); X ~ Poisson(λ)
-  lam <- MWC.gamma alpha (mu / alpha) gen
-  samplePoissonKnuth lam gen
-sampleDist (Censored d _ _) gen =
-  -- 元分布から普通にサンプリング (打ち切りは「観測過程」の話で生成側ではない)
-  sampleDist d gen
-
--- | Knuth のアルゴリズムで Poisson(λ) サンプル。λ < 30 程度なら十分高速。
-samplePoissonKnuth :: Double -> GenIO -> IO Double
-samplePoissonKnuth lam gen = do
-  let l = exp (-lam)
-      go k p = do
-        u <- MWCBase.uniform gen :: IO Double
-        let p' = p * u
-        if p' < l
-          then return (fromIntegral k)
-          else go (k + 1) p'
-  go 0 (1.0 :: Double)
-
--- ---------------------------------------------------------------------------
--- 多相モデル (@Free@ monad)
--- ---------------------------------------------------------------------------
-
--- | DSL のプリミティブ。継続が @a -> next@ なので任意の @a@ を流せる。
---
--- 'Potential' は PyMC の @pm.Potential@ 相当で、任意の log-prob 項を
--- log-joint に加える。ソフト制約・カスタム尤度・正則化項などに使える。
-data ModelF a next
-  = Sample  Text (Distribution a) (a -> next)
-  | Observe Text (Distribution a) [Double] next
-  | Potential Text a next
-    -- ^ 名前付きの ad-hoc な log-prob 項。値 @a@ がそのまま log-joint に加算される。
-  | Deterministic Text a (a -> next)
-    -- ^ 名前付きの派生量 (PyMC `pm.Deterministic`)。log-joint には寄与せず、
-    --   サンプルごとに値を保存する。継続には値そのものを通すので、その後の
-    --   モデル中でも参照可能。
-  | Data Text [Double] ([Double] -> next)
-    -- ^ 名前付き観測データプレースホルダ (PyMC `pm.Data`)。
-    --   モデル内でデータを保持し、`withData` で外部から差し替え可能。
-    --   観測値を直接 `observe` に渡す代わりに、`dataNamed` で受け取って
-    --   `observe` に渡すと、後でデータ差し替えができる。
-  deriving Functor
-
-type Model a = Free (ModelF a)
-
--- | Type alias for the polymorphic model DSL.
--- @ModelP r = forall a. (Floating a, Ord a) => Model a r@
-type ModelP r = forall a. (Floating a, Ord a) => Model a r
-
-sample :: Text -> Distribution a -> Model a a
-sample n d = liftF (Sample n d id)
-
-observe :: Text -> Distribution a -> [Double] -> Model a ()
-observe n d ys = liftF (Observe n d ys ())
-
--- | Multivariate observation (for 'MvNormal'). Each observation is a
--- length-@k@ vector; pass them as a list @[[Double]]@.
--- 内部的には @concat@ で flatten され、評価時に Distribution の次元 k で chunk される。
-observeMV :: Text -> Distribution a -> [[Double]] -> Model a ()
-observeMV n d obss = liftF (Observe n d (concat obss) ())
-
--- | Multi-output observation helper. Takes @q@ pairs of
--- @observe (prefix <> \"_\" <> j) dist_j ys_j@ を順に発行する。
---
--- 多出力回帰の尤度を 1 行で書きたいときに使う:
---
--- @
--- observeColumns \"y\" [(Normal mu_j sigma_j, ysCol j) | j <- [0 .. q - 1]]
--- @
-observeColumns :: Text -> [(Distribution a, [Double])] -> Model a ()
-observeColumns prefix pairs =
-  mapM_ (\(j, (d, ys)) ->
-           observe (prefix <> "_" <> T.pack (show (j :: Int))) d ys)
-        (zip [0..] pairs)
-
--- | Add an arbitrary log-probability term to the model (analogous to
--- PyMC's @pm.Potential@).
---
--- 通常のサンプリング/観測では表せない log-density 寄与を入れるのに使う。
--- 典型用途:
---
---   * **ソフト制約**: @potential \"order\" (if mu1 < mu2 then 0 else (-1e10))@
---   * **カスタム尤度**: 既存 'Distribution' で表せない尤度項
---   * **正則化**: ベイズ的な正則化 (e.g. ridge: @-0.5 * lambda * sum (map (^2) betas)@)
---
--- @Potential@ の値は 'logJoint' と 'logPrior' に加算される
--- ('logLikelihood' には含まれない — これらは @observe@ 専用)。
-potential :: Text -> a -> Model a ()
-potential nm v = liftF (Potential nm v ())
-
--- | 派生量を名前付きで保存する (PyMC `pm.Deterministic` 相当)。
---
--- log-joint には寄与しないが、各 posterior サンプルごとに値が記録され
--- 'augmentChainWithDeterministic' で Chain に注入できる。
---
--- 例:
---
--- > tau <- deterministic "tau" (1 / (sigma * sigma))
-deterministic :: Text -> a -> Model a a
-deterministic nm v = liftF (Deterministic nm v id)
-
--- | 名前付きデータプレースホルダを宣言する (PyMC `pm.Data` 相当)。
--- 既定値 @ys@ を持ち、後で 'withData' により差し替え可能。
---
--- 典型的な使い方:
---
--- > model = do
--- >   y <- dataNamed "y" trainData
--- >   mu <- sample "mu" (Normal 0 5)
--- >   observe "y" (Normal mu 1) y
---
--- そして @withData \"y\" testData model@ で同じ構造で別データを使う。
-dataNamed :: Text -> [Double] -> Model a [Double]
-dataNamed n ys = liftF (Data n ys id)
-
--- | Replace a named data block in the model. If no match exists the
--- model is returned unchanged.
--- 同じ名前が複数回出現する場合は全箇所で差し替わる。
---
--- 型シグネチャは @Model a r@ なので、ユーザーが @ModelP r@ から呼ぶ場合
--- そのまま多相的に使える (各 @a@ で個別に適用される)。
-withData :: forall r. Text -> [Double] -> ModelP r -> ModelP r
-withData n new m = mPoly
-  where
-    -- 戻り値を多相モデルとして再構築。各 @a@ 個別に元の m を走査する。
-    mPoly :: forall a. (Floating a, Ord a) => Model a r
-    mPoly = go m
-      where
-        go :: Model a r -> Model a r
-        go (Pure r) = Pure r
-        go (Free f) = Free (case f of
-          Data n' ys k
-            | n == n'   -> Data n' new (\d -> go (k d))
-            | otherwise -> Data n' ys  (\d -> go (k d))
-          Sample nm d k        -> Sample nm d (\v -> go (k v))
-          Observe nm d ys nx   -> Observe nm d ys (go nx)
-          Potential nm v nx    -> Potential nm v (go nx)
-          Deterministic nm v k -> Deterministic nm v (\v' -> go (k v')))
-
--- | Latent multivariate-normal vector (analogous to PyMC's
--- @pm.MvNormal@ used as a latent).
---
--- 非中心化パラメタ化 + Cholesky 分解で実装:
---
---   z_i ~ Normal(0, 1)  (i = 0..K-1, 独立な latent)
---   x   = μ + L z       (L = Cholesky(Σ))
---
--- 各 z_i は通常の latent として NUTS が探索し、x は派生量として
--- Chain に記録される。共分散行列が他の latent に依存する形でも
--- 動作する (choleskyL は @(Floating a, Ord a)@ 多相)。
---
--- 共分散が非正定値のときは μ をそのまま返す (NUTS 探索中の不正領域
--- に対する graceful fallback)。
---
--- 戻り値: K 次元 latent ベクトル @[a]@ (μ + L z)。
--- Chain には @<name>_z<i>@ (raw latent) と @<name>_<i>@ (派生量) を保存。
-mvNormalLatent :: forall a. (Floating a, Ord a)
-               => Text -> [a] -> [[a]] -> Model a [a]
-mvNormalLatent name muVec covMatrix = do
-  let k = length muVec
-  zs <- mapM (\i -> sample (name <> "_z" <> T.pack (show i)) (Normal 0 1))
-             [0 .. k - 1]
-  let xs = case choleskyL covMatrix of
-        Just l  -> [ (muVec !! i) +
-                       sum [ ((l !! i) !! j) * (zs !! j)
-                           | j <- [0 .. i] ]
-                   | i <- [0 .. k - 1] ]
-        Nothing -> muVec      -- non-PD のフォールバック
-  mapM
-    (\(i, x) -> deterministic (name <> "_" <> T.pack (show i)) x)
-    (zip [0 :: Int ..] xs)
-
--- | LKJ 相関行列の Cholesky factor (PyMC @LKJCholeskyCov@ 相当)。
---
--- LKJ(η) 事前: p(R) ∝ |R|^(η-1)。η = 1 で uniform、η > 1 で I に集中。
---
--- 実装は canonical partial correlations (CPC) 法:
---   z_ij ~ scaled Beta(α_i, α_i) on (-1, 1),  α_i = η + (K - i - 1) / 2
---     (i = 1..K-1, j = 0..i-1)
---
--- 各 z_ij は @<name>_pc<i>_<j>@ (Beta latent in (0,1)、内部で 2u-1 に変換)
--- として保存。Cholesky factor の各要素は派生量 @<name>_L<i>_<j>@。
---
--- 戻り値: K×K 下三角行列 L (R = L Lᵀ となる相関の Cholesky)。
--- 対角は √(1 - Σ z_{i,k}²)、対角下は z_ij × √(Π_{k<j}(1-z_{i,k}²))。
-lkjCorrCholesky :: forall a. (Floating a, Ord a)
-                => Text -> Int -> a -> Model a [[a]]
-lkjCorrCholesky name k eta
-  | k < 2     = error "lkjCorrCholesky: dimension must be >= 2"
-  | otherwise = do
-      -- 各 (i, j) で 1 <= j < i <= K-1 の partial correlation を sample
-      let pcIndices = [(i, j) | i <- [1 .. k - 1], j <- [0 .. i - 1]]
-      pcs <- mapM
-        (\(i, j) -> do
-            let alpha = eta + fromIntegral (k - i - 1) / 2
-                tag   = T.pack (show i) <> "_" <> T.pack (show j)
-            u <- sample (name <> "_u" <> tag) (Beta alpha alpha)
-            deterministic (name <> "_pc" <> tag) (2 * u - 1))
-        pcIndices
-      -- (i,j) → z_ij マップ
-      let pcMap = zip pcIndices pcs
-          lookupPC i j = head [v | ((ii, jj), v) <- pcMap, ii == i, jj == j]
-      -- Cholesky factor を構築 (下三角)
-      let lRow i =
-            [ if j > i then 0
-              else if i == 0 && j == 0 then 1
-              else if j == i  -- 対角
-                   then sqrt (1 - sum [ let z = lookupPC i kk
-                                        in z * z | kk <- [0 .. i - 1] ])
-              else            -- 対角下 j < i
-                let z       = lookupPC i j
-                    factor2 = product [ let z' = lookupPC i kk
-                                        in 1 - z' * z' | kk <- [0 .. j - 1] ]
-                in z * sqrt factor2
-            | j <- [0 .. k - 1] ]
-          lMat = [lRow i | i <- [0 .. k - 1]]
-      -- L 各要素を deterministic として保存
-      _ <- mapM
-        (\(i, j) ->
-          deterministic (name <> "_L" <> T.pack (show i) <> "_" <> T.pack (show j))
-                        ((lMat !! i) !! j))
-        [(i, j) | i <- [0 .. k - 1], j <- [0 .. i]]
-      return lMat
-
--- | AR(1) latent 時系列 (PyMC `pm.AR1` 相当)。
---
--- 状態方程式:  x_t = ϕ x_{t−1} + ε_t,   ε_t ~ Normal(0, σ)
--- 初期分布:    x_0 ~ Normal(0, σ / √(1 − ϕ²))   (定常分布、|ϕ| < 1 なら有限)
---
--- 引数 @phi@ は AR 係数、@sigma@ は innovation の sd。N 個の latent
--- 状態 x_0 .. x_{N-1} を非中心化パラメタ化で sample する:
---
---   raw_t ~ Normal(0, 1)
---   x_t = phi * x_{t-1} + sigma * raw_t       (t > 0)
---   x_0 = (sigma / √(1 - ϕ²)) * raw_0
---
--- 戻り値: x_0 .. x_{N-1} の latent 値リスト ([a])。各 raw_t は
--- @<name>_raw<t>@、x_t 自体は派生量 @<name>_<t>@ として保存。
---
--- |ϕ| ≥ 1 のフォールバック: 初期 sd を sigma に置き換える。
-ar1Latent :: forall a. (Floating a, Ord a)
-          => Text -> Int -> a -> a -> Model a [a]
-ar1Latent name nT phi sigma
-  | nT < 1 = error "ar1Latent: length must be >= 1"
-  | otherwise = do
-      raws <- mapM
-        (\t -> sample (name <> "_raw" <> T.pack (show t)) (Normal 0 1))
-        [0 .. nT - 1]
-      let phi2     = phi * phi
-          stat     = if phi2 < 1
-                       then sigma / sqrt (1 - phi2)
-                       else sigma   -- フォールバック
-          x0       = stat * head raws
-          xs       = scanl
-                       (\xPrev (rt, _) -> phi * xPrev + sigma * rt)
-                       x0
-                       (zip (tail raws) [(1 :: Int) ..])
-      _ <- mapM
-        (\(t, x) -> deterministic
-                       (name <> "_" <> T.pack (show t)) x)
-        (zip [0 :: Int ..] xs)
-      return xs
-
--- | 非中心化 (non-centered) 正規分布。
---
--- @x ~ Normal(loc, scale)@ を直接サンプリングする代わりに、
---
--- > raw <- sample (name <> "_raw") (Normal 0 1)
--- > deterministic name (loc + scale * raw)
---
--- に展開する。loc / scale が他の latent に依存するとき、centered
--- パラメタ化は HMC の posterior が病的になりやすいので、それを
--- 緩和するヘルパ。Neal's funnel が代表例。
---
--- 戻り値は constrained な値 @loc + scale * raw@。Chain には
--- @<name>_raw@ (latent) と @<name>@ (derived) の両方が保存される。
-nonCenteredNormal :: Num a => Text -> a -> a -> Model a a
-nonCenteredNormal name loc scale = do
-  raw <- sample (name <> "_raw") (Normal 0 1)
-  deterministic name (loc + scale * raw)
-
--- | Dirichlet distribution (analogous to PyMC's @pm.Dirichlet@), expanded
--- via stick-breaking
--- latent ベクトル。
---
--- 引数:
---   * @name@   : ベース名。展開後は @<name>_b<i>@ (i=0..K-2) が Beta 由来の
---                棒折り変数、@<name>_<i>@ (i=0..K-1) が deterministic で
---                記録された π 成分。
---   * @alphas@ : 集中度ベクトル α = (α_1,...,α_K)。長さ K ≥ 2。
---
--- アルゴリズム:
---   k = 1..K-1 で β_k ~ Beta(α_k, Σ_{j>k} α_j) を sample する。
---   π_1 = β_1,  π_k = β_k Π_{j<k} (1 − β_j),  π_K = Π_{j<K} (1 − β_j)
---
--- これは π ~ Dirichlet(α) と厳密に等価なので、追加の Jacobian 補正は不要。
--- HMC/NUTS では β_k が UnitIntervalT (logit) で自動的に
--- (0,1) ↔ ℝ 変換されるので、シンプレックス制約は満たされる。
-dirichlet :: forall a. (Floating a, Ord a) => Text -> [a] -> Model a [a]
-dirichlet name alphas = do
-  let k = length alphas
-  if k < 2
-    then error "dirichlet: 長さ 2 未満のベクトルは未対応"
-    else do
-      let -- α_k+1..K の累積和 (右から)。長さ K (最後の要素は 0)
-          tailSums = scanr (+) 0 alphas
-      -- β_0..β_{K-2} を sample
-      betas <- mapM
-        (\i -> sample (name <> "_b" <> T.pack (show i))
-                      (Beta (alphas !! i) (tailSums !! (i + 1))))
-        [0 .. k - 2]
-      -- 残り棒の累積積 prods[i] = Π_{j<i} (1 - β_j),  prods[0] = 1
-      let prods = scanl (\acc b -> acc * (1 - b)) (1 :: a) betas
-          -- π_i = β_i * prods[i] for i < K-1, π_{K-1} = prods[K-1]
-          pis = [ if i < length betas
-                    then (betas !! i) * (prods !! i)
-                    else prods !! i
-                | i <- [0 .. k - 1] ]
-      -- 各 π_i を deterministic として保存し戻り値にも返す
-      mapM (\(i, p) ->
-              deterministic (name <> "_" <> T.pack (show i)) p)
-           (zip [0 :: Int ..] pis)
-
--- ---------------------------------------------------------------------------
--- 構造検査
--- ---------------------------------------------------------------------------
-
-data NodeKind = LatentN | ObservedN Int  deriving (Show, Eq)
-
-data Node = Node
-  { nodeName :: Text
-  , nodeKind :: NodeKind
-  , nodeDist :: Text         -- 分布名 (e.g. "Normal")
-  , nodeDeps :: Set Text     -- 直接の親 (依存変数)
-  } deriving (Show)
-
--- | Walk the model with placeholder zeros and collect 'Node' metadata.
--- 依存関係 ('nodeDeps') は 'extractDeps' を使うこと (placeholder 走査では取れない)。
-collectNodes :: forall r. ModelP r -> [Node]
-collectNodes m = go m []
-  where
-    go :: Model Double r -> [Node] -> [Node]
-    go (Pure _) acc = reverse acc
-    go (Free (Sample n d k)) acc =
-      go (k 0) (Node n LatentN (distName d) Set.empty : acc)
-    go (Free (Observe n d ys next)) acc =
-      go next (Node n (ObservedN (length ys)) (distName d) Set.empty : acc)
-    go (Free (Potential _ _ next)) acc = go next acc   -- Node 表示には含めない
-    go (Free (Deterministic _ v k)) acc = go (k v) acc
-    go (Free (Data _ ys k)) acc = go (k ys) acc
-
-sampleNames :: ModelP r -> [Text]
-sampleNames m = [nodeName n | n <- collectNodes m, nodeKind n == LatentN]
-
--- ---------------------------------------------------------------------------
--- 評価インタープリタ
--- ---------------------------------------------------------------------------
-
--- | Polymorphic interpreter that computes the log-joint
--- @log p(θ, y)@.
--- 引数 @a@ を @Double@ にすると数値評価、@Reverse s Double@ にすると AD 評価が可能。
-logJoint :: (Floating a, Ord a) => Model a r -> Map Text a -> a
-logJoint model params = go model 0
-  where
-    go (Pure _) acc = acc
-    go (Free (Sample n d k)) acc =
-      case Map.lookup n params of
-        Nothing  -> negInf
-        Just v   ->
-          let lp = logDensity d v
-          in go (k v) (acc + lp)
-    go (Free (Observe _ d ys next)) acc =
-      let ll = obsLogSum d ys
-      in go next (acc + ll)
-    go (Free (Potential _ v next)) acc = go next (acc + v)
-    go (Free (Deterministic _ v k)) acc = go (k v) acc
-    go (Free (Data _ ys k)) acc = go (k ys) acc
-
--- | log p(θ) のみ (prior 部分)。
-logPrior :: (Floating a, Ord a) => Model a r -> Map Text a -> a
-logPrior model params = go model 0
-  where
-    go (Pure _) acc = acc
-    go (Free (Sample n d k)) acc =
-      case Map.lookup n params of
-        Nothing -> negInf
-        Just v  -> go (k v) (acc + logDensity d v)
-    go (Free (Observe _ _ _ next)) acc = go next acc
-    go (Free (Potential _ v next)) acc = go next (acc + v)
-    go (Free (Deterministic _ v k)) acc = go (k v) acc
-    go (Free (Data _ ys k)) acc = go (k ys) acc
-
--- | log p(y | θ) のみ (likelihood 部分)。
-logLikelihood :: (Floating a, Ord a) => Model a r -> Map Text a -> a
-logLikelihood model params = go model 0
-  where
-    go (Pure _) acc = acc
-    go (Free (Sample n _ k)) acc =
-      case Map.lookup n params of
-        Nothing -> go (k 0) acc
-        Just v  -> go (k v) acc
-    go (Free (Observe _ d ys next)) acc =
-      let ll = obsLogSum d ys
-      in go next (acc + ll)
-    go (Free (Potential _ _ next)) acc = go next acc   -- Potential は事前項とみなす
-    go (Free (Deterministic _ v k)) acc = go (k v) acc
-    go (Free (Data _ ys k)) acc = go (k ys) acc
-
--- | For each observe node, return its distribution evaluated at the
--- current parameter values together with the observed data.
--- Gibbs サンプラーが共役構造を検出する際に、潜在変数の現在値に対する
--- 観測分布のパラメータを得るために使う (Double 特殊化版)。
---
--- 例: @y ~ Normal(mu, sigma)@ で @ps = {mu=2, sigma=0.5}@ を渡すと
--- @[(\"y\", Normal 2 0.5, [...])]@ を返す。
-runObserveDists :: Model Double r
-                -> Map Text Double
-                -> [(Text, Distribution Double, [Double])]
-runObserveDists (Pure _) _ = []
-runObserveDists (Free (Sample n _ k)) ps =
-  runObserveDists (k (Map.findWithDefault 0 n ps)) ps
-runObserveDists (Free (Observe n d ys next)) ps =
-  (n, d, ys) : runObserveDists next ps
-runObserveDists (Free (Potential _ _ next)) ps =
-  runObserveDists next ps
-runObserveDists (Free (Deterministic _ v k)) ps =
-  runObserveDists (k v) ps
-runObserveDists (Free (Data _ ys k)) ps =
-  runObserveDists (k ys) ps
-
--- | For each sample node, return @(name, prior distribution)@ in the
--- @Double@-specialized form.
--- Gibbs サンプラーの共役検出で「この潜在変数の事前は Gamma か Beta か」を
--- 判定するために使う。継続値はプレースホルダ 0 を流す。
-priorList :: Model Double r -> [(Text, Distribution Double)]
-priorList (Pure _) = []
-priorList (Free (Sample n d k)) = (n, d) : priorList (k 0)
-priorList (Free (Observe _ _ _ next)) = priorList next
-priorList (Free (Potential _ _ next)) = priorList next
-priorList (Free (Deterministic _ v k)) = priorList (k v)
-priorList (Free (Data _ ys k)) = priorList (k ys)
-
--- ---------------------------------------------------------------------------
--- 互換 API
--- ---------------------------------------------------------------------------
-
--- | パラメータ名 → 値 のマップ (constrained 空間)。
-type Params = Map Text Double
-
--- | Per-observation log-likelihood (used by WAIC / LOO-CV).
--- 各 Observe ノードのすべての観測値の logDensity を平坦リストで返す。
-perObsLogLiks :: forall r. ModelP r -> Params -> [Double]
-perObsLogLiks m params = go m []
-  where
-    go :: Model Double r -> [Double] -> [Double]
-    go (Pure _) acc = reverse acc
-    go (Free (Sample n _ k)) acc =
-      go (k (Map.findWithDefault 0 n params)) acc
-    go (Free (Observe _ d ys next)) acc =
-      let lls = case d of
-            MvNormal mu cov ->
-              let k = length mu
-              in [ mvNormalLogDensity mu cov (map realToFrac yv :: [Double])
-                 | yv <- chunksOf k ys ]
-            Multinomial nn pp ->
-              let k = length pp
-              in [ multinomialLogDensity nn pp yv | yv <- chunksOf k ys ]
-            _ -> [ logDensityObs d y | y <- ys ]
-      in go next (reverse lls ++ acc)
-    go (Free (Potential _ _ next)) acc = go next acc
-    go (Free (Deterministic _ v k)) acc = go (k v) acc
-    go (Free (Data _ ys k)) acc = go (k ys) acc
-
--- | Evaluate every 'Deterministic' node and return the resulting
--- derived-quantity @Map@.
---
--- @params@ は latent 変数 (sample) の値を表す Map。Deterministic は
--- それらから導出される量で、ここでは Double 特殊化で評価する。
-runDeterministics :: forall r. ModelP r -> Params -> Map Text Double
-runDeterministics m params = go m Map.empty
-  where
-    go :: Model Double r -> Map Text Double -> Map Text Double
-    go (Pure _) acc = acc
-    go (Free (Sample n _ k)) acc =
-      go (k (Map.findWithDefault 0 n params)) acc
-    go (Free (Observe _ _ _ next)) acc = go next acc
-    go (Free (Potential _ _ next)) acc = go next acc
-    go (Free (Deterministic n v k)) acc =
-      go (k v) (Map.insert n v acc)
-    go (Free (Data _ ys k)) acc = go (k ys) acc
-
--- | Evaluate 'runDeterministics' on every posterior sample and
--- 結果を 'chainSamples' の Map にマージした新しい Chain を返す。
--- これにより @chainVals@ / @posteriorSummary@ などのヘルパで派生量を
--- そのまま参照できる。
-augmentChainWithDeterministic :: ModelP r -> Chain -> Chain
-augmentChainWithDeterministic m ch =
-  let aug ps = Map.union (runDeterministics m ps) ps
-  in ch { chainSamples = map aug (chainSamples ch) }
-
--- | Human-readable summary of the model structure (no inference is run).
-describeModel :: ModelP r -> Text
-describeModel m = T.unlines (header : map fmtNode (collectNodes m))
-  where
-    header = "Model nodes:"
-    fmtNode n = case nodeKind n of
-      LatentN     -> "  [latent]   " <> nodeName n <> " ~ " <> nodeDist n
-      ObservedN k -> "  [observed] " <> nodeName n <> " ~ " <> nodeDist n
-                  <> "  (n=" <> T.pack (show k) <> ")"
-
--- | DAG representation of the model. Edges are derived automatically by
--- 'extractDeps'.
-data ModelGraph = ModelGraph
-  { mgNodes :: [Node]
-  , mgEdges :: [(Text, Text)]   -- (parent, child)
-  } deriving (Show)
-
--- | 多相モデルから DAG を自動構築する (Track 型による依存追跡)。
---
--- 同じ名前で複数登場する Observe ノード (例: 回帰モデルで観測点ごとに
--- @observe \"y\"@ を発行する場合) は 1 つに統合される。観測数の合計と
--- 親変数集合の和をマージし、エッジも重複排除する。
-buildModelGraph :: ModelP r -> ModelGraph
-buildModelGraph m =
-  let rawNodes = extractDeps m
-      merged   = mergeByName rawNodes
-      edges    = Set.toList $ Set.fromList
-                   [ (parent, nodeName n)
-                   | n <- merged
-                   , parent <- Set.toList (nodeDeps n) ]
-  in ModelGraph merged edges
-  where
-    -- 同名ノードを統合: ObservedN n1 + ObservedN n2 → ObservedN (n1+n2)
-    -- LatentN は最初の出現を残す。deps は和集合。
-    mergeByName ns = mergeGo ns Map.empty []
-    mergeGo [] _ acc = reverse acc
-    mergeGo (n:ns) seen acc =
-      let nm = nodeName n
-      in case Map.lookup nm seen of
-           Nothing -> mergeGo ns (Map.insert nm n seen) (n : acc)
-           Just prev ->
-             let merged' = Node
-                   { nodeName = nm
-                   , nodeKind = case (nodeKind prev, nodeKind n) of
-                       (ObservedN a, ObservedN b) -> ObservedN (a + b)
-                       (k, _)                     -> k
-                   , nodeDist = nodeDist prev
-                   , nodeDeps = nodeDeps prev <> nodeDeps n
-                   }
-                 acc' = map (\x -> if nodeName x == nm then merged' else x) acc
-             in mergeGo ns (Map.insert nm merged' seen) acc'
-
--- ---------------------------------------------------------------------------
--- AD 勾配
--- ---------------------------------------------------------------------------
-
--- | AD で勾配を計算する。@names@ の順で各パラメータに対する偏微分を返す。
-gradAD :: ModelP r -> [Text] -> [Double] -> [Double]
-gradAD m names xs0 = grad f xs0
-  where
-    f xs =
-      let params = Map.fromList (zip names xs)
-      in logJoint m params
-
--- | unconstrained 空間で AD 勾配を計算する (HMC 用)。
--- 各パラメータに制約変換を適用し、Jacobian 補正項込みの log-joint を微分する。
-gradADU :: ModelP r -> [Text] -> [Transform] -> [Double] -> [Double]
-gradADU m names trans us0 = grad f us0
-  where
-    f us =
-      let paramsC = Map.fromList
-            [ (n, invTransformF t u)
-            | (n, t, u) <- zip3 names trans us ]
-          logJac  = sum
-            [ logJacF t u
-            | (t, u) <- zip trans us ]
-      in logJoint m paramsC + logJac
-
--- ---------------------------------------------------------------------------
--- 制約変換 (Floating 多相版)
--- ---------------------------------------------------------------------------
-
--- | unconstrained → constrained 変換 (Floating 多相)。
---
--- > UnconstrainedT: θ = u
--- > PositiveT:      θ = exp(u)
--- > UnitIntervalT:  θ = sigmoid(u) = 1/(1+exp(-u))
-invTransformF :: Floating a => Transform -> a -> a
-invTransformF UnconstrainedT u = u
-invTransformF PositiveT      u = exp u
-invTransformF UnitIntervalT  u = 1 / (1 + exp (-u))
-
--- | log |∂θ/∂u| — Jacobian 行列式の対数 (Floating 多相)。
-logJacF :: Floating a => Transform -> a -> a
-logJacF UnconstrainedT _ = 0
-logJacF PositiveT      u = u                       -- log(exp u) = u
-logJacF UnitIntervalT  u =
-  let p = 1 / (1 + exp (-u))
-  in log p + log (1 - p)                           -- log σ(u)(1-σ(u))
-
--- | 各 latent 変数の事前分布から制約変換を自動検出する。
-getTransforms :: ModelP r -> Map Text Transform
-getTransforms m = Map.fromList
-  [ (nodeName n, transformFor (nodeDist n))
-  | n <- collectNodes m
-  , nodeKind n == LatentN
-  ]
-  where
-    transformFor "Normal"      = UnconstrainedT
-    transformFor "Exponential" = PositiveT
-    transformFor "Gamma"       = PositiveT
-    transformFor "Beta"        = UnitIntervalT
-    transformFor "StudentT"    = UnconstrainedT
-    transformFor "Cauchy"      = UnconstrainedT
-    transformFor "HalfNormal"  = PositiveT
-    transformFor "HalfCauchy"  = PositiveT
-    transformFor "LogNormal"   = PositiveT  -- support: x>0 (log は AD 安全)
-    transformFor "Uniform"     = UnconstrainedT  -- 注: 真の制約変換は logit-on-(lo,hi) だが現状は未実装
-    transformFor "Bernoulli"   = UnitIntervalT   -- p ∈ (0,1)
-    transformFor "Categorical" = UnconstrainedT  -- ベクトル制約は未対応 (Dirichlet で別途)
-    transformFor "Mixture"     = UnconstrainedT  -- 混合分布の潜在は通常 unconstrained
-    transformFor "Truncated"   = UnconstrainedT  -- 簡易: 範囲制約は logDensity 内で扱う
-    transformFor "Censored"    = UnconstrainedT
-    transformFor "MvNormal"    = UnconstrainedT  -- observation-only
-    transformFor "InverseGamma" = PositiveT
-    transformFor "Weibull"     = PositiveT
-    transformFor "Pareto"      = PositiveT
-    transformFor "BetaBinomial" = UnitIntervalT
-    transformFor "VonMises"    = UnconstrainedT  -- 角度 (-π, π]
-    transformFor _             = UnconstrainedT
-
--- | unconstrained 空間における log-joint (Jacobian 補正込み)。
--- Jacobian 補正で確率密度の積分を保存する。
-logJointUnconstrained :: forall a r. (Floating a, Ord a)
-                      => Model a r
-                      -> [Text]      -- ^ パラメータ順序
-                      -> [Transform] -- ^ 各パラメータの変換種別
-                      -> Map Text a  -- ^ unconstrained パラメータ値
-                      -> a
-logJointUnconstrained m names trans paramsU =
-  let paramsC = Map.fromList
-        [ (n, invTransformF t (Map.findWithDefault 0 n paramsU))
-        | (n, t) <- zip names trans ]
-      logJac  = sum
-        [ logJacF t (Map.findWithDefault 0 n paramsU)
-        | (n, t) <- zip names trans ]
-  in logJoint m paramsC + logJac
-
--- ---------------------------------------------------------------------------
--- 依存追跡型 Track
--- ---------------------------------------------------------------------------
-
--- | Floating 演算を通して「この値はどの変数に依存するか」を伝播する型。
---
--- @ModelP@ をこの型で特殊化することで、各 Observe ノードが
--- どの latent 変数に依存しているか自動抽出できる。
-data Track = Track
-  { trackVal  :: !Double
-  , trackDeps :: !(Set Text)
-  } deriving (Show, Eq)
-
--- | 変数として登場する Track (deps に自分の名前を入れる)。
-trackVar :: Text -> Double -> Track
-trackVar n v = Track v (Set.singleton n)
-
--- | 定数として扱う Track (deps なし)。
-trackConst :: Double -> Track
-trackConst v = Track v Set.empty
-
--- 自然な順序関係 (Double の比較を使う)
-instance Ord Track where
-  compare a b = compare (trackVal a) (trackVal b)
-
--- Floating の階段
-instance Num Track where
-  fromInteger n = trackConst (fromInteger n)
-  Track a sa + Track b sb = Track (a + b) (sa <> sb)
-  Track a sa - Track b sb = Track (a - b) (sa <> sb)
-  Track a sa * Track b sb = Track (a * b) (sa <> sb)
-  abs    (Track a sa) = Track (abs a) sa
-  signum (Track a sa) = Track (signum a) sa
-  negate (Track a sa) = Track (negate a) sa
-
-instance Fractional Track where
-  fromRational r = trackConst (fromRational r)
-  Track a sa / Track b sb = Track (a / b) (sa <> sb)
-
-instance Floating Track where
-  pi             = trackConst pi
-  exp   (Track a sa) = Track (exp   a) sa
-  log   (Track a sa) = Track (log   a) sa
-  sin   (Track a sa) = Track (sin   a) sa
-  cos   (Track a sa) = Track (cos   a) sa
-  tan   (Track a sa) = Track (tan   a) sa
-  asin  (Track a sa) = Track (asin  a) sa
-  acos  (Track a sa) = Track (acos  a) sa
-  atan  (Track a sa) = Track (atan  a) sa
-  sinh  (Track a sa) = Track (sinh  a) sa
-  cosh  (Track a sa) = Track (cosh  a) sa
-  tanh  (Track a sa) = Track (tanh  a) sa
-  asinh (Track a sa) = Track (asinh a) sa
-  acosh (Track a sa) = Track (acosh a) sa
-  atanh (Track a sa) = Track (atanh a) sa
-  sqrt  (Track a sa) = Track (sqrt  a) sa
-  Track a sa ** Track b sb = Track (a ** b) (sa <> sb)
-  logBase (Track a sa) (Track b sb) = Track (logBase a b) (sa <> sb)
-
-instance Real Track where
-  toRational = toRational . trackVal
-
-instance RealFrac Track where
-  properFraction (Track a sa) = let (i, f) = properFraction a in (i, Track f sa)
-
--- | モデルを Track 型で実行し、各ノードの依存関係を抽出する。
---
--- Sample n: その変数自体は @{n}@ に依存する (自己依存)。
--- Observe n: 分布のパラメータに含まれる latent 変数の集合を deps とする。
-extractDeps :: forall r. ModelP r -> [Node]
-extractDeps m = go m []
-  where
-    go :: Model Track r -> [Node] -> [Node]
-    go (Pure _) acc = reverse acc
-    go (Free (Sample n d k)) acc =
-      let parentDeps = distDepsT d
-          node = Node n LatentN (distName d) parentDeps
-          v    = trackVar n 1.0  -- 1 にすると log/exp が安全
-      in go (k v) (node : acc)
-    go (Free (Observe n d ys next)) acc =
-      let parentDeps = distDepsT d
-          node = Node n (ObservedN (length ys)) (distName d) parentDeps
-      in go next (node : acc)
-    go (Free (Potential nm v next)) acc =
-      -- Potential も DAG 上は「依存を持つ無形ノード」として可視化
-      let parentDeps = trackDeps v
-          node = Node nm LatentN "Potential" parentDeps
-      in go next (node : acc)
-    go (Free (Deterministic nm v k)) acc =
-      -- Deterministic も親 latent からの導出関係を保存
-      let parentDeps = trackDeps v
-          node = Node nm LatentN "Deterministic" parentDeps
-      in go (k v) (node : acc)
-    go (Free (Data _ ys k)) acc =
-      -- Data はデータプレースホルダ。継続には [Double] をそのまま渡す。
-      go (k ys) acc
-
--- | Distribution Track に含まれる依存変数集合を取り出す。
-distDepsT :: Distribution Track -> Set Text
-distDepsT (Normal mu sig)    = trackDeps mu <> trackDeps sig
-distDepsT (Exponential r)    = trackDeps r
-distDepsT (Gamma s r)        = trackDeps s <> trackDeps r
-distDepsT (Beta a b)         = trackDeps a <> trackDeps b
-distDepsT (Poisson lam)      = trackDeps lam
-distDepsT (Binomial _ p)     = trackDeps p
-distDepsT (Uniform lo hi)    = trackDeps lo <> trackDeps hi
-distDepsT (StudentT df mu s) = trackDeps df <> trackDeps mu <> trackDeps s
-distDepsT (Cauchy loc s)     = trackDeps loc <> trackDeps s
-distDepsT (HalfNormal s)     = trackDeps s
-distDepsT (HalfCauchy s)     = trackDeps s
-distDepsT (LogNormal mu s)   = trackDeps mu <> trackDeps s
-distDepsT (Bernoulli p)      = trackDeps p
-distDepsT (Categorical ps)   = mconcat (map trackDeps ps)
-distDepsT (Mixture ws ds)    = mconcat (map trackDeps ws) <> mconcat (map distDepsT ds)
-distDepsT (Truncated d mLo mHi) =
-  distDepsT d <> maybe mempty trackDeps mLo <> maybe mempty trackDeps mHi
-distDepsT (Censored  d mLo mHi) =
-  distDepsT d <> maybe mempty trackDeps mLo <> maybe mempty trackDeps mHi
-distDepsT (MvNormal mus covRows) =
-  mconcat (map trackDeps mus)
-    <> mconcat (concatMap (map trackDeps) covRows)
-distDepsT (NegativeBinomial mu alpha) = trackDeps mu <> trackDeps alpha
-distDepsT (Multinomial _ ps) = mconcat (map trackDeps ps)
-distDepsT (ZeroInflatedPoisson psi lam) = trackDeps psi <> trackDeps lam
-distDepsT (ZeroInflatedBinomial _ psi p) = trackDeps psi <> trackDeps p
-distDepsT (InverseGamma a b) = trackDeps a <> trackDeps b
-distDepsT (Weibull k l)      = trackDeps k <> trackDeps l
-distDepsT (Pareto a xm)      = trackDeps a <> trackDeps xm
-distDepsT (BetaBinomial _ a b) = trackDeps a <> trackDeps b
-distDepsT (VonMises mu k)    = trackDeps mu <> trackDeps k
-
--- | Track でモデルを評価する (log joint も依存集合付きで計算)。
-runTrack :: forall r. ModelP r -> Map Text Track -> Track
-runTrack m params = logJoint (m :: Model Track r) params
-
--- ---------------------------------------------------------------------------
--- 数値ユーティリティ
--- ---------------------------------------------------------------------------
-
--- | log Γ(z) の Stirling 近似 (z > 0)。AD でも Track でも使える多相版。
-lgammaApprox :: (Floating a, Ord a) => a -> a
-lgammaApprox z
-  | z < 12    = lgammaApprox (z + 1) - log z
-  | otherwise = (z - 0.5) * log z - z + 0.5 * log (2 * pi)
-              + 1 / (12 * z) - 1 / (360 * z ^ (3::Int))
-
-logFactorial :: Int -> Double
-logFactorial n
-  | n <= 1    = 0
-  | otherwise = sum (map log [2 .. fromIntegral n])
-
-logBinomCoeff :: Int -> Int -> Double
-logBinomCoeff n k = logFactorial n - logFactorial k - logFactorial (n - k)
-
--- | log I_0(x) — 修正 Bessel 関数 (第一種・order 0) の対数。VonMises 用。
--- 小 x: 級数 I_0(x) = Σ (x/2)^(2k) / (k!)² (k = 0..)
--- 大 x: 漸近展開 I_0(x) ≈ exp(x) / √(2πx) × [1 + 1/(8x) + 9/(128x²) + …]
--- AD/Track 互換のため (Floating a, Ord a) 多相。
-logBesselI0 :: (Floating a, Ord a) => a -> a
-logBesselI0 x
-  | x < 0     = logBesselI0 (-x)  -- 偶関数
-  | x < 3.75  =
-      -- Abramowitz & Stegun 9.8.1: 多項式近似 (誤差 < 1.6e-7)
-      let t = (x / 3.75) ^ (2::Int)
-          i0 = 1 + t * (3.5156229 + t * (3.0899424 + t * (1.2067492
-             + t * (0.2659732 + t * (0.0360768 + t * 0.0045813)))))
-      in log i0
-  | otherwise =
-      -- Abramowitz & Stegun 9.8.2: 漸近 (誤差 < 1.9e-7)
-      let t = 3.75 / x
-          poly = 0.39894228 + t * (0.01328592 + t * (0.00225319
-               + t * (-0.00157565 + t * (0.00916281 + t * (-0.02057706
-               + t * (0.02635537 + t * (-0.01647633 + t * 0.00392377)))))))
-      in x - 0.5 * log x + log poly
+-- |
+-- Module      : Hanalyze.Model.HBM
+-- Description : 多相階層ベイズモデル (Hierarchical Bayesian Model, HBM) DSL の facade
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Polymorphic Hierarchical Bayesian Model (HBM) DSL.
+--
+-- Phase 58 で責務別 submodule に分割済み。 本モジュールは **facade**:
+-- 下位 8 module (Util/Distribution/Sampling/Model/Track/Eval/IR/Gradient) を
+-- import し、 従来の公開 API を export list 経由でそのまま再公開する。
+-- 既存 importer (18 src module + test) は無改修で従来通り使える。
+--
+-- A free-monad embedded language for probabilistic programs. The
+-- continuation type is left polymorphic so that a single model term can
+-- be reinterpreted as:
+--
+--   * a structural inspector (parameter / observation graph),
+--   * a log-joint density,
+--   * an automatically-differentiated log-joint
+--     (via @Numeric.AD.Mode.Reverse.Double@ — Double 特化の reverse モードゆえ
+--      勾配は latent 数 p に依らず ~1 sweep。 Phase 53 で forward から切替:
+--      forward は勾配 1 本に p 回評価が要り階層モデルで線形悪化していた。
+--      generic Reverse は tape boxing で低次元が遅く、 Reverse.Double が全 p で
+--      forward/generic を上回ると実測),
+--   * a dependency tracker (the 'Track' interpretation, used by
+--     @Hanalyze.Viz.ModelGraph@ to build a Mermaid DAG).
+--
+-- See @docs/bayesian/02-probabilistic-model.md@ for an extended
+-- introduction.
+--
+-- @
+-- data ModelF a next
+--   = Sample  Text (Distribution a) (a -> next)
+--   | Observe Text (Distribution a) [Double] next
+--   deriving Functor
+-- @
+--
+-- ユーザーは @forall a. (Floating a, Ord a) => Model a r@ という
+-- 「型に多相なモデル」を一度だけ書き、解釈時に @a@ を選ぶことで
+-- 同じモデルから複数の解釈 (サンプリング・log joint・AD 勾配・依存抽出)
+-- を取り出せる。
+--
+-- == 使い方
+--
+-- @
+-- import Hanalyze.Model.HBM
+--
+-- myModel :: ModelP ()
+-- myModel = do
+--   mu    <- sample "mu"    (Normal 0 10)
+--   sigma <- sample "sigma" (Exponential 1)
+--   observe "y" (Normal mu sigma) [1.5, 2.0, 1.8]
+--
+-- -- 異なる解釈:
+-- logVal = logJoint myModel (Map.fromList [("mu",1),("sigma",2)])  -- 数値評価
+-- gVec   = gradAD myModel ["mu","sigma"] [1, 2]                    -- AD 勾配
+-- deps   = extractDeps myModel                                      -- 依存関係
+-- @
+module Hanalyze.Model.HBM
+  ( -- * Polymorphic distributions
+    Distribution (..)
+  , distName
+  , logDensity
+  , logDensityObs
+  , sampleDist
+  , sampleMvDist
+  , distCDF
+  , logCDF
+  , logSF
+    -- * Polymorphic model DSL
+  , Free (..)
+  , liftF
+  , ModelF (..)
+  , Model
+  , ModelP
+  , sample
+  , observe
+  , observeMV
+  , observeColumns
+  , observeLM
+  , observeLMR
+  , observeNormalLM
+  , LMFamily (..)
+  , REff (..)
+  , REffect (..)
+  , reffNames
+  , reNormal
+  , at
+  , indexed
+  , (.#)
+  , potential
+  , deterministic
+  , runDeterministics
+  , deterministicNames
+  , augmentChainWithDeterministic
+  , nonCenteredNormal
+  , dirichlet
+  , orderedCuts
+  , dpStickBreaking
+  , hmmLatent
+  -- ** Phase 40 plate notation
+  , plate
+  , plateI
+  , plateI_
+  , plateForM
+  , plateForM_
+  , withPlate
+  , hmmForwardLogLik
+  , GlmmFamily (..)
+  , glmmRandomIntercept
+  , dataNamed
+  , dataNamedX
+  , dataNamedIx
+  , dataNamedObs
+  , Ix (..)
+  , TrackTag (..)
+  , (!!!)
+  , atIx
+  , withData
+  , withDataIx
+  , mvNormalLatent
+  , mvNormalLogDensity
+  , mvNormalCholLogDensity
+  , multinomialLogDensity
+  , mvStudentTLogDensity
+  , dirichletMultinomialLogDensity
+  , wishartLogDensity
+  , obsLogSum
+  , lkjCorrCholesky
+  , gpExpQuadCov
+  , gpLatent
+  , ar1Latent
+    -- * Structural inspection
+  , Node (..)
+  , NodeKind (..)
+  , collectNodes
+  , sampleNames
+  , dataSlots
+  , dataIxSlots
+  , extractDeps
+    -- * Type aliases
+  , Params
+    -- * Interpreters
+  , logJoint
+  , logPrior
+  , logLikelihood
+  , perObsLogLiks
+  , runObserveDists
+  , priorList
+  , describeModel
+    -- * Model graph (visualization)
+  , ModelGraph (..)
+  , buildModelGraph
+  , collapseIndexedPlateNodes
+    -- * AD gradient
+  , gradAD
+  , gradADU
+  , compileGradU
+  , compileGradUV
+  , compileGradValUV
+  , compileGradValUVM
+  , compileLogPU
+  , compileLogPUV
+  , synthGaussLMBlocks
+  , synthVecIR
+  , gradPathLabel
+    -- * Numeric utilities (test 用・Phase 56.1)
+  , lgammaApprox
+  , digamma
+    -- * Constraint transforms (for HMC)
+  , getTransforms
+  , logJointUnconstrained
+  , invTransformF
+  , logJacF
+    -- * Dependency-tracking interpretation
+  , Track (..)
+  , trackVar
+  , trackConst
+  ) where
+
+-- Phase 58.2: 純粋な数値・線形代数 leaf util を分離。 internal 利用に加え
+-- 'lgammaApprox' / 'digamma' は export list 経由でそのまま再エクスポートされる。
+import Hanalyze.Model.HBM.Util
+-- Phase 58.3/58.6a: 多相分布 ADT + 密度 + CDF を分離 (Util の上層)。 公開 API
+-- (Distribution(..)/distName/logDensity/logDensityObs/obsLogSum/distCDF/logCDF/
+-- logSF/MV密度群) は export list 経由でそのまま再エクスポート。 ★58.6a で事前
+-- logDensity と観測 logDensityObs/obsLogSum を本体から Distribution へ集約
+-- (Eval の logJoint/logPrior が logDensity を参照する back-edge を解消・密度は
+-- 本来 Distribution の責務。 INLINABLE は AD cross-module inlining 維持で保持)。
+import Hanalyze.Model.HBM.Distribution
+-- Phase 58.4: 分布からのサンプリング (sampleDist/sampleMvDist) を分離。
+-- export list 経由でそのまま再エクスポート。 PrimMonad/mwc-random 依存・非ホット。
+import Hanalyze.Model.HBM.Sampling
+-- Phase 58.5: 多相モデル DSL (Free monad + ModelF + plate + 構造検査) を分離。
+-- 公開 API (Free/liftF/ModelF/Model/ModelP/sample/observe/plate/collectNodes 等)
+-- は export list 経由でそのまま再エクスポート。
+import Hanalyze.Model.HBM.Model
+-- Phase 58.6b: 依存追跡型 Track (Track/trackVar/trackConst/extractDeps) を分離。
+-- Model/Distribution の上層・非ホット (DAG 抽出のみ・NUTS per-draw 非経路)。
+-- export list 経由でそのまま再エクスポート。
+import Hanalyze.Model.HBM.Track
+-- Phase 58.6c: 評価層 (ObserveLM 評価 + logJoint/logPrior/logLikelihood interp +
+-- 互換 API runDeterministics/buildModelGraph 等 + runTrack) を分離。 Track の上層。
+-- ★ホット (logJoint は AD 勾配経路)。 AD 勾配・IR (本体残置) は本モジュールを
+-- forward import する。 公開 API は export list 経由でそのまま再エクスポート。
+import Hanalyze.Model.HBM.Eval
+-- Phase 58.7: IR (中間表現) 層 (affine/非線形/密度 IR) を分離。 最ホット (gradVecIR)。
+import Hanalyze.Model.HBM.IR
+-- Phase 58.8: AD 勾配コンパイラ層 (compileGradUV/hybridGradClosure/gaussLMBlocks/
+-- 定数 prior 解析勾配/制約変換) を分離。 IR の上層・最ホット (NUTS per-draw 本経路)。
+-- 公開 API (gradAD/gradADU/compileGradU/compileGradUV/compileLogPU/compileLogPUV/
+-- getTransforms/logJointUnconstrained/invTransformF/logJacF) は export list 経由で再公開。
+import Hanalyze.Model.HBM.Gradient
diff --git a/src/Hanalyze/Model/HBM/Ast.hs b/src/Hanalyze/Model/HBM/Ast.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Model/HBM/Ast.hs
@@ -0,0 +1,243 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+-- |
+-- Module      : Hanalyze.Model.HBM.Ast
+-- Description : HBM dialog DSL の AST 型と JSON decoder
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- HBM dialog DSL の AST 型と JSON decoder。
+--
+-- Phase 27.5 (2026-05-31): canvas-backend @フロントエンド app.Analysis.HBM@ から
+-- 移設。 frontend が backend 統一 parser (@/api/v1/dsl/parse@) から得た
+-- @program_ast@ (JSON) を、 streaming sidecar が直接 decode して実モデルを
+-- 構築できるよう、 AST 型 + 'parseAst' をライブラリ層 (hanalyze) に置く。
+--
+-- 本 module は **canvas wire 型にも text parser (DSL frontend) にも依存しない**
+-- (= aeson のみ)。 text → AST 変換 ('parseHbmTextToExpr' 等) は HT (DSL frontend)
+-- に依存するため canvas-backend 側に残す。
+module Hanalyze.Model.HBM.Ast
+  ( -- * AST
+    Expr (..)
+  , Lit (..)
+  , Bind (..)
+  , DoStmt (..)
+    -- * JSON decode (= frontend program_ast → Expr)
+  , parseAst
+  , parseLit
+  , parseBind
+  , parseDoStmt
+    -- * JSON encode (= 'parseAst' の正確な逆。 backend が sidecar に
+    --   program_ast / top_binds を送る際に使う、 Phase 27.5 step 3)
+  , exprToJSON
+  , litToJSON
+  , bindToJSON
+  , doStmtToJSON
+    -- * helpers
+  , Err
+  , collectApp
+  , getField
+  , getStr
+  , getNum
+  , getBool
+  , getArray
+  ) where
+
+import Data.Text (Text)
+import qualified Data.Aeson as A
+import Data.Aeson.Types (Pair)
+import qualified Data.Aeson.Key as Key
+import qualified Data.Aeson.KeyMap as KM
+import qualified Data.Vector as V
+
+-- ---------------------------------------------------------------------------
+-- AST (= frontend App.Hbm.Ast / DSL frontend hanalyze.HBM.Text.HbmExpr と同形、
+--   11 ctor: ELit / ECol / EVar / EApp / ELam / EIf / ELet / ENeg / EOp /
+--   EList / EDo)
+-- ---------------------------------------------------------------------------
+
+data Expr
+  = ELit Lit
+  | ECol Text
+  | EVar Text
+  | EApp Expr Expr
+  | ELam Text Expr
+  | EIf Expr Expr Expr
+  | ELet [Bind] Expr
+  | ENeg Expr
+  | EOp Text Expr Expr
+  | EList [Expr]
+  | EDo [DoStmt] Expr
+  deriving (Show)
+
+data Lit = LNumber Double | LText Text | LBool Bool deriving (Show)
+
+data Bind = Bind { bindName :: Text, bindValue :: Expr } deriving (Show)
+
+data DoStmt
+  = DoBind Text Expr
+  | DoLet [Bind]
+  | DoExpr Expr
+  deriving (Show)
+
+-- | 評価系で多用する Either alias。
+type Err a = Either Text a
+
+-- ---------------------------------------------------------------------------
+-- JSON parser (= frontend が送る program_ast を Expr に decode)
+-- ---------------------------------------------------------------------------
+
+parseAst :: A.Value -> Either Text Expr
+parseAst v = case v of
+  A.Object o -> do
+    tag <- getStr o "tag"
+    case tag of
+      "ELit" -> ELit <$> (parseLit =<< getField o "lit")
+      "ECol" -> ECol <$> getStr o "name"
+      "EVar" -> EVar <$> getStr o "name"
+      "EApp" -> EApp <$> (parseAst =<< getField o "f") <*> (parseAst =<< getField o "x")
+      "ELam" -> ELam <$> getStr o "arg" <*> (parseAst =<< getField o "body")
+      "EIf"  -> EIf <$> (parseAst =<< getField o "c")
+                    <*> (parseAst =<< getField o "a")
+                    <*> (parseAst =<< getField o "b")
+      "ELet" -> do
+        bs <- getArray o "binds" >>= mapM parseBind
+        body <- parseAst =<< getField o "body"
+        Right (ELet bs body)
+      "ENeg" -> ENeg <$> (parseAst =<< getField o "e")
+      "EOp"  -> EOp <$> getStr o "op"
+                    <*> (parseAst =<< getField o "a")
+                    <*> (parseAst =<< getField o "b")
+      "EList" -> EList <$> (getArray o "items" >>= mapM parseAst)
+      "EDo"  -> do
+        stmts <- getArray o "stmts" >>= mapM parseDoStmt
+        ret <- parseAst =<< getField o "ret"
+        Right (EDo stmts ret)
+      _ -> Left ("Unknown AST tag: " <> tag)
+  _ -> Left "AST root must be a JSON object"
+
+parseLit :: A.Value -> Either Text Lit
+parseLit v = case v of
+  A.Object o -> do
+    tag <- getStr o "tag"
+    case tag of
+      "LNumber" -> do
+        n <- getNum o "value"
+        Right (LNumber n)
+      "LText"   -> LText <$> getStr o "value"
+      "LBool"   -> LBool <$> getBool o "value"
+      _ -> Left ("Unknown literal tag: " <> tag)
+  _ -> Left "Literal must be an object"
+
+parseBind :: A.Value -> Either Text Bind
+parseBind v = case v of
+  A.Object o -> do
+    n <- getStr o "name"
+    e <- parseAst =<< getField o "value"
+    Right (Bind n e)
+  _ -> Left "Bind must be an object"
+
+parseDoStmt :: A.Value -> Either Text DoStmt
+parseDoStmt v = case v of
+  A.Object o -> do
+    tag <- getStr o "tag"
+    case tag of
+      "DoBind" -> do
+        name <- getStr o "name"
+        rawValue <- parseAst =<< getField o "value"
+        -- Phase 9.1d-4 fix: frontend が `x <- sample "obsName" dist` を
+        -- DoBind の value に raw expression として渡してくる。 validateStmts
+        -- 以降は value が「純粋な distribution」 であることを期待するので、
+        -- ここで sample wrapper を剥がす。 sample 形でなければそのまま通す
+        -- (互換: 直接 dist を入れた古い経路があった場合のため)。
+        let distOnly = case collectApp rawValue of
+              Right ("sample", [ELit (LText _samplerName), d]) -> d
+              _ -> rawValue
+        pure (DoBind name distOnly)
+      "DoLet"  -> DoLet <$> (getArray o "binds" >>= mapM parseBind)
+      "DoExpr" -> DoExpr <$> (parseAst =<< getField o "value")
+      _ -> Left ("Unknown DoStmt tag: " <> tag)
+  _ -> Left "DoStmt must be an object"
+
+-- ---------------------------------------------------------------------------
+-- JSON encoder (= parseAst の正確な逆。 round-trip: parseAst . exprToJSON ≡ Right)
+--
+-- Phase 27.5 step 3 (2026-06-01): topology B で backend が stream sidecar に
+-- start.params を組む際、 resolveHbmModel が返す Expr / TopBind を worker
+-- (= parseAst で decode) が読める JSON 文字列に直す必要がある。 decoder と
+-- 同じ module に逆変換を置き、 tag / field 名のズレを構造的に防ぐ。
+--
+-- 注: DoBind の value は sample wrapper を剥がした dist-only を前提とする
+-- (parseDoStmt は sample wrapper を剥がすが、 既に剥がれた式には作用しない =
+-- idempotent。 resolveHbmModel 経由の Expr は剥がし済)。
+-- ---------------------------------------------------------------------------
+
+exprToJSON :: Expr -> A.Value
+exprToJSON e = case e of
+  ELit l       -> obj "ELit"  ["lit"   A..= litToJSON l]
+  ECol n       -> obj "ECol"  ["name"  A..= n]
+  EVar n       -> obj "EVar"  ["name"  A..= n]
+  EApp f x     -> obj "EApp"  ["f"     A..= exprToJSON f, "x" A..= exprToJSON x]
+  ELam a b     -> obj "ELam"  ["arg"   A..= a, "body" A..= exprToJSON b]
+  EIf c a b    -> obj "EIf"   ["c"     A..= exprToJSON c, "a" A..= exprToJSON a, "b" A..= exprToJSON b]
+  ELet bs body -> obj "ELet"  ["binds" A..= map bindToJSON bs, "body" A..= exprToJSON body]
+  ENeg x       -> obj "ENeg"  ["e"     A..= exprToJSON x]
+  EOp op a b   -> obj "EOp"   ["op"    A..= op, "a" A..= exprToJSON a, "b" A..= exprToJSON b]
+  EList xs     -> obj "EList" ["items" A..= map exprToJSON xs]
+  EDo stmts r  -> obj "EDo"   ["stmts" A..= map doStmtToJSON stmts, "ret" A..= exprToJSON r]
+  where
+    obj :: Text -> [Pair] -> A.Value
+    obj tag fields = A.object (("tag" A..= tag) : fields)
+
+litToJSON :: Lit -> A.Value
+litToJSON l = case l of
+  LNumber n -> A.object ["tag" A..= ("LNumber" :: Text), "value" A..= n]
+  LText t   -> A.object ["tag" A..= ("LText" :: Text),   "value" A..= t]
+  LBool b   -> A.object ["tag" A..= ("LBool" :: Text),   "value" A..= b]
+
+bindToJSON :: Bind -> A.Value
+bindToJSON (Bind n v) = A.object ["name" A..= n, "value" A..= exprToJSON v]
+
+doStmtToJSON :: DoStmt -> A.Value
+doStmtToJSON s = case s of
+  DoBind n v -> A.object ["tag" A..= ("DoBind" :: Text), "name" A..= n, "value" A..= exprToJSON v]
+  DoLet bs   -> A.object ["tag" A..= ("DoLet"  :: Text), "binds" A..= map bindToJSON bs]
+  DoExpr v   -> A.object ["tag" A..= ("DoExpr" :: Text), "value" A..= exprToJSON v]
+
+-- | @EApp (EApp (EVar f) a) b@ → @(f, [a, b])@。 distribution / 関数適用の
+-- head + 引数列を取り出す。 head が変数でなければ Left。
+collectApp :: Expr -> Err (Text, [Expr])
+collectApp e0 = go e0 []
+  where
+    go (EVar n) acc = Right (n, acc)
+    go (EApp f x) acc = go f (x : acc)
+    go _ _ = Left "Distribution must be a function applied to scalar args"
+
+-- ---------------------------------------------------------------------------
+-- helpers
+-- ---------------------------------------------------------------------------
+
+getField :: A.Object -> Text -> Either Text A.Value
+getField o k = case KM.lookup (Key.fromText k) o of
+  Just v -> Right v
+  Nothing -> Left ("Missing field: " <> k)
+
+getStr :: A.Object -> Text -> Either Text Text
+getStr o k = case KM.lookup (Key.fromText k) o of
+  Just (A.String s) -> Right s
+  _ -> Left ("Field not string: " <> k)
+
+getNum :: A.Object -> Text -> Either Text Double
+getNum o k = case KM.lookup (Key.fromText k) o of
+  Just (A.Number n) -> Right (realToFrac n)
+  _ -> Left ("Field not number: " <> k)
+
+getBool :: A.Object -> Text -> Either Text Bool
+getBool o k = case KM.lookup (Key.fromText k) o of
+  Just (A.Bool b) -> Right b
+  _ -> Left ("Field not bool: " <> k)
+
+getArray :: A.Object -> Text -> Either Text [A.Value]
+getArray o k = case KM.lookup (Key.fromText k) o of
+  Just (A.Array xs) -> Right (V.toList xs)
+  _ -> Left ("Field not array: " <> k)
diff --git a/src/Hanalyze/Model/HBM/Distribution.hs b/src/Hanalyze/Model/HBM/Distribution.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Model/HBM/Distribution.hs
@@ -0,0 +1,1381 @@
+{-# LANGUAGE RankNTypes #-}
+{-# LANGUAGE DeriveFunctor #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+-- |
+-- Module      : Hanalyze.Model.HBM.Distribution
+-- Description : HBM の多相確率分布 ADT と密度・CDF
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- HBM の多相確率分布 ADT と密度・CDF。
+--
+-- 'Distribution' は値型 @a@ に多相な確率分布。 @a@ は @Double@ (サンプリング・
+-- 密度)、 @Reverse s Double@ (AD 勾配)、 @Track@ (依存追跡) を渡せる。 本モジュール
+-- は型・名前・**事前密度** 'logDensity'・多変量密度・閉形式 CDF を提供し、 純粋
+-- leaf 'Hanalyze.Model.HBM.Util' のみに依存する。
+--
+-- ★観測尤度 'logDensityObs' / 'obsLogSum' は **含めない** (Eval 層へ残置。
+-- Distribution→Eval の cycle を避けるため・request/254)。
+--
+-- Phase 58.3 で 'Hanalyze.Model.HBM' から責務分離して抽出。 数値は 1 bit 不変。
+module Hanalyze.Model.HBM.Distribution
+  ( Distribution (..)
+  , distName
+  , nameToTransform
+  , distToTransform
+  , logDensity
+  , logDensityRD
+  , logDensityObs
+  , obsLogSum
+  , multinomialLogDensity
+  , mvNormalLogDensity
+  , mvNormalCholLogDensity
+  , mvStudentTLogDensity
+  , dirichletMultinomialLogDensity
+  , wishartLogDensity
+  , erfA
+  , phiCdfA
+  , distCDF
+  , logCDF
+  , logSF
+  , logCDFInterval
+  ) where
+
+import Data.List (mapAccumL, zip4)
+import Data.Text (Text)
+-- Phase 92 B3: 'logDensityRD' (AD 定数正規化項の畳み込み) 用。 多相 'logDensity'
+-- 本体は AD 非依存のまま。
+import Data.Reflection (Reifies)
+import qualified Numeric.AD.Internal.Reverse.Double as ADRD
+import Hanalyze.Model.HBM.Util
+import Hanalyze.Stat.Distribution (Transform (..))
+
+-- ---------------------------------------------------------------------------
+-- 多相分布
+-- ---------------------------------------------------------------------------
+
+-- | A probability distribution polymorphic in its value type @a@.
+--
+-- @a@ ranges over @Double@ (sampling and density), @Reverse s Double@
+-- (AD-based gradient), @Track@ (dependency tracking) and so on.
+data Distribution a
+  = Normal      a a       -- ^ Normal(μ, σ)
+  | Exponential a         -- ^ Exp(rate)
+  | Gamma       a a       -- ^ Gamma(shape, rate)
+  | Beta        a a       -- ^ Beta(α, β)
+  | Poisson     a         -- ^ Poisson(λ)
+  | Binomial    Int a     -- ^ Binomial(n, p)
+  | Uniform     a a       -- ^ Uniform(low, high)
+  | StudentT    a a a     -- ^ StudentT(ν degrees of freedom, μ location, σ scale)
+  | Cauchy      a a       -- ^ Cauchy(x₀ location, γ scale)
+  | HalfNormal  a         -- ^ HalfNormal(σ) — support: x ≥ 0
+  | HalfCauchy  a         -- ^ HalfCauchy(γ scale) — support: x ≥ 0
+  | LogNormal   a a       -- ^ LogNormal(μ log-mean, σ log-sd) — support: x > 0
+  | Bernoulli   a         -- ^ Bernoulli(p) — observed: 0 or 1
+  | Categorical [a]       -- ^ Categorical(probs) — observed: 0..K-1
+  | Mixture [a] [Distribution a]
+    -- ^ @Mixture(weights, components)@ —
+    --   @log p(x) = logSumExp(log w_k + log p_k(x))@.
+    --   Weights need only be positive; they are auto-normalized.
+  | Truncated (Distribution a) (Maybe a) (Maybe a)
+    -- ^ @Truncated(d, lo, hi)@: restrict the support of @d@ to
+    --   @[lo, hi]@. Out-of-range observations get @-∞@.
+    --   'Nothing' bounds mean @-∞ / +∞@. Only base distributions with a
+    --   CDF (Normal / Exponential / LogNormal / Uniform) are supported.
+  | Censored  (Distribution a) (Maybe a) (Maybe a)
+    -- ^ @Censored(d, lo, hi)@: censor @y ≤ lo@ on the left and
+    --   @y ≥ hi@ on the right. When @y_i@ equals a threshold the CDF/SF
+    --   is used. Useful for Tobit-style models. Only CDF-supporting
+    --   base distributions.
+  | MvNormal [a] [[a]]
+    -- ^ @MvNormal(μ, Σ)@: multivariate normal (observation-only).
+    --   @μ@ is a length-@k@ mean vector, @Σ@ is the @k×k@
+    --   symmetric-positive-definite covariance. Pass @k@-vector
+    --   observations through 'observeMV'. Density is computed via
+    --   Cholesky. /Not supported/ as a latent ('sample' returns 0
+    --   density).
+  | MvNormalChol [a] [a] [[a]]
+    -- ^ @MvNormalChol(μ, σ, L)@: multivariate normal parameterized by a
+    --   scale vector @σ@ (length @k@) and a /correlation/ Cholesky factor
+    --   @L@ (lower-triangular @k×k@, typically from 'lkjCorrCholesky').
+    --   The covariance is @Σ = (diag σ · L)(diag σ · L)ᵀ@. The density
+    --   uses the scaled Cholesky @M = diag σ · L@ directly (forward
+    --   substitution, no re-decomposition) — numerically the most stable
+    --   parameterization (Stan's @multi_normal_cholesky@ idiom).
+    --   Observation-only; pass @k@-vectors via 'observeMV'.
+  | MvNormalGpRBF [a] a a a
+    -- ^ Phase 95 B-dsl: @MvNormalGpRBF(x, α, ρ, σ)@ — zero-mean GP 回帰尤度
+    --   専用の多変量正規 (observation-only)。 共分散は RBF (exp-quad) カーネル
+    --   @Σ_ij = α² exp(-0.5 (x_i-x_j)²/ρ²) + [i=j](1e-10 + σ)@ で内部構築する。
+    --   汎用 'MvNormal' と密度は同値だが、 カーネルの役割 (x/α/ρ/σ) を型で明示
+    --   保持することで、 勾配コンパイラ ('gpRBFAnalyticVG') が **Cholesky を AD
+    --   tape に載せない閉形式随伴** (@∂Σ/∂α=2K'/α@・@∂Σ/∂ρ=K'∘d²/ρ³@・
+    --   @∂Σ/∂σ=I@) を使える。 @x@ は共変量 data (定数)、 α/ρ/σ は latent。
+    --   観測は length-@k@ ベクトルを 'observeMV' で渡す (μ=0 固定)。
+  | HmmForwardNormal [a] [[a]] [a] a
+    -- ^ Phase 92 A2: @HmmForwardNormal(π_0, trans, μs, σ)@ — Normal emission の
+    --   隠れマルコフモデル周辺尤度 (observation-only)。 観測列 y_{1..T} 全体を
+    --   1 つの多変量観測として 'observeMV' で渡す (@observeMV nm d [ys]@)。
+    --   密度は @'hmmForwardLogLik' π_0 trans emit@ (emit[t][k] =
+    --   Normal(μs[k], σ) の logpdf(y_t)) と同値。 状態役割 (π_0/遷移行/emission
+    --   平均/σ) を型で明示保持することで、 勾配コンパイラ ('hmmAnalyticVG') が
+    --   **forward-backward の閉形式随伴** (∂logL/∂emit = γ_t・∂logL/∂T_ij = ξ
+    --   集計・AD tape ゼロ) を使える。 π_0 は非正規化可 (log 空間で加算されるのみ)。
+  | ArmaNormal a a a a
+    -- ^ Phase 101 A2: @ArmaNormal(μ, φ, θ, σ)@ — ARMA(1,1) の条件付き尤度
+    --   (observation-only)。 観測列 y_{1..T} 全体を 1 つの多変量観測として
+    --   'observeMV' で渡す (@observeMV nm d [ys]@)。 密度は Stan 原典 arma11 の
+    --   err 逐次再帰 (@err_1 = y_1 − (μ+φμ)@・@err_t = y_t − μ − φ·y_{t−1} −
+    --   θ·err_{t−1}@・@err_t ~ Normal(0, σ)@) と同値。 役割 (μ/φ/θ/σ) を型で
+    --   明示保持することで、 勾配コンパイラ ('armaAnalyticVG') が **逆向き
+    --   1 パスの閉形式随伴** (@ē_t = −e_t/σ² − θ·ē_{t+1}@ の線形随伴再帰・
+    --   AD tape ゼロ) を使える。
+  | GradedResponseIrt [a] [Int] [Double] [[Double]]
+    -- ^ Phase 101 A3: @GradedResponseIrt(θs, ncats, δs, γs)@ — graded response
+    --   IRT (順序ロジット・BUGS bones) の尤度 (observation-only)。 @θs@ =
+    --   受験者能力 (latent・唯一の param 側)、 @ncats[j]@/@δs[j]@/@γs[j][k]@ =
+    --   項目 j のカテゴリ数/識別力/カットポイント (**定数データ**)。 観測は
+    --   grade 行列 (nChild×nItem 行優先・1-based カテゴリ・欠測 = −1) を
+    --   'observeMV' で 1 観測として渡す (@observeMV nm d [grades]@)。
+    --   密度は @Q_k = invlogit(δ(θ−γ_k))@ の隣接差 p のカテゴリ対数確率と
+    --   同値。 θ_i (スカラ) 毎に独立なため、 勾配コンパイラ
+    --   ('gradedIrtAnalyticVG') が **解析勾配** (@dQ/dθ = δ·Q(1−Q)@ の差分・
+    --   AD tape ゼロ) を使える。
+  | NegativeBinomial a a
+    -- ^ @NegativeBinomial(μ, α)@ (PyMC parameterization).
+    --   @mean = μ@, @var = μ + μ²/α@ (Poisson in the limit
+    --   @α → ∞@). Likelihood for over-dispersed count data;
+    --   observations are non-negative integers.
+  | Multinomial Int [a]
+    -- ^ @Multinomial(n, [p_0, …, p_{K-1}])@ (observation-only).
+    --   @n@ is the trial count and @p@ the probability vector.
+    --   Observations are @K@-dimensional count vectors summing to @n@,
+    --   passed via 'observeMV'.
+  | ZeroInflatedPoisson a a
+    -- ^ @ZeroInflatedPoisson(ψ, λ)@: zero-inflated Poisson.
+    --   @ψ ∈ [0, 1]@ is the structural-zero probability.
+    --   @P(0) = ψ + (1-ψ) e^{-λ}@,
+    --   @P(k>0) = (1-ψ) λ^k e^{-λ} / k!@.
+  | ZeroInflatedBinomial Int a a
+    -- ^ @ZeroInflatedBinomial(n, ψ, p)@: zero-inflated binomial.
+    --   @P(0) = ψ + (1-ψ) (1-p)^n@,
+    --   @P(k>0) = (1-ψ) C(n,k) p^k (1-p)^{n-k}@.
+  | InverseGamma a a
+    -- ^ @InverseGamma(α, β)@. Support @x > 0@. If
+    --   @X ~ InverseGamma(α, β)@ then @1/X ~ Gamma(α, β)@ (rate
+    --   parameterization). Common conjugate prior on variance
+    --   (@mean = β/(α−1)@, finite when @α > 1@).
+  | Weibull a a
+    -- ^ @Weibull(k shape, λ scale)@: a standard survival distribution.
+    --   Support @x > 0@. @pdf = (k/λ) (x/λ)^{k-1} exp(-(x/λ)^k)@.
+    --   With @k = 1@ this is @Exponential(rate = 1/λ)@.
+  | Pareto a a
+    -- ^ @Pareto(α shape, x_m scale)@: heavy-tailed power law.
+    --   Support @x ≥ x_m > 0@. @pdf = α x_m^α / x^{α+1}@.
+    --   Mean @= α x_m / (α-1)@ when @α > 1@.
+  | BetaBinomial Int a a
+    -- ^ @BetaBinomial(n, α, β)@ overdispersed binomial
+    --   (observation-only).
+    --   @P(k) = C(n, k) B(k+α, n-k+β) / B(α, β)@. With @α = β = 1@
+    --   this is uniform on @{0, …, n}@; large @α/β@ tends to a
+    --   binomial.
+  | VonMises a a
+    -- ^ @VonMises(μ location, κ concentration)@: distribution on the
+    --   circle @(-π, π]@.
+    --   @pdf = exp(κ cos(x − μ)) / (2π I_0(κ))@.
+    --   @κ → 0@ approaches uniform; @κ → ∞@ approaches
+    --   @Normal(μ, 1/√κ)@.
+  | SkewNormal a a a
+    -- ^ @SkewNormal(μ location, σ scale, α shape)@ (Phase 37-A2).
+    --   @pdf = (2/σ) φ((x−μ)/σ) Φ(α(x−μ)/σ)@.
+    --   @α = 0@ で標準正規。 @α > 0@ で右側に歪み、 @α < 0@ で左側。
+    --   Sample は Henze 1986: @δ = α/√(1+α²)@,
+    --   @X = μ + σ(δ |U₀| + √(1−δ²) U₁)@ with i.i.d. @U_i ~ N(0,1)@.
+  | Logistic a a
+    -- ^ @Logistic(μ location, s scale)@ (Phase 37-A2).
+    --   @pdf = e^{−z} / (s(1+e^{−z})²)@ with @z = (x−μ)/s@.
+    --   平均 @μ@、 分散 @s²π²/3@。 closed-form CDF あり。
+  | Gumbel a a
+    -- ^ @Gumbel(μ location, β scale)@ (Phase 37-A2、 最大値型極値分布)。
+    --   @pdf = (1/β) exp(−z − e^{−z})@ with @z = (x−μ)/β@.
+    --   平均 @μ + βγ@ (γ ≈ 0.5772 オイラー定数)、 分散 @β²π²/6@。
+    --   closed-form CDF: @F(x) = exp(−exp(−z))@.
+  | AsymmetricLaplace a a a
+    -- ^ @AsymmetricLaplace(b scale > 0, κ asymmetry > 0, μ location)@
+    --   (Phase 37-A2、 PyMC parameterization、 分位点回帰の尤度)。
+    --   @pdf = b/(κ+1/κ) · exp(−b·κ·(x−μ))@ for @x ≥ μ@、
+    --   @pdf = b/(κ+1/κ) · exp(b/κ·(x−μ))@ for @x < μ@。
+    --   @κ = 1@ で対称ラプラス、 @κ > 1@ で右側裾長。
+  | OrderedLogistic a [a]
+    -- ^ @OrderedLogistic(η linear predictor, cuts = [c₁, …, c_{K-1}])@
+    --   (Phase 37-A3、 順序ロジット回帰)。
+    --   観測 @y ∈ {0, …, K-1}@、
+    --   @P(y=k) = σ(c_{k+1} − η) − σ(c_k − η)@ with
+    --   @σ(x) = 1/(1+e^{-x})@, @c_0 = −∞, c_K = +∞@.
+    --   cuts は **increasing** 列、 入力側で確保すること。
+    --   observation-only。
+  | DiscreteUniform Int Int
+    -- ^ @DiscreteUniform(lo, hi)@ (Phase 37-A3、 包含両端)。
+    --   @pmf = 1/(hi-lo+1)@ for @lo ≤ y ≤ hi@。 observation-only。
+  | Geometric a
+    -- ^ @Geometric(p)@ (Phase 37-A3、 PyMC 慣例 = 初回成功までの試行回数)。
+    --   support @y = 1, 2, 3, …@、 @pmf = (1−p)^{y-1} p@。
+    --   observation-only。
+  | HyperGeometric Int Int Int
+    -- ^ @HyperGeometric(N total, K successes, n draws)@ (Phase 37-A3、
+    --   非復元抽出の成功数)。
+    --   @pmf = C(K, y) C(N-K, n-y) / C(N, n)@、
+    --   support @max(0, n+K-N) ≤ y ≤ min(n, K)@。 observation-only。
+  | ZeroInflatedNegativeBinomial a a a
+    -- ^ @ZeroInflatedNegativeBinomial(ψ, μ, α)@ (Phase 37-A3、 過分散ゼロ過剰)。
+    --   @P(0) = ψ + (1-ψ) (α/(α+μ))^α@、
+    --   @P(k>0) = (1-ψ) · NegBin(k | μ, α)@。
+  | MvStudentT a [a] [[a]]
+    -- ^ @MvStudentT(ν, μ, Σ)@ (Phase 37-A4、 ロバスト多変量)。
+    --   @ν > 0@ 自由度、 @μ@ は @k@ 次元平均、 @Σ@ は @k×k@ SPD scale matrix。
+    --   観測 (observation-only)、 @y :: [Double]@ は flatten された
+    --   @k@ ベクトル列 (@observeMV@ で渡す)。
+    --   @ν → ∞@ で MvNormal に収束。
+  | DirichletMultinomial Int [a]
+    -- ^ @DirichletMultinomial(n trials, α concentration K-vector)@
+    --   (Phase 37-A4、 過分散 multinomial)。
+    --   観測 y は @K@ 次元 counts、 @Σ yᵢ = n@。
+    --   @logpmf = log Γ(α₀) − log Γ(α₀+n)
+    --           + Σ [log Γ(yᵢ+αᵢ) − log Γ(αᵢ)]
+    --           + log n! − Σ log yᵢ!@、 @α₀ = Σαᵢ@.
+    --   observation-only。
+  | Triangular a a a
+    -- ^ @Triangular(lower, c mode, upper)@ (Phase 39-A1、 弱情報事前)。
+    --   Support @[lower, upper]@、 @lower ≤ c ≤ upper@。
+    --   @pdf = 2(x-lower)/((upper-lower)(c-lower))@ for @lower ≤ x ≤ c@、
+    --   @pdf = 2(upper-x)/((upper-lower)(upper-c))@ for @c < x ≤ upper@。
+    --   closed-form CDF / 逆 CDF sample。
+  | Kumaraswamy a a
+    -- ^ @Kumaraswamy(a, b)@ (Phase 39-A1、 Beta 代替、 closed-form CDF)。
+    --   Support @(0, 1)@、 @pdf = a·b·x^{a-1}(1-x^a)^{b-1}@。
+    --   CDF @= 1 - (1-x^a)^b@、 sample @x = (1-(1-u)^{1/b})^{1/a}@。
+  | Rice a a
+    -- ^ @Rice(ν, σ)@ (Phase 39-A1、 MRI / Rayleigh 拡張)。
+    --   Support @x ≥ 0@、 @ν ≥ 0@、 @σ > 0@。
+    --   @pdf = (x/σ²) exp(-(x²+ν²)/(2σ²)) I_0(xν/σ²)@、
+    --   @ν = 0@ で Rayleigh(σ)。 @logBesselI0@ で評価。
+    --   sample: @X = √(Y₁² + Y₂²)@ with @Y₁ ~ N(ν, σ²), Y₂ ~ N(0, σ²)@。
+  | DiscreteWeibull a a
+    -- ^ @DiscreteWeibull(q, β)@ (Phase 39-A1、 整数 Weibull)。
+    --   Support @{0, 1, 2, …}@、 @0 < q < 1, β > 0@。
+    --   @P(X ≤ k) = 1 - q^{(k+1)^β}@、
+    --   @pmf(k) = q^{k^β} - q^{(k+1)^β}@。 observation-only。
+    --   sample: @k = ⌈(log(1-u)/log q)^{1/β}⌉ - 1@。
+  | Wishart a [[a]]
+    -- ^ @Wishart(ν degrees, V scale matrix)@ (Phase 39-A2、 共分散プライアの直接表現)。
+    --   @ν > k-1@、 @V@ は @k×k@ SPD scale matrix。
+    --   観測 (observation-only)、 @k×k@ 観測行列 W を flatten で渡す
+    --   (長さ @k²@、 row-major)。 @observeMV@ で渡す想定。
+    --   @logpdf(W) = -(νk/2) log 2 - (ν/2) log|V| - log Γ_k(ν/2)
+    --              + ((ν-k-1)/2) log|W| - (1/2) tr(V⁻¹ W)@、
+    --   @log Γ_k(z) = (k(k-1)/4) log π + Σ_{i=1}^k log Γ((z+1-i)/2)@。
+  | Bound (Distribution a) (Maybe a) (Maybe a)
+    -- ^ @Bound(d, lo, hi)@ (Phase 39-A3、 PyMC 互換)。
+    --   @d@ の支持を @[lo, hi]@ に制限する。 'Truncated' とほぼ同義
+    --   (実装も委譲)。 'Nothing' は @-∞ / +∞@。
+    --   違いは語用論のみ: PyMC では prior 寄りで Bound、 観測寄りで
+    --   Truncated を使う慣例があるため API として並べた。
+  | OrderedProbit a [a]
+    -- ^ @OrderedProbit(η linear predictor, cuts = [c₁, …, c_{K-1}])@
+    --   (Phase 39-A3、 順序プロビット回帰)。
+    --   @P(y=k) = Φ(c_{k+1} − η) − Φ(c_k − η)@ with
+    --   @c_0 = −∞, c_K = +∞@、 Φ は標準正規 CDF (@phiCdfA@)。
+    --   cuts は increasing 列、 入力側で確保。 observation-only。
+  deriving (Show, Functor)
+
+-- | Display name of a distribution constructor (e.g. @\"Normal\"@).
+distName :: Distribution a -> Text
+distName Normal{}      = "Normal"
+distName Exponential{} = "Exponential"
+distName Gamma{}       = "Gamma"
+distName Beta{}        = "Beta"
+distName Poisson{}     = "Poisson"
+distName Binomial{}    = "Binomial"
+distName Uniform{}     = "Uniform"
+distName StudentT{}    = "StudentT"
+distName Cauchy{}      = "Cauchy"
+distName HalfNormal{}  = "HalfNormal"
+distName HalfCauchy{}  = "HalfCauchy"
+distName LogNormal{}   = "LogNormal"
+distName Bernoulli{}   = "Bernoulli"
+distName Categorical{} = "Categorical"
+distName Mixture{}     = "Mixture"
+distName Truncated{}   = "Truncated"
+distName Censored{}    = "Censored"
+distName MvNormal{}    = "MvNormal"
+distName MvNormalChol{} = "MvNormalChol"
+distName MvNormalGpRBF{} = "MvNormalGpRBF"
+distName HmmForwardNormal{} = "HmmForwardNormal"
+distName ArmaNormal{} = "ArmaNormal"
+distName GradedResponseIrt{} = "GradedResponseIrt"
+distName NegativeBinomial{} = "NegativeBinomial"
+distName Multinomial{}          = "Multinomial"
+distName ZeroInflatedPoisson{}  = "ZeroInflatedPoisson"
+distName ZeroInflatedBinomial{} = "ZeroInflatedBinomial"
+distName InverseGamma{}         = "InverseGamma"
+distName Weibull{}              = "Weibull"
+distName Pareto{}               = "Pareto"
+distName BetaBinomial{}         = "BetaBinomial"
+distName VonMises{}             = "VonMises"
+distName SkewNormal{}           = "SkewNormal"
+distName Logistic{}             = "Logistic"
+distName Gumbel{}               = "Gumbel"
+distName AsymmetricLaplace{}    = "AsymmetricLaplace"
+distName OrderedLogistic{}      = "OrderedLogistic"
+distName DiscreteUniform{}      = "DiscreteUniform"
+distName Geometric{}            = "Geometric"
+distName HyperGeometric{}       = "HyperGeometric"
+distName ZeroInflatedNegativeBinomial{} = "ZeroInflatedNegativeBinomial"
+distName MvStudentT{}           = "MvStudentT"
+distName DirichletMultinomial{} = "DirichletMultinomial"
+distName Triangular{}           = "Triangular"
+distName Kumaraswamy{}          = "Kumaraswamy"
+distName Rice{}                 = "Rice"
+distName DiscreteWeibull{}      = "DiscreteWeibull"
+distName Wishart{}              = "Wishart"
+distName Bound{}                = "Bound"
+distName OrderedProbit{}        = "OrderedProbit"
+
+-- | 分布名 → NUTS が探索する **unconstrained 変換種別**。 latent の制約付き台
+-- (正値・単位区間) を実数空間へ写す種別を返す。
+--
+-- ★これが分布→変換の **唯一の表**。 'getTransforms'
+-- (@Gradient@・node walk 版) も本関数へ委譲する。 分布を latent 化して台が
+-- 変わる場合はここを更新する (1 箇所)。 未列挙は保守的に 'UnconstrainedT'。
+nameToTransform :: Text -> Transform
+nameToTransform "Exponential"  = PositiveT
+nameToTransform "Gamma"        = PositiveT
+nameToTransform "HalfNormal"   = PositiveT
+nameToTransform "HalfCauchy"   = PositiveT
+nameToTransform "LogNormal"    = PositiveT     -- support: x>0 (log は AD 安全)
+nameToTransform "InverseGamma" = PositiveT
+nameToTransform "Weibull"      = PositiveT
+nameToTransform "Pareto"       = PositiveT
+nameToTransform "Beta"         = UnitIntervalT
+nameToTransform "Bernoulli"    = UnitIntervalT -- p ∈ (0,1)
+nameToTransform "BetaBinomial" = UnitIntervalT
+nameToTransform _              = UnconstrainedT -- Normal/StudentT/Cauchy/Uniform 等
+-- 注: Uniform の真の制約変換は logit-on-(lo,hi) だが現状未実装 (unconstrained 扱い)。
+
+-- | 分布 (ADT) → unconstrained 変換種別。 'nameToTransform' の値レベル版。
+distToTransform :: Distribution a -> Transform
+distToTransform = nameToTransform . distName
+
+-- | Log probability of a single multinomial observation (a @K@-vector
+-- of counts).
+--   log P(k_1, …, k_K) = log n!/Π k_i! + Σ k_i log p_i
+{-# INLINABLE multinomialLogDensity #-}
+multinomialLogDensity :: forall a. (Floating a, Ord a)
+                      => Int -> [a] -> [Double] -> a
+multinomialLogDensity n probs counts
+  | length probs /= length counts = negInf
+  | sum (map round counts :: [Int]) /= n = negInf
+  | any (< 0) counts                = negInf
+  | any (\p -> p <= 0) probs        = negInf
+  | otherwise =
+      let logFactN = realToFrac (logFactorial n) :: a
+          logFactSum = sum [ realToFrac (logFactorial (round c :: Int)) :: a
+                           | c <- counts ]
+          dotPart = sum (zipWith (\c p -> realToFrac c * log p) counts probs)
+      in logFactN - logFactSum + dotPart
+
+-- | Log density of an 'MvNormal' at a single @k@-vector observation.
+--   log p(y) = -k/2 log(2π) - 0.5 log|Σ| - 0.5 (y-μ)ᵀ Σ⁻¹ (y-μ)
+--   Σ⁻¹ と log|Σ| は Cholesky 分解 Σ = L Lᵀ から計算。
+{-# INLINABLE mvNormalLogDensity #-}
+mvNormalLogDensity :: forall a. (Floating a, Ord a) => [a] -> [[a]] -> [a] -> a
+mvNormalLogDensity mu cov yObs
+  | length mu == 0           = 0
+  | length yObs /= length mu = negInf
+  | otherwise =
+      case choleskyL cov of
+        Nothing -> negInf
+        Just l  ->
+          let k      = length mu
+              kA     = fromIntegral k :: a
+              d      = zipWith (-) yObs mu
+              z      = forwardSub l d           -- L z = d
+              quad   = sum (map (\zi -> zi * zi) z)
+              logDet = 2 * sum [ log ((l !! i) !! i) | i <- [0 .. k - 1] ]
+          in -0.5 * kA * log (2 * pi) - 0.5 * logDet - 0.5 * quad
+
+-- | 'MvNormalChol' の 1 観測 (k-vector) の log density (Phase 44)。
+--   scale vector @σ@ と /相関/ Cholesky 因子 @L@ から scaled Cholesky
+--   @M = diag σ · L@ (= @M_ij = σ_i · L_ij@) を直接構成し、 共分散
+--   @Σ = M Mᵀ@ を /再分解せず/ 評価する:
+--     @log p(y) = -k/2 log(2π) - Σ log M_ii - 0.5 |z|²@、 @M z = (y-μ)@ を
+--   前進代入で解く。 @log|Σ| = 2 Σ log M_ii@ なので密度の @-0.5 log|Σ|@ は
+--   @-Σ log M_ii@。 'mvNormalLogDensity' (full Σ → choleskyL) と @Σ = M Mᵀ@ で
+--   数値一致する。 Stan の @multi_normal_cholesky@ と同じ idiom。
+{-# INLINABLE mvNormalCholLogDensity #-}
+mvNormalCholLogDensity :: forall a. (Floating a, Ord a) => [a] -> [a] -> [[a]] -> [a] -> a
+mvNormalCholLogDensity mu sigma l yObs
+  | k == 0                                       = 0
+  | length yObs /= k || length sigma /= k        = negInf
+  | length l /= k || any ((/= k) . length) l     = negInf
+  | otherwise =
+      let m      = [ [ (sigma !! i) * ((l !! i) !! j) | j <- [0 .. k - 1] ]
+                   | i <- [0 .. k - 1] ]
+          kA     = fromIntegral k :: a
+          d      = zipWith (-) yObs mu
+          z      = forwardSub m d           -- M z = d (M 下三角)
+          quad   = sum (map (\zi -> zi * zi) z)
+          logDet = sum [ log ((m !! i) !! i) | i <- [0 .. k - 1] ]  -- = 0.5 log|Σ|
+      in -0.5 * kA * log (2 * pi) - logDet - 0.5 * quad
+  where k = length mu
+
+-- | MvStudentT(ν, μ, Σ) の 1 観測 (k-vector) の log density (Phase 37-A4)。
+--   @logpdf(y) = log Γ((ν+k)/2) − log Γ(ν/2) − (k/2) log(νπ) − (1/2) log|Σ|
+--              − ((ν+k)/2) log(1 + m²/ν)@、
+--   @m² = (y−μ)ᵀ Σ⁻¹ (y−μ)@ を Cholesky で評価。
+{-# INLINABLE mvStudentTLogDensity #-}
+mvStudentTLogDensity :: forall a. (Floating a, Ord a)
+                     => a -> [a] -> [[a]] -> [a] -> a
+mvStudentTLogDensity nu mu cov yObs
+  | nu <= 0                   = negInf
+  | length mu == 0            = 0
+  | length yObs /= length mu  = negInf
+  | otherwise =
+      case choleskyL cov of
+        Nothing -> negInf
+        Just l  ->
+          let k      = length mu
+              kA     = fromIntegral k :: a
+              d      = zipWith (-) yObs mu
+              z      = forwardSub l d
+              quad   = sum (map (\zi -> zi * zi) z)
+              logDet = 2 * sum [ log ((l !! i) !! i) | i <- [0 .. k - 1] ]
+          in lgammaApprox ((nu + kA) / 2)
+           - lgammaApprox (nu / 2)
+           - 0.5 * kA * log (nu * pi)
+           - 0.5 * logDet
+           - 0.5 * (nu + kA) * log (1 + quad / nu)
+
+-- | DirichletMultinomial(n, α) の 1 観測 (K-vector counts) の log pmf
+--   (Phase 37-A4)。
+--   @logpmf = log Γ(α₀) − log Γ(α₀+n) + Σ [log Γ(yᵢ+αᵢ) − log Γ(αᵢ)]
+--           + log n! − Σ log yᵢ!@、 @α₀ = Σ αᵢ@.
+{-# INLINABLE dirichletMultinomialLogDensity #-}
+dirichletMultinomialLogDensity :: forall a. (Floating a, Ord a)
+                               => Int -> [a] -> [Double] -> a
+dirichletMultinomialLogDensity n alpha counts
+  | length alpha /= length counts = negInf
+  | sum (map round counts :: [Int]) /= n = negInf
+  | any (< 0) counts = negInf
+  | any (\al -> al <= 0) alpha = negInf
+  | otherwise =
+      let nA       = realToFrac (fromIntegral n :: Double) :: a
+          a0       = sum alpha
+          logFactN = realToFrac (logFactorial n) :: a
+          logFactSum = sum
+            [ realToFrac (logFactorial (round c :: Int)) :: a | c <- counts ]
+          term = sum
+            [ lgammaApprox (realToFrac c + ai)  -- yᵢ + αᵢ
+              - lgammaApprox ai
+            | (c, ai) <- zip counts alpha
+            ]
+      in lgammaApprox a0
+       - lgammaApprox (a0 + nA)
+       + term
+       + logFactN
+       - logFactSum
+
+-- | Wishart(ν, V) の 1 観測 (k×k 行列を flatten した長さ k² の列) の log density
+--   (Phase 39-A2)。
+--   @logpdf(W) = -(νk/2) log 2 - (ν/2) log|V| - log Γ_k(ν/2)
+--              + ((ν-k-1)/2) log|W| - (1/2) tr(V⁻¹ W)@、
+--   @log Γ_k(z) = (k(k-1)/4) log π + Σ_{i=1}^k log Γ((z+1-i)/2)@。
+--   V / W の Cholesky で log determinant と tr(V⁻¹ W) を評価。
+{-# INLINABLE wishartLogDensity #-}
+wishartLogDensity :: forall a. (Floating a, Ord a)
+                  => a -> [[a]] -> [a] -> a
+wishartLogDensity nu vRows wFlat
+  | nu <= fromIntegral (k - 1) = negInf
+  | length wFlat /= k * k      = negInf
+  | otherwise =
+      case (choleskyL vRows, choleskyL wRows) of
+        (Just lV, Just lW) ->
+          let logDetV = 2 * sum [ log ((lV !! i) !! i) | i <- [0 .. k - 1] ]
+              logDetW = 2 * sum [ log ((lW !! i) !! i) | i <- [0 .. k - 1] ]
+              -- tr(V⁻¹ W) を列ごとに solve V z_j = w_j で計算
+              wCols   = [ [ (wRows !! i) !! j | i <- [0 .. k - 1] ]
+                        | j <- [0 .. k - 1] ]
+              solveV b =
+                let y = forwardSub lV b
+                    x = backSubLT lV y     -- Lᵀ x = y
+                in x
+              traceVW = sum [ solveV (wCols !! j) !! j
+                            | j <- [0 .. k - 1] ]
+              kA      = fromIntegral k :: a
+              -- log Γ_k(ν/2)
+              logMvGam =
+                (kA * (kA - 1) / 4) * log pi
+                + sum [ lgammaApprox ((nu + 1 - fromIntegral i) / 2)
+                      | i <- [1 .. k] ]
+          in -(nu * kA / 2) * log 2
+           - (nu / 2) * logDetV
+           - logMvGam
+           + ((nu - kA - 1) / 2) * logDetW
+           - 0.5 * traceVW
+        _ -> negInf
+  where
+    k     = length vRows
+    wRows = chunksOf k wFlat
+
+-- ---------------------------------------------------------------------------
+-- 多相 CDF / log-CDF (Truncated / Censored 用)
+-- ---------------------------------------------------------------------------
+
+-- | 多相 erf 近似 (Abramowitz & Stegun 7.1.26)。誤差 < 1.5e-7。
+-- AD でも Track でも動く。
+{-# INLINABLE erfA #-}
+erfA :: (Floating a, Ord a) => a -> a
+erfA x =
+  let p   = 0.3275911
+      a1  = 0.254829592
+      a2  = -0.284496736
+      a3  = 1.421413741
+      a4  = -1.453152027
+      a5  = 1.061405429
+      sgn = if x < 0 then -1 else 1
+      ax  = abs x
+      t   = 1 / (1 + p * ax)
+      poly = a1*t + a2*t*t + a3*t*t*t + a4*t*t*t*t + a5*t*t*t*t*t
+  in sgn * (1 - poly * exp (- ax * ax))
+
+-- | 標準正規 CDF Φ(x)。
+{-# INLINABLE phiCdfA #-}
+phiCdfA :: (Floating a, Ord a) => a -> a
+phiCdfA x = 0.5 * (1 + erfA (x / sqrt 2))
+
+-- | CDF @F(x) = P(Y ≤ x)@ of a 'Distribution'. Returns 'Nothing' for
+-- distributions that do not have a closed-form CDF in this library.
+{-# INLINABLE distCDF #-}
+distCDF :: (Floating a, Ord a) => Distribution a -> a -> Maybe a
+distCDF (Normal mu sig) x
+  | sig <= 0  = Nothing
+  | otherwise = Just (phiCdfA ((x - mu) / sig))
+distCDF (Exponential rate) x
+  | rate <= 0 = Nothing
+  | x <= 0    = Just 0
+  | otherwise = Just (1 - exp (-rate * x))
+distCDF (LogNormal mu sig) x
+  | sig <= 0 || x <= 0 = Nothing
+  | otherwise = Just (phiCdfA ((log x - mu) / sig))
+distCDF (Uniform lo hi) x
+  | hi <= lo  = Nothing
+  | x <= lo   = Just 0
+  | x >= hi   = Just 1
+  | otherwise = Just ((x - lo) / (hi - lo))
+distCDF (HalfNormal sig) x
+  | sig <= 0 = Nothing
+  | x <= 0   = Just 0
+  | otherwise = Just (erfA (x / (sig * sqrt 2)))
+distCDF (HalfCauchy sc) x
+  | sc <= 0 = Nothing
+  | x <= 0  = Just 0
+  | otherwise = Just (2 * atan (x / sc) / pi)
+distCDF (Cauchy loc sc) x
+  | sc <= 0   = Nothing
+  | otherwise = Just (0.5 + atan ((x - loc) / sc) / pi)
+distCDF (Gamma shape rate) x
+  | shape <= 0 || rate <= 0 = Nothing
+  | x <= 0                  = Just 0
+  | otherwise               = Just (incGammaPA shape (rate * x))
+distCDF (Beta a b) x
+  | a <= 0 || b <= 0 = Nothing
+  | x <= 0           = Just 0
+  | x >= 1           = Just 1
+  | otherwise        = Just (incBetaA x a b)
+distCDF (StudentT df mu sig) x
+  | df <= 0 || sig <= 0 = Nothing
+  | otherwise =
+      let z     = (x - mu) / sig
+          -- F_t(z; df) = 1 - 0.5 * I(df/(df+z²); df/2, 1/2)   (z >= 0)
+          --            =     0.5 * I(df/(df+z²); df/2, 1/2)   (z <  0)
+          ratio = df / (df + z * z)
+          ix    = incBetaA ratio (df / 2) 0.5
+      in Just (if z >= 0 then 1 - 0.5 * ix else 0.5 * ix)
+distCDF (Logistic mu s) x
+  | s <= 0    = Nothing
+  | otherwise = Just (1 / (1 + exp (-((x - mu) / s))))
+distCDF (Gumbel mu beta) x
+  | beta <= 0 = Nothing
+  | otherwise = Just (exp (- exp (-((x - mu) / beta))))
+distCDF (AsymmetricLaplace b kappa mu) x
+  | b <= 0 || kappa <= 0 = Nothing
+  | otherwise =
+      let k2  = kappa * kappa
+          pc  = k2 / (1 + k2)  -- F(μ)
+          d   = x - mu
+      in if d < 0
+           then Just (pc * exp ((b / kappa) * d))
+           else Just (1 - (1 - pc) * exp (- b * kappa * d))
+distCDF _ _ = Nothing  -- SkewNormal / 離散・Mixture・Truncated 内 Truncated 等は未対応
+
+-- | @log F(x)@. Computed as @log(F)@ directly to avoid loss of
+-- precision near the tails where @F@ approaches 0 or 1.
+{-# INLINABLE logCDF #-}
+logCDF :: (Floating a, Ord a) => Distribution a -> a -> a
+logCDF d x = case distCDF d x of
+  Nothing -> negInf
+  Just c | c <= 0    -> negInf
+         | c >= 1    -> 0
+         | otherwise -> log c
+
+-- | Log of the right-tail survival function @log(1 − F(x))@.
+{-# INLINABLE logSF #-}
+logSF :: (Floating a, Ord a) => Distribution a -> a -> a
+logSF d x = case distCDF d x of
+  Nothing -> negInf
+  Just c | c <= 0    -> 0
+         | c >= 1    -> negInf
+         | otherwise -> log (1 - c)
+
+-- | log(F(hi) − F(lo)) — Truncated の正規化定数。
+{-# INLINABLE logCDFInterval #-}
+logCDFInterval :: (Floating a, Ord a) => Distribution a -> Maybe a -> Maybe a -> a
+logCDFInterval d mLo mHi = case (mLo, mHi) of
+  (Nothing, Nothing) -> 0  -- log(1)
+  (Just lo, Nothing) -> logSF d lo
+  (Nothing, Just hi) -> logCDF d hi
+  (Just lo, Just hi) ->
+    case (distCDF d lo, distCDF d hi) of
+      (Just cl, Just ch)
+        | ch <= cl  -> negInf
+        | otherwise -> log (ch - cl)
+      _ -> negInf
+
+-- ---------------------------------------------------------------------------
+-- 多相 log 密度 (事前 logDensity + 観測 logDensityObs/obsLogSum)
+-- ---------------------------------------------------------------------------
+-- Phase 58.6: 元 HBM.hs の「事前 log 密度」節 (logDensity は 58.3 で AD 勾配と
+-- 同居のため残置していたが、 logJoint/logPrior が参照するため Eval 抽出 (58.6c) で
+-- back-edge になる。 密度は本来 Distribution の責務 (Phase 58 計画の module sketch)
+-- ゆえここへ集約する。 INLINABLE は AD 経路の cross-module inlining 維持のため保持。
+
+-- | Log prior density at a sample value of type @a@.
+{-# INLINABLE logDensity #-}
+logDensity :: (Floating a, Ord a) => Distribution a -> a -> a
+logDensity (Normal mu sig) x
+  | sig <= 0  = negInf
+  | otherwise = -0.5 * log (2 * pi) - log sig
+              - 0.5 * ((x - mu) / sig) ^ (2::Int)
+logDensity (Exponential rate) x
+  | x < 0 || rate <= 0 = negInf
+  | otherwise          = log rate - rate * x
+logDensity (Gamma shape rate) x
+  | x <= 0 || shape <= 0 || rate <= 0 = negInf
+  | otherwise =
+      (shape - 1) * log x - rate * x
+      + shape * log rate - lgammaApprox shape
+logDensity (Beta alpha beta) x
+  | x <= 0 || x >= 1 || alpha <= 0 || beta <= 0 = negInf
+  | otherwise =
+      (alpha - 1) * log x + (beta - 1) * log (1 - x)
+      - (lgammaApprox alpha + lgammaApprox beta - lgammaApprox (alpha + beta))
+logDensity (Poisson lam) x
+  | lam <= 0 = negInf
+  | x  < 0   = negInf
+  | otherwise =
+      -- x はサンプル値なので連続として扱う (整数化はしない)
+      x * log lam - lam
+logDensity (Binomial _ p) _
+  | p <= 0 || p >= 1 = negInf
+  | otherwise        = 0  -- サンプル時は使わない (構造のみ)
+logDensity (Uniform lo hi) x
+  | hi <= lo            = negInf
+  | x  < lo || x  > hi  = negInf
+  | otherwise           = -log (hi - lo)
+logDensity (StudentT df mu sig) x
+  | df <= 0 || sig <= 0 = negInf
+  | otherwise =
+      let z = (x - mu) / sig
+      in lgammaApprox ((df + 1) / 2)
+       - lgammaApprox (df / 2)
+       - 0.5 * log (df * pi)
+       - log sig
+       - ((df + 1) / 2) * log (1 + z * z / df)
+logDensity (Cauchy loc sc) x
+  | sc <= 0   = negInf
+  | otherwise =
+      let z = (x - loc) / sc
+      in -log pi - log sc - log (1 + z * z)
+logDensity (HalfNormal sig) x
+  | sig <= 0 = negInf
+  | x < 0    = negInf
+  | otherwise =
+      0.5 * log 2 - 0.5 * log pi - log sig
+      - 0.5 * (x / sig) ^ (2::Int)
+logDensity (HalfCauchy sc) x
+  | sc <= 0 = negInf
+  | x < 0   = negInf
+  | otherwise =
+      log 2 - log pi - log sc - log (1 + (x / sc) ^ (2::Int))
+logDensity (LogNormal mu sig) x
+  | sig <= 0 = negInf
+  | x  <= 0  = negInf
+  | otherwise =
+      let lx = log x
+      in -0.5 * log (2 * pi) - log sig - lx
+         - 0.5 * ((lx - mu) / sig) ^ (2::Int)
+logDensity (Bernoulli p) _
+  | p <= 0 || p >= 1 = negInf
+  | otherwise        = 0  -- 構造のみ (離散なので連続 prior 評価には使わない)
+logDensity (Categorical _) _ = 0  -- 同上
+logDensity (Mixture ws comps) x
+  | null ws || length ws /= length comps = negInf
+  | otherwise =
+      let total      = sum ws
+          logTotal   = log total
+          -- log(w_k / Σw) + log p_k(x)
+          logTerms   = zipWith (\w d -> log w - logTotal + logDensity d x) ws comps
+      in logSumExpA logTerms
+logDensity (Truncated d mLo mHi) x =
+  -- 範囲外なら 0 (=> log で −∞)
+  let outOfRange = case (mLo, mHi) of
+        (Just lo, _      ) | x < lo  -> True
+        (_,       Just hi) | x > hi  -> True
+        _                            -> False
+  in if outOfRange
+       then negInf
+       else logDensity d x - logCDFInterval d mLo mHi
+logDensity (Censored d _ _) x =
+  -- prior 評価では通常の密度を使う (打ち切りは観測時のみ意味を持つ)
+  logDensity d x
+logDensity MvNormal{} _ = 0  -- observation-only: latent としては使わない
+logDensity MvNormalChol{} _ = 0  -- observation-only
+logDensity MvNormalGpRBF{} _ = 0  -- observation-only (Phase 95 B-dsl)
+logDensity HmmForwardNormal{} _ = 0  -- observation-only (Phase 92 A2)
+logDensity ArmaNormal{} _ = 0  -- observation-only (Phase 101 A2)
+logDensity GradedResponseIrt{} _ = 0  -- observation-only (Phase 101 A3)
+logDensity Multinomial{} _ = 0  -- observation-only
+logDensity (InverseGamma alpha beta) x
+  | alpha <= 0 || beta <= 0 || x <= 0 = negInf
+  | otherwise =
+      alpha * log beta - lgammaApprox alpha
+      - (alpha + 1) * log x - beta / x
+logDensity (Weibull kShape lam) x
+  | kShape <= 0 || lam <= 0 || x <= 0 = negInf
+  | otherwise =
+      log kShape - log lam
+      + (kShape - 1) * (log x - log lam)
+      - (x / lam) ** kShape
+logDensity (Pareto alpha xm) x
+  | alpha <= 0 || xm <= 0 || x < xm = negInf
+  | otherwise =
+      log alpha + alpha * log xm - (alpha + 1) * log x
+logDensity BetaBinomial{} _ = 0  -- 観測専用 (離散)
+logDensity (VonMises mu kappa) x
+  | kappa <= 0 = negInf
+  | otherwise =
+      kappa * cos (x - mu)
+      - log (2 * pi)
+      - logBesselI0 kappa
+logDensity (ZeroInflatedPoisson psi lam) x
+  | psi < 0 || psi > 1 || lam <= 0 || x < 0 = negInf
+  | x == 0 =
+      -- log(ψ + (1-ψ) e^{-λ})
+      logSumExpA [log psi, log (1 - psi) - lam]
+  | otherwise =
+      -- log(1-ψ) + Poisson logpmf
+      log (1 - psi) + x * log lam - lam - lgammaApprox (x + 1)
+logDensity (ZeroInflatedBinomial n psi p) x
+  | psi < 0 || psi > 1 || p <= 0 || p >= 1 || x < 0 = negInf
+  | otherwise =
+      let nA   = realToFrac (fromIntegral n :: Double)
+          -- log(C(n,k)) = lgamma(n+1) - lgamma(k+1) - lgamma(n-k+1) (多相)
+          logC = lgammaApprox (nA + 1)
+               - lgammaApprox (x + 1)
+               - lgammaApprox (nA - x + 1)
+      in if x == 0
+           then logSumExpA [log psi
+                           , log (1 - psi) + nA * log (1 - p)]
+           else log (1 - psi)
+                + logC + x * log p + (nA - x) * log (1 - p)
+logDensity (NegativeBinomial mu alpha) x
+  | mu <= 0 || alpha <= 0 || x < 0 = negInf
+  | otherwise =
+      let p = alpha / (alpha + mu)        -- success prob
+      in lgammaApprox (x + alpha)
+       - lgammaApprox alpha
+       - lgammaApprox (x + 1)
+       + alpha * log p
+       + x * log (1 - p)
+logDensity (SkewNormal mu sig alpha) x
+  | sig <= 0  = negInf
+  | otherwise =
+      let z      = (x - mu) / sig
+          logPhi = -0.5 * log (2 * pi) - 0.5 * z * z
+          -- log Φ(αz) を phiCdfA 経由で。 引数が大きく負だと数値的に困るが、
+          -- phiCdfA は erfA ベースなので clip して log を取る
+          cdfArg = phiCdfA (alpha * z)
+          -- 数値下限 1e-300 程度に防御
+          logCdf = log (max cdfArg 1e-300)
+      in log 2 - log sig + logPhi + logCdf
+logDensity (Logistic mu s) x
+  | s <= 0    = negInf
+  | otherwise =
+      let z = (x - mu) / s
+      in -z - log s - 2 * log (1 + exp (-z))
+logDensity (Gumbel mu beta) x
+  | beta <= 0 = negInf
+  | otherwise =
+      let z = (x - mu) / beta
+      in -log beta - z - exp (-z)
+logDensity (AsymmetricLaplace b kappa mu) x
+  | b <= 0 || kappa <= 0 = negInf
+  | otherwise =
+      let logNorm = log b - log (kappa + 1 / kappa)
+          d       = x - mu
+      in if d >= 0
+           then logNorm - b * kappa * d
+           else logNorm + (b / kappa) * d
+-- 離散分布は構造のみ (observation-only の意味で logDensity は使われない)
+logDensity OrderedLogistic{} _      = 0
+logDensity DiscreteUniform{} _      = 0
+logDensity (Geometric p) _
+  | p <= 0 || p >= 1 = negInf
+  | otherwise        = 0
+logDensity HyperGeometric{} _       = 0
+logDensity (ZeroInflatedNegativeBinomial psi mu alpha) _
+  | psi < 0 || psi > 1 || mu <= 0 || alpha <= 0 = negInf
+  | otherwise = 0
+logDensity MvStudentT{} _ = 0          -- observation-only
+logDensity DirichletMultinomial{} _ = 0  -- observation-only
+logDensity (Triangular lo c hi) x
+  | hi <= lo || c < lo || c > hi = negInf
+  | x < lo || x > hi             = negInf
+  | x <= c =
+      log 2 + log (x - lo)
+      - log (hi - lo) - log (c - lo)
+  | otherwise =
+      log 2 + log (hi - x)
+      - log (hi - lo) - log (hi - c)
+logDensity (Kumaraswamy a b) x
+  | a <= 0 || b <= 0 || x <= 0 || x >= 1 = negInf
+  | otherwise =
+      let xa = x ** a
+      in log a + log b + (a - 1) * log x + (b - 1) * log (1 - xa)
+logDensity (Rice nu sig) x
+  | sig <= 0 || nu < 0 || x < 0 = negInf
+  | otherwise =
+      let s2 = sig * sig
+          z  = x * nu / s2
+      in log x - 2 * log sig - (x * x + nu * nu) / (2 * s2)
+         + logBesselI0 z
+logDensity DiscreteWeibull{} _ = 0   -- 離散: structure only
+logDensity Wishart{} _ = 0           -- observation-only (k×k 行列観測)
+logDensity (Bound d mLo mHi) x = logDensity (Truncated d mLo mHi) x
+logDensity OrderedProbit{} _ = 0     -- observation-only (離散)
+
+-- | 'logDensity' の AD ('ADRD.ReverseDouble') 特化版 (Phase 92 B3)。
+-- hyperparameter が**定数** ('ADRD.Zero' / 'ADRD.Lift' = tape 由来でない) の
+-- lgamma 正規化項を Double で 1 発計算して 'ADRD.Lift' で戻す。 'ADRD.Lift'
+-- 同士の AD 演算は @Lift (f b c)@ (同一の Double 演算列・tape 追記なし) なので
+-- 結果は generic 'logDensity' と **bit-identical**、 定数に勾配は流れないので
+-- 微分も不変。 hyperparameter が tape 変数 (階層 prior) なら generic へ
+-- fallback し勾配は AD がそのまま構成する。
+--
+-- 動機 (hmm reduced prof): Dirichlet(1,…,1) = 棒折り Beta(1,1) の定数濃度
+-- lgamma が AD walk 上で毎 eval Stirling recurrence (z<12 の梯子 ~11 段 ×
+-- lgamma 3 呼び出し) を boxed 'ADRD.Lift' で歩いていた (550,480 entries =
+-- 70 call/eval・time 6.6%/alloc 14.4%)。 対象は lgamma を持つ定数 prior 3 種
+-- (Beta / Gamma / StudentT の ν) のみ・折り畳み式の結合順は generic 実装と
+-- 完全一致させてある (bit 一致の根拠)。
+-- ※ 'lgammaApprox' への RULES 書き換えは過負荷関数 + 辞書引数で発火せず断念
+--    (2026-07-17 実測)、 呼び出し点注入 ('logPriorWith') 方式にした。
+logDensityRD
+  :: forall s. Reifies s ADRD.Tape
+  => Distribution (ADRD.ReverseDouble s) -> ADRD.ReverseDouble s
+  -> ADRD.ReverseDouble s
+logDensityRD d x = case d of
+  Beta a b
+    | Just a' <- constRD a, Just b' <- constRD b
+    , not (x <= 0 || x >= 1 || a' <= 0 || b' <= 0) ->
+        (a - 1) * log x + (b - 1) * log (1 - x)
+          - ADRD.Lift (lgammaApprox a' + lgammaApprox b' - lgammaApprox (a' + b'))
+  Gamma sh ra
+    | Just sh' <- constRD sh, Just ra' <- constRD ra
+    , not (x <= 0 || sh' <= 0 || ra' <= 0) ->
+        (sh - 1) * log x - ra * x
+          + ADRD.Lift (sh' * log ra') - ADRD.Lift (lgammaApprox sh')
+  StudentT df mu sig
+    | Just df' <- constRD df
+    , not (df' <= 0 || sig <= 0) ->
+        let z = (x - mu) / sig
+        in ADRD.Lift (lgammaApprox ((df' + 1) / 2) - lgammaApprox (df' / 2)
+                        - 0.5 * log (df' * pi))
+           - log sig
+           - ((df + 1) / 2) * log (1 + z * z / df)
+  _ -> logDensity d x
+  where
+    -- Zero/Lift = tape に乗らない定数 (Lift 同士の演算は Lift に閉じる)
+    constRD :: ADRD.ReverseDouble s -> Maybe Double
+    constRD ADRD.Zero             = Just 0
+    constRD (ADRD.Lift v)         = Just v
+    constRD ADRD.ReverseDouble{}  = Nothing
+
+-- | Log likelihood density at an observation (a fixed @Double@).
+-- Observations are passed as @[Double]@, so this uses only the
+-- @Floating a@ constraint.
+-- Phase 58.6c: ObserveLM 評価 (lmObsLogLiks) と logJoint が AD で微分しながら呼ぶ
+-- ホット経路。 58.6a で本体から移したため cross-module になった。 INLINABLE で
+-- 境界跨ぎ inline を維持 (M1/M2 の +25% 劣化を解消・58.6 bench 実測)。
+{-# INLINABLE logDensityObs #-}
+logDensityObs :: forall a. (Floating a, Ord a) => Distribution a -> Double -> a
+logDensityObs (Normal mu sig) y
+  | sig <= 0  = negInf
+  | otherwise =
+      let yA = realToFrac y :: a
+      in -0.5 * log (2 * pi) - log sig - 0.5 * ((yA - mu) / sig) ^ (2::Int)
+logDensityObs (Exponential rate) y
+  | y < 0      = negInf
+  | rate <= 0  = negInf
+  | otherwise  = log rate - rate * (realToFrac y :: a)
+logDensityObs (Gamma shape rate) y
+  | y <= 0     = negInf
+  | shape <= 0 || rate <= 0 = negInf
+  | otherwise  =
+      let yA = realToFrac y :: a
+      in (shape - 1) * log yA - rate * yA
+         + shape * log rate - lgammaApprox shape
+logDensityObs (Beta alpha beta) y
+  | y <= 0 || y >= 1 || alpha <= 0 || beta <= 0 = negInf
+  | otherwise =
+      let yA = realToFrac y :: a
+      in (alpha - 1) * log yA + (beta - 1) * log (1 - yA)
+         - (lgammaApprox alpha + lgammaApprox beta - lgammaApprox (alpha + beta))
+logDensityObs (Poisson lam) y
+  | lam <= 0 = negInf
+  | y < 0    = negInf
+  | otherwise =
+      let kA   = realToFrac y :: a
+          kInt = round y :: Int
+          logFactK = realToFrac (logFactorial kInt) :: a
+      in kA * log lam - lam - logFactK
+logDensityObs (Binomial n p) y
+  | p <= 0 || p >= 1 = negInf
+  | otherwise =
+      let k    = round y :: Int
+          kA   = realToFrac y :: a
+          nA   = realToFrac (fromIntegral n :: Double) :: a
+          logC = realToFrac (logBinomCoeff n k) :: a
+      in logC + kA * log p + (nA - kA) * log (1 - p)
+logDensityObs (Uniform lo hi) y
+  | hi <= lo  = negInf
+  | otherwise =
+      let yA = realToFrac y :: a
+      in if yA < lo || yA > hi then negInf else -log (hi - lo)
+logDensityObs (StudentT df mu sig) y
+  | df <= 0 || sig <= 0 = negInf
+  | otherwise =
+      let yA = realToFrac y :: a
+          z  = (yA - mu) / sig
+      in lgammaApprox ((df + 1) / 2)
+       - lgammaApprox (df / 2)
+       - 0.5 * log (df * pi)
+       - log sig
+       - ((df + 1) / 2) * log (1 + z * z / df)
+logDensityObs (Cauchy loc sc) y
+  | sc <= 0   = negInf
+  | otherwise =
+      let yA = realToFrac y :: a
+          z  = (yA - loc) / sc
+      in -log pi - log sc - log (1 + z * z)
+logDensityObs (HalfNormal sig) y
+  | sig <= 0 = negInf
+  | y  < 0   = negInf
+  | otherwise =
+      let yA = realToFrac y :: a
+      in 0.5 * log 2 - 0.5 * log pi - log sig
+       - 0.5 * (yA / sig) ^ (2::Int)
+logDensityObs (HalfCauchy sc) y
+  | sc <= 0 = negInf
+  | y  < 0  = negInf
+  | otherwise =
+      let yA = realToFrac y :: a
+      in log 2 - log pi - log sc - log (1 + (yA / sc) ^ (2::Int))
+logDensityObs (LogNormal mu sig) y
+  | sig <= 0 = negInf
+  | y  <= 0  = negInf
+  | otherwise =
+      let yA = realToFrac y :: a
+          lx = log yA
+      in -0.5 * log (2 * pi) - log sig - lx
+       - 0.5 * ((lx - mu) / sig) ^ (2::Int)
+logDensityObs (Bernoulli p) y
+  | p <= 0 || p >= 1 = negInf
+  | otherwise =
+      let k = round y :: Int
+      in case k of
+           1 -> log p
+           0 -> log (1 - p)
+           _ -> negInf
+logDensityObs (Categorical probs) y =
+  let k    = round y :: Int
+      n    = length probs
+  in if k < 0 || k >= n
+       then negInf
+       else
+         -- log p_k - log(Σ p_i)  (probs を正規化)
+         let pk     = probs !! k
+             total  = sum probs
+         in if pk <= 0 || total <= 0
+              then negInf
+              else log pk - log total
+logDensityObs (Mixture ws comps) y
+  | null ws || length ws /= length comps = negInf
+  | otherwise =
+      let total    = sum ws
+          logTotal = log total
+          logTerms = zipWith (\w d -> log w - logTotal + logDensityObs d y) ws comps
+      in logSumExpA logTerms
+logDensityObs (Truncated d mLo mHi) y =
+  let yA = realToFrac y :: a
+      outOfRange = case (mLo, mHi) of
+        (Just lo, _      ) | yA < lo  -> True
+        (_,       Just hi) | yA > hi  -> True
+        _                             -> False
+  in if outOfRange
+       then negInf
+       else logDensityObs d y - logCDFInterval d mLo mHi
+logDensityObs (Censored d mLo mHi) y =
+  -- 観測値 y が境界 lo / hi に等しい場合は左/右打ち切り尤度
+  let yA = realToFrac y :: a
+      eps = 1e-9 :: a
+      isAt v target = abs (v - target) < eps
+  in case (mLo, mHi) of
+       (Just lo, _) | yA <= lo || isAt yA lo -> logCDF d lo                -- 左打ち切り
+       (_, Just hi) | yA >= hi || isAt yA hi -> logSF  d hi                -- 右打ち切り
+       _                                     -> logDensityObs d y          -- 通常観測
+logDensityObs MvNormal{} _ = 0
+logDensityObs MvNormalChol{} _ = 0
+logDensityObs MvNormalGpRBF{} _ = 0  -- Phase 95 B-dsl: obsLogSum 経由 (下と同じ)
+logDensityObs HmmForwardNormal{} _ = 0  -- Phase 92 A2: obsLogSum 経由 (下と同じ)
+logDensityObs ArmaNormal{} _ = 0  -- Phase 101 A2: obsLogSum 経由 (下と同じ)
+logDensityObs GradedResponseIrt{} _ = 0  -- Phase 101 A3: obsLogSum 経由 (下と同じ)
+  -- スカラー観測経路では使わない (chunk して 'mvNormalLogDensity' を呼ぶ obsLogSum 経由)
+logDensityObs Multinomial{} _ = 0
+  -- スカラー観測経路では使わない (k 次元 chunk で multinomialLogDensity を呼ぶ)
+logDensityObs (InverseGamma alpha beta) y
+  | alpha <= 0 || beta <= 0 || y <= 0 = negInf
+  | otherwise =
+      let yA = realToFrac y :: a
+      in alpha * log beta - lgammaApprox alpha
+       - (alpha + 1) * log yA - beta / yA
+logDensityObs (Weibull kShape lam) y
+  | kShape <= 0 || lam <= 0 || y <= 0 = negInf
+  | otherwise =
+      let yA = realToFrac y :: a
+      in log kShape - log lam
+       + (kShape - 1) * (log yA - log lam)
+       - (yA / lam) ** kShape
+logDensityObs (Pareto alpha xm) y
+  | alpha <= 0 || xm <= 0 = negInf
+  | otherwise =
+      let yA = realToFrac y :: a
+      in if yA < xm
+           then negInf
+           else log alpha + alpha * log xm - (alpha + 1) * log yA
+logDensityObs (BetaBinomial n alpha beta) y
+  | alpha <= 0 || beta <= 0 || y < 0 = negInf
+  | otherwise =
+      let yA   = realToFrac y :: a
+          nA   = realToFrac (fromIntegral n :: Double) :: a
+          k    = round y :: Int
+          logC = realToFrac (logBinomCoeff n k) :: a
+      in logC
+       + lgammaApprox (yA + alpha)
+       + lgammaApprox (nA - yA + beta)
+       - lgammaApprox (nA + alpha + beta)
+       - (lgammaApprox alpha + lgammaApprox beta - lgammaApprox (alpha + beta))
+logDensityObs (VonMises mu kappa) y
+  | kappa <= 0 = negInf
+  | otherwise =
+      let yA = realToFrac y :: a
+      in kappa * cos (yA - mu) - log (2 * pi) - logBesselI0 kappa
+logDensityObs (ZeroInflatedPoisson psi lam) y
+  | psi < 0 || psi > 1 || lam <= 0 || y < 0 = negInf
+  | y == 0 =
+      logSumExpA [log psi, log (1 - psi) - lam]
+  | otherwise =
+      let kA       = realToFrac y :: a
+          kInt     = round y :: Int
+          logFactK = realToFrac (logFactorial kInt) :: a
+      in log (1 - psi) + kA * log lam - lam - logFactK
+logDensityObs (ZeroInflatedBinomial n psi p) y
+  | psi < 0 || psi > 1 || p <= 0 || p >= 1 || y < 0 = negInf
+  | otherwise =
+      let kA   = realToFrac y :: a
+          k    = round y :: Int
+          nA   = realToFrac (fromIntegral n :: Double) :: a
+          logC = realToFrac (logBinomCoeff n k) :: a
+      in if y == 0
+           then logSumExpA [log psi
+                           , log (1 - psi) + nA * log (1 - p)]
+           else log (1 - psi)
+                + logC + kA * log p + (nA - kA) * log (1 - p)
+logDensityObs (NegativeBinomial mu alpha) y
+  | mu <= 0 || alpha <= 0 || y < 0 = negInf
+  | otherwise =
+      let kA = realToFrac y :: a
+          p  = alpha / (alpha + mu)
+      in lgammaApprox (kA + alpha)
+       - lgammaApprox alpha
+       - lgammaApprox (kA + 1)
+       + alpha * log p
+       + kA * log (1 - p)
+logDensityObs (SkewNormal mu sig alpha) y
+  | sig <= 0 = negInf
+  | otherwise =
+      let yA     = realToFrac y :: a
+          z      = (yA - mu) / sig
+          logPhi = -0.5 * log (2 * pi) - 0.5 * z * z
+          cdfArg = phiCdfA (alpha * z)
+          logCdf = log (max cdfArg 1e-300)
+      in log 2 - log sig + logPhi + logCdf
+logDensityObs (Logistic mu s) y
+  | s <= 0 = negInf
+  | otherwise =
+      let yA = realToFrac y :: a
+          z  = (yA - mu) / s
+      in -z - log s - 2 * log (1 + exp (-z))
+logDensityObs (Gumbel mu beta) y
+  | beta <= 0 = negInf
+  | otherwise =
+      let yA = realToFrac y :: a
+          z  = (yA - mu) / beta
+      in -log beta - z - exp (-z)
+logDensityObs (AsymmetricLaplace b kappa mu) y
+  | b <= 0 || kappa <= 0 = negInf
+  | otherwise =
+      let yA      = realToFrac y :: a
+          logNorm = log b - log (kappa + 1 / kappa)
+          d       = yA - mu
+      in if d >= 0
+           then logNorm - b * kappa * d
+           else logNorm + (b / kappa) * d
+logDensityObs (OrderedLogistic eta cuts) y
+  | null cuts                 = negInf
+  | k < 0 || k > kMax         = negInf
+  | otherwise =
+      -- σ(c_{k+1} − η) − σ(c_k − η)、 c_0 = −∞、 c_K = +∞
+      let sigm x  = 1 / (1 + exp (-x))
+          kMax_a  = kMax  -- 上限カテゴリ index
+          probHi
+            | k == kMax_a = 1
+            | otherwise   = sigm (cuts !! k - eta)
+          probLo
+            | k == 0    = 0
+            | otherwise = sigm (cuts !! (k - 1) - eta)
+          pK = probHi - probLo
+      in if pK <= 0 then negInf else log pK
+  where
+    k    = round y :: Int
+    kMax = length cuts
+logDensityObs (DiscreteUniform lo hi) y
+  | hi < lo                = negInf
+  | yI < lo || yI > hi     = negInf
+  | otherwise              = -log (realToFrac (hi - lo + 1) :: a)
+  where
+    yI = round y :: Int
+logDensityObs (Geometric p) y
+  | p <= 0 || p >= 1 = negInf
+  | yI < 1           = negInf
+  | otherwise =
+      let kA = realToFrac y :: a
+      in (kA - 1) * log (1 - p) + log p
+  where
+    yI = round y :: Int
+logDensityObs (HyperGeometric nN kK nDraw) y
+  | nN <= 0 || kK < 0 || kK > nN || nDraw < 0 || nDraw > nN = negInf
+  | yI < max 0 (nDraw + kK - nN) || yI > min nDraw kK       = negInf
+  | otherwise =
+      let lc = realToFrac (logBinomCoeff kK yI
+                         + logBinomCoeff (nN - kK) (nDraw - yI)
+                         - logBinomCoeff nN nDraw) :: a
+      in lc
+  where
+    yI = round y :: Int
+logDensityObs (ZeroInflatedNegativeBinomial psi mu alpha) y
+  | psi < 0 || psi > 1 || mu <= 0 || alpha <= 0 || y < 0 = negInf
+  | y == 0 =
+      -- log(ψ + (1-ψ) (α/(α+μ))^α)
+      let p0NB = alpha * (log alpha - log (alpha + mu))
+      in logSumExpA [log psi, log (1 - psi) + p0NB]
+  | otherwise =
+      let kA = realToFrac y :: a
+          p  = alpha / (alpha + mu)
+          logNB = lgammaApprox (kA + alpha)
+                - lgammaApprox alpha
+                - lgammaApprox (kA + 1)
+                + alpha * log p
+                + kA * log (1 - p)
+      in log (1 - psi) + logNB
+logDensityObs MvStudentT{} _ = 0
+  -- スカラー観測経路では使わない (k chunk で mvStudentTLogDensity 経由)
+logDensityObs DirichletMultinomial{} _ = 0
+  -- スカラー観測経路では使わない (K chunk で dirichletMultinomialLogDensity 経由)
+logDensityObs (Triangular lo c hi) y
+  | hi <= lo || c < lo || c > hi = negInf
+  | otherwise =
+      let yA = realToFrac y :: a
+      in if yA < lo || yA > hi
+           then negInf
+           else if yA <= c
+             then log 2 + log (yA - lo)
+                  - log (hi - lo) - log (c - lo)
+             else log 2 + log (hi - yA)
+                  - log (hi - lo) - log (hi - c)
+logDensityObs (Kumaraswamy a b) y
+  | a <= 0 || b <= 0 = negInf
+  | otherwise =
+      let yA = realToFrac y :: a
+      in if yA <= 0 || yA >= 1
+           then negInf
+           else let xa = yA ** a
+                in log a + log b + (a - 1) * log yA + (b - 1) * log (1 - xa)
+logDensityObs (Rice nu sig) y
+  | sig <= 0 || nu < 0 || y < 0 = negInf
+  | otherwise =
+      let yA = realToFrac y :: a
+          s2 = sig * sig
+          z  = yA * nu / s2
+      in log yA - 2 * log sig - (yA * yA + nu * nu) / (2 * s2)
+         + logBesselI0 z
+logDensityObs Wishart{} _ = 0
+  -- スカラー観測経路では使わない (k² chunk で wishartLogDensity 経由)
+logDensityObs (Bound d mLo mHi) y = logDensityObs (Truncated d mLo mHi) y
+logDensityObs (OrderedProbit eta cuts) y
+  | null cuts                 = negInf
+  | k < 0 || k > kMax         = negInf
+  | otherwise =
+      let probHi
+            | k == kMax = 1
+            | otherwise = phiCdfA (cuts !! k - eta)
+          probLo
+            | k == 0    = 0
+            | otherwise = phiCdfA (cuts !! (k - 1) - eta)
+          pK = probHi - probLo
+      in if pK <= 0 then negInf else log pK
+  where
+    k    = round y :: Int
+    kMax = length cuts
+logDensityObs (DiscreteWeibull q beta) y
+  | y < 0 = negInf
+  | otherwise =
+      -- q は (0,1)、 β > 0
+      -- pmf(k) = q^(k^β) - q^((k+1)^β)
+      let qVal :: a
+          qVal = q
+          bVal :: a
+          bVal = beta
+      in if qVal <= 0 || qVal >= 1 || bVal <= 0
+           then negInf
+           else
+             let kI    = round y :: Int
+                 kA    = realToFrac (fromIntegral kI :: Double) :: a
+                 logQ  = log qVal
+                 -- log(q^(k^β) - q^((k+1)^β))
+                 --   = log q^(k^β) + log(1 - q^((k+1)^β - k^β))
+                 -- 安定化: a1 = (k+1)^β - k^β > 0 (β>0)
+                 pk    = kA ** bVal
+                 pk1   = (kA + 1) ** bVal
+                 diffP = pk1 - pk
+                 -- log(1 - q^diffP) = log(1 - exp(diffP * logQ))
+                 -- diffP * logQ <= 0
+                 expArg = diffP * logQ
+                 log1mE = log (1 - exp expArg)
+             in pk * logQ + log1mE
+
+-- | Sum of log likelihoods over a list of observations. For ordinary
+-- distributions one observation contributes one scalar log-density.
+-- For 'MvNormal' (which expects @k@-vectors), the flattened @[Double]@
+-- is chunked into length-@k@ groups before evaluation.
+-- Phase 58.6c: logJoint/logLikelihood の Observe 分岐が AD で呼ぶ。 cross-module
+-- inline 維持のため INLINABLE。
+{-# INLINABLE obsLogSum #-}
+obsLogSum :: forall a. (Floating a, Ord a) => Distribution a -> [Double] -> a
+obsLogSum (MvNormal mu cov) ys =
+  let k       = length mu
+      chunks  = chunksOf k ys
+  in sum [ mvNormalLogDensity mu cov (map realToFrac yv :: [a])
+         | yv <- chunks ]
+obsLogSum (MvNormalGpRBF xs alpha rho sigma) ys =
+  -- Phase 95 B-dsl: zero-mean・cov = RBF カーネル + (1e-10 + σ)·I。 値は汎用
+  -- 'MvNormal' 経路と同値 (ホット勾配のみ 'gpRBFAnalyticVG' で閉形式化)。
+  let k       = length xs
+      cov     = gpRBFCovList xs alpha rho sigma
+      mu      = replicate k 0
+      chunks  = chunksOf k ys
+  in sum [ mvNormalLogDensity mu cov (map realToFrac yv :: [a])
+         | yv <- chunks ]
+obsLogSum (GradedResponseIrt thetas ncats deltas gammas) ys =
+  -- Phase 101 A3: grade 行列 (nChild×nItem 行優先・欠測 −1) 全体を 1 観測として
+  -- 評価。 値は従来の @logCatProb + potential@ 書きと同値
+  -- (ホット勾配のみ 'gradedIrtAnalyticVG' で閉形式化)。
+  let nItem = length ncats
+      rows  = chunksOf nItem ys
+      logCatP th nc dl gm gr =
+        let kMax = nc - 1
+            qs = [ 1 / (1 + exp (negate (realToFrac dl * (th - realToFrac (gm !! (kk - 1))))))
+                 | kk <- [1 .. kMax] ]
+            ps = [ if k == 1 then 1 - head qs
+                   else if k == nc then qs !! (kMax - 1)
+                   else (qs !! (k - 2)) - (qs !! (k - 1))
+                 | k <- [1 .. nc] ]
+        in log (ps !! (gr - 1))
+  in sum [ logCatP th nc dl gm (round gr)
+         | (th, row) <- zip thetas rows
+         , (nc, dl, gm, gr) <- zip4 ncats deltas gammas row
+         , gr /= -1 ]
+obsLogSum (ArmaNormal mu phi theta sg) ys =
+  -- Phase 101 A2: 観測列全体 (長さ T) を 1 観測として err 逐次再帰で評価。
+  -- 値は従来の @mapAccumL + potential@ 書きと同値
+  -- (ホット勾配のみ 'armaAnalyticVG' で閉形式化)。
+  case ys of
+    [] -> 0
+    (y1 : rest) ->
+      let e1 = realToFrac y1 - (mu + phi * mu)
+          step (prevY, prevErr) yt =
+            let err = realToFrac yt - (mu + phi * realToFrac prevY + theta * prevErr)
+            in ((yt, err), err)
+          errs = e1 : snd (mapAccumL step (y1, e1) rest)
+      in sum [ logDensity (Normal 0 sg) e | e <- errs ]
+obsLogSum (HmmForwardNormal pi0 trans mus sg) ys =
+  -- Phase 92 A2: 観測列全体 (長さ T) を 1 観測として forward algorithm で周辺化。
+  -- 値は従来の @potential nm (hmmForwardLogLik pi0 trans emit)@ 書きと同値
+  -- (ホット勾配のみ 'hmmAnalyticVG' で閉形式化)。
+  let emit = [ [ logDensity (Normal mu sg) (realToFrac y) | mu <- mus ] | y <- ys ]
+  in hmmForwardLogLik pi0 trans emit
+obsLogSum (Multinomial n probs) ys =
+  let k      = length probs
+      chunks = chunksOf k ys
+  in sum [ multinomialLogDensity n probs yv | yv <- chunks ]
+obsLogSum (MvNormalChol mu sigma l) ys =
+  let k      = length mu
+      chunks = chunksOf k ys
+  in sum [ mvNormalCholLogDensity mu sigma l (map realToFrac yv :: [a])
+         | yv <- chunks ]
+obsLogSum (MvStudentT nu mu cov) ys =
+  let k      = length mu
+      chunks = chunksOf k ys
+  in sum [ mvStudentTLogDensity nu mu cov (map realToFrac yv :: [a])
+         | yv <- chunks ]
+obsLogSum (DirichletMultinomial n alpha) ys =
+  let k      = length alpha
+      chunks = chunksOf k ys
+  in sum [ dirichletMultinomialLogDensity n alpha yv | yv <- chunks ]
+obsLogSum (Wishart nu vRows) ys =
+  let k       = length vRows
+      chunks  = chunksOf (k * k) ys
+  in sum [ wishartLogDensity nu vRows (map realToFrac yv :: [a])
+         | yv <- chunks ]
+obsLogSum d ys = sum [ logDensityObs d y | y <- ys ]
diff --git a/src/Hanalyze/Model/HBM/Eval.hs b/src/Hanalyze/Model/HBM/Eval.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Model/HBM/Eval.hs
@@ -0,0 +1,561 @@
+{-# LANGUAGE RankNTypes #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+
+-- |
+-- Module      : Hanalyze.Model.HBM.Eval
+-- Description : HBM のモデル評価層 (log-joint/尤度インタープリタ + DAG 構築)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Phase 58.6c: モデル評価層を 'Hanalyze.Model.HBM' から分離。
+--
+-- PPL の **評価層** (記述層 'Hanalyze.Model.HBM.Model' の上):
+--
+--   * 構造化線形予測子 observe ('ObserveLM') の評価 (lmObsLogSum 等)
+--   * log-joint / log-prior / log-likelihood の多相インタープリタ
+--   * Gibbs 共役検出向けの runObserveDists / priorList
+--   * 派生量評価 (runDeterministics / augmentChainWithDeterministic) と
+--     DAG 構築 (buildModelGraph / collapseIndexedPlateNodes)
+--
+-- 依存は下層 Model / Distribution (密度) / Track (extractDeps) / Util / MCMC.Core
+-- のみ。 AD 勾配・IR は **上層** に置かれ本モジュールへ依存する (一方向)。
+module Hanalyze.Model.HBM.Eval
+  ( -- * ObserveLM 評価
+    lmObsLogSum
+    -- * Interpreters
+  , logJoint
+  , logPrior
+  , logPriorWith
+  , logLikelihood
+  , perObsLogLiks
+  , runObserveDists
+  , mvNormalObserveOf
+  , priorList
+  , describeModel
+    -- * Type aliases
+  , Params
+    -- * 派生量
+  , runDeterministics
+  , deterministicNames
+  , augmentChainWithDeterministic
+    -- * Model graph (visualization)
+  , ModelGraph (..)
+  , buildModelGraph
+  , collapseIndexedPlateNodes
+  ) where
+
+import Data.List (nub)
+import Data.Map.Strict (Map)
+import qualified Data.Map.Strict as Map
+import qualified Data.Set as Set
+import Data.Text (Text)
+import qualified Data.Text as T
+
+import Hanalyze.MCMC.Core (Chain (..))
+import Hanalyze.Model.HBM.Util (negInf, chunksOf)
+import Hanalyze.Model.HBM.Distribution
+import Hanalyze.Model.HBM.Model
+import Hanalyze.Model.HBM.Track (Track, extractDeps)
+
+-- ---------------------------------------------------------------------------
+-- ObserveLM (構造化線形予測子 observe) の評価 (Phase 54.1)
+-- ---------------------------------------------------------------------------
+
+-- | 線形予測子 η_i = Σ_j β_j·X_ij。
+-- Phase 58.6c: synthGaussLMBlocks (本体) / IR が AD で微分しながら呼ぶホット経路。
+-- monolith では同一モジュール inline されていた。 境界跨ぎで失われると M1/M2 が
+-- 約 +25% 劣化する (58.6 bench で実測) ため INLINABLE で cross-module inline を維持。
+{-# INLINABLE lmEta #-}
+lmEta :: Fractional a => [a] -> [Double] -> a
+lmEta betas xrow = sum (zipWith (\b x -> b * realToFrac x) betas xrow)
+
+-- | ランダム効果項の per-obs 寄与 @Σ_re w_i·u^{re}[gid_i]@ (長さ n)。
+-- 重み @Nothing@ = 全 1 (Phase 54.10)。
+{-# INLINABLE lmReffEta #-}
+lmReffEta :: forall a. Fractional a => [REff] -> Int -> Map Text a -> [a]
+lmReffEta reffs n params =
+  foldr (zipWith (+)) (replicate n 0)
+    [ let uvals = [ Map.findWithDefault 0 nm params | nm <- uNames ]
+          base  = [ uvals !! g | g <- gids ]
+      in case mw of
+           Nothing -> base
+           Just ws -> zipWith (\v w -> v * realToFrac w) base ws
+    | REff uNames gids _ mw _ <- reffs ]
+
+-- | 'ObserveLM' ブロックの各観測の log-density (per-obs)。 param Map から
+-- β / u / (Gaussian の) σ を名前で引く。 η_i = Σ_j β_j X_ij + Σ_re u^{re}[gid_i]
+-- を scalar 経路と同じ式で評価する。
+{-# INLINABLE lmObsLogLiks #-}
+lmObsLogLiks :: forall a. (Floating a, Ord a)
+             => [Text] -> [[Double]] -> [REff] -> LMFamily -> [Double] -> Map Text a -> [a]
+lmObsLogLiks betaNames designX reffs fam ys params =
+  let betas = [ Map.findWithDefault 0 n params | n <- betaNames ]
+      reEta = lmReffEta reffs (length ys) params
+      etas  = zipWith (\xr re -> lmEta betas xr + re) designX reEta
+      rows  = zip etas ys
+  in case fam of
+       LMGaussian sName ->
+         let sigma = Map.findWithDefault 0 sName params
+         in [ logDensityObs (Normal eta sigma) y | (eta, y) <- rows ]
+       LMPoisson ->
+         [ logDensityObs (Poisson (exp eta)) y | (eta, y) <- rows ]
+       LMBernoulli ->
+         [ logDensityObs (Bernoulli (1 / (1 + exp (negate eta)))) y
+         | (eta, y) <- rows ]
+
+-- | 'ObserveLM' ブロックの log-likelihood 和。
+{-# INLINABLE lmObsLogSum #-}
+lmObsLogSum :: (Floating a, Ord a)
+            => [Text] -> [[Double]] -> [REff] -> LMFamily -> [Double] -> Map Text a -> a
+lmObsLogSum betaNames designX reffs fam ys params =
+  sum (lmObsLogLiks betaNames designX reffs fam ys params)
+
+-- ---------------------------------------------------------------------------
+-- 評価インタープリタ
+-- ---------------------------------------------------------------------------
+
+-- | Polymorphic interpreter that computes the log-joint
+-- @log p(θ, y)@.
+-- 引数 @a@ を @Double@ にすると数値評価、@Reverse s Double@ にすると AD 評価が可能。
+logJoint :: (Floating a, Ord a) => Model a r -> Map Text a -> a
+logJoint model params = go model 0
+  where
+    go (Pure _) acc = acc
+    go (Free (Sample n d k)) acc =
+      case Map.lookup n params of
+        Nothing  -> negInf
+        Just v   ->
+          let lp = logDensity d v
+          in go (k v) (acc + lp)
+    go (Free (Observe _ d ys next)) acc =
+      let ll = obsLogSum d ys
+      in go next (acc + ll)
+    go (Free (ObserveLM _ bs xs re fam ys next)) acc =
+      go next (acc + lmObsLogSum bs xs re fam ys params)
+    go (Free (Potential _ v next)) acc = go next (acc + v)
+    go (Free (Deterministic _ v k)) acc = go (k v) acc
+    -- Phase 60.2: Data 継続は [a] を受ける (lazy list・消費 1 回で O(n)/eval)
+    go (Free (Data _ ys k)) acc = go (k (map realToFrac ys, ys)) acc
+    go (Free (DataIx _ is k)) acc = go (k is) acc
+    go (Free (PlateBegin _ _ next)) acc = go next acc
+    go (Free (PlateEnd next))       acc = go next acc
+
+-- | log p(θ) のみ (prior 部分)。
+logPrior :: (Floating a, Ord a) => Model a r -> Map Text a -> a
+logPrior = logPriorWith logDensity
+
+-- | 'logPrior' の密度関数注入版 (Phase 92 B3)。 AD 経路が定数 hyperparameter の
+-- lgamma 正規化項を Double へ畳み込む 'logDensityRD' を差し込むために使う
+-- ('Gradient' の fRest 参照)。 @logPriorWith logDensity@ = 従来の 'logPrior'。
+logPriorWith :: (Floating a, Ord a)
+             => (Distribution a -> a -> a) -> Model a r -> Map Text a -> a
+logPriorWith density model params = go model 0
+  where
+    go (Pure _) acc = acc
+    go (Free (Sample n d k)) acc =
+      case Map.lookup n params of
+        Nothing -> negInf
+        Just v  -> go (k v) (acc + density d v)
+    go (Free (Observe _ _ _ next)) acc = go next acc
+    go (Free (ObserveLM _ _ _ _ _ _ next)) acc = go next acc  -- prior 部分には寄与しない
+    go (Free (Potential _ v next)) acc = go next (acc + v)
+    go (Free (Deterministic _ v k)) acc = go (k v) acc
+    go (Free (Data _ ys k)) acc = go (k (map realToFrac ys, ys)) acc
+    go (Free (DataIx _ is k)) acc = go (k is) acc
+    go (Free (PlateBegin _ _ next)) acc = go next acc
+    go (Free (PlateEnd next))       acc = go next acc
+
+-- | log p(y | θ) のみ (likelihood 部分)。
+logLikelihood :: (Floating a, Ord a) => Model a r -> Map Text a -> a
+logLikelihood model params = go model 0
+  where
+    go (Pure _) acc = acc
+    go (Free (Sample n _ k)) acc =
+      case Map.lookup n params of
+        Nothing -> go (k 0) acc
+        Just v  -> go (k v) acc
+    go (Free (Observe _ d ys next)) acc =
+      let ll = obsLogSum d ys
+      in go next (acc + ll)
+    go (Free (ObserveLM _ bs xs re fam ys next)) acc =
+      go next (acc + lmObsLogSum bs xs re fam ys params)
+    go (Free (Potential _ _ next)) acc = go next acc   -- Potential は事前項とみなす
+    go (Free (Deterministic _ v k)) acc = go (k v) acc
+    go (Free (Data _ ys k)) acc = go (k (map realToFrac ys, ys)) acc
+    go (Free (DataIx _ is k)) acc = go (k is) acc
+    go (Free (PlateBegin _ _ next)) acc = go next acc
+    go (Free (PlateEnd next))       acc = go next acc
+
+-- | For each observe node, return its distribution evaluated at the
+-- current parameter values together with the observed data.
+-- Gibbs サンプラーが共役構造を検出する際に、潜在変数の現在値に対する
+-- 観測分布のパラメータを得るために使う (Double 特殊化版)。
+--
+-- 例: @y ~ Normal(mu, sigma)@ で @ps = {mu=2, sigma=0.5}@ を渡すと
+-- @[(\"y\", Normal 2 0.5, [...])]@ を返す。
+runObserveDists :: Model Double r
+                -> Map Text Double
+                -> [(Text, Distribution Double, [Double])]
+runObserveDists (Pure _) _ = []
+runObserveDists (Free (Sample n _ k)) ps =
+  runObserveDists (k (Map.findWithDefault 0 n ps)) ps
+runObserveDists (Free (Observe n d ys next)) ps =
+  (n, d, ys) : runObserveDists next ps
+runObserveDists (Free (ObserveLM _ _ _ _ _ _ next)) ps =
+  -- ObserveLM は per-obs で μ が異なり単一 Distribution に収まらない。
+  -- Gibbs 共役検出 (この関数の用途) の対象外ゆえスキップ。
+  runObserveDists next ps
+runObserveDists (Free (Potential _ _ next)) ps =
+  runObserveDists next ps
+runObserveDists (Free (Deterministic _ v k)) ps =
+  runObserveDists (k v) ps
+runObserveDists (Free (Data _ ys k)) ps =
+  runObserveDists (k (ys, ys)) ps
+runObserveDists (Free (DataIx _ is k)) ps =
+  runObserveDists (k is) ps
+runObserveDists (Free (PlateBegin _ _ next)) ps = runObserveDists next ps
+runObserveDists (Free (PlateEnd next))       ps = runObserveDists next ps
+
+-- | Phase 95 A6: 解析随伴 (detach) パスの適格判定 + 抽出。
+-- モデルの尤度項が **ちょうど 1 個の 'MvNormal' observe** のみ (他 'Observe' /
+-- 'ObserveLM' 無し) のとき、その @(μ, Σ, ys)@ を **現在の param 値で評価** して
+-- 返す。 それ以外は 'Nothing' (= 呼び出し側は従来の walk+ad / vecIR 経路へ)。
+--
+-- 多相 (@Floating a@) ゆえ Double でも AD 型でも走らせられる: Double 版で
+-- LAPACK 用の Σ⁻¹/logdet を作り (G,h 定数化)、 AD 版で surrogate @<G,Σ(θ)>@ を
+-- 微分する ('Gradient.compileGradUV' の解析枝)。 walk は 'logJoint' 等と同一
+-- (Sample 継続に @params Map.! name@ を流す)。 μ/Σ は Observe ノードの
+-- 'Distribution' に格納された式ゆえ、 現在の param 値で lazy に具体化される。
+--
+-- 適格条件を **1 個の MvNormal に限定**するのは正しさのため: 尤度が MvNormal
+-- 単独なら @grad(logPrior+logJac) + detach(observe)@ で厳密に総勾配を再構成できる
+-- (@logJoint = logPrior + logLikelihood@・@logLikelihood = obsLogSum(MvNormal)@)。
+mvNormalObserveOf :: (Floating a, Ord a)
+                  => Model a r -> Map Text a -> Maybe ([a], [[a]], [Double])
+mvNormalObserveOf model params =
+  case go model of
+    Just [(MvNormal mu cov, ys)] -> Just (mu, cov, ys)
+    _                            -> Nothing
+  where
+    -- Observe ノードの (dist, ys) を集める。 ObserveLM が在れば失格 (Nothing)。
+    go (Pure _) = Just []
+    go (Free (Sample n _ k)) =
+      case Map.lookup n params of
+        Nothing -> Nothing              -- param 欠落 = 失格 (通常起きない)
+        Just v  -> go (k v)
+    go (Free (Observe _ d ys next)) = ((d, ys) :) <$> go next
+    go (Free (ObserveLM {}))        = Nothing          -- 構造化尤度は対象外
+    go (Free (Potential _ _ next))  = go next          -- prior 側 (logPrior が処理)
+    go (Free (Deterministic _ v k)) = go (k v)
+    go (Free (Data _ ys k))         = go (k (map realToFrac ys, ys))
+    go (Free (DataIx _ is k))       = go (k is)
+    go (Free (PlateBegin _ _ next)) = go next
+    go (Free (PlateEnd next))       = go next
+
+-- | For each sample node, return @(name, prior distribution)@ in the
+-- @Double@-specialized form.
+-- Gibbs サンプラーの共役検出で「この潜在変数の事前は Gamma か Beta か」を
+-- 判定するために使う。継続値はプレースホルダ 0 を流す。
+priorList :: Model Double r -> [(Text, Distribution Double)]
+priorList (Pure _) = []
+priorList (Free (Sample n d k)) = (n, d) : priorList (k 0)
+priorList (Free (Observe _ _ _ next)) = priorList next
+priorList (Free (ObserveLM _ _ _ _ _ _ next)) = priorList next
+priorList (Free (Potential _ _ next)) = priorList next
+priorList (Free (Deterministic _ v k)) = priorList (k v)
+priorList (Free (Data _ ys k)) = priorList (k (ys, ys))
+priorList (Free (DataIx _ is k)) = priorList (k is)
+priorList (Free (PlateBegin _ _ next)) = priorList next
+priorList (Free (PlateEnd next))       = priorList next
+
+-- ---------------------------------------------------------------------------
+-- 互換 API
+-- ---------------------------------------------------------------------------
+
+-- | パラメータ名 → 値 のマップ (constrained 空間)。
+type Params = Map Text Double
+
+-- | Per-observation log-likelihood (used by WAIC / LOO-CV).
+-- 各 Observe ノードのすべての観測値の logDensity を平坦リストで返す。
+perObsLogLiks :: forall r. ModelP r -> Params -> [Double]
+perObsLogLiks m params = go m []
+  where
+    go :: Model Double r -> [Double] -> [Double]
+    go (Pure _) acc = reverse acc
+    go (Free (Sample n _ k)) acc =
+      go (k (Map.findWithDefault 0 n params)) acc
+    go (Free (Observe _ d ys next)) acc =
+      let lls = case d of
+            MvNormal mu cov ->
+              let k = length mu
+              in [ mvNormalLogDensity mu cov (map realToFrac yv :: [Double])
+                 | yv <- chunksOf k ys ]
+            Multinomial nn pp ->
+              let k = length pp
+              in [ multinomialLogDensity nn pp yv | yv <- chunksOf k ys ]
+            _ -> [ logDensityObs d y | y <- ys ]
+      in go next (reverse lls ++ acc)
+    go (Free (ObserveLM _ bs xs re fam ys next)) acc =
+      let lls = lmObsLogLiks bs xs re fam ys params
+      in go next (reverse lls ++ acc)
+    go (Free (Potential _ _ next)) acc = go next acc
+    go (Free (Deterministic _ v k)) acc = go (k v) acc
+    go (Free (Data _ ys k)) acc = go (k (ys, ys)) acc
+    go (Free (DataIx _ is k)) acc = go (k is) acc
+    go (Free (PlateBegin _ _ next)) acc = go next acc
+    go (Free (PlateEnd next))       acc = go next acc
+
+-- | Evaluate every 'Deterministic' node and return the resulting
+-- derived-quantity @Map@.
+--
+-- @params@ は latent 変数 (sample) の値を表す Map。Deterministic は
+-- それらから導出される量で、ここでは Double 特殊化で評価する。
+runDeterministics :: forall r. ModelP r -> Params -> Map Text Double
+runDeterministics m params = go m Map.empty
+  where
+    go :: Model Double r -> Map Text Double -> Map Text Double
+    go (Pure _) acc = acc
+    go (Free (Sample n _ k)) acc =
+      go (k (Map.findWithDefault 0 n params)) acc
+    go (Free (Observe _ _ _ next)) acc = go next acc
+    go (Free (ObserveLM _ _ _ _ _ _ next)) acc = go next acc
+    go (Free (Potential _ _ next)) acc = go next acc
+    go (Free (Deterministic n v k)) acc =
+      go (k v) (Map.insert n v acc)
+    go (Free (Data _ ys k)) acc = go (k (ys, ys)) acc
+    go (Free (DataIx _ is k)) acc = go (k is) acc
+    go (Free (PlateBegin _ _ next)) acc = go next acc
+    go (Free (PlateEnd next))       acc = go next acc
+
+-- | モデル中の 'Deterministic' 宣言名を宣言順で列挙する (Phase 103)。
+-- 同名の重複宣言 (plate 内反復等) は初出のみ残す。'collectNodes' は
+-- Deterministic を素通しして 'Node' 化しないため専用 walker で拾う。
+-- 'runDeterministics' の返す Map の key 集合と一致する (順序のみ異なる)。
+deterministicNames :: forall r. ModelP r -> [Text]
+deterministicNames m = nub (go m [])
+  where
+    go :: Model Double r -> [Text] -> [Text]
+    go (Pure _) acc = reverse acc
+    go (Free (Sample n _ k)) acc = go (k 0) acc   -- placeholder 0 (collectNodes 同型)
+    go (Free (Observe _ _ _ next)) acc = go next acc
+    go (Free (ObserveLM _ _ _ _ _ _ next)) acc = go next acc
+    go (Free (Potential _ _ next)) acc = go next acc
+    go (Free (Deterministic n v k)) acc = go (k v) (n : acc)
+    go (Free (Data _ ys k)) acc = go (k (ys, ys)) acc
+    go (Free (DataIx _ is k)) acc = go (k is) acc
+    go (Free (PlateBegin _ _ next)) acc = go next acc
+    go (Free (PlateEnd next))       acc = go next acc
+
+-- | Evaluate 'runDeterministics' on every posterior sample and
+-- 結果を 'chainSamples' の Map にマージした新しい Chain を返す。
+-- これにより @chainVals@ / @posteriorSummary@ などのヘルパで派生量を
+-- そのまま参照できる。
+augmentChainWithDeterministic :: ModelP r -> Chain -> Chain
+augmentChainWithDeterministic m ch =
+  let aug ps = Map.union (runDeterministics m ps) ps
+  in ch { chainSamples = map aug (chainSamples ch) }
+
+-- | Human-readable summary of the model structure (no inference is run).
+describeModel :: ModelP r -> Text
+describeModel m = T.unlines (header : map fmtNode (collectNodes m))
+  where
+    header = "Model nodes:"
+    fmtNode n = case nodeKind n of
+      LatentN       -> "  [latent]   " <> nodeName n <> " ~ " <> nodeDist n
+      ObservedN k   -> "  [observed] " <> nodeName n <> " ~ " <> nodeDist n
+                    <> "  (n=" <> T.pack (show k) <> ")"
+      DeterministicN -> "  [determ]   " <> nodeName n <> " = " <> nodeDist n
+      DataN k        -> "  [data]     " <> nodeName n
+                    <> "  (n=" <> T.pack (show k) <> ")"
+
+-- | DAG representation of the model. Edges are derived automatically by
+-- 'extractDeps'.
+data ModelGraph = ModelGraph
+  { mgNodes  :: [Node]
+  , mgEdges  :: [(Text, Text)]   -- (parent, child)
+  , mgPlates :: Map Text Int     -- Phase 40: plate 名 → サイズ N
+  } deriving (Show)
+
+-- | Plate 内の indexed RV (`eta_0, eta_1, …, eta_{n-1}`) を **代表 1 ノードに集約**
+-- して、 PyMC `pm.model_to_graphviz` 流の true plate 描画用に変換する
+-- (Phase 40-A8、 2026-05-30 追加)。
+--
+-- 集約条件 (heuristic):
+--
+-- - 同じ `nodePlates` (= plate スタック) に属する
+-- - 名前が @\<prefix\>_\<digit+\>$@ パターン (末尾が _ + 数字)
+-- - 同じ @prefix@ を持つノード群が 2 個以上
+-- - 同じ `nodeDist` (= 分布名が一致)
+--
+-- 集約結果:
+--
+-- - 代表ノード名は @prefix@ (例: @eta_0..eta_7@ → @eta@)
+-- - `nodeKind`: 元の集合内で最初の出現を維持 (LatentN / ObservedN)。
+--   ObservedN の場合は観測数を全集約 (Σ)
+-- - `nodeDeps`: 全集合の親集合の和 (ただし、 同じ集合内のメンバ間 deps は
+--   削除 — 自己集約のため)
+-- - edges: 集約後の名前で dedupe
+--
+-- plate 文脈外で起きる「同じ命名規則の名前衝突」 (e.g. @beta_0@ 固定効果 vs
+-- @u_0@ 群効果) はこの heuristic で誤って集約されない (plate 制約)。
+--
+-- 元 graph をそのまま渡せば不変 (idempotent)。 plate に属さない / 単独
+-- のノードは触らない。
+collapseIndexedPlateNodes :: ModelGraph -> ModelGraph
+collapseIndexedPlateNodes mg0 =
+  -- 不動点: 1 回の集約で取りこぼした多段 plate (e.g. y_0_0..y_2_1 → y_0..y_2 →
+  -- 残り index suffix を持つ → y) を順次潰す。 mgNodes 数が減らなくなれば終了。
+  let step g = collapseIndexedPlateNodesOnce g
+      iter g = let g' = step g in if length (mgNodes g') == length (mgNodes g)
+                                    then g else iter g'
+  in iter mg0
+
+-- | `collapseIndexedPlateNodes` の 1 段集約 (内部、 不動点を作る材料)。
+collapseIndexedPlateNodesOnce :: ModelGraph -> ModelGraph
+collapseIndexedPlateNodesOnce mg =
+  let ns        = mgNodes mg
+      es        = mgEdges mg
+      -- 1. 各ノードについて (plate path, prefix) または Nothing を計算
+      keyOf n = case T.breakOnEnd "_" (nodeName n) of
+        (pre, digits)
+          | not (T.null pre) && not (T.null digits)
+            && T.all (`elem` ("0123456789" :: String)) digits ->
+              Just (nodePlates n, T.init pre)  -- _ を除いた prefix
+        _ -> Nothing
+      -- 2. キー単位で groupings
+      keyed = [(keyOf n, n) | n <- ns]
+      -- 3. グループ化 (Just key) のみ、 Nothing は単独
+      grouped :: Map.Map ([Text], Text) [Node]
+      grouped = Map.fromListWith (flip (++))
+        [ (k, [n]) | (Just k, n) <- keyed ]
+      -- 4. 集約候補: size ≥ 2 かつ全 nodeDist 一致
+      collapsible = Map.filter
+        (\g -> length g >= 2
+            && all (\n -> nodeDist n == nodeDist (head g)) g)
+        grouped
+      -- 5. name → 代表名 のマップ
+      nameMap :: Map.Map Text Text
+      nameMap = Map.fromList
+        [ (nodeName n, prefix)
+        | ((_plates, prefix), grp) <- Map.toList collapsible
+        , n <- grp
+        ]
+      mapName n = Map.findWithDefault n n nameMap
+      -- 6. 集約後ノード作成
+      mkRepresentative (_, prefix) grp =
+        let first = head grp
+            kind  = case nodeKind first of
+              ObservedN _ ->
+                ObservedN (sum [k | n <- grp,
+                                    let ObservedN k = nodeKind n])
+              LatentN        -> LatentN
+              DeterministicN -> DeterministicN
+              dk@(DataN _)   -> dk
+            -- 自己集約 (同じ集合のメンバへの deps) を除外
+            memberNames = Set.fromList (map nodeName grp)
+            externalDeps = Set.unions (map nodeDeps grp)
+              `Set.difference` memberNames
+            -- 親側の名前も mapName で remap (e.g. mu_0..mu_K-1 集約済の場合)
+            remappedDeps = Set.map mapName externalDeps
+        in first { nodeName = prefix
+                 , nodeKind = kind
+                 , nodeDeps = remappedDeps
+                 }
+      -- 7. ノードリスト再構築: 集約対象は代表 1 個、 非対象はそのまま
+      isInGroup n = case keyOf n of
+        Just k -> Map.member k collapsible
+        Nothing -> False
+      seenGroups :: [([Text], Text)]
+      seenGroups = []
+      walk [] _ acc = reverse acc
+      walk (n:rest) seen acc
+        | isInGroup n =
+            let Just k = keyOf n
+            in if k `elem` seen
+                 then walk rest seen acc
+                 else let rep = mkRepresentative k (collapsible Map.! k)
+                      in walk rest (k : seen) (rep : acc)
+        | otherwise = walk rest seen
+            (n { nodeDeps = Set.map mapName (nodeDeps n) } : acc)
+      newNodes = walk ns seenGroups []
+      -- 8. edges を remap + dedupe + 自己ループ除去
+      newEdges = Set.toList $ Set.fromList
+        [ (s', t')
+        | (s, t) <- es
+        , let s' = mapName s
+        , let t' = mapName t
+        , s' /= t'   -- 自己ループ除外
+        ]
+  in mg { mgNodes = newNodes, mgEdges = newEdges }
+
+-- | 多相モデルから DAG を自動構築する (Track 型による依存追跡)。
+--
+-- 同じ名前で複数登場する Observe ノード (例: 回帰モデルで観測点ごとに
+-- @observe \"y\"@ を発行する場合) は 1 つに統合される。観測数の合計と
+-- 親変数集合の和をマージし、エッジも重複排除する。
+buildModelGraph :: ModelP r -> ModelGraph
+buildModelGraph m =
+  let (rawNodes, plates) = extractDeps m
+      merged   = assignDataPlates plates (mergeByName rawNodes)
+      edges    = Set.toList $ Set.fromList
+                   [ (parent, nodeName n)
+                   | n <- merged
+                   , parent <- Set.toList (nodeDeps n) ]
+  in ModelGraph merged edges plates
+  where
+    -- Phase 60.6 追補: 宣言位置が plate 外 (nodePlates = []) の DataN を、
+    -- PyMC の dims 同様「データ長 = plate サイズ」 の一意 match で plate に
+    -- 割り当てる (典型 = モデル冒頭で宣言した dataNamedX n=150 が obs(150)
+    -- cluster 内に描かれる)。 一致 plate が複数 / なし は据え置き (外に描く)。
+    -- 入れ子 plate の full path は、 既にその plate に居る他ノードの
+    -- nodePlates から逆引きする (plate 内ノードが無い場合は単独 path)。
+    assignDataPlates plates ns =
+      let paths = [ nodePlates n | n <- ns, not (null (nodePlates n)) ]
+          pathFor nm = case [ p | p <- paths, last p == nm ] of
+                         (p : _) -> p
+                         []      -> [nm]
+          assign n = case nodeKind n of
+            DataN k | null (nodePlates n) ->
+              case [ nm | (nm, sz) <- Map.toList plates, sz == k ] of
+                [nm] -> n { nodePlates = pathFor nm }
+                _    -> n
+            _ -> n
+      in map assign ns
+    -- 同名ノードを統合: ObservedN n1 + ObservedN n2 → ObservedN (n1+n2)
+    -- LatentN は最初の出現を残す。deps は和集合。
+    -- nodePlates は最初の出現のものを維持 (同名は同 plate 前提)。
+    mergeByName ns = mergeGo ns Map.empty []
+    mergeGo [] _ acc = reverse acc
+    mergeGo (n:ns) seen acc =
+      let nm = nodeName n
+      in case Map.lookup nm seen of
+           Nothing -> mergeGo ns (Map.insert nm n seen) (n : acc)
+           Just prev ->
+             -- Phase 60.4: DataN は最弱 — 同名の非 DataN ノード (典型 =
+             -- dataNamedObs "y" + observe "y" の docs 慣例) があれば吸収される
+             -- (PyMC で observed RV が data 容器を内包して表示されるのと同型)。
+             let (kind', dist', plates') =
+                   case (nodeKind prev, nodeKind n) of
+                     (ObservedN a, ObservedN b) ->
+                       (ObservedN (a + b), nodeDist prev, nodePlates prev)
+                     (DataN _, k2) -> (k2, nodeDist n, nodePlates n)
+                     (k1, _)       -> (k1, nodeDist prev, nodePlates prev)
+                 merged' = Node
+                   { nodeName = nm
+                   , nodeKind = kind'
+                   , nodeDist   = dist'
+                   , nodeDeps   = nodeDeps prev <> nodeDeps n
+                   , nodePlates = plates'
+                   }
+                 acc' = map (\x -> if nodeName x == nm then merged' else x) acc
+             in mergeGo ns (Map.insert nm merged' seen) acc'
+
+
+-- ---------------------------------------------------------------------------
+-- Track 評価 (logJoint の Track 特殊化)
+-- ---------------------------------------------------------------------------
+
+-- | Track でモデルを評価する (log joint も依存集合付きで計算)。
+runTrack :: forall r. ModelP r -> Map Text Track -> Track
+runTrack m params = logJoint (m :: Model Track r) params
diff --git a/src/Hanalyze/Model/HBM/Gradient.hs b/src/Hanalyze/Model/HBM/Gradient.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Model/HBM/Gradient.hs
@@ -0,0 +1,1982 @@
+{-# LANGUAGE RankNTypes #-}
+{-# LANGUAGE DeriveFunctor #-}
+{-# LANGUAGE FlexibleInstances #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE ImpredicativeTypes #-}
+
+-- |
+-- Module      : Hanalyze.Model.HBM.Gradient
+-- Description : HBM の AD 勾配コンパイラ層 (NUTS per-draw のホット経路)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Phase 58.8: AD 勾配コンパイラ層を 'Hanalyze.Model.HBM' 本体から分離。
+-- IR (中間表現) 層の **上層** であり、 NUTS per-draw の本経路 (compileGradUV →
+-- gradVecIR / hybridGradClosure) を担う最ホット モジュール。 unconstrained 空間の
+-- log-joint・解析閉形式勾配 (Gaussian LM ブロック)・ハイブリッド勾配クロージャ・
+-- 定数 prior 解析勾配・制約変換 (invTransformF/logJacF) を含む。
+--
+-- 全 top-level を export し ('module ... where' = 暗黙全公開)、 公開 API
+-- (gradAD/gradADU/compileGradU/compileGradUV/compileLogPU/compileLogPUV/
+-- getTransforms/logJointUnconstrained/invTransformF/logJacF) は facade
+-- 'Hanalyze.Model.HBM' の export list 経由で再エクスポートされる。
+module Hanalyze.Model.HBM.Gradient where
+
+import Control.DeepSeq (NFData (..), force)
+import Control.Exception (SomeException, evaluate, try)
+import Control.Monad (forM, forM_, replicateM, when)
+import Data.List (foldl', zip4)
+import System.IO.Unsafe (unsafePerformIO)
+import qualified Data.Map.Strict as Map
+import Data.Map.Strict (Map)
+import qualified Data.Set as Set
+import Data.Set (Set)
+import Data.Text (Text)
+import qualified Data.Text as T
+import Data.Reflection (Reifies)
+import Numeric.AD.Mode.Reverse.Double (grad, grad')
+import qualified Numeric.AD.Internal.Reverse.Double as ADRD
+import qualified System.Random.MWC as MWCBase
+import qualified System.Random.MWC.Distributions as MWC
+import System.Random.MWC (Gen)
+import Control.Monad.Primitive (PrimMonad, PrimState, stToPrim)
+
+import Control.Monad.ST (ST, runST)
+import qualified Data.Vector          as BV
+import qualified Data.Vector.Storable as VS
+import qualified Data.Vector.Storable.Mutable as VSM
+import qualified Data.Vector.Unboxed  as VU
+
+-- Phase 95 A6: dense MvNormal observe の解析随伴 (detach) で Σ⁻¹/logdet を LAPACK
+-- で 1 度だけ作る (cholesky を AD tape に載せない)。
+import qualified Numeric.LinearAlgebra as LA
+
+import Hanalyze.Stat.Distribution (Transform (..))
+import Hanalyze.MCMC.Core (Chain (..))
+
+-- Phase 58.2: 純粋な数値・線形代数 leaf util を分離。 internal 利用に加え
+-- 'lgammaApprox' / 'digamma' は export list 経由でそのまま再エクスポートされる。
+import Hanalyze.Model.HBM.Util
+-- Phase 58.3/58.6a: 多相分布 ADT + 密度 + CDF を分離 (Util の上層)。 公開 API
+-- (Distribution(..)/distName/logDensity/logDensityObs/obsLogSum/distCDF/logCDF/
+-- logSF/MV密度群) は export list 経由でそのまま再エクスポート。 ★58.6a で事前
+-- logDensity と観測 logDensityObs/obsLogSum を本体から Distribution へ集約
+-- (Eval の logJoint/logPrior が logDensity を参照する back-edge を解消・密度は
+-- 本来 Distribution の責務。 INLINABLE は AD cross-module inlining 維持で保持)。
+import Hanalyze.Model.HBM.Distribution
+-- Phase 58.4: 分布からのサンプリング (sampleDist/sampleMvDist) を分離。
+-- export list 経由でそのまま再エクスポート。 PrimMonad/mwc-random 依存・非ホット。
+import Hanalyze.Model.HBM.Sampling
+-- Phase 58.5: 多相モデル DSL (Free monad + ModelF + plate + 構造検査) を分離。
+-- 公開 API (Free/liftF/ModelF/Model/ModelP/sample/observe/plate/collectNodes 等)
+-- は export list 経由でそのまま再エクスポート。
+import Hanalyze.Model.HBM.Model
+
+-- Phase 58.6b: 依存追跡型 Track (Track/trackVar/trackConst/extractDeps) を分離。
+-- Model/Distribution の上層・非ホット (DAG 抽出のみ・NUTS per-draw 非経路)。
+-- export list 経由でそのまま再エクスポート。
+import Hanalyze.Model.HBM.Track
+-- Phase 58.6c: 評価層 (ObserveLM 評価 + logJoint/logPrior/logLikelihood interp +
+-- 互換 API runDeterministics/buildModelGraph 等 + runTrack) を分離。 Track の上層。
+-- ★ホット (logJoint は AD 勾配経路)。 AD 勾配・IR (本体残置) は本モジュールを
+-- forward import する。 公開 API は export list 経由でそのまま再エクスポート。
+import Hanalyze.Model.HBM.Eval
+-- Phase 58.7: IR (中間表現) 層 (affine/非線形/密度 IR) を分離。 最ホット (gradVecIR)。
+import Hanalyze.Model.HBM.IR
+
+-- Phase 60.7: '!!!' の依存タグは AD 勾配には無関係 (既定 id = サンプリング
+-- ビット不変)。 ReverseDouble は ad の internal 型ゆえ orphan instance だが、
+-- AD 経路の instantiate はこのモジュールに閉じている。
+instance TrackTag (ADRD.ReverseDouble s)
+
+-- ---------------------------------------------------------------------------
+-- AD 勾配
+-- ---------------------------------------------------------------------------
+
+-- | AD で勾配を計算する。@names@ の順で各パラメータに対する偏微分を返す。
+gradAD :: ModelP r -> [Text] -> [Double] -> [Double]
+gradAD m names xs0 = grad f xs0
+  where
+    f xs =
+      let params = Map.fromList (zip names xs)
+      in logJoint m params
+
+-- | unconstrained 空間で AD 勾配を計算する (HMC 用)。
+-- 各パラメータに制約変換を適用し、Jacobian 補正項込みの log-joint を微分する。
+--
+-- Phase 54.4a-54.6: モデルが **Gaussian-恒等リンクの 'ObserveLM' ブロック** を
+-- 含む場合、 そのブロックの観測尤度勾配 (= 規模 n に比例する支配項) を解析
+-- 閉形式 (∂β=Xᵀr/σ² 等) で計算し、 prior / jacobian / scalar observe /
+-- 非 Gaussian LM は従来 `ad` で計算してから成分加算する (ハイブリッド)。
+-- Gaussian LM を含まないモデルは従来通り全体を `ad` で微分 (後方互換)。
+gradADU :: ModelP r -> [Text] -> [Transform] -> [Double] -> [Double]
+gradADU m names trans = compileGradU m names trans
+
+-- | 'compileGradUV' の list wrapper (後方互換 API)。 NUTS は vector-native の
+-- 'compileGradUV' を直接使う。
+compileGradU :: forall r. ModelP r -> [Text] -> [Transform] -> ([Double] -> [Double])
+compileGradU m names trans =
+  let gv = compileGradUV m names trans
+  in VS.toList . gv . VS.fromList
+
+-- | 'compileGradUV' が実際に選ぶ勾配経路のラベル (診断表示用・Phase 91 A4)。
+-- 'compileGradUV' 本体の分岐順 (gaussLMBlocksAuto → synthVecIR → 全体 ad) を
+-- そのまま反映する唯一の分類子。
+--
+-- ★**束縛済モデル** ('hbmModelSpec' 経由) を渡すこと。 生の未束縛モデル
+-- ('dataNamed*' の既定 @[]@ のまま) を渡すと data 行が空になり、 Gaussian LM
+-- 合成も 'collectSymRows' も 0 行となって経路判定が狂う (Phase 91 A4 実測:
+-- 17-nes/12-ark は実際は Gaussian LM 閉形式経路なのに、 生モデルを渡した
+-- 診断が @synthVecIR = Nothing@ と誤表示していた)。
+gradPathLabel :: ModelP r -> String
+gradPathLabel m = case gaussLMBlocksAuto m of
+  ([], _) -> case synthVecIR m of
+    Nothing | hasHmmObserve m       -> "HMM forward-backward 閉形式随伴 (Phase 92)"
+            | hasArmaObserve m      -> "ARMA(1,1) 逆向き随伴の閉形式 (Phase 101)"
+            | hasGradedIrtObserve m -> "graded response IRT 解析勾配 (Phase 101)"
+            | otherwise             -> "legacy walk+ad (全体 ad)"
+    Just _  -> "vecIR (ベクトル式 IR 高速経路)"
+  _       -> "Gaussian LM 閉形式ブロック (解析勾配)"
+
+-- | Phase 92: 'gradPathLabel' 用の軽量構造判定 — 尤度が単一 'HmmForwardNormal'
+-- observe か。 param 値は不要 (latent へ 0 を給餌・分布値は強制しない)。
+-- 実際の経路選択は 'gradValPlan' 内の 'hmmAnalyticVG' (probe 同型) が行う。
+hasHmmObserve :: ModelP r -> Bool
+hasHmmObserve m = case go m of
+    Just [HmmForwardNormal {}] -> True
+    _                          -> False
+  where
+    go :: Model Double r' -> Maybe [Distribution Double]
+    go (Pure _) = Just []
+    go (Free (Sample _ _ k))        = go (k 0)
+    go (Free (Observe _ d _ next))  = (d :) <$> go next
+    go (Free (ObserveLM {}))        = Nothing
+    go (Free (Potential _ _ next))  = go next
+    go (Free (Deterministic _ v k)) = go (k v)
+    go (Free (Data _ ys k))         = go (k (ys, ys))
+    go (Free (DataIx _ is k))       = go (k is)
+    go (Free (PlateBegin _ _ next)) = go next
+    go (Free (PlateEnd next))       = go next
+
+-- | Phase 101: 'gradPathLabel' 用の軽量構造判定 — 尤度が単一 'ArmaNormal'
+-- observe か。 実際の経路選択は 'gradValPlan' 内の 'armaAnalyticVG' が行う。
+hasArmaObserve :: ModelP r -> Bool
+hasArmaObserve m = case go m of
+    Just [ArmaNormal {}] -> True
+    _                    -> False
+  where
+    go :: Model Double r' -> Maybe [Distribution Double]
+    go (Pure _) = Just []
+    go (Free (Sample _ _ k))        = go (k 0)
+    go (Free (Observe _ d _ next))  = (d :) <$> go next
+    go (Free (ObserveLM {}))        = Nothing
+    go (Free (Potential _ _ next))  = go next
+    go (Free (Deterministic _ v k)) = go (k v)
+    go (Free (Data _ ys k))         = go (k (ys, ys))
+    go (Free (DataIx _ is k))       = go (k is)
+    go (Free (PlateBegin _ _ next)) = go next
+    go (Free (PlateEnd next))       = go next
+
+-- | Phase 101: 'gradPathLabel' 用の軽量構造判定 — 尤度が単一
+-- 'GradedResponseIrt' observe か。 実際の経路選択は 'gradValPlan' 内の
+-- 'gradedIrtAnalyticVG' が行う。
+hasGradedIrtObserve :: ModelP r -> Bool
+hasGradedIrtObserve m = case go m of
+    Just [GradedResponseIrt {}] -> True
+    _                           -> False
+  where
+    go :: Model Double r' -> Maybe [Distribution Double]
+    go (Pure _) = Just []
+    go (Free (Sample _ _ k))        = go (k 0)
+    go (Free (Observe _ d _ next))  = (d :) <$> go next
+    go (Free (ObserveLM {}))        = Nothing
+    go (Free (Potential _ _ next))  = go next
+    go (Free (Deterministic _ v k)) = go (k v)
+    go (Free (Data _ ys k))         = go (k (ys, ys))
+    go (Free (DataIx _ is k))       = go (k is)
+    go (Free (PlateBegin _ _ next)) = go next
+    go (Free (PlateEnd next))       = go next
+
+-- ---------------------------------------------------------------------------
+-- Phase 95 A6: dense MvNormal observe の解析随伴 (detach) 経路
+-- ---------------------------------------------------------------------------
+
+-- | Phase 95 A6: 尤度が **単一 'MvNormal' observe** のモデル (GP 回帰・
+-- dense-MvNormal) の value+grad を **解析随伴 (detach トリック)** で計算する
+-- クロージャを構築する。 'mvNormalObserveOf' が 'Just' のとき (= 適格) のみ
+-- 'Just' を返し、 非適格なら 'Nothing' (呼び出し側は従来 walk+ad へ)。
+--
+-- === なぜ速いか
+-- 現行 walk+ad は N×N Cholesky + solve + logdet を **毎 leapfrog で reverse-AD
+-- tape 上に丸ごと展開**する (O(N³) のスカラー演算が各々 boxed AD ノードを alloc)。
+-- 大 N で壊滅的 (§A4: N=50 で対 PyMC 62×)。 本経路は PyMC/Stan と同じく
+-- **Cholesky を AD tape に載せず** Σ⁻¹/logdet を LAPACK で 1 度だけ Double 計算し、
+-- @G = ∂logp/∂Σ@・@h = ∂logp/∂μ@ を定数化して surrogate @<G,Σ(θ)>+<h,μ(θ)>@ の
+-- 軽量 ad (O(N²)・cholesky 無し) だけを微分する。
+--
+-- === 数学 (detach)
+-- 1 観測 (k-vector) の @logp = -k/2·log2π - 0.5·log|Σ| - 0.5·(y-μ)ᵀΣ⁻¹(y-μ)@ に対し
+-- @α = Σ⁻¹(y-μ)@、 @∂logp/∂Σ = 0.5(ααᵀ - Σ⁻¹)@、 @∂logp/∂μ = α@。 複数 chunk では
+-- @G = 0.5(Σ_m α_mα_mᵀ - nCh·Σ⁻¹)@、 @h = Σ_m α_m@。 surrogate @<G,Σ(θ)>+<h,μ(θ)>@ の
+-- θ 勾配は連鎖律で @∂logp/∂θ@ に厳密一致 (proto: 有限差分 2.8e-9・ad-through 2.2e-15)。
+-- θ=invTransform(u) を surrogate 内に組むので ∂/∂u が直接得られる。
+--
+-- 総勾配 = @grad(logPrior+logJac)@ (scalar・cheap) + @detach(observe)@。
+-- 値 = @(logPrior+logJac)@ + @logp_MvNormal@ (同じ LAPACK 分解から)。
+--
+-- === 常時 ON (次元しきい値なし)
+-- 解析随伴は近似でなく厳密ゆえ正しさゲート不要。 PyMC/Stan も Cholesky Op の
+-- 解析随伴を **常時**使う (次元しきい値を持たない)。 小 N (N≲40) では list 往復の
+-- 定数倍で ad-through と拮抗〜僅遅だが (§A3-proto crossover ≈N40-50)、 該当する
+-- shipping モデルは無い。 その帯の overhead 除去 (surrogate 配列化) は TODO (§A6)。
+mvNormalAnalyticVG
+  :: forall r. ModelP r -> [Text] -> [Transform]
+  -> Maybe (VS.Vector Double -> (Double, VS.Vector Double))
+mvNormalAnalyticVG m names trans =
+  -- 適格判定は構造のみ (param 値に依らない) — ダミー 0 で walk。
+  case mvNormalObserveOf m probeParams of
+    Nothing -> Nothing
+    Just _  -> Just closure
+  where
+    probeParams :: Map Text Double
+    probeParams = Map.fromList [ (n, 0) | n <- names ]
+
+    closure :: VS.Vector Double -> (Double, VS.Vector Double)
+    closure uv =
+      let us     = VS.toList uv
+          thetaD = [ invTransformF t u | (t, u) <- zip trans us ]
+          paramD = Map.fromList (zip names thetaD)
+      in case mvNormalObserveOf m paramD of
+           Nothing -> (fFullA us, VS.fromList (gradFullA us))  -- 適格判定と矛盾: fallback
+           Just (muD, covD, ys)
+             | k == 0 || null chunks ->                        -- 退化: fallback
+                 (fFullA us, VS.fromList (gradFullA us))
+             | otherwise ->
+                 let -- 片道 flat 化のみ (fromLists 不使用): [[Double]] を concat して
+                     -- row-major で Matrix に積む。 戻りの toLists は一切しない。
+                     sig       = LA.matrix k (concat covD)            -- N×N
+                     (inv, (lndet, _sgn)) = LA.invlndet sig
+                     muV       = LA.fromList muD
+                     ds        = [ LA.fromList (map realToFrac ym) - muV | ym <- chunks ]
+                     alphas    = [ inv LA.#> d | d <- ds ]            -- α_m = Σ⁻¹(y_m-μ)
+                     quadSum   = sum [ d LA.<.> a | (d, a) <- zip ds alphas ]
+                     kA        = fromIntegral k :: Double
+                     nChA      = fromIntegral nCh :: Double
+                     logpObs   = nChA * (negate 0.5 * kA * log (2 * pi) - 0.5 * lndet)
+                                 - 0.5 * quadSum
+                     -- G = 0.5(Σ_m α_mα_mᵀ - nCh·Σ⁻¹), h = Σ_m α_m
+                     gMat      = LA.scale 0.5
+                                   (foldl1 (+) [ LA.outer a a | a <- alphas ]
+                                    - LA.scale nChA inv)
+                     -- G/h は **flat Storable Vector のまま** (row-major)。 surrogate 側で
+                     -- index して realToFrac lift = nested list 化 (toLists) を回避。
+                     gFlat     = LA.flatten gMat :: VS.Vector Double  -- length k*k, row-major
+                     hVec      = foldl1 (+) alphas :: VS.Vector Double -- length k
+                     -- detach surrogate: ∂/∂u <G,Σ(θ(u))> + <h,μ(θ(u))>。 Σ(θ) は model が
+                     -- [[a]] を吐くので concat で 1 回 flat 化し gFlat と flat×flat 内積。
+                     surrogate :: forall a. (Floating a, Ord a, TrackTag a) => [a] -> a
+                     surrogate uu =
+                       let thetaA = [ invTransformF t u | (t, u) <- zip trans uu ]
+                           paramA = Map.fromList (zip names thetaA)
+                       in case mvNormalObserveOf m paramA of
+                            Just (muA, covA, _) ->
+                              dotFlatL gFlat (concat covA) + dotFlatL hVec muA
+                            Nothing -> 0
+                     gObs      = grad surrogate us                    -- ∂obs/∂u
+                     gRest     = gradRestA us                         -- ∂(logPrior+logJac)/∂u
+                     vRest     = fRestA us                            -- (logPrior+logJac)(u)
+                 in ( vRest + logpObs
+                    , VS.fromList (zipWith (+) gRest gObs) )
+             where k      = length muD
+                   chunks = chunksOf k ys
+                   nCh    = length chunks
+
+    -- flat Double Vector · [a] リスト内積: Double 側 (G/h) を list 化せず index して
+    -- realToFrac で lift。 xs (concat covA / muA) だけを 1 回舐める (toLists 往復回避)。
+    dotFlatL :: forall a. Floating a => VS.Vector Double -> [a] -> a
+    dotFlatL gv = go 0 0
+      where go !i !acc (x : xs) = go (i + 1) (acc + realToFrac (VS.unsafeIndex gv i) * x) xs
+            go _  !acc []       = acc
+
+    -- prior + logJac (尤度を除く) — scalar・dense 行列を含まない。
+    fRestA :: (Floating a, Ord a, TrackTag a) => [a] -> a
+    fRestA us =
+      let paramsC = Map.fromList (zip names [ invTransformF t u | (t, u) <- zip trans us ])
+          logJac  = sum [ logJacF t u | (t, u) <- zip trans us ]
+      in logPrior m paramsC + logJac
+    -- AD 側は 'logDensityRD' 注入版 (Phase 92 B3・値/勾配とも fRestA と bit 一致)
+    fRestRD :: forall s. Reifies s ADRD.Tape
+            => [ADRD.ReverseDouble s] -> ADRD.ReverseDouble s
+    fRestRD us =
+      let paramsC = Map.fromList (zip names [ invTransformF t u | (t, u) <- zip trans us ])
+          logJac  = sum [ logJacF t u | (t, u) <- zip trans us ]
+      in logPriorWith logDensityRD m paramsC + logJac
+    gradRestA :: [Double] -> [Double]
+    gradRestA = grad fRestRD
+
+    -- 適格判定が崩れた稀ケース用の完全 walk+ad fallback (従来経路と同一)。
+    fFullA :: (Floating a, Ord a, TrackTag a) => [a] -> a
+    fFullA us =
+      let paramsC = Map.fromList (zip names [ invTransformF t u | (t, u) <- zip trans us ])
+          logJac  = sum [ logJacF t u | (t, u) <- zip trans us ]
+      in logJoint m paramsC + logJac
+    gradFullA :: [Double] -> [Double]
+    gradFullA = grad fFullA
+
+-- ---------------------------------------------------------------------------
+-- Phase 95 B-dsl: GP (RBF) 尤度の閉形式随伴 (Cholesky を AD tape に載せない)
+-- ---------------------------------------------------------------------------
+
+-- | Phase 95 B-dsl: 尤度が **単一 'MvNormalGpRBF' observe** のモデルから
+-- @(x, α, ρ, σ, ys)@ を現在の param 値で抽出する。 それ以外 (他 Observe/
+-- ObserveLM 混在・非 GpRBF) は 'Nothing'。 walk は 'mvNormalObserveOf' と同型。
+gpRBFObserveOf :: (Floating a, Ord a)
+               => Model a r -> Map Text a -> Maybe ([a], a, a, a, [Double])
+gpRBFObserveOf model params =
+  case go model of
+    Just [(MvNormalGpRBF xs al rh sg, ys)] -> Just (xs, al, rh, sg, ys)
+    _                                      -> Nothing
+  where
+    go (Pure _) = Just []
+    go (Free (Sample n _ k)) =
+      case Map.lookup n params of
+        Nothing -> Nothing
+        Just v  -> go (k v)
+    go (Free (Observe _ d ys next)) = ((d, ys) :) <$> go next
+    go (Free (ObserveLM {}))        = Nothing
+    go (Free (Potential _ _ next))  = go next
+    go (Free (Deterministic _ v k)) = go (k v)
+    go (Free (Data _ ys k))         = go (k (map realToFrac ys, ys))
+    go (Free (DataIx _ is k))       = go (k is)
+    go (Free (PlateBegin _ _ next)) = go next
+    go (Free (PlateEnd next))       = go next
+
+-- | Phase 95 B-dsl: 'MvNormalGpRBF' 尤度の value+grad を **閉形式随伴**で計算する
+-- クロージャを構築する。 適格 (単一 GpRBF observe) のときのみ 'Just'。
+--
+-- === A案 (汎用 detach) との差 = 84% の除去
+-- A案 (§mvNormalAnalyticVG) は surrogate @<G,Σ(θ)>@ を **AD で微分**するため、
+-- 毎 leaf で Σ(θ) を @[[a]]@ で組み直し (profile: gpExpQuadCov 55%) その全 N²
+-- ノードを reverse-AD tape に載せていた (reifyTypeable+partials 29%)。 本経路は:
+--
+--   1. カーネル役割 (x/α/ρ/σ) が 'MvNormalGpRBF' の型で明示されているので、
+--      **∂Σ/∂θ を閉形式** (@∂Σ/∂α=2K'/α@・@∂Σ/∂ρ=K'∘d²/ρ³@・@∂Σ/∂σ=I@) で書ける。
+--   2. G=∂logp/∂Σ・K'・d² は **hmatrix Matrix** (脱リスト) で計算し、
+--      @g_θ = <G,∂Σ/∂θ>@ を要素積 + trace で Double 算出 (**AD tape ゼロ**)。
+--   3. u への連鎖律は **軽量 surrogate** @g_α·α(u)+g_ρ·ρ(u)+g_σ·σ(u)@ を ad。
+--      ad は α/ρ/σ の **3 scalar 抽出のみ** (cov は非展開・lazy)。 これで
+--      「どの u が α/ρ/σ か」の対応付けを AD が自動処理する (名前直書き不要)。
+--   4. 値 logp と Σ⁻¹/logdet は同じ LAPACK 分解 (@invlndet@) から。
+--
+-- 距離行列 d² は x (data・定数) から **build 時に 1 度だけ**作り、全 leaf で再利用。
+gpRBFAnalyticVG
+  :: forall r. ModelP r -> [Text] -> [Transform]
+  -> Maybe (VS.Vector Double -> (Double, VS.Vector Double))
+gpRBFAnalyticVG m names trans =
+  case gpRBFObserveOf m probeParams of
+    Just (xs0, _, _, _, _) | not (null xs0) -> Just (closure (buildD2 xs0))
+    _                                       -> Nothing
+  where
+    probeParams :: Map Text Double
+    probeParams = Map.fromList [ (nm, 0) | nm <- names ]
+
+    -- x (data・定数) から距離² 行列 D2_ij=(x_i-x_j)² を build 時 1 回だけ。
+    buildD2 :: [Double] -> LA.Matrix Double
+    buildD2 xs =
+      let nn = length xs
+          xv = VS.fromList xs
+      in LA.matrix nn [ let d = VS.unsafeIndex xv i - VS.unsafeIndex xv j in d * d
+                      | i <- [0 .. nn - 1], j <- [0 .. nn - 1] ]
+
+    paramMapOf :: forall a. Floating a => [a] -> Map Text a
+    paramMapOf us = Map.fromList (zip names [ invTransformF t u | (t, u) <- zip trans us ])
+
+    closure :: LA.Matrix Double -> VS.Vector Double -> (Double, VS.Vector Double)
+    closure d2 uv =
+      let us = VS.toList uv
+      in case gpRBFObserveOf m (paramMapOf us) of
+           Nothing -> (fFullA us, VS.fromList (gradFullA us))       -- 適格崩れ: fallback
+           Just (xs, alphaD, rhoD, sigmaD, ys)
+             | k == 0 || null chunks -> (fFullA us, VS.fromList (gradFullA us))
+             | otherwise ->
+                 let -- 純カーネル K'_ij = α² exp(-0.5 D2/ρ²)。exp は hmatrix の
+                     -- element-wise Floating instance (C ベクトル化) を使い、cmap の
+                     -- Haskell ラムダ per-element boxing (2500 boxed Double/leaf) を回避。
+                     kMat    = LA.scale (alphaD * alphaD)
+                                 (exp (LA.scale (negate 0.5 / (rhoD * rhoD)) d2))
+                     -- Σ = K' + (jitter+σ)I。対角に直接加算 (脱 ident: N×N を 3 パス→1)。
+                     covM    = LA.accum kMat (+) [ ((i, i), 1e-10 + sigmaD) | i <- [0 .. k - 1] ]
+                 -- covM = K'(PSD) + (1e-10+σ>0)I ゆえ本来 PD。Cholesky は LU 全逆行列より
+                 -- O(N³) 定数が軽い (PyMC/Stan と同じ経路)。ただし σ→0⁺ の悪条件で LAPACK が
+                 -- 非 PD 判定する稀ケースに備え mbChol で受け、崩れたら full-AD へ安全退避。
+                 in case LA.mbChol (LA.trustSym covM) of
+                      Nothing    -> (fFullA us, VS.fromList (gradFullA us))  -- 非 PD (稀): fallback
+                      Just uchol ->
+                       let inv     = LA.cholSolve uchol (LA.ident k)         -- Σ⁻¹ (SPD solve)
+                           lndet   = 2 * sum (map log (LA.toList (LA.takeDiag uchol)))  -- log|Σ|=2Σlog U_ii
+                           ds      = [ LA.fromList (map realToFrac yv) | yv <- chunks ]  -- (y - μ), μ=0
+                           alphas  = [ inv LA.#> d | d <- ds ]                 -- Σ⁻¹(y-μ)
+                           quadSum = sum [ d LA.<.> a | (d, a) <- zip ds alphas ]
+                           kA      = fromIntegral k :: Double
+                           nChA    = fromIntegral nCh :: Double
+                           logpObs = nChA * (negate 0.5 * kA * log (2 * pi) - 0.5 * lndet)
+                                     - 0.5 * quadSum
+                           -- 閉形式随伴 g_θ = <G, ∂Σ/∂θ>, G = 0.5(Σ_m α_mα_mᵀ - nCh·Σ⁻¹)。
+                           -- Frobenius 恒等式で G を materialize せず算出 (N×N 一時行列を全廃):
+                           --   <α_mα_mᵀ, M> = α_mᵀ M α_m  (BLAS mat-vec + dot・N² alloc なし)、
+                           --   <Σ⁻¹, M>     = <flatten Σ⁻¹, flatten M>  (BLAS ddot・temp なし)。
+                           -- ∂Σ/∂α=2K'/α・∂Σ/∂σ=I・∂Σ/∂ρ=K'∘d²/ρ³。
+                           kd2      = kMat * d2                              -- ∂Σ/∂ρ 用 Hadamard (1 回だけ)
+                           invFlat  = LA.flatten inv
+                           quadK    = sum [ a LA.<.> (kMat LA.#> a) | a <- alphas ]  -- Σ α_mᵀK'α_m
+                           quadKd2  = sum [ a LA.<.> (kd2  LA.#> a) | a <- alphas ]  -- Σ α_mᵀ(K'∘d²)α_m
+                           aaSum    = sum [ a LA.<.> a | a <- alphas ]              -- Σ α_mᵀα_m
+                           frobIK   = invFlat LA.<.> LA.flatten kMat               -- <Σ⁻¹, K'>
+                           frobIKd2 = invFlat LA.<.> LA.flatten kd2                -- <Σ⁻¹, K'∘d²>
+                           trInv    = LA.sumElements (LA.takeDiag inv)             -- tr(Σ⁻¹)
+                           gAlpha   = (quadK - nChA * frobIK) / alphaD             -- 2·<G,K'>/α
+                           gSigma   = 0.5 * (aaSum - nChA * trInv)                 -- <G,I> = tr(G)
+                           gRho     = 0.5 * (quadKd2 - nChA * frobIKd2) / (rhoD ** 3) -- <G,K'∘d²>/ρ³
+                           -- 軽量 scatter: g_θ を u へ連鎖 (ad は α/ρ/σ の抽出のみ・cov 非展開)
+                           surrogate :: forall a. (Floating a, Ord a, TrackTag a) => [a] -> a
+                           surrogate uu =
+                             case gpRBFObserveOf m (paramMapOf uu) of
+                               Just (_, al, rh, sg, _) ->
+                                 realToFrac gAlpha * al + realToFrac gRho * rh
+                                   + realToFrac gSigma * sg
+                               Nothing -> 0
+                           gObs    = grad surrogate us                        -- ∂obs/∂u
+                           gRest   = gradRestA us                             -- ∂(logPrior+logJac)/∂u
+                           vRest   = fRestA us                                -- (logPrior+logJac)(u)
+                       in ( vRest + logpObs
+                          , VS.fromList (zipWith (+) gRest gObs) )
+             where k      = length xs                                  -- GP 次元
+                   chunks = chunksOf k ys                              -- 通常 1 chunk
+                   nCh    = length chunks
+
+    -- prior + logJac (尤度除く) — scalar・dense 行列なし。
+    fRestA :: (Floating a, Ord a, TrackTag a) => [a] -> a
+    fRestA us = logPrior m (paramMapOf us)
+                  + sum [ logJacF t u | (t, u) <- zip trans us ]
+    -- AD 側は 'logDensityRD' 注入版 (Phase 92 B3・値/勾配とも fRestA と bit 一致)
+    fRestRD :: forall s. Reifies s ADRD.Tape
+            => [ADRD.ReverseDouble s] -> ADRD.ReverseDouble s
+    fRestRD us = logPriorWith logDensityRD m (paramMapOf us)
+                   + sum [ logJacF t u | (t, u) <- zip trans us ]
+    gradRestA :: [Double] -> [Double]
+    gradRestA = grad fRestRD
+
+    -- 適格崩れ時の完全 walk+ad fallback。
+    fFullA :: (Floating a, Ord a, TrackTag a) => [a] -> a
+    fFullA us = logJoint m (paramMapOf us)
+                  + sum [ logJacF t u | (t, u) <- zip trans us ]
+    gradFullA :: [Double] -> [Double]
+    gradFullA = grad fFullA
+
+-- ---------------------------------------------------------------------------
+-- Phase 92 A2: HMM forward 尤度の閉形式随伴 (forward-backward・AD tape ゼロ)
+-- ---------------------------------------------------------------------------
+
+-- | Phase 92 A2: 尤度が **単一 'HmmForwardNormal' observe** のモデルから
+-- @(π_0, trans, μs, σ, ys)@ を現在の param 値で抽出する。 それ以外 (他 Observe/
+-- ObserveLM 混在・非 HMM) は 'Nothing'。 walk は 'gpRBFObserveOf' と同型。
+hmmObserveOf :: (Floating a, Ord a)
+             => Model a r -> Map Text a -> Maybe ([a], [[a]], [a], a, [Double])
+hmmObserveOf model params =
+  case go model of
+    Just [(HmmForwardNormal pi0 tr mus sg, ys)] -> Just (pi0, tr, mus, sg, ys)
+    _                                           -> Nothing
+  where
+    go (Pure _) = Just []
+    go (Free (Sample n _ k)) =
+      case Map.lookup n params of
+        Nothing -> Nothing
+        Just v  -> go (k v)
+    go (Free (Observe _ d ys next)) = ((d, ys) :) <$> go next
+    go (Free (ObserveLM {}))        = Nothing
+    go (Free (Potential _ _ next))  = go next
+    go (Free (Deterministic _ v k)) = go (k v)
+    go (Free (Data _ ys k))         = go (k (map realToFrac ys, ys))
+    go (Free (DataIx _ is k))       = go (k is)
+    go (Free (PlateBegin _ _ next)) = go next
+    go (Free (PlateEnd next))       = go next
+
+-- | Phase 92 A2: 'HmmForwardNormal' 尤度の value+grad を **forward-backward の
+-- 閉形式随伴**で計算するクロージャを構築する。 適格 (単一 HMM observe) のとき
+-- のみ 'Just'。 構成は 'gpRBFAnalyticVG' と同じ 3 段:
+--
+--   1. forward α / backward β を **Double 空間** (AD tape 外) で 1 回ずつ回し、
+--      値 @logL = logSumExp_k α_T[k]@ と閉形式随伴
+--      @∂logL/∂μ_k = Σ_t γ_t[k]·(y_t-μ_k)/σ²@ (γ_t[k]=exp(α_t+β_t-logL))・
+--      @∂logL/∂T_ij = Σ_t exp(α_t[i]+emit_{t+1}[j]+β_{t+1}[j]-logL)@ (ξ 集計)・
+--      @∂logL/∂π_k = γ_0[k]/π_k@・σ も同様、 を Double で算出する。
+--   2. u への連鎖律は **軽量 surrogate** @Σ g_θ·θ(u)@ を ad。 ad は
+--      π/T/μ/σ の **O(K²) scalar 抽出のみ** (T 長の forward loop は非展開)。
+--      dirichlet の棒折り deterministic 等の合成は AD が自動処理する。
+--   3. prior + logJac は 'logPrior' ベースの fRest (走査対象から尤度を除外)。
+--
+-- 従来 walk+ad は T×K² 個の logSumExp/logDensity を毎 leapfrog boxed AD で
+-- 再評価していた (Phase 92 A1d: 数値密度系 84% + AD 6%・alloc 23.7GB/5s)。
+-- 本経路は同じ O(TK²) を unboxed Double で 2 パス回すだけで tape に載せない。
+hmmAnalyticVG
+  :: forall r. ModelP r -> [Text] -> [Transform]
+  -> Maybe (VS.Vector Double -> (Double, VS.Vector Double))
+hmmAnalyticVG m names trans =
+  case hmmObserveOf m probeParams of
+    Just (pi0, tr, mus, _, ys)
+      | kDim > 0, length tr == kDim, all ((== kDim) . length) tr
+      , length mus == kDim, not (null ys) -> Just closure
+      where kDim = length pi0
+    _ -> Nothing
+  where
+    probeParams :: Map Text Double
+    probeParams = Map.fromList [ (nm, 0) | nm <- names ]
+
+    paramMapOf :: forall a. Floating a => [a] -> Map Text a
+    paramMapOf us = Map.fromList (zip names [ invTransformF t u | (t, u) <- zip trans us ])
+
+    closure :: VS.Vector Double -> (Double, VS.Vector Double)
+    closure uv =
+      let us = VS.toList uv
+      in case hmmObserveOf m (paramMapOf us) of
+           Nothing -> (fFullA us, VS.fromList (gradFullA us))       -- 適格崩れ: fallback
+           Just (pi0D, transD, musD, sgD, ys)
+             | sgD <= 0 || null ys -> (fFullA us, VS.fromList (gradFullA us))
+             | otherwise ->
+                 -- B2-① (2026-07-17): 脱リスト化 — α/β/emit を unboxed 行 vector で持ち、
+                 -- 内側 K ループは lseK (list 非 alloc の 2 パス logSumExp)。γ は非実体化。
+                 let kk  = length pi0D
+                     tT  = length ys
+                     ixs = [0 .. kk - 1]
+                     ysV  = VU.fromList ys
+                     musV = VU.fromList musD
+                     lPi0 = VU.fromList (map safeLog pi0D)
+                     lTr  = VU.fromList (map safeLog (concat transD))   -- 行優先 K×K flat
+                     lsg  = log sgD
+                     c2pi = 0.5 * log (2 * pi)
+                     emitAt t k' = let z = (VU.unsafeIndex ysV t - VU.unsafeIndex musV k') / sgD
+                                   in -0.5 * z * z - lsg - c2pi
+                     emitRows = [ VU.generate kk (emitAt t) | t <- [0 .. tT - 1] ]
+                     -- K 要素 logSumExp (max → sumexp の 2 パス・中間 list 無し)
+                     lseK f = let mx = foldl' (\acc i -> max acc (f i)) negInf ixs
+                              in if mx == negInf then negInf
+                                 else mx + log (foldl' (\acc i -> acc + exp (f i - mx)) 0 ixs)
+                     -- forward: α_t 全行を保持 (随伴の γ/ξ に使う)
+                     alpha0 = VU.zipWith (+) lPi0 (head emitRows)
+                     stepF aPrev emT = VU.generate kk $ \j ->
+                       lseK (\i -> VU.unsafeIndex aPrev i + VU.unsafeIndex lTr (i * kk + j))
+                         + VU.unsafeIndex emT j
+                     alphaRows = scanl stepF alpha0 (tail emitRows)   -- 長さ T
+                     logL = lseK (VU.unsafeIndex (last alphaRows))
+                     -- backward: β_{T-1}=0・β_t[i] = lse_j (lT_ij + emit_{t+1}[j] + β_{t+1}[j])
+                     stepB emNext bNext = VU.generate kk $ \i ->
+                       lseK (\j -> VU.unsafeIndex lTr (i * kk + j)
+                                     + VU.unsafeIndex emNext j + VU.unsafeIndex bNext j)
+                     betaRows = scanr stepB (VU.replicate kk 0) (tail emitRows)  -- 長さ T
+                     -- 閉形式随伴 (全て Double・tape ゼロ・γ_t[k] = exp(α+β-logL) は都度計算)
+                     gammaAt aR bR k' = exp (VU.unsafeIndex aR k' + VU.unsafeIndex bR k' - logL)
+                     abRows = zip3 alphaRows betaRows [0 ..]
+                     gMu = [ foldl' (\acc (aR, bR, t) ->
+                                       acc + gammaAt aR bR k'
+                                             * (VU.unsafeIndex ysV t - VU.unsafeIndex musV k')
+                                             / (sgD * sgD))
+                                    0 abRows
+                           | k' <- ixs ]
+                     gSg = foldl' (\acc (aR, bR, t) ->
+                                     foldl' (\a2 k' ->
+                                               let z = (VU.unsafeIndex ysV t
+                                                          - VU.unsafeIndex musV k') / sgD
+                                               in a2 + gammaAt aR bR k' * (z * z - 1) / sgD)
+                                            acc ixs)
+                                  0 abRows
+                     gPi0 = [ if p > 0 then gammaAt (head alphaRows) (head betaRows) k' / p else 0
+                            | (k', p) <- zip ixs pi0D ]
+                     -- ξ 集計: ∂logL/∂T_ij = Σ_{t<T-1} exp(α_t[i]+emit_{t+1}[j]+β_{t+1}[j]-logL)
+                     xiRows = zip3 alphaRows (tail emitRows) (tail betaRows)
+                     gTr = [ [ foldl' (\acc (aR, emN, bN) ->
+                                         acc + exp (VU.unsafeIndex aR i + VU.unsafeIndex emN j
+                                                      + VU.unsafeIndex bN j - logL))
+                                      0 xiRows
+                             | j <- ixs ]
+                           | i <- ixs ]
+                     -- 軽量 scatter: g_θ を u へ連鎖 (ad は π/T/μ/σ の抽出のみ・T loop 非展開)
+                     surrogate :: forall a. (Floating a, Ord a, TrackTag a) => [a] -> a
+                     surrogate uu =
+                       case hmmObserveOf m (paramMapOf uu) of
+                         Just (p0, tr', ms, s, _) ->
+                           sum (zipWith (\g v -> realToFrac g * v) gPi0 p0)
+                             + sum (zipWith (\gr r -> sum (zipWith (\g v -> realToFrac g * v) gr r))
+                                            gTr tr')
+                             + sum (zipWith (\g v -> realToFrac g * v) gMu ms)
+                             + realToFrac gSg * s
+                         Nothing -> 0
+                     -- B2-② (2026-07-17): prior+logJac と surrogate を 1 本の AD tape に合流し
+                     -- grad' で値+勾配を同時取得 (walk 4 回/eval → Double 1 + AD 1)。
+                     -- fRest の値は vComb から surrogate の Double 値を引いて復元する。
+                     -- B3: prior 密度は 'logDensityRD' 注入 (fRestRD) = 定数
+                     -- hyperparam の lgamma 正規化項を Double へ畳み込み (bit 一致)。
+                     fCombRD :: forall s. Reifies s ADRD.Tape
+                             => [ADRD.ReverseDouble s] -> ADRD.ReverseDouble s
+                     fCombRD uu = fRestRD uu + surrogate uu
+                     (vComb, gComb) = grad' fCombRD us
+                     surrAtUs = sum (zipWith (*) gPi0 pi0D)
+                                  + sum (zipWith (\gr r -> sum (zipWith (*) gr r)) gTr transD)
+                                  + sum (zipWith (*) gMu musD)
+                                  + gSg * sgD
+                 in ( (vComb - surrAtUs) + logL
+                    , VS.fromList gComb )
+
+    safeLog :: Double -> Double
+    safeLog x = if x <= 0 then negInf else log x
+
+    -- prior + logJac (尤度除く) — 'gpRBFAnalyticVG' と同じ。
+    fRestA :: (Floating a, Ord a, TrackTag a) => [a] -> a
+    fRestA us = logPrior m (paramMapOf us)
+                  + sum [ logJacF t u | (t, u) <- zip trans us ]
+    gradRestA :: [Double] -> [Double]
+    gradRestA = grad fRestA
+
+    -- fRestA の AD 特化 (Phase 92 B3): 'logDensityRD' 注入で定数 hyperparam の
+    -- lgamma 正規化項を Double へ畳み込む。 値・勾配とも fRestA と bit 一致
+    -- ('logDensityRD' の注釈参照)。
+    fRestRD :: forall s. Reifies s ADRD.Tape
+            => [ADRD.ReverseDouble s] -> ADRD.ReverseDouble s
+    fRestRD us = logPriorWith logDensityRD m (paramMapOf us)
+                   + sum [ logJacF t u | (t, u) <- zip trans us ]
+
+    -- 適格崩れ時の完全 walk+ad fallback。
+    fFullA :: (Floating a, Ord a, TrackTag a) => [a] -> a
+    fFullA us = logJoint m (paramMapOf us)
+                  + sum [ logJacF t u | (t, u) <- zip trans us ]
+    gradFullA :: [Double] -> [Double]
+    gradFullA = grad fFullA
+
+-- ---------------------------------------------------------------------------
+-- Phase 101 A2: ARMA(1,1) 尤度の閉形式随伴 (逆向き随伴再帰・AD tape ゼロ)
+-- ---------------------------------------------------------------------------
+
+-- | Phase 101 A2: 尤度が **単一 'ArmaNormal' observe** のモデルから
+-- @(μ, φ, θ, σ, ys)@ を現在の param 値で抽出する。 それ以外 (他 Observe 混在・
+-- 非 ARMA) は 'Nothing'。 walk は 'hmmObserveOf' と同型。
+armaObserveOf :: (Floating a, Ord a)
+              => Model a r -> Map Text a -> Maybe (a, a, a, a, [Double])
+armaObserveOf model params =
+  case go model of
+    Just [(ArmaNormal mu phi theta sg, ys)] -> Just (mu, phi, theta, sg, ys)
+    _                                       -> Nothing
+  where
+    go (Pure _) = Just []
+    go (Free (Sample n _ k)) =
+      case Map.lookup n params of
+        Nothing -> Nothing
+        Just v  -> go (k v)
+    go (Free (Observe _ d ys next)) = ((d, ys) :) <$> go next
+    go (Free (ObserveLM {}))        = Nothing
+    go (Free (Potential _ _ next))  = go next
+    go (Free (Deterministic _ v k)) = go (k v)
+    go (Free (Data _ ys k))         = go (k (map realToFrac ys, ys))
+    go (Free (DataIx _ is k))       = go (k is)
+    go (Free (PlateBegin _ _ next)) = go next
+    go (Free (PlateEnd next))       = go next
+
+-- | Phase 101 A2: 'ArmaNormal' 尤度の value+grad を **逆向き 1 パスの閉形式
+-- 随伴**で計算するクロージャを構築する。 適格 (単一 ArmaNormal observe) の
+-- ときのみ 'Just'。 構成は 'hmmAnalyticVG' と同じ 3 段:
+--
+--   1. err 前向き再帰 (@e_1 = y_1 − (μ+φμ)@・@e_t = y_t − μ − φ·y_{t−1} −
+--      θ·e_{t−1}@) と随伴の逆向き再帰 (@ē_t = −e_t/σ² − θ·ē_{t+1}@・
+--      @ē_T = −e_T/σ²@) を **Double 空間** (AD tape 外) で 1 回ずつ回し、
+--      値 @logL = Σ_t log N(e_t; 0, σ)@ と閉形式随伴
+--      @∂logL/∂μ = ē_1·(−(1+φ)) − Σ_{t≥2} ē_t@・
+--      @∂logL/∂φ = ē_1·(−μ) − Σ_{t≥2} ē_t·y_{t−1}@・
+--      @∂logL/∂θ = −Σ_{t≥2} ē_t·e_{t−1}@・
+--      @∂logL/∂σ = −T/σ + (Σ_t e_t²)/σ³@ を算出する。
+--   2. u への連鎖律は **軽量 surrogate** @Σ g_θ·θ(u)@ を ad (μ/φ/θ/σ の
+--      4 scalar 抽出のみ・T 長の再帰は非展開)。
+--   3. prior + logJac は 'logDensityRD' 注入の fRestRD (Phase 92 B3 と同じ)。
+--
+-- 従来 walk+ad は T 本の 'logDensity' + mapAccumL 再帰を毎 leapfrog boxed AD
+-- で再評価していた (Phase 101 A1: logDensity 31.2% + armaModel 20.9%・
+-- alloc 72%)。 本経路は同じ O(T) を unboxed Double で 2 パス回すだけ。
+armaAnalyticVG
+  :: forall r. ModelP r -> [Text] -> [Transform]
+  -> Maybe (VS.Vector Double -> (Double, VS.Vector Double))
+armaAnalyticVG m names trans =
+  case armaObserveOf m probeParams of
+    Just (_, _, _, _, ys) | not (null ys) -> Just closure
+    _                                     -> Nothing
+  where
+    probeParams :: Map Text Double
+    probeParams = Map.fromList [ (nm, 0) | nm <- names ]
+
+    paramMapOf :: forall a. Floating a => [a] -> Map Text a
+    paramMapOf us = Map.fromList (zip names [ invTransformF t u | (t, u) <- zip trans us ])
+
+    closure :: VS.Vector Double -> (Double, VS.Vector Double)
+    closure uv =
+      let us = VS.toList uv
+      in case armaObserveOf m (paramMapOf us) of
+           Nothing -> (fFullA us, VS.fromList (gradFullA us))       -- 適格崩れ: fallback
+           Just (muD, phiD, thD, sgD, ys)
+             | sgD <= 0 || null ys -> (fFullA us, VS.fromList (gradFullA us))
+             | otherwise ->
+                 let ysV = VU.fromList ys
+                     tT  = VU.length ysV
+                     s2  = sgD * sgD
+                     -- forward: err 列 (unboxed・prefix 参照の constructN)
+                     errsV = VU.constructN tT $ \pre ->
+                       let i = VU.length pre
+                       in if i == 0
+                            then VU.unsafeIndex ysV 0 - (muD + phiD * muD)
+                            else VU.unsafeIndex ysV i
+                                   - (muD + phiD * VU.unsafeIndex ysV (i - 1)
+                                        + thD * VU.unsafeIndex pre (i - 1))
+                     sumE2 = VU.foldl' (\acc e -> acc + e * e) 0 errsV
+                     logL  = fromIntegral tT * (-0.5 * log (2 * pi) - log sgD)
+                               - sumE2 / (2 * s2)
+                     -- backward: 随伴 ē (suffix 参照の constructrN・ē_T = −e_T/σ²)
+                     ebarV = VU.constructrN tT $ \suf ->
+                       let t = tT - 1 - VU.length suf
+                           direct = negate (VU.unsafeIndex errsV t) / s2
+                       in if VU.null suf
+                            then direct
+                            else direct - thD * VU.unsafeIndex suf 0
+                     -- 閉形式随伴 (全て Double・tape ゼロ)
+                     gMu = VU.unsafeIndex ebarV 0 * negate (1 + phiD)
+                             - VU.ifoldl' (\acc t eb -> if t == 0 then acc else acc + eb)
+                                          0 ebarV
+                     gPhi = VU.unsafeIndex ebarV 0 * negate muD
+                              - VU.ifoldl' (\acc t eb ->
+                                              if t == 0 then acc
+                                              else acc + eb * VU.unsafeIndex ysV (t - 1))
+                                           0 ebarV
+                     gTh = negate (VU.ifoldl' (\acc t eb ->
+                                                 if t == 0 then acc
+                                                 else acc + eb * VU.unsafeIndex errsV (t - 1))
+                                              0 ebarV)
+                     gSg = negate (fromIntegral tT) / sgD + sumE2 / (s2 * sgD)
+                     -- 軽量 scatter: g_θ を u へ連鎖 (ad は μ/φ/θ/σ の 4 scalar 抽出のみ)
+                     surrogate :: forall a. (Floating a, Ord a, TrackTag a) => [a] -> a
+                     surrogate uu =
+                       case armaObserveOf m (paramMapOf uu) of
+                         Just (mu', phi', th', sg', _) ->
+                           realToFrac gMu * mu' + realToFrac gPhi * phi'
+                             + realToFrac gTh * th' + realToFrac gSg * sg'
+                         Nothing -> 0
+                     fCombRD :: forall s. Reifies s ADRD.Tape
+                             => [ADRD.ReverseDouble s] -> ADRD.ReverseDouble s
+                     fCombRD uu = fRestRD uu + surrogate uu
+                     (vComb, gComb) = grad' fCombRD us
+                     surrAtUs = gMu * muD + gPhi * phiD + gTh * thD + gSg * sgD
+                 in ( (vComb - surrAtUs) + logL
+                    , VS.fromList gComb )
+
+    -- prior + logJac (尤度除く)・'logDensityRD' 注入 — 'hmmAnalyticVG' と同じ。
+    fRestRD :: forall s. Reifies s ADRD.Tape
+            => [ADRD.ReverseDouble s] -> ADRD.ReverseDouble s
+    fRestRD us = logPriorWith logDensityRD m (paramMapOf us)
+                   + sum [ logJacF t u | (t, u) <- zip trans us ]
+
+    -- 適格崩れ時の完全 walk+ad fallback。
+    fFullA :: (Floating a, Ord a, TrackTag a) => [a] -> a
+    fFullA us = logJoint m (paramMapOf us)
+                  + sum [ logJacF t u | (t, u) <- zip trans us ]
+    gradFullA :: [Double] -> [Double]
+    gradFullA = grad fFullA
+
+-- ---------------------------------------------------------------------------
+-- Phase 101 A3: graded response IRT 尤度の解析勾配 (AD tape ゼロ)
+-- ---------------------------------------------------------------------------
+
+-- | Phase 101 A3: 尤度が **単一 'GradedResponseIrt' observe** のモデルから
+-- @(θs, ncats, δs, γs, ys)@ を現在の param 値で抽出する。 walk は
+-- 'armaObserveOf' と同型。
+gradedIrtObserveOf :: (Floating a, Ord a)
+                   => Model a r -> Map Text a
+                   -> Maybe ([a], [Int], [Double], [[Double]], [Double])
+gradedIrtObserveOf model params =
+  case go model of
+    Just [(GradedResponseIrt ths ncats dls gms, ys)] -> Just (ths, ncats, dls, gms, ys)
+    _                                                -> Nothing
+  where
+    go (Pure _) = Just []
+    go (Free (Sample n _ k)) =
+      case Map.lookup n params of
+        Nothing -> Nothing
+        Just v  -> go (k v)
+    go (Free (Observe _ d ys next)) = ((d, ys) :) <$> go next
+    go (Free (ObserveLM {}))        = Nothing
+    go (Free (Potential _ _ next))  = go next
+    go (Free (Deterministic _ v k)) = go (k v)
+    go (Free (Data _ ys k))         = go (k (map realToFrac ys, ys))
+    go (Free (DataIx _ is k))       = go (k is)
+    go (Free (PlateBegin _ _ next)) = go next
+    go (Free (PlateEnd next))       = go next
+
+-- | Phase 101 A3: 'GradedResponseIrt' 尤度の value+grad を **解析勾配**で
+-- 計算するクロージャを構築する。 適格 (単一 GradedResponseIrt observe) の
+-- ときのみ 'Just'。 構成は 'armaAnalyticVG' と同じ 3 段:
+--
+--   1. 各 (child i, item j, grade≠−1) の @Q_k = invlogit(δ_j(θ_i−γ_jk))@ と
+--      カテゴリ確率 p (隣接差) を **Double 空間**で評価し、 値
+--      @logL = Σ log p@ と解析勾配 @∂logL/∂θ_i = Σ_j (dp/dθ)/p@
+--      (@dQ/dθ = δ·Q(1−Q)@ の隣接差) を算出する。
+--   2. u への連鎖律は **軽量 surrogate** @Σ g_i·θ_i(u)@ を ad
+--      (θs の nChild scalar 抽出のみ)。
+--   3. prior + logJac は 'logDensityRD' 注入の fRestRD。
+--
+-- 従来 walk+ad は nChild×nItem×ncat の Q/p リスト構築 (`!!` 索引込) を毎
+-- leapfrog boxed AD で再評価していた (Phase 101 A1: logCatProb 64.8% time /
+-- 73.2% alloc)。 本経路は同じ O(Σ ncat) を Double で 1 パス回すだけ。
+gradedIrtAnalyticVG
+  :: forall r. ModelP r -> [Text] -> [Transform]
+  -> Maybe (VS.Vector Double -> (Double, VS.Vector Double))
+gradedIrtAnalyticVG m names trans =
+  case gradedIrtObserveOf m probeParams of
+    Just (ths, ncats, dls, gms, ys)
+      | not (null ths), not (null ys)
+      , length ncats == length dls, length ncats == length gms -> Just closure
+    _ -> Nothing
+  where
+    probeParams :: Map Text Double
+    probeParams = Map.fromList [ (nm, 0) | nm <- names ]
+
+    paramMapOf :: forall a. Floating a => [a] -> Map Text a
+    paramMapOf us = Map.fromList (zip names [ invTransformF t u | (t, u) <- zip trans us ])
+
+    closure :: VS.Vector Double -> (Double, VS.Vector Double)
+    closure uv =
+      let us = VS.toList uv
+      in case gradedIrtObserveOf m (paramMapOf us) of
+           Nothing -> (fFullA us, VS.fromList (gradFullA us))       -- 適格崩れ: fallback
+           Just (thsD, ncats, dls, gms, ys)
+             | null ys -> (fFullA us, VS.fromList (gradFullA us))
+             | otherwise ->
+                 let nItem = length ncats
+                     rows  = chunksOf nItem ys
+                     -- (logL, g) を child i 毎に Double で 1 パス集計
+                     childLG th row = foldl' step (0, 0) (zip4 ncats dls gms row)
+                       where
+                         step (accL, accG) (nc, dl, gm, grD)
+                           | grD == -1 = (accL, accG)
+                           | otherwise =
+                               let gr   = round grD :: Int
+                                   kMax = nc - 1
+                                   q kk = 1 / (1 + exp (negate (dl * (th - gm !! (kk - 1)))))
+                                   dq kk = let qv = q kk in dl * qv * (1 - qv)
+                                   (p, dp)
+                                     | gr == 1   = (1 - q 1, negate (dq 1))
+                                     | gr == nc  = (q kMax, dq kMax)
+                                     | otherwise = (q (gr - 1) - q gr, dq (gr - 1) - dq gr)
+                               in (accL + log p, accG + dp / p)
+                     lgs  = [ childLG th row | (th, row) <- zip thsD rows ]
+                     logL = sum (map fst lgs)
+                     gThs = map snd lgs
+                     -- 軽量 scatter: g_i を u へ連鎖 (ad は θs の scalar 抽出のみ)
+                     surrogate :: forall a. (Floating a, Ord a, TrackTag a) => [a] -> a
+                     surrogate uu =
+                       case gradedIrtObserveOf m (paramMapOf uu) of
+                         Just (ths', _, _, _, _) ->
+                           sum (zipWith (\g v -> realToFrac g * v) gThs ths')
+                         Nothing -> 0
+                     fCombRD :: forall s. Reifies s ADRD.Tape
+                             => [ADRD.ReverseDouble s] -> ADRD.ReverseDouble s
+                     fCombRD uu = fRestRD uu + surrogate uu
+                     (vComb, gComb) = grad' fCombRD us
+                     surrAtUs = sum (zipWith (*) gThs thsD)
+                 in ( (vComb - surrAtUs) + logL
+                    , VS.fromList gComb )
+
+    -- prior + logJac (尤度除く)・'logDensityRD' 注入 — 'armaAnalyticVG' と同じ。
+    fRestRD :: forall s. Reifies s ADRD.Tape
+            => [ADRD.ReverseDouble s] -> ADRD.ReverseDouble s
+    fRestRD us = logPriorWith logDensityRD m (paramMapOf us)
+                   + sum [ logJacF t u | (t, u) <- zip trans us ]
+
+    -- 適格崩れ時の完全 walk+ad fallback。
+    fFullA :: (Floating a, Ord a, TrackTag a) => [a] -> a
+    fFullA us = logJoint m (paramMapOf us)
+                  + sum [ logJacF t u | (t, u) <- zip trans us ]
+    gradFullA :: [Double] -> [Double]
+    gradFullA = grad fFullA
+
+-- | Phase 54.4b/54.6: 'gradADU' の **静的部分** (Gaussian LM ブロック抽出・
+-- 設計列のベクトル化・名前→index 解決・`ad` クロージャ構築) を 1 度だけ行い、
+-- unconstrained ベクトルを受けて勾配ベクトルを返すクロージャを構築する。
+-- NUTS / HMC は draw ループの**外**で 1 度呼び、 全 leapfrog で再利用する。
+--
+-- Phase 54.6: per-op 計測 (prof-nuts-54.4e.prof) で per-call の Text-key
+-- `Map.fromList` 組立 + `Map.fromListWith` 勾配集約 (compileGradU self 17.9%) と
+-- vec-tape の演算毎ベクトル割当 (~52%) が残ボトルネックと確定 → 名前は compile
+-- 時に index へ解決し、 勾配は ST mutable vector に解析閉形式で直接集約する
+-- (Gaussian LM の勾配は ∂β_k=X_kᵀr/σ²・∂u_j=Σ_{i∈g_j}r_i/σ²・∂σ=-n/σ+sumR2/σ³
+-- の閉形式ゆえ汎用 tape 不要)。
+compileGradUV :: forall r. ModelP r -> [Text] -> [Transform]
+              -> (VS.Vector Double -> VS.Vector Double)
+compileGradUV m names trans =
+  case gaussLMBlocksAuto m of
+    ([], _) -> case synthVecIR m of
+      -- Phase 95: 尤度が単一 dense MvNormal observe なら解析随伴。 Gp-RBF (B-dsl・
+      -- 閉形式随伴) を最優先、 次に汎用 MvNormal (A案・flat detach)、 いずれも
+      -- 非適格なら従来の全体 ad (後方互換)。
+      Nothing -> case gpRBFAnalyticVG m names trans of
+        Just vg -> \uv -> snd (vg uv)
+        Nothing -> case mvNormalAnalyticVG m names trans of
+          Just vg -> \uv -> snd (vg uv)
+          Nothing -> \uv -> VS.fromList (gradFull (VS.toList uv))
+      Just (gs, fams, sObs) ->                  -- 54.11: ベクトル式 IR (非線形 μ)
+        let ixOf   = Map.fromList (zip names [0 ..])
+            nP     = length names
+            transB = BV.fromList trans
+            cvi    = compileVecIR ixOf gs fams
+            famSet = Set.fromList (concat [ ms | (ms, _, _) <- fams ])
+            cps    = constPriorsOf m famSet
+            lnGroups = collectLogNormalGroups m        -- Phase 98 A3: LogNormal 群
+            lnUNames = concat [ us | (us, _, _) <- lnGroups ]
+            lnIx   = map (resolveLogNormal ixOf) lnGroups
+            exclNames = sObs `Set.union` famSet
+                        `Set.union` Set.fromList (map fst cps)
+                        `Set.union` Set.fromList lnUNames
+            noResid = residualFreeOfDensity exclNames m
+            cpIx   = [ (ixOf Map.! n, d) | (n, d) <- cps ]
+            mPriorGrad
+              | noResid   = Nothing
+              | otherwise = Just (grad (fExcl (compileResidual exclNames m) exclNames))
+        in \uv ->
+             let pc = VS.generate nP $ \i ->
+                        invTransformF (transB BV.! i) (uv `VS.unsafeIndex` i)
+                 mgc = runST $ do
+                   mg <- VSM.replicate nP 0
+                   ok <- gradVecIR cvi pc mg
+                   if ok
+                     then do
+                       mapM_ (\(i, d) ->
+                                case constPriorGradD d (pc `VS.unsafeIndex` i) of
+                                  Just g  -> VSM.modify mg (+ g) i
+                                  Nothing -> pure ()) cpIx
+                       mapM_ (\ln -> gradLogNormalIx ln pc mg) lnIx  -- A3
+                       Just <$> VS.unsafeFreeze mg
+                     else pure Nothing
+             in case mgc of
+                  -- guard 違反 (観測項が定数 -∞ の境界領域・例 invLogit の FP
+                  -- 飽和 p==1): walk+ad に per-call fallback し従来経路と同一の
+                  -- 勾配 (違反行 = 定数 → 勾配 0・他の行は有効) を返す。
+                  -- ★旧 tape (54.11-55.4) は unguarded で NaN が全勾配を汚染し
+                  -- NUTS が max-depth 迷走する潜在バグだった (56.2 で修正・
+                  -- 'constPriorGradD' の「guard 違反 = 勾配 0 で ad と一致」 と
+                  -- 同じ原則)。 境界点のみの稀ケースゆえ per-draw 影響なし。
+                  Nothing -> VS.fromList (gradFull (VS.toList uv))
+                  Just gC -> case mPriorGrad of
+                    Nothing ->
+                      VS.generate nP $ \i ->
+                        let t = transB BV.! i
+                            u = uv `VS.unsafeIndex` i
+                        in gC `VS.unsafeIndex` i * dInvTransform t u
+                           + dLogJacU t u
+                    Just priorGrad ->
+                      let pg = priorGrad (VS.toList uv)
+                      in VS.fromList
+                           [ pg_i + gC `VS.unsafeIndex` i * dInvTransform t u
+                           | (i, (pg_i, (t, u))) <- zip [0 :: Int ..]
+                               (zip pg (zip trans (VS.toList uv))) ]
+    (gbs, synthObs) ->                                    -- ハイブリッド (静的 hoist)
+      let -- 54.4c: REff (Just scale) の u-prior は解析勾配・u_j を ad から除外。
+          -- 54.4e: 定数パラメタ prior も解析勾配・ad から除外。 密度項が残らな
+          -- ければ ad クロージャを丸ごと省略 (logJac 勾配も解析式)。
+          (priorREs, cps, exclNames, cblocks, hierGroups, noResid) = analyzeGaussModel m gbs synthObs
+          ixOf   = Map.fromList (zip names [0 ..])
+          nP     = length names
+          transB = BV.fromList trans                      -- boxed (Storable 不可)
+          cbIx   = map (resolveLMBlock ixOf) cblocks
+          reIx   = [ ReffPriorIx (VU.fromList (map (ixOf Map.!) uNames))
+                                 (ixOf Map.! scaleName)
+                   | (uNames, scaleName) <- priorREs ]
+          hniIx  = map (resolveHierNormal ixOf) hierGroups
+          cpIx   = [ (ixOf Map.! n, d) | (n, d) <- cps ]
+          mPriorGrad                                      -- 残 prior 等の ad (fallback)
+            | noResid   = Nothing
+            | otherwise = Just (grad (fExcl (compileResidual exclNames m) exclNames))
+      in hybridGradClosure nP transB trans
+           (\pc mg -> do
+              mapM_ (\cb -> gradLMBlockIx cb pc mg) cbIx
+              mapM_ (\ri -> gradReffPriorIx ri pc mg) reIx
+              mapM_ (\hn -> gradHierNormalIx hn pc mg) hniIx
+              mapM_ (\(i, d) ->
+                       case constPriorGradD d (pc `VS.unsafeIndex` i) of
+                         Just g  -> VSM.modify mg (+ g) i
+                         Nothing -> pure ()) cpIx)
+           mPriorGrad
+  where
+    gradFull = grad fFull
+    fFull us =
+      let paramsC = Map.fromList
+            [ (n, invTransformF t u) | (n, t, u) <- zip3 names trans us ]
+          logJac  = sum [ logJacF t u | (t, u) <- zip trans us ]
+      in logJoint m paramsC + logJac
+    fExcl mcr excl us =
+      let paramsC = Map.fromList
+            [ (n, invTransformF t u) | (n, t, u) <- zip3 names trans us ]
+          logJac  = sum [ logJacF t u | (t, u) <- zip trans us ]
+      in residualExcl mcr excl m paramsC + logJac
+
+-- | Phase 87.2b: 'compileGradUV' の value-and-grad 融合版 (JAX @value_and_grad@
+-- 相当)。 返り値 = (logπ(u) (logJac 込・'compileLogPUV' と同値)・∇logπ(u)
+-- ('compileGradUV' と同値))。 NUTS の葉は leapfrog 最終勾配とエネルギー (logπ)
+-- を同一点で別々に評価していた (prof 実測で葉 logPi が全体の 19%) — 本閉包は
+-- forward pass を 1 度だけ走らせて両方を返す。 経路分岐・fallback 意味論は
+-- 'compileGradUV' / 'compileLogPUV' と対 (vecIR guard 違反 = 値 -∞ +
+-- 勾配 walk+ad fallback)。
+compileGradValUV :: forall r. ModelP r -> [Text] -> [Transform]
+                 -> (VS.Vector Double -> (Double, VS.Vector Double))
+compileGradValUV m names trans =
+  case gradValPlan m names trans of
+    GVPure f -> f
+    GVVecIR sz prep core finish -> \uv ->
+      let pc  = prep uv
+          mvg = runST $ do
+            ar  <- VSM.unsafeNew sz
+            adj <- VSM.unsafeNew sz
+            core ar adj pc
+      in finish uv pc mvg
+
+-- | Phase 90 A11-4①: 'compileGradValUV' の monadic 版。 vecIR 経路の作業
+-- バッファ (forward arena + 随伴 arena・13-traffic 実測で 34k セル × 2 =
+-- 葉勾配 0.139ms 中の確保 0.031ms + GC churn) を **閉包生成時に 1 度だけ**
+-- 確保し、 全 leapfrog 呼出で再利用する。 閉包は chain ごとに生成される
+-- ('nutsStream' 内) ため、 chain 横断 spark 並列 ('nutsChainsPure') とも
+-- 干渉しない。 返る値/勾配は毎回 fresh に freeze されるので alias しない。
+-- 非 vecIR 経路 (walk+ad / ハイブリッド) は従来 pure 閉包をそのまま包む。
+compileGradValUVM :: forall m r. PrimMonad m
+                  => ModelP r -> [Text] -> [Transform]
+                  -> m (VS.Vector Double -> m (Double, VS.Vector Double))
+compileGradValUVM m names trans =
+  case gradValPlan m names trans of
+    GVPure f -> pure (\uv -> pure (f uv))
+    GVVecIR sz prep core finish -> do
+      ar  <- VSM.unsafeNew sz
+      adj <- VSM.unsafeNew sz
+      pure $ \uv -> do
+        let pc = prep uv
+        mvg <- stToPrim (core ar adj pc)
+        pure (finish uv pc mvg)
+
+-- | Phase 90 A11-4①: 'compileGradValUV' / 'compileGradValUVM' が共有する
+-- 静的解析結果。 vecIR 経路のみ per-call の arena/adj 確保をバッファ注入
+-- (prep / core / finish の 3 分割) に分離し、 pure 版 (毎回確保・従来意味論)
+-- と monadic 版 (chain 閉包で 1 回確保) が同一 per-call コードを共有する。
+data GradValPlan
+  = GVPure (VS.Vector Double -> (Double, VS.Vector Double))
+    -- ^ walk+ad fallback / ハイブリッド経路 (arena 非使用・従来 pure 閉包)。
+  | GVVecIR
+      !Int                                    -- ^ arena/adj サイズ ('vpSize')
+      (VS.Vector Double -> VS.Vector Double)  -- ^ prep: uv → pc (invTransform)
+      (forall s. VSM.MVector s Double -> VSM.MVector s Double
+                 -> VS.Vector Double
+                 -> ST s (Maybe (Double, VS.Vector Double)))
+        -- ^ core: ar adj pc → (値, constrained 勾配)。 guard 違反 = Nothing。
+      (VS.Vector Double -> VS.Vector Double
+                 -> Maybe (Double, VS.Vector Double)
+                 -> (Double, VS.Vector Double))
+        -- ^ finish: uv pc mvg → 最終 (logπ, ∇logπ) (chain rule + fallback)。
+
+gradValPlan :: forall r. ModelP r -> [Text] -> [Transform] -> GradValPlan
+gradValPlan m names trans =
+  case gaussLMBlocksAuto m of
+    ([], _) -> case synthVecIR m of
+      -- Phase 95: 尤度が単一 dense MvNormal observe なら解析随伴 (pure 閉包 = GVPure)。
+      -- Phase 92: 単一 HmmForwardNormal observe も同様 (forward-backward 閉形式)。
+      -- Phase 101: 単一 ArmaNormal observe も同様 (逆向き随伴再帰の閉形式)。
+      -- HMM → ARMA → Gp-RBF (B-dsl・閉形式) → 汎用 MvNormal (A案) → 従来の全体 walk+ad。
+      Nothing -> case hmmAnalyticVG m names trans of
+        Just vg -> GVPure vg
+        Nothing -> case armaAnalyticVG m names trans of
+          Just vg -> GVPure vg
+          Nothing -> case gradedIrtAnalyticVG m names trans of
+           Just vg -> GVPure vg
+           Nothing -> case gpRBFAnalyticVG m names trans of
+            Just vg -> GVPure vg
+            Nothing -> case mvNormalAnalyticVG m names trans of
+              Just vg -> GVPure vg
+              Nothing -> GVPure $ \uv ->        -- 後方互換: 全体を walk + ad (融合なし)
+                let us = VS.toList uv
+                in (fFull us, VS.fromList (gradFull us))
+      Just (gs, fams, sObs) ->                -- ベクトル式 IR (compileGradUV と同静的)
+        let ixOf   = Map.fromList (zip names [0 ..])
+            nP     = length names
+            transB = BV.fromList trans
+            cvi    = compileVecIR ixOf gs fams
+            famSet = Set.fromList (concat [ ms | (ms, _, _) <- fams ])
+            cps    = constPriorsOf m famSet
+            lnGroups = collectLogNormalGroups m        -- Phase 98 A3: LogNormal 群
+            lnUNames = concat [ us | (us, _, _) <- lnGroups ]
+            lnIx   = map (resolveLogNormal ixOf) lnGroups
+            exclNames = sObs `Set.union` famSet
+                        `Set.union` Set.fromList (map fst cps)
+                        `Set.union` Set.fromList lnUNames
+            noResid = residualFreeOfDensity exclNames m
+            cpIx   = [ (ixOf Map.! n, d) | (n, d) <- cps ]
+            mPrior                             -- (勾配, 値) の対 (fExcl は logJac 込)
+              | noResid   = Nothing
+              | otherwise = let mcr = compileResidual exclNames m
+                            in Just (grad (fExcl mcr exclNames), fExcl mcr exclNames)
+            prep uv = VS.generate nP $ \i ->
+                        invTransformF (transB BV.! i) (uv `VS.unsafeIndex` i)
+            core :: forall s. VSM.MVector s Double -> VSM.MVector s Double
+                 -> VS.Vector Double -> ST s (Maybe (Double, VS.Vector Double))
+            core ar adj pc = do
+              mg <- VSM.replicate nP 0
+              mv <- gradVecIRValWith cvi ar adj pc mg
+              case mv of
+                Nothing -> pure Nothing
+                Just v  -> do
+                  mapM_ (\(i, d) ->
+                           case constPriorGradD d (pc `VS.unsafeIndex` i) of
+                             Just g  -> VSM.modify mg (+ g) i
+                             Nothing -> pure ()) cpIx
+                  mapM_ (\ln -> gradLogNormalIx ln pc mg) lnIx  -- A3
+                  gv <- VS.unsafeFreeze mg
+                  pure (Just (v, gv))
+            finish uv pc mvg = case mvg of
+              -- guard 違反: 値 = -∞ ('vecIRValue' と同一)・勾配 = walk+ad
+              -- fallback ('compileGradUV' と同一)。
+              Nothing -> ((-1) / 0, VS.fromList (gradFull (VS.toList uv)))
+              Just (vIR, gC) ->
+                let cpVal = sum [ logDensity d (pc `VS.unsafeIndex` i)
+                                | (i, d) <- cpIx ]
+                          + sum [ valueLogNormalIx ln pc | ln <- lnIx ]  -- A3
+                in case mPrior of
+                  Nothing ->
+                    let logJac = sum [ logJacF (transB BV.! i)
+                                               (uv `VS.unsafeIndex` i)
+                                     | i <- [0 .. nP - 1] ]
+                        g = VS.generate nP $ \i ->
+                              let t = transB BV.! i
+                                  u = uv `VS.unsafeIndex` i
+                              in gC `VS.unsafeIndex` i * dInvTransform t u
+                                 + dLogJacU t u
+                    in (vIR + cpVal + logJac, g)
+                  Just (priorGrad, priorVal) ->
+                    let us = VS.toList uv
+                        pg = priorGrad us
+                        g = VS.fromList
+                              [ pg_i + gC `VS.unsafeIndex` i * dInvTransform t u
+                              | (i, (pg_i, (t, u))) <- zip [0 :: Int ..]
+                                  (zip pg (zip trans us)) ]
+                    in (vIR + cpVal + priorVal us, g)
+        in GVVecIR (vpSize (cvProg cvi)) prep core finish
+    (gbs, synthObs) -> GVPure $               -- ハイブリッド (compileGradUV と同静的)
+      let (priorREs, cps, exclNames, cblocks, hierGroups, noResid) = analyzeGaussModel m gbs synthObs
+          ixOf   = Map.fromList (zip names [0 ..])
+          nP     = length names
+          transB = BV.fromList trans
+          cbIx   = map (resolveLMBlock ixOf) cblocks
+          reIx   = [ ReffPriorIx (VU.fromList (map (ixOf Map.!) uNames))
+                                 (ixOf Map.! scaleName)
+                   | (uNames, scaleName) <- priorREs ]
+          hniIx  = map (resolveHierNormal ixOf) hierGroups
+          cpIx   = [ (ixOf Map.! n, d) | (n, d) <- cps ]
+          mPrior
+            | noResid   = Nothing
+            | otherwise = let mcr = compileResidual exclNames m
+                          in Just (grad (fExcl mcr exclNames), fExcl mcr exclNames)
+      in hybridGradValClosure nP transB trans
+           (\pc mg -> do
+              mapM_ (\cb -> gradLMBlockIx cb pc mg) cbIx
+              mapM_ (\ri -> gradReffPriorIx ri pc mg) reIx
+              mapM_ (\hn -> gradHierNormalIx hn pc mg) hniIx
+              mapM_ (\(i, d) ->
+                       case constPriorGradD d (pc `VS.unsafeIndex` i) of
+                         Just g  -> VSM.modify mg (+ g) i
+                         Nothing -> pure ()) cpIx)
+           (\pc -> sum [ valueLMBlockIx cb pc | cb <- cbIx ]
+                   + sum [ valueReffPriorIx ri pc | ri <- reIx ]
+                   + sum [ valueHierNormalIx hn pc | hn <- hniIx ]
+                   + sum [ logDensity d (pc `VS.unsafeIndex` i)
+                         | (i, d) <- cpIx ])
+           mPrior
+  where
+    gradFull = grad fFull
+    fFull us =
+      let paramsC = Map.fromList
+            [ (n, invTransformF t u) | (n, t, u) <- zip3 names trans us ]
+          logJac  = sum [ logJacF t u | (t, u) <- zip trans us ]
+      in logJoint m paramsC + logJac
+    fExcl mcr excl us =
+      let paramsC = Map.fromList
+            [ (n, invTransformF t u) | (n, t, u) <- zip3 names trans us ]
+          logJac  = sum [ logJacF t u | (t, u) <- zip trans us ]
+      in residualExcl mcr excl m paramsC + logJac
+
+-- | Phase 87.2b: 'hybridGradClosure' の value-and-grad 融合版。 解析勾配
+-- (@gradC@) に加えて解析**値** (@valC@ = 'compileLogPUV' ハイブリッド経路の
+-- analytic と同一) を計算し、 (値 (logJac 込)・勾配) を返す。
+hybridGradValClosure
+  :: Int -> BV.Vector Transform -> [Transform]
+  -> (forall s. VS.Vector Double -> VSM.MVector s Double -> ST s ())
+  -> (VS.Vector Double -> Double)
+  -> Maybe ([Double] -> [Double], [Double] -> Double)
+  -> (VS.Vector Double -> (Double, VS.Vector Double))
+hybridGradValClosure nP transB trans gradC valC mPrior = \uv ->
+  let pc = VS.generate nP $ \i ->
+             invTransformF (transB BV.! i) (uv `VS.unsafeIndex` i)
+      gC = runST $ do
+        mg <- VSM.replicate nP 0
+        gradC pc mg
+        VS.unsafeFreeze mg
+      aVal = valC pc
+  in case mPrior of
+       Nothing ->
+         let logJac = sum [ logJacF (transB BV.! i) (uv `VS.unsafeIndex` i)
+                          | i <- [0 .. nP - 1] ]
+             g = VS.generate nP $ \i ->
+                   let t = transB BV.! i
+                       u = uv `VS.unsafeIndex` i
+                   in gC `VS.unsafeIndex` i * dInvTransform t u + dLogJacU t u
+         in (aVal + logJac, g)
+       Just (priorGrad, priorVal) ->            -- fExcl は logJac 込
+         let us = VS.toList uv
+             pg = priorGrad us
+             g = VS.fromList
+                   [ p + gC `VS.unsafeIndex` i * dInvTransform t u
+                   | (i, (p, (t, u))) <- zip [0 :: Int ..]
+                       (zip pg (zip trans us)) ]
+         in (aVal + priorVal us, g)
+
+-- | 'compileGradUV' の per-call 本体 (Phase 54.11 で affine 経路と IR 経路の
+-- 共有部を関数化): unconstrained ベクトル → constrained 値 → 解析/ベクトル
+-- 経路の constrained 勾配 (@gradC@ が mutable ベクトルへ加算) → chain rule。
+-- @mPriorGrad@ = 残差 ad クロージャ ('Nothing' = 密度項が残らず logJac も解析)。
+hybridGradClosure
+  :: Int -> BV.Vector Transform -> [Transform]
+  -> (forall s. VS.Vector Double -> VSM.MVector s Double -> ST s ())
+  -> Maybe ([Double] -> [Double])
+  -> (VS.Vector Double -> VS.Vector Double)
+hybridGradClosure nP transB trans gradC mPriorGrad = \uv ->
+  let pc = VS.generate nP $ \i ->
+             invTransformF (transB BV.! i) (uv `VS.unsafeIndex` i)
+      gC = runST $ do                            -- constrained 空間の解析勾配
+        mg <- VSM.replicate nP 0
+        gradC pc mg
+        VS.unsafeFreeze mg
+  in case mPriorGrad of
+       Nothing ->                                -- ad 完全省略 (logJac 解析)
+         VS.generate nP $ \i ->
+           let t = transB BV.! i
+               u = uv `VS.unsafeIndex` i
+           in gC `VS.unsafeIndex` i * dInvTransform t u + dLogJacU t u
+       Just priorGrad ->                         -- 残りは ad (chain は ad 内)
+         let pg = priorGrad (VS.toList uv)
+         in VS.fromList
+              [ p + gC `VS.unsafeIndex` i * dInvTransform t u
+              | (i, (p, (t, u))) <- zip [0 :: Int ..]
+                  (zip pg (zip trans (VS.toList uv))) ]
+
+-- | Phase 54.4e: **定数パラメタ prior** の解析勾配 @d logDensity(d, θ)/dθ@
+-- (constrained 空間)。 @Nothing@ = 未対応分布 (従来 `ad` に fallback)。
+--
+-- prior のパラメタが他 latent に依存しない (extractDeps で deps ∅) latent に
+-- のみ使う前提 (パラメタを定数として θ でだけ微分する)。 各分岐は 'logDensity'
+-- の実装・ガードと対にしてある: ガード違反域では 'logDensity' が定数 negInf を
+-- 返し `ad` の勾配は 0 になるので、 ここでも 0 を返して一致させる。
+constPriorGradD :: Distribution Double -> Double -> Maybe Double
+constPriorGradD d x = case d of
+  Normal mu sig
+    | sig <= 0           -> Just 0
+    | otherwise          -> Just (negate (x - mu) / (sig * sig))
+  Exponential rate
+    | x < 0 || rate <= 0 -> Just 0
+    | otherwise          -> Just (negate rate)
+  Gamma shape rate
+    | x <= 0 || shape <= 0 || rate <= 0 -> Just 0
+    | otherwise          -> Just ((shape - 1) / x - rate)
+  Beta alpha beta
+    | x <= 0 || x >= 1 || alpha <= 0 || beta <= 0 -> Just 0
+    | otherwise          -> Just ((alpha - 1) / x - (beta - 1) / (1 - x))
+  Uniform lo hi
+    | hi <= lo || x < lo || x > hi -> Just 0
+    | otherwise          -> Just 0
+  StudentT df mu sig
+    | df <= 0 || sig <= 0 -> Just 0
+    | otherwise          ->
+        let z = x - mu
+        in Just (negate ((df + 1) * z) / (df * sig * sig + z * z))
+  Cauchy loc sc
+    | sc <= 0            -> Just 0
+    | otherwise          ->
+        let z = x - loc
+        in Just (negate (2 * z) / (sc * sc + z * z))
+  HalfNormal sig
+    | sig <= 0 || x < 0  -> Just 0
+    | otherwise          -> Just (negate x / (sig * sig))
+  HalfCauchy sc
+    | sc <= 0 || x < 0   -> Just 0
+    | otherwise          -> Just (negate (2 * x) / (sc * sc + x * x))
+  LogNormal mu sig
+    | sig <= 0 || x <= 0 -> Just 0
+    | otherwise          ->
+        Just (negate (1 + (log x - mu) / (sig * sig)) / x)
+  InverseGamma alpha beta
+    | alpha <= 0 || beta <= 0 || x <= 0 -> Just 0
+    | otherwise          -> Just (negate (alpha + 1) / x + beta / (x * x))
+  Weibull kShape lam
+    | kShape <= 0 || lam <= 0 || x <= 0 -> Just 0
+    | otherwise          ->
+        Just ((kShape - 1) / x - (kShape / lam) * (x / lam) ** (kShape - 1))
+  Pareto alpha xm
+    | alpha <= 0 || xm <= 0 || x < xm -> Just 0
+    | otherwise          -> Just (negate (alpha + 1) / x)
+  _ -> Nothing
+
+-- | 'logJacF' の u 微分 (Phase 54.4e: ad 省略時に解析で加算)。 'logJacF' と対。
+dLogJacU :: Transform -> Double -> Double
+dLogJacU UnconstrainedT _ = 0
+dLogJacU PositiveT      _ = 1
+dLogJacU UnitIntervalT  u = let s = 1 / (1 + exp (-u)) in 1 - 2 * s
+
+-- | Phase 54.4e: @excl@ 除外後の walk に log-density 寄与が残らないか。
+-- 残らなければ 'compileGradU' は `ad` クロージャを丸ごと省略でき
+-- (reflection tape 生成 = profile の 18.9% がゼロに)、 'compileLogPU' は
+-- Free walk 自体を省略できる。 scalar 'Observe' は名前が @excl@ になければ
+-- False (Phase 54.8: 自動合成で吸収済みの Observe は除外扱い)。
+-- 'Potential' があれば常に False (従来 ad / walk 経路に fallback・正しさ担保)。
+residualFreeOfDensity :: Set Text -> Model Double r -> Bool
+residualFreeOfDensity excl = go
+  where
+    go (Pure _) = True
+    go (Free (Sample n _ k)) = n `Set.member` excl && go (k 0)
+    go (Free (Observe n _ _ next)) = n `Set.member` excl && go next
+    go (Free (ObserveLM nm _ _ _ _ _ next)) = nm `Set.member` excl && go next
+    -- Phase 90 A10: vecIR ('VGPot') に吸収済みの potential は残差に数えない。
+    go (Free (Potential n _ next)) = n `Set.member` excl && go next
+    go (Free (Deterministic _ v k)) = go (k v)
+    go (Free (Data _ ys k)) = go (k (ys, ys))
+    go (Free (DataIx _ is k)) = go (k is)
+    go (Free (PlateBegin _ _ next)) = go next
+    go (Free (PlateEnd next)) = go next
+
+-- | Phase 54.4e: 'compileGradU' / 'compileLogPU' 共通の静的解析。
+-- Gaussian LM ブロック群から (ブロック名, 解析 u-prior, 定数パラメタ prior,
+-- 除外集合, 前処理済みブロック, residual 空フラグ) を 1 度だけ求める。
+-- @synthObs@ (Phase 54.8) = 自動合成ブロックに吸収済みの scalar 'Observe' 名
+-- (除外集合に合流させ、 residual walk で二重加算しない)。
+analyzeGaussModel
+  :: ModelP r
+  -> [(Text, [Text], [[Double]], [REff], Text, [Double])]
+  -> Set Text                              -- synthObs (吸収済 scalar Observe 名)
+  -> ( [([Text], Text)]                    -- priorREs (uNames, scaleName)
+     , [(Text, Distribution Double)]       -- constPriors
+     , Set Text                            -- exclNames
+     , [CompiledLMBlock]
+     , [([Text], Text, Text)]              -- Phase 93: 階層 Normal 群 (uNames, μ名, τ名)
+     , Bool )                              -- residual に密度項が残らないか
+analyzeGaussModel m gbs synthObs =
+  let blockNames = [ bn | (bn, _, _, _, _, _) <- gbs ]
+      priorREs   = [ (uNames, scaleName)
+                   | (_, _, _, res, _, _) <- gbs
+                   , REff uNames _ (Just scaleName) _ _ <- res ]
+      exclUNames = concat [ uNames | (uNames, _) <- priorREs ]
+      -- Phase 93: 非ゼロ latent 平均の階層 Normal prior 群 (mean-0 reff とは disjoint)。
+      -- u_i の prior を解析勾配 ('gradHierNormalIx') で扱い残差 ad から外す。
+      -- μ・τ は自身の prior を持つので cps 側に残す (u のみ除外)。
+      hierGroups = collectHierNormalGroups m
+      hierUNames = concat [ us | (us, _, _) <- hierGroups ]
+      -- 定数パラメタ prior。 u_j (REff / 階層群 経由で解析済) は除く。
+      cps = constPriorsOf m (Set.fromList (exclUNames ++ hierUNames))
+      exclNames = Set.fromList (blockNames ++ exclUNames ++ hierUNames ++ map fst cps)
+                  `Set.union` synthObs
+      cblocks   = [ compileLMBlock (bs, xs, re, sn, ys)
+                  | (_, bs, xs, re, sn, ys) <- gbs ]
+      noResid   = residualFreeOfDensity exclNames m
+  in (priorREs, cps, exclNames, cblocks, hierGroups, noResid)
+
+-- | 定数パラメタ prior の抽出 (Phase 54.4e): extractDeps で親 latent 無し
+-- (deps ∅) かつ解析勾配対応分布の latent。 @exclSet@ = 別経路 (REff 族 /
+-- 54.11 IR 族) で扱う latent は除く。 54.4e/54.11 で共有。
+constPriorsOf :: ModelP r -> Set Text -> [(Text, Distribution Double)]
+constPriorsOf m exclSet =
+  let (depNodes, _) = extractDeps m
+      latentDeps = Map.fromList [ (nodeName nd, nodeDeps nd)
+                                | nd <- depNodes, nodeKind nd == LatentN ]
+  in [ (n, dist)
+     | (n, dist) <- priorList m
+     , not (Set.member n exclSet)
+     , Just deps <- [Map.lookup n latentDeps], Set.null deps
+     , Just _ <- [constPriorGradD dist 0.5] ]
+
+-- | Phase 54.4d: logp **値** 評価のコンパイル ('compileGradU' の値版)。
+--
+-- NUTS は tree node ごとにエネルギー (logp の値) を評価する。 54.4c 時点の
+-- cost-centre profile で、 勾配は vec 化済みなのに値評価が Free walk +
+-- per-obs スカラ 'logDensityObs' のままで per-draw の 46% を占めると判明
+-- (`prof-nuts-54.4c.prof`)。 本関数は 'compileGradU' と同じ静的前処理
+-- ('CompiledLMBlock') を 1 度だけ行い、 unconstrained ベクトルを受けて
+-- log-joint + log-jacobian を返すクロージャを構築する:
+--
+--   * Gaussian-恒等リンク 'ObserveLM' ブロックの観測尤度値 → 素な Double
+--     ベクトル演算 ('valueCompiledLMBlock'・tape 不要)
+--   * @REff (Just scale)@ の u-prior 値 → 解析式 ('reffPriorValue')
+--   * 残り (他 prior / scalar observe / 非 Gauss LM / jacobian)
+--     → 'logJointExclBlocks' の Double walk
+--
+-- Gaussian LM を含まないモデルは従来 'logJointUnconstrained' 相当に fallback
+-- (後方互換)。 数値は 'logJointUnconstrained' と一致 (test で担保)。
+compileLogPU :: forall r. ModelP r -> [Text] -> [Transform] -> ([Double] -> Double)
+compileLogPU m names trans =
+  let lv = compileLogPUV m names trans
+  in lv . VS.fromList
+
+-- | 'compileLogPU' の vector-native 版 (Phase 54.6)。 NUTS のエネルギー評価が
+-- 直接使う。 名前は compile 時に index へ解決し、 per-call は Storable vector
+-- 上の素な Double 演算のみ (Text-key Map 組立なし)。
+compileLogPUV :: forall r. ModelP r -> [Text] -> [Transform]
+              -> (VS.Vector Double -> Double)
+compileLogPUV m names trans =
+  case gaussLMBlocksAuto m of
+    ([], _) -> case synthVecIR m of
+      Nothing -> fFull . VS.toList                     -- 後方互換: 従来の walk 評価
+      Just (gs, fams, sObs) ->                 -- 54.11: ベクトル式 IR (非線形 μ)
+        let ixOf   = Map.fromList (zip names [0 ..])
+            nP     = length names
+            transB = BV.fromList trans
+            cvi    = compileVecIR ixOf gs fams
+            famSet = Set.fromList (concat [ ms | (ms, _, _) <- fams ])
+            cps    = constPriorsOf m famSet
+            lnGroups = collectLogNormalGroups m        -- Phase 98 A3: LogNormal 群
+            lnUNames = concat [ us | (us, _, _) <- lnGroups ]
+            lnIx   = map (resolveLogNormal ixOf) lnGroups
+            exclNames = sObs `Set.union` famSet
+                        `Set.union` Set.fromList (map fst cps)
+                        `Set.union` Set.fromList lnUNames
+            noResid = residualFreeOfDensity exclNames m
+            cpIx   = [ (ixOf Map.! n, d) | (n, d) <- cps ]
+            mResid
+              | noResid   = Nothing
+              | otherwise = Just (residualExcl (compileResidual exclNames m) exclNames m)
+        in hybridLogPClosure nP transB names
+             (\pc -> vecIRValue cvi pc
+                     + sum [ logDensity d (pc `VS.unsafeIndex` i)
+                           | (i, d) <- cpIx ]
+                     + sum [ valueLogNormalIx ln pc | ln <- lnIx ])  -- A3
+             mResid
+    (gbs, synthObs) ->
+      let -- 54.4c/54.4e と同じ静的解析: u-prior は解析値・定数パラメタ prior は
+          -- 直接 logDensity・残りだけ walk。 密度項が残らなければ Free walk 自体を
+          -- 省略する (モデル再構築 = reNormal の Text 名生成等も消える)。
+          (priorREs, cps, exclNames, cblocks, hierGroups, noResid) = analyzeGaussModel m gbs synthObs
+          ixOf   = Map.fromList (zip names [0 ..])
+          nP     = length names
+          transB = BV.fromList trans
+          cbIx   = map (resolveLMBlock ixOf) cblocks
+          reIx   = [ ReffPriorIx (VU.fromList (map (ixOf Map.!) uNames))
+                                 (ixOf Map.! scaleName)
+                   | (uNames, scaleName) <- priorREs ]
+          hniIx  = map (resolveHierNormal ixOf) hierGroups
+          cpIx   = [ (ixOf Map.! n, d) | (n, d) <- cps ]
+          mResid                                       -- 残 walk (fallback のみ)
+            | noResid   = Nothing
+            | otherwise = Just (residualExcl (compileResidual exclNames m) exclNames m)
+      in hybridLogPClosure nP transB names
+           (\pc -> sum [ valueLMBlockIx cb pc | cb <- cbIx ]
+                   + sum [ valueReffPriorIx ri pc | ri <- reIx ]
+                   + sum [ valueHierNormalIx hn pc | hn <- hniIx ]
+                   + sum [ logDensity d (pc `VS.unsafeIndex` i)
+                         | (i, d) <- cpIx ])
+           mResid
+  where
+    fFull us =
+      let paramsC = Map.fromList
+            [ (n, invTransformF t u) | (n, t, u) <- zip3 names trans us ]
+          logJac  = sum [ logJacF t u | (t, u) <- zip trans us ]
+      in logJoint m paramsC + logJac
+
+-- | 'compileLogPUV' の per-call 本体 (Phase 54.11 で affine 経路と IR 経路の
+-- 共有部を関数化): unconstrained ベクトル → constrained 値 → 解析/ベクトル
+-- 経路の log-density 値 (@analytic@) + 残差 walk (@mResid@) + log-jacobian。
+hybridLogPClosure
+  :: Int -> BV.Vector Transform -> [Text]
+  -> (VS.Vector Double -> Double)
+  -> Maybe (Map Text Double -> Double)
+  -> (VS.Vector Double -> Double)
+hybridLogPClosure nP transB names analytic mResid = \uv ->
+  let pc = VS.generate nP $ \i ->
+             invTransformF (transB BV.! i) (uv `VS.unsafeIndex` i)
+      logJac = sum [ logJacF (transB BV.! i) (uv `VS.unsafeIndex` i)
+                   | i <- [0 .. nP - 1] ]
+      residV = case mResid of
+        Nothing -> 0
+        Just rv -> rv (Map.fromList (zip names (VS.toList pc)))
+  in residV + analytic pc + logJac
+
+-- | invTransform の導関数 dθ/du (chain rule 用)。 'invTransformF' と対。
+dInvTransform :: Transform -> Double -> Double
+dInvTransform UnconstrainedT _ = 1
+dInvTransform PositiveT      u = exp u
+dInvTransform UnitIntervalT  u = let s = 1 / (1 + exp (-u)) in s * (1 - s)
+
+-- | モデル中の Gaussian-恒等リンク 'ObserveLM' ブロックを収集する
+-- (ブロック名 / β 名 / 設計行列 / ランダム効果 / σ 名 / 観測 ys)。 非 Gaussian は除外。
+gaussLMBlocks :: ModelP r -> [(Text, [Text], [[Double]], [REff], Text, [Double])]
+gaussLMBlocks m = go m []
+  where
+    go (Pure _) acc = reverse acc
+    go (Free f) acc = case f of
+      Sample _ _ k        -> go (k 0) acc
+      Observe _ _ _ next  -> go next acc
+      ObserveLM nm bs xs re fam ys next ->
+        case fam of
+          LMGaussian sn -> go next ((nm, bs, xs, re, sn, ys) : acc)
+          _             -> go next acc
+      Potential _ _ next  -> go next acc
+      Deterministic _ v k -> go (k v) acc
+      Data _ ys k         -> go (k (ys, ys)) acc
+      DataIx _ is k       -> go (k is) acc
+      PlateBegin _ _ next -> go next acc
+      PlateEnd next       -> go next acc
+
+-- | 'gaussLMBlocks' + Phase 54.8 自動合成。 明示 'ObserveLM' ブロックに、
+-- per-obs scalar 'Observe' から自動合成したブロックを連結して返す
+-- (合成に吸収した scalar Observe 名集合も返す → 'analyzeGaussModel' で除外)。
+gaussLMBlocksAuto
+  :: ModelP r
+  -> ([(Text, [Text], [[Double]], [REff], Text, [Double])], Set Text)
+gaussLMBlocksAuto m =
+  let (sblocks, sObs) = synthGaussLMBlocks m
+  in (gaussLMBlocks m ++ sblocks, sObs)
+
+
+-- | 'logJoint' と同じだが、 名前が @excl@ に含まれる項を **加算しない**:
+--
+--   * @excl@ に含まれる 'ObserveLM' ブロックの観測尤度 (vec-tape 経路で別計算)
+--   * @excl@ に含まれる scalar 'Observe' の観測尤度
+--     (Phase 54.8: 'synthGaussLMBlocks' が合成ブロックへ吸収済みのもの)
+--   * @excl@ に含まれる 'Sample' ノードの prior log-density
+--     (Phase 54.4c: 群効果 @u_j@ の prior を解析勾配経路で別計算するため)。
+--     値は継続に必要なので 'Sample' 自体は walk するが log-density は足さない。
+logJointExclBlocks :: (Floating a, Ord a)
+                   => Set Text -> Model a r -> Map Text a -> a
+logJointExclBlocks excl model params = go model 0
+  where
+    go (Pure _) acc = acc
+    go (Free (Sample n d k)) acc =
+      case Map.lookup n params of
+        Nothing -> negInf
+        Just v
+          | n `Set.member` excl -> go (k v) acc
+          | otherwise           -> go (k v) (acc + logDensity d v)
+    go (Free (Observe n d ys next)) acc
+      | n `Set.member` excl = go next acc
+      | otherwise           = go next (acc + obsLogSum d ys)
+    go (Free (ObserveLM nm bs xs re fam ys next)) acc
+      | nm `Set.member` excl = go next acc
+      | otherwise            = go next (acc + lmObsLogSum bs xs re fam ys params)
+    -- Phase 90 A10: vecIR ('VGPot') に吸収済みの potential は二重加算しない。
+    go (Free (Potential n v next)) acc
+      | n `Set.member` excl = go next acc
+      | otherwise           = go next (acc + v)
+    go (Free (Deterministic _ v k)) acc = go (k v) acc
+    go (Free (Data _ ys k)) acc = go (k (map realToFrac ys, ys)) acc
+    go (Free (DataIx _ is k)) acc = go (k is) acc
+    go (Free (PlateBegin _ _ next)) acc = go next acc
+    go (Free (PlateEnd next))       acc = go next acc
+
+-- | Phase 98 A2: excl 吸収後の残余 log-density。 'compileResidual' が成功すれば
+-- flat 畳み込み ('residualValueA'・Free walk 無し)、 失敗すれば従来の
+-- 'logJointExclBlocks' walk に fallback する。 呼び出し側は @mcr@ を 1 度だけ
+-- ('compileResidual' で) 構築して値/勾配の両クロージャに渡す ('CompiledResidual'
+-- は 'SExp' 保持の純データなので型非依存で共有できる)。
+residualExcl :: (Floating a, Ord a)
+             => Maybe CompiledResidual -> Set Text -> Model a r -> Map Text a -> a
+residualExcl (Just cr) _    _ params = residualValueA cr params
+residualExcl Nothing   excl m params = logJointExclBlocks excl m params
+
+-- | Phase 54.4b: Gaussian-恒等リンク 'ObserveLM' ブロックの **静的部分**を 1 度
+-- だけ前処理した中間表現。 NUTS の draw ループの外で構築し全 leapfrog で再利用する
+-- ことで、 設計列のベクトル化 (@row !! k@ = O(n·p²)) や群 id の unbox 変換・ys の
+-- Storable 化といった「値に依らず draw 間で不変な仕事」 を毎勾配評価から外す。
+data CompiledLMBlock = CompiledLMBlock
+  { clbBetas :: ![Text]                          -- ^ β パラメタ名 (列順)
+  , clbCols  :: ![VS.Vector Double]              -- ^ 設計列 (p 本・各 length n)
+  , clbReff  :: ![([Text], Int, VU.Vector Int, Maybe (VS.Vector Double))]
+    -- ^ (u 名, nG, gids, per-row 重み) のランダム効果 (重み Nothing = 全 1)
+  , clbSname :: !Text                            -- ^ σ パラメタ名
+  , clbYs    :: !(VS.Vector Double)              -- ^ 観測 (length n)
+  , clbN     :: !Int
+  , clbP     :: !Int
+  }
+
+-- | 'gaussLMBlocks' の 1 ブロックを 'CompiledLMBlock' に前処理する (静的・1 回)。
+compileLMBlock :: ([Text], [[Double]], [REff], Text, [Double]) -> CompiledLMBlock
+compileLMBlock (betaNames, designX, reffs, sName, ys) =
+  let p    = length betaNames
+      n    = length ys
+      cols = [ VS.fromList [ row !! k | row <- designX ] | k <- [0 .. p - 1] ]
+      reff = [ (uNames, length uNames, VU.fromList gids, fmap VS.fromList mw)
+             | REff uNames gids _ mw _ <- reffs ]
+  in CompiledLMBlock betaNames cols reff sName (VS.fromList ys) n p
+
+-- | Phase 54.6: 'CompiledLMBlock' の名前参照を param index に解決した形。
+-- compile 時に 1 度だけ作り、 per-call は Storable vector への index 参照のみ
+-- (Text-key Map lookup なし)。
+data CompiledLMBlockIx = CompiledLMBlockIx
+  { cliBetaIx :: !(VU.Vector Int)                       -- ^ β の param index (列順)
+  , cliXMat   :: !(VS.Vector Double)                    -- ^ 設計行列 row-major (n×p・X[i*p+k])
+  , cliCols   :: !(BV.Vector (VS.Vector Double))        -- ^ 設計列 (∂β dot 用・O(1) 添字)
+  , cliReff   :: ![(VU.Vector Int, Int, VU.Vector Int, Maybe (VS.Vector Double))]
+    -- ^ (u indices, nG, gids, per-row 重み)。 重み Nothing = 全 1 (Phase 54.10)
+  , cliSIx    :: !Int                                   -- ^ σ の param index
+  , cliYs     :: !(VS.Vector Double)                    -- ^ 観測 (length n)
+  , cliN      :: !Int
+  , cliP      :: !Int
+  }
+
+-- | 'CompiledLMBlock' の名前を index に解決する (静的・1 回)。 Phase 54.7a で
+-- row-major 設計行列も前計算 (残差ループのキャッシュ局所性 + リスト走査排除)。
+resolveLMBlock :: Map Text Int -> CompiledLMBlock -> CompiledLMBlockIx
+resolveLMBlock ixOf clb =
+  let n = clbN clb
+      p = clbP clb
+      cols = clbCols clb
+  in CompiledLMBlockIx
+    { cliBetaIx = VU.fromList [ ixOf Map.! nm | nm <- clbBetas clb ]
+    , cliXMat   = VS.generate (n * p) $ \ix ->
+                    let (i, k) = ix `divMod` p
+                    in (cols !! k) `VS.unsafeIndex` i
+    , cliCols   = BV.fromList cols
+    , cliReff   = [ (VU.fromList [ ixOf Map.! nm | nm <- uNames ], nG, gids, mw)
+                  | (uNames, nG, gids, mw) <- clbReff clb ]
+    , cliSIx    = ixOf Map.! clbSname clb
+    , cliYs     = clbYs clb
+    , cliN      = n
+    , cliP      = p
+    }
+
+-- | Phase 93: 階層 Normal 群 (uNames, μ名, τ名) の名前を param index に解決する
+-- ('resolveLMBlock' と同様に compile 時 1 回)。
+resolveHierNormal :: Map Text Int -> ([Text], Text, Text) -> HierNormalIx
+resolveHierNormal ixOf (uNames, meanName, scaleName) = HierNormalIx
+  { hniUIx     = VU.fromList [ ixOf Map.! nm | nm <- uNames ]
+  , hniMeanIx  = ixOf Map.! meanName
+  , hniScaleIx = ixOf Map.! scaleName
+  }
+
+-- | 残差 @r_i = y_i - Σ_k β_k X_ik - Σ_re u^{re}[gid_i]@ と @Σr²@ を
+-- **1 パスの手動ループ** で計算する (Phase 54.7a: (a)-0 実測で per-call
+-- ~48-82KB の割当が本物と確定 — `VS.generate` 内のリスト fold・`zip`/`toList`
+-- の毎回再構築・dot/sumR2 の中間ベクトルが原因。 unboxed アキュムレータの
+-- 明示ループ + row-major X で割当を r 1 本に削減)。
+lmResidualS :: CompiledLMBlockIx -> VS.Vector Double -> (VS.Vector Double, Double)
+lmResidualS blk pc = runST $ do
+  let n   = cliN blk
+      p   = cliP blk
+      xm  = cliXMat blk
+      ys  = cliYs blk
+      res = cliReff blk
+      bv  = VS.generate p (\k -> pc `VS.unsafeIndex` (cliBetaIx blk `VU.unsafeIndex` k))
+  mr <- VSM.unsafeNew n
+  let goObs !i !acc
+        | i >= n    = pure acc
+        | otherwise = do
+            let base = i * p
+                goK !k !s
+                  | k >= p    = s
+                  | otherwise = goK (k + 1)
+                      (s + bv `VS.unsafeIndex` k * (xm `VS.unsafeIndex` (base + k)))
+                reS = foldl' (\ !a (uix, _, gids, mw) ->
+                                let u = pc `VS.unsafeIndex` (uix `VU.unsafeIndex`
+                                          (gids `VU.unsafeIndex` i))
+                                in a + case mw of
+                                         Nothing -> u
+                                         Just w  -> w `VS.unsafeIndex` i * u) 0 res
+                ri  = ys `VS.unsafeIndex` i - goK 0 0 - reS
+            VSM.unsafeWrite mr i ri
+            goObs (i + 1) (acc + ri * ri)
+  sumR2 <- goObs 0 0
+  r <- VS.unsafeFreeze mr
+  pure (r, sumR2)
+
+-- | 前処理済みブロックの観測尤度 @Σ_i logDensityObs(Normal η_i σ) y_i@ の
+-- **constrained 空間**での勾配を解析閉形式で mutable 勾配ベクトルに加算する
+-- (Phase 54.6: Gaussian-恒等リンクは閉形式が書けるので汎用 tape 不要):
+--
+-- > ∂/∂β_k = X_kᵀ r / σ²
+-- > ∂/∂u_j = (Σ_{i: gid_i=j} w_i·r_i) / σ²   (scatter・O(n)・重み無しは w_i=1)
+-- > ∂/∂σ   = -n/σ + (Σ r²)/σ³
+--
+-- Phase 54.7a: dot / scatter とも unboxed アキュムレータの明示ループ
+-- (中間ベクトル・`VU.convert`・`accumulate` 割当なし)。
+gradLMBlockIx :: CompiledLMBlockIx -> VS.Vector Double
+              -> VSM.MVector s Double -> ST s ()
+gradLMBlockIx blk pc mg = do
+  let sigma = pc `VS.unsafeIndex` cliSIx blk
+      s2    = sigma * sigma
+      n     = cliN blk
+      (r, sumR2) = lmResidualS blk pc
+      n'    = fromIntegral n
+  forM_ [0 .. cliP blk - 1] $ \k -> do
+    let c = cliCols blk `BV.unsafeIndex` k
+        dot !i !acc
+          | i >= n    = acc
+          | otherwise = dot (i + 1)
+              (acc + c `VS.unsafeIndex` i * r `VS.unsafeIndex` i)
+    VSM.modify mg (+ (dot 0 0 / s2)) (cliBetaIx blk `VU.unsafeIndex` k)
+  forM_ (cliReff blk) $ \(uix, nG, gids, mw) -> do
+    macc <- VSM.replicate nG 0
+    let scat !i
+          | i >= n    = pure ()
+          | otherwise = do
+              let g  = gids `VU.unsafeIndex` i
+                  ri = r `VS.unsafeIndex` i
+                  wr = case mw of
+                         Nothing -> ri
+                         Just w  -> w `VS.unsafeIndex` i * ri
+              v <- VSM.unsafeRead macc g
+              VSM.unsafeWrite macc g (v + wr)
+              scat (i + 1)
+    scat 0
+    forM_ [0 .. nG - 1] $ \j -> do
+      gj <- VSM.unsafeRead macc j
+      VSM.modify mg (+ (gj / s2)) (uix `VU.unsafeIndex` j)
+  VSM.modify mg (+ (negate n' / sigma + sumR2 / (s2 * sigma))) (cliSIx blk)
+
+-- | 前処理済みブロックの観測尤度の **値**
+-- @-n/2·log2π - n·logσ - Σr²/(2σ²)@。 Phase 54.7a: r を materialize せず
+-- sumR2 だけを 1 パスの明示ループで累積 (割当ゼロ)。
+-- guard (σ≤0 → -∞) は 'logDensityObs' の Normal 分岐と一致させる。
+valueLMBlockIx :: CompiledLMBlockIx -> VS.Vector Double -> Double
+valueLMBlockIx blk pc
+  | sigma <= 0 = negInf
+  | otherwise  =
+      negate (0.5 * n' * log (2 * pi)) - n' * log sigma
+        - sumR2 / (2 * sigma * sigma)
+  where
+    sigma = pc `VS.unsafeIndex` cliSIx blk
+    n     = cliN blk
+    p     = cliP blk
+    n'    = fromIntegral n
+    xm    = cliXMat blk
+    ys    = cliYs blk
+    res   = cliReff blk
+    bv    = VS.generate p (\k -> pc `VS.unsafeIndex` (cliBetaIx blk `VU.unsafeIndex` k))
+    sumR2 = goObs 0 0
+    goObs !i !acc
+      | i >= n    = acc
+      | otherwise =
+          let base = i * p
+              goK !k !s
+                | k >= p    = s
+                | otherwise = goK (k + 1)
+                    (s + bv `VS.unsafeIndex` k * (xm `VS.unsafeIndex` (base + k)))
+              reS = foldl' (\ !a (uix, _, gids, mw) ->
+                              let u = pc `VS.unsafeIndex` (uix `VU.unsafeIndex`
+                                        (gids `VU.unsafeIndex` i))
+                              in a + case mw of
+                                       Nothing -> u
+                                       Just w  -> w `VS.unsafeIndex` i * u) 0 res
+              ri  = ys `VS.unsafeIndex` i - goK 0 0 - reS
+          in goObs (i + 1) (acc + ri * ri)
+
+-- | Phase 54.4c/54.6: 群効果 prior @u_j ~ Normal(0, τ)@ の index 解決形。
+data ReffPriorIx = ReffPriorIx
+  { rpiUIx     :: !(VU.Vector Int)   -- ^ u_j の param index (長さ nG)
+  , rpiScaleIx :: !Int               -- ^ τ の param index
+  }
+
+-- | 群効果 prior の **constrained 空間**での解析勾配を mutable 勾配ベクトルに
+-- 加算する (`ad` のスカラ tape を回避):
+--
+-- > log p(u | τ) = -nG/2·log(2π) - nG·log τ - (Σ u_j²)/(2τ²)
+-- > ∂/∂u_j = -u_j / τ²
+-- > ∂/∂τ   = -nG/τ + (Σ u_j²)/τ³
+--
+-- τ 成分は τ 自身の prior (解析 or `ad` 経路) と加算合流する。 unconstrained への
+-- chain rule ('dInvTransform') は呼出側で適用する。
+gradReffPriorIx :: ReffPriorIx -> VS.Vector Double -> VSM.MVector s Double -> ST s ()
+gradReffPriorIx (ReffPriorIx uix six) pc mg = do
+  let tau  = pc `VS.unsafeIndex` six
+      tau2 = tau * tau
+      nG   = VU.length uix
+  sumU2 <- VU.foldM' (\ !acc i -> do
+                        let u = pc `VS.unsafeIndex` i
+                        VSM.modify mg (+ (negate u / tau2)) i
+                        pure (acc + u * u)) 0 uix
+  VSM.modify mg (+ (negate (fromIntegral nG) / tau + sumU2 / (tau2 * tau))) six
+
+-- | 群効果 prior の log-density 和の **値** ('gradReffPriorIx' の値版)。
+-- guard (τ≤0 → -∞) は 'logDensity' の Normal 分岐と一致させる。
+valueReffPriorIx :: ReffPriorIx -> VS.Vector Double -> Double
+valueReffPriorIx (ReffPriorIx uix six) pc
+  | tau <= 0  = negInf
+  | otherwise =
+      negate (0.5 * nG' * log (2 * pi)) - nG' * log tau
+        - sumU2 / (2 * tau * tau)
+  where
+    tau   = pc `VS.unsafeIndex` six
+    nG'   = fromIntegral (VU.length uix)
+    sumU2 = VU.foldl' (\ !acc i -> let u = pc `VS.unsafeIndex` i
+                                   in acc + u * u) 0 uix
+
+-- | Phase 93: **非ゼロ latent 平均**の階層 Normal prior の解析勾配経路。
+-- 'ReffPriorIx' (mean-0 専用) の一般化で、 平均 μ・スケール τ とも latent の
+-- @u_i ~ Normal(μ, τ)@ 群を扱う (rats の @alpha[i]~Normal(muAlpha,sigmaAlpha)@ 等)。
+data HierNormalIx = HierNormalIx
+  { hniUIx     :: !(VU.Vector Int)   -- ^ u_i の param index (長さ nG)
+  , hniMeanIx  :: !Int               -- ^ μ の param index
+  , hniScaleIx :: !Int               -- ^ τ の param index
+  }
+
+-- | 'HierNormalIx' の **constrained 空間**での解析勾配を mutable 勾配ベクトルに
+-- 加算する (`ad` のスカラ tape を回避):
+--
+-- > log p(u | μ, τ) = -nG/2·log(2π) - nG·log τ - (Σ (u_i-μ)²)/(2τ²)
+-- > ∂/∂u_i = -(u_i - μ) / τ²
+-- > ∂/∂μ   =  (Σ (u_i - μ)) / τ²
+-- > ∂/∂τ   = -nG/τ + (Σ (u_i-μ)²)/τ³
+--
+-- μ・τ 成分は各自の prior (解析 or `ad` 経路) と加算合流する。 unconstrained への
+-- chain rule ('dInvTransform') は呼出側で適用する。
+gradHierNormalIx :: HierNormalIx -> VS.Vector Double -> VSM.MVector s Double -> ST s ()
+gradHierNormalIx (HierNormalIx uix mIx sIx) pc mg = do
+  let mu   = pc `VS.unsafeIndex` mIx
+      tau  = pc `VS.unsafeIndex` sIx
+      tau2 = tau * tau
+      nG   = VU.length uix
+  (sumD, sumD2) <-
+    VU.foldM' (\ (!accD, !accD2) i -> do
+                 let u = pc `VS.unsafeIndex` i
+                     d = u - mu
+                 VSM.modify mg (+ (negate d / tau2)) i
+                 pure (accD + d, accD2 + d * d)) (0, 0) uix
+  VSM.modify mg (+ (sumD / tau2)) mIx
+  VSM.modify mg (+ (negate (fromIntegral nG) / tau + sumD2 / (tau2 * tau))) sIx
+
+-- | 'HierNormalIx' の log-density 和の **値** ('gradHierNormalIx' の値版)。
+-- guard (τ≤0 → -∞) は 'logDensity' の Normal 分岐と一致させる。
+valueHierNormalIx :: HierNormalIx -> VS.Vector Double -> Double
+valueHierNormalIx (HierNormalIx uix mIx sIx) pc
+  | tau <= 0  = negInf
+  | otherwise =
+      negate (0.5 * nG' * log (2 * pi)) - nG' * log tau
+        - sumD2 / (2 * tau * tau)
+  where
+    mu    = pc `VS.unsafeIndex` mIx
+    tau   = pc `VS.unsafeIndex` sIx
+    nG'   = fromIntegral (VU.length uix)
+    sumD2 = VU.foldl' (\ !acc i -> let d = pc `VS.unsafeIndex` i - mu
+                                   in acc + d * d) 0 uix
+
+-- ---------------------------------------------------------------------------
+-- Phase 98 A3: LogNormal 群 prior の解析勾配 ('HierNormalIx' の LogNormal 版)
+-- ---------------------------------------------------------------------------
+-- @a_i ~ LogNormal(μ, σ)@ 群 (μ = 定数 or 単一 latent・σ = 単一 latent) の値/勾配を
+-- 解析式で扱い、 vecIR 経路の残余 reverse-AD tape (irt-2pl で ~30%time/~85%alloc) を消す。
+
+-- | 'collectLogNormalGroups' の結果を param index へ解決した中間表現。
+-- μ が定数なら @hlnMeanIx = Left c@、 latent なら @Right ix@。
+data LogNormalIx = LogNormalIx
+  { hlnUIx     :: !(VU.Vector Int)     -- ^ a_i の param index (長さ nG)
+  , hlnMeanIx  :: !(Either Double Int) -- ^ μ (定数 or param index)
+  , hlnScaleIx :: !Int                 -- ^ σ の param index
+  }
+
+resolveLogNormal :: Map Text Int -> ([Text], Either Double Text, Text) -> LogNormalIx
+resolveLogNormal ixOf (uNames, mean, scaleName) = LogNormalIx
+  { hlnUIx     = VU.fromList [ ixOf Map.! nm | nm <- uNames ]
+  , hlnMeanIx  = either Left (Right . (ixOf Map.!)) mean
+  , hlnScaleIx = ixOf Map.! scaleName
+  }
+
+-- | 'LogNormalIx' の **constrained 空間**での解析勾配を mutable 勾配ベクトルに
+-- 加算する (`ad` のスカラ tape を回避)。 L_i = log a_i, d_i = L_i - μ として:
+--
+-- > log p(a | μ, σ) = -nG/2·log(2π) - nG·log σ - Σ L_i - (Σ d_i²)/(2σ²)
+-- > ∂/∂a_i = -(1 + d_i/σ²) / a_i
+-- > ∂/∂μ   =  (Σ d_i) / σ²          (μ が latent のときのみ)
+-- > ∂/∂σ   = -nG/σ + (Σ d_i²)/σ³
+--
+-- unconstrained への chain rule ('dInvTransform') は呼出側で適用する。
+gradLogNormalIx :: LogNormalIx -> VS.Vector Double -> VSM.MVector s Double -> ST s ()
+gradLogNormalIx (LogNormalIx uix meanIx sIx) pc mg = do
+  let mu   = either id (pc `VS.unsafeIndex`) meanIx
+      sig  = pc `VS.unsafeIndex` sIx
+      sig2 = sig * sig
+      nG   = VU.length uix
+  (sumD, sumD2) <-
+    VU.foldM' (\ (!accD, !accD2) i -> do
+                 let a = pc `VS.unsafeIndex` i
+                     d = log a - mu
+                 VSM.modify mg (+ (negate (1 + d / sig2) / a)) i
+                 pure (accD + d, accD2 + d * d)) (0, 0) uix
+  case meanIx of
+    Right mIx -> VSM.modify mg (+ (sumD / sig2)) mIx
+    Left _    -> pure ()
+  VSM.modify mg (+ (negate (fromIntegral nG) / sig + sumD2 / (sig2 * sig))) sIx
+
+-- | 'LogNormalIx' の log-density 和の **値** ('gradLogNormalIx' の値版)。
+-- guard (σ≤0 / a_i≤0 → -∞) は 'logDensity' の LogNormal 分岐と一致させる
+-- (a は PositiveT 変換で a>0 だが安全のため一致させる)。
+valueLogNormalIx :: LogNormalIx -> VS.Vector Double -> Double
+valueLogNormalIx (LogNormalIx uix meanIx sIx) pc
+  | sig <= 0                      = negInf
+  | VU.any (\i -> pc `VS.unsafeIndex` i <= 0) uix = negInf
+  | otherwise =
+      negate (0.5 * nG' * log (2 * pi)) - nG' * log sig - sumL
+        - sumD2 / (2 * sig * sig)
+  where
+    mu    = either id (pc `VS.unsafeIndex`) meanIx
+    sig   = pc `VS.unsafeIndex` sIx
+    nG'   = fromIntegral (VU.length uix)
+    (sumL, sumD2) =
+      VU.foldl' (\ (!aL, !aD2) i ->
+                   let l = log (pc `VS.unsafeIndex` i)
+                       d = l - mu
+                   in (aL + l, aD2 + d * d)) (0, 0) uix
+
+-- ---------------------------------------------------------------------------
+-- 制約変換 (Floating 多相版)
+-- ---------------------------------------------------------------------------
+
+-- | unconstrained → constrained 変換 (Floating 多相)。
+--
+-- > UnconstrainedT: θ = u
+-- > PositiveT:      θ = exp(u)
+-- > UnitIntervalT:  θ = sigmoid(u) = 1/(1+exp(-u))
+invTransformF :: Floating a => Transform -> a -> a
+invTransformF UnconstrainedT u = u
+invTransformF PositiveT      u = exp u
+invTransformF UnitIntervalT  u = 1 / (1 + exp (-u))
+
+-- | log |∂θ/∂u| — Jacobian 行列式の対数 (Floating 多相)。
+logJacF :: Floating a => Transform -> a -> a
+logJacF UnconstrainedT _ = 0
+logJacF PositiveT      u = u                       -- log(exp u) = u
+logJacF UnitIntervalT  u =
+  let p = 1 / (1 + exp (-u))
+  in log p + log (1 - p)                           -- log σ(u)(1-σ(u))
+
+-- | 各 latent 変数の事前分布から制約変換を自動検出する。 分布名→変換の表は
+-- 'nameToTransform' (@HBM.Distribution@) に一元化 (probe 側 'vecIRProbeOK' と
+-- 同一 source)。
+getTransforms :: ModelP r -> Map Text Transform
+getTransforms m = Map.fromList
+  [ (nodeName n, nameToTransform (nodeDist n))
+  | n <- collectNodes m
+  , nodeKind n == LatentN
+  ]
+
+-- | unconstrained 空間における log-joint (Jacobian 補正込み)。
+-- Jacobian 補正で確率密度の積分を保存する。
+logJointUnconstrained :: forall a r. (Floating a, Ord a)
+                      => Model a r
+                      -> [Text]      -- ^ パラメータ順序
+                      -> [Transform] -- ^ 各パラメータの変換種別
+                      -> Map Text a  -- ^ unconstrained パラメータ値
+                      -> a
+logJointUnconstrained m names trans paramsU =
+  let paramsC = Map.fromList
+        [ (n, invTransformF t (Map.findWithDefault 0 n paramsU))
+        | (n, t) <- zip names trans ]
+      logJac  = sum
+        [ logJacF t (Map.findWithDefault 0 n paramsU)
+        | (n, t) <- zip names trans ]
+  in logJoint m paramsC + logJac
diff --git a/src/Hanalyze/Model/HBM/IR.hs b/src/Hanalyze/Model/HBM/IR.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Model/HBM/IR.hs
@@ -0,0 +1,3055 @@
+{-# LANGUAGE RankNTypes #-}
+{-# LANGUAGE DeriveFunctor #-}
+{-# LANGUAGE FlexibleInstances #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE ImpredicativeTypes #-}
+{-# LANGUAGE BangPatterns #-}
+{-# LANGUAGE FlexibleContexts #-}
+{-# LANGUAGE TupleSections #-}
+
+-- |
+-- Module      : Hanalyze.Model.HBM.IR
+-- Description : HBM の中間表現 (IR) 層 (affine 追跡・SExp/UExp コンパイル)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Phase 58.7: IR (中間表現) 層を 'Hanalyze.Model.HBM' から分離。
+--
+-- AD 勾配の高速経路で使う **中間表現** (記述層 Model / 評価層 Eval の上層):
+--
+--   * affine 追跡 ('AffV') による per-obs 手書き Gaussian モデルの自動
+--     ObserveLM 化 (Phase 54.8・'synthGaussLMBlocks')
+--   * 非線形 μ の「スカラ式 IR」 ('SExp') → 「ベクトル式 IR」 ('UExp') 合成
+--     (Phase 54.11/55・'synthVecIR' / 'compileVecIR')
+--   * 観測密度の IR 式化 ('VecObsIR' → 'CompiledVecIR') と arena 上の値/勾配
+--     評価 ('vecIRValue' / 'gradVecIR'・Phase 56.2)
+--
+-- ★**最ホット**: NUTS per-draw の勾配本経路 ('gradVecIR')。 monolith では AD 勾配
+-- コンパイラ ('compileGradUV' 本体残置) と同一モジュールで inline されていた。
+-- 境界跨ぎ inline 喪失を防ぐため定義と一緒に INLINABLE/SPECIALIZE を移送。 依存は
+-- 下層 Model / Distribution / Eval (lmObsLogSum) / Util のみ (一方向)。
+--
+-- export list は省略 (内部実装層)。 公開 surface (synthGaussLMBlocks / synthVecIR)
+-- は facade 'Hanalyze.Model.HBM' の export list が制御する。
+module Hanalyze.Model.HBM.IR where
+
+import Control.DeepSeq (NFData (..), force)
+import Control.Exception (SomeAsyncException (..), SomeException, evaluate,
+                          fromException, throwIO, try)
+import Control.Monad (forM, forM_, replicateM, when)
+import Data.IORef (IORef, modifyIORef', newIORef, readIORef, writeIORef)
+import Data.List (foldl')
+import System.IO.Unsafe (unsafePerformIO)
+import System.Mem.StableName (StableName, hashStableName, makeStableName)
+import qualified Data.IntMap.Strict as IM
+import qualified Data.Map.Strict as Map
+import Data.Map.Strict (Map)
+import qualified Data.Set as Set
+import Data.Set (Set)
+import Data.Text (Text)
+import qualified Data.Text as T
+import Numeric.AD.Mode.Reverse.Double (grad)
+import Control.Monad.Primitive (PrimMonad, PrimState)
+
+import Control.Monad.ST (ST, runST)
+import qualified Data.Vector          as BV
+import qualified Data.Vector.Storable as VS
+import qualified Data.Vector.Storable.Mutable as VSM
+import qualified Data.Vector.Unboxed  as VU
+
+import Hanalyze.Stat.Distribution (Transform (..), fromUnconstrained)
+import Hanalyze.MCMC.Core (Chain (..))
+
+import Hanalyze.Model.HBM.Util
+import Hanalyze.Model.HBM.Distribution
+import Hanalyze.Model.HBM.Sampling
+import Hanalyze.Model.HBM.Model
+import Hanalyze.Model.HBM.Track
+import Hanalyze.Model.HBM.Eval
+
+-- ---------------------------------------------------------------------------
+-- Phase 54.8: per-obs 手書きモデルの自動 ObserveLM 化 (M1 救済)
+-- ---------------------------------------------------------------------------
+
+-- | affine 追跡値 (Phase 54.8)。 latent 値の場所に流して、 式が
+-- @Σ coeff_i · latent_i + offset@ (係数は定数) の形に留まるかを追跡する。
+-- 非線形演算 (latent 同士の積・exp 等) が掛かった時点で 'NA' に落ちる。
+data AffV
+  = AffC !Double               -- ^ 定数
+  | AffL !(Map Text Double) !Double  -- ^ Σ coeff·latent + offset (affine)
+  | NA                         -- ^ 非 affine (追跡断念)
+
+-- Phase 60.7: '!!!' の依存タグは IR 抽出には無関係 (既定 id)。
+instance TrackTag AffV
+
+-- | 非定数値の比較 = 値依存分岐。 構造抽出は分岐の片側しか見られないため
+-- 誤抽出になる → error poison で walk 全体を失敗させ、 呼出側の
+-- @try/evaluate/force@ で捕捉して fallback する (安全網①)。
+affPoison :: a
+affPoison = error "AffV: non-constant comparison (value-dependent branch)"
+
+instance Eq AffV where
+  AffC a == AffC b = a == b
+  _      == _      = affPoison
+
+instance Ord AffV where
+  compare (AffC a) (AffC b) = compare a b
+  compare _        _        = affPoison
+
+instance Num AffV where
+  AffC a   + AffC b   = AffC (a + b)
+  AffC a   + AffL m c = AffL m (a + c)
+  AffL m c + AffC a   = AffL m (c + a)
+  AffL m1 c1 + AffL m2 c2 = AffL (Map.unionWith (+) m1 m2) (c1 + c2)
+  _ + _ = NA
+  AffC a   * AffC b   = AffC (a * b)
+  AffC a   * AffL m c = scaleAffV a m c
+  AffL m c * AffC a   = scaleAffV a m c
+  _ * _ = NA
+  negate (AffC a)   = AffC (negate a)
+  negate (AffL m c) = AffL (Map.map negate m) (negate c)
+  negate NA         = NA
+  abs (AffC a) = AffC (abs a)
+  abs _        = NA
+  signum (AffC a) = AffC (signum a)
+  signum _        = NA
+  fromInteger = AffC . fromInteger
+
+-- | 定数倍。 0 倍は affine 情報ごと消えて定数 0 (係数 0 の死に列を作らない)。
+scaleAffV :: Double -> Map Text Double -> Double -> AffV
+scaleAffV a m c
+  | a == 0    = AffC 0
+  | otherwise = AffL (Map.map (a *) m) (a * c)
+
+instance Fractional AffV where
+  AffC a   / AffC b = AffC (a / b)
+  AffL m c / AffC b = scaleAffV (recip b) m c
+  _        / _      = NA
+  recip (AffC a) = AffC (recip a)
+  recip _        = NA
+  fromRational = AffC . fromRational
+
+instance Floating AffV where
+  pi = AffC pi
+  exp   = affLift1 exp
+  log   = affLift1 log
+  sqrt  = affLift1 sqrt
+  sin   = affLift1 sin
+  cos   = affLift1 cos
+  tan   = affLift1 tan
+  asin  = affLift1 asin
+  acos  = affLift1 acos
+  atan  = affLift1 atan
+  sinh  = affLift1 sinh
+  cosh  = affLift1 cosh
+  tanh  = affLift1 tanh
+  asinh = affLift1 asinh
+  acosh = affLift1 acosh
+  atanh = affLift1 atanh
+
+-- | 超越関数: 定数には適用、 latent が絡んだら非 affine。
+affLift1 :: (Double -> Double) -> AffV -> AffV
+affLift1 f (AffC a) = AffC (f a)
+affLift1 _ _        = NA
+
+-- | Phase 54.8: per-obs 手書き scalar 'Observe' 群から Gaussian LM ブロックを
+-- **自動合成**する。 返り値は (合成ブロック群, 吸収した Observe ノード名集合)。
+-- 検出できない / 安全網に掛かった場合は @([], ∅)@ (従来経路に fallback)。
+--
+-- 仕組み: 'Sample' の継続に @AffL {name:1} 0@ を給餌して model を walk し、
+-- @Observe nm (Normal μ σ) ys@ の μ が affine・σ が単一 latent (係数 1・offset 0)
+-- の行を収集する。 定数 offset は ys 側に畳む (Normal は y−μ のみに依存)。
+-- σ 名ごとに 1 ブロックへまとめ、 prior が @Normal(0, τ)@ (τ 単一 latent) を
+-- 共有し **各行にちょうど 1 つ**現れる latent 族を 'REff' gather に昇格する
+-- (dense one-hot は O(nG·n) で階層に逆効果 — 54.4a 計測)。 係数は任意で、
+-- per-row 重みとして 'REff' に載せる (Phase 54.10: random slope @v_g·x_i@ も
+-- gather 化。 全 1 なら重みスロットは @Nothing@ = 従来の random intercept)。
+-- 族抽出に失敗した latent は dense β 列のまま (正しいが遅い・安全方向)。
+--
+-- 安全網 2 段: ① 'AffV' の Eq/Ord は非定数比較で error poison →
+-- 'unsafePerformIO' + 'try' + 'force' で捕捉し全体 fallback (値依存分岐モデルの
+-- 誤抽出防止・Nonlinear 系の前例に同じ)。 ② 合成ブロックの観測尤度を probe
+-- 2 点で walk 評価 ('obsOnlySum') と突合し、 不一致なら fallback。
+synthGaussLMBlocks
+  :: ModelP r
+  -> ([(Text, [Text], [[Double]], [REff], Text, [Double])], Set Text)
+synthGaussLMBlocks m = unsafePerformIO $ do
+  r <- try (evaluate (force (synthGaussLMWalk m)))
+  pure $ case r :: Either SomeException
+                    ([(Text, [Text], [[Double]], [REff], Text, [Double])], Set Text) of
+    Left _  -> ([], Set.empty)
+    Right v@(blocks, obsNames)
+      | null blocks          -> ([], Set.empty)
+      | synthProbeOK m blocks obsNames -> v
+      | otherwise            -> ([], Set.empty)
+{-# NOINLINE synthGaussLMBlocks #-}
+
+-- | 'synthGaussLMBlocks' の純粋部 (walk + 族抽出)。 poison は遅延に潜むので
+-- 呼出側が force してから使う。
+synthGaussLMWalk
+  :: ModelP r
+  -> ([(Text, [Text], [[Double]], [REff], Text, [Double])], Set Text)
+synthGaussLMWalk m =
+  let (rows, priors) = collectAffRows m
+      sigmas = ordNubT [ sn | (_, _, _, sn, _) <- rows ]
+      blocks = [ synthBlock priors sn [ r | r@(_, _, _, sn', _) <- rows, sn' == sn ]
+               | sn <- sigmas ]
+      obsNames = Set.fromList [ nm | (nm, _, _, _, _) <- rows ]
+  in (blocks, obsNames)
+  where
+    ordNubT = go Set.empty
+      where go _ [] = []
+            go seen (x:xs)
+              | x `Set.member` seen = go seen xs
+              | otherwise           = x : go (Set.insert x seen) xs
+
+-- | model を 'AffV' で walk し、 合成可能な行 (Observe 名, μ 係数, μ offset,
+-- σ 名, 観測値) と latent prior のスケール検出 (@Normal(0, τ)@ → @Just τ@) を集める。
+collectAffRows
+  :: Model AffV r
+  -> ([(Text, Map Text Double, Double, Text, Double)], Map Text (Maybe Text))
+collectAffRows = go [] Map.empty
+  where
+    go rows priors (Pure _) = (reverse rows, priors)
+    go rows priors (Free f) = case f of
+      Sample n d k ->
+        let sc = case d of
+                   Normal (AffC 0) (AffL tm 0)
+                     | [(tn, 1)] <- Map.toList tm -> Just tn
+                   _ -> Nothing
+        in go rows (Map.insert n sc priors) (k (AffL (Map.singleton n 1) 0))
+      Observe nm (Normal mu sg) ys next
+        | AffL sm 0 <- sg, [(sn, 1)] <- Map.toList sm
+        , Just (cs, off) <- affParts mu ->
+            go ([ (nm, cs, off, sn, y) | y <- ys ] ++ rows) priors next
+      Observe _ _ _ next  -> go rows priors next
+      ObserveLM _ _ _ _ _ _ next -> go rows priors next
+      Potential _ _ next  -> go rows priors next
+      Deterministic _ v k -> go rows priors (k v)
+      Data _ ys k         -> go rows priors (k (map realToFrac ys, ys))
+      DataIx _ is k       -> go rows priors (k is)
+      PlateBegin _ _ next -> go rows priors next
+      PlateEnd next       -> go rows priors next
+    affParts (AffC c)   = Just (Map.empty, c)
+    affParts (AffL m c) = Just (m, c)
+    affParts NA         = Nothing
+
+-- | Phase 93: 非ゼロ **latent 平均** の階層 Normal prior を検出する。
+-- @u_i ~ Normal(μ, τ)@ で μ・τ **ともに単一 latent** (係数 1・offset 0) の
+-- 'Sample' を集め、 (μ, τ) の組ごとに出現順を保って群化する。 返り値の各要素は
+-- @(u 名の群, μ 名, τ 名)@。
+--
+-- 平均が定数 (@AffC 0@) の reff は既存の mean-0 解析経路 ('ReffPriorIx') が
+-- 扱うのでここでは検出しない (μ が 'AffL' でないと不一致)。 係数≠1・多項・
+-- offset≠0 の平均や非 affine な μ/τ も対象外 (残差 ad に残す安全側)。
+-- rats の @alpha[i]~Normal(muAlpha,sigmaAlpha)@ / @beta[i]~Normal(muBeta,sigmaBeta)@
+-- のような varying-intercept/slope の中心化階層 prior を解析勾配へ載せるための検出器。
+collectHierNormalGroups :: Model AffV r -> [([Text], Text, Text)]
+collectHierNormalGroups = regroup . go
+  where
+    go (Pure _) = []
+    go (Free f) = case f of
+      Sample n d k ->
+        let hit = case d of
+                    Normal (AffL mm 0) (AffL tm 0)
+                      | [(mn, 1)] <- Map.toList mm
+                      , [(tn, 1)] <- Map.toList tm -> Just (n, mn, tn)
+                    _ -> Nothing
+            rest = go (k (AffL (Map.singleton n 1) 0))
+        in maybe rest (: rest) hit
+      Observe _ _ _ next          -> go next
+      ObserveLM _ _ _ _ _ _ next  -> go next
+      Potential _ _ next          -> go next
+      Deterministic _ v k         -> go (k v)
+      Data _ ys k                 -> go (k (map realToFrac ys, ys))
+      DataIx _ is k               -> go (k is)
+      PlateBegin _ _ next         -> go next
+      PlateEnd next               -> go next
+    -- (μ,τ) ごとに、u の出現順を保って群化する。
+    regroup hits =
+      [ ([ u | (u, mn', tn') <- hits, mn' == mn, tn' == tn ], mn, tn)
+      | (mn, tn) <- ordNub [ (mn, tn) | (_, mn, tn) <- hits ] ]
+    ordNub = goN Set.empty
+      where goN _ [] = []
+            goN s (x:xs) | x `Set.member` s = goN s xs
+                         | otherwise        = x : goN (Set.insert x s) xs
+
+-- | Phase 98 A3: @a_i ~ LogNormal(μ, σ)@ 群を検出する ('collectHierNormalGroups' の
+-- LogNormal 版)。μ は定数 (@Left c@・例 irt-2pl の 0) か単一 latent (@Right mn@)、
+-- σ は単一 latent (@AffL {sn:1} 0@)。σ 定数は 'constPriorsOf' が拾うのでここでは対象外。
+-- 返り値 = [(u 名, μ, σ 名)]。vecIR 経路で解析勾配 ('gradLogNormalIx') に載せ残差 ad から
+-- 外す (irt-2pl の 20-項 LogNormal prior が reverse-AD tape を張っていたのを解消)。
+collectLogNormalGroups :: Model AffV r -> [([Text], Either Double Text, Text)]
+collectLogNormalGroups = regroup . go
+  where
+    go (Pure _) = []
+    go (Free f) = case f of
+      Sample n d k ->
+        let hit = case d of
+                    LogNormal muA (AffL tm 0)
+                      | [(tn, 1)] <- Map.toList tm
+                      , Just mean <- affMean muA -> Just (n, mean, tn)
+                    _ -> Nothing
+            rest = go (k (AffL (Map.singleton n 1) 0))
+        in maybe rest (: rest) hit
+      Observe _ _ _ next          -> go next
+      ObserveLM _ _ _ _ _ _ next  -> go next
+      Potential _ _ next          -> go next
+      Deterministic _ v k         -> go (k v)
+      Data _ ys k                 -> go (k (map realToFrac ys, ys))
+      DataIx _ is k               -> go (k is)
+      PlateBegin _ _ next         -> go next
+      PlateEnd next               -> go next
+    -- μ = 定数 (AffC c / offset のみの AffL) か単一 latent (係数 1・offset 0)。
+    affMean (AffC c)                        = Just (Left c)
+    affMean (AffL mm c)
+      | Map.null mm                         = Just (Left c)
+      | [(mn, 1)] <- Map.toList mm, c == 0  = Just (Right mn)
+    affMean _                               = Nothing
+    -- (μ, σ) ごとに u の出現順を保って群化する。
+    regroup hits =
+      [ ([ u | (u, mean', sn') <- hits, mean' == mean, sn' == sn ], mean, sn)
+      | (mean, sn) <- ordNub [ (mean, sn) | (_, mean, sn) <- hits ] ]
+    ordNub = goN Set.empty
+      where goN _ [] = []
+            goN s (x:xs) | x `Set.member` s = goN s xs
+                         | otherwise        = x : goN (Set.insert x s) xs
+
+-- | 1 つの σ 名グループから (ブロック名, β 名, X, REff 族, σ 名, ys') を合成する。
+synthBlock
+  :: Map Text (Maybe Text)
+  -> Text
+  -> [(Text, Map Text Double, Double, Text, Double)]
+  -> (Text, [Text], [[Double]], [REff], Text, [Double])
+synthBlock priors sn rows =
+  let coeffs   = [ cs | (_, cs, _, _, _) <- rows ]
+      latents  = Set.toAscList (Set.unions (map Map.keysSet coeffs))
+      -- 族候補 (Phase 54.10 で「係数常 1」 を撤廃): prior = Normal(0, τ) 検出済
+      -- なら係数任意。 係数は per-row 重みとして 'REff' に載せる (random slope)。
+      isCand l = maybe False (/= Nothing) (Map.lookup l priors)
+      -- スケール τ ごとに族を貪欲抽出: 全行にちょうど 1 つ現れる族のみ採用。
+      famsByTau = Map.fromListWith (++)
+        [ (tn, [l]) | l <- latents, isCand l
+        , Just (Just tn) <- [Map.lookup l priors] ]
+      accepted = [ (tn, Set.toAscList (Set.fromList ls))
+                 | (tn, ls) <- Map.toList famsByTau
+                 , let fam = Set.fromList ls
+                 , all (\cs -> length (filter (`Map.member` cs) (Set.toList fam)) == 1)
+                       coeffs ]
+      famSet   = Set.fromList (concatMap snd accepted)
+      reffs    = [ let gws = [ gwOf fam cs | cs <- coeffs ]
+                       ws  = map snd gws
+                       mw  = if all (== 1) ws then Nothing else Just ws
+                   in REff fam (map fst gws) (Just tn) mw Nothing
+                 | (tn, fam) <- accepted ]
+      -- 各行で族中ちょうど 1 つ現れる latent の (族内 index, 係数 = 重み)。
+      gwOf fam cs = head [ (j, cs Map.! l) | (j, l) <- zip [0 ..] fam
+                         , l `Map.member` cs ]
+      betas    = [ l | l <- latents, not (l `Set.member` famSet) ]
+      xs       = [ [ Map.findWithDefault 0 b cs | b <- betas ] | cs <- coeffs ]
+      ys'      = [ y - off | (_, _, off, _, y) <- rows ]
+  in ("__synth_lm_" <> sn, betas, xs, reffs, sn, ys')
+
+-- | 安全網② (Phase 54.8): 合成ブロックの観測尤度を、 元 model の walk 評価
+-- ('obsOnlySum' = 吸収した scalar Observe だけ足す) と probe 2 点で突合する。
+-- prior は足さないので guard 起因の ±∞ で比較が壊れない。 probe 値は
+-- per-param に変えて係数の取り違えも検出する (全 latent 正値 → σ guard 安全)。
+synthProbeOK
+  :: ModelP r
+  -> [(Text, [Text], [[Double]], [REff], Text, [Double])]
+  -> Set Text -> Bool
+synthProbeOK m blocks obsNames = all check [(0.5, 0.07), (1.3, 0.11)]
+  where
+    names = sampleNames m
+    check (base, step) =
+      let pm = Map.fromList [ (n, base + step * fromIntegral i)
+                            | (n, i) <- zip names [0 :: Int ..] ]
+          ref = obsOnlySum obsNames m pm
+          syn = sum [ lmObsLogSum bs xs re (LMGaussian sn) ys pm
+                    | (_, bs, xs, re, sn, ys) <- blocks ]
+      in abs (ref - syn) <= 1e-9 * (1 + abs ref)
+
+-- | 名前が @sel@ に含まれる scalar 'Observe' の log-likelihood **だけ**を足す
+-- walk (Phase 54.8 probe 用)。
+obsOnlySum :: Set Text -> Model Double r -> Map Text Double -> Double
+obsOnlySum sel model params = go model 0
+  where
+    go (Pure _) acc = acc
+    go (Free (Sample n _ k)) acc = go (k (Map.findWithDefault 0 n params)) acc
+    go (Free (Observe n d ys next)) acc
+      | n `Set.member` sel = go next (acc + obsLogSum d ys)
+      | otherwise          = go next acc
+    go (Free (ObserveLM _ _ _ _ _ _ next)) acc = go next acc
+    go (Free (Potential _ _ next)) acc = go next acc
+    go (Free (Deterministic _ v k)) acc = go (k v) acc
+    go (Free (Data _ ys k)) acc = go (k (ys, ys)) acc
+    go (Free (DataIx _ is k)) acc = go (k is) acc
+    go (Free (PlateBegin _ _ next)) acc = go next acc
+    go (Free (PlateEnd next)) acc = go next acc
+
+-- ---------------------------------------------------------------------------
+-- Phase 54.11: 非線形 μ のベクトル式 IR (M5/M6 救済)
+-- ---------------------------------------------------------------------------
+--
+-- 54.8 の AffV (affine 限定) の代役として、 latent 値の場所に **スカラ式ノード**
+-- ('SExp') を給餌して model を walk し、 per-obs scalar @Observe (Normal μ σ)@ の
+-- μ 式 (非線形可) を行ごとに収集する。 行間で式の形が同型 (定数 leaf だけが行
+-- ごとに違う) なら定数列をベクトル leaf に束ねて「ベクトル式 IR」 ('UExp') へ
+-- 持ち上げ (μ⃗ = f(θ, x⃗))、 評価は VecAD の vector-op tape で行う (勾配) /
+-- 素な Double ベクトル演算で行う (値)。 階層 prior (a_g ~ Normal(m, τ) の族) の
+-- スカラ密度も同 IR に乗せる (M6 要件・54.9)。
+--
+-- IR 持ち上げ + 静的解析は compile 時 1 回・draw 間で再利用する (54.4b 前例)。
+-- VecAD tape 自体は per-call 構築 (spike `bench-hbm-vecir` の実測はこの構築込み)。
+
+-- | スカラ単項演算子 ('SExp' の節)。 導関数は 'sUnD' と対。
+data SUn
+  = SNegO | SAbsO | SSignumO | SExpO | SLogO | SSqrtO | SRecipO
+  | SSinO | SCosO | STanO | SAsinO | SAcosO | SAtanO
+  | SSinhO | SCoshO | STanhO | SAsinhO | SAcoshO | SAtanhO
+  | SLgammaO   -- ^ log Γ (Phase 56.2・密度 IR 用。 'Floating' 経由では現れない)
+  deriving (Eq, Ord, Show)
+
+-- | 'SUn' の評価関数を known-function として継続に渡す CPS dispatcher。
+-- Phase 105 A3: arena 実行ループが @f = sUnF op@ で closure を束縛してから
+-- 要素毎に間接呼出すると GHC が unbox できず per-element boxing が出る
+-- (irt-2pl prof で sUnF/sBinF/sUnD 計 30.5% time・38.9% alloc)。 call site を
+-- INLINE 展開して op の case をループの外に出し、 各分岐を known-function の
+-- 特殊化 unboxed ループに落とす。 演算内容・FP 順序は不変 (= posterior bit 一致)。
+withSUnF :: SUn -> ((Double -> Double) -> r) -> r
+withSUnF o k = case o of
+  SNegO    -> k negate
+  SAbsO    -> k abs
+  SSignumO -> k signum
+  SExpO    -> k exp
+  SLogO    -> k log
+  SSqrtO   -> k sqrt
+  SRecipO  -> k recip
+  SSinO    -> k sin
+  SCosO    -> k cos
+  STanO    -> k tan
+  SAsinO   -> k asin
+  SAcosO   -> k acos
+  SAtanO   -> k atan
+  SSinhO   -> k sinh
+  SCoshO   -> k cosh
+  STanhO   -> k tanh
+  SAsinhO  -> k asinh
+  SAcoshO  -> k acosh
+  SAtanhO  -> k atanh
+  SLgammaO -> k lgammaApprox
+{-# INLINE withSUnF #-}
+
+sUnF :: SUn -> Double -> Double
+sUnF o = withSUnF o id
+
+-- | 'sUnF' の導関数の CPS dispatcher ('withSUnF' と同じ意図)。
+withSUnD :: SUn -> ((Double -> Double) -> r) -> r
+withSUnD o k = case o of
+  SNegO    -> k (const (-1))
+  SAbsO    -> k signum
+  SSignumO -> k (const 0)
+  SExpO    -> k exp
+  SLogO    -> k recip
+  SSqrtO   -> k (\x -> 0.5 / sqrt x)
+  SRecipO  -> k (\x -> negate (recip (x * x)))
+  SSinO    -> k cos
+  SCosO    -> k (negate . sin)
+  STanO    -> k (\x -> let t = tan x in 1 + t * t)
+  SAsinO   -> k (\x -> 1 / sqrt (1 - x * x))
+  SAcosO   -> k (\x -> negate (1 / sqrt (1 - x * x)))
+  SAtanO   -> k (\x -> 1 / (1 + x * x))
+  SSinhO   -> k cosh
+  SCoshO   -> k sinh
+  STanhO   -> k (\x -> let t = tanh x in 1 - t * t)
+  SAsinhO  -> k (\x -> 1 / sqrt (x * x + 1))
+  SAcoshO  -> k (\x -> 1 / sqrt (x * x - 1))
+  SAtanhO  -> k (\x -> 1 / (1 - x * x))
+  -- digamma でなく項別微分: 評価関数 lgammaApprox の AD 微分 (walk+ad fallback /
+  -- 参照勾配) とビット近傍一致させる (digamma だと z=12 境界で ~1.3e-9 ズレ・56.4)
+  SLgammaO -> k lgammaApproxDeriv
+{-# INLINE withSUnD #-}
+
+-- | 'sUnF' の導関数。
+sUnD :: SUn -> Double -> Double
+sUnD o = withSUnD o id
+
+-- | スカラ二項演算子 ('SExp' の節)。
+-- | Phase 90 A3: 'SMaxO' は Mixture/ZeroInflatedBinomial の log-sum-exp を
+-- 数値安定に組むための elementwise max (勾配は winner-take-all の
+-- subgradient・'gradVecIRGo' 参照)。 'SExp' の 'Num' インスタンス経由では
+-- 構築しない (Num に max が無い) — 'logSumExp2' からのみ直接 'RU2 SMaxO' で
+-- 使う。
+data SBin = SAddO | SSubO | SMulO | SDivO | SMaxO
+  deriving (Eq, Ord, Show)
+
+-- | 二項演算子の CPS dispatcher ('withSUnF' と同じ意図)。
+withSBinF :: SBin -> ((Double -> Double -> Double) -> r) -> r
+withSBinF o k = case o of
+  SAddO -> k (+)
+  SSubO -> k (-)
+  SMulO -> k (*)
+  SDivO -> k (/)
+  SMaxO -> k max
+{-# INLINE withSBinF #-}
+
+sBinF :: SBin -> Double -> Double -> Double
+sBinF o = withSBinF o id
+
+-- | スカラ式 IR。 latent 値の場所に流して式の木を構築する (AffV と違い
+-- 非線形演算も leaf に潜らず木に残る)。 定数同士は即畳み込む ('sc1'/'sc2') ので
+-- データ由来の値は常に 'SC' leaf に正規化され、 行間の形状照合が成立する。
+data SExp
+  = SC !Double          -- ^ 定数 (データ・リテラル)
+  | SV !Text            -- ^ latent 参照
+  | S1 !SUn SExp
+  | S2 !SBin SExp SExp
+
+instance NFData SExp where
+  rnf (SC x)     = rnf x
+  rnf (SV n)     = rnf n
+  rnf (S1 o e)   = o `seq` rnf e
+  rnf (S2 o a b) = o `seq` rnf a `seq` rnf b
+
+-- Phase 60.7: '!!!' の依存タグは IR 抽出には無関係 (既定 id)。
+instance TrackTag SExp
+
+-- | 非定数値の比較 = 値依存分岐 → error poison (54.8 の AffV と同じ安全網①)。
+symPoison :: a
+symPoison = error "SExp: non-constant comparison (value-dependent branch)"
+
+instance Eq SExp where
+  SC a == SC b = a == b
+  _    == _    = symPoison
+
+instance Ord SExp where
+  compare (SC a) (SC b) = compare a b
+  compare _        _    = symPoison
+
+-- | 定数畳み込み付きノード構築。
+sc1 :: SUn -> SExp -> SExp
+sc1 o (SC a) = SC (sUnF o a)
+sc1 o e      = S1 o e
+
+sc2 :: SBin -> SExp -> SExp -> SExp
+sc2 o (SC a) (SC b) = SC (sBinF o a b)
+sc2 o a b           = S2 o a b
+
+instance Num SExp where
+  (+) = sc2 SAddO
+  (-) = sc2 SSubO
+  (*) = sc2 SMulO
+  negate = sc1 SNegO
+  abs    = sc1 SAbsO
+  signum = sc1 SSignumO
+  fromInteger = SC . fromInteger
+
+instance Fractional SExp where
+  (/) = sc2 SDivO
+  recip = sc1 SRecipO
+  fromRational = SC . fromRational
+
+instance Floating SExp where
+  pi    = SC pi
+  exp   = sc1 SExpO
+  log   = sc1 SLogO
+  sqrt  = sc1 SSqrtO
+  sin   = sc1 SSinO
+  cos   = sc1 SCosO
+  tan   = sc1 STanO
+  asin  = sc1 SAsinO
+  acos  = sc1 SAcosO
+  atan  = sc1 SAtanO
+  sinh  = sc1 SSinhO
+  cosh  = sc1 SCoshO
+  tanh  = sc1 STanhO
+  asinh = sc1 SAsinhO
+  acosh = sc1 SAcoshO
+  atanh = sc1 SAtanhO
+
+-- | 構造一致 (total・poison しない。 族 prior の同型判定用)。
+sexpEq :: SExp -> SExp -> Bool
+sexpEq (SC a)     (SC b)     = a == b
+sexpEq (SV a)     (SV b)     = a == b
+sexpEq (S1 o a)   (S1 p b)   = o == p && sexpEq a b
+sexpEq (S2 o a c) (S2 p b d) = o == p && sexpEq a b && sexpEq c d
+sexpEq _          _          = False
+
+-- | 式中の latent 参照名。
+sexpVars :: SExp -> Set Text
+sexpVars (SC _)     = Set.empty
+sexpVars (SV n)     = Set.singleton n
+sexpVars (S1 _ e)   = sexpVars e
+sexpVars (S2 _ a b) = sexpVars a `Set.union` sexpVars b
+
+-- | μ 式の「形の指紋」 (Phase 55.2)。 演算子木の形と leaf の SC/SV 区別のみで、
+-- 値・名前は含めない。 同一 σ 下で式形が混在しても指紋ごとに独立のグループとして
+-- 'unifyMany' に掛けるためのキー (形違いで σ グループ丸ごと drop しない)。
+-- 「全行同一 SV」 と「行で異なる SV (族 gather)」 の区別は従来どおり unify 側の仕事。
+sexpShape :: SExp -> String
+sexpShape (SC _)     = "c"
+sexpShape (SV _)     = "v"
+sexpShape (S1 o e)   = show o ++ '(' : sexpShape e ++ ")"
+sexpShape (S2 o a b) = show o ++ '(' : sexpShape a ++ ',' : sexpShape b ++ ")"
+
+-- | σ 式の「名前付き指紋」 (Phase 55.3)。 'sexpShape' と違い SV は latent 名を
+-- 含める: σ 側は名前が違えば別グループに分ける (σ leaf を行で混ぜて族 gather に
+-- 持ち上げると、 族 prior 条件を満たさない σ 同士の合流でグループ全体が drop する
+-- 退行が起き得るため、 σ は保守的に「同一式 (定数値のみ行依存可)」 でキーする)。
+-- heteroscedastic (例 @exp(g0 + g1·z_i)@) は名前が全行同一・データ定数だけ行で
+-- 違う形なので、 このキーで 1 グループに揃い 'unifyMany' が UC 列に持ち上げる。
+sexpKeyNamed :: SExp -> String
+sexpKeyNamed (SC _)     = "c"
+sexpKeyNamed (SV n)     = "v:" ++ T.unpack n
+sexpKeyNamed (S1 o e)   = show o ++ '(' : sexpKeyNamed e ++ ")"
+sexpKeyNamed (S2 o a b) =
+  show o ++ '(' : sexpKeyNamed a ++ ',' : sexpKeyNamed b ++ ")"
+
+-- | scalar 'Observe' 行の分布部 (Phase 55.4)。 IR 化対象の分布のみ。
+--
+-- ★分布追加チェックリスト (Phase 56.1 転記・1 分布 = 6 箇所・1 commit):
+--   1. 'collectSymRows' に Observe 分岐 (+観測値定義域チェック → 域外行を含む
+--      グループは収集時に弾く = walk の -∞ 縮退を残す安全方向)
+--   2. 'keyOf' に family タグ (位置-尺度系は scale 側を 'sexpKeyNamed')
+--   3. 'tryGroup' の unify 分岐
+--   4. 'VecGroupSrc' / 'VecObsIR' ctor (+NFData) + 観測値定数の compile 時前計算
+--   5. 密度式 + 値 guard ('logDensityObs' の該当分岐と完全一致。 56.2 後は
+--      densityIR の式のみ・勾配は記号微分で自動)
+--   6. test: 吸収確認 + 値 1e-9 + 勾配 ad 1e-9 + 中心差分 1e-4 + fallback 確認。
+--      probe 点 (0.5/1.3) の定義域を分布別に確認 (link 経由は構造上域内・
+--      パラメタ latent 直で域外なら fallback = 既知制限)
+data SymDist
+  = SDGauss SExp SExp   -- ^ Normal μ σ (σ は任意式・55.3)
+  | SDPois  SExp        -- ^ Poisson λ (λ は任意式・GLM log link は exp が式に入る)
+  | SDBern  SExp        -- ^ Bernoulli p (同・invLogit が式に入る)
+  | SDStudT !Double SExp SExp
+    -- ^ StudentT ν μ σ (56.3。 ν は SC 定数のみ吸収 = lgamma 項が定数化。
+    -- ν latent は fallback・計画の scope どおり)
+  | SDCauchy SExp SExp  -- ^ Cauchy x₀ γ (56.3)
+  | SDLogis SExp SExp   -- ^ Logistic μ s (56.3)
+  | SDGumbel SExp SExp  -- ^ Gumbel μ β (56.3)
+  | SDExpo SExp         -- ^ Exponential rate (56.4。 y ≥ 0 は収集時に確認)
+  | SDWeib SExp SExp    -- ^ Weibull k λ (56.4。 y > 0 は収集時に確認)
+  | SDLogN SExp SExp    -- ^ LogNormal μ σ (56.4。 y > 0 は収集時に確認)
+  | SDGamma SExp SExp   -- ^ Gamma α rate (56.4。 y > 0 は収集時に確認)
+  | SDBeta SExp SExp    -- ^ Beta α β (56.4。 0 < y < 1 は収集時に確認)
+  | SDBinom !Int SExp   -- ^ Binomial n p (56.5。 n は ctor 定数・
+                        --   0 ≤ round y ≤ n は収集時に確認)
+  | SDGeom SExp         -- ^ Geometric p (56.5。 round y ≥ 1 は収集時に確認)
+  | SDNegBin SExp SExp  -- ^ NegativeBinomial μ α (56.5。 y ≥ 0 は収集時に確認)
+  | SDMixNorm2 SExp SExp SExp SExp SExp SExp
+    -- ^ Mixture [w1,w2] [Normal μ1 σ1, Normal μ2 σ2] (Phase 90 A3。 2成分
+    -- Normal混合限定 — 任意分布族・K成分への一般化は対象外。 w1 w2 は
+    -- 'Distribution.hs' の Mixture 定義どおり Σw で自動正規化するので
+    -- w1+w2=1 を仮定しない (w1 w2 μ1 σ1 μ2 σ2)。
+  | SDZIBinom !Int SExp SExp
+    -- ^ ZeroInflatedBinomial n ψ p (Phase 90 A3。 n は ctor 定数・
+    -- 0 ≤ round y ≤ n は収集時に確認)
+
+-- | model を 'SExp' で walk し、 scalar @Observe@ 行 (Observe 名, 分布部, 観測値)
+-- と latent prior を集める ('collectAffRows' の 54.11 版)。 Phase 55.3 で σ を
+-- 任意式に、 55.4 で Normal 限定 → Poisson / Bernoulli にも拡張。 他のノードは
+-- 素通し (residual walk に残す)。
+collectSymRows
+  :: Model SExp r
+  -> ([(Text, SymDist, Double)], Map Text (Distribution SExp))
+collectSymRows = go [] Map.empty
+  where
+    go rows priors (Pure _) = (reverse rows, priors)
+    go rows priors (Free f) = case f of
+      Sample n d k -> go rows (Map.insert n d priors) (k (SV n))
+      Observe nm (Normal mu sg) ys next ->
+        go ([ (nm, SDGauss mu sg, y) | y <- ys ] ++ rows) priors next
+      Observe nm (Poisson lam) ys next ->
+        go ([ (nm, SDPois lam, y) | y <- ys ] ++ rows) priors next
+      Observe nm (Bernoulli p) ys next ->
+        go ([ (nm, SDBern p, y) | y <- ys ] ++ rows) priors next
+      -- 56.3 位置-尺度系 (support = ℝ → 観測値定義域チェック不要)。
+      -- StudentT は ν=SC かつ ν>0 のみ (ν≤0 は walk の -∞ を残す安全方向)。
+      Observe nm (StudentT (SC nu) mu sg) ys next | nu > 0 ->
+        go ([ (nm, SDStudT nu mu sg, y) | y <- ys ] ++ rows) priors next
+      Observe nm (Cauchy loc sc) ys next ->
+        go ([ (nm, SDCauchy loc sc, y) | y <- ys ] ++ rows) priors next
+      Observe nm (Logistic mu s) ys next ->
+        go ([ (nm, SDLogis mu s, y) | y <- ys ] ++ rows) priors next
+      Observe nm (Gumbel mu be) ys next ->
+        go ([ (nm, SDGumbel mu be, y) | y <- ys ] ++ rows) priors next
+      -- 56.4 正値・区間系 (観測値定義域チェックは tryGroup の ysV 検査で)。
+      Observe nm (Exponential rate) ys next ->
+        go ([ (nm, SDExpo rate, y) | y <- ys ] ++ rows) priors next
+      Observe nm (Weibull k lam) ys next ->
+        go ([ (nm, SDWeib k lam, y) | y <- ys ] ++ rows) priors next
+      Observe nm (LogNormal mu sg) ys next ->
+        go ([ (nm, SDLogN mu sg, y) | y <- ys ] ++ rows) priors next
+      Observe nm (Gamma sh rt) ys next ->
+        go ([ (nm, SDGamma sh rt, y) | y <- ys ] ++ rows) priors next
+      Observe nm (Beta al be) ys next ->
+        go ([ (nm, SDBeta al be, y) | y <- ys ] ++ rows) priors next
+      -- 56.5 離散系。
+      Observe nm (Binomial n p) ys next ->
+        go ([ (nm, SDBinom n p, y) | y <- ys ] ++ rows) priors next
+      Observe nm (Geometric p) ys next ->
+        go ([ (nm, SDGeom p, y) | y <- ys ] ++ rows) priors next
+      Observe nm (NegativeBinomial mu al) ys next ->
+        go ([ (nm, SDNegBin mu al, y) | y <- ys ] ++ rows) priors next
+      -- Phase 90 A3: 2成分 Normal 混合限定 (04-low-dim-gauss-mix の
+      -- log_mix(θ, normal_lpdf(μ1,σ1), normal_lpdf(μ2,σ2)) と同型)。
+      -- 任意分布族・3成分以上は対象外 (素通し → residual walk+ad)。
+      Observe nm (Mixture [w1, w2] [Normal mu1 sg1, Normal mu2 sg2]) ys next ->
+        go ([ (nm, SDMixNorm2 w1 w2 mu1 sg1 mu2 sg2, y) | y <- ys ] ++ rows) priors next
+      Observe nm (ZeroInflatedBinomial n psi p) ys next ->
+        go ([ (nm, SDZIBinom n psi p, y) | y <- ys ] ++ rows) priors next
+      Observe _ _ _ next  -> go rows priors next
+      ObserveLM _ _ _ _ _ _ next -> go rows priors next
+      Potential _ _ next  -> go rows priors next
+      Deterministic _ v k -> go rows priors (k v)
+      Data _ ys k         -> go rows priors (k (map realToFrac ys, ys))
+      DataIx _ is k       -> go rows priors (k is)
+      PlateBegin _ _ next -> go rows priors next
+      PlateEnd next       -> go rows priors next
+
+-- | model を 'SExp' で walk し、 raw 'Potential' の (名前, 式) を出現順に
+-- 集める (Phase 90 A10)。 'collectSymRows' と同じ給餌 (latent = @SV n@)。
+collectSymPots :: Model SExp r -> [(Text, SExp)]
+collectSymPots = go []
+  where
+    go acc (Pure _) = reverse acc
+    go acc (Free f) = case f of
+      Sample n _ k        -> go acc (k (SV n))
+      Observe _ _ _ next  -> go acc next
+      ObserveLM _ _ _ _ _ _ next -> go acc next
+      Potential nm v next -> go ((nm, v) : acc) next
+      Deterministic _ v k -> go acc (k v)
+      Data _ ys k         -> go acc (k (map realToFrac ys, ys))
+      DataIx _ is k       -> go acc (k is)
+      PlateBegin _ _ next -> go acc next
+      PlateEnd next       -> go acc next
+
+-- ===========================================================================
+-- Phase 98 A2: 残余 log-joint の flat compile (Free AST 再解釈の廃止)
+-- ===========================================================================
+-- 'logJointExclBlocks' (Gradient.hs) は excl 吸収後の残余 log-density を求める
+-- ため 'Model a r' の Free 構造を毎勾配評価で頭から walk する。 vecIR arena に
+-- 吸収し切れない項 (例 06-irt-2pl: `a` の LogNormal 事前分布) が残るモデルでは、
+-- 大量の吸収済み Observe plate まで「継続のため素通り walk」する純オーバーヘッド
+-- が支配する (Phase 98 A1c prof: logJointExclBlocks = 31.5% time / 41.7% alloc・
+-- Free monad `>>=`/`fmap` が十数億 entry)。
+--
+-- 本 IR は残余を **1 度の symbolic walk で flat 化**し ('CompiledResidual')、 全
+-- leapfrog で「非吸収項の畳み込み」だけを行う (Free walk 廃止)。 'CompiledLMBlock'
+-- の残余版に相当。 'SExp' 保持の純データなので値 (Double) と勾配 (AD 型) の双方で
+-- 共有できる ('residualValueA' が多相)。
+
+-- | 残余 log-joint の非吸収項を出現順に flat 化した中間表現。
+data CompiledResidual = CompiledResidual
+  { crPriors :: ![(Text, Distribution SExp)]     -- ^ 非吸収 Sample: logDensity d (params!n)
+  , crObs    :: ![(Distribution SExp, [Double])] -- ^ 非吸収 Observe: obsLogSum d ys
+  , crPots   :: ![SExp]                          -- ^ 非吸収 Potential: 式値
+  }
+
+-- | 'sUnF' の 'Floating' 一般化 (density IR 専用の 'SLgammaO' を除く — SLgammaO は
+-- 'Floating SExp' インスタンス経由では現れず 'Distribution SExp' に入らない)。
+sUnG :: Floating a => SUn -> a -> a
+sUnG SNegO    = negate
+sUnG SAbsO    = abs
+sUnG SSignumO = signum
+sUnG SExpO    = exp
+sUnG SLogO    = log
+sUnG SSqrtO   = sqrt
+sUnG SRecipO  = recip
+sUnG SSinO    = sin
+sUnG SCosO    = cos
+sUnG STanO    = tan
+sUnG SAsinO   = asin
+sUnG SAcosO   = acos
+sUnG SAtanO   = atan
+sUnG SSinhO   = sinh
+sUnG SCoshO   = cosh
+sUnG STanhO   = tanh
+sUnG SAsinhO  = asinh
+sUnG SAcoshO  = acosh
+sUnG SAtanhO  = atanh
+sUnG SLgammaO = error "sUnG: SLgammaO は残余 SExp には現れない (compileResidual の不変条件)"
+
+-- | 'sBinF' の 'Floating'+'Ord' 一般化。
+sBinG :: (Floating a, Ord a) => SBin -> a -> a -> a
+sBinG SAddO = (+)
+sBinG SSubO = (-)
+sBinG SMulO = (*)
+sBinG SDivO = (/)
+sBinG SMaxO = max
+
+-- | 'SExp' を任意の 'Floating' 型で評価する (latent 参照は @lookupVar@ 経由)。
+-- 'CompiledResidual' の per-eval 評価に使う (SExp 木の畳み込み・Free walk 無し)。
+evalSExpA :: (Floating a, Ord a) => (Text -> a) -> SExp -> a
+evalSExpA lookupVar = ev
+  where
+    ev (SC x)     = realToFrac x
+    ev (SV n)     = lookupVar n
+    ev (S1 o e)   = sUnG o (ev e)
+    ev (S2 o a b) = sBinG o (ev a) (ev b)
+
+-- | 残余 (excl 吸収後) を 1 度の symbolic walk で 'CompiledResidual' に flat 化。
+-- compiled 経路で忠実再現できない残余 (非吸収 'ObserveLM') があれば 'Nothing' を
+-- 返し、 呼び出し側は従来の 'logJointExclBlocks' walk に fallback する。
+-- 'Deterministic'/'Data' は walk 時に 'SExp' へインライン展開されるので収集式の
+-- 'SV' は必ず sampled latent を指す (per-eval の params に存在)。
+compileResidual :: Set Text -> Model SExp r -> Maybe CompiledResidual
+compileResidual excl = go [] [] []
+  where
+    go ps os pots (Pure _) =
+      Just (CompiledResidual (reverse ps) (reverse os) (reverse pots))
+    go ps os pots (Free f) = case f of
+      Sample n d k
+        | n `Set.member` excl -> go ps os pots (k (SV n))
+        | otherwise           -> go ((n, d) : ps) os pots (k (SV n))
+      Observe n d ys next
+        | n `Set.member` excl -> go ps os pots next
+        | otherwise           -> go ps ((d, ys) : os) pots next
+      ObserveLM nm _ _ _ _ _ next
+        | nm `Set.member` excl -> go ps os pots next
+        | otherwise            -> Nothing   -- 非吸収 ObserveLM は flat 化不可 → fallback
+      Potential n v next
+        | n `Set.member` excl -> go ps os pots next
+        | otherwise           -> go ps os (v : pots) next
+      Deterministic _ v k -> go ps os pots (k v)
+      Data _ ys k         -> go ps os pots (k (map realToFrac ys, ys))
+      DataIx _ is k       -> go ps os pots (k is)
+      PlateBegin _ _ next -> go ps os pots next
+      PlateEnd next       -> go ps os pots next
+
+-- | 'CompiledResidual' の per-eval 評価 (Free walk 無し・flat list の畳み込み)。
+-- 'logJointExclBlocks excl m params' と同値 (同じ 'logDensity'/'obsLogSum'・同じ
+-- params)。 sampled latent が params に無い場合は 'logJointExclBlocks' と同じく
+-- -∞ (安全網)。
+residualValueA :: (Floating a, Ord a) => CompiledResidual -> Map Text a -> a
+residualValueA cr params = priorSum + obsSum + potSum
+  where
+    ev = evalSExpA (\n -> Map.findWithDefault 0 n params)
+    priorSum = sum [ case Map.lookup n params of
+                       Nothing -> negInf
+                       Just v  -> logDensity (fmap ev d) v
+                   | (n, d) <- crPriors cr ]
+    obsSum   = sum [ obsLogSum (fmap ev d) ys | (d, ys) <- crObs cr ]
+    potSum   = sum [ ev v | v <- crPots cr ]
+
+-- | ベクトル式 IR。 'unifyMany' が行ごとの 'SExp' を束ねた結果で、 leaf は
+-- スカラ (行に依らない) かベクトル (行ごとに値が違う) のいずれか。
+data UExp
+  = UK !Double                  -- ^ 全行同一の定数 (スカラ)
+  | UC !(VS.Vector Double)      -- ^ 行ごとの定数列 (データ列・長さ n)
+  | UV !Text                    -- ^ 全行同一の latent (スカラ・broadcast)
+  | UG ![Text] !(VU.Vector Int) -- ^ 族 gather: 行 i は member[gids_i] (長さ n)
+  | U1 !SUn UExp
+  | U2 !SBin UExp UExp
+  | USum UExp                   -- ^ Σ (ベクトル → スカラ)。 Phase 90 A10:
+                                --   raw potential 内の同型 Σ チェーンの
+                                --   ベクトル化に使う ('absorbPot')。 中身は
+                                --   行依存 ('uexpIsVec') であること
+
+instance NFData UExp where
+  rnf (UK v)     = rnf v
+  rnf (UC v)     = v `seq` ()
+  rnf (UV n)     = rnf n
+  rnf (UG ms g)  = rnf ms `seq` g `seq` ()
+  rnf (U1 o e)   = o `seq` rnf e
+  rnf (U2 o a b) = o `seq` rnf a `seq` rnf b
+  rnf (USum e)   = rnf e
+
+-- | 行ごとのスカラ式を 1 本のベクトル式に持ち上げる (形状照合)。
+-- 演算子木が全行同型で、 leaf が「全行 SC」「全行同一 SV」「行ごとに違う SV
+-- (→ 族 gather 候補)」 のいずれかに揃う場合のみ成功。
+unifyMany :: [SExp] -> Maybe UExp
+unifyMany []          = Nothing
+unifyMany es@(e0 : _) = case e0 of
+  SC _ -> do
+    vs <- mapM (\e -> case e of SC v -> Just v; _ -> Nothing) es
+    Just $ case vs of
+      (v : rest) | all (== v) rest -> UK v
+      _                            -> UC (VS.fromList vs)
+  SV _ -> do
+    ns <- mapM (\e -> case e of SV n -> Just n; _ -> Nothing) es
+    Just $ case ns of
+      (n0 : rest) | all (== n0) rest -> UV n0
+      _ ->
+        let mems = Set.toAscList (Set.fromList ns)
+            ixm  = Map.fromList (zip mems [0 :: Int ..])
+        in UG mems (VU.fromList [ ixm Map.! n | n <- ns ])
+  S1 o _ -> do
+    cs <- mapM (\e -> case e of S1 o' c | o' == o -> Just c; _ -> Nothing) es
+    U1 o <$> unifyMany cs
+  S2 o _ _ -> do
+    ps <- mapM (\e -> case e of S2 o' a b | o' == o -> Just (a, b); _ -> Nothing) es
+    U2 o <$> unifyMany (map fst ps) <*> unifyMany (map snd ps)
+
+-- | IR 中のスカラ latent 参照 (出現順・重複あり)。
+uexpScalNames :: UExp -> [Text]
+uexpScalNames (UV n)     = [n]
+uexpScalNames (U1 _ e)   = uexpScalNames e
+uexpScalNames (U2 _ a b) = uexpScalNames a ++ uexpScalNames b
+uexpScalNames (USum e)   = uexpScalNames e
+uexpScalNames _          = []
+
+-- | IR 中の族 gather の member リスト (出現順・重複あり)。
+uexpFamilies :: UExp -> [[Text]]
+uexpFamilies (UG ms _)   = [ms]
+uexpFamilies (U1 _ e)    = uexpFamilies e
+uexpFamilies (U2 _ a b)  = uexpFamilies a ++ uexpFamilies b
+uexpFamilies (USum e)    = uexpFamilies e
+uexpFamilies _           = []
+
+-- | 式が行依存 (ベクトル形) か ('ruIsVec' の 'UExp' 版・Phase 90 A10)。
+-- 'USum' は Σ 済みなのでスカラ。
+-- Phase 104: 'ruIsVec' と同じ共有無視走査の残党 (absorbPot 経路で同じ指数
+-- 爆発があり得る) のため、同時に StableName memo walk 化 (詳細は 'ruIsVec')。
+uexpIsVec :: UExp -> Bool
+uexpIsVec e0 = unsafePerformIO $ do
+  memo <- newIORef IM.empty
+  let go x0 = do
+        x  <- evaluate x0
+        sn <- makeStableName x
+        let h = hashStableName sn
+        mm <- readIORef memo
+        case lookup sn =<< IM.lookup h mm of
+          Just r  -> pure r
+          Nothing -> do
+            r <- case x of
+              UC _     -> pure True
+              UG _ _   -> pure True
+              U1 _ e   -> go e
+              U2 _ a b -> (||) <$> go a <*> go b
+              _        -> pure False
+            modifyIORef' memo (IM.insertWith (++) h [(sn, r)])
+            pure r
+  go e0
+{-# NOINLINE uexpIsVec #-}
+
+-- | 出現順を保つ重複排除。
+ordNubO :: Ord a => [a] -> [a]
+ordNubO = go Set.empty
+  where
+    go _ [] = []
+    go seen (x : xs)
+      | x `Set.member` seen = go seen xs
+      | otherwise           = x : go (Set.insert x seen) xs
+
+-- | IR グループ (unify 後・compile 前)。 family ごとに観測密度の組み方が違う
+-- (Phase 55.4 で Gaussian 限定 → Poisson / Bernoulli を追加)。
+data VecGroupSrc
+  = VGGauss !UExp !UExp !(VS.Vector Double)  -- ^ μ IR, σ IR, ys
+  | VGPois  !UExp !(VS.Vector Double)        -- ^ λ IR, ys (全行 y ≥ 0 を確認済)
+  | VGBern  !UExp !(VS.Vector Double)        -- ^ p IR, ys (全行 round y ∈ {0,1})
+  | VGStudT !Double !UExp !UExp !(VS.Vector Double)
+    -- ^ ν (SC 定数), μ IR, σ IR, ys (56.3)
+  | VGCauchy !UExp !UExp !(VS.Vector Double)  -- ^ x₀ IR, γ IR, ys (56.3)
+  | VGLogis !UExp !UExp !(VS.Vector Double)   -- ^ μ IR, s IR, ys (56.3)
+  | VGGumbel !UExp !UExp !(VS.Vector Double)  -- ^ μ IR, β IR, ys (56.3)
+  | VGExpo !UExp !(VS.Vector Double)          -- ^ rate IR, ys (全行 y ≥ 0・56.4)
+  | VGWeib !UExp !UExp !(VS.Vector Double)    -- ^ k IR, λ IR, ys (全行 y > 0・56.4)
+  | VGLogN !UExp !UExp !(VS.Vector Double)    -- ^ μ IR, σ IR, ys (全行 y > 0・56.4)
+  | VGGamma !UExp !UExp !(VS.Vector Double)   -- ^ α IR, rate IR, ys (全行 y > 0・56.4)
+  | VGBeta !UExp !UExp !(VS.Vector Double)    -- ^ α IR, β IR, ys (全行 0<y<1・56.4)
+  | VGBinom !(VS.Vector Double) !UExp !(VS.Vector Double)
+    -- ^ n 列 (行対応), p IR, ys (0≤k≤n・56.5。 Phase 94 で n を行対応 Vector 化 =
+    -- n 別の group 分裂を解消し 1 group にまとめる)
+  | VGGeom !UExp !(VS.Vector Double)          -- ^ p IR, ys (round y ≥ 1・56.5)
+  | VGNegBin !UExp !UExp !(VS.Vector Double)  -- ^ μ IR, α IR, ys (y ≥ 0・56.5)
+  | VGMixNorm2 !UExp !UExp !UExp !UExp !UExp !UExp !(VS.Vector Double)
+    -- ^ w1 IR, w2 IR, μ1 IR, σ1 IR, μ2 IR, σ2 IR, ys (Phase 90 A3・2成分限定)
+  | VGZIBinom !(VS.Vector Double) !UExp !UExp !(VS.Vector Double)
+    -- ^ n 列 (行対応), ψ IR, p IR, ys (0≤k≤n・Phase 90 A3。 Phase 94 で n を
+    -- 行対応 Vector 化)
+  | VGPot !UExp
+    -- ^ raw `potential` 項 (Phase 90 A10)。 scalar 形の UExp (内部の同型
+    -- Σ チェーンは 'USum' でベクトル化済・'absorbPot')。 値 = 式そのもの
+    -- (ys なし・guard なし = walk の 'Potential' 加算と同値)
+
+instance NFData VecGroupSrc where
+  rnf (VGGauss u sg ys)    = rnf u `seq` rnf sg `seq` ys `seq` ()
+  rnf (VGPois u ys)        = rnf u `seq` ys `seq` ()
+  rnf (VGBern u ys)        = rnf u `seq` ys `seq` ()
+  rnf (VGStudT nu u sg ys) = nu `seq` rnf u `seq` rnf sg `seq` ys `seq` ()
+  rnf (VGCauchy u sc ys)   = rnf u `seq` rnf sc `seq` ys `seq` ()
+  rnf (VGLogis u s ys)     = rnf u `seq` rnf s `seq` ys `seq` ()
+  rnf (VGGumbel u be ys)   = rnf u `seq` rnf be `seq` ys `seq` ()
+  rnf (VGExpo u ys)        = rnf u `seq` ys `seq` ()
+  rnf (VGWeib k u ys)      = rnf k `seq` rnf u `seq` ys `seq` ()
+  rnf (VGLogN u sg ys)     = rnf u `seq` rnf sg `seq` ys `seq` ()
+  rnf (VGGamma sh u ys)    = rnf sh `seq` rnf u `seq` ys `seq` ()
+  rnf (VGBeta al u ys)     = rnf al `seq` rnf u `seq` ys `seq` ()
+  rnf (VGBinom nv u ys)    = nv `seq` rnf u `seq` ys `seq` ()
+  rnf (VGGeom u ys)        = rnf u `seq` ys `seq` ()
+  rnf (VGNegBin u al ys)   = rnf u `seq` rnf al `seq` ys `seq` ()
+  rnf (VGMixNorm2 w1 w2 m1 s1 m2 s2 ys) =
+    rnf w1 `seq` rnf w2 `seq` rnf m1 `seq` rnf s1 `seq` rnf m2 `seq`
+    rnf s2 `seq` ys `seq` ()
+  rnf (VGZIBinom nv psi p ys) = nv `seq` rnf psi `seq` rnf p `seq` ys `seq` ()
+  rnf (VGPot u)              = rnf u
+
+-- | 'synthVecIR' の結果: (グループ列, 族 prior (members, m, τ),
+-- 吸収した scalar Observe / raw potential 名集合 = residual walk から
+-- 除外すべき名前)。 σ は Phase 55.3 から 'UExp'
+-- (スカラ式なら値はスカラ・UC を含む行依存式なら heteroscedastic ベクトル密度)。
+type VecIRSrc =
+  ( [VecGroupSrc]
+  , [([Text], SExp, SExp)]
+  , Set Text )
+
+-- ===========================================================================
+-- Phase 90 A8: 式 DAG 化 (共有保存 hash-consing) — synthVecIR 指数ハングの根治
+-- ===========================================================================
+--
+-- 従来の合成解析 ('sexpShape'/'sexpVars'/'unifyMany'/'rnf' 等) は 'SExp' を
+-- 素朴な木として walk していたが、 ユーザコードの let 共有 (RK4 等の逐次再帰で
+-- 前状態を複数回参照する形) を無視すると訪問回数が「経路数」 (深さに対し指数)
+-- に比例して爆発する (A6 実測: RK4 深さ5で DAG 352 ノード vs 経路 6.8×10¹¹)。
+-- ここでは StableName (heap 同一性) + 構造 intern (hash-consing) で式を一度
+-- だけ明示的 DAG (ノード表 + ID) に変換し、 以後の解析を全て ID ベース
+-- O(distinct ノード数) で行う。 形状クラス・自由変数集合はノード生成時に
+-- bottom-up で確定する (子 ID は常に親より先に intern 済み)。
+
+-- | 'SExp' の DAG ノード (子は intern 済み ID)。 構造 intern のキー =
+-- 「構造が等しい ⇔ ID が等しい」 が成立する ('sexpEq'/'sexpKeyNamed' の代替)。
+data SNode = NC !Double | NV !Text | N1 !SUn !Int | N2 !SBin !Int !Int
+  deriving (Eq, Ord)
+
+-- | latent 名を消した形状クラスのキー ('sexpShape' の代替。 SV は全て KV に
+-- 潰れる = 名前違いの行が同一形状クラスに揃い族 gather 候補になる)。
+data ShapeKey = KC | KV | K1 !SUn !Int | K2 !SBin !Int !Int
+  deriving (Eq, Ord)
+
+-- | 名前付き指紋のキー ('sexpKeyNamed' の代替)。 SV は latent 名を保持・
+-- **SC は値を無視して同一クラスに潰す** (行ごとに違うデータ定数だけの σ 式を
+-- 1 グループに束ね、 unify が UC 列へ持ち上げる Phase 55.3 仕様)。 構造
+-- intern ID ('sexpEq' 相当・定数値まで厳密) とは役割が違う点に注意。
+data NamedKey = MC | MV !Text | M1 !SUn !Int | M2 !SBin !Int !Int
+  deriving (Eq, Ord)
+
+-- | intern 状態。 memo は 2 段: heap 同一性 (StableName・共有 thunk の再走査
+-- 防止) と構造 ('SNode'・等価だが別 heap の部分式を同一 ID に合流)。
+data SDagSt = SDagSt
+  { sdStable :: !(IM.IntMap [(StableName SExp, Int)])
+  , sdStruct :: !(Map SNode Int)
+  , sdNodes  :: !(IM.IntMap SNode)             -- ^ ID → ノード
+  , sdShapes :: !(Map ShapeKey Int)            -- ^ 形状 intern
+  , sdShape  :: !(IM.IntMap Int)               -- ^ ID → 形状クラス ID
+  , sdNamedKs :: !(Map NamedKey Int)           -- ^ 名前付き指紋 intern
+  , sdNamed  :: !(IM.IntMap Int)               -- ^ ID → 名前付き指紋 ID
+  , sdVars   :: !(IM.IntMap (Set Text))        -- ^ ID → 自由 latent 集合
+  , sdNext   :: !Int
+  , sdUnify  :: !(Map [Int] UExp)
+    -- ^ unify memo (ID 列 → 'UExp')。 **全 group 共有** = 同一部分式列は
+    -- 同一 'UExp' heap オブジェクトに合流し、 出力も共有付き DAG になる
+    -- (garch11 のような group 跨ぎ共有が下流 'compileVecIR' の identity
+    -- memo で 1 回だけコンパイルされるために必須)。
+  }
+
+newSDag :: IO (IORef SDagSt)
+newSDag = newIORef (SDagSt IM.empty Map.empty IM.empty Map.empty
+                            IM.empty Map.empty IM.empty IM.empty 0 Map.empty)
+
+sdagNodeOf :: SDagSt -> Int -> SNode
+sdagNodeOf st i = sdNodes st IM.! i
+
+sdagShapeOf :: SDagSt -> Int -> Int
+sdagShapeOf st i = sdShape st IM.! i
+
+sdagNamedOf :: SDagSt -> Int -> Int
+sdagNamedOf st i = sdNamed st IM.! i
+
+sdagVarsOf :: SDagSt -> Int -> Set Text
+sdagVarsOf st i = sdVars st IM.! i
+
+-- | 'SExp' を DAG に intern して ID を返す。 各 heap ノードの訪問は 1 回
+-- (StableName memo)・poison ('symPoison' 等の error thunk) はここで顕在化
+-- する ('synthVecIR' の try が捕捉する範囲内で呼ぶこと)。
+internS :: IORef SDagSt -> SExp -> IO Int
+internS ref = go
+  where
+    go e0 = do
+      e  <- evaluate e0
+      sn <- makeStableName e
+      let h = hashStableName sn
+      st <- readIORef ref
+      case lookup sn =<< IM.lookup h (sdStable st) of
+        Just i  -> pure i
+        Nothing -> do
+          nd <- case e of
+            SC v     -> pure (NC v)
+            SV n     -> pure (NV n)
+            S1 o a   -> N1 o <$> go a
+            S2 o a b -> N2 o <$> go a <*> go b
+          st1 <- readIORef ref
+          i <- case Map.lookup nd (sdStruct st1) of
+            Just j  -> pure j
+            Nothing -> do
+              let j     = sdNext st1
+                  shKey = case nd of
+                    NC _     -> KC
+                    NV _     -> KV
+                    N1 o a   -> K1 o (sdShape st1 IM.! a)
+                    N2 o a b -> K2 o (sdShape st1 IM.! a) (sdShape st1 IM.! b)
+                  (shId, shapes') = case Map.lookup shKey (sdShapes st1) of
+                    Just s  -> (s, sdShapes st1)
+                    Nothing -> let s = Map.size (sdShapes st1)
+                               in (s, Map.insert shKey s (sdShapes st1))
+                  nmKey = case nd of
+                    NC _     -> MC
+                    NV n     -> MV n
+                    N1 o a   -> M1 o (sdNamed st1 IM.! a)
+                    N2 o a b -> M2 o (sdNamed st1 IM.! a) (sdNamed st1 IM.! b)
+                  (nmId, nameds') = case Map.lookup nmKey (sdNamedKs st1) of
+                    Just s  -> (s, sdNamedKs st1)
+                    Nothing -> let s = Map.size (sdNamedKs st1)
+                               in (s, Map.insert nmKey s (sdNamedKs st1))
+                  vs = case nd of
+                    NC _     -> Set.empty
+                    NV n     -> Set.singleton n
+                    N1 _ a   -> sdVars st1 IM.! a
+                    N2 _ a b -> (sdVars st1 IM.! a) `Set.union` (sdVars st1 IM.! b)
+              writeIORef ref st1
+                { sdStruct = Map.insert nd j (sdStruct st1)
+                , sdNodes  = IM.insert j nd (sdNodes st1)
+                , sdShapes = shapes'
+                , sdShape  = IM.insert j shId (sdShape st1)
+                , sdNamedKs = nameds'
+                , sdNamed  = IM.insert j nmId (sdNamed st1)
+                , sdVars   = IM.insert j vs (sdVars st1)
+                , sdNext   = j + 1 }
+              pure j
+          modifyIORef' ref $ \s ->
+            s { sdStable = IM.insertWith (++) h [(sn, i)] (sdStable s) }
+          pure i
+
+-- | 'unifyMany' の DAG 版: 行ごとの ID で lockstep 再帰し、 位置 (= ID 列)
+-- ごとに結果 'UExp' を memo する。 同一 ID 列は同一 'UExp' オブジェクトに
+-- 合流するので出力も共有付き DAG (leaf 判定・失敗条件は 'unifyMany' と同一)。
+unifyManyD :: IORef SDagSt -> [SExp] -> IO (Maybe UExp)
+unifyManyD ref es = mapM (internS ref) es >>= goIds
+  where
+    goIds [] = pure Nothing
+    goIds is = do
+      st <- readIORef ref
+      case Map.lookup is (sdUnify st) of
+        Just u  -> pure (Just u)
+        Nothing -> do
+          mu <- case map (sdagNodeOf st) is of
+            nds@(NC _ : _) -> pure $ do
+              vs <- mapM (\n -> case n of NC v -> Just v; _ -> Nothing) nds
+              Just $ case vs of
+                (v : rest) | all (== v) rest -> UK v
+                _                            -> UC (VS.fromList vs)
+            nds@(NV _ : _) -> pure $ do
+              ns <- mapM (\n -> case n of NV nm -> Just nm; _ -> Nothing) nds
+              Just $ case ns of
+                (n0 : rest) | all (== n0) rest -> UV n0
+                _ ->
+                  let mems = Set.toAscList (Set.fromList ns)
+                      ixm  = Map.fromList (zip mems [0 :: Int ..])
+                  in UG mems (VU.fromList [ ixm Map.! n | n <- ns ])
+            nds@(N1 o _ : _) ->
+              case mapM (\n -> case n of N1 o' c | o' == o -> Just c
+                                         _                 -> Nothing) nds of
+                Nothing -> pure Nothing
+                Just cs -> fmap (U1 o) <$> goIds cs
+            nds@(N2 o _ _ : _) ->
+              case mapM (\n -> case n of N2 o' a b | o' == o -> Just (a, b)
+                                         _                   -> Nothing) nds of
+                Nothing -> pure Nothing
+                Just ps -> do
+                  ma <- goIds (map fst ps)
+                  case ma of
+                    Nothing -> pure Nothing
+                    Just ua -> fmap (U2 o ua) <$> goIds (map snd ps)
+            [] -> pure Nothing
+          case mu of
+            Nothing -> pure Nothing
+            Just u  -> do
+              u' <- evaluate u
+              modifyIORef' ref $ \s -> s { sdUnify = Map.insert is u' (sdUnify s) }
+              pure (Just u')
+
+-- | 'UExp' の scalar leaf 名と族 gather member リストを**初出順**で収集する
+-- ('uexpScalNames'/'uexpFamilies' の共有保存版)。 memo (visited 集合) を
+-- IORef で外から渡し、 複数式・複数 group を跨いで 1 本の memo で走る =
+-- 共有部分式は 1 回だけ訪問。 収集結果を 'ordNubO' に掛ける用途では
+-- スキップされた再訪問分は重複除去されるだけなので結果は木 walk と一致する。
+uexpLeavesIO :: IORef (IM.IntMap [StableName UExp]) -> UExp
+             -> IO ([Text], [[Text]])
+uexpLeavesIO seenRef = go
+  where
+    go u0 = do
+      u  <- evaluate u0
+      sn <- makeStableName u
+      let h = hashStableName sn
+      seen <- readIORef seenRef
+      if maybe False (elem sn) (IM.lookup h seen)
+        then pure ([], [])
+        else do
+          modifyIORef' seenRef (IM.insertWith (++) h [sn])
+          case u of
+            UK _     -> pure ([], [])
+            UC _     -> pure ([], [])
+            UV n     -> pure ([n], [])
+            UG ms _  -> pure ([], [ms])
+            U1 _ e   -> go e
+            U2 _ a b -> do
+              (s1, f1) <- go a
+              (s2, f2) <- go b
+              pure (s1 ++ s2, f1 ++ f2)
+            USum e   -> go e
+
+-- | 'absorbPot' が 'USum' 化を試みる加算チェーンの最小項数 (Phase 90 A10)。
+-- これ未満の和はスカラ 'U2' 連鎖のまま持つ (コスト無視できる規模)。
+potSumThreshold :: Int
+potSumThreshold = 8
+
+-- | Phase 90 A10: raw `potential` 式を scalar 'UExp' へ吸収する。
+-- 大きな同型加算チェーン (項数 ≥ 'potSumThreshold') は 'unifyManyD' で
+-- ベクトル化して 'USum' へ落とす (チェーン中の定数項は畳んで加算)。
+-- 吸収できない構造 (unify 失敗・行依存にならない縮退 Σ 等) は Nothing =
+-- その potential ごと残差 ad に残す (安全方向・値は walk と同値のまま)。
+-- 走査は StableName memo で共有保存 (A8 の教訓: 素朴な木 walk は共有式で
+-- 指数爆発)。
+absorbPot :: IORef SDagSt
+          -> IORef (IM.IntMap [(StableName SExp, Maybe UExp)])
+          -> SExp -> IO (Maybe UExp)
+absorbPot ref memoRef = go
+  where
+    go e0 = do
+      e  <- evaluate e0
+      sn <- makeStableName e
+      let h = hashStableName sn
+      mm <- readIORef memoRef
+      case lookup sn =<< IM.lookup h mm of
+        Just r  -> pure r
+        Nothing -> do
+          r <- build e
+          modifyIORef' memoRef (IM.insertWith (++) h [(sn, r)])
+          pure r
+    build e = case e of
+      SC v -> pure (Just (UK v))
+      SV n -> pure (Just (UV n))
+      S2 SAddO _ _ -> do
+        terms <- flat e []
+        let (cs, ts) = foldr part (0, []) terms
+            part t (c, acc) = case t of
+              SC v -> (c + v, acc)
+              _    -> (c, t : acc)
+        if length ts >= potSumThreshold
+          then do
+            mu <- unifyManyD ref ts
+            case mu of
+              Just u | uexpIsVec u ->
+                pure (Just (if cs == 0 then USum u
+                            else U2 SAddO (USum u) (UK cs)))
+              -- 巨大チェーンをスカラ連鎖のまま素通しすると compile 側が
+              -- 肥大するため、 unify 不能なら吸収ごと断念 (残差 ad へ)。
+              _ -> pure Nothing
+          else bin e
+      S1 o a -> fmap (U1 o) <$> go a
+      S2 {}  -> bin e
+    bin (S2 o a b) = do
+      ma <- go a
+      case ma of
+        Nothing -> pure Nothing
+        Just ua -> fmap (U2 o ua) <$> go b
+    bin _ = pure Nothing
+    -- 加算 spine の平坦化 (foldl 'sum' 由来の深い左スパイン・O(項数))。
+    flat e0 acc = do
+      e <- evaluate e0
+      case e of
+        S2 SAddO a b -> flat a =<< flat b acc
+        _            -> pure (e : acc)
+
+-- | Phase 54.11: per-obs 手書き scalar 'Observe' 群から「ベクトル式 IR」 を
+-- **自動合成**する ('synthGaussLMBlocks' の非線形版)。 検出できない / 安全網に
+-- 掛かった場合は 'Nothing' (従来経路に fallback)。
+--
+-- 安全網 2 段 (54.8 と同じ): ① 'SExp' の Eq/Ord は非定数比較で error poison →
+-- 'unsafePerformIO' + 'try' で捕捉し全体 fallback (poison は 'internS' の
+-- 走査中に顕在化する)。 async 例外 (timeout / Ctrl-C 等) は fallback にせず
+-- **透過** (Phase 90 A6: 飲み込むとハングの中断が「fallback」に誤報告される
+-- ことを実測確認)。 ② IR の値評価 (観測尤度 + 族 prior) を probe 2 点で
+-- walk 評価 ('obsOnlySum' + 'priorOnlySum') と突合し、 不一致なら fallback。
+synthVecIR :: ModelP r -> Maybe VecIRSrc
+synthVecIR m = unsafePerformIO $ do
+  r <- try (synthVecIRWalkIO m)
+  case r :: Either SomeException VecIRSrc of
+    Left e
+      | Just (SomeAsyncException _) <- fromException e -> throwIO e
+      | otherwise -> pure Nothing
+    Right v@(gs, _, _)
+      | null gs          -> pure Nothing
+      | vecIRProbeOK m v -> pure (Just v)
+      | otherwise        -> pure Nothing
+{-# NOINLINE synthVecIR #-}
+
+-- | 互換 wrapper (旧 pure 版と同じ表面)。 内部は 'synthVecIRWalkIO'。
+synthVecIRWalk :: ModelP r -> VecIRSrc
+synthVecIRWalk = unsafePerformIO . synthVecIRWalkIO
+{-# NOINLINE synthVecIRWalk #-}
+
+-- | 'synthVecIR' の合成部 (walk + 形状照合 + 族抽出)。 Phase 90 A8 で共有保存
+-- DAG ('internS'/'unifyManyD') ベースに全面改修 — 解析は全て ID 経由
+-- O(distinct ノード数) で、 RK4 のような深い自己参照式でも指数爆発しない。
+-- 照合に失敗した σ グループは丸ごと残す (residual ad に fallback・安全方向)。
+-- 結果の式部分は構築時に正格化済み (旧実装の「呼出側が force」 は不要 —
+-- 共有 DAG に rnf を掛けると経路数比例で逆に爆発するため**禁止**)。
+synthVecIRWalkIO :: ModelP r -> IO VecIRSrc
+synthVecIRWalkIO m = do
+  let (rows, priors) = collectSymRows m
+  ref      <- newSDag
+  leafSeen <- newIORef IM.empty
+      -- 族条件: 全 member の prior が構造同一の Normal(m, τ) で、 m/τ が member
+      -- 自身を参照しない (ベクトル化密度 -nG·logτ - Σ(a_j-m)²/(2τ²) が成立する形)。
+      -- 構造同一判定 ('sexpEq' 相当) は intern ID の等値。
+  let famOf ms = case mapM (`Map.lookup` priors) ms of
+        Just ds@(Normal m0 t0 : _) -> do
+          i0 <- internS ref m0
+          j0 <- internS ref t0
+          oks <- forM ds $ \d -> case d of
+            Normal mm tt -> do
+              im <- internS ref mm
+              jt <- internS ref tt
+              pure (im == i0 && jt == j0)
+            _ -> pure False
+          st <- readIORef ref
+          pure $ if and oks
+                    && Set.null ((sdagVarsOf st i0 `Set.union` sdagVarsOf st j0)
+                                 `Set.intersection` Set.fromList ms)
+                 then Just (ms, m0, t0) else Nothing
+        _ -> pure Nothing
+      -- IO 上の Maybe 連結 (MaybeT 相当の局所定義・unify 失敗の短絡用)。
+      mIO >>=? k = mIO >>= maybe (pure Nothing) k
+      okIf cond g = pure (if cond then Just g else Nothing)
+      -- family 別の unify + 観測値の妥当性 (値 guard を walk と一致させるため、
+      -- 観測値側の guard に掛かる行を含むグループは吸収しない = walk が -∞ を
+      -- 返す縮退ケースをそのまま残す安全方向)。
+      tryGroup grows = do
+        let ysV = VS.fromList [ y | (_, _, y) <- grows ]
+        mg <- case [ d | (_, d, _) <- grows ] of
+          ds@(SDGauss{} : _) ->
+            unifyManyD ref [ mu | SDGauss mu _ <- ds ] >>=? \u ->
+            unifyManyD ref [ sg | SDGauss _ sg <- ds ] >>=? \sgU ->
+            pure (Just (VGGauss u sgU ysV))
+          ds@(SDPois{} : _) ->
+            unifyManyD ref [ lam | SDPois lam <- ds ] >>=? \u ->
+            okIf (VS.all (>= 0) ysV) (VGPois u ysV)
+          ds@(SDBern{} : _) ->
+            unifyManyD ref [ p | SDBern p <- ds ] >>=? \u ->
+            okIf (VS.all (\y -> let k = round y :: Int in k == 0 || k == 1) ysV)
+                 (VGBern u ysV)
+          ds@(SDStudT nu _ _ : _) ->
+            unifyManyD ref [ mu | SDStudT _ mu _ <- ds ] >>=? \u ->
+            unifyManyD ref [ sg | SDStudT _ _ sg <- ds ] >>=? \sgU ->
+            pure (Just (VGStudT nu u sgU ysV))
+          ds@(SDCauchy{} : _) ->
+            unifyManyD ref [ loc | SDCauchy loc _ <- ds ] >>=? \u ->
+            unifyManyD ref [ sc | SDCauchy _ sc <- ds ] >>=? \scU ->
+            pure (Just (VGCauchy u scU ysV))
+          ds@(SDLogis{} : _) ->
+            unifyManyD ref [ mu | SDLogis mu _ <- ds ] >>=? \u ->
+            unifyManyD ref [ s | SDLogis _ s <- ds ] >>=? \sU ->
+            pure (Just (VGLogis u sU ysV))
+          ds@(SDGumbel{} : _) ->
+            unifyManyD ref [ mu | SDGumbel mu _ <- ds ] >>=? \u ->
+            unifyManyD ref [ be | SDGumbel _ be <- ds ] >>=? \beU ->
+            pure (Just (VGGumbel u beU ysV))
+          ds@(SDExpo{} : _) ->
+            unifyManyD ref [ rate | SDExpo rate <- ds ] >>=? \u ->
+            okIf (VS.all (>= 0) ysV) (VGExpo u ysV)
+          ds@(SDWeib{} : _) ->
+            unifyManyD ref [ k | SDWeib k _ <- ds ] >>=? \kU ->
+            unifyManyD ref [ lam | SDWeib _ lam <- ds ] >>=? \u ->
+            okIf (VS.all (> 0) ysV) (VGWeib kU u ysV)
+          ds@(SDLogN{} : _) ->
+            unifyManyD ref [ mu | SDLogN mu _ <- ds ] >>=? \u ->
+            unifyManyD ref [ sg | SDLogN _ sg <- ds ] >>=? \sgU ->
+            okIf (VS.all (> 0) ysV) (VGLogN u sgU ysV)
+          ds@(SDGamma{} : _) ->
+            unifyManyD ref [ sh | SDGamma sh _ <- ds ] >>=? \shU ->
+            unifyManyD ref [ rt | SDGamma _ rt <- ds ] >>=? \u ->
+            okIf (VS.all (> 0) ysV) (VGGamma shU u ysV)
+          ds@(SDBeta{} : _) ->
+            unifyManyD ref [ al | SDBeta al _ <- ds ] >>=? \alU ->
+            unifyManyD ref [ be | SDBeta _ be <- ds ] >>=? \u ->
+            okIf (VS.all (\y -> y > 0 && y < 1) ysV) (VGBeta alU u ysV)
+          ds@(SDBinom{} : _) ->
+            unifyManyD ref [ p | SDBinom _ p <- ds ] >>=? \u ->
+            let nsV = VS.fromList [ fromIntegral n | SDBinom n _ <- ds ]
+                -- Phase 94: n を行対応化したので、 各行を自分の n で域内判定
+                -- (旧: 先頭行の n を全行に流用 = merge 前提が単一 n だった)。
+                domOk = and [ let k = round y :: Int in k >= 0 && k <= round nn
+                            | (nn, y) <- zip (VS.toList nsV) (VS.toList ysV) ]
+            in okIf domOk (VGBinom nsV u ysV)
+          ds@(SDGeom{} : _) ->
+            unifyManyD ref [ p | SDGeom p <- ds ] >>=? \u ->
+            okIf (VS.all (\y -> (round y :: Int) >= 1) ysV) (VGGeom u ysV)
+          ds@(SDNegBin{} : _) ->
+            unifyManyD ref [ mu | SDNegBin mu _ <- ds ] >>=? \u ->
+            unifyManyD ref [ al | SDNegBin _ al <- ds ] >>=? \alU ->
+            okIf (VS.all (>= 0) ysV) (VGNegBin u alU ysV)
+          ds@(SDMixNorm2{} : _) ->
+            unifyManyD ref [ w1 | SDMixNorm2 w1 _ _ _ _ _ <- ds ] >>=? \w1U ->
+            unifyManyD ref [ w2 | SDMixNorm2 _ w2 _ _ _ _ <- ds ] >>=? \w2U ->
+            unifyManyD ref [ m1 | SDMixNorm2 _ _ m1 _ _ _ <- ds ] >>=? \m1U ->
+            unifyManyD ref [ s1 | SDMixNorm2 _ _ _ s1 _ _ <- ds ] >>=? \s1U ->
+            unifyManyD ref [ m2 | SDMixNorm2 _ _ _ _ m2 _ <- ds ] >>=? \m2U ->
+            unifyManyD ref [ s2 | SDMixNorm2 _ _ _ _ _ s2 <- ds ] >>=? \s2U ->
+            pure (Just (VGMixNorm2 w1U w2U m1U s1U m2U s2U ysV))
+          ds@(SDZIBinom{} : _) ->
+            unifyManyD ref [ psi | SDZIBinom _ psi _ <- ds ] >>=? \psiU ->
+            unifyManyD ref [ p | SDZIBinom _ _ p <- ds ] >>=? \pU ->
+            let nsV = VS.fromList [ fromIntegral n | SDZIBinom n _ _ <- ds ]
+                domOk = and [ let k = round y :: Int in k >= 0 && k <= round nn
+                            | (nn, y) <- zip (VS.toList nsV) (VS.toList ysV) ]
+            in okIf domOk (VGZIBinom nsV psiU pU ysV)
+          [] -> pure Nothing
+        case mg of
+          Nothing -> pure Nothing
+          Just g  -> do
+            -- Phase 90 A5: family absorb (prior のベクトル化) は likelihood 側の
+            -- vecIR 吸収と独立の最適化。 famOf に失敗した family は fams から
+            -- 単に除外し (absorb しない)、 その prior は既存の `constPriorsOf`
+            -- (`Gradient.hs`) 経由で扱わせる。 leaf 収集の memo (leafSeen) は
+            -- group 跨ぎ共有 — スキップされた再訪問分の family は前の group が
+            -- 同一 (ms, m0, τ0) を famsAll に登録済みなので結果は不変。
+            famLs <- concatMap snd <$> mapM (uexpLeavesIO leafSeen) (vgExprAll g)
+            famRs <- mapM famOf (ordNubO famLs)
+            let fams = [ f | Just f <- famRs ]
+            pure (Just (g, fams, Set.fromList [ nm | (nm, _, _) <- grows ]))
+      -- Phase 55.2-56.3 のグループキー (family タグ + σ 名前付き指紋 + μ 形状)
+      -- を DAG の ID で表現: 名前付き指紋 = 構造 intern ID ('sexpKeyNamed' と
+      -- 同値)、 形状 = 形状クラス ID ('sexpShape' と同値)。 String 指紋は
+      -- 長さが式の展開サイズ (= 経路数) 比例で指数爆発するため廃止 (A6)。
+      key tag named shaped = do
+        nids <- mapM (internS ref) named
+        si   <- internS ref shaped
+        st   <- readIORef ref
+        pure (tag :: String, map (sdagNamedOf st) nids, sdagShapeOf st si)
+      keyOf d = case d of
+        SDGauss mu sg    -> key "g" [sg] mu
+        SDPois  lam      -> key "p" [] lam
+        SDBern  p        -> key "b" [] p
+        SDStudT nu mu sg -> key ("t:" ++ show nu) [sg] mu
+        SDCauchy loc sc  -> key "cy" [sc] loc
+        SDLogis mu s     -> key "lg" [s] mu
+        SDGumbel mu be   -> key "gb" [be] mu
+        SDExpo rate      -> key "e" [] rate
+        SDWeib k lam     -> key "w" [k] lam
+        SDLogN mu sg     -> key "ln" [sg] mu
+        SDGamma sh rt    -> key "ga" [sh] rt
+        SDBeta al be     -> key "be" [al] be
+        SDBinom _ p      -> key "bi:" [] p   -- Phase 94: n を key から除外 (行対応 Vector 化で 1 group に merge)
+        SDGeom p         -> key "ge" [] p
+        SDNegBin mu al   -> key "nb" [al] mu
+        SDMixNorm2 w1 w2 m1 s1 m2 s2 -> key "mx2" [w1, w2, s1, m2, s2] m1
+        SDZIBinom _ psi p -> key "zb:" [psi] p   -- Phase 94: n を key から除外
+  rowsK <- forM rows $ \r@(_, d, _) -> (,) r <$> keyOf d
+  let gkeys = ordNubO (map snd rowsK)
+  cands <- concat <$> forM gkeys (\gk -> do
+             mc <- tryGroup [ r | (r, gk') <- rowsK, gk' == gk ]
+             pure (maybe [] (: []) mc))
+  -- Phase 90 A10: raw potential の吸収。 吸収成功した potential は
+  -- 'VGPot' グループ + 吸収名集合 (第3成分) に合流する。 吸収できない
+  -- potential はここに現れない = 従来どおり残差 ad が担う。
+  -- ★potential の gather は族の**部分集合** member list になり得る
+  -- (icar の node1/node2 等) ため、 leaf family を famOf に掛けると
+  -- Observe 群由来の全体族と member が重複し disjoint チェックで全体
+  -- fallback してしまう。 potential 側では族 prior 吸収を行わない —
+  -- 族に吸収されなかった latent の prior は 'constPriorsOf'
+  -- (`Gradient.hs`) が per-scalar 解析勾配で拾うので残差ゼロは保たれる。
+  potMemo <- newIORef IM.empty
+  potRs <- forM (collectSymPots m) $ \(nm, e) -> do
+    mu <- absorbPot ref potMemo e
+    pure (fmap ((,) nm) mu)
+  let pots = [ p | Just p <- potRs ]
+  let famsAll = Map.toList (Map.fromList
+                  [ (ms, (mx, tx)) | (_, fs, _) <- cands, (ms, mx, tx) <- fs ])
+      famNames = concatMap fst famsAll
+      disjoint = length famNames == Set.size (Set.fromList famNames)
+  evaluate $ if not disjoint
+    then ([], [], Set.empty)   -- 族 member が重複 (二重計上の危険) → 全体 fallback
+    else ( [ g | (g, _, _) <- cands ] ++ [ VGPot u | (_, u) <- pots ]
+         , [ (ms, mx, tx) | (ms, (mx, tx)) <- famsAll ]
+         , Set.unions [ obs | (_, _, obs) <- cands ]
+           `Set.union` Set.fromList (map fst pots) )
+
+-- | グループ中の全 'UExp' フィールド (出現順)。 Phase 90 A3: 従来の
+-- 'vgExpr1'/'vgExpr2' (最大2フィールド限定) を、 Mixture (6フィールド) 等
+-- 任意個数のフィールドを持つ family にも対応できる形に一般化した
+-- (呼び出し側 'compileVecIR' の scalNames/vecLists 収集は 1 パスに統合)。
+vgExprAll :: VecGroupSrc -> [UExp]
+vgExprAll (VGGauss u sg _)      = [u, sg]
+vgExprAll (VGPois u _)          = [u]
+vgExprAll (VGBern u _)          = [u]
+vgExprAll (VGStudT _ u sg _)    = [u, sg]
+vgExprAll (VGCauchy u sc _)     = [u, sc]
+vgExprAll (VGLogis u s _)       = [u, s]
+vgExprAll (VGGumbel u be _)     = [u, be]
+vgExprAll (VGExpo u _)          = [u]
+vgExprAll (VGWeib k u _)        = [u, k]
+vgExprAll (VGLogN u sg _)       = [u, sg]
+vgExprAll (VGGamma sh u _)      = [u, sh]
+vgExprAll (VGBeta al u _)       = [u, al]
+vgExprAll (VGBinom _ u _)       = [u]
+vgExprAll (VGGeom u _)          = [u]
+vgExprAll (VGNegBin u al _)     = [u, al]
+vgExprAll (VGMixNorm2 w1 w2 m1 s1 m2 s2 _) = [w1, w2, m1, s1, m2, s2]
+vgExprAll (VGZIBinom _ psi p _) = [psi, p]
+vgExprAll (VGPot u)             = [u]
+
+-- | グループ中の族 gather member リスト (全 'UExp' フィールドから)。
+vgFamilies :: VecGroupSrc -> [[Text]]
+vgFamilies = concatMap uexpFamilies . vgExprAll
+
+-- | 名前が @sel@ に含まれる raw 'Potential' の値**だけ**を足す walk
+-- (Phase 90 A10 probe 用・'obsOnlySum' の potential 版)。
+potOnlySum :: Set Text -> Model Double r -> Map Text Double -> Double
+potOnlySum sel model params = go model 0
+  where
+    go (Pure _) acc = acc
+    go (Free (Sample n _ k)) acc = go (k (Map.findWithDefault 0 n params)) acc
+    go (Free (Observe _ _ _ next)) acc = go next acc
+    go (Free (ObserveLM _ _ _ _ _ _ next)) acc = go next acc
+    go (Free (Potential n v next)) acc
+      | n `Set.member` sel = go next (acc + v)
+      | otherwise          = go next acc
+    go (Free (Deterministic _ v k)) acc = go (k v) acc
+    go (Free (Data _ ys k)) acc = go (k (ys, ys)) acc
+    go (Free (DataIx _ is k)) acc = go (k is) acc
+    go (Free (PlateBegin _ _ next)) acc = go next acc
+    go (Free (PlateEnd next)) acc = go next acc
+
+-- | 名前が @sel@ に含まれる 'Sample' の prior log-density **だけ**を足す walk
+-- (Phase 54.11 probe 用・'obsOnlySum' の prior 版)。
+priorOnlySum :: Set Text -> Model Double r -> Map Text Double -> Double
+priorOnlySum sel model params = go model 0
+  where
+    go (Pure _) acc = acc
+    go (Free (Sample n d k)) acc =
+      let v = Map.findWithDefault 0 n params
+      in go (k v) (if n `Set.member` sel then acc + logDensity d v else acc)
+    go (Free (Observe _ _ _ next)) acc = go next acc
+    go (Free (ObserveLM _ _ _ _ _ _ next)) acc = go next acc
+    go (Free (Potential _ _ next)) acc = go next acc
+    go (Free (Deterministic _ v k)) acc = go (k v) acc
+    go (Free (Data _ ys k)) acc = go (k (ys, ys)) acc
+    go (Free (DataIx _ is k)) acc = go (k is) acc
+    go (Free (PlateBegin _ _ next)) acc = go next acc
+    go (Free (PlateEnd next)) acc = go next acc
+
+-- | 安全網② (Phase 54.11): IR の値 (観測尤度 + 族 prior) を、 元 model の
+-- walk 評価と probe 2 点で突合する (54.8 'synthProbeOK' と同じ流儀。
+-- probe 値は per-param に変えて係数取り違えも検出・全 latent 正値で guard 安全)。
+vecIRProbeOK :: ModelP r -> VecIRSrc -> Bool
+vecIRProbeOK m (gs, fams, obsNames) = all check [(0.5, 0.07), (1.3, 0.11)]
+  where
+    names  = sampleNames m
+    -- 各 latent の prior 分布 → 制約変換種別。 probe 点を **その latent の台**
+    -- (正値 / 単位区間) に写すため。 素の base+step は有界台の latent
+    -- (Beta 等) で域外になり sqrt(1-pc²) 等が NaN 化 → 誤 fallback していた
+    -- (Phase 80.2)。 'fromUnconstrained' で恒等 / exp / sigmoid を通し常に域内。
+    --
+    -- Phase 90 A3: @base + step·i@ の @i@ は全 latent 通し番号なので、
+    -- 個体ごとの random effect 等で latent 数が多い階層モデル (M=385 等) では
+    -- 添字の大きい latent の probe 値が発散する (例: i=387 → 27.6)。
+    -- unconstrained (Normal 等・変換なし) latent は exp/log の非線形演算を
+    -- 経由すると `exp(-eps)` アンダーフロー → `log(1-p) = -Infinity` →
+    -- `ref - syn = -Inf - (-Inf) = NaN` で誤って probe 不一致 (05-mh で実測
+    -- 発覚)。 通し番号を法 16 で折り返し、 添字が多くても probe 値の広がりを
+    -- 一定に保つ (元の「係数取り違え検出のため異なる値を使う」意図は
+    -- 16 通りの相異なる値で十分に保たれる)。
+    (_, priors) = collectSymRows m
+    trOf n = maybe UnconstrainedT distToTransform (Map.lookup n priors)
+    ixOf   = Map.fromList (zip names [0 ..])
+    cvi    = compileVecIR ixOf gs fams
+    famSet = Set.fromList (concat [ ms | (ms, _, _) <- fams ])
+    check (base, step) =
+      let pm = Map.fromList
+                 [ (n, fromUnconstrained (trOf n) (base + step * fromIntegral (i `mod` 16)))
+                 | (n, i) <- zip names [0 :: Int ..] ]
+          pc = VS.fromList [ pm Map.! n | n <- names ]
+          ref = obsOnlySum obsNames m pm + priorOnlySum famSet m pm
+                + potOnlySum obsNames m pm   -- 吸収済み raw potential (A10)
+          syn = vecIRValue cvi pc
+      in abs (ref - syn) <= 1e-9 * (1 + abs ref)
+
+-- | index 解決済みのベクトル式 IR ノード。 latent 参照は leaf **位置**
+-- ('cvScalIx' / 'cvVecIxs' の添字) に解決済み (per-call の Text lookup なし)。
+data RUExp
+  = RUK !Double
+  | RUC !(VS.Vector Double)
+  | RUV !Int                    -- ^ scalar leaf 位置
+  | RUG !Int !(VU.Vector Int)   -- ^ vector leaf 位置 + gids (gather・長さ = 行数)
+  | RUVec !Int                  -- ^ vector leaf そのもの (族 prior 用・Phase 56.2)
+  | RU1 !SUn RUExp
+  | RU2 !SBin RUExp RUExp
+  | RUSum RUExp                 -- ^ Σ (ベクトル → スカラ・Phase 56.2)
+  deriving (Eq, Ord)            -- ^ compile 時 hash-consing (CSE) 用
+
+-- | 式が行依存 (ベクトル形) か (compile 時に静的に決まる)。
+-- Phase 104: 素朴な構造再帰は共有 DAG 上で**経路数**に比例して走り、
+-- garch11 の σ 逐次再帰 (sPrev² = 2 参照 × T 段 → Σ2^t 経路) で指数ハング
+-- した (prof 99.8% time・entries 2^30)。 StableName memo walk で
+-- O(distinct)/呼出に是正 ('compileVecIR' と同流儀・引数決定的なので参照透過)。
+ruIsVec :: RUExp -> Bool
+ruIsVec e0 = unsafePerformIO $ do
+  memo <- newIORef IM.empty
+  let go x0 = do
+        x  <- evaluate x0
+        sn <- makeStableName x
+        let h = hashStableName sn
+        mm <- readIORef memo
+        case lookup sn =<< IM.lookup h mm of
+          Just r  -> pure r
+          Nothing -> do
+            r <- case x of
+              RUC{}     -> pure True
+              RUG{}     -> pure True
+              RUVec{}   -> pure True
+              RU1 _ e   -> go e
+              RU2 _ a b -> (||) <$> go a <*> go b
+              _         -> pure False
+            modifyIORef' memo (IM.insertWith (++) h [(sn, r)])
+            pure r
+  go e0
+{-# NOINLINE ruIsVec #-}
+
+infixl 6 .+#, .-#
+infixl 7 .*#, ./#
+-- | 密度 IR 構築用の局所演算子 (Phase 56.2・export しない)。
+-- Phase 85.3: 恒等演算 (x·1 / x+0 / x-0 / x÷1) は構築時に畳む =
+-- 'ru2Smart'。 μ 合成 (designHBMProgram) が汎用に作る @0 + coef·x@ 連鎖が
+-- radon で 919 セル級ベクトル命令 20 本中 6 本 (~29%) を占めると 85.1 prof の
+-- 命令列 dump で実測されたため。 x·0→0 は IEEE 非保存 (x=Inf/NaN で NaN) ゆえ
+-- 畳まない。
+(.+#), (.-#), (.*#), (./#) :: RUExp -> RUExp -> RUExp
+(.+#) = ru2Smart SAddO
+(.-#) = ru2Smart SSubO
+(.*#) = ru2Smart SMulO
+(./#) = ru2Smart SDivO
+
+-- | 'RU2' の恒等演算畳み込み smart constructor (Phase 85.3)。
+-- 定数同士は即値化 (SExp の 'sc2' と同じ流儀)。
+ru2Smart :: SBin -> RUExp -> RUExp -> RUExp
+ru2Smart o (RUK a) (RUK b) = RUK (sBinF o a b)
+ru2Smart SAddO (RUK 0) b = b
+ru2Smart SAddO a (RUK 0) = a
+ru2Smart SSubO a (RUK 0) = a
+ru2Smart SMulO (RUK 1) b = b
+ru2Smart SMulO a (RUK 1) = a
+ru2Smart SDivO a (RUK 1) = a
+ru2Smart o a b = RU2 o a b
+
+-- | 'RU1' の smart constructor (Phase 90 A11-4②・命令融合)。
+--
+--   * 定数は即値化 ('ru2Smart' と同流儀)。
+--   * **@log(exp x) → x@ の代数畳み込み** (F3b): GLM log-link で観測密度が
+--     @Σ y·log(λ)@・@λ = exp(η)@ を組むと @log(exp η)@ の往復が 1 命令
+--     (観測長ぶんの `SLogO` pass + その backward) として残る。 これを恒等に
+--     畳んで η を直接使う (`Σ y·η − exp η` = Poisson の log-link 標準形)。
+--     数学的に厳密な恒等 (exp は常に正・log(exp x)=x)。 FP では ulp 差が出る
+--     ため **draws は変わる** (回帰判定は PyMC 事後突合・A11-5 と別 gate)。
+--   ⚠ @exp(log x) → x@ は x>0 でしか成立せず (log(負)=NaN) 一般には不正 =
+--     畳まない。 log∘exp のみ。
+ru1Smart :: SUn -> RUExp -> RUExp
+ru1Smart o (RUK a)          = RUK (sUnF o a)
+ru1Smart SLogO (RU1 SExpO x) = x
+ru1Smart o e                = RU1 o e
+
+-- | Phase 54.11 の前処理済み IR ('CompiledLMBlock' の IR 版)。 compile 時に
+-- 1 度だけ作り、 per-call は leaf 値の差し替え + tape/値評価のみ。
+-- | index 解決 + 観測値由来の定数前計算済みのグループ (Phase 55.4)。
+data VecObsIR
+  = VOGauss !RUExp !RUExp !(VS.Vector Double)
+    -- ^ (μ IR, σ IR, ys)。 σ IR が行依存 (RUC を含む) なら heteroscedastic
+    -- ベクトル密度 (Phase 55.3)
+  | VOPois !RUExp !(VS.Vector Double) !Double
+    -- ^ (λ IR, ys, Σ log y_i! 前計算)。 logp = Σ(y_i·logλ_i - λ_i) - Σlog y_i!
+    -- (y は定数なので factorial 項は compile 時前計算・勾配に寄与しない)
+  | VOBern !RUExp !(VS.Vector Double)
+    -- ^ (p IR, yb)。 yb = round 済 0/1 列。 logp = Σ(yb_i·log p_i +
+    -- (1-yb_i)·log(1-p_i)) ('logDensityObs' の round 分岐を係数化)
+  | VOStudT !Double !RUExp !RUExp !(VS.Vector Double)
+    -- ^ (ν 定数, μ IR, σ IR, ys)。 lgamma 項は ν=SC なので compile 時定数
+    -- (56.3。 'lgammaApprox' で walk と完全一致)
+  | VOCauchy !RUExp !RUExp !(VS.Vector Double)
+    -- ^ (x₀ IR, γ IR, ys)。 logp = -n·logπ - Σlogγ - Σlog(1+z_i²) (56.3)
+  | VOLogis !RUExp !RUExp !(VS.Vector Double)
+    -- ^ (μ IR, s IR, ys)。 logp = -Σz_i - Σlog s - 2·Σlog(1+exp(-z_i)) (56.3)
+  | VOGumbel !RUExp !RUExp !(VS.Vector Double)
+    -- ^ (μ IR, β IR, ys)。 logp = -Σlog β - Σz_i - Σexp(-z_i) (56.3)
+  | VOExpo !RUExp !(VS.Vector Double)
+    -- ^ (rate IR, ys)。 logp = Σlog rate_i - Σ rate_i·y_i (56.4)
+  | VOWeib !RUExp !RUExp !(VS.Vector Double)
+    -- ^ (k IR, λ IR, log ys 前計算)。 (y/λ)^k は exp(k·(log y - log λ)) で
+    -- 初等化 (`**` と ulp 差のみ・56.4)
+  | VOLogN !RUExp !RUExp !(VS.Vector Double) !Double
+    -- ^ (μ IR, σ IR, log ys 前計算, -Σlog y 定数)。 密度 = Gaussian ノード
+    -- ('VOGauss' の densityIR) 再利用 + 定数 (56.4 計画どおり)
+  | VOGamma !RUExp !RUExp !(VS.Vector Double) !(VS.Vector Double)
+    -- ^ (α IR, rate IR, ys, log ys 前計算)。 lgammaΓ(α) は 'SLgammaO'
+    -- (値 lgammaApprox / 導関数 lgammaApproxDeriv・56.4 初使用)
+  | VOBeta !RUExp !RUExp !(VS.Vector Double) !(VS.Vector Double)
+    -- ^ (α IR, β IR, log ys, log (1-ys) 前計算)。 56.4
+  | VOBinom !RUExp !(VS.Vector Double) !(VS.Vector Double) !Double
+    -- ^ (p IR, k 列 (raw y・walk の kA と一致), n-k 列, Σ logC(n,round y) 定数)。
+    -- Bernoulli 式の係数一般化 (56.5)
+  | VOGeom !RUExp !(VS.Vector Double)
+    -- ^ (p IR, k 列 (raw y))。 logp = Σ(k_i-1)·log(1-p_i) + Σlog p_i (56.5)
+  | VONegBin !RUExp !RUExp !(VS.Vector Double) !Double
+    -- ^ (μ IR, α IR, k 列 (raw y), Σ lgammaΓ(k_i+1) 定数)。 lgammaΓ(k_i+α) は
+    -- 'SLgammaO' の elementwise 適用 (56.5 本命)
+  | VOPot !RUExp
+    -- ^ raw `potential` 項 (Phase 90 A10)。 scalar 形 (内部 Σ は 'RUSum')。
+    -- logp 寄与 = 式の値そのもの・guard なし (walk の 'Potential' 加算と同値)
+  | VOMixNorm2 !RUExp !RUExp !RUExp !RUExp !RUExp !RUExp !(VS.Vector Double)
+    -- ^ (w1 IR, w2 IR, μ1 IR, σ1 IR, μ2 IR, σ2 IR, ys)。 Phase 90 A3・
+    -- 2成分 Normal 混合限定。 logp_i = logsumexp(logw1-logtotal+lpdf1_i,
+    -- logw2-logtotal+lpdf2_i) ('Distribution.hs' の Mixture と数式一致)
+  | VOZIBinom !(VS.Vector Double) !RUExp !RUExp !(VS.Vector Double) !(VS.Vector Double)
+              !(VS.Vector Double) !(VS.Vector Double)
+    -- ^ (n 列 (行対応・Phase 94), ψ IR, p IR, mask0 列 (y=0なら1), y 列 (raw), n-y 列,
+    -- logC(n,y) 列)。 Phase 90 A3。 y==0/y>0 の分岐は compile 時に mask 列へ
+    -- 落とし、 両方の分岐式を全行 elementwise に計算してから mask で選択
+    -- (group 分割はしない — family gather の disjoint 検査を壊さない安全設計)
+
+data CompiledVecIR = CompiledVecIR
+  { cvProg   :: !VecProgram
+    -- ^ 値 + 勾配の静的命令列 (compile 時 1 回生成・Phase 56.2 = per-call の
+    -- tape 構築を撤去し「tape を compile 時に固定」)
+  , cvScalIx :: !(VU.Vector Int)
+    -- ^ scalar leaf → param index
+  , cvVecIxs :: ![VU.Vector Int]
+    -- ^ vector leaf → member param indices
+  }
+
+
+-- | 'VecIRSrc' を param index に解決する (静的・1 回)。 Phase 90 A8:
+-- 'UExp'/'SExp' → 'RUExp' の変換と leaf 収集を identity memo (StableName) で
+-- 共有保存し、 命令列生成は intern 済み DAG ('RUNode') 上で行う (旧実装は
+-- 全て共有無視の木 walk + 'Map' 構造キーの CSE = 深い共有式で指数)。 表面は
+-- pure のまま ('Gradient.hs' の呼出互換・引数に対し決定的なので参照透過)。
+compileVecIR
+  :: Map Text Int
+  -> [VecGroupSrc] -> [([Text], SExp, SExp)]
+  -> CompiledVecIR
+compileVecIR ixOf gs fams = unsafePerformIO (compileVecIRIO ixOf gs fams)
+{-# NOINLINE compileVecIR #-}
+
+compileVecIRIO
+  :: Map Text Int
+  -> [VecGroupSrc] -> [([Text], SExp, SExp)]
+  -> IO CompiledVecIR
+compileVecIRIO ixOf gs fams = do
+  -- leaf 収集 (初出順・memo は全式共有 = ordNubO 後の結果は旧木 walk と同一)
+  seenRef   <- newIORef IM.empty
+  leafPairs <- mapM (uexpLeavesIO seenRef) [ e | g <- gs, e <- vgExprAll g ]
+  sdag <- newSDag
+  famVars <- forM fams $ \(_, mx, tx) -> do
+    im <- internS sdag mx
+    it <- internS sdag tx
+    st <- readIORef sdag
+    pure (Set.toList (sdagVarsOf st im `Set.union` sdagVarsOf st it))
+  let scalNames = ordNubO (concatMap fst leafPairs ++ concat famVars)
+      vecLists  = ordNubO (concatMap snd leafPairs
+                           ++ [ ms | (ms, _, _) <- fams ])
+      sPos = Map.fromList (zip scalNames [0 :: Int ..])
+      vPos = Map.fromList (zip vecLists [0 :: Int ..])
+  -- UExp/SExp → RUExp (identity memo で共有保存・'ru2Smart' の畳みは従来どおり)
+  rUMemo <- newIORef IM.empty
+  rSMemo <- newIORef IM.empty
+  let rU u0 = do
+        u  <- evaluate u0
+        sn <- makeStableName u
+        let h = hashStableName sn
+        mm <- readIORef rUMemo
+        case lookup sn =<< IM.lookup h mm of
+          Just r  -> pure r
+          Nothing -> do
+            r <- case u of
+              UK v       -> pure (RUK v)
+              UC v       -> pure (RUC v)
+              UV n       -> pure (RUV (sPos Map.! n))
+              UG ms gids -> pure (RUG (vPos Map.! ms) gids)
+              U1 o e     -> RU1 o <$> rU e
+              U2 o a b   -> ru2Smart o <$> rU a <*> rU b
+              USum e     -> RUSum <$> rU e
+            r' <- evaluate r
+            modifyIORef' rUMemo (IM.insertWith (++) h [(sn, r')])
+            pure r'
+      rS e0 = do
+        e  <- evaluate e0
+        sn <- makeStableName e
+        let h = hashStableName sn
+        mm <- readIORef rSMemo
+        case lookup sn =<< IM.lookup h mm of
+          Just r  -> pure r
+          Nothing -> do
+            r <- case e of
+              SC v     -> pure (RUK v)
+              SV n     -> pure (RUV (sPos Map.! n))
+              S1 o x   -> RU1 o <$> rS x
+              S2 o a b -> ru2Smart o <$> rS a <*> rS b
+            r' <- evaluate r
+            modifyIORef' rSMemo (IM.insertWith (++) h [(sn, r')])
+            pure r'
+      cgOf g = case g of
+        VGGauss u sg ys -> VOGauss <$> rU u <*> rU sg <*> pure ys
+        VGPois u ys ->
+          (\r -> VOPois r ys
+             (VS.sum (VS.map (logFactorial . (round :: Double -> Int)) ys)))
+          <$> rU u
+        VGBern u ys ->
+          (\r -> VOBern r (VS.map (\y -> fromIntegral (round y :: Int)) ys))
+          <$> rU u
+        VGStudT nu u sg ys -> VOStudT nu <$> rU u <*> rU sg <*> pure ys
+        VGCauchy u sc ys -> VOCauchy <$> rU u <*> rU sc <*> pure ys
+        VGLogis u s ys -> VOLogis <$> rU u <*> rU s <*> pure ys
+        VGGumbel u be ys -> VOGumbel <$> rU u <*> rU be <*> pure ys
+        VGExpo u ys -> (\r -> VOExpo r ys) <$> rU u
+        VGWeib k u ys ->
+          (\rk r -> VOWeib rk r (VS.map log ys)) <$> rU k <*> rU u
+        VGLogN u sg ys ->
+          let lys = VS.map log ys
+          in (\r rsg -> VOLogN r rsg lys (negate (VS.sum lys)))
+             <$> rU u <*> rU sg
+        VGGamma sh u ys ->
+          (\rsh r -> VOGamma rsh r ys (VS.map log ys)) <$> rU sh <*> rU u
+        VGBeta al u ys ->
+          (\ral r -> VOBeta ral r (VS.map log ys)
+                       (VS.map (\y -> log (1 - y)) ys))
+          <$> rU al <*> rU u
+        VGBinom nv u ys ->
+          (\r -> VOBinom r ys (VS.zipWith (-) nv ys)
+             (VS.sum (VS.zipWith (\nn y -> logBinomCoeff (round nn) (round y)) nv ys)))
+          <$> rU u
+        VGGeom u ys -> (\r -> VOGeom r ys) <$> rU u
+        VGNegBin u al ys ->
+          (\r ral -> VONegBin r ral ys
+             (VS.sum (VS.map (\y -> lgammaApprox (y + 1)) ys)))
+          <$> rU u <*> rU al
+        VGMixNorm2 w1 w2 m1 s1 m2 s2 ys ->
+          (\a b c d e f -> VOMixNorm2 a b c d e f ys)
+          <$> rU w1 <*> rU w2 <*> rU m1 <*> rU s1 <*> rU m2 <*> rU s2
+        VGZIBinom nv psi p ys ->
+          (\rpsi rp -> VOZIBinom nv rpsi rp
+             (VS.map (\y -> if round y == (0 :: Int) then 1 else 0) ys)
+             ys
+             (VS.zipWith (-) nv ys)
+             (VS.zipWith (\nn y -> logBinomCoeff (round nn) (round y)) nv ys))
+          <$> rU psi <*> rU p
+        VGPot u -> VOPot <$> rU u
+  gd <- map densityIR <$> mapM cgOf gs
+  fd <- forM fams $ \(ms, mx, tx) ->
+          famDensityIR (vPos Map.! ms) (length ms) <$> rS mx <*> rS tx
+  let obj  = foldl1 (RU2 SAddO) (map fst gd ++ map fst fd)
+      grds = concatMap snd gd ++ concatMap snd fd
+  -- RUExp → intern 済み DAG → 命令列 (構造 intern が旧 CSE cache と同じ
+  -- 重複排除を構造キー比較なしで与える)
+  rud  <- newRUDag
+  k0   <- internRU rud (RUK 0)
+  objI <- internRU rud obj
+  gIs  <- forM grds $ \(k, ge) -> (,) k <$> internRU rud ge
+  st   <- readIORef rud
+  pure CompiledVecIR
+    { cvProg   = compileVecProgramD (map length vecLists) (rudNodes st)
+                                    k0 objI gIs
+    , cvScalIx = VU.fromList [ ixOf Map.! n | n <- scalNames ]
+    , cvVecIxs = [ VU.fromList [ ixOf Map.! n | n <- ms ] | ms <- vecLists ]
+    }
+
+-- ---------------------------------------------------------------------------
+-- Phase 56.2: 観測密度の IR 式化 + 静的命令列 (記号 reverse-mode・arena 実行)
+-- ---------------------------------------------------------------------------
+
+-- | 値側 guard の種別 (勾配側は unguarded・54.11 の前例どおり)。
+data GuardKind = GPos | GUnit
+
+-- | 数値安定な 2 項 log-sum-exp: log(exp a + exp b) = max(a,b) + log(1+exp(-|a-b|))。
+-- Phase 90 A3: Mixture (log_mix)・ZeroInflatedBinomial の x=0 分岐で使う。
+-- 'SMaxO' (勾配は winner-take-all subgradient) を経由するので、 常に有限差分
+-- (|a-b| は必ず ≥0) のみ exp する = オーバーフロー安全。
+logSumExp2 :: RUExp -> RUExp -> RUExp
+logSumExp2 a b =
+  RU2 SMaxO a b .+# RU1 SLogO (RUK 1 .+# RU1 SExpO (RU1 SNegO (RU1 SAbsO (a .-# b))))
+
+-- | family 別の観測密度を IR 式として組む (Phase 56.2)。 式・guard とも
+-- 'logDensityObs' の該当分岐と値一致 (test/probe で担保)。 旧 groupVal /
+-- groupNode (手書き tape ノード) の置換 — 勾配は記号微分で自動。
+densityIR :: VecObsIR -> (RUExp, [(GuardKind, RUExp)])
+densityIR g = case g of
+  -- raw potential (Phase 90 A10): 式値そのまま・guard なし。
+  VOPot re -> (re, [])
+  -- Gaussian: -n/2·log2π - (n·logσ + Σr²/(2σ²)) (σ スカラ) /
+  --           -n/2·log2π - Σlogσ_i - Σ(r_i/σ_i)²/2 (σ 行依存・55.3)
+  VOGauss mu sge ys ->
+    let n' = fromIntegral (VS.length ys) :: Double
+        c0 = RUK (negate (0.5 * n' * log (2 * pi)))
+        r  = RUC ys .-# mu
+    in if ruIsVec sge
+       then ( c0 .-# RUSum (RU1 SLogO sge)
+                 .-# (RUSum (let t = r ./# sge in t .*# t) ./# RUK 2)
+            , [(GPos, sge)] )
+       else ( c0 .-# (RUK n' .*# RU1 SLogO sge)
+                 .-# (RUSum (r .*# r) ./# (RUK 2 .*# sge .*# sge))
+            , [(GPos, sge)] )
+  -- Poisson: Σ(y_i·logλ_i - λ_i) - Σlog y_i! (lfk 前計算・y は raw = kA 一致)
+  VOPois lam ys lfk ->
+    let n' = fromIntegral (VS.length ys) :: Double
+        -- Phase 90 A11-4② (F3b): log(exp η) を η に畳む (log-link 標準形)。
+        logLam = ru1Smart SLogO lam
+    in if ruIsVec lam
+       then ( RUSum (RUC ys .*# logLam) .-# RUSum lam .-# RUK lfk
+            , [(GPos, lam)] )
+       else ( (RUK (VS.sum ys) .*# logLam)
+                .-# (RUK n' .*# lam) .-# RUK lfk
+            , [(GPos, lam)] )
+  -- Bernoulli: Σ(yb·log p + (1-yb)·log(1-p)) (yb = round 済 0/1 定数)
+  VOBern p yb ->
+    let n' = fromIntegral (VS.length yb) :: Double
+        c1 = VS.sum yb
+        omp = RUK 1 .-# p
+    in if ruIsVec p
+       then ( RUSum (RUC yb .*# RU1 SLogO p)
+                .+# RUSum (RUC (VS.map (1 -) yb) .*# RU1 SLogO omp)
+            , [(GUnit, p)] )
+       else ( (RUK c1 .*# RU1 SLogO p) .+# (RUK (n' - c1) .*# RU1 SLogO omp)
+            , [(GUnit, p)] )
+  -- StudentT (ν=SC・56.3): n·[lgamma((ν+1)/2) - lgamma(ν/2) - ½log(νπ)]
+  --   - Σlogσ - ((ν+1)/2)·Σ log(1 + z_i²/ν)。 ν≤0 は収集時に排除済。
+  VOStudT nu mu sge ys ->
+    let n' = fromIntegral (VS.length ys) :: Double
+        c0 = RUK (n' * (lgammaApprox ((nu + 1) / 2) - lgammaApprox (nu / 2)
+                        - 0.5 * log (nu * pi)))
+        z  = zOf mu sge ys
+    in ( c0 .-# sumLogScale (VS.length ys) sge
+            .-# (RUK ((nu + 1) / 2)
+                   .*# RUSum (RU1 SLogO (RUK 1 .+# (z .*# z ./# RUK nu))))
+       , [(GPos, sge)] )
+  -- Cauchy (56.3): -n·logπ - Σlogγ - Σ log(1 + z_i²)
+  VOCauchy loc sce ys ->
+    let n' = fromIntegral (VS.length ys) :: Double
+        z  = zOf loc sce ys
+    in ( RUK (negate (n' * log pi)) .-# sumLogScale (VS.length ys) sce
+            .-# RUSum (RU1 SLogO (RUK 1 .+# z .*# z))
+       , [(GPos, sce)] )
+  -- Logistic (56.3): -Σz_i - Σlog s - 2·Σ log(1 + exp(-z_i))
+  VOLogis mu se ys ->
+    let z = zOf mu se ys
+    in ( RU1 SNegO (RUSum z) .-# sumLogScale (VS.length ys) se
+            .-# (RUK 2
+                   .*# RUSum (RU1 SLogO (RUK 1 .+# RU1 SExpO (RU1 SNegO z))))
+       , [(GPos, se)] )
+  -- Gumbel (56.3): -Σlog β - Σz_i - Σ exp(-z_i)
+  VOGumbel mu bee ys ->
+    let z = zOf mu bee ys
+    in ( RU1 SNegO (sumLogScale (VS.length ys) bee)
+            .-# RUSum z .-# RUSum (RU1 SExpO (RU1 SNegO z))
+       , [(GPos, bee)] )
+  -- Exponential (56.4): Σ log rate_i - Σ rate_i·y_i (y ≥ 0 は収集時確認済)
+  VOExpo rate ys ->
+    ( sumLogScale (VS.length ys) rate
+        .-# (if ruIsVec rate then RUSum (rate .*# RUC ys)
+             else rate .*# RUK (VS.sum ys))
+    , [(GPos, rate)] )
+  -- Weibull (56.4): Σlog k - Σlog λ + Σ(k-1)·z_i - Σ exp(k·z_i)
+  -- (z_i = log y_i - log λ_i。 walk の (y/λ)**k と ulp 差のみ)
+  VOWeib k lam lys ->
+    let n = VS.length lys
+        z = RUC lys .-# RU1 SLogO lam
+    in ( sumLogScale n k .-# sumLogScale n lam
+            .+# RUSum ((k .-# RUK 1) .*# z)
+            .-# RUSum (RU1 SExpO (k .*# z))
+       , [(GPos, k), (GPos, lam)] )
+  -- LogNormal (56.4): N(log y⃗ | μ, σ) - Σlog y (Gaussian ノード再利用・guard も流用)
+  VOLogN mu sge lys c ->
+    let (e, gds) = densityIR (VOGauss mu sge lys)
+    in (RUK c .+# e, gds)
+  -- Gamma (56.4): Σ(α-1)·log y - Σ rate·y + Σ α·log rate - Σ lgammaΓ(α)
+  VOGamma al rate ys lys ->
+    let n = VS.length ys
+    in ( RUSum ((al .-# RUK 1) .*# RUC lys)
+            .-# (if ruIsVec rate then RUSum (rate .*# RUC ys)
+                 else rate .*# RUK (VS.sum ys))
+            .+# sumOf n (al .*# RU1 SLogO rate)
+            .-# sumOf n (RU1 SLgammaO al)
+       , [(GPos, al), (GPos, rate)] )
+  -- Beta (56.4): Σ(α-1)·log y + Σ(β-1)·log(1-y) - Σ[lgΓα + lgΓβ - lgΓ(α+β)]
+  VOBeta al be lys l1ys ->
+    let n = VS.length lys
+    in ( RUSum ((al .-# RUK 1) .*# RUC lys)
+            .+# RUSum ((be .-# RUK 1) .*# RUC l1ys)
+            .-# sumOf n (RU1 SLgammaO al .+# RU1 SLgammaO be
+                           .-# RU1 SLgammaO (al .+# be))
+       , [(GPos, al), (GPos, be)] )
+  -- Binomial (56.5): ΣlogC + Σk_i·log p_i + Σ(n-k_i)·log(1-p_i)
+  -- (Bernoulli の yb/1-yb 係数を k/n-k に一般化・logC は compile 時定数)
+  VOBinom p kv nkv lc ->
+    let omp = RUK 1 .-# p
+    in if ruIsVec p
+       then ( RUK lc .+# RUSum (RUC kv .*# RU1 SLogO p)
+                .+# RUSum (RUC nkv .*# RU1 SLogO omp)
+            , [(GUnit, p)] )
+       else ( RUK lc .+# (RUK (VS.sum kv) .*# RU1 SLogO p)
+                .+# (RUK (VS.sum nkv) .*# RU1 SLogO omp)
+            , [(GUnit, p)] )
+  -- Geometric (56.5): Σ(k_i-1)·log(1-p_i) + Σlog p_i (round y ≥ 1 は収集時確認済)
+  VOGeom p kv ->
+    let n' = fromIntegral (VS.length kv) :: Double
+        omp = RUK 1 .-# p
+    in if ruIsVec p
+       then ( RUSum ((RUC kv .-# RUK 1) .*# RU1 SLogO omp)
+                .+# RUSum (RU1 SLogO p)
+            , [(GUnit, p)] )
+       else ( (RUK (VS.sum kv - n') .*# RU1 SLogO omp)
+                .+# (RUK n' .*# RU1 SLogO p)
+            , [(GUnit, p)] )
+  -- NegativeBinomial (56.5 本命): p = α/(α+μ) として
+  -- Σ lgΓ(k_i+α) - Σ lgΓ(α) - Σ lgΓ(k_i+1) + Σ α·log p_i + Σ k_i·log(1-p_i)
+  -- (lgΓ(k_i+α) は SLgammaO の elementwise・lgΓ(k_i+1) は compile 時定数)
+  VONegBin mu al kv lgk1 ->
+    let n = VS.length kv
+        p = al ./# (al .+# mu)
+    in ( RUSum (RU1 SLgammaO (RUC kv .+# al))
+            .-# sumOf n (RU1 SLgammaO al)
+            .-# RUK lgk1
+            .+# sumOf n (al .*# RU1 SLogO p)
+            .+# RUSum (RUC kv .*# RU1 SLogO (RUK 1 .-# p))
+       , [(GPos, mu), (GPos, al)] )
+  -- Mixture (Phase 90 A3・2成分 Normal 限定): 'Distribution.hs' の
+  -- @logDensity (Mixture ws comps) x = logSumExpA [log(w_k/Σw)+logDensity d_k x]@
+  -- と数式一致 (w1+w2=1 を仮定せず Σw で正規化)。 各成分の Gaussian 対数密度
+  -- (per-row) を 'gaussLpdfElem' で作り、 'logSumExp2' で数値安定に合成。
+  VOMixNorm2 w1 w2 m1 s1 m2 s2 ys ->
+    let total  = w1 .+# w2
+        logw1  = RU1 SLogO w1 .-# RU1 SLogO total
+        logw2  = RU1 SLogO w2 .-# RU1 SLogO total
+        lpdf1  = gaussLpdfElem m1 s1 ys
+        lpdf2  = gaussLpdfElem m2 s2 ys
+        perRow = logSumExp2 (logw1 .+# lpdf1) (logw2 .+# lpdf2)
+    in ( RUSum perRow
+       , [(GPos, w1), (GPos, w2), (GPos, s1), (GPos, s2)] )
+  -- ZeroInflatedBinomial (Phase 90 A3): 'Distribution.hs' の
+  -- @logDensity (ZeroInflatedBinomial n psi p) x@ と数式一致。 y==0/y>0 の
+  -- データ分岐は group を分けず mask 列で elementwise に選択する (family
+  -- gather の disjoint 検査を壊さない安全設計・A1 調査で確定した方針)。
+  -- branch0 は「もしこの行が y=0 だったら」の仮想値を **行ごとの n** (nv) で
+  -- 計算する (nmy=n-y_i は y=0 行以外で n と異なるため使えず、 専用の n 列 nv を
+  -- 使う。 Phase 94 で n を行対応化 = n 別 group 分裂を解消)。
+  VOZIBinom nv psi p mask0 yv nmy logc ->
+    let omp     = RUK 1 .-# p
+        ompsi   = RUK 1 .-# psi
+        branch0 = logSumExp2 (RU1 SLogO psi)
+                    (RU1 SLogO ompsi .+# (RUC nv .*# RU1 SLogO omp))
+        branch1 = RU1 SLogO ompsi .+# RUC logc
+                    .+# (RUC yv .*# RU1 SLogO p) .+# (RUC nmy .*# RU1 SLogO omp)
+        perRow  = (RUC mask0 .*# branch0) .+# ((RUK 1 .-# RUC mask0) .*# branch1)
+    in ( RUSum perRow, [(GUnit, psi), (GUnit, p)] )
+  where
+    -- 位置-尺度系の共通形 (56.3): z⃗ = (y⃗ - μ)/s (y⃗ は RUC 定数なので常にベクトル形)
+    zOf mu sge ys = (RUC ys .-# mu) ./# sge
+    -- Σ e: e がスカラ式なら n·e に畳む (走査回避・56.4 で一般化)
+    sumOf n e
+      | ruIsVec e = RUSum e
+      | otherwise = RUK (fromIntegral n) .*# e
+    -- Σ log s (スカラ時 n·log s・Gauss の 55.3 と同型)
+    sumLogScale n sge = sumOf n (RU1 SLogO sge)
+    -- Phase 90 A3 (Mixture 用): Gaussian 対数密度の **行ごとの値**
+    -- (-0.5·log2π - logσ - (y-μ)²/(2σ²))。 'VOGauss' の densityIR と違い
+    -- ここでは Σ を取らずベクトルのまま返す (logSumExp2 で行ごとに混合してから
+    -- 最後に 1 回だけ Σ する必要があるため)。
+    gaussLpdfElem mu sge ys =
+      let r = RUC ys .-# mu
+      in RUK (negate (0.5 * log (2 * pi))) .-# RU1 SLogO sge
+             .-# ((r .*# r) ./# (RUK 2 .*# sge .*# sge))
+
+-- | 族 prior 密度の IR 式: -nG/2·log2π - nG·logτ - Σ(a_j-m)²/(2τ²)。
+famDensityIR :: Int -> Int -> RUExp -> RUExp -> (RUExp, [(GuardKind, RUExp)])
+famDensityIR vp nG mx tx =
+  let nG' = fromIntegral nG :: Double
+      c0  = RUK (negate (0.5 * nG' * log (2 * pi)))
+      ra  = RUVec vp .-# mx
+  in ( c0 .-# (RUK nG' .*# RU1 SLogO tx)
+          .-# (RUSum (ra .*# ra) ./# (RUK 2 .*# tx .*# tx))
+     , [(GPos, tx)] )
+
+-- | 静的命令列の 1 命令。 slot i = 命令 i の結果 (SSA/ANF・共有保存)。
+-- 全 slot の形 (スカラ / 長さ n) は compile 時に確定し、 1 本の unboxed arena に
+-- オフセット解決して敷き詰める (boxed 中間表現なし)。
+data VInstr
+  = VIK !Double                          -- ^ スカラ定数
+  | VIKV !(VS.Vector Double)             -- ^ ベクトル定数 (データ列)
+  | VILeafS !Int                         -- ^ scalar leaf p の値
+  | VILeafV !Int                         -- ^ vector leaf p (member 列そのもの)
+  | VIGath !Int !(VU.Vector Int) !Int    -- ^ gather (vector leaf p, gids, 行数)
+  | VIUn !SUn !Int
+  | VIBin !SBin !Int !Int                -- ^ broadcast は形 (静的) で解決
+  | VISum !Int                           -- ^ Σ (ベクトル → スカラ)
+  -- Phase 85.3-ii: superinstruction (radon 命令列 dump 由来の頻出パターンを
+  -- compile 時に融合。 pass 数と中間 slot を削減 = 85.3a spike の融合利得)
+  | VIAxpy !Int !Int !Int                -- ^ out = a + s·v (a: slot・len 0 は
+                                         --   broadcast、 s: スカラ slot、 v: ベクトル slot)
+  | VIAxpyC !Int !Int !(VS.Vector Double)
+                                         -- ^ 同上・v がデータ列定数 (VIKV copy 消滅)
+  | VISumSqD !Int !Int                   -- ^ out(スカラ) = Σ (x_j − m_j)²
+                                         --   (x/m: slot・スカラ側は broadcast)
+  | VISumSqC !(VS.Vector Double) !Int    -- ^ 同上・x がデータ列定数
+  -- Phase 85.3-iv: RE 連鎖の gather 内蔵化 + 3 項融合 (radon 残 pass の削減)
+  | VIMulG !Int !Int !(VU.Vector Int) !Int
+                                         -- ^ out = s·gather(p) (s: スカラ slot・
+                                         --   gather は VIGath と同形で命令内蔵 =
+                                         --   gather の実体化 pass 消滅)
+  | VIAxpyG !Int !Int !Int !(VU.Vector Int) !Int
+                                         -- ^ out = a + s·gather(p)
+  | VIMulVC !Int !Int !(VS.Vector Double)
+                                         -- ^ out = s·v⊙c (スカラ×ベクトル×データ列定数)
+  | VISumSqC2 !(VS.Vector Double) !Int !Int
+                                         -- ^ out(スカラ) = Σ (c_j − m1_j − m2_j)²
+                                         --   (m1/m2: ベクトル slot・和の実体化 pass 消滅)
+  | VISumSqDGG !Int !(VU.Vector Int) !Int !(VU.Vector Int) !Int
+                                         -- ^ Phase 90 A11-4② (F2): out(スカラ) =
+                                         --   Σ (φ[px·gx_j] − φ[pm·gm_j])²。 gather 2 本を
+                                         --   SumSqD に内蔵 (ICAR ペア差分・5461 セルの
+                                         --   gather 実体化 2 本を消す)。 gather 値は pc
+                                         --   から直読み・随伴は param へ直 scatter
+
+-- | compile 済みの値+勾配プログラム (Phase 56.2)。 生成は 1 回・per-call は
+-- forward (値) / forward+backward (勾配) の実行のみ = per-call の tape 構築を
+-- 撤去し「tape を compile 時に固定」。 arena は per-call 確保 (共有 mutable
+-- なし = 'nutsChainsPure' の spark 並列と整合)。
+data VecProgram = VecProgram
+  { vpInstrs  :: !(BV.Vector VInstr)
+  , vpOff     :: !(VU.Vector Int)        -- ^ slot → arena オフセット
+  , vpLen     :: !(VU.Vector Int)        -- ^ slot → 0 (スカラ) / n (ベクトル)
+  , vpSize    :: !Int                    -- ^ arena 総長
+  , vpObj     :: !Int                    -- ^ 目的 (log-density 和) の slot
+  , vpGuards  :: ![(GuardKind, Int)]     -- ^ 値側 guard (slot 参照)
+  }
+
+-- ===========================================================================
+-- Phase 90 A8: 'RUExp' の DAG intern + 共有保存の命令列生成
+-- ===========================================================================
+
+-- | 'RUExp' の DAG ノード (子は intern 済み ID)。
+data RUNode
+  = RNK !Double
+  | RNC !(VS.Vector Double)
+  | RNV !Int
+  | RNG !Int !(VU.Vector Int)
+  | RNVec !Int
+  | RN1 !SUn !Int
+  | RN2 !SBin !Int !Int
+  | RNSum !Int
+  deriving (Eq, Ord)
+
+data RUDagSt = RUDagSt
+  { rudStable :: !(IM.IntMap [(StableName RUExp, Int)])
+  , rudStruct :: !(Map RUNode Int)
+  , rudNodes  :: !(IM.IntMap RUNode)
+  , rudNext   :: !Int
+  }
+
+newRUDag :: IO (IORef RUDagSt)
+newRUDag = newIORef (RUDagSt IM.empty Map.empty IM.empty 0)
+
+-- | 'RUExp' を DAG に intern して ID を返す ('internS' の RUExp 版)。
+-- 構造 intern が旧 'compileVecProgram' の @Map RUExp Int@ CSE と同じ重複排除を
+-- 与える (旧実装はキー比較が構造 walk = 共有木で経路数比例、 こちらは
+-- 子 ID 比較のみで O(ノード数 · log))。
+internRU :: IORef RUDagSt -> RUExp -> IO Int
+internRU ref = go
+  where
+    go e0 = do
+      e  <- evaluate e0
+      sn <- makeStableName e
+      let h = hashStableName sn
+      st <- readIORef ref
+      case lookup sn =<< IM.lookup h (rudStable st) of
+        Just i  -> pure i
+        Nothing -> do
+          nd <- case e of
+            RUK v      -> pure (RNK v)
+            RUC v      -> pure (RNC v)
+            RUV p      -> pure (RNV p)
+            RUG p gids -> pure (RNG p gids)
+            RUVec p    -> pure (RNVec p)
+            RU1 o x    -> RN1 o <$> go x
+            RU2 o a b  -> RN2 o <$> go a <*> go b
+            RUSum x    -> RNSum <$> go x
+          st1 <- readIORef ref
+          i <- case Map.lookup nd (rudStruct st1) of
+            Just j  -> pure j
+            Nothing -> do
+              let j = rudNext st1
+              writeIORef ref st1
+                { rudStruct = Map.insert nd j (rudStruct st1)
+                , rudNodes  = IM.insert j nd (rudNodes st1)
+                , rudNext   = j + 1 }
+              pure j
+          modifyIORef' ref $ \s ->
+            s { rudStable = IM.insertWith (++) h [(sn, i)] (rudStable s) }
+          pure i
+
+-- | 'compileVecProgram' の DAG 版 (Phase 90 A8)。 ノードは intern 済み ID で
+-- 参照し、 CSE cache は ID → slot の 'IM.IntMap'。 superinstruction 融合
+-- (85.3-ii/iv) の構造判定は ID 経由の 1 段 lookup。 意味は旧実装と同一 —
+-- 「構造等値 ⇔ ID 等値」 が intern で保証されるため、 Σ(x−m)² 融合の
+-- @r1 == r2@ も ID 比較で厳密に旧構造比較と一致する。
+compileVecProgramD
+  :: [Int]              -- ^ vector leaf 長
+  -> IM.IntMap RUNode   -- ^ ID → ノード (子 ID < 親 ID)
+  -> Int                -- ^ @RUK 0@ の ID (Σx² 融合で m 側が無い時の代用)
+  -> Int                -- ^ 目的 (log-density 和) root ID
+  -> [(GuardKind, Int)] -- ^ guard root ID
+  -> VecProgram
+compileVecProgramD vecLens nodes k0 objI guardIs =
+  let nodeOf i = nodes IM.! i
+      isVecA = IM.foldlWithKey'
+        (\mp i nd -> IM.insert i
+           (case nd of
+              RNC{}     -> True
+              RNG{}     -> True
+              RNVec{}   -> True
+              RN1 _ a   -> mp IM.! a
+              RN2 _ a b -> (mp IM.! a) || (mp IM.! b)
+              _         -> False) mp)
+        IM.empty nodes
+      isVec i = isVecA IM.! i
+      emit ins l i (cache, acc, lens, n) =
+        (n, (IM.insert i n cache, ins : acc, BV.snoc lens l, n + 1 :: Int))
+      -- Phase 85.3-ii: a + s·v (AXPY) の融合対象判定。
+      mulSV i = case nodeOf i of
+        RN2 SMulO p q
+          | not (isVec p), isVec q -> Just (p, q)
+          | isVec p, not (isVec q) -> Just (q, p)
+        _ -> Nothing
+      axpyMatch a b = case mulSV b of
+        Just (se, ve) -> Just (a, se, ve)
+        Nothing       -> case mulSV a of
+          Just (se, ve) -> Just (b, se, ve)
+          Nothing       -> Nothing
+      -- Phase 85.3-iv: スカラ × gather (VIMulG 判定)
+      mulSG x y = case (nodeOf x, nodeOf y) of
+        (_, RNG p gids) | not (isVec x) -> Just (x, p, gids)
+        (RNG p gids, _) | not (isVec y) -> Just (y, p, gids)
+        _ -> Nothing
+      -- Phase 85.3-iv: (スカラ×ベクトル) ⊙ データ列定数 (VIMulVC 判定)
+      mulVC x y = case (nodeOf x, nodeOf y) of
+        (RNC c, _) -> goVC c y
+        (_, RNC c) -> goVC c x
+        _          -> Nothing
+        where
+          goVC c mi = case nodeOf mi of
+            RN2 SMulO p q
+              | not (isVec p), isVec q -> Just (p, q, c)
+              | isVec p, not (isVec q) -> Just (q, p, c)
+            _ -> Nothing
+      comp i st@(cache, _, _, _) = case IM.lookup i cache of
+        Just sl -> (sl, st)
+        Nothing -> case nodeOf i of
+          RNK v      -> emit (VIK v) 0 i st
+          RNC v      -> emit (VIKV v) (VS.length v) i st
+          RNV p      -> emit (VILeafS p) 0 i st
+          RNVec p    -> emit (VILeafV p) (vecLens !! p) i st
+          RNG p gids ->
+            emit (VIGath p gids (VU.length gids)) (VU.length gids) i st
+          RN1 o x    ->
+            let (sx, st1@(_, _, lens1, _)) = comp x st
+            in emit (VIUn o sx) (lens1 BV.! sx) i st1
+          -- Phase 85.3-ii: Σ(x−m)² / Σx² を 1 命令に融合。
+          RNSum mI | RN2 SMulO r1 r2 <- nodeOf mI, r1 == r2, isVec r1 ->
+            let (xe, me) = case nodeOf r1 of
+                  RN2 SSubO x mm -> (x, mm)
+                  _              -> (r1, k0)
+            in case nodeOf xe of
+                 RNC c | RN2 SAddO m1 m2 <- nodeOf me, isVec m1, isVec m2 ->
+                   let (s1, st1) = comp m1 st
+                       (s2, st2) = comp m2 st1
+                   in emit (VISumSqC2 c s1 s2) 0 i st2
+                 RNC c ->
+                   let (sm, st1) = comp me st
+                   in emit (VISumSqC c sm) 0 i st1
+                 -- Phase 90 A11-4② (F2): Σ(gather − gather)² は gather 2 本を
+                 -- SumSqD 命令に内蔵し 5461 セルの arena 実体化を消す (ICAR)。
+                 _ | RNG px gx <- nodeOf xe, RNG pm gm <- nodeOf me
+                   , VU.length gx == VU.length gm ->
+                     emit (VISumSqDGG px gx pm gm (VU.length gx)) 0 i st
+                 _ ->
+                   let (sx, st1) = comp xe st
+                       (sm, st2) = comp me st1
+                   in emit (VISumSqD sx sm) 0 i st2
+          -- Phase 85.3-ii: a + s·v → VIAxpy。 85.3-iv: v が gather なら VIAxpyG。
+          RN2 SAddO a b | Just (ae, se, ve) <- axpyMatch a b ->
+            let (sa, st1) = comp ae st
+                (ss, st2) = comp se st1
+            in case nodeOf ve of
+                 RNC c -> emit (VIAxpyC sa ss c) (VS.length c) i st2
+                 RNG p gids ->
+                   emit (VIAxpyG sa ss p gids (VU.length gids))
+                        (VU.length gids) i st2
+                 _ ->
+                   let (sv, st3@(_, _, lens3, _)) = comp ve st2
+                   in emit (VIAxpy sa ss sv) (lens3 BV.! sv) i st3
+          -- Phase 85.3-iv: スカラ×gather → VIMulG。
+          RN2 SMulO a b | Just (se, p, gids) <- mulSG a b ->
+            let (ss, st1) = comp se st
+            in emit (VIMulG ss p gids (VU.length gids)) (VU.length gids) i st1
+          -- Phase 85.3-iv: (スカラ×ベクトル)⊙データ列定数 → VIMulVC。
+          RN2 SMulO a b | Just (se, ve, c) <- mulVC a b ->
+            let (ss, st1) = comp se st
+                (sv, st2) = comp ve st1
+            in emit (VIMulVC ss sv c) (VS.length c) i st2
+          RN2 o a b  ->
+            let (sa, st1) = comp a st
+                (sb, st2@(_, _, lens2, _)) = comp b st1
+            in emit (VIBin o sa sb) (max (lens2 BV.! sa) (lens2 BV.! sb)) i st2
+          RNSum x    -> let (sx, st1) = comp x st in emit (VISum sx) 0 i st1
+      st0 = (IM.empty :: IM.IntMap Int, [], BV.empty, 0)
+      (sObj, st1) = comp objI st0
+      (gss, (_, accF, lensF, _)) =
+        foldl (\(gacc, st) (k, gi) ->
+                 let (sl, st') = comp gi st in (gacc ++ [(k, sl)], st'))
+              ([], st1) guardIs
+      lensL = BV.toList lensF
+      offs  = scanl (+) 0 (map (max 1) lensL)   -- スカラ slot は 1 セル
+  in VecProgram
+    { vpInstrs  = BV.fromList (reverse accF)
+    , vpOff     = VU.fromList (init offs)
+    , vpLen     = VU.fromList lensL
+    , vpSize    = last offs
+    , vpObj     = sObj
+    , vpGuards  = gss
+    }
+
+-- | 'RUExp' (目的 + guard 式) を命令列へ。 leaf は重複排除・それ以外は木のまま
+-- (密度式は小さいので CSE なしで十分。 随伴は slot 単位で共有されるため
+-- 記号微分でも式膨張しない)。
+compileVecProgram :: [Int] -> RUExp -> [(GuardKind, RUExp)] -> VecProgram
+compileVecProgram vecLens obj guards =
+  let emit ins l e (cache, acc, lens, n) =
+        (n, (Map.insert e n cache, ins : acc, BV.snoc lens l, n + 1 :: Int))
+      -- Phase 85.3-ii: a + s·v (AXPY) の融合対象判定。 加数のどちらかが
+      -- (スカラ × ベクトル) 積なら Just (残りの加数, スカラ式, ベクトル式)。
+      mulSV (RU2 SMulO p q)
+        | not (ruIsVec p), ruIsVec q = Just (p, q)
+        | ruIsVec p, not (ruIsVec q) = Just (q, p)
+      mulSV _ = Nothing
+      axpyMatch a b = case mulSV b of
+        Just (se, ve) -> Just (a, se, ve)
+        Nothing       -> case mulSV a of
+          Just (se, ve) -> Just (b, se, ve)
+          Nothing       -> Nothing
+      -- Phase 85.3-iv: スカラ × gather (VIMulG 判定)
+      mulSG x y = case (x, y) of
+        (s, RUG p gids) | not (ruIsVec s) -> Just (s, p, gids)
+        (RUG p gids, s) | not (ruIsVec s) -> Just (s, p, gids)
+        _ -> Nothing
+      -- Phase 85.3-iv: (スカラ×ベクトル) ⊙ データ列定数 (VIMulVC 判定)
+      mulVC x y = case (x, y) of
+        (RUC c, m) -> goVC c m
+        (m, RUC c) -> goVC c m
+        _          -> Nothing
+        where goVC c (RU2 SMulO p q)
+                | not (ruIsVec p), ruIsVec q = Just (p, q, c)
+                | ruIsVec p, not (ruIsVec q) = Just (q, p, c)
+              goVC _ _ = Nothing
+      comp e st@(cache, _, _, _) = case Map.lookup e cache of
+        Just sl -> (sl, st)
+        Nothing -> case e of
+          RUK v      -> emit (VIK v) 0 e st
+          RUC v      -> emit (VIKV v) (VS.length v) e st
+          RUV p      -> emit (VILeafS p) 0 e st
+          RUVec p    -> emit (VILeafV p) (vecLens !! p) e st
+          RUG p gids ->
+            emit (VIGath p gids (VU.length gids)) (VU.length gids) e st
+          RU1 o x    ->
+            let (sx, st1@(_, _, lens1, _)) = comp x st
+            in emit (VIUn o sx) (lens1 BV.! sx) e st1
+          -- Phase 85.3-ii: Σ(x−m)² / Σx² を 1 命令に融合 (residual→二乗→Σ の
+          -- 3 pass → 1 pass・中間 slot 消滅)。 x がデータ列定数なら VIKV copy
+          -- ごと消す。 ruIsVec 条件はスカラ RUSum の従来意味 (0) を保存。
+          RUSum (RU2 SMulO r1 r2) | r1 == r2, ruIsVec r1 ->
+            let (xe, me) = case r1 of
+                  RU2 SSubO x m -> (x, m)
+                  x             -> (x, RUK 0)
+            in case xe of
+                 -- 85.3-iv: Σ(c − (m1+m2))² は和の実体化も畳む (radon の
+                 -- 固定効果 μ + RE 項の和がここに来る)
+                 RUC c | RU2 SAddO m1 m2 <- me, ruIsVec m1, ruIsVec m2 ->
+                   let (s1, st1) = comp m1 st
+                       (s2, st2) = comp m2 st1
+                   in emit (VISumSqC2 c s1 s2) 0 e st2
+                 RUC c ->
+                   let (sm, st1) = comp me st
+                   in emit (VISumSqC c sm) 0 e st1
+                 _ ->
+                   let (sx, st1) = comp xe st
+                       (sm, st2) = comp me st1
+                   in emit (VISumSqD sx sm) 0 e st2
+          -- Phase 85.3-ii: a + s·v → VIAxpy (2 pass → 1 pass)。
+          -- 85.3-iv: v が gather ならそれも内蔵 (VIAxpyG)。
+          RU2 SAddO a b | Just (ae, se, ve) <- axpyMatch a b ->
+            let (sa, st1) = comp ae st
+                (ss, st2) = comp se st1
+            in case ve of
+                 RUC c -> emit (VIAxpyC sa ss c) (VS.length c) e st2
+                 RUG p gids ->
+                   emit (VIAxpyG sa ss p gids (VU.length gids))
+                        (VU.length gids) e st2
+                 _     ->
+                   let (sv, st3@(_, _, lens3, _)) = comp ve st2
+                   in emit (VIAxpy sa ss sv) (lens3 BV.! sv) e st3
+          -- Phase 85.3-iv: スカラ×gather → VIMulG (gather 実体化の消滅)。
+          RU2 SMulO a b | Just (se, p, gids) <- mulSG a b ->
+            let (ss, st1) = comp se st
+            in emit (VIMulG ss p gids (VU.length gids)) (VU.length gids) e st1
+          -- Phase 85.3-iv: (スカラ×ベクトル)⊙データ列定数 → VIMulVC。
+          RU2 SMulO a b | Just (se, ve, c) <- mulVC a b ->
+            let (ss, st1) = comp se st
+                (sv, st2) = comp ve st1
+            in emit (VIMulVC ss sv c) (VS.length c) e st2
+          RU2 o a b  ->
+            let (sa, st1) = comp a st
+                (sb, st2@(_, _, lens2, _)) = comp b st1
+            in emit (VIBin o sa sb) (max (lens2 BV.! sa) (lens2 BV.! sb)) e st2
+          RUSum x    -> let (sx, st1) = comp x st in emit (VISum sx) 0 e st1
+      st0 = (Map.empty :: Map RUExp Int, [], BV.empty, 0)
+      (sObj, st1) = comp obj st0
+      (gss, (_, accF, lensF, _)) =
+        foldl (\(gacc, st) (k, ge) ->
+                 let (sl, st') = comp ge st in (gacc ++ [(k, sl)], st'))
+              ([], st1) guards
+      lensL = BV.toList lensF
+      offs  = scanl (+) 0 (map (max 1) lensL)   -- スカラ slot は 1 セル
+  in VecProgram
+    { vpInstrs  = BV.fromList (reverse accF)
+    , vpOff     = VU.fromList (init offs)
+    , vpLen     = VU.fromList lensL
+    , vpSize    = last offs
+    , vpObj     = sObj
+    , vpGuards  = gss
+    }
+
+-- | forward 実行: 全 slot の値を 1 本の arena に書く (ST・per-call 確保)。
+forwardArena
+  :: CompiledVecIR -> VS.Vector Double -> ST s (VSM.MVector s Double)
+forwardArena cvi pc = do
+  ar <- VSM.unsafeNew (vpSize (cvProg cvi))
+  forwardArenaInto cvi pc ar
+  pure ar
+
+-- | 'forwardArena' の呼出側バッファ版 (Phase 90 A11-4①: NUTS 葉勾配の
+-- per-call arena 確保 (34k セル級) を chain 閉包での 1 回確保 + 再利用に
+-- 変える)。 全 slot を毎回上書きするため zero-fill 不要。
+forwardArenaInto
+  :: CompiledVecIR -> VS.Vector Double -> VSM.MVector s Double -> ST s ()
+forwardArenaInto cvi pc ar = do
+  let prog   = cvProg cvi
+      instrs = vpInstrs prog
+      offV   = vpOff prog
+      lenV   = vpLen prog
+      misB   = BV.fromList (cvVecIxs cvi)
+      scal p = pc `VS.unsafeIndex` (cvScalIx cvi `VU.unsafeIndex` p)
+  let off i = offV `VU.unsafeIndex` i
+      len i = lenV `VU.unsafeIndex` i
+      rd  = VSM.unsafeRead ar
+      wr  = VSM.unsafeWrite ar
+      step i = do
+        let o = off i
+        case instrs BV.! i of
+          VIK v     -> wr o v
+          VIKV v    ->
+            let go !j | j >= VS.length v = pure ()
+                      | otherwise = do
+                          wr (o + j) (v `VS.unsafeIndex` j)
+                          go (j + 1)
+            in go 0
+          VILeafS p -> wr o (scal p)
+          VILeafV p ->
+            let mis = misB BV.! p
+                go !j | j >= VU.length mis = pure ()
+                      | otherwise = do
+                          wr (o + j)
+                            (pc `VS.unsafeIndex` (mis `VU.unsafeIndex` j))
+                          go (j + 1)
+            in go 0
+          VIGath p gids n ->
+            let mis = misB BV.! p
+                go !r | r >= n = pure ()
+                      | otherwise = do
+                          wr (o + r) (pc `VS.unsafeIndex`
+                            (mis `VU.unsafeIndex` (gids `VU.unsafeIndex` r)))
+                          go (r + 1)
+            in go 0
+          -- Phase 105 A3: withSUnF/withSBinF (INLINE CPS) で op の case を
+          -- ループ外に出し、 known-function の特殊化 unboxed ループに落とす
+          -- (closure 間接呼出の per-element boxing 排除。 FP 順序不変)。
+          VIUn op x -> withSUnF op $ \f -> do
+            let xo = off x
+            case len i of
+              0 -> rd xo >>= wr o . f
+              n ->
+                let go !j | j >= n = pure ()
+                          | otherwise = do
+                              v <- rd (xo + j)
+                              wr (o + j) (f v)
+                              go (j + 1)
+                in go 0
+          VIBin op x y -> withSBinF op $ \f -> do
+            let xo = off x
+                yo = off y
+            case (len x, len y) of
+              (0, 0) -> do
+                a <- rd xo
+                b <- rd yo
+                wr o (f a b)
+              (0, n) -> do
+                a <- rd xo
+                let go !j | j >= n = pure ()
+                          | otherwise = do
+                              b <- rd (yo + j)
+                              wr (o + j) (f a b)
+                              go (j + 1)
+                go 0
+              (n, 0) -> do
+                b <- rd yo
+                let go !j | j >= n = pure ()
+                          | otherwise = do
+                              a <- rd (xo + j)
+                              wr (o + j) (f a b)
+                              go (j + 1)
+                go 0
+              (n, _) ->
+                let go !j | j >= n = pure ()
+                          | otherwise = do
+                              a <- rd (xo + j)
+                              b <- rd (yo + j)
+                              wr (o + j) (f a b)
+                              go (j + 1)
+                in go 0
+          VISum x -> do
+            let xo = off x
+                n  = len x
+                go !acc !j | j >= n    = wr o acc
+                           | otherwise = do
+                               v <- rd (xo + j)
+                               go (acc + v) (j + 1)
+            go 0 0
+          -- Phase 85.3-ii superinstruction
+          VIAxpy a s v -> do
+            sv <- rd (off s)
+            let vo = off v
+                n  = len i
+            case len a of
+              0 -> do
+                av <- rd (off a)
+                let go !j | j >= n = pure ()
+                          | otherwise = do
+                              b <- rd (vo + j)
+                              wr (o + j) (av + sv * b)
+                              go (j + 1)
+                go 0
+              _ -> do
+                let ao = off a
+                    go !j | j >= n = pure ()
+                          | otherwise = do
+                              av <- rd (ao + j)
+                              b  <- rd (vo + j)
+                              wr (o + j) (av + sv * b)
+                              go (j + 1)
+                go 0
+          VIAxpyC a s c -> do
+            sv <- rd (off s)
+            let n = len i
+            case len a of
+              0 -> do
+                av <- rd (off a)
+                let go !j | j >= n = pure ()
+                          | otherwise = do
+                              wr (o + j) (av + sv * (c `VS.unsafeIndex` j))
+                              go (j + 1)
+                go 0
+              _ -> do
+                let ao = off a
+                    go !j | j >= n = pure ()
+                          | otherwise = do
+                              av <- rd (ao + j)
+                              wr (o + j) (av + sv * (c `VS.unsafeIndex` j))
+                              go (j + 1)
+                go 0
+          VISumSqD x m -> do
+            let xo = off x
+                mo = off m
+                bx = len x /= 0
+                bm = len m /= 0
+                n  = max (len x) (len m)
+                go !acc !j
+                  | j >= n = wr o acc
+                  | otherwise = do
+                      a <- rd (if bx then xo + j else xo)
+                      b <- rd (if bm then mo + j else mo)
+                      let d = a - b
+                      go (acc + d * d) (j + 1)
+            go 0 0
+          VISumSqC c m -> do
+            let mo = off m
+                bm = len m /= 0
+                n  = VS.length c
+                go !acc !j
+                  | j >= n = wr o acc
+                  | otherwise = do
+                      b <- rd (if bm then mo + j else mo)
+                      let d = c `VS.unsafeIndex` j - b
+                      go (acc + d * d) (j + 1)
+            go 0 0
+          -- Phase 85.3-iv superinstruction
+          VIMulG s p gids n -> do
+            sv <- rd (off s)
+            let mis = misB BV.! p
+                go !j | j >= n = pure ()
+                      | otherwise = do
+                          wr (o + j) (sv * (pc `VS.unsafeIndex`
+                            (mis `VU.unsafeIndex` (gids `VU.unsafeIndex` j))))
+                          go (j + 1)
+            go 0
+          VIAxpyG a s p gids n -> do
+            sv <- rd (off s)
+            let mis = misB BV.! p
+                gv j = pc `VS.unsafeIndex`
+                         (mis `VU.unsafeIndex` (gids `VU.unsafeIndex` j))
+            case len a of
+              0 -> do
+                av <- rd (off a)
+                let go !j | j >= n = pure ()
+                          | otherwise = do
+                              wr (o + j) (av + sv * gv j)
+                              go (j + 1)
+                go 0
+              _ -> do
+                let ao = off a
+                    go !j | j >= n = pure ()
+                          | otherwise = do
+                              av <- rd (ao + j)
+                              wr (o + j) (av + sv * gv j)
+                              go (j + 1)
+                go 0
+          VIMulVC s v c -> do
+            sv <- rd (off s)
+            let vo = off v
+                n  = VS.length c
+                go !j | j >= n = pure ()
+                      | otherwise = do
+                          b <- rd (vo + j)
+                          wr (o + j) (sv * b * (c `VS.unsafeIndex` j))
+                          go (j + 1)
+            go 0
+          VISumSqC2 c m1 m2 -> do
+            let m1o = off m1
+                m2o = off m2
+                n   = VS.length c
+                go !acc !j
+                  | j >= n = wr o acc
+                  | otherwise = do
+                      b1 <- rd (m1o + j)
+                      b2 <- rd (m2o + j)
+                      let d = c `VS.unsafeIndex` j - b1 - b2
+                      go (acc + d * d) (j + 1)
+            go 0 0
+          -- Phase 90 A11-4② (F2): gather 2 本内蔵の Σ(φ_a − φ_b)²。 gather 値は
+          -- pc から直読み (VIGath forward と同経路)・arena 実体化なし。
+          VISumSqDGG px gx pm gm n -> do
+            let misx = misB BV.! px
+                mism = misB BV.! pm
+                go !acc !j
+                  | j >= n = wr o acc
+                  | otherwise = do
+                      let a = pc `VS.unsafeIndex`
+                                (misx `VU.unsafeIndex` (gx `VU.unsafeIndex` j))
+                          b = pc `VS.unsafeIndex`
+                                (mism `VU.unsafeIndex` (gm `VU.unsafeIndex` j))
+                          d = a - b
+                      go (acc + d * d) (j + 1)
+            go 0 0
+      loop !i | i >= BV.length instrs = pure ()
+              | otherwise = step i >> loop (i + 1)
+  loop 0
+
+-- | IR の log-density **値** (観測尤度 + 族 prior)。 guard (σ/τ/λ ≤ 0・
+-- p ∉ (0,1) → -∞) は 'logDensityObs' / 'logDensity' の該当分岐と一致。
+vecIRValue :: CompiledVecIR -> VS.Vector Double -> Double
+vecIRValue cvi pc = runST $ do
+  let prog = cvProg cvi
+  ar <- forwardArena cvi pc
+  ok <- arenaGuardsOK prog ar
+  if ok then VSM.unsafeRead ar (vpOff prog `VU.unsafeIndex` vpObj prog)
+        else pure negInf
+
+-- | Phase 87.2b: 'gradVecIR' の value-and-grad 融合版。 forward arena を 1 度
+-- だけ構築し、 log-density **値** (objective slot・'vecIRValue' と同一) と
+-- constrained 勾配 (mg へ加算・'gradVecIR' と同一) を同時に返す。 NUTS の葉が
+-- leapfrog 最終勾配と同一点でエネルギー (logπ) を別途評価していた重複
+-- (prof 実測 19%) を除去するためのエントリポイント。 guard 違反 = Nothing
+-- (呼出側が 値 -∞ / 勾配 walk+ad fallback で従来意味論と一致させる)。
+gradVecIRVal :: CompiledVecIR -> VS.Vector Double -> VSM.MVector s Double
+             -> ST s (Maybe Double)
+gradVecIRVal cvi pc mg = do
+  let sz = vpSize (cvProg cvi)
+  ar  <- VSM.unsafeNew sz
+  adj <- VSM.unsafeNew sz
+  gradVecIRValWith cvi ar adj pc mg
+
+-- | 'gradVecIRVal' の呼出側バッファ版 (Phase 90 A11-4①)。 @ar@ / @adj@ は
+-- 長さ 'vpSize' の作業バッファで、 呼出間で再利用してよい (初期化不要・
+-- 毎回全上書き / zero-fill される)。 NUTS の葉勾配 closure が chain ごとに
+-- 1 度だけ確保して全 leapfrog で使い回すためのエントリポイント。
+gradVecIRValWith :: CompiledVecIR
+                 -> VSM.MVector s Double -> VSM.MVector s Double
+                 -> VS.Vector Double -> VSM.MVector s Double
+                 -> ST s (Maybe Double)
+gradVecIRValWith cvi ar adj pc mg = do
+  let prog = cvProg cvi
+  forwardArenaInto cvi pc ar
+  ok <- arenaGuardsOK prog ar
+  if not ok
+    then pure Nothing
+    else do
+      v <- VSM.unsafeRead ar (vpOff prog `VU.unsafeIndex` vpObj prog)
+      gradVecIRGoWith cvi pc ar adj mg
+      pure (Just v)
+
+-- | forward arena 上で値側 guard を検査 (vecIRValue / gradVecIR 共有)。
+arenaGuardsOK :: VecProgram -> VSM.MVector s Double -> ST s Bool
+arenaGuardsOK prog ar = fmap and (mapM gOK (vpGuards prog))
+  where
+    gOK (k, sl) = do
+      let o = vpOff prog `VU.unsafeIndex` sl
+          n = max 1 (vpLen prog `VU.unsafeIndex` sl)
+          chk = case k of
+            GPos  -> (> 0)
+            GUnit -> \pv -> pv > 0 && pv < 1
+          go !j | j >= n    = pure True
+                | otherwise = do
+                    v <- VSM.unsafeRead ar (o + j)
+                    if chk v then go (j + 1) else pure False
+      go 0
+
+-- | IR の constrained 勾配を mutable 勾配ベクトルへ**直接**加算する (Phase 56.2:
+-- 記号 reverse-mode・arena backward)。 forward arena と同形の随伴 arena に
+-- 逆順伝播し、 leaf 随伴は param 位置へその場で scatter。 命令列・形・
+-- オフセットは compile 時に固定済み = per-call の tape 構築なし。 勾配側は
+-- unguarded (54.11 の前例どおり・-∞ 状態は NUTS が値側で棄却する)。
+-- unconstrained への chain rule は呼出側。
+gradVecIR :: CompiledVecIR -> VS.Vector Double -> VSM.MVector s Double
+          -> ST s Bool
+gradVecIR cvi pc mg = do
+  let prog = cvProg cvi
+  ar <- forwardArena cvi pc
+  ok <- arenaGuardsOK prog ar
+  if not ok then pure False
+            else gradVecIRGo cvi pc ar mg >> pure True
+
+-- | 'gradVecIR' の backward 本体 (guard 通過後)。 Phase 85.3-iv: gather 内蔵
+-- 命令 (VIMulG/VIAxpyG) が gather 値を読むため pc (constrained params) を取る。
+gradVecIRGo
+  :: CompiledVecIR -> VS.Vector Double -> VSM.MVector s Double
+  -> VSM.MVector s Double -> ST s ()
+gradVecIRGo cvi pc ar mg = do
+  adj <- VSM.unsafeNew (vpSize (cvProg cvi))
+  gradVecIRGoWith cvi pc ar adj mg
+
+-- | 'gradVecIRGo' の呼出側 adj バッファ版 (Phase 90 A11-4①)。 zero-fill は
+-- 本関数が行う (旧 @VSM.replicate (vpSize prog) 0@ と同値) ため、 呼出側は
+-- 確保のみで初期化不要。
+gradVecIRGoWith
+  :: CompiledVecIR -> VS.Vector Double -> VSM.MVector s Double
+  -> VSM.MVector s Double -> VSM.MVector s Double -> ST s ()
+gradVecIRGoWith cvi pc ar adj mg = do
+  let prog   = cvProg cvi
+      instrs = vpInstrs prog
+      offV   = vpOff prog
+      lenV   = vpLen prog
+      misB   = BV.fromList (cvVecIxs cvi)
+      nSlots = BV.length instrs
+  VSM.set adj 0
+  let off i = offV `VU.unsafeIndex` i
+      len i = lenV `VU.unsafeIndex` i
+      rdV = VSM.unsafeRead ar
+      rdA = VSM.unsafeRead adj
+      addA o d = VSM.unsafeModify adj (+ d) o
+      addG ix d = VSM.unsafeModify mg (+ d) ix
+      step i = do
+        let o = off i
+        case instrs BV.! i of
+          VIK _  -> pure ()
+          VIKV _ -> pure ()
+          VILeafS p -> do
+            d <- rdA o
+            addG (cvScalIx cvi `VU.unsafeIndex` p) d
+          VILeafV p ->
+            let mis = misB BV.! p
+                go !j | j >= len i = pure ()
+                      | otherwise = do
+                          d <- rdA (o + j)
+                          addG (mis `VU.unsafeIndex` j) d
+                          go (j + 1)
+            in go 0
+          VIGath p gids n ->
+            let mis = misB BV.! p
+                go !r | r >= n = pure ()
+                      | otherwise = do
+                          d <- rdA (o + r)
+                          addG (mis `VU.unsafeIndex`
+                                  (gids `VU.unsafeIndex` r)) d
+                          go (r + 1)
+            in go 0
+          -- Phase 105 A3: withSUnD (INLINE CPS) で特殊化 (forward 側と同じ意図)。
+          VIUn op x -> withSUnD op $ \df -> do
+            let xo = off x
+            case len i of
+              0 -> do
+                a <- rdA o
+                v <- rdV xo
+                addA xo (df v * a)
+              n ->
+                let go !j | j >= n = pure ()
+                          | otherwise = do
+                              a <- rdA (o + j)
+                              v <- rdV (xo + j)
+                              addA (xo + j) (df v * a)
+                              go (j + 1)
+                in go 0
+          VIBin op x y -> do
+            let xo = off x
+                yo = off y
+                n  = max 1 (len i)
+                bx = len x /= 0   -- x がベクトルか
+                by = len y /= 0
+                xi j = if bx then xo + j else xo
+                yi j = if by then yo + j else yo
+            case op of
+              SAddO ->
+                let go !j | j >= n = pure ()
+                          | otherwise = do
+                              a <- rdA (o + j)
+                              addA (xi j) a
+                              addA (yi j) a
+                              go (j + 1)
+                in go 0
+              SSubO ->
+                let go !j | j >= n = pure ()
+                          | otherwise = do
+                              a <- rdA (o + j)
+                              addA (xi j) a
+                              addA (yi j) (negate a)
+                              go (j + 1)
+                in go 0
+              SMulO ->
+                let go !j | j >= n = pure ()
+                          | otherwise = do
+                              a  <- rdA (o + j)
+                              vx <- rdV (xi j)
+                              vy <- rdV (yi j)
+                              addA (xi j) (a * vy)
+                              addA (yi j) (a * vx)
+                              go (j + 1)
+                in go 0
+              SDivO ->
+                let go !j | j >= n = pure ()
+                          | otherwise = do
+                              a  <- rdA (o + j)
+                              vx <- rdV (xi j)
+                              vy <- rdV (yi j)
+                              addA (xi j) (a / vy)
+                              addA (yi j) (negate (a * vx / (vy * vy)))
+                              go (j + 1)
+                in go 0
+              -- winner-take-all subgradient (tie は測度0・x側に付与で十分)。
+              SMaxO ->
+                let go !j | j >= n = pure ()
+                          | otherwise = do
+                              a  <- rdA (o + j)
+                              vx <- rdV (xi j)
+                              vy <- rdV (yi j)
+                              if vx >= vy
+                                then addA (xi j) a
+                                else addA (yi j) a
+                              go (j + 1)
+                in go 0
+          VISum x -> do
+            a <- rdA o
+            let xo = off x
+                n  = len x
+                go !j | j >= n = pure ()
+                      | otherwise = addA (xo + j) a >> go (j + 1)
+            go 0
+          -- Phase 85.3-ii superinstruction: out = a + s·v の随伴 =
+          -- adj a += g (スカラ a は Σg)・adj s += Σ g·v・adj v += g·s。
+          VIAxpy a s v -> do
+            sv <- rdV (off s)
+            let vo = off v
+                ao = off a
+                n  = len i
+            case len a of
+              0 ->
+                let go !ga !gs !j
+                      | j >= n = addA ao ga >> addA (off s) gs
+                      | otherwise = do
+                          g  <- rdA (o + j)
+                          bv <- rdV (vo + j)
+                          addA (vo + j) (g * sv)
+                          go (ga + g) (gs + g * bv) (j + 1)
+                in go 0 0 0
+              _ ->
+                let go !gs !j
+                      | j >= n = addA (off s) gs
+                      | otherwise = do
+                          g  <- rdA (o + j)
+                          bv <- rdV (vo + j)
+                          addA (ao + j) g
+                          addA (vo + j) (g * sv)
+                          go (gs + g * bv) (j + 1)
+                in go 0 0
+          VIAxpyC a s c -> do
+            let ao = off a
+                n  = len i
+            case len a of
+              0 ->
+                let go !ga !gs !j
+                      | j >= n = addA ao ga >> addA (off s) gs
+                      | otherwise = do
+                          g <- rdA (o + j)
+                          go (ga + g) (gs + g * (c `VS.unsafeIndex` j)) (j + 1)
+                in go 0 0 0
+              _ ->
+                let go !gs !j
+                      | j >= n = addA (off s) gs
+                      | otherwise = do
+                          g <- rdA (o + j)
+                          addA (ao + j) g
+                          go (gs + g * (c `VS.unsafeIndex` j)) (j + 1)
+                in go 0 0
+          -- out = Σ(x−m)² の随伴 = adj x_j += 2(x_j−m_j)·g・adj m_j −= 同
+          -- (スカラ側は Σ を単発加算)。 2(x−m)g は旧 (r·r 同一 slot 2 加算 +
+          -- SSubO 伝播) と IEEE 同値 (x+x ≡ 2x)。
+          VISumSqD x m -> do
+            g <- rdA o
+            let xo = off x
+                mo = off m
+                bx = len x /= 0
+                bm = len m /= 0
+                n  = max (len x) (len m)
+                go !sx !sm !j
+                  | j >= n = do
+                      if bx then pure () else addA xo sx
+                      if bm then pure () else addA mo sm
+                  | otherwise = do
+                      a <- rdV (if bx then xo + j else xo)
+                      b <- rdV (if bm then mo + j else mo)
+                      let d = 2 * (a - b) * g
+                      if bx then addA (xo + j) d          else pure ()
+                      if bm then addA (mo + j) (negate d) else pure ()
+                      go (if bx then sx else sx + d)
+                         (if bm then sm else sm - d) (j + 1)
+            go 0 0 0
+          VISumSqC c m -> do
+            g <- rdA o
+            let mo = off m
+                bm = len m /= 0
+                n  = VS.length c
+                go !sm !j
+                  | j >= n = if bm then pure () else addA mo sm
+                  | otherwise = do
+                      b <- rdV (if bm then mo + j else mo)
+                      let d = 2 * (c `VS.unsafeIndex` j - b) * g
+                      if bm then addA (mo + j) (negate d) >> go sm (j + 1)
+                            else go (sm - d) (j + 1)
+            go 0 0
+          -- Phase 85.3-iv superinstruction: gather 内蔵命令の随伴は leaf
+          -- (param) へ直接 scatter ('VIGath' backward と同じ) + gather 値は
+          -- pc から読む。
+          VIMulG s p gids n -> do
+            sv <- rdV (off s)
+            let mis = misB BV.! p
+                go !gs !j
+                  | j >= n = addA (off s) gs
+                  | otherwise = do
+                      g <- rdA (o + j)
+                      let ix = mis `VU.unsafeIndex` (gids `VU.unsafeIndex` j)
+                      addG ix (g * sv)
+                      go (gs + g * (pc `VS.unsafeIndex` ix)) (j + 1)
+            go 0 0
+          VIAxpyG a s p gids n -> do
+            sv <- rdV (off s)
+            let mis = misB BV.! p
+                ao  = off a
+            case len a of
+              0 ->
+                let go !ga !gs !j
+                      | j >= n = addA ao ga >> addA (off s) gs
+                      | otherwise = do
+                          g <- rdA (o + j)
+                          let ix = mis `VU.unsafeIndex` (gids `VU.unsafeIndex` j)
+                          addG ix (g * sv)
+                          go (ga + g) (gs + g * (pc `VS.unsafeIndex` ix)) (j + 1)
+                in go 0 0 0
+              _ ->
+                let go !gs !j
+                      | j >= n = addA (off s) gs
+                      | otherwise = do
+                          g <- rdA (o + j)
+                          let ix = mis `VU.unsafeIndex` (gids `VU.unsafeIndex` j)
+                          addA (ao + j) g
+                          addG ix (g * sv)
+                          go (gs + g * (pc `VS.unsafeIndex` ix)) (j + 1)
+                in go 0 0
+          VIMulVC s v c -> do
+            sv <- rdV (off s)
+            let vo = off v
+                n  = VS.length c
+                go !gs !j
+                  | j >= n = addA (off s) gs
+                  | otherwise = do
+                      g <- rdA (o + j)
+                      b <- rdV (vo + j)
+                      let cj = c `VS.unsafeIndex` j
+                      addA (vo + j) (g * sv * cj)
+                      go (gs + g * b * cj) (j + 1)
+            go 0 0
+          VISumSqC2 c m1 m2 -> do
+            g <- rdA o
+            let m1o = off m1
+                m2o = off m2
+                n   = VS.length c
+                go !j | j >= n = pure ()
+                      | otherwise = do
+                          b1 <- rdV (m1o + j)
+                          b2 <- rdV (m2o + j)
+                          let d = 2 * (c `VS.unsafeIndex` j - b1 - b2) * g
+                          addA (m1o + j) (negate d)
+                          addA (m2o + j) (negate d)
+                          go (j + 1)
+            go 0
+          -- Phase 90 A11-4② (F2): 随伴 = ∂/∂φ_a[Σ(φ_a−φ_b)²] = 2(φ_a−φ_b)·g を
+          -- param へ直 scatter (φ_b は −同)。 gather 値は pc から直読み。
+          VISumSqDGG px gx pm gm n -> do
+            g <- rdA o
+            let misx = misB BV.! px
+                mism = misB BV.! pm
+                go !j | j >= n = pure ()
+                      | otherwise = do
+                          let ixa = misx `VU.unsafeIndex` (gx `VU.unsafeIndex` j)
+                              ixb = mism `VU.unsafeIndex` (gm `VU.unsafeIndex` j)
+                              d = 2 * (pc `VS.unsafeIndex` ixa
+                                       - pc `VS.unsafeIndex` ixb) * g
+                          addG ixa d
+                          addG ixb (negate d)
+                          go (j + 1)
+            go 0
+      loop !i | i < 0     = pure ()
+              | otherwise = step i >> loop (i - 1)
+  VSM.unsafeWrite adj (off (vpObj prog)) 1
+  loop (nSlots - 1)
diff --git a/src/Hanalyze/Model/HBM/Interp.hs b/src/Hanalyze/Model/HBM/Interp.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Model/HBM/Interp.hs
@@ -0,0 +1,1760 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE RankNTypes #-}
+{-# LANGUAGE FlexibleContexts #-}
+
+-- |
+-- Module      : Hanalyze.Model.HBM.Interp
+-- Description : HBM dialog DSL の評価系 (interpreter) と NUTS 設定・結果整形
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- HBM dialog DSL の評価系 (interpreter) + NUTS 設定 reader + 結果整形。
+--
+-- Phase 27.5 (2026-05-31) step 1: canvas-backend @フロントエンド app.Analysis.HBM@
+-- から eval/interp/curve コアを移設。 frontend が backend 統一 parser から得た
+-- @program_ast@ を streaming sidecar が直接 interpret して実モデルを構築できる
+-- よう、 DSL の評価系をライブラリ層 (hanalyze) に置く。
+--
+-- 本 module は **canvas wire 型 (AnalysisRequest 等) にも text parser
+-- (DSL frontend) にも依存しない**。 依存は 'Hanalyze.Model.HBM.Ast' (AST) +
+-- @Hanalyze.Model.HBM@ (ModelP/Distribution) + @Hanalyze.Stat.*@ /
+-- @Hanalyze.MCMC.*@ + aeson / hmatrix のみ。
+--
+-- text → AST 変換 (@parseHbmText@ 経路) と canvas 専用の @runHbm@ /
+-- @buildDataMap@ / @ProgramInfo@ 解決は canvas-backend 側に残す。
+module Hanalyze.Model.HBM.Interp
+  ( -- * core types
+    DataMap
+  , Column (..)
+  , colDoubles
+  , colLength
+  , colLevels
+  , lookupDoubles
+  , EnvA
+  , Value (..)
+  , PlateCtx (..)
+  , topCtx
+  , TopBind (..)
+  , ParamSummary (..)
+  , HbmMeanCurve (..)
+    -- * evaluation
+  , hasColRef
+  , liftD
+  , builtinTable
+  , asNum
+  , asBool
+  , asList
+  , asMatrix
+  , evalScalar
+  , evalValue
+  , evalDist
+  , buildTopEnv
+    -- * plate / groups (GLMM forEachGroup)
+  , matchForEachGroup
+  , lamBodyToStmts
+  , retToStmts
+  , groupValsIn
+  , rowsForGroup
+  , groupSuffix
+  , groupSuffixFor
+    -- * validation / interpretation
+  , inferTransforms
+  , validateAst
+  , preprocessAliases
+  , validateStmts
+  , interpStmts
+  , observeNodeMap
+    -- * NUTS config readers
+  , readChainCount
+  , readNutsConfig
+    -- * result shaping
+  , paramSummaryMulti
+  , fmtSummary
+  , round4
+  , takeEvery
+  , summaryToJson
+  , hbmMeanCurveToJson
+  , extractObserveMeans
+  , collectCols
+  , percentileOf
+  , computeMeanCurves
+    -- * WAIC / LOO / posterior predictive
+  , ObsDistSet (..)
+  , computeObsDists
+  , pointwiseLogLik
+  , finitePointwiseLogLik
+    -- * Phase 44: multi-column observe (observeMV) WAIC / PPC
+  , MvObsDistSet (..)
+  , computeMvObsDists
+  , pointwiseLogLikMv
+  , reconstructMatrixComb
+  , MatrixCombSpec (..)
+    -- * model graph plate aggregation (GLMM forEachGroup)
+  , GraphPlate (..)
+  , plateRenameMap
+  , collectGraphPlates
+  , collapsePlateGraph
+  ) where
+
+import Control.Monad (forM_, when)
+import Data.Char (isAlpha, isAlphaNum)
+import Data.List (sort, nub, transpose)
+import Data.Maybe (fromMaybe)
+import qualified Data.Set as Set
+import qualified Data.Aeson as A
+import qualified Data.Aeson.Key as Key
+import qualified Data.Aeson.KeyMap as KM
+import qualified Data.Map.Strict as Map
+import Data.Text (Text)
+import qualified Data.Text as T
+import qualified Numeric.LinearAlgebra as LA
+
+import qualified Hanalyze.MCMC.Core as MC
+import qualified Hanalyze.MCMC.NUTS as NUTS
+import qualified Hanalyze.Model.HBM as HBM
+import qualified Hanalyze.Stat.Distribution as HD
+import qualified Hanalyze.Stat.MCMC as SMC
+
+import Hanalyze.Model.HBM.Ast
+  ( Expr (..)
+  , Lit (..)
+  , Bind (..)
+  , DoStmt (..)
+  , collectApp
+  , Err
+  )
+
+-- ===========================================================================
+-- Interpreter コア型
+-- ===========================================================================
+
+-- | 列参照を含む式かどうか(observe の dist 引数に列が混じったら per-row 展開する)。
+hasColRef :: Expr -> Bool
+hasColRef = go
+  where
+    go (ECol _)       = True
+    go (EApp f x)     = go f || go x
+    go (ENeg e)       = go e
+    go (EOp _ a b)    = go a || go b
+    go (EList xs)     = any go xs
+    go (ELet bs e)    = any (go . bindValue) bs || go e
+    go (EIf c a b)    = go c || go a || go b
+    go (ELam _ b)     = go b
+    go (EDo _ _)      = False  -- nested do 不可
+    go (ELit _)       = False
+    go (EVar _)       = False
+
+-- 'Err' (= Either Text) は Hanalyze.Model.HBM.Ast から import。
+
+-- | スカラ式評価。col 参照は許可されるなら row idx を指定。Nothing なら無効でエラー。
+liftD :: Floating a => Double -> a
+liftD d = fromRational (toRational d)
+
+-- | 環境: sample / let / top-level で束縛された値 (数値・真偽・関数)。
+-- Phase 27 §F-3a で scalar から 'Value' に拡張 (ユーザ定義関数を持てる)。
+type EnvA a = Map.Map Text (Value a)
+
+-- | データ列の値 (Phase 41)。 数値列 (連続/整数) と categorical 列
+-- (factor = level 辞書 + 整数 code) を区別する sum 型。 'Numeric' は従来の
+-- @[Double]@ 相当で後方互換、 'Factor' は R の factor / PyMC coords 相当
+-- (level 出現順に 0,1,2,... の code を振る)。
+data Column
+  = Numeric ![Double]                                     -- ^ 連続 / 整数列
+  | Factor  { facLevels :: ![Text], facCodes :: ![Int] }  -- ^ categorical
+  deriving (Eq, Show)
+
+-- | データ列の Map。 列名 → 'Column'。
+type DataMap = Map.Map Text Column
+
+-- | 列を @[Double]@ として見る (群比較 / 数値 observe / mean-curve 用)。
+-- 'Numeric' はそのまま、 'Factor' は code を Double 化 (0,1,2,...)。 既存の
+-- 数値ロジックはこの accessor 経由で Factor も透過に扱える。
+colDoubles :: Column -> [Double]
+colDoubles (Numeric xs)  = xs
+colDoubles (Factor _ cs) = map fromIntegral cs
+
+-- | 列長 (行数)。
+colLength :: Column -> Int
+colLength (Numeric xs)  = length xs
+colLength (Factor _ cs) = length cs
+
+-- | 'Factor' なら level 辞書 (出現順)、 'Numeric' なら Nothing。
+colLevels :: Column -> Maybe [Text]
+colLevels (Factor ls _) = Just ls
+colLevels (Numeric _)   = Nothing
+
+-- | 列を @[Double]@ として引く (無ければ空)。 旧 @Map.findWithDefault [] k dm@ の
+-- 'Column' 対応版。
+lookupDoubles :: Text -> DataMap -> [Double]
+lookupDoubles k = maybe [] colDoubles . Map.lookup k
+
+-- | Phase 27 §F-3a: モデル本体評価中の値。 Double 閉の数値 + 真偽 +
+-- 一階クロージャ (ユーザ定義関数 / lambda) + 組込関数マーカ + 遅延エラー
+-- (top-level 値束縛の評価失敗を lookup まで遅延運搬する)。
+data Value a
+  = VNum a
+  | VBool Bool
+  | VList [Value a]                -- Phase 42: list リテラル ([e₁, …, eₙ])。 多値分布
+                                   --   (Categorical [probs] / OrderedLogistic eta [cuts])
+                                   --   の list 引数評価の土台。 要素はスカラ閉前提。
+  | VClosure (EnvA a) [Text] Expr  -- 捕捉環境, 残り仮引数, 本体 (一階・カリー化)
+  | VBuiltin Text Int              -- 組込関数 (名前, arity)。 適用時に builtinTable で解決
+  | VErr Text                      -- 遅延エラー (top-level 値 thunk の評価失敗)
+
+-- | Phase 27 §F-3a: 組込数学関数ホワイトリスト (name → (arity, impl))。
+-- すべて Double 上で閉じる純関数 (IO/import なし)。 GLM リンク
+-- (invLogit/logistic) もここに含む。 必要に応じ追加。
+builtinTable :: forall a. (Floating a, Ord a) => Map.Map Text (Int, [a] -> a)
+builtinTable = Map.fromList
+  [ ("exp",      (1, \xs -> exp (head xs)))
+  , ("log",      (1, \xs -> log (head xs)))
+  , ("log1p",    (1, \xs -> log (1 + head xs)))
+  , ("sqrt",     (1, \xs -> sqrt (head xs)))
+  , ("abs",      (1, \xs -> abs (head xs)))
+  , ("signum",   (1, \xs -> signum (head xs)))
+  , ("recip",    (1, \xs -> recip (head xs)))
+  , ("negate",   (1, \xs -> negate (head xs)))
+  , ("tanh",     (1, \xs -> tanh (head xs)))
+  , ("sin",      (1, \xs -> sin (head xs)))
+  , ("cos",      (1, \xs -> cos (head xs)))
+  , ("logistic", (1, \xs -> 1 / (1 + exp (negate (head xs)))))
+  , ("invLogit", (1, \xs -> 1 / (1 + exp (negate (head xs)))))
+  , ("min",      (2, \xs -> min (xs !! 0) (xs !! 1)))
+  , ("max",      (2, \xs -> max (xs !! 0) (xs !! 1)))
+  ]
+
+-- | Phase 43: list builtin (VList→VList) の名前集合。 スカラ 'builtinTable'
+-- (= @[a] -> a@) には乗らないので別管理。 softmax 多項ロジット
+-- (@Categorical (softmax [η₀, η₁, …])@) で多クラス線形予測子を確率に変換する。
+listBuiltins :: Set.Set Text
+listBuiltins = Set.fromList ["softmax"]
+
+-- | 安定 softmax (= @exp(xₖ − max x) / Σ@)。 識別性のため基準クラスは η=0 を
+-- 明示的に並べる前提 (例 @softmax [0, b₁·x, b₂·x]@)。 空リストはエラー。
+softmaxList :: forall a. (Floating a, Ord a) => [a] -> Err [a]
+softmaxList [] = Left "softmax: 空リストには適用できません (クラス数 ≥ 1 の [..] が必要です)"
+softmaxList xs =
+  let m  = maximum xs
+      es = map (\x -> exp (x - m)) xs
+      s  = sum es
+  in Right (map (/ s) es)
+
+-- | Value を数値に落とす (= 親切な日本語エラー)。
+asNum :: Value a -> Err a
+asNum (VNum x)     = Right x
+asNum (VBool _)    = Left "真偽値が数値の位置に現れました (比較式を算術に混ぜていませんか)"
+asNum (VClosure{}) = Left "関数が数値の位置に現れました (引数不足か、 適用し忘れ)"
+asNum (VBuiltin n _) = Left ("組込関数 " <> n <> " が数値の位置に現れました (引数を渡してください)")
+asNum (VList _)    = Left "リストが数値の位置に現れました (list 引数はスカラとして使えません)"
+asNum (VErr msg)   = Left msg
+
+-- | Value を真偽に落とす。
+asBool :: Value a -> Err Bool
+asBool (VBool b)    = Right b
+asBool (VNum _)     = Left "数値が真偽の位置に現れました (if の条件は比較式である必要があります)"
+asBool (VClosure{}) = Left "関数が真偽の位置に現れました"
+asBool (VBuiltin n _) = Left ("組込関数 " <> n <> " が真偽の位置に現れました")
+asBool (VList _)    = Left "リストが真偽の位置に現れました"
+asBool (VErr msg)   = Left msg
+
+-- | Value を list に落とす (Phase 42: 多値分布の list 引数評価)。 各要素は
+-- 'asNum' でスカラに落とせる前提。
+asList :: Value a -> Err [Value a]
+asList (VList xs)    = Right xs
+asList (VNum _)      = Left "数値がリストの位置に現れました (list 引数には [..] を渡してください)"
+asList (VBool _)     = Left "真偽値がリストの位置に現れました"
+asList (VClosure{})  = Left "関数がリストの位置に現れました"
+asList (VBuiltin n _) = Left ("組込関数 " <> n <> " がリストの位置に現れました")
+asList (VErr msg)    = Left msg
+
+-- | Value を行列 ([[a]]) に落とす (Phase 44: MvNormal の cov / lkjCorrCholesky の
+-- 行列値を評価する用途)。 VList-of-VList を期待し、 各内側 VList の長さ一致と
+-- 数値性を検査する。 list 操作で書くのは DSL スカラ値 'Value' 上の小さい構造
+-- 変換のためで、 hmatrix Matrix 経路ではない (density 計算側の choleskyL /
+-- forwardSub は既存実装を流用)。
+asMatrix :: Value a -> Err [[a]]
+asMatrix v = do
+  rows <- asList v
+  mat  <- mapM (\r -> asList r >>= mapM asNum) rows
+  case mat of
+    []        -> Left "行列が空です (cov / 行列引数には [[..],..] が必要です)"
+    (r0 : rs)
+      | all ((== length r0) . length) rs -> Right mat
+      | otherwise -> Left "行列の各行の長さが揃っていません (cov は正方行列である必要があります)"
+
+-- | スカラ式評価 (= 数値を返す)。 'evalValue' の薄いラッパ。 col 参照は
+-- mi=Just i なら行 i、 Nothing なら不可。
+evalScalar
+  :: forall a. (Floating a, Ord a)
+  => EnvA a -> DataMap -> Maybe Int -> Expr -> Err a
+evalScalar env dataMap mi e = evalValue env dataMap mi e >>= asNum
+
+-- | Phase 27 §F-3a: 式を 'Value' に評価する interpreter 中核。
+-- 適用 (EApp) / if (EIf) / 比較・論理 (EOp) / lambda (ELam) / let を解釈。
+-- ユーザ定義関数 (一階・カリー化) と組込数学関数を呼べる。
+-- 再帰なし (total) ・ IO/ADT/型クラスなし。
+evalValue
+  :: forall a. (Floating a, Ord a)
+  => EnvA a -> DataMap -> Maybe Int -> Expr -> Err (Value a)
+evalValue env dataMap mi = go
+  where
+    go :: Expr -> Err (Value a)
+    go (ELit (LNumber d)) = Right (VNum (liftD d))
+    go (ELit (LBool b))   = Right (VBool b)
+    go (ELit (LText t))   = Left ("文字列リテラル \"" <> t <> "\" は数式の中では使えません")
+    go (EVar n) = case Map.lookup n env of
+      Just v  -> Right v
+      Nothing -> case Map.lookup n (builtinTable :: Map.Map Text (Int, [a] -> a)) of
+        Just (ar, _) -> Right (VBuiltin n ar)
+        Nothing
+          -- Phase 43: list builtin (softmax 等、 VList→VList) はスカラ
+          -- builtinTable に乗らないので別途 VBuiltin として解決する。
+          | n `Set.member` listBuiltins -> Right (VBuiltin n 1)
+          | otherwise -> Left ("未定義の変数です: " <> n)
+    go (ECol c) = case mi of
+      Nothing -> Left ("列参照 '" <> c <> "' は行ごとの評価文脈 (observe の per-row) でのみ使えます")
+      Just i  -> case Map.lookup c dataMap of
+        Just col -> case drop i (colDoubles col) of
+          (x : _) -> Right (VNum (liftD x))
+          []      -> Left ("列のインデックスが範囲外です: " <> c <> "[" <> T.pack (show i) <> "]")
+        Nothing -> Left ("未知の列です: " <> c)
+    go (ENeg e) = do v <- go e; x <- asNum v; Right (VNum (negate x))
+    go (EOp op a b) = evalOp op a b
+    go (EIf c a b) = do
+      cv <- go c
+      cond <- asBool cv
+      if cond then go a else go b
+    go (ELet bs body) = do
+      env' <- foldEnv env bs
+      evalValue env' dataMap mi body
+    go (ELam x body) = Right (VClosure env [x] body)
+    go appE@(EApp _ _) =
+      let (h, args) = spine appE []
+      in do
+        hv <- go h
+        argVs <- mapM go args
+        applyValue hv argVs
+    -- Phase 42: list リテラルを VList に評価。 多値分布 (Categorical /
+    -- OrderedLogistic) の list 引数で使う。 要素は順次スカラ評価される。
+    go (EList xs) = VList <$> mapM go xs
+    go (EDo _ _) = Left "入れ子の do ブロックは未対応です"
+
+    -- 適用の脊柱を平坦化: f x y → (f, [x, y])。
+    spine :: Expr -> [Expr] -> (Expr, [Expr])
+    spine (EApp f x) acc = spine f (x : acc)
+    spine e acc = (e, acc)
+
+    -- 値を引数列に適用 (一階・カリー化)。
+    applyValue :: Value a -> [Value a] -> Err (Value a)
+    applyValue v [] = Right v
+    -- Phase 43: list builtin (softmax)。 VList を取り VList を返す (多項ロジット:
+    -- Categorical (softmax [η₀, η₁, …]))。 スカラ builtinTable とは別経路。
+    applyValue (VBuiltin "softmax" _) args = case args of
+      [VList xs] -> do ns <- mapM asNum xs; VList . map VNum <$> softmaxList ns
+      [_]        -> Left "softmax はリスト引数 ([..]) が必要です"
+      _          -> Left ("softmax はリスト引数 1 個が必要ですが " <> T.pack (show (length args)) <> " 個でした")
+    applyValue (VBuiltin name ar) args =
+      case Map.lookup name (builtinTable :: Map.Map Text (Int, [a] -> a)) of
+        Nothing -> Left ("内部エラー: 未知の組込関数 " <> name)
+        Just (_, impl)
+          | length args == ar -> do ns <- mapM asNum args; Right (VNum (impl ns))
+          | length args <  ar -> Left ("組込関数 " <> name <> " は引数 "
+              <> T.pack (show ar) <> " 個が必要ですが " <> T.pack (show (length args))
+              <> " 個でした (部分適用は未対応)")
+          | otherwise -> Left ("組込関数 " <> name <> " に引数が多すぎます (必要 "
+              <> T.pack (show ar) <> " 個)")
+    applyValue (VClosure cenv params body) args = applyClosure cenv params body args
+    applyValue (VNum _) _  = Left "数値を関数として適用しています (関数ではない値に引数を渡しています)"
+    applyValue (VBool _) _ = Left "真偽値を関数として適用しています"
+    applyValue (VList _) _ = Left "リストを関数として適用しています"
+    applyValue (VErr m) _  = Left m
+
+    -- クロージャ適用: 引数を仮引数に順次束縛。 仮引数が尽きたら (= 完全
+    -- 適用) 本体を評価して残り引数をさらに適用、 引数が尽きたが仮引数が
+    -- 残るなら部分適用 (VClosure を返す)。 節順が重要: 完全適用
+    -- (ps=[], args=[]) は本体評価を先に判定する。
+    applyClosure :: EnvA a -> [Text] -> Expr -> [Value a] -> Err (Value a)
+    applyClosure cenv [] body args = do
+      r <- evalValue cenv dataMap mi body
+      applyValue r args
+    applyClosure cenv ps body [] = Right (VClosure cenv ps body)
+    applyClosure cenv (p : ps) body (a : as) =
+      applyClosure (Map.insert p a cenv) ps body as
+
+    evalOp :: Text -> Expr -> Expr -> Err (Value a)
+    evalOp op a b = case op of
+      "+"  -> num2 (+)
+      "-"  -> num2 (-)
+      "*"  -> num2 (*)
+      "/"  -> num2 (/)
+      "**" -> num2 (**)
+      "^"  -> num2 (**)   -- DSL では Double 冪 (整数冪に限定しない)
+      "==" -> cmp (==)
+      "/=" -> cmp (/=)
+      "<"  -> cmp (<)
+      "<=" -> cmp (<=)
+      ">"  -> cmp (>)
+      ">=" -> cmp (>=)
+      "&&" -> bool2 (&&)
+      "||" -> bool2 (||)
+      _    -> Left ("モデル中で未対応の演算子です: " <> op)
+      where
+        num2 f  = do av <- asNum =<< go a; bv <- asNum =<< go b; Right (VNum (f av bv))
+        cmp f   = do av <- asNum =<< go a; bv <- asNum =<< go b; Right (VBool (f av bv))
+        bool2 f = do av <- asBool =<< go a; bv <- asBool =<< go b; Right (VBool (f av bv))
+
+    foldEnv :: EnvA a -> [Bind] -> Err (EnvA a)
+    foldEnv e [] = Right e
+    foldEnv e (Bind n v : rest) = do
+      vv <- evalValue e dataMap mi v
+      foldEnv (Map.insert n vv e) rest
+
+-- ===========================================================================
+-- Phase 27 §F-3a: top-level 束縛環境
+-- ===========================================================================
+
+-- | 1 つの top-level 値/関数束縛 (= ユーザが model と並べて書く
+-- `tmpvar = 1` / `linkfunc x = log x`)。 model (= do-block 束縛) は含まない。
+data TopBind = TopBind
+  { tbName   :: Text
+  , tbParams :: [Text]
+  , tbBody   :: Expr
+  } deriving (Show)
+
+-- | top-level 束縛から評価環境を組む。 値束縛 (引数なし) は env の中で
+-- 遅延評価して 'Value' に (相互参照は laziness で解決、 再帰は無い前提)。
+-- 関数束縛 (引数あり) は env を捕捉した 'VClosure' に。 評価失敗は 'VErr'
+-- として運び、 実際に参照された時にエラーを出す。
+buildTopEnv :: forall a. (Floating a, Ord a) => DataMap -> [TopBind] -> EnvA a
+buildTopEnv dataMap binds = env
+  where
+    env :: EnvA a
+    env = Map.fromList [ (tbName b, toVal b) | b <- binds ]
+    toVal b
+      | null (tbParams b) = case evalValue env dataMap Nothing (tbBody b) of
+          Right v -> v
+          Left e  -> VErr e
+      | otherwise = VClosure env (tbParams b) (tbBody b)
+
+-- ===========================================================================
+-- Phase 27 §F-3c: GLMM plate (forEachGroup)
+-- ===========================================================================
+--
+-- `forEachGroup "gcol" $ \g -> do { … }` は群列 gcol の distinct 値ごとに
+-- 内部 do-block を展開する専用構文。 native ModelP の
+-- `forM_ groups $ \j -> do { theta <- sample ("theta_"++show j) …; observe … }`
+-- (= Phase37 demo randomSlope/multiLevel) を AST 経由で再現する。
+--   * sample / observe 名は群値で suffix を付け、 群ごとに別 latent にする。
+--   * observe は当該群の行のみを対象にする (= 行サブセット)。
+--   * lambda 引数 g は群値 (Double) に束縛 (式で使える)。
+--   * nest 可能 (forEachGroup の中に forEachGroup): suffix 連結 + 行積集合。
+
+-- | plate 評価コンテキスト。 top-level は 'topCtx' = ("", 全行) で従来挙動を保つ。
+data PlateCtx = PlateCtx
+  { pcSuffix :: Text          -- sample/observe 名に付ける suffix (例 "_1_2")
+  , pcRows   :: Maybe [Int]    -- observe 対象行 (Nothing = 全行)
+  }
+
+topCtx :: PlateCtx
+topCtx = PlateCtx "" Nothing
+
+-- | `forEachGroup "gcol" (\g -> do { … })` を検出し (群列, 引数名, 内部 stmts)。
+matchForEachGroup :: Expr -> Maybe (Text, Text, [DoStmt])
+matchForEachGroup e = case collectApp e of
+  Right ("forEachGroup", [ELit (LText gcol), ELam param body]) ->
+    Just (gcol, param, lamBodyToStmts body)
+  _ -> Nothing
+
+-- | lambda 本体を DoStmt 列に。 do なら stmts + 末尾式、 それ以外は単一 DoExpr。
+lamBodyToStmts :: Expr -> [DoStmt]
+lamBodyToStmts (EDo stmts ret) = stmts ++ retToStmts ret
+lamBodyToStmts other           = [DoExpr other]
+
+-- | do-block 末尾式: pure/return は捨て、 それ以外 (observe 等) は DoExpr に。
+retToStmts :: Expr -> [DoStmt]
+retToStmts r = case r of
+  EApp (EVar "pure") _   -> []
+  EApp (EVar "return") _ -> []
+  ELit (LBool _)         -> []
+  _                      -> [DoExpr r]
+
+-- | ctx の対象行に限定した群列の distinct 値 (昇順)。
+groupValsIn :: DataMap -> Text -> Maybe [Int] -> [Double]
+groupValsIn dm gcol mrows =
+  let col  = lookupDoubles gcol dm
+      idxs = fromMaybe [0 .. length col - 1] mrows
+  in sort (nub [ col !! i | i <- idxs, i >= 0, i < length col ])
+
+-- | ctx の対象行のうち 群列 == gval の行 index。
+rowsForGroup :: DataMap -> Text -> Double -> Maybe [Int] -> [Int]
+rowsForGroup dm gcol gval mrows =
+  let col  = lookupDoubles gcol dm
+      idxs = fromMaybe [0 .. length col - 1] mrows
+  in [ i | i <- idxs, i >= 0, i < length col, col !! i == gval ]
+
+-- | 群値を name suffix に (整数なら "_3"、 非整数なら小数表記)。
+-- 群列は整数コード前提なので通常は整数 suffix。
+groupSuffix :: Double -> Text
+groupSuffix g
+  | g == fromIntegral (round g :: Integer) = "_" <> T.pack (show (round g :: Integer))
+  | otherwise                              = "_" <> T.pack (show g)
+
+-- | 群 suffix (Phase 41.4)。 群列が 'Factor' で code が level を指し、 その
+-- level が安全な識別子 (先頭英字/下線 + 英数字/下線のみ) なら可読 suffix
+-- "_<level>" (例 "_setosa")、 それ以外は 'groupSuffix' (数値 code suffix) に
+-- フォールバック。 charset / 衝突安全のため不安全な level は code に落とす。
+-- interpStmts / collectObsInstances / plateRenameMap の 3 経路で同一規律を使う
+-- 必要がある (node 名が一致しないと観測値/グラフが噛み合わない)。
+groupSuffixFor :: Maybe Column -> Double -> Text
+groupSuffixFor (Just (Factor levels _)) g
+  | i >= 0, i < length levels, isSafeIdent (levels !! i) = "_" <> (levels !! i)
+  where i = round g :: Int
+groupSuffixFor _ g = groupSuffix g
+
+-- | node 名 suffix に使える安全な識別子か (先頭英字/下線、 以降英数字/下線)。
+isSafeIdent :: Text -> Bool
+isSafeIdent t = case T.uncons t of
+  Nothing          -> False
+  Just (c0, rest)  -> (isAlpha c0 || c0 == '_')
+                        && T.all (\c -> isAlphaNum c || c == '_') rest
+
+-- | Distribution AST を Hanalyze.Model.HBM.Distribution に変換。
+-- mi が Nothing なら列参照不可。 mi=Just i なら行 i で評価。
+evalDist
+  :: forall a. (Floating a, Ord a)
+  => EnvA a -> DataMap -> Maybe Int -> Expr -> Err (HBM.Distribution a)
+evalDist env dataMap mi expr = do
+  (name, args) <- collectApp expr
+  case (name, args) of
+    ("Normal",     [m, s])    -> mk2 HBM.Normal m s
+    ("HalfNormal", [s])       -> mk1 HBM.HalfNormal s
+    ("Beta",       [a, b])    -> mk2 HBM.Beta a b
+    ("Gamma",      [s, r])    -> mk2 HBM.Gamma s r
+    ("Exponential", [r])      -> mk1 HBM.Exponential r
+    ("Poisson",    [l])       -> mk1 HBM.Poisson l
+    ("Bernoulli",  [p])       -> mk1 HBM.Bernoulli p
+    ("Uniform",    [l, h])    -> mk2 HBM.Uniform l h
+    ("StudentT",   [df, m, s]) -> mk3 HBM.StudentT df m s
+    ("Cauchy",     [l, s])    -> mk2 HBM.Cauchy l s
+    ("HalfCauchy", [s])       -> mk1 HBM.HalfCauchy s
+    ("LogNormal",  [m, s])    -> mk2 HBM.LogNormal m s
+    -- Phase 42: 多値 categorical 応答。 list 引数は VList 経由でスカラ列に
+    -- 評価する (observe は factor code 0..K-1)。 2 値応答は Bernoulli +
+    -- factor code 0/1 で Phase 41.5 対応済。
+    ("Categorical", [probs])  -> HBM.Categorical <$> evalList probs
+    ("OrderedLogistic", [eta, cuts]) -> HBM.OrderedLogistic <$> eval eta <*> evalList cuts
+    -- Phase 44: 多変量正規 (観測専用)。 mu = 平均ベクトル ([a])、 cov = full Σ
+    -- (VList-of-VList → [[a]])。 observeMV 経由で k-vector を観測する。 cov の
+    -- 正定値性は density 評価時 (choleskyL→-∞) 任せ、 ここでは正方性のみ検査。
+    ("MvNormal", [mu, cov]) -> do
+      muV  <- evalList mu
+      covV <- evalMat cov
+      let k = length muV
+      when (length covV /= k)
+        (Left ("MvNormal: 平均ベクトル長 " <> T.pack (show k)
+               <> " と共分散の行数 " <> T.pack (show (length covV)) <> " が一致しません"))
+      when (any ((/= k) . length) covV)
+        (Left ("MvNormal: 共分散は " <> T.pack (show k) <> "×" <> T.pack (show k)
+               <> " 正方行列である必要があります"))
+      Right (HBM.MvNormal muV covV)
+    -- Phase 44: scale vector σ + 相関 Cholesky L パラメタ化 (観測専用)。 L は
+    -- lkjCorrCholesky bind 由来の VList-of-VList。 covariance = (diag σ·L)(diag σ·L)ᵀ。
+    ("MvNormalChol", [mu, sigma, lExpr]) -> do
+      muV    <- evalList mu
+      sigmaV <- evalList sigma
+      lV     <- evalMat lExpr
+      let k = length muV
+      when (length sigmaV /= k)
+        (Left ("MvNormalChol: 平均ベクトル長 " <> T.pack (show k)
+               <> " と scale ベクトル長 " <> T.pack (show (length sigmaV)) <> " が一致しません"))
+      when (length lV /= k || any ((/= k) . length) lV)
+        (Left ("MvNormalChol: 相関 Cholesky L は " <> T.pack (show k) <> "×"
+               <> T.pack (show k) <> " 行列である必要があります"))
+      Right (HBM.MvNormalChol muV sigmaV lV)
+    -- Phase 45: 混合分布 (スカラ単一列、 観測は scalar observe)。 第 1 引数 =
+    -- 重みベクトル ([a]、 literal `[0.3,0.7]` or dirichlet 由来 VList ref、 既存
+    -- evalList で評価)、 第 2 引数 = 成分分布リスト (EList の各要素を **再帰
+    -- evalDist** = `[Distribution a]`)。 component 数 K は EList 要素数で静的決定。
+    -- MvNormal (Phase 44) と異なり multi-column ではない (logDensity はスカラ x)。
+    ("Mixture", [weights, EList distExprs]) -> do
+      ws    <- evalList weights
+      comps <- mapM (evalDist env dataMap mi) distExprs
+      when (null comps)
+        (Left "Mixture: 成分分布が空です (第 2 引数に少なくとも 1 つの分布が必要)")
+      when (length ws /= length comps)
+        (Left ("Mixture: 重み数 " <> T.pack (show (length ws))
+               <> " と成分分布数 " <> T.pack (show (length comps)) <> " が一致しません"))
+      Right (HBM.Mixture ws comps)
+    _ -> Left ("Unsupported distribution: " <> name <> " with " <> T.pack (show (length args)) <> " args")
+  where
+    eval = evalScalar env dataMap mi
+    -- list 引数 ([a]): EList を VList に評価 → 各要素をスカラに。
+    evalList e = evalValue env dataMap mi e >>= asList >>= mapM asNum
+    -- 行列引数 ([[a]]): VList-of-VList に評価 → 'asMatrix' で正方性検査。
+    evalMat e = evalValue env dataMap mi e >>= asMatrix
+    mk1 f a       = f <$> eval a
+    mk2 f a b     = f <$> eval a <*> eval b
+    mk3 f a b c   = f <$> eval a <*> eval b <*> eval c
+
+-- | k-vector 観測を取る多変量分布か。 @observeMV@ (Phase 44) はこれらのみ
+-- 受理し、 scalar 分布が渡されたら親切エラーにする。 obsLogSum
+-- (HBM.hs:987) が chunk 処理する分布と対応する。
+isMultivariateDist :: HBM.Distribution a -> Bool
+isMultivariateDist d = HBM.distName d `elem`
+  [ "MvNormal", "MvNormalChol", "MvStudentT"
+  , "Multinomial", "DirichletMultinomial", "Wishart" ]
+
+-- 'collectApp' は Hanalyze.Model.HBM.Ast から import。
+
+-- ===========================================================================
+-- Phase 43: list 値 Model combinator (latent vector を返す DoBind)
+-- ===========================================================================
+--
+-- 現 'DoBind' は scalar @sample@ 専用 (= @x <- Dist …@ で 1 値)。 だが
+-- @cuts <- orderedCuts "cut" 2 (-2) 1@ や @probs <- dirichlet "pi" [1,1,1]@ の
+-- ように **latent vector を返す** Model combinator (HBM.orderedCuts /
+-- HBM.dirichlet、 いずれも @Model a [a]@) は scalar に乗らない。 これらは
+-- 'evalDist' (= Distribution を返す) とは別経路で、 DoBind の RHS を
+-- 'matchListComb' で検出し、 Model モナドで実行して 'VList' に束縛する。
+--
+-- 消費側 (@OrderedLogistic eta cuts@ / @Categorical probs@) は env 内で cuts /
+-- probs が VList に束縛されるので Phase 42 の evalList (evalValue >>= asList >>=
+-- mapM asNum) がそのまま解決する (本機構の追加は **bind 側のみ**)。
+
+-- | 検出した list 値 combinator 呼び出し (引数は評価済 = base 名 + 構築情報)。
+-- 結果ベクトルの長さは引数から静的に決まる ('listCombLen')。
+data ListComb a
+  = OrderedCutsComb Text Int a a   -- ^ name, nCuts (= K-1 ≥ 1), cMin, HalfNormal scale
+  | DirichletComb   Text [a]       -- ^ name, α 集中度ベクトル (長さ K ≥ 2)
+
+-- | combinator が返すベクトルの長さ (validateStmts の placeholder VList 長 /
+-- interpStmts は実値から決まるので参照不要)。
+listCombLen :: ListComb a -> Int
+listCombLen (OrderedCutsComb _ n _ _) = n
+listCombLen (DirichletComb _ as)      = length as
+
+-- | base 名に plate suffix を付ける (forEachGroup 内で群ごとに別 latent にする)。
+listCombSuffix :: Text -> ListComb a -> ListComb a
+listCombSuffix suf (OrderedCutsComb nm n cm sc) = OrderedCutsComb (nm <> suf) n cm sc
+listCombSuffix suf (DirichletComb nm as)        = DirichletComb (nm <> suf) as
+
+-- | DoBind の RHS が list 値 combinator (orderedCuts / dirichlet) なら
+-- 引数を評価して 'ListComb' に。 combinator でなければ 'Nothing' (= scalar
+-- sample 経路へ)。 名前は文字列リテラル必須、 nCuts は数値リテラル必須
+-- (静的に長さを決めるため。 Phase 43 当面の制約、 doc 想定リスク参照)。
+matchListComb
+  :: forall a. (Floating a, Ord a)
+  => EnvA a -> DataMap -> Expr -> Maybe (Err (ListComb a))
+matchListComb env dataMap expr = case collectApp expr of
+  Right ("orderedCuts", [nameE, nCutsE, cMinE, scaleE]) -> Just $ do
+    nm <- textLit "orderedCuts" nameE
+    n  <- intLit  "orderedCuts" nCutsE
+    when (n < 1) (Left "orderedCuts: カット数 (第 2 引数) は 1 以上である必要があります")
+    cm <- evalScalar env dataMap Nothing cMinE
+    sc <- evalScalar env dataMap Nothing scaleE
+    Right (OrderedCutsComb nm n cm sc)
+  Right ("dirichlet", [nameE, alphasE]) -> Just $ do
+    nm <- textLit "dirichlet" nameE
+    as <- evalValue env dataMap Nothing alphasE >>= asList >>= mapM asNum
+    when (length as < 2) (Left "dirichlet: α ベクトル (第 2 引数) は長さ 2 以上の [..] である必要があります")
+    Right (DirichletComb nm as)
+  _ -> Nothing
+  where
+    textLit _ (ELit (LText t)) = Right t
+    textLit fn _ = Left (fn <> " の名前引数 (第 1 引数) は文字列リテラルである必要があります")
+    intLit :: Text -> Expr -> Err Int
+    intLit _ (ELit (LNumber d)) = Right (round d)
+    intLit fn _ = Left (fn <> " のカット数引数 (第 2 引数) は数値リテラルである必要があります (変数経由は未対応)")
+
+-- | 'ListComb' を実際の Model アクション (latent vector を sample) に。
+runListComb :: forall a. (Floating a, Ord a) => ListComb a -> HBM.Model a [a]
+runListComb (OrderedCutsComb nm n cm sc) = HBM.orderedCuts nm n cm sc
+runListComb (DirichletComb nm as)        = HBM.dirichlet nm as
+
+-- ===========================================================================
+-- Phase 44: 行列値 Model combinator (latent 相関行列を返す DoBind)
+-- ===========================================================================
+--
+-- 'ListComb' (Phase 43、 @Model a [a]@) の行列版。 @lkjCorrCholesky@ は
+-- @Model a [[a]]@ で k×k 下三角の相関 Cholesky 因子 L を返す latent
+-- combinator。 @L <- lkjCorrCholesky "L" 2 2.0@ を 'VList'-of-'VList' に束縛し、
+-- 消費側 ('MvNormalChol' の第 3 引数) は env 内の VList-of-VList を 'asMatrix'
+-- で解決する。 内部 latent (@L_pc*@ / @L_L*@ 等) は Model が自動登録する
+-- (DSL は latent を再実装しない)。
+
+-- | 検出した行列値 combinator 呼び出し (引数評価済)。
+data MatrixComb a
+  = LkjCholComb Text Int a   -- ^ name, dim k (≥ 2), eta (LKJ 集中度)
+
+-- | combinator が返す行列の次元 k (validateStmts の placeholder 用)。
+matrixCombDim :: MatrixComb a -> Int
+matrixCombDim (LkjCholComb _ k _) = k
+
+-- | base 名に plate suffix を付ける (群ごとに別 latent にする)。
+matrixCombSuffix :: Text -> MatrixComb a -> MatrixComb a
+matrixCombSuffix suf (LkjCholComb nm k eta) = LkjCholComb (nm <> suf) k eta
+
+-- | DoBind の RHS が行列値 combinator (lkjCorrCholesky) なら引数を評価して
+-- 'MatrixComb' に。 combinator でなければ 'Nothing'。 名前は文字列リテラル、
+-- 次元 k は数値リテラル必須 (静的に行列サイズを決めるため)。
+matchMatrixComb
+  :: forall a. (Floating a, Ord a)
+  => EnvA a -> DataMap -> Expr -> Maybe (Err (MatrixComb a))
+matchMatrixComb env dataMap expr = case collectApp expr of
+  Right ("lkjCorrCholesky", [nameE, kE, etaE]) -> Just $ do
+    nm  <- textLit nameE
+    k   <- intLit  kE
+    when (k < 2) (Left "lkjCorrCholesky: 次元 (第 2 引数) は 2 以上である必要があります")
+    eta <- evalScalar env dataMap Nothing etaE
+    Right (LkjCholComb nm k eta)
+  _ -> Nothing
+  where
+    textLit (ELit (LText t)) = Right t
+    textLit _ = Left "lkjCorrCholesky の名前引数 (第 1 引数) は文字列リテラルである必要があります"
+    intLit :: Expr -> Err Int
+    intLit (ELit (LNumber d)) = Right (round d)
+    intLit _ = Left "lkjCorrCholesky の次元引数 (第 2 引数) は数値リテラルである必要があります (変数経由は未対応)"
+
+-- | 'MatrixComb' を実際の Model アクション (latent 相関行列を sample) に。
+runMatrixComb :: forall a. (Floating a, Ord a) => MatrixComb a -> HBM.Model a [[a]]
+runMatrixComb (LkjCholComb nm k eta) = HBM.lkjCorrCholesky nm k eta
+
+-- | Phase 9.1d-5: stmts を walk して各 latent 変数(DoBind の左辺)に対する
+-- Transform を返す。 constrained 空間で 0 初期化は PositiveT で log 0 = -∞
+-- 発散するため、 streaming endpoint で transform 別に初期値を選ぶのに使う。
+inferTransforms :: [DoStmt] -> Map.Map Text HD.Transform
+inferTransforms = Map.fromList . concatMap extract
+  where
+    extract (DoBind name distExpr) = case collectApp distExpr of
+      Right (dname, _) -> [(name, distNameToTransform dname)]
+      _ -> [(name, HD.UnconstrainedT)]
+    extract _ = []
+
+    distNameToTransform "Normal"       = HD.UnconstrainedT
+    distNameToTransform "StudentT"     = HD.UnconstrainedT
+    distNameToTransform "Cauchy"       = HD.UnconstrainedT
+    distNameToTransform "Uniform"      = HD.UnconstrainedT
+    distNameToTransform "HalfNormal"   = HD.PositiveT
+    distNameToTransform "HalfCauchy"   = HD.PositiveT
+    distNameToTransform "Gamma"        = HD.PositiveT
+    distNameToTransform "Exponential"  = HD.PositiveT
+    distNameToTransform "LogNormal"    = HD.PositiveT
+    distNameToTransform "InverseGamma" = HD.PositiveT
+    distNameToTransform "Weibull"      = HD.PositiveT
+    distNameToTransform "Beta"         = HD.UnitIntervalT
+    distNameToTransform "Bernoulli"    = HD.UnitIntervalT
+    distNameToTransform _              = HD.UnconstrainedT
+
+-- ===========================================================================
+-- AST → Model モナド構築
+-- ===========================================================================
+
+-- | EDo 内の各 stmt を Model モナドに翻訳。実装は 'forall a' のもとに
+-- 動作する必要があるが、Err は Haskell の純粋値なので外側で先に検査して
+-- Model 構築は失敗しない前提にする。エラーは事前検証で全部捕まえる方針。
+--
+-- ここでは「事前検証ありで、検証通過後に Model を直接組み上げる」設計。
+-- ModelP は forall を含む rank-1 polymorphic 型なので Either に直接乗らない
+-- (ImpredicativeTypes を避けるため)。validate と build を分離する。
+validateAst :: [TopBind] -> Expr -> DataMap -> Either Text [DoStmt]
+validateAst topBinds body0 dataMap = do
+  rawStmts <- case body0 of
+    -- Phase 13 §9.3c-2: frontend parser は最終 DoExpr を ret として分離する
+    -- (hanalyze 慣行で「observe が最後 + pure 省略」 が許される)。 ret が
+    -- pure / return のときは捨て、 それ以外(例: observe)は body 末尾に
+    -- DoExpr として戻して扱う。
+    EDo s ret ->
+      let isDiscard = case ret of
+            EApp (EVar "pure") _ -> True
+            EApp (EVar "return") _ -> True
+            ELit (LBool _) -> True   -- frontend の implicit ret fallback
+            _ -> False
+          full = if isDiscard then s else s ++ [DoExpr ret]
+      in Right full
+    _ -> Left "Model body must be a do-block (`do { ... }`)"
+  -- Phase 26.1 §A-2 (2026-05-27): `x <- Data "label" expr` を pre-process で
+  -- substitute (= 列 alias 経路)。 詳細は streaming bridge/.../HbmAst.hs の同名
+  -- 関数 doc 参照。 hanalyze の pm.Data 厳密対応 (withData 経由) は将来 phase。
+  let stmts = preprocessAliases rawStmts
+  validateStmts topBinds dataMap stmts
+  pure stmts
+
+-- | Phase 26.1 §A-2 alias 経路 (2026-05-27 王道方針に切替): Haskell の `let`
+-- を syntactic alias として扱う pre-processing。 spec §4.2 と整合
+-- (= ∀LIC∃Code は Haskell サブセット、 `let x = col "..."` で Expression
+-- Language alias)。 詳細は streaming bridge/.../HbmAst.hs の同名関数 doc 参照。
+preprocessAliases :: [DoStmt] -> [DoStmt]
+preprocessAliases = go Map.empty
+  where
+    go _ [] = []
+    go aliases (s : rest) = case extractLetAliases aliases s of
+      Just (newAliases, mStmt) -> case mStmt of
+        Nothing   -> go newAliases rest
+        Just stmt -> stmt : go newAliases rest
+      Nothing ->
+        substituteStmt aliases s : go aliases rest
+
+    extractLetAliases
+      :: Map.Map Text Expr -> DoStmt
+      -> Maybe (Map.Map Text Expr, Maybe DoStmt)
+    extractLetAliases aliases (DoLet bs) =
+      let (newAliases, kept) = foldl step (aliases, []) bs
+          step (al, acc) (Bind n v) =
+            let substV = substitute al v
+            in if hasColRef substV
+                 then (Map.insert n substV al, acc)
+                 else (al, acc ++ [Bind n substV])
+      in Just (newAliases, if null kept then Nothing else Just (DoLet kept))
+    extractLetAliases _ _ = Nothing
+
+    substituteStmt :: Map.Map Text Expr -> DoStmt -> DoStmt
+    substituteStmt aliases (DoBind n v) = DoBind n (substitute aliases v)
+    substituteStmt aliases (DoLet bs)   = DoLet (map (substBind aliases) bs)
+    substituteStmt aliases (DoExpr e)   = DoExpr (substitute aliases e)
+
+    substBind :: Map.Map Text Expr -> Bind -> Bind
+    substBind aliases (Bind n v) = Bind n (substitute aliases v)
+
+    substitute :: Map.Map Text Expr -> Expr -> Expr
+    substitute env = go'
+      where
+        go' (EVar n) = case Map.lookup n env of
+          Just e  -> e
+          Nothing -> EVar n
+        go' (EOp op a b) = EOp op (go' a) (go' b)
+        go' (EApp f x)   = EApp (go' f) (go' x)
+        go' (ENeg e)     = ENeg (go' e)
+        go' (ELet bs body) = ELet (map (substBind env) bs) (go' body)
+        go' (EList xs)   = EList (map go' xs)
+        go' (EIf c a b)  = EIf (go' c) (go' a) (go' b)
+        go' (ELam x b)   = ELam x (go' b)
+        go' (EDo s r)    = EDo s r   -- 入れ子 do は触らない
+        go' x            = x
+
+-- | 静的検証(変数名スコープ / 列存在)。型は (Double, Double) 環境で一度評価して
+-- 実行時エラーが起きないかを確認する。
+validateStmts :: [TopBind] -> DataMap -> [DoStmt] -> Err ()
+validateStmts topBinds dataMap stmts0 = go (buildTopEnv dataMap topBinds) stmts0
+  where
+    go :: EnvA Double -> [DoStmt] -> Err ()
+    go _env [] = Right ()
+    -- Phase 43: RHS が list 値 combinator (orderedCuts / dirichlet) なら、
+    -- 構造検証 + 結果長 K の placeholder VList を束縛する (Model モナドが無い
+    -- 検証経路では実行できないため。 後続 Categorical/OrderedLogistic の検証が
+    -- 通るように長さだけ合わせる)。
+    go env (DoBind name distExpr : rest)
+      | Just ecomb <- matchListComb env dataMap distExpr :: Maybe (Err (ListComb Double)) = do
+          comb <- ecomb
+          let k = listCombLen comb
+          go (Map.insert name (VList (replicate k (VNum 0.0))) env) rest
+    -- Phase 44: 行列値 combinator は k×k placeholder VList-of-VList を束縛する
+    -- (Model モナドが無い検証経路では実行できないため、 次元だけ合わせる)。
+    go env (DoBind name distExpr : rest)
+      | Just ecomb <- matchMatrixComb env dataMap distExpr :: Maybe (Err (MatrixComb Double)) = do
+          comb <- ecomb
+          let k = matrixCombDim comb
+          go (Map.insert name (VList (replicate k (VList (replicate k (VNum 0.0))))) env) rest
+    go env (DoBind name distExpr : rest) = do
+      _ <- if hasColRef distExpr
+             then Left ("sample distribution cannot reference data column: " <> name)
+             else evalDist env dataMap Nothing distExpr :: Err (HBM.Distribution Double)
+      go (Map.insert name (VNum 0.0) env) rest
+    go env (DoLet bs : rest) = do
+      env' <- foldEnv env bs
+      go env' rest
+    go env (DoExpr e : rest)
+      -- Phase 27 §F-3c: forEachGroup は群列の存在を確認し、 内部 stmts を
+      -- 代表コンテキスト (param = 0) で検証する (行サブセットは検証不要)。
+      | Just (gcol, param, inner) <- matchForEachGroup e = do
+          when (Map.notMember gcol dataMap)
+            (Left ("forEachGroup の群列が見つかりません: " <> gcol))
+          go (Map.insert param (VNum 0) env) inner
+          go env rest
+      | otherwise = do
+          -- observe の構文形を検査
+          validateObserve env e
+          go env rest
+
+    foldEnv e [] = Right e
+    foldEnv e (Bind n v : rest) = do
+      vv <- evalValue e dataMap Nothing v
+      foldEnv (Map.insert n vv e) rest
+
+    validateObserve env e = do
+      (fname, args) <- collectApp e
+      case (fname, args) of
+        ("observe", [ELit (LText _obsName), distExpr, dataRef]) -> do
+          colName <- requireColRef dataRef
+          when (Map.notMember colName dataMap) (Left ("Unknown column in observe: " <> colName))
+          if hasColRef distExpr
+            then do
+              -- 列参照あり: 各行で評価できることを確認(row 0 でテスト)
+              _ <- evalDist env dataMap (Just 0) distExpr :: Err (HBM.Distribution Double)
+              pure ()
+            else do
+              _ <- evalDist env dataMap Nothing distExpr :: Err (HBM.Distribution Double)
+              pure ()
+        -- Phase 44: multi-column observe。 第 3 引数は観測列リスト ['y1','y2',..]。
+        -- 全列の存在 + 列数 ≥ 2 + 列長一致 + dist が多変量かを検査する。 dist の
+        -- μ/cov は行不変前提なので row 参照不可 (Nothing) で評価する。
+        ("observeMV", [ELit (LText _obsName), distExpr, EList colRefs]) -> do
+          cols <- mapM requireColRef colRefs
+          when (length cols < 2)
+            (Left "observeMV: 観測列は 2 列以上必要です (1 列なら scalar observe を使ってください)")
+          forM_ cols $ \c ->
+            when (Map.notMember c dataMap) (Left ("Unknown column in observeMV: " <> c))
+          let lens = map (length . (`lookupDoubles` dataMap)) cols
+          when (any (/= head lens) (tail lens))
+            (Left "observeMV: 観測列の長さが揃っていません (k-vector 組成には全列同長が必要です)")
+          d <- evalDist env dataMap Nothing distExpr :: Err (HBM.Distribution Double)
+          when (not (isMultivariateDist d))
+            (Left ("observeMV: 第 2 引数は多変量分布 (MvNormal 等) が必要ですが、 scalar 分布 "
+                   <> HBM.distName d <> " が渡されました"))
+        ("observeMV", _) ->
+          Left "observeMV: 第 3 引数は観測列リスト ['y1','y2',..] が必要です"
+        ("pure", _) -> Right ()   -- pure x は最終行用
+        ("return", _) -> Right ()
+        _ -> Left ("Unsupported statement: " <> fname)
+
+    requireColRef (ECol n) = Right n
+    requireColRef _ = Left "observe's third argument must be a column reference 'colname'"
+
+-- | 検証通過後の Model 構築(polymorphic in a)。エラーは想定外なので
+-- error で落とす(validation で漏れたバグは fail-fast)。
+interpStmts :: [TopBind] -> DataMap -> [DoStmt] -> HBM.ModelP ()
+interpStmts topBinds dataMap stmts = goM topCtx (buildTopEnv dataMap topBinds) stmts
+  where
+    goM :: forall a. (Floating a, Ord a) => PlateCtx -> EnvA a -> [DoStmt] -> HBM.Model a ()
+    goM _ _env [] = pure ()
+    -- Phase 43: list 値 combinator (orderedCuts / dirichlet) は scalar sample
+    -- ではなく Model アクションを実行して latent vector を VList 束縛する。
+    -- base 名に plate suffix を付け、 forEachGroup 内で群ごとに別 latent にする。
+    goM ctx env (DoBind name distExpr : rest)
+      | Just ecomb <- matchListComb env dataMap distExpr = do
+          let comb = case ecomb of
+                Right c -> listCombSuffix (pcSuffix ctx) c
+                Left e  -> error (T.unpack e)
+          xs <- runListComb comb
+          goM ctx (Map.insert name (VList (map VNum xs)) env) rest
+    -- Phase 44: 行列値 combinator (lkjCorrCholesky) は latent 相関 Cholesky 因子を
+    -- Model で実行し、 VList-of-VList に束縛する (MvNormalChol の L 引数で消費)。
+    goM ctx env (DoBind name distExpr : rest)
+      | Just ecomb <- matchMatrixComb env dataMap distExpr = do
+          let comb = case ecomb of
+                Right c -> matrixCombSuffix (pcSuffix ctx) c
+                Left e  -> error (T.unpack e)
+          m <- runMatrixComb comb
+          goM ctx (Map.insert name (VList (map (VList . map VNum) m)) env) rest
+    goM ctx env (DoBind name distExpr : rest) = do
+      let dist = case evalDist env dataMap Nothing distExpr of
+            Right d -> d
+            Left e -> error (T.unpack e)
+      x <- HBM.sample (name <> pcSuffix ctx) dist
+      goM ctx (Map.insert name (VNum x) env) rest
+    goM ctx env (DoLet bs : rest) = do
+      let env' = foldlBinds env bs
+      goM ctx env' rest
+    goM ctx env (DoExpr e : rest)
+      -- Phase 27 §F-3c: forEachGroup は群ごとに内部 do-block を展開する。
+      | Just (gcol, param, inner) <- matchForEachGroup e = do
+          let gvals = groupValsIn dataMap gcol (pcRows ctx)
+          forM_ gvals $ \gval -> do
+            let ctx' = ctx
+                  { pcSuffix = pcSuffix ctx <> groupSuffixFor (Map.lookup gcol dataMap) gval
+                  , pcRows   = Just (rowsForGroup dataMap gcol gval (pcRows ctx))
+                  }
+                env' = Map.insert param (VNum (liftD gval)) env
+            goM ctx' env' inner
+          goM ctx env rest
+      | otherwise = do
+          execObserve ctx env e
+          goM ctx env rest
+
+    foldlBinds :: forall a. (Floating a, Ord a) => EnvA a -> [Bind] -> EnvA a
+    foldlBinds e [] = e
+    foldlBinds e (Bind n v : rs) =
+      let vv = case evalValue e dataMap Nothing v of
+            Right x  -> x
+            Left err -> VErr err  -- 検証通過しているはずなので通常来ない
+      in foldlBinds (Map.insert n vv e) rs
+
+    execObserve :: forall a. (Floating a, Ord a) => PlateCtx -> EnvA a -> Expr -> HBM.Model a ()
+    execObserve ctx env e = case collectApp e of
+      Right ("observe", [ELit (LText obsName), distExpr, ECol colName]) ->
+        let ys      = lookupDoubles colName dataMap
+            allRows = [0 .. length ys - 1]
+            rows    = fromMaybe allRows (pcRows ctx)  -- 群コンテキストなら当該群の行
+            nm      = obsName <> pcSuffix ctx
+        in if hasColRef distExpr
+             then do
+               -- per-row distribution。 対象行のみ observeColumns でまとめる。
+               let pairs = [ (case evalDist env dataMap (Just i) distExpr of
+                                Right d -> d
+                                Left _ -> error "validation should have caught this"
+                             , [ys !! i])
+                           | i <- rows, i >= 0, i < length ys
+                           ]
+               HBM.observeColumns nm pairs
+             else do
+               let dist = case evalDist env dataMap Nothing distExpr of
+                     Right d -> d
+                     Left _ -> error "validation should have caught this"
+               HBM.observe nm dist [ ys !! i | i <- rows, i >= 0, i < length ys ]
+      -- Phase 44: multi-column observe。 dist (μ/cov) は行不変なので 1 回だけ
+      -- 評価し、 観測列だけを行ごとに k-vector に組んで HBM.observeMV に流す。
+      Right ("observeMV", [ELit (LText obsName), distExpr, EList colRefs]) ->
+        let cols    = [ c | ECol c <- colRefs ]
+            colVecs = map (`lookupDoubles` dataMap) cols
+            n       = if null colVecs then 0 else minimum (map length colVecs)
+            allRows = [0 .. n - 1]
+            rows    = fromMaybe allRows (pcRows ctx)
+            nm      = obsName <> pcSuffix ctx
+            dist    = case evalDist env dataMap Nothing distExpr of
+                        Right d -> d
+                        Left _  -> error "validation should have caught this"
+            obss    = [ [ cv !! i | cv <- colVecs ] | i <- rows, i >= 0, i < n ]
+        in HBM.observeMV nm dist obss
+      Right ("pure", _) -> pure ()
+      Right ("return", _) -> pure ()
+      _ -> pure ()  -- validateObserve で弾く想定
+
+-- ===========================================================================
+-- NUTS 設定 reader
+-- ===========================================================================
+
+-- | extra から NUTS 設定を読む(欠落時はデフォルト)。
+readChainCount :: A.Object -> Int
+readChainCount o = case KM.lookup (Key.fromText "hbmChains") o of
+  Just (A.Number n) -> let v = floor (realToFrac n :: Double) in max 1 (min 16 v)
+  _ -> 4
+
+readNutsConfig :: A.Object -> NUTS.NUTSConfig
+readNutsConfig o =
+  let
+    def = NUTS.defaultNUTSConfig
+    getInt :: Text -> Int
+    getInt k = case KM.lookup (Key.fromText k) o of
+      Just (A.Number n) -> floor (realToFrac n :: Double)
+      _ -> 0
+    getDbl :: Text -> Double
+    getDbl k = case KM.lookup (Key.fromText k) o of
+      Just (A.Number n) -> realToFrac n
+      _ -> 0
+    getBool' k = case KM.lookup (Key.fromText k) o of
+      Just (A.Bool b) -> b
+      _ -> False
+  in def
+    { NUTS.nutsIterations    = if getInt "hbmIterations" > 0 then getInt "hbmIterations" else NUTS.nutsIterations def
+    , NUTS.nutsBurnIn        = max 0 (getInt "hbmBurnIn")
+    , NUTS.nutsStepSize      = if getDbl "hbmStepSize" > 0 then getDbl "hbmStepSize" else NUTS.nutsStepSize def
+    , NUTS.nutsMaxDepth      = if getInt "hbmMaxDepth" > 0 then getInt "hbmMaxDepth" else NUTS.nutsMaxDepth def
+    , NUTS.nutsAdaptStepSize = getBool' "hbmAdaptStepSize"
+    , NUTS.nutsTargetAccept  = let v = getDbl "hbmTargetAccept" in if v > 0 && v < 1 then v else NUTS.nutsTargetAccept def
+    , NUTS.nutsAdaptMass     = getBool' "hbmAdaptMass"
+    }
+
+-- | Phase 13 §9.3c-2: observe ノード名 → 観測列名 のマッピングを取り出す。
+-- DSL 構文 @observe "NAME" DIST 'COL'@ から (NAME, COL) を抽出。
+-- frontend で「observe ノード "y" の観測値はどの列か」 を解決する用途。
+observeNodeMap :: [DoStmt] -> [(Text, Text)]
+observeNodeMap = concatMap step
+  where
+    step (DoExpr e) = case collectApp e of
+      Right ("observe", [ELit (LText obsName), _distExpr, ECol colName]) ->
+        [(obsName, colName)]
+      _ -> []
+    step _ = []
+
+-- ===========================================================================
+-- 結果整形 (param summary / posterior mean curves)
+-- ===========================================================================
+
+data ParamSummary = ParamSummary
+  { psName :: !Text
+  , psMean :: !Double
+  , psSd   :: !Double
+  , psLow  :: !Double
+  , psHigh :: !Double
+  , psRhat :: !(Maybe Double)
+  , psEss  :: !Double
+  } deriving (Show)
+
+-- | SC29: 複数 chain から事後統計を計算。R̂ は split-R̂ (hanalyze)、
+-- ESS は Geyer initial monotone(全チェーン pool)。
+paramSummaryMulti :: [MC.Chain] -> Text -> ParamSummary
+paramSummaryMulti chains name =
+  let perChain = map (MC.chainVals name) chains
+      pooled = concat perChain
+      n = length pooled
+      m = if n == 0 then 0 else sum pooled / fromIntegral n
+      sd2 = if n < 2 then 0
+            else sum (map (\v -> (v - m) ** 2) pooled) / fromIntegral (n - 1)
+      sd = sqrt sd2
+      sorted = LA.toList (LA.sortVector (LA.fromList pooled))
+      pct :: Double -> Double
+      pct q = if n == 0 then 0
+              else let idx = max 0 (min (n - 1) (floor (q * fromIntegral (n - 1)) :: Int))
+                   in case drop idx sorted of (x:_) -> x; [] -> 0
+      rh = SMC.rhat perChain
+      e  = SMC.ess pooled
+  in ParamSummary name m sd (pct 0.025) (pct 0.975) rh e
+
+fmtSummary :: ParamSummary -> Text
+fmtSummary p = psName p <> "="
+  <> T.pack (show (round4 (psMean p)))
+  <> "±" <> T.pack (show (round4 (psSd p)))
+
+round4 :: Double -> Double
+round4 x = fromIntegral (round (x * 10000) :: Int) / 10000
+
+-- | thinning: stride 飛ばしに要素を取る。
+takeEvery :: Int -> [a] -> [a]
+takeEvery _ []     = []
+takeEvery n (x:xs) = x : takeEvery n (drop (max 0 (n - 1)) xs)
+
+summaryToJson :: ParamSummary -> A.Value
+summaryToJson p = A.object
+  [ Key.fromText "name" A..= psName p
+  , Key.fromText "mean" A..= psMean p
+  , Key.fromText "sd"   A..= psSd p
+  , Key.fromText "ci2_5" A..= psLow p
+  , Key.fromText "ci97_5" A..= psHigh p
+  , Key.fromText "rhat"  A..= psRhat p
+  , Key.fromText "ess"   A..= psEss p
+  ]
+
+-- ---------------------------------------------------------------------------
+-- Phase NN §A (2026-05-27): HBM posterior predictive mean curves
+-- ---------------------------------------------------------------------------
+-- DSL の observe 文の mean expression (= 例: `alpha + beta * 'x'`) を
+-- 直接評価して posterior predictive curve を計算する。 frontend の
+-- buildHbmOverlay 内 heuristic (= "alpha" / "beta_<x>" 等の名前推定) を
+-- 撤去するための backend AST driven 経路。
+
+data HbmMeanCurve = HbmMeanCurve
+  { hmcObsName   :: !Text          -- observe 文の名前 (top="y"、 per-group="y_1")
+  , hmcPredictor :: !Text          -- 説明変数列名 (= 例: "x")
+  , hmcX         :: ![Double]      -- 64 grid points
+  , hmcMedian    :: ![Double]      -- posterior median of mu(x)
+  , hmcLower     :: ![Double]      -- 2.5%
+  , hmcUpper     :: ![Double]      -- 97.5%
+  -- Phase 27 GLMM overlay: per-group curve の群ラベル (top-level は Nothing)。
+  -- frontend が群ごと色分け + 凡例に使う。
+  , hmcGroupCol  :: !(Maybe Text)   -- 群列名 (= forEachGroup "gcol")
+  , hmcGroupVal  :: !(Maybe Double) -- 群値
+  } deriving (Show)
+
+hbmMeanCurveToJson :: HbmMeanCurve -> A.Value
+hbmMeanCurveToJson c = A.object
+  [ Key.fromText "obsName"   A..= hmcObsName c
+  , Key.fromText "predictor" A..= hmcPredictor c
+  , Key.fromText "x"         A..= hmcX c
+  , Key.fromText "median"    A..= hmcMedian c
+  , Key.fromText "lower"     A..= hmcLower c
+  , Key.fromText "upper"     A..= hmcUpper c
+  , Key.fromText "groupCol"  A..= hmcGroupCol c
+  , Key.fromText "groupVal"  A..= hmcGroupVal c
+  ]
+
+-- | stmts から各 observe の mean 式 (= Distribution の第 1 引数) を抽出。
+extractObserveMeans :: [DoStmt] -> [(Text, Expr)]
+extractObserveMeans = concatMap go
+  where
+    go (DoExpr e) = case collectApp e of
+      Right ("observe", [ELit (LText nm), distExpr, _]) ->
+        case collectApp distExpr of
+          Right (_, m : _) -> [(nm, m)]
+          _ -> []
+      _ -> []
+    go _ = []
+
+-- | 式中の全 ECol 列名を集める (重複排除)。
+collectCols :: Expr -> [Text]
+collectCols = nub . go
+  where
+    go (ECol c)    = [c]
+    go (EOp _ a b) = go a <> go b
+    go (EApp f x)  = go f <> go x
+    go (ENeg e)    = go e
+    go (ELet bs body) = concatMap (\(Bind _ v) -> go v) bs <> go body
+    go _           = []
+
+-- | percentile (= 0..1) を sorted リストから線形補間で取る。
+percentileOf :: Double -> [Double] -> Double
+percentileOf q xs0 =
+  let xs = sort xs0
+      n = length xs
+  in if n == 0 then 0
+     else
+       let idxF = q * fromIntegral (n - 1)
+           idx  = max 0 (min (n - 1) (floor idxF))
+       in case drop idx xs of
+            (v:_) -> v
+            []    -> 0
+
+-- | observe 1 個分の mean-curve 計算文脈 (top-level または per-group)。
+-- Phase 27 GLMM overlay: `forEachGroup` 内の observe も拾えるよう、 plate
+-- 展開を replay しながら集める ('collectObsInstances')。
+-- | Phase 43: WAIC/PPC 再評価用、 list 値 combinator 由来の latent vector を
+-- posterior サンプルから再構築する仕様。 stream worker は @sampleNames@
+-- (= sample された latent のみ) を samples に載せ、 combinator の deterministic
+-- (cut_c_*/pi_*) は載せないため、 再評価 env で sampled latent (cut_d_*/pi_b*) から
+-- VList を組み直す ('reconstructComb')。
+data ListCombSpec
+  = OCutsSpec Text Int Expr   -- ^ suffix 付き base 名, nCuts, cMin 式 (定数想定)
+  | DirSpec   Text Int        -- ^ suffix 付き base 名, K (= α ベクトル長)
+
+data ObsInstance = ObsInstance
+  { oiObsName  :: Text                  -- 表示名 (top="y"、 group="y_1")
+  , oiMeanExpr :: Expr                  -- distribution の第 1 引数 (mean 式)
+  , oiDistExpr :: Expr                  -- distribution 式全体 (例: `Normal mu sigma`)
+  , oiObsCol   :: Text                  -- 観測列名 (observe の第 3 引数 `col "y"`)
+  , oiNameKey  :: Map.Map Text Text     -- local latent 名 → samples のキー (theta→"theta_1")
+  , oiParamEnv :: Map.Map Text Double   -- 群変数 (forEachGroup の \g) → 群値
+  , oiRows     :: Maybe [Int]           -- 対象行 (Nothing=全行)
+  , oiGroup    :: Maybe (Text, Double)  -- 最内 forEachGroup の (群列, 群値)
+  , oiListCombs :: [(Text, ListCombSpec)]  -- Phase 43: list latent bind (cuts/probs) の再構築仕様
+  }
+
+-- | list 値 combinator の latent vector を 1 posterior サンプルから再構築する。
+-- @base@ は cMin 定数評価用の env (sample 非依存)、 @sm@ は sampled latent の Map。
+-- sampled latent (cut_d_*/pi_b*) が欠けていれば 'Nothing'。
+reconstructComb
+  :: EnvA Double -> DataMap -> Map.Map Text Double -> ListCombSpec -> Maybe [Double]
+reconstructComb base dataMap sm spec = case spec of
+  -- orderedCuts: c_1 = cMin、 c_i = c_{i-1} + d_i (d_i = name_d_i, i=2..n)。
+  OCutsSpec nm n cMinE -> do
+    cMin <- either (const Nothing) Just (evalScalar base dataMap Nothing cMinE)
+    ds   <- mapM (\j -> Map.lookup (nm <> "_d_" <> T.pack (show j)) sm) [2 .. n]
+    pure (scanl (+) cMin ds)            -- 長さ n、 単調増加
+  -- dirichlet: stick-breaking。 betas = name_b0..name_b{K-2}、 π を復元。
+  DirSpec nm k -> do
+    betas <- mapM (\j -> Map.lookup (nm <> "_b" <> T.pack (show j)) sm) [0 .. k - 2]
+    let prods = scanl (\acc b -> acc * (1 - b)) 1 betas
+    pure [ if j < length betas then (betas !! j) * (prods !! j) else prods !! j
+         | j <- [0 .. k - 1] ]
+
+-- | DoBind の RHS が list 値 combinator なら 'ListCombSpec' を返す (suffix 付き
+-- base 名で。 'matchListComb' / 'listCombSuffix' と同じ命名規律)。
+matchListCombSpec :: Text -> Expr -> Maybe ListCombSpec
+matchListCombSpec suf rhs = case collectApp rhs of
+  Right ("orderedCuts", [ELit (LText bn), ELit (LNumber d), cMinE, _scaleE]) ->
+    Just (OCutsSpec (bn <> suf) (round d) cMinE)
+  Right ("dirichlet", [ELit (LText bn), EList alphas]) ->
+    Just (DirSpec (bn <> suf) (length alphas))
+  _ -> Nothing
+
+-- | observe 式から (名前, mean 式, distribution 式全体, 観測列名) を抽出。
+-- mean 式 = Distribution の第 1 引数、 dist 式全体は WAIC/PPC で logDensity /
+-- sampleDist を回すために必要。 観測列名は第 3 引数 `col "y"` の列。
+observeFull :: Expr -> Maybe (Text, Expr, Expr, Text)
+observeFull e = case collectApp e of
+  Right ("observe", [ELit (LText nm), distExpr, ECol colName]) ->
+    case collectApp distExpr of
+      Right (_, m : _) -> Just (nm, m, distExpr, colName)
+      _                -> Nothing
+  _ -> Nothing
+
+-- | stmts を plate 展開しながら observe instance を集める。 'interpStmts' と
+-- 同じ suffix (groupSuffix) / 行 (rowsForGroup) / 群変数束縛の規律を replay し、
+-- top-level observe は suffix=""・全行、 forEachGroup 内 observe は群ごとに
+-- suffix 付き・群行で展開する。 latent 名は sample された scope の suffix で
+-- samples のキーに対応づける (top latent="mu"、 群 latent="theta_1" 等)。
+collectObsInstances :: DataMap -> [DoStmt] -> [ObsInstance]
+collectObsInstances dm = go "" Nothing Map.empty Map.empty Nothing []
+  where
+    go :: Text -> Maybe [Int] -> Map.Map Text Text -> Map.Map Text Double
+       -> Maybe (Text, Double) -> [(Text, ListCombSpec)] -> [DoStmt] -> [ObsInstance]
+    go _ _ _ _ _ _ [] = []
+    go suf rows nameKey penv grp combs (st : rest) = case st of
+      -- latent: 現 suffix 付きで samples に載る (= キー対応を記録)。 Phase 43:
+      -- list 値 combinator なら再構築仕様も記録 (= WAIC/PPC 再評価で VList 復元)。
+      DoBind name rhs ->
+        let combs' = case matchListCombSpec suf rhs of
+                       Just spec -> (name, spec) : combs
+                       Nothing   -> combs
+        in go suf rows (Map.insert name (name <> suf) nameKey) penv grp combs' rest
+      DoLet _ -> go suf rows nameKey penv grp combs rest
+      DoExpr e
+        | Just (gcol, param, inner) <- matchForEachGroup e ->
+            let gvals = groupValsIn dm gcol rows
+                here = concatMap
+                  (\gv ->
+                     go (suf <> groupSuffixFor (Map.lookup gcol dm) gv)
+                        (Just (rowsForGroup dm gcol gv rows))
+                        nameKey
+                        (Map.insert param gv penv)
+                        (Just (gcol, gv))
+                        combs
+                        inner)
+                  gvals
+            in here ++ go suf rows nameKey penv grp combs rest
+        | Just (obsName, meanExpr, distExpr, obsCol) <- observeFull e ->
+            ObsInstance (obsName <> suf) meanExpr distExpr obsCol nameKey penv rows grp combs
+              : go suf rows nameKey penv grp combs rest
+        | otherwise -> go suf rows nameKey penv grp combs rest
+
+-- ===========================================================================
+-- model graph plate aggregation (Phase 27.5 後続 TODO3、 2026-06-02)
+-- ===========================================================================
+--
+-- forEachGroup は群ごとに内部 do-block を展開するため、 'buildModelGraph' が
+-- 見る realized ModelP には alpha_1 / alpha_2 / alpha_3 … と群数ぶんの latent /
+-- observe ノードが並ぶ (3 群 × 数 latent で 40 ノード級に肥大)。 PyMC 流の
+-- plate 表記では「群コピーを 1 つの代表ノードに畳み、 箱のラベルに群数」 を
+-- 出すので、 ここでは AST (= forEachGroup 構造が残る層) から
+--
+--   * realized 名 → base 名 の rename map ('plateRenameMap')
+--   * forEachGroup ごとの plate (ラベル "<群列> (<群数>)" + 直下 base 名)
+--     ('collectGraphPlates')
+--
+-- を導き、 realized ModelGraph を base 名へ collapse する
+-- ('collapsePlateGraph')。 rename は interpStmts と同じ groupSuffix 規律を
+-- replay して作る total な対応なので、 文字列推測ではない。
+
+-- | model graph 上の plate (= forEachGroup 1 サイト)。 frontend
+--   hgg DAGPlate (label + member id 群) に対応。
+data GraphPlate = GraphPlate
+  { gpLabel   :: Text     -- ^ "<群列> (<群数>)"
+  , gpMembers :: [Text]   -- ^ この plate 直下の base 名 (latent + observe)
+  } deriving (Show, Eq)
+
+-- | realized node 名 (alpha_1 等) → base 名 (alpha) の rename map。
+--   'collectObsInstances' / 'interpStmts' と同じ suffix (groupSuffix) /
+--   行 (rowsForGroup) 規律を replay し、 各 DoBind latent / observe を
+--   その出現 suffix 付き名 → base 名 で登録する。 top-level は suffix="" なので
+--   恒等 (alpha → alpha)。
+plateRenameMap :: DataMap -> [DoStmt] -> Map.Map Text Text
+plateRenameMap dm = go "" Nothing
+  where
+    go :: Text -> Maybe [Int] -> [DoStmt] -> Map.Map Text Text
+    go _ _ [] = Map.empty
+    go suf rows (st : rest) = case st of
+      DoBind name _ ->
+        Map.insert (name <> suf) name (go suf rows rest)
+      DoLet _ -> go suf rows rest
+      DoExpr e
+        | Just (gcol, _param, inner) <- matchForEachGroup e ->
+            let gvals  = groupValsIn dm gcol rows
+                inners = Map.unions
+                  [ go (suf <> groupSuffixFor (Map.lookup gcol dm) gv)
+                       (Just (rowsForGroup dm gcol gv rows)) inner
+                  | gv <- gvals ]
+            in inners `Map.union` go suf rows rest
+        | Just (obsName, _meanE, distExpr, obsCol) <- observeFull e ->
+            -- observe は col 参照を含むと 'observeColumns' で per-row 展開され、
+            -- 実ノードは "<obsName><suf>_<j>" (j = 当該 plate 対象行の 0 始まり
+            -- 連番、 = execObserve の規律) になる。 col 参照無しなら単一
+            -- "<obsName><suf>"。 どちらも base 名 obsName に畳む。
+            let colLen     = length (lookupDoubles obsCol dm)
+                baseRows   = fromMaybe [0 .. colLen - 1] rows
+                validCount = length [ i | i <- baseRows, i >= 0, i < colLen ]
+                names | hasColRef distExpr =
+                          [ obsName <> suf <> "_" <> T.pack (show j)
+                          | j <- [0 .. validCount - 1] ]
+                      | otherwise = [ obsName <> suf ]
+            in Map.union (Map.fromList [ (nm, obsName) | nm <- names ])
+                         (go suf rows rest)
+        | otherwise -> go suf rows rest
+
+-- | forEachGroup ごとに 1 plate を集める (群値ぶんは展開しない)。 ラベルは
+--   "<群列> (<群数>)"、 member は当該 forEachGroup 直下の base 名 (latent +
+--   observe、 ネストした forEachGroup の中身は含めない = ネストは別 plate)。
+--   ネスト plate は代表 1 群の行で再帰的に拾う。
+collectGraphPlates :: DataMap -> [DoStmt] -> [GraphPlate]
+collectGraphPlates dm = go Nothing
+  where
+    go :: Maybe [Int] -> [DoStmt] -> [GraphPlate]
+    go _ [] = []
+    go rows (st : rest) = case st of
+      DoExpr e
+        | Just (gcol, _param, inner) <- matchForEachGroup e ->
+            let gvals   = groupValsIn dm gcol rows
+                count   = length gvals
+                label   = gcol <> " (" <> T.pack (show count) <> ")"
+                members = directMembers inner
+                nested  = case gvals of
+                  (gv : _) -> go (Just (rowsForGroup dm gcol gv rows)) inner
+                  []       -> []
+            in GraphPlate label members : nested ++ go rows rest
+        | otherwise -> go rows rest
+      _ -> go rows rest
+    -- 直下の DoBind latent + observe 名 (ネスト forEachGroup は DoExpr なので除外)。
+    directMembers :: [DoStmt] -> [Text]
+    directMembers stmts =
+      [ name | DoBind name _ <- stmts ]
+      ++ [ obsName | DoExpr e <- stmts, Just (obsName, _, _, _) <- [observeFull e] ]
+
+-- | realized 'HBM.ModelGraph' を plate 単位に collapse。 群展開ノードを base 名に
+--   畳み (重複ノードは初出を残す)、 辺は両端を rename して自己ループ除去 + 重複
+--   除去。 併せて plate 一覧を返す。
+collapsePlateGraph
+  :: DataMap -> [DoStmt] -> HBM.ModelGraph -> (HBM.ModelGraph, [GraphPlate])
+collapsePlateGraph dm stmts mg =
+  let rn      = plateRenameMap dm stmts
+      ren x   = Map.findWithDefault x x rn
+      nodes'  = dedupNodes [ renameNode ren n | n <- HBM.mgNodes mg ]
+      edges'  = nub [ (ren a, ren b)
+                    | (a, b) <- HBM.mgEdges mg, ren a /= ren b ]
+     -- Phase 40 merge: ModelGraph に mgPlates (plate→size) フィールドが追加された。
+     -- DSL forEachGroup の collapse は独自の GraphPlate 列 (collectGraphPlates) を
+     -- 別途返すので、 ここでは入力 graph の mgPlates をそのまま引き継ぐ。
+  in (HBM.ModelGraph nodes' edges' (HBM.mgPlates mg), collectGraphPlates dm stmts)
+  where
+    renameNode ren n = n
+      { HBM.nodeName = ren (HBM.nodeName n)
+      , HBM.nodeDeps = Set.map ren (HBM.nodeDeps n)
+      }
+    dedupNodes = goD Set.empty
+      where
+        goD _ [] = []
+        goD seen (n : ns)
+          | HBM.nodeName n `Set.member` seen = goD seen ns
+          | otherwise = n : goD (Set.insert (HBM.nodeName n) seen) ns
+
+-- | observe ごと × 列ごとに 64 点 curve を計算。 Phase 27 GLMM overlay:
+-- top-level observe に加え forEachGroup 内の per-group observe も対象
+-- ('collectObsInstances' が plate 展開)。
+-- |   * 主 predictor = 当該列、 grid は (per-group なら群の) data min..max
+-- |   * 他 predictor は (per-group なら群の) data median で固定
+-- |   * 群 latent は suffix 付きキー (theta_1 等)、 群変数は定数として env に注入
+-- |   * 各 sample × 各 grid 点で mean 式を Double 評価
+-- |   * 各 grid 点で全 sample から median + 2.5% / 97.5% percentile
+computeMeanCurves
+  :: [TopBind]                            -- top-level 値/関数束縛 (ユーザ定義リンク等)
+  -> [DoStmt]
+  -> DataMap                              -- data: col → values
+  -> [Map.Map Text Double]                -- posterior samples
+  -> [HbmMeanCurve]
+computeMeanCurves topBinds stmts dataMap samples =
+  [ HbmMeanCurve
+      { hmcObsName   = oiObsName inst
+      , hmcPredictor = col
+      , hmcX = xGrid
+      , hmcMedian = map (percentileOf 0.5)   valuesPerX
+      , hmcLower  = map (percentileOf 0.025) valuesPerX
+      , hmcUpper  = map (percentileOf 0.975) valuesPerX
+      , hmcGroupCol = fst <$> oiGroup inst
+      , hmcGroupVal = snd <$> oiGroup inst
+      }
+  | inst <- collectObsInstances dataMap stmts
+  , let meanExpr = oiMeanExpr inst
+        mrows    = oiRows inst
+        -- per-group なら群の行に限定して列値を取り出す。
+        colValsFor c =
+          let ca = lookupDoubles c dataMap
+          in case mrows of
+               Nothing -> ca
+               Just rs -> [ ca !! i | i <- rs, i >= 0, i < length ca ]
+  , col <- collectCols meanExpr
+  , let xs = colValsFor col
+  , not (null xs)
+  , let nGrid = 64 :: Int
+        xLo = minimum xs
+        xHi = maximum xs
+        step = if xHi == xLo then 1.0 else (xHi - xLo) / fromIntegral (nGrid - 1)
+        xGrid = [ xLo + step * fromIntegral i | i <- [0 .. nGrid - 1] ]
+        otherCols = filter (/= col) (collectCols meanExpr)
+        medianOf vs = case sort vs of
+          [] -> 0
+          ss -> ss !! (length ss `div` 2)
+        fixedOther =
+          Map.fromList [ (c, Numeric [medianOf (colValsFor c)]) | c <- otherCols ]
+        -- 群変数 (forEachGroup の \g) を定数として env に注入。
+        paramVNums = Map.map VNum (oiParamEnv inst)
+        -- sample を nameKey で remap: local latent 名 → samples の suffix 付きキー。
+        -- (Map.union は left-biased なので renamed が元キーより優先)
+        remapSample sample =
+          let renamed = Map.fromList
+                [ (localNm, v)
+                | (localNm, key) <- Map.toList (oiNameKey inst)
+                , Just v <- [Map.lookup key sample] ]
+          in Map.union renamed sample
+        evalAt sample x =
+          let synthetic = Map.insert col (Numeric [x]) fixedOther
+              sm = remapSample sample
+              -- posterior サンプル (alpha/beta 等) を env に、 top-level
+              -- 値/関数 (ユーザ定義リンク等) + 群変数も併せて見えるようにする。
+              senv = Map.union paramVNums
+                       (Map.union (Map.map VNum sm) (buildTopEnv dataMap topBinds))
+          in case evalScalar @Double senv synthetic (Just 0) meanExpr of
+               Right v -> v
+               Left _  -> 0 / 0  -- NaN
+        valuesPerX = [ [ evalAt sample x | sample <- samples ] | x <- xGrid ]
+  ]
+
+-- ---------------------------------------------------------------------------
+-- Phase 27.5 (2026-06-02): WAIC / LOO / posterior predictive 用の observe
+-- distribution 評価。 computeMeanCurves と同じ plate 展開 (collectObsInstances)
+-- を使い、 mean 式ではなく distribution 式全体を各 sample × 各対象行で評価する。
+-- これにより pointwise log-likelihood (WAIC/LOO) と posterior predictive draw
+-- (PPC、 worker 側で sampleDist) の共通基盤を 1 度で作る。
+-- ---------------------------------------------------------------------------
+
+-- | observe instance 1 個分の、 全 posterior sample × 対象行で評価した結果。
+data ObsDistSet = ObsDistSet
+  { odsName     :: !Text                          -- observe ノード名 (suffix 付き)
+  , odsObserved :: ![Double]                      -- 対象行の観測値 (= col の当該行)
+  , odsDists    :: ![[HBM.Distribution Double]]   -- [sample][row] の Distribution
+  }
+
+-- | 各 observe instance について、 各 posterior sample × 各対象行で
+-- distribution を評価する。 mean に列参照がある GLM 形 (例: Normal (a+b*'x') s)
+-- も evalDist の per-row 評価 ((Just i)) で正しく行ごとに展開される。
+computeObsDists
+  :: [TopBind]
+  -> [DoStmt]
+  -> DataMap
+  -> [Map.Map Text Double]
+  -> [ObsDistSet]
+computeObsDists topBinds stmts dataMap samples =
+  [ ObsDistSet
+      { odsName     = oiObsName inst
+      , odsObserved = ys
+      , odsDists    = [ [ distAt sample i | i <- rows ] | sample <- samples ]
+      }
+  | inst <- collectObsInstances dataMap stmts
+  , let distExpr = oiDistExpr inst
+        yCol     = lookupDoubles (oiObsCol inst) dataMap
+        allRows  = [0 .. length yCol - 1]
+        rows     = filter (\i -> i >= 0 && i < length yCol)
+                     (fromMaybe allRows (oiRows inst))
+        ys       = [ yCol !! i | i <- rows ]
+        paramVNums = Map.map VNum (oiParamEnv inst)
+        remapSample sample =
+          let renamed = Map.fromList
+                [ (localNm, v)
+                | (localNm, key) <- Map.toList (oiNameKey inst)
+                , Just v <- [Map.lookup key sample] ]
+          in Map.union renamed sample
+        distAt sample i =
+          let sm   = remapSample sample
+              base = Map.union paramVNums
+                       (Map.union (Map.map VNum sm) (buildTopEnv dataMap topBinds))
+              -- Phase 43: combinator 由来 latent vector (cuts/probs) を sampled
+              -- latent から再構築して VList 束縛 (= 消費分布の list 引数を解決)。
+              combBinds = [ (bindNm, VList (map VNum vals))
+                          | (bindNm, spec) <- oiListCombs inst
+                          , Just vals <- [reconstructComb base dataMap sample spec] ]
+              senv = Map.union (Map.fromList combBinds) base
+          in case evalDist senv dataMap (Just i) distExpr :: Err (HBM.Distribution Double) of
+               Right d -> d
+               Left _  -> HBM.Normal (0 / 0) 1   -- 評価不能は NaN 化 (logDensity→NaN)
+  , not (null rows)
+  ]
+
+-- | 'ObsDistSet' 群から WAIC/LOO 用の log-likelihood 行列 (S × N) を作る。
+-- 行 = posterior sample、 列 = 全 observe instance の全対象行を連結。
+-- @Hanalyze.Stat.ModelSelect.waic@ / @loo@ がこの shape を期待する。
+pointwiseLogLik :: [ObsDistSet] -> [[Double]]
+pointwiseLogLik sets =
+  [ concatMap (\set -> zipWith HBM.logDensity (sampleRow set s) (odsObserved set)) sets
+  | s <- [0 .. nSamples - 1] ]
+  where
+    nSamples = case sets of
+      (set : _) -> length (odsDists set)
+      []        -> 0
+    sampleRow set s = case drop s (odsDists set) of
+      (row : _) -> row
+      []        -> []
+
+-- | log-lik 行列 (S×N) から非有限 (NaN / ±Inf) を含む観測列を除外する
+-- (Phase 27.5 後続 TODO4、 2026-06-02)。 'distAt' の eval 失敗 (= Normal NaN) や
+-- 退化パラメータで 'logDensity' が NaN / Inf になった観測点は、 そのまま waic/loo に
+-- 渡すと per-observation 集計 (lppd / pwaic) を汚染して全体が NaN → JSON null に
+-- なる。 該当する観測列 (= 全 sample で同一観測点) を丸ごと落として残りで waic/loo を
+-- 計算できるようにする。 返り値は (除外後行列, 落とした列数)。 全列が非有限なら
+-- ([], N) を返し、 呼び出し側は waic/loo を Nothing にできる。
+finitePointwiseLogLik :: [[Double]] -> ([[Double]], Int)
+finitePointwiseLogLik mat =
+  let cols     = transpose mat              -- [obs点][sample]
+      keptCols = filter (all isFiniteD) cols
+      dropped  = length cols - length keptCols
+  in (transpose keptCols, dropped)
+  where
+    isFiniteD x = not (isNaN x || isInfinite x)
+
+-- ===========================================================================
+-- Phase 44: multi-column observe (observeMV) の WAIC / PPC 経路
+-- ===========================================================================
+--
+-- scalar observe (1 列) の WAIC/PPC ('collectObsInstances' / 'computeObsDists' /
+-- 'pointwiseLogLik') は per-row スカラ logDensity 前提で、 multi-column の
+-- k-vector joint density (= 'HBM.obsLogSum') を扱えない。 そこで Phase 44 の
+-- 設計方針 (observeMV は scalar observe と別 builtin の並行経路) を WAIC/PPC まで
+-- 貫き、 **MV 専用の並行経路** を足す (scalar 経路は無傷)。
+--
+-- latent Σ (lkjCorrCholesky 由来の相関 Cholesky L) は posterior sample に
+-- 内部 latent (@L_u<i>_<j>@ = partial-correlation の Beta latent) として載るので、
+-- bind 名 @L@ を 'reconstructMatrixComb' で再構築して MvNormalChol に渡す
+-- (Phase 43 'reconstructComb' の行列版)。
+
+-- | 行列値 combinator の再構築仕様 (suffix 付き base 名 + 次元 k)。
+data MatrixCombSpec
+  = LkjCholSpec Text Int   -- ^ suffix 付き base 名, k (= 行列次元)
+  deriving (Show)
+
+-- | DoBind の RHS が行列値 combinator (lkjCorrCholesky) なら 'MatrixCombSpec' を
+-- 返す ('matchMatrixComb' / 'matrixCombSuffix' と同じ命名規律)。
+matchMatrixCombSpec :: Text -> Expr -> Maybe MatrixCombSpec
+matchMatrixCombSpec suf rhs = case collectApp rhs of
+  Right ("lkjCorrCholesky", [ELit (LText bn), ELit (LNumber d), _etaE]) ->
+    Just (LkjCholSpec (bn <> suf) (round d))
+  _ -> Nothing
+
+-- | lkjCorrCholesky の相関 Cholesky L を 1 posterior サンプルから再構築する。
+-- sampled latent は @<nm>_u<i>_<j>@ (Beta in (0,1))、 partial correlation は
+-- @z_ij = 2u - 1@。 L は 'HBM.lkjCorrCholesky' の deterministic 構築を replay:
+--   L_00 = 1、 対角 L_ii = √(1 - Σ_{k<i} z_{i,k}²)、
+--   対角下 L_ij = z_ij · √(Π_{k<j}(1 - z_{i,k}²))  (j < i)。
+-- sampled latent が欠ければ 'Nothing'。
+reconstructMatrixComb :: Map.Map Text Double -> MatrixCombSpec -> Maybe [[Double]]
+reconstructMatrixComb sm (LkjCholSpec nm k) = do
+  let uKey i j = nm <> "_u" <> T.pack (show i) <> "_" <> T.pack (show j)
+  pcPairs <- mapM
+    (\(i, j) -> do u <- Map.lookup (uKey i j) sm; pure ((i, j), 2 * u - 1))
+    [(i, j) | i <- [1 .. k - 1], j <- [0 .. i - 1]]
+  let pcMap = Map.fromList pcPairs
+      pc i j = Map.findWithDefault 0 (i, j) pcMap
+      sq z = z * z
+      lRow i =
+        [ if j > i then 0
+          else if i == 0 && j == 0 then 1
+          else if j == i
+               then sqrt (max 0 (1 - sum [ sq (pc i kk) | kk <- [0 .. i - 1] ]))
+          else pc i j * sqrt (max 0 (product [ 1 - sq (pc i kk) | kk <- [0 .. j - 1] ]))
+        | j <- [0 .. k - 1] ]
+  pure [ lRow i | i <- [0 .. k - 1] ]
+
+-- | observeMV 式から (名前, distribution 式全体, 観測列名リスト) を抽出。
+observeMVFull :: Expr -> Maybe (Text, Expr, [Text])
+observeMVFull e = case collectApp e of
+  Right ("observeMV", [ELit (LText nm), distExpr, EList colRefs]) ->
+    let cols = [ c | ECol c <- colRefs ]
+    in if length cols == length colRefs && length cols >= 2
+         then Just (nm, distExpr, cols) else Nothing
+  _ -> Nothing
+
+-- | observeMV instance (plate 展開済)。 'collectObsInstances' の MV 版で、
+-- 単一 'oiObsCol' でなく **列リスト** を持ち、 list/matrix combinator の
+-- 再構築仕様も保持する。
+data MvObsInstance = MvObsInstance
+  { mviObsName     :: Text
+  , mviDistExpr    :: Expr
+  , mviObsCols     :: [Text]
+  , mviNameKey     :: Map.Map Text Text
+  , mviParamEnv    :: Map.Map Text Double
+  , mviRows        :: Maybe [Int]
+  , mviListCombs   :: [(Text, ListCombSpec)]
+  , mviMatrixCombs :: [(Text, MatrixCombSpec)]
+  }
+
+-- | stmts を plate 展開しながら observeMV instance を集める
+-- ('collectObsInstances' と同じ suffix / 行 / 群変数 / latent 名規律を replay)。
+collectMvObsInstances :: DataMap -> [DoStmt] -> [MvObsInstance]
+collectMvObsInstances dm = go "" Nothing Map.empty Map.empty [] []
+  where
+    go :: Text -> Maybe [Int] -> Map.Map Text Text -> Map.Map Text Double
+       -> [(Text, ListCombSpec)] -> [(Text, MatrixCombSpec)] -> [DoStmt]
+       -> [MvObsInstance]
+    go _ _ _ _ _ _ [] = []
+    go suf rows nameKey penv lcombs mcombs (st : rest) = case st of
+      DoBind name rhs ->
+        let lcombs' = case matchListCombSpec suf rhs of
+                        Just spec -> (name, spec) : lcombs
+                        Nothing   -> lcombs
+            mcombs' = case matchMatrixCombSpec suf rhs of
+                        Just spec -> (name, spec) : mcombs
+                        Nothing   -> mcombs
+        in go suf rows (Map.insert name (name <> suf) nameKey) penv lcombs' mcombs' rest
+      DoLet _ -> go suf rows nameKey penv lcombs mcombs rest
+      DoExpr e
+        | Just (gcol, param, inner) <- matchForEachGroup e ->
+            let gvals = groupValsIn dm gcol rows
+                here = concatMap
+                  (\gv ->
+                     go (suf <> groupSuffixFor (Map.lookup gcol dm) gv)
+                        (Just (rowsForGroup dm gcol gv rows))
+                        nameKey (Map.insert param gv penv) lcombs mcombs inner)
+                  gvals
+            in here ++ go suf rows nameKey penv lcombs mcombs rest
+        | Just (obsName, distExpr, cols) <- observeMVFull e ->
+            MvObsInstance (obsName <> suf) distExpr cols nameKey penv rows lcombs mcombs
+              : go suf rows nameKey penv lcombs mcombs rest
+        | otherwise -> go suf rows nameKey penv lcombs mcombs rest
+
+-- | observeMV 1 個分の WAIC/PPC 評価結果。 'ObsDistSet' の MV 版。
+data MvObsDistSet = MvObsDistSet
+  { mvodsName     :: !Text                          -- ^ 表示名 (top="y"、 group="y_1")
+  , mvodsCols     :: ![Text]                        -- ^ 観測列名リスト (長さ k)
+  , mvodsObserved :: ![[Double]]                    -- ^ [row][component] = 各行の k-vector
+  , mvodsDists    :: ![[HBM.Distribution Double]]   -- ^ [sample][row] の Distribution
+  }
+
+-- | observeMV instance × sample × 行で多変量 Distribution を評価する
+-- ('computeObsDists' の MV 版)。 dist は行不変 (μ/Σ は latent) なので各行で
+-- 同一だが、 既存経路に合わせ row 評価する。 latent Σ は 'reconstructMatrixComb'
+-- で L を、 list 引数は 'reconstructComb' で復元して env に束縛する。
+computeMvObsDists
+  :: [TopBind]
+  -> [DoStmt]
+  -> DataMap
+  -> [Map.Map Text Double]
+  -> [MvObsDistSet]
+computeMvObsDists topBinds stmts dataMap samples =
+  [ MvObsDistSet
+      { mvodsName     = mviObsName inst
+      , mvodsCols     = cols
+      , mvodsObserved = [ [ lookupDoubles c dataMap !! i | c <- cols ] | i <- rows ]
+      , mvodsDists    = [ [ distAt sample i | i <- rows ] | sample <- samples ]
+      }
+  | inst <- collectMvObsInstances dataMap stmts
+  , let distExpr = mviDistExpr inst
+        cols     = mviObsCols inst
+        colLens  = map (length . (`lookupDoubles` dataMap)) cols
+        colLen   = if null colLens then 0 else minimum colLens
+        allRows  = [0 .. colLen - 1]
+        rows     = filter (\i -> i >= 0 && i < colLen)
+                     (fromMaybe allRows (mviRows inst))
+        paramVNums = Map.map VNum (mviParamEnv inst)
+        remapSample sample =
+          let renamed = Map.fromList
+                [ (localNm, v)
+                | (localNm, key) <- Map.toList (mviNameKey inst)
+                , Just v <- [Map.lookup key sample] ]
+          in Map.union renamed sample
+        distAt sample i =
+          let sm   = remapSample sample
+              base = Map.union paramVNums
+                       (Map.union (Map.map VNum sm) (buildTopEnv dataMap topBinds))
+              listBinds = [ (bn, VList (map VNum vals))
+                          | (bn, spec) <- mviListCombs inst
+                          , Just vals <- [reconstructComb base dataMap sample spec] ]
+              matBinds  = [ (bn, VList (map (VList . map VNum) m))
+                          | (bn, spec) <- mviMatrixCombs inst
+                          , Just m <- [reconstructMatrixComb sample spec] ]
+              senv = Map.union (Map.fromList (listBinds ++ matBinds)) base
+          in case evalDist senv dataMap (Just i) distExpr :: Err (HBM.Distribution Double) of
+               Right d -> d
+               Left _  -> HBM.MvNormal [0 / 0] [[1]]   -- eval 不能は NaN 化
+  , not (null rows)
+  ]
+
+-- | MV observe の pointwise log-lik 行列 (S×N)。 各行 (= 1 観測点) の寄与は
+-- k-vector joint density 'HBM.obsLogSum'。 scalar 経路の 'pointwiseLogLik' と
+-- 列方向に連結して使う (worker 側)。
+pointwiseLogLikMv :: [MvObsDistSet] -> [[Double]]
+pointwiseLogLikMv sets =
+  [ concatMap (\set -> [ HBM.obsLogSum (distRow set s !! r) (mvodsObserved set !! r)
+                       | r <- [0 .. nRows set - 1] ]) sets
+  | s <- [0 .. nSamples - 1] ]
+  where
+    nSamples = case sets of
+      (set : _) -> length (mvodsDists set)
+      []        -> 0
+    nRows set = length (mvodsObserved set)
+    distRow set s = case drop s (mvodsDists set) of
+      (row : _) -> row
+      []        -> []
diff --git a/src/Hanalyze/Model/HBM/Model.hs b/src/Hanalyze/Model/HBM/Model.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Model/HBM/Model.hs
@@ -0,0 +1,1132 @@
+{-# LANGUAGE RankNTypes #-}
+{-# LANGUAGE DeriveFunctor #-}
+{-# LANGUAGE FlexibleInstances #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE ImpredicativeTypes #-}
+
+-- |
+-- Module      : Hanalyze.Model.HBM.Model
+-- Description : HBM の多相モデル DSL (Free monad) 記述層
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Phase 58.5: 多相モデル DSL (Free monad) を 'Hanalyze.Model.HBM' から分離。
+--
+-- 本モジュールは PPL の **記述層** を担う:
+--
+--   * @Free@ monad 再実装 (型は 'Hanalyze.Model.HBM' 公開のものと別個)
+--   * 'ModelF' プリミティブ (sample / observe / observeLM / deterministic /
+--     plate / Data / Potential) と 'Model' / 'ModelP' 型エイリアス
+--   * 第一級ランダム効果値 'REffect' / 'REff' と階層モデル helper 群
+--     (reNormal / mvNormalLatent / lkjCorrCholesky / ar1Latent / dirichlet /
+--      orderedCuts / dpStickBreaking / hmmLatent / glmmRandomIntercept 等)
+--   * Plate notation (Phase 40) と構造検査 ('collectNodes' / 'sampleNames')
+--
+-- 評価 (logJoint 等)・AD 勾配・IR は **上層** に置かれ、 本モジュールは
+-- それらに依存しない (leaf-first・facade 非 import の規律。 Phase 58 計画参照)。
+-- 依存は下層 'Hanalyze.Model.HBM.Util' / '...Distribution' のみ。
+module Hanalyze.Model.HBM.Model
+  ( -- * Free monad
+    Free (..)
+  , liftF
+    -- * Polymorphic model DSL
+  , ModelF (..)
+  , Model
+  , ModelP
+  , sample
+  , observe
+  , observeMV
+  , observeColumns
+  , observeLM
+  , observeLMR
+  , observeNormalLM
+  , LMFamily (..)
+  , lmFamilyName
+  , lmParents
+  , REff (..)
+  , REffect (..)
+  , reffNames
+  , reNormal
+  , at
+  , indexed
+  , (.#)
+  , potential
+  , deterministic
+  , nonCenteredNormal
+  , dirichlet
+  , orderedCuts
+  , dpStickBreaking
+  , hmmLatent
+  , hmmForwardLogLik
+  , GlmmFamily (..)
+  , glmmRandomIntercept
+  , dataNamed
+  , dataNamedX
+  , dataNamedIx
+  , dataNamedObs
+  , Ix (..)
+  , TrackTag (..)
+  , (!!!)
+  , atIx
+  , withData
+  , withDataIx
+  , mvNormalLatent
+  , lkjCorrCholesky
+  , gpExpQuadCov
+  , gpLatent
+  , ar1Latent
+    -- ** Phase 40 plate notation
+  , plate
+  , plateI
+  , plateI_
+  , plateForM
+  , plateForM_
+  , withPlate
+    -- * Structural inspection
+  , Node (..)
+  , NodeKind (..)
+  , collectNodes
+  , sampleNames
+  , dataSlots
+  , dataIxSlots
+  ) where
+
+import Control.DeepSeq (NFData (..))
+import Control.Monad (forM, forM_)
+import Data.List (foldl', nub)
+import qualified Data.Set as Set
+import Data.Set (Set)
+import Data.Text (Text)
+import qualified Data.Text as T
+
+import Hanalyze.Model.HBM.Util (negInf, logSumExpA, choleskyL, hmmForwardLogLik)
+import Hanalyze.Model.HBM.Distribution
+
+-- ---------------------------------------------------------------------------
+-- @Free@ monad (再実装。Hanalyze.Model.HBM のものとは型が違うので別途定義)
+-- ---------------------------------------------------------------------------
+
+data Free f a = Pure a | Free (f (Free f a))
+
+instance Functor f => Functor (Free f) where
+  fmap g (Pure a) = Pure (g a)
+  fmap g (Free x) = Free (fmap (fmap g) x)
+
+instance Functor f => Applicative (Free f) where
+  pure = Pure
+  Pure g <*> x  = fmap g x
+  Free fg <*> x = Free (fmap (<*> x) fg)
+
+instance Functor f => Monad (Free f) where
+  return = pure
+  Pure a >>= g = g a
+  Free x >>= g = Free (fmap (>>= g) x)
+
+liftF :: Functor f => f a -> Free f a
+liftF fa = Free (fmap Pure fa)
+
+-- ---------------------------------------------------------------------------
+-- 多相モデル (@Free@ monad)
+-- ---------------------------------------------------------------------------
+
+-- | DSL のプリミティブ。継続が @a -> next@ なので任意の @a@ を流せる。
+--
+-- 'Potential' は PyMC の @pm.Potential@ 相当で、任意の log-prob 項を
+-- log-joint に加える。ソフト制約・カスタム尤度・正則化項などに使える。
+-- | 構造化線形予測子 observe (Phase 54.1) の family / link。
+--
+-- 通常の 'Observe' は平均が不透明な AD 値ゆえ「β に線形」 という構造を
+-- 保持できない。 'ObserveLM' は設計行列 X (Double) と β パラメタ名を **分離**
+-- して持つことで線形構造をライブラリが知り、 54.2 で Gaussian-恒等リンクの
+-- 十分統計量 collapse (観測和を tape O(p²) に畳む) を可能にする。
+data LMFamily
+  = LMGaussian Text   -- ^ identity link。 引数 = σ (誤差 SD) パラメタ名。
+  | LMPoisson         -- ^ log link (μ = exp η)。
+  | LMBernoulli       -- ^ logit link (p = 1/(1+e^{-η}))。
+  deriving (Show, Eq)
+
+-- | 'ObserveLM' のランダム効果項 (Phase 54.4a)。 線形予測子に
+-- @η_i += u^{re}[gid_i]@ を gather で加える。 設計行列の one-hot 指示列として
+-- 密に展開する代わりに、 群 id ベクトルで疎に保持することで vec-tape の
+-- 観測尤度勾配が群効果に対しても O(n) で済む (密展開は O(nG·n) で階層モデルで
+-- 逆効果になる・54.4a 計測で確認)。
+--
+-- フィールド: u パラメタ名 (長さ nG・既に 'sample' 済の latent を参照) /
+-- 各観測の群 id (長さ n・0..nG-1) /
+-- prior スケール名 (Phase 54.4c): @Just τName@ なら各 u_j が
+-- @u_j ~ Normal(0, τ)@ という標準的な階層 prior を持つことを宣言する。
+-- これがあると 'compileGradU' は u-prior 勾配を **解析的に** (ベクトル化して)
+-- 計算し、 対応する @u_j@ 'Sample' ノードを `ad` walk から除外できる
+-- (per-grad の支配項だった O(nG) スカラ `ad` を排除)。 @Nothing@ なら
+-- prior は従来通り `ad` 経路で扱う (後方互換)。 通常は 'reNormal'/'at' で
+-- 自動的に @Just@ が載るので、 ユーザがこの構築子を直接書く必要はない。
+--
+-- per-row 重み (Phase 54.10): @Just ws@ (長さ n) なら @η_i += w_i·u^{re}[gid_i]@
+-- (random slope = 群別係数 × 共変量)。 @Nothing@ = 全 1 (random intercept・
+-- 後方互換)。 prior 解析勾配 (@u_j ~ Normal(0,τ)@) は重みと無関係に同形。
+-- 由来 slot 名 (Phase 62): 5 番目 field は gids がどのデータ slot
+-- ('dataNamedIx') 由来かの静的属性。 @Just slot@ なら 'lmParents' が slot 名を
+-- 親集合に加え、 DAG に slot (DataN)→観測ノードのエッジが出る (PyMC
+-- @b0[gid]@ 同型)。 'atIx' が自動で載せる。 'at' / IR 合成経路は @Nothing@
+-- (従来挙動)。 hot closure ('CompiledLMBlock') には乗らない = per-draw 無影響。
+data REff = REff [Text] [Int] (Maybe Text) (Maybe [Double]) !(Maybe Text)
+  deriving (Show, Eq)
+
+-- Phase 54.8: synthGaussLMBlocks の安全網 (force で全評価し poison を捕捉) 用。
+instance NFData REff where
+  rnf (REff us gids sc mw ms) =
+    rnf us `seq` rnf gids `seq` rnf sc `seq` rnf mw `seq` rnf ms
+
+data ModelF a next
+  = Sample  Text (Distribution a) (a -> next)
+  | Observe Text (Distribution a) [Double] next
+  | ObserveLM Text [Text] [[Double]] [REff] LMFamily [Double] next
+    -- ^ 構造化線形予測子 observe (Phase 54.1、 54.4a で REff 追加)。
+    --   フィールド: ブロック名 / β パラメタ名 (順序 = X の列) /
+    --   設計行列 X (n 行 × p 列、 Double) / ランダム効果項 (gather) /
+    --   family-link / 観測 ys (長さ n)。
+    --   各 i について η_i = Σ_j β_j·X_ij + Σ_re u^{re}[gid^{re}_i]、
+    --   μ_i = link⁻¹(η_i)、 log-lik = Σ_i logDensityObs(family μ_i) y_i。
+    --   β / u / 分散パラメタは別途 'sample' で宣言された latent を
+    --   **名前参照**する (prior は持たない)。
+    --   DAG 上は 1 観測ノード (親 = β + u + 分散パラメタ名)。
+  | Potential Text a next
+    -- ^ 名前付きの ad-hoc な log-prob 項。値 @a@ がそのまま log-joint に加算される。
+  | Deterministic Text a (a -> next)
+    -- ^ 名前付きの派生量 (PyMC `pm.Deterministic`)。log-joint には寄与せず、
+    --   サンプルごとに値を保存する。継続には値そのものを通すので、その後の
+    --   モデル中でも参照可能。
+  | Data Text [Double] (([a], [Double]) -> next)
+    -- ^ 名前付き観測データプレースホルダ (PyMC `pm.Data`)。
+    --   モデル内でデータを保持し、`withData` で外部から差し替え可能。
+    --   観測値を直接 `observe` に渡す代わりに、`dataNamed` で受け取って
+    --   `observe` に渡すと、後でデータ差し替えができる。
+    --   ★Phase 60.2 破壊的変更: 継続は ([a], [Double]) の 2 view を受ける
+    --   (格納は [Double] のまま・各 interpreter が lift)。 fst = モデル数値型
+    --   ('dataNamed'、 covariate 用・realToFrac 不要)、 snd = 生 [Double]
+    --   ('dataNamedObs'、 'observe' の観測値用)。 tuple は lazy なので
+    --   未使用側の lift コストは掛からない。
+  | DataIx Text [Int] ([Int] -> next)
+    -- ^ 離散 index 専用のデータプレースホルダ (Phase 60.2)。 群 index 等の
+    --   名義尺度を [Int] のまま運ぶ (= AD 型に持ち上げない・round 罠の根治)。
+    --   継続型は @a@ に依らず [Int] なので interpreter の lift も不要。
+  | PlateBegin Text Int next
+    -- ^ Plate 開始マーカー (Phase 40-A1、 Pyro/NumPyro 流の plate-block 糖衣)。
+    --   名前 + サイズ N を持つ plate スコープの開始。 直後から 'PlateEnd'
+    --   までに登録される 'Sample' / 'Observe' / 'Deterministic' は
+    --   buildModelGraph で「plate メンバ」 として描画される。
+    --   nested plate は LIFO スタックで対応。 log eval interpreter (logJoint
+    --   等) は **透過** に処理する (何もしない)。
+  | PlateEnd next
+    -- ^ Plate 終了マーカー (Phase 40-A1)。 最新の PlateBegin スコープを閉じる。
+  deriving Functor
+
+type Model a = Free (ModelF a)
+
+-- | Type alias for the polymorphic model DSL.
+-- @ModelP r = forall a. (Floating a, Ord a, TrackTag a) => Model a r@
+-- ('TrackTag' は Phase 60.7 '!!!' の依存タグ注入用。 数値解釈は既定 id)。
+type ModelP r = forall a. (Floating a, Ord a, TrackTag a) => Model a r
+
+sample :: Text -> Distribution a -> Model a a
+sample n d = liftF (Sample n d id)
+
+observe :: Text -> Distribution a -> [Double] -> Model a ()
+observe n d ys = liftF (Observe n d ys ())
+
+-- | 構造化線形予測子 observe (Phase 54.1)。
+--
+-- @observeLM name betaNames designX family ys@ は、 設計行列 @designX@
+-- (n 行 × p 列) と β パラメタ名 @betaNames@ (長さ p・既に 'sample' で宣言済の
+-- latent を参照) を **分離して**保持する観測ブロック。 各観測 i について
+-- η_i = Σ_j β_j·X_ij を作り、 @family@ のリンク逆関数で μ_i に写して
+-- 観測 @ys !! i@ の log-density を加算する。
+--
+-- 通常の per-obs @observe@ を N 回呼ぶのと数値的に等価だが、 線形構造を
+-- 保持するので 54.2 で Gaussian-恒等リンクの十分統計量 collapse に乗せられる。
+observeLM :: Text -> [Text] -> [[Double]] -> LMFamily -> [Double] -> Model a ()
+observeLM n betas designX fam ys = liftF (ObserveLM n betas designX [] fam ys ())
+
+-- | ランダム効果付き 'observeLM' (Phase 54.4a)。
+--
+-- @observeLMR name betaNames designX reffs family ys@ は 'observeLM' に
+-- ランダム効果項 @reffs@ を加えたもの。 各 'REff' は (u パラメタ名, 群 id) で
+-- @η_i += u^{re}[gid_i]@ を **gather** で寄与する。 群効果を設計行列の one-hot
+-- 指示列に密展開すると vec-tape 勾配が O(nG·n) になり階層モデルで逆効果になる
+-- (54.4a 計測) ため、 群構造は疎に保持して gather で O(n) に保つ。
+observeLMR :: Text -> [Text] -> [[Double]] -> [REff] -> LMFamily -> [Double]
+           -> Model a ()
+observeLMR n betas designX reffs fam ys =
+  liftF (ObserveLM n betas designX reffs fam ys ())
+
+-- ---------------------------------------------------------------------------
+-- 第一級ランダム効果値 (Phase 54.4c)
+-- ---------------------------------------------------------------------------
+
+-- | 第一級ランダム効果値 (Phase 54.4c)。 'reNormal' で宣言した nG 個の
+-- iid @Normal(0, τ)@ latent を、 構造 (基底名・群数・スケール名・値) ごと
+-- ひとつの値に載せて持ち運ぶ。 これにより観測の線形予測子に効果を載せるとき
+-- 文字列添字 (@"u_" <> show j@) も @us !! g@ も書かずに 'at' で gather でき
+-- (Haskell 王道の「構造を値に載せて流す」)、 さらにスケール名が構造として
+-- 保持されるので 'compileGradU' が u-prior 勾配を解析的にベクトル化できる。
+data REffect a = REffect
+  { reffBase   :: !Text   -- ^ 基底名 (例 @"u"@)。 latent 名は @base_<j>@。
+  , reffNG     :: !Int    -- ^ 群数 nG
+  , reffScale  :: !Text   -- ^ スケール latent の名前 (@u_j ~ Normal(0, scale)@)
+  , reffValues :: [a]     -- ^ サンプル済 nG 個の値 (forward 評価・deterministic 用)
+  }
+
+-- | 'REffect' の latent 名 (@base_0 .. base_{nG-1}@)。
+reffNames :: REffect a -> [Text]
+reffNames re = [ indexed (reffBase re) j | j <- [0 .. reffNG re - 1] ]
+
+-- | 群別ランダム効果を第一級値として宣言する (Phase 54.4c)。
+--
+-- @reNormal base nG scaleName scaleVal@ は @base_0 .. base_{nG-1}@ という
+-- nG 個の latent を各々 @Normal(0, scaleVal)@ として 'sample' し、 その構造
+-- (基底名 / nG / スケール名 / 値) を 'REffect' にまとめて返す。 @scaleName@ は
+-- @scaleVal@ を生んだスケール latent の名前 (例 @"tau_u"@) で、 解析 prior 勾配
+-- 経路 ('compileGradU') がスケール変数を引くために構造として保持する
+-- (値は名前を覚えていないため明示的に渡す)。
+--
+-- @
+-- tau <- sample "tau_u" (HalfNormal 5)
+-- u   <- reNormal "u" nG "tau_u" tau
+-- observeNormalLM "y" xRows betaNames [u \`at\` gids] "sigma" ys
+-- @
+reNormal :: Num a => Text -> Int -> Text -> a -> Model a (REffect a)
+reNormal base nG scaleName scaleVal = do
+  vals <- forM [0 .. nG - 1] $ \j ->
+            sample (indexed base j) (Normal 0 scaleVal)
+  pure (REffect base nG scaleName vals)
+
+-- | 'REffect' を観測の群 id 列に対して gather し 'REff' (観測ブロック用) に変換する。
+-- @η_i += u^{re}[gid_i]@。 スケール名を 'REff' に載せるので、 これ経由で観測に
+-- 入った効果は 'compileGradU' の解析 prior 勾配経路に乗る。
+at :: REffect a -> [Int] -> REff
+at re gids = REff (reffNames re) gids (Just (reffScale re)) Nothing Nothing
+
+-- | Gaussian-恒等リンク版の構造化 observe (Phase 54.4c)。 'observeLMR' の
+-- @LMGaussian@ 特化で、 'at' で作った 'REff' をそのまま渡せる薄いラッパ。
+--
+-- @observeNormalLM name designX betaNames reffs sigmaName ys@。
+observeNormalLM :: Text -> [[Double]] -> [Text] -> [REff] -> Text -> [Double]
+                -> Model a ()
+observeNormalLM name designX betaNames reffs sName ys =
+  observeLMR name betaNames designX reffs (LMGaussian sName) ys
+
+-- | Multivariate observation (for 'MvNormal'). Each observation is a
+-- length-@k@ vector; pass them as a list @[[Double]]@.
+-- 内部的には @concat@ で flatten され、評価時に Distribution の次元 k で chunk される。
+observeMV :: Text -> Distribution a -> [[Double]] -> Model a ()
+observeMV n d obss = liftF (Observe n d (concat obss) ())
+
+-- | Multi-output observation helper. Takes @q@ pairs of
+-- @observe (prefix <> \"_\" <> j) dist_j ys_j@ を順に発行する。
+--
+-- 多出力回帰の尤度を 1 行で書きたいときに使う:
+--
+-- @
+-- observeColumns \"y\" [(Normal mu_j sigma_j, ysCol j) | j <- [0 .. q - 1]]
+-- @
+observeColumns :: Text -> [(Distribution a, [Double])] -> Model a ()
+observeColumns prefix pairs =
+  mapM_ (\(j, (d, ys)) ->
+           observe (prefix <> "_" <> T.pack (show (j :: Int))) d ys)
+        (zip [0..] pairs)
+
+-- | インデックス付きノード名を作る: @indexed "theta" 1 == "theta_1"@。
+--
+-- 階層モデルで群ごとの 'sample' / 'observe' 名を作るときに頻出する
+-- @T.pack ("theta_" ++ show j)@ ボイラープレートを畳む。 アンダースコアは
+-- 自動付与 (= 'observeColumns' / 'nonCenteredNormal' 等の命名規約に一致)。
+--
+-- > forM_ (zip [1..] groupData) $ \(j, ys) -> do
+-- >   theta <- sample (indexed "theta" j) (Normal mu tau)   -- "theta_1" …
+-- >   observe (indexed "y" j) (Normal theta 1) ys
+indexed :: Text -> Int -> Text
+indexed pre i = pre <> "_" <> T.pack (show i)
+
+-- | 'indexed' の中置演算子版: @"theta" .# j == "theta_1"@。
+--
+-- (Haskell の演算子記号に @_@ は使えないため @.#@ を採用。)
+infixl 9 .#
+(.#) :: Text -> Int -> Text
+(.#) = indexed
+
+-- | Add an arbitrary log-probability term to the model (analogous to
+-- PyMC's @pm.Potential@).
+--
+-- 通常のサンプリング/観測では表せない log-density 寄与を入れるのに使う。
+-- 典型用途:
+--
+--   * **ソフト制約**: @potential \"order\" (if mu1 < mu2 then 0 else (-1e10))@
+--   * **カスタム尤度**: 既存 'Distribution' で表せない尤度項
+--   * **正則化**: ベイズ的な正則化 (e.g. ridge: @-0.5 * lambda * sum (map (^2) betas)@)
+--
+-- @Potential@ の値は 'logJoint' と 'logPrior' に加算される
+-- ('logLikelihood' には含まれない — これらは @observe@ 専用)。
+potential :: Text -> a -> Model a ()
+potential nm v = liftF (Potential nm v ())
+
+-- | 派生量を名前付きで保存する (PyMC `pm.Deterministic` 相当)。
+--
+-- log-joint には寄与しないが、各 posterior サンプルごとに値が記録され
+-- 'augmentChainWithDeterministic' で Chain に注入できる。
+--
+-- 例:
+--
+-- > tau <- deterministic "tau" (1 / (sigma * sigma))
+deterministic :: Text -> a -> Model a a
+deterministic nm v = liftF (Deterministic nm v id)
+
+-- | DAG / Node 表示用の分布名 (リンク逆関数を適用した観測分布の名前)。
+lmFamilyName :: LMFamily -> Text
+lmFamilyName (LMGaussian _) = "Normal"
+lmFamilyName LMPoisson      = "Poisson"
+lmFamilyName LMBernoulli    = "Bernoulli"
+
+-- | 'ObserveLM' が参照する latent パラメタ名の集合 (DAG の親)。
+-- β + ランダム効果 u + (Gaussian の) σ。
+lmParents :: [Text] -> [REff] -> LMFamily -> Set Text
+lmParents betaNames reffs fam =
+  Set.fromList betaNames
+  <> Set.fromList (concat [ uNames | REff uNames _ _ _ _ <- reffs ])
+  -- Phase 62: gids の由来 slot 名 ('atIx' 経由) も親に = slot→観測ノードのエッジ
+  <> Set.fromList [ s | REff _ _ _ _ (Just s) <- reffs ]
+  <> case fam of
+       LMGaussian sName -> Set.singleton sName
+       LMPoisson        -> Set.empty
+       LMBernoulli      -> Set.empty
+
+-- ---------------------------------------------------------------------------
+-- Phase 40-A1: Plate notation
+-- ---------------------------------------------------------------------------
+
+-- | Pyro / NumPyro 流の plate-block (Phase 40)。
+--
+-- @plate name n body@ は、 do-block 内で繰り返し作られる indexed RV 群
+-- (e.g. @eta_0, eta_1, …, eta_{n-1}@) を **同じ plate に属する** と
+-- マークする bracket。 'buildModelGraph' で plate 集約描画される。
+--
+-- 例 (8-schools):
+--
+-- > mu  <- sample "mu" (Normal 0 5)
+-- > tau <- sample "tau" (HalfCauchy 5)
+-- > etas <- plate "school" 8 $ forM [0..7] $ \j ->
+-- >           sample ("eta_" <> T.pack (show j)) (Normal 0 1)
+-- > _ <- plate "school" 8 $ forM_ [0..7] $ \j ->
+-- >        observe ("y_" <> T.pack (show j))
+-- >                (Normal (mu + tau * (etas !! j)) 1) [ys !! j]
+--
+-- 内部: 'PlateBegin' / 'PlateEnd' マーカーで囲む。 log eval (logJoint
+-- / logPrior 等) は **透過** に動作し、 plate は描画レイヤーでのみ
+-- 意味を持つ。 NUTS / Gibbs / VI への影響なし。
+plate :: Text -> Int -> Model a r -> Model a r
+plate name n body = do
+  liftF (PlateBegin name n ())
+  r <- body
+  liftF (PlateEnd ())
+  return r
+
+-- | 'plate' の利便 helper: @plateI name n f@ = @plate name n (forM [0..n-1] f)@。
+-- 「N 個の indexed RV を作る」 という最頻パターン向け糖衣。
+--
+-- 例:
+--
+-- > etas <- plateI "school" 8 $ \j ->
+-- >           sample ("eta_" <> T.pack (show j)) (Normal 0 1)
+plateI :: Text -> Int -> (Int -> Model a r) -> Model a [r]
+plateI name n action = plate name n (forM [0 .. n - 1] action)
+
+-- | 'plateI' の返り値を捨てる版 (@forM_@ の plate 版・index 反復)。
+-- @plateI_ name n f = plate name n (forM_ [0..n-1] f)@。 観測のみの index
+-- ループ向け (@plateForM_ name [0..n-1] f@ と同義だが index 反復の意図が明示的・
+-- 'plateForM' / 'plateForM_' の対称に合わせ index 版にも破棄形を用意)。
+--
+-- 例 (8-schools の観測):
+--
+-- > plateI_ "school" 8 $ \j ->
+-- >   observe ("y" .# j) (Normal (mu + tau * etas !! j) 1) [ys !! j]
+plateI_ :: Text -> Int -> (Int -> Model a r) -> Model a ()
+plateI_ name n action = plate name n (forM_ [0 .. n - 1] action)
+
+-- | データ行リストを plate で囲んで反復する糖衣 (@forM@ の plate 版・引数順も @forM@ 形)。
+-- @plateForM name rows f = plate name (length rows) (forM rows f)@。 plate サイズは
+-- 行数から自動。 観測ループの定番 @plate name (length rows) $ forM_ … rows@ を畳む。
+--
+-- 例 (ベイズ線形回帰の観測):
+--
+-- > plateForM_ "obs" (zip x y) $ \(xi, yi) -> do
+-- >   mu <- deterministic "mu" (a + b * realToFrac xi)
+-- >   observe "obs" (Normal mu s) [yi]
+plateForM :: Text -> [b] -> (b -> Model a r) -> Model a [r]
+plateForM name rows f = plate name (length rows) (forM rows f)
+
+-- | 返り値を捨てる版 (@forM_@ の plate 版)。 観測のみのループに。
+plateForM_ :: Text -> [b] -> (b -> Model a r) -> Model a ()
+plateForM_ name rows f = plate name (length rows) (forM_ rows f)
+
+-- | 低レベル plate API: 任意の Model action を plate スコープで包む。
+-- 'plate' は @withPlate name n@ + body の組合せに分解される。 nested
+-- plate を独自構築する際の primitive。
+withPlate :: Text -> Int -> Model a r -> Model a r
+withPlate = plate
+
+-- | 名前付きデータプレースホルダを宣言する (PyMC `pm.Data` 相当)。
+-- 既定値 @ys@ を持ち、後で 'withData' により差し替え可能。
+--
+-- 典型的な使い方:
+--
+-- > model = do
+-- >   y <- dataNamed "y" trainData
+-- >   mu <- sample "mu" (Normal 0 5)
+-- >   observe "y" (Normal mu 1) y
+--
+-- そして @withData \"y\" testData model@ で同じ構造で別データを使う。
+--
+-- ★Phase 60.2 破壊的変更: 戻り値は @[a]@ (モデルの数値型)。 受け取った値は
+-- そのまま式に入る (@realToFrac@ 不要)。 @a@ には @Real@ 制約が無いので、
+-- 旧コードの @realToFrac xi@ は型エラーになる (= 無言の挙動変化が起きない
+-- 壊れ方)。 機械的に @realToFrac@ を消せば移行完了。
+-- 観測値として 'observe' に渡す側 (@[Double]@ が要る) は 'dataNamedObs' を使う。
+dataNamed :: Text -> [Double] -> Model a [a]
+dataNamed n ys = liftF (Data n ys fst)
+
+-- | 'dataNamed' の同義 (Phase 60.6)。 役割 suffix 三点セットの正書き:
+--
+-- > x  <- dataNamedX   "x" []   -- 説明変数: モデル数値型 [a]
+-- > ys <- dataNamedObs "y" []   -- 目的変数: 生 [Double] ('observe' へ)
+-- > gs <- dataNamedIx  "g" []   -- 群 index: [Int]
+--
+-- 既存コードの 'dataNamed' もそのまま使える (削除予定なし)。
+dataNamedX :: Text -> [Double] -> Model a [a]
+dataNamedX = dataNamed
+
+-- | 'dataNamed' と同じ slot の **観測値 view** (生 @[Double]@)。
+-- 'observe' / 'observeLM' の観測値引数は AD に持ち上げない @[Double]@ 固定
+-- なので、 y 側のデータ slot はこちらで受ける (Phase 60.2):
+--
+-- > x  <- dataNamed    "x" []   -- covariate: モデル数値型 [a]
+-- > ys <- dataNamedObs "y" []   -- 観測値:    生 [Double]
+-- > ...
+-- > observe "y" (Normal mu s) ys
+--
+-- 同名 slot を 'dataNamed' と 'dataNamedObs' の両 view で読んでもよい
+-- (差し替えは 'withData' / 列 bind が slot 名単位で行うため一貫する)。
+dataNamedObs :: Text -> [Double] -> Model a [Double]
+dataNamedObs n ys = liftF (Data n ys snd)
+
+-- | 離散 index 専用のデータプレースホルダ (Phase 60.2、 60.7 で 'Ix' 戻りに刷新)。
+-- 群 index 等を slot 名タグ付き index 'Ix' で運ぶ。 @bs '!!!' g@ で引くと
+-- DAG に slot→利用先のエッジが自動で出る (PyMC の @b0[gid]@ 同型)。
+-- 'Ix' は Num でないので誤って算術に混ぜると型エラーで止まる
+-- (= 連続値経路の round 罠根治、 60.2 から継続)。
+--
+-- > gs <- dataNamedIx "g" [0,0,1,1,2]
+-- > let mu_i = b0s !!! g   -- round 不要・DAG に g→mu エッジ
+dataNamedIx :: Text -> [Int] -> Model a [Ix]
+dataNamedIx n is = liftF (DataIx n is (map (\i -> Ix i (Just n))))
+
+-- | slot 名タグ付き離散 index (Phase 60.7)。 'dataNamedIx' が返し、 '!!!' で
+-- 使う。 由来 slot 名 ('ixSlot') は DAG 抽出 (Track 解釈) のエッジ生成にだけ
+-- 使われ、 数値評価では 'ixVal' のみが意味を持つ。
+data Ix = Ix
+  { ixVal  :: !Int          -- ^ index 本体 (0..nG-1)
+  , ixSlot :: !(Maybe Text) -- ^ 由来 slot 名 ('dataNamedIx' なら Just)
+  } deriving (Show, Eq)
+
+-- | 解釈ごとの依存タグ注入 (Phase 60.7)。 既定 = 何もしない (数値解釈は
+-- ゼロコスト・サンプリングはビット不変)。 'Track' 解釈だけが override して
+-- 依存集合に slot 名を足し、 DAG にエッジを出す。
+class TrackTag a where
+  tagDep :: Text -> a -> a
+  tagDep _ = id
+  {-# INLINE tagDep #-}
+
+instance TrackTag Double
+
+-- dogfood 典型 (群別係数のタプル) 用: 成分ごとに伝播
+instance (TrackTag a, TrackTag b) => TrackTag (a, b) where
+  tagDep nm (a, b) = (tagDep nm a, tagDep nm b)
+instance (TrackTag a, TrackTag b, TrackTag c) => TrackTag (a, b, c) where
+  tagDep nm (a, b, c) = (tagDep nm a, tagDep nm b, tagDep nm c)
+instance (TrackTag a, TrackTag b, TrackTag c, TrackTag d)
+      => TrackTag (a, b, c, d) where
+  tagDep nm (a, b, c, d) = (tagDep nm a, tagDep nm b, tagDep nm c, tagDep nm d)
+
+-- | slot 名タグ付き索引 (Phase 60.7)。 @bs '!!!' g@ = @bs !! ixVal g@ に、
+-- Track 解釈でのみ g の由来 slot 名を依存タグとして注入する
+-- (= DAG に slot→利用先エッジ。 数値解釈は '!!' と同コスト)。
+(!!!) :: TrackTag b => [b] -> Ix -> b
+xs !!! Ix i ms = maybe id tagDep ms (xs !! i)
+infixl 9 !!!
+{-# INLINE (!!!) #-}
+
+-- | 'at' の 'Ix' 版 (Phase 60.7)。 'dataNamedIx' の gids を random effect の
+-- gather に渡す。 Phase 62: 先頭 'Ix' の由来 slot 名 ('ixSlot') を 'REff' に
+-- 載せるので、 DAG に slot→観測ノードのエッジが出る (gather の gids は単一
+-- slot 由来が通常形ゆえ先頭で代表)。 '!!!' (deterministic μ 経路) と並ぶ
+-- PyMC @b0[gid]@ 同型の両経路対応。
+atIx :: REffect a -> [Ix] -> REff
+atIx re gids =
+  REff (reffNames re) (map ixVal gids) (Just (reffScale re)) Nothing
+       (case gids of { Ix _ ms : _ -> ms; [] -> Nothing })
+
+-- | Replace a named data block in the model. If no match exists the
+-- model is returned unchanged.
+-- 同じ名前が複数回出現する場合は全箇所で差し替わる。
+--
+-- 型シグネチャは @Model a r@ なので、ユーザーが @ModelP r@ から呼ぶ場合
+-- そのまま多相的に使える (各 @a@ で個別に適用される)。
+withData :: forall r. Text -> [Double] -> ModelP r -> ModelP r
+withData n new m = mPoly
+  where
+    -- 戻り値を多相モデルとして再構築。各 @a@ 個別に元の m を走査する。
+    mPoly :: forall a. (Floating a, Ord a, TrackTag a) => Model a r
+    mPoly = go m
+      where
+        go :: Model a r -> Model a r
+        go (Pure r) = Pure r
+        go (Free f) = Free (case f of
+          Data n' ys k
+            | n == n'   -> Data n' new (\d -> go (k d))
+            | otherwise -> Data n' ys  (\d -> go (k d))
+          DataIx n' is k       -> DataIx n' is (\d -> go (k d))
+          Sample nm d k        -> Sample nm d (\v -> go (k v))
+          Observe nm d ys nx   -> Observe nm d ys (go nx)
+          ObserveLM nm bs xs re fam ys nx -> ObserveLM nm bs xs re fam ys (go nx)
+          Potential nm v nx    -> Potential nm v (go nx)
+          Deterministic nm v k -> Deterministic nm v (\v' -> go (k v'))
+          PlateBegin nm sz nx  -> PlateBegin nm sz (go nx)
+          PlateEnd nx          -> PlateEnd (go nx))
+
+-- | 'withData' の離散 index 版 (Phase 60.2): 名前付き 'DataIx' ブロックを
+-- 外部から差し替える。 一致しなければモデルは不変。
+withDataIx :: forall r. Text -> [Int] -> ModelP r -> ModelP r
+withDataIx n new m = mPoly
+  where
+    mPoly :: forall a. (Floating a, Ord a, TrackTag a) => Model a r
+    mPoly = go m
+      where
+        go :: Model a r -> Model a r
+        go (Pure r) = Pure r
+        go (Free f) = Free (case f of
+          DataIx n' is k
+            | n == n'   -> DataIx n' new (\d -> go (k d))
+            | otherwise -> DataIx n' is  (\d -> go (k d))
+          Data n' ys k         -> Data n' ys (\d -> go (k d))
+          Sample nm d k        -> Sample nm d (\v -> go (k v))
+          Observe nm d ys nx   -> Observe nm d ys (go nx)
+          ObserveLM nm bs xs re fam ys nx -> ObserveLM nm bs xs re fam ys (go nx)
+          Potential nm v nx    -> Potential nm v (go nx)
+          Deterministic nm v k -> Deterministic nm v (\v' -> go (k v'))
+          PlateBegin nm sz nx  -> PlateBegin nm sz (go nx)
+          PlateEnd nx          -> PlateEnd (go nx))
+
+-- | Latent multivariate-normal vector (analogous to PyMC's
+-- @pm.MvNormal@ used as a latent).
+--
+-- 非中心化パラメタ化 + Cholesky 分解で実装:
+--
+--   z_i ~ Normal(0, 1)  (i = 0..K-1, 独立な latent)
+--   x   = μ + L z       (L = Cholesky(Σ))
+--
+-- 各 z_i は通常の latent として NUTS が探索し、x は派生量として
+-- Chain に記録される。共分散行列が他の latent に依存する形でも
+-- 動作する (choleskyL は @(Floating a, Ord a)@ 多相)。
+--
+-- 共分散が非正定値のときは μ をそのまま返す (NUTS 探索中の不正領域
+-- に対する graceful fallback)。
+--
+-- 戻り値: K 次元 latent ベクトル @[a]@ (μ + L z)。
+-- Chain には @<name>_z<i>@ (raw latent) と @<name>_<i>@ (派生量) を保存。
+mvNormalLatent :: forall a. (Floating a, Ord a)
+               => Text -> [a] -> [[a]] -> Model a [a]
+mvNormalLatent name muVec covMatrix = do
+  let k = length muVec
+  zs <- mapM (\i -> sample (name <> "_z" <> T.pack (show i)) (Normal 0 1))
+             [0 .. k - 1]
+  let xs = case choleskyL covMatrix of
+        Just l  -> [ (muVec !! i) +
+                       sum [ ((l !! i) !! j) * (zs !! j)
+                           | j <- [0 .. i] ]
+                   | i <- [0 .. k - 1] ]
+        Nothing -> muVec      -- non-PD のフォールバック
+  mapM
+    (\(i, x) -> deterministic (name <> "_" <> T.pack (show i)) x)
+    (zip [0 :: Int ..] xs)
+
+-- | LKJ 相関行列の Cholesky factor (PyMC @LKJCholeskyCov@ 相当)。
+--
+-- LKJ(η) 事前: p(R) ∝ |R|^(η-1)。η = 1 で uniform、η > 1 で I に集中。
+--
+-- 実装は canonical partial correlations (CPC) 法:
+--   z_ij ~ scaled Beta(α_i, α_i) on (-1, 1),  α_i = η + (K - i - 1) / 2
+--     (i = 1..K-1, j = 0..i-1)
+--
+-- 各 z_ij は @<name>_pc<i>_<j>@ (Beta latent in (0,1)、内部で 2u-1 に変換)
+-- として保存。Cholesky factor の各要素は派生量 @<name>_L<i>_<j>@。
+--
+-- 戻り値: K×K 下三角行列 L (R = L Lᵀ となる相関の Cholesky)。
+-- 対角は √(1 - Σ z_{i,k}²)、対角下は z_ij × √(Π_{k<j}(1-z_{i,k}²))。
+lkjCorrCholesky :: forall a. (Floating a, Ord a)
+                => Text -> Int -> a -> Model a [[a]]
+lkjCorrCholesky name k eta
+  | k < 2     = error "lkjCorrCholesky: dimension must be >= 2"
+  | otherwise = do
+      -- 各 (i, j) で 1 <= j < i <= K-1 の partial correlation を sample
+      let pcIndices = [(i, j) | i <- [1 .. k - 1], j <- [0 .. i - 1]]
+      pcs <- mapM
+        (\(i, j) -> do
+            let alpha = eta + fromIntegral (k - i - 1) / 2
+                tag   = T.pack (show i) <> "_" <> T.pack (show j)
+            u <- sample (name <> "_u" <> tag) (Beta alpha alpha)
+            deterministic (name <> "_pc" <> tag) (2 * u - 1))
+        pcIndices
+      -- (i,j) → z_ij マップ
+      let pcMap = zip pcIndices pcs
+          lookupPC i j = head [v | ((ii, jj), v) <- pcMap, ii == i, jj == j]
+      -- Cholesky factor を構築 (下三角)
+      let lRow i =
+            [ if j > i then 0
+              else if i == 0 && j == 0 then 1
+              else if j == i  -- 対角
+                   then sqrt (1 - sum [ let z = lookupPC i kk
+                                        in z * z | kk <- [0 .. i - 1] ])
+              else            -- 対角下 j < i
+                let z       = lookupPC i j
+                    factor2 = product [ let z' = lookupPC i kk
+                                        in 1 - z' * z' | kk <- [0 .. j - 1] ]
+                in z * sqrt factor2
+            | j <- [0 .. k - 1] ]
+          lMat = [lRow i | i <- [0 .. k - 1]]
+      -- L 各要素を deterministic として保存
+      _ <- mapM
+        (\(i, j) ->
+          deterministic (name <> "_L" <> T.pack (show i) <> "_" <> T.pack (show j))
+                        ((lMat !! i) !! j))
+        [(i, j) | i <- [0 .. k - 1], j <- [0 .. i]]
+      return lMat
+
+-- | RBF (exponentiated quadratic) カーネルによる GP 共分散行列
+-- (Stan @gp_exp_quad_cov(x, alpha, rho)@ 相当)。
+--
+-- @K[i][j] = alpha^2 * exp(-0.5 * (x_i - x_j)^2 / rho^2)@、対角には数値安定化の
+-- jitter (1e-10) を加える (Stan 原典の @+ diag_matrix(rep_vector(1e-10, N))@ に
+-- 対応)。@x@ は 'dataNamedX' で束縛した @[a]@ をそのまま渡す (data と
+-- ハイパーパラメータ alpha/rho は共に @a@ 型なので realToFrac 不要)。
+--
+-- Phase 90 A2: vecIR (per-row 独立項の和が前提) には密行列が構造的に載らない
+-- ため、legacy walk+ad 経路 (`grad fFull`) で使う想定の孤立関数。
+gpExpQuadCov :: forall a. Floating a => [a] -> a -> a -> [[a]]
+gpExpQuadCov xs alpha rho =
+  [ [ let d = xi - xj
+      in alpha * alpha * exp (negate 0.5 * d * d / (rho * rho))
+           + (if i == j then 1e-10 else 0)
+    | (j, xj) <- zip [0 :: Int ..] xs ]
+  | (i, xi) <- zip [0 :: Int ..] xs ]
+
+-- | Gaussian Process 潜在関数 (Stan の non-centered GP パラメタ化相当):
+--
+-- > f_tilde ~ Normal(0, 1)     (各点独立)
+-- > L_cov = cholesky_decompose(gp_exp_quad_cov(x, alpha, rho))
+-- > f = L_cov * f_tilde
+--
+-- 既存の 'choleskyL' ('mvNormalLatent' と同じ AD 対応 Cholesky 分解) をそのまま
+-- 流用する。共分散が非正定値のときは全ゼロにフォールバックする
+-- ('mvNormalLatent' と同型の graceful fallback)。
+--
+-- 戻り値: N 次元 latent ベクトル @[a]@ (GP 事後関数値 f)。各要素は
+-- @<name>_f<i>@ として deterministic 保存される。
+gpLatent :: forall a. (Floating a, Ord a)
+         => Text -> [a] -> a -> a -> Model a [a]
+gpLatent name xs alpha rho = do
+  let n = length xs
+  ftilde <- mapM (\i -> sample (name <> "_ftilde" <> T.pack (show i)) (Normal 0 1))
+                 [0 .. n - 1]
+  let cov = gpExpQuadCov xs alpha rho
+      fs = case choleskyL cov of
+        Just l  -> [ sum [ (l !! i !! j) * (ftilde !! j) | j <- [0 .. i] ]
+                   | i <- [0 .. n - 1] ]
+        Nothing -> replicate n 0    -- non-PD のフォールバック
+  mapM
+    (\(i, f) -> deterministic (name <> "_f" <> T.pack (show i)) f)
+    (zip [0 :: Int ..] fs)
+
+-- | AR(1) latent 時系列 (PyMC `pm.AR1` 相当)。
+--
+-- 状態方程式:  x_t = ϕ x_{t−1} + ε_t,   ε_t ~ Normal(0, σ)
+-- 初期分布:    x_0 ~ Normal(0, σ / √(1 − ϕ²))   (定常分布、|ϕ| < 1 なら有限)
+--
+-- 引数 @phi@ は AR 係数、@sigma@ は innovation の sd。N 個の latent
+-- 状態 x_0 .. x_{N-1} を非中心化パラメタ化で sample する:
+--
+--   raw_t ~ Normal(0, 1)
+--   x_t = phi * x_{t-1} + sigma * raw_t       (t > 0)
+--   x_0 = (sigma / √(1 - ϕ²)) * raw_0
+--
+-- 戻り値: x_0 .. x_{N-1} の latent 値リスト ([a])。各 raw_t は
+-- @<name>_raw<t>@、x_t 自体は派生量 @<name>_<t>@ として保存。
+--
+-- |ϕ| ≥ 1 のフォールバック: 初期 sd を sigma に置き換える。
+ar1Latent :: forall a. (Floating a, Ord a)
+          => Text -> Int -> a -> a -> Model a [a]
+ar1Latent name nT phi sigma
+  | nT < 1 = error "ar1Latent: length must be >= 1"
+  | otherwise = do
+      raws <- mapM
+        (\t -> sample (name <> "_raw" <> T.pack (show t)) (Normal 0 1))
+        [0 .. nT - 1]
+      let phi2     = phi * phi
+          stat     = if phi2 < 1
+                       then sigma / sqrt (1 - phi2)
+                       else sigma   -- フォールバック
+      -- Phase 38: scanl で xs を先に組み立てると、 各 x_t の Track が
+      -- {x_raw0, …, x_raw_t} という遠い親集合を保持してしまい、 後で
+      -- deterministic 登録しても下流の親が plate-style にならない。
+      -- 各 step で deterministic の戻り値 (det 名で再ラベルされた Track)
+      -- を次の step に渡す monadic recursion で組む。
+      x0 <- deterministic (name <> "_0") (stat * head raws)
+      let chain _    []           = return []
+          chain xPrev ((t, rt):rest) = do
+            xt <- deterministic
+                    (name <> "_" <> T.pack (show t))
+                    (phi * xPrev + sigma * rt)
+            xs' <- chain xt rest
+            return (xt : xs')
+      xs' <- chain x0 (zip [(1 :: Int) .. ] (tail raws))
+      return (x0 : xs')
+
+-- | 非中心化 (non-centered) 正規分布。
+--
+-- @x ~ Normal(loc, scale)@ を直接サンプリングする代わりに、
+--
+-- > raw <- sample (name <> "_raw") (Normal 0 1)
+-- > deterministic name (loc + scale * raw)
+--
+-- に展開する。loc / scale が他の latent に依存するとき、centered
+-- パラメタ化は HMC の posterior が病的になりやすいので、それを
+-- 緩和するヘルパ。Neal's funnel が代表例。
+--
+-- 戻り値は constrained な値 @loc + scale * raw@。Chain には
+-- @<name>_raw@ (latent) と @<name>@ (derived) の両方が保存される。
+nonCenteredNormal :: Num a => Text -> a -> a -> Model a a
+nonCenteredNormal name loc scale = do
+  raw <- sample (name <> "_raw") (Normal 0 1)
+  deterministic name (loc + scale * raw)
+
+-- | GLMM family for 'glmmRandomIntercept' (Phase 37-A6)。
+data GlmmFamily
+  = GlmmGaussian   -- ^ 連続 y、 残差 SD `sigma` も sample される
+  | GlmmBinomial   -- ^ 0/1 y、 Bernoulli(σ(η))
+  | GlmmPoisson    -- ^ 非負整数 y、 Poisson(exp η)
+  deriving (Show, Eq)
+
+-- | Random intercept GLMM helper (Phase 37-A6)。
+--
+-- `y ~ X β + u_{group(i)} + (error)` を 1 関数で組み立てる:
+--
+-- * 固定効果 @β_k ~ Normal(0, 5)@ (p 個)
+-- * 群レベル SD @τ_u ~ HalfNormal(5)@
+-- * 群効果 @u_j ~ Normal(0, τ_u)@ (nG 個、 centered パラメタ化。
+--   群数大 / 群内 N 小なら別途 'nonCenteredNormal' を直接使う)
+-- * family に応じた観測:
+--     * Gaussian: 残差 @σ ~ Exp(1)@ を sample → @y ~ Normal(X β + u_j, σ)@
+--     * Binomial: @y ~ Bernoulli(σ(X β + u_j))@、 y は 0/1
+--     * Poisson:  @y ~ Poisson(exp(X β + u_j))@、 y は非負整数
+--
+-- 観測は単一の構造化ブロック @observeLMR \"y\"@ として発行される (Phase 54.4a・
+-- PyMC/Stan と同じく 1 ベクトル化観測ノード。 旧実装は per-obs @y_i@ を n 個展開)。
+-- 固定効果は密設計行列・群効果は gather で表現するので vec-tape ハイブリッド
+-- gradADU の高速経路に乗る。 chain 上の latent 名:
+-- @beta_0, …, beta_{p-1}, tau_u, u_0, …, u_{nG-1}, sigma?@.
+--
+-- 個別 (random slope や non-centered) が必要ならパターン 5 (random slope) /
+-- 形式 C (non-centered) を直接書く方が柔軟。 本 helper は最頻ユースケース
+-- 「固定効果 + 群別切片」 専用の shorthand。
+glmmRandomIntercept
+  :: forall a. (Floating a, Ord a)
+  => GlmmFamily   -- ^ 尤度の family
+  -> [[Double]]   -- ^ 固定効果 design X (n × p)、 切片は手で 1 列追加すること
+  -> [Int]        -- ^ 各観測の group id (0..nG-1)
+  -> [Double]     -- ^ 観測 y (length n)
+  -> Model a ()
+glmmRandomIntercept fam xRows gids ys = do
+  let n  = length ys
+      p  = if null xRows then 0 else length (head xRows)
+      nG = if null gids then 0 else maximum gids + 1
+  -- 固定効果
+  betas <- forM [0 .. p - 1] $ \k ->
+    sample (T.pack ("beta_" ++ show k)) (Normal 0 5)
+  -- 群レベル SD
+  tauU <- sample "tau_u" (HalfNormal 5)
+  -- 群別切片を第一級ランダム効果値として宣言 (Phase 54.4c)。 reNormal が
+  -- u_0..u_{nG-1} ~ Normal(0, tauU) を sample しつつスケール名 "tau_u" を構造に
+  -- 載せるので、 観測に `at` で gather すると compileGradU の **解析 prior 勾配**
+  -- 経路に乗り、 prior の O(nG) スカラ ad が排除される。
+  u <- reNormal "u" nG "tau_u" tauU
+  -- Gaussian のみ残差 SD
+  _mSig <- case fam of
+    GlmmGaussian -> Just <$> sample "sigma" (Exponential 1)
+    _            -> return Nothing
+  -- 観測は単一の構造化ブロック (observeLMR) として発行する (Phase 54.4a)。
+  -- η_i = Σ_k β_k X_ik + u_{g(i)} を固定効果 (密設計行列) + 群効果 (gather) で
+  -- 表現するので、 vec-tape ハイブリッド gradADU の高速経路に乗る。 PyMC/Stan と
+  -- 同じく観測は 1 ベクトル化ノード "y" (旧: per-obs y_i を n 個展開)。
+  let betaNames = [ T.pack ("beta_" ++ show k) | k <- [0 .. p - 1] ]
+      reffs     = [ u `at` gids ]
+      lmFam     = case fam of
+        GlmmGaussian -> LMGaussian "sigma"
+        GlmmBinomial -> LMBernoulli
+        GlmmPoisson  -> LMPoisson
+  -- betas/n は名前参照ゆえ値は使わないが、 latent 宣言として必要。
+  _ <- pure (betas, n)
+  observeLMR "y" betaNames xRows reffs lmFam ys
+
+-- | Dirichlet distribution (analogous to PyMC's @pm.Dirichlet@), expanded
+-- via stick-breaking
+-- latent ベクトル。
+--
+-- 引数:
+--   * @name@   : ベース名。展開後は @<name>_b<i>@ (i=0..K-2) が Beta 由来の
+--                棒折り変数、@<name>_<i>@ (i=0..K-1) が deterministic で
+--                記録された π 成分。
+--   * @alphas@ : 集中度ベクトル α = (α_1,...,α_K)。長さ K ≥ 2。
+--
+-- アルゴリズム:
+--   k = 1..K-1 で β_k ~ Beta(α_k, Σ_{j>k} α_j) を sample する。
+--   π_1 = β_1,  π_k = β_k Π_{j<k} (1 − β_j),  π_K = Π_{j<K} (1 − β_j)
+--
+-- これは π ~ Dirichlet(α) と厳密に等価なので、追加の Jacobian 補正は不要。
+-- HMC/NUTS では β_k が UnitIntervalT (logit) で自動的に
+-- (0,1) ↔ ℝ 変換されるので、シンプレックス制約は満たされる。
+dirichlet :: forall a. (Floating a, Ord a) => Text -> [a] -> Model a [a]
+dirichlet name alphas = do
+  let k = length alphas
+  if k < 2
+    then error "dirichlet: 長さ 2 未満のベクトルは未対応"
+    else do
+      let -- α_k+1..K の累積和 (右から)。長さ K (最後の要素は 0)
+          tailSums = scanr (+) 0 alphas
+      -- β_0..β_{K-2} を sample
+      betas <- mapM
+        (\i -> sample (name <> "_b" <> T.pack (show i))
+                      (Beta (alphas !! i) (tailSums !! (i + 1))))
+        [0 .. k - 2]
+      -- 残り棒の累積積 prods[i] = Π_{j<i} (1 - β_j),  prods[0] = 1
+      let prods = scanl (\acc b -> acc * (1 - b)) (1 :: a) betas
+          -- π_i = β_i * prods[i] for i < K-1, π_{K-1} = prods[K-1]
+          pis = [ if i < length betas
+                    then (betas !! i) * (prods !! i)
+                    else prods !! i
+                | i <- [0 .. k - 1] ]
+      -- 各 π_i を deterministic として保存し戻り値にも返す
+      mapM (\(i, p) ->
+              deterministic (name <> "_" <> T.pack (show i)) p)
+           (zip [0 :: Int ..] pis)
+
+-- | Increasing cuts helper for 'OrderedLogistic' / 'OrderedProbit'
+-- (Phase 39-A6)。 @c_1 = c_min@、 @c_k = c_{k-1} + d_k@ with
+-- @d_k ~ HalfNormal(scale)@ により自動的に increasing 列を保証する。
+--
+-- 戻り値は長さ @nCuts@ の Track が通る deterministic 値の列
+-- (@name_c_0@, …, @name_c_{nCuts-1}@)。 各 @d_k@ は @name_d_k@ で
+-- latent として登録される。 cuts は OrderedLogistic / OrderedProbit に
+-- そのまま渡せる。
+--
+-- DAG-safe pattern (Phase 38 で確立): monadic recursion で
+-- @deterministic@ の戻り値 (det 名で relabel された Track) を次 step に
+-- 渡すことで plate-style の親集合を保つ。
+orderedCuts :: forall a. (Floating a, Ord a)
+            => Text   -- ^ ベース名
+            -> Int    -- ^ カット数 K-1 (≥ 1)
+            -> a      -- ^ 最小値 c_min
+            -> a      -- ^ 増分の HalfNormal スケール
+            -> Model a [a]
+orderedCuts name nCuts cMin scale
+  | nCuts < 1 = error "orderedCuts: nCuts < 1 は未対応"
+  | otherwise = do
+      -- c_1 = c_min (定数を deterministic で登録、 Track 透過のため)
+      c1 <- deterministic (name <> "_c_1") cMin
+      -- c_2, ..., c_nCuts を monadic recursion で順に作る
+      -- chain prev i: 現在の前 cut Track が prev、 次に作るのは index i (1-based)
+      let chain prev i acc
+            | i > nCuts = return (reverse acc)
+            | otherwise = do
+                d  <- sample (name <> "_d_" <> T.pack (show i))
+                             (HalfNormal scale)
+                ci <- deterministic (name <> "_c_" <> T.pack (show i))
+                                    (prev + d)
+                chain ci (i + 1) (ci : acc)
+      rest <- chain c1 2 []
+      return (c1 : rest)
+
+-- | Dirichlet Process の有限近似 stick-breaking (Phase 39-A5)。
+-- @β_k ~ Beta(1, α)@ for @k = 1, …, T-1@、 重み
+-- @π_k = β_k Π_{j<k}(1 - β_j)@、 @π_T = Π_{j<T}(1 - β_j)@ (残差) で
+-- @Σ_k π_k = 1@ を保証。 truncation level @T@ で打ち切る (実用 T = 20-50)。
+--
+-- 戻り値は長さ @T@ の deterministic Track 列
+-- (@name_pi_1@, …, @name_pi_T@)。 @β_k@ は @name_b_k@ で latent 登録。
+--
+-- DAG-safe: 各 β を sample 後、 累積積を deterministic で chain して
+-- π を計算 (Phase 38 確立の規律)。
+dpStickBreaking :: forall a. (Floating a, Ord a)
+                => Text   -- ^ ベース名
+                -> Int    -- ^ truncation level T (≥ 2)
+                -> a      -- ^ concentration α (> 0)
+                -> Model a [a]
+dpStickBreaking name truncT alpha
+  | truncT < 2 = error "dpStickBreaking: truncation level < 2 は未対応"
+  | otherwise = do
+      -- β_1, …, β_{T-1} を sample
+      betas <- mapM
+        (\i -> sample (name <> "_b_" <> T.pack (show i))
+                      (Beta 1 alpha))
+        [1 .. truncT - 1]
+      -- 累積積 stick_k = Π_{j<k} (1 - β_j) を deterministic で chain
+      -- stick_1 = 1、 stick_{k+1} = stick_k * (1 - β_k)
+      stick1 <- deterministic (name <> "_stick_1") (1 :: a)
+      let stickChain prev i acc
+            | i > truncT = return (reverse acc)
+            | otherwise = do
+                let bIdx  = i - 1
+                    beta  = betas !! (bIdx - 1)  -- 1-based β_{i-1}
+                sNext <- deterministic
+                           (name <> "_stick_" <> T.pack (show i))
+                           (prev * (1 - beta))
+                stickChain sNext (i + 1) (sNext : acc)
+      restSticks <- stickChain stick1 2 []
+      let sticks = stick1 : restSticks  -- 長さ T
+      -- π_k = β_k * stick_k for k < T、 π_T = stick_T
+      pis <- mapM
+        (\i ->
+          let stickI = sticks !! (i - 1)
+              piVal  = if i < truncT
+                         then (betas !! (i - 1)) * stickI
+                         else stickI
+          in deterministic (name <> "_pi_" <> T.pack (show i)) piVal)
+        [1 .. truncT]
+      return pis
+
+-- | Hidden Markov Model 用の遷移行列 + 初期分布 prior helper
+-- (Phase 39-A4)。 K 状態の HMM について、 初期分布 π_0 と
+-- K×K 遷移行列の各行に Dirichlet(α, …, α) prior を置く。
+--
+-- 戻り値は @(π_0, transitions)@:
+-- * @π_0@: 長さ K の確率列 (Σ = 1)、 @name_pi0_<i>@ で deterministic 登録
+-- * @transitions@: 長さ K のリスト、 i 番目は遷移行列 i 行目
+--   (@name_trans_i_<j>@ で deterministic)
+--
+-- 離散状態列は **直接 latent としない** (NUTS は離散変数を扱えない)。
+-- 代わりに、 ユーザは観測列 @y@ の emission log-prob 行列を計算し、
+-- 'hmmForwardLogLik' で状態列をマージナル化した周辺対数尤度を求め、
+-- 'potential' で組み込む形を取る。
+--
+-- 内部実装は既存 'dirichlet' helper を K+1 回呼ぶだけ。 すべて
+-- deterministic chain で DAG-safe (Phase 38 規律)。
+hmmLatent :: forall a. (Floating a, Ord a)
+          => Text   -- ^ ベース名
+          -> Int    -- ^ 状態数 K (≥ 2)
+          -> a      -- ^ Dirichlet concentration α (> 0、 1 で uniform prior)
+          -> Model a ([a], [[a]])
+hmmLatent name k alpha
+  | k < 2 = error "hmmLatent: K < 2 は未対応"
+  | otherwise = do
+      pi0 <- dirichlet (name <> "_pi0") (replicate k alpha)
+      trans <- mapM
+        (\i -> dirichlet (name <> "_trans_" <> T.pack (show i))
+                         (replicate k alpha))
+        [0 .. k - 1]
+      return (pi0, trans)
+
+-- | HMM forward algorithm marginal log-likelihood (Phase 39-A4)。
+-- Phase 92 A2 で 'Hanalyze.Model.HBM.Util' へ純粋移設 (ここは re-export
+-- のみ・API 不変)。 用法は従来の @'potential' nm (hmmForwardLogLik ...)@ に加え、
+-- Normal emission の場合は 'HmmForwardNormal' + 'observeMV' が推奨
+-- (勾配コンパイラが forward-backward の閉形式随伴を使えるため大幅に速い)。
+
+-- ---------------------------------------------------------------------------
+-- 構造検査
+-- ---------------------------------------------------------------------------
+
+data NodeKind = LatentN | ObservedN Int | DeterministicN
+              | DataN Int   -- ^ Phase 60.4: データ slot ('dataNamed' / 'dataNamedIx')。
+                            --   Int = 長さ。 PyMC の pm.Data (ConstantData) 相当。
+  deriving (Show, Eq)
+
+data Node = Node
+  { nodeName   :: Text
+  , nodeKind   :: NodeKind
+  , nodeDist   :: Text         -- 分布名 (e.g. "Normal")
+  , nodeDeps   :: Set Text     -- 直接の親 (依存変数)
+  , nodePlates :: [Text]       -- Phase 40: plate スタック (外側から内側、 空 = 任意の plate に属さない)
+  } deriving (Show)
+
+-- | Walk the model with placeholder zeros and collect 'Node' metadata.
+-- 依存関係 ('nodeDeps') は 'extractDeps' を使うこと (placeholder 走査では取れない)。
+collectNodes :: forall r. ModelP r -> [Node]
+collectNodes m = go m []
+  where
+    go :: Model Double r -> [Node] -> [Node]
+    go (Pure _) acc = reverse acc
+    go (Free (Sample n d k)) acc =
+      go (k 0) (Node n LatentN (distName d) Set.empty [] : acc)
+    go (Free (Observe n d ys next)) acc =
+      go next (Node n (ObservedN (length ys)) (distName d) Set.empty [] : acc)
+    go (Free (ObserveLM n _ _ _ fam ys next)) acc =
+      go next (Node n (ObservedN (length ys)) (lmFamilyName fam) Set.empty [] : acc)
+    go (Free (Potential _ _ next)) acc = go next acc   -- Node 表示には含めない
+    go (Free (Deterministic _ v k)) acc = go (k v) acc
+    go (Free (Data n ys k)) acc =
+      go (k (ys, ys)) (Node n (DataN (length ys)) "Data" Set.empty [] : acc)
+    go (Free (DataIx n is k)) acc =
+      go (k is) (Node n (DataN (length is)) "DataIx" Set.empty [] : acc)
+    go (Free (PlateBegin _ _ next)) acc = go next acc  -- Phase 40: 透過
+    go (Free (PlateEnd next))       acc = go next acc
+
+sampleNames :: ModelP r -> [Text]
+sampleNames m = [nodeName n | n <- collectNodes m, nodeKind n == LatentN]
+
+-- | モデル中の 'Data' slot を (名前, placeholder が空か) で列挙する (Phase 60.3)。
+-- 同名 slot が複数回現れる場合は 1 entry に集約し、 **いずれかが空なら空扱い**
+-- (束縛層の loud error 判定は保守側に倒す)。 'DataIx' slot は 'dataIxSlots'。
+dataSlots :: forall r. ModelP r -> [(Text, Bool)]
+dataSlots m = dedupSlots (go m [])
+  where
+    go :: Model Double r -> [(Text, Bool)] -> [(Text, Bool)]
+    go (Pure _) acc = reverse acc
+    go (Free (Sample _ _ k)) acc = go (k 0) acc
+    go (Free (Observe _ _ _ next)) acc = go next acc
+    go (Free (ObserveLM _ _ _ _ _ _ next)) acc = go next acc
+    go (Free (Potential _ _ next)) acc = go next acc
+    go (Free (Deterministic _ v k)) acc = go (k v) acc
+    go (Free (Data n ys k)) acc = go (k (ys, ys)) ((n, null ys) : acc)
+    go (Free (DataIx _ is k)) acc = go (k is) acc
+    go (Free (PlateBegin _ _ next)) acc = go next acc
+    go (Free (PlateEnd next))       acc = go next acc
+
+-- | モデル中の 'DataIx' slot を (名前, placeholder が空か) で列挙する (Phase 60.3)。
+dataIxSlots :: forall r. ModelP r -> [(Text, Bool)]
+dataIxSlots m = dedupSlots (go m [])
+  where
+    go :: Model Double r -> [(Text, Bool)] -> [(Text, Bool)]
+    go (Pure _) acc = reverse acc
+    go (Free (Sample _ _ k)) acc = go (k 0) acc
+    go (Free (Observe _ _ _ next)) acc = go next acc
+    go (Free (ObserveLM _ _ _ _ _ _ next)) acc = go next acc
+    go (Free (Potential _ _ next)) acc = go next acc
+    go (Free (Deterministic _ v k)) acc = go (k v) acc
+    go (Free (Data _ ys k)) acc = go (k (ys, ys)) acc
+    go (Free (DataIx n is k)) acc = go (k is) ((n, null is) : acc)
+    go (Free (PlateBegin _ _ next)) acc = go next acc
+    go (Free (PlateEnd next))       acc = go next acc
+
+-- | slot 列挙の重複集約 (先頭出現順を保ち、 空 flag は OR)。
+dedupSlots :: [(Text, Bool)] -> [(Text, Bool)]
+dedupSlots xs =
+  [ (n, or [ e | (n', e) <- xs, n' == n ])
+  | n <- nub (map fst xs) ]
+
diff --git a/src/Hanalyze/Model/HBM/Sampling.hs b/src/Hanalyze/Model/HBM/Sampling.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Model/HBM/Sampling.hs
@@ -0,0 +1,405 @@
+{-# LANGUAGE ScopedTypeVariables #-}
+
+-- |
+-- Module      : Hanalyze.Model.HBM.Sampling
+-- Description : HBM の分布サンプリング (事前/事後予測用)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Phase 58.4: 分布からのサンプリング (事前/事後予測用) を分離。
+--
+-- 'Distribution' / 'HBM.Util' の上層。 PrimMonad + mwc-random に依存し、
+-- mwc-random が直接提供しない分布 (Cauchy, HalfCauchy, Weibull, …) は
+-- 逆 CDF 法 / rejection でここに実装する。 NUTS の per-draw 経路には乗らず
+-- (事前/事後予測のみ)、 性能ホットではない。
+module Hanalyze.Model.HBM.Sampling
+  ( sampleDist
+  , sampleMvDist
+  , sampleObsRep
+  ) where
+
+import Control.Monad (replicateM)
+import Data.List (zip4)
+import Control.Monad.Primitive (PrimMonad, PrimState)
+import qualified System.Random.MWC as MWCBase
+import qualified System.Random.MWC.Distributions as MWC
+import System.Random.MWC (Gen)
+
+import Hanalyze.Model.HBM.Util (choleskyL, chunksOf, gpRBFCovList)
+import Hanalyze.Model.HBM.Distribution (Distribution (..), phiCdfA)
+
+-- ---------------------------------------------------------------------------
+-- 分布からのサンプリング (事前/事後予測用)
+-- ---------------------------------------------------------------------------
+
+-- | Draw a single sample from a 'Distribution Double'.
+-- 事前予測サンプリング、事後予測サンプリング、観測値の生成に使う。
+--
+-- mwc-random が直接提供しない分布はここで実装する (Cauchy, HalfCauchy, etc.)。
+sampleDist :: forall m. PrimMonad m => Distribution Double -> Gen (PrimState m) -> m Double
+sampleDist (Normal mu sig) gen = MWC.normal mu sig gen
+sampleDist (Exponential rate) gen = do
+  u <- MWCBase.uniform gen :: m Double
+  return (-log u / rate)
+sampleDist (Gamma shape rate) gen =
+  -- mwc-random の gamma は scale パラメタ化なので 1/rate を渡す
+  MWC.gamma shape (1 / rate) gen
+sampleDist (Beta a b) gen = do
+  x <- MWC.gamma a 1 gen
+  y <- MWC.gamma b 1 gen
+  return (x / (x + y))
+sampleDist (Poisson lam) gen = samplePoissonKnuth lam gen
+sampleDist (Binomial n p) gen = do
+  -- n 回のベルヌーイ試行
+  let go 0 acc = return acc
+      go k acc = do
+        u <- MWCBase.uniform gen :: m Double
+        go (k - 1) (if u < p then acc + 1 else acc)
+  fmap fromIntegral (go n (0 :: Int))
+sampleDist (Uniform lo hi) gen = do
+  u <- MWCBase.uniform gen :: m Double
+  return (lo + u * (hi - lo))
+sampleDist (StudentT df mu sig) gen = do
+  -- t = mu + sig * Normal(0,1) / sqrt(Chi2(df) / df)
+  z    <- MWC.standard gen
+  chi2 <- MWC.gamma (df / 2) 2 gen   -- Chi2(df) = Gamma(df/2, scale=2)
+  return (mu + sig * z / sqrt (chi2 / df))
+sampleDist (Cauchy loc sc) gen = do
+  u <- MWCBase.uniform gen :: m Double
+  return (loc + sc * tan (pi * (u - 0.5)))
+sampleDist (HalfNormal sig) gen = do
+  z <- MWC.standard gen
+  return (abs (sig * z))
+sampleDist (HalfCauchy sc) gen = do
+  u <- MWCBase.uniform gen :: m Double
+  return (sc * abs (tan (pi * (u - 0.5))))
+sampleDist (LogNormal mu sig) gen = do
+  z <- MWC.standard gen
+  return (exp (mu + sig * z))
+sampleDist (Bernoulli p) gen = do
+  u <- MWCBase.uniform gen :: m Double
+  return (if u < p then 1.0 else 0.0)
+sampleDist (Categorical probs) gen = do
+  u <- MWCBase.uniform gen :: m Double
+  let total = sum probs
+      go _   []     = fromIntegral (length probs - 1)
+      go acc (p:ps) =
+        let acc' = acc + p / total
+        in if u < acc' then 0 else 1 + go acc' ps
+  return (go 0 probs)
+sampleDist (Mixture ws comps) gen
+  | null ws || length ws /= length comps = return (0/0)  -- NaN: 不正
+  | otherwise = do
+      -- 1) 重みに比例して成分 k を選ぶ
+      u <- MWCBase.uniform gen :: m Double
+      let total = sum ws
+          pickIdx _ [] = length ws - 1
+          pickIdx acc (w:rest) =
+            let acc' = acc + w / total
+            in if u < acc' then 0 else 1 + pickIdx acc' rest
+          k = pickIdx 0 ws
+      -- 2) 選んだ成分からサンプリング
+      sampleDist (comps !! k) gen
+sampleDist (Truncated d mLo mHi) gen =
+  -- 単純なリジェクション・サンプリング (範囲が極めて狭いと収束遅い)
+  let inRange y = case (mLo, mHi) of
+        (Just lo, _      ) | y < lo  -> False
+        (_,       Just hi) | y > hi  -> False
+        _                            -> True
+      tryOnce maxAttempts
+        | maxAttempts <= 0 = return (0/0)  -- 諦め
+        | otherwise = do
+            y <- sampleDist d gen
+            if inRange y then return y else tryOnce (maxAttempts - 1)
+  in tryOnce (10000 :: Int)
+sampleDist MvNormal{} _ =
+  error "MvNormal: observation-only — 'sample' 経由でのドローは未対応"
+sampleDist MvNormalChol{} _ =
+  error "MvNormalChol: observation-only — 'sample' 経由でのドローは未対応"
+sampleDist MvNormalGpRBF{} _ =
+  error "MvNormalGpRBF: observation-only — 'sample' 経由でのドローは未対応"
+sampleDist HmmForwardNormal{} _ =
+  error "HmmForwardNormal: observation-only — 'sample' 経由でのドローは未対応"
+sampleDist ArmaNormal{} _ =
+  error "ArmaNormal: observation-only — 'sample' 経由でのドローは未対応"
+sampleDist GradedResponseIrt{} _ =
+  error "GradedResponseIrt: observation-only — 'sample' 経由でのドローは未対応"
+sampleDist Multinomial{} _ =
+  error "Multinomial: observation-only — 'sample' 経由でのドローは未対応"
+sampleDist (InverseGamma alpha beta) gen = do
+  -- 1 / Gamma(α, rate=β) = 1 / Gamma(α, scale=1/β)
+  y <- MWC.gamma alpha (1 / beta) gen
+  return (1 / y)
+sampleDist (Weibull kShape lam) gen = do
+  -- 逆 CDF 法: x = λ (-log(1-u))^(1/k)
+  u <- MWCBase.uniform gen :: m Double
+  return (lam * ((-log (1 - u)) ** (1 / kShape)))
+sampleDist (Pareto alpha xm) gen = do
+  -- 逆 CDF 法: x = x_m / u^(1/α)
+  u <- MWCBase.uniform gen :: m Double
+  return (xm / (u ** (1 / alpha)))
+sampleDist (BetaBinomial n alpha beta) gen = do
+  -- p ~ Beta(α, β); k ~ Binomial(n, p)
+  p <- sampleDist (Beta alpha beta) gen
+  sampleDist (Binomial n p) gen
+sampleDist (VonMises mu kappa) gen = do
+  -- Best-Fisher の rejection sampler
+  let a = 1 + sqrt (1 + 4 * kappa * kappa)
+      b = (a - sqrt (2 * a)) / (2 * kappa)
+      r = (1 + b * b) / (2 * b)
+      tryOnce = do
+        u1 <- MWCBase.uniform gen :: m Double
+        let z = cos (pi * u1)
+            f = (1 + r * z) / (r + z)
+            c = kappa * (r - f)
+        u2 <- MWCBase.uniform gen :: m Double
+        if c * (2 - c) - u2 > 0 || log (c / u2) + 1 - c >= 0
+          then do
+            u3 <- MWCBase.uniform gen :: m Double
+            let sign = if u3 - 0.5 < 0 then (-1.0) else 1.0
+            return (mu + sign * acos f)
+          else tryOnce
+  tryOnce
+sampleDist (ZeroInflatedPoisson psi lam) gen = do
+  u <- MWCBase.uniform gen :: m Double
+  if u < psi
+    then return 0
+    else samplePoissonKnuth lam gen
+sampleDist (ZeroInflatedBinomial n psi p) gen = do
+  u <- MWCBase.uniform gen :: m Double
+  if u < psi
+    then return 0
+    else sampleDist (Binomial n p) gen
+sampleDist (SkewNormal mu sig alpha) gen = do
+  -- Henze 1986: δ = α/√(1+α²), X = μ + σ(δ|U₀| + √(1-δ²)U₁)
+  let delta = alpha / sqrt (1 + alpha * alpha)
+  u0 <- MWC.standard gen
+  u1 <- MWC.standard gen
+  return (mu + sig * (delta * abs u0 + sqrt (1 - delta * delta) * u1))
+sampleDist (Logistic mu s) gen = do
+  -- 逆 CDF: X = μ + s · log(u/(1-u))
+  u <- MWCBase.uniform gen :: m Double
+  return (mu + s * log (u / (1 - u)))
+sampleDist (Gumbel mu beta) gen = do
+  -- 逆 CDF: X = μ - β · log(-log u)
+  u <- MWCBase.uniform gen :: m Double
+  return (mu - beta * log (- log u))
+sampleDist (AsymmetricLaplace b kappa mu) gen = do
+  -- 逆 CDF。 pc = κ²/(1+κ²)、 U < pc なら左尾、 そうでなければ右尾
+  u <- MWCBase.uniform gen :: m Double
+  let k2 = kappa * kappa
+      pc = k2 / (1 + k2)
+  if u < pc
+    then return (mu + (kappa / b) * log (u / pc))
+    else return (mu - (1 / (b * kappa)) * log ((1 - u) / (1 - pc)))
+sampleDist (OrderedLogistic eta cuts) gen = do
+  -- η と cuts から各カテゴリの確率を計算して Categorical で sample
+  let sigm x = 1 / (1 + exp (-x))
+      probs  = catProbs cuts
+      catProbs []           = [1]
+      catProbs (c:rest)     = sigm (c - eta) : restProbs (sigm (c - eta)) rest
+      restProbs _    []         = [1 - sigm (last cuts - eta)]
+      restProbs prev (c:rest)   =
+        let cur = sigm (c - eta)
+        in (cur - prev) : restProbs cur rest
+  u <- MWCBase.uniform gen :: m Double
+  let go acc k []     = realToFrac (k - 1 :: Int)
+      go acc k (p:ps) =
+        let acc' = acc + p
+        in if u < acc' then realToFrac k else go acc' (k + 1) ps
+  return (go 0 (0 :: Int) probs)
+sampleDist (DiscreteUniform lo hi) gen = do
+  u <- MWCBase.uniform gen :: m Double
+  let span_ = hi - lo + 1
+      k     = lo + floor (u * realToFrac span_)
+      kClip = min hi k  -- u が 1 のとき span_ になるのを防ぐ
+  return (realToFrac kClip)
+sampleDist (Geometric p) gen = do
+  -- 逆 CDF: X = ceil(log U / log(1-p))、 PyMC convention で support から 1
+  u <- MWCBase.uniform gen :: m Double
+  let lq = log (1 - p)
+      x  = ceiling (log u / lq) :: Int
+  return (realToFrac (max 1 x))
+sampleDist (HyperGeometric nN kK nDraw) gen = do
+  -- 単純な urn sampling: 各引きで残り成功/失敗の割合から二項
+  let loop remN remK remDraw acc
+        | remDraw <= 0 = return (realToFrac (acc :: Int))
+        | otherwise = do
+            u <- MWCBase.uniform gen :: m Double
+            let pSucc = realToFrac remK / realToFrac remN :: Double
+                pick  = if u < pSucc then 1 else 0
+            loop (remN - 1) (remK - pick) (remDraw - 1) (acc + pick)
+  loop nN kK nDraw 0
+sampleDist (ZeroInflatedNegativeBinomial psi mu alpha) gen = do
+  u <- MWCBase.uniform gen :: m Double
+  if u < psi
+    then return 0
+    else sampleDist (NegativeBinomial mu alpha) gen
+sampleDist MvStudentT{} _ =
+  error "MvStudentT: observation-only — 'sample' 経由でのドローは未対応 (latent helper を別途用意予定)"
+sampleDist DirichletMultinomial{} _ =
+  error "DirichletMultinomial: observation-only — 'sample' 経由でのドローは未対応"
+sampleDist (NegativeBinomial mu alpha) gen = do
+  -- Gamma-Poisson mixture: λ ~ Gamma(α, β=α/μ); X ~ Poisson(λ)
+  lam <- MWC.gamma alpha (mu / alpha) gen
+  samplePoissonKnuth lam gen
+sampleDist (Censored d _ _) gen =
+  -- 元分布から普通にサンプリング (打ち切りは「観測過程」の話で生成側ではない)
+  sampleDist d gen
+sampleDist (Triangular lo c hi) gen = do
+  -- 逆 CDF。 Fc = (c-lo)/(hi-lo) 未満なら左側、 以上なら右側
+  u <- MWCBase.uniform gen :: m Double
+  let fc = (c - lo) / (hi - lo)
+  if u < fc
+    then return (lo + sqrt (u * (hi - lo) * (c - lo)))
+    else return (hi - sqrt ((1 - u) * (hi - lo) * (hi - c)))
+sampleDist (Kumaraswamy a b) gen = do
+  -- 逆 CDF: x = (1 - (1-u)^{1/b})^{1/a}
+  u <- MWCBase.uniform gen :: m Double
+  return ((1 - (1 - u) ** (1 / b)) ** (1 / a))
+sampleDist (Rice nu sig) gen = do
+  -- Y1 ~ N(ν, σ), Y2 ~ N(0, σ); X = sqrt(Y1²+Y2²)
+  y1 <- MWC.normal nu sig gen
+  y2 <- MWC.normal 0  sig gen
+  return (sqrt (y1 * y1 + y2 * y2))
+sampleDist Wishart{} _ =
+  error "Wishart: observation-only — 'sample' 経由でのドローは未対応 (Bartlett decomp の latent helper を別途用意予定)"
+sampleDist (Bound d mLo mHi) gen =
+  -- Bound は Truncated とほぼ同じ。 sample は rejection。
+  sampleDist (Truncated d mLo mHi) gen
+sampleDist (OrderedProbit eta cuts) gen = do
+  -- η と cuts から各カテゴリの確率を計算して Categorical で sample
+  let probs  = catProbs cuts
+      catProbs []        = [1]
+      catProbs (c:rest)  = phiCdfA (c - eta) : restProbs (phiCdfA (c - eta)) rest
+      restProbs _    []         = [1 - phiCdfA (last cuts - eta)]
+      restProbs prev (c:rest)   =
+        let cur = phiCdfA (c - eta)
+        in (cur - prev) : restProbs cur rest
+  u <- MWCBase.uniform gen :: m Double
+  let go acc k []     = realToFrac (k - 1 :: Int)
+      go acc k (p:ps) =
+        let acc' = acc + p
+        in if u < acc' then realToFrac k else go acc' (k + 1) ps
+  return (go 0 (0 :: Int) probs)
+sampleDist (DiscreteWeibull q beta) gen = do
+  -- 逆 CDF: k = ceil((log(1-u)/log q)^{1/β}) - 1
+  u <- MWCBase.uniform gen :: m Double
+  let r  = log (1 - u) / log q
+      k0 = ceiling (r ** (1 / beta)) - 1 :: Int
+      k  = max 0 k0
+  return (fromIntegral k)
+
+-- | 多変量分布から 1 観測 (k-vector) を draw する (Phase 44、 PPC 用)。
+-- 'sampleDist' (スカラ観測専用 'error') と別経路。 @y = μ + C z@ で z ~ N(0,1)、
+-- C は MvNormal なら @choleskyL Σ@、 MvNormalChol なら scaled Cholesky
+-- @M = diag σ · L@ (再分解不要)。 対応外の多変量分布は空リスト (= worker 側で
+-- graceful にスキップ。 本 Phase は MvNormal/MvNormalChol に集中)。
+sampleMvDist :: forall m. PrimMonad m => Distribution Double -> Gen (PrimState m) -> m [Double]
+sampleMvDist (MvNormal mu cov) gen = do
+  let k = length mu
+  zs <- replicateM k (MWC.standard gen)
+  pure $ case choleskyL cov of
+    Just c  -> [ (mu !! i) + sum [ ((c !! i) !! j) * (zs !! j) | j <- [0 .. i] ]
+               | i <- [0 .. k - 1] ]
+    Nothing -> mu
+sampleMvDist (MvNormalChol mu sigma l) gen = do
+  let k = length mu
+      m = [ [ (sigma !! i) * ((l !! i) !! j) | j <- [0 .. k - 1] ] | i <- [0 .. k - 1] ]
+  zs <- replicateM k (MWC.standard gen)
+  pure [ (mu !! i) + sum [ ((m !! i) !! j) * (zs !! j) | j <- [0 .. i] ]
+       | i <- [0 .. k - 1] ]
+sampleMvDist (MvNormalGpRBF xs alpha rho sigma) gen =    -- Phase 95 B-dsl: cov 展開して MvNormal と同じ
+  sampleMvDist (MvNormal (replicate (length xs) 0) (gpRBFCovList xs alpha rho sigma)) gen
+sampleMvDist _ _ = pure []
+
+-- | 1 observe ノード分の複製データ (y_rep) をまとめて draw する (Phase 90 A2)。
+-- PPC ('sampleYRep'/'epredPIAtHeld' 等) はこれまで観測分布を問わず ys の要素
+-- ごとに 'sampleDist' (スカラ専用) を呼んでいたため、 多変量分布 (MvNormal/
+-- MvNormalChol、 ys = 1 つの k-vector 観測がフラット化された形) では即座に
+-- @error@ していた (07-gp-regr で実測発覚)。 ここで次元 k ごとに ys をチャンク
+-- し 'sampleMvDist' に委譲する。 Multinomial は 'sampleMvDist' が未対応の
+-- ままなので (本 Phase の対象外)、 従来どおり 'sampleDist' に委ねる。
+sampleObsRep :: forall m. PrimMonad m
+             => Gen (PrimState m) -> Distribution Double -> [Double] -> m [Double]
+sampleObsRep gen d@(MvNormal mu _) ys =
+  concat <$> mapM (const (sampleMvDist d gen)) (chunksOf (length mu) ys)
+sampleObsRep gen d@(MvNormalChol mu _ _) ys =
+  concat <$> mapM (const (sampleMvDist d gen)) (chunksOf (length mu) ys)
+sampleObsRep gen d@(MvNormalGpRBF xs _ _ _) ys =   -- Phase 95 B-dsl
+  concat <$> mapM (const (sampleMvDist d gen)) (chunksOf (length xs) ys)
+sampleObsRep gen (HmmForwardNormal pi0 trans mus sg) ys = do
+  -- Phase 92 A2 (PPC): 状態列を π_0/遷移行列から draw → Normal(μ_s, σ) で emission。
+  -- 観測列全体 = 1 観測なので T = length ys の系列を 1 本生成する。
+  let kk = length pi0
+      pick ws = do                       -- 重み ws (非正規化可) からカテゴリを 1 つ draw
+        let s = sum ws
+        u <- (* s) <$> MWCBase.uniform gen
+        let go i acc (w:rest) | null rest || u <= acc + w = pure i
+                              | otherwise                 = go (i + 1) (acc + w) rest
+            go i _ []                                     = pure (max 0 (i - 1))
+        go 0 0 ws
+      stepState s = pick (if s < length trans then trans !! s else replicate kk 1)
+      emitAt s = do
+        z <- MWC.standard gen
+        pure ((if s < length mus then mus !! s else 0) + sg * z)
+      go' _ 0 acc = pure (reverse acc)
+      go' s n acc = do
+        y <- emitAt s
+        s' <- stepState s
+        go' s' (n - 1 :: Int) (y : acc)
+  s0 <- pick pi0
+  go' s0 (length ys) []
+sampleObsRep gen (ArmaNormal mu phi theta sg) ys = do
+  -- Phase 101 A2 (PPC): err_t ~ Normal(0, σ) を draw し、y を前向き再帰で生成
+  -- (y_1 = μ+φμ+e_1・y_t = μ + φ·y_{t−1} + θ·e_{t−1} + e_t)。
+  -- 観測列全体 = 1 観測なので T = length ys の系列を 1 本生成する。
+  let drawE = (sg *) <$> MWC.standard gen
+      go' _ _ 0 acc = pure (reverse acc)
+      go' prevY prevE n acc = do
+        e <- drawE
+        let y = mu + phi * prevY + theta * prevE + e
+        go' y e (n - 1 :: Int) (y : acc)
+  case length ys of
+    0 -> pure []
+    t -> do
+      e1 <- drawE
+      let y1 = mu + phi * mu + e1
+      go' y1 e1 (t - 1) [y1]
+sampleObsRep gen (GradedResponseIrt thetas ncats deltas gammas) ys = do
+  -- Phase 101 A3 (PPC): 各 (child, item) の p ベクトルからカテゴリを draw。
+  -- 欠測 (−1) 位置は −1 のまま返す (観測の欠測パターンを保存)。
+  let nItem = length ncats
+      rows  = chunksOf nItem ys
+      catPs th nc dl gm =
+        let kMax = nc - 1
+            qs = [ 1 / (1 + exp (negate (dl * (th - gm !! (kk - 1)))))
+                 | kk <- [1 .. kMax] ]
+        in [ if k == 1 then 1 - head qs
+             else if k == nc then qs !! (kMax - 1)
+             else (qs !! (k - 2)) - (qs !! (k - 1))
+           | k <- [1 .. nc] ]
+      pickCat ps = do
+        u <- MWCBase.uniform gen
+        let go k acc (w:rest) | null rest || u <= acc + w = pure k
+                              | otherwise                 = go (k + 1) (acc + w) rest
+            go k _ []                                     = pure (max 1 (k - 1))
+        go 1 0 ps
+      drawRow th row =
+        sequence [ if gr == -1 then pure (-1)
+                   else fromIntegral <$> pickCat (catPs th nc dl gm)
+                 | (nc, dl, gm, gr) <- zip4 ncats deltas gammas row ]
+  concat <$> sequence [ drawRow th row | (th, row) <- zip thetas rows ]
+sampleObsRep gen d ys = mapM (const (sampleDist d gen)) ys
+
+-- | Knuth のアルゴリズムで Poisson(λ) サンプル。λ < 30 程度なら十分高速。
+samplePoissonKnuth :: forall m. PrimMonad m => Double -> Gen (PrimState m) -> m Double
+samplePoissonKnuth lam gen = do
+  let l = exp (-lam)
+      go k p = do
+        u <- MWCBase.uniform gen :: m Double
+        let p' = p * u
+        if p' < l
+          then return (fromIntegral k)
+          else go (k + 1) p'
+  go 0 (1.0 :: Double)
diff --git a/src/Hanalyze/Model/HBM/Track.hs b/src/Hanalyze/Model/HBM/Track.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Model/HBM/Track.hs
@@ -0,0 +1,297 @@
+{-# LANGUAGE RankNTypes #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+
+-- |
+-- Module      : Hanalyze.Model.HBM.Track
+-- Description : HBM の依存追跡型 Track (latent 変数への依存伝播)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Phase 58.6b: 依存追跡型 'Track' を 'Hanalyze.Model.HBM' から分離。
+--
+-- 'Track' は @Floating@ 演算を通して「この値はどの latent 変数に依存するか」を
+-- 伝播する型。 'ModelP' をこの型で特殊化することで各 Observe / Deterministic
+-- ノードの親集合を自動抽出する ('extractDeps')。 DAG 可視化 (buildModelGraph)
+-- の基盤。
+--
+-- 依存は下層 'Hanalyze.Model.HBM.Model' (Node / ModelF / lmParents 等) と
+-- '...Distribution' (Distribution / distName) のみ。 評価層 (logJoint 等) には
+-- 依存しない (runTrack = logJoint の Track 特殊化は Eval 層に置く)。
+module Hanalyze.Model.HBM.Track
+  ( Track (..)
+  , trackVar
+  , trackConst
+  , extractDeps
+  ) where
+
+import Data.Set (Set)
+import qualified Data.Set as Set
+import Data.Map.Strict (Map)
+import qualified Data.Map.Strict as Map
+import Data.Text (Text)
+
+import Hanalyze.Model.HBM.Model
+import Hanalyze.Model.HBM.Distribution
+
+-- ---------------------------------------------------------------------------
+-- 依存追跡型 Track
+-- ---------------------------------------------------------------------------
+
+-- | Floating 演算を通して「この値はどの変数に依存するか」を伝播する型。
+--
+-- @ModelP@ をこの型で特殊化することで、各 Observe ノードが
+-- どの latent 変数に依存しているか自動抽出できる。
+data Track = Track
+  { trackVal  :: !Double
+  , trackDeps :: !(Set Text)
+  } deriving (Show, Eq)
+
+-- | 変数として登場する Track (deps に自分の名前を入れる)。
+trackVar :: Text -> Double -> Track
+trackVar n v = Track v (Set.singleton n)
+
+-- | 定数として扱う Track (deps なし)。
+trackConst :: Double -> Track
+trackConst v = Track v Set.empty
+
+-- Phase 60.7: '!!!' の依存タグ注入。 Track 解釈だけが slot 名を依存集合に
+-- 足し、 DAG に slot→利用先のエッジを出す (数値解釈は既定 id)。
+instance TrackTag Track where
+  tagDep nm (Track v ds) = Track v (Set.insert nm ds)
+
+-- 自然な順序関係 (Double の比較を使う)
+instance Ord Track where
+  compare a b = compare (trackVal a) (trackVal b)
+
+-- Floating の階段
+instance Num Track where
+  fromInteger n = trackConst (fromInteger n)
+  Track a sa + Track b sb = Track (a + b) (sa <> sb)
+  Track a sa - Track b sb = Track (a - b) (sa <> sb)
+  Track a sa * Track b sb = Track (a * b) (sa <> sb)
+  abs    (Track a sa) = Track (abs a) sa
+  signum (Track a sa) = Track (signum a) sa
+  negate (Track a sa) = Track (negate a) sa
+
+instance Fractional Track where
+  fromRational r = trackConst (fromRational r)
+  Track a sa / Track b sb = Track (a / b) (sa <> sb)
+
+instance Floating Track where
+  pi             = trackConst pi
+  exp   (Track a sa) = Track (exp   a) sa
+  log   (Track a sa) = Track (log   a) sa
+  sin   (Track a sa) = Track (sin   a) sa
+  cos   (Track a sa) = Track (cos   a) sa
+  tan   (Track a sa) = Track (tan   a) sa
+  asin  (Track a sa) = Track (asin  a) sa
+  acos  (Track a sa) = Track (acos  a) sa
+  atan  (Track a sa) = Track (atan  a) sa
+  sinh  (Track a sa) = Track (sinh  a) sa
+  cosh  (Track a sa) = Track (cosh  a) sa
+  tanh  (Track a sa) = Track (tanh  a) sa
+  asinh (Track a sa) = Track (asinh a) sa
+  acosh (Track a sa) = Track (acosh a) sa
+  atanh (Track a sa) = Track (atanh a) sa
+  sqrt  (Track a sa) = Track (sqrt  a) sa
+  Track a sa ** Track b sb = Track (a ** b) (sa <> sb)
+  logBase (Track a sa) (Track b sb) = Track (logBase a b) (sa <> sb)
+
+instance Real Track where
+  toRational = toRational . trackVal
+
+instance RealFrac Track where
+  properFraction (Track a sa) = let (i, f) = properFraction a in (i, Track f sa)
+
+-- | モデルを Track 型で実行し、各ノードの依存関係を抽出する。
+--
+-- Sample n: その変数自体は @{n}@ に依存する (自己依存)。
+-- Observe n: 分布のパラメータに含まれる latent 変数の集合を deps とする。
+--
+-- Phase 40: plate スタックを保持し、 各 Node に 'nodePlates' を埋める。
+-- 同時に出現した plate (name, size) を 'Map Text Int' で返す。
+extractDeps :: forall r. ModelP r -> ([Node], Map Text Int)
+extractDeps m =
+  let (ns, plates) = go m [] [] Map.empty Map.empty Map.empty in (ns, plates)
+  where
+    -- 引数 stack は **inner-most が head** の plate 名スタック。
+    -- slots / obsAcc は Phase 63.1 の side map: slots = データ slot の生値
+    -- (slot 名 → ys)、 obsAcc = observe の生 ys を obs 名ごとに chunk 蓄積
+    -- (per-point loop の observe \"y\" … [y] も連結すれば slot 全列と一致する)。
+    -- walk 終端 (Pure) で値一致逆引きし obs→slot エッジを張る ('linkObsSlots')。
+    go :: Model Track r -> [Text] -> [Node] -> Map Text Int
+       -> Map Text [Double] -> Map Text [[Double]] -> ([Node], Map Text Int)
+    go (Pure _) _ acc plates slots obsAcc =
+      (reverse (linkObsSlots slots obsAcc acc), plates)
+    go (Free (Sample n d k)) stack acc plates slots obsAcc =
+      let parentDeps = distDepsT d
+          node = Node n LatentN (distName d) parentDeps (reverse stack)
+          v    = trackVar n 1.0  -- 1 にすると log/exp が安全
+      in go (k v) stack (node : acc) plates slots obsAcc
+    go (Free (Observe n d ys next)) stack acc plates slots obsAcc =
+      let parentDeps = distDepsT d
+          node = Node n (ObservedN (length ys)) (distName d) parentDeps (reverse stack)
+      in go next stack (node : acc) plates slots (obsChunk n ys obsAcc)
+    go (Free (ObserveLM n bs _ re fam ys next)) stack acc plates slots obsAcc =
+      -- 親 = β + u + 分散パラメタ名 (lmParents)。 観測ブロックは 1 ノード。
+      let parentDeps = lmParents bs re fam
+          node = Node n (ObservedN (length ys)) (lmFamilyName fam) parentDeps (reverse stack)
+      in go next stack (node : acc) plates slots (obsChunk n ys obsAcc)
+    go (Free (Potential nm v next)) stack acc plates slots obsAcc =
+      -- Potential も DAG 上は「依存を持つ無形ノード」として可視化
+      let parentDeps = trackDeps v
+          node = Node nm LatentN "Potential" parentDeps (reverse stack)
+      in go next stack (node : acc) plates slots obsAcc
+    go (Free (Deterministic nm v k)) stack acc plates slots obsAcc =
+      -- Deterministic ノードの親は @v@ が触れた latent 集合。
+      -- 継続には deps を @{nm}@ に「再ラベル」 した Track を渡し、 下流が
+      -- @v@ の遠い親 (mu, tau 等) ではなく **det 名 nm そのもの** を
+      -- 親として認識するようにする (Phase 38 で plate-style DAG に修正)。
+      -- 数値値は元の @trackVal v@ を保持 (下流の log/exp 等が安全)。
+      let parentDeps = trackDeps v
+          node = Node nm DeterministicN "Deterministic" parentDeps (reverse stack)
+          v'   = Track (trackVal v) (Set.singleton nm)
+      in go (k v') stack (node : acc) plates slots obsAcc
+    go (Free (Data n ys k)) stack acc plates slots obsAcc =
+      -- Phase 60.4: pm.Data 相当のデータノード。 値 (fst view) には slot 名の
+      -- dep タグを載せ、 下流 (deterministic / observe の dist パラメタ) が
+      -- x→mu のエッジを自動で張れるようにする (Phase 38 deterministic
+      -- re-label と同手法)。 snd view (dataNamedObs の生 [Double]) には
+      -- deps を載せられないため、 slots に生値を控えて walk 終端で
+      -- 値一致逆引きの obs→slot エッジを張る (Phase 63.1)。
+      let node = Node n (DataN (length ys)) "Data" Set.empty (reverse stack)
+          vals = map (\v -> Track v (Set.singleton n)) ys
+      in go (k (vals, ys)) stack (node : acc) plates (Map.insert n ys slots) obsAcc
+    go (Free (DataIx n is k)) stack acc plates slots obsAcc =
+      -- DataIx は [Int] のまま継続に渡すため dep タグは載らない (ノードのみ)。
+      -- observe の ys ([Double]) と一致し得ないので slots にも入れない。
+      let node = Node n (DataN (length is)) "DataIx" Set.empty (reverse stack)
+      in go (k is) stack (node : acc) plates slots obsAcc
+    go (Free (PlateBegin nm sz next)) stack acc plates slots obsAcc =
+      -- plate を開始 = stack に push、 サイズも記録 (重複時は新値で上書き
+      -- = 同名 plate は同サイズ前提)
+      go next (nm : stack) acc (Map.insert nm sz plates) slots obsAcc
+    go (Free (PlateEnd next)) stack acc plates slots obsAcc =
+      -- plate を終了 = stack から pop。 空 stack は誤用 (PlateBegin 抜き
+      -- で PlateEnd が来た等) — 黙って無視する
+      let stack' = case stack of { _ : t -> t; [] -> [] }
+      in go next stack' acc plates slots obsAcc
+
+    -- obs 名ごとの ys chunk 蓄積 (新 chunk を先頭 prepend = 逆順保持。
+    -- per-observe の list append による O(n²) を避ける)。
+    obsChunk :: Text -> [Double] -> Map Text [[Double]] -> Map Text [[Double]]
+    obsChunk n ys = Map.insertWith (++) n [ys]
+
+    -- Phase 63.1: observe の連結 ys と値一致するデータ slot へ obs→slot エッジ
+    -- (= 該当 DataN Node の nodeDeps に obs 名を追加。 nodeDeps は「直接の親」
+    -- ゆえ slot は obs の子 = PyMC `make_compute_graph` の obs→y と同型)。
+    --
+    -- - 値一致は plate 長さ match (60.6) と同種の表示専用ヒューリスティック:
+    --   偶然同値の slot にも張られる (既知 caveat・doc 明記)、 同値 slot 複数は
+    --   全部に張る。 空 slot (未 bind placeholder) は対象外。
+    -- - 同名 (dataNamedObs \"y\" + observe \"y\" の docs 慣例) は対象外:
+    --   mergeByName で 1 ノードに統合されるため自己ループになる。
+    -- - 引数 acc は逆順のまま受けて逆順のまま返す (呼び元 Pure 節で reverse)。
+    linkObsSlots :: Map Text [Double] -> Map Text [[Double]] -> [Node] -> [Node]
+    linkObsSlots slots obsAcc acc
+      | Map.null links = acc
+      | otherwise      = map upd acc
+      where
+        -- obs 名 → 連結 ys (chunk は新しい順 prepend 蓄積ゆえ reverse)
+        obsYs = Map.map (concat . reverse) obsAcc
+        -- slot 名 → 親として足す obs 名集合
+        links = Map.fromListWith Set.union
+          [ (slotName, Set.singleton obsName)
+          | (slotName, sv) <- Map.toList slots
+          , not (null sv)
+          , (obsName, ys) <- Map.toList obsYs
+          , obsName /= slotName
+          , sv == ys ]
+        upd nd = case nodeKind nd of
+          DataN _ | Just parents <- Map.lookup (nodeName nd) links ->
+            nd { nodeDeps = nodeDeps nd <> parents }
+          _ -> nd
+
+-- | Distribution Track に含まれる依存変数集合を取り出す。
+distDepsT :: Distribution Track -> Set Text
+distDepsT (Normal mu sig)    = trackDeps mu <> trackDeps sig
+distDepsT (Exponential r)    = trackDeps r
+distDepsT (Gamma s r)        = trackDeps s <> trackDeps r
+distDepsT (Beta a b)         = trackDeps a <> trackDeps b
+distDepsT (Poisson lam)      = trackDeps lam
+distDepsT (Binomial _ p)     = trackDeps p
+distDepsT (Uniform lo hi)    = trackDeps lo <> trackDeps hi
+distDepsT (StudentT df mu s) = trackDeps df <> trackDeps mu <> trackDeps s
+distDepsT (Cauchy loc s)     = trackDeps loc <> trackDeps s
+distDepsT (HalfNormal s)     = trackDeps s
+distDepsT (HalfCauchy s)     = trackDeps s
+distDepsT (LogNormal mu s)   = trackDeps mu <> trackDeps s
+distDepsT (Bernoulli p)      = trackDeps p
+distDepsT (Categorical ps)   = mconcat (map trackDeps ps)
+distDepsT (Mixture ws ds)    = mconcat (map trackDeps ws) <> mconcat (map distDepsT ds)
+distDepsT (Truncated d mLo mHi) =
+  distDepsT d <> maybe mempty trackDeps mLo <> maybe mempty trackDeps mHi
+distDepsT (Censored  d mLo mHi) =
+  distDepsT d <> maybe mempty trackDeps mLo <> maybe mempty trackDeps mHi
+distDepsT (MvNormal mus covRows) =
+  mconcat (map trackDeps mus)
+    <> mconcat (concatMap (map trackDeps) covRows)
+distDepsT (MvNormalChol mus sigmas lRows) =
+  mconcat (map trackDeps mus)
+    <> mconcat (map trackDeps sigmas)
+    <> mconcat (concatMap (map trackDeps) lRows)
+distDepsT (MvNormalGpRBF xs alpha rho sigma) =   -- Phase 95 B-dsl: x は data・α/ρ/σ が param
+  mconcat (map trackDeps xs)
+    <> trackDeps alpha <> trackDeps rho <> trackDeps sigma
+distDepsT (HmmForwardNormal pi0 trans mus sg) =   -- Phase 92 A2: 全て param 側 (data は Observe に載る)
+  mconcat (map trackDeps pi0)
+    <> mconcat (concatMap (map trackDeps) trans)
+    <> mconcat (map trackDeps mus) <> trackDeps sg
+distDepsT (ArmaNormal mu phi theta sg) =   -- Phase 101 A2: 全て param 側 (data は Observe に載る)
+  trackDeps mu <> trackDeps phi <> trackDeps theta <> trackDeps sg
+distDepsT (GradedResponseIrt thetas _ _ _) =   -- Phase 101 A3: θs のみ param 側 (他は定数 data)
+  mconcat (map trackDeps thetas)
+distDepsT (NegativeBinomial mu alpha) = trackDeps mu <> trackDeps alpha
+distDepsT (Multinomial _ ps) = mconcat (map trackDeps ps)
+distDepsT (ZeroInflatedPoisson psi lam) = trackDeps psi <> trackDeps lam
+distDepsT (ZeroInflatedBinomial _ psi p) = trackDeps psi <> trackDeps p
+distDepsT (InverseGamma a b) = trackDeps a <> trackDeps b
+distDepsT (Weibull k l)      = trackDeps k <> trackDeps l
+distDepsT (Pareto a xm)      = trackDeps a <> trackDeps xm
+distDepsT (BetaBinomial _ a b) = trackDeps a <> trackDeps b
+distDepsT (VonMises mu k)    = trackDeps mu <> trackDeps k
+-- Phase 37 で追加した分布 (Phase 38 補修で網羅追加)
+distDepsT (SkewNormal mu sig alpha) =
+  trackDeps mu <> trackDeps sig <> trackDeps alpha
+distDepsT (Logistic mu s)    = trackDeps mu <> trackDeps s
+distDepsT (Gumbel mu beta)   = trackDeps mu <> trackDeps beta
+distDepsT (AsymmetricLaplace b kappa mu) =
+  trackDeps b <> trackDeps kappa <> trackDeps mu
+distDepsT (OrderedLogistic eta cuts) =
+  trackDeps eta <> mconcat (map trackDeps cuts)
+distDepsT DiscreteUniform{}  = mempty   -- Int 引数のみ
+distDepsT (Geometric p)      = trackDeps p
+distDepsT HyperGeometric{}   = mempty   -- Int 引数のみ
+distDepsT (ZeroInflatedNegativeBinomial psi mu alpha) =
+  trackDeps psi <> trackDeps mu <> trackDeps alpha
+distDepsT (MvStudentT nu mus covRows) =
+  trackDeps nu
+    <> mconcat (map trackDeps mus)
+    <> mconcat (concatMap (map trackDeps) covRows)
+distDepsT (DirichletMultinomial _ alphas) =
+  mconcat (map trackDeps alphas)
+distDepsT (Triangular lo c hi) =
+  trackDeps lo <> trackDeps c <> trackDeps hi
+distDepsT (Kumaraswamy a b)    = trackDeps a <> trackDeps b
+distDepsT (Rice nu sig)        = trackDeps nu <> trackDeps sig
+distDepsT (DiscreteWeibull q beta) = trackDeps q <> trackDeps beta
+distDepsT (Wishart nu vRows) =
+  trackDeps nu <> mconcat (concatMap (map trackDeps) vRows)
+distDepsT (Bound d mLo mHi) =
+  distDepsT d
+    <> maybe mempty trackDeps mLo
+    <> maybe mempty trackDeps mHi
+distDepsT (OrderedProbit eta cuts) =
+  trackDeps eta <> mconcat (map trackDeps cuts)
+
diff --git a/src/Hanalyze/Model/HBM/Util.hs b/src/Hanalyze/Model/HBM/Util.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Model/HBM/Util.hs
@@ -0,0 +1,403 @@
+{-# LANGUAGE ScopedTypeVariables #-}
+-- |
+-- Module      : Hanalyze.Model.HBM.Util
+-- Description : HBM の純粋な数値・線形代数 leaf ユーティリティ
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- HBM の純粋な数値・線形代数 leaf ユーティリティ。
+--
+-- ここに集めた定義は HBM のいずれの型 (Distribution / Model / Track 等) にも
+-- 依存しない葉 (leaf) であり、 Floating / Ord のみで多相に書かれている。
+-- AD (Reverse.Double) でも Track でも評価できるよう型クラス制約を最小に保つ。
+-- 'Hanalyze.Model.HBM' は本モジュールを import して内部利用 + 一部を re-export する
+-- (公開シンボル: 'lgammaApprox' / 'digamma')。
+--
+-- Phase 58.2 で 'Hanalyze.Model.HBM' (5,519 行) から責務分離して抽出。
+-- 数値は 1 bit も変えていない (純粋な移設)。
+module Hanalyze.Model.HBM.Util
+  ( -- * 線形代数 (下三角ソルバ / Cholesky / リスト整形)
+    backSubLT
+  , chunksOf
+  , choleskyL
+  , forwardSub
+  , gpRBFCovList
+    -- * log-sum-exp / HMM forward
+  , negInf
+  , logSumExpA
+  , hmmForwardLogLik
+    -- * 不完全ガンマ関数 P(a, x)
+  , incGammaPA
+  , igammSer
+  , igammCF
+    -- * 正則化不完全ベータ関数 I_x(a, b)
+  , incBetaA
+  , betaCFA
+    -- * 数値ユーティリティ (Γ / digamma / 階乗 / Bessel)
+  , lgammaApprox
+  , digamma
+  , lgammaApproxDeriv
+  , logFactorial
+  , logBinomCoeff
+  , logBesselI0
+  ) where
+
+import Data.List (foldl')
+import qualified Data.Vector as V
+
+-- ===========================================================================
+-- 線形代数 (下三角ソルバ / Cholesky / リスト整形)
+-- ===========================================================================
+-- Phase 95 A2 (2026-07-13): choleskyL/forwardSub/backSubLT の内部を nested-list
+--   ([[a]] + !! O(n)索引 + ++ O(n)追記) から Data.Vector (O(1) 索引 + snoc) へ
+--   脱リスト化。公開シグネチャ ([[a]]) は不変 = 呼び出し元は無改修。数値は回帰
+--   テスト内で一致 (posterior bit 一致を実測)。★N=11 の gp-regr では効果ゼロ
+--   (真因は AD tape ノード alloc・§A2 参照) だが、大 N の密行列では list !!/++ が
+--   O(N⁴) 化して支配的になるため user 判断で先行 infra として採用 (2026-07-13)。
+--   ※さらなる高速化には interface 自体の Vector 化 (呼出側の per-call 変換除去) が
+--   要・大 N 密行列モデル出現時の TODO。
+
+-- | Phase 95 B-dsl: RBF (exponentiated-quadratic) GP カーネルの共分散行列を
+--   nested list で構築する。 @Σ_ij = α² exp(-0.5 (x_i-x_j)²/ρ²) + [i=j](1e-10 + σ)@。
+--   'Hanalyze.Model.HBM.gpExpQuadCov' (jitter 1e-10 込) + 対角 σ と一致する
+--   = 'MvNormalGpRBF' 密度が呼ぶ (値は既存 gp-regr モデルと bit 一致)。 下層 (Util)
+--   に置くことで 'Distribution' の @obsLogSum@ から参照できる (Model 層の
+--   'gpExpQuadCov' は上層ゆえ密度からは呼べない)。 ★ホット経路 (Gradient の
+--   'gpRBFAnalyticVG') は本 list 版を使わず hmatrix Matrix で直接組む (脱リスト)。
+{-# INLINABLE gpRBFCovList #-}
+gpRBFCovList :: forall a. Floating a => [a] -> a -> a -> a -> [[a]]
+gpRBFCovList xs alpha rho sigma =
+  [ [ let d = xi - xj
+          k = alpha * alpha * exp (negate 0.5 * d * d / (rho * rho))
+      in k + (if i == j then 1e-10 + sigma else 0)
+    | (j, xj) <- zip [0 :: Int ..] xs ]
+  | (i, xi) <- zip [0 :: Int ..] xs ]
+
+-- | 下三角 L から Lᵀ x = b を後退代入で解く (L は @choleskyL@ 形式)。
+{-# INLINABLE backSubLT #-}
+backSubLT :: forall a. Floating a => [[a]] -> [a] -> [a]
+backSubLT l b =
+  let n   = length b
+      lV  = V.fromList [ V.fromList r | r <- l ]
+      arr = V.fromListN n (b ++ repeat 0)
+      go :: Int -> V.Vector a -> V.Vector a
+      go i acc                       -- acc = x[i+1..n-1]
+        | i < 0 = acc
+        | otherwise =
+            -- (acc は index i+1..n-1 の解、 i 番目を解く)
+            -- Lᵀ x = b → 行 i: Σ_{j>=i} L[j][i] x_j = b_i
+            -- → x_i = (b_i - Σ_{j>i} L[j][i] x_j) / L[i][i]
+            let lii  = (lV V.! i) V.! i
+                bi   = arr V.! i
+                s    = V.sum (V.imap (\t xj -> (lV V.! (i + 1 + t)) V.! i * xj) acc)
+                xi   = (bi - s) / lii
+            in go (i - 1) (V.cons xi acc)
+  in V.toList (go (n - 1) V.empty)
+
+-- | リストを長さ @n@ ごとに分割。最後が短ければそのまま (本実装では使わない想定)。
+chunksOf :: Int -> [a] -> [[a]]
+chunksOf _ [] = []
+chunksOf n xs = let (h, t) = splitAt n xs in h : chunksOf n t
+
+-- | 対称正定値行列 Σ の Cholesky 下三角分解 L (Σ = L Lᵀ)。
+-- 行列は行リスト @[[a]]@ で、l[i] は長さ @i+1@ の下三角行 ([L[i][0]..L[i][i]])。
+-- 対角が非正になれば @Nothing@。
+{-# INLINABLE choleskyL #-}
+choleskyL :: forall a. (Floating a, Ord a) => [[a]] -> Maybe [[a]]
+choleskyL a0 =
+  let n  = length a0
+      aV = V.fromList [ V.fromList r | r <- a0 ]   -- 入力行 (各行長 >= i+1)
+      step :: Int -> V.Vector (V.Vector a) -> Maybe (V.Vector (V.Vector a))
+      step i prev                                   -- prev = 確定済 L[0..i-1]
+        | i == n = Just prev
+        | otherwise =
+            let row = aV V.! i
+                buildCol :: Int -> V.Vector a -> Maybe (V.Vector a)
+                buildCol j cur                        -- cur = L[i][0..j-1]
+                  | j > i  = Just cur
+                  | j == i =
+                      let s  = V.sum (V.map (\v -> v * v) cur)
+                          d2 = (row V.! i) - s
+                      in if d2 <= 0
+                           then Nothing
+                           else buildCol (j + 1) (V.snoc cur (sqrt d2))
+                  | otherwise =
+                      let lj  = prev V.! j           -- 長さ j+1
+                          s   = V.sum (V.zipWith (*) cur lj)
+                          ljj = lj V.! j
+                      in if ljj == 0
+                           then Nothing
+                           else buildCol (j + 1) (V.snoc cur ((row V.! j - s) / ljj))
+            in case buildCol 0 V.empty of
+                 Nothing -> Nothing
+                 Just nr -> step (i + 1) (V.snoc prev nr)
+  in fmap (\v -> [ V.toList r | r <- V.toList v ]) (step 0 V.empty)
+
+-- | 下三角系 L z = b の前進代入 (L は @choleskyL@ 形式、長さ各 i+1)。
+{-# INLINABLE forwardSub #-}
+forwardSub :: forall a. Floating a => [[a]] -> [a] -> [a]
+forwardSub l b =
+  let n   = length b
+      lV  = V.fromList [ V.fromList r | r <- l ]
+      bV  = V.fromList b
+      go :: Int -> V.Vector a -> V.Vector a
+      go i acc                          -- acc = z[0..i-1]
+        | i == n = acc
+        | otherwise =
+            let lrow = lV V.! i           -- 長さ i+1
+                lii  = lrow V.! i
+                lpre = V.take i lrow      -- L[i][0..i-1]
+                bi   = bV V.! i
+                s    = V.sum (V.zipWith (*) lpre acc)
+                zi   = (bi - s) / lii
+            in go (i + 1) (V.snoc acc zi)
+  in V.toList (go 0 V.empty)
+
+-- ===========================================================================
+-- log-sum-exp
+-- ===========================================================================
+
+negInf :: Floating a => a
+negInf = -1/0
+
+-- | 多相 log-sum-exp。AD でも Track でも使えるよう Floating + Ord で書く。
+-- @logSumExpA xs = log (Σ exp x)@ を最大値シフトで安定化。
+{-# INLINABLE logSumExpA #-}
+logSumExpA :: (Floating a, Ord a) => [a] -> a
+logSumExpA []  = negInf
+logSumExpA [x] = x
+logSumExpA xs  =
+  let m = maximum xs
+  -- 全要素が -∞ なら m - m = NaN になるので早期 return
+  in if m == negInf
+       then negInf
+       else m + log (sum (map (\x -> exp (x - m)) xs))
+
+-- ===========================================================================
+-- HMM forward algorithm (状態列の周辺化)
+-- ===========================================================================
+-- Phase 92 A2 (2026-07-17): Model.hs:1071 から純粋移設 (数値は 1 bit も不変)。
+-- 'Distribution' の 'HmmForwardNormal' 密度 ('obsLogSum') が呼ぶため、
+-- Model 非依存の leaf である本モジュールへ降ろした。
+-- 'Hanalyze.Model.HBM.Model' が従来どおり re-export する。
+
+-- | 隠れマルコフモデルの周辺対数尤度 (forward algorithm)。
+--
+-- Recursion in log-space (underflow 防止):
+-- * @α_1[k] = log π_0[k] + emit[0][k]@
+-- * @α_{t+1}[k'] = logSumExp_j (α_t[j] + log T[j][k']) + emit[t+1][k']@
+-- * @log P(y_{1..T}) = logSumExp_k α_T[k]@
+--
+-- 多相 (@Floating a, Ord a@) のため Track / AD 経由でも動く。
+-- 計算量 @O(T K²)@。 大 T では list-based なので O(K²) の内部ループは
+-- そのまま、 step は foldl' で過去 α を破棄しメモリは @O(K)@。
+hmmForwardLogLik :: forall a. (Floating a, Ord a)
+                 => [a]     -- ^ 初期分布 π_0 (length K)
+                 -> [[a]]   -- ^ 遷移行列 (K×K rows of length K)
+                 -> [[a]]   -- ^ log emission [T][K]
+                 -> a
+hmmForwardLogLik pi0 trans emit
+  | null emit       = 0  -- T=0: 観測なし
+  | null pi0        = negInf
+  | length pi0 /= length trans = negInf
+  | any ((/= k) . length) trans = negInf
+  | otherwise =
+      let -- α_1[s] = log π_0[s] + emit[0][s]
+          alpha0 = zipWith (\p e -> log p + e) pi0 (head emit)
+          -- 1 step: α_{t+1}[s'] = logSumExp_s (α_t[s] + log T[s][s']) + emit_{t+1}[s']
+          step :: [a] -> [a] -> [a]
+          step alphaT emT =
+            [ logSumExpA
+                [ (alphaT !! s) + log ((trans !! s) !! s')
+                | s <- [0 .. k - 1] ]
+              + (emT !! s')
+            | s' <- [0 .. k - 1] ]
+          alphaFinal = foldl' step alpha0 (tail emit)
+      in logSumExpA alphaFinal
+  where
+    k = length pi0
+
+-- ===========================================================================
+-- 不完全ガンマ関数 P(a, x) = γ(a, x) / Γ(a)  (Numerical Recipes 6.2)
+-- ===========================================================================
+
+-- | 正則化された下側不完全ガンマ関数 P(a, x) = γ(a, x) / Γ(a) ∈ [0, 1]。
+-- これは Gamma(shape=a, rate=1) の CDF F(x)。
+{-# INLINABLE incGammaPA #-}
+incGammaPA :: (Floating a, Ord a) => a -> a -> a
+incGammaPA a x
+  | x <= 0 || a <= 0 = 0
+  | x < a + 1        = igammSer a x          -- 級数展開で P(a,x)
+  | otherwise        = 1 - igammCF a x        -- 連分数で Q(a,x)、P = 1 - Q
+
+-- 級数展開: P(a, x) = e^{-x} x^a / Γ(a) * Σ x^n / (a(a+1)...(a+n))
+{-# INLINABLE igammSer #-}
+igammSer :: forall a. (Floating a, Ord a) => a -> a -> a
+igammSer a x = sumSer * exp (-x + a * log x - lgammaApprox a)
+  where
+    -- 反復: term_{n+1} = term_n * x / (a + n + 1)
+    sumSer = go (0 :: Int) (1 / a) (1 / a)
+    eps :: a
+    eps    = 1e-13
+    maxIt  = 200 :: Int
+    go n term acc
+      | n >= maxIt           = acc
+      | abs term < abs acc * eps = acc
+      | otherwise =
+          let n'    = n + 1
+              term' = term * x / (a + fromIntegral n')
+              acc'  = acc + term'
+          in go n' term' acc'
+
+-- 連分数 (Lentz 法): Q(a, x) = e^{-x} x^a / Γ(a) * CF
+-- CF = 1/(x+1-a - 1·(1-a)/(x+3-a - 2·(2-a)/(...))
+{-# INLINABLE igammCF #-}
+igammCF :: forall a. (Floating a, Ord a) => a -> a -> a
+igammCF a x = exp (-x + a * log x - lgammaApprox a) * h
+  where
+    fpmin, eps :: a
+    fpmin = 1e-300
+    eps   = 1e-13
+    maxIt = 200 :: Int
+    -- modified Lentz's method
+    b0    = x + 1 - a
+    c0    = 1 / fpmin
+    d0    = 1 / b0
+    h     = goCF (1 :: Int) b0 c0 d0 d0
+    goCF i b c d hh
+      | i > maxIt              = hh
+      | abs (del - 1) < eps    = hh'
+      | otherwise              = goCF (i + 1) b' c'' d''' hh'
+      where
+        an   = -fromIntegral i * (fromIntegral i - a)
+        b'   = b + 2
+        d'   = b' + an * d
+        d''  = if abs d' < fpmin then fpmin else d'
+        c'   = b' + an / c
+        c''  = if abs c' < fpmin then fpmin else c'
+        d''' = 1 / d''
+        del  = d''' * c''
+        hh'  = hh * del
+    _ = c0  -- 未使用ダミー (修正された Lentz 法の起動値: 別経路)
+
+-- ===========================================================================
+-- 正則化された不完全ベータ関数 I_x(a, b) = B(x; a, b) / B(a, b)
+-- ===========================================================================
+
+-- | 正則化された不完全ベータ関数 I_x(a, b) ∈ [0, 1]。
+-- これは Beta(a, b) の CDF F(x)。
+-- StudentT の CDF にも内部で使用。
+{-# INLINABLE incBetaA #-}
+incBetaA :: (Floating a, Ord a) => a -> a -> a -> a
+incBetaA x a b
+  | x <= 0    = 0
+  | x >= 1    = 1
+  | otherwise =
+      -- 対数ベータ正規化定数
+      let bt = exp ( lgammaApprox (a + b)
+                   - lgammaApprox a
+                   - lgammaApprox b
+                   + a * log x
+                   + b * log (1 - x))
+      in if x < (a + 1) / (a + b + 2)
+           then bt * betaCFA x a b / a
+           else 1 - bt * betaCFA (1 - x) b a / b
+
+-- 連分数 (modified Lentz, Numerical Recipes §6.4)
+{-# INLINABLE betaCFA #-}
+betaCFA :: forall a. (Floating a, Ord a) => a -> a -> a -> a
+betaCFA x a b = iterate' (1 :: Int) 1 d0 h0
+  where
+    fpmin, eps :: a
+    fpmin = 1e-300
+    eps   = 1e-13
+    maxIt = 200 :: Int
+    qab = a + b
+    qap = a + 1
+    qam = a - 1
+    capLent v = if abs v < fpmin then fpmin else v
+    d0 = 1 / capLent (1 - qab * x / qap)
+    h0 = d0
+
+    iterate' m c d h
+      | m > maxIt          = h
+      | abs (del - 1) < eps = hO
+      | otherwise          = iterate' (m + 1) cO dO hO
+      where
+        mD  = fromIntegral m :: a
+        -- 偶数項: aa_2m = m(b-m)x / ((qam+2m)(a+2m))
+        aaE = mD * (b - mD) * x / ((qam + 2 * mD) * (a + 2 * mD))
+        dE  = 1 / capLent (1 + aaE * d)
+        cE  = capLent (1 + aaE / c)
+        hE  = h * dE * cE
+        -- 奇数項: aa_2m+1 = -(a+m)(qab+m)x / ((a+2m)(qap+2m))
+        aaO = -(a + mD) * (qab + mD) * x / ((a + 2 * mD) * (qap + 2 * mD))
+        dO  = 1 / capLent (1 + aaO * dE)
+        cO  = capLent (1 + aaO / cE)
+        del = dO * cO
+        hO  = hE * del
+
+-- ===========================================================================
+-- 数値ユーティリティ (Γ / digamma / 階乗 / Bessel)
+-- ===========================================================================
+
+-- | log Γ(z) の Stirling 近似 (z > 0)。AD でも Track でも使える多相版。
+{-# INLINABLE lgammaApprox #-}
+lgammaApprox :: (Floating a, Ord a) => a -> a
+lgammaApprox z
+  | z < 12    = lgammaApprox (z + 1) - log z
+  | otherwise = (z - 0.5) * log z - z + 0.5 * log (2 * pi)
+              + 1 / (12 * z) - 1 / (360 * z ^ (3::Int))
+
+-- | ψ(z) = d/dz log Γ(z) (z > 0・Phase 56.1)。 記号微分 IR の lgamma 単項 op
+-- ('SLgammaO' 予定) の導関数用。 'lgammaApprox' と同一の recurrence
+-- (z < 12 を押し上げ) + 漸近級数を lgammaApprox の Stirling 微分より 1 項深く
+-- (-1/(252 z⁶) まで) 打切り: 真の ψ との差は z=12 で ~1e-11、
+-- lgammaApprox の数値微分との差は lgammaApprox 側の打切り由来 ~1.3e-9
+-- (試験許容 1e-8 内)。 z ≤ 0 は未対応 (利用箇所は正値前提)。
+digamma :: Double -> Double
+digamma z
+  | z < 12    = digamma (z + 1) - 1 / z
+  | otherwise = log z - 1 / (2 * z) - 1 / (12 * z * z)
+              + 1 / (120 * z ^ (4 :: Int)) - 1 / (252 * z ^ (6 :: Int))
+
+-- | 'lgammaApprox' の**厳密な項別導関数** (Phase 56.4)。 'digamma' とは最終項
+-- 1/(252z⁶) の有無だけ違う (digamma は真の ψ に 1 項深い分この差 ~1.3e-9 が
+-- z=12 境界で出る・実測)。 記号微分 IR ('SLgammaO') の導関数は、 評価関数
+-- (lgammaApprox) の AD 微分 = walk+ad fallback / 参照勾配と一致させる必要が
+-- あるためこちらを使う。
+lgammaApproxDeriv :: Double -> Double
+lgammaApproxDeriv z
+  | z < 12    = lgammaApproxDeriv (z + 1) - 1 / z
+  | otherwise = log z - 1 / (2 * z) - 1 / (12 * z * z)
+              + 1 / (120 * z ^ (4 :: Int))
+
+logFactorial :: Int -> Double
+logFactorial n
+  | n <= 1    = 0
+  | otherwise = sum (map log [2 .. fromIntegral n])
+
+logBinomCoeff :: Int -> Int -> Double
+logBinomCoeff n k = logFactorial n - logFactorial k - logFactorial (n - k)
+
+-- | log I_0(x) — 修正 Bessel 関数 (第一種・order 0) の対数。VonMises 用。
+-- 小 x: 級数 I_0(x) = Σ (x/2)^(2k) / (k!)² (k = 0..)
+-- 大 x: 漸近展開 I_0(x) ≈ exp(x) / √(2πx) × [1 + 1/(8x) + 9/(128x²) + …]
+-- AD/Track 互換のため (Floating a, Ord a) 多相。
+{-# INLINABLE logBesselI0 #-}
+logBesselI0 :: (Floating a, Ord a) => a -> a
+logBesselI0 x
+  | x < 0     = logBesselI0 (-x)  -- 偶関数
+  | x < 3.75  =
+      -- Abramowitz & Stegun 9.8.1: 多項式近似 (誤差 < 1.6e-7)
+      let t = (x / 3.75) ^ (2::Int)
+          i0 = 1 + t * (3.5156229 + t * (3.0899424 + t * (1.2067492
+             + t * (0.2659732 + t * (0.0360768 + t * 0.0045813)))))
+      in log i0
+  | otherwise =
+      -- Abramowitz & Stegun 9.8.2: 漸近 (誤差 < 1.9e-7)
+      let t = 3.75 / x
+          poly = 0.39894228 + t * (0.01328592 + t * (0.00225319
+               + t * (-0.00157565 + t * (0.00916281 + t * (-0.02057706
+               + t * (0.02635537 + t * (-0.01647633 + t * 0.00392377)))))))
+      in x - 0.5 * log x + log poly
diff --git a/src/Hanalyze/Model/HBM/VecAD.hs b/src/Hanalyze/Model/HBM/VecAD.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Model/HBM/VecAD.hs
@@ -0,0 +1,337 @@
+{-# LANGUAGE BangPatterns #-}
+
+-- |
+-- Module      : Hanalyze.Model.HBM.VecAD
+-- Description : 自作の最小 reverse-mode AD (vector-op tape)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- 自作・最小 reverse-mode AD (vector-op tape)。 Phase 54.3 第2 spike で
+-- 「採用 = 案B (自前 vector-op tape)」 と判断したエンジンを本実装用に移植した
+-- もの (`bench/haskell/BenchHBMVecADSpike.hs` の gradHandroll 系)。
+--
+-- 設計: forward で「ベクトル演算ごとにノードを発番」 し、 各ノードの随伴更新
+-- クロージャを逆順リストに積む (= 自前 Wengert tape)。 backward で出力に 1 を
+-- seed し、 逆位相順 (= 発番の逆順 = prepend したリストの先頭) にクロージャを
+-- replay して入力 (leaf) の随伴を得る。 tape は「ベクトル演算 1 個 = 1 ノード」
+-- ゆえ `ad` のスカラ tape (per-scalar-op で O(n) ノード) より桁で小さい。
+--
+-- スカラは長さ 1 の Storable Vector として随伴を持ち、 ノード随伴は単一の
+-- mutable 配列に統一格納する。
+--
+-- ⚠ 値依存制御フロー (分布の台チェック等) は tape に乗らない。 本エンジンは
+-- 構造が値に依らず静的な部分 (Gaussian-恒等リンクの線形予測子 + 二乗和) 専用。
+-- 非対応の構造は呼出側で scalar (`ad`) 経路に fallback する。
+module Hanalyze.Model.HBM.VecAD
+  ( -- * 値ハンドルと文脈
+    Rval (..)
+  , Ctx
+  , ridOf
+    -- * tape の実行
+  , runTape
+    -- * leaf
+  , inputVec
+  , inputScal
+  , constVec
+    -- * ベクトル演算 (随伴付き)
+  , idxHR
+  , sliceHR
+  , scaleHR
+  , vaddHR
+  , vsubHR
+  , dotHR
+  , gatherHR
+  , vexpHR
+  , bcastAddHR
+  , hadamardHR
+  , vmap1HR
+    -- * スカラ演算 (随伴付き)
+  , map1S
+  , cstS
+  , addS
+  , subS
+  , mulS
+  , divByS
+  , expS
+  , logS
+  , mulConstS
+  , addConstS
+  , foldVadd
+  ) where
+
+import           Control.Monad (when)
+import           Control.Monad.ST
+import           Data.Array.ST (STArray, newArray, readArray, writeArray)
+import           Data.STRef
+import qualified Data.Vector.Storable as VS
+import qualified Data.Vector.Unboxed  as VU
+
+-- ===========================================================================
+-- 値ハンドルと tape 文脈
+-- ===========================================================================
+
+-- | reverse-mode の値ハンドル: ノード id + primal (scalar / vector)。
+data Rval = RScal !Int !Double | RVec !Int !(VS.Vector Double)
+
+ridOf :: Rval -> Int
+ridOf (RScal i _) = i
+ridOf (RVec  i _) = i
+
+type Adj s = STArray s Int (VS.Vector Double)
+
+-- | 発番カウンタ + backward クロージャ列 (prepend = 発番の逆順)。
+data Ctx s = Ctx !(STRef s Int) !(STRef s [Adj s -> ST s ()])
+
+fresh :: Ctx s -> ST s Int
+fresh (Ctx cnt _) = do
+  n <- readSTRef cnt
+  writeSTRef cnt (n + 1)
+  pure n
+
+record :: Ctx s -> (Adj s -> ST s ()) -> ST s ()
+record (Ctx _ bw) f = modifySTRef' bw (f :)
+
+-- | 随伴の加算 (空 = ゼロ扱い)。
+bumpA :: Adj s -> Int -> VS.Vector Double -> ST s ()
+bumpA adj i contrib = do
+  cur <- readArray adj i
+  writeArray adj i (if VS.null cur then contrib else VS.zipWith (+) cur contrib)
+
+readAdjS :: Adj s -> Int -> ST s Double
+readAdjS adj i = do
+  v <- readArray adj i
+  pure (if VS.null v then 0 else v VS.! 0)
+
+-- ===========================================================================
+-- tape の実行 (forward build → seed → backward replay)
+-- ===========================================================================
+
+-- | tape を構築するアクション (出力ノード + 勾配を読みたい leaf 群を返す) を
+-- 受け取り、 forward 評価 → 出力に 1 を seed → backward replay の上で、
+-- 各 leaf の随伴 (= 出力の各 leaf に対する勾配ベクトル) を返す。
+--
+-- @build@ は @(出力 Rval, [leaf Rval])@ を返す。 結果は leaf ごとの随伴
+-- ベクトル (RScal leaf は長さ1、 RVec leaf は元の長さ)。
+runTape :: (forall s. Ctx s -> ST s (Rval, [Rval])) -> [VS.Vector Double]
+runTape build = runST $ do
+  cnt <- newSTRef 0
+  bw  <- newSTRef []
+  let ctx = Ctx cnt bw
+  (out, leaves) <- build ctx
+  total <- readSTRef cnt
+  adj <- newArray (0, max 0 (total - 1)) VS.empty
+  writeArray adj (ridOf out) (VS.singleton 1)
+  closures <- readSTRef bw
+  mapM_ ($ adj) closures
+  mapM (\lf -> readArray adj (ridOf lf)) leaves
+
+-- ===========================================================================
+-- leaf
+-- ===========================================================================
+
+-- | ベクトル leaf (勾配を読む入力)。
+inputVec :: Ctx s -> VS.Vector Double -> ST s Rval
+inputVec ctx v = do
+  i <- fresh ctx
+  pure (RVec i v)
+
+-- | スカラ leaf (勾配を読む入力)。
+inputScal :: Ctx s -> Double -> ST s Rval
+inputScal ctx x = do
+  i <- fresh ctx
+  pure (RScal i x)
+
+-- | 定数ベクトルノード (backward 無し)。
+constVec :: Ctx s -> VS.Vector Double -> ST s Rval
+constVec ctx v = do { i <- fresh ctx; pure (RVec i v) }
+
+-- ===========================================================================
+-- ベクトル演算 (随伴付き)
+-- ===========================================================================
+
+-- | 全長 @l@ の vec から要素 @i@ を取り出す (scalar 化)。 随伴 = e_i·dy。
+idxHR :: Ctx s -> Int -> Int -> Rval -> ST s Rval
+idxHR ctx l i (RVec vid v) = do
+  o <- fresh ctx
+  record ctx $ \adj -> do
+    g <- readAdjS adj o
+    when (g /= 0) $ bumpA adj vid (VS.generate l (\j -> if j == i then g else 0))
+  pure (RScal o (v VS.! i))
+idxHR _ _ _ _ = error "idxHR: scalar input"
+
+-- | 全長 @l@ の vec から @[off, off+len)@ を切り出す。 随伴は zeros l に散布。
+sliceHR :: Ctx s -> Int -> Int -> Int -> Rval -> ST s Rval
+sliceHR ctx l off len (RVec vid v) = do
+  o <- fresh ctx
+  record ctx $ \adj -> do
+    dy <- readArray adj o
+    when (not (VS.null dy)) $
+      bumpA adj vid (VS.generate l (\j -> if j >= off && j < off + len then dy VS.! (j - off) else 0))
+  pure (RVec o (VS.slice off len v))
+sliceHR _ _ _ _ _ = error "sliceHR: scalar input"
+
+-- | scalar * vector。 ∂scalar = dy·v、 ∂v = scalar·dy。
+scaleHR :: Ctx s -> Rval -> Rval -> ST s Rval
+scaleHR ctx (RScal kid k) (RVec vid v) = do
+  o <- fresh ctx
+  record ctx $ \adj -> do
+    dy <- readArray adj o
+    when (not (VS.null dy)) $ do
+      bumpA adj kid (VS.singleton (VS.sum (VS.zipWith (*) dy v)))
+      bumpA adj vid (VS.map (* k) dy)
+  pure (RVec o (VS.map (* k) v))
+scaleHR _ _ _ = error "scaleHR: shape"
+
+-- | vector + vector。
+vaddHR :: Ctx s -> Rval -> Rval -> ST s Rval
+vaddHR ctx (RVec aid a) (RVec bid b) = do
+  o <- fresh ctx
+  record ctx $ \adj -> do
+    dy <- readArray adj o
+    when (not (VS.null dy)) $ do
+      bumpA adj aid dy
+      bumpA adj bid dy
+  pure (RVec o (VS.zipWith (+) a b))
+vaddHR _ _ _ = error "vaddHR: shape"
+
+-- | vector - vector。
+vsubHR :: Ctx s -> Rval -> Rval -> ST s Rval
+vsubHR ctx (RVec aid a) (RVec bid b) = do
+  o <- fresh ctx
+  record ctx $ \adj -> do
+    dy <- readArray adj o
+    when (not (VS.null dy)) $ do
+      bumpA adj aid dy
+      bumpA adj bid (VS.map negate dy)
+  pure (RVec o (VS.zipWith (-) a b))
+vsubHR _ _ _ = error "vsubHR: shape"
+
+-- | 内積。 ∂a = dy·b、 ∂b = dy·a。
+dotHR :: Ctx s -> Rval -> Rval -> ST s Rval
+dotHR ctx (RVec aid a) (RVec bid b) = do
+  o <- fresh ctx
+  record ctx $ \adj -> do
+    g <- readAdjS adj o
+    when (g /= 0) $ do
+      bumpA adj aid (VS.map (* g) b)
+      bumpA adj bid (VS.map (* g) a)
+  pure (RScal o (VS.sum (VS.zipWith (*) a b)))
+dotHR _ _ _ = error "dotHR: shape"
+
+-- | @u[gids]@ gather (gids/nG は定数)。 随伴は scatter-add で O(n)。
+gatherHR :: Ctx s -> VU.Vector Int -> Int -> Rval -> ST s Rval
+gatherHR ctx gids nG (RVec uid u) = do
+  let n = VU.length gids
+  o <- fresh ctx
+  record ctx $ \adj -> do
+    dy <- readArray adj o
+    when (not (VS.null dy)) $
+      bumpA adj uid (VS.convert $
+        VU.accumulate (+) (VU.replicate nG 0) (VU.zip gids (VU.convert dy :: VU.Vector Double)))
+  pure (RVec o (VS.generate n (\i -> u VS.! (gids VU.! i))))
+gatherHR _ _ _ _ = error "gatherHR: shape"
+
+-- | elementwise exp (Phase 54.11 spike: 非線形 μ 用)。 ∂v = dy ⊙ exp(v)。
+vexpHR :: Ctx s -> Rval -> ST s Rval
+vexpHR ctx (RVec vid v) = do
+  let ev = VS.map exp v
+  o <- fresh ctx
+  record ctx $ \adj -> do
+    dy <- readArray adj o
+    when (not (VS.null dy)) $
+      bumpA adj vid (VS.zipWith (*) dy ev)
+  pure (RVec o ev)
+vexpHR _ _ = error "vexpHR: scalar input"
+
+-- | scalar + vector の broadcast 加算 (Phase 54.11 spike)。 ∂scalar = Σ dy。
+bcastAddHR :: Ctx s -> Rval -> Rval -> ST s Rval
+bcastAddHR ctx (RScal kid k) (RVec vid v) = do
+  o <- fresh ctx
+  record ctx $ \adj -> do
+    dy <- readArray adj o
+    when (not (VS.null dy)) $ do
+      bumpA adj kid (VS.singleton (VS.sum dy))
+      bumpA adj vid dy
+  pure (RVec o (VS.map (+ k) v))
+bcastAddHR _ _ _ = error "bcastAddHR: shape"
+
+-- | elementwise 積 v ⊙ w (Phase 54.11 spike: gather(a)[i]·exp(-b·x_i) 用)。
+-- ∂v = dy ⊙ w、 ∂w = dy ⊙ v。
+hadamardHR :: Ctx s -> Rval -> Rval -> ST s Rval
+hadamardHR ctx (RVec aid a) (RVec bid b) = do
+  o <- fresh ctx
+  record ctx $ \adj -> do
+    dy <- readArray adj o
+    when (not (VS.null dy)) $ do
+      bumpA adj aid (VS.zipWith (*) dy b)
+      bumpA adj bid (VS.zipWith (*) dy a)
+  pure (RVec o (VS.zipWith (*) a b))
+hadamardHR _ _ _ = error "hadamardHR: shape"
+
+-- | 汎用 elementwise 単項 (Phase 54.11: ベクトル式 IR の log/recip/sqrt/tanh 等)。
+-- @f@ とその導関数 @f'@ を受け、 ∂v = dy ⊙ f'(v) (v は入力 primal)。
+vmap1HR :: Ctx s -> (Double -> Double) -> (Double -> Double) -> Rval -> ST s Rval
+vmap1HR ctx f df (RVec vid v) = do
+  o <- fresh ctx
+  record ctx $ \adj -> do
+    dy <- readArray adj o
+    when (not (VS.null dy)) $
+      bumpA adj vid (VS.zipWith (\g x -> g * df x) dy v)
+  pure (RVec o (VS.map f v))
+vmap1HR _ _ _ _ = error "vmap1HR: scalar input"
+
+-- | 非空ベクトルノード列を vadd で畳む。
+foldVadd :: Ctx s -> [Rval] -> ST s Rval
+foldVadd _   []       = error "foldVadd: empty"
+foldVadd _   [x]      = pure x
+foldVadd ctx (x:y:xs) = vaddHR ctx x y >>= \z -> foldVadd ctx (z : xs)
+
+-- ===========================================================================
+-- スカラ演算 (随伴付き)
+-- ===========================================================================
+
+cstS :: Ctx s -> Double -> ST s Rval
+cstS ctx x = do { i <- fresh ctx; pure (RScal i x) }
+
+binS :: Ctx s -> (Double -> Double -> Double) -> (Double -> Double -> (Double, Double))
+     -> Rval -> Rval -> ST s Rval
+binS ctx f df (RScal aid a) (RScal bid b) = do
+  o <- fresh ctx
+  record ctx $ \adj -> do
+    g <- readAdjS adj o
+    when (g /= 0) $ do
+      let (da, db) = df a b
+      bumpA adj aid (VS.singleton (g * da))
+      bumpA adj bid (VS.singleton (g * db))
+  pure (RScal o (f a b))
+binS _ _ _ _ _ = error "binS: scalar expected"
+
+addS, subS, mulS :: Ctx s -> Rval -> Rval -> ST s Rval
+addS ctx = binS ctx (+) (\_ _ -> (1, 1))
+subS ctx = binS ctx (-) (\_ _ -> (1, -1))
+mulS ctx = binS ctx (*) (\a b -> (b, a))
+
+-- | scalar 除算 (a/b)。
+divByS :: Ctx s -> Rval -> Rval -> ST s Rval
+divByS ctx = binS ctx (/) (\a b -> (1 / b, negate a / (b * b)))
+
+unS :: Ctx s -> (Double -> Double) -> (Double -> Double) -> Rval -> ST s Rval
+unS ctx f df (RScal aid a) = do
+  o <- fresh ctx
+  record ctx $ \adj -> do
+    g <- readAdjS adj o
+    when (g /= 0) $ bumpA adj aid (VS.singleton (g * df a))
+  pure (RScal o (f a))
+unS _ _ _ _ = error "unS: scalar expected"
+
+expS, logS :: Ctx s -> Rval -> ST s Rval
+expS ctx = unS ctx exp exp
+logS ctx = unS ctx log (\a -> 1 / a)
+
+-- | 汎用スカラ単項 (Phase 54.11)。 @f@ と導関数 @f'@ を受ける ('unS' の公開形)。
+map1S :: Ctx s -> (Double -> Double) -> (Double -> Double) -> Rval -> ST s Rval
+map1S = unS
+
+mulConstS, addConstS :: Ctx s -> Double -> Rval -> ST s Rval
+mulConstS ctx c = unS ctx (* c) (const c)
+addConstS ctx c = unS ctx (+ c) (const 1)
diff --git a/src/Hanalyze/Model/HierarchicalCluster.hs b/src/Hanalyze/Model/HierarchicalCluster.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Model/HierarchicalCluster.hs
@@ -0,0 +1,215 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE BangPatterns #-}
+-- |
+-- Module      : Hanalyze.Model.HierarchicalCluster
+-- Description : 凝集型階層クラスタリング (Agglomerative Hierarchical Clustering)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- 凝集型階層クラスタリング (Agglomerative Hierarchical Clustering)。
+--
+-- Lance-Williams update formula による O(n²) アルゴリズム。
+-- 各ステップで最近接クラスタ対をマージし、 新クラスタへの距離を再計算する。
+--
+-- 対応 linkage:
+--
+--   * 'Single'   : d(i∪j, k) = min(d(i,k), d(j,k))
+--   * 'Complete' : d(i∪j, k) = max(d(i,k), d(j,k))
+--   * 'Average'  : (|i|·d(i,k) + |j|·d(j,k)) / (|i|+|j|)
+--   * 'Ward'     : Lance-Williams 係数で分散最小化
+--
+-- 距離は Euclidean のみサポート (X の各行をサンプルとして二乗ユークリッド距離)。
+module Hanalyze.Model.HierarchicalCluster
+  ( Linkage (..)
+  , HClusterFit (..)
+  , fitHierarchical
+  , cutTree
+  ) where
+
+import qualified Data.Vector                  as V
+import qualified Data.Vector.Mutable          as MV
+import qualified Data.Vector.Unboxed.Mutable  as MU
+import qualified Numeric.LinearAlgebra        as LA
+import           Control.Monad                (forM_, when)
+import           Control.Monad.ST             (runST)
+import           Data.STRef                   (newSTRef, readSTRef, writeSTRef,
+                                               modifySTRef')
+import           Data.List                    (foldl')
+
+-- ===========================================================================
+-- 型
+-- ===========================================================================
+
+data Linkage = Single | Complete | Average | Ward
+             deriving (Show, Eq)
+
+data HClusterFit = HClusterFit
+  { hcMerges       :: ![(Int, Int)]  -- ^ マージ列 (n-1 個)。 ID は 0..n-1 が元サンプル、
+                                     --   以降 n, n+1, ... が新クラスタ
+  , hcHeights      :: ![Double]      -- ^ マージ時点での距離 (linkage に応じた値)
+  , hcLinkage      :: !Linkage
+  , hcNumOriginals :: !Int           -- ^ n_samples
+  } deriving (Show)
+
+-- ===========================================================================
+-- fit
+-- ===========================================================================
+
+-- | 階層クラスタリングを fit する。 X は n × p 行列、 各行が 1 サンプル。
+fitHierarchical :: Linkage -> LA.Matrix Double -> HClusterFit
+fitHierarchical link xs =
+  let n = LA.rows xs
+      d0 = initialDistance link xs
+  in agglomerate link n d0
+
+-- | 樹形図を K クラスタに切り、 各サンプルのクラスタ ID を返す。
+--   K = 1 → 全サンプル ID 0; K = n → 全サンプル別 ID。
+cutTree :: HClusterFit -> Int -> V.Vector Int
+cutTree fit k
+  | k <= 0 = V.replicate (hcNumOriginals fit) 0
+  | k >= n = V.generate n id
+  | otherwise =
+      let nMerges = n - k     -- K クラスタにするには n-K 回マージを適用
+          mergesUsed = take nMerges (hcMerges fit)
+          -- union-find 風: parent[i] = root cluster representative
+          parents = runST $ do
+            arr <- MV.replicate (2 * n) (-1 :: Int)
+            forM_ [0 .. n - 1] $ \i -> MV.write arr i i
+            forM_ (zip [n ..] mergesUsed) $ \(newId, (a, b)) -> do
+              ra <- findRoot arr a
+              rb <- findRoot arr b
+              MV.write arr ra newId
+              MV.write arr rb newId
+              MV.write arr newId newId
+            V.generateM n (findRoot arr)
+          uniqRoots = foldr (\r acc -> if r `elem` acc then acc else r:acc) [] (V.toList parents)
+          roots = zip uniqRoots [0 ..]
+          lookupId r = case lookup r roots of
+            Just i  -> i
+            Nothing -> 0
+      in V.map lookupId parents
+  where
+    n = hcNumOriginals fit
+    findRoot arr i = do
+      p <- MV.read arr i
+      if p == i then pure i else findRoot arr p
+
+-- ===========================================================================
+-- 内部: 距離行列の構築
+-- ===========================================================================
+
+-- | 初期距離行列 (n × n)。 二乗ユークリッド距離。
+--   Ward は二乗距離を使うのが定義どおり。 他 linkage は √ を取って通常距離にする。
+initialDistance :: Linkage -> LA.Matrix Double -> LA.Matrix Double
+initialDistance link xs =
+  let n = LA.rows xs
+      sqDist i j =
+        let r = LA.flatten (xs LA.? [i]) - LA.flatten (xs LA.? [j])
+        in LA.sumElements (r * r)
+      raw = LA.build (n, n)
+              (\i j -> sqDist (round i) (round j) :: Double)
+  in case link of
+       Ward -> raw           -- squared
+       _    -> LA.cmap sqrt raw
+
+-- ===========================================================================
+-- 内部: 凝集アルゴリズム
+-- ===========================================================================
+
+agglomerate :: Linkage -> Int -> LA.Matrix Double -> HClusterFit
+agglomerate link n d0 = runST $ do
+  -- Phase 17.2 改善:
+  --   * 距離行列を MU (Unboxed Mutable Vector Double) で flat 配列に
+  --   * active set を Unboxed Mutable Vector Int でコンパクトに保持
+  --     (毎ステップ tail 切詰めの代わりに、 in-place で a,b 位置を最後と入替え)
+  --   * unsafeRead / unsafeWrite で境界チェック排除
+  --   * inner loop の STRef 更新を local accumulator (Int * 2 + Double) で減らす
+  let !totalIds = 2 * n - 1
+  dist  <- MU.unsafeNew (totalIds * totalIds)
+  -- 初期化: ∞
+  forM_ [0 .. totalIds * totalIds - 1] $ \k -> MU.unsafeWrite dist k (1/0 :: Double)
+  sizes <- MU.replicate totalIds (1 :: Int)
+  forM_ [0 .. n - 1] $ \i ->
+    forM_ [0 .. n - 1] $ \j ->
+      when (i /= j) $
+        MU.unsafeWrite dist (i * totalIds + j) (LA.atIndex d0 (i, j))
+  -- active: 先頭 `activeLen` 要素が active な ID
+  active <- MU.unsafeNew totalIds
+  forM_ [0 .. n - 1] $ \i -> MU.unsafeWrite active i i
+  activeLenRef <- newSTRef n
+  mergesRef    <- newSTRef ([] :: [(Int, Int)])
+  heightsRef   <- newSTRef ([] :: [Double])
+  forM_ [0 .. n - 2] $ \step -> do
+    let !nextId = n + step
+    !alen <- readSTRef activeLenRef
+    -- find argmin。 active[0 .. alen-1] のペアを直接走査
+    bestRef <- newSTRef ((-1) :: Int, (-1) :: Int, 1/0 :: Double, (-1) :: Int, (-1) :: Int)
+    -- (a, b, bestDist, posA, posB)  posA/posB は active 内の位置
+    forM_ [0 .. alen - 2] $ \pi_ -> do
+      !i <- MU.unsafeRead active pi_
+      forM_ [pi_ + 1 .. alen - 1] $ \pj -> do
+        !j <- MU.unsafeRead active pj
+        !d <- MU.unsafeRead dist (i * totalIds + j)
+        (_, _, !best, _, _) <- readSTRef bestRef
+        when (d < best) $ writeSTRef bestRef (i, j, d, pi_, pj)
+    (!a, !b, !h, !pa, !pb) <- readSTRef bestRef
+    modifySTRef' mergesRef  ((a, b) :)
+    modifySTRef' heightsRef ((reportHeight link h) :)
+    !na <- MU.unsafeRead sizes a
+    !nb <- MU.unsafeRead sizes b
+    MU.unsafeWrite sizes nextId (na + nb)
+    -- active から a, b を削除し nextId を追加: pb を末尾と swap で除去、
+    -- 同様に pa を新末尾と swap、 alen 減 2、 末尾に nextId を入れて alen 増 1
+    -- ※ pa < pb 不変 (内側 loop が pj > pi)
+    !lastPos <- pure (alen - 1)
+    !valLast <- MU.unsafeRead active lastPos
+    MU.unsafeWrite active pb valLast
+    !secondLast <- pure (alen - 2)
+    !valSecond <- MU.unsafeRead active secondLast
+    -- pa の位置は pb と入替えで動いていない (pa < pb なので)
+    MU.unsafeWrite active pa valSecond
+    MU.unsafeWrite active secondLast nextId
+    writeSTRef activeLenRef (alen - 1)  -- 2 削除 + 1 追加 = -1
+    !alenNew <- readSTRef activeLenRef
+    -- Lance-Williams update: active[0 .. alenNew - 1] (末尾は nextId)
+    let !nextRow = nextId * totalIds
+    forM_ [0 .. alenNew - 2] $ \pk -> do
+      !k <- MU.unsafeRead active pk
+      !dak <- MU.unsafeRead dist (a * totalIds + k)
+      !dbk <- MU.unsafeRead dist (b * totalIds + k)
+      !nk  <- MU.unsafeRead sizes k
+      let !dNew = lanceWilliams link (na, nb, nk) dak dbk h
+      MU.unsafeWrite dist (nextRow + k) dNew
+      MU.unsafeWrite dist (k * totalIds + nextId) dNew
+  merges  <- reverse <$> readSTRef mergesRef
+  heights <- reverse <$> readSTRef heightsRef
+  pure HClusterFit
+    { hcMerges       = merges
+    , hcHeights      = heights
+    , hcLinkage      = link
+    , hcNumOriginals = n
+    }
+  where
+    reportHeight Ward h = sqrt (max 0 h)
+    reportHeight _    h = h
+
+-- | Lance-Williams recurrence:
+--   d(i∪j, k) = α_i d(i,k) + α_j d(j,k) + β d(i,j) + γ |d(i,k) − d(j,k)|
+lanceWilliams :: Linkage
+              -> (Int, Int, Int)   -- sizes (n_a, n_b, n_k)
+              -> Double            -- d(a, k)
+              -> Double            -- d(b, k)
+              -> Double            -- d(a, b)
+              -> Double
+lanceWilliams link (na, nb, nk) dak dbk dab =
+  case link of
+    Single   -> min dak dbk
+    Complete -> max dak dbk
+    Average  ->
+      let naD = fromIntegral na; nbD = fromIntegral nb
+      in (naD * dak + nbD * dbk) / (naD + nbD)
+    Ward ->
+      let naD = fromIntegral na; nbD = fromIntegral nb
+          nkD = fromIntegral nk
+          tot = naD + nbD + nkD
+      in ((naD + nkD) * dak + (nbD + nkD) * dbk - nkD * dab) / tot
diff --git a/src/Hanalyze/Model/KNN.hs b/src/Hanalyze/Model/KNN.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Model/KNN.hs
@@ -0,0 +1,134 @@
+{-# LANGUAGE BangPatterns #-}
+-- |
+-- Module      : Hanalyze.Model.KNN
+-- Description : k近傍法 (k-Nearest Neighbours、 回帰 + 分類、 brute force ユークリッド距離)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- k-Nearest Neighbours (回帰 + 分類、 brute force ユークリッド距離).
+--
+-- @
+-- import qualified Hanalyze.Model.KNN as KNN
+-- let knnR = KNN.fitKNNR 5 xTrain yTrain
+--     yR   = KNN.predictKNNR knnR xTest
+-- @
+--
+-- /Complexity/: O(n_test · n_train · d)。 KD-tree は scope 外。
+module Hanalyze.Model.KNN
+  ( KNNRegressor (..)
+  , KNNClassifier (..)
+  , fitKNNR
+  , fitKNNC
+  , predictKNNR
+  , predictKNNC
+  , predictKNNCProbs
+  ) where
+
+import qualified Data.Vector.Unboxed   as VU
+import qualified Numeric.LinearAlgebra as LA
+import qualified Data.Map.Strict       as Map
+import           Data.List             (foldl', sortBy, nub, sort)
+import           Data.Ord              (comparing)
+import           Data.Text             (Text)
+
+-- ---------------------------------------------------------------------------
+-- Types
+-- ---------------------------------------------------------------------------
+
+data KNNRegressor = KNNRegressor
+  { knnRK :: !Int
+  , knnRX :: !(LA.Matrix Double)
+  , knnRY :: !(VU.Vector Double)
+  } deriving (Show)
+
+data KNNClassifier = KNNClassifier
+  { knnCK          :: !Int
+  , knnCX          :: !(LA.Matrix Double)
+  , knnCY          :: !(VU.Vector Int)
+  , knnCClasses    :: ![Int]
+  , knnCClassNames :: ![Text]   -- ^ クラス名 (df|-> が levels 注入・空=数値表示)。
+  } deriving (Show)
+
+-- ---------------------------------------------------------------------------
+-- Fit
+-- ---------------------------------------------------------------------------
+
+fitKNNR :: Int -> LA.Matrix Double -> VU.Vector Double -> KNNRegressor
+fitKNNR k x y = KNNRegressor k x y
+
+fitKNNC :: Int -> LA.Matrix Double -> VU.Vector Int -> KNNClassifier
+fitKNNC k x y = KNNClassifier
+  { knnCK          = k
+  , knnCX          = x
+  , knnCY          = y
+  , knnCClasses    = sort (nub (VU.toList y))
+  , knnCClassNames = []          -- df|-> 経路が reqLabelWithLevels で後から注入。
+  }
+
+-- ---------------------------------------------------------------------------
+-- Predict helpers
+-- ---------------------------------------------------------------------------
+
+rowVec :: LA.Matrix Double -> Int -> LA.Vector Double
+rowVec x i = LA.flatten (x LA.? [i])
+
+-- | クエリ点に対し、 訓練データ各行までの距離 (二乗) と元 index のペア
+-- を返す。
+distancesSq :: LA.Matrix Double -> LA.Vector Double -> [(Int, Double)]
+distancesSq xTrain q =
+  let !n = LA.rows xTrain
+  in [ (i, let v = rowVec xTrain i - q in LA.dot v v)
+     | i <- [0 .. n - 1] ]
+
+kNearest :: Int -> LA.Matrix Double -> LA.Vector Double -> [Int]
+kNearest k xTrain q =
+  let ds = sortBy (comparing snd) (distancesSq xTrain q)
+  in map fst (take k ds)
+
+-- ---------------------------------------------------------------------------
+-- Predict (regression)
+-- ---------------------------------------------------------------------------
+
+predictKNNR :: KNNRegressor -> LA.Matrix Double -> VU.Vector Double
+predictKNNR knn xTest =
+  let !nT = LA.rows xTest
+      !k  = knnRK knn
+      !xT = knnRX knn
+      !yT = knnRY knn
+      pred1 i =
+        let q   = rowVec xTest i
+            ids = kNearest k xT q
+            ys  = [ yT VU.! j | j <- ids ]
+        in sum ys / fromIntegral (length ys)
+  in VU.generate nT pred1
+
+-- ---------------------------------------------------------------------------
+-- Predict (classification)
+-- ---------------------------------------------------------------------------
+
+predictKNNCProbs :: KNNClassifier
+                 -> LA.Matrix Double
+                 -> [Map.Map Int Double]
+predictKNNCProbs knn xTest =
+  let !nT = LA.rows xTest
+      !k  = knnCK knn
+      !xT = knnCX knn
+      !yT = knnCY knn
+      counts1 i =
+        let q   = rowVec xTest i
+            ids = kNearest k xT q
+            cs  = [ yT VU.! j | j <- ids ]
+            !nk = fromIntegral (length cs) :: Double
+            mp  = foldl' (\m c -> Map.insertWith (+) c 1 m)
+                          Map.empty cs
+        in Map.map (/ nk) mp
+  in [ counts1 i | i <- [0 .. nT - 1] ]
+
+predictKNNC :: KNNClassifier -> LA.Matrix Double -> VU.Vector Int
+predictKNNC knn xTest =
+  let probs = predictKNNCProbs knn xTest
+      majority m =
+        case sortBy (flip (comparing snd)) (Map.toList m) of
+          ((c, _) : _) -> c
+          []           -> 0
+  in VU.fromList (map majority probs)
diff --git a/src/Hanalyze/Model/Kernel.hs b/src/Hanalyze/Model/Kernel.hs
--- a/src/Hanalyze/Model/Kernel.hs
+++ b/src/Hanalyze/Model/Kernel.hs
@@ -1,475 +1,279 @@
 {-# LANGUAGE StrictData #-}
+{-# LANGUAGE BangPatterns #-}
 {-# LANGUAGE OverloadedStrings #-}
--- | Kernel regression — Nadaraya-Watson and kernel ridge regression.
+-- |
+-- Module      : Hanalyze.Model.Kernel
+-- Description : GP/SVM/カーネル法で共通のカーネル語彙 (RBF/Matern52/Periodic/Linear/Poly)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
 --
---   * 'Kernel'        — RBF / Matérn / triangular / Epanechnikov kernel
---     functions.
---   * 'nwRegression'  — Nadaraya-Watson (kernel-weighted moving average).
---   * 'kernelRidge'   — kernel ridge regression
---     @ŷ(x*) = k(x*)ᵀ (K + λI)⁻¹ y@.
+-- 共有カーネル語彙 (GP / SVM / カーネル法で共通) — Phase 75.18 で 'Model.GP'
+-- から分離。
 --
--- Both are non-parametric smooth nonlinear regressors. Unlike 'Hanalyze.Model.GP',
--- they do not produce uncertainty estimates.
+-- GP 族の定常/内積カーネル ('RBF' / 'Matern52' / 'Periodic' / 'Linear' / 'Poly') と
+-- そのハイパーパラメータ 'KernelParams' (ℓ / σ_f² / period / ARD per-dim ℓ) を集約する。
+-- 'GPParams' (= 'KernelParams' + 観測ノイズ σ_n²) に依存しないので、 SVM 等
+-- ノイズを持たないカーネル法はこのモジュールだけを import すればよい
+-- ('Model.GP' を import しない)。
+--
+-- 評価関数:
+--
+--   * 'kernelFn'            — 1D 入力の @k(x, x')@。
+--   * 'buildKernelMatrix'   — 1D の Gram 行列 @K(xs, xs')@。
+--   * 'applyKernel'         — 二乗距離行列 → カーネル行列 (距離カーネル専用)。
+--   * 'kernelOfParams'      — 固定パラメータの @s ↦ k(s)@ (距離カーネル専用・INLINE)。
+--   * 'ardScaleXY'          — ARD 列スケーリング。
+--   * 'buildKernelMatrixMV' — 多入力 Gram 行列 (全カーネル)。
+--   * 'kEvalMV'             — 多入力の点対点評価 @k(a, b)@ (全カーネル・SVM 等の汎用経路)。
+--
+-- 距離カーネル (RBF/Matern52/Periodic) は二乗距離から、 内積カーネル
+-- (Linear/Poly) は内積から評価する。 'applyKernel' / 'kernelOfParams' は距離専用で、
+-- 内積カーネルを渡すと error (multi-input gram は 'buildKernelMatrixMV' が内積経路へ
+-- 分岐するためそこには到達しない)。
 module Hanalyze.Model.Kernel
-  ( Kernel (..)
-  , kernelEval
-  , kernelFromSqDist
-  , nwRegression
-  , nwRegressionMulti
-  , KernelRidgeFit (..)
-  , kernelRidge
-  , predictKernelRidge
-  , gridSearchBandwidth
-  , autoBandwidthBrent
-    -- * Multi-output (1D input, multiple Y columns)
-  , KernelRidgeFitMulti (..)
-  , kernelRidgeMulti
-  , predictKernelRidgeMulti
-  , fittedKernelRidgeMulti
-  , r2Multi
-  , autoTuneKernelRidgeMulti
-  , defaultHGrid
-  , defaultLamGrid
-    -- * Multi-input (primary API; X is @n × p@, Y is @n × q@)
-  , gramMatrixMV
-  , gramMatrixMVXY
-  , KernelRidgeFitMV (..)
-  , kernelRidgeMV
-  , predictKernelRidgeMV
-  , fittedKernelRidgeMV
-  , nwRegressionMV
+  ( -- * カーネル型
+    Kernel (..)
+  , kernelName
+    -- * カーネルハイパーパラメータ
+  , KernelParams (..)
+  , defaultKernelParams
+    -- * 評価
+  , kernelFn
+  , buildKernelMatrix
+  , applyKernel
+  , kernelOfParams
+  , ardScaleXY
+  , buildKernelMatrixMV
+  , kEvalMV
   ) where
 
-import qualified Data.Vector as V
-import qualified Numeric.LinearAlgebra as LA
-import qualified Hanalyze.Optim.LineSearch as LS
-import qualified Hanalyze.Optim.Common     as OC
-import qualified Hanalyze.Stat.KernelDist  as KD
-import qualified Hanalyze.Stat.Cholesky    as Chol
+import           Data.Text (Text)
+import qualified Data.Text                    as T
+import qualified Numeric.LinearAlgebra        as LA
+import qualified Hanalyze.Stat.KernelDist as KD
+import qualified Data.Vector.Storable         as VS
+import qualified Data.Vector.Storable.Mutable as VSM
+import           Control.Monad.ST             (runST)
 
 -- ---------------------------------------------------------------------------
--- カーネル関数
+-- 型
 -- ---------------------------------------------------------------------------
 
--- | Supported kernels. The bandwidth @h@ is passed separately at the
--- call site.
+-- | GP / SVM 族のカーネル種別。
 data Kernel
-  = Gaussian       -- ^ @exp(-u²/2)@ (= RBF, infinite support).
-  | Epanechnikov   -- ^ @0.75 (1-u²)@ on @|u| ≤ 1@.
-  | Triangular     -- ^ @1 - |u|@ on @|u| ≤ 1@.
-  | Uniform        -- ^ @0.5@ on @|u| ≤ 1@ (coarsest).
-  | TriCube        -- ^ @(1-|u|³)³@ on @|u| ≤ 1@.
+  = RBF
+    -- ^ Squared exponential: @k(x,x') = σ_f² exp(−r²/(2ℓ²))@.
+    --   Best for smooth functions; the most commonly used kernel.
+  | Matern52
+    -- ^ Matérn 5/2: @k(x,x') = σ_f²(1+√5 r/ℓ+5r²/(3ℓ²)) exp(−√5 r/ℓ)@.
+    --   Slightly rougher than RBF; common in physical systems.
+  | Periodic
+    -- ^ Periodic: @k(x,x') = σ_f² exp(−2 sin²(π r/p)/ℓ²)@.
+    --   For periodic patterns; set 'kpPeriod' appropriately.
+  | Linear
+    -- ^ Linear (dot-product): @k(x,x') = σ_f² (x·x')@. A non-stationary
+    --   kernel; with SVM gives a linear decision boundary. (Phase 75.14)
+  | Poly !Int
+    -- ^ Polynomial of degree @d@: @k(x,x') = (γ (x·x') + 1)^d@ with
+    --   @γ = 1/(2ℓ²)@ (shared with the SVM γ convention). A
+    --   non-stationary kernel. (Phase 75.14)
   deriving (Show, Eq)
 
--- | Evaluate the kernel at scaled squared distance @s = ‖x − x'‖² / h²@.
--- Generalizes 'kernelEval' to multivariate inputs: every supported
--- kernel is radially symmetric, so the kernel value depends only on
--- @‖x − x'‖ / h@.
---
--- For the Gaussian kernel this avoids the redundant @sqrt@; for kernels
--- with bounded support (Epanechnikov / Triangular / Uniform / TriCube)
--- the boundary check uses @s ≤ 1@.
-kernelFromSqDist :: Kernel -> Double -> Double
-kernelFromSqDist k s = case k of
-  Gaussian     -> exp (-0.5 * s) / sqrt (2 * pi)
-  Epanechnikov -> if s <= 1 then 0.75 * (1 - s) else 0
-  Triangular   -> if s <= 1 then 1 - sqrt s else 0
-  Uniform      -> if s <= 1 then 0.5 else 0
-  TriCube      -> if s <= 1
-                    then let u = sqrt s
-                             t = 1 - u * u * u
-                         in t * t * t
-                    else 0
-
--- | Evaluate the kernel at @u = (x - x_i) / h@.
-kernelEval :: Kernel -> Double -> Double
-kernelEval k u = case k of
-  Gaussian     -> exp (-0.5 * u * u) / sqrt (2 * pi)
-  Epanechnikov -> if abs u <= 1 then 0.75 * (1 - u * u) else 0
-  Triangular   -> if abs u <= 1 then 1 - abs u else 0
-  Uniform      -> if abs u <= 1 then 0.5 else 0
-  TriCube      -> if abs u <= 1
-                    then let t = 1 - (abs u)^(3::Int)
-                         in t * t * t
-                    else 0
-
--- ---------------------------------------------------------------------------
--- Nadaraya-Watson
--- ---------------------------------------------------------------------------
-
--- | Single-output Nadaraya-Watson kernel regression.
---
--- @ŷ(x*) = Σᵢ K_h(x* - xᵢ) yᵢ / Σᵢ K_h(x* - xᵢ)@
---
--- Delegates to 'nwRegressionMulti' by promoting @y@ to a one-column
--- matrix.
-nwRegression :: Kernel
-             -> Double             -- ^ Bandwidth @h@ (@> 0@).
-             -> V.Vector Double    -- ^ Training inputs.
-             -> V.Vector Double    -- ^ Training targets.
-             -> V.Vector Double    -- ^ Prediction inputs.
-             -> V.Vector Double    -- ^ Predictions.
-nwRegression kern h xs ys xNew =
-  let yMat = LA.asColumn (LA.fromList (V.toList ys))
-      mat  = nwRegressionMulti kern h xs yMat xNew
-  in V.fromList (LA.toList (LA.flatten (mat LA.¿ [0])))
-
--- | Multi-output Nadaraya-Watson: reuse the same weight matrix across
--- every output column. With @W@ of shape @m × n@ and @Y@ of shape
--- @n × q@, the result is the row-normalized product @W · Y@ of shape
--- @m × q@.
-nwRegressionMulti :: Kernel
-                  -> Double               -- ^ Bandwidth @h@.
-                  -> V.Vector Double      -- ^ Training inputs (length @n@).
-                  -> LA.Matrix Double     -- ^ Training response @Y@ (@n × q@).
-                  -> V.Vector Double      -- ^ Prediction inputs (length @m@).
-                  -> LA.Matrix Double     -- ^ Predictions (@m × q@).
-nwRegressionMulti kern h xs ys xNew =
-  let n  = V.length xs
-      m  = V.length xNew
-      q  = LA.cols ys
-      wMat = LA.fromLists
-               [ [ kernelEval kern ((xStar - xi) / h)
-                 | xi <- V.toList xs ]
-               | xStar <- V.toList xNew ]   -- (m × n)
-      num  = wMat LA.<> ys                  -- (m × q)
-      dens = LA.toList (wMat LA.#> LA.konst 1 n)
-      rows = [ if d == 0 then replicate q 0
-                 else [ (num `LA.atIndex` (i, j)) / d | j <- [0 .. q - 1] ]
-             | (i, d) <- zip [0 .. m - 1] dens ]
-  in LA.fromLists rows
-
--- ---------------------------------------------------------------------------
--- Kernel Ridge regression
--- ---------------------------------------------------------------------------
+-- | Display name of a kernel.
+kernelName :: Kernel -> Text
+kernelName RBF       = "RBF"
+kernelName Matern52  = "Mat\xe9rn 5/2"
+kernelName Periodic  = "Periodic"
+kernelName Linear    = "Linear"
+kernelName (Poly d)  = "Poly(" <> T.pack (show d) <> ")"
 
--- | Kernel ridge regression fit; carries everything needed to predict.
-data KernelRidgeFit = KernelRidgeFit
-  { krKernel :: Kernel
-  , krH      :: Double
-  , krLambda :: Double
-  , krXs     :: V.Vector Double   -- ^ Training inputs.
-  , krAlpha  :: LA.Vector Double  -- ^ Solution @α = (K + λI)⁻¹ y@.
+-- | カーネルハイパーパラメータ (観測ノイズ σ_n² は含まない)。
+data KernelParams = KernelParams
+  { kpLengthScale  :: Double
+    -- ^ Isotropic length scale @ℓ@; larger means smoother. Used unless
+    --   'kpLengthScales' is 'Just' (= ARD), in which case the per-dim
+    --   vector overrides this for multi-input kernel evaluation.
+  , kpSignalVar    :: Double
+    -- ^ Signal variance @σ_f²@; the variability of the function values.
+  , kpPeriod       :: Double
+    -- ^ Period @p@ (only used by the @Periodic@ kernel).
+  , kpLengthScales :: Maybe (LA.Vector Double)
+    -- ^ Per-dim length scales for ARD (Automatic Relevance
+    --   Determination). When 'Just' v, the multi-input kernel uses
+    --   @D_ARD[i,j] = Σ_d (X[i,d] − X'[j,d])² / ℓ_d²@ instead of the
+    --   isotropic distance / ℓ². Has no effect on the 1D 'kernelFn'
+    --   path. 'Nothing' = isotropic (default).
   } deriving (Show)
 
--- | Build the Gram matrix @K_{ij} = K_h(x_i - x_j)@.
-gramMatrix :: Kernel -> Double -> V.Vector Double -> LA.Matrix Double
-gramMatrix kern h xs =
-  let n = V.length xs
-      xv = V.toList xs
-  in (n LA.>< n)
-       [ kernelEval kern ((xi - xj) / h)
-       | xi <- xv, xj <- xv ]
-
--- | Single-output kernel ridge regression. Delegates to
--- 'kernelRidgeMulti' by promoting @y@ to a one-column matrix and taking
--- column 0 of the resulting @α@ matrix.
-kernelRidge :: Kernel
-            -> Double             -- ^ Bandwidth @h@.
-            -> Double             -- ^ Ridge penalty @λ@.
-            -> V.Vector Double    -- ^ Training inputs.
-            -> V.Vector Double    -- ^ Training targets.
-            -> KernelRidgeFit
-kernelRidge kern h lam xs ys =
-  let yMat = LA.asColumn (LA.fromList (V.toList ys))
-      mf   = kernelRidgeMulti kern h lam xs yMat
-      a    = LA.flatten (krmAlpha mf LA.¿ [0])
-  in KernelRidgeFit kern h lam xs a
-
--- | Predict at new inputs from a 'KernelRidgeFit'.
-predictKernelRidge :: KernelRidgeFit -> V.Vector Double -> V.Vector Double
-predictKernelRidge fit xNew =
-  V.map predict xNew
-  where
-    xs    = krXs fit
-    h     = krH fit
-    kern  = krKernel fit
-    alpha = krAlpha fit
-    predict xStar =
-      let kVec = LA.fromList
-                   [ kernelEval kern ((xStar - xi) / h)
-                   | xi <- V.toList xs ]
-      in kVec LA.<.> alpha
-
--- ---------------------------------------------------------------------------
--- Bandwidth selection
--- ---------------------------------------------------------------------------
-
--- | Pick the bandwidth @h@ by leave-one-out cross-validation. Simple
--- grid search: returns the candidate with the smallest LOO RMSE.
-gridSearchBandwidth
-  :: Kernel
-  -> V.Vector Double      -- ^ Training inputs.
-  -> V.Vector Double      -- ^ Training targets.
-  -> [Double]             -- ^ Candidate bandwidths.
-  -> (Double, Double)     -- ^ @(best h, best LOO RMSE)@.
-gridSearchBandwidth kern xs ys hs =
-  let results = [(h, looErrNW kern xs ys h) | h <- hs]
-      best = head [ pair | pair <- results
-                         , snd pair == minimum (map snd results) ]
-  in best
-
--- | NW LOO-CV loss as a continuous function of @h@; shared with
--- 'autoBandwidthBrent'.
-looErrNW :: Kernel -> V.Vector Double -> V.Vector Double -> Double -> Double
-looErrNW kern xs ys h =
-  let n = V.length xs
-      yPred = V.imap
-        (\i _ ->
-          let xs'  = V.ifilter (\j _ -> j /= i) xs
-              ys'  = V.ifilter (\j _ -> j /= i) ys
-              xi   = xs V.! i
-              pred = nwRegression kern h xs' ys' (V.singleton xi)
-          in V.head pred)
-        xs
-      err = V.zipWith (\y yh -> (y - yh)^(2::Int)) ys yPred
-  in sqrt (V.sum err / fromIntegral n)
-
--- | Continuously optimize the bandwidth @h@ with Brent's method
--- (minimizing the LOO-CV loss). Assumes the bracket @[h_lo, h_hi]@ is
--- unimodal. Avoids enumerating discrete candidates the way
--- 'gridSearchBandwidth' does.
---
--- Returns @(best h, best LOO RMSE)@.
-autoBandwidthBrent
-  :: Kernel
-  -> V.Vector Double    -- ^ Training inputs.
-  -> V.Vector Double    -- ^ Training targets.
-  -> Double             -- ^ Lower bound @h_lo@.
-  -> Double             -- ^ Upper bound @h_hi@.
-  -> (Double, Double)
-autoBandwidthBrent kern xs ys hLo hHi =
-  let cfg = LS.defaultBrentConfig { LS.bcMaxIter = 80, LS.bcTol = 1e-6 }
-      result = LS.brent cfg (\[h] -> looErrNW kern xs ys h) hLo hHi
-      hStar  = head (OC.orBest result)
-  in (hStar, OC.orValue result)
+-- | Default kernel hyperparameters: @ℓ = σ_f² = p = 1@, isotropic.
+defaultKernelParams :: KernelParams
+defaultKernelParams = KernelParams 1.0 1.0 1.0 Nothing
 
 -- ---------------------------------------------------------------------------
--- 多出力 Kernel Ridge (Phase T2)
+-- 1D 評価
 -- ---------------------------------------------------------------------------
 
--- | Multi-output kernel ridge regression. With @Y@ of shape @n × q@,
--- solves each column independently but shares the Gram matrix @K@.
-data KernelRidgeFitMulti = KernelRidgeFitMulti
-  { krmKernel :: Kernel
-  , krmH      :: Double
-  , krmLambda :: Double
-  , krmXs     :: V.Vector Double
-  , krmAlpha  :: LA.Matrix Double   -- α (n × q)
-  } deriving (Show)
-
--- | Solve @(K + λI)⁻¹ Y@ once and reuse for every column (fast).
-kernelRidgeMulti :: Kernel -> Double -> Double
-                 -> V.Vector Double -> LA.Matrix Double
-                 -> KernelRidgeFitMulti
-kernelRidgeMulti kern h lam xs ys =
-  let n     = V.length xs
-      kMat  = gramMatrix kern h xs
-      regK  = kMat + LA.scale lam (LA.ident n)
-      -- regK is SPD (K is PSD, λI is PD). Use Cholesky-based solve;
-      -- jitter retry handles ill-conditioned bandwidths.
-      alpha = Chol.cholSolveJitter regK ys
-  in KernelRidgeFitMulti kern h lam xs alpha
-
--- | Predict @Ŷ@ for new inputs from a 'KernelRidgeFitMulti'.
-predictKernelRidgeMulti :: KernelRidgeFitMulti -> V.Vector Double
-                        -> LA.Matrix Double
-predictKernelRidgeMulti fit xNew =
-  let xs    = krmXs fit
-      h     = krmH fit
-      kern  = krmKernel fit
-      alpha = krmAlpha fit
-      kMat  = LA.fromLists
-                [ [ kernelEval kern ((xStar - xi) / h)
-                  | xi <- V.toList xs ]
-                | xStar <- V.toList xNew ]
-  in kMat LA.<> alpha
-
--- | Fitted values at the training inputs (= @ŷ_train@).
-fittedKernelRidgeMulti :: KernelRidgeFitMulti -> LA.Matrix Double
-fittedKernelRidgeMulti fit = predictKernelRidgeMulti fit (krmXs fit)
-
--- | Multi-output R² returned as a length-@q@ vector. @Y@ observed and
--- @Ŷ@ predicted both have shape @n × q@.
-r2Multi :: LA.Matrix Double -> LA.Matrix Double -> V.Vector Double
-r2Multi ys yhat =
-  let n  = LA.rows ys
-      q  = LA.cols ys
-      colR2 j =
-        let yc  = LA.toList (LA.flatten (ys     LA.¿ [j]))
-            yhc = LA.toList (LA.flatten (yhat   LA.¿ [j]))
-            mu  = sum yc / fromIntegral n
-            sst = sum [(y - mu)^(2::Int) | y <- yc]
-            sse = sum [(y - p)^(2::Int) | (y, p) <- zip yc yhc]
-        in if sst == 0 then 0 else 1 - sse / sst
-  in V.fromList [ colR2 j | j <- [0 .. q - 1] ]
+-- | Evaluate the kernel function @k(x, x')@ for scalar inputs.
+kernelFn :: Kernel -> KernelParams -> Double -> Double -> Double
+kernelFn RBF p x x' =
+  let d = x - x'
+      l = kpLengthScale p
+  in kpSignalVar p * exp (-(d * d) / (2 * l * l))
+kernelFn Matern52 p x x' =
+  let d = abs (x - x')
+      l = kpLengthScale p
+      s = sqrt 5 * d / l
+  in kpSignalVar p * (1 + s + s * s / 3) * exp (-s)
+kernelFn Periodic p x x' =
+  let d = abs (x - x')
+      l = kpLengthScale p
+      s = sin (pi * d / kpPeriod p)
+  in kpSignalVar p * exp (-2 * s * s / (l * l))
+kernelFn Linear p x x' =
+  -- 内積カーネル: 1D では x·x' = x*x'。
+  kpSignalVar p * (x * x')
+kernelFn (Poly d) p x x' =
+  -- (γ x·x' + 1)^d, γ = 1/(2ℓ²)。1D では x·x' = x*x'。
+  let l = kpLengthScale p
+      g = 1 / (2 * l * l)
+  in (g * (x * x') + 1) ^^ d
 
--- | Joint @(h, λ)@ grid search using the closed-form LOOCV. Computes the
--- hat-matrix diagonal once per
--- 全 q 出力の LOO 残差を一括評価。
+-- | Build the kernel matrix @K(xs, xs')@ of shape @|xs| × |xs'|@.
 --
--- 戻り値: (best fit, best h, best λ, best mean LOO MSE)
-autoTuneKernelRidgeMulti
-  :: Kernel
-  -> V.Vector Double      -- xs (n)
-  -> LA.Matrix Double     -- ys (n × q)
-  -> [Double]             -- h candidates
-  -> [Double]             -- λ candidates
-  -> (KernelRidgeFitMulti, Double, Double, Double)
-autoTuneKernelRidgeMulti kern xs ys hs lams =
-  let n   = V.length xs
-      q   = LA.cols ys
-      tot = fromIntegral (n * q) :: Double
-      score h lam =
-        let kMat = gramMatrix kern h xs
-            regK = kMat + LA.scale lam (LA.ident n)
-            ainv = LA.inv regK
-            hat  = kMat LA.<> ainv          -- (n × n)
-            diagH = LA.takeDiag hat
-            yhat = hat LA.<> ys             -- (n × q)
-            res  = ys - yhat                -- (n × q)
-            -- LOO 残差: r_i / (1 - H_ii)、列方向ブロードキャスト
-            denom = LA.cmap (\h_ii -> 1 - h_ii) diagH
-            invDenom = LA.cmap (\d -> if abs d < 1e-10 then 0 else 1/d) denom
-            scaler = LA.fromColumns (replicate q invDenom)
-            looR  = res * scaler
-            sse   = LA.sumElements (looR * looR)
-        in sse / tot
-      grid = [ (h, lam, score h lam) | h <- hs, lam <- lams ]
-      best@(bestH, bestL, bestS) = head [ p | p@(_,_,s) <- grid
-                                             , s == minimum (map (\(_,_,x) -> x) grid) ]
-      _ = best
-      fit  = kernelRidgeMulti kern bestH bestL xs ys
-  in (fit, bestH, bestL, bestS)
-
--- | Log-spaced bandwidth candidates. @defaultHGrid xs@ produces 30
--- candidates spanning the range of @xs@.
-defaultHGrid :: V.Vector Double -> [Double]
-defaultHGrid xs =
-  let xv  = V.toList xs
-      mn  = minimum xv
-      mx  = maximum xv
-      rng = mx - mn
-      lo  = max 1e-3 (rng / 100)
-      hi  = max (lo * 10) rng
-      n   = 30
-      lLo = log lo
-      lHi = log hi
-      step = (lHi - lLo) / fromIntegral (n - 1)
-  in [ exp (lLo + fromIntegral i * step) | i <- [0 .. n - 1 :: Int] ]
-
--- | Log-spaced ridge-penalty candidates (10 values from 1e-6 to 1).
-defaultLamGrid :: [Double]
-defaultLamGrid =
-  let n = 10
-      lLo = log 1e-6
-      lHi = log 1e0
-      step = (lHi - lLo) / fromIntegral (n - 1)
-  in [ exp (lLo + fromIntegral i * step) | i <- [0 .. n - 1 :: Int] ]
+-- Phase 11b (2026-05-14): fill a flat 'Storable.Vector' via @runST +
+-- MVector@ instead of materialising the @|xs|·|xs'|@ lazy @[Double]@
+-- list (one allocation per kernel call). 'kernelFn' itself is unchanged
+-- so 'Periodic' (signed-difference dependent) keeps working.
+buildKernelMatrix :: Kernel -> KernelParams -> [Double] -> [Double] -> LA.Matrix Double
+buildKernelMatrix ker p xs xs' =
+  let xv = VS.fromList xs
+      yv = VS.fromList xs'
+      n  = VS.length xv
+      m  = VS.length yv
+      out = runST $ do
+        v <- VSM.unsafeNew (n * m)
+        let go !i !j
+              | i >= n    = pure ()
+              | j >= m    = go (i + 1) 0
+              | otherwise = do
+                  let xi = VS.unsafeIndex xv i
+                      yj = VS.unsafeIndex yv j
+                  VSM.unsafeWrite v (i * m + j) (kernelFn ker p xi yj)
+                  go i (j + 1)
+        go 0 0
+        VS.unsafeFreeze v
+  in LA.reshape m out
 
 -- ---------------------------------------------------------------------------
--- Multi-input (multivariate X) API
---
--- These functions take @X@ as an @n × p@ matrix (rows = samples) and use a
--- single shared bandwidth @h@ across every input dimension. Distance
--- matrices are computed via 'Hanalyze.Stat.KernelDist' (BLAS GEMM) and the kernel
--- function is applied element-wise via 'LA.cmap'; no list traversals over
--- the @O(n²)@ pair set.
---
--- For axis-specific bandwidths, scale columns of @X@ by @1 / h_d@ before
--- calling these functions.
+-- 多入力 (multivariate) 評価
 -- ---------------------------------------------------------------------------
 
--- | Multi-input Gram matrix @K[i, j] = κ(‖X[i,:] − X[j,:]‖ / h)@.
-gramMatrixMV :: Kernel -> Double -> LA.Matrix Double -> LA.Matrix Double
-gramMatrixMV kern h x =
-  let h2 = h * h
-      d2 = KD.pairwiseSqDist x
-  in LA.cmap (\s -> kernelFromSqDist kern (s / h2)) d2
-
--- | Multi-input cross Gram matrix @K[i, j] = κ(‖X[i,:] − Y[j,:]‖ / h)@.
-gramMatrixMVXY
-  :: Kernel -> Double
-  -> LA.Matrix Double   -- ^ Query @X_*@ (@m × p@).
-  -> LA.Matrix Double   -- ^ Training @X@ (@n × p@).
-  -> LA.Matrix Double   -- ^ Result (@m × n@).
-gramMatrixMVXY kern h xs ts =
-  let h2 = h * h
-      d2 = KD.pairwiseSqDistXY xs ts
-  in LA.cmap (\s -> kernelFromSqDist kern (s / h2)) d2
+-- | Apply the kernel function to an @m × n@ matrix of squared distances.
+-- 距離カーネル (RBF/Matern52/Periodic) 専用。 内積カーネル (Linear/Poly) は
+-- 二乗距離から復元できないため error (multi-input gram は 'buildKernelMatrixMV'
+-- が内積経路へ分岐するためここには到達しない)。
+applyKernel :: Kernel -> KernelParams -> LA.Matrix Double -> LA.Matrix Double
+applyKernel RBF p d2 =
+  let l2 = kpLengthScale p ** 2
+      sf = kpSignalVar p
+  in KD.mapMatrix (\s -> sf * exp (- s / (2 * l2))) d2
+applyKernel Matern52 p d2 =
+  let l  = kpLengthScale p
+      sf = kpSignalVar p
+  in KD.mapMatrix (\s -> let r = sqrt (max 0 s)
+                             u = sqrt 5 * r / l
+                         in sf * (1 + u + u * u / 3) * exp (- u)) d2
+applyKernel Periodic p d2 =
+  let l  = kpLengthScale p
+      sf = kpSignalVar p
+      pr = kpPeriod p
+  in KD.mapMatrix (\s -> let r = sqrt (max 0 s)
+                             ss = sin (pi * r / pr)
+                         in sf * exp (- 2 * ss * ss / (l * l))) d2
+applyKernel Linear   _ _ = error "applyKernel: Linear は内積カーネル。buildKernelMatrixMV/kEvalMV を使うこと"
+applyKernel (Poly _) _ _ = error "applyKernel: Poly は内積カーネル。buildKernelMatrixMV/kEvalMV を使うこと"
 
--- | Multi-input kernel ridge fit. Holds the training matrix and the
--- solution coefficients; @α@ has shape @n × q@.
-data KernelRidgeFitMV = KernelRidgeFitMV
-  { krmvKernel :: Kernel
-  , krmvH      :: Double
-  , krmvLambda :: Double
-  , krmvXs     :: LA.Matrix Double  -- ^ Training inputs (@n × p@).
-  , krmvAlpha  :: LA.Matrix Double  -- ^ @(K + λI)⁻¹ Y@ (@n × q@).
-  } deriving (Show)
+-- | Apply ARD scaling to (X, X') if 'kpLengthScales' is 'Just'. Returns
+-- the (possibly rescaled) matrices and a 'KernelParams' with @ℓ = 1@ so
+-- that 'applyKernel' divides by 1 (the per-dim ℓ_d already absorbed into
+-- the column scaling). 'Nothing' = isotropic, returns inputs and params
+-- unchanged. The 'Periodic' kernel does not support ARD.
+ardScaleXY
+  :: Kernel -> KernelParams -> LA.Matrix Double -> LA.Matrix Double
+  -> (LA.Matrix Double, LA.Matrix Double, KernelParams)
+ardScaleXY Periodic p x y = (x, y, p)
+ardScaleXY _        p x y = case kpLengthScales p of
+  Nothing -> (x, y, p)
+  Just ls ->
+    let p_     = LA.cols x
+        lsExt  = if LA.size ls == p_
+                   then ls
+                   else LA.konst (kpLengthScale p) p_  -- safety fallback
+        invL   = LA.cmap (1 /) lsExt                 -- 1 / ℓ_d
+        scaleCols m = m LA.<> LA.diag invL
+        x'     = scaleCols x
+        y'     = scaleCols y
+        p'     = p { kpLengthScale = 1.0 }
+    in (x', y', p')
 
--- | Multi-input multi-output kernel ridge regression.
+-- | Build the kernel matrix @K(X, X')@ of shape @|X| × |X'|@ from
+-- multi-input matrices. @X@ is @n × p@; @X'@ is @m × p@.
 --
--- @α = (K + λI)⁻¹ Y@ with @K = gramMatrixMV kern h X@. Solving once and
--- reusing across the @q@ output columns.
-kernelRidgeMV
-  :: Kernel
-  -> Double                 -- ^ Bandwidth @h@.
-  -> Double                 -- ^ Ridge penalty @λ@.
-  -> LA.Matrix Double       -- ^ Training inputs @X@ (@n × p@).
-  -> LA.Matrix Double       -- ^ Training response @Y@ (@n × q@).
-  -> KernelRidgeFitMV
-kernelRidgeMV kern h lam x y =
-  let n     = LA.rows x
-      kMat  = gramMatrixMV kern h x
-      regK  = kMat + LA.scale lam (LA.ident n)
-      -- SPD: K + λI. Use Cholesky-based solve.
-      alpha = Chol.cholSolveJitter regK y
-  in KernelRidgeFitMV kern h lam x alpha
-
--- | Predict @Ŷ = K_* α@ for new query inputs (@m × p@). Output shape is
--- @m × q@.
-predictKernelRidgeMV :: KernelRidgeFitMV -> LA.Matrix Double -> LA.Matrix Double
-predictKernelRidgeMV fit xNew =
-  gramMatrixMVXY (krmvKernel fit) (krmvH fit) xNew (krmvXs fit)
-    LA.<> krmvAlpha fit
+-- When 'kpLengthScales' is 'Just', uses ARD: each input dimension is
+-- scaled by @1 / ℓ_d@ before computing pairwise squared distances.
+buildKernelMatrixMV
+  :: Kernel -> KernelParams -> LA.Matrix Double -> LA.Matrix Double
+  -> LA.Matrix Double
+buildKernelMatrixMV Linear p x x' =
+  -- 内積カーネル: K = σ_f² X X'ᵀ (距離経路を通さない)。
+  LA.scale (kpSignalVar p) (x LA.<> LA.tr x')
+buildKernelMatrixMV (Poly d) p x x' =
+  -- (γ X X'ᵀ + 1)^d, γ = 1/(2ℓ²)。
+  let l = kpLengthScale p
+      g = 1 / (2 * l * l)
+  in LA.cmap (\ip -> (g * ip + 1) ^^ d) (x LA.<> LA.tr x')
+buildKernelMatrixMV ker p x x' =
+  let (xs, ys, p') = ardScaleXY ker p x x'
+  in applyKernel ker p' (KD.pairwiseSqDistXY xs ys)
 
--- | Fitted values at the training inputs.
-fittedKernelRidgeMV :: KernelRidgeFitMV -> LA.Matrix Double
-fittedKernelRidgeMV fit = predictKernelRidgeMV fit (krmvXs fit)
+-- | 多入力カーネル評価 @k(a, b)@ (全カーネル対応・SVM 等の汎用経路)。
+-- 距離カーネル (RBF/Matern52/Periodic) は二乗距離、 内積カーネル (Linear/Poly)
+-- は内積から評価する。 (Phase 75.14)
+kEvalMV :: Kernel -> KernelParams -> LA.Vector Double -> LA.Vector Double -> Double
+kEvalMV Linear   p a b = kpSignalVar p * (a LA.<.> b)
+kEvalMV (Poly d) p a b =
+  let l = kpLengthScale p
+      g = 1 / (2 * l * l)
+  in (g * (a LA.<.> b) + 1) ^^ d
+kEvalMV ker      p a b =
+  let d = a - b
+  in kernelOfParams ker p (d LA.<.> d)   -- 距離カーネル: s = ‖a−b‖²
 
--- | Multi-input multi-output Nadaraya-Watson regression.
---
--- @ŷ(x*) = (Σⱼ K_h(x* − xⱼ) yⱼ) / Σⱼ K_h(x* − xⱼ)@, computed for every
--- query row in one pass via @W = K(X_*, X)@ then @W Y / row-sums@.
-nwRegressionMV
-  :: Kernel
-  -> Double                 -- ^ Bandwidth @h@.
-  -> LA.Matrix Double       -- ^ Training inputs @X@ (@n × p@).
-  -> LA.Matrix Double       -- ^ Training response @Y@ (@n × q@).
-  -> LA.Matrix Double       -- ^ Query inputs @X_*@ (@m × p@).
-  -> LA.Matrix Double       -- ^ Predictions (@m × q@).
-nwRegressionMV kern h xs ys xNew =
-  -- P35a (2026-05-07): replace @LA.diag safe LA.<> num@ (m×m dense
-  -- diag matrix + GEMM) with broadcast outer product → elementwise.
-  --
-  -- P35b explored further: fusing the @num@ and @denom@ GEMVs into a
-  -- single GEMM via @yAug = [ys | onesN]@ to traverse the 8 MB
-  -- weight matrix only once (it exceeds typical L3). It /regressed/
-  -- at q=1 (33.8 → 37 ms) because (a) @LA.|||@ allocates a fresh
-  -- 8 MB matrix, and (b) BLAS GEMM with k=2 RHS columns has higher
-  -- block-tiling overhead than two GEMV calls. For q ≫ 1 the fusion
-  -- would win, but the bench is q=1 so the unfused form stays.
-  --
-  -- The remaining bottleneck is @LA.cmap kernelFromSqDist@ over the
-  -- 1M-cell weight matrix — a per-element Haskell function call per
-  -- exp(). FFI'd vectorized exp (libmvec / SLEEF) would close the
-  -- 3.6× gap to sklearn but is out of scope here.
-  let !wMat   = gramMatrixMVXY kern h xNew xs           -- m × n
-      !num    = wMat LA.<> ys                           -- m × q
-      !onesN  = LA.konst 1 (LA.cols wMat) :: LA.Vector Double
-      !denom  = wMat LA.#> onesN                        -- m
-      !safe   = LA.cmap (\d -> if d == 0 then 1 else 1 / d) denom
-      !onesQ  = LA.konst 1 (LA.cols num) :: LA.Vector Double
-      !safeBc = LA.outer safe onesQ                     -- m × q
-  in safeBc * num
+-- | Specialized kernel function for a fixed parameter set, returning a
+-- monomorphic @Double -> Double@ that GHC can inline tightly into the
+-- @mkNoiseKernelFromD2@ inner loop (in 'Model.GP'). 距離カーネル専用。
+{-# INLINE kernelOfParams #-}
+kernelOfParams :: Kernel -> KernelParams -> (Double -> Double)
+kernelOfParams RBF p =
+  let !l2 = kpLengthScale p ** 2
+      !sf = kpSignalVar p
+      !inv2L2 = 1 / (2 * l2)
+  in \s -> sf * exp (- s * inv2L2)
+kernelOfParams Matern52 p =
+  let !l  = kpLengthScale p
+      !sf = kpSignalVar p
+      !invL = sqrt 5 / l
+  in \s -> let r = sqrt (max 0 s)
+               u = invL * r
+           in sf * (1 + u + u * u / 3) * exp (- u)
+kernelOfParams Periodic p =
+  let !l  = kpLengthScale p
+      !sf = kpSignalVar p
+      !pr = kpPeriod p
+      !invL2 = 1 / (l * l)
+      !invPr = pi / pr
+  in \s -> let r  = sqrt (max 0 s)
+               ss = sin (invPr * r)
+           in sf * exp (- 2 * ss * ss * invL2)
+kernelOfParams Linear   _ = error "kernelOfParams: Linear は内積カーネル。kEvalMV を使うこと"
+kernelOfParams (Poly _) _ = error "kernelOfParams: Poly は内積カーネル。kEvalMV を使うこと"
diff --git a/src/Hanalyze/Model/KernelRegression.hs b/src/Hanalyze/Model/KernelRegression.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Model/KernelRegression.hs
@@ -0,0 +1,485 @@
+{-# LANGUAGE StrictData #-}
+{-# LANGUAGE OverloadedStrings #-}
+-- |
+-- Module      : Hanalyze.Model.KernelRegression
+-- Description : カーネル回帰 (Nadaraya-Watson / kernel ridge regression)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Kernel regression — Nadaraya-Watson and kernel ridge regression.
+--
+--   * 'Kernel'        — RBF / Matérn / triangular / Epanechnikov kernel
+--     functions.
+--   * 'nwRegression'  — Nadaraya-Watson (kernel-weighted moving average).
+--   * 'kernelRidge'   — kernel ridge regression
+--     @ŷ(x*) = k(x*)ᵀ (K + λI)⁻¹ y@.
+--
+-- Both are non-parametric smooth nonlinear regressors. Unlike 'Hanalyze.Model.GP',
+-- they do not produce uncertainty estimates.
+--
+-- NB: この 'Kernel' は回帰スムージング用 (Gaussian/Epanechnikov/…)。
+-- GP/SVM 族の共有カーネル (RBF/Matérn5/2/Periodic/Linear/Poly) は別モジュール
+-- 'Hanalyze.Model.Kernel' (Phase 75.18 で分離)。
+module Hanalyze.Model.KernelRegression
+  ( Kernel (..)
+  , kernelEval
+  , kernelFromSqDist
+  , nwRegression
+  , nwRegressionMulti
+  , KernelRidgeFit (..)
+  , kernelRidge
+  , predictKernelRidge
+  , gridSearchBandwidth
+  , autoBandwidthBrent
+    -- * Multi-output (1D input, multiple Y columns)
+  , KernelRidgeFitMulti (..)
+  , kernelRidgeMulti
+  , predictKernelRidgeMulti
+  , fittedKernelRidgeMulti
+  , r2Multi
+  , autoTuneKernelRidgeMulti
+  , defaultHGrid
+  , defaultLamGrid
+    -- * Multi-input (primary API; X is @n × p@, Y is @n × q@)
+  , gramMatrixMV
+  , gramMatrixMVXY
+  , KernelRidgeFitMV (..)
+  , kernelRidgeMV
+  , predictKernelRidgeMV
+  , fittedKernelRidgeMV
+  , nwRegressionMV
+  ) where
+
+import qualified Data.Vector as V
+import qualified Numeric.LinearAlgebra as LA
+import qualified Hanalyze.Optim.LineSearch as LS
+import qualified Hanalyze.Optim.Common     as OC
+import qualified Hanalyze.Stat.KernelDist  as KD
+import qualified Hanalyze.Stat.Cholesky    as Chol
+
+-- ---------------------------------------------------------------------------
+-- カーネル関数
+-- ---------------------------------------------------------------------------
+
+-- | Supported kernels. The bandwidth @h@ is passed separately at the
+-- call site.
+data Kernel
+  = Gaussian       -- ^ @exp(-u²/2)@ (= RBF, infinite support).
+  | Epanechnikov   -- ^ @0.75 (1-u²)@ on @|u| ≤ 1@.
+  | Triangular     -- ^ @1 - |u|@ on @|u| ≤ 1@.
+  | Uniform        -- ^ @0.5@ on @|u| ≤ 1@ (coarsest).
+  | TriCube        -- ^ @(1-|u|³)³@ on @|u| ≤ 1@.
+  deriving (Show, Eq)
+
+-- | Evaluate the kernel at scaled squared distance @s = ‖x − x'‖² / h²@.
+-- Generalizes 'kernelEval' to multivariate inputs: every supported
+-- kernel is radially symmetric, so the kernel value depends only on
+-- @‖x − x'‖ / h@.
+--
+-- For the Gaussian kernel this avoids the redundant @sqrt@; for kernels
+-- with bounded support (Epanechnikov / Triangular / Uniform / TriCube)
+-- the boundary check uses @s ≤ 1@.
+kernelFromSqDist :: Kernel -> Double -> Double
+kernelFromSqDist k s = case k of
+  Gaussian     -> exp (-0.5 * s) / sqrt (2 * pi)
+  Epanechnikov -> if s <= 1 then 0.75 * (1 - s) else 0
+  Triangular   -> if s <= 1 then 1 - sqrt s else 0
+  Uniform      -> if s <= 1 then 0.5 else 0
+  TriCube      -> if s <= 1
+                    then let u = sqrt s
+                             t = 1 - u * u * u
+                         in t * t * t
+                    else 0
+
+-- | Evaluate the kernel at @u = (x - x_i) / h@.
+kernelEval :: Kernel -> Double -> Double
+kernelEval k u = case k of
+  Gaussian     -> exp (-0.5 * u * u) / sqrt (2 * pi)
+  Epanechnikov -> if abs u <= 1 then 0.75 * (1 - u * u) else 0
+  Triangular   -> if abs u <= 1 then 1 - abs u else 0
+  Uniform      -> if abs u <= 1 then 0.5 else 0
+  TriCube      -> if abs u <= 1
+                    then let t = 1 - (abs u)^(3::Int)
+                         in t * t * t
+                    else 0
+
+-- ---------------------------------------------------------------------------
+-- Nadaraya-Watson
+-- ---------------------------------------------------------------------------
+
+-- | Single-output Nadaraya-Watson kernel regression.
+--
+-- @ŷ(x*) = Σᵢ K_h(x* - xᵢ) yᵢ / Σᵢ K_h(x* - xᵢ)@
+--
+-- Delegates to 'nwRegressionMulti' by promoting @y@ to a one-column
+-- matrix.
+nwRegression :: Kernel
+             -> Double             -- ^ Bandwidth @h@ (@> 0@).
+             -> V.Vector Double    -- ^ Training inputs.
+             -> V.Vector Double    -- ^ Training targets.
+             -> V.Vector Double    -- ^ Prediction inputs.
+             -> V.Vector Double    -- ^ Predictions.
+nwRegression kern h xs ys xNew =
+  let yMat = LA.asColumn (LA.fromList (V.toList ys))
+      mat  = nwRegressionMulti kern h xs yMat xNew
+  in V.fromList (LA.toList (LA.flatten (mat LA.¿ [0])))
+
+-- | Multi-output Nadaraya-Watson: reuse the same weight matrix across
+-- every output column. With @W@ of shape @m × n@ and @Y@ of shape
+-- @n × q@, the result is the row-normalized product @W · Y@ of shape
+-- @m × q@.
+nwRegressionMulti :: Kernel
+                  -> Double               -- ^ Bandwidth @h@.
+                  -> V.Vector Double      -- ^ Training inputs (length @n@).
+                  -> LA.Matrix Double     -- ^ Training response @Y@ (@n × q@).
+                  -> V.Vector Double      -- ^ Prediction inputs (length @m@).
+                  -> LA.Matrix Double     -- ^ Predictions (@m × q@).
+nwRegressionMulti kern h xs ys xNew =
+  let n  = V.length xs
+      m  = V.length xNew
+      q  = LA.cols ys
+      wMat = LA.fromLists
+               [ [ kernelEval kern ((xStar - xi) / h)
+                 | xi <- V.toList xs ]
+               | xStar <- V.toList xNew ]   -- (m × n)
+      num  = wMat LA.<> ys                  -- (m × q)
+      dens = LA.toList (wMat LA.#> LA.konst 1 n)
+      rows = [ if d == 0 then replicate q 0
+                 else [ (num `LA.atIndex` (i, j)) / d | j <- [0 .. q - 1] ]
+             | (i, d) <- zip [0 .. m - 1] dens ]
+  in LA.fromLists rows
+
+-- ---------------------------------------------------------------------------
+-- Kernel Ridge regression
+-- ---------------------------------------------------------------------------
+
+-- | Kernel ridge regression fit; carries everything needed to predict.
+data KernelRidgeFit = KernelRidgeFit
+  { krKernel :: Kernel
+  , krH      :: Double
+  , krLambda :: Double
+  , krXs     :: V.Vector Double   -- ^ Training inputs.
+  , krAlpha  :: LA.Vector Double  -- ^ Solution @α = (K + λI)⁻¹ y@.
+  } deriving (Show)
+
+-- | Build the Gram matrix @K_{ij} = K_h(x_i - x_j)@.
+gramMatrix :: Kernel -> Double -> V.Vector Double -> LA.Matrix Double
+gramMatrix kern h xs =
+  let n = V.length xs
+      xv = V.toList xs
+  in (n LA.>< n)
+       [ kernelEval kern ((xi - xj) / h)
+       | xi <- xv, xj <- xv ]
+
+-- | Single-output kernel ridge regression. Delegates to
+-- 'kernelRidgeMulti' by promoting @y@ to a one-column matrix and taking
+-- column 0 of the resulting @α@ matrix.
+kernelRidge :: Kernel
+            -> Double             -- ^ Bandwidth @h@.
+            -> Double             -- ^ Ridge penalty @λ@.
+            -> V.Vector Double    -- ^ Training inputs.
+            -> V.Vector Double    -- ^ Training targets.
+            -> KernelRidgeFit
+kernelRidge kern h lam xs ys =
+  let yMat = LA.asColumn (LA.fromList (V.toList ys))
+      mf   = kernelRidgeMulti kern h lam xs yMat
+      a    = LA.flatten (krmAlpha mf LA.¿ [0])
+  in KernelRidgeFit kern h lam xs a
+
+-- | Predict at new inputs from a 'KernelRidgeFit'.
+predictKernelRidge :: KernelRidgeFit -> V.Vector Double -> V.Vector Double
+predictKernelRidge fit xNew =
+  V.map predict xNew
+  where
+    xs    = krXs fit
+    h     = krH fit
+    kern  = krKernel fit
+    alpha = krAlpha fit
+    predict xStar =
+      let kVec = LA.fromList
+                   [ kernelEval kern ((xStar - xi) / h)
+                   | xi <- V.toList xs ]
+      in kVec LA.<.> alpha
+
+-- ---------------------------------------------------------------------------
+-- Bandwidth selection
+-- ---------------------------------------------------------------------------
+
+-- | Pick the bandwidth @h@ by leave-one-out cross-validation. Simple
+-- grid search: returns the candidate with the smallest LOO RMSE.
+gridSearchBandwidth
+  :: Kernel
+  -> V.Vector Double      -- ^ Training inputs.
+  -> V.Vector Double      -- ^ Training targets.
+  -> [Double]             -- ^ Candidate bandwidths.
+  -> (Double, Double)     -- ^ @(best h, best LOO RMSE)@.
+gridSearchBandwidth kern xs ys hs =
+  let results = [(h, looErrNW kern xs ys h) | h <- hs]
+      best = head [ pair | pair <- results
+                         , snd pair == minimum (map snd results) ]
+  in best
+
+-- | NW LOO-CV loss as a continuous function of @h@; shared with
+-- 'autoBandwidthBrent'.
+looErrNW :: Kernel -> V.Vector Double -> V.Vector Double -> Double -> Double
+looErrNW kern xs ys h =
+  let n = V.length xs
+      yPred = V.imap
+        (\i _ ->
+          let xs'  = V.ifilter (\j _ -> j /= i) xs
+              ys'  = V.ifilter (\j _ -> j /= i) ys
+              xi   = xs V.! i
+              pred = nwRegression kern h xs' ys' (V.singleton xi)
+          in V.head pred)
+        xs
+      err = V.zipWith (\y yh -> (y - yh)^(2::Int)) ys yPred
+  in sqrt (V.sum err / fromIntegral n)
+
+-- | Continuously optimize the bandwidth @h@ with Brent's method
+-- (minimizing the LOO-CV loss). Assumes the bracket @[h_lo, h_hi]@ is
+-- unimodal. Avoids enumerating discrete candidates the way
+-- 'gridSearchBandwidth' does.
+--
+-- Returns @(best h, best LOO RMSE)@.
+autoBandwidthBrent
+  :: Kernel
+  -> V.Vector Double    -- ^ Training inputs.
+  -> V.Vector Double    -- ^ Training targets.
+  -> Double             -- ^ Lower bound @h_lo@.
+  -> Double             -- ^ Upper bound @h_hi@.
+  -> (Double, Double)
+autoBandwidthBrent kern xs ys hLo hHi =
+  let cfg = LS.defaultBrentConfig { LS.bcMaxIter = 80, LS.bcTol = 1e-6 }
+      result = LS.brent cfg (\[h] -> looErrNW kern xs ys h) hLo hHi
+      hStar  = head (OC.orBest result)
+  in (hStar, OC.orValue result)
+
+-- ---------------------------------------------------------------------------
+-- 多出力 Kernel Ridge (Phase T2)
+-- ---------------------------------------------------------------------------
+
+-- | Multi-output kernel ridge regression. With @Y@ of shape @n × q@,
+-- solves each column independently but shares the Gram matrix @K@.
+data KernelRidgeFitMulti = KernelRidgeFitMulti
+  { krmKernel :: Kernel
+  , krmH      :: Double
+  , krmLambda :: Double
+  , krmXs     :: V.Vector Double
+  , krmAlpha  :: LA.Matrix Double   -- α (n × q)
+  } deriving (Show)
+
+-- | Solve @(K + λI)⁻¹ Y@ once and reuse for every column (fast).
+kernelRidgeMulti :: Kernel -> Double -> Double
+                 -> V.Vector Double -> LA.Matrix Double
+                 -> KernelRidgeFitMulti
+kernelRidgeMulti kern h lam xs ys =
+  let n     = V.length xs
+      kMat  = gramMatrix kern h xs
+      regK  = kMat + LA.scale lam (LA.ident n)
+      -- regK is SPD (K is PSD, λI is PD). Use Cholesky-based solve;
+      -- jitter retry handles ill-conditioned bandwidths.
+      alpha = Chol.cholSolveJitter regK ys
+  in KernelRidgeFitMulti kern h lam xs alpha
+
+-- | Predict @Ŷ@ for new inputs from a 'KernelRidgeFitMulti'.
+predictKernelRidgeMulti :: KernelRidgeFitMulti -> V.Vector Double
+                        -> LA.Matrix Double
+predictKernelRidgeMulti fit xNew =
+  let xs    = krmXs fit
+      h     = krmH fit
+      kern  = krmKernel fit
+      alpha = krmAlpha fit
+      kMat  = LA.fromLists
+                [ [ kernelEval kern ((xStar - xi) / h)
+                  | xi <- V.toList xs ]
+                | xStar <- V.toList xNew ]
+  in kMat LA.<> alpha
+
+-- | Fitted values at the training inputs (= @ŷ_train@).
+fittedKernelRidgeMulti :: KernelRidgeFitMulti -> LA.Matrix Double
+fittedKernelRidgeMulti fit = predictKernelRidgeMulti fit (krmXs fit)
+
+-- | Multi-output R² returned as a length-@q@ vector. @Y@ observed and
+-- @Ŷ@ predicted both have shape @n × q@.
+r2Multi :: LA.Matrix Double -> LA.Matrix Double -> V.Vector Double
+r2Multi ys yhat =
+  let n  = LA.rows ys
+      q  = LA.cols ys
+      colR2 j =
+        let yc  = LA.toList (LA.flatten (ys     LA.¿ [j]))
+            yhc = LA.toList (LA.flatten (yhat   LA.¿ [j]))
+            mu  = sum yc / fromIntegral n
+            sst = sum [(y - mu)^(2::Int) | y <- yc]
+            sse = sum [(y - p)^(2::Int) | (y, p) <- zip yc yhc]
+        in if sst == 0 then 0 else 1 - sse / sst
+  in V.fromList [ colR2 j | j <- [0 .. q - 1] ]
+
+-- | Joint @(h, λ)@ grid search using the closed-form LOOCV. Computes the
+-- hat-matrix diagonal once per
+-- 全 q 出力の LOO 残差を一括評価。
+--
+-- 戻り値: (best fit, best h, best λ, best mean LOO MSE)
+autoTuneKernelRidgeMulti
+  :: Kernel
+  -> V.Vector Double      -- xs (n)
+  -> LA.Matrix Double     -- ys (n × q)
+  -> [Double]             -- h candidates
+  -> [Double]             -- λ candidates
+  -> (KernelRidgeFitMulti, Double, Double, Double)
+autoTuneKernelRidgeMulti kern xs ys hs lams =
+  let n   = V.length xs
+      q   = LA.cols ys
+      tot = fromIntegral (n * q) :: Double
+      score h lam =
+        let kMat = gramMatrix kern h xs
+            regK = kMat + LA.scale lam (LA.ident n)
+            ainv = LA.inv regK
+            hat  = kMat LA.<> ainv          -- (n × n)
+            diagH = LA.takeDiag hat
+            yhat = hat LA.<> ys             -- (n × q)
+            res  = ys - yhat                -- (n × q)
+            -- LOO 残差: r_i / (1 - H_ii)、列方向ブロードキャスト
+            denom = LA.cmap (\h_ii -> 1 - h_ii) diagH
+            invDenom = LA.cmap (\d -> if abs d < 1e-10 then 0 else 1/d) denom
+            scaler = LA.fromColumns (replicate q invDenom)
+            looR  = res * scaler
+            sse   = LA.sumElements (looR * looR)
+        in sse / tot
+      grid = [ (h, lam, score h lam) | h <- hs, lam <- lams ]
+      best@(bestH, bestL, bestS) = head [ p | p@(_,_,s) <- grid
+                                             , s == minimum (map (\(_,_,x) -> x) grid) ]
+      _ = best
+      fit  = kernelRidgeMulti kern bestH bestL xs ys
+  in (fit, bestH, bestL, bestS)
+
+-- | Log-spaced bandwidth candidates. @defaultHGrid xs@ produces 30
+-- candidates spanning the range of @xs@.
+defaultHGrid :: V.Vector Double -> [Double]
+defaultHGrid xs =
+  let xv  = V.toList xs
+      mn  = minimum xv
+      mx  = maximum xv
+      rng = mx - mn
+      lo  = max 1e-3 (rng / 100)
+      hi  = max (lo * 10) rng
+      n   = 30
+      lLo = log lo
+      lHi = log hi
+      step = (lHi - lLo) / fromIntegral (n - 1)
+  in [ exp (lLo + fromIntegral i * step) | i <- [0 .. n - 1 :: Int] ]
+
+-- | Log-spaced ridge-penalty candidates (10 values from 1e-6 to 1).
+defaultLamGrid :: [Double]
+defaultLamGrid =
+  let n = 10
+      lLo = log 1e-6
+      lHi = log 1e0
+      step = (lHi - lLo) / fromIntegral (n - 1)
+  in [ exp (lLo + fromIntegral i * step) | i <- [0 .. n - 1 :: Int] ]
+
+-- ---------------------------------------------------------------------------
+-- Multi-input (multivariate X) API
+--
+-- These functions take @X@ as an @n × p@ matrix (rows = samples) and use a
+-- single shared bandwidth @h@ across every input dimension. Distance
+-- matrices are computed via 'Hanalyze.Stat.KernelDist' (BLAS GEMM) and the kernel
+-- function is applied element-wise via 'LA.cmap'; no list traversals over
+-- the @O(n²)@ pair set.
+--
+-- For axis-specific bandwidths, scale columns of @X@ by @1 / h_d@ before
+-- calling these functions.
+-- ---------------------------------------------------------------------------
+
+-- | Multi-input Gram matrix @K[i, j] = κ(‖X[i,:] − X[j,:]‖ / h)@.
+gramMatrixMV :: Kernel -> Double -> LA.Matrix Double -> LA.Matrix Double
+gramMatrixMV kern h x =
+  let h2 = h * h
+      d2 = KD.pairwiseSqDist x
+  in LA.cmap (\s -> kernelFromSqDist kern (s / h2)) d2
+
+-- | Multi-input cross Gram matrix @K[i, j] = κ(‖X[i,:] − Y[j,:]‖ / h)@.
+gramMatrixMVXY
+  :: Kernel -> Double
+  -> LA.Matrix Double   -- ^ Query @X_*@ (@m × p@).
+  -> LA.Matrix Double   -- ^ Training @X@ (@n × p@).
+  -> LA.Matrix Double   -- ^ Result (@m × n@).
+gramMatrixMVXY kern h xs ts =
+  let h2 = h * h
+      d2 = KD.pairwiseSqDistXY xs ts
+  in LA.cmap (\s -> kernelFromSqDist kern (s / h2)) d2
+
+-- | Multi-input kernel ridge fit. Holds the training matrix and the
+-- solution coefficients; @α@ has shape @n × q@.
+data KernelRidgeFitMV = KernelRidgeFitMV
+  { krmvKernel :: Kernel
+  , krmvH      :: Double
+  , krmvLambda :: Double
+  , krmvXs     :: LA.Matrix Double  -- ^ Training inputs (@n × p@).
+  , krmvAlpha  :: LA.Matrix Double  -- ^ @(K + λI)⁻¹ Y@ (@n × q@).
+  } deriving (Show)
+
+-- | Multi-input multi-output kernel ridge regression.
+--
+-- @α = (K + λI)⁻¹ Y@ with @K = gramMatrixMV kern h X@. Solving once and
+-- reusing across the @q@ output columns.
+kernelRidgeMV
+  :: Kernel
+  -> Double                 -- ^ Bandwidth @h@.
+  -> Double                 -- ^ Ridge penalty @λ@.
+  -> LA.Matrix Double       -- ^ Training inputs @X@ (@n × p@).
+  -> LA.Matrix Double       -- ^ Training response @Y@ (@n × q@).
+  -> KernelRidgeFitMV
+kernelRidgeMV kern h lam x y =
+  let n     = LA.rows x
+      kMat  = gramMatrixMV kern h x
+      regK  = kMat + LA.scale lam (LA.ident n)
+      -- SPD: K + λI. Use Cholesky-based solve.
+      alpha = Chol.cholSolveJitter regK y
+  in KernelRidgeFitMV kern h lam x alpha
+
+-- | Predict @Ŷ = K_* α@ for new query inputs (@m × p@). Output shape is
+-- @m × q@.
+predictKernelRidgeMV :: KernelRidgeFitMV -> LA.Matrix Double -> LA.Matrix Double
+predictKernelRidgeMV fit xNew =
+  gramMatrixMVXY (krmvKernel fit) (krmvH fit) xNew (krmvXs fit)
+    LA.<> krmvAlpha fit
+
+-- | Fitted values at the training inputs.
+fittedKernelRidgeMV :: KernelRidgeFitMV -> LA.Matrix Double
+fittedKernelRidgeMV fit = predictKernelRidgeMV fit (krmvXs fit)
+
+-- | Multi-input multi-output Nadaraya-Watson regression.
+--
+-- @ŷ(x*) = (Σⱼ K_h(x* − xⱼ) yⱼ) / Σⱼ K_h(x* − xⱼ)@, computed for every
+-- query row in one pass via @W = K(X_*, X)@ then @W Y / row-sums@.
+nwRegressionMV
+  :: Kernel
+  -> Double                 -- ^ Bandwidth @h@.
+  -> LA.Matrix Double       -- ^ Training inputs @X@ (@n × p@).
+  -> LA.Matrix Double       -- ^ Training response @Y@ (@n × q@).
+  -> LA.Matrix Double       -- ^ Query inputs @X_*@ (@m × p@).
+  -> LA.Matrix Double       -- ^ Predictions (@m × q@).
+nwRegressionMV kern h xs ys xNew =
+  -- P35a (2026-05-07): replace @LA.diag safe LA.<> num@ (m×m dense
+  -- diag matrix + GEMM) with broadcast outer product → elementwise.
+  --
+  -- P35b explored further: fusing the @num@ and @denom@ GEMVs into a
+  -- single GEMM via @yAug = [ys | onesN]@ to traverse the 8 MB
+  -- weight matrix only once (it exceeds typical L3). It /regressed/
+  -- at q=1 (33.8 → 37 ms) because (a) @LA.|||@ allocates a fresh
+  -- 8 MB matrix, and (b) BLAS GEMM with k=2 RHS columns has higher
+  -- block-tiling overhead than two GEMV calls. For q ≫ 1 the fusion
+  -- would win, but the bench is q=1 so the unfused form stays.
+  --
+  -- The remaining bottleneck is @LA.cmap kernelFromSqDist@ over the
+  -- 1M-cell weight matrix — a per-element Haskell function call per
+  -- exp(). FFI'd vectorized exp (libmvec / SLEEF) would close the
+  -- 3.6× gap to sklearn but is out of scope here.
+  let !wMat   = gramMatrixMVXY kern h xNew xs           -- m × n
+      !num    = wMat LA.<> ys                           -- m × q
+      !onesN  = LA.konst 1 (LA.cols wMat) :: LA.Vector Double
+      !denom  = wMat LA.#> onesN                        -- m
+      !safe   = LA.cmap (\d -> if d == 0 then 1 else 1 / d) denom
+      !onesQ  = LA.konst 1 (LA.cols num) :: LA.Vector Double
+      !safeBc = LA.outer safe onesQ                     -- m × q
+  in safeBc * num
diff --git a/src/Hanalyze/Model/LM.hs b/src/Hanalyze/Model/LM.hs
--- a/src/Hanalyze/Model/LM.hs
+++ b/src/Hanalyze/Model/LM.hs
@@ -1,5 +1,11 @@
--- | Ordinary linear regression by least squares.
+-- |
+-- Module      : Hanalyze.Model.LM
+-- Description : 最小二乗法による線形回帰の fit・予測・信頼/予測区間
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
 --
+-- Ordinary linear regression by least squares.
+--
 -- Solves @β = (XᵀX)⁻¹ Xᵀ y@ via hmatrix's @\\\\@ (LAPACK). Provides
 -- confidence and prediction bands using
 -- @t × √(s² xᵢᵀ(XᵀX)⁻¹xᵢ)@ and convenient adapters from a
@@ -22,6 +28,8 @@
     -- * DataFrame helpers
   , fitDataFrameLM
   , confidenceBand
+  , confidenceBandAt
+  , predictionBandAt
   , fitWithCI
   , fitPolyWithSmooth
   ) where
@@ -29,7 +37,7 @@
 import qualified DataFrame.Internal.DataFrame as DXD
 import Hanalyze.DataIO.Convert (getDoubleVec)
 import Hanalyze.Model.Core (FitResult (..), Model (..), Band (..),
-                   coefficientsV, residualsV, fittedList)
+                   coefficientsV, residualsV)
 
 import Data.Text (Text)
 import qualified Data.Vector as V
@@ -90,20 +98,73 @@
 
 -- | Pointwise confidence band for the mean response (1 出力前提)。
 -- Formula: ŷᵢ ± t_{α/2, n−p} × sqrt(s² × xᵢᵀ (XᵀX)⁻¹ xᵢ)
+--
+-- 訓練設計行列上で評価する版 (= 各点の中心は fitted)。 grid 評価が要るときは
+-- 'confidenceBandAt' を使う。
 confidenceBand :: LA.Matrix Double -> FitResult -> Double -> CIBand
-confidenceBand x res level =
-  let df    = fromIntegral (LA.rows x - LA.cols x)
-      resV  = residualsV res
-      s2    = (resV `LA.dot` resV) / df
-      xtxi  = LA.inv (LA.tr x LA.<> x)
-      tVal  = quantile (studentT df) ((1.0 + level) / 2.0)
-      se xi = tVal * sqrt (s2 * (xi `LA.dot` (xtxi LA.#> xi)))
-      rs    = LA.toRows x
-      yHats = fittedList res
-      los   = zipWith (\yh xi -> yh - se xi) yHats rs
-      his   = zipWith (\yh xi -> yh + se xi) yHats rs
-  in CIBand los his level
+confidenceBand x res level = confidenceBandAt x res level x
 
+-- | 訓練設計行列 @xTrain@ で推定した分散核 (s², (XᵀX)⁻¹, t 値) を、 別の
+-- 評価点設計行列 @xEval@ の各行で band 化する。 中心は @xEval·β@、 半幅は
+-- @t × √(s² × x₀ᵀ (XᵀX)⁻¹ x₀)@。 自由度・s² は訓練データで決まる。
+--
+-- ★grid 評価の核: 訓練点ではなく等間隔 grid の設計行列を @xEval@ に渡すと、
+-- 回帰曲線・CI 帯が滑らかになる (= 疎・不均一データのガタつき解消)。 訓練点を
+-- そのまま渡せば 'confidenceBand' と一致する (LM では @xTrain·β = fitted@)。
+confidenceBandAt
+  :: LA.Matrix Double  -- ^ 訓練設計行列 X (分散核の推定元)
+  -> FitResult         -- ^ fit 結果 (β / 残差)
+  -> Double            -- ^ 信頼水準 (例 0.95)
+  -> LA.Matrix Double  -- ^ 評価点設計行列 X₀ (band を評価する行)
+  -> CIBand
+confidenceBandAt xTrain res level xEval =
+  let df    = fromIntegral (LA.rows xTrain - LA.cols xTrain)
+      beta  = coefficientsV res
+      rs    = LA.toRows xEval
+      yHats = [ xi `LA.dot` beta | xi <- rs ]
+  -- df<=0 (飽和・過剰指定) は s²=0/0・studentT が例外 → CI 定義不能。
+  -- 幅ゼロ帯 (lo=hi=ŷ) を返し、 帯は線に潰す (呼び元は線のみ描く)。
+  in if df <= 0
+       then CIBand yHats yHats level
+       else
+         let resV  = residualsV res
+             s2    = (resV `LA.dot` resV) / df
+             xtxi  = LA.inv (LA.tr xTrain LA.<> xTrain)
+             tVal  = quantile (studentT df) ((1.0 + level) / 2.0)
+             se xi = tVal * sqrt (s2 * (xi `LA.dot` (xtxi LA.#> xi)))
+             los   = zipWith (\yh xi -> yh - se xi) yHats rs
+             his   = zipWith (\yh xi -> yh + se xi) yHats rs
+         in CIBand los his level
+
+-- | 予測区間 (prediction interval) 版の 'confidenceBandAt'。 半幅に**観測分散**
+-- @σ̂²@ を 1 つ加える: @t × √(s² × (1 + x₀ᵀ (XᵀX)⁻¹ x₀))@ (CI には @1 +@ が無い)。
+-- = 新規観測 1 点が入る区間 (平均の信頼区間より広い)。 statsmodels の
+-- @get_prediction().summary_frame()['obs_ci_lower/upper']@ と一致する。
+-- (hanalyze-portable)
+predictionBandAt
+  :: LA.Matrix Double  -- ^ 訓練設計行列 X (分散核の推定元)
+  -> FitResult         -- ^ fit 結果 (β / 残差)
+  -> Double            -- ^ 信頼水準 (例 0.95)
+  -> LA.Matrix Double  -- ^ 評価点設計行列 X₀ (band を評価する行)
+  -> CIBand
+predictionBandAt xTrain res level xEval =
+  let df    = fromIntegral (LA.rows xTrain - LA.cols xTrain)
+      beta  = coefficientsV res
+      rs    = LA.toRows xEval
+      yHats = [ xi `LA.dot` beta | xi <- rs ]
+  -- df<=0 は CI/PI 定義不能 → 幅ゼロ帯 (線のみ)。 'confidenceBandAt' と同方針。
+  in if df <= 0
+       then CIBand yHats yHats level
+       else
+         let resV  = residualsV res
+             s2    = (resV `LA.dot` resV) / df
+             xtxi  = LA.inv (LA.tr xTrain LA.<> xTrain)
+             tVal  = quantile (studentT df) ((1.0 + level) / 2.0)
+             se xi = tVal * sqrt (s2 * (1 + xi `LA.dot` (xtxi LA.#> xi)))   -- ★CI との差は (1 +)
+             los   = zipWith (\yh xi -> yh - se xi) yHats rs
+             his   = zipWith (\yh xi -> yh + se xi) yHats rs
+         in CIBand los his level
+
 -- | Fit LM and compute confidence band in one step.
 fitWithCI :: Double -> DXD.DataFrame -> Text -> Text -> Maybe (FitResult, CIBand)
 fitWithCI level df xCol yCol = do
@@ -178,13 +239,16 @@
       xtxi   = LA.inv (LA.tr dm LA.<> dm)
       gRows  = LA.toRows dmG
 
-      computeBand level isPI =
-        let tVal   = quantile (studentT dfStat) ((1.0 + level) / 2.0)
-            extra  = if isPI then 1.0 else 0.0
-            halfW xi = tVal * sqrt (s2 * (extra + xi `LA.dot` (xtxi LA.#> xi)))
-            los    = zipWith (\yh xi -> yh - halfW xi) yGrid gRows
-            his    = zipWith (\yh xi -> yh + halfW xi) yGrid gRows
-        in (los, his)
+      computeBand level isPI
+        -- df<=0 (飽和) は s²=0/0・studentT が例外 → 帯を線に潰す (lo=hi=yGrid)。
+        | dfStat <= 0 = (yGrid, yGrid)
+        | otherwise   =
+            let tVal   = quantile (studentT dfStat) ((1.0 + level) / 2.0)
+                extra  = if isPI then 1.0 else 0.0
+                halfW xi = tVal * sqrt (s2 * (extra + xi `LA.dot` (xtxi LA.#> xi)))
+                los    = zipWith (\yh xi -> yh - halfW xi) yGrid gRows
+                his    = zipWith (\yh xi -> yh + halfW xi) yGrid gRows
+            in (los, his)
 
   case band of
     NoBand ->
diff --git a/src/Hanalyze/Model/LM/Diagnostics.hs b/src/Hanalyze/Model/LM/Diagnostics.hs
--- a/src/Hanalyze/Model/LM/Diagnostics.hs
+++ b/src/Hanalyze/Model/LM/Diagnostics.hs
@@ -1,4 +1,10 @@
--- | Inference and residual diagnostics for ordinary linear regression.
+-- |
+-- Module      : Hanalyze.Model.LM.Diagnostics
+-- Description : 線形回帰の推論統計量 (標準誤差・t/p 値・F 統計量・AIC/BIC・レバレッジ・Cook's distance)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Inference and residual diagnostics for ordinary linear regression.
 --
 -- Provides standard errors, t / p-values, F-statistic, information
 -- criteria (AIC / BIC), leverage / hat-diagonal, standardised
diff --git a/src/Hanalyze/Model/LatentClassAnalysis.hs b/src/Hanalyze/Model/LatentClassAnalysis.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Model/LatentClassAnalysis.hs
@@ -0,0 +1,170 @@
+-- |
+-- Module      : Hanalyze.Model.LatentClassAnalysis
+-- Description : EM アルゴリズムによる潜在クラス分析 (LCA、R poLCA 相当)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Latent Class Analysis (LCA) via EM algorithm (Phase 32-A2)。
+--
+-- カテゴリ変数の潜在クラスクラスタリング。 @K@ 個の潜在クラスを仮定し、
+-- 各クラスでの各 categorical 特徴の条件付き分布 @P(X_j | class)@ を推定する。
+-- R `poLCA` 相当。
+--
+-- ## モデル
+--
+-- @
+--   P(X_i) = Σ_k π_k · Π_j ρ_{k, j, X_{i,j}}
+-- @
+--
+-- ここで @π_k@ はクラス混合重み、 @ρ_{k,j,l}@ はクラス @k@ で特徴 @j@ が
+-- 水準 @l@ を取る確率。
+--
+-- ## EM
+--
+-- - **E-step**: posterior @γ_{i,k} = π_k Π_j ρ_{k,j,X_{i,j}} / Σ_{k'} (...)@
+-- - **M-step**: @π_k ← (1/n) Σ_i γ_{i,k}@、
+--   @ρ_{k,j,l} ← Σ_i γ_{i,k} [X_{i,j} = l] / Σ_i γ_{i,k}@
+--
+-- Reference: Linzer-Lewis (2011) "poLCA: An R package for polytomous
+-- variable latent class analysis". J Stat Softw 42(10).
+module Hanalyze.Model.LatentClassAnalysis
+  ( LCAFit (..)
+  , fitLCA
+  ) where
+
+import qualified Numeric.LinearAlgebra as LA
+import qualified System.Random.MWC     as MWC
+import           Control.Monad         (replicateM)
+
+-- ---------------------------------------------------------------------------
+-- 型
+-- ---------------------------------------------------------------------------
+
+data LCAFit = LCAFit
+  { lcaPi              :: !(LA.Vector Double)       -- ^ class mixing weights (length K)
+  , lcaRho             :: ![LA.Matrix Double]       -- ^ per feature: K × L (length J)
+  , lcaResponsibilities :: !(LA.Matrix Double)      -- ^ posterior γ (n × K)
+  , lcaIterations      :: !Int
+  , lcaConverged       :: !Bool
+  , lcaLogLik          :: !Double
+  } deriving (Show)
+
+-- ---------------------------------------------------------------------------
+-- fitLCA
+-- ---------------------------------------------------------------------------
+
+-- | @K@ クラス、 @L@ 水準の LCA を EM で fit。 入力 @X@ は @n@ 行 @J@ 列の
+-- 0-indexed カテゴリ値 (`[[Int]]`、 全要素 ∈ @[0, L-1]@)。
+--
+-- 初期化はランダム (Dirichlet(1) ≈ uniform-on-simplex の近似で MWC を使う)。
+-- 同じ seed で再現性あり。
+fitLCA
+  :: Int                  -- ^ K (classes)
+  -> Int                  -- ^ L (levels per feature)
+  -> [[Int]]              -- ^ X (n × J)
+  -> Int                  -- ^ max EM iterations
+  -> Double               -- ^ tolerance on log-likelihood diff
+  -> MWC.GenIO
+  -> IO LCAFit
+fitLCA k l xRaw maxIter tol gen = do
+  let n = length xRaw
+      j = if n > 0 then length (head xRaw) else 0
+  -- 初期化
+  pi0  <- randomSimplex k gen
+  rho0 <- replicateM j (randomRowStochastic k l gen)
+  let xMat = LA.fromLists [map fromIntegral row | row <- xRaw]
+      go !it !pVec !rhoList !prevLL = do
+        let (gamma, ll) = eStep xMat pVec rhoList l
+            (pNew, rhoNew) = mStep xMat gamma l
+            converged = abs (ll - prevLL) < tol
+        if it >= maxIter || converged
+          then pure (pVec, rhoList, gamma, it, converged, ll)
+          else go (it + 1) pNew rhoNew ll
+  -- 初期 ll は -inf で 1 回目は必ず更新される
+  (pFinal, rhoFinal, gamFinal, iters, conv, llFinal) <-
+    go 0 pi0 rho0 (-1 / 0)
+  pure LCAFit
+    { lcaPi              = pFinal
+    , lcaRho             = rhoFinal
+    , lcaResponsibilities = gamFinal
+    , lcaIterations      = iters
+    , lcaConverged       = conv
+    , lcaLogLik          = llFinal
+    }
+
+-- | E-step: per-row posterior @γ_{i,k}@ と log-likelihood。
+-- log-space で stable: @log P(X_i | k) = Σ_j log ρ_{k, j, X_{i,j}}@
+eStep
+  :: LA.Matrix Double  -- ^ X (n × J)、 0/1/.../L-1 を Double で
+  -> LA.Vector Double  -- ^ π
+  -> [LA.Matrix Double] -- ^ ρ (J 個の K × L)
+  -> Int               -- ^ L
+  -> (LA.Matrix Double, Double)
+eStep xMat pVec rhoList _ =
+  let n = LA.rows xMat
+      k = LA.size pVec
+      logPi = LA.cmap (\p -> log (max 1e-300 p)) pVec
+      logPx_ik i kk =
+        sum [ log (max 1e-300
+                     (LA.atIndex (rhoList !! jj)
+                        (kk, floor (LA.atIndex xMat (i, jj)))))
+            | jj <- [0 .. length rhoList - 1] ]
+      logUnnormRow i = LA.fromList
+        [ LA.atIndex logPi kk + logPx_ik i kk | kk <- [0 .. k - 1] ]
+      rows = [logUnnormRow i | i <- [0 .. n - 1]]
+      logSumExpV v =
+        let mx = LA.maxElement v
+        in mx + log (LA.sumElements (LA.cmap (\x -> exp (x - mx)) v))
+      perRowLL = [logSumExpV r | r <- rows]
+      gammaRows =
+        [ LA.cmap (\x -> exp (x - lse)) r
+        | (r, lse) <- zip rows perRowLL ]
+      gamma = LA.fromRows gammaRows
+      ll = sum perRowLL
+  in (gamma, ll)
+
+-- | M-step: γ から π / ρ を更新。
+mStep
+  :: LA.Matrix Double   -- ^ X (n × J)
+  -> LA.Matrix Double   -- ^ γ (n × K)
+  -> Int                -- ^ L
+  -> (LA.Vector Double, [LA.Matrix Double])
+mStep xMat gamma l =
+  let n   = LA.rows xMat
+      j   = LA.cols xMat
+      k   = LA.cols gamma
+      ones = LA.konst 1 n :: LA.Vector Double
+      gSum = LA.tr gamma LA.#> ones   -- length K = Σ_i γ_{i,k}
+      pNew = LA.scale (1 / fromIntegral n) gSum
+      -- 各特徴 j の ρ (K × L) を再推定
+      rhoFor jj =
+        let countMat = LA.fromLists
+              [ [ sum [ LA.atIndex gamma (i, kk)
+                      | i <- [0 .. n - 1]
+                      , floor (LA.atIndex xMat (i, jj)) == ll ]
+                | ll <- [0 .. l - 1] ]
+              | kk <- [0 .. k - 1] ]
+            denom = LA.cmap (\g -> max 1e-300 g) gSum
+        in LA.fromColumns
+             [ LA.flatten (countMat LA.¿ [c]) / denom
+             | c <- [0 .. l - 1] ]
+      rhoNew = [rhoFor jj | jj <- [0 .. j - 1]]
+  in (pNew, rhoNew)
+
+-- ---------------------------------------------------------------------------
+-- 初期化ヘルパ
+-- ---------------------------------------------------------------------------
+
+-- | 長さ @k@ の simplex 上の uniform ランダム vector (= Dir(1) 近似)。
+-- 単純に @k@ 個の uniform を引いて正規化。
+randomSimplex :: Int -> MWC.GenIO -> IO (LA.Vector Double)
+randomSimplex k gen = do
+  rs <- replicateM k (MWC.uniformR (1e-3, 1.0 :: Double) gen)
+  let s = sum rs
+  pure (LA.fromList (map (/ s) rs))
+
+-- | K × L 行 stochastic matrix のランダム生成。 各行を randomSimplex。
+randomRowStochastic :: Int -> Int -> MWC.GenIO -> IO (LA.Matrix Double)
+randomRowStochastic k l gen = do
+  rows <- replicateM k (randomSimplex l gen)
+  pure (LA.fromRows rows)
diff --git a/src/Hanalyze/Model/LiNGAM/Bootstrap.hs b/src/Hanalyze/Model/LiNGAM/Bootstrap.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Model/LiNGAM/Bootstrap.hs
@@ -0,0 +1,194 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE BangPatterns      #-}
+-- |
+-- Module      : Hanalyze.Model.LiNGAM.Bootstrap
+-- Description : BootstrapLiNGAM (エッジ出現頻度・平均係数・符号一致率による DAG confidence 診断)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- BootstrapLiNGAM: 'DirectLiNGAM' を B 個の bootstrap サンプルに対し fit し、
+--   エッジ毎の出現頻度 (confidence) と平均係数を出す。
+--
+-- ## アルゴリズム
+--
+-- 1. B 回の bootstrap サンプル (行を with-replacement で n 個抽出) を生成
+-- 2. 各サンプルで 'fitDirectLiNGAM' を呼ぶ
+-- 3. エッジ (j → i) ごとに:
+--    * 出現頻度 = (|B[i, j]| > threshold となった bootstrap の数) / B
+--    * 平均係数 = 出現した bootstrap での B[i, j] の平均
+--    * 符号一致率 = sign の合致率 (符号の不安定性を診断)
+--
+-- ## 出力
+--
+-- 'BootstrapResult' は 'edgeProbability' / 'edgeMeanWeight' / 'signConsistency'
+-- の 3 つの p × p 行列を保持。 これらを使って 「確からしい因果関係 のみ
+-- 採用する DAG」 を構築できる。
+--
+-- ## リファレンス
+--
+-- Shimizu (2014) "Bayesian estimation of causal direction in acyclic structural
+-- equation models with individual-specific confounder variables and
+-- non-Gaussian distributions" (BootstrapLiNGAM の運用紹介)。
+-- Python 実装は cdt15/lingam の `lingam/bootstrap.py`。
+module Hanalyze.Model.LiNGAM.Bootstrap
+  ( BootstrapConfig (..)
+  , BootstrapResult (..)
+  , defaultBootstrapConfig
+  , fitBootstrapLiNGAM
+  , fitBootstrapLiNGAMPure
+  , confidenceDAG
+  ) where
+
+import qualified Numeric.LinearAlgebra      as LA
+import qualified System.Random.MWC          as MWC
+import           Control.Monad              (replicateM)
+import           Control.Monad.ST           (runST)
+import qualified Data.Vector                as V
+
+import qualified Hanalyze.Model.LiNGAM.Direct as DL
+import qualified Hanalyze.Model.DAG           as DAG
+
+-- ===========================================================================
+-- 設定
+-- ===========================================================================
+
+data BootstrapConfig = BootstrapConfig
+  { bcNumBootstraps :: !Int
+    -- ^ B (resample 回数)、 default 100
+  , bcDirectCfg     :: !DL.DirectLiNGAMConfig
+    -- ^ 各 bootstrap で使う DirectLiNGAM 設定
+  , bcEdgeThreshold :: !Double
+    -- ^ |B[i, j]| > thr のとき「エッジあり」 と数える、 default 0.05
+  , bcSeed          :: !(Maybe Int)
+  } deriving (Show)
+
+defaultBootstrapConfig :: BootstrapConfig
+defaultBootstrapConfig = BootstrapConfig
+  { bcNumBootstraps = 100
+  , bcDirectCfg     = DL.defaultDirectLiNGAMConfig
+  , bcEdgeThreshold = 0.05
+  , bcSeed          = Just 42
+  }
+
+-- ===========================================================================
+-- 結果
+-- ===========================================================================
+
+data BootstrapResult = BootstrapResult
+  { brEdgeProbability :: !(LA.Matrix Double)
+    -- ^ p × p、 (i, j) = エッジ j → i の出現頻度 (0..1)
+  , brEdgeMeanWeight  :: !(LA.Matrix Double)
+    -- ^ p × p、 (i, j) = エッジが出現した bootstrap における B[i, j] の平均
+  , brSignConsistency :: !(LA.Matrix Double)
+    -- ^ p × p、 (i, j) = エッジが出現した bootstrap での符号合致率
+    --   (1.0 = 全部同符号、 0.5 = 半々)
+  , brNumBootstraps   :: !Int
+  } deriving (Show)
+
+-- ===========================================================================
+-- 主実装
+-- ===========================================================================
+
+fitBootstrapLiNGAM :: BootstrapConfig -> LA.Matrix Double -> IO BootstrapResult
+fitBootstrapLiNGAM cfg xs = do
+  let !n = LA.rows xs
+      !p = LA.cols xs
+      !b = bcNumBootstraps cfg
+      !thr = bcEdgeThreshold cfg
+  gen <- case bcSeed cfg of
+    Just s  -> MWC.initialize (V.fromList [fromIntegral s])
+    Nothing -> MWC.createSystemRandom
+  -- 各 bootstrap の B 行列を集める
+  bMats <- replicateM b $ do
+    idxs <- V.replicateM n (MWC.uniformR (0, n - 1) gen)
+    let !resample = xs LA.? V.toList idxs
+        !fit      = DL.fitDirectLiNGAM (bcDirectCfg cfg) resample
+    pure (DL.dlB fit)
+  let !probMat = computeEdgeProbability thr p bMats
+      !meanMat = computeEdgeMeanWeight  thr p bMats
+      !signMat = computeSignConsistency thr p bMats
+  pure BootstrapResult
+    { brEdgeProbability = probMat
+    , brEdgeMeanWeight  = meanMat
+    , brSignConsistency = signMat
+    , brNumBootstraps   = b
+    }
+
+-- | 'fitBootstrapLiNGAM' の **seed 純粋版** (Phase 77.C・@df |->@ 用)。 @bcSeed@ (既定 42・
+--   'Nothing' は 42 fallback) で 'runST'+MWC。 同 seed で IO 版とビット一致 (乱数列は monad 非依存)。
+fitBootstrapLiNGAMPure :: BootstrapConfig -> LA.Matrix Double -> BootstrapResult
+fitBootstrapLiNGAMPure cfg xs = runST $ do
+  let !n = LA.rows xs
+      !p = LA.cols xs
+      !b = bcNumBootstraps cfg
+      !thr = bcEdgeThreshold cfg
+  gen <- MWC.initialize (V.fromList [fromIntegral (maybe 42 id (bcSeed cfg))])
+  bMats <- replicateM b $ do
+    idxs <- V.replicateM n (MWC.uniformR (0, n - 1) gen)
+    let !resample = xs LA.? V.toList idxs
+    pure (DL.dlB (DL.fitDirectLiNGAM (bcDirectCfg cfg) resample))
+  pure BootstrapResult
+    { brEdgeProbability = computeEdgeProbability thr p bMats
+    , brEdgeMeanWeight  = computeEdgeMeanWeight  thr p bMats
+    , brSignConsistency = computeSignConsistency thr p bMats
+    , brNumBootstraps   = b
+    }
+
+-- | 「出現頻度 ≥ probThreshold かつ符号合致率 ≥ signThreshold」 のエッジだけ
+--   採用した DAG を構築。 重みは 'brEdgeMeanWeight' を使う。
+confidenceDAG
+  :: Double           -- 出現頻度閾値 (例 0.7)
+  -> Double           -- 符号合致率閾値 (例 0.8)
+  -> BootstrapResult
+  -> DAG.DAG
+confidenceDAG probThr signThr res =
+  let !p     = LA.rows (brEdgeProbability res)
+      f i j
+        | i == j                                     = 0
+        | LA.atIndex (brEdgeProbability res) (i, j) < probThr = 0
+        | LA.atIndex (brSignConsistency res) (i, j) < signThr = 0
+        | otherwise = LA.atIndex (brEdgeMeanWeight res) (i, j)
+      w = LA.build (p, p) (\i j -> f (round i) (round j) :: Double)
+  in DAG.mkDAG w
+
+-- ===========================================================================
+-- 内部: 集計
+-- ===========================================================================
+
+computeEdgeProbability :: Double -> Int -> [LA.Matrix Double] -> LA.Matrix Double
+computeEdgeProbability thr p bMats =
+  let !n = fromIntegral (length bMats) :: Double
+      f i j
+        | i == j    = 0
+        | otherwise =
+            let !cnt = length [ () | b <- bMats
+                                   , abs (LA.atIndex b (i, j)) > thr ]
+            in fromIntegral cnt / n
+  in LA.build (p, p) (\i j -> f (round i) (round j) :: Double)
+
+computeEdgeMeanWeight :: Double -> Int -> [LA.Matrix Double] -> LA.Matrix Double
+computeEdgeMeanWeight thr p bMats =
+  let f i j
+        | i == j    = 0
+        | otherwise =
+            let vs = [ LA.atIndex b (i, j)
+                     | b <- bMats
+                     , abs (LA.atIndex b (i, j)) > thr ]
+            in if null vs then 0 else sum vs / fromIntegral (length vs)
+  in LA.build (p, p) (\i j -> f (round i) (round j) :: Double)
+
+computeSignConsistency :: Double -> Int -> [LA.Matrix Double] -> LA.Matrix Double
+computeSignConsistency thr p bMats =
+  let f i j
+        | i == j    = 0
+        | otherwise =
+            let vs = [ LA.atIndex b (i, j)
+                     | b <- bMats
+                     , abs (LA.atIndex b (i, j)) > thr ]
+            in if null vs then 0
+               else let !nPos = length (filter (> 0) vs)
+                        !nNeg = length (filter (< 0) vs)
+                        !tot  = nPos + nNeg
+                    in if tot == 0 then 0
+                       else fromIntegral (max nPos nNeg) / fromIntegral tot
+  in LA.build (p, p) (\i j -> f (round i) (round j) :: Double)
diff --git a/src/Hanalyze/Model/LiNGAM/Direct.hs b/src/Hanalyze/Model/LiNGAM/Direct.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Model/LiNGAM/Direct.hs
@@ -0,0 +1,299 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE BangPatterns      #-}
+-- |
+-- Module      : Hanalyze.Model.LiNGAM.Direct
+-- Description : DirectLiNGAM (Shimizu 2011) による線形非ガウシアン因果探索
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- DirectLiNGAM (Shimizu et al. 2011) による線形非ガウシアン因果探索。
+--
+-- ## 前提モデル
+--
+-- 観測 X ∈ ℝ^(n×p) が **線形 + acyclic + 非ガウシアン独立 noise** な構造方程式
+-- モデル X = B X + e に従う (B は適切な行/列順列で下三角化可能、 e の各成分は
+-- 互いに独立かつ非ガウシアン)。 このとき DirectLiNGAM は ICA を経由せず、
+-- 残差独立性 (差分相互情報量) の最大化で因果順序を 1 変数ずつ確定する。
+--
+-- ## アルゴリズム概要
+--
+-- 1. 候補集合 U = {0..p-1}、 因果順序 K = []
+-- 2. p 回 loop:
+--    a. searchCausalOrder で M(m) = -Σ_{j∈U,j≠m} min(0, ΔMI(x_m,x_j,r_{mj},r_{jm}))²
+--       を最大化する m を選ぶ
+--    b. U の各 i ≠ m について x_i ← residual(x_i, x_m) (m で残差化)
+--    c. K に m を追加、 U から m を除く
+-- 3. K から B 行列を OLS で組み上げる (causal order に従い順に回帰)
+--
+-- ## ΔMI (差分相互情報量)
+--
+-- 標準化後の x_i, x_j と残差 r_{ij}, r_{ji} (互いに片方を片方で回帰した残差)
+-- に対し:
+--
+-- > ΔMI(x_i, x_j, r_{ij}, r_{ji}) = [H(x_j) + H(r_{ij}/σ_{r_{ij}})]
+-- >                                - [H(x_i) + H(r_{ji}/σ_{r_{ji}})]
+--
+-- H は Hyvärinen (1998) の maximum entropy 近似:
+--
+-- > H(u) = (1 + log 2π)/2 - k1·(E[log cosh u] - γ)² - k2·(E[u·exp(-u²/2)])²
+-- > k1 = 79.047, k2 = 7.4129, γ = 0.37457
+--
+-- ## リファレンス
+--
+-- Shimizu et al. (2011) "DirectLiNGAM: A direct method for learning a linear
+-- non-Gaussian structural equation model", JMLR 12. Python 実装は
+-- cdt15/lingam の `lingam/direct_lingam.py` で動作対応を確認した。
+--
+-- ## 落とし穴メモ
+--
+-- * 観測変数が **完全ガウシアン** だと ΔMI ≈ 0 となり順序が一意決まらない。
+--   ガウシアン応答には Phase 30 の causal inference (介入効果) や PC algorithm
+--   等を使う
+-- * **n < 100** だと entropy の sample 推定が不安定。 n ≥ 200 推奨
+-- * 行列 B は **causal order の根本変数を 0 行目** に置く慣習。 出力の
+--   dlB[K[j], K[i]] = β_i (i < j) で表される (= 影響先 ← 影響元 規約)
+module Hanalyze.Model.LiNGAM.Direct
+  ( DirectLiNGAMConfig (..)
+  , DirectLiNGAMFit (..)
+  , defaultDirectLiNGAMConfig
+  , fitDirectLiNGAM
+  , dlDAG
+  -- helpers (re-export 不要時は internal だが、 単体テスト用に公開)
+  , entropyApprox
+  , diffMutualInfo
+  , olsResidual
+  , standardize
+  ) where
+
+import qualified Numeric.LinearAlgebra as LA
+import           Data.List             (foldl')
+
+import qualified Hanalyze.Model.DAG    as DAG
+
+-- ===========================================================================
+-- 公開型
+-- ===========================================================================
+
+-- | DirectLiNGAM の設定。
+data DirectLiNGAMConfig = DirectLiNGAMConfig
+  { dlcPruneThr :: !Double
+    -- ^ |B_ij| < 'dlcPruneThr' は隣接行列で 0 と扱う。 default 0.05。
+  } deriving (Show)
+
+defaultDirectLiNGAMConfig :: DirectLiNGAMConfig
+defaultDirectLiNGAMConfig = DirectLiNGAMConfig
+  { dlcPruneThr = 0.05
+  }
+
+-- | DirectLiNGAM の推定結果。
+data DirectLiNGAMFit = DirectLiNGAMFit
+  { dlOrder     :: ![Int]
+    -- ^ 推定 causal order (topological)。 K[0] が最も外生的、 K[p-1] が
+    --   最も末端 (どの変数からも影響を受ける可能性のある変数)
+  , dlB         :: !(LA.Matrix Double)
+    -- ^ 構造方程式係数行列 (p × p)。 X_i = Σ_j dlB[i, j] · X_j + e_i。
+    --   causal order に従い適切な行/列順列で下三角化可能
+  , dlAdjacency :: !(LA.Matrix Double)
+    -- ^ |dlB| > dlcPruneThr の 0/1 マスク
+  , dlResiduals :: !(LA.Matrix Double)
+    -- ^ 各サンプルの推定残差 e_i (n × p)。 独立性検定の事後評価に使う
+  } deriving (Show)
+
+-- ===========================================================================
+-- 主アルゴリズム
+-- ===========================================================================
+
+-- | DirectLiNGAM を fit する。 X は n × p 行列 (各列 = 1 変数)。
+--
+-- 計算量: 因果順序探索 O(p² · n) per iteration × p iterations = O(p³ · n)
+-- (entropy 評価 + 残差化が dominant)。
+-- | 'DirectLiNGAMFit' を 'Hanalyze.Model.DAG.DAG' 表現に変換 (threshold は
+--   元の 'dlcPruneThr' を再利用)。
+dlDAG :: DirectLiNGAMConfig -> DirectLiNGAMFit -> DAG.DAG
+dlDAG cfg fit = DAG.fromBMatrix (dlcPruneThr cfg) (dlB fit)
+
+fitDirectLiNGAM :: DirectLiNGAMConfig -> LA.Matrix Double -> DirectLiNGAMFit
+fitDirectLiNGAM cfg xs =
+  let !p = LA.cols xs
+      !n = LA.rows xs
+      -- 各列を Vector に分解した可変リスト (residualize 用)
+      cols0 :: [LA.Vector Double]
+      cols0 = [ LA.flatten (xs LA.¿ [j]) | j <- [0 .. p - 1] ]
+      -- 主 loop: cols / activeU / order を順次更新
+      (order, _finalCols) = causalOrderLoop cols0 [0 .. p - 1] []
+      -- 元の X から causal order に従い B 行列を OLS で組み立て
+      bMat    = estimateB xs order
+      adjMat  = buildAdjacency (dlcPruneThr cfg) bMat
+      -- 残差: e = X - X·B^T (行ベクトル view、 単純な線形変換)
+      resid   = xs - xs LA.<> LA.tr bMat
+      _ = n  -- shadow warn 防止
+  in DirectLiNGAMFit
+       { dlOrder     = order
+       , dlB         = bMat
+       , dlAdjacency = adjMat
+       , dlResiduals = resid
+       }
+
+-- | causal order を 1 つずつ確定する主ループ。
+--   引数:
+--     cols    : 現在の (残差化された) 列ベクトルのリスト (length p、 元 index で並ぶ)
+--     activeU : まだ確定していない元 index のリスト
+--     orderRev: これまでに確定した順序 (逆順、 後で reverse)
+causalOrderLoop
+  :: [LA.Vector Double]   -- 現状の列ベクトル
+  -> [Int]                -- active 集合
+  -> [Int]                -- 確定済 (逆順)
+  -> ([Int], [LA.Vector Double])
+causalOrderLoop cols activeU orderRev
+  | null activeU = (reverse orderRev, cols)
+  | length activeU == 1 =
+      (reverse (head activeU : orderRev), cols)
+  | otherwise =
+      let !m = searchCausalOrder cols activeU
+          xm = cols !! m
+          -- m 以外の active で残差化
+          colsNew = [ if j `elem` activeU && j /= m
+                        then olsResidual (cols !! j) xm
+                        else cols !! j
+                    | j <- [0 .. length cols - 1] ]
+          activeNew = [ j | j <- activeU, j /= m ]
+      in causalOrderLoop colsNew activeNew (m : orderRev)
+
+-- | 候補集合 activeU から、 「最も外生的 (= 他から残差化された後の独立性が
+--   崩れにくい)」 index を 1 つ返す。
+--   M(m) = -Σ_{j∈U, j≠m} min(0, ΔMI(x_m,x_j,r_{mj},r_{jm}))² を最大化。
+searchCausalOrder :: [LA.Vector Double] -> [Int] -> Int
+searchCausalOrder cols activeU =
+  let !scores = [ (m, score m) | m <- activeU ]
+      score m =
+        let xm = cols !! m
+            xmStd = standardize xm
+            contribs =
+              [ let xj = cols !! j
+                    xjStd = standardize xj
+                    rmj = olsResidual xmStd xjStd   -- xm を xj で残差化
+                    rjm = olsResidual xjStd xmStd   -- xj を xm で残差化
+                    dmi = diffMutualInfo xmStd xjStd rmj rjm
+                in min 0 dmi ** 2
+              | j <- activeU, j /= m ]
+        in negate (sum contribs)
+  in fst (foldl' pickMax (head scores) (tail scores))
+  where
+    pickMax acc@(_, s0) cur@(_, s1)
+      | s1 > s0   = cur
+      | otherwise = acc
+
+-- | 差分相互情報量 ΔMI = [H(xj) + H(rij/σ)] - [H(xi) + H(rji/σ)]。
+--   入力 xi/xj は標準化済、 rij/rji は **標準化前**の残差。
+diffMutualInfo
+  :: LA.Vector Double  -- xi (標準化済)
+  -> LA.Vector Double  -- xj (標準化済)
+  -> LA.Vector Double  -- rij = xi - β xj 残差
+  -> LA.Vector Double  -- rji = xj - β xi 残差
+  -> Double
+diffMutualInfo xi xj rij rji =
+  let !hxi  = entropyApprox xi
+      !hxj  = entropyApprox xj
+      !srij = stdSafe rij
+      !srji = stdSafe rji
+      !hrij = entropyApprox (LA.scale (1 / srij) rij)
+      !hrji = entropyApprox (LA.scale (1 / srji) rji)
+  in (hxj + hrij) - (hxi + hrji)
+  where
+    stdSafe v =
+      let s = LA.norm_2 (v - LA.scalar (LA.sumElements v / fromIntegral (LA.size v)))
+                / sqrt (fromIntegral (LA.size v))
+      in if s > 1e-12 then s else 1.0
+
+-- | Hyvärinen (1998) maximum entropy 近似:
+--   H(u) = (1 + log 2π)/2 - k1·(E[log cosh u] - γ)² - k2·(E[u·exp(-u²/2)])²
+--   u は事前に標準化されていることが前提。
+entropyApprox :: LA.Vector Double -> Double
+entropyApprox u =
+  let !k1    = 79.047
+      !k2    = 7.4129
+      !gamma = 0.37457
+      !n     = fromIntegral (LA.size u) :: Double
+      !logCosh = LA.sumElements (LA.cmap (\v -> log (cosh v)) u) / n
+      !uExp    = LA.sumElements (u * LA.cmap (\v -> exp (-v * v / 2)) u) / n
+  in (1 + log (2 * pi)) / 2
+     - k1 * (logCosh - gamma) ** 2
+     - k2 * uExp ** 2
+
+-- | OLS による残差: r = xi - (Cov(xi,xj) / Var(xj)) · xj
+olsResidual :: LA.Vector Double -> LA.Vector Double -> LA.Vector Double
+olsResidual xi xj =
+  let !n   = fromIntegral (LA.size xi) :: Double
+      !mxi = LA.sumElements xi / n
+      !mxj = LA.sumElements xj / n
+      !ci  = xi - LA.scalar mxi
+      !cj  = xj - LA.scalar mxj
+      !cov = ci `LA.dot` cj / n
+      !var = cj `LA.dot` cj / n
+      !beta = if var > 1e-12 then cov / var else 0
+  in xi - LA.scale beta xj
+
+-- | 中心化 + 標準偏差で割る (zero-mean, unit-variance)。
+standardize :: LA.Vector Double -> LA.Vector Double
+standardize v =
+  let !n  = fromIntegral (LA.size v) :: Double
+      !mu = LA.sumElements v / n
+      !c  = v - LA.scalar mu
+      !s  = sqrt (c `LA.dot` c / n)
+      !sd = if s > 1e-12 then s else 1.0
+  in LA.scale (1 / sd) c
+
+-- ===========================================================================
+-- B 行列 + 隣接行列
+-- ===========================================================================
+
+-- | causal order に従い B 行列を OLS で組み立てる。
+--   B[K[j], K[i]] = OLS 回帰 X[:,K[j]] ~ X[:,K[0..j-1]] の i 番目係数。
+estimateB :: LA.Matrix Double -> [Int] -> LA.Matrix Double
+estimateB xs order =
+  let !p    = LA.cols xs
+      bRows = [ buildRow j | j <- [0 .. p - 1] ]
+      buildRow j =
+        let kj   = order !! j
+            -- 影響元候補: order の j より前
+            parents = take j order
+        in if null parents
+             then LA.fromList (replicate p 0)
+             else
+               let parentMat = LA.fromColumns
+                     [ LA.flatten (xs LA.¿ [pIdx]) | pIdx <- parents ]
+                   target = LA.flatten (xs LA.¿ [kj])
+                   beta = olsBeta parentMat target
+                   coefVec = replicate p 0
+                   -- beta を parent 位置に散布
+                   updates = zip parents (LA.toList beta)
+                   filled = foldl' (\acc (idx, v) -> setAt acc idx v) coefVec updates
+               in LA.fromList filled
+      -- 行は K の順序、 列は元 variable index。
+      -- bRows[j] は variable K[j] の行ベクトル → reorder で元 variable index 順に
+      origOrderMat = LA.fromRows
+        [ bRows !! posInOrder i | i <- [0 .. p - 1] ]
+      posInOrder i = case lookup i (zip order [0 ..]) of
+        Just k  -> k
+        Nothing -> 0   -- unreachable
+  in origOrderMat
+
+-- | OLS 係数: β = (XᵀX)⁻¹ Xᵀy
+olsBeta :: LA.Matrix Double -> LA.Vector Double -> LA.Vector Double
+olsBeta x y =
+  let xtx = LA.tr x LA.<> x
+      xty = LA.tr x LA.#> y
+  in LA.flatten (LA.linearSolveLS xtx (LA.asColumn xty))
+
+setAt :: [a] -> Int -> a -> [a]
+setAt xs i v = take i xs ++ [v] ++ drop (i + 1) xs
+
+-- | |B_ij| > threshold で 1、 以外 0 の隣接行列。 対角は 0 に固定。
+buildAdjacency :: Double -> LA.Matrix Double -> LA.Matrix Double
+buildAdjacency thr b =
+  let !p = LA.rows b
+      f i j
+        | i == j    = 0
+        | abs (LA.atIndex b (i, j)) > thr = 1
+        | otherwise = 0
+  in LA.build (p, p) (\i j -> f (round i) (round j) :: Double)
diff --git a/src/Hanalyze/Model/LiNGAM/ICA.hs b/src/Hanalyze/Model/LiNGAM/ICA.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Model/LiNGAM/ICA.hs
@@ -0,0 +1,245 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE BangPatterns      #-}
+-- |
+-- Module      : Hanalyze.Model.LiNGAM.ICA
+-- Description : ICA-LiNGAM (Shimizu 2006、原典版) by FastICA + Hungarian 順列
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- ICA-LiNGAM (Shimizu et al. 2006、 LiNGAM の原典版) by FastICA。
+--
+-- ## アルゴリズム
+--
+-- 1. 観測 X (n × p) に対し FastICA で **分離行列 W** (= ICA unmixing) を求める
+--    (元座標、 'Hanalyze.Math.ICA.icaUnmixing')
+-- 2. **A = pinv(W)** を計算 (X = S · Aᵀ + mean)
+-- 3. **行/列順列で下三角化**:
+--    a. A の絶対値の **逆数** をコスト行列とし、 行・列順列で対角要素を
+--       絶対値最大に揃える Hungarian-like (本実装は近似貪欲)
+--    b. 順列適用後の A を対角要素で正規化、 B = I - A_perm⁻¹
+--    c. B の下三角化のための **行順列** を別途決定 (= causal order)
+-- 4. B 行列を pruning して隣接行列を返す
+--
+-- ## DirectLiNGAM との違い
+--
+-- DirectLiNGAM は ICA 不要で残差独立性 + 1 変数ずつ確定。 ICA-LiNGAM は ICA
+-- (FastICA) で全成分を同時推定 → 順列で因果順序を後付けで決める。 ICA の
+-- 収束性に依存するが、 因子数が多いときは並列度で有利な場合がある。
+--
+-- 行/列順列は **Hungarian (Kuhn-Munkres, O(p³))** で大域最適化する
+-- ('Hanalyze.Math.Hungarian')。 cdt15/lingam の Python 実装は
+-- @scipy.optimize.linear_sum_assignment(1 / |W|)@ で同等のことをしており、
+-- コスト関数も @1 / (|W| + ε)@ で揃えている。 旧来の貪欲版 ('greedyAssignRows')
+-- は @ilcUseHungarian = False@ で復元可能 (回帰確認・ベンチ比較用)。
+--
+-- ## リファレンス
+--
+-- Shimizu et al. (2006) "A Linear Non-Gaussian Acyclic Model for Causal
+-- Discovery", JMLR 7. Python 実装は cdt15/lingam の `lingam/ica_lingam.py`。
+module Hanalyze.Model.LiNGAM.ICA
+  ( ICALiNGAMConfig (..)
+  , ICALiNGAMFit (..)
+  , fitICALiNGAMPure
+  , defaultICALiNGAMConfig
+  , fitICALiNGAM
+  , ilDAG
+  ) where
+
+import qualified Numeric.LinearAlgebra as LA
+import qualified Data.Vector.Unboxed   as VU
+import           Data.List             (sortBy)
+import           Data.Ord              (comparing, Down (..))
+
+import qualified Hanalyze.Math.ICA          as ICA
+import qualified Hanalyze.Math.Hungarian    as Hung
+import qualified Hanalyze.Model.DAG         as DAG
+
+-- ===========================================================================
+-- 設定 / 結果
+-- ===========================================================================
+
+data ICALiNGAMConfig = ICALiNGAMConfig
+  { ilcPruneThr      :: !Double
+  , ilcICACfg        :: !ICA.ICAConfig
+  , ilcUseHungarian  :: !Bool
+    -- ^ True: 行順列を Hungarian (O(p³)) で大域最適化 (default、 推奨)。
+    --   False: 旧来の貪欲版を使う (回帰比較・ベンチ用)。
+  } deriving (Show)
+
+defaultICALiNGAMConfig :: ICALiNGAMConfig
+defaultICALiNGAMConfig = ICALiNGAMConfig
+  { ilcPruneThr     = 0.05
+  , ilcICACfg       = ICA.defaultICAConfig
+  , ilcUseHungarian = True
+  }
+
+data ICALiNGAMFit = ICALiNGAMFit
+  { ilOrder      :: ![Int]
+  , ilB          :: !(LA.Matrix Double)
+  , ilAdjacency  :: !(LA.Matrix Double)
+  , ilICAResult  :: !ICA.ICAResult
+  } deriving (Show)
+
+-- ===========================================================================
+-- 主実装
+-- ===========================================================================
+
+fitICALiNGAM :: ICALiNGAMConfig -> LA.Matrix Double -> IO ICALiNGAMFit
+fitICALiNGAM cfg x = do
+  ica <- ICA.fitICA (ilcICACfg cfg) x
+  pure (assembleICALiNGAM cfg ica)
+
+-- | 'fitICALiNGAM' の **seed 純粋版** (Phase 77.C・@df |->@ 用)。 'fitICAPure' (seed) で
+--   FastICA を回す。 同 seed で IO 版とビット一致。
+fitICALiNGAMPure :: ICALiNGAMConfig -> LA.Matrix Double -> ICALiNGAMFit
+fitICALiNGAMPure cfg x = assembleICALiNGAM cfg (ICA.fitICAPure (ilcICACfg cfg) x)
+
+-- | ICA 結果 → 'ICALiNGAMFit' の純粋組み立て (行順列 → 正規化 → 下三角化 → adjacency)。
+assembleICALiNGAM :: ICALiNGAMConfig -> ICA.ICAResult -> ICALiNGAMFit
+assembleICALiNGAM cfg ica =
+  let !w = ICA.icaUnmixing ica      -- (p × p)
+      !p = LA.rows w
+      -- step 3a: 対角絶対値最大化の行順列を決定。 Hungarian は大域最適、
+      -- 貪欲は p > 10 でしばしば劣化する (cdt15/lingam も Hungarian 採用)。
+      !rowPerm    = if ilcUseHungarian cfg
+                      then hungarianAssignRows w
+                      else greedyAssignRows w
+      !wPerm1     = permuteRows w rowPerm
+      -- step 3b: 各行を対角で正規化
+      !wNorm      = normalizeDiag wPerm1
+      -- B' = I - W_norm
+      !bPrime     = LA.ident p - wNorm
+      -- step 3c: bPrime の行順列を causal order に並べる
+      -- 下三角化: 順列の絶対値和が下三角寄りになるよう貪欲に並べ替え
+      !causal     = causalOrderFromTriangle bPrime
+      -- causal order で再順列した B を返す
+      !bReorder   = permuteRowsCols bPrime causal causal
+      -- 元 variable index に戻す
+      -- bPrime[i, j] は permuted index 上の値、 rowPerm を逆引きする必要あり
+      !bFinal     = restoreOriginalIndex p bPrime rowPerm causal
+      !adj        = adjMatrix (ilcPruneThr cfg) bFinal
+      _ = bReorder  -- 内部debug 用、 未使用
+  in ICALiNGAMFit
+    { ilOrder      = mapPerm causal rowPerm
+    , ilB          = bFinal
+    , ilAdjacency  = adj
+    , ilICAResult  = ica
+    }
+
+-- | DAG への変換
+ilDAG :: ICALiNGAMConfig -> ICALiNGAMFit -> DAG.DAG
+ilDAG cfg fit = DAG.fromBMatrix (ilcPruneThr cfg) (ilB fit)
+
+-- ===========================================================================
+-- 内部: 順列ヘルパ
+-- ===========================================================================
+
+-- | Hungarian による行順列決定。 コスト C[i, j] = 1 / (|W[i, j]| + ε) で
+--   'Hung.hungarianMin' を呼び、 row i → col j の割当を得てから
+--   perm[j] = i に反転する (col j に row i を置く)。
+--   cdt15/lingam の Python 実装 (scipy linear_sum_assignment(1/|W|)) と同型。
+hungarianAssignRows :: LA.Matrix Double -> [Int]
+hungarianAssignRows w =
+  let p        = LA.rows w
+      eps      = 1.0e-12
+      cost     = LA.build (p, p)
+                   (\i j -> 1.0 / (abs (LA.atIndex w (round i, round j)) + eps)
+                            :: Double)
+      assign   = Hung.hungarianMin cost  -- assign[i] = j (row i → col j)
+      pairs    = sortBy (comparing fst)
+                   [ (assign VU.! i, i) | i <- [0 .. p - 1] ]
+                                          -- (col j, row i)
+  in map snd pairs                        -- perm[j] = i
+
+-- | 行順列の貪欲決定: 各列の絶対値最大要素を見て、 行と列を 1-1 対応させる
+--   greedy assignment (Hungarian の近似版)。 戻り値 perm の意味:
+--   「permuted index j に元 row index perm[j] を持ってくる」 (= rows ordering)。
+greedyAssignRows :: LA.Matrix Double -> [Int]
+greedyAssignRows w =
+  let p = LA.rows w
+      -- 候補を (元 row i, 元 col j, abs value) として絶対値降順に並べる
+      candidates :: [((Int, Int), Double)]
+      candidates = sortBy (comparing (Down . snd))
+        [ ((i, j), abs (LA.atIndex w (i, j)))
+        | i <- [0 .. p - 1], j <- [0 .. p - 1] ]
+      -- 貪欲: row と col を使用済にしながら (col j に row i を割当て)
+      assign :: [Int] -> [Int] -> [((Int, Int), Double)] -> [(Int, Int)]
+      assign _        _        []                = []
+      assign usedRows usedCols (((i, j), _):rest)
+        | i `elem` usedRows || j `elem` usedCols = assign usedRows usedCols rest
+        | otherwise = (j, i) : assign (i:usedRows) (j:usedCols) rest
+      pairs    = assign [] [] candidates           -- (col j, row i) のペア
+      sortedPairs = sortBy (comparing fst) pairs   -- col 昇順
+      perm        = map snd sortedPairs            -- perm[j] = i
+  in if length perm == p
+       then perm
+       else [0 .. p - 1]   -- fallback
+
+-- | 行を perm で並べ替える (perm[i] = 元 index)。
+permuteRows :: LA.Matrix Double -> [Int] -> LA.Matrix Double
+permuteRows m perm = m LA.? perm
+
+-- | 各行を対角要素で正規化する (W → W / diag(W))。
+normalizeDiag :: LA.Matrix Double -> LA.Matrix Double
+normalizeDiag w =
+  let p = LA.rows w
+      diags = [ LA.atIndex w (i, i) | i <- [0 .. p - 1] ]
+      f i j =
+        let d = diags !! i
+            v = LA.atIndex w (i, j)
+        in if abs d > 1e-12 then v / d else v
+  in LA.build (p, p) (\i j -> f (round i) (round j) :: Double)
+
+-- | B から下三角化のための行順列を貪欲に決める。
+--   各行の非零要素数が少ない行 (根) を先に置く戦略。
+causalOrderFromTriangle :: LA.Matrix Double -> [Int]
+causalOrderFromTriangle b =
+  let p = LA.rows b
+      scoreRow i =
+        sum [ abs (LA.atIndex b (i, j))
+            | j <- [0 .. p - 1], j /= i ]
+      sorted = sortBy (comparing snd)
+                 [ (i, scoreRow i) | i <- [0 .. p - 1] ]
+  in map fst sorted
+
+-- | 行と列を同じ perm で並び替え (DAG 構造を保つ)。
+permuteRowsCols :: LA.Matrix Double -> [Int] -> [Int] -> LA.Matrix Double
+permuteRowsCols m rp cp =
+  let mR = m LA.? rp
+      mTr = LA.tr mR LA.? cp
+  in LA.tr mTr
+
+-- | 元の variable index に戻す。
+--   permuted index 上での B → original index 上での B。
+restoreOriginalIndex
+  :: Int
+  -> LA.Matrix Double    -- B_prime (permuted index 上)
+  -> [Int]               -- rowPerm: permuted_i ← original_rowPerm[i]
+  -> [Int]               -- causal: permuted index 上での causal order
+  -> LA.Matrix Double
+restoreOriginalIndex p bPrime rowPerm _causal =
+  -- bPrime は rowPerm で permuted されている。 inverse perm で元に戻す。
+  let invPerm = invertPerm rowPerm
+      f i j   = LA.atIndex bPrime (invPerm !! i, invPerm !! j)
+  in LA.build (p, p) (\i j -> f (round i) (round j) :: Double)
+
+invertPerm :: [Int] -> [Int]
+invertPerm perm =
+  let p = length perm
+      pairs = zip perm [0 ..]
+      sorted = sortBy (comparing fst) pairs
+  in map snd sorted ++ replicate (p - length sorted) 0
+
+-- | original index 上での causal order (= permuted causal を rowPerm で戻す)
+mapPerm :: [Int] -> [Int] -> [Int]
+mapPerm causal rowPerm = map (rowPerm !!) causal
+
+-- | adjacency 行列 (|B| > thr のマスク)
+adjMatrix :: Double -> LA.Matrix Double -> LA.Matrix Double
+adjMatrix thr b =
+  let p = LA.rows b
+      f i j
+        | i == j                          = 0
+        | abs (LA.atIndex b (i, j)) > thr = 1
+        | otherwise                       = 0
+  in LA.build (p, p) (\i j -> f (round i) (round j) :: Double)
diff --git a/src/Hanalyze/Model/LiNGAM/MultiGroup.hs b/src/Hanalyze/Model/LiNGAM/MultiGroup.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Model/LiNGAM/MultiGroup.hs
@@ -0,0 +1,192 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE BangPatterns      #-}
+-- |
+-- Module      : Hanalyze.Model.LiNGAM.MultiGroup
+-- Description : MultiGroupLiNGAM (Shimizu 2012、群間で共通 DAG 構造・係数値のみ異なる LiNGAM 拡張)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- MultiGroupLiNGAM (Shimizu 2012): 複数群 (group) で **共通の DAG 構造** を
+--   仮定し、 群間で係数値は異なる可能性を許す LiNGAM 拡張。
+--
+-- ## モデル
+--
+-- 群 g = 1..G について、 観測 X^(g) は同じ causal order に従う SEM:
+--
+-- > X^(g) = B^(g) · X^(g) + e^(g)
+--
+-- 各 B^(g) の非零パターン (= DAG 構造) は **全群共通** を仮定するが、 値は
+-- 群ごとに異なってよい。 これは半導体現場の「異なる工場 / 装置号機 / 世代で
+-- 同じ因果構造、 効き量だけ違う」 という想定とマッチする。
+--
+-- ## アルゴリズム
+--
+-- 1. 各群 X^(g) について 'fitDirectLiNGAM' を独立に実行 → B^(g)、 K^(g)
+-- 2. 全群の K^(g) を集約して **多数決で共通 causal order** を確定
+--    (本実装: 各位置 j の頻度最大ノードを選び、 不一致時は位置 j の総合的
+--    平均スコアを再計算)
+-- 3. 共通 order に従い、 各群で再度 OLS で B^(g) を組み直す
+-- 4. **共通 adjacency**: 各群で |B^(g)[i, j]| > thr となるエッジ数が
+--    全群のうち過半数なら採用
+--
+-- ## リファレンス
+--
+-- Shimizu (2012) "Joint estimation of linear non-Gaussian acyclic models",
+-- Neurocomputing 81. Python 実装は cdt15/lingam の `lingam/multi_group_lingam.py`。
+module Hanalyze.Model.LiNGAM.MultiGroup
+  ( MultiGroupConfig (..)
+  , MultiGroupFit (..)
+  , defaultMultiGroupConfig
+  , fitMultiGroupLiNGAM
+  , mgCommonDAG
+  ) where
+
+import qualified Numeric.LinearAlgebra as LA
+import           Data.List             (foldl', sort, group, sortBy)
+import           Data.Ord              (comparing, Down (..))
+
+import qualified Hanalyze.Model.LiNGAM.Direct as DL
+import qualified Hanalyze.Model.DAG           as DAG
+
+-- ===========================================================================
+-- 設定 / 結果
+-- ===========================================================================
+
+data MultiGroupConfig = MultiGroupConfig
+  { mgcDirectCfg :: !DL.DirectLiNGAMConfig
+  , mgcMajority  :: !Double
+    -- ^ adjacency 多数決閾値 (0..1)、 default 0.5
+  } deriving (Show)
+
+defaultMultiGroupConfig :: MultiGroupConfig
+defaultMultiGroupConfig = MultiGroupConfig
+  { mgcDirectCfg = DL.defaultDirectLiNGAMConfig
+  , mgcMajority  = 0.5
+  }
+
+data MultiGroupFit = MultiGroupFit
+  { mgGroupFits      :: ![DL.DirectLiNGAMFit]
+    -- ^ 各群独立 fit 結果
+  , mgCommonOrder    :: ![Int]
+    -- ^ 多数決で確定した共通 causal order
+  , mgGroupBMats     :: ![LA.Matrix Double]
+    -- ^ 共通 order で再 fit した各群 B 行列
+  , mgCommonAdj      :: !(LA.Matrix Double)
+    -- ^ 多数決による共通 adjacency マスク (0/1)
+  } deriving (Show)
+
+-- ===========================================================================
+-- 主実装
+-- ===========================================================================
+
+fitMultiGroupLiNGAM :: MultiGroupConfig -> [LA.Matrix Double] -> MultiGroupFit
+fitMultiGroupLiNGAM cfg groups =
+  let !groupFits = [ DL.fitDirectLiNGAM (mgcDirectCfg cfg) g | g <- groups ]
+      !p         = if null groupFits then 0 else LA.cols (DL.dlB (head groupFits))
+      !commonOrd = majorityOrder p (map DL.dlOrder groupFits)
+      -- 共通 order に従って各群で B を再度 OLS で組み立てる
+      !commonBs  = [ refitWithOrder commonOrd g | g <- groups ]
+      !commonAdj = majorityAdjacency
+                    (mgcMajority cfg)
+                    (DL.dlcPruneThr (mgcDirectCfg cfg))
+                    commonBs
+  in MultiGroupFit
+       { mgGroupFits   = groupFits
+       , mgCommonOrder = commonOrd
+       , mgGroupBMats  = commonBs
+       , mgCommonAdj   = commonAdj
+       }
+
+-- | 共通 adjacency に基づく DAG 表現。 重みは全群 B の平均を使う。
+mgCommonDAG :: MultiGroupFit -> DAG.DAG
+mgCommonDAG fit =
+  let !bs   = mgGroupBMats fit
+      !adj  = mgCommonAdj fit
+      !p    = LA.rows adj
+      !g    = fromIntegral (length bs) :: Double
+      !meanB = LA.scale (1 / g) (foldl' (+) (LA.konst 0 (p, p)) bs)
+      f i j
+        | i == j                        = 0
+        | LA.atIndex adj (i, j) == 0    = 0
+        | otherwise                     = LA.atIndex meanB (i, j)
+      w = LA.build (p, p) (\i j -> f (round i) (round j) :: Double)
+  in DAG.mkDAG w
+
+-- ===========================================================================
+-- 内部
+-- ===========================================================================
+
+-- | 多数決で共通 causal order を決める。 各位置 j で最頻 node を取り、
+--   重複が出たら未確定 node を残りから追加する fallback。
+majorityOrder :: Int -> [[Int]] -> [Int]
+majorityOrder p orders
+  | null orders = [0 .. p - 1]
+  | otherwise =
+      let posCount j = [ ord !! j | ord <- orders, length ord > j ]
+          mostFreq xs =
+            let !grouped = sortBy (comparing (Down . length))
+                             (group (sort xs))
+            in case grouped of
+                 ((h:_):_) -> h
+                 _         -> 0
+          go acc unused j
+            | j >= p = reverse acc
+            | otherwise =
+                let !cand = mostFreq (posCount j)
+                in if cand `elem` unused
+                     then go (cand : acc) (filter (/= cand) unused) (j + 1)
+                     else
+                       -- fallback: 残りから一番低 index
+                       case unused of
+                         []      -> reverse acc
+                         (h : _) ->
+                           go (h : acc) (filter (/= h) unused) (j + 1)
+      in go [] [0 .. p - 1] 0
+
+-- | 指定 causal order に従い X から B を OLS で組み立て直す。
+refitWithOrder :: [Int] -> LA.Matrix Double -> LA.Matrix Double
+refitWithOrder order x =
+  let !p = LA.cols x
+      mkRow j =
+        let kj   = order !! j
+            parents = take j order
+        in if null parents
+             then LA.fromList (replicate p 0)
+             else
+               let pm = LA.fromColumns
+                     [ LA.flatten (x LA.¿ [pIdx]) | pIdx <- parents ]
+                   y  = LA.flatten (x LA.¿ [kj])
+                   beta = LA.flatten
+                     (LA.linearSolveLS (LA.tr pm LA.<> pm)
+                        (LA.asColumn (LA.tr pm LA.#> y)))
+                   updates = zip parents (LA.toList beta)
+                   coefV   = replicate p 0
+                   filled  = foldl' (\acc (i, v) -> set acc i v) coefV updates
+               in LA.fromList filled
+      bRows = [ mkRow j | j <- [0 .. p - 1] ]
+      pos i = case lookup i (zip order [0 ..]) of
+                Just k -> k
+                Nothing -> 0
+      origOrderMat = LA.fromRows [ bRows !! pos i | i <- [0 .. p - 1] ]
+  in origOrderMat
+  where
+    set xs i v = take i xs ++ [v] ++ drop (i + 1) xs
+
+-- | 多数決による共通 adjacency: |B^(g)[i, j]| > thr が 全群中 majorityRatio
+--   以上の比率で起こったら 1。
+majorityAdjacency
+  :: Double                -- majority ratio (0..1)
+  -> Double                -- B threshold
+  -> [LA.Matrix Double]
+  -> LA.Matrix Double
+majorityAdjacency majRatio thr bs =
+  let !p = LA.rows (head bs)
+      !g = fromIntegral (length bs) :: Double
+      f i j
+        | i == j    = 0
+        | otherwise =
+            let cnt = length [ () | b <- bs
+                                  , abs (LA.atIndex b (i, j)) > thr ]
+                rate = fromIntegral cnt / g
+            in if rate >= majRatio then 1 else 0
+  in LA.build (p, p) (\i j -> f (round i) (round j) :: Double)
diff --git a/src/Hanalyze/Model/LiNGAM/Pairwise.hs b/src/Hanalyze/Model/LiNGAM/Pairwise.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Model/LiNGAM/Pairwise.hs
@@ -0,0 +1,109 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE BangPatterns      #-}
+-- |
+-- Module      : Hanalyze.Model.LiNGAM.Pairwise
+-- Description : Pairwise LiNGAM (Hyvärinen-Smith 2013、2 変数間の因果方向推定)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Pairwise LiNGAM: 2 変数間の因果方向 (x → y か y → x か) 推定。
+--
+-- ## アルゴリズム (Hyvärinen-Smith 2013)
+--
+-- 標準化された (x, y) について、 非ガウシアン独立性に基づき:
+--
+--   R(x → y) = - Cov(x³, y) · sign(Cov(x, y)) + Cov(x, y³)
+--
+-- の符号で方向を決定する近似的測度 (LIM, likelihood ratio approximation)。
+--
+-- * R > 0 → x → y
+-- * R < 0 → y → x
+-- * |R| 小 → 判定不能 (ガウシアン近接 or 弱依存)
+--
+-- 軽量で 2 変数の方向推定に直接使える。 3 変数以上には 'DirectLiNGAM' を使う。
+--
+-- ## リファレンス
+--
+-- Hyvärinen, A. & Smith, S. M. (2013) "Pairwise likelihood ratios for
+-- estimation of non-Gaussian structural equation models", JMLR 14.
+-- Python 実装は cdt15/lingam の `lingam/lim.py` (LIM = Likelihood-based
+-- Independence Measure)。
+module Hanalyze.Model.LiNGAM.Pairwise
+  ( PairwiseDirection (..)
+  , PairwiseResult (..)
+  , pairwiseLiNGAM
+  , pairwiseScore
+  ) where
+
+import qualified Numeric.LinearAlgebra as LA
+
+-- ===========================================================================
+-- 型
+-- ===========================================================================
+
+data PairwiseDirection
+  = XtoY        -- ^ x → y
+  | YtoX        -- ^ y → x
+  | Inconclusive  -- ^ |score| < threshold
+  deriving (Show, Eq)
+
+data PairwiseResult = PairwiseResult
+  { prScore     :: !Double             -- ^ R(x → y) の値、 符号で方向決定
+  , prDirection :: !PairwiseDirection
+  , prMagnitude :: !Double             -- ^ |score|、 confidence の代理
+  } deriving (Show)
+
+-- ===========================================================================
+-- 実装
+-- ===========================================================================
+
+-- | Pairwise LiNGAM の主関数。 threshold 未満は Inconclusive。
+pairwiseLiNGAM
+  :: Double               -- threshold (default 0.0 = 符号だけで判定)
+  -> LA.Vector Double     -- x
+  -> LA.Vector Double     -- y
+  -> PairwiseResult
+pairwiseLiNGAM thr x y =
+  let !s = pairwiseScore x y
+      !mag = abs s
+      !dir
+        | mag < thr = Inconclusive
+        | s > 0     = XtoY
+        | otherwise = YtoX
+  in PairwiseResult { prScore = s, prDirection = dir, prMagnitude = mag }
+
+-- | スコア R = -Cov(x³, y)·sign(Cov(x,y)) + Cov(x, y³)
+--   x, y は内部で標準化される (zero-mean、 unit-variance)。
+pairwiseScore :: LA.Vector Double -> LA.Vector Double -> Double
+pairwiseScore xRaw yRaw =
+  let !x = standardize xRaw
+      !y = standardize yRaw
+      !x3 = x * x * x
+      !y3 = y * y * y
+      !cov_x_y   = covar x  y
+      !cov_x3_y  = covar x3 y
+      !cov_x_y3  = covar x  y3
+      !sgn = if cov_x_y >= 0 then 1.0 else (-1.0 :: Double)
+  in - cov_x3_y * sgn + cov_x_y3
+
+-- ===========================================================================
+-- 内部
+-- ===========================================================================
+
+standardize :: LA.Vector Double -> LA.Vector Double
+standardize v =
+  let !n  = fromIntegral (LA.size v) :: Double
+      !mu = LA.sumElements v / n
+      !c  = v - LA.scalar mu
+      !s  = sqrt (c `LA.dot` c / n)
+      !sd = if s > 1e-12 then s else 1.0
+  in LA.scale (1 / sd) c
+
+covar :: LA.Vector Double -> LA.Vector Double -> Double
+covar a b =
+  let !n  = fromIntegral (LA.size a) :: Double
+      !ma = LA.sumElements a / n
+      !mb = LA.sumElements b / n
+      !ca = a - LA.scalar ma
+      !cb = b - LA.scalar mb
+  in ca `LA.dot` cb / n
diff --git a/src/Hanalyze/Model/LiNGAM/Parce.hs b/src/Hanalyze/Model/LiNGAM/Parce.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Model/LiNGAM/Parce.hs
@@ -0,0 +1,218 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE BangPatterns      #-}
+-- |
+-- Module      : Hanalyze.Model.LiNGAM.Parce
+-- Description : ParceLiNGAM (Tashiro 2014、潜在交絡に頑健な bottom-up + HSIC LiNGAM 拡張)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- ParceLiNGAM (Tashiro et al. 2014): **潜在交絡 (unobserved confounders) に
+--   頑健な** LiNGAM 拡張。
+--
+-- ## モデル
+--
+-- 通常の LiNGAM は @X = B X + e@ で e の各成分独立を要求する。 潜在交絡が
+-- ある場合、 観測 X だけ見ると e が独立に見えず DirectLiNGAM は誤った因果
+-- 順序を出すことがある。 ParceLiNGAM は:
+--
+-- > X = B X + Λ · f + e
+--
+-- ここで f が潜在交絡変数。
+--
+-- ## アルゴリズム (v0.2、 bottom-up + HSIC、 cdt15/lingam 準拠)
+--
+-- cdt15/lingam の `lingam/bottom_up_parce_lingam.py` を参照実装とする
+-- bottom-up 探索:
+--
+-- 1. 候補集合 U = {0, .., p-1} を初期化
+-- 2. 各候補 j ∈ U について、 残り @U \\ {j}@ の変数で x_j を OLS 回帰した
+--    残差 R を作る。 「x_j が最も下流 (sink)」 ならば
+--    @{x_i : i ∈ U \\ {j}}@ と R は独立になるはず
+-- 3. 独立度を @hsicAggregate (x_{U \\ {j}}, R)@ で測る (HSIC 総和)。
+--    最小のものを最も下流の候補 j* として選ぶ
+-- 4. その HSIC 集約値が threshold 'pcAcceptThr' を下回れば j* を順序末尾に
+--    追加して U から削除。 そうでなければ探索停止
+-- 5. 未確定の変数群は **unresolved group** ('pcUnresolvedGroup') として
+--    まとめて返す (潜在交絡で順序が同定不能)
+--
+-- v0.1 (per-pair OLS + Pairwise LiNGAM) は **削除** した。 v0.2 は
+-- リファレンス実装と同じ「集合 vs 単変量残差」 の依存判定に切替。
+--
+-- ## 独立性判定の妥協点
+--
+-- cdt15/lingam では HSIC を gamma 近似で p 値化し Fisher 法で合成する。
+-- v0.2 では HSIC **統計量の総和** を直接スコアとして使い、 閾値で判定する
+-- (実装軽量化、 p 値の校正は将来課題)。 相対比較 (どの候補が最も独立か)
+-- は機能する。 absolute threshold はサンプル数 / 分散依存なので、 ユーザは
+-- 'pcAcceptThr' をデータに合わせて調整する想定。
+--
+-- ## リファレンス
+--
+-- Tashiro et al. (2014) "ParceLiNGAM: A causal ordering method robust against
+-- latent confounders", Neural Computation 26(1).
+-- cdt15/lingam の `lingam/bottom_up_parce_lingam.py`。
+module Hanalyze.Model.LiNGAM.Parce
+  ( ParceConfig (..)
+  , ParceFit (..)
+  , defaultParceConfig
+  , fitParceLiNGAM
+  , parceDAG
+  ) where
+
+import qualified Numeric.LinearAlgebra as LA
+import           Data.List             (foldl', sortBy)
+import           Data.Ord              (comparing)
+
+import qualified Hanalyze.Math.HSIC    as HSIC
+import qualified Hanalyze.Model.DAG    as DAG
+
+-- ===========================================================================
+-- 設定 / 結果
+-- ===========================================================================
+
+data ParceConfig = ParceConfig
+  { pcRelRatio :: !Double
+    -- ^ 受理判定の相対比閾値。 best 候補の HSIC 集約値が 2 番目候補の値の
+    --   pcRelRatio 倍未満なら sink として受理。 default 0.5
+    --   (best が 2nd の半分未満で「明瞭に独立」 と判断)。
+    --
+    --   絶対 HSIC の値はサンプル数 / 分散 / median bandwidth に強く依存する
+    --   ため、 v0.2 では絶対閾値を捨て **相対比のみ** で判定する。
+    --   |U| = 2 のときは 2 候補のうち小さい方/大きい方が pcRelRatio 未満
+    --   なら受理 (= 自然な「明瞭差」 検出)。
+  , pcPruneThr :: !Double
+    -- ^ B 行列 pruning 閾値、 default 0.05
+  } deriving (Show)
+
+defaultParceConfig :: ParceConfig
+defaultParceConfig = ParceConfig
+  { pcRelRatio = 0.5
+  , pcPruneThr = 0.05
+  }
+
+data ParceFit = ParceFit
+  { pcOrder            :: ![Int]
+    -- ^ 確定できた causal order (sink → source の順で逆に並んだものを
+    --   さらに反転 → source → sink の順)。 unresolved group があるときは
+    --   その後ろに連結 (Spec 互換のため任意順で末尾追加)
+  , pcB                :: !(LA.Matrix Double)
+    -- ^ 構造方程式係数行列。 unresolved 群内の係数は OLS で仮置きされる
+    --   (確定的順序が無いので解釈は控えめに)
+  , pcAdjacency        :: !(LA.Matrix Double)
+  , pcUnresolvedGroup  :: ![Int]
+    -- ^ 潜在交絡で順序が同定不能と判定された変数群 (空ならば全変数確定)
+  } deriving (Show)
+
+-- ===========================================================================
+-- 主実装
+-- ===========================================================================
+
+fitParceLiNGAM :: ParceConfig -> LA.Matrix Double -> ParceFit
+fitParceLiNGAM cfg x =
+  let !p          = LA.cols x
+      (sinkList, leftover) = bottomUpSearch cfg x [0 .. p - 1]
+      -- sinkList は新しく見つけた順に **prepend** しているので、
+      -- 自然と「upstream → downstream」 (source → sink) の順に並ぶ。
+      -- leftover (確定できなかった残り) を先頭に置く: 長さ 1 なら単なる
+      -- source、 長さ ≥ 2 なら **潜在交絡で順序不能** のグループ。
+      !fullOrder         = leftover ++ sinkList
+      !unresolved        = if length leftover > 1 then leftover else []
+      !bMat       = buildBFromOrder p x fullOrder
+      !adjMat     = adjFromB (pcPruneThr cfg) bMat
+  in ParceFit
+       { pcOrder           = fullOrder
+       , pcB               = bMat
+       , pcAdjacency       = adjMat
+       , pcUnresolvedGroup = unresolved
+       }
+
+-- | DAG 表現を返す。
+parceDAG :: ParceConfig -> ParceFit -> DAG.DAG
+parceDAG cfg fit = DAG.fromBMatrix (pcPruneThr cfg) (pcB fit)
+
+-- ===========================================================================
+-- bottom-up 探索
+-- ===========================================================================
+
+-- | 候補集合 U から sink を 1 つずつ削り出す。
+--   戻り値: (確定した sink を upstream→downstream の順で並べたリスト、
+--   残り未確定 U)。 ※ prepend で蓄積するため、 最後に見つけたもの
+--   (=最も upstream に近い) が先頭、 最初に見つけたもの (=最も downstream)
+--   が末尾、 つまり自然な source → sink 順。
+bottomUpSearch
+  :: ParceConfig
+  -> LA.Matrix Double
+  -> [Int]                   -- 初期 U (全変数 index)
+  -> ([Int], [Int])
+bottomUpSearch cfg x = go []
+  where
+    go !sinks u
+      | length u <= 1 = (sinks, u)         -- 1 個以下なら確定済とみなす
+      | otherwise =
+          let scored      = sortBy (comparing snd)
+                              [ (j, scoreSink x u j) | j <- u ]
+              (jStar, sB) = head scored
+              sNext       = snd (scored !! 1)
+              accept      = sB < pcRelRatio cfg * sNext
+          in if accept
+               then go (jStar : sinks) (filter (/= jStar) u)
+               else (sinks, u)              -- 明瞭な sink が無い → halt
+
+-- | 候補 j を sink と仮定したときの「他変数 U\\{j} ⊥ R_j」 の HSIC 集約値。
+--   R_j = x_j を x_{U\\{j}} で OLS 回帰した残差。
+scoreSink :: LA.Matrix Double -> [Int] -> Int -> Double
+scoreSink x u j =
+  let others = filter (/= j) u
+      xj     = LA.flatten (x LA.¿ [j])
+      xRest  = LA.fromColumns [ LA.flatten (x LA.¿ [k]) | k <- others ]
+      r      = partialResidual xj xRest
+  in HSIC.hsicAggregate xRest r
+
+-- ===========================================================================
+-- 内部ヘルパ
+-- ===========================================================================
+
+-- | y を Z (n × q 行列) に OLS 回帰した残差。
+partialResidual :: LA.Vector Double -> LA.Matrix Double -> LA.Vector Double
+partialResidual y z =
+  let xtx  = LA.tr z LA.<> z
+      xty  = LA.tr z LA.#> y
+      beta = LA.flatten (LA.linearSolveLS xtx (LA.asColumn xty))
+  in y - z LA.#> beta
+
+-- | causal order に従い OLS で B 行列を構築 (DirectLiNGAM と同手順)。
+buildBFromOrder :: Int -> LA.Matrix Double -> [Int] -> LA.Matrix Double
+buildBFromOrder p x order =
+  let mkRow j =
+        let kj      = order !! j
+            parents = take j order
+        in if null parents
+             then LA.fromList (replicate p 0)
+             else
+               let pm = LA.fromColumns
+                     [ LA.flatten (x LA.¿ [pi_]) | pi_ <- parents ]
+                   y  = LA.flatten (x LA.¿ [kj])
+                   xtx = LA.tr pm LA.<> pm
+                   xty = LA.tr pm LA.#> y
+                   beta = LA.flatten
+                            (LA.linearSolveLS xtx (LA.asColumn xty))
+                   updates = zip parents (LA.toList beta)
+                   coefV   = replicate p 0
+                   filled  = foldl' (\acc (i, v) -> set acc i v) coefV updates
+               in LA.fromList filled
+      bRows = [ mkRow j | j <- [0 .. p - 1] ]
+      pos i = case lookup i (zip order [0 ..]) of
+                Just k  -> k
+                Nothing -> 0
+  in LA.fromRows [ bRows !! pos i | i <- [0 .. p - 1] ]
+  where
+    set xs i v = take i xs ++ [v] ++ drop (i + 1) xs
+
+adjFromB :: Double -> LA.Matrix Double -> LA.Matrix Double
+adjFromB thr b =
+  let p = LA.rows b
+      f i j
+        | i == j                          = 0
+        | abs (LA.atIndex b (i, j)) > thr = 1
+        | otherwise                       = 0
+  in LA.build (p, p) (\i j -> f (round i) (round j) :: Double)
diff --git a/src/Hanalyze/Model/LiNGAM/VAR.hs b/src/Hanalyze/Model/LiNGAM/VAR.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Model/LiNGAM/VAR.hs
@@ -0,0 +1,104 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE BangPatterns      #-}
+-- |
+-- Module      : Hanalyze.Model.LiNGAM.VAR
+-- Description : VAR-LiNGAM (Hyvärinen et al. 2010) — 時系列データに対する LiNGAM 拡張
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- VAR-LiNGAM (Hyvärinen et al. 2010): 時系列データに対する LiNGAM 拡張。
+--
+-- ## モデル
+--
+-- 観測時系列 Y_t ∈ ℝ^K は以下の SVAR (構造 VAR) に従う:
+--
+-- > Y_t = Σ_{l=1..p} A_l^* · Y_{t-l} + B_0 · Y_t + e_t
+--
+-- ここで B_0 は同時刻因果 (contemporaneous causal effect、 acyclic + LiNGAM)、
+-- e_t は非ガウシアン独立 noise。 通常の reduced-form VAR(p) と関係:
+--
+-- > Y_t = Σ_l A_l · Y_{t-l} + u_t,   u_t = (I - B_0)⁻¹ · e_t
+--
+-- なので u_t に LiNGAM を適用すれば B_0 が求まり、 A_l^* も A_l と B_0 から
+-- 回収できる。
+--
+-- ## アルゴリズム
+--
+-- 1. Phase 35 の 'Hanalyze.Model.VAR.fitVAR' で reduced-form VAR(p) を fit
+-- 2. 残差 u_t (= 'varResiduals') に 'fitDirectLiNGAM' を適用 → B_0 と
+--    causal order を取得
+-- 3. 構造 lag 行列を A_l^* = (I - B_0) · A_l で復元 (l=1..p)
+--
+-- ## リファレンス
+--
+-- Hyvärinen et al. (2010) "Estimation of a Structural Vector Autoregression
+-- Model Using Non-Gaussianity", JMLR 11. Python 実装は cdt15/lingam の
+-- `lingam/var_lingam.py`。
+module Hanalyze.Model.LiNGAM.VAR
+  ( VARLiNGAMConfig (..)
+  , VARLiNGAMFit (..)
+  , defaultVARLiNGAMConfig
+  , fitVARLiNGAM
+  , vlDAG
+  ) where
+
+import qualified Numeric.LinearAlgebra as LA
+
+import qualified Hanalyze.Model.VAR           as V
+import qualified Hanalyze.Model.LiNGAM.Direct as DL
+import qualified Hanalyze.Model.DAG           as DAG
+
+-- ===========================================================================
+-- 設定 / 結果
+-- ===========================================================================
+
+data VARLiNGAMConfig = VARLiNGAMConfig
+  { vlcLagOrder  :: !Int
+    -- ^ VAR の lag 数 p (≥ 1)
+  , vlcDirectCfg :: !DL.DirectLiNGAMConfig
+  } deriving (Show)
+
+defaultVARLiNGAMConfig :: VARLiNGAMConfig
+defaultVARLiNGAMConfig = VARLiNGAMConfig
+  { vlcLagOrder  = 1
+  , vlcDirectCfg = DL.defaultDirectLiNGAMConfig
+  }
+
+data VARLiNGAMFit = VARLiNGAMFit
+  { vlVARFit          :: !V.VARFit
+    -- ^ Phase 35 の reduced-form VAR(p) fit 結果
+  , vlContempLiNGAM   :: !DL.DirectLiNGAMFit
+    -- ^ 残差 u_t に対する DirectLiNGAM 結果 (= 同時刻因果 B_0)
+  , vlB0              :: !(LA.Matrix Double)
+    -- ^ 同時刻因果係数 (K × K)、 = vlContempLiNGAM の dlB
+  , vlStructuralLags  :: ![LA.Matrix Double]
+    -- ^ 構造 lag 行列 A_l^* = (I - B_0) · A_l (length = p)
+  , vlContempOrder    :: ![Int]
+  } deriving (Show)
+
+-- ===========================================================================
+-- 主実装
+-- ===========================================================================
+
+fitVARLiNGAM :: VARLiNGAMConfig -> LA.Matrix Double -> VARLiNGAMFit
+fitVARLiNGAM cfg y =
+  let !varFit = V.fitVAR (vlcLagOrder cfg) y
+      !resid  = V.varResiduals varFit
+      !lgFit  = DL.fitDirectLiNGAM (vlcDirectCfg cfg) resid
+      !b0     = DL.dlB lgFit
+      !k      = V.varK varFit
+      !iMinusB0 = LA.ident k - b0
+      !structLags =
+        [ iMinusB0 LA.<> al | al <- V.varCoefs varFit ]
+  in VARLiNGAMFit
+       { vlVARFit         = varFit
+       , vlContempLiNGAM  = lgFit
+       , vlB0             = b0
+       , vlStructuralLags = structLags
+       , vlContempOrder   = DL.dlOrder lgFit
+       }
+
+-- | 同時刻因果 (B_0) の DAG 表現を返す。 lag 部分は含まない (時間方向は別の
+--   表現が必要、 v0.1 では同時刻のみ DAG 化)。
+vlDAG :: VARLiNGAMConfig -> VARLiNGAMFit -> DAG.DAG
+vlDAG cfg fit = DAG.fromBMatrix (DL.dlcPruneThr (vlcDirectCfg cfg)) (vlB0 fit)
diff --git a/src/Hanalyze/Model/MDS.hs b/src/Hanalyze/Model/MDS.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Model/MDS.hs
@@ -0,0 +1,105 @@
+{-# LANGUAGE OverloadedStrings #-}
+-- |
+-- Module      : Hanalyze.Model.MDS
+-- Description : MDS (多次元尺度構成法) の高レベルモデル型 (Phase 75.21)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- MDS の高レベルモデル型 (Phase 75.21)。
+--
+-- 低レベルの行列カーネル ('mdsClassical' / 'mdsSammon' / 'euclideanDist') は
+-- 'Hanalyze.Stat.MDS' に置き、 ここは @df |-> mds cfg cols@ で使う
+-- **モデル型** 'MDSResult' (= 'Hanalyze.Model.PCA.PCAResult' と同格) と
+-- その設定 'MDSConfig' を提供する。
+--
+-- MDS (多次元尺度構成法) = サンプル間の **距離 (非類似度) を保ったまま** 高次元
+-- データを 2D へ配置する可視化・次元圧縮。 'MDSClassical' (Torgerson・ユークリッド
+-- 距離なら PCA と等価) と 'MDSSammon' (小距離重視の非線形版) を選べる。 結果は
+-- 埋め込み (MDS1/MDS2) に加え **元データ (群色付け用の列を含む)** を保持し、
+-- plot 側で @toPlot m@ (単色散布) / @toPlot (mdsView m <> mdsGroupBy \"g\")@ (群色) に使う。
+module Hanalyze.Model.MDS
+  ( -- * 手法と設定
+    MDSMethod (..)
+  , MDSConfig (..)
+  , defaultMDS
+    -- ** 再 export (Sammon パラメータ)
+  , SammonConfig (..)
+  , defaultSammonConfig
+    -- * モデル型
+  , MDSResult (..)
+  , runMDS
+  ) where
+
+import           Data.Text (Text)
+import qualified Data.Text             as T
+import qualified Data.Vector           as V
+import qualified Numeric.LinearAlgebra as LA
+import qualified DataFrame.Internal.DataFrame  as DX
+
+import           Hanalyze.DataIO.Convert (getDoubleVec)
+import qualified Hanalyze.Stat.MDS       as S
+import           Hanalyze.Stat.MDS       (SammonConfig (..), defaultSammonConfig)
+
+-- ===========================================================================
+-- 手法と設定
+-- ===========================================================================
+
+-- | MDS の手法選択。 'MDSClassical' = 古典 MDS (Torgerson・固有分解)、
+-- 'MDSSammon' = Sammon 写像 (小距離重視の非線形・勾配降下)。
+data MDSMethod = MDSClassical | MDSSammon
+  deriving (Show, Eq)
+
+-- | MDS の設定。 手法 ('mdsMethod') と、 'MDSSammon' 選択時に使う Sammon
+-- パラメータ ('mdsSammon') を持つ (他の config 同様レコード型・裸の直和を
+-- spec 引数にしない)。 k=2 固定・距離はユークリッドのみ (現状実装どおり)。
+data MDSConfig = MDSConfig
+  { mdsMethod :: !MDSMethod      -- ^ 古典 / Sammon。
+  , mdsSammon :: !SammonConfig   -- ^ 'MDSSammon' 選択時の勾配降下パラメータ。
+  } deriving (Show)
+
+-- | 既定設定: 古典 MDS・Sammon パラメータは既定。
+defaultMDS :: MDSConfig
+defaultMDS = MDSConfig MDSClassical defaultSammonConfig
+
+-- ===========================================================================
+-- モデル型
+-- ===========================================================================
+
+-- | 学習済 MDS。 2D 埋め込み (MDS1/MDS2) に加え、 **元データ ('mdsSourceFrame')** を
+-- 保持して plot 側の群色付け ('mdsGroupBy') に使う。 'Hanalyze.Model.PCA.PCAResult'
+-- と同格のモデル型 (df 型ではない)。
+data MDSResult = MDSResult
+  { mdsMethodUsed  :: !MDSMethod          -- ^ 使った手法。
+  , mdsEmbedding   :: !(LA.Matrix Double) -- ^ 埋め込み (n × 2)。
+  , mdsFeatures    :: ![Text]             -- ^ 入力に使った特徴列名。
+  , mdsSourceFrame :: !DX.DataFrame       -- ^ 元データ (群色付け用に保持)。
+  }
+
+-- | @runMDS cfg frame cols@ — frame の特徴列 @cols@ を行列化し、 ユークリッド
+-- 距離 → 古典 / Sammon MDS で 2D 埋め込みを得る。 列が無い / 長さ不揃いなら 'Left'。
+runMDS :: MDSConfig -> DX.DataFrame -> [Text] -> Either String MDSResult
+runMDS _   _     []   = Left "MDS: 特徴列が空です (1 列以上必要)"
+runMDS cfg frame cols = do
+  colVecs <- mapM getCol cols
+  let lens = map length colVecs
+  if not (allEq lens)
+    then Left ("MDS: 特徴列の長さが不揃いです: " <> show lens)
+    else do
+      let n    = head lens
+          xMat = LA.fromLists [ [ v !! i | v <- colVecs ] | i <- [0 .. n - 1] ]
+          d    = S.euclideanDist xMat
+          emb  = case mdsMethod cfg of
+                   MDSClassical -> S.mdsClassical d 2
+                   MDSSammon    -> S.mdsSammon (mdsSammon cfg) d 2
+      Right MDSResult
+        { mdsMethodUsed  = mdsMethod cfg
+        , mdsEmbedding   = emb
+        , mdsFeatures    = cols
+        , mdsSourceFrame = frame
+        }
+  where
+    getCol c = case V.toList <$> getDoubleVec c frame of
+      Just vs -> Right vs
+      Nothing -> Left ("MDS: 数値列が見つかりません: " <> T.unpack c)
+    allEq []     = True
+    allEq (x:xs) = all (== x) xs
diff --git a/src/Hanalyze/Model/MultiGP.hs b/src/Hanalyze/Model/MultiGP.hs
--- a/src/Hanalyze/Model/MultiGP.hs
+++ b/src/Hanalyze/Model/MultiGP.hs
@@ -1,5 +1,11 @@
 {-# LANGUAGE OverloadedStrings #-}
--- | Multi-output Gaussian processes.
+-- |
+-- Module      : Hanalyze.Model.MultiGP
+-- Description : Multi-output Gaussian processes (共有 HP / per-output 独立 HP の 2 戦略)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Multi-output Gaussian processes.
 --
 -- Two strategies are offered; pick by how outputs should share
 -- hyperparameters:
diff --git a/src/Hanalyze/Model/MultiLM.hs b/src/Hanalyze/Model/MultiLM.hs
--- a/src/Hanalyze/Model/MultiLM.hs
+++ b/src/Hanalyze/Model/MultiLM.hs
@@ -1,5 +1,11 @@
 {-# LANGUAGE OverloadedStrings #-}
--- | Multivariate (multi-output) linear regression.
+-- |
+-- Module      : Hanalyze.Model.MultiLM
+-- Description : Multivariate (multi-output) linear regression — 列別 OLS + 残差共分散推定
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Multivariate (multi-output) linear regression.
 --
 -- @Y = XB + E@ with @Y@ of shape @n × q@ (@q@ outputs), @X@ of shape
 -- @n × p@, @B@ of shape @p × q@ and @E@ of shape @n × q@.
diff --git a/src/Hanalyze/Model/MultiOutput.hs b/src/Hanalyze/Model/MultiOutput.hs
--- a/src/Hanalyze/Model/MultiOutput.hs
+++ b/src/Hanalyze/Model/MultiOutput.hs
@@ -1,4 +1,10 @@
--- | Common foundation for multi-output regression.
+-- |
+-- Module      : Hanalyze.Model.MultiOutput
+-- Description : Common foundation for multi-output regression (単出力 ↔ 多出力変換 + 評価指標)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Common foundation for multi-output regression.
 --
 -- Design policy:
 --
diff --git a/src/Hanalyze/Model/Multivariate.hs b/src/Hanalyze/Model/Multivariate.hs
--- a/src/Hanalyze/Model/Multivariate.hs
+++ b/src/Hanalyze/Model/Multivariate.hs
@@ -1,5 +1,11 @@
 {-# LANGUAGE OverloadedStrings #-}
--- | Specialized multivariate regression: Reduced-Rank Regression, PLS,
+-- |
+-- Module      : Hanalyze.Model.Multivariate
+-- Description : Specialized multivariate regression — Reduced-Rank Regression / PLS / CCA
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Specialized multivariate regression: Reduced-Rank Regression, PLS,
 -- and CCA.
 --
 -- These all express the relationship between a multi-response @Y@
diff --git a/src/Hanalyze/Model/NaiveBayes.hs b/src/Hanalyze/Model/NaiveBayes.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Model/NaiveBayes.hs
@@ -0,0 +1,166 @@
+{-# LANGUAGE BangPatterns #-}
+-- |
+-- Module      : Hanalyze.Model.NaiveBayes
+-- Description : Naive Bayes 分類 (Gaussian + Multinomial)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Naive Bayes 分類 (Gaussian + Multinomial).
+--
+-- @
+-- import qualified Hanalyze.Model.NaiveBayes as NB
+-- let nb = NB.fitGNB x y                    -- 連続特徴: Gaussian
+--     yhat = NB.predictNB nb x
+--
+-- let mnb = NB.fitMNB 1.0 xCounts yCount    -- カウント特徴: Multinomial (Laplace α)
+-- @
+module Hanalyze.Model.NaiveBayes
+  ( -- * Gaussian NB
+    GaussianNB (..)
+  , fitGNB
+    -- * Multinomial NB
+  , MultinomialNB (..)
+  , fitMNB
+    -- * Predict (両対応)
+  , NBModel (..)
+  , predictNB
+  , predictNBLogProbs
+  ) where
+
+import qualified Data.Vector.Unboxed   as VU
+import qualified Numeric.LinearAlgebra as LA
+import           Data.Text             (Text)
+import           Data.List             (nub, sort, foldl')
+
+-- ---------------------------------------------------------------------------
+-- Gaussian NB
+-- ---------------------------------------------------------------------------
+
+-- | クラスごとに各特徴を独立 Gaussian と仮定。
+data GaussianNB = GaussianNB
+  { gnbClasses    :: ![Int]
+  , gnbLogPrior   :: ![Double]           -- ^ log π_c (classes 順)
+  , gnbMeans      :: ![LA.Vector Double] -- ^ 各クラスの μ (length d)
+  , gnbVars       :: ![LA.Vector Double] -- ^ 各クラスの σ² (length d)、 var smoothing 済
+  , gnbClassNames :: ![Text]             -- ^ クラス名 (df|-> が levels 注入・空=数値表示)。
+  } deriving (Show)
+
+-- | sklearn 互換の var smoothing (最大 var の 1e-9 倍を全 var に加算)。
+varSmoothing :: Double
+varSmoothing = 1e-9
+
+fitGNB :: LA.Matrix Double -> VU.Vector Int -> GaussianNB
+fitGNB x y =
+  let !n        = VU.length y
+      !d        = LA.cols x
+      classes   = sort (nub (VU.toList y))
+      rows c    = [ i | i <- [0 .. n - 1], y VU.! i == c ]
+      meanV ids =
+        let m = LA.fromRows [ LA.flatten (x LA.? [i]) | i <- ids ]
+            nc = fromIntegral (length ids) :: Double
+        in LA.scale (1 / nc) (LA.fromList (map LA.sumElements (LA.toColumns m)))
+      varV ids mu =
+        let nc = fromIntegral (length ids) :: Double
+            sq i = let r = LA.flatten (x LA.? [i]) - mu
+                   in r * r
+            sumSq = sum (map sq ids)
+        in LA.scale (1 / nc) sumSq
+      mus  = [ meanV (rows c) | c <- classes ]
+      vrs0 = zipWith (\c mu -> varV (rows c) mu) classes mus
+      maxVar = maximum (map (LA.maxElement . LA.cmap abs) vrs0)
+      eps    = varSmoothing * maxVar + 1e-300
+      vrs    = map (LA.cmap (+ eps)) vrs0
+      priors = [ log (fromIntegral (length (rows c)) / fromIntegral n)
+               | c <- classes ]
+      _ = d  -- d は使わない (内部で LA.size に頼る)
+  in GaussianNB classes priors mus vrs []
+
+-- | log p(x | c) = -1/2 Σ_j [ log(2π σ²_j) + (x_j - μ_j)² / σ²_j ]
+gnbLogLik :: GaussianNB -> LA.Vector Double -> [Double]
+gnbLogLik nb xv =
+  [ let r   = xv - mu
+        rsq = r * r
+        logT = LA.sumElements (LA.cmap log (LA.scale (2 * pi) vr))
+        chiT = LA.sumElements (rsq / vr)
+    in -0.5 * (logT + chiT)
+  | (mu, vr) <- zip (gnbMeans nb) (gnbVars nb) ]
+
+-- ---------------------------------------------------------------------------
+-- Multinomial NB
+-- ---------------------------------------------------------------------------
+
+-- | テキスト分類等のカウント特徴用。 ラプラス平滑化 α (典型 1.0)。
+data MultinomialNB = MultinomialNB
+  { mnbClasses    :: ![Int]
+  , mnbLogPrior   :: ![Double]
+  , mnbLogFeat    :: ![LA.Vector Double]   -- ^ log p(feature_j | c)
+  , mnbClassNames :: ![Text]               -- ^ クラス名 (df|-> が levels 注入・空=数値表示)。
+  } deriving (Show)
+
+fitMNB :: Double             -- ^ Laplace α
+       -> LA.Matrix Double  -- ^ 非負カウント (n × d)
+       -> VU.Vector Int     -- ^ y
+       -> MultinomialNB
+fitMNB alpha x y =
+  let !n       = VU.length y
+      !d       = LA.cols x
+      classes  = sort (nub (VU.toList y))
+      rows c   = [ i | i <- [0 .. n - 1], y VU.! i == c ]
+      sumRows ids =
+        foldl' (+) (LA.konst 0 d)
+          [ LA.flatten (x LA.? [i]) | i <- ids ]
+      featLog c =
+        let s     = sumRows (rows c)
+            !sNum = LA.cmap (+ alpha) s
+            !tot  = LA.sumElements sNum
+        in LA.cmap log (LA.scale (1 / tot) sNum)
+      priors = [ log (fromIntegral (length (rows c)) / fromIntegral n)
+               | c <- classes ]
+  in MultinomialNB classes priors [ featLog c | c <- classes ] []
+
+mnbLogLik :: MultinomialNB -> LA.Vector Double -> [Double]
+mnbLogLik nb xv =
+  [ LA.dot xv lf | lf <- mnbLogFeat nb ]
+
+-- ---------------------------------------------------------------------------
+-- 共通インターフェース
+-- ---------------------------------------------------------------------------
+
+data NBModel = NBGaussian GaussianNB | NBMultinomial MultinomialNB
+  deriving (Show)
+
+nbClasses :: NBModel -> [Int]
+nbClasses (NBGaussian m)    = gnbClasses m
+nbClasses (NBMultinomial m) = mnbClasses m
+
+nbLogPriorAndLik :: NBModel -> LA.Vector Double -> ([Double], [Double])
+nbLogPriorAndLik (NBGaussian m) xv    = (gnbLogPrior m, gnbLogLik m xv)
+nbLogPriorAndLik (NBMultinomial m) xv = (mnbLogPrior m, mnbLogLik m xv)
+
+predictNBLogProbs :: NBModel -> LA.Matrix Double -> [[Double]]
+predictNBLogProbs nb x =
+  let !n = LA.rows x
+      row i = LA.flatten (x LA.? [i])
+      logits xv =
+        let (lp, ll) = nbLogPriorAndLik nb xv
+        in zipWith (+) lp ll
+      -- log-sum-exp 正規化
+      lse zs =
+        let !mx = maximum zs
+        in mx + log (sum [ exp (z - mx) | z <- zs ])
+      one i =
+        let zs = logits (row i)
+            z  = lse zs
+        in [ k - z | k <- zs ]
+  in [ one i | i <- [0 .. n - 1] ]
+
+predictNB :: NBModel -> LA.Matrix Double -> VU.Vector Int
+predictNB nb x =
+  let probs = predictNBLogProbs nb x
+      classes = nbClasses nb
+      pick zs =
+        let (cMax, _) = foldr1
+                          (\(c, v) (c', v') -> if v >= v' then (c, v) else (c', v'))
+                          (zip classes zs)
+        in cMax
+  in VU.fromList (map pick probs)
diff --git a/src/Hanalyze/Model/NeuralNetwork.hs b/src/Hanalyze/Model/NeuralNetwork.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Model/NeuralNetwork.hs
@@ -0,0 +1,502 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE BangPatterns #-}
+-- |
+-- Module      : Hanalyze.Model.NeuralNetwork
+-- Description : Multi-Layer Perceptron (MLP) — feedforward neural network (mini-batch SGD + Adam)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Multi-Layer Perceptron (MLP) — feedforward neural network。
+--
+-- Mini-batch SGD + 自前 Adam で学習。 hmatrix Matrix/Vector で全演算。
+--
+-- 対応:
+--
+--   * 'fitMLPRegressor': 出力 1 次元の回帰 (MSE loss)
+--   * 'fitMLPClassifier': 多クラス分類 (cross-entropy + softmax 出力)
+--   * 'predictMLP': forward 推論
+--
+-- 隠れ層の活性化は ReLU 既定、 出力層は task に応じて自動 (回帰=Identity、
+-- 分類=Softmax)。
+module Hanalyze.Model.NeuralNetwork
+  ( Activation (..)
+  , MLPConfig (..)
+  , defaultMLP
+  , Layer (..)
+  , MLPFit (..)
+  , MLPEpochEvent (..)
+  , fitMLPRegressor
+  , fitMLPRegressorWithCallback
+  , fitMLPRegressorPure
+  , fitMLPClassifier
+  , fitMLPClassifierWithCallback
+  , fitMLPClassifierPure
+  , predictMLP
+  , predictMLPClass
+  ) where
+
+import qualified Data.Vector             as V
+import qualified Data.Vector.Unboxed     as VU
+import           Data.Text               (Text)
+import qualified Numeric.LinearAlgebra   as LA
+import           Control.Monad           (forM_)
+import           Control.Monad.Primitive (PrimMonad, PrimState)
+import           Control.Monad.ST        (runST)
+import           Data.Primitive.MutVar   (newMutVar, readMutVar, writeMutVar,
+                                          modifyMutVar')
+import           Data.Word               (Word32)
+import qualified System.Random.MWC       as MWC
+import           System.Random.MWC       (initialize)
+import           System.Random.MWC.Distributions (standard)
+
+-- ===========================================================================
+-- 型
+-- ===========================================================================
+
+data Activation = ReLU | Sigmoid | Tanh | Identity | Softmax
+  deriving (Show, Eq)
+
+data Layer = Layer
+  { lyrW :: !(LA.Matrix Double)   -- (in × out)
+  , lyrB :: !(LA.Vector Double)   -- (out)
+  , lyrAct :: !Activation
+  } deriving (Show)
+
+data MLPConfig = MLPConfig
+  { mlpHidden    :: ![Int]
+  , mlpActHidden :: !Activation
+  , mlpLR        :: !Double
+  , mlpEpochs    :: !Int
+  , mlpBatch     :: !Int
+  , mlpL2        :: !Double
+  , mlpStandardize :: !Bool
+    -- ^ True で X を z-score 標準化してから学習 (predict 時は同じ
+    --   mean/std で逆変換)。 Phase 17.3 で追加、 default True。
+  } deriving (Show)
+
+defaultMLP :: MLPConfig
+defaultMLP = MLPConfig
+  { mlpHidden    = [16]
+  , mlpActHidden = ReLU
+  , mlpLR        = 0.01
+  , mlpEpochs    = 200
+  , mlpBatch     = 16
+  , mlpL2        = 0
+  , mlpStandardize = True
+  }
+
+data MLPFit = MLPFit
+  { mlpLayers   :: ![Layer]
+  , mlpLossHist :: ![Double]
+  , mlpClasses  :: ![Int]
+    -- ^ 分類器の場合の class label 順 (sorted)。 回帰時は空。
+  , mlpClassNames :: ![Text]
+    -- ^ クラス名 (df|-> が levels 注入・空=数値表示/回帰時は空)。
+  , mlpXMean    :: !(LA.Vector Double)
+    -- ^ X 標準化に使った列平均 (Phase 17.3、 標準化 off なら length 0)
+  , mlpXStd     :: !(LA.Vector Double)
+  , mlpYMean    :: !Double
+    -- ^ regressor の場合の y 平均 (標準化 off なら 0)
+  , mlpYStd     :: !Double
+  } deriving (Show)
+
+-- ===========================================================================
+-- 活性化
+-- ===========================================================================
+
+applyAct :: Activation -> LA.Matrix Double -> LA.Matrix Double
+applyAct ReLU     = LA.cmap (\v -> max 0 v)
+applyAct Sigmoid  = LA.cmap (\v -> 1 / (1 + exp (-v)))
+applyAct Tanh     = LA.cmap tanh
+applyAct Identity = id
+applyAct Softmax  = softmaxRows
+
+actGrad :: Activation -> LA.Matrix Double -> LA.Matrix Double -> LA.Matrix Double
+actGrad ReLU     pre _   = LA.cmap (\v -> if v > 0 then 1 else 0) pre
+actGrad Sigmoid  _   out = out * (1 - out)
+actGrad Tanh     _   out = 1 - out * out
+actGrad Identity _   _   = LA.fromLists [[1 :: Double]]
+actGrad Softmax  _   _   = LA.fromLists [[1 :: Double]]
+
+softmaxRows :: LA.Matrix Double -> LA.Matrix Double
+softmaxRows m = LA.fromRows
+  [ let r = LA.flatten (m LA.? [i])
+        mx = LA.maxElement r
+        ex = LA.cmap (\v -> exp (v - mx)) r
+        s  = LA.sumElements ex
+    in LA.scale (1 / s) ex
+  | i <- [0 .. LA.rows m - 1] ]
+
+-- ===========================================================================
+-- 初期化
+-- ===========================================================================
+
+initLayers :: PrimMonad m
+           => MWC.Gen (PrimState m) -> Int -> Int -> [Int] -> Activation -> Activation -> m [Layer]
+initLayers gen inDim outDim hidden hidAct outAct = do
+  let sizes = inDim : hidden ++ [outDim]
+      pairs = zip sizes (tail sizes)
+      acts  = replicate (length hidden) hidAct ++ [outAct]
+  mapM (\((nin, nout), act) -> do
+          let scale = sqrt (2 / fromIntegral nin)
+          ws <- mapM (\_ -> standard gen) [1 .. nin * nout]
+          let w = LA.scale scale
+                    (LA.fromLists (chunksOf nout ws))
+              b = LA.fromList (replicate nout 0)
+          pure (Layer w b act))
+       (zip pairs acts)
+  where
+    chunksOf _ [] = []
+    chunksOf n xs = take n xs : chunksOf n (drop n xs)
+
+-- ===========================================================================
+-- Forward pass
+-- ===========================================================================
+
+forward :: [Layer] -> LA.Matrix Double -> [(LA.Matrix Double, LA.Matrix Double)]
+forward layers x = go x layers []
+  where
+    go _    []     acc = reverse acc
+    go inp (l:ls) acc =
+      let pre = addBias (inp LA.<> lyrW l) (lyrB l)
+          out = applyAct (lyrAct l) pre
+      in go out ls ((pre, out) : acc)
+
+-- | Add bias vector (length = out) to every row of the (n × out) matrix.
+addBias :: LA.Matrix Double -> LA.Vector Double -> LA.Matrix Double
+addBias m b = m + LA.fromRows (replicate (LA.rows m) b)
+
+-- ===========================================================================
+-- Backprop (回帰 MSE)
+-- ===========================================================================
+
+-- | Backprop with MSE for regression OR cross-entropy with softmax for
+--   classification. Output gradient at last layer differs by task:
+--     reg:   dL/dz_out = (yhat - y) / n   (with Identity output)
+--     class: dL/dz_out = (yhat - yOH) / n (softmax + CE simplification)
+backprop
+  :: [Layer]
+  -> LA.Matrix Double                       -- x (n × in)
+  -> LA.Matrix Double                       -- y (n × out) target
+  -> Bool                                   -- True = classification (softmax+CE)
+  -> Double                                 -- L2 weight
+  -> [(LA.Matrix Double, LA.Vector Double)] -- gradients (dW, dB) per layer
+backprop layers x y isClass l2 =
+  let cache = forward layers x   -- list of (pre, out) per layer
+      n     = fromIntegral (LA.rows x) :: Double
+      out_  = snd (last cache)
+      dPre_last
+        | isClass   = LA.scale (1/n) (out_ - y)
+        | otherwise = LA.scale (1/n) (out_ - y)   -- Identity output, same shape
+      -- walk backward
+      walk !dPre [] _ acc = acc
+      walk !dPre (l:ls) (c:cs) acc =
+        let -- input to layer l = (previous out) or x if first
+            inpToL = case cs of
+                       []      -> x
+                       (cPrev:_) -> snd cPrev
+            (preL, _) = c
+            dW = LA.tr inpToL LA.<> dPre + LA.scale l2 (lyrW l)
+            dB = LA.fromList [ LA.sumElements (dPre LA.¿ [j])
+                             | j <- [0 .. LA.cols dPre - 1] ]
+            -- propagate to previous layer
+            dOutPrev = dPre LA.<> LA.tr (lyrW l)
+            dPrePrev =
+              case ls of
+                []      -> dOutPrev  -- unused
+                (lPrev:_) ->
+                  let (prePrev, outPrev) = head cs
+                      g = actGrad (lyrAct lPrev) prePrev outPrev
+                  in dOutPrev * g
+        in walk dPrePrev ls cs ((dW, dB) : acc)
+      grads = walk dPre_last (reverse layers) (reverse cache) []
+  in grads
+
+-- ===========================================================================
+-- 学習ループ (Adam)
+-- ===========================================================================
+
+-- | Per-epoch event emitted by 'fitMLPRegressorWithCallback' /
+-- 'fitMLPClassifierWithCallback'。 Phase 21 で追加。
+data MLPEpochEvent = MLPEpochEvent
+  { meEpoch     :: !Int
+    -- ^ 0-based epoch index (0..epochs-1)
+  , meTrainLoss :: !Double
+    -- ^ epoch 終端での full-batch training loss
+  , meValLoss   :: !(Maybe Double)
+    -- ^ validation split loss。 v1 では常に 'Nothing' (= reserved for future)
+  , meCurrentLR :: !Double
+    -- ^ そのときの学習率 (現在は constant scheduler のみ、 将来 LR scheduler
+    --   実装で意味が出る)
+  } deriving (Show)
+
+trainMLP
+  :: PrimMonad m
+  => MWC.Gen (PrimState m) -> MLPConfig
+  -> LA.Matrix Double -> LA.Matrix Double
+  -> Bool         -- isClass
+  -> (MLPEpochEvent -> m ())   -- per-epoch callback (no-op で旧挙動)
+  -> m ([Layer], [Double])
+trainMLP gen cfg x y isClass onEpoch = do
+  let inDim   = LA.cols x
+      outDim  = LA.cols y
+      outAct  = if isClass then Softmax else Identity
+  layers0 <- initLayers gen inDim outDim (mlpHidden cfg) (mlpActHidden cfg) outAct
+  -- Adam state per layer (mW, vW, mB, vB)
+  let zeroLike w = LA.scale 0 w
+      zeroLikeV v = LA.scale 0 v
+  state <- mapM (\l -> do
+                    mw <- newMutVar (zeroLike (lyrW l))
+                    vw <- newMutVar (zeroLike (lyrW l))
+                    mb <- newMutVar (zeroLikeV (lyrB l))
+                    vb <- newMutVar (zeroLikeV (lyrB l))
+                    pure (mw, vw, mb, vb)) layers0
+  layersRef <- newMutVar layers0
+  lossRef   <- newMutVar ([] :: [Double])
+  let n  = LA.rows x
+      lr = mlpLR cfg
+      b1 = 0.9
+      b2 = 0.999
+      eps = 1e-8
+  tRef <- newMutVar (0 :: Int)
+  forM_ [0 .. mlpEpochs cfg - 1] $ \epochIdx -> do
+    -- shuffle indices
+    idx <- fisherYates gen [0 .. n - 1]
+    let batches = chunksOf (mlpBatch cfg) idx
+    forM_ batches $ \batch -> do
+      let xb = x LA.? batch
+          yb = y LA.? batch
+      ls0 <- readMutVar layersRef
+      let grads = backprop ls0 xb yb isClass (mlpL2 cfg)
+      modifyMutVar' tRef (+1)
+      t <- readMutVar tRef
+      let tD = fromIntegral t :: Double
+          c1 = 1 - b1 ** tD
+          c2 = 1 - b2 ** tD
+      newLayers <-
+        mapM (\(l, (dW, dB), (mwR, vwR, mbR, vbR)) -> do
+                mw <- readMutVar mwR
+                vw <- readMutVar vwR
+                mb <- readMutVar mbR
+                vb <- readMutVar vbR
+                let mw' = LA.scale b1 mw + LA.scale (1 - b1) dW
+                    vw' = LA.scale b2 vw + LA.scale (1 - b2) (dW * dW)
+                    mb' = LA.scale b1 mb + LA.scale (1 - b1) dB
+                    vb' = LA.scale b2 vb + LA.scale (1 - b2) (dB * dB)
+                    mwHat = LA.scale (1 / c1) mw'
+                    vwHat = LA.scale (1 / c2) vw'
+                    mbHat = LA.scale (1 / c1) mb'
+                    vbHat = LA.scale (1 / c2) vb'
+                    wNew = lyrW l - LA.scale lr
+                             (mwHat / LA.cmap (\v -> sqrt v + eps) vwHat)
+                    bNew = lyrB l - LA.scale lr
+                             (mbHat / LA.cmap (\v -> sqrt v + eps) vbHat)
+                writeMutVar mwR mw'
+                writeMutVar vwR vw'
+                writeMutVar mbR mb'
+                writeMutVar vbR vb'
+                pure l { lyrW = wNew, lyrB = bNew })
+             (zip3 ls0 grads state)
+      writeMutVar layersRef newLayers
+    -- record epoch loss + per-epoch callback (Phase 21)
+    lsFinal <- readMutVar layersRef
+    let cache = forward lsFinal x
+        out_ = snd (last cache)
+        loss = if isClass
+                 then crossEntropyLoss out_ y
+                 else mseLoss out_ y
+    modifyMutVar' lossRef (loss :)
+    onEpoch MLPEpochEvent
+      { meEpoch     = epochIdx
+      , meTrainLoss = loss
+      , meValLoss   = Nothing
+      , meCurrentLR = lr
+      }
+  finalLayers <- readMutVar layersRef
+  losses <- readMutVar lossRef
+  pure (finalLayers, reverse losses)
+
+mseLoss :: LA.Matrix Double -> LA.Matrix Double -> Double
+mseLoss yhat y =
+  let d = yhat - y
+  in LA.sumElements (d * d) / fromIntegral (LA.rows y * LA.cols y)
+
+crossEntropyLoss :: LA.Matrix Double -> LA.Matrix Double -> Double
+crossEntropyLoss yhat y =
+  let safe = LA.cmap (\v -> log (max 1e-15 v)) yhat
+  in - LA.sumElements (y * safe) / fromIntegral (LA.rows y)
+
+-- ===========================================================================
+-- 公開 API
+-- ===========================================================================
+
+-- | X の列ごと平均と標準偏差 (n-1)。
+standardizeStats :: LA.Matrix Double -> (LA.Vector Double, LA.Vector Double)
+standardizeStats x =
+  let n   = LA.rows x
+      nD  = fromIntegral n :: Double
+      mean_ = LA.fromList
+        [ LA.sumElements (x LA.¿ [j]) / nD | j <- [0 .. LA.cols x - 1] ]
+      std_ = if n < 2
+               then LA.fromList (replicate (LA.cols x) 1)
+               else LA.fromList
+                      [ let c = LA.flatten (x LA.¿ [j]) - LA.scalar (mean_ `LA.atIndex` j)
+                            v = (c `LA.dot` c) / (nD - 1)
+                            s = sqrt v
+                        in if s > 1e-12 then s else 1
+                      | j <- [0 .. LA.cols x - 1] ]
+  in (mean_, std_)
+
+applyStandardize :: LA.Vector Double -> LA.Vector Double -> LA.Matrix Double
+                 -> LA.Matrix Double
+applyStandardize mean_ std_ x =
+  let n   = LA.rows x
+      mRow = LA.fromRows (replicate n mean_)
+      sRow = LA.fromRows (replicate n std_)
+  in (x - mRow) / sRow
+
+fitMLPRegressor
+  :: MLPConfig -> LA.Matrix Double -> LA.Vector Double
+  -> MWC.GenIO -> IO MLPFit
+fitMLPRegressor cfg x y gen =
+  fitMLPRegressorWithCallback cfg x y gen (\_ -> pure ())
+
+-- | Phase 21 で追加。 epoch 終端ごとに 'MLPEpochEvent' を渡す callback 付き
+-- regressor 学習。 既存 'fitMLPRegressor' は no-op callback で本関数を呼ぶ
+-- 薄い wrapper として保持される。
+fitMLPRegressorWithCallback
+  :: PrimMonad m
+  => MLPConfig -> LA.Matrix Double -> LA.Vector Double
+  -> MWC.Gen (PrimState m)
+  -> (MLPEpochEvent -> m ())
+  -> m MLPFit
+fitMLPRegressorWithCallback cfg x y gen onEpoch = do
+  let (xMean, xStd) = if mlpStandardize cfg
+                        then standardizeStats x
+                        else (LA.fromList [], LA.fromList [])
+      xUse = if mlpStandardize cfg then applyStandardize xMean xStd x else x
+      yMat = LA.asColumn y
+  (layers, losses) <- trainMLP gen cfg xUse yMat False onEpoch
+  pure MLPFit
+    { mlpLayers   = layers
+    , mlpLossHist = losses
+    , mlpClasses  = []
+    , mlpClassNames = []
+    , mlpXMean    = xMean
+    , mlpXStd     = xStd
+    , mlpYMean    = 0
+    , mlpYStd     = 1
+    }
+
+fitMLPClassifier
+  :: MLPConfig -> LA.Matrix Double -> VU.Vector Int
+  -> MWC.GenIO -> IO MLPFit
+fitMLPClassifier cfg x y gen =
+  fitMLPClassifierWithCallback cfg x y gen (\_ -> pure ())
+
+-- | Phase 21 で追加。 'fitMLPRegressorWithCallback' の classifier 版。
+fitMLPClassifierWithCallback
+  :: PrimMonad m
+  => MLPConfig -> LA.Matrix Double -> VU.Vector Int
+  -> MWC.Gen (PrimState m)
+  -> (MLPEpochEvent -> m ())
+  -> m MLPFit
+fitMLPClassifierWithCallback cfg x y gen onEpoch = do
+  let classes = uniqueSort (VU.toList y)
+      k       = length classes
+      n       = VU.length y
+      classIdx c = case lookup c (zip classes [0 ..]) of
+        Just i  -> i
+        Nothing -> 0
+      yOH = LA.fromLists
+              [ [ if j == classIdx (y VU.! i) then 1 else 0
+                | j <- [0 .. k - 1] ]
+              | i <- [0 .. n - 1] ]
+      (xMean, xStd) = if mlpStandardize cfg
+                        then standardizeStats x
+                        else (LA.fromList [], LA.fromList [])
+      xUse = if mlpStandardize cfg then applyStandardize xMean xStd x else x
+  (layers, losses) <- trainMLP gen cfg xUse yOH True onEpoch
+  pure MLPFit
+    { mlpLayers   = layers
+    , mlpLossHist = losses
+    , mlpClasses  = classes
+    , mlpClassNames = []
+    , mlpXMean    = xMean
+    , mlpXStd     = xStd
+    , mlpYMean    = 0
+    , mlpYStd     = 1
+    }
+
+-- | 'fitMLPRegressor' の純粋版 (Phase 75.8)。 Word32 seed から @runST@ + MWC で重み初期化・
+-- shuffle を決定的に閉じる ('fitRFVPure'/'nutsPure' と同方針・同 seed → ビット同一)。
+-- IO 版は進捗 callback 用に残る。
+fitMLPRegressorPure :: MLPConfig -> LA.Matrix Double -> LA.Vector Double -> Word32 -> MLPFit
+fitMLPRegressorPure cfg x y seed =
+  runST (initialize (V.singleton seed)
+           >>= \gen -> fitMLPRegressorWithCallback cfg x y gen (\_ -> pure ()))
+
+-- | 'fitMLPClassifier' の純粋版 (Phase 75.8)。 seed から @runST@ で決定的に学習。
+fitMLPClassifierPure :: MLPConfig -> LA.Matrix Double -> VU.Vector Int -> Word32 -> MLPFit
+fitMLPClassifierPure cfg x y seed =
+  runST (initialize (V.singleton seed)
+           >>= \gen -> fitMLPClassifierWithCallback cfg x y gen (\_ -> pure ()))
+
+predictMLP :: MLPFit -> LA.Matrix Double -> LA.Matrix Double
+predictMLP fit xNew =
+  let xUse = if LA.size (mlpXMean fit) > 0
+               then applyStandardize (mlpXMean fit) (mlpXStd fit) xNew
+               else xNew
+      cache = forward (mlpLayers fit) xUse
+      raw   = snd (last cache)
+      -- regressor の場合、 y も標準化して学習しているので戻す
+  in if null (mlpClasses fit) && mlpYStd fit /= 1
+       then LA.cmap (\v -> v * mlpYStd fit + mlpYMean fit) raw
+       else raw
+
+predictMLPClass :: MLPFit -> LA.Matrix Double -> V.Vector Int
+predictMLPClass fit xNew =
+  let probs = predictMLP fit xNew
+      classes = mlpClasses fit
+  in V.generate (LA.rows probs) $ \i ->
+       let row = LA.toList (LA.flatten (probs LA.? [i]))
+           (best, _) = foldr1 (\(j, p) (jb, pb) ->
+                                  if p > pb then (j, p) else (jb, pb))
+                       (zip [0 ..] row)
+       in classes !! best
+
+-- ===========================================================================
+-- helpers
+-- ===========================================================================
+
+uniqueSort :: Ord a => [a] -> [a]
+uniqueSort = uniqAdj . sortL
+  where
+    sortL xs = foldr insertSorted [] xs
+    insertSorted x [] = [x]
+    insertSorted x ys@(y:rest)
+      | x <  y = x : ys
+      | x == y = ys
+      | otherwise = y : insertSorted x rest
+    uniqAdj []  = []
+    uniqAdj [a] = [a]
+    uniqAdj (a:b:rest)
+      | a == b = uniqAdj (b : rest)
+      | otherwise = a : uniqAdj (b : rest)
+
+chunksOf :: Int -> [a] -> [[a]]
+chunksOf _ [] = []
+chunksOf n xs = take n xs : chunksOf n (drop n xs)
+
+fisherYates :: PrimMonad m => MWC.Gen (PrimState m) -> [a] -> m [a]
+fisherYates gen xs =
+  let v0 = V.fromList xs
+  in go v0 (V.length v0 - 1)
+  where
+    go v 0 = pure (V.toList v)
+    go v i = do
+      j <- MWC.uniformR (0, i) gen
+      let vi = v V.! i
+          vj = v V.! j
+          v' = v V.// [(i, vj), (j, vi)]
+      go v' (i - 1)
diff --git a/src/Hanalyze/Model/PCA.hs b/src/Hanalyze/Model/PCA.hs
--- a/src/Hanalyze/Model/PCA.hs
+++ b/src/Hanalyze/Model/PCA.hs
@@ -1,5 +1,11 @@
 {-# LANGUAGE OverloadedStrings #-}
--- | Principal Component Analysis (PCA) and related dimensionality
+-- |
+-- Module      : Hanalyze.Model.PCA
+-- Description : Principal Component Analysis (PCA) and related dimensionality reduction
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Principal Component Analysis (PCA) and related dimensionality
 -- reduction.
 --
 -- @
diff --git a/src/Hanalyze/Model/PLS.hs b/src/Hanalyze/Model/PLS.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Model/PLS.hs
@@ -0,0 +1,405 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE BangPatterns #-}
+-- |
+-- Module      : Hanalyze.Model.PLS
+-- Description : PLS (Partial Least Squares) — 応答 Y との共分散を最大化する低ランク回帰 (NIPALS)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Partial Least Squares (PLS) — chemometrics 標準の応答連動低ランク回帰。
+--
+-- PCA (`Hanalyze.Model.PCA`) は応答無視の分散最大化、 PLS は **応答 Y と
+-- X の共分散を最大化**する低ランク射影。 多変量分光分析 / 材料設計の予測 +
+-- 変数選択を 1 モデルで実現する。
+--
+-- アルゴリズム:
+--
+--   * 'NIPALS' (default): 反復的 power iteration、 sklearn `PLSRegression` と
+--     数値一致しやすい
+--   * 'SIMPLS' (Phase 9.5 で追加予定): de Jong 1993、 SVD ベース、 multi-Y で
+--     直接的
+--
+-- 内部実装は hmatrix Matrix / Vector 演算で完結 (list 化しない)。
+module Hanalyze.Model.PLS
+  ( -- * Config
+    PLSAlgorithm (..)
+  , PLSConfig (..)
+  , defaultPLS
+    -- * Fit / predict
+  , PLSFit (..)
+  , fitPLS
+  , fitPLS1
+  , predictPLS
+  , predictPLS1
+    -- * CV による component 数選択
+  , PLSLambdaSelection (..)
+  , selectPLSComponentsCV
+  ) where
+
+import qualified Numeric.LinearAlgebra as LA
+import qualified System.Random.MWC     as MWC
+import           Data.List             (sortBy)
+import           Data.Ord              (comparing)
+import           Data.Text             (Text)
+import qualified Data.Text             as T
+
+import qualified Hanalyze.Stat.CV      as HCV
+
+-- ===========================================================================
+-- Config
+-- ===========================================================================
+
+data PLSAlgorithm
+  = NIPALS   -- ^ 反復的 power iteration (default)
+  | SIMPLS   -- ^ de Jong 1993 (Phase 9.5 で追加)
+  deriving (Show, Eq)
+
+data PLSConfig = PLSConfig
+  { plsN_Components :: !Int
+  , plsAlgorithm    :: !PLSAlgorithm
+  , plsScale        :: !Bool           -- ^ True で X, Y を column-wise に標準化
+  , plsTol          :: !Double         -- ^ NIPALS 収束許容誤差
+  , plsMaxIter      :: !Int            -- ^ NIPALS 最大反復
+  } deriving (Show)
+
+defaultPLS :: PLSConfig
+defaultPLS = PLSConfig
+  { plsN_Components = 2
+  , plsAlgorithm    = NIPALS
+  , plsScale        = True
+  , plsTol          = 1e-8
+  , plsMaxIter      = 500
+  }
+
+-- ===========================================================================
+-- 結果型
+-- ===========================================================================
+
+data PLSFit = PLSFit
+  { plsScoresT    :: !(LA.Matrix Double)  -- ^ T (n × K) X scores
+  , plsLoadingsP  :: !(LA.Matrix Double)  -- ^ P (p × K) X loadings
+  , plsLoadingsQ  :: !(LA.Matrix Double)  -- ^ Q (q × K) Y loadings
+  , plsWeightsW   :: !(LA.Matrix Double)  -- ^ W (p × K) X weights
+  , plsCoef       :: !(LA.Matrix Double)
+    -- ^ β (p × q) 回帰係数 (元スケール)。 @Ŷ = (X - X̄) · β + Ȳ@
+  , plsXMean      :: !(LA.Vector Double)  -- ^ X 列平均
+  , plsXStd       :: !(LA.Vector Double)  -- ^ X 列標準偏差 (plsScale=True なら、 そうでなければ 1)
+  , plsYMean      :: !(LA.Vector Double)
+  , plsYStd       :: !(LA.Vector Double)
+  , plsR2X        :: !(LA.Vector Double)  -- ^ 各 component の X 説明分散率
+  , plsR2Y        :: !(LA.Vector Double)  -- ^ 各 component の Y 説明分散率
+  , plsVIP        :: !(LA.Vector Double)  -- ^ 変数重要度 (Variable Importance in Projection)
+  , plsConfig     :: !PLSConfig
+  } deriving (Show)
+
+-- ===========================================================================
+-- 公開関数
+-- ===========================================================================
+
+-- | PLS fit (multi-output Y、 q ≥ 1)。
+fitPLS :: PLSConfig
+       -> LA.Matrix Double      -- ^ X (n × p)
+       -> LA.Matrix Double      -- ^ Y (n × q)
+       -> Either Text PLSFit
+fitPLS cfg x y
+  | LA.rows x /= LA.rows y =
+      Left "fitPLS: X and Y must have the same number of rows"
+  | LA.rows x < 2 =
+      Left "fitPLS: need at least 2 observations"
+  | plsN_Components cfg < 1 =
+      Left "fitPLS: n_components must be ≥ 1"
+  | plsN_Components cfg > min (LA.rows x - 1) (LA.cols x) =
+      Left (T.pack ("fitPLS: n_components (" <> show (plsN_Components cfg) <>
+                    ") exceeds min(n-1, p)"))
+  | otherwise =
+      case plsAlgorithm cfg of
+        NIPALS -> Right (nipalsFit cfg x y)
+        SIMPLS -> Left "fitPLS: SIMPLS not yet implemented (Phase 9.5)"
+
+-- | 単出力 Y ショートカット (q = 1)。
+fitPLS1 :: PLSConfig -> LA.Matrix Double -> LA.Vector Double -> Either Text PLSFit
+fitPLS1 cfg x y = fitPLS cfg x (LA.asColumn y)
+
+-- | 予測 (multi-output)。 `plsCoef` は元スケールの回帰係数なので、
+--   X を中央化するだけで予測可能 (= scaling は不要、 coef が吸収済)。
+predictPLS :: PLSFit -> LA.Matrix Double -> LA.Matrix Double
+predictPLS fit xNew =
+  let nRow = LA.rows xNew
+      xCentered = xNew - LA.fromRows (replicate nRow (plsXMean fit))
+      yCentered = xCentered LA.<> plsCoef fit
+  in yCentered + LA.fromRows (replicate nRow (plsYMean fit))
+
+predictPLS1 :: PLSFit -> LA.Matrix Double -> LA.Vector Double
+predictPLS1 fit xNew = LA.flatten (predictPLS fit xNew)
+
+-- ===========================================================================
+-- NIPALS 実装
+-- ===========================================================================
+
+-- | NIPALS 内部実装。 中央化 + (option で) 標準化 → component loop → 後処理。
+nipalsFit :: PLSConfig -> LA.Matrix Double -> LA.Matrix Double -> PLSFit
+nipalsFit cfg xRaw yRaw =
+  let !n = LA.rows xRaw
+      !p = LA.cols xRaw
+      !q = LA.cols yRaw
+      k  = plsN_Components cfg
+
+      -- 列平均
+      xMean = LA.scale (1 / fromIntegral n) (LA.fromList
+                [ LA.sumElements (xRaw LA.¿ [j]) | j <- [0 .. p - 1] ])
+      yMean = LA.scale (1 / fromIntegral n) (LA.fromList
+                [ LA.sumElements (yRaw LA.¿ [j]) | j <- [0 .. q - 1] ])
+
+      xCentered = xRaw - LA.fromRows (replicate n xMean)
+      yCentered = yRaw - LA.fromRows (replicate n yMean)
+
+      -- 列標準偏差 (n-1 分母、 plsScale=False なら 1 ベクトル)
+      -- Bug fix (Phase 17.1): 旧実装 LA.sumElements (c LA.<> LA.tr c) は
+      -- n×n 行列 c_i c_j を生成 → sumElements で (Σ c)² になっていた。
+      -- 正しくは Σ c_i² = c `LA.dot` c。
+      colSD m mean_
+        | LA.rows m < 2 = LA.fromList (replicate (LA.cols m) 1)
+        | otherwise =
+            let nm = fromIntegral (LA.rows m - 1) :: Double
+                centered = m - LA.fromRows (replicate (LA.rows m) mean_)
+                sqSum = LA.fromList
+                  [ let c = LA.flatten (centered LA.¿ [j])
+                    in c `LA.dot` c
+                  | j <- [0 .. LA.cols m - 1] ]
+            in LA.cmap (\v -> let s = sqrt (v / nm) in if s > 1e-12 then s else 1) sqSum
+
+      xStd = if plsScale cfg then colSD xRaw xMean else LA.fromList (replicate p 1)
+      yStd = if plsScale cfg then colSD yRaw yMean else LA.fromList (replicate q 1)
+
+      xScaled = if plsScale cfg
+                  then xCentered / LA.fromRows (replicate n xStd)
+                  else xCentered
+      yScaled = if plsScale cfg
+                  then yCentered / LA.fromRows (replicate n yStd)
+                  else yCentered
+
+      -- component loop: 各 component で deflate しながら w, t, p, q を取り出す
+      (wMat, tMat, pMat, qMat) = nipalsLoop cfg k xScaled yScaled
+
+      -- 回帰係数 β = W (Pᵀ W)⁻¹ Qᵀ  (centered/scaled 空間)
+      ptw = LA.tr pMat LA.<> wMat       -- K × K
+      ptwInv = case LA.linearSolve ptw (LA.ident k) of
+        Just inv -> inv
+        Nothing  -> LA.scale 0 (LA.ident k)  -- singular なら 0
+      betaScaled = wMat LA.<> ptwInv LA.<> LA.tr qMat  -- p × q
+
+      -- R²X, R²Y を component 別に計算
+      ssTotalX = LA.sumElements (xScaled * xScaled)
+      ssTotalY = LA.sumElements (yScaled * yScaled)
+      r2X = LA.fromList
+        [ let tk = tMat LA.¿ [j]
+              pk = pMat LA.¿ [j]
+              recon = tk LA.<> LA.tr pk
+              ss = LA.sumElements (recon * recon)
+          in if ssTotalX > 0 then ss / ssTotalX else 0
+        | j <- [0 .. k - 1] ]
+      r2Y = LA.fromList
+        [ let tk = tMat LA.¿ [j]
+              qk = qMat LA.¿ [j]
+              recon = tk LA.<> LA.tr qk
+              ss = LA.sumElements (recon * recon)
+          in if ssTotalY > 0 then ss / ssTotalY else 0
+        | j <- [0 .. k - 1] ]
+
+      -- VIP: VIP_j = sqrt( p · Σ_k (W²_jk · SS_Y_k) / Σ_k SS_Y_k )
+      ssYPerComp = LA.fromList
+        [ let tk = tMat LA.¿ [j]
+              qk = qMat LA.¿ [j]
+              recon = tk LA.<> LA.tr qk
+          in LA.sumElements (recon * recon)
+        | j <- [0 .. k - 1] ]
+      ssYTotal = LA.sumElements ssYPerComp
+      vip = if ssYTotal > 0
+              then LA.fromList
+                [ let wj = LA.flatten (LA.tr wMat LA.¿ [j])  -- length K
+                      contribs = (wj * wj) * ssYPerComp
+                      total = LA.sumElements contribs
+                  in sqrt (fromIntegral p * total / ssYTotal)
+                | j <- [0 .. p - 1] ]
+              else LA.fromList (replicate p 0)
+
+      -- 元スケールの coef
+      coefOrig =
+        if plsScale cfg
+          then let xStdInv = LA.cmap (1 /) xStd
+                   yStdDiag = LA.diag yStd
+                   xStdDiagInv = LA.diag xStdInv
+               in xStdDiagInv LA.<> betaScaled LA.<> yStdDiag
+          else betaScaled
+
+  in PLSFit
+       { plsScoresT    = tMat
+       , plsLoadingsP  = pMat
+       , plsLoadingsQ  = qMat
+       , plsWeightsW   = wMat
+       , plsCoef       = coefOrig
+       , plsXMean      = xMean
+       , plsXStd       = xStd
+       , plsYMean      = yMean
+       , plsYStd       = yStd
+       , plsR2X        = r2X
+       , plsR2Y        = r2Y
+       , plsVIP        = vip
+       , plsConfig     = cfg
+       }
+
+-- | NIPALS 反復ループ: scaled X, Y から K components を抽出。
+nipalsLoop
+  :: PLSConfig
+  -> Int                        -- K
+  -> LA.Matrix Double           -- X_scaled (n × p)
+  -> LA.Matrix Double           -- Y_scaled (n × q)
+  -> ( LA.Matrix Double  -- W (p × K)
+     , LA.Matrix Double  -- T (n × K)
+     , LA.Matrix Double  -- P (p × K)
+     , LA.Matrix Double  -- Q (q × K)
+     )
+nipalsLoop cfg k x0 y0 = go 0 x0 y0 [] [] [] []
+  where
+    go !i !x !y wAcc tAcc pAcc qAcc
+      | i >= k =
+          ( LA.fromColumns (reverse wAcc)
+          , LA.fromColumns (reverse tAcc)
+          , LA.fromColumns (reverse pAcc)
+          , LA.fromColumns (reverse qAcc)
+          )
+      | otherwise =
+          let (w, t, ploading, qloading) = nipalsOneComponent cfg x y
+              -- Deflate: E = E - t pᵀ, F = F - t qᵀ
+              x' = x - LA.asColumn t LA.<> LA.asRow ploading
+              y' = y - LA.asColumn t LA.<> LA.asRow qloading
+          in go (i + 1) x' y' (w : wAcc) (t : tAcc) (ploading : pAcc) (qloading : qAcc)
+
+-- | NIPALS の 1 component 抽出。 power iteration で w, t, p, q を得る。
+nipalsOneComponent
+  :: PLSConfig
+  -> LA.Matrix Double
+  -> LA.Matrix Double
+  -> ( LA.Vector Double   -- w (p)
+     , LA.Vector Double   -- t (n)
+     , LA.Vector Double   -- p loading (p)
+     , LA.Vector Double   -- q loading (q)
+     )
+nipalsOneComponent cfg x y =
+  let -- 初期 u: Y の最初の列
+      u0 = LA.flatten (y LA.¿ [0])
+      (uFinal, _iter) = iterate' cfg x y u0 0
+      -- 最終 w 計算 (deflate 前の x, y で)
+      xtu = LA.tr x LA.#> uFinal
+      normXtu = sqrt (LA.sumElements (xtu * xtu))
+      w = if normXtu > 1e-12 then LA.scale (1 / normXtu) xtu
+                              else xtu
+      t = x LA.#> w
+      tt = LA.sumElements (t * t)
+      qy = if tt > 1e-12 then LA.scale (1 / tt) (LA.tr y LA.#> t)
+                         else LA.tr y LA.#> t
+      pload = if tt > 1e-12 then LA.scale (1 / tt) (LA.tr x LA.#> t)
+                            else LA.tr x LA.#> t
+  in (w, t, pload, qy)
+
+-- | NIPALS の収束反復。 u を更新し続け、 |u_new - u| < tol で終了。
+iterate'
+  :: PLSConfig
+  -> LA.Matrix Double
+  -> LA.Matrix Double
+  -> LA.Vector Double   -- u
+  -> Int                -- iter count
+  -> (LA.Vector Double, Int)
+iterate' cfg x y u !i
+  | i >= plsMaxIter cfg = (u, i)
+  | otherwise =
+      let xtu = LA.tr x LA.#> u
+          normXtu = sqrt (LA.sumElements (xtu * xtu))
+          w = if normXtu > 1e-12 then LA.scale (1 / normXtu) xtu else xtu
+          t = x LA.#> w
+          tt = LA.sumElements (t * t)
+          ytt = LA.tr y LA.#> t
+          q = if tt > 1e-12 then LA.scale (1 / tt) ytt else ytt
+          fq = y LA.#> q
+          normFq = sqrt (LA.sumElements (fq * fq))
+          uNew = if normFq > 1e-12 then LA.scale (1 / normFq) fq else fq
+          diff = uNew - u
+          err = sqrt (LA.sumElements (diff * diff))
+      in if err < plsTol cfg
+           then (uNew, i + 1)
+           else iterate' cfg x y uNew (i + 1)
+
+-- ===========================================================================
+-- CV による component 数選択
+-- ===========================================================================
+
+data PLSLambdaSelection = PLSLambdaSelection
+  { plsBestK   :: !Int
+  , plsCVMSEs  :: ![Double]
+  , plsCVSDs   :: ![Double]
+  , plsOneSeK  :: !Int
+  } deriving (Show)
+
+-- | k-fold CV で component 数を 1..maxK の中から選ぶ。
+selectPLSComponentsCV
+  :: Int                       -- ^ k-fold の k
+  -> Int                       -- ^ maxK (component 数上限)
+  -> LA.Matrix Double          -- ^ X
+  -> LA.Matrix Double          -- ^ Y
+  -> MWC.GenIO
+  -> IO PLSLambdaSelection
+selectPLSComponentsCV kFold maxK xMat yMat gen = do
+  let n = LA.rows xMat
+  folds <- HCV.kFold kFold n gen
+  let perK kk =
+        let cfg = defaultPLS { plsN_Components = kk }
+            scores =
+              [ mseForFold cfg xMat yMat trainIdx testIdx
+              | (trainIdx, testIdx) <- folds, not (null testIdx)
+              ]
+            !nFolds = fromIntegral (length scores) :: Double
+            meanMSE = sum scores / nFolds
+            varN    = sum [(s - meanMSE) ** 2 | s <- scores] / max 1 (nFolds - 1)
+            !se     = sqrt (varN / nFolds)
+        in (meanMSE, se)
+      ks = [1 .. maxK]
+      stats = map perK ks
+      mses  = map fst stats
+      ses   = map snd stats
+      indexedMSEs = zip3 ks mses ses
+      sortedAsc   = sortBy (comparing (\(_, m, _) -> m)) indexedMSEs
+      (bestK_, bestMSE, bestSE) =
+        case sortedAsc of
+          (h:_) -> h
+          []    -> (1, 0, 0)
+      threshold = bestMSE + bestSE
+      -- 1-SE rule: 最も sparse な K (= 最小 K) で best MSE + 1·SE 以内
+      oneSe = case [k | (k, m, _) <- indexedMSEs, m <= threshold] of
+                [] -> bestK_
+                xs -> minimum xs
+  pure PLSLambdaSelection
+    { plsBestK   = bestK_
+    , plsCVMSEs  = mses
+    , plsCVSDs   = ses
+    , plsOneSeK  = oneSe
+    }
+
+mseForFold
+  :: PLSConfig
+  -> LA.Matrix Double
+  -> LA.Matrix Double
+  -> [Int]
+  -> [Int]
+  -> Double
+mseForFold cfg xMat yMat trainIdx testIdx =
+  let xTr = xMat LA.? trainIdx
+      yTr = yMat LA.? trainIdx
+      xTe = xMat LA.? testIdx
+      yTe = yMat LA.? testIdx
+  in case fitPLS cfg xTr yTr of
+       Left _    -> 1/0
+       Right fit ->
+         let yHat = predictPLS fit xTe
+             resid = yTe - yHat
+             nTe = fromIntegral (length testIdx) :: Double
+         in LA.sumElements (resid * resid) / nTe
diff --git a/src/Hanalyze/Model/PartialDependence.hs b/src/Hanalyze/Model/PartialDependence.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Model/PartialDependence.hs
@@ -0,0 +1,114 @@
+{-# LANGUAGE OverloadedStrings #-}
+-- |
+-- Module      : Hanalyze.Model.PartialDependence
+-- Description : 任意モデル対応の Partial Dependence / ICE 純粋計算エンジン (model 非依存・非ゲート層)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- 部分従属 (Partial Dependence) / ICE の純粋計算エンジン — 任意モデル対応 (Phase 75.27)。
+--
+-- R @pdp::partial@ / sklearn @sklearn.inspection.partial_dependence@ 相当。 学習済モデルの
+-- predict を「注目特徴を grid で振り、 他の特徴は訓練データの観測分布のまま」評価し、 全観測
+-- 行で平均したものが PDP、 行ごとの曲線が ICE (individual conditional expectation)。
+--
+-- model 非依存 (predict 閉包のみを受ける) ゆえ **非ゲート層** に置き、 図化は
+-- 'Hanalyze.Plot.ML' がゲート (@plot-integration@) 配下で担う。
+--
+-- @
+-- import Hanalyze.Model.PartialDependence
+--
+-- -- 任意モデルの predict 閉包を渡す (R pdp の pred.fun 流)。
+-- let r = partialDependence trainX (\\m -> map (predictRF rf) (LA.toLists m)) 0 40
+-- in  (pdpGrid r, pdpMean r)          -- 特徴 0 の PDP 曲線
+-- @
+module Hanalyze.Model.PartialDependence
+  ( -- * 結果型
+    PDPResult (..)
+    -- * 計算
+  , partialDependence
+  , partialDependenceGrid
+    -- * 変換
+  , centerICE
+  ) where
+
+import qualified Numeric.LinearAlgebra as LA
+import           Data.List             (transpose)
+
+-- ===========================================================================
+-- 結果型
+-- ===========================================================================
+
+-- | 部分従属の計算結果。 grid・PDP 平均曲線・ICE 個体曲線群をまとめて返す。
+data PDPResult = PDPResult
+  { pdpGrid :: ![Double]      -- ^ 注目特徴の grid 値 (長さ = grid 数)。
+  , pdpMean :: ![Double]      -- ^ PDP: 各 grid 値で全観測行の予測を平均 (長さ = grid 数)。
+  , pdpIce  :: ![[Double]]    -- ^ ICE: 観測行ごとの曲線 (n 本・各長さ = grid 数)。
+  } deriving (Eq, Show)
+
+-- ===========================================================================
+-- 計算
+-- ===========================================================================
+
+-- | 注目特徴 j の観測 @[min,max]@ を等間隔 grid にして PDP/ICE を計算する。
+--   grid 数 <2 は 2 に切り上げ。 空データ・列外 index は空結果 ('PDPResult' [] [] [])。
+partialDependence
+  :: LA.Matrix Double                 -- ^ 訓練特徴行列 X (n 行 × p 列)。
+  -> (LA.Matrix Double -> [Double])   -- ^ predict: 行列の各行 → 予測値 (長さ = 行数)。
+  -> Int                              -- ^ 注目特徴の列 index j (0 始まり)。
+  -> Int                              -- ^ grid 数。
+  -> PDPResult
+partialDependence x predict j n
+  | LA.rows x == 0 || j < 0 || j >= LA.cols x = PDPResult [] [] []
+  | otherwise =
+      let col  = LA.toList (LA.toColumns x !! j)
+          lo   = minimum col
+          hi   = maximum col
+          m    = max 2 n
+          grid = [ lo + (hi - lo) * fromIntegral i / fromIntegral (m - 1)
+                 | i <- [0 .. m - 1] ]
+      in partialDependenceGrid x predict j grid
+
+-- | grid を明示指定する版。 分位点 grid や任意評価点を渡したいときに使う。
+--   空 grid・空データ・列外 index は空結果。
+partialDependenceGrid
+  :: LA.Matrix Double
+  -> (LA.Matrix Double -> [Double])
+  -> Int
+  -> [Double]                         -- ^ 注目特徴の評価 grid。
+  -> PDPResult
+partialDependenceGrid x predict j grid
+  | LA.rows x == 0 || j < 0 || j >= LA.cols x || null grid = PDPResult [] [] []
+  | otherwise =
+      let nrows  = LA.rows x
+          cols   = LA.toColumns x
+          -- 各 grid 値 g で X の j 列を定数 g に置換 → 全行 predict (長さ nrows)。
+          predsAtG g =
+            let xg = LA.fromColumns
+                       [ if c == j then LA.konst g nrows else col
+                       | (c, col) <- zip [0 ..] cols ]
+            in predict xg
+          byGrid = [ predsAtG g | g <- grid ]              -- grid × n
+          means  = [ sum ps / fromIntegral nrows | ps <- byGrid ]
+          ice    = transpose byGrid                        -- n × grid (行ごとの曲線)
+      in PDPResult grid means ice
+
+-- ===========================================================================
+-- 変換
+-- ===========================================================================
+
+-- | 中心化 ICE (c-ICE)。 各 ICE 曲線を **左端 (grid[0]) の値が 0** になるよう平行移動し、
+--   PDP 平均も中心化後の ICE から取り直す。 個体間の傾き差を見やすくする
+--   (sklearn @centered=True@ / R @ice()@ centered 相当)。 空結果はそのまま。
+centerICE :: PDPResult -> PDPResult
+centerICE r
+  | null (pdpGrid r) || null (pdpIce r) = r
+  | otherwise =
+      let ice'   = [ case curve of
+                       (c0 : _) -> map (subtract c0) curve
+                       []       -> curve
+                   | curve <- pdpIce r ]
+          nrows  = length ice'
+          means' = case ice' of
+                     [] -> []
+                     _  -> map (\col -> sum col / fromIntegral nrows) (transpose ice')
+      in r { pdpMean = means', pdpIce = ice' }
diff --git a/src/Hanalyze/Model/Quantile.hs b/src/Hanalyze/Model/Quantile.hs
--- a/src/Hanalyze/Model/Quantile.hs
+++ b/src/Hanalyze/Model/Quantile.hs
@@ -1,5 +1,11 @@
 {-# LANGUAGE OverloadedStrings #-}
--- | Quantile regression.
+-- |
+-- Module      : Hanalyze.Model.Quantile
+-- Description : Quantile regression — Hunter & Lange (2000) MM 法による条件付き τ-分位点回帰
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Quantile regression.
 --
 -- Whereas OLS fits the conditional /mean/, quantile regression fits the
 -- conditional @τ@-quantile (with @τ ∈ (0, 1)@). @τ = 0.5@ gives outlier-
diff --git a/src/Hanalyze/Model/RFF.hs b/src/Hanalyze/Model/RFF.hs
--- a/src/Hanalyze/Model/RFF.hs
+++ b/src/Hanalyze/Model/RFF.hs
@@ -1,7 +1,13 @@
 {-# LANGUAGE StrictData #-}
 {-# LANGUAGE OverloadedStrings #-}
--- | Random Fourier Features (RFF) — kernel approximation.
+-- |
+-- Module      : Hanalyze.Model.RFF
+-- Description : Random Fourier Features (RFF) — Bochner の定理に基づく kernel の明示的特徴写像近似
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
 --
+-- Random Fourier Features (RFF) — kernel approximation.
+--
 -- By Bochner's theorem, a stationary kernel
 -- @k(x, x') = ∫ p(ω) e^{iω(x-x')} dω@ admits an explicit feature map
 -- defined via @D@ frequencies @ω_j@ sampled from @p(ω)@ and uniform
@@ -33,6 +39,8 @@
     -- * Feature generation
   , sampleRFFRBF
   , sampleRFFMatern52
+  , sampleRFFRBFPure
+  , sampleRFFMatern52Pure
   , rffFeatures
   , rffApproxKernel
     -- * RFF ridge regression (primary API: multi-output)
@@ -50,10 +58,15 @@
   , RFFFeaturesMV (..)
   , sampleRFFRBFMV
   , sampleRFFMatern52MV
+  , sampleRFFRBFMVPure
+  , sampleRFFMatern52MVPure
   , rffFeaturesMV
   , RFFRidgeFitMV (..)
   , rffRidgeMV
   , predictRFFRidgeMV
+  , RFFGPFitMV (..)
+  , rffGPMV
+  , predictRFFGPMV
   , RFFRidgeFitMVMO (..)
   , rffRidgeMVMulti
   , predictRFFRidgeMVMulti
@@ -63,13 +76,18 @@
   , maximizeMarginalLikRBFMV_DE
   , MLikResult (..)
     -- * LOOCV closed form (faster HP auto-tuning)
+  , loocvFromPhi
   , loocvRFFRidgeMV
   , gridSearchLOOCVRBFMV
   , gridSearchLOOCVRBFMV_DE
+  , bayesOptLOOCVRBFMV
+  , lbfgsLOOCVRBFMV
   , LOOCVResult (..)
   ) where
 
 import Control.Exception (SomeException, try, evaluate)
+import           Control.Monad.Primitive      (PrimMonad, PrimState)
+import           Data.Word                    (Word32)
 import qualified Data.Vector as V
 import qualified Data.Vector.Storable         as VS
 import qualified Data.Vector.Storable.Mutable as VSM
@@ -78,10 +96,12 @@
 import qualified System.IO.Unsafe
 import System.IO.Unsafe (unsafePerformIO)
 import qualified System.Random.MWC
-import System.Random.MWC (GenIO, uniformR)
+import System.Random.MWC (GenIO, Gen, uniformR, initialize)
 import qualified System.Random.MWC.Distributions as MWCD
 import qualified Hanalyze.Optim.DifferentialEvolution as DEM
 import qualified Hanalyze.Optim.Common as OCM
+import qualified Hanalyze.Optim.BayesOpt as BO
+import qualified Hanalyze.Optim.LBFGS as LBFGS
 import qualified Hanalyze.Stat.Cholesky as Chol
 import qualified Hanalyze.Stat.KernelDist as KD
 import qualified Data.Vector.Algorithms.Intro as Intro
@@ -113,10 +133,16 @@
 
 -- | Sample RFF features for the RBF kernel: @ω_j ~ N(0, 1/ℓ²)@,
 -- @b_j ~ U(0, 2π)@.
-sampleRFFRBF :: Int      -- ^ Feature dimension @D@.
+--
+-- 'PrimMonad' 汎用 (mwc は 'PrimMonad' 汎用ゆえ ST/IO 両経路で同コード)。
+-- IO 呼び出しは @GenIO = Gen (PrimState IO)@ ゆえ従来どおり。 純粋 (seed) 経路は
+-- 'sampleRFFRBFPure' (Phase 70.5 = 'gp' spec の RFF 近似象限を pure 'fitWith' で完結
+-- させるため・[[kMeansPure]]/[[fitRFVPure]] と一貫)。
+sampleRFFRBF :: PrimMonad m
+             => Int      -- ^ Feature dimension @D@.
              -> Double   -- ^ Length scale @ℓ@.
              -> Double   -- ^ Signal SD @σ_f@.
-             -> GenIO -> IO RFFFeatures
+             -> Gen (PrimState m) -> m RFFFeatures
 sampleRFFRBF d ell sf gen = do
   ws <- V.replicateM d (MWCD.normal 0 (1/ell) gen)
   bs <- V.replicateM d (uniformR (0, 2*pi) gen)
@@ -132,7 +158,8 @@
 -- @ω = z/√u@ where @z ~ N(0, 1/ℓ²)@ and @u ~ Gamma(ν, ν)@ with @ν = 5/2@.
 -- This is a scaled @df = 5@ Student-t distribution, matching the
 -- spectral density.
-sampleRFFMatern52 :: Int -> Double -> Double -> GenIO -> IO RFFFeatures
+sampleRFFMatern52 :: PrimMonad m
+                  => Int -> Double -> Double -> Gen (PrimState m) -> m RFFFeatures
 sampleRFFMatern52 d ell sf gen = do
   let nu = 2.5 :: Double
   ws <- V.replicateM d $ do
@@ -150,6 +177,17 @@
     , rffLengthScale = ell
     }
 
+-- | 純粋 (seed) 版 'sampleRFFRBF'。 同 seed → 同 'RFFFeatures' (ST/IO ビット一致)。
+-- 'gp' spec の @GpRff@/@RidgeRff@ 象限を pure 'fitWith' で完結させる継ぎ目。
+sampleRFFRBFPure :: Int -> Double -> Double -> Word32 -> RFFFeatures
+sampleRFFRBFPure d ell sf seed =
+  runST (initialize (V.singleton seed) >>= sampleRFFRBF d ell sf)
+
+-- | 純粋 (seed) 版 'sampleRFFMatern52'。
+sampleRFFMatern52Pure :: Int -> Double -> Double -> Word32 -> RFFFeatures
+sampleRFFMatern52Pure d ell sf seed =
+  runST (initialize (V.singleton seed) >>= sampleRFFMatern52 d ell sf)
+
 -- ---------------------------------------------------------------------------
 -- 特徴写像
 -- ---------------------------------------------------------------------------
@@ -317,7 +355,8 @@
 -- | Sample multivariate RFF features for the RBF kernel.
 -- Each component @ω_j[k] ~ N(0, 1/ℓ²)@ independently.
 sampleRFFRBFMV
-  :: Int -> Int -> Double -> Double -> GenIO -> IO RFFFeaturesMV
+  :: PrimMonad m
+  => Int -> Int -> Double -> Double -> Gen (PrimState m) -> m RFFFeaturesMV
 sampleRFFRBFMV p d ell sf gen = do
   let total = p * d
   ws <- V.replicateM total (MWCD.normal 0 (1/ell) gen)
@@ -334,7 +373,8 @@
 
 -- | Sample multivariate RFF features for the Matérn 5/2 kernel.
 sampleRFFMatern52MV
-  :: Int -> Int -> Double -> Double -> GenIO -> IO RFFFeaturesMV
+  :: PrimMonad m
+  => Int -> Int -> Double -> Double -> Gen (PrimState m) -> m RFFFeaturesMV
 sampleRFFMatern52MV p d ell sf gen = do
   let nu = 2.5 :: Double
   ws <- V.replicateM (p * d) $ do
@@ -351,6 +391,16 @@
     , rffmvLengthScale = ell
     }
 
+-- | 純粋 (seed) 版 'sampleRFFRBFMV'。
+sampleRFFRBFMVPure :: Int -> Int -> Double -> Double -> Word32 -> RFFFeaturesMV
+sampleRFFRBFMVPure p d ell sf seed =
+  runST (initialize (V.singleton seed) >>= sampleRFFRBFMV p d ell sf)
+
+-- | 純粋 (seed) 版 'sampleRFFMatern52MV'。
+sampleRFFMatern52MVPure :: Int -> Int -> Double -> Double -> Word32 -> RFFFeaturesMV
+sampleRFFMatern52MVPure p d ell sf seed =
+  runST (initialize (V.singleton seed) >>= sampleRFFMatern52MV p d ell sf)
+
 -- | Multivariate feature matrix: @X (n × p) → Φ (n × D)@.
 -- @φ_j(x) = σ_f √(2/D) cos(ω_jᵀ x + b_j)@.
 --
@@ -411,6 +461,46 @@
   let phi = rffFeaturesMV (rffrmvFeatures fit) xNew
   in LA.toList (phi LA.#> rffrmvWeights fit)
 
+-- | Multivariate-input RFF GP (Bayesian linear regression on RFF features).
+-- The multi-input analogue of 'rffGP': same posterior algebra
+-- (@Σ⁻¹ = ΦᵀΦ/σ_n² + I@, @μ = Σ Φᵀy/σ_n²@) but @Φ@ comes from
+-- 'rffFeaturesMV'. Used by the @GpRff@ quadrant of the unified @gpMulti@
+-- spec to provide a posterior-variance band under RFF approximation.
+data RFFGPFitMV = RFFGPFitMV
+  { rffgpmvFeatures :: RFFFeaturesMV
+  , rffgpmvSigma    :: LA.Matrix Double   -- ^ Posterior covariance @Σ@ (@D × D@).
+  , rffgpmvMean     :: LA.Vector Double   -- ^ Posterior mean @μ@ (length @D@).
+  , rffgpmvSigmaN   :: Double             -- ^ Observation noise SD @σ_n@.
+  } deriving (Show)
+
+-- | Fit a multivariate-input RFF Bayesian-linear-regression GP.
+rffGPMV :: RFFFeaturesMV -> LA.Matrix Double -> [Double] -> Double -> RFFGPFitMV
+rffGPMV rff x ys sigmaN =
+  let phi    = rffFeaturesMV rff x
+      d      = LA.cols (rffmvOmegas rff)
+      sigN2  = sigmaN ^ (2 :: Int)
+      yV     = LA.fromList ys
+      sigInv = LA.scale (1 / sigN2) (LA.tr phi LA.<> phi) `LA.add` LA.ident d
+      sigma  = LA.inv sigInv
+      mu     = sigma LA.#> LA.scale (1 / sigN2) (LA.tr phi LA.#> yV)
+  in RFFGPFitMV
+       { rffgpmvFeatures = rff
+       , rffgpmvSigma    = sigma
+       , rffgpmvMean     = mu
+       , rffgpmvSigmaN   = sigmaN
+       }
+
+-- | Per-test-point @(mean, variance of f)@ for an 'RFFGPFitMV'. The
+-- observation-noise term @σ_n²@ is /not/ added (matching 'predictRFFGP').
+predictRFFGPMV :: RFFGPFitMV -> LA.Matrix Double -> [(Double, Double)]
+predictRFFGPMV fit xNew =
+  let phi   = rffFeaturesMV (rffgpmvFeatures fit) xNew
+      mu    = rffgpmvMean fit
+      sigma = rffgpmvSigma fit
+      means = LA.toList (phi LA.#> mu)
+      vars  = [ max 0 (LA.dot p (sigma LA.#> p)) | p <- LA.toRows phi ]
+  in zip means vars
+
 -- | Multivariate-input multi-output RFF ridge fit. @X@ is @n × p@,
 -- @Y@ is @n × q@, weights @W@ are @D × q@.
 data RFFRidgeFitMVMO = RFFRidgeFitMVMO
@@ -829,4 +919,133 @@
     , lcLambda = bestLam
     , lcLOOCV  = bestL
     , lcGridPts = OCM.orIters r * DEM.dePopSize cfg
+    }
+
+-- | Bayesian-optimization variant of 'gridSearchLOOCVRBFMV'
+-- (金子流: 初期点 + GP 代理モデル + 獲得関数で評価回数を削減)。
+--
+-- グリッドの 160 点 (8 ℓ × 20 λ) に対し、 既定 30 評価 (init 8 + iter 22) で
+-- 同等の @(ℓ, λ)@ を (log ℓ, log λ) の 2 次元 BO ('BO.bayesOptND') で求める。
+--
+-- **RFF + BO の肝**: RFF の周波数 ω~N(0, 1/ℓ) はランダムなので、 同じ @(ℓ,λ)@ でも
+-- 引き直すと LOOCV が変わる (stochastic)。 BO は決定的目的関数を仮定するため、
+-- ここでは **基底 ω₀~N(0,1) と bias b を 1 度だけ引いて固定**し、 ℓ ごとに
+-- @ω = ω₀ / ℓ@ とスケールする。 これで LOOCV(ℓ,λ) は ℓ の決定的関数になり、 GP 代理
+-- が綺麗に乗る。 ℓ ごとに ω を引き直す grid / DE 版 (上記) より MC ノイズが小さく
+-- **むしろ安定**。 D を上げるほど RFF の分散は減る。
+bayesOptLOOCVRBFMV
+  :: Int                               -- ^ p (入力次元)
+  -> Int                               -- ^ D (特徴次元)
+  -> LA.Matrix Double                  -- ^ X
+  -> LA.Vector Double                  -- ^ y
+  -> Maybe (Int, Int)                  -- ^ (initPoints, iterations) default (8, 22) = 30 評価
+  -> System.Random.MWC.GenIO
+  -> IO LOOCVResult
+bayesOptLOOCVRBFMV p d x y mBudget gen = do
+  let (nInit, nIter) = case mBudget of { Just b -> b; Nothing -> (8, 22) }
+      yStd  = sampleStd (LA.toList y)
+      sf    = max 1e-9 yStd
+      ellM  = max 1e-3 (medianPairwiseDist x)
+      bounds =
+        [ (log (ellM * 0.05), log (ellM * 20))      -- log ℓ
+        , (log (yStd * 1e-6), log (yStd * 10))      -- log λ
+        ]
+  -- 基底周波数 ω₀~N(0,1) + bias b を 1 度だけ引いて固定 (= 決定的目的関数化)。
+  ws0 <- V.replicateM (p * d) (MWCD.normal 0 1 gen)
+  bs  <- V.replicateM d (uniformR (0, 2 * pi) gen)
+  let omega0 = LA.reshape d (LA.fromList (V.toList ws0))   -- p × d (ℓ=1 相当)
+      featsAt ell =
+        RFFFeaturesMV
+          { rffmvKernel      = RFFRBF
+          , rffmvDim         = p
+          , rffmvOmegas      = LA.scale (1 / ell) omega0   -- ω = ω₀ / ℓ
+          , rffmvBs          = bs
+          , rffmvSigmaF      = sf
+          , rffmvLengthScale = ell
+          }
+      objective [le, llam] =
+        let ell = exp le
+            lam = exp llam
+            phi = rffFeaturesMV (featsAt ell) x
+        in pure (loocvFromPhi phi y lam)
+      objective _ = pure (1 / 0)   -- 次元不一致は +∞ (起き得ないが total に)
+      cfg = BO.defaultBayesOptConfig
+              { BO.boInitPoints = nInit, BO.boIterations = nIter }
+  (_history, (bestXs, bestL)) <- BO.bayesOptND cfg 8 objective bounds gen
+  let (bestEll, bestLam) = case bestXs of
+        (le : llam : _) -> (exp le, exp llam)
+        _               -> (ellM, yStd * 1e-3)
+  return LOOCVResult
+    { lcEll = max 1e-6 bestEll
+    , lcSigmaF = sf
+    , lcLambda = max 1e-8 bestLam
+    , lcLOOCV  = bestL
+    , lcGridPts = nInit + nIter
+    }
+
+-- | L-BFGS variant of 'gridSearchLOOCVRBFMV' (固定基底 + 数値勾配 L-BFGS の多始点)。
+--
+-- 'bayesOptLOOCVRBFMV' と同じく **基底 ω₀~N(0,1) を 1 度引いて固定**し ℓ で
+-- スケールすることで LOOCV(log ℓ, log λ) を決定的・微分可能化し、 数値勾配 L-BFGS
+-- ('LBFGS.runLBFGSNumeric'、 GP の 'optimizeGP' と同じ engine) を複数始点から回して
+-- LOOCV 最小を採る。 GP の多始点 L-BFGS の RFF 版で、 評価は O(D³) なので大 n でも
+-- スケーラブル (厳密 GP marginal likelihood の O(n³) を回避)。 grid の離散性も BO の
+-- 粗いサロゲートも避け、 連続最適化で (ℓ,λ) を精密に当てる。
+lbfgsLOOCVRBFMV
+  :: Int                               -- ^ p (入力次元)
+  -> Int                               -- ^ D (特徴次元)
+  -> LA.Matrix Double                  -- ^ X
+  -> LA.Vector Double                  -- ^ y
+  -> Maybe Int                         -- ^ multi-start 数 (default 4)
+  -> System.Random.MWC.GenIO
+  -> IO LOOCVResult
+lbfgsLOOCVRBFMV p d x y mStarts gen = do
+  let nStarts = max 1 (case mStarts of { Just n -> n; Nothing -> 3 })
+      yStd    = sampleStd (LA.toList y)
+      sf      = max 1e-9 yStd
+      ellM    = max 1e-3 (medianPairwiseDist x)
+      logEll0 = log ellM
+      logLam0 = log (max 1e-12 (yStd * 1e-3))
+  -- 基底 ω₀ + bias を 1 度だけ引いて固定 (= 決定的・微分可能化)。
+  ws0 <- V.replicateM (p * d) (MWCD.normal 0 1 gen)
+  bs  <- V.replicateM d (uniformR (0, 2 * pi) gen)
+  let omega0 = LA.reshape d (LA.fromList (V.toList ws0))
+      featsAt ell =
+        RFFFeaturesMV
+          { rffmvKernel      = RFFRBF
+          , rffmvDim         = p
+          , rffmvOmegas      = LA.scale (1 / ell) omega0
+          , rffmvBs          = bs
+          , rffmvSigmaF      = sf
+          , rffmvLengthScale = ell
+          }
+      -- LOOCV (最小化対象、 lbDir 既定 = Minimize)。
+      obj [le, llam] = loocvFromPhi (rffFeaturesMV (featsAt (exp le)) x) y (exp llam)
+      obj _          = 1 / 0
+      -- 2D 目的なので maxIter は控えめで十分収束 (数値勾配が高 D で高コストなため
+      -- 評価数を抑える)。 multi-start で局所性をカバー。
+      cfg = LBFGS.defaultLBFGSConfig
+              { LBFGS.lbStop = OCM.defaultStopCriteria
+                                 { OCM.stMaxIter = 40, OCM.stTolFun = 1e-8 } }
+  -- 多始点: base + (nStarts-1) ランダム摂動 (log 空間 正規)。
+  perturbs <- mapM (\_ -> do
+                      ze <- MWCD.normal 0 1.5 gen
+                      zl <- MWCD.normal 0 2.0 gen
+                      pure [logEll0 + ze, logLam0 + zl])
+                   [1 .. nStarts - 1]
+  results <- mapM (LBFGS.runLBFGSNumeric cfg obj) ([logEll0, logLam0] : perturbs)
+  let isFin v = not (isNaN v || isInfinite v)
+      scored  = [ (OCM.orBest r, OCM.orValue r) | r <- results, isFin (OCM.orValue r) ]
+      (bestX, bestVal) = case scored of
+        [] -> ([logEll0, logLam0], obj [logEll0, logLam0])
+        _  -> foldr1 (\a b -> if snd a <= snd b then a else b) scored
+      (bLe, bLlam) = case bestX of
+        (a : b : _) -> (a, b)
+        _           -> (logEll0, logLam0)
+  return LOOCVResult
+    { lcEll     = max 1e-6 (exp bLe)
+    , lcSigmaF  = sf
+    , lcLambda  = max 1e-8 (exp bLlam)
+    , lcLOOCV   = bestVal
+    , lcGridPts = nStarts
     }
diff --git a/src/Hanalyze/Model/RandomForest.hs b/src/Hanalyze/Model/RandomForest.hs
--- a/src/Hanalyze/Model/RandomForest.hs
+++ b/src/Hanalyze/Model/RandomForest.hs
@@ -1,7 +1,13 @@
 {-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE BangPatterns #-}
--- | Random forest for regression (CART + bagging + random feature subset).
+-- |
+-- Module      : Hanalyze.Model.RandomForest
+-- Description : 回帰用 Random Forest (CART + bagging + random feature subset、行インデックス置換方式)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
 --
+-- Random forest for regression (CART + bagging + random feature subset).
+--
 -- /Performance/: this module was ported in B9b from a list-based
 -- implementation to a row-index permutation scheme, mirroring the
 -- 'Hanalyze.Model.DecisionTree' refactor:
@@ -18,27 +24,35 @@
   ( -- * Single regression tree
     Tree (..)
   , RFConfig (..)
-  , defaultRFConfig
+  , defaultRandomForest
   , buildTree
+  , buildTreeV
   , predictTree
     -- * Forest
   , RandomForest (..)
   , fitRF
   , fitRFV
+  , fitRFPure
+  , fitRFVPure
   , predictRF
   , featureImportance
+  , rfPermutationImportance
+  , defaultFeatureNames
   ) where
 
 import qualified Data.Vector                  as V
+import qualified Data.Vector.Mutable          as VM
 import qualified Data.Vector.Unboxed          as VU
 import qualified Data.Vector.Unboxed.Mutable  as VUM
 import qualified Data.Vector.Algorithms.Intro as Intro
 import qualified Numeric.LinearAlgebra        as LA
 import qualified System.Random.MWC            as MWC
 import           Control.Monad                (replicateM)
+import           Control.Monad.Primitive      (PrimMonad, PrimState)
 import           Control.Monad.ST             (runST)
-import           Data.IORef                   (IORef, newIORef, readIORef,
-                                               modifyIORef')
+import           Data.Word                    (Word32)
+import           Data.Text                    (Text)
+import qualified Data.Text                    as T
 
 -- ---------------------------------------------------------------------------
 -- Types
@@ -59,8 +73,8 @@
   , rfBootstrap  :: !Bool
   } deriving (Show)
 
-defaultRFConfig :: RFConfig
-defaultRFConfig = RFConfig
+defaultRandomForest :: RFConfig
+defaultRandomForest = RFConfig
   { rfTrees      = 100
   , rfMaxDepth   = 12
   , rfMinSamples = 3
@@ -69,44 +83,84 @@
   }
 
 data RandomForest = RandomForest
-  { rfTreesV     :: ![Tree]
-  , rfNFeatures  :: !Int
-  , rfImportance :: !(V.Vector Double)
+  { rfTreesV         :: ![Tree]
+  , rfNFeatures      :: !Int
+  , rfImportance     :: !(V.Vector Double)  -- ^ impurity/split ベース (MDI 相当・R IncNodePurity)。
+  , rfPermImportance :: !(V.Vector Double)  -- ^ permutation ベース (MSE 増加・R %IncMSE・sklearn permutation_importance)。
+  , rfFeatureNames   :: ![Text]             -- ^ 特徴列名。 df|-> 経路が実列名を設定、 低レベル行列 fit は 'defaultFeatureNames' ("f1"..)。
   } deriving (Show)
 
+-- | 名前を持たない行列入力の既定特徴名 ("f1", "f2", …・1 始まり = R/sklearn 慣例)。
+defaultFeatureNames :: Int -> [Text]
+defaultFeatureNames d = [ "f" <> T.pack (show k) | k <- [1 .. d] ]
+
 -- ---------------------------------------------------------------------------
 -- Vector-based fit (primary)
 -- ---------------------------------------------------------------------------
 
+-- | IO ラッパ。 ロジックは 'PrimMonad' 汎用の 'fitRFVM' を共有
+-- (mwc は 'PrimMonad' 汎用ゆえ ST/IO 両経路で同コード)。
 fitRFV :: RFConfig
        -> LA.Matrix Double
        -> VU.Vector Double
        -> MWC.GenIO
        -> IO RandomForest
-fitRFV cfg x y gen = do
+fitRFV = fitRFVM
+
+-- | 'PrimMonad' 汎用の forest 本体。 'fitRFV' (IO) / 'fitRFVPure' (ST) が共有。
+-- 乱数 (gen) は bootstrap index のみで使う。 木構築 'buildTreeV' と feature
+-- importance は純粋ゆえ ST/IO でビット同一。
+fitRFVM :: PrimMonad m
+        => RFConfig
+        -> LA.Matrix Double
+        -> VU.Vector Double
+        -> MWC.Gen (PrimState m)
+        -> m RandomForest
+fitRFVM cfg x y gen = do
   let !n = VU.length y
       !d = LA.cols x
-  impRef <- newIORef (V.replicate d 0.0)
   trees <- replicateM (rfTrees cfg) $ do
     !idx <- if rfBootstrap cfg
-              then bootstrapIdx n gen
+              then bootstrapIdxM n gen
               else pure (VU.enumFromN 0 n)
-    let !t = buildTreeV cfg x y idx 0
-    accumulateImportance impRef t
-    pure t
-  imp <- readIORef impRef
+    pure $! buildTreeV cfg x y idx 0
+  -- permutation importance は列シャッフルに gen を使う (bootstrap の後・seed 決定的)。
+  !perm <- permImportanceRegM x y trees gen
   pure RandomForest
-    { rfTreesV     = trees
-    , rfNFeatures  = d
-    , rfImportance = imp
+    { rfTreesV         = trees
+    , rfNFeatures      = d
+    , rfImportance     = importanceOf d trees
+    , rfPermImportance = perm
+    , rfFeatureNames   = defaultFeatureNames d
     }
 
 -- | Backwards-compatible list-based fit.
 fitRF :: RFConfig -> [[Double]] -> [Double] -> MWC.GenIO -> IO RandomForest
 fitRF cfg xs ys gen
-  | null xs   = pure (RandomForest [] 0 V.empty)
+  | null xs   = pure emptyForest
   | otherwise = fitRFV cfg (LA.fromLists xs) (VU.fromList ys) gen
 
+-- | 純粋・決定的な行列入力 forest。 同じ @seed@ なら必ず同じ 'RandomForest'。
+-- 'fitRFVM' を 'ST' で走らせ 'runST' で閉じる
+-- ([[phase-50-mcmc-purification-status]] の 'nutsPure' と同方針)。
+fitRFVPure :: RFConfig
+           -> LA.Matrix Double
+           -> VU.Vector Double
+           -> Word32
+           -> RandomForest
+fitRFVPure cfg x y seed =
+  runST (MWC.initialize (V.singleton seed) >>= fitRFVM cfg x y)
+
+-- | 純粋・決定的な list 入力 forest (list 版 'fitRF' の seed 純粋版)。
+fitRFPure :: RFConfig -> [[Double]] -> [Double] -> Word32 -> RandomForest
+fitRFPure cfg xs ys seed
+  | null xs   = emptyForest
+  | otherwise = fitRFVPure cfg (LA.fromLists xs) (VU.fromList ys) seed
+
+-- | 空データ時の forest (全フィールド空)。
+emptyForest :: RandomForest
+emptyForest = RandomForest [] 0 V.empty V.empty []
+
 -- | Single-tree builder kept for the symmetry of the old API. Most
 -- callers should use 'fitRFV'.
 buildTree :: RFConfig -> [[Double]] -> [Double] -> MWC.GenIO -> IO Tree
@@ -117,12 +171,12 @@
           !y = VU.fromList ys
           !n = VU.length y
       idx <- if rfBootstrap cfg
-               then bootstrapIdx n gen
+               then bootstrapIdxM n gen
                else pure (VU.enumFromN 0 n)
       pure (buildTreeV cfg x y idx 0)
 
-bootstrapIdx :: Int -> MWC.GenIO -> IO (VU.Vector Int)
-bootstrapIdx n gen =
+bootstrapIdxM :: PrimMonad m => Int -> MWC.Gen (PrimState m) -> m (VU.Vector Int)
+bootstrapIdxM n gen =
   VU.replicateM n (MWC.uniformR (0, n - 1) gen)
 
 -- ---------------------------------------------------------------------------
@@ -300,17 +354,83 @@
       tot = V.sum raw
   in if tot <= 0 then raw else V.map (/ tot) raw
 
+-- | Permutation importance (= 列を無作為置換したときの MSE 増加) を正の総和で
+-- 正規化して返す。 全て非正なら raw のまま (負 = その特徴が予測に無寄与)。
+-- R @randomForest %IncMSE@ / sklearn @permutation_importance@ 同方式。
+rfPermutationImportance :: RandomForest -> V.Vector Double
+rfPermutationImportance rf =
+  let raw = rfPermImportance rf
+      tot = V.sum (V.filter (> 0) raw)
+  in if tot <= 0 then raw else V.map (/ tot) raw
+
 -- ---------------------------------------------------------------------------
+-- Permutation importance (MSE 増加ベース)
+-- ---------------------------------------------------------------------------
+
+-- | 各特徴列を無作為置換し、 forest の MSE 増加量を測る (純粋・'PrimMonad')。
+-- gen は列シャッフルにのみ使う。 同 seed → ビット同一。
+permImportanceRegM :: PrimMonad m
+                   => LA.Matrix Double -> VU.Vector Double -> [Tree]
+                   -> MWC.Gen (PrimState m) -> m (V.Vector Double)
+permImportanceRegM x y trees gen
+  | LA.rows x == 0 || null trees = pure (V.replicate (LA.cols x) 0)
+  | otherwise = do
+      let !base = forestMSE x y trees
+      scores <- mapM (\j -> do
+                         xp <- permuteColM j x gen
+                         pure $! forestMSE xp y trees - base)
+                     [0 .. LA.cols x - 1]
+      pure (V.fromList scores)
+
+-- | forest の平均二乗誤差 (行毎に木予測を平均)。
+forestMSE :: LA.Matrix Double -> VU.Vector Double -> [Tree] -> Double
+forestMSE x y trees =
+  let !n = LA.rows x
+      !k = length trees
+      rowPred i =
+        let row   = LA.toList (LA.flatten (x LA.? [i]))
+            preds = map (`predictTree` row) trees
+        in if k == 0 then 0 else sum preds / fromIntegral k
+      sse = sum [ (rowPred i - y VU.! i) ^ (2 :: Int) | i <- [0 .. n - 1] ]
+  in if n == 0 then 0 else sse / fromIntegral n
+
+-- | 列 j を Fisher-Yates で置換した行列を返す (他列は不変)。
+permuteColM :: PrimMonad m
+            => Int -> LA.Matrix Double -> MWC.Gen (PrimState m) -> m (LA.Matrix Double)
+permuteColM j x gen = do
+  let cols0 = LA.toColumns x
+      colj  = VU.fromList (LA.toList (cols0 !! j))
+  shuf <- fisherYatesM gen colj
+  let newCols = [ if kk == j then LA.fromList (VU.toList shuf) else cols0 !! kk
+                | kk <- [0 .. length cols0 - 1] ]
+  pure (LA.fromColumns newCols)
+
+-- | 可変ベクトル上の Fisher-Yates シャッフル ('PrimMonad'・gen 決定的)。
+fisherYatesM :: PrimMonad m
+             => MWC.Gen (PrimState m) -> VU.Vector Double -> m (VU.Vector Double)
+fisherYatesM gen v0 = do
+  mv <- VU.thaw v0
+  let go i | i <= 0    = pure ()
+           | otherwise = do
+               j <- MWC.uniformR (0, i) gen
+               VUM.swap mv i j
+               go (i - 1)
+  go (VUM.length mv - 1)
+  VU.freeze mv
+
+-- ---------------------------------------------------------------------------
 -- Importance accumulation (per split, simple count)
 -- ---------------------------------------------------------------------------
 
-accumulateImportance :: IORef (V.Vector Double) -> Tree -> IO ()
-accumulateImportance ref = walk
-  where
-    walk (Leaf _)       = pure ()
-    walk (Node j _ l r) = do
-      modifyIORef' ref (\v ->
-        let !cur = v V.! j
-        in v V.// [(j, cur + 1.0)])
-      walk l
-      walk r
+-- | 全木の split 特徴を 1 回の可変ベクトル走査で集計 (純粋)。 旧 'IORef'
+-- 版を 'runST' + 可変ベクトルへ置換 (count の和は可換ゆえ木順不問で同値)。
+importanceOf :: Int -> [Tree] -> V.Vector Double
+importanceOf d trees = runST $ do
+  v <- VM.replicate d 0.0
+  let walk (Leaf _)       = pure ()
+      walk (Node j _ l r) = do
+        VM.modify v (+ 1.0) j
+        walk l
+        walk r
+  mapM_ walk trees
+  V.freeze v
diff --git a/src/Hanalyze/Model/RandomForestClassifier.hs b/src/Hanalyze/Model/RandomForestClassifier.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Model/RandomForestClassifier.hs
@@ -0,0 +1,236 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE BangPatterns #-}
+-- |
+-- Module      : Hanalyze.Model.RandomForestClassifier
+-- Description : Random Forest 分類版 — DecisionTree の bootstrap aggregation + OOB error + permutation importance
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Random Forest **分類版**。
+--
+-- bootstrap aggregation of 'Hanalyze.Model.DecisionTree' (CART 分類)。
+-- OOB (Out-of-Bag) error と permutation importance を併せて返す。
+module Hanalyze.Model.RandomForestClassifier
+  ( RFCConfig (..)
+  , defaultRFCConfig
+  , RFClassifierFit (..)
+  , fitRFClassifier
+  , fitRFClassifierPure
+  , predictRFClassifier
+  ) where
+
+import qualified Data.Vector                 as V
+import qualified Data.Vector.Unboxed         as VU
+import qualified Numeric.LinearAlgebra       as LA
+import qualified Data.Map.Strict             as Map
+import           Data.List                   (nub, sort, group, sortBy, foldl')
+import           Data.Ord                    (comparing, Down (..))
+import           Data.Text                   (Text)
+import           Data.Word                   (Word32)
+import qualified System.Random.MWC           as MWC
+import           Control.Monad               (forM, replicateM)
+import           Control.Monad.Primitive     (PrimMonad, PrimState)
+import           Control.Monad.ST            (runST)
+
+import qualified Hanalyze.Model.DecisionTree as DT
+import           Hanalyze.Model.RandomForest (defaultFeatureNames)
+
+-- ===========================================================================
+-- 型
+-- ===========================================================================
+
+data RFCConfig = RFCConfig
+  { rfcNTrees   :: !Int
+  , rfcMaxDepth :: !(Maybe Int)
+  , rfcMinSplit :: !Int
+  } deriving (Show)
+
+defaultRFCConfig :: RFCConfig
+defaultRFCConfig = RFCConfig
+  { rfcNTrees   = 100
+  , rfcMaxDepth = Just 10
+  , rfcMinSplit = 2
+  }
+
+data RFClassifierFit = RFClassifierFit
+  { rfcTrees          :: ![DT.DTree]
+  , rfcOOBSamples     :: ![[Int]]
+  , rfcClasses        :: ![Int]
+  , rfcOOBError       :: !Double
+  , rfcImportance     :: !(LA.Vector Double)  -- ^ permutation importance (OOB accuracy 低下)。
+  , rfcGiniImportance :: !(LA.Vector Double)  -- ^ MDI (mean decrease in gini・木構造から純粋計算・sklearn feature_importances_ 同方式)。
+  , rfcFeatureNames   :: ![Text]              -- ^ 特徴列名。 行列 fit は 'defaultFeatureNames' ("f1"..)。 実列名は df|-> 化 (後続) で。
+  , rfcConfig         :: !RFCConfig
+  } deriving (Show)
+
+-- ===========================================================================
+-- fit
+-- ===========================================================================
+
+-- | IO ラッパ。 ロジックは 'PrimMonad' 汎用の 'fitRFClassifierM' を共有。
+fitRFClassifier
+  :: RFCConfig
+  -> LA.Matrix Double
+  -> VU.Vector Int
+  -> MWC.GenIO
+  -> IO RFClassifierFit
+fitRFClassifier = fitRFClassifierM
+
+-- | 純粋・決定的な forest 分類器 (同 @seed@ → ビット同一)。 回帰の 'fitRFVPure' と同方針
+-- ([[phase-50-mcmc-purification-status]])。 df|-> ('Fit RFCSpec') 経路が使う。
+fitRFClassifierPure
+  :: RFCConfig -> LA.Matrix Double -> VU.Vector Int -> Word32 -> RFClassifierFit
+fitRFClassifierPure cfg x y seed =
+  runST (MWC.initialize (V.singleton seed) >>= fitRFClassifierM cfg x y)
+
+-- | 'PrimMonad' 汎用の forest 分類器本体。 gen は bootstrap index と permutation の
+-- 列シャッフルにのみ使う (木構築・OOB・gini は純粋ゆえ ST/IO でビット同一)。
+fitRFClassifierM
+  :: PrimMonad m
+  => RFCConfig
+  -> LA.Matrix Double
+  -> VU.Vector Int
+  -> MWC.Gen (PrimState m)
+  -> m RFClassifierFit
+fitRFClassifierM cfg x y gen = do
+  let n = LA.rows x
+      p = LA.cols x
+      classes = sort (nub (VU.toList y))
+      dtCfg = DT.defaultDecisionTree
+        { DT.dtMaxDepth        = rfcMaxDepth cfg
+        , DT.dtMinSamplesSplit = rfcMinSplit cfg
+        }
+  results <- forM [1 .. rfcNTrees cfg] $ \_ -> do
+    idxs <- replicateM n (MWC.uniformR (0, n - 1) gen)
+    let x'  = x LA.? idxs
+        y'  = VU.fromList [ y VU.! i | i <- idxs ]
+        tree = DT.fitDTV dtCfg x' y'
+        oob  = filter (`notElem` idxs) [0 .. n - 1]
+    pure (tree, oob)
+  let trees    = [ t | (t, _) <- results ]
+      oobLists = [ o | (_, o) <- results ]
+      oobErr   = computeOOB x y trees oobLists
+  -- permutation importance: fixed-seed gen for reproducibility per feature
+  imp <- permImportance gen x y trees
+  pure RFClassifierFit
+    { rfcTrees          = trees
+    , rfcOOBSamples     = oobLists
+    , rfcClasses        = classes
+    , rfcOOBError       = oobErr
+    , rfcImportance     = imp
+    , rfcGiniImportance = giniImportance p trees
+    , rfcFeatureNames   = defaultFeatureNames p
+    , rfcConfig         = cfg
+    }
+
+-- | 各サンプルを多数決で予測。
+predictRFClassifier :: RFClassifierFit -> LA.Matrix Double -> V.Vector Int
+predictRFClassifier fit xNew =
+  V.generate (LA.rows xNew) $ \i ->
+    let row = LA.toList (LA.flatten (xNew LA.? [i]))
+    in majority [ DT.predictDT t row | t <- rfcTrees fit ]
+
+-- ===========================================================================
+-- 内部
+-- ===========================================================================
+
+computeOOB
+  :: LA.Matrix Double -> VU.Vector Int -> [DT.DTree] -> [[Int]] -> Double
+computeOOB x y trees oobLists =
+  let n = LA.rows x
+      voteFor s =
+        let voters = [ t | (t, oob) <- zip trees oobLists, s `elem` oob ]
+        in if null voters then Nothing
+           else
+             let row = LA.toList (LA.flatten (x LA.? [s]))
+             in Just (majority [ DT.predictDT t row | t <- voters ])
+      voted = [ (s, p) | s <- [0 .. n - 1]
+                       , Just p <- [voteFor s] ]
+      nTotal = length voted
+      nErr   = length [ () | (s, p) <- voted, p /= (y VU.! s) ]
+  in if nTotal == 0 then 0 else fromIntegral nErr / fromIntegral nTotal
+
+majority :: [Int] -> Int
+majority xs =
+  let grouped = map (\g -> (head g, length g)) (group (sort xs))
+  in case sortBy (comparing (Down . snd)) grouped of
+       ((c, _) : _) -> c
+       []           -> 0
+
+-- | Mean Decrease in Impurity (gini) per feature, summed over all trees
+-- (sklearn @feature_importances_@ 同方式・木構造から純粋計算)。 各内部ノードの
+-- 重み付き gini 減少 @n·(imp − (nL/n)·impL − (nR/n)·impR)@ を分割特徴に加算し、
+-- 全木ぶん合計 → 合計 1 に正規化。 'DT.DTree' の 75.23 拡張 (dnN/dnImpurity) を使う。
+giniImportance :: Int -> [DT.DTree] -> LA.Vector Double
+giniImportance p trees =
+  let m0 = Map.fromList [ (j, 0 :: Double) | j <- [0 .. p - 1] ]
+      go m (DT.DLeaf{}) = m
+      go m (DT.DNode { DT.dnFeature = j, DT.dnLeft = l, DT.dnRight = r
+                     , DT.dnN = nn, DT.dnImpurity = imp }) =
+        let n    = fromIntegral nn :: Double
+            nL   = fromIntegral (nodeN l)
+            nR   = fromIntegral (nodeN r)
+            dec  = if n <= 0 then 0
+                   else n * (imp - (nL / n) * nodeImp l - (nR / n) * nodeImp r)
+            m'   = Map.insertWith (+) j dec m
+        in go (go m' l) r
+      accM = foldl' go m0 trees
+      raw  = [ Map.findWithDefault 0 j accM | j <- [0 .. p - 1] ]
+      tot  = sum raw
+  in LA.fromList (if tot <= 0 then raw else map (/ tot) raw)
+
+-- | ノードのサンプル数 / gini 不純度 (葉・内部で共通アクセス)。
+nodeN :: DT.DTree -> Int
+nodeN (DT.DLeaf{ DT.dlN = n }) = n
+nodeN (DT.DNode{ DT.dnN = n }) = n
+
+nodeImp :: DT.DTree -> Double
+nodeImp (DT.DLeaf{ DT.dlImpurity = i }) = i
+nodeImp (DT.DNode{ DT.dnImpurity = i }) = i
+
+permImportance
+  :: PrimMonad m
+  => MWC.Gen (PrimState m) -> LA.Matrix Double -> VU.Vector Int -> [DT.DTree]
+  -> m (LA.Vector Double)
+permImportance gen x y trees = do
+  let p = LA.cols x
+      baseAcc = forestAccuracy x y trees
+  scores <- forM [0 .. p - 1] $ \j -> do
+    xPerm <- permuteColumn j gen x
+    let acc = forestAccuracy xPerm y trees
+    pure (baseAcc - acc)
+  pure (LA.fromList scores)
+
+forestAccuracy :: LA.Matrix Double -> VU.Vector Int -> [DT.DTree] -> Double
+forestAccuracy x y trees =
+  let n = LA.rows x
+      preds =
+        [ let row = LA.toList (LA.flatten (x LA.? [i]))
+          in majority [ DT.predictDT t row | t <- trees ]
+        | i <- [0 .. n - 1] ]
+      correct = length [ () | (p_, i) <- zip preds [0 ..]
+                            , p_ == (y VU.! i) ]
+  in fromIntegral correct / fromIntegral n
+
+permuteColumn :: PrimMonad m
+              => Int -> MWC.Gen (PrimState m) -> LA.Matrix Double -> m (LA.Matrix Double)
+permuteColumn j gen x = do
+  let col = LA.toList (LA.flatten (x LA.¿ [j]))
+  shuf <- fisherYates gen col
+  let newCol = LA.fromList shuf
+      cols = [ if k == j then newCol else LA.flatten (x LA.¿ [k])
+             | k <- [0 .. LA.cols x - 1] ]
+  pure (LA.fromColumns cols)
+
+fisherYates :: PrimMonad m => MWC.Gen (PrimState m) -> [a] -> m [a]
+fisherYates gen xs =
+  let v0 = V.fromList xs
+  in go v0 (V.length v0 - 1)
+  where
+    go v 0 = pure (V.toList v)
+    go v i = do
+      j <- MWC.uniformR (0, i) gen
+      let vi = v V.! i
+          vj = v V.! j
+          v' = v V.// [(i, vj), (j, vi)]
+      go v' (i - 1)
diff --git a/src/Hanalyze/Model/Regularized.hs b/src/Hanalyze/Model/Regularized.hs
--- a/src/Hanalyze/Model/Regularized.hs
+++ b/src/Hanalyze/Model/Regularized.hs
@@ -1,7 +1,13 @@
 {-# LANGUAGE StrictData #-}
 {-# LANGUAGE OverloadedStrings #-}
--- | Regularized regression (Ridge / Lasso / Elastic Net) in one module.
+-- |
+-- Module      : Hanalyze.Model.Regularized
+-- Description : 正則化回帰 (Ridge / Lasso / Elastic Net) を単一 API に統合したモジュール
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
 --
+-- Regularized regression (Ridge / Lasso / Elastic Net) in one module.
+--
 -- The penalty is encoded as the sum type 'Penalty', and 'fitRegularized'
 -- handles all four models:
 --
@@ -18,6 +24,8 @@
   ( Penalty (..)
   , RegFit (..)
   , fitRegularized
+  , fitRidge
+  , fitElasticNet
   , predictRegularized
   , standardize
   , unstandardizeBeta
@@ -31,6 +39,19 @@
   , fitRegularizedWith
     -- * Regularization path
   , regularizationPath
+
+    -- * λ 自動選択 (Phase 4.4、 request/150)
+  , PenaltyKind (..)
+  , LambdaSelection (..)
+  , selectLambdaCV
+  , selectLambdaCVPure
+
+    -- * Phase 31: CD 内部プリミティブの再利用 (RegularizedAdvanced 用)
+  , softThreshold
+  , cdLoop
+  , mkRegFit
+  , fitOLS
+  , fitLasso
   ) where
 
 import qualified Data.Vector                  as V
@@ -38,8 +59,14 @@
 import qualified Data.Vector.Storable.Mutable as VSM
 import qualified Numeric.LinearAlgebra        as LA
 import           Control.Monad                (forM_, when)
-import           Data.List                    (foldl')
+import           Control.Monad.Primitive      (PrimMonad, PrimState)
+import           Control.Monad.ST             (runST)
+import           Data.List                    (foldl', sortBy)
+import           Data.Ord                     (comparing)
+import           Data.Word                    (Word32)
 import           System.IO.Unsafe             (unsafePerformIO)
+import qualified System.Random.MWC            as MWC
+import qualified Hanalyze.Stat.CV             as HCV
 
 -- ---------------------------------------------------------------------------
 -- ペナルティ型
@@ -539,3 +566,114 @@
   [ (lam, LA.toList (rfBeta (fitRegularized (mkPen lam) x y)))
   | lam <- lambdas ]
 
+
+-- ===========================================================================
+-- λ 自動選択 (Phase 4.4、 request/150)
+-- ===========================================================================
+
+-- | Penalty の "形" (λ 抜き)。 'selectLambdaCV' の grid 探索で λ を変化させる
+-- 際の penalty family を指定する。
+data PenaltyKind
+  = KindRidge                -- ^ Ridge (= 'L2' λ)
+  | KindLasso                -- ^ Lasso (= 'L1' λ)
+  | KindElasticNet !Double   -- ^ ElasticNet。 @α@ = L1 比率 (0 ≤ α ≤ 1)。
+                             --   total penalty = λ·(α·L1 + (1-α)/2·L2)、 内部で
+                             --   'ElasticNet' (α·λ) ((1-α)·λ) に展開。
+  deriving (Show, Eq)
+
+-- | λ 自動選択の結果。
+data LambdaSelection = LambdaSelection
+  { lsBestLambda  :: !Double      -- ^ CV MSE が最小の λ
+  , lsLambdas     :: ![Double]    -- ^ 検証した λ 値 (入力順)
+  , lsCVScores    :: ![Double]    -- ^ 各 λ の CV MSE (lsLambdas と対応)
+  , lsCVScoreSE   :: ![Double]    -- ^ 各 λ の CV MSE の標準誤差 (fold 間 SD)
+  , lsOneSeLambda :: !Double      -- ^ 1-SE rule の λ (best ± 1·SE 範囲内で
+                                  --   最大スパース = 最大 λ)
+  , lsKind        :: !PenaltyKind -- ^ 入力 PenaltyKind を保持 (canvas 側参照用)
+  } deriving (Show)
+
+-- | k-fold CV で λ を自動選択。
+--
+-- 入力 'PenaltyKind' に従って λ grid を Ridge/Lasso/EN の 'Penalty' に展開し、
+-- 各 λ について k-fold CV を実行、 fold 平均 MSE を計算する。
+--
+-- 返り値の 'lsBestLambda' は MSE 最小の λ、 'lsOneSeLambda' は 1-SE rule
+-- (= best MSE から 1·SE 以内で最大スパースな λ) の λ。
+selectLambdaCV
+  :: PrimMonad m
+  => Int               -- ^ k-fold の k (≥ 2)
+  -> PenaltyKind       -- ^ Ridge / Lasso / ElasticNet
+  -> [Double]          -- ^ 検証する λ grid (log-spaced 推奨)
+  -> LA.Matrix Double  -- ^ X (n × p)
+  -> LA.Vector Double  -- ^ y (n)
+  -> MWC.Gen (PrimState m)  -- ^ shuffle 用 (ST/IO 両用)
+  -> m LambdaSelection
+selectLambdaCV k kind lambdas xMat yVec gen = do
+  let n = LA.rows xMat
+  folds <- HCV.kFold k n gen
+  let perLambda lam =
+        let scores =
+              [ mseForFold (penaltyOf kind lam) xMat yVec trainIdx testIdx
+              | (trainIdx, testIdx) <- folds, not (null testIdx)
+              ]
+            !nFolds = fromIntegral (length scores) :: Double
+            mean   = sum scores / nFolds
+            varN   = sum [(s - mean) ** 2 | s <- scores] / max 1 (nFolds - 1)
+            !se    = sqrt (varN / nFolds)
+        in (mean, se)
+      stats = map perLambda lambdas
+      mses  = map fst stats
+      ses   = map snd stats
+      indexedMSEs = zip3 lambdas mses ses
+      sortedAsc   = sortBy (comparing (\(_, m, _) -> m)) indexedMSEs
+      (bestL, bestMSE, bestSE) =
+        case sortedAsc of
+          (h:_) -> h
+          []    -> (0, 0, 0)
+      threshold = bestMSE + bestSE
+      -- 1-SE λ: best から 1·SE 以内の λ のうち最大 (= 最大スパース)
+      oneSe =
+        let cands = [ lam | (lam, m, _) <- indexedMSEs, m <= threshold ]
+        in if null cands then bestL else maximum cands
+  pure LambdaSelection
+    { lsBestLambda  = bestL
+    , lsLambdas     = lambdas
+    , lsCVScores    = mses
+    , lsOneSeLambda = oneSe
+    , lsCVScoreSE   = ses
+    , lsKind        = kind
+    }
+
+-- | 純粋 (seed) 版 'selectLambdaCV'。 同 seed → 同 λ 選択 (ST/IO ビット一致)。
+-- 罰則回帰の高レベル spec (Phase 70.7 = `df |-> lasso …`) を pure 'fitWith' で完結
+-- させる継ぎ目 (GP の `AutoCV` / `kMeansPure` / `fitRFVPure` と一貫)。
+selectLambdaCVPure
+  :: Int -> PenaltyKind -> [Double] -> LA.Matrix Double -> LA.Vector Double
+  -> Word32 -> LambdaSelection
+selectLambdaCVPure k kind lambdas xMat yVec seed =
+  runST (MWC.initialize (V.singleton seed) >>= selectLambdaCV k kind lambdas xMat yVec)
+
+-- | 内部 helper: PenaltyKind と λ から具体 'Penalty' を組み立てる。
+penaltyOf :: PenaltyKind -> Double -> Penalty
+penaltyOf KindRidge          lam = L2 lam
+penaltyOf KindLasso          lam = L1 lam
+penaltyOf (KindElasticNet a) lam = ElasticNet (a * lam) ((1 - a) * lam)
+
+-- | 1 fold の MSE を返す。 train index で fit、 test index で predict + 残差²平均。
+mseForFold
+  :: Penalty
+  -> LA.Matrix Double
+  -> LA.Vector Double
+  -> [Int]            -- train 行 index
+  -> [Int]            -- test 行 index
+  -> Double
+mseForFold pen xMat yVec trainIdx testIdx =
+  let xTr = xMat LA.? trainIdx
+      yTr = LA.fromList [ yVec LA.! i | i <- trainIdx ]
+      xTe = xMat LA.? testIdx
+      yTe = LA.fromList [ yVec LA.! i | i <- testIdx ]
+      fit = fitRegularized pen xTr yTr
+      yHat = predictRegularized fit xTe
+      resid = yTe - yHat
+      nTe = fromIntegral (length testIdx) :: Double
+  in LA.sumElements (resid * resid) / nTe
diff --git a/src/Hanalyze/Model/RegularizedAdvanced.hs b/src/Hanalyze/Model/RegularizedAdvanced.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Model/RegularizedAdvanced.hs
@@ -0,0 +1,268 @@
+-- |
+-- Module      : Hanalyze.Model.RegularizedAdvanced
+-- Description : 高度な罰則項回帰 (Phase 31) — Adaptive Lasso / MCP / SCAD / Group Lasso
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- 高度な罰則項回帰 (Phase 31): Adaptive Lasso / MCP / SCAD / Group Lasso。
+--
+-- 既存 'Hanalyze.Model.Regularized' (Lasso/Ridge/Elastic Net + CV λ 選択、
+-- Phase 4 で実装) を補完する変数選択型の罰則項群。 JMP "Generalized
+-- Regression" platform / R `ncvreg` / `grpreg` / `glmnet` (adaptive オプション)
+-- 相当。
+--
+-- ## 共通の前提
+--
+-- - 罰則項は Lasso 同様 X の列スケールに敏感。 呼び出し側で
+--   'Hanalyze.Model.Regularized.standardize' しておく
+-- - 内部 CD は 'Hanalyze.Model.Regularized.cdLoop' を流用 (Adaptive Lasso は
+--   列再重み付け、 MCP / SCAD は per-coord non-convex threshold)
+-- - Group Lasso は block CD で別ループ (Yuan-Lin 2006 algorithm)
+--
+-- Reference:
+--   Zou (2006), Zhang (2010), Fan-Li (2001), Yuan-Lin (2006),
+--   Breheny-Huang (2011) "Coordinate descent algorithms for non-convex
+--   penalized regression". Ann. Appl. Stat. 5:232-253.
+module Hanalyze.Model.RegularizedAdvanced
+  ( -- * Adaptive Lasso (Zou 2006)
+    fitAdaptiveLasso
+  , adaptiveWeightsFromOLS
+    -- * MCP (Zhang 2010)
+  , fitMCP
+    -- * SCAD (Fan-Li 2001)
+  , fitSCAD
+    -- * Group Lasso (Yuan-Lin 2006)
+  , fitGroupLasso
+  ) where
+
+import qualified Numeric.LinearAlgebra        as LA
+import           Hanalyze.Model.Regularized
+                   (RegFit (..), Penalty (..), softThreshold, cdLoop,
+                    mkRegFit, fitOLS, fitLasso)
+
+-- ---------------------------------------------------------------------------
+-- 31-A1: Adaptive Lasso
+-- ---------------------------------------------------------------------------
+
+-- | Adaptive Lasso (Zou 2006): @argmin (1/2n)|y - Xβ|² + λ Σ w_j |β_j|@。
+--
+-- 解法: column reweighting trick — @x_j' = x_j / w_j@ で変形すると標準
+-- Lasso になり、 解 @β_j' = β_j · w_j@ から @β_j = β_j' / w_j@ で復元できる。
+-- 既存 'fitLasso' をそのまま流用するので追加 CD ループ不要。
+--
+-- @w_j@ は典型的に OLS pilot 推定値から構築する ('adaptiveWeightsFromOLS')。
+--
+-- 注意: @w_j = 0@ は "罰則ゼロ" ではなく実装上 "@β_j = 0@ 強制" として扱う
+-- (列 j を 0 vector に潰すため)。 罰則ゼロにしたい場合は @w_j@ を非常に
+-- 小さい正値にする。
+fitAdaptiveLasso
+  :: Double                -- ^ @λ@
+  -> LA.Vector Double      -- ^ weights @w@ (length @p@、 全 @≥ 0@)
+  -> LA.Matrix Double      -- ^ X (n × p)
+  -> LA.Vector Double      -- ^ y
+  -> Int                   -- ^ max CD iterations
+  -> Double                -- ^ tolerance
+  -> RegFit
+fitAdaptiveLasso lambda w x y maxIter tol =
+  let invW   = LA.cmap (\wj -> if wj <= 0 then 0 else 1 / wj) w
+      xRew   = x LA.<> LA.diag invW
+      lassoF = fitLasso lambda xRew y maxIter tol
+      -- 変形空間の解 β' を元の空間の β = β' / w に戻す
+      betaP  = rfBeta lassoF
+      beta   = invW * betaP
+      yHat   = x LA.#> beta
+      r      = y - yHat
+  in mkRegFit beta yHat r y (L1 lambda) (rfIters lassoF)
+
+-- | OLS pilot 推定値から Adaptive Lasso 重み @w_j = 1 / |β̂_j^OLS|^γ@ を構築。
+-- 典型値 @γ = 1@。 OLS が定義できないケース (@n < p@) では事前に Ridge pilot
+-- に切り替えるなど呼び出し側で工夫する。 0 除算回避のため @|β̂| ≤ 1e-8@ の
+-- 場合は floor @1e-8@ を使う。
+adaptiveWeightsFromOLS
+  :: Double                -- ^ @γ@ (typical 1.0)
+  -> LA.Matrix Double -> LA.Vector Double -> LA.Vector Double
+adaptiveWeightsFromOLS gamma x y =
+  let beta0 = rfBeta (fitOLS x y)
+  in LA.cmap (\b -> 1 / (max 1e-8 (abs b) ** gamma)) beta0
+
+-- ---------------------------------------------------------------------------
+-- 31-A2: MCP (Minimax Concave Penalty、 Zhang 2010)
+-- ---------------------------------------------------------------------------
+
+-- | MCP non-convex 罰則:
+--
+-- @
+--   p_{λ,γ}(β) = λ |β| - β²/(2γ)   if |β| ≤ γλ
+--              = γλ²/2              if |β| > γλ
+-- @
+--
+-- @γ → ∞@ で Lasso に縮退、 @γ → 1@ で hard-threshold 寄りになる。 典型値
+-- @γ ∈ [2, 5]@。
+--
+-- Coordinate descent 更新 (Breheny-Huang 2011, with column-norm @cSq@):
+--
+-- @
+--   z = ρ_j
+--   β_j = S(z, λ) / (cSq - 1/γ)   if |z| ≤ γλ·cSq
+--       = z / cSq                  if |z| > γλ·cSq
+-- @
+--
+-- 前提: @cSq > 1/γ@ (= 罰則項の凹性を局所凸性が上回る)。 標準化 @X@ (cSq ≈ 1)
+-- で @γ > 1@ なら自動的に満たす。 違反時は inner CD が発散する可能性があり、
+-- 呼び出し側で @standardize@ + @γ ≥ 3@ を推奨。
+fitMCP
+  :: Double                -- ^ @λ@
+  -> Double                -- ^ @γ@ (concavity、 推奨 @≥ 3@)
+  -> LA.Matrix Double      -- ^ X
+  -> LA.Vector Double      -- ^ y
+  -> Int                   -- ^ max CD iterations
+  -> Double                -- ^ tolerance
+  -> RegFit
+fitMCP lambda gamma x y maxIter tol =
+  let upd rho cSq =
+        let z      = rho
+            thresh = gamma * lambda * cSq
+        in if abs z <= thresh
+             then
+               let denom = cSq - 1 / gamma
+               in if denom <= 0
+                    then z / cSq                       -- 非凸時は OLS 解で fallback
+                    else softThreshold z lambda / denom
+             else z / cSq
+      (betaFinal, iters) = cdLoop x y maxIter tol upd
+      yHat = x LA.#> betaFinal
+      r    = y - yHat
+  in mkRegFit betaFinal yHat r y (L1 lambda) iters
+
+-- ---------------------------------------------------------------------------
+-- 31-A3: SCAD (Smoothly Clipped Absolute Deviation、 Fan-Li 2001)
+-- ---------------------------------------------------------------------------
+
+-- | SCAD non-convex 罰則 (区分三次):
+--
+-- @
+--   p'_{λ,a}(|β|) = λ                    if |β| ≤ λ
+--                 = (aλ - |β|)/(a-1)     if λ < |β| ≤ aλ
+--                 = 0                    if |β| > aλ
+-- @
+--
+-- 典型値 @a = 3.7@ (Fan-Li 2001 推奨)。
+--
+-- Coordinate descent 更新 (Breheny-Huang 2011):
+--
+-- @
+--   z = ρ_j
+--   if |z| ≤ λ·(1 + cSq) :        β_j = S(z, λ) / cSq        -- Lasso 領域
+--   elif |z| ≤ a·λ·cSq :          β_j = S(z, aλ/(a-1)) / (cSq - 1/(a-1))
+--   else :                         β_j = z / cSq               -- OLS 領域
+-- @
+fitSCAD
+  :: Double                -- ^ @λ@
+  -> Double                -- ^ @a@ (= 3.7 推奨)
+  -> LA.Matrix Double
+  -> LA.Vector Double
+  -> Int -> Double
+  -> RegFit
+fitSCAD lambda a x y maxIter tol =
+  let upd rho cSq =
+        let z = rho
+            absZ = abs z
+        in if absZ <= lambda * (1 + cSq)
+             then softThreshold z lambda / cSq
+             else if absZ <= a * lambda * cSq
+                    then
+                      let denom = cSq - 1 / (a - 1)
+                          thr   = a * lambda / (a - 1)
+                      in if denom <= 0
+                           then z / cSq
+                           else softThreshold z thr / denom
+                    else z / cSq
+      (betaFinal, iters) = cdLoop x y maxIter tol upd
+      yHat = x LA.#> betaFinal
+      r    = y - yHat
+  in mkRegFit betaFinal yHat r y (L1 lambda) iters
+
+-- ---------------------------------------------------------------------------
+-- 31-A4: Group Lasso (Yuan-Lin 2006)
+-- ---------------------------------------------------------------------------
+
+-- | Group Lasso: @argmin (1/2n)|y - Xβ|² + λ Σ_g √|g| · |β_g|₂@
+-- (group ごと L2 ノルムの和で penalize、 group 全体を 0 / non-0 にする)。
+--
+-- 解法: block coordinate descent。 各 group @g@ について部分残差
+-- @r_g = r + X_g β_g@ を作り、 group 更新
+--
+-- @
+--   z_g = X_gᵀ r_g / n
+--   β_g_new = (1 - λ √|g| / |z_g|₂)_+ · z_g / cSq_g
+-- @
+--
+-- ここで @cSq_g = |X_g|² / n@ (group 内列ノルム合計、 簡易には 1 を仮定)、
+-- @(·)_+@ は max(·, 0)。 Yuan-Lin 2006 の uncorrelated-within-group 想定で
+-- 動く simplified version。
+--
+-- @groups@ は @[[Int]]@ で、 各内側リストが列 index の集合 (重複・順不同可)。
+-- 列 index が複数 group に現れた場合は最初の group のみ扱われる。
+fitGroupLasso
+  :: Double                -- ^ @λ@
+  -> [[Int]]               -- ^ group 分割 (列 index)
+  -> LA.Matrix Double      -- ^ X (n × p)
+  -> LA.Vector Double      -- ^ y
+  -> Int                   -- ^ max iterations
+  -> Double                -- ^ tolerance
+  -> RegFit
+fitGroupLasso lambda groups x y maxIter tol =
+  let n       = LA.rows x
+      nD      = fromIntegral n :: Double
+      p       = LA.cols x
+      -- group ごとに前計算する design submatrix と column-norm sum
+      gPrep   = [ (gValid, x LA.¿ gValid, gSize gValid)
+                | g <- groups
+                , let gValid = [j | j <- g, j >= 0, j < p]
+                , not (null gValid) ]
+      gSize g = sqrt (fromIntegral (length g))   -- √|g|
+      -- 反復: β_g を block 更新
+      step beta resid =
+        foldl
+          (\(bAcc, rAcc) (gIdx, xG, gW) ->
+              let -- 部分残差 r_g = r + X_g β_g
+                  bG     = LA.fromList [ LA.atIndex bAcc j | j <- gIdx ]
+                  rG     = rAcc + xG LA.#> bG
+                  z      = LA.tr xG LA.#> rG / LA.scalar nD
+                  zNorm  = LA.norm_2 z
+                  cSqG   = LA.sumElements (xG * xG) / nD
+                  thr    = lambda * gW
+                  bGnew  = if zNorm <= thr || cSqG <= 0
+                             then LA.konst 0 (LA.size z)
+                             else LA.scale ((1 - thr / zNorm) / cSqG) z
+                  -- 残差を新 β_g で更新: r ← r - X_g (β_g_new - β_g)
+                  rNew   = rG - xG LA.#> bGnew
+                  bAcc'  = updateIndices bAcc gIdx (LA.toList bGnew)
+              in (bAcc', rNew))
+          (beta, resid) gPrep
+      loop !k !beta !resid =
+        if k >= maxIter
+          then (beta, k)
+          else
+            let (betaNew, residNew) = step beta resid
+                diff = LA.norm_2 (betaNew - beta)
+            in if diff < tol
+                 then (betaNew, k + 1)
+                 else loop (k + 1) betaNew residNew
+      beta0 = LA.konst 0 p
+      (betaFinal, iters) = loop 0 beta0 y
+      yHat  = x LA.#> betaFinal
+      r     = y - yHat
+  in mkRegFit betaFinal yHat r y (L1 lambda) iters
+
+-- | Vector の特定 index 群を新値で置き換える (immutable 経由)。 Group Lasso
+-- 専用のため module 内部 helper。
+updateIndices :: LA.Vector Double -> [Int] -> [Double] -> LA.Vector Double
+updateIndices v idx vals =
+  let xs = LA.toList v
+      m  = zip idx vals
+      n  = length xs
+      lookupNew j = case lookup j m of
+        Just nv -> nv
+        Nothing -> xs !! j
+  in LA.fromList [ lookupNew j | j <- [0 .. n - 1] ]
diff --git a/src/Hanalyze/Model/Reliability.hs b/src/Hanalyze/Model/Reliability.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Model/Reliability.hs
@@ -0,0 +1,268 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE BangPatterns #-}
+-- |
+-- Module      : Hanalyze.Model.Reliability
+-- Description : 信頼性解析 — 加速寿命試験モデル群 (Arrhenius / Eyring / Inverse Power Law)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- 信頼性解析: 加速寿命試験のモデル群。
+--
+-- ストレス変数 (温度 / 電圧 / 湿度等) と寿命の関係を回帰し、 使用条件下での
+-- 寿命予測や加速係数を計算する。
+--
+-- 提供するモデル:
+--
+--   * 'fitArrhenius' — 温度ストレス: @t = A · exp(Ea / (k_B · T))@
+--   * 'fitEyring' — 温度 + 1 ストレス: 半導体 EM 等
+--   * 'fitInversePower' — 電圧 / 機械応力: @t = A · S^(-n)@
+--
+-- いずれも対数寿命を線形モデルとして fit する (古典的アプローチ)。
+-- 寿命分布の指定が必要な場合は 'Hanalyze.Model.Weibull' の MLE 結果を
+-- 入力として渡すバリアント (本モジュールの提供外、 別フェーズで検討)。
+module Hanalyze.Model.Reliability
+  ( -- * Arrhenius
+    ArrheniusFit (..)
+  , fitArrhenius
+  , accelerationFactor
+    -- * Eyring
+  , EyringFit (..)
+  , fitEyring
+    -- * Inverse Power Law
+  , InversePowerFit (..)
+  , fitInversePower
+    -- * 共通定数
+  , kBoltzmann
+  ) where
+
+import qualified Numeric.LinearAlgebra as LA
+import           Data.Text (Text)
+
+-- ===========================================================================
+-- 共通定数
+-- ===========================================================================
+
+-- | Boltzmann 定数 (eV/K)。 Arrhenius / Eyring で温度ストレスに使う。
+kBoltzmann :: Double
+kBoltzmann = 8.617333262145e-5
+
+-- ===========================================================================
+-- Arrhenius モデル
+-- ===========================================================================
+
+-- | Arrhenius fit: @t = A · exp(Ea / (k_B · T))@
+data ArrheniusFit = ArrheniusFit
+  { afA      :: !Double  -- ^ 前指数因子 A
+  , afEa     :: !Double  -- ^ 活性化エネルギー Ea (eV)
+  , afLogLik :: !Double  -- ^ 対数尤度 (Gaussian residual 仮定)
+  , afN      :: !Int     -- ^ 観測 (温度 × 寿命) 数
+  } deriving (Show)
+
+-- | Arrhenius モデルの fit。
+--
+-- 入力: @[(temperature_K, [lifetimes])]@ の対、 温度ごとに複数寿命を観測。
+-- 解法: log t = log A + Ea/k_B · (1/T) を OLS で解く (= 線形回帰)。
+-- 戻り値: @A@ と @Ea (eV)@ の点推定、 log-likelihood (Gaussian residual 仮定)。
+--
+-- 失敗条件:
+--
+--   * 入力が空または全観測 0 個 → Left
+--   * 温度水準が 1 種類しかない (= 傾き決定不能) → Left
+--   * 任意の温度 ≤ 0 や寿命 ≤ 0 → Left (log 取得不能)
+fitArrhenius :: [(Double, [Double])] -> Either Text ArrheniusFit
+fitArrhenius input = do
+  () <- if null input then Left "fitArrhenius: empty input" else Right ()
+  let allPairs =
+        [ (t, life)
+        | (t, lives) <- input
+        , life <- lives
+        ]
+  () <- if null allPairs
+          then Left "fitArrhenius: no lifetime observations across all temperatures"
+          else Right ()
+  () <- if any (\(t, l) -> t <= 0 || l <= 0) allPairs
+          then Left "fitArrhenius: temperatures and lifetimes must all be > 0"
+          else Right ()
+  let distinctTemps = length (nubByDouble (map fst allPairs))
+  () <- if distinctTemps < 2
+          then Left "fitArrhenius: need at least 2 distinct temperatures"
+          else Right ()
+  -- (x, y) = (1/T, log t)
+  let xs = map (\(t, _) -> 1 / t) allPairs
+      ys = map (\(_, l) -> log l) allPairs
+      n  = length allPairs
+      meanX = sum xs / fromIntegral n
+      meanY = sum ys / fromIntegral n
+      sxx = sum [ (x - meanX) ** 2 | x <- xs ]
+      sxy = sum [ (x - meanX) * (y - meanY) | (x, y) <- zip xs ys ]
+  () <- if sxx <= 0
+          then Left "fitArrhenius: zero variance in 1/T (numerical issue)"
+          else Right ()
+  let b1     = sxy / sxx                     -- slope = Ea / k_B
+      b0     = meanY - b1 * meanX            -- intercept = log A
+      a      = exp b0
+      ea     = b1 * kBoltzmann
+      yHat   = [ b0 + b1 * x | x <- xs ]
+      sse    = sum [ (y - yh) ** 2 | (y, yh) <- zip ys yHat ]
+      sigma2 = if n > 2 then sse / fromIntegral (n - 2) else sse / fromIntegral n
+      ll     = -0.5 * fromIntegral n * (log (2 * pi * sigma2) + 1)
+  Right ArrheniusFit
+    { afA      = a
+    , afEa     = ea
+    , afLogLik = ll
+    , afN      = n
+    }
+
+-- | 重複除去 (浮動小数点許容なし、 完全一致のみ)。
+nubByDouble :: [Double] -> [Double]
+nubByDouble = go []
+  where
+    go acc []     = reverse acc
+    go acc (x:xs) | x `elem` acc = go acc xs
+                  | otherwise    = go (x : acc) xs
+
+-- | 加速係数 AF = exp(Ea/k_B · (1/T_use - 1/T_test))
+accelerationFactor :: ArrheniusFit -> Double -> Double -> Double
+accelerationFactor fit tUse tTest =
+  exp (afEa fit / kBoltzmann * (1/tUse - 1/tTest))
+
+-- ===========================================================================
+-- Eyring モデル (Phase 2.6)
+-- ===========================================================================
+
+-- | Eyring fit: @t = A · T^(-1) · exp(Ea / (k_B · T)) · exp(B · S)@
+-- (温度 T と 1 ストレス変数 S)
+data EyringFit = EyringFit
+  { efA      :: !Double
+  , efEa     :: !Double
+  , efB      :: !Double  -- ストレス係数
+  , efLogLik :: !Double
+  , efN      :: !Int
+  } deriving (Show)
+
+-- | Eyring モデルの fit。
+--
+-- モデル: @t · T = A · exp(Ea / (k_B · T)) · exp(B · S)@
+-- 等価に: @log t = log A − log T + Ea/(k_B · T) + B · S@
+--
+-- 入力: @[(temperature_K, stress, [lifetimes])]@。 各 (T, S) 組合せで複数寿命可。
+-- 解法: y = log t + log T を (1/T, S) の 2 変量 OLS で fit (intercept 含む)。
+--   β0 = log A、 β1 = Ea / k_B、 β2 = B
+fitEyring :: [(Double, Double, [Double])] -> Either Text EyringFit
+fitEyring input = do
+  () <- if null input then Left "fitEyring: empty input" else Right ()
+  let pairs =
+        [ (t, s, life)
+        | (t, s, lives) <- input
+        , life <- lives
+        ]
+  () <- if null pairs
+          then Left "fitEyring: no lifetime observations"
+          else Right ()
+  () <- if any (\(t, _, l) -> t <= 0 || l <= 0) pairs
+          then Left "fitEyring: temperatures and lifetimes must be > 0"
+          else Right ()
+  let distinctTS = nubByPair [ (t, s) | (t, s, _) <- pairs ]
+  () <- if length distinctTS < 3
+          then Left "fitEyring: need at least 3 distinct (T, S) combinations"
+          else Right ()
+  let xRows = [ [1, 1 / t, s] | (t, s, _) <- pairs ]
+      ys    = [ log l + log t | (t, _, l) <- pairs ]
+      xMat  = LA.fromLists xRows :: LA.Matrix Double
+      yVec  = LA.fromList ys     :: LA.Vector Double
+      -- normal equations: β = (XᵀX)⁻¹ Xᵀy
+      xt    = LA.tr xMat
+      xtx   = xt LA.<> xMat
+      xty   = xt LA.#> yVec
+  betaList <- case LA.linearSolve xtx (LA.asColumn xty) of
+    Just m  -> Right (LA.toList (LA.flatten m))
+    Nothing -> Left "fitEyring: design matrix is singular (collinear T/S?)"
+  case betaList of
+    [b0, b1, b2] -> do
+      let n      = length pairs
+          a      = exp b0
+          ea     = b1 * kBoltzmann
+          bCoef  = b2
+          yHat   = LA.toList (xMat LA.#> LA.fromList [b0, b1, b2])
+          sse    = sum [ (y - yh) ** 2 | (y, yh) <- zip ys yHat ]
+          dof    = max 1 (n - 3)
+          sigma2 = sse / fromIntegral dof
+          ll     = -0.5 * fromIntegral n * (log (2 * pi * sigma2) + 1)
+      Right EyringFit
+        { efA      = a
+        , efEa     = ea
+        , efB      = bCoef
+        , efLogLik = ll
+        , efN      = n
+        }
+    _ -> Left "fitEyring: linearSolve returned unexpected length"
+
+-- | (T, S) ペアの重複除去。
+nubByPair :: [(Double, Double)] -> [(Double, Double)]
+nubByPair = go []
+  where
+    go acc []     = reverse acc
+    go acc (p:ps) | p `elem` acc = go acc ps
+                  | otherwise    = go (p : acc) ps
+
+-- ===========================================================================
+-- Inverse Power Law モデル (Phase 2.6)
+-- ===========================================================================
+
+-- | Inverse Power Law fit: @t = A · S^(-n)@
+data InversePowerFit = InversePowerFit
+  { ipfA      :: !Double
+  , ipfN      :: !Double  -- パワー指数
+  , ipfLogLik :: !Double
+  , ipfNobs   :: !Int
+  } deriving (Show)
+
+-- | Inverse Power Law モデルの fit。
+--
+-- モデル: @t = A · S^(-n)@
+-- log 変換: @log t = log A − n · log S@
+--
+-- 入力: @[(stress, [lifetimes])]@。 stress > 0、 lifetime > 0 必須。
+-- 解法: y = log t を log S の単変量 OLS で fit。 傾き = -n。
+fitInversePower :: [(Double, [Double])] -> Either Text InversePowerFit
+fitInversePower input = do
+  () <- if null input then Left "fitInversePower: empty input" else Right ()
+  let pairs =
+        [ (s, life)
+        | (s, lives) <- input
+        , life <- lives
+        ]
+  () <- if null pairs
+          then Left "fitInversePower: no lifetime observations"
+          else Right ()
+  () <- if any (\(s, l) -> s <= 0 || l <= 0) pairs
+          then Left "fitInversePower: stress and lifetimes must be > 0"
+          else Right ()
+  let distinctS = length (nubByDouble (map fst pairs))
+  () <- if distinctS < 2
+          then Left "fitInversePower: need at least 2 distinct stress levels"
+          else Right ()
+  let xs = map (\(s, _) -> log s) pairs
+      ys = map (\(_, l) -> log l) pairs
+      n  = length pairs
+      meanX = sum xs / fromIntegral n
+      meanY = sum ys / fromIntegral n
+      sxx = sum [ (x - meanX) ** 2 | x <- xs ]
+      sxy = sum [ (x - meanX) * (y - meanY) | (x, y) <- zip xs ys ]
+  () <- if sxx <= 0
+          then Left "fitInversePower: zero variance in log S"
+          else Right ()
+  let slope  = sxy / sxx           -- = -n
+      b0     = meanY - slope * meanX
+      a      = exp b0
+      nExp   = - slope
+      yHat   = [ b0 + slope * x | x <- xs ]
+      sse    = sum [ (y - yh) ** 2 | (y, yh) <- zip ys yHat ]
+      sigma2 = if n > 2 then sse / fromIntegral (n - 2) else sse / fromIntegral n
+      ll     = -0.5 * fromIntegral n * (log (2 * pi * sigma2) + 1)
+  Right InversePowerFit
+    { ipfA      = a
+    , ipfN      = nExp
+    , ipfLogLik = ll
+    , ipfNobs   = n
+    }
diff --git a/src/Hanalyze/Model/ReliabilityBlockDiagram.hs b/src/Hanalyze/Model/ReliabilityBlockDiagram.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Model/ReliabilityBlockDiagram.hs
@@ -0,0 +1,88 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE BangPatterns #-}
+
+-- |
+-- Module      : Hanalyze.Model.ReliabilityBlockDiagram
+-- Description : 信頼性ブロック図 (RBD) の直列/並列/k-out-of-n 再帰合成による系全体信頼度計算
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Reliability Block Diagram (RBD).
+--
+-- Computes system reliability from a structural composition of components
+-- with known individual reliabilities. The three primitive combinators
+-- are the textbook ones (e.g. O'Connor & Kleyner, /Practical Reliability
+-- Engineering/):
+--
+--   * Series (every block must work):
+--       @R = ∏ Rᵢ@
+--   * Parallel (any block working suffices):
+--       @R = 1 − ∏ (1 − Rᵢ)@
+--   * k-out-of-n (at least @k@ of @n@ blocks must work):
+--       @R = Σ_{i = k}^{n} P(exactly i succeed)@
+--       computed by Poisson-binomial DP — works with heterogeneous block
+--       reliabilities (the binomial closed form is the homogeneous
+--       special case).
+--
+-- Blocks can be arbitrarily nested. Failure independence between blocks
+-- is assumed (the standard RBD assumption).
+--
+-- @
+-- import Hanalyze.Model.ReliabilityBlockDiagram
+--
+-- -- Two-out-of-three redundancy of three series strings:
+-- let sys = KofN 2 [ Series [Leaf 0.95, Leaf 0.99]
+--                  , Series [Leaf 0.95, Leaf 0.99]
+--                  , Series [Leaf 0.95, Leaf 0.99] ]
+--     r   = reliabilityOf sys
+-- @
+--
+-- == Implemented
+--
+--   * 'RBDBlock' — composable tree of components.
+--   * 'reliabilityOf' — recursive evaluation.
+module Hanalyze.Model.ReliabilityBlockDiagram
+  ( RBDBlock (..)
+  , reliabilityOf
+  ) where
+
+-- ---------------------------------------------------------------------------
+-- Types
+-- ---------------------------------------------------------------------------
+
+-- | A block in a reliability diagram. @Leaf p@ is a single component with
+-- reliability @p ∈ [0, 1]@; the other constructors compose sub-blocks.
+data RBDBlock
+  = Leaf     !Double
+  | Series   ![RBDBlock]
+  | Parallel ![RBDBlock]
+  | KofN     !Int ![RBDBlock]
+  deriving (Show, Eq)
+
+-- ---------------------------------------------------------------------------
+-- Evaluation
+-- ---------------------------------------------------------------------------
+
+-- | System reliability of a block, in @[0, 1]@. Component reliabilities
+-- are assumed independent (the standard RBD assumption).
+reliabilityOf :: RBDBlock -> Double
+reliabilityOf (Leaf p)         = p
+reliabilityOf (Series bs)      = product (map reliabilityOf bs)
+reliabilityOf (Parallel bs)    = 1 - product [ 1 - reliabilityOf b | b <- bs ]
+reliabilityOf (KofN k bs)
+  | k <= 0           = 1                   -- always satisfied
+  | k > length bs    = 0                   -- impossible
+  | otherwise        =
+      let ps     = map reliabilityOf bs
+          n      = length ps
+          -- Poisson-binomial DP: pmf!!i = P(exactly i blocks work).
+          pmf    = foldr step [1.0] ps
+            where
+              step pi acc =
+                -- acc = pmf of current partial product (length = current j + 1).
+                let len = length acc
+                in [ let aPrev = if i - 1 >= 0    then acc !! (i - 1) else 0
+                         aHere = if i     <  len then acc !! i         else 0
+                     in pi * aPrev + (1 - pi) * aHere
+                   | i <- [0 .. len] ]
+      in sum (drop k pmf)
diff --git a/src/Hanalyze/Model/Robust.hs b/src/Hanalyze/Model/Robust.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Model/Robust.hs
@@ -0,0 +1,238 @@
+-- |
+-- Module      : Hanalyze.Model.Robust
+-- Description : IRLS による Huber / Tukey biweight ロバスト回帰 (M-estimator)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Robust regression M-estimators via IRLS (Phase 31-A5)。
+--
+-- 外れ値を含むデータに対する線形回帰。 OLS の二乗損失を bounded influence
+-- 関数 (Huber / Tukey biweight) に置き換え、 Iteratively Reweighted Least
+-- Squares で β を求める。 JMP "Fit Model > Personality: Robust Fit"、
+-- R `MASS::rlm` 相当。
+--
+-- ## アルゴリズム
+--
+-- 1. β を OLS で初期化
+-- 2. 残差 @r_i = y_i - x_i^T β@ を計算
+-- 3. ロバストスケール推定 @σ̂ = MAD(r) / 0.6745@
+-- 4. 影響関数から重み @w_i@ を計算 ('huberWeight' / 'tukeyWeight')
+-- 5. 加重 LS で β を更新: @β ← (X^T W X)^{-1} X^T W y@
+-- 6. 収束まで 2-5 を繰り返す
+--
+-- ## 推定子の選択
+--
+-- - **Huber** (@k=1.345@、 95% 効率): 線形 + 線形クリップ、 滑らか、 標準
+-- - **Tukey biweight** (@c=4.685@、 95% 効率): 完全棄却閾値付き、 外れ値の
+--   影響を 0 に落とす、 だが多峰目的関数 (OLS 初期化が重要)
+--
+-- Reference:
+--   Huber (1964) "Robust estimation of a location parameter".
+--   Tukey (1977) biweight、 Rousseeuw-Leroy (1987) 教科書。
+module Hanalyze.Model.Robust
+  ( RobustEstimator (..)
+  , RobustFit (..)
+  , defaultHuberK
+  , defaultTukeyC
+  , fitRobustLM
+  , huberWeight
+  , tukeyWeight
+  , psiFn
+  , psiDerivFn
+  , robustCovBeta
+  ) where
+
+import qualified Numeric.LinearAlgebra as LA
+import           Data.List             (sort)
+
+-- ---------------------------------------------------------------------------
+-- 型
+-- ---------------------------------------------------------------------------
+
+-- | M-estimator の選択。 LTS (Least Trimmed Squares) は非凸組合せ最適化なので
+-- 別 Phase 候補 (phase-NN-regression-advanced.md §RR3 参照)。
+data RobustEstimator
+  = Huber !Double  -- ^ @k@ (= 1.345 で 95% 効率、 = 'defaultHuberK')
+  | Tukey !Double  -- ^ @c@ (= 4.685 で 95% 効率、 = 'defaultTukeyC')
+  deriving (Show, Eq)
+
+data RobustFit = RobustFit
+  { rfCoef       :: !(LA.Vector Double)   -- ^ 係数 β̂
+  , rfScale      :: !Double                -- ^ ロバストスケール σ̂ (MAD-based)
+  , rfWeights    :: !(LA.Vector Double)   -- ^ 最終 IRLS 重み (≤ 1)
+  , rfFitted     :: !(LA.Vector Double)   -- ^ ŷ = Xβ̂
+  , rfResiduals  :: !(LA.Vector Double)   -- ^ y - ŷ
+  , rfIterations :: !Int                   -- ^ IRLS 反復回数
+  , rfConverged  :: !Bool                  -- ^ tol 内収束したか
+  , rfEstimator  :: !RobustEstimator       -- ^ 使用した estimator
+  } deriving (Show)
+
+-- | Huber の標準値 (95% Gaussian 効率): @k = 1.345@
+defaultHuberK :: Double
+defaultHuberK = 1.345
+
+-- | Tukey biweight の標準値 (95% Gaussian 効率): @c = 4.685@
+defaultTukeyC :: Double
+defaultTukeyC = 4.685
+
+-- ---------------------------------------------------------------------------
+-- 重み関数 (= ψ(u)/u where ψ is the influence function)
+-- ---------------------------------------------------------------------------
+
+-- | Huber 重み: @w(u) = 1@ if @|u| ≤ k@、 @k/|u|@ otherwise。
+-- ここで @u = r / σ@ (標準化残差)。
+huberWeight :: Double -> Double -> Double
+huberWeight k u
+  | absU <= k = 1
+  | absU == 0 = 1
+  | otherwise = k / absU
+  where absU = abs u
+
+-- | Tukey biweight 重み: @w(u) = (1 - (u/c)²)²@ if @|u| ≤ c@、 @0@ otherwise。
+tukeyWeight :: Double -> Double -> Double
+tukeyWeight c u
+  | absU >= c = 0
+  | otherwise = let t = u / c
+                    s = 1 - t * t
+                in s * s
+  where absU = abs u
+
+-- ---------------------------------------------------------------------------
+-- 影響関数 ψ とその導関数 ψ' (M 推定量の漸近共分散に使う)
+-- ψ(u) = w(u)·u (重み × 標準化残差)。
+-- ---------------------------------------------------------------------------
+
+-- | 影響関数 @ψ(u) = w(u)·u@ (= 標準化残差に重みを掛けたスコア)。
+--   Huber: @u@ (|u|≤k) / @k·sign u@ (それ以外)。 Tukey: @u(1-(u/c)²)²@ (|u|≤c) / 0。
+psiFn :: RobustEstimator -> Double -> Double
+psiFn (Huber k) u = huberWeight k u * u
+psiFn (Tukey c) u = tukeyWeight c u * u
+
+-- | ψ の導関数 @ψ'(u)@ (M 推定量サンドイッチ分散の分母項)。
+--   Huber: @1@ (|u|≤k) / @0@。 Tukey: @(1-(u/c)²)(1-5(u/c)²)@ (|u|≤c) / 0。
+psiDerivFn :: RobustEstimator -> Double -> Double
+psiDerivFn (Huber k) u = if abs u <= k then 1 else 0
+psiDerivFn (Tukey c) u
+  | abs u >= c = 0
+  | otherwise  = let t2 = (u / c) * (u / c)
+                 in (1 - t2) * (1 - 5 * t2)
+
+-- ---------------------------------------------------------------------------
+-- M 推定量の漸近共分散 (サンドイッチ・statsmodels RLM cov="H1")
+-- ---------------------------------------------------------------------------
+
+-- | M 推定量 β̂ の漸近共分散行列。 statsmodels @RLM@ 既定 (cov="H1") に一致:
+--
+-- @
+-- u_i   = r_i / σ̂                       (標準化残差)
+-- m     = mean ψ'(u_i)
+-- K     = 1 + (p\/n)·Var(ψ')\/m²         (自由度補正)
+-- cov   = K²·(σ̂²·Σψ(u_i)²\/(n−p))\/m² · (XᵀX)⁻¹
+-- @
+--
+-- SE は @sqrt (diag cov)@、 β̂±z·SE が Wald 信頼区間 (RLM は正規分布で z)。
+robustCovBeta
+  :: RobustEstimator       -- ^ 使用した estimator (ψ/ψ' を決める)。
+  -> Double                -- ^ ロバストスケール σ̂ ('rfScale')。
+  -> LA.Vector Double      -- ^ 残差 r = y − ŷ ('rfResiduals')。
+  -> LA.Matrix Double      -- ^ 設計行列 X (intercept 列付き)。
+  -> LA.Matrix Double      -- ^ β̂ の共分散 (p × p)。
+robustCovBeta est scale resid x =
+  let n      = LA.rows x
+      p      = LA.cols x
+      u      = LA.cmap (/ scale) resid
+      pderiv = LA.cmap (psiDerivFn est) u
+      m      = meanV pderiv
+      varpp  = meanV (LA.cmap (\v -> (v - m) * (v - m)) pderiv)   -- 母分散 (ddof=0)
+      kcorr  = 1 + (fromIntegral p / fromIntegral n) * varpp / (m * m)
+      sspsi  = LA.sumElements (LA.cmap (\v -> let pv = psiFn est v in pv * pv) u)
+      xtxInv = LA.inv (LA.tr x LA.<> x)
+      factor = kcorr * kcorr
+               * (sspsi * scale * scale / fromIntegral (n - p)) / (m * m)
+  in LA.scale factor xtxInv
+  where
+    meanV v = LA.sumElements v / fromIntegral (LA.size v)
+
+-- ---------------------------------------------------------------------------
+-- IRLS
+-- ---------------------------------------------------------------------------
+
+-- | M-estimator IRLS で線形回帰を fit。
+--
+-- @X@ は @n × p@ (intercept 列は呼び出し側で付加)、 @y@ は長さ @n@。
+-- @maxIter@ デフォルト 50、 @tol@ デフォルト 1e-6。
+fitRobustLM
+  :: RobustEstimator
+  -> LA.Matrix Double      -- ^ X
+  -> LA.Vector Double      -- ^ y
+  -> Int                   -- ^ max IRLS iterations
+  -> Double                -- ^ tolerance on @|Δβ|₂@
+  -> RobustFit
+fitRobustLM est x y maxIter tol =
+  let -- 初期 β: OLS
+      beta0 = LA.flatten (x LA.<\> LA.asColumn y)
+      step beta =
+        let yHat   = x LA.#> beta
+            resid  = y - yHat
+            sigma  = madScale resid
+            sigma' = if sigma < 1e-12 then 1e-12 else sigma
+            uVec   = LA.cmap (/ sigma') resid
+            wVec   = case est of
+                       Huber k -> LA.cmap (huberWeight k) uVec
+                       Tukey c -> LA.cmap (tukeyWeight c) uVec
+            -- 加重 LS: β ← (X^T W X)^{-1} X^T W y
+            wDiag  = wVec
+            xtWx   = LA.tr x LA.<> (x * LA.asColumn wDiag)
+            xtWy   = LA.tr x LA.#> (wDiag * y)
+            betaN  = LA.flatten (xtWx LA.<\> LA.asColumn xtWy)
+        in (betaN, sigma', wVec)
+      loop !k !beta
+        | k >= maxIter = (beta, k, False)
+        | otherwise    =
+            let (betaN, _, _) = step beta
+                diff = LA.norm_2 (betaN - beta)
+            in if diff < tol
+                 then (betaN, k + 1, True)
+                 else loop (k + 1) betaN
+      (betaFinal, iters, converged) = loop 0 beta0
+      yHatF  = x LA.#> betaFinal
+      residF = y - yHatF
+      sigmaF = max 1e-12 (madScale residF)
+      uF     = LA.cmap (/ sigmaF) residF
+      wF     = case est of
+                 Huber k -> LA.cmap (huberWeight k) uF
+                 Tukey c -> LA.cmap (tukeyWeight c) uF
+  in RobustFit
+       { rfCoef       = betaFinal
+       , rfScale      = sigmaF
+       , rfWeights    = wF
+       , rfFitted     = yHatF
+       , rfResiduals  = residF
+       , rfIterations = iters
+       , rfConverged  = converged
+       , rfEstimator  = est
+       }
+
+-- ---------------------------------------------------------------------------
+-- ロバストスケール (Median Absolute Deviation)
+-- ---------------------------------------------------------------------------
+
+-- | MAD ベースのロバストスケール推定:
+-- @σ̂ = median(|r_i - median(r)|) / 0.6745@ (Gaussian 整合性)。
+-- | ロバストスケール σ̂ = median(|r|) / Φ⁻¹(0.75)。 残差 r は intercept で中心化済
+-- ゆえ **中心 0** で MAD を取る (= statsmodels RLM の @mad(resid, center=0)@ と一致。
+-- median 中心化は二重中心化になり scale が過小になる)。 定数は Φ⁻¹(0.75)=0.674489…。
+madScale :: LA.Vector Double -> Double
+madScale v =
+  let dev = map abs (LA.toList v)       -- 中心 0 (statsmodels RLM 準拠)
+      mad = medianList dev
+  in mad / 0.6744897501960817
+
+medianList :: [Double] -> Double
+medianList [] = 0
+medianList xs =
+  let s = sort xs
+      n = length s
+  in if odd n
+       then s !! (n `div` 2)
+       else 0.5 * (s !! (n `div` 2 - 1) + s !! (n `div` 2))
diff --git a/src/Hanalyze/Model/SVM.hs b/src/Hanalyze/Model/SVM.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Model/SVM.hs
@@ -0,0 +1,302 @@
+{-# LANGUAGE BangPatterns #-}
+
+-- |
+-- Module      : Hanalyze.Model.SVM
+-- Description : SMO ソルバによる双対形カーネル SVM (C-SVC)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- カーネル SVM (双対形・SMO ソルバ) — Phase 75.11 / 共有 Kernel 化 Phase 75.15。
+--
+-- 双対 C-SVC (hinge 損失) を SMO (Platt 1998) で解き、 共有カーネル語彙
+-- ('Hanalyze.Model.Kernel': Linear/Poly/RBF/Matern52/Periodic) と
+-- **スパースな真のサポートベクタ** (α>0 の点) を提供する。 既定カーネルは Linear で、
+-- 線形 SVM が必要なら kernel=Linear・非線形は RBF/Poly を選ぶ (R `e1071::svm` の kernel= 流)。
+--
+-- カーネルハイパラは 'KernelParams' (ℓ/σ_f²/period) を持つ。 GP の観測ノイズ σ_n² は
+-- SVM には不要なので 'GPParams' でなく 'KernelParams' のみに依存する (Phase 75.18)。
+--
+-- 双対問題: max_α  Σα_i − ½ ΣΣ α_i α_j y_i y_j K(x_i,x_j)
+--           s.t.   0 ≤ α_i ≤ C,  Σ α_i y_i = 0
+--
+-- SMO は 2 変数 (α_i, α_j) ずつ解析更新する。 第 1 変数 = KKT 違反点、 第 2 変数 =
+-- |E_i − E_j| 最大 (Platt の 2nd heuristic)。 **乱数不使用ゆえ純粋・決定的** (簡易 SMO の
+-- ランダム j 選択は使わない)。 予測は Σ_{SV} α_i y_i K(x_i, x) + b (SV のみで決まる)。
+--
+-- カーネル評価は 'kEvalMV'(距離カーネルは ‖a−b‖²、 内積カーネル Linear/Poly は a·b、
+-- Poly の γ は 'kpLengthScale' から γ=1/(2ℓ²)・Linear の倍率は σ_f²)で共有する。
+module Hanalyze.Model.SVM
+  ( SVMConfig (..)
+  , defaultSVM
+  , SVM (..)
+  , SVMMulti (..)
+  , fitSVM
+  , fitSVMMulti
+  , predictSVMScore
+  , predictSVM
+  , predictSVMMulti
+  , numSupportVectors
+    -- * 自動最適化 (k-fold CV グリッド探索・config に畳む)
+  , SVMHyper (..)
+  , SVMTuneGrid (..)
+  , defaultSVMTuneGrid
+  , tuneSVM
+  ) where
+
+import qualified Data.Vector           as V
+import qualified Data.Vector.Unboxed   as VU
+import qualified Numeric.LinearAlgebra as LA
+import           Data.Text             (Text)
+import           Data.List             (nub, sort, maximumBy)
+import           Data.Ord              (comparing)
+import           Control.Monad.ST      (runST)
+import qualified System.Random.MWC     as MWC
+import           Hanalyze.Stat.CV (Fold, kFold)
+import           Hanalyze.Model.Kernel (Kernel (..), KernelParams (..), defaultKernelParams, kEvalMV)
+
+-- ===========================================================================
+-- カーネル (共有 'Kernel' + 'KernelParams' を使う)
+-- ===========================================================================
+
+-- | Gram 行列 K (n×n)。 K_ij = kEvalMV ker params (row i) (row j)。
+kGram :: Kernel -> KernelParams -> LA.Matrix Double -> LA.Matrix Double
+kGram ker p x =
+  let rv = V.fromList (LA.toRows x)   -- boxed Vector of 行ベクトル (O(1) 添字)
+      n  = V.length rv
+  in LA.build (n, n) (\i j -> kEvalMV ker p (rv V.! round i) (rv V.! round j))
+  -- NB: LA.build の i,j は Double。 round で Int 添字に戻す (整数値ゆえ安全)。
+
+-- ===========================================================================
+-- 設定 / モデル
+-- ===========================================================================
+
+data SVMConfig = SVMConfig
+  { svmC         :: !Double        -- ^ 正則化 C (0 ≤ α ≤ C)。
+  , svmKernel    :: !Kernel        -- ^ 共有カーネル (既定 'Linear')。
+  , svmParams    :: !KernelParams  -- ^ カーネルハイパラ (ℓ→γ=1/2ℓ²、 σ_f²=Linear 倍率)。
+  , svmTol       :: !Double    -- ^ KKT 許容 (E の許容)。
+  , svmMaxPasses :: !Int       -- ^ 変化が無いパスの連続上限 (収束判定)。
+  , svmMaxIter   :: !Int       -- ^ 総パス数の上限 (安全弁)。
+  , svmHyper     :: !SVMHyper  -- ^ ハイパラの決め方 (固定 or CV グリッド探索)。 GP の
+                               --   'HyperStrategy' と同型: 調整は config に畳み動詞は @svmCls@ 一本。
+  } deriving (Show)
+
+defaultSVM :: SVMConfig
+defaultSVM = SVMConfig
+  { svmC = 1.0, svmKernel = Linear, svmParams = defaultKernelParams
+  , svmTol = 1e-3, svmMaxPasses = 5, svmMaxIter = 1000
+  , svmHyper = SVMFixed }
+
+-- | 学習済カーネル SVM。 **α>0 のサポートベクタのみ**保持 (スパース)。
+data SVM = SVM
+  { svmSVx    :: !(LA.Matrix Double)  -- ^ サポートベクタ (n_sv × d)。
+  , svmSVy    :: !(VU.Vector Double)  -- ^ その符号ラベル ±1。
+  , svmSVa    :: !(VU.Vector Double)  -- ^ 双対係数 α (>0)。
+  , svmB      :: !Double              -- ^ バイアス。
+  , svmKern   :: !Kernel              -- ^ 共有カーネル。
+  , svmKParams :: !KernelParams       -- ^ カーネルハイパラ (予測時に再利用)。
+  } deriving (Show)
+
+-- | サポートベクタ数 (= α>0 の点数)。
+numSupportVectors :: SVM -> Int
+numSupportVectors = LA.rows . svmSVx
+
+-- ===========================================================================
+-- SMO (双対・2 クラス {0,1} → ±1)
+-- ===========================================================================
+
+-- | 2 クラス C-SVC を SMO で学習 (y ∈ {0,1})。 決定的 (乱数不使用)。
+fitSVM :: SVMConfig -> LA.Matrix Double -> VU.Vector Int -> SVM
+fitSVM cfg x yInt =
+  let !n    = LA.rows x
+      ys    = VU.generate n (\i -> if yInt VU.! i == 0 then -1 else 1) :: VU.Vector Double
+      gram  = kGram (svmKernel cfg) (svmParams cfg) x
+      cC    = svmC cfg
+      tol   = svmTol cfg
+      kij i j = gram `LA.atIndex` (i, j)
+      -- 決定関数 f(i) = Σ_j α_j y_j K_ij + b
+      decision al b i = b + sum [ al VU.! j * ys VU.! j * kij i j | j <- [0 .. n - 1] ]
+      -- 1 パス: 全 i を走査し KKT 違反点を見つけ第 2 変数を選んで更新。
+      onePass (!al0, !b0) =
+        let step (al, b, changed) i =
+              let ei = decision al b i - ys VU.! i
+                  ai = al VU.! i; yi = ys VU.! i
+                  viol = (yi * ei < negate tol && ai < cC) || (yi * ei > tol && ai > 0)
+              in if not viol then (al, b, changed)
+                 else
+                   -- 第 2 変数 j = |E_i − E_j| 最大 (j /= i)。
+                   let es = [ (j, decision al b j - ys VU.! j) | j <- [0 .. n - 1], j /= i ]
+                       (j, ej) = maximumBy (comparing (\(_, e) -> abs (ei - e))) es
+                       aj = al VU.! j; yj = ys VU.! j
+                       (lo, hi) = if yi /= yj
+                                    then (max 0 (aj - ai), min cC (cC + aj - ai))
+                                    else (max 0 (ai + aj - cC), min cC (ai + aj))
+                       eta = 2 * kij i j - kij i i - kij j j
+                   in if lo >= hi || eta >= 0 then (al, b, changed)
+                      else
+                        let ajNew0 = aj - yj * (ei - ej) / eta
+                            ajNew  = min hi (max lo ajNew0)
+                        in if abs (ajNew - aj) < 1e-5 then (al, b, changed)
+                           else
+                             let aiNew = ai + yi * yj * (aj - ajNew)
+                                 al'   = al VU.// [(i, aiNew), (j, ajNew)]
+                                 b1 = b - ei - yi * (aiNew - ai) * kij i i
+                                        - yj * (ajNew - aj) * kij i j
+                                 b2 = b - ej - yi * (aiNew - ai) * kij i j
+                                        - yj * (ajNew - aj) * kij j j
+                                 bNew | aiNew > 0 && aiNew < cC = b1
+                                      | ajNew > 0 && ajNew < cC = b2
+                                      | otherwise               = (b1 + b2) / 2
+                             in (al', bNew, changed + 1)
+        in foldl step (al0, b0, 0 :: Int) [0 .. n - 1]
+      -- パスを回す: 変化無しが maxPasses 連続 or maxIter 到達で停止。
+      loop !al !b !passes !iter
+        | passes >= svmMaxPasses cfg || iter >= svmMaxIter cfg = (al, b)
+        | otherwise =
+            let (al', b', changed) = onePass (al, b)
+            in if changed == 0 then loop al' b' (passes + 1) (iter + 1)
+                               else loop al' b' 0 (iter + 1)
+      (alphaF, bF) = loop (VU.replicate n 0) 0 0 0
+      -- α>0 のみ保持 (スパース SV)。
+      svIdx = [ i | i <- [0 .. n - 1], alphaF VU.! i > 1e-8 ]
+      svX   = LA.fromRows [ LA.toRows x !! i | i <- svIdx ]
+      svY   = VU.fromList [ ys VU.! i | i <- svIdx ]
+      svA   = VU.fromList [ alphaF VU.! i | i <- svIdx ]
+  in SVM { svmSVx = svX, svmSVy = svY, svmSVa = svA
+               , svmB = bF, svmKern = svmKernel cfg
+               , svmKParams = svmParams cfg }
+
+-- | 決定値 f(x) = Σ_{SV} α_i y_i K(x_i, x) + b (各行)。
+predictSVMScore :: SVM -> LA.Matrix Double -> VU.Vector Double
+predictSVMScore m x =
+  let svRows = LA.toRows (svmSVx m)
+      nsv    = length svRows
+      ker    = svmKern m
+      kp     = svmKParams m
+      score xr = svmB m
+        + sum [ svmSVa m VU.! s * svmSVy m VU.! s * kEvalMV ker kp (svRows !! s) xr
+              | s <- [0 .. nsv - 1] ]
+  in VU.fromList (map score (LA.toRows x))
+
+-- | 予測ラベル {0,1} (score ≥ 0 → 1)。
+predictSVM :: SVM -> LA.Matrix Double -> VU.Vector Int
+predictSVM m x = VU.map (\s -> if s >= 0 then 1 else 0) (predictSVMScore m x)
+
+-- ===========================================================================
+-- 多クラス (one-vs-rest)
+-- ===========================================================================
+
+data SVMMulti = SVMMulti
+  { svmmClasses    :: ![Int]
+  , svmmBinaries   :: ![SVM]   -- ^ クラス順に 1-vs-rest。
+  , svmmClassNames :: ![Text]  -- ^ クラス名 (df|-> が levels 注入・空=数値表示)。
+  } deriving (Show)
+
+-- | 多クラス C-SVC (one-vs-rest・各 binary は 'fitSVM'・決定的)。
+fitSVMMulti :: SVMConfig -> LA.Matrix Double -> VU.Vector Int -> SVMMulti
+fitSVMMulti cfg x y =
+  let classes = sort (nub (VU.toList y))
+      bins = [ fitSVM cfg x (VU.map (\yi -> if yi == c then 1 else 0) y)
+             | c <- classes ]
+  in SVMMulti { svmmClasses = classes, svmmBinaries = bins, svmmClassNames = [] }
+
+-- | 各クラスの score 最大で分類。
+predictSVMMulti :: SVMMulti -> LA.Matrix Double -> VU.Vector Int
+predictSVMMulti m x =
+  let classes = svmmClasses m
+      scores  = [ VU.toList (predictSVMScore b x) | b <- svmmBinaries m ]
+      n       = LA.rows x
+      pick i  = let col = [ (classes !! k, scores !! k !! i) | k <- [0 .. length classes - 1] ]
+                in fst (maximumBy (comparing snd) col)
+  in VU.fromList [ pick i | i <- [0 .. n - 1] ]
+
+-- ===========================================================================
+-- 自動最適化 (k-fold CV グリッド探索)
+--
+-- SVM は確率モデルでないため GP の周辺尤度最適化は使えない。 代わりに
+-- **k-fold 交差検証の accuracy を最大化**する格子探索 (sklearn `GridSearchCV` /
+-- R `e1071::tune.svm` 相当)。 SMO は乱数不使用・fold 分割も固定 seed の
+-- 'Hanalyze.Stat.CV.kFold' を 'runST' で回すため **完全に決定的**。
+-- ===========================================================================
+
+-- | ハイパラの決め方 (GP の 'HyperStrategy' と同型)。 固定値をそのまま使うか、
+--   CV グリッドを探索して最良を選ぶか。 'SVMConfig' の @svmHyper@ に持たせ、 動詞 @svmCls@ が
+--   これを見て分岐する (別動詞 @svmClsTuned@ は作らない)。
+data SVMHyper
+  = SVMFixed              -- ^ 'SVMConfig' の C/kernel/params をそのまま使う。
+  | SVMTuneCV SVMTuneGrid -- ^ グリッドを k-fold CV で探索し最良ハイパラで再学習。
+  deriving (Show)
+
+-- | SVM ハイパラ探索グリッド。 候補は C × kernel × ℓ の直積。
+-- 'Linear' カーネルは ℓ を使わないので ℓ 軸は無視する (重複評価を避ける)。
+data SVMTuneGrid = SVMTuneGrid
+  { svmtCs      :: ![Double]   -- ^ 正則化 C 候補 (0 < C)。
+  , svmtKernels :: ![Kernel]   -- ^ カーネル候補。
+  , svmtLengths :: ![Double]   -- ^ 長さスケール ℓ 候補 (距離カーネル/Poly の γ=1/2ℓ²)。
+  , svmtFolds   :: !Int        -- ^ CV fold 数 k (2 以上)。
+  } deriving (Show)
+
+-- | 既定グリッド: C ∈ {0.1,1,10,100} × RBF × ℓ ∈ {0.25,0.5,1,2,4}・5-fold。
+defaultSVMTuneGrid :: SVMTuneGrid
+defaultSVMTuneGrid = SVMTuneGrid
+  { svmtCs      = [0.1, 1, 10, 100]
+  , svmtKernels = [RBF]
+  , svmtLengths = [0.25, 0.5, 1, 2, 4]
+  , svmtFolds   = 5
+  }
+
+-- | グリッドの 1 点に対応する 'SVMConfig' を作る (base から C/kernel/ℓ を差し替え)。
+tuneCandidate :: SVMConfig -> Double -> Kernel -> Double -> SVMConfig
+tuneCandidate base c ker l =
+  base { svmC = c, svmKernel = ker
+       , svmParams = (svmParams base) { kpLengthScale = l } }
+
+-- | グリッドの全候補 'SVMConfig' (Linear は ℓ 軸を畳む)。
+tuneCandidates :: SVMConfig -> SVMTuneGrid -> [SVMConfig]
+tuneCandidates base grid =
+  [ tuneCandidate base c ker l
+  | c   <- svmtCs grid
+  , ker <- svmtKernels grid
+  , l   <- lengthsFor ker ]
+  where
+    lengthsFor Linear = take 1 (svmtLengths grid ++ [1.0])  -- ℓ 無関係 → 1 点
+    lengthsFor _      = svmtLengths grid
+
+-- | 行添字リストで行列の行とラベルを抜き出す。
+sliceRows :: V.Vector (LA.Vector Double) -> VU.Vector Int -> [Int]
+          -> (LA.Matrix Double, VU.Vector Int)
+sliceRows rows y idx =
+  ( LA.fromRows [ rows V.! i | i <- idx ]
+  , VU.fromList [ y VU.! i | i <- idx ] )
+
+-- | 1 候補の平均 CV accuracy。 各 fold で train に学習し test の正解率を測る。
+cvAccuracy :: SVMConfig -> [Fold]
+           -> V.Vector (LA.Vector Double) -> VU.Vector Int -> Double
+cvAccuracy cfg folds rows y =
+  let accs = [ foldAcc tr te | (tr, te) <- folds, not (null te) ]
+      foldAcc trIdx teIdx =
+        let (xTr, yTr) = sliceRows rows y trIdx
+            (xTe, yTe) = sliceRows rows y teIdx
+            model = fitSVMMulti cfg xTr yTr
+            pred  = predictSVMMulti model xTe
+            nTe   = VU.length yTe
+            ok    = length [ () | i <- [0 .. nTe - 1], pred VU.! i == yTe VU.! i ]
+        in fromIntegral ok / fromIntegral nTe
+  in if null accs then 0 else sum accs / fromIntegral (length accs)
+
+-- | k-fold CV で SVM のハイパラ (C × kernel × ℓ) を調律する。 CV accuracy を
+-- 最大化する 'SVMConfig' と、 その平均 CV accuracy を返す。 **決定的** (固定 seed の
+-- fold 分割・SMO は乱数不使用)。 sklearn `GridSearchCV` / R `tune.svm` 相当。
+tuneSVM :: SVMConfig -> SVMTuneGrid -> LA.Matrix Double -> VU.Vector Int
+        -> (SVMConfig, Double)
+tuneSVM base grid x y =
+  let n     = LA.rows x
+      rows  = V.fromList (LA.toRows x)
+      k     = max 2 (min (svmtFolds grid) n)
+      -- 固定 seed の k-fold (決定的・再現可能)。
+      folds = runST $ do
+                gen <- MWC.initialize (V.singleton 42)
+                kFold k n gen
+      scored = [ (cfg, cvAccuracy cfg folds rows y)
+               | cfg <- tuneCandidates base grid ]
+  in maximumBy (comparing snd) scored
diff --git a/src/Hanalyze/Model/Spline.hs b/src/Hanalyze/Model/Spline.hs
--- a/src/Hanalyze/Model/Spline.hs
+++ b/src/Hanalyze/Model/Spline.hs
@@ -1,6 +1,13 @@
 {-# LANGUAGE OverloadedStrings #-}
--- | B-spline and natural cubic-spline regression.
+
+-- |
+-- Module      : Hanalyze.Model.Spline
+-- Description : B-spline / 自然三次スプライン回帰 (Cox-de Boor 基底 + LM フィット)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
 --
+-- B-spline and natural cubic-spline regression.
+--
 -- Builds a design matrix @B@ from spline basis functions and solves
 -- ordinary least squares for the coefficients @β@:
 --
@@ -61,12 +68,16 @@
 bsplineEval k tKnots x =
   let nBasis = length tKnots - k - 1
       -- Order 0 (= k=0): 1 if x in [t_i, t_{i+1}), else 0
-      -- 端点処理: 最後のノットでは右閉
+      -- 端点処理: 右端 x == hi は **hi で終わる最後の正幅区間** [ti, hi) に含める。
+      -- clamped ノットは hi を k+1 回重複させるため、 単純に「最後の区間 index を右閉」
+      -- にすると退化区間 [hi, hi] を選んでしまい、 高次 Cox-de Boor 再帰で d2=0 となって
+      -- 基底が全ゼロ化する (= partition of unity 崩壊。 計測で確認: x=hi で sum=0)。
+      hiKnot = last tKnots
       order0 i =
         let ti  = tKnots !! i
             ti1 = tKnots !! (i + 1)
-            isLast = i == length tKnots - 2
-        in if (x >= ti && x < ti1) || (isLast && x <= ti1 && x >= ti)
+            atRightEnd = x >= ti1 && ti1 == hiKnot && ti < ti1
+        in if (x >= ti && x < ti1) || atRightEnd
              then 1.0 else 0.0
       -- 高次: Cox-de Boor
       go p prev =
diff --git a/src/Hanalyze/Model/StateSpace.hs b/src/Hanalyze/Model/StateSpace.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Model/StateSpace.hs
@@ -0,0 +1,133 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE BangPatterns #-}
+
+-- |
+-- Module      : Hanalyze.Model.StateSpace
+-- Description : 線形ガウス状態空間モデルの Kalman Filter / RTS Smoother
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- 線形ガウス状態空間モデル (Linear Gaussian State Space Model) +
+-- Kalman Filter / RTS Smoother。
+--
+-- モデル:
+--
+-- @
+-- x_t = F x_{t-1} + w_t,   w_t ~ N(0, Q)
+-- y_t = H x_t     + v_t,   v_t ~ N(0, R)
+-- @
+--
+-- * 'kalmanFilter' は前向きフィルタリングで filtered mean / cov を計算し、
+--   同時に innovation 系列の対数尤度 (= モデル尤度) を返す。
+-- * 'kalmanSmoother' は RTS (Rauch-Tung-Striebel) で smoothed mean / cov を
+--   後ろ向きに計算。 入力に既にフィルタ済の 'KalmanResult' を渡す。
+--
+-- すべて hmatrix Vector / Matrix で実装 (list 化禁止)。
+module Hanalyze.Model.StateSpace
+  ( StateSpaceModel (..)
+  , KalmanResult (..)
+  , kalmanFilter
+  , kalmanSmoother
+  ) where
+
+import qualified Numeric.LinearAlgebra as LA
+
+-- ===========================================================================
+-- 型
+-- ===========================================================================
+
+data StateSpaceModel = StateSpaceModel
+  { ssF  :: !(LA.Matrix Double)   -- ^ 状態遷移行列 F (n_x × n_x)
+  , ssH  :: !(LA.Matrix Double)   -- ^ 観測行列 H (n_y × n_x)
+  , ssQ  :: !(LA.Matrix Double)   -- ^ プロセスノイズ共分散 Q (n_x × n_x)
+  , ssR  :: !(LA.Matrix Double)   -- ^ 観測ノイズ共分散 R (n_y × n_y)
+  , ssX0 :: !(LA.Vector Double)   -- ^ 初期状態 (n_x)
+  , ssP0 :: !(LA.Matrix Double)   -- ^ 初期共分散 (n_x × n_x)
+  } deriving (Show)
+
+data KalmanResult = KalmanResult
+  { krFilteredMean :: ![LA.Vector Double]
+  , krFilteredCov  :: ![LA.Matrix Double]
+  , krSmoothedMean :: ![LA.Vector Double]
+    -- ^ 'kalmanFilter' のみ呼んだ場合は空。 'kalmanSmoother' を通すと埋まる。
+  , krSmoothedCov  :: ![LA.Matrix Double]
+  , krLogLik       :: !Double      -- ^ Σ log p(y_t | y_{1:t-1})
+  } deriving (Show)
+
+-- ===========================================================================
+-- Kalman Filter (forward pass)
+-- ===========================================================================
+
+-- | 観測系列 ys (各列が 1 時点の観測ベクトル) からフィルタリング。
+--   ys の行 = 観測次元 n_y、 列 = 時点数 T。
+kalmanFilter :: StateSpaceModel -> LA.Matrix Double -> KalmanResult
+kalmanFilter ssm ys =
+  let nY = LA.rows ys
+      _  = nY :: Int
+      tT = LA.cols ys
+      f  = ssF ssm
+      h  = ssH ssm
+      q  = ssQ ssm
+      r  = ssR ssm
+      step (x, p, accM, accP, ll) t =
+        let yt   = LA.flatten (ys LA.¿ [t])
+            -- predict
+            xPred = f LA.#> x
+            pPred = f LA.<> p LA.<> LA.tr f + q
+            -- update
+            yPred = h LA.#> xPred
+            sInn  = h LA.<> pPred LA.<> LA.tr h + r
+            -- guard against singular S
+            sInv  = LA.inv sInn
+            gain  = pPred LA.<> LA.tr h LA.<> sInv
+            inn   = yt - yPred
+            xNew  = xPred + gain LA.#> inn
+            pNew  = pPred - gain LA.<> h LA.<> pPred
+            -- log-likelihood contribution
+            nY_   = fromIntegral (LA.size inn) :: Double
+            detS  = LA.det sInn
+            quad  = inn `LA.dot` (sInv LA.#> inn)
+            lt    = -0.5 * (nY_ * log (2 * pi) + log (max 1e-300 detS) + quad)
+        in (xNew, pNew, accM ++ [xNew], accP ++ [pNew], ll + lt)
+      (_, _, ms, ps, llTotal) =
+        foldl step (ssX0 ssm, ssP0 ssm, [], [], 0) [0 .. tT - 1]
+  in KalmanResult
+       { krFilteredMean = ms
+       , krFilteredCov  = ps
+       , krSmoothedMean = []
+       , krSmoothedCov  = []
+       , krLogLik       = llTotal
+       }
+
+-- ===========================================================================
+-- RTS Smoother (backward pass)
+-- ===========================================================================
+
+-- | RTS smoother。 'kalmanFilter' の出力を受け取り smoothed * を埋めて返す。
+kalmanSmoother :: StateSpaceModel -> KalmanResult -> KalmanResult
+kalmanSmoother ssm kr =
+  let f  = ssF ssm
+      q  = ssQ ssm
+      ms = krFilteredMean kr
+      ps = krFilteredCov  kr
+      tT = length ms
+      -- 末尾は filtered と smoothed が同じ
+      mTLast = last ms
+      pTLast = last ps
+      -- 後ろから前へ走査
+      step (smMs, smPs) i =
+        let mFilt = ms !! i
+            pFilt = ps !! i
+            mPred = f LA.#> mFilt
+            pPred = f LA.<> pFilt LA.<> LA.tr f + q
+            mNext = head smMs
+            pNext = head smPs
+            g     = pFilt LA.<> LA.tr f LA.<> LA.inv pPred
+            mNew  = mFilt + g LA.#> (mNext - mPred)
+            pNew  = pFilt + g LA.<> (pNext - pPred) LA.<> LA.tr g
+        in (mNew : smMs, pNew : smPs)
+      (smMsFinal, smPsFinal) =
+        foldl step ([mTLast], [pTLast]) (reverse [0 .. tT - 2])
+  in kr { krSmoothedMean = smMsFinal
+        , krSmoothedCov  = smPsFinal
+        }
diff --git a/src/Hanalyze/Model/Survival.hs b/src/Hanalyze/Model/Survival.hs
--- a/src/Hanalyze/Model/Survival.hs
+++ b/src/Hanalyze/Model/Survival.hs
@@ -1,7 +1,14 @@
 {-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE BangPatterns #-}
--- | Survival analysis.
+
+-- |
+-- Module      : Hanalyze.Model.Survival
+-- Description : 打ち切りを伴う生存時間解析 (Kaplan-Meier / Nelson-Aalen / log-rank / Cox PH)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
 --
+-- Survival analysis.
+--
 -- Time-to-event analysis under right censoring. Implements:
 --
 --   * 'kaplanMeier' — non-parametric survival function estimator.
@@ -82,17 +89,18 @@
   let !sorted = sortBy (comparing ssTime) samples
       !n0     = length sorted
       groups  = runLengthGroups sorted
-      go _    [] = ([], [], [], [], [])
-      go !nAt ((t, dj, cj) : rest) =
+      -- 累積生存は **先頭から** 積む: Ŝ(tᵢ) = ∏_{j ≤ i} (1 − dⱼ/nⱼ)。
+      -- (旧実装は rest を先に再帰して右から積んでおり、 最終時点の (1−dⱼ/nⱼ)=0 が
+      --  全時点を 0 に潰す逆順バグだった。 計測で確認・修正。)
+      go _    _     [] = ([], [], [], [], [])
+      go !nAt !sAcc ((t, dj, cj) : rest) =
         let !sFactor = if nAt > 0
                          then 1 - fromIntegral dj / fromIntegral nAt
                          else 1
-            (ts, ss, ns, ds, cs) = go (nAt - dj - cj) rest
-            !sNew = case ss of
-                      []      -> sFactor
-                      (s : _) -> s * sFactor
+            !sNew = sAcc * sFactor
+            (ts, ss, ns, ds, cs) = go (nAt - dj - cj) sNew rest
         in (t : ts, sNew : ss, nAt : ns, dj : ds, cj : cs)
-      (ts, ss, ns, ds, cs) = go n0 groups
+      (ts, ss, ns, ds, cs) = go n0 1.0 groups
   in KMResult ts ss ns ds cs
 
 -- | Walk a list pre-sorted by 'ssTime' and return per-distinct-time
diff --git a/src/Hanalyze/Model/TimeSeries.hs b/src/Hanalyze/Model/TimeSeries.hs
--- a/src/Hanalyze/Model/TimeSeries.hs
+++ b/src/Hanalyze/Model/TimeSeries.hs
@@ -1,6 +1,13 @@
 {-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE BangPatterns #-}
--- | Time-series modelling.
+
+-- |
+-- Module      : Hanalyze.Model.TimeSeries
+-- Description : AR/MA/ARIMA・指数平滑・STL 分解を含む時系列モデリング一式
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Time-series modelling.
 --
 -- @
 -- import Hanalyze.Model.TimeSeries
diff --git a/src/Hanalyze/Model/VAR.hs b/src/Hanalyze/Model/VAR.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Model/VAR.hs
@@ -0,0 +1,134 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE BangPatterns #-}
+
+-- |
+-- Module      : Hanalyze.Model.VAR
+-- Description : 多変量自己回帰 VAR(p) モデルの方程式別 OLS 推定と予測
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- VAR(p) — Vector AutoRegressive model.
+--
+-- Multivariate generalization of AR(p): for a @K@-dimensional series
+-- @yₜ ∈ ℝᴷ@,
+--
+-- @
+--   yₜ = c + A₁·yₜ₋₁ + A₂·yₜ₋₂ + … + Aₚ·yₜ₋ₚ + εₜ
+-- @
+--
+-- where each @Aₗ@ is a @K × K@ coefficient matrix and @c@ is a length-@K@
+-- intercept. Estimation is by equation-by-equation OLS, which is the
+-- maximum-likelihood estimator for VAR under Gaussian innovations (the
+-- stacked system has the same regressors in every equation, so SUR
+-- collapses to OLS — Lütkepohl 2005 §3.2).
+--
+-- @
+-- import Hanalyze.Model.VAR
+--
+-- let fit = fitVAR 2 yMat              -- VAR(2) on n × K series
+--     fc  = forecastVAR fit yMat 10    -- 10-step ahead
+-- @
+--
+-- == Implemented
+--
+--   * 'fitVAR' (equation-by-equation OLS, joint estimation)
+--   * 'forecastVAR' (deterministic point forecast, h steps)
+module Hanalyze.Model.VAR
+  ( VARFit (..)
+  , fitVAR
+  , forecastVAR
+  ) where
+
+import qualified Numeric.LinearAlgebra as LA
+
+-- ---------------------------------------------------------------------------
+-- Types
+-- ---------------------------------------------------------------------------
+
+-- | Fitted VAR(p) model.
+data VARFit = VARFit
+  { varP         :: !Int              -- ^ Lag order @p@.
+  , varK         :: !Int              -- ^ Series dimensionality @K@.
+  , varConst     :: !(LA.Vector Double) -- ^ Intercept @c@ (length @K@).
+  , varCoefs     :: ![LA.Matrix Double] -- ^ @[A₁, …, Aₚ]@, each @K × K@.
+  , varResiduals :: !(LA.Matrix Double) -- ^ Residuals, @(n − p) × K@.
+  , varSigma     :: !(LA.Matrix Double) -- ^ Residual covariance @K × K@.
+  } deriving (Show)
+
+-- ---------------------------------------------------------------------------
+-- Fitting
+-- ---------------------------------------------------------------------------
+
+-- | Fit a VAR(@p@) model to an @n × K@ series @Y@ by equation-by-equation
+-- OLS. The first @p@ rows are consumed as the initial lag window;
+-- @n − p@ effective observations are used. Requires @n > p · K + 1@.
+fitVAR :: Int -> LA.Matrix Double -> VARFit
+fitVAR p y =
+  let n     = LA.rows y
+      k     = LA.cols y
+      neff  = n - p
+      -- Design matrix Z: each row t = [1, y_{t-1}, y_{t-2}, …, y_{t-p}]
+      -- (1 + p·K columns), for t = p, p+1, …, n-1.
+      buildRow t =
+        1.0 : concat [ LA.toList (LA.flatten (y LA.? [t - l]))
+                     | l <- [1 .. p] ]
+      zRows = [ buildRow t | t <- [p .. n - 1] ]
+      z     = LA.fromLists zRows                -- (neff × (1 + p·K))
+      yLag  = y LA.?? (LA.Drop p, LA.All)       -- (neff × K)
+      -- OLS: B = (Zᵀ Z)⁻¹ Zᵀ Y. Use linearSolveLS (least squares) for
+      -- numerical stability.
+      bMat  = LA.linearSolveLS z yLag           -- ((1 + p·K) × K)
+      cVec  = LA.flatten (bMat LA.? [0])        -- intercept (K,)
+      coefs =
+        [ LA.tr (bMat LA.?? ( LA.Pos (LA.idxs [ 1 + (l - 1) * k + j
+                                              | j <- [0 .. k - 1] ])
+                            , LA.All ))
+        | l <- [1 .. p] ]
+        -- Each block row of B is K rows giving Aₗᵀ; transpose for K × K Aₗ.
+      yhat  = z LA.<> bMat
+      resid = yLag - yhat
+      sigma = (LA.tr resid LA.<> resid)
+              / fromIntegral (max 1 (neff - (1 + p * k)))
+  in VARFit
+       { varP         = p
+       , varK         = k
+       , varConst     = cVec
+       , varCoefs     = coefs
+       , varResiduals = resid
+       , varSigma     = sigma
+       }
+
+-- ---------------------------------------------------------------------------
+-- Forecasting
+-- ---------------------------------------------------------------------------
+
+-- | Deterministic @h@-step-ahead point forecast (ε set to zero):
+--
+-- @
+--   ŷ_{T+k} = c + Σₗ Aₗ · ŷ_{T+k-ℓ}
+-- @
+--
+-- where @ŷ_{T+j} = y_{T+j}@ for @j ≤ 0@. The full input series @y@ is
+-- accepted to supply the last @p@ rows used as initial history.
+forecastVAR :: VARFit -> LA.Matrix Double -> Int -> LA.Matrix Double
+forecastVAR fit y h
+  | h <= 0    = LA.fromLists []
+  | otherwise =
+      let p    = varP fit
+          n    = LA.rows y
+          -- Initial history: last p rows of y, as a [Vector Double] list
+          -- with index 0 = y_{T-1}, index 1 = y_{T-2}, …, index p-1 = y_{T-p}.
+          hist0 = [ LA.flatten (y LA.? [n - 1 - i]) | i <- [0 .. p - 1] ]
+          step !hist =
+            let !pred_ =
+                  varConst fit
+                  + foldr1 (+)
+                      [ (varCoefs fit !! (l - 1)) LA.#> (hist !! (l - 1))
+                      | l <- [1 .. p] ]
+            in (pred_, pred_ : init hist)
+          go !k !hist acc
+            | k > h     = reverse acc
+            | otherwise =
+                let (yk, hist') = step hist
+                in go (k + 1) hist' (yk : acc)
+      in LA.fromRows (go 1 hist0 [])
diff --git a/src/Hanalyze/Model/Weibull.hs b/src/Hanalyze/Model/Weibull.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Model/Weibull.hs
@@ -0,0 +1,306 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE BangPatterns #-}
+
+-- |
+-- Module      : Hanalyze.Model.Weibull
+-- Description : Weibull 分布の最尤推定・B_x 寿命・Wald 標準誤差 (信頼性/故障時間解析の中核)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Weibull 分布の最尤推定 + B_x 寿命 + Wald SE。
+--
+-- 信頼性 / 故障時間解析の中核。 半導体 / 材料分野の加速試験データ解析に使う。
+-- 加速モデル (Arrhenius / Eyring / Inverse Power Law) は
+-- @Hanalyze.Model.Reliability@ で別途扱う。
+--
+-- Weibull(k, λ) の確率密度 / 生存関数:
+--
+-- > f(x) = (k/λ) (x/λ)^(k-1) exp(-(x/λ)^k)        for x > 0
+-- > S(x) = exp(-(x/λ)^k)
+--
+-- 形状 k と尺度 λ は両方とも正。 k < 1 は故障率低下 (初期不良)、 k = 1 は
+-- 指数分布、 k > 1 は故障率上昇 (摩耗故障)。
+module Hanalyze.Model.Weibull
+  ( -- * 結果型
+    WeibullFit (..)
+    -- * MLE fit
+  , fitWeibullMLE
+  , fitWeibullCensored
+    -- * 派生量
+  , bxLife
+  , bxLifeCI
+  , weibullParameterSE
+  , weibullParameterCovariance
+    -- * 数値ユーティリティ
+  , quantileNormal
+  ) where
+
+import           Data.Text     (Text)
+import           Data.Vector   (Vector)
+import qualified Data.Vector   as V
+
+-- ===========================================================================
+-- 型定義
+-- ===========================================================================
+
+-- | Weibull MLE 結果。
+data WeibullFit = WeibullFit
+  { wfShape   :: !Double           -- ^ k (形状パラメータ、 > 0)
+  , wfScale   :: !Double           -- ^ λ (尺度パラメータ、 > 0)
+  , wfLogLik  :: !Double           -- ^ 対数尤度の MLE 値
+  , wfN       :: !Int              -- ^ 観測総数 (打ち切り含む)
+  , wfRObs    :: !Int              -- ^ 観測 failure 数 (打ち切り除く)
+  , wfFisher  :: !(Double, Double, Double)
+    -- ^ Fisher 情報行列 2x2 を上三角 (I_kk, I_kλ, I_λλ) で保持。
+    --   Wald SE 計算で逆行列を取る。
+  } deriving (Show)
+
+-- ===========================================================================
+-- 内部ヘルパ
+-- ===========================================================================
+
+-- | 観測値リストの sanity check (全て正で非空)。
+validatePositive :: Vector Double -> Either Text ()
+validatePositive xs
+  | V.null xs           = Left "fitWeibull: empty observation series"
+  | V.any (<= 0) xs     = Left "fitWeibull: all observations must be positive"
+  | otherwise           = Right ()
+
+-- | A(k) = Σ x_i^k log x_i (failures のみ加算する版は censored 用)。
+weightedLog :: Double -> Vector Double -> Double
+weightedLog k xs = V.sum (V.map (\x -> x ** k * log x) xs)
+
+-- | B(k) = Σ x_i^k。 censored 含む場合は加算範囲を呼び出し側で制御する。
+sumPow :: Double -> Vector Double -> Double
+sumPow k xs = V.sum (V.map (** k) xs)
+
+-- | g(k) = A(k)/B(k) − (1/r)·Σ_{failures} log x − 1/k = 0
+--   r = failure 数。 単調増加なので bisection で root を取れる。
+scoreG :: Double -> Vector Double -> Vector Double -> Int -> Double
+scoreG k allXs failuresXs r =
+  let bk = sumPow k allXs
+      ak = weightedLog k allXs
+      meanLogFail = V.sum (V.map log failuresXs) / fromIntegral r
+  in ak / bk - meanLogFail - 1 / k
+
+-- | 単調増加関数の root を bisection で。 区間 [lo, hi] で g(lo) < 0 < g(hi) を仮定。
+bisect
+  :: (Double -> Double)  -- 単調増加 g
+  -> Double              -- lo
+  -> Double              -- hi
+  -> Double              -- 許容誤差
+  -> Int                 -- 最大反復
+  -> Either Text Double
+bisect g lo0 hi0 tol maxIter = go lo0 hi0 0
+  where
+    go !lo !hi !i
+      | i >= maxIter             = Left "Weibull MLE: bisection did not converge"
+      | (hi - lo) < tol          = Right ((lo + hi) / 2)
+      | otherwise =
+          let mid = (lo + hi) / 2
+              gm  = g mid
+          in if gm > 0
+               then go lo mid (i + 1)
+               else go mid hi (i + 1)
+
+-- | 区間を「拡張 + 縮小」 でブラケットを取る。
+--   関数 g は単調増加。 g(start_lo) ≥ 0 や g(start_hi) ≤ 0 の場合は範囲を広げる。
+findBracket
+  :: (Double -> Double)
+  -> Double  -- 初期 lo (>0)
+  -> Double  -- 初期 hi
+  -> Int     -- 最大拡張回数
+  -> Either Text (Double, Double)
+findBracket g lo0 hi0 maxExp = go lo0 hi0 0
+  where
+    go !lo !hi !i
+      | i >= maxExp = Left "Weibull MLE: failed to bracket root"
+      | otherwise =
+          let glo = g lo
+              ghi = g hi
+          in if glo <= 0 && ghi >= 0
+               then Right (lo, hi)
+               else if glo > 0  -- root より大きすぎる
+                      then go (lo / 4) hi (i + 1)
+                      else if ghi < 0  -- root より小さすぎる
+                             then go lo (hi * 4) (i + 1)
+                             else Right (lo, hi)
+
+-- | 全観測 failure 仮定で MLE を解く中核ロジック。
+--   xs (failure 時間) + xsAll (全観測; censored 含む) を分けるのは Phase 2.3 用。
+solveWeibull
+  :: Vector Double  -- failures (時間)
+  -> Vector Double  -- 全観測 (失敗 + 打ち切り)
+  -> Int            -- failure 数 r
+  -> Either Text WeibullFit
+solveWeibull failuresXs allXs r = do
+  let g k = scoreG k allXs failuresXs r
+  (lo, hi) <- findBracket g 0.1 10.0 30
+  k        <- bisect g lo hi 1e-10 200
+  let bk     = sumPow k allXs
+      lam    = (bk / fromIntegral r) ** (1 / k)
+      -- log-likelihood at MLE (failures contribution + censored survival)
+      n      = V.length allXs
+      sumLogFailures = V.sum (V.map log failuresXs)
+      sumScaled = V.sum (V.map (\x -> (x / lam) ** k) allXs)
+      ll     = fromIntegral r * (log k - k * log lam)
+             + (k - 1) * sumLogFailures
+             - sumScaled
+      -- 観測 Fisher 情報 (uncensored 公式; censored ではバイアスあり)
+      -- I_kk ≈ r / k^2 + Σ (x/λ)^k (log(x/λ))^2
+      -- I_λλ ≈ k^2 · (Σ (x/λ)^k) / λ^2 − r k / λ^2  ... 簡素化:
+      -- 厳密 expected information を Phase 2.4 で詰める。 ここでは
+      -- observed information (負 Hessian) の対角成分を返す。
+      iKK   = fromIntegral r / (k * k)
+            + V.sum (V.map (\x -> (x / lam) ** k * (log (x / lam))**2) allXs)
+      iLL   = (k * k / (lam * lam)) * V.sum (V.map (\x -> (x / lam) ** k) allXs)
+            - fromIntegral r * k / (lam * lam) + 2 * k * fromIntegral r / (lam * lam)
+            -- 教科書: I_λλ = r·k² / λ²  (uncensored at MLE は Σ (x/λ)^k = r)
+            -- censored の場合は上の Σ がそのまま入る。
+      iKL   = V.sum (V.map (\x -> (x / lam) ** k * log (x / lam)) allXs)
+            * (k / lam)
+            - fromIntegral r / lam
+  pure WeibullFit
+    { wfShape   = k
+    , wfScale   = lam
+    , wfLogLik  = ll
+    , wfN       = n
+    , wfRObs    = r
+    , wfFisher  = (iKK, iKL, iLL)
+    }
+
+-- ===========================================================================
+-- 公開関数
+-- ===========================================================================
+
+-- | Weibull MLE (打ち切り無し)。
+--
+-- 入力: 全て観測済の故障時間 (> 0)。
+-- 解法: score equation @1/k = A(k)/B(k) − (1/n)·Σ log x@ を 1D bisection で
+--       解き、 λ = (Σ x^k / n)^(1/k)。
+fitWeibullMLE :: Vector Double -> Either Text WeibullFit
+fitWeibullMLE xs = do
+  _ <- validatePositive xs
+  if V.length xs < 2
+    then Left "fitWeibullMLE: need at least 2 observations"
+    else
+      let logs = V.map log xs
+          maxL = V.maximum logs
+          meanL = V.sum logs / fromIntegral (V.length xs)
+      in if abs (maxL - meanL) < 1e-12
+           then Left "fitWeibullMLE: data is constant (degenerate)"
+           else solveWeibull xs xs (V.length xs)
+
+-- | Weibull MLE (右打ち切り対応)。
+--
+-- 第 2 引数の @True@ = failure observed、 @False@ = right-censored。
+-- 同じ score equation @1/k = A_all(k)/B_all(k) − (1/r)·Σ_{δ=1} log x@ を解くが、
+-- @A@, @B@ は 全観測 (failure + 打ち切り) で加算し、 log-sum は failure のみ。
+-- @r@ は failure 数。
+fitWeibullCensored :: Vector Double -> Vector Bool -> Either Text WeibullFit
+fitWeibullCensored xs deltas = do
+  _ <- validatePositive xs
+  if V.length xs /= V.length deltas
+    then Left "fitWeibullCensored: times and delta indicators differ in length"
+    else
+      let failuresXs = V.ifilter (\i _ -> deltas V.! i) xs
+          r = V.length failuresXs
+      in if r < 2
+           then Left "fitWeibullCensored: need at least 2 observed failures"
+           else
+             let logsFail = V.map log failuresXs
+                 maxL  = V.maximum logsFail
+                 meanL = V.sum logsFail / fromIntegral r
+             in if abs (maxL - meanL) < 1e-12
+                  then Left "fitWeibullCensored: failure data is constant (degenerate)"
+                  else solveWeibull failuresXs xs r
+
+-- | B_p 寿命: F^{-1}(p) = λ · (−ln(1−p))^(1/k)。
+--
+-- 典型用途: @bxLife 0.10 fit@ → B_10 (10%故障時間)、
+--           @bxLife 0.50 fit@ → B_50 (中央寿命)。
+bxLife :: Double -> WeibullFit -> Double
+bxLife p _ | p <= 0 || p >= 1 = error "bxLife: probability must be in (0, 1)"
+bxLife p fit =
+  let k   = wfShape fit
+      lam = wfScale fit
+  in lam * (- log (1 - p)) ** (1 / k)
+
+-- | (k_SE, λ_SE) — Fisher 情報行列の逆行列の対角の平方根。
+--
+-- 2x2 逆行列: var(k) = I_λλ / det、 var(λ) = I_kk / det、 det = I_kk·I_λλ − I_kλ²
+weibullParameterSE :: WeibullFit -> (Double, Double)
+weibullParameterSE fit =
+  let (vK, _, vL) = weibullParameterCovariance fit
+  in (sqrt (max 0 vK), sqrt (max 0 vL))
+
+-- | (Var(k), Cov(k, λ), Var(λ))。 Fisher 情報行列の 2x2 逆行列。
+--   非正定値の場合は (0, 0, 0) を返す (canvas 側で警告するための signal)。
+weibullParameterCovariance :: WeibullFit -> (Double, Double, Double)
+weibullParameterCovariance fit =
+  let (iKK, iKL, iLL) = wfFisher fit
+      det = iKK * iLL - iKL * iKL
+  in if det <= 0
+       then (0, 0, 0)
+       else (iLL / det, -iKL / det, iKK / det)
+
+-- | B_p 寿命の Wald 信頼区間 (delta method)。
+--
+-- @bxLifeCI p α fit@ で「故障時間が確率 p に達する時刻」 の
+-- 信頼度 @1 − α@ 信頼区間 (例: α = 0.05 で 95% CI) を返す。
+--
+-- delta method:
+--
+-- > Var(B_p) ≈ (∂B_p/∂k)² Var(k) + (∂B_p/∂λ)² Var(λ) + 2 (∂B_p/∂k)(∂B_p/∂λ) Cov(k,λ)
+-- > ∂B_p/∂λ = B_p / λ
+-- > ∂B_p/∂k = −B_p · log(−log(1−p)) / k²
+--
+-- 戻り値: @(estimate, lower, upper)@。 lower は max(0, ...) で 0 にクリップ
+-- (寿命は非負)。 共分散が非正定値で SE 計算不能の場合は @(estimate, estimate, estimate)@。
+--
+-- 注: α は両側で考えるので 95% CI なら z = 1.96 を内部使用。
+bxLifeCI :: Double -> Double -> WeibullFit -> (Double, Double, Double)
+bxLifeCI p alpha fit =
+  let bp     = bxLife p fit
+      k      = wfShape fit
+      lam    = wfScale fit
+      (vK, cKL, vL) = weibullParameterCovariance fit
+      logArg = log (- log (1 - p))
+      dbdL   = bp / lam
+      dbdK   = - bp * logArg / (k * k)
+      varBp  = dbdK * dbdK * vK + dbdL * dbdL * vL + 2 * dbdK * dbdL * cKL
+      seBp   = if varBp > 0 then sqrt varBp else 0
+      z      = quantileNormal (1 - alpha / 2)
+      lo     = max 0 (bp - z * seBp)
+      hi     = bp + z * seBp
+  in (bp, lo, hi)
+
+-- | 標準正規分布の分位点 (近似)。 95% CI で z = 1.959964…。
+--   Acklam 高精度近似 (12 桁) を採用。
+quantileNormal :: Double -> Double
+quantileNormal q
+  | q <= 0 || q >= 1 = error "quantileNormal: q must be in (0, 1)"
+  | q < pLow = let qn = sqrt (-2 * log q) in
+      (((((cN1 * qn + cN2) * qn + cN3) * qn + cN4) * qn + cN5) * qn + cN6)
+      / ((((dN1 * qn + dN2) * qn + dN3) * qn + dN4) * qn + 1)
+  | q <= pHigh = let qn = q - 0.5; r = qn * qn in
+      ((((((aN1 * r + aN2) * r + aN3) * r + aN4) * r + aN5) * r + aN6) * qn)
+      / (((((bN1 * r + bN2) * r + bN3) * r + bN4) * r + bN5) * r + 1)
+  | otherwise = let qn = sqrt (-2 * log (1 - q)) in
+      negate $
+      (((((cN1 * qn + cN2) * qn + cN3) * qn + cN4) * qn + cN5) * qn + cN6)
+      / ((((dN1 * qn + dN2) * qn + dN3) * qn + dN4) * qn + 1)
+  where
+    pLow  = 0.02425
+    pHigh = 1 - pLow
+    aN1 = -3.969683028665376e1; aN2 =  2.209460984245205e2
+    aN3 = -2.759285104469687e2; aN4 =  1.383577518672690e2
+    aN5 = -3.066479806614716e1; aN6 =  2.506628277459239e0
+    bN1 = -5.447609879822406e1; bN2 =  1.615858368580409e2
+    bN3 = -1.556989798598866e2; bN4 =  6.680131188771972e1
+    bN5 = -1.328068155288572e1
+    cN1 = -7.784894002430293e-3; cN2 = -3.223964580411365e-1
+    cN3 = -2.400758277161838e0;  cN4 = -2.549732539343734e0
+    cN5 =  4.374664141464968e0;  cN6 =  2.938163982698783e0
+    dN1 =  7.784695709041462e-3; dN2 =  3.224671290700398e-1
+    dN3 =  2.445134137142996e0;  dN4 =  3.754408661907416e0
diff --git a/src/Hanalyze/Model/Wrappers.hs b/src/Hanalyze/Model/Wrappers.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Model/Wrappers.hs
@@ -0,0 +1,900 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RankNTypes #-}
+{-# LANGUAGE ImpredicativeTypes #-}
+{-# LANGUAGE TypeFamilies #-}
+{-# LANGUAGE FlexibleContexts #-}
+{-# LANGUAGE UndecidableInstances #-}
+
+-- |
+-- Module      : Hanalyze.Model.Wrappers
+-- Description : hgg に依存しないフィット済みモデルの描画ラッパ型と smart constructor
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- 描画ラッパ型 + smart constructor の plot 非依存層。
+--
+-- 'Hanalyze.Plot' (cabal flag @plot-integration@ 配下) が描画
+-- ('VisualSpec' 化・'Plottable' instance) を担うのに対し、 本モジュールは
+-- そこから **hgg に依存しない** 部分 (フィット済みモデルを束ねた
+-- 描画ラッパ型と、 それを組み立てる smart constructor) のみを切り出したもの。
+-- 非ゲート (常時 build) なので 'Hgg.Plot.*' を一切 import しない。
+-- 'Hanalyze.Plot' は本モジュールを import し従来の名前で再 export する。
+module Hanalyze.Model.Wrappers
+  ( -- * 多変量 effect plot のラッパ + smart ctor
+    AlongSpec (..)
+  , along
+  , HoldAgg (..)
+  , MultiLMModel (..)
+  , multiLMModel
+  , multiLMModelF
+  , MultiGLMModel (..)
+  , multiGLMModel
+  , multiGLMModelF
+  , MultiRobustModel (..)
+  , multiRobustModelF
+  , additiveFormula
+  , PLSModel (..)
+  , plsModel
+  , selectOutput
+    -- * 帯モード / 予測区間セレクタ
+  , BandMode (..)
+  , PIMethod (..)
+    -- * 応答曲面オプション
+  , SurfaceOpts (..)
+  , defaultSurfaceOpts
+    -- * 単回帰系の描画ラッパ + smart ctor
+  , LMModel (..)
+  , lmModel
+  , GLMModel (..)
+  , glmModel
+  , SplineModel (..)
+  , splineModel
+  , GAMModel (..)
+  , gamModel
+  , GAMModelN (..)
+  , RobustModel (..)
+  , robustModel
+  , QuantileModel (..)
+  , quantileModel
+  , MultiQuantileModel (..)
+    -- * MCMC チェーン ラッパ
+  , ChainModel (..)
+  , chainModel
+    -- * HBM 学習
+  , HBMConfig (..)
+  , defaultHBM
+  , HBMModel (..)
+  , bindCols
+  , bindIxCols
+  , hbmModel
+  , hbmInitPoint
+  , hbmModelPure
+  , hbmModelPureWith
+  , hbmModelIO
+  , hbmModelIOWith
+    -- * HBM 事後要約 (Phase 103)
+  , hbmSummaryNames
+  , hbmSummary
+  , printHBMSummary
+  , hbmSummaryDf
+  , hbmDrawsDf
+    -- * 時系列予測 ラッパ
+  , ForecastModel (..)
+  , forecastModel
+    -- * 統一 fit API
+  , Fit (..)
+  , LiNGAMFitted (..)
+  , reqColV
+  , reqColsM
+    -- * WLS / 標準化 / 群別フィット ラッパ
+  , WeightedLMModel (..)
+  , StandardizedModel (..)
+  , GroupedFit (..)
+    -- * カーネル回帰 ラッパ
+  , GPMethod (..)
+  , HyperStrategy (..)
+  , GPRegModel (..)
+  , GPRegModelN (..)
+    -- * 罰則回帰 ラッパ
+  , RegMethod (..)
+  , RegModel (..)
+  , regPredict
+  ) where
+
+import           Data.Maybe            (fromMaybe)
+import qualified Data.Map.Strict       as Map
+import           Data.Word             (Word32)
+import qualified Data.Vector           as V
+import           System.Random.MWC     (createSystemRandom, initialize)
+
+import           Data.Text             (Text)
+import qualified Data.Text             as T
+import qualified DataFrame.Internal.Column    as DX
+import qualified DataFrame.Internal.DataFrame  as DX
+import qualified Numeric.LinearAlgebra as LA
+
+import           Hanalyze.Data.ColumnSource     (ColumnSource (..))
+import           Hanalyze.Model.Formula         (Formula (..), Term (..), BinOp (..))
+import           Hanalyze.Model.Formula.Frame   (ModelFrame (..), VarRole (..),
+                                                 modelFrame)
+import           Hanalyze.Model.Formula.Design  (designMatrixF, responseVec,
+                                                 fitLMF)
+import           Hanalyze.Model.Formula.RFormula (parseModel)
+
+import           Hanalyze.Model.Core   (FitResult, coefficientsV)
+import           Hanalyze.Model.GLM    ( Family (..), LinkFn (..)
+                                       , fitGLMFull )
+import           Hanalyze.Model.GP     (Kernel (..), GPParams (..))
+import           Hanalyze.Model.LM     ( designMatrix, fitLMVec )
+import           Hanalyze.Model.Spline ( SplineKind (..), SplineFit (..)
+                                       , fitSpline )
+import           Hanalyze.Model.GAM    (GAMFit (..), fitGAM)
+import           Hanalyze.Model.Robust ( RobustEstimator, RobustFit (..)
+                                       , fitRobustLM )
+import           Hanalyze.Model.MultiLM (MultiFit (..))
+import           Hanalyze.Model.Quantile (QRFit (..), fitQuantile)
+import           Hanalyze.MCMC.Core     (Chain (..), chainVals)
+import           Hanalyze.MCMC.NUTS     ( nutsChains, nutsChainsPure, nutsChainsStream
+                                       , NUTSConfig (..), defaultNUTSConfig )
+import           Hanalyze.MCMC.Progress (newProgressRenderer)
+import           Hanalyze.Model.HBM     ( ModelP, withData, withDataIx, getTransforms
+                                               , sampleNames, deterministicNames
+                                               , augmentChainWithDeterministic )
+import           Hanalyze.Stat.Summary  (SummaryRow (..), posteriorSummary)
+import           Hanalyze.Viz.MCMC      (printPosteriorSummary)
+import           Hanalyze.Stat.Distribution (Transform (..))
+import           Hanalyze.Model.TimeSeries (ARFit (..), fitAR)
+import           Hanalyze.Stat.Standardize (Standardizer (..))
+import           Hanalyze.Model.PLS (PLSFit (..), fitPLS, predictPLS, PLSConfig (..))
+import           Hanalyze.Model.Regularized (RegFit (..))
+
+-- ===========================================================================
+-- 多変量 effect plot の along / HoldAgg
+-- ===========================================================================
+
+-- | 多変量 effect plot で「動かす変数」 (along)。 'statModelMulti' の必須引数。
+-- 型で単/多変量を分離し along 忘れをコンパイル時に弾く (§3 確定設計)。
+newtype AlongSpec = AlongSpec Text
+
+-- | along 変数を指定する。 @statModelMulti m (along \"x1\")@。
+along :: Text -> AlongSpec
+along = AlongSpec
+
+-- | 多変量 effect で along 以外の説明変数をどう固定するか (既定 'Mean')。
+--
+--   * 'Mean' \/ 'Median' = 連続変数の集約 (factor 列は自動で 'Mode' に振替)。
+--   * 'Mode' = 最頻 (連続は丸め最頻、 factor は最頻水準)。
+--   * 'Reference' = factor の参照水準 (昇順先頭。 連続は 'Mean' に振替)。
+--   * 'Marginalize' = 固定せず観測分布で周辺化 (PDP\/AME。 全観測行 × grid で重く、
+--     band は提供しない = 曲線のみ)。
+--   * 'Fixed' = 明示指定 (部分指定可。 指定の無い変数は 'Mean')。
+data HoldAgg
+  = Mean
+  | Median
+  | Mode
+  | Reference
+  | Marginalize
+  | Fixed [(Text, Double)]
+  deriving (Eq, Show)
+
+-- ===========================================================================
+-- 帯モード / 予測区間セレクタ
+-- ===========================================================================
+
+-- | 帯モードセレクタ (Phase 70.F)。 出す帯を 1 つの値で選ぶ ('bandMode' で指定):
+--
+--   * @BandOff@  = 帯なし (曲線のみ)。
+--   * @BandCI@   = 信頼区間のみ (平均 E[y|x] の不確実性・**既定**)。
+--   * @BandPI@   = 予測区間のみ (新規観測 1 点が入る区間・σ̂² を含むぶん広い)。
+--   * @BandCIPI@ = CI + PI を入れ子で重ねる (外=PI 薄・内=CI 濃・ファンチャート)。
+--
+-- PI 非提供モデル (Robust\/GAM\/Quantile\/非 Gaussian GLM\/effect plot) では
+-- @BandPI@\/@BandCIPI@ は CI へフォールバックする ('svGridPI' = 'Nothing')。
+data BandMode = BandOff | BandCI | BandPI | BandCIPI
+  deriving (Eq, Show)
+
+-- | 帯の算出法 (Phase 70.H・'piMethod' で指定)。 @PIClosedForm@ = 閉形式 (既定)、
+--   @PIBootstrap seed draws@ = case-resampling ブートストラップ (seed 決定的・draws 回)。
+data PIMethod = PIClosedForm | PIBootstrap !Word32 !Int
+  deriving (Eq, Show)
+
+-- ===========================================================================
+-- 応答曲面オプション
+-- ===========================================================================
+
+-- | 'surfaceOf' / 'surfaceGrid' のオプション。
+data SurfaceOpts = SurfaceOpts
+  { soN      :: Int                     -- ^ 各軸の grid 点数 (既定 40)。
+  , soHoldAt :: HoldAgg                 -- ^ 他変数の固定方式 (既定 'Mean')。
+  , soXRange :: Maybe (Double, Double)  -- ^ v1 範囲 (既定 = 観測 min\/max)。
+  , soYRange :: Maybe (Double, Double)  -- ^ v2 範囲 (既定 = 観測 min\/max)。
+  }
+
+defaultSurfaceOpts :: SurfaceOpts
+defaultSurfaceOpts = SurfaceOpts
+  { soN = 40, soHoldAt = Mean, soXRange = Nothing, soYRange = Nothing }
+
+-- ===========================================================================
+-- 多変量モデル型 (effect plot 用、 新規 fit)
+-- ===========================================================================
+
+-- | formula + 'DataFrame' で fit した多変量線形モデル (effect plot 用)。
+data MultiLMModel = MultiLMModel
+  { mlmFormula :: Formula           -- ^ R\/独自 formula (評価点設計行列の組み立てに保持)。
+  , mlmFrame   :: ModelFrame        -- ^ 訓練 frame (along range + 他変数集約元)。
+  , mlmDesign  :: LA.Matrix Double  -- ^ 訓練設計行列 X ('confidenceBandAt' の分散核)。
+  , mlmResult  :: FitResult         -- ^ OLS 結果。
+  }
+
+-- | formula 文字列 (例 @\"y ~ x1 + x2 + x3\"@) と 'DataFrame' から多変量 LM を組む。
+multiLMModel :: Text -> DX.DataFrame -> Either String MultiLMModel
+multiLMModel fml df = parseModel fml >>= \f -> multiLMModelF f df
+
+-- | 既に組み上げた 'Formula' (parse 済 or 'additiveFormula' で直接合成) から多変量 LM を
+--   組む。 重回帰 spec ('lmMulti') が parse を経ずに使う経路 (Phase 70.D)。
+multiLMModelF :: Formula -> DX.DataFrame -> Either String MultiLMModel
+multiLMModelF f df = do
+  mf         <- modelFrame f df
+  (x, _)     <- designMatrixF f mf
+  (res, _)   <- fitLMF f df
+  Right MultiLMModel { mlmFormula = f, mlmFrame = mf, mlmDesign = x, mlmResult = res }
+
+-- | formula + 'DataFrame' で fit した多変量 GLM (effect plot 用)。 帯は μ スケールで非対称。
+data MultiGLMModel = MultiGLMModel
+  { mglmFormula :: Formula           -- ^ formula (評価点設計行列に保持)。
+  , mglmFrame   :: ModelFrame        -- ^ 訓練 frame。
+  , mglmResult  :: FitResult         -- ^ 'fitGLMFull' の結果 (β\/μ̂)。
+  , mglmSigma   :: LA.Matrix Double  -- ^ 逆 Fisher 情報 Σ (CI 用)。
+  , mglmFamily  :: Family            -- ^ 分布族。
+  , mglmLink    :: LinkFn            -- ^ リンク関数。
+  }
+
+-- | family\/link + formula 文字列 + 'DataFrame' から多変量 GLM を組む。
+multiGLMModel :: Family -> LinkFn -> Text -> DX.DataFrame -> Either String MultiGLMModel
+multiGLMModel family link fml df =
+  parseModel fml >>= \f -> multiGLMModelF family link f df
+
+-- | 既に組み上げた 'Formula' から多変量 GLM を組む (重回帰 spec 'glmMulti' 用・Phase 70.D)。
+multiGLMModelF :: Family -> LinkFn -> Formula -> DX.DataFrame -> Either String MultiGLMModel
+multiGLMModelF family link f df = do
+  mf     <- modelFrame f df
+  (x, _) <- designMatrixF f mf
+  yv     <- responseVec mf
+  let y            = LA.fromList (V.toList yv)
+      (res, sigma) = fitGLMFull family link x y
+  Right MultiGLMModel { mglmFormula = f, mglmFrame = mf, mglmResult = res
+                      , mglmSigma = sigma, mglmFamily = family, mglmLink = link }
+
+-- ===========================================================================
+-- 列名リスト → 加法線形 Formula AST (パース無し直接合成) — Phase 70.D
+--
+-- 重回帰 (multiple regression) は formula DSL とは別概念: 説明変数の列名リストから
+-- 設計行列 @[1, x1, …, xp]@ を作るだけ。 これを文字列を介さず 'Formula' AST に直接
+-- 組み立て、 既存の 'multiLMModelF' / 'designMatrixF' / effect plot 機構をそのまま使う
+-- (= @parseModel "y ~ x1 + … + xp"@ と同一 AST。 パラメータ名 @_p0.._pp@ も同じ規約)。
+-- ===========================================================================
+
+-- | 応答列 @y@ と説明変数列名 @[x1,…,xp]@ から加法線形 'Formula' を直接合成する。
+--   RHS = @_p0 + _p1*x1 + … + _pp*xp@ (切片 + 各変数の主効果)。 数値列前提
+--   (factor / 交互作用 / 変換が要るなら formula 版 'lmF' を使う)。
+additiveFormula :: Text -> [Text] -> Formula
+additiveFormula y xs = Formula
+  { formResponse = y
+  , formDataVars = xs
+  , formRHS      = foldl1 (Bin Add)
+      (Ref "_p0" : zipWith (\i x -> Bin Mul (Ref ("_p" <> tshowInt i)) (Ref x))
+                           [1 :: Int ..] xs) }
+  where tshowInt = T.pack . show
+
+-- | formula + 'DataFrame' で fit した多変量ロバスト回帰 (effect plot 用)。
+data MultiRobustModel = MultiRobustModel
+  { mrmEstimator :: RobustEstimator   -- ^ Huber k or Tukey c。
+  , mrmFormula   :: Formula           -- ^ 評価点設計行列の組み立てに保持。
+  , mrmFrame     :: ModelFrame        -- ^ 訓練 frame (along range + 他変数集約元)。
+  , mrmDesign    :: LA.Matrix Double  -- ^ 訓練設計行列 X (サンドイッチ共分散の核)。
+  , mrmFit       :: RobustFit         -- ^ 'fitRobustLM' の結果 (β̂ / 重み / スケール)。
+  }
+
+-- | 'Formula' + 'DataFrame' から多変量ロバスト回帰を組む (重回帰 spec 'robustMulti' 用)。
+multiRobustModelF :: RobustEstimator -> Formula -> DX.DataFrame
+                  -> Either String MultiRobustModel
+multiRobustModelF est f df = do
+  mf     <- modelFrame f df
+  (x, _) <- designMatrixF f mf
+  yv     <- responseVec mf
+  let y   = LA.fromList (V.toList yv)
+      fit = fitRobustLM est x y 50 1e-6
+  Right MultiRobustModel { mrmEstimator = est, mrmFormula = f, mrmFrame = mf
+                         , mrmDesign = x, mrmFit = fit }
+
+-- | PLS の effect plot 用ラッパ (Phase 70.B2)。 'PLSFit' は列名/'ModelFrame' を
+-- 持たないので、 'statModelMulti' (along/holdAt/byVar) を効かせるために訓練 frame と
+-- 列順・出力選択を保持する。 'MultiLMModel' と同型 (frame-carrying wrapper)。
+data PLSModel = PLSModel
+  { plsmFit    :: !PLSFit       -- ^ 学習済 PLS。
+  , plsmFrame  :: !ModelFrame   -- ^ 訓練 frame (X 列 = 'RoleContinuous'・along range/hold の元)。
+  , plsmXNames :: ![Text]       -- ^ X 列名 ('predictPLS' へ渡す列順)。
+  , plsmYNames :: ![Text]       -- ^ Y 出力名 (出力セレクタ 'selectOutput' 用)。
+  , plsmOutIdx :: !Int          -- ^ effect plot に描く Y 出力列 index (既定 0)。
+  }
+
+-- | 列名指定で PLS effect plot 用モデルを組む。 @plsModel cfg xcols ycols df@。
+--   学習は 'fitPLS'、 frame は X 列を 'RoleContinuous' として手組み (応答ダミー)。
+plsModel :: ColumnSource d
+         => PLSConfig -> [Text] -> [Text] -> d -> Either String PLSModel
+plsModel cfg xcols ycols d = do
+  x <- reqColsM xcols d
+  y <- reqColsM ycols d
+  fit <- either (Left . T.unpack) Right (fitPLS cfg x y)
+  let n        = LA.rows x
+      xColVecs = [ V.fromList (LA.toList c) | c <- LA.toColumns x ]
+      -- 応答ダミーを先頭に (慣例: 応答が先頭・PLS の mvEvalFrame は応答を読まない)。
+      roles    = ("__pls_resp", RoleResponse (V.replicate n 0))
+               : [ (nm, RoleContinuous v) | (nm, v) <- zip xcols xColVecs ]
+      mf = ModelFrame { mfRoles = roles, mfParams = [], mfNRows = n }
+  Right PLSModel { plsmFit = fit, plsmFrame = mf, plsmXNames = xcols
+                 , plsmYNames = ycols, plsmOutIdx = 0 }
+
+-- | 描く Y 出力列を名前で選ぶ (多出力 PLS 用・既定は第 0 出力)。
+--   @statModelMulti (selectOutput \"y2\" m) (along \"x1\")@。 名前が無ければ無変更。
+selectOutput :: Text -> PLSModel -> PLSModel
+selectOutput yname m =
+  case lookup yname (zip (plsmYNames m) [0 ..]) of
+    Just i  -> m { plsmOutIdx = i }
+    Nothing -> m
+
+-- ===========================================================================
+-- 線形モデル (描画可能)
+-- ===========================================================================
+
+-- | X を束ねた描画可能な単回帰モデル。
+data LMModel = LMModel
+  { lmDesign :: LA.Matrix Double  -- ^ 設計行列 X @n × p@ (intercept 列含む)。
+  , lmResult :: FitResult         -- ^ 'fitLMVec' の結果 (β / ŷ / 残差 / R²)。
+  , lmXraw   :: LA.Vector Double  -- ^ 散布図 x 軸の生 predictor @n@ (単回帰の x)。
+  }
+
+-- | 単回帰 @(x, y)@ から 'LMModel' を組む。 設計行列は @[1, x]@、 fit は 'fitLMVec'。
+lmModel :: LA.Vector Double -> LA.Vector Double -> LMModel
+lmModel xs ys =
+  let dm  = designMatrix (V.fromList (LA.toList xs))  -- designMatrix は boxed Vector 入力
+      res = fitLMVec dm ys
+  in LMModel { lmDesign = dm, lmResult = res, lmXraw = xs }
+
+-- | X と family/link を束ねた描画可能な単回帰 GLM。
+data GLMModel = GLMModel
+  { glmDesign :: LA.Matrix Double  -- ^ 設計行列 X @n × p@ (intercept 列含む)。
+  , glmResult :: FitResult         -- ^ 'fitGLMFull' の結果 (β / μ̂ / 残差)。
+  , glmSigma  :: LA.Matrix Double  -- ^ 逆 Fisher 情報 Σ=(XᵀWX)⁻¹ (CI 用)。
+  , glmFamily :: Family            -- ^ 分布族 (帯の意味付けに保持)。
+  , glmLink   :: LinkFn            -- ^ リンク関数 (μ スケールへの逆変換に必要)。
+  , glmXraw   :: LA.Vector Double  -- ^ 散布図 x 軸の生 predictor @n@ (単回帰の x)。
+  }
+
+-- | 単回帰 @(x, y)@ と family/link から 'GLMModel' を組む。 設計行列は @[1, x]@、
+-- fit は 'fitGLMFull' (FitResult と逆 Fisher 情報 Σ の両方を返す)。
+glmModel :: Family -> LinkFn -> LA.Vector Double -> LA.Vector Double -> GLMModel
+glmModel family link xs ys =
+  let dm           = designMatrix (V.fromList (LA.toList xs))
+      (res, sigma) = fitGLMFull family link dm ys
+  in GLMModel { glmDesign = dm, glmResult = res, glmSigma = sigma
+              , glmFamily = family, glmLink = link, glmXraw = xs }
+
+-- | X (生 predictor) を束ねた描画可能なスプライン回帰モデル。
+--
+-- 'SplineFit' は基底行列を保持しないので、 'confidenceBand' / 散布図用に生 x を別途
+-- 束ねる (= LMModel と同型の「描画可能なモデル」 化)。
+data SplineModel = SplineModel
+  { splFit  :: SplineFit          -- ^ 'fitSpline' の結果 (basis 係数 + 線形核)。
+  , splXraw :: LA.Vector Double   -- ^ 散布図 x 軸の生 predictor @n@。
+  }
+
+-- | @(x, y)@ と spline 種別・ノットから 'SplineModel' を組む。
+splineModel
+  :: SplineKind        -- ^ B-spline (次数) or 自然 3 次スプライン。
+  -> [Double]          -- ^ 内部ノット (境界含む)。
+  -> LA.Vector Double  -- ^ 説明変数 x。
+  -> LA.Vector Double  -- ^ 応答 y。
+  -> SplineModel
+splineModel kind knots xs ys =
+  let xsV = V.fromList (LA.toList xs)
+      ysV = V.fromList (LA.toList ys)
+      fit = fitSpline kind knots xsV ysV
+  in SplineModel { splFit = fit, splXraw = xs }
+
+-- | X (単一 predictor の生 x) を束ねた描画可能な単変量 GAM。
+data GAMModel = GAMModel
+  { gamFit  :: GAMFit             -- ^ 'fitGAM' の結果 (基底係数 + fitted)。
+  , gamXraw :: LA.Vector Double   -- ^ 散布図 x 軸の生 predictor @n@ (単変量の x)。
+  }
+
+-- | 単変量 @(x, y)@ から 'GAMModel' を組む。 内部で 1 特徴の 'fitGAM' を呼ぶ。
+gamModel
+  :: Int               -- ^ B-spline 次数 (3 = cubic 推奨)。
+  -> Int               -- ^ 内部ノット数 (例 5)。
+  -> Double            -- ^ ridge 罰則 λ (0 で無効)。
+  -> LA.Vector Double  -- ^ 説明変数 x。
+  -> LA.Vector Double  -- ^ 応答 y。
+  -> GAMModel
+gamModel degree nKnots lambda xs ys =
+  let xsV = V.fromList (LA.toList xs)
+      ysV = V.fromList (LA.toList ys)
+      fit = fitGAM degree nKnots lambda [xsV] ysV
+  in GAMModel { gamFit = fit, gamXraw = xs }
+
+-- | df|-> 由来の (多予測子) GAM。 第1予測子を描画軸にする。
+data GAMModelN = GAMModelN
+  { gamNFit   :: GAMFit              -- ^ 'fitGAMAuto' の結果。
+  , gamNXraws :: [LA.Vector Double]  -- ^ 予測子ごとの訓練 x (列名順)。
+  , gamNNames :: [Text]             -- ^ 予測子名 (列名順)。
+  }
+
+-- | X (生 predictor) を束ねた描画可能な単回帰ロバストモデル。
+data RobustModel = RobustModel
+  { rmFit  :: RobustFit           -- ^ 'fitRobustLM' の結果 (β̂ / ŷ / 重み)。
+  , rmXraw :: LA.Vector Double    -- ^ 散布図 x 軸の生 predictor @n@ (単回帰の x)。
+  }
+
+-- | 単回帰 @(x, y)@ と estimator から 'RobustModel' を組む。 設計行列は @[1, x]@、
+-- fit は 'fitRobustLM' (max 50 iter / tol 1e-6)。
+robustModel
+  :: RobustEstimator   -- ^ Huber k or Tukey c。
+  -> LA.Vector Double  -- ^ 説明変数 x。
+  -> LA.Vector Double  -- ^ 応答 y。
+  -> RobustModel
+robustModel est xs ys =
+  let dm  = designMatrix (V.fromList (LA.toList xs))
+      fit = fitRobustLM est dm ys 50 1e-6
+  in RobustModel { rmFit = fit, rmXraw = xs }
+
+-- | X と複数 τ の fit を束ねた描画可能な分位点回帰モデル。
+data QuantileModel = QuantileModel
+  { qmFits :: [(Double, QRFit)]   -- ^ (τ, その fit) の並び (τ 昇順を推奨)。
+  , qmXraw :: LA.Vector Double    -- ^ 散布図 x 軸の生 predictor @n@ (単回帰の x)。
+  }
+
+-- | 単回帰 @(x, y)@ と分位水準 τ のリストから 'QuantileModel' を組む。 設計行列は @[1, x]@、
+-- 各 τ を 'fitQuantile' で fit。
+quantileModel
+  :: [Double]          -- ^ 分位水準 τ ∈ (0,1) のリスト (例 [0.1, 0.5, 0.9])。
+  -> LA.Vector Double  -- ^ 説明変数 x。
+  -> LA.Vector Double  -- ^ 応答 y。
+  -> QuantileModel
+quantileModel taus xs ys =
+  let dm   = designMatrix (V.fromList (LA.toList xs))
+      fits = [ (t, fitQuantile t dm ys) | t <- taus ]
+  in QuantileModel { qmFits = fits, qmXraw = xs }
+
+-- | 多変量 (重回帰) 分位点回帰の結果。 設計行列 @[1, x₁..xₚ]@ に各 τ で 'fitQuantile' を
+--   当てた fit 群を保持する。 係数は @qfBeta@ ('mqmFits' の各 'QRFit') で取り出す
+--   (分位点回帰は SE を持たないため 'coefSummary' は非対応・単変量 'quantile' と一貫)。
+data MultiQuantileModel = MultiQuantileModel
+  { mqmTaus  :: ![Double]            -- ^ 分位水準 τ の並び。
+  , mqmNames :: ![Text]             -- ^ 予測子名 (intercept を除く・設計行列の 2 列目以降と対応)。
+  , mqmFits  :: ![(Double, QRFit)]   -- ^ 各 τ の fit (係数 'qfBeta' = @[β₀, β₁, …, βₚ]@)。
+  , mqmX     :: !(LA.Matrix Double)  -- ^ 設計行列 @[1, x₁, …, xₚ]@ (effect plot の評価元)。
+  }
+
+-- ===========================================================================
+-- MCMC チェーン (描画可能)
+-- ===========================================================================
+
+-- | 1 パラメータを選んだ描画可能な MCMC チェーン。
+data ChainModel = ChainModel
+  { cmChain :: Chain   -- ^ サンプラ出力 (post-burn-in)。
+  , cmParam :: Text    -- ^ 描画対象のパラメータ名。
+  }
+
+-- | パラメータ名と 'Chain' から 'ChainModel' を組む。
+chainModel :: Text -> Chain -> ChainModel
+chainModel name ch = ChainModel { cmChain = ch, cmParam = name }
+
+-- ===========================================================================
+-- HBM (ベイズ確率プログラム) の学習 — Phase 49 A1
+-- ===========================================================================
+
+-- | HBM 学習の設定。 NUTS の chain 数 / 本サンプル数 / warmup を保持する
+-- (brms 既定 = 4 chains × 1000 draws + 1000 warmup に相当)。 'hbmSeed' は
+-- 純粋化 (将来の ST 版 @hbmModelPure seed …@) の継ぎ目として今から署名に持つ。
+data HBMConfig = HBMConfig
+  { hbmChains    :: !Int            -- ^ chain 数 (既定 4)。
+  , hbmSamples   :: !Int            -- ^ 本サンプル数 = post-warmup draws (既定 1000)。
+  , hbmWarmup    :: !Int            -- ^ warmup / burn-in (既定 1000)。
+  , hbmSeed      :: !(Maybe Word32) -- ^ 乱数シード (現状は IO 内で消費・将来 ST の継ぎ目)。
+  , hbmAdaptMass :: !Bool           -- ^ 対角質量行列の適応 (既定 True・brms/PyMC 同様)。
+                                     --   a/b と s のようにスケールが大きく異なる posterior で
+                                     --   収束 (特に scale param) に必須。 OFF だと s が未収束に
+                                     --   なりやすい (Phase 52.A12 で計測確認)。
+  , hbmWarmupInitMaxDepth :: !(Maybe Int)
+                                     -- ^ Phase 96 A5: 'nutsWarmupInitMaxDepth' の pass-through
+                                     --   (opt-in・既定 'Nothing' = 無効)。 質量行列の初回更新前
+                                     --   (M=I 期間) の tree depth 上限。 warmup 初期の ε 鋸歯で
+                                     --   deep tree を掘る浪費 (05-mh 実測で warmup evals が
+                                     --   nutpie 比 1.92×) を 'Just' 6 等で抑制する。 参照実装に
+                                     --   無いヒューリスティックゆえ既定 OFF (NUTS.hs 側と同判断)。
+  } deriving (Show, Eq)
+
+-- | 既定の HBM 設定: 4 chains × 1000 draws + 1000 warmup (brms 既定相当)。 質量行列適応 ON。
+defaultHBM :: HBMConfig
+defaultHBM = HBMConfig
+  { hbmChains    = 4
+  , hbmSamples   = 1000
+  , hbmWarmup    = 1000
+  , hbmSeed      = Nothing
+  , hbmAdaptMass = True
+  , hbmWarmupInitMaxDepth = Nothing
+  }
+
+-- | 学習済 HBM モデル。 data placeholder を bind したモデル本体 ('hbmModelSpec')、
+-- posterior draws ('hbmChainsR' = chain 群)、 bind 済みデータ ('hbmData') を保持する。
+-- ★ 抽出子 ('epred' 等) はここから純粋に図を組む (= @df |>>@ と整合)。
+data HBMModel = HBMModel
+  { hbmModelSpec :: ModelP ()          -- ^ data を bind 済みのモデル (epred 評価でも使う)。
+  , hbmChainsR   :: ![Chain]           -- ^ posterior draws (chain 群)。
+  , hbmData      :: ![(Text, [Double])] -- ^ bind 済みデータ列 (列名 → 値)。
+  , hbmFactorLevels :: ![(Text, [Text])]
+    -- ^ Phase 60.3: 'dataNamedIx' slot に Text factor 列を bind した場合の
+    --   sort 順 levels (slot 名 → levels)。 コード i = levels !! i で、
+    --   indexed パラメータ (b0_2 等) がどの群かを引ける。 数値列 bind は空。
+  }
+
+-- | 列名→値の組をモデル中の data placeholder に順に 'withData' で bind する。
+-- 明示再帰 (foldr ではなく) なのは、 'ModelP' が rank-2 多相 ('forall a.') ゆえ
+-- ImpredicativeTypes 下の foldr では accumulator が単相化してしまうため。
+bindCols :: [(Text, [Double])] -> ModelP r -> ModelP r
+bindCols []             m = m
+bindCols ((n, vs):rest) m = bindCols rest (withData n vs m)
+
+-- | 'bindCols' の 'DataIx' 版 (Phase 60.3): 列名→index 列を 'withDataIx' で bind。
+bindIxCols :: [(Text, [Int])] -> ModelP r -> ModelP r
+bindIxCols []             m = m
+bindIxCols ((n, is):rest) m = bindIxCols rest (withDataIx n is m)
+
+-- | 確率プログラム 'ModelP' を NUTS で学習し 'HBMModel' にする (MCMC ゆえ IO)。
+--
+-- 列名で 'withData' を畳み込み、 モデル中の placeholder ('dataNamed' / observe の
+-- 参照名) を df 由来の実データに差し替える (PyMC @set_data@ 同型)。 chain は既存
+-- 'nutsChains' が並列実行 (実 OS スレッド並列には @-threaded +RTS -N@ が要る)。
+--
+-- 当面の入口は @[(Text,[Double])]@ (列名→値)。 'ColumnSource' 一般化
+-- (Map/DataFrame/assoc 疎結合) は別 Phase (データ API)。
+hbmModel :: HBMConfig -> ModelP () -> [(Text, [Double])] -> IO HBMModel
+hbmModel cfg model dat = do
+  let bound :: ModelP ()
+      bound = bindCols dat model
+      initC = hbmInitPoint bound
+      ncfg  = hbmNutsConfig cfg
+  gen <- case hbmSeed cfg of
+           Nothing -> createSystemRandom
+           Just w  -> initialize (V.singleton w)
+  chains <- nutsChains bound ncfg (hbmChains cfg) initC gen
+  pure HBMModel
+    { hbmModelSpec = bound
+    , hbmChainsR   = chains
+    , hbmData      = dat
+    , hbmFactorLevels = []
+    }
+
+-- | NUTS の初期点 (制約空間)。 positive 制約 (σ 等) を 0 で初期化すると @log 0 = -∞@ で
+-- 初手から全 proposal が divergence する (実測 2026-06-05)。 'getTransforms' で制約を検出し
+-- PositiveT→1 / UnitIntervalT→0.5 / 他→0 で初期化する。
+hbmInitPoint :: ModelP () -> Map.Map Text Double
+hbmInitPoint bound = Map.map initFor (getTransforms bound)
+  where
+    initFor PositiveT      = 1.0
+    initFor UnitIntervalT  = 0.5
+    initFor UnconstrainedT = 0.0
+
+-- | 'HBMConfig' → 'NUTSConfig'。 NUTS は @total = burnIn + iterations@ を回し iterations 本
+-- だけ保持するので、 iterations に本サンプル数・burnIn に warmup を割り当てる。
+hbmNutsConfig :: HBMConfig -> NUTSConfig
+hbmNutsConfig cfg = defaultNUTSConfig
+  { nutsIterations = hbmSamples cfg
+  , nutsBurnIn     = hbmWarmup cfg
+  , nutsAdaptMass  = hbmAdaptMass cfg
+  , nutsWarmupInitMaxDepth = hbmWarmupInitMaxDepth cfg
+  -- Phase 94 A4-2: init jitter ('nutsInitJitter') は opt-in の infra として持つが
+  -- **blanket default にはしない** (=0)。 seeds の funnel は非中心化で解消済で
+  -- jitter 不要。 一律 jitter=1.0 は相関 RE 小モデル (WorkflowSpec ranSlope) の
+  -- warmup を散らして傾き回復を壊す退化が実測されたため (§A4-2)。 funnel 型で
+  -- 明示的に効かせたい呼び出し側が個別に nutsInitJitter を上げる。
+  }
+
+-- | 純粋・決定的な HBM 学習 (Phase 50.4)。 'hbmModel' の ST/seed 版で IO を持たない。
+-- 'nutsChainsPure' (chain 横断を spark 並列・seed で再現可能) を使う。 @hbmSeed@ が
+-- 'Nothing' のときは固定既定 seed (42) を用いる (純粋・決定的を保証する設計判断)。
+hbmModelPure :: HBMConfig -> ModelP () -> [(Text, [Double])] -> HBMModel
+hbmModelPure cfg model dat = hbmModelPureWith cfg model dat [] []
+
+-- | 'hbmModelPure' の拡張形 (Phase 60.3): 'DataIx' slot の index 列と
+-- Text factor levels も bind する。 'df |-> hbm' ('Fit' instance) が
+-- 'resolveIxSlots' で解決した結果を渡す主経路。
+hbmModelPureWith :: HBMConfig -> ModelP () -> [(Text, [Double])]
+                 -> [(Text, [Int])] -> [(Text, [Text])] -> HBMModel
+hbmModelPureWith cfg model dat ixDat levels =
+  let bound :: ModelP ()
+      bound  = bindIxCols ixDat (bindCols dat model)
+      initC  = hbmInitPoint bound
+      ncfg   = hbmNutsConfig cfg
+      seed   = fromMaybe 42 (hbmSeed cfg)
+      chains = nutsChainsPure bound ncfg (hbmChains cfg) initC seed
+  in HBMModel
+       { hbmModelSpec = bound
+       , hbmChainsR   = chains
+       , hbmData      = dat
+       , hbmFactorLevels = levels
+       }
+
+-- | 'hbmModelPure' の IO 版 (Phase 61.3): stderr に進捗を表示しながら学習する。
+-- bind + seed 規約は 'hbmModelPureWith' と同一・chain ごとの seed は
+-- 'chainSeeds' 共有 ('nutsChainsStream') ゆえ、 結果は同 cfg の
+-- 'hbmModelPure' と**ビット一致**する (test-plot で固定)。
+hbmModelIO :: HBMConfig -> ModelP () -> [(Text, [Double])] -> IO HBMModel
+hbmModelIO cfg model dat = hbmModelIOWith cfg model dat [] []
+
+-- | 'hbmModelPureWith' の IO + 進捗表示版 (Phase 61.3)。 '(|->!)' の
+-- HBM 経路 ('fitIO') が 'resolveIxSlots' の解決結果を渡す主経路。
+hbmModelIOWith :: HBMConfig -> ModelP () -> [(Text, [Double])]
+               -> [(Text, [Int])] -> [(Text, [Text])] -> IO HBMModel
+hbmModelIOWith cfg model dat ixDat levels = do
+  let bound :: ModelP ()
+      bound    = bindIxCols ixDat (bindCols dat model)
+      initC    = hbmInitPoint bound
+      ncfg     = hbmNutsConfig cfg
+      seed     = fromMaybe 42 (hbmSeed cfg)
+      perChain = hbmWarmup cfg + hbmSamples cfg
+  (onSample, finish) <- newProgressRenderer (hbmChains cfg) perChain
+  chains <- nutsChainsStream bound ncfg (hbmChains cfg) initC seed onSample
+  finish
+  pure HBMModel
+    { hbmModelSpec = bound
+    , hbmChainsR   = chains
+    , hbmData      = dat
+    , hbmFactorLevels = levels
+    }
+
+-- ===========================================================================
+-- HBM 事後要約 (Phase 103)
+-- ===========================================================================
+
+-- | 要約対象のパラメタ名 (latent 宣言順 → deterministic 宣言順の連結)。
+-- deterministic 派生量を既定で含めるのは PyMC/arviz の @az.summary@ が
+-- Deterministic を含むのと同型 (Phase 103 A1 確定)。
+hbmSummaryNames :: HBMModel -> [Text]
+hbmSummaryNames m = sampleNames spec ++ deterministicNames spec
+  where spec :: ModelP ()
+        spec = hbmModelSpec m
+
+-- | deterministic 派生量を注入済みの chain 群。派生量が無いモデルでは
+-- augment (全 draw の再評価) を省いて素の chain を返す。
+hbmAugmentedChains :: HBMModel -> [Chain]
+hbmAugmentedChains m
+  | null (deterministicNames spec) = hbmChainsR m
+  | otherwise = map (augmentChainWithDeterministic spec) (hbmChainsR m)
+  where spec :: ModelP ()
+        spec = hbmModelSpec m
+
+-- | 学習済 HBM の事後要約表 (@az.summary@ 相当・純粋)。
+-- mean / sd / HDI / ess_bulk (+ multi-chain 時 r_hat) を latent +
+-- deterministic の全パラメタについて返す。
+hbmSummary :: HBMModel -> [SummaryRow]
+hbmSummary m = posteriorSummary (hbmSummaryNames m) (hbmAugmentedChains m)
+
+-- | 'hbmSummary' をコンソール表として表示する。
+printHBMSummary :: HBMModel -> IO ()
+printHBMSummary m = printPosteriorSummary (hbmSummaryNames m) (hbmAugmentedChains m)
+
+-- | 'hbmSummary' の DataFrame 化。列 = param / mean / sd / hdi_lo / hdi_hi /
+-- ess_bulk (+ multi-chain 時のみ r_hat = 'printPosteriorSummary' の列規約と同じ)。
+hbmSummaryDf :: HBMModel -> DX.DataFrame
+hbmSummaryDf m =
+  let rows  = hbmSummary m
+      multi = any (\r -> case srRhat r of Just _ -> True; _ -> False) rows
+      base  =
+        [ ("param",    DX.fromList (map srName  rows))
+        , ("mean",     DX.fromList (map srMean  rows))
+        , ("sd",       DX.fromList (map srSD    rows))
+        , ("hdi_lo",   DX.fromList (map srHdiLo rows))
+        , ("hdi_hi",   DX.fromList (map srHdiHi rows))
+        , ("ess_bulk", DX.fromList (map srEssV  rows))
+        ]
+      rh    = [ ("r_hat", DX.fromList (map (fromMaybe (0 / 0) . srRhat) rows))
+              | multi ]
+  in DX.fromNamedColumns (base ++ rh)
+
+-- | 事後 draw の DataFrame 化 (1 パラメタ = 1 列・全 chain を chain 順に連結、
+-- deterministic 派生量込み)。'Hanalyze.Data.Wrangle' の
+-- @summarise@ / @groupBy@ 等で自由集計する入口。
+hbmDrawsDf :: HBMModel -> DX.DataFrame
+hbmDrawsDf m =
+  let chains = hbmAugmentedChains m
+  in DX.fromNamedColumns
+       [ (n, DX.fromList (concatMap (chainVals n) chains))
+       | n <- hbmSummaryNames m ]
+
+-- ===========================================================================
+-- 時系列予測 (描画可能)
+-- ===========================================================================
+
+-- | 履歴系列と AR fit・予測地平を束ねた描画可能な時系列予測モデル。
+data ForecastModel = ForecastModel
+  { fmFit     :: ARFit             -- ^ 'fitAR' の結果。
+  , fmHistory :: LA.Vector Double  -- ^ 観測系列 (時系列順)。
+  , fmHorizon :: Int               -- ^ 予測地平 h。
+  }
+
+-- | 系列・AR 次数・地平から 'ForecastModel' を組む ('fitAR' で fit)。
+forecastModel
+  :: Int               -- ^ AR 次数 p。
+  -> Int               -- ^ 予測地平 h。
+  -> LA.Vector Double  -- ^ 観測系列 (時系列順)。
+  -> ForecastModel
+forecastModel order horizon series =
+  ForecastModel { fmFit = fitAR order series, fmHistory = series
+                , fmHorizon = horizon }
+
+-- ===========================================================================
+-- データ源 → モデル を当てはめる統一型クラス。
+-- ===========================================================================
+
+-- | データ源 → モデル を当てはめる統一型クラス。
+--
+-- 'fitWith' / '(|->)' は **pure だが total ではない** (列欠落・parse 失敗は
+-- 'error')。 検証パイプライン用に total な 'fitEither' を併設する
+-- (既定の 'fitWith' は 'fitEither' を 'error' で潰したもの)。
+class Fit spec where
+  -- | この spec を当てはめた結果のモデル型。
+  type Fitted spec
+  -- | 当てはめ (pure・失敗は 'error')。 既定実装は 'fitEither' 経由。
+  fitWith   :: ColumnSource d => spec -> d -> Fitted spec
+  fitWith spec d = either error id (fitEither spec d)
+  -- | 当てはめ (total・失敗は 'Left')。
+  fitEither :: ColumnSource d => spec -> d -> Either String (Fitted spec)
+  -- | 当てはめ (IO・進捗表示など副作用つき学習・Phase 61.4)。 既定 =
+  -- @pure . fitWith@ で純粋 spec は挙動不変 (失敗の error 意味論も '(|->)'
+  -- と同じ)。 学習が重い spec ('HBMSpec') だけ override して進捗を出す。
+  fitIO     :: ColumnSource d => spec -> d -> IO (Fitted spec)
+  fitIO spec d = pure (fitWith spec d)
+  -- | 透過標準化ラッパ ('standardized' / 'standardizedY') が **標準化対象とする
+  -- 予測子列名** (Phase 70.3 項目 C)。 既定は @[]@ = 「被せる意味の無い spec」
+  -- であり、 'standardized' を付けても 'fitEither' が 'Left' で誤用を弾く。
+  -- 距離ベース (kNN) や線形 (整形目的) の spec だけが実列名を返す。 内部標準化済
+  -- ('GPSpec' \/ 'RegSpec' \/ 'PCASpec' \/ 'PLSSpec') と木系は二重標準化\/無意味回避で
+  -- 既定 @[]@ のまま (= ラッパ拒否)。
+  predictorCols :: spec -> [Text]
+  predictorCols _ = []
+  -- | 透過標準化ラッパが y も標準化する ('standardizedY') 際の**応答列名**
+  -- (Phase 70.3 項目 C)。 既定 'Nothing'。 **連続応答の回帰 spec のみ** @Just@ を返す。
+  -- 分類 (クラスラベル) や family\/link でスケールが拘束される GLM は標準化が不正ゆえ
+  -- 'Nothing' のまま (= 'standardizedY' を付けると 'fitEither' が 'Left')。
+  responseCol :: spec -> Maybe Text
+  responseCol _ = Nothing
+
+-- | 因果探索 (LiNGAM) の高レベル @df |->@ 結果ラッパ (Phase 77)。 各 LiNGAM fit 型は
+--   変数名を持たないため、 学習した fit @a@ に**変数名** (@df |->@ が渡した列名) を添える。
+--   'Plottable' (@Hanalyze.Plot.ML@) が @lfNames@ を DAG ノード名に使う
+--   (無ければ @x0..@ フォールバック)。 侵襲的な per-fit-型 names フィールド追加を避ける汎用ラッパ。
+data LiNGAMFitted a = LiNGAMFitted
+  { lfFit   :: !a        -- ^ 各 variant の fit 結果 ('DirectLiNGAMFit' 等)
+  , lfNames :: ![Text]   -- ^ 変数名 (行列の列順 = fit の変数 index 順)
+  } deriving (Show)
+
+-- | 列名で数値列を引き 'LA.Vector' 化 (無ければ 'Left')。 二変量近道の素経路。
+reqColV :: ColumnSource d => Text -> d -> Either String (LA.Vector Double)
+reqColV n d = case lookupCol n d of
+  Just xs -> Right (LA.fromList xs)
+  Nothing -> Left ("ColumnSource: 列が見つかりません: " <> T.unpack n)
+
+-- | 複数の列名から @n × p@ 行列を組む (各列名 = 1 変数 = 行列の 1 列・行=標本)。
+--   行列入力モデル (PCA \/ PLS \/ …) を列名 spec で高レベル化する際の素経路
+--   (Phase 70.A)。 列が 1 つも無い / 長さ不揃いは 'Left'。
+reqColsM :: ColumnSource d => [Text] -> d -> Either String (LA.Matrix Double)
+reqColsM [] _ = Left "ColumnSource: 列名が空です (1 列以上必要)"
+reqColsM ns d = do
+  cols <- mapM (`reqColV` d) ns
+  let lens = map LA.size cols
+  if all (== head lens) lens
+    then Right (LA.fromColumns cols)
+    else Left ("reqColsM: 列の長さが不揃いです: " <> show lens)
+
+-- ===========================================================================
+-- WLS / 透過標準化 / 群別フィット の結果型
+-- ===========================================================================
+
+-- | WLS の結果。 内側 'LMModel' は **√w スケール設計行列** ('lmDesign'=X_w) と
+--   その OLS 結果 ('lmResult')、 **元の x** ('lmXraw') を保持する (grid 経路で正しい
+--   WLS CI を出すための容れ物)。 weighted R² 算出用に**重み** と**元 y** も保持する。
+data WeightedLMModel = WeightedLMModel
+  { wlmInner   :: !LMModel    -- ^ √w スケール設計・OLS 結果・元 x。
+  , wlmWeights :: ![Double]   -- ^ 重み w (元の行順)。
+  , wlmY       :: ![Double]   -- ^ 元の応答 y (weighted R² 算出用)。
+  }
+
+-- | 透過標準化の結果。 内側モデル (標準化空間で学習) と逆変換に要る (μ,σ) を保持する。
+--   'SingleVarModel' / 'Plottable' instance (Phase 70.3 C2) がこれを使い元スケール軸で描く。
+data StandardizedModel m = StandardizedModel
+  { smInner :: !m                            -- ^ 標準化空間で fit した内側モデル。
+  , smXStd  :: !Standardizer                 -- ^ 予測子列の (μ,σ)。'Stat.Standardize'。
+  , smYStd  :: !(Maybe (Double, Double))     -- ^ 応答 y の (μ,σ)。'standardizedY' 時のみ。
+  , smTrain :: !(Maybe ([Double], [Double])) -- ^ 元スケール訓練 (x,y)。単変量散布図用 (予測子 1 列時のみ)。
+  }
+
+-- | 群別フィットの結果。 各群ラベル → その群の 'Fitted spec' を保持する
+--   **実結果型** ('HBMModel' 同族・'ModelSpec' ではない)。 'groupModels' で取り出す。
+newtype GroupedFit spec = GroupedFit { gfGroups :: [(Text, Fitted spec)] }
+
+-- ===========================================================================
+-- カーネル回帰 (描画可能)
+-- ===========================================================================
+
+-- | カーネル回帰の 4 象限。 @seed@/@D@ は RFF 近似コンストラクタにだけ載る。
+-- KRR 象限は 'Krr'/'KrrRff' (Kernel Ridge Regression・線形罰則回帰の 'Ridge' と区別)。
+data GPMethod
+  = Gp                       -- ^ 厳密 GP   (分布あり・事後分散→帯)。
+  | Krr                      -- ^ 厳密 KRR  (点・KRR ≡ GP 事後平均)。
+  | GpRff  !Int !Word32      -- ^ RFF 近似 GP  (@D@ 特徴次元, seed)。
+  | KrrRff !Int !Word32      -- ^ RFF 近似 KRR (@D@ 特徴次元, seed)。
+  deriving (Eq, Show)
+
+-- | ハイパラの「決め方 + (固定時のみ) 値」を一箇所に集約 (役割重複ゆえ別フィールドは持たない)。
+data HyperStrategy
+  = FixedHyper GPParams   -- ^ 固定: この 'GPParams' を使う (最適化しない)。
+  | AutoMarginalLik       -- ^ 周辺尤度で自動 ('GP.optimizeGP'・初期値はデータ駆動)。
+  | AutoCV                -- ^ LOOCV (PRESS) で自動 ('GP.autoCVHyperGP'・初期値は同上)。
+
+-- | 当てはめ済の統合カーネル回帰モデル。 象限 + 解決済ハイパラ + 予測子を保持する。
+-- 予測子 'gprPredict' は @grid x → (μ̂, Maybe 事後分散)@: 分布あり象限 (Gp/GpRff) は
+-- @Just 分散@、 点象限 (Ridge/RidgeRff) は @Nothing@ (= 帯なし)。 'SingleVarModel' /
+-- 'Plottable' instance は E2。
+data GPRegModel = GPRegModel
+  { gprMethod  :: !GPMethod                                  -- ^ Periodic フォールバック後の象限。
+  , gprKernel  :: !Kernel                                    -- ^ カーネル種 (Periodic は不変)。
+  , gprParams  :: !GPParams                                  -- ^ 解決済ハイパラ。
+  , gprXraw    :: !(LA.Vector Double)                        -- ^ 訓練 x (svRange / 散布図)。
+  , gprY       :: !(LA.Vector Double)                        -- ^ 訓練 y。
+  , gprPredict :: !([Double] -> ([Double], Maybe [Double]))  -- ^ grid x → (μ̂, Maybe 事後分散)。
+  }
+
+-- | 当てはめ済の多変量カーネル回帰モデル。 予測子 'gprnPredict' は評価行列 (@m × p@) を
+-- 取り @(μ̂, Maybe 事後分散)@ を返す (分布あり象限のみ Just)。
+data GPRegModelN = GPRegModelN
+  { gprnMethod  :: !GPMethod
+  , gprnKernel  :: !Kernel
+  , gprnParams  :: !GPParams
+  , gprnXraws   :: ![LA.Vector Double]                           -- ^ 予測子ごとの訓練 x (列名順)。
+  , gprnNames   :: ![Text]                                       -- ^ 予測子名 (列名順)。
+  , gprnYraw    :: !(LA.Vector Double)                           -- ^ 訓練応答 y (profiler 実測点の重ね用)。
+  , gprnPredict :: !(LA.Matrix Double -> ([Double], Maybe [Double])) -- ^ testX (m×p) → (μ̂, Maybe 分散)。
+  }
+
+-- ===========================================================================
+-- 罰則付き回帰 (描画可能)
+-- ===========================================================================
+
+-- | 罰則の種類 (実装済み全 7 種)。 追加パラメータも型に載せる。
+data RegMethod
+  = Ridge                      -- ^ L2。
+  | Lasso                      -- ^ L1。
+  | ElasticNet    !Double      -- ^ α = L1 比 (0..1)。
+  | MCP           !Double      -- ^ γ concavity (推奨 ≥3)。
+  | SCAD          !Double      -- ^ a (推奨 3.7)。
+  | AdaptiveLasso !Double      -- ^ OLS pilot weight 指数 γ。
+  | GroupLasso    ![Int]       -- ^ 各列の群 ID (列名順・長さ = 列数)。
+  deriving (Eq, Show)
+
+-- | 当てはめ済の罰則回帰モデル。 係数は **元スケール** (intercept + 特徴ごと)。
+data RegModel = RegModel
+  { rmgMethod    :: !RegMethod
+  , rmgNames     :: ![Text]                       -- ^ 説明変数名 (列順)。
+  , rmgLambda    :: !Double                        -- ^ 選択された λ。
+  , rmgIntercept :: !Double                        -- ^ β₀ (元スケール)。
+  , rmgCoefs     :: ![Double]                      -- ^ β (元スケール・特徴ごと・長さ = 列数)。
+  , rmgFitStd    :: !RegFit                        -- ^ 標準化空間の fit (診断用)。
+  , rmgCVPath    :: !(Maybe ([Double], [Double]))  -- ^ (λ grid, CV/LOOCV スコア)・自動選択時のみ。
+  , rmgXraw      :: !(LA.Matrix Double)            -- ^ 生設計行列 (特徴のみ・intercept 列なし)。bootstrap refit 用。
+  , rmgYraw      :: !(LA.Vector Double)            -- ^ 生応答 y。bootstrap refit 用。
+  }
+
+-- | 新規データ (各行が p 次元の特徴ベクトル) での予測 @ŷ = β₀ + Σ βⱼ xⱼ@。
+regPredict :: RegModel -> [[Double]] -> [Double]
+regPredict m rows = [ rmgIntercept m + sum (zipWith (*) (rmgCoefs m) r) | r <- rows ]
diff --git a/src/Hanalyze/Optim/Acquisition.hs b/src/Hanalyze/Optim/Acquisition.hs
--- a/src/Hanalyze/Optim/Acquisition.hs
+++ b/src/Hanalyze/Optim/Acquisition.hs
@@ -1,7 +1,11 @@
-{-# LANGUAGE StrictData #-}
-{-# LANGUAGE OverloadedStrings #-}
--- | Acquisition functions for Bayesian Optimization.
+-- |
+-- Module      : Hanalyze.Optim.Acquisition
+-- Description : ベイズ最適化の獲得関数 (単一目的 EI/UCB/PI, 多目的 EHVI/ParEGO)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
 --
+-- Acquisition functions for Bayesian Optimization.
+--
 -- Single-objective:
 --
 --   * EI  — Expected Improvement (Mockus 1978).
@@ -12,6 +16,8 @@
 --
 --   * EHVI   — Expected Hypervolume Improvement.
 --   * ParEGO — Tchebycheff scalarization + EI.
+{-# LANGUAGE StrictData #-}
+{-# LANGUAGE OverloadedStrings #-}
 module Hanalyze.Optim.Acquisition
   ( ei
   , ucb
diff --git a/src/Hanalyze/Optim/Adam.hs b/src/Hanalyze/Optim/Adam.hs
--- a/src/Hanalyze/Optim/Adam.hs
+++ b/src/Hanalyze/Optim/Adam.hs
@@ -1,6 +1,11 @@
-{-# LANGUAGE OverloadedStrings #-}
--- | Adam first-order optimizer (Kingma & Ba 2014).
+-- |
+-- Module      : Hanalyze.Optim.Adam
+-- Description : Adam 一次勾配法オプティマイザ (Kingma & Ba 2014)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
 --
+-- Adam first-order optimizer (Kingma & Ba 2014).
+--
 -- A general-purpose gradient-based optimizer used for ELBO maximization,
 -- neural-network training, acquisition-function optimization, and similar
 -- tasks. Originally embedded in @Hanalyze.Stat.VI@; extracted here as a shared
@@ -16,6 +21,7 @@
 --
 -- 'adamStep' 単体は 1 ステップだけ進める低レベル API で、`Hanalyze.Stat.VI` などが
 -- 内部で利用する。
+{-# LANGUAGE OverloadedStrings #-}
 module Hanalyze.Optim.Adam
   ( -- * 設定
     AdamConfig (..)
diff --git a/src/Hanalyze/Optim/BayesOpt.hs b/src/Hanalyze/Optim/BayesOpt.hs
--- a/src/Hanalyze/Optim/BayesOpt.hs
+++ b/src/Hanalyze/Optim/BayesOpt.hs
@@ -1,7 +1,11 @@
-{-# LANGUAGE StrictData #-}
-{-# LANGUAGE OverloadedStrings #-}
--- | Bayesian Optimization loop.
+-- |
+-- Module      : Hanalyze.Optim.BayesOpt
+-- Description : ベイズ最適化ループ (GP フィット + 獲得関数最大化)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
 --
+-- Bayesian Optimization loop.
+--
 -- Single-objective procedure:
 --
 --   1. Evaluate initial points (Latin hypercube or random).
@@ -9,10 +13,14 @@
 --   3. Maximize an acquisition function to choose the next @x@.
 --   4. Evaluate @x@ and append to the observed sequence.
 --   5. Repeat steps 2-4 for @T@ iterations.
+{-# LANGUAGE StrictData #-}
+{-# LANGUAGE OverloadedStrings #-}
 module Hanalyze.Optim.BayesOpt
   ( BayesOptConfig (..)
   , defaultBayesOptConfig
+  , BOIterEvent (..)
   , bayesOpt
+  , bayesOptWithCallback
   , bayesOptND
   , bayesOptScalarMO
   , bayesOptMOWithNSGA
@@ -29,6 +37,7 @@
 import System.Random.MWC (GenIO, uniform)
 
 import Hanalyze.Model.GP (Kernel (..), GPModel (..), GPResult (..), GPParams (..),
+                 gpKernelParams,
                  fitGP, optimizeGP, initParamsFromData,
                  GPResultMV (..), fitGPMV, optimizeGPMV,
                  logMarginalLikelihoodMV,
@@ -79,18 +88,39 @@
 --
 -- Returns @(observations, best)@: the full @(x, y)@ history and the best
 -- @(x*, y*)@.
+-- | Phase 21 で追加。 BO の各 iteration 末端で発火するイベント。
+data BOIterEvent = BOIterEvent
+  { boeIter        :: !Int               -- ^ 0-based iteration index
+  , boeProposedX   :: !Double             -- ^ acquisition が選んだ新点
+  , boeProposedY   :: !Double             -- ^ そこでの f 値
+  , boeCurrentBest :: !(Double, Double)   -- ^ (x*, y*) これまで
+  } deriving (Show)
+
 bayesOpt :: BayesOptConfig
          -> (Double -> IO Double)   -- ^ Objective (1D, minimized).
          -> (Double, Double)        -- ^ Search bounds.
          -> GenIO
          -> IO ([(Double, Double)], (Double, Double))
-bayesOpt cfg f (lo, hi) gen = do
+bayesOpt cfg f bounds gen =
+  bayesOptWithCallback cfg f bounds gen (\_ -> pure ())
+
+-- | Phase 21 で追加。 BO iteration ごとに 'BOIterEvent' を渡す callback 付き版。
+-- 既存 'bayesOpt' は no-op callback の wrapper として保持される。
+bayesOptWithCallback
+  :: BayesOptConfig
+  -> (Double -> IO Double)
+  -> (Double, Double)
+  -> GenIO
+  -> (BOIterEvent -> IO ())
+  -> IO ([(Double, Double)], (Double, Double))
+bayesOptWithCallback cfg f (lo, hi) gen onIter = do
   -- 初期点 (uniform random, 簡易)
   initX <- replicateM (boInitPoints cfg) (do
               u <- uniform gen :: IO Double
               return (lo + u * (hi - lo)))
   initY <- mapM f initX
   let history0 = zip initX initY
+      totalIter = boIterations cfg
 
   -- BO ループ
   -- 内側 acquisition 最大化は **Brent 法** (1D 単峰超線形収束)。
@@ -141,9 +171,19 @@
                 xNext = head (OC.orBest bRes)
 
             yNext <- f xNext
-            loop (t - 1) (hist ++ [(xNext, yNext)])
+            let newHist = hist ++ [(xNext, yNext)]
+                bestPair = head [pair | pair@(_, y) <- newHist
+                                      , y == minimum (map snd newHist)]
+                iterIdx = totalIter - t   -- 0-based
+            onIter BOIterEvent
+              { boeIter        = iterIdx
+              , boeProposedX   = xNext
+              , boeProposedY   = yNext
+              , boeCurrentBest = bestPair
+              }
+            loop (t - 1) newHist
 
-  finalHist <- loop (boIterations cfg) history0
+  finalHist <- loop totalIter history0
   let bestPair = head [pair | pair@(_, y) <- finalHist
                             , y == minimum (map snd finalHist)]
   return (finalHist, bestPair)
@@ -306,7 +346,7 @@
                   let xScl    = LA.fromList (scaleX xVec)
                       xRow    = LA.asRow xScl
                       kStarV  = LA.flatten
-                                 (buildKernelMatrixMV kern params xRow xMat)
+                                 (buildKernelMatrixMV kern (gpKernelParams params) xRow xMat)
                       mu      = LA.dot kStarV alpha
                       vstar   = LA.flatten
                                  (Chol.cholSolveWithFactor rChol
@@ -324,7 +364,7 @@
                   :: LA.Matrix Double  -- ^ Scaled X candidates (m × p)
                   -> (LA.Vector Double, LA.Vector Double)
                 predictBatchScaled xCand =
-                  let kStar = buildKernelMatrixMV kern params xCand xMat  -- m × n
+                  let kStar = buildKernelMatrixMV kern (gpKernelParams params) xCand xMat  -- m × n
                       mus   = kStar LA.#> alpha                            -- m
                       vMat  = Chol.cholSolveWithFactor rChol (LA.tr kStar) -- n × m
                       -- F1: diag(kStar · vMat) without forming m×m.
@@ -352,7 +392,7 @@
                       l       = gpLengthScale params
                       l2      = l * l
                       kStarV  = LA.flatten
-                                  (buildKernelMatrixMV kern params
+                                  (buildKernelMatrixMV kern (gpKernelParams params)
                                      (LA.asRow xScl) xMat)
                       factor  = case kern of
                                   RBF      ->
@@ -365,8 +405,8 @@
                                                 (sf `LA.scale`
                                                   (ef * (LA.cmap (1 +) r)))
                                     in c
-                                  Periodic ->
-                                    LA.konst 0 (LA.size kStarV)  -- numeric fallback
+                                  _ ->
+                                    LA.konst 0 (LA.size kStarV)  -- Periodic/Linear/Poly: numeric fallback
                       vstar   = LA.flatten
                                   (Chol.cholSolveWithFactor rChol
                                     (LA.asColumn kStarV))
@@ -454,8 +494,9 @@
                     jit   = (u - 0.5) * 0.05 * span_
                 pure (max lo (min hi (v + jit)))
             let useAnalytic = case kern of
-                                Periodic -> False
-                                _        -> True
+                                RBF      -> True
+                                Matern52 -> True
+                                _        -> False   -- Periodic/Linear/Poly は数値勾配
                 runMSG objFn gradFn = mapM (\x0 ->
                   LBFGS.runLBFGSWith
                     (LBFGS.defaultLBFGSConfig
diff --git a/src/Hanalyze/Optim/CMAES.hs b/src/Hanalyze/Optim/CMAES.hs
--- a/src/Hanalyze/Optim/CMAES.hs
+++ b/src/Hanalyze/Optim/CMAES.hs
@@ -1,6 +1,11 @@
-{-# LANGUAGE StrictData #-}
--- | CMA-ES (Covariance Matrix Adaptation Evolution Strategy) — Hansen 2001.
+-- |
+-- Module      : Hanalyze.Optim.CMAES
+-- Description : CMA-ES 簡易版 (対角共分散のみ) — 非凸連続最適化
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
 --
+-- CMA-ES (Covariance Matrix Adaptation Evolution Strategy) — Hansen 2001.
+--
 -- The de-facto state of the art for non-convex continuous optimization.
 -- This module implements a **simplified single-stage** version of the
 -- @(μ/μ_w, λ)@-rank-μ + rank-1 update.
@@ -15,6 +20,7 @@
 --   (no path cumulation). Sufficient for problems up to Rastrigin 5D.
 --
 -- For the full-rank tutorial CMA-ES (Hansen 2016), see 'Hanalyze.Optim.CMAESFull'.
+{-# LANGUAGE StrictData #-}
 module Hanalyze.Optim.CMAES
   ( CMAESConfig (..)
   , defaultCMAESConfig
diff --git a/src/Hanalyze/Optim/CMAESFull.hs b/src/Hanalyze/Optim/CMAESFull.hs
--- a/src/Hanalyze/Optim/CMAESFull.hs
+++ b/src/Hanalyze/Optim/CMAESFull.hs
@@ -1,6 +1,11 @@
-{-# LANGUAGE StrictData #-}
--- | Full-rank CMA-ES (Hansen 2016 tutorial, complete edition).
+-- |
+-- Module      : Hanalyze.Optim.CMAESFull
+-- Description : フルランク CMA-ES (Hansen 2016 チュートリアル準拠)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
 --
+-- Full-rank CMA-ES (Hansen 2016 tutorial, complete edition).
+--
 -- The companion module @Hanalyze.Optim.CMAES@ is a simplified diagonal variant.
 -- This module implements:
 --
@@ -13,6 +18,7 @@
 --   jumps.
 --
 -- Hyperparameters use the standard values from Hansen (2016).
+{-# LANGUAGE StrictData #-}
 module Hanalyze.Optim.CMAESFull
   ( CMAESFConfig (..)
   , defaultCMAESFConfig
diff --git a/src/Hanalyze/Optim/Common.hs b/src/Hanalyze/Optim/Common.hs
--- a/src/Hanalyze/Optim/Common.hs
+++ b/src/Hanalyze/Optim/Common.hs
@@ -1,6 +1,11 @@
-{-# LANGUAGE StrictData #-}
--- | Common foundation for the single-objective optimization algorithms.
+-- |
+-- Module      : Hanalyze.Optim.Common
+-- Description : 単一目的最適化アルゴリズム群が共有する基盤型・既定値
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
 --
+-- Common foundation for the single-objective optimization algorithms.
+--
 -- Provides the shared types and defaults used by every single-objective
 -- optimizer (@Hanalyze.Optim.NelderMead@, @Hanalyze.Optim.LBFGS@, @Hanalyze.Optim.LineSearch@,
 -- @Hanalyze.Optim.DifferentialEvolution@, @Hanalyze.Optim.CMAES@, @Hanalyze.Optim.CMAESFull@,
@@ -15,6 +20,7 @@
 --
 -- (Deterministic algorithms also return @IO@ for uniformity. A pure-only
 -- variant can be exported separately when needed.)
+{-# LANGUAGE StrictData #-}
 module Hanalyze.Optim.Common
   ( OptimResult (..)
   , StopCriteria (..)
diff --git a/src/Hanalyze/Optim/Constrained.hs b/src/Hanalyze/Optim/Constrained.hs
--- a/src/Hanalyze/Optim/Constrained.hs
+++ b/src/Hanalyze/Optim/Constrained.hs
@@ -1,4 +1,10 @@
--- | Constrained optimization via the **Augmented Lagrangian** method.
+-- |
+-- Module      : Hanalyze.Optim.Constrained
+-- Description : 拡張ラグランジュ法による制約付き最適化
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Constrained optimization via the **Augmented Lagrangian** method.
 --
 -- Internalizes equality constraints @g_i(x) = 0@ and inequality constraints
 -- @h_j(x) ≤ 0@ via Lagrange multipliers + a quadratic penalty, exposing an
diff --git a/src/Hanalyze/Optim/Desirability.hs b/src/Hanalyze/Optim/Desirability.hs
--- a/src/Hanalyze/Optim/Desirability.hs
+++ b/src/Hanalyze/Optim/Desirability.hs
@@ -1,6 +1,11 @@
-{-# LANGUAGE OverloadedStrings #-}
--- | Desirability functions (Derringer & Suich 1980).
+-- |
+-- Module      : Hanalyze.Optim.Desirability
+-- Description : Desirability 関数 (Derringer & Suich 1980) による多目的スカラー化
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
 --
+-- Desirability functions (Derringer & Suich 1980).
+--
 -- A classical scalarization for multi-objective optimization. Each response
 -- @y_j@ is mapped to a per-response desirability @d_j ∈ [0, 1]@, and the
 -- overall desirability is the geometric mean:
@@ -11,6 +16,7 @@
 --
 -- The @x@ that maximizes @D@ is a point that satisfies all responses
 -- reasonably well.
+{-# LANGUAGE OverloadedStrings #-}
 module Hanalyze.Optim.Desirability
   ( DesirabilityType (..)
   , individualDesirability
diff --git a/src/Hanalyze/Optim/DifferentialEvolution.hs b/src/Hanalyze/Optim/DifferentialEvolution.hs
--- a/src/Hanalyze/Optim/DifferentialEvolution.hs
+++ b/src/Hanalyze/Optim/DifferentialEvolution.hs
@@ -1,6 +1,11 @@
-{-# LANGUAGE StrictData #-}
--- | Differential Evolution (DE/rand/1/bin) — Storn & Price 1997.
+-- |
+-- Module      : Hanalyze.Optim.DifferentialEvolution
+-- Description : Differential Evolution (DE/rand/1/bin) — Storn & Price 1997
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
 --
+-- Differential Evolution (DE/rand/1/bin) — Storn & Price 1997.
+--
 -- A gradient-free, global, simple-to-implement and empirically robust
 -- evolutionary algorithm. Best suited to continuous non-convex problems,
 -- typically effective in the 5-30 dimensional regime.
@@ -17,6 +22,7 @@
 --
 -- Cost: @N@ function evaluations per generation (population size). Easily
 -- parallelizable, but this implementation is sequential.
+{-# LANGUAGE StrictData #-}
 module Hanalyze.Optim.DifferentialEvolution
   ( DEConfig (..)
   , DEStrategy (..)
diff --git a/src/Hanalyze/Optim/GradAscent.hs b/src/Hanalyze/Optim/GradAscent.hs
--- a/src/Hanalyze/Optim/GradAscent.hs
+++ b/src/Hanalyze/Optim/GradAscent.hs
@@ -1,6 +1,11 @@
-{-# LANGUAGE OverloadedStrings #-}
--- | Vanilla gradient ascent / descent.
+-- |
+-- Module      : Hanalyze.Optim.GradAscent
+-- Description : 素朴な勾配上昇 / 下降法
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
 --
+-- Vanilla gradient ascent / descent.
+--
 -- The numeric-gradient implementation that used to live in
 -- @Hanalyze.Model.GP.optimizeGP@, extracted as a shared foundation. The learning
 -- rate is shrunk by 0.5 % per iteration; iteration stops early when the
@@ -11,6 +16,7 @@
 --   * 'Hanalyze.Optim.Adam.runAdam' — momentum-based, robust, recommended default.
 -- - 'Hanalyze.Optim.GradAscent.gradientAscent' — シンプル、軽量、デバッグ容易
 -- - 'Hanalyze.Optim.GradAscent.gradientDescent' — 上の符号反転版
+{-# LANGUAGE OverloadedStrings #-}
 module Hanalyze.Optim.GradAscent
   ( GradConfig (..)
   , defaultGradConfig
diff --git a/src/Hanalyze/Optim/LBFGS.hs b/src/Hanalyze/Optim/LBFGS.hs
--- a/src/Hanalyze/Optim/LBFGS.hs
+++ b/src/Hanalyze/Optim/LBFGS.hs
@@ -1,6 +1,11 @@
-{-# LANGUAGE StrictData #-}
--- | L-BFGS (Limited-memory BFGS) quasi-Newton method.
+-- |
+-- Module      : Hanalyze.Optim.LBFGS
+-- Description : L-BFGS (限定記憶 BFGS) 準ニュートン法
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
 --
+-- L-BFGS (Limited-memory BFGS) quasi-Newton method.
+--
 -- Liu & Nocedal (1989). The standard for local optimization of large,
 -- smooth objectives — practical at hundreds to tens of thousands of
 -- dimensions (memory @O(mn)@ versus BFGS's @O(n²)@; @m = 10@ is typical).
@@ -16,12 +21,14 @@
 -- inner-loop arithmetic operation runs on @LA.Vector Double@ via BLAS.
 -- This eliminates the per-step Haskell list overhead that previously
 -- dominated the runtime (verified on the GLM bench in G2).
+{-# LANGUAGE StrictData #-}
 
 module Hanalyze.Optim.LBFGS
   ( LBFGSConfig (..)
   , defaultLBFGSConfig
   , runLBFGS
   , runLBFGSWith
+  , runLBFGSWithPure
   , runLBFGSNumeric
     -- * Vector-native variants (avoid list↔Vector conversion on every step)
   , runLBFGSWithV
@@ -70,7 +77,16 @@
              -> ([Double] -> [Double])      -- ^ Gradient @∇f@.
              -> [Double]                    -- ^ Initial point @x₀@.
              -> IO OptimResult
-runLBFGSWith cfg fUser gUser x0 =
+runLBFGSWith cfg fUser gUser x0 = pure (runLBFGSWithPure cfg fUser gUser x0)
+
+-- | 純粋版 ('runLBFGSWith' は本体が完全に純粋 = @let … in pure result@ ゆえ IO は不要)。
+-- 乱数を使わない決定的最適化なので、 純粋に閉じられる ('fitSVMPure' 等が利用)。
+runLBFGSWithPure :: LBFGSConfig
+                 -> ([Double] -> Double)
+                 -> ([Double] -> [Double])
+                 -> [Double]
+                 -> OptimResult
+runLBFGSWithPure cfg fUser gUser x0 =
   let mbs          = lbBounds cfg
       sign         = case lbDir cfg of { Minimize -> 1; Maximize -> -1 :: Double }
       -- The internal objective and gradient operate on LA.Vector Double.
@@ -103,7 +119,7 @@
       histUser = case lbDir cfg of
                    Minimize -> reverse hist
                    Maximize -> map negate (reverse hist)
-  in pure $ OptimResult
+  in OptimResult
        { orBest      = LA.toList xEndV
        , orValue     = vUser
        , orHistory   = histUser
@@ -206,7 +222,15 @@
   | gnorm < stTolFun (lbStop cfg)  = (x, fx, hist, iter, True)
   | otherwise =
       let d = twoLoop ss ys gx
-          (xN, fN, alpha) = lineSearch cfg f x fx gx d
+          -- 初回反復 (曲率履歴なし) は方向が未スケールの最急降下 (‖d‖=‖g‖)。
+          -- 勾配が大きい問題で α=1 の第1歩を打つと巨大にオーバーシュートし、
+          -- 平坦な退化解に嵌って勾配消失で誤収束する (GP 周辺尤度で実測:
+          -- ℓ が真の峰 105 を越えて 1e12 に飛ぶ)。Nocedal & Wright §3.5 に従い
+          -- 初回のみ α₀ = min(1, 1/‖g‖₁) に抑える (2 回目以降は quasi-Newton
+          -- 方向が自己スケールするので α=1 が適切)。
+          alpha0 | null ss   = min 1 (1 / max 1e-16 (LA.norm_1 gx))
+                 | otherwise = 1
+          (xN, fN, alpha) = lineSearch cfg f x fx gx d alpha0
       in if alpha < 1e-16
            then (x, fx, hist, iter, True)
            else
@@ -252,13 +276,15 @@
       r        = foldl step2 r0 triplesAlphas
   in LA.scale (-1) r
 
--- | backtracking + Armijo 条件 @f(x + αd) ≤ f(x) + c1 α gᵀd@.
+-- | backtracking + Armijo 条件 @f(x + αd) ≤ f(x) + c1 α gᵀd@。
+-- @alpha0@ = 初期ステップ幅 (通常 1.0、初回最急降下では 1/‖g‖₁ 等で抑える)。
 lineSearch :: LBFGSConfig
            -> (LA.Vector Double -> Double)
            -> LA.Vector Double -> Double
            -> LA.Vector Double -> LA.Vector Double
+           -> Double                                  -- ^ 初期ステップ幅 α₀
            -> (LA.Vector Double, Double, Double)
-lineSearch cfg f x fx g d =
+lineSearch cfg f x fx g d alpha0 =
   let gtd = LA.dot g d
       go alpha k
         | k >= lbLSMax cfg = (xCand, f xCand, alpha)
@@ -268,4 +294,4 @@
           xCand  = x + LA.scale alpha d
           fxCand = f xCand
           armijo = fxCand <= fx + lbLSC1 cfg * alpha * gtd
-  in go 1.0 0
+  in go alpha0 0
diff --git a/src/Hanalyze/Optim/LineSearch.hs b/src/Hanalyze/Optim/LineSearch.hs
--- a/src/Hanalyze/Optim/LineSearch.hs
+++ b/src/Hanalyze/Optim/LineSearch.hs
@@ -1,6 +1,11 @@
-{-# LANGUAGE StrictData #-}
--- | One-dimensional optimization: Brent's method + golden-section search.
+-- |
+-- Module      : Hanalyze.Optim.LineSearch
+-- Description : 1 次元最適化 (Brent 法・黄金分割探索)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
 --
+-- One-dimensional optimization: Brent's method + golden-section search.
+--
 -- Both find a local minimum on a unimodal interval @[a, b]@ to high
 -- precision.
 --
@@ -13,6 +18,7 @@
 -- Both are gradient-free. They need an initial bracket
 -- @a < x < b@ with @f(x) < f(a), f(b)@; use 'bracketMinimum' to find one
 -- automatically.
+{-# LANGUAGE StrictData #-}
 module Hanalyze.Optim.LineSearch
   ( BrentConfig (..)
   , defaultBrentConfig
diff --git a/src/Hanalyze/Optim/NSGA.hs b/src/Hanalyze/Optim/NSGA.hs
--- a/src/Hanalyze/Optim/NSGA.hs
+++ b/src/Hanalyze/Optim/NSGA.hs
@@ -1,7 +1,11 @@
-{-# LANGUAGE StrictData #-}
-{-# LANGUAGE OverloadedStrings #-}
--- | NSGA-II (Non-dominated Sorting Genetic Algorithm II) — Deb et al. 2002.
+-- |
+-- Module      : Hanalyze.Optim.NSGA
+-- Description : NSGA-II (非優越ソート多目的遺伝的アルゴリズム) — Deb et al. 2002
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
 --
+-- NSGA-II (Non-dominated Sorting Genetic Algorithm II) — Deb et al. 2002.
+--
 -- A widely-used multi-objective evolutionary algorithm based on fast
 -- non-dominated sorting + crowding-distance comparison.
 --
@@ -17,6 +21,8 @@
 --    e) Take the top N to form P_{t+1}.
 -- 3. Return the final front as a Pareto approximation.
 -- @
+{-# LANGUAGE StrictData #-}
+{-# LANGUAGE OverloadedStrings #-}
 module Hanalyze.Optim.NSGA
   ( -- * 型
     Bounds
@@ -26,6 +32,11 @@
     -- * High-level API
   , nsga2
   , nsga2WithConstraints
+  , nsga2AllFronts
+  , nsga2AllFrontsWithConstraints
+  , nsga2WithProgress
+  , nsga2WithProgressAndConstraints
+  , NSGAProgress (..)
   , evaluateSolution
     -- * Building blocks
   , dominates
@@ -272,6 +283,65 @@
   -> GenIO
   -> IO [Solution]
 nsga2WithConstraints cfg f cFn bounds gen = do
+  finalPop <- runNSGAFinalPopulation cfg f cFn bounds gen
+  -- 最終世代の最初の front (Pareto 近似) を返す
+  case nonDominatedSort finalPop of
+    (front : _) -> return front
+    []          -> return []
+
+-- | NSGA-II all-fronts variant: 最終世代の population を非優越ソートして
+-- **全 front を rank 別に**返す。 @front i@ が @rank i@ (0-origin) に対応:
+-- rank 0 = Pareto 近似、 rank 1 = それに dominate される第 2 集団、 …
+--
+-- フロントエンド app frontend で「最適解 (rank 0) の周辺の代替案 (rank 1, 2)」 を
+-- 一覧する UI を実装するために用意。
+--
+-- 既存 'nsga2' との関係: @nsga2 ≈ head <$> nsga2AllFronts@ (空 population なら
+-- empty list)。 内部 helper 'runNSGAFinalPopulation' を共有しているため、
+-- 既存 API の挙動は不変。
+nsga2AllFronts
+  :: NSGAConfig
+  -> ([Double] -> [Double])
+  -> Bounds
+  -> GenIO
+  -> IO [[Solution]]
+nsga2AllFronts cfg f bounds gen =
+  nsga2AllFrontsWithConstraints cfg f (const 0) bounds gen
+
+-- | Constrained 版 'nsga2AllFronts'。
+nsga2AllFrontsWithConstraints
+  :: NSGAConfig
+  -> ([Double] -> [Double])
+  -> ([Double] -> Double)
+  -> Bounds
+  -> GenIO
+  -> IO [[Solution]]
+nsga2AllFrontsWithConstraints cfg f cFn bounds gen = do
+  finalPop <- runNSGAFinalPopulation cfg f cFn bounds gen
+  return (nonDominatedSort finalPop)
+
+-- | 内部 helper: 最終世代の population (未ソート) を返す。 'nsga2WithConstraints'
+-- と 'nsga2AllFrontsWithConstraints' で共有する。 callback 無し版。
+runNSGAFinalPopulation
+  :: NSGAConfig
+  -> ([Double] -> [Double])
+  -> ([Double] -> Double)
+  -> Bounds
+  -> GenIO
+  -> IO [Solution]
+runNSGAFinalPopulation cfg f cFn bounds gen =
+  runNSGAFinalPopulationCb cfg f cFn bounds (\_ -> pure ()) gen
+
+-- | 内部 helper: 'runNSGAFinalPopulation' の callback 付き版。
+runNSGAFinalPopulationCb
+  :: NSGAConfig
+  -> ([Double] -> [Double])
+  -> ([Double] -> Double)
+  -> Bounds
+  -> (NSGAProgress -> IO ())  -- 各世代終端で呼ぶ progress callback
+  -> GenIO
+  -> IO [Solution]
+runNSGAFinalPopulationCb cfg f cFn bounds onProg gen = do
   let n  = nsgaPopSize cfg
       d  = length bounds
       pM = case nsgaMutationP cfg of
@@ -280,69 +350,114 @@
       etaC = nsgaEtaCross cfg
       etaM = nsgaEtaMut cfg
       pC   = nsgaCrossoverP cfg
+      tot  = nsgaGenerations cfg
 
   -- 初期母集団: Latin-Hypercube Sampling で各次元のセルを 1 度ずつ
   -- 埋める (iid uniform より初期世代の被覆良 → 第 1 世代で既に
   -- 全域の情報が手に入るため、世代あたりの収束が上がる)。
   initXs <- QR.lhsSamplesIn n bounds gen
   let initPop = [ evaluateSolution f cFn x | x <- initXs ]
-
-  -- 世代ループ
-  finalPop <- generationLoop (nsgaGenerations cfg) initPop pC etaC etaM pM bounds f cFn gen
-
-  -- 最終世代の最初の front (Pareto 近似) を返す
-  case nonDominatedSort finalPop of
-    (front : _) -> return front
-    []          -> return []
+  -- 世代ループ (callback 付き)
+  generationLoopCb tot tot initPop pC etaC etaM pM bounds f cFn onProg gen
 
--- | Build a 'Solution' from a decision vector by evaluating both the
--- objective and the constraint function.
-evaluateSolution :: ([Double] -> [Double])
-                 -> ([Double] -> Double)
-                 -> [Double]
-                 -> Solution
-evaluateSolution f cFn x =
-  Solution { solDecision   = x
-           , solObjectives = f x
-           , solViolation  = cFn x
-           }
+-- | NSGA-II 1 世代ステップの進捗。 'nsga2WithProgress' / 'nsga2WithProgressAndConstraints'
+-- の callback 引数で渡される。
+data NSGAProgress = NSGAProgress
+  { ngpGeneration :: !Int       -- ^ 0-origin の現世代番号 (@[0 .. ngpTotal - 1]@ の範囲)
+  , ngpTotal      :: !Int       -- ^ 総世代数 ('NSGAConfig.nsgaGenerations')
+  , ngpParetoSize :: !Int       -- ^ 現 rank-0 (Pareto 近似) のサイズ
+  , ngpBestObjs   :: ![Double]  -- ^ 現 rank-0 中で各目的の最小値
+  } deriving (Show, Eq)
 
--- | 1 世代の進化ステップを T 回反復。
-generationLoop
-  :: Int -> [Solution]
-  -> Double -> Double -> Double -> Double  -- pC, etaC, etaM, pM
+-- | 'generationLoop' の callback 付き版。
+--   各世代の **終端** で 'NSGAProgress' を構築して @onProg@ を呼ぶ。
+generationLoopCb
+  :: Int                              -- ^ 残り iteration t (countdown)
+  -> Int                              -- ^ 総 iteration T (callback の ngpTotal 用)
+  -> [Solution]
+  -> Double -> Double -> Double -> Double
   -> Bounds
   -> ([Double] -> [Double])
   -> ([Double] -> Double)
+  -> (NSGAProgress -> IO ())
   -> GenIO
   -> IO [Solution]
-generationLoop 0 pop _ _ _ _ _ _ _ _ = return pop
-generationLoop t pop pC etaC etaM pM bounds f cFn gen = do
+generationLoopCb 0 _ pop _ _ _ _ _ _ _ _ _ = return pop
+generationLoopCb t tot pop pC etaC etaM pM bounds f cFn onProg gen = do
   let n = length pop
-
-  -- ── ranked + crowding 情報を計算 ──
-  let fronts = nonDominatedSort pop
+      fronts = nonDominatedSort pop
       sortedFronts = map crowdingDistance fronts
       ranked = concat
         [ zip3 (repeat r) (frontDistances fr) fr
         | (r, fr) <- zip [0 :: Int ..] sortedFronts ]
-
-  -- ── 子母集団 Q を生成 (重複除去 retry 付き、pymoo 互換) ──
-  --
-  -- 'fillOffspring' は新規 child を 1 ペアずつ生成、現プール (pop +
-  -- 既存 offspring) と L∞ 距離が dupEpsilon 以下のものを破棄、必要数
-  -- (n) に達するまで最大 dupMaxRetries 回まで retry する。
-  -- pymoo の DefaultDuplicateElimination + Mating の retry ループと
-  -- 同等の挙動。重複が抑えられる分、有効 popSize が縮まずに済み、
-  -- 100 gen 単一 RNG でも安定して pymoo を上回る。
   children <- fillOffspring n pop pC etaC etaM pM bounds f cFn ranked gen
-
-  -- ── R = P ∪ Q から上位 N を選別 ──
   let combined = pop ++ children
       combinedFronts = nonDominatedSort combined
       newPop = selectTopN n combinedFronts
+      -- progress 構築: 次世代 newPop の rank-0 で報告
+      newFronts = nonDominatedSort newPop
+      pareto0   = case newFronts of { (fr:_) -> fr; [] -> [] }
+      paretoSize = length pareto0
+      bestObjs  =
+        case pareto0 of
+          [] -> []
+          _  ->
+            let m = length (solObjectives (head pareto0))
+            in [ minimum [ solObjectives s !! j | s <- pareto0 ]
+               | j <- [0 .. m - 1] ]
+      curGen = tot - t              -- 0-origin
+      progress = NSGAProgress
+        { ngpGeneration = curGen
+        , ngpTotal      = tot
+        , ngpParetoSize = paretoSize
+        , ngpBestObjs   = bestObjs
+        }
+  onProg progress
+  generationLoopCb (t - 1) tot newPop pC etaC etaM pM bounds f cFn onProg gen
 
-  generationLoop (t - 1) newPop pC etaC etaM pM bounds f cFn gen
+-- | NSGA-II with per-generation progress callback (unconstrained)。
+-- 各世代の終端で 'NSGAProgress' が @onProg@ に渡される。
+-- 戻り値は 'nsga2' と同じく rank-0 (Pareto 近似) のみ。
+-- 全 rank が欲しい場合は 'nsga2AllFronts' を別途呼ぶ。
+--
+-- 想定用途: フロントエンド app backend が WebSocket / SSE で生存中世代の
+-- progress を frontend に流す。
+nsga2WithProgress
+  :: NSGAConfig
+  -> ([Double] -> [Double])
+  -> Bounds
+  -> (NSGAProgress -> IO ())
+  -> GenIO
+  -> IO [Solution]
+nsga2WithProgress cfg f bounds onProg gen =
+  nsga2WithProgressAndConstraints cfg f (const 0) bounds onProg gen
+
+-- | NSGA-II with per-generation progress callback (constrained)。
+nsga2WithProgressAndConstraints
+  :: NSGAConfig
+  -> ([Double] -> [Double])
+  -> ([Double] -> Double)
+  -> Bounds
+  -> (NSGAProgress -> IO ())
+  -> GenIO
+  -> IO [Solution]
+nsga2WithProgressAndConstraints cfg f cFn bounds onProg gen = do
+  finalPop <- runNSGAFinalPopulationCb cfg f cFn bounds onProg gen
+  case nonDominatedSort finalPop of
+    (front : _) -> return front
+    []          -> return []
+
+-- | Build a 'Solution' from a decision vector by evaluating both the
+-- objective and the constraint function.
+evaluateSolution :: ([Double] -> [Double])
+                 -> ([Double] -> Double)
+                 -> [Double]
+                 -> Solution
+evaluateSolution f cFn x =
+  Solution { solDecision   = x
+           , solObjectives = f x
+           , solViolation  = cFn x
+           }
 
 -- | Duplicate-detection threshold (L∞).
 dupEpsilon :: Double
diff --git a/src/Hanalyze/Optim/NelderMead.hs b/src/Hanalyze/Optim/NelderMead.hs
--- a/src/Hanalyze/Optim/NelderMead.hs
+++ b/src/Hanalyze/Optim/NelderMead.hs
@@ -1,6 +1,11 @@
-{-# LANGUAGE StrictData #-}
--- | Nelder-Mead simplex method (downhill simplex).
+-- |
+-- Module      : Hanalyze.Optim.NelderMead
+-- Description : Nelder-Mead シンプレックス法
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
 --
+-- Nelder-Mead simplex method (downhill simplex).
+--
 -- Nelder & Mead (1965). Gradient-free, easy to implement at low dimension
 -- (1-30), and stable for local optimization. The default behind R's
 -- @optim(method="Nelder-Mead")@.
@@ -12,6 +17,7 @@
 --
 -- Cost: 1-2 function evaluations per iteration (@n@ on shrink). Convergence
 -- becomes slow for larger @n@ — practical up to @n ≤ 10@.
+{-# LANGUAGE StrictData #-}
 module Hanalyze.Optim.NelderMead
   ( NMConfig (..)
   , defaultNMConfig
diff --git a/src/Hanalyze/Optim/Numeric.hs b/src/Hanalyze/Optim/Numeric.hs
--- a/src/Hanalyze/Optim/Numeric.hs
+++ b/src/Hanalyze/Optim/Numeric.hs
@@ -1,6 +1,11 @@
-{-# LANGUAGE OverloadedStrings #-}
--- | Numeric gradients (finite differences).
+-- |
+-- Module      : Hanalyze.Optim.Numeric
+-- Description : 数値勾配 (有限差分法)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
 --
+-- Numeric gradients (finite differences).
+--
 -- For situations where automatic differentiation is impractical (e.g. GP
 -- log-marginal likelihood whose @det@ is computed inside hmatrix and would
 -- be cumbersome to AD-ify).
@@ -8,6 +13,7 @@
 --   * 'numGradCentral' — central differences (error @O(h²)@; recommended).
 --   * 'numGradForward' — forward differences (error @O(h)@; half the cost).
 --   * 'numHessianCentral' — Hessian approximation via central differences.
+{-# LANGUAGE OverloadedStrings #-}
 module Hanalyze.Optim.Numeric
   ( numGradCentral
   , numGradForward
diff --git a/src/Hanalyze/Optim/Pareto.hs b/src/Hanalyze/Optim/Pareto.hs
--- a/src/Hanalyze/Optim/Pareto.hs
+++ b/src/Hanalyze/Optim/Pareto.hs
@@ -1,7 +1,11 @@
-{-# LANGUAGE StrictData #-}
-{-# LANGUAGE OverloadedStrings #-}
--- | Pareto-front utilities for evaluating multi-objective results.
+-- |
+-- Module      : Hanalyze.Optim.Pareto
+-- Description : 多目的最適化結果評価のための Pareto フロント関連ユーティリティ
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
 --
+-- Pareto-front utilities for evaluating multi-objective results.
+--
 --   * 'isNonDominated' — is a given point non-dominated within the front?
 --   * 'paretoFront'    — extract just the non-dominated points from a set.
 --   * 'hypervolume'    — front volume indicator (larger is better).
@@ -12,6 +16,8 @@
 --
 -- All objectives are treated as **minimized**, matching the NSGA-II
 -- convention.
+{-# LANGUAGE StrictData #-}
+{-# LANGUAGE OverloadedStrings #-}
 module Hanalyze.Optim.Pareto
   ( isNonDominated
   , paretoFront
diff --git a/src/Hanalyze/Optim/ParticleSwarm.hs b/src/Hanalyze/Optim/ParticleSwarm.hs
--- a/src/Hanalyze/Optim/ParticleSwarm.hs
+++ b/src/Hanalyze/Optim/ParticleSwarm.hs
@@ -1,6 +1,11 @@
-{-# LANGUAGE StrictData #-}
--- | Particle Swarm Optimization (PSO).
+-- |
+-- Module      : Hanalyze.Optim.ParticleSwarm
+-- Description : Particle Swarm Optimization (PSO) — Kennedy & Eberhart 1995
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
 --
+-- Particle Swarm Optimization (PSO).
+--
 -- Kennedy & Eberhart (1995). A metaheuristic in which a swarm of particles
 -- updates velocity by being attracted to its personal best (pbest) and the
 -- global best (gbest).
@@ -14,6 +19,7 @@
 --
 -- Here @w@ is inertia, @c_1@ the cognitive coefficient, @c_2@ the social
 -- coefficient, and @r_1, r_2 ~ U(0, 1)@.
+{-# LANGUAGE StrictData #-}
 module Hanalyze.Optim.ParticleSwarm
   ( PSOConfig (..)
   , defaultPSOConfig
diff --git a/src/Hanalyze/Optim/SimulatedAnnealing.hs b/src/Hanalyze/Optim/SimulatedAnnealing.hs
--- a/src/Hanalyze/Optim/SimulatedAnnealing.hs
+++ b/src/Hanalyze/Optim/SimulatedAnnealing.hs
@@ -1,6 +1,11 @@
-{-# LANGUAGE StrictData #-}
--- | Simulated Annealing.
+-- |
+-- Module      : Hanalyze.Optim.SimulatedAnnealing
+-- Description : Simulated Annealing (焼きなまし法) — Kirkpatrick, Gelatt, Vecchi 1983
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
 --
+-- Simulated Annealing.
+--
 -- Kirkpatrick, Gelatt, Vecchi (1983). A physical analogy (cooling solids):
 -- a random walk with probabilistic acceptance approaches a global
 -- optimum.
@@ -15,6 +20,7 @@
 --
 -- Proposal: add @Normal(0, sigma)@ independently per dimension and reflect
 -- against the bounds.
+{-# LANGUAGE StrictData #-}
 module Hanalyze.Optim.SimulatedAnnealing
   ( SAConfig (..)
   , SACoolingSchedule (..)
diff --git a/src/Hanalyze/Plot.hs b/src/Hanalyze/Plot.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Plot.hs
@@ -0,0 +1,1050 @@
+{-# LANGUAGE OverloadedStrings #-} 
+{-# LANGUAGE RankNTypes #-}
+{-# LANGUAGE ImpredicativeTypes #-}
+{-# LANGUAGE TypeFamilies #-}
+{-# LANGUAGE FlexibleContexts #-}
+{-# LANGUAGE UndecidableInstances #-}
+-- |
+-- Module      : Hanalyze.Plot
+-- Description : 解析モデルを hgg の VisualSpec へ変換する連携層 (flag plot-integration)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- hgg 連携層 (= 解析モデル → 図 'VisualSpec')。
+--
+-- ⚠ 本モジュールは cabal flag @plot-integration@ (既定 off) を on にしたときのみ
+-- build される。 @hgg-core@ に依存するため **upstream hanalyze には
+-- cherry-pick しない** (= 依存方向 analyze→plot-core を flag で隔離。 plot Phase 15
+-- / analyze Phase 46 の設計)。 中立 protocol ('Hanalyze.Model.Core' の
+-- 'ResidualModel' / 'PredictiveModel') は portable、 こちらは非 portable。
+--
+-- 系統 A (モデル・アウト型): フィット済みモデルを 'toPlot' で 'VisualSpec' 化し、
+-- hgg の layer 文法に @df |>> (layer scatter <> toPlot fit)@ で重畳する
+-- ('VisualSpec' は Monoid なので新コンビネータ不要)。
+module Hanalyze.Plot
+  ( Plottable (..)
+    -- * ルート1 grid 評価 (滑らかな回帰曲線・CI 帯) — Phase 16 §3 C1
+  , ModelSpec
+  , SingleVarModel (..)
+  , GridOpts (..)
+  , statModel
+  , grid
+  , gridRange
+  , BandMode (..)
+  , bandMode
+    -- 予測区間の算出法セレクタ (closed-form / bootstrap — Phase 70.H)
+  , PIMethod (..)
+  , piMethod
+  , statColor
+  , statFill
+  , statLinetype
+  , LineType (..)
+  , statLinewidth
+  , statAlpha
+  , statLabel
+  , statEquation
+  , statR2
+  , statLevel
+  , predAt
+    -- * 多変量 effect plot — Phase 16 §3 C3
+  , MultiVarModel (..)
+  , AlongSpec
+  , along
+  , statModelMulti
+  , HoldAgg (..)
+  , holdAt
+  , byVar
+  , MultiLMModel (..)
+  , multiLMModel
+  , multiLMModelF
+  , MultiGLMModel (..)
+  , multiGLMModel
+  , multiGLMModelF
+    -- 多変量ロバスト回帰 (formula 不要・列名リスト — Phase 70.D)
+  , MultiRobustModel (..)
+  , multiRobustModelF
+  , additiveFormula
+    -- PLS effect plot (frame 保持ラッパ + 出力セレクタ — Phase 70.B2/B3)
+  , PLSModel (..)
+  , plsModel
+  , selectOutput
+    -- * 応答曲面 3D 直結 — plot Phase 24 A3
+  , SurfaceOpts (..)
+  , defaultSurfaceOpts
+  , surfaceGrid
+  , surfaceOf
+  , surfaceOfWith
+  , dataScatter3DOf
+  , epredSurfaceOf
+  , epredSurfaceOfWith
+    -- * モデル API 層 (描画と独立: predict / describe / coefficients)
+  , ModelAPI (..)
+  , Coef (..)
+    -- * 統一係数サマリ (t/z・p 値・95% CI — Phase 70.D)
+  , CoefRow (..)
+  , HasCoefSummary (..)
+  , HasCoefBoot (..)
+  , coefSummaryBoot
+    -- * 平滑項単位の近似有意性 (mgcv 流 edf + 近似 F — Phase 72.2)
+  , TermRow (..)
+  , HasTermSummary (..)
+  , termSummary
+    -- * 統一玄関 (.summary() 風 — Phase 72.3)
+  , ModelReport (..)
+  , HasReport (..)
+  , modelReport
+  , showReport
+    -- * 回帰診断の可視化 (係数 forest / 実測vs予測 — Phase 72.4/72.5)
+  , HasObsPred (..)
+  , obsVsPred
+  , obsPredSpec
+  , coefForest
+    -- * 線形モデル (描画可能 = X 同梱)
+  , LMModel (..)
+  , lmModel
+    -- * 一般化線形モデル (描画可能 = X + family/link 同梱)
+  , GLMModel (..)
+  , glmModel
+    -- * ガウス過程 (描画可能 = 予測 grid 同梱の 'GPResult' をそのまま)
+  , GPResult (..)
+    -- * カーネル法ファミリ統合 (GP / KRR / RFF・df |-> gp) — Phase 70.5 項目 E
+  , Kernel (..)
+  , GPParams (..)
+  , defaultGPParams
+  , GPMethod (..)
+  , HyperStrategy (..)
+  , GPConfig (..)
+  , defaultGP
+  , GPSpec
+  , gp
+  , GPRegModel (..)
+  , GPMultiSpec
+  , gpMulti
+  , GPRegModelN (..)
+    -- * 罰則付き回帰 統合 (Ridge/Lasso/EN/MCP/SCAD/Adaptive/Group・df |-> regularized) — Phase 70.7 項目 G
+  , RegMethod (..)
+  , LambdaStrat (..)
+  , RegConfig (..)
+  , defaultRidge
+  , defaultLasso
+  , RegSpec
+  , regularized
+  , regularizedMulti
+  , ridge
+  , ridgeMulti
+  , lasso
+  , lassoMulti
+  , elasticNet
+  , elasticNetMulti
+  , RegModel (..)
+  , regPredict
+    -- * スプライン回帰 (描画可能 = X 同梱、 平滑曲線 + CI band)
+  , SplineModel (..)
+  , splineModel
+    -- * 一般化加法モデル (描画可能 = X 同梱、 平滑曲線のみ・band 非提供)
+  , GAMModel (..)
+  , gamModel
+    -- ** GAM 基底一般化 + GCV (Phase 70.6 F3・df|-> 高レベル)
+  , GAMBasis (..)
+  , GAMLambda (..)
+  , GAMConfig (..)
+  , defaultGAMConfig
+  , GAMSpec (..)
+  , gam
+  , gamMulti
+  , GAMModelN (..)
+  , fitGAMWith
+    -- * ロバスト回帰 (描画可能 = X 同梱、 ロバスト直線・重み diagnostic)
+  , RobustModel (..)
+  , robustModel
+    -- * 多出力線形回帰 (描画可能 = 自己完結の 'MultiFit'、 残差相関 heatmap)
+  , MultiFit (..)
+    -- * 分位点回帰 (描画可能 = X 同梱、 複数分位線を色分け重畳)
+  , QuantileModel (..)
+  , quantileModel
+    -- * MCMC チェーン (描画可能 = trace + 周辺事後密度、 ベイズ出入口)
+  , ChainModel (..)
+  , chainModel
+    -- * 生存解析 (描画可能 = 自己完結、 KM 生存曲線 / 競合リスク CIF)
+  , KMResult (..)
+  , CRFit (..)
+    -- * 時系列予測 (描画可能 = 履歴 + AR 予測 + 予測区間 band)
+  , ForecastModel (..)
+  , forecastModel
+    -- * 多変量・木 (描画可能 = 自己完結、 PCA scree / RF 重要度)
+  , PCAResult (..)
+  , RandomForest (..)
+    -- * 木/アンサンブル — Phase 68 A2 (重要度 bar / 決定木 樹形図)
+    --   GradientBoosting / RandomForestClassifier = 特徴重要度 bar、
+    --   DecisionTree = MDAG 再利用の樹形図 (新規 mark 不要)
+  , GBRegressor (..)
+  , GBClassifier (..)
+  , RFClassifierFit (..)
+  , DTree (..)
+  , DTFit (..)
+  , treeImportances
+  , treePlot
+  , treePlotRaw
+    -- * 分類 — Phase 68 A3 (決定境界 + confusion + 代表散布)
+    --   Discriminant / NaiveBayes / KNN。 決定境界・confusion はヘルパ (要範囲/データ)、
+    --   toPlot は KNN=訓練点散布 / Discriminant・NB=クラス平均散布
+  , ClassPredict (..)
+  , decisionBoundaryOf
+  , confusionOf
+  , MDSView
+  , mdsView
+  , mdsGroupBy
+  , nnLossOf
+  , ResidualMode (..)
+  , ProfilerSpec (..)
+  , profiler
+  , profilerResidual
+  , contourOf
+    -- DOE ワークフロー (Phase 78・Hanalyze.Fit 由来)
+  , Design (..)
+  , DesignFactor (..)
+  , FactorKind (..)
+  , FactorScale (..)
+  , DesignKind (..)
+  , contFactor
+  , contFactorLog
+  , numFactor
+  , catFactor
+  , CustomSpec (..)
+  , customSpec
+  , customDesign
+  , Structure (..)
+  , splitPlot
+  , stripPlot
+  , blocked
+  , Constraint (..)
+  , ConstraintRel (..)
+  , ConstraintGuard (..)
+  , FactorValue (..)
+  , NatConstraint (..)
+  , natLeq
+  , natGeq
+  , natEq
+  , natForbid
+  , formulaToCustomModel
+  , factorialDesign
+  , centralCompositeDesign
+  , boxBehnkenDesign
+  , Resolution (..)
+  , resNum
+  , fractionalDesign
+  , fractionalDesignGen
+  , fractionalDesignInter
+  , fractionalDesignGenInter
+  , fractionalCatalog
+  , fracResolution
+  , aliasStructure
+  , OATable (..)
+  , taguchiDesign
+  , taguchiDesignOA
+  , OptCriterion (..)
+  , optimalDesign
+  , optimalDesignWith
+  , optimalDesignLevels
+  , mainEffects
+  , twoWay
+  , quadratic
+  , designTable
+  , designFrame
+  , designFrameRound
+  , designFactorNames
+  , designFormula
+  , RSMNature (..)
+  , RSMReport (..)
+  , rsmAnalysis
+  , steepestAscentNatural
+  , saveDesign
+  , planFromFrame
+  , DesignModelSpec (..)
+  , designModel
+  , DesignModelGPSpec (..)
+  , designModelGP
+  , ranIntercept
+  , ranSlope
+  , DesignHBMFit (..)
+  , designModelHBM
+  , MultiOutputSpec (..)
+  , multiOutput
+  , modelFor
+  , svmSupportVectorsOf
+  , ScorePredict (..)
+  , decisionLineOf
+    -- 部分従属図 (PDP / ICE) — Phase 75.27
+  , RegPredict (..)
+  , PDPView
+  , pdp
+  , pdpIce
+  , pdpOf
+  , pdpIceOf
+  , pdpPlot
+  , pdpIcePlot
+  , partialDependencePlot
+  , partialDependenceIcePlot
+  , DiscriminantFit (..)
+  , NBModel (..)
+  , GaussianNB (..)
+  , KNNClassifier (..)
+    -- * 次元圧縮 — Phase 68 A4 (PLS score/loading/VIP, MultiGP 多出力 curve)
+  , PLSFit (..)
+    -- ** PLS 診断ビュー (中間 Plottable Spec・HBM 式統一 — Phase 70.B)
+  , PLSView (..)
+  , PLSViewKind (..)
+  , scoreView
+  , loadingView
+  , vipView
+  , MultiGPResult (..)
+  , multiGpCurves
+    -- * 時系列・生存・FDA — Phase 68 A5
+    --   GARCH=volatility 帯付き線 / AFT=生存曲線 / FDA=平均+固有関数 / β(t)
+  , GARCHFit (..)
+  , garchVolatility
+  , AFTFit (..)
+  , aftSurvivalAt
+  , FunctionalPCA (..)
+  , FLMResult (..)
+    -- * 罰則回帰・因果探索 — Phase 68 A6
+    --   Regularized=係数 bar/係数パス / LiNGAM=因果 DAG (MDAG 再利用)
+  , RegFit (..)
+  , regPathPlot
+  , DirectLiNGAMFit (..)
+  , lingamDag
+    -- * 記述統計・検定 — Phase 68 A7 (describe 分布図 / 検定 effect-CI forest)
+  , TestResult (..)
+  , testForest
+  , testForestLabeled
+  , describeBox
+    -- * クラスタリング (Phase 68 A1) — KMeans の図
+    --   'Plottable' 'KMeansResult' (toPlot = centroid 散布) + データ点ヘルパ
+  , clusterScatterOf
+  , centroidsOf
+  , clusterHullOf
+  , clusterEllipseOf
+  , DendroOpts (..)
+  , defaultDendroOpts
+  , dendrogramOf
+  , dendrogramOf'
+    -- * HBM (ベイズ確率プログラム) の学習 — Phase 49 A1
+  , HBMConfig (..)
+  , defaultHBM
+  , HBMModel (..)
+  , hbmModel
+  , hbmModelPure
+  , hbmModelIO
+    -- * HBM の出力抽出子 — Phase 49 A2 / Phase 74 (trace / forest)
+  , hbmParamNames
+  , TraceOpts (..)
+  , defaultTraceOpts
+  , tracesOf
+  , tracesOfWith
+  , marginalsOf
+  , marginalsByChainOf
+    -- * HBM のサンプリング診断 — Phase 59 (divergence 可視化)
+  , divergencesOf
+  , pairOf
+  , energyOf
+  , autocorrOf
+  , autocorrOfLag
+  , defaultAutocorrMaxLag
+  , rankOf
+  , rankOfBins
+  , defaultRankBins
+  , ForestSpec (..)
+  , forestOf
+  , forestOfLevel
+    -- * HBM の出力抽出子 — Phase 49 A3 (epred = 事後予測平均 + HDI band)
+  , epred
+  , epredAt
+    -- * HBM の出力抽出子 — Phase 49 A4 (ppc = 事後予測チェック)
+  , PPCConfig (..)
+  , defaultPPC
+  , PPCSpec (..)
+  , ppcOf
+  , ppcOfWith
+  , ppcOfIO
+  , ppcOfWithIO
+    -- * HBM の出力抽出子 — Phase 49 A5 (dag = モデル構造の DAG)
+  , DagSpec (..)
+  , dagOf
+  , dagOfRaw
+  , dagOfModel
+  , dagOfModelWith
+    -- * HBM 診断ダッシュボード — Phase 74.8 (抽出子束ね)
+  , dashboardOf
+  , dashboardFullOf
+  , traceDensityOf
+    -- * df |-> spec 統一 fit API — Phase 51 (ColumnSource から学習)
+  , Fit (..)
+  , (|->)
+  , (|->!)
+    -- ** 二変量近道 spec (列名2つ) — Phase 51.2
+  , LMSpec (..)
+  , lm
+  , GLMSpec (..)
+  , glm
+  , SplineSpec (..)
+  , spline
+  , RobustSpec (..)
+  , rlm
+  , QuantileSpec (..)
+  , rq
+    -- ** 行列入力モデルの高レベル spec (列名リスト) — Phase 70.A
+  , PCASpec (..)
+  , pca
+    -- MDS (Phase 75.21)
+  , MDSSpec (..)
+  , mds
+  , MDSConfig (..)
+  , MDSMethod (..)
+  , defaultMDS
+  , MDSResult (..)
+  , PCAStandardize (..)
+  , PLSSpec (..)
+  , pls
+  , PLSConfig (..)
+  , defaultPLS
+  , LDASpec (..)
+  , lda
+  , CCASpec (..)
+  , ccaOf
+  , CCAFit (..)
+    -- ** 教師あり ML 分類器/回帰器 spec (特徴列 + ラベル列) — Phase 70.A
+  , GBRSpec (..)
+  , gbmReg
+  , GBCSpec (..)
+  , gbmCls
+  , GBConfig (..)
+  , defaultGBM
+  , DTSpec (..)
+  , decisionTree
+  , DTConfig (..)
+  , defaultDecisionTree
+  , KNNCSpec (..)
+  , knnCls
+  , KNNRSpec (..)
+  , knnReg
+  , NBSpec (..)
+  , naiveBayes
+    -- ** seed 純粋化した RNG モデル spec (KMeans / RandomForest) — Phase 70.A
+  , KMeansSpec (..)
+  , kmeans
+  , KMeansConfig (..)
+  , defaultKMeans
+  , RFSpec (..)
+  , randomForestReg
+    -- 因果探索 LiNGAM (高レベル df|-> ・Phase 77)
+  , DirectLiNGAMSpec (..)
+  , directLingam
+  , ParceLiNGAMSpec (..)
+  , parceLingam
+  , MultiGroupLiNGAMSpec (..)
+  , multiGroupLingam
+  , VARLiNGAMSpec (..)
+  , varLingam
+  , PairwiseLiNGAMSpec (..)
+  , pairwiseLingam
+  , BootstrapLiNGAMSpec (..)
+  , bootstrapLingam
+  , ICALiNGAMSpec (..)
+  , icaLingam
+  , CorrelationSpec (..)
+  , correlationOf
+  , CorrelationGraph (..)
+  , LiNGAMFitted (..)
+  , lingamDagNamed
+  , varLagDagNamed
+  , bootstrapEdgeProbOf
+  , RFCSpec (..)
+  , randomForestCls
+  , RFCConfig (..)
+  , defaultRFCConfig
+  , RFConfig (..)
+  , defaultRandomForest
+    -- ** SVM / 古典 MLP 高レベル spec (純粋・df |->) — Phase 75.9
+  , MLPClsSpec (..)
+  , mlpCls
+  , MLPRegSpec (..)
+  , mlpReg
+  , SVMSpec (..)
+  , svmCls
+  , SVMHyper (..)
+  , SVMTuneGrid (..)
+  , defaultSVMTuneGrid
+  , SVMConfig (..)
+  , defaultSVM
+  , SVM (..)
+  , SVMMulti (..)
+  , numSupportVectors
+    -- ** 重み付き最小二乗 (WLS) spec — Phase 52.A6
+  , WeightedLMSpec (..)
+  , weighted
+  , WeightedLMModel (..)
+    -- ** 透過標準化ラッパ (自動逆変換) — Phase 70.3 項目 C
+  , StandardizedSpec (..)
+  , standardized
+  , standardizedY
+  , StandardizedModel (..)
+    -- ** 群別フィット spec — Phase 52.A4
+  , GroupedSpec (..)
+  , grouped
+  , GroupedFit (..)
+  , groupModels
+  , groupLabels
+  , groupedFullrange
+    -- ** 係数診断の薄アクセサ — Phase 52.A9
+  , CoefStats (..)
+  , lmDiag
+  , groupedLmDiag
+    -- ** formula 多変量 spec (R 流) — Phase 51.3
+  , LMFormulaSpec (..)
+  , lmF
+  , GLMFormulaSpec (..)
+  , glmF
+  , GLMMFormulaSpec (..)
+  , glmmF
+    -- ** 重回帰 spec (列名リスト・formula 不要) — Phase 70.D
+  , LMMultiSpec (..)
+  , lmMulti
+  , GLMMultiSpec (..)
+  , glmMulti
+  , RobustMultiSpec (..)
+  , rlmMulti
+  , QuantileMultiSpec (..)
+  , rqMulti
+  , MultiQuantileModel (..)
+    -- ** HBM spec + データ散布図 — Phase 51.4
+  , HBMSpec
+  , hbm
+  , dataScatterOf
+  ) where
+
+import qualified Data.Map.Strict       as Map
+import           Data.Maybe            (fromMaybe)
+import qualified Data.Vector           as V
+import qualified Data.Vector.Unboxed    as VU
+import qualified Numeric.LinearAlgebra as LA
+
+import           Data.Text             (Text)
+import qualified Data.Text             as T
+-- (DataFrame の直接 import は未使用のため削除 = upstream decomp PR#2 移植の副産物調査で判明)
+
+import           Hanalyze.Data.ColumnSource     (ColumnSource (..))
+
+import           Hgg.Plot.Spec     ( VisualSpec, layer, inline, inlineCat
+                                       , ColData (..)
+                                       , scatter, line
+                                       , heatmap, colorBy
+                                       , scaleColorManual, legend
+                                       , bar, title
+                                       , LineType (..) )
+import qualified Hgg.Plot.ThreeD.Spec  as P3
+
+import           Hanalyze.Model.Wrappers
+import           Hanalyze.Plot.Core
+-- 族別 instance module (Phase 71.5)。 orphan instance を scope に取り込み、
+-- 移した族固有 helper (multiGpCurves) を re-export する。
+import           Hanalyze.Plot.Linear ()
+import           Hanalyze.Plot.Smooth (multiGpCurves)
+import           Hanalyze.Plot.Robust ()
+-- ベイズ / HBM 連携族 (Phase 71.6)。 orphan instance を scope に取り込み (())、
+-- 移した抽出子・型を re-export する。 epredPredRange は本 module の
+-- epredSurfaceOfWith でも使うため明示 import する。
+import           Hanalyze.Plot.Bayes ()
+import           Hanalyze.Plot.Bayes
+                   ( hbmParamNames, TraceOpts (..), defaultTraceOpts
+                   , tracesOf, tracesOfWith, marginalsOf
+                   , marginalsByChainOf, divergencesOf
+                   , pairOf, energyOf, autocorrOf, autocorrOfLag, defaultAutocorrMaxLag
+                   , rankOf, rankOfBins, defaultRankBins
+                   , ForestSpec (..), forestOf, forestOfLevel
+                   , epred, epredAt, epredPredRange
+                   , PPCConfig (..), defaultPPC, PPCSpec (..)
+                   , ppcOf, ppcOfWith, ppcOfIO, ppcOfWithIO
+                   , DagSpec (..), dagOf, dagOfRaw, dagOfModel, dagOfModelWith
+                   , dashboardOf, dashboardFullOf, traceDensityOf
+                   , epredSurfaceOf, epredSurfaceOfWith, dataScatterOf )
+-- 汎用ラッパ族 (Phase 71.7)。 orphan instance を scope に取り込み (())、
+-- 移したヘルパ (lmDiag / groupedLmDiag / groupedFullrange) を re-export する。
+import           Hanalyze.Plot.Wrappers ()
+import           Hanalyze.Plot.Wrappers
+                   ( lmDiag, groupedLmDiag, groupedFullrange )
+-- ML / 統計モデル連携族 (Phase 71.6)。 orphan instance を scope に取り込み (())、
+-- 移した抽出子・ヘルパ・型を re-export する。
+import           Hanalyze.Plot.ML ()
+import           Hanalyze.Plot.ML
+                   ( clusterScatterOf, centroidsOf, clusterHullOf, clusterEllipseOf
+                   , DendroOpts (..), defaultDendroOpts, dendrogramOf, dendrogramOf'
+                   , treeImportances, treePlot, treePlotRaw
+                   , decisionBoundaryOf, confusionOf, MDSView, mdsView, mdsGroupBy, nnLossOf, svmSupportVectorsOf, ScorePredict (..), decisionLineOf
+                   , RegPredict (..), PDPView, pdp, pdpIce
+                   , pdpOf, pdpIceOf, pdpPlot, pdpIcePlot, partialDependencePlot, partialDependenceIcePlot
+                   , PLSView (..), PLSViewKind (..), scoreView, loadingView, vipView
+                   , garchVolatility, aftSurvivalAt
+                   , regPathPlot, lingamDag, lingamDagNamed, varLagDagNamed, bootstrapEdgeProbOf
+                   , ResidualMode (..), ProfilerSpec (..), profiler, profilerResidual, contourOf
+                   , testForest, testForestLabeled, describeBox )
+import           Hanalyze.Diagnostics
+import           Hanalyze.Fit
+import           Hanalyze.Model.SVM (SVMConfig (..)
+                                       , defaultSVM, SVM (..)
+                                       , SVMMulti (..), numSupportVectors
+                                       , SVMHyper (..)
+                                       , SVMTuneGrid (..), defaultSVMTuneGrid)
+import           Hanalyze.Model.MDS (MDSResult (..))
+import           Hanalyze.Model.LM.Diagnostics (CoefStats (..), lmCoefStats)
+import           Hanalyze.Model.GP     (GPResult (..), Kernel (..), GPParams (..), defaultGPParams)
+import           Hanalyze.Model.LM     (linspace)
+import           Hanalyze.Model.GAM    (GAMBasis (..), GAMLambda (..)
+                                              , fitGAMWith)
+import           Hanalyze.Model.MultiLM (MultiFit (..))
+import           Hanalyze.Model.Cluster (KMeansConfig (..), defaultKMeans)
+import           Hanalyze.MCMC.Core     (Chain (..))
+import           Hanalyze.Model.HBM     (ModelP, withData
+                                       , runDeterministics)
+import           Hanalyze.Model.Survival (KMResult (..))
+import           Hanalyze.Model.CompetingRisks (CRFit (..))
+import           Hanalyze.Model.PCA     (PCAResult (..), PCAStandardize (..))
+import           Hanalyze.Stat.Standardize
+                   ( Standardizer (..)
+                   , applyStandardizerCol )
+import           Hanalyze.Model.RandomForest (RandomForest (..)
+                                       , RFConfig (..), defaultRandomForest)
+import           Hanalyze.Model.GradientBoosting (GBRegressor (..), GBClassifier (..)
+                                       , GBConfig (..), defaultGBM)
+import           Hanalyze.Model.RandomForestClassifier (RFClassifierFit (..)
+                                       , RFCConfig (..), defaultRFCConfig)
+import           Hanalyze.Model.DecisionTree (DTree (..), DTFit (..), DTConfig (..), defaultDecisionTree)
+import           Hanalyze.Model.Discriminant (DiscriminantFit (..))
+import           Hanalyze.Model.Multivariate (CCAFit (..))
+import           Hanalyze.Model.NaiveBayes (NBModel (..), GaussianNB (..))
+import           Hanalyze.Model.KNN (KNNClassifier (..)
+                                       , KNNRegressor (..), predictKNNR)
+import           Hanalyze.Model.PLS (PLSFit (..), PLSConfig (..), defaultPLS)
+import           Hanalyze.Model.MultiGP (MultiGPResult (..))
+import           Hanalyze.Model.GARCH (GARCHFit (..))
+import           Hanalyze.Model.AFT (AFTFit (..))
+import           Hanalyze.Model.FDA (FunctionalPCA (..), FLMResult (..))
+import           Hanalyze.Model.Regularized (RegFit (..))
+import           Hanalyze.Model.LiNGAM.Direct (DirectLiNGAMFit (..))
+import           Hanalyze.Stat.Test (TestResult (..))
+
+-- ===========================================================================
+-- 共通基盤 (class / ModelSpec / grid 評価核) は 'Hanalyze.Plot.Core' へ
+-- 切り出した (Phase 71.4)。 本モジュールは Core を import して従来 export を
+-- re-export しつつ、 各モデル族固有の instance を残置する。
+-- ===========================================================================
+
+-- ===========================================================================
+-- ルート1 grid 評価 (ModelSpec) — Phase 16 §3 C1 [→ Plot.Core へ移動]
+--
+-- fit 済モデルの回帰曲線・CI 帯を **訓練点ではなく等間隔 grid** で評価して描く。
+-- 疎・不均一データで曲線がガタつくのを解消する (散布図の点は従来通り訓練データ)。
+-- 'statModel' で 'ModelSpec' を作り、 @<>@ でオプションを足す:
+--
+-- > df |>> (layer (scatter "x" "y") <> toPlot (statModel m <> grid 200))
+--
+-- 'ModelSpec' は Monoid。 学習済モデル @m@ はクロージャに閉じ込め、 予測は
+-- 'toPlot' (描画時) に grid 評価する (ユーザ直感「m は学習・layer で予測」)。
+-- ===========================================================================
+
+
+-- ===========================================================================
+-- 多変量 effect plot (Phase 16 §3 C3)
+--
+-- 単変数 grid 評価 (C1) を多変量モデルへ一般化する。 along 変数を grid で動かし、
+-- 他の説明変数を 'HoldAgg' で固定した「評価点 ModelFrame」 を合成して、 訓練 formula の
+-- 'designMatrixF' で評価点設計行列を組み CI を評価する。
+--
+-- ★評価点 ModelFrame の合成は **DataFrame を経由せず VarRole を直接差し替える**
+-- ('designMatrixF' は 'mfRoles' のみ参照し応答列は使わない = Design.hs:331)。 列構造・
+-- 順序が訓練と完全一致するので 'confidenceBandAt' / 'predictGlmMuWithCI' がそのまま使える。
+-- 型で単/多変量を分離し ('SingleVarModel' / 'MultiVarModel')、 along 忘れをコンパイル時に弾く。
+-- ===========================================================================
+
+
+
+-- ===========================================================================
+-- 多変量モデル型 (effect plot 用、 新規 fit)
+--
+-- 既存の単変数 'LMModel' / 'GLMModel' (設計行列が @[1, x]@ 固定) とは別型。
+-- formula 文字列 + 'DataFrame' で多変量 fit し、 formula を保持して評価点設計行列を
+-- 組む (HoldAgg 固定 + along grid)。 ★GLM は formula 経路が未整備なので
+-- 'designMatrixF' で設計行列を作り 'fitGLMFull' を直接呼ぶ。
+-- ===========================================================================
+
+-- (instance MultiVarModel MultiLMModel は Hanalyze.Plot.Linear へ移動 — Phase 71.5)
+
+-- ===========================================================================
+-- 列名リスト → 加法線形 Formula AST (パース無し直接合成) — Phase 70.D
+--
+-- 重回帰 (multiple regression) は formula DSL とは別概念: 説明変数の列名リストから
+-- 設計行列 @[1, x1, …, xp]@ を作るだけ。 これを文字列を介さず 'Formula' AST に直接
+-- 組み立て、 既存の 'multiLMModelF' / 'designMatrixF' / effect plot 機構をそのまま使う
+-- (= @parseModel "y ~ x1 + … + xp"@ と同一 AST。 パラメータ名 @_p0.._pp@ も同じ規約)。
+-- ===========================================================================
+
+-- ===========================================================================
+-- 多変量ロバスト回帰 (effect plot + 係数サマリ) — Phase 70.D
+--
+-- ロバスト回帰は formula 経路を持たない (単回帰 'RobustModel' のみだった) ので、
+-- 'MultiLMModel' と同型の frame-carrying ラッパを新設する。 設計行列は
+-- 'additiveFormula' 由来 ('designMatrixF' で @[1, x1,…,xp]@)、 fit は 'fitRobustLM'、
+-- CI 帯は M 推定量サンドイッチ共分散 ('robustCovBeta'・statsmodels RLM 一致)。
+-- ===========================================================================
+
+-- (instance MultiVarModel MultiRobustModel は Hanalyze.Plot.Robust へ移動 — Phase 71.5)
+
+-- (instance MultiVarModel MultiGLMModel は Hanalyze.Plot.Linear へ移動 — Phase 71.5)
+
+-- (instance MultiVarModel PLSModel は Hanalyze.Plot.ML へ移動 — Phase 71.6)
+
+-- ===========================================================================
+-- 線形モデル (描画可能)
+--
+-- 'FitResult' (数値核) は設計行列 X を保持しないが、 回帰線・CI band を描くには
+-- X が要る ('confidenceBand' は X 引数)。 そこで X と生 predictor を束ねた
+-- 「描画可能なモデル」 を別型にする (= plot Phase 15 §2.1 の開放論点を (i) で確定)。
+-- ===========================================================================
+
+
+-- (instance Plottable LMModel / SingleVarModel LMModel は
+--  Hanalyze.Plot.Linear へ移動 — Phase 71.5)
+
+-- ===========================================================================
+-- 一般化線形モデル (描画可能)
+--
+-- GLM の不確実性帯は **μ (応答) スケールで非対称** (線形予測子 η の対称 Wald CI を
+-- 逆リンク gInv で μ に写すため、 Logit/Log 等では下側・上側の半幅が異なる)。 ゆえに
+-- LMModel/GPResult の対称 band (ŷ±se) では忠実に描けない。 そこで
+-- 下境界 lo / 上境界 hi を別々に持てる 'band' layer (= MBand area fill) を使い、 μ 曲線は
+-- 'line' で重ねる。 帯は **訓練点での Wald CI** を 'predictGlmMuWithCI' で評価する
+-- (= grid 補間でなく fit と整合)。 'fitGLMFull' が返す逆 Fisher 情報 Σ=(XᵀWX)⁻¹ が要る。
+-- ===========================================================================
+
+-- (instance Plottable GLMModel / SingleVarModel GLMModel は
+--  Hanalyze.Plot.Linear へ移動 — Phase 71.5)
+
+-- ===========================================================================
+-- ガウス過程 (描画可能)
+--
+-- 'GPResult' (Hanalyze.Model.GP) は予測 grid (gpTestX) + 事後平均 (gpMean) +
+-- credible band (gpLower/gpUpper) を **自己完結** で保持する。 ゆえに LMModel の
+-- ように X を別途束ねる必要がなく、 結果型をそのまま 'Plottable' にできる
+-- (= 'FitResult' 系と異なる形でも protocol が成り立つことの実証 = plot Phase 15
+-- / analyze Phase 46 A6)。
+-- ===========================================================================
+
+-- (instance Plottable GPResult は Hanalyze.Plot.Smooth へ移動 — Phase 71.5)
+
+-- ===========================================================================
+-- スプライン回帰 (描画可能)
+--
+-- 'SplineFit' (Hanalyze.Model.Spline) は基底係数 'sfBeta' と、 基底行列で fit した
+-- 線形モデル核 'sfResult' (= 'FitResult') を保持する。 ゆえに **基底行列を設計行列と
+-- みなせば** LMModel と同じ 'confidenceBand' (= X (XᵀX)⁻¹ Xᵀ の対角) がそのまま使える。
+-- 違いは「曲線」 である点だけ: 単回帰の直線でなく、 訓練点を x 昇順に結ぶと基底展開に
+-- よる平滑曲線になる ('renderRegression' は encX/encY を線形再フィットせず折れ線で
+-- 結ぶため、 ソート済みの点列を渡せば曲線がそのまま描ける = GP と同じ性質)。 帯は
+-- LM と同じ **線形モデルの対称 Wald CI** (基底空間での予測分散) なので意味付けも明快。
+-- ===========================================================================
+
+-- (splineBasisAt / instance Plottable SplineModel / SingleVarModel SplineModel /
+--  Plottable GAMModel / gamGridCI / SingleVarModel GAMModel / SingleVarModel GAMModelN /
+--  Plottable GAMModelN は Hanalyze.Plot.Smooth へ移動 — Phase 71.5)
+
+-- ===========================================================================
+-- ロバスト回帰 (描画可能)
+--
+-- 'RobustFit' (Hanalyze.Model.Robust) は M-estimator IRLS の係数 'rfCoef' / fitted
+-- 'rfFitted' / 最終重み 'rfWeights' (≤ 1、 外れ値ほど小) を持つが、 **CI / 予測帯を
+-- 返す helper を持たない** (sandwich 分散等を別途計算すれば帯は出せるが本 Phase 対象外)。
+-- ゆえに代表図 ('toPlot') は **ロバスト直線のみ** (band 無し)。 ロバスト回帰の価値=
+-- 「どの点がダウンウェイトされたか」 は 'diagnosticPlots' 側で **点サイズ = IRLS 重み**
+-- の散布図に encode して見せる (主図に点を描くと合成 @df |>> layer scatter <> toPlot@
+-- で点が二重になるため、 主図は直線だけにして重み表示は診断束へ回す = user 決定 2026-06-04)。
+-- ===========================================================================
+
+-- (instance Plottable RobustModel / robustBand / SingleVarModel RobustModel は
+--  Hanalyze.Plot.Robust へ移動 — Phase 71.5)
+
+-- ===========================================================================
+-- 多出力線形回帰 (描画可能)
+--
+-- 'MultiFit' (Hanalyze.Model.MultiLM) は q 個の応答を共通の予測子で同時回帰し、
+-- 固有の成果物として **出力間の残差相関 'mfResidCor' (q×q)** を保持する。 q 本の回帰
+-- 関係を単一図に素直に載せる方法は一意でない (出力ごとスケールが異なり得る) ため、
+-- 代表図 ('toPlot') は **残差相関 heatmap** とする (= 多出力回帰固有の図。 user 決定
+-- 2026-06-04)。 'MultiFit' は heatmap に必要な相関行列を自己完結で持つので、 'GPResult'
+-- 同様 X を別途束ねず結果型をそのまま 'Plottable' にできる。 個別の出力 j の回帰線は
+-- 'predictMultiLM' で別途描ける (本 instance の対象外)。
+--
+-- ⚠ 'heatmap' (geom_tile) は **categorical 軸専用** (renderHeatmap が x/y をラベルとして
+-- カテゴリ軸の index に引く。 実測: Render/Statistical.hs)。 ゆえに格子座標は数値でなく
+-- **出力名ラベル** ("y1", "y2", …) を 'inlineCat' で渡す (数値だとカテゴリ軸が立たず
+-- 全セルが drop されてタイルが描かれない = 計測で確認)。
+-- ===========================================================================
+
+-- (instance Plottable MultiFit は Hanalyze.Plot.Wrappers へ移動 — Phase 71.7)
+
+-- ===========================================================================
+-- 分位点回帰 (描画可能)
+--
+-- 'QRFit' (Hanalyze.Model.Quantile) は 1 つの分位 τ に対する係数 + fitted 'qfYHat' を
+-- 持つ。 OLS が条件付き平均を引くのに対し分位回帰は条件付き τ-分位を引くので、 複数の
+-- τ (例 0.1/0.5/0.9) の fit を重ねると **予測区間そのものを線群で** 表現できる
+-- (= heteroscedastic データで帯より直接的)。 ゆえに 'QuantileModel' は複数の τ-fit を
+-- 束ね、 'toPlot' で **分位ごとに 1 本の line layer を色分けして重畳** する (band は使わ
+-- ない。 分位線自体が区間の縁を成すため)。 各線は 'color' ('fromHex') で固定色を割り当てる。
+-- ===========================================================================
+
+-- (instance Plottable QuantileModel / Plottable MultiQuantileModel は
+--  Hanalyze.Plot.Robust へ移動 — Phase 71.5)
+
+
+-- ===========================================================================
+-- クラスタリング (KMeans) の図 — Phase 68 A1
+--
+-- KMeans の分野定番の図は「クラスタ別散布 (色=ラベル)」。 ただし
+-- 'KMeansResult' は centroids + labels + inertia のみ保持し **生データ座標を
+-- 持たない**。 そこで 'surfaceOf' <> 'dataScatter3DOf' と同じ **model 層 / data
+-- 層の二層イディオム**に分ける:
+--
+--   * 'Plottable' 'KMeansResult' の 'toPlot' = centroid 散布のみ (データ不要・
+--     クラス契約 @m -> VisualSpec@ を満たす)。 既定は centroid 行列の第 0/1 次元。
+--   * 'clusterScatterOf' = データ点をラベル色で散布 (要データ源・列名指定)。
+--   * 'centroidsOf' = centroid を任意 2 次元で重畳 (✚ マーカー・次元 index 明示)。
+--
+-- 定番図 = @df |>> (clusterScatterOf df res \"x\" \"y\" <> centroidsOf res 0 1)@。
+-- ⚠ centroid 行列は **学習時の特徴量列順**のみで列名を持たない。 重畳時は
+-- データ列 (@xn@, @yn@) と centroid 次元 (@i@, @j@) の対応をユーザが揃える。
+-- ===========================================================================
+
+-- (instance Plottable KMeansResult / clusterScatterOf / centroidsOf は
+--  Hanalyze.Plot.ML へ移動 — Phase 71.6)
+
+-- ===========================================================================
+-- HBM (ベイズ確率プログラム) の学習 — Phase 49 A1
+--
+-- 'Hanalyze.Model.HBM' の free-monad DSL で書いた確率プログラム ('ModelP') を
+-- NUTS で学習し、 「学習済 HBM モデル」 ('HBMModel') という第一級の値にする。
+-- 命名は頻度論側の @lmModel → LMModel@ / @glmModel → GLMModel@ と対称的
+-- ('hbmModel → HBMModel')。 違いは学習が MCMC ゆえ IO・重い・async 並列
+-- (既存 'nutsChains' が 'mapConcurrently' で multi-chain 並列) という点のみ。
+--
+-- データは df 由来の列 (名前付き) を 'withData' でモデル中の placeholder
+-- ('dataNamed' / observe の参照名) に **自動 bind** する。 これは PyMC の
+-- @pm.Data@ + @set_data@ と同型 (= 同じモデルを別データで再評価できる設計)。
+--
+-- ★ 'HBMModel' は **直接 'Plottable' にしない** (確率プログラムは「単一の図」 に
+-- 一意に落ちない)。 描画は抽出子 ('epred' / 'tracesOf' / 'ppcOf' / 'forestOf' /
+-- 'dagOf'、 後続 sub で追加) を明示する設計 (Phase 49 計画 Q1)。
+-- ===========================================================================
+
+
+-- ===========================================================================
+-- 生存解析 (描画可能)
+--
+-- 生存関数 Ŝ(t) (Kaplan-Meier) と累積発生関数 CIF (競合リスク) はいずれも **階段関数**
+-- (イベント時刻で不連続にジャンプ、 その間は平坦)。 折れ線 ('line') は点間を線形に結ぶので、
+-- そのまま渡すとジャンプが斜めになる。 ゆえに **階段頂点を明示展開** する helper
+-- 'stepVerts' で (0, s0) から各イベント時刻の「水平→垂直」 2 頂点を作り、 line で結ぶ
+-- (= 正しい階段形)。 KM は s0=1 で下降、 CIF は s0=0 で上昇。 KMResult / CRFit は時刻と
+-- 値を自己完結で持つので 'GPResult' 同様そのまま 'Plottable' にできる。
+-- ===========================================================================
+
+
+-- (instance Plottable KMResult / Plottable CRFit は
+--  Hanalyze.Plot.ML へ移動 — Phase 71.6)
+
+-- ===========================================================================
+-- 時系列予測 (描画可能)
+--
+-- AR(p) の点予測 'forecastAR' は将来値の中心のみを返す。 予測の不確実性帯は **h-step
+-- 予測分散** から得る: AR の MA(∞) 表現の ψ-weights (ψ₀=1, ψⱼ=Σφᵢψⱼ₋ᵢ) を用いて
+-- @Var(ŷ_{n+k}) = σ² Σ_{j=0}^{k-1} ψⱼ²@ (σ² = 革新分散 'arResidVar')。 これは Gaussian
+-- 革新の下での正統な予測区間 (地平 k とともに単調に広がる)。 対称ゆえ band は
+-- @中心 ± z·se@。 'toPlot' は履歴折れ線 + 予測折れ線 + 予測区間 band を 1 枚に重ねる。
+-- ===========================================================================
+
+-- (arPsiWeights / arForecastSE / instance Plottable ForecastModel は
+--  Hanalyze.Plot.ML へ移動 — Phase 71.6)
+
+-- ===========================================================================
+-- 多変量・木 (描画可能)
+--
+-- PCA の代表図は **scree plot** (各主成分の寄与率 'pcaExplainedRatio' を棒で)、 木 (RF) の
+-- 代表図は **特徴重要度バー** ('featureImportance')。 いずれも自己完結ゆえそのまま
+-- 'Plottable'。 棒の x 軸はラベル ("PC1".. / "f1"..) なので 'inlineCat' (categorical) で渡す
+-- (heatmap A9 と同じく 'bar' も categorical 軸が必要)。 優先低 (§3.5 A14) ゆえ scree/重要度
+-- の 1 枚ずつに絞る (biplot や木構造図は将来拡張)。
+-- ===========================================================================
+
+-- (instance Plottable PCAResult / Plottable RandomForest は
+--  Hanalyze.Plot.ML へ移動 — Phase 71.6)
+
+-- ===========================================================================
+-- 木/アンサンブル — Phase 68 A2
+--
+-- 各モデルの分野定番図を **既存 mark のみ**で描く (新規 plot mark 不要):
+--
+--   * GradientBoosting (回帰/分類)・RandomForestClassifier = **特徴重要度 bar**。
+--     GBM は重要度フィールドを持たないので弱学習器 ('Tree') の split 使用回数から
+--     純粋計算する ('treeImportances'・RF.'featureImportance' と同方式・正規化)。
+--   * DecisionTree = **樹形図**。 決定木は DAG の特殊形 (二分木) ゆえ、 HBM の
+--     ModelGraph と同じ MDAG (Sugiyama 階層 layout) を **再利用**して node-link で描く
+--     (split ノード = "f{j} ≤ {thr}"、 葉 = "y={class}")。
+--
+-- ⚠ DecisionTree の edge True/False ラベル・gini・サンプル数表示 (sklearn plot_tree
+-- 相当) は DAGNode/DAGEdge が持たないため v1 では描かない。 必要なら専用 mark を
+-- plot 側 Phase として起こす (= dendrogram Phase 48 と同型の判断)。
+-- ===========================================================================
+
+-- (treeImportances / instance Plottable GBRegressor / GBClassifier /
+--  RFClassifierFit / DTree / dtreeToDag は Hanalyze.Plot.ML へ移動 — Phase 71.6)
+
+-- ===========================================================================
+-- 分類 (Discriminant / NaiveBayes / KNN) — Phase 68 A3
+--
+-- 代表図は **決定境界** と **confusion 行列**。 いずれも「学習済モデルを評価点で
+-- 走らせる」 図ゆえ、 KMeans (A1) と同じく **データ/範囲を取るヘルパ**で提供する
+-- (新規 plot mark 不要):
+--
+--   * 'decisionBoundaryOf' = 2D grid を予測しクラス色で塗る (= 連続軸の散布を
+--     四角マーカー・低 alpha で「領域」表現。 ★renderHeatmap はカテゴリ軸なので
+--     連続 grid には不適 → 'MScatter' + 'colorBy' (離散色) を採用)。 2 特徴前提。
+--   * 'confusionOf' = テストデータの真値×予測の件数を 'MHeatmap' で (カテゴリ軸が適合)。
+--
+-- 'Plottable' の 'toPlot' (データ非保持で描ける代表 1 枚):
+--   * KNN は訓練データ ('knnCX'/'knnCY') を保持 → **ラベル色の訓練点散布**。
+--   * Discriminant / NaiveBayes(Gaussian) は **クラス平均散布** (✚)、
+--     NaiveBayes(Multinomial) は **クラス事前確率 bar**。
+-- ===========================================================================
+
+
+-- (instance ClassPredict DiscriminantFit / NBModel / KNNClassifier /
+--  decisionBoundaryOf / confusionOf / instance Plottable KNNClassifier /
+--  DiscriminantFit / NBModel は Hanalyze.Plot.ML へ移動 — Phase 71.6)
+
+-- ===========================================================================
+-- 次元圧縮 (PLS / MultiGP) — Phase 68 A4
+--
+-- どちらも結果が自己完結 ('PCAResult' 同様) なので外部データ不要で 'Plottable':
+--
+--   * 'PLSFit' = 潜在空間の **score plot** (標本 T) を代表図に、 'loading plot' (変数 P)
+--     と **VIP bar** を診断図束に。 いずれも既存 'MScatter'/'bar'。
+--   * 'MultiGPResult' = **多出力の予測曲線 + 95% band** (出力ごとに色分け・x=index)。
+--     'MLine' + 'MBand' を出力数ぶん重畳。
+--
+-- ※ 'Hanalyze.Model.MultiOutput' は変換+メトリクスの **ユーティリティ**で
+-- fit 結果型を持たないため 'Plottable' 対象外 (多出力の「相関」図は既存
+-- 'MultiFit' = 残差相関 heatmap が担当)。 新規 plot mark は不要。
+-- ===========================================================================
+
+
+-- (PLSViewKind / PLSView / scoreView / loadingView / vipView /
+--  instance Plottable PLSView / PLSFit は Hanalyze.Plot.ML へ移動 — Phase 71.6)
+
+-- (multiGpCurves / instance Plottable MultiGPResult は
+--  Hanalyze.Plot.Smooth へ移動 — Phase 71.5)
+
+-- ===========================================================================
+-- 時系列・生存・FDA (GARCH / AFT / FDA) — Phase 68 A5
+--
+-- 新規 plot mark は不要 (既存 line/band の重畳):
+--
+--   * 'GARCHFit'      = 系列 (μ + ε_t) + 条件付き volatility 帯 (μ ± 2σ_t) の帯付き線。
+--   * 'AFTFit'        = パラメトリック生存曲線 S(t|x)。 fit は観測時刻を持たないので
+--                       代表図 ('toPlot') は **基準共変量** (intercept のみ) の曲線、
+--                       任意共変量は 'aftSurvivalAt' ヘルパ。 t 範囲は予測平均寿命から導出。
+--   * 'FunctionalPCA' = 平均関数 + 上位固有関数を grid 上に重畳 (x = grid index)。
+--   * 'FLMResult'     = 関数回帰係数 β(t) の曲線。
+-- ===========================================================================
+
+-- (garchVolatility / instance Plottable GARCHFit / aftSurvivalAt /
+--  instance Plottable AFTFit / FunctionalPCA / FLMResult は
+--  Hanalyze.Plot.ML へ移動 — Phase 71.6)
+
+-- ===========================================================================
+-- 罰則回帰・因果探索 (Regularized / LiNGAM) — Phase 68 A6
+--
+-- 新規 plot mark は不要:
+--
+--   * 'RegFit'          = 単一 λ の係数 ('rfBeta') を bar (代表図)。
+--   * 'regPathPlot'     = 正則化パス @[(λ, [β_j])]@ ('regularizationPath' 出力) を、
+--                         係数ごとに 1 本の line で λ-横軸に重畳 (= LASSO 係数パス図)。
+--   * 'DirectLiNGAMFit' = 推定した因果構造を **MDAG** で描く (B 行列 → node/edge、
+--                         決定木と同じ MDAG 再利用)。 edge j→i は @|adjacency[i,j]|>0@。
+-- ===========================================================================
+
+-- (instance Plottable RegFit / regPathPlot / lingamDag /
+--  instance Plottable DirectLiNGAMFit は Hanalyze.Plot.ML へ移動 — Phase 71.6)
+
+-- ===========================================================================
+-- 記述統計・検定 (Stat.*) — Phase 68 A7
+--
+-- 新規 plot mark は不要:
+--
+--   * 'TestResult'  = 効果量 + 95% CI の **forest** (検定パラメータの区間 + 0 基準線)。
+--                     代表図 ('toPlot') は 1 行 forest、 複数検定は 'testForest'。
+--   * 'describeBox' = 生データ列の **box plot** (= describe の分布図・5 数要約を可視化)。
+-- ===========================================================================
+
+-- (testForest / testForestLabeled / instance Plottable TestResult /
+--  describeBox は Hanalyze.Plot.ML へ移動 — Phase 71.6)
+
+
+-- (instance SingleVarModel WeightedLMModel / Plottable WeightedLMModel は
+--  Hanalyze.Plot.Linear へ移動 — Phase 71.5)
+
+
+
+-- C2: 元スケール逆変換 instance (Phase 70.3 項目 C) -------------------------
+--
+-- 内側モデルは標準化空間で学習されている。 ここで予測子 x を入力時に標準化し、
+-- ('standardizedY' なら) 応答 y を出力時に逆変換することで、 図・予測を**元スケール**で
+-- 返す。 単変量 (1 特徴) 描画が対象 (smXStd の 0 次元を使う)。
+
+-- (instance SingleVarModel KNNRegressor / stMu1 / stSd1 / unstdY /
+--  SingleVarModel (StandardizedModel m) / Plottable (StandardizedModel m) は
+--  Hanalyze.Plot.Wrappers へ移動 — Phase 71.7)
+
+-- ===========================================================================
+-- 混合効果モデル (random effects) — Phase 52 D3
+--
+-- 'GLMMResultRE' (Phase 48 の vector random effects: random intercept + slope)
+-- を caterpillar plot で描く。 各 group の BLUP @b̂_j@ を **値で昇順ソート**し、
+-- forest mark (水平棒) で並べる。 0 (= 固定効果からの偏差ゼロ) に参照線を引く。
+-- group 間の random effect のばらつき・外れ群を一目で読めるのが GLMM 固有の定番図。
+--
+-- ★ CI 帯は現状なし (点のみ): 'GLMMResultRE' は per-group の conditional variance
+-- も観測数 @n_j@ も格納しておらず (scalar 専用の 'glmmBLUPSE' は 'GLMMResult' 用で
+-- 流用不可)、 BLUP の標準誤差を単体から計算できない。 将来 conditional variance を
+-- 持たせれば forest の誤差半幅を埋めて帯化できる (forest mark は対称 CI 対応済)。
+--
+-- 'toPlot'          = random-effect 第 1 列 (通常 intercept) の caterpillar 1 枚。
+-- 'diagnosticPlots' = 全 r 列 (intercept + 各 slope) の caterpillar list。
+-- ===========================================================================
+
+
+
+-- (instance SingleVarModel GPRegModel / Plottable GPRegModel /
+--  SingleVarModel GPRegModelN / Plottable GPRegModelN は
+--  Hanalyze.Plot.Smooth へ移動 — Phase 71.5)
+
+
+-- (instance Plottable RegModel / regMethodName / roundTo は
+--  Hanalyze.Plot.Wrappers へ移動 — Phase 71.7)
+
+-- (familyObsDist は Hanalyze.Plot.Linear へ移動 — Phase 71.5)
+
+-- (lmDiag / groupedLmDiag / instance Plottable (GroupedFit spec) /
+--  renderGrouped / groupedFullrange / renderGroupedWith /
+--  instance ColumnSource [(Text, ColData)] は
+--  Hanalyze.Plot.Wrappers へ移動 — Phase 71.7)
+
+-- (dataScatterOf は Hanalyze.Plot.Bayes へ移動 — Phase 71.7)
+
diff --git a/src/Hanalyze/Plot/Bayes.hs b/src/Hanalyze/Plot/Bayes.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Plot/Bayes.hs
@@ -0,0 +1,1011 @@
+-- |
+-- Module      : Hanalyze.Plot.Bayes
+-- Description : hgg 連携層 — ベイズ / HBM 連携族の図化 instance + 抽出子
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- hgg 連携層 — **ベイズ / HBM 連携族** の図化 instance + 抽出子 (Phase 71.6)。
+--
+-- ⚠ 親 'Hanalyze.Plot' と同じ cabal flag @plot-integration@ (既定 off) を
+-- on にしたときのみ build される。 共通基盤 (class / ModelSpec / grid 評価核) は
+-- 'Hanalyze.Plot.Core' を import して取り込む (orphan instance を許容)。
+--
+-- 担当する型・抽出子 (= MCMC chain / HBM 出力):
+--   ChainModel の trace / 周辺事後密度・HBM の trace/forest/epred/ppc/dag 抽出子 (Phase 74 統一)・
+--   GLMMResultRE の caterpillar plot。 HBM の *学習* (hbmModel 等) は
+--   'Hanalyze.Fit' / 'Hanalyze.Model.Wrappers' 側 (こちらは描画連携のみ)。
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RankNTypes #-}
+{-# LANGUAGE ImpredicativeTypes #-}
+{-# LANGUAGE TypeFamilies #-}
+{-# LANGUAGE FlexibleContexts #-}
+module Hanalyze.Plot.Bayes
+  ( -- * HBM の出力抽出子 — Phase 49 A2 / Phase 74 (trace / forest)
+    hbmParamNames
+  , TraceOpts (..)
+  , defaultTraceOpts
+  , tracesOf
+  , tracesOfWith
+  , marginalsOf
+  , marginalsByChainOf
+    -- * HBM のサンプリング診断 — Phase 59 (divergence 可視化)
+  , divergencesOf
+  , pairOf
+  , energyOf
+  , autocorrOf
+  , autocorrOfLag
+  , defaultAutocorrMaxLag
+  , rankOf
+  , rankOfBins
+  , defaultRankBins
+  , ForestSpec (..)
+  , forestOf
+  , forestOfLevel
+    -- * HBM の出力抽出子 — Phase 49 A3 (epred = 事後予測平均 + HDI band)
+  , epred
+  , epredAt
+  , epredPredRange
+    -- * 応答曲面 3D / 散布 (HBM 固有) — Phase 71.7
+  , epredSurfaceOf
+  , epredSurfaceOfWith
+  , dataScatterOf
+    -- * HBM の出力抽出子 — Phase 49 A4 (ppc = 事後予測チェック)
+  , PPCConfig (..)
+  , defaultPPC
+  , PPCSpec (..)
+  , ppcOf
+  , ppcOfWith
+  , ppcOfIO
+  , ppcOfWithIO
+    -- * HBM の出力抽出子 — Phase 49 A5 (dag = モデル構造の DAG)
+  , DagSpec (..)
+  , dagOf
+  , dagOfRaw
+  , dagOfModel
+  , dagOfModelWith
+    -- * HBM 診断ダッシュボード — Phase 74.8 (抽出子束ね)
+  , dashboardOf
+  , dashboardFullOf
+  , traceDensityOf
+  ) where
+
+import           Data.List             (sortBy, transpose)
+import qualified Data.Map.Strict       as Map
+import           Data.Maybe            (fromMaybe)
+import           Data.Ord              (comparing)
+import           Data.Word             (Word32)
+import qualified Data.Vector           as V
+import           System.Random.MWC     (createSystemRandom, initialize, Gen)
+import           Control.Monad.Primitive (PrimMonad, PrimState)
+import           Control.Monad.ST      (runST)
+import qualified Numeric.LinearAlgebra as LA
+
+import           Data.Text             (Text)
+import qualified Data.Text             as T
+
+import           Hgg.Plot.Spec     ( VisualSpec, layer, inline, inlineCat
+                                       , Color (..), fromHex
+                                       , scatter, line, band, bar
+                                       , position, Position (..)
+                                       , color, colorBy, lineRange
+                                       , scaleColorManual, legendOff
+                                       , legendPos, LegendPosition (..)
+                                       , trace, density, forest, forestNull
+                                       , subplots, subplotCols, width, height
+                                       , xLabel, yLabel, title
+                                       , ecdf, alpha
+                                       , dagFromListsWithPlates
+                                       , DAGNode (..), DAGEdge (..), DAGPlate (..)
+                                       , DAGNodeKind (..), DAGLayoutAlgorithm (..) )
+import           Hgg.Plot.DAG      (layoutHierarchicalFullWithPlates)
+import           Hgg.Plot.Render.Special (bakeDAGRoutesInSpec)
+import qualified Hgg.Plot.ThreeD.Spec  as P3
+
+import           Hanalyze.Model.Wrappers
+import           Hanalyze.Plot.Core
+import           Hanalyze.MCMC.Core     (Chain (..), chainVals)
+import           Hanalyze.MCMC.BayesianTest (highestDensityInterval)
+import           Hanalyze.Stat.MCMC    (kde, autocorr, rankHist)
+import           Hanalyze.Model.HBM.Sampling (sampleObsRep)
+import           Hanalyze.Model.HBM     (ModelP, withData
+                                       , runDeterministics, runObserveDists
+                                       , buildModelGraph, ModelGraph (..)
+                                       , collapseIndexedPlateNodes
+                                       , sampleNames
+                                       , Node (..), NodeKind (..))
+import           Hanalyze.Model.LM     (linspace)
+import           Hanalyze.Model.GLMM   (GLMMResultRE (..))
+
+-- ===========================================================================
+-- MCMC チェーン (描画可能)
+--
+-- 'Chain' (Hanalyze.MCMC.Core) は post-burn-in の draw 列 'chainSamples' を保持する
+-- (各 draw は Map パラメータ名→値)。 ベイズの「出入口」 = サンプラの収束診断と周辺事後の
+-- 可視化。 1 つのパラメータを選び、 代表図 ('toPlot') は **trace plot** (draw index 対値、
+-- = 混合・定常性の目視)、 診断束 ('diagnosticPlots') に **周辺事後密度** (MDensity) を加える。
+-- trace と density は座標系が異なる (index-値 vs 値-密度) ため 1 枚に混ぜず別図にする。
+-- ===========================================================================
+
+instance Plottable ChainModel where
+  -- trace plot: draw index 対 パラメータ値 (折れ線 = MTrace)。
+  toPlot m =
+    let vals  = chainVals (cmParam m) (cmChain m)
+        iters = [ fromIntegral i | i <- [1 .. length vals] ] :: [Double]
+    in layer (trace (inline iters) (inline vals))
+
+  -- 診断束: trace + 周辺事後密度 (MDensity)。
+  diagnosticPlots m =
+    let vals  = chainVals (cmParam m) (cmChain m)
+        iters = [ fromIntegral i | i <- [1 .. length vals] ] :: [Double]
+    in [ layer (trace (inline iters) (inline vals))
+       , layer (density (inline vals))
+       ]
+
+-- ===========================================================================
+-- HBM の出力抽出子 — Phase 49 A2 / Phase 74 (trace / forest)
+--
+-- 'HBMModel' は直接 'Plottable' にしない (確率プログラムは単一の図に一意に落ちない)。
+-- 代わりに抽出子を明示する。 trace は 'tracesOf' / 'tracesOfWith' に統一:
+--   * 'tracesOf'     = 各 latent パラメータの trace plot を **param ごと独立パネル**
+--                      ('[VisualSpec]') で返す。 divergence rug は既定 ON (ArviZ 流)。
+--   * 'tracesOfWith' = 'TraceOpts' で divergence on/off と chain 別重畳を切り替える。
+--   * 'forestOf'     = 各 latent の事後区間 (事後平均 + 94% HDI) を 'MForest' mark で。
+--
+-- ★ Phase 74 で旧 'traceOf' ([ChainModel]) / 'tracesByChainOf' /
+-- 'tracesWithDivergencesOf' の 3 本を統合した。 戻り型を兄弟抽出子 (marginalsOf 等)
+-- と同じ '[VisualSpec]' に揃え、 @vconcat (tracesOf m)@ で param ごと縦並びに描ける
+-- (旧 docs の @foldMap toPlot (traceOf m)@ = 全 param を 1 軸に重畳する誤りを排除)。
+-- ===========================================================================
+
+-- | 学習済モデルの latent パラメータ名 (= 事後を持つ未知数の一覧)。
+hbmParamNames :: HBMModel -> [Text]
+hbmParamNames = sampleNames . hbmModelSpec
+
+-- | 1 パラメータの post-burn-in draw を全 chain 連結で取り出す。
+hbmDraws :: Text -> HBMModel -> [Double]
+hbmDraws name = concatMap (chainVals name) . hbmChainsR
+
+-- | 全 chain の draw を 1 本に連結した 'Chain' (trace 表示用)。 index は
+-- chain を端から端へ並べた通し番号になる (A2 の trace は混合の目視が目的)。
+-- Phase 59.4: divergence index も同じ連結順の通し番号に変換する
+-- ('pooledDivergences' が正本。 chain 内 index のまま連結すると merged frame で
+-- 別の draw を指してしまう)。
+mergeChains :: [Chain] -> Chain
+mergeChains []  = Chain [] 0 0 [] [] []
+mergeChains chs = Chain
+  { chainSamples     = concatMap chainSamples chs
+  , chainAccepted    = sum (map chainAccepted chs)
+  , chainTotal       = sum (map chainTotal chs)
+  , chainEnergy      = concatMap chainEnergy chs
+  , chainDivergences = pooledDivergences chs
+  , chainTreeDepths  = concatMap chainTreeDepths chs
+  }
+
+-- | trace 診断の設定 ('ppcOf' / 'PPCConfig' と同じ「関数 + config」 慣用)。
+data TraceOpts = TraceOpts
+  { toShowDivergences :: !Bool  -- ^ 発散 draw の rug を重ねる (既定 True・ArviZ 流)。
+  , toByChain         :: !Bool  -- ^ True で chain 別重畳、 False で全 chain merged (既定)。
+  } deriving (Show, Eq)
+
+-- | 既定の trace 設定 = divergence rug ON・全 chain merged。
+defaultTraceOpts :: TraceOpts
+defaultTraceOpts = TraceOpts { toShowDivergences = True, toByChain = False }
+
+-- | 各 latent パラメータの trace plot を **param ごと独立パネル** ('[VisualSpec]')
+-- で返す (divergence rug 既定 ON)。 @noDf |>> vconcat (tracesOf m)@ で param ごとに
+-- 縦並びの trace になる (= ArviZ @plot_trace@ 右列)。 設定は 'tracesOfWith'。
+tracesOf :: HBMModel -> [VisualSpec]
+tracesOf = tracesOfWith defaultTraceOpts
+
+-- | 'TraceOpts' を明示する 'tracesOf'。 旧 'traceOf' (merged 単線) /
+-- 'tracesByChainOf' (chain 別重畳) / 'tracesWithDivergencesOf' (chain 別 + rug) の
+-- 3 本を 1 つに統合したもの:
+--
+--   * @tracesOfWith (TraceOpts False False)@ = 旧 'traceOf' 相当 (merged 単線・rug 無し)
+--   * @tracesOfWith (TraceOpts False True )@ = 旧 'tracesByChainOf' (chain 別重畳・rug 無し)
+--   * @tracesOfWith (TraceOpts True  True )@ = 旧 'tracesWithDivergencesOf' (chain 別 + rug)
+--   * 既定 @tracesOf@ = @TraceOpts True False@ (merged + rug)
+--
+-- divergence rug は各図下端 (y = 当該 param の全 chain 最小値) に発散 draw の x 位置を
+-- 縦棒 ('lineRange') で打つ。 merged では通し index ('divergencesOf')、 chain 別では
+-- chain 内 1-based iteration を x にする (それぞれの trace の x 軸と整合)。
+-- divergence が無ければ rug レイヤは付かない。
+tracesOfWith :: TraceOpts -> HBMModel -> [VisualSpec]
+tracesOfWith opts hbm =
+  [ traceLayers nm <> rugLayer nm <> title nm | nm <- hbmParamNames hbm ]
+  where
+    chs = hbmChainsR hbm
+    traceLayers nm
+      | toByChain opts =
+          foldMap (\(k, ch) ->
+              let vals  = chainVals nm ch
+                  iters = [ fromIntegral i | i <- [1 .. length vals] ] :: [Double]
+              in layer (trace (inline iters) (inline vals) <> color (fromHex (chainColor k))))
+            (zip [0 ..] chs)
+      | otherwise =
+          let vals  = chainVals nm (mergeChains chs)
+              iters = [ fromIntegral i | i <- [1 .. length vals] ] :: [Double]
+          in layer (trace (inline iters) (inline vals))
+    rugLayer nm
+      | not (toShowDivergences opts) = mempty
+      | otherwise =
+          let allVals = concatMap (chainVals nm) chs
+              -- merged: 連結通し index (divergencesOf)。chain 別: chain 内 1-based iteration。
+              xs | toByChain opts = [ fromIntegral (i + 1) | ch <- chs, i <- chainDivergences ch ] :: [Double]
+                 | otherwise      = [ fromIntegral (i + 1) | i <- divergencesOf hbm ] :: [Double]
+          in if null xs || null allVals
+               then mempty
+               -- ArviZ tick 同型 = 下端から値域 2% の短い縦棒。 定数 trace では 1e-9 最小高。
+               -- ★lineRange の意味論は (x, 中心 y, ±err) = 下端 yMin〜yMin+tick の棒。
+               else let yMin = minimum allVals
+                        yMax = maximum allVals
+                        tick = max ((yMax - yMin) * 0.02) 1e-9
+                        nDiv = length xs
+                    in layer (lineRange (inline xs)
+                                        (inline (replicate nDiv (yMin + tick / 2)))
+                                        (inline (replicate nDiv (tick / 2)))
+                              <> color (fromHex divergenceColor))
+
+-- | 各 latent パラメータの **周辺事後密度** を per-param で list 返しする。
+-- 'tracesOf' (per-param trace) の密度版で、 'ChainModel' の @diagnosticPlots@ が出す
+-- 周辺事後密度 (@density@、 root: 'diagnosticPlots' ChainModel 経路) を 1 パラメータ
+-- 1 図に切り出したもの。 全 chain の post-burn-in draw をプール ('hbmDraws') した
+-- 周辺分布を描き、 図タイトルにパラメータ名を付す。
+--
+-- @subplots (map toPlot (marginalsOf fit)) <> subplotCols 1@ で周辺事後の grid を組め、
+-- B1 の入れ子 subplots と合わせて HBM ダッシュボードの 1 列になる。
+marginalsOf :: HBMModel -> [VisualSpec]
+marginalsOf hbm =
+  [ layer (density (inline (hbmDraws nm hbm))) <> title nm
+  | nm <- hbmParamNames hbm ]
+
+-- ===========================================================================
+-- HBM のサンプリング診断 — Phase 59.4 / 74 (divergence の通し index + pair/energy)
+--
+-- 'Chain' は NUTS の発散 draw index ('chainDivergences' = chain 内 0-based・
+-- post-burn-in、 root: request/255 §4) と Hamiltonian energy を記録済み。 ここでは
+-- それを plot-core の語彙 (scatter + color) で図示する。 rug 用の新 MarkKind は
+-- 追加しない (計画 md の設計判断: 既存 mark の組合せで足りることを確認してから諮る)。
+-- trace の divergence rug 自体は 'tracesOfWith' (Phase 74 統合) に移譲した。
+-- ===========================================================================
+
+-- | [Chain] の発散 draw を連結順の通し index に変換する内部正本
+-- (chain c の offset = それ以前の chain の draw 数合計)。 'mergeChains' /
+-- 'divergencesOf' の双方がこれを使う (重複実装しない)。
+pooledDivergences :: [Chain] -> [Int]
+pooledDivergences chs =
+  concat [ map (+ off) (chainDivergences ch)
+         | (off, ch) <- zip offsets chs ]
+  where offsets = scanl (+) 0 (map (length . chainSamples) chs)
+
+-- | 全 chain を pool した発散 draw の通し index ('mergeChains' の連結順と整合)。
+-- 'tracesOf' (merged trace) の rug 位置や、 発散 draw の抽出
+-- (@map (chainSamples merged !!) (divergencesOf fit)@) に使う。
+divergencesOf :: HBMModel -> [Int]
+divergencesOf = pooledDivergences . hbmChainsR
+
+-- | divergence rug / 強調点の色 ('Hanalyze.Viz.MCMC' の pairScatterDiv と同じ赤)。
+divergenceColor :: Text
+divergenceColor = "#dd2222"   -- 小文字 (toCss 出力と byte 一致・視覚は #DD2222 と同一)
+
+-- | ArviZ @plot_pair(divergences=True)@ 流: 指定パラメータ対の joint 散布
+-- (全 chain pool・薄表示) + 発散 draw を強調色で重畳。 funnel 診断の本命
+-- (例: @pairOf fit [("tau_b1", "b1_2")]@ で漏斗の首に発散が集中するのが見える)。
+-- 発散 draw の抽出は 'divergencesOf' の通し index を pool 後の draw 列に引く
+-- (chain 連結順は 'hbmDraws' = 'mergeChains' と同一)。
+-- divergence が無ければ強調レイヤは付かない。
+pairOf :: HBMModel -> [(Text, Text)] -> [VisualSpec]
+pairOf hbm prs =
+  [ let xs   = hbmDraws xn hbm
+        ys   = hbmDraws yn hbm
+        n    = min (length xs) (length ys)
+        dIdx = [ i | i <- divergencesOf hbm, i < n ]
+        dxs  = map (xs !!) dIdx
+        dys  = map (ys !!) dIdx
+    in layer (scatter (inline xs) (inline ys) <> alpha 0.25)
+       <> (if null dIdx
+             then mempty
+             else layer (scatter (inline dxs) (inline dys)
+                         <> color (fromHex divergenceColor)))
+       <> xLabel xn <> yLabel yn <> title (xn <> " × " <> yn)
+  | (xn, yn) <- prs ]
+
+-- | ArviZ @plot_energy@ 流: marginal energy (E − Ē、 chain 別中心化) と
+-- transition energy (ΔE = E_{i+1} − E_i、 chain 内差分・境界を跨がない) の密度重畳。
+-- ΔE 分布が marginal より極端に狭ければ、 サンプラが posterior の energy 分布を
+-- 探索しきれていないサイン (低 BFMI 相当。 数値は 'Hanalyze.Viz.MCMC' の bfmi)。
+-- energy ('chainEnergy' = draw ごとの Hamiltonian) は HMC / NUTS のみ記録される
+-- ため、 MH / Gibbs 等の fit では空図になる。 系列名は Viz 側 energyPlot と同一。
+--
+-- ★mark は 'density' でなく KDE ('Hanalyze.Stat.MCMC' の kde 200 = Viz energyPlot と
+-- 同一) + 'line'。 理由: 固定色 'color' と categorical 'colorBy' は同一 field (lyColor) の
+-- Last で相互排他、 かつ renderDensity は categorical 色を見ない (staticColorOr のみ) ため、
+-- density mark では「2 色の曲線 + 凡例」 が両立できない。 line は群色対応済なので
+-- 多モデル重畳 (line + color inlineCat + scaleColorManual + legend) の確立パターンに
+-- 乗せる。
+energyOf :: HBMModel -> VisualSpec
+energyOf hbm =
+  curve lblMar eMar <> curve lblTr eTrans <> legendSpec
+    <> xLabel "Energy" <> yLabel "Density" <> title "energy"
+  where
+    lblMar = "marginal E (centered)"
+    lblTr  = "transition ΔE"
+    ess    = filter (not . null) (map chainEnergy (hbmChainsR hbm))
+    center es = let mu = sum es / fromIntegral (length es)
+                in map (subtract mu) es
+    eMar   = concatMap center ess
+    eTrans = concatMap (\es -> zipWith (-) (drop 1 es) es) ess
+    curve lbl vals
+      | length vals < 2 = mempty
+      | otherwise =
+          let (gx, gy) = unzip (kde 200 vals)
+          in layer (line (inline gx) (inline gy)
+                    <> colorBy (inlineCat (replicate (length gx) lbl)))
+    legendSpec
+      | null eMar = mempty
+      | otherwise = scaleColorManual [ (lblMar, "#4C72B0"), (lblTr, "#DD8452") ]
+                      -- 凡例は図内 (右上)。 密度は中央が高く右裾は 0 ゆえ右上が空く。
+                      -- 外・右だと右に余白が出て subplot/dashboard が不格好になる。
+                      <> legendPos LegendInsideTopRight
+
+-- | chain 別の **周辺事後密度** を 1 図に重畳した per-param list (= ArviZ @plot_trace@ 左側 /
+-- @plot_posterior@ の chain 重ね)。 'marginalsOf' が全 chain プールの 1 本を描くのに対し、
+-- こちらは chain ごとに別レイヤを 'color' ('fromHex') で重ねる。
+marginalsByChainOf :: HBMModel -> [VisualSpec]
+marginalsByChainOf hbm =
+  [ foldMap (\(k, ch) -> layer (density (inline (chainVals nm ch)) <> color (fromHex (chainColor k))))
+            (zip [0 ..] (hbmChainsR hbm))
+    <> title nm
+  | nm <- hbmParamNames hbm ]
+
+-- | 自己相関 plot の既定最大ラグ (= ArviZ @plot_autocorr@ の見やすさに合わせた 30。
+-- ArviZ 既定の 100 は SVG では横に潰れるので短めにする)。
+defaultAutocorrMaxLag :: Int
+defaultAutocorrMaxLag = 30
+
+-- | 各 latent パラメータの **自己相関** を per-param list で返す (= ArviZ @plot_autocorr@)。
+-- lag 0..'defaultAutocorrMaxLag' の ACF を縦棒 ('bar') で描く。 chain 連結の境界アーティ
+-- ファクトを避けるため **chain ごとに 'autocorr' を計算し lag ごとに平均**する
+-- ('energyOf' が chain 別に算出して連結するのと同方針)。 ACF が速く 0 に減衰するほど
+-- mixing が良い (高い自己相関 = ESS 低下のサイン)。
+autocorrOf :: HBMModel -> [VisualSpec]
+autocorrOf = autocorrOfLag defaultAutocorrMaxLag
+
+-- | 最大ラグを明示する 'autocorrOf'。
+autocorrOfLag :: Int -> HBMModel -> [VisualSpec]
+autocorrOfLag maxLag hbm =
+  [ acSpec nm | nm <- hbmParamNames hbm ]
+  where
+    chains = hbmChainsR hbm
+    acSpec nm =
+      let perChain = [ autocorr maxLag vs
+                     | c <- chains, let vs = chainVals nm c, not (null vs) ]
+      in case perChain of
+           []      -> mempty
+           (ac0:_) ->
+             let lags    = map (fromIntegral . fst) ac0 :: [Double]
+                 acfByCh = map (map snd) perChain                  -- [chain][lag]
+                 meanACF = map (\col -> sum col / fromIntegral (length col))
+                               (transpose acfByCh)                 -- lag ごとの chain 平均
+             -- y 軸ラベルは省く (図が潰れるため。 title でパラメータ名は分かる)。
+             in layer (bar (inline lags) (inline meanACF))
+                  <> title nm <> xLabel "lag"
+
+-- | rank plot の既定ビン数 (= PyMC @plot_rank@ 既定 20)。
+defaultRankBins :: Int
+defaultRankBins = 20
+
+-- | 各 latent パラメータの **rank plot** を per-param list で返す (= ArviZ @plot_rank@・
+-- Vehtari et al. 2021)。 全 chain をプールした値の rank を chain ごとにヒストグラム化し、
+-- chain 別の棒を色分けして重畳する。 **収束時は各 chain がほぼ一様** (= どのビンも同程度)。
+-- chain が偏る (= 山ができる) と R̂ 悪化のサイン。 rank 計算は 'rankHist' (Stat.MCMC) に
+-- 一元化し Viz 経路と共有する。 **要 chain ≥ 2** (1 本だと rank が自明に一様ゆえ空図)。
+rankOf :: HBMModel -> [VisualSpec]
+rankOf = rankOfBins defaultRankBins
+
+-- | ビン数を明示する 'rankOf'。
+rankOfBins :: Int -> HBMModel -> [VisualSpec]
+rankOfBins nBins hbm =
+  [ rankSpec nm | nm <- hbmParamNames hbm ]
+  where
+    chains = hbmChainsR hbm
+    nCh    = length chains
+    rankSpec nm =
+      let perChain = map (chainVals nm) chains
+      in if nCh < 2 || all null perChain
+           then mempty
+           else
+             -- chain を横並び (dodge) にした 1 層の bar (= ArviZ plot_rank の単一パネル版)。
+             -- long-form: (bin, count, chain) を chain×bin 行で展開し colorBy + PosDodge。
+             let hists    = rankHist nBins perChain                 -- [chain][bin]
+                 -- ビンは categorical だが軸はアルファベット順ゆえ、 数値順を保つよう
+                 -- 0 埋めラベル ("00".."19") にする (= 文字列ソート = 数値順)。
+                 w        = length (show (nBins - 1))
+                 pad i    = let s = show (i :: Int)
+                            in T.pack (replicate (w - length s) '0' ++ s)
+                 binCat   = concat [ [ pad b | b <- [0 .. nBins - 1] ] | _ <- [1 .. nCh] ]
+                 cntCol   = concatMap (map fromIntegral) hists :: [Double]
+                 chainCat = concat [ replicate nBins (T.pack ("chain " <> show k))
+                                   | k <- [0 .. nCh - 1] ]
+             -- y 軸ラベル・凡例は省く (図が潰れるため。 chain は色 dodge で判別可)。
+             -- colorBy は既定で凡例を出すので legendOff で明示的に抑制する。
+             in layer ( bar (inlineCat binCat) (inline cntCol)
+                        <> colorBy (inlineCat chainCat)
+                        <> position PosDodge )
+                  <> title nm <> xLabel "rank bin" <> legendOff
+
+
+-- | 係数 forest plot の描画仕様。 'HBMModel' を直接 'Plottable' にしないため、
+-- 抽出後の図を包む薄い newtype (後続 sub の ppc/epred/dag も同型に揃える)。
+newtype ForestSpec = ForestSpec { unForestSpec :: VisualSpec }
+
+instance Plottable ForestSpec where
+  toPlot = unForestSpec
+
+-- | 各 latent パラメータの事後区間を 1 枚の forest plot にする (94% HDI 既定)。
+forestOf :: HBMModel -> ForestSpec
+forestOf = forestOfLevel 0.94
+
+-- | 信頼水準を明示する 'forestOf'。 point = 事後平均、 bar 半幅 = HDI 半幅。
+--
+-- ★ 'forest' mark は対称 CI (± 半幅) のみ対応するため、 非対称な HDI は
+-- 「事後平均 ± (hi−lo)/2」 の対称バーで近似表示する (mark 側の TODO = 非対称 forest)。
+forestOfLevel :: Double -> HBMModel -> ForestSpec
+forestOfLevel level hbm = ForestSpec $
+  layer (forest (inlineCat names) (inline ests) (inline errs) <> forestNull 0)
+  where
+    names = hbmParamNames hbm
+    rows  = [ (mean, (hi - lo) / 2)
+            | nm <- names
+            , let d        = hbmDraws nm hbm
+                  mean     = if null d then 0 else sum d / fromIntegral (length d)
+                  (lo, hi) = highestDensityInterval level d
+            ]
+    ests = map fst rows
+    errs = map snd rows
+
+-- ===========================================================================
+-- HBM の事後予測平均 — Phase 49 A3 (epred = E[y|x] の grid 評価 + HDI band)
+--
+-- ベイズ回帰の代表図。 予測子 (@predName@、 学習時 'dataNamed' の参照名) を grid 上で
+-- 1 点ずつ動かし、 各 posterior draw でモデル中の deterministic ノード (@muName@、
+-- 通常は線形予測子の平均 μ) を 'runDeterministics' で評価する。 これで grid 点ごとに
+-- N draws 分の μ サンプルが得られ、 その **事後平均** (線) と **94% HDI** (帯、 ArviZ 既定)
+-- を描く。 これは PyMC の @pm.sample_posterior_predictive@ で得る epred (expected value of
+-- the posterior predictive) に相当する (観測ノイズを含まない平均の不確実性)。
+--
+-- ★ O1 規約: epred 用モデルは予測子を @dataNamed predName@ で受け、 その平均を
+-- @deterministic muName@ で 1 点スカラとして公開する (学習 likelihood とは併存)。
+-- grid 評価では @withData predName [xi]@ で 1 点に差し替えるため、 deterministic 内で
+-- @head x@ を取れば @xi@ が読める。
+--
+-- ★ Phase 74: 多予測子の hold。 非軸の予測子 slot は、 既定では 'HoldAgg' に従って
+-- bind データの集約値 ('Mean' 既定) で固定する (旧実装は bind データ先頭値 @head@ に
+-- 固定で選択不能だった)。 頻度論 effect plot ('statModelMulti') と **同じ語彙**を共有:
+--   * @epred fit "x1" "mu" \<\> holdAt Median@         … 非軸を中央値で固定
+--   * @epred fit "x1" "mu" \<\> holdAt (Fixed [("x2", 5)])@  … x2 のみ 5・他は Mean
+--   * @epred fit "x1" "mu" \<\> byVar "x2" [0, 1]@      … x2 の水準別に曲線色分け重畳
+-- 'holdAt' / 'byVar' は 'Hanalyze.Plot.Core' の既存コンビネータ (= ModelSpec の
+-- @msHoldAt@ / @msByVar@ を設定) をそのまま使う (epred 専用版は作らない)。
+--
+-- ★ 設計: 専用 newtype を作らず **'ModelSpec' を再利用**する (record は描画クロージャの
+-- 容れ物で 'SingleVarModel' 束縛ではない)。 これにより @epred hbm "x" "mu" \<\> grid 200
+-- \<\> statLevel 0.9@ が Phase 16 C1 のコンビネータと同綴りで合成できる (既定 level 0.94 と
+-- 帯 ON = ArviZ 流の HDI 帯を焼き込む。 epred の帯はオプトアウト不可)。
+-- ===========================================================================
+
+-- | 1 つの予測子値 @x@ における事後予測平均と HDI (非軸予測子は bind データのまま)。
+-- @predName@ を @[x]@ に差し替え、 全 chain の各 draw で deterministic @muName@ を
+-- 評価し、 (事後平均, (lo, hi)) を返す。 非軸予測子を固定する版は 'epredAtHeld'。
+epredAt
+  :: HBMModel
+  -> Text     -- ^ 予測子の data 参照名 (@dataNamed@ / @withData@ の名前)。
+  -> Text     -- ^ 平均の deterministic ノード名。
+  -> Double   -- ^ HDI 水準 (例 0.94)。
+  -> Double   -- ^ 予測子値 x。
+  -> (Double, (Double, Double))
+epredAt hbm = epredAtHeld hbm []
+
+-- | (slot 名, 固定値) のリストを @withData@ で 1 点ずつ bind してネストする。
+-- 'ModelP' は impredicative (@forall a. Model a r@) ゆえ foldr では多相が逃げる。
+-- トップレベル再帰なら各 'withData' が @ModelP r -> ModelP r@ を保つので通る。
+bindHolds :: [(Text, Double)] -> ModelP r -> ModelP r
+bindHolds []              m = m
+bindHolds ((nm, v) : rest) m = withData nm [v] (bindHolds rest m)
+
+-- | 'epredAt' の多予測子版。 @holds@ = 非軸予測子の (slot 名, 固定値) を 1 点ずつ
+-- @withData@ で bind し ('head' でその値が読める)、 軸 @predName@ を @[gx]@ に差し替える。
+epredAtHeld
+  :: HBMModel
+  -> [(Text, Double)]   -- ^ 非軸予測子の固定 (slot 名, 値)。
+  -> Text -> Text -> Double -> Double
+  -> (Double, (Double, Double))
+epredAtHeld hbm holds predName muName level gx =
+  let bound :: ModelP ()
+      bound = withData predName [gx] (bindHolds holds (hbmModelSpec hbm))
+      draws = concatMap chainSamples (hbmChainsR hbm)
+      mus   = [ v | ps <- draws
+                  , Just v <- [Map.lookup muName (runDeterministics bound ps)] ]
+      mean  = if null mus then 0 else sum mus / fromIntegral (length mus)
+  in (mean, highestDensityInterval level mus)
+
+-- | grid 点 @gx@ における事後予測区間 (PI = 観測ノイズ込みの新規 1 点の HDI)。
+-- 'epredAtHeld' が deterministic μ の HDI (= CI 相当) を返すのに対し、 こちらは
+-- **観測ノードの予測分布から y をサンプルしてプール**し HDI を取る。 観測ノード名は
+-- 引数に取らず 'runObserveDists' でモデルから自動検出する (頻度論 'svGridPI' が obs 名を
+-- 要らないのと対称)。 単一 likelihood の通常ケースが対象で、 observe が複数なら全プール。
+-- 任意の観測分布 (Normal/Poisson/NegBinom…) に効く ('ppc' の 'sampleDist' を再利用)。
+-- @runST@ + 固定 seed (既定 'epredPISeed' = 42・'ppcOfWith' と同方式) で純粋・決定的。
+epredPIAtHeld
+  :: HBMModel
+  -> [(Text, Double)]   -- ^ 非軸予測子の固定 (slot 名, 値)。
+  -> Text               -- ^ 軸予測子の data 参照名。
+  -> Word32             -- ^ サンプリング seed。
+  -> Double             -- ^ HDI 水準。
+  -> Double             -- ^ 予測子値 x。
+  -> (Double, Double)
+epredPIAtHeld hbm holds predName seed level gx =
+  let bound :: ModelP ()
+      bound = withData predName [gx] (bindHolds holds (hbmModelSpec hbm))
+      draws = concatMap chainSamples (hbmChainsR hbm)
+      samples = runST $ do
+        gen <- initialize (V.singleton seed)
+        concat <$> mapM
+          (\ps ->
+             let nodes = [ (d, ys) | (_, d, ys) <- runObserveDists bound ps ]
+             in concat <$> mapM (\(d, ys) -> sampleObsRep gen d ys) nodes)
+          draws
+  in if null samples then (0, 0) else highestDensityInterval level samples
+
+-- | 'epredPIAtHeld' の既定サンプリング seed (純粋・決定的に閉じる。 'ppcOfWith' と同値)。
+epredPISeed :: Word32
+epredPISeed = 42
+
+-- | 非軸予測子 1 slot の固定値を 'HoldAgg' (+ byVar override) から決める。
+-- @override@ (byVar の明示固定) が 'HoldAgg' より優先。 HBM データは数値列ゆえ
+-- factor / Reference は無く、 Reference\/Marginalize は安全側に Mean とする
+-- (Marginalize の真の周辺化は epred では未対応)。
+epredHoldValue :: HoldAgg -> Text -> [Double] -> [(Text, Double)] -> Double
+epredHoldValue hold nm vs override =
+  case lookup nm override of
+    Just v  -> v
+    Nothing -> case hold of
+      Mean        -> meanL vs
+      Median      -> medianL vs
+      Mode        -> medianL vs                       -- 数値連続に最頻は無意味 → 中央値で代替
+      Reference   -> meanL vs
+      Marginalize -> meanL vs
+      Fixed fm    -> fromMaybe (meanL vs) (lookup nm fm)
+  where
+    meanL xs   = if null xs then 0 else sum xs / fromIntegral (length xs)
+    medianL xs = case xs of
+      [] -> 0
+      _  -> let s = sortBy compare xs
+                k = length s
+            in if even k
+                 then (s !! (k `div` 2 - 1) + s !! (k `div` 2)) / 2
+                 else s !! (k `div` 2)
+
+-- | grid 上の事後予測平均線 + HDI 帯を組む 'GridOpts' クロージャ ('epred' が設定)。
+-- 'renderGridMulti' (頻度論 effect plot) と同型: 非軸予測子を 'goHoldAt' で固定し、
+-- 'goByVar' があれば第2予測子の水準ごとに曲線を色分け重畳する。 各曲線は帯 (先) +
+-- 線 (後)。 'goPredAt' 指定点は lineRange (区間) + scatter (事後平均) で重畳する。
+-- 帯は非対称な HDI を lo/hi で忠実に描く。
+renderEpred :: HBMModel -> Text -> Text -> GridOpts -> VisualSpec
+renderEpred hbm predName muName opts =
+  let (lo0, hi0) = epredPredRange hbm predName
+      (lo, hi)   = fromMaybe (lo0, hi0) (goRange opts)
+      n          = max 2 (goN opts)
+      gxs        = linspace lo hi n
+      level      = goLevel opts
+      hold       = goHoldAt opts
+      -- 非軸予測子 (= hbmData の predName 以外の slot) を HoldAgg + byVar override で固定。
+      -- 応答列も含むが deterministic μ は応答に依存しないため無害。
+      holdBinds override =
+        [ (nm, epredHoldValue hold nm vs override)
+        | (nm, vs) <- hbmData hbm, nm /= predName ]
+      -- 1 曲線分 (override = byVar 固定の (名,値)、 mCol = 線/帯色)。
+      -- BandMode で CI (μ HDI) / PI (観測ノイズ込み) / CIPI (入れ子) / なし を切替
+      -- (頻度論 'renderGrid' と同型)。 PI 系は遅延ゆえ CI/Off では評価されない。
+      oneCurve override mCol =
+        let holds = holdBinds override
+            rows  = map (epredAtHeld hbm holds predName muName level) gxs
+            mu    = map fst rows
+            ciLos = map (fst . snd) rows
+            ciHis = map (snd . snd) rows
+            piPairs = map (epredPIAtHeld hbm holds predName epredPISeed level) gxs
+            piLos = map fst piPairs
+            piHis = map snd piPairs
+            lineL = layer (goLineDeco opts mCol n (line (inline gxs) (inline mu)))
+            ciDeco = goBandDeco opts mCol
+            -- 入れ子時の PI 帯は薄め (CI が内側で見えるように・頻度論と同じ既定)。
+            piA    = maybe 0.10 (* 0.5) (goAlpha opts)
+            piDeco = goBandDeco (opts { goAlpha = Just piA }) mCol
+            mkBand deco los his = layer (deco (band (inline gxs) (inline los) (inline his)))
+        in case goBandMode opts of
+             BandOff  -> lineL
+             BandCI   -> mkBand ciDeco ciLos ciHis <> lineL
+             BandPI   -> mkBand ciDeco piLos piHis <> lineL
+             BandCIPI -> mkBand piDeco piLos piHis        -- 外: PI 薄 (下)
+                      <> mkBand ciDeco ciLos ciHis        -- 内: CI 濃 (上)
+                      <> lineL
+      curves = case goByVar opts of
+        Nothing         -> oneCurve [] Nothing
+        Just (v2, vals) ->
+          foldMap
+            (\(i, val) ->
+               let col = fromHex (effectPalette !! (i `mod` length effectPalette))
+               in oneCurve [(v2, val)] (Just col))
+            (zip [0 :: Int ..] vals)
+      pts = goPredAt opts
+      predLayers
+        | null pts  = mempty
+        | otherwise =
+            let prows = map (epredAtHeld hbm (holdBinds []) predName muName level) pts
+                pmu   = map fst prows
+                mids  = map (\(_, (l, h)) -> (l + h) / 2) prows
+                halfs = map (\(_, (l, h)) -> (h - l) / 2) prows
+            in layer (lineRange (inline pts) (inline mids) (inline halfs))
+                 <> layer (scatter (inline pts) (inline pmu))
+  in curves <> predLayers <> labelLegend opts
+
+-- | 予測子列の観測範囲 (grid 既定範囲)。 bind 済みデータ ('hbmData') から引く。
+epredPredRange :: HBMModel -> Text -> (Double, Double)
+epredPredRange hbm predName =
+  case lookup predName (hbmData hbm) of
+    Just vs | not (null vs) -> (minimum vs, maximum vs)
+    _                       -> (0, 1)
+
+-- | HBM の事後予測平均 (E[y|x]) を grid 評価する 'ModelSpec' を作る。 既定は 94% HDI 帯
+-- (ArviZ 流・帯 ON 焼き込み)、 grid 100 点、 範囲 = 予測子の観測 min/max。 @\<\>@ で
+-- 'grid' / 'gridRange' / 'statLevel' / 'predAt' を合成できる (Phase 16 C1 と同綴り)。
+--
+-- @
+-- noDf |>> toPlot (epred fit \"x\" \"mu\" \<\> grid 200 \<\> statLevel 0.9)
+-- @
+epred
+  :: HBMModel
+  -> Text   -- ^ 予測子の data 参照名。
+  -> Text   -- ^ 平均の deterministic ノード名。
+  -> ModelSpec
+epred hbm predName muName = mempty
+  { msRender = Just (renderEpred hbm predName muName)
+  , msLevel  = Just 0.94          -- ArviZ 既定の 94% HDI (statLevel で上書き可)
+  , msBandMode = Just BandCI      -- HDI 帯が epred の本体ゆえ既定で出す
+  }
+
+-- ===========================================================================
+-- HBM の事後予測チェック — Phase 49 A4 (ppc = posterior predictive check)
+--
+-- 観測 y の分布に対して、 学習済モデルが再現する複製データ y_rep の分布を重ねる
+-- (ArviZ @az.plot_ppc@ 相当)。 各 posterior draw について 'runObserveDists' で
+-- observe ノードの分布 (= 観測ノイズ込みの予測分布) を取り出し、 'sampleDist' で
+-- 1 セット y_rep をサンプリングする。 これを N draw 分重ねると「観測がモデルの予測
+-- 分布の典型から外れていないか」 を目視できる。
+--
+-- 描画 (ArviZ 流):
+--   * 観測 density (濃色・実線)            … 実データ。
+--   * y_rep density を N 本 (薄色・低 alpha) … 各 draw の複製データ。
+--   * プール y_rep density (破線)          … 事後予測分布全体 (= ppc の中心)。
+--
+-- ★ サンプリングに RNG が要るため 'IO' (頻度論 toPlot や epred/forest と違い純粋に
+-- できない)。 'hbmModel' 自体 IO なので非対称ではない。 cumulative 版は density を
+-- 'ecdf' に差し替える ('ppcCumulative')。
+-- ===========================================================================
+
+-- | ppc の設定: 重ねる複製データ本数 ('ppcReps')、 乱数シード、 累積版 (ecdf) 切替。
+data PPCConfig = PPCConfig
+  { ppcReps       :: !Int            -- ^ 重ねる y_rep 本数 (既定 40・draw から等間隔抽出)。
+  , ppcSeed       :: !(Maybe Word32) -- ^ サンプリングのシード (Nothing = system)。
+  , ppcCumulative :: !Bool           -- ^ True で density を ecdf (累積分布) に差し替える。
+  } deriving (Show, Eq)
+
+-- | 既定 ppc 設定: y_rep 40 本・system 乱数・density 表示。
+defaultPPC :: PPCConfig
+defaultPPC = PPCConfig { ppcReps = 40, ppcSeed = Nothing, ppcCumulative = False }
+
+-- | 事後予測チェック plot の描画仕様 ('forestOf' 等と同型の薄い newtype)。
+newtype PPCSpec = PPCSpec { unPPCSpec :: VisualSpec }
+
+instance Plottable PPCSpec where
+  toPlot = unPPCSpec
+
+-- | observe ノード名が prefix に一致するか。 単一 @observe \"obs\"@ (n == prefix) と
+-- 'observeColumns' 由来の @\"obs_0\"@.. (prefix <> \"_\" が接頭辞) の両方を拾う。
+ppcMatches :: Text -> Text -> Bool
+ppcMatches prefix n = n == prefix || (prefix <> "_") `T.isPrefixOf` n
+
+-- | 1 draw 分の複製データ y_rep をサンプリングする。 prefix 一致の各 observe ノードの
+-- 分布から、 観測値と同数だけ引いてプールする。 Phase 50: 'PrimMonad' に一般化
+-- (IO でも ST でも引ける → 純粋な 'ppcOf' が runST で決定的にサンプリングできる)。
+sampleYRep :: PrimMonad m
+           => Gen (PrimState m) -> ModelP () -> Text -> Map.Map Text Double -> m [Double]
+sampleYRep gen spec prefix ps =
+  let nodes = [ (d, ys) | (n, d, ys) <- runObserveDists spec ps, ppcMatches prefix n ]
+  in concat <$> mapM (\(d, ys) -> sampleObsRep gen d ys) nodes
+
+-- | 観測値 (prefix 一致 observe ノードの ys をプール)。 params に依らないので任意 draw から。
+ppcObserved :: HBMModel -> Text -> [Double]
+ppcObserved hbm prefix =
+  case concatMap chainSamples (hbmChainsR hbm) of
+    (p0:_) -> concat [ ys | (n, _, ys) <- runObserveDists (hbmModelSpec hbm) p0
+                          , ppcMatches prefix n ]
+    []     -> []
+
+-- | ppc の対象 draw 群 ('ppcReps' 本に間引き)。
+ppcDrawsFor :: PPCConfig -> HBMModel -> [Map.Map Text Double]
+ppcDrawsFor cfg hbm = selectEvenly (ppcReps cfg) (concatMap chainSamples (hbmChainsR hbm))
+
+-- | 観測値・y_rep 群から ppc plot を組む (純粋)。 薄い y_rep 群 (背景・各 draw) を先に、
+-- 観測 (濃) を上に重ねる。 純粋 'ppcOfWith' と IO 'ppcOfWithIO' で共有。
+--
+-- ★ 旧実装はプール y_rep (全 draw 連結) の密度を赤破線で重ねていたが、 KDE の Silverman
+-- バンド幅が **n 依存** (@h ∝ n^(-0.2)@) ゆえ、 n=Σ(draw×n_obs) のプールは観測 (n=n_obs) より
+-- バンド幅が小さく過小平滑になり、 観測と異なる形 (外側へ膨らむ) に見えて誤解を招いた。
+-- 比較は観測 (黒) vs 各 draw の y_rep (青・同じ n) で行うべきなので、 プール線は削除した
+-- (ArviZ @plot_ppc@ もプール KDE は描かない)。
+buildPPCSpec :: PPCConfig -> [Double] -> [[Double]] -> PPCSpec
+buildPPCSpec cfg observed yreps =
+  let densLayer = if ppcCumulative cfg then ecdf else density
+      repLayers = foldMap
+        (\yr -> layer (densLayer (inline yr) <> color (fromHex "#1f77b4") <> alpha 0.15))
+        yreps
+      obsLayer    = layer (densLayer (inline observed) <> color (fromHex "#000000"))
+  in PPCSpec (repLayers <> obsLayer)
+
+-- | draw 列から 'ppcReps' 本を等間隔で抽出する (本数以下ならそのまま)。
+selectEvenly :: Int -> [a] -> [a]
+selectEvenly k xs
+  | k <= 0 || n <= k = xs
+  | otherwise        = [ xs !! (i * n `div` k) | i <- [0 .. k - 1] ]
+  where n = length xs
+
+-- | 既定設定の事後予測チェック (純粋・決定的が**正本**。 'ppcOfWith' 'defaultPPC')。
+-- y_rep サンプリングを @runST@ で閉じ、 @ppcSeed@ 既定 (42) で常に再現可能。 IO 版は 'ppcOfIO'。
+ppcOf :: HBMModel -> Text -> PPCSpec
+ppcOf = ppcOfWith defaultPPC
+
+-- | 事後予測チェックを組む (純粋・正本)。 @prefix@ は observe ノード名 (@observeColumns@ なら接頭辞)。
+-- y_rep サンプリングを @runST@ で閉じる。 @ppcSeed@ が 'Nothing' のときは固定既定 seed (42) で再現可能。
+ppcOfWith :: PPCConfig -> HBMModel -> Text -> PPCSpec
+ppcOfWith cfg hbm prefix =
+  let spec :: ModelP ()
+      spec  = hbmModelSpec hbm
+      draws = ppcDrawsFor cfg hbm
+      seed  = fromMaybe 42 (ppcSeed cfg)
+      yreps = runST $ do
+        gen <- initialize (V.singleton seed)
+        mapM (sampleYRep gen spec prefix) draws
+  in buildPPCSpec cfg (ppcObserved hbm prefix) yreps
+
+-- | 既定設定の事後予測チェック (IO 版・'ppcOfWithIO' 'defaultPPC')。 通常は純粋な 'ppcOf' を使う
+-- (将来 deprecate 予定)。 @ppcSeed@ 'Nothing' でシステム乱数を引きたいときだけ IO 版が要る。
+ppcOfIO :: HBMModel -> Text -> IO PPCSpec
+ppcOfIO = ppcOfWithIO defaultPPC
+
+-- | 事後予測チェックを組む (IO 版)。 @ppcSeed@ 'Nothing' で 'createSystemRandom' を引く。
+ppcOfWithIO :: PPCConfig -> HBMModel -> Text -> IO PPCSpec
+ppcOfWithIO cfg hbm prefix = do
+  let spec :: ModelP ()
+      spec  = hbmModelSpec hbm
+      draws = ppcDrawsFor cfg hbm
+  gen <- case ppcSeed cfg of
+           Nothing -> createSystemRandom
+           Just w  -> initialize (V.singleton w)
+  yreps <- mapM (sampleYRep gen spec prefix) draws
+  pure $ buildPPCSpec cfg (ppcObserved hbm prefix) yreps
+
+-- ===========================================================================
+-- HBM 診断ダッシュボード — 複数の抽出子を 1 枚に束ねる便宜関数 (Phase 74.8)
+--
+-- 個別の抽出子 (dagOf / forestOf / ppcOf / energyOf / tracesOf / marginalsOf) を
+-- 'subplots' で並べ「構造・推定・当てはまり・収束を一目で点検する」 パネル束にする。 2 種:
+--   * 'dashboardOf'     … コンパクト 2×2 (構造 / 推定値 / 当てはまり / サンプラ健全性)。
+--                          各 1 パネルゆえ param 数に依らず一定で見やすい。
+--   * 'dashboardFullOf' … 上段に同じ 2×2、 その下に param ごと [事後分布 | trace] を 2 列で
+--                          連結 (ArviZ @plot_trace@ 流)。 係数が増えると下へ行が増えるだけ。
+-- どちらも observe ノード名を引数に取る (ppc 用)。 @noDf |>> dashboardOf m "obs"@。
+-- autocorr/rank はダッシュボードに入れない (mixing は trace・BFMI は energy で見えるため。
+-- ESS 定量は個別 'autocorrOf'、 chain 一様性は 'rankOf' で見る)。
+-- ===========================================================================
+
+-- | コンパクト健全性 2×2 のパネル群 (左上から 構造 / 推定値 / 当てはまり / サンプラ健全性)。
+-- 'dashboardOf' (単体) と 'dashboardFullOf' (上段) で共有する内部ヘルパ。
+dashboardHealthPanels :: HBMModel -> Text -> [VisualSpec]
+dashboardHealthPanels hbm obsName =
+  [ toPlot (dagOf hbm)         <> title "構造 (DAG)"
+  , toPlot (forestOf hbm)      <> title "推定値 (forest 94% HDI)"
+  , toPlot (ppcOf hbm obsName) <> title "当てはまり (PPC: 観測 vs 事後予測)"
+  , energyOf hbm               <> title "サンプラ健全性 (energy / BFMI)" ]
+
+-- | コンパクトな HBM 診断ダッシュボード (2×2)。 **構造** ('dagOf'・左上)・**推定値**
+-- ('forestOf'・94% HDI)・**当てはまり** ('ppcOf'・観測 vs 事後予測の密度重ね)・**サンプラ
+-- 健全性** ('energyOf'・BFMI) を 1 パネルずつ。 各 1 パネルゆえ param 数に依らず見やすい
+-- (係数が増えても forest が縦に密になるだけ。 収束 R̂/trace は 'dashboardFullOf' で見る)。
+dashboardOf :: HBMModel -> Text -> VisualSpec
+dashboardOf hbm obsName =
+  subplots (dashboardHealthPanels hbm obsName)
+    <> subplotCols 2 <> width 1100 <> height 760
+
+-- | param ごと **[事後分布 (左) | trace (右)]** のパネル群 (ArviZ @plot_trace@ の中身)。
+-- 'traceDensityOf' (単体) と 'dashboardFullOf' (下段) で共有する内部ヘルパ。 事後分布・
+-- trace とも chain 別を色違いで重畳する ('marginalsByChainOf' / 'tracesOfWith' byChain)。
+tracePostPanels :: HBMModel -> [VisualSpec]
+tracePostPanels hbm =
+  concat (zipWith (\p t -> [p, t])
+            (marginalsByChainOf hbm)
+            (tracesOfWith defaultTraceOpts { toByChain = True } hbm))
+
+-- | trace と事後分布だけのダッシュボード (= ArviZ @plot_trace@ 相当)。 param ごとに
+-- **[事後分布 (左) | trace (右)]** を 2 列で並べる (chain は色違いで重畳)。 収束 (定常・
+-- chain 一致) と事後の形を同時に確認する定番。 係数が増えると下に行が増える。
+traceDensityOf :: HBMModel -> VisualSpec
+traceDensityOf hbm =
+  let np = max 1 (length (hbmParamNames hbm))
+  in subplots (tracePostPanels hbm)
+       <> subplotCols 2 <> width 900 <> height (180 * fromIntegral np)
+
+-- | フルの HBM 診断ダッシュボード。 上段に 'dashboardOf' と同じ健全性 2×2、 その下に
+-- param ごと **[事後分布 (左) | trace (右)]** を 2 列で連結する (ArviZ @plot_trace@ 流・
+-- chain は色違いで重畳)。 全体が 1 つの 2 列グリッドなので、 **係数が増えると下に行が
+-- 増えるだけ** (高さを行数 = 2 + param 数 に比例させ各パネルを潰さない)。 epred (予測曲線)
+-- はモデル固有の予測子/平均ノード名と df が要るためここには含めない (個別に描く)。
+dashboardFullOf :: HBMModel -> Text -> VisualSpec
+dashboardFullOf hbm obsName =
+  let np   = max 1 (length (hbmParamNames hbm))
+      rows = 2 + np                                  -- 健全性 2 行 + param 行
+  in subplots (dashboardHealthPanels hbm obsName ++ tracePostPanels hbm)
+       <> subplotCols 2 <> width 1100 <> height (220 * fromIntegral rows)
+
+-- ===========================================================================
+-- HBM のモデル構造 DAG — Phase 49 A5 (dag = 確率プログラムの依存グラフ)
+--
+-- 確率プログラム ('ModelP') の依存構造を 'buildModelGraph' (= 'extractDeps' +
+-- 同名ノード統合) で 'ModelGraph' (nodes / edges / plates) にし、 plot-core の
+-- DAG 描画 ('dagFromListsWithPlates'、 Sugiyama 階層 layout) に橋渡しする。 PyMC の
+-- @pm.model_to_graphviz@ に相当する「モデルの絵」。
+--
+-- ノード種 (latent / observed) と分布名は 'Node' のメタデータをそのまま 'DAGNode' に
+-- 写す。 plate ('plate' で囲んだ繰り返し) は 'mgPlates' を 'DAGPlate' に変換する
+-- (plate メンバは 'nodePlates' から逆引き)。 plate を使わないモデルでは
+-- 'observeColumns' 由来の @obs_0..@ が個別ノードとして出る (collapse したい場合は
+-- モデル側を 'plate' で囲む)。
+-- ===========================================================================
+
+-- | モデル構造 DAG の描画仕様 ('forestOf' 等と同型の薄い newtype)。
+newtype DagSpec = DagSpec { unDagSpec :: VisualSpec }
+
+instance Plottable DagSpec where
+  toPlot = unDagSpec
+
+-- | 学習済モデルの構造を DAG にする ('buildModelGraph' → plate-collapse →
+-- plot-core DAG)。 layout は階層 ('LayoutHierarchical')。 学習結果には依存しない
+-- (構造のみ)。 Phase 59.3: plate 内の indexed RV (@b0_0..b0_2@ 等) を
+-- 'collapseIndexedPlateNodes' で 1 ノードに畳むのが既定 (PyMC
+-- @model_to_graphviz@ と同じ見た目)。 indexed 個別ノードのまま見たい場合は
+-- 'dagOfRaw'。
+dagOf :: HBMModel -> DagSpec
+dagOf = dagFromModelGraph . collapseIndexedPlateNodes . buildModelGraph . hbmModelSpec
+
+-- | 'dagOf' の plate-collapse 無し版 (Phase 49-59.2 の旧既定。 plate 内 indexed RV を
+-- 個別ノードで列挙する。 展開後の全ノード/エッジを確認するデバッグ用)。
+dagOfRaw :: HBMModel -> DagSpec
+dagOfRaw = dagFromModelGraph . buildModelGraph . hbmModelSpec
+
+-- | **学習前**にモデル構造だけを DAG にする (PyMC @pm.model_to_graphviz@ 相当。 Phase 74.9)。
+-- 'dagOf' が学習済 'HBMModel' を取るのに対し、 こちらは生の 'ModelP' を直接取り
+-- **サンプリングを一切しない** (構造は事後に依らないため)。 @noDf |>> toPlot (dagOfModel m)@。
+--
+-- ★ 注意: データ駆動 plate (@plateForM_@ / @observeColumns@ で plate サイズを **データ長**から
+-- 決めるモデル) は、 データ未束縛 (slot が @[]@) だとループ本体が回らず plate 内ノード
+-- (mu / obs 等) が出ない。 その場合は 'dagOfModelWith' でダミーでないデータを束ねてから描く
+-- (サンプリングは走らない)。 明示 plate (@plate name N@ / @plateI@ で N を直書き) のモデルは
+-- データ無しでも構造が完全に出る。
+dagOfModel :: ModelP () -> DagSpec
+dagOfModel = dagFromModelGraph . collapseIndexedPlateNodes . buildModelGraph
+
+-- | 'dagOfModel' のデータ束ね版 (PyMC で観測を渡してから @model_to_graphviz@ する形)。
+-- @dat@ を 'bindCols' でモデルへ束ねてから DAG を組む = **データ駆動 plate のサイズが
+-- 正しく出る**。 'hbmModel' と同じ束ね方だが **NUTS は走らない** (学習前のプレビュー)。
+-- @noDf |>> toPlot (dagOfModelWith [("x", xs), ("y", ys)] m)@。
+dagOfModelWith :: [(Text, [Double])] -> ModelP () -> DagSpec
+dagOfModelWith dat = dagOfModel . bindCols dat
+
+-- | 'ModelGraph' → plot-core DAG 描画仕様 ('dagOf' / 'dagOfRaw' の共通部)。
+dagFromModelGraph :: ModelGraph -> DagSpec
+dagFromModelGraph mg =
+  -- ★ renderDAG は dnX/dnY をそのまま使い layout を実行しない。 ゆえに描画前に
+  -- Sugiyama 階層 layout ('layoutHierarchicalFullWithPlates') で座標を確定させる
+  -- (これを省くと全ノードが原点 (0,0) に重なる)。
+  let (positioned, routed) = layoutHierarchicalFullWithPlates dnodes dedges dplates
+  -- ★ HS=PS parity: routing を spec へ焼き込む (= 'deRoute' 充填)。 これが無いと PS canvas
+  --   は 'deRoute = Nothing' で直線フォールバックになり、 HS の live routing (曲線) と乖離する。
+  --   baking は area 非依存 (dagToScreen が 0..1 domain を正規化 pt 空間へ map・描画時に
+  --   fitPrimsToArea で affine fit) なので layout 直後のここで焼ける。
+  in DagSpec $ bakeDAGRoutesInSpec $
+       layer (dagFromListsWithPlates positioned routed LayoutHierarchical dplates)
+  where
+    ns = mgNodes mg
+    dnodes = map toDNode ns
+    dedges = [ DAGEdge { deFrom = p, deTo = c, dePath = Nothing, deRoute = Nothing }
+             | (p, c) <- mgEdges mg ]
+    dplates = [ DAGPlate
+                  { dpLabel   = nm <> " (" <> T.pack (show sz) <> ")"
+                  , dpNodeIds = [ nodeName n | n <- ns, nm `elem` nodePlates n ] }
+              | (nm, sz) <- Map.toList (mgPlates mg) ]
+    toDNode n = DAGNode
+      { dnId    = nodeName n
+      , dnLabel = nodeName n
+      , dnKind  = case nodeKind n of
+                    LatentN        -> NodeLatent
+                    ObservedN _    -> NodeObserved
+                    DeterministicN -> NodeDeterministic
+                    -- Phase 60.4: NodeData は plot-core に既実装 (Phase 26 §E-6)
+                    DataN _        -> NodeData
+      , dnDist  = Just (nodeDist n)
+      , dnX     = 0
+      , dnY     = 0
+      }
+
+-- | random-effect 第 @k@ 列の caterpillar plot。 BLUP を group ごとに取り、
+-- **値で昇順ソート**して forest mark (errs=0 の点) で並べ、 0 に 'forestNull' 参照線。
+caterpillarColumn :: GLMMResultRE -> Int -> VisualSpec
+caterpillarColumn res k =
+  let cols   = LA.toColumns (reBLUPs res)
+      blups  = if k >= 0 && k < length cols then LA.toList (cols !! k) else []
+      groups = V.toList (reGroups res)
+      sorted = sortBy (comparing snd) (zip groups blups)
+      gs     = map fst sorted
+      es     = map snd sorted
+      zeros  = map (const (0 :: Double)) es
+  in layer (forest (inlineCat gs) (inline es) (inline zeros) <> forestNull 0)
+       <> title ("Random effects (col " <> T.pack (show k) <> ")")
+
+instance Plottable GLMMResultRE where
+  -- 代表 1 枚 = 第 1 列 (通常 random intercept) の caterpillar。
+  toPlot res = caterpillarColumn res 0
+  -- 診断束 = 全 r 列 (intercept + slope) の caterpillar。
+  diagnosticPlots res =
+    [ caterpillarColumn res k | k <- [0 .. LA.cols (reBLUPs res) - 1] ]
+
+-- | HBM の事後予測平均 (epred) 応答曲面。 2 つの予測子 slot (@p1@, @p2@) を
+--   grid で動かし、 各点で deterministic @muName@ の事後平均を取る
+--   ('epredAt' の 2 変数版・O1 規約は 'renderEpred' の節を参照)。
+--   ★コスト = grid 点数² × 全 draw のモデル評価。 既定 n=30 (900 点)。
+epredSurfaceOf :: HBMModel -> Text -> Text -> Text -> P3.VisualSpec3D
+epredSurfaceOf hbm p1 p2 muName =
+  epredSurfaceOfWith hbm p1 p2 muName defaultSurfaceOpts { soN = 30 }
+
+epredSurfaceOfWith :: HBMModel -> Text -> Text -> Text -> SurfaceOpts -> P3.VisualSpec3D
+epredSurfaceOfWith hbm p1 p2 muName opts =
+  let (xlo, xhi) = fromMaybe (epredPredRange hbm p1) (soXRange opts)
+      (ylo, yhi) = fromMaybe (epredPredRange hbm p2) (soYRange opts)
+      n     = max 2 (soN opts)
+      gxs   = linspace xlo xhi n
+      gys   = linspace ylo yhi n
+      draws = concatMap chainSamples (hbmChainsR hbm)
+      muAt gx gy =
+        let bound :: ModelP ()
+            bound = withData p1 [gx] (withData p2 [gy] (hbmModelSpec hbm))
+            mus   = [ v | ps <- draws
+                        , Just v <- [Map.lookup muName (runDeterministics bound ps)] ]
+        in if null mus then 0 else sum mus / fromIntegral (length mus)
+      grid = [ [ muAt gx gy | gx <- gxs ] | gy <- gys ]
+  in P3.layer3D ( P3.surface3DGrid grid
+               <> P3.xRange3D (xlo, xhi)
+               <> P3.yRange3D (ylo, yhi)
+               <> P3.colormap3D )
+
+-- | 学習済 HBM が保持するデータ列 ('hbmData') から散布図層を作る (B10)。
+--
+-- @df |-> hbm cfg model@ で学習した後、 @dataScatterOf m \"x\" \"y\"@ で
+-- 観測散布図を出せるので、 epred\/forest 等の抽出子と重畳するとき
+-- **df を学習時 1 回だけ**書けばよい:
+--
+-- > let m = df |-> hbm defaultHBM model
+-- > noDf |>> (dataScatterOf m "x" "y" <> toPlot (epred m "x" "mu"))
+dataScatterOf :: HBMModel -> Text -> Text -> VisualSpec
+dataScatterOf m xn yn =
+  case (lookup xn (hbmData m), lookup yn (hbmData m)) of
+    (Just xs, Just ys) -> layer (scatter (inline xs) (inline ys))
+    _                  -> mempty
diff --git a/src/Hanalyze/Plot/Core.hs b/src/Hanalyze/Plot/Core.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Plot/Core.hs
@@ -0,0 +1,918 @@
+-- |
+-- Module      : Hanalyze.Plot.Core
+-- Description : hgg 連携層の共通基盤 (モデル族非依存のクラス・型・評価核)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- hgg 連携層の **共通基盤** (= モデル族非依存のクラス・型・評価核)。
+--
+-- ⚠ 本モジュールは親 'Hanalyze.Plot' と同じ cabal flag @plot-integration@
+-- (既定 off) を on にしたときのみ build される。 @hgg-core@ に依存するため
+-- **upstream hanalyze には cherry-pick しない**。
+--
+-- ここに集約するもの (Phase 71.4 確定):
+--
+--   * 図化能力の最終クラス 'Plottable'、 grid 評価クラス 'SingleVarModel' /
+--     'MultiVarModel'、 分類器抽象 'ClassPredict'。
+--   * grid 評価の仕様 'ModelSpec' (Semigroup\/Monoid)・確定オプション 'GridOpts'・
+--     ブートストラップ素材 'BootKit'、 および @statModel@\/@grid@\/@bandMode@ 等の
+--     合成子 (smart ctor)。
+--   * grid 評価核 ('renderGrid' \/ 'renderGridMulti' \/ 'bootstrapBands' \/ 'evalFrame'
+--     系)・応答曲面核 ('surfaceGrid' \/ 'surfaceOf' 系) と、 複数のモデル族が共有する
+--     描画 helper。
+--
+-- 各モデル族固有の @instance Plottable XxxModel@ 等は親 'Hanalyze.Plot' 側に
+-- 残置する (orphan instance を許容: クラスは Core・instance は Plot・型は Wrappers)。
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RankNTypes #-}
+{-# LANGUAGE TypeFamilies #-}
+{-# LANGUAGE FlexibleContexts #-}
+module Hanalyze.Plot.Core
+  ( -- * Plottable protocol
+    Plottable (..)
+    -- * ルート1 grid 評価 (ModelSpec)
+  , ModelSpec (..)
+  , GridOpts (..)
+  , BootKit (..)
+  , SingleVarModel (..)
+  , MultiVarModel (..)
+    -- ** 合成子 (smart ctor)
+  , statModel
+  , grid
+  , gridRange
+  , bandMode
+  , piMethod
+  , statColor
+  , statFill
+  , statLinetype
+  , statLinewidth
+  , statAlpha
+  , statLabel
+  , statEquation
+  , statR2
+  , statLevel
+  , holdAt
+  , byVar
+  , predAt
+  , statModelMulti
+    -- ** 描画 deco / 凡例 helper
+  , goLineDeco
+  , goBandDeco
+  , labelLegend
+  , fitLabelText
+    -- ** grid 評価核
+  , bootstrapBands
+  , renderGrid
+  , renderGridMulti
+  , marginalizeCurve
+  , alongRange
+  , evalFrame
+  , setAlong
+  , isResponseRole
+  , holdRole
+  , fixedRole
+  , clampIdx
+  , effectPalette
+    -- * 応答曲面 3D 核
+  , evalFrame2
+  , surfaceGrid
+  , chunkRows
+  , surfaceOf
+  , surfaceOfWith
+  , dataScatter3DOf
+    -- * 集約 helper (連続列の代表値)
+  , meanV
+  , medianV
+  , modeV
+  , modeIdx
+  , mostCommon
+    -- * 共有描画 helper (複数族が利用)
+  , defaultCILevel
+  , quantilePalette
+  , stepVerts
+  , gridCurves
+  , importanceBar
+  , matCols2
+  , classMeansScatter
+  , classMeansScatterNamed
+  , chainColor
+    -- * 分類器抽象
+  , ClassPredict (..)
+    -- * 回帰診断の可視化 (係数 forest / 実測vs予測) — Phase 72.4/72.5
+  , HasObsPred (..)
+  , obsVsPred
+  , obsPredSpec
+  , coefForest
+  ) where
+
+import           Control.Applicative   ((<|>))
+import           Data.List             (group, maximumBy, sort, transpose)
+import           Data.Maybe            (fromMaybe)
+import           Data.Ord              (comparing)
+import           Data.Word             (Word32)
+import qualified Data.Vector           as V
+import           System.Random.MWC     (initialize, uniformR)
+import           Control.Monad.ST      (runST)
+import           Control.Monad         (replicateM)
+import           Hanalyze.Model.HBM.Interp (percentileOf)
+import           Hanalyze.Model.HBM.Sampling (sampleDist)
+import qualified Hanalyze.Model.HBM.Distribution as BD
+import qualified Numeric.LinearAlgebra as LA
+
+import           Data.Text             (Text)
+import qualified Data.Text             as T
+
+import           Hgg.Plot.Spec     ( VisualSpec, Layer, layer, inline, inlineCat
+                                       , Color (..), fromHex
+                                       , scatter, line, band
+                                       , shape, MarkShape (..)
+                                       , color, colorBy, lineRange, bar
+                                       , scaleColorManual, legend
+                                       , forest, forestNull
+                                       , xLabel, yLabel
+                                       , LineType (..)
+                                       , linetype, alpha, stroke )
+import           Hanalyze.Diagnostics ( CoefRow (..), HasCoefSummary (..) )
+import           Hgg.Plot.Unit     (pt', (*~))
+import qualified Hgg.Plot.ThreeD.Spec  as P3
+import           Hgg.Plot.ThreeD.Types (Point3 (..))
+import           Hgg.Plot.Color        (toCss)
+
+import           Hanalyze.Model.Wrappers
+import           Hanalyze.Model.Formula.Frame   (ModelFrame (..), VarRole (..))
+import           Hanalyze.Model.LM     (linspace)
+import           Numeric               (showFFloat)
+
+-- ===========================================================================
+-- Plottable protocol
+-- ===========================================================================
+
+-- | 解析オブジェクトを図 ('VisualSpec') に変換できる能力。
+--
+-- 能力差は中立 protocol ('Hanalyze.Model.Core' の 'ResidualModel' /
+-- 'PredictiveModel') 側に持たせ、 ここは「図にできる」 という最終能力のみを表す。
+class Plottable m where
+  -- | 代表 1 枚の図 (= layer 重畳の主役、 @<>@ で他 layer と合成可)。
+  toPlot          :: m -> VisualSpec
+
+  -- | 診断図の束 (= レポート用)。 既定は代表 1 枚のみ。
+  diagnosticPlots :: m -> [VisualSpec]
+  diagnosticPlots m = [toPlot m]
+
+-- ===========================================================================
+-- ルート1 grid 評価 (ModelSpec) — Phase 16 §3 C1
+--
+-- fit 済モデルの回帰曲線・CI 帯を **訓練点ではなく等間隔 grid** で評価して描く。
+-- 疎・不均一データで曲線がガタつくのを解消する (散布図の点は従来通り訓練データ)。
+-- 'statModel' で 'ModelSpec' を作り、 @<>@ でオプションを足す:
+--
+-- > df |>> (layer (scatter "x" "y") <> toPlot (statModel m <> grid 200))
+--
+-- 'ModelSpec' は Monoid。 学習済モデル @m@ はクロージャに閉じ込め、 予測は
+-- 'toPlot' (描画時) に grid 評価する (ユーザ直感「m は学習・layer で予測」)。
+-- ===========================================================================
+
+-- | grid 評価の確定オプション ('ModelSpec' の Maybe field を既定で埋めたもの)。
+data GridOpts = GridOpts
+  { goN       :: Int                    -- ^ 評価点数 (既定 100)。
+  , goRange   :: Maybe (Double, Double) -- ^ 評価範囲 (既定 = 説明変数 min/max)。
+  , goLevel   :: Double                 -- ^ CI 水準 (既定 0.95)。
+  , goBandMode :: BandMode              -- ^ 帯モード (既定 'BandCI'。 Phase 70.F)。
+  , goPIMethod :: PIMethod              -- ^ 帯の算出法 (既定 'PIClosedForm'。 Phase 70.H)。
+  , goPredAt  :: [Double]               -- ^ 予測点 x のリスト (C2)。
+  , goHoldAt  :: HoldAgg                 -- ^ 多変量 effect の他変数固定方式 (既定 Mean, C3)。
+  , goByVar   :: Maybe (Text, [Double])  -- ^ 層別 = 第2変数を複数値で固定 (C3)。
+  , goColor   :: Maybe Color             -- ^ 線の固定色 (statColor, A2)。
+  , goFill    :: Maybe Color             -- ^ 帯の塗り色 (statFill, A2)。
+  , goLinetype  :: Maybe LineType        -- ^ 線種 (statLinetype, A2)。
+  , goLinewidth :: Maybe Double          -- ^ 線幅 = stroke (statLinewidth, A2)。
+  , goAlpha   :: Maybe Double            -- ^ 帯/線の透明度 (statAlpha, A2)。
+  , goLabel   :: Maybe Text              -- ^ 単線の凡例ラベル (statLabel, A3)。
+  , goShowEq  :: Bool                    -- ^ 回帰式を凡例ラベルに出す (statEquation, A8)。
+  , goShowR2  :: Bool                    -- ^ R² を凡例ラベルに出す (statR2, A8)。
+  }
+
+-- | grid 上で曲線評価できる単変数モデル。 'statModel' が要求する能力。
+class SingleVarModel m where
+  -- | 生 predictor の範囲 (grid 既定範囲の算出元)。
+  svRange :: m -> (Double, Double)
+  -- | 信頼水準と grid x 列から (中心 μ̂, 帯 @(lo, hi)@) を評価する。
+  -- band を持たないモデル (GAM/Robust) は 'Nothing'。
+  svGrid  :: m -> Double -> [Double] -> ([Double], Maybe ([Double], [Double]))
+  -- | **予測区間** (PI) 帯 @(lo, hi)@ を評価する (A5)。 観測分散 σ̂² を持つモデル
+  -- (LM・Gaussian/Identity GLM) のみ実装し、 それ以外は既定の 'Nothing' (= PI 非提供)。
+  -- 中心 μ̂ は 'svGrid' と共通ゆえここでは帯のみ返す。
+  svGridPI :: m -> Double -> [Double] -> Maybe ([Double], [Double])
+  svGridPI _ _ _ = Nothing
+  -- | 当てはめ係数 @[β₀, β₁]@ と R² (A8 の式/R² 凡例注釈用)。 線形で「式」 の意味が
+  -- 明快なモデル (LM) のみ実装し、 それ以外は既定の 'Nothing' (= 式注釈を出さない)。
+  -- GLM は係数が η (リンク) スケールゆえ @y = β₀ + β₁x@ の素朴な式が成り立たず Nothing。
+  svCoefR2 :: m -> Maybe ([Double], Double)
+  svCoefR2 _ = Nothing
+  -- | ブートストラップ ('piMethod (PIBootstrap …)') 用の素材。 訓練 (x, y)・再標本化データで
+  -- refit する関数・新規観測の分布 (GLM family。 'Nothing' = 加法残差) を束ねて返す。 既定
+  -- 'Nothing' (= ブートストラップ非対応 → 閉形式へフォールバック)。 closed-form を持たない
+  -- モデル (非 Gaussian GLM / ロバスト) でも、 これを実装すれば PI を出せる。
+  svBootKit :: m -> Maybe (BootKit m)
+  svBootKit _ = Nothing
+
+-- | ブートストラップに必要な素材 ('svBootKit' が返す。 内部利用)。
+data BootKit m = BootKit
+  { bkX       :: [Double]                              -- ^ 訓練 x。
+  , bkY       :: [Double]                              -- ^ 訓練 y。
+  , bkRefit   :: [Double] -> [Double] -> m             -- ^ 再標本化 (x, y) で refit。
+  , bkObsDist :: Maybe (Double -> BD.Distribution Double) -- ^ 新規観測の分布 (GLM)。 Nothing=加法残差。
+  }
+
+-- | grid 評価の仕様。 'statModel' で生成し @<>@ でオプション合成 (Monoid)。
+-- @msRender@ にモデルの grid 評価関数をクロージャで保持する (= 案A)。
+data ModelSpec = ModelSpec
+  { msRender  :: Maybe (GridOpts -> VisualSpec)  -- ^ statModel が設定 (先勝ち)。
+  , msN       :: Maybe Int                       -- ^ grid 点数 (後勝ち)。
+  , msRange   :: Maybe (Double, Double)          -- ^ grid 範囲 (後勝ち)。
+  , msLevel   :: Maybe Double                    -- ^ CI 水準 (後勝ち)。
+  , msBandMode :: Maybe BandMode                 -- ^ 帯モード (後勝ち、 既定 'BandCI'。 Phase 70.F
+                                                 --   で帯 ON/OFF と CI/PI を 'bandMode' 1 本に統合)。
+  , msPIMethod :: Maybe PIMethod                 -- ^ 帯の算出法 (後勝ち、 既定 'PIClosedForm'。 Phase 70.H)。
+  , msPredAt  :: [Double]                        -- ^ 予測点 x (リスト累積 ++)。
+  , msHoldAt  :: Maybe HoldAgg                   -- ^ 多変量 effect の固定方式 (後勝ち、 既定 Mean)。
+  , msByVar   :: Maybe (Text, [Double])          -- ^ 層別変数 (後勝ち)。
+  , msColor   :: Maybe Color                      -- ^ 線の固定色 (後勝ち, A2)。
+  , msFill    :: Maybe Color                      -- ^ 帯の塗り色 (後勝ち, A2)。
+  , msLinetype  :: Maybe LineType                 -- ^ 線種 (後勝ち, A2)。
+  , msLinewidth :: Maybe Double                   -- ^ 線幅 = stroke (後勝ち, A2)。
+  , msAlpha   :: Maybe Double                      -- ^ 帯/線の透明度 (後勝ち, A2)。
+  , msLabel   :: Maybe Text                         -- ^ 単線の凡例ラベル (後勝ち, A3)。
+  , msShowEq  :: Bool                                 -- ^ 回帰式を凡例に出す (Any, A8)。
+  , msShowR2  :: Bool                                 -- ^ R² を凡例に出す (Any, A8)。
+  }
+
+instance Semigroup ModelSpec where
+  a <> b = ModelSpec
+    { msRender  = msRender a <|> msRender b      -- モデルは先勝ち (通常 1 個)
+    , msN       = msN b      <|> msN a           -- オプションは後勝ち
+    , msRange   = msRange b  <|> msRange a
+    , msLevel   = msLevel b  <|> msLevel a
+    , msBandMode = msBandMode b <|> msBandMode a  -- 帯モードは後勝ち
+    , msPIMethod = msPIMethod b <|> msPIMethod a  -- 算出法も後勝ち
+    , msPredAt  = msPredAt a ++ msPredAt b       -- 予測点はリスト累積
+    , msHoldAt  = msHoldAt b  <|> msHoldAt a
+    , msByVar   = msByVar b   <|> msByVar a
+    , msColor   = msColor b     <|> msColor a     -- aes は後勝ち
+    , msFill    = msFill b      <|> msFill a
+    , msLinetype  = msLinetype b  <|> msLinetype a
+    , msLinewidth = msLinewidth b <|> msLinewidth a
+    , msAlpha   = msAlpha b     <|> msAlpha a
+    , msLabel   = msLabel b     <|> msLabel a
+    , msShowEq   = msShowEq a || msShowEq b           -- 注釈はオプトイン (Any)
+    , msShowR2   = msShowR2 a || msShowR2 b
+    }
+
+instance Monoid ModelSpec where
+  mempty = ModelSpec
+    { msRender = Nothing, msN = Nothing, msRange = Nothing, msLevel = Nothing
+    , msBandMode = Nothing, msPIMethod = Nothing, msPredAt = [], msHoldAt = Nothing, msByVar = Nothing
+    , msColor = Nothing, msFill = Nothing, msLinetype = Nothing
+    , msLinewidth = Nothing, msAlpha = Nothing, msLabel = Nothing
+    , msShowEq = False, msShowR2 = False }
+
+-- | 学習済の単変数モデルから grid 評価 'ModelSpec' を作る (along 不要)。
+statModel :: SingleVarModel m => m -> ModelSpec
+statModel m = mempty { msRender = Just (renderGrid m) }
+
+-- | grid 評価点数を指定 (既定 100)。
+grid :: Int -> ModelSpec
+grid n = mempty { msN = Just n }
+
+-- | grid 評価範囲を指定 (既定 = 説明変数 min/max)。
+gridRange :: Double -> Double -> ModelSpec
+gridRange lo hi = mempty { msRange = Just (lo, hi) }
+
+-- | 出す帯を 1 つの値で選ぶ (Phase 70.F で帯 ON/OFF と CI/PI を統合)。 'BandMode' は
+--   @BandOff@ (なし) \/ @BandCI@ (既定・信頼区間) \/ @BandPI@ (予測区間) \/ @BandCIPI@
+--   (入れ子)。 既定 (未指定) は @BandCI@。 PI 非提供モデルでは PI 系は CI へフォールバック。
+--
+--   @statModel m \<\> bandMode BandPI@ \/ @… \<\> bandMode BandCIPI@ \/ @… \<\> bandMode BandOff@。
+bandMode :: BandMode -> ModelSpec
+bandMode m = mempty { msBandMode = Just m }
+
+-- | 帯 (CI/PI) の**算出法**を選ぶ (Phase 70.H)。 @bandMode@ が「どの帯を出すか」を選ぶのに対し、
+--   @piMethod@ は「どう計算するか」を選ぶ直交軸:
+--
+--     * @PIClosedForm@   = 閉形式 (Wald / 基底空間 OLS。 **既定**)。
+--     * @PIBootstrap seed draws@ = case-resampling ブートストラップ (seed で決定的)。
+--       閉形式 CI/PI を持たないモデル (非 Gaussian GLM / ロバスト) でも PI を出せる。
+--
+--   @statModel m \<\> bandMode BandPI \<\> piMethod (PIBootstrap 42 2000)@。
+piMethod :: PIMethod -> ModelSpec
+piMethod p = mempty { msPIMethod = Just p }
+
+-- | 回帰線の固定色 (ggplot @geom_smooth(color=)@, A2)。 凡例は付かない (単線命名は 'statLabel')。
+--   型安全な 'Color' を受ける (plot-core の 'color' と同じ方針)。 @statColor (fromHex "#ff0000")@
+--   / @statColor N.red@ / @statColor (rgb 255 0 0)@。 Text→Color は 'fromHex' に委ねる。
+statColor :: Color -> ModelSpec
+statColor c = mempty { msColor = Just c }
+
+-- | CI 帯の塗り色 (ggplot @geom_smooth(fill=)@, A2)。 型安全な 'Color' を受ける。
+statFill :: Color -> ModelSpec
+statFill c = mempty { msFill = Just c }
+
+-- | 回帰線の線種 (ggplot @geom_smooth(linetype=)@, A2)。 'LineType' = 'LtSolid' / 'LtDashed' 等。
+statLinetype :: LineType -> ModelSpec
+statLinetype lt = mempty { msLinetype = Just lt }
+
+-- | 回帰線の太さ (= stroke 幅。 ggplot @geom_smooth(linewidth=)@, A2)。
+statLinewidth :: Double -> ModelSpec
+statLinewidth w = mempty { msLinewidth = Just w }
+
+-- | 帯/線の透明度 (ggplot @geom_smooth(alpha=)@, A2)。 帯に適用 (薄い塗り潰しの ggplot 流)。
+statAlpha :: Double -> ModelSpec
+statAlpha a = mempty { msAlpha = Just a }
+
+-- | 単線に凡例ラベルを付ける (A3)。 1 群カテゴリ ('ColorByCol') + 'scaleColorManual' で
+-- 色を固定し凡例エントリを 1 つ出す (固定色 'color' は @hasColorEncoding=False@ で
+-- 凡例が出ない罠を回避)。 色は 'statColor' があればそれ、 なければ既定パレット先頭。
+-- ★モデル比較で各線に名前を付ける用途 (= 群数 1 の 'byGroup' 特殊形)。
+statLabel :: Text -> ModelSpec
+statLabel lbl = mempty { msLabel = Just lbl }
+
+-- | 回帰式を凡例ラベルに出す (A8。 ggplot @ggpubr::stat_regline_equation@ 相当)。
+-- 'svCoefR2' を持つモデル (LM) で @y = β₀ + β₁x@ を自動生成し A3 機構 (凡例) に載せる。
+-- 明示 'statLabel' があればそちらを優先。 式の出せないモデル (GLM 等) では注釈なし。
+-- 'statR2' と併用すると @y = … + …x, R² = …@ のように 1 ラベルに連結する。
+statEquation :: ModelSpec
+statEquation = mempty { msShowEq = True }
+
+-- | R² を凡例ラベルに出す (A8。 ggplot @ggpubr::stat_cor(aes(label=..rr.label..))@ 相当)。
+-- 'svCoefR2' を持つモデル (LM) の R² を @R² = 0.987@ の形で凡例に載せる。
+statR2 :: ModelSpec
+statR2 = mempty { msShowR2 = True }
+
+-- | CI 水準を指定 (既定 0.95)。
+statLevel :: Double -> ModelSpec
+statLevel l = mempty { msLevel = Just l }
+
+-- | 多変量 effect で along 以外の説明変数の固定方式を指定 (既定 'Mean', C3)。
+holdAt :: HoldAgg -> ModelSpec
+holdAt h = mempty { msHoldAt = Just h }
+
+-- | 層別 = 第2変数 @v@ を複数値 @vals@ で固定し、 値ごとに 1 曲線を色分け重畳する
+-- (R @ggpredict@ terms 第2項相当, C3)。 多変量モデル ('statModelMulti') 専用。
+byVar :: Text -> [Double] -> ModelSpec
+byVar v vals = mempty { msByVar = Just (v, vals) }
+
+-- | 予測点を 1 つ足す (C2)。 @<>@ でリスト累積 → @… <> predAt 1 <> predAt 3@ で複数点。
+-- 各点は μ̂ (scatter) + CI 区間 [lo, hi] (lineRange) で描かれる (band を持たない GAM/
+-- Robust は μ̂ 点のみ)。 単変数モデル前提 (多変量 effect は C3 の statModelMulti で対応)。
+predAt :: Double -> ModelSpec
+predAt x = mempty { msPredAt = [x] }
+
+-- | A2/A3: 線レイヤへ aes (色・線種・太さ) を適用。 色の決定順は
+-- (1) 群色 @mCol@ (byVar) → 'color'、 (2) 'statLabel' (@goLabel@) → 1 群 'colorBy'
+-- (凡例を出すため・@n@ 点ぶんのカテゴリ列)、 (3) 'statColor' → 'color'。
+-- 線種・太さは色と独立に適用。 @n@ = grid 点数 (label カテゴリ列の長さ)。
+goLineDeco :: GridOpts -> Maybe Color -> Int -> Layer -> Layer
+goLineDeco o mCol n l =
+  let colorL = case (mCol, goLabel o) of
+        (Just c, _)         -> color c                                   -- 群色優先
+        (Nothing, Just lbl) -> colorBy (inlineCat (replicate n lbl))      -- statLabel: ColorByCol で凡例
+        (Nothing, Nothing)  -> maybe mempty color (goColor o)            -- statColor or 無色
+  in l <> colorL
+       <> maybe mempty linetype (goLinetype o)
+       <> maybe mempty (\lw -> stroke (lw *~ pt')) (goLinewidth o)
+
+-- | A2: 帯レイヤへ fill 色・透明度を適用。 群色 @mCol@ があれば fill は群色を優先 ('statFill' で上書き不可)。
+goBandDeco :: GridOpts -> Maybe Color -> Layer -> Layer
+goBandDeco o mCol b =
+  b <> maybe mempty color (mCol <|> goFill o)
+    <> maybe mempty alpha       (goAlpha o)
+
+-- | A3: 'statLabel' があれば @scaleColorManual@ で色を固定し @legend@ を出す 'VisualSpec'。
+-- 色は 'statColor' (@goColor@) 優先・なければ既定パレット先頭。 ラベル無しは空。
+labelLegend :: GridOpts -> VisualSpec
+labelLegend o = case goLabel o of
+  Just lbl -> scaleColorManual [(lbl, maybe (head effectPalette) toCss (goColor o))] <> legend
+  Nothing  -> mempty
+
+-- | A8: 式/R² 凡例ラベル文字列を組む。 @showEq@ で @y = β₀ + β₁x@、 @showR2@ で
+-- @R² = 0.987@ を入れ、 両方なら @", "@ で連結する。 係数は単回帰 @[β₀, β₁]@ を想定
+-- (β₁ の符号で @+@/@-@ を切替)。 どちらの flag も立っていなければ 'Nothing'。
+fitLabelText :: Bool -> Bool -> [Double] -> Double -> Maybe Text
+fitLabelText showEq showR2 coefs r2 =
+  let f3 x = T.pack (showFFloat (Just 3) x "")          -- 小数 3 桁固定
+      eqPart = case coefs of
+        (b0 : b1 : _) ->
+          let sgn = if b1 < 0 then " − " else " + "
+          in "y = " <> f3 b0 <> sgn <> f3 (abs b1) <> "x"
+        [b0]          -> "y = " <> f3 b0
+        _             -> "y = ?"
+      r2Part = "R² = " <> f3 r2
+      parts  = [ eqPart | showEq ] ++ [ r2Part | showR2 ]
+  in if null parts then Nothing else Just (T.intercalate ", " parts)
+
+-- | case-resampling ブートストラップで grid 上の CI / PI 帯を計算する (Phase 70.H)。
+--   訓練 (x, y) を seed 付きで再標本化 → 'bkRefit' で refit → 'svGrid' で grid μ を予測、
+--   を @draws@ 回。 CI = μ_b の分位点 (係数の不確実性)。 PI = 新規観測 y* の分位点
+--   (加法残差 'bkObsDist'=Nothing、 または Family(μ) からの parametric ドロー)。 seed 純粋
+--   (runST + mwc・同 seed でビット同一)。 戻り = (CI (lo,hi), PI (lo,hi))。
+bootstrapBands :: SingleVarModel m
+               => m -> BootKit m -> Word32 -> Int -> Double -> [Double]
+               -> (([Double], [Double]), ([Double], [Double]))
+bootstrapBands m kit seed draws level gxs =
+  let xs    = V.fromList (bkX kit)
+      ys    = V.fromList (bkY kit)
+      n     = V.length xs
+      ng    = length gxs
+      a2    = (1 - level) / 2
+      resid = V.fromList (zipWith (-) (bkY kit) (fst (svGrid m level (bkX kit))))
+      paths = runST $ do
+        gen <- initialize (V.singleton seed)
+        replicateM draws $ do
+          idx <- replicateM n (uniformR (0, n - 1) gen)
+          let xs' = [ xs V.! i | i <- idx ]
+              ys' = [ ys V.! i | i <- idx ]
+              muB = fst (svGrid (bkRefit kit xs' ys') level gxs)
+          pis <- case bkObsDist kit of
+            Just toDist -> mapM (\mu -> sampleDist (toDist mu) gen) muB
+            Nothing     -> mapM (\mu -> do j <- uniformR (0, n - 1) gen
+                                           pure (mu + resid V.! j)) muB
+          pure (muB, pis)
+      muT = transpose (map fst paths)   -- ng × draws
+      piT = transpose (map snd paths)
+      q lo xss = map (percentileOf lo) xss
+  in if n < 2 || ng == 0
+       then (([], []), ([], []))
+       else ( (q a2 muT, q (1 - a2) muT), (q a2 piT, q (1 - a2) piT) )
+
+-- | grid 評価して曲線 (+ 帯) + 予測点の 'VisualSpec' を組む。 'statModel' がクロージャ化。
+-- 帯がある場合は @band@ を先に置き @line@ (μ̂ 曲線) を上に重ねる。 予測点 (goPredAt) は
+-- CI 区間を @lineRange@ (縦線 [lo,hi]) + μ̂ を @scatter@ で重ね、 μ̂ が区間内のどこにあるか
+-- (非対称な GLM 帯でも) 忠実に示す。
+renderGrid :: SingleVarModel m => m -> GridOpts -> VisualSpec
+renderGrid m opts0 =
+  -- A8: statEquation/statR2 が立っていれば svCoefR2 から式/R² 文字列を作り、
+  -- A3 と同じ凡例経路 (goLabel) に流す。 明示 statLabel が優先 (上書きしない)。
+  let autoLabel = case (goShowEq opts0 || goShowR2 opts0, svCoefR2 m) of
+        (True, Just (coefs, r2)) -> fitLabelText (goShowEq opts0) (goShowR2 opts0) coefs r2
+        _                        -> Nothing
+      opts = case goLabel opts0 of
+        Just _  -> opts0                              -- 明示ラベル優先
+        Nothing -> opts0 { goLabel = autoLabel }
+      (lo0, hi0) = svRange m
+      (lo, hi)   = fromMaybe (lo0, hi0) (goRange opts)
+      n          = max 2 (goN opts)
+      gxs        = linspace lo hi n
+      (mu, mbCIcf) = svGrid m (goLevel opts) gxs
+      -- 帯の算出法 (Phase 70.H): 既定 closed-form、 PIBootstrap で case-resampling。
+      -- bootstrap は CI/PI を両方その場で計算 ('svBootKit' を持つモデルのみ。 無ければ
+      -- closed-form へフォールバック)。 中心曲線 mu は元の当てはめのまま。
+      (mbCI, mbPI) = case goPIMethod opts of
+        PIBootstrap seed draws
+          | Just kit <- svBootKit m ->
+              let (ci, pii) = bootstrapBands m kit seed draws (goLevel opts) gxs
+              in (Just ci, Just pii)
+        _ -> (mbCIcf, svGridPI m (goLevel opts) gxs)
+      -- 帯モードで CI/PI/両方/なしを描く (Phase 70.F)。 PI 非提供は CI へフォールバック。
+      lineL    = layer (goLineDeco opts Nothing n (line (inline gxs) (inline mu)))
+      bandL deco mb = case mb of
+        Just (los, his) -> layer (deco (band (inline gxs) (inline los) (inline his)))
+        Nothing         -> mempty
+      ciDeco = goBandDeco opts Nothing
+      -- 入れ子時の PI 帯は薄め (CI が内側で見えるように)。
+      piA    = maybe 0.10 (* 0.5) (goAlpha opts)
+      piDeco = goBandDeco (opts { goAlpha = Just piA }) Nothing
+      curve = case goBandMode opts of
+        BandOff  -> lineL
+        BandCI   -> bandL ciDeco mbCI <> lineL
+        BandPI   -> case mbPI of
+                      Just _  -> bandL ciDeco mbPI <> lineL   -- PI 単独 (通常の濃さ)
+                      Nothing -> bandL ciDeco mbCI <> lineL   -- PI 非提供 → CI
+        BandCIPI -> case mbPI of
+                      Just _  -> bandL piDeco mbPI            -- 外: PI 薄 (下)
+                              <> bandL ciDeco mbCI            -- 内: CI 濃 (上)
+                              <> lineL
+                      Nothing -> bandL ciDeco mbCI <> lineL   -- PI 非提供 → CI のみ
+      pts = goPredAt opts
+      predLayers
+        | null pts  = mempty
+        | otherwise =
+            let (pmu, pmb) = svGrid m (goLevel opts) pts
+            in case pmb of
+                 Just (plos, phis) ->
+                   let mids  = zipWith (\l h -> (l + h) / 2) plos phis
+                       halfs = zipWith (\l h -> (h - l) / 2) plos phis
+                   in layer (lineRange (inline pts) (inline mids) (inline halfs))
+                        <> layer (scatter (inline pts) (inline pmu))
+                 Nothing -> layer (scatter (inline pts) (inline pmu))
+  in curve <> predLayers <> labelLegend opts
+
+-- ★案B: 既存 'Plottable' の 'toPlot' を 'ModelSpec' にも overload (同綴り)。
+instance Plottable ModelSpec where
+  toPlot ms = case msRender ms of
+    Nothing -> mempty   -- モデル未設定 (オプションのみ) は空図。
+    Just f  -> f GridOpts
+      { goN       = fromMaybe 100 (msN ms)
+      , goRange   = msRange ms
+      , goLevel   = fromMaybe 0.95 (msLevel ms)
+      , goBandMode = fromMaybe BandCI (msBandMode ms)
+      , goPIMethod = fromMaybe PIClosedForm (msPIMethod ms)
+      , goPredAt  = msPredAt ms
+      , goHoldAt  = fromMaybe Mean (msHoldAt ms)
+      , goByVar   = msByVar ms
+      , goColor     = msColor ms
+      , goFill      = msFill ms
+      , goLinetype  = msLinetype ms
+      , goLinewidth = msLinewidth ms
+      , goAlpha     = msAlpha ms
+      , goLabel     = msLabel ms
+      , goShowEq     = msShowEq ms
+      , goShowR2     = msShowR2 ms
+      }
+
+-- ===========================================================================
+-- 多変量 effect plot (Phase 16 §3 C3)
+--
+-- 単変数 grid 評価 (C1) を多変量モデルへ一般化する。 along 変数を grid で動かし、
+-- 他の説明変数を 'HoldAgg' で固定した「評価点 ModelFrame」 を合成して、 訓練 formula の
+-- 'designMatrixF' で評価点設計行列を組み CI を評価する。
+--
+-- ★評価点 ModelFrame の合成は **DataFrame を経由せず VarRole を直接差し替える**
+-- ('designMatrixF' は 'mfRoles' のみ参照し応答列は使わない = Design.hs:331)。 列構造・
+-- 順序が訓練と完全一致するので 'confidenceBandAt' / 'predictGlmMuWithCI' がそのまま使える。
+-- 型で単/多変量を分離し ('SingleVarModel' / 'MultiVarModel')、 along 忘れをコンパイル時に弾く。
+-- ===========================================================================
+
+-- | along を必須引数に持つ多変量モデル。 'statModelMulti' が要求する能力。
+class MultiVarModel m where
+  -- | 訓練 'ModelFrame' (along の range と他変数の集約元)。
+  mvFrame     :: m -> ModelFrame
+  -- | 評価点 'ModelFrame' から (中心 μ̂, CI 帯 @(lo, hi)@) を評価する。
+  --   設計行列が組めない場合は空 + 'Nothing'。
+  mvEvalFrame :: m -> Double -> ModelFrame -> ([Double], Maybe ([Double], [Double]))
+  -- | 評価点での予測区間 (PI)。 既定 'Nothing' (PI 非提供)。 closed-form PI を持つ
+  --   モデル ('MultiLMModel' = 多変量 OLS) のみ override する ('svGridPI' と同じ方針)。
+  mvEvalFramePI :: m -> Double -> ModelFrame -> Maybe ([Double], [Double])
+  mvEvalFramePI _ _ _ = Nothing
+
+-- | 学習済の多変量モデルと along 変数から effect plot の 'ModelSpec' を作る。
+--   along は **必須引数** (型で単/多変量を分離し誤用を弾く)。
+--   @df |>> (layer (scatter \"x1\" \"y\") <> toPlot (statModelMulti m (along \"x1\") <> holdAt Median))@。
+statModelMulti :: MultiVarModel m => m -> AlongSpec -> ModelSpec
+statModelMulti m (AlongSpec v) = mempty { msRender = Just (renderGridMulti m v) }
+
+-- | effect plot の 'VisualSpec' を組む。 along を grid で動かし他変数を 'HoldAgg' で固定。
+--   byVar があれば第2変数の各値で曲線を色分け重畳する。 'statModelMulti' がクロージャ化。
+renderGridMulti :: MultiVarModel m => m -> Text -> GridOpts -> VisualSpec
+renderGridMulti m alongV opts =
+  let mf         = mvFrame m
+      (lo0, hi0) = alongRange mf alongV
+      (lo, hi)   = fromMaybe (lo0, hi0) (goRange opts)
+      n          = max 2 (goN opts)
+      gxs        = linspace lo hi n
+      level      = goLevel opts
+      hold       = goHoldAt opts
+      -- 1 曲線分 (override = byVar 固定, mCol = 線色)。
+      oneCurve override mCol =
+        case hold of
+          Marginalize -> marginalizeCurve opts m alongV level gxs override mCol
+          _ ->
+            let ef         = evalFrame mf alongV hold override gxs
+                (mu, mbCI) = mvEvalFrame m level ef
+                mbPI       = mvEvalFramePI m level ef
+                lineL      = layer (goLineDeco opts mCol n (line (inline gxs) (inline mu)))
+                bL deco mb = case mb of
+                  Just (los, his) -> layer (deco (band (inline gxs) (inline los) (inline his)))
+                  Nothing         -> mempty
+                ciDeco = goBandDeco opts mCol
+                piA    = maybe 0.10 (* 0.5) (goAlpha opts)
+                piDeco = goBandDeco (opts { goAlpha = Just piA }) mCol
+                bands  = case goBandMode opts of
+                  BandOff  -> mempty
+                  BandCI   -> bL ciDeco mbCI
+                  BandPI   -> case mbPI of
+                                Just _  -> bL ciDeco mbPI
+                                Nothing -> bL ciDeco mbCI       -- PI 非提供 → CI
+                  BandCIPI -> case mbPI of
+                                Just _  -> bL piDeco mbPI <> bL ciDeco mbCI
+                                Nothing -> bL ciDeco mbCI       -- PI 非提供 → CI のみ
+            in bands <> lineL
+  in case goByVar opts of
+       Nothing          -> oneCurve [] Nothing <> labelLegend opts
+       Just (v2, vals)  ->
+         foldMap
+           (\(i, val) ->
+              let col = fromHex (effectPalette !! (i `mod` length effectPalette))
+              in oneCurve [(v2, val)] (Just col))
+           (zip [0 :: Int ..] vals)
+
+-- | Marginalize (PDP/AME): 各 grid 点で along=gx に固定し他変数は **観測分布のまま**、
+--   μ̂ を全観測行で平均する (band なし・曲線のみ。 全観測行 × grid で重い)。
+marginalizeCurve :: MultiVarModel m
+                 => GridOpts -> m -> Text -> Double -> [Double] -> [(Text, Double)] -> Maybe Color -> VisualSpec
+marginalizeCurve opts m alongV level gxs override mCol =
+  let mf   = mvFrame m
+      nObs = mfNRows mf
+      base = mf { mfRoles = [ (nm, baseRole nm r) | (nm, r) <- mfRoles mf ] }
+      baseRole nm r
+        | isResponseRole r              = RoleResponse (V.replicate nObs 0)
+        | Just fv <- lookup nm override = fixedRole r nObs fv
+        | otherwise                     = r                       -- 観測分布のまま
+      muAt gx =
+        let (mu, _) = mvEvalFrame m level (setAlong base alongV gx)
+        in sum mu / fromIntegral (max 1 (length mu))
+      mus  = map muAt gxs
+  in layer (goLineDeco opts mCol (length gxs) (line (inline gxs) (inline mus)))
+
+-- | along 変数の観測範囲 (effect grid の既定範囲)。 along が連続でなければ退避 @(0,1)@。
+alongRange :: ModelFrame -> Text -> (Double, Double)
+alongRange mf v = case lookup v (mfRoles mf) of
+  Just (RoleContinuous xs) | not (V.null xs) -> (V.minimum xs, V.maximum xs)
+  _                                          -> (0, 1)
+
+-- | 各説明変数を 'HoldAgg' で固定値の定数列に差し替えた評価点 'ModelFrame' を合成する。
+--   along 変数は grid (gxs)、 応答列はダミー ('designMatrixF' は応答を使わない)。
+--   override は byVar 等の明示固定で 'HoldAgg' より優先する。
+evalFrame :: ModelFrame -> Text -> HoldAgg -> [(Text, Double)] -> [Double] -> ModelFrame
+evalFrame mf alongV hold override gxs =
+  let n = length gxs
+      adjust (nm, role)
+        | isResponseRole role           = (nm, RoleResponse (V.replicate n 0))
+        | nm == alongV                  = (nm, RoleContinuous (V.fromList gxs))
+        | Just fv <- lookup nm override = (nm, fixedRole role n fv)
+        | otherwise                     = (nm, holdRole hold n nm role)
+  in mf { mfRoles = map adjust (mfRoles mf), mfNRows = n }
+
+-- | frame の along 列だけを定数 gx に差し替える (行数据え置き、 Marginalize 用)。
+setAlong :: ModelFrame -> Text -> Double -> ModelFrame
+setAlong mf alongV gx =
+  let n = mfNRows mf
+      adj (nm, role)
+        | nm == alongV = (nm, RoleContinuous (V.replicate n gx))
+        | otherwise    = (nm, role)
+  in mf { mfRoles = map adj (mfRoles mf) }
+
+isResponseRole :: VarRole -> Bool
+isResponseRole (RoleResponse _) = True
+isResponseRole _                = False
+
+-- | 1 変数を 'HoldAgg' で固定した定数列にする (連続は集約値、 factor は固定水準 index)。
+--   factor は Mean\/Median\/Mode\/Fixed すべて最頻水準に振替 (Reference のみ参照=index 0)。
+holdRole :: HoldAgg -> Int -> Text -> VarRole -> VarRole
+holdRole hold n nm role = case role of
+  RoleContinuous xs ->
+    let v = case hold of
+              Mean        -> meanV xs
+              Median      -> medianV xs
+              Mode        -> modeV xs
+              Reference   -> meanV xs              -- 連続に参照水準は無し → 平均で代替
+              Marginalize -> meanV xs              -- (Marginalize は別経路。 安全側に平均)
+              Fixed fm    -> fromMaybe (meanV xs) (lookup nm fm)
+    in RoleContinuous (V.replicate n v)
+  RoleFactor levels idx ->
+    let fixIdx = case hold of
+                   Reference -> 0
+                   Fixed fm  -> maybe (modeIdx idx) (clampIdx levels . round) (lookup nm fm)
+                   _         -> modeIdx idx
+    in RoleFactor levels (V.replicate n fixIdx)
+  RoleResponse _ -> RoleResponse (V.replicate n 0)
+
+-- | 明示値 (byVar / Fixed override) で 1 変数を定数列にする。
+fixedRole :: VarRole -> Int -> Double -> VarRole
+fixedRole role n fv = case role of
+  RoleContinuous _    -> RoleContinuous (V.replicate n fv)
+  RoleFactor levels _ -> RoleFactor levels (V.replicate n (clampIdx levels (round fv)))
+  RoleResponse _      -> RoleResponse (V.replicate n fv)
+
+clampIdx :: [Text] -> Int -> Int
+clampIdx levels i = max 0 (min (length levels - 1) i)
+
+-- | byVar 曲線の固定色パレット (層別の値ごとに 1 色)。
+effectPalette :: [Text]
+effectPalette =
+  [ "#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd", "#8c564b", "#e377c2" ]
+
+-- ===========================================================================
+-- 応答曲面 3D 直結 — plot Phase 24 A3 (fit 済み多変量モデル → surface)
+--
+-- JMP Surface Profiler 同型: 2 因子 (v1, v2) を grid で動かし他変数を 'HoldAgg'
+-- で固定、 μ̂ を 3D surface (z colormap 既定 ON) で描く。 effect plot
+-- ('statModelMulti') の 2 因子版で、 評価核は同じ 'mvEvalFrame'。
+-- ===========================================================================
+
+-- | 2 因子 grid + 'HoldAgg' の評価点 frame ('evalFrame' の 2 変数版)。
+--   行 = v2 (外側)、 列 = v1 (内側) — 'P3.surface3D' の grid 規約
+--   (row = y 方向) に一致させる。
+evalFrame2 :: ModelFrame -> Text -> Text -> HoldAgg -> [Double] -> [Double] -> ModelFrame
+evalFrame2 mf v1 v2 hold gxs gys =
+  let n   = length gxs * length gys
+      x1s = [ gx | _  <- gys, gx <- gxs ]
+      x2s = [ gy | gy <- gys, _  <- gxs ]
+      adjust (nm, role)
+        | isResponseRole role = (nm, RoleResponse (V.replicate n 0))
+        | nm == v1            = (nm, RoleContinuous (V.fromList x1s))
+        | nm == v2            = (nm, RoleContinuous (V.fromList x2s))
+        | otherwise           = (nm, holdRole hold n nm role)
+  in mf { mfRoles = map adjust (mfRoles mf), mfNRows = n }
+
+-- | 応答曲面の数値核: @(gxs, gys, grid)@。 @grid !! j !! i = μ̂(gxs!!i, gys!!j)@。
+surfaceGrid :: MultiVarModel m
+            => m -> Text -> Text -> SurfaceOpts -> ([Double], [Double], [[Double]])
+surfaceGrid m v1 v2 opts =
+  let mf         = mvFrame m
+      (xlo, xhi) = fromMaybe (alongRange mf v1) (soXRange opts)
+      (ylo, yhi) = fromMaybe (alongRange mf v2) (soYRange opts)
+      n          = max 2 (soN opts)
+      gxs        = linspace xlo xhi n
+      gys        = linspace ylo yhi n
+      ef         = evalFrame2 mf v1 v2 (soHoldAt opts) gxs gys
+      (mu, _)    = mvEvalFrame m 0.95 ef
+  in (gxs, gys, chunkRows n mu)
+
+chunkRows :: Int -> [a] -> [[a]]
+chunkRows k = go
+  where go [] = []
+        go xs = let (h, t) = splitAt k xs in h : go t
+
+-- | fit 済み多変量モデル → 3D 応答曲面 (z colormap 既定 ON・colorbar 自動)。
+--   @saveSVG3D path (surfaceOf m "x1" "x2" <> dataScatter3DOf m "x1" "x2")@。
+surfaceOf :: MultiVarModel m => m -> Text -> Text -> P3.VisualSpec3D
+surfaceOf m v1 v2 = surfaceOfWith m v1 v2 defaultSurfaceOpts
+
+-- | オプション付き ('SurfaceOpts': grid 点数・hold・範囲)。
+surfaceOfWith :: MultiVarModel m => m -> Text -> Text -> SurfaceOpts -> P3.VisualSpec3D
+surfaceOfWith m v1 v2 opts =
+  let (gxs, gys, grid') = surfaceGrid m v1 v2 opts
+  in P3.layer3D ( P3.surface3DGrid grid'
+               <> P3.xRange3D (head gxs, last gxs)
+               <> P3.yRange3D (head gys, last gys)
+               <> P3.colormap3D )
+
+-- | 実測点の 3D overlay: 訓練データの @(v1, v2, y)@ を scatter3D で重畳。
+dataScatter3DOf :: MultiVarModel m => m -> Text -> Text -> P3.VisualSpec3D
+dataScatter3DOf m v1 v2 =
+  let mf = mvFrame m
+      contOf nm = case lookup nm (mfRoles mf) of
+        Just (RoleContinuous xs) -> V.toList xs
+        _                        -> []
+      ys = case [ v | (_, RoleResponse v) <- mfRoles mf ] of
+        (v : _) -> V.toList v
+        []      -> []
+      pts = zipWith3 Point3 (contOf v1) (contOf v2) ys
+  in P3.layer3D (P3.scatter3DPoints pts <> P3.color3D (fromHex "#d62728") <> P3.size3D 4)
+
+meanV :: V.Vector Double -> Double
+meanV xs | V.null xs = 0
+         | otherwise = V.sum xs / fromIntegral (V.length xs)
+
+medianV :: V.Vector Double -> Double
+medianV xs
+  | null ys   = 0
+  | odd k     = ys !! (k `div` 2)
+  | otherwise = (ys !! (k `div` 2 - 1) + ys !! (k `div` 2)) / 2
+  where ys = sort (V.toList xs)
+        k  = length ys
+
+-- | 連続列の最頻 (観測値の完全一致でグループ化。 繰り返しのない真の連続では任意)。
+modeV :: V.Vector Double -> Double
+modeV xs | V.null xs = 0
+         | otherwise = mostCommon (V.toList xs)
+
+-- | factor の最頻水準 index。
+modeIdx :: V.Vector Int -> Int
+modeIdx idx | V.null idx = 0
+            | otherwise  = mostCommon (V.toList idx)
+
+mostCommon :: Ord a => [a] -> a
+mostCommon = fst . maximumBy (comparing snd)
+           . map (\g -> (head g, length g)) . group . sort
+
+-- ===========================================================================
+-- 共有描画 helper (複数のモデル族が利用)
+-- ===========================================================================
+
+-- | CI band の既定 level (95%)。
+defaultCILevel :: Double
+defaultCILevel = 0.95
+
+-- | 分位線の色パレット (τ 昇順に割当て。 必要数を循環)。
+quantilePalette :: [T.Text]
+quantilePalette =
+  [ "#4575b4", "#d73027", "#1a9850", "#984ea3", "#ff7f00", "#377eb8" ]
+
+-- | 階段関数の頂点列を作る。 開始値 @s0@ (= t=0 での値) から、 各 @(tᵢ, sᵢ)@ について
+-- 直前の高さで @tᵢ@ まで水平に来てから @sᵢ@ に垂直に跳ぶ 2 頂点を出す。
+stepVerts :: Double -> [(Double, Double)] -> [(Double, Double)]
+stepVerts s0 pts = (0, s0) : go s0 pts
+  where
+    go _    []            = []
+    go prev ((t, s) : rest) = (t, prev) : (t, s) : go s rest
+
+-- | grid index を x として複数曲線を色分け重畳する内部 helper。
+gridCurves :: [(Text, [Double])] -> VisualSpec
+gridCurves named =
+  let mkLine (lbl, ys) =
+        let xs = [ fromIntegral i | i <- [1 .. length ys] ] :: [Double]
+        in layer ( line (inline xs) (inline ys)
+                 <> colorBy (inlineCat (replicate (length ys) lbl)) )
+  in mconcat (map mkLine named)
+
+-- | 特徴重要度 → bar layer ("f1", "f2", … をカテゴリ軸に・値=重要度)。
+importanceBar :: [Double] -> VisualSpec
+importanceBar imps =
+  let labels = [ "f" <> T.pack (show k) | k <- [1 .. length imps] ]
+  in layer (bar (inlineCat labels) (inline imps))
+
+-- | 行列の第 @i@/@j@ 列を (xs, ys) として取り出す (列不足は 0 埋め)。
+matCols2 :: LA.Matrix Double -> Int -> Int -> ([Double], [Double])
+matCols2 m i j =
+  let cols = LA.toColumns m
+      colAt k = if k < length cols then LA.toList (cols !! k) else replicate (LA.rows m) 0
+  in (colAt i, colAt j)
+
+-- | クラス代表点 (平均) をクラス色 ✚ で散布する (第 0/1 特徴)。 Discriminant /
+--   NaiveBayes(Gaussian) の data-free 代表図。
+classMeansScatter :: [[Double]] -> [Int] -> VisualSpec
+classMeansScatter rows cids = classMeansScatterNamed rows cids []
+
+-- | 'classMeansScatter' の **クラス名つき**版。 @names@ があれば凡例をクラス名 (levels)
+--   に、 無ければ整数へフォールバック (@names !! k@・範囲外は show)。 df|-> 経路が
+--   levels を載せた分類モデルの代表図で使う。
+classMeansScatterNamed :: [[Double]] -> [Int] -> [Text] -> VisualSpec
+classMeansScatterNamed rows cids names
+  | null rows = mempty
+  | otherwise =
+      let xs   = [ if not (null r) then head r else 0 | r <- rows ]
+          ys   = [ if length r >= 2 then r !! 1 else 0 | r <- rows ]
+          nameOf k | k >= 0 && k < length names = names !! k
+                   | otherwise                  = T.pack (show k)
+          labs = map nameOf cids
+      in layer ( scatter (inline xs) (inline ys)
+               <> colorBy (inlineCat labs)
+               <> shape MShCross )
+
+-- | chain index → 色 (effectPalette を巡回)。
+chainColor :: Int -> Text
+chainColor k = effectPalette !! (k `mod` length effectPalette)
+
+-- ===========================================================================
+-- 分類器抽象 (Discriminant / NaiveBayes / KNN 共通) — Phase 68 A3
+-- ===========================================================================
+
+-- | 学習済分類器を評価点行列で走らせ、 各行の予測クラスを返す共通インターフェース。
+--   ('decisionBoundaryOf' / 'confusionOf' が分類器種に依らず動くための薄い抽象)。
+class ClassPredict c where
+  predictClasses :: c -> LA.Matrix Double -> [Int]
+  -- | クラス番号 0..K-1 に対応する **クラス名 (levels)**。 高レベル @df |->@ 経路が
+  --   fit 時に載せる (factor 列なら levels 名・数値列なら数値)。 既定は空 = 名前を
+  --   持たないモデル ('confusionOf' 等は空なら整数ラベルにフォールバック)。
+  classNamesOf :: c -> [Text]
+  classNamesOf _ = []
+
+-- ===========================================================================
+-- 回帰診断の可視化 (係数 forest / 実測vs予測) — Phase 72.4/72.5
+--
+-- 係数表 ('coefSummary'・'Hanalyze.Diagnostics') と各モデルの実測/予測ペアを
+-- 図に落とす薄い玄関。 数値層 (係数統計・予測) は非ゲートの 'Diagnostics' / 各 fit が
+-- 持ち、 ここはゲート (plot-integration) 配下で 'VisualSpec' 化だけを担う。
+-- ===========================================================================
+
+-- | fit 済モデルから (実測値, 予測値) の対を取り出せる能力。 実測値は
+--   @fitted + residual@ で復元する (回帰一般で成り立つ)。 instance は各モデル族の
+--   'Plottable' と同じ Plot.* 側に置く (orphan・クラス=Core / instance=族 module)。
+class HasObsPred m where
+  -- | @(observed, predicted)@。 長さは観測数 n で一致する。
+  obsPredPairs :: m -> ([Double], [Double])
+
+-- | 実測 vs 予測プロット。 x=実測値・y=予測値の散布に @y = x@ の参照線 (灰の破線) を
+--   重ねる。 点が参照線に近いほど当てはまりが良い (残差が小さい)。
+obsVsPred :: HasObsPred m => m -> VisualSpec
+obsVsPred m = let (obs, prd) = obsPredPairs m in obsPredSpec obs prd
+
+-- | (実測, 予測) のリストから実測 vs 予測 spec を組む。 'obsVsPred' の純データ版
+--   (テスト・任意のペアからの作図に再利用)。 空入力は空図。
+obsPredSpec :: [Double] -> [Double] -> VisualSpec
+obsPredSpec obs prd
+  | null obs  = mempty
+  | otherwise =
+      let lo = minimum (obs ++ prd)
+          hi = maximum (obs ++ prd)
+      in  layer ( line (inline [lo, hi]) (inline [lo, hi])
+                <> linetype LtDashed
+                <> color (fromHex "#888888") )
+       <> layer (scatter (inline obs) (inline prd))
+       <> xLabel "observed"
+       <> yLabel "predicted"
+
+-- | 係数 forest plot。 各係数の点推定 ('crEstimate') を中心、 95% CI ('crCI95') の
+--   半幅を誤差バーとして 1 行ずつ水平に並べ、 0 (= 効果なし) に参照線を引く。 解析
+--   Wald CI ('coefSummary') を持つ線形系で使う (CI は左右対称なので半幅で表せる)。
+--   bootstrap 由来の非対称 CI を図にしたい場合は 'coefSummaryBoot' の行から個別に組む。
+coefForest :: HasCoefSummary m => m -> VisualSpec
+coefForest m =
+  let rows  = coefSummary m
+      names = [ crTerm r | r <- rows ]
+      ests  = [ crEstimate r | r <- rows ]
+      errs  = [ (hi - lo) / 2 | r <- rows, let (lo, hi) = crCI95 r ]
+  in if null rows
+       then mempty
+       else layer (forest (inlineCat names) (inline ests) (inline errs) <> forestNull 0)
diff --git a/src/Hanalyze/Plot/Linear.hs b/src/Hanalyze/Plot/Linear.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Plot/Linear.hs
@@ -0,0 +1,272 @@
+-- |
+-- Module      : Hanalyze.Plot.Linear
+-- Description : hgg 連携層 — 線形モデル族の図化 instance
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- hgg 連携層 — **線形モデル族** の図化 instance (Phase 71.5)。
+--
+-- ⚠ 親 'Hanalyze.Plot' と同じ cabal flag @plot-integration@ (既定 off) を
+-- on にしたときのみ build される。 共通基盤 (class / ModelSpec / grid 評価核) は
+-- 'Hanalyze.Plot.Core' を import して取り込む (orphan instance を許容:
+-- クラス=Core・instance=ここ・型=Wrappers)。
+--
+-- 担当する型 (= LM 系・GLM 系・WLS):
+--   LMModel / MultiLMModel / WeightedLMModel / GLMModel / MultiGLMModel。
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeFamilies #-}
+{-# LANGUAGE FlexibleContexts #-}
+module Hanalyze.Plot.Linear
+  ( familyObsDist
+  ) where
+
+import           Data.List             (sortBy, zip4)
+import           Data.Ord              (comparing)
+import qualified Hanalyze.Model.HBM.Distribution as BD
+import qualified Numeric.LinearAlgebra as LA
+
+import           Hgg.Plot.Spec     ( layer, inline
+                                       , scatter, line, band )
+
+import           Hanalyze.Model.Wrappers
+import           Hanalyze.Plot.Core
+import           Hanalyze.Fit            (weightedR2)
+import           Hanalyze.Model.Core     (FitResult, coefficientsV, fittedV, residualsV, rSquared1)
+import           Hanalyze.Model.GLM      ( Family (..), LinkFn (..), GlmPredictCI (..)
+                                                , predictGlmMuWithCI )
+import           Hanalyze.Model.LM       ( CIBand (..), confidenceBand, confidenceBandAt
+                                                , predictionBandAt )
+import           Hanalyze.Model.Formula.Design  (designMatrixF)
+
+-- ===========================================================================
+-- 多変量モデル型 (effect plot 用、 新規 fit)
+--
+-- 既存の単変数 'LMModel' / 'GLMModel' (設計行列が @[1, x]@ 固定) とは別型。
+-- formula 文字列 + 'DataFrame' で多変量 fit し、 formula を保持して評価点設計行列を
+-- 組む (HoldAgg 固定 + along grid)。 ★GLM は formula 経路が未整備なので
+-- 'designMatrixF' で設計行列を作り 'fitGLMFull' を直接呼ぶ。
+-- ===========================================================================
+
+instance MultiVarModel MultiLMModel where
+  mvFrame = mlmFrame
+  mvEvalFrame m level ef =
+    case designMatrixF (mlmFormula m) ef of
+      Left _        -> ([], Nothing)
+      Right (xe, _) ->
+        let cib = confidenceBandAt (mlmDesign m) (mlmResult m) level xe
+            los = lowerBound cib
+            his = upperBound cib
+            mu  = zipWith (\l h -> (l + h) / 2) los his
+        in (mu, Just (los, his))
+  -- 多変量 OLS の closed-form PI (評価点設計行列 → predictionBandAt)。
+  mvEvalFramePI m level ef =
+    case designMatrixF (mlmFormula m) ef of
+      Left _        -> Nothing
+      Right (xe, _) ->
+        let pib = predictionBandAt (mlmDesign m) (mlmResult m) level xe
+        in Just (lowerBound pib, upperBound pib)
+
+instance MultiVarModel MultiGLMModel where
+  mvFrame = mglmFrame
+  mvEvalFrame m level ef =
+    case designMatrixF (mglmFormula m) ef of
+      Left _        -> ([], Nothing)
+      Right (xe, _) ->
+        let beta = coefficientsV (mglmResult m)
+            cis  = [ predictGlmMuWithCI (mglmLink m) level beta (mglmSigma m) r
+                   | r <- LA.toRows xe ]
+        in (map gpMu cis, Just (map gpLo cis, map gpHi cis))
+
+-- ===========================================================================
+-- 線形モデル (描画可能)
+--
+-- 'FitResult' (数値核) は設計行列 X を保持しないが、 回帰線・CI band を描くには
+-- X が要る ('confidenceBand' は X 引数)。 そこで X と生 predictor を束ねた
+-- 「描画可能なモデル」 を別型にする (= plot Phase 15 §2.1 の開放論点を (i) で確定)。
+-- ===========================================================================
+
+
+instance Plottable LMModel where
+  -- 散布図に重ねる回帰線 + CI band。 'confidenceBand' は **訓練点**で評価し
+  -- @yHats ± se@ を返す (= grid を渡すと fitted と不整合)。 ゆえに合成 grid を
+  -- 使わず、 訓練 x を昇順ソートして直線を結ぶ (= 単回帰なら直線で grid と同形、
+  -- かつ 'confidenceBand' を無改修で再利用できる)。 ± 半幅 errorY = se。
+  toPlot m =
+    let res    = lmResult m
+        xs     = LA.toList (lmXraw m)
+        yhat   = LA.toList (fittedV res)
+        cib    = confidenceBand (lmDesign m) res defaultCILevel
+        se     = zipWith (-) (upperBound cib) yhat   -- upper - ŷ = 片側半幅
+        sorted = sortBy (comparing (\(x, _, _) -> x)) (zip3 xs yhat se)
+        xsS    = [ x | (x, _, _) <- sorted ]
+        yhatS  = [ y | (_, y, _) <- sorted ]
+        seS    = [ e | (_, _, e) <- sorted ]
+    in layer (band (inline xsS) (inline (zipWith (-) yhatS seS)) (inline (zipWith (+) yhatS seS)))
+         <> layer (line (inline xsS) (inline yhatS))
+
+  -- 残差診断 (代表回帰線 + 残差 vs fitted)。
+  diagnosticPlots m =
+    let res  = lmResult m
+        yhat = LA.toList (fittedV res)
+        resd = LA.toList (residualsV res)
+    in [ toPlot m
+       , layer (scatter (inline yhat) (inline resd))
+       ]
+
+-- | grid 評価 (Phase 16 C1)。 grid x で設計行列 @[1, x]@ を再構築し、
+-- 訓練の分散核を流用する 'confidenceBandAt' で滑らかな曲線 + 対称 CI 帯を出す。
+instance SingleVarModel LMModel where
+  svRange m = let xs = LA.toList (lmXraw m) in (minimum xs, maximum xs)
+  svGrid m level gxs =
+    let xEval = LA.fromColumns [ LA.konst 1 (length gxs), LA.fromList gxs ]
+        cib   = confidenceBandAt (lmDesign m) (lmResult m) level xEval
+        los   = lowerBound cib
+        his   = upperBound cib
+        mu    = zipWith (\l h -> (l + h) / 2) los his
+    in (mu, Just (los, his))
+  -- PI = closed form σ̂²(1 + xᵀ(XᵀX)⁻¹x) (statsmodels obs_ci と一致)。
+  svGridPI m level gxs =
+    let xEval = LA.fromColumns [ LA.konst 1 (length gxs), LA.fromList gxs ]
+        pib   = predictionBandAt (lmDesign m) (lmResult m) level xEval
+    in Just (lowerBound pib, upperBound pib)
+  -- A8: 係数 [β₀, β₁] と R² (式/R² 凡例注釈用)。
+  svCoefR2 m = Just (LA.toList (coefficientsV (lmResult m)), rSquared1 (lmResult m))
+  -- ブートストラップ: 加法誤差ゆえ obsDist=Nothing (μ + 再標本化残差)。
+  svBootKit m = Just BootKit
+    { bkX = LA.toList (lmXraw m)
+    , bkY = zipWith (+) (LA.toList (fittedV (lmResult m))) (LA.toList (residualsV (lmResult m)))
+    , bkRefit = \xs ys -> lmModel (LA.fromList xs) (LA.fromList ys)
+    , bkObsDist = Nothing }
+
+-- ===========================================================================
+-- 一般化線形モデル (描画可能)
+--
+-- GLM の不確実性帯は **μ (応答) スケールで非対称** (線形予測子 η の対称 Wald CI を
+-- 逆リンク gInv で μ に写すため、 Logit/Log 等では下側・上側の半幅が異なる)。 ゆえに
+-- LMModel/GPResult の対称 band (ŷ±se) では忠実に描けない。 そこで
+-- 下境界 lo / 上境界 hi を別々に持てる 'band' layer (= MBand area fill) を使い、 μ 曲線は
+-- 'line' で重ねる。 帯は **訓練点での Wald CI** を 'predictGlmMuWithCI' で評価する
+-- (= grid 補間でなく fit と整合)。 'fitGLMFull' が返す逆 Fisher 情報 Σ=(XᵀWX)⁻¹ が要る。
+-- ===========================================================================
+
+instance Plottable GLMModel where
+  -- μ 曲線 + 非対称 Wald CI 帯。 各訓練点 (設計行列の行) で 'predictGlmMuWithCI' を
+  -- 評価し、 x 昇順にソートして band (lo→hi の area) と μ 折れ線を重ねる。 帯を先に
+  -- 置いて μ 線を上に描く。
+  toPlot m =
+    let beta  = coefficientsV (glmResult m)
+        rows  = LA.toRows (glmDesign m)
+        cis   = [ predictGlmMuWithCI (glmLink m) defaultCILevel beta (glmSigma m) r
+                | r <- rows ]
+        quads = sortBy (comparing (\(x, _, _, _) -> x))
+                  (zip4 (LA.toList (glmXraw m))
+                        (map gpMu cis) (map gpLo cis) (map gpHi cis))
+        xsS = [ x | (x, _, _, _) <- quads ]
+        muS = [ u | (_, u, _, _) <- quads ]
+        loS = [ l | (_, _, l, _) <- quads ]
+        hiS = [ h | (_, _, _, h) <- quads ]
+    in layer (band (inline xsS) (inline loS) (inline hiS))
+         <> layer (line (inline xsS) (inline muS))
+
+  -- 残差診断 (μ 曲線 + 帯、 残差 vs fitted μ̂)。
+  diagnosticPlots m =
+    let res  = glmResult m
+        yhat = LA.toList (fittedV res)
+        resd = LA.toList (residualsV res)
+    in [ toPlot m
+       , layer (scatter (inline yhat) (inline resd))
+       ]
+
+-- | grid 評価 (Phase 16 C1)。 grid x の行 @[1, x]@ を 'predictGlmMuWithCI' に渡し、
+-- μ スケールの非対称 Wald CI 帯を滑らかに評価する (band lo/hi は別々に保持)。
+instance SingleVarModel GLMModel where
+  svRange m = let xs = LA.toList (glmXraw m) in (minimum xs, maximum xs)
+  svGrid m level gxs =
+    let beta = coefficientsV (glmResult m)
+        cis  = [ predictGlmMuWithCI (glmLink m) level beta (glmSigma m)
+                   (LA.fromList [1, gx])
+               | gx <- gxs ]
+    in (map gpMu cis, Just (map gpLo cis, map gpHi cis))
+  -- PI は **Gaussian + Identity のみ** = LM の closed form に帰着 (μ̂ = Xβ・W=I)。
+  -- 非 Gaussian (Poisson/Binomial) は予測区間が応答分布の離散/非対称分位を要し
+  -- closed form で出ないため 'Nothing' (over-claim しない・CI 帯と同じ部分集合方針)。
+  svGridPI m level gxs = case (glmFamily m, glmLink m) of
+    (Gaussian, Identity) ->
+      let xEval = LA.fromColumns [ LA.konst 1 (length gxs), LA.fromList gxs ]
+          pib   = predictionBandAt (glmDesign m) (glmResult m) level xEval
+      in Just (lowerBound pib, upperBound pib)
+    _ -> Nothing
+  -- ブートストラップ: 新規観測は Family(μ) から parametric にドロー (Poisson/Bernoulli)。
+  -- これにより closed form PI を持たない非 Gaussian GLM でも PI を出せる。
+  svBootKit m = Just BootKit
+    { bkX = LA.toList (glmXraw m)
+    , bkY = zipWith (+) (LA.toList (fittedV (glmResult m))) (LA.toList (residualsV (glmResult m)))
+    , bkRefit = \xs ys -> glmModel (glmFamily m) (glmLink m) (LA.fromList xs) (LA.fromList ys)
+    , bkObsDist = familyObsDist (glmFamily m) }
+
+-- ===========================================================================
+-- 重み付き最小二乗 (WLS)
+-- ===========================================================================
+
+-- | grid 経路に委譲 (内側 LM の svGrid/PI は非スケール xEval × スケール設計で正しい
+--   WLS CI を出す)。 'svRange' は元 x ('lmXraw') から。 'svCoefR2' のみ override し、
+--   R² は statsmodels WLS と一致する weighted R² を返す (β̂ は内側のスケール OLS が WLS)。
+instance SingleVarModel WeightedLMModel where
+  svRange  (WeightedLMModel m _ _)  = svRange m
+  svGrid   (WeightedLMModel m _ _)  = svGrid m
+  svGridPI (WeightedLMModel m _ _)  = svGridPI m
+  svCoefR2 (WeightedLMModel m ws ys) =
+    let coefs = LA.toList (coefficientsV (lmResult m))
+        yhats = case coefs of                                  -- ŷ = β₀ + β₁x (元スケール)
+          (b0 : b1 : _) -> [ b0 + b1 * x | x <- LA.toList (lmXraw m) ]
+          [b0]          -> [ b0 | _ <- LA.toList (lmXraw m) ]
+          _             -> ys
+    in Just (coefs, weightedR2 ws ys yhats)
+
+-- | ★訓練点経路 ('LMModel' の素の 'toPlot') を**使わず** grid 経路 ('statModel') に
+--   固定する。 これで WLS 線+CI が元 x スケールで出て、 元データ散布図と整合する。
+instance Plottable WeightedLMModel where
+  toPlot = toPlot . statModel
+
+-- ===========================================================================
+-- GLM family → 観測分布 (ブートストラップ PI 用)
+-- ===========================================================================
+
+-- | GLM family → 新規観測の分布関数 (μ ↦ 分布。 ブートストラップ PI の parametric ドロー用)。
+--   Gaussian は加法残差で扱うため 'Nothing' (σ̂ を別途要さない)。 'svBootKit' が使う。
+familyObsDist :: Family -> Maybe (Double -> BD.Distribution Double)
+familyObsDist Poisson  = Just (\mu -> BD.Poisson  (max 1e-9 mu))
+familyObsDist Binomial = Just (\mu -> BD.Bernoulli (min (1 - 1e-12) (max 1e-12 mu)))
+familyObsDist Gaussian = Nothing
+
+-- ===========================================================================
+-- 実測 vs 予測 (HasObsPred) — Phase 72.4
+--
+-- 実測値 = fitted + residual で復元する (回帰一般)。 WLS は内側 fit が √w スケール
+-- なので予測を 1/√w で元スケールへ戻し、 実測は保持した元 y ('wlmY') を使う。
+-- ===========================================================================
+
+-- | FitResult から (実測, 予測) を復元する共通ヘルパ。
+obsPredFromFit :: FitResult -> ([Double], [Double])
+obsPredFromFit r =
+  let f = LA.toList (fittedV r)
+      e = LA.toList (residualsV r)
+  in (zipWith (+) f e, f)
+
+instance HasObsPred LMModel where
+  obsPredPairs = obsPredFromFit . lmResult
+
+instance HasObsPred MultiLMModel where
+  obsPredPairs = obsPredFromFit . mlmResult
+
+instance HasObsPred GLMModel where
+  obsPredPairs = obsPredFromFit . glmResult
+
+instance HasObsPred MultiGLMModel where
+  obsPredPairs = obsPredFromFit . mglmResult
+
+instance HasObsPred WeightedLMModel where
+  obsPredPairs m =
+    let fScaled = LA.toList (fittedV (lmResult (wlmInner m)))
+        prd     = zipWith (\f w -> if w > 0 then f / sqrt w else f) fScaled (wlmWeights m)
+    in (wlmY m, prd)
diff --git a/src/Hanalyze/Plot/ML.hs b/src/Hanalyze/Plot/ML.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Plot/ML.hs
@@ -0,0 +1,1801 @@
+-- |
+-- Module      : Hanalyze.Plot.ML
+-- Description : hgg 連携層 — ML / 統計モデル連携族の図化 instance + 抽出子
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- hgg 連携層 — **ML / 統計モデル連携族** の図化 instance + 抽出子 (Phase 71.6)。
+--
+-- ⚠ 親 'Hanalyze.Plot' と同じ cabal flag @plot-integration@ (既定 off) を
+-- on にしたときのみ build される。 共通基盤 (class / ModelSpec / grid 評価核) は
+-- 'Hanalyze.Plot.Core' を import して取り込む (orphan instance を許容:
+-- クラス=Core・instance=ここ・型=Wrappers/各 Model module)。
+--
+-- 担当する型・ヘルパ (= Phase 68 A1-A7 群):
+--   クラスタリング (KMeans) / 木・アンサンブル (PCA/RF/GB/DT) / 分類
+--   (Discriminant/NaiveBayes/KNN) / 次元圧縮 (PLS) / 時系列・生存・FDA
+--   (Forecast/GARCH/AFT/FunctionalPCA/FLM) / 罰則回帰・因果探索 (Reg/LiNGAM) /
+--   記述統計・検定 (TestResult)。 新規 plot mark は不要 (既存 mark の組合せ)。
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeFamilies #-}
+{-# LANGUAGE FlexibleContexts #-}
+module Hanalyze.Plot.ML
+  ( -- * クラスタリング (Phase 68 A1)
+    clusterScatterOf
+  , centroidsOf
+    -- * クラスタを囲む (凸包輪郭 / 95% 共分散楕円) — Phase 76.B
+  , clusterHullOf
+  , clusterEllipseOf
+    -- * DOE prediction profiler — Phase 78.C / 78.D / 78.E / 78.F
+  , ResidualMode (..)
+  , ProfilerSpec (..)
+  , profiler
+  , profilerResidual
+  , contourOf
+    -- * 階層クラスタリング dendrogram — Phase 76.C
+  , DendroOpts (..)
+  , defaultDendroOpts
+  , dendrogramOf
+  , dendrogramOf'
+    -- * 木/アンサンブル — Phase 68 A2
+  , treeImportances
+    -- * 決定木 樹形図 (rpart.plot 流・annotation ベース) — Phase 75.26
+  , treePlot
+  , treePlotRaw
+    -- * 分類 — Phase 68 A3
+  , decisionBoundaryOf
+  , confusionOf
+    -- * MDS 埋め込み (モデル型 + 群色オプション) — Phase 75.21
+  , MDSView
+  , mdsView
+  , mdsGroupBy
+    -- * NN 可視化 — Phase 75.5
+  , nnLossOf
+    -- * カーネル SVM サポートベクタ可視化 — Phase 75.12
+  , svmSupportVectorsOf
+    -- * 決定境界を線で描く (等高線) — Phase 75.13b
+  , ScorePredict (..)
+  , decisionLineOf
+    -- * 部分従属図 (PDP / ICE) — Phase 75.27
+  , RegPredict (..)
+    -- ** Plottable 中間型 (Phase 76.D・HBM 抽出子と同型・toPlot で描画)
+  , PDPView
+  , pdp
+  , pdpIce
+  , pdpOf
+  , pdpIceOf
+  , pdpPlot
+  , pdpIcePlot
+  , partialDependencePlot
+  , partialDependenceIcePlot
+    -- * 次元圧縮 (PLS 診断ビュー) — Phase 68 A4 / 70.B
+  , PLSView (..)
+  , PLSViewKind (..)
+  , scoreView
+  , loadingView
+  , vipView
+    -- * 時系列・生存・FDA — Phase 68 A5
+  , garchVolatility
+  , aftSurvivalAt
+    -- * 罰則回帰・因果探索 — Phase 68 A6
+  , regPathPlot
+  , lingamDag
+  , lingamDagNamed
+  , varLagDagNamed
+  , bootstrapEdgeProbOf
+    -- * 記述統計・検定 — Phase 68 A7
+  , testForest
+  , testForestLabeled
+  , describeBox
+  ) where
+
+import           Control.Applicative   ((<|>))
+import           Data.Maybe            (fromMaybe)
+import           Data.List             (nub, sort, elemIndex, sortBy, foldl')
+import           Data.Ord              (comparing)
+import qualified Data.Map.Strict       as Map
+import qualified Data.Vector           as V
+import qualified Data.Vector.Unboxed    as VU
+import qualified Numeric.LinearAlgebra as LA
+
+import           Data.Text             (Text)
+import qualified Data.Text             as T
+
+import           Hgg.Plot.Spec     ( VisualSpec, layer, inline, inlineCat
+                                       , fromHex
+                                       , scatter, line, band
+                                       , shape, MarkShape (..)
+                                       , heatmap, contour, contourFilled, contourLevels
+                                       , label, color, colorBy, bar, boxplot, forest, forestNull
+                                       , legendOff
+                                       , title, coordFlip, coordCartesian, subplots, subplotCols
+                                       , scaleXDiscreteLimits
+                                       , xLabel, yLabel
+                                       , annotTextP, annotRectP
+                                       , annotate, Annotation (..)
+                                       , theme, ThemeName (..), themeGrid, themeAxisLine, panelBorder
+                                       , tickColor
+                                       , xAxis, yAxis, hideTicks
+                                       , axisBreaksLabeled, axisRotate
+                                       , scaleColorManual
+                                       , themeLegendFont, fontSize
+                                       , alpha
+                                       , dagFromListsWithPlates
+                                       , DAGNode (..), DAGEdge (..)
+                                       , DAGNodeKind (..), DAGLayoutAlgorithm (..) )
+import           Hgg.Plot.Unit     (Pos (..))
+import           Hgg.Plot.Palette  (ggplotHue)
+import           Hgg.Plot.Custom.Dendrogram (DendroSeg (..), DendroPayload (..), dendrogramMark)  -- Phase 48
+import           Hgg.Plot.DAG      (layoutHierarchicalFullWithPlates)
+import           Hgg.Plot.Render.Special (bakeDAGRoutesInSpec)
+
+import           Numeric               (showFFloat)
+
+import           Hanalyze.Data.ColumnSource     (ColumnSource (..))
+import           Hanalyze.Model.Formula.Frame   (ModelFrame (..), VarRole (..))
+import           Hanalyze.Model.Formula.Design  (designMatrixF)
+import           Hanalyze.Fit                   (DesignHBMFit (..))
+import           Hanalyze.Model.Wrappers
+import           Hanalyze.Plot.Core
+import           Hanalyze.Model.LM     (linspace)
+import           Hanalyze.Model.GP     (gpNoiseVar)
+import           Hanalyze.Model.Weibull (quantileNormal)
+import           Hanalyze.Model.PLS    (predictPLS)
+import           Hanalyze.Model.Cluster (KMeansResult (..))
+import           Hanalyze.Model.HierarchicalCluster
+                   (HClusterFit (..), cutTree)
+import           Hanalyze.Model.RandomForest (RandomForest (..), featureImportance, rfPermutationImportance, defaultFeatureNames, Tree)
+import qualified Hanalyze.Model.RandomForest as RF
+import           Hanalyze.Model.GradientBoosting (GBRegressor (..), GBClassifier (..), predictGBR)
+import           Hanalyze.Model.PartialDependence
+                   (PDPResult, partialDependence, pdpGrid, pdpMean)   -- pdpIce 欄は PD. で参照 (関数名と衝突回避)
+import qualified Hanalyze.Model.PartialDependence as PD
+import           Hanalyze.Model.RandomForestClassifier (RFClassifierFit (..))
+import           Hanalyze.Model.DecisionTree (DTree (..), DTFit (..))
+import           Hanalyze.Model.Discriminant (DiscriminantFit (..), predictDiscriminant)
+import           Hanalyze.Model.NaiveBayes (NBModel (..), GaussianNB (..)
+                                       , MultinomialNB (..), predictNB)
+import           Hanalyze.Model.KNN (KNNClassifier (..), predictKNNC)
+import           Hanalyze.Model.NeuralNetwork (MLPFit (..), predictMLPClass)
+import           Hanalyze.Model.SVM (SVM (..), SVMMulti (..)
+                                       , predictSVM, predictSVMMulti, predictSVMScore)
+import           Hanalyze.Model.MDS (MDSResult (..))
+import           Hanalyze.DataIO.Convert (getTextVec, getDoubleVec)
+import qualified DataFrame.Internal.DataFrame  as DXD
+import           Hanalyze.Model.PLS (PLSFit (..))
+import           Hanalyze.Model.GARCH (GARCHFit (..))
+import           Hanalyze.Model.AFT (AFTFit (..), logS, predictAFT)
+import           Hanalyze.Model.FDA (FunctionalPCA (..), FLMResult (..))
+import           Hanalyze.Model.Regularized (RegFit (..))
+import           Hanalyze.Model.LiNGAM.Direct (DirectLiNGAMFit (..))
+import           Hanalyze.Model.LiNGAM.Parce (ParceFit (..))
+import           Hanalyze.Model.LiNGAM.MultiGroup (MultiGroupFit (..))
+import           Hanalyze.Model.LiNGAM.VAR (VARLiNGAMFit (..))
+import           Hanalyze.Model.LiNGAM.Pairwise (PairwiseResult (..), PairwiseDirection (..))
+import           Hanalyze.Model.LiNGAM.Bootstrap (BootstrapResult (..))
+import           Hanalyze.Model.LiNGAM.ICA (ICALiNGAMFit (..))
+import           Hanalyze.Stat.CorrelationNetwork (CorrelationGraph (..))
+import           Hanalyze.Stat.Test (TestResult (..))
+import           Hanalyze.Model.PCA     (PCAResult (..))
+import           Hanalyze.Model.Survival (KMResult (..))
+import           Hanalyze.Model.CompetingRisks (CRFit (..))
+import           Hanalyze.Model.TimeSeries (ARFit (..), forecastAR)
+
+-- ===========================================================================
+-- クラスタリング (KMeans) の図 — Phase 68 A1
+--
+-- KMeans の分野定番の図は「クラスタ別散布 (色=ラベル)」。 ただし
+-- 'KMeansResult' は centroids + labels + inertia のみ保持し **生データ座標を
+-- 持たない**。 そこで 'surfaceOf' <> 'dataScatter3DOf' と同じ **model 層 / data
+-- 層の二層イディオム**に分ける:
+--
+--   * 'Plottable' 'KMeansResult' の 'toPlot' = centroid 散布のみ (データ不要・
+--     クラス契約 @m -> VisualSpec@ を満たす)。 既定は centroid 行列の第 0/1 次元。
+--   * 'clusterScatterOf' = データ点をラベル色で散布 (要データ源・列名指定)。
+--   * 'centroidsOf' = centroid を任意 2 次元で重畳 (✚ マーカー・次元 index 明示)。
+--
+-- 定番図 = @df |>> (clusterScatterOf df res \"x\" \"y\" <> centroidsOf res 0 1)@。
+-- ⚠ centroid 行列は **学習時の特徴量列順**のみで列名を持たない。 重畳時は
+-- データ列 (@xn@, @yn@) と centroid 次元 (@i@, @j@) の対応をユーザが揃える。
+-- ===========================================================================
+
+-- | KMeans クラスタの代表図 = centroid 散布 (第 0/1 次元・クラスタ色・✚ マーカー)。
+--   生データ点は 'clusterScatterOf' で別 layer に重ねる
+--   (cf. 'surfaceOf' (model) <> 'dataScatter3DOf' (data) の二層イディオム)。
+instance Plottable KMeansResult where
+  toPlot res = centroidsOf res 0 1
+
+-- | データ点をラベル色で散布する (= KMeans の定番「クラスタ別散布」)。
+--   @d@ は 'ColumnSource' (DataFrame / assoc / Map 等)、 @xn@\/@yn@ は描く列名。
+--   色はクラスタラベル ('kmrLabels') の categorical (= 点と同順)。
+--   列が無ければ空 ('mempty')。
+clusterScatterOf :: ColumnSource d => d -> KMeansResult -> Text -> Text -> VisualSpec
+clusterScatterOf d res xn yn =
+  case (lookupCol xn d, lookupCol yn d) of
+    (Just xs, Just ys) ->
+      layer ( scatter (inline xs) (inline ys)
+            <> colorBy (inlineCat (map (T.pack . show) (kmrLabels res))) )
+    _ -> mempty
+
+-- | centroid を任意 2 次元 (@i@, @j@) で散布 (クラスタ色・✚ マーカーで点と区別)。
+--   index が centroid 次元数を超える / 負なら空 ('mempty')。
+centroidsOf :: KMeansResult -> Int -> Int -> VisualSpec
+centroidsOf res i j
+  | i < 0 || j < 0 || i >= d || j >= d = mempty
+  | otherwise =
+      layer ( scatter (inline xs) (inline ys)
+            <> colorBy (inlineCat cids)
+            <> shape MShCross )
+  where
+    cs   = kmrCentroids res
+    d    = LA.cols cs
+    k    = LA.rows cs
+    cols = LA.toColumns cs
+    xs   = LA.toList (cols !! i)
+    ys   = LA.toList (cols !! j)
+    cids = map (T.pack . show) [0 .. k - 1 :: Int]
+
+-- | render の categorical 群色 (colorBy → @sort.nub@ 順 → 'ggplotHue') を analyze 側で
+--   再現し、 カテゴリ名 → 色(hex) の辞書を返す。 annotation の色は spec 時に確定するため
+--   ('clusterScatterOf'/'toPlot' の凡例色と一致させる用)。
+hueColorMap :: [Text] -> Map.Map Text Text
+hueColorMap labels =
+  let cats = sort (nub labels)
+  in Map.fromList (zip cats (ggplotHue (length cats) ++ repeat "#cccccc"))
+
+-- | 色・太さ指定の線分注釈 ('annotLineP' は色固定なので 'AnnLine' を直接構築)。
+annotLineC :: Text -> Double -> (Double, Double) -> (Double, Double) -> VisualSpec
+annotLineC col w (x1, y1) (x2, y2) = annotate AnnLine
+  { anX1 = PNative x1, anY1 = PNative y1, anX2 = PNative x2, anY2 = PNative y2
+  , anColor = col, anWidth = w }
+
+-- | 頂点列を閉じた折れ線 (最後→最初も結ぶ) として色付き線分で描く。
+closedPolyline :: Text -> Double -> [(Double, Double)] -> VisualSpec
+closedPolyline _   _ []  = mempty
+closedPolyline _   _ [_] = mempty
+closedPolyline col w vs  =
+  mconcat [ annotLineC col w p q | (p, q) <- zip vs (tail vs ++ [head vs]) ]
+
+-- | 2D 凸包 (Andrew monotone chain)・反時計回り頂点列。 3 点未満は入力そのまま。
+convexHull :: [(Double, Double)] -> [(Double, Double)]
+convexHull ps0 =
+  let ps = sort (nub ps0)                 -- lexicographic (x, y)
+  in if length ps <= 2 then ps
+     else let lower = half ps
+              upper = half (reverse ps)
+          in init lower ++ init upper      -- 端点重複を除いて連結
+  where
+    -- 単調鎖: 直近 2 点と p が右回り (cross<=0) の間は pop。 stack は head=最新。
+    half = reverse . foldl step []
+    step acc p = p : popRight acc p
+    popRight (b : a : rest) p
+      | cross a b p <= 0 = popRight (a : rest) p
+    popRight acc _ = acc
+    cross (ox, oy) (ax, ay) (bx, by) = (ax - ox) * (by - oy) - (ay - oy) * (bx - ox)
+
+-- | クラスタ点群をラベルごとにグルーピング (色は 'clusterScatterOf' と一致)。
+--   d が xn/yn 列を持たなければ空。
+clusterGroups
+  :: ColumnSource d => d -> KMeansResult -> Text -> Text
+  -> [(Text, [(Double, Double)])]         -- (群色 hex, 点列)
+clusterGroups d res xn yn =
+  case (lookupCol xn d, lookupCol yn d) of
+    (Just xs, Just ys) ->
+      let labs = kmrLabels res
+          cmap = hueColorMap (map (T.pack . show) labs)
+          gmap = Map.fromListWith (flip (++))
+                   [ (l, [(x, y)]) | (l, x, y) <- zip3 labs xs ys ]
+      in [ (Map.findWithDefault "#cccccc" (T.pack (show l)) cmap, ps)
+         | (l, ps) <- Map.toList gmap ]
+    _ -> []
+
+-- | 各クラスタを **凸包の輪郭線**で囲む (ggplot @geom_encircle@ 相当・塗りなし)。
+--   群色は 'clusterScatterOf' と一致。 定番 = @cdf |>> (clusterScatterOf … \<\> clusterHullOf …)@。
+--   ⚠ annotation は軸平行矩形しか塗れないため**輪郭線のみ** (半透明塗りは将来 'MPolygon' 移譲)。
+clusterHullOf :: ColumnSource d => d -> KMeansResult -> Text -> Text -> VisualSpec
+clusterHullOf d res xn yn =
+  mconcat [ closedPolyline col 1.5 (convexHull ps)
+          | (col, ps) <- clusterGroups d res xn yn ]
+
+-- | 各クラスタを **95% 共分散楕円** (χ²(0.95, 2)=5.991) の輪郭で囲む (ggplot @stat_ellipse@
+--   相当・正規分布仮定)。 群平均 μ・共分散 Σ を固有分解 ('LA.eigSH') し、 固有軸方向へ
+--   半径 √5.991·√λ の楕円点列を折れ線で近似。 群色は 'clusterScatterOf' と一致。
+--   点数 3 未満の群は描かない (共分散が定義できないため)。
+clusterEllipseOf :: ColumnSource d => d -> KMeansResult -> Text -> Text -> VisualSpec
+clusterEllipseOf d res xn yn =
+  let ellipses = [ (col, ellipse95 ps) | (col, ps) <- clusterGroups d res xn yn ]
+      outlines = mconcat [ closedPolyline col 1.5 pts | (col, pts) <- ellipses ]
+      allPts   = concatMap snd ellipses
+      -- annotation は軸ドメインを駆動しないため、 95% 楕円 (データ点より外へ広がる) が
+      -- フレームをはみ出す。 楕円点を alpha=0 の不可視散布で載せ軸を広げる (colorBy 無し=
+      -- 凡例に出ない)。 決定境界の coordCartesian と違い、 ここは重畳データ点も含めて
+      -- auto-fit させたいので固定でなく anchor 方式。
+      anchor
+        | null allPts = mempty
+        | otherwise   = layer ( scatter (inline (map fst allPts)) (inline (map snd allPts))
+                                <> alpha 0 )
+  in outlines <> anchor
+  where
+    seg   = 64 :: Int
+    scl   = sqrt 5.991                       -- χ²(0.95, 2)
+    ellipse95 ps
+      | n < 3     = []
+      | otherwise =
+          [ ( mux + a * (v1 !! 0) + b * (v2 !! 0)
+            , muy + a * (v1 !! 1) + b * (v2 !! 1) )
+          | t <- [ 2 * pi * fromIntegral k / fromIntegral seg | k <- [0 .. seg - 1] ]
+          , let a = scl * sqrt (max 0 l1) * cos t
+                b = scl * sqrt (max 0 l2) * sin t ]
+      where
+        n   = length ps
+        xs  = map fst ps; ys = map snd ps
+        mux = sum xs / fromIntegral n
+        muy = sum ys / fromIntegral n
+        sxx = sum [ (x - mux) ^ (2 :: Int) | x <- xs ] / fromIntegral (n - 1)
+        syy = sum [ (y - muy) ^ (2 :: Int) | y <- ys ] / fromIntegral (n - 1)
+        sxy = sum [ (x - mux) * (y - muy) | (x, y) <- ps ] / fromIntegral (n - 1)
+        sigma = LA.fromLists [[sxx, sxy], [sxy, syy]]
+        (vals, vecs) = LA.eigSH (LA.trustSym sigma)   -- λ 降順・列=固有ベクトル
+        l1 = vals `LA.atIndex` 0
+        l2 = vals `LA.atIndex` 1
+        cols = LA.toColumns vecs
+        v1 = LA.toList (cols !! 0)
+        v2 = LA.toList (cols !! 1)
+
+-- | dendrogram の描画オプション。
+data DendroOpts = DendroOpts
+  { doLineColor      :: !Text            -- ^ 閾値超 (または閾値未指定) の線色。
+  , doWidth          :: !Double          -- ^ 線幅。
+  , doColorThreshold :: !(Maybe Double)  -- ^ @Just t@ で高さ @t@ 未満のサブツリーをクラスタ色分け
+                                         --   (scipy @color_threshold@ 流)。 @Nothing@ で単色。
+  } deriving (Show)
+
+-- | 既定 = 単色 (grey20 相当・閾値なし)。
+defaultDendroOpts :: DendroOpts
+defaultDendroOpts = DendroOpts "#4C4C4C" 1.2 Nothing
+
+instance Plottable HClusterFit where
+  toPlot = dendrogramOf
+
+-- | 階層クラスタリング結果を **dendrogram** で描く (scipy @dendrogram@ / ggdendro 流)。
+--   マージ列 ('hcMerges') と高さ ('hcHeights') から U 字リンク (縦 2 + 横 1) を 'AnnLine' で
+--   描画。 葉は x 軸に等間隔・各マージノードの x = 子の中点・y = マージ高。 リーフに元サンプル
+--   ID ラベル。 plot core は触らず annotation で描く (将来 plot 正式 mark 移譲予定)。
+dendrogramOf :: HClusterFit -> VisualSpec
+dendrogramOf = dendrogramOf' defaultDendroOpts
+
+-- | 色閾値・線色等を指定できる版。
+dendrogramOf' :: DendroOpts -> HClusterFit -> VisualSpec
+dendrogramOf' opts fit
+  | n <= 1 || null merges = mempty
+  | otherwise =
+      -- R base / scipy 同様 grid・軸線・枠なし (theme_minimal + grid off)。
+      theme ThemeMinimal <> themeGrid False <> themeAxisLine False <> panelBorder False
+        <> tickColor "transparent"      -- 目盛マーク (短線) を消す。 数字ラベルは残る。
+        -- 葉ラベルは x 軸目盛 (slot 位置・縦書き) で。 軸ラベルは margin を予約するので
+        -- リンク根と被らない (annotText と違い R と同挙動)。
+        -- ★ axisRotate は CCW 正 (R/matplotlib/ggplot 準拠・hgg Phase 50 A1)。
+        --   90 = CCW 90 = 下→上読みで R base / scipy dendrogram の既定向きと一致。
+        <> xAxis (axisBreaksLabeled leafTicks <> axisRotate 90)
+        <> layer (dendrogramMark payload)  -- ★ Phase 48: U字リンクを custom mark で描く (焼き込み)。
+                                           --   encX/encY で軸 range を束ねる (旧 anchor 不要)。
+        <> yAxisLine                    -- 軸線は 2 辺一括制御しか無いので y 軸線だけ自前描画。
+        <> yLabel "height"              -- y = マージ高 (結合時の非類似度・Ward 増分)。
+  where
+    n       = hcNumOriginals fit
+    merges  = hcMerges fit
+    heights = hcHeights fit
+    root    = 2 * n - 2                                 -- 最終マージ = 根ノード
+    childrenOf node = merges !! (node - n)
+    leavesOf node
+      | node < n  = [node]
+      | otherwise = let (a, b) = childrenOf node in leavesOf a ++ leavesOf b
+    order   = leavesOf root                             -- 葉 ID を左→右の並びで
+    slotOf  = Map.fromList (zip order [0 :: Int ..])
+    -- ノードの x (子の中点)・高さ・代表葉を fold で確定 (子は id が小さく先に入る)。
+    (nodeX, nodeH, leafRep) = foldl' step (x0, h0, r0) (zip [0 :: Int ..] merges)
+      where
+        x0 = Map.fromList [ (l, fromIntegral (slotOf Map.! l)) | l <- [0 .. n - 1] ]
+        h0 = Map.fromList [ (l, 0 :: Double) | l <- [0 .. n - 1] ]
+        r0 = Map.fromList [ (l, l) | l <- [0 .. n - 1] ]
+        step (mx, mh, mr) (i, (a, b)) =
+          let node = n + i
+          in ( Map.insert node ((mx Map.! a + mx Map.! b) / 2) mx
+             , Map.insert node (heights !! i) mh
+             , Map.insert node (mr Map.! a) mr )
+    maxH    = maximum heights
+    -- 葉ラベル = x 軸目盛 (slot 位置に元サンプル ID)。 縦書きは axisRotate 90。
+    leafTicks = [ (fromIntegral slot, T.pack (show leaf))
+                | (leaf, slot) <- zip order [0 :: Int ..] ]
+    -- 色閾値: t 未満マージ数だけ切って各葉のクラスタ ID を得る (hcMerges は高さ昇順)。
+    thrInf     = maybe (1 / 0) id (doColorThreshold opts)
+    kCut       = n - length (filter (< thrInf) heights)
+    clusterIds = cutTree fit kCut
+    distinctCs = foldr (\c acc -> if c `elem` acc then acc else acc ++ [c])
+                       [] (V.toList clusterIds)         -- 出現順
+    cmap       = Map.fromList (zip distinctCs
+                   (ggplotHue (length distinctCs) ++ repeat "#999999"))
+    linkColor i = case doColorThreshold opts of
+      Just t | heights !! i < t ->
+        Map.findWithDefault (doLineColor opts)
+                            (clusterIds V.! (leafRep Map.! (n + i))) cmap
+      _ -> doLineColor opts
+    -- U 字リンク (子の高さ→マージ高の縦線 2 本 + マージ高の横線 1 本) を焼き込み線分に。
+    -- 座標系は従来の annotLine 版と同一 (x=葉 slot/node 中点、 y=height)。
+    payload = DendroPayload
+      { dpSegments = concat
+          [ [ DendroSeg xa ha  xa hgt col w
+            , DendroSeg xa hgt xb hgt col w
+            , DendroSeg xb hgt xb hb  col w ]
+          | (i, (a, b)) <- zip [0 :: Int ..] merges
+          , let xa  = nodeX Map.! a; xb = nodeX Map.! b
+                ha  = nodeH Map.! a; hb = nodeH Map.! b
+                hgt = heights !! i
+                col = linkColor i
+                w   = doWidth opts ]
+      , dpXRange = (-0.6, fromIntegral n - 0.4)   -- 旧 anchor と同じ range
+      , dpYRange = (0, maxH * 1.05)
+      }
+    -- 左辺 (panel npc x=0) に y 軸線を 1 本 (下辺 x 軸線は出さない = R 流)。
+    yAxisLine = annotate AnnLine
+      { anX1 = PNpc 0, anY1 = PNpc 0, anX2 = PNpc 0, anY2 = PNpc 1
+      , anColor = "#333333", anWidth = 1 }
+
+-- ===========================================================================
+-- 時系列予測 (描画可能)
+--
+-- AR(p) の点予測 'forecastAR' は将来値の中心のみを返す。 予測の不確実性帯は **h-step
+-- 予測分散** から得る: AR の MA(∞) 表現の ψ-weights (ψ₀=1, ψⱼ=Σφᵢψⱼ₋ᵢ) を用いて
+-- @Var(ŷ_{n+k}) = σ² Σ_{j=0}^{k-1} ψⱼ²@ (σ² = 革新分散 'arResidVar')。 これは Gaussian
+-- 革新の下での正統な予測区間 (地平 k とともに単調に広がる)。 対称ゆえ band は
+-- @中心 ± z·se@。 'toPlot' は履歴折れ線 + 予測折れ線 + 予測区間 band を 1 枚に重ねる。
+-- ===========================================================================
+
+-- | AR(p) の MA(∞) 表現の ψ-weights ψ₀..ψ_{h-1} (ψ₀=1, ψⱼ=Σ_{i=1}^{min j p} φᵢ ψⱼ₋ᵢ)。
+arPsiWeights :: [Double] -> Int -> [Double]
+arPsiWeights phi h = go [1.0]
+  where
+    p = length phi
+    go ps
+      | length ps >= h = take h ps
+      | otherwise =
+          let j  = length ps
+              pj = sum [ (phi !! (i - 1)) * (ps !! (j - i)) | i <- [1 .. min j p] ]
+          in go (ps ++ [pj])
+
+-- | k-step (k=1..h) 予測標準誤差 se_k = sqrt(σ² Σ_{j<k} ψⱼ²)。
+arForecastSE :: ARFit -> Int -> [Double]
+arForecastSE fit h =
+  let phi  = LA.toList (arPhi fit)
+      s2   = arResidVar fit
+      psis = arPsiWeights phi h
+  in [ sqrt (s2 * sum (map (^ (2 :: Int)) (take k psis))) | k <- [1 .. h] ]
+
+instance Plottable ForecastModel where
+  -- 履歴折れ線 + 予測折れ線 + 予測区間 band (中心 ± 1.96·se)。 x = 時刻 index
+  -- (履歴 1..n、 予測 n+1..n+h)。 予測線は履歴末尾点から繋げる。 帯を先・線を後に重ねる。
+  toPlot m =
+    let fit  = fmFit m
+        hist = LA.toList (fmHistory m)
+        n    = length hist
+        h    = fmHorizon m
+        fc   = LA.toList (forecastAR fit (fmHistory m) h)
+        se   = arForecastSE fit h
+        fx   = [ fromIntegral (n + k) | k <- [1 .. h] ] :: [Double]
+        lo   = zipWith (\f s -> f - 1.96 * s) fc se
+        hi   = zipWith (\f s -> f + 1.96 * s) fc se
+        histX = [ fromIntegral i | i <- [1 .. n] ] :: [Double]
+        -- 予測線は履歴末尾 (n, hist[n-1]) から始めて連続させる。
+        lineX = fromIntegral n : fx
+        lineY = last hist : fc
+    in layer (band (inline fx) (inline lo) (inline hi))
+         <> layer (line (inline histX) (inline hist))
+         <> layer (line (inline lineX) (inline lineY))
+
+-- ===========================================================================
+-- 生存解析 (描画可能)
+--
+-- KM 生存曲線・CIF (競合リスク) はいずれも階段関数。 'stepVerts' (Core) で階段頂点を
+-- 明示展開して line で結ぶ。 KM は s0=1 で下降、 CIF は s0=0 で上昇。
+-- ===========================================================================
+
+instance Plottable KMResult where
+  -- KM 生存曲線 (階段、 S=1 から下降)。
+  toPlot km =
+    let pts   = zip (kmrTimes km) (kmrSurvival km)
+        verts = stepVerts 1.0 pts
+    in layer (line (inline (map fst verts)) (inline (map snd verts)))
+
+instance Plottable CRFit where
+  -- 競合リスク CIF (cause ごとに 0 から上昇する階段、 色分け重畳)。
+  toPlot cr =
+    let ts = LA.toList (crfTimes cr)
+        mkCause (i, (_cause, cifV)) =
+          let pts   = zip ts (LA.toList cifV)
+              verts = stepVerts 0.0 pts
+              col   = quantilePalette !! (i `mod` length quantilePalette)
+          in layer (line (inline (map fst verts)) (inline (map snd verts))
+                      <> color (fromHex col))
+    in foldMap mkCause (zip [0 ..] (crfCIF cr))
+
+-- ===========================================================================
+-- 多変量・木 (描画可能)
+--
+-- PCA の代表図は **scree plot** (各主成分の寄与率 'pcaExplainedRatio' を棒で)、 木 (RF) の
+-- 代表図は **特徴重要度バー** ('featureImportance')。 いずれも自己完結ゆえそのまま
+-- 'Plottable'。 棒の x 軸はラベル ("PC1".. / "f1"..) なので 'inlineCat' (categorical) で渡す
+-- (heatmap A9 と同じく 'bar' も categorical 軸が必要)。 優先低 (§3.5 A14) ゆえ scree/重要度
+-- の 1 枚ずつに絞る (biplot や木構造図は将来拡張)。
+-- ===========================================================================
+
+instance Plottable PCAResult where
+  -- scree plot: 各主成分 (PC1, PC2, …) の寄与率を棒で。
+  toPlot res =
+    let ratios = LA.toList (pcaExplainedRatio res)
+        labels = [ "PC" <> T.pack (show k) | k <- [1 .. length ratios] ]
+    in layer (bar (inlineCat labels) (inline ratios))
+
+instance Plottable RandomForest where
+  -- R 'varImpPlot' 流の 2 パネル: 左 = impurity (IncNodePurity)、 右 = permutation
+  -- (%IncMSE)。 各パネルは降順ソート + 実列名 + 横棒 ('coordFlip')。
+  toPlot rf =
+    let n     = V.length (featureImportance rf)
+        names = case rfFeatureNames rf of
+                  [] -> defaultFeatureNames n
+                  ns -> ns
+        imp   = V.toList (featureImportance rf)
+        perm  = V.toList (rfPermutationImportance rf)
+    in subplots
+         [ importanceBarNamed "IncNodePurity (impurity)" names imp
+         , importanceBarNamed "%IncMSE (permutation)"    names perm ]
+       <> subplotCols 2
+
+-- | 名前つき importance を横棒 ('coordFlip') で描く (R 'varImpPlot' 流)。 重要度で
+--   ソートするため 'scaleXDiscreteLimits' でカテゴリ順を明示する (bar 軸は既定
+--   アルファベット順ゆえデータ並びでは効かない)。 coordFlip 後は limits 順が下→上
+--   なので、 昇順 limits を渡して最重要を上端に置く。 タイトル付き。
+importanceBarNamed :: T.Text -> [T.Text] -> [Double] -> VisualSpec
+importanceBarNamed ttl names vals =
+  let ascByVal = map fst (sortBy (comparing snd) (zip names vals))  -- 昇順 → 最大が末尾 = 上端
+  in layer (bar (inlineCat names) (inline vals))
+       <> scaleXDiscreteLimits ascByVal
+       <> coordFlip <> title ttl
+
+-- ===========================================================================
+-- 木/アンサンブル — Phase 68 A2
+--
+-- 各モデルの分野定番図を **既存 mark のみ**で描く (新規 plot mark 不要):
+--
+--   * GradientBoosting (回帰/分類)・RandomForestClassifier = **特徴重要度 bar**。
+--     GBM は重要度フィールドを持たないので弱学習器 ('Tree') の split 使用回数から
+--     純粋計算する ('treeImportances'・RF.'featureImportance' と同方式・正規化)。
+--   * DecisionTree = **樹形図**。 決定木は DAG の特殊形 (二分木) ゆえ、 HBM の
+--     ModelGraph と同じ MDAG (Sugiyama 階層 layout) を **再利用**して node-link で描く
+--     (split ノード = "f{j} ≤ {thr}"、 葉 = "y={class}")。
+--
+-- ⚠ DecisionTree の edge True/False ラベル・gini・サンプル数表示 (sklearn plot_tree
+-- 相当) は DAGNode/DAGEdge が持たないため v1 では描かない。 必要なら専用 mark を
+-- plot 側 Phase として起こす (= dendrogram Phase 48 と同型の判断)。
+-- ===========================================================================
+
+-- | 弱学習器 ('Tree') 列の split 使用回数による特徴重要度 (RF と同方式・合計 1 に正規化)。
+--   特徴数は出現した最大 index + 1 (= 木で一度も使われない末尾特徴は現れない)。
+treeImportances :: [Tree] -> [Double]
+treeImportances trees =
+  let counts = foldr walk Map.empty trees
+      walk (RF.Leaf _)       m = m
+      walk (RF.Node j _ l r) m = walk l (walk r (Map.insertWith (+) j (1 :: Double) m))
+      d   = if Map.null counts then 0 else maximum (Map.keys counts) + 1
+      raw = [ Map.findWithDefault 0 j counts | j <- [0 .. d - 1] ]
+      tot = sum raw
+  in if tot <= 0 then raw else map (/ tot) raw
+
+instance Plottable GBRegressor where
+  -- 弱学習器の split 使用回数による特徴重要度 bar。
+  toPlot gb = importanceBar (treeImportances (gbrTrees gb))
+
+instance Plottable GBClassifier where
+  toPlot gb = importanceBar (treeImportances (gbcTrees gb))
+
+instance Plottable RFClassifierFit where
+  -- R 'varImpPlot' 流の 2 パネル: 左 = permutation (MeanDecreaseAccuracy)、
+  -- 右 = gini 減少 (MeanDecreaseGini・MDI)。 各パネル降順・実列名・横棒。
+  toPlot fit =
+    let perm  = LA.toList (rfcImportance fit)
+        gini  = LA.toList (rfcGiniImportance fit)
+        names = case rfcFeatureNames fit of
+                  [] -> defaultFeatureNames (length perm)
+                  ns -> ns
+    in subplots
+         [ importanceBarNamed "MeanDecreaseAccuracy" names perm
+         , importanceBarNamed "MeanDecreaseGini"     names gini ]
+       <> subplotCols 2
+
+instance Plottable DTree where
+  -- 決定木 → node-link 樹形図 (MDAG 再利用・Sugiyama 階層 layout)。
+  toPlot t =
+    let (dnodes, dedges)     = dtreeToDag t
+        (positioned, routed) = layoutHierarchicalFullWithPlates dnodes dedges []
+    in bakeDAGRoutesInSpec $
+         layer (dagFromListsWithPlates positioned routed LayoutHierarchical [])
+
+-- | 学習済み 'DTFit' → **rpart.plot 流**の樹形図 ('treePlot' と同じ)。 @df |-> decisionTree@
+--   の返り値をそのまま @toPlot@ に渡せる。 素の node-link 図は 'DTree' の 'Plottable'。
+instance Plottable DTFit where
+  toPlot = treePlot
+
+-- | 'DTree' を MDAG の node/edge 列へ変換する。 ノード id は根から L/R を辿る経路
+--   ("n" / "nL" / "nLR" …) で一意。 split ノードは @NodeOther@、 葉は @NodeObserved@
+--   (色で区別)。 左 child = 条件成立 (≤)・右 = 不成立 (>) の慣例で並べる。
+dtreeToDag :: DTree -> ([DAGNode], [DAGEdge])
+dtreeToDag = go "n"
+  where
+    mkNode nid lbl kind = DAGNode
+      { dnId = nid, dnLabel = lbl, dnKind = kind, dnDist = Nothing, dnX = 0, dnY = 0 }
+    go nid DLeaf{dlMajority = maj} =
+      ( [ mkNode nid ("y=" <> T.pack (show maj)) NodeObserved ], [] )
+    go nid DNode{dnFeature = f, dnThr = thr, dnLeft = l, dnRight = r} =
+      let self     = mkNode nid ("f" <> T.pack (show f) <> " ≤ " <> fmt2 thr) NodeOther
+          lid      = nid <> "L"
+          rid      = nid <> "R"
+          (ln, le) = go lid l
+          (rn, re) = go rid r
+          edges    = [ DAGEdge nid lid Nothing Nothing
+                     , DAGEdge nid rid Nothing Nothing ]
+      in (self : ln ++ rn, edges ++ le ++ re)
+    fmt2 x = T.pack (showFFloat (Just 2) x "")
+
+-- ---------------------------------------------------------------------------
+-- Phase 75.26: 決定木 樹形図 (rpart.plot 流・annotation ベース)
+-- ---------------------------------------------------------------------------
+
+-- | 位置付け済みの決定木ノード (annotation 描画用の中間表現)。 @tpU@ は葉単位の
+--   水平座標 (葉 = 0,1,2,…・内部 = 子の中点)、 @tpDepth@ は根からの深さ。
+data TPNode = TPNode
+  { tpU     :: !Double                -- ^ 葉単位の水平座標。
+  , tpDepth :: !Int                   -- ^ 根からの深さ (根 = 0)。
+  , tpMaj   :: !Int                   -- ^ 多数決 (予測) クラス。
+  , tpN     :: !Int                   -- ^ ノードのサンプル数。
+  , tpProbs :: !(Map.Map Int Double)  -- ^ クラス割合。
+  , tpSplit :: !(Maybe (Int, Double)) -- ^ 分岐なら (特徴 index, 閾値)。 葉は Nothing。
+  , tpKids  :: [TPNode]               -- ^ [] = 葉、 [左, 右] = 分岐。
+  }
+
+-- | Phase 75.26: 決定木を **rpart.plot 流**の樹形図で描く (analyze 側 annotation ベース)。
+--
+-- 各ノードを矩形で表し、 内部に **予測クラス / 全クラス確率 / サンプル割合** を 3 行で
+-- 書く (rpart.plot @type=2@ 既定に相当)。 配線は R と同じく **親→バスの縦線を引かず**、
+-- 分割条件 @feat < thr@ を親の少し下の水平バス上に置き、 枝はその両端から出て子の真上で
+-- 折れる。 条件の両脇 (**根の分岐のみ**) に枠付き白箱で @yes@ (左=成立)・@no@ (右) を添える。
+--
+-- 塗り色は rpart.plot @box.palette="auto"@ 準拠で、 クラスごとに ColorBrewer 連番
+-- パレット (Reds/Greys/Greens/…) を割当て、 **濃淡で予測クラスの確率 (確信度)** を表す
+-- (淡=低・濃=高)。 暗い塗りには白文字を自動選択。 右上にクラス色の凡例を出す。
+--
+-- 第 1 = 特徴量名、 第 2 = クラス名 ('printRpart' と同型・長さ不足は @f{i}@/整数へ
+-- フォールバック)。 木レイアウトは葉を左→右へ等間隔・深さ→縦位置で配置し、 座標は
+-- panel 正規化 (PNpc) で算術する。 plot core の型は触らず annotation だけで描く
+-- (図が固まれば plot 正式 mark へ移譲予定・PS parity は移譲時に対応)。
+--
+-- ⚠ 文字幅は annotation では実測できないため npc で概算する ('wpc')。 既定は図幅
+-- 〜680px 前提に調律してあり、 極端なサイズでは箱幅/マスク幅が僅かにズレる。
+--
+-- 高レベル 'treePlot' は 'DTFit' 一つを取り (@df |-> decisionTree@ の返り値をそのまま
+-- 渡せる)、 内部に載った特徴量名・クラス名を使う。 名前を手渡ししたい行列 fit 用は
+-- 'treePlotRaw'。 'DTFit' は 'Plottable' なので @toPlot@ でも同じ図が出る。
+treePlot :: DTFit -> VisualSpec
+treePlot (DTFit tree feats classes) = treePlotRaw feats classes tree
+
+-- | 行列 fit 用の低レベル版 — 特徴量名・クラス名を明示的に渡す (名無しは @f{i}@/整数へ
+--   フォールバック)。
+treePlotRaw :: [Text] -> [Text] -> DTree -> VisualSpec
+treePlotRaw featNames classNames tree =
+  theme ThemeVoid
+    <> xAxis hideTicks <> yAxis hideTicks       -- 目盛線・目盛ラベルを消す (樹形図は座標軸不要)。
+    <> legendLayer                              -- クラス色の凡例 (標準機構・他マークと同じ)。
+    <> themeLegendFont (fontSize 11)            -- 凡例文字をノード (class 11pt) に揃える。
+    <> mconcat (concatMap edgesOf allNodes)     -- 枝を先に (ノード矩形の下敷き)。
+    <> mconcat (concatMap nodeAnns allNodes)
+  where
+    (nLeaves, root) = assign 0 0 tree
+    allNodes        = flatten root
+    total           = tpN root
+    maxD            = maximum (map tpDepth allNodes)
+    classes         = Map.keys (Map.fromList
+                        [ (c, ()) | t <- allNodes
+                        , c <- tpMaj t : Map.keys (tpProbs t) ])
+    nClasses        = length classes
+    colorIx         = Map.fromList (zip classes [0 :: Int ..])
+
+    -- ---- 配色: rpart.plot box.palette="auto" 準拠 --------------------------
+    --   クラスごとに ColorBrewer 連番パレット (Reds/Greys/Greens/…) を割当て、
+    --   塗りの **濃淡で予測クラスの確率 (確信度)** を表す。 R iris 実測と一致:
+    --   setosa=Reds・versicolor=Greys・virginica=Greens、 淡=低確率・濃=高確率。
+    nodeFill t =
+      let pi_  = maybe 0 id (Map.lookup (tpMaj t) colorIx)
+          pal9 = ix greysP brewerPals (pi_ `mod` length brewerPals)
+          p    = Map.findWithDefault 0 (tpMaj t) (tpProbs t)
+      in ix "#cccccc" pal9 (shadeIx p)
+    -- 予測確率 p∈[1/K,1] を 9 段 palette の index (概ね 1..5) へ (R 実測に fit)。
+    shadeIx p =
+      let k = fromIntegral (max 2 nClasses) :: Double
+      in max 0 (min 8 (round (1 + (p - 1 / k) / (1 - 1 / k) * 4) :: Int))
+    -- 塗りが暗いときは白文字 (簡易輝度判定)。
+    textColorFor hex = if luminance hex < 0.5 then "#ffffff" else "#111111"
+
+    -- ---- npc 座標変換 -----------------------------------------------------
+    leftM = 0.04; rightM = 0.04; topM = 0.85; botM = 0.16
+    spanX = 1 - leftM - rightM
+    xNpc u = leftM + (u + 0.5) / fromIntegral nLeaves * spanX
+    yNpc d | maxD <= 0 = topM
+           | otherwise = topM - fromIntegral d / fromIntegral maxD * (topM - botM)
+    colW = spanX / fromIntegral nLeaves
+    -- 箱は中身 (最長のクラス名 / 確率行) に合わせて締める (スカスカ回避)。 フォントは
+    -- **凡例 (themeLegendFont 11pt) と揃える** (class 11 / 数値 10)。 font を膨らませず
+    -- 箱側を締めて詰めて見せる (凡例とノードのサイズを統一)。
+    contentW = maximum (0.06 : [ wpc 10 (plineOf t) | t <- allNodes ]
+                            ++ [ wpc 11 (classLabel (tpMaj t)) | t <- allNodes ])
+    hw   = min (colW * 0.47) (contentW / 2 + 0.016)  -- 矩形半幅。
+    hh   = 0.054                      -- 矩形半高。
+    dy   = 0.030                      -- 3 行ラベルの行間 (npc)。
+    bc   = -0.011                     -- ベースライン補正 (npc・下げて上下中央に見せる)。
+    plineOf t = T.intercalate "  "
+                  [ fmtP (Map.findWithDefault 0 c (tpProbs t)) | c <- classes ]
+
+    -- ---- ノード矩形 + 3 行ラベル (rpart.plot type=2 相当・上下中央) ---------
+    --   1 行目 = 予測クラス、 2 行目 = 全クラス確率 (.34 .30 .35 形式)、
+    --   3 行目 = 全体に占めるサンプル割合 (%)。
+    nodeAnns t =
+      let x    = xNpc (tpU t); y = yNpc (tpDepth t)
+          fill = nodeFill t
+          tc   = textColorFor fill
+          pct  = 100 * fromIntegral (tpN t) / fromIntegral total :: Double
+          box  = rectA fill "#404040" 0.7 (x - hw) (y - hh) (x + hw) (y + hh)
+          l1   = textC tc x (y + dy + bc) 11 (classLabel (tpMaj t))
+          l2   = textC tc x (y      + bc) 10 (plineOf t)
+          l3   = textC tc x (y - dy + bc) 10 (fmt0 pct <> "%")
+      in [box, l1, l2, l3]
+
+    -- ---- 凡例 (標準機構) --------------------------------------------------
+    --   手描き annotation は中央アンカーで文字が揃わないため、 **他マークと同じ
+    --   凡例機構**に載せる: 不可視 (alpha 0) の colorBy 散布レイヤを 1 枚足し、
+    --   'scaleColorManual' で各クラス名→代表色 (ColorBrewer index 4) を固定する。
+    --   凡例スウォッチは layer alpha 非適用ゆえ満色で出る (グリフだけ不可視)。
+    reprColor i = ix "#888888" (ix greysP brewerPals (i `mod` length brewerPals)) 4
+    legendLayer =
+      let cats = [ classLabel c | c <- classes ] :: [Text]
+          xs   = [ fromIntegral i | i <- [0 .. nClasses - 1] ] :: [Double]
+          dict = [ (classLabel c, reprColor i) | (i, c) <- zip [0 :: Int ..] classes ]
+      in layer (scatter (inline xs) (inline xs) <> colorBy (inlineCat cats) <> alpha 0)
+           <> scaleColorManual dict
+
+    -- ---- 枝 = rpart.plot type=2 の配線 -----------------------------------
+    --   ★親→バスの縦線は引かない (R 準拠)。 分割ラベルを親の少し下に置き、 枝は
+    --   ラベル両端から水平に出て子の真上で下へ折れる。 中央 (ラベル/yes-no) 部分は
+    --   線を描かないことで枝線をマスクする。 yes/no は **根の分岐のみ**・枠付き白箱。
+    edgesOf t = case (tpKids t, tpSplit t) of
+      ([l, r], Just (f, thr)) ->
+        let px   = xNpc (tpU t); pBot = yNpc (tpDepth t) - hh
+            lx   = xNpc (tpU l); rx   = xNpc (tpU r)
+            cTop = yNpc (tpDepth l) + hh          -- 子上端 (左右子は同じ深さ)。
+            busY = pBot - 0.03                    -- バスは親の少し下 (縦線なし)。
+            condTxt = featName f <> " < " <> fmt2 thr
+            lw    = wpc 11 condTxt
+            isRoot = tpDepth t == 0
+            -- 中央の非描画幅 (ラベル + 根なら yes/no 箱ぶん)。
+            clr   = lw / 2 + (if isRoot then 0.075 else 0.008)
+            branch = [ lineA lx busY (px - clr) busY  -- 左枝 (水平)。
+                     , lineA (px + clr) busY rx busY  -- 右枝 (水平)。
+                     , lineA lx busY lx cTop          -- 左子へ縦。
+                     , lineA rx busY rx cTop ]        -- 右子へ縦。
+            cond = textA px (busY - 0.004) 11 condTxt
+            yn   = if isRoot
+                     then labelBox (px - lw / 2 - 0.03) busY "yes"
+                       ++ labelBox (px + lw / 2 + 0.026) busY "no"
+                     else []
+        in branch ++ cond : yn
+      _ -> []
+
+    -- yes/no の枠付き白箱 (中央にテキスト)。
+    labelBox cx cy txt =
+      let w = wpc 10 txt + 0.014; h = 0.03
+      in [ rectA "#ffffff" "#555555" 0.7 (cx - w / 2) (cy - h / 2) (cx + w / 2) (cy + h / 2)
+         , textA cx (cy - 0.004) 10 txt ]
+
+    -- ---- annotation プリミティブ (PNpc 固定) ----------------------------
+    rectA fill stroke sw x1 y1 x2 y2 = annotate $
+      AnnRect (PNpc x1) (PNpc y1) (PNpc x2) (PNpc y2) fill stroke sw 1.0
+    textA = textC "#111111"
+    textC col x y sz t = annotate $
+      AnnText (PNpc x) (PNpc y) t col sz
+    lineA x1 y1 x2 y2 = annotate $
+      AnnLine (PNpc x1) (PNpc y1) (PNpc x2) (PNpc y2) "#606060" 0.8
+
+    -- 文字列の描画幅を npc で概算 (font px と文字数から線形近似・図幅 ~680px 前提)。
+    -- annotation は実測不可ゆえの heuristic。 doc/demo は size を指定して調律に合わせる。
+    wpc fs t = 0.00095 * fs * fromIntegral (T.length t)
+
+    -- ---- 名前解決 ('printRpart' と同じ規則) ------------------------------
+    featName i   = pick i featNames  ("f" <> tShowI i)
+    classLabel i = pick i classNames (tShowI i)
+    pick i xs d  = case drop i xs of
+      (nm : _) | not (T.null nm) -> nm
+      _                          -> d
+
+    tShowI = T.pack . show :: Int -> Text
+    fmt2 x = T.pack (showFFloat (Just 2) x "")
+    fmt0 x = T.pack (showFFloat (Just 0) x "")
+    -- rpart.plot 流の確率表記 (先頭 0 を落として ".34"、 1.00 は据置き)。
+    fmtP x = let s = T.pack (showFFloat (Just 2) x "")
+             in maybe s id (T.stripPrefix "0" s)
+    ix d xs i = if i >= 0 && i < length xs then xs !! i else d
+
+-- | 'DTree' を葉単位で位置付けした 'TPNode' へ変換する。 葉に左→右で連番 (slot) を
+--   振り、 内部ノードは左右子の中点を水平座標にする。 戻りは (葉総数, 根ノード)。
+assign :: Int -> Int -> DTree -> (Int, TPNode)
+assign depth k node = case node of
+  DLeaf p m n _ ->
+    (k + 1, TPNode (fromIntegral k) depth m n p Nothing [])
+  DNode f thr l r n _ p m ->
+    let (k1, lp) = assign (depth + 1) k  l
+        (k2, rp) = assign (depth + 1) k1 r
+        u        = (tpU lp + tpU rp) / 2
+    in (k2, TPNode u depth m n p (Just (f, thr)) [lp, rp])
+
+-- | 'TPNode' 木を前順で平坦化する。
+flatten :: TPNode -> [TPNode]
+flatten t = t : concatMap flatten (tpKids t)
+
+-- | ColorBrewer 9 段連番パレット (rpart.plot box.palette="auto" の per-class 割当)。
+--   クラス index 0,1,2,… に Reds, Greys, Greens, Blues, Purples, Oranges を循環割当。
+brewerPals :: [[Text]]
+brewerPals = [redsP, greysP, greensP, bluesP, purplesP, orangesP]
+
+redsP, greysP, greensP, bluesP, purplesP, orangesP :: [Text]
+redsP    = ["#fff5f0","#fee0d2","#fcbba1","#fc9272","#fb6a4a","#ef3b2c","#cb181d","#a50f15","#67000d"]
+greysP   = ["#ffffff","#f0f0f0","#d9d9d9","#bdbdbd","#969696","#737373","#525252","#252525","#000000"]
+greensP  = ["#f7fcf5","#e5f5e0","#c7e9c0","#a1d99b","#74c476","#41ab5d","#238b45","#006d2c","#00441b"]
+bluesP   = ["#f7fbff","#deebf7","#c6dbef","#9ecae1","#6baed6","#4292c6","#2171b5","#08519c","#08306b"]
+purplesP = ["#fcfbfd","#efedf5","#dadaeb","#bcbddc","#9e9ac8","#807dba","#6a51a3","#54278f","#3f007d"]
+orangesP = ["#fff5eb","#fee6ce","#fdd0a2","#fdae6b","#fd8d3c","#f16913","#d94801","#a63603","#7f2704"]
+
+-- | @#rrggbb@ の相対輝度 (0..1・Rec.601 加重和)。 塗りの明暗で文字色を切替える用。
+luminance :: Text -> Double
+luminance hex =
+  let s = T.dropWhile (== '#') hex
+      hx a b = fromIntegral (16 * hv a + hv b) :: Double
+      hv c | c >= '0' && c <= '9' = fromEnum c - fromEnum '0'
+           | c >= 'a' && c <= 'f' = fromEnum c - fromEnum 'a' + 10
+           | c >= 'A' && c <= 'F' = fromEnum c - fromEnum 'A' + 10
+           | otherwise            = 0
+  in case T.unpack s of
+       (r1:r2:g1:g2:b1:b2:_) ->
+         (0.299 * hx r1 r2 + 0.587 * hx g1 g2 + 0.114 * hx b1 b2) / 255
+       _ -> 1
+
+-- ===========================================================================
+-- 分類 (Discriminant / NaiveBayes / KNN) — Phase 68 A3
+--
+-- 代表図は **決定境界** と **confusion 行列**。 いずれも「学習済モデルを評価点で
+-- 走らせる」 図ゆえ、 KMeans (A1) と同じく **データ/範囲を取るヘルパ**で提供する
+-- (新規 plot mark 不要):
+--
+--   * 'decisionBoundaryOf' = 2D grid を予測しクラス色で塗る (= 連続軸の散布を
+--     四角マーカー・低 alpha で「領域」表現。 ★renderHeatmap はカテゴリ軸なので
+--     連続 grid には不適 → 'MScatter' + 'colorBy' (離散色) を採用)。 2 特徴前提。
+--   * 'confusionOf' = テストデータの真値×予測の件数を 'MHeatmap' で (カテゴリ軸が適合)。
+--
+-- 'Plottable' の 'toPlot' (データ非保持で描ける代表 1 枚):
+--   * KNN は訓練データ ('knnCX'/'knnCY') を保持 → **ラベル色の訓練点散布**。
+--   * Discriminant / NaiveBayes(Gaussian) は **クラス平均散布** (✚)、
+--     NaiveBayes(Multinomial) は **クラス事前確率 bar**。
+-- ===========================================================================
+
+
+instance ClassPredict DiscriminantFit where
+  predictClasses fit m = V.toList (fst (predictDiscriminant fit m))
+
+instance ClassPredict NBModel where
+  predictClasses nb m = VU.toList (predictNB nb m)
+  classNamesOf (NBGaussian g)    = gnbClassNames g
+  classNamesOf (NBMultinomial g) = mnbClassNames g
+
+instance ClassPredict KNNClassifier where
+  predictClasses knn m = VU.toList (predictKNNC knn m)
+  classNamesOf = knnCClassNames
+
+-- Phase 75.5: 分類 NN も同様に decisionBoundaryOf / confusionOf 対応。
+instance ClassPredict MLPFit where
+  predictClasses fit m = V.toList (predictMLPClass fit m)
+  classNamesOf = mlpClassNames
+
+-- Phase 75.12: カーネル SVM (真の SV) も decisionBoundaryOf (非線形境界) / confusionOf 対応。
+instance ClassPredict SVM where
+  predictClasses m x = VU.toList (predictSVM m x)
+
+instance ClassPredict SVMMulti where
+  predictClasses m x = VU.toList (predictSVMMulti m x)
+  classNamesOf = svmmClassNames
+
+-- | 決定境界 (2 特徴) の **領域塗り** (Phase 76.A・annotation ベース)。
+--
+-- @res×res@ の格子セルを中心で予測し、 各セルを予測クラス色の塗り矩形 ('annotRectP')
+-- で敷き詰める (sklearn @DecisionBoundaryDisplay@ の pcolormesh 相当)。 点散布でなく
+-- **実矩形**をセル境界ぴったりに敷くので、 旧実装 (半透明の四角散布) の**縞模様**が出ない。
+--
+-- クラス色は 'toPlot' の凡例 (@colorBy@ → ggplot @hue_pal()@) と一致させる。 render が
+-- categorical を @sort.nub@ 順に並べ 'ggplotHue' を割り当てるのと同順で再現する。
+-- 訓練点・クラス平均は呼び出し側で上に重ねる (@decisionBoundaryOf c xr yr res \<\> toPlot c@)。
+--
+-- ⚠ **annotation の制約**: 塗りは 'annotRectP' 固定の @fill-opacity=0.2@ (薄塗り)。
+-- また annotation は layer の**後**に描かれるため、 塗りは重ねた訓練点の**上**に来る
+-- (0.2 の薄塗りなので点は透けて見える)。 「点が上・塗りが下」 の厳密な重ね順や
+-- 半透明でない濃淡は将来 plot 正式 mark ('MTile'/'MRaster') 移譲時に対応する。
+-- クラス色は既定 hue パレット前提 (theme series palette を差し替えた場合、 塗り色は
+-- 追従しない — annotation 色は spec 時に確定するため)。
+decisionBoundaryOf
+  :: ClassPredict c => c -> (Double, Double) -> (Double, Double) -> Int -> VisualSpec
+decisionBoundaryOf c (x0, x1) (y0, y1) res
+  | res <= 0 || x1 <= x0 || y1 <= y0 = mempty
+  | otherwise =
+      -- 軸ドメインをグリッド範囲へ正確に固定 (expand=FALSE)。 annotation は軸を駆動しない
+      -- ため、 これが無いと軸がデータ点範囲に縮み塗りがフレーム外へはみ出す (sklearn は
+      -- 軸 = グリッド範囲)。 範囲外の重畳点は panel に clip される。
+      coordCartesian x0 x1 y0 y1 <> mconcat
+      [ annotRectP (PNative cx0) (PNative cy0) (PNative cx1) (PNative cy1) (colorFor k)
+      | (idx, k) <- zip [0 :: Int ..] preds
+      , let (i, j) = idx `divMod` res
+            cx0 = x0 + fromIntegral i * dx
+            cx1 = cx0 + dx
+            cy0 = y0 + fromIntegral j * dy
+            cy1 = cy0 + dy ]
+  where
+    dx = (x1 - x0) / fromIntegral res
+    dy = (y1 - y0) / fromIntegral res
+    -- セル中心 (行 = i*res + j・列 = [x, y]) をまとめて 1 回でバッチ予測する。
+    centers = [ [ x0 + (fromIntegral i + 0.5) * dx, y0 + (fromIntegral j + 0.5) * dy ]
+              | i <- [0 .. res - 1], j <- [0 .. res - 1] ]
+    preds = predictClasses c (LA.fromLists centers)
+    -- クラス色の対応: render は colorBy の categorical を sort.nub 順に並べ ggplotHue を
+    -- 割り当てる。 同じ手順を再現し、 予測クラス k → クラス名 → cats 内 index → 色。
+    names     = classNamesOf c
+    labelOf k = classNameByIx names k
+    classK    = if null names then sort (nub preds) else [0 .. length names - 1]
+    cmap      = hueColorMap (map labelOf classK)
+    colorFor k = Map.findWithDefault "#cccccc" (labelOf k) cmap
+
+-- | confusion 行列のヒートマップ: テストデータ @X@ を予測し、 真値 @yTrue@ との件数を
+--   x=予測 / y=真値 のセルに集計する ('MHeatmap'・色 = 件数)。
+-- | クラス番号 k → 名前 (levels があれば @names !! k@・範囲外/空なら整数 show)。
+--   分類 toPlot / confusion がクラス名を出す共通ヘルパ。
+classNameByIx :: [Text] -> Int -> Text
+classNameByIx names k
+  | k >= 0 && k < length names = names !! k
+  | otherwise                  = T.pack (show k)
+
+confusionOf :: ClassPredict c => c -> LA.Matrix Double -> [Int] -> VisualSpec
+confusionOf c x yTrue =
+  let yPred   = predictClasses c x
+      classes = sort (nub (yTrue ++ yPred))
+      -- クラス番号 → クラス名 (levels があれば名前・無ければ整数)。 対角は t==p→同名→
+      -- 同 index ゆえ、 名前順が整数順とずれても混同行列は正しい (対角=正解が保たれる)。
+      nameOf  = classNameByIx (classNamesOf c)
+      counts  = Map.fromListWith (+) [ ((t, p), 1 :: Int) | (t, p) <- zip yTrue yPred ]
+      cells   = [ (t, p, Map.findWithDefault 0 (t, p) counts) | t <- classes, p <- classes ]
+      xs = [ nameOf p | (_, p, _) <- cells ]
+      ys = [ nameOf t | (t, _, _) <- cells ]
+      vs = [ fromIntegral nC | (_, _, nC) <- cells ] :: [Double]
+      -- セル件数の数値注釈 (sklearn ConfusionMatrixDisplay 同型)。 heatmap の categorical
+      -- 軸は label を 'orderedCats' (= sort.nub) の index 位置に置くので、 text は同じ
+      -- index 位置 (数値座標) に重ねる (任意クラス数で整合)。 背景 box 付き ('label') ゆえ
+      -- viridis のどのセル色 (暗紫〜黄) でも読める。
+      axisLabels = sort (nub xs)                       -- x/y 同 classes ゆえ共通・軸順と一致
+      idxOf lbl  = maybe 0 fromIntegral (elemIndex lbl axisLabels) :: Double
+      txIdx  = [ idxOf (nameOf p) | (_, p, _) <- cells ]
+      tyIdx  = [ idxOf (nameOf t) | (t, _, _) <- cells ]
+      cntTxt = [ T.pack (show nC) | (_, _, nC) <- cells ]
+  in layer (heatmap (inlineCat xs) (inlineCat ys) (inline vs))
+       <> layer (label (inline txIdx) (inline tyIdx) (inlineCat cntTxt))
+       <> xLabel "predicted" <> yLabel "true"
+
+-- ===========================================================================
+-- MDS 埋め込み (モデル型 'MDSResult' + 群色オプション) — Phase 75.21
+--
+-- 'MDSResult' は @df |-> mds cfg cols@ の結果 (PCAResult 同格のモデル型)。
+-- 既定は単色散布 ('Plottable' 'MDSResult' の @toPlot m@)、 群色は元データの列名を
+-- 指定する 'mdsGroupBy' を @<>@ で合成する (regression の @statModel <> statColor@ と
+-- 同形。 ただし 'statColor' は 'Color' 専用ゆえ「列名で群色」は別オプション)。
+--
+-- > m = df |-> mds defaultMDS ["x1","x2","x3"]
+-- > noDf |>> toPlot m                              -- 単色
+-- > noDf |>> toPlot (mdsView m <> mdsGroupBy "species")  -- species で群色
+--
+-- MDS は反転・回転自由度があるので軸の向きは本質でない (相対配置を見る)。
+-- ===========================================================================
+
+-- | MDS 埋め込みの描画オプション束 (Monoid)。 'mdsView' で結果を載せ、
+-- 'mdsGroupBy' で群色列を足して @<>@ で合成する。
+data MDSView = MDSView
+  { mvResult   :: !(Maybe MDSResult)  -- ^ 描く埋め込み (後勝ち)。
+  , mvGroupCol :: !(Maybe Text)       -- ^ 群色に使う元データの列名 (後勝ち)。
+  }
+
+instance Semigroup MDSView where
+  a <> b = MDSView (orElse (mvResult b) (mvResult a))
+                   (orElse (mvGroupCol b) (mvGroupCol a))
+    where orElse (Just x) _ = Just x
+          orElse Nothing  y = y
+
+instance Monoid MDSView where
+  mempty = MDSView Nothing Nothing
+
+-- | MDS 結果を描画オプションに載せる (@<>@ の起点)。
+mdsView :: MDSResult -> MDSView
+mdsView m = mempty { mvResult = Just m }
+
+-- | 元データの列名で群色を付ける (factor/数値どちらでも categorical 色に)。
+-- @toPlot (mdsView m <> mdsGroupBy "species")@。
+mdsGroupBy :: Text -> MDSView
+mdsGroupBy c = mempty { mvGroupCol = Just c }
+
+instance Plottable MDSResult where
+  -- 単色の埋め込み散布。
+  toPlot m = toPlot (mdsView m)
+
+instance Plottable MDSView where
+  toPlot v = case mvResult v of
+    Nothing -> mempty
+    Just m  ->
+      let cols = LA.toColumns (mdsEmbedding m)
+          xs   = if not (null cols)  then LA.toList (head cols) else []
+          ys   = if length cols >= 2 then LA.toList (cols !! 1) else replicate (length xs) 0
+          base = scatter (inline xs) (inline ys)
+          withColor = case mvGroupCol v >>= \gc -> groupLabels gc (mdsSourceFrame m) of
+            Just labs -> base <> colorBy (inlineCat labs)
+            Nothing   -> base
+      in layer withColor <> xLabel "MDS1" <> yLabel "MDS2"
+
+-- | 元データの列を categorical な群ラベル ('[Text]') に変換する。 text 列
+-- ('getTextVec') を優先し、 無ければ数値列 ('getDoubleVec') を整数寄せで文字列化。
+groupLabels :: Text -> DXD.DataFrame -> Maybe [Text]
+groupLabels gc frame =
+  case getTextVec gc frame of
+    Just tv -> Just (V.toList tv)
+    Nothing -> case getDoubleVec gc frame of
+      Just dv -> Just (map numLabel (V.toList dv))
+      Nothing -> Nothing
+  where
+    -- 整数値は小数点を出さない (0.0 → "0")。
+    numLabel x = let r = round x :: Int
+                 in if fromIntegral r == x then T.pack (show r) else T.pack (show x)
+
+-- | NN 学習損失曲線 (Phase 75.5)。 'mlpLossHist' (エポックごとの損失) を epoch (x) 対
+-- loss (y) の line で描く。 損失が単調減少して平坦化すれば収束 (keras @history@ 同型)。
+nnLossOf :: MLPFit -> VisualSpec
+nnLossOf fit =
+  let losses = mlpLossHist fit
+      epochs = [ fromIntegral i | i <- [1 .. length losses] ] :: [Double]
+  in layer (line (inline epochs) (inline losses))
+       <> xLabel "epoch" <> yLabel "loss"
+
+-- | カーネル SVM のサポートベクタ (α>0 の点) を強調散布する (Phase 75.12)。 第 0/1 特徴を
+-- **そのクラスの色のまま ✚ (cross) マーカー**で打つ (通常点 ○ と形で区別・色はクラスで一致)。
+-- 決定境界に重ねて「SV が境界を定義する」 様子を見る。 凡例は通常点散布側に任せる
+-- ('legendOff')。 SV が無い/1 次元なら空。
+svmSupportVectorsOf :: SVM -> VisualSpec
+svmSupportVectorsOf m =
+  let cols = LA.toColumns (svmSVx m)
+      xs   = if not (null cols)  then LA.toList (head cols) else []
+      ys   = if length cols >= 2 then LA.toList (cols !! 1) else []
+      -- svmSVy は ±1 (+1 = 正クラス=1・-1 = クラス 0)。 散布の colorBy "cls" と同綴りに
+      -- "0"/"1" の categorical 色で合わせる (= 同グループ同色)。
+      labs = [ if y > 0 then "1" else "0" | y <- VU.toList (svmSVy m) ] :: [Text]
+  in if null xs || null ys then mempty
+     else layer ( scatter (inline xs) (inline ys)
+                  <> colorBy (inlineCat labs) <> shape MShCross )
+
+-- | 連続な決定スコアを持つ分類器 (decisionLineOf 用)。 score ≥ 0 が片クラス、 < 0 が他。
+class ScorePredict c where
+  decisionScore :: c -> LA.Matrix Double -> [Double]
+
+instance ScorePredict SVM where
+  decisionScore m x = VU.toList (predictSVMScore m x)
+
+-- | 決定境界を **線 (等高線)** で描く (Phase 75.13b)。 'decisionBoundaryOf' が領域を色で
+-- 塗り分けるのに対し、 こちらは決定スコア = 0 の等値線を marching squares で引く
+-- (sklearn の @contour(…, levels=[0])@ 相当)。 スコアベースなので滑らかな曲線になる。
+-- @res@ = grid 解像度 (大きいほど滑らか)。 2 特徴前提。
+decisionLineOf :: ScorePredict c
+               => c -> (Double, Double) -> (Double, Double) -> Int -> VisualSpec
+decisionLineOf c (xlo, xhi) (ylo, yhi) res0 =
+  let res = max 2 res0
+      ax i = xlo + (xhi - xlo) * fromIntegral i / fromIntegral (res - 1)
+      ay j = ylo + (yhi - ylo) * fromIntegral j / fromIntegral (res - 1)
+      xsV  = V.generate res ax
+      ysV  = V.generate res ay
+      grid = LA.fromLists [ [xsV V.! i, ysV V.! j] | j <- [0 .. res - 1], i <- [0 .. res - 1] ]
+      zV   = V.fromList (decisionScore c grid)     -- row-major: index = j*res + i
+      z i j = zV V.! (j * res + i)
+      lvl = 0 :: Double
+      straddle a b = (a < lvl) /= (b < lvl)
+      interp (px, py) (qx, qy) va vb =
+        let t = (lvl - va) / (vb - va) in (px + t * (qx - px), py + t * (qy - py))
+      cellSegs i j =
+        let p00 = (xsV V.! i, ysV V.! j);       v00 = z i j
+            p10 = (xsV V.! (i+1), ysV V.! j);   v10 = z (i+1) j
+            p01 = (xsV V.! i, ysV V.! (j+1));   v01 = z i (j+1)
+            p11 = (xsV V.! (i+1), ysV V.! (j+1)); v11 = z (i+1) (j+1)
+            cross = concat
+              [ [ interp p00 p10 v00 v10 | straddle v00 v10 ]
+              , [ interp p10 p11 v10 v11 | straddle v10 v11 ]
+              , [ interp p01 p11 v01 v11 | straddle v01 v11 ]
+              , [ interp p00 p01 v00 v01 | straddle v00 v01 ] ]
+        in case cross of
+             [a, b]       -> [(a, b)]
+             [a, b, d, e] -> [(a, b), (d, e)]   -- saddle (近似ペアリング)
+             _            -> []
+      segs = concat [ cellSegs i j | i <- [0 .. res - 2], j <- [0 .. res - 2] ]
+  in mconcat
+       [ layer ( line (inline [x1, x2]) (inline [y1, y2])
+                 <> color (fromHex "#333333") )
+       | ((x1, y1), (x2, y2)) <- segs ]
+
+
+
+-- ===========================================================================
+-- 部分従属図 (PDP / ICE) — Phase 75.27
+--
+-- 純粋エンジン 'partialDependence' ('Model.PartialDependence') を VisualSpec に落とす
+-- 玄関。 回帰モデルは 'RegPredict' instance で短く (@pdpPlot rf trainX 0 "age"@)、 未対応の
+-- モデルや分類確率は predict 閉包を直接渡す escape hatch (@partialDependencePlot@) で描く。
+-- R @pdp::partial@ / sklearn @PartialDependenceDisplay@ 相当。
+-- ===========================================================================
+
+-- | 学習済モデルを評価点行列で走らせ、 各行の **連続予測値** を返す共通インターフェース
+--   (回帰モデルの PDP を種に依らず組むための薄い抽象)。 分類確率など instance の無い
+--   ものは 'partialDependencePlot' に predict 閉包を直接渡す。
+class RegPredict m where
+  predictReg :: m -> LA.Matrix Double -> [Double]
+
+instance RegPredict RandomForest where
+  predictReg rf x = map (RF.predictRF rf) (LA.toLists x)
+
+instance RegPredict GBRegressor where
+  predictReg gb x = VU.toList (predictGBR gb x)
+
+-- | 高レベル PDP: 訓練 df ('ColumnSource') と**列名**で部分従属図を描く。
+--   @featCols@ = fit に使った特徴列 (順序込み)、 @target@ = 部分従属を見る列。 注目特徴を
+--   観測範囲の grid で振り、 他特徴は訓練分布のまま各行予測して平均した曲線を描く
+--   (R pdp / sklearn @kind='average'@ 相当)。 列が引けない / target が featCols に無いときは空図。
+pdpOf :: (RegPredict m, ColumnSource d) => m -> d -> [Text] -> Text -> VisualSpec
+pdpOf model d featCols target =
+  case (reqColsM featCols d, elemIndex target featCols) of
+    (Right x, Just j) -> partialDependencePlot x (predictReg model) j target
+    _                 -> mempty
+
+-- | 高レベル PDP + ICE 重畳 (sklearn @kind='both'@)。 個体条件付き期待 (ICE) を薄灰で観測数
+--   ぶん重ね、 平均 (PDP) を上描きする。 'pdpOf' の ICE 版。
+pdpIceOf :: (RegPredict m, ColumnSource d) => m -> d -> [Text] -> Text -> VisualSpec
+pdpIceOf model d featCols target =
+  case (reqColsM featCols d, elemIndex target featCols) of
+    (Right x, Just j) -> partialDependenceIcePlot x (predictReg model) j target
+    _                 -> mempty
+
+-- | 低レベル PDP: 訓練特徴**行列**と列 index を直接取る ('pdpOf' の実体)。
+pdpPlot :: RegPredict m => m -> LA.Matrix Double -> Int -> Text -> VisualSpec
+pdpPlot m x j name = partialDependencePlot x (predictReg m) j name
+
+-- | 低レベル PDP + ICE (行列・列 index 版)。
+pdpIcePlot :: RegPredict m => m -> LA.Matrix Double -> Int -> Text -> VisualSpec
+pdpIcePlot m x j name = partialDependenceIcePlot x (predictReg m) j name
+
+-- | 任意モデル用 PDP。 predict 閉包 (行列 → 予測値) を直接受ける escape hatch。
+--   分類の部分従属 (あるクラスの予測確率) 等、 'RegPredict' instance の無いモデルに使う。
+partialDependencePlot
+  :: LA.Matrix Double -> (LA.Matrix Double -> [Double]) -> Int -> Text -> VisualSpec
+partialDependencePlot x predict j name =
+  let r = partialDependence x predict j 40
+  in if null (pdpGrid r)
+       then mempty
+       else layer ( line (inline (pdpGrid r)) (inline (pdpMean r))
+                    <> color (fromHex "#1f77b4") )
+            <> xLabel name <> yLabel "partial dependence"
+
+-- | 任意モデル用 PDP+ICE。 'partialDependencePlot' の ICE 重畳版 (predict 閉包版)。
+partialDependenceIcePlot
+  :: LA.Matrix Double -> (LA.Matrix Double -> [Double]) -> Int -> Text -> VisualSpec
+partialDependenceIcePlot x predict j name =
+  let r  = partialDependence x predict j 40
+      g  = pdpGrid r
+  in if null g
+       then mempty
+       else mconcat
+              [ layer ( line (inline g) (inline curve)
+                        <> color (fromHex "#bbbbbb") <> alpha 0.35 )
+              | curve <- PD.pdpIce r ]
+            <> layer ( line (inline g) (inline (pdpMean r))
+                       <> color (fromHex "#1f77b4") )
+            <> xLabel name <> yLabel "partial dependence"
+
+-- ---------------------------------------------------------------------------
+-- Phase 76.D: PDP を HBM 抽出子と同型に (Plottable 中間型 + toPlot・<> で合成)
+--
+-- @pdpOf model d featCols target@ は 'VisualSpec' を直に返すが、 demo は @[] |>> (…)@ の
+-- ダミー束ねが要り不格好だった。 HBM の @forestOf@/@epred@ と同じく **Plottable 中間型**
+-- ('PDPView') にし、 @toPlot@ で描画・@<>@ で装飾を合成する:
+--
+-- > noDf |>> (toPlot (pdp rf trainDf featCols target) <> title \"…\")
+--
+-- ★HBM 抽出子は fit が事後分布を内包し自己完結だが、 RF/GBM は訓練データを保持しないため
+--   PDP は訓練 df ('ColumnSource') を受け取る (周辺化に訓練分布が要る)。 予測は 'RegPredict'。
+-- ---------------------------------------------------------------------------
+
+data PDPKind = PDPAverage | PDPBoth
+
+-- | PDP の Plottable 中間型 (Phase 76.D)。 特徴行列・予測子・注目列 index を捕捉し、
+--   'toPlot' で PDP (平均) / PDP+ICE 曲線に描く。 'pdp' / 'pdpIce' で作る。
+data PDPView = PDPView
+  { pvX       :: !(LA.Matrix Double)              -- 訓練特徴行列 (周辺化の分布)
+  , pvPredict :: LA.Matrix Double -> [Double]     -- モデルの連続予測 (RegPredict 由来)
+  , pvJ       :: !Int                             -- 注目特徴の列 index
+  , pvName    :: !Text                            -- 注目特徴名 (x 軸)
+  , pvKind    :: !PDPKind
+  }
+
+-- | 訓練 df + 特徴列から (特徴行列, 注目列 index) を解く。 引けない / target が featCols に
+--   無いときは 0×0 行列 (toPlot が 'mempty' にする)。
+pdpXJ :: ColumnSource d => d -> [Text] -> Text -> (LA.Matrix Double, Int)
+pdpXJ d feats target =
+  case (reqColsM feats d, elemIndex target feats) of
+    (Right x, Just j) -> (x, j)
+    _                 -> (LA.fromLists [], 0)
+
+-- | 平均部分従属 (PDP)。 @noDf |>> (toPlot (pdp model trainDf featCols target) <> …)@。
+pdp :: (RegPredict m, ColumnSource d) => m -> d -> [Text] -> Text -> PDPView
+pdp model d feats target =
+  let (x, j) = pdpXJ d feats target in PDPView x (predictReg model) j target PDPAverage
+
+-- | PDP + ICE 重畳 (sklearn @kind='both'@)。 個体曲線 (薄灰) + 平均 (青)。
+pdpIce :: (RegPredict m, ColumnSource d) => m -> d -> [Text] -> Text -> PDPView
+pdpIce model d feats target =
+  let (x, j) = pdpXJ d feats target in PDPView x (predictReg model) j target PDPBoth
+
+instance Plottable PDPView where
+  toPlot (PDPView x predict j name k)
+    | LA.rows x == 0 = mempty
+    | otherwise = case k of
+        PDPAverage -> partialDependencePlot    x predict j name
+        PDPBoth    -> partialDependenceIcePlot x predict j name
+
+instance Plottable KNNClassifier where
+  -- 訓練データをラベル色で散布 (第 0/1 特徴)。 KNN は X/Y を保持するので data-rich。
+  -- 凡例は df|-> が載せた knnCClassNames があればクラス名・無ければ整数 ('classNameByIx')。
+  toPlot knn =
+    let cols = LA.toColumns (knnCX knn)
+        xs   = if not (null cols)      then LA.toList (head cols)   else []
+        ys   = if length cols >= 2     then LA.toList (cols !! 1)   else []
+        labs = map (classNameByIx (knnCClassNames knn)) (VU.toList (knnCY knn))
+    in layer (scatter (inline xs) (inline ys) <> colorBy (inlineCat labs))
+
+instance Plottable DiscriminantFit where
+  toPlot fit =
+    classMeansScatter (LA.toLists (dfMeans fit))
+                      (map round (LA.toList (dfClasses fit)))
+
+instance Plottable NBModel where
+  toPlot (NBGaussian m)    =
+    classMeansScatterNamed (map LA.toList (gnbMeans m)) (gnbClasses m) (gnbClassNames m)
+  toPlot (NBMultinomial m) =
+    let labels = [ classNameByIx (mnbClassNames m) cl | cl <- mnbClasses m ]
+    in layer (bar (inlineCat labels) (inline (map exp (mnbLogPrior m))))
+
+-- ===========================================================================
+-- 次元圧縮 (PLS / MultiGP) — Phase 68 A4
+--
+-- どちらも結果が自己完結 ('PCAResult' 同様) なので外部データ不要で 'Plottable':
+--
+--   * 'PLSFit' = 潜在空間の **score plot** (標本 T) を代表図に、 'loading plot' (変数 P)
+--     と **VIP bar** を診断図束に。 いずれも既存 'MScatter'/'bar'。
+--   * 'MultiGPResult' = **多出力の予測曲線 + 95% band** (出力ごとに色分け・x=index)。
+--     'MLine' + 'MBand' を出力数ぶん重畳。
+--
+-- ※ 'Hanalyze.Model.MultiOutput' は変換+メトリクスの **ユーティリティ**で
+-- fit 結果型を持たないため 'Plottable' 対象外 (多出力の「相関」図は既存
+-- 'MultiFit' = 残差相関 heatmap が担当)。 新規 plot mark は不要。
+-- ===========================================================================
+
+
+-- | PLS 診断ビューの種別 (score / loading / VIP)。
+data PLSViewKind = ScoreView | LoadingView | VipView
+  deriving (Show, Eq)
+
+-- | PLS の中間 Plottable Spec (HBM 式統一 — Phase 70.B)。 終端 'VisualSpec' を
+-- 直返ししていた旧 @plsScorePlot@ 系を、 forest/trace 等と同じく **'Plottable' な
+-- 中間 Spec** に揃える ('toPlot' 境界でオプション合成可・診断束を型で表現)。
+data PLSView = PLSView !PLSFit !PLSViewKind
+
+-- | score ビュー: 標本を潜在空間の第 1/2 成分 (T[:,0] vs T[:,1]) で散布。
+scoreView :: PLSFit -> PLSView
+scoreView fit = PLSView fit ScoreView
+
+-- | loading ビュー: 変数を潜在空間の第 1/2 成分 (P[:,0] vs P[:,1]) で散布。
+loadingView :: PLSFit -> PLSView
+loadingView fit = PLSView fit LoadingView
+
+-- | VIP ビュー: 変数重要度 (Variable Importance in Projection) bar。
+vipView :: PLSFit -> PLSView
+vipView fit = PLSView fit VipView
+
+instance Plottable PLSView where
+  toPlot (PLSView fit ScoreView) =
+    let (xs, ys) = matCols2 (plsScoresT fit) 0 1
+    in layer (scatter (inline xs) (inline ys))
+         <> xLabel "comp 1" <> yLabel "comp 2"
+  toPlot (PLSView fit LoadingView) =
+    let (xs, ys) = matCols2 (plsLoadingsP fit) 0 1
+    in layer (scatter (inline xs) (inline ys))
+         <> xLabel "loading 1" <> yLabel "loading 2"
+  toPlot (PLSView fit VipView) =
+    let vips   = LA.toList (plsVIP fit)
+        labels = [ "f" <> T.pack (show k) | k <- [1 .. length vips] ]
+    in layer (bar (inlineCat labels) (inline vips))
+
+instance Plottable PLSFit where
+  -- 代表図 = score ビュー (標本の潜在空間布置)。
+  toPlot = toPlot . scoreView
+  -- 診断束 = score / loading / VIP の 3 枚。
+  diagnosticPlots fit = map toPlot [ scoreView fit, loadingView fit, vipView fit ]
+
+-- ===========================================================================
+-- 時系列・生存・FDA (GARCH / AFT / FDA) — Phase 68 A5
+--
+-- 新規 plot mark は不要 (既存 line/band の重畳):
+--
+--   * 'GARCHFit'      = 系列 (μ + ε_t) + 条件付き volatility 帯 (μ ± 2σ_t) の帯付き線。
+--   * 'AFTFit'        = パラメトリック生存曲線 S(t|x)。 fit は観測時刻を持たないので
+--                       代表図 ('toPlot') は **基準共変量** (intercept のみ) の曲線、
+--                       任意共変量は 'aftSurvivalAt' ヘルパ。 t 範囲は予測平均寿命から導出。
+--   * 'FunctionalPCA' = 平均関数 + 上位固有関数を grid 上に重畳 (x = grid index)。
+--   * 'FLMResult'     = 関数回帰係数 β(t) の曲線。
+-- ===========================================================================
+
+-- | GARCH の条件付き volatility 帯付き線: 系列 @y_t = μ + ε_t@ の line に、
+--   @μ ± 2σ_t@ (σ_t = √σ²_t) の帯を重ねる。 x = 時刻 index。
+garchVolatility :: GARCHFit -> VisualSpec
+garchVolatility fit =
+  let eps   = LA.toList (gResiduals fit)
+      s2    = LA.toList (gSigma2 fit)
+      mu    = gMu fit
+      n     = min (length eps) (length s2)
+      xs    = [ fromIntegral i | i <- [1 .. n] ] :: [Double]
+      ys    = [ mu + e | e <- take n eps ]
+      sig   = [ sqrt (max 0 v) | v <- take n s2 ]
+      lo    = zipWith (\_ s -> mu - 2 * s) xs sig
+      hi    = zipWith (\_ s -> mu + 2 * s) xs sig
+  in layer (band (inline xs) (inline lo) (inline hi) <> alpha 0.25)
+       <> layer (line (inline xs) (inline ys))
+       <> xLabel "t" <> yLabel "y"
+
+instance Plottable GARCHFit where
+  toPlot = garchVolatility
+
+-- | AFT 生存曲線 S(t|x): 共変量 @x@ の線形予測子 @lp = x·β@ から
+--   @z(t) = (log t − lp)/σ@・@S = exp(logS dist z)@ を t-grid 上で評価する。
+--   t 範囲は予測平均寿命の @(0.01, 3×mean)@、 grid 120 点。
+aftSurvivalAt :: AFTFit -> [Double] -> VisualSpec
+aftSurvivalAt fit x =
+  let beta   = LA.toList (aftBeta fit)
+      lp     = sum (zipWith (*) x beta)
+      sigma  = aftScale fit
+      dist   = aftDistribution fit
+      meanL  = let v = predictAFT fit (LA.fromLists [x]) in head (LA.toList v)
+      tMax   = if meanL > 0 && not (isInfinite meanL) then 3 * meanL else 10
+      tMin   = max 1e-3 (tMax / 200)
+      ts     = linspace tMin tMax 120
+      surv t = exp (logS dist ((log t - lp) / sigma))
+      ss     = map surv ts
+  in layer (line (inline ts) (inline ss))
+       <> xLabel "t" <> yLabel "S(t)"
+
+instance Plottable AFTFit where
+  -- 代表図 = 基準共変量 (intercept 列のみ = [1,0,…,0]) の生存曲線。
+  toPlot fit =
+    let p = LA.size (aftBeta fit)
+        xRef = if p <= 0 then [] else 1 : replicate (p - 1) 0
+    in aftSurvivalAt fit xRef
+
+
+instance Plottable FunctionalPCA where
+  -- 平均関数 + 上位 (最大 3) 固有関数を grid 上に重畳。
+  toPlot fpca =
+    let meanFn = LA.toList (fpcaMeanFn fpca)
+        eigs   = LA.toRows (fpcaEigenfn fpca)
+        eigNs  = [ ("PC" <> T.pack (show k), LA.toList e)
+                 | (k, e) <- zip [1 :: Int ..] (take 3 eigs) ]
+    in gridCurves (("mean", meanFn) : eigNs)
+
+instance Plottable FLMResult where
+  -- 関数回帰係数 β(t) の曲線 (x = grid index)。
+  toPlot flm =
+    let betaFn = LA.toList (flmBetaFn flm)
+        xs     = [ fromIntegral i | i <- [1 .. length betaFn] ] :: [Double]
+    in layer (line (inline xs) (inline betaFn))
+         <> xLabel "t" <> yLabel "beta(t)"
+
+-- ===========================================================================
+-- 罰則回帰・因果探索 (Regularized / LiNGAM) — Phase 68 A6
+--
+-- 新規 plot mark は不要:
+--
+--   * 'RegFit'          = 単一 λ の係数 ('rfBeta') を bar (代表図)。
+--   * 'regPathPlot'     = 正則化パス @[(λ, [β_j])]@ ('regularizationPath' 出力) を、
+--                         係数ごとに 1 本の line で λ-横軸に重畳 (= LASSO 係数パス図)。
+--   * 'DirectLiNGAMFit' = 推定した因果構造を **MDAG** で描く (B 行列 → node/edge、
+--                         決定木と同じ MDAG 再利用)。 edge j→i は @|adjacency[i,j]|>0@。
+-- ===========================================================================
+
+instance Plottable RegFit where
+  -- 係数 bar (b1, b2, … = rfBeta)。 intercept 含む並びをそのまま描く。
+  toPlot fit =
+    let bs     = LA.toList (rfBeta fit)
+        labels = [ "b" <> T.pack (show k) | k <- [0 .. length bs - 1] ]
+    in layer (bar (inlineCat labels) (inline bs))
+
+-- | 正則化パス図: @[(λ, [β_j])]@ を係数ごとに 1 本の line で重畳。 横軸は **log₁₀λ**
+--   (glmnet の係数パス図と同じ慣例・小 λ=full model が左、 大 λ=sparse が右)。 色=係数 index。
+--   λ は正を仮定する (パスの λ グリッドは常に @> 0@)。
+regPathPlot :: [(Double, [Double])] -> VisualSpec
+regPathPlot path
+  | null path = mempty
+  | otherwise =
+      let logLams = map (logBase 10 . fst) path   -- x = log₁₀λ
+          rows = map snd path           -- λ ごとの [β_j]
+          p    = minimum (map length rows)
+          mkCoef j =
+            let ys  = [ r !! j | r <- rows ]
+                lbl = "b" <> T.pack (show j)
+            in layer ( line (inline logLams) (inline ys)
+                     <> colorBy (inlineCat (replicate (length logLams) lbl)) )
+      in mconcat [ mkCoef j | j <- [0 .. p - 1] ]
+           <> xLabel "log10(lambda)" <> yLabel "coef"
+
+-- | 隣接行列 + 変数名から因果 DAG (MDAG) を描く低レベル (Phase 77.A で切り出し)。
+--   edge @j→i@ は @|adj[i,j]| > 0@ (= x_i が x_j に依存)。 @names@ が列数と一致しなければ
+--   @x0..@ フォールバック。 全 LiNGAM variant の Plottable が共有する。
+lingamDagNamed :: [Text] -> LA.Matrix Double -> VisualSpec
+lingamDagNamed rawNames adj =
+  let p     = LA.rows adj
+      names = if length rawNames == p && p > 0
+                then rawNames
+                else [ "x" <> T.pack (show j) | j <- [0 .. p - 1] ]
+      dnodes = [ DAGNode { dnId = nm, dnLabel = nm, dnKind = NodeObserved
+                         , dnDist = Nothing, dnX = 0, dnY = 0 } | nm <- names ]
+      dedges = [ DAGEdge (names !! j) (names !! i) Nothing Nothing
+               | i <- [0 .. p - 1], j <- [0 .. p - 1]
+               , abs (adj `LA.atIndex` (i, j)) > 0 ]
+      (positioned, routed) = layoutHierarchicalFullWithPlates dnodes dedges []
+  in bakeDAGRoutesInSpec $
+       layer (dagFromListsWithPlates positioned routed LayoutHierarchical [])
+
+-- | 推定因果構造 (DirectLiNGAM) を MDAG で描く。 ノード = @x0..x_{p-1}@ (変数名は
+--   高レベル @df |-> directLingam@ 経由で付く・'LiNGAMFitted' の Plottable 参照)。
+lingamDag :: DirectLiNGAMFit -> VisualSpec
+lingamDag fit = lingamDagNamed [] (dlAdjacency fit)
+
+instance Plottable DirectLiNGAMFit where
+  toPlot = lingamDag
+
+-- | 高レベル @df |-> directLingam cols@ の結果 = **実変数名**の因果 DAG (Phase 77.A)。
+instance Plottable (LiNGAMFitted DirectLiNGAMFit) where
+  toPlot (LiNGAMFitted fit names) = lingamDagNamed names (dlAdjacency fit)
+
+-- | ParceLiNGAM の名前付き DAG (Phase 77.B・pcAdjacency)。
+instance Plottable (LiNGAMFitted ParceFit) where
+  toPlot (LiNGAMFitted fit names) = lingamDagNamed names (pcAdjacency fit)
+
+-- | MultiGroupLiNGAM の**共通** DAG (Phase 77.B・多数決 mgCommonAdj・名前付き)。
+instance Plottable (LiNGAMFitted MultiGroupFit) where
+  toPlot (LiNGAMFitted fit names) = lingamDagNamed names (mgCommonAdj fit)
+
+-- | VARLiNGAM の**時間ラグ DAG** (Phase 77.B)。 ノード = 各変数の @name[t]@ / @name[t-l]@、
+--   辺 = 同時刻 (@B0@: x_j[t]→x_i[t]) + ラグ (@structuralLags[l]@: x_j[t-l]→x_i[t])。
+--   @thr@ 未満の係数は辺を出さない。 孤立したラグノード (辺に現れない) は省く。
+varLagDagNamed :: [Text] -> LA.Matrix Double -> [LA.Matrix Double] -> Double -> VisualSpec
+varLagDagNamed rawNames b0 lags thr =
+  let k    = LA.rows b0
+      base = if length rawNames == k && k > 0
+               then rawNames else [ "x" <> T.pack (show j) | j <- [0 .. k - 1] ]
+      p    = length lags
+      nm i 0 = base !! i <> "[t]"
+      nm i l = base !! i <> "[t-" <> T.pack (show l) <> "]"
+      contempEdges = [ DAGEdge (nm j 0) (nm i 0) Nothing Nothing
+                     | i <- [0 .. k - 1], j <- [0 .. k - 1]
+                     , abs (b0 `LA.atIndex` (i, j)) > thr ]
+      lagEdges = [ DAGEdge (nm j l) (nm i 0) Nothing Nothing
+                 | l <- [1 .. p], i <- [0 .. k - 1], j <- [0 .. k - 1]
+                 , abs ((lags !! (l - 1)) `LA.atIndex` (i, j)) > thr ]
+      dedges = contempEdges ++ lagEdges
+      refIds = concatMap (\(DAGEdge a b _ _) -> [a, b]) dedges
+      allNodes = [ (i, l) | l <- [0 .. p], i <- [0 .. k - 1] ]
+      keep (i, l) = l == 0 || nm i l `elem` refIds        -- 現時刻は常に・ラグは辺があるものだけ
+      dnodes = [ DAGNode { dnId = nm i l, dnLabel = nm i l, dnKind = NodeObserved
+                         , dnDist = Nothing, dnX = 0, dnY = 0 }
+               | (i, l) <- allNodes, keep (i, l) ]
+      (positioned, routed) = layoutHierarchicalFullWithPlates dnodes dedges []
+  in bakeDAGRoutesInSpec $
+       layer (dagFromListsWithPlates positioned routed LayoutHierarchical [])
+
+-- | VARLiNGAM の高レベル結果 = 時間ラグ DAG (辺閾値 0.1・同時刻 + ラグ)。
+instance Plottable (LiNGAMFitted VARLiNGAMFit) where
+  toPlot (LiNGAMFitted fit names) =
+    varLagDagNamed names (vlB0 fit) (vlStructuralLags fit) 0.1
+
+-- | PairwiseLiNGAM の 2 変数向き図 (Phase 77.B)。 検出向きの矢印 1 本 (Inconclusive は無向)。
+--   2×2 隣接に落として 'lingamDagNamed' を再利用する。
+instance Plottable (LiNGAMFitted PairwiseResult) where
+  toPlot (LiNGAMFitted r names) =
+    let adj = case prDirection r of
+          XtoY         -> LA.fromLists [[0, 0], [1, 0]]   -- x(0) → y(1): adj[1,0]=1
+          YtoX         -> LA.fromLists [[0, 1], [0, 0]]   -- y(1) → x(0)
+          Inconclusive -> LA.fromLists [[0, 0], [0, 0]]   -- 無向 (2 ノードのみ)
+    in lingamDagNamed names adj
+
+-- | ICA-LiNGAM の名前付き DAG (Phase 77.C・ilAdjacency)。
+instance Plottable (LiNGAMFitted ICALiNGAMFit) where
+  toPlot (LiNGAMFitted fit names) = lingamDagNamed names (ilAdjacency fit)
+
+-- | 相関ネットワークのグラフ (Phase 77)。 @|r| > cgThreshold@ の対を辺にする (無向・向きは
+--   index 順の便宜配置で**因果でない**)。 LiNGAM DAG と対比すると間接相関の過剰さが分かる。
+--   下三角のみ辺にして重複/自己ループを避ける (相関は対称ゆえ)。
+instance Plottable CorrelationGraph where
+  toPlot (CorrelationGraph corr names thr) =
+    let p   = LA.rows corr
+        adj = LA.build (p, p)
+                (\i j -> let (ii, jj) = (round i, round j)
+                         in if ii > jj && abs (corr `LA.atIndex` (ii, jj)) > thr
+                              then 1 else 0 :: Double)
+    in lingamDagNamed names adj
+
+-- | BootstrapLiNGAM の**確信度 DAG** (Phase 77.C)。 出現確率 ≥ 0.5 のエッジだけ描く
+--   (= 過半数の bootstrap で現れた信頼できる因果構造)。 全確率は 'bootstrapEdgeProbOf' で。
+instance Plottable (LiNGAMFitted BootstrapResult) where
+  toPlot (LiNGAMFitted res names) =
+    let prob = brEdgeProbability res
+        p    = LA.rows prob
+        adj  = LA.build (p, p)
+                 (\i j -> if prob `LA.atIndex` (round i, round j) >= 0.5 then 1 else 0)
+    in lingamDagNamed names adj
+
+-- | BootstrapLiNGAM の**エッジ出現確率ヒートマップ** (Phase 77.C)。 行=結果 i・列=原因 j、
+--   セル = P(j→i) (0..1)。 確信度の全体像を DAG と別に見せる (python lingam の確率行列相当)。
+bootstrapEdgeProbOf :: LiNGAMFitted BootstrapResult -> VisualSpec
+bootstrapEdgeProbOf (LiNGAMFitted res rawNames) =
+  let prob  = brEdgeProbability res
+      p     = LA.rows prob
+      names = if length rawNames == p && p > 0
+                then rawNames else [ "x" <> T.pack (show j) | j <- [0 .. p - 1] ]
+      cells = [ (names !! j, names !! i, prob `LA.atIndex` (i, j))
+              | i <- [0 .. p - 1], j <- [0 .. p - 1] ]
+      xs = [ c | (c, _, _) <- cells ]      -- 原因 j (x 軸)
+      ys = [ r | (_, r, _) <- cells ]      -- 結果 i (y 軸)
+      vs = [ v | (_, _, v) <- cells ]
+  in layer (heatmap (inlineCat xs) (inlineCat ys) (inline vs))
+       <> xLabel "cause (j)" <> yLabel "effect (i)"
+
+-- ===========================================================================
+-- DOE prediction profiler — Phase 78.C/D/F
+--
+-- JMP の Prediction Profiler 相当 = **応答 × 各因子**のパネルをグリッドに並べる
+-- (行=応答・列=因子)。 各パネル = 予測線 + 95% CI 帯 (他因子は中央値固定) + 打点。
+-- 打点は 'Raw' (実測 y) か 'Partial' (偏残差 = 部分効果 + 全モデル残差) を @<>@ で選ぶ。
+-- 既存 effect plot ('statModelMulti' + 'along' + 'holdAt') を再利用する。
+--
+-- 中間 Plottable 型 ('ProfilerSpec') にして @toPlot@ で描画・@<>@ でオプション合成
+-- (HBM @epred@ / 'PDPView' と同じ流儀)。 打点はモデル ('mvFrame') の観測値から算出
+-- するので @noDf@ で束ねられる。 複数応答は @df |-> 'multiOutput' ys (designModel plan)@
+-- が返す @[(応答名, モデル)]@ をそのまま渡す。
+--
+-- > let model = df |-> multiOutput ["strength","yield"] (designModel plan)
+-- > noDf |>> toPlot (profiler model ["temp","time"] <> profilerResidual Partial)
+-- ===========================================================================
+
+-- | 打点の種別。 'Raw' = 実測 y (他因子が動くぶん予測線から縦に散る = 多変量の正しい挙動)。
+--   'Partial' = **偏残差** @fⱼ(xⱼ) + (全モデル残差)@ で他因子の寄与を除き点を予測線に乗せる
+--   (R @termplot(partial.resid=TRUE)@ / @car::crPlots@ 相当)。
+data ResidualMode = Raw | Partial
+  deriving (Eq, Show)
+
+-- | prediction profiler の中間 Plottable Spec (Phase 78.F)。 @(応答名, モデル)@ のリスト
+--   (複数応答)・因子名・打点モード ('ResidualMode') を捕捉し、 'toPlot' で「行=応答 ×
+--   列=因子」 のグリッドに描く。 'profiler' で作り、 @<> 'profilerResidual' Partial@ で
+--   モードを合成する。
+data ProfilerSpec m = ProfilerSpec
+  { psModels   :: [(Text, m)]        -- ^ (応答ラベル, 学習済モデル)。 行になる。
+  , psFactors  :: [Text]             -- ^ 説明因子名。 列になる。
+  , psResidual :: Maybe ResidualMode -- ^ 打点モード (合成後 'Nothing' は 'Raw' 既定)。
+  }
+
+-- | 右バイアス合成 (option-only 片は models\/factors が空)。 mode は後勝ち。
+instance Semigroup (ProfilerSpec m) where
+  a <> b = ProfilerSpec
+    { psModels   = psModels a  <> psModels b
+    , psFactors  = if null (psFactors b) then psFactors a else psFactors b
+    , psResidual = psResidual b <|> psResidual a }
+
+instance Monoid (ProfilerSpec m) where
+  mempty = ProfilerSpec [] [] Nothing
+
+-- | @profiler models factors@ — 応答×因子の profiler。 @models@ は
+--   @df |-> 'multiOutput' ys (designModel plan)@ が返す @[(応答名, モデル)]@。 既定は 'Raw'。
+profiler :: [(Text, m)] -> [Text] -> ProfilerSpec m
+profiler models factors = ProfilerSpec models factors Nothing
+
+-- | 打点モードを差す option (@<>@ で合成)。 @profiler … <> profilerResidual Partial@。
+profilerResidual :: ResidualMode -> ProfilerSpec m
+profilerResidual mode = mempty { psResidual = Just mode }
+
+instance MultiVarModel m => Plottable (ProfilerSpec m) where
+  toPlot (ProfilerSpec models factors mMode)
+    | null models || null factors = mempty
+    | otherwise =
+        subplots [ panel lbl m f | (lbl, m) <- models, f <- factors ]
+          <> subplotCols (length factors)
+    where
+      mode = fromMaybe Raw mMode
+      -- 1 パネル = 予測線 + CI + 打点 (Raw: 実測 y / Partial: 偏残差)。他因子は中央値固定。
+      panel lbl m f =
+        let mf    = mvFrame m
+            contOf nm = case lookup nm (mfRoles mf) of
+              Just (RoleContinuous xs) -> V.toList xs
+              _                        -> []
+            xsf   = contOf f
+            (pts, ylab) = case mode of
+              Raw ->
+                let ysObs = case [ v | (_, RoleResponse v) <- mfRoles mf ] of
+                              (v : _) -> V.toList v
+                              []      -> []
+                in (ysObs, lbl)
+              Partial ->
+                let ysObs = case [ v | (_, RoleResponse v) <- mfRoles mf ] of
+                              (v : _) -> V.toList v
+                              []      -> []
+                    (muFull, _) = mvEvalFrame m 0.95 mf
+                    resid       = zipWith (-) ysObs muFull
+                    -- 部分効果 fⱼ(xⱼ): f=観測値・他因子=中央値固定 (予測線と同じ hold)。
+                    ef          = evalFrame mf f Median [] xsf
+                    (muPart, _) = mvEvalFrame m 0.95 ef
+                in (zipWith (+) muPart resid, "partial: " <> lbl)
+        in layer (scatter (inline xsf) (inline pts))
+             <> toPlot (statModelMulti m (along f) <> holdAt Median <> grid 60)
+             <> xLabel f <> yLabel ylab
+
+-- | RSM **等高線 / 応答曲面** (Phase 78.E)。 2 因子 (v1, v2) を grid で動かし他因子を
+--   中央値固定して応答 μ̂ を評価し、 **塗り等値帯 ('contourFilled') + 等高線 ('contour')** で
+--   描く (R @rsm::contour@ / matplotlib @contourf+contour@ 相当・応答面を平面で俯瞰)。
+--   3D の応答曲面は 'surfaceOf' (別途 @saveSVG3D@)。 評価はモデル観測範囲なので
+--   @noDf |>> contourOf model "temp" "time"@ で描ける。
+contourOf :: MultiVarModel m => m -> Text -> Text -> VisualSpec
+contourOf m v1 v2 =
+  let (gxs, gys, grid') = surfaceGrid m v1 v2 (defaultSurfaceOpts { soHoldAt = Median })
+      -- grid' !! j !! i = μ̂(gxs!!i, gys!!j)。 (x, y, z) へ平坦化。
+      pts = concat (zipWith (\gy row -> zipWith (\gx z -> (gx, gy, z)) gxs row) gys grid')
+      xs  = [ x | (x, _, _) <- pts ]
+      ys  = [ y | (_, y, _) <- pts ]
+      zs  = [ z | (_, _, z) <- pts ]
+  in layer (contourFilled (inline xs) (inline ys) (inline zs))
+       <> layer (contour (inline xs) (inline ys) (inline zs) <> contourLevels 10)
+       <> xLabel v1 <> yLabel v2
+
+-- ===========================================================================
+-- 記述統計・検定 (Stat.*) — Phase 68 A7
+--
+-- 新規 plot mark は不要:
+--
+--   * 'TestResult'  = 効果量 + 95% CI の **forest** (検定パラメータの区間 + 0 基準線)。
+--                     代表図 ('toPlot') は 1 行 forest、 複数検定は 'testForest'。
+--   * 'describeBox' = 生データ列の **box plot** (= describe の分布図・5 数要約を可視化)。
+-- ===========================================================================
+
+-- | 検定結果の forest plot: 各検定の 95% CI ('trCI') を区間、 中心を点推定として
+--   1 行に並べ、 0 の基準線を引く。 CI を持たない検定は除外する。 行ラベルは
+--   'trMethod'。 同種検定を群間で並べるなど **ラベルを区別したい場合は
+--   'testForestLabeled'** を使う。
+--
+-- ⚠ 0 基準線は **平均差・効果量** (null = 0) 向け。 生の平均など null ≠ 0 の量を
+-- 混在させると軸ドメインが歪むので、 同一スケールの量だけを 1 枚に並べること。
+testForest :: [TestResult] -> VisualSpec
+testForest = testForestLabeled . map (\r -> (trMethod r, r))
+
+-- | ラベル指定版 'testForest' (= 行ラベルを呼び出し側で与える)。 同じ検定種を
+--   群ごとに並べる (= 同名衝突を避ける) 用途に使う。
+testForestLabeled :: [(Text, TestResult)] -> VisualSpec
+testForestLabeled labeled =
+  let rows  = [ (nm, lo, hi) | (nm, r) <- labeled, Just (lo, hi) <- [trCI r] ]
+      names = [ nm            | (nm, _,  _ ) <- rows ]
+      ests  = [ (lo + hi) / 2 | (_,  lo, hi) <- rows ]
+      errs  = [ (hi - lo) / 2 | (_,  lo, hi) <- rows ]
+  in if null rows
+       then mempty
+       else layer (forest (inlineCat names) (inline ests) (inline errs) <> forestNull 0)
+
+instance Plottable TestResult where
+  -- 代表図 = 単一検定の 1 行 forest (effect/CI)。
+  toPlot r = testForest [r]
+
+-- | describe の分布図: 生データ列の box plot (5 数要約を可視化)。
+describeBox :: [Double] -> VisualSpec
+describeBox xs = layer (boxplot (inline xs))
+
+-- ===========================================================================
+-- 次元圧縮 (PLS effect plot) — Phase 70.B2/B3
+-- ===========================================================================
+
+instance MultiVarModel PLSModel where
+  mvFrame = plsmFrame
+  -- PLS は閉形式 CI を持たない → band 非提供 (曲線のみ・GAM と同じ honest 方針)。
+  mvEvalFrame m _level ef =
+    let n      = mfNRows ef
+        colOf nm = case lookup nm (mfRoles ef) of
+          Just (RoleContinuous v) -> LA.fromList (V.toList v)
+          _                       -> LA.fromList (replicate n 0)
+        xMat  = LA.fromColumns (map colOf (plsmXNames m))   -- n × p (xNames 順)
+        yPred = predictPLS (plsmFit m) xMat                 -- n × q
+        ycols = LA.toColumns yPred
+        idx   = plsmOutIdx m
+        mu    = if idx < length ycols then LA.toList (ycols !! idx)
+                                      else replicate n 0
+    in (mu, Nothing)
+
+-- Phase 78.G-e: 多変量カーネル回帰 (GP/RFF) を effect plot / profiler / contour で使う
+-- (DOE の非 LM 化)。 mvEvalFrame は ef から予測子を 'gprnNames' 順に取り 'gprnPredict'
+-- に渡す ('PLSModel' と同型)。 帯 = **事後予測帯** (潜在分散 + 観測 noise σ_n²) で、
+-- 分布あり象限 (Gp/GpRff) のみ Just、 mean のみ象限 (Krr/KrrRff) は帯なし。 'gpmvVar' は
+-- σ_n² を含まない ('GP.hs' の diagKss=σ_f²) ので noise を足して予測帯にする。
+instance MultiVarModel GPRegModelN where
+  mvFrame m =
+    let n     = LA.size (gprnYraw m)
+        roles = ("__gp_resp", RoleResponse (V.fromList (LA.toList (gprnYraw m))))
+              : [ (nm, RoleContinuous (V.fromList (LA.toList xv)))
+                | (nm, xv) <- zip (gprnNames m) (gprnXraws m) ]
+    in ModelFrame { mfRoles = roles, mfNRows = n }
+  mvEvalFrame m level ef =
+    let n        = mfNRows ef
+        colOf nm = case lookup nm (mfRoles ef) of
+          Just (RoleContinuous v) -> V.toList v
+          _                       -> replicate n 0
+        xMat        = LA.fromColumns (map (LA.fromList . colOf) (gprnNames m))  -- n × p
+        (mu, mbVar) = gprnPredict m xMat
+        z           = quantileNormal (1 - (1 - level) / 2)
+        sn2         = max 0 (gpNoiseVar (gprnParams m))
+    in case mbVar of
+         Just vs -> let sds = map (\v -> sqrt (max 0 v + sn2)) vs
+                    in ( mu, Just ( zipWith (\u s -> u - z * s) mu sds
+                                  , zipWith (\u s -> u + z * s) mu sds ) )
+         Nothing -> (mu, Nothing)
+
+-- | DOE 階層ベイズ fit の effect plot 開通 (Phase 78.G-f)。固定効果 β の事後 draw で
+--   評価点の μ を計算し、事後予測帯 (μ の分散 + 観測 noise σ²) を CI slot に載せる。
+--   ランダム効果は集団平均で marginalize (profiler = 代表条件の予測)。
+instance MultiVarModel DesignHBMFit where
+  mvFrame = dhfFrame
+  mvEvalFrame m level ef =
+    case designMatrixF (dhfFormula m) ef of
+      Left _          -> ([], Nothing)
+      Right (xMat, _) ->
+        let rows  = map LA.toList (LA.toRows xMat)   -- 評価点 × p
+            draws = dhfBetaDraws m                   -- draws × p
+            muAt row = [ sum (zipWith (*) row bd) | bd <- draws ]
+            perPoint = map muAt rows                 -- 評価点ごとの draw 列
+            z     = quantileNormal (1 - (1 - level) / 2)
+            s2bar = let ss = dhfSigmaDraws m
+                    in if null ss then 0 else sum (map (^ (2::Int)) ss) / fromIntegral (length ss)
+            center = map mean0L perPoint
+            sds    = map (\ds -> sqrt (varL ds + s2bar)) perPoint
+        in ( center
+           , Just ( zipWith (\c s -> c - z * s) center sds
+                  , zipWith (\c s -> c + z * s) center sds ) )
+    where
+      mean0L xs = if null xs then 0 else sum xs / fromIntegral (length xs)
+      varL   xs = let mu = mean0L xs
+                  in if null xs then 0 else sum (map (\x -> (x - mu) ^ (2::Int)) xs) / fromIntegral (length xs)
+
diff --git a/src/Hanalyze/Plot/Robust.hs b/src/Hanalyze/Plot/Robust.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Plot/Robust.hs
@@ -0,0 +1,207 @@
+-- |
+-- Module      : Hanalyze.Plot.Robust
+-- Description : hgg 連携層 — ロバスト・分位点回帰族の図化 instance
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- hgg 連携層 — **ロバスト・分位点回帰族** の図化 instance (Phase 71.5)。
+--
+-- ⚠ 親 'Hanalyze.Plot' と同じ cabal flag @plot-integration@ (既定 off) を
+-- on にしたときのみ build される。 共通基盤 (class / ModelSpec / grid 評価核) は
+-- 'Hanalyze.Plot.Core' を import して取り込む (orphan instance を許容)。
+--
+-- 担当する型 (= M 推定ロバスト回帰・分位点回帰):
+--   RobustModel / MultiRobustModel / QuantileModel / MultiQuantileModel。
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeFamilies #-}
+{-# LANGUAGE FlexibleContexts #-}
+module Hanalyze.Plot.Robust
+  ( robustBand
+  ) where
+
+import           Data.List             (sortBy, minimumBy)
+import           Data.Ord              (comparing)
+import qualified Data.Vector           as V
+import qualified Numeric.LinearAlgebra as LA
+
+import           Hgg.Plot.Spec     ( layer, inline, fromHex
+                                       , scatter, line, band
+                                       , sizeBy, color )
+
+import           Hanalyze.Model.Wrappers
+import           Hanalyze.Plot.Core
+import           Hanalyze.Model.LM       (designMatrix)
+import           Hanalyze.Model.Robust   (RobustFit (..), robustCovBeta)
+import           Hanalyze.Model.Weibull  (quantileNormal)
+import           Hanalyze.Model.Quantile (QRFit (..))
+import           Hanalyze.Model.Formula.Design  (designMatrixF)
+
+-- ===========================================================================
+-- 多変量ロバスト回帰 (effect plot + 係数サマリ) — Phase 70.D
+--
+-- ロバスト回帰は formula 経路を持たない (単回帰 'RobustModel' のみだった) ので、
+-- 'MultiLMModel' と同型の frame-carrying ラッパを新設する。 設計行列は
+-- 'additiveFormula' 由来 ('designMatrixF' で @[1, x1,…,xp]@)、 fit は 'fitRobustLM'、
+-- CI 帯は M 推定量サンドイッチ共分散 ('robustCovBeta'・statsmodels RLM 一致)。
+-- ===========================================================================
+
+instance MultiVarModel MultiRobustModel where
+  mvFrame = mrmFrame
+  mvEvalFrame m level ef =
+    case designMatrixF (mrmFormula m) ef of
+      Left _        -> ([], Nothing)
+      Right (xe, _) ->
+        let fit  = mrmFit m
+            beta = rfCoef fit
+            cov  = robustCovBeta (rfEstimator fit) (rfScale fit)
+                                 (rfResiduals fit) (mrmDesign m)
+            z    = quantileNormal ((1 + level) / 2)
+            rows = LA.toRows xe
+            mu   = [ r `LA.dot` beta | r <- rows ]
+            se   = [ sqrt (max 0 (r `LA.dot` (cov LA.#> r))) | r <- rows ]
+        in ( mu, Just ( zipWith (\mu' s -> mu' - z * s) mu se
+                      , zipWith (\mu' s -> mu' + z * s) mu se ) )
+
+-- ===========================================================================
+-- ロバスト回帰 (描画可能)
+--
+-- 'RobustFit' (Hanalyze.Model.Robust) は M-estimator IRLS の係数 'rfCoef' / fitted
+-- 'rfFitted' / 最終重み 'rfWeights' (≤ 1、 外れ値ほど小) を持つ。 代表図 ('toPlot') は
+-- ロバスト直線 + サンドイッチ CI 帯。 「どの点がダウンウェイトされたか」 は
+-- 'diagnosticPlots' 側で **点サイズ = IRLS 重み** の散布図に encode して見せる。
+-- ===========================================================================
+
+instance Plottable RobustModel where
+  -- ロバスト直線 + CI 帯 ('robustBand' = M 推定量サンドイッチ共分散)。 LM と揃え、
+  -- 訓練点の ŷ='rfFitted' を x 昇順に結ぶ (= 単回帰なので直線) + 帯を重ねる。
+  toPlot m =
+    let fit        = rmFit m
+        xs         = LA.toList (rmXraw m)
+        yhat       = LA.toList (rfFitted fit)
+        sorted     = sortBy (comparing fst) (zip xs yhat)
+        xsS        = map fst sorted
+        yhatS      = map snd sorted
+        (los, his) = robustBand m defaultCILevel xsS
+    in layer (band (inline xsS) (inline los) (inline his))
+         <> layer (line (inline xsS) (inline yhatS))
+
+  -- 診断束: ロバスト直線 + 残差 vs fitted + **重み encode 散布図** (点サイズ = IRLS
+  -- 重み、 小さい点 = ダウンウェイトされた外れ値)。 y は ŷ + 残差で復元。
+  diagnosticPlots m =
+    let fit  = rmFit m
+        xs   = LA.toList (rmXraw m)
+        yhat = LA.toList (rfFitted fit)
+        resd = LA.toList (rfResiduals fit)
+        ys   = zipWith (+) yhat resd
+        ws   = LA.toList (rfWeights fit)
+    in [ toPlot m
+       , layer (scatter (inline yhat) (inline resd))
+       , layer (scatter (inline xs) (inline ys) <> sizeBy (inline ws))
+       ]
+
+-- | ロバスト回帰の CI 帯。 M 推定量 β̂ の漸近共分散 ('robustCovBeta'・サンドイッチ・
+-- statsmodels RLM 一致) から、 評価点 x での @se(ŷ) = √([1,x]·Cov·[1,x]ᵀ)@、
+-- 帯 = @μ̂ ∓ z·se@ (z = 正規分位点・RLM は正規で Wald CI)。
+robustBand :: RobustModel -> Double -> [Double] -> ([Double], [Double])
+robustBand m level gxs =
+  let fit  = rmFit m
+      xd   = designMatrix (V.fromList (LA.toList (rmXraw m)))   -- [1, x]
+      cov  = robustCovBeta (rfEstimator fit) (rfScale fit) (rfResiduals fit) xd
+      z    = quantileNormal ((1 + level) / 2)
+      beta = rfCoef fit
+      b0   = LA.atIndex beta 0
+      b1   = if LA.size beta > 1 then LA.atIndex beta 1 else 0
+      muAt gx = b0 + b1 * gx
+      seAt gx = let v = LA.fromList [1, gx]
+                in sqrt (max 0 (v `LA.dot` (cov LA.#> v)))
+  in ( [ muAt gx - z * seAt gx | gx <- gxs ]
+     , [ muAt gx + z * seAt gx | gx <- gxs ] )
+
+-- | grid 評価 (Phase 16 C1)。 grid x で β̂·[1, x] を評価しロバスト直線を滑らかに描く。
+-- band は 'robustBand' (サンドイッチ CI) を返す (LM と揃えた)。
+instance SingleVarModel RobustModel where
+  svRange m = let xs = LA.toList (rmXraw m) in (minimum xs, maximum xs)
+  svGrid m level gxs =
+    let beta = rfCoef (rmFit m)
+        mu   = [ LA.atIndex beta 0
+                 + (if LA.size beta > 1 then LA.atIndex beta 1 * gx else 0)
+               | gx <- gxs ]
+        (los, his) = robustBand m level gxs
+    in (mu, Just (los, his))
+  -- ブートストラップ: 加法誤差 (残差再標本化)。 refit は同じ estimator で再 fit。
+  svBootKit m =
+    let fit = rmFit m
+    in Just BootKit
+       { bkX = LA.toList (rmXraw m)
+       , bkY = zipWith (+) (LA.toList (rfFitted fit)) (LA.toList (rfResiduals fit))
+       , bkRefit = \xs ys -> robustModel (rfEstimator fit) (LA.fromList xs) (LA.fromList ys)
+       , bkObsDist = Nothing }
+
+-- ===========================================================================
+-- 分位点回帰 (描画可能)
+--
+-- 'QRFit' (Hanalyze.Model.Quantile) は 1 つの分位 τ に対する係数 + fitted 'qfYHat' を
+-- 持つ。 複数の τ (例 0.1/0.5/0.9) の fit を重ねると **予測区間そのものを線群で** 表現
+-- できる (= heteroscedastic データで帯より直接的)。 各線は 'color' ('fromHex') で固定色。
+-- ===========================================================================
+
+instance Plottable QuantileModel where
+  -- 各 τ-fit を x 昇順に結んだ折れ線を、 固定色で重畳 (分位ごとに 1 layer)。
+  toPlot m =
+    let xs = LA.toList (qmXraw m)
+        mkLine (i, (_tau, fit)) =
+          let yhat   = LA.toList (qfYHat fit)
+              sorted = sortBy (comparing fst) (zip xs yhat)
+              col    = quantilePalette !! (i `mod` length quantilePalette)
+          in layer (line (inline (map fst sorted)) (inline (map snd sorted))
+                      <> color (fromHex col))
+    in foldMap mkLine (zip [0 ..] (qmFits m))
+
+-- | 多変量分位点回帰の代表図 = **第 1 予測子に沿った effect plot** (他予測子は訓練平均に
+--   固定)。 各 τ を 1 本の線で色分け重畳する (単変量 'QuantileModel' の τ 別線群の一般化)。
+--   分位点回帰は閉形式 CI を持たないため帯はなし。
+instance Plottable MultiQuantileModel where
+  toPlot m =
+    case LA.toColumns (mqmX m) of                    -- [1, x₁, …, xₚ]
+      (_ : x1 : rest) ->
+        let xs1   = LA.toList x1
+            means = [ LA.sumElements c / fromIntegral (max 1 (LA.size c)) | c <- rest ]  -- x₂..xₚ の平均
+            (lo, hi) = (minimum xs1, maximum xs1)
+            gn    = 100 :: Int
+            grid' = [ lo + (hi - lo) * fromIntegral i / fromIntegral (gn - 1) | i <- [0 .. gn - 1] ]
+            evalX = LA.fromRows [ LA.fromList (1 : gx : means) | gx <- grid' ]
+            mkLine (i, (_t, fit)) =
+              let yhat = LA.toList (evalX LA.#> qfBeta fit)
+                  col  = quantilePalette !! (i `mod` length quantilePalette)
+              in layer (line (inline grid') (inline yhat) <> color (fromHex col))
+        in foldMap mkLine (zip [0 :: Int ..] (mqmFits m))
+      _ -> mempty   -- 予測子が無い (設計行列が intercept のみ) = 描画不能
+
+-- ===========================================================================
+-- 実測 vs 予測 (HasObsPred) — Phase 72.4
+--
+-- ロバスト/分位点 fit は ŷ と残差を直接持つので 実測 = ŷ + residual。 分位点回帰は
+-- 0.5 (中央値) に最も近い τ の fit を代表予測に使う (中央値回帰 = 条件付き中央値)。
+-- ===========================================================================
+
+instance HasObsPred RobustModel where
+  obsPredPairs m =
+    let f = LA.toList (rfFitted (rmFit m))
+        e = LA.toList (rfResiduals (rmFit m))
+    in (zipWith (+) f e, f)
+
+instance HasObsPred MultiRobustModel where
+  obsPredPairs m =
+    let f = LA.toList (rfFitted (mrmFit m))
+        e = LA.toList (rfResiduals (mrmFit m))
+    in (zipWith (+) f e, f)
+
+instance HasObsPred QuantileModel where
+  obsPredPairs m =
+    case qmFits m of
+      [] -> ([], [])
+      fs ->
+        let (_, fit) = minimumBy (comparing (\(t, _) -> abs (t - 0.5))) fs
+            f = LA.toList (qfYHat fit)
+            e = LA.toList (qfResid fit)
+        in (zipWith (+) f e, f)
diff --git a/src/Hanalyze/Plot/Smooth.hs b/src/Hanalyze/Plot/Smooth.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Plot/Smooth.hs
@@ -0,0 +1,323 @@
+-- |
+-- Module      : Hanalyze.Plot.Smooth
+-- Description : hgg 連携層 — 平滑化・カーネル法族の図化 instance
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- hgg 連携層 — **平滑化・カーネル法族** の図化 instance (Phase 71.5)。
+--
+-- ⚠ 親 'Hanalyze.Plot' と同じ cabal flag @plot-integration@ (既定 off) を
+-- on にしたときのみ build される。 共通基盤 (class / ModelSpec / grid 評価核) は
+-- 'Hanalyze.Plot.Core' を import して取り込む (orphan instance を許容)。
+--
+-- 担当する型 (= spline / GAM / GP / kernel 法):
+--   SplineModel / GAMModel / GAMModelN / GPResult / GPRegModel / GPRegModelN /
+--   MultiGPResult。
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeFamilies #-}
+{-# LANGUAGE FlexibleContexts #-}
+module Hanalyze.Plot.Smooth
+  ( splineBasisAt
+  , gamGridCI
+  , multiGpCurves
+  ) where
+
+import           Data.List             (sortBy)
+import           Data.Ord              (comparing)
+import qualified Data.Vector           as V
+import qualified Numeric.LinearAlgebra as LA
+
+import           Data.Text             (Text)
+import qualified Data.Text             as T
+
+import           Hgg.Plot.Spec     ( VisualSpec, layer, inline, inlineCat
+                                       , scatter, line, band
+                                       , colorBy, alpha )
+
+import           Hanalyze.Model.Wrappers
+import           Hanalyze.Plot.Core
+import           Hanalyze.Model.Core     (fittedV, residualsV)
+import           Hanalyze.Model.GP       (GPResult (..), gpNoiseVar)
+import           Hanalyze.Model.LM       ( CIBand (..), confidenceBand, confidenceBandAt
+                                                , predictionBandAt )
+import           Hanalyze.Model.Spline   ( SplineKind (..), SplineFit (..)
+                                                , bsplineBasis, naturalSplineBasis )
+import           Hanalyze.Model.GAM      (GAMFit (..), predictGAMSE)
+import           Hanalyze.Model.Weibull  (quantileNormal)
+import           Hanalyze.Model.MultiGP  (MultiGPResult (..))
+import qualified Statistics.Distribution         as SD
+import           Statistics.Distribution.StudentT (studentT)
+
+-- ===========================================================================
+-- ガウス過程 (描画可能)
+--
+-- 'GPResult' (Hanalyze.Model.GP) は予測 grid (gpTestX) + 事後平均 (gpMean) +
+-- credible band (gpLower/gpUpper) を **自己完結** で保持する。 ゆえに LMModel の
+-- ように X を別途束ねる必要がなく、 結果型をそのまま 'Plottable' にできる。
+-- ===========================================================================
+
+instance Plottable GPResult where
+  -- 事後平均 (曲線) + credible band。 予測 grid をソートして 'line' に渡せば GP の曲線が
+  -- そのまま描ける。 band 半幅は対称 (mean ± 2σ) ゆえ es = gpUpper − gpMean とし、
+  -- 'band' に [mean−es, mean+es] を、 'line' に mean を載せる。
+  toPlot res =
+    let triples = sortBy (comparing (\(x, _, _) -> x))
+                    (zip3 (gpTestX res) (gpMean res)
+                          (zipWith (-) (gpUpper res) (gpMean res)))
+        xs = [ x | (x, _, _) <- triples ]
+        ys = [ y | (_, y, _) <- triples ]
+        es = [ e | (_, _, e) <- triples ]
+    in layer (band (inline xs) (inline (zipWith (-) ys es)) (inline (zipWith (+) ys es)))
+         <> layer (line (inline xs) (inline ys))
+
+-- ===========================================================================
+-- スプライン回帰 (描画可能)
+--
+-- 'SplineFit' (Hanalyze.Model.Spline) は基底係数 'sfBeta' と、 基底行列で fit した
+-- 線形モデル核 'sfResult' (= 'FitResult') を保持する。 ゆえに **基底行列を設計行列と
+-- みなせば** LMModel と同じ 'confidenceBand' (= X (XᵀX)⁻¹ Xᵀ の対角) がそのまま使える。
+-- 違いは「曲線」 である点だけ: 単回帰の直線でなく、 訓練点を x 昇順に結ぶと基底展開に
+-- よる平滑曲線になる。 帯は LM と同じ **線形モデルの対称 Wald CI** (基底空間での予測分散)。
+-- ===========================================================================
+
+-- | 'SplineFit' を訓練 x で評価したときの基底行列 (= confidenceBand の設計行列)。
+splineBasisAt :: SplineFit -> LA.Vector Double -> LA.Matrix Double
+splineBasisAt fit xs =
+  let xsV = V.fromList (LA.toList xs)
+  in case sfKind fit of
+       BSpline k    -> bsplineBasis k (sfKnots fit) xsV
+       NaturalCubic -> naturalSplineBasis (sfKnots fit) xsV
+
+instance Plottable SplineModel where
+  -- 平滑曲線 + CI band。 基底行列を設計行列とみなして 'confidenceBand' を訓練点で
+  -- 評価し (LMModel と同じ Wald CI)、 x 昇順にソートして折れ線で結ぶ (= 平滑曲線)。
+  -- ± 半幅 errorY = se (帯は基底空間の予測分散 = 対称)。
+  toPlot m =
+    let fit    = splFit m
+        res    = sfResult fit
+        xs     = LA.toList (splXraw m)
+        yhat   = LA.toList (fittedV res)
+        basis  = splineBasisAt fit (splXraw m)
+        cib    = confidenceBand basis res defaultCILevel
+        se     = zipWith (-) (upperBound cib) yhat   -- upper - ŷ = 片側半幅
+        sorted = sortBy (comparing (\(x, _, _) -> x)) (zip3 xs yhat se)
+        xsS    = [ x | (x, _, _) <- sorted ]
+        yhatS  = [ y | (_, y, _) <- sorted ]
+        seS    = [ e | (_, _, e) <- sorted ]
+    in layer (band (inline xsS) (inline (zipWith (-) yhatS seS)) (inline (zipWith (+) yhatS seS)))
+         <> layer (line (inline xsS) (inline yhatS))
+
+  -- 残差診断 (平滑曲線 + 残差 vs fitted)。
+  diagnosticPlots m =
+    let res  = sfResult (splFit m)
+        yhat = LA.toList (fittedV res)
+        resd = LA.toList (residualsV res)
+    in [ toPlot m
+       , layer (scatter (inline yhat) (inline resd))
+       ]
+
+-- | grid 評価 (Phase 16 C1)。 grid x で基底行列を再構築し、 それを設計行列とみなして
+-- 'confidenceBandAt' を評価する (基底空間の対称 Wald CI = 訓練 'confidenceBand' と同核)。
+instance SingleVarModel SplineModel where
+  svRange m = let xs = LA.toList (splXraw m) in (minimum xs, maximum xs)
+  svGrid m level gxs =
+    let fit        = splFit m
+        basisTrain = splineBasisAt fit (splXraw m)
+        basisGrid  = splineBasisAt fit (LA.fromList gxs)
+        cib        = confidenceBandAt basisTrain (sfResult fit) level basisGrid
+        los        = lowerBound cib
+        his        = upperBound cib
+        mu         = zipWith (\l h -> (l + h) / 2) los his
+    in (mu, Just (los, his))
+  -- PI = closed form σ̂²(1 + xᵀ(XᵀX)⁻¹x) (基底空間 OLS ゆえ LM と同型・statsmodels obs_ci 相当)。
+  svGridPI m level gxs =
+    let fit        = splFit m
+        basisTrain = splineBasisAt fit (splXraw m)
+        basisGrid  = splineBasisAt fit (LA.fromList gxs)
+        pib        = predictionBandAt basisTrain (sfResult fit) level basisGrid
+    in Just (lowerBound pib, upperBound pib)
+  -- ブートストラップ: 加法誤差。 refit は同じ kind/knots で再 fit。
+  svBootKit m =
+    let fit = splFit m
+        res = sfResult fit
+    in Just BootKit
+       { bkX = LA.toList (splXraw m)
+       , bkY = zipWith (+) (LA.toList (fittedV res)) (LA.toList (residualsV res))
+       , bkRefit = \xs ys -> splineModel (sfKind fit) (sfKnots fit) (LA.fromList xs) (LA.fromList ys)
+       , bkObsDist = Nothing }
+
+-- ===========================================================================
+-- 一般化加法モデル (描画可能)
+--
+-- 'GAMFit' (Hanalyze.Model.GAM) は各特徴の基底係数 + fitted 'gamYHat' を保持する。
+-- 本 Phase では mgcv 流 Bayesian CI を実装した平滑曲線 + CI 帯を描く。
+-- ===========================================================================
+
+instance Plottable GAMModel where
+  -- 平滑曲線 + CI 帯 (Phase 70.6 G で mgcv 流 Bayesian CI を実装)。 grid 経路
+  -- ('statModel') に固定し、 LM/spline と同様 band + line を出す。
+  toPlot = toPlot . statModel
+
+  -- 残差診断 (平滑曲線 + 残差 vs fitted)。
+  diagnosticPlots m =
+    let fit  = gamFit m
+        yhat = LA.toList (gamYHat fit)
+        resd = LA.toList (gamResid fit)
+    in [ toPlot m
+       , layer (scatter (inline yhat) (inline resd))
+       ]
+
+-- | GAM の grid 評価 (中心 μ̂ + **mgcv 流 Bayesian 信頼帯**)。 'predictGAMSE' の
+--   pointwise se に t_{n−edf} 臨界値を掛けて帯にする (Vβ='gamCov')。
+gamGridCI :: GAMFit -> Double -> [V.Vector Double] -> ([Double], Maybe ([Double], [Double]))
+gamGridCI fit level cols =
+  let (muV, seV) = predictGAMSE fit cols
+      mu   = V.toList muV
+      se   = V.toList seV
+      df   = fromIntegral (LA.size (gamResid fit)) - gamEdf fit
+      tVal = SD.quantile (studentT (max 1 df)) ((1 + level) / 2)
+      lo   = zipWith (\u s -> u - tVal * s) mu se
+      hi   = zipWith (\u s -> u + tVal * s) mu se
+  in (mu, Just (lo, hi))
+
+-- | grid 評価 (Phase 16 C1)。 grid x を 'predictGAMSE' に通し平滑曲線 + CI 帯を評価
+-- (Phase 70.6 G: mgcv 流 Bayesian CI を実装)。
+instance SingleVarModel GAMModel where
+  svRange m = let xs = LA.toList (gamXraw m) in (minimum xs, maximum xs)
+  svGrid m level gxs = gamGridCI (gamFit m) level [V.fromList gxs]
+
+
+-- | 第1予測子を描画軸に、 他予測子は訓練平均に固定して偏依存曲線を評価する。
+instance SingleVarModel GAMModelN where
+  svRange m = case gamNXraws m of
+    (x:_) -> let xs = LA.toList x in (minimum xs, maximum xs)
+    []    -> (0, 1)
+  svGrid m level gxs =
+    let n          = length gxs
+        others     = drop 1 (gamNXraws m)
+        holdMean v = V.replicate n (LA.sumElements v / fromIntegral (LA.size v))
+        cols       = V.fromList gxs : map holdMean others
+    in gamGridCI (gamNFit m) level cols
+  svCoefR2 m = Just ([gamIntercept (gamNFit m)], gamR2 (gamNFit m))
+
+instance Plottable GAMModelN where
+  -- 平滑曲線 + CI 帯 (Phase 70.6 G)。 grid 経路 ('statModel') に固定。 多予測子では
+  -- 第1予測子を軸に他を訓練平均で固定した偏依存曲線 + その点の CI。
+  toPlot = toPlot . statModel
+
+-- ===========================================================================
+-- カーネル回帰 (GP / KRR / RFF) の描画可能ラッパ
+-- ===========================================================================
+
+-- | grid 評価 (E2)。 予測子 'gprPredict' を grid x に当て、 分布あり象限 (Gp/GpRff) は
+-- 事後分散→正規 credible 帯 (μ̂ ± z·σ)、 点象限 (Ridge/RidgeRff) は帯なし ('Nothing')。
+-- 信頼水準 @level@ → @z = Φ⁻¹(1 − (1−level)/2)@ ('quantileNormal')。 'WeightedLMModel'
+-- と同じく 'toPlot' を grid 経路 ('statModel') に固定する (元データ散布図と整合)。
+instance SingleVarModel GPRegModel where
+  svRange m = let xs = LA.toList (gprXraw m) in (minimum xs, maximum xs)
+  svGrid m level gxs =
+    let (mu, mbVar) = gprPredict m gxs
+        z           = quantileNormal (1 - (1 - level) / 2)
+    in case mbVar of
+         Just vs -> let sds = map (sqrt . max 0) vs
+                        los = zipWith (\u s -> u - z * s) mu sds
+                        his = zipWith (\u s -> u + z * s) mu sds
+                    in (mu, Just (los, his))
+         Nothing -> (mu, Nothing)               -- Ridge 系 = 帯なし
+  -- 予測区間 (PI) = 事後予測分散 (f の分散 + 観測ノイズ σ_n²) の正規帯。 分布あり象限のみ。
+  svGridPI m level gxs =
+    let (mu, mbVar) = gprPredict m gxs
+        z           = quantileNormal (1 - (1 - level) / 2)
+        sn2         = max 0 (gpNoiseVar (gprParams m))
+    in case mbVar of
+         Just vs -> let sds = map (\v -> sqrt (max 0 v + sn2)) vs
+                    in Just ( zipWith (\u s -> u - z * s) mu sds
+                            , zipWith (\u s -> u + z * s) mu sds )
+         Nothing -> Nothing
+  -- カーネル回帰は β₀+β₁x の線形「式」を持たないため式/R² 注釈は出さない。
+  svCoefR2 _ = Nothing
+
+-- | ★訓練点経路ではなく grid 経路 ('statModel') に固定 (元データ散布図と整合)。
+-- 分布あり象限は曲線 + credible 帯、 点象限は曲線のみ。
+instance Plottable GPRegModel where
+  toPlot = toPlot . statModel
+
+
+-- | 第1予測子を描画軸に、 他予測子を訓練平均に固定した偏依存曲線 (band は分布あり象限のみ)。
+instance SingleVarModel GPRegModelN where
+  svRange m = case gprnXraws m of
+    (x:_) -> let xs = LA.toList x in (minimum xs, maximum xs)
+    []    -> (0, 1)
+  svGrid m level gxs =
+    let n          = length gxs
+        others     = drop 1 (gprnXraws m)
+        holdMean v = LA.konst (LA.sumElements v / fromIntegral (LA.size v)) n
+        testX      = LA.fromColumns (LA.fromList gxs : map holdMean others)
+        (mu, mbVar) = gprnPredict m testX
+        z          = quantileNormal (1 - (1 - level) / 2)
+    in case mbVar of
+         Just vs -> let sds = map (sqrt . max 0) vs
+                    in (mu, Just ( zipWith (\u s -> u - z * s) mu sds
+                                 , zipWith (\u s -> u + z * s) mu sds ))
+         Nothing -> (mu, Nothing)
+  svGridPI m level gxs =
+    let n          = length gxs
+        others     = drop 1 (gprnXraws m)
+        holdMean v = LA.konst (LA.sumElements v / fromIntegral (LA.size v)) n
+        testX      = LA.fromColumns (LA.fromList gxs : map holdMean others)
+        (mu, mbVar) = gprnPredict m testX
+        z          = quantileNormal (1 - (1 - level) / 2)
+        sn2        = max 0 (gpNoiseVar (gprnParams m))
+    in case mbVar of
+         Just vs -> let sds = map (\v -> sqrt (max 0 v + sn2)) vs
+                    in Just ( zipWith (\u s -> u - z * s) mu sds
+                            , zipWith (\u s -> u + z * s) mu sds )
+         Nothing -> Nothing
+  svCoefR2 _ = Nothing
+
+instance Plottable GPRegModelN where
+  toPlot = toPlot . statModel
+
+-- ===========================================================================
+-- 多出力 GP (描画可能)
+-- ===========================================================================
+
+-- | 多出力 GP の予測曲線 + 95% band (出力ごとに色分け・x = 予測点 index)。
+multiGpCurves :: MultiGPResult -> VisualSpec
+multiGpCurves res =
+  let outs = zip3 (mgpMean res) (mgpLower res) (mgpUpper res)
+      mkOut k (m, lo, hi) =
+        let xs  = [ fromIntegral i | i <- [1 .. length m] ] :: [Double]
+            lbl = "y" <> T.pack (show (k :: Int))
+            grp = inlineCat (replicate (length m) lbl)
+        in layer (band (inline xs) (inline lo) (inline hi) <> colorBy grp <> alpha 0.2)
+             <> layer (line (inline xs) (inline m) <> colorBy grp)
+  in mconcat (zipWith mkOut [0 ..] outs)
+
+instance Plottable MultiGPResult where
+  toPlot = multiGpCurves
+
+-- ===========================================================================
+-- 実測 vs 予測 (HasObsPred) — Phase 72.4
+--
+-- spline は内側の線形 fit ('sfResult') から、 GAM は保持する ŷ/残差から復元する。
+-- ===========================================================================
+
+instance HasObsPred SplineModel where
+  obsPredPairs m =
+    let r = sfResult (splFit m)
+        f = LA.toList (fittedV r)
+        e = LA.toList (residualsV r)
+    in (zipWith (+) f e, f)
+
+instance HasObsPred GAMModel where
+  obsPredPairs m =
+    let f = LA.toList (gamYHat (gamFit m))
+        e = LA.toList (gamResid (gamFit m))
+    in (zipWith (+) f e, f)
+
+instance HasObsPred GAMModelN where
+  obsPredPairs m =
+    let f = LA.toList (gamYHat (gamNFit m))
+        e = LA.toList (gamResid (gamNFit m))
+    in (zipWith (+) f e, f)
diff --git a/src/Hanalyze/Plot/Wrappers.hs b/src/Hanalyze/Plot/Wrappers.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Plot/Wrappers.hs
@@ -0,0 +1,264 @@
+-- |
+-- Module      : Hanalyze.Plot.Wrappers
+-- Description : hgg 連携層 — 汎用ラッパの Plottable / SingleVarModel 連携 instance
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- hgg 連携層 — **汎用ラッパ (どの族にも属さない) の Plottable / SingleVarModel**
+-- 連携 instance + 専用 helper (Phase 71.7)。
+--
+-- ⚠ 親 'Hanalyze.Plot' と同じ cabal flag @plot-integration@ (既定 off) を
+-- on にしたときのみ build される。 共通基盤 (class / ModelSpec / grid 評価核) は
+-- 'Hanalyze.Plot.Core' を import して取り込む (orphan instance を許容:
+-- クラス=Core・instance=ここ・型=Wrappers/各 Model module)。
+--
+-- 担当する型・ヘルパ (= 特定の ML / ベイズ族に属さない汎用ラッパ):
+--   多出力線形回帰 'MultiFit' の残差相関 heatmap・k-NN 回帰の単変量描画
+--   ('KNNRegressor')・透過標準化ラッパ 'StandardizedModel'・罰則回帰結果
+--   'RegModel' の係数 bar・群別フィット 'GroupedFit' の N 曲線重畳・plot ColData
+--   源の 'ColumnSource'・LM 係数診断アクセサ ('lmDiag' / 'groupedLmDiag')。
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeFamilies #-}
+{-# LANGUAGE FlexibleContexts #-}
+{-# LANGUAGE FlexibleInstances #-}
+{-# LANGUAGE UndecidableInstances #-}
+module Hanalyze.Plot.Wrappers
+  ( -- ** 係数診断の薄アクセサ — Phase 52.A9
+    lmDiag
+  , groupedLmDiag
+    -- ** 群別フィットの fullrange レンダラ — Phase 52.A4 / A7
+  , groupedFullrange
+  ) where
+
+import qualified Data.Map.Strict       as Map
+import qualified Data.Vector           as V
+import qualified Data.Vector.Unboxed    as VU
+import qualified Numeric.LinearAlgebra as LA
+
+import           Data.Text             (Text)
+import qualified Data.Text             as T
+import qualified DataFrame.Internal.Column    as DX
+import qualified DataFrame.Internal.DataFrame  as DX
+
+import           Hanalyze.Data.ColumnSource     (ColumnSource (..))
+
+import           Hgg.Plot.Spec     ( VisualSpec, layer, inline, inlineCat
+                                       , ColData (..)
+                                       , scatter, line
+                                       , heatmap, colorBy
+                                       , scaleColorManual, legend
+                                       , bar, title )
+
+import           Hanalyze.Model.Wrappers
+import           Hanalyze.Plot.Core
+import           Hanalyze.Fit
+import           Hanalyze.Model.LM.Diagnostics (CoefStats (..), lmCoefStats)
+import           Hanalyze.Model.LM     (linspace)
+import           Hanalyze.Model.MultiLM (MultiFit (..))
+import           Hanalyze.Stat.Standardize
+                   ( Standardizer (..)
+                   , applyStandardizerCol )
+import           Hanalyze.Model.KNN (KNNRegressor (..), predictKNNR)
+
+-- ===========================================================================
+-- 多出力線形回帰 (描画可能)
+--
+-- 'MultiFit' (Hanalyze.Model.MultiLM) は q 個の応答を共通の予測子で同時回帰し、
+-- 固有の成果物として **出力間の残差相関 'mfResidCor' (q×q)** を保持する。 q 本の回帰
+-- 関係を単一図に素直に載せる方法は一意でない (出力ごとスケールが異なり得る) ため、
+-- 代表図 ('toPlot') は **残差相関 heatmap** とする (= 多出力回帰固有の図。 user 決定
+-- 2026-06-04)。 'MultiFit' は heatmap に必要な相関行列を自己完結で持つので、 'GPResult'
+-- 同様 X を別途束ねず結果型をそのまま 'Plottable' にできる。 個別の出力 j の回帰線は
+-- 'predictMultiLM' で別途描ける (本 instance の対象外)。
+--
+-- ⚠ 'heatmap' (geom_tile) は **categorical 軸専用** (renderHeatmap が x/y をラベルとして
+-- カテゴリ軸の index に引く。 実測: Render/Statistical.hs)。 ゆえに格子座標は数値でなく
+-- **出力名ラベル** ("y1", "y2", …) を 'inlineCat' で渡す (数値だとカテゴリ軸が立たず
+-- 全セルが drop されてタイルが描かれない = 計測で確認)。
+-- ===========================================================================
+
+instance Plottable MultiFit where
+  -- 残差相関 q×q を heatmap に。 行 i・列 j のセル (x=yⱼ, y=yᵢ) に相関値 mfResidCor[i,j]
+  -- を割り当てて 'heatmap' (= geom_tile) layer を 1 枚返す。 軸は出力名ラベル (categorical)。
+  toPlot mf =
+    let cor   = LA.toLists (mfResidCor mf)
+        q     = length cor
+        lbl k = "y" <> T.pack (show (k + 1 :: Int))   -- 出力名ラベル
+        cells = [ (lbl j, lbl i, (cor !! i) !! j)
+                | i <- [0 .. q - 1], j <- [0 .. q - 1] ]
+        xs = [ x | (x, _, _) <- cells ]
+        ys = [ y | (_, y, _) <- cells ]
+        vs = [ v | (_, _, v) <- cells ]
+    in layer (heatmap (inlineCat xs) (inlineCat ys) (inline vs))
+
+-- C2: 元スケール逆変換 instance (Phase 70.3 項目 C) -------------------------
+--
+-- 内側モデルは標準化空間で学習されている。 ここで予測子 x を入力時に標準化し、
+-- ('standardizedY' なら) 応答 y を出力時に逆変換することで、 図・予測を**元スケール**で
+-- 返す。 単変量 (1 特徴) 描画が対象 (smXStd の 0 次元を使う)。
+
+-- | k-NN 回帰の単変量描画 (透過標準化の内側として要る)。 1 特徴 ('knnRX' が 1 列) を
+--   仮定し grid 点を予測曲線にする。 局所平均ゆえ band は持たない (Nothing)。
+instance SingleVarModel KNNRegressor where
+  svRange m =
+    let c0 = LA.toList (head (LA.toColumns (knnRX m)))
+    in (minimum c0, maximum c0)
+  svGrid m _ gxs =
+    let xEval = LA.fromColumns [LA.fromList gxs]    -- n × 1 (単一特徴)
+    in (VU.toList (predictKNNR m xEval), Nothing)   -- 帯なし
+
+-- | 0 次元 (単変量描画の予測子) の (μ, σ)。
+stMu1, stSd1 :: Standardizer -> Double
+stMu1 = head . stMu
+stSd1 = head . stSd
+
+-- | 応答 y の逆変換 (@smYStd = Just@ のみ実施。 @Nothing@ は元 y スケールのまま)。
+unstdY :: Maybe (Double, Double) -> Double -> Double
+unstdY (Just (muY, sdY)) v = v * sdY + muY
+unstdY Nothing           v = v
+
+-- | 透過標準化ラッパの単変量描画 (元スケール)。 入力 x を標準化 → 内側を評価 →
+--   ('standardizedY' なら) 出力 y を逆変換する。 内側 'svRange' (標準化空間) は
+--   smXStd の 0 次元で元スケールへ戻す。 band/PI も同様に y を逆変換。
+instance SingleVarModel m => SingleVarModel (StandardizedModel m) where
+  svRange (StandardizedModel inner sx _ _) =
+    let (zlo, zhi) = svRange inner
+        unX z = z * stSd1 sx + stMu1 sx
+    in (unX zlo, unX zhi)
+  svGrid (StandardizedModel inner sx mY _) level xs =
+    let zs            = map (applyStandardizerCol sx 0) xs
+        (muZ, mbBand) = svGrid inner level zs
+    in ( map (unstdY mY) muZ
+       , fmap (\(lo, hi) -> (map (unstdY mY) lo, map (unstdY mY) hi)) mbBand )
+  svGridPI (StandardizedModel inner sx mY _) level xs =
+    let zs = map (applyStandardizerCol sx 0) xs
+    in fmap (\(lo, hi) -> (map (unstdY mY) lo, map (unstdY mY) hi))
+            (svGridPI inner level zs)
+  -- 線形内側のみ式注釈 (係数を元スケールへ逆変換・R² はスケール不変で透過)。
+  --   X のみ標準化: y = β₀ + β₁·(x−μₓ)/σₓ = (β₀ − β₁μₓ/σₓ) + (β₁/σₓ)·x。
+  --   X+y 標準化:   y = σ_y·(β₀ + β₁·(x−μₓ)/σₓ) + μ_y。
+  svCoefR2 (StandardizedModel inner sx mY _) =
+    case svCoefR2 inner of
+      Just ([b0, b1], r2) ->
+        let mux = stMu1 sx; sdx = stSd1 sx
+            (a0, a1) = case mY of
+              Nothing         -> (b0 - b1 * mux / sdx, b1 / sdx)
+              Just (muY, sdY) -> (b0 * sdY + muY - b1 * sdY * mux / sdx, b1 * sdY / sdx)
+        in Just ([a0, a1], r2)
+      _ -> Nothing   -- 非線形 (kNN 等) は式注釈なし
+
+-- | 透過標準化ラッパの代表図 = 元スケールの予測曲線 (+ 単変量散布 'smTrain')。
+--   内側 'toPlot' (標準化軸) には依存せず、 ラッパ自身の 'SingleVarModel' を
+--   'statModel' grid 機構へ流す。
+instance SingleVarModel m => Plottable (StandardizedModel m) where
+  toPlot sm = case smTrain sm of
+    Just (xs, ys) -> layer (scatter (inline xs) (inline ys)) <> toPlot (statModel sm)
+    Nothing       -> toPlot (statModel sm)
+
+-- | 係数 bar (特徴名ラベル・元スケール) を代表図に。 CV パスがあれば診断束に λ-MSE 図。
+instance Plottable RegModel where
+  toPlot m =
+    layer (bar (inlineCat (rmgNames m)) (inline (rmgCoefs m)))
+      <> title (regMethodName (rmgMethod m) <> " coefficients (\955="
+                <> T.pack (show (roundTo 4 (rmgLambda m))) <> ")")
+  diagnosticPlots m = toPlot m : case rmgCVPath m of
+    Just (lams, scores) ->
+      [ layer (line (inline lams) (inline scores)) <> title "CV/LOOCV score path" ]
+    Nothing -> []
+
+-- | RegMethod の表示名 (図タイトル用)。
+regMethodName :: RegMethod -> Text
+regMethodName Ridge            = "Ridge"
+regMethodName Lasso            = "Lasso"
+regMethodName (ElasticNet _)   = "Elastic Net"
+regMethodName (MCP _)          = "MCP"
+regMethodName (SCAD _)         = "SCAD"
+regMethodName (AdaptiveLasso _) = "Adaptive Lasso"
+regMethodName (GroupLasso _)   = "Group Lasso"
+
+-- | 小数 n 桁丸め (タイトル表示用)。
+roundTo :: Int -> Double -> Double
+roundTo n v = let f = 10 ^^ n in fromIntegral (round (v * f) :: Integer) / f
+
+-- | A9: 'LMModel' の係数診断 (SE / t値 / p値) を一発取得する薄アクセサ。
+--   数値核は 'Hanalyze.Model.LM.Diagnostics.lmCoefStats'。 描画用に X を束ねた
+--   'LMModel' から設計行列 ('lmDesign') と fit 結果 ('lmResult') を渡すだけ。
+--   返りは係数順 (@[(Intercept), x]@) の 'CoefStats' リスト。
+lmDiag :: LMModel -> [CoefStats]
+lmDiag m = lmCoefStats (lmDesign m) (lmResult m)
+
+-- | A9: 群別 LM フィット ('grouped "g" (lm …)' の結果) の各群係数診断を取り出す。
+--   @[(群ラベル, [係数の CoefStats])]@。 群間で傾き SE/有意性を比較する用途。
+--   ★@Fitted spec ~ LMModel@ に特殊化 (LM 群フィット専用)。
+groupedLmDiag :: (Fitted spec ~ LMModel) => GroupedFit spec -> [(Text, [CoefStats])]
+groupedLmDiag = map (fmap lmDiag) . groupModels
+
+-- | 群別フィットを N 曲線で重畳する ('toPlot' = 各群 'svGrid' の μ̂ 曲線・群色 + 凡例)。
+--   ★A3 の凡例機構を N 群へ一般化: 各曲線を 'ColorByCol' (群ラベル) に載せ
+--   'scaleColorManual' で群色を固定し 'legend' を出す (固定色だと凡例が出ない罠を回避)。
+--   grid 点数 100・帯なし (A1 既定 OFF) 固定。 群色は 'effectPalette' の循環。
+instance SingleVarModel (Fitted spec) => Plottable (GroupedFit spec) where
+  toPlot = renderGrouped
+
+-- | 群別フィットを **各群の x 範囲のみ**で描く (既定。 'toPlot' = これ)。
+renderGrouped :: SingleVarModel (Fitted spec) => GroupedFit spec -> VisualSpec
+renderGrouped = renderGroupedWith False
+
+-- | 群別フィットを **データ全幅** (全群 x の union 範囲) へ延ばして描く (A7 fullrange)。
+--   ggplot @geom_smooth(fullrange = TRUE)@ 相当: 各群の回帰線を、 その群の x 範囲だけでなく
+--   **全群を合わせた x の min/max** まで延長して評価する (群間の傾き差を全域で比較しやすい)。
+--   ★単一モデルでは「データ全幅 = 訓練 x」 ゆえ意味を持たない (range 拡張は grouped 固有)。
+--   'toPlot' とは別経路 (結果型 'GroupedFit' に描画 flag を持たせない・別レンダラとして提供)。
+groupedFullrange :: SingleVarModel (Fitted spec) => GroupedFit spec -> VisualSpec
+groupedFullrange = renderGroupedWith True
+
+-- | 群別フィットの共通レンダラ。 @full@ で評価 x 範囲を切替える
+--   (@False@ = 各群自範囲、 @True@ = 全群 union 範囲 = A7 fullrange)。
+renderGroupedWith :: SingleVarModel (Fitted spec) => Bool -> GroupedFit spec -> VisualSpec
+renderGroupedWith full gf =
+  let pairs   = zip [0 :: Int ..] (gfGroups gf)
+      n       = 100
+      colOf i = effectPalette !! (i `mod` length effectPalette)
+      -- fullrange = 全群 svRange の union (lo = 最小, hi = 最大)。 群が無ければ使われない。
+      ranges  = [ svRange m | (_, (_, m)) <- pairs ]
+      unionLo = minimum (map fst ranges)
+      unionHi = maximum (map snd ranges)
+      curveOf (_, (lbl, m)) =
+        let (lo, hi) = if full then (unionLo, unionHi) else svRange m
+            gxs      = linspace lo hi n
+            (mu, _)  = svGrid m defaultCILevel gxs
+        in layer (line (inline gxs) (inline mu)
+                    <> colorBy (inlineCat (replicate n lbl)))
+      legendSpec
+        | null pairs = mempty
+        | otherwise  = scaleColorManual [ (lbl, colOf i) | (i, (lbl, _)) <- pairs ]
+                         <> legend
+  in foldMap curveOf pairs <> legendSpec
+
+-- --- plot ColData 源の ColumnSource instance (flag 配下・非 portable) -------
+--
+-- hgg の @[(Text, ColData)]@ (= df 中立表現)。 'NumData' は数値列、
+-- 'TxtData' は factor 列。 'lookupCol' は数値列のみ返し、 'toFrame' は
+-- 数値・文字列の両方を 'DX.DataFrame' に詰めて formula 経路で factor を温存する。
+instance ColumnSource [(Text, ColData)] where
+  lookupCol n cs = case lookup n cs of
+    Just (NumData v) -> Just (V.toList v)
+    _                -> Nothing
+  columnNames = map fst
+  toFrame cs  = DX.fromNamedColumns (concatMap toCol cs)
+    where
+      toCol (n, NumData v) = [(n, DX.fromList (V.toList v))]
+      toCol (n, TxtData v) = [(n, DX.fromList (V.toList v))]
+
+-- ===========================================================================
+-- 実測 vs 予測 (HasObsPred) — Phase 72.4
+--
+-- 罰則回帰 ('RegModel') は元スケール係数 (rmgIntercept + rmgCoefs) と生設計
+-- 'rmgXraw' から予測を再構成し、 実測は生応答 'rmgYraw' を使う。
+-- ===========================================================================
+
+instance HasObsPred RegModel where
+  obsPredPairs m =
+    let beta = LA.fromList (rmgCoefs m)
+        prd  = map (+ rmgIntercept m) (LA.toList (rmgXraw m LA.#> beta))
+    in (LA.toList (rmgYraw m), prd)
diff --git a/src/Hanalyze/Stat/AD.hs b/src/Hanalyze/Stat/AD.hs
--- a/src/Hanalyze/Stat/AD.hs
+++ b/src/Hanalyze/Stat/AD.hs
@@ -1,5 +1,11 @@
 {-# LANGUAGE RankNTypes #-}
--- | Exact gradient computation via automatic differentiation (AD), with HMC
+-- |
+-- Module      : Hanalyze.Stat.AD
+-- Description : automatic differentiation (AD) による正確な勾配計算 (HMC 連携)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Exact gradient computation via automatic differentiation (AD), with HMC
 -- integration.
 --
 -- Uses reverse-mode AD from @Numeric.AD@ (ekmett/ad) to compute gradients.
@@ -53,7 +59,7 @@
 import Data.IORef
 import qualified Data.Map.Strict as Map
 import Data.Text (Text)
-import Numeric.AD.Mode.Forward (grad)
+import Numeric.AD.Mode.Reverse.Double (grad)
 import System.Random.MWC (GenIO, uniform)
 import System.Random.MWC.Distributions (standard)
 
diff --git a/src/Hanalyze/Stat/AdaptiveGrid.hs b/src/Hanalyze/Stat/AdaptiveGrid.hs
--- a/src/Hanalyze/Stat/AdaptiveGrid.hs
+++ b/src/Hanalyze/Stat/AdaptiveGrid.hs
@@ -1,4 +1,10 @@
--- | Adaptive 1D grid generation.
+-- |
+-- Module      : Hanalyze.Stat.AdaptiveGrid
+-- Description : 複数 id 間で変化の急な領域に点を集中させる適応的 1D グリッド生成
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Adaptive 1D grid generation.
 --
 -- Builds a common grid that concentrates grid points in regions where the
 -- function changes rapidly across multiple ids.
diff --git a/src/Hanalyze/Stat/BayesFactor.hs b/src/Hanalyze/Stat/BayesFactor.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Stat/BayesFactor.hs
@@ -0,0 +1,101 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RankNTypes        #-}
+-- |
+-- Module      : Hanalyze.Stat.BayesFactor
+-- Description : Bridge Sampling による Bayes Factor (Kass & Raftery 1995) 計算
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Bayes Factor (Kass & Raftery 1995) via Bridge Sampling.
+--
+-- @
+--   BF_{10} = p(y | M_1) / p(y | M_0)
+-- @
+--
+-- 計算は 2 モデルそれぞれに対し
+-- 'Hanalyze.Stat.BridgeSampling.bridgeSampling' を呼び、 log marginal
+-- 同士の差を取る。 解釈表は Kass-Raftery (1995) Table 1。
+--
+-- Reference: Kass & Raftery (1995) "Bayes factors". JASA 90:773-795.
+module Hanalyze.Stat.BayesFactor
+  ( BayesFactorResult (..)
+  , bayesFactor
+  , BFInterpretation (..)
+  , interpretBF
+  ) where
+
+import           System.Random.MWC         (GenIO)
+
+import           Hanalyze.Model.HBM        (ModelP)
+import           Hanalyze.MCMC.Core        (Chain)
+import           Hanalyze.Stat.BridgeSampling
+                   (BridgeConfig, BridgeResult (..), bridgeSampling)
+
+-- ---------------------------------------------------------------------------
+-- Bayes Factor
+-- ---------------------------------------------------------------------------
+
+data BayesFactorResult = BayesFactorResult
+  { bfLog10        :: !Double  -- ^ log_10 BF_{10}
+  , bfLogE         :: !Double  -- ^ log_e BF_{10} = log p(y|M_1) - log p(y|M_0)
+  , bfLogMarginal0 :: !Double  -- ^ log p(y | M_0)
+  , bfLogMarginal1 :: !Double  -- ^ log p(y | M_1)
+  , bfConverged0   :: !Bool
+  , bfConverged1   :: !Bool
+  } deriving (Show)
+
+-- | 2 モデル間の Bayes Factor BF_{10} = p(y|M_1) / p(y|M_0)。
+-- 各モデルに対し Bridge Sampling で log marginal を推定、 差を取る。
+bayesFactor
+  :: forall r0 r1.
+     ModelP r0 -> Chain     -- ^ M_0 + posterior chain
+  -> ModelP r1 -> Chain     -- ^ M_1 + posterior chain
+  -> BridgeConfig
+  -> GenIO
+  -> IO BayesFactorResult
+bayesFactor m0 ch0 m1 ch1 cfg gen = do
+  r0 <- bridgeSampling m0 cfg ch0 gen
+  r1 <- bridgeSampling m1 cfg ch1 gen
+  let logE   = brLogMarginal r1 - brLogMarginal r0
+      log10v = logE / log 10
+  pure BayesFactorResult
+    { bfLog10        = log10v
+    , bfLogE         = logE
+    , bfLogMarginal0 = brLogMarginal r0
+    , bfLogMarginal1 = brLogMarginal r1
+    , bfConverged0   = brConverged r0
+    , bfConverged1   = brConverged r1
+    }
+
+-- ---------------------------------------------------------------------------
+-- Kass-Raftery 解釈表
+-- ---------------------------------------------------------------------------
+
+-- | Bayes Factor の強度区分 (Kass & Raftery 1995 Table 1)。
+-- 区分の境界は @log_e BF@ で定義 (= log_10 ≈ /2.303):
+--
+-- @
+--   0 < log_e BF < 1   (1 < BF < 2.7)    : Negligible
+--   1 ≤ log_e BF < 3   (2.7 ≤ BF < 20)   : Positive (substantial)
+--   3 ≤ log_e BF < 5   (20 ≤ BF < 150)   : Strong
+--   5 ≤ log_e BF       (BF ≥ 150)        : Very strong (decisive)
+-- @
+--
+-- 負側は対称 (M_0 寄り)。
+data BFInterpretation
+  = BFNegligible
+  | BFPositive          -- substantial evidence
+  | BFStrong
+  | BFVeryStrong
+  deriving (Show, Eq)
+
+-- | log_e BF 値から強度区分を返す。 符号で方向 (M_0 / M_1 どちらに寄与) は
+-- 呼び出し側が判定する想定 (@abs logE@ を渡しても OK)。
+interpretBF :: Double -> BFInterpretation
+interpretBF logE
+  | a < 1     = BFNegligible
+  | a < 3     = BFPositive
+  | a < 5     = BFStrong
+  | otherwise = BFVeryStrong
+  where
+    a = abs logE
diff --git a/src/Hanalyze/Stat/BayesianModelAveraging.hs b/src/Hanalyze/Stat/BayesianModelAveraging.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Stat/BayesianModelAveraging.hs
@@ -0,0 +1,88 @@
+{-# LANGUAGE OverloadedStrings #-}
+-- |
+-- Module      : Hanalyze.Stat.BayesianModelAveraging
+-- Description : Bridge Sampling の log marginal を用いた真の Bayesian Model Averaging (BMA)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- True Bayesian Model Averaging (BMA) via Bridge Sampling log marginals.
+--
+-- @
+--   p(θ | y) = Σ_k p(θ | y, M_k) · p(M_k | y)
+--   p(M_k | y) ∝ p(y | M_k) · p(M_k)
+-- @
+--
+-- 入力: 各モデルの **Bridge Sampling 推定 log marginal likelihood** +
+-- prior model weights (省略時 uniform 1/K)。
+--
+-- 出力: posterior model weights + 重み付き予測の helper。
+--
+-- ## 既存 pseudo-BMA との位置付け
+--
+-- 既存 'Hanalyze.Stat.ModelSelect' の pseudo-BMA (= PSIS-LOO ベース近似) は
+-- 軽量だが marginal likelihood を正しく計算していない (= LOO予測精度を代理
+-- 指標として使う)。 本 module は Bridge Sampling 経由で **真の log marginal**
+-- を使う BMA で、 解釈が一貫している (= Bayes Factor / 仮説検定と同じ基盤)。
+--
+-- Reference: Hoeting, Madigan, Raftery, Volinsky (1999) "Bayesian Model
+-- Averaging: A Tutorial". Statistical Science 14(4):382-417.
+module Hanalyze.Stat.BayesianModelAveraging
+  ( BMAResult (..)
+  , bayesianModelAveraging
+  , averagePredictions
+  ) where
+
+import qualified Numeric.LinearAlgebra    as LA
+
+-- ---------------------------------------------------------------------------
+-- BMA
+-- ---------------------------------------------------------------------------
+
+data BMAResult = BMAResult
+  { bmaWeights      :: ![Double]   -- ^ posterior model weights @p(M_k|y)@、 Σ = 1
+  , bmaLogMarginals :: ![Double]   -- ^ 入力された per-model @log p(y|M_k)@ (引き継ぎ)
+  , bmaLogPriors    :: ![Double]   -- ^ 入力された per-model @log p(M_k)@ (引き継ぎ)
+  } deriving (Show)
+
+-- | log marginal + log prior weights (省略時 uniform) から posterior model
+-- weights を計算 (softmax 安定化)。
+--
+-- @
+--   p(M_k | y) ∝ exp(log p(y|M_k) + log p(M_k))
+-- @
+--
+-- 引数の長さは同じである必要 (異なる場合は短い方に合わせる)。 全 log
+-- marginal が -∞ なら uniform fallback。
+bayesianModelAveraging
+  :: [Double]          -- ^ log marginals (Bridge Sampling 推定値 等)
+  -> Maybe [Double]    -- ^ optional log prior weights (Nothing = uniform)
+  -> BMAResult
+bayesianModelAveraging logMs mPriors =
+  let k = length logMs
+      logPriors = case mPriors of
+        Just ps | length ps == k -> ps
+        _                        -> replicate k (- log (fromIntegral k))
+      logUnnorm = zipWith (+) logMs logPriors
+      ws = if all isInfinite logUnnorm
+             then replicate k (1 / fromIntegral k)   -- fallback uniform
+             else
+               let m  = maximum logUnnorm
+                   es = map (\x -> exp (x - m)) logUnnorm
+                   s  = sum es
+               in if s == 0 then replicate k (1 / fromIntegral k)
+                            else map (/ s) es
+  in BMAResult
+       { bmaWeights      = ws
+       , bmaLogMarginals = logMs
+       , bmaLogPriors    = logPriors
+       }
+
+-- | per-model 予測ベクトル (= 各モデルから出した y* の posterior mean 等) を
+-- BMA weights で加重平均。 全ベクトルは同じ長さである必要。
+averagePredictions :: BMAResult -> [LA.Vector Double] -> LA.Vector Double
+averagePredictions bma preds
+  | null preds = LA.fromList []
+  | length preds /= length (bmaWeights bma) =
+      error "averagePredictions: number of predictions ≠ number of weights"
+  | otherwise =
+      foldr1 (+) [ LA.scale w v | (w, v) <- zip (bmaWeights bma) preds ]
diff --git a/src/Hanalyze/Stat/Bootstrap.hs b/src/Hanalyze/Stat/Bootstrap.hs
--- a/src/Hanalyze/Stat/Bootstrap.hs
+++ b/src/Hanalyze/Stat/Bootstrap.hs
@@ -1,5 +1,11 @@
 {-# LANGUAGE OverloadedStrings #-}
--- | Bootstrap resampling and permutation tests.
+-- |
+-- Module      : Hanalyze.Stat.Bootstrap
+-- Description : ブートストラップ再標本化と置換検定
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Bootstrap resampling and permutation tests.
 --
 -- @
 -- import Hanalyze.Stat.Bootstrap
diff --git a/src/Hanalyze/Stat/BridgeSampling.hs b/src/Hanalyze/Stat/BridgeSampling.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Stat/BridgeSampling.hs
@@ -0,0 +1,233 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RankNTypes        #-}
+{-# LANGUAGE BangPatterns      #-}
+-- |
+-- Module      : Hanalyze.Stat.BridgeSampling
+-- Description : Bridge Sampling による周辺尤度 log p(y) 推定 (Meng & Wong 1996)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Bridge Sampling estimator of the marginal likelihood @log p(y)@
+-- (Meng & Wong 1996).
+--
+-- Reference:
+--
+--   * Meng & Wong (1996) "Simulating ratios of normalising constants
+--     via a simple identity: a theoretical exploration". Statistica
+--     Sinica 6:831-860.
+--   * Gronau, Sarafoglou, Matzke, Ly, Boehm, Marsman, Leslie, Forster,
+--     Wagenmakers, Steingroever (2017) "A tutorial on bridge sampling".
+--     Journal of Mathematical Psychology 81:80-97.
+--
+-- ## アルゴリズム (Phase 29-A2)
+--
+-- 目的: 周辺尤度 @log p(y) = log ∫ p(y|θ) p(θ) dθ@ を、 既存 MCMC chain
+-- (posterior samples) と diagonal Gaussian proposal @g(θ)@ から推定する。
+--
+-- Bridge identity (Meng-Wong):
+--
+-- @
+--   p(y) = E_g[α(θ) q(θ)] / E_p[α(θ) g(θ)]
+-- @
+--
+-- 最適 bridge function @α*(θ) = 1 / (s_1 q(θ) + s_2 r g(θ))@ を使った
+-- iterative scheme で @r̂@ を求める:
+--
+-- @
+--   r̂_{t+1} = [(1/N_2) Σ_i q(θ̃_2,i) / (s_1 q(θ̃_2,i) + s_2 r̂_t g(θ̃_2,i))]
+--           / [(1/N_1) Σ_j g(θ̃_1,j) / (s_1 q(θ̃_1,j) + s_2 r̂_t g(θ̃_1,j))]
+-- @
+--
+-- ここで:
+--   * @θ̃_1@ は proposal @g@ から (本実装では Gaussian fit-to-chain)
+--   * @θ̃_2@ は posterior chain サンプル
+--   * @s_1 = N_1/(N_1+N_2)@、 @s_2 = N_2/(N_1+N_2)@
+--   * @q(θ) = p(y|θ)·p(θ)@ = 'logJoint' の exp 化
+--
+-- 全計算は **log space** で行い (log-sum-exp 安定化)、 浮動小数 underflow を回避。
+--
+-- ## SMC との関係 (Phase 29-A1/A2 統合)
+--
+-- SMC は副産物として log marginal を推定する (= temperature schedule の
+-- incremental log-mean-weight 累積)。 Bridge Sampling は MCMC chain + proposal
+-- から **独立な推定経路** で求めるので、 両者が 5% 以内で一致すれば妥当性が裏付け。
+-- 不一致なら chain の収束不足 / SMC schedule 粗さ / proposal 不適切のサイン。
+module Hanalyze.Stat.BridgeSampling
+  ( BridgeConfig (..)
+  , defaultBridgeConfig
+  , BridgeResult (..)
+  , bridgeSampling
+  ) where
+
+import           Control.Monad             (replicateM, forM)
+import qualified Data.Map.Strict           as Map
+import           Data.Text                 (Text)
+import           System.Random.MWC         (GenIO)
+import           System.Random.MWC.Distributions (normal)
+
+import           Hanalyze.Model.HBM        (ModelP, Params, logJoint, sampleNames)
+import           Hanalyze.MCMC.Core        (Chain (..), chainVals)
+
+-- ---------------------------------------------------------------------------
+-- Configuration
+-- ---------------------------------------------------------------------------
+
+-- | Bridge Sampling 設定。
+data BridgeConfig = BridgeConfig
+  { bcNProposal :: !Int     -- ^ N_1: proposal samples 数 (典型 chain サンプル数と同等)
+  , bcMaxIter   :: !Int     -- ^ 反復解の最大回数 (典型 100、 通常 < 20 で収束)
+  , bcTolerance :: !Double  -- ^ 反復収束判定 |Δ log r̂| < tol (典型 1e-6)
+  } deriving (Show)
+
+defaultBridgeConfig :: BridgeConfig
+defaultBridgeConfig = BridgeConfig
+  { bcNProposal = 500
+  , bcMaxIter   = 100
+  , bcTolerance = 1e-6
+  }
+
+-- | Bridge Sampling 結果。
+data BridgeResult = BridgeResult
+  { brLogMarginal :: !Double   -- ^ 推定 @log p(y)@
+  , brIterations  :: !Int      -- ^ 収束に要した反復数
+  , brConverged   :: !Bool     -- ^ tol 以内で収束したか
+  } deriving (Show)
+
+-- ---------------------------------------------------------------------------
+-- 公開 API
+-- ---------------------------------------------------------------------------
+
+-- | Bridge Sampling で @log p(y)@ を推定。
+--
+-- 入力:
+--   * モデル (logJoint = log q(θ) = log p(y|θ) + log p(θ))
+--   * posterior chain (既存 NUTS / MH / SMC 等の結果)
+--   * proposal は **diagonal Gaussian fit** to chain (各パラメータの sample
+--     mean / SD から構築)
+--
+-- 出力: log marginal likelihood 推定値 + 収束情報。
+bridgeSampling
+  :: forall r. ModelP r
+  -> BridgeConfig
+  -> Chain                     -- ^ posterior chain
+  -> GenIO
+  -> IO BridgeResult
+bridgeSampling model cfg chain gen = do
+  let names      = sampleNames model
+      posterior  = chainSamples chain
+      n2         = length posterior
+      (mus, sds) = fitDiagGaussian names chain
+  -- 1. Sample N_1 from proposal g (diagonal Gaussian)
+  proposal <- replicateM (bcNProposal cfg) (sampleProposal names mus sds gen)
+  let n1   = length proposal
+      s1   = fromIntegral n1 / fromIntegral (n1 + n2)
+      s2   = fromIntegral n2 / fromIntegral (n1 + n2)
+      -- 2. Precompute log q (logJoint) and log g (proposal log-density)
+      logq2 = map (logJoint model) posterior
+      logq1 = map (logJoint model) proposal
+      logg2 = map (logProposal names mus sds) posterior
+      logg1 = map (logProposal names mus sds) proposal
+  -- 3. Iterative solve for log r̂
+  let (logR, niter, converged) =
+        iterateBridge cfg logq1 logg1 logq2 logg2 s1 s2 0.0
+  pure BridgeResult
+    { brLogMarginal = logR
+    , brIterations  = niter
+    , brConverged   = converged
+    }
+
+-- | Meng-Wong iterative formula in log space.
+iterateBridge
+  :: BridgeConfig
+  -> [Double] -> [Double]   -- ^ logq1, logg1 (proposal samples)
+  -> [Double] -> [Double]   -- ^ logq2, logg2 (posterior samples)
+  -> Double                 -- ^ s_1
+  -> Double                 -- ^ s_2
+  -> Double                 -- ^ 初期 log r̂
+  -> (Double, Int, Bool)
+iterateBridge cfg logq1 logg1 logq2 logg2 s1 s2 logR0 = go 0 logR0
+  where
+    ls1 = log s1
+    ls2 = log s2
+    go !it !logR
+      | it >= bcMaxIter cfg = (logR, it, False)
+      | otherwise =
+          let -- Numerator: posterior 側の logq2 - logSumExp(s1·q2, s2·r·g2)
+              numTerms =
+                [ lq - logSumExp2 (ls1 + lq) (ls2 + logR + lg)
+                | (lq, lg) <- zip logq2 logg2 ]
+              -- Denominator: proposal 側の logg1 - logSumExp(s1·q1, s2·r·g1)
+              denTerms =
+                [ lg - logSumExp2 (ls1 + lq) (ls2 + logR + lg)
+                | (lq, lg) <- zip logq1 logg1 ]
+              num = logMeanExp numTerms
+              den = logMeanExp denTerms
+              logR' = num - den
+              diff  = abs (logR' - logR)
+          in if diff < bcTolerance cfg
+               then (logR', it + 1, True)
+               else go (it + 1) logR'
+
+-- ---------------------------------------------------------------------------
+-- Diagonal Gaussian proposal (fit-to-chain)
+-- ---------------------------------------------------------------------------
+
+-- | chain から各パラメータの sample mean / SD を抽出。 SD = 0 になりうる
+-- (定数推定) 場合は 1e-6 で下駄を履かせる (g(θ) 評価で除算 0 を避ける safety)。
+fitDiagGaussian
+  :: [Text] -> Chain -> (Map.Map Text Double, Map.Map Text Double)
+fitDiagGaussian names chain =
+  let mus = Map.fromList
+        [ (n, mean (chainVals n chain)) | n <- names ]
+      sds = Map.fromList
+        [ (n, max 1e-6 (stddev (chainVals n chain))) | n <- names ]
+  in (mus, sds)
+  where
+    mean xs = sum xs / fromIntegral (length xs)
+    stddev xs =
+      let mu = mean xs
+          n  = fromIntegral (length xs) :: Double
+      in if n <= 1 then 0
+                   else sqrt (sum [(x - mu) ^ (2 :: Int) | x <- xs] / (n - 1))
+
+-- | Diagonal Gaussian proposal からサンプル抽出。
+sampleProposal
+  :: [Text] -> Map.Map Text Double -> Map.Map Text Double -> GenIO
+  -> IO Params
+sampleProposal names mus sds gen =
+  fmap Map.fromList $ forM names $ \n -> do
+    let mu = Map.findWithDefault 0 n mus
+        sd = Map.findWithDefault 1 n sds
+    x <- normal mu sd gen
+    pure (n, x)
+
+-- | log density of diagonal Gaussian proposal at θ。
+logProposal
+  :: [Text] -> Map.Map Text Double -> Map.Map Text Double -> Params
+  -> Double
+logProposal names mus sds theta =
+  sum
+    [ let mu = Map.findWithDefault 0 n mus
+          sd = Map.findWithDefault 1 n sds
+          x  = Map.findWithDefault 0 n theta
+          z  = (x - mu) / sd
+      in -0.5 * log (2 * pi) - log sd - 0.5 * z * z
+    | n <- names ]
+
+-- ---------------------------------------------------------------------------
+-- log-sum-exp helpers
+-- ---------------------------------------------------------------------------
+
+logSumExp2 :: Double -> Double -> Double
+logSumExp2 a b
+  | a > b     = a + log (1 + exp (b - a))
+  | otherwise = b + log (1 + exp (a - b))
+
+logMeanExp :: [Double] -> Double
+logMeanExp xs
+  | null xs   = -1 / 0
+  | otherwise =
+      let m  = maximum xs
+          s  = sum [ exp (x - m) | x <- xs ]
+          n  = fromIntegral (length xs) :: Double
+      in m + log (s / n)
diff --git a/src/Hanalyze/Stat/CV.hs b/src/Hanalyze/Stat/CV.hs
--- a/src/Hanalyze/Stat/CV.hs
+++ b/src/Hanalyze/Stat/CV.hs
@@ -1,6 +1,12 @@
 {-# LANGUAGE OverloadedStrings #-}
--- | Cross-validation framework.
+-- |
+-- Module      : Hanalyze.Stat.CV
+-- Description : クロスバリデーションのフレームワーク (fold 分割 + 汎用 crossValidate)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
 --
+-- Cross-validation framework.
+--
 -- Provides train/validation splits and a generic 'crossValidate'
 -- function that runs a user-supplied @fit@ + @score@ on each fold.
 --
@@ -44,6 +50,7 @@
 import qualified Data.Vector           as V
 import qualified Data.Vector.Mutable   as VM
 import           Control.Monad         (forM, forM_)
+import           Control.Monad.Primitive (PrimMonad, PrimState)
 import           Data.List             (sortBy)
 import           Data.Ord              (comparing)
 import qualified System.Random.MWC     as MWC
@@ -60,12 +67,16 @@
 -- Split strategies
 -- ---------------------------------------------------------------------------
 
--- | Random k-fold split.
+-- | Random k-fold split. 'PrimMonad' 汎用 (mwc は 'PrimMonad' 汎用) ゆえ ST/IO 両経路で
+-- 同コード。 IO 呼び出しは従来どおり。 純粋 (seed) 経路は呼び出し側で
+-- @runST (MWC.initialize (V.singleton seed) >>= kFold k n)@ で完結 (Phase 70.7 = 罰則回帰
+-- の λ CV 純粋化に使う・[[selectLambdaCV]])。
 kFold
-  :: Int            -- ^ Number of folds @k@.
+  :: PrimMonad m
+  => Int            -- ^ Number of folds @k@.
   -> Int            -- ^ Total sample count @n@.
-  -> MWC.GenIO
-  -> IO [Fold]
+  -> MWC.Gen (PrimState m)
+  -> m [Fold]
 kFold k n gen
   | k < 2     = pure [(allIdx n, [])]
   | k > n     = leaveOneOut n
@@ -118,7 +129,7 @@
 
 -- | Leave-one-out cross-validation: @n@ folds, each test set is a
 -- single row.
-leaveOneOut :: Int -> IO [Fold]
+leaveOneOut :: Applicative f => Int -> f [Fold]
 leaveOneOut n =
   pure [ ([j | j <- [0 .. n - 1], j /= i], [i]) | i <- [0 .. n - 1] ]
 
@@ -236,8 +247,8 @@
 allIdx :: Int -> [Int]
 allIdx n = [0 .. n - 1]
 
--- | Fisher-Yates shuffle producing a list of indices.
-shuffleIndices :: Int -> MWC.GenIO -> IO [Int]
+-- | Fisher-Yates shuffle producing a list of indices. 'PrimMonad' 汎用 (ST/IO 両用)。
+shuffleIndices :: PrimMonad m => Int -> MWC.Gen (PrimState m) -> m [Int]
 shuffleIndices n gen = do
   v <- V.thaw (V.fromList [0 .. n - 1])
   forM_ [n - 1, n - 2 .. 1] $ \i -> do
@@ -248,8 +259,8 @@
     VM.write v j a
   V.toList <$> V.freeze v
 
--- | Shuffle an arbitrary list.
-shuffleList :: [a] -> MWC.GenIO -> IO [a]
+-- | Shuffle an arbitrary list. 'PrimMonad' 汎用 (ST/IO 両用)。
+shuffleList :: PrimMonad m => [a] -> MWC.Gen (PrimState m) -> m [a]
 shuffleList xs gen = do
   let n = length xs
   v <- V.thaw (V.fromList xs)
diff --git a/src/Hanalyze/Stat/Causal/CATE.hs b/src/Hanalyze/Stat/Causal/CATE.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Stat/Causal/CATE.hs
@@ -0,0 +1,197 @@
+-- |
+-- Module      : Hanalyze.Stat.Causal.CATE
+-- Description : Künzel et al. (2019) の S/T/X-Learner による CATE meta-learner 実装
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Conditional Average Treatment Effect (CATE) meta-learners (Phase 30-A4)。
+--
+-- Künzel et al. (2019) の 3 meta-learner を実装:
+--
+-- - 'SLearner': 単一モデル @μ̂(X, T)@、 @τ̂(X) = μ̂(X, 1) - μ̂(X, 0)@
+-- - 'TLearner': 2 モデル @μ̂_1(X)@ / @μ̂_0(X)@、 @τ̂(X) = μ̂_1(X) - μ̂_0(X)@
+-- - 'XLearner': T-learner の残差を再帰回帰、 PS で重み付け平均
+--
+-- base learner は 'CATELM' (= 'Hanalyze.Model.LM') と 'CATERF' (=
+-- 'Hanalyze.Model.RandomForest') から選択。 将来 Causal Forest 等を追加する
+-- ときは新 constructor を加える。
+--
+-- ## 使い方
+--
+-- @
+--   gen <- MWC.create
+--   r   <- fitCATE TLearner CATELM x t y gen
+--   print (cateATE r)   -- average of cateEstimates
+-- @
+--
+-- Reference:
+--   Künzel, Sekhon, Bickel, Yu (2019) "Metalearners for estimating
+--   heterogeneous treatment effects using machine learning".
+--   PNAS 116:4156-4165.
+module Hanalyze.Stat.Causal.CATE
+  ( CATEBaseLearner (..)
+  , CATELearner (..)
+  , CATEResult (..)
+  , fitCATE
+  ) where
+
+import qualified Numeric.LinearAlgebra      as LA
+import qualified Data.Vector.Storable       as VS
+import qualified Data.Vector.Unboxed        as VU
+import qualified Hanalyze.Model.LM          as LM
+import qualified Hanalyze.Model.RandomForest as RF
+import           Hanalyze.Model.Core         (coefficientsV)
+import           Hanalyze.Stat.Causal.PropensityScore
+                   (PropensityScore (..), propensityScore, trimPropensity)
+import           Hanalyze.Stat.Causal.IPW   (defaultPSTrim)
+import qualified System.Random.MWC          as MWC
+
+-- ---------------------------------------------------------------------------
+-- 型
+-- ---------------------------------------------------------------------------
+
+-- | base learner 選択。 LM は OLS、 RF は Random Forest。
+data CATEBaseLearner = CATELM | CATERF RF.RFConfig
+  deriving (Show)
+
+-- | meta-learner 選択。
+data CATELearner = SLearner | TLearner | XLearner
+  deriving (Show, Eq)
+
+data CATEResult = CATEResult
+  { cateEstimates :: !(LA.Vector Double)  -- ^ τ̂(X_i) for each unit
+  , cateMethod    :: !CATELearner
+  , cateATE       :: !Double               -- ^ mean of cateEstimates
+  } deriving (Show)
+
+-- ---------------------------------------------------------------------------
+-- Base learner abstraction
+-- ---------------------------------------------------------------------------
+
+-- | Train a base learner on (X, y) and return a predictor for new X.
+-- Random forest path threads through @MWC.GenIO@; LM is pure but is
+-- wrapped in @IO@ for uniform signature.
+fitPredict :: CATEBaseLearner
+           -> LA.Matrix Double -> LA.Vector Double -> MWC.GenIO
+           -> IO (LA.Matrix Double -> LA.Vector Double)
+fitPredict CATELM x y _ = do
+  let beta = coefficientsV (LM.fitLMVec x y)
+  pure (\xNew -> LM.predictLMVec beta xNew)
+fitPredict (CATERF cfg) x y gen = do
+  rf <- RF.fitRFV cfg x (VS.convert y :: VU.Vector Double)
+                  gen
+  pure (\xNew ->
+          let rows = LA.toRows xNew
+          in LA.fromList [RF.predictRF rf (LA.toList r) | r <- rows])
+
+-- ---------------------------------------------------------------------------
+-- fitCATE
+-- ---------------------------------------------------------------------------
+
+fitCATE :: CATELearner -> CATEBaseLearner
+        -> LA.Matrix Double -> LA.Vector Double -> LA.Vector Double
+        -> MWC.GenIO -> IO CATEResult
+fitCATE method base x t y gen = case method of
+  SLearner -> sLearner base x t y gen
+  TLearner -> tLearner base x t y gen
+  XLearner -> xLearner base x t y gen
+
+-- ---------------------------------------------------------------------------
+-- S-learner: 単一モデル on (X, T)
+-- ---------------------------------------------------------------------------
+
+sLearner :: CATEBaseLearner
+         -> LA.Matrix Double -> LA.Vector Double -> LA.Vector Double
+         -> MWC.GenIO -> IO CATEResult
+sLearner base x t y gen = do
+  let xt  = LA.fromBlocks [[x, LA.asColumn t]]
+      n   = LA.rows x
+      x1  = LA.fromBlocks [[x, LA.asColumn (LA.fromList (replicate n 1))]]
+      x0  = LA.fromBlocks [[x, LA.asColumn (LA.fromList (replicate n 0))]]
+  predict <- fitPredict base xt y gen
+  let mu1 = predict x1
+      mu0 = predict x0
+      tauHat = mu1 - mu0
+  pure CATEResult
+    { cateEstimates = tauHat
+    , cateMethod    = SLearner
+    , cateATE       = LA.sumElements tauHat / fromIntegral n
+    }
+
+-- ---------------------------------------------------------------------------
+-- T-learner: 2 モデル、 群別 fit
+-- ---------------------------------------------------------------------------
+
+tLearner :: CATEBaseLearner
+         -> LA.Matrix Double -> LA.Vector Double -> LA.Vector Double
+         -> MWC.GenIO -> IO CATEResult
+tLearner base x t y gen = do
+  let n    = LA.rows x
+      idx1 = filterIdx (== 1.0) t
+      idx0 = filterIdx (== 0.0) t
+      x1   = x LA.? idx1
+      y1   = LA.fromList [LA.atIndex y i | i <- idx1]
+      x0   = x LA.? idx0
+      y0   = LA.fromList [LA.atIndex y i | i <- idx0]
+  pred1 <- fitPredict base x1 y1 gen
+  pred0 <- fitPredict base x0 y0 gen
+  let mu1 = pred1 x
+      mu0 = pred0 x
+      tauHat = mu1 - mu0
+  pure CATEResult
+    { cateEstimates = tauHat
+    , cateMethod    = TLearner
+    , cateATE       = LA.sumElements tauHat / fromIntegral n
+    }
+
+-- ---------------------------------------------------------------------------
+-- X-learner: 残差再回帰 + PS 重み付け
+-- ---------------------------------------------------------------------------
+
+xLearner :: CATEBaseLearner
+         -> LA.Matrix Double -> LA.Vector Double -> LA.Vector Double
+         -> MWC.GenIO -> IO CATEResult
+xLearner base x t y gen = do
+  let n    = LA.rows x
+      idx1 = filterIdx (== 1.0) t
+      idx0 = filterIdx (== 0.0) t
+      x1   = x LA.? idx1
+      y1   = LA.fromList [LA.atIndex y i | i <- idx1]
+      x0   = x LA.? idx0
+      y0   = LA.fromList [LA.atIndex y i | i <- idx0]
+  -- Step 1: T-learner と同じ outcome models
+  pred1 <- fitPredict base x1 y1 gen
+  pred0 <- fitPredict base x0 y0 gen
+  -- Step 2: imputed treatment effects
+  --   For T=1 units: D̃_1 = Y - μ̂_0(X)
+  --   For T=0 units: D̃_0 = μ̂_1(X) - Y
+  let mu0_at_x1 = pred0 x1
+      mu1_at_x0 = pred1 x0
+      dTilde1   = y1 - mu0_at_x1
+      dTilde0   = mu1_at_x0 - y0
+  -- Step 3: τ̂_1(X) を D̃_1 ~ X_{T=1} で fit、 τ̂_0(X) は D̃_0 ~ X_{T=0}
+  tau1Pred <- fitPredict base x1 dTilde1 gen
+  tau0Pred <- fitPredict base x0 dTilde0 gen
+  let tau1At = tau1Pred x
+      tau0At = tau0Pred x
+  -- Step 4: PS 重み付け平均
+  --   τ̂(X) = p̂(X) · τ̂_0(X) + (1 - p̂(X)) · τ̂_1(X)
+  --   (treated が少ない領域では τ̂_0 を信頼、 control が少ない領域では τ̂_1)
+      (lo, hi) = defaultPSTrim
+  let ps     = trimPropensity lo hi (propensityScore x t)
+      p      = psScores ps
+      one    = LA.scalar 1
+      tauHat = p * tau0At + (one - p) * tau1At
+  pure CATEResult
+    { cateEstimates = tauHat
+    , cateMethod    = XLearner
+    , cateATE       = LA.sumElements tauHat / fromIntegral n
+    }
+
+-- ---------------------------------------------------------------------------
+-- ヘルパ
+-- ---------------------------------------------------------------------------
+
+filterIdx :: (Double -> Bool) -> LA.Vector Double -> [Int]
+filterIdx pr v =
+  [ i | i <- [0 .. LA.size v - 1], pr (LA.atIndex v i) ]
diff --git a/src/Hanalyze/Stat/Causal/DoublyRobust.hs b/src/Hanalyze/Stat/Causal/DoublyRobust.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Stat/Causal/DoublyRobust.hs
@@ -0,0 +1,99 @@
+-- |
+-- Module      : Hanalyze.Stat.Causal.DoublyRobust
+-- Description : Doubly Robust / Augmented IPW (AIPW) 推定量
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Doubly Robust / Augmented IPW (AIPW) 推定量 (Phase 30-A3)。
+--
+-- 結果モデル @μ̂_1(X)@ / @μ̂_0(X)@ と傾向スコア @p̂(X)@ の両方を使い、
+-- どちらか一方が正しく指定されていれば一致性を持つ推定量:
+--
+-- @
+--   ATE_AIPW = (1/n) Σ [ μ̂_1(X_i) - μ̂_0(X_i)
+--                       + T_i (Y_i - μ̂_1(X_i)) / p̂_i
+--                       - (1-T_i) (Y_i - μ̂_0(X_i)) / (1 - p̂_i) ]
+-- @
+--
+-- 結果モデルは 'Hanalyze.Model.LM.fitLM' を流用 (= OLS、 線形)。 非線形が
+-- 必要な場合は呼び出し側で X を拡張するか CATE module (30-A4) を使う。
+--
+-- Reference:
+--   Robins, Rotnitzky, Zhao (1994) "Estimation of Regression Coefficients
+--   When Some Regressors Are Not Always Observed". JASA 89:846-866.
+module Hanalyze.Stat.Causal.DoublyRobust
+  ( DoublyRobustResult (..)
+  , doublyRobust
+  , doublyRobustWith
+  ) where
+
+import qualified Numeric.LinearAlgebra as LA
+import qualified Hanalyze.Model.LM    as LM
+import           Hanalyze.Model.Core   (coefficientsV)
+import           Hanalyze.Stat.Causal.PropensityScore
+                   (PropensityScore (..), propensityScore, trimPropensity)
+import           Hanalyze.Stat.Causal.IPW (defaultPSTrim)
+
+-- ---------------------------------------------------------------------------
+-- 型
+-- ---------------------------------------------------------------------------
+
+data DoublyRobustResult = DoublyRobustResult
+  { drATE          :: !Double
+  , drMu1Predicted :: !(LA.Vector Double)  -- ^ μ̂_1(X_i) for all i
+  , drMu0Predicted :: !(LA.Vector Double)  -- ^ μ̂_0(X_i) for all i
+  , drPropensity   :: !PropensityScore
+  } deriving (Show)
+
+-- ---------------------------------------------------------------------------
+-- AIPW
+-- ---------------------------------------------------------------------------
+
+-- | 共変量 @X@ (intercept 列を含む)、 二値処置 @T@、 結果 @Y@ から AIPW ATE
+-- を推定。 内部で 'propensityScore' + 'defaultPSTrim' を適用、 outcome
+-- model は OLS で群別 fit。
+doublyRobust :: LA.Matrix Double -> LA.Vector Double -> LA.Vector Double
+             -> DoublyRobustResult
+doublyRobust x t y =
+  let (lo, hi) = defaultPSTrim
+      ps       = trimPropensity lo hi (propensityScore x t)
+  in doublyRobustWith ps x t y
+
+-- | 既存 PS を再利用する版。 PS と outcome model の組み合わせを変えて
+-- 二重ロバスト性を検証したい場合に有用。
+doublyRobustWith :: PropensityScore -> LA.Matrix Double -> LA.Vector Double
+                 -> LA.Vector Double -> DoublyRobustResult
+doublyRobustWith ps x t y =
+  let n     = fromIntegral (LA.size t) :: Double
+      one   = LA.scalar 1
+      p     = psScores ps
+      -- 群別 OLS: T=1 部分集合 / T=0 部分集合
+      idx1 = filterIdx (== 1.0) t
+      idx0 = filterIdx (== 0.0) t
+      x1   = x LA.? idx1
+      y1   = LA.fromList [LA.atIndex y i | i <- idx1]
+      x0   = x LA.? idx0
+      y0   = LA.fromList [LA.atIndex y i | i <- idx0]
+      beta1 = coefficientsV (LM.fitLMVec x1 y1)
+      beta0 = coefficientsV (LM.fitLMVec x0 y0)
+      mu1   = LM.predictLMVec beta1 x
+      mu0   = LM.predictLMVec beta0 x
+      -- AIPW contribution per unit
+      contrib = (mu1 - mu0)
+              + t * (y - mu1) / p
+              - (one - t) * (y - mu0) / (one - p)
+      ateHat = LA.sumElements contrib / n
+  in DoublyRobustResult
+       { drATE          = ateHat
+       , drMu1Predicted = mu1
+       , drMu0Predicted = mu0
+       , drPropensity   = ps
+       }
+
+-- ---------------------------------------------------------------------------
+-- ヘルパ
+-- ---------------------------------------------------------------------------
+
+filterIdx :: (Double -> Bool) -> LA.Vector Double -> [Int]
+filterIdx pr v =
+  [ i | i <- [0 .. LA.size v - 1], pr (LA.atIndex v i) ]
diff --git a/src/Hanalyze/Stat/Causal/IPW.hs b/src/Hanalyze/Stat/Causal/IPW.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Stat/Causal/IPW.hs
@@ -0,0 +1,103 @@
+-- |
+-- Module      : Hanalyze.Stat.Causal.IPW
+-- Description : Inverse Probability Weighting (IPW) による ATE / ATT 推定
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Inverse Probability Weighting (IPW) による ATE / ATT 推定 (Phase 30-A2)。
+--
+-- Hajek 正規化推定量 (finite-sample で stable、 Horvitz-Thompson より低分散):
+--
+-- @
+--   ATE = Σ(T·Y/p) / Σ(T/p)  -  Σ((1-T)·Y/(1-p)) / Σ((1-T)/(1-p))
+--   ATT = Σ(T·Y) / Σ T       -  Σ((1-T)·(p/(1-p))·Y) / Σ((1-T)·(p/(1-p)))
+-- @
+--
+-- ここで @p_i@ は 'PropensityScore' で推定した P(T=1 | X_i)。 重みは
+-- @PropensityScore.ipwWeights@ / @attWeights@ で hmatrix Vector 演算で計算。
+--
+-- ## 使い方
+--
+-- @
+--   let r = ipw xConf treat outcome           -- 共変量から PS 推定 + trim も内部で実施
+--   print (ipwATE r, ipwATT r)
+--
+--   -- 既に PS を計算済 / カスタム trim したい場合:
+--   let ps' = trimPropensity 0.05 0.95 (propensityScore x t)
+--       r'  = ipwWith ps' t y
+-- @
+--
+-- Reference:
+--   Horvitz & Thompson (1952) "A Generalization of Sampling Without
+--   Replacement from a Finite Universe". JASA 47:663-685.
+module Hanalyze.Stat.Causal.IPW
+  ( IPWResult (..)
+  , ipw
+  , ipwWith
+  , defaultPSTrim
+  ) where
+
+import qualified Numeric.LinearAlgebra            as LA
+import           Hanalyze.Stat.Causal.PropensityScore
+                   (PropensityScore (..), propensityScore, trimPropensity,
+                    ipwWeights, attWeights)
+
+-- ---------------------------------------------------------------------------
+-- 型
+-- ---------------------------------------------------------------------------
+
+data IPWResult = IPWResult
+  { ipwATE        :: !Double
+  , ipwATT        :: !Double
+  , ipwWeightsATE :: !(LA.Vector Double)
+  , ipwWeightsATT :: !(LA.Vector Double)
+  , ipwPropensity :: !PropensityScore
+  } deriving (Show)
+
+-- | 既定の PS trim 範囲 @(0.01, 0.99)@ (Rosenbaum 慣例)。
+defaultPSTrim :: (Double, Double)
+defaultPSTrim = (0.01, 0.99)
+
+-- ---------------------------------------------------------------------------
+-- 推定
+-- ---------------------------------------------------------------------------
+
+-- | 共変量 @X@、 二値処置 @T@、 結果 @Y@ から ATE / ATT を IPW で推定。
+-- 内部で 'propensityScore' + 'defaultPSTrim' を適用。
+ipw :: LA.Matrix Double -> LA.Vector Double -> LA.Vector Double -> IPWResult
+ipw x t y =
+  let (lo, hi) = defaultPSTrim
+      ps       = trimPropensity lo hi (propensityScore x t)
+  in ipwWith ps t y
+
+-- | 既に算出 (+trim) 済の PropensityScore を再利用する版。 同じ X から
+-- ATE / ATT を複数バリアントで比べたい場合に有用。
+ipwWith :: PropensityScore -> LA.Vector Double -> LA.Vector Double -> IPWResult
+ipwWith ps t y =
+  let p     = psScores ps
+      one   = LA.scalar 1
+      wATE  = ipwWeights ps t
+      wATT  = attWeights ps t
+      -- ATE (Hajek 正規化): 各群の重み付き平均の差
+      --   μ̂_1 = Σ (T/p)·Y  /  Σ (T/p)
+      --   μ̂_0 = Σ ((1-T)/(1-p))·Y / Σ ((1-T)/(1-p))
+      w1     = t / p
+      w0     = (one - t) / (one - p)
+      mu1Hat = safeDiv (LA.sumElements (w1 * y)) (LA.sumElements w1)
+      mu0Hat = safeDiv (LA.sumElements (w0 * y)) (LA.sumElements w0)
+      ateHat = mu1Hat - mu0Hat
+      -- ATT (Hajek 正規化): treated 平均と、 p/(1-p) で再重み付けした control 平均の差
+      wt1    = t                       -- treated indicator
+      wt0    = (one - t) * (p / (one - p))
+      attMu1 = safeDiv (LA.sumElements (wt1 * y)) (LA.sumElements wt1)
+      attMu0 = safeDiv (LA.sumElements (wt0 * y)) (LA.sumElements wt0)
+      attHat = attMu1 - attMu0
+  in IPWResult
+       { ipwATE        = ateHat
+       , ipwATT        = attHat
+       , ipwWeightsATE = wATE
+       , ipwWeightsATT = wATT
+       , ipwPropensity = ps
+       }
+  where
+    safeDiv num den = if abs den < 1e-12 then 0 else num / den
diff --git a/src/Hanalyze/Stat/Causal/PropensityScore.hs b/src/Hanalyze/Stat/Causal/PropensityScore.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Stat/Causal/PropensityScore.hs
@@ -0,0 +1,89 @@
+-- |
+-- Module      : Hanalyze.Stat.Causal.PropensityScore
+-- Description : logistic regression による Propensity Score P(T=1|X) の推定
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Propensity Score の推定 (Phase 30-A1)。
+--
+-- @p_i = P(T = 1 | X_i)@ を logistic regression (GLM Binomial+Logit) で
+-- 推定する。 観測研究での因果効果推定 (IPW / AIPW / CATE) の前提となる
+-- 共変量バランス指標。
+--
+-- ## 使い方
+--
+-- @
+--   let ps = propensityScore xConf treat
+--       ps' = trimPropensity 0.01 0.99 ps   -- 重み発散防止
+--       w   = ipwWeights ps' treat          -- t/p + (1-t)/(1-p)
+-- @
+--
+-- Reference:
+--   Rosenbaum & Rubin (1983) "The Central Role of the Propensity Score in
+--   Observational Studies for Causal Effects". Biometrika 70:41-55.
+module Hanalyze.Stat.Causal.PropensityScore
+  ( PropensityScore (..)
+  , propensityScore
+  , trimPropensity
+  , ipwWeights
+  , attWeights
+  ) where
+
+import qualified Numeric.LinearAlgebra as LA
+import qualified Hanalyze.Model.GLM   as GLM
+import           Hanalyze.Model.Core   (coefficientsV, fittedV)
+
+-- ---------------------------------------------------------------------------
+-- 型
+-- ---------------------------------------------------------------------------
+
+data PropensityScore = PropensityScore
+  { psScores :: !(LA.Vector Double)  -- ^ @p_i = P(T=1|X_i)@、 長さ @n@
+  , psBeta   :: !(LA.Vector Double)  -- ^ logistic coefficients
+  , psN      :: !Int                 -- ^ サンプル数
+  } deriving (Show)
+
+-- ---------------------------------------------------------------------------
+-- 推定
+-- ---------------------------------------------------------------------------
+
+-- | 共変量行列 @X@ (intercept 列は呼び出し側で付加) と二値処置 @T ∈ {0,1}@
+-- から logistic regression で傾向スコアを推定。
+--
+-- @X@ は @n × p@、 @T@ は長さ @n@ の 0/1 vector。 intercept が欲しい場合は
+-- @1@ 列を先頭に prepend して渡す。
+propensityScore :: LA.Matrix Double -> LA.Vector Double -> PropensityScore
+propensityScore x t =
+  let (fit, _) = GLM.fitGLMFull GLM.Binomial GLM.Logit x t
+  in PropensityScore
+       { psScores = fittedV fit
+       , psBeta   = coefficientsV fit
+       , psN      = LA.size t
+       }
+
+-- | @[lo, hi]@ に clip。 @p_i@ が 0 / 1 に張り付くと IPW 重みが発散する
+-- ので必須。 推奨値: @lo = 0.01@, @hi = 0.99@。
+trimPropensity :: Double -> Double -> PropensityScore -> PropensityScore
+trimPropensity lo hi ps =
+  ps { psScores = LA.cmap (clamp lo hi) (psScores ps) }
+  where
+    clamp a b v = max a (min b v)
+
+-- ---------------------------------------------------------------------------
+-- 重み (hmatrix Vector 演算)
+-- ---------------------------------------------------------------------------
+
+-- | ATE 用の Horvitz-Thompson 重み: @w_i = t_i/p_i + (1-t_i)/(1-p_i)@
+ipwWeights :: PropensityScore -> LA.Vector Double -> LA.Vector Double
+ipwWeights ps t =
+  let p   = psScores ps
+      one = LA.scalar 1
+  in t / p + (one - t) / (one - p)
+
+-- | ATT 用の重み: @w_i = t_i + (1-t_i) · p_i/(1-p_i)@
+-- (treated は重み 1、 control は odds ratio で再重み付け)
+attWeights :: PropensityScore -> LA.Vector Double -> LA.Vector Double
+attWeights ps t =
+  let p   = psScores ps
+      one = LA.scalar 1
+  in t + (one - t) * p / (one - p)
diff --git a/src/Hanalyze/Stat/Cholesky.hs b/src/Hanalyze/Stat/Cholesky.hs
--- a/src/Hanalyze/Stat/Cholesky.hs
+++ b/src/Hanalyze/Stat/Cholesky.hs
@@ -1,5 +1,11 @@
 {-# LANGUAGE StrictData #-}
--- | Cholesky-based linear solver for symmetric positive-definite (SPD)
+-- |
+-- Module      : Hanalyze.Stat.Cholesky
+-- Description : 対称正定値 (SPD) 系向け Cholesky 分解ベースの線形ソルバ
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Cholesky-based linear solver for symmetric positive-definite (SPD)
 -- systems.
 --
 -- Replaces the generic least-squares solve @LA.\<\\\>@ in code paths
diff --git a/src/Hanalyze/Stat/ClassMetrics.hs b/src/Hanalyze/Stat/ClassMetrics.hs
--- a/src/Hanalyze/Stat/ClassMetrics.hs
+++ b/src/Hanalyze/Stat/ClassMetrics.hs
@@ -1,5 +1,11 @@
 {-# LANGUAGE OverloadedStrings #-}
--- | Classification model evaluation metrics.
+-- |
+-- Module      : Hanalyze.Stat.ClassMetrics
+-- Description : 分類モデル評価指標 (混同行列・ROC/AUC・PR 曲線・logLoss 等)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Classification model evaluation metrics.
 --
 -- Two families:
 --
diff --git a/src/Hanalyze/Stat/CorrelationNetwork.hs b/src/Hanalyze/Stat/CorrelationNetwork.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Stat/CorrelationNetwork.hs
@@ -0,0 +1,240 @@
+-- |
+-- Module      : Hanalyze.Stat.CorrelationNetwork
+-- Description : Graphical Lasso による sparse precision matrix 推定 (相関ネットワーク)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Correlation Network via Graphical Lasso (Phase 32-A1)。
+--
+-- 高次元データの相関構造を sparse precision matrix @Θ = Σ^{-1}@ で
+-- 表現する。 「ゼロ要素 ↔ 条件付き独立」 の対応で変数間ネットワークを
+-- 推定する。 scikit-learn `GraphicalLasso`、 R `glasso` 相当。
+--
+-- ## 最適化
+--
+-- @
+--   max_{Θ ≻ 0}  log det Θ - tr(SΘ) - λ ‖Θ‖_{1,off}
+-- @
+--
+-- ここで @S@ は経験共分散行列、 @λ@ は L1 罰則。 対角は罰しない (FHT 2008
+-- 慣例)。
+--
+-- ## アルゴリズム (Friedman-Hastie-Tibshirani 2008、 block CD)
+--
+-- 1. @Σ ← S + λI@ で初期化 (対角に λ shrinkage)
+-- 2. 各列 @j@ について部分問題:
+--    - @W_{11}@ = @Σ@ の row j / col j を除いた部分 (p-1 × p-1)
+--    - @s_{12}@ = @S@ の列 j (行 j を除く)
+--    - 内部 Lasso: @argmin_β (1/2) β^T W_{11} β - s_{12}^T β + λ |β|_1@
+--    - @Σ_{:j} = W_{11} β@ で列を更新 (対角は @S_{jj} + λ@)
+-- 3. @Σ@ が収束するまで全列 sweep を反復
+-- 4. @Θ = Σ^{-1}@ を計算
+--
+-- Reference:
+--   Friedman, Hastie, Tibshirani (2008) "Sparse inverse covariance
+--   estimation with the graphical lasso". Biostatistics 9(3):432-441.
+module Hanalyze.Stat.CorrelationNetwork
+  ( GLassoFit (..)
+  , graphicalLasso
+  , graphicalLassoFromCov
+  , empiricalCov
+  , nonZeroPrecision
+    -- * Pearson 相関ネットワーク (Phase 77・df|-> correlationOf 用)
+  , correlationMatrix
+  , CorrelationGraph (..)
+  ) where
+
+import           Data.Text             (Text)
+import qualified Numeric.LinearAlgebra as LA
+
+-- ---------------------------------------------------------------------------
+-- 型
+-- ---------------------------------------------------------------------------
+
+data GLassoFit = GLassoFit
+  { glPrecision  :: !(LA.Matrix Double)   -- ^ 推定された Θ (precision)
+  , glCovariance :: !(LA.Matrix Double)   -- ^ 推定された Σ = Θ⁻¹
+  , glIterations :: !Int                   -- ^ 外側 sweep の反復数
+  , glConverged  :: !Bool                  -- ^ tol 内収束したか
+  , glLambda     :: !Double                -- ^ 使用した λ
+  } deriving (Show)
+
+-- ---------------------------------------------------------------------------
+-- API
+-- ---------------------------------------------------------------------------
+
+-- | 経験共分散行列 (= 中央化 + scale 1/(n-1))。
+empiricalCov :: LA.Matrix Double -> LA.Matrix Double
+empiricalCov x =
+  let n    = LA.rows x
+      ones = LA.konst 1 n :: LA.Vector Double
+      mu   = LA.scale (1 / fromIntegral n) (LA.tr x LA.#> ones)
+      xc   = x - LA.asRow mu
+      m    = max 1 (n - 1)
+  in LA.scale (1 / fromIntegral m) (LA.tr xc LA.<> xc)
+
+-- | Pearson 相関行列 (@X@ n×p → p×p)。 'empiricalCov' を対角の標準偏差で正規化する
+--   (@r_ij = Σ_ij / (σ_i σ_j)@)。 分散 0 の列は 0 除算回避で 0 相関扱い。
+correlationMatrix :: LA.Matrix Double -> LA.Matrix Double
+correlationMatrix x =
+  let cov  = empiricalCov x
+      p    = LA.rows cov
+      sds  = [ sqrt (cov `LA.atIndex` (i, i)) | i <- [0 .. p - 1] ]
+      dInv = LA.diag (LA.fromList [ if s > 1e-12 then 1 / s else 0 | s <- sds ])
+  in dInv LA.<> cov LA.<> dInv
+
+-- | 相関ネットワーク (Pearson 相関 + 閾値) の結果 (Phase 77・@df |-> correlationOf thr cols@)。
+--   'Plottable' (@Hanalyze.Plot.ML@) が @|r| > cgThreshold@ の対を辺にしたグラフを描く
+--   (無向・向きは便宜上の配置。 因果でない)。 LiNGAM DAG と対比すると間接相関の過剰さが分かる。
+data CorrelationGraph = CorrelationGraph
+  { cgCorr      :: !(LA.Matrix Double)   -- ^ p × p Pearson 相関行列
+  , cgNames     :: ![Text]               -- ^ 変数名 (列順)
+  , cgThreshold :: !Double               -- ^ |r| > この値で辺を張る
+  } deriving (Show)
+
+-- | データ行列 @X@ (n × p) から graphical Lasso 推定。 内部で
+-- 'empiricalCov' を計算してから 'graphicalLassoFromCov' を呼ぶ。
+graphicalLasso
+  :: LA.Matrix Double      -- ^ X (n × p)
+  -> Double                -- ^ λ
+  -> Int                   -- ^ max outer sweeps (推奨 100)
+  -> Double                -- ^ tolerance (推奨 1e-4)
+  -> GLassoFit
+graphicalLasso x lambda maxOuter tol =
+  graphicalLassoFromCov (empiricalCov x) lambda maxOuter tol
+
+-- | 経験共分散行列から直接推定 (= 既に共分散を持っているとき向け)。
+graphicalLassoFromCov
+  :: LA.Matrix Double      -- ^ S (p × p)
+  -> Double                -- ^ λ
+  -> Int -> Double
+  -> GLassoFit
+graphicalLassoFromCov s lambda maxOuter tol =
+  let p = LA.rows s
+      -- 初期化: Σ = S + λI (対角 shrinkage)
+      sigma0 = s + LA.scale lambda (LA.ident p)
+      -- 外側 sweep
+      sweep sigma =
+        foldl
+          (\sigCur j -> updateColumn sigCur s lambda j)
+          sigma
+          [0 .. p - 1]
+      loop !k !sigma
+        | k >= maxOuter = (sigma, k, False)
+        | otherwise     =
+            let sigmaN = sweep sigma
+                d      = LA.maxElement (LA.cmap abs (sigmaN - sigma))
+            in if d < tol
+                 then (sigmaN, k + 1, True)
+                 else loop (k + 1) sigmaN
+      (sigmaFinal, iters, conv) = loop 0 sigma0
+      -- 対角を S + λ にリセット (FHT 慣例)
+      sigmaDiag = setDiag sigmaFinal (LA.takeDiag s + LA.konst lambda p)
+      theta     = LA.inv sigmaDiag
+  in GLassoFit
+       { glPrecision  = theta
+       , glCovariance = sigmaDiag
+       , glIterations = iters
+       , glConverged  = conv
+       , glLambda     = lambda
+       }
+
+-- | 1 列の更新: 内部 Lasso を解いて @Σ@ の j 列 / j 行を上書き。
+updateColumn :: LA.Matrix Double -> LA.Matrix Double -> Double -> Int
+             -> LA.Matrix Double
+updateColumn sigma s lambda j =
+  let p   = LA.rows sigma
+      ids = [i | i <- [0 .. p - 1], i /= j]
+      w11 = sigma LA.? ids LA.¿ ids
+      s12 = LA.fromList [LA.atIndex s (i, j) | i <- ids]
+      beta = innerLassoQuad w11 s12 lambda 200 1e-5
+      newCol = w11 LA.#> beta
+      sigma' = updateOffDiagColumn sigma j ids (LA.toList newCol)
+  in sigma'
+
+-- | 内部 Lasso (quadratic form):
+-- @argmin_β (1/2) β^T W β - s^T β + λ |β|_1@
+-- coord update: @β_k ← S(s_k - Σ_{l≠k} W_{kl} β_l, λ) / W_{kk}@。
+innerLassoQuad
+  :: LA.Matrix Double -> LA.Vector Double -> Double -> Int -> Double
+  -> LA.Vector Double
+innerLassoQuad w sVec lambda maxIter tol =
+  let m  = LA.size sVec
+      diagW = LA.takeDiag w
+      sweep beta =
+        foldl
+          (\(bAcc, mDelta) k ->
+              let wkk = LA.atIndex diagW k
+                  wRow = LA.flatten (w LA.? [k])
+                  pred_k = wRow LA.<.> bAcc - wkk * LA.atIndex bAcc k
+                  rho = LA.atIndex sVec k - pred_k
+                  bk' = if wkk <= 0
+                          then 0
+                          else softT rho lambda / wkk
+                  bk  = LA.atIndex bAcc k
+                  d   = abs (bk' - bk)
+                  bAcc' = updateAt bAcc k bk'
+              in (bAcc', max mDelta d))
+          (beta, 0)
+          [0 .. m - 1]
+      loop !k !beta
+        | k >= maxIter = beta
+        | otherwise    =
+            let (betaN, d) = sweep beta
+            in if d < tol
+                 then betaN
+                 else loop (k + 1) betaN
+  in loop 0 (LA.konst 0 m)
+
+-- ---------------------------------------------------------------------------
+-- ヘルパ
+-- ---------------------------------------------------------------------------
+
+softT :: Double -> Double -> Double
+softT z g
+  | z > g     = z - g
+  | z < -g    = z + g
+  | otherwise = 0
+
+setDiag :: LA.Matrix Double -> LA.Vector Double -> LA.Matrix Double
+setDiag m d =
+  let p = LA.rows m
+      xs = LA.toLists m
+      ds = LA.toList d
+      rewrite (i, row) =
+        [ if i == j then ds !! i else (xs !! i) !! j | j <- [0 .. p - 1] ]
+  in LA.fromLists [rewrite (i, xs !! i) | i <- [0 .. p - 1]]
+
+updateAt :: LA.Vector Double -> Int -> Double -> LA.Vector Double
+updateAt v i nv =
+  LA.fromList [ if k == i then nv else LA.atIndex v k
+              | k <- [0 .. LA.size v - 1] ]
+
+-- | Σ の列 j / 行 j を新値で上書き (対角は触らない、 残り対角は別 step で
+-- 設定)。 @ids@ は j を除いた行 index、 @vals@ は @ids@ 順の長さ p-1。
+updateOffDiagColumn
+  :: LA.Matrix Double -> Int -> [Int] -> [Double] -> LA.Matrix Double
+updateOffDiagColumn sigma j ids vals =
+  let p   = LA.rows sigma
+      pairs = zip ids vals
+      lookupV i = case lookup i pairs of
+        Just v -> v
+        Nothing -> 0
+      rows = LA.toLists sigma
+      newRow i
+        | i == j    = [ if k == j then (rows !! i) !! k else lookupV k
+                      | k <- [0 .. p - 1] ]
+        | otherwise = [ if k == j then lookupV i
+                                  else (rows !! i) !! k
+                      | k <- [0 .. p - 1] ]
+  in LA.fromLists [newRow i | i <- [0 .. p - 1]]
+
+-- | precision matrix の非零要素数 (対角を除く上三角)。 @threshold@ で
+-- 「ゼロ」 とみなす絶対値の閾値を指定。
+nonZeroPrecision :: Double -> LA.Matrix Double -> Int
+nonZeroPrecision threshold theta =
+  let p = LA.rows theta
+  in length [ ()
+            | i <- [0 .. p - 1]
+            , j <- [i + 1 .. p - 1]
+            , abs (LA.atIndex theta (i, j)) > threshold ]
diff --git a/src/Hanalyze/Stat/Descriptive.hs b/src/Hanalyze/Stat/Descriptive.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Stat/Descriptive.hs
@@ -0,0 +1,149 @@
+{-# LANGUAGE FlexibleContexts  #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+-- |
+-- Module      : Hanalyze.Stat.Descriptive
+-- Description : 一次元記述統計 (mean/quantile/variance 等) の単一の正 (single source of truth)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- 一次元の記述統計 (descriptive statistics) の公開 API。
+--
+-- hanalyze の記述統計の **単一の正 (single source of truth)**。 従来は
+-- @mean@ / @median@ / @quantile@ / @variance@ が 'Stat.GroupComparison' /
+-- 'Stat.ModelSelect' / 'Stat.Effect' / 'Model.Quantile' / 'Stat.Bootstrap' 等に
+-- 私的 helper として散在 (シグネチャ @[Double]@ / @[Int]@ / @LA.Vector@ 混在・
+-- ほぼ未 export) していたのを、 ここに集約する (Phase 65)。
+--
+-- === 正準型 = 'Data.Vector.Generic.Vector' v Double
+-- 'statistics' パッケージ自身と同じく @G.Vector v Double@ で多相。 これにより
+-- Storable (= hmatrix @LA.Vector@)・Unboxed・boxed (@V.Vector@・DataFrame 列) の
+-- いずれも **ゼロ変換**で渡せる (速度経路は list 化を挟まない)。 素の @[Double]@
+-- 利用には末尾の @*L@ wrapper を用意する。
+--
+-- === 実装方針
+-- @mean@ / @variance@ (n-1) / @sd@ は 'Statistics.Sample' を再利用。 @quantile@ は
+-- R 既定の **type-7** (線形補間) を自前実装し R 一致を保証する (@median@ / @iqr@ /
+-- @percentile@ はこれを呼ぶ)。 ソートは 'Data.Vector.Algorithms.Intro'。
+--
+-- === NA
+-- 本モジュールは NA を扱わない (total・純粋)。 R の @na.rm = TRUE@ 相当は呼び手が
+-- @mapMaybe id@ で除去してから 'Data.Vector.Generic.fromList' する。
+module Hanalyze.Stat.Descriptive
+  ( -- * 中心
+    mean, median
+    -- * 位置
+  , quantile, percentile, minimum', maximum'
+    -- * 散布
+  , variance, sd, iqr, range'
+    -- * [Double] 便宜 wrapper
+  , meanL, medianL, quantileL, sdL, varianceL, iqrL
+  ) where
+
+import qualified Data.Vector.Generic            as G
+import qualified Data.Vector.Storable           as VS
+import qualified Data.Vector.Algorithms.Intro   as Intro
+import qualified Statistics.Sample              as S
+
+-- ===========================================================================
+-- 中心
+-- ===========================================================================
+
+-- | 算術平均。 空なら NaN (R @mean(numeric(0))@)。
+mean :: G.Vector v Double => v Double -> Double
+mean v | G.null v  = nan
+       | otherwise = S.mean v
+{-# INLINE mean #-}
+
+-- | 中央値 (= type-7 の 0.5 分位点・偶数長は中央 2 点の平均)。
+median :: G.Vector v Double => v Double -> Double
+median = quantile 0.5
+{-# INLINE median #-}
+
+-- ===========================================================================
+-- 位置 (分位点は R 既定 type-7)
+-- ===========================================================================
+
+-- | R 既定 (type-7) の分位点。 確率を第 1 引数に取る (@quantile 0.95 v@)。
+--
+-- ソート済 0-index 列 @x[0..n-1]@・@h = (n-1) p@ として
+-- @x[⌊h⌋] + (h - ⌊h⌋)(x[⌊h⌋+1] - x[⌊h⌋])@。 空なら NaN。
+quantile :: G.Vector v Double => Double -> v Double -> Double
+quantile p v
+  | n == 0    = nan
+  | n == 1    = G.head v
+  | otherwise =
+      let sorted = G.modify Intro.sort v
+          h      = fromIntegral (n - 1) * p
+          lo     = floor h
+          lo'    = max 0 (min (n - 1) lo)
+          hi'    = min (n - 1) (lo' + 1)
+          frac   = h - fromIntegral lo'
+          xlo    = G.unsafeIndex sorted lo'
+          xhi    = G.unsafeIndex sorted hi'
+      in xlo + frac * (xhi - xlo)
+  where n = G.length v
+
+-- | パーセンタイル (= @quantile (p/100)@)。
+percentile :: G.Vector v Double => Double -> v Double -> Double
+percentile p = quantile (p / 100)
+{-# INLINE percentile #-}
+
+-- | 最小値 (空なら NaN)。
+minimum' :: G.Vector v Double => v Double -> Double
+minimum' v | G.null v  = nan
+           | otherwise = G.minimum v
+{-# INLINE minimum' #-}
+
+-- | 最大値 (空なら NaN)。
+maximum' :: G.Vector v Double => v Double -> Double
+maximum' v | G.null v  = nan
+           | otherwise = G.maximum v
+{-# INLINE maximum' #-}
+
+-- ===========================================================================
+-- 散布
+-- ===========================================================================
+
+-- | 標本分散 (n-1 で割る・R @var()@)。 n<2 なら NaN。
+variance :: G.Vector v Double => v Double -> Double
+variance v | G.length v < 2 = nan
+           | otherwise       = S.varianceUnbiased v
+{-# INLINE variance #-}
+
+-- | 標準偏差 (= sqrt . variance・R @sd()@)。
+sd :: G.Vector v Double => v Double -> Double
+sd v | G.length v < 2 = nan
+     | otherwise       = S.stdDev v
+{-# INLINE sd #-}
+
+-- | 四分位範囲 (= type-7 の 0.75 分位点 - 0.25 分位点・R @IQR()@)。
+iqr :: G.Vector v Double => v Double -> Double
+iqr v = quantile 0.75 v - quantile 0.25 v
+{-# INLINE iqr #-}
+
+-- | 範囲 (= 最大 - 最小)。
+range' :: G.Vector v Double => v Double -> Double
+range' v = maximum' v - minimum' v
+{-# INLINE range' #-}
+
+-- ===========================================================================
+-- [Double] 便宜 wrapper (= f . VS.fromList)
+-- ===========================================================================
+
+meanL     :: [Double] -> Double
+meanL      = mean     . VS.fromList
+medianL   :: [Double] -> Double
+medianL    = median   . VS.fromList
+quantileL :: Double -> [Double] -> Double
+quantileL p = quantile p . VS.fromList
+sdL       :: [Double] -> Double
+sdL        = sd       . VS.fromList
+varianceL :: [Double] -> Double
+varianceL  = variance . VS.fromList
+iqrL      :: [Double] -> Double
+iqrL       = iqr      . VS.fromList
+
+-- ===========================================================================
+
+nan :: Double
+nan = 0 / 0
diff --git a/src/Hanalyze/Stat/Distribution.hs b/src/Hanalyze/Stat/Distribution.hs
--- a/src/Hanalyze/Stat/Distribution.hs
+++ b/src/Hanalyze/Stat/Distribution.hs
@@ -1,5 +1,11 @@
 {-# LANGUAGE OverloadedStrings #-}
--- | Probability distributions used throughout the library.
+-- |
+-- Module      : Hanalyze.Stat.Distribution
+-- Description : ライブラリ全体で使う確率分布 27 種と HMC/NUTS 用の制約変換
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Probability distributions used throughout the library.
 --
 -- Provides 27 named distributions (Normal, Beta, Gamma, StudentT, LKJ,
 -- Truncated, Censored, ...) with @density@ / @logDensity@ / @supportRange@
diff --git a/src/Hanalyze/Stat/Effect.hs b/src/Hanalyze/Stat/Effect.hs
--- a/src/Hanalyze/Stat/Effect.hs
+++ b/src/Hanalyze/Stat/Effect.hs
@@ -1,6 +1,12 @@
 {-# LANGUAGE OverloadedStrings #-}
--- | Effect sizes and power analysis.
+-- |
+-- Module      : Hanalyze.Stat.Effect
+-- Description : 効果量 (Cohen's d 等) と検出力分析
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
 --
+-- Effect sizes and power analysis.
+--
 -- Effect-size measures complement p-values by quantifying the
 -- magnitude of an effect, not just its statistical significance.
 -- Power analysis lets the user pick sample sizes a priori or assess
@@ -22,11 +28,13 @@
 module Hanalyze.Stat.Effect
   ( -- * Effect-size measures (location)
     cohenD
+  , cohenDCI
   , cohenDPaired
   , hedgesG
     -- * Effect-size (ANOVA / regression)
   , cohensF
   , eta2
+  , eta2CI
   , omega2
     -- * Effect-size (categorical)
   , cramerV
@@ -65,6 +73,23 @@
       pooledV = ((n1 - 1) * v1 + (n2 - 1) * v2) / (n1 + n2 - 2)
   in if pooledV <= 0 then 0 else (m1 - m2) / sqrt pooledV
 
+-- | Cohen's d with (1-α) confidence interval (Hedges-Olkin SE approximation).
+--
+-- > SE(d) ≈ √( (n1+n2)/(n1·n2) + d² / (2(n1+n2)) )
+-- > CI    = d ± z_{1-α/2} · SE(d)
+--
+-- 厳密な非中心 t 分布の逆変換ではないが、 サンプルサイズ ≥ 20 程度で
+-- 十分実用的 (Cumming 2012)。
+cohenDCI :: LA.Vector Double -> LA.Vector Double -> Double
+         -> (Double, (Double, Double))
+cohenDCI xs ys alpha =
+  let d   = cohenD xs ys
+      n1  = fromIntegral (LA.size xs) :: Double
+      n2  = fromIntegral (LA.size ys) :: Double
+      se  = sqrt ((n1 + n2) / (n1 * n2) + d * d / (2 * (n1 + n2)))
+      z   = SD.quantile Normal.standard (1 - alpha / 2)
+  in (d, (d - z * se, d + z * se))
+
 -- | Cohen's d for paired samples (uses SD of differences).
 cohenDPaired :: LA.Vector Double -> LA.Vector Double -> Double
 cohenDPaired xs ys =
@@ -107,6 +132,33 @@
           ssT   = sum [ LA.sumElements ((g - LA.scalar grand)^(2::Int))
                       | g <- groups ]
       in if ssT <= 0 then 0 else ssB / ssT
+
+-- | η² with (1-α) confidence interval from F-statistic + df via the
+--   noncentrality parameter inversion.
+--
+--   F-statistic, df_between, df_within を入力に取り、 η² の (lo, hi) CI を
+--   返す。 信頼区間は noncentrality parameter λ の (lo, hi) を二分探索で
+--   求め、 そこから η² = λ / (λ + df_total + 1) に変換する近似版。
+--
+--   既存 @anovaOneWay@ 等で得た F 値を入れて使う。
+eta2CI :: Double            -- ^ F statistic
+       -> (Int, Int)        -- ^ (df_between, df_within)
+       -> Double            -- ^ α (例: 0.05)
+       -> (Double, (Double, Double))
+eta2CI fStat (dfB, dfW) alpha =
+  let dfBd = fromIntegral dfB :: Double
+      dfWd = fromIntegral dfW :: Double
+      dfTotal = dfBd + dfWd + 1
+      eta = (fStat * dfBd) / (fStat * dfBd + dfWd)
+      -- noncentrality parameter from observed F (point estimate)
+      lambdaHat = max 0 (fStat * dfBd - dfBd)
+      -- crude symmetric CI on λ via Patnaik / Helmert approximation:
+      seL = sqrt (2 * (2 * lambdaHat + dfBd + dfWd))
+      z   = SD.quantile Normal.standard (1 - alpha / 2)
+      lamLo = max 0 (lambdaHat - z * seL)
+      lamHi = max 0 (lambdaHat + z * seL)
+      toEta l = l / (l + dfTotal)
+  in (eta, (toEta lamLo, toEta lamHi))
 
 -- | ω² (omega-squared): unbiased version of η².
 -- @ω² = (SS_between − (k − 1) × MS_within) / (SS_total + MS_within)@.
diff --git a/src/Hanalyze/Stat/GroupComparison.hs b/src/Hanalyze/Stat/GroupComparison.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Stat/GroupComparison.hs
@@ -0,0 +1,119 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE BangPatterns #-}
+-- |
+-- Module      : Hanalyze.Stat.GroupComparison
+-- Description : 2 群間の多変量比較ランキング (Spotfire 風 "Good vs Bad")
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- 2 群間の多変量比較ランキング (Spotfire 風 "Good vs Bad")。
+--
+-- 「良品 vs 不良品」 を二値ラベルで分け、 各説明変数について
+-- (i) 平均差、 (ii) Cohen's d 効果量、 (iii) Welch t-test p 値 を計算し、
+-- 効果量の絶対値降順にランク付けして返す。 半導体品質解析等で頻出。
+--
+-- 単独検定ではなく **複数変数の並列比較に最適化**された helper。
+-- 多重比較補正は呼び出し側で `Hanalyze.Stat.MultipleTesting` を使う。
+module Hanalyze.Stat.GroupComparison
+  ( -- * 結果型
+    GroupCompResult (..)
+    -- * 比較
+  , goodVsBad
+  ) where
+
+import qualified Data.Vector           as V
+import qualified Numeric.LinearAlgebra as LA
+import           Data.List             (sortBy)
+import           Data.Ord              (comparing, Down (..))
+import           Data.Text             (Text)
+import           Data.Vector           (Vector)
+
+import qualified Hanalyze.Stat.Test    as ST
+import qualified Hanalyze.Stat.Effect  as Eff
+
+-- ===========================================================================
+-- 型
+-- ===========================================================================
+
+-- | 1 変数の Good vs Bad 比較結果。
+data GroupCompResult = GroupCompResult
+  { gcrVarName  :: !Text     -- ^ 変数名
+  , gcrMeanG    :: !Double   -- ^ Good 群 (label = True) の平均
+  , gcrMeanB    :: !Double   -- ^ Bad  群 (label = False) の平均
+  , gcrMeanDiff :: !Double   -- ^ Mean(Bad) − Mean(Good)
+  , gcrEffect   :: !Double   -- ^ Cohen's d (signed; |gcrEffect| でランク)
+  , gcrPValue   :: !Double   -- ^ Welch's two-sided t-test の p 値
+  , gcrNG       :: !Int      -- ^ Good 群サイズ
+  , gcrNB       :: !Int      -- ^ Bad  群サイズ
+  } deriving (Show, Eq)
+
+-- ===========================================================================
+-- 公開関数
+-- ===========================================================================
+
+-- | 各説明変数について 2 群間の差を計算し、 効果量絶対値降順でランク付け。
+--
+-- 入力契約:
+--
+--   * 変数リストは非空 (1 変数以上)
+--   * 各変数の Vector 長 = labels の長さ (一致しないと 'Left')
+--   * 両群とも 2 個以上の観測必須 (Welch t-test の前提)
+goodVsBad
+  :: [(Text, Vector Double)]   -- ^ (変数名, 値ベクトル) のリスト
+  -> Vector Bool               -- ^ 群ラベル (True = Good、 False = Bad)
+  -> Either Text [GroupCompResult]
+goodVsBad vars labels
+  | null vars               = Left "goodVsBad: empty variable list"
+  | V.null labels           = Left "goodVsBad: empty labels"
+  | any (\(_, v) -> V.length v /= V.length labels) vars
+                            = Left "goodVsBad: variable length mismatch with labels"
+  | nG < 2 || nB < 2        = Left "goodVsBad: each group needs at least 2 observations"
+  | otherwise =
+      let results = map (compareOne labels) vars
+      in Right (sortBy (comparing (Down . absEffect)) results)
+  where
+    nG = V.length (V.filter id labels)
+    nB = V.length labels - nG
+    absEffect = abs . gcrEffect
+
+-- ---------------------------------------------------------------------------
+-- 1 変数の比較
+-- ---------------------------------------------------------------------------
+
+compareOne :: Vector Bool -> (Text, Vector Double) -> GroupCompResult
+compareOne labels (name, vals) =
+  let (goodList, badList) = partitionByLabels labels vals
+      gVec = LA.fromList goodList
+      bVec = LA.fromList badList
+      tr   = ST.tTestWelch gVec bVec ST.TwoSided
+      pVal = ST.trPValue tr
+      d    = Eff.cohenD bVec gVec   -- Mean(Bad) − Mean(Good) 方向
+      mG   = mean goodList
+      mB   = mean badList
+  in GroupCompResult
+       { gcrVarName  = name
+       , gcrMeanG    = mG
+       , gcrMeanB    = mB
+       , gcrMeanDiff = mB - mG
+       , gcrEffect   = d
+       , gcrPValue   = pVal
+       , gcrNG       = length goodList
+       , gcrNB       = length badList
+       }
+
+-- | label が True の要素を good、 False を bad として分割。
+partitionByLabels :: Vector Bool -> Vector Double -> ([Double], [Double])
+partitionByLabels labels vals = go 0 ([], [])
+  where
+    n = V.length vals
+    go !i (gs, bs)
+      | i >= n = (reverse gs, reverse bs)
+      | otherwise =
+          let v = vals V.! i
+              l = labels V.! i
+          in if l then go (i + 1) (v : gs, bs)
+                  else go (i + 1) (gs, v : bs)
+
+mean :: [Double] -> Double
+mean [] = 0
+mean xs = sum xs / fromIntegral (length xs)
diff --git a/src/Hanalyze/Stat/Interpolate.hs b/src/Hanalyze/Stat/Interpolate.hs
--- a/src/Hanalyze/Stat/Interpolate.hs
+++ b/src/Hanalyze/Stat/Interpolate.hs
@@ -1,4 +1,10 @@
--- | One-dimensional interpolation (Linear / natural cubic spline / PCHIP).
+-- |
+-- Module      : Hanalyze.Stat.Interpolate
+-- Description : 一次元補間 (線形 / 自然三次スプライン / PCHIP)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- One-dimensional interpolation (Linear / natural cubic spline / PCHIP).
 --
 -- Builds a continuous @Double -> Double@ function from observed points
 -- @[(x_i, y_i)]@ (sorted ascending, distinct in x). Out-of-range queries
diff --git a/src/Hanalyze/Stat/Interpret.hs b/src/Hanalyze/Stat/Interpret.hs
--- a/src/Hanalyze/Stat/Interpret.hs
+++ b/src/Hanalyze/Stat/Interpret.hs
@@ -1,5 +1,11 @@
 {-# LANGUAGE OverloadedStrings #-}
--- | Model interpretability tools.
+-- |
+-- Module      : Hanalyze.Stat.Interpret
+-- Description : モデル解釈ツール (permutation importance / partial dependence / ICE)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Model interpretability tools.
 --
 -- Model-agnostic explanations of predictions:
 --
diff --git a/src/Hanalyze/Stat/KernelDist.hs b/src/Hanalyze/Stat/KernelDist.hs
--- a/src/Hanalyze/Stat/KernelDist.hs
+++ b/src/Hanalyze/Stat/KernelDist.hs
@@ -1,5 +1,11 @@
 {-# LANGUAGE StrictData #-}
--- | BLAS-backed pairwise distance helpers.
+-- |
+-- Module      : Hanalyze.Stat.KernelDist
+-- Description : BLAS を使った行列間ペアワイズ距離の高速計算
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- BLAS-backed pairwise distance helpers.
 --
 -- Computes the @n × n@ (or @m × n@) matrix of squared Euclidean
 -- distances between rows of input matrices via the identity
diff --git a/src/Hanalyze/Stat/MCMC.hs b/src/Hanalyze/Stat/MCMC.hs
--- a/src/Hanalyze/Stat/MCMC.hs
+++ b/src/Hanalyze/Stat/MCMC.hs
@@ -1,6 +1,12 @@
 {-# LANGUAGE OverloadedStrings #-}
--- | Pure post-processing utilities for MCMC chains.
+-- |
+-- Module      : Hanalyze.Stat.MCMC
+-- Description : MCMC チェーンの純粋な後処理 (自己相関・HDI・ESS・R-hat・KDE・BFMI)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
 --
+-- Pure post-processing utilities for MCMC chains.
+--
 -- Provides autocorrelation, highest-density intervals (HDI), effective
 -- sample size (Geyer's initial monotone sequence estimator), split-R-hat
 -- (Vehtari et al. 2021), kernel density estimation (Silverman bandwidth)
@@ -10,14 +16,23 @@
   ( autocorr
   , hdi
   , ess
+  , essBulk
   , rhat
   , kde
   , bfmi
+  , rankHist
   ) where
 
-import Data.List (minimumBy, sort)
+import Control.Monad (when)
+import Control.Monad.ST (runST)
+import Data.Function (on)
+import Data.List (groupBy, minimumBy, sort, sortBy)
 import Data.Ord  (comparing)
 import qualified Data.Vector as V
+import qualified Data.Vector.Unboxed as VU
+import qualified Data.Vector.Unboxed.Mutable as VUM
+import qualified Statistics.Distribution as SD
+import Statistics.Distribution.Normal (standard)
 
 -- | Autocorrelation at lags 0 .. min(maxLag, n-1).
 -- Uses O(n × maxLag) time with Vector indexing.
@@ -69,6 +84,132 @@
     pairSums (a : b : rest) = (a + b) : pairSums rest
     pairSums _              = []
 
+-- | arviz / Stan 互換の rank-normalized **bulk ESS** (Vehtari et al. 2021)。
+--
+-- 引数は 'rhat' と同じ「パラメータ 1 つの chain ごとの sample 列」。手順は
+-- arviz の @ess(method="bulk")@ と同一:
+--
+-- 1. 各 chain を半分に split (奇数長は中央 1 点を落とす) して 2M 本の
+--    sub-chain にする
+-- 2. 全値プールの平均 rank (同値は平均) を
+--    @(r − 3\/8) \/ (S + 1\/4)@ で (0,1) に写し Φ⁻¹ で z 化 (rank 正規化)
+-- 3. 多 chain 結合自己相関 @ρ̂_t = 1 − (W − mean acov_t) \/ var⁺@ に
+--    Geyer の initial positive + monotone sequence を適用し
+--    @τ̂ = −1 + 2Σρ̂@ (下限 @1\/log₁₀(MN)@)、@ESS = MN \/ τ̂@
+--
+-- 単 chain の 'ess' (Geyer IMSE・τ 下限 1 クランプで n 頭打ち) と異なり
+-- 多 chain 情報と rank 正規化で裾の重い分布でも安定し、PyMC / arviz の
+-- @ess_bulk@ と数値比較できる (Phase 92 B4: bench の指標非対称の是正)。
+-- chain が短すぎるとき (split 後 4 draw 未満・arviz は NaN を返す領域) は
+-- フォールバックとして元の総 draw 数を返す。
+essBulk :: [[Double]] -> Double
+essBulk chains
+  | m < 1 || n < 4 = fromIntegral (sum (map length nonEmpty))  -- 元の総 draw 数
+  | otherwise      = essMultiChain (rankNormalize sub)
+  where
+    nonEmpty = filter (not . null) chains
+    -- arviz _split_chains: 前半 floor(n/2) + 後半 floor(n/2) (奇数長は中央落ち)
+    splitOne vs = let h = length vs `div` 2
+                  in [take h vs, drop (length vs - h) vs]
+    sub0 = concatMap splitOne nonEmpty
+    n    = if null sub0 then 0 else minimum (map length sub0)
+    sub  = map (take n) sub0
+    m    = length sub
+
+-- | rank 正規化 (arviz @_z_scale@): 全 chain プールの平均 rank →
+-- @(r − 3\/8)\/(S + 1\/4)@ → 標準正規の分位関数。chain 構造は保存する。
+rankNormalize :: [[Double]] -> [[Double]]
+rankNormalize chains = rechunk (map length chains) (map z ranks)
+  where
+    flat  = concat chains
+    s     = fromIntegral (length flat) :: Double
+    ranks = averageRanks flat
+    z r   = SD.quantile standard ((r - 3 / 8) / (s + 0.25))
+    rechunk []           _  = []
+    rechunk (len : lens) xs = let (h, t) = splitAt len xs in h : rechunk lens t
+
+-- | 同値を平均 rank (scipy @rankdata(method="average")@ 相当) にした
+-- 1-based rank を入力順で返す。
+averageRanks :: [Double] -> [Double]
+averageRanks xs = map snd (sortBy (comparing fst) ranked)
+  where
+    byVal  = sortBy (comparing snd) (zip [0 :: Int ..] xs)
+    groups = groupBy ((==) `on` snd) byVal
+    ranked = go 0 groups
+    go _ [] = []
+    go pos (g : gs) =
+      let k   = length g
+          -- ranks pos+1 .. pos+k の平均
+          avg = fromIntegral (2 * pos + k + 1) / 2 :: Double
+      in [ (i, avg) | (i, _) <- g ] ++ go (pos + k) gs
+
+-- | 多 chain 結合 ESS (arviz @_ess@ の忠実な移植)。入力 = z 化済み等長
+-- sub-chain 群。
+essMultiChain :: [[Double]] -> Double
+essMultiChain sub
+  | isNaN varPlus || varPlus <= 0 = sTotal
+  | otherwise = runST $ do
+      rhoT <- VUM.replicate n 0
+      VUM.write rhoT 0 1
+      let rho1 = rho 1
+      VUM.write rhoT 1 rho1
+      -- Geyer initial positive sequence (ペア和が正の間だけ採用)
+      let goPos t rhoEven rhoOdd
+            | t < n - 3 && rhoEven + rhoOdd > 0 = do
+                let re = rho (t + 1)
+                    ro = rho (t + 2)
+                when (re + ro >= 0) $ do
+                  VUM.write rhoT (t + 1) re
+                  VUM.write rhoT (t + 2) ro
+                goPos (t + 2) re ro
+            | otherwise = pure (t, rhoEven)
+      (tEnd, lastEven) <- goPos 1 1.0 rho1
+      let maxT = tEnd - 2
+      when (lastEven > 0 && maxT + 1 < n) $
+        VUM.write rhoT (maxT + 1) lastEven
+      -- Geyer initial monotone sequence (ペア和を非増加に均す)
+      let goMono t
+            | t <= maxT - 2 = do
+                a <- VUM.read rhoT (t - 1)
+                b <- VUM.read rhoT t
+                c <- VUM.read rhoT (t + 1)
+                d <- VUM.read rhoT (t + 2)
+                when (c + d > a + b) $ do
+                  VUM.write rhoT (t + 1) ((a + b) / 2)
+                  VUM.write rhoT (t + 2) ((a + b) / 2)
+                goMono (t + 2)
+            | otherwise = pure ()
+      goMono 1
+      frozen <- VU.unsafeFreeze rhoT
+      let tauRaw = -1 + 2 * VU.sum (VU.take (maxT + 1) frozen)
+                      + (if maxT + 1 < n then frozen VU.! (maxT + 1) else 0)
+          tau    = max tauRaw (1 / logBase 10 sTotal)
+      pure (sTotal / tau)
+  where
+    m      = length sub
+    n      = length (head sub)
+    sTotal = fromIntegral (m * n)
+    acovs  = map (autocovBiased . V.fromList) sub
+    chainMeans = map (\vs -> sum vs / fromIntegral n) sub
+    meanAcov t = sum (map (V.! t) acovs) / fromIntegral m
+    meanVar = meanAcov 0 * fromIntegral n / fromIntegral (n - 1)
+    varPlus = meanVar * fromIntegral (n - 1) / fromIntegral n
+            + (if m > 1 then sampleVar chainMeans else 0)
+    rho t   = 1 - (meanVar - meanAcov t) / varPlus
+    sampleVar vs =
+      let mu = sum vs / fromIntegral (length vs)
+      in sum [ (x - mu) ^ (2 :: Int) | x <- vs ] / fromIntegral (length vs - 1)
+
+-- | biased 自己共分散 (分母 n・arviz @_autocov@ と同じ規約) を lag 0..n-1 で。
+autocovBiased :: V.Vector Double -> V.Vector Double
+autocovBiased v = V.generate nn at
+  where
+    nn = V.length v
+    mu = V.sum v / fromIntegral nn
+    c  = V.map (subtract mu) v
+    at t = V.sum (V.zipWith (*) (V.take (nn - t) c) (V.drop t c))
+           / fromIntegral nn
+
 -- | Split-R-hat convergence diagnostic (Vehtari et al. 2021).
 --
 -- Splits each chain in half to obtain @2M@ sub-chains, then computes
@@ -148,3 +289,28 @@
     diffs    = zipWith (-) (drop 1 es) es
     numer    = sum (map (\d -> d * d) diffs)
                / fromIntegral (length diffs)
+
+-- | Rank-normalized per-chain histogram counts (PyMC @plot_rank@ の素材・
+-- Vehtari et al. 2021)。 全 chain をプールした値に昇順 rank (1..n) を振り、
+-- chain ごとに @nBins@ 個のビンへ振り分けた **ビンごとのカウント** を返す。
+-- 返り値は chain ごとの長さ @nBins@ のカウント列 (= @[[count]]@・入力 chain 順)。
+-- 収束時は各 chain の rank 分布が一様 (= どのビンもほぼ同数) に近づく。
+--
+-- ビン境界は Viz/Plot 両経路で共有するためここに一元化する (二重実装を避ける)。
+rankHist :: Int -> [[Double]] -> [[Int]]
+rankHist nBins perChain =
+  [ [ length (filter (== b) (chainBins c)) | b <- [0 .. nBins - 1] ]
+  | c <- [0 .. nCh - 1] ]
+  where
+    nCh       = length perChain
+    flat      = [ (cid, v) | (cid, vs) <- zip [0 :: Int ..] perChain, v <- vs ]
+    n         = length flat
+    -- 値昇順に rank 1..n を振り、 元 (flat) 順序へ戻す
+    ranked    = zipWith (\rk (oi, _) -> (oi, rk))
+                        [1 :: Int ..]
+                        (sortBy (comparing (snd . snd)) (zip [0 :: Int ..] flat))
+    rankByIdx = map snd (sortBy (comparing fst) ranked)   -- flat 順の rank
+    binSize   = max 1 (n `div` nBins)
+    binOf r   = min (nBins - 1) ((r - 1) `div` binSize)
+    chainSeq  = map fst flat
+    chainBins c = [ binOf r | (cid, r) <- zip chainSeq rankByIdx, cid == c ]
diff --git a/src/Hanalyze/Stat/MDS.hs b/src/Hanalyze/Stat/MDS.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Stat/MDS.hs
@@ -0,0 +1,144 @@
+{-# LANGUAGE BangPatterns #-}
+-- |
+-- Module      : Hanalyze.Stat.MDS
+-- Description : 多次元尺度構成法 (古典 MDS / Sammon MDS)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Multidimensional Scaling (MDS).
+--
+-- * Classical MDS (Torgerson) — 距離行列を二重中心化 → 固有分解 → 上位 k 成分。
+-- * Sammon MDS — Sammon stress を勾配降下で最小化 (古典 MDS を初期値)。
+--
+-- @
+-- import qualified Hanalyze.Stat.MDS as MDS
+-- let d  = MDS.euclideanDist x                -- x :: Matrix Double (n × p)
+--     emb = MDS.mdsClassical d 2              -- 2-D 埋め込み (n × 2)
+-- @
+module Hanalyze.Stat.MDS
+  ( euclideanDist
+  , mdsClassical
+  , mdsSammon
+  , sammonStress
+  , SammonConfig (..)
+  , defaultSammonConfig
+  ) where
+
+import qualified Numeric.LinearAlgebra as LA
+
+-- ---------------------------------------------------------------------------
+-- Distance matrix helper
+-- ---------------------------------------------------------------------------
+
+-- | n × p のデータ行列から n × n のユークリッド距離行列を作る。
+euclideanDist :: LA.Matrix Double -> LA.Matrix Double
+euclideanDist x =
+  let !n = LA.rows x
+      row i = LA.flatten (x LA.? [i])
+      dij i j = LA.norm_2 (row i - row j)
+  in LA.build (n, n) (\i j -> dij (round i) (round j))
+
+-- ---------------------------------------------------------------------------
+-- Classical MDS (Torgerson)
+-- ---------------------------------------------------------------------------
+
+-- | 距離行列 D (n × n) を k 次元埋め込み (n × k) に。
+--
+-- B = -1/2 · H · D² · H、 H = I - 1/n · 11ᵀ。 B = V Λ Vᵀ から
+-- 正の上位 k 成分のみ抽出して X = V_k √Λ_k。
+mdsClassical :: LA.Matrix Double  -- ^ 距離行列 D (n × n)。
+             -> Int                -- ^ 目的次元 k。
+             -> LA.Matrix Double  -- ^ 埋め込み (n × k)。
+mdsClassical d k =
+  let !n   = LA.rows d
+      !d2  = d * d
+      ones = LA.konst 1 (n, n) :: LA.Matrix Double
+      h    = LA.ident n - LA.scale (1 / fromIntegral n) ones
+      b    = LA.scale (-0.5) (h LA.<> d2 LA.<> h)
+      -- 対称化 (数値誤差吸収)
+      bSym = LA.scale 0.5 (b + LA.tr b)
+      (eigVals, eigVecs) = LA.eigSH (LA.trustSym bSym)
+      -- 降順 (hmatrix eigSH)。 正かつ上位 k を採用。
+      lamList = LA.toList eigVals
+      take_   = min k n
+      lamPos  = [ if v > 0 then v else 0 | v <- take take_ lamList ]
+      sqrtL   = LA.diag (LA.fromList (map sqrt lamPos))
+      vK      = eigVecs LA.¿ [0 .. take_ - 1]
+  in vK LA.<> sqrtL
+
+-- ---------------------------------------------------------------------------
+-- Sammon MDS
+-- ---------------------------------------------------------------------------
+
+data SammonConfig = SammonConfig
+  { sammonMaxIter :: !Int
+  , sammonLR      :: !Double   -- ^ 学習率。
+  , sammonTol     :: !Double   -- ^ stress 改善の許容下限。
+  } deriving (Show)
+
+defaultSammonConfig :: SammonConfig
+defaultSammonConfig = SammonConfig
+  { sammonMaxIter = 300
+  , sammonLR      = 0.3
+  , sammonTol     = 1e-6
+  }
+
+-- | Sammon stress E = (1/c) Σ_{i<j} (δ_ij - d_ij)² / δ_ij
+--   ただし δ_ij は元距離、 d_ij は埋め込み距離、 c = Σ_{i<j} δ_ij。
+sammonStress :: LA.Matrix Double  -- ^ 元距離行列 (n × n)。
+             -> LA.Matrix Double  -- ^ 埋め込み (n × k)。
+             -> Double
+sammonStress d y =
+  let !n = LA.rows d
+      row i = LA.flatten (y LA.? [i])
+      pairs = [ (i, j) | i <- [0 .. n - 1], j <- [i + 1 .. n - 1] ]
+      delta i j = LA.atIndex d (i, j)
+      dij i j = LA.norm_2 (row i - row j)
+      cTot  = sum [ delta i j | (i, j) <- pairs ]
+      num   = sum [ let !del = delta i j
+                        !dd  = dij i j
+                    in if del > 0 then (del - dd)^(2 :: Int) / del
+                                  else 0
+                  | (i, j) <- pairs ]
+  in if cTot > 0 then num / cTot else 0
+
+-- | Sammon MDS。 古典 MDS を初期値にして勾配降下。
+mdsSammon :: SammonConfig
+          -> LA.Matrix Double  -- ^ 距離行列 D (n × n)。
+          -> Int                -- ^ 目的次元 k。
+          -> LA.Matrix Double  -- ^ 埋め込み (n × k)。
+mdsSammon cfg d k =
+  let !y0 = mdsClassical d k
+      loop !y !iter !prevE
+        | iter >= sammonMaxIter cfg = y
+        | otherwise =
+            let !grad = sammonGrad d y
+                !y'   = y - LA.scale (sammonLR cfg) grad
+                !e'   = sammonStress d y'
+            in if abs (prevE - e') < sammonTol cfg
+                 then y'
+                 else loop y' (iter + 1) e'
+  in loop y0 0 (sammonStress d y0)
+
+-- | Sammon stress の勾配 (n × k)。
+sammonGrad :: LA.Matrix Double -> LA.Matrix Double -> LA.Matrix Double
+sammonGrad d y =
+  let !n = LA.rows d
+      !k = LA.cols y
+      row i = LA.flatten (y LA.? [i])
+      delta i j = LA.atIndex d (i, j)
+      cTot = sum [ delta i j | i <- [0 .. n - 1]
+                             , j <- [i + 1 .. n - 1] ]
+      scl = if cTot > 0 then 2 / cTot else 0
+      gradRow i =
+        let yi = row i
+            contribs = [ let yj = row j
+                             dij = LA.norm_2 (yi - yj)
+                             del = delta i j
+                         in if del > 0 && dij > 0
+                              then LA.scale ((del - dij) / (del * dij))
+                                     (yi - yj)
+                              else LA.konst 0 k
+                       | j <- [0 .. n - 1], j /= i ]
+        in LA.scale (negate scl) (sum contribs)
+  in LA.fromRows [ gradRow i | i <- [0 .. n - 1] ]
diff --git a/src/Hanalyze/Stat/ModelSelect.hs b/src/Hanalyze/Stat/ModelSelect.hs
--- a/src/Hanalyze/Stat/ModelSelect.hs
+++ b/src/Hanalyze/Stat/ModelSelect.hs
@@ -1,6 +1,12 @@
 {-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE RankNTypes #-}
--- | MCMC-based model comparison criteria.
+-- |
+-- Module      : Hanalyze.Stat.ModelSelect
+-- Description : MCMC ベースのモデル比較基準 (WAIC / PSIS-LOO / pseudo-BMA)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- MCMC-based model comparison criteria.
 --
 -- Provides WAIC (Widely Applicable Information Criterion) and PSIS-LOO
 -- (Pareto-Smoothed Importance Sampling LOO-CV), plus a @pm.compare@-style
diff --git a/src/Hanalyze/Stat/MultipleTesting.hs b/src/Hanalyze/Stat/MultipleTesting.hs
--- a/src/Hanalyze/Stat/MultipleTesting.hs
+++ b/src/Hanalyze/Stat/MultipleTesting.hs
@@ -1,5 +1,11 @@
 {-# LANGUAGE OverloadedStrings #-}
--- | Multiple-testing correction.
+-- |
+-- Module      : Hanalyze.Stat.MultipleTesting
+-- Description : 多重比較補正 (FWER: Bonferroni/Holm、 FDR: BH/BY)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Multiple-testing correction.
 --
 -- Adjusts a list of p-values to control either:
 --
diff --git a/src/Hanalyze/Stat/NumberFormat.hs b/src/Hanalyze/Stat/NumberFormat.hs
--- a/src/Hanalyze/Stat/NumberFormat.hs
+++ b/src/Hanalyze/Stat/NumberFormat.hs
@@ -1,5 +1,11 @@
 {-# LANGUAGE OverloadedStrings #-}
--- | Number-formatting helpers for reports and CLI output.
+-- |
+-- Module      : Hanalyze.Stat.NumberFormat
+-- Description : レポート/CLI 出力向けの数値フォーマット helper (桁数に応じた固定/指数表記の自動選択)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Number-formatting helpers for reports and CLI output.
 --
 -- A single function chooses fixed-point or exponential notation based on
 -- magnitude:
diff --git a/src/Hanalyze/Stat/PosteriorPredictive.hs b/src/Hanalyze/Stat/PosteriorPredictive.hs
--- a/src/Hanalyze/Stat/PosteriorPredictive.hs
+++ b/src/Hanalyze/Stat/PosteriorPredictive.hs
@@ -1,6 +1,12 @@
 {-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE RankNTypes #-}
--- | Prior- and posterior-predictive sampling (analogous to PyMC's
+-- |
+-- Module      : Hanalyze.Stat.PosteriorPredictive
+-- Description : 事前/事後予測サンプリング (PyMC の sample_prior/posterior_predictive 相当)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Prior- and posterior-predictive sampling (analogous to PyMC's
 -- @sample_prior_predictive@ / @sample_posterior_predictive@).
 --
 -- @
diff --git a/src/Hanalyze/Stat/QuasiRandom.hs b/src/Hanalyze/Stat/QuasiRandom.hs
--- a/src/Hanalyze/Stat/QuasiRandom.hs
+++ b/src/Hanalyze/Stat/QuasiRandom.hs
@@ -1,5 +1,11 @@
--- | Quasi-random number sequences with low discrepancy.
+-- |
+-- Module      : Hanalyze.Stat.QuasiRandom
+-- Description : 低不一致準乱数列 (Halton 列・LHS) — ベイズ最適化の初期設計に利用
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
 --
+-- Quasi-random number sequences with low discrepancy.
+--
 -- These sequences cover a multi-dimensional unit hyper-cube more
 -- evenly than independent uniform-random samples and are the
 -- recommended way to seed Bayesian-optimization initial designs and
@@ -15,6 +21,7 @@
   , haltonSequence
   , haltonSequenceIn
   , haltonMatrix
+  , radicalInverse
   , primes
     -- * Latin Hypercube Sampling
   , lhsSamples
diff --git a/src/Hanalyze/Stat/SPC.hs b/src/Hanalyze/Stat/SPC.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Stat/SPC.hs
@@ -0,0 +1,749 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE BangPatterns #-}
+-- |
+-- Module      : Hanalyze.Stat.SPC
+-- Description : 統計的工程管理 (SPC) — 管理図 (X̄-R/I-MR/p/np/c/u) + 判定ルール
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- 統計的工程管理 (Statistical Process Control) — 管理図 + 判定ルール。
+--
+-- 変数管理図 (X̄-R / I-MR) と属性管理図 (p / np / c / u) を共通 API で扱う。
+-- 判定ルール (Western Electric / Nelson) は fit と分離した pure 関数。
+--
+-- ===  公開 API
+--
+-- * 'SPCChart' / 'SPCInput' / 'SPCChartResult'
+-- * 'fitSPC'
+-- * 'westernElectricRules' / 'nelsonRules' / 'checkRules'
+--
+-- ===  典型的な使い方
+--
+-- > case fitSPC XR (VarSubgroups subs) of
+-- >   Left err -> ...
+-- >   Right [xbar, rChart] -> do
+-- >     let viols = checkRules westernElectricRules xbar
+-- >     ...
+module Hanalyze.Stat.SPC
+  ( -- * chart 種別
+    SPCChart (..)
+  , SPCInput  (..)
+  , SPCChartResult (..)
+    -- * fit
+  , fitSPC
+    -- * 判定ルール
+  , SPCRule (..)
+  , SPCViolation (..)
+  , westernElectricRules
+  , nelsonRules
+  , checkRules
+  ) where
+
+import qualified Data.Text     as T
+import qualified Data.Vector   as V
+import           Data.Text     (Text)
+import           Data.Vector   (Vector)
+
+-- ===========================================================================
+-- 型定義
+-- ===========================================================================
+
+-- | 管理図の種別。
+data SPCChart
+  = XR    -- ^ X̄-R chart (subgroup 平均 + range)
+  | IMR   -- ^ I-MR chart (individual + moving range)
+  | P     -- ^ p chart (不良率、 subgroup size 可変)
+  | NP    -- ^ np chart (不良数、 subgroup size 一定)
+  | C     -- ^ c chart (単位あたり欠陥数、 unit size 一定)
+  | U     -- ^ u chart (単位あたり欠陥率、 unit size 可変)
+  | EWMAChart    -- ^ EWMA (Exponentially Weighted Moving Average) chart (Phase 11)
+  | CUSUMChart   -- ^ CUSUM (Cumulative Sum) chart 両側 (Phase 11)
+  deriving (Show, Eq)
+
+-- | 管理図入力。 chart 種別に対応した構成のみ受け付ける。
+data SPCInput
+  = -- | 変数管理図 (X̄-R) 用。 各 subgroup の観測値ベクトル。
+    --   subgroup サイズ (内側 Vector の長さ) は全 subgroup で同一であること。
+    VarSubgroups   !(Vector (Vector Double))
+  | -- | I-MR 用。 個別観測値の系列。
+    VarIndividual  !(Vector Double)
+  | -- | p chart 用。 (不良数, sample size) の系列。
+    AttrProportion !(Vector Int) !(Vector Int)
+  | -- | np chart 用。 (不良数の系列, 一定 sample size)。
+    AttrCount      !(Vector Int) !Int
+  | -- | c chart 用。 欠陥数の系列 (unit size は一定と仮定)。
+    AttrDefects    !(Vector Int)
+  | -- | u chart 用。 (欠陥数, unit size) の系列。
+    AttrDefectRate !(Vector Int) !(Vector Int)
+  | -- | EWMA 用。 (個別観測値 xs, λ ∈ (0,1], L (sigma 倍数), μ₀ target, σ₀ baseline σ)。
+    --   σ₀ ≤ 0 を渡すと xs の標本標準偏差で代用。
+    EWMAInput      !(Vector Double) !Double !Double !Double !Double
+  | -- | CUSUM 用。 (個別観測値 xs, μ₀ target, σ₀ baseline σ, k (allowance, σ単位), h (decision interval, σ単位))。
+    --   σ₀ ≤ 0 を渡すと xs の標本標準偏差で代用。 両側 CUSUM (C+, C-) を返す。
+    CUSUMInput     !(Vector Double) !Double !Double !Double !Double
+  deriving (Show, Eq)
+
+-- | 1 つの管理図の fit 結果。 X̄-R / I-MR では 2 つ並んで返る。
+--
+-- 不変条件:
+--
+--   * @V.length spcPoints == V.length spcUCL == V.length spcLCL@
+--   * 固定 limit chart (X̄-R / I-MR / np / c) では UCL/LCL は全要素同値
+--   * 変動 limit chart (p / u) では UCL/LCL が点ごとに異なる
+data SPCChartResult = SPCChartResult
+  { spcPoints    :: !(Vector Double)
+    -- ^ 点ごとにプロットする統計量 (X̄、 R、 個別値、 MR、 p̂、 np、 c、 u 等)
+  , spcCenter    :: !Double
+    -- ^ 中心線 (CL)
+  , spcUCL       :: !(Vector Double)
+    -- ^ 上方管理限界 (点ごと)
+  , spcLCL       :: !(Vector Double)
+    -- ^ 下方管理限界 (点ごと)
+  , spcSigma     :: !Double
+    -- ^ 推定 σ (rule 判定用、 zone A/B/C の境界を計算するのに使う)
+  , spcChartName :: !Text
+    -- ^ "X-bar" / "R" / "I" / "MR" / "p" / "np" / "c" / "u"
+  } deriving (Show)
+
+-- ===========================================================================
+-- Montgomery 定数 (n = 2..15)
+-- ===========================================================================
+
+-- | 出典: Montgomery, "Introduction to Statistical Quality Control" 9th ed.
+--   Appendix VI。 @(A2, D3, D4, d2)@。
+--   subgroup size 範囲外の @n@ では 'Nothing'。
+subgroupConst :: Int -> Maybe (Double, Double, Double, Double)
+subgroupConst n = case n of
+  2  -> Just (1.880, 0.000, 3.267, 1.128)
+  3  -> Just (1.023, 0.000, 2.574, 1.693)
+  4  -> Just (0.729, 0.000, 2.282, 2.059)
+  5  -> Just (0.577, 0.000, 2.115, 2.326)
+  6  -> Just (0.483, 0.000, 2.004, 2.534)
+  7  -> Just (0.419, 0.076, 1.924, 2.704)
+  8  -> Just (0.373, 0.136, 1.864, 2.847)
+  9  -> Just (0.337, 0.184, 1.816, 2.970)
+  10 -> Just (0.308, 0.223, 1.777, 3.078)
+  11 -> Just (0.285, 0.256, 1.744, 3.173)
+  12 -> Just (0.266, 0.283, 1.717, 3.258)
+  13 -> Just (0.249, 0.307, 1.693, 3.336)
+  14 -> Just (0.235, 0.328, 1.672, 3.407)
+  15 -> Just (0.223, 0.347, 1.653, 3.472)
+  _  -> Nothing
+
+-- ===========================================================================
+-- 内部ヘルパ
+-- ===========================================================================
+
+vmean :: Vector Double -> Double
+vmean v
+  | V.null v  = 0
+  | otherwise = V.sum v / fromIntegral (V.length v)
+
+vrange :: Vector Double -> Double
+vrange v
+  | V.null v  = 0
+  | otherwise = V.maximum v - V.minimum v
+
+-- | 単一値で埋めた長さ @n@ の Vector。
+vconst :: Int -> Double -> Vector Double
+vconst n x = V.replicate n x
+
+tshow :: Show a => a -> Text
+tshow = T.pack . show
+
+chartTag :: SPCChart -> Text
+chartTag XR  = "XR"
+chartTag IMR = "IMR"
+chartTag P   = "P"
+chartTag NP  = "NP"
+chartTag C   = "C"
+chartTag U   = "U"
+chartTag EWMAChart  = "EWMA"
+chartTag CUSUMChart = "CUSUM"
+
+inputTag :: SPCInput -> Text
+inputTag VarSubgroups{}   = "VarSubgroups"
+inputTag VarIndividual{}  = "VarIndividual"
+inputTag AttrProportion{} = "AttrProportion"
+inputTag AttrCount{}      = "AttrCount"
+inputTag AttrDefects{}    = "AttrDefects"
+inputTag AttrDefectRate{} = "AttrDefectRate"
+inputTag EWMAInput{}      = "EWMAInput"
+inputTag CUSUMInput{}     = "CUSUMInput"
+
+-- ===========================================================================
+-- 公開関数
+-- ===========================================================================
+
+-- | 管理図を fit する。 X̄-R / I-MR は 2 chart を返す
+-- (順に X̄ chart / R chart、 I chart / MR chart)。
+-- chart 種別と入力の組合せが不正な場合 'Left' を返す。
+fitSPC :: SPCChart -> SPCInput -> Either Text [SPCChartResult]
+fitSPC XR  (VarSubgroups subs)    = fitXR subs
+fitSPC IMR (VarIndividual xs)     = fitIMR xs
+fitSPC P   (AttrProportion ds ns) = fitP  ds ns
+fitSPC NP  (AttrCount ds n)       = fitNP ds n
+fitSPC C   (AttrDefects ds)       = fitC  ds
+fitSPC U   (AttrDefectRate ds ns) = fitU  ds ns
+fitSPC EWMAChart  (EWMAInput xs lam ll mu0 s0)        = fitEWMA xs lam ll mu0 s0
+fitSPC CUSUMChart (CUSUMInput xs mu0 s0 k h)          = fitCUSUM xs mu0 s0 k h
+fitSPC chart inp =
+  Left $ "Hanalyze.Stat.SPC.fitSPC: chart kind "
+       <> chartTag chart
+       <> " does not match input "
+       <> inputTag inp
+
+-- ---------------------------------------------------------------------------
+-- X̄-R chart
+-- ---------------------------------------------------------------------------
+
+-- | X̄-R chart:
+--
+--   * X̄ chart: CL = X̿、 UCL = X̿ + A2·R̄、 LCL = X̿ − A2·R̄、 σ̂ = R̄ / d2
+--   * R chart: CL = R̄、 UCL = D4·R̄、 LCL = D3·R̄
+fitXR :: Vector (Vector Double) -> Either Text [SPCChartResult]
+fitXR subs
+  | V.null subs = Left "fitSPC XR: empty subgroup list"
+  | otherwise =
+      let !n  = V.length (V.head subs)
+          !k  = V.length subs
+          sizesOk = V.all (\s -> V.length s == n) subs
+      in if not sizesOk
+           then Left "fitSPC XR: subgroup sizes are not uniform"
+           else case subgroupConst n of
+             Nothing -> Left $ "fitSPC XR: subgroup size n=" <> tshow n
+                            <> " is outside supported range (2..15)"
+             Just (a2, d3, d4, d2c) ->
+               let means   = V.map vmean  subs
+                   ranges  = V.map vrange subs
+                   xBarBar = vmean means
+                   rBar    = vmean ranges
+                   sigma   = rBar / d2c
+                   uclX    = xBarBar + a2 * rBar
+                   lclX    = xBarBar - a2 * rBar
+                   uclR    = d4 * rBar
+                   lclR    = d3 * rBar
+                   xChart  = SPCChartResult
+                     { spcPoints    = means
+                     , spcCenter    = xBarBar
+                     , spcUCL       = vconst k uclX
+                     , spcLCL       = vconst k lclX
+                     , spcSigma     = sigma
+                     , spcChartName = "X-bar"
+                     }
+                   rChart  = SPCChartResult
+                     { spcPoints    = ranges
+                     , spcCenter    = rBar
+                     , spcUCL       = vconst k uclR
+                     , spcLCL       = vconst k lclR
+                     , spcSigma     = sigma
+                     , spcChartName = "R"
+                     }
+               in Right [xChart, rChart]
+
+-- ---------------------------------------------------------------------------
+-- I-MR chart
+-- ---------------------------------------------------------------------------
+
+-- | I-MR chart:
+--
+--   * MR_i = |x_i − x_{i−1}|  for i = 1..N−1
+--   * I chart:  CL = x̄、 σ̂ = MR̄ / d2(n=2) = MR̄ / 1.128、 UCL/LCL = x̄ ± 3σ̂
+--   * MR chart: CL = MR̄、 UCL = D4(2)·MR̄ = 3.267·MR̄、 LCL = D3(2)·MR̄ = 0
+fitIMR :: Vector Double -> Either Text [SPCChartResult]
+fitIMR xs
+  | V.length xs < 2 = Left "fitSPC IMR: need at least 2 individual observations"
+  | otherwise =
+      let !n      = V.length xs
+          xBar    = vmean xs
+          mr      = V.generate (n - 1) (\i -> abs (xs V.! (i + 1) - xs V.! i))
+          mrBar   = vmean mr
+          (_, d3, d4, d2c) = case subgroupConst 2 of
+            Just t  -> t
+            Nothing -> (0, 0, 0, 1.128)  -- 到達不能
+          sigma   = mrBar / d2c
+          uclI    = xBar + 3 * sigma
+          lclI    = xBar - 3 * sigma
+          uclMR   = d4 * mrBar
+          lclMR   = d3 * mrBar
+          iChart  = SPCChartResult
+            { spcPoints    = xs
+            , spcCenter    = xBar
+            , spcUCL       = vconst n uclI
+            , spcLCL       = vconst n lclI
+            , spcSigma     = sigma
+            , spcChartName = "I"
+            }
+          mrChart = SPCChartResult
+            { spcPoints    = mr
+            , spcCenter    = mrBar
+            , spcUCL       = vconst (n - 1) uclMR
+            , spcLCL       = vconst (n - 1) lclMR
+            , spcSigma     = sigma
+            , spcChartName = "MR"
+            }
+      in Right [iChart, mrChart]
+
+-- ---------------------------------------------------------------------------
+-- p chart (proportion defective, variable subgroup size)
+-- ---------------------------------------------------------------------------
+
+-- | p chart:
+--
+--   * p̂_i = d_i / n_i
+--   * p̄   = Σ d_i / Σ n_i
+--   * CL  = p̄
+--   * UCL_i = p̄ + 3·sqrt(p̄(1−p̄)/n_i)、 LCL_i = max(0, …)
+--
+-- σ̂ は **平均 n** に基づく代表値 (rule 判定用)。
+fitP :: Vector Int -> Vector Int -> Either Text [SPCChartResult]
+fitP ds ns
+  | V.length ds /= V.length ns
+      = Left "fitSPC P: defectives and sample-size series differ in length"
+  | V.null ds = Left "fitSPC P: empty series"
+  | V.any (< 0) ds = Left "fitSPC P: defectives must be non-negative"
+  | V.any (<= 0) ns = Left "fitSPC P: sample sizes must be positive"
+  | V.or (V.zipWith (>) ds ns) = Left "fitSPC P: defectives exceed sample size"
+  | otherwise =
+      let k       = V.length ds
+          totalD  = sum (V.toList ds) :: Int
+          totalN  = sum (V.toList ns) :: Int
+          pBar    = fromIntegral totalD / fromIntegral totalN
+          phat    = V.zipWith (\d n -> fromIntegral d / fromIntegral n) ds ns
+          ucl     = V.map (\ni -> pBar + 3 * sqrt (pBar * (1 - pBar) /
+                                                   fromIntegral ni)) ns
+          lcl     = V.map (\ni -> max 0 $ pBar - 3 * sqrt (pBar * (1 - pBar) /
+                                                           fromIntegral ni)) ns
+          nMean   = fromIntegral totalN / fromIntegral k :: Double
+          sigma   = sqrt (pBar * (1 - pBar) / nMean)
+      in Right [SPCChartResult
+        { spcPoints    = phat
+        , spcCenter    = pBar
+        , spcUCL       = ucl
+        , spcLCL       = lcl
+        , spcSigma     = sigma
+        , spcChartName = "p"
+        }]
+
+-- ---------------------------------------------------------------------------
+-- np chart (count defective, constant subgroup size n)
+-- ---------------------------------------------------------------------------
+
+-- | np chart (n は全 subgroup で一定):
+--
+--   * CL  = n·p̄ = 平均不良数
+--   * σ̂  = sqrt(n·p̄·(1−p̄))
+--   * UCL = n·p̄ + 3·σ̂、 LCL = max(0, …)
+fitNP :: Vector Int -> Int -> Either Text [SPCChartResult]
+fitNP ds n
+  | V.null ds        = Left "fitSPC NP: empty defectives series"
+  | n <= 0           = Left "fitSPC NP: sample size n must be positive"
+  | V.any (< 0) ds   = Left "fitSPC NP: defectives must be non-negative"
+  | V.any (> n) ds   = Left "fitSPC NP: defectives exceed sample size"
+  | otherwise =
+      let k       = V.length ds
+          totalD  = sum (V.toList ds) :: Int
+          pBar    = fromIntegral totalD / fromIntegral (n * k) :: Double
+          cl      = fromIntegral n * pBar
+          sigma   = sqrt (fromIntegral n * pBar * (1 - pBar))
+          ucl     = cl + 3 * sigma
+          lcl     = max 0 (cl - 3 * sigma)
+          pts     = V.map fromIntegral ds :: Vector Double
+      in Right [SPCChartResult
+        { spcPoints    = pts
+        , spcCenter    = cl
+        , spcUCL       = vconst k ucl
+        , spcLCL       = vconst k lcl
+        , spcSigma     = sigma
+        , spcChartName = "np"
+        }]
+
+-- ---------------------------------------------------------------------------
+-- c chart (count of defects, constant unit size)
+-- ---------------------------------------------------------------------------
+
+-- | c chart:
+--
+--   * CL  = c̄ = 平均欠陥数
+--   * σ̂  = sqrt(c̄)
+--   * UCL = c̄ + 3·sqrt(c̄)、 LCL = max(0, …)
+fitC :: Vector Int -> Either Text [SPCChartResult]
+fitC ds
+  | V.null ds         = Left "fitSPC C: empty defects series"
+  | V.any (< 0) ds    = Left "fitSPC C: defects must be non-negative"
+  | otherwise =
+      let k       = V.length ds
+          cBar    = fromIntegral (sum (V.toList ds)) / fromIntegral k :: Double
+          sigma   = sqrt cBar
+          ucl     = cBar + 3 * sigma
+          lcl     = max 0 (cBar - 3 * sigma)
+          pts     = V.map fromIntegral ds :: Vector Double
+      in Right [SPCChartResult
+        { spcPoints    = pts
+        , spcCenter    = cBar
+        , spcUCL       = vconst k ucl
+        , spcLCL       = vconst k lcl
+        , spcSigma     = sigma
+        , spcChartName = "c"
+        }]
+
+-- ---------------------------------------------------------------------------
+-- u chart (defect rate, variable unit size)
+-- ---------------------------------------------------------------------------
+
+-- | u chart:
+--
+--   * u_i = d_i / n_i
+--   * ū   = Σ d_i / Σ n_i
+--   * CL  = ū
+--   * UCL_i = ū + 3·sqrt(ū/n_i)、 LCL_i = max(0, …)
+fitU :: Vector Int -> Vector Int -> Either Text [SPCChartResult]
+fitU ds ns
+  | V.length ds /= V.length ns
+      = Left "fitSPC U: defects and unit-size series differ in length"
+  | V.null ds       = Left "fitSPC U: empty series"
+  | V.any (< 0) ds  = Left "fitSPC U: defects must be non-negative"
+  | V.any (<= 0) ns = Left "fitSPC U: unit sizes must be positive"
+  | otherwise =
+      let k       = V.length ds
+          totalD  = fromIntegral (sum (V.toList ds)) :: Double
+          totalN  = fromIntegral (sum (V.toList ns)) :: Double
+          uBar    = totalD / totalN
+          us      = V.zipWith (\d n -> fromIntegral d / fromIntegral n) ds ns
+          ucl     = V.map (\ni -> uBar + 3 * sqrt (uBar / fromIntegral ni)) ns
+          lcl     = V.map (\ni -> max 0 (uBar - 3 * sqrt (uBar / fromIntegral ni))) ns
+          nMean   = totalN / fromIntegral k
+          sigma   = sqrt (uBar / nMean)
+      in Right [SPCChartResult
+        { spcPoints    = us
+        , spcCenter    = uBar
+        , spcUCL       = ucl
+        , spcLCL       = lcl
+        , spcSigma     = sigma
+        , spcChartName = "u"
+        }]
+
+-- ===========================================================================
+-- 判定ルール (Phase 1.4 / 1.5 で実装)
+-- ===========================================================================
+
+-- | 判定ルール 1 個。
+data SPCRule = SPCRule
+  { ruleName   :: !Text                       -- ^ "Western Electric 1" / "Nelson 1" 等
+  , ruleNumber :: !Int                        -- ^ ルール番号 (1..8)
+  , ruleCheck  :: SPCChartResult -> [Int]     -- ^ 違反点の 0-origin index list
+  }
+
+-- | ルール違反 1 件。
+data SPCViolation = SPCViolation
+  { vRuleName    :: !Text
+  , vRuleNumber  :: !Int
+  , vPointIndex  :: !Int
+  , vChartName   :: !Text   -- ^ どの chart で違反したか (X-bar / R / 等)
+  } deriving (Show, Eq)
+
+-- ---------------------------------------------------------------------------
+-- 内部パターン検出 (rule 共通)
+-- ---------------------------------------------------------------------------
+
+-- $patternDetectors
+-- ゾーン境界は CL ± k·σ で定義 (σ は 'spcSigma' フィールド)。
+-- 可変 limit chart (p / u) では σ は代表値 (平均 n から算出) なので、
+-- ゾーン判定はやや近似となる (canvas display 用途では実用上問題なし)。
+
+-- | k·σ の絶対値を超えた点の index (0-origin)。 chart 種別非依存。
+beyondSigma :: Double -> SPCChartResult -> [Int]
+beyondSigma k r =
+  let cl    = spcCenter r
+      sigma = spcSigma r
+      pts   = V.toList (spcPoints r)
+  in [ i | (i, x) <- zip [0..] pts
+         , abs (x - cl) > k * sigma ]
+
+-- | k·σ を超える点について「+ なら +1、 − なら −1、 ゾーン内なら 0」。
+sideAtSigma :: Double -> SPCChartResult -> [Int]
+sideAtSigma k r =
+  let cl    = spcCenter r
+      sigma = spcSigma r
+      pts   = V.toList (spcPoints r)
+      classify x
+        | x - cl >  k * sigma =  1
+        | x - cl < -k * sigma = -1
+        | otherwise           =  0
+  in map classify pts
+
+-- | CL に対する符号 (上 = +1, 下 = -1, 上 = 0)。
+sideOfCenter :: SPCChartResult -> [Int]
+sideOfCenter r =
+  let cl    = spcCenter r
+      pts   = V.toList (spcPoints r)
+      classify x
+        | x >  cl =  1
+        | x <  cl = -1
+        | otherwise = 0
+  in map classify pts
+
+-- | N 個連続で同符号 (CL の同じ側) になっている末尾点の index を返す。
+--   例: 8 連続 → 連続区間の 8 点目以降を全部 violation として返す。
+runSameSide :: Int -> SPCChartResult -> [Int]
+runSameSide n r = go 0 0 0 (sideOfCenter r) []
+  where
+    go !i !curSide !runLen ss acc = case ss of
+      []     -> reverse acc
+      (s:xs) ->
+        let (curSide', runLen')
+              | s == 0           = (0, 0)
+              | s == curSide     = (curSide, runLen + 1)
+              | otherwise        = (s, 1)
+            acc' | runLen' >= n = i : acc
+                 | otherwise    = acc
+        in go (i + 1) curSide' runLen' xs acc'
+
+-- | N 個連続で単調 (全て上昇 or 全て下降) のパターンの末尾 index。
+trendMono :: Int -> SPCChartResult -> [Int]
+trendMono n r = go 0 0 0 (V.toList (spcPoints r)) []
+  where
+    -- direction: +1 = increasing, -1 = decreasing, 0 = none yet
+    go _ _ _ [] acc = reverse acc
+    go _ _ _ [_] acc = reverse acc
+    go !i !dir !runLen (x : ys@(y : _)) acc =
+      let d | y > x =  1
+            | y < x = -1
+            | otherwise = 0
+          (dir', runLen')
+            | d == 0      = (0, 0)
+            | d == dir    = (dir, runLen + 1)
+            | otherwise   = (d, 2)   -- 始まり: 2 点で run=2
+          -- 違反 = runLen が n 以上、 i+1 (現在の y) の index を記録
+          acc' | runLen' >= n = (i + 1) : acc
+               | otherwise    = acc
+      in go (i + 1) dir' runLen' ys acc'
+
+-- | N 個連続で交互上下のパターンの末尾 index。
+alternating :: Int -> SPCChartResult -> [Int]
+alternating n r = go 0 0 0 (V.toList (spcPoints r)) []
+  where
+    go _ _ _ [] acc = reverse acc
+    go _ _ _ [_] acc = reverse acc
+    go !i !lastDir !runLen (x : ys@(y : _)) acc =
+      let d | y > x =  1
+            | y < x = -1
+            | otherwise = 0
+          (lastDir', runLen')
+            | d == 0                       = (0, 0)
+            | lastDir == 0                 = (d, 2)
+            | d == negate lastDir          = (d, runLen + 1)
+            | otherwise                    = (d, 2)
+          acc' | runLen' >= n = (i + 1) : acc
+               | otherwise    = acc
+      in go (i + 1) lastDir' runLen' ys acc'
+
+-- | k 個連続で σ 倍の絶対値以内 (= ゾーン C 内のみ) の末尾 index。
+--   stratification (W-E rule 6 / Nelson 7)。
+withinSigma :: Int -> Double -> SPCChartResult -> [Int]
+withinSigma n k r =
+  let cl    = spcCenter r
+      sigma = spcSigma r
+      pts   = V.toList (spcPoints r)
+      flags = map (\x -> abs (x - cl) <= k * sigma) pts
+  in collectRun n flags
+
+-- | k 個連続で σ 倍の絶対値より外 (= ゾーン A or B、 中央線の同/異側問わず) の末尾 index。
+--   mixture (W-E rule 7 / Nelson 8)。
+beyondSigmaEither :: Int -> Double -> SPCChartResult -> [Int]
+beyondSigmaEither n k r =
+  let cl    = spcCenter r
+      sigma = spcSigma r
+      pts   = V.toList (spcPoints r)
+      flags = map (\x -> abs (x - cl) > k * sigma) pts
+  in collectRun n flags
+
+-- | True が n 個以上連続するパターンの末尾 index 集合。
+collectRun :: Int -> [Bool] -> [Int]
+collectRun n = go 0 0 []
+  where
+    go _ _ acc [] = reverse acc
+    go !i !rn acc (f : fs) =
+      let rn'  = if f then rn + 1 else 0
+          acc' | rn' >= n = i : acc
+               | otherwise = acc
+      in go (i + 1) rn' acc' fs
+
+-- | 「直近 m 点のうち k 点以上が k·σ を **同じ側** で超えている」 末尾 index。
+--   Western Electric 2 / 3 用 (m, k, σ係数)。
+kOfMBeyondSameSide :: Int -> Int -> Double -> SPCChartResult -> [Int]
+kOfMBeyondSameSide kth m sigK r = go 0 (sideAtSigma sigK r) []
+  where
+    go _ ss acc | length ss < m = reverse acc
+    go !i ss acc =
+      let window = take m ss
+          posCount = length (filter (==  1) window)
+          negCount = length (filter (== -1) window)
+          hit      = posCount >= kth || negCount >= kth
+          -- 違反 index は window の末尾 (= i + m - 1)
+          acc' | hit       = (i + m - 1) : acc
+               | otherwise = acc
+      in case ss of
+           []     -> reverse acc'
+           (_:xs) -> go (i + 1) xs acc'
+
+-- ---------------------------------------------------------------------------
+-- Western Electric rules (WECO 8 rules)
+-- ---------------------------------------------------------------------------
+
+-- | Western Electric Company (WECO) rules。 8 rules。
+--
+-- (Western Electric Statistical Quality Control Handbook 1956 +
+-- 一般的な 8-rule 拡張)
+--
+--   * Rule 1: 1 点が 3σ 超
+--   * Rule 2: 3 点中 2 点が同じ側で 2σ 超
+--   * Rule 3: 5 点中 4 点が同じ側で 1σ 超
+--   * Rule 4: 8 点連続で CL の同じ側
+--   * Rule 5: 6 点連続で単調 (上昇 or 下降)
+--   * Rule 6: 15 点連続で 1σ 以内 (stratification)
+--   * Rule 7: 8 点連続で 1σ 外 (mixture; どちら側でも可)
+--   * Rule 8: 14 点連続で交互上下
+westernElectricRules :: [SPCRule]
+westernElectricRules =
+  [ SPCRule "Western Electric 1" 1 (beyondSigma 3)
+  , SPCRule "Western Electric 2" 2 (kOfMBeyondSameSide 2 3 2)
+  , SPCRule "Western Electric 3" 3 (kOfMBeyondSameSide 4 5 1)
+  , SPCRule "Western Electric 4" 4 (runSameSide 8)
+  , SPCRule "Western Electric 5" 5 (trendMono 6)
+  , SPCRule "Western Electric 6" 6 (withinSigma 15 1)
+  , SPCRule "Western Electric 7" 7 (beyondSigmaEither 8 1)
+  , SPCRule "Western Electric 8" 8 (alternating 14)
+  ]
+
+-- ---------------------------------------------------------------------------
+-- Nelson rules (1984、 8 rules)
+-- ---------------------------------------------------------------------------
+
+-- | Nelson rules (Nelson, L.S. 1984, J. Qual. Tech.)。 8 rules。
+--
+-- WE 8 rules と多くが重複するが、 ルール番号と一部の N が異なる:
+--
+--   * Rule 1: 1 点が 3σ 超                                   (= WE 1)
+--   * Rule 2: 9 点連続で CL の同じ側                          (WE 4 は 8 点)
+--   * Rule 3: 6 点連続で単調                                  (= WE 5)
+--   * Rule 4: 14 点連続で交互上下                              (= WE 8)
+--   * Rule 5: 3 点中 2 点が同じ側で 2σ 超                      (= WE 2)
+--   * Rule 6: 5 点中 4 点が同じ側で 1σ 超                      (= WE 3)
+--   * Rule 7: 15 点連続で 1σ 以内                              (= WE 6)
+--   * Rule 8: 8 点連続で 1σ 外 (どちら側でも可)                (= WE 7)
+--
+-- 検出ロジックは [[westernElectricRules]] と同じヘルパを再利用。
+nelsonRules :: [SPCRule]
+nelsonRules =
+  [ SPCRule "Nelson 1" 1 (beyondSigma 3)
+  , SPCRule "Nelson 2" 2 (runSameSide 9)
+  , SPCRule "Nelson 3" 3 (trendMono 6)
+  , SPCRule "Nelson 4" 4 (alternating 14)
+  , SPCRule "Nelson 5" 5 (kOfMBeyondSameSide 2 3 2)
+  , SPCRule "Nelson 6" 6 (kOfMBeyondSameSide 4 5 1)
+  , SPCRule "Nelson 7" 7 (withinSigma 15 1)
+  , SPCRule "Nelson 8" 8 (beyondSigmaEither 8 1)
+  ]
+
+-- | 指定したルール集合で違反点を検出する。
+checkRules :: [SPCRule] -> SPCChartResult -> [SPCViolation]
+checkRules rs r =
+  [ SPCViolation (ruleName ru) (ruleNumber ru) i (spcChartName r)
+  | ru <- rs
+  , i  <- ruleCheck ru r
+  ]
+
+-- ---------------------------------------------------------------------------
+-- EWMA chart (Phase 11)
+-- ---------------------------------------------------------------------------
+
+-- | EWMA chart:
+--
+--   * 再帰: @z_i = λ x_i + (1 − λ) z_{i−1}@, @z_0 = μ₀@
+--   * 時変管理限界: @μ₀ ± L σ √(λ/(2−λ) · (1 − (1−λ)^{2i}))@
+--   * σ₀ ≤ 0 のとき xs の標本標準偏差で代用。
+--
+-- 入力検証: 0 < λ ≤ 1, L > 0, |xs| ≥ 1。
+fitEWMA :: Vector Double -> Double -> Double -> Double -> Double
+        -> Either Text [SPCChartResult]
+fitEWMA xs lam ll mu0 s0In
+  | V.null xs                = Left "fitSPC EWMA: empty input"
+  | not (lam > 0 && lam <= 1) = Left "fitSPC EWMA: λ must be in (0, 1]"
+  | ll <= 0                  = Left "fitSPC EWMA: L must be > 0"
+  | otherwise =
+      let !n     = V.length xs
+          !sigma = if s0In > 0 then s0In else sampleSD xs
+          zs     = V.scanl' (\z x -> lam * x + (1 - lam) * z) mu0 xs
+          -- scanl' includes initial → drop the seed
+          zsTail = V.tail zs
+          ucl = V.generate n (\i ->
+            let i1 = fromIntegral (i + 1) :: Double
+                factor = lam / (2 - lam) * (1 - (1 - lam) ** (2 * i1))
+            in mu0 + ll * sigma * sqrt factor)
+          lcl = V.generate n (\i ->
+            let i1 = fromIntegral (i + 1) :: Double
+                factor = lam / (2 - lam) * (1 - (1 - lam) ** (2 * i1))
+            in mu0 - ll * sigma * sqrt factor)
+      in Right [ SPCChartResult
+                   { spcPoints    = zsTail
+                   , spcCenter    = mu0
+                   , spcUCL       = ucl
+                   , spcLCL       = lcl
+                   , spcSigma     = sigma
+                   , spcChartName = "EWMA"
+                   } ]
+
+-- ---------------------------------------------------------------------------
+-- CUSUM chart (Phase 11)
+-- ---------------------------------------------------------------------------
+
+-- | CUSUM (両側) chart:
+--
+--   * @C⁺_i = max(0, x_i − (μ₀ + k σ) + C⁺_{i−1})@,  @C⁺_0 = 0@
+--   * @C⁻_i = max(0, (μ₀ − k σ) − x_i + C⁻_{i−1})@,  @C⁻_0 = 0@
+--   * 決定限界: @H = h σ@  (上側のみ、 下側は @−H@ として描画用に @-1 × C⁻@ を返す)
+--
+-- 返り値: [C⁺ chart, C⁻ chart]。 C⁻ chart は points が負方向に出るよう
+-- @spcPoints = − C⁻@ として表現し、 LCL = −H、 UCL = 0 とする。
+fitCUSUM :: Vector Double -> Double -> Double -> Double -> Double
+         -> Either Text [SPCChartResult]
+fitCUSUM xs mu0 s0In k h
+  | V.null xs = Left "fitSPC CUSUM: empty input"
+  | k < 0     = Left "fitSPC CUSUM: k must be ≥ 0"
+  | h <= 0    = Left "fitSPC CUSUM: h must be > 0"
+  | otherwise =
+      let !n     = V.length xs
+          !sigma = if s0In > 0 then s0In else sampleSD xs
+          kAbs   = k * sigma
+          hAbs   = h * sigma
+          cPos   = V.scanl' (\c x -> max 0 (c + (x - mu0) - kAbs)) 0 xs
+          cNeg   = V.scanl' (\c x -> max 0 (c + (mu0 - x) - kAbs)) 0 xs
+          cPosT  = V.tail cPos
+          cNegT  = V.tail cNeg
+          chartPos = SPCChartResult
+            { spcPoints    = cPosT
+            , spcCenter    = 0
+            , spcUCL       = vconst n hAbs
+            , spcLCL       = vconst n 0
+            , spcSigma     = sigma
+            , spcChartName = "CUSUM+"
+            }
+          chartNeg = SPCChartResult
+            { spcPoints    = V.map negate cNegT
+            , spcCenter    = 0
+            , spcUCL       = vconst n 0
+            , spcLCL       = vconst n (-hAbs)
+            , spcSigma     = sigma
+            , spcChartName = "CUSUM-"
+            }
+      in Right [chartPos, chartNeg]
+
+-- | 標本標準偏差 (n-1 補正)。 EWMA/CUSUM の σ₀ デフォルト用。
+sampleSD :: Vector Double -> Double
+sampleSD xs
+  | V.length xs < 2 = 0
+  | otherwise =
+      let m  = vmean xs
+          ss = V.sum (V.map (\x -> (x - m) ** 2) xs)
+      in sqrt (ss / fromIntegral (V.length xs - 1))
diff --git a/src/Hanalyze/Stat/Standardize.hs b/src/Hanalyze/Stat/Standardize.hs
--- a/src/Hanalyze/Stat/Standardize.hs
+++ b/src/Hanalyze/Stat/Standardize.hs
@@ -1,5 +1,11 @@
 {-# LANGUAGE OverloadedStrings #-}
--- | Input-feature standardization (z-score) utilities.
+-- |
+-- Module      : Hanalyze.Stat.Standardize
+-- Description : 入力特徴量の標準化 (z-score) ユーティリティ
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Input-feature standardization (z-score) utilities.
 --
 -- Use cases:
 --
diff --git a/src/Hanalyze/Stat/Summary.hs b/src/Hanalyze/Stat/Summary.hs
--- a/src/Hanalyze/Stat/Summary.hs
+++ b/src/Hanalyze/Stat/Summary.hs
@@ -1,6 +1,12 @@
 {-# LANGUAGE OverloadedStrings #-}
--- | Posterior-distribution summary statistics.
+-- |
+-- Module      : Hanalyze.Stat.Summary
+-- Description : 事後分布の要約統計 (ArviZ az.summary 相当)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
 --
+-- Posterior-distribution summary statistics.
+--
 -- Provides 'SummaryRow' and 'posteriorSummary', mirroring the columns of
 -- ArviZ's @az.summary@ (mean, sd, HDI, ESS, R-hat). Originally lived in
 -- @Hanalyze.Viz.MCMC@; moved to the statistics layer to decouple it from the
@@ -16,7 +22,7 @@
 
 import Data.Text (Text)
 import Hanalyze.MCMC.Core (Chain, chainVals)
-import Hanalyze.Stat.MCMC (ess, hdi, rhat)
+import Hanalyze.Stat.MCMC (essBulk, hdi, rhat)
 
 -- | One row of posterior summary statistics for a single parameter.
 data SummaryRow = SummaryRow
@@ -25,14 +31,15 @@
   , srSD    :: Double   -- ^ Posterior standard deviation.
   , srHdiLo :: Double   -- ^ Lower bound of the 94% HDI.
   , srHdiHi :: Double   -- ^ Upper bound of the 94% HDI.
-  , srEssV  :: Double   -- ^ Effective sample size.
+  , srEssV  :: Double   -- ^ Effective sample size (rank-normalized bulk ESS, ArviZ @ess_bulk@ 互換).
   , srRhat  :: Maybe Double  -- ^ Split-R-hat (only for multi-chain runs).
   } deriving (Show)
 
 -- | Compute posterior summaries for the named parameters across one or
 -- more chains. With a single chain @R-hat@ is 'Nothing'; with multiple
--- chains, mean / SD / HDI / ESS are computed on the pooled samples and
--- split-R-hat is computed across chains.
+-- chains, mean / SD / HDI are computed on the pooled samples, while ESS
+-- (bulk ESS, ArviZ @ess_bulk@ 互換 = Phase 100 で旧 pooled 'ess' から切替) and
+-- split-R-hat are computed across chains.
 posteriorSummary :: [Text] -> [Chain] -> [SummaryRow]
 posteriorSummary params chains =
   let multi = length chains > 1
@@ -46,7 +53,7 @@
                        else sqrt (sum [(x - mu) ^ (2::Int) | x <- allVals]
                                   / fromIntegral (n - 1))
             (lo, hi) = hdi 0.94 allVals
-            essV     = ess allVals
+            essV     = essBulk perChain
             rh       = if multi then rhat perChain else Nothing
         in SummaryRow p mu sd_ lo hi essV rh
   in map mkRow params
diff --git a/src/Hanalyze/Stat/Test.hs b/src/Hanalyze/Stat/Test.hs
--- a/src/Hanalyze/Stat/Test.hs
+++ b/src/Hanalyze/Stat/Test.hs
@@ -1,7 +1,13 @@
 {-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE BangPatterns #-}
--- | Hypothesis tests with a unified result format.
+-- |
+-- Module      : Hanalyze.Stat.Test
+-- Description : 統一結果形式を持つ仮説検定群 (パラメトリック/ノンパラ/適合度/正規性/分散)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
 --
+-- Hypothesis tests with a unified result format.
+--
 -- Most tests delegate to the @statistics@ package internals
 -- (@Statistics.Test.*@) and add hanalyze-specific niceties: a single
 -- 'TestResult' record, effect sizes, confidence intervals, and a
@@ -25,12 +31,16 @@
   , tTest1Sample
   , tTestPaired
   , tTestWelch
+  , tostWelch
   , tTestStudent
   , anovaOneWay
     -- * Non-parametric (location / rank)
   , mannWhitneyU
   , wilcoxonSignedRank
   , kruskalWallis
+  , friedmanTest
+  , MultiCompareResult (..)
+  , dunnTest
     -- * Goodness-of-fit / independence
   , chiSquareGOF
   , chiSquareIndep
@@ -42,6 +52,10 @@
   , leveneTest
   , bartlettTest
   , fTestVariance
+    -- * Multivariate (Phase 4.3、 request/140)
+  , hotellingsT2
+  , hotellingsT2TwoSample
+  , manova
   ) where
 
 import qualified Data.List                      as L
@@ -202,6 +216,58 @@
                     Nothing
                     t
 
+-- | TOST (Two One-Sided Tests) for equivalence using Welch's degrees of freedom.
+--
+-- Tests whether @|μ_A − μ_B| < Δ@ (i.e. the two groups are equivalent within
+-- the margin Δ). Implements two one-sided t-tests:
+--
+--   * Lower: @H₀: μ_A − μ_B ≤ −Δ@ vs @H₁: μ_A − μ_B > −Δ@
+--   * Upper: @H₀: μ_A − μ_B ≥ +Δ@ vs @H₁: μ_A − μ_B < +Δ@
+--
+-- @p_TOST = max(p_lower, p_upper)@. Equivalence is concluded at level α if
+-- @p_TOST < α@. The returned 'trCI' is the @(1 − 2α)@ confidence interval
+-- (here α = 0.05 → 90% CI), which is the standard TOST CI convention.
+tostWelch
+  :: LA.Vector Double  -- ^ Sample A
+  -> LA.Vector Double  -- ^ Sample B
+  -> Double            -- ^ Equivalence margin Δ (must be > 0)
+  -> TestResult
+tostWelch xs ys delta
+  | delta <= 0 =
+      noResultTRR "TOST (Welch)" TwoSided "delta must be > 0"
+  | LA.size xs < 2 || LA.size ys < 2 =
+      noResultTRR "TOST (Welch)" TwoSided "insufficient samples"
+  | otherwise =
+      let n1 = fromIntegral (LA.size xs) :: Double
+          n2 = fromIntegral (LA.size ys) :: Double
+          m1 = LA.sumElements xs / n1
+          m2 = LA.sumElements ys / n2
+          v1 = LA.sumElements ((xs - LA.scalar m1) ^ (2 :: Int)) / (n1 - 1)
+          v2 = LA.sumElements ((ys - LA.scalar m2) ^ (2 :: Int)) / (n2 - 1)
+          se = sqrt (v1 / n1 + v2 / n2)
+          diff = m1 - m2
+          df = (v1/n1 + v2/n2) ^ (2 :: Int)
+               / ((v1/n1)^(2::Int)/(n1-1) + (v2/n2)^(2::Int)/(n2-1))
+          tDist = StuT.studentT df
+          tLower = (diff - (-delta)) / se   -- want > 0 (upper-tail rejects H0_lower)
+          tUpper = (diff -   delta)  / se   -- want < 0 (lower-tail rejects H0_upper)
+          pLower = pFromT TRight tLower tDist
+          pUpper = pFromT TLeft  tUpper tDist
+          pTost  = max pLower pUpper
+          -- 90% CI (α = 0.05 each side)
+          tCrit = SD.quantile tDist 0.95
+          ci = (diff - tCrit * se, diff + tCrit * se)
+      in TestResult
+           { trMethod      = "TOST (Welch)"
+           , trStatistic   = min (abs tLower) (abs tUpper)
+           , trDf          = Just (df, Nothing)
+           , trPValue      = pTost
+           , trEffect      = Just ("Delta", delta)
+           , trCI          = Just ci
+           , trAlternative = TwoSided
+           , trNote        = Just "Equivalence demonstrated if p < alpha"
+           }
+
 -- | Student's two-sample t-test (assumes equal variance).
 tTestStudent
   :: LA.Vector Double
@@ -338,6 +404,118 @@
            , trNote        = Just "chi-square approximation"
            }
 
+-- | Friedman test — non-parametric two-way ANOVA without replication.
+--
+-- 入力: n × k 行列。 行 = block (被験者)、 列 = treatment。
+-- 各 block 内で treatment を順位付け (1..k) し、 列ごとの平均順位の分散から
+-- 検定統計量 Q を構成 (χ²(k-1) 近似)。
+friedmanTest :: LA.Matrix Double -> TestResult
+friedmanTest mat
+  | LA.rows mat < 2 || LA.cols mat < 2 =
+      noResultTRR "Friedman" TwoSided "need ≥ 2 blocks × ≥ 2 treatments"
+  | otherwise =
+      let n = LA.rows mat
+          k = LA.cols mat
+          nD = fromIntegral n :: Double
+          kD = fromIntegral k :: Double
+          -- 各行を順位化 (tie は midrank)
+          rankedRows =
+            [ midrank (LA.toList (LA.flatten (mat LA.? [i])))
+            | i <- [0 .. n - 1] ]
+          colSums = [ sum [ rankedRows !! i !! j | i <- [0 .. n - 1] ]
+                    | j <- [0 .. k - 1] ]
+          q = (12 / (nD * kD * (kD + 1)))
+              * sum [ s * s | s <- colSums ]
+              - 3 * nD * (kD + 1)
+          df = kD - 1
+          p  = SD.complCumulative (ChiSq.chiSquared (k - 1)) q
+      in TestResult
+           { trMethod      = "Friedman"
+           , trStatistic   = q
+           , trDf          = Just (df, Nothing)
+           , trPValue      = p
+           , trEffect      = Nothing
+           , trCI          = Nothing
+           , trAlternative = TwoSided
+           , trNote        = Just "chi-square approximation"
+           }
+
+-- | 多重比較の結果。 ペアごとの z 値と raw / adjusted p-value。
+data MultiCompareResult = MultiCompareResult
+  { mcrPairs :: ![(Int, Int)]
+  , mcrZ     :: ![Double]
+  , mcrPRaw  :: ![Double]
+  , mcrPAdj  :: ![Double]   -- Holm correction
+  } deriving (Show)
+
+-- | Dunn 多重比較 (Kruskal-Wallis post-hoc)。
+--   各グループの平均順位 R̄_i / R̄_j の差を SE で標準化:
+--
+--     z_{ij} = (R̄_i - R̄_j) / √( (N(N+1)/12) (1/n_i + 1/n_j) )
+--
+--   p_raw = 2 (1 - Φ(|z|))、 Holm 補正で族別 p_adj。
+dunnTest :: [LA.Vector Double] -> MultiCompareResult
+dunnTest groups =
+  let k      = length groups
+      sizes  = map LA.size groups
+      allRanks = midrank (concatMap LA.toList groups)
+      -- 各グループの平均順位
+      starts = scanl (+) 0 sizes
+      grpRanks = [ take (sizes !! i)
+                     (drop (starts !! i) allRanks)
+                 | i <- [0 .. k - 1] ]
+      meanR i = sum (grpRanks !! i) / fromIntegral (sizes !! i)
+      n      = sum sizes
+      nD     = fromIntegral n :: Double
+      se i j =
+        sqrt (nD * (nD + 1) / 12
+              * (1 / fromIntegral (sizes !! i) + 1 / fromIntegral (sizes !! j)))
+      pairs = [ (i, j) | i <- [0 .. k - 2], j <- [i + 1 .. k - 1] ]
+      zs    = [ (meanR i - meanR j) / se i j | (i, j) <- pairs ]
+      pRaw  = [ 2 * (1 - SD.cumulative Normal.standard (abs z)) | z <- zs ]
+      pAdj  = holmAdjust pRaw
+  in MultiCompareResult
+       { mcrPairs = pairs
+       , mcrZ     = zs
+       , mcrPRaw  = pRaw
+       , mcrPAdj  = pAdj
+       }
+
+-- | Holm-Bonferroni p-value adjustment.
+holmAdjust :: [Double] -> [Double]
+holmAdjust ps =
+  let m     = length ps
+      idx   = zip [0 ..] ps
+      sorted = L.sortBy (comparing snd) idx
+      stepwise = zipWith
+        (\rank (origIdx, p) ->
+            (origIdx, min 1 (p * fromIntegral (m - rank))))
+        [0 ..] sorted
+      -- monotone increasing enforcement
+      mono = scanl1 (\(_, prev) (i, p) -> (i, max prev p)) stepwise
+  in map snd (L.sortBy (comparing fst) mono)
+
+-- | midrank: 同順位は順位平均。 入力: list of values, 出力: 同 length の rank list。
+midrank :: [Double] -> [Double]
+midrank xs =
+  let indexed = zip [0 :: Int ..] xs
+      sorted  = L.sortBy (comparing snd) indexed
+      n       = length xs
+      -- グループ化: 同値を 1 グループに
+      go _ [] = []
+      go pos (g:gs) =
+        let len = length g
+            avgRank = fromIntegral (sum [pos .. pos + len - 1]) / fromIntegral len + 1
+        in [(i, avgRank) | (i, _) <- g] ++ go (pos + len) gs
+      grouped = groupBy (\(_, a) (_, b) -> a == b) sorted
+      ranked  = go 0 grouped
+  in map snd (L.sortBy (comparing fst) ranked)
+  where
+    groupBy _ [] = []
+    groupBy eq (x:xs') =
+      let (same, rest) = span (eq x) xs'
+      in (x : same) : groupBy eq rest
+
 -- ---------------------------------------------------------------------------
 -- Goodness-of-fit / independence
 -- ---------------------------------------------------------------------------
@@ -728,4 +906,217 @@
         Less     -> SD.cumulative Normal.standard z
         Greater  -> SD.complCumulative Normal.standard z
   in (wPlus, wMinus, p)
+
+-- ===========================================================================
+-- 多変量検定 (Phase 4.3、 request/140)
+-- ===========================================================================
+
+-- | 1 サンプル Hotelling T² 検定 (H_0: μ = μ_0)。
+--
+-- 入力:
+--
+--   * X (n × p): 各行が 1 観測の多変量ベクトル
+--   * μ_0 (長さ p): 仮説の平均
+--
+-- 統計量と分布:
+--
+-- > T² = n · (μ̂ − μ_0)ᵀ S⁻¹ (μ̂ − μ_0)
+-- > F  = ((n − p) / ((n − 1) · p)) · T²,    df = (p, n − p)
+--
+-- 戻り値の 'trStatistic' は F 値、 'trEffect' に @("T²", T²)@ を格納。
+hotellingsT2 :: LA.Matrix Double -> LA.Vector Double -> TestResult
+hotellingsT2 x mu0
+  | n < 2 = noResultTRR "Hotelling T² (1-sample)" TwoSided "need ≥ 2 observations"
+  | p < 1 = noResultTRR "Hotelling T² (1-sample)" TwoSided "need ≥ 1 variable"
+  | LA.size mu0 /= p =
+      noResultTRR "Hotelling T² (1-sample)" TwoSided "μ_0 length mismatch"
+  | n <= p =
+      noResultTRR "Hotelling T² (1-sample)" TwoSided "need n > p (covariance singular)"
+  | otherwise =
+      let nD     = fromIntegral n :: Double
+          pD     = fromIntegral p :: Double
+          xMean  = columnMeans x
+          diff   = xMean - mu0
+          sCov   = sampleCovariance x
+          maybeT2 = do
+            sInv <- LA.linearSolve sCov (LA.asColumn diff)
+            return $! nD * LA.sumElements (diff * LA.flatten sInv)
+      in case maybeT2 of
+           Nothing -> noResultTRR "Hotelling T² (1-sample)" TwoSided
+                                  "covariance matrix singular"
+           Just t2 ->
+             let df1   = pD
+                 df2   = nD - pD
+                 fStat = (df2 / ((nD - 1) * pD)) * t2
+                 pVal  = SD.complCumulative
+                           (FDist.fDistribution (round df1) (round df2))
+                           fStat
+             in TestResult
+                  { trMethod      = "Hotelling T² (1-sample)"
+                  , trStatistic   = fStat
+                  , trDf          = Just (df1, Just df2)
+                  , trPValue      = pVal
+                  , trEffect      = Just ("T²", t2)
+                  , trCI          = Nothing
+                  , trAlternative = TwoSided
+                  , trNote        = Nothing
+                  }
+  where
+    n = LA.rows x
+    p = LA.cols x
+
+-- | 2 サンプル Hotelling T² 検定 (等分散仮定、 H_0: μ_X = μ_Y)。
+--
+-- 入力: X (n_1 × p)、 Y (n_2 × p)。 両標本の次元 p は一致が必要。
+--
+-- 統計量:
+--
+-- > T² = (n_1·n_2 / (n_1+n_2)) · (μ̂_1 − μ̂_2)ᵀ S_p⁻¹ (μ̂_1 − μ̂_2)
+-- > F  = ((n_1+n_2−p−1) / ((n_1+n_2−2)·p)) · T²,  df = (p, n_1+n_2−p−1)
+hotellingsT2TwoSample :: LA.Matrix Double -> LA.Matrix Double -> TestResult
+hotellingsT2TwoSample x y
+  | n1 < 2 || n2 < 2 =
+      noResultTRR "Hotelling T² (2-sample)" TwoSided "each group needs ≥ 2 observations"
+  | LA.cols x /= LA.cols y =
+      noResultTRR "Hotelling T² (2-sample)" TwoSided "dimension mismatch (p_X ≠ p_Y)"
+  | n1 + n2 - p - 1 <= 0 =
+      noResultTRR "Hotelling T² (2-sample)" TwoSided "need n_1 + n_2 > p + 1"
+  | otherwise =
+      let n1D   = fromIntegral n1 :: Double
+          n2D   = fromIntegral n2 :: Double
+          pD    = fromIntegral p :: Double
+          m1    = columnMeans x
+          m2    = columnMeans y
+          s1    = sampleCovariance x
+          s2    = sampleCovariance y
+          sP    = LA.scale ((n1D - 1) / (n1D + n2D - 2)) s1
+                + LA.scale ((n2D - 1) / (n1D + n2D - 2)) s2
+          diff  = m1 - m2
+          maybeT2 = do
+            sInv <- LA.linearSolve sP (LA.asColumn diff)
+            return $! (n1D * n2D / (n1D + n2D))
+                    * LA.sumElements (diff * LA.flatten sInv)
+      in case maybeT2 of
+           Nothing -> noResultTRR "Hotelling T² (2-sample)" TwoSided
+                                  "pooled covariance singular"
+           Just t2 ->
+             let df1   = pD
+                 df2   = n1D + n2D - pD - 1
+                 fStat = (df2 / ((n1D + n2D - 2) * pD)) * t2
+                 pVal  = SD.complCumulative
+                           (FDist.fDistribution (round df1) (round df2))
+                           fStat
+             in TestResult
+                  { trMethod      = "Hotelling T² (2-sample)"
+                  , trStatistic   = fStat
+                  , trDf          = Just (df1, Just df2)
+                  , trPValue      = pVal
+                  , trEffect      = Just ("T²", t2)
+                  , trCI          = Nothing
+                  , trAlternative = TwoSided
+                  , trNote        = Nothing
+                  }
+  where
+    n1 = LA.rows x
+    n2 = LA.rows y
+    p  = LA.cols x
+
+-- | 1 元配置 MANOVA (H_0: 全群の μ が等しい)。
+--
+-- 入力: 各群の観測行列リスト @[X_1, X_2, ..., X_k]@、 各 X_i は @n_i × p@。
+--
+-- 統計量: Wilks' Λ = det(W) / det(W + B)。
+--   B = between-group SSCP、 W = within-group SSCP。
+-- p-value は Rao の F 近似:
+--
+-- > s = sqrt((p²·q² − 4) / (p² + q² − 5))     (q = k − 1)
+-- > m = N − 1 − (p + q + 1) / 2
+-- > df1 = p · q,   df2 = m·s − (p·q − 2) / 2
+-- > F   = ((1 − Λ^(1/s)) / Λ^(1/s)) · (df2 / df1)
+--
+-- 'trStatistic' に F 値、 'trEffect' に @("Wilks Λ", Λ)@。
+manova :: [LA.Matrix Double] -> TestResult
+manova groups
+  | k < 2 = noResultTRR "MANOVA (one-way)" TwoSided "need ≥ 2 groups"
+  | any (\g -> LA.rows g < 2) groups =
+      noResultTRR "MANOVA (one-way)" TwoSided "each group needs ≥ 2 observations"
+  | not (all ((== p) . LA.cols) groups) =
+      noResultTRR "MANOVA (one-way)" TwoSided "dimension mismatch across groups"
+  | otherwise =
+      let nis      = map (fromIntegral . LA.rows) groups :: [Double]
+          totalN   = sum nis
+          pD       = fromIntegral p :: Double
+          q        = fromIntegral (k - 1) :: Double
+          groupMs  = map columnMeans groups
+          allMean  = LA.scale (1 / totalN)
+                     (foldr1 (+) (zipWith LA.scale nis groupMs))
+          mkOuter v = LA.outer v v
+          bMat     = foldr1 (+)
+                       [ LA.scale ni (mkOuter (m - allMean))
+                       | (ni, m) <- zip nis groupMs ]
+          wMat     = foldr1 (+) [ withinSSCP g (groupMs !! i)
+                                | (i, g) <- zip [0 ..] groups ]
+          detW     = LA.det wMat
+          detTot   = LA.det (wMat + bMat)
+      in if detTot == 0
+           then noResultTRR "MANOVA (one-way)" TwoSided
+                            "W+B is singular"
+           else
+             let wilks = detW / detTot
+                 -- Rao F approximation
+                 numS  = pD*pD * q*q - 4
+                 denS  = pD*pD + q*q - 5
+                 s | denS > 0 && numS > 0 = sqrt (numS / denS)
+                   | otherwise            = 1
+                 mAdj  = totalN - 1 - (pD + q + 1) / 2
+                 df1   = pD * q
+                 df2   = mAdj * s - (pD * q - 2) / 2
+                 lam1s = wilks ** (1 / s)
+                 fStat = ((1 - lam1s) / lam1s) * (df2 / df1)
+                 df1i  = max 1 (round df1)
+                 df2i  = max 1 (round df2)
+                 pVal  = if df2 > 0 && fStat > 0
+                           then SD.complCumulative
+                                  (FDist.fDistribution df1i df2i) fStat
+                           else 1.0
+             in TestResult
+                  { trMethod      = "MANOVA (one-way, Wilks' Λ)"
+                  , trStatistic   = fStat
+                  , trDf          = Just (df1, Just df2)
+                  , trPValue      = pVal
+                  , trEffect      = Just ("Wilks Λ", wilks)
+                  , trCI          = Nothing
+                  , trAlternative = TwoSided
+                  , trNote        = Nothing
+                  }
+  where
+    k = length groups
+    p = if null groups then 0 else LA.cols (head groups)
+
+-- ---------------------------------------------------------------------------
+-- 多変量 helper
+-- ---------------------------------------------------------------------------
+
+-- | 列ごとの平均 (= サンプル平均ベクトル)。
+columnMeans :: LA.Matrix Double -> LA.Vector Double
+columnMeans m =
+  let n = fromIntegral (LA.rows m) :: Double
+  in LA.scale (1 / n) (LA.fromList [ LA.sumElements (m LA.¿ [j])
+                                    | j <- [0 .. LA.cols m - 1] ])
+
+-- | 標本共分散行列 (n - 1 分母)。
+sampleCovariance :: LA.Matrix Double -> LA.Matrix Double
+sampleCovariance m =
+  let n      = fromIntegral (LA.rows m) :: Double
+      means  = columnMeans m
+      meanRow = LA.asRow means
+      centered = m - LA.fromRows (replicate (LA.rows m) means)
+      _ = meanRow  -- silence unused warning
+  in LA.scale (1 / (n - 1)) (LA.tr centered LA.<> centered)
+
+-- | 群内 SSCP: Σ (x_{ij} − x̄_i)(x_{ij} − x̄_i)ᵀ
+withinSSCP :: LA.Matrix Double -> LA.Vector Double -> LA.Matrix Double
+withinSSCP g groupMean =
+  let centered = g - LA.fromRows (replicate (LA.rows g) groupMean)
+  in LA.tr centered LA.<> centered
 
diff --git a/src/Hanalyze/Stat/VI.hs b/src/Hanalyze/Stat/VI.hs
--- a/src/Hanalyze/Stat/VI.hs
+++ b/src/Hanalyze/Stat/VI.hs
@@ -1,6 +1,12 @@
 {-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE RankNTypes #-}
--- | Variational inference (ADVI — Automatic Differentiation Variational
+-- |
+-- Module      : Hanalyze.Stat.VI
+-- Description : 変分推論 (ADVI: Automatic Differentiation Variational Inference)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- Variational inference (ADVI — Automatic Differentiation Variational
 -- Inference).
 --
 -- Implements the mean-field normal VI of Kucukelbir et al. (2017). Uses
@@ -31,11 +37,13 @@
   ( VIConfig (..)
   , defaultVIConfig
   , VIResult (..)
+  , VIMethod (..)
   , advi
+  , fullRankAdvi
   ) where
 
 import Control.DeepSeq (force)
-import Control.Monad (forM, forM_, replicateM)
+import Control.Monad (forM, forM_, replicateM, when)
 import Data.IORef
 import qualified Data.Map.Strict as Map
 import System.Random.MWC (GenIO)
@@ -80,14 +88,21 @@
 -- 結果
 -- ---------------------------------------------------------------------------
 
--- | ADVI result.
+-- | VI 近似法。 mean-field (`advi`) と full-rank (`fullRankAdvi`) を区別する。
+data VIMethod = MeanField | FullRank
+  deriving (Show, Eq)
+
+-- | ADVI result. mean-field と full-rank の両方が返す。 full-rank では
+-- @viCovU@ に @n×n@ 下三角 Cholesky 因子 @L@ (unconstrained 空間) が入る。
 data VIResult = VIResult
-  { viPostMeans   :: Params    -- ^ Posterior means (constrained space, sample mean).
-  , viPostSDs     :: Params    -- ^ Posterior SDs   (constrained space).
-  , viMuU         :: [Double]  -- ^ Variational mean @μ@ (unconstrained).
-  , viSigmaU      :: [Double]  -- ^ Variational SD   @σ@ (unconstrained).
-  , viElboHistory :: [Double]  -- ^ ELBO trajectory (for convergence inspection).
-  , viDraws       :: [Params]  -- ^ Posterior draws in the constrained space (length 'viNumDraws').
+  { viPostMeans   :: Params           -- ^ Posterior means (constrained space, sample mean).
+  , viPostSDs     :: Params           -- ^ Posterior SDs   (constrained space).
+  , viMuU         :: [Double]         -- ^ Variational mean @μ@ (unconstrained).
+  , viSigmaU      :: [Double]         -- ^ Variational SD   @σ@ (unconstrained、 mean-field の場合は対角要素、 full-rank なら L_ii)。
+  , viCovU        :: Maybe [[Double]] -- ^ Full-rank ADVI: 下三角 Cholesky 因子 @L@ ([row][col])、 @LLᵀ = Σ@。 mean-field では @Nothing@。
+  , viMethod      :: VIMethod         -- ^ どちらの近似法か。
+  , viElboHistory :: [Double]         -- ^ ELBO trajectory (for convergence inspection).
+  , viDraws       :: [Params]         -- ^ Posterior draws in the constrained space (length 'viNumDraws').
   } deriving (Show)
 
 -- ---------------------------------------------------------------------------
@@ -216,9 +231,192 @@
     , viPostSDs     = postSDs
     , viMuU         = muFinal
     , viSigmaU      = sigmaFinal
+    , viCovU        = Nothing
+    , viMethod      = MeanField
     , viElboHistory = elboHistory
     , viDraws       = draws
     }
+
+-- ---------------------------------------------------------------------------
+-- Full-rank ADVI (Phase 37-A5)
+-- ---------------------------------------------------------------------------
+
+-- | Full-rank ADVI: 共分散を含めた変分近似 @q(u) = N(μ, LLᵀ)@ を最適化する。
+--
+-- 平均場 'advi' との違い:
+--
+-- * 変分パラメータは @μ@ (n-vector) と @L@ (下三角 n×n、 対角は log で
+--   parameterize して正値保証)
+-- * @u = μ + L·ε@ の reparameterization で勾配を取り、 ELBO の補正項は
+--   @log|L| = Σ log L_ii = Σ ω_i@
+-- * 推定共分散 @Σ = LLᵀ@ は @viCovU@ に入る (下三角 @L@ そのもの)
+--
+-- 平均場と比べて posterior の相関を捉えられるが、 パラメタ数 @O(n²)@、
+-- 計算量も @O(n² S)@ per iteration なので n が大きいモデルでは重い。
+-- 平均場が「SD を過小評価」 する hierarchical model で特に有用。
+fullRankAdvi :: ModelP r -> VIConfig -> Params -> GenIO -> IO VIResult
+fullRankAdvi model cfg initP gen = do
+  let names      = sampleNames model
+      transforms = getTransforms model
+      n          = length names
+      initU      = paramsToVec names (toUnconstrainedParams transforms initP)
+
+      logJ :: [Double] -> Double
+      logJ uVec = logJointU model transforms (vecToParams names uVec)
+
+      h = viGradStep cfg
+      numGrad :: [Double] -> [Double]
+      numGrad uVec =
+        [ let ui  = uVec !! i
+              lp  = logJ (replaceAt i (ui + h) uVec)
+              lm  = logJ (replaceAt i (ui - h) uVec)
+              raw = (lp - lm) / (2 * h)
+          in if isNaN raw || isInfinite raw then 0 else raw
+        | i <- [0 .. n-1]
+        ]
+
+  -- 変分パラメータ: μ (n-vector)、 ω (n-vector、 ω_i = log L_ii)、
+  -- offdiag (下三角の i > j 要素を行優先で並べた長さ n(n-1)/2 のリスト)
+  muRef    <- newIORef initU
+  omegaRef <- newIORef (replicate n 0.0)             -- L_ii = exp(0) = 1
+  let nOff = n * (n - 1) `div` 2
+  offRef   <- newIORef (replicate nOff 0.0)          -- off-diag は 0 で初期化
+
+  -- Adam モーメント (μ / ω / offdiag それぞれ)
+  m1MuRef <- newIORef (replicate n 0.0)
+  m2MuRef <- newIORef (replicate n 0.0)
+  m1OmRef <- newIORef (replicate n 0.0)
+  m2OmRef <- newIORef (replicate n 0.0)
+  m1OffRef <- newIORef (replicate nOff 0.0)
+  m2OffRef <- newIORef (replicate nOff 0.0)
+
+  elboRef <- newIORef []
+
+  let b1    = viBeta1        cfg
+      b2    = viBeta2        cfg
+      eps_  = viEpsilon      cfg
+      alpha = viLearningRate cfg
+      sNum  = viSamples      cfg
+
+  forM_ [1 .. viIterations cfg] $ \t -> do
+    mu     <- readIORef muRef
+    omega  <- readIORef omegaRef
+    offdg  <- readIORef offRef
+    let lMat  = buildL n omega offdg                  -- 下三角 L
+
+    -- MC 勾配
+    mcResults <- forM [1 .. sNum] $ \_ -> do
+      epsilons <- replicateM n (standard gen)
+      let uVec = vecAdd mu (matVec lMat epsilons)
+          lj   = logJ uVec
+          gU   = numGrad uVec                          -- ∂lp/∂u_i, length n
+          dMu  = gU                                    -- ∂ELBO/∂μ_i = gU_i
+          -- ∂ELBO/∂ω_i = ε_i × L_ii × gU_i + 1  (entropy +1)
+          dOm  = [ epsilons !! i
+                 * (lMat !! i !! i)
+                 * (gU !! i) + 1
+                 | i <- [0 .. n-1] ]
+          -- ∂ELBO/∂L_ij (i > j) = ε_j × gU_i  (no entropy contribution)
+          dOff = [ (epsilons !! j) * (gU !! i)
+                 | i <- [1 .. n-1], j <- [0 .. i-1] ]
+      return (lj, dMu, dOm, dOff)
+
+    let sD    = fromIntegral sNum :: Double
+        !ljMC = sum (map (\(l,_,_,_) -> l) mcResults) / sD
+        -- ELBO = E[logJointU] + log|L| + n/2 (1 + log 2π)
+        !elboV = ljMC + sum omega + fromIntegral n * 0.5 * (1 + log (2*pi))
+        !gMu   = force (map (/ sD) $ foldr1 (zipWith (+))
+                                     (map (\(_,g,_,_) -> g) mcResults))
+        !gOm   = force (map (/ sD) $ foldr1 (zipWith (+))
+                                     (map (\(_,_,g,_) -> g) mcResults))
+        !gOff  = if nOff == 0
+                   then []
+                   else force (map (/ sD) $ foldr1 (zipWith (+))
+                                            (map (\(_,_,_,g) -> g) mcResults))
+
+    modifyIORef' elboRef (elboV :)
+
+    -- Adam で μ
+    m1Mu <- readIORef m1MuRef
+    m2Mu <- readIORef m2MuRef
+    let (m1Mu', m2Mu', dxMu) = adamStep b1 b2 eps_ alpha t m1Mu m2Mu gMu
+    writeIORef m1MuRef (force m1Mu')
+    writeIORef m2MuRef (force m2Mu')
+    writeIORef muRef   (force (zipWith (+) mu dxMu))
+
+    -- Adam で ω
+    m1Om <- readIORef m1OmRef
+    m2Om <- readIORef m2OmRef
+    let (m1Om', m2Om', dxOm) = adamStep b1 b2 eps_ alpha t m1Om m2Om gOm
+    writeIORef m1OmRef (force m1Om')
+    writeIORef m2OmRef (force m2Om')
+    writeIORef omegaRef (force (zipWith (+) omega dxOm))
+
+    -- Adam で off-diagonal (n=1 のときは空)
+    when (nOff > 0) $ do
+      m1Off <- readIORef m1OffRef
+      m2Off <- readIORef m2OffRef
+      let (m1Off', m2Off', dxOff) = adamStep b1 b2 eps_ alpha t m1Off m2Off gOff
+      writeIORef m1OffRef (force m1Off')
+      writeIORef m2OffRef (force m2Off')
+      writeIORef offRef   (force (zipWith (+) offdg dxOff))
+
+  -- 収束後
+  muFinal    <- readIORef muRef
+  omegaFinal <- readIORef omegaRef
+  offFinal   <- readIORef offRef
+  let lFinal   = buildL n omegaFinal offFinal
+      lDiag    = [ lFinal !! i !! i | i <- [0 .. n-1] ]
+
+  draws <- forM [1 .. viNumDraws cfg] $ \_ -> do
+    epsilons <- replicateM n (standard gen)
+    let uVec = vecAdd muFinal (matVec lFinal epsilons)
+    return (fromUnconstrainedParams transforms (vecToParams names uVec))
+
+  let nD        = fromIntegral (viNumDraws cfg) :: Double
+      getVals p = map (Map.findWithDefault 0 p) draws
+      muP     p = let vs = getVals p in sum vs / nD
+      sdP     p = let vs = getVals p
+                      mu = muP p
+                  in sqrt (sum (map (\v -> (v - mu) ^ (2::Int)) vs) / nD)
+      postMeans = Map.fromList [(nm, muP nm) | nm <- names]
+      postSDs   = Map.fromList [(nm, sdP nm) | nm <- names]
+
+  elboHistory <- fmap reverse (readIORef elboRef)
+
+  return VIResult
+    { viPostMeans   = postMeans
+    , viPostSDs     = postSDs
+    , viMuU         = muFinal
+    , viSigmaU      = lDiag
+    , viCovU        = Just lFinal
+    , viMethod      = FullRank
+    , viElboHistory = elboHistory
+    , viDraws       = draws
+    }
+
+-- | 下三角 L を構築。 @omega@ は対角 (L_ii = exp ω_i)、
+-- @offdg@ は (i, j) for i > j を行優先 (i 昇順、 同 i 内で j 昇順) で
+-- 並べたリスト。 結果は @n × n@ 行列、 上三角は 0。
+buildL :: Int -> [Double] -> [Double] -> [[Double]]
+buildL n omega offdg =
+  let -- offdg をインデックス map に変換
+      offMap = Map.fromList (zip pairs offdg)
+      pairs  = [ (i, j) | i <- [1 .. n-1], j <- [0 .. i-1] ]
+      diag i = exp (omega !! i)
+      row i  = [ if j < i  then Map.findWithDefault 0 (i, j) offMap
+                 else if j == i then diag i
+                 else 0
+               | j <- [0 .. n-1] ]
+  in [ row i | i <- [0 .. n-1] ]
+
+-- | 行列・ベクトル積 @y = M·x@。
+matVec :: [[Double]] -> [Double] -> [Double]
+matVec mat x = [ sum (zipWith (*) row x) | row <- mat ]
+
+-- | ベクトル足し算。
+vecAdd :: [Double] -> [Double] -> [Double]
+vecAdd = zipWith (+)
 
 -- ---------------------------------------------------------------------------
 -- 補助関数
diff --git a/src/Hanalyze/Viz/AnalysisReport.hs b/src/Hanalyze/Viz/AnalysisReport.hs
--- a/src/Hanalyze/Viz/AnalysisReport.hs
+++ b/src/Hanalyze/Viz/AnalysisReport.hs
@@ -1,3 +1,9 @@
+-- |
+-- Module      : Hanalyze.Viz.AnalysisReport
+-- Description : 【非推奨】 LM/GLM/GLMM/GP/HBM 専用の sum-type ベース HTML レポート (ReportBuilder に移行済)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
 {-# LANGUAGE OverloadedStrings #-}
 -- | __DEPRECATED__ — sum-type-based HTML report dedicated to
 -- LM / GLM / GLMM / GP / HBM (~2000 lines). Superseded by
@@ -54,7 +60,7 @@
 import qualified Data.Vector as V
 import qualified Numeric.LinearAlgebra as LA
 
-import qualified DataFrame                    as DX
+import qualified DataFrame.Operations.Core     as DX
 import qualified DataFrame.Internal.DataFrame as DXD
 import Hanalyze.DataIO.Convert (getDoubleVec, getTextVec)
 import Hanalyze.MCMC.Core    (Chain, chainSamples, chainAccepted, chainTotal)
@@ -966,6 +972,14 @@
       [ "    <p><b>Periodic カーネル</b></p>"
       , "    <div class=\"formula\">k(x, x') = σ²_f · exp( −2 sin²(π|x−x'|/p) / ℓ² )</div>"
       ]
+    kernelDesc Linear =
+      [ "    <p><b>Linear (内積) カーネル</b></p>"
+      , "    <div class=\"formula\">k(x, x') = σ²_f · (x·x')</div>"
+      ]
+    kernelDesc (Poly d) =
+      [ "    <p><b>Polynomial カーネル (次数 " <> T.pack (show d) <> ")</b></p>"
+      , "    <div class=\"formula\">k(x, x') = (γ·(x·x') + 1)^" <> T.pack (show d) <> " &nbsp; (γ = 1/(2ℓ²))</div>"
+      ]
 
 lmAppendix :: Text -> Text
 lmAppendix link = T.unlines
@@ -1270,6 +1284,8 @@
 jsKernelId RBF      = "rbf"
 jsKernelId Matern52 = "matern52"
 jsKernelId Periodic = "periodic"
+jsKernelId Linear   = "linear"
+jsKernelId (Poly _) = "poly"
 
 jsGPParams :: Kernel -> GPParams -> Text
 jsGPParams ker p =
diff --git a/src/Hanalyze/Viz/Assets.hs b/src/Hanalyze/Viz/Assets.hs
--- a/src/Hanalyze/Viz/Assets.hs
+++ b/src/Hanalyze/Viz/Assets.hs
@@ -1,3 +1,9 @@
+-- |
+-- Module      : Hanalyze.Viz.Assets
+-- Description : オフライン HTML レポート用に Vega/Vega-Lite/Vega-Embed の JS 本体を同梱する自動生成モジュール
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
 {-# LANGUAGE OverloadedStrings #-}
 -- Auto-generated from assets/ — do not edit by hand.
 -- Bundles Vega v5, Vega-Lite v5, Vega-Embed v6 for offline HTML reports.
diff --git a/src/Hanalyze/Viz/Bar.hs b/src/Hanalyze/Viz/Bar.hs
--- a/src/Hanalyze/Viz/Bar.hs
+++ b/src/Hanalyze/Viz/Bar.hs
@@ -1,3 +1,9 @@
+-- |
+-- Module      : Hanalyze.Viz.Bar
+-- Description : 棒グラフ (縦・横・積み上げ・グループ化) の可視化
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
 {-# LANGUAGE OverloadedStrings #-}
 -- | Bar-chart visualizations.
 --
diff --git a/src/Hanalyze/Viz/Core.hs b/src/Hanalyze/Viz/Core.hs
--- a/src/Hanalyze/Viz/Core.hs
+++ b/src/Hanalyze/Viz/Core.hs
@@ -1,3 +1,9 @@
+-- |
+-- Module      : Hanalyze.Viz.Core
+-- Description : 全 Viz.* モジュール共有の I/O ヘルパ (writeSpec / openInBrowser / vlJson 等)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
 {-# LANGUAGE OverloadedStrings #-}
 -- | Core visualization I/O helpers shared by every @Viz.*@ module.
 --
diff --git a/src/Hanalyze/Viz/GP.hs b/src/Hanalyze/Viz/GP.hs
--- a/src/Hanalyze/Viz/GP.hs
+++ b/src/Hanalyze/Viz/GP.hs
@@ -1,3 +1,9 @@
+-- |
+-- Module      : Hanalyze.Viz.GP
+-- Description : ガウス過程回帰結果 (訓練データ・事後平均・信用区間) の可視化
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
 {-# LANGUAGE OverloadedStrings #-}
 -- | Visualization of Gaussian-process regression results.
 --
diff --git a/src/Hanalyze/Viz/GPReport.hs b/src/Hanalyze/Viz/GPReport.hs
--- a/src/Hanalyze/Viz/GPReport.hs
+++ b/src/Hanalyze/Viz/GPReport.hs
@@ -1,3 +1,9 @@
+-- |
+-- Module      : Hanalyze.Viz.GPReport
+-- Description : GP 回帰用の統合 HTML レポート (データ特性・モデル比較・回帰結果・対話予測・付録)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
 {-# LANGUAGE OverloadedStrings #-}
 -- | Comprehensive HTML report for GP regression.
 --
@@ -540,6 +546,16 @@
       , "    <div class=\"formula\">k(x, x') = σ²_f · exp( −2 sin²(π|x−x'|/p) / ℓ² )</div>"
       , "    <p>周期 p の周期的パターンを持つ関数をモデル化します。</p>"
       ]
+    kernelDesc Linear =
+      [ "    <p><b>Linear (内積) カーネル</b></p>"
+      , "    <div class=\"formula\">k(x, x') = σ²_f · (x·x')</div>"
+      , "    <p>線形な関数をモデル化する非定常カーネル。</p>"
+      ]
+    kernelDesc (Poly d) =
+      [ "    <p><b>Polynomial カーネル (次数 " <> T.pack (show d) <> ")</b></p>"
+      , "    <div class=\"formula\">k(x, x') = (γ·(x·x') + 1)^" <> T.pack (show d) <> " &nbsp; (γ = 1/(2ℓ²))</div>"
+      , "    <p>多項式の決定境界をモデル化する非定常カーネル。</p>"
+      ]
 
 appendixHyperparams :: Text
 appendixHyperparams = T.unlines
@@ -687,6 +703,8 @@
 jsKernelId RBF      = "rbf"
 jsKernelId Matern52 = "matern52"
 jsKernelId Periodic = "periodic"
+jsKernelId Linear   = "linear"
+jsKernelId (Poly _) = "poly"
 
 jsParams :: Kernel -> GPParams -> Text
 jsParams ker p = "{ell:" <> fmtJS (gpLengthScale p)
diff --git a/src/Hanalyze/Viz/Histogram.hs b/src/Hanalyze/Viz/Histogram.hs
--- a/src/Hanalyze/Viz/Histogram.hs
+++ b/src/Hanalyze/Viz/Histogram.hs
@@ -1,3 +1,9 @@
+-- |
+-- Module      : Hanalyze.Viz.Histogram
+-- Description : ヒストグラム描画 (基本ヒストグラム + 理論分布密度の重ね描き)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
 {-# LANGUAGE OverloadedStrings #-}
 -- | Histogram plotting.
 --
diff --git a/src/Hanalyze/Viz/MCMC.hs b/src/Hanalyze/Viz/MCMC.hs
--- a/src/Hanalyze/Viz/MCMC.hs
+++ b/src/Hanalyze/Viz/MCMC.hs
@@ -1,3 +1,9 @@
+-- |
+-- Module      : Hanalyze.Viz.MCMC
+-- Description : Vega-Lite ベースの MCMC 診断プロット (トレース・事後密度・自己相関・forest/energy 等)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
 {-# LANGUAGE OverloadedStrings #-}
 -- | MCMC diagnostic plots (built on Vega-Lite).
 --
@@ -37,7 +43,7 @@
 import Graphics.Vega.VegaLite
 
 import Hanalyze.MCMC.Core  (Chain (..), chainVals)
-import Hanalyze.Stat.MCMC    (autocorr, hdi, kde, bfmi)
+import Hanalyze.Stat.MCMC    (autocorr, hdi, kde, bfmi, rankHist)
 import Hanalyze.Stat.Summary (SummaryRow (..), posteriorSummary)
 import Data.List   (sortBy)
 import Data.Maybe (fromMaybe)
@@ -578,7 +584,7 @@
       header = T.unlines
         [ "    <tr>"
         , "      <th>Parameter</th><th>Mean</th><th>SD</th>"
-        , "      <th>HDI 3%</th><th>HDI 97%</th><th>ESS</th>" <> rhatHeader
+        , "      <th>HDI 3%</th><th>HDI 97%</th><th>ESS (bulk)</th>" <> rhatHeader
         , "    </tr>"
         ]
   in T.unlines
@@ -615,27 +621,27 @@
   let rows  = posteriorSummary params chains
       multi = any (\r -> case srRhat r of Just _ -> True; _ -> False) rows
       hdr | multi     =
-              printf "%-12s  %10s  %10s  %10s  %10s  %6s  %6s\n"
+              printf "%-12s  %10s  %10s  %10s  %10s  %8s  %6s\n"
                      ("Parameter" :: String) ("mean" :: String) ("sd" :: String)
                      ("hdi_3%" :: String) ("hdi_97%" :: String)
-                     ("ess" :: String) ("r_hat" :: String)
+                     ("ess_bulk" :: String) ("r_hat" :: String)
           | otherwise =
-              printf "%-12s  %10s  %10s  %10s  %10s  %6s\n"
+              printf "%-12s  %10s  %10s  %10s  %10s  %8s\n"
                      ("Parameter" :: String) ("mean" :: String) ("sd" :: String)
                      ("hdi_3%" :: String) ("hdi_97%" :: String)
-                     ("ess" :: String)
+                     ("ess_bulk" :: String)
       pr r
         | multi =
             let rh = case srRhat r of Just v -> printf "%.3f" v; Nothing -> "—" :: String
-            in printf "%-12s  %10.4f  %10.4f  %10.4f  %10.4f  %6d  %6s\n"
+            in printf "%-12s  %10.4f  %10.4f  %10.4f  %10.4f  %8d  %6s\n"
                   (T.unpack (srName r)) (srMean r) (srSD r)
                   (srHdiLo r) (srHdiHi r) (round (srEssV r) :: Int) rh
         | otherwise =
-            printf "%-12s  %10.4f  %10.4f  %10.4f  %10.4f  %6d\n"
+            printf "%-12s  %10.4f  %10.4f  %10.4f  %10.4f  %8d\n"
                   (T.unpack (srName r)) (srMean r) (srSD r)
                   (srHdiLo r) (srHdiHi r) (round (srEssV r) :: Int)
   hdr
-  putStrLn (replicate (if multi then 79 else 72) '-')
+  putStrLn (replicate (if multi then 81 else 74) '-')
   mapM_ pr rows
 
 -- ---------------------------------------------------------------------------
@@ -656,31 +662,14 @@
     nChains = length chains
     panel pname =
       let perChain  = map (chainVals pname) chains
-          flat      = [ (cid, v)
-                      | (cid, vs) <- zip [(1 :: Int) ..] perChain
-                      , v <- vs ]
-          n         = length flat
-          -- 順位 (1..n) を value 昇順に割り当てる
-          indexed   = zip [(0 :: Int) ..] flat
-          sorted    = sortBy (\(_, (_, a)) (_, (_, b)) -> compare a b) indexed
-          ranked    = zipWith (\rk (origIdx, _) -> (origIdx, rk))
-                              [(1 :: Int) ..] sorted
-          rankBy    = sortBy (\(a,_) (b,_) -> compare a b) ranked
-          ranks     = map snd rankBy   -- 元 (chain, value) 順序の rank 列
-          chainSeq  = map fst flat
-          binSize   = max 1 (n `div` nBins)
-          binOf r   = min (nBins - 1) ((r - 1) `div` binSize)
-          counts    = [ ((cid, b), 1 :: Int)
-                      | (cid, r) <- zip chainSeq ranks
-                      , let b = binOf r ]
-          -- (chain, bin) ごとに集計
-          tally     = groupAndCount counts
-          xs        = [ fromIntegral b :: Double
-                      | (_, b) <- map fst tally ]
-          ys        = [ fromIntegral c :: Double
-                      | (_, c) <- tally ]
-          chainIds  = [ T.pack (show cid)
-                      | ((cid, _), _) <- tally ]
+          -- rank 正規化ヒストグラムは Stat.MCMC.rankHist に一元化 (Plot 経路と共有)。
+          hists     = rankHist nBins perChain          -- [chain][bin] (chain は 0-based)
+          triples   = [ (cid, b, c)
+                      | (cid, cnts) <- zip [(1 :: Int) ..] hists   -- 表示は 1-based chain
+                      , (b, c)      <- zip [(0 :: Int) ..] cnts ]
+          xs        = [ fromIntegral b :: Double | (_, b, _) <- triples ]
+          ys        = [ fromIntegral c :: Double | (_, _, c) <- triples ]
+          chainIds  = [ T.pack (show cid)          | (cid, _, _) <- triples ]
       in asSpec
           [ dataFromColumns []
               . dataColumn "bin"   (Numbers xs)
@@ -702,16 +691,6 @@
           , width (max 60 (plotWidth cfg / fromIntegral nChains))
           , height 100
           ]
-
-    groupAndCount :: [((Int, Int), Int)] -> [((Int, Int), Int)]
-    groupAndCount xs =
-      let mp = foldr (\(k, v) acc -> insertWith (+) k v acc) [] xs
-      in mp
-      where
-        insertWith f k v ((k', v'):rest)
-          | k == k'   = (k', f v v') : rest
-          | otherwise = (k', v')     : insertWith f k v rest
-        insertWith _ k v [] = [(k, v)]
 
 rankPlotFile :: OutputFormat -> FilePath -> PlotConfig
              -> Int -> [Text] -> [Chain] -> IO ()
diff --git a/src/Hanalyze/Viz/ModelGraph.hs b/src/Hanalyze/Viz/ModelGraph.hs
--- a/src/Hanalyze/Viz/ModelGraph.hs
+++ b/src/Hanalyze/Viz/ModelGraph.hs
@@ -1,9 +1,45 @@
+-- |
+-- Module      : Hanalyze.Viz.ModelGraph
+-- Description : モデル DAG の Mermaid.js 可視化 (ModelGraph → HTML)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
 {-# LANGUAGE OverloadedStrings #-}
 -- | Mermaid.js visualization of model DAGs.
 --
 -- Renders the 'ModelGraph' that 'Hanalyze.Model.HBM.buildModelGraph' derives
 -- automatically from a polymorphic model into an HTML file (displayed
 -- in the browser via the Mermaid CDN).
+--
+-- == 3 ルートの選び方 (= hgg Phase 2 で 3 ルート併存方針確立)
+--
+-- 同じ 'Hanalyze.Model.HBM.ModelGraph' を可視化する 3 種類のルート:
+--
+-- +---------------+--------------------------------------------------+---------------------+-----------------------+
+-- | ルート        | 場所                                             | 出力 / 描画依存     | 推奨用途              |
+-- +===============+==================================================+=====================+=======================+
+-- | __本 module__ | 'renderModelGraph' (= Mermaid HTML)              | .html + CDN script  | GitHub README、 ノート |
+-- +---------------+--------------------------------------------------+---------------------+-----------------------+
+-- | Graphviz DOT  | "Hanalyze.Viz.ModelGraphDot".renderModelGraphDot | .dot + dot CLI 別途 | graphviz 連携、 加工  |
+-- +---------------+--------------------------------------------------+---------------------+-----------------------+
+-- | hgg  | @Hgg.Plot.Bridge.Analyze.renderModelGraphSVG@| .svg (依存ゼロ)     | production、 offline  |
+-- |               | (= @hgg-analyze-bridge@ package)        |                     |                       |
+-- +---------------+--------------------------------------------------+---------------------+-----------------------+
+--
+-- 3 ルートとも同じ 'Hanalyze.Model.HBM.ModelGraph' 構造 (= node / edge / plate)
+-- を表現する。 visual layout は実装ごとに異なる。 本ルート (= Mermaid) の利点:
+--
+--   * GitHub / GitLab README で render される (= 添付画像不要、 文字列で済む)
+--   * ノート系 tool (= Notion 等) に貼り付けやすい
+--   * 軽量 (= .html 1 ファイル、 ~5KB)
+--
+-- 弱点 (= 上記表の他ルートで補える):
+--
+--   * ブラウザ + ネット必須 (= offline 不可)
+--   * production アプリ組込みには不向き (= hgg ルート推奨)
+--   * 高度な layout (= graphviz dot 流) には不向き (= ModelGraphDot 推奨)
+--
+-- __本 module は撤廃されません__。 OSS 利用者の既存ワークフローを尊重して 3 ルート併存。
 module Hanalyze.Viz.ModelGraph
   ( renderModelGraph
   , buildMermaid
@@ -13,6 +49,9 @@
 import qualified Data.Text    as T
 import qualified Data.Text.IO as TIO
 import qualified Data.Set as Set
+import qualified Data.Map.Strict as Map
+import Data.List (groupBy, sortOn)
+import Data.Function (on)
 
 import Hanalyze.Model.HBM (ModelGraph (..), Node (..), NodeKind (..))
 
@@ -65,10 +104,12 @@
 -- ---------------------------------------------------------------------------
 
 -- | Build the Mermaid @flowchart TD@ source for a 'ModelGraph'.
+-- Phase 40: plate に属するノードは @subgraph plate_<name>["<name> × N"]@
+-- で囲まれる。 nested plate も入れ子で出力。
 buildMermaid :: ModelGraph -> Text
 buildMermaid mg = T.unlines $
   [ "flowchart TD" ] ++
-  map mkNodeLine (mgNodes mg) ++
+  renderNodesGrouped 1 [] (mgNodes mg) (mgPlates mg) ++
   [ "" ] ++
   map mkEdgeLine (mgEdges mg) ++
   [ "" ] ++
@@ -76,6 +117,44 @@
   [ "    classDef observed fill:#DD8844,color:#fff,stroke:#b06020,stroke-width:1.5px" ] ++
   classAssignLines mg
 
+-- | nodePlates のスタックに沿って nodes をグルーピングし、 nested
+-- subgraph を出力する。 'depth' はインデント用。 'curPath' は現在
+-- 出力中の plate path (外→内)。
+renderNodesGrouped :: Int -> [Text] -> [Node] -> Map.Map Text Int -> [Text]
+renderNodesGrouped depth curPath ns plateSizes =
+  let ind = T.replicate (depth * 4) " "
+      -- 現位置に属する (= nodePlates == curPath) ノードを直接出力
+      hereNodes = [n | n <- ns, nodePlates n == curPath]
+      -- このスコープより内側 (= nodePlates が curPath で始まる かつ より長い) を集める
+      innerNodes = [n | n <- ns, isStrictPrefix curPath (nodePlates n)]
+      -- innerNodes を「curPath の直後の plate 名」 でグループ化
+      keyOf n = (nodePlates n) !! length curPath
+      sortedInner = sortOn keyOf innerNodes
+      grouped = groupBy ((==) `on` keyOf) sortedInner
+      hereLines = map (\n -> ind <> mkNodeLine n) hereNodes
+      innerLines = concatMap (renderPlateGroup depth curPath plateSizes) grouped
+  in hereLines ++ innerLines
+
+renderPlateGroup :: Int -> [Text] -> Map.Map Text Int -> [Node] -> [Text]
+renderPlateGroup _ _ _ [] = []
+renderPlateGroup depth curPath plateSizes ns@(n0:_) =
+  let plateName = (nodePlates n0) !! length curPath
+      sz        = Map.findWithDefault 0 plateName plateSizes
+      ind       = T.replicate (depth * 4) " "
+      header    = ind <> "subgraph plate_" <> sanitize plateName
+                <> "[\"" <> plateName <> " × " <> T.pack (show sz) <> "\"]"
+      footer    = ind <> "end"
+      inner     = renderNodesGrouped (depth + 1) (curPath ++ [plateName])
+                                     ns plateSizes
+  in [header] ++ inner ++ [footer]
+
+isStrictPrefix :: Eq a => [a] -> [a] -> Bool
+isStrictPrefix prefix xs =
+  length prefix < length xs && take (length prefix) xs == prefix
+
+sanitize :: Text -> Text
+sanitize = T.map (\c -> if c `elem` (" -.+*/" :: String) then '_' else c)
+
 mkNodeLine :: Node -> Text
 mkNodeLine n = "    " <> nid <> shapeOpen <> escaped <> shapeClose
   where
@@ -87,10 +166,16 @@
                        else " (deps: " <> T.intercalate "," (Set.toList (nodeDeps n)) <> ")")
       ObservedN k -> nodeName n <> "\\n" <> nodeDist n
                   <> "  (n=" <> T.pack (show k) <> ")"
+      -- Phase 60.4: DeterministicN は従来非網羅 (deterministic を含むモデルで
+      -- crash・ModelGraphDot の Phase 59.2 と同類) だったのを同時修正。
+      DeterministicN -> nodeName n <> "\\n" <> nodeDist n
+      DataN k -> nodeName n <> "\\n(n=" <> T.pack (show k) <> ")"
     escaped = T.replace "\"" "&quot;" label
     (shapeOpen, shapeClose) = case nodeKind n of
       LatentN     -> ("[\"",  "\"]")
       ObservedN _ -> ("([\"", "\"])")
+      DeterministicN -> ("[\"", "\"]")
+      DataN _     -> ("(\"", "\")")
 
 mkEdgeLine :: (Text, Text) -> Text
 mkEdgeLine (from, to) = "    " <> nodeId from <> " --> " <> nodeId to
diff --git a/src/Hanalyze/Viz/ModelGraphDot.hs b/src/Hanalyze/Viz/ModelGraphDot.hs
new file mode 100644
--- /dev/null
+++ b/src/Hanalyze/Viz/ModelGraphDot.hs
@@ -0,0 +1,166 @@
+-- |
+-- Module      : Hanalyze.Viz.ModelGraphDot
+-- Description : モデル DAG の Graphviz DOT 出力 (PyMC model_to_graphviz 同等の plate 描画)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+{-# LANGUAGE OverloadedStrings #-}
+-- | Graphviz DOT 出力 (Phase 40-A3、 PyMC @pm.model_to_graphviz@ 同等の plate 描画)。
+--
+-- 'Hanalyze.Model.HBM.buildModelGraph' が出す 'ModelGraph' を DOT
+-- ソースに変換する。 plate は @subgraph cluster_<name>@ + @label="<name> × N"@
+-- (右下サイズ数字) で囲まれ、 PyMC 流の角丸長方形描画になる。
+--
+-- 使い方:
+--
+-- > let g = HBM.buildModelGraph m
+-- > let dot = renderModelGraphDot g
+-- > T.writeFile "model.dot" dot
+-- > -- graphviz CLI で PNG / SVG 化:
+-- > -- $ dot -Tpng model.dot -o model.png
+--
+-- == 3 ルートの選び方 (= hgg Phase 2 で 3 ルート併存方針確立)
+--
+-- 同じ 'Hanalyze.Model.HBM.ModelGraph' を可視化する 3 種類のルート:
+--
+-- +---------------+--------------------------------------------------+---------------------+-----------------------+
+-- | ルート        | 場所                                             | 出力 / 描画依存     | 推奨用途              |
+-- +===============+==================================================+=====================+=======================+
+-- | Mermaid HTML  | "Hanalyze.Viz.ModelGraph".renderModelGraph       | .html + CDN script  | GitHub README、 ノート |
+-- +---------------+--------------------------------------------------+---------------------+-----------------------+
+-- | __本 module__ | 'renderModelGraphDot' (= Graphviz DOT)           | .dot + dot CLI 別途 | graphviz 連携、 加工  |
+-- +---------------+--------------------------------------------------+---------------------+-----------------------+
+-- | hgg  | @Hgg.Plot.Bridge.Analyze.renderModelGraphSVG@| .svg (依存ゼロ)     | production、 offline  |
+-- |               | (= @hgg-analyze-bridge@ package)        |                     |                       |
+-- +---------------+--------------------------------------------------+---------------------+-----------------------+
+--
+-- 3 ルートとも同じ 'Hanalyze.Model.HBM.ModelGraph' 構造 (= node / edge / plate)
+-- を表現する。 visual layout は実装ごとに異なる。 本ルート (= Graphviz DOT) の利点:
+--
+--   * graphviz dot の高品質 layout (= Sugiyama framework 本家、 数十年の蓄積)
+--   * @-Tpng@ @-Tsvg@ @-Tpdf@ @-Tps@ 等 多 format 出力
+--   * @rank=same@ @constraint=false@ @cluster@ 等 dot 固有 directive で細かい制御
+--   * 既存 graphviz エコシステム (= xdot、 gephi 等) と連携
+--
+-- 弱点 (= 上記表の他ルートで補える):
+--
+--   * @dot@ CLI が install 済必須 (= production 配布で外部依存)
+--   * 出力は .dot text 中間ファイル (= 描画は別 step、 pipeline 化必要)
+--
+-- __本 module は撤廃されません__。 OSS 利用者の既存ワークフローを尊重して 3 ルート併存。
+module Hanalyze.Viz.ModelGraphDot
+  ( renderModelGraphDot
+  , writeModelGraphDot
+  ) where
+
+import Data.Text (Text)
+import qualified Data.Text    as T
+import qualified Data.Text.IO as TIO
+import qualified Data.Set as Set
+import qualified Data.Map.Strict as Map
+import Data.List (groupBy, sortOn)
+import Data.Function (on)
+
+import Hanalyze.Model.HBM (ModelGraph (..), Node (..), NodeKind (..))
+
+-- ---------------------------------------------------------------------------
+-- Public API
+-- ---------------------------------------------------------------------------
+
+-- | 'ModelGraph' を Graphviz DOT 形式の 'Text' に変換する。
+renderModelGraphDot :: ModelGraph -> Text
+renderModelGraphDot mg = T.unlines $
+  [ "digraph G {"
+  , "    rankdir=TB;"
+  , "    node [fontname=\"sans-serif\", fontsize=10];"
+  , "    edge [arrowsize=0.7];"
+  , ""
+  ] ++
+  renderNodesGrouped 1 [] (mgNodes mg) (mgPlates mg) ++
+  [ "" ] ++
+  map mkEdgeLine (mgEdges mg) ++
+  [ "}" ]
+
+-- | DOT をファイルに書き出す利便 helper。
+writeModelGraphDot :: FilePath -> ModelGraph -> IO ()
+writeModelGraphDot path mg = TIO.writeFile path (renderModelGraphDot mg)
+
+-- ---------------------------------------------------------------------------
+-- Node grouping by plate (nested cluster)
+-- ---------------------------------------------------------------------------
+
+renderNodesGrouped :: Int -> [Text] -> [Node] -> Map.Map Text Int -> [Text]
+renderNodesGrouped depth curPath ns plateSizes =
+  let ind = T.replicate (depth * 4) " "
+      hereNodes = [n | n <- ns, nodePlates n == curPath]
+      innerNodes = [n | n <- ns, isStrictPrefix curPath (nodePlates n)]
+      keyOf n = (nodePlates n) !! length curPath
+      sortedInner = sortOn keyOf innerNodes
+      grouped = groupBy ((==) `on` keyOf) sortedInner
+      hereLines = map (\n -> ind <> mkNodeLine n) hereNodes
+      innerLines = concatMap (renderPlateGroup depth curPath plateSizes) grouped
+  in hereLines ++ innerLines
+
+renderPlateGroup :: Int -> [Text] -> Map.Map Text Int -> [Node] -> [Text]
+renderPlateGroup _ _ _ [] = []
+renderPlateGroup depth curPath plateSizes ns@(n0:_) =
+  let plateName = (nodePlates n0) !! length curPath
+      sz        = Map.findWithDefault 0 plateName plateSizes
+      ind       = T.replicate (depth * 4) " "
+      header    = ind <> "subgraph cluster_" <> sanitize plateName <> " {"
+      label     = ind <> "    label=\"" <> plateName <> " × "
+                  <> T.pack (show sz) <> "\";"
+      style     = ind <> "    style=\"rounded\";"
+      labelloc  = ind <> "    labelloc=\"b\";"  -- 下に表示 (PyMC 流)
+      footer    = ind <> "}"
+      inner     = renderNodesGrouped (depth + 1) (curPath ++ [plateName])
+                                     ns plateSizes
+  in [header, label, style, labelloc] ++ inner ++ [footer]
+
+isStrictPrefix :: Eq a => [a] -> [a] -> Bool
+isStrictPrefix prefix xs =
+  length prefix < length xs && take (length prefix) xs == prefix
+
+-- ---------------------------------------------------------------------------
+-- Node / Edge rendering
+-- ---------------------------------------------------------------------------
+
+mkNodeLine :: Node -> Text
+mkNodeLine n =
+  let nid     = nodeId (nodeName n)
+      label   = case nodeKind n of
+        LatentN        -> nodeName n <> "\\n" <> nodeDist n
+        ObservedN k    -> nodeName n <> "\\n" <> nodeDist n
+                          <> "\\n(n=" <> T.pack (show k) <> ")"
+        DeterministicN -> nodeName n <> "\\n" <> nodeDist n
+        -- Phase 60.4: データ slot は名前 + 長さのみ (分布を持たない)
+        DataN k        -> nodeName n <> "\\n(n=" <> T.pack (show k) <> ")"
+      escaped = T.replace "\"" "&quot;" label
+      attrs = case nodeKind n of
+        -- 潜在: 楕円・白塗り
+        LatentN        -> "label=\"" <> escaped <> "\", shape=ellipse"
+        -- 観測: 楕円・灰色塗り (PyMC 流)
+        ObservedN _    -> "label=\"" <> escaped <> "\", shape=ellipse, "
+                          <> "style=filled, fillcolor=lightgray"
+        -- 決定的変換: 四角・白塗り (PyMC の Deterministic 流)
+        DeterministicN -> "label=\"" <> escaped <> "\", shape=box"
+        -- データ slot (pm.Data 相当): 角丸四角・灰塗り (PyMC ConstantData 流)
+        DataN _        -> "label=\"" <> escaped <> "\", shape=box, "
+                          <> "style=\"rounded,filled\", fillcolor=lightgray"
+  in nid <> " [" <> attrs <> "];"
+
+mkEdgeLine :: (Text, Text) -> Text
+mkEdgeLine (from, to) = "    " <> nodeId from <> " -> " <> nodeId to <> ";"
+
+-- ---------------------------------------------------------------------------
+-- Helpers
+-- ---------------------------------------------------------------------------
+
+nodeId :: Text -> Text
+nodeId = T.map (\c -> if c `elem` (" -.+*/" :: String) then '_' else c)
+
+sanitize :: Text -> Text
+sanitize = nodeId
+
+_unused :: Set.Set Text -> Set.Set Text
+_unused = id
diff --git a/src/Hanalyze/Viz/Pareto.hs b/src/Hanalyze/Viz/Pareto.hs
--- a/src/Hanalyze/Viz/Pareto.hs
+++ b/src/Hanalyze/Viz/Pareto.hs
@@ -1,3 +1,9 @@
+-- |
+-- Module      : Hanalyze.Viz.Pareto
+-- Description : パレートフロント可視化 (散布・pairs・parallel coordinates・hypervolume 推移・比較)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
 {-# LANGUAGE OverloadedStrings #-}
 -- | Pareto-front visualizations (130 規約: PlotData ベース).
 --
diff --git a/src/Hanalyze/Viz/PlotConfig.hs b/src/Hanalyze/Viz/PlotConfig.hs
--- a/src/Hanalyze/Viz/PlotConfig.hs
+++ b/src/Hanalyze/Viz/PlotConfig.hs
@@ -1,3 +1,9 @@
+-- |
+-- Module      : Hanalyze.Viz.PlotConfig
+-- Description : 全 Viz.* モジュール共有のプロット設定 (PlotConfig / defaultConfig) を定義
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
 {-# LANGUAGE OverloadedStrings #-}
 -- | Common plot configuration shared by every @Hanalyze.Viz.*@ module.
 --
diff --git a/src/Hanalyze/Viz/PlotData.hs b/src/Hanalyze/Viz/PlotData.hs
--- a/src/Hanalyze/Viz/PlotData.hs
+++ b/src/Hanalyze/Viz/PlotData.hs
@@ -1,3 +1,9 @@
+-- |
+-- Module      : Hanalyze.Viz.PlotData
+-- Description : プロットデータの入力元非依存な中間表現 (PlotData / ToPlotData)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
 {-# LANGUAGE OverloadedStrings #-}
 -- | Source-agnostic intermediate representation for plot data.
 --
diff --git a/src/Hanalyze/Viz/PlotData/DataFrame.hs b/src/Hanalyze/Viz/PlotData/DataFrame.hs
--- a/src/Hanalyze/Viz/PlotData/DataFrame.hs
+++ b/src/Hanalyze/Viz/PlotData/DataFrame.hs
@@ -1,3 +1,9 @@
+-- |
+-- Module      : Hanalyze.Viz.PlotData.DataFrame
+-- Description : Hackage dataframe 用の ToPlotData インスタンス (数値/テキスト列を PlotData に変換)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
 {-# LANGUAGE OverloadedStrings #-}
 {-# OPTIONS_GHC -Wno-orphans #-}
 -- | 'ToPlotData' instance for Hackage @dataframe@.
@@ -20,7 +26,7 @@
 import qualified Data.Map.Strict          as Map
 import           Data.Text                (Text)
 import qualified Data.Vector              as V
-import qualified DataFrame                as DX
+import qualified DataFrame.Internal.DataFrame  as DX
 
 import           Hanalyze.DataIO.Convert  (getDoubleVec, getTextVec)
 import           Hanalyze.Viz.PlotData    (PlotData (..), ToPlotData (..),
diff --git a/src/Hanalyze/Viz/Report.hs b/src/Hanalyze/Viz/Report.hs
--- a/src/Hanalyze/Viz/Report.hs
+++ b/src/Hanalyze/Viz/Report.hs
@@ -1,3 +1,9 @@
+-- |
+-- Module      : Hanalyze.Viz.Report
+-- Description : MCMC 結果の統合 HTML レポート (モデル DAG・事後要約表・診断プロット・pairs 散布図)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
 {-# LANGUAGE OverloadedStrings #-}
 -- | Integrated HTML report for MCMC results.
 --
@@ -28,7 +34,6 @@
 
 import Hanalyze.Model.HBM        (ModelGraph)
 import Hanalyze.MCMC.Core        (Chain (..), chainVals, posteriorMean, posteriorSD, posteriorQuantile)
-import Hanalyze.Stat.MCMC        (ess, rhat)
 import Hanalyze.Stat.Summary     (SummaryRow (..), posteriorSummary)
 import Hanalyze.Viz.Assets       (vegaJS, vegaLiteJS, vegaEmbedJS)
 import Hanalyze.Viz.MCMC         (mcmcDiagnostics, mcmcDiagnosticsMulti, autocorrPlot, pairScatter)
@@ -262,7 +267,7 @@
     , "  <table>"
     , "    <thead><tr>"
     , "      <th>Parameter</th><th>Mean</th><th>SD</th>"
-    , "      <th>HDI 3%</th><th>HDI 97%</th><th>ESS</th>" <> rhatHeader
+    , "      <th>HDI 3%</th><th>HDI 97%</th><th>ESS (bulk)</th>" <> rhatHeader
     , "    </tr></thead>"
     , "    <tbody>"
     , T.concat (map tableRow rows)
diff --git a/src/Hanalyze/Viz/ReportBuilder.hs b/src/Hanalyze/Viz/ReportBuilder.hs
--- a/src/Hanalyze/Viz/ReportBuilder.hs
+++ b/src/Hanalyze/Viz/ReportBuilder.hs
@@ -1,3 +1,9 @@
+-- |
+-- Module      : Hanalyze.Viz.ReportBuilder
+-- Description : 全モデル/解析種別共通の合成型 HTML レポートビルダー (ReportSection / Reportable)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
 {-# LANGUAGE OverloadedStrings #-}
 -- | Compositional HTML report builder.
 --
@@ -116,7 +122,7 @@
 import qualified Data.Vector as V
 import Text.Printf (printf)
 
-import qualified DataFrame                    as DX
+import qualified DataFrame.Operations.Core     as DX
 import qualified DataFrame.Internal.DataFrame as DXD
 import Hanalyze.DataIO.Convert (getDoubleVec, getTextVec)
 import Hanalyze.MCMC.Core (Chain)
diff --git a/src/Hanalyze/Viz/ReportInstances.hs b/src/Hanalyze/Viz/ReportInstances.hs
--- a/src/Hanalyze/Viz/ReportInstances.hs
+++ b/src/Hanalyze/Viz/ReportInstances.hs
@@ -1,3 +1,9 @@
+-- |
+-- Module      : Hanalyze.Viz.ReportInstances
+-- Description : 各種フィット結果型に対する Reportable インスタンス集
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
 {-# LANGUAGE OverloadedStrings #-}
 {-# OPTIONS_GHC -Wno-orphans #-}
 -- | 'Hanalyze.Viz.ReportBuilder.Reportable' instances for the various fit types.
@@ -19,7 +25,7 @@
 -- 提供されるインスタンス:
 -- - 'RegFit'         (Hanalyze.Model.Regularized) — 正則化線形回帰
 -- - 'SplineFit'      (Hanalyze.Model.Spline)      — B-spline / Natural cubic
--- - 'KernelRidgeFit' (Hanalyze.Model.Kernel)      — Kernel Ridge regression
+-- - 'KernelRidgeFit' (Hanalyze.Model.KernelRegression)      — Kernel Ridge regression
 -- - 'RFFRidgeFit'    (Hanalyze.Model.RFF)         — Random Fourier Features Ridge
 -- - 'RobustGPFit'    (Hanalyze.Model.GPRobust)    — ロバスト GP
 --
@@ -56,7 +62,7 @@
 import Hanalyze.Model.GLM       (Family (..), LinkFn (..))
 import Hanalyze.Model.Regularized (RegFit (..), Penalty (..), predictRegularized)
 import Hanalyze.Model.Spline     (SplineFit (..), SplineKind (..), predictSpline, sfBeta)
-import Hanalyze.Model.Kernel     (KernelRidgeFit (..), predictKernelRidge)
+import Hanalyze.Model.KernelRegression     (KernelRidgeFit (..), predictKernelRidge)
 import Hanalyze.Model.RFF        (RFFRidgeFit (..), predictRFFRidge, rffrFeatures,
                          rffSigmaF, rffLengthScale, rffOmegas,
                          RFFRidgeFitMV (..), RFFFeaturesMV (..),
diff --git a/src/Hanalyze/Viz/Scatter.hs b/src/Hanalyze/Viz/Scatter.hs
--- a/src/Hanalyze/Viz/Scatter.hs
+++ b/src/Hanalyze/Viz/Scatter.hs
@@ -1,3 +1,9 @@
+-- |
+-- Module      : Hanalyze.Viz.Scatter
+-- Description : 散布図とその重ね描き (回帰直線/平滑化・グループ別・予測 vs 実測)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
 {-# LANGUAGE OverloadedStrings #-}
 -- | Scatter plots and overlays.
 --
diff --git a/src/Hanalyze/Viz/Taguchi.hs b/src/Hanalyze/Viz/Taguchi.hs
--- a/src/Hanalyze/Viz/Taguchi.hs
+++ b/src/Hanalyze/Viz/Taguchi.hs
@@ -1,3 +1,9 @@
+-- |
+-- Module      : Hanalyze.Viz.Taguchi
+-- Description : 田口メソッド解析結果の HTML レポート (SN 比・主効果・最適水準)
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
 {-# LANGUAGE OverloadedStrings #-}
 -- | HTML report for Taguchi-method analysis.
 --
diff --git a/test/Hanalyze/Data/FactorSpec.hs b/test/Hanalyze/Data/FactorSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Data/FactorSpec.hs
@@ -0,0 +1,158 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+-- | Hanalyze.Data.Factor の HSpec (Phase 28 Ch16 "Factors")。
+--   R4DS Ch16 / forcats の挙動を一次根拠に Factor 型の生成・参照を固定する。
+module Hanalyze.Data.FactorSpec (spec) where
+
+import           Test.Hspec
+import qualified Data.List
+import qualified Data.Vector.Unboxed as VU
+import           Hanalyze.Data.Factor
+
+spec :: Spec
+spec = describe "Data.Factor (A2: 生成 / 参照)" $ do
+
+  describe "factor (R factor() 既定 = ソート済 unique)" $ do
+    it "水準は値のソート済 unique" $
+      levels (factor ["Dec", "Apr", "Jan", "Mar"]) `shouldBe` ["Apr", "Dec", "Jan", "Mar"]
+    it "重複は 1 水準に畳む" $
+      levels (factor ["a", "b", "a", "c", "b"]) `shouldBe` ["a", "b", "c"]
+    it "コードは 0 始まりで水準 index を指す" $
+      facCodes (factor ["b", "a", "b"]) `shouldBe` VU.fromList [1, 0, 1]
+
+  describe "factorWith (水準明示)" $ do
+    it "指定 levels の順序を保つ (sort しない)" $ do
+      let f = factorWith ["Jan", "Feb", "Mar"] ["Mar", "Jan", "Mar"]
+      levels f `shouldBe` ["Jan", "Feb", "Mar"]
+      facCodes f `shouldBe` VU.fromList [2, 0, 2]
+    it "levels に無い値は NA (naCode)" $
+      facCodes (factorWith ["a", "b"] ["a", "z", "b"]) `shouldBe` VU.fromList [0, naCode, 1]
+
+  describe "fct (forcats fct() = 出現順 unique)" $
+    it "水準は出現順 (factor() の sort と異なる)" $
+      levels (fct ["Dec", "Apr", "Jan", "Mar"]) `shouldBe` ["Dec", "Apr", "Jan", "Mar"]
+
+  describe "ordered (R ordered())" $ do
+    it "順序付きフラグが立つ" $
+      isOrdered (ordered ["lo", "mid", "hi"] ["mid", "hi"]) `shouldBe` True
+    it "factor は既定で非順序" $
+      isOrdered (factor ["a", "b"]) `shouldBe` False
+
+  describe "asTexts / asTextsMaybe (as.character())" $ do
+    it "コードをラベルに復元" $
+      asTexts (factorWith ["a", "b", "c"] ["c", "a", "b"]) `shouldBe` ["c", "a", "b"]
+    it "NA は asTexts で \"\"・asTextsMaybe で Nothing" $ do
+      let f = factorWith ["a", "b"] ["a", "z"]
+      asTexts f      `shouldBe` ["a", ""]
+      asTextsMaybe f `shouldBe` [Just "a", Nothing]
+
+  describe "fctCount (dplyr count())" $ do
+    it "水準順に頻度・0 件水準も含む" $
+      fctCount (factorWith ["a", "b", "c"] ["a", "a", "c"])
+        `shouldBe` [("a", 2), ("b", 0), ("c", 1)]
+    it "NA は集計から除外" $
+      fctCount (factorWith ["a", "b"] ["a", "z", "b", "a"])
+        `shouldBe` [("a", 2), ("b", 1)]
+
+  -- A3: 順序操作 (16.4) ----------------------------------------------------
+  describe "fctReorder (fct_reorder = x の集約値で昇順)" $ do
+    it "各水準の x 集約値の昇順に並べ替える (観測は不変)" $ do
+      -- a→[3], b→[1], c→[2] : 集約 (median 相当=identity) 昇順は b,c,a
+      let f  = factorWith ["a", "b", "c"] ["a", "b", "c"]
+          f' = fctReorder medianD f [3, 1, 2]
+      levels f' `shouldBe` ["b", "c", "a"]
+      -- どの観測がどの水準かは保存される
+      asTexts f' `shouldBe` ["a", "b", "c"]
+    it "1 水準に複数値があれば集約関数を適用" $
+      levels (fctReorder medianD (factorWith ["a", "b"] ["a", "a", "b"]) [10, 20, 5])
+        `shouldBe` ["b", "a"]  -- a の median 15 > b の 5
+
+  describe "fctRelevel (fct_relevel = 指定水準を先頭)" $ do
+    it "指定水準を先頭へ・残りは相対順序保持" $
+      levels (fctRelevel ["c"] (factorWith ["a", "b", "c", "d"] []))
+        `shouldBe` ["c", "a", "b", "d"]
+    it "複数指定は指定順で先頭に並ぶ" $
+      levels (fctRelevel ["d", "b"] (factorWith ["a", "b", "c", "d"] []))
+        `shouldBe` ["d", "b", "a", "c"]
+    it "存在しない水準名は無視" $
+      levels (fctRelevel ["z", "b"] (factorWith ["a", "b", "c"] []))
+        `shouldBe` ["b", "a", "c"]
+
+  describe "fctReorder2 (fct_reorder2 = 最大 x での y で降順)" $
+    it "各水準の最大 x に対応する y の降順" $ do
+      -- a: x=[1,3] y=[9,1] → max x=3 の y=1 ; b: x=[2] y=[5] → 5
+      let f = factorWith ["a", "b"] ["a", "a", "b"]
+      levels (fctReorder2 f [1, 3, 2] [9, 1, 5]) `shouldBe` ["b", "a"]
+
+  describe "fctInfreq (fct_infreq = 頻度降順)" $ do
+    it "出現頻度の降順" $
+      levels (fctInfreq (factorWith ["a", "b", "c"] ["a", "b", "b", "c", "c", "c"]))
+        `shouldBe` ["c", "b", "a"]
+    it "同頻度は元の水準順を保つ (安定)" $
+      levels (fctInfreq (factorWith ["a", "b", "c"] ["a", "b", "c"]))
+        `shouldBe` ["a", "b", "c"]
+
+  describe "fctRev (fct_rev = 水準逆順)" $
+    it "水準を逆順にする (観測は不変)" $ do
+      let f' = fctRev (factorWith ["a", "b", "c"] ["a", "c"])
+      levels f'  `shouldBe` ["c", "b", "a"]
+      asTexts f' `shouldBe` ["a", "c"]
+
+  -- A4: 水準操作 (16.5) ----------------------------------------------------
+  describe "fctRecode (fct_recode = ラベル改名)" $ do
+    it "ラベルを改名・未言及は不変・順序保持" $ do
+      let f' = fctRecode [("A", "a"), ("C", "c")] (factorWith ["a", "b", "c"] ["a", "b", "c"])
+      levels f'  `shouldBe` ["A", "b", "C"]
+      asTexts f' `shouldBe` ["A", "b", "C"]
+    it "複数の旧を同じ新に向けると併合される" $ do
+      let f' = fctRecode [("X", "a"), ("X", "b")] (factorWith ["a", "b", "c"] ["a", "b", "c", "a"])
+      levels f'  `shouldBe` ["X", "c"]
+      asTexts f' `shouldBe` ["X", "X", "c", "X"]
+
+  describe "fctCollapse (fct_collapse = 複数水準を併合)" $
+    it "(新,[旧]) で併合・未言及は残す" $ do
+      let f' = fctCollapse [("other", ["b", "c"])] (factorWith ["a", "b", "c", "d"] ["a", "b", "c", "d"])
+      levels f'  `shouldBe` ["a", "other", "d"]
+      asTexts f' `shouldBe` ["a", "other", "other", "d"]
+
+  describe "fctLumpN (fct_lump_n = 上位 n 残し他を Other)" $ do
+    it "上位 n 水準を残し他を Other (末尾)" $ do
+      -- 頻度: a=3,b=2,c=1,d=1 → 上位2 = a,b
+      let f  = factorWith ["a", "b", "c", "d"] ["a", "a", "a", "b", "b", "c", "d"]
+          f' = fctLumpN 2 f
+      levels f' `shouldBe` ["a", "b", "Other"]
+      fctCount f' `shouldBe` [("a", 3), ("b", 2), ("Other", 2)]
+    it "n が水準数以上なら Other を作らない" $
+      levels (fctLumpN 9 (factorWith ["a", "b"] ["a", "b"])) `shouldBe` ["a", "b"]
+
+  describe "fctLumpMin (fct_lump_min = 回数<min を Other)" $
+    it "出現回数 < min を Other へ" $ do
+      let f  = factorWith ["a", "b", "c"] ["a", "a", "a", "b", "b", "c"]
+          f' = fctLumpMin 2 f   -- c(1) のみ < 2
+      levels f'   `shouldBe` ["a", "b", "Other"]
+      fctCount f' `shouldBe` [("a", 3), ("b", 2), ("Other", 1)]
+
+  describe "fctLumpProp (fct_lump_prop = 割合<prop を Other)" $
+    it "出現割合 < prop を Other へ" $ do
+      -- total=10: a=6(0.6),b=3(0.3),c=1(0.1)。prop=0.2 → c のみ lump
+      let f  = factorWith ["a", "b", "c"] (replicate 6 "a" ++ replicate 3 "b" ++ ["c"])
+          f' = fctLumpProp 0.2 f
+      levels f'   `shouldBe` ["a", "b", "Other"]
+      fctCount f' `shouldBe` [("a", 6), ("b", 3), ("Other", 1)]
+
+  describe "fctLumpLowfreq (fct_lump_lowfreq = Other が最小のまま低頻度を併合)" $
+    it "降順で残り合計を上回る水準以降を Other に" $ do
+      -- 頻度 a=100,b=5,c=3,d=2 → a > 10 で cut=1、b,c,d を Other(=10<100)
+      let f  = factorWith ["a", "b", "c", "d"]
+                 (replicate 100 "a" ++ replicate 5 "b" ++ replicate 3 "c" ++ replicate 2 "d")
+          f' = fctLumpLowfreq f
+      levels f'   `shouldBe` ["a", "Other"]
+      fctCount f' `shouldBe` [("a", 100), ("Other", 10)]
+
+-- | テスト用 median (偶数個は中央 2 値平均)。
+medianD :: [Double] -> Double
+medianD xs =
+  let s = Data.List.sort xs
+      n = length s
+  in if odd n then s !! (n `div` 2)
+     else (s !! (n `div` 2 - 1) + s !! (n `div` 2)) / 2
diff --git a/test/Hanalyze/Data/StringsSpec.hs b/test/Hanalyze/Data/StringsSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Data/StringsSpec.hs
@@ -0,0 +1,219 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+-- | Hanalyze.Data.Strings の HSpec (Phase 28 Ch14)。
+--   R4DS Ch14 の例を一次根拠に str_* の挙動を固定する。
+module Hanalyze.Data.StringsSpec (spec) where
+
+import           Test.Hspec
+import           Control.Exception  (evaluate)
+import           Data.Text          (Text)
+import qualified Data.Text          as T
+import qualified Data.Vector        as V
+import qualified DataFrame.Internal.Column    as DF
+import qualified DataFrame.Internal.DataFrame  as DF
+import qualified DataFrame.Operations.Core     as DF
+import           Hanalyze.Data.Strings
+import           Hanalyze.DataIO.Convert (getMaybeTextVec)
+
+-- | 純粋値を spine + 要素まで強制して error を IO に持ち上げる (deepseq 非依存)。
+forceStrs :: [a] -> (a -> Int) -> IO Int
+forceStrs xs f = evaluate (sum (map f xs))
+
+-- | DataFrame の列を @[Maybe Text]@ で読み戻す (テスト検証用)。
+colMT :: Text -> DF.DataFrame -> [Maybe Text]
+colMT c d = maybe (error ("no col " ++ T.unpack c)) V.toList (getMaybeTextVec c d)
+
+spec :: Spec
+spec = do
+  describe "strLength / strSub (str_length / str_sub)" $ do
+    it "strLength = 文字数" $ do
+      strLength "Apple"  `shouldBe` 5
+      strLength ""       `shouldBe` 0
+    it "strSub: 1 始まり両端含む" $
+      strSub 1 3 "Apple" `shouldBe` "App"
+    it "strSub: 負 index は末尾から" $ do
+      strSub (-3) (-1) "Apple" `shouldBe` "ple"
+      strSub (-1) (-1) "Apple" `shouldBe` "e"     -- last char (R4DS 14.5.2)
+      strSub 1 1     "Apple"   `shouldBe` "A"      -- first char
+    it "strSub: 範囲外はクリップ・逆転は空" $ do
+      strSub 1 100 "Pear" `shouldBe` "Pear"
+      strSub 3 2   "Pear" `shouldBe` ""
+
+  describe "strC / strCMaybe (str_c・recycling + NA)" $ do
+    it "リテラル + 列の recycling" $
+      strC [["Hello "], ["Flora","David","Terra"], ["!"]]
+        `shouldBe` ["Hello Flora!","Hello David!","Hello Terra!"]
+    it "NA 伝播 (R4DS 14.3.1: NA を含む name)" $
+      strCMaybe [[Just "Hello "], [Just "Flora", Nothing, Just "Terra"], [Just "!"]]
+        `shouldBe` [Just "Hello Flora!", Nothing, Just "Hello Terra!"]
+    it "長さ不整合は error" $
+      forceStrs (strC [["a","b"], ["x","y","z"]]) strLength `shouldThrow` anyErrorCall
+
+  describe "strFlatten (str_flatten)" $
+    it "collapse で結合" $
+      strFlatten ", " ["a","b","c"] `shouldBe` "a, b, c"
+
+  describe "strGlue (str_glue)" $ do
+    it "{key} を列で置換 (R4DS 14.3.2)" $
+      strGlue "Hello {name}!" [("name", ["Flora","David"])]
+        `shouldBe` ["Hello Flora!","Hello David!"]
+    it "複数 placeholder + リテラル" $
+      strGlue "{a}-{b}" [("a",["1","2"]),("b",["x","y"])]
+        `shouldBe` ["1-x","2-y"]
+    it "{{ }} はリテラル波括弧" $
+      strGlue "{{x}}" [] `shouldBe` ["{x}"]
+    it "未知 key は error" $
+      forceStrs (strGlue "{z}" [("a",["1"])]) strLength `shouldThrow` anyErrorCall
+
+  describe "strToUpper / strSort" $ do
+    it "大文字化" $
+      strToUpper "abc" `shouldBe` "ABC"
+    it "既定 (コードポイント) 昇順" $
+      strSort ["banana","apple","cherry"] `shouldBe` ["apple","banana","cherry"]
+
+  describe "separateLongerDelim / separateLongerPosition (§14.4.1)" $ do
+    let df = DF.fromNamedColumns
+               [ ("x",  DF.fromList ["a,b,c","d,e","f" :: Text])
+               , ("id", DF.fromList ["r1","r2","r3" :: Text]) ]
+    it "delim: 1 行→複数行に展開し他列を複製 (R4DS df1)" $ do
+      let out = separateLongerDelim "x" "," df
+      DF.dimensions out `shouldBe` (6, 2)
+      colMT "x"  out `shouldBe` map Just ["a","b","c","d","e","f"]
+      colMT "id" out `shouldBe` map Just ["r1","r1","r1","r2","r2","r3"]
+    it "delim: NA (Nothing) は分割せず 1 行保持" $ do
+      let dfn = DF.fromNamedColumns
+                  [("x", DF.fromList [Just "a,b", Nothing, Just "c" :: Maybe Text])]
+          out = separateLongerDelim "x" "," dfn
+      DF.dimensions out `shouldBe` (4, 1)
+      colMT "x" out `shouldBe` [Just "a", Just "b", Nothing, Just "c"]
+    it "position: width=1 で各文字を 1 行に (R4DS df3)" $ do
+      let df3 = DF.fromNamedColumns [("x", DF.fromList ["1211","131","21" :: Text])]
+          out = separateLongerPosition "x" 1 df3
+      DF.dimensions out `shouldBe` (9, 1)
+      colMT "x" out `shouldBe` map (Just . T.singleton) "121113121"
+    it "position: width=2 で 2 文字塊・端数は短い塊" $ do
+      let df4 = DF.fromNamedColumns [("x", DF.fromList ["abcde","fg" :: Text])]
+          out = separateLongerPosition "x" 2 df4
+      colMT "x" out `shouldBe` map Just ["ab","cd","e","fg"]
+
+  describe "strEqual / charToRaw (§14.6 Non-English Text)" $ do
+    it "strEqual: 合成 ü と 基底+結合 ü は NFC で等価 (R4DS 14.6.2)" $ do
+      -- u = c("ü", "ü")
+      let u1 = "\x00fc"; u2 = "u\x0308"
+      (u1 == u2)        `shouldBe` False   -- 素の比較は不一致
+      strEqual u1 u2    `shouldBe` True     -- str_equal は一致
+      strLength u1      `shouldBe` 1        -- 14.6.2: 長さは 1
+      strLength u2      `shouldBe` 2        --         と 2 で違う
+    it "strEqual: 異なる文字は不一致" $
+      strEqual "a" "b" `shouldBe` False
+    it "charToRaw: UTF-8 バイト列 (R4DS 14.6.1: Hadley)" $
+      charToRaw "Hadley" `shouldBe` [0x48,0x61,0x64,0x6c,0x65,0x79]
+
+  describe "separateWiderDelim / separateWiderPosition (§14.4.2-3)" $ do
+    it "delim + names: 1 セル→複数列 (R4DS df3)" $ do
+      let df3 = DF.fromNamedColumns
+                  [("x", DF.fromList ["a10.1.2022","b10.2.2011","e15.1.2015" :: Text])]
+          out = separateWiderDelim "x" "." [Just "code", Just "edition", Just "year"] df3
+      DF.dimensions out `shouldBe` (3, 3)
+      colMT "code"    out `shouldBe` map Just ["a10","b10","e15"]
+      colMT "edition" out `shouldBe` map Just ["1","2","1"]
+      colMT "year"    out `shouldBe` map Just ["2022","2011","2015"]
+    it "delim + names に NA: その piece を捨てる (R4DS)" $ do
+      let df3 = DF.fromNamedColumns
+                  [("x", DF.fromList ["a10.1.2022","b10.2.2011","e15.1.2015" :: Text])]
+          out = separateWiderDelim "x" "." [Just "code", Nothing, Just "year"] df3
+      DF.columnNames out `shouldMatchList` ["code","year"]
+      colMT "code" out `shouldBe` map Just ["a10","b10","e15"]
+      colMT "year" out `shouldBe` map Just ["2022","2011","2015"]
+    it "position widths: 固定幅で列分割 (R4DS df4)" $ do
+      let df4 = DF.fromNamedColumns
+                  [("x", DF.fromList ["202215TX","202122LA","202325CA" :: Text])]
+          out = separateWiderPosition "x" [("year",4),("age",2),("state",2)] df4
+      colMT "year"  out `shouldBe` map Just ["2022","2021","2023"]
+      colMT "age"   out `shouldBe` map Just ["15","22","25"]
+      colMT "state" out `shouldBe` map Just ["TX","LA","CA"]
+
+    -- §14.4.3 診断 df: a = c("1-1-1","1-1-2","1-3","1-3-2","1")
+    let dfA = DF.fromNamedColumns
+                [("a", DF.fromList ["1-1-1","1-1-2","1-3","1-3-2","1" :: Text])]
+        nm  = [Just "x", Just "y", Just "z"]
+    it "too_few = align_start: 不足は右を NA (R4DS)" $ do
+      let out = separateWiderDelimWith "a" "-" nm AlignStart TooManyError dfA
+      colMT "x" out `shouldBe` map Just ["1","1","1","1","1"]
+      colMT "y" out `shouldBe` [Just "1", Just "1", Just "3", Just "3", Nothing]
+      colMT "z" out `shouldBe` [Just "1", Just "2", Nothing, Just "2", Nothing]
+    it "too_few = align_end: 不足は左を NA" $ do
+      let out = separateWiderDelimWith "a" "-" nm AlignEnd TooManyError dfA
+      colMT "x" out `shouldBe` [Just "1", Just "1", Nothing, Just "1", Nothing]
+      colMT "y" out `shouldBe` [Just "1", Just "1", Just "1", Just "3", Nothing]
+      colMT "z" out `shouldBe` [Just "1", Just "2", Just "3", Just "2", Just "1"]
+    it "too_few = error: 不足があれば error" $
+      forceStrs [DF.dimensions (separateWiderDelimWith "a" "-" nm TooFewError TooManyError dfA)] fst
+        `shouldThrow` anyErrorCall
+
+    -- §14.4.3 too_many df: a = c("1-1-1","1-1-2","1-3-5-6","1-3-2","1-3-5-7-9")
+    let dfM = DF.fromNamedColumns
+                [("a", DF.fromList ["1-1-1","1-1-2","1-3-5-6","1-3-2","1-3-5-7-9" :: Text])]
+    it "too_many = drop: 余剰は捨てる (R4DS)" $ do
+      let out = separateWiderDelimWith "a" "-" nm AlignStart DropExtra dfM
+      colMT "x" out `shouldBe` map Just ["1","1","1","1","1"]
+      colMT "y" out `shouldBe` map Just ["1","1","3","3","3"]
+      colMT "z" out `shouldBe` map Just ["1","2","5","2","5"]
+    it "too_many = merge: 余剰を最終列に再結合 (R4DS)" $ do
+      let out = separateWiderDelimWith "a" "-" nm AlignStart MergeExtra dfM
+      colMT "x" out `shouldBe` map Just ["1","1","1","1","1"]
+      colMT "y" out `shouldBe` map Just ["1","1","3","3","3"]
+      colMT "z" out `shouldBe` map Just ["1","2","5-6","2","5-7-9"]
+    it "debug: 診断列 _ok / _pieces / _remainder を付与" $ do
+      let out = separateWiderDelimWith "a" "-" nm TooFewError TooManyDebug dfM
+      DF.columnNames out `shouldMatchList`
+        ["x","y","z","a_ok","a_pieces","a_remainder"]
+      colMT "a_remainder" out `shouldBe`
+        [Just "", Just "", Just "6", Just "", Just "7-9"]
+
+  describe "正規表現 (§15 Regular expressions・regex-tdfa)" $ do
+    it "strDetect: 基本マッチ + \\d 変換 + \\b 単語境界" $ do
+      strDetect "an" "banana"            `shouldBe` True
+      strDetect "z"  "banana"            `shouldBe` False
+      strDetect "\\d" "a1b"              `shouldBe` True   -- \d → [[:digit:]]
+      strDetect "\\d" "abc"              `shouldBe` False
+      strDetect "[[:digit:]]" "a1b"      `shouldBe` True
+      strDetect "\\bcat\\b" "the cat sat" `shouldBe` True
+      strDetect "\\bcat\\b" "category"    `shouldBe` False
+    it "strDetectWith: ignore_case (§15.5)" $ do
+      strDetect "ABC" "xabcy"            `shouldBe` False
+      strDetectWith True "ABC" "xabcy"   `shouldBe` True
+    it "strCount: マッチ回数" $
+      strCount "a" "banana"              `shouldBe` 3
+    it "strSubset / strWhich" $ do
+      strSubset "^a" ["apple","banana","avocado"] `shouldBe` ["apple","avocado"]
+      strWhich  "a"  ["xyz","cat","dog"]          `shouldBe` [2]
+    it "strExtract / strExtractAll (\\d+)" $ do
+      strExtract    "\\d+" "abc123def45" `shouldBe` Just "123"
+      strExtractAll "\\d+" "abc123def45" `shouldBe` ["123","45"]
+      strExtract    "\\d+" "no digits"   `shouldBe` Nothing
+    it "strMatch: capture groups (whole + groups)" $
+      strMatch "([a-z])-([0-9])" "x-5" `shouldBe`
+        [Just "x-5", Just "x", Just "5"]
+    it "strReplace / strReplaceAll + backref \\1\\2 (§15.3.3 swap)" $ do
+      strReplace    "a" "_" "banana"           `shouldBe` "b_nana"
+      strReplaceAll "a" "_" "banana"           `shouldBe` "b_n_n_"
+      strReplaceAll "([a-z])([a-z])" "\\2\\1" "abcd" `shouldBe` "badc"
+    it "strRemove / strRemoveAll" $ do
+      strRemove    "a" "banana" `shouldBe` "bnana"
+      strRemoveAll "a" "banana" `shouldBe` "bnn"
+    it "strSplit (delim + \\s+)" $ do
+      strSplit ","     "a,b,c"   `shouldBe` ["a","b","c"]
+      strSplit "\\s+"  "a  b c"  `shouldBe` ["a","b","c"]
+    it "strLocate: 1 始まり両端含む" $ do
+      strLocate "an" "banana" `shouldBe` Just (2,3)
+      strLocate "z"  "banana" `shouldBe` Nothing
+    it "strEscape: メタ文字をエスケープ" $
+      strEscape "a.b+c" `shouldBe` "a\\.b\\+c"
+    it "separateWiderRegex: 名前付きグループで列分割 (§15.3.4)" $ do
+      let dfR = DF.fromNamedColumns [("x", DF.fromList ["1-2","3-4" :: Text])]
+          out = separateWiderRegex "x"
+                  [(Just "a", "\\d+"), (Nothing, "-"), (Just "b", "\\d+")] dfR
+      DF.columnNames out `shouldMatchList` ["a","b"]
+      colMT "a" out `shouldBe` [Just "1", Just "3"]
+      colMT "b" out `shouldBe` [Just "2", Just "4"]
diff --git a/test/Hanalyze/Data/TransformSpec.hs b/test/Hanalyze/Data/TransformSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Data/TransformSpec.hs
@@ -0,0 +1,68 @@
+{-# LANGUAGE OverloadedStrings #-}
+-- | 'Hanalyze.Data.Transform' のテスト (Phase 66)。
+--   dplyr/base R の対応関数と数値・配置一致を確認する。 参照値は R 4.x。
+module Hanalyze.Data.TransformSpec (spec) where
+
+import Test.Hspec
+import Data.Ord (Down (..))
+import qualified Hanalyze.Data.Transform as T
+
+spec :: Spec
+spec = describe "Hanalyze.Data.Transform" $ do
+
+  describe "順位 (dplyr ranking)" $ do
+    -- x <- c(1, 5, 5, 17, 22)
+    let x = [1, 5, 5, 17, 22] :: [Int]
+    it "minRank (1,2,2,4,5)"     $ T.minRank x   `shouldBe` [1,2,2,4,5]
+    it "denseRank (1,2,2,3,4)"   $ T.denseRank x `shouldBe` [1,2,2,3,4]
+    it "rowNumber (1,2,3,4,5)"   $ T.rowNumber x `shouldBe` [1,2,3,4,5]
+    it "percentRank (0,.25,.25,.75,1)" $
+      T.percentRank x `shouldBe` [0, 0.25, 0.25, 0.75, 1]
+    it "cumeDist (.2,.6,.6,.8,1)" $
+      T.cumeDist x `shouldBe` [0.2, 0.6, 0.6, 0.8, 1.0]
+    it "minRank desc (= Down): 5,3,3,2,1" $
+      T.minRank (map Down x) `shouldBe` [5,3,3,2,1]
+
+  describe "順位 NA 保持 (R x=c(1,5,5,17,22,NA))" $ do
+    let xs = [Just 1, Just 5, Just 5, Just 17, Just 22, Nothing] :: [Maybe Int]
+    it "minRankNA" $
+      T.minRankNA xs `shouldBe` [Just 1, Just 2, Just 2, Just 4, Just 5, Nothing]
+    it "denseRankNA" $
+      T.denseRankNA xs `shouldBe` [Just 1, Just 2, Just 2, Just 3, Just 4, Nothing]
+    it "percentRankNA" $
+      T.percentRankNA xs `shouldBe` [Just 0, Just 0.25, Just 0.25, Just 0.75, Just 1, Nothing]
+
+  describe "オフセット" $ do
+    -- x <- c(2, 5, 11, 11, 19, 35)
+    let x = [2, 5, 11, 11, 19, 35] :: [Int]
+    it "lag 1 (先頭 default)" $
+      T.lag 1 (-1) x `shouldBe` [-1, 2, 5, 11, 11, 19]
+    it "lead 1 (末尾 default)" $
+      T.lead 1 (-1) x `shouldBe` [5, 11, 11, 19, 35, -1]
+    it "lag 2" $ T.lag 2 0 x `shouldBe` [0, 0, 2, 5, 11, 11]
+    it "lag n>=length = 全 default" $ T.lag 10 0 x `shouldBe` [0,0,0,0,0,0]
+
+  describe "累積" $ do
+    it "cumsum 1:10" $ T.cumsum [1..10 :: Int] `shouldBe` [1,3,6,10,15,21,28,36,45,55]
+    it "cumprod"  $ T.cumprod [1,2,3,4 :: Int] `shouldBe` [1,2,6,24]
+    it "cummin"   $ T.cummin [5,3,4,1,2 :: Int] `shouldBe` [5,3,3,1,1]
+    it "cummax"   $ T.cummax [1,3,2,5,4 :: Int] `shouldBe` [1,3,3,5,5]
+    it "cummean"  $ T.cummean [2,4,6] `shouldBe` [2,3,4]
+
+  describe "区間化 (cut・right=TRUE)" $ do
+    -- x <- c(1,2,5,10,15,20); cut(x, breaks=c(0,5,10,15,20))
+    --   → (0,5] (0,5] (0,5] (5,10] (10,15] (15,20]  = bin 1,1,1,2,3,4
+    let x = [1,2,5,10,15,20]
+    it "cut bin index" $
+      T.cut [0,5,10,15,20] x `shouldBe` [Just 1, Just 1, Just 1, Just 2, Just 3, Just 4]
+    it "範囲外 → Nothing" $
+      T.cut [0,5,10,15,20] [-10, 5, 10, 30] `shouldBe` [Nothing, Just 1, Just 2, Nothing]
+    it "ラベル付き" $
+      T.cutLabels (["sm","md","lg","xl"] :: [String]) [0,5,10,15,20] x
+        `shouldBe` [Just "sm", Just "sm", Just "sm", Just "md", Just "lg", Just "xl"]
+
+  describe "連続識別子 (consecutive_id)" $ do
+    -- x <- c("a","a","a","b","c","c","d","e","a","a","b","b")
+    it "値が変わるたび +1" $
+      T.consecutiveId (["a","a","a","b","c","c","d","e","a","a","b","b"] :: [String])
+        `shouldBe` [1,1,1,2,3,3,4,5,6,6,7,7]
diff --git a/test/Hanalyze/Data/WrangleSpec.hs b/test/Hanalyze/Data/WrangleSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Data/WrangleSpec.hs
@@ -0,0 +1,55 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications  #-}
+-- | 'Hanalyze.Data.Wrangle' のテスト (Phase 67)。
+--   dplyr summarise/mutate/group_by 相当の出力一致を確認する。
+module Hanalyze.Data.WrangleSpec (spec) where
+
+import Test.Hspec
+import           Data.Text (Text)
+import qualified DataFrame.Internal.Column    as DF
+import qualified DataFrame.Internal.DataFrame  as DF
+import qualified DataFrame.Operations.Core     as DF
+import qualified DataFrame.Operators           as DF
+import qualified DataFrame.Internal.Column as DFC
+import           DataFrame.Operators ((|>))
+import Hanalyze.Data.Wrangle
+
+near :: Double -> Double -> Bool
+near a b = abs (a - b) < 1e-9
+
+-- g = c(a,a,b,b,b), x = c(1,2,3,5,7)
+df :: DF.DataFrame
+df = DF.fromNamedColumns
+  [ ("g", DF.fromList (["a","a","b","b","b"] :: [Text]))
+  , ("x", DF.fromList ([1,2,3,5,7] :: [Double])) ]
+
+col :: forall a. DFC.Columnable a => Text -> DF.DataFrame -> [a]
+col n = DF.columnAsList (DF.col @a n)
+
+spec :: Spec
+spec = describe "Hanalyze.Data.Wrangle" $ do
+
+  describe "summarise (ungrouped・1 行)" $ do
+    let r = df |> summarise [ "mean" =: meanOf "x"
+                            , "q75"  =: quantileOf 0.75 "x"
+                            , "n"    =: nOf ]
+    it "1 行" $ fst (DF.dimensions r) `shouldBe` 1
+    it "mean(x) = 3.6" $ head (col @Double "mean" r) `shouldSatisfy` near 3.6
+    it "q75(x) = 5"    $ head (col @Double "q75" r)  `shouldSatisfy` near 5
+    it "n = 5 (Int 列)" $ col @Int "n" r `shouldBe` [5]
+
+  describe "groupBy + summarise (キー昇順)" $ do
+    let r = summarise [ "mean" =: meanOf "x", "n" =: nOf ] (groupBy ["g"] df)
+    it "2 群" $ fst (DF.dimensions r) `shouldBe` 2
+    it "キー昇順 a,b" $ col @Text "g" r `shouldBe` ["a","b"]
+    it "群 mean = [1.5, 5]" $
+      (col @Double "mean" r) `shouldSatisfy` (\ms -> near (ms!!0) 1.5 && near (ms!!1) 5)
+    it "群 n = [2,3]" $ col @Int "n" r `shouldBe` [2,3]
+
+  describe "mutate (元列温存 + 新列)" $ do
+    let r = df |> mutate [ "rank" =: minRankOf "x", "lag1" =: lagOf 1 "x" ]
+    it "元列 x 温存" $ col @Double "x" r `shouldBe` [1,2,3,5,7]
+    it "rank = 1..5" $ col @(Maybe Double) "rank" r
+        `shouldBe` [Just 1, Just 2, Just 3, Just 4, Just 5]
+    it "lag1 先頭 NA" $ col @(Maybe Double) "lag1" r
+        `shouldBe` [Nothing, Just 1, Just 2, Just 3, Just 5]
diff --git a/test/Hanalyze/DataIO/CSVSpec.hs b/test/Hanalyze/DataIO/CSVSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/DataIO/CSVSpec.hs
@@ -0,0 +1,228 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.DataIO.CSVSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Data.Vector as V
+import qualified Data.Text   as T
+import qualified DataFrame.Internal.Column    as DX
+import qualified DataFrame.Internal.DataFrame  as DX
+import qualified DataFrame.Operations.Core     as DX
+import qualified Hanalyze.DataIO.Preprocess as Pp
+import qualified Hanalyze.DataIO.Log        as Log
+import qualified Hanalyze.DataIO.CSV        as CSV
+import qualified Hanalyze.DataIO.Clean      as Clean
+import qualified Hanalyze.DataIO.Convert    as Conv2
+import qualified Hanalyze.Stat.AdaptiveGrid as AG
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.DataIO.CSV.loadAutoSafe" $ do
+    it "Empty file → Left, no exception" $
+      withSystemTempFile "ha-empty.csv" $ \fp h -> do
+        hPutStr h ""
+        hClose h
+        r <- CSV.loadAutoSafe fp
+        case r of
+          Left msg -> T.isInfixOf "Empty" (T.pack msg) `shouldBe` True
+          Right _  -> expectationFailure "expected Left for empty file"
+    it "Header-only file → Left" $
+      withSystemTempFile "ha-hdr.csv" $ \fp h -> do
+        hPutStr h "x,y,z\n"
+        hClose h
+        r <- CSV.loadAutoSafe fp
+        case r of
+          Left msg -> T.isInfixOf "header" (T.pack msg) `shouldBe` True
+          Right _  -> expectationFailure "expected Left for header-only file"
+    it "Valid CSV → Right with empty log by default" $
+      withSystemTempFile "ha-ok.csv" $ \fp h -> do
+        hPutStr h "x,y\n1,2\n3,4\n"
+        hClose h
+        r <- CSV.loadAutoSafe fp
+        case r of
+          Left  msg      -> expectationFailure ("unexpected Left: " ++ msg)
+          Right (_, lg)  -> Log.entries lg `shouldBe` []
+
+  describe "Hanalyze.DataIO.CSV.loadAutoSafeWith" $ do
+    it "--no-header: 先頭行をデータ行として扱い col0... を生成" $
+      withSystemTempFile "ha-noh.csv" $ \fp h -> do
+        hPutStr h "1,2\n3,4\n5,6\n"
+        hClose h
+        r <- CSV.loadAutoSafeWith
+               (CSV.defaultLoadOpts { CSV.loNoHeader = True }) fp
+        case r of
+          Left e -> expectationFailure ("unexpected Left: " ++ e)
+          Right (df, lg) -> do
+            let cols = DX.columnNames df
+            cols `shouldBe` ["col0", "col1"]
+            map Log.lgCode (Log.entries lg) `shouldContain` ["I012"]
+    it "--skip 2: 先頭 2 行を skip" $
+      withSystemTempFile "ha-skip.csv" $ \fp h -> do
+        hPutStr h "# c1\n# c2\nx,y\n1,2\n3,4\n"
+        hClose h
+        r <- CSV.loadAutoSafeWith
+               (CSV.defaultLoadOpts { CSV.loSkip = 2 }) fp
+        case r of
+          Left e -> expectationFailure ("unexpected Left: " ++ e)
+          Right (df, _) -> DX.columnNames df `shouldBe` ["x", "y"]
+    it "sniff: ヘッダ無し CSV を自動推論で col0... に変える" $
+      withSystemTempFile "ha-sniff-noh.csv" $ \fp h -> do
+        hPutStr h "1.0,2.0\n3.0,4.0\n"
+        hClose h
+        r <- CSV.loadAutoSafeWith CSV.defaultLoadOpts fp
+        case r of
+          Left e -> expectationFailure ("unexpected Left: " ++ e)
+          Right (df, lg) -> do
+            DX.columnNames df `shouldBe` ["col0", "col1"]
+            map Log.lgCode (Log.entries lg) `shouldContain` ["I013"]
+    it "sniff: コメント行 # を skip 推論" $
+      withSystemTempFile "ha-sniff-skip.csv" $ \fp h -> do
+        hPutStr h "# comment 1\n# comment 2\nx,y\n1,2\n3,4\n"
+        hClose h
+        r <- CSV.loadAutoSafeWith CSV.defaultLoadOpts fp
+        case r of
+          Left e -> expectationFailure ("unexpected Left: " ++ e)
+          Right (df, _) -> DX.columnNames df `shouldBe` ["x", "y"]
+    it "sniff: セミコロン区切りを自動検出" $
+      withSystemTempFile "ha-sniff-semi.csv" $ \fp h -> do
+        hPutStr h "a;b;c\n1;2;3\n4;5;6\n"
+        hClose h
+        r <- CSV.loadAutoSafeWith CSV.defaultLoadOpts fp
+        case r of
+          Left e -> expectationFailure ("unexpected Left: " ++ e)
+          Right (df, _) -> DX.columnNames df `shouldBe` ["a", "b", "c"]
+    it "sniff: --no-sniff で自動推論を切れる" $
+      withSystemTempFile "ha-no-sniff.csv" $ \fp h -> do
+        hPutStr h "1.0,2.0\n3.0,4.0\n"
+        hClose h
+        r <- CSV.loadAutoSafeWith
+               (CSV.defaultLoadOpts { CSV.loSniff = False }) fp
+        case r of
+          Left e -> expectationFailure ("unexpected Left: " ++ e)
+          Right (df, lg) -> do
+            -- ヘッダ無しの自動修復は走らないので col0 にはならない
+            DX.columnNames df `shouldBe` ["1.0", "2.0"]
+            -- 代わりに W001 が出る
+            map Log.lgCode (Log.entries lg) `shouldContain` ["W001"]
+
+    it "Clean.stripUnitsCol: 12.3kg → 12.3" $ do
+      let df0 = DX.insertColumn "w"
+                   (DX.fromList (["12.3kg", "11.5cm", "10kg"] :: [T.Text]))
+              $ DX.empty
+          (df1, lg) = Clean.applyRule Clean.StripUnits "w" df0
+      map Log.lgCode (Log.entries lg) `shouldContain` ["I100"]
+      case Conv2.getDoubleVec "w" df1 of
+        Just v  -> V.toList v `shouldBe` [12.3, 11.5, 10.0]
+        Nothing -> expectationFailure "expected numeric column"
+    it "Clean.parseCurrencyCol: $1,234.56 → 1234.56" $ do
+      let df0 = DX.insertColumn "p"
+                   (DX.fromList (["$1,234.56", "$2,500.00"] :: [T.Text]))
+              $ DX.empty
+          (df1, _) = Clean.applyRule Clean.ParseCurrency "p" df0
+      case Conv2.getDoubleVec "p" df1 of
+        Just v  -> V.toList v `shouldBe` [1234.56, 2500.0]
+        Nothing -> expectationFailure "expected numeric column"
+    it "Clean.coerceNumericCol: 混在パターンを最大限拾う" $ do
+      let df0 = DX.insertColumn "x"
+                   (DX.fromList (["12.3", "12.3kg", "$1,000"] :: [T.Text]))
+              $ DX.empty
+          (df1, _) = Clean.applyRule Clean.CoerceNumeric "x" df0
+      case Conv2.getDoubleVec "x" df1 of
+        Just v  -> V.toList v `shouldBe` [12.3, 12.3, 1000.0]
+        Nothing -> expectationFailure "expected all-success column"
+    it "Preprocess.meltLonger: wide → long、NA セルは除外、列名を Double に parse" $ do
+      let df0 = DX.insertColumn "id" (DX.fromList (["a", "b"] :: [T.Text]))
+              $ DX.insertColumn "1"  (DX.fromList ([Just 10.0, Nothing] :: [Maybe Double]))
+              $ DX.insertColumn "2"  (DX.fromList ([Just 20.0, Just 30.0] :: [Maybe Double]))
+              $ DX.insertColumn "3"  (DX.fromList ([Nothing,   Just 60.0] :: [Maybe Double]))
+              $ DX.empty
+          df1 = Pp.meltLonger ["id"] ["1", "2", "3"] "t" "y" True df0
+          (nrows, ncols) = DX.dimensions df1
+      nrows `shouldBe` 4    -- a,1=10; a,2=20; b,2=30; b,3=60
+      ncols `shouldBe` 3    -- id, t, y
+      DX.columnNames df1 `shouldMatchList` ["id", "t", "y"]
+      case Conv2.getDoubleVec "y" df1 of
+        Just v  -> sort (V.toList v) `shouldBe` [10, 20, 30, 60]
+        Nothing -> expectationFailure "expected y as numeric"
+      case Conv2.getDoubleVec "t" df1 of
+        Just v  -> sort (V.toList v) `shouldBe` [1, 2, 2, 3]
+        Nothing -> expectationFailure "expected t parsed as numeric"
+
+    it "Preprocess.regridLong: ZIntersection モードで全 id が共通範囲に収まる" $ do
+      -- id=a: z=0..3, id=b: z=1..4 → intersection は (1, 3)
+      let df0 = DX.insertColumn "id" (DX.fromList (["a","a","a","a","b","b","b","b"] :: [T.Text]))
+              $ DX.insertColumn "z"  (DX.fromList ([0,1,2,3,1,2,3,4] :: [Double]))
+              $ DX.insertColumn "y"  (DX.fromList ([0,1,4,9,1,4,9,16] :: [Double]))
+              $ DX.empty
+          opts = Pp.defaultRegridOpts
+                   { Pp.roN = 5, Pp.roZBoundsMode = Pp.ZIntersection
+                   , Pp.roGridKind = AG.Uniform }
+          rr = Pp.regridLong "id" "z" "y" opts df0
+      Pp.rrZMin rr `shouldBe` 1.0
+      Pp.rrZMax rr `shouldBe` 3.0
+      length (Pp.rrZGrid rr) `shouldBe` 5
+      length (Pp.rrIds rr) `shouldBe` 2
+
+    it "Preprocess.regridLong: ZUnion モードで [min,max] が和集合になる" $ do
+      let df0 = DX.insertColumn "id" (DX.fromList (["a","a","b","b"] :: [T.Text]))
+              $ DX.insertColumn "z"  (DX.fromList ([0,2,1,3] :: [Double]))
+              $ DX.insertColumn "y"  (DX.fromList ([0,4,1,9] :: [Double]))
+              $ DX.empty
+          opts = Pp.defaultRegridOpts
+                   { Pp.roN = 4, Pp.roZBoundsMode = Pp.ZUnion
+                   , Pp.roGridKind = AG.Uniform }
+          rr = Pp.regridLong "id" "z" "y" opts df0
+      Pp.rrZMin rr `shouldBe` 0.0
+      Pp.rrZMax rr `shouldBe` 3.0
+      -- 外挿が記録される (id=a の上端 0..2、共通 0..3 → above=1)
+      let stat_a = head [s | s <- Pp.rrPerIdStats rr, Pp.piId s == "a"]
+      Pp.piExtrapAbove stat_a `shouldBe` 1.0
+
+    it "Clean.cleanPipeline: 複数列を一括変換" $ do
+      let df0 = DX.insertColumn "p"
+                   (DX.fromList (["$10", "$20"] :: [T.Text]))
+              $ DX.insertColumn "w"
+                   (DX.fromList (["1kg", "2kg"]   :: [T.Text]))
+              $ DX.empty
+          rules = [("p", Clean.ParseCurrency), ("w", Clean.StripUnits)]
+          (df1, lg) = Clean.cleanPipeline rules df0
+          codes = map Log.lgCode (Log.entries lg)
+      codes `shouldContain` ["I101"]
+      codes `shouldContain` ["I100"]
+      Conv2.getDoubleVec "p" df1 `shouldSatisfy` \mv ->
+        case mv of { Just v -> V.toList v == [10, 20]; Nothing -> False }
+
+    it "--strict + 警告ありデータ (sniff off) → Left" $
+      withSystemTempFile "ha-strict.csv" $ \fp h -> do
+        hPutStr h "1.0,2.0\n3.0,4.0\n"  -- ヘッダ無し疑い W001
+        hClose h
+        -- sniff を切ると W001 が残るので strict が短絡する
+        r <- CSV.loadAutoSafeWith
+               (CSV.defaultLoadOpts { CSV.loStrict = True
+                                    , CSV.loSniff  = False }) fp
+        case r of
+          Left _   -> return ()
+          Right _  -> expectationFailure "expected Left under --strict --no-sniff"
+
+  -- ===========================================================================
+  -- 多出力 API の q=1 等価性 (M1〜M8)
+  -- ===========================================================================
diff --git a/test/Hanalyze/DataIO/ConvertSpec.hs b/test/Hanalyze/DataIO/ConvertSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/DataIO/ConvertSpec.hs
@@ -0,0 +1,51 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.DataIO.ConvertSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Data.Vector as V
+import qualified Hanalyze.DataIO.CSV        as CSV
+import qualified Hanalyze.DataIO.Convert    as Conv
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.DataIO.Convert deep-eval" $ do
+    it "getMaybeTextVec on text column with mixed NA strings returns Just" $
+      withSystemTempFile "ha-na.csv" $ \fp h -> do
+        -- 複数 NA 表現を混ぜると Hackage は Maybe Text 列として保持する。
+        -- ヘッダ判定で n/a / null は欠損扱い → null bitmap が立つ。
+        hPutStr h "id,score\n1,A\n2,n/a\n3,null\n4,B\n5,-\n"
+        hClose h
+        r <- CSV.loadAutoSafe fp
+        case r of
+          Right (df, _) -> case Conv.getMaybeTextVec "score" df of
+            Just v  -> length (V.toList v) `shouldBe` 5
+            Nothing -> expectationFailure "getMaybeTextVec returned Nothing"
+          Left msg -> expectationFailure ("load failed: " ++ msg)
+    it "getDoubleVec returns Nothing without crashing on NA-mixed numeric column" $
+      withSystemTempFile "ha-na2.csv" $ \fp h -> do
+        hPutStr h "id,score\n1,85\n2,NA\n3,92\n"
+        hClose h
+        r <- CSV.loadAutoSafe fp
+        case r of
+          Right (df, _) -> Conv.getDoubleVec "score" df `shouldBe` Nothing
+          Left msg      -> expectationFailure ("load failed: " ++ msg)
diff --git a/test/Hanalyze/DataIO/HealthSpec.hs b/test/Hanalyze/DataIO/HealthSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/DataIO/HealthSpec.hs
@@ -0,0 +1,65 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.DataIO.HealthSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Data.Text   as T
+import qualified DataFrame.Internal.Column    as DX
+import qualified DataFrame.Internal.DataFrame  as DX
+import qualified Hanalyze.DataIO.Log        as Log
+import qualified Hanalyze.DataIO.Health     as Health
+import qualified Data.ByteString   as BS
+import qualified Hanalyze.Stat.BridgeSampling as BS
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.DataIO.Health" $ do
+    it "W001: ヘッダ無し疑い (列名が全て数値)" $ do
+      let df = DX.insertColumn "1.0" (DX.fromList ([2.0, 4.0] :: [Double]))
+             $ DX.insertColumn "2.0" (DX.fromList ([4.1, 8.0] :: [Double]))
+             $ DX.empty
+          codes = map Log.lgCode (Log.entries (Health.detectHeaderless df))
+      codes `shouldContain` ["W001"]
+    it "W001 は通常ヘッダでは発火しない" $ do
+      let df = DX.insertColumn "x" (DX.fromList ([1.0, 2.0] :: [Double]))
+             $ DX.empty
+      Log.entries (Health.detectHeaderless df) `shouldBe` []
+    it "W002: コメント行 (# 始まり) を検出" $ do
+      let preview = "# header comment\n# more comment\nx,y\n1,2\n"
+          codes   = map Log.lgCode (Log.entries (Health.detectCommentLines preview))
+      codes `shouldContain` ["W002"]
+    it "W005: 1 列 DataFrame + プレビューにタブ → delimiter ミスマッチ" $ do
+      let df = DX.insertColumn "x\ty" (DX.fromList ([1.0] :: [Double]))
+             $ DX.empty
+          preview = "x\ty\n1\t2\n3\t4\n"
+          codes = map Log.lgCode
+                    (Log.entries (Health.detectDelimiterMismatch preview df))
+      codes `shouldContain` ["W005"]
+    it "W008: 通貨記号付き列を検出" $ do
+      let df = DX.insertColumn "price"
+                 (DX.fromList (["$1,234.56", "$2,500.00", "$3,000.00", "$4,000"] :: [T.Text]))
+             $ DX.empty
+          codes = map Log.lgCode (Log.entries (Health.detectThousandsCurrency df))
+      codes `shouldContain` ["W008"]
+    -- BS インポートを使う何かのスモーク (未使用 warning 防止)
+    it "preview is non-empty for typical use" $
+      BS.length "x,y\n1,2" `shouldSatisfy` (> 0)
diff --git a/test/Hanalyze/DataIO/LogSpec.hs b/test/Hanalyze/DataIO/LogSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/DataIO/LogSpec.hs
@@ -0,0 +1,60 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.DataIO.LogSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Data.Text   as T
+import qualified Hanalyze.DataIO.Log        as Log
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.DataIO.Log" $ do
+    it "Monoid: noLog <> r == r" $ do
+      let r = Log.logReport (Log.mkWarn "W001" "msg" Nothing)
+      Log.entries (Log.noLog <> r) `shouldBe` Log.entries r
+      Log.entries (r <> Log.noLog) `shouldBe` Log.entries r
+    it "addEntry appends" $ do
+      let r0 = Log.logReport (Log.mkInfo "I001" "first" Nothing)
+          r1 = Log.addEntry (Log.mkWarn "W001" "second" (Just "ヒント")) r0
+      length (Log.entries r1) `shouldBe` 2
+      Log.lgSev  (last (Log.entries r1)) `shouldBe` Log.Warn
+      Log.lgHint (last (Log.entries r1)) `shouldBe` Just "ヒント"
+    it "hasErrors / hasWarnings detect severity" $ do
+      let rW = Log.logReport (Log.mkWarn "W"  "w"  Nothing)
+          rE = Log.logReport (Log.mkErr  "E"  "e"  Nothing)
+      Log.hasWarnings rW         `shouldBe` True
+      Log.hasErrors   rW         `shouldBe` False
+      Log.hasErrors   (rW <> rE) `shouldBe` True
+    it "severityCount counts each level" $ do
+      let r = Log.logReport (Log.mkInfo "I" "i" Nothing)
+            <> Log.logReport (Log.mkWarn "W1" "w" Nothing)
+            <> Log.logReport (Log.mkWarn "W2" "w" Nothing)
+            <> Log.logReport (Log.mkErr  "E"  "e" Nothing)
+      Log.severityCount Log.Info r `shouldBe` 1
+      Log.severityCount Log.Warn r `shouldBe` 2
+      Log.severityCount Log.Err  r `shouldBe` 1
+    it "prettyEntry: includes code, message, hint" $ do
+      let s = Log.prettyEntry (Log.mkWarn "W042" "壊れている" (Just "助言"))
+      T.isInfixOf "[WARN]" s   `shouldBe` True
+      T.isInfixOf "W042"   s   `shouldBe` True
+      T.isInfixOf "壊れている" s `shouldBe` True
+      T.isInfixOf "助言"   s   `shouldBe` True
diff --git a/test/Hanalyze/DataIO/PreprocessSpec.hs b/test/Hanalyze/DataIO/PreprocessSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/DataIO/PreprocessSpec.hs
@@ -0,0 +1,145 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.DataIO.PreprocessSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Data.Text   as T
+import qualified DataFrame.Internal.Column    as DX
+import qualified DataFrame.Internal.DataFrame  as DX
+import qualified DataFrame.Operations.Core     as DX
+import qualified DataFrame.Operators           as DX
+import qualified Hanalyze.DataIO.Preprocess as Pp
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.DataIO.Preprocess" $ do
+    let dfNA = DX.fromNamedColumns
+                 [ ("group", DX.fromList (["A","B","A","B","C"] :: [T.Text]))
+                 , ("x",     DX.fromList (["1","NA","3","","5"]   :: [T.Text]))
+                 , ("y",     DX.fromList ([10, 20, 30, 40, 50]    :: [Double]))
+                 ]
+
+    it "isNAString detects standard NA strings" $ do
+      Pp.isNAString "NA"    `shouldBe` True
+      Pp.isNAString "N/A"   `shouldBe` True
+      Pp.isNAString "null"  `shouldBe` True
+      Pp.isNAString ""      `shouldBe` True
+      Pp.isNAString "  "    `shouldBe` True
+      Pp.isNAString "valid" `shouldBe` False
+
+    it "countMissing counts NAs in Text columns; numeric is 0" $ do
+      let counts = Pp.countMissing dfNA
+      lookup "x"     counts `shouldBe` Just 2
+      lookup "y"     counts `shouldBe` Just 0
+      lookup "group" counts `shouldBe` Just 0
+
+    it "dropMissingRows removes rows with NA in target columns" $ do
+      let df' = Pp.dropMissingRows ["x"] dfNA
+          (n, _) = DX.dimensions df'
+      n `shouldBe` 3   -- only rows with x ∈ {"1","3","5"} remain
+
+    it "imputeMean converts Text/NA column to Double with mean fill" $ do
+      case Pp.imputeMean "x" dfNA of
+        Just df' -> do
+          let xs = DX.columnAsList (DX.col @Double "x") df'
+          length xs `shouldBe` 5
+          -- mean of [1, 3, 5] = 3
+          (xs !! 1) `shouldBe` 3.0   -- was "NA"
+          (xs !! 3) `shouldBe` 3.0   -- was ""
+        Nothing -> expectationFailure "imputeMean failed"
+
+    it "selectColumns retains only listed columns" $ do
+      let df' = Pp.selectColumns ["y", "group"] dfNA
+      DX.columnNames df' `shouldMatchList` ["y", "group"]
+
+    it "filterRowsByNumeric filters numeric column" $ do
+      let df' = Pp.filterRowsByNumeric "y" (>= 30) dfNA
+          (n, _) = DX.dimensions df'
+      n `shouldBe` 3
+
+    it "mapNumeric applies a unary function" $ do
+      let df' = Pp.mapNumeric "y" (* 2) dfNA
+          xs = DX.columnAsList (DX.col @Double "y") df'
+      xs `shouldBe` [20, 40, 60, 80, 100]
+
+  -- ─────────────────────────────────────────────────────────────────────
+
+  describe "Hanalyze.DataIO.Preprocess (groupBy)" $ do
+    let dfGrp = DX.fromNamedColumns
+                  [ ("group", DX.fromList (["A","B","A","B","A","C"] :: [T.Text]))
+                  , ("y",     DX.fromList ([1, 4, 3, 6, 5, 10]       :: [Double]))
+                  ]
+
+    it "groupByMean computes per-group mean" $ do
+      case Pp.groupByMean "group" "y" dfGrp of
+        Just df' -> do
+          let (n, _) = DX.dimensions df'
+          n `shouldBe` 3
+          let gs = DX.columnAsList (DX.col @T.Text "group") df'
+              vs = DX.columnAsList (DX.col @Double "y")    df'
+              pairs = zip gs vs
+          lookup "A" pairs `shouldBe` Just 3.0       -- (1+3+5)/3
+          lookup "B" pairs `shouldBe` Just 5.0       -- (4+6)/2
+          lookup "C" pairs `shouldBe` Just 10.0
+        Nothing -> expectationFailure "groupByMean failed"
+
+    it "groupBySum computes per-group sum" $ do
+      case Pp.groupBySum "group" "y" dfGrp of
+        Just df' -> do
+          let gs = DX.columnAsList (DX.col @T.Text "group") df'
+              vs = DX.columnAsList (DX.col @Double "y")    df'
+              pairs = zip gs vs
+          lookup "A" pairs `shouldBe` Just 9.0
+          lookup "B" pairs `shouldBe` Just 10.0
+        Nothing -> expectationFailure "groupBySum failed"
+
+    it "groupByCount counts rows per group" $ do
+      case Pp.groupByCount "group" dfGrp of
+        Just df' -> do
+          let gs = DX.columnAsList (DX.col @T.Text "group") df'
+              vs = DX.columnAsList (DX.col @Double "count") df'
+              pairs = zip gs vs
+          lookup "A" pairs `shouldBe` Just 3.0
+          lookup "B" pairs `shouldBe` Just 2.0
+          lookup "C" pairs `shouldBe` Just 1.0
+        Nothing -> expectationFailure "groupByCount failed"
+
+    it "groupByMin/Max return correct extremes" $ do
+      case Pp.groupByMin "group" "y" dfGrp of
+        Just dfMin -> do
+          let gs = DX.columnAsList (DX.col @T.Text "group") dfMin
+              vs = DX.columnAsList (DX.col @Double "y")    dfMin
+              pairs = zip gs vs
+          lookup "A" pairs `shouldBe` Just 1.0
+          lookup "B" pairs `shouldBe` Just 4.0
+        Nothing -> expectationFailure "groupByMin failed"
+
+      case Pp.groupByMax "group" "y" dfGrp of
+        Just dfMax -> do
+          let gs = DX.columnAsList (DX.col @T.Text "group") dfMax
+              vs = DX.columnAsList (DX.col @Double "y")    dfMax
+              pairs = zip gs vs
+          lookup "A" pairs `shouldBe` Just 5.0
+          lookup "B" pairs `shouldBe` Just 6.0
+        Nothing -> expectationFailure "groupByMax failed"
+
+  -- ─────────────────────────────────────────────────────────────────────
diff --git a/test/Hanalyze/DataIO/ReshapeSpec.hs b/test/Hanalyze/DataIO/ReshapeSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/DataIO/ReshapeSpec.hs
@@ -0,0 +1,72 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.DataIO.ReshapeSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Data.Text   as T
+import qualified DataFrame.Internal.Column    as DX
+import qualified DataFrame.Internal.DataFrame  as DX
+import qualified Hanalyze.DataIO.Reshape    as Reshape
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.DataIO.Reshape" $ do
+    it "lagColumn(1): 先頭が NaN、残りは 1 つずれる" $ do
+      let df = DX.fromNamedColumns
+                 [("x", DX.fromList [1.0, 2.0, 3.0, 4.0, 5.0 :: Double])]
+          df' = Reshape.lagColumn 1 "x" "x_lag1" df
+      -- Lagged column should exist
+      "x_lag1" `elem` DX.columnNames df' `shouldBe` True
+
+    it "leadColumn(1): 末尾が NaN、残りは 1 つ前進" $ do
+      let df = DX.fromNamedColumns
+                 [("x", DX.fromList [1.0, 2.0, 3.0, 4.0, 5.0 :: Double])]
+          df' = Reshape.leadColumn 1 "x" "x_lead1" df
+      "x_lead1" `elem` DX.columnNames df' `shouldBe` True
+
+    it "rollingMean(3): 最初 2 つが NaN、3 番目以降は窓内平均" $ do
+      let df = DX.fromNamedColumns
+                 [("x", DX.fromList [1.0, 2.0, 3.0, 4.0, 5.0 :: Double])]
+          df' = Reshape.rollingMean 3 "x" "x_rmean3" df
+      "x_rmean3" `elem` DX.columnNames df' `shouldBe` True
+
+    it "oneHot: text 列を indicator 列に展開" $ do
+      let df = DX.fromNamedColumns
+                 [ ("id", DX.fromList [1, 2, 3, 4, 5 :: Int])
+                 , ("category", DX.fromList ["A", "B", "A", "C", "B" :: T.Text])
+                 ]
+          df' = Reshape.oneHot False "category" df
+      let cols = DX.columnNames df'
+      "category" `elem` cols `shouldBe` False  -- 元列削除
+      "category_A" `elem` cols `shouldBe` True
+      "category_B" `elem` cols `shouldBe` True
+      "category_C" `elem` cols `shouldBe` True
+
+    it "oneHot dropFirst=True: 1 列分減る" $ do
+      let df = DX.fromNamedColumns
+                 [("c", DX.fromList ["X", "Y", "Z" :: T.Text])]
+          df' = Reshape.oneHot True "c" df
+      length (DX.columnNames df') `shouldBe` 2  -- Y, Z (X drop)
+
+  -- ===========================================================================
+  -- Hanalyze.Stat.Effect (Phase 9)
+  -- ===========================================================================
diff --git a/test/Hanalyze/Design/ConstraintSpec.hs b/test/Hanalyze/Design/ConstraintSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Design/ConstraintSpec.hs
@@ -0,0 +1,72 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Design.ConstraintSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Numeric.LinearAlgebra as LA
+import qualified Hanalyze.Design.Optimal       as OPT
+import qualified Hanalyze.Design.Constraint    as DCons
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Design.Constraint (Phase 23-b)" $ do
+    let cands =
+          [ [1, x1, x2] | x1 <- [-1, 0, 1], x2 <- [-1, 0, 1] ]
+    it "checkRow: LinearConstraint x1 + x2 <= 1 を判定" $ do
+      let c = DCons.LinearConstraint [0, 1, 1] DCons.CLeq 1
+      DCons.checkRow [c] [1, -1, -1] `shouldBe` True
+      DCons.checkRow [c] [1,  1,  1] `shouldBe` False
+      DCons.checkRow [c] [1,  0,  1] `shouldBe` True
+    it "checkRow: ForbiddenCombination で完全一致を弾く" $ do
+      let c = DCons.ForbiddenCombination [1, 1, 1]
+      DCons.checkRow [c] [1, 1, 1] `shouldBe` False
+      DCons.checkRow [c] [1, 0, 1] `shouldBe` True
+    it "checkRow: 浮動小数 tolerance (1e-9) で ForbiddenCombination 判定" $ do
+      let c = DCons.ForbiddenCombination [1, 1]
+      DCons.checkRow [c] [1, 1 + 1e-11] `shouldBe` False  -- tol 内 = 一致 = 違反
+      DCons.checkRow [c] [1, 1 + 1e-6 ] `shouldBe` True   -- tol 外 = OK
+    it "checkDesign: 違反 row index を列挙" $ do
+      let c    = DCons.LinearConstraint [0, 1, 1] DCons.CLeq 1
+          mat  = LA.fromLists [[1,-1,-1],[1,1,1],[1,0,1],[1,1,0.5]]
+      DCons.checkDesign [c] mat `shouldBe` [1, 3]
+    it "filterCandidates: x1 + x2 <= 1 で 9 候補 → 8 候補 (排除: (1,1))" $ do
+      let c   = DCons.LinearConstraint [0, 1, 1] DCons.CLeq 1
+          fc  = DCons.filterCandidates [c] cands
+      length fc `shouldBe` 8
+    it "filterCandidates 後の候補で dOptimal が動作 (5 行抽出)" $ do
+      let c   = DCons.LinearConstraint [0, 1, 1] DCons.CLeq 1
+          fc  = DCons.filterCandidates [c] cands
+          (idxs, des) = OPT.dOptimal fc 5 42
+      length idxs `shouldBe` 5
+      length des `shouldBe` 5
+    it "より厳しい制約 x1 + x2 <= 0 で 9 候補 → 6 候補に縮約" $ do
+      -- 排除: (0,1)=1、 (1,0)=1、 (1,1)=2 の 3 件
+      let c   = DCons.LinearConstraint [0, 1, 1] DCons.CLeq 0
+          fc  = DCons.filterCandidates [c] cands
+      length fc `shouldBe` 6
+    it "CEq / CGeq 判定" $ do
+      let cEq  = DCons.LinearConstraint [0, 1, 1] DCons.CEq  0
+          cGeq = DCons.LinearConstraint [0, 1, 1] DCons.CGeq 1
+      DCons.checkRow [cEq]  [1, -1, 1] `shouldBe` True
+      DCons.checkRow [cEq]  [1,  1, 1] `shouldBe` False
+      DCons.checkRow [cGeq] [1,  1, 1] `shouldBe` True
+      DCons.checkRow [cGeq] [1, -1, 0] `shouldBe` False
diff --git a/test/Hanalyze/Design/Custom/AugmentSpec.hs b/test/Hanalyze/Design/Custom/AugmentSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Design/Custom/AugmentSpec.hs
@@ -0,0 +1,130 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Design.Custom.AugmentSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Numeric.LinearAlgebra as LA
+import qualified Hanalyze.Stat.ClassMetrics as CM
+import qualified Hanalyze.Design.Optimal       as OPT
+import qualified Hanalyze.Design.Custom.Factor     as CF
+import qualified Hanalyze.Design.Custom.Model      as CM
+import qualified Hanalyze.Design.Custom.Coordinate as CX
+import qualified Hanalyze.Design.Custom.Augment    as CAUG
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Design.Custom.Augment (Phase 25-6/7/8)" $ do
+    let f1a = CF.Factor "x1" (CF.Continuous (-1) 1) CF.Controllable
+        f2a = CF.Factor "x2" (CF.Continuous (-1) 1) CF.Controllable
+        fcA = CF.Factor "cat" (CF.Categorical ["A","B"]) CF.Controllable
+        modelA = CM.Model
+          [CM.TIntercept, CM.TMain "x1", CM.TMain "x2"] CM.NCoded
+        rawExisting = LA.fromLists [[-1,-1],[1,-1],[-1,1],[1,1]]
+        baseSpec = CX.CustomDesignSpec
+          { CX.cdsFactors = [f1a, f2a]
+          , CX.cdsModel   = modelA
+          , CX.cdsConstraints = []
+          , CX.cdsNRuns   = 4
+          , CX.cdsCriterion = OPT.DOpt
+          , CX.cdsBudget    = CX.defaultBudget
+          , CX.cdsSeed      = Just 0
+          , CX.cdsInitial   = Just rawExisting
+
+          , CX.cdsDJConvention = False
+          }
+    it "cdsInitial = Nothing で Left" $ do
+      let s = baseSpec { CX.cdsInitial = Nothing }
+      r <- CAUG.augmentMenu s (CAUG.Replicate 1)
+      case r of Left _ -> pure (); Right _ -> expectationFailure "expected Left"
+    it "Replicate 2: 既存 4 行を 2 回複製、 合計 12 行" $ do
+      Right r <- CAUG.augmentMenu baseSpec (CAUG.Replicate 2)
+      LA.rows (CAUG.amrMatrix r) `shouldBe` 12
+      CAUG.amrAdded r `shouldBe` 8
+      CAUG.amrMethod r `shouldBe` "Replicate"
+    it "AddCenter 3: 中心 3 行追加 (全 0)" $ do
+      Right r <- CAUG.augmentMenu baseSpec (CAUG.AddCenter 3)
+      LA.rows (CAUG.amrMatrix r) `shouldBe` 7
+      let lastRows = drop 4 (LA.toLists (CAUG.amrMatrix r))
+      all (all (== 0)) lastRows `shouldBe` True
+    it "AddAxial α=1.5: 連続 2 因子 → 4 axial 点追加 (2 * 2 factors)" $ do
+      Right r <- CAUG.augmentMenu baseSpec (CAUG.AddAxial 1.5 False)
+      LA.rows (CAUG.amrMatrix r) `shouldBe` 8
+      CAUG.amrAdded r `shouldBe` 4
+      let axial = drop 4 (LA.toLists (CAUG.amrMatrix r))
+      -- 各 axial 行は 1 因子だけ ±1.5、 残り 0
+      all (\row -> length (filter (\v -> abs v > 1e-9) row) == 1) axial `shouldBe` True
+    it "Phase 28-10 AddAxial rawUnits=True: Continuous (500, 560) で center 530 ± α·30" $ do
+      let fT = CF.Factor "T" (CF.Continuous 500 560) CF.Controllable
+          rawExisting' = LA.fromLists [[500], [560]]
+          spec' = CX.CustomDesignSpec
+            { CX.cdsFactors = [fT]
+            , CX.cdsModel   = CM.Model [CM.TIntercept, CM.TMain "T"] CM.NCoded
+            , CX.cdsConstraints = []
+            , CX.cdsNRuns   = 2
+            , CX.cdsCriterion = OPT.DOpt
+            , CX.cdsBudget    = CX.defaultBudget
+            , CX.cdsSeed      = Just 1
+            , CX.cdsInitial   = Just rawExisting'
+            , CX.cdsDJConvention = False
+            }
+      Right r <- CAUG.augmentMenu spec' (CAUG.AddAxial 1.4 True)
+      let axial = drop 2 (LA.toLists (CAUG.amrMatrix r))
+      -- center=530、 half-range=30、 axial = 530 ± 1.4·30 = 530 ± 42 = [488, 572]
+      axial `shouldBe` [[572], [488]]
+    it "AddRuns 2: 候補集合から 2 行追加" $ do
+      Right r <- CAUG.augmentMenu baseSpec (CAUG.AddRuns 2)
+      LA.rows (CAUG.amrMatrix r) `shouldBe` 6
+      CAUG.amrAdded r `shouldBe` 2
+    it "Phase 28-7 Foldover CategoricalSwap: cat 因子 A→B / B→A を swap した行追加" $ do
+      let fc = CF.Factor "c" (CF.Categorical ["A","B","C"]) CF.Controllable
+          fx = CF.Factor "x" (CF.Continuous (-1) 1) CF.Controllable
+          existing = LA.fromLists [[0, -1], [1, 1], [2, 0]]  -- c=A/B/C、 x=-1/1/0
+          spec' = CX.CustomDesignSpec
+            { CX.cdsFactors = [fc, fx]
+            , CX.cdsModel   = CM.Model [CM.TIntercept] CM.NCoded
+            , CX.cdsConstraints = []
+            , CX.cdsNRuns   = 3
+            , CX.cdsCriterion = OPT.DOpt
+            , CX.cdsBudget    = CX.defaultBudget
+            , CX.cdsSeed      = Just 1
+            , CX.cdsInitial   = Just existing
+            , CX.cdsDJConvention = False
+            }
+      Right r <- CAUG.augmentMenu spec'
+        (CAUG.Foldover (CAUG.CategoricalSwap [("c", [("A","B"),("B","A")])]))
+      let swapped = drop 3 (LA.toLists (CAUG.amrMatrix r))
+      -- 期待: A(0) → B(1)、 B(1) → A(0)、 C(2) はそのまま
+      -- x 列はそのまま
+      swapped `shouldBe` [[1, -1], [0, 1], [2, 0]]
+    it "Foldover Full: 連続 2 因子 → 全部の符号 flip した 4 行追加" $ do
+      Right r <- CAUG.augmentMenu baseSpec (CAUG.Foldover CAUG.FullFoldover)
+      LA.rows (CAUG.amrMatrix r) `shouldBe` 8
+      let flipped = drop 4 (LA.toLists (CAUG.amrMatrix r))
+      flipped `shouldBe` [[1,1],[-1,1],[1,-1],[-1,-1]]
+    it "Foldover Partial [x1]: x1 のみ flip" $ do
+      Right r <- CAUG.augmentMenu baseSpec
+        (CAUG.Foldover (CAUG.PartialFoldover ["x1"]))
+      let flipped = drop 4 (LA.toLists (CAUG.amrMatrix r))
+      flipped `shouldBe` [[1,-1],[-1,-1],[1,1],[-1,1]]
+    it "Replicate 0 は Left" $ do
+      r <- CAUG.augmentMenu baseSpec (CAUG.Replicate 0)
+      case r of Left _ -> pure (); Right _ -> expectationFailure "expected Left"
diff --git a/test/Hanalyze/Design/Custom/BayesianSpec.hs b/test/Hanalyze/Design/Custom/BayesianSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Design/Custom/BayesianSpec.hs
@@ -0,0 +1,145 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Design.Custom.BayesianSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Data.Text   as T
+import qualified Numeric.LinearAlgebra as LA
+import qualified Hanalyze.Stat.ClassMetrics as CM
+import qualified Hanalyze.Design.Optimal       as OPT
+import qualified Hanalyze.Design.Custom.Factor     as CF
+import qualified Hanalyze.Design.Custom.Model      as CM
+import qualified Hanalyze.Design.Custom.Coordinate as CX
+import qualified Hanalyze.Design.Custom.Compare    as CCMP
+import qualified Hanalyze.Design.Custom.Bayesian   as CB
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Design.Custom.Bayesian (Phase 26 BayesianD)" $ do
+    let f1b = CF.Factor "x1" (CF.Continuous (-1) 1) CF.Controllable
+        f2b = CF.Factor "x2" (CF.Continuous (-1) 1) CF.Controllable
+        modelRSM = CM.Model
+          [ CM.TIntercept, CM.TMain "x1", CM.TMain "x2"
+          , CM.TInter ["x1","x2"]
+          , CM.TPower "x1" 2, CM.TPower "x2" 2
+          ] CM.NCoded
+    it "priorPrecisionDefault: intercept / 主効果 = 0、 2fi / 二乗 = τ²" $ do
+      let pp = CB.priorPrecisionDefault [f1b, f2b] modelRSM 1.5
+          km = LA.fromLists (CB.precisionToMatrix pp)
+      LA.rows km `shouldBe` 6
+      LA.cols km `shouldBe` 6
+      -- 対角: [0 (intercept), 0 (x1), 0 (x2), 1.5 (x1*x2), 1.5 (x1²), 1.5 (x2²)]
+      LA.toList (LA.takeDiag km) `shouldBe` [0, 0, 0, 1.5, 1.5, 1.5]
+    it "bayesianDValueM: K = 0 で classic D に縮退" $ do
+      let pp = CB.PriorPrecision (LA.konst 0 (3, 3))
+          x  = LA.fromLists [[1,-1,-1],[1,1,-1],[1,-1,1],[1,1,1]]
+      -- |X'X| = 64
+      CB.bayesianDValueM pp x `shouldBe` 64
+    it "bayesianDValueM: K > 0 で det(X'X + K) > det(X'X)" $ do
+      let x  = LA.fromLists [[1,-1,-1],[1,1,-1],[1,-1,1],[1,1,1]]
+          k0 = CB.PriorPrecision (LA.konst 0 (3, 3))
+          k1 = CB.PriorPrecision (LA.diagl [0, 1, 1])
+      let d0 = CB.bayesianDValueM k0 x
+          d1 = CB.bayesianDValueM k1 x
+      d1 `shouldSatisfy` (> d0)
+    it "OptCriterion.BayesianD: coordinateExchange で動作 (連続 2 因子)" $ do
+      let pp = CB.priorPrecisionDefault [f1b, f2b] modelRSM 1.0
+          spec = CX.CustomDesignSpec
+            { CX.cdsFactors = [f1b, f2b]
+            , CX.cdsModel   = modelRSM
+            , CX.cdsConstraints = []
+            , CX.cdsNRuns   = 10
+            , CX.cdsCriterion = OPT.BayesianD (CB.precisionToMatrix pp)
+            , CX.cdsBudget    = CX.defaultBudget
+                { CX.dbRestarts = 3, CX.dbMaxIter = 20 }
+            , CX.cdsSeed      = Just 77
+            , CX.cdsInitial   = Nothing
+
+            , CX.cdsDJConvention = False
+            }
+      r <- CX.coordinateExchange spec
+      case r of
+        Left e -> expectationFailure (T.unpack e)
+        Right cd -> do
+          LA.rows (CX.cdMatrix cd) `shouldBe` 10
+          -- criterion 値が finite + 0 より小さい (= - det > 0)
+          CX.crCriterionValue (CX.cdReport cd) `shouldSatisfy` (< 0)
+    it "Phase 28-12a djTransformColumns: paper §2.2 例 (x²→x²-0.5、 x³→(x³-0.85x)/0.6) と一致" $ do
+      let fx  = CF.Factor "x" (CF.Continuous (-1) 1) CF.Controllable
+          m   = CM.Model
+                  [CM.TIntercept, CM.TMain "x", CM.TPower "x" 2, CM.TPower "x" 3]
+                  CM.NCoded
+          -- 候補集合: {-1, -0.5, 0, 0.5, 1} (paper §2.2 の前提)
+          cand = LA.fromLists [[-1],[-0.5],[0],[0.5],[1]]
+          -- 同じ点を「設計」 として渡す (full grid)
+          design = cand
+          nearly a b = abs (a - b) < 1e-9
+      case CM.expandDesignMatrix [fx] m design of
+        Left e -> expectationFailure (T.unpack e)
+        Right x0 ->
+          case CB.djTransformColumns [fx] m cand x0 of
+            Left e -> expectationFailure (T.unpack e)
+            Right xT -> do
+              -- 列順: [Intercept, x, x², x³] → 変換後: [1, x, x²−0.5, (x³−0.85x)/0.6]
+              LA.cols xT `shouldBe` 4
+              -- 行 0: x = -1 → [1, -1, 1-0.5=0.5, (-1+0.85)/0.6 = -0.25]
+              (xT `LA.atIndex` (0, 2)) `shouldSatisfy` nearly 0.5
+              (xT `LA.atIndex` (0, 3)) `shouldSatisfy` nearly (-0.25)
+              -- 行 1: x = -0.5 → [1, -0.5, 0.25-0.5=-0.25, (-0.125+0.425)/0.6 = 0.5]
+              (xT `LA.atIndex` (1, 2)) `shouldSatisfy` nearly (-0.25)
+              (xT `LA.atIndex` (1, 3)) `shouldSatisfy` nearly 0.5
+              -- 行 2: x = 0 → [1, 0, -0.5, 0]
+              (xT `LA.atIndex` (2, 2)) `shouldSatisfy` nearly (-0.5)
+              (xT `LA.atIndex` (2, 3)) `shouldSatisfy` nearly 0
+              -- 行 3: x = 0.5 → [1, 0.5, -0.25, -0.5]
+              (xT `LA.atIndex` (3, 2)) `shouldSatisfy` nearly (-0.25)
+              (xT `LA.atIndex` (3, 3)) `shouldSatisfy` nearly (-0.5)
+              -- 行 4: x = 1 → [1, 1, 0.5, 0.25]
+              (xT `LA.atIndex` (4, 2)) `shouldSatisfy` nearly 0.5
+              (xT `LA.atIndex` (4, 3)) `shouldSatisfy` nearly 0.25
+              -- primary 列 (0, 1) はそのまま
+              (xT `LA.atIndex` (3, 0)) `shouldSatisfy` nearly 1
+              (xT `LA.atIndex` (3, 1)) `shouldSatisfy` nearly 0.5
+    it "Phase 28-12a djFitTransform: primary/potential 分類が defaultClassifier と整合" $ do
+      let fx = CF.Factor "x" (CF.Continuous (-1) 1) CF.Controllable
+          fy = CF.Factor "y" (CF.Continuous (-1) 1) CF.Controllable
+          m  = CM.Model
+                 [ CM.TIntercept, CM.TMain "x", CM.TMain "y"
+                 , CM.TInter ["x","y"], CM.TPower "x" 2 ]
+                 CM.NCoded
+          cand = LA.fromLists [[a,b] | a <- [-1, 0, 1], b <- [-1, 0, 1]]
+      case CB.djFitTransform [fx, fy] m cand of
+        Left e -> expectationFailure (T.unpack e)
+        Right t -> do
+          -- expand 後の列: [Intercept(0), x(1), y(2), x:y(3), x²(4)]
+          -- primary: 0,1,2、 potential: 3,4
+          CB.djtPrimaryIdx t   `shouldBe` [0, 1, 2]
+          CB.djtPotentialIdx t `shouldBe` [3, 4]
+    it "Compound 重み正規化: 負を 0、 合計 = 1" $ do
+      let ws  = [(0.7, OPT.DOpt), (0.5, OPT.AOpt), (-0.1, OPT.IOpt)]
+          ws' = CCMP.normalizeCompoundWeights ws
+          total = sum (map fst ws')
+      abs (total - 1.0) `shouldSatisfy` (< 1e-9)
+      (fst (ws' !! 2)) `shouldBe` 0
+    it "Compound 重み合計 ≤ 0 は no-op" $ do
+      let ws = [(-1, OPT.DOpt), (-2, OPT.AOpt)]
+      CCMP.normalizeCompoundWeights ws `shouldBe` ws
diff --git a/test/Hanalyze/Design/Custom/CompareSpec.hs b/test/Hanalyze/Design/Custom/CompareSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Design/Custom/CompareSpec.hs
@@ -0,0 +1,285 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Design.Custom.CompareSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Data.Text   as T
+import qualified Numeric.LinearAlgebra as LA
+import qualified Hanalyze.Stat.ClassMetrics as CM
+import qualified Hanalyze.Design.Optimal       as OPT
+import qualified Hanalyze.Design.Custom.Factor     as CF
+import qualified Hanalyze.Design.Custom.Model      as CM
+import qualified Hanalyze.Design.Custom.Constraint as CC
+import qualified Hanalyze.Design.Custom.RegionMoment as RM
+import qualified Hanalyze.Design.Custom.Coordinate as CX
+import qualified Hanalyze.Design.Custom.Compare    as CCMP
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Design.Custom.Compare (Phase 24-7 Design Evaluation)" $ do
+    -- 2² factorial 設計 (4 行) を手動で構築、 main effects model で評価
+    let f1' = CF.Factor "x1" (CF.Continuous (-1) 1) CF.Controllable
+        f2' = CF.Factor "x2" (CF.Continuous (-1) 1) CF.Controllable
+        modelMain = CM.Model
+          [CM.TIntercept, CM.TMain "x1", CM.TMain "x2"] CM.NCoded
+        modelFull2fi = CM.Model
+          [CM.TIntercept, CM.TMain "x1", CM.TMain "x2"
+          , CM.TInter ["x1","x2"]] CM.NCoded
+        rawFact = LA.fromLists
+          [[-1,-1],[1,-1],[-1,1],[1,1]]
+        cdFact = CX.CustomDesign
+          { CX.cdMatrix  = rawFact
+          , CX.cdFactors = [f1', f2']
+          , CX.cdModel   = modelMain
+          , CX.cdReport  = CX.CustomDesignReport
+              { CX.crCriterion = OPT.DOpt
+              , CX.crCriterionValue = -64
+              , CX.crIterations = 0
+              , CX.crRestarts   = 0
+              , CX.crConverged  = True
+              , CX.crSeed       = Nothing
+              }
+          }
+        cdFull = cdFact { CX.cdModel = modelFull2fi }
+        -- 非直交な 3 行設計 (主効果と 2fi が confound): aliasNormOf > 0 を出すための材料
+        rawUnbal = LA.fromLists [[-1,-1],[1,-1],[-1,1]]
+        cdUnbalMain = cdFact { CX.cdMatrix = rawUnbal, CX.cdModel = modelMain }
+        cdUnbalFull = cdFact { CX.cdMatrix = rawUnbal, CX.cdModel = modelFull2fi }
+    it "compareDesigns: 2² factorial で D-eff ≈ 1.0 / 4 列 (D/A/G/I)" $ do
+      let dc = CCMP.compareDesigns [("F2", cdFact)]
+      LA.rows (CCMP.dcEffTable dc) `shouldBe` 1
+      LA.cols (CCMP.dcEffTable dc) `shouldBe` 4
+      (CCMP.dcEffTable dc `LA.atIndex` (0, 0)) `shouldSatisfy` (> 0.99)
+    it "fdsVector: 長さ 500 ・昇順 sort" $ do
+      let v = CCMP.fdsVector cdFact
+      LA.size v `shouldBe` 500
+      let vs = LA.toList v
+      and (zipWith (<=) vs (tail vs)) `shouldBe` True
+    it "aliasNormOf: 完全直交 2² factorial + main-only モデルで 2fi 部分 alias = 0、 二乗項 部分は > 0 (Phase 28-6 で Z 拡張)" $ do
+      -- 2² factorial では 2fi (x1·x2) と main は orthogonal だが、
+      -- x1² = 1 (constant) で intercept と完全 alias、 Phase 28-6 拡張 Z で
+      -- alias_norm > 0 が正しい新動作
+      CCMP.aliasNormOf cdFact `shouldSatisfy` (> 0)
+    it "aliasNormOf: 非直交 3 行設計 + main-only で alias norm > 0" $ do
+      CCMP.aliasNormOf cdUnbalMain `shouldSatisfy` (> 0)
+    it "aliasNormOf: 2fi 含むモデル + Phase 28-6 で Z は二乗項のみ。 2² factorial で x1²=1 alias > 0" $ do
+      -- Phase 28-6 拡張で Z に TPower x1 2 / TPower x2 2 が残る (model に
+      -- 含まれない)、 2² factorial で x1² = 1 = intercept で完全 alias
+      CCMP.aliasNormOf cdFull `shouldSatisfy` (> 0)
+      -- cdUnbalFull は 3 行 × 4 列 full2fi で singular → NaN
+      CCMP.aliasNormOf cdUnbalFull `shouldSatisfy` isNaN
+    it "Phase 28-6 aliasNormOf: 完全 quadratic モデル (Z = 空) で alias = 0" $ do
+      -- Intercept + main + 2fi + 二乗項 全部入れれば Z = 空 → alias = 0
+      -- 3² 完全 factorial 9 行で X (9×6) を full rank に
+      let modelFullQuad = CM.Model
+            [CM.TIntercept, CM.TMain "x1", CM.TMain "x2"
+            , CM.TInter ["x1","x2"]
+            , CM.TPower "x1" 2, CM.TPower "x2" 2]
+            CM.NCoded
+          raw3by3 = LA.fromLists [[a, b] | a <- [-1, 0, 1], b <- [-1, 0, 1]]
+          cdQuad = cdFact { CX.cdMatrix = raw3by3, CX.cdModel = modelFullQuad }
+      CCMP.aliasNormOf cdQuad `shouldBe` 0
+    it "compareDesigns: main-only vs full2fi で main 側は alias > 0、 full 側は singular (NaN) (Phase 28-6 拡張 Z で挙動変化)" $ do
+      let dc = CCMP.compareDesigns
+            [("unbalMain", cdUnbalMain), ("unbalFull", cdUnbalFull)]
+      map fst (CCMP.dcFDS dc) `shouldBe` ["unbalMain", "unbalFull"]
+      map fst (CCMP.dcAliasNorm dc) `shouldBe` ["unbalMain", "unbalFull"]
+      -- 3 行 main-only: x1²/x2² と直交化されない → alias > 0
+      snd (CCMP.dcAliasNorm dc !! 0) `shouldSatisfy` (> 0)
+      -- 3 行 full2fi (4 列): X は singular (rank 3) → 0/0 = NaN
+      snd (CCMP.dcAliasNorm dc !! 1) `shouldSatisfy` isNaN
+    it "Phase 28-8 compareDesignsWithResponses: design + response から MCp/MCpk 計算" $ do
+      let f1' = CF.Factor "x1" (CF.Continuous (-1) 1) CF.Controllable
+          mFull = CM.Model [CM.TIntercept, CM.TMain "x1"] CM.NCoded
+          raw = LA.fromLists [[-1],[1],[-1],[1]]
+          cd = CX.CustomDesign
+            { CX.cdMatrix = raw, CX.cdFactors = [f1'], CX.cdModel = mFull
+            , CX.cdReport = CX.CustomDesignReport
+                { CX.crCriterion = OPT.DOpt, CX.crCriterionValue = 0
+                , CX.crIterations = 0, CX.crRestarts = 0
+                , CX.crConverged = True, CX.crSeed = Nothing }
+            }
+          -- 2 応答変数 (y1, y2) 4 観測、 仕様 (LSL=-3, USL=3) で十分余裕あり
+          y = LA.fromLists [[0.1, 0.5], [-0.2, 0.4], [0.3, -0.1], [-0.4, 0.2]]
+          specs = [(-3, 3), (-3, 3)]
+      let res = CCMP.compareDesignsWithResponses [("d1", cd, y, specs)]
+      length (CCMP.dceMCp res) `shouldBe` 1
+      case snd (head (CCMP.dceMCp res)) of
+        Right v -> v `shouldSatisfy` (> 0)
+        Left e  -> expectationFailure (T.unpack e)
+    it "Phase 28-9 compoundGeometric: 重み 0.5/0.5 で 2 つの efficiency の幾何平均" $ do
+      let comp = CCMP.compoundGeometric [(1.0, 0.8), (1.0, 0.5)]
+      -- 期待値: sqrt(0.8 · 0.5) = sqrt(0.4) ≈ 0.6324555
+      abs (comp - sqrt 0.4) < 1e-9 `shouldBe` True
+    it "Phase 28-9 compoundGeometric: efficiency が 0 を含めば 0" $ do
+      CCMP.compoundGeometric [(1.0, 0.8), (1.0, 0.0)] `shouldBe` 0
+    it "Phase 28-9 dEfficiency / aEfficiency: 2² factorial main-only" $ do
+      -- X = [[1,-1,-1],[1,1,-1],[1,-1,1],[1,1,1]] expand → 4×3、 X'X = 4·I_3
+      -- D-eff = (det(4·I_3) / 4^3)^(1/3) = (64/64)^(1/3) = 1.0
+      -- A-eff = 3 / (4 · trace((4·I_3)⁻¹)) = 3 / (4 · 3/4) = 1.0
+      let x = LA.fromLists [[1,-1,-1],[1,1,-1],[1,-1,1],[1,1,1]]
+      abs (CCMP.dEfficiency x - 1.0) < 1e-9 `shouldBe` True
+      abs (CCMP.aEfficiency x - 1.0) < 1e-9 `shouldBe` True
+    it "Phase 28-5 designEffs: BayesianD criterion 時に D 列が Bayesian D-eff に切替" $ do
+      -- 同じ設計行列に対し、 cdReport.crCriterion を DOpt vs BayesianD(K) で
+      -- 切り替えると、 D 列 (eff[0]) が変わる
+      let f1' = CF.Factor "x1" (CF.Continuous (-1) 1) CF.Controllable
+          f2' = CF.Factor "x2" (CF.Continuous (-1) 1) CF.Controllable
+          mFull = CM.Model
+            [CM.TIntercept, CM.TMain "x1", CM.TMain "x2", CM.TInter ["x1","x2"]]
+            CM.NCoded
+          raw = LA.fromLists [[-1,-1],[1,-1],[-1,1],[1,1]]
+          cdBase = CX.CustomDesign
+            { CX.cdMatrix  = raw
+            , CX.cdFactors = [f1', f2']
+            , CX.cdModel   = mFull
+            , CX.cdReport  = CX.CustomDesignReport
+                { CX.crCriterion = OPT.DOpt
+                , CX.crCriterionValue = 0
+                , CX.crIterations = 0
+                , CX.crRestarts   = 0
+                , CX.crConverged  = True
+                , CX.crSeed       = Nothing
+                }
+            }
+          kBayes = [[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,1.0]]  -- K_jj=1 on 2fi only
+          cdBayes = cdBase
+            { CX.cdReport = (CX.cdReport cdBase)
+                { CX.crCriterion = OPT.BayesianD kBayes } }
+      let dcD = CCMP.compareDesigns [("d", cdBase)]
+          dcB = CCMP.compareDesigns [("b", cdBayes)]
+          dEffD = CCMP.dcEffTable dcD `LA.atIndex` (0, 0)
+          dEffB = CCMP.dcEffTable dcB `LA.atIndex` (0, 0)
+      -- DOpt 側 = 古典 D-eff (= 1.0 for 2² factorial)
+      -- BayesianD 側 = (det(X'X + K)/n^p)^(1/p) > 古典 D-eff (K_jj=1 を足したので det 増)
+      dEffB `shouldSatisfy` (> dEffD)
+
+  describe "Hanalyze.Design.Custom.Compare (Phase 28-4a regionMomentMatrixAnalytic)" $ do
+    -- 連続因子 z ∈ U[-1, 1]、 categorical K 水準等確率の解析積分
+    let fc1 = CF.Factor "x1" (CF.Continuous (-1) 1) CF.Controllable
+        fc2 = CF.Factor "x2" (CF.Continuous (-1) 1) CF.Controllable
+        fcat = CF.Factor "c" (CF.Categorical ["A","B","C"]) CF.Controllable
+        nearly a b = abs (a - b) < 1e-12
+    it "TIntercept のみ: M_R = [[1]]" $ do
+      let m = CM.Model [CM.TIntercept] CM.NCoded
+      case CCMP.regionMomentMatrixAnalytic [fc1] m of
+        Right mr -> do
+          LA.rows mr `shouldBe` 1
+          LA.cols mr `shouldBe` 1
+          (mr `LA.atIndex` (0,0)) `shouldSatisfy` nearly 1
+        Left e -> expectationFailure (show e)
+    it "Intercept + 連続 main: E[1]=1, E[x]=0, E[x²]=1/3" $ do
+      let m = CM.Model [CM.TIntercept, CM.TMain "x1", CM.TMain "x2"] CM.NCoded
+      case CCMP.regionMomentMatrixAnalytic [fc1, fc2] m of
+        Right mr -> do
+          (mr `LA.atIndex` (0,0)) `shouldSatisfy` nearly 1
+          (mr `LA.atIndex` (0,1)) `shouldSatisfy` nearly 0
+          (mr `LA.atIndex` (0,2)) `shouldSatisfy` nearly 0
+          (mr `LA.atIndex` (1,1)) `shouldSatisfy` nearly (1/3)
+          (mr `LA.atIndex` (2,2)) `shouldSatisfy` nearly (1/3)
+          (mr `LA.atIndex` (1,2)) `shouldSatisfy` nearly 0
+        Left e -> expectationFailure (show e)
+    it "TInter [x1,x2]: E[x1·x2]=0, E[(x1·x2)²]=1/9" $ do
+      let m = CM.Model [CM.TIntercept, CM.TInter ["x1","x2"]] CM.NCoded
+      case CCMP.regionMomentMatrixAnalytic [fc1, fc2] m of
+        Right mr -> do
+          (mr `LA.atIndex` (0,1)) `shouldSatisfy` nearly 0
+          (mr `LA.atIndex` (1,1)) `shouldSatisfy` nearly (1/9)
+        Left e -> expectationFailure (show e)
+    it "TPower x1 2: E[x1²]=1/3, E[(x1²)²]=1/5" $ do
+      let m = CM.Model [CM.TIntercept, CM.TPower "x1" 2] CM.NCoded
+      case CCMP.regionMomentMatrixAnalytic [fc1] m of
+        Right mr -> do
+          (mr `LA.atIndex` (0,1)) `shouldSatisfy` nearly (1/3)
+          (mr `LA.atIndex` (1,1)) `shouldSatisfy` nearly (1/5)
+        Left e -> expectationFailure (show e)
+    it "Categorical 3 水準 main: E[I_l]=1/3, E[I_l²]=1/3, E[I_l·I_m]=0 (l≠m)" $ do
+      let m = CM.Model [CM.TIntercept, CM.TMain "c"] CM.NCoded
+      case CCMP.regionMomentMatrixAnalytic [fcat] m of
+        Right mr -> do
+          LA.rows mr `shouldBe` 3   -- Intercept + 2 indicator cols
+          (mr `LA.atIndex` (0,1)) `shouldSatisfy` nearly (1/3)
+          (mr `LA.atIndex` (1,1)) `shouldSatisfy` nearly (1/3)
+          (mr `LA.atIndex` (2,2)) `shouldSatisfy` nearly (1/3)
+          (mr `LA.atIndex` (1,2)) `shouldSatisfy` nearly 0
+        Left e -> expectationFailure (show e)
+    it "Mixture 因子は Left (Phase 28-4a 非対応)" $ do
+      let fm = CF.Factor "w" (CF.Mixture 0 1) CF.Controllable
+          m  = CM.Model [CM.TIntercept, CM.TMain "w"] CM.NCoded
+      case CCMP.regionMomentMatrixAnalytic [fm] m of
+        Left _  -> pure ()
+        Right _ -> expectationFailure "Mixture should be rejected"
+    it "Phase 28-4c regionMomentMatrixMC: 制約無しなら analytic とほぼ一致" $ do
+      let m = CM.Model [CM.TIntercept, CM.TMain "x1", CM.TPower "x1" 2] CM.NCoded
+      case (CCMP.regionMomentMatrixAnalytic [fc1] m,
+            RM.regionMomentMatrixMC 5000 [fc1] m []) of
+        (Right mA, Right mC) -> do
+          let diff i j = abs (mA `LA.atIndex` (i, j) - mC `LA.atIndex` (i, j))
+          maximum [diff i j | i <- [0..2], j <- [0..2]] `shouldSatisfy` (< 0.05)
+        _ -> expectationFailure "analytic or MC failed"
+    it "Phase 28-4c regionMomentMatrixMC: 制約 (x1 ≥ 0) で M_R が変化" $ do
+      let m    = CM.Model [CM.TIntercept, CM.TMain "x1"] CM.NCoded
+          cons = [CC.LinearIneq [("x1", 1)] CC.CGeq 0]
+      case RM.regionMomentMatrixMC 5000 [fc1] m cons of
+        Right mC -> do
+          -- 制約 x1 ≥ 0 下で coded [0, 1] uniform → E[x1] = 0.5
+          (mC `LA.atIndex` (0, 1)) `shouldSatisfy` (> 0.4)
+          (mC `LA.atIndex` (0, 1)) `shouldSatisfy` (< 0.6)
+        Left e -> expectationFailure (T.unpack e)
+    it "iValueRegionM: 2² factorial main-only で p/n と異なる値 (= 縮退脱却)" $ do
+      let m = CM.Model [CM.TIntercept, CM.TMain "x1", CM.TMain "x2"] CM.NCoded
+          raw = LA.fromLists [[-1,-1],[1,-1],[-1,1],[1,1]]
+      case (CCMP.regionMomentMatrixAnalytic [fc1, fc2] m,
+            CM.expandDesignMatrix [fc1, fc2] m raw) of
+        (Right mr, Right x) -> do
+          let v = CCMP.iValueRegionM mr x
+          -- self-moment 版は p/n = 3/4 = 0.75。 region 版は trace(I) where
+          -- 期待される値: I = (1/n) · trace((X'X)⁻¹ · M_R)? 違う、 Iは trace((X'X)⁻¹ M_R)
+          -- 2² factorial で X'X = 4·I_3 → (X'X)⁻¹ = (1/4) I_3
+          -- M_R = diag(1, 1/3, 1/3) → trace = (1/4)·(1 + 1/3 + 1/3) = (1/4)·(5/3) = 5/12 ≈ 0.4167
+          v `shouldSatisfy` nearly (5/12)
+        _ -> expectationFailure "expand or moment failed"
+
+  describe "Hanalyze.Design.Custom.Compare (Phase 28-4b designEffs I-eff region)" $ do
+    let f1' = CF.Factor "x1" (CF.Continuous (-1) 1) CF.Controllable
+        f2' = CF.Factor "x2" (CF.Continuous (-1) 1) CF.Controllable
+        mMain = CM.Model
+          [CM.TIntercept, CM.TMain "x1", CM.TMain "x2"] CM.NCoded
+        rawFact = LA.fromLists [[-1,-1],[1,-1],[-1,1],[1,1]]
+        cdFact = CX.CustomDesign
+          { CX.cdMatrix  = rawFact
+          , CX.cdFactors = [f1', f2']
+          , CX.cdModel   = mMain
+          , CX.cdReport  = CX.CustomDesignReport
+              { CX.crCriterion = OPT.IOpt
+              , CX.crCriterionValue = 0
+              , CX.crIterations = 0
+              , CX.crRestarts   = 0
+              , CX.crConverged  = True
+              , CX.crSeed       = Nothing
+              }
+          }
+    it "compareDesigns: I-eff (4列目) = 1/(n·trace) = 1/(4·5/12) = 0.6 (region 版)" $ do
+      let dc  = CCMP.compareDesigns [("F2", cdFact)]
+          iE = CCMP.dcEffTable dc `LA.atIndex` (0, 3)
+      -- 旧 self-moment 版なら 1/p = 1/3 ≈ 0.333、 region 版は 0.6
+      abs (iE - 0.6) < 1e-9 `shouldBe` True
diff --git a/test/Hanalyze/Design/Custom/CoordinateSpec.hs b/test/Hanalyze/Design/Custom/CoordinateSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Design/Custom/CoordinateSpec.hs
@@ -0,0 +1,268 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Design.Custom.CoordinateSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Data.Text   as T
+import qualified Numeric.LinearAlgebra as LA
+import qualified Data.Vector.Unboxed        as VU
+import qualified Hanalyze.Stat.ClassMetrics as CM
+import qualified Hanalyze.Design.Optimal       as OPT
+import qualified Hanalyze.Design.Custom.Factor     as CF
+import qualified Hanalyze.Design.Custom.Model      as CM
+import qualified Hanalyze.Design.Custom.Constraint as CC
+import qualified Hanalyze.Design.Custom.Coordinate as CX
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Design.Custom.Coordinate (Phase 24-3 Coordinate Exchange)" $ do
+    let fx1 = CF.Factor "x1" (CF.Continuous (-1) 1) CF.Controllable
+        fx2 = CF.Factor "x2" (CF.Continuous (-1) 1) CF.Controllable
+        fCat = CF.Factor "cat" (CF.Categorical ["A","B"]) CF.Controllable
+        modelMain2 = CM.Model
+          [CM.TIntercept, CM.TMain "x1", CM.TMain "x2"] CM.NCoded
+        budget = CX.defaultBudget
+                   { CX.dbRestarts = 3
+                   , CX.dbMaxIter  = 30
+                   , CX.dbCxStepGrid = 11
+                   }
+        baseSpec = CX.CustomDesignSpec
+          { CX.cdsFactors     = [fx1, fx2]
+          , CX.cdsModel       = modelMain2
+          , CX.cdsConstraints = []
+          , CX.cdsNRuns       = 6
+          , CX.cdsCriterion   = OPT.DOpt
+          , CX.cdsBudget      = budget
+          , CX.cdsSeed        = Just 42
+          , CX.cdsInitial     = Nothing
+
+          , CX.cdsDJConvention = False
+          }
+    it "gridForBudget: 21 点 grid は [-1, 1] の等間隔" $ do
+      let g = CX.gridForBudget CX.defaultBudget
+      VU.length g `shouldBe` 21
+      g VU.! 0  `shouldBe` (-1)
+      g VU.! 20 `shouldBe` 1
+      g VU.! 10 `shouldBe` 0
+    it "factorGrid: Continuous は dbCxStepGrid 点 / Categorical は [0..K-1]" $ do
+      let gCont = CX.factorGrid CX.defaultBudget fx1
+          gCat  = CX.factorGrid CX.defaultBudget fCat  -- K=2
+      VU.length gCont `shouldBe` 21
+      VU.toList  gCat `shouldBe` [0, 1]
+    it "dbRestarts < 1 は Left" $ do
+      let spec = baseSpec { CX.cdsBudget = budget { CX.dbRestarts = 0 } }
+      r <- CX.coordinateExchange spec
+      case r of
+        Left _  -> pure ()
+        Right _ -> expectationFailure "expected Left"
+    it "D-opt: 連続 2 因子 main-effect モデルで factorial 2² 設計に近い |det| を得る" $ do
+      r <- CX.coordinateExchange baseSpec
+      case r of
+        Left e  -> expectationFailure (T.unpack e)
+        Right cd -> do
+          -- 戻り値の整合
+          LA.rows (CX.cdMatrix cd) `shouldBe` 6
+          LA.cols (CX.cdMatrix cd) `shouldBe` 2
+          -- raw matrix は [-1, 1] grid 内
+          let vs = concat (LA.toLists (CX.cdMatrix cd))
+          all (\v -> v >= -1.0001 && v <= 1.0001) vs `shouldBe` True
+          -- D 値 = −critValue (最小化方向)
+          let dval = - (CX.crCriterionValue (CX.cdReport cd))
+          dval `shouldSatisfy` (> 0)
+          -- 6 点で main+intercept (3 列) なら |X'X| は概ね 6^3 = 216 付近が上限
+          -- (各列ノルム² ≤ 6、 直交時に等号)。 0.7 倍 = 151.2 以上を要求。
+          dval `shouldSatisfy` (> 150)
+    it "D-opt: ランダム単 start より multi-start が同等以上の D 値" $ do
+      let spec1 = baseSpec { CX.cdsBudget = budget { CX.dbRestarts = 1 }
+                           , CX.cdsSeed = Just 7 }
+          spec5 = baseSpec { CX.cdsBudget = budget { CX.dbRestarts = 5 }
+                           , CX.cdsSeed = Just 7 }
+      r1 <- CX.coordinateExchange spec1
+      r5 <- CX.coordinateExchange spec5
+      case (r1, r5) of
+        (Right cd1, Right cd5) -> do
+          let d1 = - CX.crCriterionValue (CX.cdReport cd1)
+              d5 = - CX.crCriterionValue (CX.cdReport cd5)
+          (d5 + 1e-9) `shouldSatisfy` (>= d1)
+        _ -> expectationFailure "both runs should return Right"
+    it "critValueM (DOpt): D 値が古典 dValue と一致" $ do
+      let x = LA.fromLists [[1, -1, -1], [1, 1, -1], [1, -1, 1], [1, 1, 1]]
+          c = CX.critValueM OPT.DOpt x
+      c `shouldBe` (-64)   -- |X'X| = 4 * 4 * 4 = 64
+    -- Phase 24-4 hybrid: continuous + categorical 混合
+    it "hybrid: x1 (連続) + cat (K=2) の D-opt 設計、 cat 列は 0/1 のみ" $ do
+      let modelMix = CM.Model
+            [CM.TIntercept, CM.TMain "x1", CM.TMain "cat"] CM.NCoded
+          spec = baseSpec
+            { CX.cdsFactors = [fx1, fCat]
+            , CX.cdsModel   = modelMix
+            , CX.cdsNRuns   = 8
+            , CX.cdsSeed    = Just 100
+            }
+      r <- CX.coordinateExchange spec
+      case r of
+        Left e -> expectationFailure (T.unpack e)
+        Right cd -> do
+          let m = CX.cdMatrix cd
+          LA.rows m `shouldBe` 8
+          LA.cols m `shouldBe` 2
+          -- cat 列 (index 1) は integer level index ∈ {0, 1}
+          let catCol = LA.toList (LA.flatten (LA.subMatrix (0, 1) (8, 1) m))
+          all (`elem` [0, 1]) (map round catCol :: [Int]) `shouldBe` True
+          all (\v -> abs (v - fromIntegral (round v :: Int)) < 1e-9) catCol
+            `shouldBe` True
+          -- 両 level が少なくとも 1 回ずつ出現 (= balanced に近い)
+          (0 `elem` map round catCol :: Bool) `shouldBe` True
+          (1 `elem` (map round catCol :: [Int])) `shouldBe` True
+          -- x1 列は [-1, 1]
+          let x1Col = LA.toList (LA.flatten (LA.subMatrix (0, 0) (8, 1) m))
+          all (\v -> v >= -1.0001 && v <= 1.0001) x1Col `shouldBe` True
+          -- D > 0 (full rank)
+          let dval = - CX.crCriterionValue (CX.cdReport cd)
+          dval `shouldSatisfy` (> 0)
+    -- Phase 24-5 制約統合
+    it "LinearIneq (x1 + x2 <= 1): 全 row が制約を満たす" $ do
+      let modelMain2 = CM.Model
+            [CM.TIntercept, CM.TMain "x1", CM.TMain "x2"] CM.NCoded
+          spec = baseSpec
+            { CX.cdsFactors = [fx1, fx2]
+            , CX.cdsModel   = modelMain2
+            , CX.cdsConstraints =
+                [CC.LinearIneq [("x1", 1), ("x2", 1)] CC.CLeq 1]
+            , CX.cdsNRuns   = 6
+            , CX.cdsSeed    = Just 21
+            }
+      r <- CX.coordinateExchange spec
+      case r of
+        Left e -> expectationFailure (T.unpack e)
+        Right cd -> do
+          let rows = LA.toLists (CX.cdMatrix cd)
+              ok [a, b] = a + b <= 1 + 1e-6
+              ok _ = False
+          all ok rows `shouldBe` True
+    it "Forbidden (cat=A, x1=1): 該当組合せが出現しない" $ do
+      let modelMix = CM.Model
+            [CM.TIntercept, CM.TMain "x1", CM.TMain "cat"] CM.NCoded
+          spec = baseSpec
+            { CX.cdsFactors = [fx1, fCat]
+            , CX.cdsModel   = modelMix
+            , CX.cdsConstraints =
+                [CC.Forbidden [("cat", CC.FVText "A"), ("x1", CC.FVDouble 1)]]
+            , CX.cdsNRuns   = 8
+            , CX.cdsSeed    = Just 33
+            }
+      r <- CX.coordinateExchange spec
+      case r of
+        Left e -> expectationFailure (T.unpack e)
+        Right cd -> do
+          let rows = LA.toLists (CX.cdMatrix cd)
+              bad [x1, c]
+                | round c == (0 :: Int) && abs (x1 - 1) < 1e-9 = True
+                | otherwise = False
+              bad _ = False
+          any bad rows `shouldBe` False
+    it "実現不能制約 (x1 >= 2): Left" $ do
+      let modelMain1 = CM.Model
+            [CM.TIntercept, CM.TMain "x1"] CM.NCoded
+          spec = baseSpec
+            { CX.cdsFactors = [fx1]
+            , CX.cdsModel   = modelMain1
+            , CX.cdsConstraints =
+                [CC.LinearIneq [("x1", 1)] CC.CGeq 2]   -- x1 >= 2 は [-1,1] grid で実現不能
+            , CX.cdsNRuns   = 4
+            , CX.cdsSeed    = Just 1
+            }
+      r <- CX.coordinateExchange spec
+      case r of
+        Left _  -> pure ()
+        Right _ -> expectationFailure "expected Left for infeasible constraint"
+
+    it "hybrid: 3 水準 categorical のみで level 全て出現可能" $ do
+      let fCat3 = CF.Factor "k" (CF.Categorical ["A","B","C"]) CF.Controllable
+          modelCat = CM.Model
+            [CM.TIntercept, CM.TMain "k"] CM.NCoded
+          spec = baseSpec
+            { CX.cdsFactors = [fCat3]
+            , CX.cdsModel   = modelCat
+            , CX.cdsNRuns   = 9
+            , CX.cdsSeed    = Just 11
+            }
+      r <- CX.coordinateExchange spec
+      case r of
+        Left e -> expectationFailure (T.unpack e)
+        Right cd -> do
+          let col = LA.toList (LA.flatten (CX.cdMatrix cd))
+              idxs = map round col :: [Int]
+          all (`elem` [0, 1, 2]) idxs `shouldBe` True
+          -- D-opt なら 9 runs で各 level 3 回ずつが理論最適。
+          -- 3 level 全部出現するはず。
+          length (filter (== 0) idxs) `shouldSatisfy` (> 0)
+          length (filter (== 1) idxs) `shouldSatisfy` (> 0)
+          length (filter (== 2) idxs) `shouldSatisfy` (> 0)
+
+  -- Phase 78.M M1: IO→ST pure 化。同一 seed で純粋版が IO 版とビット一致
+  -- (アルゴリズム不変の回帰保証) + 純粋版の参照透過性を検証。
+  describe "Phase 78.M M1: coordinateExchangePure (seed 決定的 pure)" $ do
+    let fx1 = CF.Factor "x1" (CF.Continuous (-1) 1) CF.Controllable
+        fx2 = CF.Factor "x2" (CF.Continuous (-1) 1) CF.Controllable
+        fCat3 = CF.Factor "k" (CF.Categorical ["A","B","C"]) CF.Controllable
+        budget = CX.defaultBudget
+                   { CX.dbRestarts = 3, CX.dbMaxIter = 30, CX.dbCxStepGrid = 11 }
+        mkSpec fs md nruns seed = CX.CustomDesignSpec
+          { CX.cdsFactors = fs, CX.cdsModel = md, CX.cdsConstraints = []
+          , CX.cdsNRuns = nruns, CX.cdsCriterion = OPT.DOpt, CX.cdsBudget = budget
+          , CX.cdsSeed = seed, CX.cdsInitial = Nothing, CX.cdsDJConvention = False }
+        model2 = CM.Model [CM.TIntercept, CM.TMain "x1", CM.TMain "x2"] CM.NCoded
+        modelCat = CM.Model [CM.TIntercept, CM.TMain "k"] CM.NCoded
+
+    it "純粋版は IO 版と同 seed でビット一致 (連続 2 因子・RSM)" $ do
+      let spec = mkSpec [fx1, fx2] model2 6 (Just 42)
+      io <- CX.coordinateExchange spec
+      let pu = CX.coordinateExchangePure spec
+      case (io, pu) of
+        (Right cdIO, Right cdPu) -> do
+          LA.toLists (CX.cdMatrix cdIO) `shouldBe` LA.toLists (CX.cdMatrix cdPu)
+          CX.crCriterionValue (CX.cdReport cdIO)
+            `shouldBe` CX.crCriterionValue (CX.cdReport cdPu)
+        _ -> expectationFailure "expected Right from both IO and pure"
+
+    it "純粋版は IO 版と同 seed でビット一致 (categorical 因子)" $ do
+      let spec = mkSpec [fCat3] modelCat 9 (Just 11)
+      io <- CX.coordinateExchange spec
+      case (io, CX.coordinateExchangePure spec) of
+        (Right cdIO, Right cdPu) ->
+          LA.toLists (CX.cdMatrix cdIO) `shouldBe` LA.toLists (CX.cdMatrix cdPu)
+        _ -> expectationFailure "expected Right from both IO and pure"
+
+    it "純粋版は参照透過 (同 spec を 2 回呼んで同結果)" $ do
+      let spec = mkSpec [fx1, fx2] model2 6 (Just 7)
+      case (CX.coordinateExchangePure spec, CX.coordinateExchangePure spec) of
+        (Right a, Right b) ->
+          LA.toLists (CX.cdMatrix a) `shouldBe` LA.toLists (CX.cdMatrix b)
+        _ -> expectationFailure "expected Right from both pure calls"
+
+    it "seed が Nothing でも純粋版は全域 (defaultPureSeed で決定的)" $ do
+      let spec = mkSpec [fx1, fx2] model2 6 Nothing
+      case (CX.coordinateExchangePure spec, CX.coordinateExchangePure spec) of
+        (Right a, Right b) ->
+          LA.toLists (CX.cdMatrix a) `shouldBe` LA.toLists (CX.cdMatrix b)
+        _ -> expectationFailure "expected Right (total) even with Nothing seed"
diff --git a/test/Hanalyze/Design/Custom/FactorSpec.hs b/test/Hanalyze/Design/Custom/FactorSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Design/Custom/FactorSpec.hs
@@ -0,0 +1,172 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Design.Custom.FactorSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Data.Text   as T
+import qualified Numeric.LinearAlgebra as LA
+import qualified Hanalyze.Stat.ClassMetrics as CM
+import qualified Hanalyze.Design.Custom.Factor     as CF
+import qualified Hanalyze.Design.Custom.Model      as CM
+import qualified Hanalyze.Design.Custom.Constraint as CC
+import qualified Data.Map.Strict as M
+import qualified Data.Map.Strict    as M
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Design.Custom.Factor / Model / Constraint (Phase 24-1 skeleton)" $ do
+    let f1 = CF.Factor "x1" (CF.Continuous (-1) 1)   CF.Controllable
+        f2 = CF.Factor "x2" (CF.Continuous (-1) 1)   CF.Controllable
+        fCat = CF.Factor "cat" (CF.Categorical ["A","B","C"]) CF.Controllable
+        factors = [f1, f2]
+        raw = LA.fromLists [[-1,-1],[1,-1],[-1,1],[1,1]]
+        modelMain = CM.Model [CM.TIntercept, CM.TMain "x1", CM.TMain "x2"] CM.NCoded
+        modelFull = CM.Model
+          [CM.TIntercept, CM.TMain "x1", CM.TMain "x2"
+          , CM.TInter ["x1","x2"], CM.TPower "x1" 2] CM.NCoded
+    it "Factor.factorIsContinuous / factorDimension" $ do
+      CF.factorIsContinuous f1   `shouldBe` True
+      CF.factorIsContinuous fCat `shouldBe` False
+      CF.factorDimension f1   `shouldBe` 1
+      CF.factorDimension fCat `shouldBe` 2  -- treatment coding: levels - 1
+    it "expandDesignMatrix: 主効果のみ 2² で 4×3 (intercept + x1 + x2)" $ do
+      case CM.expandDesignMatrix factors modelMain raw of
+        Left e  -> expectationFailure (T.unpack e)
+        Right m -> do
+          LA.rows m `shouldBe` 4
+          LA.cols m `shouldBe` 3
+          LA.toLists m `shouldBe`
+            [[1,-1,-1],[1,1,-1],[1,-1,1],[1,1,1]]
+    it "expandDesignMatrix: 交互作用 + 二乗を含むフルモデル" $ do
+      case CM.expandDesignMatrix factors modelFull raw of
+        Left e  -> expectationFailure (T.unpack e)
+        Right m -> do
+          LA.cols m `shouldBe` 5
+          -- 各 row の最後の 2 列を確認: x1*x2、 x1^2
+          let rows = LA.toLists m
+              lastTwo = map (drop 3) rows
+          lastTwo `shouldBe` [[1,1],[-1,1],[-1,1],[1,1]]
+    it "expandDesignMatrix: 列数不一致は Left" $ do
+      case CM.expandDesignMatrix factors modelMain (LA.fromLists [[1]]) of
+        Left _  -> pure ()
+        Right _ -> expectationFailure "expected Left"
+    it "expandDesignMatrix: 未知因子参照は Left" $ do
+      let m = CM.Model [CM.TMain "x9"] CM.NCoded
+      case CM.expandDesignMatrix factors m raw of
+        Left _  -> pure ()
+        Right _ -> expectationFailure "expected Left"
+    it "expandDesignMatrix: Categorical TMain は treatment coding で K-1 列 (Phase 24-2)" $ do
+      let fs = [fCat]
+          m  = CM.Model [CM.TIntercept, CM.TMain "cat"] CM.NCoded
+          rawCat = LA.fromLists [[0],[1],[2],[0],[1]]
+      case CM.expandDesignMatrix fs m rawCat of
+        Left e  -> expectationFailure (T.unpack e)
+        Right d -> do
+          LA.rows d `shouldBe` 5
+          LA.cols d `shouldBe` 3  -- intercept + (3-1) treatment cols
+          LA.toLists d `shouldBe`
+            [ [1, 0, 0]  -- ref (A)
+            , [1, 1, 0]  -- B
+            , [1, 0, 1]  -- C
+            , [1, 0, 0]
+            , [1, 1, 0]
+            ]
+    it "expandDesignMatrix: Categorical × 連続 TInter は (K-1) 列" $ do
+      let fs = [f1, fCat]
+          m  = CM.Model [CM.TInter ["x1","cat"]] CM.NCoded
+          rawMix = LA.fromLists [[-1, 0], [1, 1], [-1, 2], [1, 0]]
+      case CM.expandDesignMatrix fs m rawMix of
+        Left e  -> expectationFailure (T.unpack e)
+        Right d -> do
+          LA.cols d `shouldBe` 2
+          LA.toLists d `shouldBe`
+            [ [(-1) * 0, (-1) * 0]   -- cat = A → 両 indicator 0
+            , [1 * 1,    1 * 0]      -- cat = B
+            , [(-1) * 0, (-1) * 1]   -- cat = C
+            , [1 * 0,    1 * 0]      -- cat = A
+            ]
+    it "expandDesignMatrix: Categorical raw が非整数なら Left" $ do
+      let fs = [fCat]
+          m  = CM.Model [CM.TMain "cat"] CM.NCoded
+      case CM.expandDesignMatrix fs m (LA.fromLists [[0.5]]) of
+        Left _  -> pure ()
+        Right _ -> expectationFailure "expected Left for non-integer level index"
+    it "expandDesignMatrix: Categorical raw が範囲外なら Left" $ do
+      let fs = [fCat]
+          m  = CM.Model [CM.TMain "cat"] CM.NCoded
+      case CM.expandDesignMatrix fs m (LA.fromLists [[3]]) of
+        Left _  -> pure ()
+        Right _ -> expectationFailure "expected Left for out-of-range index"
+    it "expandDesignMatrix: TPower を Categorical に適用すると Left" $ do
+      let fs = [fCat]
+          m  = CM.Model [CM.TPower "cat" 2] CM.NCoded
+      case CM.expandDesignMatrix fs m (LA.fromLists [[0]]) of
+        Left _  -> pure ()
+        Right _ -> expectationFailure "expected Left for TPower on categorical"
+    it "Phase 28-1 TNested: A within B で K_B × (K_A - 1) 列、 indicator I[B=b]·I[A=a]" $ do
+      -- B = {b0,b1} (2 levels)、 A = {a0,a1,a2} (3 levels) → 2 × 2 = 4 列
+      let fA = CF.Factor "A" (CF.Categorical ["a0","a1","a2"]) CF.Controllable
+          fB = CF.Factor "B" (CF.Categorical ["b0","b1"])      CF.Controllable
+          fs = [fA, fB]
+          m  = CM.Model [CM.TNested "A" "B"] CM.NCoded
+          -- 6 行: 全 (B, A) 組合せ + B=0,A=0 / B=1,A=2 を 1 行ずつ
+          raw = LA.fromLists
+            [[0, 0]  -- A=a0, B=b0 → all 4 cols = 0 (a=0 は reference)
+            ,[1, 0]  -- A=a1, B=b0 → col (b0, a1) = 1
+            ,[2, 0]  -- A=a2, B=b0 → col (b0, a2) = 1
+            ,[0, 1]  -- A=a0, B=b1 → all = 0
+            ,[1, 1]  -- A=a1, B=b1 → col (b1, a1) = 1
+            ,[2, 1]  -- A=a2, B=b1 → col (b1, a2) = 1
+            ]
+      case CM.expandDesignMatrix fs m raw of
+        Left e -> expectationFailure (T.unpack e)
+        Right x -> do
+          LA.rows x `shouldBe` 6
+          LA.cols x `shouldBe` 4    -- K_B(2) × (K_A-1)(2) = 4
+          -- 列順 (b, a) = (0,1), (0,2), (1,1), (1,2)
+          LA.toLists x `shouldBe`
+            [[0,0,0,0]   -- A=0, B=0
+            ,[1,0,0,0]   -- A=1, B=0 → col (0,1) = 1
+            ,[0,1,0,0]   -- A=2, B=0 → col (0,2) = 1
+            ,[0,0,0,0]   -- A=0, B=1
+            ,[0,0,1,0]   -- A=1, B=1 → col (1,1) = 1
+            ,[0,0,0,1]   -- A=2, B=1 → col (1,2) = 1
+            ]
+    it "modelNumColumns: Categorical を含むモデルで K-1 を考慮" $ do
+      let m = CM.Model [CM.TIntercept, CM.TMain "x1", CM.TMain "cat"
+                       , CM.TInter ["x1","cat"]] CM.NCoded
+      -- 1 (intercept) + 1 (x1) + 2 (cat K-1) + 2 (x1 * cat K-1) = 6
+      CM.modelNumColumns [f1, fCat] m `shouldBe` 6
+    it "Constraint.LinearIneq 評価" $ do
+      let row = CC.compileRowFromFactors ["x1","x2"] [0.4, 0.4]
+          c   = CC.LinearIneq [("x1", 1), ("x2", 1)] CC.CLeq 1
+      CC.checkRowAgainst row c `shouldBe` True
+      let row2 = CC.compileRowFromFactors ["x1","x2"] [0.7, 0.7]
+      CC.checkRowAgainst row2 c `shouldBe` False
+    it "Constraint.Forbidden + Conditional 評価" $ do
+      let row = M.fromList [("cat", CC.FVText "A"), ("temp", CC.FVDouble 90)]
+          forb = CC.Forbidden [("cat", CC.FVText "A"), ("temp", CC.FVDouble 90)]
+          cond = CC.Conditional
+                    (CC.GuardEq "cat" (CC.FVText "A"))
+                    [CC.RangeBound "temp" 0 80]
+      CC.checkRowAgainst row forb `shouldBe` False  -- forbidden 一致 = 違反
+      CC.checkRowAgainst row cond `shouldBe` False  -- cat=A の時 temp ≤ 80 を要求、 90 は違反
diff --git a/test/Hanalyze/Design/Custom/GoldenSpec.hs b/test/Hanalyze/Design/Custom/GoldenSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Design/Custom/GoldenSpec.hs
@@ -0,0 +1,137 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Design.Custom.GoldenSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Data.Text   as T
+import qualified Numeric.LinearAlgebra as LA
+import qualified Hanalyze.Stat.ClassMetrics as CM
+import qualified Hanalyze.Design.Optimal       as OPT
+import qualified Hanalyze.Design.Custom.Factor     as CF
+import qualified Hanalyze.Design.Custom.Model      as CM
+import qualified Hanalyze.Design.Custom.Constraint as CC
+import qualified Hanalyze.Design.Custom.Coordinate as CX
+import qualified Hanalyze.Design.Custom.Compare    as CCMP
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Custom Design ゴールデン例題 (Phase 24-9)" $ do
+    -- Example 1: 2 因子 2nd-order RSM (full quadratic、 p_terms = 6)
+    it "golden ex1: 2 factor + 2nd-order RSM、 D-eff > 0、 全 grid 内" $ do
+      let f1g = CF.Factor "x1" (CF.Continuous (-1) 1) CF.Controllable
+          f2g = CF.Factor "x2" (CF.Continuous (-1) 1) CF.Controllable
+          modelRSM = CM.Model
+            [ CM.TIntercept
+            , CM.TMain "x1", CM.TMain "x2"
+            , CM.TInter ["x1","x2"]
+            , CM.TPower "x1" 2, CM.TPower "x2" 2
+            ] CM.NCoded
+          spec = CX.CustomDesignSpec
+            { CX.cdsFactors     = [f1g, f2g]
+            , CX.cdsModel       = modelRSM
+            , CX.cdsConstraints = []
+            , CX.cdsNRuns       = 12
+            , CX.cdsCriterion   = OPT.DOpt
+            , CX.cdsBudget      = CX.defaultBudget
+            , CX.cdsSeed        = Just 42
+            , CX.cdsInitial     = Nothing
+
+            , CX.cdsDJConvention = False
+            }
+      r <- CX.coordinateExchange spec
+      case r of
+        Left e -> expectationFailure (T.unpack e)
+        Right cd -> do
+          LA.rows (CX.cdMatrix cd) `shouldBe` 12
+          LA.cols (CX.cdMatrix cd) `shouldBe` 2
+          let vs = concat (LA.toLists (CX.cdMatrix cd))
+          all (\v -> v >= -1.0001 && v <= 1.0001) vs `shouldBe` True
+          -- D 値 > 0 (非特異)
+          let dval = - CX.crCriterionValue (CX.cdReport cd)
+          dval `shouldSatisfy` (> 0)
+          -- D-eff via Compare も 0 より大きい
+          let dc = CCMP.compareDesigns [("rsm", cd)]
+          (CCMP.dcEffTable dc `LA.atIndex` (0, 0)) `shouldSatisfy` (> 0.4)
+    -- Example 2: 1 連続 + 1 categorical(3) + main + interaction
+    it "golden ex2: 1 cont + 1 cat(3) + main+int model、 各 cat level 出現" $ do
+      let fc = CF.Factor "x"  (CF.Continuous (-1) 1)            CF.Controllable
+          fk = CF.Factor "k"  (CF.Categorical ["A","B","C"])    CF.Controllable
+          model = CM.Model
+            [ CM.TIntercept
+            , CM.TMain "x", CM.TMain "k"
+            , CM.TInter ["x","k"]
+            ] CM.NCoded
+          spec = CX.CustomDesignSpec
+            { CX.cdsFactors     = [fc, fk]
+            , CX.cdsModel       = model
+            , CX.cdsConstraints = []
+            , CX.cdsNRuns       = 12
+            , CX.cdsCriterion   = OPT.DOpt
+            , CX.cdsBudget      = CX.defaultBudget
+            , CX.cdsSeed        = Just 100
+            , CX.cdsInitial     = Nothing
+
+            , CX.cdsDJConvention = False
+            }
+      r <- CX.coordinateExchange spec
+      case r of
+        Left e -> expectationFailure (T.unpack e)
+        Right cd -> do
+          LA.rows (CX.cdMatrix cd) `shouldBe` 12
+          let kCol = LA.toList (LA.flatten (LA.subMatrix (0, 1) (12, 1) (CX.cdMatrix cd)))
+              ks   = map (round :: Double -> Int) kCol
+          all (`elem` [0, 1, 2]) ks `shouldBe` True
+          -- D-opt なら全 3 level が出現するはず (= balanced 設計の必要条件)
+          length (filter (== 0) ks) `shouldSatisfy` (> 0)
+          length (filter (== 1) ks) `shouldSatisfy` (> 0)
+          length (filter (== 2) ks) `shouldSatisfy` (> 0)
+    -- Example 3: LinearIneq 制約 + 2 factor main
+    it "golden ex3: LinearIneq (x1+x2 <= 0.5) で全 row が制約満足" $ do
+      let f1g = CF.Factor "x1" (CF.Continuous (-1) 1) CF.Controllable
+          f2g = CF.Factor "x2" (CF.Continuous (-1) 1) CF.Controllable
+          modelMain = CM.Model
+            [CM.TIntercept, CM.TMain "x1", CM.TMain "x2"] CM.NCoded
+          spec = CX.CustomDesignSpec
+            { CX.cdsFactors     = [f1g, f2g]
+            , CX.cdsModel       = modelMain
+            , CX.cdsConstraints =
+                [CC.LinearIneq [("x1", 1), ("x2", 1)] CC.CLeq 0.5]
+            , CX.cdsNRuns       = 8
+            , CX.cdsCriterion   = OPT.DOpt
+            , CX.cdsBudget      = CX.defaultBudget
+            , CX.cdsSeed        = Just 21
+            , CX.cdsInitial     = Nothing
+
+            , CX.cdsDJConvention = False
+            }
+      r <- CX.coordinateExchange spec
+      case r of
+        Left e -> expectationFailure (T.unpack e)
+        Right cd -> do
+          LA.rows (CX.cdMatrix cd) `shouldBe` 8
+          let rows = LA.toLists (CX.cdMatrix cd)
+              ok [a, b] = a + b <= 0.5 + 1e-6
+              ok _      = False
+          all ok rows `shouldBe` True
+          -- D 値 > 0 (非特異)
+          let dval = - CX.crCriterionValue (CX.cdReport cd)
+          dval `shouldSatisfy` (> 0)
diff --git a/test/Hanalyze/Design/Custom/PowerSpec.hs b/test/Hanalyze/Design/Custom/PowerSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Design/Custom/PowerSpec.hs
@@ -0,0 +1,80 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Design.Custom.PowerSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Numeric.LinearAlgebra as LA
+import qualified Hanalyze.Stat.ClassMetrics as CM
+import qualified Hanalyze.Design.Optimal       as OPT
+import qualified Hanalyze.Design.Custom.Factor     as CF
+import qualified Hanalyze.Design.Custom.Model      as CM
+import qualified Hanalyze.Design.Custom.Coordinate as CX
+import qualified Hanalyze.Design.Custom.Power      as CPW
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Design.Custom.Power (Phase 24-8 designPower)" $ do
+    -- Phase 24-7 で使った 2² factorial 設計 cdFact を再利用
+    let f1p = CF.Factor "x1" (CF.Continuous (-1) 1) CF.Controllable
+        f2p = CF.Factor "x2" (CF.Continuous (-1) 1) CF.Controllable
+        mp  = CM.Model
+          [CM.TIntercept, CM.TMain "x1", CM.TMain "x2"
+          , CM.TInter ["x1","x2"]] CM.NCoded
+        rawFact2 = LA.fromLists
+          [[-1,-1],[1,-1],[-1,1],[1,1]
+          ,[-1,-1],[1,-1],[-1,1],[1,1]]  -- 8 行 (n - p = 8 - 4 = 4 で df2 > 0)
+        cdPow = CX.CustomDesign
+          { CX.cdMatrix  = rawFact2
+          , CX.cdFactors = [f1p, f2p]
+          , CX.cdModel   = mp
+          , CX.cdReport  = CX.CustomDesignReport
+              { CX.crCriterion = OPT.DOpt
+              , CX.crCriterionValue = -1, CX.crIterations = 0
+              , CX.crRestarts = 0, CX.crConverged = True, CX.crSeed = Nothing
+              }
+          }
+    it "termName: 各 ADT が canonical 名に変換される" $ do
+      CPW.termName CM.TIntercept            `shouldBe` "(Intercept)"
+      CPW.termName (CM.TMain "x1")          `shouldBe` "x1"
+      CPW.termName (CM.TInter ["x1","x2"])  `shouldBe` "x1:x2"
+      CPW.termName (CM.TPower "x1" 2)       `shouldBe` "x1^2"
+    it "termColumnIndices: 連続のみで各 term が単一 column" $ do
+      let m = CM.Model [CM.TIntercept, CM.TMain "x1", CM.TMain "x2"] CM.NCoded
+      CPW.termColumnIndices [f1p, f2p] m
+        `shouldBe` [("(Intercept)", [0]), ("x1", [1]), ("x2", [2])]
+    it "designPower: effect 大 → power 増加 (x1 main)" $ do
+      let p1 = CPW.designPower cdPow 1.0 [("x1", 0.5)] 0.05
+          p2 = CPW.designPower cdPow 1.0 [("x1", 2.0)] 0.05
+      length p1 `shouldBe` 1
+      length p2 `shouldBe` 1
+      CPW.dpPower (head p2) `shouldSatisfy` (> CPW.dpPower (head p1))
+    it "designPower: effect 0 で power ≈ α" $ do
+      let p = CPW.designPower cdPow 1.0 [("x1", 0)] 0.05
+      CPW.dpPower (head p) `shouldSatisfy` (\v -> v >= 0 && v <= 0.10)
+    it "designPower: 未知 term は power = 0" $ do
+      let p = CPW.designPower cdPow 1.0 [("xnope", 1.0)] 0.05
+      CPW.dpPower (head p) `shouldBe` 0
+    it "designPower: alpha / effect / term が結果に保存される" $ do
+      let [r] = CPW.designPower cdPow 1.0 [("x1", 1.5)] 0.01
+      CPW.dpTerm   r `shouldBe` "x1"
+      CPW.dpEffect r `shouldBe` 1.5
+      CPW.dpAlpha  r `shouldBe` 0.01
diff --git a/test/Hanalyze/Design/Custom/SplitPlotSpec.hs b/test/Hanalyze/Design/Custom/SplitPlotSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Design/Custom/SplitPlotSpec.hs
@@ -0,0 +1,215 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Design.Custom.SplitPlotSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Data.Text   as T
+import qualified Numeric.LinearAlgebra as LA
+import qualified Hanalyze.Stat.ClassMetrics as CM
+import qualified Hanalyze.Design.Optimal       as OPT
+import qualified Hanalyze.Design.Custom.Factor     as CF
+import qualified Hanalyze.Design.Custom.Model      as CM
+import qualified Hanalyze.Design.Custom.Coordinate as CX
+import qualified Hanalyze.Design.Custom.SplitPlot  as CSP
+import qualified Data.Vector.Storable              as VS
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Design.Custom.SplitPlot (Phase 25-3/4)" $ do
+    let fWP = CF.Factor "temp" (CF.Continuous (-1) 1) CF.HardToChange
+        fSP = CF.Factor "rate" (CF.Continuous (-1) 1) CF.Controllable
+        modelSP = CM.Model
+          [CM.TIntercept, CM.TMain "temp", CM.TMain "rate"
+          , CM.TInter ["temp","rate"]] CM.NCoded
+        spec = CX.CustomDesignSpec
+          { CX.cdsFactors = [fWP, fSP]
+          , CX.cdsModel   = modelSP
+          , CX.cdsConstraints = []
+          , CX.cdsNRuns   = 8
+          , CX.cdsCriterion = OPT.DOpt
+          , CX.cdsBudget    = CX.defaultBudget
+              { CX.dbRestarts = 3, CX.dbMaxIter = 30 }
+          , CX.cdsSeed      = Just 50
+          , CX.cdsInitial   = Nothing
+
+          , CX.cdsDJConvention = False
+          }
+        cfg = CSP.defaultSplitPlotConfig 4   -- 4 WP × 2 runs = 8
+    it "wholePlotIndicator: 8 行 / 4 WP で [0,0,1,1,2,2,3,3]" $ do
+      VS.toList (CSP.wholePlotIndicator 8 4) `shouldBe` [0,0,1,1,2,2,3,3]
+    it "whichRoleIsWP: HardToChange 因子の index を返す" $ do
+      CSP.whichRoleIsWP [fWP, fSP] `shouldBe` [0]
+      CSP.whichRoleIsWP [fSP, fWP] `shouldBe` [1]
+    it "HardToChange 因子なしで Left" $ do
+      let s = spec { CX.cdsFactors = [fSP, fSP] }
+      r <- CSP.generateSplitPlot s cfg
+      case r of Left _ -> pure (); Right _ -> expectationFailure "expected Left"
+    it "spcNWhole < 1 で Left" $ do
+      r <- CSP.generateSplitPlot spec (CSP.SplitPlotConfig 0 1.0 Nothing)
+      case r of Left _ -> pure (); Right _ -> expectationFailure "expected Left"
+    it "Phase 28-2 generateSplitPlot: strip-plot (VeryHardToChange) 因子は strip 内で constant" $ do
+      let fWP    = CF.Factor "wp"    (CF.Continuous (-1) 1) CF.HardToChange
+          fStrip = CF.Factor "strip" (CF.Continuous (-1) 1) CF.VeryHardToChange
+          fSP    = CF.Factor "sp"    (CF.Continuous (-1) 1) CF.Controllable
+          specStrip = CX.CustomDesignSpec
+            { CX.cdsFactors     = [fWP, fStrip, fSP]
+            , CX.cdsModel       = CM.Model
+                [CM.TIntercept, CM.TMain "wp", CM.TMain "strip", CM.TMain "sp"] CM.NCoded
+            , CX.cdsConstraints = []
+            , CX.cdsNRuns       = 12  -- 4 WP × 3 strip
+            , CX.cdsCriterion   = OPT.DOpt
+            , CX.cdsBudget      = (CX.defaultBudget)
+                { CX.dbRestarts = 1, CX.dbMaxIter = 5 }
+            , CX.cdsSeed        = Just 3
+            , CX.cdsInitial     = Nothing
+            , CX.cdsDJConvention = False
+            }
+          cfgStrip = CSP.SplitPlotConfig 4 1.0 (Just 3)
+      r <- CSP.generateSplitPlot specStrip cfgStrip
+      case r of
+        Left e -> expectationFailure (T.unpack e)
+        Right spd -> do
+          let n = 12
+              wpId    = CSP.spdWholePlotId spd
+              stripId = case CSP.spdSubPlotId spd of
+                Just s -> s
+                Nothing -> error "stripId should be Just in 28-2 strip-plot"
+              wpCol    = LA.toList (LA.flatten (LA.subMatrix (0, 0) (n, 1) (CSP.spdMatrix spd)))
+              stripCol = LA.toList (LA.flatten (LA.subMatrix (0, 1) (n, 1) (CSP.spdMatrix spd)))
+              wpConstantInWP w =
+                let vs = [wpCol !! i | i <- [0 .. n-1], wpId VS.! i == w]
+                in all (\x -> abs (x - head vs) < 1e-9) vs
+              stripConstantInStrip s =
+                let vs = [stripCol !! i | i <- [0 .. n-1], stripId VS.! i == s]
+                in all (\x -> abs (x - head vs) < 1e-9) vs
+          all wpConstantInWP [0..3] `shouldBe` True
+          all stripConstantInStrip [0..2] `shouldBe` True
+    it "Phase 28-3 generateSplitPlot: Categorical WP 因子 (machine) も WP 内で constant" $ do
+      let fMachine = CF.Factor "machine" (CF.Categorical ["A","B"]) CF.HardToChange
+          fX = CF.Factor "x" (CF.Continuous (-1) 1) CF.Controllable
+          specCat = CX.CustomDesignSpec
+            { CX.cdsFactors     = [fMachine, fX]
+            , CX.cdsModel       = CM.Model
+                [CM.TIntercept, CM.TMain "machine", CM.TMain "x"] CM.NCoded
+            , CX.cdsConstraints = []
+            , CX.cdsNRuns       = 8
+            , CX.cdsCriterion   = OPT.DOpt
+            , CX.cdsBudget      = (CX.defaultBudget)
+                { CX.dbRestarts = 1, CX.dbMaxIter = 5 }
+            , CX.cdsSeed        = Just 7
+            , CX.cdsInitial     = Nothing
+            , CX.cdsDJConvention = False
+            }
+          cfgCat = CSP.SplitPlotConfig 4 1.0 Nothing
+      r <- CSP.generateSplitPlot specCat cfgCat
+      case r of
+        Left e -> expectationFailure (T.unpack e)
+        Right spd -> do
+          let wpId = CSP.spdWholePlotId spd
+              machineCol = LA.toList (LA.flatten (LA.subMatrix (0, 0) (8, 1) (CSP.spdMatrix spd)))
+              perWP w = [ machineCol !! i | i <- [0 .. 7], wpId VS.! i == w ]
+              constant xs = all (\x -> abs (x - head xs) < 1e-9) xs
+          all constant [perWP w | w <- [0..3]] `shouldBe` True
+    it "generateSplitPlot: WP 因子 (temp) は WP 内で constant" $ do
+      r <- CSP.generateSplitPlot spec cfg
+      case r of
+        Left e -> expectationFailure (T.unpack e)
+        Right spd -> do
+          LA.rows (CSP.spdMatrix spd) `shouldBe` 8
+          let wpId = CSP.spdWholePlotId spd
+              wpCol = LA.toList (LA.flatten (LA.subMatrix (0, 0) (8, 1) (CSP.spdMatrix spd)))
+          -- 各 WP 内で temp 値が一致しているか
+          let perWP w = [ wpCol !! i | i <- [0 .. 7], wpId VS.! i == w ]
+              constant xs = all (\x -> abs (x - head xs) < 1e-9) xs
+          all constant [perWP w | w <- [0..3]] `shouldBe` True
+
+  describe "Phase 27-2 pinned: Jones-Goos (2012) Table 2 D-criterion regression" $ do
+    -- 一次根拠: Jones & Goos (2012) "I-optimal versus D-optimal split-plot
+    -- response surface designs" (U Antwerp Research Paper 2012-002) Table 2、
+    -- 20-run split-plot D-Optimal design (4 WP × 5 SP、 1 WP w + 1 SP s、
+    -- full quadratic、 η=1)。 Phase 27-2 で hanalyze の SplitPlot
+    -- D-opt 結果が同じ D-criterion (= 2684.444...) に到達することを確認済。
+    -- 本テストは regression 防止 (CI 常時検証)。 bench/custom-design/REPORT.md
+    -- §Phase 27-2 参照。
+    let factorsJG =
+          [ CF.Factor "w" (CF.Continuous (-1) 1) CF.HardToChange
+          , CF.Factor "s" (CF.Continuous (-1) 1) CF.Controllable
+          ]
+        modelJG = CM.Model
+          [ CM.TIntercept
+          , CM.TMain "w", CM.TMain "s"
+          , CM.TInter ["w","s"]
+          , CM.TPower "w" 2, CM.TPower "s" 2
+          ] CM.NCoded
+        specJG = CX.CustomDesignSpec
+          { CX.cdsFactors     = factorsJG
+          , CX.cdsModel       = modelJG
+          , CX.cdsConstraints = []
+          , CX.cdsNRuns       = 20
+          , CX.cdsCriterion   = OPT.DOpt
+          , CX.cdsBudget      = CX.defaultBudget
+          , CX.cdsSeed        = Just 42
+          , CX.cdsInitial     = Nothing
+
+          , CX.cdsDJConvention = False
+          }
+        cfgJG = CSP.SplitPlotConfig
+          { CSP.spcNWhole = 4, CSP.spcVarRatio = 1.0, CSP.spcNStrip = Nothing }
+    it "Jones-Goos golden: SplitPlot D-opt の det(X' M⁻¹ X) ≥ 2684.0" $ do
+      r <- CSP.generateSplitPlot specJG cfgJG
+      case r of
+        Left e   -> expectationFailure (T.unpack e)
+        Right sp -> do
+          -- spdGEFFEst = -det(X' M⁻¹ X)、 文献値 2684.4444...
+          let dval = - CSP.spdGEFFEst sp
+          dval `shouldSatisfy` (>= 2684.0)
+          -- 0.05% 以内で文献値と一致 (現状実測 1.0000、 retry 余地 0.0005)
+          abs (dval - 2684.4444444444) `shouldSatisfy` (< 1.5)
+
+  -- Phase 78.M M1: IO→ST pure 化。純粋版が IO 版とビット一致 (アルゴリズム不変)。
+  describe "Phase 78.M M1: generateSplitPlotPure (seed 決定的 pure)" $ do
+    let fWP = CF.Factor "temp" (CF.Continuous (-1) 1) CF.HardToChange
+        fSP = CF.Factor "rate" (CF.Continuous (-1) 1) CF.Controllable
+        modelSP = CM.Model
+          [CM.TIntercept, CM.TMain "temp", CM.TMain "rate"
+          , CM.TInter ["temp","rate"]] CM.NCoded
+        specSP = CX.CustomDesignSpec
+          { CX.cdsFactors = [fWP, fSP], CX.cdsModel = modelSP
+          , CX.cdsConstraints = [], CX.cdsNRuns = 8, CX.cdsCriterion = OPT.DOpt
+          , CX.cdsBudget = CX.defaultBudget { CX.dbRestarts = 3, CX.dbMaxIter = 30 }
+          , CX.cdsSeed = Just 50, CX.cdsInitial = Nothing, CX.cdsDJConvention = False }
+        cfgSP = CSP.defaultSplitPlotConfig 4
+
+    it "純粋版は IO 版と同 seed でビット一致 (split-plot)" $ do
+      io <- CSP.generateSplitPlot specSP cfgSP
+      case (io, CSP.generateSplitPlotPure specSP cfgSP) of
+        (Right a, Right b) -> do
+          LA.toLists (CSP.spdMatrix a) `shouldBe` LA.toLists (CSP.spdMatrix b)
+          CSP.spdGEFFEst a `shouldBe` CSP.spdGEFFEst b
+          VS.toList (CSP.spdWholePlotId a) `shouldBe` VS.toList (CSP.spdWholePlotId b)
+        _ -> expectationFailure "expected Right from both IO and pure"
+
+    it "純粋版は参照透過 (同 spec を 2 回で同結果)" $ do
+      case (CSP.generateSplitPlotPure specSP cfgSP, CSP.generateSplitPlotPure specSP cfgSP) of
+        (Right a, Right b) ->
+          LA.toLists (CSP.spdMatrix a) `shouldBe` LA.toLists (CSP.spdMatrix b)
+        _ -> expectationFailure "expected Right from both pure calls"
diff --git a/test/Hanalyze/Design/DSDSpec.hs b/test/Hanalyze/Design/DSDSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Design/DSDSpec.hs
@@ -0,0 +1,117 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Design.DSDSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Data.Text   as T
+import qualified Numeric.LinearAlgebra as LA
+import qualified Hanalyze.Design.DSD           as DSD
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Design.DSD (Phase 6.2)" $ do
+    it "sanity: k < 2 は Left" $ do
+      case DSD.dsdDesign 0 of
+        Left _  -> pure ()
+        Right _ -> expectationFailure "expected Left for k=0"
+      case DSD.dsdDesign 1 of
+        Left _  -> pure ()
+        Right _ -> expectationFailure "expected Left for k=1"
+
+    it "k=4: verified DSD (Jones-Nachtsheim conference matrix 由来)" $
+      case DSD.dsdDesign 4 of
+        Left e  -> expectationFailure (T.unpack e)
+        Right r -> do
+          DSD.dsdNFactors r `shouldBe` 4
+          DSD.dsdNRuns r    `shouldBe` 9   -- = 2*4 + 1
+          DSD.dsdHasOptimal r `shouldBe` True
+          LA.rows (DSD.dsdMatrix r) `shouldBe` 9
+          LA.cols (DSD.dsdMatrix r) `shouldBe` 4
+
+    it "k=4: row 0 (center) は全 0" $
+      case DSD.dsdDesign 4 of
+        Left e  -> expectationFailure (T.unpack e)
+        Right r -> do
+          let row0 = LA.toList (LA.flatten (LA.tr (DSD.dsdMatrix r) LA.¿ [0]))
+          row0 `shouldBe` replicate 4 0.0
+
+    it "k=4: 各 row (center 除く) は値域 {-1, 0, +1}" $
+      case DSD.dsdDesign 4 of
+        Left e  -> expectationFailure (T.unpack e)
+        Right r ->
+          all (\v -> v == -1 || v == 0 || v == 1)
+              (LA.toList (LA.flatten (DSD.dsdMatrix r))) `shouldBe` True
+
+    it "k=4: 各 row (center 除く) に 0 が ちょうど 1 個" $
+      case DSD.dsdDesign 4 of
+        Left e  -> expectationFailure (T.unpack e)
+        Right r -> do
+          let rs = LA.toLists (DSD.dsdMatrix r)
+              nonCenter = tail rs  -- skip row 0
+              zeroCount row = length (filter (== 0) row)
+          all (\row -> zeroCount row == 1) nonCenter `shouldBe` True
+
+    it "k=4: foldover 構造 (row i+k = -row i for i in 1..k)" $
+      case DSD.dsdDesign 4 of
+        Left e  -> expectationFailure (T.unpack e)
+        Right r -> do
+          let rs = LA.toLists (DSD.dsdMatrix r)
+              -- rows 1..4 と rows 5..8 が foldover ペア
+              forward  = take 4 (drop 1 rs)
+              backward = take 4 (drop 5 rs)
+              negated  = map (map negate) forward
+          backward `shouldBe` negated
+
+    it "k=4: conference matrix 検証 C·Cᵀ = (n-1)·I (verified DSD のみ)" $
+      case DSD.dsdDesign 4 of
+        Left e  -> expectationFailure (T.unpack e)
+        Right r -> do
+          -- conference matrix = rows 1..4 of dsdMatrix
+          let cMat = (DSD.dsdMatrix r) LA.? [1, 2, 3, 4]
+              prod = cMat LA.<> LA.tr cMat
+              -- (n-1) * I_4 should be diag(3, 3, 3, 3)
+              expected = LA.scale 3 (LA.ident 4)
+              diff = prod - expected
+          LA.maxElement (LA.cmap abs diff) `shouldSatisfy` (< 1e-12)
+
+    it "k=6: structural DSD (Hadamard-like 近似)" $
+      case DSD.dsdDesign 6 of
+        Left e  -> expectationFailure (T.unpack e)
+        Right r -> do
+          DSD.dsdNFactors r `shouldBe` 6
+          DSD.dsdNRuns r    `shouldBe` 13  -- = 2*6 + 1
+          DSD.dsdHasOptimal r `shouldBe` False  -- conference matrix では無い
+          let rs = LA.toLists (DSD.dsdMatrix r)
+          -- center + 2k=12 rows
+          length rs `shouldBe` 13
+          -- 各非中心行に 0 が 1 個
+          all (\row -> length (filter (== 0) row) == 1) (tail rs) `shouldBe` True
+
+    it "structural DSD の foldover 構造維持" $
+      case DSD.dsdDesign 8 of
+        Left e  -> expectationFailure (T.unpack e)
+        Right r -> do
+          DSD.dsdNRuns r `shouldBe` 17
+          let rs = LA.toLists (DSD.dsdMatrix r)
+              forward  = take 8 (drop 1 rs)
+              backward = take 8 (drop 9 rs)
+          backward `shouldBe` map (map negate) forward
diff --git a/test/Hanalyze/Design/DiagnosticsSpec.hs b/test/Hanalyze/Design/DiagnosticsSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Design/DiagnosticsSpec.hs
@@ -0,0 +1,68 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Design.DiagnosticsSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Numeric.LinearAlgebra as LA
+import qualified Hanalyze.Design.Diagnostics   as DDiag
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Design.Diagnostics (Phase 14)" $ do
+    let -- 直交設計 (full factorial 2² + intercept)
+        xOrth = LA.fromLists
+          [ [1, -1, -1]
+          , [1,  1, -1]
+          , [1, -1,  1]
+          , [1,  1,  1]
+          ]
+        -- multicollinear: 列 2 = 列 1 + 微小ノイズ
+        xCollin = LA.fromLists
+          [ [1, -1, -0.999]
+          , [1,  1,  1.001]
+          , [1, -1, -1.001]
+          , [1,  1,  0.999]
+          ]
+    it "diagnostics: 直交設計で全 efficiency が >= 0" $ do
+      let dd = DDiag.diagnostics xOrth
+      DDiag.ddDEff dd `shouldSatisfy` (> 0)
+      DDiag.ddAEff dd `shouldSatisfy` (> 0)
+      DDiag.ddGEff dd `shouldSatisfy` (> 0)
+    it "VIF: 直交設計で全列の VIF ≈ 1" $ do
+      let dd = DDiag.diagnostics xOrth
+          v  = DDiag.ddVIF dd
+      LA.atIndex v 0 `shouldSatisfy` (\x -> abs (x - 1) < 1e-6)
+      LA.atIndex v 1 `shouldSatisfy` (\x -> abs (x - 1) < 1e-6)
+      LA.atIndex v 2 `shouldSatisfy` (\x -> abs (x - 1) < 1e-6)
+    it "VIF: 多重共線設計で列 1/2 の VIF が大きい" $ do
+      let dd = DDiag.diagnostics xCollin
+          v  = DDiag.ddVIF dd
+      LA.atIndex v 1 `shouldSatisfy` (> 10)
+    it "diagnostics: D-efficiency は直交 > collinear" $ do
+      let dOrth = DDiag.ddDEff (DDiag.diagnostics xOrth)
+          dCol  = DDiag.ddDEff (DDiag.diagnostics xCollin)
+      dOrth `shouldSatisfy` (> dCol)
+    it "aliasMatrix: 直交設計 vs 平方項で形状確認" $ do
+      let z = LA.fromLists [[1], [1], [1], [1]]   -- 例: 全 1 (intercept alias)
+          a = DDiag.aliasMatrix xOrth z
+      LA.rows a `shouldBe` 3
+      LA.cols a `shouldBe` 1
diff --git a/test/Hanalyze/Design/GaugeRRSpec.hs b/test/Hanalyze/Design/GaugeRRSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Design/GaugeRRSpec.hs
@@ -0,0 +1,65 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Design.GaugeRRSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Data.Vector as V
+import qualified Data.Text   as T
+import qualified Hanalyze.Design.GaugeRR    as GRR
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Design.GaugeRR (Phase 10)" $ do
+    -- 3 parts × 2 operators × 2 reps = 12 obs
+    -- 部品 0/1/2 で本来 ばらつき、 操作者間は小さい
+    let crossedData =
+          let parts = V.fromList (concat (replicate 4 [0, 1, 2]))  -- 12 obs total
+              ops   = V.fromList (concat
+                        [ [0, 0, 0]  -- part rep 1 ops
+                        , [0, 0, 0]  -- part rep 2 ops
+                        , [1, 1, 1]
+                        , [1, 1, 1]
+                        ])
+              -- Each part has 4 obs (2 ops × 2 reps), part effect strong
+              ys = V.fromList
+                [10.0, 20.0, 30.0,    -- part 0/1/2 by op 0 rep 1
+                 10.1, 20.2, 29.9,    -- ... rep 2
+                 10.2, 20.1, 30.1,    -- op 1 rep 1
+                 9.9,  19.9, 30.0]    -- op 1 rep 2
+          in (ops, parts, ys)
+
+    it "gaugeRRCrossed: 部品支配ばらつき → grrPctPart 高い" $ do
+      let (ops, parts, ys) = crossedData
+      case GRR.gaugeRRCrossed ops parts ys of
+        Left e -> expectationFailure (T.unpack e)
+        Right r -> do
+          -- 部品 0/1/2 の平均がそれぞれ 10/20/30 で大きく異なる → 部品分散支配
+          GRR.grrPctPart r `shouldSatisfy` (> 90)
+          GRR.grrTotalVar r `shouldSatisfy` (> 0)
+
+    it "gaugeRRCrossed: 入力長 mismatch は Left" $
+      case GRR.gaugeRRCrossed
+             (V.fromList [0, 1])
+             (V.fromList [0, 1, 2])
+             (V.fromList [1, 2, 3]) of
+        Left _ -> pure ()
+        Right _ -> expectationFailure "expected Left"
diff --git a/test/Hanalyze/Design/MixtureSpec.hs b/test/Hanalyze/Design/MixtureSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Design/MixtureSpec.hs
@@ -0,0 +1,105 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Design.MixtureSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Data.Text   as T
+import qualified Numeric.LinearAlgebra as LA
+import qualified Hanalyze.Design.Mixture       as Mix
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Design.Mixture (Phase 7.1)" $ do
+    let sumsToOne row = abs (sum row - 1.0) < 1e-12
+
+    it "sanity: m < 2 は Left" $ do
+      case Mix.mixtureDesign Mix.SimplexCentroid 1 of
+        Left _ -> pure ()
+        Right _ -> expectationFailure "expected Left for m=1"
+      case Mix.mixtureDesign (Mix.SimplexLattice 2) 0 of
+        Left _ -> pure ()
+        Right _ -> expectationFailure "expected Left for m=0"
+
+    it "sanity: SimplexLattice d < 1 は Left" $
+      case Mix.mixtureDesign (Mix.SimplexLattice 0) 3 of
+        Left _ -> pure ()
+        Right _ -> expectationFailure "expected Left for d=0"
+
+    it "SimplexLattice {3, 2}: 6 点、 全行の合計が 1" $
+      case Mix.mixtureDesign (Mix.SimplexLattice 2) 3 of
+        Left e -> expectationFailure (T.unpack e)
+        Right r -> do
+          Mix.mdNComponents r `shouldBe` 3
+          Mix.mdNRuns r       `shouldBe` 6  -- C(3+2-1, 2) = C(4, 2) = 6
+          LA.rows (Mix.mdMatrix r) `shouldBe` 6
+          LA.cols (Mix.mdMatrix r) `shouldBe` 3
+          let rows = LA.toLists (Mix.mdMatrix r)
+          all sumsToOne rows `shouldBe` True
+          -- 各点の値は {0, 0.5, 1.0} のみ
+          all (\v -> v == 0.0 || v == 0.5 || v == 1.0)
+              (LA.toList (LA.flatten (Mix.mdMatrix r))) `shouldBe` True
+
+    it "SimplexLattice {3, 3}: 10 点 (C(5,3)=10)、 値は {0, 1/3, 2/3, 1}" $
+      case Mix.mixtureDesign (Mix.SimplexLattice 3) 3 of
+        Left e -> expectationFailure (T.unpack e)
+        Right r -> do
+          Mix.mdNRuns r `shouldBe` 10
+          let rows = LA.toLists (Mix.mdMatrix r)
+          all sumsToOne rows `shouldBe` True
+          -- 各値は {0, 1/3, 2/3, 1} に近い
+          let expectedVals = [0.0, 1/3, 2/3, 1.0]
+              isClose v = any (\e -> abs (v - e) < 1e-12) expectedVals
+          all isClose (LA.toList (LA.flatten (Mix.mdMatrix r))) `shouldBe` True
+
+    it "SimplexCentroid (3): 7 点 (= 2^3 - 1)" $
+      case Mix.mixtureDesign Mix.SimplexCentroid 3 of
+        Left e -> expectationFailure (T.unpack e)
+        Right r -> do
+          Mix.mdNRuns r `shouldBe` 7
+          let rows = LA.toLists (Mix.mdMatrix r)
+          all sumsToOne rows `shouldBe` True
+          -- 単体頂点 (1, 0, 0), (0, 1, 0), (0, 0, 1) 全て含む
+          [1, 0, 0] `elem` rows `shouldBe` True
+          [0, 1, 0] `elem` rows `shouldBe` True
+          [0, 0, 1] `elem` rows `shouldBe` True
+
+    it "SimplexCentroid (3) は全体重心 (1/3, 1/3, 1/3) を含む" $
+      case Mix.mixtureDesign Mix.SimplexCentroid 3 of
+        Left e -> expectationFailure (T.unpack e)
+        Right r -> do
+          let centroid = [1/3, 1/3, 1/3]
+              rows = LA.toLists (Mix.mdMatrix r)
+              isClose pt = abs (sum (zipWith (\a b -> abs (a - b)) pt centroid)) < 1e-12
+          any isClose rows `shouldBe` True
+
+    it "SimplexCentroid (4): 15 点 (= 2^4 - 1)" $
+      case Mix.mixtureDesign Mix.SimplexCentroid 4 of
+        Left e -> expectationFailure (T.unpack e)
+        Right r -> Mix.mdNRuns r `shouldBe` 15
+
+    it "SimplexLattice {3, 1}: 3 点 (= 単体頂点のみ)" $
+      case Mix.mixtureDesign (Mix.SimplexLattice 1) 3 of
+        Left e -> expectationFailure (T.unpack e)
+        Right r -> do
+          Mix.mdNRuns r `shouldBe` 3
+          let rows = LA.toLists (Mix.mdMatrix r)
+          rows `shouldMatchList` [[1,0,0],[0,1,0],[0,0,1]]
diff --git a/test/Hanalyze/Design/OptimalSpec.hs b/test/Hanalyze/Design/OptimalSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Design/OptimalSpec.hs
@@ -0,0 +1,123 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Design.OptimalSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Hanalyze.Design.Optimal       as OPT
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Design.Optimal.augmentDesign (Phase 5)" $ do
+    -- 2 因子の二次応答曲面候補 (intercept + x1 + x2 + x1² + x2² + x1x2)
+    -- = quadraticCandidates 2 3 → 9 候補 (3×3 grid)
+    let cands2 = OPT.quadraticCandidates 2 3
+        -- 既存 4 行 design (適当に factorial corner を 3 個 + 中心)
+        existing4 =
+          [ [1.0, -1.0, -1.0, 1.0, 1.0,  1.0]
+          , [1.0,  1.0, -1.0, 1.0, 1.0, -1.0]
+          , [1.0, -1.0,  1.0, 1.0, 1.0, -1.0]
+          , [1.0,  0.0,  0.0, 0.0, 0.0,  0.0]
+          ]
+
+    it "N=0 や 候補不足は空の new で返す" $ do
+      let r0 = OPT.augmentDesign OPT.DOpt existing4 0    cands2 42
+          r1 = OPT.augmentDesign OPT.DOpt existing4 1000 (take 1 cands2) 42
+      OPT.arNewIndices r0 `shouldBe` []
+      OPT.arNewIndices r1 `shouldBe` []
+      OPT.arFullDesign r0 `shouldBe` existing4
+      OPT.arFullDesign r1 `shouldBe` existing4
+
+    it "N=2 追加で arNewRows の長さは 2、 arFullDesign は existing ++ new" $ do
+      let r = OPT.augmentDesign OPT.DOpt existing4 2 cands2 42
+      length (OPT.arNewIndices r) `shouldBe` 2
+      length (OPT.arNewRows r)    `shouldBe` 2
+      length (OPT.arFullDesign r) `shouldBe` length existing4 + 2
+      take 4 (OPT.arFullDesign r) `shouldBe` existing4  -- 順序保存
+
+    it "augment 後の |XᵀX| が augment 前 以上 (実質単調増加)" $ do
+      let r = OPT.augmentDesign OPT.DOpt existing4 3 cands2 42
+      -- arInitialCrit は existing 単独の |XᵀX|、 arFinalCrit は augment 後
+      -- D-opt なので大きい方が良い
+      OPT.arFinalCrit r `shouldSatisfy` (>= OPT.arInitialCrit r)
+
+    it "異なる seed でも arNewRows 長さは一定、 |XᵀX| 改善は保つ" $ do
+      let r1 = OPT.augmentDesign OPT.DOpt existing4 3 cands2 1
+          r2 = OPT.augmentDesign OPT.DOpt existing4 3 cands2 99
+      length (OPT.arNewRows r1) `shouldBe` 3
+      length (OPT.arNewRows r2) `shouldBe` 3
+      OPT.arFinalCrit r1 `shouldSatisfy` (>= OPT.arInitialCrit r1)
+      OPT.arFinalCrit r2 `shouldSatisfy` (>= OPT.arInitialCrit r2)
+
+    it "空 existing でも augment は機能 (= 通常の optimal design 相当)" $ do
+      let r = OPT.augmentDesign OPT.DOpt [] 5 cands2 42
+      length (OPT.arNewRows r) `shouldBe` 5
+      OPT.arInitialCrit r `shouldBe` 0   -- 空 existing は singular扱い
+      OPT.arFinalCrit r `shouldSatisfy` (> 0)
+
+    it "選ばれた追加点は候補集合の subset" $ do
+      let r = OPT.augmentDesign OPT.DOpt existing4 3 cands2 42
+          newSet  = OPT.arNewRows r
+          candSet = cands2
+      all (`elem` candSet) newSet `shouldBe` True
+
+  describe "Hanalyze.Design.Optimal I/E-optimal (Phase 14)" $ do
+    let candidates =
+          [ [1, x1, x2] | x1 <- [-1, 0, 1], x2 <- [-1, 0, 1] ]
+    it "iOptimal: 9 候補から 5 設計を抽出、 fullrank" $ do
+      let (idxs, des) = OPT.iOptimal candidates 5 42
+      length idxs `shouldBe` 5
+      length des `shouldBe` 5
+    it "eOptimal: 9 候補から 5 設計を抽出" $ do
+      let (idxs, _) = OPT.eOptimal candidates 5 42
+      length idxs `shouldBe` 5
+
+  describe "Hanalyze.Design.Optimal GOpt / Compound (Phase 23-a)" $ do
+    let candidates =
+          [ [1, x1, x2] | x1 <- [-1, 0, 1], x2 <- [-1, 0, 1] ]
+    it "gOptimal: 9 候補から 5 設計を抽出 (max leverage 最小化)" $ do
+      let (idxs, des) = OPT.gOptimal candidates 5 42
+      length idxs `shouldBe` 5
+      length des `shouldBe` 5
+    it "Compound [(1, DOpt)] は DOpt と同じ design を返す" $ do
+      let (idxsD, _) = OPT.dOptimal candidates 5 42
+          (idxsC, _) = OPT.optimalDesign (OPT.Compound [(1.0, OPT.DOpt)]) candidates 5 42
+      idxsC `shouldBe` idxsD
+    it "Compound [(0.5, DOpt), (0.5, AOpt)] が n 行を選択して動作" $ do
+      let (idxs, _) =
+            OPT.optimalDesign
+              (OPT.Compound [(0.5, OPT.DOpt), (0.5, OPT.AOpt)])
+              candidates 5 42
+      length idxs `shouldBe` 5
+
+    -- Phase 78.I: n > 候補点数 のとき点の反復を許し、 きっちり n 点を返す (頭打ちしない)。
+    it "optimalDesign: n > 候補点数 は点を反復して n 点を返す" $ do
+      let (idxs, des) = OPT.optimalDesign OPT.DOpt candidates 12 42  -- 候補 9・n=12
+      length idxs `shouldBe` 12                       -- 頭打ちせず 12 点
+      length des  `shouldBe` 12
+      length (nub idxs) < length idxs `shouldBe` True -- 少なくとも 1 点は反復
+      all (`elem` [0 .. length candidates - 1]) idxs `shouldBe` True  -- 全て候補 index
+    -- distinct が一意に D-最適な場合 (linear 2 因子・n=4 = 4 隅) は反復しない。
+    -- ※真の exact D-最適ゆえ、 反復が D を上げる場合 (linear の n=5 等) は n<=nC でも反復し得る。
+    it "optimalDesign: distinct が最適なら反復しない (linear n=4 = 4 隅)" $ do
+      let (idxs, _) = OPT.optimalDesign OPT.DOpt candidates 4 42
+      length idxs `shouldBe` 4
+      length (nub idxs) `shouldBe` 4                   -- 4 隅・反復なし
diff --git a/test/Hanalyze/Design/OrthogonalSpec.hs b/test/Hanalyze/Design/OrthogonalSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Design/OrthogonalSpec.hs
@@ -0,0 +1,133 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Design.OrthogonalSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Hanalyze.Design.Orthogonal as OA
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Design.Orthogonal" $ do
+    it "L4 has 4 runs and 3 columns" $ do
+      OA.oaRuns OA.l4    `shouldBe` 4
+      OA.oaFactors OA.l4 `shouldBe` 3
+      length (OA.oaTable OA.l4) `shouldBe` 4
+
+    it "L8 has 8 runs and 7 columns" $ do
+      OA.oaRuns OA.l8    `shouldBe` 8
+      OA.oaFactors OA.l8 `shouldBe` 7
+
+    it "L9 has 9 runs and 4 columns at 3 levels each" $ do
+      OA.oaRuns OA.l9    `shouldBe` 9
+      OA.oaFactors OA.l9 `shouldBe` 4
+      OA.oaLevels OA.l9  `shouldBe` [3, 3, 3, 3]
+
+    it "L18 has 18 runs and 8 columns (1×2 + 7×3)" $ do
+      OA.oaRuns OA.l18    `shouldBe` 18
+      OA.oaLevels OA.l18  `shouldBe` 2 : replicate 7 3
+
+    it "L27 has 27 runs and 13 columns at 3 levels each" $ do
+      OA.oaRuns OA.l27    `shouldBe` 27
+      OA.oaFactors OA.l27 `shouldBe` 13
+      OA.oaLevels OA.l27  `shouldBe` replicate 13 3
+
+    it "L27 columns are balanced (each level appears 9 times)" $ do
+      let table = OA.oaTable OA.l27
+          colJ j = [ row !! j | row <- table ]
+      mapM_ (\j -> mapM_ (\l ->
+        length (filter (== l) (colJ j)) `shouldBe` 9) [1,2,3]) [0 .. 12]
+
+    it "L27 column pairs are strength-2 orthogonal (each of 9 combos appears 3 times)" $ do
+      let table = OA.oaTable OA.l27
+          colJ j = [ row !! j | row <- table ]
+          pairCount j1 j2 a b =
+            length (filter id (zipWith (\x y -> x == a && y == b)
+                                       (colJ j1) (colJ j2)))
+      mapM_ (\(j1, j2) -> mapM_ (\(a, b) ->
+        pairCount j1 j2 a b `shouldBe` 3)
+        [ (a, b) | a <- [1,2,3], b <- [1,2,3] ])
+        [ (j1, j2) | j1 <- [0 .. 12], j2 <- [j1 + 1 .. 12] ]
+
+    it "lookupOA finds L27" $
+      OA.oaName <$> OA.lookupOA "L27" `shouldBe` Just "L27(3^13)"
+
+    it "L8 columns are balanced (each level appears 4 times)" $ do
+      let table = OA.oaTable OA.l8
+          colJ j = [ row !! j | row <- table ]
+      mapM_ (\j -> do
+        let cs = colJ j
+        length (filter (== 1) cs) `shouldBe` 4
+        length (filter (== 2) cs) `shouldBe` 4) [0 .. 6]
+
+    it "L8 column pairs are pairwise orthogonal" $ do
+      let table = OA.oaTable OA.l8
+          colJ j = [ row !! j | row <- table ]
+          pairCount j1 j2 a b =
+            length (filter id (zipWith (\x y -> x == a && y == b)
+                                       (colJ j1) (colJ j2)))
+      -- For 2-level orthogonality: each pair (1,1)/(1,2)/(2,1)/(2,2) must appear equally
+      mapM_ (\(j1, j2) -> do
+        pairCount j1 j2 1 1 `shouldBe` 2
+        pairCount j1 j2 1 2 `shouldBe` 2
+        pairCount j1 j2 2 1 `shouldBe` 2
+        pairCount j1 j2 2 2 `shouldBe` 2)
+        [(0,1),(0,2),(0,3),(1,2),(1,3),(2,3)]
+
+    it "lookupOA finds standard arrays case-insensitively" $ do
+      OA.oaName <$> OA.lookupOA "L9"   `shouldBe` Just "L9(3^4)"
+      OA.oaName <$> OA.lookupOA "l9"   `shouldBe` Just "L9(3^4)"
+      OA.oaName <$> OA.lookupOA "L99"  `shouldBe` Nothing
+
+    it "assignFactors fills levels correctly for L4" $ do
+      let specs = [ OA.FactorSpec "A" [OA.LText "lo", OA.LText "hi"]
+                  , OA.FactorSpec "B" [OA.LNumeric 0,   OA.LNumeric 1]
+                  ]
+      case OA.assignFactors OA.l4 specs of
+        Right ad -> do
+          length (OA.adRows ad) `shouldBe` 4
+          map length (OA.adRows ad) `shouldBe` [2, 2, 2, 2]
+        Left e -> expectationFailure (show e)
+
+    it "assignFactors rejects too many factors" $ do
+      let specs = replicate 5 (OA.FactorSpec "X" [OA.LNumeric 1, OA.LNumeric 2])
+      OA.assignFactors OA.l4 specs `shouldSatisfy`
+        \r -> case r of { Left _ -> True; Right _ -> False }
+
+    it "assignFactors rejects level count mismatch" $ do
+      let specs = [ OA.FactorSpec "A" [OA.LText "x"] ]   -- only 1 level, L4 needs 2
+      OA.assignFactors OA.l4 specs `shouldSatisfy`
+        \r -> case r of { Left _ -> True; Right _ -> False }
+
+  -- ─────────────────────────────────────────────────────────────────────
+
+  describe "Hanalyze.Design.Orthogonal.listArraysWithSize" $ do
+    it "returns one entry per standard array" $
+      length OA.listArraysWithSize `shouldBe` length OA.standardArrays
+
+    it "L9 entry exposes runs / factors / levels" $ do
+      let l9meta = head [ m | m <- OA.listArraysWithSize
+                            , OA.omName m == OA.oaName OA.l9 ]
+      OA.omRuns l9meta    `shouldBe` 9
+      OA.omFactors l9meta `shouldBe` 4
+      OA.omLevels l9meta  `shouldBe` [3, 3, 3, 3]
+
+  -- ─────────────────────────────────────────────────────────────────────
diff --git a/test/Hanalyze/Design/QualitySpec.hs b/test/Hanalyze/Design/QualitySpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Design/QualitySpec.hs
@@ -0,0 +1,121 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Design.QualitySpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Numeric.LinearAlgebra as LA
+import qualified Hanalyze.Design.Quality    as Quality
+import qualified Hanalyze.Model.Weibull     as WB
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Design.Quality.processCapability" $ do
+    it "centred process with σ=1, USL=6, LSL=−6 → Cp ≈ 2.0, Cpk ≈ 2.0" $ do
+      -- 11-point symmetric sample around 0 with σ=1 (population)
+      let xs = LA.fromList [-1.5, -1.0, -0.5, 0.5, 1.0, 1.5,
+                             1.5,  1.0,  0.5, -0.5, -1.0, -1.5]
+          cap = Quality.processCapability (-6) 6 xs
+      Quality.capCp  cap `shouldSatisfy` (> 0)
+      -- For a centred sample, Cp == Cpk by symmetry.
+      abs (Quality.capCp cap - Quality.capCpk cap)
+        `shouldSatisfy` (< 1e-2)
+
+    it "shifted process: Cpk < Cp" $ do
+      let xs = LA.fromList [4.0, 4.5, 4.2, 4.8, 4.3, 4.6, 4.4, 4.7]
+          cap = Quality.processCapability 0 6 xs
+      Quality.capCp cap  `shouldSatisfy` (> Quality.capCpk cap)
+
+    it "processCapabilityUpper: only USL → Cp == Cpk" $ do
+      let xs = LA.fromList [1.0, 1.2, 0.9, 1.1, 1.05, 0.95]
+          cap = Quality.processCapabilityUpper 2.0 xs
+      Quality.capCp cap `shouldBe` Quality.capCpk cap
+
+  describe "Hanalyze.Design.Quality 非正規 (Phase 13.3)" $ do
+    it "processCapabilityWeibull: 形状 k=2 の Weibull で Cp 計算が finite" $ do
+      let wf = WB.WeibullFit 2.0 100.0 0 10 10 (0, 0, 0)
+          cap = Quality.processCapabilityWeibull wf 10 300
+      Quality.capCp cap `shouldSatisfy` (> 0)
+      Quality.capCp cap `shouldSatisfy` (\v -> not (isNaN v))
+    it "processCapabilityLogNormal: 対称 spec で Cpk = Cp の上下" $ do
+      let mu = 0
+          sigma = 0.3
+          med = exp mu
+          cap = Quality.processCapabilityLogNormal mu sigma (med * 0.5) (med * 2.0)
+      Quality.capCp cap `shouldSatisfy` (> 0)
+      Quality.capCpk cap `shouldSatisfy` (<= Quality.capCp cap + 1e-9)
+    it "processCapabilityLogNormal: spec を 0 spread にすると Cp = 0" $ do
+      let cap = Quality.processCapabilityLogNormal 0 0.3 1.0 1.0
+      Quality.capCp cap `shouldSatisfy` (\v -> v <= 0)
+
+  describe "Hanalyze.Design.Quality 非正規 Gamma + 統一エントリ (Phase 23-c)" $ do
+    it "processCapabilityGamma: shape=2 scale=50 で Cp 計算が finite + 正" $ do
+      let cap = Quality.processCapabilityGamma 2.0 50.0 5 400
+      Quality.capCp cap `shouldSatisfy` (> 0)
+      Quality.capCp cap `shouldSatisfy` (not . isNaN)
+    it "processCapabilityGamma: spec の spread を 0 にすると Cp ≤ 0" $ do
+      let cap = Quality.processCapabilityGamma 2.0 50.0 100 100
+      Quality.capCp cap `shouldSatisfy` (<= 0)
+    it "processCapabilityNonNormal: Weibull dispatch が個別関数と一致" $ do
+      let wf = WB.WeibullFit 2.0 100.0 0 10 10 (0, 0, 0)
+          capA = Quality.processCapabilityNonNormal (Quality.NNFWeibull wf) 10 300
+          capB = Quality.processCapabilityWeibull wf 10 300
+      Quality.capCp  capA `shouldBe` Quality.capCp  capB
+      Quality.capCpk capA `shouldBe` Quality.capCpk capB
+    it "processCapabilityNonNormal: LogNormal dispatch が個別関数と一致" $ do
+      let capA = Quality.processCapabilityNonNormal (Quality.NNFLogNormal 0 0.3) 0.5 2.0
+          capB = Quality.processCapabilityLogNormal 0 0.3 0.5 2.0
+      Quality.capCp  capA `shouldBe` Quality.capCp  capB
+    it "processCapabilityNonNormal: Gamma dispatch が個別関数と一致" $ do
+      let capA = Quality.processCapabilityNonNormal (Quality.NNFGamma 2.0 50.0) 5 400
+          capB = Quality.processCapabilityGamma 2.0 50.0 5 400
+      Quality.capCp  capA `shouldBe` Quality.capCp  capB
+
+  describe "Hanalyze.Design.Quality 多変量 Cp (Phase 23-d)" $ do
+    let -- 中心 (5, 10)、 各軸 σ ≈ 1 の擬似 2 変数データ (8 点)
+        dat = LA.fromLists
+          [ [4, 9], [5, 10], [6, 11], [5, 10]
+          , [4, 11], [6, 9], [5, 11], [5, 9] ]
+        specs = [(2, 8), (7, 13)]   -- 各軸 ±3 中心、 spread = 6
+    it "processCapabilityMultivariate: 基本動作、 MCp 正・finite" $ do
+      case Quality.processCapabilityMultivariate dat specs of
+        Left e   -> expectationFailure (show e)
+        Right mc -> do
+          Quality.mcNVars mc `shouldBe` 2
+          Quality.mcMCp mc `shouldSatisfy` (> 0)
+          Quality.mcMCp mc `shouldSatisfy` (not . isNaN)
+          Quality.mcInSpecRate mc `shouldBe` 1.0
+    it "MCpk ≤ MCp (中心オフセット penalty で抑制)" $ do
+      case Quality.processCapabilityMultivariate dat specs of
+        Left e   -> expectationFailure (show e)
+        Right mc -> Quality.mcMCpk mc `shouldSatisfy` (<= Quality.mcMCp mc + 1e-9)
+    it "specs 長さ不一致は Left" $ do
+      case Quality.processCapabilityMultivariate dat [(0, 1)] of
+        Left _   -> pure ()
+        Right _  -> expectationFailure "expected Left"
+    it "n=1 (観測 1 件) は Left" $ do
+      case Quality.processCapabilityMultivariate (LA.fromLists [[1, 2]]) specs of
+        Left _   -> pure ()
+        Right _  -> expectationFailure "expected Left"
+    it "InSpecRate: spec を厳しくすると内包率が下がる" $ do
+      case Quality.processCapabilityMultivariate dat [(4.5, 5.5), (9.5, 10.5)] of
+        Left e   -> expectationFailure (show e)
+        Right mc -> Quality.mcInSpecRate mc `shouldSatisfy` (< 1.0)
diff --git a/test/Hanalyze/Design/SequentialSpec.hs b/test/Hanalyze/Design/SequentialSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Design/SequentialSpec.hs
@@ -0,0 +1,89 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Design.SequentialSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Numeric.LinearAlgebra as LA
+import qualified Hanalyze.Design.Sequential    as Seq
+import qualified Hanalyze.Design.RSM           as RSMd
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Design.Sequential (Phase 7.2)" $ do
+    it "steepestAscent: ascent 方向 = +b / |b|" $ do
+      let sa = Seq.steepestAscent True [0, 0] [3, 4] 1.0 5
+          d = LA.toList (Seq.sarDirection sa)
+      -- (3, 4) の単位ベクトル = (0.6, 0.8)
+      abs (d !! 0 - 0.6) `shouldSatisfy` (< 1e-12)
+      abs (d !! 1 - 0.8) `shouldSatisfy` (< 1e-12)
+      length (Seq.sarStepPoints sa) `shouldBe` 6  -- nSteps + 1
+      Seq.sarStepPoints sa !! 0 `shouldBe` [0, 0]
+      -- step 1 = center + 1 * (0.6, 0.8) = (0.6, 0.8)
+      abs (Seq.sarStepPoints sa !! 1 !! 0 - 0.6) `shouldSatisfy` (< 1e-12)
+
+    it "steepestAscent: descent 方向 = -b / |b|" $ do
+      let sa = Seq.steepestAscent False [10, 10] [1, 0] 0.5 3
+      Seq.sarMaximize sa `shouldBe` False
+      let d = LA.toList (Seq.sarDirection sa)
+      -- (1, 0) の単位ベクトル = (1, 0)、 descent なので -1, 0
+      abs (d !! 0 - (-1.0)) `shouldSatisfy` (< 1e-12)
+      abs (d !! 1) `shouldSatisfy` (< 1e-12)
+      -- step 2 = (10, 10) + 2 * 0.5 * (-1, 0) = (9, 10)
+      abs (Seq.sarStepPoints sa !! 2 !! 0 - 9.0) `shouldSatisfy` (< 1e-12)
+      abs (Seq.sarStepPoints sa !! 2 !! 1 - 10.0) `shouldSatisfy` (< 1e-12)
+
+    it "steepestAscent: |b| = 0 なら方向 0、 全点 center" $ do
+      let sa = Seq.steepestAscent True [5, 5, 5] [0, 0, 0] 1.0 3
+          d = LA.toList (Seq.sarDirection sa)
+      all (== 0) d `shouldBe` True
+      all (== [5,5,5]) (Seq.sarStepPoints sa) `shouldBe` True
+
+    it "steepestAscentFromQuad: QuadFit から第一階係数を抽出" $ do
+      -- 2 因子の簡単な response surface y = 1 + 2*x1 + 3*x2 + ...
+      let xs = [[-1,-1], [1,-1], [-1,1], [1,1], [0,0]]
+          ys = [ 1 + 2*x1 + 3*x2 | [x1, x2] <- xs ]
+          qf = RSMd.fitQuadratic xs ys
+          sa = Seq.steepestAscentFromQuad True [0, 0] qf 0.1 4
+      -- 第一階係数 (2, 3) → 単位ベクトル (2, 3)/sqrt(13)
+      let expectedX = 2 / sqrt 13
+          expectedY = 3 / sqrt 13
+          d = LA.toList (Seq.sarDirection sa)
+      abs (d !! 0 - expectedX) `shouldSatisfy` (< 0.05)  -- numerical fit tolerance
+      abs (d !! 1 - expectedY) `shouldSatisfy` (< 0.05)
+
+    it "sequentialCCD: 新中心 + span で coded / real 両方返す" $ do
+      let res = Seq.sequentialCCD [10, 20] 2.0 2 RSMd.CCF 1
+      Seq.sccdCenter res `shouldBe` [10, 20]
+      Seq.sccdSpan res   `shouldBe` 2.0
+      -- coded は -1, 0, +1 の組合せ (FaceCentered なので axial も ±1)
+      length (Seq.sccdCoded res) `shouldBe` length (Seq.sccdReal res)
+      -- real の各行 = center + span * coded
+      let checkRow (coded, real) =
+            real == zipWith (\c x -> c + 2.0 * x) [10, 20] coded
+      all checkRow (zip (Seq.sccdCoded res) (Seq.sccdReal res)) `shouldBe` True
+
+    it "sequentialCCD: factorial part は ±span 周辺" $ do
+      let res = Seq.sequentialCCD [50, 50] 5.0 2 RSMd.CCF 1
+          realRows = Seq.sccdReal res
+      -- 角点は (45,45), (55,45), (45,55), (55,55) を含むはず
+      [45, 45] `elem` realRows `shouldBe` True
+      [55, 55] `elem` realRows `shouldBe` True
diff --git a/test/Hanalyze/Design/SpaceFillingSpec.hs b/test/Hanalyze/Design/SpaceFillingSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Design/SpaceFillingSpec.hs
@@ -0,0 +1,90 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Design.SpaceFillingSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Numeric.LinearAlgebra as LA
+import qualified Hanalyze.Design.SpaceFilling  as SF
+import qualified System.Random.MWC as MWC
+import qualified System.Random.MWC as MWC
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Design.SpaceFilling (Phase 6.1)" $ do
+    let inUnitBox m =
+          all (\v -> v >= 0 && v < 1) (LA.toList (LA.flatten m))
+
+    it "latinHypercube: n × d 行列、 [0,1) 内、 minDist > 0" $ do
+      gen <- MWC.create
+      des <- SF.latinHypercube 20 3 gen
+      SF.sfdNPoints des `shouldBe` 20
+      SF.sfdNDims   des `shouldBe` 3
+      LA.rows (SF.sfdMatrix des) `shouldBe` 20
+      LA.cols (SF.sfdMatrix des) `shouldBe` 3
+      inUnitBox (SF.sfdMatrix des) `shouldBe` True
+      SF.sfdMinDist des `shouldSatisfy` (> 0)
+      SF.sfdMethod  des `shouldBe` "LHS"
+
+    it "latinHypercube: 各列の stratification (n セルに 1 点ずつ)" $ do
+      gen <- MWC.create
+      des <- SF.latinHypercube 10 2 gen
+      let mat = SF.sfdMatrix des
+          col0 = LA.toList (LA.flatten (mat LA.¿ [0]))
+          cellOf x = floor (x * 10) :: Int
+          cells0 = map cellOf col0
+      length (Data.List.nub cells0) `shouldBe` 10  -- 全 10 セル使用
+
+    it "latinHypercubeMaximin: minDist ≥ plain LHS minDist (LHS から始まる)" $ do
+      gen1 <- MWC.create
+      gen2 <- MWC.create
+      lhs <- SF.latinHypercube 15 3 gen1
+      mxmn <- SF.latinHypercubeMaximin 15 3 200 gen2
+      SF.sfdMethod mxmn `shouldBe` "MaximinLHS"
+      -- maximin は LHS の swap 局所探索なので、 同 seed では LHS と
+      -- 同等以上の minDist を期待。 異 seed でも壊滅的に悪くはならないはず:
+      -- 「max(LHS, MaximinLHS) ≥ LHS の半分」 程度の緩い検証で OK
+      SF.sfdMinDist mxmn `shouldSatisfy` (>= SF.sfdMinDist lhs / 2)
+
+    it "haltonDesign: 決定的 (= 同じ n, d で同じ結果)" $ do
+      let d1 = SF.haltonDesign 10 3
+          d2 = SF.haltonDesign 10 3
+      SF.sfdMatrix d1 `shouldBe` SF.sfdMatrix d2
+      SF.sfdMethod  d1 `shouldBe` "Halton"
+      inUnitBox (SF.sfdMatrix d1) `shouldBe` True
+      SF.sfdMinDist d1 `shouldSatisfy` (> 0)
+
+    it "designMinDistance: 既知 2 点 design で正しい距離" $ do
+      let m = LA.fromLists [[0.0, 0.0], [3.0, 4.0]]  -- 距離 = 5
+      abs (SF.designMinDistance m - 5.0) `shouldSatisfy` (< 1e-12)
+
+    it "designMinDistance: 行数 < 2 で 0" $ do
+      let m0 = (0 LA.>< 0) []
+          m1 = LA.fromLists [[1.0, 2.0]]
+      SF.designMinDistance m0 `shouldBe` 0
+      SF.designMinDistance m1 `shouldBe` 0
+
+    it "n=0 or d=0 で安全に empty design" $ do
+      gen <- MWC.create
+      des0 <- SF.latinHypercube 0 3 gen
+      SF.sfdNPoints des0 `shouldBe` 0
+      let halt0 = SF.haltonDesign 0 3
+      SF.sfdNPoints halt0 `shouldBe` 0
diff --git a/test/Hanalyze/Design/TaguchiSpec.hs b/test/Hanalyze/Design/TaguchiSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Design/TaguchiSpec.hs
@@ -0,0 +1,108 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Design.TaguchiSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Hanalyze.Design.Orthogonal as OA
+import qualified Hanalyze.Design.Taguchi as TG
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Design.Taguchi" $ do
+    it "smaller-the-better SN: lower y → higher η" $ do
+      let etaSmall = TG.snRatio TG.SmallerBetter [0.5, 0.5, 0.5]
+          etaLarge = TG.snRatio TG.SmallerBetter [5.0, 5.0, 5.0]
+      etaSmall `shouldSatisfy` (> etaLarge)
+
+    it "larger-the-better SN: higher y → higher η" $ do
+      let etaLarge = TG.snRatio TG.LargerBetter [10, 10, 10]
+          etaSmall = TG.snRatio TG.LargerBetter [1, 1, 1]
+      etaLarge `shouldSatisfy` (> etaSmall)
+
+    it "nominal-the-best SN: high mean / low var → high η" $ do
+      let highSN = TG.snRatio TG.NominalBest [10, 10.01, 9.99, 10]
+          lowSN  = TG.snRatio TG.NominalBest [1, 4, 7, 10]
+      highSN `shouldSatisfy` (> lowSN)
+
+    it "nominal-target SN: closer to target → higher η" $ do
+      let closer = TG.snRatio (TG.NominalBestTarget 5) [4.9, 5.0, 5.1]
+          farther = TG.snRatio (TG.NominalBestTarget 5) [3, 5, 7]
+      closer `shouldSatisfy` (> farther)
+
+    it "snRatio on empty list is 0" $
+      TG.snRatio TG.SmallerBetter [] `shouldBe` 0
+
+    it "snRatioRows produces same length as input" $ do
+      let yMatrix = [[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]]
+      length (TG.snRatioRows TG.SmallerBetter yMatrix) `shouldBe` 3
+
+    it "analyzeSN gives one FactorEffect per assigned factor" $ do
+      let specs = [ OA.FactorSpec "A" [OA.LText "lo", OA.LText "hi"]
+                  , OA.FactorSpec "B" [OA.LText "lo", OA.LText "hi"]
+                  , OA.FactorSpec "C" [OA.LText "lo", OA.LText "hi"]
+                  ]
+          Right ad = OA.assignFactors OA.l4 specs
+          fes = TG.analyzeSN ad [10, 20, 30, 40]
+      length fes `shouldBe` 3
+      map TG.feFactor fes `shouldBe` ["A", "B", "C"]
+      mapM_ (\fe -> length (TG.feSNByLevel fe) `shouldBe` 2) fes
+
+    it "optimalLevels picks max-SN level per factor" $ do
+      let specs = [ OA.FactorSpec "A" [OA.LText "lo", OA.LText "hi"]
+                  , OA.FactorSpec "B" [OA.LText "lo", OA.LText "hi"]
+                  , OA.FactorSpec "C" [OA.LText "lo", OA.LText "hi"]
+                  ]
+          Right ad = OA.assignFactors OA.l4 specs
+          -- L4 row 1 ("hi" for A,B,C) has SN=100; others 0
+          sns = [0, 0, 0, 100]
+          fes = TG.analyzeSN ad sns
+          opts = TG.optimalLevels fes
+      length opts `shouldBe` 3
+      -- Row 4 has all "hi" by L4 structure (2,2,1) — verify each factor's best
+      mapM_ (\(_, _, eta) -> eta `shouldSatisfy` (>= 0)) opts
+
+  -- ─────────────────────────────────────────────────────────────────────
+
+  describe "Hanalyze.Design.Taguchi extras" $ do
+    it "snRatioWithDetails: SmallerBetter on [1, 2, 3]" $ do
+      let d = TG.snRatioWithDetails TG.SmallerBetter [1.0, 2.0, 3.0]
+      TG.sdN d `shouldBe` 3
+      TG.sdMean d     `shouldSatisfy` (\v -> abs (v - 2.0) < 1e-12)
+      TG.sdVariance d `shouldSatisfy` (\v -> abs (v - 1.0) < 1e-12)
+      -- η = -10 log10((1+4+9)/3) = -10 log10(14/3) ≈ -6.690
+      TG.sdSN d `shouldSatisfy` (\v -> abs (v - (-6.690)) < 1e-2)
+
+    it "factorEffectsTable: contributions sum to 1" $ do
+      let specs = [ OA.FactorSpec "A" [OA.LText "lo", OA.LText "hi"]
+                  , OA.FactorSpec "B" [OA.LNumeric 0,  OA.LNumeric 1]
+                  ]
+      case OA.assignFactors OA.l4 specs of
+        Right ad -> do
+          let sns = [10.0, 12.0, 11.0, 13.0]
+              ext = TG.factorEffectsTable ad sns
+          length ext `shouldBe` 2
+          let totalC = sum (map TG.feeContribution ext)
+          totalC `shouldSatisfy` (\v -> abs (v - 1.0) < 1e-12)
+          all ((>= 0) . TG.feeRange) ext `shouldBe` True
+        Left e -> expectationFailure (show e)
+
+  -- ─────────────────────────────────────────────────────────────────────
diff --git a/test/Hanalyze/Design/WorkflowSpec.hs b/test/Hanalyze/Design/WorkflowSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Design/WorkflowSpec.hs
@@ -0,0 +1,1241 @@
+{-# LANGUAGE CPP               #-}
+{-# LANGUAGE OverloadedStrings #-}
+-- | Phase 78.A/B: DOE ワークフロー (設計オブジェクト + runsheet + designModel)。
+--
+-- 大半は standalone (flag plot-integration off・upstream portable) で build/run できるが、
+-- 一部の診断テスト (tracesOf / MultiVarModel の事後予測帯) は plot 連携層
+-- (Hanalyze.Plot.*) に依存するため @PLOT_INTEGRATION@ CPP で囲む
+-- (= flag plot-integration on のときだけ compile)。
+module Hanalyze.Design.WorkflowSpec (spec) where
+
+import qualified Data.Text as T
+import           Data.List (findIndex, isInfixOf, nub)
+import           Data.Maybe (isJust)
+import           Control.Exception (evaluate, ErrorCall (..))
+import           System.CPUTime (getCPUTime)
+import           System.IO (hPutStrLn, stderr)
+import           System.IO.Temp (withSystemTempDirectory)
+import qualified Numeric.LinearAlgebra as LA
+import qualified Data.Vector as V
+import qualified DataFrame.Internal.Column    as DX
+import qualified DataFrame.Internal.DataFrame  as DX
+import           Test.Hspec
+import           Hanalyze.Design.Workflow
+import           Hanalyze.Fit             (designModel, designModelGP, defaultGP, gpMulti, GPConfig (..), (|->), ranIntercept, ranSlope, designHBMProgram, designModelHBM, DesignHBMFit (..), multiOutput, modelFor)
+#ifdef PLOT_INTEGRATION
+import           Hanalyze.Plot.Bayes      (tracesOf)
+#endif
+import           Hanalyze.Model.HBM       (sampleNames, ModelP)
+import           Hanalyze.Model.Wrappers  (MultiLMModel (..), GPRegModelN (..), GPMethod (..), HyperStrategy (..), defaultHBM, HBMConfig (..))
+#ifdef PLOT_INTEGRATION
+-- ModelFrame/VarRole は MultiVarModel 事後予測帯テスト (guarded) 専用ゆえ一緒に囲む。
+import           Hanalyze.Model.Formula.Frame (ModelFrame (..), VarRole (..))
+import           Hanalyze.Plot.Core       (MultiVarModel (..))
+import           Hanalyze.Plot.ML         ()  -- instance MultiVarModel DesignHBMFit
+#endif
+import           Hanalyze.Model.GP        (GPParams (..), Kernel (..))
+import           Hanalyze.Model.Core      (rSquared1)
+import           Hanalyze.Model.Formula.RFormula (parseRFormula)
+import           Hanalyze.Model.Formula.Mixed (RandomSpec (..))
+import qualified Hanalyze.Design.Custom.Model as CMd
+import qualified Hanalyze.Design.Custom.Factor as CF
+import qualified Hanalyze.Design.Custom.Structured as ST
+import qualified Data.Vector.Storable as VS
+import           Hanalyze.DataIO.Convert (getDoubleVec)
+
+spec :: Spec
+spec = do
+  describe "Design.Workflow (Phase 78.A/B)" $ do
+    let planF = factorialDesign [contFactor "temp" (150, 180), contFactor "time" (10, 20)]
+
+    it "factorialDesign: formula = 全交互作用" $
+      designFormula planF "y" `shouldBe` "y ~ temp * time"
+
+    it "factorialDesign: runsheet は 2^k row・uncoded 実値・run 番号列" $ do
+      let rs = designTable planF
+      lookup "temp" rs `shouldBe` Just [150, 150, 180, 180]
+      lookup "time" rs `shouldBe` Just [10, 20, 10, 20]
+      lookup "run"  rs `shouldBe` Just [1, 2, 3, 4]
+      designFactorNames planF `shouldBe` ["temp", "time"]
+
+    it "centralCompositeDesign: formula = 2 次モデル (交互作用 + 平方項)" $
+      designFormula (centralCompositeDesign [contFactor "x1" (150, 180), contFactor "x2" (10, 20)]) "y"
+        `shouldBe` "y ~ x1 + x2 + x1:x2 + I(x1^2) + I(x2^2)"
+
+    it "centralCompositeDesign: CCD (factorial + 軸点 + 中心点) の run 数 > 2^k" $ do
+      let rs = designTable (centralCompositeDesign [contFactor "x1" (0, 1), contFactor "x2" (0, 1)])
+      length (maybe [] id (lookup "x1" rs)) `shouldSatisfy` (> 4)   -- 2^2=4 より多い
+
+    it "designModel: 真モデル由来データを飽和当てはめ (R^2 ~ 1)" $ do
+      let rs    = designTable planF
+          temps = maybe [] id (lookup "temp" rs)
+          times = maybe [] id (lookup "time" rs)
+          ys    = zipWith (\t d -> 5 + 2 * t + 3 * d + 0.01 * t * d) temps times
+          m     = (("y", ys) : rs) |-> designModel planF "y"
+      -- 2^2 factorial × 交互作用モデル = 飽和 (4 param / 4 点) → 完全再現
+      rSquared1 (mlmResult m) `shouldSatisfy` (> 0.999)
+
+    -- Phase 78.G-e: FRR/HBM 化 (v1 = GP/RFF)。designModel の非 LM 版。
+    it "designModelGP: 連続因子で GP を当てはめ・予測子名を plan から保持" $ do
+      let rs    = designTable planF
+          temps = maybe [] id (lookup "temp" rs)
+          times = maybe [] id (lookup "time" rs)
+          ys    = zipWith (\t d -> 5 + 2 * t + 3 * d) temps times
+          m     = (("y", ys) : rs) |-> designModelGP defaultGP planF "y"
+      gprnNames m `shouldBe` ["temp", "time"]
+
+    it "designModelGP: カテゴリ因子混在は error (連続専用)" $ do
+      let planMix = factorialDesign [contFactor "temp" (150, 180), catFactor "cat" ["a", "b"]]
+          df      = [("y", [1, 2, 3, 4]), ("temp", [150, 150, 180, 180])] :: [(T.Text, [Double])]
+      evaluate (gprnNames (df |-> designModelGP defaultGP planMix "y")) `shouldThrow` anyErrorCall
+
+    -- GP/GpRff 象限は帯 (事後予測分散) を出す。 Krr/KrrRff (mean-only) は帯なし。
+    it "designModelGP: GpRff 象限は帯 (予測分散 Just) を出す = RFF 開通" $ do
+      let rs     = designTable planF
+          temps  = maybe [] id (lookup "temp" rs)
+          times  = maybe [] id (lookup "time" rs)
+          ys     = zipWith (\t d -> 5 + 2 * t + 3 * d) temps times
+          cfgRff = GPConfig RBF (GpRff 128 7) AutoMarginalLik
+          mRff   = (("y", ys) : rs) |-> designModelGP cfgRff planF "y"
+      snd (gprnPredict mRff (LA.fromLists [[165, 15]])) `shouldSatisfy` isJust
+
+    it "designModelGP: Krr 象限は帯なし (mean-only・var=Nothing)" $ do
+      let rs     = designTable planF
+          temps  = maybe [] id (lookup "temp" rs)
+          times  = maybe [] id (lookup "time" rs)
+          ys     = zipWith (\t d -> 5 + 2 * t + 3 * d) temps times
+          cfgKrr = GPConfig RBF Krr AutoMarginalLik
+          mKrr   = (("y", ys) : rs) |-> designModelGP cfgKrr planF "y"
+      snd (gprnPredict mKrr (LA.fromLists [[165, 15]])) `shouldBe` Nothing
+
+    -- Phase 78.G-e 回帰: GP のハイパラ自動調整 (AutoMarginalLik) が退化しないこと。
+    -- かつて LBFGS の初回ステップが未スケール最急降下方向に α=1 を打ち、勾配の
+    -- 大きい GP 周辺尤度で巨大オーバーシュートして ℓ が真の峰 (≈105) を越え 1e12 に
+    -- 飛び、予測が水平化していた (spread=0)。LBFGS.hs の初回 α₀=min(1,1/‖g‖₁) 修正で
+    -- sklearn 一致の大域最適 (ℓ≈105・LML=-35.77) に到達する。ここでは既知の 2 次応答で
+    -- 予測レンジ (spread) が真値に一致し ℓ が有限であることを担保する。
+    it "GP (AutoMarginalLik) が退化せず既知応答を当てる (LBFGS 初回ステップ回帰)" $ do
+      let respf t d = 50 + 0.6 * t - 0.02 * (t - 165) * (t - 165) + 1.5 * d
+          noise  = cycle [0.6, -0.5, 0.4, -0.3, 0.2, -0.1]
+          planG  = centralCompositeDesign [contFactor "temp" (150, 180), contFactor "time" (10, 20)]
+          rs     = designTable planG
+          temps  = maybe [] id (lookup "temp" rs)
+          times  = maybe [] id (lookup "time" rs)
+          ys     = zipWith3 (\t d e -> respf t d + e) temps times noise
+          dat    = ("y", ys) : rs
+          predAt m t d = head (fst (gprnPredict m (LA.fromLists [[t, d]])))
+          trueSpread   = respf 180 15 - respf 150 15   -- = 18
+          spreadOf m   = predAt m 180 15 - predAt m 150 15
+          -- (a) 高レベル DOE 経路 (designModelGP)  (b) 汎用 gpMulti (uncoded 素通し)
+          mDoe = dat |-> designModelGP defaultGP planG "y"
+          mGp  = dat |-> gpMulti defaultGP ["temp", "time"] "y"
+      -- 退化なら spread≈0。真値 18 に十分近い (±2) こと・ℓ が有限で妥当 (<1e6)。
+      spreadOf mDoe `shouldSatisfy` (\s -> abs (s - trueSpread) < 2)
+      spreadOf mGp  `shouldSatisfy` (\s -> abs (s - trueSpread) < 2)
+      gpLengthScale (gprnParams mGp) `shouldSatisfy` (< 1e6)
+
+    it "designModel: 同じ plan を別データ (sim/実物想定) に使い回せる" $ do
+      let rs    = designTable planF
+          temps = maybe [] id (lookup "temp" rs)
+          times = maybe [] id (lookup "time" rs)
+          ysA   = zipWith (\t d -> 1 + t + d) temps times          -- 「sim」
+          ysB   = zipWith (\t d -> 9 + 2 * t - d) temps times      -- 「実物」
+          mA    = (("y", ysA) : rs) |-> designModel planF "y"
+          mB    = (("y", ysB) : rs) |-> designModel planF "y"
+      rSquared1 (mlmResult mA) `shouldSatisfy` (> 0.99)
+      rSquared1 (mlmResult mB) `shouldSatisfy` (> 0.99)
+
+    -- Phase 78.G-f: 型付きランダム効果項 (designModelHBM 用の smart constructor)。
+    it "ranIntercept は (1|g) 相当の RandomSpec" $
+      ranIntercept "lot" `shouldBe` RandomSpec True [] "lot"
+    it "ranSlope は intercept 込みの (1+s|g)" $
+      ranSlope ["temp"] "lot" `shouldBe` RandomSpec True ["temp"] "lot"
+
+    it "designHBMProgram は betaNames と群効果を含む ModelP を組む" $ do
+      let x   = [[1,0],[1,1],[1,0],[1,1]]      -- intercept + temp
+          bn  = ["b0","temp"]
+          res = [([0,0,1,1], 2, [])]           -- lot: 行0,1=群0 / 行2,3=群1 (切片のみ)
+          ys  = [1.0, 2.0, 1.1, 2.2]
+          prog :: ModelP ()
+          prog = designHBMProgram x bn res ys
+          nms  = sampleNames prog
+      nms `shouldContain` ["b0"]
+      nms `shouldContain` ["temp"]
+      nms `shouldContain` ["u_g0_0"]
+
+    -- Phase 78.G-f: designModelHBM 本体 (df → designHBMProgram → hbm → 事後 draw)。
+    -- temp∈{-1,1}・lot∈{A,B} の 2 群、 lot 差 (切片ずれ) を仕込んだ合成データで
+    -- 固定効果係数 temp の事後平均が真値 3 に近く収束することを確認する。
+    -- ('|->' は失敗を error に落とすので、 ここでは happy-path のみ検証する。)
+    it "designModelHBM は lot 群込みで固定効果を回復する" $ do
+      let mean0 xs = sum xs / fromIntegral (length xs)
+          -- ★designMatrixF (R formula 経路) は列ラベルを合成パラメータ込みの項全体
+          --   (例 "_p1 * temp") で付ける (Wrappers.hs の additiveFormula と同じ命名規約)。
+          --   ここでは変数名の suffix 一致で該当列を探す。
+          drawsFor nm m = case findIndex (T.isSuffixOf nm) (dhfBetaNames m) of
+            Just i  -> map (!! i) (dhfBetaDraws m)
+            Nothing -> []
+          -- y = 2 + 3*temp + lotShift + small noise (固定値・シード不要)。
+          temps  = [-1, 1, -1, 1, -1, 1, -1, 1, -1, 1, -1, 1] :: [Double]
+          lots   = ["A","A","A","A","A","A","B","B","B","B","B","B"] :: [T.Text]
+          noise  = [0.05, -0.03, 0.02, -0.04, 0.01, -0.02, 0.03, -0.01, 0.04, -0.05, 0.02, -0.03]
+          lotShift l = if l == "A" then (-1.0) else 1.0
+          ys     = [ 2 + 3 * t + lotShift l + e
+                   | (t, l, e) <- zip3 temps lots noise ]
+          mkFrameHBM = DX.insertColumn "lot"  (DX.fromList lots)
+                     $ DX.insertColumn "temp" (DX.fromList temps)
+                     $ DX.insertColumn "y"    (DX.fromList ys)
+                     $ DX.empty
+          plan = factorialDesign [contFactor "temp" (-1, 1)]
+          m    = mkFrameHBM |-> designModelHBM defaultHBM plan [ranIntercept "lot"] "y"
+          bTemp = mean0 (drawsFor "temp" m)
+      bTemp `shouldSatisfy` (\b -> abs (b - 3) < 1.0)
+
+#ifdef PLOT_INTEGRATION
+    -- Phase 78.J: designModelHBM の学習済 HBM を dhfModel で露出し、 診断抽出子
+    -- (tracesOf / dagOf 等) に渡せる (DesignHBMFit から診断が出せる)。
+    -- ※ tracesOf は plot 連携層 (Hanalyze.Plot.Bayes)・plot-integration 限定。
+    it "designModelHBM: dhfModel で診断 (tracesOf) が出せる" $ do
+      let temps  = [-1, 1, -1, 1, -1, 1, -1, 1] :: [Double]
+          lots   = ["A","A","A","A","B","B","B","B"] :: [T.Text]
+          ys     = [ 2 + 3 * t + (if l == "A" then -1 else 1) | (t, l) <- zip temps lots ]
+          df     = DX.insertColumn "lot"  (DX.fromList lots)
+                 $ DX.insertColumn "temp" (DX.fromList temps)
+                 $ DX.insertColumn "y"    (DX.fromList ys)
+                 $ DX.empty
+          plan   = factorialDesign [contFactor "temp" (-1, 1)]
+          m      = df |-> designModelHBM defaultHBM plan [ranIntercept "lot"] "y"
+      length (tracesOf (dhfModel m)) `shouldSatisfy` (> 0)   -- param ごとの trace が出る
+#endif
+
+    -- Phase 78.G-f 回帰: NA 行 drop と群 idx の行ずれ (code review 指摘)。
+    -- 'modelFrame' (DropRows) は formula 関与列 (temp/y) に NA を含む行を落として
+    -- designX/ys を作るが、 群 idx (prepRE) が raw df (NA 除去前・行数不変) から
+    -- 作られていると、 designX/ys (post-drop・行数 -k) と 1:1 対応しなくなる。
+    -- lot を A,A,B,B の 2 行ブロック周期にし、 4 行 (temp) を NA にすると
+    -- 「raw 先頭 n 行を機械的に使う」 バグ実装では postDrop の並びに対して行ずれが
+    -- 生じ、 複数箇所で A/B が入れ替わって群対応が壊れる (単純な A↔B 一括入替では
+    -- 吸収できない不整合・実測: バグ版 bTemp ≈ 1.96 = |b-3| ≈ 1.04 > 1.0 で検出可)。
+    -- lot 差 (±50、 通常の ±1 より大きめ) を仕込むことで群対応の誤りが固定効果
+    -- temp の事後平均に十分な偏りとして現れるようにしてある。
+    -- fix 後は "同じ post-drop 行集合" から群 idx を作るので、 NA 行があっても
+    -- 固定効果 temp は真値 3 近辺へ回復する。
+    it "designModelHBM: NA 行があっても群 idx が post-drop 行と正しく対応する (行ずれ回帰)" $ do
+      let mean0 xs = sum xs / fromIntegral (length xs)
+          drawsFor nm m = case findIndex (T.isSuffixOf nm) (dhfBetaNames m) of
+            Just i  -> map (!! i) (dhfBetaDraws m)
+            Nothing -> []
+          -- lot は 2 行ブロック周期 (A,A,B,B の繰り返し) — 複数 NA drop で生じる
+          -- 「先頭 n 行 raw vs post-drop」 のオフセットが単純な A/B 一括入替に
+          -- 縮退しない配置 (境界が複数あるので relabeling で吸収できない)。
+          n        = 16 :: Int
+          lots     = take n (cycle ["A", "A", "B", "B"]) :: [T.Text]
+          tempsRaw = take n (cycle [-1, 1]) :: [Double]
+          noise    = take n (cycle [0.05, -0.03, 0.02, -0.04, 0.01, -0.02, 0.03, -0.01])
+          lotShift l = if l == "A" then (-50.0) else 50.0
+          ysRaw    = [ 2 + 3 * t + lotShift l + e
+                     | (t, l, e) <- zip3 tempsRaw lots noise ]
+          -- 行 0,2,4,6 (temp) を NA にする — dropMissingRows で 4 行落ちる。
+          naIdxs   = [0, 2, 4, 6] :: [Int]
+          tempsNA  = [ if i `elem` naIdxs then Nothing else Just t
+                     | (i, t) <- zip [0 :: Int ..] tempsRaw ] :: [Maybe Double]
+          mkFrameHBM = DX.insertColumn "lot"  (DX.fromList lots)
+                     $ DX.insertColumn "temp" (DX.fromList tempsNA)
+                     $ DX.insertColumn "y"    (DX.fromList ysRaw)
+                     $ DX.empty
+          plan  = factorialDesign [contFactor "temp" (-1, 1)]
+          m     = mkFrameHBM |-> designModelHBM defaultHBM plan [ranIntercept "lot"] "y"
+          bTemp = mean0 (drawsFor "temp" m)
+      bTemp `shouldSatisfy` (\b -> abs (b - 3) < 1.0)
+
+    -- Phase 78.G-f2: 相関ランダム傾き (1+temp|lot)。 lot ごとに temp の傾きが
+    -- 異なるデータ (A の傾き 5・B の傾き 1・平均 3) を作る。 変量傾きを捉えられれば
+    -- 残差 σ は仕込んだ noise 水準 (~0.05) まで縮む。 切片のみ (ranIntercept) では
+    -- 傾き差 (±2·temp) を残差に押し込むため σ が ~2 に膨らむ = 判別可能。
+    -- 固定効果 temp は両群平均の 3 近辺へ回復する。
+    -- Phase 80.2b で相関 RE を非中心化 (a) vecIR 化 (funnel 撤去) したため再有効化。
+    -- 非中心化パラメタ化で funnel が消え defaultHBM でも収束する。
+    it "designModelHBM: 相関ランダム傾き (ranSlope) は群別の傾き差を捉え σ を縮める" $ do
+      let mean0 xs = sum xs / fromIntegral (length xs)
+          drawsFor nm mm = case findIndex (T.isSuffixOf nm) (dhfBetaNames mm) of
+            Just i  -> map (!! i) (dhfBetaDraws mm)
+            Nothing -> []
+          temps  = [-1, 1, -1, 1, -1, 1, -1, 1, -1, 1, -1, 1] :: [Double]
+          lots   = ["A","A","A","A","A","A","B","B","B","B","B","B"] :: [T.Text]
+          noise  = [0.05, -0.03, 0.02, -0.04, 0.01, -0.02, 0.03, -0.01, 0.04, -0.05, 0.02, -0.03]
+          -- lot A: y = 2 + 5·temp、 lot B: y = 2 + 1·temp (傾き差を仕込む)。
+          slopeOf l = if l == "A" then 5.0 else 1.0
+          ys     = [ 2 + slopeOf l * t + e
+                   | (t, l, e) <- zip3 temps lots noise ]
+          mkFrameHBM = DX.insertColumn "lot"  (DX.fromList lots)
+                     $ DX.insertColumn "temp" (DX.fromList temps)
+                     $ DX.insertColumn "y"    (DX.fromList ys)
+                     $ DX.empty
+          plan   = factorialDesign [contFactor "temp" (-1, 1)]
+          m      = mkFrameHBM |-> designModelHBM defaultHBM plan [ranSlope ["temp"] "lot"] "y"
+          bTemp  = mean0 (drawsFor "temp" m)
+          sigBar = mean0 (dhfSigmaDraws m)
+      bTemp  `shouldSatisfy` (\b -> abs (b - 3) < 1.0)   -- 平均傾き 3 を回復
+      sigBar `shouldSatisfy` (< 1.0)                     -- 変量傾きで σ が縮む (切片のみなら ~2)
+
+    -- ===== PROFILE: ボトルネック切り分け (壁時計 + iter スケーリング) =====
+    -- 同一データ・同一軽量 config で「相関 (per-obs observe = full ad 疑い) vs
+    -- 切片のみ (compiled observeLMR)」の壁時計を測り、 さらに相関を 2 倍 iter で
+    -- 測って線形性 (per-eval 支配 vs tree-depth 増大) を見る。
+    let mkFrameP slopeOf =
+          let temps  = [-1, 1, -1, 1, -1, 1, -1, 1, -1, 1, -1, 1] :: [Double]
+              lots   = ["A","A","A","A","A","A","B","B","B","B","B","B"] :: [T.Text]
+              noise  = [0.05,-0.03,0.02,-0.04,0.01,-0.02,0.03,-0.01,0.04,-0.05,0.02,-0.03]
+              ys     = [ 2 + slopeOf l * t + e | (t,l,e) <- zip3 temps lots noise ]
+          in DX.insertColumn "lot"  (DX.fromList lots)
+           $ DX.insertColumn "temp" (DX.fromList temps)
+           $ DX.insertColumn "y"    (DX.fromList ys) DX.empty
+        planP    = factorialDesign [contFactor "temp" (-1, 1)]
+        tinyCfg n = defaultHBM { hbmChains = 1, hbmWarmup = n, hbmSamples = n }
+        timeFit label fit = do
+          t0 <- getCPUTime
+          s  <- evaluate (sum (dhfSigmaDraws fit))
+          t1 <- getCPUTime
+          hPutStrLn stderr ("[PROFILE] " <> label <> ": "
+            <> show (fromIntegral (t1 - t0) / 1e12 :: Double) <> " s  (Σσ=" <> show s <> ")")
+    -- Phase 79 用の perf 診断 (pending)。 手動計測時のみ xit→it に戻して回す。
+    xit "PROFILE 切片のみ 50iter" $ do
+      timeFit "intercept  1x50"
+        (mkFrameP (\l -> if l=="A" then 3 else 3)
+           |-> designModelHBM (tinyCfg 50) planP [ranIntercept "lot"] "y")
+      True `shouldBe` True
+    xit "PROFILE 相関 50iter" $ do
+      timeFit "correlated 1x50"
+        (mkFrameP (\l -> if l=="A" then 5 else 1)
+           |-> designModelHBM (tinyCfg 50) planP [ranSlope ["temp"] "lot"] "y")
+      True `shouldBe` True
+    xit "PROFILE 相関 100iter" $ do
+      timeFit "correlated 1x100"
+        (mkFrameP (\l -> if l=="A" then 5 else 1)
+           |-> designModelHBM (tinyCfg 100) planP [ranSlope ["temp"] "lot"] "y")
+      True `shouldBe` True
+
+#ifdef PLOT_INTEGRATION
+    -- Phase 78.G-f Task 4: profiler/contour が designModelHBM に載る事後予測帯。
+    -- designMatrixF (dhfFormula m) ef を直接叩き、 fit 時と同じ列順の設計行列を作る
+    -- ( evalFrameAt 相当の helper は無いので eval ModelFrame を手組みする)。
+    -- ★mfParams (合成パラメータ名 "_p0"/"_p1"…) は formula 内部表現なので手で推測せず、
+    --   訓練済 'dhfFrame' (= mvFrame m) を土台に mfRoles/mfNRows だけ差し替える
+    --   (本番の Core.hs evalFrame と同じ据え置き方)。
+    -- ※ MultiVarModel (mvFrame/mvEvalFrame) は plot 連携層・plot-integration 限定。
+    it "MultiVarModel DesignHBMFit は事後予測帯を返す" $ do
+      let temps  = [-1, 1, -1, 1, -1, 1, -1, 1, -1, 1, -1, 1] :: [Double]
+          lots   = ["A","A","A","A","A","A","B","B","B","B","B","B"] :: [T.Text]
+          noise  = [0.05, -0.03, 0.02, -0.04, 0.01, -0.02, 0.03, -0.01, 0.04, -0.05, 0.02, -0.03]
+          lotShift l = if l == "A" then (-1.0) else 1.0
+          ys     = [ 2 + 3 * t + lotShift l + e
+                   | (t, l, e) <- zip3 temps lots noise ]
+          mkFrameHBM = DX.insertColumn "lot"  (DX.fromList lots)
+                     $ DX.insertColumn "temp" (DX.fromList temps)
+                     $ DX.insertColumn "y"    (DX.fromList ys)
+                     $ DX.empty
+          plan = factorialDesign [contFactor "temp" (-1, 1)]
+          m    = mkFrameHBM |-> designModelHBM defaultHBM plan [ranIntercept "lot"] "y"
+          ef   = (mvFrame m)
+                   { mfRoles  = [ ("y",    RoleResponse (V.fromList [0, 0, 0]))
+                                , ("temp", RoleContinuous (V.fromList [-1, 0, 1]))
+                                ]
+                   , mfNRows  = 3
+                   }
+          (mu, band) = mvEvalFrame m 0.95 ef
+      length mu `shouldBe` 3
+      band `shouldSatisfy` isJust
+      -- 中心 μ は temp とともに増加 (真の傾き ≈ 3)。
+      (last mu - head mu) `shouldSatisfy` (> 3)
+#endif
+
+    -- Phase 78.G-f Task 5: multiOutput が designModelHBM とも対称に使えること
+    -- (LM/GP と同じ「カレー化 spec (Text -> spec) を渡せば複数応答へ一括適用」 が
+    -- 効くか) の確認。 designModelHBM :: HBMConfig -> Design -> [RandomSpec] -> Text -> spec
+    -- は y が最終引数なので、 部分適用した @designModelHBM defaultHBM plan [ranIntercept "lot"]@
+    -- は @Text -> DesignModelHBMSpec@ となり multiOutput にそのまま渡せる。
+    it "multiOutput で designModelHBM を複数応答に適用できる" $ do
+      let temps  = [-1, 1, -1, 1, -1, 1, -1, 1, -1, 1, -1, 1] :: [Double]
+          lots   = ["A","A","A","A","A","A","B","B","B","B","B","B"] :: [T.Text]
+          noise  = [0.05, -0.03, 0.02, -0.04, 0.01, -0.02, 0.03, -0.01, 0.04, -0.05, 0.02, -0.03]
+          lotShift l = if l == "A" then (-1.0) else 1.0
+          -- y1: 傾き 3、 y2: 傾き -2 (符号違いの別応答 → 2 fit が実際に異なることの傍証)。
+          y1s    = [ 2 + 3    * t + lotShift l + e | (t, l, e) <- zip3 temps lots noise ]
+          y2s    = [ 5 + (-2) * t + lotShift l + e | (t, l, e) <- zip3 temps lots noise ]
+          mkFrameHBM2 = DX.insertColumn "lot"  (DX.fromList lots)
+                      $ DX.insertColumn "temp" (DX.fromList temps)
+                      $ DX.insertColumn "y1"   (DX.fromList y1s)
+                      $ DX.insertColumn "y2"   (DX.fromList y2s)
+                      $ DX.empty
+          plan = factorialDesign [contFactor "temp" (-1, 1)]
+          ms   = mkFrameHBM2 |-> multiOutput ["y1", "y2"] (designModelHBM defaultHBM plan [ranIntercept "lot"])
+      map fst ms `shouldBe` ["y1", "y2"]
+
+    -- Phase 78.J: modelFor で multiOutput 結果 [(Text,m)] から応答名でモデルを取り出せる
+    -- (contourOf / surfaceOf に snd (head …) でなく応答名で渡すための selector)。
+    it "modelFor: 応答名でモデルを取り出す (無い名前は error)" $ do
+      modelFor "b" [("a", 1), ("b", 2)] `shouldBe` (2 :: Int)
+      evaluate (modelFor "z" [("a", 1)] :: Int) `shouldThrow` anyErrorCall
+
+  -- Phase 78.G-d: 応答曲面 解析 (自然単位で報告)。 coded 空間に既知の最適点を仕込んだ
+  -- 応答から、 停留点が **自然単位** で復元されること・canonical で性質判定されること・
+  -- steepest ascent 経路が自然単位で出ることを検証する。
+  describe "Design.Workflow RSM 解析 (Phase 78.G-d)" $ do
+    let planR = centralCompositeDesign [contFactor "x1" (150, 180), contFactor "x2" (10, 20)]
+        -- runsheet (自然単位) → coded へ逆変換 (c = (x-center)/half)。
+        codedOf = do
+          let rs    = designTable planR
+              temps = maybe [] id (lookup "x1" rs)
+              times = maybe [] id (lookup "x2" rs)
+              c1s   = map (\t -> (t - 165) / 15) temps
+              c2s   = map (\d -> (d - 15)  / 5)  times
+          (c1s, c2s)
+        approx tol a b = abs (a - b) < tol
+        stat name rep = maybe (1/0) id (lookup name (rsmStationary rep))
+
+    it "rsmAnalysis: coded 原点が最大の曲面 → 停留点は自然単位の中心・RMaximum" $ do
+      let (c1s, c2s) = codedOf
+          ys  = zipWith (\a b -> 100 - (a*a + b*b)) c1s c2s   -- coded max at (0,0)
+          rep = rsmAnalysis planR ys
+      rsmNature rep `shouldBe` RMaximum
+      rsmInRegion rep `shouldBe` True
+      stat "x1" rep `shouldSatisfy` approx 1e-6 165   -- 中心 (自然単位)
+      stat "x2" rep `shouldSatisfy` approx 1e-6 15
+      rsmPredicted rep `shouldSatisfy` approx 1e-6 100
+      rsmR2 rep `shouldSatisfy` (> 0.999)
+      all ((< 0) . fst) (rsmCanonical rep) `shouldBe` True   -- 全固有値 < 0
+
+    it "rsmAnalysis: coded (0.5,-0.5) が最大 → 停留点が対応する自然単位へ decode" $ do
+      let (c1s, c2s) = codedOf
+          ys  = zipWith (\a b -> 50 - ((a-0.5)^(2::Int) + (b+0.5)^(2::Int))) c1s c2s
+          rep = rsmAnalysis planR ys
+      rsmNature rep `shouldBe` RMaximum
+      stat "x1" rep `shouldSatisfy` approx 1e-6 172.5   -- 165 + 0.5*15
+      stat "x2" rep `shouldSatisfy` approx 1e-6 12.5    -- 15  + (-0.5)*5
+
+    it "rsmAnalysis: c1² - c2² は鞍点 (固有値 混在) → RSaddle" $ do
+      let (c1s, c2s) = codedOf
+          ys  = zipWith (\a b -> a*a - b*b) c1s c2s
+          rep = rsmAnalysis planR ys
+      rsmNature rep `shouldBe` RSaddle
+      any ((> 0) . fst) (rsmCanonical rep) `shouldBe` True
+      any ((< 0) . fst) (rsmCanonical rep) `shouldBe` True
+
+    it "steepestAscentNatural: 線形上昇 (coded x1 方向) の経路が自然単位で出る" $ do
+      let (c1s, c2s) = codedOf
+          ys   = zipWith (\a _ -> a) c1s c2s          -- coded 勾配 = (1, 0)
+          path = steepestAscentNatural True planR ys 0.5 2
+          x1s  = map (maybe (1/0) id . lookup "x1") path
+          x2s  = map (maybe (1/0) id . lookup "x2") path
+      length path `shouldBe` 3                          -- nSteps + 1
+      and (zipWith (approx 1e-6) x1s [165, 172.5, 180]) `shouldBe` True
+      all (approx 1e-6 15) x2s `shouldBe` True          -- x2 は動かない
+
+    it "rsmAnalysis: カテゴリ因子を含む設計は error (連続専用)" $ do
+      let planCat = factorialDesign [contFactor "x1" (0,1), catFactor "g" ["a","b"]]
+      evaluate (rsmNature (rsmAnalysis planCat [1,2,3,4])) `shouldThrow` anyException
+
+  -- Phase 78.G: Box-Behnken 応答曲面計画 (k = 3,4,5・±α なし・2 次モデル)。
+  describe "Design.Workflow Box-Behnken (Phase 78.G)" $ do
+    let planBB = boxBehnkenDesign [contFactor "t" (150,180), contFactor "p" (1,3), contFactor "c" (5,15)]
+
+    it "boxBehnkenDesign: k=3 は 12 corner + 中心点 (= CCD より少なめ・±α なし)" $ do
+      let rs = designTable planBB
+          n  = length (maybe [] id (lookup "t" rs))
+      n `shouldBe` (12 + 3)                                        -- corner 12 + nC=k=3
+
+    it "boxBehnkenDesign: 各因子は -1/0/+1 の 3 水準に収まる (±α を持たない)" $ do
+      let rs   = designTable planBB
+          -- t: center 165・half 15 → 実値は 150/165/180 のみ (coded -1/0/+1)
+          tvals = maybe [] id (lookup "t" rs)
+      -- CCD の軸点 (143.8.. 等) のような立方体外の値が無いことを確認
+      all (`elem` [150,165,180]) tvals `shouldBe` True
+
+    it "boxBehnkenDesign: RSM ゆえ formula = 2 次モデル" $
+      designFormula planBB "y" `shouldBe`
+        "y ~ t + p + c + t:p + t:c + p:c + I(t^2) + I(p^2) + I(c^2)"
+
+    it "boxBehnkenDesign: designModel で 2 次モデルを当てられる (真モデル再現)" $ do
+      let rs    = designTable planBB
+          tv    = maybe [] id (lookup "t" rs)
+          pv    = maybe [] id (lookup "p" rs)
+          cv    = maybe [] id (lookup "c" rs)
+          ys    = zipWith3 (\t p c -> 10 + 0.1*t + 2*p - 0.5*c + 0.01*t*p) tv pv cv
+          m     = (("y", ys) : rs) |-> designModel planBB "y"
+      rSquared1 (mlmResult m) `shouldSatisfy` (> 0.99)
+
+  -- Phase 78.G: 一部実施要因 (fractional factorial・run 削減)。
+  describe "Design.Workflow fractional (Phase 78.G)" $ do
+    let specs7 = [ contFactor (T.pack [c]) (0, 1) | c <- "abcdefg" ]   -- 7 因子
+
+    -- ★カタログ自己検証: 各 generator の解像度が表のラベルと一致するか (誤り混入ガード)。
+    it "fractionalCatalog: 全エントリの generator 解像度がラベルと一致 (fracResolution 照合)" $
+      [ (k, n, resNum r, fracResolution k gens)
+      | k <- [3..16], (n, r, gens) <- fractionalCatalog k
+      , resNum r /= fracResolution k gens ]
+        `shouldBe` []
+
+    it "fractionalCatalog: run 数 = 2^(k - p) (p = generator 数)" $
+      [ (k, n, 2 ^ (k - length gens))
+      | k <- [3..16], (n, _, gens) <- fractionalCatalog k
+      , n /= 2 ^ (k - length gens) ]
+        `shouldBe` []
+
+    -- Phase 78.G-c k>7 拡張: 16/32-run 追加設計 (NIST 標準表)。
+    let mkN n = [ contFactor (T.pack [c]) (0,1) | c <- take n ['a' ..] ]
+
+    it "fractionalCatalog: k=8 は 16-run Res IV を持つ (NIST 2^(8-4))" $
+      [ (n, resNum r) | (n, r, _) <- fractionalCatalog 8, n == 16 ]
+        `shouldBe` [(16, 4)]
+
+    it "fractionalDesign: 8 因子 ResIV は 16 run" $
+      length (dsCoded (fractionalDesign (mkN 8) ResIV)) `shouldBe` 16
+
+    it "fractionalDesign: 9 因子 ResIV は 32 run (16-run では ResIII しか出ない)" $
+      length (dsCoded (fractionalDesign (mkN 9) ResIV)) `shouldBe` 32
+
+    it "fractionalDesign: 11 因子 ResIV は 32 run" $
+      length (dsCoded (fractionalDesign (mkN 11) ResIV)) `shouldBe` 32
+
+    it "fractionalDesign: 15 因子 (16-run 飽和 ResIII) は 16 run" $
+      length (dsCoded (fractionalDesign (mkN 15) ResIII)) `shouldBe` 16
+
+    it "fractionalDesign: 追加設計も主効果が直交 (k=9 ResIV 32-run・k=15 16-run)" $ do
+      let orthogonal plan =
+            let k    = length (dsFactors plan)
+                cols = [ [ row !! j | row <- dsCoded plan ] | j <- [0 .. k - 1] ]
+                dot a b = sum (zipWith (*) a b)
+            in all (\c -> abs (sum c) < 1e-9) cols
+               && and [ abs (dot (cols!!i) (cols!!j)) < 1e-9
+                      | i <- [0..k-1], j <- [i+1..k-1] ]
+      orthogonal (fractionalDesign (mkN 9) ResIV)  `shouldBe` True
+      orthogonal (fractionalDesign (mkN 15) ResIII) `shouldBe` True
+
+    it "fractionalDesign: 7 因子 ResIII は 8 run (完全要因 128 から大幅削減)" $ do
+      let plan = fractionalDesign specs7 ResIII
+          rs   = designTable plan
+      length (maybe [] id (lookup "a" rs)) `shouldBe` 8
+
+    it "fractionalDesign: 解像度指定は「以上」で最小 run を選ぶ (5 因子 ResV = 16 run)" $ do
+      let plan = fractionalDesign [ contFactor (T.pack [c]) (0,1) | c <- "abcde" ] ResV
+      length (dsCoded plan) `shouldBe` 16
+
+    it "fractionalDesign: formula は主効果のみ (交互作用なし)" $
+      designFormula (fractionalDesign specs7 ResIII) "y"
+        `shouldBe` "y ~ a + b + c + d + e + f + g"
+
+    it "fractionalDesign: 主効果は直交 (列同士の内積 = 0)・各列は平衡 (和 = 0)" $ do
+      let plan = fractionalDesign specs7 ResIII
+          cols = [ [ row !! j | row <- dsCoded plan ] | j <- [0 .. 6] ]
+          dot a b = sum (zipWith (*) a b)
+      all (\c -> abs (sum c) < 1e-9) cols `shouldBe` True                 -- 平衡
+      and [ abs (dot (cols!!i) (cols!!j)) < 1e-9
+          | i <- [0..6], j <- [i+1..6] ] `shouldBe` True                 -- 直交
+
+    it "fractionalDesignGen: generator 明示 (D=ABC の 2^(4-1)) は 8 run・主効果直交" $ do
+      let plan = fractionalDesignGen [ contFactor (T.pack [c]) (0,1) | c <- "abcd" ] [[1,2,3]]
+      length (dsCoded plan) `shouldBe` 8
+
+    it "fractionalDesign: 削減設計でも主効果 LM を当てられる (主効果 DGP を再現)" $ do
+      let plan  = fractionalDesign specs7 ResIII
+          rs    = designTable plan
+          cols  = map (\c -> maybe [] id (lookup (T.pack [c]) rs)) "abcdefg"
+          ys    = foldr1 (zipWith (+))
+                    (zipWith (\w col -> map (* w) col) [2,3,-1,4,-2,1,0.5] cols)
+          m     = (("y", ys) : rs) |-> designModel plan "y"
+      rSquared1 (mlmResult m) `shouldSatisfy` (> 0.99)
+
+    -- Phase 78.G-c: alias 構造 + clear 2FI formula (fractionalDesignInter/Gen)。
+    let mkS cs = [ contFactor (T.pack [c]) (0,1) | c <- cs ]
+        nInter = T.count ":"   -- 2FI 項数 = formula 中の ":" 数
+
+    it "fractionalDesignGenInter: Res V (k=5, I=ABCDE) は全 2FI (C(5,2)=10) を足す" $
+      nInter (designFormula (fractionalDesignGenInter (mkS "abcde") [[1,2,3,4]]) "y")
+        `shouldBe` 10
+
+    it "fractionalDesignGenInter: Res IV (k=4, D=ABC) は交絡群ごと代表 1 個 = 3 個" $
+      nInter (designFormula (fractionalDesignGenInter (mkS "abcd") [[1,2,3]]) "y")
+        `shouldBe` 3
+
+    it "fractionalDesignGenInter: Res III (k=3, C=AB) は 2FI が全て主効果と交絡 → 0 個" $
+      designFormula (fractionalDesignGenInter (mkS "abc") [[1,2]]) "y"
+        `shouldBe` "y ~ a + b + c"
+
+    it "aliasStructure: Res IV (k=4, D=ABC) で a:b は c:d と交絡" $ do
+      let plan = fractionalDesignGenInter (mkS "abcd") [[1,2,3]]
+      fmap (elem "c:d") (lookup "a:b" (aliasStructure plan)) `shouldBe` Just True
+
+    it "fractionalDesignInter: 主効果 + 交互作用 DGP を飽和当てはめ (k=4 ResIV・p=8・R^2~1)" $ do
+      let plan = fractionalDesignGenInter (mkS "abcd") [[1,2,3]]   -- 8 run
+          rs   = designTable plan
+          col c = maybe [] id (lookup (T.pack [c]) rs)
+          [a,b,cc,d] = map col "abcd"
+          -- 主効果 + 3 交互作用 (交絡群の代表) の DGP。
+          ys   = [ 2 + 3*(a!!i) - (b!!i) + 2*(cc!!i) + 0.5*(d!!i)
+                     + 1.5*(a!!i * b!!i) - 0.8*(a!!i * cc!!i) + 0.6*(a!!i * d!!i)
+                 | i <- [0 .. length a - 1] ]
+          m    = (("y", ys) : rs) |-> designModel plan "y"
+      snd (LA.size (mlmDesign m)) `shouldBe` 8            -- 切片 + 4 主効果 + 3 2FI
+      rSquared1 (mlmResult m) `shouldSatisfy` (> 0.999)
+
+    it "fractionalDesignInter: 解像度自動 (k=5 ResV) は 16 run + 全 2FI" $ do
+      let plan = fractionalDesignInter (mkS "abcde") ResV
+      length (dsCoded plan) `shouldBe` 16
+      nInter (designFormula plan "y") `shouldBe` 10
+
+  -- Phase 78.G-a: Taguchi 直交表 (2 水準スクリーニング)。
+  describe "Design.Workflow taguchi (Phase 78.G-a)" $ do
+    let mkSpecs cs = [ contFactor (T.pack [c]) (0, 1) | c <- cs ]
+
+    it "taguchiDesign: 最小 OA 自動選択 (3→L4/7→L8/11→L12/15→L16 run)" $
+      [ length (dsCoded (taguchiDesign (mkSpecs cs)))
+      | cs <- [take n ['a'..] | n <- [3, 7, 11, 15]] ]
+        `shouldBe` [4, 8, 12, 16]
+
+    it "taguchiDesign: 列数境界で次の表へ繰り上がる (4 因子→L8・8 因子→L12)" $
+      [ length (dsCoded (taguchiDesign (mkSpecs (take n ['a'..]))))
+      | n <- [4, 8] ]
+        `shouldBe` [8, 12]
+
+    it "taguchiDesign: coded は ±1 のみ" $
+      all (`elem` [-1, 1]) (concat (dsCoded (taguchiDesign (mkSpecs "abcde"))))
+        `shouldBe` True
+
+    it "taguchiDesign: formula は主効果のみ (交互作用なし)" $
+      designFormula (taguchiDesign (mkSpecs "abcde")) "y"
+        `shouldBe` "y ~ a + b + c + d + e"
+
+    it "taguchiDesign: 各列は平衡 (和 = 0)・列同士は直交 (内積 = 0)" $ do
+      let plan   = taguchiDesign (mkSpecs "abcde")   -- L8 の先頭 5 列
+          k      = 5
+          cols   = [ [ row !! j | row <- dsCoded plan ] | j <- [0 .. k - 1] ]
+          dot a b = sum (zipWith (*) a b)
+      all (\c -> abs (sum c) < 1e-9) cols `shouldBe` True
+      and [ abs (dot (cols !! i) (cols !! j)) < 1e-9
+          | i <- [0 .. k - 1], j <- [i + 1 .. k - 1] ] `shouldBe` True
+
+    it "taguchiDesignOA: L12 は 11 因子/12 run のスクリーニング (Plackett-Burman)" $ do
+      let plan = taguchiDesignOA L12 (mkSpecs "abcdefghijk")
+      length (dsCoded plan) `shouldBe` 12
+      length (dsFactors plan) `shouldBe` 11
+
+    it "taguchiDesignOA: 列挙型 OATable で表を明示できる (L8 = 8 run)" $
+      length (dsCoded (taguchiDesignOA L8 (mkSpecs "abc")))
+        `shouldBe` 8
+
+    it "taguchiDesign: L12 で主効果 LM を当てられる (11 因子・主効果 DGP を再現)" $ do
+      let plan = taguchiDesignOA L12 (mkSpecs "abcdefghijk")
+          rs   = designTable plan
+          cols = map (\c -> maybe [] id (lookup (T.pack [c]) rs)) "abcdefghijk"
+          ws   = [2, 3, -1, 4, -2, 1, 0.5, -3, 2.5, -1.5, 0.8]
+          ys   = foldr1 (zipWith (+))
+                   (zipWith (\w col -> map (* w) col) ws cols)
+          m    = (("y", ys) : rs) |-> designModel plan "y"
+      rSquared1 (mlmResult m) `shouldSatisfy` (> 0.99)
+
+  -- Phase 78.G-a2: 3 水準/混合直交表 (L9/L18/L27)・数値 (opoly) / カテゴリ (contrast)。
+  describe "Design.Workflow taguchi 3水準/混合 (Phase 78.G-a2)" $ do
+    let cats4 = [ catFactor (T.pack [c]) ["lo","mid","hi"] | c <- "abcd" ]
+        nums4 = [ numFactor (T.pack [c]) [1, 2, 3] | c <- "abcd" ]
+
+    it "taguchiDesign: 3 水準カテゴリ 4 因子 → L9 (9 run)" $
+      length (dsCoded (taguchiDesign cats4)) `shouldBe` 9
+
+    it "taguchiDesign: 3 水準数値 4 因子 → L9 (9 run)" $
+      length (dsCoded (taguchiDesign nums4)) `shouldBe` 9
+
+    it "taguchiDesign: 数値 3 水準の formula は opoly (直交多項式 2 自由度)" $
+      designFormula (taguchiDesign [ numFactor "temp" [150,165,180]
+                                   , numFactor "time" [10,20,30] ]) "y"
+        `shouldBe` "y ~ opoly(temp,2) + opoly(time,2)"
+
+    it "taguchiDesign: カテゴリ 3 水準の formula は主効果名 (contrast は engine 任せ)" $
+      designFormula (taguchiDesign [ catFactor "cat" ["A","B","C"]
+                                   , catFactor "mat" ["X","Y","Z"] ]) "y"
+        `shouldBe` "y ~ cat + mat"
+
+    it "designTable: 数値 3 水準因子は実水準値の Double 列 (index→実値)" $ do
+      -- L9 の先頭列 (level code 1/2/3) が実値 150/165/180 に写る。 Num は numeric ゆえ
+      -- designTable (numeric runsheet) に載る (Cat と違い error にならない)。
+      let rs  = designTable (taguchiDesign [ numFactor "temp" [150,165,180] ])
+          col = maybe [] id (lookup "temp" rs)
+      (length col, all (`elem` [150,165,180]) col) `shouldBe` (9, True)
+
+    it "designFrame: 数値 3 水準因子の列を持つ" $
+      DX.columnNames (designFrame (taguchiDesign [ numFactor "temp" [150,165,180] ]))
+        `shouldContain` ["temp"]
+
+    it "taguchiDesign: 混合 (連続 1 + カテゴリ 3水準 2) → L18 (18 run)" $
+      length (dsCoded (taguchiDesign [ contFactor "p" (0, 1)
+                                     , catFactor "q" ["a","b","c"]
+                                     , catFactor "r" ["x","y","z"] ]))
+        `shouldBe` 18
+
+    it "taguchiDesignOA: L9 を明示し 3 水準因子を割り当てられる" $
+      length (dsCoded (taguchiDesignOA L9 nums4)) `shouldBe` 9
+
+    it "taguchiDesign: 数値 3 水準で加法 2 次 DGP を designModel で再現 (R^2 ~ 1)" $ do
+      let plan = taguchiDesign [ numFactor "a" [1,2,3], numFactor "b" [1,2,3] ]
+          rs   = designTable plan
+          as   = maybe [] id (lookup "a" rs)
+          bs   = maybe [] id (lookup "b" rs)
+          ys   = zipWith (\a b -> 2 + 3*a - 0.5*a*a + 1*b + 0.2*b*b) as bs
+          m    = (("y", ys) : rs) |-> designModel plan "y"
+      rSquared1 (mlmResult m) `shouldSatisfy` (> 0.999)
+
+  -- Phase 78.G-b1: 最適計画 (D/A/I/E/G-最適・カスタム formula・連続因子)。
+  describe "Design.Workflow optimal (Phase 78.G-b1)" $ do
+    let mkSpecs cs = [ contFactor (T.pack [c]) (-1, 1) | c <- cs ]
+
+    it "optimalDesign: run 数 = 指定 n" $
+      length (dsCoded (optimalDesign (mkSpecs "abc") (mainEffects ["a","b","c"]) 6))
+        `shouldBe` 6
+
+    -- Phase 78.I: 候補点数 (2 因子 quadratic = 3×3 = 9) を超える n も点を反復して satisfy する。
+    it "optimalDesign: n が候補点数を超えても run 数 = n (点を反復)" $
+      length (dsCoded (optimalDesign (mkSpecs "ab") (quadratic ["a","b"]) 12))
+        `shouldBe` 12
+
+    it "optimalDesign: 主効果モデル既定は 2 水準候補 (coded ∈ {-1,+1})" $
+      all (`elem` [-1, 1])
+          (concat (dsCoded (optimalDesign (mkSpecs "ab") (mainEffects ["a","b"]) 4)))
+        `shouldBe` True
+
+    it "optimalDesign: 2 次項ありモデル既定は 3 水準候補 (coded ∈ {-1,0,+1})" $
+      all (`elem` [-1, 0, 1])
+          (concat (dsCoded (optimalDesign (mkSpecs "ab") (quadratic ["a","b"]) 8)))
+        `shouldBe` True
+
+    it "optimalDesignLevels: 水準数を明示できる (3 水準グリッド)" $
+      all (`elem` [-1, 0, 1])
+          (concat (dsCoded (optimalDesignLevels 3 (mkSpecs "ab") (mainEffects ["a","b"]) 5)))
+        `shouldBe` True
+
+    it "optimalDesign: 主効果 n=2^k は全格子 = 直交・平衡な full factorial を選ぶ" $ do
+      let plan = optimalDesign (mkSpecs "ab") (mainEffects ["a","b"]) 4
+          cols = [ [ row !! j | row <- dsCoded plan ] | j <- [0, 1] ]
+          dot a b = sum (zipWith (*) a b)
+      all (\c -> abs (sum c) < 1e-9) cols `shouldBe` True          -- 平衡
+      abs (dot (cols !! 0) (cols !! 1)) < 1e-9 `shouldBe` True     -- 直交
+
+    it "optimalDesign: KCustom formula を焼き込む (designFormula に応答名が入る)" $
+      designFormula (optimalDesign (mkSpecs "ab") (twoWay ["a","b"]) 4) "yield"
+        `shouldSatisfy` T.isPrefixOf "yield "
+
+    it "optimalDesign: n < p (パラメータ数) は error" $
+      -- quadratic 2 因子 = p=6・n=4 < 6
+      evaluate (length (dsCoded (optimalDesign (mkSpecs "ab") (quadratic ["a","b"]) 4)))
+        `shouldThrow` anyErrorCall
+
+    it "optimalDesign: designModel で当てられ・列数 p が formula と一致 (quadratic 2 因子 = 6)" $ do
+      let plan = optimalDesign [contFactor "t" (150,180), contFactor "p" (1,5)] (quadratic ["t","p"]) 9
+          rs   = designTable plan
+          tv   = maybe [] id (lookup "t" rs)
+          pv   = maybe [] id (lookup "p" rs)
+          ys   = zipWith (\t p -> 2 + 0.1*t - 0.5*p + 0.01*(t-165)^(2::Int) + 0.02*p*p) tv pv
+          m    = (("y", ys) : rs) |-> designModel plan "y"
+      snd (LA.size (mlmDesign m)) `shouldBe` 6                     -- 切片+t+p+t:p+t^2+p^2
+      rSquared1 (mlmResult m) `shouldSatisfy` (> 0.99)            -- 真 2 次 DGP を再現
+
+    it "optimalDesign: parseRFormula 文字列でモデル指定できる" $ do
+      case (either (const Nothing) Just (parseRFormula "y ~ a + b + a:b")) of
+        Nothing  -> expectationFailure "parseRFormula 失敗"
+        Just fml ->
+          length (dsCoded (optimalDesign (mkSpecs "ab") fml 4)) `shouldBe` 4
+
+  -- Phase 78.G-b2: カテゴリ因子 (DesignFactor の FactorKind 和型化・型手術)。
+  describe "Design.Workflow categorical (Phase 78.G-b2)" $ do
+    -- 連続1 (temp) × カテゴリ3水準 (cat) の完全要因 = 2×3 = 6 run。
+    let planMix = factorialDesign
+                    [ contFactor "temp" (150, 180)
+                    , catFactor  "cat"  ["A", "B", "C"] ]
+
+    it "factorialDesign: 連続×カテゴリ混合は各水準総当り (2×3 = 6 run)" $
+      length (dsCoded planMix) `shouldBe` 6
+
+    it "factorialDesign: 単一カテゴリ3水準は 3 run (水準数ぶん)" $
+      length (dsCoded (factorialDesign [catFactor "cat" ["A","B","C"]]))
+        `shouldBe` 3
+
+    it "designFrame: カテゴリ列は水準名 (Text)・連続列と run 列を持つ" $ do
+      let df = designFrame planMix
+      DX.columnNames df `shouldContain` ["cat"]
+      DX.columnNames df `shouldContain` ["temp"]
+      DX.columnNames df `shouldContain` ["run"]
+
+    it "designTable: カテゴリ因子を含むと error (designFrame へ誘導)" $
+      evaluate (length (designTable planMix)) `shouldThrow` anyErrorCall
+
+    it "designModel: カテゴリを contrast 展開して当てられる (factor×factor 飽和 → p=6・R^2~1)" $ do
+      -- 純カテゴリ 2 因子 (3水準×2水準 = 6 run) の完全要因 y ~ cat * grp。 treatment contrast で
+      -- 列数 = Kcat*Kgrp = 6 (切片+cat 2+grp 1+cat:grp 2)・6 点 = 飽和 → R^2~1 (DesignSpec 検証点①と同型)。
+      let planCC = factorialDesign [ catFactor "cat" ["A","B","C"], catFactor "grp" ["X","Y"] ]
+          coded  = dsCoded planCC
+          catEff i = [0, 5, -3] !! (round i :: Int) :: Double
+          grpEff j = [0, 4]    !! (round j :: Int) :: Double
+          ys     = [ 10 + catEff (row !! 0) + grpEff (row !! 1) | row <- coded ] :: [Double]
+          df     = DX.insertColumn "y" (DX.fromList ys) (designFrame planCC)
+          m      = df |-> designModel planCC "y"
+      snd (LA.size (mlmDesign m)) `shouldBe` 6                 -- 切片+cat(2)+grp(1)+cat:grp(2)
+      rSquared1 (mlmResult m) `shouldSatisfy` (> 0.999)
+
+    it "centralCompositeDesign: カテゴリ因子は error (連続専用・±α 軸点ゆえ)" $
+      evaluate (length (dsCoded (centralCompositeDesign [contFactor "x" (0,1), catFactor "c" ["A","B"]])))
+        `shouldThrow` anyErrorCall
+
+    it "taguchiDesign: 連続 + binary カテゴリ (共に 2 水準) → L4 (G-a2 で受理)" $
+      -- G-a2 以降 taguchi はカテゴリを受ける。 連続(2)+binary(2) = 2 因子とも 2 水準 → L4。
+      length (dsCoded (taguchiDesign [contFactor "x" (0,1), catFactor "c" ["A","B"]]))
+        `shouldBe` 4
+
+    it "fractionalDesign: 2 水準 (binary) カテゴリは許容 (coded ±1 に写す)" $ do
+      -- 連続5 + binary カテゴリ2 = 7 因子・ResIII = 8 run。
+      let specs = [ contFactor (T.pack [c]) (0,1) | c <- "abcde" ]
+                  ++ [ catFactor "cat1" ["lo","hi"], catFactor "cat2" ["off","on"] ]
+      length (dsCoded (fractionalDesign specs ResIII)) `shouldBe` 8
+
+    it "fractionalDesign: 3 水準以上のカテゴリは error (binary のみ対応)" $
+      evaluate (length (dsCoded
+        (fractionalDesign
+          ([ contFactor (T.pack [c]) (0,1) | c <- "abc" ]
+            ++ [ catFactor "cat" ["A","B","C"] ]) ResIII)))
+        `shouldThrow` anyErrorCall
+
+    it "optimalDesign: カテゴリ因子を候補展開して選べる (run 数 = n)" $
+      length (dsCoded
+        (optimalDesign
+          [ contFactor "x" (-1,1), catFactor "cat" ["A","B"] ]
+          (mainEffects ["x","cat"]) 4))
+        `shouldBe` 4
+
+  -- Phase 78.K: 設計の保存 (CSV) と DataFrame からの復元 (planFromFrame)。
+  describe "Design.Workflow save / planFromFrame (Phase 78.K)" $ do
+    let approxRows a b =
+          length a == length b
+            && and (zipWith (\r s -> length r == length s
+                                       && and (zipWith (\x y -> abs (x - y) < 1e-9) r s)) a b)
+
+    it "planFromFrame: designFrame の逆で coded を復元 (連続・round-trip)" $ do
+      let facs  = [ contFactor "temp" (150, 180), contFactor "time" (10, 20) ]
+          plan  = centralCompositeDesign facs
+          plan2 = planFromFrame facs (quadratic ["temp", "time"]) (designFrame plan)
+      approxRows (dsCoded plan2) (dsCoded plan) `shouldBe` True
+
+    it "planFromFrame: カテゴリ混在も復元し designModel で当たる" $ do
+      let facs = [ contFactor "temp" (150, 180), catFactor "cat" ["A", "B", "C"] ]
+          plan = factorialDesign facs
+          df   = designFrame plan
+          plan2 = planFromFrame facs (mainEffects ["temp", "cat"]) df
+      length (dsCoded plan2) `shouldBe` length (dsCoded plan)      -- run 数一致
+      -- 応答を足して fit できる (formula = 主効果・p = 切片+temp+cat(2) = 4)
+      let ys = map fromIntegral [1 .. length (dsCoded plan)] :: [Double]
+          m  = DX.insertColumn "y" (DX.fromList ys) df |-> designModel plan2 "y"
+      snd (LA.size (mlmDesign m)) `shouldBe` 4
+
+    it "planFromFrame: 因子列が df に無いと error" $ do
+      let df = designFrame (factorialDesign [contFactor "temp" (150, 180)])
+      evaluate (length (dsCoded
+        (planFromFrame [contFactor "NOPE" (0, 1)] (mainEffects ["NOPE"]) df)))
+        `shouldThrow` anyErrorCall
+
+    it "saveDesign: runsheet を CSV に書ける (header + N run 行)" $
+      withSystemTempDirectory "doe-save" $ \dir -> do
+        let plan = centralCompositeDesign [contFactor "temp" (150, 180), contFactor "time" (10, 20)]
+            path = dir ++ "/runsheet.csv"
+        saveDesign path plan
+        content <- readFile path
+        let ls = filter (not . null) (lines content)
+        length ls `shouldBe` (1 + length (dsCoded plan))     -- header + 10 run
+        head ls `shouldSatisfy` (\h -> all (`isInfixOf` h) ["run", "temp", "time"])
+
+  -- Phase 79: 因子は純粋に因子 (役割 dfRole は撤去・階層は Structure が名前で持つ)。
+  describe "Phase 79: 因子の smart ctor (dfName/dfKind)" $ do
+    it "smart ctor の dfName/dfKind (連続 / 数値順序 / カテゴリ)" $ do
+      dfName (contFactor "temp" (150, 180)) `shouldBe` "temp"
+      dfKind (contFactor "temp" (150, 180)) `shouldBe` Cont 150 180 SLinear
+      dfKind (contFactorLog "conc" (0.01, 10)) `shouldBe` Cont 0.01 10 SLog
+      dfKind (numFactor "t" [150, 165, 180]) `shouldBe` Num [150, 165, 180]
+      dfKind (catFactor "cat" ["A", "B"])    `shouldBe` Cat ["A", "B"]
+
+  -- Phase 78.M M3: Formula (効果DSL) → Custom.Model 変換。
+  describe "Phase 78.M M3: formulaToCustomModel" $ do
+    let names = ["a", "b"]
+        terms f = fmap CMd.mTerms (formulaToCustomModel names f)
+    it "mainEffects → 切片 + 各主効果 (TIntercept/TMain)" $
+      terms (mainEffects names)
+        `shouldBe` Right [CMd.TIntercept, CMd.TMain "a", CMd.TMain "b"]
+
+    it "twoWay → 主効果 + 2 因子交互作用 (TInter)" $
+      terms (twoWay names)
+        `shouldBe` Right [CMd.TIntercept, CMd.TMain "a", CMd.TMain "b", CMd.TInter ["a", "b"]]
+
+    it "quadratic → 主効果 + 交互作用 + 2 次項 (TPower)" $
+      terms (quadratic names)
+        `shouldBe` Right
+          [ CMd.TIntercept, CMd.TMain "a", CMd.TMain "b"
+          , CMd.TInter ["a", "b"], CMd.TPower "a" 2, CMd.TPower "b" 2 ]
+
+    it "正規化は NCoded (optimalDesign と同じ coded 規約)" $
+      fmap CMd.mNorm (formulaToCustomModel names (quadratic names))
+        `shouldBe` Right CMd.NCoded
+
+    it "基底関数など未対応項は Left" $ do
+      let f = either (error "parse") id (parseRFormula "_y ~ opoly(a, 2)")
+      case formulaToCustomModel ["a"] f of
+        Left _  -> pure ()
+        Right m -> expectationFailure ("expected Left, got " ++ show (CMd.mTerms m))
+
+  -- Phase 79: 完全カスタムデザインエンジン (CustomSpec / Structure)。
+  describe "Phase 79: customDesign (CustomSpec)" $ do
+    it "CRD: n run の Design を返す (KStructured [])" $ do
+      let plan = customDesign (customSpec
+                   [contFactor "temp" (150, 180), contFactor "time" (10, 20)]
+                   (quadratic ["temp", "time"]) 10 42)
+      length (dsCoded plan) `shouldBe` 10
+      case dsKind plan of
+        KStructured [] _ -> pure ()               -- CRD = 群列なし
+        k                -> expectationFailure ("expected KStructured [] , got " ++ show k)
+
+    it "CRD: seed 決定的 (2 回同結果・pure)" $ do
+      let mk = customDesign (customSpec [contFactor "a" (-1, 1), contFactor "b" (-1, 1)]
+                            (twoWay ["a", "b"]) 8 7)
+      dsCoded mk `shouldBe` dsCoded mk
+
+    it "customSpec の既定は CRD・DOpt・制約なし" $ do
+      let cs = customSpec [contFactor "a" (-1, 1)] (mainEffects ["a"]) 4 1
+      csStructure cs  `shouldBe` CRD
+      csCriterion cs  `shouldBe` DOpt
+      csConstraints cs `shouldBe` []
+
+    it "制約つき CRD: 線形制約 x1+x2<=0.5 (coded) を全 run が満たす" $ do
+      let cons = [ LinearIneq [("x1", 1), ("x2", 1)] CLeq 0.5 ]
+          plan = customDesign (customSpec
+                   [contFactor "x1" (-1, 1), contFactor "x2" (-1, 1)]
+                   (twoWay ["x1", "x2"]) 8 42) { csConstraints = cons }
+          sums = map sum (dsCoded plan)          -- coded x1 + x2
+      length (dsCoded plan) `shouldBe` 8
+      all (<= 0.5 + 1e-9) sums `shouldBe` True    -- 制約違反 (例 [1,1]=2.0) が出ない
+
+    it "csConstraints=[] は制約なし既定と同結果 (回帰)" $ do
+      let fs   = [contFactor "x1" (-1, 1), contFactor "x2" (-1, 1)]
+          base = customSpec fs (twoWay ["x1", "x2"]) 8 42
+      dsCoded (customDesign base)
+        `shouldBe` dsCoded (customDesign base { csConstraints = [] })
+
+    it "CRD: Num 因子 (数値順序) も使える — dsCoded は水準 index" $ do
+      let plan = customDesign (customSpec
+                   [numFactor "temp" [150, 165, 180], contFactor "time" (10, 20)]
+                   (quadratic ["temp", "time"]) 9 3)
+      length (dsCoded plan) `shouldBe` 9
+      -- temp (列 0) は水準 index {0,1,2} (numLevelAt が実値へ戻す規約)
+      let tempIdx = map (round . head) (dsCoded plan) :: [Int]
+      all (`elem` [0, 1, 2]) tempIdx `shouldBe` True
+      -- designFrame は temp を実水準値 {150,165,180} に decode する (round-trip)
+      let tempVals = case getDoubleVec "temp" (designFrame plan) of
+            Just v  -> V.toList v
+            Nothing -> error "temp 列が無い"
+      all (`elem` [150, 165, 180]) tempVals `shouldBe` True
+
+    -- Phase 79.2: SplitPlot 構造 (群単位ムーブ + GLS 基準)。
+    it "SplitPlot: KStructured [(wholePlot, ids)]・群 ID が n 個・nWhole 群" $ do
+      let plan = customDesign (customSpec
+                   [contFactor "temp" (150, 180), contFactor "rate" (10, 20)]
+                   (twoWay ["temp", "rate"]) 8 50) { csStructure = splitPlot ["temp"] 4 }
+      length (dsCoded plan) `shouldBe` 8
+      case dsKind plan of
+        KStructured [("wholePlot", ids)] _ -> do
+          length ids `shouldBe` 8
+          length (nub ids) `shouldBe` 4          -- 4 whole-plot
+        k -> expectationFailure ("expected KStructured [(wholePlot,_)], got " ++ show k)
+
+    it "SplitPlot: whole-plot 因子 (temp) は群内で一定" $ do
+      let plan = customDesign (customSpec
+                   [contFactor "temp" (150, 180), contFactor "rate" (10, 20)]
+                   (twoWay ["temp", "rate"]) 8 50) { csStructure = splitPlot ["temp"] 4 }
+      case dsKind plan of
+        KStructured [("wholePlot", ids)] _ -> do
+          let tempCol   = [ row !! 0 | row <- dsCoded plan ]       -- temp = 列 0
+              byGroup g = [ t | (t, i) <- zip tempCol ids, i == g ]
+          all (\g -> let vs = byGroup g in all (== head vs) vs) (nub ids)
+            `shouldBe` True
+        _ -> expectationFailure "expected KStructured [(wholePlot,_)]"
+
+    it "SplitPlot: seed 決定的 (2 回同結果・pure)" $ do
+      let mk = customDesign (customSpec
+                 [contFactor "temp" (150, 180), contFactor "rate" (10, 20)]
+                 (twoWay ["temp", "rate"]) 8 50) { csStructure = splitPlot ["temp"] 4 }
+      dsCoded mk `shouldBe` dsCoded mk
+
+    -- Jones-Goos (2012) Table 2 golden を高レベル API へ移植: 20-run split-plot
+    -- (4 WP × 5 SP・full quadratic・η=1) の D-criterion det(Xᵀ M⁻¹ X) が文献値 2684.44 に到達。
+    -- 低レベル golden (Custom.SplitPlotSpec) と同じ math を新エンジンで再確認する。
+    it "SplitPlot golden: det(Xᵀ M⁻¹ X) が Jones-Goos 2012 文献値 ≥ 2684" $ do
+      let fs   = [contFactor "w" (-1, 1), contFactor "s" (-1, 1)]
+          plan = customDesign (customSpec fs (quadratic ["w", "s"]) 20 42)
+                   { csStructure = splitPlot ["w"] 4 }
+      case dsKind plan of
+        KStructured [("wholePlot", ids)] _ -> do
+          let raw = LA.fromLists (dsCoded plan)
+              cfW = [ CF.Factor "w" (CF.Continuous (-1) 1) CF.Controllable
+                    , CF.Factor "s" (CF.Continuous (-1) 1) CF.Controllable ]
+              model = either (error . T.unpack) id
+                        (formulaToCustomModel ["w", "s"] (quadratic ["w", "s"]))
+              mInv = ST.buildMInvFromGroups 20 [(1.0, VS.fromList ids)]
+          case CMd.expandDesignMatrix cfW model raw of
+            Left e  -> expectationFailure (T.unpack e)
+            Right x -> do
+              let dval = LA.det (LA.tr x LA.<> (mInv LA.<> x))
+              dval `shouldSatisfy` (>= 2684.0)
+        k -> expectationFailure ("expected KStructured [(wholePlot,_)], got " ++ show k)
+
+    -- Phase 79.3: StripPlot (交差 2 階層・whole-plot × strip)。
+    it "StripPlot: 2 群列 (wholePlot, strip)・n = nWhole × nStrip" $ do
+      let plan = customDesign (customSpec
+                   [ contFactor "wp" (-1, 1), contFactor "st" (-1, 1), contFactor "sp" (-1, 1) ]
+                   (mainEffects ["wp", "st", "sp"]) 12 7)
+                   { csStructure = stripPlot ["wp"] 4 ["st"] 3 }
+      length (dsCoded plan) `shouldBe` 12
+      case dsKind plan of
+        KStructured [("wholePlot", wpIds), ("strip", stIds)] _ -> do
+          length wpIds `shouldBe` 12
+          length stIds `shouldBe` 12
+          length (nub wpIds) `shouldBe` 4
+          length (nub stIds) `shouldBe` 3
+        k -> expectationFailure ("expected KStructured [(wholePlot,_),(strip,_)], got " ++ show k)
+
+    it "StripPlot: whole-plot 因子は WP 群内で一定・strip 因子は strip 群内で一定" $ do
+      let plan = customDesign (customSpec
+                   [ contFactor "wp" (-1, 1), contFactor "st" (-1, 1), contFactor "sp" (-1, 1) ]
+                   (mainEffects ["wp", "st", "sp"]) 12 7)
+                   { csStructure = stripPlot ["wp"] 4 ["st"] 3 }
+      case dsKind plan of
+        KStructured [("wholePlot", wpIds), ("strip", stIds)] _ -> do
+          let colOf j   = [ row !! j | row <- dsCoded plan ]
+              constIn ids col = all (\g -> let vs = [ c | (c, i) <- zip col ids, i == g ]
+                                           in all (== head vs) vs) (nub ids)
+          constIn wpIds (colOf 0) `shouldBe` True    -- wp (列 0) は WP 群内で一定
+          constIn stIds (colOf 1) `shouldBe` True    -- st (列 1) は strip 群内で一定
+        _ -> expectationFailure "expected KStructured [(wholePlot,_),(strip,_)]"
+
+    it "StripPlot: n ≠ nWhole × nStrip は error" $ do
+      let bad = customDesign (customSpec
+                  [ contFactor "wp" (-1, 1), contFactor "st" (-1, 1) ]
+                  (mainEffects ["wp", "st"]) 10 7)
+                  { csStructure = stripPlot ["wp"] 4 ["st"] 3 }   -- 4×3=12 ≠ 10
+      evaluate (length (dsCoded bad)) `shouldThrow` anyErrorCall
+
+    -- Phase 79.4: Blocked (ランダムブロック)。 全因子はブロック内で自由・block は run 割付のみ。
+    it "Blocked: KStructured [(block, ids)]・n run・nBlocks 群・因子列は block を含まない" $ do
+      let plan = customDesign (customSpec
+                   [ contFactor "x1" (-1, 1), contFactor "x2" (-1, 1) ]
+                   (twoWay ["x1", "x2"]) 12 5) { csStructure = blocked 3 }
+      length (dsCoded plan) `shouldBe` 12
+      map length (dsCoded plan) `shouldSatisfy` all (== 2)   -- 因子は 2 列 (block は列でない)
+      case dsKind plan of
+        KStructured [("block", ids)] _ -> do
+          length ids `shouldBe` 12
+          length (nub ids) `shouldBe` 3
+        k -> expectationFailure ("expected KStructured [(block,_)], got " ++ show k)
+
+    it "Blocked: det(Xᵀ M⁻¹ X) > 0 (非特異・full-rank 設計) + seed 決定的" $ do
+      let fs   = [ contFactor "x1" (-1, 1), contFactor "x2" (-1, 1) ]
+          plan = customDesign (customSpec fs (twoWay ["x1", "x2"]) 12 5)
+                   { csStructure = blocked 3 }
+      dsCoded plan `shouldBe` dsCoded plan   -- pure
+      case dsKind plan of
+        KStructured [("block", ids)] _ -> do
+          let raw = LA.fromLists (dsCoded plan)
+              cfs = [ CF.Factor "x1" (CF.Continuous (-1) 1) CF.Controllable
+                    , CF.Factor "x2" (CF.Continuous (-1) 1) CF.Controllable ]
+              model = either (error . T.unpack) id
+                        (formulaToCustomModel ["x1", "x2"] (twoWay ["x1", "x2"]))
+              mInv = ST.buildMInvFromGroups 12 [(1.0, VS.fromList ids)]
+          case CMd.expandDesignMatrix cfs model raw of
+            Left e  -> expectationFailure (T.unpack e)
+            Right x -> LA.det (LA.tr x LA.<> (mInv LA.<> x)) `shouldSatisfy` (> 0)
+        _ -> expectationFailure "expected KStructured [(block,_)]"
+
+    -- Phase 79.5: 制約 × 構造。 78.M では customDesign (制約) と splitPlotDesign (階層) が
+    -- 別関数で両立不可だった本命ギャップ。 whole-plot 群内一定 かつ 制約満足を同時に満たす。
+    it "SplitPlot + 線形制約: whole-plot 群内一定 かつ rate<=0.5 (coded) を全 run が満たす" $ do
+      let cons = [ LinearIneq [("rate", 1)] CLeq 0.5 ]
+          plan = customDesign (customSpec
+                   [contFactor "temp" (150, 180), contFactor "rate" (-1, 1)]
+                   (twoWay ["temp", "rate"]) 8 50)
+                   { csStructure = splitPlot ["temp"] 4, csConstraints = cons }
+      length (dsCoded plan) `shouldBe` 8
+      case dsKind plan of
+        KStructured [("wholePlot", ids)] _ -> do
+          let tempCol = [ row !! 0 | row <- dsCoded plan ]     -- temp = 列 0 (whole-plot)
+              rateCol = [ row !! 1 | row <- dsCoded plan ]     -- rate = 列 1 (sub-plot)
+          -- (a) 制約: 全 run の rate <= 0.5
+          all (<= 0.5 + 1e-9) rateCol `shouldBe` True
+          -- (b) 階層: temp が whole-plot 群内で一定
+          let byGroup g = [ t | (t, i) <- zip tempCol ids, i == g ]
+          all (\g -> let vs = byGroup g in all (== head vs) vs) (nub ids)
+            `shouldBe` True
+        k -> expectationFailure ("expected KStructured [(wholePlot,_)], got " ++ show k)
+
+    it "SplitPlot + 制約: seed 決定的 (pure)" $ do
+      let cons = [ LinearIneq [("rate", 1)] CLeq 0.5 ]
+          mk   = customDesign (customSpec
+                   [contFactor "temp" (150, 180), contFactor "rate" (-1, 1)]
+                   (twoWay ["temp", "rate"]) 8 50)
+                   { csStructure = splitPlot ["temp"] 4, csConstraints = cons }
+      dsCoded mk `shouldBe` dsCoded mk
+
+    -- Phase 79.6: 複数群列 → 複数 ranIntercept の HBM round-trip。 StripPlot は 2 群列
+    -- (wholePlot, strip) を designFrame に Text ラベルで出すので、 そのまま階層モデルに乗る。
+    it "round-trip: StripPlot → designFrame に 2 群列 (Text)・両方 ranIntercept が当たる" $ do
+      let plan = customDesign (customSpec
+                   [ contFactor "wp" (-1, 1), contFactor "st" (-1, 1), contFactor "sp" (-1, 1) ]
+                   (mainEffects ["wp", "st", "sp"]) 12 7)
+                   { csStructure = stripPlot ["wp"] 4 ["st"] 3 }
+          n   = length (dsCoded plan)
+          ys  = [ 100 + 2 * fromIntegral i | i <- [1 .. n] ] :: [Double]
+          df  = DX.insertColumn "y" (DX.fromList ys) (designFrame plan)
+          m   = df |-> designModelHBM defaultHBM plan
+                        [ranIntercept "wholePlot", ranIntercept "strip"] "y"
+      -- 固定効果 beta が取り出せれば 2 階層 round-trip は地続きに成立 (happy-path 構造検証)
+      length (dhfBetaNames m) `shouldSatisfy` (> 0)
+
+  -- Phase 82: 自然単位の制約 (natLeq / natGeq / natForbid)。 実単位で書いた制約が
+  -- customDesign 入口で coded へ正規化され、 designFrame の実値がその実単位境界を守る。
+  describe "Phase 82: 自然単位の線形制約 (natLeq/natForbid)" $ do
+    it "natLeq temp<=160 (Cont 150..180): designFrame の temp が全 run <= 160" $ do
+      let plan  = customDesign (customSpec
+                    [contFactor "temp" (150, 180), contFactor "time" (10, 20)]
+                    (mainEffects ["temp", "time"]) 8 42)
+                    { csNatConstraints = [natLeq [("temp", 1)] 160] }
+          temps = maybe (error "temp 列なし") V.toList (getDoubleVec "temp" (designFrame plan))
+      length temps `shouldBe` 8
+      all (<= 160 + 1e-9) temps `shouldBe` True
+
+    it "natLeq temp+time<=175 (実単位和): 全 run で temp+time <= 175" $ do
+      let plan = customDesign (customSpec
+                   [contFactor "temp" (150, 180), contFactor "time" (10, 20)]
+                   (mainEffects ["temp", "time"]) 8 42)
+                   { csNatConstraints = [natLeq [("temp", 1), ("time", 1)] 175] }
+          df   = designFrame plan
+          ts   = maybe (error "temp") V.toList (getDoubleVec "temp" df)
+          tm   = maybe (error "time") V.toList (getDoubleVec "time" df)
+      all (<= 175 + 1e-9) (zipWith (+) ts tm) `shouldBe` True
+
+    it "natForbid: Num 因子を実水準値 (180) で禁止 → designFrame に 180 が出ない" $ do
+      let plan = customDesign (customSpec
+                   [numFactor "temp" [150, 165, 180], contFactor "time" (10, 20)]
+                   (mainEffects ["temp", "time"]) 8 42)
+                   { csNatConstraints = [natForbid [("temp", FVDouble 180)]] }
+          temps = maybe (error "temp") V.toList (getDoubleVec "temp" (designFrame plan))
+      notElem 180 temps `shouldBe` True
+      all (`elem` [150, 165]) temps `shouldBe` True
+
+    it "natLeq がカテゴリ因子を参照するとエラー (順序なし)" $ do
+      let plan = customDesign (customSpec
+                   [catFactor "cat" ["A", "B"], contFactor "x" (0, 1)]
+                   (mainEffects ["cat", "x"]) 4 1)
+                   { csNatConstraints = [natLeq [("cat", 1)] 0.5] }
+      evaluate (length (dsCoded plan)) `shouldThrow` anyErrorCall
+
+  -- Phase 82.2: 離散数値 (Num) 因子の実値閾値。 単一項 natLeq/natGeq/natEq は
+  -- 「閾値を満たさない水準を除外」に展開される (半空間でなく水準フィルタ)。
+  describe "Phase 82.2: 離散数値因子の実値閾値フィルタ (natLeq/natGeq Num)" $ do
+    it "natLeq temp<=160 (Num 150/165/180): 全 run が 160 以下 (= 150 のみ残る)" $ do
+      let plan  = customDesign (customSpec
+                    [numFactor "temp" [150, 165, 180], contFactor "time" (10, 20)]
+                    (mainEffects ["temp", "time"]) 8 42)
+                    { csNatConstraints = [natLeq [("temp", 1)] 160] }
+          temps = maybe (error "temp") V.toList (getDoubleVec "temp" (designFrame plan))
+      all (<= 160 + 1e-9) temps `shouldBe` True
+      all (== 150) temps `shouldBe` True
+
+    it "natGeq temp>=165 (Num): 165/180 のみ残り 150 は消える" $ do
+      let plan  = customDesign (customSpec
+                    [numFactor "temp" [150, 165, 180], contFactor "time" (10, 20)]
+                    (mainEffects ["temp", "time"]) 8 42)
+                    { csNatConstraints = [natGeq [("temp", 1)] 165] }
+          temps = maybe (error "temp") V.toList (getDoubleVec "temp" (designFrame plan))
+      notElem 150 temps `shouldBe` True
+      all (`elem` [165, 180]) temps `shouldBe` True
+
+    it "係数付き natLeq 2·temp<=330 (Num): 実値換算で 165 以下 (150/165 が残る)" $ do
+      let plan  = customDesign (customSpec
+                    [numFactor "temp" [150, 165, 180], contFactor "time" (10, 20)]
+                    (mainEffects ["temp", "time"]) 8 42)
+                    { csNatConstraints = [natLeq [("temp", 2)] 330] }
+          temps = maybe (error "temp") V.toList (getDoubleVec "temp" (designFrame plan))
+      notElem 180 temps `shouldBe` True
+      all (`elem` [150, 165]) temps `shouldBe` True
+
+    it "Num 因子を他因子と線形結合するとエラー (単一項のみ)" $ do
+      let plan = customDesign (customSpec
+                   [numFactor "temp" [150, 165, 180], contFactor "time" (10, 20)]
+                   (mainEffects ["temp", "time"]) 8 42)
+                   { csNatConstraints = [natLeq [("temp", 1), ("time", 1)] 175] }
+      evaluate (length (dsCoded plan)) `shouldThrow` anyErrorCall
+
+    it "どの水準も満たさない閾値はエラー (temp<=100・水準 150..180)" $ do
+      let plan = customDesign (customSpec
+                   [numFactor "temp" [150, 165, 180], contFactor "time" (10, 20)]
+                   (mainEffects ["temp", "time"]) 8 42)
+                   { csNatConstraints = [natLeq [("temp", 1)] 100] }
+      evaluate (length (dsCoded plan)) `shouldThrow` anyErrorCall
+
+  -- Phase 82.3: 対数スケール連続因子 (contFactorLog)。 coded [-1,1] は不変だが自然単位へは
+  -- 幾何的 (10^…) に写す。 中心点が幾何平均・自然単位制約も log 空間で載る。
+  describe "Phase 82.3: 対数スケール連続因子 (contFactorLog)" $ do
+    it "幾何 decode: 中心点 conc が幾何平均 (10^-0.5≈0.316)・算術平均 (5.005) でない" $ do
+      let plan  = customDesign (customSpec
+                    [contFactorLog "conc" (0.01, 10), contFactor "t" (10, 20)]
+                    (quadratic ["conc", "t"]) 12 7)
+          concs = maybe (error "conc 列なし") V.toList (getDoubleVec "conc" (designFrame plan))
+          geoM  = 10 ** (-0.5)          -- 幾何平均
+      -- 全 conc が範囲内 [0.01, 10]
+      all (\x -> x >= 0.01 - 1e-9 && x <= 10 + 1e-9) concs `shouldBe` True
+      -- 中心点は幾何平均 (0.316…) が現れ、 算術平均 (5.005) は現れない
+      any (\x -> abs (x - geoM) < 1e-6) concs `shouldBe` True
+      all (\x -> abs (x - 5.005) > 1e-3) concs `shouldBe` True
+
+    it "natLeq conc<=0.1 (log因子・単一境界): 全 conc <= 0.1" $ do
+      let plan  = customDesign (customSpec
+                    [contFactorLog "conc" (0.01, 10), contFactor "t" (10, 20)]
+                    (mainEffects ["conc", "t"]) 8 7)
+                    { csNatConstraints = [natLeq [("conc", 1)] 0.1] }
+          concs = maybe (error "conc") V.toList (getDoubleVec "conc" (designFrame plan))
+      all (<= 0.1 + 1e-9) concs `shouldBe` True
+
+    it "自然単位の線形結合に log 因子が混ざるとエラー (非線形)" $ do
+      let plan = customDesign (customSpec
+                   [contFactorLog "conc" (0.01, 10), contFactor "t" (10, 20)]
+                   (mainEffects ["conc", "t"]) 8 7)
+                   { csNatConstraints = [natLeq [("conc", 1), ("t", 1)] 15] }
+      evaluate (length (dsCoded plan)) `shouldThrow` anyErrorCall
+
+    it "実行不能エラーは有効な制約と因子範囲を実単位で添える" $ do
+      -- temp∈[150,180] に temp<=100 は両立不能。 エラーに実単位の制約と範囲が出る。
+      let plan = customDesign (customSpec
+                   [contFactor "temp" (150, 180), contFactor "t" (10, 20)]
+                   (mainEffects ["temp", "t"]) 6 1)
+                   { csNatConstraints = [natLeq [("temp", 1)] 100] }
+      evaluate (length (dsCoded plan)) `shouldThrow`
+        (\(ErrorCall m) -> "1.0·temp ≤ 100.0" `isInfixOf` m
+                             && "temp ∈ [150.0, 180.0]" `isInfixOf` m)
+
+  -- designFrameRound: runsheet の桁数調整 (CCD 軸点の長い小数を丸める)。
+  describe "designFrameRound (runsheet 桁数調整)" $ do
+    it "連続因子の実値を小数第 n 位に丸める (CCD ±α 軸点)" $ do
+      let plan  = centralCompositeDesign
+                    [contFactor "temp" (150, 180), contFactor "time" (10, 20)]
+          df    = designFrameRound 2 plan
+          temps = maybe (error "temp 列なし") V.toList (getDoubleVec "temp" df)
+      -- 軸点 143.78679… / 186.21320… が第 2 位に丸まる
+      (143.79 `elem` temps) `shouldBe` True
+      (186.21 `elem` temps) `shouldBe` True
+      -- 全値が小数第 2 位以内 (x*100 が整数)
+      all (\x -> abs (x * 100 - fromIntegral (round (x * 100) :: Integer)) < 1e-9) temps
+        `shouldBe` True
+
+    it "run 数は designFrame と同じ (丸めのみ・行は増減しない)" $ do
+      let plan  = centralCompositeDesign [contFactor "temp" (150, 180), contFactor "time" (10, 20)]
+          nrun df = maybe 0 V.length (getDoubleVec "run" df)
+      nrun (designFrameRound 3 plan) `shouldBe` nrun (designFrame plan)
diff --git a/test/Hanalyze/MCMC/BayesianTestSpec.hs b/test/Hanalyze/MCMC/BayesianTestSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/MCMC/BayesianTestSpec.hs
@@ -0,0 +1,74 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.MCMC.BayesianTestSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified System.Random.MWC as MWC
+import qualified System.Random.MWC as MWC
+import qualified Hanalyze.MCMC.NUTS as NUTS
+import qualified Hanalyze.MCMC.BayesianTest as BAB
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.MCMC.BayesianTest (Phase 8)" $ do
+    -- HDI helper の単体テスト
+    it "highestDensityInterval: 単純な uniform で expected width" $ do
+      let xs = [fromIntegral i / 99 | i <- [0..99 :: Int]]  -- 0, 1/99, ..., 1
+          (lo, hi) = BAB.highestDensityInterval 0.90 xs
+      -- 90% HDI は約 [0, 0.9]、 width ≈ 0.9
+      (hi - lo) `shouldSatisfy` (\w -> w >= 0.85 && w <= 0.95)
+
+    it "highestDensityInterval: 0 元素で (0, 0)" $
+      BAB.highestDensityInterval 0.95 [] `shouldBe` (0, 0)
+
+    -- classifyROPE の単体テスト (HDI 形状で)
+    it "ROPE 完全外: RejectH0" $ do
+      let cfg = BAB.defaultBayesianABConfig
+            { BAB.babCredible = 0.95
+            , BAB.babRule = BAB.ROPEDecision (-0.1) 0.1
+            , BAB.babNUTS = (BAB.babNUTS BAB.defaultBayesianABConfig)
+                { NUTS.nutsIterations = 300, NUTS.nutsBurnIn = 200 }
+            }
+          -- 明確に異なる 2 群
+          ysA = [10 + 0.1 * fromIntegral i | i <- [1..30 :: Int]]
+          ysB = [20 + 0.1 * fromIntegral i | i <- [1..30 :: Int]]
+      gen <- MWC.create
+      res <- BAB.bayesianAB cfg ysA ysB gen
+      BAB.babDecision res `shouldBe` BAB.RejectH0
+      BAB.babMeanDiff res `shouldSatisfy` (> 5)  -- 強く正
+      BAB.babProbDiffPos res `shouldSatisfy` (> 0.95)
+
+    it "HDIOnly: NoRuleApplied" $ do
+      let cfg = BAB.defaultBayesianABConfig
+            { BAB.babNUTS = (BAB.babNUTS BAB.defaultBayesianABConfig)
+                { NUTS.nutsIterations = 200, NUTS.nutsBurnIn = 100 }
+            }
+          ysA = [1, 2, 3, 4, 5 :: Double]
+          ysB = [3, 4, 5, 6, 7 :: Double]
+      gen <- MWC.create
+      res <- BAB.bayesianAB cfg ysA ysB gen
+      BAB.babDecision res `shouldBe` BAB.NoRuleApplied
+      -- HDI は finite + lo ≤ hi
+      let (lo, hi) = BAB.babHDI res
+      lo `shouldSatisfy` (<= hi)
+      -- posterior サンプルが期待数
+      length (BAB.babPosteriorDiff res) `shouldBe` 200
diff --git a/test/Hanalyze/MCMC/NUTSSpec.hs b/test/Hanalyze/MCMC/NUTSSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/MCMC/NUTSSpec.hs
@@ -0,0 +1,162 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.MCMC.NUTSSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Data.Vector as V
+import qualified Data.Map.Strict as M
+import qualified System.Random.MWC as MWC
+import qualified Hanalyze.Model.Core        as Core
+import qualified System.Random.MWC as MWC
+import qualified Hanalyze.MCMC.NUTS as NUTS
+import qualified Hanalyze.MCMC.Core as Core
+import qualified Hanalyze.Model.HBM as HBM
+import qualified Data.Map.Strict    as M
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.MCMC.NUTS.nutsStream" $ do
+    let smallCfg = NUTS.defaultNUTSConfig
+          { NUTS.nutsIterations = 20
+          , NUTS.nutsBurnIn     = 10
+          , NUTS.nutsAdaptStepSize = False
+          , NUTS.nutsAdaptMass     = False
+          }
+        -- Trivial model: mu ~ Normal(0, 1), observe one data point.
+        model :: HBM.ModelP ()
+        model = do
+          mu <- HBM.sample "mu" (HBM.Normal 0 1)
+          HBM.observe "y" (HBM.Normal mu 1) [0.5]
+
+    it "callback が iterations + burnIn 回呼ばれる" $ do
+      eventsRef <- newIORef []
+      gen <- MWC.create
+      _ <- NUTS.nutsStream model smallCfg (M.fromList [("mu", 0)]) gen $ \ev ->
+        modifyIORef' eventsRef (ev :)
+      events <- reverse <$> readIORef eventsRef
+      length events `shouldBe`
+        (NUTS.nutsBurnIn smallCfg + NUTS.nutsIterations smallCfg)
+
+    it "seIter は 0..total-1 で順番に並ぶ" $ do
+      eventsRef <- newIORef []
+      gen <- MWC.create
+      _ <- NUTS.nutsStream model smallCfg (M.fromList [("mu", 0)]) gen $ \ev ->
+        modifyIORef' eventsRef (ev :)
+      events <- reverse <$> readIORef eventsRef
+      map NUTS.seIter events
+        `shouldBe`
+        [0 .. NUTS.nutsBurnIn smallCfg + NUTS.nutsIterations smallCfg - 1]
+
+    it "seIsBurnIn は 先頭 burnIn 個だけ True" $ do
+      eventsRef <- newIORef []
+      gen <- MWC.create
+      _ <- NUTS.nutsStream model smallCfg (M.fromList [("mu", 0)]) gen $ \ev ->
+        modifyIORef' eventsRef (ev :)
+      events <- reverse <$> readIORef eventsRef
+      length (filter NUTS.seIsBurnIn events)
+        `shouldBe` NUTS.nutsBurnIn smallCfg
+
+    it "既存 nuts は nutsStream の wrapper として同一結果(同じ seed)" $ do
+      gen1 <- MWC.create
+      chain1 <- NUTS.nuts model smallCfg (M.fromList [("mu", 0)]) gen1
+      gen2 <- MWC.create
+      chain2 <- NUTS.nutsStream model smallCfg (M.fromList [("mu", 0)])
+                  gen2 (\_ -> pure ())
+      -- 同じ seed (MWC.create は決定的) なので chain length 一致
+      length (Core.chainSamples chain1)
+        `shouldBe` length (Core.chainSamples chain2)
+
+  describe "Hanalyze.MCMC.NUTS 純粋版 (Phase 50.3 nutsPure / nutsChainsPure)" $ do
+    let cfg = NUTS.defaultNUTSConfig
+          { NUTS.nutsIterations = 30, NUTS.nutsBurnIn = 20
+          , NUTS.nutsAdaptStepSize = False, NUTS.nutsAdaptMass = False }
+        model :: HBM.ModelP ()
+        model = do
+          mu <- HBM.sample "mu" (HBM.Normal 0 10)
+          HBM.observe "y" (HBM.Normal mu 1) (replicate 8 5.0)
+        initC = M.fromList [("mu", 0)]
+
+    it "nutsPure: 同 seed なら chainSamples がビット同一 (再現性)" $ do
+      let c1 = NUTS.nutsPure model cfg initC 12345
+          c2 = NUTS.nutsPure model cfg initC 12345
+      Core.chainSamples c1 `shouldBe` Core.chainSamples c2
+
+    it "nutsPure: 別 seed なら chainSamples が異なる" $ do
+      let c1 = NUTS.nutsPure model cfg initC 1
+          c2 = NUTS.nutsPure model cfg initC 2
+      (Core.chainSamples c1 == Core.chainSamples c2) `shouldBe` False
+
+    it "nutsPure: IO nuts と同 seed でビット同一 (ST/IO 等価)" $ do
+      gen <- MWC.initialize (V.singleton 777)
+      ioChain <- NUTS.nuts model cfg initC gen
+      let pureChain = NUTS.nutsPure model cfg initC 777
+      Core.chainSamples pureChain `shouldBe` Core.chainSamples ioChain
+
+    it "nutsChainsPure: numChains 本・各 chain は iterations 長・再現性あり" $ do
+      let chains = NUTS.nutsChainsPure model cfg 3 initC 99
+      length chains `shouldBe` 3
+      map (length . Core.chainSamples) chains
+        `shouldBe` replicate 3 (NUTS.nutsIterations cfg)
+      -- 同 seed で全 chain がビット同一 (parList は決定性に影響しない)
+      map Core.chainSamples (NUTS.nutsChainsPure model cfg 3 initC 99)
+        `shouldBe` map Core.chainSamples chains
+
+    it "nutsChainsPure: 子 seed が分かれ chain 同士は異なる" $ do
+      let chains = NUTS.nutsChainsPure model cfg 2 initC 55
+      case chains of
+        [a, b] -> (Core.chainSamples a == Core.chainSamples b) `shouldBe` False
+        _      -> expectationFailure "expected 2 chains"
+
+    -- Phase 94 A4-2: init jitter。 既定 (nutsInitJitter=0) は従来と完全一致
+    -- (RNG stream を消費しない = 既存全テストの再現性を保つ)。 jitter>0 は
+    -- 初期位置を散らして結果が変わるが、 同 seed なら決定的に再現。
+    it "nutsInitJitter=0 は jitter 無指定 (従来) とビット同一" $ do
+      let c0 = NUTS.nutsPure model cfg initC 2024
+          cJ0 = NUTS.nutsPure model cfg { NUTS.nutsInitJitter = 0 } initC 2024
+      Core.chainSamples cJ0 `shouldBe` Core.chainSamples c0
+
+    it "nutsInitJitter>0 は結果を変える (init を散らす) が同 seed で再現" $ do
+      let c0 = NUTS.nutsPure model cfg initC 2024
+          cJ = NUTS.nutsPure model cfg { NUTS.nutsInitJitter = 1.0 } initC 2024
+          cJ' = NUTS.nutsPure model cfg { NUTS.nutsInitJitter = 1.0 } initC 2024
+      (Core.chainSamples cJ == Core.chainSamples c0) `shouldBe` False
+      Core.chainSamples cJ `shouldBe` Core.chainSamples cJ'
+
+    -- Phase 61.1: IO 経路 (nutsChainsStream) は chainSeeds 共有 + ST/IO 等価
+    -- (Phase 50 実証) により pure 経路とビット一致するのが設計の柱。
+    it "nutsChainsStream: no-op callback で nutsChainsPure とビット一致" $ do
+      ioChains <- NUTS.nutsChainsStream model cfg 3 initC 99 (\_ _ -> pure ())
+      let pureChains = NUTS.nutsChainsPure model cfg 3 initC 99
+      map Core.chainSamples ioChains
+        `shouldBe` map Core.chainSamples pureChains
+
+    it "nutsChainsStream: callback が chain ごとに total 回 (burnIn 込み) 呼ばれる" $ do
+      countsRef <- newIORef (M.empty :: M.Map Int Int)
+      _ <- NUTS.nutsChainsStream model cfg 2 initC 7
+             (\i _ -> modifyIORef' countsRef (M.insertWith (+) i 1))
+      counts <- readIORef countsRef
+      let total = NUTS.nutsBurnIn cfg + NUTS.nutsIterations cfg
+      counts `shouldBe` M.fromList [(0, total), (1, total)]
+
+  -- Phase 53: gradADU の正しさを中心差分 (ground truth) で検証。
+  -- ★前進/逆 AD どちらでも勾配は数学的に同一ゆえ、 reverse 化の前後で
+  --   同じテストが通る = 切替が正しさを保存することの担保。
diff --git a/test/Hanalyze/MCMC/ProgressSpec.hs b/test/Hanalyze/MCMC/ProgressSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/MCMC/ProgressSpec.hs
@@ -0,0 +1,17 @@
+{-# LANGUAGE OverloadedStrings #-}
+-- | Hanalyze.MCMC.Progress の純粋部 (formatProgress) のテスト (Phase 61.2)。
+module Hanalyze.MCMC.ProgressSpec (spec) where
+
+import Test.Hspec
+import Hanalyze.MCMC.Progress
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.MCMC.Progress.formatProgress" $ do
+    it "warmup 中: 計画 md の例どおりの 1 行" $
+      formatProgress (ProgressSnapshot 4 2 3400 8000 True 12 380.0)
+        `shouldBe` "chains 2/4 done | draw 3400/8000 (warmup) | div 12 | 380.0 it/s"
+
+    it "warmup 後: (warmup) タグが消える" $
+      formatProgress (ProgressSnapshot 4 4 8000 8000 False 0 95.25)
+        `shouldBe` "chains 4/4 done | draw 8000/8000 | div 0 | 95.2 it/s"
diff --git a/test/Hanalyze/MCMC/SMCSpec.hs b/test/Hanalyze/MCMC/SMCSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/MCMC/SMCSpec.hs
@@ -0,0 +1,71 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.MCMC.SMCSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Data.Map.Strict as M
+import qualified System.Random.MWC as MWC
+import qualified Hanalyze.Model.Core        as Core
+import qualified System.Random.MWC as MWC
+import qualified Hanalyze.MCMC.SMC  as SMC
+import qualified Hanalyze.MCMC.Core as Core
+import qualified Hanalyze.Model.HBM as HBM
+import qualified Data.Map.Strict    as M
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.MCMC.SMC (Phase 29-A1 tempered Sequential Monte Carlo)" $ do
+    -- Simple Gaussian model: μ ~ N(0, 10)、 y ~ N(μ, 1)、 obs y = 5.0 (n=10)
+    -- Analytic posterior: μ | y ~ N(μ_post, σ_post²)、
+    --   σ_post² = 1 / (1/100 + 10/1) = 1/10.01 ≈ 0.0999
+    --   μ_post = σ_post² · (0/100 + 10·5/1) = 0.0999·50 ≈ 4.995
+    -- 解析解との比較 (5% tolerance)
+    let modelSMC :: HBM.ModelP ()
+        modelSMC = do
+          mu <- HBM.sample "mu" (HBM.Normal 0 10)
+          HBM.observe "y" (HBM.Normal mu 1) (replicate 10 5.0)
+        cfg = (SMC.defaultSMCConfig ["mu"])
+          { SMC.smcNParticles   = 500
+          , SMC.smcNSteps       = 15
+          , SMC.smcMHIterations = 8
+          , SMC.smcMHStepSize   = M.fromList [("mu", 0.5)]
+          , SMC.smcInitJitter   = 5.0
+          }
+    it "SMC: 粒子数 = N、 log marginal が finite" $ do
+      gen <- MWC.create
+      res <- SMC.smc modelSMC cfg (M.fromList [("mu", 0)]) gen
+      length (Core.chainSamples (SMC.smcChain res)) `shouldBe` 500
+      SMC.smcLogMarginal res `shouldSatisfy` (not . isInfinite)
+      SMC.smcLogMarginal res `shouldSatisfy` (not . isNaN)
+    it "SMC: posterior mean が解析解 (4.995) と 5% 以内一致" $ do
+      gen <- MWC.create
+      res <- SMC.smc modelSMC cfg (M.fromList [("mu", 0)]) gen
+      case Core.posteriorMean "mu" (SMC.smcChain res) of
+        Just mu -> abs (mu - 4.995) `shouldSatisfy` (< 0.25)  -- 5% of 5
+        Nothing -> expectationFailure "posterior mean not available"
+    it "SMC: posterior SD が解析解 (0.316) と妥当範囲内" $ do
+      gen <- MWC.create
+      res <- SMC.smc modelSMC cfg (M.fromList [("mu", 0)]) gen
+      case Core.posteriorSD "mu" (SMC.smcChain res) of
+        -- 真値 sqrt(0.0999) ≈ 0.316、 SMC の N=500 サンプリング誤差で 50% tol
+        Just sd -> sd `shouldSatisfy` (\s -> s > 0.15 && s < 0.6)
+        Nothing -> expectationFailure "posterior SD not available"
diff --git a/test/Hanalyze/MCMC/SamplersPureSpec.hs b/test/Hanalyze/MCMC/SamplersPureSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/MCMC/SamplersPureSpec.hs
@@ -0,0 +1,95 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.MCMC.SamplersPureSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Data.Vector as V
+import qualified Data.Map.Strict as M
+import qualified System.Random.MWC as MWC
+import qualified Hanalyze.Model.Core        as Core
+import qualified System.Random.MWC as MWC
+import qualified Hanalyze.MCMC.SMC  as SMC
+import qualified Hanalyze.MCMC.MH    as MH
+import qualified Hanalyze.MCMC.Slice as Slice
+import qualified Hanalyze.MCMC.HMC   as HMC
+import qualified Hanalyze.MCMC.Gibbs as Gibbs
+import qualified Hanalyze.MCMC.Core as Core
+import qualified Hanalyze.Model.HBM as HBM
+import qualified Data.Map.Strict    as M
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "他サンプラ純粋版 (Phase 50.6-50.10: MH/Slice/HMC/Gibbs/SMC)" $ do
+    let model :: HBM.ModelP ()
+        model = do
+          mu <- HBM.sample "mu" (HBM.Normal 0 10)
+          HBM.observe "y" (HBM.Normal mu 1) (replicate 8 5.0)
+        initC = M.fromList [("mu", 0)]
+        samp  = Core.chainSamples
+
+    it "metropolisPure: 同 seed ビット同一 / IO 版と ST 等価" $ do
+      let c = MH.defaultMCMCConfig ["mu"]
+          cfg = c { MH.mcmcIterations = 40, MH.mcmcBurnIn = 20 }
+      samp (MH.metropolisPure model cfg initC 11)
+        `shouldBe` samp (MH.metropolisPure model cfg initC 11)
+      gen <- MWC.initialize (V.singleton 11)
+      ioC <- MH.metropolis model cfg initC gen
+      samp (MH.metropolisPure model cfg initC 11) `shouldBe` samp ioC
+
+    it "metropolisChainsPure: 3 本・再現性・子 seed で相異" $ do
+      let cfg = (MH.defaultMCMCConfig ["mu"]) { MH.mcmcIterations = 30, MH.mcmcBurnIn = 10 }
+          chains = MH.metropolisChainsPure model cfg 3 initC 7
+      length chains `shouldBe` 3
+      map samp (MH.metropolisChainsPure model cfg 3 initC 7) `shouldBe` map samp chains
+
+    it "slicePure: 同 seed ビット同一 / IO 版と ST 等価" $ do
+      let cfg = (Slice.defaultSliceConfig ["mu"])
+                  { Slice.sliceIterations = 30, Slice.sliceBurnIn = 10 }
+      samp (Slice.slicePure model cfg initC 22)
+        `shouldBe` samp (Slice.slicePure model cfg initC 22)
+      gen <- MWC.initialize (V.singleton 22)
+      ioC <- Slice.slice model cfg initC gen
+      samp (Slice.slicePure model cfg initC 22) `shouldBe` samp ioC
+
+    it "hmcPure: 同 seed ビット同一 / IO 版と ST 等価" $ do
+      let cfg = HMC.defaultHMCConfig { HMC.hmcIterations = 30, HMC.hmcBurnIn = 10 }
+      samp (HMC.hmcPure model cfg initC 33)
+        `shouldBe` samp (HMC.hmcPure model cfg initC 33)
+      gen <- MWC.initialize (V.singleton 33)
+      ioC <- HMC.hmc model cfg initC gen
+      samp (HMC.hmcPure model cfg initC 33) `shouldBe` samp ioC
+
+    it "gibbsBetaBinomialPure: 同 seed ビット同一 / IO 版と ST 等価" $ do
+      let cfg = Gibbs.defaultGibbsConfig { Gibbs.gibbsIterations = 50, Gibbs.gibbsBurnIn = 10 }
+      samp (Gibbs.gibbsBetaBinomialPure "p" 2 2 10 7 cfg 44)
+        `shouldBe` samp (Gibbs.gibbsBetaBinomialPure "p" 2 2 10 7 cfg 44)
+      gen <- MWC.initialize (V.singleton 44)
+      ioC <- Gibbs.gibbsBetaBinomial "p" 2 2 10 7 cfg gen
+      samp (Gibbs.gibbsBetaBinomialPure "p" 2 2 10 7 cfg 44) `shouldBe` samp ioC
+
+    it "smcPure: 同 seed で SMCResult (chain) ビット同一" $ do
+      let cfg = (SMC.defaultSMCConfig ["mu"])
+                  { SMC.smcNParticles = 100, SMC.smcNSteps = 5, SMC.smcMHIterations = 3 }
+          r1 = SMC.smcPure model cfg initC 9
+          r2 = SMC.smcPure model cfg initC 9
+      samp (SMC.smcChain r1) `shouldBe` samp (SMC.smcChain r2)
+      SMC.smcLogMarginal r1 `shouldBe` SMC.smcLogMarginal r2
diff --git a/test/Hanalyze/Math/HSICSpec.hs b/test/Hanalyze/Math/HSICSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Math/HSICSpec.hs
@@ -0,0 +1,43 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Math.HSICSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Numeric.LinearAlgebra as LA
+import qualified Hanalyze.Stat.QuasiRandom  as QR
+import qualified Hanalyze.Math.HSIC                   as HSIC
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Math.HSIC" $ do
+    it "完全従属 (X = Y) で独立データより HSIC が大きい" $ do
+      let halton b k = QR.radicalInverse b k - 0.5
+          xs = LA.asColumn (LA.fromList [halton 2 i | i <- [1..200]])
+          ys = LA.asColumn (LA.fromList [halton 7 i | i <- [1..200]])
+          dep = HSIC.hsicBiased xs xs    -- 同データ = 完全従属
+          ind = HSIC.hsicBiased xs ys    -- 異独立データ
+      dep `shouldSatisfy` (> ind)
+      ind `shouldSatisfy` (< 0.05)
+
+    it "medianBandwidth は正値" $ do
+      let xs = LA.asColumn (LA.fromList [QR.radicalInverse 2 i | i <- [1..50]])
+      HSIC.medianBandwidth xs `shouldSatisfy` (> 0)
diff --git a/test/Hanalyze/Math/HungarianSpec.hs b/test/Hanalyze/Math/HungarianSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Math/HungarianSpec.hs
@@ -0,0 +1,58 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Math.HungarianSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Numeric.LinearAlgebra as LA
+import qualified Data.Vector.Unboxed        as VU
+import qualified Hanalyze.Math.Hungarian              as Hung
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Math.Hungarian" $ do
+    it "4×4 既知最適: 対角コスト最小割当を当てる" $ do
+      -- C[i,j] = 0 (i == j)、 それ以外は 1。 最適は assignment[i] = i、 cost 0。
+      let c = LA.build (4, 4) (\i j ->
+            if (round i :: Int) == (round j :: Int) then 0.0 else 1.0)
+          a = Hung.hungarianMin c
+          total = sum [ LA.atIndex c (i, a VU.! i) | i <- [0 .. 3] ]
+      VU.toList a `shouldBe` [0, 1, 2, 3]
+      total `shouldBe` 0.0
+
+    it "順列が必要な行列でも全単射な最適解を返す" $ do
+      -- 既知最適: assignment = [3, 2, 1, 0]、 cost 4。
+      let rows = [ [9, 9, 9, 1]
+                 , [9, 9, 1, 9]
+                 , [9, 1, 9, 9]
+                 , [1, 9, 9, 9]
+                 ]
+          c = LA.fromLists rows :: LA.Matrix Double
+          a = Hung.hungarianMin c
+          total = sum [ LA.atIndex c (i, a VU.! i) | i <- [0 .. 3] ]
+      VU.toList a `shouldBe` [3, 2, 1, 0]
+      total `shouldBe` 4.0
+      -- 全単射 (重複なし)
+      length (VU.toList a) `shouldBe` length (nub (VU.toList a))
+
+    it "1×1 と空行列の境界" $ do
+      VU.toList (Hung.hungarianMin (LA.fromLists [[42.0]])) `shouldBe` [0]
+      VU.toList (Hung.hungarianMin ((0 LA.>< 0) [])) `shouldBe` []
diff --git a/test/Hanalyze/Model/AFTSpec.hs b/test/Hanalyze/Model/AFTSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Model/AFTSpec.hs
@@ -0,0 +1,64 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Model.AFTSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Data.Vector as V
+import qualified Data.Text   as T
+import qualified Numeric.LinearAlgebra as LA
+import qualified Hanalyze.Model.AFT         as AFT
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Model.AFT (Phase 12)" $ do
+    let ts = LA.fromList [exp 1.5, exp 1.8, exp 2.0, exp 2.1, exp 2.3,
+                          exp 1.9, exp 2.05, exp 2.15, exp 1.95, exp 2.2]
+        delta = V.replicate 10 True
+        x1 = LA.fromColumns [LA.fromList (replicate 10 1.0)]
+    it "fitAFT LogNormal intercept-only: β₀ ≈ mean(log t)" $ do
+      r <- AFT.fitAFT AFT.AFTLogNormal x1 ts delta
+      case r of
+        Left e -> expectationFailure (T.unpack e)
+        Right fit -> do
+          let b0   = LA.atIndex (AFT.aftBeta fit) 0
+              meanLogT = LA.sumElements (LA.cmap log ts) / fromIntegral (LA.size ts)
+          abs (b0 - meanLogT) `shouldSatisfy` (< 0.05)
+    it "fitAFT Weibull: 打ち切り混在で動く" $ do
+      let delta2 = V.fromList [True, True, False, True, True,
+                               True, False, True, True, True]
+      r <- AFT.fitAFT AFT.AFTWeibull x1 ts delta2
+      case r of
+        Left e -> expectationFailure (T.unpack e)
+        Right fit -> AFT.aftScale fit `shouldSatisfy` (> 0)
+    it "fitAFT 入力長 mismatch は Left" $ do
+      r <- AFT.fitAFT AFT.AFTLogNormal x1 (LA.fromList [1, 2]) delta
+      case r of
+        Left _ -> pure ()
+        Right _ -> expectationFailure "expected Left"
+    it "predictAFT Exponential: E[T] = exp(X β)" $ do
+      r <- AFT.fitAFT AFT.AFTExponential x1 ts delta
+      case r of
+        Left e -> expectationFailure (T.unpack e)
+        Right fit -> do
+          let preds = AFT.predictAFT fit x1
+              b0    = LA.atIndex (AFT.aftBeta fit) 0
+          abs (LA.atIndex preds 0 - exp b0) `shouldSatisfy` (< 1e-6)
diff --git a/test/Hanalyze/Model/ClusterSpec.hs b/test/Hanalyze/Model/ClusterSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Model/ClusterSpec.hs
@@ -0,0 +1,69 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Model.ClusterSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Numeric.LinearAlgebra as LA
+import qualified Hanalyze.Model.Cluster     as Cl
+import qualified System.Random.MWC as MWC
+import qualified System.Random.MWC as MWC
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Model.Cluster" $ do
+    it "kMeans: 2 つの離れたクラスタで正しく分類" $ do
+      gen <- MWC.createSystemRandom
+      -- Cluster 1: around (0, 0); cluster 2: around (10, 10)
+      let xs = LA.fromLists $
+            [[0.1*x, 0.1*y] | x <- [-3..3], y <- [-3..3]] ++
+            [[10 + 0.1*x, 10 + 0.1*y] | x <- [-3..3], y <- [-3..3]]
+          cfg = Cl.defaultKMeans 2
+      r <- Cl.kMeans cfg xs gen
+      LA.rows (Cl.kmrCentroids r) `shouldBe` 2
+      -- 全 49 points がクラスタ 1、49 が クラスタ 2
+      let labels = Cl.kmrLabels r
+          (c0, c1) = (length (filter (== 0) labels), length (filter (== 1) labels))
+      (min c0 c1) `shouldBe` 49
+      (max c0 c1) `shouldBe` 49
+
+    it "silhouette: well-separated clusters で > 0.5" $ do
+      gen <- MWC.createSystemRandom
+      let xs = LA.fromLists $
+            [[0.1*x, 0.1*y] | x <- [-3..3], y <- [-3..3]] ++
+            [[20 + 0.1*x, 20 + 0.1*y] | x <- [-3..3], y <- [-3..3]]
+          cfg = Cl.defaultKMeans 2
+      r <- Cl.kMeans cfg xs gen
+      let s = Cl.silhouette xs (Cl.kmrLabels r)
+      s `shouldSatisfy` (> 0.7)
+
+    it "kMeans: inertia は monotone non-increasing in iter" $ do
+      gen <- MWC.createSystemRandom
+      let xs = LA.fromLists [[fromIntegral i, fromIntegral j]
+                            | i <- [0..9::Int], j <- [0..9::Int]]
+          cfg = (Cl.defaultKMeans 4) { Cl.kmRestarts = 5 }
+      r <- Cl.kMeans cfg xs gen
+      Cl.kmrInertia r `shouldSatisfy` (>= 0)
+      Cl.kmrConverged r `shouldBe` True
+
+  -- ===========================================================================
+  -- Hanalyze.Stat.MultipleTesting (Phase 6)
+  -- ===========================================================================
diff --git a/test/Hanalyze/Model/CompetingRisksSpec.hs b/test/Hanalyze/Model/CompetingRisksSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Model/CompetingRisksSpec.hs
@@ -0,0 +1,64 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Model.CompetingRisksSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Numeric.LinearAlgebra as LA
+import qualified Hanalyze.Model.CompetingRisks as CR
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Model.CompetingRisks (Phase 35-A3)" $ do
+    -- 簡単な手計算例: 5 サンプル、 2 causes、 censoring 含む
+    -- t=1 (cause 1), t=2 (cause 2), t=3 (cause 1), t=4 (censored), t=5 (cause 2)
+    let samples = [ CR.CRSample 1.0 1, CR.CRSample 2.0 2, CR.CRSample 3.0 1
+                  , CR.CRSample 4.0 0, CR.CRSample 5.0 2 ]
+        fit     = CR.fitCompetingRisks samples
+    it "fitCompetingRisks: 検出された causes" $ do
+      CR.crfCauses fit `shouldBe` [1, 2]
+    it "fitCompetingRisks: event times (censored 除外)" $ do
+      LA.toList (CR.crfTimes fit) `shouldBe` [1.0, 2.0, 3.0, 5.0]
+    it "fitCompetingRisks: 全 CIF の和 + 全生存 ≈ 1 (各 event time で)" $ do
+      let s    = LA.toList (CR.crfOverallSurvival fit)
+          cifs = [ LA.toList v | (_, v) <- CR.crfCIF fit ]
+          n    = length s
+          totAt i = sum [ cif !! i | cif <- cifs ] + (s !! i)
+      and [ abs (totAt i - 1.0) < 1e-9 | i <- [0 .. n - 1] ]
+        `shouldBe` True
+    it "fitCompetingRisks: CIF は単調非減少" $ do
+      let nonDec xs = and (zipWith (<=) xs (tail xs))
+      and [ nonDec (LA.toList v) | (_, v) <- CR.crfCIF fit ]
+        `shouldBe` True
+    it "fitCompetingRisks: t=1 で F̂_1 = 1/5 (cause 1、 n=5, d=1)" $ do
+      let Just cif1 = lookup 1 (CR.crfCIF fit)
+      abs (LA.atIndex cif1 0 - 0.2) `shouldSatisfy` (< 1e-9)
+    it "fitCompetingRisks: cause 2 だけのケースは KM 1-S と一致" $ do
+      -- 単一 cause なら CIF = 1 - S (overall) と一致するはず
+      let s2 = [ CR.CRSample 1.0 1, CR.CRSample 2.0 1, CR.CRSample 3.0 0
+               , CR.CRSample 4.0 1 ]
+          fit2 = CR.fitCompetingRisks s2
+          Just cif = lookup 1 (CR.crfCIF fit2)
+          sv = CR.crfOverallSurvival fit2
+          oneMinusS = [ 1 - x | x <- LA.toList sv ]
+      and [ abs (a - b) < 1e-9
+          | (a, b) <- zip (LA.toList cif) oneMinusS ]
+        `shouldBe` True
diff --git a/test/Hanalyze/Model/DAGSpec.hs b/test/Hanalyze/Model/DAGSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Model/DAGSpec.hs
@@ -0,0 +1,51 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Model.DAGSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Numeric.LinearAlgebra as LA
+import qualified Hanalyze.Model.DAG                   as DAG
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Model.DAG (Phase 36-LiNGAM 共通基盤)" $ do
+    it "fromBMatrix + topoSort: 3 ノード DAG の順序" $ do
+      let b = LA.fromLists [[0, 0, 0]
+                           ,[0.8, 0, 0]
+                           ,[0.4, 0.6, 0]]
+          g = DAG.fromBMatrix 0.05 b
+      DAG.topoSort g `shouldBe` Just [0, 1, 2]
+    it "isAcyclic: DAG は True、 cycle 入りなら False" $ do
+      let acyc = DAG.fromBMatrix 0.05
+                   (LA.fromLists [[0,0,0]
+                                 ,[0.5,0,0]
+                                 ,[0,0.5,0]])
+          cyc  = DAG.fromBMatrix 0.05
+                   (LA.fromLists [[0,0.5,0]
+                                 ,[0,0,0.5]
+                                 ,[0.5,0,0]])
+      DAG.isAcyclic acyc `shouldBe` True
+      DAG.isAcyclic cyc  `shouldBe` False
+    it "dagEdges: エッジ数が非零要素数と一致" $ do
+      let b = LA.fromLists [[0,0,0],[0.5,0,0],[0.3,0.4,0]]
+          g = DAG.fromBMatrix 0.05 b
+      length (DAG.dagEdges g) `shouldBe` 3
diff --git a/test/Hanalyze/Model/DecisionTreeSpec.hs b/test/Hanalyze/Model/DecisionTreeSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Model/DecisionTreeSpec.hs
@@ -0,0 +1,113 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Model.DecisionTreeSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Hanalyze.Model.DecisionTree as DT
+import qualified Data.Text as T
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Model.DecisionTree" $ do
+    it "fitDT: 線形分離可能なデータで perfect train accuracy" $ do
+      -- y = 0 if x[0] < 5, else 1
+      let xs = [[fromIntegral x] | x <- [1..10::Int]]
+          ys = [if x < 5 then 0 else 1 | x <- [1..10::Int]]
+          tree = DT.fitDT DT.defaultDecisionTree xs ys
+          preds = map (DT.predictDT tree) xs
+      preds `shouldBe` ys
+
+    it "fitDT: 2D XOR-like パターン" $ do
+      let xs = [[0, 0], [0, 1], [1, 0], [1, 1]]
+          ys = [0, 1, 1, 0]  -- XOR
+          tree = DT.fitDT DT.defaultDecisionTree xs ys
+          preds = map (DT.predictDT tree) xs
+      preds `shouldBe` ys
+
+    it "predictDT: 混在葉は多数派 (argmax) を予測する (argmin 回帰バグ防止)" $ do
+      -- depth=0 に制限し root 自身を混在葉にする。 x に依らずクラス 1 が多数 (7:3)。
+      -- 旧バグ (argmin) だと少数派クラス 0 を返していた。
+      let cfg  = DT.defaultDecisionTree { DT.dtMaxDepth = Just 0 }
+          xs   = [[fromIntegral i] | i <- [1..10::Int]]
+          ys   = [0,0,0, 1,1,1,1,1,1,1]   -- class 1 が 7、 class 0 が 3。
+          tree = DT.fitDT cfg xs ys
+      map (DT.predictDT tree) xs `shouldBe` replicate 10 1
+      let xs = [[1.0], [2.0], [3.0]]
+          ys = [0, 0, 1]
+          tree = DT.fitDT DT.defaultDecisionTree xs ys
+          probs = DT.predictDTProbs tree [1.5]
+      -- x=1.5 should reach a leaf where most samples are class 0
+      probs `shouldSatisfy` (\m -> length m >= 1)
+
+    it "giniImpurity: 純粋クラスで 0、均等で 0.5 (2 クラス)" $ do
+      DT.giniImpurity [0, 0, 0, 0] `shouldBe` 0.0
+      DT.giniImpurity [1, 1, 1, 1] `shouldBe` 0.0
+      DT.giniImpurity [0, 0, 1, 1] `shouldBe` 0.5
+
+    it "maxDepth=1: shallow tree、underfit に近い" $ do
+      let cfg = DT.defaultDecisionTree { DT.dtMaxDepth = Just 1 }
+          xs = [[fromIntegral x, fromIntegral y]
+               | x <- [1..5::Int], y <- [1..5::Int]]
+          ys = [if x + y > 5 then 1 else 0
+               | x <- [1..5::Int], y <- [1..5::Int]]
+          tree = DT.fitDT cfg xs ys
+      -- maxDepth=1 → 1 split, root = decision node
+      case tree of
+        DT.DNode {} -> True `shouldBe` True
+        _           -> expectationFailure "Expected DNode at root"
+
+    it "printRpart: R print.rpart 形式 (node#/split/n/loss/yval/yprob/*)" $ do
+      -- 3 クラス・特徴 1 (petal_width 風) で完全分離する木。
+      let xs = [[1.4,0.2],[1.3,0.2],[1.5,0.2],[1.4,0.3]
+               ,[4.5,1.5],[4.7,1.4],[4.9,1.5],[4.0,1.3]
+               ,[6.0,2.5],[5.8,2.2],[6.3,1.8],[5.5,2.1]]
+          ys = [0,0,0,0, 1,1,1,1, 2,2,2,2]
+          tree = DT.fitDT DT.defaultDecisionTree xs ys
+          out  = DT.printRpartRaw ["petal_length","petal_width"]
+                               ["setosa","versicolor","virginica"] tree
+          ls   = lines (T.unpack out)
+      -- ヘッダ (n= / legend / * denotes)。
+      ls `shouldContain` ["n= 12"]
+      ls `shouldContain` ["node), split, n, loss, yval, (yprob)"]
+      -- root は node# 1・split=root・n=12・loss=8・yval=setosa (全クラス同数の tie)。
+      ls `shouldContain` ["1) root 12 8 setosa (0.3333 0.3333 0.3333)"]
+      -- 左枝 (≤・条件成立) = "name< thr"・終端 * 付き・純粋クラス確率。
+      ls `shouldContain`
+        ["  2) petal_width< 0.80 4 0 setosa (1.0000 0.0000 0.0000) *"]
+      -- 右枝 = "name>=thr"・内部ノード (* 無し)。
+      ls `shouldContain` ["  3) petal_width>=0.80 8 4 versicolor (0.0000 0.5000 0.5000)"]
+      -- node# は R 慣例 root=1・子 2k/2k+1 → node 3 の子は 6/7。
+      ls `shouldContain` ["    6) petal_width< 1.65 4 0 versicolor (0.0000 1.0000 0.0000) *"]
+      ls `shouldContain` ["    7) petal_width>=1.65 4 0 virginica (0.0000 0.0000 1.0000) *"]
+
+    it "printRpart: 列名・クラス名なしは f{i}/整数へフォールバック" $ do
+      let xs = [[1.4,0.2],[1.3,0.2],[4.5,1.5],[4.7,1.4]]
+          ys = [0,0,1,1]
+          tree = DT.fitDT DT.defaultDecisionTree xs ys
+          out  = DT.printRpartRaw [] [] tree
+      -- 特徴 index 1 → f1、 クラス → 整数のまま。
+      out `shouldSatisfy` \t -> "f" `T.isInfixOf` t
+                             && "root 4 2 0" `T.isInfixOf` t
+
+  -- ===========================================================================
+  -- Hanalyze.Model.TimeSeries (Phase 11)
+  -- ===========================================================================
diff --git a/test/Hanalyze/Model/DiscriminantSpec.hs b/test/Hanalyze/Model/DiscriminantSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Model/DiscriminantSpec.hs
@@ -0,0 +1,90 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Model.DiscriminantSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Data.Vector as V
+import qualified Data.Text   as T
+import qualified Numeric.LinearAlgebra as LA
+import qualified System.Random.MWC as MWC
+import qualified System.Random.MWC as MWC
+import qualified Hanalyze.Model.Discriminant as LDA
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Model.Discriminant (Phase 10 LDA/QDA)" $ do
+    -- 2 クラス × 2 特徴量、 明確に分離
+    let classData :: IO (LA.Matrix Double, V.Vector Int)
+        classData = do
+          gen <- MWC.create
+          xs0 <- sequence
+                   [ sequence [MWC.uniformR (0, 1) gen, MWC.uniformR (0, 1) gen]
+                   | _ <- [1..20 :: Int] ]
+          xs1 <- sequence
+                   [ sequence [MWC.uniformR (3, 4) gen, MWC.uniformR (3, 4) gen]
+                   | _ <- [1..20 :: Int] ]
+          pure ( LA.fromLists (xs0 ++ xs1)
+               , V.fromList (replicate 20 0 ++ replicate 20 1)
+               )
+
+    it "fitLDA: 2 クラスで fit、 shape 整合" $ do
+      (xs, ys) <- classData
+      case LDA.fitLDA xs ys of
+        Left e  -> expectationFailure (T.unpack e)
+        Right f -> do
+          LA.rows (LDA.dfMeans f) `shouldBe` 2
+          LA.cols (LDA.dfMeans f) `shouldBe` 2
+          LDA.dfMethod f `shouldBe` LDA.LDA
+          LA.size (LDA.dfPriors f) `shouldBe` 2
+
+    it "fitLDA + predict: 訓練データで自己判別精度 = 100%" $ do
+      (xs, ys) <- classData
+      case LDA.fitLDA xs ys of
+        Left e  -> expectationFailure (T.unpack e)
+        Right f -> do
+          let (preds, _) = LDA.predictDiscriminant f xs
+              matches = V.length (V.filter id (V.zipWith (==) preds ys))
+          matches `shouldBe` V.length ys
+
+    it "fitQDA: 2 クラスで fit、 dfCovariances 長さ = K" $ do
+      (xs, ys) <- classData
+      case LDA.fitQDA xs ys of
+        Left e  -> expectationFailure (T.unpack e)
+        Right f -> do
+          LDA.dfMethod f `shouldBe` LDA.QDA
+          length (LDA.dfCovariances f) `shouldBe` 2
+
+    it "predictDiscriminant: posterior は行ごとに sum = 1" $ do
+      (xs, ys) <- classData
+      case LDA.fitLDA xs ys of
+        Left e -> expectationFailure (T.unpack e)
+        Right f -> do
+          let (_, posts) = LDA.predictDiscriminant f xs
+              rowSums = [LA.sumElements (posts LA.! i) | i <- [0 .. LA.rows posts - 1]]
+          all (\s -> abs (s - 1) < 1e-9) rowSums `shouldBe` True
+
+    it "fitLDA: 単一クラスは Left" $ do
+      let xs = LA.fromLists [[1,1],[2,2],[3,3]]
+          ys = V.fromList [0, 0, 0 :: Int]
+      case LDA.fitLDA xs ys of
+        Left _ -> pure ()
+        Right _ -> expectationFailure "expected Left for single class"
diff --git a/test/Hanalyze/Model/FDASpec.hs b/test/Hanalyze/Model/FDASpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Model/FDASpec.hs
@@ -0,0 +1,137 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Model.FDASpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Numeric.LinearAlgebra as LA
+import qualified Data.Vector.Storable              as VS
+import qualified System.Random.MWC as MWC
+import qualified System.Random.MWC as MWC
+import qualified Hanalyze.Model.FDA                   as FDA
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Model.FDA (Phase 33 Functional Data Analysis)" $ do
+    -- 共通: t grid [0, 1] を 50 点、 内部 knots 8 点で B-spline degree=3
+    let nGrid = 50
+        tList = [fromIntegral i / fromIntegral (nGrid - 1)
+                | i <- [0 .. nGrid - 1]]
+        tGrid = LA.fromList tList
+        -- bsplineBasis は境界も含むノット列を期待 (Spline.hs §bsplineBasis 注釈)
+        intKnots = [fromIntegral i / 11 | i <- [0 .. 11]]
+        basis = FDA.BSpline 3 intKnots
+    it "smoothBasis: noisy sinusoid を 12% RMSE 以内で復元 (Phase 33-A1)" $ do
+      gen <- MWC.create
+      let trueFn t = sin (2 * pi * t)
+          nSamp = 5
+      -- 同じ true 関数 + 異なる noise の 5 sample
+      noisyRows <- mapM (\_ -> do
+        ns <- VS.replicateM nGrid (do
+                u1 <- MWC.uniformR (1e-9, 1.0 :: Double) gen
+                u2 <- MWC.uniformR (0.0, 1.0 :: Double) gen
+                pure (0.1 * sqrt (-2 * log u1) * cos (2 * pi * u2)))
+        pure (zipWith (\t e -> trueFn t + e) tList (VS.toList ns)))
+        [1 .. nSamp]
+      let yMat = LA.fromLists noisyRows
+          fits = FDA.smoothBasis basis 1e-3 tGrid yMat
+      length fits `shouldBe` nSamp
+      -- 復元値と真値の RMSE
+      let evals = [LA.toList (FDA.evalFunctional fit tGrid) | fit <- fits]
+          trueVals = map trueFn tList
+          rmse vs = sqrt (sum [(v - tv)^(2::Int) | (v, tv) <- zip vs trueVals]
+                           / fromIntegral nGrid)
+      all (\v -> rmse v < 0.12) evals `shouldBe` True
+    it "smoothBasis: 大 λ で over-smooth (= 直線近似)、 小 λ で interpolate" $ do
+      -- データ自体に二次的な変動が必要、 小 λ では当てはまる、 大 λ では潰す
+      let trueFn t = sin (4 * pi * t)
+          yRow = map trueFn tList
+          yMat = LA.fromLists [yRow]
+          fitSmall = head (FDA.smoothBasis basis 1e-6 tGrid yMat)
+          fitBig   = head (FDA.smoothBasis basis 1e8  tGrid yMat)
+          residSmall = sum [(v - r)^(2::Int)
+                           | (v, r) <- zip (LA.toList (FDA.evalFunctional fitSmall tGrid)) yRow]
+          residBig   = sum [(v - r)^(2::Int)
+                           | (v, r) <- zip (LA.toList (FDA.evalFunctional fitBig tGrid)) yRow]
+      -- 小 λ の方が残差小、 大 λ は明確に大きい
+      residSmall `shouldSatisfy` (< residBig / 10)
+    it "functionalPCA: 2 成分で分散 90% 以上 + 主成分関数の符号一致 (Phase 33-A2)" $ do
+      gen <- MWC.create
+      let nSamp = 80
+      -- DGP: x_i(t) = c1_i · sin(2πt) + c2_i · cos(2πt) + small noise
+      --     c1, c2 ~ N(0, 1) 独立
+      curves <- mapM (\_ -> do
+        u1a <- MWC.uniformR (1e-9, 1.0 :: Double) gen
+        u2a <- MWC.uniformR (0.0, 1.0 :: Double) gen
+        u1b <- MWC.uniformR (1e-9, 1.0 :: Double) gen
+        u2b <- MWC.uniformR (0.0, 1.0 :: Double) gen
+        let c1 = sqrt (-2 * log u1a) * cos (2 * pi * u2a)
+            c2 = sqrt (-2 * log u1b) * cos (2 * pi * u2b)
+        ns <- VS.replicateM nGrid (do
+                u1 <- MWC.uniformR (1e-9, 1.0 :: Double) gen
+                u2 <- MWC.uniformR (0.0, 1.0 :: Double) gen
+                pure (0.05 * sqrt (-2 * log u1) * cos (2 * pi * u2)))
+        pure [ c1 * sin (2 * pi * t) + c2 * cos (2 * pi * t) + e
+             | (t, e) <- zip tList (VS.toList ns) ])
+        [1 .. nSamp]
+      let yMat = LA.fromLists curves
+          fits = FDA.smoothBasis basis 1e-4 tGrid yMat
+          pca  = FDA.functionalPCA 5 fits
+          vals = LA.toList (FDA.fpcaEigenvalues pca)
+      length vals `shouldSatisfy` (>= 2)
+      -- 上位 2 成分が分散 90% 以上を説明
+      let top2 = sum (take 2 vals)
+          total = sum vals
+      (top2 / total) `shouldSatisfy` (> 0.9)
+      -- 主成分関数を grid 上で取り、 sin / cos との相関を見る
+      LA.rows (FDA.fpcaEigenfn pca) `shouldSatisfy` (>= 2)
+    it "fLM: 既知 β(t) = sin(2πt) を回復 (R² > 0.85、 Phase 33-A3)" $ do
+      gen <- MWC.create
+      let nSamp = 60
+          alphaTrue = 0.5
+          betaFn t = sin (2 * pi * t)
+          dt = head tList - head (tail tList)  -- not used; trapezoidal は内部
+      -- x_i(t) = s1·sin(2πt) + s2·cos(2πt)  (β と s1 が orthogonal でない設計)
+      -- → ∫x_i β dt = s1·∫sin²(2πt) dt + 0 = 0.5·s1
+      curves <- mapM (\_ -> do
+        u1a <- MWC.uniformR (1e-9, 1.0 :: Double) gen
+        u2a <- MWC.uniformR (0.0, 1.0 :: Double) gen
+        u1b <- MWC.uniformR (1e-9, 1.0 :: Double) gen
+        u2b <- MWC.uniformR (0.0, 1.0 :: Double) gen
+        let s1 = sqrt (-2 * log u1a) * cos (2 * pi * u2a)
+            s2 = sqrt (-2 * log u1b) * cos (2 * pi * u2b)
+        pure [s1 * sin (2 * pi * t) + s2 * cos (2 * pi * t) | t <- tList])
+        [1 .. nSamp]
+      ns <- VS.replicateM nSamp (do
+              u1 <- MWC.uniformR (1e-9, 1.0 :: Double) gen
+              u2 <- MWC.uniformR (0.0, 1.0 :: Double) gen
+              pure (0.05 * sqrt (-2 * log u1) * cos (2 * pi * u2)))
+      let dtVal = 1.0 / fromIntegral (nGrid - 1)
+          intXBeta xs = dtVal *
+            sum [ x * betaFn t | (x, t) <- zip xs tList ]   -- 簡易 trap
+          ys = LA.fromList [alphaTrue + intXBeta x + e
+                           | (x, e) <- zip curves (VS.toList ns)]
+          yMat = LA.fromLists curves
+          fits = FDA.smoothBasis basis 1e-4 tGrid yMat
+          flm  = FDA.fLM fits ys 1e-3
+      FDA.flmR2 flm `shouldSatisfy` (> 0.85)
+      abs (FDA.flmAlpha flm - alphaTrue) `shouldSatisfy` (< 0.2)
+      LA.size (FDA.flmBetaFn flm) `shouldBe` nGrid
diff --git a/test/Hanalyze/Model/FitYByXSpec.hs b/test/Hanalyze/Model/FitYByXSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Model/FitYByXSpec.hs
@@ -0,0 +1,70 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Model.FitYByXSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Data.Text   as T
+import qualified Numeric.LinearAlgebra as LA
+import qualified Hanalyze.Stat.Test         as ST
+import qualified Hanalyze.Model.FitYByX     as FXY
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Model.FitYByX (Phase 13.4)" $ do
+    let xCont = LA.fromList [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
+        yCont = LA.fromList [2.1, 4.0, 5.9, 8.1, 10.0, 11.9, 14.1, 16.0, 18.0, 20.1]
+        xCat  = LA.fromList [0, 0, 0, 1, 1, 1, 2, 2, 2]
+        yByCat = LA.fromList [1, 2, 1.5, 10, 11, 10.5, 20, 21, 20.5]
+        xCat2 = LA.fromList [0, 0, 1, 1, 0, 1]
+        yCat2 = LA.fromList [0, 0, 1, 1, 0, 1]
+        xBin  = LA.fromList [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]
+        yBin  = LA.fromList [0, 0, 0, 0, 1, 1, 1, 1]
+    it "FitContCont: y ≈ 2x で線形" $ do
+      case FXY.fitYByX FXY.Continuous FXY.Continuous xCont yCont of
+        Left e -> expectationFailure (T.unpack e)
+        Right (FXY.FitContCont _) -> pure ()
+        Right _ -> expectationFailure "wrong variant"
+    it "FitCatCont: 3 group ANOVA で強く有意" $ do
+      case FXY.fitYByX FXY.Categorical FXY.Continuous xCat yByCat of
+        Left e -> expectationFailure (T.unpack e)
+        Right (FXY.FitCatCont tr means) -> do
+          ST.trPValue tr `shouldSatisfy` (< 0.001)
+          length means `shouldBe` 3
+        Right _ -> expectationFailure "wrong variant"
+    it "FitCatCat: 完全相関で chi-square 棄却" $ do
+      case FXY.fitYByX FXY.Categorical FXY.Categorical xCat2 yCat2 of
+        Left e -> expectationFailure (T.unpack e)
+        Right (FXY.FitCatCat tr) -> ST.trPValue tr `shouldSatisfy` (< 0.05)
+        Right _ -> expectationFailure "wrong variant"
+    it "FitContCat: binary y で logistic GLM 動作" $ do
+      case FXY.fitYByX FXY.Continuous FXY.Categorical xBin yBin of
+        Left e -> expectationFailure (T.unpack e)
+        Right (FXY.FitContCat _) -> pure ()
+        Right _ -> expectationFailure "wrong variant"
+    it "FitContCat: y が 0/1 でないと Left" $ do
+      case FXY.fitYByX FXY.Continuous FXY.Categorical xBin yByCat of
+        Left _  -> pure ()
+        Right _ -> expectationFailure "expected Left"
+    it "長さ mismatch は Left" $ do
+      case FXY.fitYByX FXY.Continuous FXY.Continuous xCont (LA.fromList [1, 2]) of
+        Left _  -> pure ()
+        Right _ -> expectationFailure "expected Left"
diff --git a/test/Hanalyze/Model/Formula/ContrastSpec.hs b/test/Hanalyze/Model/Formula/ContrastSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Model/Formula/ContrastSpec.hs
@@ -0,0 +1,92 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Model.Formula.ContrastSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Data.Text   as T
+import qualified Numeric.LinearAlgebra as LA
+import qualified DataFrame.Internal.Column    as DX
+import qualified DataFrame.Internal.DataFrame  as DX
+import qualified Hanalyze.Model.Core        as Core
+import qualified Hanalyze.MCMC.Core as Core
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Contrast coding (Phase 47 A2)" $ do
+    let frm s = case parseFormula s of Right f -> f; Left e -> error e
+        gs = ["A","A","A","B","B","B","C","C","C"] :: [T.Text]
+        xs = [1,2,3, 1,2,3, 1,2,3] :: [Double]
+        ys = [10,11,12, 20,22,24, 5,6,7] :: [Double]
+        df = DX.fromNamedColumns
+               [ ("y", DX.fromList ys), ("g", DX.fromList gs), ("x", DX.fromList xs) ]
+        fitOf s = case fitLMF (frm s) df of Right (fr, _) -> fr; Left e -> error e
+        yhat    = Core.fittedList
+        close a b = and (zipWith (\p q -> abs (p - q) < 1e-9) a b)
+
+    describe "parameterization 不変 (ŷ/R² は contrast 非依存 = Python 非依存オラクル)" $ do
+      let trt = fitOf "y g = b0 + bg ! g"
+          sm  = fitOf "y g = b0 + bg ! C(g, Sum)"
+          hel = fitOf "y g = b0 + bg ! C(g, Helmert)"
+          pol = fitOf "y g = b0 + bg ! C(g, Poly)"
+      it "Treatment と Sum で ŷ 一致" $ close (yhat trt) (yhat sm) `shouldBe` True
+      it "Treatment と Helmert で ŷ 一致" $ close (yhat trt) (yhat hel) `shouldBe` True
+      it "Treatment と Polynomial で ŷ 一致" $ close (yhat trt) (yhat pol) `shouldBe` True
+      it "Treatment と Sum で R² 一致" $
+        abs (Core.rSquared1 trt - Core.rSquared1 sm) < 1e-9 `shouldBe` True
+      it "ŷ = 群平均 (主効果のみ)" $ do
+        let mean zs = sum zs / fromIntegral (length zs)
+            gm = [ mean [v | (g', v) <- zip gs ys, g' == g] | g <- gs ]
+        close (yhat trt) gm `shouldBe` True
+
+    describe "列数 / 識別性" $ do
+      let dmOf s = case modelFrame (frm s) df >>= designMatrixF (frm s) of
+                     Right (x, _) -> x; Left e -> error e
+      it "切片あり主効果 (Sum) = 切片1 + contrast(k-1)=2 = 3 列" $
+        LA.cols (dmOf "y g = b0 + bg ! C(g, Sum)") `shouldBe` 3
+      it "Sum は満ランク" $
+        LA.rank (dmOf "y g = b0 + bg ! C(g, Sum)") `shouldBe` 3
+
+    describe "contrastMatrix (構造)" $ do
+      it "Treatment k=3: 参照行 (水準0) = [0,0]" $
+        LA.toLists (contrastMatrix Treatment 3) `shouldBe` [[0,0],[1,0],[0,1]]
+      it "Sum k=3: 最終行 = [-1,-1] (sum-to-zero)" $
+        LA.toLists (contrastMatrix Sum 3) `shouldBe` [[1,0],[0,1],[-1,-1]]
+      it "Polynomial k=3: 列直交 (QᵀQ = I)" $ do
+        let m = contrastMatrix Polynomial 3
+            g = LA.tr m LA.<> m
+        and [ abs (g `LA.atIndex` (i, j) - (if i == j then 1 else 0)) < 1e-9
+            | i <- [0, 1], j <- [0, 1] ] `shouldBe` True
+
+    describe "factor×連続 (masked 列は full coding ゆえ contrast 非依存・ŷ 不変)" $ do
+      let trt = fitOf "y g x = b0 + bg ! g + bx ! g * x"
+          sm  = fitOf "y g x = b0 + bg ! C(g, Sum) + bx ! C(g, Sum) * x"
+      it "Treatment と Sum で ŷ 一致" $ close (yhat trt) (yhat sm) `shouldBe` True
+
+    describe "R front-end C(g, Sum) は正本構文と等価" $ do
+      let nat  = fitOf "y g = b0 + bg ! C(g, Sum)"
+          rfit = case parseModel "y ~ C(g, Sum)" >>= \f -> fitLMF f df of
+                   Right (fr, _) -> fr; Left e -> error e
+      it "ŷ 一致" $ close (yhat nat) (yhat rfit) `shouldBe` True
+
+  -- ----------------------------------------------------------------------------
+  -- A3 (Phase 47) weights / offset = WLS
+  -- ----------------------------------------------------------------------------
diff --git a/test/Hanalyze/Model/Formula/DesignSpec.hs b/test/Hanalyze/Model/Formula/DesignSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Model/Formula/DesignSpec.hs
@@ -0,0 +1,127 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Model.Formula.DesignSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Data.Vector as V
+import qualified Data.Text   as T
+import qualified Numeric.LinearAlgebra as LA
+import qualified DataFrame.Internal.Column    as DX
+import qualified DataFrame.Internal.DataFrame  as DX
+import qualified Hanalyze.Model.Spline      as Sp
+import qualified Hanalyze.Model.Core        as Core
+import qualified Hanalyze.MCMC.Core as Core
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Model.Formula.Design (A17 designMatrixF/fitLMF)" $ do
+    let frm s = case parseFormula s of Right f -> f; Left e -> error e
+
+    describe "検証点①: factor×factor 飽和モデル (ŷ = セル平均・満ランク)" $ do
+      let gs = ["A","A","B","B","C","C","A","A","B","B","C","C"] :: [T.Text]
+          ts = ["P","Q","P","Q","P","Q","P","Q","P","Q","P","Q"] :: [T.Text]
+          ys = [10,20,30,40,50,60,12,22,34,44,52,62] :: [Double]
+          df = DX.fromNamedColumns
+                 [ ("y", DX.fromList ys)
+                 , ("g", DX.fromList gs)
+                 , ("t", DX.fromList ts) ]
+          Right (fr, lbls) = fitLMF (frm "y g t = b0 + bg!g + bt!t + bgt!g!t") df
+          cellMean a b = let zs = [v | (gi,ti,v) <- zip3 gs ts ys, gi==a, ti==b]
+                         in sum zs / fromIntegral (length zs)
+          expected = [cellMean a b | (a,b) <- zip gs ts]
+          Right mf = modelFrame (frm "y g t = b0 + bg!g + bt!t + bgt!g!t") df
+          Right (x, _) = designMatrixF (frm "y g t = b0 + bg!g + bt!t + bgt!g!t") mf
+      it "列数 = Kg*Kt = 6 (treatment contrast)" $
+        length lbls `shouldBe` 6
+      it "設計行列が満ランク (制約後)" $
+        LA.rank x `shouldBe` 6
+      it "ŷ = セル平均" $
+        and (zipWith (\p q -> abs (p - q) < 1e-9) (Core.fittedList fr) expected)
+          `shouldBe` True
+
+    describe "線形連続 (既存 OLS と一致)" $ do
+      let df = DX.fromNamedColumns
+                 [ ("y", DX.fromList ([2.1,3.9,6.1,7.9,10.1] :: [Double]))
+                 , ("x", DX.fromList ([1,2,3,4,5] :: [Double])) ]
+          Right (fr, _) = fitLMF (frm "y x = b0 + b1*x") df
+          coefs = LA.toList (Core.coefficientsV fr)
+      it "切片≈0・傾き≈2" $ do
+        abs (coefs !! 0 - 0.02) `shouldSatisfy` (< 1e-6)
+        abs (coefs !! 1 - 2.0)  `shouldSatisfy` (< 1e-6)
+
+    describe "factor×連続 (水準別傾き) は列が水準分" $ do
+      let df = DX.fromNamedColumns
+                 [ ("y", DX.fromList ([1,2,3,4,5,6] :: [Double]))
+                 , ("x", DX.fromList ([1,2,3,4,5,6] :: [Double]))
+                 , ("g", DX.fromList (["A","A","B","B","C","C"] :: [T.Text])) ]
+          Right (_, lbls) = fitLMF (frm "y g x = b0 + bg!g + bs!g*x") df
+      it "切片 + g 主効果2 + g×x 傾き3 (連続 interaction は全水準保持) = 6 列" $
+        length lbls `shouldBe` 6
+
+    describe "線形性検出" $ do
+      let df = DX.fromNamedColumns
+                 [ ("y", DX.fromList ([1,2,3] :: [Double]))
+                 , ("x", DX.fromList ([1,2,3] :: [Double])) ]
+      it "a*exp(-b*x) は非線形ゆえ Left" $
+        fitLMF (frm "y x = a*exp(-b*x)") df `shouldSatisfy` isLeftE
+      it "b0 + b1*x + b2*log x は線形ゆえ Right" $
+        linearityCheck (frm "y x = b0 + b1*x + b2*log x") df `shouldSatisfy` isRightE
+
+    describe "検証点②: 基底展開 (Python 非依存オラクル)" $ do
+      let xs = [0.2,0.5,0.9,1.4,2.0,2.6,3.1,3.7,4.2,4.8] :: [Double]
+          ys = map (\x -> sin x + 0.1*x) xs
+          df = DX.fromNamedColumns
+                 [ ("y", DX.fromList ys), ("x", DX.fromList xs) ]
+      it "poly(x,2) は二次を厳密再現 (y=1+2x+3x² で R²≈1)" $ do
+        let yq = map (\x -> 1 + 2*x + 3*x*x) xs
+            dfq = DX.fromNamedColumns [ ("y", DX.fromList yq), ("x", DX.fromList xs) ]
+            Right (fr, _) = fitLMF (frm "y x = b0 + bp ! poly(x,2)") dfq
+        and (zipWith (\p q -> abs (p - q) < 1e-9) (Core.fittedList fr) yq)
+          `shouldBe` True
+      it "opoly(x,2) は不等間隔でも設計列が直交 (linear ⊥ quadratic・raw poly と別)" $ do
+        -- 不等間隔 3 点。 raw poly(x,2) なら x·x² = 1·1+2·4+5·25 = 134 ≠ 0 で共線だが、
+        -- opoly は実測値を QR 直交化するので linear 列 ⊥ quadratic 列 (内積 ≈ 0)。
+        let xsu = [1.0, 2.0, 5.0] :: [Double]
+            dfu = DX.fromNamedColumns
+                    [ ("y", DX.fromList [0,0,0 :: Double]), ("x", DX.fromList xsu) ]
+            frmR s = case parseRFormula s of Right f -> f; Left e -> error e
+            fR  = frmR "y ~ opoly(x,2)"
+            Right mfu       = modelFrame fR dfu
+            Right (xmat, _) = designMatrixF fR mfu
+            cols            = LA.toColumns xmat
+            [c1, c2]        = drop (length cols - 2) cols   -- opoly の 2 列 (切片を除く)
+        abs (LA.dot c1 c2) `shouldSatisfy` (< 1e-9)
+      it "opoly(x,2) は二次を厳密再現 (raw poly と同 span・ŷ 一致)" $ do
+        let xsu = [1.0, 2.0, 5.0, 7.0, 8.0] :: [Double]
+            yq  = map (\x -> 1 + 2*x + 3*x*x) xsu
+            dfq2 = DX.fromNamedColumns [ ("y", DX.fromList yq), ("x", DX.fromList xsu) ]
+            frmR s = case parseRFormula s of Right f -> f; Left e -> error e
+            Right (fr, _) = fitLMF (frmR "y ~ opoly(x,2)") dfq2
+        and (zipWith (\p q -> abs (p - q) < 1e-9) (Core.fittedList fr) yq)
+          `shouldBe` True
+      it "bspline(x,4) (切片なし) の ŷ = fitSpline (BSpline 3, quantileKnots 4)" $ do
+        let Right (fr, _) = fitLMF (frm "y x = bs ! bspline(x,4)") df
+            sf = Sp.fitSpline (Sp.BSpline 3) (Sp.quantileKnots 4 (V.fromList xs))
+                              (V.fromList xs) (V.fromList ys)
+            yhatSpline = V.toList (Sp.predictSpline sf (V.fromList xs))
+        and (zipWith (\p q -> abs (p - q) < 1e-9) (Core.fittedList fr) yhatSpline)
+          `shouldBe` True
diff --git a/test/Hanalyze/Model/Formula/FrameSpec.hs b/test/Hanalyze/Model/Formula/FrameSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Model/Formula/FrameSpec.hs
@@ -0,0 +1,119 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Model.Formula.FrameSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Data.Vector as V
+import qualified Data.Text   as T
+import qualified DataFrame.Internal.Column    as DX
+import qualified DataFrame.Internal.DataFrame  as DX
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Model.Formula.Frame (A16 ModelFrame)" $ do
+    let dfLM = DX.fromNamedColumns
+          [ ("x",     DX.fromList ([1,2,3,4, 1,2,3,4, 1,2,3,4] :: [Double]))
+          , ("y",     DX.fromList ([7,6,7,7, 5,4,5,5, 3,2,3,3]  :: [Double]))
+          , ("group", DX.fromList (["A","A","A","A","B","B","B","B","C","C","C","C"] :: [T.Text])) ]
+        Right f1 = parseFormula "y x group = b0 + b1*x + bg ! group"
+        Right mf1 = modelFrame f1 dfLM
+
+    it "連続 + factor: パラメータ分離" $
+      mfParams mf1 `shouldBe` ["b0", "b1", "bg"]
+    it "行数 = 応答列長" $
+      mfNRows mf1 `shouldBe` 12
+    it "factor は使われ方 (! の右) で検出・水準は昇順・index 付き" $
+      lookup "group" (mfRoles mf1)
+        `shouldBe` Just (RoleFactor ["A", "B", "C"]
+                          (V.fromList [0,0,0,0, 1,1,1,1, 2,2,2,2]))
+    it "算術中の変数は連続" $
+      lookup "x" (mfRoles mf1)
+        `shouldBe` Just (RoleContinuous (V.fromList [1,2,3,4, 1,2,3,4, 1,2,3,4]))
+
+    describe "factor×factor (検証点① の前段)" $ do
+      let dfGT = DX.fromNamedColumns
+            [ ("y", DX.fromList ([1,2,3,4,5,6] :: [Double]))
+            , ("g", DX.fromList (["A","A","B","B","C","C"] :: [T.Text]))
+            , ("t", DX.fromList (["P","Q","P","Q","P","Q"] :: [T.Text])) ]
+          Right fGT  = parseFormula "y g t = b0 + bg!g + bt!t + bgt!g!t"
+          Right mfGT = modelFrame fGT dfGT
+      it "g, t とも factor・パラメータは b0/bg/bt/bgt" $ do
+        mfParams mfGT `shouldBe` ["b0", "bg", "bt", "bgt"]
+        indexedVars (formRHS fGT) `shouldBe` ["g", "t"]
+
+    describe "error" $ do
+      it "応答列が無ければ Left" $
+        modelFrame f1 (DX.fromNamedColumns
+          [ ("x", DX.fromList ([1,2] :: [Double])) ]) `shouldSatisfy` isLeftE
+      it "連続変数が無ければ Left" $ do
+        let Right fNo = parseFormula "y q = b0 + b1*q"
+        modelFrame fNo dfLM `shouldSatisfy` isLeftE
+
+    describe "MissingPolicy (Phase 47 A1)" $ do
+      let dfNA = DX.fromNamedColumns
+            [ ("x", DX.fromList ([Just 1, Just 2, Nothing, Just 4] :: [Maybe Double]))
+            , ("y", DX.fromList ([10,20,30,40] :: [Double]))
+            , ("g", DX.fromList (["A","B","A","B"] :: [T.Text])) ]
+          Right fNA = parseFormula "y x g = b0 + b1*x + bg ! g"
+          contOf mf = case lookup "x" (mfRoles mf) of
+                        Just (RoleContinuous v) -> V.toList v
+                        _                       -> []
+
+      it "DropRows: NA 行を除外" $ do
+        let Right mf = modelFrameWith DropRows fNA dfNA
+        mfNRows mf `shouldBe` 3
+        contOf mf `shouldBe` [1,2,4]
+      it "modelFrame は DropRows 既定 (後方互換)" $
+        modelFrame fNA dfNA `shouldBe` modelFrameWith DropRows fNA dfNA
+      it "ErrorOnMissing: NA があれば Left" $
+        modelFrameWith ErrorOnMissing fNA dfNA `shouldSatisfy` isLeftE
+      it "Impute ImputeMean: 連続説明変数を平均補完 (行数不変)" $ do
+        let Right mf = modelFrameWith (Impute ImputeMean) fNA dfNA
+        mfNRows mf `shouldBe` 4
+        (contOf mf !! 2) `shouldSatisfy` (\v -> abs (v - 7/3) < 1e-9)  -- mean [1,2,4]
+      it "Impute: 応答の欠損は Left に誘導" $ do
+        let dfYNA = DX.fromNamedColumns
+              [ ("x", DX.fromList ([1,2,3,4] :: [Double]))
+              , ("y", DX.fromList ([Just 10, Nothing, Just 30, Just 40] :: [Maybe Double]))
+              , ("g", DX.fromList (["A","B","A","B"] :: [T.Text])) ]
+        modelFrameWith (Impute ImputeMean) fNA dfYNA `shouldSatisfy` isLeftE
+
+      describe "TreatAsCategory" $ do
+        let dfFNA = DX.fromNamedColumns
+              [ ("y", DX.fromList ([10,20,30,40] :: [Double]))
+              , ("x", DX.fromList ([1,2,3,4]    :: [Double]))
+              , ("g", DX.fromList (["A","B","NA","B"] :: [T.Text])) ]  -- "NA" = 欠損文字列
+        it "factor の NA を独立水準 <NA> として保持 (行数不変・水準は昇順)" $ do
+          let Right mf = modelFrameWith TreatAsCategory fNA dfFNA
+          mfNRows mf `shouldBe` 4
+          lookup "g" (mfRoles mf)
+            `shouldBe` Just (RoleFactor ["<NA>","A","B"] (V.fromList [1,2,0,2]))
+        it "連続列の欠損は TreatAsCategory では埋めず Left に誘導" $ do
+          let dfXFNA = DX.fromNamedColumns
+                [ ("y", DX.fromList ([10,20,30,40] :: [Double]))
+                , ("x", DX.fromList ([Just 1, Nothing, Just 3, Just 4] :: [Maybe Double]))
+                , ("g", DX.fromList (["A","B","A","B"] :: [T.Text])) ]
+          modelFrameWith TreatAsCategory fNA dfXFNA `shouldSatisfy` isLeftE
+
+  -- ----------------------------------------------------------------------------
+  -- A17 designMatrixF / fitLMF — 基底展開 (factor) + 線形性検出 + 識別性
+  -- ----------------------------------------------------------------------------
diff --git a/test/Hanalyze/Model/Formula/MixedSpec.hs b/test/Hanalyze/Model/Formula/MixedSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Model/Formula/MixedSpec.hs
@@ -0,0 +1,106 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Model.Formula.MixedSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Data.Vector as V
+import qualified Data.Text   as T
+import qualified Numeric.LinearAlgebra as LA
+import qualified DataFrame.Internal.Column    as DX
+import qualified DataFrame.Internal.DataFrame  as DX
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Model.Formula.Mixed (Phase 48 A3/A4)" $ do
+    let approxG tol a b = abs (a - b) < tol
+
+    describe "extractRandom: (…|g) ブロック抽出" $ do
+      it "(1|g) = random intercept" $
+        extractRandom "y ~ x + (1|g)"
+          `shouldBe` Right ("y ~ x", [RandomSpec True [] "g"])
+      it "(1+x|g) = random intercept + slope" $
+        extractRandom "y ~ x + (1+x|g)"
+          `shouldBe` Right ("y ~ x", [RandomSpec True ["x"] "g"])
+      it "(0+x|g) = random slope のみ (intercept 抑制)" $
+        extractRandom "y ~ x + (0+x|g)"
+          `shouldBe` Right ("y ~ x", [RandomSpec False ["x"] "g"])
+      it "(1|g) のみ → 固定は intercept 補完" $
+        extractRandom "y ~ (1|g)"
+          `shouldBe` Right ("y ~ 1", [RandomSpec True [] "g"])
+      it "random 項なし → 空リスト" $
+        extractRandom "y ~ x"
+          `shouldBe` Right ("y ~ x", [])
+      it "独自構文 y x = b0 + b1*x + (1|g)" $
+        extractRandom "y x = b0 + b1*x + (1|g)"
+          `shouldBe` Right ("y x = b0 + b1*x", [RandomSpec True [] "g"])
+
+    -- e2e: 3 群 × 4 obs (GLMM describe と同データ)。 (1|group) は
+    -- fitLMEDataFrame の random intercept と同一結果になるはず。
+    let dfL = DX.fromNamedColumns
+                [ ("x",     DX.fromList ([1,2,3,4, 1,2,3,4, 1,2,3,4] :: [Double]))
+                , ("y",     DX.fromList ([7.1,6.9,7.0,7.0, 5.0,4.9,5.1,5.0, 3.0,2.9,3.1,3.0] :: [Double]))
+                , ("group", DX.fromList (["A","A","A","A","B","B","B","B","C","C","C","C"] :: [T.Text])) ]
+
+    describe "fitMixedLME: random intercept (1|group)" $ do
+      let resE = fitMixedLME "y ~ x + (1|group)" dfL
+          ref  = fitLMEDataFrame [("x", 1)] "group" "y" dfL
+      it "returns Right" $
+        resE `shouldSatisfy` either (const False) (const True)
+      it "G is 1×1 and matches fitLMEDataFrame σ²_u" $
+        case (resE, ref) of
+          (Right (r, _), Just rd) ->
+            LA.atIndex (reRandCov r) (0,0) `shouldSatisfy` approxG 1e-6 (glmmRandVar rd)
+          _ -> expectationFailure "expected Right + Just"
+      it "BLUPs match fitLMEDataFrame (random intercept equivalence)" $
+        case (resE, ref) of
+          (Right (r, _), Just rd) ->
+            LA.toList (LA.flatten (reBLUPs r))
+              `shouldSatisfy` (\bs -> and (zipWith (approxG 1e-6) bs (V.toList (glmmBLUPs rd))))
+          _ -> expectationFailure "expected Right + Just"
+      it "fixed coef labels include intercept + x" $
+        case resE of
+          Right (_, labels) -> length labels `shouldBe` 2
+          _                 -> expectationFailure "expected Right"
+
+    describe "fitMixedLME: random slope (1+x|group)" $ do
+      let resE = fitMixedLME "y ~ x + (1+x|group)" dfL
+      it "returns Right with 2×2 G and q×2 BLUPs" $
+        case resE of
+          Right (r, _) -> do
+            LA.size (reRandCov r) `shouldBe` (2, 2)
+            LA.size (reBLUPs r)   `shouldBe` (3, 2)
+          _ -> expectationFailure "expected Right"
+
+    describe "error paths" $ do
+      it "random 項なしは Left" $
+        fitMixedLME "y ~ x" dfL `shouldSatisfy` either (const True) (const False)
+      it "存在しない grouping 列は Left" $
+        fitMixedLME "y ~ x + (1|nosuch)" dfL `shouldSatisfy` either (const True) (const False)
+
+    describe "fitMixedGLMM: Binomial (1|group)" $ do
+      let dfB = DX.fromNamedColumns
+                  [ ("dose",     DX.fromList ([1,2,3,4,5, 1,2,3,4,5, 1,2,3,4,5] :: [Double]))
+                  , ("success",  DX.fromList ([1,1,1,1,1, 1,1,0,1,0, 0,0,0,1,0] :: [Double]))
+                  , ("hospital", DX.fromList (["A","A","A","A","A","B","B","B","B","B","C","C","C","C","C"] :: [T.Text])) ]
+      it "returns Right" $
+        fitMixedGLMM Binomial Logit "success ~ dose + (1|hospital)" dfB
+          `shouldSatisfy` either (const False) (const True)
diff --git a/test/Hanalyze/Model/Formula/NonlinearSpec.hs b/test/Hanalyze/Model/Formula/NonlinearSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Model/Formula/NonlinearSpec.hs
@@ -0,0 +1,58 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Model.Formula.NonlinearSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Data.Text   as T
+import qualified DataFrame.Internal.Column    as DX
+import qualified DataFrame.Internal.DataFrame  as DX
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "非線形フィット NLS (Phase 47 A4)" $ do
+    let frm s = case parseFormula s of Right f -> f; Left e -> error e
+        xs = [0,0.5,1,1.5,2,2.5,3,3.5,4,4.5,5] :: [Double]
+        a0 = 3.0; b0 = 0.5
+        ys = map (\x -> a0 * exp (negate b0 * x)) xs    -- ノイズなし → 完全 recovery 可
+        df = DX.fromNamedColumns [ ("y", DX.fromList ys), ("x", DX.fromList xs) ]
+        f  = frm "y x = a * exp(-b * x)"
+        lookupP k pm = maybe (0/0) id (lookup k pm)
+
+    it "a*exp(-b*x) のパラメータ復元 (ノイズなし、 init a=1 b=1)" $ do
+      let Right r = fitNLS f df [("a",1),("b",1)]
+          pm = nlsParams r
+      (abs (lookupP "a" pm - a0) < 1e-2 && abs (lookupP "b" pm - b0) < 1e-2)
+        `shouldBe` True
+    it "ノイズなしで SSR ≈ 0 かつ収束" $ do
+      let Right r = fitNLS f df [("a",1),("b",1)]
+      (nlsSSR r < 1e-6 && nlsConverged r) `shouldBe` True
+    it "初期値が無いパラメータは Left" $
+      fitNLS f df [("a",1)] `shouldSatisfy` isLeftE
+    it "factor 添字は NLS 非対応 (Left)" $ do
+      let dfg = DX.fromNamedColumns
+                  [ ("y", DX.fromList ([1,2,3,4]::[Double]))
+                  , ("g", DX.fromList (["A","B","A","B"]::[T.Text])) ]
+      fitNLS (frm "y g = a * bg ! g") dfg [("a",1),("bg",1)] `shouldSatisfy` isLeftE
+
+  -- ----------------------------------------------------------------------------
+  -- A18 R/patsy front-end — 同 AST へ + クロス front-end 等価 (検証点④ の Python 非依存半分)
+  -- ----------------------------------------------------------------------------
diff --git a/test/Hanalyze/Model/Formula/RFormulaSpec.hs b/test/Hanalyze/Model/Formula/RFormulaSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Model/Formula/RFormulaSpec.hs
@@ -0,0 +1,68 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Model.Formula.RFormulaSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Data.Text   as T
+import qualified DataFrame.Internal.Column    as DX
+import qualified DataFrame.Internal.DataFrame  as DX
+import qualified Hanalyze.Model.Core        as Core
+import qualified Hanalyze.MCMC.Core as Core
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Model.Formula.RFormula (A18 R front-end)" $ do
+    let gs = ["A","A","B","B","C","C","A","A","B","B","C","C"] :: [T.Text]
+        ts = ["P","Q","P","Q","P","Q","P","Q","P","Q","P","Q"] :: [T.Text]
+        ys = [10,20,30,40,50,60,12,22,34,44,52,62] :: [Double]
+        xs = [1,2,3,4,5,6,1,2,3,4,5,6] :: [Double]
+        df = DX.fromNamedColumns
+               [ ("y", DX.fromList ys), ("g", DX.fromList gs)
+               , ("t", DX.fromList ts), ("x", DX.fromList xs) ]
+        yhat s = case parseModel s >>= \f -> fitLMF f df of
+                   Right (fr, _) -> Right (Core.fittedList fr)
+                   Left e        -> Left e
+        equiv rsyn usyn = case (yhat rsyn, yhat usyn) of
+          (Right a, Right b) -> and (zipWith (\p q -> abs (p - q) < 1e-9) a b)
+          _                  -> False
+
+    describe "dispatch: ~ を含めば R・無ければ独自" $ do
+      it "y ~ x → 応答 y / データ変数 x" $
+        case parseModel "y ~ x" of
+          Right (Formula r dv _) -> (r, dv) `shouldBe` ("y", ["x"])
+          Left e                 -> expectationFailure e
+      it "独自構文 (= 区切り) も同 dispatch で通る" $
+        parseModel "y x = b0 + b1*x" `shouldSatisfy` isRightE
+
+    describe "クロス front-end 等価 (R 構文 vs 独自構文 → 同 ŷ)" $ do
+      it "linear: y~x ≡ y x = b0+b1*x" $
+        equiv "y ~ x" "y x = b0 + b1*x" `shouldBe` True
+      it "factor×factor: y~C(g)*C(t) ≡ 飽和" $
+        equiv "y ~ C(g)*C(t)" "y g t = b0 + bg!g + bt!t + bgt!g!t" `shouldBe` True
+      it "factor×連続: y~C(g)+C(g):x ≡ 水準別傾き" $
+        equiv "y ~ C(g) + C(g):x" "y g x = b0 + bg!g + bs!g*x" `shouldBe` True
+      it "I(x**2): y~x+I(x**2) ≡ b0+b1*x+b2*x^2" $
+        equiv "y ~ x + I(x**2)" "y x = b0 + b1*x + b2*x^2" `shouldBe` True
+      it "poly: y~poly(x,2) ≡ b0+bp!poly(x,2)" $
+        equiv "y ~ poly(x,2)" "y x = b0 + bp ! poly(x,2)" `shouldBe` True
+
+  -- round-trip 用の任意 Formula 生成 (識別子は固定プール・Lit は非負整数)。
diff --git a/test/Hanalyze/Model/Formula/WLSSpec.hs b/test/Hanalyze/Model/Formula/WLSSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Model/Formula/WLSSpec.hs
@@ -0,0 +1,69 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Model.Formula.WLSSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Numeric.LinearAlgebra as LA
+import qualified DataFrame.Internal.Column    as DX
+import qualified DataFrame.Internal.DataFrame  as DX
+import qualified Hanalyze.Model.Core        as Core
+import qualified Hanalyze.MCMC.Core as Core
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "weights / offset = WLS (Phase 47 A3)" $ do
+    let frm s = case parseFormula s of Right f -> f; Left e -> error e
+        xs = [1,2,3,4,5,6,7,8] :: [Double]
+        ys = [2.1,3.9,6.2,7.8,10.1,12.2,13.8,16.1] :: [Double]
+        ws = [1,1,2,2,3,3,4,4] :: [Double]
+        df = DX.fromNamedColumns
+               [ ("y", DX.fromList ys), ("x", DX.fromList xs), ("w", DX.fromList ws) ]
+        f  = frm "y x = b0 + b1*x"
+        coefOf = LA.toList . Core.coefficientsV
+        close a b = and (zipWith (\p q -> abs (p - q) < 1e-8) a b)
+
+    it "w≡1 (重みなし) は OLS = fitLMF と一致" $ do
+      let Right (a, _) = fitWLSF defaultWLS f df
+          Right (b, _) = fitLMF f df
+      close (coefOf a) (coefOf b) `shouldBe` True
+
+    it "WLS 係数 = 閉形式 (XᵀWX)⁻¹XᵀWy (hmatrix 直計算オラクル)" $ do
+      let Right (r, _) = fitWLSF defaultWLS { wcWeights = Just "w" } f df
+          x    = LA.fromLists [ [1, xi] | xi <- xs ]
+          w    = LA.diag (LA.fromList ws)
+          y    = LA.asColumn (LA.fromList ys)
+          beta = LA.flatten (LA.inv (LA.tr x LA.<> w LA.<> x) LA.<> LA.tr x LA.<> w LA.<> y)
+      close (coefOf r) (LA.toList beta) `shouldBe` True
+
+    it "offset: y を offset z 付きで fit = (y−z) を素の fit と一致" $ do
+      let zs  = [0.5,1.0,1.5,2.0,2.5,3.0,3.5,4.0] :: [Double]
+          dfO = DX.fromNamedColumns
+                  [ ("y", DX.fromList ys), ("x", DX.fromList xs), ("z", DX.fromList zs) ]
+          dfM = DX.fromNamedColumns
+                  [ ("y", DX.fromList (zipWith (-) ys zs)), ("x", DX.fromList xs) ]
+          Right (ro, _) = fitWLSF defaultWLS { wcOffset = Just "z" } f dfO
+          Right (rm, _) = fitLMF f dfM
+      close (coefOf ro) (coefOf rm) `shouldBe` True
+
+  -- ----------------------------------------------------------------------------
+  -- A4 (Phase 47) 非線形フィット NLS
+  -- ----------------------------------------------------------------------------
diff --git a/test/Hanalyze/Model/FormulaSpec.hs b/test/Hanalyze/Model/FormulaSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Model/FormulaSpec.hs
@@ -0,0 +1,55 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Model.FormulaSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Data.Text   as T
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Model.Formula (A15 parser)" $ do
+    describe "golden: 入力 → 期待 AST (優先順位・結合の固定)" $ do
+      let g src expect = it (T.unpack src) $ parseFormula src `shouldBe` Right expect
+      g "y x = b1*x"
+        (Formula "y" ["x"] (Bin Mul (Ref "b1") (Ref "x")))
+      g "y x = b0 + b1*x"
+        (Formula "y" ["x"] (Bin Add (Ref "b0") (Bin Mul (Ref "b1") (Ref "x"))))
+      g "y g t = b ! g ! t"
+        (Formula "y" ["g", "t"] (Index (Index (Ref "b") (Ref "g")) (Ref "t")))
+      g "y g x = bg ! g * x"  -- ! は * より高優先 (factor×連続 = 水準別傾き)
+        (Formula "y" ["g", "x"] (Bin Mul (Index (Ref "bg") (Ref "g")) (Ref "x")))
+      g "y x = a*exp(-b*x)"  -- 単項 - は * より高優先 ゆえ -b*x = (-b)*x
+        (Formula "y" ["x"] (Bin Mul (Ref "a") (App "exp" [Bin Mul (Neg (Ref "b")) (Ref "x")])))
+      g "y x = b0 + b1*x^2"  -- ^ は * より高優先
+        (Formula "y" ["x"] (Bin Add (Ref "b0") (Bin Mul (Ref "b1") (Bin Pow (Ref "x") (Lit 2)))))
+      g "y x = log (x+1)"    -- 空白並置適用
+        (Formula "y" ["x"] (App "log" [Bin Add (Ref "x") (Lit 1)]))
+
+    describe "error: 不正入力は Left" $ do
+      let bad src = it (T.unpack src) $ parseFormula src `shouldSatisfy` isLeftE
+      bad "y x = "       -- 右辺なし
+      bad "y x b1*x"     -- = なし
+      bad "= b0 + b1*x"  -- 左辺なし
+
+    describe "round-trip: parse . pretty == id (QuickCheck)" $
+      prop "任意 Formula で round-trip" $ \f ->
+        parseFormula (prettyFormula f) === Right f
diff --git a/test/Hanalyze/Model/GARCHSpec.hs b/test/Hanalyze/Model/GARCHSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Model/GARCHSpec.hs
@@ -0,0 +1,89 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Model.GARCHSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Numeric.LinearAlgebra as LA
+import qualified Hanalyze.Model.GARCH          as GARCH
+import qualified System.Random.MWC.Distributions as MWCD
+import qualified System.Random.MWC as MWC
+import qualified System.Random.MWC as MWC
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Model.GARCH (Phase 35-A1)" $ do
+    -- GARCH(1,1) からのサンプル生成: y_t = σ_t · z_t, z_t ~ N(0,1)
+    -- σ²_t = ω + α ε²_{t-1} + β σ²_{t-1}
+    let simulateGARCH gen omega alpha beta n = do
+          let unc = omega / (1 - alpha - beta)
+              loop !i !s2Prev !ePrev acc
+                | i >= n = pure (reverse acc)
+                | otherwise = do
+                    z <- MWCD.standard gen
+                    let !s2 = if i == 0 then unc
+                              else omega + alpha * ePrev * ePrev + beta * s2Prev
+                        !sigma = sqrt s2
+                        !e     = sigma * z
+                    loop (i + 1) s2 e (e : acc)
+          es <- loop 0 0 0 []
+          pure (LA.fromList es)
+    it "fitGARCH: ω/α/β > 0、 α + β < 1" $ do
+      gen <- MWC.create
+      ys <- simulateGARCH gen (0.05 :: Double) 0.10 0.85 1000
+      let fit = GARCH.fitGARCH ys
+      GARCH.gOmega fit `shouldSatisfy` (> 0)
+      GARCH.gAlpha fit `shouldSatisfy` (>= 0)
+      GARCH.gBeta  fit `shouldSatisfy` (>= 0)
+      (GARCH.gAlpha fit + GARCH.gBeta fit) `shouldSatisfy` (< 1)
+    it "fitGARCH: 真値 (ω=0.05, α=0.10, β=0.85) を概ね回復" $ do
+      gen <- MWC.create
+      ys <- simulateGARCH gen (0.05 :: Double) 0.10 0.85 2000
+      let fit = GARCH.fitGARCH ys
+          ab  = GARCH.gAlpha fit + GARCH.gBeta fit
+      -- α+β (persistence) は推定が安定しやすい
+      ab `shouldSatisfy` (> 0.80)
+      ab `shouldSatisfy` (< 1.00)
+      -- 無条件分散 ω/(1-α-β) はサンプル分散に近い
+      let n     = LA.size ys
+          var_y = LA.dot ys ys / fromIntegral n
+          uncV  = GARCH.gOmega fit / (1 - ab)
+      abs (uncV - var_y) / var_y `shouldSatisfy` (< 0.5)
+    it "fitGARCH: gSigma2 の長さ = 入力長" $ do
+      gen <- MWC.create
+      ys <- simulateGARCH gen (0.1 :: Double) 0.05 0.90 500
+      let fit = GARCH.fitGARCH ys
+      LA.size (GARCH.gSigma2 fit) `shouldBe` 500
+    it "forecastGARCH: 長期予測が無条件分散 ω/(1-α-β) に収束" $ do
+      gen <- MWC.create
+      ys <- simulateGARCH gen (0.05 :: Double) 0.10 0.85 1000
+      let fit = GARCH.fitGARCH ys
+          fc  = GARCH.forecastGARCH fit 200
+          ab  = GARCH.gAlpha fit + GARCH.gBeta fit
+          unc = GARCH.gOmega fit / (1 - ab)
+          fLast = LA.atIndex fc 199
+      abs (fLast - unc) / unc `shouldSatisfy` (< 0.05)
+    it "forecastGARCH: 長さ = h" $ do
+      gen <- MWC.create
+      ys <- simulateGARCH gen (0.05 :: Double) 0.10 0.85 200
+      let fit = GARCH.fitGARCH ys
+          fc  = GARCH.forecastGARCH fit 12
+      LA.size fc `shouldBe` 12
diff --git a/test/Hanalyze/Model/GLMMSpec.hs b/test/Hanalyze/Model/GLMMSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Model/GLMMSpec.hs
@@ -0,0 +1,316 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Model.GLMMSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Data.Vector as V
+import qualified Data.Text   as T
+import qualified Numeric.LinearAlgebra as LA
+import qualified DataFrame.Internal.Column    as DX
+import qualified DataFrame.Internal.DataFrame  as DX
+import qualified Hanalyze.Model.Core        as Core
+import qualified Hanalyze.MCMC.Core as Core
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Model.GLMM" $ do
+    -- Dataset: 3 groups × 4 obs, strong between-group signal, weak within-group noise.
+    -- True: β₀≈5, β₁≈0, u_A≈2, u_B≈0, u_C≈-2, σ²_u≈4, σ²≈small → ICC≈high.
+    let df  = DX.fromNamedColumns
+                [ ("x",     DX.fromList ([1,2,3,4, 1,2,3,4, 1,2,3,4] :: [Double]))
+                , ("y",     DX.fromList ([7.1,6.9,7.0,7.0, 5.0,4.9,5.1,5.0, 3.0,2.9,3.1,3.0] :: [Double]))
+                , ("group", DX.fromList (["A","A","A","A","B","B","B","B","C","C","C","C"] :: [T.Text])) ]
+        res = fitLMEDataFrame [("x", 1)] "group" "y" df
+
+    it "returns Just for valid input" $
+      res `shouldSatisfy` (\r -> case r of { Just _ -> True; Nothing -> False })
+
+    it "ICC is in [0, 1]" $
+      maybe (expectationFailure "expected Just") (\r ->
+        glmmICC r `shouldSatisfy` (\v -> v >= 0 && v <= 1)) res
+
+    it "ICC is high for strongly grouped data" $
+      maybe (expectationFailure "expected Just") (\r ->
+        glmmICC r `shouldSatisfy` (> 0.9)) res
+
+    it "random variance is positive" $
+      maybe (expectationFailure "expected Just") (\r ->
+        glmmRandVar r `shouldSatisfy` (> 0)) res
+
+    it "residual variance is positive" $
+      maybe (expectationFailure "expected Just") (\r ->
+        glmmResidVar r `shouldSatisfy` (> 0)) res
+
+    it "BLUP count equals number of groups" $
+      maybe (expectationFailure "expected Just") (\r ->
+        V.length (glmmBLUPs r) `shouldBe` 3) res
+
+    it "group labels are sorted" $
+      case res of
+        Just r  -> glmmGroups r `shouldBe` V.fromList ["A","B","C"]
+        Nothing -> expectationFailure "expected Just"
+
+    it "returns Nothing for missing column" $
+      fitLMEDataFrame [("x", 1)] "group" "missing" df
+        `shouldSatisfy` (\r -> case r of { Nothing -> True; Just _ -> False })
+
+  describe "Hanalyze.Model.GLMM general random effects (Phase 48)" $ do
+    let approxG tol a b = abs (a - b) < tol
+        buildG gvec =
+          let lbls = V.fromList . sort
+                       . foldr (\x acc -> if x `elem` acc then acc else x:acc) []
+                       $ V.toList gvec
+              idxF x = maybe 0 id (V.elemIndex x lbls)
+          in (lbls, V.map idxF gvec)
+
+    -- (1) r=1, intercept-only Z must reproduce scalar fitLME exactly.
+    describe "reduces to fitLME for intercept-only random effect (r=1)" $ do
+      let xMat = LA.fromLists
+                   [[1,1],[1,2],[1,3],[1,4],
+                    [1,1],[1,2],[1,3],[1,4],
+                    [1,1],[1,2],[1,3],[1,4]] :: LA.Matrix Double
+          zOne = LA.fromLists (replicate 12 [1.0])          :: LA.Matrix Double
+          y    = LA.fromList [7.1,6.9,7.0,7.0, 5.0,4.9,5.1,5.0, 3.0,2.9,3.1,3.0]
+          gv   = V.fromList (["A","A","A","A","B","B","B","B","C","C","C","C"] :: [T.Text])
+          (lbls, idx) = buildG gv
+          sizes = V.fromList [ V.length (V.filter (== j) idx) | j <- [0..V.length lbls - 1] ]
+          scal = fitLME xMat y idx lbls sizes
+          gen  = fitLMEGeneral xMat zOne y idx lbls
+
+      it "random variance matches glmmRandVar" $
+        LA.atIndex (reRandCov gen) (0,0) `shouldSatisfy` approxG 1e-6 (glmmRandVar scal)
+      it "residual variance matches glmmResidVar" $
+        reResidVar gen `shouldSatisfy` approxG 1e-6 (glmmResidVar scal)
+      it "fixed-effect β matches glmmFixed" $
+        LA.toList (Core.coefficientsV (reFixed gen))
+          `shouldSatisfy` (\bs -> and (zipWith (approxG 1e-6) bs
+                                        (LA.toList (Core.coefficientsV (glmmFixed scal)))))
+      it "BLUPs match glmmBLUPs (q×1 column)" $
+        LA.toList (LA.flatten (reBLUPs gen))
+          `shouldSatisfy` (\bs -> and (zipWith (approxG 1e-6) bs (V.toList (glmmBLUPs scal))))
+      it "group labels preserved" $
+        reGroups gen `shouldBe` V.fromList ["A","B","C"]
+
+    -- (2) random slope: 4 groups with known per-group intercept+slope deviations.
+    --     Fixed mean β=[2,3]; group lines differ in both intercept and slope.
+    describe "recovers random slope structure (r=2)" $ do
+      let oneGrp = [[1,0],[1,1],[1,2],[1,3],[1,4]] :: [[Double]]
+          xMat = LA.fromLists (concat (replicate 4 oneGrp))   :: LA.Matrix Double
+          zMat = xMat                                          -- (1 + x | g)
+          -- A: 3+3.5x  B: 1+2.5x  C: 2.5+2.7x  D: 1.5+3.3x  (+tiny noise)
+          y = LA.fromList
+                [ 3.01, 6.49, 10.01, 13.49, 17.00      -- A
+                , 1.01, 3.49,  6.01,  8.49, 11.00      -- B
+                , 2.51, 5.19,  7.91, 10.59, 13.30      -- C
+                , 1.51, 4.79,  8.11, 11.39, 14.70 ]    -- D
+          gv = V.fromList (concatMap (replicate 5) (["A","B","C","D"] :: [T.Text]))
+          (lbls, idx) = buildG gv
+          gen = fitLMEGeneral xMat zMat y idx lbls
+          beta = LA.toList (Core.coefficientsV (reFixed gen))
+
+      it "covariance G is 2×2" $
+        LA.size (reRandCov gen) `shouldBe` (2, 2)
+      it "BLUP matrix is q×2 (4 groups × intercept+slope)" $
+        LA.size (reBLUPs gen) `shouldBe` (4, 2)
+      it "residual variance positive" $
+        reResidVar gen `shouldSatisfy` (> 0)
+      it "G diagonal (variances) positive" $
+        (LA.atIndex (reRandCov gen) (0,0) > 0 && LA.atIndex (reRandCov gen) (1,1) > 0)
+          `shouldBe` True
+      it "fixed-effect β recovers population mean [2, 3]" $
+        beta `shouldSatisfy` (\b -> approxG 0.3 (b !! 0) 2.0 && approxG 0.3 (b !! 1) 3.0)
+      it "BLUP intercept ordering: A > B" $
+        (LA.atIndex (reBLUPs gen) (0,0) > LA.atIndex (reBLUPs gen) (1,0)) `shouldBe` True
+      it "BLUP slope ordering: A > B" $
+        (LA.atIndex (reBLUPs gen) (0,1) > LA.atIndex (reBLUPs gen) (1,1)) `shouldBe` True
+
+  describe "Hanalyze.Model.GLMM general random effects non-Gaussian (Phase 48 A2)" $ do
+    let approxG tol a b = abs (a - b) < tol
+        buildG gvec =
+          let lbls = V.fromList . sort
+                       . foldr (\x acc -> if x `elem` acc then acc else x:acc) []
+                       $ V.toList gvec
+              idxF x = maybe 0 id (V.elemIndex x lbls)
+          in (lbls, V.map idxF gvec)
+
+    -- (1) r=1, intercept-only Z reproduces scalar fitGLMM (same MLE fixed point).
+    describe "reduces to fitGLMM for intercept-only random effect (r=1)" $ do
+      let xMat = LA.fromLists (concatMap (\d -> [[1,d]]) ([1,2,3,4,5] ++ [1,2,3,4,5] ++ [1,2,3,4,5]))
+                   :: LA.Matrix Double
+          zOne = LA.fromLists (replicate 15 [1.0])  :: LA.Matrix Double
+          gv   = V.fromList (["A","A","A","A","A","B","B","B","B","B","C","C","C","C","C"] :: [T.Text])
+          (lbls, idx) = buildG gv
+          sizes = V.fromList [ V.length (V.filter (== j) idx) | j <- [0..V.length lbls - 1] ]
+
+      describe "Binomial / Logit" $ do
+        let y    = LA.fromList [1,1,1,1,1, 1,1,0,1,0, 0,0,0,1,0]
+            scal = fitGLMM Binomial Logit xMat y idx lbls sizes
+            gen  = fitGLMMGeneral Binomial Logit xMat zOne y idx lbls
+        it "random variance matches glmmRandVar" $
+          LA.atIndex (reRandCov gen) (0,0) `shouldSatisfy` approxG 1e-4 (glmmRandVar scal)
+        it "fixed-effect β matches glmmFixed" $
+          LA.toList (Core.coefficientsV (reFixed gen))
+            `shouldSatisfy` (\bs -> and (zipWith (approxG 1e-4) bs
+                                          (LA.toList (Core.coefficientsV (glmmFixed scal)))))
+        it "BLUPs match glmmBLUPs" $
+          LA.toList (LA.flatten (reBLUPs gen))
+            `shouldSatisfy` (\bs -> and (zipWith (approxG 1e-4) bs (V.toList (glmmBLUPs scal))))
+        it "residual variance is 1.0 (non-Gaussian convention)" $
+          reResidVar gen `shouldSatisfy` approxG 1e-12 1.0
+
+      describe "Poisson / Log" $ do
+        let y    = LA.fromList [15,18,22,20,25, 7,9,8,10,11, 2,3,2,4,3]
+            scal = fitGLMM Poisson Log xMat y idx lbls sizes
+            gen  = fitGLMMGeneral Poisson Log xMat zOne y idx lbls
+        it "random variance matches glmmRandVar" $
+          LA.atIndex (reRandCov gen) (0,0) `shouldSatisfy` approxG 1e-4 (glmmRandVar scal)
+        it "fixed-effect β matches glmmFixed" $
+          LA.toList (Core.coefficientsV (reFixed gen))
+            `shouldSatisfy` (\bs -> and (zipWith (approxG 1e-4) bs
+                                          (LA.toList (Core.coefficientsV (glmmFixed scal)))))
+
+    -- (2) random slope (r=2): structure + finiteness sanity for a Poisson GLMM.
+    describe "random slope structure (r=2, Poisson)" $ do
+      let oneGrp = [[1,0],[1,1],[1,2],[1,3],[1,4]] :: [[Double]]
+          xMat = LA.fromLists (concat (replicate 3 oneGrp)) :: LA.Matrix Double
+          zMat = xMat
+          -- region X: steep growth, Y: moderate, Z: flat (slope differs by group)
+          y = LA.fromList [ 3, 5, 9, 16, 28      -- X (steep)
+                          , 4, 6, 8, 11, 15      -- Y (moderate)
+                          , 5, 5, 6, 6,  7 ]     -- Z (flat)
+          gv = V.fromList (concatMap (replicate 5) (["X","Y","Z"] :: [T.Text]))
+          (lbls, idx) = buildG gv
+          gen = fitGLMMGeneral Poisson Log xMat zMat y idx lbls
+      it "covariance G is 2×2" $
+        LA.size (reRandCov gen) `shouldBe` (2, 2)
+      it "BLUP matrix is q×2 (3 groups × intercept+slope)" $
+        LA.size (reBLUPs gen) `shouldBe` (3, 2)
+      it "G diagonal variances positive" $
+        (LA.atIndex (reRandCov gen) (0,0) > 0 && LA.atIndex (reRandCov gen) (1,1) > 0)
+          `shouldBe` True
+      it "fixed-effect β finite" $
+        LA.toList (Core.coefficientsV (reFixed gen))
+          `shouldSatisfy` all (not . isNaN)
+      it "BLUP slope ordering: X (steep) > Z (flat)" $
+        (LA.atIndex (reBLUPs gen) (0,1) > LA.atIndex (reBLUPs gen) (2,1)) `shouldBe` True
+
+  describe "Hanalyze.Model.GLMM (non-Gaussian)" $ do
+    -- Binomial GLMM: 3 hospitals, binary outcome (treatment success)
+    -- Strong hospital effect; within each hospital, dose → higher success rate.
+    -- True: u_A ≈ +1, u_B ≈ 0, u_C ≈ -1  (on logit scale)
+    let dfBin  = DX.fromNamedColumns
+                   [ ("dose",     DX.fromList ([1,2,3,4,5, 1,2,3,4,5, 1,2,3,4,5] :: [Double]))
+                   , ("success",  DX.fromList ([1,1,1,1,1, 1,1,0,1,0, 0,0,0,1,0] :: [Double]))
+                   , ("hospital", DX.fromList (["A","A","A","A","A","B","B","B","B","B","C","C","C","C","C"] :: [T.Text])) ]
+        resBin = fitGLMMDataFrame Binomial Logit [("dose", 1)] "hospital" "success" dfBin
+
+    it "Binomial GLMM returns Just" $
+      resBin `shouldSatisfy` (\r -> case r of { Just _ -> True; Nothing -> False })
+
+    it "Binomial ICC in [0, 1]" $
+      maybe (expectationFailure "expected Just") (\r ->
+        glmmICC r `shouldSatisfy` (\v -> v >= 0 && v <= 1)) resBin
+
+    it "Binomial σ²_u is positive" $
+      maybe (expectationFailure "expected Just") (\r ->
+        glmmRandVar r `shouldSatisfy` (> 0)) resBin
+
+    -- Poisson GLMM: 3 regions, count outcome (events per month)
+    -- True: β₀ on log scale ≈ 2 (≈7 events baseline), u differs by region.
+    let dfPois  = DX.fromNamedColumns
+                    [ ("time",   DX.fromList ([1,2,3,4,5, 1,2,3,4,5, 1,2,3,4,5] :: [Double]))
+                    , ("count",  DX.fromList ([15,18,22,20,25, 7,9,8,10,11, 2,3,2,4,3] :: [Double]))
+                    , ("region", DX.fromList (["X","X","X","X","X","Y","Y","Y","Y","Y","Z","Z","Z","Z","Z"] :: [T.Text])) ]
+        resPois = fitGLMMDataFrame Poisson Log [("time", 1)] "region" "count" dfPois
+
+    it "Poisson GLMM returns Just" $
+      resPois `shouldSatisfy` (\r -> case r of { Just _ -> True; Nothing -> False })
+
+    it "Poisson σ²_u is positive" $
+      maybe (expectationFailure "expected Just") (\r ->
+        glmmRandVar r `shouldSatisfy` (> 0)) resPois
+
+    it "Poisson ICC in [0, 1]" $
+      maybe (expectationFailure "expected Just") (\r ->
+        glmmICC r `shouldSatisfy` (\v -> v >= 0 && v <= 1)) resPois
+
+  -- ─────────────────────────────────────────────────────────────────────
+
+  describe "Hanalyze.Model.GLMM SE (request/100)" $ do
+    -- Same fixture as the GLMM tests above (3 groups × 4 obs).
+    -- design X = [1, x] over the same 12 rows.
+    let xMat12 = LA.matrix 2
+                   ( concatMap (\v -> [1, v])
+                       [1,2,3,4,1,2,3,4,1,2,3,4 :: Double] )
+        yVec12 = LA.fromList
+                   [7.1,6.9,7.0,7.0, 5.0,4.9,5.1,5.0, 3.0,2.9,3.1,3.0]
+        gVec12 = V.fromList
+                   ["A","A","A","A","B","B","B","B","C","C","C","C" :: T.Text]
+        -- Inline group construction (mirrors Hanalyze.Model.GLMM.buildGroups
+        -- which is currently internal).
+        gLabels12 = V.fromList ["A", "B", "C"] :: V.Vector T.Text
+        gIdx12    = V.map
+                      (\g -> case V.elemIndex g gLabels12 of
+                               Just i  -> i
+                               Nothing -> 0)
+                      gVec12
+        gSizes12  = V.fromList [4, 4, 4]
+        glmmRes   = fitLME xMat12 yVec12 gIdx12 gLabels12 gSizes12
+        idx12     = gIdx12
+
+    it "glmmFixedSE: returns one SE per coefficient (length p)" $ do
+      let ses = glmmFixedSE xMat12 idx12 glmmRes
+      LA.size ses `shouldBe` LA.cols xMat12
+
+    it "glmmFixedSE: all SEs are positive" $ do
+      let ses = glmmFixedSE xMat12 idx12 glmmRes
+      mapM_ (\v -> v `shouldSatisfy` (> 0)) (LA.toList ses)
+
+    it "glmmFixedSE: σ_u → 0 reduces to OLS SE within tolerance" $ do
+      -- Force σ²_u to 0 (no random effects → OLS).
+      let resOLS = glmmRes { glmmRandVar = 0 }
+          sesG   = glmmFixedSE xMat12 idx12 resOLS
+          -- Reference OLS SE from σ² (XᵀX)⁻¹.
+          xtx   = LA.tr xMat12 LA.<> xMat12
+          covOLS = LA.scale (glmmResidVar resOLS) (LA.inv xtx)
+          sesOLS = LA.fromList
+                     [ sqrt (LA.atIndex covOLS (i, i))
+                     | i <- [0 .. LA.cols xMat12 - 1] ]
+      LA.norm_Inf (sesG - sesOLS) `shouldSatisfy` (< 1e-9)
+
+    it "glmmBLUPSE: one entry per group, all positive" $ do
+      let ses = glmmBLUPSE idx12 glmmRes
+      V.length ses `shouldBe` V.length (glmmGroups glmmRes)
+      mapM_ (\v -> v `shouldSatisfy` (> 0)) (V.toList ses)
+
+    it "glmmBLUPSE: shrinkage formula (1/σ²_u + n_j/σ²)⁻¹^½" $ do
+      let ses    = glmmBLUPSE idx12 glmmRes
+          sig2u  = glmmRandVar  glmmRes
+          sig2   = glmmResidVar glmmRes
+          -- Group sizes: A=4, B=4, C=4 (balanced design)
+          expected = sqrt (1.0 / (1.0 / sig2u + 4.0 / sig2))
+      mapM_ (\(_, v) -> abs (v - expected) `shouldSatisfy` (< 1e-9))
+            (zip [0 :: Int ..] (V.toList ses))
+
+  -- ========================================================================
+  -- NUTS streaming callback (Phase 9.1a)
+  -- ========================================================================
diff --git a/test/Hanalyze/Model/GLMSpec.hs b/test/Hanalyze/Model/GLMSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Model/GLMSpec.hs
@@ -0,0 +1,112 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Model.GLMSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Numeric.LinearAlgebra as LA
+import qualified Hanalyze.Model.Core        as Core
+import qualified Hanalyze.Model.GLM         as GLM
+import qualified Hanalyze.MCMC.Core as Core
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Model.GLM IRLS 収束 (28d1feb7 回帰の防止)" $ do
+    -- ★かつて runIRLS の converge が初回 dLL=0 (seed llP=ll(β0) と llHere=ll(β0) が一致)
+    -- で IRLS を 1 ステップで早期停止し、 μ̂ が系統的に過大だった (28d1feb7 perf リライトの
+    -- 回帰)。 真の MLE はスコア方程式 Xᵀ(y-μ̂)=0 を満たすので、 それを外部非依存オラクル
+    -- として検証する (statsmodels の β とも突合)。
+    let xsP   = [1,2,3,4,5,6,7,8] :: [Double]
+        ysP   = [1,1,3,4,7,10,15,22] :: [Double]
+        xMatP = LA.matrix 2 (concatMap (\x -> [1, x]) xsP)
+        yVecP = LA.fromList ysP
+        (frP, _) = GLM.fitGLMFull GLM.Poisson GLM.Log xMatP yVecP
+        betaP = LA.toList (head (LA.toColumns (Core.coefficients frP)))
+        muP   = head (LA.toColumns (Core.fitted frP))
+
+    it "Poisson/Log: スコア方程式 Xᵀ(y-μ̂)=0 (真の MLE に収束)" $ do
+      let score = LA.tr xMatP LA.#> (yVecP - muP)
+      LA.norm_Inf score `shouldSatisfy` (< 1e-6)
+
+    it "Poisson/Log: 係数が statsmodels GLM と一致 (-0.33398, 0.43288)" $ do
+      abs (betaP !! 0 - (-0.33397763)) `shouldSatisfy` (< 1e-4)
+      abs (betaP !! 1 -   0.43287516)  `shouldSatisfy` (< 1e-4)
+
+    it "Binomial/Logit: スコア方程式 Xᵀ(y-μ̂)=0 (分離しない data で MLE)" $ do
+      let xsB   = [0,1,2,3,4,5,6,7,8,9] :: [Double]
+          ysB   = [0,0,0,0,1,0,1,1,1,1] :: [Double]
+          xMatB = LA.matrix 2 (concatMap (\x -> [1, x]) xsB)
+          yVecB = LA.fromList ysB
+          (frB, _) = GLM.fitGLMFull GLM.Binomial GLM.Logit xMatB yVecB
+          muB   = head (LA.toColumns (Core.fitted frB))
+          score = LA.tr xMatB LA.#> (yVecB - muB)
+      LA.norm_Inf score `shouldSatisfy` (< 1e-6)
+
+  describe "Hanalyze.Model.GLM diagnostics (request/090-AB)" $ do
+    let xsG = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] :: [Double]
+        ysG = [1.1, 2.9, 5.2, 6.8, 9.1, 11.0, 13.2, 14.8, 17.0, 19.1] :: [Double]
+        xMatG = LA.matrix 2 (concatMap (\x -> [1, x]) xsG)
+        yVecG = LA.fromList ysG
+        (frG, sigmaG) = GLM.fitGLMFull GLM.Gaussian GLM.Identity xMatG yVecG
+        betaG = head (LA.toColumns (Core.coefficients frG))
+        muVG  = head (LA.toColumns (Core.fitted frG))
+
+    it "glmPearsonResiduals (Gaussian, V=1) == raw residuals" $ do
+      let pr = GLM.glmPearsonResiduals GLM.Gaussian yVecG muVG
+          rr = yVecG - muVG
+      LA.norm_2 (pr - rr) `shouldSatisfy` (< 1e-9)
+
+    it "glmDevianceResiduals (Gaussian) == sign(y-μ)·|y-μ|" $ do
+      let dr  = GLM.glmDevianceResiduals GLM.Gaussian yVecG muVG
+          ref = LA.fromList
+                  [ signum (y - m) * abs (y - m)
+                  | (y, m) <- zip ysG (LA.toList muVG) ]
+      LA.norm_2 (dr - ref) `shouldSatisfy` (< 1e-9)
+
+    it "glmVariance: Binomial μ(1-μ); Poisson μ" $ do
+      GLM.glmVariance GLM.Binomial 0.3 `shouldBe` 0.3 * 0.7
+      GLM.glmVariance GLM.Poisson  4.0 `shouldBe` 4.0
+
+    it "predictGlmEtaWithSE: η = xᵀβ, SE > 0" $ do
+      let xNew = LA.fromList [1, 5.0]
+          (eta, se) = GLM.predictGlmEtaWithSE betaG sigmaG xNew
+      eta `shouldSatisfy` (\e -> abs (e - (1 + 2 * 5.0)) < 0.5)
+      se  `shouldSatisfy` (> 0)
+      se  `shouldSatisfy` (< 1)
+
+    it "predictGlmMuWithCI (Identity): half-width ≈ 1.96·SE" $ do
+      let xNew    = LA.fromList [1, 4.5]
+          ci      = GLM.predictGlmMuWithCI GLM.Identity 0.95 betaG sigmaG xNew
+          (_, se) = GLM.predictGlmEtaWithSE betaG sigmaG xNew
+          halfW   = (GLM.gpHi ci - GLM.gpLo ci) / 2
+      abs (halfW - 1.96 * se) `shouldSatisfy` (< 1e-2)
+
+    it "predictGlmMuWithCI (Logit): CI stays in (0,1)" $ do
+      let xs2  = LA.matrix 2 (concatMap (\i -> [1, fromIntegral (i :: Int)]) [0..9])
+          ys2  = LA.fromList [0,1,0,1,0,1,0,1,0,1]
+          (_, sigma2) = GLM.fitGLMFull GLM.Binomial GLM.Logit xs2 ys2
+          beta2 = LA.fromList [0.0, 0.05]
+          xNew  = LA.fromList [1, 5.0]
+          ci    = GLM.predictGlmMuWithCI GLM.Logit 0.95 beta2 sigma2 xNew
+      GLM.gpMu ci `shouldSatisfy` (\v -> v > 0 && v < 1)
+      GLM.gpLo ci `shouldSatisfy` (\v -> v >= 0)
+      GLM.gpHi ci `shouldSatisfy` (\v -> v <= 1)
+      GLM.gpLo ci `shouldSatisfy` (< GLM.gpHi ci)
diff --git a/test/Hanalyze/Model/GPRobustSpec.hs b/test/Hanalyze/Model/GPRobustSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Model/GPRobustSpec.hs
@@ -0,0 +1,57 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Model.GPRobustSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Hanalyze.Model.GP          as GP
+import qualified Hanalyze.Model.GPRobust    as GPR
+import qualified Hanalyze.Model.GP        as GP
+import qualified Hanalyze.Model.GPRobust  as GPR
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Model.GPRobust" $ do
+    it "Cauchy GP is more accurate than Gaussian GP under outliers" $ do
+      let trueF x = sin x
+          xs = [0.0, 0.5 .. 6.0]
+          cleanY = map trueF xs
+          -- Inject outlier at index 5
+          ys = zipWith (\i y -> if i == 5 then y + 5 else y)
+                       [0::Int ..] cleanY
+          hp = GP.GPParams 1.0 1.0 0.05 1.0 Nothing
+          gpRes  = GP.fitGP (GP.GPModel GP.RBF hp) xs ys xs
+          gaussRMSE = sqrt (sum [ (a - b) ^ (2::Int)
+                                | (a, b) <- zip cleanY (GP.gpMean gpRes) ]
+                            / fromIntegral (length xs))
+          cauchyFit = GPR.fitGPRobust GP.RBF hp (GPR.RCauchy 0.5) xs ys
+          cauchyPred = GPR.predictGPRobust cauchyFit xs
+          cauchyRMSE = sqrt (sum [ (a - b) ^ (2::Int)
+                                 | (a, (b, _)) <- zip cleanY cauchyPred ]
+                             / fromIntegral (length xs))
+      cauchyRMSE `shouldSatisfy` (< gaussRMSE)
+
+    it "IRLS converges in finite iterations" $ do
+      let xs = [0.0, 1.0, 2.0, 3.0, 4.0]
+          ys = [0.1, 1.05, 1.95, 2.9, 4.1]
+          hp = GP.GPParams 1.0 1.0 0.1 1.0 Nothing
+          fit = GPR.fitGPRobust GP.RBF hp (GPR.RStudentT 4 0.5) xs ys
+      GPR.rgpIters fit `shouldSatisfy` (\n -> n > 0 && n <= 50)
diff --git a/test/Hanalyze/Model/GradientBoostingSpec.hs b/test/Hanalyze/Model/GradientBoostingSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Model/GradientBoostingSpec.hs
@@ -0,0 +1,64 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Model.GradientBoostingSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Numeric.LinearAlgebra as LA
+import qualified Data.Vector.Unboxed        as VU
+import qualified Hanalyze.Model.GradientBoosting as GB
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Model.GradientBoosting (Phase 34-A1)" $ do
+    let xs = [[fromIntegral i / 5 - 1] | i <- [0 :: Int .. 19]]
+        xMat = LA.fromLists xs
+        ys = VU.fromList [2 * (fromIntegral i / 5 - 1) + 1
+                         | i <- [0 :: Int .. 19]]
+        -- 二値分類: x < 0 → 0, x >= 0 → 1
+        yCls = VU.fromList ([0,0,0,0,0,0,0,0,0,0,
+                              1,1,1,1,1,1,1,1,1,1] :: [Int])
+    it "fitGBRegressor: 線形データに収束 (MSE 低)" $ do
+      let cfg = GB.defaultGBM { GB.gbNRounds = 100
+                                   , GB.gbMaxDepth = 3
+                                   , GB.gbLearnRate = 0.1 }
+          gb  = GB.fitGBRegressor cfg xMat ys
+          yhat = GB.predictGBR gb xMat
+          mse  = VU.sum (VU.zipWith (\a b -> (a - b)^(2::Int)) yhat ys)
+                   / fromIntegral (VU.length ys)
+      mse `shouldSatisfy` (< 0.05)
+    it "fitGBRegressor: gbrTrees の長さ = gbNRounds" $ do
+      let cfg = GB.defaultGBM { GB.gbNRounds = 20 }
+          gb  = GB.fitGBRegressor cfg xMat ys
+      length (GB.gbrTrees gb) `shouldBe` 20
+    it "fitGBClassifier: 線形分離可能データで訓練精度 100%" $ do
+      let cfg = GB.defaultGBM { GB.gbNRounds = 50
+                                   , GB.gbMaxDepth = 2 }
+          gb  = GB.fitGBClassifier cfg xMat yCls
+          yhat = GB.predictGBC gb xMat
+          correct = VU.length (VU.filter id
+                       (VU.zipWith (==) yhat yCls))
+      correct `shouldBe` VU.length yCls
+    it "predictGBCProbs: 全行 [0,1] 範囲" $ do
+      let cfg = GB.defaultGBM { GB.gbNRounds = 20 }
+          gb  = GB.fitGBClassifier cfg xMat yCls
+          ps  = GB.predictGBCProbs gb xMat
+      VU.all (\p -> p >= 0 && p <= 1) ps `shouldBe` True
diff --git a/test/Hanalyze/Model/HBM/InterpSpec.hs b/test/Hanalyze/Model/HBM/InterpSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Model/HBM/InterpSpec.hs
@@ -0,0 +1,467 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Model.HBM.InterpSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Data.Map.Strict as M
+import qualified Hanalyze.Model.HBM as HBM
+import qualified Hanalyze.Model.HBM.Interp as HI
+import qualified Data.Map.Strict    as M
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Model.HBM.Interp categorical 列 (Phase 41)" $ do
+    it "colDoubles: Numeric はそのまま、 Factor は code を Double 化" $ do
+      HI.colDoubles (HI.Numeric [1.0, 2.5]) `shouldBe` [1.0, 2.5]
+      HI.colDoubles (HI.Factor ["a", "b"] [0, 1, 0]) `shouldBe` [0.0, 1.0, 0.0]
+    it "colLevels: Factor は level 辞書、 Numeric は Nothing" $ do
+      HI.colLevels (HI.Factor ["a", "b"] [0, 1]) `shouldBe` Just ["a", "b"]
+      HI.colLevels (HI.Numeric [1.0]) `shouldBe` Nothing
+    it "groupSuffixFor: 安全な level は可読 suffix '_versicolor'" $
+      HI.groupSuffixFor (Just (HI.Factor ["setosa", "versicolor"] [0, 1])) 1
+        `shouldBe` "_versicolor"
+    it "groupSuffixFor: 不安全な level (空白/記号) は数値 code に fallback" $
+      HI.groupSuffixFor (Just (HI.Factor ["A/B", "C D"] [0, 1])) 0 `shouldBe` "_0"
+    it "groupSuffixFor: 数値先頭 level も識別子規則違反で fallback" $
+      HI.groupSuffixFor (Just (HI.Factor ["1cat", "2dog"] [0, 1])) 0 `shouldBe` "_0"
+    it "groupSuffixFor: Numeric / Nothing は従来の数値 suffix" $ do
+      HI.groupSuffixFor (Just (HI.Numeric [0, 1, 2])) 2 `shouldBe` "_2"
+      HI.groupSuffixFor Nothing 3 `shouldBe` "_3"
+    it "groupSuffixFor: 範囲外 code は数値 fallback" $
+      HI.groupSuffixFor (Just (HI.Factor ["a"] [0])) 5 `shouldBe` "_5"
+
+  describe "Hanalyze.Model.HBM.Interp categorical observe (Phase 41.5)" $ do
+    -- 2 値応答列 outcome = yes/no を出現順 code (yes=0, no=1) で持つ factor。
+    let dmF = M.fromList [("outcome", HI.Factor ["yes", "no"] [0, 1, 0, 1])]
+        -- do { p <- Beta 1 1; observe "y" (Bernoulli p) 'outcome' }
+        beta11 = EApp (EApp (EVar "Beta") (ELit (LNumber 1))) (ELit (LNumber 1))
+        bern   = EApp (EVar "Bernoulli") (EVar "p")
+        observeOn col = EApp (EApp (EApp (EVar "observe") (ELit (LText "y"))) bern) (ECol col)
+        modelOn col = EDo [ DoBind "p" beta11, DoExpr (observeOn col) ]
+                          (EApp (EVar "pure") (ELit (LNumber 0)))
+        isRight' = either (const False) (const True)
+    it "factor 観測列の値は code として渡る (= 2 値 0/1)" $
+      HI.lookupDoubles "outcome" dmF `shouldBe` [0.0, 1.0, 0.0, 1.0]
+    it "validateAst は factor 列の Bernoulli observe を受理する" $
+      HI.validateAst [] (modelOn "outcome") dmF `shouldSatisfy` isRight'
+    it "validateAst は存在しない観測列は弾く (factor も例外でない)" $
+      HI.validateAst [] (modelOn "nope") dmF `shouldSatisfy` (not . isRight')
+
+  describe "Hanalyze.Model.HBM.Interp 多値 categorical 応答 (Phase 42)" $ do
+    let emptyEnv = M.empty :: HI.EnvA Double
+        emptyDM  = M.empty :: HI.DataMap
+        evalD e  = HI.evalDist emptyEnv emptyDM Nothing e :: Err (HBM.Distribution Double)
+        evalV e  = HI.evalValue emptyEnv emptyDM Nothing e :: Err (HI.Value Double)
+        num d    = ELit (LNumber d)
+        -- Categorical [0.2, 0.3, 0.5]
+        catE     = EApp (EVar "Categorical") (EList [num 0.2, num 0.3, num 0.5])
+        -- OrderedLogistic 0.0 [-1, 1]
+        olE      = EApp (EApp (EVar "OrderedLogistic") (num 0.0))
+                        (EList [ENeg (num 1), num 1])
+        isRight' = either (const False) (const True)
+    it "evalValue: EList を VList に評価する (Phase 42 list 引数の土台)" $
+      (case evalV (EList [num 1, num 2, num 3]) of
+         Right (HI.VList xs) -> length xs == 3
+         _                   -> False) `shouldBe` True
+    it "asList: VList は要素列を返し、 非 list はエラー" $ do
+      isRight' (HI.asList (HI.VList [HI.VNum (1 :: Double), HI.VNum 2])) `shouldBe` True
+      isRight' (HI.asList (HI.VNum (1 :: Double))) `shouldBe` False
+    it "evalDist: Categorical [probs] を構築する" $
+      show (evalD catE)
+        `shouldBe` show (Right (HBM.Categorical [0.2, 0.3, 0.5])
+                          :: Err (HBM.Distribution Double))
+    it "evalDist: OrderedLogistic eta [cuts] を構築する" $
+      show (evalD olE)
+        `shouldBe` show (Right (HBM.OrderedLogistic 0.0 [-1, 1])
+                          :: Err (HBM.Distribution Double))
+    it "evalDist: Categorical の probs に非リスト (スカラ) を渡すとエラー" $
+      evalD (EApp (EVar "Categorical") (num 0.5)) `shouldSatisfy` (not . isRight')
+
+    -- 3 水準 factor 応答列 grade = A/B/C を出現順 code (A=0,B=1,C=2) で観測。
+    let dm3 = M.fromList [("grade", HI.Factor ["A", "B", "C"] [0, 1, 2, 0, 1, 2])]
+        normal01 = EApp (EApp (EVar "Normal") (num 0)) (num 1)
+        olObs    = EApp (EApp (EVar "OrderedLogistic") (EVar "eta"))
+                        (EList [ENeg (num 1), num 1])
+        observeOL col = EApp (EApp (EApp (EVar "observe") (ELit (LText "y"))) olObs) (ECol col)
+        modelOL col = EDo [ DoBind "eta" normal01, DoExpr (observeOL col) ]
+                          (EApp (EVar "pure") (num 0))
+    it "validateAst: 3 水準 factor 列の OrderedLogistic observe を受理する" $
+      HI.validateAst [] (modelOL "grade") dm3 `shouldSatisfy` isRight'
+
+  describe "Hanalyze.Model.HBM.Interp list 値 latent (Phase 43)" $ do
+    let num d    = ELit (LNumber d)
+        isRight' = either (const False) (const True)
+        -- 全ノード名 (kind 問わず): combinator 内部 latent が実際に Model に
+        -- 登録されたかの証明に使う。
+        allNames g = sort [ HBM.nodeName n | n <- HBM.mgNodes g ]
+        -- 3 水準 factor 応答列。
+        dm = M.fromList
+          [ ("grade",  HI.Factor ["A", "B", "C"] [0, 1, 2, 0, 1, 2])
+          , ("choice", HI.Factor ["x", "y", "z"] [0, 1, 2, 0, 1, 2])
+          ]
+        -- orderedCuts "cut" 2 (-2) 1
+        ocE = EApp (EApp (EApp (EApp (EVar "orderedCuts") (ELit (LText "cut")))
+                              (num 2)) (ENeg (num 2))) (num 1)
+        -- dirichlet "pi" [1, 1, 1]
+        dirE = EApp (EApp (EVar "dirichlet") (ELit (LText "pi")))
+                    (EList [num 1, num 1, num 1])
+        normal05 = EApp (EApp (EVar "Normal") (num 0)) (num 5)
+        olD = EApp (EApp (EVar "OrderedLogistic") (EVar "alpha")) (EVar "cuts")
+        catD = EApp (EVar "Categorical") (EVar "probs")
+        observeE nm dist col =
+          EApp (EApp (EApp (EVar "observe") (ELit (LText nm))) dist) (ECol col)
+        -- alpha <- Normal 0 5; cuts <- orderedCuts "cut" 2 (-2) 1;
+        -- observe "y" (OrderedLogistic alpha cuts) 'grade'
+        modelCuts = EDo [ DoBind "alpha" normal05
+                        , DoBind "cuts" ocE
+                        , DoExpr (observeE "y" olD "grade") ]
+                        (EApp (EVar "pure") (num 0))
+        -- probs <- dirichlet "pi" [1,1,1]; observe "z" (Categorical probs) 'choice'
+        modelDir  = EDo [ DoBind "probs" dirE
+                        , DoExpr (observeE "z" catD "choice") ]
+                        (EApp (EVar "pure") (num 0))
+
+    -- Phase 43.1: latent cuts つき OrderedLogistic
+    it "validateAst: cuts <- orderedCuts の list 値 bind を受理する" $
+      HI.validateAst [] modelCuts dm `shouldSatisfy` isRight'
+    it "interpStmts: orderedCuts が Model モナドで走り内部 latent cut_d_2 が出る" $ do
+      let Right stmts = HI.validateAst [] modelCuts dm
+          g = HBM.buildModelGraph (HI.interpStmts [] dm stmts)
+      -- orderedCuts "cut" 2 …: cut_c_1/cut_c_2 (det) + cut_d_2 (HalfNormal latent)。
+      -- cut_d_2 の存在が combinator 実行の証明 (固定リテラルでは出ない)。
+      ("cut_d_2" `elem` allNames g) `shouldBe` True
+      ("alpha"   `elem` allNames g) `shouldBe` True
+    it "validateAst: orderedCuts のカット数が非リテラル (変数) ならエラー" $ do
+      let bad = EApp (EApp (EApp (EApp (EVar "orderedCuts") (ELit (LText "cut")))
+                              (EVar "k")) (ENeg (num 2))) (num 1)
+          m = EDo [ DoBind "cuts" bad, DoExpr (observeE "y" olD "grade") ]
+                  (EApp (EVar "pure") (num 0))
+      HI.validateAst [] m dm `shouldSatisfy` (not . isRight')
+
+    -- Phase 43.2: Dirichlet prior つき Categorical
+    it "validateAst: probs <- dirichlet の list 値 bind を受理する" $
+      HI.validateAst [] modelDir dm `shouldSatisfy` isRight'
+    it "interpStmts: dirichlet が Model モナドで走り内部 latent pi_b0/pi_b1 が出る" $ do
+      let Right stmts = HI.validateAst [] modelDir dm
+          g = HBM.buildModelGraph (HI.interpStmts [] dm stmts)
+      -- dirichlet "pi" [1,1,1]: stick-breaking で pi_b0/pi_b1 (Beta latent) +
+      -- pi_0/pi_1/pi_2 (det π)。 pi_b0 の存在が combinator 実行の証明。
+      ("pi_b0" `elem` allNames g) `shouldBe` True
+      ("pi_b1" `elem` allNames g) `shouldBe` True
+    it "validateAst: dirichlet の α が長さ 1 ([..]) ならエラー" $ do
+      let bad = EApp (EApp (EVar "dirichlet") (ELit (LText "pi"))) (EList [num 1])
+          m = EDo [ DoBind "probs" bad, DoExpr (observeE "z" catD "choice") ]
+                  (EApp (EVar "pure") (num 0))
+      HI.validateAst [] m dm `shouldSatisfy` (not . isRight')
+
+    -- Phase 43.3: softmax 多項ロジット
+    let evalV2 e = HI.evalValue (M.empty :: HI.EnvA Double) (M.empty :: HI.DataMap) Nothing e
+        smx es   = EApp (EVar "softmax") (EList es)
+        nums r   = case r of Right (HI.VList xs) -> [ x | HI.VNum x <- xs ]; _ -> []
+        catSmx   = EApp (EVar "Categorical") (smx [num 0, EVar "b1", EVar "b2"])
+        mlModel  = EDo [ DoBind "b1" normal05, DoBind "b2" normal05
+                       , DoExpr (observeE "y" catSmx "grade") ]
+                       (EApp (EVar "pure") (num 0))
+    it "softmax: 確率に正規化される (総和 ≈ 1)" $
+      (abs (sum (nums (evalV2 (smx [num 0, num 1, num 2]))) - 1) < 1e-9) `shouldBe` True
+    it "softmax: 一様入力 [0,0,0] は各クラス 1/3" $
+      all (\x -> abs (x - 1/3) < 1e-9) (nums (evalV2 (smx [num 0, num 0, num 0]))) `shouldBe` True
+    it "softmax: 大きな入力でも overflow しない (安定版 [1000,1000] → [0.5,0.5])" $
+      nums (evalV2 (smx [num 1000, num 1000]))
+        `shouldSatisfy` (\ys -> length ys == 2 && all (\y -> abs (y - 0.5) < 1e-9) ys)
+    it "softmax: 空リスト ([]) はエラー" $
+      isRight' (evalV2 (smx [])) `shouldBe` False
+    it "validateAst: Categorical (softmax [0,b1,b2]) 多項ロジットを受理する" $
+      HI.validateAst [] mlModel dm `shouldSatisfy` isRight'
+    it "interpStmts: softmax 多項ロジットが Model を構築し b1/b2 が latent に出る" $ do
+      let Right stmts = HI.validateAst [] mlModel dm
+          g = HBM.buildModelGraph (HI.interpStmts [] dm stmts)
+      (("b1" `elem` allNames g) && ("b2" `elem` allNames g)) `shouldBe` True
+
+    -- WAIC/PPC 再評価: combinator 由来 latent vector を posterior sample から
+    -- 再構築する (= NaN fallback でなく実分布が組める)。
+    it "computeObsDists: orderedCuts の cuts を sample (cut_d_2) から再構築" $ do
+      let Right stmts = HI.validateAst [] modelCuts dm
+          -- posterior 1 sample: sampled latent のみ (alpha/beta/cut_d_2)。
+          sample = M.fromList [("alpha", 0.5), ("beta", 1.0), ("cut_d_2", 2.0)]
+          dists  = concat (concatMap HI.odsDists (HI.computeObsDists [] stmts dm [sample]))
+          isOL d = case d of
+            HBM.OrderedLogistic _ cs -> length cs == 2 && all (not . isNaN) cs
+            _                        -> False
+      (not (null dists) && all isOL dists) `shouldBe` True
+    it "computeObsDists: dirichlet の probs を stick-breaking 再構築 (Σπ≈1)" $ do
+      let Right stmts = HI.validateAst [] modelDir dm
+          -- b0=0.3, b1=0.5 → π=[0.3, 0.35, 0.35]、 総和 1。
+          sample = M.fromList [("pi_b0", 0.3), ("pi_b1", 0.5)]
+          dists  = concat (concatMap HI.odsDists (HI.computeObsDists [] stmts dm [sample]))
+          isCat d = case d of
+            HBM.Categorical ps -> length ps == 3 && abs (sum ps - 1) < 1e-9
+            _                  -> False
+      (not (null dists) && all isCat dists) `shouldBe` True
+
+  describe "Hanalyze.Model.HBM.Interp MvNormal multi-column observe (Phase 44)" $ do
+    let num d    = ELit (LNumber d)
+        isRight' = either (const False) (const True)
+        allNames g = sort [ HBM.nodeName n | n <- HBM.mgNodes g ]
+        -- 2 列の連続観測 (y1, y2)。 行長は 4 で揃える。
+        dm = M.fromList
+          [ ("y1", HI.Numeric [1.0, 2.0, 3.0, 4.0])
+          , ("y2", HI.Numeric [1.5, 2.5, 2.0, 3.5]) ]
+        emptyEnv = M.empty :: HI.EnvA Double
+        evalD e  = HI.evalDist emptyEnv dm Nothing e :: Err (HBM.Distribution Double)
+        normal10 = EApp (EApp (EVar "Normal") (num 0)) (num 10)
+        -- MvNormal [mu1, mu2] [[1, 0.5], [0.5, 1]]
+        mvE muRow = EApp (EApp (EVar "MvNormal") muRow)
+                         (EList [ EList [num 1, num 0.5], EList [num 0.5, num 1] ])
+        mvLit = mvE (EList [num 0, num 0])
+        -- observeMV "y" (MvNormal …) ['y1', 'y2']
+        observeMvE dist colE = EApp (EApp (EApp (EVar "observeMV") (ELit (LText "y"))) dist) colE
+        cols2 = EList [ECol "y1", ECol "y2"]
+        -- latent μ + 固定 cov の最短モデル
+        mvModel = EDo [ DoBind "mu1" normal10, DoBind "mu2" normal10
+                      , DoExpr (observeMvE (mvE (EList [EVar "mu1", EVar "mu2"])) cols2) ]
+                      (EApp (EVar "pure") (num 0))
+
+    -- Phase 44.2: evalDist MvNormal
+    it "evalDist: MvNormal [mu] [[cov]] を構築する" $
+      show (evalD mvLit)
+        `shouldBe` show (Right (HBM.MvNormal [0, 0] [[1, 0.5], [0.5, 1]])
+                          :: Err (HBM.Distribution Double))
+    it "evalDist: 非正方 cov はエラー (2×3)" $
+      evalD (EApp (EApp (EVar "MvNormal") (EList [num 0, num 0]))
+                  (EList [ EList [num 1, num 0, num 0], EList [num 0, num 1, num 0] ]))
+        `shouldSatisfy` (not . isRight')
+    it "evalDist: μ 長と cov 次元の不一致はエラー (μ=3, cov=2×2)" $
+      evalD (mvE (EList [num 0, num 0, num 0])) `shouldSatisfy` (not . isRight')
+
+    -- Phase 44.1: observeMV 機構
+    it "asMatrix: VList-of-VList を [[a]] に落とす + 行長不一致はエラー" $ do
+      isRight' (HI.asMatrix (HI.VList [ HI.VList [HI.VNum (1::Double), HI.VNum 2]
+                                      , HI.VList [HI.VNum 3, HI.VNum 4] ])) `shouldBe` True
+      isRight' (HI.asMatrix (HI.VList [ HI.VList [HI.VNum (1::Double)]
+                                      , HI.VList [HI.VNum 3, HI.VNum 4] ])) `shouldBe` False
+    it "validateAst: latent μ + 固定 cov の observeMV を受理する" $
+      HI.validateAst [] mvModel dm `shouldSatisfy` isRight'
+    it "interpStmts: observeMV が Model を構築し latent mu1/mu2 が出る" $ do
+      let Right stmts = HI.validateAst [] mvModel dm
+          g = HBM.buildModelGraph (HI.interpStmts [] dm stmts)
+      (("mu1" `elem` allNames g) && ("mu2" `elem` allNames g)) `shouldBe` True
+    it "validateAst: observeMV の観測列が 1 列ならエラー (scalar observe を使うべき)" $ do
+      let m = EDo [ DoBind "mu1" normal10
+                  , DoExpr (observeMvE (mvE (EList [EVar "mu1"])) (EList [ECol "y1"])) ]
+                  (EApp (EVar "pure") (num 0))
+      HI.validateAst [] m dm `shouldSatisfy` (not . isRight')
+    it "validateAst: observeMV の観測列が欠落しているとエラー" $ do
+      let m = EDo [ DoBind "mu1" normal10, DoBind "mu2" normal10
+                  , DoExpr (observeMvE (mvE (EList [EVar "mu1", EVar "mu2"]))
+                                       (EList [ECol "y1", ECol "nope"])) ]
+                  (EApp (EVar "pure") (num 0))
+      HI.validateAst [] m dm `shouldSatisfy` (not . isRight')
+    it "validateAst: observeMV に scalar 分布 (Normal) を渡すと親切エラー" $ do
+      let m = EDo [ DoBind "mu1" normal10
+                  , DoExpr (observeMvE normal10 cols2) ]
+                  (EApp (EVar "pure") (num 0))
+      HI.validateAst [] m dm `shouldSatisfy` (not . isRight')
+
+    -- Phase 44.3: MvNormalChol 分布 + lkjCorrCholesky 行列値 bind (latent Σ)
+    let halfN1   = EApp (EVar "HalfNormal") (num 1)
+        -- lkjCorrCholesky "L" 2 2.0
+        lkjE k   = EApp (EApp (EApp (EVar "lkjCorrCholesky") (ELit (LText "L")))
+                              (num (fromIntegral (k :: Int)))) (num 2.0)
+        -- MvNormalChol [mu1,mu2] [s1,s2] L
+        mvcE muRow sigRow lRef =
+          EApp (EApp (EApp (EVar "MvNormalChol") muRow) sigRow) lRef
+        -- L <- lkjCorrCholesky "L" 2 2.0; s1/s2 <- HalfNormal 1;
+        -- mu1/mu2 <- Normal 0 10; observeMV "y" (MvNormalChol [mu1,mu2] [s1,s2] L) ['y1','y2']
+        mvcModel = EDo [ DoBind "L"   (lkjE 2)
+                       , DoBind "s1"  halfN1, DoBind "s2"  halfN1
+                       , DoBind "mu1" normal10, DoBind "mu2" normal10
+                       , DoExpr (observeMvE
+                           (mvcE (EList [EVar "mu1", EVar "mu2"])
+                                 (EList [EVar "s1", EVar "s2"]) (EVar "L")) cols2) ]
+                       (EApp (EVar "pure") (num 0))
+    -- 相関 Cholesky L (ρ=0.5) + σ=[2,3] → M=diag σ·L、 Σ=M Mᵀ=[[4,3],[3,9]]。
+    it "mvNormalCholLogDensity: full-Σ 版 (mvNormalLogDensity) と Σ=M Mᵀ で数値一致" $ do
+      let lCorr = [[1, 0], [0.5, sqrt 0.75]] :: [[Double]]
+          sig   = [2, 3] :: [Double]
+          mu    = [0, 0] :: [Double]
+          y     = [1, 2] :: [Double]
+          cov   = [[4, 3], [3, 9]] :: [[Double]]   -- = (diag σ·L)(diag σ·L)ᵀ
+          a = HBM.mvNormalCholLogDensity mu sig lCorr y
+          b = HBM.mvNormalLogDensity mu cov y
+      abs (a - b) < 1e-9 `shouldBe` True
+    it "evalDist: MvNormalChol [mu] [sigma] [[L]] を構築する" $
+      show (evalD (mvcE (EList [num 0, num 0]) (EList [num 1, num 1])
+                        (EList [EList [num 1, num 0], EList [num 0, num 1]])))
+        `shouldBe` show (Right (HBM.MvNormalChol [0,0] [1,1] [[1,0],[0,1]])
+                          :: Err (HBM.Distribution Double))
+    it "validateAst: lkjCorrCholesky bind + MvNormalChol observe を受理する" $
+      HI.validateAst [] mvcModel dm `shouldSatisfy` isRight'
+    it "interpStmts: lkjCorrCholesky が Model で走り内部 latent L_u1_0 が出る" $ do
+      let Right stmts = HI.validateAst [] mvcModel dm
+          g = HBM.buildModelGraph (HI.interpStmts [] dm stmts)
+      -- lkjCorrCholesky "L" 2 …: L_u1_0 (Beta latent) + L_pc1_0 (det)。
+      -- L_u1_0 の存在が combinator 実行の証明 (固定リテラルでは出ない)。
+      (("L_u1_0" `elem` allNames g) && ("mu1" `elem` allNames g)) `shouldBe` True
+    it "validateAst: lkjCorrCholesky の次元が 1 (k<2) ならエラー" $ do
+      let m = EDo [ DoBind "L" (lkjE 1)
+                  , DoBind "mu1" normal10, DoBind "mu2" normal10
+                  , DoExpr (observeMvE
+                      (mvcE (EList [EVar "mu1", EVar "mu2"])
+                            (EList [num 1, num 1]) (EVar "L")) cols2) ]
+                  (EApp (EVar "pure") (num 0))
+      HI.validateAst [] m dm `shouldSatisfy` (not . isRight')
+    it "evalDist: MvNormalChol の σ 長と μ 長の不一致はエラー" $
+      evalD (mvcE (EList [num 0, num 0]) (EList [num 1])
+                  (EList [EList [num 1, num 0], EList [num 0, num 1]]))
+        `shouldSatisfy` (not . isRight')
+
+    -- Phase 44.4: WAIC/PPC 再評価 (observeMV 専用並行経路)
+    let finiteAll = all (all (\x -> not (isNaN x || isInfinite x)))
+    it "reconstructMatrixComb: L_u1_0=0.5 (pc=0) から単位 Cholesky を再構築" $ do
+      let sm = M.fromList [("L_u1_0", 0.5 :: Double)]   -- pc = 2*0.5-1 = 0
+          Just l = HI.reconstructMatrixComb sm (HI.LkjCholSpec "L" 2)
+      (length l == 2 && abs ((l !! 0 !! 0) - 1) < 1e-9 && abs (l !! 1 !! 0) < 1e-9
+        && abs ((l !! 1 !! 1) - 1) < 1e-9) `shouldBe` True
+    it "reconstructMatrixComb: pc=0.5 → L=[[1,0],[0.5,√0.75]] (相関 0.5)" $ do
+      let sm = M.fromList [("L_u1_0", 0.75 :: Double)]  -- pc = 2*0.75-1 = 0.5
+          Just l = HI.reconstructMatrixComb sm (HI.LkjCholSpec "L" 2)
+      (abs (l !! 1 !! 0 - 0.5) < 1e-9 && abs (l !! 1 !! 1 - sqrt 0.75) < 1e-9) `shouldBe` True
+    it "computeMvObsDists: literal cov observeMV を sample から MvNormal に再評価" $ do
+      let Right stmts = HI.validateAst [] mvModel dm
+          sample = M.fromList [("mu1", 0.0), ("mu2", 0.0)]
+          sets = HI.computeMvObsDists [] stmts dm [sample]
+          isMv d = case d of HBM.MvNormal mu _ -> length mu == 2; _ -> False
+      (not (null sets) && all isMv (concat (HI.mvodsDists (head sets)))) `shouldBe` True
+    it "pointwiseLogLikMv: literal cov の log-lik が非空・有限" $ do
+      let Right stmts = HI.validateAst [] mvModel dm
+          sample = M.fromList [("mu1", 0.0), ("mu2", 0.0)]
+          ll = HI.pointwiseLogLikMv (HI.computeMvObsDists [] stmts dm [sample])
+      (not (null ll) && not (null (head ll)) && finiteAll ll) `shouldBe` True
+    it "computeMvObsDists+LogLik: latent Σ を L_u1_0 から MvNormalChol に再評価 (有限)" $ do
+      let Right stmts = HI.validateAst [] mvcModel dm
+          sample = M.fromList [ ("mu1", 0.0), ("mu2", 0.0), ("s1", 1.0), ("s2", 1.0)
+                              , ("L_u1_0", 0.75) ]
+          sets = HI.computeMvObsDists [] stmts dm [sample]
+          isChol d = case d of HBM.MvNormalChol mu _ _ -> length mu == 2; _ -> False
+          ll = HI.pointwiseLogLikMv sets
+      (not (null sets) && all isChol (concat (HI.mvodsDists (head sets)))
+        && finiteAll ll) `shouldBe` True
+
+  describe "Hanalyze.Model.HBM.Interp Mixture 分布リスト引数 (Phase 45)" $ do
+    let num d    = ELit (LNumber d)
+        isRight' = either (const False) (const True)
+        allNames g = sort [ HBM.nodeName n | n <- HBM.mgNodes g ]
+        -- 二峰性の連続観測列 (混合の主用途)。
+        dm = M.fromList [("value", HI.Numeric [1.0, 1.5, 8.0, 9.0])]
+        emptyEnv = M.empty :: HI.EnvA Double
+        evalD e  = HI.evalDist emptyEnv dm Nothing e :: Err (HBM.Distribution Double)
+        normalE m s = EApp (EApp (EVar "Normal") m) s
+        catE probs  = EApp (EVar "Categorical") (EList probs)
+        -- Mixture weights comps
+        mixE w c = EApp (EApp (EVar "Mixture") w) c
+        -- Mixture [0.3, 0.7] [Normal 0 1, Normal 5 1]
+        mixLit = mixE (EList [num 0.3, num 0.7])
+                      (EList [normalE (num 0) (num 1), normalE (num 5) (num 1)])
+
+    -- Phase 45.1: evalDist Mixture 節 (literal weights + 再帰 evalDist)
+    it "evalDist: Mixture [w1,w2] [Normal,Normal] を構築する" $
+      show (evalD mixLit)
+        `shouldBe` show (Right (HBM.Mixture [0.3, 0.7] [HBM.Normal 0 1, HBM.Normal 5 1])
+                          :: Err (HBM.Distribution Double))
+    it "evalDist: 第 2 引数の各成分を再帰 evalDist する (Categorical 成分のネスト)" $
+      show (evalD (mixE (EList [num 0.5, num 0.5])
+                        (EList [catE [num 0.2, num 0.8], catE [num 0.6, num 0.4]])))
+        `shouldBe` show (Right (HBM.Mixture [0.5, 0.5]
+                                 [HBM.Categorical [0.2, 0.8], HBM.Categorical [0.6, 0.4]])
+                          :: Err (HBM.Distribution Double))
+    it "evalDist: 成分分布が空 (Mixture [] []) はエラー" $
+      evalD (mixE (EList []) (EList [])) `shouldSatisfy` (not . isRight')
+    it "evalDist: 重み数 ≠ 成分数 (2 vs 1) はエラー" $
+      evalD (mixE (EList [num 0.3, num 0.7])
+                  (EList [normalE (num 0) (num 1)])) `shouldSatisfy` (not . isRight')
+    it "logDensity: 構築した Mixture は有限な対数密度を返す" $ do
+      let Right d = evalD mixLit
+          ld = HBM.logDensity d (4.0 :: Double)
+      (not (isNaN ld || isInfinite ld)) `shouldBe` True
+
+    -- literal weights + latent component mean の最短モデル
+    let normal010 = normalE (num 0) (num 10)
+        halfN2    = EApp (EVar "HalfNormal") (num 2)
+        observeE nm dist col =
+          EApp (EApp (EApp (EVar "observe") (ELit (LText nm))) dist) (ECol col)
+        -- Mixture [0.3,0.7] [Normal mu1 sigma, Normal mu2 sigma]
+        mixD = mixE (EList [num 0.3, num 0.7])
+                    (EList [ normalE (EVar "mu1") (EVar "sigma")
+                           , normalE (EVar "mu2") (EVar "sigma") ])
+        -- mu1/mu2 <- Normal 0 10; sigma <- HalfNormal 2;
+        -- observe "y" (Mixture …) 'value'
+        mixModel = EDo [ DoBind "mu1" normal010, DoBind "mu2" normal010
+                       , DoBind "sigma" halfN2
+                       , DoExpr (observeE "y" mixD "value") ]
+                       (EApp (EVar "pure") (num 0))
+    it "validateAst: literal weights Mixture observe を受理する" $
+      HI.validateAst [] mixModel dm `shouldSatisfy` isRight'
+    it "interpStmts: Mixture observe が Model を構築し latent mu1/mu2 が出る" $ do
+      let Right stmts = HI.validateAst [] mixModel dm
+          g = HBM.buildModelGraph (HI.interpStmts [] dm stmts)
+      (("mu1" `elem` allNames g) && ("mu2" `elem` allNames g)) `shouldBe` True
+    it "computeObsDists+LogLik: literal weights Mixture を sample から再評価 (有限)" $ do
+      let Right stmts = HI.validateAst [] mixModel dm
+          sample = M.fromList [("mu1", 1.0), ("mu2", 8.0), ("sigma", 1.0)]
+          sets = HI.computeObsDists [] stmts dm [sample]
+          ll = HI.pointwiseLogLik sets
+      (not (null sets) && not (null ll) && not (null (head ll))
+        && all (all (\x -> not (isNaN x || isInfinite x))) ll) `shouldBe` True
+
+    -- Phase 45.2: Dirichlet latent weights 経路 (Phase 43 dirichlet bind を流用)。
+    -- pi <- dirichlet "pi" [1,1]; mu1/mu2 <- Normal 0 10; sigma <- HalfNormal 2;
+    -- observe "y" (Mixture pi [Normal mu1 sigma, Normal mu2 sigma]) 'value'
+    let dirE = EApp (EApp (EVar "dirichlet") (ELit (LText "pi"))) (EList [num 1, num 1])
+        mixDirD = mixE (EVar "pi")
+                       (EList [ normalE (EVar "mu1") (EVar "sigma")
+                              , normalE (EVar "mu2") (EVar "sigma") ])
+        mixDirModel = EDo [ DoBind "pi" dirE
+                          , DoBind "mu1" normal010, DoBind "mu2" normal010
+                          , DoBind "sigma" halfN2
+                          , DoExpr (observeE "y" mixDirD "value") ]
+                          (EApp (EVar "pure") (num 0))
+    it "validateAst: Dirichlet latent 重み Mixture observe を受理する" $
+      HI.validateAst [] mixDirModel dm `shouldSatisfy` isRight'
+    it "interpStmts: dirichlet が走り内部 latent pi_b0 + mu1/mu2 が出る" $ do
+      let Right stmts = HI.validateAst [] mixDirModel dm
+          g = HBM.buildModelGraph (HI.interpStmts [] dm stmts)
+      (("pi_b0" `elem` allNames g) && ("mu1" `elem` allNames g)) `shouldBe` True
+    it "computeObsDists+LogLik: 重み pi を pi_b0 から再構築し Mixture 再評価 (有限)" $ do
+      -- combinator 内部 latent pi_b0 から reconstructComb が π を復元し、
+      -- Mixture の重みとして解決される (新規再構築コード不要 = scalar 経路流用)。
+      let Right stmts = HI.validateAst [] mixDirModel dm
+          sample = M.fromList [("pi_b0", 0.5), ("mu1", 1.0), ("mu2", 8.0), ("sigma", 1.0)]
+          sets = HI.computeObsDists [] stmts dm [sample]
+          ll = HI.pointwiseLogLik sets
+          isMix d = case d of HBM.Mixture ws _ -> length ws == 2; _ -> False
+      (not (null sets) && all isMix (concat (HI.odsDists (head sets)))
+        && not (null ll) && all (all (\x -> not (isNaN x || isInfinite x))) ll) `shouldBe` True
+
+-- ============================================================================
+-- Hanalyze.Model.Formula (Phase 46/15 §3.6 A15) — parser / AST / round-trip
+-- ============================================================================
diff --git a/test/Hanalyze/Model/HBM/LogpSpec.hs b/test/Hanalyze/Model/HBM/LogpSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Model/HBM/LogpSpec.hs
@@ -0,0 +1,1728 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Model.HBM.LogpSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub, mapAccumL, zip4)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Data.Text   as T
+import qualified Numeric.AD.Mode.Reverse.Double as RevD
+import qualified Data.Map.Strict as M
+import qualified Data.Set        as Set
+import qualified Hanalyze.Model.HBM as HBM
+import           Hanalyze.Fit (designHBMProgram)
+import qualified Data.Map.Strict    as M
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Phase 53: gradADU 勾配の正しさ (中心差分 ground truth)" $ do
+    let centralGrad m names trans us =
+          let h = 1e-5
+              f vs = HBM.logJointUnconstrained m names trans
+                       (M.fromList (zip names vs))
+              bump i d = [ if j == i then u + d else u
+                         | (j, u) <- zip [(0 :: Int) ..] us ]
+          in [ (f (bump i h) - f (bump i (-h))) / (2 * h)
+             | i <- [0 .. length us - 1] ]
+        closeVec tol a b =
+          length a == length b &&
+          and [ abs (x - y) <= tol * (1 + abs y) | (x, y) <- zip a b ]
+
+    it "M1 pooled 回帰 (latent 3: Normal×2 + Exp): gradADU ≈ 中心差分" $ do
+      let xs = [-1.0, -0.4, 0.2, 0.8, 1.5] :: [Double]
+          ys = [0.3, 1.1, 2.4, 3.0, 4.2] :: [Double]
+          m :: HBM.ModelP ()
+          m = do
+            a <- HBM.sample "a"     (HBM.Normal 0 10)
+            b <- HBM.sample "b"     (HBM.Normal 0 10)
+            s <- HBM.sample "sigma" (HBM.Exponential 1)
+            mapM_ (\(i, (x, y)) ->
+                     HBM.observe (T.pack ("y_" ++ show (i :: Int)))
+                       (HBM.Normal (a + b * realToFrac x) s) [y])
+                  (zip [0 ..] (zip xs ys))
+          names = HBM.sampleNames m
+          tmap  = HBM.getTransforms m
+          trans = [ tmap M.! n | n <- names ]
+          us    = [0.3, -0.2, 0.1]
+      closeVec 1e-4 (HBM.gradADU m names trans us) (centralGrad m names trans us)
+        `shouldBe` True
+
+    it "M2 random intercept GLMM (latent 6: HalfNormal/Exp 含む): gradADU ≈ 中心差分" $ do
+      let xRows = [ [1.0, -0.5], [1.0, 0.3], [1.0, 1.2]
+                  , [1.0, -0.8], [1.0, 0.6], [1.0, 0.1] ]
+          gids  = [0, 0, 0, 1, 1, 1]
+          ys    = [0.2, 0.9, 1.8, 0.5, 1.3, 1.0] :: [Double]
+          m :: HBM.ModelP ()
+          m = HBM.glmmRandomIntercept HBM.GlmmGaussian xRows gids ys
+          names = HBM.sampleNames m
+          tmap  = HBM.getTransforms m
+          trans = [ tmap M.! n | n <- names ]
+          us    = take (length names) [0.1, -0.2, 0.15, 0.05, -0.1, 0.2, 0.3, 0.0]
+      closeVec 1e-4 (HBM.gradADU m names trans us) (centralGrad m names trans us)
+        `shouldBe` True
+
+    it "GP-RBF (MvNormalGpRBF・Phase 95 B-dsl 閉形式随伴): gradADU ≈ 中心差分" $ do
+      -- gp_pois_regr データ (N=11)。 gradADU → compileGradUV → gpRBFAnalyticVG
+      -- (Cholesky を AD tape に載せない閉形式随伴) が真の密度の中心差分と一致するか。
+      let xs = [-10,-8,-6,-4,-2,0,2,4,6,8,10] :: [Double]
+          ys = [ 4.75906, 1.59423, 2.99548, 5.27501, 1.66472, 2.24347
+               , 2.8914, 4.08681, 4.60588, 0.802364, 3.92136 ] :: [Double]
+          m :: HBM.ModelP ()
+          m = do
+            rho   <- HBM.sample "rho"   (HBM.Gamma 25 4)
+            alpha <- HBM.sample "alpha" (HBM.HalfNormal 2)
+            sigma <- HBM.sample "sigma" (HBM.HalfNormal 1)
+            HBM.observeMV "y"
+              (HBM.MvNormalGpRBF (map realToFrac xs) alpha rho sigma) [ys]
+          names = HBM.sampleNames m
+          tmap  = HBM.getTransforms m
+          trans = [ tmap M.! n | n <- names ]
+          us    = [1.8, 0.88, 0.59]   -- exp(u) ≈ posterior 近傍 (ρ6,α2.4,σ1.8)
+      closeVec 1e-4 (HBM.gradADU m names trans us) (centralGrad m names trans us)
+        `shouldBe` True
+
+    it "HMM (HmmForwardNormal・Phase 92 A2 forward-backward 閉形式随伴): gradADU ≈ 中心差分" $ do
+      -- 14-hmm-example と同構造 (mu_2=mu_1+gap の順序制約 + dirichlet 遷移行)。
+      -- gradADU → gradValPlan → hmmAnalyticVG (forward-backward・AD tape ゼロ) が
+      -- 真の密度 (obsLogSum = hmmForwardLogLik) の中心差分と一致するか。
+      let ys = [0.5, 3.2, 9.8, 10.1, 2.7, 9.5, 0.1, 10.4] :: [Double]
+          kk = 2 :: Int
+          m :: HBM.ModelP ()
+          m = do
+            mu1 <- HBM.sample "mu_1" (HBM.Normal 3 1)
+            gap <- HBM.sample "gap" (HBM.HalfNormal 5)
+            mu2 <- HBM.deterministic "mu_2" (mu1 + gap)
+            HBM.potential "mu2_prior"
+              (HBM.logDensity (HBM.Normal 10 1) mu2
+                 - HBM.logDensity (HBM.HalfNormal 5) gap)
+            th1 <- HBM.dirichlet "theta1" (replicate kk 1)
+            th2 <- HBM.dirichlet "theta2" (replicate kk 1)
+            HBM.observeMV "y_seq"
+              (HBM.HmmForwardNormal (replicate kk 1) [th1, th2] [mu1, mu2] 1) [ys]
+          names = HBM.sampleNames m
+          tmap  = HBM.getTransforms m
+          trans = [ tmap M.! n | n <- names ]
+          us    = [0.4, 0.9, 0.3, -0.5]   -- mu_1, gap(log), theta1_b0(logit), theta2_b0(logit)
+      closeVec 1e-4 (HBM.gradADU m names trans us) (centralGrad m names trans us)
+        `shouldBe` True
+
+    it "HmmForwardNormal 密度 = 従来 potential (hmmForwardLogLik) 書きと一致 (Phase 92 A2)" $ do
+      -- 同一パラメータ点で logJointUnconstrained が旧書き方と一致 (移行の同値性)。
+      let ys = [0.5, 3.2, 9.8, 10.1, 2.7, 9.5, 0.1, 10.4] :: [Double]
+          kk = 2 :: Int
+          mNew :: HBM.ModelP ()
+          mNew = do
+            mu1 <- HBM.sample "mu_1" (HBM.Normal 3 1)
+            gap <- HBM.sample "gap" (HBM.HalfNormal 5)
+            _mu2 <- HBM.deterministic "mu_2" (mu1 + gap)
+            th1 <- HBM.dirichlet "theta1" (replicate kk 1)
+            th2 <- HBM.dirichlet "theta2" (replicate kk 1)
+            HBM.observeMV "y_seq"
+              (HBM.HmmForwardNormal (replicate kk 1) [th1, th2] [mu1, mu1 + gap] 1) [ys]
+          mOld :: HBM.ModelP ()
+          mOld = do
+            mu1 <- HBM.sample "mu_1" (HBM.Normal 3 1)
+            gap <- HBM.sample "gap" (HBM.HalfNormal 5)
+            _mu2 <- HBM.deterministic "mu_2" (mu1 + gap)
+            th1 <- HBM.dirichlet "theta1" (replicate kk 1)
+            th2 <- HBM.dirichlet "theta2" (replicate kk 1)
+            let emit = [ [ HBM.logDensity (HBM.Normal mu 1) (realToFrac y)
+                         | mu <- [mu1, mu1 + gap] ] | y <- ys ]
+            HBM.potential "hmm_loglik"
+              (HBM.hmmForwardLogLik (replicate kk 1) [th1, th2] emit)
+          names = HBM.sampleNames mNew
+          tmap  = HBM.getTransforms mNew
+          trans = [ tmap M.! n | n <- names ]
+          us    = M.fromList (zip names ([0.4, 0.9, 0.3, -0.5] :: [Double]))
+      abs (HBM.logJointUnconstrained mNew names trans us
+             - HBM.logJointUnconstrained mOld names trans us)
+        `shouldSatisfy` (< 1e-10)
+
+    it "ARMA(1,1) (ArmaNormal・Phase 101 A2 逆向き随伴の閉形式): gradADU ≈ 中心差分" $ do
+      -- 22-arma と同構造 (μ/φ/θ Normal prior + σ HalfCauchy)。gradADU →
+      -- gradValPlan → armaAnalyticVG (逆向き随伴再帰・AD tape ゼロ) が
+      -- 真の密度 (obsLogSum = err 再帰) の中心差分と一致するか。
+      let ys = [0.5, 0.8, 0.2, -0.4, 0.9, 1.3, 0.1, -0.7, 0.4, 0.6] :: [Double]
+          m :: HBM.ModelP ()
+          m = do
+            mu    <- HBM.sample "mu"    (HBM.Normal 0 10)
+            phi   <- HBM.sample "phi"   (HBM.Normal 0 2)
+            theta <- HBM.sample "theta" (HBM.Normal 0 2)
+            sigma <- HBM.sample "sigma" (HBM.HalfCauchy 2.5)
+            HBM.observeMV "y_seq" (HBM.ArmaNormal mu phi theta sigma) [ys]
+          names = HBM.sampleNames m
+          tmap  = HBM.getTransforms m
+          trans = [ tmap M.! n | n <- names ]
+          us    = [0.3, 0.7, -0.2, -0.4]   -- mu, phi, theta, sigma(log)
+      closeVec 1e-4 (HBM.gradADU m names trans us) (centralGrad m names trans us)
+        `shouldBe` True
+
+    it "ArmaNormal 密度 = 従来 mapAccumL + potential 書きと一致 (Phase 101 A2)" $ do
+      -- 同一パラメータ点で logJointUnconstrained が旧書き方と一致 (移行の同値性)。
+      let ys = [0.5, 0.8, 0.2, -0.4, 0.9, 1.3, 0.1, -0.7, 0.4, 0.6] :: [Double]
+          mNew :: HBM.ModelP ()
+          mNew = do
+            mu    <- HBM.sample "mu"    (HBM.Normal 0 10)
+            phi   <- HBM.sample "phi"   (HBM.Normal 0 2)
+            theta <- HBM.sample "theta" (HBM.Normal 0 2)
+            sigma <- HBM.sample "sigma" (HBM.HalfCauchy 2.5)
+            HBM.observeMV "y_seq" (HBM.ArmaNormal mu phi theta sigma) [ys]
+          mOld :: HBM.ModelP ()
+          mOld = do
+            mu    <- HBM.sample "mu"    (HBM.Normal 0 10)
+            phi   <- HBM.sample "phi"   (HBM.Normal 0 2)
+            theta <- HBM.sample "theta" (HBM.Normal 0 2)
+            sigma <- HBM.sample "sigma" (HBM.HalfCauchy 2.5)
+            let (y1 : rest) = map realToFrac ys
+                e1 = y1 - (mu + phi * mu)
+                step (prevY, prevErr) yt =
+                  let err = yt - (mu + phi * prevY + theta * prevErr)
+                  in ((yt, err), err)
+                errs = e1 : snd (mapAccumL step (y1, e1) rest)
+            HBM.potential "arma_loglik"
+              (sum [ HBM.logDensity (HBM.Normal 0 sigma) e | e <- errs ])
+          names = HBM.sampleNames mNew
+          tmap  = HBM.getTransforms mNew
+          trans = [ tmap M.! n | n <- names ]
+          us    = M.fromList (zip names ([0.3, 0.7, -0.2, -0.4] :: [Double]))
+      abs (HBM.logJointUnconstrained mNew names trans us
+             - HBM.logJointUnconstrained mOld names trans us)
+        `shouldSatisfy` (< 1e-10)
+
+    it "graded response IRT (GradedResponseIrt・Phase 101 A3 解析勾配): gradADU ≈ 中心差分" $ do
+      -- 20-bones と同構造 (theta のみ latent・delta/gamma/ncat は定数 data・
+      -- 欠測 −1 スキップ込)。gradADU → gradValPlan → gradedIrtAnalyticVG
+      -- (dQ/dθ = δ·Q(1−Q) の隣接差・AD tape ゼロ) が真の密度の中心差分と一致するか。
+      let ncats  = [3, 2] :: [Int]
+          deltas = [1.2, 0.7] :: [Double]
+          gammas = [[-0.5, 0.8], [0.1]] :: [[Double]]
+          grades = [1, 2, 3, -1, 2, 1] :: [Double]   -- 3 child × 2 item 行優先 (−1 = 欠測)
+          m :: HBM.ModelP ()
+          m = do
+            ths <- mapM (\i -> HBM.sample (T.pack ("theta_" ++ show (i :: Int)))
+                                 (HBM.Normal 0 6)) [0 .. 2]
+            HBM.observeMV "grades" (HBM.GradedResponseIrt ths ncats deltas gammas) [grades]
+          names = HBM.sampleNames m
+          tmap  = HBM.getTransforms m
+          trans = [ tmap M.! n | n <- names ]
+          us    = [0.4, -0.8, 1.1]
+      closeVec 1e-4 (HBM.gradADU m names trans us) (centralGrad m names trans us)
+        `shouldBe` True
+
+    it "GradedResponseIrt 密度 = 従来 logCatProb + potential 書きと一致 (Phase 101 A3)" $ do
+      -- 同一パラメータ点で logJointUnconstrained が旧書き方と一致 (移行の同値性)。
+      let ncats  = [3, 2] :: [Int]
+          deltas = [1.2, 0.7] :: [Double]
+          gammas = [[-0.5, 0.8], [0.1]] :: [[Double]]
+          grades = [1, 2, 3, -1, 2, 1] :: [Double]
+          logCatProb th nc dl gm gr =
+            let kMax = nc - 1
+                qs = [ 1 / (1 + exp (negate (realToFrac dl * (th - realToFrac (gm !! (kk - 1))))))
+                     | kk <- [1 .. kMax :: Int] ]
+                ps = [ if k == 1 then 1 - head qs
+                       else if k == nc then qs !! (kMax - 1)
+                       else (qs !! (k - 2)) - (qs !! (k - 1))
+                     | k <- [1 .. nc] ]
+            in log (ps !! (gr - 1))
+          mNew :: HBM.ModelP ()
+          mNew = do
+            ths <- mapM (\i -> HBM.sample (T.pack ("theta_" ++ show (i :: Int)))
+                                  (HBM.Normal 0 6)) [0 .. 2]
+            HBM.observeMV "grades" (HBM.GradedResponseIrt ths ncats deltas gammas) [grades]
+          mOld :: HBM.ModelP ()
+          mOld = do
+            ths <- mapM (\i -> HBM.sample (T.pack ("theta_" ++ show (i :: Int)))
+                                  (HBM.Normal 0 6)) [0 .. 2]
+            let rows = [ take 2 grades, take 2 (drop 2 grades), drop 4 grades ]
+                terms = [ logCatProb th nc dl gm (round gr)
+                        | (th, row) <- zip ths rows
+                        , (nc, dl, gm, gr) <- zip4 ncats deltas gammas row
+                        , gr /= -1 ]
+            HBM.potential "bones_loglik" (sum terms)
+          names = HBM.sampleNames mNew
+          tmap  = HBM.getTransforms mNew
+          trans = [ tmap M.! n | n <- names ]
+          us    = M.fromList (zip names ([0.4, -0.8, 1.1] :: [Double]))
+      abs (HBM.logJointUnconstrained mNew names trans us
+             - HBM.logJointUnconstrained mOld names trans us)
+        `shouldSatisfy` (< 1e-10)
+
+    it "irt-2pl 型 (LogNormal-latent-scale 解析勾配・Phase 98 A3): gradADU ≈ 中心差分" $ do
+      -- Bernoulli-logit 積尤度 (a_i·theta_j) で vecIR 経路に載り、a_i~LogNormal(0,σ_a)
+      -- (σ_a latent) が 'gradLogNormalIx' で解析勾配に載る (reverse-AD tape 全廃)。
+      -- theta は Normal 族で arena 吸収・σ 群は constPrior。真の密度の中心差分と一致確認。
+      let ys = [1,0,1, 0,1,0] :: [Double]   -- 2 item × 3 person の (i,j) 行優先
+          m :: HBM.ModelP ()
+          m = do
+            sigTheta <- HBM.sample "sigma_theta" (HBM.HalfCauchy 2)
+            th <- mapM (\j -> HBM.sample (T.pack ("theta_" ++ show (j :: Int)))
+                                (HBM.Normal 0 sigTheta)) [0 .. 2]
+            sigA <- HBM.sample "sigma_a" (HBM.HalfCauchy 2)
+            as <- mapM (\i -> HBM.sample (T.pack ("a_" ++ show (i :: Int)))
+                                (HBM.LogNormal 0 sigA)) [0 .. 1]
+            mapM_ (\((i, j), y) ->
+                     let logit = (as !! i) * (th !! j)
+                     in HBM.observe (T.pack ("y_" ++ show i ++ "_" ++ show j))
+                          (HBM.Bernoulli (1 / (1 + exp (negate logit)))) [y])
+                  (zip [ (i, j) | i <- [0 .. 1], j <- [0 .. 2] ] ys)
+          names = HBM.sampleNames m
+          tmap  = HBM.getTransforms m
+          trans = [ tmap M.! n | n <- names ]
+          us    = [0.2, 0.3, -0.1, 0.25, 0.15, 0.4, -0.2]  -- 7 latent (σθ,θ0-2,σa,a0-1)
+      closeVec 1e-4 (HBM.gradADU m names trans us) (centralGrad m names trans us)
+        `shouldBe` True
+
+  -- Phase 54.1: 構造化線形予測子 observe (ObserveLM)。 設計行列 X と β 名を
+  -- 分離保持する観測ブロックが、 per-obs observe を N 回呼ぶのと数値等価かを担保。
+  -- (54.2 で Gaussian-恒等リンクの suff-stat collapse に乗せる前提テスト)
+
+  describe "Phase 54.1: ObserveLM が per-obs observe と数値等価" $ do
+    let centralGrad m names trans us =
+          let h = 1e-5
+              f vs = HBM.logJointUnconstrained m names trans
+                       (M.fromList (zip names vs))
+              bump i d = [ if j == i then u + d else u
+                         | (j, u) <- zip [(0 :: Int) ..] us ]
+          in [ (f (bump i h) - f (bump i (-h))) / (2 * h)
+             | i <- [0 .. length us - 1] ]
+        closeVec tol a b =
+          length a == length b &&
+          and [ abs (x - y) <= tol * (1 + abs y) | (x, y) <- zip a b ]
+        xs = [-1.0, -0.4, 0.2, 0.8, 1.5] :: [Double]
+        designX = [ [1.0, x] | x <- xs ]   -- intercept + slope
+
+    it "Gaussian-identity: logJoint/gradADU が per-obs observe と一致 + 中心差分" $ do
+      let ys = [0.3, 1.1, 2.4, 3.0, 4.2] :: [Double]
+          mObs, mLM :: HBM.ModelP ()
+          mObs = do
+            a <- HBM.sample "a"     (HBM.Normal 0 10)
+            b <- HBM.sample "b"     (HBM.Normal 0 10)
+            s <- HBM.sample "sigma" (HBM.Exponential 1)
+            mapM_ (\(i, (x, y)) ->
+                     HBM.observe (T.pack ("y_" ++ show (i :: Int)))
+                       (HBM.Normal (a + b * realToFrac x) s) [y])
+                  (zip [0 ..] (zip xs ys))
+          mLM = do
+            _ <- HBM.sample "a"     (HBM.Normal 0 10)
+            _ <- HBM.sample "b"     (HBM.Normal 0 10)
+            _ <- HBM.sample "sigma" (HBM.Exponential 1)
+            HBM.observeLM "y" ["a", "b"] designX (HBM.LMGaussian "sigma") ys
+          names = HBM.sampleNames mLM
+          tmap  = HBM.getTransforms mLM
+          trans = [ tmap M.! n | n <- names ]
+          us    = [0.3, -0.2, 0.1]
+          -- Phase 60.7: TrackTag (非標準クラス) が defaulting を止めるので明示
+          ps    = M.fromList [("a", 0.4), ("b", 1.5), ("sigma", 0.6)]
+                    :: M.Map T.Text Double
+      -- sampleNames は ObserveLM が latent を増やさず一致
+      names `shouldBe` ["a", "b", "sigma"]
+      -- logJoint が per-obs と一致
+      closeVec 1e-9 [HBM.logJoint mLM ps] [HBM.logJoint mObs ps] `shouldBe` True
+      -- gradADU が per-obs と一致
+      closeVec 1e-7 (HBM.gradADU mLM names trans us)
+                    (HBM.gradADU mObs names trans us) `shouldBe` True
+      -- gradADU が中心差分 ground truth と一致
+      closeVec 1e-4 (HBM.gradADU mLM names trans us)
+                    (centralGrad mLM names trans us) `shouldBe` True
+
+    it "Poisson (log link): ObserveLM が per-obs observe と logJoint 一致" $ do
+      let ys = [1.0, 0.0, 3.0, 2.0, 5.0] :: [Double]
+          mObs, mLM :: HBM.ModelP ()
+          mObs = do
+            a <- HBM.sample "a" (HBM.Normal 0 10)
+            b <- HBM.sample "b" (HBM.Normal 0 10)
+            mapM_ (\(i, (x, y)) ->
+                     HBM.observe (T.pack ("y_" ++ show (i :: Int)))
+                       (HBM.Poisson (exp (a + b * realToFrac x))) [y])
+                  (zip [0 ..] (zip xs ys))
+          mLM = do
+            _ <- HBM.sample "a" (HBM.Normal 0 10)
+            _ <- HBM.sample "b" (HBM.Normal 0 10)
+            HBM.observeLM "y" ["a", "b"] designX HBM.LMPoisson ys
+          ps = M.fromList [("a", 0.2), ("b", 0.5)] :: M.Map T.Text Double
+      closeVec 1e-9 [HBM.logJoint mLM ps] [HBM.logJoint mObs ps] `shouldBe` True
+
+    it "Bernoulli (logit link): ObserveLM が per-obs observe と logJoint 一致" $ do
+      let ys = [1.0, 0.0, 1.0, 1.0, 0.0] :: [Double]
+          logistic z = 1 / (1 + exp (negate z))
+          mObs, mLM :: HBM.ModelP ()
+          mObs = do
+            a <- HBM.sample "a" (HBM.Normal 0 10)
+            b <- HBM.sample "b" (HBM.Normal 0 10)
+            mapM_ (\(i, (x, y)) ->
+                     HBM.observe (T.pack ("y_" ++ show (i :: Int)))
+                       (HBM.Bernoulli (logistic (a + b * realToFrac x))) [y])
+                  (zip [0 ..] (zip xs ys))
+          mLM = do
+            _ <- HBM.sample "a" (HBM.Normal 0 10)
+            _ <- HBM.sample "b" (HBM.Normal 0 10)
+            HBM.observeLM "y" ["a", "b"] designX HBM.LMBernoulli ys
+          ps = M.fromList [("a", -0.3), ("b", 0.8)] :: M.Map T.Text Double
+      closeVec 1e-9 [HBM.logJoint mLM ps] [HBM.logJoint mObs ps] `shouldBe` True
+
+    it "extractDeps: ObserveLM は観測 1 ノード・親 = β + σ" $ do
+      let ys = [0.3, 1.1, 2.4, 3.0, 4.2] :: [Double]
+          mLM :: HBM.ModelP ()
+          mLM = do
+            _ <- HBM.sample "a"     (HBM.Normal 0 10)
+            _ <- HBM.sample "b"     (HBM.Normal 0 10)
+            _ <- HBM.sample "sigma" (HBM.Exponential 1)
+            HBM.observeLM "y" ["a", "b"] designX (HBM.LMGaussian "sigma") ys
+          (nodes, _) = HBM.extractDeps mLM
+          yNode = head [ n | n <- nodes, HBM.nodeName n == "y" ]
+      HBM.nodeKind yNode `shouldBe` HBM.ObservedN (length ys)
+      HBM.nodeDeps yNode `shouldBe` Set.fromList ["a", "b", "sigma"]
+
+    it "observeLMR (REff gather): random intercept が per-obs observe と一致 + 中心差分" $ do
+      -- random intercept: η_i = a + b·x_i + u_{g(i)}、 g = [0,0,1,1,0]
+      let ys   = [0.3, 1.1, 2.4, 3.0, 4.2] :: [Double]
+          gids = [0, 0, 1, 1, 0] :: [Int]
+          mObs, mLMR :: HBM.ModelP ()
+          mObs = do
+            a  <- HBM.sample "a"     (HBM.Normal 0 10)
+            b  <- HBM.sample "b"     (HBM.Normal 0 10)
+            tu <- HBM.sample "tau_u" (HBM.HalfNormal 5)
+            u0 <- HBM.sample "u_0"   (HBM.Normal 0 tu)
+            u1 <- HBM.sample "u_1"   (HBM.Normal 0 tu)
+            s  <- HBM.sample "sigma" (HBM.Exponential 1)
+            let us = [u0, u1]
+            mapM_ (\(i, (x, g, y)) ->
+                     HBM.observe (T.pack ("y_" ++ show (i :: Int)))
+                       (HBM.Normal (a + b * realToFrac x + us !! g) s) [y])
+                  (zip [0 ..] (zip3 xs gids ys))
+          mLMR = do
+            _ <- HBM.sample "a"     (HBM.Normal 0 10)
+            _ <- HBM.sample "b"     (HBM.Normal 0 10)
+            tu <- HBM.sample "tau_u" (HBM.HalfNormal 5)
+            _ <- HBM.sample "u_0"   (HBM.Normal 0 tu)
+            _ <- HBM.sample "u_1"   (HBM.Normal 0 tu)
+            _ <- HBM.sample "sigma" (HBM.Exponential 1)
+            HBM.observeLMR "y" ["a", "b"] designX [HBM.REff ["u_0", "u_1"] gids Nothing Nothing Nothing]
+              (HBM.LMGaussian "sigma") ys
+          names = HBM.sampleNames mLMR
+          tmap  = HBM.getTransforms mLMR
+          trans = [ tmap M.! n | n <- names ]
+          us    = [0.3, -0.2, 0.1, 0.4, -0.3, 0.0]
+          ps    = M.fromList
+            [ ("a", 0.4), ("b", 1.5), ("tau_u", 0.7)
+            , ("u_0", 0.2), ("u_1", -0.3), ("sigma", 0.6) ]
+                    :: M.Map T.Text Double
+      names `shouldBe` ["a", "b", "tau_u", "u_0", "u_1", "sigma"]
+      -- logJoint が per-obs と一致
+      closeVec 1e-9 [HBM.logJoint mLMR ps] [HBM.logJoint mObs ps] `shouldBe` True
+      -- gradADU (ハイブリッド vec-tape) が per-obs ad と一致
+      closeVec 1e-7 (HBM.gradADU mLMR names trans us)
+                    (HBM.gradADU mObs names trans us) `shouldBe` True
+      -- gradADU が中心差分 ground truth と一致
+      closeVec 1e-4 (HBM.gradADU mLMR names trans us)
+                    (centralGrad mLMR names trans us) `shouldBe` True
+      -- extractDeps: 親 = β + u + σ
+      let (nodes, _) = HBM.extractDeps mLMR
+          yNode = head [ n | n <- nodes, HBM.nodeName n == "y" ]
+      HBM.nodeDeps yNode `shouldBe`
+        Set.fromList ["a", "b", "u_0", "u_1", "sigma"]
+
+    it "reNormal/at (Phase 54.4c): 解析 prior 勾配が ad 経路・中心差分と一致" $ do
+      -- 第一級ランダム効果 (reNormal/at) で組んだモデル mNew は u-prior 勾配を
+      -- 解析計算 + u_j Sample を ad から除外する。 文字列 REff (Nothing) で組んだ
+      -- mAd は prior を従来 ad で計算する。 両者は数値的に等価でなければならない。
+      let ys   = [0.3, 1.1, 2.4, 3.0, 4.2] :: [Double]
+          gids = [0, 0, 1, 1, 0] :: [Int]
+          mAd, mNew :: HBM.ModelP ()
+          mAd = do
+            _  <- HBM.sample "a"     (HBM.Normal 0 10)
+            _  <- HBM.sample "b"     (HBM.Normal 0 10)
+            tu <- HBM.sample "tau_u" (HBM.HalfNormal 5)
+            _  <- HBM.sample "u_0"   (HBM.Normal 0 tu)
+            _  <- HBM.sample "u_1"   (HBM.Normal 0 tu)
+            _  <- HBM.sample "sigma" (HBM.Exponential 1)
+            HBM.observeLMR "y" ["a", "b"] designX [HBM.REff ["u_0", "u_1"] gids Nothing Nothing Nothing]
+              (HBM.LMGaussian "sigma") ys
+          mNew = do
+            _   <- HBM.sample "a"     (HBM.Normal 0 10)
+            _   <- HBM.sample "b"     (HBM.Normal 0 10)
+            tau <- HBM.sample "tau_u" (HBM.HalfNormal 5)
+            u   <- HBM.reNormal "u" 2 "tau_u" tau
+            _   <- HBM.sample "sigma" (HBM.Exponential 1)
+            HBM.observeNormalLM "y" designX ["a", "b"] [u `HBM.at` gids] "sigma" ys
+          names = HBM.sampleNames mNew
+          tmap  = HBM.getTransforms mNew
+          trans = [ tmap M.! n | n <- names ]
+          us    = [0.3, -0.2, 0.1, 0.4, -0.3, 0.0]
+      -- mAd の prior には tau に依存する u_j prior が ad で入る。 mNew では u_j prior
+      -- を解析計算するが、 値は同一なので両勾配は一致するはず。
+      names `shouldBe` ["a", "b", "tau_u", "u_0", "u_1", "sigma"]
+      closeVec 1e-7 (HBM.gradADU mNew names trans us)
+                    (HBM.gradADU mAd names trans us) `shouldBe` True
+      closeVec 1e-4 (HBM.gradADU mNew names trans us)
+                    (centralGrad mNew names trans us) `shouldBe` True
+
+    it "compileLogPU (Phase 54.4d): 値評価が logJointUnconstrained と一致 (3 経路)" $ do
+      -- (1) 解析経路 (reNormal/at = Just scale) / (2) Nothing REff (LM vec のみ) /
+      -- (3) Gaussian LM 無し (scalar fallback)。 いずれも従来 walk 評価と一致すること。
+      let ys   = [0.3, 1.1, 2.4, 3.0, 4.2] :: [Double]
+          gids = [0, 0, 1, 1, 0] :: [Int]
+          mAna, mPlain, mScalar :: HBM.ModelP ()
+          mAna = do
+            _   <- HBM.sample "a"     (HBM.Normal 0 10)
+            _   <- HBM.sample "b"     (HBM.Normal 0 10)
+            tau <- HBM.sample "tau_u" (HBM.HalfNormal 5)
+            u   <- HBM.reNormal "u" 2 "tau_u" tau
+            _   <- HBM.sample "sigma" (HBM.Exponential 1)
+            HBM.observeNormalLM "y" designX ["a", "b"] [u `HBM.at` gids] "sigma" ys
+          mPlain = do
+            _  <- HBM.sample "a"     (HBM.Normal 0 10)
+            _  <- HBM.sample "b"     (HBM.Normal 0 10)
+            tu <- HBM.sample "tau_u" (HBM.HalfNormal 5)
+            _  <- HBM.sample "u_0"   (HBM.Normal 0 tu)
+            _  <- HBM.sample "u_1"   (HBM.Normal 0 tu)
+            _  <- HBM.sample "sigma" (HBM.Exponential 1)
+            HBM.observeLMR "y" ["a", "b"] designX [HBM.REff ["u_0", "u_1"] gids Nothing Nothing Nothing]
+              (HBM.LMGaussian "sigma") ys
+          mScalar = do
+            mu <- HBM.sample "mu" (HBM.Normal 0 10)
+            s  <- HBM.sample "s"  (HBM.Exponential 1)
+            HBM.observe "y" (HBM.Normal mu s) ys
+          checkEq :: HBM.ModelP () -> [Double] -> Expectation
+          checkEq mdl uvals = do
+            let nms  = HBM.sampleNames mdl
+                tmap = HBM.getTransforms mdl
+                trs  = [ tmap M.! n | n <- nms ]
+                pU   = M.fromList (zip nms uvals)
+            HBM.compileLogPU mdl nms trs uvals
+              `shouldSatisfy` (\v -> abs (v - HBM.logJointUnconstrained mdl nms trs pU) < 1e-9)
+      checkEq mAna    [0.3, -0.2, 0.1, 0.4, -0.3, 0.0]
+      checkEq mPlain  [0.3, -0.2, 0.1, 0.4, -0.3, 0.0]
+      checkEq mScalar [0.7, -0.1]
+
+    it "54.4e fallback: LM ブロック + scalar observe 混在 (residual 非空) で ad/中心差分一致" $ do
+      -- β/σ は定数パラメタ prior (解析勾配) だが scalar observe "z" が residual に
+      -- 残るので ad 経路も併用される。 値・勾配とも従来 walk / 中心差分と一致すること。
+      let ys = [0.3, 1.1, 2.4, 3.0, 4.2] :: [Double]
+          mMix :: HBM.ModelP ()
+          mMix = do
+            a <- HBM.sample "a"     (HBM.Normal 0 10)
+            _ <- HBM.sample "b"     (HBM.Normal 0 10)
+            _ <- HBM.sample "sigma" (HBM.Exponential 1)
+            HBM.observe "z" (HBM.Normal a 2) [0.5, 0.9]   -- residual に残る scalar observe
+            HBM.observeLM "y" ["a", "b"] designX (HBM.LMGaussian "sigma") ys
+          names = HBM.sampleNames mMix
+          tmap  = HBM.getTransforms mMix
+          trans = [ tmap M.! n | n <- names ]
+          us    = [0.3, -0.2, 0.1]
+          pU    = M.fromList (zip names us)
+      names `shouldBe` ["a", "b", "sigma"]
+      abs (HBM.compileLogPU mMix names trans us
+           - HBM.logJointUnconstrained mMix names trans pU) < 1e-9 `shouldBe` True
+      closeVec 1e-4 (HBM.gradADU mMix names trans us)
+                    (centralGrad mMix names trans us) `shouldBe` True
+
+    it "synthGaussLMBlocks (Phase 54.8): M1 per-obs 手書きが自動合成され値/勾配一致" $ do
+      -- per-obs scalar observe 手書きの pooled 回帰 (bench M1 形)。 affine 追跡で
+      -- ObserveLM ブロックに自動合成され、 全 Observe が吸収されること +
+      -- 値/勾配が従来 walk・中心差分と一致すること。
+      let ys = [0.3, 1.1, 2.4, 3.0, 4.2] :: [Double]
+          mM1 :: HBM.ModelP ()
+          mM1 = do
+            a <- HBM.sample "a"     (HBM.Normal 0 10)
+            b <- HBM.sample "b"     (HBM.Normal 0 10)
+            s <- HBM.sample "sigma" (HBM.Exponential 1)
+            mapM_ (\(i, (x, y)) ->
+                     HBM.observe (T.pack ("y_" ++ show (i :: Int)))
+                       (HBM.Normal (a + b * realToFrac x) s) [y])
+                  (zip [0 ..] (zip xs ys))
+          (blocks, absorbed) = HBM.synthGaussLMBlocks mM1
+          names = HBM.sampleNames mM1
+          tmap  = HBM.getTransforms mM1
+          trans = [ tmap M.! n | n <- names ]
+          us    = [0.3, -0.2, 0.1]
+          pU    = M.fromList (zip names us)
+      [ (bs, xs', re, sn, ys') | (_, bs, xs', re, sn, ys') <- blocks ]
+        `shouldBe` [ (["a", "b"], designX, [], "sigma", ys) ]
+      Set.toList absorbed
+        `shouldBe` [ T.pack ("y_" ++ show i) | i <- [0 .. 4 :: Int] ]
+      abs (HBM.compileLogPU mM1 names trans us
+           - HBM.logJointUnconstrained mM1 names trans pU) < 1e-9 `shouldBe` True
+      closeVec 1e-4 (HBM.gradADU mM1 names trans us)
+                    (centralGrad mM1 names trans us) `shouldBe` True
+
+    it "synthGaussLMBlocks (Phase 54.8): 階層 per-obs 手書きで one-hot 族が REff gather 化" $ do
+      -- m2Scalar 形: 係数常 1 の u_0/u_1 (prior = Normal(0, tau_u)) が dense 列で
+      -- なく REff gather (Just tau_u = 解析 prior 経路) に昇格すること。
+      let gids = [0, 0, 1, 1, 0] :: [Int]
+          ys   = [0.3, 1.1, 2.4, 3.0, 4.2] :: [Double]
+          mH :: HBM.ModelP ()
+          mH = do
+            a   <- HBM.sample "a"     (HBM.Normal 0 10)
+            b   <- HBM.sample "b"     (HBM.Normal 0 10)
+            tau <- HBM.sample "tau_u" (HBM.HalfNormal 5)
+            uvs <- mapM (\j -> HBM.sample (T.pack ("u_" ++ show (j :: Int)))
+                                          (HBM.Normal 0 tau)) [0, 1]
+            s   <- HBM.sample "sigma" (HBM.Exponential 1)
+            mapM_ (\(i, ((x, g), y)) ->
+                     HBM.observe (T.pack ("y_" ++ show (i :: Int)))
+                       (HBM.Normal (a + b * realToFrac x + uvs !! g) s) [y])
+                  (zip [0 ..] (zip (zip xs gids) ys))
+          (blocks, _) = HBM.synthGaussLMBlocks mH
+          names = HBM.sampleNames mH
+          tmap  = HBM.getTransforms mH
+          trans = [ tmap M.! n | n <- names ]
+          uv    = [0.3, -0.2, 0.1, 0.4, -0.3, 0.0]
+          pU    = M.fromList (zip names uv)
+      [ (bs, re) | (_, bs, _, re, _, _) <- blocks ]
+        `shouldBe` [ (["a", "b"], [HBM.REff ["u_0", "u_1"] gids (Just "tau_u") Nothing Nothing]) ]
+      abs (HBM.compileLogPU mH names trans uv
+           - HBM.logJointUnconstrained mH names trans pU) < 1e-9 `shouldBe` True
+      closeVec 1e-4 (HBM.gradADU mH names trans uv)
+                    (centralGrad mH names trans uv) `shouldBe` True
+
+    it "synthGaussLMBlocks (Phase 54.8): 値依存分岐は合成せず fallback (値/勾配は従来経路)" $ do
+      -- μ に値依存分岐 (if a > 0) を含むモデル。 AffV の Ord poison →
+      -- try/force 捕捉で合成全体が fallback し、 値/勾配は従来 ad 経路で正しいこと。
+      let mBr :: HBM.ModelP ()
+          mBr = do
+            a <- HBM.sample "a" (HBM.Normal 0 1)
+            s <- HBM.sample "s" (HBM.Exponential 1)
+            let mu = if a > 0 then a else negate a
+            HBM.observe "y" (HBM.Normal mu s) [0.5, 1.0]
+          (blocks, absorbed) = HBM.synthGaussLMBlocks mBr
+          names = HBM.sampleNames mBr
+          tmap  = HBM.getTransforms mBr
+          trans = [ tmap M.! n | n <- names ]
+          uv    = [0.7, -0.1]
+          pU    = M.fromList (zip names uv)
+      null blocks `shouldBe` True
+      Set.null absorbed `shouldBe` True
+      abs (HBM.compileLogPU mBr names trans uv
+           - HBM.logJointUnconstrained mBr names trans pU) < 1e-9 `shouldBe` True
+      closeVec 1e-4 (HBM.gradADU mBr names trans uv)
+                    (centralGrad mBr names trans uv) `shouldBe` True
+
+    it "synthGaussLMBlocks (Phase 54.10): random slope (係数付き) も u/v 二重族で REff 化" $ do
+      -- M3 形: u_g (係数 1) と v_g (係数 x_i) が、 ともに REff gather に昇格する
+      -- こと (v 族は per-row 重み = x・u 族は重み Nothing)。 β (b0/b1) は dense
+      -- 列のまま。 値/勾配は従来 walk・中心差分と一致。
+      let gids = [0, 0, 1, 1, 0] :: [Int]
+          ys   = [0.3, 1.1, 2.4, 3.0, 4.2] :: [Double]
+          mRS :: HBM.ModelP ()
+          mRS = do
+            b0  <- HBM.sample "b0"    (HBM.Normal 0 5)
+            b1  <- HBM.sample "b1"    (HBM.Normal 0 5)
+            tu  <- HBM.sample "tau_u" (HBM.HalfNormal 5)
+            tv  <- HBM.sample "tau_v" (HBM.HalfNormal 5)
+            uvs <- mapM (\j -> HBM.sample (T.pack ("u_" ++ show (j :: Int)))
+                                          (HBM.Normal 0 tu)) [0, 1]
+            vvs <- mapM (\j -> HBM.sample (T.pack ("v_" ++ show (j :: Int)))
+                                          (HBM.Normal 0 tv)) [0, 1]
+            s   <- HBM.sample "sigma" (HBM.Exponential 1)
+            mapM_ (\(i, ((x, g), y)) ->
+                     HBM.observe (T.pack ("y_" ++ show (i :: Int)))
+                       (HBM.Normal (b0 + b1 * realToFrac x + uvs !! g
+                                    + (vvs !! g) * realToFrac x) s) [y])
+                  (zip [0 ..] (zip (zip xs gids) ys))
+          (blocks, absorbed) = HBM.synthGaussLMBlocks mRS
+          names = HBM.sampleNames mRS
+          tmap  = HBM.getTransforms mRS
+          trans = [ tmap M.! n | n <- names ]
+          uv    = [0.3, -0.2, 0.1, 0.4, -0.3, 0.2, 0.15, -0.25, 0.0]
+          pU    = M.fromList (zip names uv)
+      [ (bs, re) | (_, bs, _, re, _, _) <- blocks ]
+        `shouldBe` [ (["b0", "b1"],
+                      [ HBM.REff ["u_0", "u_1"] gids (Just "tau_u") Nothing Nothing
+                      , HBM.REff ["v_0", "v_1"] gids (Just "tau_v") (Just xs) Nothing ]) ]
+      Set.size absorbed `shouldBe` 5
+      abs (HBM.compileLogPU mRS names trans uv
+           - HBM.logJointUnconstrained mRS names trans pU) < 1e-9 `shouldBe` True
+      closeVec 1e-4 (HBM.gradADU mRS names trans uv)
+                    (centralGrad mRS names trans uv) `shouldBe` True
+
+    it "observeLMR (Phase 54.10): 明示の重み付き REff が per-obs 手書きと値一致" $ do
+      -- 重み付き gather (η_i += w_i·v_{g_i}) を Nothing スケール (汎用 walk 経路)
+      -- で明示構築し、 同じモデルの per-obs 手書きと logJointUnconstrained が
+      -- 一致すること (lmReffEta の重み対応の直接検証)。
+      let gids = [0, 0, 1, 1, 0] :: [Int]
+          ys   = [0.3, 1.1, 2.4, 3.0, 4.2] :: [Double]
+          mW, mHand :: HBM.ModelP ()
+          mW = do
+            _  <- HBM.sample "a"     (HBM.Normal 0 10)
+            _  <- HBM.sample "b"     (HBM.Normal 0 10)
+            tv <- HBM.sample "tau_v" (HBM.HalfNormal 5)
+            _  <- HBM.sample "v_0"   (HBM.Normal 0 tv)
+            _  <- HBM.sample "v_1"   (HBM.Normal 0 tv)
+            _  <- HBM.sample "sigma" (HBM.Exponential 1)
+            HBM.observeLMR "y" ["a", "b"] designX
+              [HBM.REff ["v_0", "v_1"] gids Nothing (Just xs) Nothing]
+              (HBM.LMGaussian "sigma") ys
+          mHand = do
+            a  <- HBM.sample "a"     (HBM.Normal 0 10)
+            b  <- HBM.sample "b"     (HBM.Normal 0 10)
+            tv <- HBM.sample "tau_v" (HBM.HalfNormal 5)
+            vvs <- mapM (\j -> HBM.sample (T.pack ("v_" ++ show (j :: Int)))
+                                          (HBM.Normal 0 tv)) [0, 1]
+            s  <- HBM.sample "sigma" (HBM.Exponential 1)
+            mapM_ (\(i, ((x, g), y)) ->
+                     HBM.observe (T.pack ("y_" ++ show (i :: Int)))
+                       (HBM.Normal (a + b * realToFrac x
+                                    + (vvs !! g) * realToFrac x) s) [y])
+                  (zip [0 ..] (zip (zip xs gids) ys))
+          names = HBM.sampleNames mW
+          tmap  = HBM.getTransforms mW
+          trans = [ tmap M.! n | n <- names ]
+          uv    = [0.3, -0.2, 0.1, 0.4, -0.3, 0.0]
+          pU    = M.fromList (zip names uv)
+      names `shouldBe` HBM.sampleNames mHand
+      abs (HBM.logJointUnconstrained mW names trans pU
+           - HBM.logJointUnconstrained mHand names trans pU) < 1e-9 `shouldBe` True
+      abs (HBM.compileLogPU mW names trans uv
+           - HBM.logJointUnconstrained mW names trans pU) < 1e-9 `shouldBe` True
+      closeVec 1e-4 (HBM.gradADU mW names trans uv)
+                    (centralGrad mW names trans uv) `shouldBe` True
+
+    -- Phase 54.11 共通: 従来 ad の unconstrained 勾配 (fFull 相当) を参照値に。
+    let adGradRef :: HBM.ModelP () -> [T.Text] -> [Transform] -> [Double] -> [Double]
+        adGradRef mm nms trs uvs =
+          RevD.grad
+            (\uv' -> HBM.logJoint mm
+                       (M.fromList (zip nms (zipWith HBM.invTransformF trs uv')))
+                     + sum (zipWith HBM.logJacF trs uv'))
+            uvs
+
+    it "synthVecIR (Phase 54.11): M5 形 (非線形 μ) がベクトル式 IR 化され値/勾配一致" $ do
+      -- μ_i = a·exp(-b·x_i) + c (bench M5 形)。 affine 合成 (54.8) は不成立、
+      -- ベクトル式 IR が全 Observe を吸収し、 値/勾配が従来 ad・中心差分と一致。
+      let ys = [2.1, 1.7, 1.4, 1.2, 1.0] :: [Double]
+          m5 :: HBM.ModelP ()
+          m5 = do
+            a <- HBM.sample "a"     (HBM.Normal 0 10)
+            b <- HBM.sample "b"     (HBM.HalfNormal 2)
+            c <- HBM.sample "c"     (HBM.Normal 0 10)
+            s <- HBM.sample "sigma" (HBM.Exponential 1)
+            mapM_ (\(i, (x, y)) ->
+                     HBM.observe (T.pack ("y_" ++ show (i :: Int)))
+                       (HBM.Normal (a * exp (negate b * realToFrac x) + c) s) [y])
+                  (zip [0 ..] (zip xs ys))
+          names = HBM.sampleNames m5
+          tmap  = HBM.getTransforms m5
+          trans = [ tmap M.! n | n <- names ]
+          uvs   = [0.8, log 0.9, 0.3, log 0.4]
+          pU    = M.fromList (zip names uvs)
+      null (fst (HBM.synthGaussLMBlocks m5)) `shouldBe` True
+      case HBM.synthVecIR m5 of
+        Nothing -> expectationFailure "synthVecIR: expected Just"
+        Just (gs, fams, sObs) -> do
+          length gs `shouldBe` 1
+          length fams `shouldBe` 0
+          Set.toList sObs `shouldBe`
+            [ T.pack ("y_" ++ show i) | i <- [0 .. 4 :: Int] ]
+      abs (HBM.compileLogPU m5 names trans uvs
+           - HBM.logJointUnconstrained m5 names trans pU) < 1e-9 `shouldBe` True
+      closeVec 1e-9 (HBM.gradADU m5 names trans uvs)
+                    (adGradRef m5 names trans uvs) `shouldBe` True
+      closeVec 1e-4 (HBM.gradADU m5 names trans uvs)
+                    (centralGrad m5 names trans uvs) `shouldBe` True
+
+    it "synthVecIR (Phase 54.11): M6 形 (階層×非線形) で族 prior も IR に乗り値/勾配一致" $ do
+      -- μ_i = a_{g(i)}·exp(-b·x_i)、 a_g ~ Normal(μ_a, τ_a) (bench M6 形)。
+      -- a_g 族が gather + ベクトル化 prior として IR に乗ること。
+      let gids = [0, 0, 1, 1, 0] :: [Int]
+          ys   = [2.1, 1.7, 1.4, 1.2, 1.0] :: [Double]
+          m6 :: HBM.ModelP ()
+          m6 = do
+            muA  <- HBM.sample "mu_a"  (HBM.Normal 0 10)
+            tauA <- HBM.sample "tau_a" (HBM.HalfNormal 2)
+            as   <- mapM (\j -> HBM.sample (T.pack ("a_" ++ show (j :: Int)))
+                                           (HBM.Normal muA tauA)) [0, 1]
+            b    <- HBM.sample "b"     (HBM.HalfNormal 2)
+            s    <- HBM.sample "sigma" (HBM.Exponential 1)
+            mapM_ (\(i, ((x, g), y)) ->
+                     HBM.observe (T.pack ("y_" ++ show (i :: Int)))
+                       (HBM.Normal ((as !! g) * exp (negate b * realToFrac x)) s) [y])
+                  (zip [0 ..] (zip (zip xs gids) ys))
+          names = HBM.sampleNames m6
+          tmap  = HBM.getTransforms m6
+          trans = [ tmap M.! n | n <- names ]
+          uvs   = [1.5, log 0.6, 1.8, 1.6, log 0.9, log 0.4]
+          pU    = M.fromList (zip names uvs)
+      case HBM.synthVecIR m6 of
+        Nothing -> expectationFailure "synthVecIR: expected Just"
+        Just (gs, fams, _) -> do
+          length gs `shouldBe` 1
+          [ ms | (ms, _, _) <- fams ] `shouldBe` [["a_0", "a_1"]]
+      abs (HBM.compileLogPU m6 names trans uvs
+           - HBM.logJointUnconstrained m6 names trans pU) < 1e-9 `shouldBe` True
+      closeVec 1e-9 (HBM.gradADU m6 names trans uvs)
+                    (adGradRef m6 names trans uvs) `shouldBe` True
+      closeVec 1e-4 (HBM.gradADU m6 names trans uvs)
+                    (centralGrad m6 names trans uvs) `shouldBe` True
+
+    it "synthVecIR (Phase 54.11): 値依存分岐 (非線形) は合成せず fallback" $ do
+      -- μ に値依存分岐を含む非線形モデル。 SExp の Ord poison → try/force 捕捉で
+      -- IR 合成全体が fallback し、 値/勾配は従来 ad 経路で正しいこと。
+      let mBr :: HBM.ModelP ()
+          mBr = do
+            a <- HBM.sample "a" (HBM.Normal 0 1)
+            s <- HBM.sample "s" (HBM.Exponential 1)
+            let mu = if a > 0 then exp a else negate a
+            HBM.observe "y" (HBM.Normal mu s) [0.5, 1.0]
+          names = HBM.sampleNames mBr
+          tmap  = HBM.getTransforms mBr
+          trans = [ tmap M.! n | n <- names ]
+          uvs   = [0.7, -0.1]
+          pU    = M.fromList (zip names uvs)
+      (case HBM.synthVecIR mBr of Nothing -> True; Just _ -> False)
+        `shouldBe` True
+      abs (HBM.compileLogPU mBr names trans uvs
+           - HBM.logJointUnconstrained mBr names trans pU) < 1e-9 `shouldBe` True
+      closeVec 1e-4 (HBM.gradADU mBr names trans uvs)
+                    (centralGrad mBr names trans uvs) `shouldBe` True
+
+    it "synthVecIR (Phase 54.11→55.2): 式形混在 σ グループも指紋サブグループ化で全吸収" $ do
+      -- σ グループ s1 は同型非線形・s2 は行ごとに式の形が違う。 Phase 55.2 の
+      -- (σ名, μ式形指紋) サブグループ化により z_0/z_1 も**それぞれ独立の
+      -- グループとして吸収**される (54.11 時点では s2 丸ごと residual だった)。
+      let mMix :: HBM.ModelP ()
+          mMix = do
+            a  <- HBM.sample "a"  (HBM.Normal 0 10)
+            b  <- HBM.sample "b"  (HBM.HalfNormal 2)
+            s1 <- HBM.sample "s1" (HBM.Exponential 1)
+            s2 <- HBM.sample "s2" (HBM.Exponential 1)
+            mapM_ (\(i, (x, y)) ->
+                     HBM.observe (T.pack ("y1_" ++ show (i :: Int)))
+                       (HBM.Normal (a * exp (negate b * realToFrac x)) s1) [y])
+                  (zip [0 ..] (zip xs [2.1, 1.7, 1.4, 1.2, 1.0 :: Double]))
+            HBM.observe "z_0" (HBM.Normal (exp a) s2) [1.3]
+            HBM.observe "z_1" (HBM.Normal (a * a) s2) [0.9]
+          names = HBM.sampleNames mMix
+          tmap  = HBM.getTransforms mMix
+          trans = [ tmap M.! n | n <- names ]
+          uvs   = [0.5, log 0.8, log 0.6, log 0.7]
+          pU    = M.fromList (zip names uvs)
+      case HBM.synthVecIR mMix of
+        Nothing -> expectationFailure "synthVecIR: expected Just"
+        Just (gs, _, sObs) -> do
+          length gs `shouldBe` 3
+          Set.member "y1_0" sObs `shouldBe` True
+          Set.member "z_0" sObs `shouldBe` True
+          Set.member "z_1" sObs `shouldBe` True
+      abs (HBM.compileLogPU mMix names trans uvs
+           - HBM.logJointUnconstrained mMix names trans pU) < 1e-9 `shouldBe` True
+      closeVec 1e-9 (HBM.gradADU mMix names trans uvs)
+                    (adGradRef mMix names trans uvs) `shouldBe` True
+      closeVec 1e-4 (HBM.gradADU mMix names trans uvs)
+                    (centralGrad mMix names trans uvs) `shouldBe` True
+
+    it "synthVecIR (Phase 55.2): 同一 σ 下 2 形 (複数行ずつ) を両方吸収 + 非対応分布は residual" $ do
+      -- 同一 σ 下に exp a 群と a*a 群 (各 2 行) → 形指紋で 2 グループに割れて
+      -- 両方吸収。 AsymmetricLaplace 観測は対象外で residual walk に残る
+      -- (部分吸収の継続。 55.4 時点は StudentT だったが 56.3 で吸収対象化)。
+      let mShapes :: HBM.ModelP ()
+          mShapes = do
+            a <- HBM.sample "a" (HBM.Normal 0 10)
+            s <- HBM.sample "s" (HBM.Exponential 1)
+            mapM_ (\(i, y) ->
+                     HBM.observe (T.pack ("p_" ++ show (i :: Int)))
+                       (HBM.Normal (exp a) s) [y])
+                  (zip [0 ..] [1.3, 1.1 :: Double])
+            mapM_ (\(i, y) ->
+                     HBM.observe (T.pack ("q_" ++ show (i :: Int)))
+                       (HBM.Normal (a * a) s) [y])
+                  (zip [0 ..] [0.9, 0.7 :: Double])
+            HBM.observe "r" (HBM.AsymmetricLaplace s 1 a) [0.2]
+          names = HBM.sampleNames mShapes
+          tmap  = HBM.getTransforms mShapes
+          trans = [ tmap M.! n | n <- names ]
+          uvs   = [0.6, log 0.5]
+          pU    = M.fromList (zip names uvs)
+      case HBM.synthVecIR mShapes of
+        Nothing -> expectationFailure "synthVecIR: expected Just"
+        Just (gs, fams, sObs) -> do
+          length gs `shouldBe` 2
+          length fams `shouldBe` 0
+          Set.toList sObs `shouldBe` ["p_0", "p_1", "q_0", "q_1"]
+      abs (HBM.compileLogPU mShapes names trans uvs
+           - HBM.logJointUnconstrained mShapes names trans pU) < 1e-9 `shouldBe` True
+      closeVec 1e-9 (HBM.gradADU mShapes names trans uvs)
+                    (adGradRef mShapes names trans uvs) `shouldBe` True
+      closeVec 1e-4 (HBM.gradADU mShapes names trans uvs)
+                    (centralGrad mShapes names trans uvs) `shouldBe` True
+
+    it "synthVecIR (Phase 80.2): 非中心化相関 RE μ(L×z) が (a) 経路に載り値/勾配一致" $ do
+      -- 2 群・k=2 (切片 + 傾き 1)・群あたり 3 obs = 6 obs。 非中心化:
+      --   b_g^0 = τ0·z0_g,  b_g^1 = τ1·(pc·z0_g + √(1-pc²)·z1_g),  pc = 2u-1
+      --   μ_i   = β + b_{g(i)}^0 + b_{g(i)}^1·w_i,  y_i ~ Normal(μ_i, σ)
+      -- μ に latent×latent 積 (τ·z, L·z) が入る非 affine 形。 Phase 80.2 で probe を
+      -- ドメイン対応化する前は、 pcu (Beta ∈ (0,1)) が固定 probe 点 base=1.3 で
+      -- 域外 (pcu=1.74 → √(1-pc²)=NaN) となり誤 fallback していた。 unify/gather/
+      -- 族 prior は元から成立しており、 IR は数値的に忠実。
+      let gids = [0,0,0,1,1,1] :: [Int]
+          ws   = [-1.0, 0.0, 1.0, -0.5, 0.5, 1.5] :: [Double]
+          ys   = [0.2, 0.5, 1.1, -0.3, 0.4, 1.2]  :: [Double]
+          m :: HBM.ModelP ()
+          m = do
+            beta <- HBM.sample "beta"  (HBM.Normal 0 10)
+            sig  <- HBM.sample "sigma" (HBM.HalfNormal 5)
+            tau0 <- HBM.sample "tau0"  (HBM.HalfNormal 5)
+            tau1 <- HBM.sample "tau1"  (HBM.HalfNormal 5)
+            u    <- HBM.sample "pcu"   (HBM.Beta 2 2)
+            let pc  = 2 * u - 1
+                l11 = sqrt (1 - pc * pc)
+            z0 <- mapM (\g -> HBM.sample (T.pack ("z0_" ++ show (g :: Int)))
+                                         (HBM.Normal 0 1)) [0, 1]
+            z1 <- mapM (\g -> HBM.sample (T.pack ("z1_" ++ show (g :: Int)))
+                                         (HBM.Normal 0 1)) [0, 1]
+            let b0 g = tau0 * (z0 !! g)
+                b1 g = tau1 * (pc * (z0 !! g) + l11 * (z1 !! g))
+                mu i = beta + b0 (gids !! i) + b1 (gids !! i) * realToFrac (ws !! i)
+            mapM_ (\i -> HBM.observe (T.pack ("y_" ++ show i))
+                           (HBM.Normal (mu i) sig) [ys !! i])
+                  [0 .. 5 :: Int]
+          names = HBM.sampleNames m
+          tmap  = HBM.getTransforms m
+          trans = [ tmap M.! n | n <- names ]
+          -- unconstrained probe (変換で Beta→(0,1)・HalfNormal→(0,∞) に写り NaN 無し)。
+          uvs   = [ 0.15 + 0.07 * fromIntegral i | i <- [0 .. length names - 1] ]
+          pU    = M.fromList (zip names uvs)
+      -- (b) affine 合成には載らない (latent×latent) が、 (a) vecIR には載る。
+      null (fst (HBM.synthGaussLMBlocks m)) `shouldBe` True
+      case HBM.synthVecIR m of
+        Nothing -> expectationFailure "synthVecIR: expected Just (probe ドメイン対応後)"
+        Just (gs, fams, sObs) -> do
+          length gs `shouldBe` 1
+          Set.fromList [ ms | (ms, _, _) <- fams ]
+            `shouldBe` Set.fromList [["z0_0", "z0_1"], ["z1_0", "z1_1"]]
+          Set.toList sObs `shouldBe` [ T.pack ("y_" ++ show i) | i <- [0 .. 5 :: Int] ]
+      -- 値: compiled IR ≈ 従来 walk。
+      abs (HBM.compileLogPU m names trans uvs
+           - HBM.logJointUnconstrained m names trans pU) < 1e-9 `shouldBe` True
+      -- 勾配: IR ≈ 従来 ad (1e-9) ≈ 中心差分 (finite-diff 1e-6)。
+      closeVec 1e-9 (HBM.gradADU m names trans uvs)
+                    (adGradRef m names trans uvs) `shouldBe` True
+      closeVec 1e-6 (HBM.gradADU m names trans uvs)
+                    (centralGrad m names trans uvs) `shouldBe` True
+
+    it "synthVecIR (Phase 80.2b): 実 designHBMProgram (相関傾き) が非中心化 (a) に載る" $ do
+      -- Phase 80.2b: Fit.hs:designHBMProgram の相関傾き branch を非中心化へ改修した後、
+      -- 実コードが吐く ModelP が (b) affine には載らず (a) vecIR に載ることを担保。
+      -- 2 群・k=2 (切片 + 傾き 1)・群あたり 3 obs = 6 obs。 spike (Phase 80.2) と
+      -- 同型だが、 lkjCorrCholesky / deterministic b を経由する **実コード経路**を検証。
+      let gids    = [0,0,0,1,1,1] :: [Int]
+          ws      = [-1.0, 0.0, 1.0, -0.5, 0.5, 1.5] :: [Double]
+          ys      = [0.2, 0.5, 1.1, -0.3, 0.4, 1.2]  :: [Double]
+          designX = [ [1.0, w] | w <- ws ]           -- (Intercept) + temp
+          betaNames = ["(Intercept)", "temp"]
+          res     = [(gids, 2, [ws])]                -- 1 RE 群・傾き列 = temp
+          m :: HBM.ModelP ()
+          m       = designHBMProgram designX betaNames res ys
+          names   = HBM.sampleNames m
+          tmap    = HBM.getTransforms m
+          trans   = [ tmap M.! n | n <- names ]
+          -- unconstrained probe (変換で Beta→(0,1)/HalfNormal→(0,∞) に写り NaN 無し)。
+          uvs     = [ 0.1 + 0.05 * fromIntegral i | i <- [0 .. length names - 1] ]
+          pU      = M.fromList (zip names uvs)
+      -- (b) affine 合成には載らない (latent×latent = τ·L·z)。
+      null (fst (HBM.synthGaussLMBlocks m)) `shouldBe` True
+      -- (a) vecIR に載る (6 観測)。
+      case HBM.synthVecIR m of
+        Nothing -> expectationFailure "synthVecIR: expected Just (非中心化相関 RE が (a) に載る)"
+        Just (_gs, _fams, sObs) -> Set.size sObs `shouldBe` 6
+      -- 値: compiled IR ≈ 従来 walk。
+      abs (HBM.compileLogPU m names trans uvs
+           - HBM.logJointUnconstrained m names trans pU) < 1e-9 `shouldBe` True
+      -- 勾配: IR ≈ 従来 ad (1e-9) ≈ 中心差分 (finite-diff 1e-6)。
+      closeVec 1e-9 (HBM.gradADU m names trans uvs)
+                    (adGradRef m names trans uvs) `shouldBe` True
+      closeVec 1e-6 (HBM.gradADU m names trans uvs)
+                    (centralGrad m names trans uvs) `shouldBe` True
+
+    it "synthVecIR (Phase 55.3): σ がスカラ式 (定数倍 2·s) でも吸収され値/勾配一致" $ do
+      -- σ = 2*s は 54.11 の「σ = 単一 latent」 条件を満たさず residual 落ち
+      -- していた形。 55.3 で σ 位置が任意 SExp に拡張され吸収される。
+      let mScale :: HBM.ModelP ()
+          mScale = do
+            a <- HBM.sample "a" (HBM.Normal 0 10)
+            b <- HBM.sample "b" (HBM.HalfNormal 2)
+            s <- HBM.sample "s" (HBM.Exponential 1)
+            mapM_ (\(i, (x, y)) ->
+                     HBM.observe (T.pack ("y_" ++ show (i :: Int)))
+                       (HBM.Normal (a * exp (negate b * realToFrac x)) (2 * s))
+                       [y])
+                  (zip [0 ..] (zip xs [2.1, 1.7, 1.4, 1.2, 1.0 :: Double]))
+          names = HBM.sampleNames mScale
+          tmap  = HBM.getTransforms mScale
+          trans = [ tmap M.! n | n <- names ]
+          uvs   = [0.5, log 0.7, log 0.6]
+          pU    = M.fromList (zip names uvs)
+      null (fst (HBM.synthGaussLMBlocks mScale)) `shouldBe` True
+      case HBM.synthVecIR mScale of
+        Nothing -> expectationFailure "synthVecIR: expected Just"
+        Just (gs, _, sObs) -> do
+          length gs `shouldBe` 1
+          Set.size sObs `shouldBe` 5
+      abs (HBM.compileLogPU mScale names trans uvs
+           - HBM.logJointUnconstrained mScale names trans pU) < 1e-9 `shouldBe` True
+      closeVec 1e-9 (HBM.gradADU mScale names trans uvs)
+                    (adGradRef mScale names trans uvs) `shouldBe` True
+      closeVec 1e-4 (HBM.gradADU mScale names trans uvs)
+                    (centralGrad mScale names trans uvs) `shouldBe` True
+
+    it "synthVecIR (Phase 55.3): heteroscedastic σ_i = exp(g0+g1·z_i) がベクトル密度で吸収" $ do
+      -- σ が行依存 (z_i はデータ定数)。 名前付き σ 指紋で 1 グループに揃い、
+      -- UC 列を含む σ IR → ベクトル版密度 -Σlogσ_i - Σr_i²/(2σ_i²) (値/tape 両方)。
+      let zs = [0.2, -0.5, 1.0, 0.4, -1.2] :: [Double]
+          mHet :: HBM.ModelP ()
+          mHet = do
+            a  <- HBM.sample "a"  (HBM.Normal 0 10)
+            g0 <- HBM.sample "g0" (HBM.Normal 0 2)
+            g1 <- HBM.sample "g1" (HBM.Normal 0 2)
+            mapM_ (\(i, (z, y)) ->
+                     HBM.observe (T.pack ("y_" ++ show (i :: Int)))
+                       (HBM.Normal a (exp (g0 + g1 * realToFrac z))) [y])
+                  (zip [0 ..] (zip zs [1.4, 0.8, 1.9, 1.1, 0.5 :: Double]))
+          names = HBM.sampleNames mHet
+          tmap  = HBM.getTransforms mHet
+          trans = [ tmap M.! n | n <- names ]
+          uvs   = [0.9, -0.3, 0.4]
+          pU    = M.fromList (zip names uvs)
+      null (fst (HBM.synthGaussLMBlocks mHet)) `shouldBe` True
+      case HBM.synthVecIR mHet of
+        Nothing -> expectationFailure "synthVecIR: expected Just"
+        Just (gs, fams, sObs) -> do
+          length gs `shouldBe` 1
+          length fams `shouldBe` 0
+          Set.size sObs `shouldBe` 5
+      abs (HBM.compileLogPU mHet names trans uvs
+           - HBM.logJointUnconstrained mHet names trans pU) < 1e-9 `shouldBe` True
+      closeVec 1e-9 (HBM.gradADU mHet names trans uvs)
+                    (adGradRef mHet names trans uvs) `shouldBe` True
+      closeVec 1e-4 (HBM.gradADU mHet names trans uvs)
+                    (centralGrad mHet names trans uvs) `shouldBe` True
+
+    it "synthVecIR (Phase 55.4): M7 形 (Poisson 回帰 log link) が IR 吸収され値/勾配一致" $ do
+      -- y_i ~ Poisson(exp(a + b·x_i))。 非 Gaussian 観測の IR 化 (本丸)。
+      -- Σlog y_i! は compile 時前計算 (勾配に寄与しない)。
+      let ysP = [1, 0, 3, 2, 5] :: [Double]
+          m7 :: HBM.ModelP ()
+          m7 = do
+            a <- HBM.sample "a" (HBM.Normal 0 5)
+            b <- HBM.sample "b" (HBM.Normal 0 5)
+            mapM_ (\(i, (x, y)) ->
+                     HBM.observe (T.pack ("y_" ++ show (i :: Int)))
+                       (HBM.Poisson (exp (a + b * realToFrac x))) [y])
+                  (zip [0 ..] (zip xs ysP))
+          names = HBM.sampleNames m7
+          tmap  = HBM.getTransforms m7
+          trans = [ tmap M.! n | n <- names ]
+          uvs   = [0.4, 0.7]
+          pU    = M.fromList (zip names uvs)
+      null (fst (HBM.synthGaussLMBlocks m7)) `shouldBe` True
+      case HBM.synthVecIR m7 of
+        Nothing -> expectationFailure "synthVecIR: expected Just"
+        Just (gs, fams, sObs) -> do
+          length gs `shouldBe` 1
+          length fams `shouldBe` 0
+          Set.size sObs `shouldBe` 5
+      abs (HBM.compileLogPU m7 names trans uvs
+           - HBM.logJointUnconstrained m7 names trans pU) < 1e-9 `shouldBe` True
+      closeVec 1e-9 (HBM.gradADU m7 names trans uvs)
+                    (adGradRef m7 names trans uvs) `shouldBe` True
+      closeVec 1e-4 (HBM.gradADU m7 names trans uvs)
+                    (centralGrad m7 names trans uvs) `shouldBe` True
+
+    it "synthVecIR (Phase 55.4): M8 形 (logistic 回帰) が IR 吸収され値/勾配一致" $ do
+      -- y_i ~ Bernoulli(invLogit(a + b·x_i))。 y ∈ {0,1} は定数係数化。
+      let ysB = [1, 0, 1, 1, 0] :: [Double]
+          m8 :: HBM.ModelP ()
+          m8 = do
+            a <- HBM.sample "a" (HBM.Normal 0 5)
+            b <- HBM.sample "b" (HBM.Normal 0 5)
+            mapM_ (\(i, (x, y)) ->
+                     HBM.observe (T.pack ("y_" ++ show (i :: Int)))
+                       (HBM.Bernoulli
+                          (1 / (1 + exp (negate (a + b * realToFrac x))))) [y])
+                  (zip [0 ..] (zip xs ysB))
+          names = HBM.sampleNames m8
+          tmap  = HBM.getTransforms m8
+          trans = [ tmap M.! n | n <- names ]
+          uvs   = [0.2, 1.1]
+          pU    = M.fromList (zip names uvs)
+      case HBM.synthVecIR m8 of
+        Nothing -> expectationFailure "synthVecIR: expected Just"
+        Just (gs, fams, sObs) -> do
+          length gs `shouldBe` 1
+          length fams `shouldBe` 0
+          Set.size sObs `shouldBe` 5
+      abs (HBM.compileLogPU m8 names trans uvs
+           - HBM.logJointUnconstrained m8 names trans pU) < 1e-9 `shouldBe` True
+      closeVec 1e-9 (HBM.gradADU m8 names trans uvs)
+                    (adGradRef m8 names trans uvs) `shouldBe` True
+      closeVec 1e-4 (HBM.gradADU m8 names trans uvs)
+                    (centralGrad m8 names trans uvs) `shouldBe` True
+
+    it "synthVecIR (Phase 55.4): 階層 GLM 形 (Poisson + 群 intercept) で族 prior も IR に乗る" $ do
+      -- y_i ~ Poisson(exp(b0 + u_{g(i)}))、 u_g ~ Normal(0, τ)。 λ 式中の
+      -- 族 gather + 族 prior が既存機構のまま乗ること (M6 の Poisson 版)。
+      let gids = [0, 0, 1, 1, 0] :: [Int]
+          ysP  = [2, 1, 4, 3, 2] :: [Double]
+          mG :: HBM.ModelP ()
+          mG = do
+            b0  <- HBM.sample "b0"  (HBM.Normal 0 5)
+            tau <- HBM.sample "tau" (HBM.HalfNormal 2)
+            us  <- mapM (\j -> HBM.sample (T.pack ("u_" ++ show (j :: Int)))
+                                          (HBM.Normal 0 tau)) [0, 1]
+            mapM_ (\(i, (g, y)) ->
+                     HBM.observe (T.pack ("y_" ++ show (i :: Int)))
+                       (HBM.Poisson (exp (b0 + us !! g))) [y])
+                  (zip [0 ..] (zip gids ysP))
+          names = HBM.sampleNames mG
+          tmap  = HBM.getTransforms mG
+          trans = [ tmap M.! n | n <- names ]
+          uvs   = [0.5, log 0.8, 0.3, -0.2]
+          pU    = M.fromList (zip names uvs)
+      case HBM.synthVecIR mG of
+        Nothing -> expectationFailure "synthVecIR: expected Just"
+        Just (gs, fams, sObs) -> do
+          length gs `shouldBe` 1
+          [ ms | (ms, _, _) <- fams ] `shouldBe` [["u_0", "u_1"]]
+          Set.size sObs `shouldBe` 5
+      abs (HBM.compileLogPU mG names trans uvs
+           - HBM.logJointUnconstrained mG names trans pU) < 1e-9 `shouldBe` True
+      closeVec 1e-9 (HBM.gradADU mG names trans uvs)
+                    (adGradRef mG names trans uvs) `shouldBe` True
+      closeVec 1e-4 (HBM.gradADU mG names trans uvs)
+                    (centralGrad mG names trans uvs) `shouldBe` True
+
+    it "synthVecIR (Phase 55.4): 非対応分布 (AsymmetricLaplace obs のみ) は従来どおり fallback" $ do
+      -- 55.4 時点は StudentT で確認していたが 56.3 で吸収対象になったため、
+      -- 引き続き非対応の AsymmetricLaplace に差し替え。
+      let mT :: HBM.ModelP ()
+          mT = do
+            a <- HBM.sample "a" (HBM.Normal 0 5)
+            s <- HBM.sample "s" (HBM.Exponential 1)
+            HBM.observe "y" (HBM.AsymmetricLaplace s 1 a) [0.5, 1.0, -0.2]
+          names = HBM.sampleNames mT
+          tmap  = HBM.getTransforms mT
+          trans = [ tmap M.! n | n <- names ]
+          uvs   = [0.3, -0.1]
+          pU    = M.fromList (zip names uvs)
+      (case HBM.synthVecIR mT of Nothing -> True; Just _ -> False)
+        `shouldBe` True
+      abs (HBM.compileLogPU mT names trans uvs
+           - HBM.logJointUnconstrained mT names trans pU) < 1e-9 `shouldBe` True
+      closeVec 1e-4 (HBM.gradADU mT names trans uvs)
+                    (centralGrad mT names trans uvs) `shouldBe` True
+
+    it "digamma (Phase 56.1): 既知値 + 漸化式 + lgammaApprox 中心差分一致" $ do
+      let gammaE = 0.5772156649015329 :: Double
+      -- 既知値: ψ(1) = -γ, ψ(1/2) = -γ - 2 ln 2
+      abs (HBM.digamma 1.0 - negate gammaE) < 1e-9 `shouldBe` True
+      abs (HBM.digamma 0.5 - (negate gammaE - 2 * log 2)) < 1e-9 `shouldBe` True
+      -- 漸化式 ψ(x+1) = ψ(x) + 1/x (構成上ほぼ厳密)
+      mapM_ (\x -> abs (HBM.digamma (x + 1) - HBM.digamma x - 1 / x) < 1e-12
+                     `shouldBe` True)
+            [0.3, 1.7, 5.5, 20.0 :: Double]
+      -- 実際に使う lgammaApprox の数値微分 (中心差分 h=1e-5) と 1e-8 一致。
+      -- ⚠ x は整数を避ける: x±h が lgammaApprox の再帰段数境界 (x+k=12) を
+      -- 跨ぐと打切り誤差ジャンプ ~1e-9 が /2h 増幅され FD 自体が壊れる
+      -- (lgammaApprox の性質・digamma の問題ではない)。
+      let h = 1e-5
+          cd x = (HBM.lgammaApprox (x + h) - HBM.lgammaApprox (x - h)) / (2 * h)
+      mapM_ (\x -> abs (cd x - HBM.digamma x) < 1e-8 `shouldBe` True)
+            [0.7, 2.3, 8.1, 15.3, 40.2 :: Double]
+
+    it "synthVecIR (Phase 56.3): StudentT (ν=SC) robust 回帰形が IR 吸収され値/勾配一致" $ do
+      -- y_i ~ StudentT(4, a + b·x_i, σ)。 ν=SC 定数 → lgamma 項は compile 時
+      -- 定数化され密度は初等演算のみ (外れ値 8.0 入りの robust 回帰形)。
+      let ysT = [2.1, 1.7, 1.4, 1.2, 8.0] :: [Double]
+          mSt :: HBM.ModelP ()
+          mSt = do
+            a <- HBM.sample "a"     (HBM.Normal 0 10)
+            b <- HBM.sample "b"     (HBM.Normal 0 10)
+            s <- HBM.sample "sigma" (HBM.Exponential 1)
+            mapM_ (\(i, (x, y)) ->
+                     HBM.observe (T.pack ("y_" ++ show (i :: Int)))
+                       (HBM.StudentT 4 (a + b * realToFrac x) s) [y])
+                  (zip [0 ..] (zip xs ysT))
+          names = HBM.sampleNames mSt
+          tmap  = HBM.getTransforms mSt
+          trans = [ tmap M.! n | n <- names ]
+          uvs   = [1.4, -0.6, log 0.5]
+          pU    = M.fromList (zip names uvs)
+      case HBM.synthVecIR mSt of
+        Nothing -> expectationFailure "synthVecIR: expected Just"
+        Just (gs, fams, sObs) -> do
+          length gs `shouldBe` 1
+          length fams `shouldBe` 0
+          Set.size sObs `shouldBe` 5
+      abs (HBM.compileLogPU mSt names trans uvs
+           - HBM.logJointUnconstrained mSt names trans pU) < 1e-9 `shouldBe` True
+      closeVec 1e-9 (HBM.gradADU mSt names trans uvs)
+                    (adGradRef mSt names trans uvs) `shouldBe` True
+      closeVec 1e-4 (HBM.gradADU mSt names trans uvs)
+                    (centralGrad mSt names trans uvs) `shouldBe` True
+
+    it "synthVecIR (Phase 56.3): StudentT の ν latent は fallback (計画 scope どおり)" $ do
+      -- ν を latent にすると lgamma(ν) 項が定数化できず収集対象外 →
+      -- IR 合成されず従来 ad 経路で値/勾配が正しいこと。
+      let mNu :: HBM.ModelP ()
+          mNu = do
+            nu <- HBM.sample "nu" (HBM.Exponential 0.1)
+            a  <- HBM.sample "a"  (HBM.Normal 0 10)
+            s  <- HBM.sample "s"  (HBM.Exponential 1)
+            HBM.observe "y" (HBM.StudentT nu a s) [0.5, 1.0, -0.2]
+          names = HBM.sampleNames mNu
+          tmap  = HBM.getTransforms mNu
+          trans = [ tmap M.! n | n <- names ]
+          uvs   = [log 4, 0.3, log 0.7]
+          pU    = M.fromList (zip names uvs)
+      (case HBM.synthVecIR mNu of Nothing -> True; Just _ -> False)
+        `shouldBe` True
+      abs (HBM.compileLogPU mNu names trans uvs
+           - HBM.logJointUnconstrained mNu names trans pU) < 1e-9 `shouldBe` True
+      closeVec 1e-4 (HBM.gradADU mNu names trans uvs)
+                    (centralGrad mNu names trans uvs) `shouldBe` True
+
+    it "synthVecIR (Phase 56.3): Cauchy robust 回帰形が IR 吸収され値/勾配一致" $ do
+      -- y_i ~ Cauchy(a + b·x_i, γ)。 logp = -n·logπ - Σlogγ - Σlog(1+z²)。
+      let ysC = [2.1, 1.7, 1.4, 1.2, 8.0] :: [Double]
+          mC :: HBM.ModelP ()
+          mC = do
+            a <- HBM.sample "a" (HBM.Normal 0 10)
+            b <- HBM.sample "b" (HBM.Normal 0 10)
+            g <- HBM.sample "gamma" (HBM.Exponential 1)
+            mapM_ (\(i, (x, y)) ->
+                     HBM.observe (T.pack ("y_" ++ show (i :: Int)))
+                       (HBM.Cauchy (a + b * realToFrac x) g) [y])
+                  (zip [0 ..] (zip xs ysC))
+          names = HBM.sampleNames mC
+          tmap  = HBM.getTransforms mC
+          trans = [ tmap M.! n | n <- names ]
+          uvs   = [1.4, -0.6, log 0.5]
+          pU    = M.fromList (zip names uvs)
+      case HBM.synthVecIR mC of
+        Nothing -> expectationFailure "synthVecIR: expected Just"
+        Just (gs, fams, sObs) -> do
+          length gs `shouldBe` 1
+          length fams `shouldBe` 0
+          Set.size sObs `shouldBe` 5
+      abs (HBM.compileLogPU mC names trans uvs
+           - HBM.logJointUnconstrained mC names trans pU) < 1e-9 `shouldBe` True
+      closeVec 1e-9 (HBM.gradADU mC names trans uvs)
+                    (adGradRef mC names trans uvs) `shouldBe` True
+      closeVec 1e-4 (HBM.gradADU mC names trans uvs)
+                    (centralGrad mC names trans uvs) `shouldBe` True
+
+    it "synthVecIR (Phase 56.3): Logistic 回帰形が IR 吸収され値/勾配一致" $ do
+      -- y_i ~ Logistic(a + b·x_i, s)。 logp = -Σz - Σlog s - 2·Σlog(1+exp(-z))。
+      let ysL = [2.1, 1.7, 1.4, 1.2, 1.0] :: [Double]
+          mL :: HBM.ModelP ()
+          mL = do
+            a <- HBM.sample "a" (HBM.Normal 0 10)
+            b <- HBM.sample "b" (HBM.Normal 0 10)
+            s <- HBM.sample "s" (HBM.Exponential 1)
+            mapM_ (\(i, (x, y)) ->
+                     HBM.observe (T.pack ("y_" ++ show (i :: Int)))
+                       (HBM.Logistic (a + b * realToFrac x) s) [y])
+                  (zip [0 ..] (zip xs ysL))
+          names = HBM.sampleNames mL
+          tmap  = HBM.getTransforms mL
+          trans = [ tmap M.! n | n <- names ]
+          uvs   = [1.4, -0.6, log 0.5]
+          pU    = M.fromList (zip names uvs)
+      case HBM.synthVecIR mL of
+        Nothing -> expectationFailure "synthVecIR: expected Just"
+        Just (gs, fams, sObs) -> do
+          length gs `shouldBe` 1
+          length fams `shouldBe` 0
+          Set.size sObs `shouldBe` 5
+      abs (HBM.compileLogPU mL names trans uvs
+           - HBM.logJointUnconstrained mL names trans pU) < 1e-9 `shouldBe` True
+      closeVec 1e-9 (HBM.gradADU mL names trans uvs)
+                    (adGradRef mL names trans uvs) `shouldBe` True
+      closeVec 1e-4 (HBM.gradADU mL names trans uvs)
+                    (centralGrad mL names trans uvs) `shouldBe` True
+
+    it "synthVecIR (Phase 56.3): Logistic heteroscedastic s_i = exp(g0+g1·x_i) もベクトル密度で吸収" $ do
+      -- scale 行依存 → Σlog s_i のベクトル分岐 ('sumLogScale' の RUSum 側) カバー。
+      let ysL = [2.1, 1.7, 1.4, 1.2, 1.0] :: [Double]
+          mLh :: HBM.ModelP ()
+          mLh = do
+            a  <- HBM.sample "a"  (HBM.Normal 0 10)
+            g0 <- HBM.sample "g0" (HBM.Normal 0 2)
+            g1 <- HBM.sample "g1" (HBM.Normal 0 2)
+            mapM_ (\(i, (x, y)) ->
+                     HBM.observe (T.pack ("y_" ++ show (i :: Int)))
+                       (HBM.Logistic a (exp (g0 + g1 * realToFrac x))) [y])
+                  (zip [0 ..] (zip xs ysL))
+          names = HBM.sampleNames mLh
+          tmap  = HBM.getTransforms mLh
+          trans = [ tmap M.! n | n <- names ]
+          uvs   = [1.4, -0.3, 0.2]
+          pU    = M.fromList (zip names uvs)
+      case HBM.synthVecIR mLh of
+        Nothing -> expectationFailure "synthVecIR: expected Just"
+        Just (gs, fams, sObs) -> do
+          length gs `shouldBe` 1
+          length fams `shouldBe` 0
+          Set.size sObs `shouldBe` 5
+      abs (HBM.compileLogPU mLh names trans uvs
+           - HBM.logJointUnconstrained mLh names trans pU) < 1e-9 `shouldBe` True
+      closeVec 1e-9 (HBM.gradADU mLh names trans uvs)
+                    (adGradRef mLh names trans uvs) `shouldBe` True
+      closeVec 1e-4 (HBM.gradADU mLh names trans uvs)
+                    (centralGrad mLh names trans uvs) `shouldBe` True
+
+    it "synthVecIR (Phase 56.3): Gumbel 回帰形が IR 吸収され値/勾配一致" $ do
+      -- y_i ~ Gumbel(a + b·x_i, β)。 logp = -Σlogβ - Σz - Σexp(-z) (極値回帰形)。
+      let ysG = [2.5, 2.0, 1.8, 1.5, 3.2] :: [Double]
+          mGu :: HBM.ModelP ()
+          mGu = do
+            a <- HBM.sample "a" (HBM.Normal 0 10)
+            b <- HBM.sample "b" (HBM.Normal 0 10)
+            be <- HBM.sample "beta" (HBM.Exponential 1)
+            mapM_ (\(i, (x, y)) ->
+                     HBM.observe (T.pack ("y_" ++ show (i :: Int)))
+                       (HBM.Gumbel (a + b * realToFrac x) be) [y])
+                  (zip [0 ..] (zip xs ysG))
+          names = HBM.sampleNames mGu
+          tmap  = HBM.getTransforms mGu
+          trans = [ tmap M.! n | n <- names ]
+          uvs   = [1.8, -0.4, log 0.6]
+          pU    = M.fromList (zip names uvs)
+      case HBM.synthVecIR mGu of
+        Nothing -> expectationFailure "synthVecIR: expected Just"
+        Just (gs, fams, sObs) -> do
+          length gs `shouldBe` 1
+          length fams `shouldBe` 0
+          Set.size sObs `shouldBe` 5
+      abs (HBM.compileLogPU mGu names trans uvs
+           - HBM.logJointUnconstrained mGu names trans pU) < 1e-9 `shouldBe` True
+      closeVec 1e-9 (HBM.gradADU mGu names trans uvs)
+                    (adGradRef mGu names trans uvs) `shouldBe` True
+      closeVec 1e-4 (HBM.gradADU mGu names trans uvs)
+                    (centralGrad mGu names trans uvs) `shouldBe` True
+
+    it "synthVecIR (Phase 56.4): Exponential 生存形 (rate=exp(η)) が IR 吸収され値/勾配一致" $ do
+      -- y_i ~ Exponential(exp(a + b·x_i))。 logp = Σlog rate - Σ rate·y。
+      let ysE = [0.8, 1.5, 0.3, 2.1, 0.6] :: [Double]
+          mE :: HBM.ModelP ()
+          mE = do
+            a <- HBM.sample "a" (HBM.Normal 0 5)
+            b <- HBM.sample "b" (HBM.Normal 0 5)
+            mapM_ (\(i, (x, y)) ->
+                     HBM.observe (T.pack ("y_" ++ show (i :: Int)))
+                       (HBM.Exponential (exp (a + b * realToFrac x))) [y])
+                  (zip [0 ..] (zip xs ysE))
+          names = HBM.sampleNames mE
+          tmap  = HBM.getTransforms mE
+          trans = [ tmap M.! n | n <- names ]
+          uvs   = [0.4, -0.3]
+          pU    = M.fromList (zip names uvs)
+      case HBM.synthVecIR mE of
+        Nothing -> expectationFailure "synthVecIR: expected Just"
+        Just (gs, fams, sObs) -> do
+          length gs `shouldBe` 1
+          length fams `shouldBe` 0
+          Set.size sObs `shouldBe` 5
+      abs (HBM.compileLogPU mE names trans uvs
+           - HBM.logJointUnconstrained mE names trans pU) < 1e-9 `shouldBe` True
+      closeVec 1e-9 (HBM.gradADU mE names trans uvs)
+                    (adGradRef mE names trans uvs) `shouldBe` True
+      closeVec 1e-4 (HBM.gradADU mE names trans uvs)
+                    (centralGrad mE names trans uvs) `shouldBe` True
+
+    it "synthVecIR (Phase 56.4): 定義域外 y (負値) を含むグループは収集拒否で fallback" $ do
+      -- Exponential 観測に y < 0 が混在 → グループ丸ごと吸収しない
+      -- (walk の -∞ 縮退をそのまま残す安全方向・55.4 の Poisson と同じ規律)。
+      let mNeg :: HBM.ModelP ()
+          mNeg = do
+            a <- HBM.sample "a" (HBM.Normal 0 5)
+            HBM.observe "y" (HBM.Exponential (exp a)) [0.8, -0.5, 1.2]
+      (case HBM.synthVecIR mNeg of Nothing -> True; Just _ -> False)
+        `shouldBe` True
+
+    it "synthVecIR (Phase 56.4): Weibull 生存形 (k latent, λ=exp(η)) が IR 吸収され値/勾配一致" $ do
+      -- y_i ~ Weibull(k, exp(a + b·x_i))。 (y/λ)^k = exp(k·(log y - log λ)) の
+      -- 初等化で k も latent のまま吸収 (lgamma 不要)。
+      let ysW = [0.8, 1.5, 0.3, 2.1, 0.6] :: [Double]
+          mW :: HBM.ModelP ()
+          mW = do
+            k <- HBM.sample "k" (HBM.Exponential 1)
+            a <- HBM.sample "a" (HBM.Normal 0 5)
+            b <- HBM.sample "b" (HBM.Normal 0 5)
+            mapM_ (\(i, (x, y)) ->
+                     HBM.observe (T.pack ("y_" ++ show (i :: Int)))
+                       (HBM.Weibull k (exp (a + b * realToFrac x))) [y])
+                  (zip [0 ..] (zip xs ysW))
+          names = HBM.sampleNames mW
+          tmap  = HBM.getTransforms mW
+          trans = [ tmap M.! n | n <- names ]
+          uvs   = [log 1.3, 0.4, -0.3]
+          pU    = M.fromList (zip names uvs)
+      case HBM.synthVecIR mW of
+        Nothing -> expectationFailure "synthVecIR: expected Just"
+        Just (gs, fams, sObs) -> do
+          length gs `shouldBe` 1
+          length fams `shouldBe` 0
+          Set.size sObs `shouldBe` 5
+      abs (HBM.compileLogPU mW names trans uvs
+           - HBM.logJointUnconstrained mW names trans pU) < 1e-9 `shouldBe` True
+      closeVec 1e-9 (HBM.gradADU mW names trans uvs)
+                    (adGradRef mW names trans uvs) `shouldBe` True
+      closeVec 1e-4 (HBM.gradADU mW names trans uvs)
+                    (centralGrad mW names trans uvs) `shouldBe` True
+
+    it "synthVecIR (Phase 56.4): LogNormal 回帰形が Gaussian ノード再利用で IR 吸収され値/勾配一致" $ do
+      -- y_i ~ LogNormal(a + b·x_i, σ)。 log y 前計算 → VOGauss densityIR
+      -- 再利用 + 定数 -Σlog y (新密度ノード無し・計画どおり)。
+      let ysLn = [0.8, 1.5, 0.3, 2.1, 0.6] :: [Double]
+          mLn :: HBM.ModelP ()
+          mLn = do
+            a <- HBM.sample "a" (HBM.Normal 0 5)
+            b <- HBM.sample "b" (HBM.Normal 0 5)
+            s <- HBM.sample "sigma" (HBM.Exponential 1)
+            mapM_ (\(i, (x, y)) ->
+                     HBM.observe (T.pack ("y_" ++ show (i :: Int)))
+                       (HBM.LogNormal (a + b * realToFrac x) s) [y])
+                  (zip [0 ..] (zip xs ysLn))
+          names = HBM.sampleNames mLn
+          tmap  = HBM.getTransforms mLn
+          trans = [ tmap M.! n | n <- names ]
+          uvs   = [0.2, -0.4, log 0.7]
+          pU    = M.fromList (zip names uvs)
+      case HBM.synthVecIR mLn of
+        Nothing -> expectationFailure "synthVecIR: expected Just"
+        Just (gs, fams, sObs) -> do
+          length gs `shouldBe` 1
+          length fams `shouldBe` 0
+          Set.size sObs `shouldBe` 5
+      abs (HBM.compileLogPU mLn names trans uvs
+           - HBM.logJointUnconstrained mLn names trans pU) < 1e-9 `shouldBe` True
+      closeVec 1e-9 (HBM.gradADU mLn names trans uvs)
+                    (adGradRef mLn names trans uvs) `shouldBe` True
+      closeVec 1e-4 (HBM.gradADU mLn names trans uvs)
+                    (centralGrad mLn names trans uvs) `shouldBe` True
+
+    it "synthVecIR (Phase 56.4): Gamma 回帰形 (α latent, rate=exp(η)) が IR 吸収され値/勾配一致" $ do
+      -- y_i ~ Gamma(α, exp(a + b·x_i))。 lgammaΓ(α) は SLgammaO
+      -- (値 lgammaApprox / 導関数 digamma) — α latent の勾配も記号微分で自動。
+      let ysG = [0.8, 1.5, 0.3, 2.1, 0.6] :: [Double]
+          mGa :: HBM.ModelP ()
+          mGa = do
+            al <- HBM.sample "alpha" (HBM.Exponential 1)
+            a  <- HBM.sample "a" (HBM.Normal 0 5)
+            b  <- HBM.sample "b" (HBM.Normal 0 5)
+            mapM_ (\(i, (x, y)) ->
+                     HBM.observe (T.pack ("y_" ++ show (i :: Int)))
+                       (HBM.Gamma al (exp (a + b * realToFrac x))) [y])
+                  (zip [0 ..] (zip xs ysG))
+          names = HBM.sampleNames mGa
+          tmap  = HBM.getTransforms mGa
+          trans = [ tmap M.! n | n <- names ]
+          uvs   = [log 1.6, 0.4, -0.3]
+          pU    = M.fromList (zip names uvs)
+      case HBM.synthVecIR mGa of
+        Nothing -> expectationFailure "synthVecIR: expected Just"
+        Just (gs, fams, sObs) -> do
+          length gs `shouldBe` 1
+          length fams `shouldBe` 0
+          Set.size sObs `shouldBe` 5
+      abs (HBM.compileLogPU mGa names trans uvs
+           - HBM.logJointUnconstrained mGa names trans pU) < 1e-9 `shouldBe` True
+      closeVec 1e-9 (HBM.gradADU mGa names trans uvs)
+                    (adGradRef mGa names trans uvs) `shouldBe` True
+      closeVec 1e-4 (HBM.gradADU mGa names trans uvs)
+                    (centralGrad mGa names trans uvs) `shouldBe` True
+
+    it "synthVecIR (Phase 56.4): Beta 回帰形 (α=μφ, β=(1-μ)φ, μ=invLogit(η)) が IR 吸収され値/勾配一致" $ do
+      -- y_i ~ Beta(μ_i·φ, (1-μ_i)·φ)。 両パラメタとも行依存式 + lgammaΓ 3 項。
+      -- ⚠ φ は整数を避ける (φ=3.0 だと α+β の lgammaApprox 再帰が x+k=12 整数
+      -- 境界に乗り中心差分が壊れる・56.1 で記録済みの FD 罠を実測で再確認)。
+      let ysB = [0.3, 0.6, 0.4, 0.7, 0.5] :: [Double]
+          mBe :: HBM.ModelP ()
+          mBe = do
+            a  <- HBM.sample "a" (HBM.Normal 0 5)
+            b  <- HBM.sample "b" (HBM.Normal 0 5)
+            ph <- HBM.sample "phi" (HBM.Exponential 0.5)
+            mapM_ (\(i, (x, y)) ->
+                     let muI = 1 / (1 + exp (negate (a + b * realToFrac x)))
+                     in HBM.observe (T.pack ("y_" ++ show (i :: Int)))
+                          (HBM.Beta (muI * ph) ((1 - muI) * ph)) [y])
+                  (zip [0 ..] (zip xs ysB))
+          names = HBM.sampleNames mBe
+          tmap  = HBM.getTransforms mBe
+          trans = [ tmap M.! n | n <- names ]
+          uvs   = [0.4, -0.3, log 3.3]
+          pU    = M.fromList (zip names uvs)
+      case HBM.synthVecIR mBe of
+        Nothing -> expectationFailure "synthVecIR: expected Just"
+        Just (gs, fams, sObs) -> do
+          length gs `shouldBe` 1
+          length fams `shouldBe` 0
+          Set.size sObs `shouldBe` 5
+      abs (HBM.compileLogPU mBe names trans uvs
+           - HBM.logJointUnconstrained mBe names trans pU) < 1e-9 `shouldBe` True
+      closeVec 1e-9 (HBM.gradADU mBe names trans uvs)
+                    (adGradRef mBe names trans uvs) `shouldBe` True
+      closeVec 1e-4 (HBM.gradADU mBe names trans uvs)
+                    (centralGrad mBe names trans uvs) `shouldBe` True
+
+    it "synthVecIR (Phase 56.5): Binomial グループ形 (n=10, p=invLogit(η)) が IR 吸収され値/勾配一致" $ do
+      -- y_i ~ Binomial(10, invLogit(a + b·x_i))。 Bernoulli の 0/1 係数を
+      -- k/n-k に一般化 + ΣlogC(n,k) は compile 時定数。
+      let ysK = [3, 7, 5, 8, 2] :: [Double]
+          mBi :: HBM.ModelP ()
+          mBi = do
+            a <- HBM.sample "a" (HBM.Normal 0 5)
+            b <- HBM.sample "b" (HBM.Normal 0 5)
+            mapM_ (\(i, (x, y)) ->
+                     HBM.observe (T.pack ("y_" ++ show (i :: Int)))
+                       (HBM.Binomial 10
+                          (1 / (1 + exp (negate (a + b * realToFrac x))))) [y])
+                  (zip [0 ..] (zip xs ysK))
+          names = HBM.sampleNames mBi
+          tmap  = HBM.getTransforms mBi
+          trans = [ tmap M.! n | n <- names ]
+          uvs   = [0.3, 0.8]
+          pU    = M.fromList (zip names uvs)
+      case HBM.synthVecIR mBi of
+        Nothing -> expectationFailure "synthVecIR: expected Just"
+        Just (gs, fams, sObs) -> do
+          length gs `shouldBe` 1
+          length fams `shouldBe` 0
+          Set.size sObs `shouldBe` 5
+      abs (HBM.compileLogPU mBi names trans uvs
+           - HBM.logJointUnconstrained mBi names trans pU) < 1e-9 `shouldBe` True
+      closeVec 1e-9 (HBM.gradADU mBi names trans uvs)
+                    (adGradRef mBi names trans uvs) `shouldBe` True
+      closeVec 1e-4 (HBM.gradADU mBi names trans uvs)
+                    (centralGrad mBi names trans uvs) `shouldBe` True
+
+    it "synthVecIR (Phase 94): 行ごとに n が異なる Binomial が 1 group に merge され値/勾配一致" $ do
+      -- Phase 94 の核心。 seeds (11-seeds) 型 = 各行の試行数 n_i が異なる。
+      -- 修正前は n を group key に含めていたため n 別に分裂 (ここでは 5 行全て
+      -- 相異なる n → 5 group)。 n を行対応 Vector 化し key から除外することで
+      -- 1 group に merge される (= per-eval の 17→1 group 化と同じ改修)。
+      let nsB  = [10, 12, 8, 15, 6] :: [Double]   -- 全て相異なる n
+          ysK  = [3, 7, 5, 8, 2]  :: [Double]     -- 0 ≤ y_i ≤ n_i
+          mBn :: HBM.ModelP ()
+          mBn = do
+            a <- HBM.sample "a" (HBM.Normal 0 5)
+            b <- HBM.sample "b" (HBM.Normal 0 5)
+            mapM_ (\(i, (x, (nn, y))) ->
+                     HBM.observe (T.pack ("y_" ++ show (i :: Int)))
+                       (HBM.Binomial (round nn)
+                          (1 / (1 + exp (negate (a + b * realToFrac x))))) [y])
+                  (zip [0 ..] (zip xs (zip nsB ysK)))
+          names = HBM.sampleNames mBn
+          tmap  = HBM.getTransforms mBn
+          trans = [ tmap M.! n | n <- names ]
+          uvs   = [0.3, 0.8]
+          pU    = M.fromList (zip names uvs)
+      case HBM.synthVecIR mBn of
+        Nothing -> expectationFailure "synthVecIR: expected Just"
+        Just (gs, fams, sObs) -> do
+          length gs   `shouldBe` 1   -- ★Phase 94: n 別分裂が解消され 1 group
+          length fams `shouldBe` 0
+          Set.size sObs `shouldBe` 5
+      abs (HBM.compileLogPU mBn names trans uvs
+           - HBM.logJointUnconstrained mBn names trans pU) < 1e-9 `shouldBe` True
+      closeVec 1e-9 (HBM.gradADU mBn names trans uvs)
+                    (adGradRef mBn names trans uvs) `shouldBe` True
+      closeVec 1e-4 (HBM.gradADU mBn names trans uvs)
+                    (centralGrad mBn names trans uvs) `shouldBe` True
+
+    it "synthVecIR (Phase 56.5): Geometric 回帰形 (p=invLogit(η)) が IR 吸収され値/勾配一致" $ do
+      -- y_i ~ Geometric(invLogit(a + b·x_i))。 logp = Σ(k-1)·log(1-p) + Σlog p。
+      let ysG = [2, 1, 4, 3, 1] :: [Double]
+          mGe :: HBM.ModelP ()
+          mGe = do
+            a <- HBM.sample "a" (HBM.Normal 0 5)
+            b <- HBM.sample "b" (HBM.Normal 0 5)
+            mapM_ (\(i, (x, y)) ->
+                     HBM.observe (T.pack ("y_" ++ show (i :: Int)))
+                       (HBM.Geometric
+                          (1 / (1 + exp (negate (a + b * realToFrac x))))) [y])
+                  (zip [0 ..] (zip xs ysG))
+          names = HBM.sampleNames mGe
+          tmap  = HBM.getTransforms mGe
+          trans = [ tmap M.! n | n <- names ]
+          uvs   = [-0.2, 0.5]
+          pU    = M.fromList (zip names uvs)
+      case HBM.synthVecIR mGe of
+        Nothing -> expectationFailure "synthVecIR: expected Just"
+        Just (gs, fams, sObs) -> do
+          length gs `shouldBe` 1
+          length fams `shouldBe` 0
+          Set.size sObs `shouldBe` 5
+      abs (HBM.compileLogPU mGe names trans uvs
+           - HBM.logJointUnconstrained mGe names trans pU) < 1e-9 `shouldBe` True
+      closeVec 1e-9 (HBM.gradADU mGe names trans uvs)
+                    (adGradRef mGe names trans uvs) `shouldBe` True
+      closeVec 1e-4 (HBM.gradADU mGe names trans uvs)
+                    (centralGrad mGe names trans uvs) `shouldBe` True
+
+    it "synthVecIR (Phase 56.5): NegBin 回帰形 (μ=exp(η), α latent) が IR 吸収され値/勾配一致" $ do
+      -- y_i ~ NegativeBinomial(exp(a + b·x_i), α)。 lgammaΓ(k_i+α) は SLgammaO の
+      -- elementwise 適用・lgammaΓ(k_i+1) は compile 時定数 (56.5 本命)。
+      -- ⚠ α は整数を避ける (lgammaApprox x+k=12 境界の FD 罠)。
+      let ysN = [2, 0, 5, 3, 1] :: [Double]
+          mNb :: HBM.ModelP ()
+          mNb = do
+            a  <- HBM.sample "a" (HBM.Normal 0 5)
+            b  <- HBM.sample "b" (HBM.Normal 0 5)
+            al <- HBM.sample "alpha" (HBM.Exponential 0.5)
+            mapM_ (\(i, (x, y)) ->
+                     HBM.observe (T.pack ("y_" ++ show (i :: Int)))
+                       (HBM.NegativeBinomial
+                          (exp (a + b * realToFrac x)) al) [y])
+                  (zip [0 ..] (zip xs ysN))
+          names = HBM.sampleNames mNb
+          tmap  = HBM.getTransforms mNb
+          trans = [ tmap M.! n | n <- names ]
+          uvs   = [0.6, -0.3, log 2.3]
+          pU    = M.fromList (zip names uvs)
+      case HBM.synthVecIR mNb of
+        Nothing -> expectationFailure "synthVecIR: expected Just"
+        Just (gs, fams, sObs) -> do
+          length gs `shouldBe` 1
+          length fams `shouldBe` 0
+          Set.size sObs `shouldBe` 5
+      abs (HBM.compileLogPU mNb names trans uvs
+           - HBM.logJointUnconstrained mNb names trans pU) < 1e-9 `shouldBe` True
+      closeVec 1e-9 (HBM.gradADU mNb names trans uvs)
+                    (adGradRef mNb names trans uvs) `shouldBe` True
+      closeVec 1e-4 (HBM.gradADU mNb names trans uvs)
+                    (centralGrad mNb names trans uvs) `shouldBe` True
+
+    it "synthVecIR (Phase 94): 行ごとに n が異なる ZeroInflatedBinomial が 1 group に merge され値/勾配一致" $ do
+      -- Phase 94 で SDZIBinom も n を行対応 Vector 化 (SDBinom と同型)。
+      -- y=0 行を含めて branch0 (仮想 y=0 密度 = 行ごとの n を使う) を発火させる。
+      let nsB  = [10, 12, 8, 15, 6] :: [Double]   -- 全て相異なる n
+          ysZ  = [0, 7, 0, 8, 2]  :: [Double]     -- y=0 行 2 つで branch0 を経由
+          mZb :: HBM.ModelP ()
+          mZb = do
+            a <- HBM.sample "a" (HBM.Normal 0 5)
+            b <- HBM.sample "b" (HBM.Normal 0 5)
+            c <- HBM.sample "c" (HBM.Normal 0 5)
+            mapM_ (\(i, (x, (nn, y))) ->
+                     HBM.observe (T.pack ("y_" ++ show (i :: Int)))
+                       (HBM.ZeroInflatedBinomial (round nn)
+                          (1 / (1 + exp (negate c)))                     -- ψ = invLogit(c)
+                          (1 / (1 + exp (negate (a + b * realToFrac x))))) [y])
+                  (zip [0 ..] (zip xs (zip nsB ysZ)))
+          names = HBM.sampleNames mZb
+          tmap  = HBM.getTransforms mZb
+          trans = [ tmap M.! n | n <- names ]
+          uvs   = [0.3, 0.8, -0.5]
+          pU    = M.fromList (zip names uvs)
+      case HBM.synthVecIR mZb of
+        Nothing -> expectationFailure "synthVecIR: expected Just"
+        Just (gs, fams, sObs) -> do
+          length gs   `shouldBe` 1   -- ★Phase 94: n 別分裂が解消され 1 group
+          length fams `shouldBe` 0
+          Set.size sObs `shouldBe` 5
+      abs (HBM.compileLogPU mZb names trans uvs
+           - HBM.logJointUnconstrained mZb names trans pU) < 1e-9 `shouldBe` True
+      closeVec 1e-9 (HBM.gradADU mZb names trans uvs)
+                    (adGradRef mZb names trans uvs) `shouldBe` True
+      closeVec 1e-4 (HBM.gradADU mZb names trans uvs)
+                    (centralGrad mZb names trans uvs) `shouldBe` True
+
+    it "synthVecIR (Phase 90 A10): raw potential (同型 Σ チェーン) が VGPot 吸収され値/勾配一致" $ do
+      -- BYM2 ミニ形 (13-traffic の縮小同型): Poisson 尤度 + icar ペア差分
+      -- potential (12 項 ≥ potSumThreshold) + ソフトゼロ和 potential (内部
+      -- Σφ = 10 項)。 両 potential が USum ベクトル化で VGPot に吸収され
+      -- 残差 ad ゼロ。 値/勾配が従来 ad・中心差分と一致。
+      let nA = 10 :: Int
+          edges = [ (i, i + 1) | i <- [0 .. 8] ] ++ [(0, 5), (2, 7), (4, 9)]
+          ysB = [1, 0, 2, 1, 3, 0, 1, 2, 1, 0] :: [Double]
+          mB :: HBM.ModelP ()
+          mB = do
+            b0 <- HBM.sample "b0" (HBM.Normal 0 1)
+            sg <- HBM.sample "sg" (HBM.HalfNormal 1)
+            phis <- mapM (\i -> HBM.sample (T.pack ("phi_" ++ show (i :: Int)))
+                                           (HBM.Normal 0 10)) [0 .. nA - 1]
+            mapM_ (\(i, y) ->
+                     HBM.observe (T.pack ("y_" ++ show (i :: Int)))
+                       (HBM.Poisson (exp (b0 + (phis !! i) * sg))) [y])
+                  (zip [0 ..] ysB)
+            HBM.potential "icar" (negate 0.5 * sum
+              [ (phis !! a - phis !! b) * (phis !! a - phis !! b)
+              | (a, b) <- edges ])
+            HBM.potential "szero"
+              (HBM.logDensity (HBM.Normal 0 0.01) (sum phis))
+          names = HBM.sampleNames mB
+          tmap  = HBM.getTransforms mB
+          trans = [ tmap M.! n | n <- names ]
+          uvs   = [ 0.05 + 0.03 * fromIntegral i
+                  | i <- [0 .. length names - 1] ]
+          pU    = M.fromList (zip names uvs)
+      case HBM.synthVecIR mB of
+        Nothing -> expectationFailure "synthVecIR: expected Just"
+        Just (gs, _fams, sObs) -> do
+          length gs `shouldBe` 3            -- Poisson 群 + VGPot ×2
+          ("icar" `Set.member` sObs) `shouldBe` True
+          ("szero" `Set.member` sObs) `shouldBe` True
+      abs (HBM.compileLogPU mB names trans uvs
+           - HBM.logJointUnconstrained mB names trans pU) < 1e-9 `shouldBe` True
+      closeVec 1e-9 (HBM.gradADU mB names trans uvs)
+                    (adGradRef mB names trans uvs) `shouldBe` True
+      closeVec 1e-4 (HBM.gradADU mB names trans uvs)
+                    (centralGrad mB names trans uvs) `shouldBe` True
+
+    it "synthVecIR (Phase 90 A10): unify 不能な大チェーン potential は残差 ad に残り値/勾配一致" $ do
+      -- 項の形が交互に違う (φa·φb と (φa-φb)²) 12 項チェーン → unifyManyD
+      -- 失敗 → その potential は吸収されず残差 ad が担う (安全方向)。
+      -- Poisson 群は吸収されたまま・二重計上/脱落なく値/勾配一致。
+      let nA = 10 :: Int
+          edges = [ (i, i + 1) | i <- [0 .. 8] ] ++ [(0, 5), (2, 7), (4, 9)]
+          ysB = [1, 0, 2, 1, 3, 0, 1, 2, 1, 0] :: [Double]
+          mB :: HBM.ModelP ()
+          mB = do
+            b0 <- HBM.sample "b0" (HBM.Normal 0 1)
+            sg <- HBM.sample "sg" (HBM.HalfNormal 1)
+            phis <- mapM (\i -> HBM.sample (T.pack ("phi_" ++ show (i :: Int)))
+                                           (HBM.Normal 0 10)) [0 .. nA - 1]
+            mapM_ (\(i, y) ->
+                     HBM.observe (T.pack ("y_" ++ show (i :: Int)))
+                       (HBM.Poisson (exp (b0 + (phis !! i) * sg))) [y])
+                  (zip [0 ..] ysB)
+            HBM.potential "mixpot" (negate 0.5 * sum
+              [ if even k
+                  then (phis !! a) * (phis !! b)
+                  else (phis !! a - phis !! b) * (phis !! a - phis !! b)
+              | (k, (a, b)) <- zip [0 :: Int ..] edges ])
+          names = HBM.sampleNames mB
+          tmap  = HBM.getTransforms mB
+          trans = [ tmap M.! n | n <- names ]
+          uvs   = [ 0.05 + 0.03 * fromIntegral i
+                  | i <- [0 .. length names - 1] ]
+          pU    = M.fromList (zip names uvs)
+      case HBM.synthVecIR mB of
+        Nothing -> expectationFailure "synthVecIR: expected Just"
+        Just (gs, _fams, sObs) -> do
+          length gs `shouldBe` 1            -- Poisson 群のみ (VGPot 無し)
+          ("mixpot" `Set.member` sObs) `shouldBe` False
+      abs (HBM.compileLogPU mB names trans uvs
+           - HBM.logJointUnconstrained mB names trans pU) < 1e-9 `shouldBe` True
+      closeVec 1e-9 (HBM.gradADU mB names trans uvs)
+                    (adGradRef mB names trans uvs) `shouldBe` True
+      closeVec 1e-4 (HBM.gradADU mB names trans uvs)
+                    (centralGrad mB names trans uvs) `shouldBe` True
diff --git a/test/Hanalyze/Model/HBMSpec.hs b/test/Hanalyze/Model/HBMSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Model/HBMSpec.hs
@@ -0,0 +1,1601 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Model.HBMSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Data.Text   as T
+import qualified Data.Map.Strict as M
+import qualified System.Random.MWC as MWC
+import qualified Hanalyze.Model.Core        as Core
+import qualified System.Random.MWC as MWC
+import qualified Hanalyze.MCMC.NUTS as NUTS
+import qualified Hanalyze.MCMC.Core as Core
+import qualified Hanalyze.Model.HBM as HBM
+import qualified Data.Map.Strict    as M
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Model.HBM Distribution (Phase 37-A2: 連続 4 分布)" $ do
+    let isClose eps a b = abs (a - b) < eps
+    --
+    -- SkewNormal: α=0 で標準正規一致、 α>0 で右側に質量
+    --
+    it "SkewNormal(0,1,0) は Normal(0,1) と一致する (α=0、 erfA 近似誤差 ~1e-9)" $ do
+      let p = HBM.logDensity (HBM.SkewNormal 0 1 0 :: HBM.Distribution Double) 0
+      isClose 1e-8 p (-0.9189385332046727) `shouldBe` True
+    it "SkewNormal(0,1,5) は x=+1 で x=-1 より大きい (右側に歪み)" $ do
+      let pPos = HBM.logDensity (HBM.SkewNormal 0 1 5 :: HBM.Distribution Double) 1
+          pNeg = HBM.logDensity (HBM.SkewNormal 0 1 5 :: HBM.Distribution Double) (-1)
+      (pPos > pNeg) `shouldBe` True
+    it "SkewNormal(0,1,0) の sample 1000 個で 0 付近に集中" $ do
+      gen <- MWC.create
+      xs <- mapM (\_ -> HBM.sampleDist (HBM.SkewNormal 0 1 0) gen) [1..1000::Int]
+      let mean = sum xs / 1000
+      isClose 0.1 mean 0 `shouldBe` True
+    --
+    -- Logistic: μ=0, s=1 で logpdf(0) = -log 4、 CDF(0) = 0.5
+    --
+    it "Logistic(0,1) logDensity(0) = -log 4" $ do
+      let p = HBM.logDensity (HBM.Logistic 0 1 :: HBM.Distribution Double) 0
+      isClose 1e-9 p (- log 4) `shouldBe` True
+    it "Logistic(0,1) CDF(0) = 0.5" $ do
+      case HBM.distCDF (HBM.Logistic 0 1 :: HBM.Distribution Double) 0 of
+        Just c  -> isClose 1e-9 c 0.5 `shouldBe` True
+        Nothing -> expectationFailure "distCDF returned Nothing"
+    it "Logistic sample 1000 個で平均が μ=2 近傍" $ do
+      gen <- MWC.create
+      xs <- mapM (\_ -> HBM.sampleDist (HBM.Logistic 2 1) gen) [1..1000::Int]
+      let mean = sum xs / 1000
+      isClose 0.2 mean 2 `shouldBe` True
+    --
+    -- Gumbel: μ=0, β=1 で logpdf(0) = -1、 CDF(0) = exp(-1)
+    --
+    it "Gumbel(0,1) logDensity(0) = -1" $ do
+      let p = HBM.logDensity (HBM.Gumbel 0 1 :: HBM.Distribution Double) 0
+      isClose 1e-9 p (-1) `shouldBe` True
+    it "Gumbel(0,1) CDF(0) = exp(-1)" $ do
+      case HBM.distCDF (HBM.Gumbel 0 1 :: HBM.Distribution Double) 0 of
+        Just c  -> isClose 1e-9 c (exp (-1)) `shouldBe` True
+        Nothing -> expectationFailure "distCDF returned Nothing"
+    it "Gumbel(0,1) sample 2000 個で平均が μ + β·γ ≈ 0.5772 近傍" $ do
+      gen <- MWC.create
+      xs <- mapM (\_ -> HBM.sampleDist (HBM.Gumbel 0 1) gen) [1..2000::Int]
+      let mean = sum xs / 2000
+      -- オイラー定数 γ ≈ 0.5772、 サンプル誤差 ~ β·π/√(6·n) ≈ 0.029
+      isClose 0.15 mean 0.5772156649 `shouldBe` True
+    --
+    -- AsymmetricLaplace: κ=1 で対称ラプラス、 logpdf(0) = -log 2、 CDF(0) = 0.5
+    --
+    it "AsymmetricLaplace(1,1,0) は対称ラプラス" $ do
+      let p1 = HBM.logDensity (HBM.AsymmetricLaplace 1 1 0 :: HBM.Distribution Double) 1
+          pN1 = HBM.logDensity (HBM.AsymmetricLaplace 1 1 0 :: HBM.Distribution Double) (-1)
+      isClose 1e-9 p1 pN1 `shouldBe` True
+    it "AsymmetricLaplace(1,1,0) logDensity(0) = -log 2" $ do
+      let p = HBM.logDensity (HBM.AsymmetricLaplace 1 1 0 :: HBM.Distribution Double) 0
+      isClose 1e-9 p (- log 2) `shouldBe` True
+    it "AsymmetricLaplace(1,1,0) CDF(0) = 0.5 (κ=1 で対称)" $ do
+      case HBM.distCDF (HBM.AsymmetricLaplace 1 1 0 :: HBM.Distribution Double) 0 of
+        Just c  -> isClose 1e-9 c 0.5 `shouldBe` True
+        Nothing -> expectationFailure "distCDF returned Nothing"
+    it "AsymmetricLaplace(1,2,0) は右側裾長 (κ=2 で正側に長い尾)" $ do
+      -- κ=2 で正の x の方が log density 高いが、 大きい値で右側が遅く減衰
+      let p_3pos = HBM.logDensity (HBM.AsymmetricLaplace 1 2 0 :: HBM.Distribution Double) 3
+          p_3neg = HBM.logDensity (HBM.AsymmetricLaplace 1 2 0 :: HBM.Distribution Double) (-3)
+      -- x=+3 では -b·κ·3 = -6、 x=-3 では (b/κ)·(-3) = -1.5
+      -- → x=-3 の方が log density 高い (左側裾は短く局在)
+      (p_3neg > p_3pos) `shouldBe` True
+    it "AsymmetricLaplace(1,1,5) sample 2000 個で平均が μ=5 近傍" $ do
+      gen <- MWC.create
+      xs <- mapM (\_ -> HBM.sampleDist (HBM.AsymmetricLaplace 1 1 5) gen) [1..2000::Int]
+      let mean = sum xs / 2000
+      isClose 0.2 mean 5 `shouldBe` True
+
+  describe "Hanalyze.Model.HBM Distribution (Phase 37-A3: 離散 5 分布)" $ do
+    let isClose eps a b = abs (a - b) < eps
+    --
+    -- OrderedLogistic: K=3 (cuts=[c1, c2]) で 3 カテゴリ
+    --
+    it "OrderedLogistic(η=0, cuts=[-1,1]) は 3 カテゴリの確率総和 1" $ do
+      let d = HBM.OrderedLogistic 0 [-1, 1] :: HBM.Distribution Double
+          p0 = exp (HBM.logDensityObs d 0)
+          p1 = exp (HBM.logDensityObs d 1)
+          p2 = exp (HBM.logDensityObs d 2)
+      isClose 1e-9 (p0 + p1 + p2) 1 `shouldBe` True
+    it "OrderedLogistic(η=0, cuts=[-1,1]) は対称 (P(0) = P(2))" $ do
+      let d = HBM.OrderedLogistic 0 [-1, 1] :: HBM.Distribution Double
+          p0 = exp (HBM.logDensityObs d 0)
+          p2 = exp (HBM.logDensityObs d 2)
+      isClose 1e-9 p0 p2 `shouldBe` True
+    it "OrderedLogistic(η=2, cuts=[-1,1]) は P(2) > P(0) (η 増 → 高カテゴリ寄り)" $ do
+      let d = HBM.OrderedLogistic 2 [-1, 1] :: HBM.Distribution Double
+          p0 = exp (HBM.logDensityObs d 0)
+          p2 = exp (HBM.logDensityObs d 2)
+      (p2 > p0) `shouldBe` True
+    --
+    -- DiscreteUniform: 0..9 で logpmf = -log 10
+    --
+    it "DiscreteUniform(0,9) logpmf(5) = -log 10" $ do
+      let p = HBM.logDensityObs (HBM.DiscreteUniform 0 9 :: HBM.Distribution Double) 5
+      isClose 1e-9 p (- log 10) `shouldBe` True
+    it "DiscreteUniform(0,9) range 外で logpmf = -∞" $ do
+      let p = HBM.logDensityObs (HBM.DiscreteUniform 0 9 :: HBM.Distribution Double) 10
+      isInfinite p && p < 0 `shouldBe` True
+    it "DiscreteUniform(0,9) sample 1000 個で全カテゴリが現れる" $ do
+      gen <- MWC.create
+      xs <- mapM (\_ -> HBM.sampleDist (HBM.DiscreteUniform 0 9) gen) [1..1000::Int]
+      let unique = length (foldr (\x acc -> if x `elem` acc then acc else x:acc) [] xs)
+      (unique >= 9) `shouldBe` True
+    --
+    -- Geometric: PyMC 慣例 (support 1, 2, ...)
+    --
+    it "Geometric(0.5) logpmf(1) = log 0.5" $ do
+      let p = HBM.logDensityObs (HBM.Geometric 0.5 :: HBM.Distribution Double) 1
+      isClose 1e-9 p (log 0.5) `shouldBe` True
+    it "Geometric(0.5) logpmf(3) = 3 log 0.5" $ do
+      let p = HBM.logDensityObs (HBM.Geometric 0.5 :: HBM.Distribution Double) 3
+      isClose 1e-9 p (3 * log 0.5) `shouldBe` True
+    it "Geometric(0.5) sample 2000 個の平均が 1/p = 2 近傍" $ do
+      gen <- MWC.create
+      xs <- mapM (\_ -> HBM.sampleDist (HBM.Geometric 0.5) gen) [1..2000::Int]
+      let mean = sum xs / 2000
+      isClose 0.2 mean 2 `shouldBe` True
+    --
+    -- HyperGeometric(N=10, K=5, n=3): k=2 で scipy ≈ 0.4167
+    --   C(5,2)*C(5,1)/C(10,3) = 10 * 5 / 120 = 50/120 = 0.4167
+    --
+    it "HyperGeometric(10,5,3) pmf(2) ≈ 0.4167" $ do
+      let p = exp (HBM.logDensityObs (HBM.HyperGeometric 10 5 3 :: HBM.Distribution Double) 2)
+      isClose 1e-6 p (50 / 120) `shouldBe` True
+    it "HyperGeometric(10,5,3) pmf 0..3 総和 = 1" $ do
+      let d = HBM.HyperGeometric 10 5 3 :: HBM.Distribution Double
+          tot = sum [exp (HBM.logDensityObs d (realToFrac k)) | k <- [0..3 :: Int]]
+      isClose 1e-9 tot 1 `shouldBe` True
+    --
+    -- ZeroInflatedNegativeBinomial: ψ=0 で NegBin と一致
+    --
+    it "ZeroInflatedNegativeBinomial(0, 5, 2) は NegativeBinomial(5,2) と一致 (ψ=0)" $ do
+      let d1 = HBM.ZeroInflatedNegativeBinomial 0 5 2 :: HBM.Distribution Double
+          d2 = HBM.NegativeBinomial 5 2 :: HBM.Distribution Double
+          p1 = HBM.logDensityObs d1 3
+          p2 = HBM.logDensityObs d2 3
+      isClose 1e-9 p1 p2 `shouldBe` True
+    it "ZeroInflatedNegativeBinomial(0.3, 5, 2) は P(y=0) が NegBin より大きい" $ do
+      let d1 = HBM.ZeroInflatedNegativeBinomial 0.3 5 2 :: HBM.Distribution Double
+          d2 = HBM.NegativeBinomial 5 2 :: HBM.Distribution Double
+          p1 = exp (HBM.logDensityObs d1 0)
+          p2 = exp (HBM.logDensityObs d2 0)
+      (p1 > p2) `shouldBe` True
+    it "ZeroInflatedNegativeBinomial(0.3, 3, 2) sample 2000 個で 0 比率 ≈ 0.3 + (1-0.3)·NegBin(0)" $ do
+      gen <- MWC.create
+      xs <- mapM (\_ -> HBM.sampleDist (HBM.ZeroInflatedNegativeBinomial 0.3 3 2) gen) [1..2000::Int]
+      let zeros = length (filter (== 0) xs)
+          rate0 = realToFrac zeros / 2000
+          -- 期待される P(0): 0.3 + 0.7 * (2/(2+3))^2 = 0.3 + 0.7 * 0.16 = 0.412
+          expectedP0 = 0.3 + 0.7 * (2 / 5) ** 2
+      (abs (rate0 - expectedP0) < 0.05) `shouldBe` True
+
+  describe "Hanalyze.Model.HBM Distribution (Phase 37-A4: 多変量 2 分布)" $ do
+    let isClose eps a b = abs (a - b) < eps
+    --
+    -- MvStudentT(ν=10, μ=[0,0], Σ=I) at y=[0,0]: scipy ≈ -1.8379
+    --
+    it "MvStudentT(ν=10, μ=[0,0], Σ=I) logpdf([0,0]) ≈ -1.8379 (scipy)" $ do
+      let p = HBM.mvStudentTLogDensity 10
+                ([0, 0] :: [Double])
+                [[1, 0], [0, 1]]
+                [0, 0]
+      isClose 1e-3 p (-1.8379) `shouldBe` True
+    it "MvStudentT(ν=1000) は MvNormal に漸近 (logpdf at [0,0] ≈ −log(2π))" $ do
+      let pT = HBM.mvStudentTLogDensity 1000
+                ([0, 0] :: [Double])
+                [[1, 0], [0, 1]]
+                [0, 0]
+          pN = HBM.mvNormalLogDensity
+                ([0, 0] :: [Double])
+                [[1, 0], [0, 1]]
+                [0, 0]
+      -- ν=1000 で diff ~ 1e-3 オーダ
+      (abs (pT - pN) < 0.01) `shouldBe` True
+    it "MvStudentT は中心で density が周辺より大きい" $ do
+      let pCenter = HBM.mvStudentTLogDensity 5
+                     ([0, 0] :: [Double])
+                     [[1, 0], [0, 1]]
+                     [0, 0]
+          pEdge   = HBM.mvStudentTLogDensity 5
+                     ([0, 0] :: [Double])
+                     [[1, 0], [0, 1]]
+                     [3, 3]
+      (pCenter > pEdge) `shouldBe` True
+    --
+    -- DirichletMultinomial(n=2, α=[1,1,1]) at counts=[1,1,0]: scipy = log(1/6) ≈ -1.7918
+    --
+    it "DirichletMultinomial(n=2, α=[1,1,1]) pmf([1,1,0]) = 1/6" $ do
+      let p = HBM.dirichletMultinomialLogDensity 2
+                ([1, 1, 1] :: [Double])
+                [1, 1, 0]
+      isClose 1e-9 p (log (1/6)) `shouldBe` True
+    it "DirichletMultinomial(n=2, α=[1,1,1]) は全 6 種類の組合せ確率総和 = 1" $ do
+      let combos = [[2,0,0],[0,2,0],[0,0,2],[1,1,0],[1,0,1],[0,1,1]]
+          tot = sum [ exp (HBM.dirichletMultinomialLogDensity 2
+                            ([1, 1, 1] :: [Double]) c)
+                    | c <- combos ]
+      isClose 1e-9 tot 1 `shouldBe` True
+    it "DirichletMultinomial(n=3, α=[1,1,1,1]) total prob = 1 (10 種)" $ do
+      let combos = [ [c0, c1, c2, c3]
+                   | c0 <- [0..3], c1 <- [0..3-c0]
+                   , c2 <- [0..3-c0-c1]
+                   , let c3 = 3 - c0 - c1 - c2
+                   , c3 >= 0 ]
+          tot = sum [ exp (HBM.dirichletMultinomialLogDensity 3
+                            ([1, 1, 1, 1] :: [Double]) c)
+                    | c <- combos ]
+      isClose 1e-9 tot 1 `shouldBe` True
+    --
+    -- obsLogSum 経由で MvStudentT が k 次元 chunk として処理されることを確認
+    --
+    it "obsLogSum (MvStudentT) は 2 観測 (k=2、 flat 4 要素) を 2 個の log と一致" $ do
+      let dist = HBM.MvStudentT 10 ([0, 0] :: [Double]) [[1, 0], [0, 1]]
+          flat = [0, 0,  1, 1]
+          summed = HBM.obsLogSum dist flat
+          p1 = HBM.mvStudentTLogDensity 10 [0,0] [[1,0],[0,1]] [0,0]
+          p2 = HBM.mvStudentTLogDensity 10 [0,0] [[1,0],[0,1]] [1,1]
+      isClose 1e-9 summed (p1 + p2) `shouldBe` True
+
+  describe "Hanalyze.Model.HBM Distribution (Phase 39-A1: 連続 3 + 離散 1)" $ do
+    let isClose eps a b = abs (a - b) < eps
+    --
+    -- Triangular: lower=0, c=0.5, upper=1
+    --   pdf(0.5) = 2/(1·0.5) = 4  -> logpdf = log 4
+    --   CDF closed-form via inverse sample test (平均 ≈ (lo+c+hi)/3)
+    --
+    it "Triangular(0,0.5,1) logDensity(0.5) = log 2 (peak pdf = 2/(hi-lo))" $ do
+      let p = HBM.logDensity (HBM.Triangular 0 0.5 1 :: HBM.Distribution Double) 0.5
+      isClose 1e-9 p (log 2) `shouldBe` True
+    it "Triangular(0,0.5,1) は範囲外 (-0.1) で −∞" $ do
+      let p = HBM.logDensity (HBM.Triangular 0 0.5 1 :: HBM.Distribution Double) (-0.1)
+      (p < -1e10) `shouldBe` True
+    it "Triangular(0,0.5,1) sample 2000 個で平均 ≈ 0.5 (= (0+0.5+1)/3)" $ do
+      gen <- MWC.create
+      xs <- mapM (\_ -> HBM.sampleDist (HBM.Triangular 0 0.5 1) gen) [1..2000::Int]
+      let mean = sum xs / 2000
+      isClose 0.05 mean 0.5 `shouldBe` True
+    --
+    -- Kumaraswamy(a=1, b=1) は uniform(0,1)
+    --   logpdf(0.5) = log 1 + log 1 + 0 + 0 = 0
+    --
+    it "Kumaraswamy(1,1) は Uniform(0,1) (logDensity = 0)" $ do
+      let p = HBM.logDensity (HBM.Kumaraswamy 1 1 :: HBM.Distribution Double) 0.5
+      isClose 1e-9 p 0 `shouldBe` True
+    it "Kumaraswamy(2,2) logDensity(0.5)" $ do
+      -- pdf(0.5) = 2*2*0.5*(1-0.25) = 4*0.5*0.75 = 1.5
+      let p = HBM.logDensity (HBM.Kumaraswamy 2 2 :: HBM.Distribution Double) 0.5
+      isClose 1e-9 p (log 1.5) `shouldBe` True
+    it "Kumaraswamy(2,2) sample 2000 個で平均が ~0.5 近傍" $ do
+      gen <- MWC.create
+      xs <- mapM (\_ -> HBM.sampleDist (HBM.Kumaraswamy 2 2) gen) [1..2000::Int]
+      let mean = sum xs / 2000
+      -- E[X] = b * B(1+1/a, b) = 2 * B(1.5, 2) = 2 * Γ(1.5)Γ(2)/Γ(3.5)
+      --      = 2 * 0.8862269 * 1 / 3.3233509 = 0.5333...
+      isClose 0.05 mean 0.5333333 `shouldBe` True
+    --
+    -- Rice(ν=0, σ) は Rayleigh(σ)、 pdf(x) = (x/σ²) exp(-x²/(2σ²))
+    --   x=σ=1: logpdf = log 1 - 0 - 0.5 + logI0(0) = -0.5
+    --
+    it "Rice(0,1) logDensity(1) = -0.5 (Rayleigh、 I0(0)=1)" $ do
+      let p = HBM.logDensity (HBM.Rice 0 1 :: HBM.Distribution Double) 1
+      isClose 1e-7 p (-0.5) `shouldBe` True
+    it "Rice(0,1) sample 2000 個で平均 ≈ σ·√(π/2) ≈ 1.2533 (Rayleigh 平均)" $ do
+      gen <- MWC.create
+      xs <- mapM (\_ -> HBM.sampleDist (HBM.Rice 0 1) gen) [1..2000::Int]
+      let mean = sum xs / 2000
+      isClose 0.05 mean 1.2533141 `shouldBe` True
+    it "Rice(2,1) sample 2000 個で平均 > Rice(0,1) 平均 (ν>0 で右シフト)" $ do
+      gen <- MWC.create
+      xs0 <- mapM (\_ -> HBM.sampleDist (HBM.Rice 0 1) gen) [1..2000::Int]
+      xs2 <- mapM (\_ -> HBM.sampleDist (HBM.Rice 2 1) gen) [1..2000::Int]
+      (sum xs2 > sum xs0) `shouldBe` True
+    --
+    -- DiscreteWeibull(q=0.5, β=1) は Geometric(p=0.5) (PyMC 慣例の {0,1,...})
+    --   pmf(0) = q^0 - q^1 = 1 - 0.5 = 0.5
+    --   pmf(1) = q^1 - q^4 (β=1) で q^1 - q^2 = 0.5 - 0.25 = 0.25
+    --
+    it "DiscreteWeibull(0.5, 1) pmf(0) = 0.5" $ do
+      let p = exp (HBM.logDensityObs (HBM.DiscreteWeibull 0.5 1 :: HBM.Distribution Double) 0)
+      isClose 1e-9 p 0.5 `shouldBe` True
+    it "DiscreteWeibull(0.5, 1) pmf(1) = 0.25" $ do
+      let p = exp (HBM.logDensityObs (HBM.DiscreteWeibull 0.5 1 :: HBM.Distribution Double) 1)
+      isClose 1e-9 p 0.25 `shouldBe` True
+    it "DiscreteWeibull(0.5, 1) pmf 0..30 総和 ≈ 1" $ do
+      let d = HBM.DiscreteWeibull 0.5 1 :: HBM.Distribution Double
+          tot = sum [exp (HBM.logDensityObs d (realToFrac k)) | k <- [0..30::Int]]
+      isClose 1e-6 tot 1 `shouldBe` True
+    it "DiscreteWeibull(0.5, 1) sample 2000 個で整数 (≥0) のみ" $ do
+      gen <- MWC.create
+      xs <- mapM (\_ -> HBM.sampleDist (HBM.DiscreteWeibull 0.5 1) gen) [1..2000::Int]
+      (all (\x -> x >= 0 && x == fromIntegral (round x :: Int)) xs) `shouldBe` True
+    --
+    -- DAG: distDepsT は Phase 38 で確立した「新分布追加 4 箇所」 規律で網羅
+    --
+    it "Triangular の buildModelGraph: lo, c, hi → y の 3 edges" $ do
+      let m :: HBM.ModelP ()
+          m = do
+            lo <- HBM.sample "lo" (HBM.Normal 0 1)
+            c  <- HBM.sample "c"  (HBM.Normal 0 1)
+            hi <- HBM.sample "hi" (HBM.Normal 0 1)
+            HBM.observe "y" (HBM.Triangular lo c hi) [0.5]
+      let g = HBM.buildModelGraph m
+          edges = HBM.mgEdges g
+      length [() | (s, t) <- edges, t == "y", s `elem` ["lo", "c", "hi"]] `shouldBe` 3
+    it "Rice の buildModelGraph: ν, σ → y の 2 edges" $ do
+      let m :: HBM.ModelP ()
+          m = do
+            nu  <- HBM.sample "nu"  (HBM.HalfNormal 1)
+            sig <- HBM.sample "sig" (HBM.HalfNormal 1)
+            HBM.observe "y" (HBM.Rice nu sig) [1.0]
+      let g = HBM.buildModelGraph m
+          edges = HBM.mgEdges g
+      length [() | (s, t) <- edges, t == "y", s `elem` ["nu", "sig"]] `shouldBe` 2
+
+  describe "Hanalyze.Model.HBM Distribution (Phase 39-A2: Wishart)" $ do
+    let isClose eps a b = abs (a - b) < eps
+    --
+    -- Wishart(ν=3, V=I_2) at W=I_2:
+    --   logpdf = -3 log 2 - log(π/2) - 1 ≈ -3.5310
+    --
+    it "Wishart(3, I_2) logpdf(I_2) ≈ -3.531 (scipy.stats.wishart 同等)" $ do
+      let p = HBM.wishartLogDensity 3
+                ([[1, 0], [0, 1]] :: [[Double]])
+                [1, 0, 0, 1]  -- W = I_2 flatten
+      isClose 1e-4 p (-2 * log 2 - log pi - 1) `shouldBe` True
+    --
+    -- ν < k で degenerate (-∞)
+    --
+    it "Wishart(ν=0) は degenerate (logpdf = -∞)" $ do
+      let p = HBM.wishartLogDensity 0
+                ([[1, 0], [0, 1]] :: [[Double]])
+                [1, 0, 0, 1]
+      (p < -1e10) `shouldBe` True
+    --
+    -- V≠I のスケール変換: V = 2I で logpdf がシフトする
+    --   logpdf = -3 log 2 - (3/2) log|2I| - log Γ_2(3/2) - (1/2) tr((2I)⁻¹ I)
+    --         = -3 log 2 - (3/2)(2 log 2) - log(π/2) - 0.5
+    --         = -3 log 2 - 3 log 2 - log(π/2) - 0.5
+    --         = -6 log 2 - log π + log 2 - 0.5
+    --         = -5 log 2 - log π - 0.5
+    --
+    it "Wishart(3, 2·I_2) logpdf(I_2) で V スケール反映" $ do
+      let p = HBM.wishartLogDensity 3
+                ([[2, 0], [0, 2]] :: [[Double]])
+                [1, 0, 0, 1]
+      isClose 1e-4 p (-5 * log 2 - log pi - 0.5) `shouldBe` True
+    --
+    -- obsLogSum 経由: 2 観測 (k=2、 flat 8 要素) を 2 個の log と一致
+    --
+    it "obsLogSum (Wishart) は 2 観測 (flat 8 要素) を 2 個の log と一致" $ do
+      let dist = HBM.Wishart 3 ([[1, 0], [0, 1]] :: [[Double]])
+          flat = [1, 0, 0, 1,   2, 0, 0, 2]
+          summed = HBM.obsLogSum dist flat
+          p1 = HBM.wishartLogDensity 3 [[1,0],[0,1]] [1, 0, 0, 1]
+          p2 = HBM.wishartLogDensity 3 [[1,0],[0,1]] [2, 0, 0, 2]
+      isClose 1e-9 summed (p1 + p2) `shouldBe` True
+    --
+    -- DAG: ν, V → y のエッジ
+    --
+    it "Wishart の buildModelGraph: ν, V 各要素 → y のエッジ" $ do
+      let m :: HBM.ModelP ()
+          m = do
+            nu  <- HBM.sample "nu"  (HBM.HalfNormal 5)
+            v00 <- HBM.sample "v00" (HBM.HalfNormal 1)
+            v11 <- HBM.sample "v11" (HBM.HalfNormal 1)
+            HBM.observeMV "y"
+              (HBM.Wishart nu [[v00, 0], [0, v11]])
+              [[1, 0, 0, 1]]
+      let g = HBM.buildModelGraph m
+          edges = HBM.mgEdges g
+      length [() | (s, t) <- edges, t == "y", s `elem` ["nu", "v00", "v11"]] `shouldBe` 3
+
+  describe "Hanalyze.Model.HBM Distribution (Phase 39-A3: Bound + OrderedProbit)" $ do
+    let isClose eps a b = abs (a - b) < eps
+    --
+    -- Bound = Truncated と同等
+    --
+    it "Bound(Normal(0,1), 0, +∞) は Truncated と同じ logDensity" $ do
+      let p1 = HBM.logDensity (HBM.Bound (HBM.Normal 0 1) (Just 0) Nothing :: HBM.Distribution Double) 1
+          p2 = HBM.logDensity (HBM.Truncated (HBM.Normal 0 1) (Just 0) Nothing :: HBM.Distribution Double) 1
+      isClose 1e-12 p1 p2 `shouldBe` True
+    it "Bound(Normal(0,1), 0, ∞) は範囲外 (-1) で −∞" $ do
+      let p = HBM.logDensity (HBM.Bound (HBM.Normal 0 1) (Just 0) Nothing :: HBM.Distribution Double) (-1)
+      (p < -1e10) `shouldBe` True
+    it "Bound sample (rejection) は範囲内に収まる" $ do
+      gen <- MWC.create
+      xs <- mapM (\_ -> HBM.sampleDist (HBM.Bound (HBM.Normal 0 1) (Just 0) (Just 2)) gen) [1..500::Int]
+      (all (\x -> x >= 0 && x <= 2) xs) `shouldBe` True
+    --
+    -- OrderedProbit: K=3 (cuts=[c1, c2]) で 3 カテゴリの確率総和 1
+    --
+    it "OrderedProbit(η=0, cuts=[-1,1]) は 3 カテゴリの確率総和 1" $ do
+      let d = HBM.OrderedProbit 0 [-1, 1] :: HBM.Distribution Double
+          p0 = exp (HBM.logDensityObs d 0)
+          p1 = exp (HBM.logDensityObs d 1)
+          p2 = exp (HBM.logDensityObs d 2)
+      isClose 1e-9 (p0 + p1 + p2) 1 `shouldBe` True
+    it "OrderedProbit(η=0, cuts=[-1,1]) は中央カテゴリの確率最大 (対称)" $ do
+      let d = HBM.OrderedProbit 0 [-1, 1] :: HBM.Distribution Double
+          p0 = exp (HBM.logDensityObs d 0)
+          p1 = exp (HBM.logDensityObs d 1)
+          p2 = exp (HBM.logDensityObs d 2)
+      (p1 > p0 && p1 > p2) `shouldBe` True
+    it "OrderedProbit(η=2, cuts=[-1,1]) は最大カテゴリ寄り (右シフト)" $ do
+      -- η=2: P(y=2) = 1 - Φ(1 - 2) = 1 - Φ(-1) ≈ 0.841
+      let d = HBM.OrderedProbit 2 [-1, 1] :: HBM.Distribution Double
+          p2 = exp (HBM.logDensityObs d 2)
+      isClose 0.01 p2 0.8413 `shouldBe` True
+    it "OrderedProbit sample 2000 個で全カテゴリ {0,1,2} に分布" $ do
+      gen <- MWC.create
+      xs <- mapM (\_ -> HBM.sampleDist (HBM.OrderedProbit 0 [-1, 1]) gen) [1..2000::Int]
+      let cats = [ length (filter (== fromIntegral k) xs) | k <- [0..2 :: Int]]
+      (all (> 0) cats) `shouldBe` True
+    --
+    -- DAG
+    --
+    it "OrderedProbit の buildModelGraph: η, cuts → y" $ do
+      let m :: HBM.ModelP ()
+          m = do
+            eta <- HBM.sample "eta" (HBM.Normal 0 1)
+            c1  <- HBM.sample "c1"  (HBM.Normal 0 1)
+            c2  <- HBM.sample "c2"  (HBM.Normal 0 1)
+            HBM.observe "y" (HBM.OrderedProbit eta [c1, c2]) [0, 1, 2]
+      let g = HBM.buildModelGraph m
+          edges = HBM.mgEdges g
+      length [() | (s, t) <- edges, t == "y", s `elem` ["eta", "c1", "c2"]] `shouldBe` 3
+
+  describe "Hanalyze.Model.HBM.orderedCuts (Phase 39-A6)" $ do
+    --
+    -- orderedCuts は increasing 列を deterministic で生成
+    --
+    it "orderedCuts: K-1=3 で長さ 3 の Track 列、 d_i は latent として登録" $ do
+      let m :: HBM.ModelP ()
+          m = do
+            cs <- HBM.orderedCuts "cuts" 3 (-1) 1
+            -- 観測なし (構造のみ確認)
+            HBM.observe "y" (HBM.OrderedLogistic 0 cs) [0, 1, 2, 3]
+      let g = HBM.buildModelGraph m
+          nodes = HBM.mgNodes g
+          nodeNames = map HBM.nodeName nodes
+      -- 期待: cuts_c_1, cuts_d_2, cuts_c_2, cuts_d_3, cuts_c_3, y
+      elem "cuts_c_1" nodeNames `shouldBe` True
+      elem "cuts_d_2" nodeNames `shouldBe` True
+      elem "cuts_c_2" nodeNames `shouldBe` True
+      elem "cuts_d_3" nodeNames `shouldBe` True
+      elem "cuts_c_3" nodeNames `shouldBe` True
+    it "orderedCuts: cuts は y の親に並ぶ (DAG-safe helper)" $ do
+      let m :: HBM.ModelP ()
+          m = do
+            cs <- HBM.orderedCuts "cuts" 2 0 1
+            HBM.observe "y" (HBM.OrderedLogistic 0 cs) [0, 1, 2]
+      let g = HBM.buildModelGraph m
+          edges = HBM.mgEdges g
+      -- y の親に cuts_c_1, cuts_c_2 が含まれる
+      elem ("cuts_c_1", "y") edges `shouldBe` True
+      elem ("cuts_c_2", "y") edges `shouldBe` True
+    it "orderedCuts は sample 時に c_min から増加する列を返す" $ do
+      let m :: HBM.ModelP ()
+          m = do
+            cs <- HBM.orderedCuts "cuts" 3 (-2) 0.5
+            -- 戻り値をそのまま使うため observe で形だけ束縛 (drift 防止に gen=()
+            -- は使わず deterministic 確認は sampleNames 経由)
+            HBM.observe "y" (HBM.OrderedLogistic 0 cs) [0]
+      -- buildModelGraph 経由で deterministic ノード数を数える
+      let g = HBM.buildModelGraph m
+          dets = filter (\n -> HBM.nodeDist n == "Deterministic") (HBM.mgNodes g)
+      length dets `shouldBe` 3  -- c_1, c_2, c_3
+
+  describe "Hanalyze.Model.HBM.dpStickBreaking (Phase 39-A5)" $ do
+    --
+    -- T=5 で β_1..β_4 latent + stick_1..stick_5 det + pi_1..pi_5 det
+    --
+    it "dpStickBreaking T=5 α=1: β_1..β_4 + stick + π ノードが揃う" $ do
+      let m :: HBM.ModelP ()
+          m = do
+            pis <- HBM.dpStickBreaking "dp" 5 1
+            HBM.observe "y" (HBM.Categorical pis) [0, 1, 2, 3, 4]
+      let g = HBM.buildModelGraph m
+          ns = map HBM.nodeName (HBM.mgNodes g)
+      all (`elem` ns) ["dp_b_1", "dp_b_2", "dp_b_3", "dp_b_4"] `shouldBe` True
+      all (`elem` ns) ["dp_pi_1", "dp_pi_2", "dp_pi_3", "dp_pi_4", "dp_pi_5"] `shouldBe` True
+    it "dpStickBreaking π の和は 1 (構造的、 任意 β で成立)" $ do
+      -- Track 経由のテスト: β_k = 0.5 固定で π_k の和を計算
+      -- stick_1 = 1
+      -- π_1 = 0.5 * 1 = 0.5
+      -- stick_2 = 1 * 0.5 = 0.5
+      -- π_2 = 0.5 * 0.5 = 0.25
+      -- stick_3 = 0.5 * 0.5 = 0.25
+      -- π_3 = 0.5 * 0.25 = 0.125
+      -- stick_4 = 0.25 * 0.5 = 0.125
+      -- π_4 = stick_4 = 0.125 (last)
+      -- 和: 0.5 + 0.25 + 0.125 + 0.125 = 1.0
+      let pis = let beta = 0.5
+                    stk1 = 1
+                    stk2 = stk1 * (1 - beta)
+                    stk3 = stk2 * (1 - beta)
+                    stk4 = stk3 * (1 - beta)
+                    p1 = beta * stk1
+                    p2 = beta * stk2
+                    p3 = beta * stk3
+                    p4 = stk4
+                in [p1, p2, p3, p4 :: Double]
+      sum pis `shouldBe` 1.0
+    it "dpStickBreaking T=3: pi_3 が y の親に含まれる (DAG-safe)" $ do
+      let m :: HBM.ModelP ()
+          m = do
+            pis <- HBM.dpStickBreaking "dp" 3 2
+            HBM.observe "y" (HBM.Categorical pis) [0, 1, 2]
+      let g = HBM.buildModelGraph m
+          edges = HBM.mgEdges g
+      elem ("dp_pi_1", "y") edges `shouldBe` True
+      elem ("dp_pi_2", "y") edges `shouldBe` True
+      elem ("dp_pi_3", "y") edges `shouldBe` True
+
+  describe "Hanalyze.Model.HBM.hmmForwardLogLik (Phase 39-A4)" $ do
+    let isClose eps a b = abs (a - b) < eps
+    --
+    -- 手計算確認 K=2, T=2:
+    --   π_0 = [0.6, 0.4]、 T = [[0.7, 0.3], [0.4, 0.6]]
+    --   emit (y_1=0, y_2=1): emit = [[log 0.5, log 0.1], [log 0.4, log 0.5]]
+    --   直接列挙: Σ_{s0,s1} π_0[s0] e_0[s0] T[s0][s1] e_1[s1]
+    --     0,0: 0.6 * 0.5 * 0.7 * 0.4 = 0.084
+    --     0,1: 0.6 * 0.5 * 0.3 * 0.5 = 0.045
+    --     1,0: 0.4 * 0.1 * 0.4 * 0.4 = 0.0064
+    --     1,1: 0.4 * 0.1 * 0.6 * 0.5 = 0.012
+    --   sum = 0.1474 → log ≈ -1.9143
+    --
+    it "K=2, T=2 の周辺対数尤度が直接列挙と一致 (-1.9143)" $ do
+      let pi0   = [0.6, 0.4] :: [Double]
+          trans = [[0.7, 0.3], [0.4, 0.6]]
+          emit  = [[log 0.5, log 0.1], [log 0.4, log 0.5]]
+          ll = HBM.hmmForwardLogLik pi0 trans emit
+      isClose 1e-9 ll (log 0.1474) `shouldBe` True
+    --
+    -- T=1 の自明ケース: log P(y_1) = logSumExp(log π_0[k] + emit[0][k])
+    --
+    it "T=1 で周辺対数尤度 = logSumExp(log π_0 + emit)" $ do
+      let pi0  = [0.7, 0.3] :: [Double]
+          tr   = [[0.5, 0.5], [0.5, 0.5]]
+          emit = [[log 0.8, log 0.2]]
+          ll   = HBM.hmmForwardLogLik pi0 tr emit
+          expected = log (0.7 * 0.8 + 0.3 * 0.2)
+      isClose 1e-9 ll expected `shouldBe` True
+    --
+    -- T=0 (観測なし) は 0
+    --
+    it "T=0 (空観測) は logLik = 0" $ do
+      let pi0 = [0.5, 0.5] :: [Double]
+          tr  = [[0.5, 0.5], [0.5, 0.5]]
+          ll  = HBM.hmmForwardLogLik pi0 tr ([] :: [[Double]])
+      ll `shouldBe` 0
+    --
+    -- 恒等遷移 (T = I) は各時刻独立: log P(y) = log Σ_k π_0[k] Π_t e_t[k]
+    --
+    it "恒等遷移 (絶対吸収) で π_0 と emission の積が正しく入る" $ do
+      let pi0  = [1.0, 0.0] :: [Double]  -- 確実に状態 0 開始
+          tr   = [[1.0, 0.0], [0.0, 1.0]]  -- 状態固定
+          emit = [[log 0.5, log 0.9], [log 0.4, log 0.8]]
+          ll   = HBM.hmmForwardLogLik pi0 tr emit
+          -- 状態 0 確定: P = 0.5 * 0.4 = 0.2
+          expected = log 0.2
+      isClose 1e-9 ll expected `shouldBe` True
+    --
+    -- 大 T (T=200) で underflow しない (log-space の効用)
+    --
+    it "T=200 で underflow せず有限値を返す (log-space)" $ do
+      let pi0  = [0.5, 0.5] :: [Double]
+          tr   = [[0.9, 0.1], [0.1, 0.9]]
+          emit = replicate 200 [log 0.3, log 0.5]
+          ll   = HBM.hmmForwardLogLik pi0 tr emit
+      -- 期待値ではなく「有限かつ負」 を確認
+      (ll < 0 && not (isInfinite ll) && not (isNaN ll)) `shouldBe` True
+
+  describe "Hanalyze.Model.HBM.hmmLatent (Phase 39-A4)" $ do
+    --
+    -- K=2 で pi0 + 2 trans rows = 3 Dirichlet が立つ
+    --
+    it "hmmLatent K=2 α=1: pi0 + trans_0, trans_1 の Dirichlet が立つ" $ do
+      let m :: HBM.ModelP ()
+          m = do
+            (pi0, trans) <- HBM.hmmLatent "hmm" 2 1
+            -- emit の dummy 計算 (実用では emission 毎時刻で別々)
+            let emit = [[log (pi0 !! 0), log (pi0 !! 1)]]
+                tr   = trans
+            HBM.potential "lik" (HBM.hmmForwardLogLik pi0 tr emit)
+      let g = HBM.buildModelGraph m
+          ns = map HBM.nodeName (HBM.mgNodes g)
+      -- pi0: hmm_pi0_0, hmm_pi0_1 (Dirichlet 内部の det)
+      elem "hmm_pi0_0" ns `shouldBe` True
+      elem "hmm_pi0_1" ns `shouldBe` True
+      -- trans: hmm_trans_0_0, hmm_trans_0_1, hmm_trans_1_0, hmm_trans_1_1
+      elem "hmm_trans_0_0" ns `shouldBe` True
+      elem "hmm_trans_1_1" ns `shouldBe` True
+    it "hmmLatent K=3 α=1: 3 + 9 = 12 個の π det ノード" $ do
+      let m :: HBM.ModelP ()
+          m = do
+            (pi0, trans) <- HBM.hmmLatent "hmm" 3 1
+            let emit = [[log p | p <- pi0]]
+            HBM.potential "lik" (HBM.hmmForwardLogLik pi0 trans emit)
+      let g = HBM.buildModelGraph m
+          ns = map HBM.nodeName (HBM.mgNodes g)
+          -- 各 dirichlet は内部に β_b<i> latent も持つので、 π det は
+          -- 末尾が数字のみ (β は _b<i> 形)
+          isPiDet base n =
+            base `T.isPrefixOf` n
+            && case T.stripPrefix base n of
+                 Just rest -> T.all (`elem` ("0123456789" :: String)) rest
+                 Nothing   -> False
+          piCnt = length [n | n <- ns, isPiDet "hmm_pi0_" n]
+          trCnt = sum
+            [ length [n | n <- ns, isPiDet ("hmm_trans_" <> T.pack (show i) <> "_") n]
+            | i <- [0..2 :: Int] ]
+      piCnt `shouldBe` 3   -- π_0[0..2]
+      trCnt `shouldBe` 9   -- 3 行 × 3 列
+
+  describe "Hanalyze.Model.HBM.plate (Phase 40-A1/A2)" $ do
+    --
+    -- 8-schools: plate "school" 8 で eta_j を囲う、 mu/tau は plate 外
+    --
+    let eightSchools :: HBM.ModelP ()
+        eightSchools = do
+          mu  <- HBM.sample "mu"  (HBM.Normal 0 5)
+          tau <- HBM.sample "tau" (HBM.HalfCauchy 5)
+          _ <- HBM.plate "school" 8 $ forM [0..7 :: Int] $ \j -> do
+            eta <- HBM.sample ("eta_" <> T.pack (show j)) (HBM.Normal 0 1)
+            HBM.observe ("y_" <> T.pack (show j))
+              (HBM.Normal (mu + tau * eta) 1) [realToFrac j]
+          return ()
+    it "8-schools: mgPlates に \"school\" → 8 が入る" $ do
+      let g = HBM.buildModelGraph eightSchools
+      M.lookup "school" (HBM.mgPlates g) `shouldBe` Just 8
+    it "8-schools: mu / tau は nodePlates が空 (plate 外)" $ do
+      let g = HBM.buildModelGraph eightSchools
+          nByName nm = filter (\n -> HBM.nodeName n == nm) (HBM.mgNodes g)
+      case nByName "mu" of
+        [n] -> HBM.nodePlates n `shouldBe` []
+        _   -> expectationFailure "mu not found"
+      case nByName "tau" of
+        [n] -> HBM.nodePlates n `shouldBe` []
+        _   -> expectationFailure "tau not found"
+    it "8-schools: eta_j / y_j は nodePlates = [\"school\"]" $ do
+      let g = HBM.buildModelGraph eightSchools
+          nByName nm = filter (\n -> HBM.nodeName n == nm) (HBM.mgNodes g)
+      case nByName "eta_3" of
+        [n] -> HBM.nodePlates n `shouldBe` ["school"]
+        _   -> expectationFailure "eta_3 not found"
+      case nByName "y_5" of
+        [n] -> HBM.nodePlates n `shouldBe` ["school"]
+        _   -> expectationFailure "y_5 not found"
+    --
+    -- plateI 糖衣の動作確認
+    --
+    it "plateI helper: plate name n (forM [0..n-1] f) と同等" $ do
+      let m :: HBM.ModelP ()
+          m = do
+            _ <- HBM.plateI "g" 3 $ \j ->
+              HBM.sample ("x_" <> T.pack (show j)) (HBM.Normal 0 1)
+            return ()
+      let g = HBM.buildModelGraph m
+          xPlate = [HBM.nodePlates n | n <- HBM.mgNodes g, HBM.nodeName n == "x_1"]
+      xPlate `shouldBe` [["g"]]
+      M.lookup "g" (HBM.mgPlates g) `shouldBe` Just 3
+    --
+    -- plateI_ 糖衣の動作確認 (返り値破棄版・plateForM_ name [0..n-1] と同等)
+    --
+    it "plateI_ helper: plate name n (forM_ [0..n-1] f) と同等" $ do
+      let m :: HBM.ModelP ()
+          m = HBM.plateI_ "g" 3 $ \j ->
+                HBM.sample ("x" HBM..# j) (HBM.Normal 0 1)
+      let g = HBM.buildModelGraph m
+          xPlate = [HBM.nodePlates n | n <- HBM.mgNodes g, HBM.nodeName n == "x_1"]
+      xPlate `shouldBe` [["g"]]
+      M.lookup "g" (HBM.mgPlates g) `shouldBe` Just 3
+    --
+    -- nested plate: outer "school" 3 内に inner "student" 2
+    --
+    it "nested plate: nodePlates が [\"school\", \"student\"] (外→内)" $ do
+      let m :: HBM.ModelP ()
+          m = do
+            _ <- HBM.plate "school" 3 $ forM_ [0..2 :: Int] $ \j ->
+              HBM.plate "student" 2 $ forM_ [0..1 :: Int] $ \i ->
+                HBM.sample ("y_" <> T.pack (show j) <> "_" <> T.pack (show i))
+                           (HBM.Normal 0 1)
+            return ()
+      let g = HBM.buildModelGraph m
+          yNode = filter (\n -> HBM.nodeName n == "y_1_0") (HBM.mgNodes g)
+      case yNode of
+        [n] -> HBM.nodePlates n `shouldBe` ["school", "student"]
+        _   -> expectationFailure "y_1_0 not found"
+      M.lookup "school"  (HBM.mgPlates g) `shouldBe` Just 3
+      M.lookup "student" (HBM.mgPlates g) `shouldBe` Just 2
+    --
+    -- 既存 helper (plate 未使用) との regression: nodePlates は空
+    --
+    it "既存モデル (plate 未使用) の nodePlates は空 (regression なし)" $ do
+      let m :: HBM.ModelP ()
+          m = do
+            mu <- HBM.sample "mu" (HBM.Normal 0 1)
+            HBM.observe "y" (HBM.Normal mu 1) [0, 1, 2]
+      let g = HBM.buildModelGraph m
+      all (null . HBM.nodePlates) (HBM.mgNodes g) `shouldBe` True
+      M.null (HBM.mgPlates g) `shouldBe` True
+
+  describe "Hanalyze.Model.HBM ノード名ヘルパ (indexed / .#)" $ do
+    it "indexed はアンダースコア付きインデックス名を作る" $ do
+      HBM.indexed "theta" 1 `shouldBe` ("theta_1" :: T.Text)
+      HBM.indexed "y" 0     `shouldBe` ("y_0" :: T.Text)
+    it ".# は indexed の中置版 (= 同じ結果)" $
+      ("theta" HBM..# 3) `shouldBe` HBM.indexed "theta" 3
+    it "sample 名に使うと sampleNames に反映される" $ do
+      let m :: HBM.ModelP ()
+          m = do
+            mu <- HBM.sample "mu" (HBM.Normal 0 5)
+            _  <- HBM.sample (HBM.indexed "theta" 1) (HBM.Normal mu 1)
+            _  <- HBM.sample ("theta" HBM..# 2)      (HBM.Normal mu 1)
+            pure ()
+      sort (HBM.sampleNames m) `shouldBe` sort ["mu", "theta_1", "theta_2"]
+
+  describe "Hanalyze.Model.HBM plate 罠 6 件 (Phase 40-A4)" $ do
+    --
+    -- 罠 1: 非中心化 (nonCentered) + plate
+    --   raw eta_raw + det eta が両方 plate "school" に属するべき
+    --
+    it "罠 1: nonCenteredNormal を plate 内で使うと raw + det 両方 plate" $ do
+      let m :: HBM.ModelP ()
+          m = do
+            mu  <- HBM.sample "mu" (HBM.Normal 0 5)
+            tau <- HBM.sample "tau" (HBM.HalfCauchy 5)
+            _ <- HBM.plate "school" 4 $ forM_ [0..3 :: Int] $ \j -> do
+              eta <- HBM.nonCenteredNormal ("eta_" <> T.pack (show j)) mu tau
+              HBM.observe ("y_" <> T.pack (show j)) (HBM.Normal eta 1)
+                          [realToFrac j]
+            return ()
+      let g = HBM.buildModelGraph m
+          nByName nm = filter (\n -> HBM.nodeName n == nm) (HBM.mgNodes g)
+      case nByName "eta_2_raw" of
+        [n] -> HBM.nodePlates n `shouldBe` ["school"]
+        _   -> expectationFailure "eta_2_raw not found"
+      case nByName "eta_2" of
+        [n] -> HBM.nodePlates n `shouldBe` ["school"]
+        _   -> expectationFailure "eta_2 not found"
+    --
+    -- 罠 2: 入れ子 plate (multi-level: school × student) は A2 で確認済
+    --   ここでは edge が境界を超えても plate 構造を壊さないことを確認
+    --
+    it "罠 2: nested plate で内側 edge は内 plate のまま" $ do
+      let m :: HBM.ModelP ()
+          m = do
+            _ <- HBM.plate "school" 2 $ forM_ [0..1 :: Int] $ \j -> do
+              theta <- HBM.sample ("theta_" <> T.pack (show j))
+                         (HBM.Normal 0 1)
+              _ <- HBM.plate "student" 2 $ forM_ [0..1 :: Int] $ \i ->
+                HBM.observe ("y_" <> T.pack (show j) <> "_" <> T.pack (show i))
+                  (HBM.Normal theta 1) [realToFrac i]
+              return ()
+            return ()
+      let g = HBM.buildModelGraph m
+          nByName nm = filter (\n -> HBM.nodeName n == nm) (HBM.mgNodes g)
+      -- theta は school plate のみ
+      case nByName "theta_0" of
+        [n] -> HBM.nodePlates n `shouldBe` ["school"]
+        _   -> expectationFailure "theta_0 not found"
+      -- y_0_0 は school + student 両 plate
+      case nByName "y_0_0" of
+        [n] -> HBM.nodePlates n `shouldBe` ["school", "student"]
+        _   -> expectationFailure "y_0_0 not found"
+    --
+    -- 罠 3: クロス plate (subject × time): 重ならない 2 plate を別個に書く
+    --   (PyMC でも完全な交差は描けないので、 2 plate 並列のみ確認)
+    --
+    it "罠 3: crossed plate は 2 つの別々 plate として記録される" $ do
+      let m :: HBM.ModelP ()
+          m = do
+            -- subject ごとの効果 (plate "subject" 3)
+            _ <- HBM.plate "subject" 3 $ forM_ [0..2 :: Int] $ \s ->
+              HBM.sample ("u_" <> T.pack (show s)) (HBM.Normal 0 1)
+            -- time ごとの効果 (plate "time" 2、 別 plate)
+            _ <- HBM.plate "time" 2 $ forM_ [0..1 :: Int] $ \t ->
+              HBM.sample ("v_" <> T.pack (show t)) (HBM.Normal 0 1)
+            return ()
+      let g = HBM.buildModelGraph m
+      M.lookup "subject" (HBM.mgPlates g) `shouldBe` Just 3
+      M.lookup "time"    (HBM.mgPlates g) `shouldBe` Just 2
+    --
+    -- 罠 4: plate 外から plate 内 への edge (mu → eta_j × 8 本)
+    --   → mu の plate は [] / eta は plate ['school'] / edge は数本残る
+    --
+    it "罠 4: plate 外 mu から plate 内 eta_j への edge が複数本記録される" $ do
+      let m :: HBM.ModelP ()
+          m = do
+            mu <- HBM.sample "mu" (HBM.Normal 0 5)
+            _ <- HBM.plate "school" 3 $ forM_ [0..2 :: Int] $ \j ->
+              HBM.sample ("eta_" <> T.pack (show j)) (HBM.Normal mu 1)
+            return ()
+      let g = HBM.buildModelGraph m
+          edges = HBM.mgEdges g
+          muEdges = [() | (s, t) <- edges, s == "mu",
+                          T.isPrefixOf "eta_" t]
+      length muEdges `shouldBe` 3
+    --
+    -- 罠 5: plate 内同士の edge (eta_j → y_j 同 plate)
+    --
+    it "罠 5: plate 内同士の edge (eta_j → y_j) が記録される" $ do
+      let m :: HBM.ModelP ()
+          m = do
+            _ <- HBM.plate "school" 3 $ forM_ [0..2 :: Int] $ \j -> do
+              eta <- HBM.sample ("eta_" <> T.pack (show j))
+                       (HBM.Normal 0 1)
+              HBM.observe ("y_" <> T.pack (show j)) (HBM.Normal eta 1)
+                          [realToFrac j]
+            return ()
+      let g = HBM.buildModelGraph m
+          edges = HBM.mgEdges g
+      elem ("eta_0", "y_0") edges `shouldBe` True
+      elem ("eta_1", "y_1") edges `shouldBe` True
+      elem ("eta_2", "y_2") edges `shouldBe` True
+    --
+    -- 罠 6: observe の自動 merge と plate の整合
+    --   同名 observe を統合する mergeByName は plate 内でも動く
+    --   (個別名 y_0..y_7 を使えば衝突なし)
+    --
+    it "罠 6: 同名 observe \"y\" の自動 merge と plate の整合" $ do
+      let m :: HBM.ModelP ()
+          m = do
+            mu <- HBM.sample "mu" (HBM.Normal 0 1)
+            -- plate 内で同じ "y" を 3 回 observe (mergeByName が 1 ノードに統合)
+            _ <- HBM.plate "obs" 3 $ forM_ [0..2 :: Int] $ \_ ->
+              HBM.observe "y" (HBM.Normal mu 1) [0]
+            return ()
+      let g = HBM.buildModelGraph m
+          ys = filter (\n -> HBM.nodeName n == "y") (HBM.mgNodes g)
+      length ys `shouldBe` 1
+      -- merge された y は最初の plate 状態を維持 (= ["obs"])
+      case ys of
+        [n] -> HBM.nodePlates n `shouldBe` ["obs"]
+        _   -> expectationFailure "y not found"
+
+  describe "既存 helper (dirichlet / glmm / ar1Latent) を plate で包む (Phase 40-A5)" $ do
+    --
+    -- B2 plate は既存 helper をそのまま囲うだけで plate-aware 化できる
+    -- (新規 helper 追加なし)。 これがメリット。
+    --
+    it "dirichlet を plate で包むと β / π すべてが plate メンバ" $ do
+      let m :: HBM.ModelP ()
+          m = do
+            _ <- HBM.plate "K" 3 $ HBM.dirichlet "pi" [1, 1, 1]
+            return ()
+      let g = HBM.buildModelGraph m
+          ns = HBM.mgNodes g
+          piNodes = filter (\n -> "pi_" `T.isPrefixOf` HBM.nodeName n) ns
+      all (\n -> HBM.nodePlates n == ["K"]) piNodes `shouldBe` True
+      M.lookup "K" (HBM.mgPlates g) `shouldBe` Just 3
+    it "ar1Latent を plate で包むと x_raw_t / x_t すべてが plate メンバ" $ do
+      let m :: HBM.ModelP ()
+          m = do
+            _ <- HBM.plate "T" 4 $ HBM.ar1Latent "x" 4 0.5 1
+            return ()
+      let g = HBM.buildModelGraph m
+          ns = HBM.mgNodes g
+          xNodes = filter (\n -> "x_" `T.isPrefixOf` HBM.nodeName n) ns
+      all (\n -> HBM.nodePlates n == ["T"]) xNodes `shouldBe` True
+    it "plate 入れ子で glmmRandomIntercept を使うと u_j のみ plate" $ do
+      let m :: HBM.ModelP ()
+          m = do
+            -- glmmRandomIntercept は内部で複数 sample/observe を出すので、
+            -- plate "subject" J で包むと全てが plate メンバになる
+            let xs = [[1.0], [1.0], [1.0]] :: [[Double]]
+                gids = [0, 1, 0] :: [Int]
+                ys = [0.5, 1.0, 0.7] :: [Double]
+            _ <- HBM.plate "subject" 2 $
+                   HBM.glmmRandomIntercept HBM.GlmmGaussian xs gids ys
+            return ()
+      let g = HBM.buildModelGraph m
+          ns = HBM.mgNodes g
+          uNodes = filter (\n -> "u_" `T.isPrefixOf` HBM.nodeName n) ns
+      -- u_j は全て plate "subject"
+      all (\n -> HBM.nodePlates n == ["subject"]) uNodes `shouldBe` True
+      -- 結論: 既存 helper 無変更で plate で包めば自動的に DAG にも plate 反映
+
+  describe "Hanalyze.Model.HBM.collapseIndexedPlateNodes (Phase 40-A8: PyMC 同等)" $ do
+    --
+    -- 8-schools collapse: eta_0..eta_7 → eta、 y_0..y_7 → y (n=8)
+    --
+    it "8-schools: collapse 後ノード数 = 4 (mu, tau, eta, y)" $ do
+      let m :: HBM.ModelP ()
+          m = do
+            mu  <- HBM.sample "mu"  (HBM.Normal 0 5)
+            tau <- HBM.sample "tau" (HBM.HalfCauchy 5)
+            _ <- HBM.plate "school" 8 $ forM_ [0..7 :: Int] $ \j -> do
+              eta <- HBM.sample ("eta_" <> T.pack (show j)) (HBM.Normal 0 1)
+              HBM.observe ("y_" <> T.pack (show j))
+                          (HBM.Normal (mu + tau * eta) 1) [realToFrac j]
+            return ()
+      let g  = HBM.buildModelGraph m
+          gc = HBM.collapseIndexedPlateNodes g
+          names = map HBM.nodeName (HBM.mgNodes gc)
+      length (HBM.mgNodes gc) `shouldBe` 4
+      all (`elem` names) ["mu", "tau", "eta", "y"] `shouldBe` True
+    it "8-schools: 集約後 y の観測数 = 8" $ do
+      let m :: HBM.ModelP ()
+          m = do
+            _ <- HBM.plate "school" 8 $ forM_ [0..7 :: Int] $ \j ->
+              HBM.observe ("y_" <> T.pack (show j)) (HBM.Normal 0 1)
+                          [realToFrac j]
+            return ()
+      let gc = HBM.collapseIndexedPlateNodes (HBM.buildModelGraph m)
+          yNode = filter (\n -> HBM.nodeName n == "y") (HBM.mgNodes gc)
+      case yNode of
+        [n] -> HBM.nodeKind n `shouldBe` HBM.ObservedN 8
+        _   -> expectationFailure "y not found"
+    it "8-schools: 集約後 edges 3 本 (eta→y, mu→y, tau→y)" $ do
+      let m :: HBM.ModelP ()
+          m = do
+            mu  <- HBM.sample "mu"  (HBM.Normal 0 5)
+            tau <- HBM.sample "tau" (HBM.HalfCauchy 5)
+            _ <- HBM.plate "school" 8 $ forM_ [0..7 :: Int] $ \j -> do
+              eta <- HBM.sample ("eta_" <> T.pack (show j)) (HBM.Normal 0 1)
+              HBM.observe ("y_" <> T.pack (show j))
+                          (HBM.Normal (mu + tau * eta) 1) [realToFrac j]
+            return ()
+      let gc = HBM.collapseIndexedPlateNodes (HBM.buildModelGraph m)
+      length (HBM.mgEdges gc) `shouldBe` 3
+      elem ("eta", "y") (HBM.mgEdges gc) `shouldBe` True
+      elem ("mu",  "y") (HBM.mgEdges gc) `shouldBe` True
+      elem ("tau", "y") (HBM.mgEdges gc) `shouldBe` True
+    --
+    -- nested multilevel: 不動点で 2 段集約
+    --
+    it "nested multilevel: 集約後 y は n=6 (J=3 × K=2)" $ do
+      let m :: HBM.ModelP ()
+          m = do
+            _ <- HBM.plate "school" 3 $ forM_ [0..2 :: Int] $ \j -> do
+              theta <- HBM.sample ("theta_" <> T.pack (show j))
+                                  (HBM.Normal 0 1)
+              _ <- HBM.plate "student" 2 $ forM_ [0..1 :: Int] $ \i ->
+                HBM.observe ("y_" <> T.pack (show j) <> "_" <> T.pack (show i))
+                            (HBM.Normal theta 1) [realToFrac i]
+              return ()
+            return ()
+      let gc = HBM.collapseIndexedPlateNodes (HBM.buildModelGraph m)
+          yNode = filter (\n -> HBM.nodeName n == "y") (HBM.mgNodes gc)
+      case yNode of
+        [n] -> HBM.nodeKind n `shouldBe` HBM.ObservedN 6
+        _   -> expectationFailure "y not found"
+    it "idempotent: collapse 2 回適用しても結果不変" $ do
+      let m :: HBM.ModelP ()
+          m = do
+            _ <- HBM.plate "g" 5 $ forM_ [0..4 :: Int] $ \j ->
+              HBM.sample ("x_" <> T.pack (show j)) (HBM.Normal 0 1)
+            return ()
+      let g  = HBM.buildModelGraph m
+          gc1 = HBM.collapseIndexedPlateNodes g
+          gc2 = HBM.collapseIndexedPlateNodes gc1
+      length (HBM.mgNodes gc1) `shouldBe` length (HBM.mgNodes gc2)
+      length (HBM.mgEdges gc1) `shouldBe` length (HBM.mgEdges gc2)
+    --
+    -- 非集約ケース: 単独 / heterogeneous な分布
+    --
+    it "非 indexed RV は触らない (mu / tau はそのまま)" $ do
+      let m :: HBM.ModelP ()
+          m = do
+            mu  <- HBM.sample "mu"  (HBM.Normal 0 1)
+            _ <- HBM.sample "tau" (HBM.HalfNormal 1)
+            HBM.observe "y" (HBM.Normal mu 1) [0]
+            return ()
+      let gc = HBM.collapseIndexedPlateNodes (HBM.buildModelGraph m)
+          names = map HBM.nodeName (HBM.mgNodes gc)
+      sort names `shouldBe` sort ["mu", "tau", "y"]
+    it "命名規則違いは集約しない: eta_0 / phi_1 は別名で残る" $ do
+      let m :: HBM.ModelP ()
+          m = do
+            _ <- HBM.plate "g" 2 $ do
+              _ <- HBM.sample "eta_0" (HBM.Normal 0 1)
+              _ <- HBM.sample "phi_1" (HBM.Normal 0 1)
+              return ()
+            return ()
+      let gc = HBM.collapseIndexedPlateNodes (HBM.buildModelGraph m)
+          names = map HBM.nodeName (HBM.mgNodes gc)
+      sort names `shouldBe` sort ["eta_0", "phi_1"]
+
+  describe "Hanalyze.Model.HBM.glmmRandomIntercept (Phase 37-A6)" $ do
+    -- 3 群、 切片のみの GLMM (X = [[1], [1], ...]、 つまり全観測で 1 列)
+    let xRows = replicate 9 [1.0]                 -- intercept only
+        gids  = [0, 0, 0, 1, 1, 1, 2, 2, 2]       -- 3 群、 各 3 観測
+        ysGauss = [1.0, 0.8, 1.3, 4.9, 5.2, 4.7, 8.9, 9.0, 8.7]
+        modelGauss :: HBM.ModelP ()
+        modelGauss = HBM.glmmRandomIntercept HBM.GlmmGaussian xRows gids ysGauss
+        modelBin :: HBM.ModelP ()
+        modelBin = HBM.glmmRandomIntercept HBM.GlmmBinomial xRows gids
+                     [1, 0, 1, 0, 0, 1, 1, 1, 0]
+        modelPois :: HBM.ModelP ()
+        modelPois = HBM.glmmRandomIntercept HBM.GlmmPoisson xRows gids
+                     [2, 1, 3, 5, 6, 4, 8, 9, 7]
+    --
+    it "GlmmGaussian は beta_0 / tau_u / u_0..u_2 / sigma を sample 名に持つ" $ do
+      let ns = HBM.sampleNames modelGauss
+      ns `shouldContain` ["beta_0"]
+      ns `shouldContain` ["tau_u"]
+      ns `shouldContain` ["u_0", "u_1", "u_2"]
+      ns `shouldContain` ["sigma"]
+    it "GlmmBinomial は sigma を含まない (残差不要)" $ do
+      let ns = HBM.sampleNames modelBin
+      ns `shouldContain` ["beta_0"]
+      ns `shouldNotContain` ["sigma"]
+    it "GlmmPoisson は sigma を含まない (残差不要)" $ do
+      let ns = HBM.sampleNames modelPois
+      let containsSigma = "sigma" `elem` ns
+      containsSigma `shouldBe` False
+    it "GlmmGaussian で短い NUTS が収束し u_2 > u_0 を回復" $ do
+      gen <- MWC.create
+      let cfg = NUTS.defaultNUTSConfig
+                  { NUTS.nutsIterations = 200
+                  , NUTS.nutsBurnIn     = 100
+                  , NUTS.nutsStepSize   = 0.05
+                  }
+          initP = M.fromList
+            [ ("beta_0", 5), ("tau_u", 3), ("sigma", 0.3)
+            , ("u_0", -4), ("u_1", 0), ("u_2", 4)
+            ]
+      ch <- NUTS.nuts modelGauss cfg initP gen
+      let u0 = case Core.posteriorMean "u_0" ch of Just v -> v; Nothing -> 0
+          u2 = case Core.posteriorMean "u_2" ch of Just v -> v; Nothing -> 0
+      (u2 > u0) `shouldBe` True
+
+  describe "Hanalyze.Model.HBM.buildModelGraph (Phase 38: 簡単 6 例)" $ do
+    let latentsOf g  = sort [ HBM.nodeName n
+                            | n <- HBM.mgNodes g
+                            , HBM.nodeKind n == HBM.LatentN ]
+        obsOf g      = sort [ (HBM.nodeName n, k)
+                            | n <- HBM.mgNodes g
+                            , HBM.ObservedN k <- [HBM.nodeKind n] ]
+        edgesOf g    = sort (HBM.mgEdges g)
+    --
+    -- 簡単 1: Normal(μ, σ既知) + observe
+    --
+    it "簡単 1: Normal(μ, σ既知) で 1 latent / 1 obs / 1 edge" $ do
+      let m :: HBM.ModelP ()
+          m = do
+            mu <- HBM.sample "mu" (HBM.Normal 0 10)
+            HBM.observe "y" (HBM.Normal mu 1) [0, 1, 2]
+          g = HBM.buildModelGraph m
+      latentsOf g `shouldBe` ["mu"]
+      obsOf g     `shouldBe` [("y", 3)]
+      edgesOf g   `shouldBe` [("mu", "y")]
+    --
+    -- 簡単 2: Normal(μ, σ) + observe (両方 latent)
+    --
+    it "簡単 2: Normal(μ, σ) で 2 latent / 1 obs / 2 edges" $ do
+      let m :: HBM.ModelP ()
+          m = do
+            mu    <- HBM.sample "mu"    (HBM.Normal 0 10)
+            sigma <- HBM.sample "sigma" (HBM.HalfNormal 1)
+            HBM.observe "y" (HBM.Normal mu sigma) [0, 1, 2, 3]
+          g = HBM.buildModelGraph m
+      latentsOf g `shouldBe` ["mu", "sigma"]
+      obsOf g     `shouldBe` [("y", 4)]
+      edgesOf g   `shouldBe` [("mu", "y"), ("sigma", "y")]
+    --
+    -- 簡単 3: Beta-Binomial A/B (2 群独立)
+    --
+    it "簡単 3: Beta-Binomial A/B で 2 latent / 2 obs / 2 edges" $ do
+      let m :: HBM.ModelP ()
+          m = do
+            pC <- HBM.sample "p_ctrl" (HBM.Beta 1 1)
+            pT <- HBM.sample "p_trt"  (HBM.Beta 1 1)
+            HBM.observe "y_ctrl" (HBM.Binomial 100 pC) [60]
+            HBM.observe "y_trt"  (HBM.Binomial 100 pT) [72]
+          g = HBM.buildModelGraph m
+      latentsOf g `shouldBe` ["p_ctrl", "p_trt"]
+      obsOf g     `shouldBe` [("y_ctrl", 1), ("y_trt", 1)]
+      edgesOf g   `shouldBe` [("p_ctrl", "y_ctrl"), ("p_trt", "y_trt")]
+    --
+    -- 簡単 4: SkewNormal (Phase 37-A2 + 38 補修で distDepsT 追加)
+    --
+    it "簡単 4: SkewNormal で μ, σ, α → y の 3 edges" $ do
+      let m :: HBM.ModelP ()
+          m = do
+            mu    <- HBM.sample "mu"    (HBM.Normal 0 1)
+            sigma <- HBM.sample "sigma" (HBM.HalfNormal 1)
+            alpha <- HBM.sample "alpha" (HBM.Normal 0 1)
+            HBM.observe "y" (HBM.SkewNormal mu sigma alpha) [0, 1, 2]
+          g = HBM.buildModelGraph m
+      latentsOf g `shouldBe` ["alpha", "mu", "sigma"]
+      obsOf g     `shouldBe` [("y", 3)]
+      edgesOf g   `shouldBe` [("alpha", "y"), ("mu", "y"), ("sigma", "y")]
+    --
+    -- 簡単 5: OrderedLogistic (cuts もすべて latent として親に出る)
+    --
+    it "簡単 5: OrderedLogistic で η, c_1, c_2 → y の 3 edges" $ do
+      let m :: HBM.ModelP ()
+          m = do
+            eta <- HBM.sample "eta" (HBM.Normal 0 1)
+            c1  <- HBM.sample "c_1" (HBM.Normal (-1) 1)
+            c2  <- HBM.sample "c_2" (HBM.Normal 1 1)
+            HBM.observe "y" (HBM.OrderedLogistic eta [c1, c2]) [0, 1, 2]
+          g = HBM.buildModelGraph m
+      latentsOf g `shouldBe` ["c_1", "c_2", "eta"]
+      obsOf g     `shouldBe` [("y", 3)]
+      edgesOf g   `shouldBe` [("c_1", "y"), ("c_2", "y"), ("eta", "y")]
+    --
+    -- 簡単 6: MvStudentT (ν と μ vector は latent、 Σ は定数行列)
+    --
+    it "簡単 6: MvStudentT で ν, μ_1, μ_2 → y の 3 edges (Σ=I 定数)" $ do
+      let m :: HBM.ModelP ()
+          m = do
+            nu  <- HBM.sample "nu"  (HBM.Gamma 2 0.1)
+            mu1 <- HBM.sample "mu_1" (HBM.Normal 0 1)
+            mu2 <- HBM.sample "mu_2" (HBM.Normal 0 1)
+            HBM.observe "y"
+              (HBM.MvStudentT nu [mu1, mu2] [[1, 0], [0, 1]])
+              [0, 0, 1, 1]
+          g = HBM.buildModelGraph m
+      latentsOf g `shouldBe` ["mu_1", "mu_2", "nu"]
+      obsOf g     `shouldBe` [("y", 4)]
+      edgesOf g   `shouldBe` [("mu_1", "y"), ("mu_2", "y"), ("nu", "y")]
+
+  describe "Hanalyze.Model.HBM.buildModelGraph (Phase 38: 代表 9 例)" $ do
+    let latentsOf g  = sort [ HBM.nodeName n
+                            | n <- HBM.mgNodes g
+                            , HBM.nodeKind n == HBM.LatentN ]
+        -- LatentN + DeterministicN (Phase 52.A15 で det は DeterministicN に
+        -- 分類。 latent + 派生量が全て graph に出ることを見るテスト用)。
+        latDetOf g   = sort [ HBM.nodeName n
+                            | n <- HBM.mgNodes g
+                            , HBM.nodeKind n `elem` [HBM.LatentN, HBM.DeterministicN] ]
+        obsOf g      = sort [ (HBM.nodeName n, k)
+                            | n <- HBM.mgNodes g
+                            , HBM.ObservedN k <- [HBM.nodeKind n] ]
+        edgesOf g    = sort (HBM.mgEdges g)
+        containsAll xs ys = all (`elem` xs) ys
+    --
+    -- 代表 1: 形式 A (per-group data、 群 J=3、 θ_j を unroll)
+    --
+    it "代表 1: 形式 A で μ, τ → θ_j → y_j の 2 段階層 (J=3)" $ do
+      let m :: HBM.ModelP ()
+          m = do
+            mu  <- HBM.sample "mu"  (HBM.Normal 0 10)
+            tau <- HBM.sample "tau" (HBM.HalfNormal 5)
+            t1  <- HBM.sample "theta_1" (HBM.Normal mu tau)
+            t2  <- HBM.sample "theta_2" (HBM.Normal mu tau)
+            t3  <- HBM.sample "theta_3" (HBM.Normal mu tau)
+            HBM.observe "y_1" (HBM.Normal t1 1) [1.0, 1.2]
+            HBM.observe "y_2" (HBM.Normal t2 1) [5.0, 5.3]
+            HBM.observe "y_3" (HBM.Normal t3 1) [9.0, 9.2]
+          g = HBM.buildModelGraph m
+      latentsOf g `shouldBe` sort ["mu", "tau", "theta_1", "theta_2", "theta_3"]
+      obsOf g     `shouldBe` sort [("y_1", 2), ("y_2", 2), ("y_3", 2)]
+      edgesOf g   `shouldBe` sort
+        [ ("mu", "theta_1"), ("tau", "theta_1"), ("theta_1", "y_1")
+        , ("mu", "theta_2"), ("tau", "theta_2"), ("theta_2", "y_2")
+        , ("mu", "theta_3"), ("tau", "theta_3"), ("theta_3", "y_3") ]
+    --
+    -- 代表 2: 形式 B (long-format、 forM で展開)
+    --   Track 透過性: forM 経由でも親集合が伝わることを確認
+    --
+    it "代表 2: 形式 B (forM long-format) で形式 A と同じ DAG" $ do
+      let m :: HBM.ModelP ()
+          m = do
+            mu  <- HBM.sample "mu"  (HBM.Normal 0 10)
+            tau <- HBM.sample "tau" (HBM.HalfNormal 5)
+            thetas <- forM [1 :: Int, 2, 3] $ \j ->
+              HBM.sample (T.pack ("theta_" ++ show j)) (HBM.Normal mu tau)
+            forM_ (zip [1 :: Int, 2, 3] thetas) $ \(j, th) ->
+              HBM.observe (T.pack ("y_" ++ show j)) (HBM.Normal th 1) [1.0]
+          g = HBM.buildModelGraph m
+      latentsOf g `shouldBe` sort ["mu", "tau", "theta_1", "theta_2", "theta_3"]
+      obsOf g     `shouldBe` sort [("y_1", 1), ("y_2", 1), ("y_3", 1)]
+      edgesOf g   `shouldBe` sort
+        [ ("mu", "theta_1"), ("tau", "theta_1"), ("theta_1", "y_1")
+        , ("mu", "theta_2"), ("tau", "theta_2"), ("theta_2", "y_2")
+        , ("mu", "theta_3"), ("tau", "theta_3"), ("theta_3", "y_3") ]
+    --
+    -- 代表 3: 形式 C (non-centered、 raw + deterministic 2 段)
+    --   nonCenteredNormal は raw (Normal(0,1)) + det (loc + scale * raw) に展開
+    --
+    it "代表 3: 形式 C (non-centered) で raw → det theta_j → y_j" $ do
+      let m :: HBM.ModelP ()
+          m = do
+            mu  <- HBM.sample "mu"  (HBM.Normal 0 10)
+            tau <- HBM.sample "tau" (HBM.HalfNormal 5)
+            thetas <- forM [1 :: Int, 2, 3] $ \j ->
+              HBM.nonCenteredNormal (T.pack ("theta_" ++ show j)) mu tau
+            forM_ (zip [1 :: Int, 2, 3] thetas) $ \(j, th) ->
+              HBM.observe (T.pack ("y_" ++ show j)) (HBM.Normal th 1) [1.0]
+          g = HBM.buildModelGraph m
+      -- raw (Normal(0,1) 由来・LatentN) と det theta_j (DeterministicN) の
+      -- 両方が graph に現れる (Phase 52.A15 で det は DeterministicN)
+      latDetOf g `shouldBe` sort
+        [ "mu", "tau"
+        , "theta_1_raw", "theta_2_raw", "theta_3_raw"
+        , "theta_1", "theta_2", "theta_3" ]
+      obsOf g `shouldBe` sort [("y_1", 1), ("y_2", 1), ("y_3", 1)]
+      -- raw は Normal(0,1) なので親なし、 det theta_j は mu, tau, theta_j_raw が親
+      containsAll (HBM.mgEdges g)
+        [ ("mu", "theta_1"), ("tau", "theta_1"), ("theta_1_raw", "theta_1")
+        , ("theta_1", "y_1") ] `shouldBe` True
+      containsAll (HBM.mgEdges g)
+        [ ("mu", "theta_3"), ("tau", "theta_3"), ("theta_3_raw", "theta_3")
+        , ("theta_3", "y_3") ] `shouldBe` True
+      -- raw は親なし
+      [ p | (p, c) <- HBM.mgEdges g, c == "theta_1_raw" ] `shouldBe` []
+    --
+    -- 代表 4: Random slope (per-group α_j, β_j、 J=2)
+    --
+    it "代表 4: Random slope で μ_α, τ_α, μ_β, τ_β → α_j, β_j → y_j" $ do
+      let m :: HBM.ModelP ()
+          m = do
+            mua <- HBM.sample "mu_a"  (HBM.Normal 0 5)
+            taa <- HBM.sample "tau_a" (HBM.HalfNormal 1)
+            mub <- HBM.sample "mu_b"  (HBM.Normal 0 5)
+            tab <- HBM.sample "tau_b" (HBM.HalfNormal 1)
+            a1 <- HBM.sample "alpha_1" (HBM.Normal mua taa)
+            a2 <- HBM.sample "alpha_2" (HBM.Normal mua taa)
+            b1 <- HBM.sample "beta_1"  (HBM.Normal mub tab)
+            b2 <- HBM.sample "beta_2"  (HBM.Normal mub tab)
+            HBM.observe "y_1" (HBM.Normal (a1 + b1 * 1.0) 1) [1.0]
+            HBM.observe "y_2" (HBM.Normal (a2 + b2 * 1.0) 1) [2.0]
+          g = HBM.buildModelGraph m
+      latentsOf g `shouldBe` sort
+        [ "mu_a", "tau_a", "mu_b", "tau_b"
+        , "alpha_1", "alpha_2", "beta_1", "beta_2" ]
+      obsOf g `shouldBe` sort [("y_1", 1), ("y_2", 1)]
+      containsAll (HBM.mgEdges g)
+        [ ("mu_a", "alpha_1"), ("tau_a", "alpha_1")
+        , ("mu_b", "beta_1"),  ("tau_b", "beta_1")
+        , ("alpha_1", "y_1"),  ("beta_1", "y_1")
+        , ("alpha_2", "y_2"),  ("beta_2", "y_2") ] `shouldBe` True
+    --
+    -- 代表 5: Multi-level (3-level: μ → δ_d → θ_{d,s} → y_{d,s})、 D=2, S=2
+    --
+    it "代表 5: 3 階層 (μ → δ_d → θ_{d,s} → y_{d,s})" $ do
+      let m :: HBM.ModelP ()
+          m = do
+            mu  <- HBM.sample "mu"     (HBM.Normal 0 5)
+            tDd <- HBM.sample "tau_d"  (HBM.HalfNormal 1)
+            tSs <- HBM.sample "tau_s"  (HBM.HalfNormal 1)
+            d1  <- HBM.sample "delta_1" (HBM.Normal mu tDd)
+            d2  <- HBM.sample "delta_2" (HBM.Normal mu tDd)
+            t11 <- HBM.sample "theta_1_1" (HBM.Normal d1 tSs)
+            t12 <- HBM.sample "theta_1_2" (HBM.Normal d1 tSs)
+            t21 <- HBM.sample "theta_2_1" (HBM.Normal d2 tSs)
+            t22 <- HBM.sample "theta_2_2" (HBM.Normal d2 tSs)
+            HBM.observe "y_1_1" (HBM.Normal t11 1) [1.0]
+            HBM.observe "y_1_2" (HBM.Normal t12 1) [1.0]
+            HBM.observe "y_2_1" (HBM.Normal t21 1) [1.0]
+            HBM.observe "y_2_2" (HBM.Normal t22 1) [1.0]
+          g = HBM.buildModelGraph m
+      latentsOf g `shouldBe` sort
+        [ "mu", "tau_d", "tau_s"
+        , "delta_1", "delta_2"
+        , "theta_1_1", "theta_1_2", "theta_2_1", "theta_2_2" ]
+      length (obsOf g) `shouldBe` 4
+      containsAll (HBM.mgEdges g)
+        [ ("mu", "delta_1"),    ("tau_d", "delta_1")
+        , ("delta_1", "theta_1_1"), ("tau_s", "theta_1_1")
+        , ("theta_1_1", "y_1_1")
+        , ("mu", "delta_2"),    ("tau_d", "delta_2")
+        , ("delta_2", "theta_2_2"), ("tau_s", "theta_2_2")
+        , ("theta_2_2", "y_2_2") ] `shouldBe` True
+    --
+    -- 代表 6: Crossed (subject × time、 S=2, T=2)
+    --
+    it "代表 6: Crossed (α_s + γ_t → y_{s,t})" $ do
+      let m :: HBM.ModelP ()
+          m = do
+            mua <- HBM.sample "mu_a"  (HBM.Normal 0 5)
+            taa <- HBM.sample "tau_a" (HBM.HalfNormal 1)
+            tag <- HBM.sample "tau_g" (HBM.HalfNormal 1)
+            a1 <- HBM.sample "alpha_1" (HBM.Normal mua taa)
+            a2 <- HBM.sample "alpha_2" (HBM.Normal mua taa)
+            g1 <- HBM.sample "gamma_1" (HBM.Normal 0 tag)
+            g2 <- HBM.sample "gamma_2" (HBM.Normal 0 tag)
+            HBM.observe "y_1_1" (HBM.Normal (a1 + g1) 1) [1.0]
+            HBM.observe "y_1_2" (HBM.Normal (a1 + g2) 1) [1.0]
+            HBM.observe "y_2_1" (HBM.Normal (a2 + g1) 1) [1.0]
+            HBM.observe "y_2_2" (HBM.Normal (a2 + g2) 1) [1.0]
+          g = HBM.buildModelGraph m
+      latentsOf g `shouldBe` sort
+        [ "mu_a", "tau_a", "tau_g"
+        , "alpha_1", "alpha_2", "gamma_1", "gamma_2" ]
+      length (obsOf g) `shouldBe` 4
+      containsAll (HBM.mgEdges g)
+        [ ("mu_a", "alpha_1"), ("tau_a", "alpha_1")
+        , ("tau_g", "gamma_1")
+        , ("alpha_1", "y_1_1"), ("gamma_1", "y_1_1")
+        , ("alpha_2", "y_2_2"), ("gamma_2", "y_2_2") ] `shouldBe` True
+    --
+    -- 代表 7: GLMM helper (glmmRandomIntercept GlmmGaussian、 nG=3, n=6)
+    --
+    it "代表 7: glmmRandomIntercept Gaussian で β_0/τ_u/σ/u_j → y (単一ブロック)" $ do
+      let xRows = replicate 6 [1.0]
+          gids  = [0, 0, 1, 1, 2, 2]
+          ys    = [1, 1.1, 4.9, 5.0, 8.9, 9.0]
+          m :: HBM.ModelP ()
+          m = HBM.glmmRandomIntercept HBM.GlmmGaussian xRows gids ys
+          g = HBM.buildModelGraph m
+      latentsOf g `shouldBe` sort
+        ["beta_0", "tau_u", "sigma", "u_0", "u_1", "u_2"]
+      -- Phase 54.4a: 観測は単一の observeLMR ブロック "y" (PyMC/Stan 同様の
+      -- ベクトル化観測ノード。 旧実装は per-obs y_0..y_5 を 6 個展開)。
+      obsOf g `shouldBe` [("y", 6)]
+      -- τ_u → u_j が伝わる (centered パラメタ化)
+      containsAll (HBM.mgEdges g)
+        [ ("tau_u", "u_0"), ("tau_u", "u_1"), ("tau_u", "u_2") ]
+        `shouldBe` True
+      -- 単一観測ブロック "y" の親 = β + 全群効果 u + σ
+      let parentsOf nm = sort [ p | (p, c) <- HBM.mgEdges g, c == nm ]
+      parentsOf "y" `shouldBe`
+        sort ["beta_0", "sigma", "u_0", "u_1", "u_2"]
+    --
+    -- 代表 8: nonCenteredNormal 単体の derived 関係 (raw → det)
+    --
+    it "代表 8: nonCenteredNormal で raw (親なし) + name (deterministic)" $ do
+      let m :: HBM.ModelP ()
+          m = do
+            mu  <- HBM.sample "mu"  (HBM.Normal 0 10)
+            tau <- HBM.sample "tau" (HBM.HalfNormal 1)
+            th  <- HBM.nonCenteredNormal "theta" mu tau
+            HBM.observe "y" (HBM.Normal th 1) [1, 2, 3]
+          g = HBM.buildModelGraph m
+      latDetOf g `shouldBe` sort ["mu", "tau", "theta_raw", "theta"]
+      obsOf g     `shouldBe` [("y", 3)]
+      -- theta_raw は Normal(0,1) なので親なし
+      [ p | (p, c) <- HBM.mgEdges g, c == "theta_raw" ] `shouldBe` []
+      -- theta (det) の親 = {mu, tau, theta_raw}
+      sort [ p | (p, c) <- HBM.mgEdges g, c == "theta" ]
+        `shouldBe` sort ["mu", "tau", "theta_raw"]
+      -- theta → y
+      [ p | (p, c) <- HBM.mgEdges g, c == "y" ] `shouldBe` ["theta"]
+    --
+    -- 代表 9: dirichlet stick-breaking (K=3、 β_k latent + π_k deterministic)
+    --
+    it "代表 9: dirichlet K=3 で β_b<i> (2 個) + π_<i> (3 個) det" $ do
+      let m :: HBM.ModelP ()
+          m = do
+            pis <- HBM.dirichlet "p" [1, 1, 1]
+            HBM.observe "y" (HBM.Categorical pis) [0, 1, 2]
+          g = HBM.buildModelGraph m
+      -- K=3 → β は p_b0, p_b1 (2 個)、 π は p_0, p_1, p_2 (3 個 deterministic)
+      latDetOf g `shouldBe` sort ["p_b0", "p_b1", "p_0", "p_1", "p_2"]
+      obsOf g     `shouldBe` [("y", 3)]
+      -- π_0 = β_0、 π_1 = β_1 (1 - β_0)、 π_2 = (1 - β_0)(1 - β_1)
+      sort [ p | (p, c) <- HBM.mgEdges g, c == "p_0" ]
+        `shouldBe` ["p_b0"]
+      sort [ p | (p, c) <- HBM.mgEdges g, c == "p_1" ]
+        `shouldBe` sort ["p_b0", "p_b1"]
+      sort [ p | (p, c) <- HBM.mgEdges g, c == "p_2" ]
+        `shouldBe` sort ["p_b0", "p_b1"]
+      -- y の親 = π_0, π_1, π_2 (Categorical の引数すべて)
+      sort [ p | (p, c) <- HBM.mgEdges g, c == "y" ]
+        `shouldBe` sort ["p_0", "p_1", "p_2"]
+
+  describe "Hanalyze.Model.HBM.buildModelGraph (Phase 38: 複雑 9 例)" $ do
+    let latentsOf g  = sort [ HBM.nodeName n
+                            | n <- HBM.mgNodes g
+                            , HBM.nodeKind n == HBM.LatentN ]
+        -- LatentN + DeterministicN。 Phase 52.A15 で deterministic は
+        -- DeterministicN に分類されたので、 「latent + 派生量が全て graph に
+        -- 出る」 を見るテストはこちらを使う (latentsOf は sampler latent のみ)。
+        latDetOf g   = sort [ HBM.nodeName n
+                            | n <- HBM.mgNodes g
+                            , HBM.nodeKind n `elem` [HBM.LatentN, HBM.DeterministicN] ]
+        obsOf g      = sort [ (HBM.nodeName n, k)
+                            | n <- HBM.mgNodes g
+                            , HBM.ObservedN k <- [HBM.nodeKind n] ]
+        parentsOf g nm = sort [ p | (p, c) <- HBM.mgEdges g, c == nm ]
+    --
+    -- 複雑 1: ZIN-NegBin GLMM (ψ, β_0, τ_u, α 群効果 + 過分散ゼロ過剰)
+    --
+    it "複雑 1: ZIN-NegBin GLMM で ψ, β_0, α, u_j → y_i" $ do
+      let m :: HBM.ModelP ()
+          m = do
+            b0  <- HBM.sample "beta_0" (HBM.Normal 0 5)
+            tau <- HBM.sample "tau_u"  (HBM.HalfNormal 5)
+            psi <- HBM.sample "psi"    (HBM.Beta 1 1)
+            al  <- HBM.sample "alpha"  (HBM.Gamma 2 1)
+            u0  <- HBM.sample "u_0"    (HBM.Normal 0 tau)
+            u1  <- HBM.sample "u_1"    (HBM.Normal 0 tau)
+            HBM.observe "y_0"
+              (HBM.ZeroInflatedNegativeBinomial psi (exp (b0 + u0)) al)
+              [3, 0]
+            HBM.observe "y_1"
+              (HBM.ZeroInflatedNegativeBinomial psi (exp (b0 + u1)) al)
+              [0, 5]
+          g = HBM.buildModelGraph m
+      latentsOf g `shouldBe` sort
+        ["beta_0", "tau_u", "psi", "alpha", "u_0", "u_1"]
+      obsOf g `shouldBe` sort [("y_0", 2), ("y_1", 2)]
+      parentsOf g "u_0" `shouldBe` ["tau_u"]
+      parentsOf g "u_1" `shouldBe` ["tau_u"]
+      parentsOf g "y_0" `shouldBe` sort ["alpha", "beta_0", "psi", "u_0"]
+      parentsOf g "y_1" `shouldBe` sort ["alpha", "beta_0", "psi", "u_1"]
+    --
+    -- 複雑 2: 階層 OrderedLogistic (cuts 共通、 η は群別)
+    --
+    it "複雑 2: 階層 OrderedLogistic で μ/τ → η_j、 cuts は y 親に共有" $ do
+      let m :: HBM.ModelP ()
+          m = do
+            mu  <- HBM.sample "mu"  (HBM.Normal 0 1)
+            tau <- HBM.sample "tau" (HBM.HalfNormal 1)
+            c1  <- HBM.sample "c_1" (HBM.Normal (-1) 1)
+            c2  <- HBM.sample "c_2" (HBM.Normal 1 1)
+            e1  <- HBM.sample "eta_1" (HBM.Normal mu tau)
+            e2  <- HBM.sample "eta_2" (HBM.Normal mu tau)
+            HBM.observe "y_1" (HBM.OrderedLogistic e1 [c1, c2]) [0, 1, 2]
+            HBM.observe "y_2" (HBM.OrderedLogistic e2 [c1, c2]) [0, 1, 2]
+          g = HBM.buildModelGraph m
+      latentsOf g `shouldBe` sort
+        ["mu", "tau", "c_1", "c_2", "eta_1", "eta_2"]
+      parentsOf g "eta_1" `shouldBe` sort ["mu", "tau"]
+      parentsOf g "y_1"   `shouldBe` sort ["c_1", "c_2", "eta_1"]
+      parentsOf g "y_2"   `shouldBe` sort ["c_1", "c_2", "eta_2"]
+    --
+    -- 複雑 3: Hierarchical mixture (mu_1, mu_2 が共通 hyper から)
+    --
+    it "複雑 3: Hierarchical mixture で μ_g/τ_g → μ_k → y" $ do
+      let m :: HBM.ModelP ()
+          m = do
+            w   <- HBM.sample "w"     (HBM.Beta 1 1)
+            mug <- HBM.sample "mu_g"  (HBM.Normal 0 5)
+            tag <- HBM.sample "tau_g" (HBM.HalfNormal 1)
+            m1  <- HBM.sample "mu_1"  (HBM.Normal mug tag)
+            m2  <- HBM.sample "mu_2"  (HBM.Normal mug tag)
+            HBM.observe "y"
+              (HBM.Mixture [w, 1 - w] [HBM.Normal m1 1, HBM.Normal m2 1])
+              [0, 1, 2, 3]
+          g = HBM.buildModelGraph m
+      latentsOf g `shouldBe` sort ["w", "mu_g", "tau_g", "mu_1", "mu_2"]
+      parentsOf g "mu_1" `shouldBe` sort ["mu_g", "tau_g"]
+      parentsOf g "mu_2" `shouldBe` sort ["mu_g", "tau_g"]
+      parentsOf g "y"    `shouldBe` sort ["mu_1", "mu_2", "w"]
+    --
+    -- 複雑 4: Gaussian + potential 制約 (mu_1 ≈ mu_2 ペナルティ)
+    --   potential は LatentN "Potential" として親集合付きで出る
+    --
+    it "複雑 4: Gaussian + potential 制約で constr ノードが mu_1, mu_2 親" $ do
+      let m :: HBM.ModelP ()
+          m = do
+            m1 <- HBM.sample "mu_1" (HBM.Normal 0 5)
+            m2 <- HBM.sample "mu_2" (HBM.Normal 0 5)
+            HBM.potential "constr" (negate ((m1 - m2) ** 2))
+            HBM.observe "y" (HBM.Normal m1 1) [1, 2, 3]
+          g = HBM.buildModelGraph m
+      latentsOf g `shouldBe` sort ["mu_1", "mu_2", "constr"]
+      obsOf g     `shouldBe` [("y", 3)]
+      parentsOf g "constr" `shouldBe` sort ["mu_1", "mu_2"]
+      parentsOf g "y"      `shouldBe` ["mu_1"]
+    --
+    -- 複雑 5: Multi-output (mu 共有、 sigma 個別)
+    --
+    it "複雑 5: Multi-output (mu 共有, sigma_k 個別) で 3 obs、 mu 共有親" $ do
+      let m :: HBM.ModelP ()
+          m = do
+            mu <- HBM.sample "mu"      (HBM.Normal 0 5)
+            s1 <- HBM.sample "sigma_1" (HBM.HalfNormal 1)
+            s2 <- HBM.sample "sigma_2" (HBM.HalfNormal 1)
+            s3 <- HBM.sample "sigma_3" (HBM.HalfNormal 1)
+            HBM.observe "y_1" (HBM.Normal mu s1) [0, 1, 2]
+            HBM.observe "y_2" (HBM.Normal mu s2) [3, 4, 5]
+            HBM.observe "y_3" (HBM.Normal mu s3) [6, 7, 8]
+          g = HBM.buildModelGraph m
+      latentsOf g `shouldBe` sort ["mu", "sigma_1", "sigma_2", "sigma_3"]
+      parentsOf g "y_1" `shouldBe` sort ["mu", "sigma_1"]
+      parentsOf g "y_2" `shouldBe` sort ["mu", "sigma_2"]
+      parentsOf g "y_3" `shouldBe` sort ["mu", "sigma_3"]
+    --
+    -- 複雑 6: Random slope + nonCentered (形式 C + slope の組合せ)
+    --
+    it "複雑 6: Random slope + nonCenteredNormal で raw + det 2 軸" $ do
+      let m :: HBM.ModelP ()
+          m = do
+            mua <- HBM.sample "mu_a"  (HBM.Normal 0 5)
+            taa <- HBM.sample "tau_a" (HBM.HalfNormal 1)
+            mub <- HBM.sample "mu_b"  (HBM.Normal 0 5)
+            tab <- HBM.sample "tau_b" (HBM.HalfNormal 1)
+            a1 <- HBM.nonCenteredNormal "alpha_1" mua taa
+            a2 <- HBM.nonCenteredNormal "alpha_2" mua taa
+            b1 <- HBM.nonCenteredNormal "beta_1"  mub tab
+            b2 <- HBM.nonCenteredNormal "beta_2"  mub tab
+            HBM.observe "y_1" (HBM.Normal (a1 + b1) 1) [1.0]
+            HBM.observe "y_2" (HBM.Normal (a2 + b2) 1) [2.0]
+          g = HBM.buildModelGraph m
+      latDetOf g `shouldBe` sort
+        [ "mu_a", "tau_a", "mu_b", "tau_b"
+        , "alpha_1_raw", "alpha_2_raw", "beta_1_raw", "beta_2_raw"
+        , "alpha_1", "alpha_2", "beta_1", "beta_2" ]
+      parentsOf g "alpha_1_raw" `shouldBe` []
+      parentsOf g "alpha_1" `shouldBe` sort ["mu_a", "tau_a", "alpha_1_raw"]
+      parentsOf g "beta_2"  `shouldBe` sort ["mu_b", "tau_b", "beta_2_raw"]
+      parentsOf g "y_1"     `shouldBe` sort ["alpha_1", "beta_1"]
+      parentsOf g "y_2"     `shouldBe` sort ["alpha_2", "beta_2"]
+    --
+    -- 複雑 7: Dirichlet mixture (K=3 有限近似)
+    --
+    it "複雑 7: Dirichlet mixture K=3 で β/π/μ_k → y" $ do
+      let m :: HBM.ModelP ()
+          m = do
+            pis <- HBM.dirichlet "p" [1, 1, 1]
+            m1  <- HBM.sample "mu_1" (HBM.Normal 0 5)
+            m2  <- HBM.sample "mu_2" (HBM.Normal 0 5)
+            m3  <- HBM.sample "mu_3" (HBM.Normal 0 5)
+            HBM.observe "y"
+              (HBM.Mixture pis
+                [HBM.Normal m1 1, HBM.Normal m2 1, HBM.Normal m3 1])
+              [0, 1, 2]
+          g = HBM.buildModelGraph m
+      latDetOf g `shouldBe` sort
+        ["p_b0", "p_b1", "p_0", "p_1", "p_2", "mu_1", "mu_2", "mu_3"]
+      parentsOf g "p_0" `shouldBe` ["p_b0"]
+      parentsOf g "y"   `shouldBe` sort
+        ["mu_1", "mu_2", "mu_3", "p_0", "p_1", "p_2"]
+    --
+    -- 複雑 8: AR(1) latent 時系列 (ar1Latent helper、 nT=3)
+    --   plate-style 期待: x_raw_t → x_t (det)、 x_{t-1} → x_t (t≥1)、 x_2 → y
+    --
+    it "複雑 8: ar1Latent nT=3 で x_raw_t + det x_t chain + x_2 → y" $ do
+      let m :: HBM.ModelP ()
+          m = do
+            xs <- HBM.ar1Latent "x" 3 0.8 0.3
+            HBM.observe "y" (HBM.Normal (xs !! 2) 1) [1.0]
+          g = HBM.buildModelGraph m
+      latDetOf g `shouldBe` sort
+        ["x_raw0", "x_raw1", "x_raw2", "x_0", "x_1", "x_2"]
+      -- raw は親なし
+      parentsOf g "x_raw0" `shouldBe` []
+      parentsOf g "x_raw1" `shouldBe` []
+      parentsOf g "x_raw2" `shouldBe` []
+      -- x_0 の親は x_raw0 のみ (定数 phi/sigma は trackConst で deps 空)
+      parentsOf g "x_0" `shouldBe` ["x_raw0"]
+      -- x_1 の親は x_0 と x_raw1
+      parentsOf g "x_1" `shouldBe` sort ["x_0", "x_raw1"]
+      -- x_2 の親は x_1 と x_raw2
+      parentsOf g "x_2" `shouldBe` sort ["x_1", "x_raw2"]
+      -- y の親は x_2 のみ
+      parentsOf g "y" `shouldBe` ["x_2"]
+    --
+    -- 複雑 9: sampleNames と buildModelGraph の latent 名一致確認
+    --   (Phase 37-A5 fullRankAdvi 等の推論側で出る latent 名と DAG が整合)
+    --
+    it "複雑 9: sampleNames m と graph latent (det/potential 除く) が一致" $ do
+      let m :: HBM.ModelP ()
+          m = do
+            mu  <- HBM.sample "mu"  (HBM.Normal 0 5)
+            tau <- HBM.sample "tau" (HBM.HalfNormal 1)
+            th  <- HBM.nonCenteredNormal "theta" mu tau
+            HBM.observe "y" (HBM.Normal th 1) [1, 2, 3]
+          g = HBM.buildModelGraph m
+          -- buildModelGraph の latent から Deterministic を除いたもの
+          pureLatents = sort
+            [ HBM.nodeName n
+            | n <- HBM.mgNodes g
+            , HBM.nodeKind n == HBM.LatentN
+            , HBM.nodeDist n /= "Deterministic"
+            , HBM.nodeDist n /= "Potential" ]
+          sampled = sort (HBM.sampleNames m)
+      sampled `shouldBe` pureLatents
+      -- 具体的に: mu, tau, theta_raw が sample されている (theta は det)
+      sampled `shouldBe` sort ["mu", "tau", "theta_raw"]
diff --git a/test/Hanalyze/Model/HBMSummarySpec.hs b/test/Hanalyze/Model/HBMSummarySpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Model/HBMSummarySpec.hs
@@ -0,0 +1,126 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RankNTypes #-}
+-- | Phase 103: HBM 事後要約の一発 API (hbmSummary / hbmSummaryNames) の spec。
+--
+-- fixture は A1 probe (experiments/phase103-hbm-summary-df-helpers/) と同型の
+-- 決定的 fit (hbmModelPure = 既定 seed 42)。固定する配線:
+--   * hbmSummary = posteriorSummary (latent+deterministic 名) (augment 済 chain)
+--     の手繋ぎ直呼びと完全一致 (一発 API が既存経路の糖衣であること)
+--   * deterministic 派生量 (mu) が既定で要約に含まれる (A1 確定の設計)
+--   * deterministicNames は宣言順・runDeterministics の key 集合と一致
+module Hanalyze.Model.HBMSummarySpec (spec) where
+
+import qualified Data.Map.Strict as Map
+import Data.Maybe (catMaybes)
+import qualified DataFrame.Internal.DataFrame  as DX
+import Test.Hspec
+
+import qualified Hanalyze.Data.Wrangle as W
+import Hanalyze.DataIO.Preprocess (readMaybeDoubleColumn)
+import Hanalyze.MCMC.Core (chainVals)
+import qualified Hanalyze.Model.HBM as HBM
+import Hanalyze.Model.Wrappers (HBMModel (..), HBMConfig (..), defaultHBM,
+                                       hbmModelPure, hbmSummary, hbmSummaryNames,
+                                       hbmSummaryDf, hbmDrawsDf)
+import Hanalyze.Stat.Summary (SummaryRow (..), posteriorSummary)
+
+-- | A1 probe と同型の最小モデル (latent 2 + deterministic 1)。
+model :: HBM.ModelP ()
+model = do
+  xs <- HBM.dataNamed    "x" [1, 2, 3]
+  ys <- HBM.dataNamedObs "y" [2, 4, 6]
+  b  <- HBM.sample "b" (HBM.Normal 0 1)
+  s  <- HBM.sample "s" (HBM.HalfNormal 1)
+  mu <- HBM.deterministic "mu" (b * head xs)
+  HBM.observe "y" (HBM.Normal mu s) ys
+
+-- | deterministic 無しモデル (augment 迂回パスの確認用)。
+modelNoDet :: HBM.ModelP ()
+modelNoDet = do
+  ys <- HBM.dataNamedObs "y" [2, 4, 6]
+  b  <- HBM.sample "b" (HBM.Normal 0 1)
+  HBM.observe "y" (HBM.Normal b 1) ys
+
+-- | 決定的 fit (seed 42 既定・test 速度優先の小 iteration)。
+fitted :: HBMModel
+fitted = hbmModelPure (defaultHBM { hbmChains = 2, hbmWarmup = 100, hbmSamples = 100 })
+                      model []
+
+rowKey :: SummaryRow -> (String, Double, Double, Double, Double, Double, Maybe Double)
+rowKey r = (show (srName r), srMean r, srSD r, srHdiLo r, srHdiHi r, srEssV r, srRhat r)
+
+spec :: Spec
+spec = do
+  describe "hbmSummaryNames (Phase 103)" $ do
+    it "latent 宣言順 → deterministic 宣言順の連結" $
+      hbmSummaryNames fitted `shouldBe` ["b", "s", "mu"]
+
+    it "deterministicNames = runDeterministics の key 集合 (順序のみ宣言順)" $ do
+      let spec' :: HBM.ModelP ()
+          spec' = hbmModelSpec fitted
+          ks    = Map.keys (HBM.runDeterministics spec' (Map.fromList [("b", 1), ("s", 1)]))
+      HBM.deterministicNames spec' `shouldMatchList` ks
+
+  describe "hbmSummary (Phase 103)" $ do
+    it "posteriorSummary 直呼び (augment 手繋ぎ) と完全一致" $ do
+      let spec' :: HBM.ModelP ()
+          spec'  = hbmModelSpec fitted
+          names  = HBM.sampleNames spec' ++ HBM.deterministicNames spec'
+          chains = map (HBM.augmentChainWithDeterministic spec') (hbmChainsR fitted)
+          direct = posteriorSummary names chains
+      map rowKey (hbmSummary fitted) `shouldBe` map rowKey direct
+
+    it "deterministic 派生量 mu が既定で要約に含まれ有限値を持つ" $ do
+      let rows = hbmSummary fitted
+          mus  = [r | r <- rows, srName r == "mu"]
+      length mus `shouldBe` 1
+      all (\r -> not (isNaN (srMean r))) mus `shouldBe` True
+
+    it "multi-chain fit では r_hat が Just" $
+      all (\r -> srRhat r /= Nothing) (hbmSummary fitted) `shouldBe` True
+
+    it "deterministic 無しモデルでも成立 (augment 迂回パス)" $ do
+      let m    = hbmModelPure (defaultHBM { hbmChains = 1, hbmWarmup = 50, hbmSamples = 50 })
+                              modelNoDet []
+          rows = hbmSummary m
+      map srName rows `shouldBe` ["b"]
+      -- 単 chain は r_hat 無し (posteriorSummary の既存挙動に一致)
+      map srRhat rows `shouldBe` [Nothing]
+
+  describe "hbmSummaryDf (Phase 103)" $ do
+    it "multi-chain: 列 = param/mean/sd/hdi_lo/hdi_hi/ess_bulk/r_hat・行 = 全パラメタ" $ do
+      let df = hbmSummaryDf fitted
+      DX.columnNames df `shouldBe`
+        ["param", "mean", "sd", "hdi_lo", "hdi_hi", "ess_bulk", "r_hat"]
+      let Just means = readMaybeDoubleColumn "mean" df
+      catMaybes means `shouldBe` map srMean (hbmSummary fitted)
+
+    it "単 chain: r_hat 列が付かない (printPosteriorSummary の列規約と同じ)" $ do
+      let m  = hbmModelPure (defaultHBM { hbmChains = 1, hbmWarmup = 50, hbmSamples = 50 })
+                            modelNoDet []
+          df = hbmSummaryDf m
+      DX.columnNames df `shouldBe`
+        ["param", "mean", "sd", "hdi_lo", "hdi_hi", "ess_bulk"]
+
+  describe "hbmDrawsDf (Phase 103)" $ do
+    it "1 パラメタ = 1 列 (deterministic 込み)・全 chain 連結の draw 数" $ do
+      let df = hbmDrawsDf fitted
+      DX.columnNames df `shouldBe` ["b", "s", "mu"]
+      let Just mus = readMaybeDoubleColumn "mu" df
+      length (catMaybes mus) `shouldBe` 200   -- 2 chain x 100 draw
+
+    it "draw 値 = augment 済 chain の chainVals 連結 (chain 順)" $ do
+      let spec' :: HBM.ModelP ()
+          spec'  = hbmModelSpec fitted
+          direct = concatMap (chainVals "mu")
+                     (map (HBM.augmentChainWithDeterministic spec') (hbmChainsR fitted))
+          Just mus = readMaybeDoubleColumn "mu" (hbmDrawsDf fitted)
+      catMaybes mus `shouldBe` direct
+
+    it "Wrangle summarise (meanOf) がそのまま効く" $ do
+      let df = hbmDrawsDf fitted
+          out = W.summarise ["m" W.=: W.meanOf "mu"] df
+          Just [Just m] = readMaybeDoubleColumn "m" out
+          Just mus = readMaybeDoubleColumn "mu" df
+          xs = catMaybes mus
+      abs (m - sum xs / fromIntegral (length xs)) `shouldSatisfy` (< 1e-9)
diff --git a/test/Hanalyze/Model/HierarchicalClusterSpec.hs b/test/Hanalyze/Model/HierarchicalClusterSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Model/HierarchicalClusterSpec.hs
@@ -0,0 +1,51 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Model.HierarchicalClusterSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Data.Vector as V
+import qualified Numeric.LinearAlgebra as LA
+import qualified Hanalyze.Model.HierarchicalCluster as HC
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Model.HierarchicalCluster (Phase 12)" $ do
+    let blob = LA.fromLists
+                 [ [0,0], [0.1,0], [0,0.1]    -- cluster A
+                 , [5,5], [5.1,5], [5,5.1]    -- cluster B
+                 ]
+    it "fitHierarchical Ward: 6 サンプルで 5 マージ" $ do
+      let fit = HC.fitHierarchical HC.Ward blob
+      length (HC.hcMerges fit) `shouldBe` 5
+      length (HC.hcHeights fit) `shouldBe` 5
+    it "cutTree K=2 で 2 ブロブが分離" $ do
+      let fit  = HC.fitHierarchical HC.Ward blob
+          ids  = HC.cutTree fit 2
+          half = V.length (V.filter (== V.head ids) ids)
+      half `shouldBe` 3
+    it "Single linkage でも 5 マージ系列" $ do
+      let fit = HC.fitHierarchical HC.Single blob
+      length (HC.hcMerges fit) `shouldBe` 5
+    it "Complete linkage 高さは Single 以上" $ do
+      let fS = HC.fitHierarchical HC.Single blob
+          fC = HC.fitHierarchical HC.Complete blob
+      last (HC.hcHeights fC) `shouldSatisfy` (>= last (HC.hcHeights fS) - 1e-9)
diff --git a/test/Hanalyze/Model/KNNSpec.hs b/test/Hanalyze/Model/KNNSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Model/KNNSpec.hs
@@ -0,0 +1,56 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Model.KNNSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Numeric.LinearAlgebra as LA
+import qualified Data.Vector.Unboxed        as VU
+import qualified Data.Map.Strict as M
+import qualified Hanalyze.Model.KNN            as KNN
+import qualified Data.Map.Strict    as M
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Model.KNN (Phase 34-A4)" $ do
+    let xTrain = LA.fromLists [[fromIntegral i] | i <- [0 :: Int .. 9]]
+        yTrainR = VU.fromList [fromIntegral i * 2 | i <- [0 :: Int .. 9]]
+        yTrainC = VU.fromList ([0,0,0,0,0,1,1,1,1,1] :: [Int])
+        xTest   = LA.fromLists [[2.0], [7.5]]
+    it "fitKNNR + predictKNNR: k=3 で局所平均" $ do
+      let knn = KNN.fitKNNR 3 xTrain yTrainR
+          ys  = KNN.predictKNNR knn xTest
+      VU.length ys `shouldBe` 2
+      -- x=2 → 近傍 {1,2,3} の y= {2,4,6} → 平均 4
+      abs (ys VU.! 0 - 4.0) `shouldSatisfy` (< 1e-9)
+    it "fitKNNC + predictKNNC: 分類で多数決" $ do
+      let knn = KNN.fitKNNC 3 xTrain yTrainC
+          ys  = KNN.predictKNNC knn xTest
+      ys VU.! 0 `shouldBe` 0
+      ys VU.! 1 `shouldBe` 1
+    it "fitKNNC: knnCClasses は sorted unique" $ do
+      let knn = KNN.fitKNNC 3 xTrain yTrainC
+      KNN.knnCClasses knn `shouldBe` [0, 1]
+    it "predictKNNCProbs: 確率は和 1" $ do
+      let knn = KNN.fitKNNC 3 xTrain yTrainC
+          ps  = KNN.predictKNNCProbs knn xTest
+      and [ abs (sum (M.elems m) - 1.0) < 1e-10 | m <- ps ]
+        `shouldBe` True
diff --git a/test/Hanalyze/Model/KernelSpec.hs b/test/Hanalyze/Model/KernelSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Model/KernelSpec.hs
@@ -0,0 +1,138 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Model.KernelSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Numeric.LinearAlgebra as LA
+import qualified Hanalyze.Model.KernelRegression      as Kn
+import qualified Hanalyze.Model.GP          as GP
+import qualified Hanalyze.Model.GPRobust    as GPR
+import qualified Hanalyze.Model.GP        as GP
+import qualified Hanalyze.Model.GPRobust  as GPR
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Model.KernelRegression multi-input (MV)" $ do
+    -- 2D regression target: y = sin(x1) + 0.5 cos(x2)
+    let n     = 60
+        h     = 0.5
+        lam   = 1e-4
+        f x1 x2 = sin x1 + 0.5 * cos x2
+        xs    = LA.fromLists
+                  [ [ fromIntegral i / 10
+                    , fromIntegral (n - i) / 10
+                    ]
+                  | i <- [0 .. n - 1] ]
+        ys    = LA.asColumn $ LA.fromList
+                  [ f (xs `LA.atIndex` (i, 0)) (xs `LA.atIndex` (i, 1))
+                  | i <- [0 .. n - 1] ]
+        fit   = Kn.kernelRidgeMV Kn.Gaussian h lam xs ys
+        yhat  = Kn.fittedKernelRidgeMV fit
+        ssErr = LA.sumElements ((ys - yhat) ** 2)
+        ssTot = let muY = LA.sumElements ys / fromIntegral n
+                in LA.sumElements ((ys - LA.konst muY (n, 1)) ** 2)
+        r2    = 1 - ssErr / ssTot
+
+    it "achieves R² > 0.95 on a 2D smooth target" $
+      r2 > 0.95 `shouldBe` True
+
+    it "predict at training points equals fitted" $ do
+      let p = Kn.predictKernelRidgeMV fit xs
+      LA.norm_Inf (p - yhat) < 1e-9 `shouldBe` True
+
+    it "gramMatrixMV matches kernelFromSqDist by element" $ do
+      let xS  = LA.fromLists [[0.0, 0.0], [1.0, 0.0], [0.5, 0.5]] :: LA.Matrix Double
+          gMV = Kn.gramMatrixMV Kn.Gaussian 1.0 xS
+          rs  = LA.toRows xS
+          ref = LA.fromLists
+                  [ [ let d = rs !! i - rs !! j
+                          s = (d `LA.dot` d) / (1.0 * 1.0)
+                      in Kn.kernelFromSqDist Kn.Gaussian s
+                    | j <- [0 .. 2] ]
+                  | i <- [0 .. 2] ]
+      LA.norm_Inf (gMV - ref) < 1e-12 `shouldBe` True
+
+    it "Hanalyze.Model.GP MV: 1D input matches legacy 1D fitGP within 1e-6" $ do
+      let xL  = [fromIntegral i / 5 | i <- [0 .. 19 :: Int]]
+          yL  = map sin xL
+          tL  = [0.5, 1.5, 2.5]
+          mdl = GP.GPModel GP.RBF (GP.GPParams 1.0 1.0 0.05 1.0 Nothing)
+          legacy = GP.fitGP mdl xL yL tL
+          xMV = LA.fromLists (map (:[]) xL) :: LA.Matrix Double
+          yMV = LA.fromList yL
+          tMV = LA.fromLists (map (:[]) tL) :: LA.Matrix Double
+          mv  = GP.fitGPMV mdl xMV yMV tMV
+          dMu = LA.norm_Inf
+                  (GP.gpmvMean mv - LA.fromList (GP.gpMean legacy))
+          dVr = LA.norm_Inf
+                  (GP.gpmvVar  mv - LA.fromList (GP.gpVar  legacy))
+      (dMu < 1e-6) `shouldBe` True
+      (dVr < 1e-6) `shouldBe` True
+
+    it "Hanalyze.Model.GP MV: 2D RBF reaches R² > 0.95 with optimized HP" $ do
+      let nN  = 50
+          gx  = [(fromIntegral i / 10, fromIntegral (nN - i) / 10)
+                | i <- [0 .. nN - 1 :: Int]]
+          ftn (x1, x2) = sin x1 + 0.5 * cos x2
+          xMV = LA.fromLists [ [a, b] | (a, b) <- gx ] :: LA.Matrix Double
+          yMV = LA.fromList [ ftn p | p <- gx ]
+          p0  = GP.GPParams 1.0 1.0 0.01 1.0 Nothing
+          po  = GP.optimizeGPMV GP.RBF xMV yMV p0
+          mdl = GP.GPModel GP.RBF po
+          res = GP.fitGPMV mdl xMV yMV xMV
+          mu  = GP.gpmvMean res
+          y   = yMV
+          ss  = LA.sumElements ((y - mu) ** 2)
+          mY  = LA.sumElements y / fromIntegral nN
+          st  = LA.sumElements ((y - LA.konst mY nN) ** 2)
+          r2  = 1 - ss / st
+      (r2 > 0.95) `shouldBe` True
+
+    it "Hanalyze.Model.GPRobust MV: 1D input matches legacy fitGPRobust" $ do
+      let xL  = [fromIntegral i / 5 | i <- [0 .. 14 :: Int]]
+          yL  = map sin xL
+          tL  = [0.5, 1.5, 2.5]
+          ker = GP.RBF
+          ps  = GP.GPParams 1.0 1.0 0.05 1.0 Nothing
+          lik = GPR.RGaussian 0.1
+          legFit = GPR.fitGPRobust ker ps lik xL yL
+          legacy = GPR.predictGPRobust legFit tL
+          legM   = LA.fromList (map fst legacy)
+          xMV    = LA.fromLists (map (:[]) xL) :: LA.Matrix Double
+          yMV    = LA.fromList yL
+          tMV    = LA.fromLists (map (:[]) tL) :: LA.Matrix Double
+          mvFit  = GPR.fitGPRobustMV ker ps lik xMV yMV
+          (mvM, _) = GPR.predictGPRobustMV mvFit tMV
+      LA.norm_Inf (mvM - legM) < 1e-6 `shouldBe` True
+
+    it "MV gramMatrix on a single-column input agrees with kernelFromSqDist" $ do
+      let xs1 = LA.fromLists [[fromIntegral i / 5] | i <- [0 .. 19 :: Int]]
+                  :: LA.Matrix Double
+          gMV2 = Kn.gramMatrixMV Kn.Gaussian 0.4 xs1
+          ref  = LA.fromLists
+                   [ [ let xi = xs1 `LA.atIndex` (i, 0)
+                           xj = xs1 `LA.atIndex` (j, 0)
+                           d  = xi - xj
+                       in Kn.kernelFromSqDist Kn.Gaussian (d * d / (0.4 * 0.4))
+                     | j <- [0 .. 19] ]
+                   | i <- [0 .. 19] ]
+      LA.norm_Inf (gMV2 - ref) < 1e-12 `shouldBe` True
diff --git a/test/Hanalyze/Model/LM/DiagnosticsSpec.hs b/test/Hanalyze/Model/LM/DiagnosticsSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Model/LM/DiagnosticsSpec.hs
@@ -0,0 +1,116 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Model.LM.DiagnosticsSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Numeric.LinearAlgebra as LA
+import qualified Hanalyze.Model.LM             as LM
+import qualified Hanalyze.Model.LM.Diagnostics as LMD
+import SpecHelper
+
+spec :: Spec
+spec = do
+  -- Phase 82: 飽和 fit (df = n−p <= 0) で CI/PI 帯が Student-t 例外を出さず、
+  -- 幅ゼロ帯 (lo=hi=ŷ) に潰れること。 profiler が飽和モデルで落ちないための保証。
+  describe "LM CI/PI band: 飽和 (df<=0) ガード (Phase 82)" $ do
+    let xSat = LA.fromColumns [ LA.konst 1.0 2, LA.fromList [1.0, 2.0] ]  -- 2×2 = 2 param
+        ySat = LA.fromList [3.0, 5.0]
+        fitS = LM.fitLMVec xSat ySat                                     -- df = 2 − 2 = 0
+    it "confidenceBandAt: df=0 は例外なし・幅ゼロ (lo=hi)" $ do
+      let b = LM.confidenceBandAt xSat fitS 0.95 xSat
+      LM.lowerBound b `shouldBe` LM.upperBound b
+    it "predictionBandAt: df=0 は例外なし・幅ゼロ (lo=hi)" $ do
+      let b = LM.predictionBandAt xSat fitS 0.95 xSat
+      LM.lowerBound b `shouldBe` LM.upperBound b
+    it "df>0 では通常どおり幅を持つ (回帰・帯が線でない)" $ do
+      let x3 = LA.fromColumns [ LA.konst 1.0 3, LA.fromList [1.0, 2.0, 3.0] ]
+          y3 = LA.fromList [3.0, 5.2, 6.9]
+          b  = LM.confidenceBandAt x3 (LM.fitLMVec x3 y3) 0.95 x3
+      LM.lowerBound b `shouldNotBe` LM.upperBound b
+
+  describe "Hanalyze.Model.LM.Diagnostics (vs statsmodels OLS)" $ do
+    let xRaw = [1.0, 2, 3, 4, 5, 6, 7, 8, 9, 10]
+        yRaw = [3.7, 5.2, 6.8, 8.5, 9.9, 11.3, 13.1, 14.4, 15.8, 17.2]
+        x    = LA.fromColumns
+                 [ LA.konst 1.0 (length xRaw)
+                 , LA.fromList xRaw
+                 ]
+        y    = LA.fromList yRaw
+        fit  = LM.fitLMVec x y
+
+        approx tol a b = abs (a - b) < tol
+        approxV tol expected actual =
+          length expected == LA.size actual &&
+          and (zipWith (approx tol) expected (LA.toList actual))
+
+    it "ciTValue: 95% / df=8 ≈ 2.306" $
+      LMD.ciTValue 0.95 8 `shouldSatisfy` approx 1e-3 2.306
+
+    it "lmStdErrors matches statsmodels (intercept, slope)" $
+      LMD.lmStdErrors x fit `shouldSatisfy`
+        approxV 1e-5 [0.09602188, 0.01547533]
+
+    it "lmCoefStats t / p match statsmodels" $ do
+      let cs = LMD.lmCoefStats x fit
+      length cs `shouldBe` 2
+      LMD.csTValue (head cs)    `shouldSatisfy` approx 1e-3 23.8834
+      LMD.csTValue (cs !! 1)    `shouldSatisfy` approx 1e-3 97.4768
+      LMD.csPValue (head cs)    `shouldSatisfy` approx 1e-9 1.0062e-8
+      LMD.csPValue (cs !! 1)    `shouldSatisfy` approx 1e-13 1.3699e-13
+
+    it "lmFStatistic matches statsmodels (F, p, df1, df2)" $ do
+      let fs = head (LMD.lmFStatistic x fit)
+      LMD.fsValue fs  `shouldSatisfy` approx 1e-1 9501.7193
+      LMD.fsPValue fs `shouldSatisfy` approx 1e-13 1.3699e-13
+      LMD.fsDf1 fs `shouldBe` 1
+      LMD.fsDf2 fs `shouldBe` 8
+
+    it "lmInformationCriteria (R lm() convention, k = p + 1 with σ)" $ do
+      let ic = LMD.lmInformationCriteria fit
+      LMD.icLogLik ic `shouldSatisfy` approx 1e-5 6.547424
+      LMD.icAIC    ic `shouldSatisfy` approx 1e-5 (-7.094848)
+      LMD.icBIC    ic `shouldSatisfy` approx 1e-5 (-6.187093)
+
+    it "hatDiagonal matches statsmodels leverage" $
+      LMD.hatDiagonal x `shouldSatisfy`
+        approxV 1e-6
+          [ 0.34545455, 0.24848485, 0.17575758, 0.12727273, 0.10303030
+          , 0.10303030, 0.12727273, 0.17575758, 0.24848485, 0.34545455 ]
+
+    it "standardizedResiduals match statsmodels (resid_studentized_internal)" $
+      LMD.standardizedResiduals x fit `shouldSatisfy`
+        approxV 1e-5
+          [ -0.89534127, -0.90521501, -0.14722564,  1.31539084,  0.48257654
+          , -0.33234045,  1.88308584,  0.30394970, -0.57197652, -1.56684722 ]
+
+    it "cooksDistance matches statsmodels" $
+      LMD.cooksDistance x fit `shouldSatisfy`
+        approxV 1e-5
+          [ 0.21154283, 0.13546767, 0.00231098, 0.12616429, 0.01337487
+          , 0.00634342, 0.25856339, 0.00984992, 0.05408646, 0.64784992 ]
+
+    it "predictorStdDevs: intercept col=0, x col≈3.0277" $ do
+      let sds = LMD.predictorStdDevs x
+      LA.size sds `shouldBe` 2
+      (LA.toList sds !! 0) `shouldSatisfy` approx 1e-12 0
+      (LA.toList sds !! 1) `shouldSatisfy` approx 1e-6 3.027650
+
+  -- ─────────────────────────────────────────────────────────────────────
diff --git a/test/Hanalyze/Model/LatentClassAnalysisSpec.hs b/test/Hanalyze/Model/LatentClassAnalysisSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Model/LatentClassAnalysisSpec.hs
@@ -0,0 +1,75 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Model.LatentClassAnalysisSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Numeric.LinearAlgebra as LA
+import qualified System.Random.MWC as MWC
+import qualified System.Random.MWC as MWC
+import qualified Hanalyze.Model.LatentClassAnalysis   as LCA
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Model.LatentClassAnalysis (Phase 32-A2 EM)" $ do
+    -- DGP: 2 クラス、 4 二値特徴、 各 n=400
+    --   class 0: 各特徴 ρ_{0,j,1} = 0.9 (= "1" 多)
+    --   class 1: 各特徴 ρ_{1,j,1} = 0.1 (= "0" 多)
+    --   π = [0.5, 0.5]
+    it "fitLCA: 2 クラス分離 DGP で π / ρ を回復 (label switch 許容)" $ do
+      gen <- MWC.create
+      let nL = 400
+          jL = 4
+          drawObs :: Int -> IO [Int]
+          drawObs cls = mapM (\_ -> do
+              u <- MWC.uniformR (0.0, 1.0 :: Double) gen
+              let pOne = if cls == 0 then 0.9 else 0.1
+              pure (if u < pOne then 1 else 0))
+              [0 .. jL - 1]
+      rows <- mapM (\i -> do
+                cls <- if i < nL `div` 2 then pure 0 else pure 1
+                drawObs cls)
+              [0 .. nL - 1]
+      fit <- LCA.fitLCA 2 2 rows 100 1e-4 gen
+      let pis = LA.toList (LCA.lcaPi fit)
+      length pis `shouldBe` 2
+      -- 両クラスとも 0.5 ± 0.1
+      all (\p -> abs (p - 0.5) < 0.1) pis `shouldBe` True
+      -- ρ: 各特徴のクラス間で「1 を取る確率」 が極端 (0.1 / 0.9 付近)
+      -- label switch 対応: クラス 0 / 1 の (1 を取る確率) を取り出し sort
+      let p1OfClass k =
+            map (\rho -> LA.atIndex rho (k, 1)) (LCA.lcaRho fit)
+          c0 = p1OfClass 0
+          c1 = p1OfClass 1
+          (hiSet, loSet) = if sum c0 > sum c1 then (c0, c1) else (c1, c0)
+      all (> 0.7) hiSet `shouldBe` True
+      all (< 0.3) loSet `shouldBe` True
+    it "fitLCA: 単一クラス (K=1) は trivial、 ρ がデータ周辺分布と一致" $ do
+      gen <- MWC.create
+      let rows = replicate 100 [0, 1] ++ replicate 100 [1, 0]
+      fit <- LCA.fitLCA 1 2 rows 50 1e-6 gen
+      LA.toList (LCA.lcaPi fit) `shouldBe` [1.0]
+      -- K=1 で feature 0: P(0)=0.5, P(1)=0.5
+      let rho0_row0 = LA.toList (LA.flatten ((head (LCA.lcaRho fit)) LA.? [0]))
+      rho0_row0 `shouldSatisfy`
+        (\xs -> length xs == 2
+             && abs ((xs !! 0) - 0.5) < 0.01
+             && abs ((xs !! 1) - 0.5) < 0.01)
diff --git a/test/Hanalyze/Model/LiNGAM/BootstrapSpec.hs b/test/Hanalyze/Model/LiNGAM/BootstrapSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Model/LiNGAM/BootstrapSpec.hs
@@ -0,0 +1,62 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Model.LiNGAM.BootstrapSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Numeric.LinearAlgebra as LA
+import qualified Hanalyze.Stat.QuasiRandom  as QR
+import qualified Hanalyze.Model.LiNGAM.Bootstrap      as LNGB
+import qualified Hanalyze.Model.DAG                   as DAG
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Model.LiNGAM.Bootstrap (Phase 36 A2)" $ do
+    let mkDataN n =
+          let halton b k = QR.radicalInverse b k - 0.5
+              e0s = [halton 2 i | i <- [1..n]]
+              e1s = [halton 3 i | i <- [1..n]]
+              e2s = [halton 5 i | i <- [1..n]]
+              x0s = e0s
+              x1s = zipWith (\a b -> 0.8 * a + b) x0s e1s
+              x2s = zipWith3 (\a b c -> 0.4 * a + 0.6 * b + c) x0s x1s e2s
+          in LA.fromColumns [LA.fromList x0s, LA.fromList x1s, LA.fromList x2s]
+    it "fitBootstrapLiNGAM: 真エッジの出現頻度が高 (>= 0.7)、 偽エッジは低" $ do
+      let cfg = LNGB.defaultBootstrapConfig { LNGB.bcNumBootstraps = 30 }
+      res <- LNGB.fitBootstrapLiNGAM cfg (mkDataN 400)
+      -- 真エッジ: x1 ← x0、 x2 ← x0、 x2 ← x1
+      let probMat = LNGB.brEdgeProbability res
+      LA.atIndex probMat (1, 0) `shouldSatisfy` (>= 0.7)
+      LA.atIndex probMat (2, 1) `shouldSatisfy` (>= 0.7)
+      -- 偽エッジ: 逆方向 x0 ← x1 等は低頻度
+      LA.atIndex probMat (0, 1) `shouldSatisfy` (<= 0.3)
+    it "confidenceDAG: 高頻度エッジのみ採用、 acyclic 維持" $ do
+      let cfg = LNGB.defaultBootstrapConfig { LNGB.bcNumBootstraps = 30 }
+      res <- LNGB.fitBootstrapLiNGAM cfg (mkDataN 400)
+      let g = LNGB.confidenceDAG 0.7 0.8 res
+      DAG.isAcyclic g `shouldBe` True
+    it "signConsistency: 真エッジは符号合致率高い" $ do
+      let cfg = LNGB.defaultBootstrapConfig { LNGB.bcNumBootstraps = 30 }
+      res <- LNGB.fitBootstrapLiNGAM cfg (mkDataN 400)
+      let sigMat = LNGB.brSignConsistency res
+      -- 真エッジは符号合致率 1 に近い
+      LA.atIndex sigMat (1, 0) `shouldSatisfy` (>= 0.9)
+      LA.atIndex sigMat (2, 1) `shouldSatisfy` (>= 0.9)
diff --git a/test/Hanalyze/Model/LiNGAM/DirectSpec.hs b/test/Hanalyze/Model/LiNGAM/DirectSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Model/LiNGAM/DirectSpec.hs
@@ -0,0 +1,88 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Model.LiNGAM.DirectSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Numeric.LinearAlgebra as LA
+import qualified Hanalyze.Stat.QuasiRandom  as QR
+import qualified Hanalyze.Model.LiNGAM.Direct         as LNG
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Model.LiNGAM.Direct (Phase 36 A1)" $ do
+    -- 既知 DAG: x0 → x1 → x2, x0 → x2、 非ガウシアン noise (uniform)
+    -- 合成データを deterministic に作って causal order と B 行列を確認
+    let mkSyntheticData :: Int -> LA.Matrix Double
+        mkSyntheticData n =
+          let -- low-discrepancy 列由来の非ガウシアン noise (uniform-like)
+              halton b k = QR.radicalInverse b k - 0.5  -- 平均 0、 ~[-0.5,0.5]
+              e0s = [ halton 2 i | i <- [1..n] ]
+              e1s = [ halton 3 i | i <- [1..n] ]
+              e2s = [ halton 5 i | i <- [1..n] ]
+              -- 真の SEM: x0 = e0、 x1 = 0.8 x0 + e1、 x2 = 0.4 x0 + 0.6 x1 + e2
+              x0s = e0s
+              x1s = zipWith (\x0 e1 -> 0.8 * x0 + e1) x0s e1s
+              x2s = zipWith3 (\x0 x1 e2 -> 0.4 * x0 + 0.6 * x1 + e2)
+                             x0s x1s e2s
+          in LA.fromColumns [LA.fromList x0s, LA.fromList x1s, LA.fromList x2s]
+    it "fitDirectLiNGAM 3 変数 SEM: 因果順序を [0,1,2] と推定" $ do
+      let xs  = mkSyntheticData 500
+          fit = LNG.fitDirectLiNGAM LNG.defaultDirectLiNGAMConfig xs
+      LNG.dlOrder fit `shouldBe` [0, 1, 2]
+    it "B 行列の非ゼロ要素が真の構造と一致 (x1←x0, x2←x0, x2←x1)" $ do
+      let xs  = mkSyntheticData 500
+          fit = LNG.fitDirectLiNGAM LNG.defaultDirectLiNGAMConfig xs
+          b   = LNG.dlB fit
+      -- B[1,0] ≈ 0.8 (x1 ← 0.8 x0)
+      abs (LA.atIndex b (1, 0) - 0.8) `shouldSatisfy` (< 0.1)
+      -- B[2,1] ≈ 0.6 (x2 ← 0.6 x1)
+      abs (LA.atIndex b (2, 1) - 0.6) `shouldSatisfy` (< 0.1)
+      -- B[0,1] = 0 (x0 は x1 から影響受けない、 acyclic 構造のため)
+      abs (LA.atIndex b (0, 1)) `shouldSatisfy` (< 0.1)
+    it "Adjacency: 因果関係エッジ 3 本のみ True (threshold 0.05)" $ do
+      let xs  = mkSyntheticData 500
+          fit = LNG.fitDirectLiNGAM LNG.defaultDirectLiNGAMConfig xs
+          adj = LNG.dlAdjacency fit
+          -- |B| > 0.05 のエッジ数
+          edges = sum [ round (LA.atIndex adj (i, j)) :: Int
+                      | i <- [0..2], j <- [0..2], i /= j ]
+      edges `shouldBe` 3
+    it "entropyApprox: 標準正規分布の entropy ≈ (1+log 2π)/2 ≈ 1.419" $ do
+      -- u が標準ガウシアン近似 (大数 + 中心極限) なら H ≈ 1.4189
+      let n = 1000
+          us = LA.fromList
+                 [ let u = QR.radicalInverse 2 i
+                       v = QR.radicalInverse 3 i
+                   in sqrt (-2 * log (u + 1e-12)) * cos (2 * pi * v)
+                 | i <- [1..n] ]
+          uStd = LNG.standardize us
+          h = LNG.entropyApprox uStd
+      -- ガウシアンの真値は 1.4189385、 サンプル + 近似誤差で多少ズレ
+      abs (h - 1.4189) `shouldSatisfy` (< 0.05)
+    it "olsResidual: 完全相関時に残差 ≈ 0" $ do
+      let x = LA.fromList [1..10 :: Double]
+          y = LA.scale 2 x + LA.scalar 1  -- y = 2x + 1
+          r = LNG.olsResidual y x   -- y を x で回帰した残差は constant 部分のみ
+          -- residual の標準偏差 ≈ 0 (定数 1 は intercept 込み回帰で吸収されるが
+          -- 本実装は no-intercept なので constant 1 が残る → variance 0 のみ)
+          v = LA.norm_2 (r - LA.scalar (LA.sumElements r / 10))
+      v `shouldSatisfy` (< 1e-9)
diff --git a/test/Hanalyze/Model/LiNGAM/ICASpec.hs b/test/Hanalyze/Model/LiNGAM/ICASpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Model/LiNGAM/ICASpec.hs
@@ -0,0 +1,79 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Model.LiNGAM.ICASpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Numeric.LinearAlgebra as LA
+import qualified Hanalyze.Stat.QuasiRandom  as QR
+import qualified Hanalyze.Model.LiNGAM.ICA            as LNGI
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Model.LiNGAM.ICA (Phase 36 A4)" $ do
+    -- 2 変数 SEM: x0 → x1 (係数 0.8)、 非ガウシアン noise
+    let mkData2 n =
+          let halton b k = QR.radicalInverse b k - 0.5
+              e0s = [halton 2 i | i <- [1..n]]
+              e1s = [halton 3 i | i <- [1..n]]
+              x0s = e0s
+              x1s = zipWith (\a b -> 0.8 * a + b) x0s e1s
+          in LA.fromColumns [LA.fromList x0s, LA.fromList x1s]
+    it "fitICALiNGAM: 2 変数 SEM の order を推定" $ do
+      let x = mkData2 500
+      fit <- LNGI.fitICALiNGAM LNGI.defaultICALiNGAMConfig x
+      -- ICA の符号曖昧性 + 順列貪欲のため、 厳密な order ではなく adjacency
+      -- に 1 つのエッジが立つことを確認
+      let adj = LNGI.ilAdjacency fit
+          edges = sum [ round (LA.atIndex adj (i, j)) :: Int
+                      | i <- [0, 1], j <- [0, 1], i /= j ]
+      edges `shouldSatisfy` (>= 1)
+
+  describe "Hanalyze.Model.LiNGAM.ICA: Hungarian 化 (p=8)" $ do
+    -- 8 変数 chain SEM: x_0 → x_1 → ... → x_7、 係数 0.6、 非ガウシアン noise。
+    -- Hungarian/greedy のどちらでも fit が走ることだけ確認 (adjacency >= 1)。
+    -- 純粋なアルゴリズム妥当性は Hungarian テストで担保。
+    let mkChain n p0 =
+          let halton b k = QR.radicalInverse b k - 0.5
+              primes = take p0 [2, 3, 5, 7, 11, 13, 17, 19, 23, 29]
+              es     = [ [halton b i | i <- [1..n]] | b <- primes ]
+              go acc []     = reverse acc
+              go []  (e:rs) = go [e] rs
+              go (prev:rest) (e:rs) =
+                let cur = zipWith (\a b -> 0.6 * a + b) prev e
+                in go (cur : prev : rest) rs
+              cols   = case es of
+                         []     -> []
+                         (e0:r) -> go [e0] r
+          in LA.fromColumns (map LA.fromList cols)
+    it "Hungarian 化: 8 変数 chain で adjacency が立つ" $ do
+      let x = mkChain 400 8
+          cfg = LNGI.defaultICALiNGAMConfig { LNGI.ilcUseHungarian = True }
+      fit <- LNGI.fitICALiNGAM cfg x
+      let adj = LNGI.ilAdjacency fit
+          edges = sum [ round (LA.atIndex adj (i, j)) :: Int
+                      | i <- [0 .. 7], j <- [0 .. 7], i /= j ]
+      edges `shouldSatisfy` (>= 1)
+    it "貪欲版も同じ shape で動く (ilcUseHungarian = False、 回帰確認)" $ do
+      let x = mkChain 400 8
+          cfg = LNGI.defaultICALiNGAMConfig { LNGI.ilcUseHungarian = False }
+      fit <- LNGI.fitICALiNGAM cfg x
+      LA.rows (LNGI.ilAdjacency fit) `shouldBe` 8
diff --git a/test/Hanalyze/Model/LiNGAM/MultiGroupSpec.hs b/test/Hanalyze/Model/LiNGAM/MultiGroupSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Model/LiNGAM/MultiGroupSpec.hs
@@ -0,0 +1,46 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Model.LiNGAM.MultiGroupSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Numeric.LinearAlgebra as LA
+import qualified Hanalyze.Stat.QuasiRandom  as QR
+import qualified Hanalyze.Model.LiNGAM.MultiGroup     as LNGM
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Model.LiNGAM.MultiGroup (Phase 36 A6)" $ do
+    -- 共通構造 x0 → x1 → x2 を 2 群分作成 (係数は群ごとに微小差)
+    let mkGroup n c1 c2 =
+          let halton b k = QR.radicalInverse b k - 0.5
+              e0s = [halton 2 i | i <- [1..n]]
+              e1s = [halton 3 i | i <- [1..n]]
+              e2s = [halton 5 i | i <- [1..n]]
+              x0s = e0s
+              x1s = zipWith (\a b -> c1 * a + b) x0s e1s
+              x2s = zipWith3 (\a b c -> c2 * b + c) x0s x1s e2s
+          in LA.fromColumns [LA.fromList x0s, LA.fromList x1s, LA.fromList x2s]
+    it "2 群の共通 causal order を [0,1,2] と推定" $ do
+      let g1 = mkGroup 400 0.8 0.6
+          g2 = mkGroup 400 0.7 0.5
+          fit = LNGM.fitMultiGroupLiNGAM LNGM.defaultMultiGroupConfig [g1, g2]
+      LNGM.mgCommonOrder fit `shouldBe` [0, 1, 2]
diff --git a/test/Hanalyze/Model/LiNGAM/PairwiseSpec.hs b/test/Hanalyze/Model/LiNGAM/PairwiseSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Model/LiNGAM/PairwiseSpec.hs
@@ -0,0 +1,46 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Model.LiNGAM.PairwiseSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Numeric.LinearAlgebra as LA
+import qualified Hanalyze.Stat.QuasiRandom  as QR
+import qualified Hanalyze.Model.LiNGAM.Pairwise       as LNGP
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Model.LiNGAM.Pairwise (Phase 36 A3)" $ do
+    -- x → y、 noise は uniform 由来非ガウシアン
+    let mkPair n =
+          let halton b k = QR.radicalInverse b k - 0.5
+              xs = LA.fromList [halton 2 i | i <- [1..n]]
+              es = LA.fromList [halton 3 i | i <- [1..n]]
+              ys = LA.scale 0.8 xs + es
+          in (xs, ys)
+    it "x → y を XtoY と判定" $ do
+      let (x, y) = mkPair 500
+          r = LNGP.pairwiseLiNGAM 0 x y
+      LNGP.prDirection r `shouldBe` LNGP.XtoY
+    it "y → x を YtoX と判定 (入力順を逆転)" $ do
+      let (x, y) = mkPair 500
+          r = LNGP.pairwiseLiNGAM 0 y x
+      LNGP.prDirection r `shouldBe` LNGP.YtoX
diff --git a/test/Hanalyze/Model/LiNGAM/ParceSpec.hs b/test/Hanalyze/Model/LiNGAM/ParceSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Model/LiNGAM/ParceSpec.hs
@@ -0,0 +1,68 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Model.LiNGAM.ParceSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Numeric.LinearAlgebra as LA
+import qualified Hanalyze.Stat.QuasiRandom  as QR
+import qualified Hanalyze.Model.LiNGAM.Parce          as LNGPa
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Model.LiNGAM.Parce (Phase 36 A7)" $ do
+    -- 同 SEM データで ParceLiNGAM が DirectLiNGAM と同等の結果を出すか確認
+    let mkData n =
+          let halton b k = QR.radicalInverse b k - 0.5
+              e0s = [halton 2 i | i <- [1..n]]
+              e1s = [halton 3 i | i <- [1..n]]
+              e2s = [halton 5 i | i <- [1..n]]
+              x0s = e0s
+              x1s = zipWith (\a b -> 0.8 * a + b) x0s e1s
+              x2s = zipWith3 (\a b c -> 0.4 * a + 0.6 * b + c) x0s x1s e2s
+          in LA.fromColumns [LA.fromList x0s, LA.fromList x1s, LA.fromList x2s]
+    it "fitParceLiNGAM: 潜在交絡なしのデータで causal order を [0,1,2] と推定" $ do
+      let x = mkData 500
+          fit = LNGPa.fitParceLiNGAM LNGPa.defaultParceConfig x
+      LNGPa.pcOrder fit `shouldBe` [0, 1, 2]
+      LNGPa.pcUnresolvedGroup fit `shouldBe` []
+
+    -- 潜在交絡: hidden h が x0, x1 を駆動、 x2 は両者の sink
+    --   h ~ 非ガウシアン、 x0 = 0.7 h + e0、 x1 = 0.6 h + e1、 x2 = 0.5 x0 + 0.4 x1 + e2
+    --   観測 X = [x0, x1, x2] のみ。 v0.2 は x2 を sink と同定、 x0/x1 は
+    --   独立性が満たされないため unresolved group に入る想定。
+    let mkLatent n =
+          let halton b k = QR.radicalInverse b k - 0.5
+              hs  = [halton 2 i | i <- [1..n]]
+              e0s = [halton 3 i | i <- [1..n]]
+              e1s = [halton 5 i | i <- [1..n]]
+              e2s = [halton 7 i | i <- [1..n]]
+              x0s = zipWith (\h e -> 0.7 * h + e) hs e0s
+              x1s = zipWith (\h e -> 0.6 * h + e) hs e1s
+              x2s = zipWith3 (\a b c -> 0.5 * a + 0.4 * b + c) x0s x1s e2s
+          in LA.fromColumns [LA.fromList x0s, LA.fromList x1s, LA.fromList x2s]
+    it "fitParceLiNGAM v0.2: 潜在交絡シナリオで unresolved group に x0/x1 が残る" $ do
+      let x   = mkLatent 500
+          fit = LNGPa.fitParceLiNGAM LNGPa.defaultParceConfig x
+      -- x2 は sink として確定 (末尾)
+      last (LNGPa.pcOrder fit) `shouldBe` 2
+      -- x0, x1 は順序確定できず unresolved group に入る (順序問わず)
+      sort (LNGPa.pcUnresolvedGroup fit) `shouldBe` [0, 1]
diff --git a/test/Hanalyze/Model/LiNGAM/VARSpec.hs b/test/Hanalyze/Model/LiNGAM/VARSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Model/LiNGAM/VARSpec.hs
@@ -0,0 +1,57 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Model.LiNGAM.VARSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Numeric.LinearAlgebra as LA
+import qualified Hanalyze.Stat.QuasiRandom  as QR
+import qualified Hanalyze.Model.LiNGAM.VAR            as LNGV
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Model.LiNGAM.VAR (Phase 36 A5)" $ do
+    -- VAR(1) + contemporaneous LiNGAM の合成データ
+    -- Y_t = A · Y_{t-1} + u_t、 u_t は 2 変数 (x0 → x1) の LiNGAM 構造
+    let mkSeries n =
+          let halton b k = QR.radicalInverse b k - 0.5
+              e0s = [halton 2 i | i <- [1..n]]
+              e1s = [halton 3 i | i <- [1..n]]
+              -- u_t (同時刻): u0 = e0、 u1 = 0.7 u0 + e1
+              u0s = e0s
+              u1s = zipWith (\a b -> 0.7 * a + b) u0s e1s
+              -- VAR(1): Y_t = 0.3 Y_{t-1} + u_t (両変数とも自己回帰)
+              go [_, _]    _    _    acc = reverse acc
+              go (u0:u1:rest) y0Prev y1Prev acc =
+                let y0 = 0.3 * y0Prev + u0
+                    y1 = 0.3 * y1Prev + u1
+                in go rest y0 y1 ((y0, y1) : acc)
+              go _ _ _ acc = reverse acc
+              pairs = go (interleave u0s u1s) 0 0 []
+              ys = take (n - 1) (map fst pairs)
+              y1ss = take (n - 1) (map snd pairs)
+              interleave (a:as) (b:bs) = a : b : interleave as bs
+              interleave xs _          = xs
+          in LA.fromColumns [LA.fromList ys, LA.fromList y1ss]
+    it "fitVARLiNGAM: 同時刻 contemporaneous order を推定" $ do
+      let y = mkSeries 500
+          fit = LNGV.fitVARLiNGAM LNGV.defaultVARLiNGAMConfig y
+      LNGV.vlContempOrder fit `shouldBe` [0, 1]
diff --git a/test/Hanalyze/Model/MultiOutputSpec.hs b/test/Hanalyze/Model/MultiOutputSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Model/MultiOutputSpec.hs
@@ -0,0 +1,185 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Model.MultiOutputSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Data.Vector as V
+import qualified Data.Text   as T
+import qualified Numeric.LinearAlgebra as LA
+import qualified System.Random.MWC as MWC
+import qualified Hanalyze.Model.GP          as GP
+import qualified Hanalyze.Model.GPRobust    as GPR
+import qualified Hanalyze.Model.GP        as GP
+import qualified Hanalyze.Model.GPRobust  as GPR
+import qualified Hanalyze.Model.RFF       as RFF
+import qualified Hanalyze.Model.Regularized as Reg
+import qualified Hanalyze.Model.Spline      as Sp
+import qualified Hanalyze.Model.KernelRegression      as K
+import qualified Hanalyze.Model.Core        as Core
+import qualified Hanalyze.Model.GLM         as GLM
+import qualified System.Random.MWC as MWC
+import qualified Hanalyze.MCMC.Core as Core
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Multi-output equivalence (q=1)" $ do
+    let xs = LA.fromLists [[1,1.0], [1,2.0], [1,3.0], [1,4.0], [1,5.0]] :: LA.Matrix Double
+        yV = LA.fromList [2.1, 3.9, 6.0, 8.1, 10.0]                      :: LA.Vector Double
+        yM = LA.asColumn yV
+        approx tol a b = abs (a - b) < tol
+        approxList tol as bs = length as == length bs &&
+                               all (uncurry (approx tol)) (zip as bs)
+        buildGroupsLocal gvec =
+          let lbls = V.fromList . sort . foldr (\x acc -> if x `elem` acc then acc else x:acc) [] $ V.toList gvec
+              qN   = V.length lbls
+              idxFor x = case V.elemIndex x lbls of
+                           Just i  -> i
+                           Nothing -> 0
+              idx  = V.map idxFor gvec
+              sz   = V.fromList [ V.length (V.filter (== j) idx) | j <- [0 .. qN - 1] ]
+          in (lbls, idx, sz)
+
+    it "M1 Regularized Ridge: fitRegularized == fitRegularizedMulti col 0" $ do
+      let single = Reg.fitRegularized (Reg.L2 0.1) xs yV
+          multi  = Reg.fitRegularizedMulti (Reg.L2 0.1) xs yM
+          extr   = Reg.regFitFromMulti 0 multi
+      approxList 1e-9 (LA.toList (Reg.rfBeta single))
+                      (LA.toList (Reg.rfBeta extr))
+        `shouldBe` True
+
+    it "M1 Regularized Lasso: q=1 一致" $ do
+      let single = Reg.fitRegularized (Reg.L1 0.05) xs yV
+          multi  = Reg.fitRegularizedMulti (Reg.L1 0.05) xs yM
+          extr   = Reg.regFitFromMulti 0 multi
+      approxList 1e-9 (LA.toList (Reg.rfBeta single))
+                      (LA.toList (Reg.rfBeta extr))
+        `shouldBe` True
+
+    it "M2 Spline: fitSpline == fitSplineMulti col 0" $ do
+      let xv = V.fromList [1,2,3,4,5,6,7,8,9,10] :: V.Vector Double
+          yv = V.fromList (map (\x -> sin (x/2) + 0.01*x) (V.toList xv))
+          knots = [1,3,5,7,10]
+          single = Sp.fitSpline (Sp.BSpline 3) knots xv yv
+          ymat   = LA.asColumn (LA.fromList (V.toList yv))
+          multi  = Sp.fitSplineMulti (Sp.BSpline 3) knots xv ymat
+          colS   = LA.toList (Sp.sfBeta single)
+          colM   = LA.toList (LA.flatten (Sp.smfBeta multi LA.¿ [0]))
+      approxList 1e-9 colS colM `shouldBe` True
+
+    it "bsplineBasis: partition of unity が右端 x=hi でも成立 (= 1)" $ do
+      -- clamped ノットで hi が重複するため、 退化区間 [hi,hi] を右閉にすると
+      -- 高次再帰で基底全ゼロ化する欠陥があった (計測で確認・修正済)。 内点と
+      -- 端点の両方で行和 = 1 を要求する回帰テスト。
+      let knots = [0,2,4,6,8] :: [Double]
+          xs    = V.fromList [0, 4, 7.99, 8.0]   -- 左端 / 内点 / 端近傍 / 右端
+          basis = Sp.bsplineBasis 3 knots xs
+          sums  = map sum (LA.toLists basis)
+      approxList 1e-9 sums [1,1,1,1] `shouldBe` True
+
+    it "fitSpline: 右端 x=hi のフィット値が崩れない (基底全ゼロ化の回帰)" $ do
+      -- y=x² を 3 次 B-spline で fit。 右端の fitted が 0 に落ちず原値に近いこと。
+      let xv = V.fromList [0,1,2,3,4,5,6,7,8] :: V.Vector Double
+          yv = V.map (\x -> x*x) xv
+          fit = Sp.fitSpline (Sp.BSpline 3) [0,2,4,6,8] xv yv
+          yhatLast = V.last (Sp.predictSpline fit (V.fromList [8.0]))
+      yhatLast `shouldSatisfy` (\v -> abs (v - 64) < 5)  -- 64 = 8² 近傍 (0 でない)
+
+    it "M3 Kernel Ridge: kernelRidge == kernelRidgeMulti col 0" $ do
+      let xv = V.fromList [0.0,1,2,3,4,5,6,7,8,9] :: V.Vector Double
+          yv = V.fromList [0.0, 0.5, 1.0, 1.4, 1.7, 1.9, 2.0, 2.0, 1.95, 1.8]
+          single = K.kernelRidge K.Gaussian 1.0 0.01 xv yv
+          ymat   = LA.asColumn (LA.fromList (V.toList yv))
+          multi  = K.kernelRidgeMulti K.Gaussian 1.0 0.01 xv ymat
+      approxList 1e-9 (LA.toList (K.krAlpha single))
+                      (LA.toList (LA.flatten (K.krmAlpha multi LA.¿ [0])))
+        `shouldBe` True
+
+    it "M3 Kernel NW: nwRegression == nwRegressionMulti col 0" $ do
+      let xv = V.fromList [0.0,1,2,3,4,5,6,7,8,9] :: V.Vector Double
+          yv = V.fromList [0.1, 0.3, 0.7, 1.0, 1.5, 1.9, 2.0, 1.95, 1.8, 1.5]
+          xn = V.fromList [0.5, 2.5, 5.5, 8.5]
+          single = K.nwRegression K.Gaussian 1.0 xv yv xn
+          ymat   = LA.asColumn (LA.fromList (V.toList yv))
+          multi  = K.nwRegressionMulti K.Gaussian 1.0 xv ymat xn
+          colM   = LA.toList (LA.flatten (multi LA.¿ [0]))
+      approxList 1e-9 (V.toList single) colM `shouldBe` True
+
+    it "M4 RFF Ridge: rffRidge == rffRidgeMulti col 0" $ do
+      gen <- MWC.create
+      rff <- RFF.sampleRFFRBF 16 1.0 1.0 gen
+      let xList = [0.0, 1, 2, 3, 4, 5]
+          yList = [0.1, 0.5, 1.0, 1.4, 1.7, 1.9]
+          single = RFF.rffRidge rff xList yList 0.01
+          ymat   = LA.asColumn (LA.fromList yList)
+          multi  = RFF.rffRidgeMulti rff xList ymat 0.01
+      approxList 1e-9 (LA.toList (RFF.rffrWeights single))
+                      (LA.toList (LA.flatten (RFF.rffrmWeights multi LA.¿ [0])))
+        `shouldBe` True
+
+    it "M5 GP: fitGP mean == fitGPMulti col 0" $ do
+      let model = GP.GPModel GP.RBF GP.defaultGPParams
+          trX   = [0.0, 1, 2, 3, 4, 5]
+          trY   = [0.1, 0.4, 0.9, 1.3, 1.6, 1.8]
+          tsX   = [0.5, 2.5, 4.5]
+          single = GP.fitGP model trX trY tsX
+          ymat   = LA.asColumn (LA.fromList trY)
+          (mMat, _) = GP.fitGPMulti model trX ymat tsX
+      approxList 1e-9 (GP.gpMean single)
+                      (LA.toList (LA.flatten (mMat LA.¿ [0])))
+        `shouldBe` True
+
+    it "M5 GPRobust: fitGPRobust α == fitGPRobustMulti col 0" $ do
+      let params = GP.defaultGPParams
+          trX    = [0.0, 1, 2, 3, 4, 5]
+          trY    = [0.1, 0.4, 5.0, 1.3, 1.6, 1.8]   -- 1 outlier at idx 2
+          single = GPR.fitGPRobust GP.RBF params (GPR.RStudentT 4 0.3) trX trY
+          ymat   = LA.asColumn (LA.fromList trY)
+          multi  = GPR.fitGPRobustMulti GP.RBF params (GPR.RStudentT 4 0.3) trX ymat
+          firstFit = head (GPR.rgmFits multi)
+      approxList 1e-9 (LA.toList (GPR.rgpAlpha single))
+                      (LA.toList (GPR.rgpAlpha firstFit))
+        `shouldBe` True
+
+    it "M6 GLM Gaussian: fitGLM == fitGLMMulti col 0" $ do
+      let single = GLM.fitGLM GLM.Gaussian xs yV
+          multi  = GLM.fitGLMMulti GLM.Gaussian GLM.Identity xs yM
+          colM   = LA.toList (LA.flatten (GLM.gfmBeta multi LA.¿ [0]))
+          colS   = LA.toList (LA.flatten (Core.coefficients single))
+      approxList 1e-7 colS colM `shouldBe` True
+
+    it "M7 LME: fitLME == fitLMEMulti col 0" $ do
+      let xMat = LA.fromLists
+                   [[1,1],[1,2],[1,3],[1,4],
+                    [1,1],[1,2],[1,3],[1,4],
+                    [1,1],[1,2],[1,3],[1,4]] :: LA.Matrix Double
+          y1   = LA.fromList [7.1,6.9,7.0,7.0, 5.0,4.9,5.1,5.0, 3.0,2.9,3.1,3.0]
+          ym   = LA.asColumn y1
+          gv   = V.fromList (["A","A","A","A","B","B","B","B","C","C","C","C"] :: [T.Text])
+          (lbls, idx, sz) = buildGroupsLocal gv
+          single = fitLME xMat y1 idx lbls sz
+          multi  = fitLMEMulti xMat ym idx lbls sz
+          firstM = head (glmmFits multi)
+      glmmRandVar firstM `shouldSatisfy` approx 1e-9 (glmmRandVar single)
+
+  -- ===========================================================================
+  -- 単目的オプティマイザ (Hanalyze.Optim.NelderMead)
+  -- ===========================================================================
diff --git a/test/Hanalyze/Model/NaiveBayesSpec.hs b/test/Hanalyze/Model/NaiveBayesSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Model/NaiveBayesSpec.hs
@@ -0,0 +1,59 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Model.NaiveBayesSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Numeric.LinearAlgebra as LA
+import qualified Data.Vector.Unboxed        as VU
+import qualified Hanalyze.Model.NaiveBayes     as NB
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Model.NaiveBayes (Phase 34-A5)" $ do
+    -- Gaussian: 2 つの正規分布
+    let xG = LA.fromLists $
+               [[-2.0, -2.0], [-1.5, -1.8], [-2.1, -1.9], [-1.9, -2.2]] ++
+               [[ 2.0,  2.0], [ 1.8,  2.1], [ 2.2,  1.9], [ 1.7,  2.3]]
+        yG = VU.fromList ([0,0,0,0, 1,1,1,1] :: [Int])
+    it "fitGNB + predictNB: 2 ガウス分離で訓練精度 100%" $ do
+      let nb = NB.NBGaussian (NB.fitGNB xG yG)
+          ys = NB.predictNB nb xG
+          correct = VU.length (VU.filter id (VU.zipWith (==) ys yG))
+      correct `shouldBe` VU.length yG
+    it "fitGNB: gnbMeans の長さ = クラス数" $ do
+      let nb = NB.fitGNB xG yG
+      length (NB.gnbMeans nb) `shouldBe` 2
+      NB.gnbClasses nb `shouldBe` [0, 1]
+    it "predictNBLogProbs: 正規化済 (exp 和 = 1)" $ do
+      let nb = NB.NBGaussian (NB.fitGNB xG yG)
+          lps = NB.predictNBLogProbs nb xG
+      and [ abs (sum [ exp z | z <- row ] - 1) < 1e-10 | row <- lps ]
+        `shouldBe` True
+    it "fitMNB + predictNB: カウント特徴 (Multinomial)" $ do
+      -- クラス 0 は feature 0 中心、 クラス 1 は feature 1 中心
+      let xM = LA.fromLists [[5,1,0],[6,0,1],[4,2,0],
+                              [0,1,5],[1,0,6],[0,2,4]]
+          yM = VU.fromList ([0,0,0, 1,1,1] :: [Int])
+          nb = NB.NBMultinomial (NB.fitMNB 1.0 xM yM)
+          ys = NB.predictNB nb xM
+          correct = VU.length (VU.filter id (VU.zipWith (==) ys yM))
+      correct `shouldBe` VU.length yM
diff --git a/test/Hanalyze/Model/NeuralNetworkSpec.hs b/test/Hanalyze/Model/NeuralNetworkSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Model/NeuralNetworkSpec.hs
@@ -0,0 +1,88 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Model.NeuralNetworkSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Data.Vector as V
+import qualified Numeric.LinearAlgebra as LA
+import qualified Data.Vector.Unboxed        as VU
+import qualified Hanalyze.Model.NeuralNetwork  as NN
+import qualified System.Random.MWC as MWC
+import qualified System.Random.MWC as MWC
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Model.NeuralNetwork (Phase 16)" $ do
+    let -- 線形 y = 2x + 1 + ノイズ なし
+        xReg = LA.fromColumns
+                 [LA.fromList [fromIntegral i / 5 - 1 | i <- [0 :: Int .. 9]]]
+        yReg = LA.fromList [2 * (fromIntegral i / 5 - 1) + 1
+                           | i <- [0 :: Int .. 9]]
+        -- 2 クラス分類: x < 0 → 0、 x >= 0 → 1
+        xCls = LA.fromColumns
+                 [LA.fromList [fromIntegral i - 5 | i <- [0 :: Int .. 9]]]
+        yCls = VU.fromList [0, 0, 0, 0, 0, 1, 1, 1, 1, 1]
+    it "fitMLPRegressor: 線形データに学習収束" $ do
+      gen <- MWC.createSystemRandom
+      let cfg = NN.defaultMLP
+            { NN.mlpHidden = [8]
+            , NN.mlpEpochs = 500
+            , NN.mlpBatch  = 5
+            , NN.mlpLR     = 0.05
+            }
+      fit <- NN.fitMLPRegressor cfg xReg yReg gen
+      let preds = LA.flatten (NN.predictMLP fit xReg)
+          mse = LA.sumElements ((preds - yReg) ^ (2 :: Int))
+                  / fromIntegral (LA.size yReg)
+      mse `shouldSatisfy` (< 0.3)
+    it "fitMLPRegressor: loss history が単調減少 (大局的)" $ do
+      gen <- MWC.createSystemRandom
+      let cfg = NN.defaultMLP { NN.mlpEpochs = 100 }
+      fit <- NN.fitMLPRegressor cfg xReg yReg gen
+      let losses = NN.mlpLossHist fit
+      length losses `shouldBe` 100
+      last losses `shouldSatisfy` (< head losses)
+    it "fitMLPClassifier: 1-D 線形分離で訓練精度 100%" $ do
+      gen <- MWC.createSystemRandom
+      let cfg = NN.defaultMLP
+            { NN.mlpHidden = [4]
+            , NN.mlpEpochs = 300
+            , NN.mlpBatch  = 5
+            , NN.mlpLR     = 0.1
+            }
+      fit <- NN.fitMLPClassifier cfg xCls yCls gen
+      let preds = NN.predictMLPClass fit xCls
+          correct = length [ () | i <- [0 .. VU.length yCls - 1]
+                                , preds V.! i == yCls VU.! i ]
+      correct `shouldSatisfy` (>= 9)
+    it "fitMLPClassifier: mlpClasses は sorted unique label" $ do
+      gen <- MWC.createSystemRandom
+      let cfg = NN.defaultMLP { NN.mlpEpochs = 5 }
+      fit <- NN.fitMLPClassifier cfg xCls yCls gen
+      NN.mlpClasses fit `shouldBe` [0, 1]
+    it "predictMLP: 出力 shape が (n × out)" $ do
+      gen <- MWC.createSystemRandom
+      let cfg = NN.defaultMLP { NN.mlpEpochs = 5 }
+      fit <- NN.fitMLPRegressor cfg xReg yReg gen
+      let out = NN.predictMLP fit xReg
+      LA.rows out `shouldBe` 10
+      LA.cols out `shouldBe` 1
diff --git a/test/Hanalyze/Model/PCASpec.hs b/test/Hanalyze/Model/PCASpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Model/PCASpec.hs
@@ -0,0 +1,65 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Model.PCASpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Numeric.LinearAlgebra as LA
+import qualified Hanalyze.Model.PCA         as PCA
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Model.PCA" $ do
+    it "PCA on rank-1 matrix: 1st component explains ~100% var" $ do
+      let -- Rank-1: each row = scalar × [1, 2, 3]
+          ks = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]
+          xs = LA.fromLists [[k, 2 * k, 3 * k] | k <- ks]
+          r  = PCA.pca PCA.Center Nothing xs
+      head (LA.toList (PCA.pcaExplainedRatio r))
+        `shouldSatisfy` (> 0.999)
+
+    it "pcaTransform + pcaInverse は Center mode で全成分なら復元 ≈ x" $ do
+      let xs = LA.fromLists [[1, 2, 3], [4, 5, 6], [7, 8, 9], [2, 1, 0]]
+          r  = PCA.pca PCA.Center Nothing xs
+          scores = PCA.pcaTransform r xs
+          recon  = PCA.pcaInverse r scores
+          diff   = LA.norm_2 (LA.flatten (xs - recon))
+      diff `shouldSatisfy` (< 1e-9)
+
+    it "pcaCumExplained は monotone increasing で max ≤ 1" $ do
+      let xs = LA.fromLists [[k, 2*k+1, k*k]
+                            | k <- [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]]
+          r  = PCA.pca PCA.Center Nothing xs
+          cum = LA.toList (PCA.pcaCumExplained r)
+      last cum `shouldSatisfy` (<= 1.0001)
+      and (zipWith (<=) cum (tail cum)) `shouldBe` True
+
+    it "CenterScale で各列の SD が 1 に正規化される" $ do
+      let xs = LA.fromLists [[1, 100, 0.1], [2, 200, 0.2],
+                             [3, 300, 0.3], [4, 400, 0.4]]
+          r  = PCA.pca PCA.CenterScale Nothing xs
+      -- SD は元データの per-col SD で保存される
+      LA.size (PCA.pcaScale r) `shouldBe` 3
+      all (> 0) (LA.toList (PCA.pcaScale r)) `shouldBe` True
+
+  -- ===========================================================================
+  -- Hanalyze.Stat.ClassMetrics (Phase 3)
+  -- ===========================================================================
diff --git a/test/Hanalyze/Model/PLSSpec.hs b/test/Hanalyze/Model/PLSSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Model/PLSSpec.hs
@@ -0,0 +1,105 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Model.PLSSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Data.Text   as T
+import qualified Numeric.LinearAlgebra as LA
+import qualified System.Random.MWC as MWC
+import qualified System.Random.MWC as MWC
+import qualified Hanalyze.Model.PLS         as PLS
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Model.PLS (Phase 9)" $ do
+    -- 合成データ: y = 2 x1 + 1 x2 + 0 x3 (3 features) + noise
+    let synthData :: IO (LA.Matrix Double, LA.Vector Double, LA.Vector Double)
+        synthData = do
+          gen <- MWC.create
+          let n = 100; p = 5
+          xs <- LA.fromLists <$> sequence
+                  [ sequence [ MWC.uniformR (-1, 1) gen | _ <- [1..p] ]
+                  | _ <- [1..n] ]
+          let trueB = LA.fromList [2.0, 1.0, 0.0, 0.0, 0.0] :: LA.Vector Double
+              y0 = xs LA.#> trueB
+          noise <- LA.fromList <$> sequence
+                     [ MWC.uniformR (-0.1, 0.1) gen | _ <- [1..n] ]
+          pure (xs, y0 + noise, trueB)
+
+    it "fitPLS1: n_components=2 で fit、 shape 整合性" $ do
+      (xs, y, _) <- synthData
+      let cfg = PLS.defaultPLS { PLS.plsN_Components = 2 }
+      case PLS.fitPLS1 cfg xs y of
+        Left e -> expectationFailure (T.unpack e)
+        Right f -> do
+          LA.rows (PLS.plsScoresT f)    `shouldBe` 100
+          LA.cols (PLS.plsScoresT f)    `shouldBe` 2
+          LA.rows (PLS.plsLoadingsP f)  `shouldBe` 5
+          LA.cols (PLS.plsLoadingsP f)  `shouldBe` 2
+          LA.size (PLS.plsVIP f)        `shouldBe` 5
+          LA.size (PLS.plsR2X f)        `shouldBe` 2
+
+    it "predictPLS1: 合成データで真値に近い予測" $ do
+      (xs, y, _) <- synthData
+      let cfg = PLS.defaultPLS { PLS.plsN_Components = 3 }
+      case PLS.fitPLS1 cfg xs y of
+        Left e -> expectationFailure (T.unpack e)
+        Right f -> do
+          let yHat = PLS.predictPLS1 f xs
+              resid = y - yHat
+              n = LA.size y :: Int
+              mse = LA.sumElements (resid * resid) / fromIntegral n
+          mse `shouldSatisfy` (< 0.05)
+
+    it "VIP: 強い変数 (x1, x2) は VIP > 1、 ノイズ変数は VIP < 1 に近い" $ do
+      (xs, y, _) <- synthData
+      let cfg = PLS.defaultPLS { PLS.plsN_Components = 3 }
+      case PLS.fitPLS1 cfg xs y of
+        Left e -> expectationFailure (T.unpack e)
+        Right f -> do
+          let vip = LA.toList (PLS.plsVIP f)
+          -- VIP[0] (x1) は最大級
+          (vip !! 0) `shouldSatisfy` (> 1.0)
+          -- VIP[1] (x2) も 1 超え
+          (vip !! 1) `shouldSatisfy` (> 0.5)
+
+    it "n_components が n-1 超えで Left" $ do
+      (xs, y, _) <- synthData
+      let cfg = PLS.defaultPLS { PLS.plsN_Components = 200 }
+      case PLS.fitPLS1 cfg xs y of
+        Left _ -> pure ()
+        Right _ -> expectationFailure "expected Left for too many components"
+
+    it "SIMPLS は現状未実装で Left" $ do
+      (xs, y, _) <- synthData
+      let cfg = PLS.defaultPLS { PLS.plsAlgorithm = PLS.SIMPLS }
+      case PLS.fitPLS1 cfg xs y of
+        Left _ -> pure ()
+        Right _ -> expectationFailure "expected Left for SIMPLS (not implemented)"
+
+    it "selectPLSComponentsCV: best K が grid 内" $ do
+      (xs, y, _) <- synthData
+      gen <- MWC.create
+      sel <- PLS.selectPLSComponentsCV 5 4 xs (LA.asColumn y) gen
+      PLS.plsBestK sel `shouldSatisfy` (\k -> k >= 1 && k <= 4)
+      length (PLS.plsCVMSEs sel) `shouldBe` 4
+      PLS.plsOneSeK sel `shouldSatisfy` (<= PLS.plsBestK sel)
diff --git a/test/Hanalyze/Model/PartialDependenceSpec.hs b/test/Hanalyze/Model/PartialDependenceSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Model/PartialDependenceSpec.hs
@@ -0,0 +1,58 @@
+{-# LANGUAGE OverloadedStrings #-}
+-- | 部分従属 (PDP) / ICE 純粋エンジンのテスト (Phase 75.27)。
+--   既知の predict 関数で PDP の解析値・ICE 本数・grid 端点・中心化を検証する。
+module Hanalyze.Model.PartialDependenceSpec (spec) where
+
+import           Test.Hspec
+import qualified Numeric.LinearAlgebra as LA
+import           Hanalyze.Model.PartialDependence
+
+-- 3 行 × 2 列の小さな訓練行列。 列 0 = {0,1,2}, 列 1 = {10,20,30}。
+trainX :: LA.Matrix Double
+trainX = LA.fromLists [ [0, 10], [1, 20], [2, 30] ]
+
+spec :: Spec
+spec =
+  describe "Hanalyze.Model.PartialDependence" $ do
+
+    it "線形 predict f = 2*x_j: PDP は grid をそのまま 2 倍 (他列に非依存)" $ do
+      let predict m = [ 2 * (row LA.! 0) | row <- LA.toRows m ]   -- 列 0 のみ使う
+          r = partialDependence trainX predict 0 3
+      pdpGrid r `shouldBe` [0, 1, 2]
+      pdpMean r `shouldBe` [0, 2, 4]
+
+    it "加法 f = 3*x0 + 5*x1: 特徴 0 の PDP 傾きは 3, 切片は 5*mean(x1)" $ do
+      let predict m = [ 3 * (row LA.! 0) + 5 * (row LA.! 1) | row <- LA.toRows m ]
+          r = partialDependence trainX predict 0 3
+          -- x1 平均 = 20 → 定数寄与 100。 PDP = 3*grid + 100。
+      pdpMean r `shouldBe` [100, 103, 106]
+
+    it "ICE: 曲線本数 = 行数、 各曲線の長さ = grid 数" $ do
+      let predict m = [ row LA.! 0 + row LA.! 1 | row <- LA.toRows m ]
+          r = partialDependence trainX predict 0 4
+      length (pdpIce r) `shouldBe` 3
+      map length (pdpIce r) `shouldBe` [4, 4, 4]
+
+    it "grid 端点は注目列の観測 min/max" $ do
+      let predict m = replicate (LA.rows m) 0
+          r = partialDependence trainX predict 1 5   -- 列 1 = {10,20,30}
+      head (pdpGrid r) `shouldBe` 10
+      last (pdpGrid r) `shouldBe` 30
+
+    it "PDP = ICE 群の各 grid 列平均に一致" $ do
+      let predict m = [ 2 * (row LA.! 0) + (row LA.! 1) | row <- LA.toRows m ]
+          r = partialDependence trainX predict 0 3
+          colMeans = map (\k -> sum [ c !! k | c <- pdpIce r ] / 3) [0, 1, 2]
+      pdpMean r `shouldBe` colMeans
+
+    it "centerICE: 各 ICE 曲線が左端で 0 になる" $ do
+      let predict m = [ 3 * (row LA.! 0) + (row LA.! 1) | row <- LA.toRows m ]
+          r  = centerICE (partialDependence trainX predict 0 3)
+      map head (pdpIce r) `shouldBe` [0, 0, 0]
+      -- 中心化後の PDP も左端 0。
+      head (pdpMean r) `shouldBe` 0
+
+    it "空行列・列外 index は空結果" $ do
+      let predict m = replicate (LA.rows m) 0
+      partialDependence (LA.fromLists []) predict 0 5 `shouldBe` PDPResult [] [] []
+      partialDependence trainX predict 9 5 `shouldBe` PDPResult [] [] []
diff --git a/test/Hanalyze/Model/RFFSpec.hs b/test/Hanalyze/Model/RFFSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Model/RFFSpec.hs
@@ -0,0 +1,143 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Model.RFFSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Data.Vector as V
+import qualified Numeric.LinearAlgebra as LA
+import qualified System.Random.MWC as MWC
+import qualified Hanalyze.Model.RFF       as RFF
+import qualified System.Random.MWC as MWC
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Model.RFF (multivariate, Phase B-RFF)" $ do
+    it "logMarginalLikRBFMV: 既知 ℓ で最大化される (合成データで)" $ do
+      -- y = sin(x) (1D) で ℓ をスキャンし、データの z-score 後の長さスケールに
+      -- 近い値で marg-lik が最大になることを確認。
+      let xs = [0.0, 0.3 .. 6.0]
+          ys = map sin xs
+          xMat = LA.fromLists [[x] | x <- xs]
+          yV   = LA.fromList ys
+          ells = [0.05, 0.2, 0.5, 1.0, 2.0, 5.0]
+          mliks = [ RFF.logMarginalLikRBFMV xMat yV ell 1.0 0.05 | ell <- ells ]
+          best  = snd (maximum (zip mliks ells))
+      best `shouldSatisfy` (\b -> b >= 0.2 && b <= 2.0)
+    it "loocvRFFRidgeMV: λ → ∞ で残差ベース LOOCV が増える、適度な λ で最小" $ do
+      let xs = [0.0, 0.3 .. 6.0]
+          ys = map sin xs
+          xMat = LA.fromLists [[x] | x <- xs]
+          yV   = LA.fromList ys
+      gen   <- MWC.createSystemRandom
+      feats <- RFF.sampleRFFRBFMV 1 64 0.5 1.0 gen
+      let lamSmall = RFF.loocvRFFRidgeMV feats xMat yV 1e-2
+          lamHuge  = RFF.loocvRFFRidgeMV feats xMat yV 1e6
+      lamSmall `shouldSatisfy` (< lamHuge)
+    it "gridSearchLOOCVRBFMV: ℓ/λ を自動探索して LOOCV が小さくなる" $ do
+      let xs = [0.0, 0.5 .. 10.0]
+          ys = [ sin (x/2) | x <- xs ]
+          xMat = LA.fromLists [[x] | x <- xs]
+          yV   = LA.fromList ys
+      gen <- MWC.createSystemRandom
+      res <- RFF.gridSearchLOOCVRBFMV 1 100 xMat yV (Just (4, 8)) gen
+      RFF.lcLOOCV res `shouldSatisfy` (< 1.0)
+      RFF.lcEll res   `shouldSatisfy` (> 0)
+    it "maximizeMarginalLikRBFMV: 雑音ありデータで mlik が改善する" $ do
+      let xs = [0.0, 0.5 .. 10.0]
+          ys = [ sin (x/2) + 0.05 * (fromIntegral i / 21) - 0.025
+               | (i, x) <- zip [0::Int ..] xs ]
+          xMat = LA.fromLists [[x] | x <- xs]
+          yV   = LA.fromList ys
+          res  = RFF.maximizeMarginalLikRBFMV xMat yV (Just (8, 4, 4))
+      -- 最適 mlik > 任意の "ヘンな" 値 (ℓ=100, σ_n=10) より高い
+          weak = RFF.logMarginalLikRBFMV xMat yV 100 1.0 10.0
+      RFF.mlLogMlik res `shouldSatisfy` (> weak)
+      RFF.mlEll res     `shouldSatisfy` (> 0)
+      RFF.mlSigmaN res  `shouldSatisfy` (> 0)
+
+    it "rffRidgeMV: y = x1 * t を完全にフィット" $ do
+      let xs = [(x1, t) | x1 <- [1, 2, 3, 5, 7], t <- [1..10]]
+          xss = [[x1, t] | (x1, t) <- xs]
+          ys  = [x1 * t | (x1, t) <- xs]
+          xMat = LA.fromLists xss
+      gen   <- MWC.createSystemRandom
+      feats <- RFF.sampleRFFRBFMV 2 256 1.0 1.0 gen
+      let fit  = RFF.rffRidgeMV feats xMat ys 0.001
+          yhat = RFF.predictRFFRidgeMV fit xMat
+          rmse = sqrt (sum (zipWith (\a b -> (a-b)*(a-b)) ys yhat)
+                       / fromIntegral (length ys))
+      rmse `shouldSatisfy` (< 1.0)
+
+  describe "Hanalyze.Model.RFF" $ do
+    it "feature matrix has correct shape" $ do
+      gen   <- MWC.createSystemRandom
+      feats <- RFF.sampleRFFRBF 50 1.0 1.0 gen
+      RFF.rffDim feats `shouldBe` 50
+      let phi = RFF.rffFeatures feats [0.0, 1.0, 2.0]
+      -- phi is n × D = 3 × 50
+      V.length (V.fromList [0::Int]) `shouldBe` 1   -- placeholder for typing
+      -- We can't easily check matrix shape without hmatrix import here,
+      -- so just ensure the function doesn't crash.
+      length (RFF.rffOmegas feats) `shouldSatisfy` (== 50)
+      let _ = phi
+      return ()
+
+    it "RFF Ridge fits y ≈ x reasonably" $ do
+      gen   <- MWC.createSystemRandom
+      feats <- RFF.sampleRFFRBF 100 1.0 1.0 gen
+      let xs = [0.0, 0.1 .. 1.0]
+          ys = map (\x -> 2 * x + 0.5) xs
+          fit = RFF.rffRidge feats xs ys 0.001
+          yhat = RFF.predictRFFRidge fit xs
+          rmse = sqrt (sum [ (y - yh) ^ (2 :: Int)
+                           | (y, yh) <- zip ys yhat ]
+                       / fromIntegral (length ys))
+      rmse `shouldSatisfy` (< 0.5)
+
+  -- ─────────────────────────────────────────────────────────────────────
+
+  describe "Hanalyze.Model.RFF DE-based auto-HP" $ do
+    it "maximizeMarginalLikRBFMV_DE: y = sin x + noise で妥当な ℓ" $ do
+      gen <- MWC.create
+      let n = 30
+          xs = [ fromIntegral i / 5 | i <- [0 .. n - 1] ] :: [Double]
+          ys = [ sin x + 0.05 * cos (3 * x) | x <- xs ]
+          xMat = LA.fromColumns [LA.fromList xs]
+          yVec = LA.fromList ys
+      r <- RFF.maximizeMarginalLikRBFMV_DE xMat yVec 30 gen
+      -- ℓ が極端に小さくない (>1e-2) ことだけ確認
+      RFF.mlEll r `shouldSatisfy` (> 1e-2)
+
+    it "gridSearchLOOCVRBFMV_DE: LOOCV が有限値、ℓ が探索範囲内" $ do
+      gen <- MWC.create
+      let n = 25
+          xs = [ fromIntegral i / 4 | i <- [0 .. n - 1] ] :: [Double]
+          ys = [ x + 0.1 * sin (2 * x) | x <- xs ]
+          xMat = LA.fromColumns [LA.fromList xs]
+          yVec = LA.fromList ys
+      r <- RFF.gridSearchLOOCVRBFMV_DE 1 50 xMat yVec 20 gen
+      RFF.lcLOOCV r `shouldSatisfy` (\v -> not (isNaN v) && v >= 0)
+      RFF.lcEll   r `shouldSatisfy` (> 1e-3)
+
+  -- ===========================================================================
+  -- Hanalyze.Viz.ReportBuilder.secInterpolation (Phase G4)
+  -- ===========================================================================
diff --git a/test/Hanalyze/Model/RandomForestClassifierSpec.hs b/test/Hanalyze/Model/RandomForestClassifierSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Model/RandomForestClassifierSpec.hs
@@ -0,0 +1,74 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Model.RandomForestClassifierSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Data.Vector as V
+import qualified Numeric.LinearAlgebra as LA
+import qualified Hanalyze.Model.RandomForestClassifier as RFC
+import qualified Data.Vector.Unboxed        as VU
+import qualified System.Random.MWC as MWC
+import qualified System.Random.MWC as MWC
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Model.RandomForestClassifier (Phase 13.5)" $ do
+    it "fitRFClassifier: 線形分離可能 2 クラスで OOB error 低い" $ do
+      gen <- MWC.createSystemRandom
+      let n = 60
+          half = n `div` 2
+          x = LA.fromColumns
+                [ LA.fromList
+                    (replicate half 0 ++ replicate half 1)
+                , LA.fromList
+                    (replicate half 0 ++ replicate half 1)
+                ]
+          y = VU.fromList (replicate half 0 ++ replicate half 1)
+      fit <- RFC.fitRFClassifier
+               (RFC.defaultRFCConfig { RFC.rfcNTrees = 30 })
+               x y gen
+      RFC.rfcOOBError fit `shouldSatisfy` (< 0.1)
+      RFC.rfcClasses fit `shouldBe` [0, 1]
+    it "predictRFClassifier: 訓練データで自己分類精度高い" $ do
+      gen <- MWC.createSystemRandom
+      let x = LA.fromColumns [LA.fromList [0,0,0,1,1,1,2,2,2]]
+          y = VU.fromList    [0,0,0,1,1,1,2,2,2]
+      fit <- RFC.fitRFClassifier
+               (RFC.defaultRFCConfig { RFC.rfcNTrees = 20 })
+               x y gen
+      let pred_ = RFC.predictRFClassifier fit x
+          correct = length [ () | i <- [0 .. VU.length y - 1]
+                                , pred_ V.! i == y VU.! i ]
+      correct `shouldSatisfy` (>= 8)
+    it "permutation importance: ノイズ列 < 真の列" $ do
+      gen <- MWC.createSystemRandom
+      let n = 80
+          half = n `div` 2
+          xCol1 = LA.fromList (replicate half 0 ++ replicate half 1)
+          xCol2 = LA.fromList [ sin (fromIntegral i) | i <- [0 .. n - 1] ]
+          x = LA.fromColumns [xCol1, xCol2]
+          y = VU.fromList (replicate half 0 ++ replicate half 1)
+      fit <- RFC.fitRFClassifier
+               (RFC.defaultRFCConfig { RFC.rfcNTrees = 30 })
+               x y gen
+      let imp = RFC.rfcImportance fit
+      LA.atIndex imp 0 `shouldSatisfy` (>= LA.atIndex imp 1)
diff --git a/test/Hanalyze/Model/RegularizedAdvanced/AdaptiveLassoSpec.hs b/test/Hanalyze/Model/RegularizedAdvanced/AdaptiveLassoSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Model/RegularizedAdvanced/AdaptiveLassoSpec.hs
@@ -0,0 +1,77 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Model.RegularizedAdvanced.AdaptiveLassoSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Numeric.LinearAlgebra as LA
+import qualified Data.Vector.Storable              as VS
+import qualified System.Random.MWC as MWC
+import qualified Hanalyze.Model.Regularized as Reg
+import qualified System.Random.MWC as MWC
+import qualified Hanalyze.Model.RegularizedAdvanced   as RegA
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Model.RegularizedAdvanced.AdaptiveLasso (Phase 31-A1)" $ do
+    -- Sparse 合成: β_true = [3, 1.5, 0, 0, 2, 0, 0, 0]、 n=200、 p=8
+    -- Y = X β + N(0, 0.1)
+    -- Adaptive Lasso (OLS pilot 重み + γ=1) は zero 係数を完全 0 に潰す傾向
+    -- が Lasso より強い。
+    it "fitAdaptiveLasso: zero 係数を Lasso 同等以上に潰せる + non-zero は回復" $ do
+      gen <- MWC.create
+      let nA = 200
+          pA = 8
+          betaTrue = [3.0, 1.5, 0.0, 0.0, 2.0, 0.0, 0.0, 0.0] :: [Double]
+      xVals <- VS.replicateM (nA * pA) (do
+                 u1 <- MWC.uniformR (1e-9, 1.0 :: Double) gen
+                 u2 <- MWC.uniformR (0.0, 1.0 :: Double) gen
+                 pure (sqrt (-2 * log u1) * cos (2 * pi * u2)))
+      noisesA <- VS.replicateM nA (do
+                   u1 <- MWC.uniformR (1e-9, 1.0 :: Double) gen
+                   u2 <- MWC.uniformR (0.0, 1.0 :: Double) gen
+                   pure (0.1 * sqrt (-2 * log u1) * cos (2 * pi * u2)))
+      let xMat = LA.reshape pA (LA.fromList (VS.toList xVals))
+          bt   = LA.fromList betaTrue
+          yClean = xMat LA.#> bt
+          yVec   = yClean + LA.fromList (VS.toList noisesA)
+          -- OLS pilot 重み (γ=1)
+          w     = RegA.adaptiveWeightsFromOLS 1.0 xMat yVec
+          fit   = RegA.fitAdaptiveLasso 0.1 w xMat yVec 1000 1e-5
+          beta  = LA.toList (Reg.rfBeta fit)
+      -- True zero (index 2,3,5,6,7) はほぼ 0、 non-zero (0,1,4) は |β| > 0.5
+      length beta `shouldBe` pA
+      [beta !! i | i <- [0, 1, 4]] `shouldSatisfy` all (\b -> abs b > 0.5)
+      [beta !! i | i <- [2, 3, 5, 6, 7]] `shouldSatisfy` all (\b -> abs b < 0.3)
+      -- 推定 β が真値の 30% 以内 (non-zero に限る)
+      let nonZeroOK = and
+            [ abs (beta !! i - betaTrue !! i) < 0.3 * abs (betaTrue !! i)
+            | i <- [0, 1, 4] ]
+      nonZeroOK `shouldBe` True
+    it "adaptiveWeightsFromOLS: OLS 推定値が大きい列は重み小、 小は重み大" $ do
+      -- 2 column X、 β_true = [5, 0]、 noise 0 → OLS β̂ ≈ [5, 0]
+      -- → w ≈ [1/5, 1/1e-8] = [0.2, 1e8] (列 2 を強く罰)
+      let x = LA.fromLists [[1, 0], [1, 0], [0, 1], [0, 1]]
+          y = LA.fromList [5, 5, 0, 0]
+          w = RegA.adaptiveWeightsFromOLS 1.0 x y
+          ws = LA.toList w
+      ws !! 0 `shouldSatisfy` (\v -> v > 0.15 && v < 0.25)  -- ≈ 1/5
+      ws !! 1 `shouldSatisfy` (> 1e6)                       -- floor 1e-8 経由
diff --git a/test/Hanalyze/Model/RegularizedAdvanced/GroupLassoSpec.hs b/test/Hanalyze/Model/RegularizedAdvanced/GroupLassoSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Model/RegularizedAdvanced/GroupLassoSpec.hs
@@ -0,0 +1,74 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Model.RegularizedAdvanced.GroupLassoSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Numeric.LinearAlgebra as LA
+import qualified Data.Vector.Storable              as VS
+import qualified System.Random.MWC as MWC
+import qualified Hanalyze.Model.Regularized as Reg
+import qualified System.Random.MWC as MWC
+import qualified Hanalyze.Model.RegularizedAdvanced   as RegA
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Model.RegularizedAdvanced.GroupLasso (Phase 31-A4)" $ do
+    -- 3 group: G1 = [0,1,2] (active)、 G2 = [3,4,5] (inactive)、 G3 = [6,7] (active)
+    -- β_true = [3, 1, 2, 0, 0, 0, 1.5, -1]
+    -- Y = X β + N(0, 0.1)
+    it "fitGroupLasso: 不活性 group の全係数を 0、 活性 group は回復" $ do
+      gen <- MWC.create
+      let nG = 300
+          pG = 8
+          betaTrue = [3.0, 1.0, 2.0, 0.0, 0.0, 0.0, 1.5, -1.0] :: [Double]
+          groups   = [[0, 1, 2], [3, 4, 5], [6, 7]]
+      xVals <- VS.replicateM (nG * pG) (do
+                 u1 <- MWC.uniformR (1e-9, 1.0 :: Double) gen
+                 u2 <- MWC.uniformR (0.0, 1.0 :: Double) gen
+                 pure (sqrt (-2 * log u1) * cos (2 * pi * u2)))
+      noisesG <- VS.replicateM nG (do
+                   u1 <- MWC.uniformR (1e-9, 1.0 :: Double) gen
+                   u2 <- MWC.uniformR (0.0, 1.0 :: Double) gen
+                   pure (0.1 * sqrt (-2 * log u1) * cos (2 * pi * u2)))
+      let xMat = LA.reshape pG (LA.fromList (VS.toList xVals))
+          bt   = LA.fromList betaTrue
+          yVec = xMat LA.#> bt + LA.fromList (VS.toList noisesG)
+          fit  = RegA.fitGroupLasso 0.05 groups xMat yVec 1000 1e-5
+          beta = LA.toList (Reg.rfBeta fit)
+      length beta `shouldBe` pG
+      -- G2 (index 3,4,5) は全て 0 近く
+      [beta !! i | i <- [3, 4, 5]] `shouldSatisfy` all (\b -> abs b < 0.1)
+      -- G1 / G3 は活性
+      [beta !! i | i <- [0, 1, 2, 6, 7]] `shouldSatisfy` any (\b -> abs b > 0.5)
+    it "fitGroupLasso: 大 λ で全 group を 0" $ do
+      gen <- MWC.create
+      let nG = 100
+          pG = 4
+          groups = [[0, 1], [2, 3]]
+      xVals <- VS.replicateM (nG * pG) (do
+                 u1 <- MWC.uniformR (1e-9, 1.0 :: Double) gen
+                 u2 <- MWC.uniformR (0.0, 1.0 :: Double) gen
+                 pure (sqrt (-2 * log u1) * cos (2 * pi * u2)))
+      let xMat = LA.reshape pG (LA.fromList (VS.toList xVals))
+          yVec = LA.fromList [fromIntegral (i `mod` 3) | i <- [0 .. nG - 1]]
+          fit  = RegA.fitGroupLasso 100.0 groups xMat yVec 1000 1e-5
+      LA.toList (Reg.rfBeta fit) `shouldSatisfy` all (\b -> abs b < 0.05)
diff --git a/test/Hanalyze/Model/RegularizedAdvanced/MCPSpec.hs b/test/Hanalyze/Model/RegularizedAdvanced/MCPSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Model/RegularizedAdvanced/MCPSpec.hs
@@ -0,0 +1,74 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Model.RegularizedAdvanced.MCPSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Numeric.LinearAlgebra as LA
+import qualified Data.Vector.Storable              as VS
+import qualified System.Random.MWC as MWC
+import qualified Hanalyze.Model.Regularized as Reg
+import qualified System.Random.MWC as MWC
+import qualified Hanalyze.Model.RegularizedAdvanced   as RegA
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Model.RegularizedAdvanced.MCP (Phase 31-A2)" $ do
+    -- 同 sparse DGP を共有: β_true = [3, 1.5, 0, 0, 2, 0, 0, 0]、 n=200、 p=8
+    -- 標準化済 X (各列 N(0,1)) なので γ ≥ 3 で凸性条件満たす
+    let mkSparseData :: MWC.GenIO -> IO (LA.Matrix Double, LA.Vector Double, [Double])
+        mkSparseData gen = do
+          let nMCP = 200
+              pMCP = 8
+              betaTrue = [3.0, 1.5, 0.0, 0.0, 2.0, 0.0, 0.0, 0.0] :: [Double]
+          xVals <- VS.replicateM (nMCP * pMCP) (do
+                     u1 <- MWC.uniformR (1e-9, 1.0 :: Double) gen
+                     u2 <- MWC.uniformR (0.0, 1.0 :: Double) gen
+                     pure (sqrt (-2 * log u1) * cos (2 * pi * u2)))
+          noisesMCP <- VS.replicateM nMCP (do
+                         u1 <- MWC.uniformR (1e-9, 1.0 :: Double) gen
+                         u2 <- MWC.uniformR (0.0, 1.0 :: Double) gen
+                         pure (0.1 * sqrt (-2 * log u1) * cos (2 * pi * u2)))
+          let xMat = LA.reshape pMCP (LA.fromList (VS.toList xVals))
+              bt   = LA.fromList betaTrue
+              yVec = xMat LA.#> bt + LA.fromList (VS.toList noisesMCP)
+          pure (xMat, yVec, betaTrue)
+    it "fitMCP: γ=3、 中 λ で sparse 回復 + zero 係数を Lasso より強く潰す" $ do
+      gen <- MWC.create
+      (x, y, betaTrue) <- mkSparseData gen
+      let fit  = RegA.fitMCP 0.1 3.0 x y 1000 1e-5
+          beta = LA.toList (Reg.rfBeta fit)
+      length beta `shouldBe` 8
+      [beta !! i | i <- [0, 1, 4]] `shouldSatisfy` all (\b -> abs b > 0.5)
+      [beta !! i | i <- [2, 3, 5, 6, 7]] `shouldSatisfy` all (\b -> abs b < 0.2)
+      -- non-zero は真値の 20% 以内 (MCP は Lasso より bias 小)
+      and [ abs (beta !! i - betaTrue !! i) < 0.2 * abs (betaTrue !! i)
+          | i <- [0, 1, 4] ] `shouldBe` True
+    it "fitMCP: 大 λ で全 β を 0、 小 λ で OLS 近似 (連続スペクトル)" $ do
+      gen <- MWC.create
+      (x, y, _) <- mkSparseData gen
+      let bigFit  = RegA.fitMCP 100.0 3.0 x y 1000 1e-5
+          smallFit = RegA.fitMCP 1e-4 3.0 x y 1000 1e-5
+      -- 大 λ: 全係数 ≈ 0
+      LA.toList (Reg.rfBeta bigFit) `shouldSatisfy` all (\b -> abs b < 0.05)
+      -- 小 λ: 真の non-zero 係数を回復
+      let smallB = LA.toList (Reg.rfBeta smallFit)
+      [smallB !! i | i <- [0, 1, 4]] `shouldSatisfy` all (\b -> abs b > 1.0)
diff --git a/test/Hanalyze/Model/RegularizedAdvanced/SCADSpec.hs b/test/Hanalyze/Model/RegularizedAdvanced/SCADSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Model/RegularizedAdvanced/SCADSpec.hs
@@ -0,0 +1,69 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Model.RegularizedAdvanced.SCADSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Numeric.LinearAlgebra as LA
+import qualified Data.Vector.Storable              as VS
+import qualified System.Random.MWC as MWC
+import qualified Hanalyze.Model.Regularized as Reg
+import qualified System.Random.MWC as MWC
+import qualified Hanalyze.Model.RegularizedAdvanced   as RegA
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Model.RegularizedAdvanced.SCAD (Phase 31-A3)" $ do
+    let mkSparseSCAD :: MWC.GenIO -> IO (LA.Matrix Double, LA.Vector Double, [Double])
+        mkSparseSCAD gen = do
+          let nS = 200
+              pS = 8
+              betaTrue = [3.0, 1.5, 0.0, 0.0, 2.0, 0.0, 0.0, 0.0] :: [Double]
+          xVals <- VS.replicateM (nS * pS) (do
+                     u1 <- MWC.uniformR (1e-9, 1.0 :: Double) gen
+                     u2 <- MWC.uniformR (0.0, 1.0 :: Double) gen
+                     pure (sqrt (-2 * log u1) * cos (2 * pi * u2)))
+          noisesS <- VS.replicateM nS (do
+                       u1 <- MWC.uniformR (1e-9, 1.0 :: Double) gen
+                       u2 <- MWC.uniformR (0.0, 1.0 :: Double) gen
+                       pure (0.1 * sqrt (-2 * log u1) * cos (2 * pi * u2)))
+          let xMat = LA.reshape pS (LA.fromList (VS.toList xVals))
+              bt   = LA.fromList betaTrue
+              yVec = xMat LA.#> bt + LA.fromList (VS.toList noisesS)
+          pure (xMat, yVec, betaTrue)
+    it "fitSCAD: a=3.7、 中 λ で sparse 回復 + non-zero 真値の 20% 以内" $ do
+      gen <- MWC.create
+      (x, y, betaTrue) <- mkSparseSCAD gen
+      let fit  = RegA.fitSCAD 0.1 3.7 x y 1000 1e-5
+          beta = LA.toList (Reg.rfBeta fit)
+      length beta `shouldBe` 8
+      [beta !! i | i <- [0, 1, 4]] `shouldSatisfy` all (\b -> abs b > 0.5)
+      [beta !! i | i <- [2, 3, 5, 6, 7]] `shouldSatisfy` all (\b -> abs b < 0.2)
+      and [ abs (beta !! i - betaTrue !! i) < 0.2 * abs (betaTrue !! i)
+          | i <- [0, 1, 4] ] `shouldBe` True
+    it "fitSCAD: 3 領域 thresholding が連続 (λ → 0 で OLS、 λ 大で 0)" $ do
+      gen <- MWC.create
+      (x, y, _) <- mkSparseSCAD gen
+      let smallFit = RegA.fitSCAD 1e-4 3.7 x y 1000 1e-5
+          bigFit   = RegA.fitSCAD 100.0 3.7 x y 1000 1e-5
+      LA.toList (Reg.rfBeta bigFit) `shouldSatisfy` all (\b -> abs b < 0.05)
+      let sb = LA.toList (Reg.rfBeta smallFit)
+      [sb !! i | i <- [0, 1, 4]] `shouldSatisfy` all (\b -> abs b > 1.0)
diff --git a/test/Hanalyze/Model/RegularizedSpec.hs b/test/Hanalyze/Model/RegularizedSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Model/RegularizedSpec.hs
@@ -0,0 +1,84 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Model.RegularizedSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Numeric.LinearAlgebra as LA
+import qualified System.Random.MWC as MWC
+import qualified Hanalyze.Model.Regularized as Reg
+import qualified System.Random.MWC as MWC
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Model.Regularized.selectLambdaCV (Phase 4.4)" $ do
+    -- 合成データ: y = 2 x_1 + 1 x_2 + noise (x_3..x_5 は無関係)
+    let synthData :: IO (LA.Matrix Double, LA.Vector Double)
+        synthData = do
+          gen <- MWC.create
+          let n = 50; p = 5
+          xs <- LA.fromLists <$> sequence
+                  [ sequence [ MWC.uniformR (-1, 1) gen | _ <- [1..p] ]
+                  | _ <- [1..n] ]
+          let trueB = LA.fromList [2.0, 1.0, 0.0, 0.0, 0.0] :: LA.Vector Double
+              y0 = xs LA.#> trueB
+          noise <- LA.fromList <$> sequence
+                     [ MWC.uniformR (-0.1, 0.1) gen | _ <- [1..n] ]
+          pure (xs, y0 + noise)
+
+    it "Ridge: 全 λ で CV MSE が finite、 best λ が grid 内" $ do
+      (xs, y) <- synthData
+      let lambdas = [0.001, 0.01, 0.1, 1.0, 10.0]
+      gen <- MWC.create
+      sel <- Reg.selectLambdaCV 5 Reg.KindRidge lambdas xs y gen
+      Reg.lsLambdas sel `shouldBe` lambdas
+      length (Reg.lsCVScores sel) `shouldBe` length lambdas
+      all (\v -> not (isNaN v) && not (isInfinite v) && v >= 0)
+        (Reg.lsCVScores sel) `shouldBe` True
+      Reg.lsBestLambda sel `shouldSatisfy` (`elem` lambdas)
+      Reg.lsOneSeLambda sel `shouldSatisfy` (>= Reg.lsBestLambda sel)
+      Reg.lsKind sel `shouldBe` Reg.KindRidge
+
+    it "Lasso: best λ は grid 内、 各 fold で MSE finite" $ do
+      (xs, y) <- synthData
+      let lambdas = [0.001, 0.01, 0.05, 0.1, 0.5]
+      gen <- MWC.create
+      sel <- Reg.selectLambdaCV 5 Reg.KindLasso lambdas xs y gen
+      Reg.lsBestLambda sel `shouldSatisfy` (`elem` lambdas)
+      length (Reg.lsCVScores sel) `shouldBe` length lambdas
+      all (\v -> not (isNaN v) && not (isInfinite v) && v >= 0)
+        (Reg.lsCVScores sel) `shouldBe` True
+
+    it "ElasticNet α=0.5: PenaltyKind が結果に反映される" $ do
+      (xs, y) <- synthData
+      let lambdas = [0.01, 0.1, 1.0]
+      gen <- MWC.create
+      sel <- Reg.selectLambdaCV 5 (Reg.KindElasticNet 0.5) lambdas xs y gen
+      Reg.lsKind sel `shouldBe` Reg.KindElasticNet 0.5
+      length (Reg.lsCVScores sel) `shouldBe` length lambdas
+
+    it "1-SE rule: lsOneSeLambda ≥ lsBestLambda" $ do
+      (xs, y) <- synthData
+      gen <- MWC.create
+      sel <- Reg.selectLambdaCV 5 Reg.KindRidge
+                                [0.001, 0.01, 0.1, 1.0, 10.0, 100.0]
+                                xs y gen
+      Reg.lsOneSeLambda sel `shouldSatisfy` (>= Reg.lsBestLambda sel)
diff --git a/test/Hanalyze/Model/ReliabilityBlockDiagramSpec.hs b/test/Hanalyze/Model/ReliabilityBlockDiagramSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Model/ReliabilityBlockDiagramSpec.hs
@@ -0,0 +1,61 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Model.ReliabilityBlockDiagramSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Hanalyze.Model.ReliabilityBlockDiagram as RBD
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Model.ReliabilityBlockDiagram (Phase 35-A4)" $ do
+    it "Leaf: そのまま返す" $ do
+      RBD.reliabilityOf (RBD.Leaf 0.9) `shouldBe` 0.9
+    it "Series: 2 個直列 = 積" $ do
+      let r = RBD.reliabilityOf (RBD.Series [RBD.Leaf 0.9, RBD.Leaf 0.8])
+      abs (r - 0.72) `shouldSatisfy` (< 1e-12)
+    it "Parallel: 2 個並列 = 1 - (1-p₁)(1-p₂)" $ do
+      let r = RBD.reliabilityOf (RBD.Parallel [RBD.Leaf 0.9, RBD.Leaf 0.8])
+      abs (r - (1 - 0.1 * 0.2)) `shouldSatisfy` (< 1e-12)
+    it "KofN k=1 ≡ Parallel" $ do
+      let bs = [RBD.Leaf 0.7, RBD.Leaf 0.6, RBD.Leaf 0.5]
+          rK = RBD.reliabilityOf (RBD.KofN 1 bs)
+          rP = RBD.reliabilityOf (RBD.Parallel bs)
+      abs (rK - rP) `shouldSatisfy` (< 1e-12)
+    it "KofN k=n ≡ Series" $ do
+      let bs = [RBD.Leaf 0.7, RBD.Leaf 0.6, RBD.Leaf 0.5]
+          rK = RBD.reliabilityOf (RBD.KofN 3 bs)
+          rS = RBD.reliabilityOf (RBD.Series bs)
+      abs (rK - rS) `shouldSatisfy` (< 1e-12)
+    it "KofN 2-of-3 (同質 p=0.9): C(3,2)p²(1-p) + p³ = 0.972" $ do
+      let r = RBD.reliabilityOf
+                (RBD.KofN 2 [RBD.Leaf 0.9, RBD.Leaf 0.9, RBD.Leaf 0.9])
+      abs (r - 0.972) `shouldSatisfy` (< 1e-12)
+    it "ネスト: Parallel[Series[…], Series[…]]" $ do
+      let s1 = RBD.Series [RBD.Leaf 0.9, RBD.Leaf 0.95]
+          s2 = RBD.Series [RBD.Leaf 0.8, RBD.Leaf 0.85]
+          r  = RBD.reliabilityOf (RBD.Parallel [s1, s2])
+          r1 = 0.9 * 0.95
+          r2 = 0.8 * 0.85
+      abs (r - (1 - (1 - r1) * (1 - r2))) `shouldSatisfy` (< 1e-12)
+    it "KofN k > n: 0、 k ≤ 0: 1" $ do
+      RBD.reliabilityOf (RBD.KofN 5 [RBD.Leaf 0.9, RBD.Leaf 0.9]) `shouldBe` 0
+      RBD.reliabilityOf (RBD.KofN 0 [RBD.Leaf 0.9, RBD.Leaf 0.9]) `shouldBe` 1
diff --git a/test/Hanalyze/Model/ReliabilitySpec.hs b/test/Hanalyze/Model/ReliabilitySpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Model/ReliabilitySpec.hs
@@ -0,0 +1,152 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Model.ReliabilitySpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Data.Text   as T
+import qualified Hanalyze.Model.Reliability    as Rel
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Model.Reliability.fitArrhenius (Phase 2.5)" $ do
+    it "sanity: 空入力は Left" $
+      case Rel.fitArrhenius [] of
+        Left _  -> pure ()
+        Right _ -> expectationFailure "expected Left for empty"
+
+    it "sanity: 1 温度のみは Left" $
+      case Rel.fitArrhenius [(300.0, [1000, 1100, 950])] of
+        Left _  -> pure ()
+        Right _ -> expectationFailure "expected Left for single temperature"
+
+    it "sanity: 非正の温度 / 寿命は Left" $ do
+      case Rel.fitArrhenius [(300.0, [1000]), (350.0, [-500])] of
+        Left _  -> pure ()
+        Right _ -> expectationFailure "expected Left for negative lifetime"
+      case Rel.fitArrhenius [(0.0, [1000]), (350.0, [500])] of
+        Left _  -> pure ()
+        Right _ -> expectationFailure "expected Left for T=0"
+
+    it "正確データでパラメータが復元される (真 A=1e-6 s, Ea=0.7 eV)" $ do
+      let trueA  = 1e-6
+          trueEa = 0.7    -- eV
+          predict t = trueA * exp (trueEa / Rel.kBoltzmann / t)
+          temps  = [298.15, 323.15, 348.15, 373.15, 398.15]  -- K
+          input  = [ (t, [predict t]) | t <- temps ]
+      case Rel.fitArrhenius input of
+        Left e  -> expectationFailure (T.unpack e)
+        Right f -> do
+          -- 完全データなので E_a がぴったり一致
+          abs (Rel.afEa f - trueEa) `shouldSatisfy` (< 1e-9)
+          -- A は exp(b0) で recover; 数値誤差は < 0.1%
+          abs (Rel.afA f - trueA) / trueA `shouldSatisfy` (< 1e-6)
+          Rel.afN f `shouldBe` length temps
+
+    it "ノイズあり 5 温度 × 3 replicate で Ea が真値の ±15% に入る" $ do
+      let trueA  = 1e-8
+          trueEa = 0.5    -- eV
+          predict t = trueA * exp (trueEa / Rel.kBoltzmann / t)
+          temps  = [298.15, 323.15, 348.15, 373.15, 398.15]
+          -- 乗法ノイズ: ±10% を確定的に振る
+          noises = [0.95, 1.0, 1.05]
+          input  = [ (t, [predict t * f | f <- noises]) | t <- temps ]
+      case Rel.fitArrhenius input of
+        Left e  -> expectationFailure (T.unpack e)
+        Right f -> do
+          abs (Rel.afEa f - trueEa) / trueEa `shouldSatisfy` (< 0.15)
+          Rel.afN f `shouldBe` length temps * length noises
+
+    it "accelerationFactor: 試験温度の方が高ければ AF > 1" $ do
+      let trueA  = 1e-6
+          trueEa = 0.7
+          predict t = trueA * exp (trueEa / Rel.kBoltzmann / t)
+          input = [ (t, [predict t]) | t <- [298.15, 348.15, 398.15] ]
+      case Rel.fitArrhenius input of
+        Left e  -> expectationFailure (T.unpack e)
+        Right f -> do
+          -- 試験 125°C で使用 25°C
+          let af = Rel.accelerationFactor f 298.15 398.15
+          af `shouldSatisfy` (> 1)
+          -- 同じ温度なら AF = 1
+          abs (Rel.accelerationFactor f 350 350 - 1) `shouldSatisfy` (< 1e-12)
+
+  describe "Hanalyze.Model.Reliability.fitEyring (Phase 2.6)" $ do
+    it "sanity: 空入力は Left" $
+      case Rel.fitEyring [] of
+        Left _  -> pure ()
+        Right _ -> expectationFailure "expected Left"
+
+    it "sanity: 3 (T, S) ペア未満は Left" $
+      case Rel.fitEyring [(300.0, 1.0, [1000, 1100]), (350.0, 1.0, [500])] of
+        Left _  -> pure ()
+        Right _ -> expectationFailure "expected Left for < 3 distinct (T, S)"
+
+    it "正確データでパラメータ復元 (A=1e-6, Ea=0.7 eV, B=0.5)" $ do
+      let trueA  = 1e-6
+          trueEa = 0.7
+          trueB  = 0.5
+          predict t s = trueA / t * exp (trueEa / Rel.kBoltzmann / t) * exp (trueB * s)
+          tsPairs = [(t, s) | t <- [298.15, 348.15, 398.15], s <- [0.5, 1.0, 1.5]]
+          input = [ (t, s, [predict t s]) | (t, s) <- tsPairs ]
+      case Rel.fitEyring input of
+        Left e  -> expectationFailure (T.unpack e)
+        Right f -> do
+          abs (Rel.efEa f - trueEa) `shouldSatisfy` (< 1e-9)
+          abs (Rel.efB  f - trueB)  `shouldSatisfy` (< 1e-9)
+          abs (Rel.efA  f - trueA) / trueA `shouldSatisfy` (< 1e-6)
+          Rel.efN f `shouldBe` length tsPairs
+
+  describe "Hanalyze.Model.Reliability.fitInversePower (Phase 2.6)" $ do
+    it "sanity: 空入力 / 1 stress / 非正値は Left" $ do
+      case Rel.fitInversePower [] of
+        Left _ -> pure ()
+        Right _ -> expectationFailure "expected Left for empty"
+      case Rel.fitInversePower [(10.0, [1000, 1100])] of
+        Left _ -> pure ()
+        Right _ -> expectationFailure "expected Left for single stress"
+      case Rel.fitInversePower [(10.0, [1000]), (20.0, [-100])] of
+        Left _ -> pure ()
+        Right _ -> expectationFailure "expected Left for negative lifetime"
+
+    it "正確データでパラメータ復元 (A=1e6, n=2)" $ do
+      let trueA = 1e6
+          trueN = 2.0
+          predict s = trueA * s ** (- trueN)
+          stresses = [10, 20, 50, 100, 200]
+          input = [ (s, [predict s]) | s <- stresses ]
+      case Rel.fitInversePower input of
+        Left e  -> expectationFailure (T.unpack e)
+        Right f -> do
+          abs (Rel.ipfN f - trueN) `shouldSatisfy` (< 1e-9)
+          abs (Rel.ipfA f - trueA) / trueA `shouldSatisfy` (< 1e-6)
+          Rel.ipfNobs f `shouldBe` length stresses
+
+    it "ノイズあり ±5% で n が真値の ±10% 内" $ do
+      let trueA = 1e6
+          trueN = 2.0
+          predict s = trueA * s ** (- trueN)
+          stresses = [10, 20, 50, 100, 200]
+          noises = [0.95, 1.0, 1.05]
+          input = [ (s, [predict s * f | f <- noises]) | s <- stresses ]
+      case Rel.fitInversePower input of
+        Left e  -> expectationFailure (T.unpack e)
+        Right f -> abs (Rel.ipfN f - trueN) / trueN `shouldSatisfy` (< 0.10)
diff --git a/test/Hanalyze/Model/RobustSpec.hs b/test/Hanalyze/Model/RobustSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Model/RobustSpec.hs
@@ -0,0 +1,81 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Model.RobustSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Numeric.LinearAlgebra as LA
+import qualified Data.Vector.Storable              as VS
+import qualified System.Random.MWC as MWC
+import qualified System.Random.MWC as MWC
+import qualified Hanalyze.Model.Robust                as Rob
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Model.Robust (Phase 31-A5 Huber/Tukey IRLS)" $ do
+    -- 合成データ: y = 2 + 1.5·x + ε、 ε ~ N(0, 0.5)
+    -- 5% を巨大 outlier に置換 (y += 30)。 OLS は biased、 Huber/Tukey が回復
+    let mkOutlierData :: MWC.GenIO -> IO (LA.Matrix Double, LA.Vector Double)
+        mkOutlierData gen = do
+          let nR = 200
+          xs <- VS.replicateM nR (MWC.uniformR (-3.0, 3.0 :: Double) gen)
+          noisesR <- VS.replicateM nR (do
+                       u1 <- MWC.uniformR (1e-9, 1.0 :: Double) gen
+                       u2 <- MWC.uniformR (0.0, 1.0 :: Double) gen
+                       pure (0.5 * sqrt (-2 * log u1) * cos (2 * pi * u2)))
+          let yClean = VS.zipWith (\x e -> 2.0 + 1.5 * x + e) xs noisesR
+              -- 先頭 5% を outlier (y += 30)
+              nOut   = nR `div` 20
+              yWithOut = VS.imap
+                (\i v -> if i < nOut then v + 30.0 else v) yClean
+              xMat = LA.fromColumns
+                       [ LA.fromList (replicate nR 1.0)
+                       , LA.fromList (VS.toList xs) ]
+              yVec = LA.fromList (VS.toList yWithOut)
+          pure (xMat, yVec)
+    it "Huber: 5% outlier 混入で intercept / slope を真値の 15% 以内回復" $ do
+      gen <- MWC.create
+      (x, y) <- mkOutlierData gen
+      let fit  = Rob.fitRobustLM (Rob.Huber Rob.defaultHuberK) x y 50 1e-6
+          [b0, b1] = LA.toList (Rob.rfCoef fit)
+      abs (b0 - 2.0) `shouldSatisfy` (< 0.15 * 2.0)
+      abs (b1 - 1.5) `shouldSatisfy` (< 0.15 * 1.5)
+      Rob.rfConverged fit `shouldBe` True
+    it "Tukey biweight: 同 outlier で Huber 同等以上の精度 (5% 以内)" $ do
+      gen <- MWC.create
+      (x, y) <- mkOutlierData gen
+      let fit  = Rob.fitRobustLM (Rob.Tukey Rob.defaultTukeyC) x y 50 1e-6
+          [b0, b1] = LA.toList (Rob.rfCoef fit)
+      abs (b0 - 2.0) `shouldSatisfy` (< 0.05 * 2.0)
+      abs (b1 - 1.5) `shouldSatisfy` (< 0.05 * 1.5)
+      -- outlier に重み 0 が付く (Tukey の特徴)
+      let ws = LA.toList (Rob.rfWeights fit)
+          nOut = length ws `div` 20
+      take nOut ws `shouldSatisfy` all (< 0.1)
+    it "huberWeight / tukeyWeight: 単体関数の境界挙動" $ do
+      -- |u| < k で w = 1
+      Rob.huberWeight 1.345 0.5 `shouldBe` 1.0
+      -- |u| > k で w = k/|u|
+      abs (Rob.huberWeight 1.345 3.0 - 1.345 / 3.0) `shouldSatisfy` (< 1e-12)
+      -- Tukey: u = 0 で w = 1、 |u| ≥ c で w = 0
+      Rob.tukeyWeight 4.685 0.0 `shouldBe` 1.0
+      Rob.tukeyWeight 4.685 5.0 `shouldBe` 0.0
+      Rob.tukeyWeight 4.685 (-5.0) `shouldBe` 0.0
diff --git a/test/Hanalyze/Model/SVMSpec.hs b/test/Hanalyze/Model/SVMSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Model/SVMSpec.hs
@@ -0,0 +1,95 @@
+{-# LANGUAGE OverloadedStrings #-}
+module Hanalyze.Model.SVMSpec (spec) where
+
+import Test.Hspec
+import qualified Data.Vector.Unboxed   as VU
+import qualified Numeric.LinearAlgebra as LA
+import           Hanalyze.Model.SVM
+import           Hanalyze.Model.Kernel (Kernel (..), KernelParams (..), defaultKernelParams)
+
+-- 線形分離可能な 2 クラス (左下 vs 右上)。
+linData :: (LA.Matrix Double, VU.Vector Int)
+linData =
+  ( LA.fromLists [[0,0],[0,1],[1,0],[4,4],[4,5],[5,4]]
+  , VU.fromList  [0,0,0,1,1,1] )
+
+-- 同心 (XOR 風)・線形非分離: 内側 1 クラス・外側 1 クラス。
+ringData :: (LA.Matrix Double, VU.Vector Int)
+ringData =
+  ( LA.fromLists [ [0,0],[0.3,0.2],[-0.2,0.1]            -- 内側 (class 0)
+                 , [3,0],[-3,0],[0,3],[0,-3],[2.1,2.1],[-2.1,-2.1] ]  -- 外側 (class 1)
+  , VU.fromList  [0,0,0, 1,1,1,1,1,1] )
+
+trainAcc :: SVM -> LA.Matrix Double -> VU.Vector Int -> Double
+trainAcc m x y =
+  let pred = predictSVM m x
+      n    = VU.length y
+      ok   = length [ () | i <- [0 .. n - 1], pred VU.! i == y VU.! i ]
+  in fromIntegral ok / fromIntegral n
+
+-- γ=1/(2ℓ²) ゆえ γ を ℓ に変換する (共有 Kernel は ℓ ベース・Phase 75.15)。
+rbfParams :: Double -> KernelParams
+rbfParams gamma = defaultKernelParams { kpLengthScale = sqrt (1 / (2 * gamma)) }
+
+spec :: Spec
+spec = describe "Hanalyze.Model.SVM (Phase 75.11/75.15)" $ do
+  let (xl, yl) = linData
+      (xr, yr) = ringData
+
+  it "Linear カーネル: 線形分離データを訓練精度 100%" $ do
+    let m = fitSVM defaultSVM { svmKernel = Linear } xl yl
+    trainAcc m xl yl `shouldBe` 1.0
+
+  it "RBF カーネル: 同心 (線形非分離) を訓練精度 100%" $ do
+    let m = fitSVM defaultSVM
+              { svmKernel = RBF, svmParams = rbfParams 0.5, svmC = 10 } xr yr
+    trainAcc m xr yr `shouldBe` 1.0
+
+  it "サポートベクタはスパース (α>0 の点 < 全点数)" $ do
+    let m = fitSVM defaultSVM
+              { svmKernel = RBF, svmParams = rbfParams 0.5 } xr yr
+    numSupportVectors m `shouldSatisfy` (< LA.rows xr)
+    numSupportVectors m `shouldSatisfy` (> 0)
+
+  it "決定的 (同入力で 2 回 fit して SV 数一致)" $ do
+    let cfg = defaultSVM { svmKernel = RBF, svmParams = rbfParams 0.5 }
+        m1  = fitSVM cfg xr yr
+        m2  = fitSVM cfg xr yr
+    numSupportVectors m1 `shouldBe` numSupportVectors m2
+
+  it "多クラス (one-vs-rest): 3 クラスを訓練上で正しく分類" $ do
+    let x3 = LA.fromLists [[0,0],[0.2,0.1],[5,0],[5,0.3],[0,5],[0.1,5]]
+        y3 = VU.fromList [0,0,1,1,2,2]
+        mm = fitSVMMulti defaultSVM
+               { svmKernel = RBF, svmParams = rbfParams 0.3, svmC = 10 } x3 y3
+        pr = predictSVMMulti mm x3
+    VU.toList pr `shouldBe` VU.toList y3
+
+  -- Phase 75.20: k-fold CV グリッド自動調律 (tuneSVM)。
+  describe "tuneSVM (CV グリッド自動最適化・決定的)" $ do
+    -- 同心 2 クラスを十分なサンプルで生成 (CV に必要な点数を確保)。
+    let ringBig =
+          ( LA.fromLists $ inner ++ outer
+          , VU.fromList (replicate (length inner) 0 ++ replicate (length outer) 1) )
+        inner = [ [0.3 * cos t, 0.3 * sin t] | t <- angles ]
+        outer = [ [3.0 * cos t, 3.0 * sin t] | t <- angles ]
+        angles = [ 2 * pi * fromIntegral i / 12 | i <- [0 .. 11 :: Int] ]
+        (xb, yb) = ringBig
+        grid = defaultSVMTuneGrid { svmtCs = [1, 10], svmtLengths = [0.5, 1, 2], svmtFolds = 3 }
+
+    it "決定的: 同入力で 2 回呼んで同じ config/score" $ do
+      let (c1, s1) = tuneSVM defaultSVM grid xb yb
+          (c2, s2) = tuneSVM defaultSVM grid xb yb
+      svmC c1 `shouldBe` svmC c2
+      kpLengthScale (svmParams c1) `shouldBe` kpLengthScale (svmParams c2)
+      s1 `shouldBe` s2
+
+    it "非線形 (同心) で CV accuracy が高い (> 0.8) 構成を選ぶ" $ do
+      let (_best, score) = tuneSVM defaultSVM grid xb yb
+      score `shouldSatisfy` (> 0.8)
+
+    it "選ばれた config で全データ再学習すると訓練精度 100%" $ do
+      let (best, _) = tuneSVM defaultSVM grid xb yb
+          m = fitSVMMulti best xb yb
+          pr = predictSVMMulti m xb
+      VU.toList pr `shouldBe` VU.toList yb
diff --git a/test/Hanalyze/Model/StateSpaceSpec.hs b/test/Hanalyze/Model/StateSpaceSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Model/StateSpaceSpec.hs
@@ -0,0 +1,78 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Model.StateSpaceSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Numeric.LinearAlgebra as LA
+import qualified Hanalyze.Model.StateSpace     as SS
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Model.StateSpace (Phase 15)" $ do
+    let -- 1次元 random walk: x_t = x_{t-1} + w, y_t = x_t + v
+        rwModel = SS.StateSpaceModel
+          { SS.ssF  = LA.fromLists [[1]]
+          , SS.ssH  = LA.fromLists [[1]]
+          , SS.ssQ  = LA.fromLists [[0.1]]
+          , SS.ssR  = LA.fromLists [[1.0]]
+          , SS.ssX0 = LA.fromList  [0]
+          , SS.ssP0 = LA.fromLists [[1.0]]
+          }
+        -- 真の状態 0, 0.1, 0.3, 0.5, ... + ノイズ込み観測
+        obs = LA.fromLists [[0.1, 0.2, 0.4, 0.6, 0.7, 0.9, 1.1]]
+    it "kalmanFilter: T 時点で filtered list 長 = T" $ do
+      let kr = SS.kalmanFilter rwModel obs
+      length (SS.krFilteredMean kr) `shouldBe` 7
+      length (SS.krFilteredCov kr)  `shouldBe` 7
+    it "kalmanFilter: 共分散が正定値 (対角 > 0)" $ do
+      let kr = SS.kalmanFilter rwModel obs
+      all (\p -> LA.atIndex p (0, 0) > 0) (SS.krFilteredCov kr) `shouldBe` True
+    it "kalmanFilter: 観測列が長くなるほど 共分散は減少 (情報蓄積)" $ do
+      let kr = SS.kalmanFilter rwModel obs
+          covs = SS.krFilteredCov kr
+          c0 = LA.atIndex (head covs) (0, 0)
+          cT = LA.atIndex (last covs) (0, 0)
+      cT `shouldSatisfy` (< c0)
+    it "kalmanFilter: 観測トレンドに状態が追随" $ do
+      let kr = SS.kalmanFilter rwModel obs
+          last_x = LA.atIndex (last (SS.krFilteredMean kr)) 0
+      last_x `shouldSatisfy` (> 0.3)   -- 観測が 1 近辺に上がっている
+    it "kalmanFilter: 対数尤度が有限" $ do
+      let kr = SS.kalmanFilter rwModel obs
+      SS.krLogLik kr `shouldSatisfy` (\v -> not (isNaN v) && not (isInfinite v))
+    it "kalmanSmoother: smoothed list 長 = T" $ do
+      let kr = SS.kalmanSmoother rwModel (SS.kalmanFilter rwModel obs)
+      length (SS.krSmoothedMean kr) `shouldBe` 7
+      length (SS.krSmoothedCov kr)  `shouldBe` 7
+    it "kalmanSmoother: 末尾は filtered と一致" $ do
+      let kr = SS.kalmanFilter rwModel obs
+          ks = SS.kalmanSmoother rwModel kr
+      abs (LA.atIndex (last (SS.krSmoothedMean ks)) 0
+           - LA.atIndex (last (SS.krFilteredMean ks)) 0)
+        `shouldSatisfy` (< 1e-9)
+    it "kalmanSmoother: 中間点 smoothed 共分散 ≤ filtered" $ do
+      let kr = SS.kalmanFilter rwModel obs
+          ks = SS.kalmanSmoother rwModel kr
+          mid = 3
+          covF = LA.atIndex (SS.krFilteredCov ks !! mid) (0, 0)
+          covS = LA.atIndex (SS.krSmoothedCov ks !! mid) (0, 0)
+      covS `shouldSatisfy` (<= covF + 1e-9)
diff --git a/test/Hanalyze/Model/SurvivalSpec.hs b/test/Hanalyze/Model/SurvivalSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Model/SurvivalSpec.hs
@@ -0,0 +1,88 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Model.SurvivalSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Numeric.LinearAlgebra as LA
+import qualified Hanalyze.Model.Survival     as Surv
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Model.Survival" $ do
+    it "kaplanMeier: 全 event observed で S(t) は単調減少" $ do
+      let samples = [ Surv.SurvSample t Surv.Observed | t <- [1, 2, 3, 4, 5] ]
+          km = Surv.kaplanMeier samples
+      length (Surv.kmrTimes km) `shouldBe` 5
+      let ss = Surv.kmrSurvival km
+      and (zipWith (>=) ss (tail ss)) `shouldBe` True
+
+    it "kaplanMeier: S(t) は前向き累積積の実値と一致 (逆順バグの回帰)" $ do
+      -- n=5・各時刻 1 event: S = [4/5, 3/4, 2/3, 1/2, 0] の累積。
+      -- 旧実装は右から積んで最終 factor 0 が全時点を 0 に潰していた。
+      let samples = [ Surv.SurvSample t Surv.Observed | t <- [1, 2, 3, 4, 5] ]
+          ss = Surv.kmrSurvival (Surv.kaplanMeier samples)
+          expected = [0.8, 0.6, 0.4, 0.2, 0.0]
+      and (zipWith (\a b -> abs (a - b) < 1e-9) ss expected) `shouldBe` True
+
+    it "kaplanMeier: censored data でも非負の生存確率" $ do
+      let samples = [ Surv.SurvSample 1 Surv.Observed
+                    , Surv.SurvSample 2 Surv.Censored
+                    , Surv.SurvSample 3 Surv.Observed
+                    , Surv.SurvSample 4 Surv.Observed
+                    , Surv.SurvSample 5 Surv.Censored
+                    ]
+          km = Surv.kaplanMeier samples
+      all (>= 0) (Surv.kmrSurvival km) `shouldBe` True
+      all (<= 1) (Surv.kmrSurvival km) `shouldBe` True
+
+    it "nelsonAalen: 累積ハザードは monotone non-decreasing" $ do
+      let samples = [ Surv.SurvSample t Surv.Observed | t <- [1, 2, 3, 4, 5] ]
+          na = Surv.nelsonAalen samples
+          h = Surv.narCumHazard na
+      and (zipWith (<=) h (tail h)) `shouldBe` True
+
+    it "logRankTest: 同一分布で p > 0.05" $ do
+      let g1 = [ Surv.SurvSample t Surv.Observed | t <- [1, 2, 3, 4, 5] ]
+          g2 = [ Surv.SurvSample t Surv.Observed | t <- [1, 2, 3, 4, 5] ]
+          lr = Surv.logRankTest [g1, g2]
+      Surv.lrPValue lr `shouldSatisfy` (> 0.05)
+
+    it "logRankTest: 異なる分布で p < 0.05" $ do
+      let g1 = [ Surv.SurvSample t Surv.Observed | t <- [1, 2, 3, 4, 5] ]
+          g2 = [ Surv.SurvSample t Surv.Observed | t <- [10, 11, 12, 13, 14] ]
+          lr = Surv.logRankTest [g1, g2]
+      Surv.lrPValue lr `shouldSatisfy` (< 0.05)
+
+    it "coxPH: 共変量と event 時間に強い相関で β > 0" $ do
+      -- x が大きい個体が早く event を起こす想定の合成データ
+      let n = 30
+          xs = [ LA.fromList [fromIntegral i / fromIntegral n :: Double]
+               | i <- [1 .. n] ]
+          times = [ 30 - i | i <- [1 .. n] ]   -- x 大きいほど early
+          samples = [ Surv.SurvSample (fromIntegral t) Surv.Observed
+                    | t <- times ]
+          fit = Surv.coxPH xs samples
+      LA.atIndex (Surv.coxBeta fit) 0 `shouldSatisfy` (> 0)
+
+  -- ===========================================================================
+  -- Hanalyze.Stat.Interpret (Phase 13)
+  -- ===========================================================================
diff --git a/test/Hanalyze/Model/TimeSeriesSpec.hs b/test/Hanalyze/Model/TimeSeriesSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Model/TimeSeriesSpec.hs
@@ -0,0 +1,95 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Model.TimeSeriesSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Numeric.LinearAlgebra as LA
+import qualified Hanalyze.Model.TimeSeries   as TS
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Model.TimeSeries" $ do
+    it "autocorrelation: lag 0 = 1.0" $ do
+      let y = LA.fromList [1.0, 2.0, 1.5, 2.5, 1.8]
+          acf = TS.autocorrelation 5 y
+      LA.atIndex acf 0 `shouldBe` 1.0
+
+    it "autocorrelation: 周期 4 の sin 系列で lag 4 が高い" $ do
+      let y = LA.fromList [sin (2*pi*fromIntegral t / 4) | t <- [0..40::Int]]
+          acf = TS.autocorrelation 8 y
+      LA.atIndex acf 4 `shouldSatisfy` (> 0.5)
+
+    it "fitAR(1) on quasi-AR(1) data: φ̂ in (0, 1)" $ do
+      -- Quasi-AR(1) with φ=0.7 + small deterministic perturbation
+      let phi = 0.7
+          go _    0   = []
+          go prev n   =
+            let yi = phi * prev + 0.01 * sin (fromIntegral n :: Double)
+            in yi : go yi (n - 1)
+          ys  = take 100 (drop 50 (go 1.0 200))  -- burn-in 50
+          y = LA.fromList ys
+          fit = TS.fitAR 1 y
+      let phiHat = LA.atIndex (TS.arPhi fit) 0
+      -- AR(1) coefficient should be in (0, 1) for stationary positive AR
+      phiHat `shouldSatisfy` (\p -> p > 0 && p < 1)
+
+    it "forecastAR: h-step forecast 同 size" $ do
+      let y = LA.fromList [1.0, 1.5, 2.0, 2.5, 3.0, 2.5, 2.0, 1.5, 1.0, 1.5,
+                          2.0, 2.5, 3.0, 2.5, 2.0]
+          fit = TS.fitAR 2 y
+          fc = TS.forecastAR fit y 5
+      LA.size fc `shouldBe` 5
+
+    it "differencing: y' length = n - 1" $ do
+      let y = LA.fromList [1.0, 3.0, 2.0, 5.0, 4.0]
+          d1 = TS.differencing y
+      LA.size d1 `shouldBe` 4
+      LA.toList d1 `shouldBe` [2.0, -1.0, 3.0, -1.0]
+
+    it "simpleExpSmoothing: α=1 で原系列、α=0 で初期値固定" $ do
+      let y = LA.fromList [1.0, 2.0, 3.0, 4.0, 5.0]
+          s1 = TS.simpleExpSmoothing 1.0 y    -- α=1
+          s0 = TS.simpleExpSmoothing 0.0 y    -- α=0
+      LA.toList s1 `shouldBe` LA.toList y    -- exact
+      all (== 1.0) (LA.toList s0) `shouldBe` True
+
+    it "holtWinters: 線形 trend + 周期 4 を再現" $ do
+      let -- y_t = t + 5 sin(2πt/4)
+          ys = [ fromIntegral t + 3 * sin (2 * pi * fromIntegral t / 4)
+               | t <- [0 .. 39 :: Int] ]
+          y  = LA.fromList ys
+          fit = TS.holtWinters TS.HWAdditive 4 y
+          fc  = TS.hwForecast fit 4
+      LA.size fc `shouldBe` 4
+      -- Forecast at t=40,41,42,43 should be roughly 40 + sin pattern
+      LA.atIndex fc 0 `shouldSatisfy` (> 35)
+
+    it "stlDecompose: 周期成分が period 倍で繰り返す" $ do
+      let ys = [ fromIntegral t / 10 + 2 * sin (2 * pi * fromIntegral t / 4)
+               | t <- [0 .. 39 :: Int] ]
+          y  = LA.fromList ys
+          (_trend, seasonal, _resid) = TS.stlDecompose 4 y
+      LA.size seasonal `shouldBe` LA.size y
+
+  -- ===========================================================================
+  -- Hanalyze.Model.Survival (Phase 12)
+  -- ===========================================================================
diff --git a/test/Hanalyze/Model/VARSpec.hs b/test/Hanalyze/Model/VARSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Model/VARSpec.hs
@@ -0,0 +1,86 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Model.VARSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Numeric.LinearAlgebra as LA
+import qualified Hanalyze.Model.VAR            as VAR
+import qualified System.Random.MWC.Distributions as MWCD
+import qualified System.Random.MWC as MWC
+import qualified System.Random.MWC as MWC
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Model.VAR (Phase 35-A2)" $ do
+    -- 既知の VAR(1) からシミュレーション。 真の A₁ = [[0.5, 0.1], [0.2, 0.4]]
+    let simulateVAR1 gen a11 a12 a21 a22 c1 c2 n = do
+          let loop !i !y1Prev !y2Prev acc
+                | i >= n = pure (reverse acc)
+                | otherwise = do
+                    z1 <- MWCD.standard gen
+                    z2 <- MWCD.standard gen
+                    let !y1 = c1 + a11 * y1Prev + a12 * y2Prev + 0.1 * z1
+                        !y2 = c2 + a21 * y1Prev + a22 * y2Prev + 0.1 * z2
+                    loop (i + 1) y1 y2 ([y1, y2] : acc)
+          rows <- loop 0 0 0 []
+          pure (LA.fromLists rows)
+    it "fitVAR: 真の係数 (A₁) を概ね回復" $ do
+      gen <- MWC.create
+      yMat <- simulateVAR1 gen (0.5 :: Double) 0.1 0.2 0.4 0.05 (-0.03) 500
+      let fit = VAR.fitVAR 1 yMat
+          a1  = head (VAR.varCoefs fit)
+      -- 各要素 ±0.05 以内
+      abs (LA.atIndex a1 (0, 0) - 0.5) `shouldSatisfy` (< 0.1)
+      abs (LA.atIndex a1 (0, 1) - 0.1) `shouldSatisfy` (< 0.1)
+      abs (LA.atIndex a1 (1, 0) - 0.2) `shouldSatisfy` (< 0.1)
+      abs (LA.atIndex a1 (1, 1) - 0.4) `shouldSatisfy` (< 0.1)
+    it "fitVAR: varP / varK / 係数行列の数とサイズ" $ do
+      gen <- MWC.create
+      yMat <- simulateVAR1 gen (0.5 :: Double) 0.1 0.2 0.4 0 0 300
+      let fit = VAR.fitVAR 2 yMat
+      VAR.varP fit `shouldBe` 2
+      VAR.varK fit `shouldBe` 2
+      length (VAR.varCoefs fit) `shouldBe` 2
+      LA.size (VAR.varConst fit) `shouldBe` 2
+      mapM_ (\m -> LA.size m `shouldBe` (2, 2)) (VAR.varCoefs fit)
+    it "fitVAR: 残差行列 = (n - p) × K" $ do
+      gen <- MWC.create
+      yMat <- simulateVAR1 gen (0.5 :: Double) 0.1 0.2 0.4 0 0 200
+      let fit = VAR.fitVAR 3 yMat
+      LA.size (VAR.varResiduals fit) `shouldBe` (200 - 3, 2)
+    it "forecastVAR: 長さ × 次元" $ do
+      gen <- MWC.create
+      yMat <- simulateVAR1 gen (0.5 :: Double) 0.1 0.2 0.4 0 0 200
+      let fit = VAR.fitVAR 2 yMat
+          fc  = VAR.forecastVAR fit yMat 5
+      LA.size fc `shouldBe` (5, 2)
+    it "forecastVAR: 定常 VAR(1) で長期予測が平均 (≈ (I - A)⁻¹·c) に収束" $ do
+      gen <- MWC.create
+      yMat <- simulateVAR1 gen (0.5 :: Double) 0.1 0.2 0.4 0.05 (-0.03) 1000
+      let fit = VAR.fitVAR 1 yMat
+          fc  = VAR.forecastVAR fit yMat 100
+          a1  = head (VAR.varCoefs fit)
+          iK  = LA.ident 2
+          mu  = (iK - a1) LA.<\> VAR.varConst fit
+          fLast = LA.flatten (fc LA.? [99])
+      abs (LA.atIndex fLast 0 - LA.atIndex mu 0) `shouldSatisfy` (< 0.05)
+      abs (LA.atIndex fLast 1 - LA.atIndex mu 1) `shouldSatisfy` (< 0.05)
diff --git a/test/Hanalyze/Model/WeibullSpec.hs b/test/Hanalyze/Model/WeibullSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Model/WeibullSpec.hs
@@ -0,0 +1,191 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Model.WeibullSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Data.Vector as V
+import qualified Data.Text   as T
+import qualified Hanalyze.Model.Weibull        as Wei
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Model.Weibull.fitWeibullMLE (Phase 2.2)" $ do
+    let -- generate Weibull(k, λ) samples deterministically via inverse CDF
+        -- x_i = λ · (−log(1 − u_i))^(1/k), u_i uniform on (0, 1)
+        invCDFWeibull k lam u = lam * (- log (1 - u)) ** (1 / k)
+        uniforms n = [ (fromIntegral i - 0.5) / fromIntegral n | i <- [1..n] ]
+
+    it "sanity: 入力が空または非正なら Left" $ do
+      case Wei.fitWeibullMLE V.empty of
+        Left _  -> pure ()
+        Right _ -> expectationFailure "expected Left for empty"
+      case Wei.fitWeibullMLE (V.fromList [1.0, -2.0, 3.0]) of
+        Left _  -> pure ()
+        Right _ -> expectationFailure "expected Left for negative input"
+      case Wei.fitWeibullMLE (V.fromList [1.0]) of
+        Left _  -> pure ()
+        Right _ -> expectationFailure "expected Left for n=1"
+      case Wei.fitWeibullMLE (V.fromList [2.0, 2.0, 2.0]) of
+        Left _  -> pure ()
+        Right _ -> expectationFailure "expected Left for constant data"
+
+    it "k=2, λ=10 の合成データから MLE が真値の ±10% に入る" $ do
+      let trueK = 2.0
+          trueL = 10.0
+          xs    = V.fromList [ invCDFWeibull trueK trueL u | u <- uniforms 200 ]
+      case Wei.fitWeibullMLE xs of
+        Left e  -> expectationFailure (T.unpack e)
+        Right f -> do
+          Wei.wfShape f `shouldSatisfy` (\k -> abs (k - trueK) / trueK < 0.10)
+          Wei.wfScale f `shouldSatisfy` (\l -> abs (l - trueL) / trueL < 0.10)
+          Wei.wfRObs  f `shouldBe` V.length xs
+          Wei.wfN     f `shouldBe` V.length xs
+
+    it "k=1 (指数分布相当)、 λ=5 の合成データでも収束" $ do
+      let trueK = 1.0
+          trueL = 5.0
+          xs    = V.fromList [ invCDFWeibull trueK trueL u | u <- uniforms 300 ]
+      case Wei.fitWeibullMLE xs of
+        Left e  -> expectationFailure (T.unpack e)
+        Right f -> do
+          Wei.wfShape f `shouldSatisfy` (\k -> abs (k - trueK) / trueK < 0.10)
+          Wei.wfScale f `shouldSatisfy` (\l -> abs (l - trueL) / trueL < 0.10)
+
+    it "bxLife: 真値 (k=2, λ=10) で B_10 ≈ λ · (−ln 0.9)^(1/k)" $ do
+      let trueK = 2.0
+          trueL = 10.0
+          xs    = V.fromList [ invCDFWeibull trueK trueL u | u <- uniforms 200 ]
+          expectedB10 = trueL * (- log 0.9) ** (1 / trueK)
+      case Wei.fitWeibullMLE xs of
+        Left e  -> expectationFailure (T.unpack e)
+        Right f -> Wei.bxLife 0.10 f
+                     `shouldSatisfy` (\b -> abs (b - expectedB10) / expectedB10 < 0.15)
+
+    it "weibullParameterSE: 両 SE > 0 で finite" $ do
+      let trueK = 2.0
+          trueL = 10.0
+          xs    = V.fromList [ invCDFWeibull trueK trueL u | u <- uniforms 200 ]
+      case Wei.fitWeibullMLE xs of
+        Left e  -> expectationFailure (T.unpack e)
+        Right f -> do
+          let (seK, seL) = Wei.weibullParameterSE f
+          seK `shouldSatisfy` (\v -> v > 0 && not (isNaN v) && not (isInfinite v))
+          seL `shouldSatisfy` (\v -> v > 0 && not (isNaN v) && not (isInfinite v))
+
+  describe "Hanalyze.Model.Weibull.fitWeibullCensored (Phase 2.3)" $ do
+    let invCDFWeibull k lam u = lam * (- log (1 - u)) ** (1 / k)
+        uniforms n = [ (fromIntegral i - 0.5) / fromIntegral n | i <- [1..n] ]
+
+    it "sanity: 時間と delta の長さ mismatch は Left" $
+      case Wei.fitWeibullCensored
+             (V.fromList [1.0, 2.0, 3.0])
+             (V.fromList [True, False]) of
+        Left  _ -> pure ()
+        Right _ -> expectationFailure "expected Left for length mismatch"
+
+    it "sanity: failure 数が 2 未満は Left" $
+      case Wei.fitWeibullCensored
+             (V.fromList [1.0, 2.0, 3.0])
+             (V.fromList [True, False, False]) of
+        Left  _ -> pure ()
+        Right _ -> expectationFailure "expected Left for r < 2"
+
+    it "全 failure (打ち切り無し) は fitWeibullMLE と同一結果" $ do
+      let trueK = 2.0
+          trueL = 10.0
+          xs = V.fromList [ invCDFWeibull trueK trueL u | u <- uniforms 100 ]
+          ds = V.replicate (V.length xs) True
+      case (Wei.fitWeibullMLE xs, Wei.fitWeibullCensored xs ds) of
+        (Right f1, Right f2) -> do
+          abs (Wei.wfShape f1 - Wei.wfShape f2) `shouldSatisfy` (< 1e-8)
+          abs (Wei.wfScale f1 - Wei.wfScale f2) `shouldSatisfy` (< 1e-8)
+        _ -> expectationFailure "both fits should succeed"
+
+    it "Type-II 打ち切り (大きい時間を打ち切り) で λ̂ が真値に近い" $ do
+      let trueK = 2.0
+          trueL = 10.0
+          -- 200 サンプル中、 上位 30% を打ち切りに
+          allXs = [ invCDFWeibull trueK trueL u | u <- uniforms 200 ]
+          sortedXs = V.fromList (sort allXs)
+          cutoff = V.length sortedXs * 70 `div` 100
+          deltas = V.generate (V.length sortedXs) (\i -> i < cutoff)
+          -- 打ち切られた点は打ち切り時刻 = cutoff 時刻
+          censorTime = sortedXs V.! (cutoff - 1)
+          xs = V.imap (\i x -> if i < cutoff then x else censorTime) sortedXs
+      case Wei.fitWeibullCensored xs deltas of
+        Left e  -> expectationFailure (T.unpack e)
+        Right f -> do
+          Wei.wfRObs f `shouldBe` cutoff
+          Wei.wfN    f `shouldBe` V.length xs
+          -- 打ち切り 30% の loss は MLE バイアス小、 ±20% 内に収まる
+          Wei.wfShape f `shouldSatisfy` (\k -> abs (k - trueK) / trueK < 0.20)
+          Wei.wfScale f `shouldSatisfy` (\l -> abs (l - trueL) / trueL < 0.20)
+
+    it "打ち切り混在: failure < n だが r ≥ 2 で fit 成立" $ do
+      -- 故障時間 [1, 2, 3, 4]、 打ち切り時間 [5, 5] (右打ち切り)
+      let xs     = V.fromList [1.0, 2.0, 3.0, 4.0, 5.0, 5.0]
+          deltas = V.fromList [True, True, True, True, False, False]
+      case Wei.fitWeibullCensored xs deltas of
+        Left e  -> expectationFailure (T.unpack e)
+        Right f -> do
+          Wei.wfRObs f `shouldBe` 4
+          Wei.wfN    f `shouldBe` 6
+          Wei.wfShape f `shouldSatisfy` (> 0)
+          Wei.wfScale f `shouldSatisfy` (> 0)
+
+  describe "Hanalyze.Model.Weibull.bxLifeCI + covariance (Phase 2.4)" $ do
+    let invCDFWeibull k lam u = lam * (- log (1 - u)) ** (1 / k)
+        uniforms n = [ (fromIntegral i - 0.5) / fromIntegral n | i <- [1..n] ]
+        synthFit = case Wei.fitWeibullMLE
+                         (V.fromList [ invCDFWeibull 2.0 10.0 u | u <- uniforms 200 ]) of
+                     Right f -> f
+                     Left  e -> error (T.unpack e)
+
+    it "weibullParameterCovariance: var > 0、 cov is finite" $ do
+      let (vK, cKL, vL) = Wei.weibullParameterCovariance synthFit
+      vK  `shouldSatisfy` (> 0)
+      vL  `shouldSatisfy` (> 0)
+      cKL `shouldSatisfy` (\v -> not (isNaN v) && not (isInfinite v))
+
+    it "weibullParameterSE と Covariance の対角 sqrt が一致" $ do
+      let (vK, _, vL) = Wei.weibullParameterCovariance synthFit
+          (sK, sL)    = Wei.weibullParameterSE synthFit
+      abs (sK - sqrt vK) `shouldSatisfy` (< 1e-12)
+      abs (sL - sqrt vL) `shouldSatisfy` (< 1e-12)
+
+    it "bxLifeCI: estimate は bxLife と一致、 lower ≤ estimate ≤ upper" $ do
+      let (est, lo, hi) = Wei.bxLifeCI 0.10 0.05 synthFit
+          bp = Wei.bxLife 0.10 synthFit
+      abs (est - bp) `shouldSatisfy` (< 1e-12)
+      lo `shouldSatisfy` (<= est)
+      est `shouldSatisfy` (<= hi)
+      lo `shouldSatisfy` (>= 0)  -- lifetimes are non-negative
+
+    it "bxLifeCI: 95% CI が 99% CI より狭い" $ do
+      let (_, lo95, hi95) = Wei.bxLifeCI 0.10 0.05 synthFit
+          (_, lo99, hi99) = Wei.bxLifeCI 0.10 0.01 synthFit
+      (hi95 - lo95) `shouldSatisfy` (< hi99 - lo99)
+
+    it "bxLifeCI: 真値 B_10 が 95% CI に入る (200 サンプル)" $ do
+      let trueB10 = 10.0 * (- log 0.9) ** (1 / 2.0)
+          (_, lo, hi) = Wei.bxLifeCI 0.10 0.05 synthFit
+      trueB10 `shouldSatisfy` (\b -> b >= lo && b <= hi)
diff --git a/test/Hanalyze/Optim/BayesOptSpec.hs b/test/Hanalyze/Optim/BayesOptSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Optim/BayesOptSpec.hs
@@ -0,0 +1,46 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Optim.BayesOptSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified System.Random.MWC as MWC
+import qualified Hanalyze.Optim.BayesOpt    as BO
+import qualified System.Random.MWC as MWC
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Optim.BayesOpt (acquisition optimizer swap)" $ do
+    it "bayesOpt 1D: Brent 内側で簡単な凸関数 (x-1.5)^2 を見つける" $ do
+      gen <- MWC.create
+      let cfg = BO.defaultBayesOptConfig
+                  { BO.boIterations = 8
+                  , BO.boInitPoints = 4
+                  , BO.boGridSize   = 32
+                  }
+          target x = pure ((x - 1.5)^(2::Int) :: Double)
+      (_, (xb, _)) <- BO.bayesOpt cfg target (0, 3) gen
+      -- 8 反復では精度は緩めに。3.0 範囲のうち 0.5 以内に収束を期待
+      abs (xb - 1.5) `shouldSatisfy` (< 0.5)
+
+  -- ===========================================================================
+  -- RFF HP 自動チューニングの DE 版 (Phase O9)
+  -- ===========================================================================
diff --git a/test/Hanalyze/Optim/CMAESSpec.hs b/test/Hanalyze/Optim/CMAESSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Optim/CMAESSpec.hs
@@ -0,0 +1,66 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Optim.CMAESSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified System.Random.MWC as MWC
+import qualified Hanalyze.Optim.CMAES       as CMAES
+import qualified Hanalyze.Optim.CMAESFull   as CMAESF
+import qualified Hanalyze.Optim.Common      as OC
+import qualified System.Random.MWC as MWC
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Optim.CMAES" $ do
+    let sphere xs = sum [x*x | x <- xs]
+        l2 a b = sqrt (sum (zipWith (\x y -> (x-y)^(2::Int)) a b))
+
+    it "CMA-ES: sphere 5D を最小化、原点近傍に到達" $ do
+      gen <- MWC.create
+      let cfg = CMAES.defaultCMAESConfig
+                  { CMAES.cmStop = OC.defaultStopCriteria { OC.stMaxIter = 200 }
+                  , CMAES.cmSigma0 = 1.0 }
+      r <- CMAES.runCMAESWith cfg sphere [3, -2, 1, 0.5, -1.5] gen
+      l2 (OC.orBest r) [0,0,0,0,0] `shouldSatisfy` (< 0.5)
+
+    it "CMA-ES Full: sphere 5D で 1e-3 以内に到達" $ do
+      gen <- MWC.create
+      let cfg = CMAESF.defaultCMAESFConfig
+                  { CMAESF.cmfStop = OC.defaultStopCriteria { OC.stMaxIter = 300 }
+                  , CMAESF.cmfSigma0 = 1.0 }
+      r <- CMAESF.runCMAESFullWith cfg sphere [3, -2, 1, 0.5, -1.5] gen
+      OC.orValue r `shouldSatisfy` (< 1e-3)
+
+    it "CMA-ES Full: Rosenbrock 2D で (1,1) に 0.1 以内" $ do
+      gen <- MWC.create
+      let cfg = CMAESF.defaultCMAESFConfig
+                  { CMAESF.cmfStop   = OC.defaultStopCriteria { OC.stMaxIter = 500 }
+                  , CMAESF.cmfSigma0 = 0.5
+                  , CMAESF.cmfLambda = Just 20 }
+          rosen [x, y] = (1-x)^(2::Int) + 100 * (y - x*x)^(2::Int)
+          rosen _      = error "2D"
+      r <- CMAESF.runCMAESFullWith cfg rosen [-1.2, 1.0] gen
+      l2 (OC.orBest r) [1, 1] `shouldSatisfy` (< 0.1)
+
+  -- ===========================================================================
+  -- メタヒューリスティック (Tier 2: Simulated Annealing, PSO)
+  -- ===========================================================================
diff --git a/test/Hanalyze/Optim/CommonSpec.hs b/test/Hanalyze/Optim/CommonSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Optim/CommonSpec.hs
@@ -0,0 +1,82 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Optim.CommonSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified System.Random.MWC as MWC
+import qualified Hanalyze.Optim.NelderMead  as NM
+import qualified Hanalyze.Optim.LBFGS       as LBFGS
+import qualified Hanalyze.Optim.CMAES       as CMAES
+import qualified Hanalyze.Optim.CMAESFull   as CMAESF
+import qualified Hanalyze.Optim.Common      as OC
+import qualified System.Random.MWC as MWC
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Optim.Common box constraints" $ do
+    it "clipToBounds: 範囲外を反射、範囲内はそのまま" $ do
+      OC.clipToBounds [(0,10)] [5]    `shouldBe` [5]
+      OC.clipToBounds [(0,10)] [-2]   `shouldBe` [2]    -- 反射
+      OC.clipToBounds [(0,10)] [12]   `shouldBe` [8]
+
+    it "boundsPenalty: 範囲内 0、範囲外で大きい正値" $ do
+      OC.boundsPenalty (Just [(0,10)]) [5]    `shouldBe` 0
+      OC.boundsPenalty Nothing         [-99]  `shouldBe` 0
+      OC.boundsPenalty (Just [(0,10)]) [-1]   `shouldSatisfy` (> 1e5)
+
+    it "NelderMead: nmBounds で box 内に解が収まる" $ do
+      let f xs = (head xs - 5)^(2::Int)   -- 真の最小は x=5
+          cfg = NM.defaultNMConfig { NM.nmBounds = Just [(0, 2)] }
+      r <- NM.runNelderMeadWith cfg f [1.0]
+      head (OC.orBest r) `shouldSatisfy` (\v -> v >= -0.1 && v <= 2.1)
+
+    it "LBFGS: lbBounds で box 内に解が収まる" $ do
+      let f xs = (head xs - 5)^(2::Int)
+          cfg = LBFGS.defaultLBFGSConfig { LBFGS.lbBounds = Just [(0, 2)] }
+      r <- LBFGS.runLBFGSNumeric cfg f [1.0]
+      head (OC.orBest r) `shouldSatisfy` (\v -> v >= -0.1 && v <= 2.1)
+
+    it "CMAES (簡易版): cmBounds で box 内サンプル" $ do
+      gen <- MWC.create
+      let f xs = sum [x*x | x <- xs]
+          cfg = CMAES.defaultCMAESConfig
+                  { CMAES.cmBounds = Just [(-1, 1), (-1, 1)]
+                  , CMAES.cmStop   = (CMAES.cmStop CMAES.defaultCMAESConfig)
+                                       { OC.stMaxIter = 80 }
+                  }
+      r <- CMAES.runCMAESWith cfg f [0.5, 0.5] gen
+      all (\v -> v >= -1.05 && v <= 1.05) (OC.orBest r) `shouldBe` True
+
+    it "CMAESFull: cmfBounds で box 内サンプル" $ do
+      gen <- MWC.create
+      let f xs = sum [x*x | x <- xs]
+          cfg = CMAESF.defaultCMAESFConfig
+                  { CMAESF.cmfBounds = Just [(-1, 1), (-1, 1)]
+                  , CMAESF.cmfStop   = (CMAESF.cmfStop CMAESF.defaultCMAESFConfig)
+                                         { OC.stMaxIter = 80 }
+                  }
+      r <- CMAESF.runCMAESFullWith cfg f [0.5, 0.5] gen
+      all (\v -> v >= -1.05 && v <= 1.05) (OC.orBest r) `shouldBe` True
+
+  -- ===========================================================================
+  -- Bayesian Optimization 内部最適化の差し替え (Hanalyze.Optim.BayesOpt)
+  -- ===========================================================================
diff --git a/test/Hanalyze/Optim/ConstrainedSpec.hs b/test/Hanalyze/Optim/ConstrainedSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Optim/ConstrainedSpec.hs
@@ -0,0 +1,75 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Optim.ConstrainedSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Hanalyze.Optim.Constrained as Con
+import qualified Hanalyze.Optim.Common      as OC
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Optim.Constrained" $ do
+    it "Augmented Lagrangian: min x1²+x2² s.t. x1+x2=1 → (0.5, 0.5)" $ do
+      let f xs = (head xs)^(2::Int) + (xs !! 1)^(2::Int)
+          cs = Con.ConstraintSet
+                 { Con.csEq   = [\xs -> head xs + xs !! 1 - 1]
+                 , Con.csIneq = []
+                 }
+      (r, viol) <- Con.runAugmentedLagrangian
+                     Con.defaultConstrainedConfig f cs [0, 0]
+      let [x1, x2] = OC.orBest r
+      abs (x1 - 0.5) `shouldSatisfy` (< 0.05)
+      abs (x2 - 0.5) `shouldSatisfy` (< 0.05)
+      viol `shouldSatisfy` (< 1e-3)
+
+    it "Augmented Lagrangian: 不等式 x ≥ 1 (h(x)=1-x≤0) で min x²" $ do
+      let f xs  = (head xs)^(2::Int)
+          cs   = Con.ConstraintSet
+                 { Con.csEq   = []
+                 , Con.csIneq = [\xs -> 1 - head xs]    -- 1 - x ≤ 0 ⇔ x ≥ 1
+                 }
+      (r, viol) <- Con.runAugmentedLagrangian
+                     Con.defaultConstrainedConfig f cs [0]
+      let [x] = OC.orBest r
+      x `shouldSatisfy` (\v -> v >= 1 - 0.01)
+      viol `shouldSatisfy` (< 1e-3)
+
+    it "Penalty method: 等式 x1+x2=1 (簡易版)" $ do
+      let f xs = (head xs)^(2::Int) + (xs !! 1)^(2::Int)
+          cs = Con.ConstraintSet
+                 { Con.csEq   = [\xs -> head xs + xs !! 1 - 1]
+                 , Con.csIneq = []
+                 }
+      (r, _) <- Con.penaltyMethod Con.defaultConstrainedConfig f cs [0, 0]
+      let [x1, x2] = OC.orBest r
+      abs (x1 + x2 - 1) `shouldSatisfy` (< 0.05)
+
+    it "boxToIneq: bounds [(1,5)] を不等式 2 本に展開、x=3 で両方満たす" $ do
+      let ineqs = Con.boxToIneq [(1.0, 5.0)]
+      length ineqs `shouldBe` 2
+      all (\h -> h [3.0] <= 0) ineqs `shouldBe` True
+      any (\h -> h [0.0] > 0) ineqs `shouldBe` True   -- 下限違反
+      any (\h -> h [6.0] > 0) ineqs `shouldBe` True   -- 上限違反
+
+  -- ===========================================================================
+  -- box 制約 (Bounds) 統一インターフェース (Hanalyze.Optim.Common)
+  -- ===========================================================================
diff --git a/test/Hanalyze/Optim/DifferentialEvolutionSpec.hs b/test/Hanalyze/Optim/DifferentialEvolutionSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Optim/DifferentialEvolutionSpec.hs
@@ -0,0 +1,57 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Optim.DifferentialEvolutionSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified System.Random.MWC as MWC
+import qualified Hanalyze.Optim.DifferentialEvolution as DE
+import qualified Hanalyze.Optim.Common      as OC
+import qualified System.Random.MWC as MWC
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Optim.DifferentialEvolution" $ do
+    let sphere xs = sum [x*x | x <- xs]
+        rastrigin xs =
+          10 * fromIntegral (length xs) +
+          sum [x*x - 10 * cos (2 * pi * x) | x <- xs]
+        l2 a b = sqrt (sum (zipWith (\x y -> (x-y)^(2::Int)) a b))
+
+    it "DE: sphere 5D が原点付近に到達" $ do
+      gen <- MWC.create
+      let bs = replicate 5 (-5, 5)
+          cfg = (DE.defaultDEConfig bs)
+                  { DE.deStop = OC.defaultStopCriteria { OC.stMaxIter = 200 } }
+      r <- DE.runDEWith cfg sphere gen
+      OC.orValue r `shouldSatisfy` (< 1e-3)
+
+    it "DE: Rastrigin 3D の大域最小 (原点) を見つける" $ do
+      gen <- MWC.create
+      let bs = replicate 3 (-5.12, 5.12)
+          cfg = (DE.defaultDEConfig bs)
+                  { DE.deStop = OC.defaultStopCriteria { OC.stMaxIter = 400 } }
+      r <- DE.runDEWith cfg rastrigin gen
+      l2 (OC.orBest r) [0, 0, 0] `shouldSatisfy` (< 0.5)
+
+  -- ===========================================================================
+  -- 大域オプティマイザ (Hanalyze.Optim.CMAES、簡易対角版)
+  -- ===========================================================================
diff --git a/test/Hanalyze/Optim/LBFGSSpec.hs b/test/Hanalyze/Optim/LBFGSSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Optim/LBFGSSpec.hs
@@ -0,0 +1,58 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Optim.LBFGSSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Hanalyze.Optim.LBFGS       as LBFGS
+import qualified Hanalyze.Optim.Common      as OC
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Optim.LBFGS" $ do
+    let l2 :: [Double] -> [Double] -> Double
+        l2 a b = sqrt (sum (zipWith (\x y -> (x-y)^(2::Int)) a b))
+        sphere xs = sum [x*x | x <- xs]
+        sphereGrad xs = [2*x | x <- xs]
+        rosen [x, y] = (1-x)^(2::Int) + 100*(y - x*x)^(2::Int)
+        rosen _ = error "rosen: 2D"
+        rosenGrad [x, y] =
+          [ -2*(1-x) - 400*x*(y - x*x), 200*(y - x*x) ]
+        rosenGrad _ = error "rosenGrad: 2D"
+
+    it "minimises sphere 5D with analytic grad to ~0" $ do
+      r <- LBFGS.runLBFGS sphere sphereGrad [3, -2, 1, 0.5, -1.5]
+      OC.orValue r `shouldSatisfy` (< 1e-8)
+
+    it "minimises Rosenbrock 2D within 0.01 of (1,1)" $ do
+      let cfg = LBFGS.defaultLBFGSConfig
+                  { LBFGS.lbStop = OC.defaultStopCriteria { OC.stMaxIter = 500 } }
+      r <- LBFGS.runLBFGSWith cfg rosen rosenGrad [-1.2, 1.0]
+      l2 (OC.orBest r) [1, 1] `shouldSatisfy` (< 0.01)
+
+    it "numeric gradient: sphere 30D converges" $ do
+      let x0 = take 30 (cycle [1.5, -2.0, 0.5])
+      r <- LBFGS.runLBFGSNumeric LBFGS.defaultLBFGSConfig sphere x0
+      OC.orValue r `shouldSatisfy` (< 1e-4)
+
+  -- ===========================================================================
+  -- 1D オプティマイザ (Hanalyze.Optim.LineSearch)
+  -- ===========================================================================
diff --git a/test/Hanalyze/Optim/LineSearchSpec.hs b/test/Hanalyze/Optim/LineSearchSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Optim/LineSearchSpec.hs
@@ -0,0 +1,61 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Optim.LineSearchSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Data.Vector as V
+import qualified Hanalyze.Model.KernelRegression      as K
+import qualified Hanalyze.Optim.LineSearch  as LS
+import qualified Hanalyze.Optim.Common      as OC
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Optim.LineSearch" $ do
+    let parabola [x] = (x - 2.5)^(2::Int) + 1.0
+        parabola _   = error "1D"
+        cosBowl [x] = cos x + 0.1 * x * x   -- 単峰、最小 ≈ 1.428 付近
+        cosBowl _   = error "1D"
+
+    it "Brent: parabola minimum at x = 2.5" $ do
+      let r = LS.brent LS.defaultBrentConfig parabola 0 5
+      abs (head (OC.orBest r) - 2.5) `shouldSatisfy` (< 1e-5)
+      OC.orValue r `shouldSatisfy` (\v -> abs (v - 1) < 1e-8)
+
+    it "Brent: cos x + 0.1 x² minimum (verified by GS)" $ do
+      let rB = LS.brent LS.defaultBrentConfig cosBowl 0 4
+          rG = LS.goldenSection OC.Minimize cosBowl 0 4 1e-8 200
+      abs (head (OC.orBest rB) - head (OC.orBest rG)) `shouldSatisfy` (< 1e-3)
+
+    it "GoldenSection: parabola minimum at x = 2.5" $ do
+      let r = LS.goldenSection OC.Minimize parabola 0 5 1e-7 200
+      abs (head (OC.orBest r) - 2.5) `shouldSatisfy` (< 1e-3)
+
+    it "Kernel.autoBandwidthBrent: 同じ最適 h を grid 法とほぼ一致" $ do
+      let xs = V.fromList [0.0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
+          ys = V.map (\x -> sin x + 0.1 * x) xs
+          (hG, _) = K.gridSearchBandwidth K.Gaussian xs ys [0.5, 0.8, 1.0, 1.5, 2.0, 3.0]
+          (hB, _) = K.autoBandwidthBrent K.Gaussian xs ys 0.3 4.0
+      abs (hB - hG) `shouldSatisfy` (< 1.0)   -- グリッドと近い領域
+
+  -- ===========================================================================
+  -- 大域オプティマイザ (Hanalyze.Optim.DifferentialEvolution)
+  -- ===========================================================================
diff --git a/test/Hanalyze/Optim/NSGASpec.hs b/test/Hanalyze/Optim/NSGASpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Optim/NSGASpec.hs
@@ -0,0 +1,212 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Optim.NSGASpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Numeric.LinearAlgebra as LA
+import qualified Hanalyze.Optim.NSGA           as NSGAP3
+import qualified Hanalyze.Optim.NSGA        as NSGA
+import qualified System.Random.MWC as MWC
+import qualified System.Random.MWC as MWC
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Optim.NSGA building blocks" $ do
+    let mkSol obj = NSGA.Solution
+                      { NSGA.solDecision   = obj   -- decision unused here
+                      , NSGA.solObjectives = obj
+                      , NSGA.solViolation  = 0
+                      }
+        s00 = mkSol [0.0, 0.0]   -- dominated by no one
+        s11 = mkSol [1.0, 1.0]   -- dominated by s00
+        s05 = mkSol [0.5, 0.5]
+        sa  = mkSol [0.0, 1.0]   -- non-comparable with sb
+        sb  = mkSol [1.0, 0.0]
+
+    it "dominates: zero-violation pair uses paretoDominates" $ do
+      NSGA.dominates s00 s11 `shouldBe` True
+      NSGA.dominates s11 s00 `shouldBe` False
+      NSGA.dominates sa  sb  `shouldBe` False
+      NSGA.dominates sb  sa  `shouldBe` False
+
+    it "nonDominatedSort: 3-point chain returns 3 fronts" $ do
+      -- s00 ≻ s05 ≻ s11
+      let fronts = NSGA.nonDominatedSort [s11, s05, s00]
+      length fronts `shouldBe` 3
+      length (head fronts) `shouldBe` 1   -- F_1 = {s00}
+
+    it "crowdingDistance: 2-point front keeps both (≤2 → ∞)" $
+      length (NSGA.crowdingDistance [sa, sb]) `shouldBe` 2
+
+    it "crowdingDistance: 3-point front returns all 3 (∞ endpoints + 1 mid)" $ do
+      let f3     = [mkSol [0.0, 1.0], mkSol [0.5, 0.5], mkSol [1.0, 0.0]]
+          sorted = NSGA.crowdingDistance f3
+      length sorted `shouldBe` 3
+
+    it "dominationMatrix matches per-pair dominates on a 3-individual pop" $ do
+      let s00 = NSGA.Solution [0, 0] [0.0, 0.0] 0   -- best
+          s11 = NSGA.Solution [1, 1] [1.0, 1.0] 0   -- worst
+          s05 = NSGA.Solution [0.5, 0.5] [0.5, 0.5] 0  -- middle
+          pop  = [s00, s05, s11]
+          pm   = NSGA.fromSolutions pop
+          mDom = NSGA.dominationMatrix pm
+          n    = length pop
+          ref  = LA.fromLists
+                   [ [ if i == j then 0
+                       else if NSGA.dominates (pop !! i) (pop !! j) then 1
+                       else if NSGA.dominates (pop !! j) (pop !! i) then -1
+                       else 0
+                     | j <- [0 .. n - 1] ]
+                   | i <- [0 .. n - 1] ]
+                   :: LA.Matrix Double
+      LA.norm_Inf (mDom - ref) `shouldBe` 0
+
+    it "polynomialMutation: respects bounds and pMut=0 keeps x" $ do
+      gen <- MWC.createSystemRandom
+      let bs = [(0, 1), (0, 1), (0, 1)] :: [(Double, Double)]
+      x' <- NSGA.polynomialMutation 20 0 bs [0.5, 0.5, 0.5] gen
+      x' `shouldBe` [0.5, 0.5, 0.5]
+      y' <- NSGA.polynomialMutation 20 1.0 bs [0.5, 0.5, 0.5] gen
+      all (\(z, (lo, hi)) -> z >= lo && z <= hi) (zip y' bs)
+        `shouldBe` True
+
+  describe "Hanalyze.Optim.NSGA.nsga2AllFronts (Phase 3.2)" $ do
+    -- ZDT1 (2D decision): f1 = x1、 g = 1 + 9·x2、 f2 = g·(1 − √(f1/g))。
+    -- x2 > 0 が dominated 解を生む → rank ≥ 1 を確実に残せる。
+    let zdt1 :: [Double] -> [Double]
+        zdt1 xs = case xs of
+          [x1, x2] ->
+            let g  = 1 + 9 * x2
+                f1 = x1
+                f2 = g * (1 - sqrt (max 1e-12 (f1 / g)))
+            in [f1, f2]
+          _ -> [0, 0]
+        bounds2 = [(0.0, 1.0), (0.0, 1.0)]
+        cfg = NSGAP3.defaultNSGAConfig
+          { NSGAP3.nsgaPopSize     = 40
+          , NSGAP3.nsgaGenerations = 5     -- 少世代で dominated 解を残す
+          }
+
+    it "nsga2AllFronts は最低 1 front (rank 0) を返す" $ do
+      gen <- MWC.create
+      fronts <- NSGAP3.nsga2AllFronts cfg zdt1 bounds2 gen
+      length fronts `shouldSatisfy` (>= 1)
+      head fronts `shouldSatisfy` (not . null)
+
+    it "front の総 Solution 数 = 母集団サイズ" $ do
+      gen <- MWC.create
+      fronts <- NSGAP3.nsga2AllFronts cfg zdt1 bounds2 gen
+      sum (map length fronts) `shouldBe` NSGAP3.nsgaPopSize cfg
+
+    it "既存 nsga2 の結果と nsga2AllFronts の rank 0 が同一 (同じ seed)" $ do
+      gen1 <- MWC.create
+      front0_legacy <- NSGAP3.nsga2 cfg zdt1 bounds2 gen1
+      gen2 <- MWC.create
+      fronts_new   <- NSGAP3.nsga2AllFronts cfg zdt1 bounds2 gen2
+      map NSGAP3.solDecision front0_legacy
+        `shouldBe` map NSGAP3.solDecision (head fronts_new)
+
+    it "rank ≥ 1 にアクセスできる (ZDT1 で dominated 解が必ず残る)" $ do
+      gen <- MWC.create
+      fronts <- NSGAP3.nsga2AllFronts cfg zdt1 bounds2 gen
+      length fronts `shouldSatisfy` (>= 2)
+
+    it "take (k+1) fronts で rank フィルタ" $ do
+      gen <- MWC.create
+      fronts <- NSGAP3.nsga2AllFronts cfg zdt1 bounds2 gen
+      let top2 = take 2 fronts
+      length top2 `shouldSatisfy` (<= 2)
+      length top2 `shouldSatisfy` (>= 1)
+
+    it "constrained 版 nsga2AllFrontsWithConstraints も動作" $ do
+      gen <- MWC.create
+      let constraint xs = case xs of
+            [a, b] -> max 0 (a + b - 1.5)
+            _      -> 0
+      fronts <- NSGAP3.nsga2AllFrontsWithConstraints cfg zdt1 constraint bounds2 gen
+      length fronts `shouldSatisfy` (>= 1)
+      head fronts `shouldSatisfy` (not . null)
+
+  describe "Hanalyze.Optim.NSGA.nsga2WithProgress (Phase 3.3)" $ do
+    let zdt1 :: [Double] -> [Double]
+        zdt1 xs = case xs of
+          [x1, x2] ->
+            let g  = 1 + 9 * x2
+                f1 = x1
+                f2 = g * (1 - sqrt (max 1e-12 (f1 / g)))
+            in [f1, f2]
+          _ -> [0, 0]
+        bounds2 = [(0.0, 1.0), (0.0, 1.0)]
+        cfg = NSGAP3.defaultNSGAConfig
+          { NSGAP3.nsgaPopSize     = 30
+          , NSGAP3.nsgaGenerations = 8
+          }
+
+    it "callback が ngpTotal 回呼ばれる" $ do
+      eventsRef <- newIORef []
+      gen <- MWC.create
+      _ <- NSGAP3.nsga2WithProgress cfg zdt1 bounds2
+             (\p -> modifyIORef' eventsRef (p :)) gen
+      events <- reverse <$> readIORef eventsRef
+      length events `shouldBe` NSGAP3.nsgaGenerations cfg
+
+    it "ngpGeneration は 0..total-1 で順番に並ぶ" $ do
+      eventsRef <- newIORef []
+      gen <- MWC.create
+      _ <- NSGAP3.nsga2WithProgress cfg zdt1 bounds2
+             (\p -> modifyIORef' eventsRef (p :)) gen
+      events <- reverse <$> readIORef eventsRef
+      map NSGAP3.ngpGeneration events
+        `shouldBe` [0 .. NSGAP3.nsgaGenerations cfg - 1]
+
+    it "ngpTotal は全 event で同値" $ do
+      eventsRef <- newIORef []
+      gen <- MWC.create
+      _ <- NSGAP3.nsga2WithProgress cfg zdt1 bounds2
+             (\p -> modifyIORef' eventsRef (p :)) gen
+      events <- readIORef eventsRef
+      length (nub (map NSGAP3.ngpTotal events)) `shouldBe` 1
+      head (map NSGAP3.ngpTotal events) `shouldBe` NSGAP3.nsgaGenerations cfg
+
+    it "ngpParetoSize > 0 (= rank 0 が常に非空)" $ do
+      eventsRef <- newIORef []
+      gen <- MWC.create
+      _ <- NSGAP3.nsga2WithProgress cfg zdt1 bounds2
+             (\p -> modifyIORef' eventsRef (p :)) gen
+      events <- readIORef eventsRef
+      all ((> 0) . NSGAP3.ngpParetoSize) events `shouldBe` True
+
+    it "ngpBestObjs の length = 目的次元数 (= 2 for ZDT1)" $ do
+      eventsRef <- newIORef []
+      gen <- MWC.create
+      _ <- NSGAP3.nsga2WithProgress cfg zdt1 bounds2
+             (\p -> modifyIORef' eventsRef (p :)) gen
+      events <- readIORef eventsRef
+      all ((== 2) . length . NSGAP3.ngpBestObjs) events `shouldBe` True
+
+    it "既存 nsga2 と nsga2WithProgress (no-op callback) は同じ Pareto を返す" $ do
+      gen1 <- MWC.create
+      front_legacy <- NSGAP3.nsga2 cfg zdt1 bounds2 gen1
+      gen2 <- MWC.create
+      front_prog   <- NSGAP3.nsga2WithProgress cfg zdt1 bounds2 (\_ -> pure ()) gen2
+      map NSGAP3.solDecision front_legacy
+        `shouldBe` map NSGAP3.solDecision front_prog
diff --git a/test/Hanalyze/Optim/NelderMeadSpec.hs b/test/Hanalyze/Optim/NelderMeadSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Optim/NelderMeadSpec.hs
@@ -0,0 +1,57 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Optim.NelderMeadSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Hanalyze.Optim.NelderMead  as NM
+import qualified Hanalyze.Optim.Common      as OC
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Optim.NelderMead" $ do
+    let l2 :: [Double] -> [Double] -> Double
+        l2 a b = sqrt (sum (zipWith (\x y -> (x-y)^(2::Int)) a b))
+        sphere xs = sum [x*x | x <- xs]
+        rosenbrock [x, y] = (1 - x)^(2::Int) + 100 * (y - x*x)^(2::Int)
+        rosenbrock _ = error "rosenbrock: 2D only"
+
+    it "minimises sphere f(x)=Σx² to ~0 from x0=[3,-2,1]" $ do
+      r <- NM.runNelderMead sphere [3, -2, 1]
+      OC.orValue r `shouldSatisfy` (< 1e-6)
+
+    it "minimises Rosenbrock 2D to (1,1) within 0.05" $ do
+      let cfg = NM.defaultNMConfig
+                  { NM.nmStop = OC.defaultStopCriteria { OC.stMaxIter = 5000 } }
+      r <- NM.runNelderMeadWith cfg rosenbrock [-1.2, 1.0]
+      l2 (OC.orBest r) [1, 1] `shouldSatisfy` (< 0.05)
+
+    it "Maximize: -sphere has optimum 0 at origin" $ do
+      let cfg = NM.defaultNMConfig { NM.nmDir = OC.Maximize }
+      r <- NM.runNelderMeadWith cfg (\xs -> negate (sphere xs)) [3, -2, 1]
+      -- Maximize: orValue は元尺度 (= negate sphere の最大値、すなわち 0 に近い)
+      OC.orValue r `shouldSatisfy` (\v -> v > -1e-6)
+      -- 最良点は原点近傍
+      l2 (OC.orBest r) [0, 0, 0] `shouldSatisfy` (< 1e-2)
+
+  -- ===========================================================================
+  -- 単目的オプティマイザ (Hanalyze.Optim.LBFGS)
+  -- ===========================================================================
diff --git a/test/Hanalyze/Optim/ParticleSwarmSpec.hs b/test/Hanalyze/Optim/ParticleSwarmSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Optim/ParticleSwarmSpec.hs
@@ -0,0 +1,57 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Optim.ParticleSwarmSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified System.Random.MWC as MWC
+import qualified Hanalyze.Optim.ParticleSwarm as PSO
+import qualified Hanalyze.Optim.Common      as OC
+import qualified System.Random.MWC as MWC
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Optim.ParticleSwarm" $ do
+    it "PSO: sphere 5D で原点近傍 (0.5 以内)" $ do
+      gen <- MWC.create
+      let bs = replicate 5 (-5, 5)
+          cfg = (PSO.defaultPSOConfig bs)
+                  { PSO.psoStop = OC.defaultStopCriteria { OC.stMaxIter = 200 }
+                  , PSO.psoNum  = 30 }
+          sphere xs = sum [x*x | x <- xs]
+      r <- PSO.runPSOWith cfg sphere gen
+      OC.orValue r `shouldSatisfy` (< 0.5)
+
+    it "PSO: Rastrigin 3D の大域最小に近い" $ do
+      gen <- MWC.create
+      let bs = replicate 3 (-5.12, 5.12)
+          cfg = (PSO.defaultPSOConfig bs)
+                  { PSO.psoStop = OC.defaultStopCriteria { OC.stMaxIter = 300 }
+                  , PSO.psoNum  = 40 }
+          rastrigin xs =
+            10 * fromIntegral (length xs) +
+            sum [x*x - 10 * cos (2 * pi * x) | x <- xs]
+      r <- PSO.runPSOWith cfg rastrigin gen
+      OC.orValue r `shouldSatisfy` (< 5.0)   -- 大域近傍 (10 程度の局所有り)
+
+  -- ===========================================================================
+  -- 制約付き最適化 (Augmented Lagrangian)
+  -- ===========================================================================
diff --git a/test/Hanalyze/Optim/SimulatedAnnealingSpec.hs b/test/Hanalyze/Optim/SimulatedAnnealingSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Optim/SimulatedAnnealingSpec.hs
@@ -0,0 +1,42 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Optim.SimulatedAnnealingSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified System.Random.MWC as MWC
+import qualified Hanalyze.Optim.SimulatedAnnealing as SA
+import qualified Hanalyze.Optim.Common      as OC
+import qualified System.Random.MWC as MWC
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Optim.SimulatedAnnealing" $ do
+    it "SA: sphere 5D で sufficient annealing" $ do
+      gen <- MWC.create
+      let bs = replicate 5 (-3, 3)
+          cfg = (SA.defaultSAConfig bs)
+                  { SA.saStop = OC.defaultStopCriteria { OC.stMaxIter = 5000 }
+                  , SA.saInitTemp = 2.0
+                  , SA.saSchedule = SA.Geometric 0.997 }
+          sphere xs = sum [x*x | x <- xs]
+      r <- SA.runSAWith cfg sphere [2, -1.5, 1, 0.5, -0.7] gen
+      OC.orValue r `shouldSatisfy` (< 0.5)
diff --git a/test/Hanalyze/Stat/AdaptiveGridSpec.hs b/test/Hanalyze/Stat/AdaptiveGridSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Stat/AdaptiveGridSpec.hs
@@ -0,0 +1,56 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Stat.AdaptiveGridSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Hanalyze.Stat.AdaptiveGrid as AG
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Stat.AdaptiveGrid" $ do
+    it "uniformGrid: 端点を含み等間隔" $ do
+      let g = AG.uniformGrid 5 0 4
+      g `shouldBe` [0, 1, 2, 3, 4]
+
+    it "Adaptive: 急激な変化付近に grid が集中する (step 関数)" $ do
+      -- y は z=0..1 でほぼ平坦、z=1..2 で急激に変化、z=2..3 で再び平坦
+      let pts1 = [(z, if z < 1 then 0 else if z < 2 then 10*(z-1) else 10) | z <- [0, 0.1 .. 3]]
+          spec = (AG.defaultGridSpec 30) { AG.gsKind = AG.Adaptive }
+          g    = AG.makeGrid [pts1] (0, 3) spec
+          -- 中央領域 [1, 2] にある grid 点数 vs 端領域 [0,1] の grid 点数
+          midN = length (filter (\z -> z >= 1 && z <= 2) g)
+          edgeN = length (filter (\z -> z < 1) g)
+      length g `shouldBe` 30
+      midN `shouldSatisfy` (> edgeN)
+
+    it "Uniform: 端点 + 等間隔" $ do
+      let g = AG.makeGrid [] (0, 1) ((AG.defaultGridSpec 6) { AG.gsKind = AG.Uniform })
+      length g `shouldBe` 6
+      head g `shouldBe` 0
+      last g `shouldBe` 1
+
+    it "N < 10 で adaptive 指定でも uniform にフォールバック" $ do
+      let pts1 = [(z, sin z) | z <- [0, 0.1 .. 3]]
+          spec = (AG.defaultGridSpec 5) { AG.gsKind = AG.Adaptive }
+          g    = AG.makeGrid [pts1] (0, 3) spec
+          gU   = AG.uniformGrid 5 0 3
+      g `shouldBe` gU
diff --git a/test/Hanalyze/Stat/BayesFactorSpec.hs b/test/Hanalyze/Stat/BayesFactorSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Stat/BayesFactorSpec.hs
@@ -0,0 +1,73 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Stat.BayesFactorSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Data.Map.Strict as M
+import qualified System.Random.MWC as MWC
+import qualified Data.ByteString   as BS
+import qualified System.Random.MWC as MWC
+import qualified Hanalyze.MCMC.NUTS as NUTS
+import qualified Hanalyze.Stat.BridgeSampling as BS
+import qualified Hanalyze.Stat.BayesFactor    as BF
+import qualified Hanalyze.Model.HBM as HBM
+import qualified Data.Map.Strict    as M
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Stat.BayesFactor (Phase 29-A3 via Bridge Sampling)" $ do
+    -- M_0: μ ~ N(0, 1)    (= 強い prior、 データ y=5 から離れている)
+    -- M_1: μ ~ N(0, 10)   (= 弱い prior、 データに近い μ が許容される)
+    -- データ y_i = 5 (n=10)
+    -- → M_1 が事実上 favored、 BF_{10} > 1 が期待
+    let m0 :: HBM.ModelP ()
+        m0 = do
+          mu <- HBM.sample "mu" (HBM.Normal 0 1)
+          HBM.observe "y" (HBM.Normal mu 1) (replicate 10 5.0)
+        m1 :: HBM.ModelP ()
+        m1 = do
+          mu <- HBM.sample "mu" (HBM.Normal 0 10)
+          HBM.observe "y" (HBM.Normal mu 1) (replicate 10 5.0)
+        nutsCfg = NUTS.defaultNUTSConfig
+          { NUTS.nutsIterations = 2000
+          , NUTS.nutsBurnIn     = 500
+          , NUTS.nutsAdaptStepSize = True
+          }
+    it "Bayes Factor: BF_{10} > 1 (= log_e BF > 0)、 M_1 が favored" $ do
+      gen0 <- MWC.create
+      ch0 <- NUTS.nuts m0 nutsCfg (M.fromList [("mu", 5)]) gen0
+      gen1 <- MWC.create
+      ch1 <- NUTS.nuts m1 nutsCfg (M.fromList [("mu", 5)]) gen1
+      gen2 <- MWC.create
+      r <- BF.bayesFactor m0 ch0 m1 ch1 BS.defaultBridgeConfig gen2
+      BF.bfLogE r `shouldSatisfy` (> 0)
+      -- 解釈: log_e BF > 5 (very strong) を期待 (= 強 prior vs 弱 prior の差)
+      BF.bfLogE r `shouldSatisfy` (> 5)
+      BF.interpretBF (BF.bfLogE r) `shouldBe` BF.BFVeryStrong
+      BF.bfConverged0 r `shouldBe` True
+      BF.bfConverged1 r `shouldBe` True
+    it "interpretBF: Kass-Raftery 解釈閾値" $ do
+      BF.interpretBF 0.5 `shouldBe` BF.BFNegligible
+      BF.interpretBF 2.0 `shouldBe` BF.BFPositive
+      BF.interpretBF 4.0 `shouldBe` BF.BFStrong
+      BF.interpretBF 6.0 `shouldBe` BF.BFVeryStrong
+      BF.interpretBF (-4.0) `shouldBe` BF.BFStrong   -- 符号反転にも対応
diff --git a/test/Hanalyze/Stat/BayesianModelAveragingSpec.hs b/test/Hanalyze/Stat/BayesianModelAveragingSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Stat/BayesianModelAveragingSpec.hs
@@ -0,0 +1,83 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Stat.BayesianModelAveragingSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Numeric.LinearAlgebra as LA
+import qualified Data.Map.Strict as M
+import qualified System.Random.MWC as MWC
+import qualified Data.ByteString   as BS
+import qualified System.Random.MWC as MWC
+import qualified Hanalyze.MCMC.NUTS as NUTS
+import qualified Hanalyze.Stat.BridgeSampling as BS
+import qualified Hanalyze.Stat.BayesianModelAveraging as BMA
+import qualified Hanalyze.Model.HBM as HBM
+import qualified Data.Map.Strict    as M
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Stat.BayesianModelAveraging (Phase 29-A4)" $ do
+    it "bayesianModelAveraging: 同じ log marginal で uniform weights、 prior 省略" $ do
+      let r = BMA.bayesianModelAveraging [-10, -10, -10] Nothing
+      BMA.bmaWeights r `shouldBe` [1/3, 1/3, 1/3]
+    it "bayesianModelAveraging: 大きい log marginal が高い weight、 softmax stable" $ do
+      let r = BMA.bayesianModelAveraging [-100, -98, -102] Nothing
+      -- 真ん中 (-98) が最大、 隣接で 1 / e^2 ≈ 0.135 倍
+      let ws = BMA.bmaWeights r
+      sum ws `shouldSatisfy` (\s -> abs (s - 1.0) < 1e-9)
+      (ws !! 1) `shouldSatisfy` (> ws !! 0)
+      (ws !! 1) `shouldSatisfy` (> ws !! 2)
+    it "averagePredictions: weighted sum of vectors" $ do
+      let r = BMA.bayesianModelAveraging [log 0.7, log 0.3] Nothing
+          v1 = LA.fromList [1, 2, 3]
+          v2 = LA.fromList [4, 5, 6]
+          avg = BMA.averagePredictions r [v1, v2]
+      -- 0.7·[1,2,3] + 0.3·[4,5,6] = [1.9, 2.9, 3.9]
+      LA.toList avg `shouldSatisfy`
+        (\xs -> length xs == 3 && all (\(a,b) -> abs (a - b) < 1e-9)
+                                     (zip xs [1.9, 2.9, 3.9]))
+    it "BMA 統合: Bridge 経由の log marginal で 2 モデル比較" $ do
+      let m0 :: HBM.ModelP ()
+          m0 = do
+            mu <- HBM.sample "mu" (HBM.Normal 0 1)
+            HBM.observe "y" (HBM.Normal mu 1) (replicate 10 5.0)
+          m1 :: HBM.ModelP ()
+          m1 = do
+            mu <- HBM.sample "mu" (HBM.Normal 0 10)
+            HBM.observe "y" (HBM.Normal mu 1) (replicate 10 5.0)
+          nutsCfg = NUTS.defaultNUTSConfig
+            { NUTS.nutsIterations = 2000
+            , NUTS.nutsBurnIn     = 500
+            , NUTS.nutsAdaptStepSize = True
+            }
+      gen0 <- MWC.create
+      ch0 <- NUTS.nuts m0 nutsCfg (M.fromList [("mu", 5)]) gen0
+      gen1 <- MWC.create
+      ch1 <- NUTS.nuts m1 nutsCfg (M.fromList [("mu", 5)]) gen1
+      gen2 <- MWC.create
+      r0 <- BS.bridgeSampling m0 BS.defaultBridgeConfig ch0 gen2
+      gen3 <- MWC.create
+      r1 <- BS.bridgeSampling m1 BS.defaultBridgeConfig ch1 gen3
+      let bma = BMA.bayesianModelAveraging
+            [BS.brLogMarginal r0, BS.brLogMarginal r1] Nothing
+      -- M_1 (= 弱 prior) が dominant weight (> 0.9 想定)
+      (BMA.bmaWeights bma !! 1) `shouldSatisfy` (> 0.9)
diff --git a/test/Hanalyze/Stat/BootstrapSpec.hs b/test/Hanalyze/Stat/BootstrapSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Stat/BootstrapSpec.hs
@@ -0,0 +1,56 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Stat.BootstrapSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Numeric.LinearAlgebra as LA
+import qualified Hanalyze.Stat.Bootstrap       as Boot
+import qualified System.Random.MWC as MWC
+import qualified System.Random.MWC as MWC
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Stat.Bootstrap" $ do
+    it "bootstrapCI on N(0,1) sample: 0 が 95% CI 内" $ do
+      gen <- MWC.createSystemRandom
+      let xs = LA.fromList [-1.0, -0.5, 0.0, 0.5, 1.0,
+                            -0.3, 0.3, -0.7, 0.7, 0.0]
+      (lo, hi) <- Boot.bootstrapCI 2000 0.95 Boot.sampleMean xs gen
+      lo `shouldSatisfy` (< 0.5)
+      hi `shouldSatisfy` (> -0.5)
+
+    it "permutationTest: 異なる平均で p < 0.05" $ do
+      gen <- MWC.createSystemRandom
+      let xs = LA.fromList [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]
+          ys = LA.fromList [10.0, 11.0, 12.0, 13.0, 14.0, 15.0]
+      (_diff, p) <- Boot.permutationTest 2000 xs ys gen
+      p `shouldSatisfy` (< 0.05)
+
+    it "sampleMean / sampleVar / sampleMedian の整合性" $ do
+      let v = LA.fromList [1.0, 2.0, 3.0, 4.0, 5.0]
+      Boot.sampleMean v `shouldBe` 3.0
+      Boot.sampleVar v `shouldBe` 2.5  -- variance of 1..5 (unbiased)
+      Boot.sampleMedian v `shouldBe` 3.0
+
+  -- ===========================================================================
+  -- Hanalyze.DataIO.Reshape (Phase 8)
+  -- ===========================================================================
diff --git a/test/Hanalyze/Stat/BridgeSamplingSpec.hs b/test/Hanalyze/Stat/BridgeSamplingSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Stat/BridgeSamplingSpec.hs
@@ -0,0 +1,63 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Stat.BridgeSamplingSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Data.Map.Strict as M
+import qualified System.Random.MWC as MWC
+import qualified Data.ByteString   as BS
+import qualified System.Random.MWC as MWC
+import qualified Hanalyze.MCMC.NUTS as NUTS
+import qualified Hanalyze.Stat.BridgeSampling as BS
+import qualified Hanalyze.Model.HBM as HBM
+import qualified Data.Map.Strict    as M
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Stat.BridgeSampling (Phase 29-A2 Meng-Wong)" $ do
+    -- Gaussian × Gaussian model: μ ~ N(0, σ_p=10)、 y ~ N(μ, σ_y=1)、 n=10、 y=5.0
+    -- 解析的 log marginal (Python で検算): log p(y) ≈ -12.7686
+    -- 算出法: log p(y) = log p(y|μ*) + log p(μ*) - log p(μ*|y) at μ* = posterior mean
+    --   μ_post = 4.99500、 σ_post² = 0.0999、 ȳ = 5.0
+    let modelBS :: HBM.ModelP ()
+        modelBS = do
+          mu <- HBM.sample "mu" (HBM.Normal 0 10)
+          HBM.observe "y" (HBM.Normal mu 1) (replicate 10 5.0)
+        nutsCfg = NUTS.defaultNUTSConfig
+          { NUTS.nutsIterations = 2000
+          , NUTS.nutsBurnIn     = 500
+          , NUTS.nutsAdaptStepSize = True
+          }
+    it "Bridge Sampling: log marginal が解析解 (-12.77) と 0.5 以内一致" $ do
+      gen <- MWC.create
+      chain <- NUTS.nuts modelBS nutsCfg (M.fromList [("mu", 5)]) gen
+      gen2 <- MWC.create
+      r <- BS.bridgeSampling modelBS BS.defaultBridgeConfig chain gen2
+      let analytic = (-12.7686)
+      BS.brLogMarginal r `shouldSatisfy` (\x -> abs (x - analytic) < 0.5)
+      BS.brConverged r `shouldBe` True
+    it "Bridge Sampling: 反復が tolerance 内で収束 (< 50 iter)" $ do
+      gen <- MWC.create
+      chain <- NUTS.nuts modelBS nutsCfg (M.fromList [("mu", 5)]) gen
+      gen2 <- MWC.create
+      r <- BS.bridgeSampling modelBS BS.defaultBridgeConfig chain gen2
+      BS.brIterations r `shouldSatisfy` (< 50)
diff --git a/test/Hanalyze/Stat/CVSpec.hs b/test/Hanalyze/Stat/CVSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Stat/CVSpec.hs
@@ -0,0 +1,74 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Stat.CVSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Data.Vector as V
+import qualified Hanalyze.Stat.CV           as CV
+import qualified System.Random.MWC as MWC
+import qualified System.Random.MWC as MWC
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Stat.CV" $ do
+    it "kFold(5, 100): 5 fold で全 100 行を test に使用、重複なし" $ do
+      gen <- MWC.createSystemRandom
+      folds <- CV.kFold 5 100 gen
+      length folds `shouldBe` 5
+      let allTest = concatMap snd folds
+      length allTest `shouldBe` 100
+      length (V.toList (V.fromList allTest)) `shouldBe` 100  -- 重複なし
+
+    it "kFold: train + test = total samples per fold" $ do
+      gen <- MWC.createSystemRandom
+      folds <- CV.kFold 5 100 gen
+      mapM_ (\(tr, te) -> length tr + length te `shouldBe` 100) folds
+
+    it "leaveOneOut(10): 10 folds、test set size 1 each" $ do
+      folds <- CV.leaveOneOut 10
+      length folds `shouldBe` 10
+      mapM_ (\(_, te) -> length te `shouldBe` 1) folds
+
+    it "stratifiedKFold(3): クラスバランスがほぼ保持される" $ do
+      gen <- MWC.createSystemRandom
+      let labels = replicate 30 0 ++ replicate 30 1 ++ replicate 30 2
+      folds <- CV.stratifiedKFold 3 labels gen
+      length folds `shouldBe` 3
+      -- 各 fold の test set には各クラスの ~10 が含まれる
+      mapM_ (\(_, te) -> length te `shouldSatisfy` (\n -> n >= 27 && n <= 33)) folds
+
+    it "shuffleSplit: 反復回数とテストサイズが正しい" $ do
+      gen <- MWC.createSystemRandom
+      folds <- CV.shuffleSplit 5 0.2 100 gen
+      length folds `shouldBe` 5
+      mapM_ (\(_, te) -> length te `shouldBe` 20) folds
+
+    it "timeSeriesSplit: forward-chaining で過去のみで学習" $ do
+      let folds = CV.timeSeriesSplit 50 10 100  -- initial=50, step=10, n=100
+      length folds `shouldBe` 5  -- (100-50)/10 = 5 folds
+      -- 全 fold で train indices < min(test indices)
+      mapM_ (\(tr, te) ->
+               (maximum tr < minimum te) `shouldBe` True) folds
+
+  -- ===========================================================================
+  -- Hanalyze.Model.Cluster (Phase 5)
+  -- ===========================================================================
diff --git a/test/Hanalyze/Stat/Causal/CATESpec.hs b/test/Hanalyze/Stat/Causal/CATESpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Stat/Causal/CATESpec.hs
@@ -0,0 +1,125 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Stat.Causal.CATESpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Numeric.LinearAlgebra as LA
+import qualified Hanalyze.Model.RandomForest           as RF
+import qualified Data.Vector.Storable              as VS
+import qualified System.Random.MWC as MWC
+import qualified System.Random.MWC as MWC
+import qualified Hanalyze.Stat.Causal.CATE            as CCATE
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Stat.Causal.CATE (Phase 30-A4 meta-learners)" $ do
+    -- 異質 treatment effect DGP:
+    --   X1, X2 ~ N(0,1)、 T ~ Bern(σ(0.5·X1))
+    --   Y = 1 + 0.5·X1 + 0.3·X2 + (1 + X1)·T + N(0, 0.5)
+    -- 真の τ(X) = 1 + X1、 ATE = E[τ] = 1.0
+    -- LM base learner では: T-learner / X-learner は τ(X) を線形回復、
+    -- S-learner は単純な intercept-shift で ATE のみ近似 (interaction 無し)。
+    it "CATE: T-learner と X-learner が ATE を 20% 以内回復、 X1 と単調" $ do
+      gen <- MWC.create
+      let nC = 2000
+          trueATE = 1.0 :: Double
+      x1s <- VS.replicateM nC (do
+              u1 <- MWC.uniformR (1e-9, 1.0 :: Double) gen
+              u2 <- MWC.uniformR (0.0, 1.0 :: Double) gen
+              pure (sqrt (-2 * log u1) * cos (2 * pi * u2)))
+      x2s <- VS.replicateM nC (do
+              u1 <- MWC.uniformR (1e-9, 1.0 :: Double) gen
+              u2 <- MWC.uniformR (0.0, 1.0 :: Double) gen
+              pure (sqrt (-2 * log u1) * cos (2 * pi * u2)))
+      noises <- VS.replicateM nC (do
+              u1 <- MWC.uniformR (1e-9, 1.0 :: Double) gen
+              u2 <- MWC.uniformR (0.0, 1.0 :: Double) gen
+              pure (0.5 * sqrt (-2 * log u1) * cos (2 * pi * u2)))
+      let sigC z = 1.0 / (1.0 + exp (-z))
+      us <- VS.replicateM nC (MWC.uniformR (0.0, 1.0 :: Double) gen)
+      let ps0  = VS.map (\a -> sigC (0.5 * a)) x1s
+          tsV  = VS.zipWith (\p u -> if u < p then 1.0 else 0.0) ps0 us
+          ysV  = VS.zipWith4 (\x1 x2 tt e ->
+                    1.0 + 0.5 * x1 + 0.3 * x2 + (1.0 + x1) * tt + e)
+                  x1s x2s tsV noises
+          x1List = VS.toList x1s
+          xMatC = LA.fromColumns
+                    [ LA.fromList (replicate nC 1)
+                    , LA.fromList x1List
+                    , LA.fromList (VS.toList x2s) ]
+          tVecC = LA.fromList (VS.toList tsV)
+          yVecC = LA.fromList (VS.toList ysV)
+      tR <- CCATE.fitCATE CCATE.TLearner CCATE.CATELM xMatC tVecC yVecC gen
+      sR <- CCATE.fitCATE CCATE.SLearner CCATE.CATELM xMatC tVecC yVecC gen
+      xR <- CCATE.fitCATE CCATE.XLearner CCATE.CATELM xMatC tVecC yVecC gen
+      -- ATE 回復: T / X-learner は 20% 以内
+      CCATE.cateATE tR `shouldSatisfy` (\v -> abs (v - trueATE) < 0.2)
+      CCATE.cateATE xR `shouldSatisfy` (\v -> abs (v - trueATE) < 0.2)
+      -- S-learner は constant CATE のため ATE ≈ 1 を 30% 以内
+      CCATE.cateATE sR `shouldSatisfy` (\v -> abs (v - trueATE) < 0.3)
+      -- 長さ一致
+      LA.size (CCATE.cateEstimates tR) `shouldBe` nC
+      LA.size (CCATE.cateEstimates sR) `shouldBe` nC
+      LA.size (CCATE.cateEstimates xR) `shouldBe` nC
+      -- T-learner τ̂(X) は X1 と単調 (= rank correlation > 0.5)
+      let tauT = LA.toList (CCATE.cateEstimates tR)
+          rankCorr as bs =
+            let pairs = zip as bs
+                concCount = sum
+                  [ 1 :: Int
+                  | (i, (ai, bi)) <- zip [0..] pairs
+                  , (j, (aj, bj)) <- zip [0..] pairs
+                  , i < j
+                  , (ai - aj) * (bi - bj) > 0 ]
+                discCount = sum
+                  [ 1 :: Int
+                  | (i, (ai, bi)) <- zip [0..] pairs
+                  , (j, (aj, bj)) <- zip [0..] pairs
+                  , i < j
+                  , (ai - aj) * (bi - bj) < 0 ]
+                tot = concCount + discCount
+            in if tot == 0
+                 then 0.0
+                 else fromIntegral (concCount - discCount)
+                       / fromIntegral tot
+          -- 速度のため先頭 200 サンプルだけで rank correlation を概算
+          rc = rankCorr (take 200 tauT) (take 200 x1List)
+      rc `shouldSatisfy` (> 0.5)
+    it "CATE: RF base learner で T-learner が ATE を 30% 以内回復 (smoke test)" $ do
+      gen <- MWC.create
+      let nSmall = 400
+          trueATE = 1.0 :: Double
+      x1s <- VS.replicateM nSmall (MWC.uniformR (-2.0, 2.0 :: Double) gen)
+      us  <- VS.replicateM nSmall (MWC.uniformR (0.0, 1.0 :: Double) gen)
+      let sigC z = 1.0 / (1.0 + exp (-z))
+          ps0  = VS.map (\a -> sigC (0.5 * a)) x1s
+          tsV  = VS.zipWith (\p u -> if u < p then 1.0 else 0.0) ps0 us
+          ysV  = VS.zipWith (\x1 tt -> 1.0 + 0.5 * x1 + (1.0 + x1) * tt)
+                            x1s tsV
+          xMatS = LA.fromColumns [LA.fromList (VS.toList x1s)]
+          tVecS = LA.fromList (VS.toList tsV)
+          yVecS = LA.fromList (VS.toList ysV)
+          rfCfg = RF.defaultRandomForest { RF.rfTrees = 30, RF.rfMaxDepth = 6 }
+      r <- CCATE.fitCATE CCATE.TLearner (CCATE.CATERF rfCfg)
+                         xMatS tVecS yVecS gen
+      CCATE.cateATE r `shouldSatisfy` (\v -> abs (v - trueATE) < 0.3)
+      LA.size (CCATE.cateEstimates r) `shouldBe` nSmall
diff --git a/test/Hanalyze/Stat/Causal/DoublyRobustSpec.hs b/test/Hanalyze/Stat/Causal/DoublyRobustSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Stat/Causal/DoublyRobustSpec.hs
@@ -0,0 +1,83 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Stat.Causal.DoublyRobustSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Numeric.LinearAlgebra as LA
+import qualified Data.Vector.Storable              as VS
+import qualified System.Random.MWC as MWC
+import qualified System.Random.MWC as MWC
+import qualified Hanalyze.Stat.Causal.PropensityScore as PS
+import qualified Hanalyze.Stat.Causal.DoublyRobust    as CDR
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Stat.Causal.DoublyRobust (Phase 30-A3 AIPW)" $ do
+    -- IPW と同じ合成データ (真の ATE = 2.0)、 共通 helper にすると
+    -- IO の流れが複雑になるので 1 つの it 内で 4 ケースまとめて検証。
+    it "AIPW: 二重ロバスト性 (正しい outcome / 誤った PS でも 15% 以内回復)" $ do
+      gen <- MWC.create
+      let nC = 2000
+          trueTau = 2.0 :: Double
+      x1s <- VS.replicateM nC (do
+              u1 <- MWC.uniformR (1e-9, 1.0 :: Double) gen
+              u2 <- MWC.uniformR (0.0, 1.0 :: Double) gen
+              pure (sqrt (-2 * log u1) * cos (2 * pi * u2)))
+      x2s <- VS.replicateM nC (do
+              u1 <- MWC.uniformR (1e-9, 1.0 :: Double) gen
+              u2 <- MWC.uniformR (0.0, 1.0 :: Double) gen
+              pure (sqrt (-2 * log u1) * cos (2 * pi * u2)))
+      noises <- VS.replicateM nC (do
+              u1 <- MWC.uniformR (1e-9, 1.0 :: Double) gen
+              u2 <- MWC.uniformR (0.0, 1.0 :: Double) gen
+              pure (0.5 * sqrt (-2 * log u1) * cos (2 * pi * u2)))
+      let sigC z = 1.0 / (1.0 + exp (-z))
+      us <- VS.replicateM nC (MWC.uniformR (0.0, 1.0 :: Double) gen)
+      let ps0  = VS.zipWith (\a b -> sigC (0.8 * a + 0.4 * b)) x1s x2s
+          tsV  = VS.zipWith (\p u -> if u < p then 1.0 else 0.0) ps0 us
+          ysV  = VS.zipWith4 (\x1 x2 tt e ->
+                    1.0 + 0.5 * x1 + 0.3 * x2 + trueTau * tt + e)
+                  x1s x2s tsV noises
+          xMatC = LA.fromColumns
+                    [ LA.fromList (replicate nC 1)
+                    , LA.fromList (VS.toList x1s)
+                    , LA.fromList (VS.toList x2s) ]
+          tVecC = LA.fromList (VS.toList tsV)
+          yVecC = LA.fromList (VS.toList ysV)
+      -- (a) 両方正しい: DR は τ=2 を 10% 以内
+      let drR = CDR.doublyRobust xMatC tVecC yVecC
+      CDR.drATE drR `shouldSatisfy` (\v -> abs (v - trueTau) < 0.1 * trueTau)
+      LA.size (CDR.drMu1Predicted drR) `shouldBe` nC
+      LA.size (CDR.drMu0Predicted drR) `shouldBe` nC
+      -- (b) PS misspecified (= 全て 0.5、 = "RCT 想定"): outcome model が正しい
+      --     ので DR は 15% 以内に補正される (二重ロバスト性の片側)
+      let psBad = PS.PropensityScore
+                    (LA.fromList (replicate nC 0.5))
+                    (LA.fromList [0]) nC
+          drBadPS = CDR.doublyRobustWith psBad xMatC tVecC yVecC
+      CDR.drATE drBadPS `shouldSatisfy` (\v -> abs (v - trueTau) < 0.15 * trueTau)
+      -- (c) PS は正しく、 outcome model は intercept-only (= misspecified):
+      --     DR は IPW 相当に縮退し、 PS が正しいので 15% 以内
+      let xIntOnly = LA.fromColumns [LA.fromList (replicate nC 1)]
+          psGood   = PS.trimPropensity 0.01 0.99 (PS.propensityScore xMatC tVecC)
+          drBadOut = CDR.doublyRobustWith psGood xIntOnly tVecC yVecC
+      CDR.drATE drBadOut `shouldSatisfy` (\v -> abs (v - trueTau) < 0.15 * trueTau)
diff --git a/test/Hanalyze/Stat/Causal/IPWSpec.hs b/test/Hanalyze/Stat/Causal/IPWSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Stat/Causal/IPWSpec.hs
@@ -0,0 +1,88 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Stat.Causal.IPWSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Numeric.LinearAlgebra as LA
+import qualified Data.Vector.Storable              as VS
+import qualified System.Random.MWC as MWC
+import qualified System.Random.MWC as MWC
+import qualified Hanalyze.Stat.Causal.PropensityScore as PS
+import qualified Hanalyze.Stat.Causal.IPW             as CIPW
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Stat.Causal.IPW (Phase 30-A2)" $ do
+    -- 合成データ (真の ATE = 2.0):
+    --   X1 ~ N(0, 1)、 X2 ~ N(0, 1)、 T ~ Bern(σ(0.8·X1 + 0.4·X2))
+    --   Y = 1.0 + 0.5·X1 + 0.3·X2 + 2.0·T + N(0, 0.5)
+    -- → naive E[Y|T=1] - E[Y|T=0] は X1/X2 経由の交絡で真値より上、
+    --   IPW で正しく τ=2 に補正されるはず。
+    let trueTau = 2.0 :: Double
+    it "ipw: ATE が真値 2.0 の 10% 以内 (MWC 合成、 n=2000)" $ do
+      gen <- MWC.createSystemRandom >> MWC.create  -- 固定 seed
+      let nC = 2000
+      x1s <- VS.replicateM nC (do
+              u1 <- MWC.uniformR (1e-9, 1.0 :: Double) gen
+              u2 <- MWC.uniformR (0.0, 1.0 :: Double) gen
+              pure (sqrt (-2 * log u1) * cos (2 * pi * u2)))
+      x2s <- VS.replicateM nC (do
+              u1 <- MWC.uniformR (1e-9, 1.0 :: Double) gen
+              u2 <- MWC.uniformR (0.0, 1.0 :: Double) gen
+              pure (sqrt (-2 * log u1) * cos (2 * pi * u2)))
+      noises <- VS.replicateM nC (do
+              u1 <- MWC.uniformR (1e-9, 1.0 :: Double) gen
+              u2 <- MWC.uniformR (0.0, 1.0 :: Double) gen
+              pure (0.5 * sqrt (-2 * log u1) * cos (2 * pi * u2)))
+      let sigC z = 1.0 / (1.0 + exp (-z))
+      us <- VS.replicateM nC (MWC.uniformR (0.0, 1.0 :: Double) gen)
+      let ps0  = VS.zipWith (\a b -> sigC (0.8 * a + 0.4 * b)) x1s x2s
+          tsV  = VS.zipWith (\p u -> if u < p then 1.0 else 0.0) ps0 us
+          ysV  = VS.zipWith4 (\x1 x2 tt e ->
+                    1.0 + 0.5 * x1 + 0.3 * x2 + trueTau * tt + e)
+                  x1s x2s tsV noises
+          xMatC = LA.fromColumns
+                    [ LA.fromList (replicate nC 1)
+                    , LA.fromList (VS.toList x1s)
+                    , LA.fromList (VS.toList x2s) ]
+          tVecC = LA.fromList (VS.toList tsV)
+          yVecC = LA.fromList (VS.toList ysV)
+          ipwR  = CIPW.ipw xMatC tVecC yVecC
+      CIPW.ipwATE ipwR `shouldSatisfy` (\v -> abs (v - trueTau) < 0.1 * trueTau)
+      -- 定数効果なので ATT も同程度の精度
+      CIPW.ipwATT ipwR `shouldSatisfy` (\v -> abs (v - trueTau) < 0.15 * trueTau)
+      -- 重み長さ + 非負
+      LA.size (CIPW.ipwWeightsATE ipwR) `shouldBe` nC
+      LA.toList (CIPW.ipwWeightsATE ipwR) `shouldSatisfy` all (>= 0)
+      -- defaultPSTrim 適用済
+      LA.toList (PS.psScores (CIPW.ipwPropensity ipwR)) `shouldSatisfy`
+        all (\p -> p >= 0.01 && p <= 0.99)
+    it "ipwWith: 既存 PS 再利用、 別 trim と組み合わせ可能" $ do
+      -- 簡単な手書きデータ: T=1 → Y=12, T=0 → Y=10、 PS = 0.5
+      -- → ATE = 2、 重み均等
+      let ps  = PS.PropensityScore (LA.fromList [0.5, 0.5, 0.5, 0.5])
+                                   (LA.fromList [0]) 4
+          tv  = LA.fromList [1, 0, 1, 0]
+          yv  = LA.fromList [12, 10, 12, 10]
+          r   = CIPW.ipwWith ps tv yv
+      CIPW.ipwATE r `shouldSatisfy` (\v -> abs (v - 2) < 1e-9)
+      CIPW.ipwATT r `shouldSatisfy` (\v -> abs (v - 2) < 1e-9)
diff --git a/test/Hanalyze/Stat/Causal/PropensityScoreSpec.hs b/test/Hanalyze/Stat/Causal/PropensityScoreSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Stat/Causal/PropensityScoreSpec.hs
@@ -0,0 +1,87 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Stat.Causal.PropensityScoreSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Numeric.LinearAlgebra as LA
+import qualified Hanalyze.Stat.Causal.PropensityScore as PS
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Stat.Causal.PropensityScore (Phase 30-A1)" $ do
+    -- 合成データ: x ~ uniform[-2, 2]、 T ~ Bernoulli(σ(x))
+    -- 真の logit: logit(p) = x、 intercept ≈ 0、 slope ≈ 1
+    let n = 500
+        xs = [(-2.0) + 4.0 * fromIntegral i / fromIntegral (n - 1) | i <- [0 .. n - 1]]
+        sigmoid z = 1.0 / (1.0 + exp (-z))
+        -- 決定的 0/1 割り当て (= 並べ替え逆数で疑似 Bernoulli): σ(x) > 閾値
+        -- → 安定したテストのため、 各 x で T = 1 if σ(x) > i_norm else 0
+        -- i_norm を [0,1] 等差にすることで rate ≈ σ(x) を実現
+        -- 閾値は i に対し x と無相関になるよう golden ratio low-discrepancy で散らす
+        frac z = z - fromIntegral (floor z :: Int)
+        thrs = [ frac (fromIntegral i * 0.6180339887498949) | i <- [0 .. n - 1] ]
+        ts   = [ if sigmoid x > thr then 1.0 else 0.0
+               | (x, thr) <- zip xs thrs ]
+        xMat = LA.fromColumns [LA.fromList (replicate n 1), LA.fromList xs]  -- intercept + x
+        tVec = LA.fromList ts
+        psR  = PS.propensityScore xMat tVec
+    it "propensityScore: psN がサンプル数と一致" $
+      PS.psN psR `shouldBe` n
+    it "propensityScore: psScores が [0,1] に収まる + ベクトル長一致" $ do
+      LA.size (PS.psScores psR) `shouldBe` n
+      LA.toList (PS.psScores psR) `shouldSatisfy`
+        all (\p -> p >= 0 && p <= 1)
+    it "propensityScore: logistic slope > 0 (真値 +1 の符号と一致)" $ do
+      let beta = LA.toList (PS.psBeta psR)
+      length beta `shouldBe` 2
+      (beta !! 1) `shouldSatisfy` (> 0.3)   -- slope 正、 強く回復しなくても符号は必須
+    it "trimPropensity: [0.01, 0.99] にクリップ" $ do
+      let ps0 = PS.PropensityScore (LA.fromList [0.0, 0.5, 1.0])
+                                   (LA.fromList [0, 0]) 3
+          ps1 = PS.trimPropensity 0.01 0.99 ps0
+      LA.toList (PS.psScores ps1) `shouldBe` [0.01, 0.5, 0.99]
+    it "ipwWeights: t/p + (1-t)/(1-p) を要素ごとに計算" $ do
+      let ps0 = PS.PropensityScore (LA.fromList [0.2, 0.8])
+                                   (LA.fromList [0]) 2
+          t0  = LA.fromList [1.0, 0.0]
+          ws  = LA.toList (PS.ipwWeights ps0 t0)
+      -- 期待: [1/0.2, 1/0.2] = [5.0, 5.0]
+      ws `shouldSatisfy` (\xs -> length xs == 2
+                              && abs (xs !! 0 - 5.0) < 1e-9
+                              && abs (xs !! 1 - 5.0) < 1e-9)
+    it "attWeights: t + (1-t)·p/(1-p) を要素ごとに計算" $ do
+      let ps0 = PS.PropensityScore (LA.fromList [0.25, 0.75])
+                                   (LA.fromList [0]) 2
+          t0  = LA.fromList [1.0, 0.0]
+          ws  = LA.toList (PS.attWeights ps0 t0)
+      -- 期待: [1.0, 0.75/0.25] = [1.0, 3.0]
+      ws `shouldSatisfy` (\xs -> length xs == 2
+                              && abs (xs !! 0 - 1.0) < 1e-9
+                              && abs (xs !! 1 - 3.0) < 1e-9)
+    it "trim 後の重みが有限 (発散しない)" $ do
+      -- p = 0 / 1 を含むケースを作り、 trim 前後で max weight を比較
+      let ps0  = PS.PropensityScore (LA.fromList [0.001, 0.5, 0.999])
+                                    (LA.fromList [0]) 3
+          ps1  = PS.trimPropensity 0.01 0.99 ps0
+          t0   = LA.fromList [1.0, 1.0, 0.0]
+          ws1  = LA.toList (PS.ipwWeights ps1 t0)
+      ws1 `shouldSatisfy` all (\w -> not (isInfinite w) && w < 200)
diff --git a/test/Hanalyze/Stat/CholeskySpec.hs b/test/Hanalyze/Stat/CholeskySpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Stat/CholeskySpec.hs
@@ -0,0 +1,55 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Stat.CholeskySpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Numeric.LinearAlgebra as LA
+import qualified Hanalyze.Stat.Cholesky     as Chol
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Stat.Cholesky" $ do
+    let aSPD = LA.fromLists [[4, 2, 1], [2, 5, 3], [1, 3, 6]]
+                 :: LA.Matrix Double
+        b    = LA.asColumn (LA.fromList [1.0, 2.0, 3.0])
+
+    it "cholSolve agrees with LA.<\\> on a 3x3 SPD system (1e-9)" $ do
+      let xC = Chol.cholSolve aSPD b
+          xR = aSPD LA.<\> b
+      case xC of
+        Nothing -> expectationFailure "cholSolve returned Nothing on SPD"
+        Just xc -> LA.norm_Inf (xc - xR) < 1e-9 `shouldBe` True
+
+    it "cholSolveJitter falls back gracefully on a singular matrix" $ do
+      let aSing = LA.fromLists [[1, 0, 0], [0, 0, 0], [0, 0, 1]]
+                    :: LA.Matrix Double
+          bSing = LA.asColumn (LA.fromList [1.0, 0.0, 1.0])
+          x = Chol.cholSolveJitter aSing bSing
+      LA.rows x `shouldBe` 3   -- did not crash; whatever LSQ gives is fine
+
+    it "cholFactor returns Just for SPD and Nothing for non-SPD" $ do
+      Chol.cholFactor aSPD `shouldSatisfy` (\m -> case m of
+                                                    Just _  -> True
+                                                    Nothing -> False)
+      let aNeg = LA.fromLists [[1, 2], [2, 1]] :: LA.Matrix Double
+                  -- eigenvalues 3 and -1 → not SPD
+      Chol.cholFactor aNeg `shouldBe` Nothing
diff --git a/test/Hanalyze/Stat/ClassMetricsSpec.hs b/test/Hanalyze/Stat/ClassMetricsSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Stat/ClassMetricsSpec.hs
@@ -0,0 +1,76 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Stat.ClassMetricsSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Hanalyze.Stat.ClassMetrics as CM
+import qualified Hanalyze.Design.Custom.Model      as CM
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Stat.ClassMetrics" $ do
+    it "confusionMatrix: 完全一致で TP/TN のみ" $ do
+      let c = CM.confusionMatrix [1, 0, 1, 0, 1] [1, 0, 1, 0, 1]
+      CM.confTP c `shouldBe` 3
+      CM.confTN c `shouldBe` 2
+      CM.confFP c `shouldBe` 0
+      CM.confFN c `shouldBe` 0
+      CM.accuracy c `shouldBe` 1.0
+      CM.f1Score c  `shouldBe` 1.0
+
+    it "precision/recall/f1: 不均衡な誤分類" $ do
+      let c = CM.confusionMatrix [1, 1, 1, 0, 0] [1, 1, 0, 1, 0]
+      -- TP=2, FN=1, FP=1, TN=1
+      CM.precision c `shouldBe` 2/3   -- 2/(2+1)
+      CM.recall c    `shouldBe` 2/3   -- 2/(2+1)
+      CM.f1Score c   `shouldBe` 2/3
+
+    it "AUC: 完全分離で 1.0、ランダムスコアで ~0.5" $ do
+      let ys = [0, 0, 0, 1, 1, 1]
+          perfectScores  = [0.1, 0.2, 0.3, 0.7, 0.8, 0.9]
+          aucPerfect = CM.auc ys perfectScores
+      aucPerfect `shouldBe` 1.0
+
+    it "logLoss: 自信ある正解で小、自信ある誤りで大" $ do
+      let lossGood = CM.logLoss [1, 0, 1, 0] [0.99, 0.01, 0.99, 0.01]
+          lossBad  = CM.logLoss [1, 0, 1, 0] [0.01, 0.99, 0.01, 0.99]
+      lossGood `shouldSatisfy` (< 0.05)
+      lossBad  `shouldSatisfy` (> 4.0)
+
+    it "brierScore: 完全予測で 0、最悪予測で 1" $ do
+      let bsGood = CM.brierScore [1, 0, 1, 0] [1.0, 0.0, 1.0, 0.0]
+          bsBad  = CM.brierScore [1, 0, 1, 0] [0.0, 1.0, 0.0, 1.0]
+      bsGood `shouldSatisfy` (< 1e-10)
+      bsBad  `shouldBe` 1.0
+
+    it "MCC: 完全一致で 1、ランダムで 0 付近" $ do
+      let cGood = CM.confusionMatrix [1, 0, 1, 0, 1] [1, 0, 1, 0, 1]
+      CM.matthewsCorr cGood `shouldBe` 1.0
+
+    it "macroF1 (multi-class): 完全分類で 1.0" $ do
+      let cm = CM.confusionMulti [0, 1, 2, 0, 1, 2] [0, 1, 2, 0, 1, 2]
+      CM.accuracyMulti cm `shouldBe` 1.0
+      CM.macroF1 cm       `shouldBe` 1.0
+
+  -- ===========================================================================
+  -- Hanalyze.Stat.CV (Phase 4)
+  -- ===========================================================================
diff --git a/test/Hanalyze/Stat/CorrelationNetworkSpec.hs b/test/Hanalyze/Stat/CorrelationNetworkSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Stat/CorrelationNetworkSpec.hs
@@ -0,0 +1,89 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Stat.CorrelationNetworkSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Numeric.LinearAlgebra as LA
+import qualified Data.Vector.Storable              as VS
+import qualified System.Random.MWC as MWC
+import qualified System.Random.MWC as MWC
+import qualified Hanalyze.Stat.CorrelationNetwork     as CN
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Stat.CorrelationNetwork (Phase 32-A1 Graphical Lasso)" $ do
+    -- 真の precision matrix:
+    --   Θ = [[ 2.0, -0.8,  0.0,  0.0],
+    --        [-0.8,  2.0, -0.6,  0.0],
+    --        [ 0.0, -0.6,  2.0,  0.0],
+    --        [ 0.0,  0.0,  0.0,  2.0]]
+    -- → 非零 off-diag: (0,1), (1,2)。 (0,2), (0,3), (1,3), (2,3) はゼロ
+    -- Σ = Θ⁻¹ を計算してから X ~ N(0, Σ) をサンプリング
+    it "graphicalLasso: sparse 構造を回復 (中 λ で 0 を維持 + non-zero 検出)" $ do
+      gen <- MWC.create
+      let theta = LA.fromLists
+            [ [ 2.0, -0.8,  0.0,  0.0]
+            , [-0.8,  2.0, -0.6,  0.0]
+            , [ 0.0, -0.6,  2.0,  0.0]
+            , [ 0.0,  0.0,  0.0,  2.0] ]
+          sigma = LA.inv theta
+          -- Cholesky 因子から N(0, Σ) サンプル
+          chol  = LA.chol (LA.trustSym sigma)
+          nGL   = 500
+          pGL   = 4
+      zs <- VS.replicateM (nGL * pGL) (do
+              u1 <- MWC.uniformR (1e-9, 1.0 :: Double) gen
+              u2 <- MWC.uniformR (0.0, 1.0 :: Double) gen
+              pure (sqrt (-2 * log u1) * cos (2 * pi * u2)))
+      let zMat = LA.reshape pGL (LA.fromList (VS.toList zs))
+          xMat = zMat LA.<> chol  -- N(0, chol^T chol) = N(0, Σ)
+          fit  = CN.graphicalLasso xMat 0.05 100 1e-4
+          thetaHat = CN.glPrecision fit
+      -- 真の非零位置 (0,1), (1,2) を検出
+      abs (LA.atIndex thetaHat (0, 1)) `shouldSatisfy` (> 0.1)
+      abs (LA.atIndex thetaHat (1, 2)) `shouldSatisfy` (> 0.1)
+      -- 真のゼロ位置は十分小さい
+      abs (LA.atIndex thetaHat (0, 3)) `shouldSatisfy` (< 0.15)
+      abs (LA.atIndex thetaHat (2, 3)) `shouldSatisfy` (< 0.15)
+      CN.glConverged fit `shouldBe` True
+    it "graphicalLasso: λ=0 に近いとほぼ S⁻¹、 λ 大で対角優位 (連続性)" $ do
+      gen <- MWC.create
+      let nGL = 200
+          pGL = 3
+      zs <- VS.replicateM (nGL * pGL) (do
+              u1 <- MWC.uniformR (1e-9, 1.0 :: Double) gen
+              u2 <- MWC.uniformR (0.0, 1.0 :: Double) gen
+              pure (sqrt (-2 * log u1) * cos (2 * pi * u2)))
+      let x = LA.reshape pGL (LA.fromList (VS.toList zs))
+          fitBig   = CN.graphicalLasso x 5.0 100 1e-4
+          thetaBig = CN.glPrecision fitBig
+      -- 大 λ: off-diag がほぼ 0 に潰される
+      CN.nonZeroPrecision 0.05 thetaBig `shouldBe` 0
+    it "empiricalCov: 中央化後の (1/(n-1)) X^T X" $ do
+      let x = LA.fromLists [[1, 2], [2, 4], [3, 6], [4, 8]]
+          s = CN.empiricalCov x
+      -- 共分散: var(col1) = var([1,2,3,4]) = 5/3 ≈ 1.6667
+      --        var(col2) = 4·var(col1) = 6.6667
+      --        cov(col1, col2) = 2·var(col1) = 3.3333
+      abs (LA.atIndex s (0, 0) - 5/3) `shouldSatisfy` (< 1e-9)
+      abs (LA.atIndex s (1, 1) - 20/3) `shouldSatisfy` (< 1e-9)
+      abs (LA.atIndex s (0, 1) - 10/3) `shouldSatisfy` (< 1e-9)
diff --git a/test/Hanalyze/Stat/DescriptiveSpec.hs b/test/Hanalyze/Stat/DescriptiveSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Stat/DescriptiveSpec.hs
@@ -0,0 +1,54 @@
+{-# LANGUAGE OverloadedStrings #-}
+-- | 'Hanalyze.Stat.Descriptive' のテスト。
+--   R の @mean@/@median@/@var@/@sd@/@IQR@/@quantile(type=7)@ と数値一致を確認する
+--   (Phase 65)。 参照値は R 4.x で算出。
+module Hanalyze.Stat.DescriptiveSpec (spec) where
+
+import Test.Hspec
+import qualified Data.Vector.Storable as VS
+import qualified Hanalyze.Stat.Descriptive as D
+
+-- 近似一致
+near :: Double -> Double -> Bool
+near a b = abs (a - b) < 1e-9
+
+spec :: Spec
+spec = describe "Hanalyze.Stat.Descriptive" $ do
+  -- x <- c(1,2,3,5,7,11,13); R: mean=6, median=5, var=22.66667, sd=4.760953, IQR=7
+  let x  = VS.fromList [1,2,3,5,7,11,13] :: VS.Vector Double
+      xl = [1,2,3,5,7,11,13] :: [Double]
+
+  describe "中心" $ do
+    it "mean" $ D.mean x `shouldSatisfy` near 6
+    it "median (奇数長)" $ D.median x `shouldSatisfy` near 5
+    it "median (偶数長 = 中央 2 点平均)" $
+      D.median (VS.fromList [1,2,3,4] :: VS.Vector Double) `shouldSatisfy` near 2.5
+
+  describe "散布 (R var/sd = n-1)" $ do
+    -- R: var(x) = 21 (= 126/6・偏差平方和 126), sd = 4.582576
+    it "variance (不偏・n-1)" $ D.variance x `shouldSatisfy` near 21
+    it "sd" $ D.sd x `shouldSatisfy` near (sqrt 21)
+    it "range'" $ D.range' x `shouldSatisfy` near 12
+
+  describe "分位点 (R type-7)" $ do
+    -- R quantile(x, c(0,.25,.5,.75,.95,1), type=7):
+    --   0% 1 / 25% 2.5 / 50% 5 / 75% 9 / 95% 12.4 / 100% 13
+    it "p=0   → 最小" $ D.quantile 0    x `shouldSatisfy` near 1
+    it "p=.25 → 2.5"  $ D.quantile 0.25 x `shouldSatisfy` near 2.5
+    it "p=.5  → median" $ D.quantile 0.5  x `shouldSatisfy` near 5
+    it "p=.75 → 9"    $ D.quantile 0.75 x `shouldSatisfy` near 9
+    it "p=.95 → 12.4" $ D.quantile 0.95 x `shouldSatisfy` near 12.4
+    it "p=1   → 最大" $ D.quantile 1    x `shouldSatisfy` near 13
+    it "IQR = q75 - q25 = 6.5" $ D.iqr x `shouldSatisfy` near 6.5
+    it "percentile 95 = quantile 0.95" $
+      D.percentile 95 x `shouldSatisfy` near (D.quantile 0.95 x)
+
+  describe "[Double] wrapper" $ do
+    it "meanL ≡ mean" $ D.meanL xl `shouldSatisfy` near (D.mean x)
+    it "quantileL ≡ quantile" $ D.quantileL 0.95 xl `shouldSatisfy` near 12.4
+
+  describe "空・単一要素" $ do
+    let e = VS.empty :: VS.Vector Double
+    it "mean 空 = NaN" $ isNaN (D.mean e) `shouldBe` True
+    it "variance 単一 = NaN" $ isNaN (D.variance (VS.fromList [3])) `shouldBe` True
+    it "quantile 単一 = その値" $ D.quantile 0.7 (VS.fromList [42]) `shouldSatisfy` near 42
diff --git a/test/Hanalyze/Stat/EffectSpec.hs b/test/Hanalyze/Stat/EffectSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Stat/EffectSpec.hs
@@ -0,0 +1,95 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Stat.EffectSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Numeric.LinearAlgebra as LA
+import qualified Hanalyze.Stat.Effect          as Eff
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Stat.Effect" $ do
+    it "cohenD: 同じ分布で 0、平均差 1 SD で ~1" $ do
+      let xs = LA.fromList [1, 2, 3, 4, 5] :: LA.Vector Double
+          ys = LA.fromList [1, 2, 3, 4, 5] :: LA.Vector Double
+      Eff.cohenD xs ys `shouldBe` 0.0
+
+      let zs = LA.fromList [2, 3, 4, 5, 6] :: LA.Vector Double  -- shifted
+          d2 = Eff.cohenD zs xs
+      d2 `shouldSatisfy` (> 0.5)
+
+    it "hedgesG ≤ |cohenD| (small-sample correction)" $ do
+      let xs = LA.fromList [1, 2, 3, 4, 5] :: LA.Vector Double
+          ys = LA.fromList [3, 4, 5, 6, 7] :: LA.Vector Double
+          d  = Eff.cohenD xs ys
+          g  = Eff.hedgesG xs ys
+      abs g `shouldSatisfy` (<= abs d + 1e-9)
+
+    it "eta2: 完全分離で大、同分布で 0" $ do
+      let g1 = LA.fromList [1, 2, 3] :: LA.Vector Double
+          g2 = LA.fromList [10, 11, 12] :: LA.Vector Double
+          g3 = LA.fromList [20, 21, 22] :: LA.Vector Double
+      Eff.eta2 [g1, g2, g3] `shouldSatisfy` (> 0.9)
+
+    it "powerTTest: d = 0 で α 付近 (= ~0.05)" $ do
+      let p = Eff.powerTTest 30 0.05 0.0
+      p `shouldSatisfy` (\x -> abs (x - 0.05) < 0.01)
+
+    it "powerTTest: 大きい d で高い power" $ do
+      let p = Eff.powerTTest 30 0.05 0.8
+      p `shouldSatisfy` (> 0.85)
+
+    it "sampleSizeTTest: 小 d ほど大 n が必要" $ do
+      let n1 = Eff.sampleSizeTTest 0.80 0.05 0.2  -- small effect
+          n2 = Eff.sampleSizeTTest 0.80 0.05 0.5  -- medium
+          n3 = Eff.sampleSizeTTest 0.80 0.05 0.8  -- large
+      n1 `shouldSatisfy` (> n2)
+      n2 `shouldSatisfy` (> n3)
+
+    it "cramerV: 2×2 完全独立で ~0、強従属で大" $ do
+      Eff.cramerV 0 100 2 2 `shouldBe` 0.0
+      Eff.cramerV 50 100 2 2 `shouldSatisfy` (> 0.5)
+
+  -- ===========================================================================
+  -- Hanalyze.Model.DecisionTree (Phase 10)
+  -- ===========================================================================
+
+  describe "Hanalyze.Stat.Effect CI (Phase 13.2)" $ do
+    it "cohenDCI: 大効果 d ≈ 2 で CI が 0 を含まない" $ do
+      let xs = LA.fromList [0, 0.1, -0.1, 0.2, -0.2, 0.05, -0.05, 0.15, -0.15, 0.0]
+          ys = LA.fromList [2, 2.1, 1.9, 2.2, 1.8, 2.05, 1.95, 2.15, 1.85, 2.0]
+          (d, (lo, hi)) = Eff.cohenDCI xs ys 0.05
+      d `shouldSatisfy` (< 0)
+      hi `shouldSatisfy` (< 0)
+      lo `shouldSatisfy` (< hi)
+    it "cohenDCI: 同分布で CI が 0 を含む" $ do
+      let xs = LA.fromList [1, 2, 3, 2, 1, 2, 3, 2, 1, 2]
+          ys = LA.fromList [1.1, 2.1, 2.9, 2.05, 1.05, 1.95, 3.05, 2.1, 0.95, 2.0]
+          (_, (lo, hi)) = Eff.cohenDCI xs ys 0.05
+      lo `shouldSatisfy` (< 0)
+      hi `shouldSatisfy` (> 0)
+    it "eta2CI: 大 F で点推定が 0 より十分大きい" $ do
+      let (eta, _) = Eff.eta2CI 50.0 (2, 27) 0.05
+      eta `shouldSatisfy` (> 0.5)
+    it "eta2CI: 小 F で CI 下端が 0 に近い" $ do
+      let (_, (lo, _)) = Eff.eta2CI 0.1 (2, 27) 0.05
+      lo `shouldSatisfy` (< 0.01)
diff --git a/test/Hanalyze/Stat/GroupComparisonSpec.hs b/test/Hanalyze/Stat/GroupComparisonSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Stat/GroupComparisonSpec.hs
@@ -0,0 +1,94 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Stat.GroupComparisonSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Data.Vector as V
+import qualified Data.Text   as T
+import qualified Hanalyze.Stat.GroupComparison as GC
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Stat.GroupComparison.goodVsBad (Phase 4.2)" $ do
+    it "sanity: 空変数 / 空ラベル / 長さ mismatch は Left" $ do
+      case GC.goodVsBad [] (V.fromList [True, False]) of
+        Left _ -> pure ()
+        Right _ -> expectationFailure "expected Left for empty vars"
+      case GC.goodVsBad [("a", V.fromList [1.0, 2.0])] V.empty of
+        Left _ -> pure ()
+        Right _ -> expectationFailure "expected Left for empty labels"
+      case GC.goodVsBad
+             [("a", V.fromList [1.0, 2.0, 3.0])]
+             (V.fromList [True, False]) of
+        Left _ -> pure ()
+        Right _ -> expectationFailure "expected Left for length mismatch"
+
+    it "sanity: 群サイズ < 2 は Left" $
+      case GC.goodVsBad
+             [("a", V.fromList [1.0, 2.0, 3.0])]
+             (V.fromList [True, False, False]) of  -- nG=1
+        Left _ -> pure ()
+        Right _ -> expectationFailure "expected Left for nG<2"
+
+    it "差が無いデータでは effect ≈ 0、 p-value 高い" $ do
+      let labels = V.fromList (concat (replicate 5 [True, False]))
+          -- 両群同じ分布 (微小ばらつきあり)
+          vals   = V.fromList [1.0, 1.1, 0.9, 1.0, 1.1, 1.0, 1.0, 0.9, 1.1, 1.0]
+      case GC.goodVsBad [("x", vals)] labels of
+        Left e -> expectationFailure (T.unpack e)
+        Right [r] -> do
+          abs (GC.gcrEffect r) `shouldSatisfy` (< 1.0)  -- 小さい effect
+          GC.gcrPValue r `shouldSatisfy` (> 0.1)        -- 有意ではない
+        Right _ -> expectationFailure "expected single result"
+
+    it "大きな差のデータでは |effect| が大きい、 p-value 小さい" $ do
+      let labels = V.fromList (replicate 10 True ++ replicate 10 False)
+          vals   = V.fromList ([1, 1, 2, 2, 1, 1, 2, 2, 1, 2]
+                            ++ [10, 11, 12, 10, 11, 12, 10, 11, 12, 11])
+      case GC.goodVsBad [("x", vals)] labels of
+        Left e -> expectationFailure (T.unpack e)
+        Right [r] -> do
+          abs (GC.gcrEffect r) `shouldSatisfy` (> 3.0)  -- huge effect
+          GC.gcrPValue r `shouldSatisfy` (< 1e-6)
+          GC.gcrMeanG r `shouldSatisfy` (\v -> v < 2.0)
+          GC.gcrMeanB r `shouldSatisfy` (\v -> v > 9.0)
+          GC.gcrNG r `shouldBe` 10
+          GC.gcrNB r `shouldBe` 10
+        Right _ -> expectationFailure "expected single result"
+
+    it "複数変数の場合、 効果量絶対値降順でソート" $ do
+      let labels = V.fromList (replicate 10 True ++ replicate 10 False)
+          -- v1: large diff (Good ≈ 1、 Bad ≈ 10)
+          v1 = V.fromList ([1, 1.1, 0.9, 1, 1.1, 1, 0.9, 1, 1.1, 1]
+                        ++ [10, 10.1, 9.9, 10, 10.1, 10, 9.9, 10, 10.1, 10])
+          -- v2: medium diff (Good ≈ 1、 Bad ≈ 3)
+          v2 = V.fromList ([1, 1.1, 0.9, 1, 1.1, 1, 0.9, 1, 1.1, 1]
+                        ++ [3, 3.1, 2.9, 3, 3.1, 3, 2.9, 3, 3.1, 3])
+          -- v3: no diff (両群同じ分布)
+          v3 = V.fromList ([1, 1.1, 0.9, 1, 1.1, 1, 0.9, 1, 1.1, 1]
+                        ++ [1, 1.1, 0.9, 1, 1.1, 1, 0.9, 1, 1.1, 1])
+      case GC.goodVsBad [("v3", v3), ("v1", v1), ("v2", v2)] labels of
+        Left e -> expectationFailure (T.unpack e)
+        Right rs -> do
+          length rs `shouldBe` 3
+          -- 並び順は v1 → v2 → v3
+          map GC.gcrVarName rs `shouldBe` ["v1", "v2", "v3"]
diff --git a/test/Hanalyze/Stat/InterpolateSpec.hs b/test/Hanalyze/Stat/InterpolateSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Stat/InterpolateSpec.hs
@@ -0,0 +1,54 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Stat.InterpolateSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Hanalyze.Stat.Interpolate  as Interp
+import qualified Hanalyze.Stat.Interpret       as Interp
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Stat.Interpolate" $ do
+    let pts = [(0,0), (1,1), (2,4), (3,9), (4,16)]   -- y = x^2
+    it "Linear: 観測点で原値を厳密に再現" $ do
+      let f = Interp.interp1d Interp.Linear pts
+      mapM_ (\(x, y) -> abs (f x - y) `shouldSatisfy` (< 1e-12)) pts
+
+    it "NaturalSpline: 観測点で原値を厳密に再現、中間点で線形より精度高い" $ do
+      let fl = Interp.interp1d Interp.Linear pts
+          fs = Interp.interp1d Interp.NaturalSpline pts
+          true x = x * x
+          xMid = 1.5
+          errL = abs (fl xMid - true xMid)
+          errS = abs (fs xMid - true xMid)
+      mapM_ (\(x, y) -> abs (fs x - y) `shouldSatisfy` (< 1e-9)) pts
+      errS `shouldSatisfy` (< errL)
+
+    it "PCHIP: 単調データ ([0,1,2,4,8,16]) で出力も単調" $ do
+      let mp = [(0,0), (1,1), (2,2), (3,4), (4,8), (5,16)]
+          fp = Interp.interp1d Interp.PCHIP mp
+          ys = map fp [0, 0.1 .. 5]
+      and (zipWith (<=) ys (tail ys)) `shouldBe` True
+
+    it "PCHIP: 観測点で原値を厳密に再現" $ do
+      let fp = Interp.interp1d Interp.PCHIP pts
+      mapM_ (\(x, y) -> abs (fp x - y) `shouldSatisfy` (< 1e-9)) pts
diff --git a/test/Hanalyze/Stat/InterpretSpec.hs b/test/Hanalyze/Stat/InterpretSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Stat/InterpretSpec.hs
@@ -0,0 +1,69 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Stat.InterpretSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Hanalyze.Stat.Interpolate  as Interp
+import qualified Hanalyze.Stat.Interpret       as Interp
+import qualified System.Random.MWC as MWC
+import qualified System.Random.MWC as MWC
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Stat.Interpret" $ do
+    it "permutationImportance: 重要 feature が高 importance" $ do
+      gen <- MWC.createSystemRandom
+      -- y = x_0 のみに依存、x_1 は無関係
+      let xs = [[fromIntegral i, fromIntegral (i * i)]
+               | i <- [1 .. 20 :: Int]]
+          ys = [head row | row <- xs]
+          predict zs = [head row | row <- zs]
+          score yt yp =
+            let mse = sum [(yt !! i - yp !! i) ^ (2 :: Int)
+                          | i <- [0 .. length yt - 1]]
+            in negate mse  -- higher (= 0) is better
+          cfg = (Interp.defaultPermutationConfig)
+                  { Interp.pcNRepeats = 5 }
+      r <- Interp.permutationImportance cfg predict score xs ys gen
+      let imps = Interp.piMeanImportance r
+      -- 0番目 feature の importance が高いはず (重要)
+      head imps `shouldSatisfy` (> 0.5 * (imps !! 1))
+
+    it "partialDependence: y = x[0] で PDP が grid に追従" $ do
+      let xs = [[fromIntegral i, 0.0] | i <- [1..10::Int]]
+          predict zs = [head row | row <- zs]
+          grid = [1.0, 5.0, 10.0]
+          pdp = Interp.partialDependence predict xs 0 grid
+      -- 各 grid 点での PD は値そのもの (= grid 値)
+      Interp.pdpMeanPredict pdp `shouldBe` grid
+
+    it "icePlot: 全 sample について curve を返す" $ do
+      let xs = [[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]]
+          predict zs = [sum row | row <- zs]
+          grid = [0.0, 1.0, 2.0]
+          ice = Interp.icePlot predict xs 0 grid
+      length (Interp.iceCurves ice) `shouldBe` 3
+      length (Interp.iceFeatureValues ice) `shouldBe` 3
+      -- iceMean should equal partial dependence
+      length (Interp.iceMean ice) `shouldBe` 3
+
+  -- ─────────────────────────────────────────────────────────────────────
diff --git a/test/Hanalyze/Stat/KernelDistSpec.hs b/test/Hanalyze/Stat/KernelDistSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Stat/KernelDistSpec.hs
@@ -0,0 +1,59 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Stat.KernelDistSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Numeric.LinearAlgebra as LA
+import qualified Hanalyze.Stat.KernelDist   as KD
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Stat.KernelDist" $ do
+    let xs = LA.fromLists [[0, 0], [3, 4], [1, 1]] :: LA.Matrix Double
+        d  = KD.pairwiseSqDist xs
+        -- naive reference
+        naive m =
+          let rows = LA.toRows m
+              n    = length rows
+          in (n LA.>< n)
+               [ let r = rows !! i - rows !! j in r `LA.dot` r
+               | i <- [0 .. n - 1], j <- [0 .. n - 1] ]
+        ref = naive xs
+
+    it "returns an n x n matrix with zero diagonal" $ do
+      LA.rows d `shouldBe` 3
+      LA.cols d `shouldBe` 3
+      LA.toList (LA.takeDiag d) `shouldBe` [0, 0, 0]
+
+    it "matches the naive reference within 1e-9" $
+      LA.norm_Inf (d - ref) < 1e-9 `shouldBe` True
+
+    it "pairwiseSqDistXY matches reference for cross-matrix" $ do
+      let ys  = LA.fromLists [[0, 0], [1, 0]] :: LA.Matrix Double
+          dXY = KD.pairwiseSqDistXY xs ys
+          rxs = LA.toRows xs
+          rys = LA.toRows ys
+          ref' = (LA.rows xs LA.>< LA.rows ys)
+                   [ let r = rxs !! i - rys !! j in r `LA.dot` r
+                   | i <- [0 .. LA.rows xs - 1]
+                   , j <- [0 .. LA.rows ys - 1] ]
+      LA.norm_Inf (dXY - ref') < 1e-9 `shouldBe` True
diff --git a/test/Hanalyze/Stat/MCMCSpec.hs b/test/Hanalyze/Stat/MCMCSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Stat/MCMCSpec.hs
@@ -0,0 +1,68 @@
+-- | Hanalyze.Stat.MCMC の spec (Phase 92 B4: essBulk の arviz 互換性)。
+--
+-- golden 値は arviz (pymc312 venv・2026-07-17) の @az.ess(method="bulk")@ で
+-- 採取した。テストデータは整数 LCG + AR(1) フィルタで生成する —
+-- 整数演算 + IEEE の積和のみなので Python 側と **bit 一致** し、rank が
+-- 言語間で入れ替わらないことを保証できる (sin 等の libm 依存を避ける)。
+-- 再採取手順は本 spec 末尾のコメント参照。
+module Hanalyze.Stat.MCMCSpec (spec) where
+
+import Test.Hspec
+
+import Hanalyze.Stat.MCMC (ess, essBulk)
+
+-- | glibc 系数の LCG (mod 2^31)・[-0.5, 0.5) 一様。Integer 演算なので厳密。
+lcg :: Int -> Int -> [Double]
+lcg seed n = take n (map toU (drop 1 (iterate step (fromIntegral seed))))
+  where
+    step x = (1103515245 * x + 12345) `mod` (2 ^ (31 :: Int)) :: Integer
+    toU x  = fromIntegral x / 2 ^ (31 :: Int) - 0.5
+
+-- | AR(1): y_i = phi*y_{i-1} + u_i (y_0 起点 0)。積和 2 op のみで言語間 bit 一致。
+ar1 :: Int -> Int -> Double -> [Double]
+ar1 seed n phi = drop 1 (scanl (\prev u -> phi * prev + u) 0 (lcg seed n))
+
+relClose :: Double -> Double -> Double -> Bool
+relClose tol expected actual = abs (actual - expected) <= tol * abs expected
+
+spec :: Spec
+spec = do
+  describe "essBulk (arviz az.ess(method=\"bulk\") 互換)" $ do
+    it "4 chain x 300 (AR(1) phi=0.9) で arviz golden に一致" $ do
+      let chains = [ ar1 (c + 1) 300 0.9 | c <- [0 .. 3] ]
+      essBulk chains `shouldSatisfy` relClose 1e-6 84.42798772749184
+
+    it "2 chain x 100 (AR(1) phi=0.3) で arviz golden に一致" $ do
+      let chains = [ ar1 (c + 10) 100 0.3 | c <- [0 .. 1] ]
+      essBulk chains `shouldSatisfy` relClose 1e-6 94.75370782074775
+
+    it "奇数長 3 chain x 101 (split の中央落ち) で arviz golden に一致" $ do
+      let chains = [ ar1 (c + 5) 101 0.5 | c <- [0 .. 2] ]
+      essBulk chains `shouldSatisfy` relClose 1e-6 126.38779986541799
+
+    it "定数 chain (分散 0) は総 draw 数へフォールバック" $
+      essBulk [replicate 50 1.0, replicate 50 1.0] `shouldBe` 100
+
+    it "短すぎる chain (split 後 4 draw 未満) は元の総 draw 数を返す" $
+      essBulk [[1, 2, 3]] `shouldBe` 3
+
+    it "独立標本 (iid 一様 4 chain x 500) で arviz golden に一致" $ do
+      -- 独立標本では総 draw 数 2000 を多少超える (arviz と同挙動)
+      let chains = [ lcg (c + 100) 500 | c <- [0 .. 3] ]
+      essBulk chains `shouldSatisfy` relClose 1e-6 2190.946800658338
+
+  describe "ess (既存・単 chain Geyer IMSE)" $
+    it "essBulk 導入後も従来挙動 (tau 下限 1 で n 頭打ち) を維持" $ do
+      let xs = lcg 7 200
+      ess xs `shouldSatisfy` (<= 200.000001)
+
+-- golden 再採取 (pymc312 venv):
+--   def lcg(seed,n):
+--     x=seed; out=[]
+--     for _ in range(n): x=(1103515245*x+12345)%(2**31); out.append(x/2**31-0.5)
+--     return out
+--   def ar1(seed,n,phi):
+--     u=lcg(seed,n); p=0.0; y=[]
+--     for ui in u: p=phi*p+ui; y.append(p)
+--     return y
+--   az.ess(az.convert_to_dataset({"x": np.array([ar1(c+1,300,0.9) for c in range(4)])}), method="bulk")
diff --git a/test/Hanalyze/Stat/MDSSpec.hs b/test/Hanalyze/Stat/MDSSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Stat/MDSSpec.hs
@@ -0,0 +1,55 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Stat.MDSSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Numeric.LinearAlgebra as LA
+import qualified Hanalyze.Stat.MDS             as MDS
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Stat.MDS (Phase 34-A3)" $ do
+    -- 2-D 配列 → 距離行列 → 2-D 再構成で距離保存を確認
+    let pts2d = LA.fromLists [[0, 0], [1, 0], [0, 1], [1, 1], [2, 0.5]]
+        d     = MDS.euclideanDist pts2d
+    it "mdsClassical: 2-D データを 2-D 復元すると距離が保たれる" $ do
+      let emb = MDS.mdsClassical d 2
+          d2  = MDS.euclideanDist emb
+          err = LA.maxElement (LA.cmap abs (d - d2))
+      err `shouldSatisfy` (< 1e-8)
+    it "mdsClassical: 出力 shape は (n × k)" $ do
+      let emb = MDS.mdsClassical d 2
+      LA.rows emb `shouldBe` 5
+      LA.cols emb `shouldBe` 2
+    it "euclideanDist: 対角は 0、 対称" $ do
+      let n = 5
+      and [ abs (LA.atIndex d (i, i)) < 1e-10 | i <- [0 .. n - 1] ]
+        `shouldBe` True
+      and [ abs (LA.atIndex d (i, j) - LA.atIndex d (j, i)) < 1e-10
+          | i <- [0 .. n - 1], j <- [0 .. n - 1] ]
+        `shouldBe` True
+    it "mdsSammon: stress が古典 MDS より同等以下" $ do
+      let embC = MDS.mdsClassical d 2
+          embS = MDS.mdsSammon MDS.defaultSammonConfig d 2
+          sC   = MDS.sammonStress d embC
+          sS   = MDS.sammonStress d embS
+      sS `shouldSatisfy` (<= sC + 1e-8)
diff --git a/test/Hanalyze/Stat/MultipleTestingSpec.hs b/test/Hanalyze/Stat/MultipleTestingSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Stat/MultipleTestingSpec.hs
@@ -0,0 +1,58 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Stat.MultipleTestingSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Hanalyze.Stat.MultipleTesting as MT
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Stat.MultipleTesting" $ do
+    it "Bonferroni: p × m, capped at 1" $ do
+      let ps = [0.01, 0.04, 0.05, 0.10]
+          adj = MT.bonferroni ps
+      adj `shouldBe` [0.04, 0.16, 0.20, 0.40]
+
+    it "Holm: 単調 + Bonferroni より緩い" $ do
+      let ps = [0.01, 0.02, 0.03, 0.04]
+          adj = MT.holm ps
+      -- 各 adj ≥ 元 p、最初は p × m = 0.04
+      head adj `shouldBe` 0.04
+      and (zipWith (<=) ps adj) `shouldBe` True
+
+    it "BH: monotonic non-decreasing in sorted p order" $ do
+      let ps = [0.01, 0.04, 0.03, 0.05]
+          adj = MT.benjaminiHochberg ps
+      length adj `shouldBe` 4
+      all (<= 1.0) adj `shouldBe` True
+      all (>= 0.0) adj `shouldBe` True
+
+    it "BY: BH より conservative" $ do
+      let ps = [0.01, 0.02, 0.03, 0.04, 0.05]
+          bh = MT.benjaminiHochberg ps
+          by = MT.benjaminiYekutieli ps
+      -- BY は cumulative harmonic factor を掛けるので大きい
+      and (zipWith (>=) by bh) `shouldBe` True
+
+  -- ===========================================================================
+  -- Hanalyze.Stat.Bootstrap (Phase 7)
+  -- ===========================================================================
diff --git a/test/Hanalyze/Stat/NumberFormatSpec.hs b/test/Hanalyze/Stat/NumberFormatSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Stat/NumberFormatSpec.hs
@@ -0,0 +1,47 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Stat.NumberFormatSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Hanalyze.Stat.NumberFormat as NF
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Stat.NumberFormat" $ do
+    it "0 → '0.00'"            $ NF.fmtNum 0       `shouldBe` "0.00"
+    it "中域 (0.01..999) は固定小数点 2 桁" $ do
+      NF.fmtNum 0.91   `shouldBe` "0.91"
+      NF.fmtNum 12.34  `shouldBe` "12.34"
+      NF.fmtNum 998.7  `shouldBe` "998.70"
+    it "巨大値は指数表記" $ do
+      NF.fmtNum 1.10e13 `shouldBe` "1.10E+13"
+      NF.fmtNum 1234.5  `shouldBe` "1.23E+3"
+    it "極小値は指数表記" $ do
+      NF.fmtNum 3.057e-24 `shouldBe` "3.06E-24"
+      NF.fmtNum 0.0099    `shouldBe` "9.90E-3"
+    it "負の値" $ do
+      NF.fmtNum (-12.34)  `shouldBe` "-12.34"
+      NF.fmtNum (-1.5e10) `shouldBe` "-1.50E+10"
+    it "非有限値" $ do
+      NF.fmtNum (0/0)        `shouldBe` "NaN"
+      NF.fmtNum (1/0)        `shouldBe` "+Inf"
+      NF.fmtNum (-1/0)       `shouldBe` "-Inf"
diff --git a/test/Hanalyze/Stat/QuasiRandomSpec.hs b/test/Hanalyze/Stat/QuasiRandomSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Stat/QuasiRandomSpec.hs
@@ -0,0 +1,71 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Stat.QuasiRandomSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Hanalyze.Stat.QuasiRandom  as QR
+import qualified System.Random.MWC as MWC
+import qualified System.Random.MWC as MWC
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Stat.QuasiRandom (Halton)" $ do
+    it "haltonSequence 10 1 lies in [0, 1) and is a permutation" $ do
+      let pts = QR.haltonSequence 10 1
+      length pts `shouldBe` 10
+      all (\[u] -> u >= 0 && u < 1) pts `shouldBe` True
+
+    it "haltonSequence 16 2 covers a 4x4 grid better than random" $ do
+      -- For n = 16, d = 2 the Halton points should cover all 16
+      -- 0.25-bins; an iid uniform usually misses some.
+      let pts = QR.haltonSequence 16 2
+          binIdx p = (floor (4 * head p) :: Int,
+                      floor (4 * (p !! 1)) :: Int)
+          unique = length (foldr (\b acc -> if b `elem` acc then acc
+                                              else b : acc) [] (map binIdx pts))
+      unique `shouldSatisfy` (>= 14)   -- Halton normally hits ≥ 14 / 16
+
+    it "haltonSequenceIn rescales into the supplied bounds" $ do
+      let bs  = [(-2, 2), (10, 20)] :: [(Double, Double)]
+          pts = QR.haltonSequenceIn 5 bs
+      all (\[a, b] -> a >= -2 && a <  2
+                   && b >= 10 && b <  20) pts `shouldBe` True
+
+    it "lhsSamples 10 3 lies in [0,1)^3 and fills every per-dim cell" $ do
+      gen <- MWC.createSystemRandom
+      pts <- QR.lhsSamples 10 3 gen
+      length pts `shouldBe` 10
+      all (\xs -> all (\u -> u >= 0 && u < 1) xs) pts `shouldBe` True
+      -- For each dim, the 10 points should occupy 10 distinct cells
+      -- (= floor(10 * u) is a permutation of [0..9]).
+      let cellsAlong k = map (\xs -> floor (10 * (xs !! k)) :: Int) pts
+          ok k = sort (cellsAlong k) == [0 .. 9]
+      ok 0 `shouldBe` True
+      ok 1 `shouldBe` True
+      ok 2 `shouldBe` True
+
+    it "lhsSamplesIn rescales into bounds" $ do
+      gen <- MWC.createSystemRandom
+      let bs = [(-1, 1), (5, 10)] :: [(Double, Double)]
+      pts <- QR.lhsSamplesIn 8 bs gen
+      all (\[a, b] -> a >= -1 && a <  1
+                   && b >=  5 && b < 10) pts `shouldBe` True
diff --git a/test/Hanalyze/Stat/SPCSpec.hs b/test/Hanalyze/Stat/SPCSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Stat/SPCSpec.hs
@@ -0,0 +1,485 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Stat.SPCSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Data.Vector as V
+import qualified Data.Text   as T
+import qualified Hanalyze.Stat.SPC             as SPC
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Stat.SPC X-bar / R chart (Phase 1.2)" $ do
+    -- Montgomery 9th ed. Example 6.1 (Piston Ring): n=5, k=25
+    -- Here we use a small synthetic but consistent set so we can check
+    -- the algebra exactly.
+    let -- 5 subgroups, each of size 4
+        subs :: V.Vector (V.Vector Double)
+        subs = V.fromList $ map V.fromList
+          [ [10.0, 10.4, 10.2, 9.8]   -- mean 10.10  range 0.6
+          , [9.9,  10.1, 10.3, 10.0]  -- mean 10.075 range 0.4
+          , [10.2, 9.9,  10.0, 10.3]  -- mean 10.10  range 0.4
+          , [9.8,  10.0, 10.1, 9.9]   -- mean 9.95   range 0.3
+          , [10.1, 10.2, 10.0, 10.4]  -- mean 10.175 range 0.4
+          ]
+        -- Hand-computed: X̿ = mean of means; R̄ = mean of ranges
+        expectedXBarBar = (10.10 + 10.075 + 10.10 + 9.95 + 10.175) / 5
+        expectedRBar    = (0.6  + 0.4   + 0.4  + 0.3 + 0.4)   / 5
+        -- For n=4: A2 = 0.729, D3 = 0, D4 = 2.282, d2 = 2.059
+        a2_4 = 0.729 :: Double
+        d3_4 = 0.000 :: Double
+        d4_4 = 2.282 :: Double
+        d2_4 = 2.059 :: Double
+
+    it "fitSPC XR returns 2 charts (X-bar then R)" $
+      case SPC.fitSPC SPC.XR (SPC.VarSubgroups subs) of
+        Left  e  -> expectationFailure (T.unpack e)
+        Right xs -> do
+          length xs `shouldBe` 2
+          SPC.spcChartName (xs !! 0) `shouldBe` "X-bar"
+          SPC.spcChartName (xs !! 1) `shouldBe` "R"
+
+    it "X-bar chart の center, UCL, LCL が Montgomery 公式と一致" $
+      case SPC.fitSPC SPC.XR (SPC.VarSubgroups subs) of
+        Left  e        -> expectationFailure (T.unpack e)
+        Right (x:_:_)  -> do
+          SPC.spcCenter x  `shouldSatisfy` (\v -> abs (v - expectedXBarBar) < 1e-9)
+          let uclX = expectedXBarBar + a2_4 * expectedRBar
+              lclX = expectedXBarBar - a2_4 * expectedRBar
+          V.head (SPC.spcUCL x) `shouldSatisfy` (\v -> abs (v - uclX) < 1e-9)
+          V.head (SPC.spcLCL x) `shouldSatisfy` (\v -> abs (v - lclX) < 1e-9)
+        Right _ -> expectationFailure "expected 2 charts"
+
+    it "R chart の center, UCL, LCL が Montgomery 公式と一致" $
+      case SPC.fitSPC SPC.XR (SPC.VarSubgroups subs) of
+        Left  e          -> expectationFailure (T.unpack e)
+        Right (_:r:_)    -> do
+          SPC.spcCenter r `shouldSatisfy` (\v -> abs (v - expectedRBar) < 1e-9)
+          let uclR = d4_4 * expectedRBar
+              lclR = d3_4 * expectedRBar
+          V.head (SPC.spcUCL r) `shouldSatisfy` (\v -> abs (v - uclR) < 1e-9)
+          V.head (SPC.spcLCL r) `shouldSatisfy` (\v -> abs (v - lclR) < 1e-9)
+        Right _ -> expectationFailure "expected 2 charts"
+
+    it "推定 σ = R̄ / d2(n) (両 chart で同値)" $
+      case SPC.fitSPC SPC.XR (SPC.VarSubgroups subs) of
+        Left e            -> expectationFailure (T.unpack e)
+        Right (x:r:_)     -> do
+          let expectedSigma = expectedRBar / d2_4
+          SPC.spcSigma x `shouldSatisfy` (\v -> abs (v - expectedSigma) < 1e-9)
+          SPC.spcSigma r `shouldBe` SPC.spcSigma x
+        Right _ -> expectationFailure "expected 2 charts"
+
+    it "subgroup size が不揃いだと Left" $
+      case SPC.fitSPC SPC.XR (SPC.VarSubgroups (V.fromList
+             [ V.fromList [1, 2, 3]
+             , V.fromList [4, 5]  -- size mismatch
+             ])) of
+        Left  _ -> pure ()
+        Right _ -> expectationFailure "expected Left for mismatched subgroup sizes"
+
+    it "subgroup size が 範囲外 (1 or >15) だと Left" $ do
+      case SPC.fitSPC SPC.XR (SPC.VarSubgroups (V.fromList
+             [ V.fromList [1]
+             , V.fromList [2]
+             ])) of
+        Left  _ -> pure ()
+        Right _ -> expectationFailure "expected Left for n=1"
+      case SPC.fitSPC SPC.XR (SPC.VarSubgroups (V.fromList
+             [ V.fromList (replicate 16 1)
+             , V.fromList (replicate 16 2)
+             ])) of
+        Left  _ -> pure ()
+        Right _ -> expectationFailure "expected Left for n=16"
+
+    it "chart kind と入力の不一致は Left" $
+      case SPC.fitSPC SPC.XR (SPC.VarIndividual (V.fromList [1, 2, 3])) of
+        Left  _ -> pure ()
+        Right _ -> expectationFailure "expected Left for XR + VarIndividual"
+
+  describe "Hanalyze.Stat.SPC I-MR chart (Phase 1.2)" $ do
+    let xs = V.fromList [10.0, 10.2, 9.8, 10.1, 9.9, 10.3, 10.0, 9.7, 10.2, 10.1]
+        -- moving range:
+        -- |0.2|, |0.4|, |0.3|, |0.2|, |0.4|, |0.3|, |0.3|, |0.5|, |0.1|
+        -- MR̄ = 2.7 / 9 = 0.3
+        -- x̄ = sum(xs)/10 = 100.3 / 10 = 10.03
+        expectedXBar = 10.03
+        expectedMRBar = 0.3
+        -- For n=2: d2 = 1.128, D4 = 3.267, D3 = 0
+        d2_2 = 1.128 :: Double
+        d4_2 = 3.267 :: Double
+        expectedSigma = expectedMRBar / d2_2
+        expectedUCLI = expectedXBar + 3 * expectedSigma
+        expectedLCLI = expectedXBar - 3 * expectedSigma
+        expectedUCLMR = d4_2 * expectedMRBar
+
+    it "fitSPC IMR は I chart と MR chart を返す" $
+      case SPC.fitSPC SPC.IMR (SPC.VarIndividual xs) of
+        Left e         -> expectationFailure (T.unpack e)
+        Right [iCh, mr] -> do
+          SPC.spcChartName iCh `shouldBe` "I"
+          SPC.spcChartName mr  `shouldBe` "MR"
+        Right _ -> expectationFailure "expected 2 charts"
+
+    it "I chart の CL / UCL / LCL が MR̄/d2(2)·3 公式と一致" $
+      case SPC.fitSPC SPC.IMR (SPC.VarIndividual xs) of
+        Left e            -> expectationFailure (T.unpack e)
+        Right (iCh:_)     -> do
+          SPC.spcCenter iCh `shouldSatisfy` (\v -> abs (v - expectedXBar) < 1e-9)
+          V.head (SPC.spcUCL iCh) `shouldSatisfy` (\v -> abs (v - expectedUCLI) < 1e-9)
+          V.head (SPC.spcLCL iCh) `shouldSatisfy` (\v -> abs (v - expectedLCLI) < 1e-9)
+          V.length (SPC.spcPoints iCh) `shouldBe` V.length xs
+        Right _ -> expectationFailure "expected at least 1 chart"
+
+    it "MR chart の CL / UCL / LCL" $
+      case SPC.fitSPC SPC.IMR (SPC.VarIndividual xs) of
+        Left e            -> expectationFailure (T.unpack e)
+        Right (_:mr:_)    -> do
+          SPC.spcCenter mr `shouldSatisfy` (\v -> abs (v - expectedMRBar) < 1e-9)
+          V.head (SPC.spcUCL mr) `shouldSatisfy` (\v -> abs (v - expectedUCLMR) < 1e-9)
+          V.head (SPC.spcLCL mr) `shouldBe` 0
+          V.length (SPC.spcPoints mr) `shouldBe` V.length xs - 1
+        Right _ -> expectationFailure "expected 2 charts"
+
+    it "観測 1 個以下だと Left" $ do
+      case SPC.fitSPC SPC.IMR (SPC.VarIndividual (V.fromList [1.0])) of
+        Left  _ -> pure ()
+        Right _ -> expectationFailure "expected Left for n=1"
+      case SPC.fitSPC SPC.IMR (SPC.VarIndividual V.empty) of
+        Left  _ -> pure ()
+        Right _ -> expectationFailure "expected Left for empty"
+
+  describe "Hanalyze.Stat.SPC attribute charts: p / np / c / u (Phase 1.3)" $ do
+
+    --------------------------------------------------------------------- p
+    it "p chart: 一定 n では p̂ = d/n、 p̄ = Σd/Σn、 UCL/LCL は ±3·sqrt(p̄q̄/n)" $ do
+      -- 全 subgroup n=100、 不良数 [5, 6, 4, 7, 3]
+      let ds  = V.fromList [5,6,4,7,3]
+          ns  = V.fromList [100,100,100,100,100]
+          pBar = (5+6+4+7+3) / 500.0 :: Double  -- = 0.05
+          se   = sqrt (pBar*(1-pBar)/100)
+          ucl0 = pBar + 3*se
+          lcl0 = max 0 (pBar - 3*se)
+      case SPC.fitSPC SPC.P (SPC.AttrProportion ds ns) of
+        Left e        -> expectationFailure (T.unpack e)
+        Right [ch]    -> do
+          SPC.spcChartName ch `shouldBe` "p"
+          SPC.spcCenter ch `shouldSatisfy` (\v -> abs (v - pBar) < 1e-12)
+          V.head (SPC.spcUCL ch) `shouldSatisfy` (\v -> abs (v - ucl0) < 1e-12)
+          V.head (SPC.spcLCL ch) `shouldSatisfy` (\v -> abs (v - lcl0) < 1e-12)
+          -- p̂_0 = 5/100 = 0.05
+          V.head (SPC.spcPoints ch) `shouldSatisfy` (\v -> abs (v - 0.05) < 1e-12)
+        Right _ -> expectationFailure "expected single chart"
+
+    it "p chart: 可変 n では UCL_i が point ごとに違う" $
+      case SPC.fitSPC SPC.P
+             (SPC.AttrProportion (V.fromList [5,5,5]) (V.fromList [100,200,400])) of
+        Left e        -> expectationFailure (T.unpack e)
+        Right [ch]    -> do
+          let ucls = V.toList (SPC.spcUCL ch)
+          -- n が増えるほど UCL は CL に近づく (狭まる)
+          (ucls !! 0) `shouldSatisfy` (> (ucls !! 1))
+          (ucls !! 1) `shouldSatisfy` (> (ucls !! 2))
+        Right _ -> expectationFailure "expected single chart"
+
+    it "p chart: 不良数 > sample size は Left" $
+      case SPC.fitSPC SPC.P
+             (SPC.AttrProportion (V.fromList [10,3]) (V.fromList [5,5])) of
+        Left  _ -> pure ()
+        Right _ -> expectationFailure "expected Left"
+
+    --------------------------------------------------------------------- np
+    it "np chart: CL = n·p̄, σ̂ = sqrt(n·p̄·(1−p̄)), UCL/LCL = ±3σ̂" $ do
+      let ds = V.fromList [5,6,4,7,3]
+          n  = 100
+          pBar = 25 / 500.0 :: Double  -- = 0.05
+          cl   = fromIntegral n * pBar  -- = 5.0
+          sigma = sqrt (fromIntegral n * pBar * (1 - pBar))
+          ucl   = cl + 3 * sigma
+          lcl   = max 0 (cl - 3 * sigma)
+      case SPC.fitSPC SPC.NP (SPC.AttrCount ds n) of
+        Left e        -> expectationFailure (T.unpack e)
+        Right [ch]    -> do
+          SPC.spcChartName ch `shouldBe` "np"
+          SPC.spcCenter ch     `shouldSatisfy` (\v -> abs (v - cl) < 1e-12)
+          V.head (SPC.spcUCL ch) `shouldSatisfy` (\v -> abs (v - ucl) < 1e-12)
+          V.head (SPC.spcLCL ch) `shouldSatisfy` (\v -> abs (v - lcl) < 1e-12)
+          SPC.spcSigma ch       `shouldSatisfy` (\v -> abs (v - sigma) < 1e-12)
+        Right _ -> expectationFailure "expected single chart"
+
+    it "np chart: d > n は Left" $
+      case SPC.fitSPC SPC.NP (SPC.AttrCount (V.fromList [3, 11, 2]) 10) of
+        Left  _ -> pure ()
+        Right _ -> expectationFailure "expected Left"
+
+    --------------------------------------------------------------------- c
+    it "c chart: CL = c̄, σ̂ = sqrt(c̄), UCL = c̄ + 3·sqrt(c̄)" $ do
+      let ds = V.fromList [4,5,3,6,2,5,4]
+          cBar = (4+5+3+6+2+5+4) / 7.0 :: Double  -- = 29/7 ≈ 4.142857
+          sigma = sqrt cBar
+          ucl   = cBar + 3 * sigma
+      case SPC.fitSPC SPC.C (SPC.AttrDefects ds) of
+        Left e        -> expectationFailure (T.unpack e)
+        Right [ch]    -> do
+          SPC.spcChartName ch `shouldBe` "c"
+          SPC.spcCenter ch     `shouldSatisfy` (\v -> abs (v - cBar) < 1e-12)
+          V.head (SPC.spcUCL ch) `shouldSatisfy` (\v -> abs (v - ucl) < 1e-12)
+          SPC.spcSigma ch       `shouldSatisfy` (\v -> abs (v - sigma) < 1e-12)
+        Right _ -> expectationFailure "expected single chart"
+
+    it "c chart: c̄ が小さければ LCL = 0 にクリップ" $
+      case SPC.fitSPC SPC.C (SPC.AttrDefects (V.fromList [0,1,0,1,0])) of
+        Left e        -> expectationFailure (T.unpack e)
+        Right [ch]    -> V.head (SPC.spcLCL ch) `shouldBe` 0
+        Right _       -> expectationFailure "expected single chart"
+
+    --------------------------------------------------------------------- u
+    it "u chart: u_i = d_i/n_i, ū = Σd/Σn, UCL_i 可変" $ do
+      let ds  = V.fromList [10, 8, 12, 6]
+          ns  = V.fromList [50, 40, 60, 30]
+          uBar = (10+8+12+6) / fromIntegral (50+40+60+30) :: Double  -- = 36/180 = 0.2
+      case SPC.fitSPC SPC.U (SPC.AttrDefectRate ds ns) of
+        Left e        -> expectationFailure (T.unpack e)
+        Right [ch]    -> do
+          SPC.spcChartName ch `shouldBe` "u"
+          SPC.spcCenter ch    `shouldSatisfy` (\v -> abs (v - uBar) < 1e-12)
+          -- u_0 = 10/50 = 0.2
+          V.head (SPC.spcPoints ch) `shouldSatisfy` (\v -> abs (v - 0.2) < 1e-12)
+          -- UCL_i が異なる n に対して異なる
+          let ucls = V.toList (SPC.spcUCL ch)
+          length (filter (== head ucls) ucls) `shouldSatisfy` (< length ucls)
+        Right _ -> expectationFailure "expected single chart"
+
+    it "u chart: 系列長 mismatch は Left" $
+      case SPC.fitSPC SPC.U
+             (SPC.AttrDefectRate (V.fromList [1,2,3]) (V.fromList [10,20])) of
+        Left  _ -> pure ()
+        Right _ -> expectationFailure "expected Left"
+
+  describe "Hanalyze.Stat.SPC Western Electric rules (Phase 1.4)" $ do
+    -- 8 rules を持っているか
+    it "westernElectricRules は 8 rules" $
+      length SPC.westernElectricRules `shouldBe` 8
+    it "rule 番号は 1..8" $
+      map SPC.ruleNumber SPC.westernElectricRules `shouldBe` [1..8]
+
+    -- 合成 chart 結果でルール個別検証
+    let mkChart :: [Double] -> SPC.SPCChartResult
+        mkChart pts = SPC.SPCChartResult
+          { SPC.spcPoints    = V.fromList pts
+          , SPC.spcCenter    = 0
+          , SPC.spcUCL       = V.fromList (map (const 3.0)  pts)
+          , SPC.spcLCL       = V.fromList (map (const (-3.0)) pts)
+          , SPC.spcSigma     = 1
+          , SPC.spcChartName = "test"
+          }
+        weRule n = SPC.westernElectricRules !! (n - 1)
+
+    it "Rule 1 (>3σ): 4.0 だけ違反、 2.0 や 0 は違反しない" $
+      SPC.ruleCheck (weRule 1) (mkChart [0, 1, 2.9, 4.0, -3.5, 2.0])
+        `shouldBe` [3, 4]
+
+    it "Rule 2 (3点中2点が同側>2σ): window 末尾で違反 (overlap window 各回検出)" $ do
+      -- [0, +1, +1, 0]: window i=0..2 (2 positive) → index 2、
+      --                  window i=1..3 (2 positive) → index 3
+      SPC.ruleCheck (weRule 2) (mkChart [0, 2.5, 2.5, 0])
+        `shouldBe` [2, 3]
+      -- 隔てパターン: window 1..3 のみ hit
+      SPC.ruleCheck (weRule 2) (mkChart [0, 2.5, 0, 2.5])
+        `shouldBe` [3]
+
+    it "Rule 3 (5点中4点が同側>1σ): 1.5,1.5,0,1.5,1.5 で末尾違反" $
+      SPC.ruleCheck (weRule 3) (mkChart [1.5, 1.5, 0, 1.5, 1.5])
+        `shouldBe` [4]
+
+    it "Rule 4 (8点連続同側): 8 連続 +1 → index 7 から違反、 4 連続では無し" $ do
+      SPC.ruleCheck (weRule 4) (mkChart (replicate 8 1.0))
+        `shouldBe` [7]
+      SPC.ruleCheck (weRule 4) (mkChart (replicate 4 1.0))
+        `shouldBe` []
+
+    it "Rule 5 (6点連続単調): 0..5 → index 5 から違反" $
+      SPC.ruleCheck (weRule 5) (mkChart [0,1,2,3,4,5])
+        `shouldBe` [5]
+
+    it "Rule 6 (15点連続 1σ 以内): すべて 0.5 → index 14 以降違反" $
+      take 1 (SPC.ruleCheck (weRule 6) (mkChart (replicate 15 0.5)))
+        `shouldBe` [14]
+
+    it "Rule 7 (8点連続 1σ 外): 1.5,1.5,...,1.5 8 点 → index 7" $
+      SPC.ruleCheck (weRule 7) (mkChart (replicate 8 1.5))
+        `shouldBe` [7]
+
+    it "Rule 8 (14点連続交互): 交互系列で末尾違反" $
+      SPC.ruleCheck (weRule 8)
+        (mkChart [0,1,0,1,0,1,0,1,0,1,0,1,0,1])
+        `shouldBe` [13]
+
+    it "checkRules: 全ルール適用で SPCViolation list を返す" $ do
+      -- 4σ 単発点 + 8 連続正側 → Rule 1 と Rule 4 と (1σ 外連続なので) Rule 7 が hit
+      let ch = mkChart (4.0 : replicate 8 1.5)
+          vs = SPC.checkRules SPC.westernElectricRules ch
+      -- Rule 1 (index 0) は確実に含まれる
+      (1 `elem` map SPC.vRuleNumber vs) `shouldBe` True
+      -- Rule 4 (index 8, 8 連続同側 +1.5) も
+      (4 `elem` map SPC.vRuleNumber vs) `shouldBe` True
+      -- vChartName は元の chart の名前を保持
+      all ((== "test") . SPC.vChartName) vs `shouldBe` True
+
+    it "全 in-control データには違反が出ない" $ do
+      let ch = mkChart [0.1, -0.2, 0.3, -0.1, 0.2, -0.3, 0.1, 0.0, 0.2, -0.2]
+      SPC.checkRules SPC.westernElectricRules ch `shouldBe` []
+
+  describe "Hanalyze.Stat.SPC Nelson rules (Phase 1.5)" $ do
+    let mkChart' :: [Double] -> SPC.SPCChartResult
+        mkChart' pts = SPC.SPCChartResult
+          { SPC.spcPoints    = V.fromList pts
+          , SPC.spcCenter    = 0
+          , SPC.spcUCL       = V.fromList (map (const  3.0) pts)
+          , SPC.spcLCL       = V.fromList (map (const (-3.0)) pts)
+          , SPC.spcSigma     = 1
+          , SPC.spcChartName = "test"
+          }
+        nelsonRule n = SPC.nelsonRules !! (n - 1)
+
+    it "nelsonRules は 8 rules、 番号 1..8" $ do
+      length SPC.nelsonRules `shouldBe` 8
+      map SPC.ruleNumber SPC.nelsonRules `shouldBe` [1..8]
+
+    it "Rule 1 (>3σ) = WE 1 と同じ検出" $
+      SPC.ruleCheck (nelsonRule 1) (mkChart' [0, 4.0, 2.0, -3.5])
+        `shouldBe` [1, 3]
+
+    it "Rule 2 (9点連続同側): 8 点では未検出、 9 点目で検出" $ do
+      SPC.ruleCheck (nelsonRule 2) (mkChart' (replicate 8 1.0))
+        `shouldBe` []
+      SPC.ruleCheck (nelsonRule 2) (mkChart' (replicate 9 1.0))
+        `shouldBe` [8]
+
+    it "Rule 2 ≠ WE 4 (8 vs 9 連続)" $ do
+      -- 同じデータ (8 点連続同側) で WE 4 は hit、 Nelson 2 は miss
+      let ch = mkChart' (replicate 8 1.0)
+      SPC.ruleCheck (SPC.westernElectricRules !! 3) ch `shouldBe` [7]
+      SPC.ruleCheck (nelsonRule 2) ch `shouldBe` []
+
+    it "Rule 3 (6点連続単調) = WE 5" $
+      SPC.ruleCheck (nelsonRule 3) (mkChart' [0,1,2,3,4,5])
+        `shouldBe` [5]
+
+    it "Rule 4 (14点連続交互) = WE 8" $
+      SPC.ruleCheck (nelsonRule 4)
+        (mkChart' [0,1,0,1,0,1,0,1,0,1,0,1,0,1])
+        `shouldBe` [13]
+
+    it "Rule 5 (3点中2点>2σ同側) = WE 2" $
+      SPC.ruleCheck (nelsonRule 5) (mkChart' [0, 2.5, 0, 2.5])
+        `shouldBe` [3]
+
+    it "Rule 6 (5点中4点>1σ同側) = WE 3" $
+      SPC.ruleCheck (nelsonRule 6) (mkChart' [1.5, 1.5, 0, 1.5, 1.5])
+        `shouldBe` [4]
+
+    it "Rule 7 (15点連続 1σ 以内) = WE 6" $
+      take 1 (SPC.ruleCheck (nelsonRule 7) (mkChart' (replicate 15 0.5)))
+        `shouldBe` [14]
+
+    it "Rule 8 (8点連続 1σ 外) = WE 7" $
+      SPC.ruleCheck (nelsonRule 8) (mkChart' (replicate 8 1.5))
+        `shouldBe` [7]
+
+    it "in-control データに対しては Nelson も violation 0" $ do
+      let ch = mkChart' [0.1, -0.2, 0.3, -0.1, 0.2, -0.3, 0.1, 0.0, 0.2, -0.2]
+      SPC.checkRules SPC.nelsonRules ch `shouldBe` []
+
+    it "checkRules: Nelson rule で違反した点はルール名 \"Nelson N\"" $ do
+      let ch = mkChart' (replicate 9 1.0)  -- Rule 2 確実 hit (9 点同側)
+          vs = SPC.checkRules SPC.nelsonRules ch
+      (2 `elem` map SPC.vRuleNumber vs) `shouldBe` True
+      -- 違反 record の名前が "Nelson 2" 含む
+      any (\v -> SPC.vRuleName v == "Nelson 2") vs `shouldBe` True
+
+  describe "Hanalyze.Stat.SPC EWMA / CUSUM (Phase 11)" $ do
+    let inControl = V.fromList [10.1, 9.9, 10.05, 9.95, 10.02, 9.98, 10.0, 10.03, 9.97, 10.01]
+        shifted   = V.fromList [10.0, 10.1, 9.9, 10.05, 11.5, 11.6, 11.8, 11.7, 11.9, 12.0]
+    it "EWMA: in-control 系列で λ=0.2 L=3 ですべての点が管理限界内" $
+      case SPC.fitSPC SPC.EWMAChart (SPC.EWMAInput inControl 0.2 3.0 10.0 0.1) of
+        Left e -> expectationFailure (T.unpack e)
+        Right [c] -> do
+          V.length (SPC.spcPoints c) `shouldBe` V.length inControl
+          let zs   = SPC.spcPoints c
+              ucls = SPC.spcUCL c
+              lcls = SPC.spcLCL c
+              inLim = V.izipWith
+                (\i z _ ->
+                   z <= V.unsafeIndex ucls i + 1e-9
+                   && z >= V.unsafeIndex lcls i - 1e-9)
+                zs zs
+          V.and inLim `shouldBe` True
+        Right _ -> expectationFailure "expected single chart"
+    it "EWMA: 平均シフト系列で後半に管理限界超過点あり" $
+      case SPC.fitSPC SPC.EWMAChart (SPC.EWMAInput shifted 0.2 3.0 10.0 0.1) of
+        Left _ -> expectationFailure "expected Right"
+        Right [c] -> do
+          let zs   = SPC.spcPoints c
+              ucls = SPC.spcUCL c
+              anyOut = V.any id $ V.izipWith
+                (\i z _ -> z > V.unsafeIndex ucls i) zs zs
+          anyOut `shouldBe` True
+        Right _ -> expectationFailure "expected single chart"
+    it "EWMA: λ ∉ (0,1] は Left" $
+      case SPC.fitSPC SPC.EWMAChart (SPC.EWMAInput inControl 1.5 3.0 10.0 0.1) of
+        Left _  -> pure ()
+        Right _ -> expectationFailure "expected Left"
+    it "EWMA: 漸近的に limit が μ₀ ± L σ √(λ/(2−λ)) に収束" $
+      case SPC.fitSPC SPC.EWMAChart
+             (SPC.EWMAInput (V.replicate 200 10.0) 0.2 3.0 10.0 0.1) of
+        Right [c] -> do
+          let lambda = 0.2
+              ll     = 3.0
+              s      = 0.1
+              asym   = ll * s * sqrt (lambda / (2 - lambda))
+              uclLast = V.last (SPC.spcUCL c)
+          abs (uclLast - (10 + asym)) `shouldSatisfy` (< 1e-6)
+        _ -> expectationFailure "expected single chart"
+
+    it "CUSUM: in-control で両側とも限界内" $
+      case SPC.fitSPC SPC.CUSUMChart (SPC.CUSUMInput inControl 10.0 0.1 0.5 4.0) of
+        Left e -> expectationFailure (T.unpack e)
+        Right [cp, cn] -> do
+          SPC.spcChartName cp `shouldBe` "CUSUM+"
+          SPC.spcChartName cn `shouldBe` "CUSUM-"
+          let hAbs = 4.0 * 0.1
+              cpPts = SPC.spcPoints cp
+              cnPts = SPC.spcPoints cn  -- already negated
+          V.all (<= hAbs + 1e-9)         cpPts `shouldBe` True
+          V.all (>= negate hAbs - 1e-9)  cnPts `shouldBe` True
+        Right _ -> expectationFailure "expected 2 charts"
+    it "CUSUM: 上方シフトで C+ が h σ を超える点あり" $
+      case SPC.fitSPC SPC.CUSUMChart (SPC.CUSUMInput shifted 10.0 0.1 0.5 4.0) of
+        Right [cp, _] -> do
+          let hAbs = 4.0 * 0.1
+          V.any (> hAbs) (SPC.spcPoints cp) `shouldBe` True
+        _ -> expectationFailure "expected 2 charts"
+    it "CUSUM: h ≤ 0 は Left" $
+      case SPC.fitSPC SPC.CUSUMChart (SPC.CUSUMInput inControl 10.0 0.1 0.5 0) of
+        Left _  -> pure ()
+        Right _ -> expectationFailure "expected Left"
diff --git a/test/Hanalyze/Stat/StandardizeSpec.hs b/test/Hanalyze/Stat/StandardizeSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Stat/StandardizeSpec.hs
@@ -0,0 +1,61 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Stat.StandardizeSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Numeric.LinearAlgebra as LA
+import qualified Hanalyze.Stat.Standardize  as Std
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Stat.Standardize" $ do
+    let xMat = LA.fromLists [[1, 100], [2, 200], [3, 300], [4, 400], [5, 500]] :: LA.Matrix Double
+        s    = Std.fitStandardizer xMat
+    it "fit: 各列の μ が一致" $
+      Std.stMu s `shouldSatisfy`
+        (\ms -> length ms == 2
+              && abs (ms !! 0 - 3) < 1e-9
+              && abs (ms !! 1 - 300) < 1e-9)
+    it "fit: 各列の σ が不偏分散の平方根 (n-1 正規化)" $
+      Std.stSd s `shouldSatisfy`
+        (\ss -> length ss == 2
+              && abs (ss !! 0 - sqrt 2.5) < 1e-9
+              && abs (ss !! 1 - sqrt 25000) < 1e-9)
+    it "apply 後は各列 mean≈0, std≈1" $ do
+      let x' = Std.applyStandardizer s xMat
+          c0 = LA.toColumns x' !! 0
+          c1 = LA.toColumns x' !! 1
+          mn v = LA.sumElements v / fromIntegral (LA.size v)
+      abs (mn c0) `shouldSatisfy` (< 1e-9)
+      abs (mn c1) `shouldSatisfy` (< 1e-9)
+    it "unapply で元の値に戻る" $ do
+      let x'  = Std.applyStandardizer s xMat
+          x'' = Std.unapplyStandardizer s x'
+          d   = LA.norm_2 (xMat - x'') :: Double
+      d `shouldSatisfy` (< 1e-9)
+    it "定数列 (std=0) は std=1 にフォールバック (中央化のみ)" $ do
+      let constMat = LA.fromLists [[7, 1], [7, 2], [7, 3]] :: LA.Matrix Double
+          s2      = Std.fitStandardizer constMat
+      abs (Std.stSd s2 !! 0 - 1.0) `shouldSatisfy` (< 1e-12)
+      let x' = Std.applyStandardizer s2 constMat
+          c0 = LA.toColumns x' !! 0
+      abs (LA.sumElements c0) `shouldSatisfy` (< 1e-9)
diff --git a/test/Hanalyze/Stat/SummarySpec.hs b/test/Hanalyze/Stat/SummarySpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Stat/SummarySpec.hs
@@ -0,0 +1,53 @@
+{-# LANGUAGE OverloadedStrings #-}
+-- | Hanalyze.Stat.Summary の spec (Phase 100: 表示系 ESS の essBulk 統一)。
+--
+-- posteriorSummary の srEssV が旧 pooled 'ess' ではなく chain 構造を渡した
+-- 'essBulk' (arviz ess_bulk 互換・MCMCSpec の golden で検証済) と一致する
+-- 配線を固定する。データは MCMCSpec と同じ決定的 LCG+AR(1)。
+module Hanalyze.Stat.SummarySpec (spec) where
+
+import qualified Data.Map.Strict as Map
+import Test.Hspec
+
+import Hanalyze.MCMC.Core    (Chain (..))
+import Hanalyze.Stat.MCMC    (essBulk)
+import Hanalyze.Stat.Summary (SummaryRow (..), posteriorSummary)
+
+-- | glibc 系数の LCG (mod 2^31)・[-0.5, 0.5) 一様。Integer 演算なので厳密。
+lcg :: Int -> Int -> [Double]
+lcg seed n = take n (map toU (drop 1 (iterate step (fromIntegral seed))))
+  where
+    step x = (1103515245 * x + 12345) `mod` (2 ^ (31 :: Int)) :: Integer
+    toU x  = fromIntegral x / 2 ^ (31 :: Int) - 0.5
+
+-- | AR(1): y_i = phi*y_{i-1} + u_i (y_0 起点 0)。
+ar1 :: Int -> Int -> Double -> [Double]
+ar1 seed n phi = drop 1 (scanl (\prev u -> phi * prev + u) 0 (lcg seed n))
+
+mkChain :: [Double] -> Chain
+mkChain vs = Chain
+  { chainSamples     = [Map.singleton "x" v | v <- vs]
+  , chainAccepted    = 0
+  , chainTotal       = 0
+  , chainEnergy      = []
+  , chainDivergences = []
+  , chainTreeDepths  = []
+  }
+
+relClose :: Double -> Double -> Double -> Bool
+relClose tol expected actual = abs (actual - expected) <= tol * abs expected
+
+spec :: Spec
+spec = do
+  describe "posteriorSummary の ESS (Phase 100: essBulk 統一)" $ do
+    it "多 chain: srEssV = essBulk perChain (arviz golden 84.428・旧 pooled ess 86.804 ではない)" $ do
+      let perChain = [ar1 (c + 1) 300 0.9 | c <- [0 .. 3 :: Int]]
+          [row]    = posteriorSummary ["x"] (map mkChain perChain)
+      srEssV row `shouldBe` essBulk perChain
+      srEssV row `shouldSatisfy` relClose 1e-6 84.42798772749184
+
+    it "単一 chain でも essBulk (split 2 sub-chain) を返す" $ do
+      let vs    = ar1 42 300 0.5
+          [row] = posteriorSummary ["x"] [mkChain vs]
+      srEssV row `shouldBe` essBulk [vs]
+      srRhat row `shouldBe` Nothing
diff --git a/test/Hanalyze/Stat/TestSpec.hs b/test/Hanalyze/Stat/TestSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Stat/TestSpec.hs
@@ -0,0 +1,210 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Stat.TestSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Data.Text   as T
+import qualified Numeric.LinearAlgebra as LA
+import qualified Hanalyze.Stat.Test         as ST
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Stat.Test" $ do
+    it "tTest1Sample: μ₀=0 で同分布なら p > 0.05" $ do
+      let xs = LA.fromList [0.1, -0.2, 0.3, 0.0, 0.15, -0.1, 0.05, 0.2]
+          tr = ST.tTest1Sample xs 0 ST.TwoSided
+      ST.trPValue tr `shouldSatisfy` (> 0.05)
+
+    it "tTestWelch: 明らかにずれた 2 群で p < 0.05" $ do
+      let xs = LA.fromList [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]
+          ys = LA.fromList [10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 17.0]
+          tr = ST.tTestWelch xs ys ST.TwoSided
+      ST.trPValue tr `shouldSatisfy` (< 0.05)
+
+    it "anovaOneWay: 3 群が異なる平均なら有意" $ do
+      let g1 = LA.fromList [1.0, 2.0, 3.0]
+          g2 = LA.fromList [4.0, 5.0, 6.0]
+          g3 = LA.fromList [7.0, 8.0, 9.0]
+          tr = ST.anovaOneWay [g1, g2, g3]
+      ST.trStatistic tr `shouldSatisfy` (> 1)
+      ST.trPValue   tr `shouldSatisfy` (< 0.05)
+
+    it "chiSquareIndep: 強い独立で p > 0.05" $ do
+      let tbl = LA.matrix 2 [25, 25, 25, 25]  -- 完全独立
+          tr = ST.chiSquareIndep tbl
+      ST.trPValue tr `shouldSatisfy` (> 0.5)
+
+    it "chiSquareIndep: 強い従属で p < 0.05" $ do
+      let tbl = LA.matrix 2 [40, 10, 10, 40]  -- 高 chi2
+          tr = ST.chiSquareIndep tbl
+      ST.trPValue tr `shouldSatisfy` (< 0.05)
+
+    it "leveneTest: 分散が大きく異なれば p < 0.05" $ do
+      let g1 = LA.fromList [1, 1.1, 0.9, 1.05, 0.95, 1.02, 0.98, 1.03]
+          g2 = LA.fromList [10, 20, 5, 25, 8, 30, 3, 22]  -- much larger var
+          tr = ST.leveneTest [g1, g2]
+      ST.trPValue tr `shouldSatisfy` (< 0.05)
+
+    it "shapiroWilk: roughly-normal 系列で p > 0.05" $ do
+      let xs = LA.fromList [-1.5, -0.5, 0.0, 0.3, 0.8, 1.2, -0.3, 0.5, 1.0, -1.0]
+          tr = ST.shapiroWilk xs
+      ST.trPValue tr `shouldSatisfy` (> 0.05)
+
+    it "mannWhitneyU: 明らかにずれた 2 群で p < 0.05" $ do
+      let xs = LA.fromList [1, 2, 3, 4, 5, 6]
+          ys = LA.fromList [11, 12, 13, 14, 15, 16]
+          tr = ST.mannWhitneyU xs ys ST.TwoSided
+      ST.trPValue tr `shouldSatisfy` (< 0.05)
+
+    it "fisherExact2x2: 強い偏りで p < 0.05" $ do
+      let tr = ST.fisherExact2x2 ((20, 5), (5, 20)) ST.TwoSided
+      ST.trPValue tr `shouldSatisfy` (< 0.05)
+
+  -- ===========================================================================
+  -- Hanalyze.Model.PCA (Phase 2)
+  -- ===========================================================================
+
+  describe "Hanalyze.Stat.Test multivariate (Phase 4.3, request/140)" $ do
+    -- 1-sample Hotelling T² 既知ケース: 平均 μ̂ = (1, 2) と仮説 μ_0 を比較
+    it "hotellingsT2 (1-sample): 仮説と異なる平均で有意" $ do
+      -- X = 10 観測の 2 次元 (mean ≈ [5, 5])
+      let xs = LA.fromLists
+            [ [4.8, 5.1], [5.1, 5.2], [4.9, 4.8], [5.0, 5.0], [5.2, 5.1]
+            , [4.7, 4.9], [5.1, 5.0], [5.0, 5.3], [4.9, 4.7], [5.3, 5.2]
+            ]
+          mu0 = LA.fromList [0.0, 0.0]
+          tr = ST.hotellingsT2 xs mu0
+      ST.trPValue tr `shouldSatisfy` (< 1e-6)
+      case ST.trEffect tr of
+        Just ("T²", t2) -> t2 `shouldSatisfy` (> 100)
+        _ -> expectationFailure "expected T² effect"
+
+    it "hotellingsT2 (1-sample): 仮説と一致する μ_0 で有意でない" $ do
+      let xs = LA.fromLists
+            [ [4.8, 5.1], [5.1, 5.2], [4.9, 4.8], [5.0, 5.0], [5.2, 5.1]
+            , [4.7, 4.9], [5.1, 5.0], [5.0, 5.3], [4.9, 4.7], [5.3, 5.2]
+            ]
+          mu0 = LA.fromList [5.0, 5.0]
+          tr = ST.hotellingsT2 xs mu0
+      ST.trPValue tr `shouldSatisfy` (> 0.05)
+
+    it "hotellingsT2 sanity: μ_0 長さ mismatch、 n ≤ p は trNote" $ do
+      let xs = LA.fromLists [[1,2],[3,4],[5,6]]
+      ST.trMethod (ST.hotellingsT2 xs (LA.fromList [0])) `shouldSatisfy`
+        (\m -> T.isPrefixOf "Hotelling" m)
+      ST.trMethod (ST.hotellingsT2 (LA.fromLists [[1,2,3],[4,5,6]])
+                                   (LA.fromList [0,0,0]))
+        `shouldSatisfy` (\m -> T.isPrefixOf "Hotelling" m)
+
+    it "hotellingsT2TwoSample: 異なる中心の 2 群で有意" $ do
+      let xs = LA.fromLists
+            [ [4.8, 5.1], [5.1, 5.2], [4.9, 4.8], [5.0, 5.0], [5.2, 5.1]
+            , [4.7, 4.9], [5.1, 5.0], [5.0, 5.3], [4.9, 4.7], [5.3, 5.2]
+            ]
+          ys = LA.fromLists
+            [ [8.8, 9.1], [9.1, 9.2], [8.9, 8.8], [9.0, 9.0], [9.2, 9.1]
+            , [8.7, 8.9], [9.1, 9.0], [9.0, 9.3], [8.9, 8.7], [9.3, 9.2]
+            ]
+          tr = ST.hotellingsT2TwoSample xs ys
+      ST.trPValue tr `shouldSatisfy` (< 1e-6)
+
+    it "hotellingsT2TwoSample: 同じ中心の 2 群で有意でない" $ do
+      let xs = LA.fromLists
+            [ [4.8, 5.1], [5.1, 5.2], [4.9, 4.8], [5.0, 5.0], [5.2, 5.1]
+            , [4.7, 4.9], [5.1, 5.0], [5.0, 5.3], [4.9, 4.7], [5.3, 5.2]
+            ]
+          ys = LA.fromLists
+            [ [5.1, 4.9], [4.9, 5.0], [5.0, 5.1], [4.8, 4.9], [5.2, 5.0]
+            , [4.9, 5.2], [5.1, 4.8], [4.8, 5.1], [5.0, 4.7], [5.2, 5.1]
+            ]
+          tr = ST.hotellingsT2TwoSample xs ys
+      ST.trPValue tr `shouldSatisfy` (> 0.05)
+
+    it "manova: 3 群 で平均が大きく異なる場合に有意" $ do
+      let g1 = LA.fromLists
+            [ [4.8, 5.1], [5.1, 5.2], [4.9, 4.8], [5.0, 5.0], [5.2, 5.1]
+            , [4.7, 4.9], [5.1, 5.0], [5.0, 5.3]
+            ]
+          g2 = LA.fromLists
+            [ [8.8, 9.1], [9.1, 9.2], [8.9, 8.8], [9.0, 9.0], [9.2, 9.1]
+            , [8.7, 8.9], [9.1, 9.0], [9.0, 9.3]
+            ]
+          g3 = LA.fromLists
+            [ [12.8, 13.1], [13.1, 13.2], [12.9, 12.8], [13.0, 13.0]
+            , [13.2, 13.1], [12.7, 12.9], [13.1, 13.0], [13.0, 13.3]
+            ]
+          tr = ST.manova [g1, g2, g3]
+      ST.trPValue tr `shouldSatisfy` (< 1e-6)
+      case ST.trEffect tr of
+        Just ("Wilks Λ", w) -> w `shouldSatisfy` (\v -> v >= 0 && v <= 1)
+        _ -> expectationFailure "expected Wilks Λ effect"
+
+    it "manova: 1 群しか無い場合は trNote (= 検定不能)" $ do
+      let g1 = LA.fromLists [[1,2],[3,4]]
+          tr = ST.manova [g1]
+      ST.trMethod tr `shouldSatisfy` (\m -> T.isPrefixOf "MANOVA" m)
+
+  describe "Hanalyze.Stat.Test TOST (Phase 12)" $ do
+    let near1 = LA.fromList [9.95, 10.05, 10.0, 10.1, 9.9, 10.02]
+        near2 = LA.fromList [10.02, 9.98, 10.05, 9.93, 10.07, 10.01]
+        far1  = LA.fromList [9.9, 10.0, 10.1, 9.95, 10.05]
+        far2  = LA.fromList [12.0, 12.1, 11.9, 12.05, 11.95]
+    it "TOST: 平均差小 + Δ=0.5 → equivalence (p < 0.05)" $ do
+      let r = ST.tostWelch near1 near2 0.5
+      ST.trPValue r `shouldSatisfy` (< 0.05)
+    it "TOST: 平均差大 → not equivalent (p ≥ 0.05)" $ do
+      let r = ST.tostWelch far1 far2 0.5
+      ST.trPValue r `shouldSatisfy` (>= 0.05)
+    it "TOST: delta ≤ 0 は note 付きで返る" $ do
+      let r = ST.tostWelch near1 near2 0
+      ST.trNote r `shouldSatisfy` maybe False (\n -> T.isInfixOf "delta" n)
+
+  describe "Hanalyze.Stat.Test Friedman + Dunn (Phase 13.1)" $ do
+    it "friedmanTest: 全 block 全 cell 同値 → Q ≈ 0、 p ≈ 1" $ do
+      -- 全 cell が同一なら treatment 間に差が無い → Q ≈ 0
+      let m = LA.fromLists
+                [ [1,1,1], [1,1,1], [1,1,1], [1,1,1] ]
+          r = ST.friedmanTest m
+      ST.trPValue r `shouldSatisfy` (> 0.5)
+    it "friedmanTest: 完全 treatment 効果あり (列ごと一定差) → 強い棄却" $ do
+      let m = LA.fromLists
+                [ [1, 2, 3], [2, 3, 4], [1.5, 2.5, 3.5], [1.2, 2.2, 3.2],
+                  [1.1, 2.1, 3.1], [1.3, 2.3, 3.3] ]
+          r = ST.friedmanTest m
+      ST.trPValue r `shouldSatisfy` (< 0.05)
+    it "friedmanTest: 1 行のみは Left (note 付き)" $ do
+      let m = LA.fromLists [[1, 2, 3]]
+          r = ST.friedmanTest m
+      ST.trNote r `shouldSatisfy` maybe False (T.isInfixOf "need")
+    it "dunnTest: 3 group、 強分離で全 p_adj < 0.10" $ do
+      let g1 = LA.fromList [1, 2, 1.5, 1.2, 1.8, 0.5, 1.3, 1.6]
+          g2 = LA.fromList [5, 6, 5.5, 5.2, 5.8, 4.5, 5.3, 5.6]
+          g3 = LA.fromList [10, 11, 10.5, 10.2, 10.8, 9.5, 10.3, 10.6]
+          r = ST.dunnTest [g1, g2, g3]
+      length (ST.mcrPairs r) `shouldBe` 3
+      all (< 0.10) (ST.mcrPAdj r) `shouldBe` True
+    it "dunnTest: 同分布の 2 group は p_adj 高い" $ do
+      let g1 = LA.fromList [1.0, 1.5, 2.0, 1.2, 1.8]
+          g2 = LA.fromList [1.1, 1.4, 2.1, 1.3, 1.9]
+          r = ST.dunnTest [g1, g2]
+      length (ST.mcrPairs r) `shouldBe` 1
+      head (ST.mcrPAdj r) `shouldSatisfy` (> 0.3)
diff --git a/test/Hanalyze/Stat/VISpec.hs b/test/Hanalyze/Stat/VISpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Stat/VISpec.hs
@@ -0,0 +1,87 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Stat.VISpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Data.Map.Strict as M
+import qualified System.Random.MWC as MWC
+import qualified System.Random.MWC as MWC
+import qualified Hanalyze.Model.HBM as HBM
+import qualified Hanalyze.Stat.VI as VI
+import qualified Data.Map.Strict    as M
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Stat.VI.fullRankAdvi (Phase 37-A5)" $ do
+    -- 相関のある事後を持つ簡単な 2 次元モデル
+    -- mu1 ~ Normal(0, 5), mu2 ~ Normal(0, 5)
+    -- y_i ~ Normal(mu1 + mu2, 0.3) を 10 件観測 → mu1 + mu2 の事後はタイトだが
+    -- mu1 単独 / mu2 単独 は逆相関 (sum が決まると個別は trade-off)
+    let model :: HBM.ModelP ()
+        model = do
+          mu1 <- HBM.sample "mu1" (HBM.Normal 0 5)
+          mu2 <- HBM.sample "mu2" (HBM.Normal 0 5)
+          HBM.observe "y" (HBM.Normal (mu1 + mu2) 0.3)
+            (replicate 10 6.0)
+        cfg = VI.defaultVIConfig
+                { VI.viIterations = 300
+                , VI.viSamples    = 5
+                , VI.viLearningRate = 0.1
+                , VI.viNumDraws   = 200
+                }
+        initP = M.fromList [("mu1", 3.0), ("mu2", 3.0)]
+    --
+    it "fullRankAdvi は viCovU = Just と viMethod = FullRank を返す" $ do
+      gen <- MWC.create
+      res <- VI.fullRankAdvi model cfg initP gen
+      VI.viMethod res `shouldBe` VI.FullRank
+      (case VI.viCovU res of Just _ -> True; Nothing -> False) `shouldBe` True
+    it "mean-field advi は viCovU = Nothing と viMethod = MeanField" $ do
+      gen <- MWC.create
+      res <- VI.advi model cfg initP gen
+      VI.viMethod res `shouldBe` VI.MeanField
+      (case VI.viCovU res of Just _ -> False; Nothing -> True) `shouldBe` True
+    it "fullRankAdvi の L は下三角 (上三角は 0)" $ do
+      gen <- MWC.create
+      res <- VI.fullRankAdvi model cfg initP gen
+      case VI.viCovU res of
+        Just l ->
+          let n = length l
+              upperZero = and [ (l !! i !! j) == 0
+                              | i <- [0 .. n-1]
+                              , j <- [i+1 .. n-1] ]
+          in upperZero `shouldBe` True
+        Nothing -> expectationFailure "viCovU should be Just"
+    it "fullRankAdvi は相関を学習 (L の off-diagonal が非ゼロ)" $ do
+      gen <- MWC.create
+      res <- VI.fullRankAdvi model cfg initP gen
+      case VI.viCovU res of
+        Just l | length l >= 2 ->
+          -- L[1][0] が非ゼロ = 相関を捉えている
+          (abs (l !! 1 !! 0) > 0.01) `shouldBe` True
+        _ -> expectationFailure "viCovU must be at least 2×2"
+    it "fullRankAdvi 事後平均が mu1+mu2 ≈ 6 を回復 (合計が観測値)" $ do
+      gen <- MWC.create
+      res <- VI.fullRankAdvi model cfg initP gen
+      let m1 = M.findWithDefault 0 "mu1" (VI.viPostMeans res)
+          m2 = M.findWithDefault 0 "mu2" (VI.viPostMeans res)
+      (abs (m1 + m2 - 6) < 0.5) `shouldBe` True
diff --git a/test/Hanalyze/Viz/ModelGraphSpec.hs b/test/Hanalyze/Viz/ModelGraphSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Viz/ModelGraphSpec.hs
@@ -0,0 +1,272 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Viz.ModelGraphSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Data.Text   as T
+import qualified Hanalyze.Viz.ModelGraph    as VMG
+import qualified Hanalyze.Viz.ModelGraphDot as VMGD
+import qualified Hanalyze.Model.HBM as HBM
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Viz.ModelGraph + ModelGraphDot (Phase 40-A3)" $ do
+    let m :: HBM.ModelP ()
+        m = do
+          mu <- HBM.sample "mu" (HBM.Normal 0 5)
+          _ <- HBM.plate "g" 4 $ forM [0..3 :: Int] $ \j ->
+            HBM.sample ("x_" <> T.pack (show j)) (HBM.Normal mu 1)
+          return ()
+    --
+    -- Mermaid plate 出力
+    --
+    it "buildMermaid: plate 内ノードが subgraph で囲まれる" $ do
+      let g = HBM.buildModelGraph m
+          src = VMG.buildMermaid g
+      T.isInfixOf "subgraph plate_g[\"g × 4\"]" src `shouldBe` True
+      T.isInfixOf "end" src `shouldBe` True
+    it "buildMermaid: plate 外ノード mu は subgraph の外" $ do
+      let g = HBM.buildModelGraph m
+          src = VMG.buildMermaid g
+          -- "subgraph plate_g" の前に mu が現れるか後か
+          beforeSub = T.takeWhile (\_ -> True) (T.takeWhile (/= 's') src)
+              -- 大雑把: mu は src の前半に出現
+          muIdx = T.length (fst (T.breakOn "mu" src))
+          subIdx = T.length (fst (T.breakOn "subgraph plate_g" src))
+      (muIdx < subIdx) `shouldBe` True
+      -- beforeSub だけ使う体裁を維持
+      T.length beforeSub `shouldSatisfy` (>= 0)
+    --
+    -- Graphviz DOT 出力
+    --
+    it "renderModelGraphDot: 出力が digraph G { で始まり } で終わる" $ do
+      let g = HBM.buildModelGraph m
+          src = VMGD.renderModelGraphDot g
+      T.isPrefixOf "digraph G {" src `shouldBe` True
+      T.isSuffixOf "}\n" src `shouldBe` True
+    it "renderModelGraphDot: plate が cluster_g { label=\"g × 4\" } で囲まれる" $ do
+      let g = HBM.buildModelGraph m
+          src = VMGD.renderModelGraphDot g
+      T.isInfixOf "subgraph cluster_g {" src `shouldBe` True
+      T.isInfixOf "label=\"g × 4\";" src `shouldBe` True
+      T.isInfixOf "labelloc=\"b\";" src `shouldBe` True
+    it "renderModelGraphDot: edge は arrow (->) で出力" $ do
+      let g = HBM.buildModelGraph m
+          src = VMGD.renderModelGraphDot g
+      T.isInfixOf "mu -> x_0;" src `shouldBe` True
+    --
+    -- DeterministicN ノード (Phase 59.2 回帰: mkNodeLine が LatentN/ObservedN
+    -- のみで non-exhaustive crash していた。 request/255 §1 の最小再現)
+    --
+    it "renderModelGraphDot: deterministic ノードで crash せず box 形状で出力" $ do
+      let dm :: HBM.ModelP ()
+          dm = do
+            a  <- HBM.sample "a" (HBM.Normal 0 1)
+            mu <- HBM.deterministic "mu" (2 * a)
+            HBM.observe "y" (HBM.Normal mu 1) [0.5]
+      let g = HBM.buildModelGraph dm
+          src = VMGD.renderModelGraphDot g
+      T.isInfixOf "mu [label=\"mu\\nDeterministic\", shape=box];" src `shouldBe` True
+      T.isInfixOf "a -> mu;" src `shouldBe` True
+      T.isInfixOf "mu -> y;" src `shouldBe` True
+    --
+    -- Phase 60.4: データ slot (dataNamed/dataNamedIx) の DAG ノード化
+    -- (pm.Data 相当・既定 ON)。 label/attrs 両 case + 回帰 test を同時に
+    -- (Phase 59.2 の non-exhaustive crash の教訓)。
+    --
+    it "renderModelGraphDot: data slot が角丸灰色 box + x→mu エッジ (60.4)" $ do
+      let xm :: HBM.ModelP ()
+          xm = do
+            xs  <- HBM.dataNamed    "x" [1, 2, 3]
+            ys  <- HBM.dataNamedObs "y" [2, 4, 6]
+            _gs <- HBM.dataNamedIx  "g" [0, 1, 0]
+            b   <- HBM.sample "b" (HBM.Normal 0 1)
+            mu  <- HBM.deterministic "mu" (b * head xs)
+            HBM.observe "y" (HBM.Normal mu 1) ys
+      let g = HBM.buildModelGraph xm
+          src = VMGD.renderModelGraphDot g
+      -- ノード種: x/g は DataN、 y は observe に吸収され ObservedN
+      -- (dataNamedObs "y" + observe "y" の docs 慣例・PyMC で observed RV が
+      -- data 容器を内包するのと同型)
+      [ HBM.nodeKind n | n <- HBM.mgNodes g, HBM.nodeName n == "x" ]
+        `shouldBe` [HBM.DataN 3]
+      [ HBM.nodeKind n | n <- HBM.mgNodes g, HBM.nodeName n == "g" ]
+        `shouldBe` [HBM.DataN 3]
+      [ HBM.nodeKind n | n <- HBM.mgNodes g, HBM.nodeName n == "y" ]
+        `shouldBe` [HBM.ObservedN 3]
+      -- エッジ: x→mu (dataNamed の dep タグ)・mu→y。 g ([Int]) はエッジなし
+      HBM.mgEdges g `shouldSatisfy` (("x", "mu") `elem`)
+      HBM.mgEdges g `shouldSatisfy` (("mu", "y") `elem`)
+      [ e | e@(f, _) <- HBM.mgEdges g, f == "g" ] `shouldBe` []
+      -- DOT: 角丸灰色 box (PyMC ConstantData 流)・crash しない
+      T.isInfixOf "x [label=\"x\\n(n=3)\", shape=box, style=\"rounded,filled\", fillcolor=lightgray];" src
+        `shouldBe` True
+      T.isInfixOf "x -> mu;" src `shouldBe` True
+
+    it "dataNamedIx + (!!!) で slot→利用先エッジが出る (60.7)" $ do
+      let im :: HBM.ModelP ()
+          im = do
+            gs <- HBM.dataNamedIx  "g" [0, 1, 0]
+            ys <- HBM.dataNamedObs "yv" [1, 2, 3]
+            m0 <- HBM.sample "m0" (HBM.Normal 0 5)
+            m1 <- HBM.sample "m1" (HBM.Normal 0 5)
+            s  <- HBM.sample "s" (HBM.HalfNormal 1)
+            let ms = [m0, m1]
+            HBM.plateForM_ "obs" (zip gs ys) $ \(g, y) -> do
+              mu <- HBM.deterministic "mu" (ms HBM.!!! g)
+              HBM.observe "y" (HBM.Normal mu s) [y]
+      let g = HBM.buildModelGraph im
+      -- (!!!) の Track 解釈で g→mu エッジ (PyMC の b0[gid] 同型)
+      HBM.mgEdges g `shouldSatisfy` (("g", "mu") `elem`)
+      -- index 由来でない親 (m0/m1) も従来通り
+      HBM.mgEdges g `shouldSatisfy` (("m0", "mu") `elem`)
+
+    -- Phase 62: REff 経路 (atIx = ObserveLM 構造化ブロック) でも slot→観測
+    -- ノードのエッジが出る (60.7 では「既知の制限」 だった残り半分)。
+    it "dataNamedIx + reNormal/atIx + observeLMR で slot→obs エッジが出る (62)" $ do
+      let rm :: HBM.ModelP ()
+          rm = do
+            gids <- HBM.dataNamedIx "g" [0, 1, 0, 1]
+            a    <- HBM.sample "a" (HBM.Normal 0 5)
+            tau  <- HBM.sample "tau_u" (HBM.HalfNormal 1)
+            u    <- HBM.reNormal "u" 2 "tau_u" tau
+            _    <- pure a
+            HBM.observeNormalLM "y" [[1], [1], [1], [1]] ["a"]
+              [u `HBM.atIx` gids] "sigma" [1, 2, 3, 4]
+          rg = HBM.buildModelGraph rm
+      -- slot g → 観測ブロック y のエッジ (lmParents が slot 名を運ぶ)
+      HBM.mgEdges rg `shouldSatisfy` (("g", "y") `elem`)
+      -- 従来の親 (β=a / u_j / σ 名は sample が無くとも親集合に入る) も不変
+      HBM.mgEdges rg `shouldSatisfy` (("a", "y") `elem`)
+      HBM.mgEdges rg `shouldSatisfy` (("u_0", "y") `elem`)
+
+    it "at ([Int] 経路) は従来どおり slot エッジなし (62 非影響確認)" $ do
+      let rm :: HBM.ModelP ()
+          rm = do
+            a   <- HBM.sample "a" (HBM.Normal 0 5)
+            tau <- HBM.sample "tau_u" (HBM.HalfNormal 1)
+            u   <- HBM.reNormal "u" 2 "tau_u" tau
+            _   <- pure a
+            HBM.observeNormalLM "y" [[1], [1], [1], [1]] ["a"]
+              [u `HBM.at` [0, 1, 0, 1]] "sigma" [1, 2, 3, 4]
+          rg = HBM.buildModelGraph rm
+      [ e | e@(f, _) <- HBM.mgEdges rg, f == "g" ] `shouldBe` []
+      HBM.mgEdges rg `shouldSatisfy` (("u_1", "y") `elem`)
+
+    -- Phase 63.1: dataNamedObs の slot は observe の生 ys と値一致で
+    -- obs→slot エッジが張られ obs の子になる (PyMC make_compute_graph の
+    -- obs→y 同型。 従来はエッジゼロで source rank に浮遊し x と被っていた)。
+    it "dataNamedObs slot に obs→slot エッジが出る (63.1・構造化 observe)" $ do
+      let om :: HBM.ModelP ()
+          om = do
+            xs <- HBM.dataNamed    "x"  [1, 2, 3]
+            ys <- HBM.dataNamedObs "yv" [2, 4, 6]
+            b  <- HBM.sample "b" (HBM.Normal 0 1)
+            mu <- HBM.deterministic "mu" (b * head xs)
+            HBM.observe "y" (HBM.Normal mu 1) ys
+      let g = HBM.buildModelGraph om
+      -- obs→slot (y が親・yv が子 = yv は y の下に描かれる)
+      HBM.mgEdges g `shouldSatisfy` (("y", "yv") `elem`)
+      -- x slot は従来どおり x→mu のみ (値が異なるので obs からのエッジなし)
+      HBM.mgEdges g `shouldSatisfy` (("x", "mu") `elem`)
+      [ e | e@(_, t) <- HBM.mgEdges g, t == "x" ] `shouldBe` []
+
+    it "per-point loop の observe も連結 ys で slot match する (63.1)" $ do
+      let lm :: HBM.ModelP ()
+          lm = do
+            xs <- HBM.dataNamed    "x"  [1, 2, 3]
+            ys <- HBM.dataNamedObs "yv" [2, 4, 6]
+            b  <- HBM.sample "b" (HBM.Normal 0 1)
+            s  <- HBM.sample "s" (HBM.HalfNormal 1)
+            HBM.plateForM_ "obs" (zip xs ys) $ \(x, y) -> do
+              mu <- HBM.deterministic "mu" (b * x)
+              HBM.observe "y" (HBM.Normal mu s) [y]
+      let g = HBM.buildModelGraph lm
+      HBM.mgEdges g `shouldSatisfy` (("y", "yv") `elem`)
+
+    it "dataNamedObs と observe が同名なら自己ループを張らない (63.1)" $ do
+      let sm :: HBM.ModelP ()
+          sm = do
+            ys <- HBM.dataNamedObs "y" [2, 4, 6]
+            mu <- HBM.sample "mu" (HBM.Normal 0 1)
+            HBM.observe "y" (HBM.Normal mu 1) ys
+      let g = HBM.buildModelGraph sm
+      [ e | e@(f, t) <- HBM.mgEdges g, f == t ] `shouldBe` []
+
+    it "data slot がデータ長 = plate サイズの一意 match で plate に入る (60.6 追補)" $ do
+      let pm :: HBM.ModelP ()
+          pm = do
+            xs <- HBM.dataNamed    "x" [1, 2, 3]
+            ys <- HBM.dataNamedObs "yv" [2, 4, 6]
+            b  <- HBM.sample "b" (HBM.Normal 0 1)
+            s  <- HBM.sample "s" (HBM.HalfNormal 1)
+            HBM.plateForM_ "obs" (zip xs ys) $ \(x, y) -> do
+              mu <- HBM.deterministic "mu" (b * x)
+              HBM.observe "y" (HBM.Normal mu s) [y]
+      let g = HBM.buildModelGraph pm
+          platesOf nm = [ HBM.nodePlates n | n <- HBM.mgNodes g
+                                           , HBM.nodeName n == nm ]
+      -- 宣言は plate 外だが n=3 = plate "obs" (3) の一意 match → obs 内
+      platesOf "x"  `shouldBe` [["obs"]]
+      platesOf "yv" `shouldBe` [["obs"]]
+      -- latent は従来通り plate 外
+      platesOf "b" `shouldBe` [[]]
+
+    it "data 長と一致する plate が複数なら据え置き (60.6 追補・曖昧 match)" $ do
+      let am :: HBM.ModelP ()
+          am = do
+            xs <- HBM.dataNamed "x" [1, 2]
+            b  <- HBM.sample "b" (HBM.Normal 0 1)
+            _  <- HBM.plateForM "p1" [0, 1 :: Int] $ \j ->
+                    HBM.sample ("a" HBM..# j) (HBM.Normal b 1)
+            _  <- HBM.plateForM "p2" [0, 1 :: Int] $ \j ->
+                    HBM.sample ("c" HBM..# j) (HBM.Normal b 1)
+            HBM.observe "y" (HBM.Normal (head xs) 1) [0.5]
+      let g = HBM.buildModelGraph am
+      [ HBM.nodePlates n | n <- HBM.mgNodes g, HBM.nodeName n == "x" ]
+        `shouldBe` [[]]
+
+    it "describeModel: data slot が [data] 行で出る (60.4)" $ do
+      let xm :: HBM.ModelP ()
+          xm = do
+            xs <- HBM.dataNamed "x" [1, 2, 3]
+            b  <- HBM.sample "b" (HBM.Normal 0 1)
+            HBM.observe "y" (HBM.Normal (b * head xs) 1) [0.5]
+      T.isInfixOf "[data]" (HBM.describeModel xm) `shouldBe` True
+
+    --
+    -- Nested plate: cluster の入れ子
+    --
+    it "renderModelGraphDot: nested plate で cluster が入れ子" $ do
+      let nm :: HBM.ModelP ()
+          nm = do
+            _ <- HBM.plate "school" 2 $ forM_ [0..1 :: Int] $ \j ->
+                   HBM.plate "student" 2 $ forM_ [0..1 :: Int] $ \i ->
+                     HBM.sample ("y_" <> T.pack (show j) <> "_" <> T.pack (show i))
+                                (HBM.Normal 0 1)
+            return ()
+      let g = HBM.buildModelGraph nm
+          src = VMGD.renderModelGraphDot g
+          -- "school" cluster の中に "student" cluster
+          schoolIdx = T.length (fst (T.breakOn "cluster_school" src))
+          studentIdx = T.length (fst (T.breakOn "cluster_student" src))
+      (schoolIdx < studentIdx) `shouldBe` True
diff --git a/test/Hanalyze/Viz/ReportBuilderSpec.hs b/test/Hanalyze/Viz/ReportBuilderSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hanalyze/Viz/ReportBuilderSpec.hs
@@ -0,0 +1,60 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+module Hanalyze.Viz.ReportBuilderSpec (spec) where
+
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+import qualified Hanalyze.Viz.ReportBuilder as RB
+import SpecHelper
+
+spec :: Spec
+spec = do
+  describe "Hanalyze.Viz.ReportBuilder.secInterpolation" $ do
+    it "defaultInterpReport で renderReport まで通る (smoke)" $
+      withSystemTempFile "ha-interp.html" $ \fp h -> do
+        hClose h
+        let ir = RB.defaultInterpReport "test"
+        RB.renderReport fp (RB.defaultReportConfig "T") [RB.secInterpolation ir]
+        out <- readFile fp
+        length out `shouldSatisfy` (> 100)
+
+    it "InterpReport 全フィールド + extra で HTML に主要要素が含まれる" $
+      withSystemTempFile "ha-interp2.html" $ \fp h -> do
+        hClose h
+        let ir = (RB.defaultInterpReport "regrid")
+                   { RB.irInterpKind    = "PCHIP"
+                   , RB.irGridKind      = "Adaptive"
+                   , RB.irN             = 3
+                   , RB.irPerIdObserved = [("a", [(0, 0), (1, 1)])]
+                   , RB.irPerIdInterpY  = [("a", [(0, 0), (0.5, 0.5), (1, 1)])]
+                   , RB.irGrid          = [0, 0.5, 1]
+                   , RB.irDensity       = [(0, 1), (0.5, 2), (1, 1)]
+                   , RB.irPerIdSummary  = [("a", 2, 0, 1, 0, 0, 0)]
+                   , RB.irExtraEnabled  = True
+                   , RB.irPerIdYRange   = [("a", 0, 1, 0, 1)]
+                   }
+        RB.renderReport fp (RB.defaultReportConfig "T") [RB.secInterpolation ir]
+        out <- readFile fp
+        out `shouldContain` "Parameters"
+        out `shouldContain` "PCHIP"
+
+  -- ===========================================================================
+  -- Hanalyze.Stat.Test (Phase 1: hypothesis tests)
+  -- ===========================================================================
diff --git a/test/Spec.hs b/test/Spec.hs
--- a/test/Spec.hs
+++ b/test/Spec.hs
@@ -1,2417 +1,1 @@
-{-# LANGUAGE OverloadedStrings #-}
-{-# LANGUAGE TypeApplications #-}
-module Main where
-
-import Test.Hspec
-import Hanalyze.Model.GLMM
-import Hanalyze.Model.GLM (Family (..), LinkFn (..))
-import qualified Data.Vector as V
-import qualified Data.Text   as T
-import qualified Numeric.LinearAlgebra as LA
-import Data.List (sort)
-
-import qualified DataFrame                    as DX
-import qualified Hanalyze.Design.Orthogonal as OA
-import qualified Hanalyze.Design.Quality    as Quality
-import qualified Hanalyze.Design.Taguchi as TG
-import qualified Hanalyze.Model.LM             as LM
-import qualified Hanalyze.Model.LM.Diagnostics as LMD
-import qualified Hanalyze.DataIO.Preprocess as Pp
-import qualified Hanalyze.DataIO.Log        as Log
-import qualified Hanalyze.DataIO.CSV        as CSV
-import qualified Hanalyze.DataIO.Convert    as Conv
-import qualified Hanalyze.DataIO.Health     as Health
-import qualified Hanalyze.DataIO.Clean      as Clean
-import qualified Hanalyze.DataIO.Convert    as Conv2
-import qualified Hanalyze.Stat.Standardize  as Std
-import qualified Hanalyze.Stat.NumberFormat as NF
-import qualified Hanalyze.Stat.Interpolate  as Interp
-import qualified Hanalyze.Stat.AdaptiveGrid as AG
-import qualified Hanalyze.Stat.KernelDist   as KD
-import qualified Hanalyze.Stat.Cholesky     as Chol
-import qualified Hanalyze.Stat.QuasiRandom  as QR
-import qualified Hanalyze.Stat.Test         as ST
-import qualified Hanalyze.Stat.ClassMetrics as CM
-import qualified Hanalyze.Stat.CV           as CV
-import qualified Hanalyze.Stat.MultipleTesting as MT
-import qualified Hanalyze.Stat.Bootstrap       as Boot
-import qualified Hanalyze.Stat.Effect          as Eff
-import qualified Hanalyze.Stat.Interpret       as Interp
-import qualified Hanalyze.Model.PCA         as PCA
-import qualified Hanalyze.Model.Cluster     as Cl
-import qualified Hanalyze.Model.DecisionTree as DT
-import qualified Hanalyze.Model.TimeSeries   as TS
-import qualified Hanalyze.Model.Survival     as Surv
-import qualified Hanalyze.DataIO.Reshape    as Reshape
-import qualified Hanalyze.Optim.NSGA        as NSGA
-import qualified System.Random.MWC as MWC
-import qualified Hanalyze.Model.Kernel      as Kn
-import qualified Hanalyze.Model.GP          as GP
-import qualified Hanalyze.Model.GPRobust    as GPR
-import qualified Hanalyze.Viz.ReportBuilder as RB
-import qualified Data.ByteString   as BS
-import System.IO.Temp (withSystemTempFile)
-import System.IO     (hPutStr, hClose)
-import qualified Hanalyze.Model.GP        as GP
-import qualified Hanalyze.Model.GPRobust  as GPR
-import qualified Hanalyze.Model.RFF       as RFF
-import qualified Hanalyze.Model.Regularized as Reg
-import qualified Hanalyze.Model.Spline      as Sp
-import qualified Hanalyze.Model.Kernel      as K
-import qualified Hanalyze.Model.Core        as Core
-import qualified Hanalyze.Model.GLM         as GLM
-import qualified Hanalyze.Optim.NelderMead  as NM
-import qualified Hanalyze.Optim.LBFGS       as LBFGS
-import qualified Hanalyze.Optim.LineSearch  as LS
-import qualified Hanalyze.Optim.DifferentialEvolution as DE
-import qualified Hanalyze.Optim.CMAES       as CMAES
-import qualified Hanalyze.Optim.CMAESFull   as CMAESF
-import qualified Hanalyze.Optim.SimulatedAnnealing as SA
-import qualified Hanalyze.Optim.ParticleSwarm as PSO
-import qualified Hanalyze.Optim.Constrained as Con
-import qualified Hanalyze.Optim.BayesOpt    as BO
-import qualified Hanalyze.Optim.Common      as OC
-import qualified System.Random.MWC as MWC
-
-main :: IO ()
-main = hspec $ do
-  describe "Hanalyze.Stat.KernelDist" $ do
-    let xs = LA.fromLists [[0, 0], [3, 4], [1, 1]] :: LA.Matrix Double
-        d  = KD.pairwiseSqDist xs
-        -- naive reference
-        naive m =
-          let rows = LA.toRows m
-              n    = length rows
-          in (n LA.>< n)
-               [ let r = rows !! i - rows !! j in r `LA.dot` r
-               | i <- [0 .. n - 1], j <- [0 .. n - 1] ]
-        ref = naive xs
-
-    it "returns an n x n matrix with zero diagonal" $ do
-      LA.rows d `shouldBe` 3
-      LA.cols d `shouldBe` 3
-      LA.toList (LA.takeDiag d) `shouldBe` [0, 0, 0]
-
-    it "matches the naive reference within 1e-9" $
-      LA.norm_Inf (d - ref) < 1e-9 `shouldBe` True
-
-    it "pairwiseSqDistXY matches reference for cross-matrix" $ do
-      let ys  = LA.fromLists [[0, 0], [1, 0]] :: LA.Matrix Double
-          dXY = KD.pairwiseSqDistXY xs ys
-          rxs = LA.toRows xs
-          rys = LA.toRows ys
-          ref' = (LA.rows xs LA.>< LA.rows ys)
-                   [ let r = rxs !! i - rys !! j in r `LA.dot` r
-                   | i <- [0 .. LA.rows xs - 1]
-                   , j <- [0 .. LA.rows ys - 1] ]
-      LA.norm_Inf (dXY - ref') < 1e-9 `shouldBe` True
-
-  describe "Hanalyze.Optim.NSGA building blocks" $ do
-    let mkSol obj = NSGA.Solution
-                      { NSGA.solDecision   = obj   -- decision unused here
-                      , NSGA.solObjectives = obj
-                      , NSGA.solViolation  = 0
-                      }
-        s00 = mkSol [0.0, 0.0]   -- dominated by no one
-        s11 = mkSol [1.0, 1.0]   -- dominated by s00
-        s05 = mkSol [0.5, 0.5]
-        sa  = mkSol [0.0, 1.0]   -- non-comparable with sb
-        sb  = mkSol [1.0, 0.0]
-
-    it "dominates: zero-violation pair uses paretoDominates" $ do
-      NSGA.dominates s00 s11 `shouldBe` True
-      NSGA.dominates s11 s00 `shouldBe` False
-      NSGA.dominates sa  sb  `shouldBe` False
-      NSGA.dominates sb  sa  `shouldBe` False
-
-    it "nonDominatedSort: 3-point chain returns 3 fronts" $ do
-      -- s00 ≻ s05 ≻ s11
-      let fronts = NSGA.nonDominatedSort [s11, s05, s00]
-      length fronts `shouldBe` 3
-      length (head fronts) `shouldBe` 1   -- F_1 = {s00}
-
-    it "crowdingDistance: 2-point front keeps both (≤2 → ∞)" $
-      length (NSGA.crowdingDistance [sa, sb]) `shouldBe` 2
-
-    it "crowdingDistance: 3-point front returns all 3 (∞ endpoints + 1 mid)" $ do
-      let f3     = [mkSol [0.0, 1.0], mkSol [0.5, 0.5], mkSol [1.0, 0.0]]
-          sorted = NSGA.crowdingDistance f3
-      length sorted `shouldBe` 3
-
-    it "dominationMatrix matches per-pair dominates on a 3-individual pop" $ do
-      let s00 = NSGA.Solution [0, 0] [0.0, 0.0] 0   -- best
-          s11 = NSGA.Solution [1, 1] [1.0, 1.0] 0   -- worst
-          s05 = NSGA.Solution [0.5, 0.5] [0.5, 0.5] 0  -- middle
-          pop  = [s00, s05, s11]
-          pm   = NSGA.fromSolutions pop
-          mDom = NSGA.dominationMatrix pm
-          n    = length pop
-          ref  = LA.fromLists
-                   [ [ if i == j then 0
-                       else if NSGA.dominates (pop !! i) (pop !! j) then 1
-                       else if NSGA.dominates (pop !! j) (pop !! i) then -1
-                       else 0
-                     | j <- [0 .. n - 1] ]
-                   | i <- [0 .. n - 1] ]
-                   :: LA.Matrix Double
-      LA.norm_Inf (mDom - ref) `shouldBe` 0
-
-    it "polynomialMutation: respects bounds and pMut=0 keeps x" $ do
-      gen <- MWC.createSystemRandom
-      let bs = [(0, 1), (0, 1), (0, 1)] :: [(Double, Double)]
-      x' <- NSGA.polynomialMutation 20 0 bs [0.5, 0.5, 0.5] gen
-      x' `shouldBe` [0.5, 0.5, 0.5]
-      y' <- NSGA.polynomialMutation 20 1.0 bs [0.5, 0.5, 0.5] gen
-      all (\(z, (lo, hi)) -> z >= lo && z <= hi) (zip y' bs)
-        `shouldBe` True
-
-  describe "Hanalyze.Stat.QuasiRandom (Halton)" $ do
-    it "haltonSequence 10 1 lies in [0, 1) and is a permutation" $ do
-      let pts = QR.haltonSequence 10 1
-      length pts `shouldBe` 10
-      all (\[u] -> u >= 0 && u < 1) pts `shouldBe` True
-
-    it "haltonSequence 16 2 covers a 4x4 grid better than random" $ do
-      -- For n = 16, d = 2 the Halton points should cover all 16
-      -- 0.25-bins; an iid uniform usually misses some.
-      let pts = QR.haltonSequence 16 2
-          binIdx p = (floor (4 * head p) :: Int,
-                      floor (4 * (p !! 1)) :: Int)
-          unique = length (foldr (\b acc -> if b `elem` acc then acc
-                                              else b : acc) [] (map binIdx pts))
-      unique `shouldSatisfy` (>= 14)   -- Halton normally hits ≥ 14 / 16
-
-    it "haltonSequenceIn rescales into the supplied bounds" $ do
-      let bs  = [(-2, 2), (10, 20)] :: [(Double, Double)]
-          pts = QR.haltonSequenceIn 5 bs
-      all (\[a, b] -> a >= -2 && a <  2
-                   && b >= 10 && b <  20) pts `shouldBe` True
-
-    it "lhsSamples 10 3 lies in [0,1)^3 and fills every per-dim cell" $ do
-      gen <- MWC.createSystemRandom
-      pts <- QR.lhsSamples 10 3 gen
-      length pts `shouldBe` 10
-      all (\xs -> all (\u -> u >= 0 && u < 1) xs) pts `shouldBe` True
-      -- For each dim, the 10 points should occupy 10 distinct cells
-      -- (= floor(10 * u) is a permutation of [0..9]).
-      let cellsAlong k = map (\xs -> floor (10 * (xs !! k)) :: Int) pts
-          ok k = sort (cellsAlong k) == [0 .. 9]
-      ok 0 `shouldBe` True
-      ok 1 `shouldBe` True
-      ok 2 `shouldBe` True
-
-    it "lhsSamplesIn rescales into bounds" $ do
-      gen <- MWC.createSystemRandom
-      let bs = [(-1, 1), (5, 10)] :: [(Double, Double)]
-      pts <- QR.lhsSamplesIn 8 bs gen
-      all (\[a, b] -> a >= -1 && a <  1
-                   && b >=  5 && b < 10) pts `shouldBe` True
-
-  describe "Hanalyze.Stat.Cholesky" $ do
-    let aSPD = LA.fromLists [[4, 2, 1], [2, 5, 3], [1, 3, 6]]
-                 :: LA.Matrix Double
-        b    = LA.asColumn (LA.fromList [1.0, 2.0, 3.0])
-
-    it "cholSolve agrees with LA.<\\> on a 3x3 SPD system (1e-9)" $ do
-      let xC = Chol.cholSolve aSPD b
-          xR = aSPD LA.<\> b
-      case xC of
-        Nothing -> expectationFailure "cholSolve returned Nothing on SPD"
-        Just xc -> LA.norm_Inf (xc - xR) < 1e-9 `shouldBe` True
-
-    it "cholSolveJitter falls back gracefully on a singular matrix" $ do
-      let aSing = LA.fromLists [[1, 0, 0], [0, 0, 0], [0, 0, 1]]
-                    :: LA.Matrix Double
-          bSing = LA.asColumn (LA.fromList [1.0, 0.0, 1.0])
-          x = Chol.cholSolveJitter aSing bSing
-      LA.rows x `shouldBe` 3   -- did not crash; whatever LSQ gives is fine
-
-    it "cholFactor returns Just for SPD and Nothing for non-SPD" $ do
-      Chol.cholFactor aSPD `shouldSatisfy` (\m -> case m of
-                                                    Just _  -> True
-                                                    Nothing -> False)
-      let aNeg = LA.fromLists [[1, 2], [2, 1]] :: LA.Matrix Double
-                  -- eigenvalues 3 and -1 → not SPD
-      Chol.cholFactor aNeg `shouldBe` Nothing
-
-  describe "Hanalyze.Model.Kernel multi-input (MV)" $ do
-    -- 2D regression target: y = sin(x1) + 0.5 cos(x2)
-    let n     = 60
-        h     = 0.5
-        lam   = 1e-4
-        f x1 x2 = sin x1 + 0.5 * cos x2
-        xs    = LA.fromLists
-                  [ [ fromIntegral i / 10
-                    , fromIntegral (n - i) / 10
-                    ]
-                  | i <- [0 .. n - 1] ]
-        ys    = LA.asColumn $ LA.fromList
-                  [ f (xs `LA.atIndex` (i, 0)) (xs `LA.atIndex` (i, 1))
-                  | i <- [0 .. n - 1] ]
-        fit   = Kn.kernelRidgeMV Kn.Gaussian h lam xs ys
-        yhat  = Kn.fittedKernelRidgeMV fit
-        ssErr = LA.sumElements ((ys - yhat) ** 2)
-        ssTot = let muY = LA.sumElements ys / fromIntegral n
-                in LA.sumElements ((ys - LA.konst muY (n, 1)) ** 2)
-        r2    = 1 - ssErr / ssTot
-
-    it "achieves R² > 0.95 on a 2D smooth target" $
-      r2 > 0.95 `shouldBe` True
-
-    it "predict at training points equals fitted" $ do
-      let p = Kn.predictKernelRidgeMV fit xs
-      LA.norm_Inf (p - yhat) < 1e-9 `shouldBe` True
-
-    it "gramMatrixMV matches kernelFromSqDist by element" $ do
-      let xS  = LA.fromLists [[0.0, 0.0], [1.0, 0.0], [0.5, 0.5]] :: LA.Matrix Double
-          gMV = Kn.gramMatrixMV Kn.Gaussian 1.0 xS
-          rs  = LA.toRows xS
-          ref = LA.fromLists
-                  [ [ let d = rs !! i - rs !! j
-                          s = (d `LA.dot` d) / (1.0 * 1.0)
-                      in Kn.kernelFromSqDist Kn.Gaussian s
-                    | j <- [0 .. 2] ]
-                  | i <- [0 .. 2] ]
-      LA.norm_Inf (gMV - ref) < 1e-12 `shouldBe` True
-
-    it "Hanalyze.Model.GP MV: 1D input matches legacy 1D fitGP within 1e-6" $ do
-      let xL  = [fromIntegral i / 5 | i <- [0 .. 19 :: Int]]
-          yL  = map sin xL
-          tL  = [0.5, 1.5, 2.5]
-          mdl = GP.GPModel GP.RBF (GP.GPParams 1.0 1.0 0.05 1.0 Nothing)
-          legacy = GP.fitGP mdl xL yL tL
-          xMV = LA.fromLists (map (:[]) xL) :: LA.Matrix Double
-          yMV = LA.fromList yL
-          tMV = LA.fromLists (map (:[]) tL) :: LA.Matrix Double
-          mv  = GP.fitGPMV mdl xMV yMV tMV
-          dMu = LA.norm_Inf
-                  (GP.gpmvMean mv - LA.fromList (GP.gpMean legacy))
-          dVr = LA.norm_Inf
-                  (GP.gpmvVar  mv - LA.fromList (GP.gpVar  legacy))
-      (dMu < 1e-6) `shouldBe` True
-      (dVr < 1e-6) `shouldBe` True
-
-    it "Hanalyze.Model.GP MV: 2D RBF reaches R² > 0.95 with optimized HP" $ do
-      let nN  = 50
-          gx  = [(fromIntegral i / 10, fromIntegral (nN - i) / 10)
-                | i <- [0 .. nN - 1 :: Int]]
-          ftn (x1, x2) = sin x1 + 0.5 * cos x2
-          xMV = LA.fromLists [ [a, b] | (a, b) <- gx ] :: LA.Matrix Double
-          yMV = LA.fromList [ ftn p | p <- gx ]
-          p0  = GP.GPParams 1.0 1.0 0.01 1.0 Nothing
-          po  = GP.optimizeGPMV GP.RBF xMV yMV p0
-          mdl = GP.GPModel GP.RBF po
-          res = GP.fitGPMV mdl xMV yMV xMV
-          mu  = GP.gpmvMean res
-          y   = yMV
-          ss  = LA.sumElements ((y - mu) ** 2)
-          mY  = LA.sumElements y / fromIntegral nN
-          st  = LA.sumElements ((y - LA.konst mY nN) ** 2)
-          r2  = 1 - ss / st
-      (r2 > 0.95) `shouldBe` True
-
-    it "Hanalyze.Model.GPRobust MV: 1D input matches legacy fitGPRobust" $ do
-      let xL  = [fromIntegral i / 5 | i <- [0 .. 14 :: Int]]
-          yL  = map sin xL
-          tL  = [0.5, 1.5, 2.5]
-          ker = GP.RBF
-          ps  = GP.GPParams 1.0 1.0 0.05 1.0 Nothing
-          lik = GPR.RGaussian 0.1
-          legFit = GPR.fitGPRobust ker ps lik xL yL
-          legacy = GPR.predictGPRobust legFit tL
-          legM   = LA.fromList (map fst legacy)
-          xMV    = LA.fromLists (map (:[]) xL) :: LA.Matrix Double
-          yMV    = LA.fromList yL
-          tMV    = LA.fromLists (map (:[]) tL) :: LA.Matrix Double
-          mvFit  = GPR.fitGPRobustMV ker ps lik xMV yMV
-          (mvM, _) = GPR.predictGPRobustMV mvFit tMV
-      LA.norm_Inf (mvM - legM) < 1e-6 `shouldBe` True
-
-    it "MV gramMatrix on a single-column input agrees with kernelFromSqDist" $ do
-      let xs1 = LA.fromLists [[fromIntegral i / 5] | i <- [0 .. 19 :: Int]]
-                  :: LA.Matrix Double
-          gMV2 = Kn.gramMatrixMV Kn.Gaussian 0.4 xs1
-          ref  = LA.fromLists
-                   [ [ let xi = xs1 `LA.atIndex` (i, 0)
-                           xj = xs1 `LA.atIndex` (j, 0)
-                           d  = xi - xj
-                       in Kn.kernelFromSqDist Kn.Gaussian (d * d / (0.4 * 0.4))
-                     | j <- [0 .. 19] ]
-                   | i <- [0 .. 19] ]
-      LA.norm_Inf (gMV2 - ref) < 1e-12 `shouldBe` True
-
-  describe "Hanalyze.Model.GLMM" $ do
-    -- Dataset: 3 groups × 4 obs, strong between-group signal, weak within-group noise.
-    -- True: β₀≈5, β₁≈0, u_A≈2, u_B≈0, u_C≈-2, σ²_u≈4, σ²≈small → ICC≈high.
-    let df  = DX.fromNamedColumns
-                [ ("x",     DX.fromList ([1,2,3,4, 1,2,3,4, 1,2,3,4] :: [Double]))
-                , ("y",     DX.fromList ([7.1,6.9,7.0,7.0, 5.0,4.9,5.1,5.0, 3.0,2.9,3.1,3.0] :: [Double]))
-                , ("group", DX.fromList (["A","A","A","A","B","B","B","B","C","C","C","C"] :: [T.Text])) ]
-        res = fitLMEDataFrame [("x", 1)] "group" "y" df
-
-    it "returns Just for valid input" $
-      res `shouldSatisfy` (\r -> case r of { Just _ -> True; Nothing -> False })
-
-    it "ICC is in [0, 1]" $
-      maybe (expectationFailure "expected Just") (\r ->
-        glmmICC r `shouldSatisfy` (\v -> v >= 0 && v <= 1)) res
-
-    it "ICC is high for strongly grouped data" $
-      maybe (expectationFailure "expected Just") (\r ->
-        glmmICC r `shouldSatisfy` (> 0.9)) res
-
-    it "random variance is positive" $
-      maybe (expectationFailure "expected Just") (\r ->
-        glmmRandVar r `shouldSatisfy` (> 0)) res
-
-    it "residual variance is positive" $
-      maybe (expectationFailure "expected Just") (\r ->
-        glmmResidVar r `shouldSatisfy` (> 0)) res
-
-    it "BLUP count equals number of groups" $
-      maybe (expectationFailure "expected Just") (\r ->
-        V.length (glmmBLUPs r) `shouldBe` 3) res
-
-    it "group labels are sorted" $
-      case res of
-        Just r  -> glmmGroups r `shouldBe` V.fromList ["A","B","C"]
-        Nothing -> expectationFailure "expected Just"
-
-    it "returns Nothing for missing column" $
-      fitLMEDataFrame [("x", 1)] "group" "missing" df
-        `shouldSatisfy` (\r -> case r of { Nothing -> True; Just _ -> False })
-
-  describe "Hanalyze.Model.GLMM (non-Gaussian)" $ do
-    -- Binomial GLMM: 3 hospitals, binary outcome (treatment success)
-    -- Strong hospital effect; within each hospital, dose → higher success rate.
-    -- True: u_A ≈ +1, u_B ≈ 0, u_C ≈ -1  (on logit scale)
-    let dfBin  = DX.fromNamedColumns
-                   [ ("dose",     DX.fromList ([1,2,3,4,5, 1,2,3,4,5, 1,2,3,4,5] :: [Double]))
-                   , ("success",  DX.fromList ([1,1,1,1,1, 1,1,0,1,0, 0,0,0,1,0] :: [Double]))
-                   , ("hospital", DX.fromList (["A","A","A","A","A","B","B","B","B","B","C","C","C","C","C"] :: [T.Text])) ]
-        resBin = fitGLMMDataFrame Binomial Logit [("dose", 1)] "hospital" "success" dfBin
-
-    it "Binomial GLMM returns Just" $
-      resBin `shouldSatisfy` (\r -> case r of { Just _ -> True; Nothing -> False })
-
-    it "Binomial ICC in [0, 1]" $
-      maybe (expectationFailure "expected Just") (\r ->
-        glmmICC r `shouldSatisfy` (\v -> v >= 0 && v <= 1)) resBin
-
-    it "Binomial σ²_u is positive" $
-      maybe (expectationFailure "expected Just") (\r ->
-        glmmRandVar r `shouldSatisfy` (> 0)) resBin
-
-    -- Poisson GLMM: 3 regions, count outcome (events per month)
-    -- True: β₀ on log scale ≈ 2 (≈7 events baseline), u differs by region.
-    let dfPois  = DX.fromNamedColumns
-                    [ ("time",   DX.fromList ([1,2,3,4,5, 1,2,3,4,5, 1,2,3,4,5] :: [Double]))
-                    , ("count",  DX.fromList ([15,18,22,20,25, 7,9,8,10,11, 2,3,2,4,3] :: [Double]))
-                    , ("region", DX.fromList (["X","X","X","X","X","Y","Y","Y","Y","Y","Z","Z","Z","Z","Z"] :: [T.Text])) ]
-        resPois = fitGLMMDataFrame Poisson Log [("time", 1)] "region" "count" dfPois
-
-    it "Poisson GLMM returns Just" $
-      resPois `shouldSatisfy` (\r -> case r of { Just _ -> True; Nothing -> False })
-
-    it "Poisson σ²_u is positive" $
-      maybe (expectationFailure "expected Just") (\r ->
-        glmmRandVar r `shouldSatisfy` (> 0)) resPois
-
-    it "Poisson ICC in [0, 1]" $
-      maybe (expectationFailure "expected Just") (\r ->
-        glmmICC r `shouldSatisfy` (\v -> v >= 0 && v <= 1)) resPois
-
-  -- ─────────────────────────────────────────────────────────────────────
-  describe "Hanalyze.Design.Orthogonal" $ do
-    it "L4 has 4 runs and 3 columns" $ do
-      OA.oaRuns OA.l4    `shouldBe` 4
-      OA.oaFactors OA.l4 `shouldBe` 3
-      length (OA.oaTable OA.l4) `shouldBe` 4
-
-    it "L8 has 8 runs and 7 columns" $ do
-      OA.oaRuns OA.l8    `shouldBe` 8
-      OA.oaFactors OA.l8 `shouldBe` 7
-
-    it "L9 has 9 runs and 4 columns at 3 levels each" $ do
-      OA.oaRuns OA.l9    `shouldBe` 9
-      OA.oaFactors OA.l9 `shouldBe` 4
-      OA.oaLevels OA.l9  `shouldBe` [3, 3, 3, 3]
-
-    it "L18 has 18 runs and 8 columns (1×2 + 7×3)" $ do
-      OA.oaRuns OA.l18    `shouldBe` 18
-      OA.oaLevels OA.l18  `shouldBe` 2 : replicate 7 3
-
-    it "L8 columns are balanced (each level appears 4 times)" $ do
-      let table = OA.oaTable OA.l8
-          colJ j = [ row !! j | row <- table ]
-      mapM_ (\j -> do
-        let cs = colJ j
-        length (filter (== 1) cs) `shouldBe` 4
-        length (filter (== 2) cs) `shouldBe` 4) [0 .. 6]
-
-    it "L8 column pairs are pairwise orthogonal" $ do
-      let table = OA.oaTable OA.l8
-          colJ j = [ row !! j | row <- table ]
-          pairCount j1 j2 a b =
-            length (filter id (zipWith (\x y -> x == a && y == b)
-                                       (colJ j1) (colJ j2)))
-      -- For 2-level orthogonality: each pair (1,1)/(1,2)/(2,1)/(2,2) must appear equally
-      mapM_ (\(j1, j2) -> do
-        pairCount j1 j2 1 1 `shouldBe` 2
-        pairCount j1 j2 1 2 `shouldBe` 2
-        pairCount j1 j2 2 1 `shouldBe` 2
-        pairCount j1 j2 2 2 `shouldBe` 2)
-        [(0,1),(0,2),(0,3),(1,2),(1,3),(2,3)]
-
-    it "lookupOA finds standard arrays case-insensitively" $ do
-      OA.oaName <$> OA.lookupOA "L9"   `shouldBe` Just "L9(3^4)"
-      OA.oaName <$> OA.lookupOA "l9"   `shouldBe` Just "L9(3^4)"
-      OA.oaName <$> OA.lookupOA "L99"  `shouldBe` Nothing
-
-    it "assignFactors fills levels correctly for L4" $ do
-      let specs = [ OA.FactorSpec "A" [OA.LText "lo", OA.LText "hi"]
-                  , OA.FactorSpec "B" [OA.LNumeric 0,   OA.LNumeric 1]
-                  ]
-      case OA.assignFactors OA.l4 specs of
-        Right ad -> do
-          length (OA.adRows ad) `shouldBe` 4
-          map length (OA.adRows ad) `shouldBe` [2, 2, 2, 2]
-        Left e -> expectationFailure (show e)
-
-    it "assignFactors rejects too many factors" $ do
-      let specs = replicate 5 (OA.FactorSpec "X" [OA.LNumeric 1, OA.LNumeric 2])
-      OA.assignFactors OA.l4 specs `shouldSatisfy`
-        \r -> case r of { Left _ -> True; Right _ -> False }
-
-    it "assignFactors rejects level count mismatch" $ do
-      let specs = [ OA.FactorSpec "A" [OA.LText "x"] ]   -- only 1 level, L4 needs 2
-      OA.assignFactors OA.l4 specs `shouldSatisfy`
-        \r -> case r of { Left _ -> True; Right _ -> False }
-
-  -- ─────────────────────────────────────────────────────────────────────
-  describe "Hanalyze.Design.Taguchi" $ do
-    it "smaller-the-better SN: lower y → higher η" $ do
-      let etaSmall = TG.snRatio TG.SmallerBetter [0.5, 0.5, 0.5]
-          etaLarge = TG.snRatio TG.SmallerBetter [5.0, 5.0, 5.0]
-      etaSmall `shouldSatisfy` (> etaLarge)
-
-    it "larger-the-better SN: higher y → higher η" $ do
-      let etaLarge = TG.snRatio TG.LargerBetter [10, 10, 10]
-          etaSmall = TG.snRatio TG.LargerBetter [1, 1, 1]
-      etaLarge `shouldSatisfy` (> etaSmall)
-
-    it "nominal-the-best SN: high mean / low var → high η" $ do
-      let highSN = TG.snRatio TG.NominalBest [10, 10.01, 9.99, 10]
-          lowSN  = TG.snRatio TG.NominalBest [1, 4, 7, 10]
-      highSN `shouldSatisfy` (> lowSN)
-
-    it "nominal-target SN: closer to target → higher η" $ do
-      let closer = TG.snRatio (TG.NominalBestTarget 5) [4.9, 5.0, 5.1]
-          farther = TG.snRatio (TG.NominalBestTarget 5) [3, 5, 7]
-      closer `shouldSatisfy` (> farther)
-
-    it "snRatio on empty list is 0" $
-      TG.snRatio TG.SmallerBetter [] `shouldBe` 0
-
-    it "snRatioRows produces same length as input" $ do
-      let yMatrix = [[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]]
-      length (TG.snRatioRows TG.SmallerBetter yMatrix) `shouldBe` 3
-
-    it "analyzeSN gives one FactorEffect per assigned factor" $ do
-      let specs = [ OA.FactorSpec "A" [OA.LText "lo", OA.LText "hi"]
-                  , OA.FactorSpec "B" [OA.LText "lo", OA.LText "hi"]
-                  , OA.FactorSpec "C" [OA.LText "lo", OA.LText "hi"]
-                  ]
-          Right ad = OA.assignFactors OA.l4 specs
-          fes = TG.analyzeSN ad [10, 20, 30, 40]
-      length fes `shouldBe` 3
-      map TG.feFactor fes `shouldBe` ["A", "B", "C"]
-      mapM_ (\fe -> length (TG.feSNByLevel fe) `shouldBe` 2) fes
-
-    it "optimalLevels picks max-SN level per factor" $ do
-      let specs = [ OA.FactorSpec "A" [OA.LText "lo", OA.LText "hi"]
-                  , OA.FactorSpec "B" [OA.LText "lo", OA.LText "hi"]
-                  , OA.FactorSpec "C" [OA.LText "lo", OA.LText "hi"]
-                  ]
-          Right ad = OA.assignFactors OA.l4 specs
-          -- L4 row 1 ("hi" for A,B,C) has SN=100; others 0
-          sns = [0, 0, 0, 100]
-          fes = TG.analyzeSN ad sns
-          opts = TG.optimalLevels fes
-      length opts `shouldBe` 3
-      -- Row 4 has all "hi" by L4 structure (2,2,1) — verify each factor's best
-      mapM_ (\(_, _, eta) -> eta `shouldSatisfy` (>= 0)) opts
-
-  -- ─────────────────────────────────────────────────────────────────────
-  describe "Hanalyze.DataIO.Preprocess" $ do
-    let dfNA = DX.fromNamedColumns
-                 [ ("group", DX.fromList (["A","B","A","B","C"] :: [T.Text]))
-                 , ("x",     DX.fromList (["1","NA","3","","5"]   :: [T.Text]))
-                 , ("y",     DX.fromList ([10, 20, 30, 40, 50]    :: [Double]))
-                 ]
-
-    it "isNAString detects standard NA strings" $ do
-      Pp.isNAString "NA"    `shouldBe` True
-      Pp.isNAString "N/A"   `shouldBe` True
-      Pp.isNAString "null"  `shouldBe` True
-      Pp.isNAString ""      `shouldBe` True
-      Pp.isNAString "  "    `shouldBe` True
-      Pp.isNAString "valid" `shouldBe` False
-
-    it "countMissing counts NAs in Text columns; numeric is 0" $ do
-      let counts = Pp.countMissing dfNA
-      lookup "x"     counts `shouldBe` Just 2
-      lookup "y"     counts `shouldBe` Just 0
-      lookup "group" counts `shouldBe` Just 0
-
-    it "dropMissingRows removes rows with NA in target columns" $ do
-      let df' = Pp.dropMissingRows ["x"] dfNA
-          (n, _) = DX.dimensions df'
-      n `shouldBe` 3   -- only rows with x ∈ {"1","3","5"} remain
-
-    it "imputeMean converts Text/NA column to Double with mean fill" $ do
-      case Pp.imputeMean "x" dfNA of
-        Just df' -> do
-          let xs = DX.columnAsList (DX.col @Double "x") df'
-          length xs `shouldBe` 5
-          -- mean of [1, 3, 5] = 3
-          (xs !! 1) `shouldBe` 3.0   -- was "NA"
-          (xs !! 3) `shouldBe` 3.0   -- was ""
-        Nothing -> expectationFailure "imputeMean failed"
-
-    it "selectColumns retains only listed columns" $ do
-      let df' = Pp.selectColumns ["y", "group"] dfNA
-      DX.columnNames df' `shouldMatchList` ["y", "group"]
-
-    it "filterRowsByNumeric filters numeric column" $ do
-      let df' = Pp.filterRowsByNumeric "y" (>= 30) dfNA
-          (n, _) = DX.dimensions df'
-      n `shouldBe` 3
-
-    it "mapNumeric applies a unary function" $ do
-      let df' = Pp.mapNumeric "y" (* 2) dfNA
-          xs = DX.columnAsList (DX.col @Double "y") df'
-      xs `shouldBe` [20, 40, 60, 80, 100]
-
-  -- ─────────────────────────────────────────────────────────────────────
-  describe "Hanalyze.DataIO.Preprocess (groupBy)" $ do
-    let dfGrp = DX.fromNamedColumns
-                  [ ("group", DX.fromList (["A","B","A","B","A","C"] :: [T.Text]))
-                  , ("y",     DX.fromList ([1, 4, 3, 6, 5, 10]       :: [Double]))
-                  ]
-
-    it "groupByMean computes per-group mean" $ do
-      case Pp.groupByMean "group" "y" dfGrp of
-        Just df' -> do
-          let (n, _) = DX.dimensions df'
-          n `shouldBe` 3
-          let gs = DX.columnAsList (DX.col @T.Text "group") df'
-              vs = DX.columnAsList (DX.col @Double "y")    df'
-              pairs = zip gs vs
-          lookup "A" pairs `shouldBe` Just 3.0       -- (1+3+5)/3
-          lookup "B" pairs `shouldBe` Just 5.0       -- (4+6)/2
-          lookup "C" pairs `shouldBe` Just 10.0
-        Nothing -> expectationFailure "groupByMean failed"
-
-    it "groupBySum computes per-group sum" $ do
-      case Pp.groupBySum "group" "y" dfGrp of
-        Just df' -> do
-          let gs = DX.columnAsList (DX.col @T.Text "group") df'
-              vs = DX.columnAsList (DX.col @Double "y")    df'
-              pairs = zip gs vs
-          lookup "A" pairs `shouldBe` Just 9.0
-          lookup "B" pairs `shouldBe` Just 10.0
-        Nothing -> expectationFailure "groupBySum failed"
-
-    it "groupByCount counts rows per group" $ do
-      case Pp.groupByCount "group" dfGrp of
-        Just df' -> do
-          let gs = DX.columnAsList (DX.col @T.Text "group") df'
-              vs = DX.columnAsList (DX.col @Double "count") df'
-              pairs = zip gs vs
-          lookup "A" pairs `shouldBe` Just 3.0
-          lookup "B" pairs `shouldBe` Just 2.0
-          lookup "C" pairs `shouldBe` Just 1.0
-        Nothing -> expectationFailure "groupByCount failed"
-
-    it "groupByMin/Max return correct extremes" $ do
-      case Pp.groupByMin "group" "y" dfGrp of
-        Just dfMin -> do
-          let gs = DX.columnAsList (DX.col @T.Text "group") dfMin
-              vs = DX.columnAsList (DX.col @Double "y")    dfMin
-              pairs = zip gs vs
-          lookup "A" pairs `shouldBe` Just 1.0
-          lookup "B" pairs `shouldBe` Just 4.0
-        Nothing -> expectationFailure "groupByMin failed"
-
-      case Pp.groupByMax "group" "y" dfGrp of
-        Just dfMax -> do
-          let gs = DX.columnAsList (DX.col @T.Text "group") dfMax
-              vs = DX.columnAsList (DX.col @Double "y")    dfMax
-              pairs = zip gs vs
-          lookup "A" pairs `shouldBe` Just 5.0
-          lookup "B" pairs `shouldBe` Just 6.0
-        Nothing -> expectationFailure "groupByMax failed"
-
-  -- ─────────────────────────────────────────────────────────────────────
-  describe "Hanalyze.Stat.NumberFormat" $ do
-    it "0 → '0.00'"            $ NF.fmtNum 0       `shouldBe` "0.00"
-    it "中域 (0.01..999) は固定小数点 2 桁" $ do
-      NF.fmtNum 0.91   `shouldBe` "0.91"
-      NF.fmtNum 12.34  `shouldBe` "12.34"
-      NF.fmtNum 998.7  `shouldBe` "998.70"
-    it "巨大値は指数表記" $ do
-      NF.fmtNum 1.10e13 `shouldBe` "1.10E+13"
-      NF.fmtNum 1234.5  `shouldBe` "1.23E+3"
-    it "極小値は指数表記" $ do
-      NF.fmtNum 3.057e-24 `shouldBe` "3.06E-24"
-      NF.fmtNum 0.0099    `shouldBe` "9.90E-3"
-    it "負の値" $ do
-      NF.fmtNum (-12.34)  `shouldBe` "-12.34"
-      NF.fmtNum (-1.5e10) `shouldBe` "-1.50E+10"
-    it "非有限値" $ do
-      NF.fmtNum (0/0)        `shouldBe` "NaN"
-      NF.fmtNum (1/0)        `shouldBe` "+Inf"
-      NF.fmtNum (-1/0)       `shouldBe` "-Inf"
-
-  describe "Hanalyze.Stat.Standardize" $ do
-    let xMat = LA.fromLists [[1, 100], [2, 200], [3, 300], [4, 400], [5, 500]] :: LA.Matrix Double
-        s    = Std.fitStandardizer xMat
-    it "fit: 各列の μ が一致" $
-      Std.stMu s `shouldSatisfy`
-        (\ms -> length ms == 2
-              && abs (ms !! 0 - 3) < 1e-9
-              && abs (ms !! 1 - 300) < 1e-9)
-    it "fit: 各列の σ が不偏分散の平方根 (n-1 正規化)" $
-      Std.stSd s `shouldSatisfy`
-        (\ss -> length ss == 2
-              && abs (ss !! 0 - sqrt 2.5) < 1e-9
-              && abs (ss !! 1 - sqrt 25000) < 1e-9)
-    it "apply 後は各列 mean≈0, std≈1" $ do
-      let x' = Std.applyStandardizer s xMat
-          c0 = LA.toColumns x' !! 0
-          c1 = LA.toColumns x' !! 1
-          mn v = LA.sumElements v / fromIntegral (LA.size v)
-      abs (mn c0) `shouldSatisfy` (< 1e-9)
-      abs (mn c1) `shouldSatisfy` (< 1e-9)
-    it "unapply で元の値に戻る" $ do
-      let x'  = Std.applyStandardizer s xMat
-          x'' = Std.unapplyStandardizer s x'
-          d   = LA.norm_2 (xMat - x'') :: Double
-      d `shouldSatisfy` (< 1e-9)
-    it "定数列 (std=0) は std=1 にフォールバック (中央化のみ)" $ do
-      let constMat = LA.fromLists [[7, 1], [7, 2], [7, 3]] :: LA.Matrix Double
-          s2      = Std.fitStandardizer constMat
-      abs (Std.stSd s2 !! 0 - 1.0) `shouldSatisfy` (< 1e-12)
-      let x' = Std.applyStandardizer s2 constMat
-          c0 = LA.toColumns x' !! 0
-      abs (LA.sumElements c0) `shouldSatisfy` (< 1e-9)
-
-  describe "Hanalyze.Stat.Interpolate" $ do
-    let pts = [(0,0), (1,1), (2,4), (3,9), (4,16)]   -- y = x^2
-    it "Linear: 観測点で原値を厳密に再現" $ do
-      let f = Interp.interp1d Interp.Linear pts
-      mapM_ (\(x, y) -> abs (f x - y) `shouldSatisfy` (< 1e-12)) pts
-
-    it "NaturalSpline: 観測点で原値を厳密に再現、中間点で線形より精度高い" $ do
-      let fl = Interp.interp1d Interp.Linear pts
-          fs = Interp.interp1d Interp.NaturalSpline pts
-          true x = x * x
-          xMid = 1.5
-          errL = abs (fl xMid - true xMid)
-          errS = abs (fs xMid - true xMid)
-      mapM_ (\(x, y) -> abs (fs x - y) `shouldSatisfy` (< 1e-9)) pts
-      errS `shouldSatisfy` (< errL)
-
-    it "PCHIP: 単調データ ([0,1,2,4,8,16]) で出力も単調" $ do
-      let mp = [(0,0), (1,1), (2,2), (3,4), (4,8), (5,16)]
-          fp = Interp.interp1d Interp.PCHIP mp
-          ys = map fp [0, 0.1 .. 5]
-      and (zipWith (<=) ys (tail ys)) `shouldBe` True
-
-    it "PCHIP: 観測点で原値を厳密に再現" $ do
-      let fp = Interp.interp1d Interp.PCHIP pts
-      mapM_ (\(x, y) -> abs (fp x - y) `shouldSatisfy` (< 1e-9)) pts
-
-  describe "Hanalyze.Stat.AdaptiveGrid" $ do
-    it "uniformGrid: 端点を含み等間隔" $ do
-      let g = AG.uniformGrid 5 0 4
-      g `shouldBe` [0, 1, 2, 3, 4]
-
-    it "Adaptive: 急激な変化付近に grid が集中する (step 関数)" $ do
-      -- y は z=0..1 でほぼ平坦、z=1..2 で急激に変化、z=2..3 で再び平坦
-      let pts1 = [(z, if z < 1 then 0 else if z < 2 then 10*(z-1) else 10) | z <- [0, 0.1 .. 3]]
-          spec = (AG.defaultGridSpec 30) { AG.gsKind = AG.Adaptive }
-          g    = AG.makeGrid [pts1] (0, 3) spec
-          -- 中央領域 [1, 2] にある grid 点数 vs 端領域 [0,1] の grid 点数
-          midN = length (filter (\z -> z >= 1 && z <= 2) g)
-          edgeN = length (filter (\z -> z < 1) g)
-      length g `shouldBe` 30
-      midN `shouldSatisfy` (> edgeN)
-
-    it "Uniform: 端点 + 等間隔" $ do
-      let g = AG.makeGrid [] (0, 1) ((AG.defaultGridSpec 6) { AG.gsKind = AG.Uniform })
-      length g `shouldBe` 6
-      head g `shouldBe` 0
-      last g `shouldBe` 1
-
-    it "N < 10 で adaptive 指定でも uniform にフォールバック" $ do
-      let pts1 = [(z, sin z) | z <- [0, 0.1 .. 3]]
-          spec = (AG.defaultGridSpec 5) { AG.gsKind = AG.Adaptive }
-          g    = AG.makeGrid [pts1] (0, 3) spec
-          gU   = AG.uniformGrid 5 0 3
-      g `shouldBe` gU
-
-  describe "Hanalyze.Model.RFF (multivariate, Phase B-RFF)" $ do
-    it "logMarginalLikRBFMV: 既知 ℓ で最大化される (合成データで)" $ do
-      -- y = sin(x) (1D) で ℓ をスキャンし、データの z-score 後の長さスケールに
-      -- 近い値で marg-lik が最大になることを確認。
-      let xs = [0.0, 0.3 .. 6.0]
-          ys = map sin xs
-          xMat = LA.fromLists [[x] | x <- xs]
-          yV   = LA.fromList ys
-          ells = [0.05, 0.2, 0.5, 1.0, 2.0, 5.0]
-          mliks = [ RFF.logMarginalLikRBFMV xMat yV ell 1.0 0.05 | ell <- ells ]
-          best  = snd (maximum (zip mliks ells))
-      best `shouldSatisfy` (\b -> b >= 0.2 && b <= 2.0)
-    it "loocvRFFRidgeMV: λ → ∞ で残差ベース LOOCV が増える、適度な λ で最小" $ do
-      let xs = [0.0, 0.3 .. 6.0]
-          ys = map sin xs
-          xMat = LA.fromLists [[x] | x <- xs]
-          yV   = LA.fromList ys
-      gen   <- MWC.createSystemRandom
-      feats <- RFF.sampleRFFRBFMV 1 64 0.5 1.0 gen
-      let lamSmall = RFF.loocvRFFRidgeMV feats xMat yV 1e-2
-          lamHuge  = RFF.loocvRFFRidgeMV feats xMat yV 1e6
-      lamSmall `shouldSatisfy` (< lamHuge)
-    it "gridSearchLOOCVRBFMV: ℓ/λ を自動探索して LOOCV が小さくなる" $ do
-      let xs = [0.0, 0.5 .. 10.0]
-          ys = [ sin (x/2) | x <- xs ]
-          xMat = LA.fromLists [[x] | x <- xs]
-          yV   = LA.fromList ys
-      gen <- MWC.createSystemRandom
-      res <- RFF.gridSearchLOOCVRBFMV 1 100 xMat yV (Just (4, 8)) gen
-      RFF.lcLOOCV res `shouldSatisfy` (< 1.0)
-      RFF.lcEll res   `shouldSatisfy` (> 0)
-    it "maximizeMarginalLikRBFMV: 雑音ありデータで mlik が改善する" $ do
-      let xs = [0.0, 0.5 .. 10.0]
-          ys = [ sin (x/2) + 0.05 * (fromIntegral i / 21) - 0.025
-               | (i, x) <- zip [0::Int ..] xs ]
-          xMat = LA.fromLists [[x] | x <- xs]
-          yV   = LA.fromList ys
-          res  = RFF.maximizeMarginalLikRBFMV xMat yV (Just (8, 4, 4))
-      -- 最適 mlik > 任意の "ヘンな" 値 (ℓ=100, σ_n=10) より高い
-          weak = RFF.logMarginalLikRBFMV xMat yV 100 1.0 10.0
-      RFF.mlLogMlik res `shouldSatisfy` (> weak)
-      RFF.mlEll res     `shouldSatisfy` (> 0)
-      RFF.mlSigmaN res  `shouldSatisfy` (> 0)
-
-    it "rffRidgeMV: y = x1 * t を完全にフィット" $ do
-      let xs = [(x1, t) | x1 <- [1, 2, 3, 5, 7], t <- [1..10]]
-          xss = [[x1, t] | (x1, t) <- xs]
-          ys  = [x1 * t | (x1, t) <- xs]
-          xMat = LA.fromLists xss
-      gen   <- MWC.createSystemRandom
-      feats <- RFF.sampleRFFRBFMV 2 256 1.0 1.0 gen
-      let fit  = RFF.rffRidgeMV feats xMat ys 0.001
-          yhat = RFF.predictRFFRidgeMV fit xMat
-          rmse = sqrt (sum (zipWith (\a b -> (a-b)*(a-b)) ys yhat)
-                       / fromIntegral (length ys))
-      rmse `shouldSatisfy` (< 1.0)
-
-  describe "Hanalyze.Model.RFF" $ do
-    it "feature matrix has correct shape" $ do
-      gen   <- MWC.createSystemRandom
-      feats <- RFF.sampleRFFRBF 50 1.0 1.0 gen
-      RFF.rffDim feats `shouldBe` 50
-      let phi = RFF.rffFeatures feats [0.0, 1.0, 2.0]
-      -- phi is n × D = 3 × 50
-      V.length (V.fromList [0::Int]) `shouldBe` 1   -- placeholder for typing
-      -- We can't easily check matrix shape without hmatrix import here,
-      -- so just ensure the function doesn't crash.
-      length (RFF.rffOmegas feats) `shouldSatisfy` (== 50)
-      let _ = phi
-      return ()
-
-    it "RFF Ridge fits y ≈ x reasonably" $ do
-      gen   <- MWC.createSystemRandom
-      feats <- RFF.sampleRFFRBF 100 1.0 1.0 gen
-      let xs = [0.0, 0.1 .. 1.0]
-          ys = map (\x -> 2 * x + 0.5) xs
-          fit = RFF.rffRidge feats xs ys 0.001
-          yhat = RFF.predictRFFRidge fit xs
-          rmse = sqrt (sum [ (y - yh) ^ (2 :: Int)
-                           | (y, yh) <- zip ys yhat ]
-                       / fromIntegral (length ys))
-      rmse `shouldSatisfy` (< 0.5)
-
-  -- ─────────────────────────────────────────────────────────────────────
-  describe "Hanalyze.Model.GPRobust" $ do
-    it "Cauchy GP is more accurate than Gaussian GP under outliers" $ do
-      let trueF x = sin x
-          xs = [0.0, 0.5 .. 6.0]
-          cleanY = map trueF xs
-          -- Inject outlier at index 5
-          ys = zipWith (\i y -> if i == 5 then y + 5 else y)
-                       [0::Int ..] cleanY
-          hp = GP.GPParams 1.0 1.0 0.05 1.0 Nothing
-          gpRes  = GP.fitGP (GP.GPModel GP.RBF hp) xs ys xs
-          gaussRMSE = sqrt (sum [ (a - b) ^ (2::Int)
-                                | (a, b) <- zip cleanY (GP.gpMean gpRes) ]
-                            / fromIntegral (length xs))
-          cauchyFit = GPR.fitGPRobust GP.RBF hp (GPR.RCauchy 0.5) xs ys
-          cauchyPred = GPR.predictGPRobust cauchyFit xs
-          cauchyRMSE = sqrt (sum [ (a - b) ^ (2::Int)
-                                 | (a, (b, _)) <- zip cleanY cauchyPred ]
-                             / fromIntegral (length xs))
-      cauchyRMSE `shouldSatisfy` (< gaussRMSE)
-
-    it "IRLS converges in finite iterations" $ do
-      let xs = [0.0, 1.0, 2.0, 3.0, 4.0]
-          ys = [0.1, 1.05, 1.95, 2.9, 4.1]
-          hp = GP.GPParams 1.0 1.0 0.1 1.0 Nothing
-          fit = GPR.fitGPRobust GP.RBF hp (GPR.RStudentT 4 0.5) xs ys
-      GPR.rgpIters fit `shouldSatisfy` (\n -> n > 0 && n <= 50)
-
-  describe "Hanalyze.DataIO.Log" $ do
-    it "Monoid: noLog <> r == r" $ do
-      let r = Log.logReport (Log.mkWarn "W001" "msg" Nothing)
-      Log.entries (Log.noLog <> r) `shouldBe` Log.entries r
-      Log.entries (r <> Log.noLog) `shouldBe` Log.entries r
-    it "addEntry appends" $ do
-      let r0 = Log.logReport (Log.mkInfo "I001" "first" Nothing)
-          r1 = Log.addEntry (Log.mkWarn "W001" "second" (Just "ヒント")) r0
-      length (Log.entries r1) `shouldBe` 2
-      Log.lgSev  (last (Log.entries r1)) `shouldBe` Log.Warn
-      Log.lgHint (last (Log.entries r1)) `shouldBe` Just "ヒント"
-    it "hasErrors / hasWarnings detect severity" $ do
-      let rW = Log.logReport (Log.mkWarn "W"  "w"  Nothing)
-          rE = Log.logReport (Log.mkErr  "E"  "e"  Nothing)
-      Log.hasWarnings rW         `shouldBe` True
-      Log.hasErrors   rW         `shouldBe` False
-      Log.hasErrors   (rW <> rE) `shouldBe` True
-    it "severityCount counts each level" $ do
-      let r = Log.logReport (Log.mkInfo "I" "i" Nothing)
-            <> Log.logReport (Log.mkWarn "W1" "w" Nothing)
-            <> Log.logReport (Log.mkWarn "W2" "w" Nothing)
-            <> Log.logReport (Log.mkErr  "E"  "e" Nothing)
-      Log.severityCount Log.Info r `shouldBe` 1
-      Log.severityCount Log.Warn r `shouldBe` 2
-      Log.severityCount Log.Err  r `shouldBe` 1
-    it "prettyEntry: includes code, message, hint" $ do
-      let s = Log.prettyEntry (Log.mkWarn "W042" "壊れている" (Just "助言"))
-      T.isInfixOf "[WARN]" s   `shouldBe` True
-      T.isInfixOf "W042"   s   `shouldBe` True
-      T.isInfixOf "壊れている" s `shouldBe` True
-      T.isInfixOf "助言"   s   `shouldBe` True
-
-  describe "Hanalyze.DataIO.CSV.loadAutoSafe" $ do
-    it "Empty file → Left, no exception" $
-      withSystemTempFile "ha-empty.csv" $ \fp h -> do
-        hPutStr h ""
-        hClose h
-        r <- CSV.loadAutoSafe fp
-        case r of
-          Left msg -> T.isInfixOf "Empty" (T.pack msg) `shouldBe` True
-          Right _  -> expectationFailure "expected Left for empty file"
-    it "Header-only file → Left" $
-      withSystemTempFile "ha-hdr.csv" $ \fp h -> do
-        hPutStr h "x,y,z\n"
-        hClose h
-        r <- CSV.loadAutoSafe fp
-        case r of
-          Left msg -> T.isInfixOf "header" (T.pack msg) `shouldBe` True
-          Right _  -> expectationFailure "expected Left for header-only file"
-    it "Valid CSV → Right with empty log by default" $
-      withSystemTempFile "ha-ok.csv" $ \fp h -> do
-        hPutStr h "x,y\n1,2\n3,4\n"
-        hClose h
-        r <- CSV.loadAutoSafe fp
-        case r of
-          Left  msg      -> expectationFailure ("unexpected Left: " ++ msg)
-          Right (_, lg)  -> Log.entries lg `shouldBe` []
-
-  describe "Hanalyze.DataIO.Convert deep-eval" $ do
-    it "getMaybeTextVec on text column with mixed NA strings returns Just" $
-      withSystemTempFile "ha-na.csv" $ \fp h -> do
-        -- 複数 NA 表現を混ぜると Hackage は Maybe Text 列として保持する。
-        -- ヘッダ判定で n/a / null は欠損扱い → null bitmap が立つ。
-        hPutStr h "id,score\n1,A\n2,n/a\n3,null\n4,B\n5,-\n"
-        hClose h
-        r <- CSV.loadAutoSafe fp
-        case r of
-          Right (df, _) -> case Conv.getMaybeTextVec "score" df of
-            Just v  -> length (V.toList v) `shouldBe` 5
-            Nothing -> expectationFailure "getMaybeTextVec returned Nothing"
-          Left msg -> expectationFailure ("load failed: " ++ msg)
-    it "getDoubleVec returns Nothing without crashing on NA-mixed numeric column" $
-      withSystemTempFile "ha-na2.csv" $ \fp h -> do
-        hPutStr h "id,score\n1,85\n2,NA\n3,92\n"
-        hClose h
-        r <- CSV.loadAutoSafe fp
-        case r of
-          Right (df, _) -> Conv.getDoubleVec "score" df `shouldBe` Nothing
-          Left msg      -> expectationFailure ("load failed: " ++ msg)
-
-  describe "Hanalyze.DataIO.Health" $ do
-    it "W001: ヘッダ無し疑い (列名が全て数値)" $ do
-      let df = DX.insertColumn "1.0" (DX.fromList ([2.0, 4.0] :: [Double]))
-             $ DX.insertColumn "2.0" (DX.fromList ([4.1, 8.0] :: [Double]))
-             $ DX.empty
-          codes = map Log.lgCode (Log.entries (Health.detectHeaderless df))
-      codes `shouldContain` ["W001"]
-    it "W001 は通常ヘッダでは発火しない" $ do
-      let df = DX.insertColumn "x" (DX.fromList ([1.0, 2.0] :: [Double]))
-             $ DX.empty
-      Log.entries (Health.detectHeaderless df) `shouldBe` []
-    it "W002: コメント行 (# 始まり) を検出" $ do
-      let preview = "# header comment\n# more comment\nx,y\n1,2\n"
-          codes   = map Log.lgCode (Log.entries (Health.detectCommentLines preview))
-      codes `shouldContain` ["W002"]
-    it "W005: 1 列 DataFrame + プレビューにタブ → delimiter ミスマッチ" $ do
-      let df = DX.insertColumn "x\ty" (DX.fromList ([1.0] :: [Double]))
-             $ DX.empty
-          preview = "x\ty\n1\t2\n3\t4\n"
-          codes = map Log.lgCode
-                    (Log.entries (Health.detectDelimiterMismatch preview df))
-      codes `shouldContain` ["W005"]
-    it "W008: 通貨記号付き列を検出" $ do
-      let df = DX.insertColumn "price"
-                 (DX.fromList (["$1,234.56", "$2,500.00", "$3,000.00", "$4,000"] :: [T.Text]))
-             $ DX.empty
-          codes = map Log.lgCode (Log.entries (Health.detectThousandsCurrency df))
-      codes `shouldContain` ["W008"]
-    -- BS インポートを使う何かのスモーク (未使用 warning 防止)
-    it "preview is non-empty for typical use" $
-      BS.length "x,y\n1,2" `shouldSatisfy` (> 0)
-
-  describe "Hanalyze.DataIO.CSV.loadAutoSafeWith" $ do
-    it "--no-header: 先頭行をデータ行として扱い col0... を生成" $
-      withSystemTempFile "ha-noh.csv" $ \fp h -> do
-        hPutStr h "1,2\n3,4\n5,6\n"
-        hClose h
-        r <- CSV.loadAutoSafeWith
-               (CSV.defaultLoadOpts { CSV.loNoHeader = True }) fp
-        case r of
-          Left e -> expectationFailure ("unexpected Left: " ++ e)
-          Right (df, lg) -> do
-            let cols = DX.columnNames df
-            cols `shouldBe` ["col0", "col1"]
-            map Log.lgCode (Log.entries lg) `shouldContain` ["I012"]
-    it "--skip 2: 先頭 2 行を skip" $
-      withSystemTempFile "ha-skip.csv" $ \fp h -> do
-        hPutStr h "# c1\n# c2\nx,y\n1,2\n3,4\n"
-        hClose h
-        r <- CSV.loadAutoSafeWith
-               (CSV.defaultLoadOpts { CSV.loSkip = 2 }) fp
-        case r of
-          Left e -> expectationFailure ("unexpected Left: " ++ e)
-          Right (df, _) -> DX.columnNames df `shouldBe` ["x", "y"]
-    it "sniff: ヘッダ無し CSV を自動推論で col0... に変える" $
-      withSystemTempFile "ha-sniff-noh.csv" $ \fp h -> do
-        hPutStr h "1.0,2.0\n3.0,4.0\n"
-        hClose h
-        r <- CSV.loadAutoSafeWith CSV.defaultLoadOpts fp
-        case r of
-          Left e -> expectationFailure ("unexpected Left: " ++ e)
-          Right (df, lg) -> do
-            DX.columnNames df `shouldBe` ["col0", "col1"]
-            map Log.lgCode (Log.entries lg) `shouldContain` ["I013"]
-    it "sniff: コメント行 # を skip 推論" $
-      withSystemTempFile "ha-sniff-skip.csv" $ \fp h -> do
-        hPutStr h "# comment 1\n# comment 2\nx,y\n1,2\n3,4\n"
-        hClose h
-        r <- CSV.loadAutoSafeWith CSV.defaultLoadOpts fp
-        case r of
-          Left e -> expectationFailure ("unexpected Left: " ++ e)
-          Right (df, _) -> DX.columnNames df `shouldBe` ["x", "y"]
-    it "sniff: セミコロン区切りを自動検出" $
-      withSystemTempFile "ha-sniff-semi.csv" $ \fp h -> do
-        hPutStr h "a;b;c\n1;2;3\n4;5;6\n"
-        hClose h
-        r <- CSV.loadAutoSafeWith CSV.defaultLoadOpts fp
-        case r of
-          Left e -> expectationFailure ("unexpected Left: " ++ e)
-          Right (df, _) -> DX.columnNames df `shouldBe` ["a", "b", "c"]
-    it "sniff: --no-sniff で自動推論を切れる" $
-      withSystemTempFile "ha-no-sniff.csv" $ \fp h -> do
-        hPutStr h "1.0,2.0\n3.0,4.0\n"
-        hClose h
-        r <- CSV.loadAutoSafeWith
-               (CSV.defaultLoadOpts { CSV.loSniff = False }) fp
-        case r of
-          Left e -> expectationFailure ("unexpected Left: " ++ e)
-          Right (df, lg) -> do
-            -- ヘッダ無しの自動修復は走らないので col0 にはならない
-            DX.columnNames df `shouldBe` ["1.0", "2.0"]
-            -- 代わりに W001 が出る
-            map Log.lgCode (Log.entries lg) `shouldContain` ["W001"]
-
-    it "Clean.stripUnitsCol: 12.3kg → 12.3" $ do
-      let df0 = DX.insertColumn "w"
-                   (DX.fromList (["12.3kg", "11.5cm", "10kg"] :: [T.Text]))
-              $ DX.empty
-          (df1, lg) = Clean.applyRule Clean.StripUnits "w" df0
-      map Log.lgCode (Log.entries lg) `shouldContain` ["I100"]
-      case Conv2.getDoubleVec "w" df1 of
-        Just v  -> V.toList v `shouldBe` [12.3, 11.5, 10.0]
-        Nothing -> expectationFailure "expected numeric column"
-    it "Clean.parseCurrencyCol: $1,234.56 → 1234.56" $ do
-      let df0 = DX.insertColumn "p"
-                   (DX.fromList (["$1,234.56", "$2,500.00"] :: [T.Text]))
-              $ DX.empty
-          (df1, _) = Clean.applyRule Clean.ParseCurrency "p" df0
-      case Conv2.getDoubleVec "p" df1 of
-        Just v  -> V.toList v `shouldBe` [1234.56, 2500.0]
-        Nothing -> expectationFailure "expected numeric column"
-    it "Clean.coerceNumericCol: 混在パターンを最大限拾う" $ do
-      let df0 = DX.insertColumn "x"
-                   (DX.fromList (["12.3", "12.3kg", "$1,000"] :: [T.Text]))
-              $ DX.empty
-          (df1, _) = Clean.applyRule Clean.CoerceNumeric "x" df0
-      case Conv2.getDoubleVec "x" df1 of
-        Just v  -> V.toList v `shouldBe` [12.3, 12.3, 1000.0]
-        Nothing -> expectationFailure "expected all-success column"
-    it "Preprocess.meltLonger: wide → long、NA セルは除外、列名を Double に parse" $ do
-      let df0 = DX.insertColumn "id" (DX.fromList (["a", "b"] :: [T.Text]))
-              $ DX.insertColumn "1"  (DX.fromList ([Just 10.0, Nothing] :: [Maybe Double]))
-              $ DX.insertColumn "2"  (DX.fromList ([Just 20.0, Just 30.0] :: [Maybe Double]))
-              $ DX.insertColumn "3"  (DX.fromList ([Nothing,   Just 60.0] :: [Maybe Double]))
-              $ DX.empty
-          df1 = Pp.meltLonger ["id"] ["1", "2", "3"] "t" "y" True df0
-          (nrows, ncols) = DX.dimensions df1
-      nrows `shouldBe` 4    -- a,1=10; a,2=20; b,2=30; b,3=60
-      ncols `shouldBe` 3    -- id, t, y
-      DX.columnNames df1 `shouldMatchList` ["id", "t", "y"]
-      case Conv2.getDoubleVec "y" df1 of
-        Just v  -> sort (V.toList v) `shouldBe` [10, 20, 30, 60]
-        Nothing -> expectationFailure "expected y as numeric"
-      case Conv2.getDoubleVec "t" df1 of
-        Just v  -> sort (V.toList v) `shouldBe` [1, 2, 2, 3]
-        Nothing -> expectationFailure "expected t parsed as numeric"
-
-    it "Preprocess.regridLong: ZIntersection モードで全 id が共通範囲に収まる" $ do
-      -- id=a: z=0..3, id=b: z=1..4 → intersection は (1, 3)
-      let df0 = DX.insertColumn "id" (DX.fromList (["a","a","a","a","b","b","b","b"] :: [T.Text]))
-              $ DX.insertColumn "z"  (DX.fromList ([0,1,2,3,1,2,3,4] :: [Double]))
-              $ DX.insertColumn "y"  (DX.fromList ([0,1,4,9,1,4,9,16] :: [Double]))
-              $ DX.empty
-          opts = Pp.defaultRegridOpts
-                   { Pp.roN = 5, Pp.roZBoundsMode = Pp.ZIntersection
-                   , Pp.roGridKind = AG.Uniform }
-          rr = Pp.regridLong "id" "z" "y" opts df0
-      Pp.rrZMin rr `shouldBe` 1.0
-      Pp.rrZMax rr `shouldBe` 3.0
-      length (Pp.rrZGrid rr) `shouldBe` 5
-      length (Pp.rrIds rr) `shouldBe` 2
-
-    it "Preprocess.regridLong: ZUnion モードで [min,max] が和集合になる" $ do
-      let df0 = DX.insertColumn "id" (DX.fromList (["a","a","b","b"] :: [T.Text]))
-              $ DX.insertColumn "z"  (DX.fromList ([0,2,1,3] :: [Double]))
-              $ DX.insertColumn "y"  (DX.fromList ([0,4,1,9] :: [Double]))
-              $ DX.empty
-          opts = Pp.defaultRegridOpts
-                   { Pp.roN = 4, Pp.roZBoundsMode = Pp.ZUnion
-                   , Pp.roGridKind = AG.Uniform }
-          rr = Pp.regridLong "id" "z" "y" opts df0
-      Pp.rrZMin rr `shouldBe` 0.0
-      Pp.rrZMax rr `shouldBe` 3.0
-      -- 外挿が記録される (id=a の上端 0..2、共通 0..3 → above=1)
-      let stat_a = head [s | s <- Pp.rrPerIdStats rr, Pp.piId s == "a"]
-      Pp.piExtrapAbove stat_a `shouldBe` 1.0
-
-    it "Clean.cleanPipeline: 複数列を一括変換" $ do
-      let df0 = DX.insertColumn "p"
-                   (DX.fromList (["$10", "$20"] :: [T.Text]))
-              $ DX.insertColumn "w"
-                   (DX.fromList (["1kg", "2kg"]   :: [T.Text]))
-              $ DX.empty
-          rules = [("p", Clean.ParseCurrency), ("w", Clean.StripUnits)]
-          (df1, lg) = Clean.cleanPipeline rules df0
-          codes = map Log.lgCode (Log.entries lg)
-      codes `shouldContain` ["I101"]
-      codes `shouldContain` ["I100"]
-      Conv2.getDoubleVec "p" df1 `shouldSatisfy` \mv ->
-        case mv of { Just v -> V.toList v == [10, 20]; Nothing -> False }
-
-    it "--strict + 警告ありデータ (sniff off) → Left" $
-      withSystemTempFile "ha-strict.csv" $ \fp h -> do
-        hPutStr h "1.0,2.0\n3.0,4.0\n"  -- ヘッダ無し疑い W001
-        hClose h
-        -- sniff を切ると W001 が残るので strict が短絡する
-        r <- CSV.loadAutoSafeWith
-               (CSV.defaultLoadOpts { CSV.loStrict = True
-                                    , CSV.loSniff  = False }) fp
-        case r of
-          Left _   -> return ()
-          Right _  -> expectationFailure "expected Left under --strict --no-sniff"
-
-  -- ===========================================================================
-  -- 多出力 API の q=1 等価性 (M1〜M8)
-  -- ===========================================================================
-  describe "Multi-output equivalence (q=1)" $ do
-    let xs = LA.fromLists [[1,1.0], [1,2.0], [1,3.0], [1,4.0], [1,5.0]] :: LA.Matrix Double
-        yV = LA.fromList [2.1, 3.9, 6.0, 8.1, 10.0]                      :: LA.Vector Double
-        yM = LA.asColumn yV
-        approx tol a b = abs (a - b) < tol
-        approxList tol as bs = length as == length bs &&
-                               all (uncurry (approx tol)) (zip as bs)
-        buildGroupsLocal gvec =
-          let lbls = V.fromList . sort . foldr (\x acc -> if x `elem` acc then acc else x:acc) [] $ V.toList gvec
-              qN   = V.length lbls
-              idxFor x = case V.elemIndex x lbls of
-                           Just i  -> i
-                           Nothing -> 0
-              idx  = V.map idxFor gvec
-              sz   = V.fromList [ V.length (V.filter (== j) idx) | j <- [0 .. qN - 1] ]
-          in (lbls, idx, sz)
-
-    it "M1 Regularized Ridge: fitRegularized == fitRegularizedMulti col 0" $ do
-      let single = Reg.fitRegularized (Reg.L2 0.1) xs yV
-          multi  = Reg.fitRegularizedMulti (Reg.L2 0.1) xs yM
-          extr   = Reg.regFitFromMulti 0 multi
-      approxList 1e-9 (LA.toList (Reg.rfBeta single))
-                      (LA.toList (Reg.rfBeta extr))
-        `shouldBe` True
-
-    it "M1 Regularized Lasso: q=1 一致" $ do
-      let single = Reg.fitRegularized (Reg.L1 0.05) xs yV
-          multi  = Reg.fitRegularizedMulti (Reg.L1 0.05) xs yM
-          extr   = Reg.regFitFromMulti 0 multi
-      approxList 1e-9 (LA.toList (Reg.rfBeta single))
-                      (LA.toList (Reg.rfBeta extr))
-        `shouldBe` True
-
-    it "M2 Spline: fitSpline == fitSplineMulti col 0" $ do
-      let xv = V.fromList [1,2,3,4,5,6,7,8,9,10] :: V.Vector Double
-          yv = V.fromList (map (\x -> sin (x/2) + 0.01*x) (V.toList xv))
-          knots = [1,3,5,7,10]
-          single = Sp.fitSpline (Sp.BSpline 3) knots xv yv
-          ymat   = LA.asColumn (LA.fromList (V.toList yv))
-          multi  = Sp.fitSplineMulti (Sp.BSpline 3) knots xv ymat
-          colS   = LA.toList (Sp.sfBeta single)
-          colM   = LA.toList (LA.flatten (Sp.smfBeta multi LA.¿ [0]))
-      approxList 1e-9 colS colM `shouldBe` True
-
-    it "M3 Kernel Ridge: kernelRidge == kernelRidgeMulti col 0" $ do
-      let xv = V.fromList [0.0,1,2,3,4,5,6,7,8,9] :: V.Vector Double
-          yv = V.fromList [0.0, 0.5, 1.0, 1.4, 1.7, 1.9, 2.0, 2.0, 1.95, 1.8]
-          single = K.kernelRidge K.Gaussian 1.0 0.01 xv yv
-          ymat   = LA.asColumn (LA.fromList (V.toList yv))
-          multi  = K.kernelRidgeMulti K.Gaussian 1.0 0.01 xv ymat
-      approxList 1e-9 (LA.toList (K.krAlpha single))
-                      (LA.toList (LA.flatten (K.krmAlpha multi LA.¿ [0])))
-        `shouldBe` True
-
-    it "M3 Kernel NW: nwRegression == nwRegressionMulti col 0" $ do
-      let xv = V.fromList [0.0,1,2,3,4,5,6,7,8,9] :: V.Vector Double
-          yv = V.fromList [0.1, 0.3, 0.7, 1.0, 1.5, 1.9, 2.0, 1.95, 1.8, 1.5]
-          xn = V.fromList [0.5, 2.5, 5.5, 8.5]
-          single = K.nwRegression K.Gaussian 1.0 xv yv xn
-          ymat   = LA.asColumn (LA.fromList (V.toList yv))
-          multi  = K.nwRegressionMulti K.Gaussian 1.0 xv ymat xn
-          colM   = LA.toList (LA.flatten (multi LA.¿ [0]))
-      approxList 1e-9 (V.toList single) colM `shouldBe` True
-
-    it "M4 RFF Ridge: rffRidge == rffRidgeMulti col 0" $ do
-      gen <- MWC.create
-      rff <- RFF.sampleRFFRBF 16 1.0 1.0 gen
-      let xList = [0.0, 1, 2, 3, 4, 5]
-          yList = [0.1, 0.5, 1.0, 1.4, 1.7, 1.9]
-          single = RFF.rffRidge rff xList yList 0.01
-          ymat   = LA.asColumn (LA.fromList yList)
-          multi  = RFF.rffRidgeMulti rff xList ymat 0.01
-      approxList 1e-9 (LA.toList (RFF.rffrWeights single))
-                      (LA.toList (LA.flatten (RFF.rffrmWeights multi LA.¿ [0])))
-        `shouldBe` True
-
-    it "M5 GP: fitGP mean == fitGPMulti col 0" $ do
-      let model = GP.GPModel GP.RBF GP.defaultGPParams
-          trX   = [0.0, 1, 2, 3, 4, 5]
-          trY   = [0.1, 0.4, 0.9, 1.3, 1.6, 1.8]
-          tsX   = [0.5, 2.5, 4.5]
-          single = GP.fitGP model trX trY tsX
-          ymat   = LA.asColumn (LA.fromList trY)
-          (mMat, _) = GP.fitGPMulti model trX ymat tsX
-      approxList 1e-9 (GP.gpMean single)
-                      (LA.toList (LA.flatten (mMat LA.¿ [0])))
-        `shouldBe` True
-
-    it "M5 GPRobust: fitGPRobust α == fitGPRobustMulti col 0" $ do
-      let params = GP.defaultGPParams
-          trX    = [0.0, 1, 2, 3, 4, 5]
-          trY    = [0.1, 0.4, 5.0, 1.3, 1.6, 1.8]   -- 1 outlier at idx 2
-          single = GPR.fitGPRobust GP.RBF params (GPR.RStudentT 4 0.3) trX trY
-          ymat   = LA.asColumn (LA.fromList trY)
-          multi  = GPR.fitGPRobustMulti GP.RBF params (GPR.RStudentT 4 0.3) trX ymat
-          firstFit = head (GPR.rgmFits multi)
-      approxList 1e-9 (LA.toList (GPR.rgpAlpha single))
-                      (LA.toList (GPR.rgpAlpha firstFit))
-        `shouldBe` True
-
-    it "M6 GLM Gaussian: fitGLM == fitGLMMulti col 0" $ do
-      let single = GLM.fitGLM GLM.Gaussian xs yV
-          multi  = GLM.fitGLMMulti GLM.Gaussian GLM.Identity xs yM
-          colM   = LA.toList (LA.flatten (GLM.gfmBeta multi LA.¿ [0]))
-          colS   = LA.toList (LA.flatten (Core.coefficients single))
-      approxList 1e-7 colS colM `shouldBe` True
-
-    it "M7 LME: fitLME == fitLMEMulti col 0" $ do
-      let xMat = LA.fromLists
-                   [[1,1],[1,2],[1,3],[1,4],
-                    [1,1],[1,2],[1,3],[1,4],
-                    [1,1],[1,2],[1,3],[1,4]] :: LA.Matrix Double
-          y1   = LA.fromList [7.1,6.9,7.0,7.0, 5.0,4.9,5.1,5.0, 3.0,2.9,3.1,3.0]
-          ym   = LA.asColumn y1
-          gv   = V.fromList (["A","A","A","A","B","B","B","B","C","C","C","C"] :: [T.Text])
-          (lbls, idx, sz) = buildGroupsLocal gv
-          single = fitLME xMat y1 idx lbls sz
-          multi  = fitLMEMulti xMat ym idx lbls sz
-          firstM = head (glmmFits multi)
-      glmmRandVar firstM `shouldSatisfy` approx 1e-9 (glmmRandVar single)
-
-  -- ===========================================================================
-  -- 単目的オプティマイザ (Hanalyze.Optim.NelderMead)
-  -- ===========================================================================
-  describe "Hanalyze.Optim.NelderMead" $ do
-    let l2 :: [Double] -> [Double] -> Double
-        l2 a b = sqrt (sum (zipWith (\x y -> (x-y)^(2::Int)) a b))
-        sphere xs = sum [x*x | x <- xs]
-        rosenbrock [x, y] = (1 - x)^(2::Int) + 100 * (y - x*x)^(2::Int)
-        rosenbrock _ = error "rosenbrock: 2D only"
-
-    it "minimises sphere f(x)=Σx² to ~0 from x0=[3,-2,1]" $ do
-      r <- NM.runNelderMead sphere [3, -2, 1]
-      OC.orValue r `shouldSatisfy` (< 1e-6)
-
-    it "minimises Rosenbrock 2D to (1,1) within 0.05" $ do
-      let cfg = NM.defaultNMConfig
-                  { NM.nmStop = OC.defaultStopCriteria { OC.stMaxIter = 5000 } }
-      r <- NM.runNelderMeadWith cfg rosenbrock [-1.2, 1.0]
-      l2 (OC.orBest r) [1, 1] `shouldSatisfy` (< 0.05)
-
-    it "Maximize: -sphere has optimum 0 at origin" $ do
-      let cfg = NM.defaultNMConfig { NM.nmDir = OC.Maximize }
-      r <- NM.runNelderMeadWith cfg (\xs -> negate (sphere xs)) [3, -2, 1]
-      -- Maximize: orValue は元尺度 (= negate sphere の最大値、すなわち 0 に近い)
-      OC.orValue r `shouldSatisfy` (\v -> v > -1e-6)
-      -- 最良点は原点近傍
-      l2 (OC.orBest r) [0, 0, 0] `shouldSatisfy` (< 1e-2)
-
-  -- ===========================================================================
-  -- 単目的オプティマイザ (Hanalyze.Optim.LBFGS)
-  -- ===========================================================================
-  describe "Hanalyze.Optim.LBFGS" $ do
-    let l2 :: [Double] -> [Double] -> Double
-        l2 a b = sqrt (sum (zipWith (\x y -> (x-y)^(2::Int)) a b))
-        sphere xs = sum [x*x | x <- xs]
-        sphereGrad xs = [2*x | x <- xs]
-        rosen [x, y] = (1-x)^(2::Int) + 100*(y - x*x)^(2::Int)
-        rosen _ = error "rosen: 2D"
-        rosenGrad [x, y] =
-          [ -2*(1-x) - 400*x*(y - x*x), 200*(y - x*x) ]
-        rosenGrad _ = error "rosenGrad: 2D"
-
-    it "minimises sphere 5D with analytic grad to ~0" $ do
-      r <- LBFGS.runLBFGS sphere sphereGrad [3, -2, 1, 0.5, -1.5]
-      OC.orValue r `shouldSatisfy` (< 1e-8)
-
-    it "minimises Rosenbrock 2D within 0.01 of (1,1)" $ do
-      let cfg = LBFGS.defaultLBFGSConfig
-                  { LBFGS.lbStop = OC.defaultStopCriteria { OC.stMaxIter = 500 } }
-      r <- LBFGS.runLBFGSWith cfg rosen rosenGrad [-1.2, 1.0]
-      l2 (OC.orBest r) [1, 1] `shouldSatisfy` (< 0.01)
-
-    it "numeric gradient: sphere 30D converges" $ do
-      let x0 = take 30 (cycle [1.5, -2.0, 0.5])
-      r <- LBFGS.runLBFGSNumeric LBFGS.defaultLBFGSConfig sphere x0
-      OC.orValue r `shouldSatisfy` (< 1e-4)
-
-  -- ===========================================================================
-  -- 1D オプティマイザ (Hanalyze.Optim.LineSearch)
-  -- ===========================================================================
-  describe "Hanalyze.Optim.LineSearch" $ do
-    let parabola [x] = (x - 2.5)^(2::Int) + 1.0
-        parabola _   = error "1D"
-        cosBowl [x] = cos x + 0.1 * x * x   -- 単峰、最小 ≈ 1.428 付近
-        cosBowl _   = error "1D"
-
-    it "Brent: parabola minimum at x = 2.5" $ do
-      let r = LS.brent LS.defaultBrentConfig parabola 0 5
-      abs (head (OC.orBest r) - 2.5) `shouldSatisfy` (< 1e-5)
-      OC.orValue r `shouldSatisfy` (\v -> abs (v - 1) < 1e-8)
-
-    it "Brent: cos x + 0.1 x² minimum (verified by GS)" $ do
-      let rB = LS.brent LS.defaultBrentConfig cosBowl 0 4
-          rG = LS.goldenSection OC.Minimize cosBowl 0 4 1e-8 200
-      abs (head (OC.orBest rB) - head (OC.orBest rG)) `shouldSatisfy` (< 1e-3)
-
-    it "GoldenSection: parabola minimum at x = 2.5" $ do
-      let r = LS.goldenSection OC.Minimize parabola 0 5 1e-7 200
-      abs (head (OC.orBest r) - 2.5) `shouldSatisfy` (< 1e-3)
-
-    it "Kernel.autoBandwidthBrent: 同じ最適 h を grid 法とほぼ一致" $ do
-      let xs = V.fromList [0.0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
-          ys = V.map (\x -> sin x + 0.1 * x) xs
-          (hG, _) = K.gridSearchBandwidth K.Gaussian xs ys [0.5, 0.8, 1.0, 1.5, 2.0, 3.0]
-          (hB, _) = K.autoBandwidthBrent K.Gaussian xs ys 0.3 4.0
-      abs (hB - hG) `shouldSatisfy` (< 1.0)   -- グリッドと近い領域
-
-  -- ===========================================================================
-  -- 大域オプティマイザ (Hanalyze.Optim.DifferentialEvolution)
-  -- ===========================================================================
-  describe "Hanalyze.Optim.DifferentialEvolution" $ do
-    let sphere xs = sum [x*x | x <- xs]
-        rastrigin xs =
-          10 * fromIntegral (length xs) +
-          sum [x*x - 10 * cos (2 * pi * x) | x <- xs]
-        l2 a b = sqrt (sum (zipWith (\x y -> (x-y)^(2::Int)) a b))
-
-    it "DE: sphere 5D が原点付近に到達" $ do
-      gen <- MWC.create
-      let bs = replicate 5 (-5, 5)
-          cfg = (DE.defaultDEConfig bs)
-                  { DE.deStop = OC.defaultStopCriteria { OC.stMaxIter = 200 } }
-      r <- DE.runDEWith cfg sphere gen
-      OC.orValue r `shouldSatisfy` (< 1e-3)
-
-    it "DE: Rastrigin 3D の大域最小 (原点) を見つける" $ do
-      gen <- MWC.create
-      let bs = replicate 3 (-5.12, 5.12)
-          cfg = (DE.defaultDEConfig bs)
-                  { DE.deStop = OC.defaultStopCriteria { OC.stMaxIter = 400 } }
-      r <- DE.runDEWith cfg rastrigin gen
-      l2 (OC.orBest r) [0, 0, 0] `shouldSatisfy` (< 0.5)
-
-  -- ===========================================================================
-  -- 大域オプティマイザ (Hanalyze.Optim.CMAES、簡易対角版)
-  -- ===========================================================================
-  describe "Hanalyze.Optim.CMAES" $ do
-    let sphere xs = sum [x*x | x <- xs]
-        l2 a b = sqrt (sum (zipWith (\x y -> (x-y)^(2::Int)) a b))
-
-    it "CMA-ES: sphere 5D を最小化、原点近傍に到達" $ do
-      gen <- MWC.create
-      let cfg = CMAES.defaultCMAESConfig
-                  { CMAES.cmStop = OC.defaultStopCriteria { OC.stMaxIter = 200 }
-                  , CMAES.cmSigma0 = 1.0 }
-      r <- CMAES.runCMAESWith cfg sphere [3, -2, 1, 0.5, -1.5] gen
-      l2 (OC.orBest r) [0,0,0,0,0] `shouldSatisfy` (< 0.5)
-
-    it "CMA-ES Full: sphere 5D で 1e-3 以内に到達" $ do
-      gen <- MWC.create
-      let cfg = CMAESF.defaultCMAESFConfig
-                  { CMAESF.cmfStop = OC.defaultStopCriteria { OC.stMaxIter = 300 }
-                  , CMAESF.cmfSigma0 = 1.0 }
-      r <- CMAESF.runCMAESFullWith cfg sphere [3, -2, 1, 0.5, -1.5] gen
-      OC.orValue r `shouldSatisfy` (< 1e-3)
-
-    it "CMA-ES Full: Rosenbrock 2D で (1,1) に 0.1 以内" $ do
-      gen <- MWC.create
-      let cfg = CMAESF.defaultCMAESFConfig
-                  { CMAESF.cmfStop   = OC.defaultStopCriteria { OC.stMaxIter = 500 }
-                  , CMAESF.cmfSigma0 = 0.5
-                  , CMAESF.cmfLambda = Just 20 }
-          rosen [x, y] = (1-x)^(2::Int) + 100 * (y - x*x)^(2::Int)
-          rosen _      = error "2D"
-      r <- CMAESF.runCMAESFullWith cfg rosen [-1.2, 1.0] gen
-      l2 (OC.orBest r) [1, 1] `shouldSatisfy` (< 0.1)
-
-  -- ===========================================================================
-  -- メタヒューリスティック (Tier 2: Simulated Annealing, PSO)
-  -- ===========================================================================
-  describe "Hanalyze.Optim.SimulatedAnnealing" $ do
-    it "SA: sphere 5D で sufficient annealing" $ do
-      gen <- MWC.create
-      let bs = replicate 5 (-3, 3)
-          cfg = (SA.defaultSAConfig bs)
-                  { SA.saStop = OC.defaultStopCriteria { OC.stMaxIter = 5000 }
-                  , SA.saInitTemp = 2.0
-                  , SA.saSchedule = SA.Geometric 0.997 }
-          sphere xs = sum [x*x | x <- xs]
-      r <- SA.runSAWith cfg sphere [2, -1.5, 1, 0.5, -0.7] gen
-      OC.orValue r `shouldSatisfy` (< 0.5)
-
-  describe "Hanalyze.Optim.ParticleSwarm" $ do
-    it "PSO: sphere 5D で原点近傍 (0.5 以内)" $ do
-      gen <- MWC.create
-      let bs = replicate 5 (-5, 5)
-          cfg = (PSO.defaultPSOConfig bs)
-                  { PSO.psoStop = OC.defaultStopCriteria { OC.stMaxIter = 200 }
-                  , PSO.psoNum  = 30 }
-          sphere xs = sum [x*x | x <- xs]
-      r <- PSO.runPSOWith cfg sphere gen
-      OC.orValue r `shouldSatisfy` (< 0.5)
-
-    it "PSO: Rastrigin 3D の大域最小に近い" $ do
-      gen <- MWC.create
-      let bs = replicate 3 (-5.12, 5.12)
-          cfg = (PSO.defaultPSOConfig bs)
-                  { PSO.psoStop = OC.defaultStopCriteria { OC.stMaxIter = 300 }
-                  , PSO.psoNum  = 40 }
-          rastrigin xs =
-            10 * fromIntegral (length xs) +
-            sum [x*x - 10 * cos (2 * pi * x) | x <- xs]
-      r <- PSO.runPSOWith cfg rastrigin gen
-      OC.orValue r `shouldSatisfy` (< 5.0)   -- 大域近傍 (10 程度の局所有り)
-
-  -- ===========================================================================
-  -- 制約付き最適化 (Augmented Lagrangian)
-  -- ===========================================================================
-  describe "Hanalyze.Optim.Constrained" $ do
-    it "Augmented Lagrangian: min x1²+x2² s.t. x1+x2=1 → (0.5, 0.5)" $ do
-      let f xs = (head xs)^(2::Int) + (xs !! 1)^(2::Int)
-          cs = Con.ConstraintSet
-                 { Con.csEq   = [\xs -> head xs + xs !! 1 - 1]
-                 , Con.csIneq = []
-                 }
-      (r, viol) <- Con.runAugmentedLagrangian
-                     Con.defaultConstrainedConfig f cs [0, 0]
-      let [x1, x2] = OC.orBest r
-      abs (x1 - 0.5) `shouldSatisfy` (< 0.05)
-      abs (x2 - 0.5) `shouldSatisfy` (< 0.05)
-      viol `shouldSatisfy` (< 1e-3)
-
-    it "Augmented Lagrangian: 不等式 x ≥ 1 (h(x)=1-x≤0) で min x²" $ do
-      let f xs  = (head xs)^(2::Int)
-          cs   = Con.ConstraintSet
-                 { Con.csEq   = []
-                 , Con.csIneq = [\xs -> 1 - head xs]    -- 1 - x ≤ 0 ⇔ x ≥ 1
-                 }
-      (r, viol) <- Con.runAugmentedLagrangian
-                     Con.defaultConstrainedConfig f cs [0]
-      let [x] = OC.orBest r
-      x `shouldSatisfy` (\v -> v >= 1 - 0.01)
-      viol `shouldSatisfy` (< 1e-3)
-
-    it "Penalty method: 等式 x1+x2=1 (簡易版)" $ do
-      let f xs = (head xs)^(2::Int) + (xs !! 1)^(2::Int)
-          cs = Con.ConstraintSet
-                 { Con.csEq   = [\xs -> head xs + xs !! 1 - 1]
-                 , Con.csIneq = []
-                 }
-      (r, _) <- Con.penaltyMethod Con.defaultConstrainedConfig f cs [0, 0]
-      let [x1, x2] = OC.orBest r
-      abs (x1 + x2 - 1) `shouldSatisfy` (< 0.05)
-
-    it "boxToIneq: bounds [(1,5)] を不等式 2 本に展開、x=3 で両方満たす" $ do
-      let ineqs = Con.boxToIneq [(1.0, 5.0)]
-      length ineqs `shouldBe` 2
-      all (\h -> h [3.0] <= 0) ineqs `shouldBe` True
-      any (\h -> h [0.0] > 0) ineqs `shouldBe` True   -- 下限違反
-      any (\h -> h [6.0] > 0) ineqs `shouldBe` True   -- 上限違反
-
-  -- ===========================================================================
-  -- box 制約 (Bounds) 統一インターフェース (Hanalyze.Optim.Common)
-  -- ===========================================================================
-  describe "Hanalyze.Optim.Common box constraints" $ do
-    it "clipToBounds: 範囲外を反射、範囲内はそのまま" $ do
-      OC.clipToBounds [(0,10)] [5]    `shouldBe` [5]
-      OC.clipToBounds [(0,10)] [-2]   `shouldBe` [2]    -- 反射
-      OC.clipToBounds [(0,10)] [12]   `shouldBe` [8]
-
-    it "boundsPenalty: 範囲内 0、範囲外で大きい正値" $ do
-      OC.boundsPenalty (Just [(0,10)]) [5]    `shouldBe` 0
-      OC.boundsPenalty Nothing         [-99]  `shouldBe` 0
-      OC.boundsPenalty (Just [(0,10)]) [-1]   `shouldSatisfy` (> 1e5)
-
-    it "NelderMead: nmBounds で box 内に解が収まる" $ do
-      let f xs = (head xs - 5)^(2::Int)   -- 真の最小は x=5
-          cfg = NM.defaultNMConfig { NM.nmBounds = Just [(0, 2)] }
-      r <- NM.runNelderMeadWith cfg f [1.0]
-      head (OC.orBest r) `shouldSatisfy` (\v -> v >= -0.1 && v <= 2.1)
-
-    it "LBFGS: lbBounds で box 内に解が収まる" $ do
-      let f xs = (head xs - 5)^(2::Int)
-          cfg = LBFGS.defaultLBFGSConfig { LBFGS.lbBounds = Just [(0, 2)] }
-      r <- LBFGS.runLBFGSNumeric cfg f [1.0]
-      head (OC.orBest r) `shouldSatisfy` (\v -> v >= -0.1 && v <= 2.1)
-
-    it "CMAES (簡易版): cmBounds で box 内サンプル" $ do
-      gen <- MWC.create
-      let f xs = sum [x*x | x <- xs]
-          cfg = CMAES.defaultCMAESConfig
-                  { CMAES.cmBounds = Just [(-1, 1), (-1, 1)]
-                  , CMAES.cmStop   = (CMAES.cmStop CMAES.defaultCMAESConfig)
-                                       { OC.stMaxIter = 80 }
-                  }
-      r <- CMAES.runCMAESWith cfg f [0.5, 0.5] gen
-      all (\v -> v >= -1.05 && v <= 1.05) (OC.orBest r) `shouldBe` True
-
-    it "CMAESFull: cmfBounds で box 内サンプル" $ do
-      gen <- MWC.create
-      let f xs = sum [x*x | x <- xs]
-          cfg = CMAESF.defaultCMAESFConfig
-                  { CMAESF.cmfBounds = Just [(-1, 1), (-1, 1)]
-                  , CMAESF.cmfStop   = (CMAESF.cmfStop CMAESF.defaultCMAESFConfig)
-                                         { OC.stMaxIter = 80 }
-                  }
-      r <- CMAESF.runCMAESFullWith cfg f [0.5, 0.5] gen
-      all (\v -> v >= -1.05 && v <= 1.05) (OC.orBest r) `shouldBe` True
-
-  -- ===========================================================================
-  -- Bayesian Optimization 内部最適化の差し替え (Hanalyze.Optim.BayesOpt)
-  -- ===========================================================================
-  describe "Hanalyze.Optim.BayesOpt (acquisition optimizer swap)" $ do
-    it "bayesOpt 1D: Brent 内側で簡単な凸関数 (x-1.5)^2 を見つける" $ do
-      gen <- MWC.create
-      let cfg = BO.defaultBayesOptConfig
-                  { BO.boIterations = 8
-                  , BO.boInitPoints = 4
-                  , BO.boGridSize   = 32
-                  }
-          target x = pure ((x - 1.5)^(2::Int) :: Double)
-      (_, (xb, _)) <- BO.bayesOpt cfg target (0, 3) gen
-      -- 8 反復では精度は緩めに。3.0 範囲のうち 0.5 以内に収束を期待
-      abs (xb - 1.5) `shouldSatisfy` (< 0.5)
-
-  -- ===========================================================================
-  -- RFF HP 自動チューニングの DE 版 (Phase O9)
-  -- ===========================================================================
-  describe "Hanalyze.Model.RFF DE-based auto-HP" $ do
-    it "maximizeMarginalLikRBFMV_DE: y = sin x + noise で妥当な ℓ" $ do
-      gen <- MWC.create
-      let n = 30
-          xs = [ fromIntegral i / 5 | i <- [0 .. n - 1] ] :: [Double]
-          ys = [ sin x + 0.05 * cos (3 * x) | x <- xs ]
-          xMat = LA.fromColumns [LA.fromList xs]
-          yVec = LA.fromList ys
-      r <- RFF.maximizeMarginalLikRBFMV_DE xMat yVec 30 gen
-      -- ℓ が極端に小さくない (>1e-2) ことだけ確認
-      RFF.mlEll r `shouldSatisfy` (> 1e-2)
-
-    it "gridSearchLOOCVRBFMV_DE: LOOCV が有限値、ℓ が探索範囲内" $ do
-      gen <- MWC.create
-      let n = 25
-          xs = [ fromIntegral i / 4 | i <- [0 .. n - 1] ] :: [Double]
-          ys = [ x + 0.1 * sin (2 * x) | x <- xs ]
-          xMat = LA.fromColumns [LA.fromList xs]
-          yVec = LA.fromList ys
-      r <- RFF.gridSearchLOOCVRBFMV_DE 1 50 xMat yVec 20 gen
-      RFF.lcLOOCV r `shouldSatisfy` (\v -> not (isNaN v) && v >= 0)
-      RFF.lcEll   r `shouldSatisfy` (> 1e-3)
-
-  -- ===========================================================================
-  -- Hanalyze.Viz.ReportBuilder.secInterpolation (Phase G4)
-  -- ===========================================================================
-  describe "Hanalyze.Viz.ReportBuilder.secInterpolation" $ do
-    it "defaultInterpReport で renderReport まで通る (smoke)" $
-      withSystemTempFile "ha-interp.html" $ \fp h -> do
-        hClose h
-        let ir = RB.defaultInterpReport "test"
-        RB.renderReport fp (RB.defaultReportConfig "T") [RB.secInterpolation ir]
-        out <- readFile fp
-        length out `shouldSatisfy` (> 100)
-
-    it "InterpReport 全フィールド + extra で HTML に主要要素が含まれる" $
-      withSystemTempFile "ha-interp2.html" $ \fp h -> do
-        hClose h
-        let ir = (RB.defaultInterpReport "regrid")
-                   { RB.irInterpKind    = "PCHIP"
-                   , RB.irGridKind      = "Adaptive"
-                   , RB.irN             = 3
-                   , RB.irPerIdObserved = [("a", [(0, 0), (1, 1)])]
-                   , RB.irPerIdInterpY  = [("a", [(0, 0), (0.5, 0.5), (1, 1)])]
-                   , RB.irGrid          = [0, 0.5, 1]
-                   , RB.irDensity       = [(0, 1), (0.5, 2), (1, 1)]
-                   , RB.irPerIdSummary  = [("a", 2, 0, 1, 0, 0, 0)]
-                   , RB.irExtraEnabled  = True
-                   , RB.irPerIdYRange   = [("a", 0, 1, 0, 1)]
-                   }
-        RB.renderReport fp (RB.defaultReportConfig "T") [RB.secInterpolation ir]
-        out <- readFile fp
-        out `shouldContain` "Parameters"
-        out `shouldContain` "PCHIP"
-
-  -- ===========================================================================
-  -- Hanalyze.Stat.Test (Phase 1: hypothesis tests)
-  -- ===========================================================================
-  describe "Hanalyze.Stat.Test" $ do
-    it "tTest1Sample: μ₀=0 で同分布なら p > 0.05" $ do
-      let xs = LA.fromList [0.1, -0.2, 0.3, 0.0, 0.15, -0.1, 0.05, 0.2]
-          tr = ST.tTest1Sample xs 0 ST.TwoSided
-      ST.trPValue tr `shouldSatisfy` (> 0.05)
-
-    it "tTestWelch: 明らかにずれた 2 群で p < 0.05" $ do
-      let xs = LA.fromList [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]
-          ys = LA.fromList [10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 17.0]
-          tr = ST.tTestWelch xs ys ST.TwoSided
-      ST.trPValue tr `shouldSatisfy` (< 0.05)
-
-    it "anovaOneWay: 3 群が異なる平均なら有意" $ do
-      let g1 = LA.fromList [1.0, 2.0, 3.0]
-          g2 = LA.fromList [4.0, 5.0, 6.0]
-          g3 = LA.fromList [7.0, 8.0, 9.0]
-          tr = ST.anovaOneWay [g1, g2, g3]
-      ST.trStatistic tr `shouldSatisfy` (> 1)
-      ST.trPValue   tr `shouldSatisfy` (< 0.05)
-
-    it "chiSquareIndep: 強い独立で p > 0.05" $ do
-      let tbl = LA.matrix 2 [25, 25, 25, 25]  -- 完全独立
-          tr = ST.chiSquareIndep tbl
-      ST.trPValue tr `shouldSatisfy` (> 0.5)
-
-    it "chiSquareIndep: 強い従属で p < 0.05" $ do
-      let tbl = LA.matrix 2 [40, 10, 10, 40]  -- 高 chi2
-          tr = ST.chiSquareIndep tbl
-      ST.trPValue tr `shouldSatisfy` (< 0.05)
-
-    it "leveneTest: 分散が大きく異なれば p < 0.05" $ do
-      let g1 = LA.fromList [1, 1.1, 0.9, 1.05, 0.95, 1.02, 0.98, 1.03]
-          g2 = LA.fromList [10, 20, 5, 25, 8, 30, 3, 22]  -- much larger var
-          tr = ST.leveneTest [g1, g2]
-      ST.trPValue tr `shouldSatisfy` (< 0.05)
-
-    it "shapiroWilk: roughly-normal 系列で p > 0.05" $ do
-      let xs = LA.fromList [-1.5, -0.5, 0.0, 0.3, 0.8, 1.2, -0.3, 0.5, 1.0, -1.0]
-          tr = ST.shapiroWilk xs
-      ST.trPValue tr `shouldSatisfy` (> 0.05)
-
-    it "mannWhitneyU: 明らかにずれた 2 群で p < 0.05" $ do
-      let xs = LA.fromList [1, 2, 3, 4, 5, 6]
-          ys = LA.fromList [11, 12, 13, 14, 15, 16]
-          tr = ST.mannWhitneyU xs ys ST.TwoSided
-      ST.trPValue tr `shouldSatisfy` (< 0.05)
-
-    it "fisherExact2x2: 強い偏りで p < 0.05" $ do
-      let tr = ST.fisherExact2x2 ((20, 5), (5, 20)) ST.TwoSided
-      ST.trPValue tr `shouldSatisfy` (< 0.05)
-
-  -- ===========================================================================
-  -- Hanalyze.Model.PCA (Phase 2)
-  -- ===========================================================================
-  describe "Hanalyze.Model.PCA" $ do
-    it "PCA on rank-1 matrix: 1st component explains ~100% var" $ do
-      let -- Rank-1: each row = scalar × [1, 2, 3]
-          ks = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]
-          xs = LA.fromLists [[k, 2 * k, 3 * k] | k <- ks]
-          r  = PCA.pca PCA.Center Nothing xs
-      head (LA.toList (PCA.pcaExplainedRatio r))
-        `shouldSatisfy` (> 0.999)
-
-    it "pcaTransform + pcaInverse は Center mode で全成分なら復元 ≈ x" $ do
-      let xs = LA.fromLists [[1, 2, 3], [4, 5, 6], [7, 8, 9], [2, 1, 0]]
-          r  = PCA.pca PCA.Center Nothing xs
-          scores = PCA.pcaTransform r xs
-          recon  = PCA.pcaInverse r scores
-          diff   = LA.norm_2 (LA.flatten (xs - recon))
-      diff `shouldSatisfy` (< 1e-9)
-
-    it "pcaCumExplained は monotone increasing で max ≤ 1" $ do
-      let xs = LA.fromLists [[k, 2*k+1, k*k]
-                            | k <- [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]]
-          r  = PCA.pca PCA.Center Nothing xs
-          cum = LA.toList (PCA.pcaCumExplained r)
-      last cum `shouldSatisfy` (<= 1.0001)
-      and (zipWith (<=) cum (tail cum)) `shouldBe` True
-
-    it "CenterScale で各列の SD が 1 に正規化される" $ do
-      let xs = LA.fromLists [[1, 100, 0.1], [2, 200, 0.2],
-                             [3, 300, 0.3], [4, 400, 0.4]]
-          r  = PCA.pca PCA.CenterScale Nothing xs
-      -- SD は元データの per-col SD で保存される
-      LA.size (PCA.pcaScale r) `shouldBe` 3
-      all (> 0) (LA.toList (PCA.pcaScale r)) `shouldBe` True
-
-  -- ===========================================================================
-  -- Hanalyze.Stat.ClassMetrics (Phase 3)
-  -- ===========================================================================
-  describe "Hanalyze.Stat.ClassMetrics" $ do
-    it "confusionMatrix: 完全一致で TP/TN のみ" $ do
-      let c = CM.confusionMatrix [1, 0, 1, 0, 1] [1, 0, 1, 0, 1]
-      CM.confTP c `shouldBe` 3
-      CM.confTN c `shouldBe` 2
-      CM.confFP c `shouldBe` 0
-      CM.confFN c `shouldBe` 0
-      CM.accuracy c `shouldBe` 1.0
-      CM.f1Score c  `shouldBe` 1.0
-
-    it "precision/recall/f1: 不均衡な誤分類" $ do
-      let c = CM.confusionMatrix [1, 1, 1, 0, 0] [1, 1, 0, 1, 0]
-      -- TP=2, FN=1, FP=1, TN=1
-      CM.precision c `shouldBe` 2/3   -- 2/(2+1)
-      CM.recall c    `shouldBe` 2/3   -- 2/(2+1)
-      CM.f1Score c   `shouldBe` 2/3
-
-    it "AUC: 完全分離で 1.0、ランダムスコアで ~0.5" $ do
-      let ys = [0, 0, 0, 1, 1, 1]
-          perfectScores  = [0.1, 0.2, 0.3, 0.7, 0.8, 0.9]
-          aucPerfect = CM.auc ys perfectScores
-      aucPerfect `shouldBe` 1.0
-
-    it "logLoss: 自信ある正解で小、自信ある誤りで大" $ do
-      let lossGood = CM.logLoss [1, 0, 1, 0] [0.99, 0.01, 0.99, 0.01]
-          lossBad  = CM.logLoss [1, 0, 1, 0] [0.01, 0.99, 0.01, 0.99]
-      lossGood `shouldSatisfy` (< 0.05)
-      lossBad  `shouldSatisfy` (> 4.0)
-
-    it "brierScore: 完全予測で 0、最悪予測で 1" $ do
-      let bsGood = CM.brierScore [1, 0, 1, 0] [1.0, 0.0, 1.0, 0.0]
-          bsBad  = CM.brierScore [1, 0, 1, 0] [0.0, 1.0, 0.0, 1.0]
-      bsGood `shouldSatisfy` (< 1e-10)
-      bsBad  `shouldBe` 1.0
-
-    it "MCC: 完全一致で 1、ランダムで 0 付近" $ do
-      let cGood = CM.confusionMatrix [1, 0, 1, 0, 1] [1, 0, 1, 0, 1]
-      CM.matthewsCorr cGood `shouldBe` 1.0
-
-    it "macroF1 (multi-class): 完全分類で 1.0" $ do
-      let cm = CM.confusionMulti [0, 1, 2, 0, 1, 2] [0, 1, 2, 0, 1, 2]
-      CM.accuracyMulti cm `shouldBe` 1.0
-      CM.macroF1 cm       `shouldBe` 1.0
-
-  -- ===========================================================================
-  -- Hanalyze.Stat.CV (Phase 4)
-  -- ===========================================================================
-  describe "Hanalyze.Stat.CV" $ do
-    it "kFold(5, 100): 5 fold で全 100 行を test に使用、重複なし" $ do
-      gen <- MWC.createSystemRandom
-      folds <- CV.kFold 5 100 gen
-      length folds `shouldBe` 5
-      let allTest = concatMap snd folds
-      length allTest `shouldBe` 100
-      length (V.toList (V.fromList allTest)) `shouldBe` 100  -- 重複なし
-
-    it "kFold: train + test = total samples per fold" $ do
-      gen <- MWC.createSystemRandom
-      folds <- CV.kFold 5 100 gen
-      mapM_ (\(tr, te) -> length tr + length te `shouldBe` 100) folds
-
-    it "leaveOneOut(10): 10 folds、test set size 1 each" $ do
-      folds <- CV.leaveOneOut 10
-      length folds `shouldBe` 10
-      mapM_ (\(_, te) -> length te `shouldBe` 1) folds
-
-    it "stratifiedKFold(3): クラスバランスがほぼ保持される" $ do
-      gen <- MWC.createSystemRandom
-      let labels = replicate 30 0 ++ replicate 30 1 ++ replicate 30 2
-      folds <- CV.stratifiedKFold 3 labels gen
-      length folds `shouldBe` 3
-      -- 各 fold の test set には各クラスの ~10 が含まれる
-      mapM_ (\(_, te) -> length te `shouldSatisfy` (\n -> n >= 27 && n <= 33)) folds
-
-    it "shuffleSplit: 反復回数とテストサイズが正しい" $ do
-      gen <- MWC.createSystemRandom
-      folds <- CV.shuffleSplit 5 0.2 100 gen
-      length folds `shouldBe` 5
-      mapM_ (\(_, te) -> length te `shouldBe` 20) folds
-
-    it "timeSeriesSplit: forward-chaining で過去のみで学習" $ do
-      let folds = CV.timeSeriesSplit 50 10 100  -- initial=50, step=10, n=100
-      length folds `shouldBe` 5  -- (100-50)/10 = 5 folds
-      -- 全 fold で train indices < min(test indices)
-      mapM_ (\(tr, te) ->
-               (maximum tr < minimum te) `shouldBe` True) folds
-
-  -- ===========================================================================
-  -- Hanalyze.Model.Cluster (Phase 5)
-  -- ===========================================================================
-  describe "Hanalyze.Model.Cluster" $ do
-    it "kMeans: 2 つの離れたクラスタで正しく分類" $ do
-      gen <- MWC.createSystemRandom
-      -- Cluster 1: around (0, 0); cluster 2: around (10, 10)
-      let xs = LA.fromLists $
-            [[0.1*x, 0.1*y] | x <- [-3..3], y <- [-3..3]] ++
-            [[10 + 0.1*x, 10 + 0.1*y] | x <- [-3..3], y <- [-3..3]]
-          cfg = Cl.defaultKMeansConfig 2
-      r <- Cl.kMeans cfg xs gen
-      LA.rows (Cl.kmrCentroids r) `shouldBe` 2
-      -- 全 49 points がクラスタ 1、49 が クラスタ 2
-      let labels = Cl.kmrLabels r
-          (c0, c1) = (length (filter (== 0) labels), length (filter (== 1) labels))
-      (min c0 c1) `shouldBe` 49
-      (max c0 c1) `shouldBe` 49
-
-    it "silhouette: well-separated clusters で > 0.5" $ do
-      gen <- MWC.createSystemRandom
-      let xs = LA.fromLists $
-            [[0.1*x, 0.1*y] | x <- [-3..3], y <- [-3..3]] ++
-            [[20 + 0.1*x, 20 + 0.1*y] | x <- [-3..3], y <- [-3..3]]
-          cfg = Cl.defaultKMeansConfig 2
-      r <- Cl.kMeans cfg xs gen
-      let s = Cl.silhouette xs (Cl.kmrLabels r)
-      s `shouldSatisfy` (> 0.7)
-
-    it "kMeans: inertia は monotone non-increasing in iter" $ do
-      gen <- MWC.createSystemRandom
-      let xs = LA.fromLists [[fromIntegral i, fromIntegral j]
-                            | i <- [0..9::Int], j <- [0..9::Int]]
-          cfg = (Cl.defaultKMeansConfig 4) { Cl.kmRestarts = 5 }
-      r <- Cl.kMeans cfg xs gen
-      Cl.kmrInertia r `shouldSatisfy` (>= 0)
-      Cl.kmrConverged r `shouldBe` True
-
-  -- ===========================================================================
-  -- Hanalyze.Stat.MultipleTesting (Phase 6)
-  -- ===========================================================================
-  describe "Hanalyze.Stat.MultipleTesting" $ do
-    it "Bonferroni: p × m, capped at 1" $ do
-      let ps = [0.01, 0.04, 0.05, 0.10]
-          adj = MT.bonferroni ps
-      adj `shouldBe` [0.04, 0.16, 0.20, 0.40]
-
-    it "Holm: 単調 + Bonferroni より緩い" $ do
-      let ps = [0.01, 0.02, 0.03, 0.04]
-          adj = MT.holm ps
-      -- 各 adj ≥ 元 p、最初は p × m = 0.04
-      head adj `shouldBe` 0.04
-      and (zipWith (<=) ps adj) `shouldBe` True
-
-    it "BH: monotonic non-decreasing in sorted p order" $ do
-      let ps = [0.01, 0.04, 0.03, 0.05]
-          adj = MT.benjaminiHochberg ps
-      length adj `shouldBe` 4
-      all (<= 1.0) adj `shouldBe` True
-      all (>= 0.0) adj `shouldBe` True
-
-    it "BY: BH より conservative" $ do
-      let ps = [0.01, 0.02, 0.03, 0.04, 0.05]
-          bh = MT.benjaminiHochberg ps
-          by = MT.benjaminiYekutieli ps
-      -- BY は cumulative harmonic factor を掛けるので大きい
-      and (zipWith (>=) by bh) `shouldBe` True
-
-  -- ===========================================================================
-  -- Hanalyze.Stat.Bootstrap (Phase 7)
-  -- ===========================================================================
-  describe "Hanalyze.Stat.Bootstrap" $ do
-    it "bootstrapCI on N(0,1) sample: 0 が 95% CI 内" $ do
-      gen <- MWC.createSystemRandom
-      let xs = LA.fromList [-1.0, -0.5, 0.0, 0.5, 1.0,
-                            -0.3, 0.3, -0.7, 0.7, 0.0]
-      (lo, hi) <- Boot.bootstrapCI 2000 0.95 Boot.sampleMean xs gen
-      lo `shouldSatisfy` (< 0.5)
-      hi `shouldSatisfy` (> -0.5)
-
-    it "permutationTest: 異なる平均で p < 0.05" $ do
-      gen <- MWC.createSystemRandom
-      let xs = LA.fromList [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]
-          ys = LA.fromList [10.0, 11.0, 12.0, 13.0, 14.0, 15.0]
-      (_diff, p) <- Boot.permutationTest 2000 xs ys gen
-      p `shouldSatisfy` (< 0.05)
-
-    it "sampleMean / sampleVar / sampleMedian の整合性" $ do
-      let v = LA.fromList [1.0, 2.0, 3.0, 4.0, 5.0]
-      Boot.sampleMean v `shouldBe` 3.0
-      Boot.sampleVar v `shouldBe` 2.5  -- variance of 1..5 (unbiased)
-      Boot.sampleMedian v `shouldBe` 3.0
-
-  -- ===========================================================================
-  -- Hanalyze.DataIO.Reshape (Phase 8)
-  -- ===========================================================================
-  describe "Hanalyze.DataIO.Reshape" $ do
-    it "lagColumn(1): 先頭が NaN、残りは 1 つずれる" $ do
-      let df = DX.fromNamedColumns
-                 [("x", DX.fromList [1.0, 2.0, 3.0, 4.0, 5.0 :: Double])]
-          df' = Reshape.lagColumn 1 "x" "x_lag1" df
-      -- Lagged column should exist
-      "x_lag1" `elem` DX.columnNames df' `shouldBe` True
-
-    it "leadColumn(1): 末尾が NaN、残りは 1 つ前進" $ do
-      let df = DX.fromNamedColumns
-                 [("x", DX.fromList [1.0, 2.0, 3.0, 4.0, 5.0 :: Double])]
-          df' = Reshape.leadColumn 1 "x" "x_lead1" df
-      "x_lead1" `elem` DX.columnNames df' `shouldBe` True
-
-    it "rollingMean(3): 最初 2 つが NaN、3 番目以降は窓内平均" $ do
-      let df = DX.fromNamedColumns
-                 [("x", DX.fromList [1.0, 2.0, 3.0, 4.0, 5.0 :: Double])]
-          df' = Reshape.rollingMean 3 "x" "x_rmean3" df
-      "x_rmean3" `elem` DX.columnNames df' `shouldBe` True
-
-    it "oneHot: text 列を indicator 列に展開" $ do
-      let df = DX.fromNamedColumns
-                 [ ("id", DX.fromList [1, 2, 3, 4, 5 :: Int])
-                 , ("category", DX.fromList ["A", "B", "A", "C", "B" :: T.Text])
-                 ]
-          df' = Reshape.oneHot False "category" df
-      let cols = DX.columnNames df'
-      "category" `elem` cols `shouldBe` False  -- 元列削除
-      "category_A" `elem` cols `shouldBe` True
-      "category_B" `elem` cols `shouldBe` True
-      "category_C" `elem` cols `shouldBe` True
-
-    it "oneHot dropFirst=True: 1 列分減る" $ do
-      let df = DX.fromNamedColumns
-                 [("c", DX.fromList ["X", "Y", "Z" :: T.Text])]
-          df' = Reshape.oneHot True "c" df
-      length (DX.columnNames df') `shouldBe` 2  -- Y, Z (X drop)
-
-  -- ===========================================================================
-  -- Hanalyze.Stat.Effect (Phase 9)
-  -- ===========================================================================
-  describe "Hanalyze.Stat.Effect" $ do
-    it "cohenD: 同じ分布で 0、平均差 1 SD で ~1" $ do
-      let xs = LA.fromList [1, 2, 3, 4, 5] :: LA.Vector Double
-          ys = LA.fromList [1, 2, 3, 4, 5] :: LA.Vector Double
-      Eff.cohenD xs ys `shouldBe` 0.0
-
-      let zs = LA.fromList [2, 3, 4, 5, 6] :: LA.Vector Double  -- shifted
-          d2 = Eff.cohenD zs xs
-      d2 `shouldSatisfy` (> 0.5)
-
-    it "hedgesG ≤ |cohenD| (small-sample correction)" $ do
-      let xs = LA.fromList [1, 2, 3, 4, 5] :: LA.Vector Double
-          ys = LA.fromList [3, 4, 5, 6, 7] :: LA.Vector Double
-          d  = Eff.cohenD xs ys
-          g  = Eff.hedgesG xs ys
-      abs g `shouldSatisfy` (<= abs d + 1e-9)
-
-    it "eta2: 完全分離で大、同分布で 0" $ do
-      let g1 = LA.fromList [1, 2, 3] :: LA.Vector Double
-          g2 = LA.fromList [10, 11, 12] :: LA.Vector Double
-          g3 = LA.fromList [20, 21, 22] :: LA.Vector Double
-      Eff.eta2 [g1, g2, g3] `shouldSatisfy` (> 0.9)
-
-    it "powerTTest: d = 0 で α 付近 (= ~0.05)" $ do
-      let p = Eff.powerTTest 30 0.05 0.0
-      p `shouldSatisfy` (\x -> abs (x - 0.05) < 0.01)
-
-    it "powerTTest: 大きい d で高い power" $ do
-      let p = Eff.powerTTest 30 0.05 0.8
-      p `shouldSatisfy` (> 0.85)
-
-    it "sampleSizeTTest: 小 d ほど大 n が必要" $ do
-      let n1 = Eff.sampleSizeTTest 0.80 0.05 0.2  -- small effect
-          n2 = Eff.sampleSizeTTest 0.80 0.05 0.5  -- medium
-          n3 = Eff.sampleSizeTTest 0.80 0.05 0.8  -- large
-      n1 `shouldSatisfy` (> n2)
-      n2 `shouldSatisfy` (> n3)
-
-    it "cramerV: 2×2 完全独立で ~0、強従属で大" $ do
-      Eff.cramerV 0 100 2 2 `shouldBe` 0.0
-      Eff.cramerV 50 100 2 2 `shouldSatisfy` (> 0.5)
-
-  -- ===========================================================================
-  -- Hanalyze.Model.DecisionTree (Phase 10)
-  -- ===========================================================================
-  describe "Hanalyze.Model.DecisionTree" $ do
-    it "fitDT: 線形分離可能なデータで perfect train accuracy" $ do
-      -- y = 0 if x[0] < 5, else 1
-      let xs = [[fromIntegral x] | x <- [1..10::Int]]
-          ys = [if x < 5 then 0 else 1 | x <- [1..10::Int]]
-          tree = DT.fitDT DT.defaultDTConfig xs ys
-          preds = map (DT.predictDT tree) xs
-      preds `shouldBe` ys
-
-    it "fitDT: 2D XOR-like パターン" $ do
-      let xs = [[0, 0], [0, 1], [1, 0], [1, 1]]
-          ys = [0, 1, 1, 0]  -- XOR
-          tree = DT.fitDT DT.defaultDTConfig xs ys
-          preds = map (DT.predictDT tree) xs
-      preds `shouldBe` ys
-
-    it "predictDTProbs: leaf で確率 1.0、混合 leaf で fractional" $ do
-      let xs = [[1.0], [2.0], [3.0]]
-          ys = [0, 0, 1]
-          tree = DT.fitDT DT.defaultDTConfig xs ys
-          probs = DT.predictDTProbs tree [1.5]
-      -- x=1.5 should reach a leaf where most samples are class 0
-      probs `shouldSatisfy` (\m -> length m >= 1)
-
-    it "giniImpurity: 純粋クラスで 0、均等で 0.5 (2 クラス)" $ do
-      DT.giniImpurity [0, 0, 0, 0] `shouldBe` 0.0
-      DT.giniImpurity [1, 1, 1, 1] `shouldBe` 0.0
-      DT.giniImpurity [0, 0, 1, 1] `shouldBe` 0.5
-
-    it "maxDepth=1: shallow tree、underfit に近い" $ do
-      let cfg = DT.defaultDTConfig { DT.dtMaxDepth = Just 1 }
-          xs = [[fromIntegral x, fromIntegral y]
-               | x <- [1..5::Int], y <- [1..5::Int]]
-          ys = [if x + y > 5 then 1 else 0
-               | x <- [1..5::Int], y <- [1..5::Int]]
-          tree = DT.fitDT cfg xs ys
-      -- maxDepth=1 → 1 split, root = decision node
-      case tree of
-        DT.DNode {} -> True `shouldBe` True
-        _           -> expectationFailure "Expected DNode at root"
-
-  -- ===========================================================================
-  -- Hanalyze.Model.TimeSeries (Phase 11)
-  -- ===========================================================================
-  describe "Hanalyze.Model.TimeSeries" $ do
-    it "autocorrelation: lag 0 = 1.0" $ do
-      let y = LA.fromList [1.0, 2.0, 1.5, 2.5, 1.8]
-          acf = TS.autocorrelation 5 y
-      LA.atIndex acf 0 `shouldBe` 1.0
-
-    it "autocorrelation: 周期 4 の sin 系列で lag 4 が高い" $ do
-      let y = LA.fromList [sin (2*pi*fromIntegral t / 4) | t <- [0..40::Int]]
-          acf = TS.autocorrelation 8 y
-      LA.atIndex acf 4 `shouldSatisfy` (> 0.5)
-
-    it "fitAR(1) on quasi-AR(1) data: φ̂ in (0, 1)" $ do
-      -- Quasi-AR(1) with φ=0.7 + small deterministic perturbation
-      let phi = 0.7
-          go _    0   = []
-          go prev n   =
-            let yi = phi * prev + 0.01 * sin (fromIntegral n :: Double)
-            in yi : go yi (n - 1)
-          ys  = take 100 (drop 50 (go 1.0 200))  -- burn-in 50
-          y = LA.fromList ys
-          fit = TS.fitAR 1 y
-      let phiHat = LA.atIndex (TS.arPhi fit) 0
-      -- AR(1) coefficient should be in (0, 1) for stationary positive AR
-      phiHat `shouldSatisfy` (\p -> p > 0 && p < 1)
-
-    it "forecastAR: h-step forecast 同 size" $ do
-      let y = LA.fromList [1.0, 1.5, 2.0, 2.5, 3.0, 2.5, 2.0, 1.5, 1.0, 1.5,
-                          2.0, 2.5, 3.0, 2.5, 2.0]
-          fit = TS.fitAR 2 y
-          fc = TS.forecastAR fit y 5
-      LA.size fc `shouldBe` 5
-
-    it "differencing: y' length = n - 1" $ do
-      let y = LA.fromList [1.0, 3.0, 2.0, 5.0, 4.0]
-          d1 = TS.differencing y
-      LA.size d1 `shouldBe` 4
-      LA.toList d1 `shouldBe` [2.0, -1.0, 3.0, -1.0]
-
-    it "simpleExpSmoothing: α=1 で原系列、α=0 で初期値固定" $ do
-      let y = LA.fromList [1.0, 2.0, 3.0, 4.0, 5.0]
-          s1 = TS.simpleExpSmoothing 1.0 y    -- α=1
-          s0 = TS.simpleExpSmoothing 0.0 y    -- α=0
-      LA.toList s1 `shouldBe` LA.toList y    -- exact
-      all (== 1.0) (LA.toList s0) `shouldBe` True
-
-    it "holtWinters: 線形 trend + 周期 4 を再現" $ do
-      let -- y_t = t + 5 sin(2πt/4)
-          ys = [ fromIntegral t + 3 * sin (2 * pi * fromIntegral t / 4)
-               | t <- [0 .. 39 :: Int] ]
-          y  = LA.fromList ys
-          fit = TS.holtWinters TS.HWAdditive 4 y
-          fc  = TS.hwForecast fit 4
-      LA.size fc `shouldBe` 4
-      -- Forecast at t=40,41,42,43 should be roughly 40 + sin pattern
-      LA.atIndex fc 0 `shouldSatisfy` (> 35)
-
-    it "stlDecompose: 周期成分が period 倍で繰り返す" $ do
-      let ys = [ fromIntegral t / 10 + 2 * sin (2 * pi * fromIntegral t / 4)
-               | t <- [0 .. 39 :: Int] ]
-          y  = LA.fromList ys
-          (_trend, seasonal, _resid) = TS.stlDecompose 4 y
-      LA.size seasonal `shouldBe` LA.size y
-
-  -- ===========================================================================
-  -- Hanalyze.Model.Survival (Phase 12)
-  -- ===========================================================================
-  describe "Hanalyze.Model.Survival" $ do
-    it "kaplanMeier: 全 event observed で S(t) は単調減少" $ do
-      let samples = [ Surv.SurvSample t Surv.Observed | t <- [1, 2, 3, 4, 5] ]
-          km = Surv.kaplanMeier samples
-      length (Surv.kmrTimes km) `shouldBe` 5
-      let ss = Surv.kmrSurvival km
-      and (zipWith (>=) ss (tail ss)) `shouldBe` True
-
-    it "kaplanMeier: censored data でも非負の生存確率" $ do
-      let samples = [ Surv.SurvSample 1 Surv.Observed
-                    , Surv.SurvSample 2 Surv.Censored
-                    , Surv.SurvSample 3 Surv.Observed
-                    , Surv.SurvSample 4 Surv.Observed
-                    , Surv.SurvSample 5 Surv.Censored
-                    ]
-          km = Surv.kaplanMeier samples
-      all (>= 0) (Surv.kmrSurvival km) `shouldBe` True
-      all (<= 1) (Surv.kmrSurvival km) `shouldBe` True
-
-    it "nelsonAalen: 累積ハザードは monotone non-decreasing" $ do
-      let samples = [ Surv.SurvSample t Surv.Observed | t <- [1, 2, 3, 4, 5] ]
-          na = Surv.nelsonAalen samples
-          h = Surv.narCumHazard na
-      and (zipWith (<=) h (tail h)) `shouldBe` True
-
-    it "logRankTest: 同一分布で p > 0.05" $ do
-      let g1 = [ Surv.SurvSample t Surv.Observed | t <- [1, 2, 3, 4, 5] ]
-          g2 = [ Surv.SurvSample t Surv.Observed | t <- [1, 2, 3, 4, 5] ]
-          lr = Surv.logRankTest [g1, g2]
-      Surv.lrPValue lr `shouldSatisfy` (> 0.05)
-
-    it "logRankTest: 異なる分布で p < 0.05" $ do
-      let g1 = [ Surv.SurvSample t Surv.Observed | t <- [1, 2, 3, 4, 5] ]
-          g2 = [ Surv.SurvSample t Surv.Observed | t <- [10, 11, 12, 13, 14] ]
-          lr = Surv.logRankTest [g1, g2]
-      Surv.lrPValue lr `shouldSatisfy` (< 0.05)
-
-    it "coxPH: 共変量と event 時間に強い相関で β > 0" $ do
-      -- x が大きい個体が早く event を起こす想定の合成データ
-      let n = 30
-          xs = [ LA.fromList [fromIntegral i / fromIntegral n :: Double]
-               | i <- [1 .. n] ]
-          times = [ 30 - i | i <- [1 .. n] ]   -- x 大きいほど early
-          samples = [ Surv.SurvSample (fromIntegral t) Surv.Observed
-                    | t <- times ]
-          fit = Surv.coxPH xs samples
-      LA.atIndex (Surv.coxBeta fit) 0 `shouldSatisfy` (> 0)
-
-  -- ===========================================================================
-  -- Hanalyze.Stat.Interpret (Phase 13)
-  -- ===========================================================================
-  describe "Hanalyze.Stat.Interpret" $ do
-    it "permutationImportance: 重要 feature が高 importance" $ do
-      gen <- MWC.createSystemRandom
-      -- y = x_0 のみに依存、x_1 は無関係
-      let xs = [[fromIntegral i, fromIntegral (i * i)]
-               | i <- [1 .. 20 :: Int]]
-          ys = [head row | row <- xs]
-          predict zs = [head row | row <- zs]
-          score yt yp =
-            let mse = sum [(yt !! i - yp !! i) ^ (2 :: Int)
-                          | i <- [0 .. length yt - 1]]
-            in negate mse  -- higher (= 0) is better
-          cfg = (Interp.defaultPermutationConfig)
-                  { Interp.pcNRepeats = 5 }
-      r <- Interp.permutationImportance cfg predict score xs ys gen
-      let imps = Interp.piMeanImportance r
-      -- 0番目 feature の importance が高いはず (重要)
-      head imps `shouldSatisfy` (> 0.5 * (imps !! 1))
-
-    it "partialDependence: y = x[0] で PDP が grid に追従" $ do
-      let xs = [[fromIntegral i, 0.0] | i <- [1..10::Int]]
-          predict zs = [head row | row <- zs]
-          grid = [1.0, 5.0, 10.0]
-          pdp = Interp.partialDependence predict xs 0 grid
-      -- 各 grid 点での PD は値そのもの (= grid 値)
-      Interp.pdpMeanPredict pdp `shouldBe` grid
-
-    it "icePlot: 全 sample について curve を返す" $ do
-      let xs = [[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]]
-          predict zs = [sum row | row <- zs]
-          grid = [0.0, 1.0, 2.0]
-          ice = Interp.icePlot predict xs 0 grid
-      length (Interp.iceCurves ice) `shouldBe` 3
-      length (Interp.iceFeatureValues ice) `shouldBe` 3
-      -- iceMean should equal partial dependence
-      length (Interp.iceMean ice) `shouldBe` 3
-
-  -- ─────────────────────────────────────────────────────────────────────
-  describe "Hanalyze.Model.LM.Diagnostics (vs statsmodels OLS)" $ do
-    let xRaw = [1.0, 2, 3, 4, 5, 6, 7, 8, 9, 10]
-        yRaw = [3.7, 5.2, 6.8, 8.5, 9.9, 11.3, 13.1, 14.4, 15.8, 17.2]
-        x    = LA.fromColumns
-                 [ LA.konst 1.0 (length xRaw)
-                 , LA.fromList xRaw
-                 ]
-        y    = LA.fromList yRaw
-        fit  = LM.fitLMVec x y
-
-        approx tol a b = abs (a - b) < tol
-        approxV tol expected actual =
-          length expected == LA.size actual &&
-          and (zipWith (approx tol) expected (LA.toList actual))
-
-    it "ciTValue: 95% / df=8 ≈ 2.306" $
-      LMD.ciTValue 0.95 8 `shouldSatisfy` approx 1e-3 2.306
-
-    it "lmStdErrors matches statsmodels (intercept, slope)" $
-      LMD.lmStdErrors x fit `shouldSatisfy`
-        approxV 1e-5 [0.09602188, 0.01547533]
-
-    it "lmCoefStats t / p match statsmodels" $ do
-      let cs = LMD.lmCoefStats x fit
-      length cs `shouldBe` 2
-      LMD.csTValue (head cs)    `shouldSatisfy` approx 1e-3 23.8834
-      LMD.csTValue (cs !! 1)    `shouldSatisfy` approx 1e-3 97.4768
-      LMD.csPValue (head cs)    `shouldSatisfy` approx 1e-9 1.0062e-8
-      LMD.csPValue (cs !! 1)    `shouldSatisfy` approx 1e-13 1.3699e-13
-
-    it "lmFStatistic matches statsmodels (F, p, df1, df2)" $ do
-      let fs = head (LMD.lmFStatistic x fit)
-      LMD.fsValue fs  `shouldSatisfy` approx 1e-1 9501.7193
-      LMD.fsPValue fs `shouldSatisfy` approx 1e-13 1.3699e-13
-      LMD.fsDf1 fs `shouldBe` 1
-      LMD.fsDf2 fs `shouldBe` 8
-
-    it "lmInformationCriteria (R lm() convention, k = p + 1 with σ)" $ do
-      let ic = LMD.lmInformationCriteria fit
-      LMD.icLogLik ic `shouldSatisfy` approx 1e-5 6.547424
-      LMD.icAIC    ic `shouldSatisfy` approx 1e-5 (-7.094848)
-      LMD.icBIC    ic `shouldSatisfy` approx 1e-5 (-6.187093)
-
-    it "hatDiagonal matches statsmodels leverage" $
-      LMD.hatDiagonal x `shouldSatisfy`
-        approxV 1e-6
-          [ 0.34545455, 0.24848485, 0.17575758, 0.12727273, 0.10303030
-          , 0.10303030, 0.12727273, 0.17575758, 0.24848485, 0.34545455 ]
-
-    it "standardizedResiduals match statsmodels (resid_studentized_internal)" $
-      LMD.standardizedResiduals x fit `shouldSatisfy`
-        approxV 1e-5
-          [ -0.89534127, -0.90521501, -0.14722564,  1.31539084,  0.48257654
-          , -0.33234045,  1.88308584,  0.30394970, -0.57197652, -1.56684722 ]
-
-    it "cooksDistance matches statsmodels" $
-      LMD.cooksDistance x fit `shouldSatisfy`
-        approxV 1e-5
-          [ 0.21154283, 0.13546767, 0.00231098, 0.12616429, 0.01337487
-          , 0.00634342, 0.25856339, 0.00984992, 0.05408646, 0.64784992 ]
-
-    it "predictorStdDevs: intercept col=0, x col≈3.0277" $ do
-      let sds = LMD.predictorStdDevs x
-      LA.size sds `shouldBe` 2
-      (LA.toList sds !! 0) `shouldSatisfy` approx 1e-12 0
-      (LA.toList sds !! 1) `shouldSatisfy` approx 1e-6 3.027650
-
-  -- ─────────────────────────────────────────────────────────────────────
-  describe "Hanalyze.Design.Orthogonal.listArraysWithSize" $ do
-    it "returns one entry per standard array" $
-      length OA.listArraysWithSize `shouldBe` length OA.standardArrays
-
-    it "L9 entry exposes runs / factors / levels" $ do
-      let l9meta = head [ m | m <- OA.listArraysWithSize
-                            , OA.omName m == OA.oaName OA.l9 ]
-      OA.omRuns l9meta    `shouldBe` 9
-      OA.omFactors l9meta `shouldBe` 4
-      OA.omLevels l9meta  `shouldBe` [3, 3, 3, 3]
-
-  -- ─────────────────────────────────────────────────────────────────────
-  describe "Hanalyze.Design.Taguchi extras" $ do
-    it "snRatioWithDetails: SmallerBetter on [1, 2, 3]" $ do
-      let d = TG.snRatioWithDetails TG.SmallerBetter [1.0, 2.0, 3.0]
-      TG.sdN d `shouldBe` 3
-      TG.sdMean d     `shouldSatisfy` (\v -> abs (v - 2.0) < 1e-12)
-      TG.sdVariance d `shouldSatisfy` (\v -> abs (v - 1.0) < 1e-12)
-      -- η = -10 log10((1+4+9)/3) = -10 log10(14/3) ≈ -6.690
-      TG.sdSN d `shouldSatisfy` (\v -> abs (v - (-6.690)) < 1e-2)
-
-    it "factorEffectsTable: contributions sum to 1" $ do
-      let specs = [ OA.FactorSpec "A" [OA.LText "lo", OA.LText "hi"]
-                  , OA.FactorSpec "B" [OA.LNumeric 0,  OA.LNumeric 1]
-                  ]
-      case OA.assignFactors OA.l4 specs of
-        Right ad -> do
-          let sns = [10.0, 12.0, 11.0, 13.0]
-              ext = TG.factorEffectsTable ad sns
-          length ext `shouldBe` 2
-          let totalC = sum (map TG.feeContribution ext)
-          totalC `shouldSatisfy` (\v -> abs (v - 1.0) < 1e-12)
-          all ((>= 0) . TG.feeRange) ext `shouldBe` True
-        Left e -> expectationFailure (show e)
-
-  -- ─────────────────────────────────────────────────────────────────────
-  describe "Hanalyze.Design.Quality.processCapability" $ do
-    it "centred process with σ=1, USL=6, LSL=−6 → Cp ≈ 2.0, Cpk ≈ 2.0" $ do
-      -- 11-point symmetric sample around 0 with σ=1 (population)
-      let xs = LA.fromList [-1.5, -1.0, -0.5, 0.5, 1.0, 1.5,
-                             1.5,  1.0,  0.5, -0.5, -1.0, -1.5]
-          cap = Quality.processCapability (-6) 6 xs
-      Quality.capCp  cap `shouldSatisfy` (> 0)
-      -- For a centred sample, Cp == Cpk by symmetry.
-      abs (Quality.capCp cap - Quality.capCpk cap)
-        `shouldSatisfy` (< 1e-2)
-
-    it "shifted process: Cpk < Cp" $ do
-      let xs = LA.fromList [4.0, 4.5, 4.2, 4.8, 4.3, 4.6, 4.4, 4.7]
-          cap = Quality.processCapability 0 6 xs
-      Quality.capCp cap  `shouldSatisfy` (> Quality.capCpk cap)
-
-    it "processCapabilityUpper: only USL → Cp == Cpk" $ do
-      let xs = LA.fromList [1.0, 1.2, 0.9, 1.1, 1.05, 0.95]
-          cap = Quality.processCapabilityUpper 2.0 xs
-      Quality.capCp cap `shouldBe` Quality.capCpk cap
-
-  describe "Hanalyze.Model.GLM diagnostics (request/090-AB)" $ do
-    let xsG = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] :: [Double]
-        ysG = [1.1, 2.9, 5.2, 6.8, 9.1, 11.0, 13.2, 14.8, 17.0, 19.1] :: [Double]
-        xMatG = LA.matrix 2 (concatMap (\x -> [1, x]) xsG)
-        yVecG = LA.fromList ysG
-        (frG, sigmaG) = GLM.fitGLMFull GLM.Gaussian GLM.Identity xMatG yVecG
-        betaG = head (LA.toColumns (Core.coefficients frG))
-        muVG  = head (LA.toColumns (Core.fitted frG))
-
-    it "glmPearsonResiduals (Gaussian, V=1) == raw residuals" $ do
-      let pr = GLM.glmPearsonResiduals GLM.Gaussian yVecG muVG
-          rr = yVecG - muVG
-      LA.norm_2 (pr - rr) `shouldSatisfy` (< 1e-9)
-
-    it "glmDevianceResiduals (Gaussian) == sign(y-μ)·|y-μ|" $ do
-      let dr  = GLM.glmDevianceResiduals GLM.Gaussian yVecG muVG
-          ref = LA.fromList
-                  [ signum (y - m) * abs (y - m)
-                  | (y, m) <- zip ysG (LA.toList muVG) ]
-      LA.norm_2 (dr - ref) `shouldSatisfy` (< 1e-9)
-
-    it "glmVariance: Binomial μ(1-μ); Poisson μ" $ do
-      GLM.glmVariance GLM.Binomial 0.3 `shouldBe` 0.3 * 0.7
-      GLM.glmVariance GLM.Poisson  4.0 `shouldBe` 4.0
-
-    it "predictGlmEtaWithSE: η = xᵀβ, SE > 0" $ do
-      let xNew = LA.fromList [1, 5.0]
-          (eta, se) = GLM.predictGlmEtaWithSE betaG sigmaG xNew
-      eta `shouldSatisfy` (\e -> abs (e - (1 + 2 * 5.0)) < 0.5)
-      se  `shouldSatisfy` (> 0)
-      se  `shouldSatisfy` (< 1)
-
-    it "predictGlmMuWithCI (Identity): half-width ≈ 1.96·SE" $ do
-      let xNew    = LA.fromList [1, 4.5]
-          ci      = GLM.predictGlmMuWithCI GLM.Identity 0.95 betaG sigmaG xNew
-          (_, se) = GLM.predictGlmEtaWithSE betaG sigmaG xNew
-          halfW   = (GLM.gpHi ci - GLM.gpLo ci) / 2
-      abs (halfW - 1.96 * se) `shouldSatisfy` (< 1e-2)
-
-    it "predictGlmMuWithCI (Logit): CI stays in (0,1)" $ do
-      let xs2  = LA.matrix 2 (concatMap (\i -> [1, fromIntegral (i :: Int)]) [0..9])
-          ys2  = LA.fromList [0,1,0,1,0,1,0,1,0,1]
-          (_, sigma2) = GLM.fitGLMFull GLM.Binomial GLM.Logit xs2 ys2
-          beta2 = LA.fromList [0.0, 0.05]
-          xNew  = LA.fromList [1, 5.0]
-          ci    = GLM.predictGlmMuWithCI GLM.Logit 0.95 beta2 sigma2 xNew
-      GLM.gpMu ci `shouldSatisfy` (\v -> v > 0 && v < 1)
-      GLM.gpLo ci `shouldSatisfy` (\v -> v >= 0)
-      GLM.gpHi ci `shouldSatisfy` (\v -> v <= 1)
-      GLM.gpLo ci `shouldSatisfy` (< GLM.gpHi ci)
-
-  describe "Hanalyze.Model.GLMM SE (request/100)" $ do
-    -- Same fixture as the GLMM tests above (3 groups × 4 obs).
-    -- design X = [1, x] over the same 12 rows.
-    let xMat12 = LA.matrix 2
-                   ( concatMap (\v -> [1, v])
-                       [1,2,3,4,1,2,3,4,1,2,3,4 :: Double] )
-        yVec12 = LA.fromList
-                   [7.1,6.9,7.0,7.0, 5.0,4.9,5.1,5.0, 3.0,2.9,3.1,3.0]
-        gVec12 = V.fromList
-                   ["A","A","A","A","B","B","B","B","C","C","C","C" :: T.Text]
-        -- Inline group construction (mirrors Hanalyze.Model.GLMM.buildGroups
-        -- which is currently internal).
-        gLabels12 = V.fromList ["A", "B", "C"] :: V.Vector T.Text
-        gIdx12    = V.map
-                      (\g -> case V.elemIndex g gLabels12 of
-                               Just i  -> i
-                               Nothing -> 0)
-                      gVec12
-        gSizes12  = V.fromList [4, 4, 4]
-        glmmRes   = fitLME xMat12 yVec12 gIdx12 gLabels12 gSizes12
-        idx12     = gIdx12
-
-    it "glmmFixedSE: returns one SE per coefficient (length p)" $ do
-      let ses = glmmFixedSE xMat12 idx12 glmmRes
-      LA.size ses `shouldBe` LA.cols xMat12
-
-    it "glmmFixedSE: all SEs are positive" $ do
-      let ses = glmmFixedSE xMat12 idx12 glmmRes
-      mapM_ (\v -> v `shouldSatisfy` (> 0)) (LA.toList ses)
-
-    it "glmmFixedSE: σ_u → 0 reduces to OLS SE within tolerance" $ do
-      -- Force σ²_u to 0 (no random effects → OLS).
-      let resOLS = glmmRes { glmmRandVar = 0 }
-          sesG   = glmmFixedSE xMat12 idx12 resOLS
-          -- Reference OLS SE from σ² (XᵀX)⁻¹.
-          xtx   = LA.tr xMat12 LA.<> xMat12
-          covOLS = LA.scale (glmmResidVar resOLS) (LA.inv xtx)
-          sesOLS = LA.fromList
-                     [ sqrt (LA.atIndex covOLS (i, i))
-                     | i <- [0 .. LA.cols xMat12 - 1] ]
-      LA.norm_Inf (sesG - sesOLS) `shouldSatisfy` (< 1e-9)
-
-    it "glmmBLUPSE: one entry per group, all positive" $ do
-      let ses = glmmBLUPSE idx12 glmmRes
-      V.length ses `shouldBe` V.length (glmmGroups glmmRes)
-      mapM_ (\v -> v `shouldSatisfy` (> 0)) (V.toList ses)
-
-    it "glmmBLUPSE: shrinkage formula (1/σ²_u + n_j/σ²)⁻¹^½" $ do
-      let ses    = glmmBLUPSE idx12 glmmRes
-          sig2u  = glmmRandVar  glmmRes
-          sig2   = glmmResidVar glmmRes
-          -- Group sizes: A=4, B=4, C=4 (balanced design)
-          expected = sqrt (1.0 / (1.0 / sig2u + 4.0 / sig2))
-      mapM_ (\(_, v) -> abs (v - expected) `shouldSatisfy` (< 1e-9))
-            (zip [0 :: Int ..] (V.toList ses))
+{-# OPTIONS_GHC -F -pgmF hspec-discover #-}
diff --git a/test/SpecHelper.hs b/test/SpecHelper.hs
new file mode 100644
--- /dev/null
+++ b/test/SpecHelper.hs
@@ -0,0 +1,203 @@
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+-- 全 Spec ファイル共通の helper / orphan Arbitrary instance。
+module SpecHelper where
+
+import qualified Data.Vector as V
+import qualified Data.Text   as T
+import qualified Numeric.AD.Mode.Reverse.Double as RevD
+import qualified Numeric.LinearAlgebra as LA
+-- (DataFrame の直接 import は未使用のため削除 = upstream decomp PR#2 移植の副産物調査で判明)
+import qualified Hanalyze.Design.Orthogonal as OA
+import qualified Hanalyze.Design.Quality    as Quality
+import qualified Hanalyze.Design.Taguchi as TG
+import qualified Hanalyze.Model.LM             as LM
+import qualified Hanalyze.Model.LM.Diagnostics as LMD
+import qualified Hanalyze.DataIO.Preprocess as Pp
+import qualified Hanalyze.DataIO.Log        as Log
+import qualified Hanalyze.DataIO.CSV        as CSV
+import qualified Hanalyze.DataIO.Convert    as Conv
+import qualified Hanalyze.DataIO.Health     as Health
+import qualified Hanalyze.DataIO.Clean      as Clean
+import qualified Hanalyze.DataIO.Convert    as Conv2
+import qualified Hanalyze.Stat.Standardize  as Std
+import qualified Hanalyze.Stat.NumberFormat as NF
+import qualified Hanalyze.Stat.Interpolate  as Interp
+import qualified Hanalyze.Stat.AdaptiveGrid as AG
+import qualified Hanalyze.Stat.KernelDist   as KD
+import qualified Hanalyze.Stat.Cholesky     as Chol
+import qualified Hanalyze.Stat.QuasiRandom  as QR
+import qualified Hanalyze.Stat.Test         as ST
+import qualified Hanalyze.Model.HierarchicalCluster as HC
+import qualified Hanalyze.Model.AFT         as AFT
+import qualified Hanalyze.Model.RandomForestClassifier as RFC
+import qualified Hanalyze.Model.RandomForest           as RF
+import qualified Hanalyze.Model.FitYByX     as FXY
+import qualified Hanalyze.Model.Weibull     as WB
+import qualified Data.Vector.Unboxed        as VU
+import qualified Hanalyze.Stat.ClassMetrics as CM
+import qualified Hanalyze.Stat.CV           as CV
+import qualified Hanalyze.Stat.MultipleTesting as MT
+import qualified Hanalyze.Stat.Bootstrap       as Boot
+import qualified Hanalyze.Stat.Effect          as Eff
+import qualified Hanalyze.Stat.Interpret       as Interp
+import qualified Hanalyze.Stat.SPC             as SPC
+import qualified Hanalyze.Model.Weibull        as Wei
+import qualified Hanalyze.Model.Reliability    as Rel
+import qualified Hanalyze.Optim.NSGA           as NSGAP3
+import qualified Hanalyze.Stat.GroupComparison as GC
+import qualified Hanalyze.Design.Optimal       as OPT
+import qualified Hanalyze.Design.Diagnostics   as DDiag
+import qualified Hanalyze.Design.Constraint    as DCons
+import qualified Hanalyze.Design.Custom.Factor     as CF
+import qualified Hanalyze.Design.Custom.Model      as CM
+import qualified Hanalyze.Design.Custom.Constraint as CC
+import qualified Hanalyze.Design.Custom.RegionMoment as RM
+import qualified Hanalyze.Design.Custom.Coordinate as CX
+import qualified Hanalyze.Design.Custom.Compare    as CCMP
+import qualified Hanalyze.Design.Custom.Power      as CPW
+import qualified Hanalyze.Design.Custom.Augment    as CAUG
+import qualified Hanalyze.Design.Custom.SplitPlot  as CSP
+import qualified Hanalyze.Design.Custom.Bayesian   as CB
+import qualified Data.Vector.Storable              as VS
+import qualified Data.Map.Strict as M
+import qualified Data.Set        as Set
+import qualified Hanalyze.Model.StateSpace     as SS
+import qualified Hanalyze.Model.NeuralNetwork  as NN
+import qualified Hanalyze.Model.GradientBoosting as GB
+import qualified Hanalyze.Stat.MDS             as MDS
+import qualified Hanalyze.Model.KNN            as KNN
+import qualified Hanalyze.Model.NaiveBayes     as NB
+import qualified Hanalyze.Model.GARCH          as GARCH
+import qualified Hanalyze.Model.VAR            as VAR
+import qualified Hanalyze.Model.CompetingRisks as CR
+import qualified Hanalyze.Model.ReliabilityBlockDiagram as RBD
+import qualified System.Random.MWC.Distributions as MWCD
+import qualified Hanalyze.Design.SpaceFilling  as SF
+import qualified Hanalyze.Design.DSD           as DSD
+import qualified Hanalyze.Design.Mixture       as Mix
+import qualified Hanalyze.Design.Sequential    as Seq
+import qualified Hanalyze.Design.RSM           as RSMd
+import qualified Hanalyze.Model.PCA         as PCA
+import qualified Hanalyze.Model.Cluster     as Cl
+import qualified Hanalyze.Model.DecisionTree as DT
+import qualified Hanalyze.Model.TimeSeries   as TS
+import qualified Hanalyze.Model.Survival     as Surv
+import qualified Hanalyze.DataIO.Reshape    as Reshape
+import qualified Hanalyze.Optim.NSGA        as NSGA
+import qualified System.Random.MWC as MWC
+import qualified Hanalyze.Model.KernelRegression      as Kn
+import qualified Hanalyze.Model.GP          as GP
+import qualified Hanalyze.Model.GPRobust    as GPR
+import qualified Hanalyze.Viz.ReportBuilder as RB
+import qualified Hanalyze.Viz.ModelGraph    as VMG
+import qualified Hanalyze.Viz.ModelGraphDot as VMGD
+import qualified Data.ByteString   as BS
+import qualified Hanalyze.Model.GP        as GP
+import qualified Hanalyze.Model.GPRobust  as GPR
+import qualified Hanalyze.Model.RFF       as RFF
+import qualified Hanalyze.Model.Regularized as Reg
+import qualified Hanalyze.Model.Spline      as Sp
+import qualified Hanalyze.Model.KernelRegression      as K
+import qualified Hanalyze.Model.Core        as Core
+import qualified Hanalyze.Model.GLM         as GLM
+import qualified Hanalyze.Optim.NelderMead  as NM
+import qualified Hanalyze.Optim.LBFGS       as LBFGS
+import qualified Hanalyze.Optim.LineSearch  as LS
+import qualified Hanalyze.Optim.DifferentialEvolution as DE
+import qualified Hanalyze.Optim.CMAES       as CMAES
+import qualified Hanalyze.Optim.CMAESFull   as CMAESF
+import qualified Hanalyze.Optim.SimulatedAnnealing as SA
+import qualified Hanalyze.Optim.ParticleSwarm as PSO
+import qualified Hanalyze.Optim.Constrained as Con
+import qualified Hanalyze.Optim.BayesOpt    as BO
+import qualified Hanalyze.Optim.Common      as OC
+import qualified System.Random.MWC as MWC
+import qualified Hanalyze.MCMC.NUTS as NUTS
+import qualified Hanalyze.MCMC.SMC  as SMC
+import qualified Hanalyze.MCMC.MH    as MH
+import qualified Hanalyze.MCMC.Slice as Slice
+import qualified Hanalyze.MCMC.HMC   as HMC
+import qualified Hanalyze.MCMC.Gibbs as Gibbs
+import qualified Hanalyze.Stat.BridgeSampling as BS
+import qualified Hanalyze.Stat.BayesFactor    as BF
+import qualified Hanalyze.Stat.BayesianModelAveraging as BMA
+import qualified Hanalyze.Stat.Causal.PropensityScore as PS
+import qualified Hanalyze.Stat.Causal.IPW             as CIPW
+import qualified Hanalyze.Stat.Causal.DoublyRobust    as CDR
+import qualified Hanalyze.Stat.Causal.CATE            as CCATE
+import qualified Hanalyze.Model.RegularizedAdvanced   as RegA
+import qualified Hanalyze.Model.Robust                as Rob
+import qualified Hanalyze.Stat.CorrelationNetwork     as CN
+import qualified Hanalyze.Model.LatentClassAnalysis   as LCA
+import qualified Hanalyze.Model.FDA                   as FDA
+import qualified Hanalyze.Model.LiNGAM.Direct         as LNG
+import qualified Hanalyze.Model.LiNGAM.Pairwise       as LNGP
+import qualified Hanalyze.Model.LiNGAM.MultiGroup     as LNGM
+import qualified Hanalyze.Model.LiNGAM.VAR            as LNGV
+import qualified Hanalyze.Model.LiNGAM.Parce          as LNGPa
+import qualified Hanalyze.Model.LiNGAM.Bootstrap      as LNGB
+import qualified Hanalyze.Model.LiNGAM.ICA            as LNGI
+import qualified Hanalyze.Math.Hungarian              as Hung
+import qualified Hanalyze.Math.HSIC                   as HSIC
+import qualified Hanalyze.Model.DAG                   as DAG
+import qualified Hanalyze.MCMC.Core as Core
+import qualified Hanalyze.MCMC.BayesianTest as BAB
+import qualified Hanalyze.Model.PLS         as PLS
+import qualified Hanalyze.Model.Discriminant as LDA
+import qualified Hanalyze.Design.GaugeRR    as GRR
+import qualified Hanalyze.Model.HBM as HBM
+import qualified Hanalyze.Model.HBM.Interp as HI
+import qualified Hanalyze.Stat.VI as VI
+import qualified Data.Map.Strict    as M
+import Test.Hspec
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck
+import Hanalyze.Model.Formula
+import Hanalyze.Model.Formula.Frame
+import Hanalyze.Model.Formula.Design
+import Hanalyze.Model.Formula.RFormula
+import Hanalyze.Model.Formula.Nonlinear
+import Hanalyze.Model.Formula.Mixed
+import Hanalyze.Model.GLMM
+import Hanalyze.Model.GLM (Family (..), LinkFn (..))
+import Hanalyze.Stat.Distribution (Transform)
+import Data.List (sort, nub)
+import Control.Monad (forM, forM_)
+import System.IO.Temp (withSystemTempFile)
+import System.IO     (hPutStr, hClose)
+import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
+import           Data.IORef         (newIORef, readIORef, modifyIORef')
+
+isLeftE :: Either a b -> Bool
+isLeftE = either (const True) (const False)
+
+isRightE :: Either a b -> Bool
+isRightE = either (const False) (const True)
+
+genName :: Gen T.Text
+genName = elements ["a", "b", "c", "x", "y", "z", "g", "t", "b0", "b1", "bg"]
+
+genTerm :: Int -> Gen Term
+genTerm n
+  | n <= 1 = oneof
+      [ Lit . fromIntegral <$> (choose (0, 9) :: Gen Int)
+      , Ref <$> genName
+      ]
+  | otherwise = oneof
+      [ Lit . fromIntegral <$> (choose (0, 9) :: Gen Int)
+      , Ref <$> genName
+      , App <$> genName <*> (choose (1, 2) >>= \k -> vectorOf k (genTerm (n `div` 2)))
+      , Index <$> genTerm (n `div` 2) <*> genTerm (n `div` 2)
+      , Neg <$> genTerm (n - 1)
+      , Bin <$> elements [Add, Sub, Mul, Div, Pow]
+            <*> genTerm (n `div` 2) <*> genTerm (n `div` 2)
+      ]
+
+instance Arbitrary Formula where
+  arbitrary = do
+    nd   <- choose (0, 2) :: Gen Int
+    vars <- vectorOf (nd + 1) genName
+    rhs  <- sized genTerm
+    pure (Formula (head vars) (tail vars) rhs)
