Skip to content

Commit 3136585

Browse files
committed
Add post-dump plausibility gate returning status:degraded (#334)
Compare persisted SQLite node counts to in-memory dump counts after index_repository completes so partial WAL/durability loss surfaces as status:"degraded" instead of silent indexed.
1 parent a50b086 commit 3136585

10 files changed

Lines changed: 442 additions & 17 deletions

File tree

Makefile.cbm

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,8 @@ FOUNDATION_SRCS = \
111111
src/foundation/compat_regex.c \
112112
src/foundation/mem.c \
113113
src/foundation/diagnostics.c \
114-
src/foundation/profile.c
114+
src/foundation/profile.c \
115+
src/foundation/dump_verify.c
115116

116117
# Existing extraction C code (compiled from current location)
117118
EXTRACTION_SRCS = \
@@ -297,7 +298,8 @@ TEST_FOUNDATION_SRCS = \
297298
tests/test_str_intern.c \
298299
tests/test_log.c \
299300
tests/test_str_util.c \
300-
tests/test_platform.c
301+
tests/test_platform.c \
302+
tests/test_dump_verify.c
301303

302304
TEST_EXTRACTION_SRCS = \
303305
tests/test_extraction.c \
@@ -315,7 +317,8 @@ TEST_STORE_SRCS = \
315317
tests/test_store_arch.c \
316318
tests/test_store_bulk.c \
317319
tests/test_store_pragmas.c \
318-
tests/test_store_checkpoint.c
320+
tests/test_store_checkpoint.c \
321+
tests/test_dump_verify_io.c
319322

320323
TEST_CYPHER_SRCS = \
321324
tests/test_cypher.c

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,7 @@ codebase-memory-mcp config reset auto_index # reset to default
445445
| `CBM_DOWNLOAD_URL` | *(GitHub releases)* | Override the download URL for updates. Used for testing or self-hosted deployments. |
446446
| `CBM_LOG_LEVEL` | `info` | Set the minimum log level. Accepted values (case-insensitive): `debug`, `info`, `warn`, `error`, `none` — or their numeric equivalents `0``4` matching the internal enum. Logs go to stderr; stdout is reserved for MCP JSON-RPC. |
447447
| `CBM_WORKERS` | *(detected)* | Override the parallel-indexing worker count returned by `cbm_default_worker_count`. Useful inside containers where `sysconf(_SC_NPROCESSORS_ONLN)` reports host CPUs rather than the cgroup's effective quota. Range 1–256; invalid values are ignored with a warning. |
448+
| `CBM_DUMP_VERIFY_MIN_RATIO` | `0.5` | After indexing, compare persisted SQLite node count to the in-memory dump count. When persisted nodes fall below this fraction of committed nodes (and committed > 50), `index_repository` returns `status:"degraded"` instead of silent `indexed`. Range 0–1; set `0` to disable. Invalid values are ignored with a warning. |
448449

449450
```bash
450451
# Store indexes in a custom directory

src/foundation/dump_verify.c

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/*
2+
* dump_verify.c — Post-dump plausibility gate (#334).
3+
*/
4+
#include "foundation/dump_verify.h"
5+
#include "foundation/constants.h"
6+
#include "foundation/log.h"
7+
#include "foundation/platform.h"
8+
9+
#include <stdlib.h>
10+
#include <string.h>
11+
12+
bool cbm_dump_verify_is_degraded(int committed_nodes, int persisted_nodes, double ratio,
13+
int min_floor) {
14+
if (ratio <= 0.0) {
15+
return false;
16+
}
17+
if (committed_nodes < 0) {
18+
return false;
19+
}
20+
if (committed_nodes <= min_floor) {
21+
return false;
22+
}
23+
if (persisted_nodes < 0) {
24+
return true;
25+
}
26+
return (double)persisted_nodes < (double)committed_nodes * ratio;
27+
}
28+
29+
double cbm_dump_verify_min_ratio(void) {
30+
char buf[CBM_SZ_32];
31+
if (cbm_safe_getenv("CBM_DUMP_VERIFY_MIN_RATIO", buf, sizeof(buf), NULL) != NULL) {
32+
char *end = NULL;
33+
double r = strtod(buf, &end);
34+
if (end != buf && r >= 0.0 && r <= 1.0) {
35+
return r;
36+
}
37+
cbm_log_warn("dump_verify.env.invalid", "value", buf, "fallback", "0.5");
38+
}
39+
return CBM_DUMP_VERIFY_DEFAULT_RATIO;
40+
}

src/foundation/dump_verify.h

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/*
2+
* dump_verify.h — Post-dump plausibility gate (#334 design b).
3+
*
4+
* Compares committed in-memory node counts against persisted SQLite rows
5+
* after index_repository completes. Nodes-only gate (edges shrink legitimately
6+
* at dump when endpoints fail to resolve).
7+
*/
8+
#ifndef CBM_DUMP_VERIFY_H
9+
#define CBM_DUMP_VERIFY_H
10+
11+
#include <stdbool.h>
12+
13+
/** Repos with at most this many committed nodes skip the ratio gate. */
14+
enum { CBM_DUMP_VERIFY_MIN_FLOOR = 50 };
15+
16+
/** Default minimum persisted/committed ratio when env is unset. */
17+
#define CBM_DUMP_VERIFY_DEFAULT_RATIO 0.5
18+
19+
/**
20+
* True when persisted_nodes is implausibly below committed_nodes.
21+
*
22+
* Returns false when ratio <= 0 (gate disabled), committed_nodes < 0 (no dump),
23+
* committed_nodes <= min_floor (sparse repo), or persisted >= committed * ratio.
24+
* Returns true when persisted_nodes < 0 (count error).
25+
*/
26+
bool cbm_dump_verify_is_degraded(int committed_nodes, int persisted_nodes, double ratio,
27+
int min_floor);
28+
29+
/** Read CBM_DUMP_VERIFY_MIN_RATIO (0..1); invalid/unset -> default 0.5. Set 0 to disable. */
30+
double cbm_dump_verify_min_ratio(void);
31+
32+
#endif /* CBM_DUMP_VERIFY_H */

src/mcp/mcp.c

Lines changed: 73 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ enum {
5454
#include "foundation/compat_thread.h"
5555
#include "foundation/log.h"
5656
#include "foundation/str_util.h"
57+
#include "foundation/dump_verify.h"
5758
#include "foundation/compat_regex.h"
5859
#include "pipeline/artifact.h"
5960

@@ -2522,28 +2523,84 @@ static void add_excluded_summary(yyjson_mut_doc *doc, yyjson_mut_val *root, char
25222523
yyjson_mut_obj_add_val(doc, root, "excluded", excluded);
25232524
}
25242525

2525-
/* Build the success portion of the index_repository response. */
2526-
static void build_index_success_response(cbm_mcp_server_t *srv, yyjson_mut_doc *doc,
2526+
/* Build the success portion of the index_repository response.
2527+
* Returns true when status should be "degraded" (#334 plausibility gate). */
2528+
static bool build_index_success_response(cbm_mcp_server_t *srv, yyjson_mut_doc *doc,
25272529
yyjson_mut_val *root, const char *project_name,
25282530
const char *repo_path, bool persistence,
2529-
char **excluded_dirs, int excluded_count) {
2531+
cbm_pipeline_t *p, char **excluded_dirs,
2532+
int excluded_count) {
25302533
add_excluded_summary(doc, root, excluded_dirs, excluded_count);
25312534

2535+
int exp_nodes = -1;
2536+
int exp_edges = -1;
2537+
cbm_pipeline_get_committed_counts(p, &exp_nodes, &exp_edges);
2538+
2539+
const double ratio = cbm_dump_verify_min_ratio();
2540+
const int min_floor = CBM_DUMP_VERIFY_MIN_FLOOR;
2541+
25322542
cbm_store_t *store = resolve_store(srv, project_name);
2543+
int nodes = 0;
2544+
int edges = 0;
2545+
bool degraded = false;
2546+
25332547
if (!store) {
2534-
return;
2548+
degraded = true;
2549+
} else {
2550+
nodes = cbm_store_count_nodes(store, project_name);
2551+
edges = cbm_store_count_edges(store, project_name);
2552+
if (nodes < 0) {
2553+
degraded = true;
2554+
nodes = 0;
2555+
edges = edges >= 0 ? edges : 0;
2556+
} else if (cbm_dump_verify_is_degraded(exp_nodes, nodes, ratio, min_floor)) {
2557+
(void)cbm_store_checkpoint(store);
2558+
int nodes2 = cbm_store_count_nodes(store, project_name);
2559+
int edges2 = cbm_store_count_edges(store, project_name);
2560+
if (nodes2 >= 0) {
2561+
nodes = nodes2;
2562+
}
2563+
if (edges2 >= 0) {
2564+
edges = edges2;
2565+
}
2566+
degraded = cbm_dump_verify_is_degraded(exp_nodes, nodes, ratio, min_floor);
2567+
}
25352568
}
2536-
int nodes = cbm_store_count_nodes(store, project_name);
2537-
int edges = cbm_store_count_edges(store, project_name);
2569+
25382570
yyjson_mut_obj_add_int(doc, root, "nodes", nodes);
25392571
yyjson_mut_obj_add_int(doc, root, "edges", edges);
2572+
if (exp_nodes >= 0) {
2573+
yyjson_mut_obj_add_int(doc, root, "expected_nodes", exp_nodes);
2574+
yyjson_mut_obj_add_int(doc, root, "expected_edges", exp_edges);
2575+
}
2576+
2577+
if (degraded) {
2578+
if (!store) {
2579+
yyjson_mut_obj_add_str(
2580+
doc, root, "hint",
2581+
"Index database failed integrity check and was removed. "
2582+
"Re-run index_repository(repo_path=...) to rebuild.");
2583+
cbm_log_warn("dump.verify", "reason", "store_missing", "expected_nodes",
2584+
exp_nodes >= 0 ? "set" : "unknown");
2585+
} else {
2586+
char exp_buf[MCP_FIELD_SIZE];
2587+
char got_buf[MCP_FIELD_SIZE];
2588+
snprintf(exp_buf, sizeof(exp_buf), "%d", exp_nodes);
2589+
snprintf(got_buf, sizeof(got_buf), "%d", nodes);
2590+
yyjson_mut_obj_add_str(
2591+
doc, root, "hint",
2592+
"Persisted far fewer nodes than indexed — likely durability loss from a "
2593+
"hard-killed sibling process. Re-run index_repository(repo_path=...) to rebuild.");
2594+
cbm_log_warn("dump.verify", "expected_nodes", exp_buf, "persisted_nodes", got_buf);
2595+
}
2596+
}
25402597

25412598
char adr_path[CBM_SZ_4K];
25422599
snprintf(adr_path, sizeof(adr_path), "%s/.codebase-memory/adr.md", repo_path);
25432600
struct stat adr_st;
25442601
bool adr_exists = (stat(adr_path, &adr_st) == 0);
25452602
yyjson_mut_obj_add_bool(doc, root, "adr_present", adr_exists);
2546-
if (!adr_exists) {
2603+
if (!adr_exists && !degraded) {
25472604
yyjson_mut_obj_add_str(
25482605
doc, root, "adr_hint",
25492606
"Project indexed. Consider creating an Architecture Decision Record: "
@@ -2558,6 +2615,8 @@ static void build_index_success_response(cbm_mcp_server_t *srv, yyjson_mut_doc *
25582615
"Persistent artifact written to .codebase-memory/graph.db.zst. "
25592616
"Commit this file to share the index with teammates.");
25602617
}
2618+
2619+
return degraded;
25612620
}
25622621

25632622
static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) {
@@ -2638,19 +2697,19 @@ static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) {
26382697
yyjson_mut_doc_set_root(doc, root);
26392698

26402699
yyjson_mut_obj_add_str(doc, root, "project", project_name);
2641-
yyjson_mut_obj_add_str(doc, root, "status", rc == 0 ? "indexed" : "error");
26422700

2643-
if (rc != 0) {
2701+
bool degraded = false;
2702+
if (rc == 0) {
2703+
degraded = build_index_success_response(srv, doc, root, project_name, repo_path,
2704+
persistence, p, excluded_dirs, excluded_count);
2705+
yyjson_mut_obj_add_str(doc, root, "status", degraded ? "degraded" : "indexed");
2706+
} else {
2707+
yyjson_mut_obj_add_str(doc, root, "status", "error");
26442708
yyjson_mut_obj_add_str(doc, root, "hint",
26452709
"Pipeline failed. Check repo_path exists and contains source files. "
26462710
"Try mode='fast' for a quicker diagnostic run.");
26472711
}
26482712

2649-
if (rc == 0) {
2650-
build_index_success_response(srv, doc, root, project_name, repo_path, persistence,
2651-
excluded_dirs, excluded_count);
2652-
}
2653-
26542713
char *json = yy_doc_to_str(doc);
26552714
yyjson_mut_doc_free(doc);
26562715
/* Free the pipeline only after the response doc copied the excluded list. */

src/pipeline/pipeline.c

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,10 @@ struct cbm_pipeline {
9090

9191
/* User-defined extension overrides (loaded once per run) */
9292
cbm_userconfig_t *userconfig;
93+
94+
/* Committed graph size at dump time (-1 = dump did not run). #334 gate axis. */
95+
int committed_nodes;
96+
int committed_edges;
9397
};
9498

9599
/* ── Global pkgmap (one active pipeline at a time) ─────────────── */
@@ -149,6 +153,8 @@ cbm_pipeline_t *cbm_pipeline_new(const char *repo_path, const char *db_path,
149153
p->project_name = cbm_project_name_from_path(repo_path);
150154
p->mode = mode;
151155
p->persistence = false;
156+
p->committed_nodes = -1;
157+
p->committed_edges = -1;
152158
atomic_init(&p->cancelled, 0);
153159

154160
return p;
@@ -211,6 +217,15 @@ void cbm_pipeline_get_excluded(const cbm_pipeline_t *p, char ***out, int *count)
211217
}
212218
}
213219

220+
void cbm_pipeline_get_committed_counts(const cbm_pipeline_t *p, int *nodes, int *edges) {
221+
if (nodes) {
222+
*nodes = p ? p->committed_nodes : -1;
223+
}
224+
if (edges) {
225+
*edges = p ? p->committed_edges : -1;
226+
}
227+
}
228+
214229
/* Resolve the DB path for this pipeline. Caller must free(). */
215230
static char *resolve_db_path(const cbm_pipeline_t *p) {
216231
char *path = malloc(CBM_SZ_1K);
@@ -814,6 +829,8 @@ static int dump_and_persist_hashes(cbm_pipeline_t *p, const cbm_file_info_t *fil
814829
cbm_log_error("pipeline.err", "phase", "dump");
815830
return rc;
816831
}
832+
p->committed_nodes = cbm_gbuf_node_count(p->gbuf);
833+
p->committed_edges = cbm_gbuf_edge_count(p->gbuf);
817834
cbm_log_info("pass.timing", "pass", "dump", "elapsed_ms", itoa_buf((int)elapsed_ms(*t)));
818835
cbm_store_t *hash_store = cbm_store_open_path(db_path);
819836
if (hash_store) {

src/pipeline/pipeline.h

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,10 @@ int cbm_pipeline_get_mode(const cbm_pipeline_t *p);
7272
* to NULL/0 when p is NULL or nothing was excluded. Do not free. */
7373
void cbm_pipeline_get_excluded(const cbm_pipeline_t *p, char ***out, int *count);
7474

75+
/* Committed node/edge counts captured at dump time (-1 when dump did not run).
76+
* Nodes are the #334 plausibility-gate axis; edges are informational only. */
77+
void cbm_pipeline_get_committed_counts(const cbm_pipeline_t *p, int *nodes, int *edges);
78+
7579
/* ── Index lock (prevents concurrent pipeline runs on same DB) ──── */
7680

7781
/* Try to acquire the global index lock. Returns true if acquired,

tests/test_dump_verify.c

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/*
2+
* test_dump_verify.c — Post-dump plausibility gate (#334).
3+
*
4+
* Pure-function matrix mirrors sast-ai-app checkSilentDegradation cases.
5+
* I/O-level coverage that drives the gate against a real on-disk SQLite store
6+
* lives in test_dump_verify_io.c (store-linked, excluded from test-foundation).
7+
*/
8+
#include "../src/foundation/compat.h"
9+
#include "../src/foundation/dump_verify.h"
10+
#include "test_framework.h"
11+
12+
#include <stdlib.h>
13+
14+
TEST(dump_verify_no_baseline) {
15+
ASSERT_FALSE(cbm_dump_verify_is_degraded(-1, 500, 0.5, CBM_DUMP_VERIFY_MIN_FLOOR));
16+
PASS();
17+
}
18+
19+
TEST(dump_verify_sparse_at_floor) {
20+
ASSERT_FALSE(cbm_dump_verify_is_degraded(50, 10, 0.5, CBM_DUMP_VERIFY_MIN_FLOOR));
21+
ASSERT_FALSE(cbm_dump_verify_is_degraded(12, 5, 0.5, CBM_DUMP_VERIFY_MIN_FLOOR));
22+
PASS();
23+
}
24+
25+
TEST(dump_verify_shortfall_below_ratio) {
26+
ASSERT_TRUE(cbm_dump_verify_is_degraded(1000, 400, 0.5, CBM_DUMP_VERIFY_MIN_FLOOR));
27+
PASS();
28+
}
29+
30+
TEST(dump_verify_just_above_ratio) {
31+
ASSERT_FALSE(cbm_dump_verify_is_degraded(1000, 500, 0.5, CBM_DUMP_VERIFY_MIN_FLOOR));
32+
PASS();
33+
}
34+
35+
TEST(dump_verify_just_below_ratio) {
36+
ASSERT_TRUE(cbm_dump_verify_is_degraded(1000, 499, 0.5, CBM_DUMP_VERIFY_MIN_FLOOR));
37+
PASS();
38+
}
39+
40+
TEST(dump_verify_zero_persisted) {
41+
ASSERT_TRUE(cbm_dump_verify_is_degraded(1000, 0, 0.5, CBM_DUMP_VERIFY_MIN_FLOOR));
42+
PASS();
43+
}
44+
45+
TEST(dump_verify_growth) {
46+
ASSERT_FALSE(cbm_dump_verify_is_degraded(500, 750, 0.5, CBM_DUMP_VERIFY_MIN_FLOOR));
47+
PASS();
48+
}
49+
50+
TEST(dump_verify_count_error) {
51+
ASSERT_TRUE(cbm_dump_verify_is_degraded(1000, -1, 0.5, CBM_DUMP_VERIFY_MIN_FLOOR));
52+
PASS();
53+
}
54+
55+
TEST(dump_verify_ratio_zero_disables) {
56+
ASSERT_FALSE(cbm_dump_verify_is_degraded(1000, 10, 0.0, CBM_DUMP_VERIFY_MIN_FLOOR));
57+
PASS();
58+
}
59+
60+
TEST(dump_verify_loosened_ratio) {
61+
ASSERT_FALSE(cbm_dump_verify_is_degraded(1000, 600, 0.5, CBM_DUMP_VERIFY_MIN_FLOOR));
62+
PASS();
63+
}
64+
65+
TEST(dump_verify_tightened_ratio) {
66+
ASSERT_TRUE(cbm_dump_verify_is_degraded(1000, 900, 0.95, CBM_DUMP_VERIFY_MIN_FLOOR));
67+
PASS();
68+
}
69+
70+
TEST(dump_verify_edges_shrank_nodes_ok) {
71+
/* Edges are not gated; this documents nodes-only semantics for integrators. */
72+
ASSERT_FALSE(cbm_dump_verify_is_degraded(200, 200, 0.5, CBM_DUMP_VERIFY_MIN_FLOOR));
73+
PASS();
74+
}
75+
76+
SUITE(dump_verify) {
77+
RUN_TEST(dump_verify_no_baseline);
78+
RUN_TEST(dump_verify_sparse_at_floor);
79+
RUN_TEST(dump_verify_shortfall_below_ratio);
80+
RUN_TEST(dump_verify_just_above_ratio);
81+
RUN_TEST(dump_verify_just_below_ratio);
82+
RUN_TEST(dump_verify_zero_persisted);
83+
RUN_TEST(dump_verify_growth);
84+
RUN_TEST(dump_verify_count_error);
85+
RUN_TEST(dump_verify_ratio_zero_disables);
86+
RUN_TEST(dump_verify_loosened_ratio);
87+
RUN_TEST(dump_verify_tightened_ratio);
88+
RUN_TEST(dump_verify_edges_shrank_nodes_ok);
89+
}

0 commit comments

Comments
 (0)