Skip to content

Commit 62b4451

Browse files
fix(confidence): stop an unreadable confidence from reading as 0.0
strtod answers 0.0 for text it cannot read, and 0.0 is a real confidence in both places that call it. So a property blob carrying a malformed value, such as "confidence":null, came back as a recorded confidence of zero. Two things went wrong with that. In src/graph_buffer/graph_buffer.c, edge_props_confidence answers CBM_EDGE_CONF_ABSENT (-1) when an edge carries no confidence, so any real confidence outranks it. A malformed value answered 0.0, which beats -1 in the merge comparison, so the malformed blob displaced a clean stored one. The function's own comment already promised that "absent/unparseable reads as -1". Only the absent half was true. In src/mcp/mcp.c, bfs_edge_evidence_for_hop sets the confidence to -1 and the emitter publishes any value of 0 or more as a recorded number. A malformed value printed as 0.00, which reads as "the resolver was certain this is wrong" rather than "nobody wrote a number here". Both sites now pass an end pointer to strtod and keep the absent sentinel when the pointer never moved, which is the shape src/store/store.c:402 already uses. Two tests come with the change, and both were seen failing before the fix and passing after: gbuf_edge_props_unreadable_confidence_does_not_displace_absent tool_trace_path_unreadable_confidence_reports_not_recorded Red: 258 passed, 2 failed. Green: 260 passed, 0 failed. The full suite reports 7633 passed, 2 failed. Both failures are in tests/test_cli.c (lines 1749 and 6725) and reproduce on a clean tree without this change. They depend on the coding agents installed on the machine, not on this change. make -f Makefile.cbm lint-ci passes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Joshua Richter <jrichter5781@gmail.com>
1 parent 5fbab7b commit 62b4451

4 files changed

Lines changed: 115 additions & 2 deletions

File tree

src/graph_buffer/graph_buffer.c

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1045,7 +1045,18 @@ static double edge_props_confidence(const char *props_json) {
10451045
if (!p) {
10461046
return CBM_EDGE_CONF_ABSENT;
10471047
}
1048-
return strtod(p + sizeof(conf_key) - SKIP_ONE, NULL);
1048+
/* strtod answers 0.0 for text it cannot read, and 0.0 is a real
1049+
* confidence that beats CBM_EDGE_CONF_ABSENT. So a blob carrying
1050+
* "confidence":null used to outrank a clean blob that carries no
1051+
* confidence at all, and displace it. Ask strtod where it stopped: an
1052+
* end pointer that never moved means it read nothing. */
1053+
const char *value = p + sizeof(conf_key) - SKIP_ONE;
1054+
char *end = NULL;
1055+
double conf = strtod(value, &end);
1056+
if (end == value) {
1057+
return CBM_EDGE_CONF_ABSENT;
1058+
}
1059+
return conf;
10491060
}
10501061

10511062
/* Decide whether an incoming property blob replaces the stored one on a

src/mcp/mcp.c

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6498,7 +6498,16 @@ static bool bfs_edge_evidence_for_hop(cbm_traverse_result_t *tr, int64_t hop_nod
64986498
if (conf) {
64996499
const char *colon = strchr(conf, ':');
65006500
if (colon) {
6501-
*confidence_out = strtod(colon + 1, NULL);
6501+
/* strtod answers 0.0 for text it cannot read, and the caller
6502+
* publishes any value >= 0 as a recorded confidence. So a
6503+
* malformed value used to print as 0.00 — the one number the
6504+
* surrounding code works to keep meaningful. Keep the -1 when
6505+
* the end pointer never moved: nothing was read. */
6506+
char *end = NULL;
6507+
double parsed = strtod(colon + 1, &end);
6508+
if (end != colon + 1) {
6509+
*confidence_out = parsed;
6510+
}
65026511
}
65036512
}
65046513
return true;

tests/test_graph_buffer.c

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,34 @@ TEST(gbuf_edge_props_merge_prefers_higher_confidence) {
236236
PASS();
237237
}
238238

239+
/* A confidence the code cannot read is not evidence of anything, so it must
240+
* not outrank an edge that simply carries no confidence at all.
241+
*
242+
* edge_props_confidence answers -1 for "absent" so that any real confidence
243+
* beats it. strtod answers 0.0 for text it cannot read, so an unreadable
244+
* value used to come back as a real confidence of zero -- which beats -1 and
245+
* displaced the stored blob. The function's own comment already promised
246+
* that "absent/unparseable reads as -1"; only the absent half was true. */
247+
TEST(gbuf_edge_props_unreadable_confidence_does_not_displace_absent) {
248+
const char *no_conf = "{\"callee\":\"f\",\"strategy\":\"lsp\"}";
249+
const char *bad_conf = "{\"callee\":\"f\",\"confidence\":null,\"strategy\":\"registry\"}";
250+
251+
cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp");
252+
int64_t a = cbm_gbuf_upsert_node(gb, "Function", "a", "pkg.a", "f.go", 1, 5, "{}");
253+
int64_t b = cbm_gbuf_upsert_node(gb, "Function", "b", "pkg.b", "f.go", 6, 10, "{}");
254+
cbm_gbuf_insert_edge(gb, a, b, "CALLS", no_conf);
255+
cbm_gbuf_insert_edge(gb, a, b, "CALLS", bad_conf); /* unreadable, arrives last */
256+
257+
const cbm_gbuf_edge_t **edges = NULL;
258+
int count = 0;
259+
cbm_gbuf_find_edges_by_type(gb, "CALLS", &edges, &count);
260+
ASSERT_EQ(count, 1);
261+
ASSERT_TRUE(strstr(edges[0]->properties_json, "\"strategy\":\"lsp\"") != NULL);
262+
263+
cbm_gbuf_free(gb);
264+
PASS();
265+
}
266+
239267
/* An empty incoming blob must never displace real stored properties. */
240268
TEST(gbuf_edge_props_merge_keeps_existing_on_empty) {
241269
const char *lsp = "{\"callee\":\"f\",\"confidence\":0.95,\"strategy\":\"lsp\"}";
@@ -1152,6 +1180,7 @@ SUITE(graph_buffer) {
11521180
/* Edge property merge determinism */
11531181
RUN_TEST(gbuf_edge_props_merge_is_order_independent);
11541182
RUN_TEST(gbuf_edge_props_merge_prefers_higher_confidence);
1183+
RUN_TEST(gbuf_edge_props_unreadable_confidence_does_not_displace_absent);
11551184
RUN_TEST(gbuf_edge_props_merge_keeps_existing_on_empty);
11561185

11571186
/* Shared ID tests */

tests/test_mcp.c

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3629,6 +3629,69 @@ TEST(trace_evidence_strategy_class_vocabulary_is_closed) {
36293629
PASS();
36303630
}
36313631

3632+
/* A confidence the code cannot read must be reported as "not recorded", not
3633+
* as a recorded zero.
3634+
*
3635+
* The emitter reserves ev_conf < 0 for "no confidence on this edge" and
3636+
* prints "-" (text) or null (json). The reader set ev_conf with
3637+
* strtod(colon + 1, NULL), and strtod answers 0.0 for text it cannot read --
3638+
* so a malformed value passed the ev_conf >= 0.0 test and printed 0.00, the
3639+
* one value the surrounding code goes out of its way to keep meaningful.
3640+
* A caller then cannot tell "the resolver was certain this is wrong" from
3641+
* "nobody wrote a number here". */
3642+
TEST(tool_trace_path_unreadable_confidence_reports_not_recorded) {
3643+
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
3644+
cbm_store_t *st = cbm_mcp_server_store(srv);
3645+
const char *proj = "badconf-proj";
3646+
cbm_mcp_server_set_project(srv, proj);
3647+
cbm_store_upsert_project(st, proj, "/tmp/badconf");
3648+
cbm_node_t caller = {.project = proj,
3649+
.label = "Function",
3650+
.name = "caller",
3651+
.qualified_name = "badconf-proj.src.caller",
3652+
.file_path = "src/a.c",
3653+
.start_line = 1,
3654+
.end_line = 5};
3655+
cbm_node_t callee = {.project = proj,
3656+
.label = "Function",
3657+
.name = "target",
3658+
.qualified_name = "badconf-proj.src.target",
3659+
.file_path = "src/a.c",
3660+
.start_line = 10,
3661+
.end_line = 20};
3662+
int64_t id_caller = cbm_store_upsert_node(st, &caller);
3663+
int64_t id_callee = cbm_store_upsert_node(st, &callee);
3664+
ASSERT_GT(id_caller, 0);
3665+
ASSERT_GT(id_callee, 0);
3666+
/* The strategy reads fine; only the confidence is malformed. */
3667+
cbm_edge_t e = {.project = proj,
3668+
.source_id = id_caller,
3669+
.target_id = id_callee,
3670+
.type = "CALLS",
3671+
.properties_json = "{\"callee\":\"target\",\"confidence\":null,"
3672+
"\"strategy\":\"lsp_trait_dispatch\",\"candidates\":1}"};
3673+
ASSERT_GT(cbm_store_insert_edge(st, &e), 0);
3674+
3675+
char *ev = cbm_mcp_server_handle(
3676+
srv, "{\"jsonrpc\":\"2.0\",\"id\":93,\"method\":\"tools/call\","
3677+
"\"params\":{\"name\":\"trace_path\",\"arguments\":{\"function_name\":\"caller\","
3678+
"\"project\":\"badconf-proj\",\"direction\":\"outbound\",\"include_evidence\":true}}}");
3679+
ASSERT_NOT_NULL(ev);
3680+
char *ev_txt = extract_text_content(ev);
3681+
ASSERT_NOT_NULL(ev_txt);
3682+
/* Positive controls: the hop and its readable class still come through, so
3683+
* a failure below is about the confidence and not a broken request. */
3684+
ASSERT_NOT_NULL(strstr(ev_txt, "target"));
3685+
ASSERT_NOT_NULL(strstr(ev_txt, "lsp"));
3686+
/* The claim: an unreadable confidence is never published as 0.00. */
3687+
ASSERT_NULL(strstr(ev_txt, "0.00"));
3688+
free(ev_txt);
3689+
free(ev);
3690+
3691+
cbm_mcp_server_free(srv);
3692+
PASS();
3693+
}
3694+
36323695
/* Distilled from #559 (@vvenegasv). The indexer already records
36333696
* {strategy, confidence} on every CALLS edge (pass_calls.c:355) and the store
36343697
* reads it back, but no tool ever surfaced it — an agent could see THAT A->B
@@ -13870,6 +13933,7 @@ SUITE(mcp) {
1387013933
RUN_TEST(trace_evidence_strategy_class_vocabulary_is_closed);
1387113934
RUN_TEST(tool_trace_path_evidence_is_opt_in_and_class_mapped);
1387213935
RUN_TEST(tool_trace_path_evidence_columns_match_header_issue1542);
13936+
RUN_TEST(tool_trace_path_unreadable_confidence_reports_not_recorded);
1387313937
RUN_TEST(tool_trace_call_path_depth_clamped);
1387413938
RUN_TEST(tool_trace_call_path_distinct_defs_not_over_unioned);
1387513939
RUN_TEST(tool_trace_call_path_dts_stub_unions_with_impl);

0 commit comments

Comments
 (0)