tacet_core/analysis/effect.rs
1//! Effect decomposition using Bayesian linear regression (spec §3.4.6).
2//!
3//! This module decomposes timing differences into interpretable components:
4//!
5//! - **Uniform shift (μ)**: All quantiles move equally (e.g., branch timing)
6//! - **Tail effect (τ)**: Upper quantiles shift more than lower (e.g., cache misses)
7//!
8//! ## Design Matrix (spec §3.4.6)
9//!
10//! X = [1 | b_tail] where:
11//! - Column 0: ones = [1, 1, ..., 1] - uniform shift affects all quantiles equally
12//! - Column 1: b_tail = [-0.5, -0.375, ..., 0.5] - tail effect is antisymmetric
13//!
14//! The tail basis is centered (sums to zero) so μ and τ are orthogonal.
15//!
16//! ## Model
17//!
18//! Δ = Xβ + ε, ε ~ N(0, Σ_n)
19//!
20//! With the same conjugate Gaussian model as the Bayesian layer (spec §3.4.6).
21//!
22//! ## Effect Pattern Classification (spec §3.4.6)
23//!
24//! An effect component is "significant" if |effect| > 2×SE:
25//! - UniformShift: Only μ is significant
26//! - TailEffect: Only τ is significant
27//! - Mixed: Both are significant
28//! - Indeterminate: Neither is significant (classify by relative magnitude)
29
30use nalgebra::Cholesky;
31
32use crate::math;
33use crate::result::EffectPattern;
34use crate::types::{Matrix2, Matrix9, Matrix9x2, Vector2, Vector9};
35
36/// Result of effect decomposition (internal, detailed).
37///
38/// Contains the full posterior distribution for diagnostic purposes.
39/// For the simplified public API, use `EffectEstimate`.
40#[derive(Debug, Clone)]
41pub struct EffectDecomposition {
42 /// Posterior mean of (shift, tail) effects in nanoseconds.
43 /// - β[0] = μ (uniform shift)
44 /// - β[1] = τ (tail effect)
45 pub posterior_mean: Vector2,
46
47 /// Posterior covariance of (shift, tail) effects.
48 /// Λ_post = (Xᵀ Σ_n⁻¹ X + Λ₀⁻¹)⁻¹
49 pub posterior_cov: Matrix2,
50
51 /// 95% credible interval for shift effect (μ).
52 pub shift_ci: (f64, f64),
53
54 /// 95% credible interval for tail effect (τ).
55 pub tail_ci: (f64, f64),
56
57 /// Classified effect pattern (spec §3.4.6).
58 pub pattern: EffectPattern,
59}
60
61/// Simplified effect estimate for public API.
62///
63/// This is the format used by the adaptive architecture to report
64/// effect sizes to callers.
65#[derive(Debug, Clone)]
66pub struct EffectEstimate {
67 /// Uniform shift in nanoseconds (positive = baseline slower).
68 ///
69 /// All quantiles shift by approximately this amount.
70 /// Example: A branch that adds constant overhead.
71 pub shift_ns: f64,
72
73 /// Tail effect in nanoseconds (positive = baseline has heavier upper tail).
74 ///
75 /// Upper quantiles shift more than lower quantiles.
76 /// Example: Cache misses that occur probabilistically.
77 pub tail_ns: f64,
78
79 /// 95% credible interval for total effect magnitude in nanoseconds.
80 ///
81 /// Computed from ||β||₂ samples from the posterior distribution.
82 /// Note: May not contain point estimate when β ≈ 0.
83 pub credible_interval_ns: (f64, f64),
84
85 /// Dominant effect pattern (spec §3.4.6).
86 pub pattern: EffectPattern,
87}
88
89impl EffectDecomposition {
90 /// Convert to simplified effect estimate.
91 ///
92 /// Uses the magnitude CI from the posterior samples rather than
93 /// computing it from the marginal CIs (which would be incorrect
94 /// for non-independent components).
95 pub fn to_estimate(&self, magnitude_ci: (f64, f64)) -> EffectEstimate {
96 EffectEstimate {
97 shift_ns: self.posterior_mean[0],
98 tail_ns: self.posterior_mean[1],
99 credible_interval_ns: magnitude_ci,
100 pattern: self.pattern,
101 }
102 }
103}
104
105/// Decompose timing differences into shift and tail effects (spec §3.4.6).
106///
107/// Uses Bayesian linear regression with the same model as the Bayesian layer:
108/// - Design matrix X = [ones | b_tail] (9×2)
109/// - Gaussian prior on β: N(0, Λ₀), Λ₀ = diag(σ_μ², σ_τ²)
110/// - Likelihood: Δ | β ~ N(Xβ, Σ_n)
111///
112/// # Arguments
113///
114/// * `delta` - Observed quantile differences (9-vector, baseline - sample)
115/// * `sigma_n` - Covariance matrix (already scaled for inference sample size)
116/// * `prior_sigmas` - Prior standard deviations (σ_μ, σ_τ) in nanoseconds
117///
118/// # Returns
119///
120/// An `EffectDecomposition` with posterior estimates and credible intervals.
121///
122/// # Note
123///
124/// This function duplicates some computation with `compute_bayes_gibbs`.
125/// In the adaptive architecture, prefer using `BayesResult.beta_mean` and
126/// `BayesResult.beta_cov` directly, then calling `classify_pattern` for
127/// the pattern classification.
128pub fn decompose_effect(
129 delta: &Vector9,
130 sigma_n: &Matrix9,
131 prior_sigmas: (f64, f64),
132) -> EffectDecomposition {
133 // Build design matrix X = [ones | b_tail]
134 let design_matrix = crate::analysis::bayes::build_design_matrix();
135
136 // Compute posterior using Bayesian linear regression
137 let (posterior_mean, posterior_cov) =
138 bayesian_linear_regression(&design_matrix, delta, sigma_n, prior_sigmas);
139
140 // Compute 95% credible intervals (marginal, for each component)
141 let shift_ci = compute_credible_interval(posterior_mean[0], posterior_cov[(0, 0)]);
142 let tail_ci = compute_credible_interval(posterior_mean[1], posterior_cov[(1, 1)]);
143
144 // Classify the effect pattern (spec §3.4.6)
145 let pattern = classify_pattern(&posterior_mean, &posterior_cov);
146
147 EffectDecomposition {
148 posterior_mean,
149 posterior_cov,
150 shift_ci,
151 tail_ci,
152 pattern,
153 }
154}
155
156/// Bayesian linear regression with Gaussian prior (spec §3.4.6).
157///
158/// Prior: β ~ N(0, Λ₀), Λ₀ = diag(σ_μ², σ_τ²)
159/// Likelihood: Δ | β ~ N(Xβ, Σ_n)
160///
161/// Posterior: β | Δ ~ N(β_post, Λ_post)
162/// where:
163/// Λ_post = (Xᵀ Σ_n⁻¹ X + Λ₀⁻¹)⁻¹
164/// β_post = Λ_post Xᵀ Σ_n⁻¹ Δ
165fn bayesian_linear_regression(
166 design: &Matrix9x2,
167 delta: &Vector9,
168 sigma_n: &Matrix9,
169 prior_sigmas: (f64, f64),
170) -> (Vector2, Matrix2) {
171 // Apply variance floor regularization (spec §3.3.2)
172 // This prevents near-zero variance quantiles from dominating the regression
173 let regularized = regularize_covariance(sigma_n);
174
175 let chol = match Cholesky::new(regularized) {
176 Some(c) => c,
177 None => {
178 // Fallback: add more jitter if still failing
179 let extra_reg = regularized + Matrix9::identity() * 1e-6;
180 Cholesky::new(extra_reg).expect("Regularized covariance should be positive definite")
181 }
182 };
183
184 // Σ_n⁻¹ via Cholesky
185 let sigma_n_inv = chol.inverse();
186
187 // Compute sufficient statistics
188 let xt_sigma_n_inv = design.transpose() * sigma_n_inv;
189 let xt_sigma_n_inv_x = xt_sigma_n_inv * design; // Xᵀ Σ_n⁻¹ X (data precision)
190 let xt_sigma_n_inv_delta = xt_sigma_n_inv * delta; // Xᵀ Σ_n⁻¹ Δ (data contribution)
191
192 // Prior precision: Λ₀⁻¹ = diag(1/σ_μ², 1/σ_τ²)
193 let (sigma_mu, sigma_tau) = prior_sigmas;
194 let mut prior_precision = Matrix2::zeros();
195 prior_precision[(0, 0)] = 1.0 / math::sq(sigma_mu.max(1e-12));
196 prior_precision[(1, 1)] = 1.0 / math::sq(sigma_tau.max(1e-12));
197
198 // Posterior precision: Λ_post⁻¹ = Xᵀ Σ_n⁻¹ X + Λ₀⁻¹
199 let posterior_precision = xt_sigma_n_inv_x + prior_precision;
200
201 // Posterior covariance: Λ_post = (Xᵀ Σ_n⁻¹ X + Λ₀⁻¹)⁻¹
202 let posterior_cov = match Cholesky::new(posterior_precision) {
203 Some(c) => c.inverse(),
204 None => Matrix2::identity() * 1e6, // Very wide prior if inversion fails
205 };
206
207 // Posterior mean: β_post = Λ_post Xᵀ Σ_n⁻¹ Δ
208 let posterior_mean = posterior_cov * xt_sigma_n_inv_delta;
209
210 (posterior_mean, posterior_cov)
211}
212
213/// Compute 95% credible interval assuming normal posterior.
214///
215/// For a Gaussian posterior with given mean and variance, the 95% CI is:
216/// [mean - 1.96×σ, mean + 1.96×σ]
217fn compute_credible_interval(mean: f64, variance: f64) -> (f64, f64) {
218 let std = math::sqrt(variance);
219 let z = 1.96; // 97.5th percentile of standard normal
220 (mean - z * std, mean + z * std)
221}
222
223/// Classify the effect pattern based on posterior estimates (spec §3.4.6).
224///
225/// An effect component is "significant" if its magnitude exceeds twice
226/// its posterior standard error: |effect| > 2×SE
227///
228/// Classification rules:
229/// - UniformShift: |μ| > 2σ_μ, |τ| ≤ 2σ_τ
230/// - TailEffect: |τ| > 2σ_τ, |μ| ≤ 2σ_μ
231/// - Mixed: Both significant
232/// - Indeterminate: Neither significant
233///
234/// # Arguments
235///
236/// * `beta_mean` - Posterior mean β = (μ, τ)
237/// * `beta_cov` - Posterior covariance Λ_post
238pub fn classify_pattern(beta_mean: &Vector2, beta_cov: &Matrix2) -> EffectPattern {
239 let shift = beta_mean[0]; // μ
240 let tail = beta_mean[1]; // τ
241
242 // Posterior standard errors
243 let shift_se = math::sqrt(beta_cov[(0, 0)]);
244 let tail_se = math::sqrt(beta_cov[(1, 1)]);
245
246 // Check if effects are "significant" (|effect| > 2×SE)
247 let shift_significant = shift.abs() > 2.0 * shift_se;
248 let tail_significant = tail.abs() > 2.0 * tail_se;
249
250 match (shift_significant, tail_significant) {
251 (true, false) => EffectPattern::UniformShift,
252 (false, true) => EffectPattern::TailEffect,
253 (true, true) => EffectPattern::Mixed,
254 // When neither effect is statistically significant, return Indeterminate
255 (false, false) => EffectPattern::Indeterminate,
256 }
257}
258
259/// Apply variance floor regularization for numerical stability (spec §3.3.2).
260///
261/// When some quantiles have zero or near-zero variance (common in discrete mode
262/// with ties), the covariance matrix becomes ill-conditioned. Even if Cholesky
263/// succeeds, the inverse has huge values for near-zero variance elements,
264/// causing them to dominate the Bayesian regression incorrectly.
265///
266/// We regularize by ensuring a minimum diagonal value of 1% of mean variance.
267/// This bounds the condition number to ~100, preventing numerical instability.
268///
269/// Formula (spec §3.3.2):
270/// σ²ᵢ ← max(σ²ᵢ, 0.01 × σ̄²) + ε
271/// where σ̄² = tr(Σ)/9 and ε = 10⁻¹⁰ + σ̄² × 10⁻⁸
272fn regularize_covariance(sigma: &Matrix9) -> Matrix9 {
273 let trace: f64 = (0..9).map(|i| sigma[(i, i)]).sum();
274 let mean_var = trace / 9.0;
275
276 // Use 1% of mean variance as floor, with absolute minimum of 1e-10
277 let min_var = (0.01 * mean_var).max(1e-10);
278
279 // Also add small jitter proportional to scale for numerical stability
280 let jitter = 1e-10 + mean_var * 1e-8;
281
282 let mut regularized = *sigma;
283 for i in 0..9 {
284 regularized[(i, i)] = regularized[(i, i)].max(min_var) + jitter;
285 }
286 regularized
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292 use crate::analysis::bayes::build_design_matrix;
293 use crate::constants::B_TAIL;
294
295 #[test]
296 fn test_design_matrix_structure() {
297 let x = build_design_matrix();
298
299 // First column should be all ones
300 for i in 0..9 {
301 assert!((x[(i, 0)] - 1.0).abs() < 1e-10);
302 }
303
304 // Second column should match B_TAIL
305 for i in 0..9 {
306 assert!((x[(i, 1)] - B_TAIL[i]).abs() < 1e-10);
307 }
308 }
309
310 #[test]
311 fn test_credible_interval_symmetry() {
312 let (low, high) = compute_credible_interval(0.0, 1.0);
313 assert!(
314 (low + high).abs() < 0.01,
315 "CI should be symmetric around zero"
316 );
317 }
318
319 #[test]
320 fn test_classify_uniform_shift() {
321 // Large shift, no tail effect
322 let mean = Vector2::new(10.0, 0.1);
323 let cov = Matrix2::identity();
324 let pattern = classify_pattern(&mean, &cov);
325 assert_eq!(pattern, EffectPattern::UniformShift);
326 }
327
328 #[test]
329 fn test_classify_tail_effect() {
330 // No shift, large tail effect
331 let mean = Vector2::new(0.1, 10.0);
332 let cov = Matrix2::identity();
333 let pattern = classify_pattern(&mean, &cov);
334 assert_eq!(pattern, EffectPattern::TailEffect);
335 }
336
337 #[test]
338 fn test_classify_indeterminate() {
339 // Neither shift nor tail significant (both small relative to SE)
340 let mean = Vector2::new(0.5, 0.5);
341 let cov = Matrix2::identity();
342 let pattern = classify_pattern(&mean, &cov);
343 assert_eq!(pattern, EffectPattern::Indeterminate);
344 }
345}