Skip to main content

antecedent_core/query/
attribution.rs

1//! Query submodule.
2//!
3//! SPDX-License-Identifier: MIT OR Apache-2.0
4
5use std::sync::Arc;
6
7use crate::ids::VariableId;
8
9use super::error::QueryError;
10
11#[derive(Clone, Debug, PartialEq, Eq, Hash)]
12/// Anomaly attribution query for observed units.
13pub struct AnomalyAttributionQuery {
14    /// Variables whose anomaly scores are requested.
15    pub targets: Arc<[VariableId]>,
16    /// Optional row indices into the factual table (`None` = all complete rows).
17    pub unit_rows: Option<Arc<[usize]>>,
18    /// Maximum number of units to score (hard size limit).
19    pub max_units: usize,
20}
21
22impl AnomalyAttributionQuery {
23    /// Score all complete rows for `targets`, capped at `max_units`.
24    #[must_use]
25    pub fn new(targets: impl Into<Arc<[VariableId]>>, max_units: usize) -> Self {
26        Self { targets: targets.into(), unit_rows: None, max_units }
27    }
28
29    /// Restrict to explicit row indices.
30    #[must_use]
31    pub fn with_unit_rows(mut self, rows: impl Into<Arc<[usize]>>) -> Self {
32        self.unit_rows = Some(rows.into());
33        self
34    }
35
36    /// Validate targets and limits.
37    ///
38    /// # Errors
39    ///
40    /// Empty targets or zero `max_units`.
41    pub fn validate(&self) -> Result<(), QueryError> {
42        if self.targets.is_empty() {
43            return Err(QueryError::EmptyAnomalyTargets);
44        }
45        if self.max_units == 0 {
46            return Err(QueryError::NonPositiveAnomalyLimit);
47        }
48        Ok(())
49    }
50}
51
52/// Population / period selector for change attribution.
53#[derive(Clone, Debug, PartialEq, Eq, Hash)]
54#[non_exhaustive]
55pub enum PopulationSelector {
56    /// All rows of the bound table.
57    All,
58    /// Explicit row indices into a tabular view.
59    Rows(Arc<[usize]>),
60    /// Multi-environment index (resolved against [`EnvironmentId`] maps at call sites).
61    Environment {
62        /// Dense environment index into a multi-env container.
63        env_index: usize,
64    },
65    /// Inclusive-exclusive time/row range `[start, end)`.
66    TimeRange {
67        /// Start index (inclusive).
68        start: usize,
69        /// End index (exclusive).
70        end: usize,
71    },
72}
73
74impl PopulationSelector {
75    /// Validate selector geometry.
76    ///
77    /// # Errors
78    ///
79    /// Empty row sets or inverted time ranges.
80    pub fn validate(&self) -> Result<(), QueryError> {
81        match self {
82            Self::All | Self::Environment { .. } => Ok(()),
83            Self::Rows(rows) => {
84                if rows.is_empty() {
85                    Err(QueryError::EmptyPopulationRows)
86                } else {
87                    Ok(())
88                }
89            }
90            Self::TimeRange { start, end } => {
91                if *end <= *start {
92                    Err(QueryError::InvalidPopulationTimeRange { start: *start, end: *end })
93                } else {
94                    Ok(())
95                }
96            }
97        }
98    }
99}
100
101/// Which structural pieces participate in change decomposition.
102#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
103#[non_exhaustive]
104pub enum AttributionComponents {
105    /// Input / exogenous / parent value changes only.
106    Inputs,
107    /// Mechanism (conditional) changes only.
108    Mechanisms,
109    /// Graph-structure changes only.
110    Structure,
111    /// Inputs and mechanisms jointly.
112    InputsAndMechanisms,
113    /// Full component set.
114    All,
115}
116
117/// Shapley estimation mode.
118#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
119#[non_exhaustive]
120pub enum ShapleyMode {
121    /// Exact enumeration of all coalitions (`2^n`).
122    Exact,
123    /// Monte Carlo coalition sampling.
124    MonteCarlo {
125        /// Number of coalition / permutation samples.
126        n_samples: usize,
127    },
128    /// Random permutation sampling (classic Shapley estimator).
129    Permutation {
130        /// Number of random permutations.
131        n_permutations: usize,
132    },
133}
134
135/// Configuration for Shapley allocation.
136#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
137pub struct ShapleyConfig {
138    /// Estimation mode.
139    pub mode: ShapleyMode,
140    /// Hard limit on exact combinatorial size (default 12).
141    pub max_exact_components: usize,
142    /// When true, Exact may exceed `max_exact_components` (explicit override).
143    pub allow_exact_override: bool,
144    /// RNG seed for approximate modes.
145    pub seed: u64,
146}
147
148impl ShapleyConfig {
149    /// Default exact config with size limit 12.
150    #[must_use]
151    pub const fn exact() -> Self {
152        Self {
153            mode: ShapleyMode::Exact,
154            max_exact_components: 12,
155            allow_exact_override: false,
156            seed: 0,
157        }
158    }
159
160    /// Monte Carlo Shapley with `n_samples` coalition evaluations.
161    #[must_use]
162    pub const fn monte_carlo(n_samples: usize) -> Self {
163        Self {
164            mode: ShapleyMode::MonteCarlo { n_samples },
165            max_exact_components: 12,
166            allow_exact_override: false,
167            seed: 0,
168        }
169    }
170
171    /// Permutation sampling with `n_permutations` random orders.
172    #[must_use]
173    pub const fn permutation(n_permutations: usize) -> Self {
174        Self {
175            mode: ShapleyMode::Permutation { n_permutations },
176            max_exact_components: 12,
177            allow_exact_override: false,
178            seed: 0,
179        }
180    }
181
182    /// Override the exact size limit.
183    #[must_use]
184    pub const fn with_max_exact_components(mut self, max: usize) -> Self {
185        self.max_exact_components = max;
186        self
187    }
188
189    /// Allow Exact above the configured limit (explicit opt-in).
190    #[must_use]
191    pub const fn with_exact_override(mut self, allow: bool) -> Self {
192        self.allow_exact_override = allow;
193        self
194    }
195
196    /// Set RNG seed for approximate modes.
197    #[must_use]
198    pub const fn with_seed(mut self, seed: u64) -> Self {
199        self.seed = seed;
200        self
201    }
202
203    /// Validate configuration.
204    ///
205    /// # Errors
206    ///
207    /// Zero sample budgets or zero exact limit.
208    pub fn validate(&self) -> Result<(), QueryError> {
209        if self.max_exact_components == 0 {
210            return Err(QueryError::NonPositiveShapleyLimit);
211        }
212        match self.mode {
213            ShapleyMode::Exact => Ok(()),
214            ShapleyMode::MonteCarlo { n_samples } => {
215                if n_samples == 0 {
216                    Err(QueryError::NonPositiveShapleySamples)
217                } else {
218                    Ok(())
219                }
220            }
221            ShapleyMode::Permutation { n_permutations } => {
222                if n_permutations == 0 {
223                    Err(QueryError::NonPositiveShapleySamples)
224                } else {
225                    Ok(())
226                }
227            }
228        }
229    }
230}
231
232/// How to allocate total change across components.
233#[derive(Clone, Debug, PartialEq, Eq, Hash)]
234#[non_exhaustive]
235pub enum AllocationMethod {
236    /// Fixed sequential order (path-dependent; interactions explicit).
237    Sequential {
238        /// Component evaluation order.
239        order: Arc<[crate::ids::ComponentId]>,
240    },
241    /// Shapley symmetrization (exact or approximate).
242    Shapley {
243        /// Approximation / size-limit config.
244        approximation: ShapleyConfig,
245    },
246    /// Path-based dynamic-programming decomposition.
247    PathBased,
248}
249
250impl AllocationMethod {
251    /// Validate allocation settings.
252    ///
253    /// # Errors
254    ///
255    /// Empty sequential order or invalid Shapley config.
256    pub fn validate(&self) -> Result<(), QueryError> {
257        match self {
258            Self::Sequential { order } if order.is_empty() => Err(QueryError::EmptyAllocationOrder),
259            Self::Sequential { .. } | Self::PathBased => Ok(()),
260            Self::Shapley { approximation } => approximation.validate(),
261        }
262    }
263}
264
265/// Distribution / population change attribution query.
266#[derive(Clone, Debug, PartialEq, Eq, Hash)]
267pub struct ChangeAttributionQuery {
268    /// Outcome whose marginal (or summary) change is attributed.
269    pub outcome: VariableId,
270    /// Baseline population / period.
271    pub baseline: PopulationSelector,
272    /// Comparison population / period.
273    pub comparison: PopulationSelector,
274    /// Which structural pieces participate.
275    pub components: AttributionComponents,
276    /// Allocation rule.
277    pub allocation: AllocationMethod,
278    /// Maximum number of attribution components (hard size guard for Exact).
279    pub max_components: usize,
280}
281
282impl ChangeAttributionQuery {
283    /// Construct with Shapley Monte Carlo allocation (common pinned baseline-GCM path).
284    #[must_use]
285    pub fn new(
286        outcome: VariableId,
287        baseline: PopulationSelector,
288        comparison: PopulationSelector,
289    ) -> Self {
290        Self {
291            outcome,
292            baseline,
293            comparison,
294            components: AttributionComponents::Mechanisms,
295            allocation: AllocationMethod::Shapley {
296                approximation: ShapleyConfig::monte_carlo(2_000),
297            },
298            max_components: 64,
299        }
300    }
301
302    /// Set component family.
303    #[must_use]
304    pub const fn with_components(mut self, components: AttributionComponents) -> Self {
305        self.components = components;
306        self
307    }
308
309    /// Set allocation method.
310    #[must_use]
311    pub fn with_allocation(mut self, allocation: AllocationMethod) -> Self {
312        self.allocation = allocation;
313        self
314    }
315
316    /// Cap the number of components considered.
317    #[must_use]
318    pub const fn with_max_components(mut self, max_components: usize) -> Self {
319        self.max_components = max_components;
320        self
321    }
322
323    /// Validate query.
324    ///
325    /// # Errors
326    ///
327    /// Invalid populations, allocation, or zero `max_components`.
328    pub fn validate(&self) -> Result<(), QueryError> {
329        if self.max_components == 0 {
330            return Err(QueryError::NonPositiveComponentLimit);
331        }
332        self.baseline.validate()?;
333        self.comparison.validate()?;
334        self.allocation.validate()?;
335        Ok(())
336    }
337}
338
339/// Mechanism-change *detection* query — not attribution.
340#[derive(Clone, Debug, PartialEq, Eq, Hash)]
341pub struct MechanismChangeQuery {
342    /// Nodes whose mechanisms are tested for change.
343    pub targets: Arc<[VariableId]>,
344    /// Baseline population.
345    pub baseline: PopulationSelector,
346    /// Comparison population.
347    pub comparison: PopulationSelector,
348    /// Significance level for change tests.
349    pub significance_level: OrderedFloatBits,
350    /// Maximum targets to test.
351    pub max_targets: usize,
352}
353
354/// Bit-pattern wrapper so [`MechanismChangeQuery`] stays `Eq`/`Hash` with an f64 level.
355#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
356pub struct OrderedFloatBits(u64);
357
358impl OrderedFloatBits {
359    /// From f64 (NaN → 0 payload).
360    #[must_use]
361    pub fn from_f64(v: f64) -> Self {
362        Self(if v.is_nan() { 0 } else { v.to_bits() })
363    }
364
365    /// To f64.
366    #[must_use]
367    pub const fn to_f64(self) -> f64 {
368        f64::from_bits(self.0)
369    }
370}
371
372impl MechanismChangeQuery {
373    /// Test all `targets` at the given significance level.
374    #[must_use]
375    pub fn new(
376        targets: impl Into<Arc<[VariableId]>>,
377        baseline: PopulationSelector,
378        comparison: PopulationSelector,
379        significance_level: f64,
380        max_targets: usize,
381    ) -> Self {
382        Self {
383            targets: targets.into(),
384            baseline,
385            comparison,
386            significance_level: OrderedFloatBits::from_f64(significance_level),
387            max_targets,
388        }
389    }
390
391    /// Validate.
392    ///
393    /// # Errors
394    ///
395    /// Empty targets, invalid α, or bad populations.
396    pub fn validate(&self) -> Result<(), QueryError> {
397        if self.targets.is_empty() {
398            return Err(QueryError::EmptyMechanismChangeTargets);
399        }
400        if self.max_targets == 0 {
401            return Err(QueryError::NonPositiveComponentLimit);
402        }
403        let alpha = self.significance_level.to_f64();
404        if !(alpha > 0.0 && alpha < 1.0) {
405            return Err(QueryError::InvalidSignificanceLevel);
406        }
407        self.baseline.validate()?;
408        self.comparison.validate()?;
409        Ok(())
410    }
411}
412
413/// Per-unit change attribution query.
414#[derive(Clone, Debug, PartialEq, Eq, Hash)]
415pub struct UnitChangeQuery {
416    /// Outcome variable.
417    pub outcome: VariableId,
418    /// Optional factual row indices (`None` = all units up to `max_units`).
419    pub unit_rows: Option<Arc<[usize]>>,
420    /// Components to attribute (inputs / mechanisms / both).
421    pub components: AttributionComponents,
422    /// Allocation method.
423    pub allocation: AllocationMethod,
424    /// Hard unit count limit.
425    pub max_units: usize,
426}
427
428impl UnitChangeQuery {
429    /// Attribute change for `outcome` across units.
430    #[must_use]
431    pub fn new(outcome: VariableId, max_units: usize) -> Self {
432        Self {
433            outcome,
434            unit_rows: None,
435            components: AttributionComponents::Inputs,
436            allocation: AllocationMethod::Shapley {
437                approximation: ShapleyConfig::monte_carlo(500),
438            },
439            max_units,
440        }
441    }
442
443    /// Restrict to explicit rows.
444    #[must_use]
445    pub fn with_unit_rows(mut self, rows: impl Into<Arc<[usize]>>) -> Self {
446        self.unit_rows = Some(rows.into());
447        self
448    }
449
450    /// Set components.
451    #[must_use]
452    pub const fn with_components(mut self, components: AttributionComponents) -> Self {
453        self.components = components;
454        self
455    }
456
457    /// Set allocation.
458    #[must_use]
459    pub fn with_allocation(mut self, allocation: AllocationMethod) -> Self {
460        self.allocation = allocation;
461        self
462    }
463
464    /// Validate.
465    ///
466    /// # Errors
467    ///
468    /// Zero `max_units` or invalid allocation.
469    pub fn validate(&self) -> Result<(), QueryError> {
470        if self.max_units == 0 {
471            return Err(QueryError::NonPositiveAnomalyLimit);
472        }
473        self.allocation.validate()?;
474        Ok(())
475    }
476}