Skip to content

Latest commit

 

History

History
1023 lines (545 loc) · 161 KB

File metadata and controls

1023 lines (545 loc) · 161 KB

Fugue Ecosystem Audit — July 2026 — fugue findings

Date: 2026-07-10 · Scope: fugue (monadic PPL, ~9.4k LOC) and fugue-evo (evolutionary computation, ~33k LOC incl. WASM crate)

Method: 180-agent orchestrated audit in four phases: 21 domain auditors over disjoint source slices; independent adversarial verification of every math/correctness/performance finding (two verifiers for critical/high, one otherwise, each re-deriving formulas from primary references and checking numerically where decisive); a completeness critic that commissioned three gap-fill audits; and a re-run of one degenerate auditor. 2 findings were refuted during verification and are excluded; 2 duplicates were merged.

Totals: 170 findings — 12 critical, 38 high, 72 medium, 48 low. 110 adversarially confirmed.

Cross-cutting conclusions:

  1. The primitives are right; the composition is wrong. Distribution log-densities, NSGA-II internals, SBX/polynomial mutation, CMA-ES strategy parameters, and benchmark functions are faithful to their references. The confirmed-wrong code concentrates in the layers that join components: proposal corrections, importance weights, optimizer update loops, wiring between subsystems.
  2. Silent wrong answers, not crashes. Every one of fugue's four inference engines (MCMC, SMC, VI, ABC-SMC) had at least one confirmed bug that biases its posterior without raising an error.
  3. Ornamental subsystems. fugue's memory-optimization layer, fugue-evo's Bayesian hyperparameter learner, the fugue_integration module, and 12 of 20 fugue error codes had zero production call sites.
  4. Stapled, not fused. fugue-evo's algorithms are implemented entirely without fugue; the integration existed in marketing only.
  5. Tests are broad but shallow. 837 green tests validate shapes and finiteness more often than values; known-answer tests were the exception.

Full interactive report (with verifier transcripts): https://claude.ai/code/artifact/4c4fc55e-52cf-42e9-90e3-0cf190ed11c3

Finding IDs are stable: FG-* = fugue, EV-* = fugue-evo. Resolutions below are updated as remediation lands; every finding must end fixed or carry a substantive resolution — no deferrals.

Remediation status (2026-07-11): COMPLETE. All 170 findings (64 FG + 106 EV) carry a final resolution below, and every one was independently re-verified by an adversarial verifier against the final code (170/170 verified; 14 initially judged incomplete were repaired by fixup passes and re-verified). Post-remediation regression review over the full diffs surfaced 7 new defects (2 medium, 5 low) — all fixed. Final state: fugue branch audit/2026-07-remediation — 64/64, full gate green (tests, doctests, clippy, fmt, benches); fugue-evo branch audit/2026-07-remediation — 106/106, full suite green, now depending on the co-developed sibling fugue via path (EV-30).


fugue (fugue-ppl)

Severity: critical (4)

FG-01 — Effective sample size is silently wrong by an order of magnitude for any parameter whose variance isn't ~1

  • Location: fugue/src/inference/diagnostics.rs:257
  • Severity: critical · Dimension: math · Verification: confirmed · Auditor confidence: n/a

effective_sample_size (diagnostics.rs:257-307) computes 'autocorrelations' as raw autocovariances (num/count, diagnostics.rs:283) and never divides by the lag-0 autocovariance (the variance) before summing them into tau (line 302: tau = 1.0 + 2.0 * autocorrs[1..=lag].iter().sum::<f64>()). The correct integrated-autocorrelation-time formula is tau = 1 + 2*sum(rho_k) where rho_k = autocov_k / autocov_0 is the normalized autocorrelation (a dimensionless quantity in [-1,1]); using raw autocovariances instead means tau (and therefore ESS = n/tau) scales with the parameter's variance rather than being a dimensionless diagnostic. I verified this numerically: for an AR(1) series with true variance ≈13.9, the buggy formula gives tau≈45.9 (ESS≈436) while the correctly-normalized formula on the identical data gives tau≈4.2 (ESS≈4724) — an ~11x discrepancy that tracks the series variance. This function is wired directly into summarize_f64_parameter (line 368) and thus into print_diagnostics, the crate's headline user-facing diagnostics entry point (re-exported at the crate root and demonstrated in examples/debugging_models.rs and examples/production_deployment.rs). Damningly, the crate contains a SECOND, CORRECT implementation of the same statistic in src/inference/mcmc_utils.rs (effective_sample_size_mcmc/compute_autocorrelation, lines 142-197) that does divide by variance (autocorrs.push(covariance / var), line 196) — proving the team knows the right formula but never consolidated it into the diagnostics module that users actually call. No unit test checks a numeric ESS value against ground truth, which is how this went unnoticed.

Suggested fix: Delete the duplicate, buggy implementation in diagnostics.rs and have summarize_f64_parameter call the correct effective_sample_size_mcmc from mcmc_utils.rs (or fix diagnostics.rs to divide by autocorrs[0] before summing). Add a regression test asserting ESS is invariant to rescaling the input series by a constant.

Verifier correction: The defect is exactly as described. Two small refinements: (1) the first-negative-autocorrelation break (line 298) is unaffected by the missing normalization since sign(gamma_k)=sign(rho_k), so only the summed magnitude is wrong; (2) the buggy/correct ratio only approximately tracks variance rather than equaling it, because the automatic-windowing condition lag >= 6.0*tau (line 305) truncates at a different lag when tau is inflated. Additional consequence: for parameters with variance < 1, the un-normalized tau can fall below 1, producing ESS > n, which is diagnostically impossible.

Verifier correction: Finding is correct. Minor nuance: the auditor's exact "tau=45.9 / 11x" numbers depend on seed/length (I reproduced the structural claim with ratio 12.5 for var13.9). Also, the windowing guard lag >= 6.0*tau (line 305) uses the inflated tau so it never truncates early when variance>1; the loop instead stops at the first non-positive raw autocovariance — this doesn't change the conclusion but is why the ratio isn't exactly the variance.

Resolution: fixed — The buggy diagnostics.rs effective_sample_size (which summed raw, unnormalized autocovariances into tau) was deleted and replaced with a thin wrapper calling the correct, normalized effective_sample_size_mcmc from mcmc_utils.rs, matching the audit's suggested fix exactly. summarize_f64_parameter was also switched to the new multi-chain estimator (see FG-37).

Regression tests: ess_is_scale_invariant, ess_matches_ar1_known_answer, fg01_summary_ess_scale_invariant.

Re-verification: verified (independent adversarial verifier).

FG-02 — Log-space random-walk proposal omits the Jacobian/Hastings correction — chain targets π(σ)/σ, not π(σ)

  • Location: fugue/src/inference/mh.rs:410
  • Severity: critical · Dimension: math · Verification: confirmed · Auditor confidence: certain

The acceptance ratio at mh.rs:410 is log_alpha = prop_scored.total_log_weight() - cur_scored.total_log_weight(), i.e. purely log π(x') − log π(x) of the full joint. This is only valid for SYMMETRIC proposals. But LogSpaceWalkProposal (mh.rs:118–136) proposes log x' = log x + scale·z, x' = exp(log x'), which is asymmetric in the original space. Derivation: y'=log x' | y=log x ~ N(y,scale²); pushing forward, q(x'|x) = φ((log x'−log x)/scale)/(scale·x'). By the same token q(x|x') = φ(...)/(scale·x). Since φ is symmetric the Gaussian factors are equal, so the Hastings ratio q(x|x')/q(x'|x) = x'/x. The correct acceptance term is therefore log π(x') − log π(x) + (ln x' − ln x). The ln x' − ln x term is missing. Solving detailed balance for the coded (uncorrected) kernel shows its stationary law is p(x) ∝ π(x)/x — an extra improper 1/x factor. This heuristic fires for ANY address containing sigma/scale/rate/lambda/tau/precision/nu with value>0 (mh.rs:241–251), i.e. essentially every scale/SD parameter in every model, so it is mainline. Numerically confirmed: with target Gamma(k=3,rate=2) (true mean 1.500) the coded chain yields sample mean 0.999 = mean of Gamma(k−1,rate); adding ln(x'/x) restores 1.500. The ProposalStrategy::log_proposal_prob hook (mh.rs:92) that would supply this correction is defined but never called anywhere in the codebase.

Suggested fix: For any non-symmetric proposal add the Hastings term to log_alpha. For the log-space walk specifically add (proposed.ln() − current.ln()). Generally: compute log_alpha = logπ(x') − logπ(x) + strategy.log_proposal_prob(x',x,scale) − strategy.log_proposal_prob(x,x',scale), and actually wire log_proposal_prob into the accept step.

Resolution: fixedLogSpaceWalkProposal::log_proposal_prob now returns the true log q(to|from) density (normal_logpdf on the log scale minus ln(to), i.e. the Jacobian term), and the acceptance ratio in single_site_mh_step/adaptive_single_site_mh adds (log_q_reverse - log_q_forward) to the log-joint difference, restoring the previously-missing +(ln x' - ln x) correction.

Regression tests: log_space_jacobian_is_correct, fg02_log_space_walk_targets_gamma_mean, fg02_log_space_override_targets_gamma_mean.

Re-verification: verified (independent adversarial verifier).

FG-03 — SMC importance weights double-count the prior (prior-squaring), biasing every posterior estimate

  • Location: fugue/src/inference/smc.rs:476
  • Severity: critical · Dimension: math · Verification: confirmed · Auditor confidence: certain

smc_prior_particles draws each particle from the prior via PriorHandler (interpreters.rs:43-56 samples x~dist and adds dist.log_prob(x) into trace.log_prior), then sets log_weight = t.total_log_weight() = log_prior + log_likelihood + log_factors (trace.rs:198). This is the mainline weight used by normalize_particles and returned to the caller.

Derivation of the correct weight: with proposal q(theta)=prior p(theta) and target proportional to p(theta)*p(y|theta), the self-normalized importance weight is w_i = target/proposal = p(theta_i)*p(y|theta_i)/p(theta_i) = p(y|theta_i). So the log-weight must be log_likelihood + log_factors ONLY; the log_prior term must be excluded because sampling from the prior already realizes p(theta).

Including log_prior makes the weighted empirical measure converge to something proportional to q(theta)w(theta) = p(theta)[p(theta)p(y|theta)] = p(theta)^2 * p(y|theta) — the prior is squared. Numeric check (muN(0,1), observe yN(mu,0.5), y=2): correct posterior mean=1.6, var=0.2; this code targets mean=1.333, var=0.167. Every mean/quantile/credible-interval computed from these particles is silently wrong, and worse as the prior gets tighter relative to the likelihood.

Suggested fix: Set log_weight = t.log_likelihood + t.log_factors (the incremental evidence relative to the prior proposal), not t.total_log_weight().

Resolution: fixed — In smc_prior_particles (src/inference/smc.rs), the particle log_weight is now computed as the likelihood-only contribution (particle_log_likelihood(&t), i.e. log_likelihood + log_factors) instead of t.total_log_weight() (which included log_prior), removing the prior-squaring bias since the prior cancels against the prior-proposal in self-normalized importance sampling.

Regression tests: fg03_smc_prior_weights_do_not_square_the_prior.

Re-verification: verified (independent adversarial verifier).

FG-04 — VI optimizer only updates location parameters; variational scale (log_sigma, log_beta) is never optimized

  • Location: fugue/src/inference/vi.rs:441
  • Severity: critical · Dimension: correctness · Verification: confirmed · Auditor confidence: certain

In optimize_meanfield_vi the match arms bind and update only the location parameter and explicitly ignore the scale. Normal arm (line 441) is VariationalParam::Normal { mu, log_sigma: _ } and only perturbs/updates mu (lines 445-459); LogNormal only updates mu; Beta (line 485) is { log_alpha, log_beta: _ } and only updates log_alpha. The scale/spread parameter is frozen at its initial value forever. The whole point of variational inference is to fit BOTH the location and the spread of q so that KL(q||p) is minimized; the ELBO's entropy term H(q) = E_q[-log q] depends directly on log_sigma, and the optimum log_sigma* is determined by the posterior curvature (for a Gaussian target with precision tau, the mean-field optimum is sigma* = tau^{-1/2}). Because log_sigma is never touched, the returned guide reports whatever posterior uncertainty the user happened to initialize, independent of the data. This silently produces an incorrect posterior approximation in the mainline optimizer and means the method is not actually performing variational inference over the scale.

Suggested fix: Add finite-difference (or analytic) gradient steps for log_sigma / log_beta with the same machinery used for the location parameter, and update them in the unconstrained log-space.

Resolution: fixed — optimize_meanfield_vi_with_config in src/inference/vi.rs was rewritten to iterate over both ParamCoord::Location and ParamCoord::Scale for every guide parameter (mu/log_sigma for Normal and LogNormal, log_alpha/log_beta for Beta) and apply finite-difference gradient updates to both, replacing the old code that only ever updated the location coordinate and left the scale frozen at its initial value.

Regression tests: fg04_scale_parameter_is_optimized_not_frozen.

Re-verification: verified (independent adversarial verifier).

Severity: high (18)

FG-05 — Address is a String allocated fresh on every sample-site visit and cloned twice per handler call; this dominates per-iteration cost for typical model sizes

  • Location: fugue/src/core/address.rs:52
  • Severity: high · Dimension: performance · Verification: confirmed · Auditor confidence: n/a

Address is pub struct Address(pub String) (address.rs:21) and the addr! macro heap-allocates a new String on every invocation via .to_string() or format!("{}#{}", ...) (address.rs:54-59). Because model closures (model_fn: impl Fn() -> Model<A>) are re-invoked from scratch by every PriorHandler/ScoreGivenTrace/ReplayHandler run() call — and mh.rs's adaptive_single_site_mh does this 2-4 times per MCMC step, smc.rs does it once per particle per rejuvenation step, abc.rs once per rejection-sampling attempt — every addr!(...) call at every sample/observe site is re-executed and re-allocates its String on every one of those runs. On top of that, every concrete handler (PriorHandler, ReplayHandler, SafeReplayHandler in interpreters.rs, e.g. lines 43-56) clones the Address twice per sample call: once as the BTreeMap key argument to .insert(addr.clone(), ...) and once again inside the Choice{ addr: addr.clone(), .. } value, so each site costs at least 2 String allocations before any BTreeMap insertion (which itself requires O(log N) String comparisons to find the insertion point). For a model with even a few dozen sample sites, one MCMC step therefore performs on the order of a hundred+ small heap allocations and string comparisons for bookkeeping alone, versus a handful of FLOPs for the actual Box-Muller draw and log_prob evaluation per site — the allocation/hashing overhead plausibly dominates wall-clock runtime for small-to-moderate models, exactly matching the profiling concern in the task brief.

Suggested fix: Consider replacing Address's String backing with a cheaply-clonable representation (Arc, an interned symbol/index, or Copy-able fixed-size small-string), and/or caching the constructed Address list once per model shape instead of reconstructing it every handler run. Also stop storing the Address redundantly inside Choice when it is already the map key (or make Choice.addr borrow/Arc-share it).

Resolution: fixed — Address is now backed by Arc + a precomputed u64 hash: Clone is a refcount bump (allocation-free), Hash writes the cached u64, Eq/Ord still compare the underlying str so BTreeMap iteration order and correctness are preserved. addr! macro, scoped_addr!, and all in-crate construction/field-access sites updated (.0 -> as_str()/Address::new()); Display/Deref preserved so downstream code compiles. Measured with a criterion bench of adaptive_mcmc_chain on 20- and 50-site models, before (temporarily reverted String-backed Address) vs after: 50-site 7.409ms -> 7.310ms (~1.3% faster, non-overlapping CIs); 20-site 1.530ms -> 1.532ms (neutral). Honest correction to the finding's magnitude claim, recorded in the bench doc comment and commit: the per-step hot path is a BTreeMap<Address,Choice> ordered by Ord (never hashed), so the cached hash contributes nothing there; the measurable win is Arc making the 3-5 whole-trace clones per MH step allocation-free in their keys, which is why the benefit appears only at the larger model. The cached hash is retained because Address is a genuine HashMap key in mh.rs (kind_cache), mcmc_utils.rs (scales/accept_counts), and vi.rs (params), where it removes the per-probe string re-hash. Regression test cached_hash_is_consistent_with_eq_and_clone (references FG-05) asserts Hash agrees with Eq, clones stay equal and share the Arc, and distinct addresses differ; it will not compile/pass on the pre-fix Address(pub String).

Regression tests: src/core/address.rs::tests::cached_hash_is_consistent_with_eq_and_clone (FG-05 regression: Hash/Eq/clone/Arc-sharing invariants of the new representation).

Re-verification: verified (independent adversarial verifier).

FG-06 — Distribution log_prob formulas are never checked against closed-form reference values at interior points

  • Location: fugue/src/core/distribution.rs:1125
  • Severity: high · Dimension: testing · Verification: judgment · Auditor confidence: n/a

The entire mod tests block (lines 1120-1248) for Normal, Uniform, LogNormal, Exponential, Bernoulli, Categorical, Beta, Gamma, Binomial, Poisson checks only: (a) constructor validation errors, (b) that log_prob is -inf outside support / is_finite inside support. No test compares log_prob at a generic interior point against the true closed-form value. E.g. normal_constructor_and_log_prob (line 1126) only asserts n.log_prob(&0.0).is_finite(), never that it equals -0.5ln(2π) ≈ -0.9189385332 (verified: Normal(0,1).log_prob(0) = -ln(sigma) - 0.5ln(2π) - 0 = -0.918938...). Similarly gamma_validation_and_support (1197) and poisson_validation_and_log_prob (1217) only check finiteness at x=1 and x=0/5. Because MCMC acceptance ratios, importance weights and ELBO all flow through log_prob, a sign error, missing normalization constant, or wrong parameterization (e.g. rate vs scale in Gamma/Exponential) at interior points would pass every test in this file.

Suggested fix: Add exact numeric assertions at 2-3 interior points per distribution using assert_relative_eq! against hand- or scipy-computed reference values, not just boundary/finiteness checks.

Resolution: fixed — Added interior-point known-answer tests for all 10 distributions at 2+ interior points each, using closed-form (scipy-equivalent) constants at 1e-9 tolerance, in a new integration test file plus a compact module test at the finding's cited location.

Regression tests: tests/f_dist_distributions.rs::fg06_normal_interior_points, tests/f_dist_distributions.rs::fg06_uniform_interior_points, tests/f_dist_distributions.rs::fg06_lognormal_interior_points, tests/f_dist_distributions.rs::fg06_exponential_interior_points, tests/f_dist_distributions.rs::fg06_bernoulli_interior_points, tests/f_dist_distributions.rs::fg06_categorical_interior_points, tests/f_dist_distributions.rs::fg06_beta_interior_points, tests/f_dist_distributions.rs::fg06_gamma_interior_points, tests/f_dist_distributions.rs::fg06_binomial_interior_points, tests/f_dist_distributions.rs::fg06_poisson_interior_points, core::distribution::tests::fg06_interior_point_known_answers.

Re-verification: verified (independent adversarial verifier).

FG-07 — Gamma::log_prob returns -inf for rate*x>700 — this fires across the ENTIRE high-density region (including the mode) of any Gamma with mean ≳ 700

  • Location: fugue/src/core/distribution.rs:930
  • Severity: high · Dimension: correctness · Verification: confirmed · Auditor confidence: certain

Line 930 guards if self.rate * x > 700.0 { return f64::NEG_INFINITY }. The stated rationale (avoiding exp overflow) is spurious: the log_prob formula on line 940 works entirely in log-space (shape*ln(rate) + (shape-1)*ln(x) - rate*x - lgamma(shape)) and never computes exp(), so -rate*x is a perfectly finite subtraction no matter how large ratex is. The correct density log Gamma(x;k,λ)=k·ln(λ)+(k-1)·ln(x)-λx-lnΓ(k) is finite for all x>0. The guard is therefore not a stability aid — it silently deletes finite densities. Crucially, ratex>700 is not a tail condition: for Gamma(shape=1000, rate=1) the mode is at (k-1)/rate=999 and essentially the whole distribution lives in x∈[900,1100], all of which have rate*x>700. I verified: true log_prob at x=1000 is -4.373 (near the peak) but the code returns -inf. So a Gamma prior/posterior with mean above ~700 (large-shape informative priors, or a Gamma-Poisson conjugate posterior after many high-count observations) evaluates to -inf everywhere with meaningful mass, silently making the whole model log-density -inf. The same unjustified anti-pattern also appears at line 473 (Exponential, rate*x>700) and lines 180/387 (Normal/LogNormal, |z|>37).

Suggested fix: Delete the rate*x>700 short-circuit (and the x.ln()*(shape-1) < -700 one) entirely; the log-space formula is already stable. If a guard is wanted it should cap the RESULT, not force -inf when the true value is finite.

Resolution: fixed — Deleted the rate*x > 700 || ln(x)*(shape-1) < -700 short-circuit in Gamma::log_prob; the log-space formula k*ln(rate)+(k-1)ln(x)-ratex-lnGamma(k) is finite for all x>0. Only the genuine x<=0 support check remains. Regression test confirms Gamma(2,1).log_prob(800) = -793.3153882723 (was -inf).

Regression tests: tests/f_dist_distributions.rs::fg07_gamma_large_argument_is_finite, core::distribution::tests::fg07_fg08_fg30_removed_overflow_guards_return_finite.

Re-verification: verified (independent adversarial verifier).

FG-08 — Normal & LogNormal::log_prob return -inf for |z|>37 — a tight-sigma likelihood with a moderate residual collapses the whole model to -inf

  • Location: fugue/src/core/distribution.rs:180
  • Severity: high · Dimension: correctness · Verification: confirmed · Auditor confidence: certain

Normal::log_prob line 180 (if z.abs() > 37.0 { return f64::NEG_INFINITY }) and LogNormal::log_prob line 387 short-circuit to -inf for standardized residual |z|>37. The comment claims '|z|>37 gives exp(-z²/2) < machine epsilon', but log_prob never computes that exp — it computes -0.5*z*z - ln(σ) - 0.5·ln(2π) directly (line 186), which is finite for any finite z. I verified: N(0,1).logpdf(40) = -800.919 and N(0,0.001).logpdf(0.05) = -1244.01, both finite, both returned as -inf by the code. This is not an astronomically-rare condition: |z|>37 is trivially reached by a measurement model with a small sigma, e.g. observe(Normal::new(mu, 0.001), y) with residual >0.037, or any outlier observation. When the observation's log-likelihood is forced to -inf the entire posterior log-density becomes -inf, so importance weights, MH acceptance ratios, and HMC potentials all degenerate — inference silently fails or every proposal is rejected. It also injects a discontinuity/absorbing boundary at |z|=37 that breaks gradient-based (HMC/NUTS) trajectories. Standard reference: the Gaussian log-density is defined and finite on all of ℝ; there is no support boundary to clip at |z|=37.

Suggested fix: Remove the z.abs()>37.0 guards in Normal (line 180) and LogNormal (line 387). The direct -0.5*z*z ... computation is already the numerically stable form and is finite everywhere; clipping it produces wrong log-densities.

Resolution: fixed — Deleted the |z|>37 short-circuit in both Normal::log_prob and LogNormal::log_prob; the direct -0.5z^2 - ln(sigma) - 0.5ln(2pi) form is finite for any finite z. Regression tests confirm Normal(0,0.001).log_prob(0.05) = -1244.0111832542, Normal(0,1).log_prob(40) = -800.9189385332, LogNormal(0,0.001).log_prob(1.05) = -1184.3000332585 (all were -inf).

Regression tests: tests/f_dist_distributions.rs::fg08_normal_large_residual_is_finite, tests/f_dist_distributions.rs::fg08_lognormal_tight_sigma_is_finite.

Re-verification: verified (independent adversarial verifier).

FG-09 — ABC-SMC perturbation step has no importance weights or kernel correction, so the returned population is not the ABC posterior

  • Location: fugue/src/inference/abc.rs:424
  • Severity: high · Dimension: math · Verification: confirmed · Auditor confidence: certain

In abc_smc, each new-round particle is produced by (a) picking a previous particle UNIFORMLY (line 428), (b) replacing one random site with a fresh prior draw (lines 439-451), and (c) accepting iff dist<=eps_new (line 458). No importance weights are ever computed or stored, and the traces are returned unweighted.

Proper ABC-SMC (Toni et al. 2009; Del Moral/Sisson) requires weight w_i^t proportional to pi(theta_i) / sum_j w_j^{t-1} K_t(theta_i | theta_j) to correct for the perturbation kernel K_t. The accepted theta' here is distributed proportional to q(theta')*1[dist<=eps], where q is the mixture proposal induced by uniform selection + single-site prior replacement — which is NOT proportional to pi(theta')*1[dist<=eps]. Without the weight correction the sample is a biased approximation of the ABC posterior; expectations computed from it are systematically off. (The initial rejection round is unbiased; the degradation is introduced by every subsequent tolerance level.)

Suggested fix: Maintain per-particle weights across rounds, resample/select proportional to weight, and reweight perturbed particles by prior/kernel ratio; or document abc_smc as a heuristic, not a valid posterior approximation.

Verifier correction: The finding is accurate. Minor clarification: the uniform selection at line 428 is not independently a bug for the first sequential round (round-1 rejection particles carry equal weight), so uniform draw is momentarily correct there. The essential, load-bearing defect is the total absence of the importance-weight (prior/kernel) correction after perturbation and the lack of any stored weights to select by in later rounds — which is exactly what makes the returned unweighted population a biased approximation of the ABC posterior at each subsequent tolerance level.

Verifier correction: The finding is correct as stated, with one scope nuance worth recording: for single-site models (all four in-file tests use a single "mu" site) the perturbation at lines 439-451 replaces the model's only site with a fresh full-prior draw, so the round degenerates to plain rejection ABC and is coincidentally UNBIASED. The bias the finding describes manifests only for models with two or more sample sites, where keeping (d-1) coordinates from the base particle and prior-resampling one coordinate yields a proposal q(θ) not proportional to π(θ). Consequently the existing unit tests cannot detect this defect. A secondary practical point (efficiency, not correctness): resampling a site fully from its prior rather than applying a localized kernel also negates the efficiency advantage SMC is supposed to provide over rejection.

Resolution: fixed — abc_smc/abc_smc_weighted in src/inference/abc.rs were rewritten to maintain per-particle importance weights across rounds, using a weighted-covariance perturbation kernel and reweighting w ~ pi/sum_j w_j K after perturbation (Beaumont/Toni-style ABC-SMC), plus prior-zero rejection; abc_smc_weighted exposes the weighted population and the legacy abc_smc wraps it.

Regression tests: fg09_abc_smc_matches_rejection_reference, fg09_legacy_abc_smc_matches_reference.

Re-verification: verified (independent adversarial verifier).

FG-10 — Categorical (usize) proposal is asymmetric and uncorrected; range depends on current value and can fall outside support

  • Location: fugue/src/inference/mh.rs:294
  • Severity: high · Dimension: math · Verification: confirmed · Auditor confidence: certain

For ChoiceValue::Usize (Categorical latents, e.g. mixture-model component indicators), propose_using_strategies always constructs UniformCategoricalProposal { n_categories: None } (mh.rs:294–301). The None branch (mh.rs:218–223) sets max_val = max(current+5, 10) and draws uniformly in [0,max_val). So q(x'|x)=1/max(current+5,10) DEPENDS on the current state ⇒ q(x'|x) ≠ q(x|x'); e.g. q(8|0)=1/10 but q(0|8)=1/13. The acceptance ratio at mh.rs:410 applies no correction, so the chain is biased. Numerically (K=8 arbitrary categorical target): uncorrected L1 error to the true pmf = 0.050 and systematically over-weights higher indices (k=6: 0.228 vs true 0.214; k=7: 0.083 vs 0.071); adding ln[max(x+5,10)/max(x'+5,10)] drops L1 to 0.002. Second defect: because n_categories is never populated, max_val is a heuristic that can exceed the true K (wasted rejections outside support) or, for K>max_val, can never propose the top categories at all (non-ergodic — those states are unreachable).

Suggested fix: Thread the true category count into the proposal (so it draws in [0,K)), which makes q symmetric (uniform independent of current) and needs no correction; or keep the current-dependent proposal but add the Hastings term ln(q(x|x')/q(x'|x)).

Verifier correction: One secondary sub-claim is overstated: 'for K>max_val, can never propose the top categories at all (non-ergodic — those states are unreachable)' is not strictly true. Because max_val = max(current+5, 10) GROWS with the current value, the chain can climb incrementally (from state x it can reach up to x+4 per step once x>=5, or up to 9 from any x<5). For a full-support categorical, every category therefore remains reachable across multiple steps, so the chain is still irreducible/ergodic. The real harm is (a) the biased stationary distribution from the uncorrected asymmetric proposal (the genuine high-severity bug) and (b) degraded mixing plus wasted -inf-rejected proposals when max_val overshoots K; it is NOT a strict ergodicity failure. Everything else in the finding — file/lines, the asymmetry, the missing Hastings correction, the numerical bias magnitude/direction, and the fix — is accurate.

Verifier correction: All load-bearing claims are confirmed. The only overstatement is in the "second defect": the claim that for K > max_val the top categories are "unreachable (non-ergodic)". This is not strictly true in general. The chain is initialized from an actual prior draw over [0,K) (PriorHandler samples the real Categorical), and from any state x the proposal can reach up to x+4 (since max_val = max(x+5,10), the largest proposable index is max_val−1 = x+4 for x≥5). Hence high categories are reachable by incremental climbing as long as the intervening categories carry positive target mass, so the chain is technically irreducible in the generic all-positive-probability case. Strict non-ergodicity only arises in the degenerate case where an intermediate category has exactly zero target probability and blocks the climb. So this sub-point is better stated as a severe mixing/efficiency problem (plus genuinely wasted rejections for proposals ≥ K, which IS correct) rather than guaranteed non-ergodicity. This nuance does not weaken the primary finding: the asymmetric uncorrected proposal biases the stationary distribution, which the simulation confirms numerically.

Resolution: fixed-with-design-change — Initial fix (unchanged by the fixup): SingleSiteProposalHandler.on_sample_usize replaced the current-dependent UniformCategoricalProposal with an independence sampler that resamples directly from the site's prior, so q(x'|x) cancels the prior in the acceptance ratio and every category stays reachable regardless of the true category count. The original K=3 regression test could not distinguish this from the pre-fix bias; the fixup rewrote it to a K=8 target with per-category and aggregate-L1 tolerances that pass on the fix and fail (L1=0.061) on the reinstated asymmetric proposal, plus a new K=12>max_val case proving top-category reachability.

Regression tests: fg10_categorical_prior_resample_recovers_posterior, fg10_categorical_top_categories_reachable_for_large_k.

Re-verification: verified (independent adversarial verifier).

FG-11 — Single-site MH is O(N) per step (full trace clone + full model re-execution) instead of O(1), making full sweeps O(N^2) in the number of latent variables

  • Location: fugue/src/inference/mh.rs:376
  • Severity: high · Dimension: performance · Verification: confirmed · Auditor confidence: n/a

adaptive_single_site_mh nominally updates one site, but per call it: (1) builds let sites: Vec<_> = current.choices.keys().collect() (line 376) — an O(N) traversal/allocation of the whole BTreeMap just to pick 1 random key; (2) does current.clone() at least twice and up to four times (lines 386, 396, 403, 421, 426) — each a deep clone of the BTreeMap<Address,Choice>, i.e. O(N) String allocations (each Address is stored twice, once as the map key and once inside Choice.addr, per trace.rs:124-131/168-177); (3) calls ScoreGivenTrace 2-3 times, each of which re-executes the ENTIRE model from scratch (re-derives every sample/observe site, not just the touched one) and internally clones every retained Choice again into a freshly built output trace (interpreters.rs:335 self.trace.choices.insert(addr.clone(), c.clone()) executed once per site). Net effect: a conceptually O(1) single-site update costs O(N) time and O(N) allocations, repeated 2-4x per call. Since single-site MH needs on the order of N steps to update every latent variable once (one 'sweep'), a full sweep costs O(N) steps × O(N) work/step = O(N^2), and adaptive_mcmc_chain (mh.rs:480-512) running S total single-site steps costs O(S·N). For models where N grows with problem size (e.g. a hierarchical or time-series model with N latent states), this quadratic-in-model-size behavior will silently dominate runtime with no error or warning — it just gets slow. The identical .keys().cloned().collect() pattern to pick one random site recurs in fugue/src/inference/abc.rs:434, so this is a structural idiom across the crate's site-selection code, not a one-off slip.

Suggested fix: Avoid materializing all keys just to pick one (e.g. reservoir-sample or use an index into an order-stable Vec

maintained once per chain instead of rebuilding from the BTreeMap every step). More importantly, replace 'clone whole trace + rerun whole model' with an incremental update: mutate only the touched site and only recompute the log-density contributions of that site (plus any observe sites that structurally depend on it), rather than re-deriving all N choices via ScoreGivenTrace's on_sample_* callbacks every time.

Verifier correction: One numeric detail is slightly understated. The auditor says the trace is cloned "at least twice and up to four times". Counting the actual .clone() calls of a whole Trace: on the ACCEPT path there are 3 (lines 386, 396, 403 — line 417 returns the already-cloned proposed_trace, not a new clone), and on the REJECT path there are 5 (386, 396, 403, 421, 426). So the real range is 3-5 whole-trace clones per call, not 2-4. Also the abc.rs line is 433, not 434. Neither correction weakens the finding; the O(N)-per-step and O(N^2)-per-sweep conclusions are unaffected.

Resolution: mitigated-and-documented — The 3-5 whole-trace clones per step and the .keys().collect() O(N) site-list rebuild every iteration are eliminated: a new SingleSiteProposalHandler folds proposal generation and full re-scoring into a single model run, and adaptive_mcmc_chain_with_overrides caches the current state's log-weight and site list across iterations so each transition re-executes the model exactly once. A new mh.rs module doc section ('Cost model (FG-11)') explicitly documents that per-transition cost remains O(model-size) because scoring any proposal inherently requires re-deriving the log-density contributions of the whole model, and recommends gradient-based kernels for models with very many latent variables.

Regression tests: one_model_run_per_transition.

Re-verification: verified (independent adversarial verifier).

FG-12 — adaptive_single_site_mh re-runs the entire model a third, provably redundant time on every rejected proposal

  • Location: fugue/src/inference/mh.rs:419
  • Severity: high · Dimension: performance · Verification: confirmed · Auditor confidence: n/a

In adaptive_single_site_mh (mh.rs:357-428), let (_a_cur, cur_scored) = run(ScoreGivenTrace{ base: current.clone(), .. }, model_fn()) computes and discards _a_cur (line 394-400). ScoreGivenTrace (interpreters.rs:316-410) has no RNG field and is purely a deterministic function of base and the model: every on_sample_* reads its value from base.choices and never calls dist.sample. Therefore, on the reject branch (mh.rs:419-425), rerunning run(ScoreGivenTrace{ base: current.clone(), .. }, model_fn()) to obtain a reproduces byte-for-byte the same value that was already computed and thrown away as _a_cur. This wastes, on every rejected step (the common case at a tuned ~30-50% acceptance rate), one full extra model traversal (re-executing every sample/observe call, re-allocating every Address via addr!()) plus one extra current.clone() deep copy of the whole trace.

Suggested fix: Keep the first result: rename _a_cur to a_cur and return (a_cur, current.clone()) in the reject branch instead of re-running ScoreGivenTrace a third time.

Resolution: fixedadaptive_single_site_mh now keeps the model result a_cur from the single current-state ScoreGivenTrace run and reuses it on the reject branch ((a_cur, current.clone())) instead of re-running ScoreGivenTrace a third time; the driver-based single_site_mh_step path never rescoring the current state at all on rejection, matching the suggested fix.

Regression tests: one_model_run_per_transition.

Re-verification: verified (independent adversarial verifier).

FG-13 — Rejuvenation re-weights particles by the full joint after an MCMC move, re-introducing prior-squaring and destroying the post-resample uniform weights

  • Location: fugue/src/inference/smc.rs:407
  • Severity: high · Dimension: correctness · Verification: confirmed · Auditor confidence: certain

After resampling (which correctly sets weights to uniform, lines 314-315) and an adaptive_single_site_mh rejuvenation move, line 407-408 does particle.log_weight = particle.trace.total_log_weight() and then normalize_particles renormalizes. Two problems: (1) an MCMC kernel that leaves the posterior invariant should keep particle weights UNIFORM — reweighting at all is wrong; (2) reweighting by total_log_weight() again applies the same prior-squaring bias as Finding 1. The result is a set of particles that were just equalized by resampling, then re-skewed by ~p(theta)^2 p(y|theta) density. This corrupts the population whenever rejuvenation_steps>0.

Suggested fix: Do not reweight after a posterior-invariant MCMC move; leave weights uniform. If a genuine SMC tempering step is intended, add only the incremental log-weight for the new target, never the full joint.

Verifier correction: The defect is real and as located (smc.rs:407-408, +412 renormalize). Correction only to the characterization of the resulting bias: in the rejuvenation context the resampled+moved particles already approximate the posterior π ∝ p(θ)p(y|θ), so reweighting each by total_log_weight ∝ p(θ)p(y|θ) skews the weighted density toward π² ∝ [p(θ)p(y|θ)]², i.e. an extra full-posterior factor causing over-concentration — not literally p(θ)²p(y|θ) (that latter expression is the effective density of the initial prior-importance-sampling step described in Finding 1). Core fix stands: after a posterior-invariant MCMC rejuvenation move following resampling, leave weights uniform (do not reweight and do not renormalize by the full joint).

Verifier correction: The reweight at line 407 is an assignment (=), not a multiplication, so it overwrites the uniform post-resample weight rather than compounding it. Each particle's weight therefore becomes proportional to the full joint p(theta,y) = p(theta)p(y|theta); applied to a population that already approximates the posterior pi, the weighted empirical distribution is skewed toward pi^2 = p(theta)^2 p(y|theta)^2, not exactly the 'p(theta)^2 p(y|theta)' stated. The qualitative defect (a just-equalized/uniform population re-skewed by the full joint after a posterior-invariant MCMC move) and the fix (do not reweight; keep weights uniform) are correct. Note this only affects runs with rejuvenation_steps > 0; SMCConfig::default() uses 0.

Resolution: fixed — The rejuvenation step in src/inference/smc.rs was changed to a pi_beta-invariant MH move that no longer reassigns particle.log_weight = trace.total_log_weight() after resampling; weights are left uniform following rejuvenation instead of being re-skewed by the full joint density.

Regression tests: fg13_rejuvenation_preserves_uniform_weights.

Re-verification: verified (independent adversarial verifier).

FG-14 — Sampler statistical correctness (moments/goodness-of-fit) is validated for exactly one distribution (Normal) out of nine

  • Location: fugue/src/inference/validation.rs:279
  • Severity: high · Dimension: testing · Verification: judgment · Auditor confidence: n/a

ks_test_distribution (validation.rs:17) is a legitimate two-sample KS goodness-of-fit test and is a genuine strength where it's used - but grep across the whole crate shows its only 3 call sites (validation.rs:251, validation.rs:292, tests/public_api_coverage.rs:735, tests/inference_integration.rs:374) all test Normal::new(0.0,1.0) against Normal reference samples. Beta, Gamma, Poisson, Binomial, Exponential, LogNormal, Bernoulli, Categorical samplers have zero moment-matching or chi-square/KS tests anywhere in the crate; distribution.rs's sampling_basic_sanity (line 1226) checks only finiteness/bounds for Normal, Uniform, Bernoulli. A parameterization bug (e.g., Gamma::new(shape,rate) accidentally treated as Gamma(shape,scale), or Poisson using variance instead of rate) would be invisible to the test suite as long as sampled values stay finite/in-support.

Suggested fix: Add ks_test_distribution or chi-square goodness-of-fit checks for Beta, Gamma, Poisson, Binomial, Exponential at minimum, using either scipy-generated reference samples or independent reference samplers (as already done for Normal via Box-Muller in validation.rs:284-289).

Resolution: fixed — Added tests/f_tests_sampler_validation.rs: a one-sample Kolmogorov-Smirnov test (n=5000 seeded draws, alpha=0.001) against hand-implemented analytic CDFs for all 12 continuous distributions, plus chi-square goodness-of-fit tests (alpha=0.001, tabulated critical values) for all 5 discrete distributions, plus a standardized-moment check |sample_mean-mu|/SE<5 for all 17 exported distributions in src/core/distribution.rs (previously only Normal had any statistical test). The analytic CDFs route through hand-implemented regularized incomplete gamma (Numerical-Recipes gser/gcf) and incomplete beta (betacf) special functions, cross-checked offline in tests/gen_refs.py via pure-stdlib Simpson's-rule numerical integration of the raw PDFs plus closed-form identities (Gamma(1,r)==Exponential(r), ChiSquared(2)==Exponential(0.5), Beta(1,1)==Uniform(0,1), StudentT(1)==Cauchy) since scipy/mpmath were unreachable in this sandbox (no network egress to PyPI). Verified the Gamma test is discriminating by manually injecting a rate-vs-scale parameterization bug into distribution.rs, confirming the test fails (z=362.9), then reverting.

Regression tests: tests/f_tests_sampler_validation.rs: fg14_normal_ks_and_moments, fg14_uniform_ks_and_moments, fg14_lognormal_ks_and_moments, fg14_exponential_ks_and_moments, fg14_beta_ks_and_moments, fg14_gamma_ks_and_moments, fg14_studentt_ks_and_moments, fg14_cauchy_ks_only, fg14_laplace_ks_and_moments, fg14_weibull_ks_and_moments, fg14_chi_squared_ks_and_moments, fg14_inverse_gamma_ks_and_moments, fg14_bernoulli_chi_square_and_moments, fg14_categorical_chi_square_and_moments, fg14_binomial_chi_square_and_moments, fg14_poisson_chi_square_and_moments, fg14_discrete_uniform_chi_square_and_moments.

Re-verification: verified (independent adversarial verifier).

FG-15 — The library's dedicated analytical-posterior validation harness (test_conjugate_normal_model) is exported but never exercised by any test

  • Location: fugue/src/inference/validation.rs:92
  • Severity: high · Dimension: completeness · Verification: judgment · Auditor confidence: n/a

test_conjugate_normal_model (validation.rs:92-157) computes the exact Normal-Normal conjugate posterior and checks MCMC output against it within 2 standard errors - this is precisely the 'gold standard' PPL validation the task description calls for. It is publicly re-exported (fugue/src/lib.rs:49-51: pub use inference::validation::{ks_test_distribution, test_conjugate_normal_model, ValidationResult};). Grepping the whole repo for test_conjugate_normal_model( and ConjugateNormalConfig shows zero call sites outside its own definition - it is not invoked by validation.rs's own #[cfg(test)] modules, nor by any integration test. tests/inference_integration.rs:385-387 explicitly notes: 'For now, just test that the validation function exists... The full conjugate test would require the ConjugateNormalConfig which isn't exported' - but ConjugateNormalConfig IS reachable via fugue::inference::validation::ConjugateNormalConfig since inference (lib.rs:12) and validation (inference/mod.rs:7) are both pub mod. Note the underlying Normal-Normal and Beta-Binomial conjugate math IS separately validated by hand-rederiving the same closed forms inline in test_mcmc_normal_mean_recovery (inference_integration.rs:114) and test_mcmc_beta_binomial_conjugacy (inference_integration.rs:391), so the actual mathematical claim is checked - just not through the reusable harness built for that purpose, which sits as dead-from-a-testing-perspective code.

Suggested fix: Add a test that calls test_conjugate_normal_model with adaptive_mcmc_chain and asserts is_valid(), exercising the harness the library authors built specifically for this purpose; fix the integration-test comment which incorrectly claims the config type isn't exported.

Resolution: fixed — Extended src/inference/validation.rs with test_conjugate_beta_bernoulli_model/ConjugateBetaBernoulliConfig alongside the existing test_conjugate_normal_model/ConjugateNormalConfig, factoring the shared MCMC-vs-analytical-posterior scoring logic into a private validate_against_analytical_posterior helper to avoid duplication. Re-exported the new public items from src/lib.rs. Added tests/analytical_validation.rs which actually calls both harnesses end-to-end (adaptive_mcmc_chain -> analytical posterior comparison) with seeded RNG, for a Normal-Normal model and a Beta-Bernoulli model (built via traverse_vec over indexed Bernoulli observations), asserting is_valid() and cross-checking the harness's own posterior arithmetic against independently-derived closed-form values. Fixed the stale/false comment in tests/inference_integration.rs claiming ConjugateNormalConfig 'isn't exported'. Verified the harness is discriminating by injecting a +5.0 bias into the posterior-mean formula and confirming the new test fails, then reverting.

Regression tests: tests/analytical_validation.rs: fg15_conjugate_normal_model_harness_is_exercised, fg15_conjugate_beta_bernoulli_model_harness_is_exercised.

Re-verification: verified (independent adversarial verifier).

FG-16 — Finite-difference ELBO gradient uses independent Monte Carlo draws (no common random numbers) and mismatched sample counts, giving a noise-dominated, biased gradient

  • Location: fugue/src/inference/vi.rs:450
  • Severity: high · Dimension: math · Verification: confirmed · Auditor confidence: certain

optimize_meanfield_vi estimates dELBO/dphi as (elbo_plus - current_elbo)/eps with eps=0.01. current_elbo (line 435) is a Monte Carlo average over n_samples_per_iter draws from rng; elbo_plus (line 450) is a SEPARATE MC average over a hard-coded 10 draws that advance the SAME rng, so the two estimates use independent random numbers. The variance of the difference is Var[elbo_plus]+Var[current_elbo] (they do not cancel), and dividing by eps=0.01 amplifies the standard deviation by 100x. Correct stochastic finite differencing requires common random numbers (evaluate both perturbed and base ELBO on the identical sample set) so the O(1) noise cancels and only the O(eps) signal survives; alternatively use the analytic score-function estimator grad = E_q[(log p - log q) grad_phi log q] (with a control-variate baseline) or the reparameterization gradient. I simulated the exact scheme (base n=3, plus n=10, independent noise, eps=0.01): the FD estimate had mean -1.10 and std 131.6 against a true gradient of +1.0 (wrong sign on average, signal-to-noise ~0.008), whereas the common-random-number FD gave mean 0.995, std ~0. The mismatched sample counts (n_samples_per_iter vs 10) also make the two estimates have different bias, adding a systematic error to the difference.

Suggested fix: Reuse a fixed set of standard-normal draws (reparameterization) or a fixed rng seed for both ELBO evaluations so noise cancels; or switch to the analytic score-function/reparameterized gradient with a baseline. Use the same sample count for base and perturbed evaluations.

Verifier correction: The core defect (no common random numbers -> noise-dominated gradient, 100x std amplification from 1/eps, independent draws off the same advancing rng) is real and correctly described. However, two sub-claims are inaccurate and should be dropped: (1) that mismatched sample counts (n_samples_per_iter vs 10) create 'different bias' / 'systematic error' in the difference, and (2) that the gradient is 'wrong sign on average'. elbo_with_guide is a plain average of iid unbiased draws, so it is an unbiased estimator of the true ELBO for ANY sample count; mismatched n changes only the variance, not the expectation. Hence the FD gradient is unbiased in expectation up to the standard small O(eps) forward-difference bias — it is not systematically biased in sign. The auditor's reported mean of -1.10 (vs true +1.0) is merely a noisy point estimate (with std ~132 the sample mean is itself meaningless); my simulation's no-CRN mean was ~0.96, consistent with the true +1.0. The accurate characterization is 'noise/variance-dominated gradient', not 'biased gradient'.

Verifier correction: The dominant, real defect is variance/noise domination from the absence of common random numbers (std amplified ~100x by dividing by eps=0.01), confirmed both analytically and by simulation. However, the finding's "biased" framing is imprecise and partially wrong: elbo_with_guide is an unbiased Monte Carlo estimator of the ELBO for ANY sample count (mean of i.i.d. unbiased per-sample terms log p - log q), so E[(elbo_plus - current_elbo)/eps] = (ELBO(guide_plus) - ELBO(guide))/eps, the correct directional finite difference up to the usual O(eps) discretization term — regardless of the mismatched sample counts. The mismatched sample counts do NOT add systematic bias to the difference. The finding's reported 'mean -1.10, wrong sign on average' is itself Monte-Carlo sampling noise (given the ~131 std), not a genuine bias; my simulation's independent-FD mean was statistically consistent with the true gradient. So: confirm the noise-domination / lack-of-CRN defect and the recommended fixes; drop the claim that mismatched sample counts cause a biased gradient.

Resolution: fixed — A new elbo_gradient_fd function in src/inference/vi.rs computes central finite differences (elbo_plus - elbo_minus)/(2*eps) where both the +eps and -eps ELBO evaluations are seeded from the same StdRng::seed_from_u64(seed), i.e. common random numbers, and both use the same n_samples_per_iter sample count, replacing the old scheme that used the ambient advancing rng for the base ELBO and a separate hard-coded 10-sample draw for the perturbed ELBO.

Regression tests: fg16_crn_gradient_sign_matches_analytic.

Re-verification: verified (independent adversarial verifier).

FG-17 — Guide ignores the model's support and value types: sample_trace always emits F64, causing -inf ELBO for constrained-support latents and panics for discrete latents

  • Location: fugue/src/inference/vi.rs:384
  • Severity: high · Dimension: correctness · Verification: confirmed · Auditor confidence: certain

sample_trace always stores value: ChoiceValue::F64(value) (line 381-388). Two failure modes follow when the guide family does not match the model's support: (1) Continuous constrained latents: a VariationalParam::Normal guide proposes values on all of R. If the model site is e.g. Beta ([0,1]), Gamma/Exponential/LogNormal ((0,inf)), then in elbo_with_guide the model's dist.log_prob(&x) returns -inf for out-of-support proposals (ScoreGivenTrace::on_sample_f64, interpreters.rs:333), so log_joint=-inf and the ELBO term is -inf; averaging yields -inf with no diagnostic. There is no transform / Jacobian mapping an unconstrained guide to the constrained support (the standard fix, e.g. sigmoid for [0,1] or exp for (0,inf) with the corresponding log-Jacobian added to log q). (2) Discrete latents: for a bool/u64/usize sample site, ScoreGivenTrace::on_sample_bool/u64/usize (interpreters.rs:347/363/379) does match c.value { ChoiceValue::Bool(v)=>..., _ => panic!("expected bool at ...") }, so the F64-only guide makes elbo_with_guide panic. Related: if the guide holds an address the model never samples, its -log q(z) is still subtracted from the ELBO with no matching log p term, biasing the estimate.

Suggested fix: Match guide family to model support and store the correct ChoiceValue variant; for constrained continuous latents sample in an unconstrained base space and apply a bijector with its log-Jacobian added to log q; validate that guide addresses and types agree with the model's sample sites.

Verifier correction: Same defect as reported; only the file path is corrected. The ScoreGivenTrace scoring code is in fugue/src/runtime/interpreters.rs (on_sample_f64 at lines 323-337 with log_prob at 333; on_sample_bool/u64/usize panics at lines 347/363/379), not a top-level interpreters.rs. The vi.rs store of ChoiceValue::F64 is at line 385 (finding said ~384). Beta out-of-support -inf is at distribution.rs:808-810. All substantive claims hold.

Resolution: fixed — src/inference/vi.rs adds a Support enum (Real/Positive/Unit), a GuideError::UnsupportedDiscreteLatent typed error, VariationalParam::for_support to build a family matching a latent's declared support, and MeanFieldGuide::add_latent/from_trace now return Result<_, GuideError> and reject discrete latents instead of always storing ChoiceValue::F64.

Regression tests: fg17_discrete_latent_is_typed_error, fg17_positive_support_guide_matches_and_is_finite, fg17_unit_support_guide_matches_and_is_finite.

Re-verification: verified (independent adversarial verifier).

FG-18 — MeanFieldGuide::from_trace builds a degenerate LogNormal guide (log_sigma = ln(0) = -inf) that samples NaN

  • Location: fugue/src/inference/vi.rs:329
  • Severity: high · Dimension: correctness · Verification: confirmed · Auditor confidence: certain

For a positive f64 choice, from_trace creates VariationalParam::LogNormal { mu: val.ln(), log_sigma: 0.0_f64.ln() } (lines 327-330). In Rust 0.0_f64.ln() evaluates to -inf (no panic), so log_sigma = -inf and sigma = exp(-inf) = 0.0. VariationalParam::sample then hits the guard sigma <= 0.0 (line 155-157) and returns f64::NAN. sample_trace stores that NaN and log_prob(NaN) = NaN, poisoning trace.log_prior, so elbo_with_guide returns NaN for any model with a positive continuous latent (i.e. any scale/rate/variance parameter). I verified numerically: ln(0)=-inf, exp(-inf)=0.0, guard sigma<=0 true -> NaN. Every other branch correctly uses 1.0_f64.ln() (= 0.0, sigma=1); only this LogNormal branch uses 0.0_f64.ln(). This is almost certainly a typo for 1.0_f64.ln(). Separately, choosing LogNormal purely because a single prior draw was positive gives a guide whose support (0,inf) may not match the latent's true support and can never represent negative regions.

Suggested fix: Replace 0.0_f64.ln() with 1.0_f64.ln() (or 0.0) so sigma initializes to 1; do not infer support from the sign of a single prior sample.

Verifier correction: The root cause (log_sigma = 0.0_f64.ln() = -inf → sigma = 0 → sample() returns NaN) is exactly correct. The downstream mechanism differs: the finding claims log_prob(NaN) returns NaN and silently poisons trace.log_prior so elbo returns NaN. In fact sample_trace (vi.rs:379) calls param.log_prob(value), and VariationalParam::log_prob for LogNormal (vi.rs:245-247) does LogNormal::new(*mu, sigma).unwrap() with sigma=0.0; the custom LogNormal::new (fugue/src/core/distribution.rs:344) returns Err when sigma <= 0.0, so .unwrap() panics rather than returning NaN. Net effect is the same or worse (unusable/crashing guide for positive latents). Fix is as suggested: replace 0.0_f64.ln() with 1.0_f64.ln() (or 0.0) so sigma initializes to 1, and do not infer LogNormal support from the sign of a single prior draw.

Resolution: fixed — A new init_log_sigma(value) helper in src/inference/vi.rs returns ln(max(0.1*|value|, 0.1)), which is always finite, and MeanFieldGuide::from_trace now uses it for the LogNormal branch instead of the old 0.0_f64.ln() (which evaluated to -inf and produced NaN samples).

Regression tests: fg18_from_trace_scale_is_finite_and_nan_proof.

Re-verification: verified (independent adversarial verifier).

FG-19 — Model interpreter run()/go() is recursive with no trampoline — deep models (large plate!/loops) overflow the stack

  • Location: fugue/src/runtime/handler.rs:100
  • Severity: high · Dimension: correctness · Verification: confirmed · Auditor confidence: likely

run interprets the model via an inner fn go that, for every effectful node, calls go(h, k(x)) (lines 105, 109, 113, 117, 126, 134, 142, 150, 157). Rust guarantees no tail-call optimization, so interpretation depth equals the number of sample/observe/factor nodes on the execution path; each node keeps a live stack frame (match arm + boxed FnOnce). A model with ~10k sequential binds therefore consumes ~10k frames. On the default 8MB main-thread stack this is right at the overflow boundary and deeper chains overflow outright. This is not an exotic case: the library's own plate! macro lowers to traverse_vecsequence_vec (model.rs:538-545, macros/mod.rs:48-50), which folds N models into an N-deep bind chain, and MCMC/SMC over time series routinely produces thousands of sites. The CPS construction via bind (model.rs:423-495) is O(1)-stack per bind (it defers), so the model builds fine; the overflow is purely at interpretation. A worker thread with a large stack, or rewriting go as an explicit loop over an enum step (the encoding is already a linked list of continuations, so a while let trampoline is natural), removes the ceiling.

Suggested fix: Convert go from recursion to an iterative loop: hold let mut m = model; and loop { match m { Pure(a)=>return a, Sample..=>{ let x = h.on_sample(..); m = k(x); } ... } }. This makes interpretation O(1) stack regardless of model depth.

Verifier correction: The mechanism and location are correct. One refinement to the quantitative estimate: the "~10k frames / right at the 8MB boundary" figure is approximate and somewhat conservative. Per traversed node the code stacks more than a single frame — go(h, k(x)) first evaluates k(x) = k1(x).bind(k), invoking the recursive bind over the left spine before go itself recurses — so the per-node stack cost is a small constant multiple and the true overflow threshold depends on frame sizes and the actual stack limit (Rust spawned threads default to 2MB; the macOS main thread default is 8MB). The qualitative claim is unaffected: interpretation stack grows unboundedly with model depth and deep chains overflow with no way to raise the ceiling short of the trampoline fix.

Resolution: fixed — Initial fix trampolined the interpreter's run()/go() recursion in handler.rs into an iterative loop, removing per-node stack growth for directly-recursive models. This left plate! uncovered: sequence_vec/traverse_vec (model.rs) still built a left-associated zip/map tower that overflowed the stack independently of the trampoline. The fixup rewrote sequence_vec to thread the result Vec through a right-nested bind chain assembled by an iterative for-loop, so the trampoline advances one plate! site per O(1) step with no terminal reverse, preserving element order.

Regression tests: interpretation_is_stack_safe_for_deep_models, fg19_deep_model_is_stack_safe_via_public_api, fg19_plate_left_fold_is_stack_safe, fg19_sequence_vec_preserves_order.

Re-verification: verified (independent adversarial verifier).

FG-20 — Branching/trans-dimensional proposals: new addresses panic; vanishing addresses give a non-RJMCMC (uncorrected) ratio plus stale ghost choices

  • Location: fugue/src/runtime/interpreters.rs:326
  • Severity: high · Dimension: correctness · Verification: confirmed · Auditor confidence: certain

Single-site MH mutates one value in a clone of the current trace (mh.rs:386–391) then re-scores via ScoreGivenTrace. If the changed value opens a branch requiring an address not already present, ScoreGivenTrace hits .unwrap_or_else(|| panic!("missing value for site {} in base trace")) (interpreters.rs:326–328) and the whole chain PANICS — so any model whose structure depends on a continuous/discrete latent (if x>0 {...}) can crash mid-run. If instead a proposal makes an existing address DISAPPEAR (a branch is skipped), that site is simply never scored, so log_alpha compares a lower-dimensional state to a higher-dimensional one with no reverse-move proposal density and no Jacobian — i.e. none of the RJMCMC dimension-matching terms — giving a silently biased trans-dimensional acceptance. Worse, on acceptance the code returns proposed_trace (mh.rs:417), which still CONTAINS the now-unused 'ghost' choice cloned from current; that stale address persists in the returned sample and may be re-selected for meaningless updates on later steps. There is no RJMCMC machinery at all.

Suggested fix: Either document/enforce fixed-structure models, or implement proper reversible-jump moves (birth/death with matched proposal densities + Jacobian). At minimum, prune addresses not visited during the proposed re-score before returning, and handle newly-required addresses by sampling them from the prior with the corresponding q-term rather than panicking.

Verifier correction: Finding is accurate. One minor label imprecision: the function is adaptive_single_site_mh (mh.rs:357), not a generic "single-site MH"; single_site_random_walk_mh (mh.rs:515) is a thin wrapper that delegates to it. All cited line numbers and mechanics are correct. Additionally, the auditor could note the codebase already ships a SafeReplayHandler (interpreters.rs:412+) that samples missing addresses instead of panicking, but the MH scoring path deliberately uses the panicking ScoreGivenTrace — so a low-effort partial mitigation for the panic already exists but is not wired into MCMC.

Resolution: fixed — Initial fix added an opt-in ReconcilingScoreGivenTrace/score_given_trace_reconciled path that samples new addresses from the prior and reports vanished ones, but the production adaptive_mcmc_chain/adaptive_single_site_mh sampler still used the uncorrected ratio. The fixup made that actual hot path RJMCMC-correct: SingleSiteProposalHandler samples born dimensions from the prior into log_q_forward, propose_and_score adds died dimensions' prior density to log_q_reverse, and single_site_mh_step/adaptive_single_site_mh add the site-selection term ln|sites(current)|-ln|sites(proposed)|; all three terms are 0 for fixed-structure models, so existing behavior is unchanged.

Regression tests: fg20_reconciling_scoring_samples_fresh_and_reports_vanished, doctest on score_given_trace_reconciled, fg20_adaptive_chain_recovers_transdimensional_posterior.

Re-verification: verified (independent adversarial verifier).

FG-21 — Core adaptive MH sampler panics instead of returning a Result when a model's address structure varies between proposals

  • Location: fugue/src/runtime/interpreters.rs:328
  • Severity: high · Dimension: correctness · Verification: confirmed · Auditor confidence: n/a

ScoreGivenTrace (used exclusively by adaptive_single_site_mh/adaptive_mcmc_chain in src/inference/mh.rs, the flagship inference entry point shown in the README's own quickstart example) calls .unwrap_or_else(|| panic!("missing value for site {} in base trace", addr)) when an address in the current model execution isn't found in the base trace (line 328, and equivalents at 344/360/376), and panic!("expected f64/bool/u64/usize at {}", addr) on a type mismatch (lines 331/347/363/379). Single-site MH (adaptive_single_site_mh, src/inference/mh.rs:357-428) re-runs the entire model against the base/proposed trace at every step; any model whose control flow makes different sample-site addresses (or a different value type at the same address) reachable depending on a sampled value — e.g. a variable-structure/model-selection model, or simply a bug that causes a plate! index to shift — will hard-crash the whole process mid-inference with no way to catch or recover. FugueError even has purpose-built variants for this (ErrorCode::UnexpectedModelStructure = 302, ErrorCode::AddressConflict = 301, ErrorCode::ModelExecutionFailed = 300) but grepping the entire src tree shows these codes are never constructed anywhere outside error.rs's own tests — the error machinery exists on paper but the hot path panics instead of using it. SafeReplayHandler/SafeScoreGivenTrace exist and avoid this failure mode, but adaptive_mcmc_chain/adaptive_single_site_mh don't use them.

Suggested fix: Either (a) make adaptive_single_site_mh/adaptive_mcmc_chain use the Safe* handler variants and return a FugueResult so structural mismatches surface as InferenceError/ModelError, or (b) clearly document the 'fixed address structure' invariant models must satisfy to use these functions safely, and add a debug-mode check that turns the panic into a documented FugueError before release.

Verifier correction: One minor imprecision in the auditor's wording: the finding says the three error codes "are never constructed anywhere outside error.rs's own tests." That is exactly true for AddressConflict(301) and UnexpectedModelStructure(302), and true for ModelExecutionFailed on the structural-mismatch path. However ModelExecutionFailed(300) IS constructed in non-test production code — in error.rs's From<&str> (line 549) and From<String> (line 560) conversion impls (line 885 is the only test construction). This does not weaken the finding: those From impls merely wrap arbitrary string messages and are never invoked from the ScoreGivenTrace panic path. The core point — the runtime/inference hot path panics instead of constructing any of these purpose-built error variants — is fully confirmed.

Verifier correction: Behavior confirmed. One cosmetic correction: the type-mismatch panic messages are per-type (panic!("expected f64 at {}", addr) at line 331, "expected bool at {}" at 347, "expected u64 at {}" at 363, "expected usize at {}" at 379), not the single combined string "expected f64/bool/u64/usize at {}" as paraphrased. This does not affect the substance of the finding.

Resolution: fixed — Initial fix added an opt-in StrictScoreGivenTrace/score_given_trace_strict path returning Err(UnexpectedModelStructure) instead of panicking, but left the actual ScoreGivenTrace used by adaptive_mcmc_chain/adaptive_single_site_mh panicking on structural mismatches. The fixup removed the panic from that real sampler path directly: SingleSiteProposalHandler now falls back to dist.sample() for addresses missing from the base trace, and the chain's cached site list refreshes on any accepted structural change, including address swaps that keep the site count constant. Reverting the fallback to panic! crashes the new branch-switching regression test.

Regression tests: fg21_strict_scoring_errors_on_new_address_instead_of_panicking, doctest on score_given_trace_strict, fg21_adaptive_sampler_handles_branch_switching_addresses.

Re-verification: verified (independent adversarial verifier).

FG-22 — Entire memory-optimization subsystem (CowTrace, TracePool, TraceBuilder, PooledPriorHandler) is dead code — never called by any inference algorithm

  • Location: fugue/src/runtime/memory.rs:1
  • Severity: high · Dimension: completeness · Verification: judgment · Auditor confidence: n/a

grep across the whole crate shows the only non-test/non-doctest references to CowTrace, TracePool, TraceBuilder, PooledPriorHandler are the re-export at fugue/src/lib.rs:54 and their own module's benches. None of fugue/src/inference/{mh,smc,vi,abc}.rs or fugue/src/runtime/interpreters.rs import or use any of these types — the real MCMC/SMC/VI/ABC code paths use plain Trace (BTreeMap<Address,Choice>) with ordinary .clone(), PriorHandler, ReplayHandler, and ScoreGivenTrace. Yet fugue/src/docs/runtime/memory.md explicitly and falsely claims 'Seamlessly integrates with all handler types and inference algorithms' and, in its 'Integration Notes' section, asserts per-algorithm integration that does not exist in the code ('MCMC: CowTrace ideal for sharing data between proposal states', 'SMC: TracePool essential for particle generation/resampling', 'VI: TraceBuilder efficient for gradient estimation', 'ABC: Pool + Builder combination for rejection sampling loops'). The doc also cites four example files (examples/memory_pool_basic.rs, cow_trace_mcmc.rs, zero_allocation_inference.rs, memory_profiling_demo.rs) that do not exist anywhere in the repo (verified via find). The 'zero-allocation inference' headline claim in memory.md is therefore aspirational, not descriptive of shipped behavior.

Suggested fix: Either wire PooledPriorHandler/CowTrace into mh.rs/smc.rs/vi.rs/abc.rs (e.g. use TracePool inside adaptive_mcmc_chain's sample loop, use CowTrace instead of Trace for the MH current/proposed state) or remove the misleading integration claims and nonexistent example references from memory.md, and consider gating the unused public API behind an explicit 'experimental, not yet wired into inference' note.

Resolution: fixed-by-removal — Initial fix deleted the entire memory-optimization subsystem (memory.rs: CowTrace, TracePool, TraceBuilder, PooledPriorHandler) after a pooling_evidence benchmark showed only ~3.8% speedup, and scrubbed most doc/example references, but missed remaining runtime::memory/TracePool/PooledPriorHandler/TraceBuilder mentions in docs/src/how-to/README.md, docs/src/how-to/production-deployment.md, docs/src/tutorials/foundation/trace-manipulation.md, and .github/CHANGELOG.md. The fixup scrubbed those four files and added tests/f_docs_no_dead_module_refs.rs, which greps docs/src and the changelog for the dead-module terms and fails CI if any are reintroduced.

Regression tests: docs_do_not_reference_removed_memory_module.

Re-verification: verified (independent adversarial verifier).

Severity: medium (27)

FG-23 — 'Production-ready' tagline contradicts 0.1.0 maturity, single-maintainer bus factor, and the project's own roadmap disclaimer

  • Location: fugue/README.md:7
  • Severity: medium · Dimension: usefulness · Verification: judgment · Auditor confidence: certain

README.md:7 bills fugue as 'A production-ready, monadic probabilistic programming library' and features list says 'Production Ready' (line 33), yet the Roadmap (lines 94-101) admits 'parts may not be 100% complete or correct yet.' Both crates are v0.1.0 (Cargo.toml). Git history shows a bus factor of 1: sole human author Alex Nodeland (plus AI co-authors Copilot/Claude); fugue is ~77 commits over ~3 weeks (Aug 19–Sep 8 2025) and fugue-evo ~42 commits (Dec 2025–Feb 2026). No SemVer stability guarantee is possible pre-1.0, and no second maintainer means continuity risk. For a staff engineer, 'production-ready' at 0.1.0 with a single maintainer and a self-admitted correctness caveat is a red flag that undermines rather than builds trust.

Suggested fix: Drop 'production-ready' in favor of accurate framing (e.g. 'a monadic PPL for Rust, pre-1.0, actively developed'); keep the honest roadmap language, which is a strength. Add an explicit API-stability/SemVer policy note.

Resolution: fixed — Replaced the 'production-ready' tagline in README.md and docs/src/home.md with honest positioning ('type-safe, monadic, pre-1.0, actively developed'); added an explicit pre-1.0 SemVer policy note (Cargo's pre-1.0 convention) to README's Roadmap section; deleted a stale, unreferenced docs/index.html landing page that duplicated the exact overclaiming language and outdated feature list (verified nothing in book.toml/docs.yml/README references it -- GH Pages deploys docs/book, not this file); softened one parallel 'Production Ready' heading in docs/src/getting-started/README.md to 'Diagnostics Built In'.

Re-verification: verified (independent adversarial verifier).

FG-24 — Benchmark suite never measures the actual inference algorithms — it benchmarks either dead code or isolated bookkeeping utilities

  • Location: fugue/benches/memory_benchmarks.rs:1
  • Severity: medium · Dimension: testing · Verification: judgment · Auditor confidence: n/a

benches/memory_benchmarks.rs (494 lines) exercises CowTrace/TracePool/TraceBuilder entirely in isolation — the exact subsystem shown to be unused by real inference code (see the dead-code finding). Its 'mcmc_memory' group (lines 272-396) compares 'standard_traces' vs 'pooled_traces' vs 'cow_mcmc_pattern', but none of these call adaptive_mcmc_chain or adaptive_single_site_mh from mh.rs — the functions a library user actually calls to run MCMC. Separately, benches/mcmc_benchmarks.rs (263 lines) only benchmarks DiminishingAdaptation::update/get_scale and the standalone diagnostic functions effective_sample_size_mcmc/geweke_diagnostic in isolation — again never adaptive_single_site_mh or adaptive_mcmc_chain themselves. There is no benchmark anywhere in the crate that runs a representative model through the shipped MCMC/SMC/VI/ABC entry points end-to-end, so none of the 'this optimization helps MCMC' claims in the benches' doc comments (e.g. memory_benchmarks.rs:1-7 'validate the performance improvements ... in MCMC') are actually validated against the code path that ships to users, and the O(N) per-step cost identified in mh.rs is invisible to this bench suite.

Suggested fix: Add a criterion benchmark group that calls adaptive_mcmc_chain/adaptive_smc directly on a small parametrized model (varying number of sites N and number of samples) so the real per-step and per-N cost is visible and regressions in the hot path are caught.

Resolution: fixed — The bench suite previously measured only dead code (memory_benchmarks.rs) or isolated bookkeeping (mcmc_benchmarks.rs: DiminishingAdaptation/ESS/geweke) and never ran the shipped inference entry points. Added/completed benches/f_perf.rs which benchmarks the real entry points end-to-end on a reference hierarchical model: adaptive_mcmc_chain (20- and 50-site), the tempered adaptive_smc (64 particles, rejuvenation_steps=3, exercising resample+MCMC-move), and elbo_with_guide (128 samples). Committed baseline numbers in the module doc comment (mcmc 20-site 1.53ms / 50-site 7.31ms; smc 49.4ms; vi 2.27ms) as regression tripwires, all measured seeded/deterministic. Deleted benches/memory_benchmarks.rs (it benchmarked the now-deleted subsystem). Cargo.toml already declared the f_perf bench; kept mcmc_benchmarks.rs since it benchmarks live utilities (not deleted code).

Regression tests: benches/f_perf.rs bench_mcmc_end_to_end (20/50-site adaptive_mcmc_chain), benches/f_perf.rs bench_smc (tempered adaptive_smc), benches/f_perf.rs bench_vi_elbo (elbo_with_guide).

Re-verification: verified (independent adversarial verifier).

FG-25 — Three of the four advertised 'Multiple Inference Methods' (SMC, ABC, VI) have zero coverage across all 14 examples and all mdBook guides

  • Location: fugue/examples
  • Severity: medium · Dimension: completeness · Verification: judgment · Auditor confidence: n/a

README.md:31 and docs/src/home.md:44 both list 'Multiple Inference Methods: MCMC, SMC, Variational Inference, ABC' as a headline feature, and lib.rs re-exports adaptive_smc/SMCConfig, abc_rejection/abc_smc, and optimize_meanfield_vi/elbo_with_guide at the crate root. But grepping all 14 files in fugue/examples/.rs for these identifiers returns zero matches — only adaptive_mcmc_chain (MCMC) is exercised, across 6 of the 14 examples. The same grep against docs/src/**/.md (the mdBook tutorial/how-to source) also returns zero matches. A first-time user following the README's pointer to 'Examples — see examples/ directory' or the mdBook guides would never see SMC, ABC, or VI actually invoked; the only usage examples for 3 of 4 headline inference methods live solely in rustdoc doctests buried inside src/inference/{smc,abc,vi}.rs.

Suggested fix: Add at least one example file (or one section in an existing example) exercising adaptive_smc, abc_rejection/abc_smc, and optimize_meanfield_vi/elbo_with_guide so the advertised inference-method diversity is actually demonstrated end-to-end, not just claimed.

Resolution: fixed — Added examples/smc_inference.rs, examples/abc_inference.rs, examples/vi_inference.rs -- seeded (StdRng::seed_from_u64), each running the post-remediation SMC/ABC/VI APIs (adaptive_smc, abc_smc_weighted, optimize_meanfield_vi_with_config) on a shared conjugate Normal-Normal model with a closed-form posterior N(1.2, 0.2) (derived via python3, hardcoded as commented constants), and asserting the recovered posterior against justified tolerances. Verified each example's actual output empirically before locking tolerances (SMC mean 1.2004, ABC mean 1.2153, VI fit Normal(1.1799, 0.4385) vs exact Normal(1.2, 0.4472)). Wired all three into a new mdBook 'Advanced Inference' tutorial section (docs/src/tutorials/advanced-inference/{README,sequential-monte-carlo,approximate-bayesian-computation,variational-inference}.md) using the existing ANCHOR-include convention, added SUMMARY.md entries, cross-linked from tutorials/README.md, foundation/README.md, and statistical-modeling/README.md (also fixed two pre-existing dangling links to a nonexistent 'advanced-applications' page in passing). Added hmc_chain to the README's example index and to the mdBook overview. Verified mdbook build renders the ANCHOR includes correctly.

Regression tests: tests/f_docs_inference_examples.rs: fg25_smc_example_recovers_known_posterior, fg25_abc_example_recovers_known_posterior, fg25_vi_example_recovers_known_posterior -- mirror the three examples as #[test]s so a regression in adaptive_smc/abc_smc_weighted/optimize_meanfield_vi_with_config (or their public API surface) fails CI, not just an eyeballed example run.

Re-verification: verified (independent adversarial verifier).

FG-26 — Address is a raw stringly-typed wrapper with format!-based indexing and zero collision detection, despite a dedicated AddressConflict error code

  • Location: fugue/src/core/address.rs:57
  • Severity: medium · Dimension: correctness · Verification: confirmed · Auditor confidence: n/a

Address(pub String) and addr!($name, $i) => Address(format!("{}#{}", $name, $i)) (address.rs:52-60) mean two syntactically different addr! calls can collide silently: addr!("x#3") and addr!("x", 3) both produce Address("x#3"), as do addr!("a", "b#3") and addr!("a#b", 3) (both -> "a#b#3", since $i:expr accepts anything Display, including strings). Every place a Choice is inserted into a trace (PriorHandler/ReplayHandler/ScoreGivenTrace in runtime/interpreters.rs, TraceBuilder in runtime/memory.rs, Trace::insert_choice in runtime/trace.rs) uses a plain HashMap/BTreeMap::insert, which silently overwrites on collision with no detection or warning. Concretely, in PriorHandler::on_sample_f64 the trace's log_prior is incremented unconditionally before the insert, so if a plate!/model bug causes the same address to be sampled twice, log_prior double-counts both samples' log-density while trace.choices retains only the second value — a silent, hard-to-debug likelihood corruption. This is exactly the scenario ErrorCode::AddressConflict (301) exists to describe, but per the dead-code finding above, that code is never actually raised anywhere.

Suggested fix: Have Choice insertion check choices.contains_key(&addr) and return/raise a FugueError::ModelError{code: AddressConflict} (or at minimum debug_assert!) on collision, and consider a stricter Address constructor that disallows the # separator character in raw names used with addr!(name) to prevent format-string collisions with addr!(name, i).

Resolution: fixed — Made addr! collision-free with an injective backslash escaping scheme applied to each segment (escape_addr_segment: '\'->"\\", '#'->"\#"); the name/index separator is the only unescaped '#'. addr!("a#1") now stores "a\#1" while addr!("a",1) stores "a#1", so they are distinct. Names without '#'/'\' are stored verbatim (common case unchanged), Display stays human-readable, and the scheme is documented on Address.

Regression tests: src/core/address.rs::tests::addr_indexed_and_literal_hash_do_not_alias, src/core/address.rs::tests::addr_encoding_is_injective_across_hash_placements, tests/f_runtime_audit.rs::fg26_fg52_addr_indexing_is_collision_free.

Re-verification: verified (independent adversarial verifier).

FG-27 — Beta::log_prob's ln_x/ln_(1-x) < -700 guard returns -inf for finite (even large-positive) densities — wrong including in sign for α<1 or β<1 (e.g. Jeffreys prior)

  • Location: fugue/src/core/distribution.rs:826
  • Severity: medium · Dimension: math · Verification: confirmed · Auditor confidence: certain

Line 826 if ln_x < -700.0 || ln_1_minus_x < -700.0 { return f64::NEG_INFINITY } triggers when x is within ~1e-304 of a boundary. But log Beta(x;α,β)=(α-1)·ln x+(β-1)·ln(1-x)-lnB(α,β) is finite there whenever the density is finite, and for α<1 it actually diverges to +∞ (density ∝ x^{α-1}→∞). I verified: Beta(0.5,0.5) (the standard Jeffreys prior) at x=1e-320 has true log_prob = +367.27, and Beta(1.0,5.0) at x=1e-320 has true log_prob = +1.609 — both returned as -inf by the code. So for α≤1 the guard returns -inf when the true log-density is a large positive number (or a moderate O(1) value), i.e. the guard is wrong in sign/magnitude, not merely over-conservative. Even for α>1 (Beta(2,2)→true -735, finite) it converts a finite value to -inf. The trigger x<~1e-304 is rare in forward sampling but reachable via observe()/transformed coordinates, and α<1 priors (Jeffreys, sparse Dirichlet marginals) are common. The analogous x.ln()*(shape-1)<-700 clause in Gamma (line 930) has the same defect though its magnitude impact is smaller.

Suggested fix: Drop the ln_x/ln_(1-x) < -700 short-circuit; the lgamma-based formula is already stable and finite. If protecting against α<1 spikes, clamp the result to a finite bound rather than -inf, and never clip when (α-1) or (β-1) is ≤0.

Verifier correction: The Beta defect is real but its cause is line 813 (if *x < 1e-100 || *x > 1.0 - 1e-100 { return NEG_INFINITY }), NOT line 826. Line 826's ln_x/ln_1_minus_x < -700 guard is unreachable dead code because e^-700≈9.86e-305 < 1e-100, so line 813 always returns -inf first. All the auditor's examples (x=1e-320) are truncated by line 813. The practical trigger is therefore x<1e-100 (broader than the stated ~1e-304): even Beta(0.5,0.5)@x=1e-100 (true logpdf +113.98) is wrongly returned as -inf. Correct fix must relax line 813's hard 1e-100 cutoff (compute the lgamma formula and, if desired, clamp to a finite bound, never clip when α-1≤0 or β-1≤0); removing line 826 alone is a no-op. Gamma line 930's x.ln()*(shape-1)<-700 clause is reachable but only fires for shape>1 (positive product for shape<1), where the true log-density is already ≈-736 and heading to -inf, so the impact is minor.

Resolution: fixed-with-design-change — Removed the 1e-100 hard cutoff (the real culprit per the verifier) and the dead ln<-700 guard in Beta::log_prob. Interior x in (0,1) is now computed exactly with no ln guards (f64 ln handles subnormals). Endpoints x=0/x=1 return the true scipy limits: -inf when the shape param > 1, the finite value (ln(beta)/ln(alpha)) when == 1, and +inf when < 1 (density diverges, e.g. Jeffreys prior). Rustdoc updated to document the boundary conventions. This changes boundary behavior (previously always -inf) to match scipy.stats.beta.logpdf.

Regression tests: tests/f_dist_distributions.rs::fg27_beta_subnormal_interior_no_longer_clipped, tests/f_dist_distributions.rs::fg27_beta_endpoint_limits, core::distribution::tests::fg27_beta_boundaries.

Re-verification: verified (independent adversarial verifier).

FG-28 — Binomial::log_prob yields NaN (not the correct 0) at the boundary parameters p=0 or p=1

  • Location: fugue/src/core/distribution.rs:1020
  • Severity: medium · Dimension: correctness · Verification: confirmed · Auditor confidence: certain

Line 1020 computes log_binom_coeff + (k as f64)*self.p.ln() + ((self.n-k) as f64)*(1.0-self.p).ln(). Binomial::new (line 984) accepts any p∈[0,1], so p=0 and p=1 are valid. With p=0, k=0 the middle term is 0.0 * ln(0.0) = 0.0 * -inf = NaN (I confirmed in Rust: prints NaN); true P(X=0)=1 so log_prob should be 0. Symmetrically p=1, k=n gives 0.0 * ln(1-1) = NaN; true log_prob should be 0. NaN is materially worse than -inf: it propagates through the total log-weight and poisons every downstream comparison (all NaN comparisons are false), so an MCMC/IS run that touches a degenerate p silently corrupts rather than merely rejecting. Note Bernoulli (lines 550-564) explicitly branches on p<=0/p>=1 to avoid exactly this 0·ln0, but Binomial was not given the same guard — an internal inconsistency.

Suggested fix: Guard the p·ln(p) terms like Bernoulli does: only add k*p.ln() when k>0 (treat k=0 term as 0), and only add (n-k)*(1-p).ln() when n-k>0; equivalently use if p==0 {return if k==0 {0.0} else {-inf}} and the mirror for p==1.

Resolution: fixed — Guarded Binomial::log_prob against 0*ln(0)=NaN at the valid degenerate parameters: p=0 returns 0.0 for k=0 and -inf otherwise; p=1 returns 0.0 for k=n and -inf otherwise. Reviewed Bernoulli (already correct via its p<=0/p>=1 branches) and Poisson (no degenerate boundary since lambda>0 is enforced) and added no-NaN tests for both.

Regression tests: tests/f_dist_distributions.rs::fg28_binomial_degenerate_p_is_exact_not_nan, tests/f_dist_distributions.rs::fg28_bernoulli_and_poisson_boundaries_no_nan, core::distribution::tests::fg28_binomial_degenerate_p_not_nan.

Re-verification: verified (independent adversarial verifier).

FG-29 — Every distribution constructor returns Result even for statically-valid literal parameters, forcing pervasive .unwrap() (390 occurrences across 14 examples)

  • Location: fugue/src/core/distribution.rs:132
  • Severity: medium · Dimension: usability · Verification: judgment · Auditor confidence: n/a

All 10 distributions expose only new(...) -> FugueResult<Self> (e.g. Normal::new, distribution.rs:132; confirmed identically for all 10 via grep 'pub fn new'); there is no infallible convenience constructor (e.g. Normal::standard()) for common, statically-known-valid parameterizations. Every sample()/observe() call site in the crate's own rustdoc examples and all 14 examples/*.rs therefore chains .unwrap()grep -c '.unwrap()' examples/*.rs totals 390 occurrences across the 14 example files (mean 28/file, up to 53 in hierarchical_models.rs and trace_manipulation.rs). Even examples/production_deployment.rs — the example specifically framed around 'production-ready...robust error handling' (its own doc comment at line 11: 'Production-ready handler that gracefully handles failures') — unwraps every Normal::new(...)/Bernoulli::new(...) call at lines 271, 275, 280, 640, 742, 746, 752, etc., rather than propagating the Result, undercutting the example's own stated purpose.

Suggested fix: Provide infallible convenience constructors for common cases (Normal::standard(), Uniform::unit(), Bernoulli::fair(), Categorical::uniform() already exists) and/or a From<(f64,f64)>-style ergonomic path, so the common case doesn't require unwrap(); at minimum, rewrite production_deployment.rs to actually propagate/handle FugueResult from distribution construction to match its stated theme.

Resolution: fixed — Added infallible convenience constructors for statically-valid cases: Normal::standard() (N(0,1)), Uniform::unit() ([0,1)), Beta::uniform_prior() (Beta(1,1)), Bernoulli::fair() (p=0.5), each with rustdoc and a doctest. Validated new() remains the primary constructor; Categorical::uniform already existed.

Regression tests: tests/f_dist_distributions.rs::fg29_infallible_constructors, core::distribution::tests::fg29_infallible_constructors.

Re-verification: verified (independent adversarial verifier).

FG-30 — Exponential::log_prob returns -inf for rate*x>700 although the log-density is finite

  • Location: fugue/src/core/distribution.rs:473
  • Severity: medium · Dimension: correctness · Verification: confirmed · Auditor confidence: certain

Line 473 if self.rate * x > 700.0 { return f64::NEG_INFINITY } before the finite formula ln(rate) - rate*x (line 476). As with Gamma/Normal, the formula is pure log-space and never overflows; -rate*x is finite for any finite x. I verified Exp(rate=0.001).logpdf(800000) = -806.908 (finite) but the code returns -inf. Reaching rate*x>700 in the tail means x is ~700 means out (P<e^-700), so unlike the Gamma case this is deep-tail rather than near-mode; the practical damage is limited to adversarial observe() values, hence medium/low. Still, it is a wrong log-density and creates a spurious discontinuity in the tail (relevant if the value is used as a likelihood penalty).

Suggested fix: Remove the rate*x>700 guard; return self.rate.ln() - self.rate*x for all x>=0.

Resolution: fixed — Deleted the ratex>700 short-circuit in Exponential::log_prob; ln(rate)-ratex is finite for all x>=0. Regression test confirms Exponential(2).log_prob(400) = -799.3068528194 (was -inf).

Regression tests: tests/f_dist_distributions.rs::fg30_exponential_large_argument_is_finite.

Re-verification: verified (independent adversarial verifier).

FG-31 — No gradient-based inference (HMC/NUTS) and only 11 univariate distributions caps fugue's competitive ceiling for serious Bayesian work

  • Location: fugue/src/core/distribution.rs:124
  • Severity: medium · Dimension: usefulness · Verification: judgment · Auditor confidence: certain

Competitive positioning: fugue is a pure-Rust monadic PPL, so its true competitors are Stan/PyMC/NumPyro (via FFI/Python) and, at the distribution layer, the Rust rv crate. Two gaps limit adoption for real modeling: (1) No gradient-based inference — grep for NUTS/HMC/leapfrog/hamiltonian finds only docs and vi.rs; the inference surface (lib.rs re-exports) is adaptive Metropolis-Hastings, SMC, mean-field VI, and ABC. Modern Bayesian workhorses (Stan, NumPyro) are HMC/NUTS-based; without it, fugue mixes poorly on correlated/high-dimensional continuous posteriors. (2) Only ~11 univariate distributions (Normal, Uniform, LogNormal, Exponential, Bernoulli, Categorical, Beta, Gamma, Binomial, Poisson) — grep finds NO MultivariateNormal, Dirichlet, Multinomial, Student-t, or Cauchy anywhere in src/. rv ships far more, including multivariate and conjugate families. Net: fugue is not a Stan/PyMC replacement; its realistic niche is Rust-native/embedded/WASM inference and teaching, where its no-FFI single-language story is the genuine draw. The README should set that expectation rather than 'production-ready … state-of-the-art inference'.

Suggested fix: Prioritize HMC/NUTS (or at least a gradient-based kernel) and multivariate/Dirichlet distributions on the roadmap; meanwhile position fugue explicitly as a pure-Rust/embeddable/WASM PPL rather than implying parity with HMC-based ecosystems.

Resolution: fixed — Verified commit 7b3a2db closes FG-31's gradient-inference and distribution-coverage gaps. It adds src/inference/hmc.rs implementing HMC over a trace's f64 sites: leapfrog integrator, central finite-difference forces, Hoffman & Gelman (2014) dual-averaging step-size adaptation frozen to its running average after warmup, Alg.4 reasonable-epsilon init, optional diagonal mass adaptation, and a module-doc proof of MH exactness. It adds 7 distributions (StudentT, Cauchy, Laplace, Weibull, ChiSquared, InverseGamma, DiscreteUniform) with validated constructors and full normalizing constants, wires ChoiceValue::I64 end-to-end via DiscreteUniform, and exports hmc_chain/HMCConfig/sample_i64/all 7 dists through lib.rs. cargo check --all-targets passes and all 15 FG-31 tests pass; no additional fixes or commits were required.

Regression tests: fg31_dual_averaging_moves_step_size_toward_target, fg31_hmc_diagonal_mass_adaptation_axis_scaled, fg31_hmc_standard_normal_marginal, fg31_returned_traces_have_fresh_weights, fg31_new_distributions_interior_point_log_prob, fg31_new_distributions_moment_sanity, fg31_new_distributions_validation_and_support, fg31_discrete_uniform_prior_sample_records_i64, fg31_discrete_uniform_observe_i64_likelihood, fg31_discrete_uniform_replay_and_score_i64, fg31_discrete_uniform_mcmc_recovers_posterior_mode, fg31_hmc_beats_mh_on_ess_per_model_eval, fg31_hmc_correlated_gaussian_mean_and_covariance, fg31_hmc_conjugate_normal_normal_matches_analytic, fg31_hmc_bounded_support_stays_in_support.

Re-verification: verified (independent adversarial verifier).

FG-32 — log_sum_exp / normalize_log_probs, the crate's core numerically-stable primitives, are tested only for finiteness, never for the correct numeric value

  • Location: fugue/src/core/numerical.rs:6
  • Severity: medium · Dimension: testing · Verification: judgment · Auditor confidence: n/a

test_log_sum_exp_stability (numerical.rs, in mod tests) feeds [700.0, 701.0, 699.0] and [-700.0,-701.0,-699.0] and only checks .is_finite(). The true value of log_sum_exp([700,701,699]) is 701 + ln(e^-1 + 1 + e^-2) = 701.40760596... (verified numerically). No test anywhere asserts this or any other non-degenerate log_sum_exp value; only degenerate all-(-inf) and empty-slice edge cases get exact-value checks. test_normalize_log_probs (same file) checks the output sums to 1 and preserves ordering but not the actual ratios (e.g. probs[0]/probs[1] should equal e^(-1-(-2))=e≈2.71828). Since log_sum_exp underlies importance-weight normalization, ESS, and SMC resampling weights throughout the codebase, an implementation bug that shifts by the wrong max or double-counts a term would be invisible to this suite as long as it stays finite.

Suggested fix: Add assert_relative_eq! checks against hand-computed log_sum_exp values (e.g. the 701.4076 example above) and check normalize_log_probs output ratios exactly, not just ordering/sum-to-one.

Resolution: fixed — Added exact-value tests for log_sum_exp (701.4076059644 for [700,701,699]; single-element; all -inf; empty; extreme spreads), normalize_log_probs (exact softmax values and the exp-spaced ratio e, not just ordering/sum-to-one), and log1p_exp (ln2 at 0, ln(1+e^2) mid-range, saturation for large/very-negative x). Strengthened the pre-existing finiteness-only module tests in numerical.rs and added a dedicated integration test file.

Regression tests: tests/f_dist_numerical.rs::fg32_log_sum_exp_exact_values, tests/f_dist_numerical.rs::fg32_normalize_log_probs_exact_ratios, tests/f_dist_numerical.rs::fg32_normalize_log_probs_uniform_input, tests/f_dist_numerical.rs::fg32_log1p_exp_exact_values, core::numerical::tests::test_log_sum_exp_stability, core::numerical::tests::test_normalize_log_probs, core::numerical::tests::test_log1p_exp_stability.

Re-verification: verified (independent adversarial verifier).

FG-33 — FugueError's 20-variant error-code taxonomy is largely dead code — only 8 of 20 codes are ever actually produced

  • Location: fugue/src/error.rs:11
  • Severity: medium · Dimension: elegance · Verification: judgment · Auditor confidence: n/a

error.rs (910 lines, ~800 excluding its own #[cfg(test)] module) defines 20 ErrorCode variants across 6 categories (distribution validation 1xx, numerical 2xx, model execution 3xx, inference 4xx, trace 5xx, type system 6xx) plus a rich ErrorContext/cause-chain apparatus. Grepping every non-test/non-doctest use of ErrorCode:: and FugueError::{invalid_parameters,trace_error,type_mismatch,numerical_error} across src/ shows only 8 codes are ever actually constructed by real logic: the 7 distribution-validation codes (InvalidMean/Variance/Probability/Range/Shape/Rate/Count, all inside Distribution::new() constructors in core/distribution.rs) plus TraceAddressNotFound (Trace::get_*_result in runtime/trace.rs). NumericalOverflow/Underflow/Instability/InvalidLogDensity, ModelExecutionFailed/AddressConflict/UnexpectedModelStructure, InferenceConvergenceFailed/InsufficientSamples/InvalidInferenceConfig, and TraceCorrupted/TraceReplayFailed/TypeMismatch(code)/UnsupportedType are never constructed anywhere in the library's actual code paths — the numerical, model-execution, and inference-algorithm error categories the README advertises ('robust error handling', 'Production diagnostics ... robust error handling') are essentially unimplemented placeholders. Meanwhile the real numerical-instability paths (log_sum_exp, Normal::sample, etc.) just silently return NaN/-inf instead of surfacing NumericalError.

Suggested fix: Either wire the unused ErrorCode variants into the code paths they were designed for (numerical instability detection, inference non-convergence, model-structure mismatches — see the ScoreGivenTrace panic finding above), or trim the enum to the codes that are actually produced. As-is the size of error.rs overstates how much of the crate's failure surface is actually captured by structured errors.

Resolution: fixed-by-removal — Grepped every real (non-test) construction site of ErrorCode:: and FugueError::{type_mismatch,trace_error} across src/ and confirmed exactly 11 of 22 ErrorCode variants are ever constructed by real logic: the 7 distribution-validation codes, AddressConflict + UnexpectedModelStructure (runtime::interpreters), TraceAddressNotFound (runtime::trace), and TypeMismatch (runtime::trace, via the type_mismatch() helper -- initially missed this one in a first pass since it's not called via ErrorCode:: syntax directly). Deleted the 11 unconstructed variants (NumericalOverflow/Underflow/Instability, InvalidLogDensity, ModelExecutionFailed, InferenceConvergenceFailed, InsufficientSamples, InvalidInferenceConfig, TraceCorrupted, TraceReplayFailed, UnsupportedType), the now-associated dead FugueError::{NumericalError,InferenceError} variants (each only ever constructed by error.rs's own From impls / unit tests, never by real logic), the numerical_error() constructor and numerical_error! macro, the From<ParseFloatError/ParseIntError/&str/String> impls (unused anywhere in the crate, verified by full-repo grep), the now-meaningless is_recoverable()/is_numerical_error() predicates, and the two now-empty ErrorCategory variants (NumericalComputation, InferenceAlgorithm). Kept ABC's ABCError and VI's GuideError as their own dedicated, more precise algorithm-specific error types rather than force-fitting them into ErrorCode (the design decision's suggested 'VI unsupported-latent, ABC exhaustion' wiring didn't materialize as ErrorCode variants in the actual post-remediation code -- documented why in error.rs's module docs). Documented the live-11 table in error.rs module docs. Rewrote error.rs's test module: added error_code_taxonomy_is_exactly_the_live_set (enumerates and validates all 11 survivors) plus updated existing tests to only reference live codes.

Regression tests: src/error.rs: error_code_taxonomy_is_exactly_the_live_set, plus retained/updated error_code_category_and_description, invalid_parameters_constructor_and_context, error_macros_create_expected_variants, type_mismatch_constructor, validate_trait_on_valid_distributions, error_cause_chaining_and_display_variants.

Re-verification: verified (independent adversarial verifier).

FG-34 — abc_smc can loop forever and can panic on an empty initial population

  • Location: fugue/src/inference/abc.rs:426
  • Severity: medium · Dimension: correctness · Verification: confirmed · Auditor confidence: certain

The inner loop while new_particles.len() < config.particles_per_round (line 426) has no attempt cap, unlike abc_rejection which bounds attempts at max_samples*100 (line 292). If a tolerance level in the schedule is too tight to reach by single-site prior perturbation, this hangs indefinitely with no progress or error. Separately, if the initial abc_rejection round accepts zero particles (tight initial_tolerance, or attempt cap hit), the first schedule iteration calls rng.gen_range(0..current_particles.len()) = gen_range(0..0) (line 428), which panics in rand. Both are reachable with ordinary configurations.

Suggested fix: Add an attempt cap to the inner loop and return early (with a warning) if it cannot be met; guard against an empty current_particles before gen_range.

Resolution: fixed — abc_smc/abc_smc_weighted now bound each tolerance stage by max_attempts_per_stage and return a typed ABCError (EmptyInitialPopulation or StageExhausted) instead of looping forever or panicking on rng.gen_range(0..0) when the initial population is empty.

Regression tests: fg34_empty_initial_population_is_typed_error, fg34_stage_exhaustion_is_typed_error.

Re-verification: verified (independent adversarial verifier).

FG-35 — R-hat and ESS diagnostics are never checked against known-answer scenarios, only sanity bounds

  • Location: fugue/src/inference/diagnostics.rs:210
  • Severity: medium · Dimension: testing · Verification: judgment · Auditor confidence: n/a

r_hat_from_f64_chains (diagnostics.rs:210-252) implements the classic Gelman-Rubin R-hat: B = n/(m-1)sum((mean_i-mean)^2), W=mean(within-chain variances), var+ = (n-1)/nW + B/n, Rhat=sqrt(var+/W) - this formula is correct per Gelman & Rubin (1992). However every test that touches r_hat_f64 (diagnostics.rs r_hat_and_summary_compute, and inference_integration.rs test_diagnostics_basic line 275, test_diagnostics_multi_chain line 547, test_workflow_complete_bayesian_analysis line 665) only asserts r_hat.is_finite() && r_hat > 0.0 - a check that a stub returning a constant like 1.0 or 5.0 would also pass. No test constructs two identical chains (expect Rhat≈1.0) or two chains with clearly different means (expect Rhat >> 1.0) to confirm the diagnostic actually detects non-convergence. Similarly for effective_sample_size_mcmc: mcmc_utils.rs's test_effective_sample_size (mcmc_utils.rs) builds both a low-correlation and a highly-correlated chain but never asserts ess_corr < ess_random, the one comparison that would actually validate the autocorrelation-based ESS reduction is working.

Suggested fix: Add a test with two identical chains asserting r_hat is within e.g. 1e-6 of 1.0, and two chains with a large mean offset asserting r_hat > 1.1 (a common convergence-failure threshold); add assert!(ess_corr < ess_random) to the existing correlated-vs-random ESS test.

Resolution: fixed — New known-answer regression tests were added: two identical-distribution chains now assert split-R-hat < 1.01, and two chains sharing a within-chain linear drift assert split-R-hat > 1.1 while classic R-hat stays < 1.01 (demonstrating the diagnostic actually detects non-convergence, not just returns a finite positive number). A new AR(1) test also checks ESS/n against the closed-form (1-phi)/(1+phi) limit. NOT-VERIFIED: the specific suggested assertion ess_corr < ess_random was not added to the existing test_effective_sample_size in mcmc_utils.rs (that test is unchanged in the diff), though the new AR(1) known-answer test covers a strictly stronger version of the same intent.

Regression tests: fg36_identical_distribution_chains_split_rhat_near_one, fg36_within_chain_drift_only_caught_by_split, ess_matches_ar1_known_answer.

Re-verification: verified (independent adversarial verifier).

FG-36 — R-hat implementation is the classic (non-split) Gelman-Rubin statistic, undermining the 'state-of-the-art' inference claim

  • Location: fugue/src/inference/diagnostics.rs:212
  • Severity: medium · Dimension: math · Verification: confirmed · Auditor confidence: n/a

r_hat_from_f64_chains (diagnostics.rs:212-254) correctly implements the classic Gelman & Rubin (1992) potential-scale-reduction statistic: B = n/(m-1)Σ(chain_mean_i - overall_mean)², W = mean of within-chain variances, var+ = (n-1)/nW + 1/n*B, R-hat = sqrt(var+/W). The arithmetic matches the textbook formula. However, current best practice (Vehtari, Gelman, Simpson, Carpenter & Bürkner 2021, 'Rank-normalization, folding, and localization: An improved R-hat for assessing convergence of MCMC') is split-R-hat, which splits each chain in half before computing between/within variance specifically because classic R-hat can fail to detect within-chain non-stationarity/trend (a single chain that drifts monotonically can still show R-hat≈1 across chains that all drift similarly). Fugue implements only the 1992 statistic with no splitting, and neither the README's 'state-of-the-art inference algorithms' claim nor the diagnostics module doc ('R-hat (Potential Scale Reduction Factor)... close to 1.0 indicate convergence') mentions this limitation.

Suggested fix: Either implement split-R-hat (trivial: split each input chain's Vec in half and treat the halves as separate chains before applying the existing formula) or soften the 'state-of-the-art' framing in the README/docs to acknowledge the diagnostic is the classical variant.

Verifier correction: The finding is accurate. One severity note: the implementation is mathematically correct for the classic diagnostic — this is not a computation bug but a 'not-latest-best-practice + overstated marketing framing' gap. Classic R-hat remains valid and widely used, and the suggested fix (implement split-R-hat, or soften the 'state-of-the-art' wording) is a documentation/enhancement change rather than a correctness fix. Hence low rather than medium severity.

Resolution: fixed — Added split_f64_chains/split_r_hat_from_f64_chains, which halve each chain (Vehtari et al. 2021) before applying the existing Gelman-Rubin formula; r_hat_f64 (used by summarize_f64_parameter) now returns this split statistic by default, while the original 1992 statistic is preserved and exposed as classic_r_hat_f64 (re-exported from lib.rs).

Regression tests: fg36_identical_distribution_chains_split_rhat_near_one, fg36_within_chain_drift_only_caught_by_split.

Re-verification: verified (independent adversarial verifier).

FG-37 — summarize_f64_parameter computes ESS from only the first chain while pooling all chains for mean/std/quantiles

  • Location: fugue/src/inference/diagnostics.rs:367
  • Severity: medium · Dimension: correctness · Verification: confirmed · Auditor confidence: certain

In summarize_f64_parameter, mean/std/quantiles are computed over all_values pooled across every chain (lines 325–363), but ESS is effective_sample_size(&extract_f64_values(&chains[0], addr)) (line 368) — the FIRST chain only. With M chains this throws away (M−1)/M of the data and reports a per-chain ESS mislabeled as the parameter's ESS, understating the true multi-chain effective sample size and making ESS inconsistent with the other summary fields. (This is on top of the normalization bug in the ESS estimator itself.)

Suggested fix: Compute ESS across all chains (e.g. sum of per-chain ESS, or the proper multi-chain rank-ESS), consistent with how the mean/quantiles pool the chains.

Resolution: fixedsummarize_f64_parameter now computes ESS via the new effective_sample_size_multichain, called on per-chain f64 values extracted from every chain (a Vehtari et al. multi-chain estimator using the pooled W+B variance), replacing the old effective_sample_size(&chains[0], ...) call that used only the first chain.

Regression tests: fg37_summary_ess_uses_all_chains.

Re-verification: verified (independent adversarial verifier).

FG-38 — DiminishingAdaptation clones the Address key on every entry() call, even cache hits

  • Location: fugue/src/inference/mcmc_utils.rs:51
  • Severity: medium · Dimension: performance · Verification: confirmed · Auditor confidence: n/a

get_scale (line 51-53) does self.scales.entry(addr.clone()).or_insert(..) and update (lines 59-102) does the same for total_counts (line 61) and conditionally accept_counts (line 65) and scales (line 82) — up to 3 Address (String) clones per update() call. Rust's stable HashMap::entry API requires an owned key be constructed before it can check for a hit, so this clones the Address string on every call regardless of whether the site was already present (which, after the first ~10 iterations of any given chain, is always true). This runs once per site per MCMC step, i.e. thousands to millions of times over a chain.

Suggested fix: Guard with if let Some(v) = self.scales.get(addr) { ... } else { self.scales.insert(addr.clone(), ..); } (or use HashMap::raw_entry/hashbrown's entry_ref once available on stable) so the clone only happens on an actual miss.

Resolution: fixedDiminishingAdaptation::get_scale and update were rewritten from HashMap::entry(addr.clone()) (which allocates the Address key on every call) to get/get_mut lookups that only call .insert(addr.clone(), ...) on an actual first-time miss, so steady-state calls (the common case after the first ~10 iterations) perform zero Address clones, matching the suggested fix. No dedicated regression test was added for this change; the diff only shows a println! removed from the existing test_diminishing_adaptation.

Re-verification: verified (independent adversarial verifier).

FG-39 — Geweke diagnostic uses the raw sample variance for the SE, ignoring autocorrelation → z-scores systematically inflated

  • Location: fugue/src/inference/mcmc_utils.rs:226
  • Severity: medium · Dimension: math · Verification: confirmed · Auditor confidence: certain

geweke_diagnostic (mcmc_utils.rs:206–233) forms se = sqrt(var1/n1 + var2/n2) (line 226) where var1,var2 are ordinary sample variances (lines 221–224). Geweke's Z requires the variance OF THE MEAN of an autocorrelated series, i.e. the spectral density at zero S(0)/n (asymptotic variance = (1+2Στ)·s²/n), not s²/n. For a correlated MCMC segment with integrated autocorrelation time τ, the true SE is √τ larger, so the coded Z is inflated by ≈√τ, producing false 'non-convergence' flags (|Z|>2) even for perfectly stationary but autocorrelated chains. The formula is only correct for iid draws.

Suggested fix: Estimate each segment's spectral density at frequency 0 (or its integrated autocorrelation time) and use varmean = (1 + 2·Στ_k)·s²/n in the SE, matching Geweke (1992).

Resolution: fixedgeweke_diagnostic's standard error is now computed via a new spectral_variance_of_mean helper (s^2 * tau / n, with tau the integrated autocorrelation time estimated from the same normalized-autocovariance machinery used for ESS) instead of the raw iid sample variance divided by n, removing the sqrt(tau) inflation of the z-score for autocorrelated chains.

Regression tests: geweke_stationary_is_small, geweke_drift_is_flagged.

Re-verification: verified (independent adversarial verifier).

FG-40 — Accepted traces returned with stale log_prior/log_likelihood/log_factors — total_log_weight() on samples is wrong

  • Location: fugue/src/inference/mh.rs:416
  • Severity: medium · Dimension: correctness · Verification: confirmed · Auditor confidence: certain

proposed_trace = current.clone() (mh.rs:386) copies the scalar accumulators log_prior/log_likelihood/log_factors and then only mutates .value at one site (lines 388–391); the changed choice's .logp field is also left stale. On acceptance the code returns proposed_trace (mh.rs:417), NOT the freshly-scored prop_scored (which has correct accumulators and logp). So every accepted sample carries the weight fields of the PREVIOUS state, and trace.total_log_weight() on returned samples is incorrect (after the first acceptance, current is itself such a stale trace, so this persists for the whole chain). The chain DYNAMICS are still correct because acceptance re-scores fresh via ScoreGivenTrace (cur_scored/prop_scored) rather than reading the stale fields, and diagnostics read .value, so posterior parameter estimates are unaffected — but any downstream use of the samples' log-weights/logp is wrong.

Suggested fix: Return prop_scored (which has correct choices, logp, and accumulators) on acceptance, and cur_scored on rejection, instead of the hand-mutated clone.

Verifier correction: Two minor refinements (neither refutes the finding): (1) The stale accumulators are not just the PREVIOUS state's — because the initial current_trace comes from PriorHandler with correct accumulators and neither the accept path (returns proposed_trace) nor the reject path (returns current.clone()) ever updates them, the accumulators are effectively FROZEN at the initial prior-draw values for the entire chain. (2) The suggested-fix note that prop_scored has 'correct logp' per-choice is slightly inaccurate: ScoreGivenTrace inserts c.clone() from its base (interpreters.rs:335), so prop_scored.choices[site].logp is itself stale at the mutated site; however, since total_log_weight() ignores per-choice logp and prop_scored's accumulators ARE correct, returning prop_scored still fully fixes the reported total_log_weight bug.

Resolution: fixed — On acceptance, adaptive_single_site_mh/single_site_mh_step now return the freshly-built trace produced inline by SingleSiteProposalHandler (with correct log_prior/log_likelihood/log_factors accumulators from the single scoring run) instead of a hand-mutated clone of the previous trace; because every acceptance now writes a fully fresh trace, the accumulators are no longer permanently frozen at the initial prior-draw values.

Regression tests: returned_trace_weight_matches_fresh_rescore.

Re-verification: verified (independent adversarial verifier).

FG-41 — DiscreteWalkProposal .max(0) truncation is an uncorrected boundary asymmetry for counts near zero

  • Location: fugue/src/inference/mh.rs:201
  • Severity: medium · Dimension: math · Verification: confirmed · Auditor confidence: likely

DiscreteWalkProposal (mh.rs:195–203) sets delta = round(scale·z), x' = (x+delta).max(0). Away from 0 the proposal is symmetric (round(scale·z) is symmetric in ±k), so no correction is needed there. But the clamp at 0 folds all would-be-negative proposals onto 0: from x=1 with scale making P(delta≤−1) sizeable, the move to 0 receives extra mass, while from 0 the reverse move to 1 does not — q(0|1) ≠ q(1|0). This asymmetry is uncorrected in the acceptance ratio, biasing count-valued parameters (Poisson/Binomial latents) near the 0 boundary. (Also delta=0 is a wasted self-proposal.)

Suggested fix: Either reject (leave x unchanged) when the raw proposal is negative — a symmetric rejection that needs no correction — or add the boundary Hastings term.

Resolution: fixedDiscreteWalkProposal no longer clamps negative proposals to 0 via .max(0); it now reflects them about -1/2 (k -> -k-1), which has no fixed point at the boundary and produces an exactly symmetric kernel q(a|b) = q(b|a) including at state 0, as the suggested fix's boundary-correction option intended.

Regression tests: discrete_walk_is_symmetric_at_boundary, fg41_discrete_walk_recovers_poisson_at_boundary.

Re-verification: verified (independent adversarial verifier).

FG-42 — Name-substring constraint heuristic is over-broad and can trap an unbounded parameter in [0,1], breaking ergodicity

  • Location: fugue/src/inference/mh.rs:252
  • Severity: medium · Dimension: correctness · Verification: confirmed · Auditor confidence: likely

The probability-parameter branch (mh.rs:252–261) triggers on (0.0..=1.0).contains(&current_val) && (addr contains "prob" || addr contains "p" || addr contains "beta"). contains("p") matches any address containing the letter p (alpha, temp, shape, sample, ...). When it fires, ReflectionWalkProposal on [0,1] is used, which by construction NEVER proposes outside [0,1] (mh.rs:165–174). If such a parameter's true support extends beyond [0,1] but its current value merely happens to lie in [0,1], the chain is confined to [0,1] and cannot explore the rest of the support — a non-ergodic, silently biased sampler. The scale/positivity heuristic (mh.rs:241–251) is similarly name-based and misclassifies by coincidence of substrings.

Suggested fix: Drive proposal choice from the distribution's actual support (available at the sample site) rather than address-name substrings; at minimum require an exact/word-boundary match and never confine a parameter whose support is not known to be [0,1].

Verifier correction: Finding is accurate as written. Minor clarification: the trap is a one-way absorbing condition — it engages only once the parameter's value enters [0,1] (before that, the Gaussian branch permits free movement), after which the chain is permanently confined to [0,1]. Also note the scale branch is evaluated first, so names like precision/lambda/rate are routed to log-space and never reach the probability branch; the over-broad matches that actually cause the [0,1] trap are unconstrained-support names containing 'p' such as slope, temp, shape, sample, alpha, intercept.

Resolution: fixed-with-design-change — Proposal-kind selection for f64 sites no longer inspects address-name substrings at all; SingleSiteProposalHandler::f64_kind now probes the site's distribution directly (checking whether its log-density is -inf at a negative test value) to detect genuine positive support, defaulting to a symmetric Gaussian walk otherwise so out-of-support proposals are simply rejected via -inf density rather than confined. This goes beyond the audit's suggested word-boundary-match fix by adding a new SiteProposal enum and adaptive_mcmc_chain_with_overrides API for explicit per-address overrides.

Regression tests: fg42_name_heuristic_no_longer_traps_unbounded_parameter.

Re-verification: verified (independent adversarial verifier).

FG-43 — adaptive_smc is not sequential and uses resampling as its terminal operation, adding variance without benefit

  • Location: fugue/src/inference/smc.rs:383
  • Severity: medium · Dimension: correctness · Verification: confirmed · Auditor confidence: likely

Despite the SMC/particle-filter framing, adaptive_smc runs the whole model to completion once via PriorHandler (a single importance-sampling pass), then does at most ONE ESS-triggered resample as the last step before returning (lines 389-416). There is no per-observation stepping, tempering, or data annealing — the 'sequential' structure is absent. Moreover, resampling as the final operation (when rejuvenation_steps==0) is strictly harmful: it replaces the properly weighted particle set with uniformly-weighted duplicates, discarding information and increasing Monte Carlo variance of posterior estimates, while a downstream effective_sample_size() call will misleadingly report ~N because duplicates all carry weight 1/N. Proper SMC only resamples BETWEEN reweighting steps, never as the terminal step.

Suggested fix: Either implement true sequential steps (reweight per observation/temperature, resample between steps), or, for a single-pass importance sampler, return the weighted particles without a terminal resample.

Resolution: fixed — adaptive_smc in src/inference/smc.rs was rewritten into genuine sequential likelihood-tempered SMC: an adaptive beta ladder with ESS-triggered systematic resampling between tempering steps and pi_beta-invariant MH rejuvenation, returning an SMCResult rather than doing a single prior-importance pass with a terminal resample.

Regression tests: fg43_fg58_tempered_smc_matches_conjugate_evidence_and_mean.

Re-verification: verified (independent adversarial verifier).

FG-44 — No convergence detection or step-size schedule, and module docs falsely claim the method is deterministic with no random sampling

  • Location: fugue/src/inference/vi.rs:434
  • Severity: medium · Dimension: correctness · Verification: confirmed · Auditor confidence: certain

optimize_meanfield_vi runs a fixed n_iterations with a constant learning_rate and no ELBO-based stopping/convergence check (lines 434-520); it only prints the ELBO every 100 iters. With a stochastic (and here high-variance) gradient, a constant step size does not converge to a stationary point — the Robbins-Monro conditions require a decreasing schedule with sum(a_t)=inf and sum(a_t^2)<inf; a constant step causes the iterate to random-walk around the optimum with residual variance proportional to the step size. The module documentation contradicts the implementation: line 23 states 'Deterministic: No random sampling, reproducible results' and line 26 claims 'Convergence detection: Clear optimization objective to monitor', but the implementation is Monte-Carlo stochastic (samples from the guide every iteration) and has no convergence detection. This misleads users about reproducibility and stopping behavior.

Suggested fix: Add a decreasing/Adam-style step schedule and an ELBO-plateau convergence criterion; correct the docs to state the optimizer is stochastic (or make it deterministic via fixed reparameterization draws).

Resolution: fixed — optimize_meanfield_vi_with_config in src/inference/vi.rs adds a VIConfig with a step_decay_exponent (Robbins-Monro decaying step size, step = base_learning_rate * (iter+1)^(-step_decay_exponent)) and an ELBO-plateau convergence test comparing mean ELBO over two recent windows against convergence_tol, returning VIResult{elbo_history, converged, iterations} instead of running a fixed number of iterations at a constant learning rate with no stopping criterion.

Regression tests: fg44_convergence_detection_and_elbo_improves.

Re-verification: verified (independent adversarial verifier).

FG-45 — Variational inference tests never check convergence toward the analytically-known optimum

  • Location: fugue/src/inference/vi.rs
  • Severity: medium · Dimension: testing · Verification: judgment · Auditor confidence: n/a

All VI tests (vi.rs mod tests: variational_param_sampling_and_log_prob, elbo_computation_is_finite, meanfield_from_trace_and_sampling, optimize_vi_updates_parameters_and_is_stable; plus tests/inference_integration.rs test_variational_inference_basic line 320 and test_vi_different_models line 611) assert only that ELBO/samples are finite and that fitted params stay within an arbitrary clamp (±100). None asserts that the ELBO improves over optimization iterations, nor that the fitted Gaussian mean/variance approach the true posterior. This is a missed opportunity: for a Normal-likelihood/Normal-prior model with a Gaussian mean-field guide, the ELBO's exact global optimum equals the true Normal-Normal conjugate posterior (same closed form as used in test_mcmc_normal_mean_recovery), so this is a free, analytically-known target that VI-specific tests do not exploit. Per fugue/lcov.info, vi.rs has 65.8% line coverage (134/386 lines missed... wait 254/386 covered = 65.8%), the second-lowest of the inference modules after validation.rs (64.4%).

Suggested fix: Add a test that runs optimize_meanfield_vi to convergence on a Normal-Normal model and asserts the fitted VariationalParam::Normal{mu, log_sigma} is within tolerance of the analytical posterior mean/std, and a separate test asserting ELBO is non-decreasing (or improves) across iterations.

Resolution: fixed — A new integration test in tests/f_vi_regressions.rs (fg45_vi_recovers_conjugate_posterior_mean_and_scale) runs optimize_meanfield_vi_with_config on a Normal-Normal conjugate model and asserts the fitted guide's mean and scale are within tolerance of the closed-form analytic posterior, which no prior VI test did.

Regression tests: fg45_vi_recovers_conjugate_posterior_mean_and_scale.

Re-verification: verified (independent adversarial verifier).

FG-46 — estimate_elbo does not compute an ELBO — it returns E_prior[log p(x,z)], double-counting the prior entropy

  • Location: fugue/src/inference/vi.rs:524
  • Severity: medium · Dimension: math · Verification: confirmed · Auditor confidence: certain

estimate_elbo samples z from the prior (PriorHandler), re-scores with ScoreGivenTrace, and averages scored.total_log_weight() = log p(z)+log p(x|z) = log p(x,z) (lines 529-547). The ELBO with guide q equals E_q[log p(x,z) - log q(z)]; taking q = prior gives E_prior[log p(x,z) - log p(z)] = E_prior[log p(x|z)], which by Jensen is a valid lower bound on log p(x). The function omits the - log q(z) = - log p(z) term, so it returns E_prior[log p(x|z)] + E_prior[log p(z)]. The extra term E_prior[log p(z)] = -H(prior) is generally nonzero, so the result is neither the ELBO nor a valid evidence bound — it is off by the (negative) prior entropy. The name and the 'backward compatibility ELBO' comment are misleading and will give wrong numbers if used to monitor an evidence bound.

Suggested fix: Either subtract the prior log-density (return average of scored.log_likelihood only, i.e. E_prior[log p(x|z)]) or rename/document it as an average joint log-density, not an ELBO.

Resolution: fixed — estimate_elbo in src/inference/vi.rs was changed to accumulate prior_t.log_likelihood + prior_t.log_factors (omitting the prior log-density) instead of scored.total_log_weight() from a second ScoreGivenTrace run, so it now returns E_prior[log p(x|z)] rather than E_prior[log p(x,z)].

Regression tests: fg46_estimate_elbo_is_prior_elbo_not_joint.

Re-verification: verified (independent adversarial verifier).

FG-47 — Duplicate sample addresses silently double-count log_prior and drop a choice — no detection despite being documented as a 'serious error'

  • Location: fugue/src/runtime/interpreters.rs:47
  • Severity: medium · Dimension: correctness · Verification: confirmed · Auditor confidence: certain

Every sampling handler does self.trace.log_prior += lp; then self.trace.choices.insert(addr.clone(), ..) (PriorHandler f64 at lines 45-54, and identically in the other 3 types and in ReplayHandler/SafeReplayHandler). If two sample statements use the same Address, log_prior accumulates BOTH log-probs (lp1+lp2) while the BTreeMap keeps only the second value — the trace's weight and its stored choices become mutually inconsistent, silently corrupting importance weights, MCMC acceptance ratios, and any per-site bookkeeping. The docs explicitly call this out (docs/runtime/handler.md:270 'The same address used twice in a model is a serious error'; docs/core/address.md:16 lists 'Zero footguns' and 'Minimize collisions' as design goals; AGENTS.md:82 'Address collisions are programming errors'), yet nothing detects it at runtime — it is exactly the silent footgun the design goals disclaim. Detection is cheap given the BTreeMap: check contains_key before insert and panic/warn, or expose a debug-mode assertion.

Suggested fix: In the sampling handlers, guard the insert: if the address is already present in self.trace.choices, panic with a clear 'address reused' message (or return a Result). This turns a silent numerical corruption into a loud, addressable error.

Resolution: fixed — Made ErrorCode::AddressConflict real: all sampling handlers now detect a second visit to an address already recorded in the output trace (cheap O(log n) contains_key, no extra state). Fast handlers (PriorHandler/ReplayHandler/ScoreGivenTrace) panic with a precise AddressConflict message; safe handlers (SafeReplay/SafeScore) invalidate the trace with -inf and warn; the strict and reconciling paths return Err(ErrorCode::AddressConflict). This replaces the previous silent log_prior double-count + dropped choice.

Regression tests: tests/f_runtime_audit.rs::fg47_prior_handler_panics_on_duplicate_address, tests/f_runtime_audit.rs::fg47_strict_scoring_reports_address_conflict_error_code, tests/f_runtime_audit.rs::fg47_safe_replay_invalidates_on_duplicate_address.

Re-verification: verified (independent adversarial verifier).

FG-48 — ScoreGivenTrace / SafeScoreGivenTrace store the base choice's STALE logp while accumulating a freshly-scored lp into log_prior

  • Location: fugue/src/runtime/interpreters.rs:335
  • Severity: medium · Dimension: correctness · Verification: confirmed · Auditor confidence: likely

In ScoreGivenTrace::on_sample_f64 the value is read from the base trace, lp = dist.log_prob(&x) is computed under the CURRENT (possibly different) distribution and added to self.trace.log_prior (lines 333-334) — correct for scoring — but the choice written to the new trace is c.clone() (line 335), i.e. the BASE choice, whose logp field is the log-prob under the ORIGINAL distribution, not lp. The same happens in all four ScoreGivenTrace types and in SafeScoreGivenTrace (lines 646, 666, 687, 707 insert choice.clone() from base). By contrast PriorHandler and ReplayHandler store a fresh Choice{ logp: lp } consistent with what they added to log_prior. Result: after scoring, sum(choice.logp) over the trace ≠ trace.log_prior, and the per-site logp reflects the wrong model. Any inference routine that inspects choice.logp (a public field, documented at trace.rs:129-130 as 'log-probability of this value under the generating distribution') to reconstruct site-level scores gets the base model's numbers. The whole point of ScoreGivenTrace is to re-score under a new distribution, so the stored per-site logp being stale is a latent bug.

Suggested fix: Insert a fresh choice with the newly computed lp: self.trace.choices.insert(addr.clone(), Choice{ addr: addr.clone(), value: c.value.clone(), logp: lp }); instead of cloning the base choice wholesale.

Resolution: fixed — ScoreGivenTrace and SafeScoreGivenTrace now write a FRESH Choice carrying the newly computed logp (under the current distribution) instead of cloning the stale base choice, so the sum of stored choice logps equals trace.log_prior. Empirically verified: reverting the insert to c.clone() makes the regression test fail.

Regression tests: tests/f_runtime_audit.rs::fg48_scored_choice_logps_sum_to_log_prior (asserts sum(choice.logp)==log_prior and that re-scored logps differ from the base's).

Re-verification: verified (independent adversarial verifier).

FG-49 — Broad end-to-end workflow tests use tolerances loose enough to mask real regressions, and this is self-acknowledged in comments

  • Location: fugue/tests/end_to_end_workflows.rs:875
  • Severity: medium · Dimension: testing · Verification: judgment · Auditor confidence: n/a

Several workflow tests use very wide pass bands: line 859 asserts mse < 200.0 for a regression fit test, and line 875 explicitly comments 'Very lenient bound - just check it's not completely unreasonable' for pred_range < 100.0. tests/inference_integration.rs's regression test (test_workflow_parameter_estimation_uncertainty, line 757) similarly allows beta_mean to differ from the true slope of 2.0 by up to 1.5 (75% relative error) and alpha by up to 2.0 absolute. These end-to-end tests provide good code-path coverage (exercising the full prior->MCMC->diagnostics->prediction pipeline) but their tolerances are wide enough that e.g. a variance computed 2x too large, or an MCMC step-size adaptation that's badly miscalibrated, would very likely still pass.

Suggested fix: Keep the broad workflow tests for path coverage but add a smaller number of tighter, analytically-grounded assertions (as already done well in test_mcmc_normal_mean_recovery and test_mcmc_beta_binomial_conjugacy) so a genuine 2x-scale bug in variance/posterior computation would fail at least one test.

Resolution: fixed — Replaced the self-acknowledged loose tolerances with CLT-justified bounds, each with a derivation comment. In tests/end_to_end_workflows.rs's test_validation_cross_validation: replaced 'mse<200.0'/'pred_range<100.0 (Very lenient bound)' with bounds derived from the closed-form Bayesian-linear-regression posterior-mean prediction bias (~0.04) plus posterior-predictive parameter variance (0.56) per LOO fold, cross-checked in tests/gen_refs.py-style hand derivation; discovered along the way that the test's original 30-samples/10-warmup MCMC config never reached the asymptotic regime (empirically mse27 vs theoretical ~0.2-0.6), so bumped it to 150/50 (still sub-second) rather than loosening the bound to accommodate an under-provisioned chain. In tests/inference_integration.rs's test_workflow_parameter_estimation_uncertainty: replaced the 'very generous tolerance' absolute caps (alpha within 2.0, beta within 1.5, std<2.0/1.0, CI width<4.0/2.0) with bounds derived from the model's exact closed-form bivariate-normal posterior (computed via a 2x2 conjugate linear solve, independently reproduced in tests/gen_refs.py) combined with the chain's measured effective_sample_size_mcmc, using a 4-standard-error mean bound, a 3x-relative-error variance bound, and a 1.5x-slack 95%-CI-width bound.

Re-verification: verified (independent adversarial verifier).

Severity: low (15)

FG-50 — '10+ built-in probability distributions' claim is exactly 10, not '10+' in the sense most readers infer

  • Location: fugue/README.md:30
  • Severity: low · Dimension: docs · Verification: judgment · Auditor confidence: n/a

README.md:30 and docs/src/home.md:42 both state 'Type-Safe Distributions: 10+ built-in probability distributions'. Counting impl Distribution<_> for blocks in src/core/distribution.rs gives exactly 10: Normal, Uniform, LogNormal, Exponential, Bernoulli, Categorical, Beta, Gamma, Binomial, Poisson. '10+' is literally true under an 'at least 10' reading, but is normally read by users as 'more than 10' / implying headroom for growth; it is a marginal but real instance of the marketing language in the audit brief ('production-ready', 'state-of-the-art', 'comprehensive diagnostics') running slightly ahead of what's verifiably shipped.

Suggested fix: State the exact count ('10 built-in distributions') or genuinely add an 11th (e.g. StudentT, Dirichlet, or NegativeBinomial are common asks) to make '10+' unambiguously true.

Resolution: fixed — Grepped impl Distribution<_> for blocks in src/core/distribution.rs: exactly 17 (Normal, Uniform, LogNormal, Exponential, Bernoulli, Categorical, Beta, Gamma, Binomial, Poisson, StudentT, Cauchy, Laplace, Weibull, ChiSquared, InverseGamma, DiscreteUniform -- 7 new since the audit's '10' baseline, added by the parallel HMC-track fixes), all confirmed re-exported at the crate root in src/lib.rs. Updated README.md and docs/src/home.md to state the exact count (17) and enumerate all 17 in a new README 'Distributions' section, replacing the ambiguous '10+' claim.

Re-verification: verified (independent adversarial verifier).

FG-51 — README claims MSRV 1.70+ for fugue but nothing pins or verifies it

  • Location: fugue/rust-toolchain.toml:2
  • Severity: low · Dimension: completeness · Verification: judgment · Auditor confidence: n/a

fugue/README.md:11,23 advertises 'Supported Rust: 1.70+', but fugue/rust-toolchain.toml only pins channel = "stable" (no explicit 1.70 pin), and grep over fugue/.github/workflows/*.yml (ci-develop.yml, coverage.yml, docs.yml, publish.yml) turns up no MSRV-specific job/version check. So the 1.70 claim is untested by CI and could silently drift as the codebase adopts newer stdlib/edition features. fugue-evo makes no MSRV claim in its README at all, another inconsistency between the two sibling crates' documented guarantees.

Suggested fix: Add an MSRV CI job pinned to 1.70 (e.g. cargo +1.70 check) or use cargo-msrv to verify the claim automatically, and either add a matching MSRV statement to fugue-evo's README or explicitly note it is not MSRV-pinned.

Resolution: fixed — Initial verification attempt (rustup run 1.70.0 cargo check) was invalidated by a PATH bug: child rustc invocations resolved to Homebrew's newer rustc (1.96.1) instead of the pinned 1.70.0, silently making the check meaningless. Re-verified with an explicit toolchain-bin-first PATH override, which uncovered real failures under genuine rustc 1.70.0: an E0659 'ambiguous name' error on pub mod core vs. the core extern-prelude crate, and usize::is_multiple_of (src/inference/abc.rs) requiring rustc 1.87.0. Installed and empirically verified rustc 1.87.0 builds [dependencies] cleanly with zero clippy incompatible_msrv findings anywhere in the crate at rust-version = "1.87". Pinned rust-version = "1.87" in Cargo.toml (only field touched) with a comment explaining the verification and the two 1.70 failure modes; corrected the README/mdBook '1.70+' badges and installation guide to '1.87+'; added a dedicated msrv job to .github/workflows/ci.yml using dtolnay/rust-toolchain@1.87.0 that strips [dev-dependencies] (mdbook + plugins, criterion, proptest -- maintainer tooling with their own higher MSRV floors, never pulled in by a downstream cargo add fugue-ppl) from an ephemeral CI-checkout copy of Cargo.toml, regenerates Cargo.lock, and runs cargo check --lib. Verified this exact CI recipe end-to-end against a fresh checkout of the final committed state.

Re-verification: verified (independent adversarial verifier).

FG-52 — addr! index separator scheme admits silent aliasing between distinct sites

  • Location: fugue/src/core/address.rs:57
  • Severity: low · Dimension: correctness · Verification: confirmed · Auditor confidence: certain

addr!("x", 3) produces the String "x#3" (address.rs:57-59), which is byte-identical to addr!("x#3") and to scoped_addr!-style compositions that also use '#'/'::' as separators (macros/mod.rs:63-70). Because Address is just Address(pub String) with structural Eq/Hash (address.rs:20-21), these alias to the same trace key. Combined with the fact that address collisions are silently overwritten (see the double-count finding), a user who has a literal '#' in a name, or who mixes indexed and scoped forms, can unknowingly merge two semantically different sites. This directly undercuts the documented 'Minimize collisions and accidental reuse' / 'Zero footguns' goals (docs/core/address.md:16). Low severity because it requires the user to pick colliding names, but the separator is an in-band delimiter with no escaping.

Suggested fix: Document the '#'/'::' delimiters as reserved, or make Address structured (e.g. a path of segments) rather than a flat String so indexed and named forms cannot alias.

Resolution: fixed — Same design as FG-26: the addr! index-separator scheme no longer admits silent aliasing (addr!("a","b#3") vs addr!("a#b",3) are now distinct via per-segment escaping). scoped_addr! was made consistent by escaping '#' in its name/index segments; '::' is documented as the reserved scope delimiter.

Regression tests: src/core/address.rs::tests::addr_encoding_is_injective_across_hash_placements, tests/f_runtime_audit.rs::fg26_fg52_addr_indexing_is_collision_free.

Re-verification: verified (independent adversarial verifier).

FG-53 — Categorical re-sums and re-validates the full probability vector on every sample() and log_prob() call

  • Location: fugue/src/core/distribution.rs:705
  • Severity: low · Dimension: performance · Verification: confirmed · Auditor confidence: certain

log_prob (lines 705-708) recomputes prob_sum: f64 = self.probs.iter().sum() and scans self.probs.iter().any(|&p| p<0.0 || !p.is_finite()) on every call, and sample (lines 683-684) does the same. Since probs is private and already validated to sum≈1 and be non-negative/finite in the constructor (lines 617-640), these O(k) re-validations are redundant work on the hot inference path — every log-density evaluation of a k-category variable pays an extra k-element sum plus a k-element scan. In inner MCMC/SMC loops over large categorical vocabularies this is a measurable, avoidable cost.

Suggested fix: Rely on the constructor's invariant (probs are non-negative, finite, sum≈1) and drop the per-call re-validation; log_prob can be just the bounds check on x plus self.probs[*x].ln() (with the <=0 => -inf guard).

Resolution: fixed — Categorical now validates the probability vector exactly once at construction (extracted into validate_probs), caches the inclusive CDF (cumulative field), samples via partition_point binary search over the cached CDF (O(log k), producing the identical first-index-with-cumulative>=u mapping as the old linear scan so seeded reproducibility is preserved), and log_prob is a bounds-checked slice index. Added a public revalidate() method for unchecked-reconstruction paths. Removed the per-call O(k) re-sum/re-scan from both sample and log_prob.

Regression tests: tests/f_dist_distributions.rs::fg53_categorical_log_prob_and_revalidate, tests/f_dist_distributions.rs::fg53_categorical_sample_matches_probabilities, core::distribution::tests::fg53_categorical_cached_cdf_and_revalidate.

Re-verification: verified (independent adversarial verifier).

FG-54 — Fourfold monomorphization of Model variants and Handler methods is accidental complexity; ChoiceValue::I64 is an asymmetric dead value type

  • Location: fugue/src/core/model.rs:20
  • Severity: low · Dimension: elegance · Verification: judgment · Auditor confidence: certain

Model carries 4 near-identical Sample variants + 4 near-identical Observe variants (model.rs:24-102), the Handler trait has 8 parallel on_sample_/on_observe_ methods (handler.rs:31-52), and each of the 5 interpreters repeats the same body 4 times (interpreters.rs) — the sample handlers are ~13 identical lines copied per type. A unified value carrier already exists (ChoiceValue), and a single Sample{addr, dist: Box<dyn Distribution<ChoiceValue-ish>>, k} with a typed-dispatch shim (as the SampleType trait already provides at model.rs:219-310) would collapse most of this. More concretely, this hand-monomorphization has already drifted: ChoiceValue::I64 plus get_i64/as_i64/get_i64_result (trace.rs:36,78,223,294) form a full first-class trace value type, but there is NO SampleF64-style i64 Model variant and NO impl SampleType for i64 (only f64/bool/u64/usize exist, model.rs:227-310) — so nothing in the model layer can ever produce an I64 choice. It is dead, asymmetric API surface that a reader must reconcile.

Suggested fix: Either add the missing i64 sample path for symmetry or remove ChoiceValue::I64 and its accessors. Longer term, consider collapsing the 4x variants/methods behind the existing SampleType/ChoiceValue machinery to cut the copy-paste surface.

Resolution: fixed — Collapsed the fourfold (now fivefold) hand-monomorphization behind internal macro_rules (for_each_value_type + per-handler impl_* macros) that expand the shared sample/observe bodies once per value type — behavior-identical, plus the FG-47/FG-48 fixes applied uniformly. Made ChoiceValue::I64 live: added Model::SampleI64/ObserveI64, SampleType for i64, sample_i64, Handler::on_sample_i64/on_observe_i64 (defaulted so out-of-ownership handlers keep compiling), trampoline arms, and i64 support in all five in-crate handlers. get_i64/insert already existed. A DiscreteUniform distribution is noted as a later work package; the i64 path is tested via a mock distribution and manually built traces.

Regression tests: tests/f_runtime_audit.rs::fg54_i64_sample_replay_and_score_roundtrip (PriorHandler sample, ReplayHandler replay from a manual i64 trace, ScoreGivenTrace scoring, and an out-of-support i64 observe), doctest on core::model::sample_i64.

Re-verification: verified (independent adversarial verifier).

FG-55 — Validate trait is implemented for only 7 of the 10 exported distributions

  • Location: fugue/src/error.rs:629
  • Severity: low · Dimension: completeness · Verification: judgment · Auditor confidence: n/a

The public Validate trait (error.rs:629-631, re-exported at crate root) has impl Validate for {Normal, Exponential, Beta, Gamma, Uniform, Bernoulli, Categorical} but no impl for LogNormal, Binomial, or Poisson — 3 of the 10 distributions listed in core::distribution.rs. This is low-impact in practice since all 10 distributions independently validate their parameters inside their own new() constructor (confirmed by reading each constructor), so the behavior users get is fine; but a user who discovers and relies on the standalone Validate trait for e.g. revalidating a distribution obtained via some other path (deserialization, mutation through a hypothetical setter) would find it silently incomplete for a third of the distribution suite, with no compile error to catch the gap.

Suggested fix: Add the three missing impl Validate for {LogNormal, Binomial, Poisson} (trivial — mirror the existing new() validation logic) for API consistency, or document that Validate is deliberately a partial/legacy trait superseded by constructor-time validation.

Resolution: fixed — Implemented Validate for the 10 distributions missing it (LogNormal, Binomial, Poisson, StudentT, Cauchy, Laplace, Weibull, ChiSquared, InverseGamma, DiscreteUniform), each mirroring its new() constructor exactly (same predicates, messages, ErrorCode, context keys), bringing coverage to all 17 exported distributions. A public-API drift guard enumerates all 17 with a length assertion so adding a distribution without a Validate impl breaks the test file. Commit d6bebba.

Regression tests: validate_all_exported_distributions, fg55_validate_rejects_invalid_parameters, validate_trait_on_valid_distributions.

Re-verification: verified (independent adversarial verifier).

FG-56 — Manual %2==0 modulus check flagged by clippy as reimplementing .is_multiple_of()

  • Location: fugue/src/inference/abc.rs:200
  • Severity: low · Dimension: elegance · Verification: judgment · Auditor confidence: n/a

cargo clippy --all-targets on fugue emits exactly one lint: 'warning: manual implementation of .is_multiple_of()' at fugue/src/inference/abc.rs:200:25, for let median = if sorted.len() % 2 == 0 { ... }. Purely cosmetic (clippy::manual_is_multiple_of), not a correctness issue, but it is the single non-clean spot in an otherwise lint-clean crate.

Suggested fix: Replace with sorted.len().is_multiple_of(2) per clippy's own suggested fix (cargo clippy --fix).

Resolution: fixed — src/inference/abc.rs:203 already reads sorted.len().is_multiple_of(2) and the diff for this commit contains no change to that line or its surrounding function; the commit message states the fix was 'already in place from the trunk migration' prior to this remediation commit.

Re-verification: verified (independent adversarial verifier).

FG-57 — Adaptation is never frozen after warmup — it keeps adapting during the sampling phase

  • Location: fugue/src/inference/mh.rs:505
  • Severity: low · Dimension: math · Verification: confirmed · Auditor confidence: certain

adaptive_mcmc_chain reuses the SAME DiminishingAdaptation object in both the warmup loop (mh.rs:499–502) and the collection loop (mh.rs:505–509); scales continue to change while samples are being recorded. This does not break asymptotic correctness because the step size 1/n^γ → 0 (mcmc_utils.rs:79) satisfies Roberts & Rosenthal's diminishing-adaptation condition, so the collected chain still converges. But it is non-standard: the sampling-phase kernel is time-inhomogeneous, and the recorded samples are not draws from a single fixed transition kernel (Stan/PyMC freeze the tuned parameters after warmup). Minor, and largely masked because per-site n is already large by the sampling phase so adaptation is nearly negligible. Related dead code: should_continue_adaptation (mcmc_utils.rs:107) and log_proposal_prob (mh.rs:92) are never called, and the ChoiceValue::I64 proposal branch (mh.rs:279–293) is unreachable (no i64 sampling handler exists).

Suggested fix: Freeze scales at the end of warmup (e.g. clone the adaptation and stop calling update() during collection), and remove or wire up the dead hooks.

Resolution: fixedadaptive_mcmc_chain_with_overrides now passes adapt=true to single_site_mh_step only during the warmup loop and adapt=false during the sampling loop, so DiminishingAdaptation::update is never invoked while samples are being collected and scales are frozen after warmup, matching the suggested fix. The related dead code noted in the finding was also addressed: log_proposal_prob is now actually called in the acceptance ratio, and the previously-unreachable ChoiceValue::I64 proposal branch now has a real on_sample_i64 handler.

Regression tests: adaptation_freezes_after_warmup.

Re-verification: verified (independent adversarial verifier).

FG-58 — No marginal-likelihood / evidence estimate despite SMC framing

  • Location: fugue/src/inference/smc.rs:419
  • Severity: low · Dimension: completeness · Verification: judgment · Auditor confidence: certain

A key deliverable of SMC is the unbiased marginal-likelihood estimate Z_hat = prod_t (1/N sum_i w_i^t) using the UNNORMALIZED incremental weights. normalize_particles immediately self-normalizes weights and discards the log-normalizing constant log_sum_exp(log_weights) - log(N), so no evidence estimate is available to callers. This is a missing capability rather than a wrong result, but it is the main reason to prefer SMC over plain MCMC for model comparison.

Suggested fix: Accumulate and return log Z_hat = log_sum_exp(incremental_log_weights) - log(N) at each reweighting (using likelihood-only incremental weights per Finding 1).

Resolution: fixed — The rewritten adaptive_smc (src/inference/smc.rs) now accumulates a log-evidence estimate across tempering steps (log-mean incremental weight at each step) and returns it as part of SMCResult, providing the marginal-likelihood estimate that was previously discarded by immediate self-normalization.

Regression tests: fg43_fg58_tempered_smc_matches_conjugate_evidence_and_mean.

Re-verification: verified (independent adversarial verifier).

FG-59 — SMC particle initialization clones the full trace unnecessarily on every particle

  • Location: fugue/src/inference/smc.rs:474
  • Severity: low · Dimension: performance · Verification: confirmed · Auditor confidence: n/a

In smc_prior_particles (lines 459-481), the particle is built as Particle { trace: t.clone(), weight: 0.0, log_weight: t.total_log_weight() } (line 474-476). total_log_weight(&self) only needs a shared borrow and runs after the .clone() in field-evaluation order, so the clone of t (a full BTreeMap<Address,Choice> deep copy) is unnecessary — t could simply be moved into trace after computing log_weight first.

Suggested fix: Compute let log_weight = t.total_log_weight(); before constructing the struct, then move t in directly: Particle { trace: t, weight: 0.0, log_weight }.

Resolution: fixed — In smc_prior_particles (src/inference/smc.rs), the weight is now computed into a local log_weight variable via particle_log_likelihood(&t) before constructing the Particle, and t is moved into trace: t instead of trace: t.clone(), eliminating the redundant full-trace clone.

Re-verification: verified (independent adversarial verifier).

FG-60 — Beta sample_with_aux is not a valid reparameterization: Gaussian moment-match plus hard clamp biases samples and kills gradients

  • Location: fugue/src/inference/vi.rs:203
  • Severity: low · Dimension: math · Verification: confirmed · Auditor confidence: certain

The Beta branch of sample_with_aux (lines 203-223) draws a Gaussian with the Beta's mean/variance and clamps to [0.001, 0.999]. This is not the reparameterization trick for a Beta (Beta has no location-scale reparameterization; correct approaches are implicit reparameterized gradients or the Kumaraswamy/inverse-CDF surrogate). The Gaussian approximation is a different distribution (wrong tails, can exceed [0,1]), and the clamp introduces a point mass at the boundary and zeroes the pathwise derivative there, so any gradient built on this would be biased. This is mitigated only because sample_with_aux is dead code — grep shows it is never called and its _log_prob is discarded — but it is public and labeled the reparameterization path.

Suggested fix: Remove or clearly mark as unimplemented; if a Beta reparameterization is needed, use implicit reparameterized gradients or a Kumaraswamy surrogate rather than a clamped Gaussian.

Resolution: fixed — The Beta arm of VariationalParam::sample_with_aux in src/inference/vi.rs now calls self.sample(rng) (an exact Beta draw via rand_distr, matching the Normal/sample family already used elsewhere) and returns f64::NAN as the auxiliary value to signal no reparameterization base exists, replacing the previous Gaussian moment-match plus clamp to [0.001, 0.999].

Regression tests: fg60_beta_sampling_is_exact_not_clamped_gaussian.

Re-verification: verified (independent adversarial verifier).

FG-61 — prob! do-notation only binds bare identifiers, not patterns

  • Location: fugue/src/macros/mod.rs:21
  • Severity: low · Dimension: usability · Verification: judgment · Auditor confidence: certain

The let $var <- $expr arm (macros/mod.rs:21-23) matches $var:ident, so let (a, b) <- zip(ma, mb); or let mut x <- ... do not compile inside prob!. Since zip (model.rs:512) returns tuples and is a first-class combinator, tuple-destructuring bind is a natural thing to want and its absence forces an extra .bind(|pair| { let (a,b)=pair; ... }) by hand, defeating the ergonomic point of the macro. The macro is otherwise correctly hygienic (uses move closures, fully-qualified $crate:: paths in addr!/plate!/scoped_addr!). Note also prob! expands to $expr.bind(..) which silently requires ModelExt to be in scope; it is re-exported in the prelude so use fugue::* works, but a fully-qualified ModelExt::bind would be more robust.

Suggested fix: Add a let $pat:pat <- $expr; arm to prob! (matching $pat:pat instead of only $var:ident) to support tuple/struct destructuring in monadic binds.

Resolution: fixed — prob! do-notation now accepts any irrefutable pattern on the left of <- (tuples, structs, mut bindings). Because a $p:pat matcher cannot be followed by '<' (Rust fragment follow-set), the bind arm uses a tt-muncher that accumulates the pattern tokens until it hits '<-' (bind) or '=' (plain let). Expansion also uses fully-qualified $crate::core::model::ModelExt::bind so ModelExt need not be imported.

Regression tests: src/macros/mod.rs::tests::prob_macro_binds_tuple_patterns, src/macros/mod.rs::tests::prob_macro_binds_struct_and_mut_patterns, src/macros/mod.rs::tests::prob_macro_tuple_pattern_with_sampling, tests/f_runtime_audit.rs::fg61_prob_accepts_tuple_and_struct_patterns.

Re-verification: verified (independent adversarial verifier).

FG-62 — CowTrace's copy-on-write is correctly implemented — no aliasing bug — but note it is unused

  • Location: fugue/src/runtime/memory.rs:86
  • Severity: low · Dimension: correctness · Verification: confirmed · Auditor confidence: n/a

choices_mut() (lines 86-92) follows the standard Arc make_mut pattern: it checks Arc::strong_count(&self.choices) > 1 and only then deep-clones into a fresh Arc, otherwise calling Arc::get_mut(&mut self.choices).unwrap() for genuinely exclusive in-place mutation. This is semantically correct: mutating one CowTrace handle is never visible through a sibling clone (mutation always either happens on a private strong_count==1 Arc, or triggers a fresh clone first), and this is verified by the crate's own Arc::ptr_eq assertions in test_cow_trace_efficiency and test_cow_trace_memory_sharing (memory.rs:539-567, 704-738). No aliasing/mutation-visible-through-other-handle bug was found. This correctness is moot in practice, however, since (per the dead-code finding) CowTrace is not called from any inference algorithm.

Suggested fix: None needed for correctness; the finding is recorded because the task specifically asked to verify CoW aliasing safety.

Resolution: fixed-by-removal — CowTrace's copy-on-write was verified correct (no aliasing bug) but the finding notes it is unused. Under the same evidence-based cut as FG-22 (pooling <4% end-to-end, below the 10% bar), CowTrace was deleted along with the rest of the memory subsystem, so there is no longer unused/misleading dead code. Its correctness was moot in practice exactly as the finding states.

Re-verification: verified (independent adversarial verifier).

FG-63 — TraceBuilder::with_capacity ignores its argument and silently does nothing different from new()

  • Location: fugue/src/runtime/memory.rs:158
  • Severity: low · Dimension: docs · Verification: judgment · Auditor confidence: n/a

pub fn with_capacity(_capacity: usize) -> Self { // BTreeMap doesn't have with_capacity, but we can pre-allocate differently \n Self::new() } (memory.rs:158-161) takes a capacity parameter that is unused (_capacity) and the comment promises an alternate pre-allocation strategy that isn't implemented — the method is byte-for-byte identical to new(). TraceBuilder is re-exported at the crate root (lib.rs) as part of the 'Performance-minded: memory pooling, copy-on-write traces' claim, so a user calling TraceBuilder::with_capacity(10_000) expecting an allocation hint gets silently no benefit.

Suggested fix: Either implement actual pre-sizing (e.g., switch the internal map to a Vec-backed structure that does support with_capacity, or store a size hint used at build() time) or remove the misleading method/comment and just document that BTreeMap has no capacity control.

Resolution: fixed-by-removal — TraceBuilder::with_capacity silently ignored its capacity argument (byte-for-byte identical to new()). Since the memory subsystem was cut on benchmark evidence, TraceBuilder (and therefore the misleading with_capacity method and its false comment) was deleted entirely. No caller is left expecting a pre-sizing hint.

Re-verification: verified (independent adversarial verifier).

FG-64 — TraceBuilder::with_capacity silently ignores its capacity argument

  • Location: fugue/src/runtime/memory.rs:158
  • Severity: low · Dimension: usability · Verification: judgment · Auditor confidence: n/a

pub fn with_capacity(_capacity: usize) -> Self { // BTreeMap doesn't have with_capacity, but we can pre-allocate differently\n Self::new() } (lines 158-161) takes a capacity hint and does nothing with it — the comment admits BTreeMap has no with_capacity, but the function is still exposed as if sizing mattered, which will mislead callers trying to pre-size a builder for a known number of choices.

Suggested fix: Either implement an actual pre-allocation strategy (e.g. use a Vec<(Address,Choice)> buffer sized with Vec::with_capacity and build the BTreeMap from it at .build() time via BTreeMap::from_iter, which is faster than incremental inserts) or remove the misleading API and document that BTreeMap-backed builders cannot be pre-sized.

Resolution: fixed-by-removal — Duplicate of FG-63 (same with_capacity no-op reported under usability). Resolved by the same deletion of TraceBuilder with the rest of the memory subsystem.

Re-verification: verified (independent adversarial verifier).