Skip to main content

sim_lib_agent/
fairness.rs

1//! Deterministic fairness and explanation facets for offline agent recipes.
2
3use crate::expr_to_value;
4use sim_kernel::{
5    Cx, Error, Event, EventKind, EventLedger, Expr, NumberLiteral, Ref, Result, Symbol, Value,
6    card::{Card, card_help_predicate, card_kind_predicate, card_result_predicate},
7    effect_ledger::EffectLedger,
8};
9use sim_lib_numbers_stats::{BinaryOutcomeCounts, disparate_impact};
10use std::{collections::BTreeMap, sync::Arc};
11
12const ATTRIBUTION_KIND: &str = "fairness-attribution";
13
14/// One attribution row derived from a run event or effect record.
15#[derive(Clone, Debug, PartialEq, Eq)]
16pub struct AttributionEvidence {
17    /// Origin of the row, such as `event` or `effect`.
18    pub source: Symbol,
19    /// Event sequence number, when the row comes from an event.
20    pub sequence: Option<u64>,
21    /// Kind of event or effect record.
22    pub kind: Symbol,
23    /// Reference to the subject of the row.
24    pub subject: Ref,
25    /// Reference to the result, when one was produced.
26    pub result: Option<Ref>,
27    /// Whether the underlying effect resolved.
28    pub resolved: bool,
29    /// Whether the underlying effect was aborted.
30    pub aborted: bool,
31}
32
33/// Card-shaped attribution summary over an event ledger and effect ledger.
34#[derive(Clone, Debug, PartialEq, Eq)]
35pub struct AttributionCard {
36    /// Reference to the run this card summarizes.
37    pub run: Ref,
38    /// Number of events attributed to the run.
39    pub event_count: usize,
40    /// Number of effect records observed.
41    pub effect_count: usize,
42    /// Number of effect records that never resolved.
43    pub unresolved_effect_count: usize,
44    /// Number of effect records that were aborted.
45    pub aborted_effect_count: usize,
46    /// Per-event and per-effect attribution rows.
47    pub evidence: Vec<AttributionEvidence>,
48}
49
50impl AttributionCard {
51    /// Encodes this attribution card as ordinary SIM expression data.
52    pub fn as_expr(&self) -> Expr {
53        Expr::Map(vec![
54            key(
55                "kind",
56                Expr::Symbol(Symbol::qualified("agent", ATTRIBUTION_KIND)),
57            ),
58            key("run", ref_expr(&self.run)),
59            key("event-count", usize_expr(self.event_count)),
60            key("effect-count", usize_expr(self.effect_count)),
61            key(
62                "unresolved-effect-count",
63                usize_expr(self.unresolved_effect_count),
64            ),
65            key(
66                "aborted-effect-count",
67                usize_expr(self.aborted_effect_count),
68            ),
69            key(
70                "evidence",
71                Expr::List(
72                    self.evidence
73                        .iter()
74                        .map(AttributionEvidence::as_expr)
75                        .collect(),
76                ),
77            ),
78        ])
79    }
80
81    /// Encodes this attribution as a kernel Card value for browse surfaces.
82    pub fn as_card_value(&self, cx: &mut Cx) -> Result<Value> {
83        let entries = vec![
84            (
85                card_kind_predicate(),
86                expr_to_value(
87                    cx,
88                    &Expr::Symbol(Symbol::qualified("agent", ATTRIBUTION_KIND)),
89                )?,
90            ),
91            (
92                card_help_predicate(),
93                expr_to_value(
94                    cx,
95                    &Expr::String(
96                        "Deterministic attribution over run events and effects".to_owned(),
97                    ),
98                )?,
99            ),
100            (card_result_predicate(), expr_to_value(cx, &self.as_expr())?),
101        ];
102        cx.factory()
103            .opaque(Arc::new(Card::new(self.run.clone(), entries)))
104    }
105}
106
107impl AttributionEvidence {
108    fn as_expr(&self) -> Expr {
109        let mut entries = vec![
110            key("source", Expr::Symbol(self.source.clone())),
111            key("kind", Expr::Symbol(self.kind.clone())),
112            key("subject", ref_expr(&self.subject)),
113            key("resolved", Expr::Bool(self.resolved)),
114            key("aborted", Expr::Bool(self.aborted)),
115        ];
116        if let Some(sequence) = self.sequence {
117            entries.push(key("sequence", usize_expr(sequence as usize)));
118        }
119        if let Some(result) = &self.result {
120            entries.push(key("result", ref_expr(result)));
121        }
122        Expr::Map(entries)
123    }
124}
125
126/// Builds an attribution card from the evidence for one run.
127pub fn attribution_card(
128    run: Ref,
129    events: &EventLedger,
130    effects: &EffectLedger,
131) -> Result<AttributionCard> {
132    let run_events = events.events_for_run(&run);
133    let records = effects.records();
134    if run_events.is_empty() && records.is_empty() {
135        return Err(Error::Eval(
136            "attribution card requires event or effect evidence".to_owned(),
137        ));
138    }
139
140    let mut evidence = Vec::with_capacity(run_events.len() + records.len());
141    evidence.extend(run_events.iter().map(event_evidence));
142    evidence.extend(records.iter().map(|record| AttributionEvidence {
143        source: Symbol::new("effect"),
144        sequence: None,
145        kind: Symbol::new("effect-record"),
146        subject: record.effect.clone(),
147        result: record.result.clone(),
148        resolved: record.resolved_event.is_some(),
149        aborted: record.aborted,
150    }));
151
152    let unresolved_effect_count = records
153        .iter()
154        .filter(|record| record.resolved_event.is_none())
155        .count();
156    let aborted_effect_count = records.iter().filter(|record| record.aborted).count();
157
158    Ok(AttributionCard {
159        run,
160        event_count: run_events.len(),
161        effect_count: records.len(),
162        unresolved_effect_count,
163        aborted_effect_count,
164        evidence,
165    })
166}
167
168/// One deterministic synthetic record used for counterfactual search.
169#[derive(Clone, Debug, PartialEq)]
170pub struct SyntheticRecord {
171    /// Unique record identifier.
172    pub id: String,
173    /// Group label the record belongs to.
174    pub group: String,
175    /// Whether the record was selected (the positive outcome).
176    pub selected: bool,
177    /// Finite feature values keyed by feature name.
178    pub features: BTreeMap<String, f64>,
179}
180
181impl SyntheticRecord {
182    /// Builds a synthetic record and rejects missing ids, groups, or live values.
183    pub fn new(
184        id: impl Into<String>,
185        group: impl Into<String>,
186        selected: bool,
187        features: impl IntoIterator<Item = (impl Into<String>, f64)>,
188    ) -> Result<Self> {
189        let id = id.into();
190        let group = group.into();
191        if id.is_empty() {
192            return Err(Error::Eval("synthetic record id is required".to_owned()));
193        }
194        if group.is_empty() {
195            return Err(Error::Eval("synthetic record group is required".to_owned()));
196        }
197        let mut feature_map = BTreeMap::new();
198        for (name, value) in features {
199            let name = name.into();
200            if name.is_empty() {
201                return Err(Error::Eval("synthetic feature name is required".to_owned()));
202            }
203            if !value.is_finite() {
204                return Err(Error::Eval(format!(
205                    "synthetic feature {name} must be finite"
206                )));
207            }
208            feature_map.insert(name, value);
209        }
210        if feature_map.is_empty() {
211            return Err(Error::Eval(
212                "synthetic record requires at least one feature".to_owned(),
213            ));
214        }
215        Ok(Self {
216            id,
217            group,
218            selected,
219            features: feature_map,
220        })
221    }
222}
223
224/// One feature delta in a counterfactual explanation.
225#[derive(Clone, Debug, PartialEq)]
226pub struct CounterfactualChange {
227    /// Name of the changed feature.
228    pub feature: String,
229    /// Source value, when the feature was present.
230    pub from: Option<f64>,
231    /// Counterfactual value, when the feature is present.
232    pub to: Option<f64>,
233    /// Magnitude of the change contributing to the distance.
234    pub delta: f64,
235}
236
237/// Minimal-change counterfactual found in a deterministic synthetic table.
238#[derive(Clone, Debug, PartialEq)]
239pub struct Counterfactual {
240    /// Id of the source record.
241    pub source_id: String,
242    /// Id of the nearest desired-outcome record.
243    pub counterfactual_id: String,
244    /// Outcome the counterfactual achieves.
245    pub desired_selected: bool,
246    /// Summed distance between the records.
247    pub distance: f64,
248    /// Per-feature changes from source to counterfactual.
249    pub changes: Vec<CounterfactualChange>,
250}
251
252impl Counterfactual {
253    /// Encodes the counterfactual as expression data for recipes.
254    pub fn as_expr(&self) -> Expr {
255        Expr::Map(vec![
256            key("source-id", Expr::String(self.source_id.clone())),
257            key(
258                "counterfactual-id",
259                Expr::String(self.counterfactual_id.clone()),
260            ),
261            key("desired-selected", Expr::Bool(self.desired_selected)),
262            key("distance", f64_expr(self.distance)),
263            key(
264                "changes",
265                Expr::List(
266                    self.changes
267                        .iter()
268                        .map(CounterfactualChange::as_expr)
269                        .collect(),
270                ),
271            ),
272        ])
273    }
274}
275
276impl CounterfactualChange {
277    fn as_expr(&self) -> Expr {
278        let mut entries = vec![
279            key("feature", Expr::String(self.feature.clone())),
280            key("delta", f64_expr(self.delta)),
281        ];
282        if let Some(from) = self.from {
283            entries.push(key("from", f64_expr(from)));
284        }
285        if let Some(to) = self.to {
286            entries.push(key("to", f64_expr(to)));
287        }
288        Expr::Map(entries)
289    }
290}
291
292/// Finds the nearest synthetic record with the desired outcome.
293pub fn minimal_counterfactual(
294    records: &[SyntheticRecord],
295    source_id: &str,
296    desired_selected: bool,
297) -> Result<Counterfactual> {
298    let source = records
299        .iter()
300        .find(|record| record.id == source_id)
301        .ok_or_else(|| Error::Eval(format!("counterfactual source not found: {source_id}")))?;
302    if source.selected == desired_selected {
303        return Err(Error::Eval(
304            "counterfactual source already has desired outcome".to_owned(),
305        ));
306    }
307
308    let mut candidates = records
309        .iter()
310        .filter(|record| record.id != source.id && record.selected == desired_selected)
311        .map(|record| {
312            let changes = feature_changes(source, record);
313            let distance = changes.iter().map(|change| change.delta).sum::<f64>();
314            (record, distance, changes)
315        })
316        .collect::<Vec<_>>();
317
318    candidates.sort_by(|left, right| {
319        left.1
320            .total_cmp(&right.1)
321            .then_with(|| left.0.id.cmp(&right.0.id))
322    });
323
324    let Some((record, distance, changes)) = candidates.into_iter().next() else {
325        return Err(Error::Eval(
326            "counterfactual search found no desired-outcome record".to_owned(),
327        ));
328    };
329
330    Ok(Counterfactual {
331        source_id: source.id.clone(),
332        counterfactual_id: record.id.clone(),
333        desired_selected,
334        distance,
335        changes,
336    })
337}
338
339/// Binary-outcome group counts for fairness summaries.
340#[derive(Clone, Debug, PartialEq, Eq)]
341pub struct FairnessGroup {
342    /// Group label.
343    pub label: String,
344    /// Number of selected (positive-outcome) members.
345    pub selected: u64,
346    /// Total number of members.
347    pub total: u64,
348}
349
350impl FairnessGroup {
351    /// Builds a fairness group and rejects impossible counts.
352    pub fn new(label: impl Into<String>, selected: u64, total: u64) -> Result<Self> {
353        let label = label.into();
354        if label.is_empty() {
355            return Err(Error::Eval("fairness group label is required".to_owned()));
356        }
357        BinaryOutcomeCounts::new(selected, total).map_err(stats_error)?;
358        Ok(Self {
359            label,
360            selected,
361            total,
362        })
363    }
364
365    fn counts(&self) -> Result<BinaryOutcomeCounts> {
366        BinaryOutcomeCounts::new(self.selected, self.total).map_err(stats_error)
367    }
368}
369
370/// Fairness summary backed by the F4 disparate-impact metric.
371#[derive(Clone, Debug, PartialEq)]
372pub struct FairnessSummary {
373    /// Label of the reference group.
374    pub reference_group: String,
375    /// Label of the comparison group.
376    pub comparison_group: String,
377    /// Selection rate of the reference group.
378    pub reference_rate: f64,
379    /// Selection rate of the comparison group.
380    pub comparison_rate: f64,
381    /// Disparate-impact ratio against the four-fifths rule.
382    pub four_fifths_ratio: f64,
383    /// Whether the ratio passes the four-fifths threshold.
384    pub passes_four_fifths: bool,
385}
386
387impl FairnessSummary {
388    /// Encodes the summary as expression data for recipes.
389    pub fn as_expr(&self) -> Expr {
390        Expr::Map(vec![
391            key(
392                "reference-group",
393                Expr::String(self.reference_group.clone()),
394            ),
395            key(
396                "comparison-group",
397                Expr::String(self.comparison_group.clone()),
398            ),
399            key("reference-rate", f64_expr(self.reference_rate)),
400            key("comparison-rate", f64_expr(self.comparison_rate)),
401            key("four-fifths-ratio", f64_expr(self.four_fifths_ratio)),
402            key("passes-four-fifths", Expr::Bool(self.passes_four_fifths)),
403        ])
404    }
405}
406
407/// Computes a deterministic fairness summary for two binary-outcome groups.
408pub fn fairness_summary(
409    reference: &FairnessGroup,
410    comparison: &FairnessGroup,
411) -> Result<FairnessSummary> {
412    let impact =
413        disparate_impact(reference.counts()?, comparison.counts()?).map_err(stats_error)?;
414    Ok(FairnessSummary {
415        reference_group: reference.label.clone(),
416        comparison_group: comparison.label.clone(),
417        reference_rate: impact.reference_rate,
418        comparison_rate: impact.comparison_rate,
419        four_fifths_ratio: impact.ratio,
420        passes_four_fifths: impact.passes_four_fifths,
421    })
422}
423
424fn event_evidence(event: &Event) -> AttributionEvidence {
425    let (kind, subject, result) = match &event.kind {
426        EventKind::Started { request } => (Symbol::new("started"), request.clone(), None),
427        EventKind::Claim { claim } => (Symbol::new("claim"), claim.clone(), None),
428        EventKind::Diagnostic(_) => (
429            Symbol::new("diagnostic"),
430            Ref::Symbol(Symbol::qualified("event", "diagnostic")),
431            None,
432        ),
433        EventKind::Trace(trace) => (Symbol::new("trace"), trace.clone(), None),
434        EventKind::Chunk { payload } => (Symbol::new("chunk"), payload.clone(), None),
435        EventKind::EffectRequested { effect } => {
436            (Symbol::new("effect-requested"), effect.clone(), None)
437        }
438        EventKind::EffectResolved { effect, result } => (
439            Symbol::new("effect-resolved"),
440            effect.clone(),
441            Some(result.clone()),
442        ),
443        EventKind::Capture { effect } => (Symbol::new("capture"), effect.clone(), None),
444        EventKind::Card { subject, card } => {
445            (Symbol::new("card"), subject.clone(), Some(card.clone()))
446        }
447        EventKind::Final(value) => (Symbol::new("final"), value.clone(), None),
448        EventKind::Failed(error) => (Symbol::new("failed"), error.clone(), None),
449        EventKind::Done => (
450            Symbol::new("done"),
451            Ref::Symbol(Symbol::qualified("event", "done")),
452            None,
453        ),
454    };
455    AttributionEvidence {
456        source: Symbol::new("event"),
457        sequence: Some(event.seq),
458        kind,
459        subject,
460        result,
461        resolved: true,
462        aborted: false,
463    }
464}
465
466fn feature_changes(
467    source: &SyntheticRecord,
468    counterfactual: &SyntheticRecord,
469) -> Vec<CounterfactualChange> {
470    let mut keys = source.features.keys().cloned().collect::<Vec<_>>();
471    keys.extend(
472        counterfactual
473            .features
474            .keys()
475            .filter(|key| !source.features.contains_key(*key))
476            .cloned(),
477    );
478    keys.sort();
479
480    keys.into_iter()
481        .filter_map(|feature| {
482            let from = source.features.get(&feature).copied();
483            let to = counterfactual.features.get(&feature).copied();
484            let delta = match (from, to) {
485                (Some(from), Some(to)) => (to - from).abs(),
486                (Some(_), None) | (None, Some(_)) => 1.0,
487                (None, None) => 0.0,
488            };
489            (delta > 0.0).then_some(CounterfactualChange {
490                feature,
491                from,
492                to,
493                delta,
494            })
495        })
496        .collect()
497}
498
499fn ref_expr(reference: &Ref) -> Expr {
500    match reference {
501        Ref::Symbol(symbol) => Expr::Symbol(symbol.clone()),
502        _ => Expr::String(format!("{reference:?}")),
503    }
504}
505
506fn key(name: &str, value: Expr) -> (Expr, Expr) {
507    (Expr::Symbol(Symbol::new(name)), value)
508}
509
510fn usize_expr(value: usize) -> Expr {
511    Expr::Number(NumberLiteral {
512        domain: Symbol::qualified("numbers", "f64"),
513        canonical: value.to_string(),
514    })
515}
516
517fn f64_expr(value: f64) -> Expr {
518    Expr::Number(NumberLiteral {
519        domain: Symbol::qualified("numbers", "f64"),
520        canonical: value.to_string(),
521    })
522}
523
524fn stats_error(error: impl std::fmt::Display) -> Error {
525    Error::Eval(error.to_string())
526}