Skip to main content

candor_classify/
surface.rs

1//! The SURPRISE heuristic — the single (or top-N) most surprising transitive reach in a crate (the
2//! cold-repo hook). SHARED so candor-scan's scan-time note and candor-query's `tour` verb CANNOT
3//! drift: this crate hosts the one implementation, both callers delegate here.
4//!
5//! Fully deterministic — pure call-graph + name analysis, NO LLM. A CANDIDATE is a function `F` that
6//! INHERITS an effect `E` (E ∈ inferred[F] but E ∉ direct[F]); we BFS to the nearest local direct SOURCE
7//! `S` and score by how surprising the reach is (a benign-named function reaching a scary effect). The
8//! find is never *wrong*: `candor path` re-derives the chain and the gate is ground truth. When nothing
9//! clears the bar the caller emits an honest "nothing hidden" fallback — never a manufactured surprise.
10//!
11//! Generic over the effect element type (`E: AsRef<str>`), so BOTH candor-scan's
12//! `BTreeSet<&'static str>` maps AND candor-query's `BTreeSet<String>` maps drive the SAME code.
13
14use std::collections::{BTreeSet, HashMap, VecDeque};
15
16/// Name tokens that read as local / pure / config — a function whose leaf is named like this reaching a
17/// scary effect is the core surprise signal.
18const BENIGN: &[&str] = &[
19    "settings", "config", "conf", "options", "opts", "util", "utils", "helper", "helpers", "model",
20    "models", "dto", "entity", "format", "fmt", "parse", "get", "load", "new", "default", "validate",
21    "valid", "render", "view", "build", "builder", "item", "entry", "record", "state", "context",
22    "ctx", "info", "meta", "data", "value", "node", "field", "name", "key", "id", "path", "kind",
23    "type", "status", "check", "init", "setup",
24];
25
26/// Name tokens that are effect-suggestive — a function in/near an effect-flavored context reaching that
27/// effect is EXPECTED, not surprising, so we EXCLUDE it.
28const EFFECTY: &[&str] = &[
29    "fetch", "http", "https", "client", "api", "sync", "request", "req", "download", "upload", "query",
30    "sql", "store", "save", "persist", "connect", "conn", "socket", "send", "recv", "read", "write",
31    "open", "file", "fs", "io", "net", "tcp", "udp", "dns", "url", "host", "port", "cmd", "command",
32    "shell", "process", "proc", "exec", "spawn", "env", "clock", "time", "now", "rand", "random",
33    "log", "logger", "trace", "db",
34];
35
36/// Split a qualified name (or a leaf) into lowercase tokens on `_`, `::` and camelCase boundaries.
37fn tokenize(name: &str) -> Vec<String> {
38    let mut out = Vec::new();
39    let mut cur = String::new();
40    let mut prev_lower = false;
41    for ch in name.chars() {
42        if ch == '_' || ch == ':' {
43            if !cur.is_empty() {
44                out.push(std::mem::take(&mut cur));
45            }
46            prev_lower = false;
47            continue;
48        }
49        // camelCase boundary: a lower/digit followed by an upper starts a new token.
50        if ch.is_uppercase() && prev_lower && !cur.is_empty() {
51            out.push(std::mem::take(&mut cur));
52        }
53        cur.push(ch.to_ascii_lowercase());
54        prev_lower = ch.is_lowercase() || ch.is_ascii_digit();
55    }
56    if !cur.is_empty() {
57        out.push(cur);
58    }
59    out
60}
61
62/// The leaf (final `::` segment) of a qualified name.
63fn leaf(qual: &str) -> &str {
64    qual.rsplit("::").next().unwrap_or(qual)
65}
66
67/// The module portion of a qualified name (everything before the leaf).
68fn module_of(qual: &str) -> &str {
69    match qual.rfind("::") {
70        Some(i) => &qual[..i],
71        None => "",
72    }
73}
74
75fn has_token(name: &str, lexicon: &[&str]) -> Option<String> {
76    tokenize(name).into_iter().find(|t| lexicon.contains(&t.as_str()))
77}
78
79/// Salience of an effect — only the boundary/security-relevant effects a reviewer cares about are ever
80/// surfaced as "surprising": Net/Exec/Db/Llm/Ipc (sharpest), then Fs/Env (medium). Clock/Log/Rand — and anything
81/// else, including Unknown — score 0, so they're NEVER the opener. A "settings" method that reads the clock
82/// or logs isn't a find a reviewer files away; calling it "the most surprising reach" over-promises. A repo
83/// whose only reaches are mundane honestly reports "nothing hidden" instead (corpus-dogfood refinement).
84fn salience(effect: &str) -> i64 {
85    match effect {
86        "Net" | "Exec" | "Db" | "Llm" | "Ipc" => 5,
87        "Fs" | "Env" => 3,
88        _ => 0,
89    }
90}
91
92fn hops_factor(hops: usize) -> i64 {
93    match hops {
94        1 => 2,
95        2..=4 => 3,
96        5..=6 => 2,
97        _ => 1, // ≥7 (hops is always ≥1 for an inherited reach)
98    }
99}
100
101/// A scored candidate reach. Generic over the caller's effect-string type via owned `String` fields, so
102/// the SAME struct serves candor-scan (`&'static str` maps) and candor-query (`String` maps).
103pub struct Find {
104    pub func: String,
105    pub effect: String,
106    pub hops: usize,
107    pub source: String,
108    /// "file:line" of the effect SOURCE, resolved from the caller's `loc` map ("" when absent).
109    pub source_loc: String,
110    /// The benign leaf token that made the reach surprising ("" when the leaf isn't benign-named).
111    pub benign_token: String,
112    pub score: i64,
113}
114
115/// Test code: excluded from surfacing. A function is test code when any MODULE segment (every `::`-segment
116/// EXCEPT the final leaf) is, case-insensitively, `test`/`tests` (a test module), or — original case — ends
117/// with `Test`/`Tests` (an XCTest-style `*Tests` type). Never based on the leaf, so a production
118/// `Foo::test_connection` (leaf `test_connection`) is KEPT; a top-level `tests::helper` or a nested
119/// `foo::tests::helper` is excluded. Matches the java/swift ports' segment rule (TS uses a path predicate).
120fn is_test(qual: &str) -> bool {
121    let mut segs: Vec<&str> = qual.split("::").collect();
122    segs.pop(); // the final segment is the leaf — never the exclusion basis
123    segs.iter().any(|s| {
124        let l = s.to_ascii_lowercase();
125        l == "test" || l == "tests" || s.ends_with("Test") || s.ends_with("Tests")
126    })
127}
128
129/// Does a set contain the effect `e`? Membership via `AsRef<str>` so it works over both
130/// `BTreeSet<&'static str>` and `BTreeSet<String>`.
131fn set_has<E: AsRef<str>>(set: &BTreeSet<E>, e: &str) -> bool {
132    set.iter().any(|x| x.as_ref() == e)
133}
134
135/// BFS from `func` over `calls` (follow callees, shortest hops) to the nearest function `S` with
136/// `effect` ∈ direct[S]. Returns (hops≥1, S). Only traverses through callees that transitively carry
137/// the effect, so the frontier stays on-effect (matches `candor path`'s walk).
138fn nearest_source<'a, E: AsRef<str>>(
139    func: &'a str,
140    effect: &str,
141    direct: &'a HashMap<String, BTreeSet<E>>,
142    inferred: &'a HashMap<String, BTreeSet<E>>,
143    calls: &'a HashMap<String, BTreeSet<String>>,
144) -> Option<(usize, &'a str)> {
145    let mut seen: BTreeSet<&str> = BTreeSet::new();
146    let mut q: VecDeque<(&str, usize)> = VecDeque::new();
147    seen.insert(func);
148    q.push_back((func, 0));
149    while let Some((cur, d)) = q.pop_front() {
150        // A direct source found at distance d≥1 is the nearest (BFS). The start `func` itself is an
151        // INHERITED reach (E ∉ direct[func]) so it never matches at d==0.
152        if d >= 1 && direct.get(cur).is_some_and(|s| set_has(s, effect)) {
153            return Some((d, cur));
154        }
155        if let Some(cs) = calls.get(cur) {
156            for c in cs {
157                let cc = c.as_str();
158                if !seen.contains(cc) && inferred.get(cc).is_some_and(|s| set_has(s, effect)) {
159                    seen.insert(cc);
160                    q.push_back((cc, d + 1));
161                }
162            }
163        }
164    }
165    None
166}
167
168/// Compute the top-`top_n` most surprising reaches, most-surprising first. DEDUPED by function — each
169/// function appears at most once (its single highest-scoring reach). The list is empty when nothing
170/// clears the bar (the caller decides whether to emit the honest fallback vs nothing, using
171/// [`any_effectful`]).
172///
173/// Ranking (the tie-break, applied to the whole candidate pool before the per-function dedup + take):
174/// score DESC → hops ASC → qualified name ASC. With `top_n == 1` the result is BYTE-IDENTICAL to the old
175/// scan-time `best_find` (conformance PART 4f + the candor-scan tests pin this).
176pub fn best_finds<E: AsRef<str>>(
177    inferred: &HashMap<String, BTreeSet<E>>,
178    direct: &HashMap<String, BTreeSet<E>>,
179    calls: &HashMap<String, BTreeSet<String>>,
180    loc: &HashMap<String, String>,
181    top_n: usize,
182) -> Vec<Find> {
183    // Deterministic iteration: sort quals ascending so the tie-break (qual ascending) is stable and
184    // HashMap order never leaks into the result.
185    let mut quals: Vec<&String> = inferred.keys().collect();
186    quals.sort();
187
188    let mut cands: Vec<Find> = Vec::new();
189
190    for f in quals {
191        let inf = &inferred[f];
192        if is_test(f) {
193            continue;
194        }
195        let f_leaf = leaf(f);
196        let f_mod = module_of(f);
197        // EXCLUDE the whole function if its leaf OR module reads effecty — its reach is obvious.
198        if has_token(f_leaf, EFFECTY).is_some() || has_token(f_mod, EFFECTY).is_some() {
199            continue;
200        }
201        let empty = BTreeSet::new();
202        let dir = direct.get(f).unwrap_or(&empty);
203        // Candidate effects: inherited (in inferred, not direct), not Unknown. Sorted ascending so the
204        // per-function winner is deterministic.
205        let mut effects: Vec<String> = inf
206            .iter()
207            .map(|e| e.as_ref().to_string())
208            .filter(|e| e != "Unknown" && !set_has(dir, e))
209            .collect();
210        effects.sort();
211        for e in effects {
212            let sal = salience(&e);
213            if sal == 0 {
214                continue;
215            }
216            let Some((hops, s)) = nearest_source(f, &e, direct, inferred, calls) else {
217                continue; // no LOCAL direct source — nothing to show
218            };
219            let benign = has_token(f_leaf, BENIGN);
220            let benignity = if benign.is_some() { 3 } else { 1 };
221            let crossing = if module_of(s) != f_mod { 2 } else { 1 };
222            let score = sal * benignity * hops_factor(hops) * crossing;
223            if score == 0 {
224                continue;
225            }
226            cands.push(Find {
227                func: f.clone(),
228                effect: e,
229                hops,
230                source: s.to_string(),
231                source_loc: loc.get(s).cloned().unwrap_or_default(),
232                benign_token: benign.unwrap_or_default(),
233                score,
234            });
235        }
236    }
237
238    // Rank the whole pool: score DESC, hops ASC, qual ASC. Quals were iterated ascending and effects
239    // ascending, so on a full tie the first-pushed (smallest qual) candidate sorts first — matching the
240    // old `best_find`'s "keep the earliest winner on an exact tie".
241    cands.sort_by(|a, b| {
242        b.score
243            .cmp(&a.score)
244            .then_with(|| a.hops.cmp(&b.hops))
245            .then_with(|| a.func.cmp(&b.func))
246    });
247
248    // DEDUP by function — each function appears at most once (its single highest-scoring reach, which is
249    // the first one seen in the ranked order). Then take up to `top_n` distinct functions.
250    let mut seen_fns: BTreeSet<String> = BTreeSet::new();
251    let mut out: Vec<Find> = Vec::new();
252    for c in cands {
253        if out.len() >= top_n {
254            break;
255        }
256        if seen_fns.insert(c.func.clone()) {
257            out.push(c);
258        }
259    }
260    out
261}
262
263/// Is the crate EFFECTFUL — does ANY function carry a real (non-Unknown) effect? Governs whether the
264/// caller emits the honest "nothing hidden" fallback (effectful, but nothing clears the bar) vs nothing
265/// at all (a genuinely effect-free crate).
266pub fn any_effectful<E: AsRef<str>>(inferred: &HashMap<String, BTreeSet<E>>) -> bool {
267    inferred.values().any(|s| s.iter().any(|e| e.as_ref() != "Unknown"))
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    fn set(items: &[&'static str]) -> BTreeSet<&'static str> {
275        items.iter().copied().collect()
276    }
277    fn sset(items: &[&str]) -> BTreeSet<String> {
278        items.iter().map(|s| s.to_string()).collect()
279    }
280
281    #[test]
282    fn tokenize_splits_all_boundaries() {
283        assert_eq!(tokenize("settings::Settings::needsUpdate"), vec!["settings", "settings", "needs", "update"]);
284        assert_eq!(tokenize("api_client::latest_version"), vec!["api", "client", "latest", "version"]);
285    }
286
287    #[test]
288    fn benign_deep_inherited_beats_shallow_effecty() {
289        // Graph:
290        //   settings::Settings::load  (benign leaf "load")  -inherits-> Net, 3 hops
291        //     -> core::refresh -> core::sync_state -> net_layer::do_send (direct Net)
292        //   api::fetch  (effecty leaf "fetch") -inherits-> Net, 1 hop  (EXCLUDED — effecty)
293        //     -> net_layer::do_send
294        let mut direct: HashMap<String, BTreeSet<&'static str>> = HashMap::new();
295        let mut inferred: HashMap<String, BTreeSet<&'static str>> = HashMap::new();
296        let mut calls: HashMap<String, BTreeSet<String>> = HashMap::new();
297        let loc: HashMap<String, String> = HashMap::new();
298
299        direct.insert("net_layer::do_send".into(), set(&["Net"]));
300        inferred.insert("net_layer::do_send".into(), set(&["Net"]));
301
302        inferred.insert("core::sync_state".into(), set(&["Net"]));
303        calls.insert("core::sync_state".into(), sset(&["net_layer::do_send"]));
304
305        inferred.insert("core::refresh".into(), set(&["Net"]));
306        calls.insert("core::refresh".into(), sset(&["core::sync_state"]));
307
308        // benign candidate: settings::Settings::load, 3 hops to source.
309        inferred.insert("settings::Settings::load".into(), set(&["Net"]));
310        calls.insert("settings::Settings::load".into(), sset(&["core::refresh"]));
311
312        // effecty candidate: api::fetch, 1 hop — must be excluded by the EFFECTY leaf/module.
313        inferred.insert("api::fetch".into(), set(&["Net"]));
314        calls.insert("api::fetch".into(), sset(&["net_layer::do_send"]));
315
316        assert!(any_effectful(&inferred));
317        let got = best_finds(&inferred, &direct, &calls, &loc, 1);
318        assert_eq!(got.len(), 1);
319        assert_eq!(got[0].func, "settings::Settings::load");
320        assert_eq!(got[0].effect, "Net");
321        assert_eq!(got[0].hops, 3);
322        assert_eq!(got[0].source, "net_layer::do_send");
323        assert_eq!(got[0].benign_token, "load");
324    }
325
326    #[test]
327    fn dedup_one_row_per_function_top_n() {
328        // Two distinct benign candidates reach Net at different depths; the top-N lists each ONCE,
329        // ranked, and top_n caps the list. Uses String maps (the candor-query element type) to exercise
330        // the generic path. (The `sync_state` intermediary is EFFECTY-named — `sync` — so it's excluded,
331        // making the surprising set exactly {settings::Settings::load, model::render}.)
332        let mut direct: HashMap<String, BTreeSet<String>> = HashMap::new();
333        let mut inferred: HashMap<String, BTreeSet<String>> = HashMap::new();
334        let mut calls: HashMap<String, BTreeSet<String>> = HashMap::new();
335        let loc: HashMap<String, String> = HashMap::new();
336
337        direct.insert("net_layer::do_send".into(), sset(&["Net"]));
338        inferred.insert("net_layer::do_send".into(), sset(&["Net"]));
339
340        // settings::Settings::load — 3 hops (benign, deep → higher score). All intermediaries are
341        // EFFECTY-named (`sync_state` → sync, `download_step` → download) so they don't add rows.
342        inferred.insert("core::sync_state".into(), sset(&["Net"]));
343        calls.insert("core::sync_state".into(), sset(&["net_layer::do_send"]));
344        inferred.insert("core::download_step".into(), sset(&["Net"]));
345        calls.insert("core::download_step".into(), sset(&["core::sync_state"]));
346        inferred.insert("settings::Settings::load".into(), sset(&["Net"]));
347        calls.insert("settings::Settings::load".into(), sset(&["core::download_step"]));
348
349        // model::render — 1 hop (benign, shallow → lower score).
350        inferred.insert("model::render".into(), sset(&["Net"]));
351        calls.insert("model::render".into(), sset(&["net_layer::do_send"]));
352
353        let got = best_finds(&inferred, &direct, &calls, &loc, 10);
354        assert_eq!(got.len(), 2, "two distinct benign functions, one row each");
355        assert_eq!(got[0].func, "settings::Settings::load"); // deeper reach ranks first
356        assert_eq!(got[1].func, "model::render");
357        // top_n caps the list.
358        assert_eq!(best_finds(&inferred, &direct, &calls, &loc, 1).len(), 1);
359        // and no function is listed twice.
360        assert_eq!(got.iter().map(|f| &f.func).collect::<BTreeSet<_>>().len(), got.len());
361    }
362
363    #[test]
364    fn fallback_when_nothing_qualifies() {
365        // One effectful function, but it is a DIRECT source (not inherited) AND effecty-named — no
366        // candidate qualifies → an empty list, yet the crate IS effectful (caller emits the fallback).
367        let mut direct: HashMap<String, BTreeSet<&'static str>> = HashMap::new();
368        let mut inferred: HashMap<String, BTreeSet<&'static str>> = HashMap::new();
369        let calls: HashMap<String, BTreeSet<String>> = HashMap::new();
370        let loc: HashMap<String, String> = HashMap::new();
371        direct.insert("net::client::send".into(), set(&["Net"]));
372        inferred.insert("net::client::send".into(), set(&["Net"]));
373
374        assert!(best_finds(&inferred, &direct, &calls, &loc, 1).is_empty());
375        assert!(any_effectful(&inferred), "effectful, so the caller emits the honest fallback");
376    }
377
378    #[test]
379    fn nothing_when_no_effects() {
380        // No non-Unknown effect anywhere → empty list AND not effectful (caller emits nothing at all).
381        let direct: HashMap<String, BTreeSet<&'static str>> = HashMap::new();
382        let mut inferred: HashMap<String, BTreeSet<&'static str>> = HashMap::new();
383        let calls: HashMap<String, BTreeSet<String>> = HashMap::new();
384        let loc: HashMap<String, String> = HashMap::new();
385        inferred.insert("util::parse".into(), set(&["Unknown"]));
386        assert!(best_finds(&inferred, &direct, &calls, &loc, 1).is_empty());
387        assert!(!any_effectful(&inferred));
388    }
389}