Skip to main content

gam_models/inference/
generative.rs

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