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`](crate::ids::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 { order } => {
260                for (i, component) in order.iter().enumerate() {
261                    if order[..i].contains(component) {
262                        return Err(QueryError::DuplicateAllocationComponent);
263                    }
264                }
265                Ok(())
266            }
267            Self::PathBased => Ok(()),
268            Self::Shapley { approximation } => approximation.validate(),
269        }
270    }
271}
272
273/// Distribution / population change attribution query.
274#[derive(Clone, Debug, PartialEq, Eq, Hash)]
275pub struct ChangeAttributionQuery {
276    /// Outcome whose marginal (or summary) change is attributed.
277    pub outcome: VariableId,
278    /// Baseline population / period.
279    pub baseline: PopulationSelector,
280    /// Comparison population / period.
281    pub comparison: PopulationSelector,
282    /// Which structural pieces participate.
283    pub components: AttributionComponents,
284    /// Allocation rule.
285    pub allocation: AllocationMethod,
286    /// Maximum number of attribution components (hard size guard for Exact).
287    pub max_components: usize,
288}
289
290impl ChangeAttributionQuery {
291    /// Construct with Shapley Monte Carlo allocation (common pinned baseline-GCM path).
292    #[must_use]
293    pub fn new(
294        outcome: VariableId,
295        baseline: PopulationSelector,
296        comparison: PopulationSelector,
297    ) -> Self {
298        Self {
299            outcome,
300            baseline,
301            comparison,
302            components: AttributionComponents::Mechanisms,
303            allocation: AllocationMethod::Shapley {
304                approximation: ShapleyConfig::monte_carlo(2_000),
305            },
306            max_components: 64,
307        }
308    }
309
310    /// Set component family.
311    #[must_use]
312    pub const fn with_components(mut self, components: AttributionComponents) -> Self {
313        self.components = components;
314        self
315    }
316
317    /// Set allocation method.
318    #[must_use]
319    pub fn with_allocation(mut self, allocation: AllocationMethod) -> Self {
320        self.allocation = allocation;
321        self
322    }
323
324    /// Cap the number of components considered.
325    #[must_use]
326    pub const fn with_max_components(mut self, max_components: usize) -> Self {
327        self.max_components = max_components;
328        self
329    }
330
331    /// Validate query.
332    ///
333    /// # Errors
334    ///
335    /// Invalid populations, allocation, or zero `max_components`.
336    pub fn validate(&self) -> Result<(), QueryError> {
337        if self.max_components == 0 {
338            return Err(QueryError::NonPositiveComponentLimit);
339        }
340        self.baseline.validate()?;
341        self.comparison.validate()?;
342        self.allocation.validate()?;
343        Ok(())
344    }
345}
346
347/// Mechanism-change *detection* query — not attribution.
348#[derive(Clone, Debug, PartialEq, Eq, Hash)]
349pub struct MechanismChangeQuery {
350    /// Nodes whose mechanisms are tested for change.
351    pub targets: Arc<[VariableId]>,
352    /// Baseline population.
353    pub baseline: PopulationSelector,
354    /// Comparison population.
355    pub comparison: PopulationSelector,
356    /// Significance level for change tests.
357    pub significance_level: OrderedFloatBits,
358    /// Maximum targets to test.
359    pub max_targets: usize,
360}
361
362/// Bit-pattern wrapper so [`MechanismChangeQuery`] stays `Eq`/`Hash` with an f64 level.
363#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
364pub struct OrderedFloatBits(u64);
365
366impl OrderedFloatBits {
367    /// From f64 (NaN → 0 payload).
368    #[must_use]
369    pub fn from_f64(v: f64) -> Self {
370        Self(if v.is_nan() { 0 } else { v.to_bits() })
371    }
372
373    /// To f64.
374    #[must_use]
375    pub const fn to_f64(self) -> f64 {
376        f64::from_bits(self.0)
377    }
378}
379
380impl MechanismChangeQuery {
381    /// Test all `targets` at the given significance level.
382    #[must_use]
383    pub fn new(
384        targets: impl Into<Arc<[VariableId]>>,
385        baseline: PopulationSelector,
386        comparison: PopulationSelector,
387        significance_level: f64,
388        max_targets: usize,
389    ) -> Self {
390        Self {
391            targets: targets.into(),
392            baseline,
393            comparison,
394            significance_level: OrderedFloatBits::from_f64(significance_level),
395            max_targets,
396        }
397    }
398
399    /// Validate.
400    ///
401    /// # Errors
402    ///
403    /// Empty targets, invalid α, or bad populations.
404    pub fn validate(&self) -> Result<(), QueryError> {
405        if self.targets.is_empty() {
406            return Err(QueryError::EmptyMechanismChangeTargets);
407        }
408        if self.max_targets == 0 {
409            return Err(QueryError::NonPositiveComponentLimit);
410        }
411        let alpha = self.significance_level.to_f64();
412        if !(alpha > 0.0 && alpha < 1.0) {
413            return Err(QueryError::InvalidSignificanceLevel);
414        }
415        self.baseline.validate()?;
416        self.comparison.validate()?;
417        Ok(())
418    }
419}
420
421/// Per-unit change attribution query.
422#[derive(Clone, Debug, PartialEq, Eq, Hash)]
423pub struct UnitChangeQuery {
424    /// Outcome variable.
425    pub outcome: VariableId,
426    /// Optional factual row indices (`None` = all units up to `max_units`).
427    pub unit_rows: Option<Arc<[usize]>>,
428    /// Components to attribute (inputs / mechanisms / both).
429    pub components: AttributionComponents,
430    /// Allocation method.
431    pub allocation: AllocationMethod,
432    /// Hard unit count limit.
433    pub max_units: usize,
434}
435
436impl UnitChangeQuery {
437    /// Attribute change for `outcome` across units.
438    #[must_use]
439    pub fn new(outcome: VariableId, max_units: usize) -> Self {
440        Self {
441            outcome,
442            unit_rows: None,
443            components: AttributionComponents::Inputs,
444            allocation: AllocationMethod::Shapley {
445                approximation: ShapleyConfig::monte_carlo(500),
446            },
447            max_units,
448        }
449    }
450
451    /// Restrict to explicit rows.
452    #[must_use]
453    pub fn with_unit_rows(mut self, rows: impl Into<Arc<[usize]>>) -> Self {
454        self.unit_rows = Some(rows.into());
455        self
456    }
457
458    /// Set components.
459    #[must_use]
460    pub const fn with_components(mut self, components: AttributionComponents) -> Self {
461        self.components = components;
462        self
463    }
464
465    /// Set allocation.
466    #[must_use]
467    pub fn with_allocation(mut self, allocation: AllocationMethod) -> Self {
468        self.allocation = allocation;
469        self
470    }
471
472    /// Validate.
473    ///
474    /// # Errors
475    ///
476    /// Zero `max_units` or invalid allocation.
477    pub fn validate(&self) -> Result<(), QueryError> {
478        if self.max_units == 0 {
479            return Err(QueryError::NonPositiveAnomalyLimit);
480        }
481        self.allocation.validate()?;
482        Ok(())
483    }
484}
485
486#[cfg(test)]
487mod tests {
488    use super::*;
489    use crate::ComponentId;
490
491    #[test]
492    fn sequential_allocation_rejects_duplicate_components() {
493        let component = ComponentId::from_raw(7);
494        let allocation = AllocationMethod::Sequential { order: Arc::from([component, component]) };
495        assert_eq!(allocation.validate(), Err(QueryError::DuplicateAllocationComponent));
496    }
497}