Skip to main content

extension_impact/
extension_impact.rs

1//! Offline replay of native impact answers with a fixed extension candidate cohort.
2
3use std::fs;
4use std::io::{self, Write};
5use std::path::Path;
6
7use devscout_rs::{graph, query};
8use serde::Deserialize;
9use serde_json::{json, Value};
10
11#[derive(Deserialize)]
12struct Spec {
13    candidates: Vec<usize>,
14    seeds: Vec<String>,
15}
16
17fn admit(index: &mut query::GraphIndex<'_>, numbers: &[usize]) -> Result<(), String> {
18    for &number in numbers {
19        let Some(graph::Edge::UsesMember {
20            to,
21            heuristic: true,
22            tier: Some(graph::HeuristicTier::Ext),
23            ..
24        }) = index.graph.edges.get(number)
25        else {
26            return Err(format!("candidate {number} is not extension evidence"));
27        };
28        let entry = index
29            .heuristic_inbound
30            .get_mut(to)
31            .ok_or_else(|| format!("candidate {number} has no heuristic adjacency"))?;
32        let position = entry
33            .uses_member
34            .iter()
35            .position(|&value| value == number)
36            .ok_or_else(|| format!("candidate {number} is missing or duplicated"))?;
37        entry.uses_member.remove(position);
38        index
39            .inbound
40            .entry(to.clone())
41            .or_default()
42            .uses_member
43            .push(number);
44    }
45    Ok(())
46}
47
48fn answer(index: &query::GraphIndex<'_>, seed: &str, cap: usize) -> Result<Value, String> {
49    let query::ImpactResult::Resolved(model) = query::build_impact_model(
50        index,
51        seed,
52        query::DEFAULT_HOPS,
53        cap,
54        true,
55        query::DEFAULT_IFACE_MAX_FANIN,
56        query::DEFAULT_HUB_MAX_INDEGREE,
57    ) else {
58        return Err(format!("seed is not uniquely resolved: {seed}"));
59    };
60    Ok(json!({
61        "rows": model.rows.iter().map(|row| json!({
62            "file": row.file, "hop": row.hop, "heuristic": row.heuristic,
63            "why": row.why.as_str(), "from_lines": row.from_lines,
64            "symbols": row.top_symbols, "infra": row.infra,
65        })).collect::<Vec<_>>(),
66        "dropped": model.dropped,
67        "total_affected": model.total_affected,
68        "heuristic_affected": model.heuristic_affected,
69        "tests_affected": model.tests_affected,
70        "braked_interfaces": model.braked.iter().map(|b| json!({
71            "iface": b.iface, "fanin": b.fanin,
72        })).collect::<Vec<_>>(),
73        "braked_files": model.braked_files.iter().map(|b| json!({
74            "file": b.file, "indegree": b.indegree,
75        })).collect::<Vec<_>>(),
76    }))
77}
78
79fn truth(index: &query::GraphIndex<'_>, seed: &str) -> Result<Value, String> {
80    let query::SeedResolution::Resolved { ids, .. } = query::resolve_impact_seed(index, seed)
81    else {
82        return Err(format!("oracle seed is not uniquely resolved: {seed}"));
83    };
84    // Oracle truncation would turn valid predictions beyond a brake into false positives.
85    let walk = query::impact_walk(index, &ids, query::DEFAULT_HOPS, true, 0, 0);
86    Ok(json!({"rows": walk.visited.iter()
87        .filter(|(_, row)| row.via_count > 0 || row.ambiguous_count > 0)
88        .map(|(file, row)| json!({"file": file, "hop": row.hop, "heuristic": false}))
89        .collect::<Vec<_>>() }))
90}
91
92fn run(directory: &Path) -> Result<(), Box<dyn std::error::Error>> {
93    let baseline: graph::Graph =
94        serde_json::from_slice(&fs::read(directory.join("baseline.json"))?)?;
95    let oracle: graph::Graph = serde_json::from_slice(&fs::read(directory.join("oracle.json"))?)?;
96    let spec: Spec = serde_json::from_slice(&fs::read(directory.join("spec.json"))?)?;
97    let root = directory.join("unmapped");
98    let baseline_index = query::load_graph_index(&baseline, &root);
99    let mut candidate_index = query::load_graph_index(&baseline, &root);
100    let oracle_index = query::load_graph_index(&oracle, &root);
101    admit(&mut candidate_index, &spec.candidates)?;
102    let stdout = io::stdout();
103    let mut out = io::BufWriter::new(stdout.lock());
104    for seed in spec.seeds {
105        let record = json!({
106            "seed": seed,
107            "baseline": answer(&baseline_index, &seed, query::DEFAULT_CAP)?,
108            "candidate": answer(&candidate_index, &seed, query::DEFAULT_CAP)?,
109            "oracle": truth(&oracle_index, &seed)?,
110        });
111        serde_json::to_writer(&mut out, &record)?;
112        writeln!(out)?;
113    }
114    Ok(())
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    fn fixture() -> graph::Graph {
122        serde_json::from_value(json!({
123            "schema_version": 3, "built_at_head": null,
124            "defs": (["Alpha", "Beta", "Gamma", "Delta", "Epsilon"].iter().map(|name| json!({
125                "id": name, "name": name, "namespace": "", "kind": "class",
126                "file": format!("{name}.cs"), "line": 1, "methods": ["Run"],
127            })).collect::<Vec<_>>()),
128            "edges": [
129                graph::Edge::uses_member("Beta.cs".into(), 7, "Alpha".into(), "Alpha.cs".into(),
130                    Some("Run".into()), Some(graph::HeuristicTier::Ext)),
131                graph::Edge::uses_member("Gamma.cs".into(), 7, "Beta".into(), "Beta.cs".into(),
132                    Some("Run".into()), Some(graph::HeuristicTier::Ext)),
133                graph::Edge::uses_member("Delta.cs".into(), 7, "Gamma".into(), "Gamma.cs".into(),
134                    Some("Run".into()), Some(graph::HeuristicTier::Guess)),
135                graph::Edge::uses_member("Epsilon.cs".into(), 7, "Delta".into(), "Delta.cs".into(),
136                    Some("Run".into()), None),
137            ],
138            "stats": {"def_count": 5, "file_count": 5, "edges_by_kind": graph::EdgesByKind::default(),
139                "ambiguous_count": 0, "ambiguous_pct": 0.0, "unresolved_external_count": 0},
140        })).unwrap()
141    }
142
143    #[test]
144    fn two_extension_hops_add_a_file_without_changing_graph_evidence() {
145        let graph = fixture();
146        let before = graph.clone();
147        let mut index = query::load_graph_index(&graph, Path::new("unmapped"));
148        assert_eq!(
149            answer(&index, "Alpha.cs", 50).unwrap()["rows"]
150                .as_array()
151                .unwrap()
152                .len(),
153            1
154        );
155        admit(&mut index, &[0, 1]).unwrap();
156        let result = answer(&index, "Alpha.cs", 50).unwrap();
157        let rows = result["rows"].as_array().unwrap();
158        assert!(rows
159            .iter()
160            .any(|row| row["file"] == "Gamma.cs" && row["hop"] == 2));
161        assert!(!rows.iter().any(|row| row["file"] == "Delta.cs"));
162        assert_eq!(graph, before);
163    }
164
165    #[test]
166    fn guess_cannot_be_admitted_or_used_to_reach_its_caller() {
167        let graph = fixture();
168        let mut index = query::load_graph_index(&graph, Path::new("unmapped"));
169        assert!(admit(&mut index, &[2]).is_err());
170        let result = answer(&index, "Gamma.cs", 50).unwrap();
171        let rows = result["rows"].as_array().unwrap();
172        assert_eq!(rows.len(), 1);
173        assert_eq!(rows[0]["file"], "Delta.cs");
174        assert_eq!(rows[0]["heuristic"], true);
175    }
176
177    #[test]
178    fn oracle_truth_is_not_truncated_by_a_numerical_hub_brake() {
179        let mut graph = fixture();
180        graph.edges[0] = graph::Edge::uses_member(
181            "Beta.cs".into(),
182            7,
183            "Alpha".into(),
184            "Alpha.cs".into(),
185            Some("Run".into()),
186            None,
187        );
188        graph.edges[1] = graph::Edge::uses_member(
189            "Gamma.cs".into(),
190            7,
191            "Beta".into(),
192            "Beta.cs".into(),
193            Some("Run".into()),
194            None,
195        );
196        for number in 0..35 {
197            let mut definition = graph.defs[0].clone();
198            definition.id = format!("Caller{number}");
199            definition.name = definition.id.clone();
200            definition.file = format!("Caller{number}.cs");
201            graph.edges.push(graph::Edge::uses_member(
202                definition.file.clone(),
203                7,
204                "Beta".into(),
205                "Beta.cs".into(),
206                Some("Run".into()),
207                None,
208            ));
209            graph.defs.push(definition);
210        }
211        let index = query::load_graph_index(&graph, Path::new("unmapped"));
212        let predicted = answer(&index, "Alpha.cs", 50).unwrap();
213        let expected = truth(&index, "Alpha.cs").unwrap();
214        assert!(!predicted["rows"]
215            .as_array()
216            .unwrap()
217            .iter()
218            .any(|r| r["file"] == "Gamma.cs"));
219        assert!(expected["rows"]
220            .as_array()
221            .unwrap()
222            .iter()
223            .any(|r| r["file"] == "Gamma.cs"));
224    }
225
226    #[test]
227    fn an_empty_cohort_restores_the_baseline_answer() {
228        let graph = fixture();
229        let baseline = query::load_graph_index(&graph, Path::new("unmapped"));
230        let mut suppressed = query::load_graph_index(&graph, Path::new("unmapped"));
231        admit(&mut suppressed, &[]).unwrap();
232        assert_eq!(
233            answer(&baseline, "Alpha.cs", 50),
234            answer(&suppressed, "Alpha.cs", 50)
235        );
236    }
237}
238
239fn main() -> Result<(), Box<dyn std::error::Error>> {
240    let directory = std::env::args_os()
241        .nth(1)
242        .ok_or("usage: extension_impact <replay-directory>")?;
243    run(Path::new(&directory))
244}