Skip to main content

cea_core/
attribution.rs

1use std::cmp::Ordering;
2use std::collections::{HashMap, HashSet};
3
4use check_runner::{CheckResult, EffectSignature, ParsedCheckOutput};
5use typed_patch::{Anchor, EditOp, LineAttributionMap, StructuredPatch};
6
7use crate::error::CeaCoreError;
8use crate::scope::infer_scope;
9use crate::types::{AnchorKind, EditOpKind, EditOpSignature, FileIndex, OpIndex};
10
11const RUN_HASH_VERSION: &[u8] = b"cea-core:run-hash:v2";
12const CAUSE_HASH_VERSION: &[u8] = b"cea-core:cause:v2";
13const EFFECT_HASH_VERSION: &[u8] = b"cea-core:effect:v2";
14
15#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
16pub struct AttributionTriple {
17    /// The attributed edit operation.
18    pub cause: EditOpSignature,
19    /// The observed effect emitted by a checker.
20    pub effect: EffectSignature,
21    /// Absolute line distance used during candidate scoring.
22    pub distance: i32,
23    /// Fractional contribution for this cause/effect pair. Weights are normalized per effect.
24    pub weight: f64,
25}
26
27#[derive(Debug, Clone)]
28pub struct AttributedRunResult {
29    pub triples: Vec<AttributionTriple>,
30    pub check_result: CheckResult,
31    pub run_hash: String,
32}
33
34impl AttributedRunResult {
35    pub fn new(triples: Vec<AttributionTriple>, check_result: CheckResult) -> Self {
36        let mut run = Self {
37            triples,
38            check_result,
39            run_hash: String::new(),
40        };
41        run.run_hash = compute_run_hash(&run);
42        run
43    }
44}
45
46#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
47pub struct AttributionConfig {
48    /// The baseline same-file distance cut-off.
49    pub max_line_distance: u32,
50    /// Radius multiplier applied before candidate selection.
51    pub candidate_radius_multiplier: u32,
52    /// Maximum causes emitted for any single effect.
53    pub top_k_causes: usize,
54    /// Softmax temperature used to normalize candidate scores.
55    pub softmax_temperature: f64,
56    /// Distance decay constant used by [`attribution_score_with_confidence`].
57    pub distance_decay: f64,
58    /// Confidence assigned when a line cannot be resolved exactly.
59    pub missing_line_confidence: f64,
60    /// Exponential decay factor applied to graph edge weights during ingest.
61    pub decay_factor: f64,
62}
63
64impl Default for AttributionConfig {
65    fn default() -> Self {
66        Self {
67            max_line_distance: 12,
68            candidate_radius_multiplier: 2,
69            top_k_causes: 5,
70            softmax_temperature: 0.75,
71            distance_decay: 8.0,
72            missing_line_confidence: 0.35,
73            decay_factor: 0.995,
74        }
75    }
76}
77
78impl AttributionConfig {
79    pub fn validate(&self) -> Result<(), CeaCoreError> {
80        if self.candidate_radius_multiplier == 0 {
81            return Err(CeaCoreError::InvalidConfig(
82                "candidate_radius_multiplier must be >= 1".to_string(),
83            ));
84        }
85        if self.top_k_causes == 0 {
86            return Err(CeaCoreError::InvalidConfig(
87                "top_k_causes must be >= 1".to_string(),
88            ));
89        }
90        if !self.softmax_temperature.is_finite() || self.softmax_temperature <= 0.0 {
91            return Err(CeaCoreError::InvalidConfig(
92                "softmax_temperature must be finite and > 0".to_string(),
93            ));
94        }
95        if !self.distance_decay.is_finite() || self.distance_decay <= 0.0 {
96            return Err(CeaCoreError::InvalidConfig(
97                "distance_decay must be finite and > 0".to_string(),
98            ));
99        }
100        if !self.missing_line_confidence.is_finite()
101            || !(0.0..=1.0).contains(&self.missing_line_confidence)
102        {
103            return Err(CeaCoreError::InvalidConfig(
104                "missing_line_confidence must be in [0, 1]".to_string(),
105            ));
106        }
107        if !self.decay_factor.is_finite() || !(0.0..=1.0).contains(&self.decay_factor) {
108            return Err(CeaCoreError::InvalidConfig(
109                "decay_factor must be in [0, 1]".to_string(),
110            ));
111        }
112        Ok(())
113    }
114}
115
116#[derive(Debug, Clone)]
117struct EffectOccurrence {
118    signature: EffectSignature,
119    file: Option<String>,
120    line: Option<u32>,
121}
122
123#[derive(Debug, Clone)]
124struct CauseCandidate {
125    signature: EditOpSignature,
126    file: String,
127    line: Option<u32>,
128    mapping_confidence: f64,
129}
130
131#[derive(Debug, Clone)]
132struct WeightedCandidate {
133    cause: EditOpSignature,
134    distance: i32,
135    score: f64,
136}
137
138#[derive(Debug, Clone, Default)]
139pub(crate) struct LineMapIndex {
140    pub mappings: HashMap<String, HashMap<u32, u32>>,
141    resolved_anchors: HashMap<String, HashMap<usize, u32>>,
142}
143
144impl From<&LineAttributionMap> for LineMapIndex {
145    fn from(value: &LineAttributionMap) -> Self {
146        Self {
147            mappings: value
148                .mappings
149                .iter()
150                .map(|(file, mappings)| {
151                    let indexed = mappings.iter().copied().collect::<HashMap<_, _>>();
152                    (file.clone(), indexed)
153                })
154                .collect(),
155            resolved_anchors: value.resolved_anchors.iter().fold(
156                HashMap::new(),
157                |mut acc, ((file, op_index), line)| {
158                    acc.entry(file.clone())
159                        .or_insert_with(HashMap::new)
160                        .insert(*op_index, *line);
161                    acc
162                },
163            ),
164        }
165    }
166}
167
168impl LineMapIndex {
169    fn lookup_mapped_line(&self, file_key: &str, original_line: u32) -> Option<u32> {
170        self.mappings
171            .get(file_key)
172            .and_then(|mappings| mappings.get(&original_line).copied())
173    }
174
175    fn resolved_anchor(&self, file_key: &str, op_index: usize) -> Option<u32> {
176        self.resolved_anchors
177            .get(file_key)
178            .and_then(|anchors| anchors.get(&op_index).copied())
179    }
180}
181
182pub(crate) fn build_line_map_index(line_map: &LineAttributionMap) -> LineMapIndex {
183    LineMapIndex::from(line_map)
184}
185
186pub fn build_edit_op_signature(
187    op: &EditOp,
188    op_index: usize,
189    _total_ops: usize,
190    file_index: usize,
191    _total_files: usize,
192    file_extension: &str,
193) -> Result<EditOpSignature, CeaCoreError> {
194    let context_lines = op.context_lines();
195    let context = context_lines
196        .iter()
197        .map(|line| line.trim())
198        .filter(|line| !line.is_empty())
199        .collect::<Vec<_>>()
200        .join("\n");
201
202    let op_index = u32::try_from(op_index).map_err(|_| {
203        CeaCoreError::InvalidInput(format!("op_index {op_index} does not fit into u32"))
204    })?;
205    let file_index = u32::try_from(file_index).map_err(|_| {
206        CeaCoreError::InvalidInput(format!("file_index {file_index} does not fit into u32"))
207    })?;
208
209    let lines_added = u32::try_from(op.lines_added()).map_err(|_| {
210        CeaCoreError::InvalidInput(format!(
211            "lines_added {} does not fit into u32",
212            op.lines_added()
213        ))
214    })?;
215    let lines_removed = u32::try_from(op.lines_removed()).map_err(|_| {
216        CeaCoreError::InvalidInput(format!(
217            "lines_removed {} does not fit into u32",
218            op.lines_removed()
219        ))
220    })?;
221
222    Ok(EditOpSignature {
223        op_kind: match op {
224            EditOp::Insert { .. } => EditOpKind::Insert,
225            EditOp::Delete { .. } => EditOpKind::Delete,
226            EditOp::Replace { .. } => EditOpKind::Replace,
227        },
228        anchor_kind: match op {
229            EditOp::Insert {
230                anchor: Anchor::AfterLine { .. },
231                ..
232            } => AnchorKind::AfterLine,
233            EditOp::Insert {
234                anchor: Anchor::BeforeLine { .. },
235                ..
236            } => AnchorKind::BeforeLine,
237            EditOp::Insert {
238                anchor: Anchor::AfterMatch { .. },
239                ..
240            } => AnchorKind::AfterMatch,
241            EditOp::Insert {
242                anchor: Anchor::BeforeMatch { .. },
243                ..
244            } => AnchorKind::BeforeMatch,
245            EditOp::Delete { .. } | EditOp::Replace { .. } => AnchorKind::Range,
246        },
247        lines_added,
248        lines_removed,
249        context_hash: blake3::hash(context.as_bytes()).to_hex().to_string(),
250        file_extension: file_extension.to_ascii_lowercase(),
251        scope_tag: infer_scope(&context_lines),
252        op_index: OpIndex(op_index),
253        file_index: FileIndex(file_index),
254    })
255}
256
257pub fn edit_op_node_id(signature: &EditOpSignature) -> String {
258    let mut hasher = blake3::Hasher::new();
259    hasher.update(CAUSE_HASH_VERSION);
260    hasher.update(signature.op_kind.as_str().as_bytes());
261    hasher.update(signature.anchor_kind.as_str().as_bytes());
262    hasher.update(&signature.lines_added.to_le_bytes());
263    hasher.update(&signature.lines_removed.to_le_bytes());
264    hasher.update(signature.context_hash.as_bytes());
265    hasher.update(signature.file_extension.as_bytes());
266    hasher.update(signature.scope_tag.as_str().as_bytes());
267    hasher.update(&signature.op_index.0.to_le_bytes());
268    hasher.update(&signature.file_index.0.to_le_bytes());
269    hasher.finalize().to_hex().to_string()
270}
271
272pub fn effect_node_id(signature: &EffectSignature) -> String {
273    let mut hasher = blake3::Hasher::new();
274    hasher.update(EFFECT_HASH_VERSION);
275    hasher.update(signature.check_kind.as_bytes());
276    hasher.update(signature.outcome.as_bytes());
277    hasher.update(signature.severity.as_bytes());
278    hasher.update(signature.message_class.as_bytes());
279    hasher.update(
280        &signature
281            .line_offset_from_edit
282            .unwrap_or(i32::MIN)
283            .to_le_bytes(),
284    );
285    hasher.finalize().to_hex().to_string()
286}
287
288pub fn global_pass_effect_signature(check_kind: &str) -> EffectSignature {
289    EffectSignature {
290        check_kind: check_kind.to_string(),
291        outcome: "pass".to_string(),
292        severity: "pass".to_string(),
293        message_class: "global_pass".to_string(),
294        line_offset_from_edit: None,
295    }
296}
297
298pub fn attribute_effects(
299    patch: &StructuredPatch,
300    check_result: &CheckResult,
301    line_map: &LineAttributionMap,
302    max_line_distance: u32,
303) -> Result<Vec<AttributionTriple>, CeaCoreError> {
304    let config = AttributionConfig {
305        max_line_distance,
306        ..AttributionConfig::default()
307    };
308    attribute_effects_with_config(patch, check_result, line_map, &config)
309}
310
311pub fn attribute_effects_with_config(
312    patch: &StructuredPatch,
313    check_result: &CheckResult,
314    line_map: &LineAttributionMap,
315    config: &AttributionConfig,
316) -> Result<Vec<AttributionTriple>, CeaCoreError> {
317    config.validate()?;
318
319    let line_map_index = build_line_map_index(line_map);
320    let changed_files = patch
321        .edits
322        .iter()
323        .map(|edit| edit.path.to_string_lossy().to_string())
324        .collect::<HashSet<_>>();
325    let causes = collect_cause_candidates(patch, &line_map_index, config)?;
326    let mut triples = Vec::new();
327
328    for effect in collect_effect_occurrences(check_result) {
329        let weighted = select_weighted_candidates(&causes, &changed_files, &effect, config);
330        triples.extend(weighted.into_iter().map(|candidate| AttributionTriple {
331            cause: candidate.cause,
332            effect: effect.signature.clone(),
333            distance: candidate.distance,
334            weight: candidate.score,
335        }));
336    }
337
338    triples.sort_by(canonical_triple_cmp);
339    Ok(triples)
340}
341
342pub fn compute_run_hash(result: &AttributedRunResult) -> String {
343    let mut hasher = blake3::Hasher::new();
344    hasher.update(RUN_HASH_VERSION);
345
346    for category in check_categories(&result.check_result) {
347        hasher.update(category.check_kind.as_bytes());
348        hasher.update(&[u8::from(category.passed)]);
349        let effect_count = u64::try_from(category.output.effects.len()).unwrap_or(u64::MAX);
350        hasher.update(&effect_count.to_le_bytes());
351    }
352
353    let mut effects = collect_effect_occurrences(&result.check_result);
354    effects.sort_by(canonical_effect_cmp);
355    for effect in effects {
356        hasher.update(effect_node_id(&effect.signature).as_bytes());
357        if let Some(file) = effect.file {
358            hasher.update(file.as_bytes());
359        }
360        hasher.update(&effect.line.unwrap_or(u32::MAX).to_le_bytes());
361    }
362
363    let mut canonical = result.triples.clone();
364    canonical.sort_by(canonical_triple_cmp);
365    for triple in canonical {
366        hasher.update(edit_op_node_id(&triple.cause).as_bytes());
367        hasher.update(effect_node_id(&triple.effect).as_bytes());
368        hasher.update(&triple.distance.to_le_bytes());
369        hasher.update(&weight_bucket(triple.weight).to_le_bytes());
370    }
371
372    hasher.finalize().to_hex().to_string()
373}
374
375pub fn attribution_score(distance: i32, severity: &str, decay_factor: f64) -> f64 {
376    attribution_score_with_confidence(distance, severity, decay_factor, 1.0)
377}
378
379pub fn attribution_score_with_confidence(
380    distance: i32,
381    severity: &str,
382    decay_factor: f64,
383    mapping_confidence: f64,
384) -> f64 {
385    let base = match severity {
386        "error" => 1.0,
387        "warning" => 0.75,
388        "test_fail" => 0.9,
389        "pass" => 0.2,
390        _ => 0.5,
391    };
392
393    let distance = distance.unsigned_abs() as f64;
394    let decay = 1.0 / (1.0 + (distance / decay_factor.max(f64::EPSILON)));
395    (base * decay * mapping_confidence.clamp(0.0, 1.0)).max(0.0)
396}
397
398fn collect_cause_candidates(
399    patch: &StructuredPatch,
400    line_map_index: &LineMapIndex,
401    config: &AttributionConfig,
402) -> Result<Vec<CauseCandidate>, CeaCoreError> {
403    let mut causes = Vec::new();
404
405    for (file_index, edit) in patch.edits.iter().enumerate() {
406        let extension = edit
407            .path
408            .extension()
409            .and_then(|value| value.to_str())
410            .unwrap_or("");
411        let file_key = edit.path.to_string_lossy().to_string();
412
413        for (op_index, op) in edit.ops.iter().enumerate() {
414            let signature = build_edit_op_signature(
415                op,
416                op_index,
417                edit.ops.len(),
418                file_index,
419                patch.edits.len(),
420                extension,
421            )?;
422            let (line, mapping_confidence) =
423                op_line_position(op, &file_key, op_index, line_map_index, config);
424
425            causes.push(CauseCandidate {
426                signature,
427                file: file_key.clone(),
428                line,
429                mapping_confidence,
430            });
431        }
432    }
433
434    Ok(causes)
435}
436
437fn op_line_position(
438    op: &EditOp,
439    file_key: &str,
440    op_index: usize,
441    line_map_index: &LineMapIndex,
442    config: &AttributionConfig,
443) -> (Option<u32>, f64) {
444    match op {
445        EditOp::Insert { anchor, .. } => match anchor {
446            Anchor::AfterLine { line, .. } => {
447                let mapped = line_map_index.lookup_mapped_line(file_key, *line);
448                (
449                    Some(mapped.unwrap_or(*line).saturating_add(1)),
450                    if mapped.is_some() {
451                        1.0
452                    } else {
453                        config.missing_line_confidence
454                    },
455                )
456            }
457            Anchor::BeforeLine { line, .. } => {
458                let mapped = line_map_index.lookup_mapped_line(file_key, *line);
459                (
460                    Some(mapped.unwrap_or(*line)),
461                    if mapped.is_some() {
462                        1.0
463                    } else {
464                        config.missing_line_confidence
465                    },
466                )
467            }
468            Anchor::AfterMatch { .. } | Anchor::BeforeMatch { .. } => {
469                let resolved = line_map_index.resolved_anchor(file_key, op_index);
470                (
471                    resolved,
472                    if resolved.is_some() {
473                        1.0
474                    } else {
475                        config.missing_line_confidence
476                    },
477                )
478            }
479        },
480        EditOp::Delete { range } | EditOp::Replace { range, .. } => {
481            let mapped = line_map_index.lookup_mapped_line(file_key, range.start);
482            (
483                Some(mapped.unwrap_or(range.start)),
484                if mapped.is_some() {
485                    1.0
486                } else {
487                    config.missing_line_confidence
488                },
489            )
490        }
491    }
492}
493
494fn select_weighted_candidates(
495    causes: &[CauseCandidate],
496    changed_files: &HashSet<String>,
497    effect: &EffectOccurrence,
498    config: &AttributionConfig,
499) -> Vec<WeightedCandidate> {
500    let candidate_radius = config
501        .max_line_distance
502        .saturating_mul(config.candidate_radius_multiplier);
503    let mut candidates = causes
504        .iter()
505        .filter_map(|cause| {
506            if let Some(effect_file) = effect.file.as_deref() {
507                if !changed_files.contains(effect_file) || cause.file != effect_file {
508                    return None;
509                }
510            }
511
512            let distance = candidate_distance(effect, cause, config.max_line_distance);
513            if distance > candidate_radius as i32 {
514                return None;
515            }
516
517            let score = attribution_score_with_confidence(
518                distance,
519                &effect.signature.severity,
520                config.distance_decay,
521                cause.mapping_confidence,
522            );
523
524            Some(WeightedCandidate {
525                cause: cause.signature.clone(),
526                distance,
527                score,
528            })
529        })
530        .collect::<Vec<_>>();
531
532    candidates.sort_by(|left, right| {
533        right
534            .score
535            .partial_cmp(&left.score)
536            .unwrap_or(Ordering::Equal)
537            .then_with(|| edit_op_node_id(&left.cause).cmp(&edit_op_node_id(&right.cause)))
538            .then_with(|| left.distance.cmp(&right.distance))
539    });
540    candidates.truncate(config.top_k_causes);
541
542    normalize_candidates(&mut candidates, config.softmax_temperature);
543    candidates
544}
545
546fn normalize_candidates(candidates: &mut [WeightedCandidate], temperature: f64) {
547    if candidates.is_empty() {
548        return;
549    }
550
551    let logits = candidates
552        .iter()
553        .map(|candidate| candidate.score / temperature)
554        .collect::<Vec<_>>();
555    let max_logit = logits.iter().copied().fold(f64::NEG_INFINITY, f64::max);
556    let mut partition = 0.0;
557    let mut weights = Vec::with_capacity(logits.len());
558    for logit in logits {
559        let value = if logit.is_finite() {
560            (logit - max_logit).exp()
561        } else {
562            0.0
563        };
564        partition += value;
565        weights.push(value);
566    }
567
568    if partition <= f64::EPSILON {
569        let uniform = 1.0 / candidates.len() as f64;
570        for candidate in candidates {
571            candidate.score = uniform;
572        }
573        return;
574    }
575
576    for (candidate, weight) in candidates.iter_mut().zip(weights) {
577        candidate.score = weight / partition;
578    }
579}
580
581fn candidate_distance(
582    effect: &EffectOccurrence,
583    cause: &CauseCandidate,
584    max_line_distance: u32,
585) -> i32 {
586    match (effect.line, cause.line) {
587        (Some(effect_line), Some(cause_line)) => (effect_line as i32 - cause_line as i32).abs(),
588        (Some(_), None) | (None, Some(_)) => max_line_distance as i32,
589        (None, None) => 0,
590    }
591}
592
593fn collect_effect_occurrences(check_result: &CheckResult) -> Vec<EffectOccurrence> {
594    let mut effects = check_categories(check_result)
595        .into_iter()
596        .flat_map(|category| {
597            category
598                .output
599                .effects
600                .iter()
601                .map(|effect| EffectOccurrence {
602                    signature: effect.sig.clone(),
603                    file: effect
604                        .file
605                        .as_ref()
606                        .map(|path| path.to_string_lossy().to_string()),
607                    line: effect.line,
608                })
609        })
610        .collect::<Vec<_>>();
611
612    for category in check_categories(check_result) {
613        if category.passed && category.output.effects.is_empty() {
614            effects.push(EffectOccurrence {
615                signature: global_pass_effect_signature(category.check_kind),
616                file: None,
617                line: None,
618            });
619        }
620    }
621
622    effects
623}
624
625struct CheckCategory<'a> {
626    check_kind: &'static str,
627    passed: bool,
628    output: &'a ParsedCheckOutput,
629}
630
631fn check_categories(check_result: &CheckResult) -> [CheckCategory<'_>; 3] {
632    [
633        CheckCategory {
634            check_kind: "fmt",
635            passed: check_result.fmt_pass,
636            output: &check_result.fmt_output,
637        },
638        CheckCategory {
639            check_kind: "clippy",
640            passed: check_result.clippy_pass,
641            output: &check_result.clippy_output,
642        },
643        CheckCategory {
644            check_kind: "test",
645            passed: check_result.test_pass,
646            output: &check_result.test_output,
647        },
648    ]
649}
650
651fn canonical_effect_cmp(left: &EffectOccurrence, right: &EffectOccurrence) -> Ordering {
652    effect_node_id(&left.signature)
653        .cmp(&effect_node_id(&right.signature))
654        .then_with(|| left.file.cmp(&right.file))
655        .then_with(|| left.line.cmp(&right.line))
656}
657
658fn canonical_triple_cmp(left: &AttributionTriple, right: &AttributionTriple) -> Ordering {
659    edit_op_node_id(&left.cause)
660        .cmp(&edit_op_node_id(&right.cause))
661        .then_with(|| effect_node_id(&left.effect).cmp(&effect_node_id(&right.effect)))
662        .then_with(|| left.distance.cmp(&right.distance))
663        .then_with(|| weight_bucket(left.weight).cmp(&weight_bucket(right.weight)))
664}
665
666fn weight_bucket(weight: f64) -> u64 {
667    (weight.clamp(0.0, 1.0) * 1_000_000_000.0).round() as u64
668}