Skip to main content

sim_lib_agent/
fairness.rs

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