Skip to content

Commit 8683c1f

Browse files
Your Nameclaude
andcommitted
feat(derived-artifacts): P1 keystone - bucketed version consts + DerivedStatus + embedding-space marker
Executes P1 of docs/plans/2026-08-08-derived-artifact-hardening-execution-plan.md, the keystone that gates the rest of the derived-artifact hardening backlog: every later phase changes extraction/derivation logic, and on a hand-bumped policy_version alone, shipping any of them risks silently stale incremental indexes on already-deployed installs. - SOURCE_EXTRACTION_VERSION / GRAPH_DERIVATION_VERSION / PACKAGE_GRAPH_VERSION: new version consts folded into InputCatalog::index_input_snapshot's existing config_material/context_material fingerprint buckets (refresh.rs). Extends the index_input_state mechanism that already existed rather than building a new one - source-extraction bumps trigger a full reparse, graph/package bumps trigger the cheaper graph-rebuild-only path. - derived_artifact_versions.rs: 3 drift-guard tests (UPDATE_TOOLSNAPS-style) hashing each version's frozen-fixture output against a checked-in literal, so a behavior change without a matching version bump is caught in CI. - DerivedStatus (Ready/NeedsBaseline/Stale only - Unsupported/Failed/Disabled omitted, no real signal backs them) surfaced as indexing_status.derived_status.{overall,source_facts,graph_facts}. Backed by new index_input_bucket_drift (additive, index_input_drift itself untouched) since type_relations/symbol_effects and symbol_digests/ package_dependencies need independently accurate freshness - the existing 4-way IndexInputDrift short-circuits on a config mismatch without checking context, which would wrongly mark both stale if reused naively per-bucket. - EMBEDDING_SPACE_VERSION + heal_embedding_space_mismatch: extends the existing dimension-only self-heal (heal_dimension_mismatch) to also catch a same-dimension model swap or a symbol_doc-formatting version bump, which a stored-vector blob-length peek alone cannot distinguish from "nothing changed". Wired into both real production call sites (bootstrap_embeddings, calm-cli's index subcommand) rather than threaded through create_embedding_table/create_chunk_embedding_table's signatures, which would have forced ~17 call-site edits for zero behavioral gain in tests. Also includes the remaining pre-existing, adjacent fixes that happened to land in the same two files this session: Poetry dependency-group parsing and orphaned embedding-vector pruning (package_deps.rs/embedding.rs). Verified: full `cargo test --workspace --features embeddings` green (1054 calm-core + 365 calm-server + all other packages, 0 failures), clippy -D warnings clean, rustfmt clean, indexing_status toolsnap regenerated. Deliberately deferred to a later phase (P7, bundle hardening): folding embedding_model_id into bundle.rs's config_fingerprint - that fingerprint's own doc comment scopes it to file-coverage config only (languages/ignore), so this belongs as an independent field there instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent fb58b2b commit 8683c1f

12 files changed

Lines changed: 1171 additions & 2 deletions

File tree

crates/calm-cli/src/main.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -541,6 +541,15 @@ async fn main() -> Result<()> {
541541
// semantic.dimensions (config, possibly stale) — see
542542
// Embedder::load and create_embedding_table's self-heal.
543543
calm_core::embedding::create_embedding_table(&conn, embedder.dim())?;
544+
// P1 (docs/plans/2026-08-08-derived-artifact-hardening-execution-plan.md):
545+
// catches a same-dimension MODEL swap that
546+
// heal_dimension_mismatch (dimension-only) cannot --
547+
// must run before anything is embedded below.
548+
calm_core::embedding::heal_embedding_space_mismatch(
549+
&conn,
550+
&semantic.model,
551+
embedder.dim(),
552+
)?;
544553
let n = calm_core::embedding::embed_pending(&conn, &embedder)?;
545554
calm_core::embedding::create_chunk_embedding_table(&conn, embedder.dim())?;
546555
let nc = calm_core::embedding::embed_pending_chunks(&conn, &embedder)?;

crates/calm-core/src/embedding.rs

Lines changed: 203 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,15 @@ pub const ENABLED: bool = cfg!(feature = "embeddings");
2020
/// HuggingFace Hub — kept as one constant so the two can't drift apart.
2121
pub const DEFAULT_MODEL_ID: &str = "minishlab/potion-code-16M";
2222

23+
/// P1 (docs/plans/2026-08-08-derived-artifact-hardening-execution-plan.md):
24+
/// bumped whenever CALM's OWN embedding-input assembly changes shape --
25+
/// today that's just `symbol_doc`'s formatting (the text actually fed to
26+
/// the model). Folded into `heal_embedding_space_mismatch`'s stored marker
27+
/// alongside the model id, so a change here forces the same re-embed a
28+
/// model swap does, even though neither the model id nor the vector
29+
/// dimension changed.
30+
pub const EMBEDDING_SPACE_VERSION: u32 = 1;
31+
2332
/// The text embedded for a symbol: name + signature + docstring. This is
2433
/// Layer 1 of semantic search — *symbol identity*. Layer 2 (`code_chunks` /
2534
/// `code_chunk_vecs`, populated by `indexer::chunker`) embeds the raw code
@@ -580,6 +589,66 @@ mod imp {
580589
Ok(())
581590
}
582591

592+
/// P1: extends the dimension-only self-heal above to catch a
593+
/// same-dimension MODEL swap (or a bump to `EMBEDDING_SPACE_VERSION`
594+
/// after a change to `symbol_doc`'s formatting), which a stored-vector
595+
/// blob-length peek alone cannot distinguish from "nothing changed" --
596+
/// two different models can share a dimension. Call once per process
597+
/// after both `create_embedding_table`/`create_chunk_embedding_table`
598+
/// have run, before embedding anything -- clears BOTH vector tables
599+
/// together (they always share one embedding space) rather than being
600+
/// threaded through each `create_*_table` call individually, which
601+
/// would need `model_id` added to their public signatures and every one
602+
/// of their ~15 test call sites updated for a check that only matters
603+
/// at the two real production call sites
604+
/// (`calm-server::lib::bootstrap_embeddings`, `calm-cli::main`).
605+
pub fn heal_embedding_space_mismatch(
606+
conn: &Connection,
607+
model_id: &str,
608+
dim: usize,
609+
) -> rusqlite::Result<()> {
610+
use rusqlite::OptionalExtension;
611+
612+
conn.execute_batch(
613+
"CREATE TABLE IF NOT EXISTS embedding_space_state (
614+
id INTEGER PRIMARY KEY CHECK (id = 1),
615+
model_id TEXT NOT NULL,
616+
dim INTEGER NOT NULL
617+
);",
618+
)?;
619+
620+
let current_key = format!("{model_id}#v{EMBEDDING_SPACE_VERSION}");
621+
let stored: Option<(String, i64)> = conn
622+
.query_row(
623+
"SELECT model_id, dim FROM embedding_space_state WHERE id = 1",
624+
[],
625+
|r| Ok((r.get(0)?, r.get(1)?)),
626+
)
627+
.optional()?;
628+
let matches = matches!(&stored, Some((m, d)) if *m == current_key && *d == dim as i64);
629+
if !matches {
630+
if stored.is_some() {
631+
tracing::warn!(
632+
"embedding space changed (model/formatting/dim) since the last index \
633+
run -- clearing embedding_vecs/code_chunk_vecs to re-embed from scratch"
634+
);
635+
}
636+
// Best-effort: a table that doesn't exist yet (this process's
637+
// very first run, before `create_embedding_table`/
638+
// `create_chunk_embedding_table`) is not an error here.
639+
let _ = conn.execute("DELETE FROM embedding_vecs", []);
640+
let _ = conn.execute("DELETE FROM code_chunk_vecs", []);
641+
invalidate(symbol_cache(), conn);
642+
invalidate(chunk_cache(), conn);
643+
}
644+
conn.execute(
645+
"INSERT INTO embedding_space_state (id, model_id, dim) VALUES (1, ?1, ?2) \
646+
ON CONFLICT(id) DO UPDATE SET model_id = excluded.model_id, dim = excluded.dim",
647+
rusqlite::params![current_key, dim as i64],
648+
)?;
649+
Ok(())
650+
}
651+
583652
pub fn store_embedding(conn: &Connection, symbol_id: i64, vec: &[f32]) -> rusqlite::Result<()> {
584653
conn.execute(
585654
"INSERT OR REPLACE INTO embedding_vecs(symbol_id, embedding) VALUES (?1, ?2)",
@@ -603,7 +672,9 @@ mod imp {
603672
}
604673

605674
/// Embed every symbol that has no embedding yet; returns how many were added.
675+
/// Prunes orphaned vectors first -- see `prune_orphaned_symbol_vecs`.
606676
pub fn embed_pending(conn: &Connection, embedder: &Embedder) -> rusqlite::Result<usize> {
677+
prune_orphaned_symbol_vecs(conn)?;
607678
let rows: Vec<(i64, String, String, String)> = {
608679
let mut stmt = conn.prepare(
609680
"SELECT id, name, signature, docstring FROM symbols \
@@ -677,6 +748,25 @@ mod imp {
677748
Ok(n)
678749
}
679750

751+
/// Delete `embedding_vecs` rows whose `symbol_id` no longer exists in
752+
/// `symbols` -- the Layer-1 (symbol) counterpart to
753+
/// `prune_orphaned_chunk_vecs`. `symbols.id` is `AUTOINCREMENT`, so
754+
/// every reindex (full or per-file) that drops a symbol never reuses its
755+
/// id -- without this, the dead vector sits in `embedding_vecs` forever,
756+
/// still competing for a KNN top-K slot (`knn`'s subsequent `symbols`
757+
/// lookup then fails for that id, silently shrinking the effective K)
758+
/// on top of the unbounded disk growth across reindex cycles.
759+
pub fn prune_orphaned_symbol_vecs(conn: &Connection) -> rusqlite::Result<usize> {
760+
let n = conn.execute(
761+
"DELETE FROM embedding_vecs WHERE symbol_id NOT IN (SELECT id FROM symbols)",
762+
[],
763+
)?;
764+
if n > 0 {
765+
invalidate(symbol_cache(), conn);
766+
}
767+
Ok(n)
768+
}
769+
680770
/// Embed every Layer-2 code chunk that has no embedding yet; returns how
681771
/// many were added. Prunes orphaned vectors first — see
682772
/// `prune_orphaned_chunk_vecs`.
@@ -816,6 +906,14 @@ mod imp {
816906
Ok(())
817907
}
818908

909+
pub fn heal_embedding_space_mismatch(
910+
_conn: &Connection,
911+
_model_id: &str,
912+
_dim: usize,
913+
) -> rusqlite::Result<()> {
914+
Ok(())
915+
}
916+
819917
/// Always `false` — there's no vendored asset to be unusable when the
820918
/// `embeddings` feature itself is off; `Embedder::load`'s own stub
821919
/// failure below is what surfaces this build's real limitation.
@@ -847,6 +945,9 @@ mod imp {
847945
pub fn embed_pending(_c: &Connection, _e: &Embedder) -> rusqlite::Result<usize> {
848946
Ok(0)
849947
}
948+
pub fn prune_orphaned_symbol_vecs(_c: &Connection) -> rusqlite::Result<usize> {
949+
Ok(0)
950+
}
850951
pub fn knn(_c: &Connection, _q: &[f32], _k: usize) -> rusqlite::Result<Vec<(i64, f64)>> {
851952
Ok(Vec::new())
852953
}
@@ -873,8 +974,9 @@ mod imp {
873974

874975
pub use imp::{
875976
Embedder, chunk_at, create_chunk_embedding_table, create_embedding_table,
876-
default_vendored_asset_unusable, embed_pending, embed_pending_chunks, knn, knn_chunks,
877-
prune_orphaned_chunk_vecs, store_chunk_embedding, store_embedding,
977+
default_vendored_asset_unusable, embed_pending, embed_pending_chunks,
978+
heal_embedding_space_mismatch, knn, knn_chunks, prune_orphaned_chunk_vecs,
979+
prune_orphaned_symbol_vecs, store_chunk_embedding, store_embedding,
878980
};
879981

880982
#[cfg(test)]
@@ -1004,6 +1106,47 @@ mod tests {
10041106
assert_eq!(hits[0].0, 2);
10051107
}
10061108

1109+
#[cfg(feature = "embeddings")]
1110+
#[test]
1111+
fn heal_embedding_space_mismatch_clears_on_model_swap_at_same_dimension() {
1112+
use rusqlite::Connection;
1113+
let conn = Connection::open_in_memory().unwrap();
1114+
crate::db::schema::init_db(&conn).unwrap();
1115+
create_embedding_table(&conn, 3).unwrap();
1116+
store_embedding(&conn, 1, &[1.0, 0.0, 0.0]).unwrap();
1117+
heal_embedding_space_mismatch(&conn, "model-a", 3).unwrap();
1118+
1119+
let count_before: i64 = conn
1120+
.query_row("SELECT COUNT(*) FROM embedding_vecs", [], |r| r.get(0))
1121+
.unwrap();
1122+
assert_eq!(
1123+
count_before, 0,
1124+
"first-ever call (no persisted marker) must clear to establish a fresh baseline"
1125+
);
1126+
1127+
store_embedding(&conn, 3, &[0.0, 0.0, 1.0]).unwrap();
1128+
// Same dimension, DIFFERENT model id -- heal_dimension_mismatch alone
1129+
// (blob-length peek) cannot see this; heal_embedding_space_mismatch
1130+
// must clear anyway.
1131+
heal_embedding_space_mismatch(&conn, "model-b", 3).unwrap();
1132+
let count_after: i64 = conn
1133+
.query_row("SELECT COUNT(*) FROM embedding_vecs", [], |r| r.get(0))
1134+
.unwrap();
1135+
assert_eq!(
1136+
count_after, 0,
1137+
"a same-dimension model swap must still clear stale vectors"
1138+
);
1139+
1140+
// Calling again with the model that's now current must be a no-op
1141+
// (no repeated clearing of freshly-embedded vectors).
1142+
store_embedding(&conn, 2, &[0.0, 1.0, 0.0]).unwrap();
1143+
heal_embedding_space_mismatch(&conn, "model-b", 3).unwrap();
1144+
let count_stable: i64 = conn
1145+
.query_row("SELECT COUNT(*) FROM embedding_vecs", [], |r| r.get(0))
1146+
.unwrap();
1147+
assert_eq!(count_stable, 1, "an unchanged model/dim must not re-clear");
1148+
}
1149+
10071150
/// Defense in depth for anything that manages to leave a mismatched-
10081151
/// length row in the table anyway (e.g. written outside
10091152
/// `create_embedding_table`'s heal path): `knn` must skip it, not feed
@@ -1196,6 +1339,64 @@ mod tests {
11961339
assert_eq!(hits[0].0, 2);
11971340
}
11981341

1342+
#[cfg(feature = "embeddings")]
1343+
#[test]
1344+
fn prune_orphaned_symbol_vecs_removes_only_dangling_rows() {
1345+
use rusqlite::Connection;
1346+
let conn = Connection::open_in_memory().unwrap();
1347+
crate::db::schema::init_db(&conn).unwrap();
1348+
create_embedding_table(&conn, 3).unwrap();
1349+
1350+
// id 2 has a matching symbols row; id 1 is an orphan (e.g. left over
1351+
// from a symbol deleted by a reindex -- symbols.id is AUTOINCREMENT
1352+
// so a fresh symbol never reuses id 1).
1353+
conn.execute(
1354+
"INSERT INTO symbols (id, qualified_name, name, kind, language, path, line_start, line_end) \
1355+
VALUES (2, 'a.py::foo', 'foo', 'function', 'python', 'a.py', 1, 1)",
1356+
[],
1357+
)
1358+
.unwrap();
1359+
store_embedding(&conn, 1, &[1.0, 0.0, 0.0]).unwrap();
1360+
store_embedding(&conn, 2, &[0.0, 1.0, 0.0]).unwrap();
1361+
1362+
let pruned = prune_orphaned_symbol_vecs(&conn).unwrap();
1363+
assert_eq!(pruned, 1, "exactly the dangling id-1 row must be pruned");
1364+
1365+
let hits = knn(&conn, &[0.0, 1.0, 0.0], 10).unwrap();
1366+
assert_eq!(hits.len(), 1);
1367+
assert_eq!(hits[0].0, 2);
1368+
}
1369+
1370+
#[cfg(feature = "embeddings")]
1371+
#[test]
1372+
fn embed_pending_prunes_orphans_before_embedding_new_symbols() {
1373+
use rusqlite::Connection;
1374+
const DIM: usize = 256;
1375+
let conn = Connection::open_in_memory().unwrap();
1376+
crate::db::schema::init_db(&conn).unwrap();
1377+
create_embedding_table(&conn, DIM).unwrap();
1378+
1379+
// Orphan left behind by a prior reindex.
1380+
store_embedding(&conn, 999, &[1.0; DIM]).unwrap();
1381+
1382+
let before: i64 = conn
1383+
.query_row("SELECT COUNT(*) FROM embedding_vecs", [], |r| r.get(0))
1384+
.unwrap();
1385+
assert_eq!(before, 1, "orphan row must exist before embed_pending runs");
1386+
1387+
// No real symbols to embed, but embed_pending must still prune first.
1388+
let embedder = Embedder::load(DEFAULT_MODEL_ID, DIM).unwrap();
1389+
embed_pending(&conn, &embedder).unwrap();
1390+
1391+
let after: i64 = conn
1392+
.query_row("SELECT COUNT(*) FROM embedding_vecs", [], |r| r.get(0))
1393+
.unwrap();
1394+
assert_eq!(
1395+
after, 0,
1396+
"embed_pending must prune the orphan even with no pending symbols"
1397+
);
1398+
}
1399+
11991400
/// KNN latency benchmark: 100k synthetic 256-dim vectors, topK=10, a
12001401
/// *fresh connection per query* — matching real MCP usage
12011402
/// (`make_read_conn` opens a new `Connection` per tool call). The cache

crates/calm-core/src/graph/digest.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,21 @@ fn format_callee(c: &CalleeFact) -> String {
293293
}
294294
}
295295

296+
/// Bumped whenever `compute_digests`'s rendering/rollup logic changes what a
297+
/// digest contains (facts included, role-tag derivation, truncation). Folded
298+
/// into `InputCatalog::index_input_snapshot`'s `context_material` bucket
299+
/// (`indexer::refresh`) alongside `PACKAGE_GRAPH_VERSION` -- both are fully
300+
/// recomputed by every `rebuild_graph`/`incremental_graph_update` call, so a
301+
/// `Context`-class drift (graph rebuild, no reparse) is sufficient to pick up
302+
/// a bump here, unlike `SOURCE_EXTRACTION_VERSION` which needs a full reparse.
303+
/// See docs/plans/2026-08-08-derived-artifact-hardening-execution-plan.md P1.
304+
///
305+
/// A change here is verified by
306+
/// `derived_artifact_versions::graph_derivation_fixture_is_pinned_to_its_version`
307+
/// (crates/calm-core/tests/derived_artifact_versions.rs) -- bump this AND
308+
/// that test's expected hash together, in the same commit, never one alone.
309+
pub const GRAPH_DERIVATION_VERSION: i64 = 1;
310+
296311
/// Recompute every digestable symbol's `symbol_digests` row from current
297312
/// DB state — see the module doc comment for why this is a full
298313
/// DELETE-then-reinsert, not selective invalidation. Call sites: mirrors

0 commit comments

Comments
 (0)