Skip to main content

sim_lib_numbers_stats/
claim.rs

1//! Claim value support for statistics and fairness metrics.
2
3use std::{any::Any, sync::Arc};
4
5use sim_kernel::{
6    Claim, Cx, Datum, Error, Expr, Factory, NumberLiteral, Object, ObjectCompat, Ref, Result,
7    Symbol, Value,
8};
9use sim_lib_numbers_core::domains;
10
11use crate::{BinaryOutcomeCounts, StatsResult, disparate_impact};
12
13const FOUR_FIFTHS_THRESHOLD: f64 = 0.8;
14
15/// Evidence carried by the disparate-impact fairness Claim.
16#[derive(Clone, Debug, PartialEq)]
17pub struct FairnessEvidence {
18    /// Number of selected cases in the reference group.
19    pub reference_selected: u64,
20    /// Total cases in the reference group.
21    pub reference_total: u64,
22    /// Number of selected cases in the comparison group.
23    pub comparison_selected: u64,
24    /// Total cases in the comparison group.
25    pub comparison_total: u64,
26    /// Selection rate for the reference group.
27    pub reference_rate: f64,
28    /// Selection rate for the comparison group.
29    pub comparison_rate: f64,
30    /// Comparison rate divided by reference rate.
31    pub ratio: f64,
32    /// Four-fifths rule threshold.
33    pub threshold: f64,
34    /// Whether `ratio >= threshold`.
35    pub passes_four_fifths: bool,
36}
37
38impl FairnessEvidence {
39    /// Computes evidence for a disparate-impact Claim from validated counts.
40    pub fn new(
41        reference_selected: u64,
42        reference_total: u64,
43        comparison_selected: u64,
44        comparison_total: u64,
45    ) -> StatsResult<Self> {
46        let reference = BinaryOutcomeCounts::new(reference_selected, reference_total)?;
47        let comparison = BinaryOutcomeCounts::new(comparison_selected, comparison_total)?;
48        let impact = disparate_impact(reference, comparison)?;
49        Ok(Self {
50            reference_selected,
51            reference_total,
52            comparison_selected,
53            comparison_total,
54            reference_rate: impact.reference_rate,
55            comparison_rate: impact.comparison_rate,
56            ratio: impact.ratio,
57            threshold: FOUR_FIFTHS_THRESHOLD,
58            passes_four_fifths: impact.passes_four_fifths,
59        })
60    }
61
62    /// Returns the evidence as a browsable runtime table.
63    pub fn table_value(&self, factory: &dyn Factory) -> Result<Value> {
64        factory.table(vec![
65            (
66                Symbol::new("reference-selected"),
67                u64_value(factory, self.reference_selected)?,
68            ),
69            (
70                Symbol::new("reference-total"),
71                u64_value(factory, self.reference_total)?,
72            ),
73            (
74                Symbol::new("comparison-selected"),
75                u64_value(factory, self.comparison_selected)?,
76            ),
77            (
78                Symbol::new("comparison-total"),
79                u64_value(factory, self.comparison_total)?,
80            ),
81            (
82                Symbol::new("reference-rate"),
83                f64_value(factory, self.reference_rate)?,
84            ),
85            (
86                Symbol::new("comparison-rate"),
87                f64_value(factory, self.comparison_rate)?,
88            ),
89            (Symbol::new("ratio"), f64_value(factory, self.ratio)?),
90            (Symbol::new("value"), f64_value(factory, self.ratio)?),
91            (
92                Symbol::new("threshold"),
93                f64_value(factory, self.threshold)?,
94            ),
95            (
96                Symbol::new("passes-four-fifths"),
97                factory.bool(self.passes_four_fifths)?,
98            ),
99        ])
100    }
101
102    fn datum(&self) -> Datum {
103        Datum::Node {
104            tag: Symbol::qualified("stats", "disparate-impact-evidence"),
105            fields: vec![
106                (
107                    Symbol::new("reference-selected"),
108                    u64_datum(self.reference_selected),
109                ),
110                (
111                    Symbol::new("reference-total"),
112                    u64_datum(self.reference_total),
113                ),
114                (
115                    Symbol::new("comparison-selected"),
116                    u64_datum(self.comparison_selected),
117                ),
118                (
119                    Symbol::new("comparison-total"),
120                    u64_datum(self.comparison_total),
121                ),
122                (
123                    Symbol::new("reference-rate"),
124                    f64_datum(self.reference_rate),
125                ),
126                (
127                    Symbol::new("comparison-rate"),
128                    f64_datum(self.comparison_rate),
129                ),
130                (Symbol::new("ratio"), f64_datum(self.ratio)),
131                (Symbol::new("value"), f64_datum(self.ratio)),
132                (Symbol::new("threshold"), f64_datum(self.threshold)),
133                (
134                    Symbol::new("passes-four-fifths"),
135                    Datum::Bool(self.passes_four_fifths),
136                ),
137            ],
138        }
139    }
140}
141
142/// Evidence carried by a descriptive statistics Claim.
143#[derive(Clone, Debug, PartialEq)]
144pub struct StatsClaimEvidence {
145    metric: Symbol,
146    inputs: Vec<f64>,
147    value: f64,
148}
149
150impl StatsClaimEvidence {
151    /// Creates descriptive metric evidence from validated inputs and a result.
152    pub fn new(metric: Symbol, inputs: Vec<f64>, value: f64) -> Self {
153        Self {
154            metric,
155            inputs,
156            value,
157        }
158    }
159
160    /// Returns the metric named by this evidence.
161    pub fn metric(&self) -> &Symbol {
162        &self.metric
163    }
164
165    /// Returns the input samples or probabilities used to compute the metric.
166    pub fn inputs(&self) -> &[f64] {
167        &self.inputs
168    }
169
170    /// Returns the computed metric value.
171    pub fn value(&self) -> f64 {
172        self.value
173    }
174
175    /// Returns the evidence as a browsable runtime table.
176    pub fn table_value(&self, factory: &dyn Factory) -> Result<Value> {
177        factory.table(vec![
178            (Symbol::new("metric"), factory.symbol(self.metric.clone())?),
179            (
180                Symbol::new("inputs"),
181                f64_list_value(factory, &self.inputs)?,
182            ),
183            (
184                Symbol::new("count"),
185                u64_value(factory, self.inputs.len() as u64)?,
186            ),
187            (Symbol::new("value"), f64_value(factory, self.value)?),
188        ])
189    }
190
191    fn datum(&self) -> Datum {
192        Datum::Node {
193            tag: Symbol::qualified("stats", "result-evidence"),
194            fields: vec![
195                (Symbol::new("metric"), Datum::Symbol(self.metric.clone())),
196                (
197                    Symbol::new("inputs"),
198                    Datum::Vector(self.inputs.iter().copied().map(f64_datum).collect()),
199                ),
200                (Symbol::new("count"), u64_datum(self.inputs.len() as u64)),
201                (Symbol::new("value"), f64_datum(self.value)),
202            ],
203        }
204    }
205}
206
207/// Builds the public descriptive statistics Claim and interns its evidence object.
208pub fn stats_result_claim(cx: &mut Cx, evidence: &StatsClaimEvidence) -> Result<Claim> {
209    Claim::content_object(
210        cx.datum_store_mut(),
211        Ref::Symbol(evidence.metric().clone()),
212        Symbol::new("stats-result"),
213        evidence.datum(),
214    )
215}
216
217/// A first-class runtime object wrapping a descriptive statistics Claim.
218#[derive(Clone, Debug, PartialEq)]
219pub struct StatsClaimValue {
220    claim: Claim,
221    evidence: StatsClaimEvidence,
222}
223
224impl StatsClaimValue {
225    /// Creates a new runtime Claim wrapper.
226    pub fn new(claim: Claim, evidence: StatsClaimEvidence) -> Self {
227        Self { claim, evidence }
228    }
229
230    /// Returns the underlying kernel Claim record.
231    pub fn claim(&self) -> &Claim {
232        &self.claim
233    }
234
235    /// Returns the evidence used to compute the Claim object.
236    pub fn evidence(&self) -> &StatsClaimEvidence {
237        &self.evidence
238    }
239
240    /// Builds a runtime table exposing the Claim and evidence fields.
241    pub fn table_value(&self, cx: &mut Cx) -> Result<Value> {
242        let object = match &self.claim.object {
243            Ref::Content(id) => cx.factory().string(format!("{id:?}"))?,
244            other => cx.factory().string(format!("{other:?}"))?,
245        };
246        cx.factory().table(vec![
247            (
248                Symbol::new("kind"),
249                cx.factory().string("claim".to_owned())?,
250            ),
251            (
252                Symbol::new("subject"),
253                cx.factory().symbol(self.evidence.metric().clone())?,
254            ),
255            (
256                Symbol::new("predicate"),
257                cx.factory().symbol(self.claim.predicate.clone())?,
258            ),
259            (Symbol::new("object"), object),
260            (
261                Symbol::new("evidence"),
262                self.evidence.table_value(cx.factory())?,
263            ),
264        ])
265    }
266}
267
268impl Object for StatsClaimValue {
269    fn snapshot(&self, _cx: &mut Cx) -> Result<Option<Datum>> {
270        Ok(Some(self.claim.canonical_datum()))
271    }
272
273    fn display(&self, _cx: &mut Cx) -> Result<String> {
274        Ok(format!(
275            "#<claim {} stats-result>",
276            self.evidence.metric().as_qualified_str()
277        ))
278    }
279
280    fn as_any(&self) -> &dyn Any {
281        self
282    }
283}
284
285impl ObjectCompat for StatsClaimValue {
286    fn as_expr(&self, _cx: &mut Cx) -> Result<Expr> {
287        Ok(Expr::from(self.claim.canonical_datum()))
288    }
289
290    fn as_table(&self, cx: &mut Cx) -> Result<Value> {
291        self.table_value(cx)
292    }
293}
294
295/// Wraps a descriptive statistics Claim as a runtime value.
296pub fn stats_result_claim_value(cx: &mut Cx, evidence: StatsClaimEvidence) -> Result<Value> {
297    let claim = stats_result_claim(cx, &evidence)?;
298    cx.factory()
299        .opaque(Arc::new(StatsClaimValue::new(claim, evidence)))
300}
301
302/// Builds the public disparate-impact fairness Claim and interns its evidence object.
303pub fn fairness_claim(cx: &mut Cx, evidence: &FairnessEvidence) -> Result<Claim> {
304    Claim::content_object(
305        cx.datum_store_mut(),
306        Ref::Symbol(Symbol::qualified("stats", "disparate-impact")),
307        Symbol::new("fairness-result"),
308        evidence.datum(),
309    )
310}
311
312/// A first-class runtime object wrapping the fairness Claim and its evidence.
313#[derive(Clone, Debug, PartialEq)]
314pub struct FairnessClaimValue {
315    claim: Claim,
316    evidence: FairnessEvidence,
317}
318
319impl FairnessClaimValue {
320    /// Creates a new runtime Claim wrapper.
321    pub fn new(claim: Claim, evidence: FairnessEvidence) -> Self {
322        Self { claim, evidence }
323    }
324
325    /// Returns the underlying kernel Claim record.
326    pub fn claim(&self) -> &Claim {
327        &self.claim
328    }
329
330    /// Returns the evidence used to compute the Claim object.
331    pub fn evidence(&self) -> &FairnessEvidence {
332        &self.evidence
333    }
334
335    /// Builds a runtime table exposing the Claim and evidence fields.
336    pub fn table_value(&self, cx: &mut Cx) -> Result<Value> {
337        let object = match &self.claim.object {
338            Ref::Content(id) => cx.factory().string(format!("{id:?}"))?,
339            other => cx.factory().string(format!("{other:?}"))?,
340        };
341        cx.factory().table(vec![
342            (
343                Symbol::new("kind"),
344                cx.factory().string("claim".to_owned())?,
345            ),
346            (
347                Symbol::new("subject"),
348                cx.factory()
349                    .symbol(Symbol::qualified("stats", "disparate-impact"))?,
350            ),
351            (
352                Symbol::new("predicate"),
353                cx.factory().symbol(self.claim.predicate.clone())?,
354            ),
355            (Symbol::new("object"), object),
356            (
357                Symbol::new("evidence"),
358                self.evidence.table_value(cx.factory())?,
359            ),
360        ])
361    }
362}
363
364impl Object for FairnessClaimValue {
365    fn snapshot(&self, _cx: &mut Cx) -> Result<Option<Datum>> {
366        Ok(Some(self.claim.canonical_datum()))
367    }
368
369    fn display(&self, _cx: &mut Cx) -> Result<String> {
370        Ok("#<claim stats/disparate-impact fairness-result>".to_owned())
371    }
372
373    fn as_any(&self) -> &dyn Any {
374        self
375    }
376}
377
378impl ObjectCompat for FairnessClaimValue {
379    fn as_expr(&self, _cx: &mut Cx) -> Result<Expr> {
380        Ok(Expr::from(self.claim.canonical_datum()))
381    }
382
383    fn as_table(&self, cx: &mut Cx) -> Result<Value> {
384        self.table_value(cx)
385    }
386}
387
388/// Wraps a fairness Claim as a runtime value.
389pub fn fairness_claim_value(cx: &mut Cx, evidence: FairnessEvidence) -> Result<Value> {
390    let claim = fairness_claim(cx, &evidence)?;
391    cx.factory()
392        .opaque(Arc::new(FairnessClaimValue::new(claim, evidence)))
393}
394
395fn u64_value(factory: &dyn Factory, value: u64) -> Result<Value> {
396    factory.number_literal(domains::u64(), value.to_string())
397}
398
399fn f64_value(factory: &dyn Factory, value: f64) -> Result<Value> {
400    factory.number_literal(domains::f64(), value.to_string())
401}
402
403fn f64_list_value(factory: &dyn Factory, values: &[f64]) -> Result<Value> {
404    let values = values
405        .iter()
406        .copied()
407        .map(|value| f64_value(factory, value))
408        .collect::<Result<Vec<_>>>()?;
409    factory.list(values)
410}
411
412fn u64_datum(value: u64) -> Datum {
413    Datum::Number(NumberLiteral {
414        domain: domains::u64(),
415        canonical: value.to_string(),
416    })
417}
418
419fn f64_datum(value: f64) -> Datum {
420    Datum::Number(NumberLiteral {
421        domain: domains::f64(),
422        canonical: value.to_string(),
423    })
424}
425
426pub(crate) fn stats_error_to_kernel(error: crate::StatsError) -> Error {
427    Error::DomainError {
428        domain: Symbol::qualified("numbers", "stats"),
429        category: Symbol::new("invalid-input"),
430        message: error.to_string(),
431    }
432}