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 pub cause: EditOpSignature,
19 pub effect: EffectSignature,
21 pub distance: i32,
23 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 pub max_line_distance: u32,
50 pub candidate_radius_multiplier: u32,
52 pub top_k_causes: usize,
54 pub softmax_temperature: f64,
56 pub distance_decay: f64,
58 pub missing_line_confidence: f64,
60 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}