1use 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#[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 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
129pub 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#[derive(Clone, Debug, PartialEq)]
173pub struct SyntheticRecord {
174 pub id: String,
176 pub group: String,
178 pub selected: bool,
180 pub features: BTreeMap<String, f64>,
182}
183
184impl SyntheticRecord {
185 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#[derive(Clone, Debug, PartialEq)]
229pub struct CounterfactualChange {
230 pub feature: String,
232 pub from: Option<f64>,
234 pub to: Option<f64>,
236 pub delta: f64,
238}
239
240#[derive(Clone, Debug, PartialEq)]
242pub struct Counterfactual {
243 pub source_id: String,
245 pub counterfactual_id: String,
247 pub desired_selected: bool,
249 pub distance: f64,
251 pub changes: Vec<CounterfactualChange>,
253}
254
255impl Counterfactual {
256 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
295pub 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#[derive(Clone, Debug, PartialEq, Eq)]
344pub struct FairnessGroup {
345 pub label: String,
347 pub selected: u64,
349 pub total: u64,
351}
352
353impl FairnessGroup {
354 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#[derive(Clone, Debug, PartialEq)]
375pub struct FairnessSummary {
376 pub reference_group: String,
378 pub comparison_group: String,
380 pub reference_rate: f64,
382 pub comparison_rate: f64,
384 pub four_fifths_ratio: f64,
386 pub passes_four_fifths: bool,
388}
389
390impl FairnessSummary {
391 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
410pub 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}