From 808666c4054fba5e6d02fece320ba4307fbb3bd0 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Tue, 19 May 2026 23:59:26 -0700 Subject: [PATCH 1/2] fix(lore): embed-rebuild refuses when no embedder is wired Before this change, EmbedRebuildCommand checked meta.embedder_state, saw "enabled", then proceeded to wipe lore_vectors and re-encode using embed.NewNullEmbedder() as a placeholder. Every encode returned ErrEmbedderDisabled, so coverage stayed at 0% even while lore_health reported the embedder as enabled. Net effect: silent destruction of vectors with no usable rebuild. This change pulls *EmbedDeps via embedFromDeps(ctx, d) (the same contract appraise_cmd uses) and refuses to rebuild when the embedder is not wired: - meta says disabled OR embedder is not wired -> Disabled: true with a reason explaining the wiring gap, and existing vectors are not touched. - meta says enabled AND embedder is wired -> the real embedder is used for the encode loop and coverage returns >0%. The previous "placeholder NullEmbedder pending QUEST-212" block is removed. internal/lore/embed_rebuild_test.go adds two table-free tests: - TestEmbedRebuild_RefusesWhenEmbedderNotWired: seeds initial vectors, calls the handler with stubCommandDeps(nil), asserts Disabled=true and vector rows still match the pre-call count. - TestEmbedRebuild_HappyPathWithWiredEmbedder: seeds entries without vectors, calls the handler with a wired EmbedDeps holding a 384-dim fake embedder, asserts Encoded>0 and lore_vectors row count matches. Closes #88 --- internal/lore/embed_rebuild_cmd.go | 19 ++- internal/lore/embed_rebuild_test.go | 186 ++++++++++++++++++++++++++++ 2 files changed, 199 insertions(+), 6 deletions(-) create mode 100644 internal/lore/embed_rebuild_test.go diff --git a/internal/lore/embed_rebuild_cmd.go b/internal/lore/embed_rebuild_cmd.go index 7ef6839..1963ff8 100644 --- a/internal/lore/embed_rebuild_cmd.go +++ b/internal/lore/embed_rebuild_cmd.go @@ -66,13 +66,20 @@ var EmbedRebuildCommand = &command.Command[EmbedRebuildInput, EmbedRebuildOutput }, nil } - // Use a NullEmbedder as placeholder when no real embedder is wired - // into the CLI deps yet (QUEST-212 wires the production embedder). - // For now the rebuild still performs the reset (zeroes coverage_num, - // flips states to pending) which is the user-visible guarantee. - e := embed.NewNullEmbedder() + ed := embedFromDeps(ctx, d) + if !ed.Enabled() { + return EmbedRebuildOutput{ + ProjectID: pid, + Disabled: true, + Reason: "embedder runtime is not wired into this surface (QUEST-212 pending); embed-rebuild cannot encode and will not wipe existing vectors", + }, nil + } - modelID := report.ModelID + e := ed.Embedder + modelID := ed.ModelID + if modelID == "" { + modelID = report.ModelID + } if modelID == "" { modelID = string(MetaEmbedderModelID) } diff --git a/internal/lore/embed_rebuild_test.go b/internal/lore/embed_rebuild_test.go new file mode 100644 index 0000000..11a2ad2 --- /dev/null +++ b/internal/lore/embed_rebuild_test.go @@ -0,0 +1,186 @@ +package lore + +import ( + "context" + "database/sql" + "path/filepath" + "testing" + "time" + + "github.com/mathomhaus/guild/internal/lore/embed" + "github.com/mathomhaus/guild/internal/storage" +) + +const ( + embedRebuildTestModelID = "test-model" + embedRebuildFakeDim = 384 +) + +type embedRebuildFakeEmbedder struct{} + +func (embedRebuildFakeEmbedder) Embed(_ context.Context, text string) ([]float32, error) { + vec := make([]float32, embedRebuildFakeDim) + seed := float32(len(text)%7 + 1) + for i := range vec { + vec[i] = seed + float32(i%5)/10 + } + return vec, nil +} + +func (embedRebuildFakeEmbedder) Dimension() int { + return embedRebuildFakeDim +} + +func TestEmbedRebuild_RefusesWhenEmbedderNotWired(t *testing.T) { + ctx := context.Background() + dbPath, db := newEmbedRebuildTestDB(t, "proj") + enableEmbedRebuildMeta(t, db) + entryIDs := seedEmbedRebuildEntries(t, ctx, db, "proj") + insertEmbedRebuildVectors(t, ctx, db, entryIDs) + + before := countEmbedRebuildVectors(t, ctx, db) + if before == 0 { + t.Fatal("test setup did not write initial vectors") + } + + deps := stubCommandDeps(nil) + deps.OpenDB = func(ctx context.Context) (*sql.DB, error) { + return storage.Open(ctx, dbPath) + } + deps.ResolveProj = func(_ context.Context, _ string) (string, error) { + return "proj", nil + } + + out, err := EmbedRebuildCommand.Handler(ctx, deps, EmbedRebuildInput{}) + if err != nil { + t.Fatalf("embed-rebuild: %v", err) + } + if !out.Disabled { + t.Fatalf("Disabled = false, want true") + } + if out.Reason == "" { + t.Fatal("Reason is empty") + } + + after := countEmbedRebuildVectors(t, ctx, db) + if after != before { + t.Fatalf("vector rows after refusal = %d, want %d", after, before) + } +} + +func TestEmbedRebuild_HappyPathWithWiredEmbedder(t *testing.T) { + ctx := context.Background() + dbPath, db := newEmbedRebuildTestDB(t, "proj") + enableEmbedRebuildMeta(t, db) + seedEmbedRebuildEntries(t, ctx, db, "proj") + + deps := stubCommandDeps(&EmbedDeps{ + Embedder: embedRebuildFakeEmbedder{}, + ModelID: embedRebuildTestModelID, + }) + deps.OpenDB = func(ctx context.Context) (*sql.DB, error) { + return storage.Open(ctx, dbPath) + } + deps.ResolveProj = func(_ context.Context, _ string) (string, error) { + return "proj", nil + } + + out, err := EmbedRebuildCommand.Handler(ctx, deps, EmbedRebuildInput{}) + if err != nil { + t.Fatalf("embed-rebuild: %v", err) + } + if out.Disabled { + t.Fatalf("Disabled = true, want false: %s", out.Reason) + } + if out.Encoded == 0 { + t.Fatal("Encoded = 0, want > 0") + } + if got := countEmbedRebuildVectors(t, ctx, db); got != out.Encoded { + t.Fatalf("vector rows = %d, want encoded count %d", got, out.Encoded) + } +} + +func newEmbedRebuildTestDB(t *testing.T, projectID string) (string, *sql.DB) { + t.Helper() + ctx := context.Background() + dbPath := filepath.Join(t.TempDir(), "lore.db") + db, err := storage.Open(ctx, dbPath) + if err != nil { + t.Fatalf("storage.Open: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + if err := storage.MigrateTo(ctx, db, "test", nil); err != nil { + t.Fatalf("storage.MigrateTo: %v", err) + } + if _, err := db.ExecContext(ctx, + `INSERT INTO projects (id, path) VALUES (?, ?)`, + projectID, "/fake/"+projectID, + ); err != nil { + t.Fatalf("insert project %q: %v", projectID, err) + } + return dbPath, db +} + +func enableEmbedRebuildMeta(t *testing.T, db *sql.DB) { + t.Helper() + ctx := context.Background() + for _, kv := range []struct { + key string + value string + }{ + {"embedder_state", string(embed.EmbedderStateEnabled)}, + {"embedder_model_id", embedRebuildTestModelID}, + } { + if _, err := db.ExecContext(ctx, + `INSERT INTO meta (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value`, + kv.key, kv.value, + ); err != nil { + t.Fatalf("set meta %s: %v", kv.key, err) + } + } +} + +func seedEmbedRebuildEntries(t *testing.T, ctx context.Context, db *sql.DB, projectID string) []int64 { + t.Helper() + now := time.Date(2026, 5, 20, 12, 0, 0, 0, time.UTC) + entryIDs := make([]int64, 0, 2) + for i, title := range []string{"first rebuild entry", "second rebuild entry"} { + res, err := Inscribe(ctx, db, &InscribeParams{ + ProjectID: projectID, + Kind: KindObservation, + Title: title, + Summary: title + " has deterministic content for vector rebuild testing.", + Topic: "embed-rebuild", + Now: now.Add(time.Duration(i) * time.Minute), + }) + if err != nil { + t.Fatalf("inscribe %q: %v", title, err) + } + entryIDs = append(entryIDs, res.Entry.ID) + } + return entryIDs +} + +func insertEmbedRebuildVectors(t *testing.T, ctx context.Context, db *sql.DB, entryIDs []int64) { + t.Helper() + for _, entryID := range entryIDs { + if _, err := db.ExecContext(ctx, + `INSERT INTO lore_vectors + (entry_id, model_id, dim, vec, encoded_at, content_hash) + VALUES (?, ?, ?, ?, ?, ?)`, + entryID, embedRebuildTestModelID, 64, make([]byte, 64), time.Now().UTC().Unix(), "hash", + ); err != nil { + t.Fatalf("insert lore_vectors for entry %d: %v", entryID, err) + } + } +} + +func countEmbedRebuildVectors(t *testing.T, ctx context.Context, db *sql.DB) int64 { + t.Helper() + var count int64 + if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM lore_vectors`).Scan(&count); err != nil { + t.Fatalf("count lore_vectors: %v", err) + } + return count +} From 1fbd095484be58f51d8d223c9914321850ba9c81 Mon Sep 17 00:00:00 2001 From: Kunal Lanjewar <5488221+kunallanjewar@users.noreply.github.com> Date: Fri, 12 Jun 2026 11:39:58 -0700 Subject: [PATCH 2/2] fix(lore): drop internal task id from user-facing embed-rebuild skip reason --- internal/lore/embed_rebuild_cmd.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/lore/embed_rebuild_cmd.go b/internal/lore/embed_rebuild_cmd.go index 1963ff8..0c99426 100644 --- a/internal/lore/embed_rebuild_cmd.go +++ b/internal/lore/embed_rebuild_cmd.go @@ -71,7 +71,7 @@ var EmbedRebuildCommand = &command.Command[EmbedRebuildInput, EmbedRebuildOutput return EmbedRebuildOutput{ ProjectID: pid, Disabled: true, - Reason: "embedder runtime is not wired into this surface (QUEST-212 pending); embed-rebuild cannot encode and will not wipe existing vectors", + Reason: "embedder runtime is not wired into this surface; embed-rebuild cannot encode and will not wipe existing vectors", }, nil }