diff --git a/README.md b/README.md index b87f0244e..4f291b488 100644 --- a/README.md +++ b/README.md @@ -798,7 +798,7 @@ codebase-memory-mcp config list # show all settings codebase-memory-mcp config set auto_index true # auto-index on session start codebase-memory-mcp config set auto_index_limit 50000 # max files for auto-index codebase-memory-mcp config set auto_watch false # don't register background git watcher (default: true) -codebase-memory-mcp config set doc_candidates false # skip doc<->code candidate scoring on huge repos (default: true) +codebase-memory-mcp config set doc_candidates false # skip doc<->code candidate scoring on huge repos (default: auto = on for full, off for moderate/fast) codebase-memory-mcp config set watcher_enabled false # stop the watcher thread entirely (default: true) codebase-memory-mcp config set watch_non_git true # also poll non-git project roots (default: false) codebase-memory-mcp config set index_max_files 250000 # optional per-index source-file limit diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index b26378e95..3e9956d60 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -88,7 +88,7 @@ Current keys: | `auto_index` | `false` | Automatically index new projects when an MCP session starts. | | `auto_index_limit` | `50000` | Maximum file count allowed for automatic indexing of a new project. | | `auto_watch` | `true` | Register the session's project with the background git watcher on connect. Set `false` to keep a session from registering its project (the watcher still runs for other projects). | -| `doc_candidates` | `true` | Score documentation sections against code to store doc↔code candidates (the possible targets of a section, and the sections that may describe a function or file, each with a judged probability). The scoring grows with sections × functions: under a second up to ~100k functions, ~15 s at ~400k, ~3 minutes on the Linux kernel. Set `false` to skip it; no candidates are stored and the rest of the graph is unchanged. `index_repository` also takes `doc_candidates` for a single index, which wins over this setting. Applies from the next index. | +| `doc_candidates` | `auto` | Score documentation sections against code to store doc↔code candidates (the possible targets of a section, and the sections that may describe a function or file, each with a judged probability). Unset (`auto`), the index mode decides: on for `full` indexes (the default mode), off for `moderate` and `fast`. Set `true` or `false` to choose for every mode; `config reset doc_candidates` returns to `auto`. The scoring grows with sections × functions: under a second up to ~100k functions, ~15 s at ~400k, ~3 minutes on the Linux kernel. Off stores no candidates and leaves the rest of the graph unchanged; on in `fast` mode stores candidates without adding `fast`'s skipped `SIMILAR_TO` / `SEMANTICALLY_RELATED` edges. `index_repository` also takes `doc_candidates` for a single index, which wins over this setting. Applies from the next index. | | `watcher_enabled` | `true` | Master switch for the background watcher subsystem. Set `false` to stop the watcher from starting at all — no poll thread and no project registration. Reindex manually with `index_repository` when disabled. Only an open MCP session's own project is ever watched; `index_status` reports the current state in its `watch` object. | | `watch_non_git` | `false` | Also poll project roots that are **not git repositories**. By default the watcher only follows git projects, so a project indexed from a plain directory is never refreshed after its first index — reindex it manually with `index_repository`. Set `true` to poll such roots on the same adaptive cadence with a file-tree scan: the indexer's own discovery walk (same skip lists, `.gitignore` and `.cbmignore` rules) hashed over each file's path, size and mtime. Any change reindexes once; paths the indexer skips (including cbm's own `.codebase-memory/` output) never trigger. The first poll after the daemon starts reindexes each such project once, since nothing records which tree state the index holds. The scan walks the whole tree every poll, so it costs more than git polling on very large trees. Read once when the daemon starts, like `watcher_enabled`. | | `index_max_files` | `off` | Optional maximum number of accepted source files in one discovery run. | diff --git a/src/cli/cli.c b/src/cli/cli.c index a90a448be..65902d4ca 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -7461,18 +7461,29 @@ const char *cbm_config_get(cbm_config_t *cfg, const char *key, const char *defau return result; } -bool cbm_config_get_bool(cbm_config_t *cfg, const char *key, bool default_val) { +bool cbm_config_get_bool_set(cbm_config_t *cfg, const char *key, bool *value) { const char *val = cbm_config_get(cfg, key, NULL); if (!val) { - return default_val; - } - if (strcmp(val, "true") == 0 || strcmp(val, "1") == 0 || strcmp(val, "on") == 0) { - return true; - } - if (strcmp(val, "false") == 0 || strcmp(val, "0") == 0 || strcmp(val, "off") == 0) { return false; } - return default_val; + bool parsed; + if (strcmp(val, "true") == 0 || strcmp(val, "1") == 0 || strcmp(val, "on") == 0) { + parsed = true; + } else if (strcmp(val, "false") == 0 || strcmp(val, "0") == 0 || strcmp(val, "off") == 0) { + parsed = false; + } else { + return false; + } + if (value) { + *value = parsed; + } + return true; +} + +bool cbm_config_get_bool(cbm_config_t *cfg, const char *key, bool default_val) { + bool value = default_val; + (void)cbm_config_get_bool_set(cfg, key, &value); + return value; } int cbm_config_get_int(cbm_config_t *cfg, const char *key, int default_val) { @@ -7574,6 +7585,9 @@ static const config_key_def_t CONFIG_KEYS[] = { {CBM_CONFIG_AUTO_INDEX, "false", "Enable auto-indexing on MCP session start"}, {CBM_CONFIG_AUTO_INDEX_LIMIT, "50000", "Max files for auto-indexing new projects"}, {CBM_CONFIG_AUTO_WATCH, "true", "Register background git watcher on session connect"}, + /* Unset: the index mode decides (cbm_pipeline_doc_candidates_enabled). */ + {CBM_CONFIG_DOC_CANDIDATES, "auto", + "Doc-code candidates: auto = on for full, off for moderate/fast; true/false forces"}, {CBM_CONFIG_WATCHER_ENABLED, "true", "Run the background watcher thread (auto-reindex); false to disable"}, {CBM_CONFIG_WATCH_NON_GIT, "false", diff --git a/src/cli/cli.h b/src/cli/cli.h index a0274502a..e0c87b4d7 100644 --- a/src/cli/cli.h +++ b/src/cli/cli.h @@ -434,6 +434,11 @@ const char *cbm_config_get(cbm_config_t *cfg, const char *key, const char *defau /* Get a config value as bool. "true"/"1"/"on" → true. */ bool cbm_config_get_bool(cbm_config_t *cfg, const char *key, bool default_val); +/* A key the user set to a boolean ("true"/"1"/"on", "false"/"0"/"off"): + * true, with *value. Unset or unparseable: false, *value untouched -- the + * caller's own default applies (cbm_config_get_bool's reading). */ +bool cbm_config_get_bool_set(cbm_config_t *cfg, const char *key, bool *value); + /* Get a config value as int. Returns default_val if not found or invalid. */ int cbm_config_get_int(cbm_config_t *cfg, const char *key, int default_val); diff --git a/src/cli/runtime_settings.c b/src/cli/runtime_settings.c index ff98e5557..1e3b1e6fe 100644 --- a/src/cli/runtime_settings.c +++ b/src/cli/runtime_settings.c @@ -46,10 +46,12 @@ static const setting_t settings[] = { CFG("auto_watch", "Automatic watching", "Register a background Git watcher when an MCP session connects.", "boolean", "true", 0, 0, NULL, "src/mcp/mcp.c", "next-session"), + /* No fixed default: unset, the index mode decides (shown as automatic). */ CFG("doc_candidates", "Doc-code candidates", - "Score documentation sections against code for doc-code candidates. Costly on very large " - "repositories; off stores none.", - "boolean", "true", 0, 0, NULL, "src/mcp/mcp.c", "next-session"), + "Score documentation sections against code for doc-code candidates. Automatic: on for " + "full indexes, off for moderate and fast; on or off applies to every mode. Costly on very " + "large repositories; off stores none.", + "boolean", NULL, 0, 0, NULL, "src/mcp/mcp.c", "next-session"), CFG("watcher_enabled", "Background watcher", "Enable the daemon's background watcher thread.", "boolean", "true", 0, 0, NULL, "src/daemon/application.c", "restart"), {"ui-lang", "Interface language", "Language preference used by subsequent HTTP requests.", diff --git a/src/daemon/application.c b/src/daemon/application.c index ee121b5c6..7ad9fd21e 100644 --- a/src/daemon/application.c +++ b/src/daemon/application.c @@ -2319,12 +2319,16 @@ static bool application_index_args_add_policy(cbm_daemon_application_t *applicat cbm_index_resource_policy_t policy; char error[CBM_SZ_256] = {0}; bool loaded = cbm_config_load_index_policy(config, &policy, error, sizeof(error)); + /* The worker reads no config: a doc_candidates setting the user set + * travels with the request; unset, the worker's index mode decides. */ + bool doc_candidates_encoded = + !loaded || cbm_mcp_index_doc_candidates_add_to_args(document, root, config); cbm_config_close(owned_config); if (!loaded) { cbm_log_error("daemon.index.policy", "error", error); return false; } - return cbm_mcp_index_policy_add_to_args(document, root, &policy); + return doc_candidates_encoded && cbm_mcp_index_policy_add_to_args(document, root, &policy); } static char *application_auto_index_args(cbm_daemon_application_t *application, diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 73afd3373..03741e23a 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -549,7 +549,8 @@ static const tool_def_t TOOLS[] = { "\"persistence\":{\"type\":\"boolean\",\"default\":false,\"description\":" "\"Write .codebase-memory/graph.db.zst.\"}," "\"doc_candidates\":{\"type\":\"boolean\",\"description\":" - "\"Doc-code candidates; default: the doc_candidates setting (on).\"}," + "\"Doc-code candidates; default: the doc_candidates setting, else on for full, off for " + "moderate/fast.\"}," "\"async\":{\"type\":\"boolean\",\"default\":false,\"description\":" "\"Start the index in the background and return immediately; it keeps running if this " "call is cancelled or times out. Not with status or cross-repo-intelligence. Refused for a " @@ -11457,6 +11458,13 @@ bool cbm_mcp_index_policy_add_to_args(yyjson_mut_doc *doc, yyjson_mut_val *root, return valid && yyjson_mut_obj_add_val(doc, root, "_cbm_index_policy", encoded); } +bool cbm_mcp_index_doc_candidates_add_to_args(yyjson_mut_doc *doc, yyjson_mut_val *root, + cbm_config_t *config) { + bool enabled = false; + return !cbm_config_get_bool_set(config, CBM_CONFIG_DOC_CANDIDATES, &enabled) || + yyjson_mut_obj_add_bool(doc, root, "doc_candidates", enabled); +} + /* Run index_repository in a supervised worker subprocess with skip-and-continue * (Stage 3c). Returns the response string (caller frees): * - the worker's own response on a clean first run (the common path); @@ -11786,8 +11794,11 @@ static char *index_run_supervised_path(cbm_mcp_server_t *srv, const char *root_p yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); yyjson_mut_val *root = yyjson_mut_obj(doc); yyjson_mut_doc_set_root(doc, root); + /* A doc_candidates setting the user set travels with the request (the + * worker reads no config); unset, the worker's index mode decides. */ if (!yyjson_mut_obj_add_strcpy(doc, root, "repo_path", root_path) || - !cbm_mcp_index_policy_add_to_args(doc, root, &policy)) { + !cbm_mcp_index_policy_add_to_args(doc, root, &policy) || + !cbm_mcp_index_doc_candidates_add_to_args(doc, root, srv ? srv->config : NULL)) { yyjson_mut_doc_free(doc); return NULL; } @@ -11837,32 +11848,52 @@ static bool resolve_session_repo_path(cbm_mcp_server_t *srv, char **repo_path) { return true; } -/* doc_candidates for one index: an explicit boolean argument wins, else the - * doc_candidates setting (default on). The parent resolves it once and passes - * the decided value on, so a daemon job or supervised worker gets it as an - * argument instead of reading a config of its own. */ -static bool index_doc_candidates_enabled(cbm_mcp_server_t *srv, const char *args) { +/* doc_candidates for one index, explicit or unset: an explicit boolean + * argument wins, else a doc_candidates setting the user set to a boolean. + * Neither: unset, and the pipeline's index mode decides (on for full, off + * for moderate and fast). The parent resolves an explicit value once and + * passes it on, so a daemon job or supervised worker gets it as an argument + * instead of reading a config of its own; an unset one is not passed. */ +typedef enum { + INDEX_DOC_CANDIDATES_BY_MODE = 0, + INDEX_DOC_CANDIDATES_ON, + INDEX_DOC_CANDIDATES_OFF, +} index_doc_candidates_t; + +static index_doc_candidates_t index_doc_candidates_choice(cbm_mcp_server_t *srv, const char *args) { yyjson_doc *doc = args ? yyjson_read(args, strlen(args), 0) : NULL; yyjson_val *val = doc ? yyjson_obj_get(yyjson_doc_get_root(doc), "doc_candidates") : NULL; bool explicit_value = val && yyjson_is_bool(val); - bool enabled = explicit_value ? yyjson_get_bool(val) : true; + bool enabled = explicit_value && yyjson_get_bool(val); yyjson_doc_free(doc); - if (!explicit_value && srv && srv->config) { - enabled = cbm_config_get_bool(srv->config, CBM_CONFIG_DOC_CANDIDATES, true); + if (!explicit_value) { + explicit_value = + cbm_config_get_bool_set(srv ? srv->config : NULL, CBM_CONFIG_DOC_CANDIDATES, &enabled); + } + if (!explicit_value) { + return INDEX_DOC_CANDIDATES_BY_MODE; + } + return enabled ? INDEX_DOC_CANDIDATES_ON : INDEX_DOC_CANDIDATES_OFF; +} + +/* An explicit choice is set on the pipeline; unset leaves the mode default. */ +static void index_doc_candidates_apply(cbm_pipeline_t *p, index_doc_candidates_t choice) { + if (choice != INDEX_DOC_CANDIDATES_BY_MODE) { + cbm_pipeline_set_doc_candidates_enabled(p, choice == INDEX_DOC_CANDIDATES_ON); } - return enabled; } /* Preserve every index option while replacing all caller-supplied repo_path * keys with the one canonical path that was actually authorized. A non-empty * project_name likewise replaces any caller "name" (#2134: the project key the * parent resolved for this root travels to the daemon job / worker verbatim, - * so lease key and written index cannot diverge). doc_candidates is replaced - * by the value the parent resolved. */ + * so lease key and written index cannot diverge). Every caller doc_candidates + * is replaced by the explicit value the parent resolved, or dropped when it + * is unset, so the worker's index mode decides. */ static char *index_args_with_repo_path(const char *args, const char *canonical_repo_path, const char *project_name, const cbm_index_resource_policy_t *policy, - bool doc_candidates) { + index_doc_candidates_t doc_candidates) { if (!args || !canonical_repo_path || !policy) { return NULL; } @@ -11906,7 +11937,9 @@ static char *index_args_with_repo_path(const char *args, const char *canonical_r if ((has_name && !yyjson_mut_obj_add_strcpy(copy, copy_root, "name", project_name)) || !yyjson_mut_obj_add_strcpy(copy, copy_root, "repo_path", canonical_repo_path) || !cbm_mcp_index_policy_add_to_args(copy, copy_root, policy) || - !yyjson_mut_obj_add_bool(copy, copy_root, "doc_candidates", doc_candidates)) { + (doc_candidates != INDEX_DOC_CANDIDATES_BY_MODE && + !yyjson_mut_obj_add_bool(copy, copy_root, "doc_candidates", + doc_candidates == INDEX_DOC_CANDIDATES_ON))) { yyjson_mut_doc_free(copy); return NULL; } @@ -12272,7 +12305,7 @@ static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) { index_args_free(repo_path, mode_str, name_override); return cbm_mcp_text_result(policy_error, true); } - bool doc_candidates = index_doc_candidates_enabled(srv, args); + index_doc_candidates_t doc_candidates = index_doc_candidates_choice(srv, args); /* A daemon session delegates the one physical write to its shared job * registry only after path canonicalization and workspace authorization. */ @@ -12371,7 +12404,7 @@ static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) { free(name_override); cbm_pipeline_set_persistence(p, persistence); cbm_pipeline_set_resource_policy(p, &resource_policy); - cbm_pipeline_set_doc_candidates_enabled(p, doc_candidates); + index_doc_candidates_apply(p, doc_candidates); char *project_name = heap_strdup(cbm_pipeline_project_name(p)); @@ -20080,7 +20113,7 @@ static void *autoindex_thread(void *arg) { cbm_log_warn("autoindex.err", "msg", "pipeline_create_failed"); return NULL; } - cbm_pipeline_set_doc_candidates_enabled(p, index_doc_candidates_enabled(srv, NULL)); + index_doc_candidates_apply(p, index_doc_candidates_choice(srv, NULL)); /* Block until any concurrent pipeline finishes */ cbm_pipeline_lock(); diff --git a/src/mcp/mcp_internal.h b/src/mcp/mcp_internal.h index ead1fca38..02da636b2 100644 --- a/src/mcp/mcp_internal.h +++ b/src/mcp/mcp_internal.h @@ -54,6 +54,12 @@ bool cbm_mcp_jsonrpc_response_prepend_notice(char **response_io, const char *not bool cbm_mcp_index_policy_add_to_args(yyjson_mut_doc *doc, yyjson_mut_val *root, const cbm_index_resource_policy_t *policy); +/* Encode a doc_candidates setting the user set to a boolean on an internal + * worker request (the worker reads no config). Unset or unparseable adds no + * key, so the worker's index mode decides. False only when encoding fails. */ +bool cbm_mcp_index_doc_candidates_add_to_args(yyjson_mut_doc *doc, yyjson_mut_val *root, + struct cbm_config *config); + enum { CBM_MCP_DEFAULT_AUTO_INDEX_LIMIT = 50000 }; /* Count indexable files with the pipeline's native full-mode discovery policy, diff --git a/src/pipeline/pass_semantic_edges.c b/src/pipeline/pass_semantic_edges.c index bfde1d6bc..48e9914c7 100644 --- a/src/pipeline/pass_semantic_edges.c +++ b/src/pipeline/pass_semantic_edges.c @@ -3184,8 +3184,16 @@ static void phase6c_doc_sections(cbm_pipeline_ctx_t *ctx, const cbm_sem_func_t * } int cbm_pipeline_pass_semantic_edges(cbm_pipeline_ctx_t *ctx) { - /* Controlled by pipeline mode (moderate/full), not env var */ - cbm_log_info("pass.start", "pass", "semantic_edges"); + /* Controlled by pipeline mode, not env var. Full and moderate run every + * phase. Fast mode has no SEMANTICALLY_RELATED edges: it runs the pass + * only for doc -> code candidates, doc-only -- phases 1-4 (the corpus and + * vectors phase 6c scores against) and 6c, never the LSH + scoring of + * 5-6 -- and not at all with doc candidates off. */ + bool doc_only = ctx->mode == CBM_MODE_FAST; + if (doc_only && !cbm_pipeline_doc_candidates_enabled(ctx->pipeline)) { + return 0; + } + cbm_log_info("pass.start", "pass", "semantic_edges", "doc_only", doc_only ? "true" : "false"); cbm_gbuf_t *gbuf = ctx->gbuf; cbm_sem_config_t cfg = cbm_sem_get_config(); @@ -3254,22 +3262,25 @@ int cbm_pipeline_pass_semantic_edges(cbm_pipeline_ctx_t *ctx) { cbm_log_info("pass.semantic.vectors_stored", "count", itoa_log(func_count)); - /* Phase 5: LSH hyperplanes → signatures → buckets. */ - CBM_PROF_START(t_phase5); uint64_t *signatures = NULL; sem_bucket_t **band_buckets = NULL; - phase5_lsh_build(funcs, func_count, worker_count, &signatures, &band_buckets); - CBM_PROF_END_N("semantic_edges", "5_lsh_build", t_phase5, func_count); + int total_edges = 0; + if (!doc_only) { + /* Phase 5: LSH hyperplanes → signatures → buckets. */ + CBM_PROF_START(t_phase5); + phase5_lsh_build(funcs, func_count, worker_count, &signatures, &band_buckets); + CBM_PROF_END_N("semantic_edges", "5_lsh_build", t_phase5, func_count); - cbm_log_info("pass.semantic.lsh_built", "functions", itoa_log(func_count), "bands", - itoa_log(SEM_LSH_BANDS)); - sem_mem_mark("5_lsh"); + cbm_log_info("pass.semantic.lsh_built", "functions", itoa_log(func_count), "bands", + itoa_log(SEM_LSH_BANDS)); + sem_mem_mark("5_lsh"); - /* Phase 6: Parallel scoring + sequential edge merge. */ - int total_edges = - run_scoring_phase(gbuf, funcs, signatures, band_buckets, cfg, func_count, worker_count); + /* Phase 6: Parallel scoring + sequential edge merge. */ + total_edges = + run_scoring_phase(gbuf, funcs, signatures, band_buckets, cfg, func_count, worker_count); - sem_mem_mark("6_score"); + sem_mem_mark("6_score"); + } /* Phase 6c: doc sections -> function candidates (doc_link_candidates). */ phase6c_doc_sections(ctx, funcs, func_count, corpus, worker_count); diff --git a/src/pipeline/pipeline.c b/src/pipeline/pipeline.c index 9b5134f58..390073929 100644 --- a/src/pipeline/pipeline.c +++ b/src/pipeline/pipeline.c @@ -181,6 +181,13 @@ void cbm_pipeline_unlock(void) { /* ── Internal state ──────────────────────────────────────────────── */ +/* doc_candidates: unset (the mode decides), or explicitly on / off. */ +typedef enum { + PIPELINE_DOC_CANDIDATES_BY_MODE = 0, + PIPELINE_DOC_CANDIDATES_ON, + PIPELINE_DOC_CANDIDATES_OFF, +} pipeline_doc_candidates_t; + struct cbm_pipeline { cbm_pipeline_frozen_t *frozen; /* NULL for every ordinary caller */ char *repo_path; @@ -278,8 +285,9 @@ struct cbm_pipeline { * cbm_pipeline_free). NULL when the pass found none or did not run. */ cbm_doc_candidate_t *doc_candidates; int doc_candidate_count; - /* doc_candidates setting off (calloc'd false = on, the default). */ - bool doc_candidates_off; + /* The doc_candidates choice. Unset (calloc'd) lets the index mode + * decide: on for full, off for moderate and fast. */ + pipeline_doc_candidates_t doc_candidates_choice; /* Deterministic test-only seam at the final publication boundary. Kept * per pipeline so concurrent test/process activity cannot cross-trigger. */ @@ -878,12 +886,22 @@ void cbm_pipeline_set_persistence(cbm_pipeline_t *p, bool enabled) { void cbm_pipeline_set_doc_candidates_enabled(cbm_pipeline_t *p, bool enabled) { if (p) { - p->doc_candidates_off = !enabled; + p->doc_candidates_choice = + enabled ? PIPELINE_DOC_CANDIDATES_ON : PIPELINE_DOC_CANDIDATES_OFF; } } +/* Unset: the mode decides. p->mode is the effective mode -- a run promoted to + * an existing index's fuller coverage scores candidates like that index. No + * pipeline (a pass driven directly) keeps the pass's historic on. */ bool cbm_pipeline_doc_candidates_enabled(const cbm_pipeline_t *p) { - return !p || !p->doc_candidates_off; + if (!p) { + return true; + } + if (p->doc_candidates_choice != PIPELINE_DOC_CANDIDATES_BY_MODE) { + return p->doc_candidates_choice == PIPELINE_DOC_CANDIDATES_ON; + } + return p->mode == CBM_MODE_FULL; } void cbm_pipeline_set_resource_policy(cbm_pipeline_t *p, @@ -2152,26 +2170,44 @@ static void log_result_arenas(const char *tag, CBMFileResult **cache, int file_c log_result_census(tag, cache, file_count); } +/* Which modes run a predump pass. */ +typedef enum { + PREDUMP_EVERY_MODE, + PREDUMP_SKIP_FAST, + /* Fast mode runs it only for doc -> code candidates (doc_candidates + * explicitly on); the semantic pass then emits no edges (doc-only). */ + PREDUMP_FAST_DOC_CANDIDATES, +} predump_gate_t; + +static bool predump_pass_runs(const cbm_pipeline_t *p, predump_gate_t gate) { + /* Compare explicitly against FAST rather than `> MODERATE` so a heavier + * mode with a larger value is never mistaken for a lighter one. */ + if (p->mode != CBM_MODE_FAST || gate == PREDUMP_EVERY_MODE) { + return true; + } + return gate == PREDUMP_FAST_DOC_CANDIDATES && cbm_pipeline_doc_candidates_enabled(p); +} + /* Results are gone after resolve; so is the store. Logs what spill did. */ static void run_predump_passes(cbm_pipeline_t *p, cbm_pipeline_ctx_t *ctx) { static const struct { predump_pass_fn fn; const char *name; - bool moderate_only; /* true = skip in fast mode */ + predump_gate_t gate; } passes[] = { - {predump_deco, "decorator_tags", false}, - {predump_cfg, "configlink", false}, - {predump_operator, "operator", false}, - {predump_doclinks, "doclinks", false}, - {predump_route, "route_match", false}, - {predump_ensemble, "ensemble_routing", false}, - {predump_sim, "similarity", true}, - {predump_sem, "semantic_edges", true}, - {predump_complexity, "complexity", false}, + {predump_deco, "decorator_tags", PREDUMP_EVERY_MODE}, + {predump_cfg, "configlink", PREDUMP_EVERY_MODE}, + {predump_operator, "operator", PREDUMP_EVERY_MODE}, + {predump_doclinks, "doclinks", PREDUMP_EVERY_MODE}, + {predump_route, "route_match", PREDUMP_EVERY_MODE}, + {predump_ensemble, "ensemble_routing", PREDUMP_EVERY_MODE}, + {predump_sim, "similarity", PREDUMP_SKIP_FAST}, + {predump_sem, "semantic_edges", PREDUMP_FAST_DOC_CANDIDATES}, + {predump_complexity, "complexity", PREDUMP_EVERY_MODE}, /* Importance runs LAST: it reads CALLS/USAGE (extraction) and TESTS * (pass_tests, which run_post_extraction runs before this loop), so * every edge type its score depends on already exists here. */ - {predump_importance, "importance", false}, + {predump_importance, "importance", PREDUMP_EVERY_MODE}, }; /* Derived from the table, never hand-written. A hand-written count that * lags a newly appended entry silently skips the LAST pass while every @@ -2180,11 +2216,7 @@ static void run_predump_passes(cbm_pipeline_t *p, cbm_pipeline_ctx_t *ctx) { enum { PREDUMP_PASS_COUNT = (int)(sizeof(passes) / sizeof(passes[0])) }; struct timespec t; for (int i = 0; i < PREDUMP_PASS_COUNT && !check_cancel(p); i++) { - /* "moderate_only" passes (similarity/semantic edges) run in FULL, - * MODERATE and ADVANCED — they are skipped only in FAST. Compare - * explicitly against FAST rather than `> MODERATE` so ADVANCED - * (numerically 3) is not mistaken for a lighter mode than FULL. */ - if (passes[i].moderate_only && p->mode == CBM_MODE_FAST) { + if (!predump_pass_runs(p, passes[i].gate)) { continue; } cbm_clock_gettime(CLOCK_MONOTONIC, &t); diff --git a/src/pipeline/pipeline.h b/src/pipeline/pipeline.h index fa7c88def..b0ba5bc15 100644 --- a/src/pipeline/pipeline.h +++ b/src/pipeline/pipeline.h @@ -56,10 +56,14 @@ void cbm_pipeline_set_persistence(cbm_pipeline_t *p, bool enabled); /* Apply a validated discovery resource policy. The value is copied. */ void cbm_pipeline_set_resource_policy(cbm_pipeline_t *p, const cbm_index_resource_policy_t *policy); -/* Doc -> code candidates (the doc_candidates setting; default on). Off skips - * scoring documentation sections against code and leaves the project with no - * doc_link_candidates rows; nodes and edges are unchanged. The scoring grows - * with sections x functions, which is the cost on very large repositories. */ +/* Doc -> code candidates (the doc_candidates setting). Never set, the index + * mode decides: on for full, off for moderate and fast. The setter makes an + * explicit choice that wins in every mode. Off skips scoring documentation + * sections against code and leaves the project with no doc_link_candidates + * rows; nodes and edges are unchanged. On in fast mode scores them without + * fast's skipped SIMILAR_TO / SEMANTICALLY_RELATED edges. The scoring grows + * with sections x functions, which is the cost on very large repositories. + * enabled() reports the effective choice for the pipeline's current mode. */ void cbm_pipeline_set_doc_candidates_enabled(cbm_pipeline_t *p, bool enabled); bool cbm_pipeline_doc_candidates_enabled(const cbm_pipeline_t *p); diff --git a/src/pipeline/pipeline_incremental.c b/src/pipeline/pipeline_incremental.c index 1ee95389a..f2991b4e7 100644 --- a/src/pipeline/pipeline_incremental.c +++ b/src/pipeline/pipeline_incremental.c @@ -2098,7 +2098,9 @@ static int run_postpasses(cbm_pipeline_ctx_t *ctx, cbm_file_info_t *changed_file return rc < 0 ? rc : CBM_NOT_FOUND; } - /* SIMILAR_TO + SEMANTICALLY_RELATED edges only in moderate/full modes */ + /* SIMILAR_TO + SEMANTICALLY_RELATED edges only in moderate/full modes. + * Fast mode runs the semantic pass only for doc -> code candidates + * (doc_candidates explicitly on), doc-only: no edges. */ if (ctx->mode <= CBM_MODE_MODERATE) { cbm_clock_gettime(CLOCK_MONOTONIC, &t); rc = cbm_pipeline_pass_similarity(ctx); @@ -2107,7 +2109,8 @@ static int run_postpasses(cbm_pipeline_ctx_t *ctx, cbm_file_info_t *changed_file if (rc < 0 || cbm_pipeline_check_cancel(ctx)) { return rc < 0 ? rc : CBM_NOT_FOUND; } - + } + if (ctx->mode <= CBM_MODE_MODERATE || cbm_pipeline_doc_candidates_enabled(ctx->pipeline)) { cbm_clock_gettime(CLOCK_MONOTONIC, &t); rc = cbm_pipeline_pass_semantic_edges(ctx); cbm_log_info("pass.timing", "pass", "incr_semantic_edges", "elapsed_ms", diff --git a/tests/test_daemon_application.c b/tests/test_daemon_application.c index e3b684d83..e4f65adca 100644 --- a/tests/test_daemon_application.c +++ b/tests/test_daemon_application.c @@ -2810,6 +2810,79 @@ TEST(daemon_application_programmatic_index_injects_resource_policy) { ASSERT_TRUE(stopped); PASS(); } + +/* The doc_candidates key a programmatic (UI / watcher) index hands its worker + * with the setting stored as value (NULL: unset): 1 true, 0 false, -1 absent, + * -2 on a failure. */ +static int app_programmatic_index_doc_candidates(const char *value) { + /* th_mktempdir returns one static buffer: copy each path. */ + char *made = th_mktempdir("cbm_app_doccand_root"); + char *root = made ? strdup(made) : NULL; + made = th_mktempdir("cbm_app_doccand_cache"); + char *cache = made ? strdup(made) : NULL; + cbm_config_t *stored_config = cache ? cbm_config_open(cache) : NULL; + bool configured = + root && stored_config && + (!value || cbm_config_set(stored_config, CBM_CONFIG_DOC_CANDIDATES, value) == 0); + + app_fake_worker_context_t fake; + app_fake_worker_context_init(&fake); + atomic_store(&fake.allow_completion, true); + cbm_daemon_application_worker_ops_t worker_ops = { + .context = &fake, + .start = app_fake_worker_start, + .poll = app_fake_worker_poll, + .cancel = app_fake_worker_cancel, + .log_path = app_fake_worker_log_path, + .destroy = app_fake_worker_destroy, + }; + cbm_daemon_application_config_t config = { + .config = stored_config, + .worker_ops = &worker_ops, + }; + cbm_daemon_application_t *application = configured ? cbm_daemon_application_new(&config) : NULL; + int index_rc = + application ? cbm_daemon_application_index(application, "doccand-programmatic", root) : -1; + int forwarded = -2; + if (index_rc == 0 && app_wait_for_atomic_bool(&fake.args_captured[0], true)) { + const char *args = fake.args_json[0]; + yyjson_doc *document = args ? yyjson_read(args, strlen(args), 0) : NULL; + yyjson_val *args_root = document ? yyjson_doc_get_root(document) : NULL; + yyjson_val *doc_candidates = args_root && yyjson_is_obj(args_root) + ? yyjson_obj_get(args_root, "doc_candidates") + : NULL; + forwarded = !args_root ? -2 + : !doc_candidates ? -1 + : !yyjson_is_bool(doc_candidates) ? -2 + : yyjson_get_bool(doc_candidates) ? 1 + : 0; + yyjson_doc_free(document); + } + bool stopped = application && cbm_daemon_application_shutdown(application, APP_TEST_TIMEOUT_MS); + + cbm_daemon_application_free(application); + cbm_config_close(stored_config); + th_cleanup(root); + th_cleanup(cache); + free(root); + free(cache); + return stopped ? forwarded : -2; +} + +/* A doc_candidates setting the user set reaches a programmatic index's + * worker, which has no config of its own; unset (or unparseable) it is not + * forwarded, so the worker's index mode decides. */ +TEST(daemon_application_programmatic_index_forwards_set_doc_candidates) { + int unset = app_programmatic_index_doc_candidates(NULL); + int off = app_programmatic_index_doc_candidates("false"); + int on = app_programmatic_index_doc_candidates("on"); + int garbage = app_programmatic_index_doc_candidates("maybe"); + ASSERT_EQ(unset, -1); + ASSERT_EQ(off, 0); + ASSERT_EQ(on, 1); + ASSERT_EQ(garbage, -1); + PASS(); +} TEST(daemon_application_auto_index_honors_tracked_file_limit) { app_env_backup_t cache_environment; bool cache_saved = app_env_backup_capture(&cache_environment, "CBM_CACHE_DIR"); @@ -6852,6 +6925,7 @@ SUITE(daemon_application) { RUN_TEST(daemon_application_unnamed_status_keeps_path_derived_key_issue2134); RUN_TEST(daemon_application_unnamed_async_and_status_refuse_ambiguous_root_issue2134); RUN_TEST(daemon_application_programmatic_index_injects_resource_policy); + RUN_TEST(daemon_application_programmatic_index_forwards_set_doc_candidates); RUN_TEST(daemon_application_auto_index_honors_tracked_file_limit); RUN_TEST(daemon_application_auto_index_file_count_handles_literal_metacharacter_path); RUN_TEST(daemon_application_auto_index_file_count_supports_non_git_roots); diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 7d044d35e..907471f30 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -15371,9 +15371,10 @@ static int doccand_index(cbm_mcp_server_t *srv, const char *cache, const char *r return ok ? doccand_rows(cache) : -1; } -/* index_repository doc_candidates: an explicit false stores no candidate - * rows; without the argument the doc_candidates setting decides (default on, - * off stores none); an explicit true wins over the setting. */ +/* index_repository doc_candidates (full mode): an explicit false stores no + * candidate rows; without the argument a set doc_candidates setting decides + * (unset: the mode decides, on in full; off stores none); an explicit true + * wins over the setting. */ TEST(tool_index_repository_doc_candidates_switch) { char repo[256]; snprintf(repo, sizeof(repo), "/tmp/cbm-doccand-repo-XXXXXX"); @@ -15410,6 +15411,162 @@ TEST(tool_index_repository_doc_candidates_switch) { PASS(); } +/* Moderate mode: without an argument or a set doc_candidates setting the + * mode decides (off: no rows); an explicit true and a setting set to true + * each turn it on; an unparseable setting is not a choice and leaves the + * mode default. */ +TEST(tool_index_repository_doc_candidates_follow_mode) { + char repo[256]; + snprintf(repo, sizeof(repo), "/tmp/cbm-doccand-mrepo-XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(repo)); + char cache[256]; + snprintf(cache, sizeof(cache), "/tmp/cbm-doccand-mcache-XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(cache)); + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? strdup(saved) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + doccand_fixture(repo); + cbm_config_t *cfg = cbm_config_open(cache); + ASSERT_NOT_NULL(cfg); + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_mcp_server_set_config(srv, cfg); + + int moderate_default = doccand_index(srv, cache, repo, ",\"mode\":\"moderate\""); + int moderate_on = + doccand_index(srv, cache, repo, ",\"mode\":\"moderate\",\"doc_candidates\":true"); + cbm_config_set(cfg, CBM_CONFIG_DOC_CANDIDATES, "true"); + int moderate_setting_on = doccand_index(srv, cache, repo, ",\"mode\":\"moderate\""); + cbm_config_set(cfg, CBM_CONFIG_DOC_CANDIDATES, "maybe"); + int moderate_setting_garbage = doccand_index(srv, cache, repo, ",\"mode\":\"moderate\""); + int full_setting_garbage = doccand_index(srv, cache, repo, ""); + + cbm_mcp_server_free(srv); + cbm_config_close(cfg); + restore_cache_dir(saved_copy); + free(saved_copy); + th_rmtree(cache); + th_rmtree(repo); + ASSERT_EQ(moderate_default, 0); + ASSERT_GT(moderate_on, 0); + ASSERT_GT(moderate_setting_on, 0); + ASSERT_EQ(moderate_setting_garbage, 0); + ASSERT_GT(full_setting_garbage, 0); + PASS(); +} + +/* The worker arguments a daemon session hands its index executor. */ +typedef struct { + char *args; + int calls; +} doccand_executor_capture_t; + +static char *doccand_capture_executor(void *context, const char *repo_path, const char *args_json, + bool async) { + (void)repo_path; + (void)async; + doccand_executor_capture_t *capture = context; + free(capture->args); + capture->args = args_json ? strdup(args_json) : NULL; + capture->calls++; + return cbm_mcp_text_result("{\"status\":\"indexed\"}", false); +} + +/* The doc_candidates key of captured worker args: 1 true, 0 false, -1 absent, + * -2 not a single boolean (or unparseable args). */ +static int doccand_forwarded(const doccand_executor_capture_t *capture) { + yyjson_doc *doc = capture->args ? yyjson_read(capture->args, strlen(capture->args), 0) : NULL; + yyjson_val *root = doc ? yyjson_doc_get_root(doc) : NULL; + if (!root || !yyjson_is_obj(root)) { + yyjson_doc_free(doc); + return -2; + } + int keys = 0; + int value = -1; + size_t idx; + size_t max; + yyjson_val *key; + yyjson_val *val; + yyjson_obj_foreach(root, idx, max, key, val) { + if (strcmp(yyjson_get_str(key), "doc_candidates") == 0) { + keys++; + value = yyjson_is_bool(val) ? (yyjson_get_bool(val) ? 1 : 0) : -2; + } + } + yyjson_doc_free(doc); + return keys > 1 ? -2 : value; +} + +static int doccand_forward_call(cbm_mcp_server_t *srv, doccand_executor_capture_t *capture, + const char *repo, const char *extra) { + char args[1024]; + snprintf(args, sizeof(args), "{\"repo_path\":\"%s\"%s}", repo, extra); + free(capture->args); + capture->args = NULL; + int before = capture->calls; + char *resp = cbm_mcp_handle_tool(srv, "index_repository", args); + free(resp); + return capture->calls == before + 1 ? doccand_forwarded(capture) : -3; +} + +/* A daemon session forwards doc_candidates only when it is explicit: an + * argument (caller duplicates replaced by the one resolved value) or a + * setting the user set to a boolean. Otherwise no key reaches the worker and + * the worker's pipeline lets the index mode decide. */ +TEST(tool_index_repository_doc_candidates_forwarded_only_when_explicit) { + char repo[256]; + snprintf(repo, sizeof(repo), "/tmp/cbm-doccand-frepo-XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(repo)); + char cache[256]; + snprintf(cache, sizeof(cache), "/tmp/cbm-doccand-fcache-XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(cache)); + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? strdup(saved) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + cbm_config_t *cfg = cbm_config_open(cache); + ASSERT_NOT_NULL(cfg); + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_mcp_server_set_config(srv, cfg); + doccand_executor_capture_t capture = {0}; + cbm_mcp_server_set_index_executor(srv, doccand_capture_executor, &capture); + + int unset = doccand_forward_call(srv, &capture, repo, ""); + int unset_moderate = doccand_forward_call(srv, &capture, repo, ",\"mode\":\"moderate\""); + int arg_off = doccand_forward_call(srv, &capture, repo, ",\"doc_candidates\":false"); + int arg_on_fast = + doccand_forward_call(srv, &capture, repo, ",\"mode\":\"fast\",\"doc_candidates\":true"); + int arg_not_bool = doccand_forward_call(srv, &capture, repo, ",\"doc_candidates\":\"yes\""); + cbm_config_set(cfg, CBM_CONFIG_DOC_CANDIDATES, "true"); + int setting_on = doccand_forward_call(srv, &capture, repo, ",\"mode\":\"fast\""); + int arg_over_setting = doccand_forward_call(srv, &capture, repo, ",\"doc_candidates\":false"); + cbm_config_set(cfg, CBM_CONFIG_DOC_CANDIDATES, "off"); + int setting_off = doccand_forward_call(srv, &capture, repo, ""); + cbm_config_set(cfg, CBM_CONFIG_DOC_CANDIDATES, "maybe"); + int setting_garbage = doccand_forward_call(srv, &capture, repo, ""); + cbm_config_delete(cfg, CBM_CONFIG_DOC_CANDIDATES); + int setting_reset = doccand_forward_call(srv, &capture, repo, ""); + + cbm_mcp_server_free(srv); + free(capture.args); + cbm_config_close(cfg); + restore_cache_dir(saved_copy); + free(saved_copy); + th_rmtree(cache); + th_rmtree(repo); + ASSERT_EQ(unset, -1); + ASSERT_EQ(unset_moderate, -1); + ASSERT_EQ(arg_off, 0); + ASSERT_EQ(arg_on_fast, 1); + ASSERT_EQ(arg_not_bool, -1); + ASSERT_EQ(setting_on, 1); + ASSERT_EQ(arg_over_setting, 0); + ASSERT_EQ(setting_off, 0); + ASSERT_EQ(setting_garbage, -1); + ASSERT_EQ(setting_reset, -1); + PASS(); +} + /* Same gap, opposite outcome: a project name that was never indexed has no * stored root_path to resolve, so it must still fail with the same clear * "repo_path is required" error rather than a resolver crash or silent @@ -24689,6 +24846,8 @@ SUITE(mcp) { RUN_TEST(tool_index_repository_reports_store_backed_adr); RUN_TEST(tool_index_repository_resolves_root_path_from_project_name_issue1211); RUN_TEST(tool_index_repository_doc_candidates_switch); + RUN_TEST(tool_index_repository_doc_candidates_follow_mode); + RUN_TEST(tool_index_repository_doc_candidates_forwarded_only_when_explicit); RUN_TEST(tool_index_repository_unknown_project_name_still_requires_repo_path); RUN_TEST(tool_index_repository_dot_uses_absolute_project_key_and_preserves_adr); RUN_TEST(index_repository_relative_path_uses_explicit_session_root); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 55a10f507..7aecf1cef 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -21343,7 +21343,7 @@ static int doc_candidate_row_count(cbm_store_t *s) { /* doc_candidates off: the same repository indexed with the switch off has no * candidate rows and exactly the nodes and edges of the default run, which has - * candidates (the default is on). */ + * candidates (the default is on in full mode). */ TEST(pipeline_doc_candidates_off_stores_none_and_keeps_graph) { char tmp[256]; snprintf(tmp, sizeof(tmp), "/tmp/cbm_doccand_off_XXXXXX"); @@ -21445,6 +21445,187 @@ TEST(pipeline_doc_candidates_off_clears_rows_on_delta) { PASS(); } +/* One index run of the doc-candidate fixture in tmp into db. set < 0 leaves + * doc_candidates unset (the index mode decides); 0 / 1 set it explicitly. */ +typedef struct { + bool enabled_before; /* the switch as the pipeline reports it before the run */ + int rows; /* doc_link_candidates rows; -1 on an error */ + int sem; /* SEMANTICALLY_RELATED edges */ + int sim; /* SIMILAR_TO edges */ + int nodes; + int edges; +} doc_mode_run_t; + +static doc_mode_run_t doc_candidates_mode_run(const char *tmp, const char *db, + cbm_index_mode_t mode, int set) { + doc_mode_run_t r = {.rows = -1}; + cbm_pipeline_incremental_test_reset_faults(); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db, mode); + if (!p) { + return r; + } + if (set >= 0) { + cbm_pipeline_set_doc_candidates_enabled(p, set == 1); + } + r.enabled_before = cbm_pipeline_doc_candidates_enabled(p); + /* A threshold low enough that the fixture's near-duplicates relate, so + * the SEMANTICALLY_RELATED count shows whether the edge phases ran. */ + cbm_setenv("CBM_SEMANTIC_THRESHOLD", "0.3", 1); + int rc = cbm_pipeline_run(p); + cbm_unsetenv("CBM_SEMANTIC_THRESHOLD"); + char project[512]; + snprintf(project, sizeof(project), "%s", cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); + cbm_store_t *s = rc == 0 ? cbm_store_open_path(db) : NULL; + if (!s) { + return r; + } + r.rows = doc_candidate_row_count(s); + r.sem = cbm_store_count_edges_by_type(s, project, "SEMANTICALLY_RELATED"); + r.sim = cbm_store_count_edges_by_type(s, project, "SIMILAR_TO"); + r.nodes = cbm_store_count_nodes(s, project); + r.edges = cbm_store_count_edges(s, project); + cbm_store_close(s); + return r; +} + +/* doc_candidates unset: the index mode decides -- a full index stores + * candidate rows, moderate and fast store none. Moderate still runs the + * semantic pass (its SEMANTICALLY_RELATED edges), only without the doc + * phase. Each run writes a fresh database outside the indexed tree. */ +TEST(pipeline_doc_candidates_default_follows_mode) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_doccand_mode_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + write_doc_candidate_fixture(tmp); + char dbdir[256]; + snprintf(dbdir, sizeof(dbdir), "/tmp/cbm_doccand_mdb_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(dbdir)); + char db_full[512]; + char db_moderate[512]; + char db_fast[512]; + snprintf(db_full, sizeof(db_full), "%s/full.db", dbdir); + snprintf(db_moderate, sizeof(db_moderate), "%s/moderate.db", dbdir); + snprintf(db_fast, sizeof(db_fast), "%s/fast.db", dbdir); + + doc_mode_run_t full = doc_candidates_mode_run(tmp, db_full, CBM_MODE_FULL, -1); + doc_mode_run_t moderate = doc_candidates_mode_run(tmp, db_moderate, CBM_MODE_MODERATE, -1); + doc_mode_run_t fast = doc_candidates_mode_run(tmp, db_fast, CBM_MODE_FAST, -1); + th_rmtree(tmp); + th_rmtree(dbdir); + cbm_pipeline_incremental_test_reset_faults(); + + ASSERT_TRUE(full.enabled_before); + ASSERT_GT(full.rows, 0); + ASSERT_FALSE(moderate.enabled_before); + ASSERT_EQ(moderate.rows, 0); + ASSERT_GT(moderate.sem, 0); + ASSERT_FALSE(fast.enabled_before); + ASSERT_EQ(fast.rows, 0); + ASSERT_EQ(fast.sem, 0); + PASS(); +} + +/* An explicit doc_candidates wins over the mode in both directions: full + + * off stores none, moderate + on and fast + on store rows. Fast keeps its + * contract -- the doc-only semantic pass adds no SEMANTICALLY_RELATED or + * SIMILAR_TO edge, and the graph is exactly fast's default graph. */ +TEST(pipeline_doc_candidates_explicit_wins_over_mode) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_doccand_expl_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + write_doc_candidate_fixture(tmp); + char dbdir[256]; + snprintf(dbdir, sizeof(dbdir), "/tmp/cbm_doccand_edb_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(dbdir)); + char db_full_off[512]; + char db_moderate_on[512]; + char db_fast_on[512]; + char db_fast[512]; + snprintf(db_full_off, sizeof(db_full_off), "%s/full_off.db", dbdir); + snprintf(db_moderate_on, sizeof(db_moderate_on), "%s/moderate_on.db", dbdir); + snprintf(db_fast_on, sizeof(db_fast_on), "%s/fast_on.db", dbdir); + snprintf(db_fast, sizeof(db_fast), "%s/fast.db", dbdir); + + doc_mode_run_t full_off = doc_candidates_mode_run(tmp, db_full_off, CBM_MODE_FULL, 0); + doc_mode_run_t moderate_on = doc_candidates_mode_run(tmp, db_moderate_on, CBM_MODE_MODERATE, 1); + doc_mode_run_t fast_on = doc_candidates_mode_run(tmp, db_fast_on, CBM_MODE_FAST, 1); + doc_mode_run_t fast = doc_candidates_mode_run(tmp, db_fast, CBM_MODE_FAST, -1); + th_rmtree(tmp); + th_rmtree(dbdir); + cbm_pipeline_incremental_test_reset_faults(); + + ASSERT_FALSE(full_off.enabled_before); + ASSERT_EQ(full_off.rows, 0); + ASSERT_TRUE(moderate_on.enabled_before); + ASSERT_GT(moderate_on.rows, 0); + ASSERT_GT(moderate_on.sem, 0); + ASSERT_TRUE(fast_on.enabled_before); + ASSERT_GT(fast_on.rows, 0); + ASSERT_EQ(fast_on.sem, 0); + ASSERT_EQ(fast_on.sim, 0); + ASSERT_EQ(fast.rows, 0); + ASSERT_GT(fast.nodes, 0); + ASSERT_EQ(fast_on.nodes, fast.nodes); + ASSERT_EQ(fast_on.edges, fast.edges); + PASS(); +} + +#if defined(CBM_INCREMENTAL_TEST_API) && CBM_INCREMENTAL_TEST_API +/* The incremental legacy-partial route mirrors the full path: a fast reindex + * with doc_candidates explicitly on recomputes candidate rows over the + * rehydrated project and still adds no SEMANTICALLY_RELATED / SIMILAR_TO + * edge. */ +TEST(pipeline_doc_candidates_fast_on_legacy_incremental) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_doccand_finc_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + write_doc_candidate_fixture(tmp); + char dbdir[256]; + snprintf(dbdir, sizeof(dbdir), "/tmp/cbm_doccand_fidb_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(dbdir)); + char db[512]; + snprintf(db, sizeof(db), "%s/fast.db", dbdir); + + doc_mode_run_t first = doc_candidates_mode_run(tmp, db, CBM_MODE_FAST, -1); + char edited[512]; + snprintf(edited, sizeof(edited), "%s/User_config.go", tmp); + FILE *ef = cbm_fopen(edited, "a"); + ASSERT_NOT_NULL(ef); + (void)fputs("\n// edited after the first index\n", ef); + (void)fclose(ef); + cbm_pipeline_incremental_test_reset_faults(); + cbm_pipeline_incremental_test_force_legacy_partial_once(); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_set_doc_candidates_enabled(p, true); + cbm_setenv("CBM_SEMANTIC_THRESHOLD", "0.3", 1); + int rc = cbm_pipeline_run(p); + cbm_unsetenv("CBM_SEMANTIC_THRESHOLD"); + ASSERT_EQ(rc, 0); + cbm_incremental_route_t route = cbm_pipeline_incremental_test_last_route(); + char project[512]; + snprintf(project, sizeof(project), "%s", cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); + cbm_store_t *s = cbm_store_open_path(db); + ASSERT_NOT_NULL(s); + int rows = doc_candidate_row_count(s); + int sem = cbm_store_count_edges_by_type(s, project, "SEMANTICALLY_RELATED"); + int sim = cbm_store_count_edges_by_type(s, project, "SIMILAR_TO"); + cbm_store_close(s); + th_rmtree(tmp); + th_rmtree(dbdir); + cbm_pipeline_incremental_test_reset_faults(); + + ASSERT_EQ(first.rows, 0); + ASSERT_EQ(route, CBM_INCREMENTAL_ROUTE_LEGACY_PARTIAL); + ASSERT_GT(rows, 0); + ASSERT_EQ(sem, 0); + ASSERT_EQ(sim, 0); + PASS(); +} +#endif + /* One Python family per subject: parse / load / validate / write, each with * a docstring, so the functions' corpus has the subject words to match. */ static void write_py_family(const char *base, const char *subject) { @@ -22870,6 +23051,11 @@ SUITE(pipeline) { RUN_TEST(pipeline_doc_candidates_published_and_kept_by_delta); RUN_TEST(pipeline_doc_candidates_off_stores_none_and_keeps_graph); RUN_TEST(pipeline_doc_candidates_off_clears_rows_on_delta); + RUN_TEST(pipeline_doc_candidates_default_follows_mode); + RUN_TEST(pipeline_doc_candidates_explicit_wins_over_mode); +#if defined(CBM_INCREMENTAL_TEST_API) && CBM_INCREMENTAL_TEST_API + RUN_TEST(pipeline_doc_candidates_fast_on_legacy_incremental); +#endif RUN_TEST(pipeline_doc_candidates_skip_exact_links_and_use_format_curves); RUN_TEST(pipeline_doc_candidates_home_position_and_kinds); RUN_TEST(pipeline_doc_candidates_diagram_family_curves);