Skip to main content

gam_models/inference/
generative.rs

1use crate::inference::predict_io::PredictResult;
2use gam_custom_family::{CustomFamily, ParameterBlockState};
3use gam_problem::types::{
4    LikelihoodScaleMetadata, LikelihoodSpec, ResponseFamily, is_valid_tweedie_power,
5};
6use gam_solve::estimate::EstimationError;
7use ndarray::{Array1, Array2};
8
9/// THE single source of truth for the scalar dispersion the generative
10/// observation model uses for a fitted family — the value handed to
11/// [`NoiseModel::from_likelihood`] / [`generativespec_from_predict`] as
12/// `gaussian_scale`.
13///
14/// For every exponential-dispersion / overdispersed family the dispersion is
15/// **estimated jointly with the mean** and recorded in the fit's
16/// [`LikelihoodScaleMetadata`] (`scale`); the value embedded in the response
17/// spec (`likelihood.response`) is only the construction-time *seed* (e.g.
18/// `theta = 1.0`, `phi = 1.0`), left un-updated after the fit refreshes the
19/// estimate. Generation must therefore read the *fitted* dispersion off `scale`,
20/// falling back to the seed only for fit-free construction. Reading the seed was
21/// the shared root cause of a whole family of bugs — Gamma #678, Beta #769/#770,
22/// Tweedie #771, and the NB sibling #1124 (`Var = mu + mu^2` instead of
23/// `mu + mu^2/theta_hat`).
24///
25/// This helper exists in exactly one place precisely because that bug class
26/// recurred: the dispersion-picking logic had been duplicated across the CLI
27/// `gam generate` path and the Python `sample_replicates` path, and fixing one
28/// copy left the other drawing at the seed. Both paths now call this function,
29/// so the set of supported families and the interpretation of each dispersion
30/// parameter can never diverge again. (The per-row dispersion location-scale
31/// path, #913/#1125, is the one exception that bypasses this scalar picker — it
32/// threads a full `exp(eta_d(x))` vector via
33/// [`NoiseModel::from_likelihood_with_per_row_dispersion`] instead.)
34///
35/// `standard_deviation` is the fit's residual scale, used as the Gamma-shape and
36/// Gaussian-`sigma` fallback. Returns `None` only for families that carry no
37/// dispersion at all in the fallback arm (never, in practice, for the families
38/// above).
39pub fn family_noise_parameter(
40    scale: LikelihoodScaleMetadata,
41    standard_deviation: f64,
42    likelihood: &LikelihoodSpec,
43) -> Option<f64> {
44    match likelihood.response {
45        // Tweedie: `gaussian_scale` carries the *dispersion* phi; the variance
46        // power `p` is read straight off the family spec by `from_likelihood`.
47        // phi is estimated jointly with the mean (#771), so consult the fit's
48        // scale metadata; unit dispersion is the fit-free fallback.
49        ResponseFamily::Tweedie { .. } => scale.fixed_phi().or(Some(1.0)),
50        // NB overdispersion theta is estimated jointly with the mean and stored
51        // as `EstimatedNegBinTheta`; the spec theta is only the seed (#1124).
52        ResponseFamily::NegativeBinomial { theta, .. } => scale.negbin_theta().or(Some(theta)),
53        // Beta precision phi is estimated jointly with the mean (#567/#770); the
54        // spec phi is only the seed.
55        ResponseFamily::Beta { phi } => scale.fixed_phi().or(Some(phi)),
56        // Gamma shape k is estimated jointly with the mean (#678); fall back to
57        // the residual scale only when the fit recorded no shape.
58        ResponseFamily::Gamma => scale.gamma_shape().or(Some(standard_deviation)),
59        // Gaussian / Poisson / Binomial: the residual scale is the generative
60        // sigma (Poisson/Binomial ignore it downstream).
61        _ => Some(standard_deviation),
62    }
63}
64
65/// Observation-noise model used for generative sampling.
66#[derive(Clone, Debug)]
67pub enum NoiseModel {
68    Gaussian {
69        /// Per-observation standard deviation.
70        sigma: Array1<f64>,
71    },
72    Poisson,
73    Tweedie {
74        p: f64,
75        /// Per-observation dispersion φ (> 0). A scalar-dispersion fit broadcasts
76        /// one value to every row; a dispersion location-scale fit (#913/#1125)
77        /// supplies the fitted per-row φ = 1/exp(eta_d(x)).
78        phi: Array1<f64>,
79    },
80    NegativeBinomial {
81        /// Per-observation overdispersion θ (> 0); see `Tweedie::phi`.
82        theta: Array1<f64>,
83    },
84    Beta {
85        /// Per-observation precision φ (> 0); see `Tweedie::phi`.
86        phi: Array1<f64>,
87    },
88    Gamma {
89        /// Per-observation Gamma shape k (> 0), with mean-driven scale; see
90        /// `Tweedie::phi`.
91        shape: Array1<f64>,
92    },
93    Bernoulli,
94    /// Inverse-transform sampling for a conditional transformation-normal (CTM)
95    /// model (issue #1613). The fitted latent transform `h(·|x_i)` is strictly
96    /// increasing in `y` and `h(Y|x) ~ N(0, 1)`, so a response-scale draw is
97    /// `Y = h⁻¹(Z | x_i)` with `Z ~ N(0, 1)`. The earlier generate path drew
98    /// Gaussian noise around the mean, which produced latent-scale draws whose
99    /// per-row mean moved the wrong way with the covariate; this variant instead
100    /// samples from the genuine conditional law `F(·|x)`.
101    ///
102    /// Both this sampler and the response-scale conditional mean `E[Y|x]` used by
103    /// `predict` (#1612) invert the SAME per-row monotone curve, materialized on
104    /// a shared response grid, so the two paths cannot disagree on the underlying
105    /// transform.
106    TransformationNormalQuantile {
107        /// Shared, strictly increasing response grid (length `g ≥ 2`).
108        grid_y: Array1<f64>,
109        /// `h_grid[[i, k]] = h(grid_y[k] | x_i)`, strictly increasing in `k` for
110        /// every row `i` (one row per observation).
111        h_grid: Array2<f64>,
112    },
113}
114
115/// Invert a monotone increasing tabulated function `z = h_row(grid_y)` at the
116/// latent value `target`: find the bracketing grid interval and linearly
117/// interpolate `y`. Values below/above the tabulated range map to the support
118/// endpoints (the finite-support tails carry no mass between the clamp and the
119/// endpoint). This is the same bracketing inversion the CTM `predict` mean
120/// (#1612) uses on its quadrature nodes, applied here to a random latent draw.
121fn invert_monotone_grid(
122    grid_y: &Array1<f64>,
123    h_row: ndarray::ArrayView1<'_, f64>,
124    target: f64,
125) -> f64 {
126    let g = grid_y.len();
127    if target <= h_row[0] {
128        return grid_y[0];
129    }
130    if target >= h_row[g - 1] {
131        return grid_y[g - 1];
132    }
133    let mut lo = 0usize;
134    let mut hi = g - 1;
135    while hi - lo > 1 {
136        let mid = (lo + hi) / 2;
137        if h_row[mid] <= target {
138            lo = mid;
139        } else {
140            hi = mid;
141        }
142    }
143    let t = (target - h_row[lo]) / (h_row[hi] - h_row[lo]);
144    grid_y[lo] + t * (grid_y[hi] - grid_y[lo])
145}
146
147/// First-class generative specification: mean process + observation noise.
148#[derive(Clone, Debug)]
149pub struct GenerativeSpec {
150    pub mean: Array1<f64>,
151    pub noise: NoiseModel,
152}
153
154impl GenerativeSpec {
155    /// Number of observations `n` in the mean vector, matching the row
156    /// count of the design used to produce this generative specification.
157    pub fn nobs(&self) -> usize {
158        self.mean.len()
159    }
160}
161
162/// Build a generative specification for built-in GAM families from eta/mean.
163pub fn generativespec_from_predict(
164    prediction: PredictResult,
165    likelihood: LikelihoodSpec,
166    gaussian_scale: Option<f64>,
167    prior_weights: Option<&Array1<f64>>,
168) -> Result<GenerativeSpec, EstimationError> {
169    let mut noise =
170        NoiseModel::from_likelihood(&likelihood, prediction.mean.len(), gaussian_scale)?;
171    // Analytic prior weights define `Var(y_i) = sigma^2 / w_i` for a weighted
172    // Gaussian fit, so replicate observation noise is heteroskedastic:
173    // `sigma_i = sigma_hat / sqrt(w_i)`. `from_likelihood` broadcasts the pooled
174    // scalar `sigma_hat` to every row (the correct value for an unweighted fit),
175    // so rescale it per row here whenever the fit carried prior weights (#2025).
176    // Only the Gaussian arm exposes a location-scale `sigma`; the other families
177    // encode dispersion through their own precision parameter and analytic prior
178    // weights do not enter their observation draw, so they are left untouched.
179    if let (NoiseModel::Gaussian { sigma }, Some(weights)) = (&mut noise, prior_weights) {
180        scale_gaussian_sigma_by_prior_weights(sigma, weights)?;
181    }
182    Ok(GenerativeSpec {
183        mean: prediction.mean,
184        noise,
185    })
186}
187
188/// Rescale a broadcast Gaussian `sigma_hat` vector into the per-row analytic-weight
189/// observation scale `sigma_i = sigma_hat / sqrt(w_i)` (#2025). The prior weights
190/// `w_i` are the same non-negative weights the fit consumed; a zero or non-finite
191/// weight has no finite observation variance under the analytic-weight model, so it
192/// is rejected rather than silently producing an infinite draw scale.
193fn scale_gaussian_sigma_by_prior_weights(
194    sigma: &mut Array1<f64>,
195    weights: &Array1<f64>,
196) -> Result<(), EstimationError> {
197    if weights.len() != sigma.len() {
198        crate::bail_invalid_estim!(
199            "prior weights length {} does not match observation count {}",
200            weights.len(),
201            sigma.len()
202        );
203    }
204    for (s, &w) in sigma.iter_mut().zip(weights.iter()) {
205        if !(w.is_finite() && w > 0.0) {
206            crate::bail_invalid_estim!(
207                "Gaussian replicate prior weights must be finite and > 0; got {w}"
208            );
209        }
210        *s /= w.sqrt();
211    }
212    Ok(())
213}
214
215impl NoiseModel {
216    /// Single canonical mapping from a fitted `LikelihoodSpec` (response
217    /// distribution + dispersion `gaussian_scale`) to the observation
218    /// `NoiseModel` used for generative sampling. Both simulation
219    /// (`FamilyStrategy::simulate_noise`) and generative inference
220    /// (`generativespec_from_predict`) route through this one helper so the
221    /// set of supported likelihoods and the interpretation of dispersion
222    /// parameters can never diverge between the two paths.
223    ///
224    /// `nobs` is the number of observations the resulting per-observation
225    /// Gaussian `sigma` vector should span; it is ignored for families whose
226    /// noise carries no per-observation state.
227    pub fn from_likelihood(
228        likelihood: &LikelihoodSpec,
229        nobs: usize,
230        gaussian_scale: Option<f64>,
231    ) -> Result<NoiseModel, EstimationError> {
232        match &likelihood.response {
233            ResponseFamily::Gaussian => {
234                let sigma =
235                    Self::require_noise_parameter(likelihood, "Gaussian sigma", gaussian_scale)?;
236                if sigma < 0.0 {
237                    crate::bail_invalid_estim!(
238                        "{} generative sampling requires Gaussian sigma >= 0; got {sigma}",
239                        likelihood.pretty_name()
240                    );
241                }
242                Ok(NoiseModel::Gaussian {
243                    sigma: Array1::from_elem(nobs, sigma),
244                })
245            }
246            ResponseFamily::Binomial => Ok(NoiseModel::Bernoulli),
247            ResponseFamily::Poisson => Ok(NoiseModel::Poisson),
248            ResponseFamily::Tweedie { p } => {
249                let p = *p;
250                if !is_valid_tweedie_power(p) {
251                    crate::bail_invalid_estim!(
252                        "Tweedie variance power must be finite and strictly between 1 and 2; got {p}"
253                    );
254                }
255                let phi = Self::require_positive_noise_parameter(
256                    likelihood,
257                    "Tweedie dispersion phi",
258                    gaussian_scale,
259                )?;
260                Ok(NoiseModel::Tweedie {
261                    p,
262                    // Scalar-dispersion fit: broadcast one φ to every row. The
263                    // dispersion location-scale path (#1125) builds the per-row
264                    // vector directly in `run_generate_unified` instead.
265                    phi: Array1::from_elem(nobs, phi),
266                })
267            }
268            ResponseFamily::NegativeBinomial { theta, .. } => {
269                // The NB overdispersion θ is estimated jointly with the mean and
270                // the authoritative post-fit value is handed in as
271                // `gaussian_scale` (from `likelihood_scale.negbin_theta()`);
272                // the θ embedded in the response spec is only the seed (1.0).
273                // Reading the seed was the NB sibling of the Beta #770 bug:
274                // generate drew Var = μ + μ² (θ = 1) regardless of the fitted
275                // overdispersion (#1124). Mirror the Beta arm below.
276                let theta = gaussian_scale.unwrap_or(*theta);
277                if !(theta.is_finite() && theta > 0.0) {
278                    crate::bail_invalid_estim!(
279                        "negative-binomial theta must be finite and > 0; got {theta}"
280                    );
281                }
282                Ok(NoiseModel::NegativeBinomial {
283                    theta: Array1::from_elem(nobs, theta),
284                })
285            }
286            ResponseFamily::Beta { phi } => {
287                // The Beta precision φ is estimated jointly with the mean
288                // (issue #567), so the authoritative value after fitting is the
289                // dispersion handed in as `gaussian_scale` — exactly as Gamma's
290                // shape and Tweedie's φ already take theirs. The `phi` embedded
291                // in the response spec is only the construction-time *seed* (left
292                // at its original value, e.g. 1.0, after the fit refreshes the
293                // estimate in `likelihood_scale`), so it serves solely as a
294                // fallback for fit-free construction where no fitted dispersion
295                // is supplied. Reading the seed instead of `gaussian_scale` was
296                // issue #770: the generative/observation path drew Beta responses
297                // with φ = 1.0 regardless of the data — nearly uniform on (0,1),
298                // ~20× too much variance — even though the fit estimated φ and
299                // the caller forwarded it here.
300                let phi = gaussian_scale.unwrap_or(*phi);
301                if !(phi.is_finite() && phi > 0.0) {
302                    crate::bail_invalid_estim!(
303                        "beta-regression phi must be finite and > 0; got {phi}"
304                    );
305                }
306                Ok(NoiseModel::Beta {
307                    phi: Array1::from_elem(nobs, phi),
308                })
309            }
310            ResponseFamily::Gamma => {
311                let shape = Self::require_positive_noise_parameter(
312                    likelihood,
313                    "Gamma shape",
314                    gaussian_scale,
315                )?;
316                Ok(NoiseModel::Gamma {
317                    shape: Array1::from_elem(nobs, shape),
318                })
319            }
320            ResponseFamily::RoystonParmar => Err(EstimationError::InvalidInput(
321                "RoystonParmar generative sampling is not exposed via generic generation"
322                    .to_string(),
323            )),
324        }
325    }
326
327    /// Build the observation `NoiseModel` for a dispersion location-scale fit
328    /// (#1125) from a fitted PER-ROW dispersion surface `dispersion[i]` (the
329    /// predictor's `exp(eta_d(x_i))` mapped into NoiseModel units — NB θ, Gamma
330    /// shape, Beta φ directly, Tweedie φ as the reciprocal). Unlike
331    /// `from_likelihood`, which broadcasts a single scalar dispersion to every
332    /// row, this threads the genuine per-observation precision channel so
333    /// generated data reproduces the fitted non-constant dispersion instead of
334    /// coming out homoscedastic at the seed.
335    pub fn from_likelihood_with_per_row_dispersion(
336        likelihood: &LikelihoodSpec,
337        dispersion: Array1<f64>,
338    ) -> Result<NoiseModel, EstimationError> {
339        match &likelihood.response {
340            ResponseFamily::Tweedie { p } => {
341                let p = *p;
342                if !is_valid_tweedie_power(p) {
343                    crate::bail_invalid_estim!(
344                        "Tweedie variance power must be finite and strictly between 1 and 2; got {p}"
345                    );
346                }
347                Ok(NoiseModel::Tweedie { p, phi: dispersion })
348            }
349            ResponseFamily::NegativeBinomial { .. } => {
350                Ok(NoiseModel::NegativeBinomial { theta: dispersion })
351            }
352            ResponseFamily::Beta { .. } => Ok(NoiseModel::Beta { phi: dispersion }),
353            ResponseFamily::Gamma => Ok(NoiseModel::Gamma { shape: dispersion }),
354            other => Err(EstimationError::InvalidInput(format!(
355                "per-row dispersion generative sampling is only defined for the dispersion \
356                 location-scale families (Gamma/NegativeBinomial/Beta/Tweedie); got {other:?}"
357            ))),
358        }
359    }
360
361    fn require_noise_parameter(
362        likelihood: &LikelihoodSpec,
363        parameter_name: &str,
364        value: Option<f64>,
365    ) -> Result<f64, EstimationError> {
366        let value = value.ok_or_else(|| {
367            EstimationError::InvalidInput(format!(
368                "{} generative sampling requires fitted {parameter_name}",
369                likelihood.pretty_name()
370            ))
371        })?;
372        if value.is_finite() {
373            Ok(value)
374        } else {
375            Err(EstimationError::InvalidInput(format!(
376                "{} generative sampling requires finite {parameter_name}; got {value}",
377                likelihood.pretty_name()
378            )))
379        }
380    }
381
382    fn require_positive_noise_parameter(
383        likelihood: &LikelihoodSpec,
384        parameter_name: &str,
385        value: Option<f64>,
386    ) -> Result<f64, EstimationError> {
387        let value = Self::require_noise_parameter(likelihood, parameter_name, value)?;
388        if value > 0.0 {
389            Ok(value)
390        } else {
391            Err(EstimationError::InvalidInput(format!(
392                "{} generative sampling requires {parameter_name} > 0; got {value}",
393                likelihood.pretty_name()
394            )))
395        }
396    }
397}
398
399/// Validate that a per-observation dispersion vector matches the mean length.
400/// Scalar-dispersion fits broadcast one value across all rows (length `n`);
401/// dispersion location-scale fits (#1125) carry the genuine per-row vector.
402fn check_dispersion_len(
403    dispersion: &Array1<f64>,
404    nobs: usize,
405    name: &str,
406) -> Result<(), EstimationError> {
407    if dispersion.len() != nobs {
408        crate::bail_invalid_estim!(
409            "{name} length {} does not match mean length {nobs}",
410            dispersion.len()
411        );
412    }
413    Ok(())
414}
415
416/// Draw one synthetic observation vector from a generative spec.
417pub fn sampleobservations<R: rand::Rng + ?Sized>(
418    spec: &GenerativeSpec,
419    rng: &mut R,
420) -> Result<Array1<f64>, EstimationError> {
421    if spec.mean.iter().any(|m| !m.is_finite()) {
422        crate::bail_invalid_estim!("generative mean contains non-finite values");
423    }
424    match &spec.noise {
425        NoiseModel::Gaussian { sigma } => {
426            if sigma.len() != spec.mean.len() {
427                crate::bail_invalid_estim!(
428                    "Gaussian sigma length {} does not match mean length {}",
429                    sigma.len(),
430                    spec.mean.len()
431                );
432            }
433            let mut y = spec.mean.clone();
434            for i in 0..y.len() {
435                let sd = sigma[i].max(0.0);
436                if sd == 0.0 {
437                    continue;
438                }
439                let dist = rand_distr::Normal::new(0.0, sd).map_err(|e| {
440                    EstimationError::InvalidInput(format!("invalid Gaussian noise scale {sd}: {e}"))
441                })?;
442                y[i] += rand_distr::Distribution::sample(&dist, rng);
443            }
444            Ok(y)
445        }
446        NoiseModel::Poisson => {
447            let mut y = Array1::<f64>::zeros(spec.mean.len());
448            for i in 0..y.len() {
449                let lam = spec.mean[i].max(1e-12);
450                let dist = rand_distr::Poisson::new(lam).map_err(|e| {
451                    EstimationError::InvalidInput(format!("invalid Poisson rate {lam}: {e}"))
452                })?;
453                let draw = rand_distr::Distribution::sample(&dist, rng);
454                y[i] = draw;
455            }
456            Ok(y)
457        }
458        NoiseModel::Tweedie { p, phi } => {
459            if !(p.is_finite() && *p >= 1.0 && *p <= 2.0) {
460                crate::bail_invalid_estim!("invalid Tweedie power p: {p}");
461            }
462            check_dispersion_len(phi, spec.mean.len(), "Tweedie dispersion phi")?;
463            for (i, &phi_i) in phi.iter().enumerate() {
464                if !(phi_i.is_finite() && phi_i > 0.0) {
465                    crate::bail_invalid_estim!(
466                        "invalid Tweedie dispersion phi at row {i}: {phi_i}"
467                    );
468                }
469            }
470            let mut y = Array1::<f64>::zeros(spec.mean.len());
471            if (*p - 1.0).abs() <= 1.0e-12 {
472                for i in 0..y.len() {
473                    let phi_i = phi[i];
474                    let lam = (spec.mean[i] / phi_i).max(1e-12);
475                    let dist = rand_distr::Poisson::new(lam).map_err(|e| {
476                        EstimationError::InvalidInput(format!(
477                            "invalid Tweedie-Poisson rate {lam}: {e}"
478                        ))
479                    })?;
480                    y[i] = phi_i * rand_distr::Distribution::sample(&dist, rng);
481                }
482                return Ok(y);
483            }
484            if (*p - 2.0).abs() <= 1.0e-12 {
485                for i in 0..y.len() {
486                    let phi_i = phi[i];
487                    let shape = (1.0 / phi_i).max(1e-12);
488                    let mu = spec.mean[i].max(1e-12);
489                    let scale = (mu * phi_i).max(1e-12);
490                    let dist = rand_distr::Gamma::new(shape, scale).map_err(|e| {
491                        EstimationError::InvalidInput(format!(
492                            "invalid Tweedie-Gamma params shape={shape} scale={scale}: {e}"
493                        ))
494                    })?;
495                    y[i] = rand_distr::Distribution::sample(&dist, rng);
496                }
497                return Ok(y);
498            }
499            let alpha = (2.0 - *p) / (*p - 1.0);
500            for i in 0..y.len() {
501                let phi_i = phi[i];
502                let mu = spec.mean[i].max(1e-12);
503                let lambda = (mu.powf(2.0 - *p) / (phi_i * (2.0 - *p))).max(1e-12);
504                let scale = (phi_i * (*p - 1.0) * mu.powf(*p - 1.0)).max(1e-12);
505                let count_dist = rand_distr::Poisson::new(lambda).map_err(|e| {
506                    EstimationError::InvalidInput(format!(
507                        "invalid Tweedie compound-Poisson rate {lambda}: {e}"
508                    ))
509                })?;
510                let count = rand_distr::Distribution::sample(&count_dist, rng) as usize;
511                if count == 0 {
512                    continue;
513                }
514                let jump_dist = rand_distr::Gamma::new(alpha, scale).map_err(|e| {
515                    EstimationError::InvalidInput(format!(
516                        "invalid Tweedie jump params shape={alpha} scale={scale}: {e}"
517                    ))
518                })?;
519                y[i] = (0..count)
520                    .map(|_| rand_distr::Distribution::sample(&jump_dist, rng))
521                    .sum();
522            }
523            Ok(y)
524        }
525        NoiseModel::NegativeBinomial { theta } => {
526            check_dispersion_len(theta, spec.mean.len(), "NegativeBinomial theta")?;
527            let mut y = Array1::<f64>::zeros(spec.mean.len());
528            for i in 0..y.len() {
529                let theta_i = theta[i];
530                if !(theta_i.is_finite() && theta_i > 0.0) {
531                    crate::bail_invalid_estim!(
532                        "invalid negative-binomial theta at row {i}: {theta_i}"
533                    );
534                }
535                let mu = spec.mean[i].max(1e-12);
536                let scale = (mu / theta_i).max(1e-12);
537                let gamma = rand_distr::Gamma::new(theta_i, scale).map_err(|e| {
538                    EstimationError::InvalidInput(format!(
539                        "invalid NegativeBinomial gamma mixture params theta={theta_i} scale={scale}: {e}"
540                    ))
541                })?;
542                let lambda = rand_distr::Distribution::sample(&gamma, rng).max(1e-12);
543                let poisson = rand_distr::Poisson::new(lambda).map_err(|e| {
544                    EstimationError::InvalidInput(format!(
545                        "invalid NegativeBinomial Poisson rate {lambda}: {e}"
546                    ))
547                })?;
548                y[i] = rand_distr::Distribution::sample(&poisson, rng);
549            }
550            Ok(y)
551        }
552        NoiseModel::Beta { phi } => {
553            check_dispersion_len(phi, spec.mean.len(), "Beta phi")?;
554            let mut y = Array1::<f64>::zeros(spec.mean.len());
555            for i in 0..y.len() {
556                let phi_i = phi[i];
557                if !(phi_i.is_finite() && phi_i > 0.0) {
558                    crate::bail_invalid_estim!("invalid beta-regression phi at row {i}: {phi_i}");
559                }
560                let mu = spec.mean[i].clamp(1e-12, 1.0 - 1e-12);
561                let alpha = (mu * phi_i).max(1e-12);
562                let beta = ((1.0 - mu) * phi_i).max(1e-12);
563                let dist = rand_distr::Beta::new(alpha, beta).map_err(|e| {
564                    EstimationError::InvalidInput(format!(
565                        "invalid Beta params alpha={alpha} beta={beta}: {e}"
566                    ))
567                })?;
568                y[i] = rand_distr::Distribution::sample(&dist, rng);
569            }
570            Ok(y)
571        }
572        NoiseModel::Gamma { shape } => {
573            check_dispersion_len(shape, spec.mean.len(), "Gamma shape")?;
574            let mut y = Array1::<f64>::zeros(spec.mean.len());
575            for i in 0..y.len() {
576                let shape_i = shape[i];
577                if !shape_i.is_finite() || shape_i <= 0.0 {
578                    crate::bail_invalid_estim!("invalid Gamma shape at row {i}: {shape_i}");
579                }
580                let mu = spec.mean[i].max(1e-12);
581                let scale = (mu / shape_i).max(1e-12);
582                let dist = rand_distr::Gamma::new(shape_i, scale).map_err(|e| {
583                    EstimationError::InvalidInput(format!(
584                        "invalid Gamma params shape={shape_i} scale={scale}: {e}"
585                    ))
586                })?;
587                y[i] = rand_distr::Distribution::sample(&dist, rng);
588            }
589            Ok(y)
590        }
591        NoiseModel::Bernoulli => {
592            let mut y = Array1::<f64>::zeros(spec.mean.len());
593            for i in 0..y.len() {
594                let p = spec.mean[i];
595                let dist = rand_distr::Bernoulli::new(p).map_err(|e| {
596                    EstimationError::InvalidInput(format!("invalid Bernoulli probability {p}: {e}"))
597                })?;
598                y[i] = if rand_distr::Distribution::sample(&dist, rng) {
599                    1.0
600                } else {
601                    0.0
602                };
603            }
604            Ok(y)
605        }
606        NoiseModel::TransformationNormalQuantile { grid_y, h_grid } => {
607            let n = spec.mean.len();
608            if h_grid.nrows() != n {
609                crate::bail_invalid_estim!(
610                    "transformation-normal h_grid has {} rows but mean length is {n}",
611                    h_grid.nrows()
612                );
613            }
614            let g = grid_y.len();
615            if g < 2 || h_grid.ncols() != g {
616                crate::bail_invalid_estim!(
617                    "transformation-normal grid is degenerate: grid_y len {g}, h_grid cols {}",
618                    h_grid.ncols()
619                );
620            }
621            // `h(Y|x) ~ N(0,1)` ⇒ a response-scale draw is `Y = h⁻¹(Z | x)`,
622            // `Z ~ N(0,1)`. One independent latent draw per observation, inverted
623            // through that row's monotone transform.
624            let dist = rand_distr::Normal::new(0.0, 1.0).map_err(|e| {
625                EstimationError::InvalidInput(format!(
626                    "invalid standard-normal latent sampler: {e}"
627                ))
628            })?;
629            let mut y = Array1::<f64>::zeros(n);
630            for i in 0..n {
631                let z: f64 = rand_distr::Distribution::sample(&dist, rng);
632                y[i] = invert_monotone_grid(grid_y, h_grid.row(i), z);
633            }
634            Ok(y)
635        }
636    }
637}
638
639/// Draw multiple synthetic replicates (n_draws x nobs).
640pub fn sampleobservation_replicates<R: rand::Rng + ?Sized>(
641    spec: &GenerativeSpec,
642    n_draws: usize,
643    rng: &mut R,
644) -> Result<Array2<f64>, EstimationError> {
645    let n = spec.nobs();
646    let mut out = Array2::<f64>::zeros((n_draws, n));
647    for d in 0..n_draws {
648        let draw = sampleobservations(spec, rng)?;
649        out.row_mut(d).assign(&draw);
650    }
651    Ok(out)
652}
653
654/// Extension trait for custom multi-block families that provide explicit
655/// generative semantics (mean + observation noise) at a fitted state.
656pub trait CustomFamilyGenerative: CustomFamily {
657    fn generativespec(
658        &self,
659        block_states: &[ParameterBlockState],
660    ) -> Result<GenerativeSpec, String>;
661}
662
663#[cfg(test)]
664mod tests {
665    use super::*;
666    use crate::family_runtime::{FamilyStrategy, strategy_for_spec};
667
668    /// The CTM inverse-transform sampler (#1613) must draw `Y = h⁻¹(Z|x)`,
669    /// `Z ~ N(0,1)`, from each row's monotone transform — NOT Gaussian noise on
670    /// the latent scale. With the analytically invertible linear transform
671    /// `h(y|x_i) = slope_i·(y − center_i)` we have `h⁻¹(z) = center_i + z/slope_i`,
672    /// so the draws must be `N(center_i, (1/slope_i)²)`: the per-row mean tracks
673    /// `center_i` (response scale) and the spread is `1/slope_i` (NOT ≈ 1, the
674    /// latent scale of the old buggy path).
675    #[test]
676    fn transformation_normal_quantile_sampler_is_inverse_transform() {
677        use rand::SeedableRng;
678
679        let g = 801usize;
680        let (y_lo, y_hi) = (-12.0_f64, 12.0_f64);
681        let grid_y =
682            Array1::from_shape_fn(g, |k| y_lo + (y_hi - y_lo) * (k as f64) / ((g - 1) as f64));
683        // Row 0: center -1, slope 2 (sd 0.5). Row 1: center +2, slope 4 (sd 0.25).
684        let centers = [-1.0_f64, 2.0_f64];
685        let slopes = [2.0_f64, 4.0_f64];
686        let mut h_grid = Array2::<f64>::zeros((2, g));
687        for i in 0..2 {
688            for k in 0..g {
689                h_grid[[i, k]] = slopes[i] * (grid_y[k] - centers[i]);
690            }
691        }
692        let spec = GenerativeSpec {
693            mean: Array1::from_vec(vec![centers[0], centers[1]]),
694            noise: NoiseModel::TransformationNormalQuantile {
695                grid_y: grid_y.clone(),
696                h_grid,
697            },
698        };
699
700        let mut rng = rand::rngs::StdRng::seed_from_u64(20240613);
701        let n_draws = 40_000usize;
702        let draws = sampleobservation_replicates(&spec, n_draws, &mut rng).unwrap();
703        assert_eq!(draws.shape(), &[n_draws, 2]);
704
705        let mut row_means = [0.0_f64; 2];
706        for i in 0..2 {
707            let col = draws.column(i);
708            let mean = col.sum() / (n_draws as f64);
709            let var = col.iter().map(|v| (v - mean) * (v - mean)).sum::<f64>() / (n_draws as f64);
710            let sd = var.sqrt();
711            row_means[i] = mean;
712            assert!(
713                (mean - centers[i]).abs() < 0.02,
714                "row {i} draw mean {mean:.4} should be the response-scale center {:.4}",
715                centers[i]
716            );
717            let expected_sd = 1.0 / slopes[i];
718            assert!(
719                (sd - expected_sd).abs() < 0.02,
720                "row {i} draw sd {sd:.4} should be the response-scale 1/slope {expected_sd:.4}, \
721                 not the latent ≈1 of the old Gaussian-noise path"
722            );
723        }
724        // The conditional mean must INCREASE with the covariate-driven center —
725        // the exact direction the #1613 bug got backwards.
726        assert!(
727            row_means[1] > row_means[0],
728            "draw means must increase with center: row0={:.4} row1={:.4}",
729            row_means[0],
730            row_means[1]
731        );
732    }
733
734    /// The canonical dispersion picker must read the *fitted* dispersion off the
735    /// scale metadata, never the construction seed embedded in the response
736    /// spec. This is the single guard for the whole "generate draws at the seed
737    /// dispersion" bug family — Gamma #678, Beta #769/#770, Tweedie #771, and
738    /// the NB sibling #1124 — now that the picker lives in exactly one place
739    /// (previously three divergent copies let a fix in one miss the others).
740    #[test]
741    fn family_noise_parameter_reads_fitted_dispersion_not_seed() {
742        // NB: spec carries the seed theta = 1; the fit estimated theta_hat.
743        let nb = LikelihoodSpec::negative_binomial_log(1.0);
744        assert_eq!(
745            family_noise_parameter(
746                LikelihoodScaleMetadata::EstimatedNegBinTheta { theta: 2.97 },
747                0.0,
748                &nb,
749            ),
750            Some(2.97),
751            "NB picker must read theta_hat (#1124), not the seed theta=1"
752        );
753
754        // Tweedie: the picker must return the dispersion phi, never the variance
755        // power p that lives on the spec.
756        let tw = LikelihoodSpec::tweedie_log(1.5);
757        assert_eq!(
758            family_noise_parameter(
759                LikelihoodScaleMetadata::EstimatedTweediePhi { phi: 7.25 },
760                0.0,
761                &tw,
762            ),
763            Some(7.25),
764            "Tweedie picker must read phi_hat (#771), not the variance power p"
765        );
766
767        // Beta: spec carries the seed phi = 1; the fit estimated phi_hat.
768        let beta = LikelihoodSpec::beta_logit(1.0);
769        assert_eq!(
770            family_noise_parameter(
771                LikelihoodScaleMetadata::EstimatedBetaPhi { phi: 12.0 },
772                0.0,
773                &beta,
774            ),
775            Some(12.0),
776            "Beta picker must read phi_hat (#770), not the seed phi=1"
777        );
778
779        // Gamma: the estimated shape must win over the residual-scale fallback.
780        let gamma = LikelihoodSpec::gamma_log();
781        assert_eq!(
782            family_noise_parameter(
783                LikelihoodScaleMetadata::EstimatedGammaShape { shape: 4.5 },
784                0.123,
785                &gamma,
786            ),
787            Some(4.5),
788            "Gamma picker must read shape_hat (#678), not the residual-scale fallback"
789        );
790    }
791
792    /// With no fitted dispersion recorded (fit-free construction), the picker
793    /// falls back to the seed on the spec / the residual scale. It must never
794    /// return `None` for a dispersion family, or generation would have nothing
795    /// to draw with.
796    #[test]
797    fn family_noise_parameter_falls_back_to_seed_when_unfitted() {
798        // `ProfiledGaussian` carries no fixed_phi / negbin_theta / gamma_shape,
799        // so every accessor returns `None` and the picker must use the fallback.
800        let none = LikelihoodScaleMetadata::ProfiledGaussian;
801        assert_eq!(
802            family_noise_parameter(none, 0.0, &LikelihoodSpec::negative_binomial_log(3.5)),
803            Some(3.5),
804            "NB picker must fall back to the spec seed theta"
805        );
806        assert_eq!(
807            family_noise_parameter(none, 0.0, &LikelihoodSpec::beta_logit(8.0)),
808            Some(8.0),
809            "Beta picker must fall back to the spec seed phi"
810        );
811        assert_eq!(
812            family_noise_parameter(none, 0.0, &LikelihoodSpec::tweedie_log(1.5)),
813            Some(1.0),
814            "Tweedie picker must fall back to unit dispersion"
815        );
816        assert_eq!(
817            family_noise_parameter(none, 2.0, &LikelihoodSpec::gamma_log()),
818            Some(2.0),
819            "Gamma picker must fall back to the residual scale"
820        );
821    }
822
823    /// End-to-end through the exact composition `gam generate` and
824    /// `sample_replicates` use — picker → `from_likelihood`. The seed-spec
825    /// theta = 1 plus an estimated theta_hat must yield a per-row NB noise model
826    /// at theta_hat, not at the seed. This is the #1124 repro at the unit level,
827    /// from the angle of the *composed* path rather than `from_likelihood` alone.
828    #[test]
829    fn picker_then_from_likelihood_threads_fitted_nb_theta() {
830        let nobs = 6usize;
831        let seed_spec = LikelihoodSpec::negative_binomial_log(1.0);
832        let scale = LikelihoodScaleMetadata::EstimatedNegBinTheta { theta: 2.751 };
833        let picked = family_noise_parameter(scale, 0.0, &seed_spec);
834        let noise =
835            NoiseModel::from_likelihood(&seed_spec, nobs, picked).expect("NB noise model builds");
836        let NoiseModel::NegativeBinomial { theta } = noise else {
837            panic!("expected an NB observation noise model");
838        };
839        assert!(
840            theta.len() == nobs && theta.iter().all(|&t| (t - 2.751).abs() < 1e-12),
841            "NB generate composes the seed theta=1 instead of theta_hat (#1124): {theta:?}"
842        );
843    }
844
845    /// A weighted Gaussian fit has `Var(y_i) = sigma^2 / w_i`, so the generative
846    /// observation noise must be heteroskedastic in the analytic prior weights:
847    /// `sigma_i = sigma_hat / sqrt(w_i)`. Before #2025 the replicate path dropped
848    /// the weights and broadcast the pooled scalar `sigma_hat` to every row (flat
849    /// sigma). This asserts the per-row scaling and that unit weights leave the
850    /// scalar untouched (so unweighted fits are unchanged).
851    #[test]
852    fn gaussian_generativespec_scales_sigma_by_prior_weights() {
853        let sigma_hat = 2.0_f64;
854        let weights = Array1::from(vec![1.0, 4.0, 0.25]);
855        let mean = Array1::from(vec![0.0, 1.0, -1.0]);
856        let prediction = PredictResult {
857            eta: mean.clone(),
858            mean: mean.clone(),
859        };
860        let spec = generativespec_from_predict(
861            prediction,
862            LikelihoodSpec::gaussian_identity(),
863            Some(sigma_hat),
864            Some(&weights),
865        )
866        .expect("weighted Gaussian generative spec builds");
867        let NoiseModel::Gaussian { sigma } = spec.noise else {
868            panic!("expected Gaussian observation noise");
869        };
870        // sigma_hat / sqrt(w_i) for w = [1, 4, 0.25] -> [2, 1, 4].
871        let expected = [2.0_f64, 1.0, 4.0];
872        for (i, (&got, &want)) in sigma.iter().zip(expected.iter()).enumerate() {
873            assert!(
874                (got - want).abs() < 1e-12,
875                "row {i}: sigma must be sigma_hat/sqrt(w_i)={want}, got {got} \
876                 (flat sigma_hat={sigma_hat} drops the prior weights, #2025)"
877            );
878        }
879        assert!(
880            sigma.iter().any(|&s| (s - sigma_hat).abs() > 1e-9),
881            "sigma is flat at the pooled scalar; prior weights were dropped (#2025)"
882        );
883
884        // Unit prior weights must reproduce the unweighted pooled scalar exactly.
885        let unit = Array1::from_elem(3, 1.0_f64);
886        let unweighted = generativespec_from_predict(
887            PredictResult {
888                eta: mean.clone(),
889                mean,
890            },
891            LikelihoodSpec::gaussian_identity(),
892            Some(sigma_hat),
893            Some(&unit),
894        )
895        .expect("unit-weight Gaussian generative spec builds");
896        let NoiseModel::Gaussian { sigma: flat } = unweighted.noise else {
897            panic!("expected Gaussian observation noise");
898        };
899        assert!(
900            flat.iter().all(|&s| (s - sigma_hat).abs() < 1e-12),
901            "unit prior weights must leave sigma at the pooled scalar sigma_hat"
902        );
903    }
904
905    /// Structural equality for `NoiseModel` (no derived `PartialEq` so that
906    /// the live enum can carry per-observation arrays). Two models are equal
907    /// when they are the same variant with bitwise-identical parameters.
908    fn noise_models_match(a: &NoiseModel, b: &NoiseModel) -> bool {
909        match (a, b) {
910            (NoiseModel::Gaussian { sigma: sa }, NoiseModel::Gaussian { sigma: sb }) => sa == sb,
911            (NoiseModel::Poisson, NoiseModel::Poisson) => true,
912            (NoiseModel::Bernoulli, NoiseModel::Bernoulli) => true,
913            (NoiseModel::Tweedie { p: pa, phi: pha }, NoiseModel::Tweedie { p: pb, phi: phb }) => {
914                pa == pb && pha == phb
915            }
916            (
917                NoiseModel::NegativeBinomial { theta: ta },
918                NoiseModel::NegativeBinomial { theta: tb },
919            ) => ta == tb,
920            (NoiseModel::Beta { phi: pa }, NoiseModel::Beta { phi: pb }) => pa == pb,
921            (NoiseModel::Gamma { shape: sa }, NoiseModel::Gamma { shape: sb }) => sa == sb,
922            _ => false,
923        }
924    }
925
926    /// For every supported built-in family, the canonical
927    /// `NoiseModel::from_likelihood` mapping and the simulation adapter
928    /// `FamilyStrategy::simulate_noise` must produce the same `NoiseModel`
929    /// from the same fitted dispersion — this is the single-mapping guarantee
930    /// the unification provides.
931    #[test]
932    fn from_likelihood_matches_simulate_noise_for_each_family() {
933        let nobs = 5usize;
934        let mean = Array1::from_elem(nobs, 0.5_f64);
935
936        // (spec, dispersion/gaussian_scale, expected noise variant).
937        let cases: [(LikelihoodSpec, Option<f64>, NoiseModel); 7] = [
938            (
939                LikelihoodSpec::gaussian_identity(),
940                Some(0.7),
941                NoiseModel::Gaussian {
942                    sigma: Array1::from_elem(nobs, 0.7),
943                },
944            ),
945            (
946                LikelihoodSpec::binomial_logit(),
947                None,
948                NoiseModel::Bernoulli,
949            ),
950            (LikelihoodSpec::poisson_log(), None, NoiseModel::Poisson),
951            (
952                LikelihoodSpec::tweedie_log(1.4),
953                Some(0.9),
954                NoiseModel::Tweedie {
955                    p: 1.4,
956                    phi: Array1::from_elem(nobs, 0.9),
957                },
958            ),
959            (
960                LikelihoodSpec::negative_binomial_log(2.5),
961                None,
962                NoiseModel::NegativeBinomial {
963                    theta: Array1::from_elem(nobs, 2.5),
964                },
965            ),
966            (
967                LikelihoodSpec::beta_logit(3.0),
968                None,
969                NoiseModel::Beta {
970                    phi: Array1::from_elem(nobs, 3.0),
971                },
972            ),
973            (
974                LikelihoodSpec::gamma_log(),
975                Some(1.5),
976                NoiseModel::Gamma {
977                    shape: Array1::from_elem(nobs, 1.5),
978                },
979            ),
980        ];
981
982        for (spec, scale, expected) in cases {
983            let from_helper = NoiseModel::from_likelihood(&spec, nobs, scale)
984                .expect("canonical mapping must accept a supported family");
985            let from_strategy = strategy_for_spec(&spec)
986                .simulate_noise(&mean, scale)
987                .expect("simulation adapter must accept a supported family");
988
989            assert!(
990                noise_models_match(&from_helper, &expected),
991                "{} canonical mapping produced an unexpected NoiseModel",
992                spec.pretty_name()
993            );
994            assert!(
995                noise_models_match(&from_helper, &from_strategy),
996                "{} simulation and inference disagree on the NoiseModel",
997                spec.pretty_name()
998            );
999        }
1000    }
1001
1002    /// RoystonParmar is not exposed through the generic generative path, and
1003    /// both the canonical mapping and the simulation adapter must reject it
1004    /// identically so the two paths stay in lockstep.
1005    #[test]
1006    fn royston_parmar_rejected_on_both_paths() {
1007        let spec = LikelihoodSpec::royston_parmar();
1008        let mean = Array1::from_elem(3, 0.0_f64);
1009        assert!(NoiseModel::from_likelihood(&spec, 3, None).is_err());
1010        assert!(
1011            strategy_for_spec(&spec)
1012                .simulate_noise(&mean, None)
1013                .is_err()
1014        );
1015    }
1016
1017    /// Invalid / missing dispersion is rejected the same way regardless of
1018    /// which entry point is used.
1019    #[test]
1020    fn invalid_dispersion_rejected_on_both_paths() {
1021        let mean = Array1::from_elem(4, 0.0_f64);
1022
1023        // Gaussian sigma missing.
1024        let gauss = LikelihoodSpec::gaussian_identity();
1025        assert!(NoiseModel::from_likelihood(&gauss, 4, None).is_err());
1026        assert!(
1027            strategy_for_spec(&gauss)
1028                .simulate_noise(&mean, None)
1029                .is_err()
1030        );
1031
1032        // Tweedie power outside (1, 2).
1033        let bad_tweedie = LikelihoodSpec::tweedie_log(2.5);
1034        assert!(NoiseModel::from_likelihood(&bad_tweedie, 4, Some(0.5)).is_err());
1035        assert!(
1036            strategy_for_spec(&bad_tweedie)
1037                .simulate_noise(&mean, Some(0.5))
1038                .is_err()
1039        );
1040
1041        // Gamma shape non-positive.
1042        let gamma = LikelihoodSpec::gamma_log();
1043        assert!(NoiseModel::from_likelihood(&gamma, 4, Some(-1.0)).is_err());
1044        assert!(
1045            strategy_for_spec(&gamma)
1046                .simulate_noise(&mean, Some(-1.0))
1047                .is_err()
1048        );
1049    }
1050}