Skip to main content

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}