Skip to content

Commit c67439f

Browse files
Your Nameclaude
andcommitted
feat(resolver): PR#6 target-aware ambiguity_groups membership
candidate_group_key was the bare callee name, so callers()/reference_impact's unresolved-group caveat matched by name alone -- an unrelated same-named symbol in another language/module (e.g. a Python `helper`) could inherit an overflow-candidate caveat that was never about it (e.g. from an unrelated Rust `helper` group). Same identity-collapse-to-a-scalar shape as the P0 formal-upgrade bug: the overflow branch already computed the real candidate set, then discarded everything but its length. Add ambiguity_group_candidates (group_id, candidate_qn, candidate_path), thread the real (qualified_name, path) candidate set through resolve_sites_to_edges instead of Some(t.len()), and switch both consumer queries to join + match agc.candidate_qn = c.qualified_name instead of the bare candidate_group_key. CASCADE off the parent row so the existing DELETE scopes (rebuild_graph, incremental_graph_update) clear members for free. New test ambiguity_group_membership_is_target_aware_not_bare_name proves the cross-language non-leak DoD case. callers_reports_unresolved_ambiguity_groups updated to insert a matching candidate row. Toolsnaps regenerated (UPDATE_TOOLSNAPS=1) for the two doc-comment-only description changes. Verified: 1306+400 tests green (calm-core + calm-server, full rebuild from clean target/), zero regressions. Plan doc updated with Part E execution-ready specs for PR#6-10 (docs/plans/2026-08-19-evidence-architecture-execution-plan.md). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent ec01252 commit c67439f

7 files changed

Lines changed: 406 additions & 38 deletions

File tree

crates/calm-core/src/db/schema.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,28 @@ CREATE TABLE IF NOT EXISTS ambiguity_groups (
305305
CREATE INDEX IF NOT EXISTS idx_ambiguity_groups_call_site ON ambiguity_groups(call_site_id);
306306
CREATE INDEX IF NOT EXISTS idx_ambiguity_groups_from_path ON ambiguity_groups(from_path);
307307
308+
-- PR#6 (target-aware ambiguity membership): the surviving candidate SET of an
309+
-- overflow group (too many same-named candidates to trust any single one) used
310+
-- to be DISCARDED -- only its length survived as ambiguity_groups.candidate_count,
311+
-- the same identity-collapse-to-a-scalar as the P0 formal-upgrade bug. Persisting
312+
-- the members lets callers()/reference_impact ask whether THIS symbol (by
313+
-- qualified_name) is one of the candidates, instead of matching any unrelated
314+
-- symbol that merely shares the bare name (a Python helper inheriting a Rust
315+
-- helper group's caveat). candidate_qn is exactly the resolver's target
316+
-- qualified_name -- same format as symbols.qualified_name / call_edges.to_symbol.
317+
-- CASCADE off ambiguity_groups(id) so the existing group DELETE scopes (full
318+
-- sweep in rebuild_graph, from_path-scoped in incremental_graph_update) clear
319+
-- members for free -- staleness structurally impossible, same lifecycle as parent.
320+
CREATE TABLE IF NOT EXISTS ambiguity_group_candidates (
321+
id INTEGER PRIMARY KEY AUTOINCREMENT,
322+
group_id INTEGER NOT NULL REFERENCES ambiguity_groups(id) ON DELETE CASCADE,
323+
candidate_qn TEXT NOT NULL,
324+
candidate_path TEXT,
325+
rank_hint INTEGER
326+
);
327+
CREATE INDEX IF NOT EXISTS idx_ambiguity_group_candidates_group ON ambiguity_group_candidates(group_id);
328+
CREATE INDEX IF NOT EXISTS idx_ambiguity_group_candidates_qn ON ambiguity_group_candidates(candidate_qn);
329+
308330
-- WS7 (evidence reconciliation): a provider (SCIP) proof that CONTRADICTS a
309331
-- confident static resolution of the SAME call site is a conflict, not a new
310332
-- target -- inserting it would manufacture a false-confidence edge (fixture I /

crates/calm-core/src/indexer/pipeline.rs

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1292,12 +1292,21 @@ fn resolve_via_inheritance_closure(
12921292
/// anywhere. `candidate_group_key` is the raw `callee_name` text (not a
12931293
/// resolved qualified name — by definition none of the candidates were
12941294
/// ever picked), so it's a grouping key for "sites stuck on this same
1295-
/// ambiguous name," not an identity.
1295+
/// ambiguous name," not an identity. `candidates` (PR#6) is the real
1296+
/// identity: the actual (qualified_name, path) surviving-candidate SET, so
1297+
/// `callers()`/`reference_impact` can match a queried symbol precisely
1298+
/// instead of via the bare `candidate_group_key` (which let an unrelated
1299+
/// same-named symbol in another language/module inherit this caveat).
12961300
struct AmbiguityGroup {
12971301
call_site_id: i64,
12981302
from_path: String,
12991303
candidate_group_key: String,
13001304
candidate_count: usize,
1305+
// PR#6: the surviving candidate SET (qualified_name, path) pairs, persisted
1306+
// so callers()/reference_impact can match by real identity instead of the
1307+
// bare candidate_group_key above -- see ambiguity_group_candidates in
1308+
// db/schema.rs for why this kills the cross-language bare-name collision.
1309+
candidates: Vec<(String, String)>,
13011310
reason: String,
13021311
}
13031312

@@ -1380,11 +1389,15 @@ fn resolve_sites_to_edges(
13801389
// `candidate_rank = 0` to the preferred (same-dir) subset and `1` to
13811390
// every other surviving candidate, instead of the old behavior of
13821391
// silently discarding them.
1392+
// PR#6: 4th field now carries the actual overflow candidate SET (not just
1393+
// its length) so ambiguity_groups can persist real (qualified_name, path)
1394+
// members instead of only a count -- callers()/reference_impact then match
1395+
// a queried symbol by identity, not by the group's bare candidate_group_key.
13831396
type CandidateResult = (
13841397
Vec<(String, String)>,
13851398
bool,
13861399
bool,
1387-
Option<usize>,
1400+
Option<Vec<(String, String)>>,
13881401
Option<HashSet<(String, String)>>,
13891402
);
13901403
let candidates: Vec<CandidateResult> = sites
@@ -1831,7 +1844,7 @@ fn resolve_sites_to_edges(
18311844
// this specific case and record it as an ambiguity_groups row
18321845
// instead of silent zero-edge dropping -- previously
18331846
// indistinguishable from a genuinely unresolved site.
1834-
(Vec::new(), false, false, Some(t.len()), None)
1847+
(Vec::new(), false, false, Some(t.clone()), None)
18351848
}
18361849
},
18371850
)
@@ -1862,12 +1875,13 @@ fn resolve_sites_to_edges(
18621875
(targets, namespace_confirmed, weak_receiver_fallback, overflow_count, preferred_subset),
18631876
) in sites.iter().zip(candidates.iter())
18641877
{
1865-
if let Some(candidate_count) = overflow_count {
1878+
if let Some(overflow_candidates) = overflow_count {
18661879
ambiguity_groups.push(AmbiguityGroup {
18671880
call_site_id: *call_site_id,
18681881
from_path: from_path.clone(),
18691882
candidate_group_key: callee.clone(),
1870-
candidate_count: *candidate_count,
1883+
candidate_count: overflow_candidates.len(),
1884+
candidates: overflow_candidates.clone(),
18711885
reason: "unscoped_candidates_exceeded_max_callee_candidates".to_string(),
18721886
});
18731887
}
@@ -1935,6 +1949,13 @@ fn insert_ambiguity_groups_batch(
19351949
"INSERT INTO ambiguity_groups (call_site_id, from_path, candidate_group_key, candidate_count, reason) \
19361950
VALUES (?1, ?2, ?3, ?4, ?5)",
19371951
)?;
1952+
// PR#6: persist the real (qualified_name, path) candidate members alongside
1953+
// the parent row so callers()/reference_impact can match by identity --
1954+
// see ambiguity_group_candidates in db/schema.rs.
1955+
let mut candidate_stmt = tx.prepare(
1956+
"INSERT INTO ambiguity_group_candidates (group_id, candidate_qn, candidate_path) \
1957+
VALUES (?1, ?2, ?3)",
1958+
)?;
19381959
for g in groups {
19391960
stmt.execute(rusqlite::params![
19401961
g.call_site_id,
@@ -1943,6 +1964,10 @@ fn insert_ambiguity_groups_batch(
19431964
g.candidate_count as i64,
19441965
g.reason,
19451966
])?;
1967+
let group_id = tx.last_insert_rowid();
1968+
for (qn, path) in &g.candidates {
1969+
candidate_stmt.execute(rusqlite::params![group_id, qn, path])?;
1970+
}
19461971
}
19471972
Ok(())
19481973
}

crates/calm-server/src/__toolsnaps__/callers.snap

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -322,7 +322,7 @@
322322
"$ref": "#/$defs/ConfidenceBreakdown"
323323
},
324324
"unresolved_group_count": {
325-
"description": "Count of `ambiguity_groups` rows (WS3) whose `candidate_group_key`\nmatches this symbol's bare name — call sites elsewhere that had more\nthan `MAX_CALLEE_CANDIDATES` same-named candidates and so produced\nno edge to ANY of them, this symbol possibly included. Neither\n`direct` nor `ambiguous` above can ever contain these — they are\ninvisible to both. See `Caveat::unresolved_ambiguity_groups`.",
325+
"description": "Count of distinct `ambiguity_groups` rows (WS3) whose PERSISTED\ncandidate members (`ambiguity_group_candidates`, PR#6) include this\nsymbol's real `qualified_name` — call sites elsewhere that had more\nthan `MAX_CALLEE_CANDIDATES` same-named candidates, this symbol\ngenuinely among them, and so produced no edge to ANY of them. Target-\naware: an unrelated same-named symbol in another language/module can\nno longer inherit this caveat (pre-PR#6 it matched on bare name).\nNeither `direct` nor `ambiguous` above can ever contain these — they\nare invisible to both. See `Caveat::unresolved_ambiguity_groups`.",
326326
"type": "integer",
327327
"format": "uint",
328328
"minimum": 0

crates/calm-server/src/__toolsnaps__/reference_impact.snap

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,7 @@
262262
"minimum": 0
263263
},
264264
"unresolved_many_count": {
265-
"description": "Count of `ambiguity_groups` rows (WS3) whose `candidate_group_key`\nmatches this symbol's bare name — sibling of `review_count`, but for\ncall sites that never became any edge at all (too many same-named\ncandidates), so they cannot appear in `references` alongside the\nother four classifications. See `CallersOutput::unresolved_group_count`.",
265+
"description": "Count of distinct `ambiguity_groups` rows (WS3) whose PERSISTED\ncandidate members include this symbol's real `qualified_name` (PR#6,\ntarget-aware) — sibling of `review_count`, but for call sites that\nnever became any edge at all (too many same-named candidates), so\nthey cannot appear in `references` alongside the other four\nclassifications. See `CallersOutput::unresolved_group_count`.",
266266
"type": "integer",
267267
"format": "uint",
268268
"minimum": 0

crates/calm-server/src/tools.rs

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6200,6 +6200,15 @@ mod tests {
62006200
[],
62016201
)
62026202
.unwrap();
6203+
// PR#6: persisted member row -- 'a.rs::helper' is genuinely among
6204+
// the 25 overflow candidates, so callers() must match it by real
6205+
// qualified_name, not merely by the group's bare candidate_group_key.
6206+
conn.execute(
6207+
"INSERT INTO ambiguity_group_candidates (group_id, candidate_qn, candidate_path)
6208+
VALUES (1, 'a.rs::helper', 'a.rs')",
6209+
[],
6210+
)
6211+
.unwrap();
62036212
}
62046213
let v = jv(
62056214
server.callers(rmcp::handler::server::wrapper::Parameters(CallersParams {
@@ -6229,6 +6238,89 @@ mod tests {
62296238

62306239
let _ = std::fs::remove_dir_all(&dir);
62316240
}
6241+
/// PR#6 (docs/plans/2026-08-19-evidence-architecture-execution-plan.md
6242+
/// Part E): an unrelated same-named symbol in another language must NOT
6243+
/// inherit an `ambiguity_groups` caveat that was never about it -- pre-PR#6
6244+
/// the query matched on the group's bare `candidate_group_key` alone, so a
6245+
/// Python `helper` overflow group would leak onto an unrelated Rust
6246+
/// `helper`. Target-aware membership (`ambiguity_group_candidates`,
6247+
/// matched by real `qualified_name`) must keep the caveat scoped to
6248+
/// symbols the resolver actually saw as candidates.
6249+
#[test]
6250+
fn ambiguity_group_membership_is_target_aware_not_bare_name() {
6251+
let dir =
6252+
std::env::temp_dir().join(format!("ci_callers_ambgroup_xlang_{}", std::process::id()));
6253+
let _ = std::fs::remove_dir_all(&dir);
6254+
std::fs::create_dir_all(&dir).unwrap();
6255+
let server = CalmServer::new(dir.clone(), dir.join("index.db")).unwrap();
6256+
{
6257+
let conn = server.db();
6258+
conn.execute(
6259+
"INSERT INTO symbols (qualified_name, name, kind, language, path, line_start, line_end, signature, docstring, name_tokens, caller_count, is_hub, is_entry_point)
6260+
VALUES ('rust_mod.rs::helper', 'helper', 'function', 'rust', 'rust_mod.rs', 1, 1, 'fn helper()', '', 'helper', 0, 0, 0)",
6261+
[],
6262+
)
6263+
.unwrap();
6264+
conn.execute(
6265+
"INSERT INTO symbols (qualified_name, name, kind, language, path, line_start, line_end, signature, docstring, name_tokens, caller_count, is_hub, is_entry_point)
6266+
VALUES ('py_mod.py::helper', 'helper', 'function', 'python', 'py_mod.py', 1, 1, 'def helper()', '', 'helper', 0, 0, 0)",
6267+
[],
6268+
)
6269+
.unwrap();
6270+
conn.execute(
6271+
"INSERT INTO call_sites (id, from_path, enclosing_qn, callee_name, call_line, identity_version, confidence, edge_kind)
6272+
VALUES (1, 'caller.py', 'caller.py::use_it', 'helper', 9, 1, 'ambiguous', 'call')",
6273+
[],
6274+
)
6275+
.unwrap();
6276+
conn.execute(
6277+
"INSERT INTO ambiguity_groups (call_site_id, from_path, candidate_group_key, candidate_count, reason)
6278+
VALUES (1, 'caller.py', 'helper', 25, 'unscoped_candidates_exceeded_max_callee_candidates')",
6279+
[],
6280+
)
6281+
.unwrap();
6282+
// The overflow candidate set is entirely Python -- the Rust `helper`
6283+
// symbol was never a real candidate at this call site, only its
6284+
// bare name coincides.
6285+
conn.execute(
6286+
"INSERT INTO ambiguity_group_candidates (group_id, candidate_qn, candidate_path)
6287+
VALUES (1, 'py_mod.py::helper', 'py_mod.py')",
6288+
[],
6289+
)
6290+
.unwrap();
6291+
}
6292+
let rust_result = jv(
6293+
server.callers(rmcp::handler::server::wrapper::Parameters(CallersParams {
6294+
symbol: "helper".into(),
6295+
path: Some("rust_mod.rs".into()),
6296+
line: Some(1),
6297+
transitive: false,
6298+
max_depth: None,
6299+
if_none_match: None,
6300+
})),
6301+
);
6302+
assert_eq!(
6303+
rust_result["unresolved_group_count"], 0,
6304+
"the Rust helper was never a member of the Python overflow group -- must not inherit its caveat"
6305+
);
6306+
6307+
let py_result = jv(
6308+
server.callers(rmcp::handler::server::wrapper::Parameters(CallersParams {
6309+
symbol: "helper".into(),
6310+
path: Some("py_mod.py".into()),
6311+
line: Some(1),
6312+
transitive: false,
6313+
max_depth: None,
6314+
if_none_match: None,
6315+
})),
6316+
);
6317+
assert_eq!(
6318+
py_result["unresolved_group_count"], 1,
6319+
"the Python helper genuinely is a persisted member of the overflow group"
6320+
);
6321+
6322+
let _ = std::fs::remove_dir_all(&dir);
6323+
}
62326324

62336325
/// WS5 (docs/plans/2026-08-18-context-intelligence-upgrade-plan.md):
62346326
/// `callers()` must sort `ambiguous` by `candidate_rank` so a

0 commit comments

Comments
 (0)