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};
8use rand::RngExt as _;
9
10/// THE single source of truth for the scalar dispersion the generative
11/// observation model uses for a fitted family — the value handed to
12/// [`NoiseModel::from_likelihood`] / [`generativespec_from_predict`] as
13/// `gaussian_scale`.
14///
15/// For every exponential-dispersion / overdispersed family the dispersion is
16/// **estimated jointly with the mean** and recorded in the fit's
17/// [`LikelihoodScaleMetadata`] (`scale`); the value embedded in the response
18/// spec (`likelihood.response`) is only the construction-time *seed* (e.g.
19/// `theta = 1.0`, `phi = 1.0`), left un-updated after the fit refreshes the
20/// estimate. Generation must therefore read the *fitted* dispersion off `scale`.
21/// Reading the seed was
22/// the shared root cause of a whole family of bugs — Gamma #678, Beta #769/#770,
23/// Tweedie #771, and the NB sibling #1124 (`Var = mu + mu^2` instead of
24/// `mu + mu^2/theta_hat`).
25///
26/// This helper exists in exactly one place precisely because that bug class
27/// recurred: the dispersion-picking logic had been duplicated across the CLI
28/// `gam generate` path and the Python `sample_replicates` path, and fixing one
29/// copy left the other drawing at the seed. Both paths now call this function,
30/// so the set of supported families and the interpretation of each dispersion
31/// parameter can never diverge again. (The per-row dispersion location-scale
32/// path, #913/#1125, is the one exception that bypasses this scalar picker — it
33/// threads a full `exp(eta_d(x))` vector via
34/// [`NoiseModel::from_likelihood_with_per_row_dispersion`] instead.)
35///
36/// `standard_deviation` is used only by a profiled Gaussian. Families without a
37/// scalar noise parameter return `Ok(None)`; unresolved or inconsistent scale
38/// metadata is an error.
39pub fn family_noise_parameter(
40    scale: LikelihoodScaleMetadata,
41    standard_deviation: f64,
42    likelihood: &LikelihoodSpec,
43) -> Result<Option<f64>, EstimationError> {
44    let invalid = |reason: String| {
45        EstimationError::InvalidInput(format!(
46            "{} generative scale is unresolved: {reason}",
47            likelihood.pretty_name()
48        ))
49    };
50    let positive = |name: &str, value: f64| {
51        if value.is_finite() && value > 0.0 {
52            Ok(Some(value))
53        } else {
54            Err(invalid(format!(
55                "{name} must be finite and strictly positive, got {value}"
56            )))
57        }
58    };
59    match (&likelihood.response, scale) {
60        (ResponseFamily::Gaussian, LikelihoodScaleMetadata::ProfiledGaussian) => {
61            if standard_deviation.is_finite() && standard_deviation >= 0.0 {
62                Ok(Some(if standard_deviation == 0.0 {
63                    0.0
64                } else {
65                    standard_deviation
66                }))
67            } else {
68                Err(invalid(format!(
69                    "profiled Gaussian sigma must be finite and non-negative, got {standard_deviation}"
70                )))
71            }
72        }
73        (ResponseFamily::Gaussian, LikelihoodScaleMetadata::FixedDispersion { phi }) => {
74            positive("fixed Gaussian dispersion", phi).map(|_| Some(phi.sqrt()))
75        }
76        // Tweedie: `gaussian_scale` carries the *dispersion* phi; the variance
77        // power `p` is read straight off the family spec by `from_likelihood`.
78        // phi is estimated jointly with the mean (#771), so consult the fit's
79        // scale metadata; unit dispersion is the fit-free fallback.
80        (
81            ResponseFamily::Tweedie { .. },
82            LikelihoodScaleMetadata::EstimatedTweediePhi { phi }
83            | LikelihoodScaleMetadata::FixedDispersion { phi },
84        ) => positive("Tweedie dispersion phi", phi),
85        // NB overdispersion theta is estimated jointly with the mean and stored
86        // as `EstimatedNegBinTheta`; the spec theta is only the seed (#1124).
87        (
88            ResponseFamily::NegativeBinomial {
89                theta: _,
90                theta_fixed: false,
91            },
92            LikelihoodScaleMetadata::EstimatedNegBinTheta {
93                theta: metadata_theta,
94            },
95        )
96        | (
97            ResponseFamily::NegativeBinomial {
98                theta: _,
99                theta_fixed: true,
100            },
101            LikelihoodScaleMetadata::FixedNegBinTheta {
102                theta: metadata_theta,
103            },
104        ) => positive("negative-binomial theta", metadata_theta),
105        // Beta precision phi is estimated jointly with the mean (#567/#770); the
106        // spec phi is only the seed.
107        (
108            ResponseFamily::Beta { .. },
109            LikelihoodScaleMetadata::EstimatedBetaPhi { phi: metadata_phi },
110        ) => positive("Beta precision phi", metadata_phi),
111        // Gamma shape k is estimated jointly with the mean (#678); fall back to
112        // the residual scale only when the fit recorded no shape.
113        (ResponseFamily::Gamma, LikelihoodScaleMetadata::FixedGammaShape { shape })
114        | (ResponseFamily::Gamma, LikelihoodScaleMetadata::EstimatedGammaShape { shape }) => {
115            positive("Gamma shape", shape)
116        }
117        // Gaussian / Poisson / Binomial: the residual scale is the generative
118        // sigma (Poisson/Binomial ignore it downstream).
119        (
120            ResponseFamily::Binomial | ResponseFamily::Poisson,
121            LikelihoodScaleMetadata::FixedDispersion { phi },
122        ) if phi.to_bits() == 1.0_f64.to_bits() => Ok(None),
123        (ResponseFamily::RoystonParmar, _) => Err(invalid(
124            "Royston-Parmar has no generic scalar generative noise parameter".to_string(),
125        )),
126        (_, metadata) => Err(invalid(format!(
127            "family and likelihood-scale metadata are inconsistent: {metadata:?}"
128        ))),
129    }
130}
131
132/// Observation-noise model used for generative sampling.
133#[derive(Clone, Debug)]
134pub enum NoiseModel {
135    Gaussian {
136        /// Per-observation standard deviation.
137        sigma: Array1<f64>,
138    },
139    Poisson,
140    Tweedie {
141        p: f64,
142        /// Per-observation dispersion φ (> 0). A scalar-dispersion fit broadcasts
143        /// one value to every row; a dispersion location-scale fit (#913/#1125)
144        /// supplies the fitted per-row φ = 1/exp(eta_d(x)).
145        phi: Array1<f64>,
146    },
147    NegativeBinomial {
148        /// Per-observation overdispersion θ (> 0); see `Tweedie::phi`.
149        theta: Array1<f64>,
150    },
151    Beta {
152        /// Per-observation precision φ (> 0); see `Tweedie::phi`.
153        phi: Array1<f64>,
154    },
155    Gamma {
156        /// Per-observation Gamma shape k (> 0), with mean-driven scale; see
157        /// `Tweedie::phi`.
158        shape: Array1<f64>,
159    },
160    Bernoulli,
161    /// Row-specific categorical response law.
162    ///
163    /// `probabilities[[i, j]]` is the fitted probability that observation `i`
164    /// takes `labels[j]`. This is the natural saved-response representation for
165    /// competing-risk event-window generation: label zero means no event in the
166    /// requested window and positive labels identify the persisted causes.
167    Categorical {
168        probabilities: Array2<f64>,
169        labels: Array1<f64>,
170    },
171    /// Inverse-transform sampling for a conditional transformation-normal (CTM)
172    /// model (issue #1613). The fitted latent transform `h(·|x_i)` is strictly
173    /// increasing in `y` and `h(Y|x) ~ N(0, 1)`, so a response-scale draw is
174    /// `Y = h⁻¹(Z | x_i)` with `Z ~ N(0, 1)`. The earlier generate path drew
175    /// Gaussian noise around the mean, which produced latent-scale draws whose
176    /// per-row mean moved the wrong way with the covariate; this variant instead
177    /// samples from the genuine conditional law `F(·|x)`.
178    ///
179    /// Both this sampler and the response-scale conditional mean `E[Y|x]` used by
180    /// `predict` (#1612) invert the SAME per-row monotone curve, materialized on
181    /// a shared response grid, so the two paths cannot disagree on the underlying
182    /// transform.
183    TransformationNormalQuantile {
184        /// Shared, strictly increasing response grid (length `g ≥ 2`).
185        grid_y: Array1<f64>,
186        /// `h_grid[[i, k]] = h(grid_y[k] | x_i)`, strictly increasing in `k` for
187        /// every row `i` (one row per observation).
188        h_grid: Array2<f64>,
189    },
190}
191
192/// Invert a monotone increasing tabulated function `z = h_row(grid_y)` at the
193/// latent value `target`: find the bracketing grid interval and linearly
194/// interpolate `y`. Values below/above the tabulated range map to the support
195/// endpoints (the finite-support tails carry no mass between the clamp and the
196/// endpoint). This is the same bracketing inversion the CTM `predict` mean
197/// (#1612) uses on its quadrature nodes, applied here to a random latent draw.
198fn invert_monotone_grid(
199    grid_y: &Array1<f64>,
200    h_row: ndarray::ArrayView1<'_, f64>,
201    target: f64,
202) -> f64 {
203    let g = grid_y.len();
204    if target <= h_row[0] {
205        return grid_y[0];
206    }
207    if target >= h_row[g - 1] {
208        return grid_y[g - 1];
209    }
210    let mut lo = 0usize;
211    let mut hi = g - 1;
212    while hi - lo > 1 {
213        let mid = (lo + hi) / 2;
214        if h_row[mid] <= target {
215            lo = mid;
216        } else {
217            hi = mid;
218        }
219    }
220    let t = (target - h_row[lo]) / (h_row[hi] - h_row[lo]);
221    grid_y[lo] + t * (grid_y[hi] - grid_y[lo])
222}
223
224/// First-class generative specification: mean process + observation noise.
225#[derive(Clone, Debug)]
226pub struct GenerativeSpec {
227    pub mean: Array1<f64>,
228    pub noise: NoiseModel,
229}
230
231impl GenerativeSpec {
232    /// Number of observations `n` in the mean vector, matching the row
233    /// count of the design used to produce this generative specification.
234    pub fn nobs(&self) -> usize {
235        self.mean.len()
236    }
237}
238
239/// Build a generative specification for built-in GAM families from eta/mean.
240pub fn generativespec_from_predict(
241    prediction: PredictResult,
242    likelihood: LikelihoodSpec,
243    gaussian_scale: Option<f64>,
244    prior_weights: Option<&Array1<f64>>,
245) -> Result<GenerativeSpec, EstimationError> {
246    let mut noise =
247        NoiseModel::from_likelihood(&likelihood, prediction.mean.len(), gaussian_scale)?;
248    // Analytic prior weights define `Var(y_i) = sigma^2 / w_i` for a weighted
249    // Gaussian fit, so replicate observation noise is heteroskedastic:
250    // `sigma_i = sigma_hat / sqrt(w_i)`. `from_likelihood` broadcasts the pooled
251    // scalar `sigma_hat` to every row (the correct value for an unweighted fit),
252    // so rescale it per row here whenever the fit carried prior weights (#2025).
253    // Only the Gaussian arm exposes a location-scale `sigma`; the other families
254    // encode dispersion through their own precision parameter and analytic prior
255    // weights do not enter their observation draw, so they are left untouched.
256    if let (NoiseModel::Gaussian { sigma }, Some(weights)) = (&mut noise, prior_weights) {
257        scale_gaussian_sigma_by_prior_weights(sigma, weights)?;
258    }
259    Ok(GenerativeSpec {
260        mean: prediction.mean,
261        noise,
262    })
263}
264
265/// Rescale a broadcast Gaussian `sigma_hat` vector into the per-row analytic-weight
266/// observation scale `sigma_i = sigma_hat / sqrt(w_i)` (#2025). The prior weights
267/// `w_i` are the same non-negative weights the fit consumed; a zero or non-finite
268/// weight has no finite observation variance under the analytic-weight model, so it
269/// is rejected rather than silently producing an infinite draw scale.
270fn scale_gaussian_sigma_by_prior_weights(
271    sigma: &mut Array1<f64>,
272    weights: &Array1<f64>,
273) -> Result<(), EstimationError> {
274    if weights.len() != sigma.len() {
275        crate::bail_invalid_estim!(
276            "prior weights length {} does not match observation count {}",
277            weights.len(),
278            sigma.len()
279        );
280    }
281    for (s, &w) in sigma.iter_mut().zip(weights.iter()) {
282        if !(w.is_finite() && w > 0.0) {
283            crate::bail_invalid_estim!(
284                "Gaussian replicate prior weights must be finite and > 0; got {w}"
285            );
286        }
287        *s /= w.sqrt();
288    }
289    Ok(())
290}
291
292impl NoiseModel {
293    /// Single canonical mapping from a fitted `LikelihoodSpec` (response
294    /// distribution + dispersion `gaussian_scale`) to the observation
295    /// `NoiseModel` used for generative sampling. Both simulation
296    /// (`FamilyStrategy::simulate_noise`) and generative inference
297    /// (`generativespec_from_predict`) route through this one helper so the
298    /// set of supported likelihoods and the interpretation of dispersion
299    /// parameters can never diverge between the two paths.
300    ///
301    /// `nobs` is the number of observations the resulting per-observation
302    /// Gaussian `sigma` vector should span; it is ignored for families whose
303    /// noise carries no per-observation state.
304    pub fn from_likelihood(
305        likelihood: &LikelihoodSpec,
306        nobs: usize,
307        gaussian_scale: Option<f64>,
308    ) -> Result<NoiseModel, EstimationError> {
309        match &likelihood.response {
310            ResponseFamily::Gaussian => {
311                let sigma =
312                    Self::require_noise_parameter(likelihood, "Gaussian sigma", gaussian_scale)?;
313                if sigma < 0.0 {
314                    crate::bail_invalid_estim!(
315                        "{} generative sampling requires Gaussian sigma >= 0; got {sigma}",
316                        likelihood.pretty_name()
317                    );
318                }
319                Ok(NoiseModel::Gaussian {
320                    sigma: Array1::from_elem(nobs, sigma),
321                })
322            }
323            ResponseFamily::Binomial => Ok(NoiseModel::Bernoulli),
324            ResponseFamily::Poisson => Ok(NoiseModel::Poisson),
325            ResponseFamily::Tweedie { p } => {
326                let p = *p;
327                if !is_valid_tweedie_power(p) {
328                    crate::bail_invalid_estim!(
329                        "Tweedie variance power must be finite and strictly between 1 and 2; got {p}"
330                    );
331                }
332                let phi = Self::require_positive_noise_parameter(
333                    likelihood,
334                    "Tweedie dispersion phi",
335                    gaussian_scale,
336                )?;
337                Ok(NoiseModel::Tweedie {
338                    p,
339                    // Scalar-dispersion fit: broadcast one φ to every row. The
340                    // dispersion location-scale path (#1125) builds the per-row
341                    // vector directly in `run_generate_unified` instead.
342                    phi: Array1::from_elem(nobs, phi),
343                })
344            }
345            ResponseFamily::NegativeBinomial { .. } => {
346                // The NB overdispersion θ is estimated jointly with the mean and
347                // the authoritative post-fit value is handed in as
348                // `gaussian_scale` (from `likelihood_scale.negbin_theta()`);
349                // the θ embedded in the response spec is only the seed (1.0).
350                // Reading the seed was the NB sibling of the Beta #770 bug:
351                // generate drew Var = μ + μ² (θ = 1) regardless of the fitted
352                // overdispersion (#1124). Mirror the Beta arm below.
353                let theta = Self::require_positive_noise_parameter(
354                    likelihood,
355                    "negative-binomial theta",
356                    gaussian_scale,
357                )?;
358                Ok(NoiseModel::NegativeBinomial {
359                    theta: Array1::from_elem(nobs, theta),
360                })
361            }
362            ResponseFamily::Beta { .. } => {
363                // The Beta precision φ is estimated jointly with the mean
364                // (issue #567), so the authoritative value after fitting is the
365                // dispersion handed in as `gaussian_scale` — exactly as Gamma's
366                // shape and Tweedie's φ already take theirs. The `phi` embedded
367                // in the response spec is only the construction-time *seed* (left
368                // at its original value, e.g. 1.0, after the fit refreshes the
369                // estimate in `likelihood_scale`), so it serves solely as a
370                // fallback for fit-free construction where no fitted dispersion
371                // is supplied. Reading the seed instead of `gaussian_scale` was
372                // issue #770: the generative/observation path drew Beta responses
373                // with φ = 1.0 regardless of the data — nearly uniform on (0,1),
374                // ~20× too much variance — even though the fit estimated φ and
375                // the caller forwarded it here.
376                let phi = Self::require_positive_noise_parameter(
377                    likelihood,
378                    "beta-regression phi",
379                    gaussian_scale,
380                )?;
381                Ok(NoiseModel::Beta {
382                    phi: Array1::from_elem(nobs, phi),
383                })
384            }
385            ResponseFamily::Gamma => {
386                let shape = Self::require_positive_noise_parameter(
387                    likelihood,
388                    "Gamma shape",
389                    gaussian_scale,
390                )?;
391                Ok(NoiseModel::Gamma {
392                    shape: Array1::from_elem(nobs, shape),
393                })
394            }
395            ResponseFamily::RoystonParmar => Err(EstimationError::InvalidInput(
396                "RoystonParmar generative sampling is not exposed via generic generation"
397                    .to_string(),
398            )),
399        }
400    }
401
402    /// Build the observation `NoiseModel` for a dispersion location-scale fit
403    /// (#1125) from a fitted PER-ROW dispersion surface `dispersion[i]` (the
404    /// predictor's `exp(eta_d(x_i))` mapped into NoiseModel units — NB θ, Gamma
405    /// shape, Beta φ directly, Tweedie φ as the reciprocal). Unlike
406    /// `from_likelihood`, which broadcasts a single scalar dispersion to every
407    /// row, this threads the genuine per-observation precision channel so
408    /// generated data reproduces the fitted non-constant dispersion instead of
409    /// coming out homoscedastic at the seed.
410    pub fn from_likelihood_with_per_row_dispersion(
411        likelihood: &LikelihoodSpec,
412        dispersion: Array1<f64>,
413    ) -> Result<NoiseModel, EstimationError> {
414        for (index, &value) in dispersion.iter().enumerate() {
415            if !(value.is_finite() && value > 0.0) {
416                crate::bail_invalid_estim!(
417                    "{} per-row generative dispersion at index {index} must be finite and strictly positive, got {value}",
418                    likelihood.pretty_name()
419                );
420            }
421        }
422        match &likelihood.response {
423            ResponseFamily::Tweedie { p } => {
424                let p = *p;
425                if !is_valid_tweedie_power(p) {
426                    crate::bail_invalid_estim!(
427                        "Tweedie variance power must be finite and strictly between 1 and 2; got {p}"
428                    );
429                }
430                Ok(NoiseModel::Tweedie { p, phi: dispersion })
431            }
432            ResponseFamily::NegativeBinomial { .. } => {
433                Ok(NoiseModel::NegativeBinomial { theta: dispersion })
434            }
435            ResponseFamily::Beta { .. } => Ok(NoiseModel::Beta { phi: dispersion }),
436            ResponseFamily::Gamma => Ok(NoiseModel::Gamma { shape: dispersion }),
437            other => Err(EstimationError::InvalidInput(format!(
438                "per-row dispersion generative sampling is only defined for the dispersion \
439                 location-scale families (Gamma/NegativeBinomial/Beta/Tweedie); got {other:?}"
440            ))),
441        }
442    }
443
444    fn require_noise_parameter(
445        likelihood: &LikelihoodSpec,
446        parameter_name: &str,
447        value: Option<f64>,
448    ) -> Result<f64, EstimationError> {
449        let value = value.ok_or_else(|| {
450            EstimationError::InvalidInput(format!(
451                "{} generative sampling requires fitted {parameter_name}",
452                likelihood.pretty_name()
453            ))
454        })?;
455        if value.is_finite() {
456            Ok(value)
457        } else {
458            Err(EstimationError::InvalidInput(format!(
459                "{} generative sampling requires finite {parameter_name}; got {value}",
460                likelihood.pretty_name()
461            )))
462        }
463    }
464
465    fn require_positive_noise_parameter(
466        likelihood: &LikelihoodSpec,
467        parameter_name: &str,
468        value: Option<f64>,
469    ) -> Result<f64, EstimationError> {
470        let value = Self::require_noise_parameter(likelihood, parameter_name, value)?;
471        if value > 0.0 {
472            Ok(value)
473        } else {
474            Err(EstimationError::InvalidInput(format!(
475                "{} generative sampling requires {parameter_name} > 0; got {value}",
476                likelihood.pretty_name()
477            )))
478        }
479    }
480}
481
482/// Validate that a per-observation dispersion vector matches the mean length.
483/// Scalar-dispersion fits broadcast one value across all rows (length `n`);
484/// dispersion location-scale fits (#1125) carry the genuine per-row vector.
485fn check_dispersion_len(
486    dispersion: &Array1<f64>,
487    nobs: usize,
488    name: &str,
489) -> Result<(), EstimationError> {
490    if dispersion.len() != nobs {
491        crate::bail_invalid_estim!(
492            "{name} length {} does not match mean length {nobs}",
493            dispersion.len()
494        );
495    }
496    Ok(())
497}
498
499/// Draw one synthetic observation vector from a generative spec.
500pub fn sampleobservations<R: rand::Rng + ?Sized>(
501    spec: &GenerativeSpec,
502    rng: &mut R,
503) -> Result<Array1<f64>, EstimationError> {
504    if spec.mean.iter().any(|m| !m.is_finite()) {
505        crate::bail_invalid_estim!("generative mean contains non-finite values");
506    }
507    match &spec.noise {
508        NoiseModel::Gaussian { sigma } => {
509            if sigma.len() != spec.mean.len() {
510                crate::bail_invalid_estim!(
511                    "Gaussian sigma length {} does not match mean length {}",
512                    sigma.len(),
513                    spec.mean.len()
514                );
515            }
516            let mut y = spec.mean.clone();
517            for i in 0..y.len() {
518                let sd = sigma[i];
519                if !(sd.is_finite() && sd >= 0.0) {
520                    crate::bail_invalid_estim!(
521                        "Gaussian sigma at row {i} must be finite and non-negative, got {sd}"
522                    );
523                }
524                if sd == 0.0 {
525                    continue;
526                }
527                let dist = rand_distr::Normal::new(0.0, sd).map_err(|e| {
528                    EstimationError::InvalidInput(format!("invalid Gaussian noise scale {sd}: {e}"))
529                })?;
530                y[i] += rand_distr::Distribution::sample(&dist, rng);
531            }
532            Ok(y)
533        }
534        NoiseModel::Poisson => {
535            let mut y = Array1::<f64>::zeros(spec.mean.len());
536            for i in 0..y.len() {
537                let lam = spec.mean[i];
538                if lam < 0.0 {
539                    crate::bail_invalid_estim!(
540                        "Poisson mean at row {i} must be non-negative, got {lam}"
541                    );
542                }
543                if lam == 0.0 {
544                    continue;
545                }
546                let dist = rand_distr::Poisson::new(lam).map_err(|e| {
547                    EstimationError::InvalidInput(format!("invalid Poisson rate {lam}: {e}"))
548                })?;
549                let draw = rand_distr::Distribution::sample(&dist, rng);
550                y[i] = draw;
551            }
552            Ok(y)
553        }
554        NoiseModel::Tweedie { p, phi } => {
555            if !(p.is_finite() && *p >= 1.0 && *p <= 2.0) {
556                crate::bail_invalid_estim!("invalid Tweedie power p: {p}");
557            }
558            check_dispersion_len(phi, spec.mean.len(), "Tweedie dispersion phi")?;
559            for (i, &phi_i) in phi.iter().enumerate() {
560                if !(phi_i.is_finite() && phi_i > 0.0) {
561                    crate::bail_invalid_estim!(
562                        "invalid Tweedie dispersion phi at row {i}: {phi_i}"
563                    );
564                }
565            }
566            let mut y = Array1::<f64>::zeros(spec.mean.len());
567            if (*p - 1.0).abs() <= 1.0e-12 {
568                for i in 0..y.len() {
569                    let phi_i = phi[i];
570                    let mu = spec.mean[i];
571                    if mu < 0.0 {
572                        crate::bail_invalid_estim!(
573                            "Tweedie-Poisson mean at row {i} must be non-negative, got {mu}"
574                        );
575                    }
576                    if mu == 0.0 {
577                        continue;
578                    }
579                    let lam = mu / phi_i;
580                    if !(lam.is_finite() && lam > 0.0) {
581                        crate::bail_invalid_estim!(
582                            "Tweedie-Poisson rate at row {i} is not representable: {mu}/{phi_i}"
583                        );
584                    }
585                    let dist = rand_distr::Poisson::new(lam).map_err(|e| {
586                        EstimationError::InvalidInput(format!(
587                            "invalid Tweedie-Poisson rate {lam}: {e}"
588                        ))
589                    })?;
590                    y[i] = phi_i * rand_distr::Distribution::sample(&dist, rng);
591                }
592                return Ok(y);
593            }
594            if (*p - 2.0).abs() <= 1.0e-12 {
595                for i in 0..y.len() {
596                    let phi_i = phi[i];
597                    let mu = spec.mean[i];
598                    if mu < 0.0 {
599                        crate::bail_invalid_estim!(
600                            "Tweedie-Gamma mean at row {i} must be non-negative, got {mu}"
601                        );
602                    }
603                    if mu == 0.0 {
604                        continue;
605                    }
606                    let shape = 1.0 / phi_i;
607                    let scale = mu * phi_i;
608                    if !(shape.is_finite() && shape > 0.0) {
609                        crate::bail_invalid_estim!(
610                            "Tweedie-Gamma reciprocal dispersion at row {i} is not representable: 1/{phi_i}"
611                        );
612                    }
613                    if !(scale.is_finite() && scale > 0.0) {
614                        crate::bail_invalid_estim!(
615                            "Tweedie-Gamma scale at row {i} is not representable: {mu}*{phi_i}"
616                        );
617                    }
618                    let dist = rand_distr::Gamma::new(shape, scale).map_err(|e| {
619                        EstimationError::InvalidInput(format!(
620                            "invalid Tweedie-Gamma params shape={shape} scale={scale}: {e}"
621                        ))
622                    })?;
623                    y[i] = rand_distr::Distribution::sample(&dist, rng);
624                }
625                return Ok(y);
626            }
627            let alpha = (2.0 - *p) / (*p - 1.0);
628            for i in 0..y.len() {
629                let phi_i = phi[i];
630                let mu = spec.mean[i];
631                if mu < 0.0 {
632                    crate::bail_invalid_estim!(
633                        "Tweedie mean at row {i} must be non-negative, got {mu}"
634                    );
635                }
636                if mu == 0.0 {
637                    continue;
638                }
639                let log_lambda = (2.0 - *p) * mu.ln() - phi_i.ln() - (2.0 - *p).ln();
640                let log_scale = phi_i.ln() + (*p - 1.0).ln() + (*p - 1.0) * mu.ln();
641                let lambda = log_lambda.exp();
642                let scale = log_scale.exp();
643                if !(lambda.is_finite() && lambda > 0.0) {
644                    crate::bail_invalid_estim!(
645                        "Tweedie compound-Poisson rate at row {i} is not representable (log rate {log_lambda})"
646                    );
647                }
648                if !(scale.is_finite() && scale > 0.0) {
649                    crate::bail_invalid_estim!(
650                        "Tweedie jump scale at row {i} is not representable (log scale {log_scale})"
651                    );
652                }
653                let count_dist = rand_distr::Poisson::new(lambda).map_err(|e| {
654                    EstimationError::InvalidInput(format!(
655                        "invalid Tweedie compound-Poisson rate {lambda}: {e}"
656                    ))
657                })?;
658                let count = rand_distr::Distribution::sample(&count_dist, rng) as usize;
659                if count == 0 {
660                    continue;
661                }
662                let jump_dist = rand_distr::Gamma::new(alpha, scale).map_err(|e| {
663                    EstimationError::InvalidInput(format!(
664                        "invalid Tweedie jump params shape={alpha} scale={scale}: {e}"
665                    ))
666                })?;
667                y[i] = (0..count)
668                    .map(|_| rand_distr::Distribution::sample(&jump_dist, rng))
669                    .sum();
670            }
671            Ok(y)
672        }
673        NoiseModel::NegativeBinomial { theta } => {
674            check_dispersion_len(theta, spec.mean.len(), "NegativeBinomial theta")?;
675            let mut y = Array1::<f64>::zeros(spec.mean.len());
676            for i in 0..y.len() {
677                let theta_i = theta[i];
678                if !(theta_i.is_finite() && theta_i > 0.0) {
679                    crate::bail_invalid_estim!(
680                        "invalid negative-binomial theta at row {i}: {theta_i}"
681                    );
682                }
683                let mu = spec.mean[i];
684                if mu < 0.0 {
685                    crate::bail_invalid_estim!(
686                        "negative-binomial mean at row {i} must be non-negative, got {mu}"
687                    );
688                }
689                if mu == 0.0 {
690                    continue;
691                }
692                let scale = mu / theta_i;
693                if !(scale.is_finite() && scale > 0.0) {
694                    crate::bail_invalid_estim!(
695                        "negative-binomial Gamma-mixture scale at row {i} is not representable: {mu}/{theta_i}"
696                    );
697                }
698                let gamma = rand_distr::Gamma::new(theta_i, scale).map_err(|e| {
699                    EstimationError::InvalidInput(format!(
700                        "invalid NegativeBinomial gamma mixture params theta={theta_i} scale={scale}: {e}"
701                    ))
702                })?;
703                let lambda = rand_distr::Distribution::sample(&gamma, rng);
704                if lambda == 0.0 {
705                    continue;
706                }
707                if !lambda.is_finite() {
708                    crate::bail_invalid_estim!(
709                        "negative-binomial latent Poisson rate at row {i} is non-finite"
710                    );
711                }
712                let poisson = rand_distr::Poisson::new(lambda).map_err(|e| {
713                    EstimationError::InvalidInput(format!(
714                        "invalid NegativeBinomial Poisson rate {lambda}: {e}"
715                    ))
716                })?;
717                y[i] = rand_distr::Distribution::sample(&poisson, rng);
718            }
719            Ok(y)
720        }
721        NoiseModel::Beta { phi } => {
722            check_dispersion_len(phi, spec.mean.len(), "Beta phi")?;
723            let mut y = Array1::<f64>::zeros(spec.mean.len());
724            for i in 0..y.len() {
725                let phi_i = phi[i];
726                if !(phi_i.is_finite() && phi_i > 0.0) {
727                    crate::bail_invalid_estim!("invalid beta-regression phi at row {i}: {phi_i}");
728                }
729                let mu = spec.mean[i];
730                if !(mu > 0.0 && mu < 1.0) {
731                    crate::bail_invalid_estim!(
732                        "Beta mean at row {i} must lie strictly in (0, 1), got {mu}"
733                    );
734                }
735                let alpha = mu * phi_i;
736                let beta = (1.0 - mu) * phi_i;
737                if !(alpha.is_finite() && alpha > 0.0 && beta.is_finite() && beta > 0.0) {
738                    crate::bail_invalid_estim!(
739                        "Beta shape parameters at row {i} are not representable: alpha={alpha}, beta={beta}"
740                    );
741                }
742                let dist = rand_distr::Beta::new(alpha, beta).map_err(|e| {
743                    EstimationError::InvalidInput(format!(
744                        "invalid Beta params alpha={alpha} beta={beta}: {e}"
745                    ))
746                })?;
747                y[i] = rand_distr::Distribution::sample(&dist, rng);
748            }
749            Ok(y)
750        }
751        NoiseModel::Gamma { shape } => {
752            check_dispersion_len(shape, spec.mean.len(), "Gamma shape")?;
753            let mut y = Array1::<f64>::zeros(spec.mean.len());
754            for i in 0..y.len() {
755                let shape_i = shape[i];
756                if !shape_i.is_finite() || shape_i <= 0.0 {
757                    crate::bail_invalid_estim!("invalid Gamma shape at row {i}: {shape_i}");
758                }
759                let mu = spec.mean[i];
760                if !(mu > 0.0) {
761                    crate::bail_invalid_estim!(
762                        "Gamma mean at row {i} must be strictly positive, got {mu}"
763                    );
764                }
765                let scale = mu / shape_i;
766                if !(scale.is_finite() && scale > 0.0) {
767                    crate::bail_invalid_estim!(
768                        "Gamma scale at row {i} is not representable: {mu}/{shape_i}"
769                    );
770                }
771                let dist = rand_distr::Gamma::new(shape_i, scale).map_err(|e| {
772                    EstimationError::InvalidInput(format!(
773                        "invalid Gamma params shape={shape_i} scale={scale}: {e}"
774                    ))
775                })?;
776                y[i] = rand_distr::Distribution::sample(&dist, rng);
777            }
778            Ok(y)
779        }
780        NoiseModel::Bernoulli => {
781            let mut y = Array1::<f64>::zeros(spec.mean.len());
782            for i in 0..y.len() {
783                let p = spec.mean[i];
784                let dist = rand_distr::Bernoulli::new(p).map_err(|e| {
785                    EstimationError::InvalidInput(format!("invalid Bernoulli probability {p}: {e}"))
786                })?;
787                y[i] = if rand_distr::Distribution::sample(&dist, rng) {
788                    1.0
789                } else {
790                    0.0
791                };
792            }
793            Ok(y)
794        }
795        NoiseModel::Categorical {
796            probabilities,
797            labels,
798        } => {
799            let n = spec.mean.len();
800            if probabilities.nrows() != n {
801                crate::bail_invalid_estim!(
802                    "categorical probability rows {} do not match mean length {n}",
803                    probabilities.nrows()
804                );
805            }
806            if labels.is_empty() || probabilities.ncols() != labels.len() {
807                crate::bail_invalid_estim!(
808                    "categorical label/probability width mismatch: labels={}, columns={}",
809                    labels.len(),
810                    probabilities.ncols()
811                );
812            }
813            if labels.iter().any(|label| !label.is_finite()) {
814                crate::bail_invalid_estim!("categorical labels must be finite");
815            }
816            let mut y = Array1::<f64>::zeros(n);
817            for row in 0..n {
818                let probability_row = probabilities.row(row);
819                let mut total = 0.0_f64;
820                for (category, &probability) in probability_row.iter().enumerate() {
821                    if !(probability.is_finite() && probability >= 0.0) {
822                        crate::bail_invalid_estim!(
823                            "categorical probability at row {row}, category {category} must be finite and non-negative, got {probability}"
824                        );
825                    }
826                    total += probability;
827                }
828                let tolerance = 64.0 * f64::EPSILON * labels.len().max(1) as f64;
829                if !(total.is_finite() && (total - 1.0).abs() <= tolerance) {
830                    crate::bail_invalid_estim!(
831                        "categorical probabilities at row {row} sum to {total}, expected one within {tolerance}"
832                    );
833                }
834                let uniform = rng.random::<f64>();
835                let mut cumulative = 0.0_f64;
836                let mut selected = labels.len() - 1;
837                for category in 0..labels.len() - 1 {
838                    cumulative += probability_row[category];
839                    if uniform < cumulative {
840                        selected = category;
841                        break;
842                    }
843                }
844                y[row] = labels[selected];
845            }
846            Ok(y)
847        }
848        NoiseModel::TransformationNormalQuantile { grid_y, h_grid } => {
849            let n = spec.mean.len();
850            if h_grid.nrows() != n {
851                crate::bail_invalid_estim!(
852                    "transformation-normal h_grid has {} rows but mean length is {n}",
853                    h_grid.nrows()
854                );
855            }
856            let g = grid_y.len();
857            if g < 2 || h_grid.ncols() != g {
858                crate::bail_invalid_estim!(
859                    "transformation-normal grid is degenerate: grid_y len {g}, h_grid cols {}",
860                    h_grid.ncols()
861                );
862            }
863            // `h(Y|x) ~ N(0,1)` ⇒ a response-scale draw is `Y = h⁻¹(Z | x)`,
864            // `Z ~ N(0,1)`. One independent latent draw per observation, inverted
865            // through that row's monotone transform.
866            let dist = rand_distr::Normal::new(0.0, 1.0).map_err(|e| {
867                EstimationError::InvalidInput(format!(
868                    "invalid standard-normal latent sampler: {e}"
869                ))
870            })?;
871            let mut y = Array1::<f64>::zeros(n);
872            for i in 0..n {
873                let z: f64 = rand_distr::Distribution::sample(&dist, rng);
874                y[i] = invert_monotone_grid(grid_y, h_grid.row(i), z);
875            }
876            Ok(y)
877        }
878    }
879}
880
881/// Draw replicate chunks in deterministic draw order without materializing the
882/// full `n_draws × nobs` matrix.
883///
884/// The same RNG is advanced exactly once per observation draw regardless of
885/// `chunk_draws`, so changing the chunk size changes only memory and sink call
886/// boundaries, never the generated values. Frontends that write a file or
887/// yield an iterator should use this API; collecting the full matrix is an
888/// explicit convenience operation implemented below.
889pub fn sampleobservation_replicate_chunks<R, F>(
890    spec: &GenerativeSpec,
891    n_draws: usize,
892    chunk_draws: usize,
893    rng: &mut R,
894    mut consume: F,
895) -> Result<(), EstimationError>
896where
897    R: rand::Rng + ?Sized,
898    F: for<'a> FnMut(usize, ndarray::ArrayView2<'a, f64>) -> Result<(), EstimationError>,
899{
900    if chunk_draws == 0 {
901        crate::bail_invalid_estim!("replicate chunk size must be strictly positive");
902    }
903    if n_draws == 0 {
904        return Ok(());
905    }
906    let n = spec.nobs();
907    let capacity = chunk_draws.min(n_draws);
908    let mut chunk = Array2::<f64>::zeros((capacity, n));
909    let mut start = 0usize;
910    while start < n_draws {
911        let len = (n_draws - start).min(capacity);
912        for local_draw in 0..len {
913            let draw = sampleobservations(spec, rng)?;
914            chunk.row_mut(local_draw).assign(&draw);
915        }
916        consume(start, chunk.slice(ndarray::s![..len, ..]))?;
917        start += len;
918    }
919    Ok(())
920}
921
922/// Derive the independent RNG seed for one globally indexed replicate.
923///
924/// SplitMix64's published integer mixer gives every `(seed, draw_index)` pair
925/// one stable stream without advancing through preceding draws. This makes a
926/// saved-model replicate stream seekable: Python/CLI consumers can request
927/// disjoint chunks, retry a chunk, or change chunk size without changing any
928/// value at a given global draw index.
929#[inline]
930fn indexed_replicate_seed(seed: u64, draw_index: u64) -> u64 {
931    let mut value =
932        seed.wrapping_add(0x9E3779B97F4A7C15_u64.wrapping_mul(draw_index.wrapping_add(1)));
933    value = (value ^ (value >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
934    value = (value ^ (value >> 27)).wrapping_mul(0x94D049BB133111EB);
935    value ^ (value >> 31)
936}
937
938/// Draw a seekable range of independently seeded replicate chunks.
939///
940/// `draw_start` is the global draw index and `n_draws` is the range length.
941/// Values are a pure function of `(spec, seed, global_draw, observation)`, so
942/// separate calls over adjacent ranges concatenate bit-for-bit to a single
943/// call over their union. The sink receives global, not range-local, starts.
944pub fn sampleobservation_seeded_replicate_chunks<F>(
945    spec: &GenerativeSpec,
946    draw_start: usize,
947    n_draws: usize,
948    chunk_draws: usize,
949    seed: u64,
950    mut consume: F,
951) -> Result<(), EstimationError>
952where
953    F: for<'a> FnMut(usize, ndarray::ArrayView2<'a, f64>) -> Result<(), EstimationError>,
954{
955    use rand::SeedableRng;
956
957    if chunk_draws == 0 {
958        crate::bail_invalid_estim!("replicate chunk size must be strictly positive");
959    }
960    let draw_end = draw_start.checked_add(n_draws).ok_or_else(|| {
961        EstimationError::InvalidInput(format!(
962            "replicate draw range overflows usize: start={draw_start}, count={n_draws}"
963        ))
964    })?;
965    if n_draws == 0 {
966        return Ok(());
967    }
968    let n = spec.nobs();
969    let capacity = chunk_draws.min(n_draws);
970    let mut chunk = Array2::<f64>::zeros((capacity, n));
971    let mut start = draw_start;
972    while start < draw_end {
973        let len = (draw_end - start).min(capacity);
974        for local_draw in 0..len {
975            let global_draw = start + local_draw;
976            let global_draw_u64 = u64::try_from(global_draw).map_err(|_| {
977                EstimationError::InvalidInput(format!(
978                    "replicate draw index {global_draw} is not representable as u64"
979                ))
980            })?;
981            let mut rng =
982                rand::rngs::StdRng::seed_from_u64(indexed_replicate_seed(seed, global_draw_u64));
983            let draw = sampleobservations(spec, &mut rng)?;
984            chunk.row_mut(local_draw).assign(&draw);
985        }
986        consume(start, chunk.slice(ndarray::s![..len, ..]))?;
987        start += len;
988    }
989    Ok(())
990}
991
992/// Collect a seekable range into an allocating `n_draws × nobs` matrix.
993pub fn sampleobservation_seeded_replicates(
994    spec: &GenerativeSpec,
995    draw_start: usize,
996    n_draws: usize,
997    seed: u64,
998) -> Result<Array2<f64>, EstimationError> {
999    let mut out = Array2::<f64>::zeros((n_draws, spec.nobs()));
1000    sampleobservation_seeded_replicate_chunks(
1001        spec,
1002        draw_start,
1003        n_draws,
1004        n_draws.max(1),
1005        seed,
1006        |global_start, chunk| {
1007            let local_start = global_start - draw_start;
1008            let local_end = local_start + chunk.nrows();
1009            out.slice_mut(ndarray::s![local_start..local_end, ..])
1010                .assign(&chunk);
1011            Ok(())
1012        },
1013    )?;
1014    Ok(out)
1015}
1016
1017/// Collect multiple synthetic replicates into an `n_draws × nobs` matrix.
1018///
1019/// This is intentionally the allocating convenience surface. Streaming
1020/// consumers should call [`sampleobservation_replicate_chunks`] directly.
1021pub fn sampleobservation_replicates<R: rand::Rng + ?Sized>(
1022    spec: &GenerativeSpec,
1023    n_draws: usize,
1024    rng: &mut R,
1025) -> Result<Array2<f64>, EstimationError> {
1026    let n = spec.nobs();
1027    let mut out = Array2::<f64>::zeros((n_draws, n));
1028    sampleobservation_replicate_chunks(spec, n_draws, n_draws.max(1), rng, |start, chunk| {
1029        let end = start + chunk.nrows();
1030        out.slice_mut(ndarray::s![start..end, ..]).assign(&chunk);
1031        Ok(())
1032    })?;
1033    Ok(out)
1034}
1035
1036/// Extension trait for custom multi-block families that provide explicit
1037/// generative semantics (mean + observation noise) at a fitted state.
1038pub trait CustomFamilyGenerative: CustomFamily {
1039    fn generativespec(
1040        &self,
1041        block_states: &[ParameterBlockState],
1042    ) -> Result<GenerativeSpec, String>;
1043}
1044
1045#[cfg(test)]
1046mod tests {
1047    use super::*;
1048    use crate::family_runtime::{FamilyStrategy, strategy_for_spec};
1049
1050    #[test]
1051    fn categorical_sampler_draws_only_persisted_labels() {
1052        use rand::SeedableRng;
1053
1054        let spec = GenerativeSpec {
1055            mean: ndarray::array![1.5, 0.0],
1056            noise: NoiseModel::Categorical {
1057                probabilities: ndarray::array![[0.25, 0.75], [1.0, 0.0]],
1058                labels: ndarray::array![0.0, 2.0],
1059            },
1060        };
1061        let mut rng = rand::rngs::StdRng::seed_from_u64(2300);
1062        let draws = sampleobservation_replicates(&spec, 2_000, &mut rng).unwrap();
1063        assert!(
1064            draws
1065                .column(0)
1066                .iter()
1067                .all(|value| *value == 0.0 || *value == 2.0)
1068        );
1069        assert!(draws.column(1).iter().all(|value| *value == 0.0));
1070        let first_mean = draws.column(0).sum() / draws.nrows() as f64;
1071        assert!((first_mean - 1.5).abs() < 0.08, "mean={first_mean}");
1072    }
1073
1074    #[test]
1075    fn replicate_chunk_size_does_not_change_seeded_draw_stream() {
1076        use rand::SeedableRng;
1077
1078        let spec = GenerativeSpec {
1079            mean: ndarray::array![0.2, 0.7, 0.95],
1080            noise: NoiseModel::Bernoulli,
1081        };
1082        let collect = |chunk_draws: usize| {
1083            let mut rng = rand::rngs::StdRng::seed_from_u64(91);
1084            let mut values = Vec::<f64>::new();
1085            sampleobservation_replicate_chunks(&spec, 257, chunk_draws, &mut rng, |_, chunk| {
1086                values.extend(chunk.iter().copied());
1087                Ok(())
1088            })
1089            .unwrap();
1090            values
1091        };
1092        assert_eq!(collect(1), collect(7));
1093        assert_eq!(collect(7), collect(256));
1094    }
1095
1096    #[test]
1097    fn seekable_seeded_ranges_concatenate_bit_exactly() {
1098        let spec = GenerativeSpec {
1099            mean: ndarray::array![1.5, 4.0],
1100            noise: NoiseModel::Poisson,
1101        };
1102        let whole = sampleobservation_seeded_replicates(&spec, 0, 257, 2300).unwrap();
1103        let first = sampleobservation_seeded_replicates(&spec, 0, 91, 2300).unwrap();
1104        let second = sampleobservation_seeded_replicates(&spec, 91, 166, 2300).unwrap();
1105        assert_eq!(whole.slice(ndarray::s![..91, ..]), first.view());
1106        assert_eq!(whole.slice(ndarray::s![91.., ..]), second.view());
1107
1108        let mut streamed = Vec::<f64>::new();
1109        sampleobservation_seeded_replicate_chunks(&spec, 0, 257, 13, 2300, |_, chunk| {
1110            streamed.extend(chunk.iter().copied());
1111            Ok(())
1112        })
1113        .unwrap();
1114        assert_eq!(streamed, whole.iter().copied().collect::<Vec<_>>());
1115    }
1116
1117    /// The CTM inverse-transform sampler (#1613) must draw `Y = h⁻¹(Z|x)`,
1118    /// `Z ~ N(0,1)`, from each row's monotone transform — NOT Gaussian noise on
1119    /// the latent scale. With the analytically invertible linear transform
1120    /// `h(y|x_i) = slope_i·(y − center_i)` we have `h⁻¹(z) = center_i + z/slope_i`,
1121    /// so the draws must be `N(center_i, (1/slope_i)²)`: the per-row mean tracks
1122    /// `center_i` (response scale) and the spread is `1/slope_i` (NOT ≈ 1, the
1123    /// latent scale of the old buggy path).
1124    #[test]
1125    fn transformation_normal_quantile_sampler_is_inverse_transform() {
1126        use rand::SeedableRng;
1127
1128        let g = 801usize;
1129        let (y_lo, y_hi) = (-12.0_f64, 12.0_f64);
1130        let grid_y =
1131            Array1::from_shape_fn(g, |k| y_lo + (y_hi - y_lo) * (k as f64) / ((g - 1) as f64));
1132        // Row 0: center -1, slope 2 (sd 0.5). Row 1: center +2, slope 4 (sd 0.25).
1133        let centers = [-1.0_f64, 2.0_f64];
1134        let slopes = [2.0_f64, 4.0_f64];
1135        let mut h_grid = Array2::<f64>::zeros((2, g));
1136        for i in 0..2 {
1137            for k in 0..g {
1138                h_grid[[i, k]] = slopes[i] * (grid_y[k] - centers[i]);
1139            }
1140        }
1141        let spec = GenerativeSpec {
1142            mean: Array1::from_vec(vec![centers[0], centers[1]]),
1143            noise: NoiseModel::TransformationNormalQuantile {
1144                grid_y: grid_y.clone(),
1145                h_grid,
1146            },
1147        };
1148
1149        let mut rng = rand::rngs::StdRng::seed_from_u64(20240613);
1150        let n_draws = 40_000usize;
1151        let draws = sampleobservation_replicates(&spec, n_draws, &mut rng).unwrap();
1152        assert_eq!(draws.shape(), &[n_draws, 2]);
1153
1154        let mut row_means = [0.0_f64; 2];
1155        for i in 0..2 {
1156            let col = draws.column(i);
1157            let mean = col.sum() / (n_draws as f64);
1158            let var = col.iter().map(|v| (v - mean) * (v - mean)).sum::<f64>() / (n_draws as f64);
1159            let sd = var.sqrt();
1160            row_means[i] = mean;
1161            assert!(
1162                (mean - centers[i]).abs() < 0.02,
1163                "row {i} draw mean {mean:.4} should be the response-scale center {:.4}",
1164                centers[i]
1165            );
1166            let expected_sd = 1.0 / slopes[i];
1167            assert!(
1168                (sd - expected_sd).abs() < 0.02,
1169                "row {i} draw sd {sd:.4} should be the response-scale 1/slope {expected_sd:.4}, \
1170                 not the latent ≈1 of the old Gaussian-noise path"
1171            );
1172        }
1173        // The conditional mean must INCREASE with the covariate-driven center —
1174        // the exact direction the #1613 bug got backwards.
1175        assert!(
1176            row_means[1] > row_means[0],
1177            "draw means must increase with center: row0={:.4} row1={:.4}",
1178            row_means[0],
1179            row_means[1]
1180        );
1181    }
1182
1183    /// The canonical dispersion picker must read the *fitted* dispersion off the
1184    /// scale metadata, never the construction seed embedded in the response
1185    /// spec. This is the single guard for the whole "generate draws at the seed
1186    /// dispersion" bug family — Gamma #678, Beta #769/#770, Tweedie #771, and
1187    /// the NB sibling #1124 — now that the picker lives in exactly one place
1188    /// (previously three divergent copies let a fix in one miss the others).
1189    #[test]
1190    fn family_noise_parameter_reads_fitted_dispersion_not_seed() {
1191        // NB: spec carries the seed theta = 1; the fit estimated theta_hat.
1192        let nb = LikelihoodSpec::negative_binomial_log(1.0);
1193        assert_eq!(
1194            family_noise_parameter(
1195                LikelihoodScaleMetadata::EstimatedNegBinTheta { theta: 2.97 },
1196                0.0,
1197                &nb,
1198            )
1199            .unwrap(),
1200            Some(2.97),
1201            "NB picker must read theta_hat (#1124), not the seed theta=1"
1202        );
1203
1204        // Tweedie: the picker must return the dispersion phi, never the variance
1205        // power p that lives on the spec.
1206        let tw = LikelihoodSpec::tweedie_log(1.5);
1207        assert_eq!(
1208            family_noise_parameter(
1209                LikelihoodScaleMetadata::EstimatedTweediePhi { phi: 7.25 },
1210                0.0,
1211                &tw,
1212            )
1213            .unwrap(),
1214            Some(7.25),
1215            "Tweedie picker must read phi_hat (#771), not the variance power p"
1216        );
1217
1218        // Beta: spec carries the seed phi = 1; the fit estimated phi_hat.
1219        let beta = LikelihoodSpec::beta_logit(1.0);
1220        assert_eq!(
1221            family_noise_parameter(
1222                LikelihoodScaleMetadata::EstimatedBetaPhi { phi: 12.0 },
1223                0.0,
1224                &beta,
1225            )
1226            .unwrap(),
1227            Some(12.0),
1228            "Beta picker must read phi_hat (#770), not the seed phi=1"
1229        );
1230
1231        // Gamma: the estimated shape must win over the residual-scale fallback.
1232        let gamma = LikelihoodSpec::gamma_log();
1233        assert_eq!(
1234            family_noise_parameter(
1235                LikelihoodScaleMetadata::EstimatedGammaShape { shape: 4.5 },
1236                0.123,
1237                &gamma,
1238            )
1239            .unwrap(),
1240            Some(4.5),
1241            "Gamma picker must read shape_hat (#678), not the residual-scale fallback"
1242        );
1243    }
1244
1245    /// Construction seeds are not fitted dispersion. Missing/inconsistent
1246    /// metadata must fail rather than silently changing the generated law.
1247    #[test]
1248    fn family_noise_parameter_rejects_unresolved_fit_metadata() {
1249        let none = LikelihoodScaleMetadata::ProfiledGaussian;
1250        assert!(
1251            family_noise_parameter(none, 0.0, &LikelihoodSpec::negative_binomial_log(3.5)).is_err()
1252        );
1253        assert!(family_noise_parameter(none, 0.0, &LikelihoodSpec::beta_logit(8.0)).is_err());
1254        assert!(family_noise_parameter(none, 0.0, &LikelihoodSpec::tweedie_log(1.5)).is_err());
1255        assert!(family_noise_parameter(none, 2.0, &LikelihoodSpec::gamma_log()).is_err());
1256    }
1257
1258    /// End-to-end through the exact composition `gam generate` and
1259    /// `sample_replicates` use — picker → `from_likelihood`. The seed-spec
1260    /// theta = 1 plus an estimated theta_hat must yield a per-row NB noise model
1261    /// at theta_hat, not at the seed. This is the #1124 repro at the unit level,
1262    /// from the angle of the *composed* path rather than `from_likelihood` alone.
1263    #[test]
1264    fn picker_then_from_likelihood_threads_fitted_nb_theta() {
1265        let nobs = 6usize;
1266        let seed_spec = LikelihoodSpec::negative_binomial_log(1.0);
1267        let scale = LikelihoodScaleMetadata::EstimatedNegBinTheta { theta: 2.751 };
1268        let picked = family_noise_parameter(scale, 0.0, &seed_spec).unwrap();
1269        let noise =
1270            NoiseModel::from_likelihood(&seed_spec, nobs, picked).expect("NB noise model builds");
1271        let NoiseModel::NegativeBinomial { theta } = noise else {
1272            panic!("expected an NB observation noise model");
1273        };
1274        assert!(
1275            theta.len() == nobs && theta.iter().all(|&t| (t - 2.751).abs() < 1e-12),
1276            "NB generate composes the seed theta=1 instead of theta_hat (#1124): {theta:?}"
1277        );
1278    }
1279
1280    /// A weighted Gaussian fit has `Var(y_i) = sigma^2 / w_i`, so the generative
1281    /// observation noise must be heteroskedastic in the analytic prior weights:
1282    /// `sigma_i = sigma_hat / sqrt(w_i)`. Before #2025 the replicate path dropped
1283    /// the weights and broadcast the pooled scalar `sigma_hat` to every row (flat
1284    /// sigma). This asserts the per-row scaling and that unit weights leave the
1285    /// scalar untouched (so unweighted fits are unchanged).
1286    #[test]
1287    fn gaussian_generativespec_scales_sigma_by_prior_weights() {
1288        let sigma_hat = 2.0_f64;
1289        let weights = Array1::from(vec![1.0, 4.0, 0.25]);
1290        let mean = Array1::from(vec![0.0, 1.0, -1.0]);
1291        let prediction = PredictResult {
1292            eta: mean.clone(),
1293            mean: mean.clone(),
1294        };
1295        let spec = generativespec_from_predict(
1296            prediction,
1297            LikelihoodSpec::gaussian_identity(),
1298            Some(sigma_hat),
1299            Some(&weights),
1300        )
1301        .expect("weighted Gaussian generative spec builds");
1302        let NoiseModel::Gaussian { sigma } = spec.noise else {
1303            panic!("expected Gaussian observation noise");
1304        };
1305        // sigma_hat / sqrt(w_i) for w = [1, 4, 0.25] -> [2, 1, 4].
1306        let expected = [2.0_f64, 1.0, 4.0];
1307        for (i, (&got, &want)) in sigma.iter().zip(expected.iter()).enumerate() {
1308            assert!(
1309                (got - want).abs() < 1e-12,
1310                "row {i}: sigma must be sigma_hat/sqrt(w_i)={want}, got {got} \
1311                 (flat sigma_hat={sigma_hat} drops the prior weights, #2025)"
1312            );
1313        }
1314        assert!(
1315            sigma.iter().any(|&s| (s - sigma_hat).abs() > 1e-9),
1316            "sigma is flat at the pooled scalar; prior weights were dropped (#2025)"
1317        );
1318
1319        // Unit prior weights must reproduce the unweighted pooled scalar exactly.
1320        let unit = Array1::from_elem(3, 1.0_f64);
1321        let unweighted = generativespec_from_predict(
1322            PredictResult {
1323                eta: mean.clone(),
1324                mean,
1325            },
1326            LikelihoodSpec::gaussian_identity(),
1327            Some(sigma_hat),
1328            Some(&unit),
1329        )
1330        .expect("unit-weight Gaussian generative spec builds");
1331        let NoiseModel::Gaussian { sigma: flat } = unweighted.noise else {
1332            panic!("expected Gaussian observation noise");
1333        };
1334        assert!(
1335            flat.iter().all(|&s| (s - sigma_hat).abs() < 1e-12),
1336            "unit prior weights must leave sigma at the pooled scalar sigma_hat"
1337        );
1338    }
1339
1340    /// Structural equality for `NoiseModel` (no derived `PartialEq` so that
1341    /// the live enum can carry per-observation arrays). Two models are equal
1342    /// when they are the same variant with bitwise-identical parameters.
1343    fn noise_models_match(a: &NoiseModel, b: &NoiseModel) -> bool {
1344        match (a, b) {
1345            (NoiseModel::Gaussian { sigma: sa }, NoiseModel::Gaussian { sigma: sb }) => sa == sb,
1346            (NoiseModel::Poisson, NoiseModel::Poisson) => true,
1347            (NoiseModel::Bernoulli, NoiseModel::Bernoulli) => true,
1348            (NoiseModel::Tweedie { p: pa, phi: pha }, NoiseModel::Tweedie { p: pb, phi: phb }) => {
1349                pa == pb && pha == phb
1350            }
1351            (
1352                NoiseModel::NegativeBinomial { theta: ta },
1353                NoiseModel::NegativeBinomial { theta: tb },
1354            ) => ta == tb,
1355            (NoiseModel::Beta { phi: pa }, NoiseModel::Beta { phi: pb }) => pa == pb,
1356            (NoiseModel::Gamma { shape: sa }, NoiseModel::Gamma { shape: sb }) => sa == sb,
1357            _ => false,
1358        }
1359    }
1360
1361    /// For every supported built-in family, the canonical
1362    /// `NoiseModel::from_likelihood` mapping and the simulation adapter
1363    /// `FamilyStrategy::simulate_noise` must produce the same `NoiseModel`
1364    /// from the same fitted dispersion — this is the single-mapping guarantee
1365    /// the unification provides.
1366    #[test]
1367    fn from_likelihood_matches_simulate_noise_for_each_family() {
1368        let nobs = 5usize;
1369        let mean = Array1::from_elem(nobs, 0.5_f64);
1370
1371        // (spec, dispersion/gaussian_scale, expected noise variant).
1372        let cases: [(LikelihoodSpec, Option<f64>, NoiseModel); 7] = [
1373            (
1374                LikelihoodSpec::gaussian_identity(),
1375                Some(0.7),
1376                NoiseModel::Gaussian {
1377                    sigma: Array1::from_elem(nobs, 0.7),
1378                },
1379            ),
1380            (
1381                LikelihoodSpec::binomial_logit(),
1382                None,
1383                NoiseModel::Bernoulli,
1384            ),
1385            (LikelihoodSpec::poisson_log(), None, NoiseModel::Poisson),
1386            (
1387                LikelihoodSpec::tweedie_log(1.4),
1388                Some(0.9),
1389                NoiseModel::Tweedie {
1390                    p: 1.4,
1391                    phi: Array1::from_elem(nobs, 0.9),
1392                },
1393            ),
1394            (
1395                LikelihoodSpec::negative_binomial_log(2.5),
1396                None,
1397                NoiseModel::NegativeBinomial {
1398                    theta: Array1::from_elem(nobs, 2.5),
1399                },
1400            ),
1401            (
1402                LikelihoodSpec::beta_logit(3.0),
1403                None,
1404                NoiseModel::Beta {
1405                    phi: Array1::from_elem(nobs, 3.0),
1406                },
1407            ),
1408            (
1409                LikelihoodSpec::gamma_log(),
1410                Some(1.5),
1411                NoiseModel::Gamma {
1412                    shape: Array1::from_elem(nobs, 1.5),
1413                },
1414            ),
1415        ];
1416
1417        for (spec, scale, expected) in cases {
1418            let from_helper = NoiseModel::from_likelihood(&spec, nobs, scale)
1419                .expect("canonical mapping must accept a supported family");
1420            let from_strategy = strategy_for_spec(&spec)
1421                .simulate_noise(&mean, scale)
1422                .expect("simulation adapter must accept a supported family");
1423
1424            assert!(
1425                noise_models_match(&from_helper, &expected),
1426                "{} canonical mapping produced an unexpected NoiseModel",
1427                spec.pretty_name()
1428            );
1429            assert!(
1430                noise_models_match(&from_helper, &from_strategy),
1431                "{} simulation and inference disagree on the NoiseModel",
1432                spec.pretty_name()
1433            );
1434        }
1435    }
1436
1437    /// RoystonParmar is not exposed through the generic generative path, and
1438    /// both the canonical mapping and the simulation adapter must reject it
1439    /// identically so the two paths stay in lockstep.
1440    #[test]
1441    fn royston_parmar_rejected_on_both_paths() {
1442        let spec = LikelihoodSpec::royston_parmar();
1443        let mean = Array1::from_elem(3, 0.0_f64);
1444        assert!(NoiseModel::from_likelihood(&spec, 3, None).is_err());
1445        assert!(
1446            strategy_for_spec(&spec)
1447                .simulate_noise(&mean, None)
1448                .is_err()
1449        );
1450    }
1451
1452    /// Invalid / missing dispersion is rejected the same way regardless of
1453    /// which entry point is used.
1454    #[test]
1455    fn invalid_dispersion_rejected_on_both_paths() {
1456        let mean = Array1::from_elem(4, 0.0_f64);
1457
1458        // Gaussian sigma missing.
1459        let gauss = LikelihoodSpec::gaussian_identity();
1460        assert!(NoiseModel::from_likelihood(&gauss, 4, None).is_err());
1461        assert!(
1462            strategy_for_spec(&gauss)
1463                .simulate_noise(&mean, None)
1464                .is_err()
1465        );
1466
1467        // Tweedie power outside (1, 2).
1468        let bad_tweedie = LikelihoodSpec::tweedie_log(2.5);
1469        assert!(NoiseModel::from_likelihood(&bad_tweedie, 4, Some(0.5)).is_err());
1470        assert!(
1471            strategy_for_spec(&bad_tweedie)
1472                .simulate_noise(&mean, Some(0.5))
1473                .is_err()
1474        );
1475
1476        // Gamma shape non-positive.
1477        let gamma = LikelihoodSpec::gamma_log();
1478        assert!(NoiseModel::from_likelihood(&gamma, 4, Some(-1.0)).is_err());
1479        assert!(
1480            strategy_for_spec(&gamma)
1481                .simulate_noise(&mean, Some(-1.0))
1482                .is_err()
1483        );
1484    }
1485}