1use 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#[derive(Clone, Debug, PartialEq, Eq)]
16pub struct AttributionEvidence {
17 pub source: Symbol,
19 pub sequence: Option<u64>,
21 pub kind: Symbol,
23 pub subject: Ref,
25 pub result: Option<Ref>,
27 pub resolved: bool,
29 pub aborted: bool,
31}
32
33#[derive(Clone, Debug, PartialEq, Eq)]
35pub struct AttributionCard {
36 pub run: Ref,
38 pub event_count: usize,
40 pub effect_count: usize,
42 pub unresolved_effect_count: usize,
44 pub aborted_effect_count: usize,
46 pub evidence: Vec<AttributionEvidence>,
48}
49
50impl AttributionCard {
51 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 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
126pub 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#[derive(Clone, Debug, PartialEq)]
170pub struct SyntheticRecord {
171 pub id: String,
173 pub group: String,
175 pub selected: bool,
177 pub features: BTreeMap<String, f64>,
179}
180
181impl SyntheticRecord {
182 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#[derive(Clone, Debug, PartialEq)]
226pub struct CounterfactualChange {
227 pub feature: String,
229 pub from: Option<f64>,
231 pub to: Option<f64>,
233 pub delta: f64,
235}
236
237#[derive(Clone, Debug, PartialEq)]
239pub struct Counterfactual {
240 pub source_id: String,
242 pub counterfactual_id: String,
244 pub desired_selected: bool,
246 pub distance: f64,
248 pub changes: Vec<CounterfactualChange>,
250}
251
252impl Counterfactual {
253 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
292pub 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#[derive(Clone, Debug, PartialEq, Eq)]
341pub struct FairnessGroup {
342 pub label: String,
344 pub selected: u64,
346 pub total: u64,
348}
349
350impl FairnessGroup {
351 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#[derive(Clone, Debug, PartialEq)]
372pub struct FairnessSummary {
373 pub reference_group: String,
375 pub comparison_group: String,
377 pub reference_rate: f64,
379 pub comparison_rate: f64,
381 pub four_fifths_ratio: f64,
383 pub passes_four_fifths: bool,
385}
386
387impl FairnessSummary {
388 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
407pub 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}