akm-cli 0.9.15 → 0.9.16-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/CHANGELOG.md +144 -0
  2. package/dist/assets/tasks/core/index-refresh.yml +1 -1
  3. package/dist/cli/retired-commands.js +2 -0
  4. package/dist/cli/unknown-flags.js +36 -3
  5. package/dist/commands/improve/collapse-detector.js +2 -2
  6. package/dist/commands/improve/consolidate.js +6 -4
  7. package/dist/commands/improve/improve-cli.js +1 -1
  8. package/dist/commands/proposal/repository.js +12 -3
  9. package/dist/commands/read/curate.js +34 -44
  10. package/dist/commands/read/search.js +50 -2
  11. package/dist/commands/sources/index-status.js +99 -0
  12. package/dist/commands/sources/info.js +8 -8
  13. package/dist/commands/sources/installed-stashes.js +33 -12
  14. package/dist/commands/sources/source-add.js +21 -6
  15. package/dist/commands/sources/stash-cli.js +119 -111
  16. package/dist/core/adapter/adapters/akm-adapter.js +35 -3
  17. package/dist/core/adapter/adapters/akm-metadata.js +11 -1
  18. package/dist/core/asset/asset-placement.js +35 -0
  19. package/dist/core/config/schema/embedding.js +7 -30
  20. package/dist/core/config/schema/search.js +11 -9
  21. package/dist/core/errors.js +5 -2
  22. package/dist/core/hash.js +18 -0
  23. package/dist/core/maintenance-barrier.js +8 -6
  24. package/dist/core/paths.js +0 -11
  25. package/dist/core/run-lock.js +5 -2
  26. package/dist/core/state/migrations.js +26 -1
  27. package/dist/core/state-db.js +63 -27
  28. package/dist/indexer/drain.js +306 -0
  29. package/dist/indexer/embedding-identity.js +20 -0
  30. package/dist/indexer/enrich.js +260 -0
  31. package/dist/indexer/ensure-index.js +5 -0
  32. package/dist/indexer/index-written-assets.js +133 -171
  33. package/dist/indexer/indexer.js +458 -1621
  34. package/dist/indexer/lookup/adapter-concept-owner.js +19 -5
  35. package/dist/indexer/passes/metadata.js +18 -1
  36. package/dist/indexer/reconcile.js +890 -0
  37. package/dist/indexer/scan/drain-dir.js +27 -70
  38. package/dist/indexer/scan/parse-file.js +66 -0
  39. package/dist/indexer/search/db-search.js +373 -89
  40. package/dist/indexer/search/ranking-contributors.js +21 -16
  41. package/dist/indexer/search/ranking.js +135 -57
  42. package/dist/indexer/units/unit.js +159 -0
  43. package/dist/llm/client.js +10 -1
  44. package/dist/llm/embedder.js +10 -3
  45. package/dist/llm/embedders/provider-limits.js +288 -0
  46. package/dist/llm/embedders/remote.js +133 -104
  47. package/dist/llm/feature-gate.js +4 -2
  48. package/dist/llm/rerank-client.js +3 -3
  49. package/dist/output/shapes/passthrough.js +1 -0
  50. package/dist/output/text/command-format.js +19 -13
  51. package/dist/output/text/helpers.js +1 -1
  52. package/dist/output/text/index.js +5 -2
  53. package/dist/scripts/akm-migrate-node.js +1141 -1237
  54. package/dist/scripts/akm-migrate.js +1141 -1237
  55. package/dist/setup/semantic-assets.js +2 -2
  56. package/dist/setup/steps/connection.js +3 -2
  57. package/dist/storage/repositories/files-repository.js +181 -0
  58. package/dist/storage/repositories/index-connection.js +1 -3
  59. package/dist/storage/repositories/index-entries-repository.js +77 -68
  60. package/dist/storage/repositories/index-entry-schema.js +16 -25
  61. package/dist/storage/repositories/index-fts-repository.js +29 -263
  62. package/dist/storage/repositories/index-meta-repository.js +0 -29
  63. package/dist/storage/repositories/index-schema.js +115 -122
  64. package/dist/storage/repositories/index-utility-repository.js +1 -1
  65. package/dist/storage/repositories/index-vec-repository.js +21 -334
  66. package/dist/storage/repositories/units-repository.js +510 -0
  67. package/docs/migration/release-notes/0.9.15.md +34 -36
  68. package/docs/migration/release-notes/0.9.16.md +110 -0
  69. package/docs/migration/release-notes/README.md +5 -0
  70. package/docs/reference/cli.md +93 -87
  71. package/docs/reference/configuration.md +128 -89
  72. package/docs/reference/data-and-telemetry.md +2 -1
  73. package/package.json +1 -1
  74. package/schemas/akm-config.json +2 -58
  75. package/dist/indexer/index-db-contention.js +0 -56
  76. package/dist/indexer/index-rebuild-lock.js +0 -73
  77. package/dist/indexer/materialize-embeddings.js +0 -771
  78. package/dist/indexer/passes/dir-staleness.js +0 -161
  79. package/dist/storage/repositories/embedding-salvage-repository.js +0 -184
@@ -24,17 +24,18 @@ import { systemErrorCode } from "../../core/system-error.js";
24
24
  import { allowsFragmentRef, defaultRendererRegistry } from "../../core/type-presentation.js";
25
25
  import { normalizeEmbeddingEndpoint } from "../../llm/embedders/remote.js";
26
26
  import { assertIndexPathReadable, closeDatabase, openExistingDatabase, } from "../../storage/repositories/index-connection.js";
27
- import { getAllEntries, getBaseBeliefStatesForDerivedTwins, getEntryById, getEntryCount, getPositiveFeedbackCountsByIds, } from "../../storage/repositories/index-entries-repository.js";
28
- import { getIndexedMarkdownFragment, getIndexedMarkdownFragments, searchFts, } from "../../storage/repositories/index-fts-repository.js";
27
+ import { getAllEntries, getBaseBeliefStatesForDerivedTwins, getEntryCount, getPositiveFeedbackCountsByIds, } from "../../storage/repositories/index-entries-repository.js";
28
+ import { getIndexedMarkdownFragment, getIndexedMarkdownFragments, } from "../../storage/repositories/index-fts-repository.js";
29
29
  import { getMeta } from "../../storage/repositories/index-meta-repository.js";
30
- import { getEmbeddingCount, searchVec } from "../../storage/repositories/index-vec-repository.js";
30
+ import { searchUnits } from "../../storage/repositories/units-repository.js";
31
31
  import { getCurrentWorkflowScopeKey } from "../../workflows/authoring/scope-key.js";
32
+ import { deriveObservedEmbeddingIdentity } from "../embedding-identity.js";
32
33
  import { ensureIndex } from "../ensure-index.js";
33
34
  import { collectGraphRelatedHit, loadGraphBoostContext } from "../graph/graph-boost.js";
34
35
  import { isProposedQuality } from "../passes/metadata.js";
35
36
  import { resolveProjectContext } from "../walk/project-context.js";
36
37
  import { buildLexicalQueryPlan, parseRefPrefixQuery, parseRetiredTypePrefixQuery, } from "./fts-query.js";
37
- import { applyRankingRules, combineSearchScores, lexicalNameMatchTier, normalizeFtsScores } from "./ranking.js";
38
+ import { applyRankingRules, fuseByEntry, lexicalNameMatchTier } from "./ranking.js";
38
39
  import { typeBoostFor } from "./ranking-contributors.js";
39
40
  import { attachSearchHitAttribution, copySearchHitAttribution, getSearchHitAttribution } from "./search-attribution.js";
40
41
  import { enrichSearchHit } from "./search-hit-enrichers.js";
@@ -225,6 +226,39 @@ export function canonicalContentTieKey(entry) {
225
226
  const source = (body || entry.description || "").replace(/^ +| +$/g, "");
226
227
  return Buffer.from(asciiCaseFold(source), "utf8").toString("hex");
227
228
  }
229
+ /**
230
+ * Priority rank for `RankedEntryInput.lexicalMatch` — lower is stronger
231
+ * evidence. `undefined` (a pure-semantic hit with no lexical component at
232
+ * all) ranks weakest, below even a relaxed OR-pool recovery.
233
+ *
234
+ * Named-mechanism fix (fix-ranking-derived-outranks-primary): the exact →
235
+ * prefix → relaxed tier ladder (`searchUnitsLexicalScoped` in this file) is
236
+ * computed and carried on every candidate as `lexicalMatch`, but nothing
237
+ * downstream ever CONSULTED it as ranking evidence — `fuseByEntry` scores
238
+ * every tier on the same `stableFtsScore` magnitude scale (deliberately, so a
239
+ * relaxed hit that topped up the candidate pool floors at 0.3 instead of
240
+ * racing on rank), and the final comparator below sorted purely by that
241
+ * magnitude. `stableFtsScore`'s [0.3, 0.8] compression then flattens a large
242
+ * raw-BM25 gap between an all-token exact match and a two-of-three relaxed
243
+ * match to a few thousandths (e.g. 0.7148 vs 0.7053 for a ~6x BM25 gap) — well
244
+ * inside the swing of any single additive ranking contributor (alias-ranking
245
+ * alone is +0.3) or a belief-state ceiling. So a contributor or a ceiling,
246
+ * neither of which is supposed to do more than nudge, ends up DECIDING an
247
+ * ordering that the lexical tier — which already told us conclusively that
248
+ * one candidate matched every query token and the other did not — should
249
+ * have decided.
250
+ *
251
+ * This is the same escape hatch `aNameTier === 3` below already uses for a
252
+ * perfect name match, generalized to the tier ladder: exact tier is stronger
253
+ * evidence than prefix, which is stronger than relaxed, independent of the
254
+ * compressed magnitude gap between them. It sits after the name-tier-3 gate
255
+ * (an exact full name equality is stronger evidence still) and before the
256
+ * score comparison it used to lose to.
257
+ */
258
+ const LEXICAL_TIER_RANK = { exact: 0, prefix: 1, relaxed: 2 };
259
+ function lexicalTierRank(tier) {
260
+ return tier === undefined ? 3 : LEXICAL_TIER_RANK[tier];
261
+ }
228
262
  function buildSearchResultComparator(query) {
229
263
  const queryTokens = buildLexicalQueryPlan(query).tokens.map((token) => token.toLowerCase());
230
264
  const displayScore = (score) => Math.round(displaySearchScore(score) * 10000) / 10000;
@@ -237,6 +271,9 @@ function buildSearchResultComparator(query) {
237
271
  if (nameDiff !== 0)
238
272
  return nameDiff;
239
273
  }
274
+ const tierDiff = lexicalTierRank(a.lexicalMatch) - lexicalTierRank(b.lexicalMatch);
275
+ if (tierDiff !== 0)
276
+ return tierDiff;
240
277
  const scoreDiff = displayScore(b.score) - displayScore(a.score);
241
278
  if (scoreDiff !== 0)
242
279
  return scoreDiff;
@@ -245,9 +282,8 @@ function buildSearchResultComparator(query) {
245
282
  return rawScoreDiff;
246
283
  // Ceiling values are intentionally allowed to demote visibility, but not
247
284
  // to erase relevance. Prefer the score before a relaxed body-only ceiling;
248
- // a later belief-state ceiling has its own minScore handoff and must not
249
- // overwrite this ordering evidence. Belief-only ceilings fall back to
250
- // their `preCeilingScore`.
285
+ // a later belief-state ceiling must not overwrite this ordering evidence.
286
+ // Belief-only ceilings fall back to their `preCeilingScore`.
251
287
  const preCeilingRelevance = (item) => item.preRelaxedCeilingScore ?? item.preCeilingScore ?? item.score;
252
288
  const ceilingDiff = stableRankScore(preCeilingRelevance(b)) - stableRankScore(preCeilingRelevance(a));
253
289
  if (ceilingDiff !== 0)
@@ -325,35 +361,13 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceD
325
361
  mode: "keyword",
326
362
  };
327
363
  }
328
- // Start the async embedding request without awaiting, then run FTS
329
- // synchronously while the HTTP/local embedding request is in-flight.
364
+ // Start the async embedding request without awaiting, then run the lexical
365
+ // units_fts query synchronously while the HTTP/local embedding request is
366
+ // in-flight.
330
367
  const typeFilter = searchType === "any" ? undefined : searchType;
331
- const { ftsResults, embeddingScores, embedMs, mode, semanticWarning } = await collectSearchSignals(db, query, limit * 3, typeFilter, defaultExcludes, config);
368
+ const { embedMs, mode, semanticWarning, unitScored } = await collectSearchSignals(db, query, limit * 3, typeFilter, defaultExcludes, config);
332
369
  const tRank0 = Date.now();
333
- // ── Score normalization ──────────────────────────────────────────────
334
- // Stable bounded BM25 transform + cosine similarity with weighted addition
335
- // (FTS 0.7, vector 0.3). The lexical transform is per-row, so widening the
336
- // candidate set cannot alter a pre-existing row's base score.
337
- const ftsScoreMap = normalizeFtsScores(ftsResults);
338
- // Build embedding score map (cosine similarities already 0-1)
339
- const embedScoreMap = new Map();
340
- if (embeddingScores) {
341
- for (const [id, cosine] of embeddingScores) {
342
- embedScoreMap.set(id, cosine);
343
- }
344
- }
345
- // ── Combine FTS + vector scores ──────────────────────────────────────
346
- const scored = combineSearchScores({
347
- ftsScoreMap,
348
- embedScoreMap,
349
- getEntryById: (id) => getEntryById(db, id) ?? undefined,
350
- typeFilter,
351
- // #627 — also exclude default-hidden types from the vector-only branch so a
352
- // session asset that is a top-k vector neighbor (but not an FTS match) does
353
- // not leak into default ('any') results. defaultExcludes is already []
354
- // unless this is the untyped path without includeExcludedTypes.
355
- excludeTypes: defaultExcludes,
356
- }).filter(hasIndexedProvenance);
370
+ const scored = unitScored.filter(hasIndexedProvenance);
357
371
  // ── Scoring Phase ──────────────────────────────────────────────────────
358
372
  // Apply boosts as multiplicative factors (all boosts in a single phase
359
373
  // so that sort order and displayed scores are always consistent).
@@ -419,21 +433,15 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceD
419
433
  positiveFeedbackCounts,
420
434
  scopeKey,
421
435
  });
422
- // ── minScore floor ──────────────────────────────────────────────────────
423
- // Drop semantic-only hits (cosine-only, no FTS match) whose score falls
424
- // below the configured floor. FTS hits and hybrid hits are always kept.
425
- // Default floor: 0.2. Set search.minScore = 0 in config to disable.
426
- // Judged on the PRE-ceiling score when a demoting belief state clamped the
427
- // item (`preCeilingScore`): the belief ceilings can sit below this floor
428
- // (archived 0.15 < 0.2), and a demotion must rank the hit last, not
429
- // silently remove a result that would otherwise have listed.
430
- const minScore = config.search?.minScore ?? 0.2;
431
- const preFilter = minScore > 0
432
- ? scored.filter((item) => item.rankingMode !== "semantic" || (item.preCeilingScore ?? item.score) >= minScore)
433
- : scored;
434
- preFilter.sort(buildSearchResultComparator(query));
436
+ // The units path's magnitude-fused score (`fuseByEntry` in ranking.ts) is
437
+ // already the same [0, 1]-ish scale the ranking contributors and the
438
+ // belief-state ceiling are calibrated for (`stableFtsScore`'s floor/ceiling)
439
+ // — no separate minScore floor is applied. A demoting belief state already
440
+ // caps a hit's score and ranks it last via `buildSearchResultComparator`
441
+ // rather than dropping it.
442
+ scored.sort(buildSearchResultComparator(query));
435
443
  // Deduplicate by file path — keep only the highest-scored entry per file.
436
- const deduped = deduplicateByPath(preFilter);
444
+ const deduped = deduplicateByPath(scored);
437
445
  // Source → scope → proposed-quality → derived-twin belief inheritance →
438
446
  // belief: the post-candidate filter chain shared with enumerateEntries (see
439
447
  // applyEntryFilters). Applied AFTER ranking so filtering narrows the result
@@ -475,6 +483,7 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceD
475
483
  rankingMode,
476
484
  lexicalMatch: ranked.lexicalMatch,
477
485
  fragmentId: ranked.fragmentId,
486
+ matchedUnit: ranked.matchedUnit,
478
487
  indexedFragment: ranked.fragmentId ? (selectedFragmentByEntryId.get(ranked.id) ?? null) : undefined,
479
488
  defaultStashDir: stashDir,
480
489
  allSourceDirs,
@@ -489,24 +498,298 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceD
489
498
  }));
490
499
  return { embedMs, rankMs, hits, mode, semanticWarning };
491
500
  }
501
+ // ── Units search (index-redesign-contract.md B3) ────────────────────────────
502
+ //
503
+ // Every write path (reconcile, and `indexWrittenAssets` for a just-written
504
+ // asset) populates `unit_texts`/`units_fts`/`entry_units` atomically with the
505
+ // `entries` row itself (B1's contract), so there is exactly one search path:
506
+ // lexical `units_fts` fused with semantic `units_vec` by evidence magnitude
507
+ // (`ranking.ts`'s `fuseByEntry` — see its doc for why magnitude, not rank
508
+ // fusion). There is no longer a coverage check to branch on — B5a's generation bump
509
+ // (index-schema.ts) discards `entries` outright on an incompatible schema, so
510
+ // a readable `entries` row always has its `entry_units` sibling.
511
+ /**
512
+ * `units_fts`/`units_vec` are keyed by UNIT, not by entry, and one entry can
513
+ * own several units (its structured-fields card plus one per Markdown
514
+ * fragment). Retrieving only `candidateLimit` units therefore yields fewer
515
+ * than `candidateLimit` distinct entries once grouped — this scales the
516
+ * requested `k` by the corpus's observed mean so entry-level recall stays
517
+ * comparable to the old per-entry candidate pool. 1 is the floor for a
518
+ * corpus with no `entry_units` rows yet (nothing to divide by).
519
+ */
520
+ function meanUnitsPerEntry(db) {
521
+ const row = db
522
+ .prepare("SELECT AVG(cnt) AS mean FROM (SELECT COUNT(*) AS cnt FROM entry_units GROUP BY entry_id)")
523
+ .get();
524
+ const mean = row?.mean;
525
+ return typeof mean === "number" && Number.isFinite(mean) && mean > 0 ? mean : 1;
526
+ }
527
+ /**
528
+ * Build the `unit_hash IN (...)` clause that pushes a type predicate into the
529
+ * SQL BEFORE the candidate cap (item 3 — the confirmed defect: applying
530
+ * `typeFilter`/`excludeTypes` in JS after `fuseByEntry` filtered a pool that
531
+ * `LIMIT` already truncated could drop every eligible candidate). A unit is
532
+ * eligible if it has an owning entry (via `entry_units` → `entries`) that
533
+ * satisfies both predicates at once — the same entry, not independently
534
+ * matched rows — which is the correct reading for a unit hash shared by more
535
+ * than one entry (content-addressed reuse).
536
+ */
537
+ function buildUnitTypeClause(typeOpts) {
538
+ if (!typeOpts?.typeFilter?.length && !typeOpts?.excludeTypes?.length)
539
+ return null;
540
+ const clauses = [];
541
+ const params = [];
542
+ if (typeOpts.typeFilter?.length) {
543
+ clauses.push(`e.type IN (${typeOpts.typeFilter.map(() => "?").join(",")})`);
544
+ params.push(...typeOpts.typeFilter);
545
+ }
546
+ if (typeOpts.excludeTypes?.length) {
547
+ clauses.push(`e.type NOT IN (${typeOpts.excludeTypes.map(() => "?").join(",")})`);
548
+ params.push(...typeOpts.excludeTypes);
549
+ }
550
+ return {
551
+ sql: `unit_hash IN (SELECT eu.unit_hash FROM entry_units eu JOIN entries e ON e.id = eu.entry_id WHERE ${clauses.join(" AND ")})`,
552
+ params,
553
+ };
554
+ }
555
+ function runUnitsFtsQuery(db, ftsQuery, lexicalMatch, k, kind, typeOpts) {
556
+ // `kind` filters via a subquery against `unit_texts` rather than joining
557
+ // (and aliasing) `units_fts` directly — FTS5's `bm25()` auxiliary function
558
+ // must name the exact identifier `units_fts` is referenced by in the FROM
559
+ // clause, so aliasing it would mean threading that alias through `bm25()`
560
+ // too. The subquery keeps `units_fts` unaliased and lets the MATCH still
561
+ // drive the query through FTS5's own index (`unit_texts_kind` then narrows
562
+ // it, see `files-repository.ts`). The type predicate (item 3) is pushed in
563
+ // the same way, and — critically — BEFORE the `LIMIT`, so an ineligible
564
+ // unit never occupies a slot a genuinely eligible one needed.
565
+ const conditions = ["units_fts MATCH ?"];
566
+ const params = [ftsQuery];
567
+ if (kind) {
568
+ conditions.push("unit_hash IN (SELECT unit_hash FROM unit_texts WHERE kind = ?)");
569
+ params.push(kind);
570
+ }
571
+ const typeClause = buildUnitTypeClause(typeOpts);
572
+ if (typeClause) {
573
+ conditions.push(typeClause.sql);
574
+ params.push(...typeClause.params);
575
+ }
576
+ params.push(k);
577
+ const rows = db
578
+ .prepare(`SELECT unit_hash AS unitHash, bm25(units_fts) AS score
579
+ FROM units_fts
580
+ WHERE ${conditions.join(" AND ")}
581
+ ORDER BY score ASC
582
+ LIMIT ?`)
583
+ .all(...params);
584
+ // Competition ranking (ties share a rank) rather than strict sequential
585
+ // position: SQLite gives no deterministic secondary order for an exact
586
+ // bm25 tie, and the final ranking comparator's content-based tie-break
587
+ // (`canonicalContentTieKey`) needs an exact score tie to survive to ever
588
+ // run — a strict `index + 1` would silently turn "these two units tied on
589
+ // relevance" into "this one wins".
590
+ let rank = 0;
591
+ let previousScore;
592
+ return rows.map((row, index) => {
593
+ if (previousScore === undefined || row.score !== previousScore)
594
+ rank = index + 1;
595
+ previousScore = row.score;
596
+ return { unitHash: row.unitHash, rank, bm25: row.score, lexicalMatch };
597
+ });
598
+ }
599
+ /**
600
+ * `units_fts` bm25 lexical search over unit text, ranked best-first,
601
+ * optionally scoped to one unit `kind`. Mirrors `searchFts`'s own exact →
602
+ * prefix → relaxed fallback (`index-fts-repository.ts`), but as a PRIORITY
603
+ * ORDER rather than an early exit (item 2): a unit is a card or one Markdown
604
+ * section, so a conjunctive query is rarely satisfied by any single unit —
605
+ * stopping at the first non-empty tier let one incidental hit (e.g. a pasted
606
+ * stack trace quoting every query token) suppress the far larger, more
607
+ * relevant relaxed pool. Instead, take the exact hits, then top up with
608
+ * prefix hits, then relaxed hits, until `k` is reached — each hit keeps the
609
+ * tier it came from in `lexicalMatch`. Magnitude fusion (`ranking.ts`'s
610
+ * `fuseByEntry`) is what makes topping up safe: a relaxed-tier junk match now
611
+ * scores at `stableFtsScore`'s 0.3 floor instead of near the top of a rank
612
+ * list.
613
+ *
614
+ * A genuine bm25 tie at the `k` boundary is never split across the cutoff:
615
+ * `addTier` finishes the whole tied group even if that pushes the result
616
+ * past `k`, because the final ranking comparator's content-based tie-break
617
+ * depends on that exact score tie surviving into `fuseByEntry`'s output.
618
+ * Ties are only tracked WITHIN one tier's own query — bm25 from different
619
+ * MATCH queries (exact vs. prefix vs. relaxed) is not comparable, so a
620
+ * later tier always starts its own fresh rank sequence, offset to continue
621
+ * numbering after the tiers already taken.
622
+ */
623
+ function searchUnitsLexicalScoped(db, query, k, kind, typeOpts) {
624
+ if (k <= 0)
625
+ return [];
626
+ const plan = buildLexicalQueryPlan(query);
627
+ if (!plan.exact)
628
+ return [];
629
+ const hits = [];
630
+ const seen = new Set();
631
+ let rankOffset = 0;
632
+ const addTier = (tierHits) => {
633
+ let lastRank;
634
+ for (const hit of tierHits) {
635
+ if (seen.has(hit.unitHash))
636
+ continue;
637
+ if (hits.length >= k && hit.rank !== lastRank)
638
+ break;
639
+ seen.add(hit.unitHash);
640
+ hits.push({ ...hit, rank: hit.rank + rankOffset });
641
+ lastRank = hit.rank;
642
+ }
643
+ const tierMaxRank = tierHits[tierHits.length - 1]?.rank ?? 0;
644
+ rankOffset += tierMaxRank;
645
+ };
646
+ addTier(runUnitsFtsQuery(db, plan.exact, "exact", k, kind, typeOpts));
647
+ if (hits.length < k && plan.exactPrefix) {
648
+ addTier(runUnitsFtsQuery(db, plan.exactPrefix, "prefix", k, kind, typeOpts));
649
+ }
650
+ if (hits.length < k && plan.relaxed) {
651
+ addTier(runUnitsFtsQuery(db, plan.relaxed, "relaxed", k, kind, typeOpts));
652
+ }
653
+ return hits;
654
+ }
655
+ /**
656
+ * `units_fts` bm25 lexical search over EVERY unit, kind-agnostic — the
657
+ * original single-pool query, kept for callers that want one flat
658
+ * entry-level lexical ranking rather than the card/fragment split
659
+ * `collectSearchSignals` uses (below): `searchEntriesLexical`'s
660
+ * deterministic-only canary scoring for collapse-detector, which has no use
661
+ * for field emphasis.
662
+ */
663
+ export function searchUnitsLexical(db, query, k) {
664
+ return searchUnitsLexicalScoped(db, query, k);
665
+ }
666
+ /**
667
+ * `units_fts` bm25 lexical search over BOTH kind-scoped pools (`"card"`,
668
+ * `"fragment"`) at once (index-redesign-contract.md B5f item 2). Structural
669
+ * field emphasis: `"card"` units hold name/description/tags/hints and are
670
+ * few (one per entry), so a name match ranks near the top of a SMALL pool
671
+ * instead of racing every fragment's body text in one shared BM25 ranking —
672
+ * the same effect the old per-column BM25 weights (name 10x, description
673
+ * 5x, ...) bought through tuning, gotten here from the units' own structure
674
+ * instead.
675
+ *
676
+ * Each pool runs its OWN exact → prefix → relaxed priority-order ladder
677
+ * (item 2 — `searchUnitsLexicalScoped`'s own doc), rather than sharing one
678
+ * tier decision as an earlier revision did: sharing let an incidental
679
+ * fragment-exact match (e.g. a pasted stack trace quoting every query token)
680
+ * lock the card pool out of ever escalating to its own relaxed recovery, so
681
+ * a well-named relevant entry disappeared behind an unrelated log dump.
682
+ * Magnitude fusion (`ranking.ts`'s `fuseByEntry`) is what makes independent
683
+ * ladders safe: a relaxed-tier junk match now scores at `stableFtsScore`'s
684
+ * 0.3 floor instead of competing on rank, so a stray fragment-side escalation
685
+ * can no longer crowd out a genuine card-side exact hit the way it would
686
+ * have under rank fusion.
687
+ */
688
+ export function searchUnitsLexicalPair(db, query, k, typeOpts) {
689
+ return {
690
+ card: searchUnitsLexicalScoped(db, query, k, "card", typeOpts),
691
+ fragment: searchUnitsLexicalScoped(db, query, k, "fragment", typeOpts),
692
+ };
693
+ }
694
+ /** Count of `units` rows for the active identity. */
695
+ function getUnitVectorCount(db, identity) {
696
+ try {
697
+ const row = db.prepare("SELECT COUNT(*) AS cnt FROM units WHERE identity = ?").get(identity);
698
+ return row?.cnt ?? 0;
699
+ }
700
+ catch {
701
+ // The design doc's migration story has units_fts populated (lexical
702
+ // ready) before the first embedding drain completes (`units` empty or
703
+ // absent) — an expected transient state, not a fault. Lexical-only
704
+ // results are the correct behavior until the drain catches up.
705
+ return 0;
706
+ }
707
+ }
708
+ async function tryUnitVecScores(db, query, k, config, typeOpts) {
709
+ if (config.semanticSearchMode === "off")
710
+ return { hits: null };
711
+ const identity = getMeta(db, "embeddingIdentity");
712
+ if (!identity || getUnitVectorCount(db, identity) === 0)
713
+ return { hits: null };
714
+ try {
715
+ const { embed } = await import("../../llm/embedder.js");
716
+ const queryEmbedding = await embed(query, config.embedding);
717
+ // item 5 — a query embedded under a different identity than the index
718
+ // must not be trusted, even when it happens to come back the same width
719
+ // (768/1024/1536 are all common across otherwise-unrelated models): a
720
+ // width match alone is not a vector-space match. `deriveObservedEmbeddingIdentity`
721
+ // is the same derivation `drain.ts` uses to learn/verify the identity it
722
+ // is embedding units under; there is no server-reported model for a
723
+ // single query `embed()` call (only `embedBatch`'s `onBatch` threads that
724
+ // through from the provider's response), so this derives from the
725
+ // CURRENT config the same way drain does whenever the provider's
726
+ // response echoes the configured model — the case `embedding.model`/
727
+ // `embedding.endpoint` being edited since the last index actually
728
+ // exercises. A genuine width mismatch is already safe (sqlite-vec throws
729
+ // below); this catches the same-width, different-model case that would
730
+ // otherwise silently compare incompatible vector spaces.
731
+ const observedIdentity = deriveObservedEmbeddingIdentity(config.embedding, undefined, queryEmbedding.length);
732
+ if (observedIdentity !== identity) {
733
+ return { hits: null, warning: buildIdentityMismatchWarning(config) };
734
+ }
735
+ return { hits: searchUnits(db, queryEmbedding, k, identity, typeOpts) };
736
+ }
737
+ catch (error) {
738
+ return { hits: null, warning: buildVectorFallbackWarning(config, error) };
739
+ }
740
+ }
492
741
  async function collectSearchSignals(db, query, candidateLimit, typeFilter, excludeTypes, config) {
493
742
  const startedAt = Date.now();
494
- const embeddingPromise = tryVecScores(db, query, candidateLimit, config);
495
- const ftsResults = searchFts(db, query, candidateLimit, typeFilter, excludeTypes);
496
- const embeddingResult = await embeddingPromise;
497
- const mode = embeddingResult.warning
743
+ const unitK = Math.max(1, Math.round(candidateLimit * meanUnitsPerEntry(db)));
744
+ // item 3 — push the type predicate into the SQL of both the lexical and
745
+ // semantic candidate queries, not just into `fuseByEntry`'s post-grouping
746
+ // JS filter below: applying it only after each list was already cut to
747
+ // `unitK` by `LIMIT`/`k` let a whole type-excluded pool (e.g. 100 `session`
748
+ // cards exactly matching the query) crowd a genuinely-matching entry of a
749
+ // different type out of the candidate window entirely. `fuseByEntry`'s own
750
+ // filter stays as a cheap guard, not the mechanism.
751
+ const typeOpts = { typeFilter: typeFilter ? [typeFilter] : undefined, excludeTypes };
752
+ const semanticPromise = tryUnitVecScores(db, query, unitK, config, typeOpts);
753
+ // index-redesign-contract.md B5f item 2 — two kind-scoped lexical lists,
754
+ // not one mixed pool: a card (name/description/tags/hints) match ranks
755
+ // within its own small pool instead of competing against every fragment's
756
+ // body text on raw BM25, so field emphasis falls out of the units'
757
+ // structure rather than tuned per-column weights. Each pool runs its own
758
+ // priority-order ladder — see `searchUnitsLexicalPair`'s own doc.
759
+ const { card: cardLexicalHits, fragment: fragmentLexicalHits } = searchUnitsLexicalPair(db, query, unitK, typeOpts);
760
+ const semanticResult = await semanticPromise;
761
+ const mode = semanticResult.warning
498
762
  ? "fts-fallback"
499
- : embeddingResult.scores !== null
763
+ : semanticResult.hits !== null
500
764
  ? "semantic"
501
765
  : "keyword";
766
+ const unitScored = fuseByEntry(db, cardLexicalHits, fragmentLexicalHits, semanticResult.hits ?? [], typeOpts);
502
767
  return {
503
- ftsResults,
504
- embeddingScores: embeddingResult.scores,
505
768
  embedMs: Date.now() - startedAt,
506
769
  mode,
507
- semanticWarning: embeddingResult.warning,
770
+ semanticWarning: semanticResult.warning,
771
+ unitScored,
508
772
  };
509
773
  }
774
+ /**
775
+ * Entry-level lexical-only search over units, best match first — for
776
+ * consumers that need ranked entries without semantic fusion (e.g.
777
+ * collapse-detector's canary scoring, which is deterministic-only by design:
778
+ * see `src/commands/improve/collapse-detector.ts`). The `units_fts` card unit
779
+ * carries name/description/tags/hints, so an entry-level lexical search is a
780
+ * units query grouped by entry — the same grouping `collectSearchSignals`
781
+ * uses, with an empty semantic list so `fuseByEntry`'s magnitude fusion
782
+ * degenerates to a pure lexical-bm25 ordering.
783
+ */
784
+ export function searchEntriesLexical(db, query, k) {
785
+ const unitK = Math.max(1, Math.round(k * meanUnitsPerEntry(db)));
786
+ const lexicalHits = searchUnitsLexical(db, query, unitK);
787
+ // One flat kind-agnostic pool, not the card/fragment split
788
+ // `collectSearchSignals` uses — deliberately: this is the kind-agnostic
789
+ // single-list mode `fuseByEntry` still supports for a caller with no use
790
+ // for field emphasis (see `searchUnitsLexical`'s own doc).
791
+ return fuseByEntry(db, lexicalHits, [], []).sort((a, b) => b.score - a.score);
792
+ }
510
793
  /**
511
794
  * The no-hits tip. A query in the retired `<type>:` / `<type>:<prefix>/` browse
512
795
  * grammar gets the conceptId spelling that replaces it: without this it comes
@@ -688,33 +971,6 @@ function matchBeliefFilter(beliefState, filter) {
688
971
  beliefState === "archived");
689
972
  }
690
973
  // ── Vector scorer ───────────────────────────────────────────────────────────
691
- async function tryVecScores(db, query, k, config) {
692
- if (config.semanticSearchMode === "off")
693
- return { scores: null };
694
- // A real-time completeness fact, not a cached verdict: skip the network
695
- // round trip only when the index has never embedded anything. A PARTIAL
696
- // failure (some entries embedded, one write degraded) still attempts —
697
- // and if the endpoint is genuinely down, the failure surfaces as a live
698
- // `semanticWarning` below instead of silently skipping with no signal.
699
- if (getEmbeddingCount(db) === 0)
700
- return { scores: null };
701
- try {
702
- const { embed } = await import("../../llm/embedder.js");
703
- const queryEmbedding = await embed(query, config.embedding);
704
- const vecResults = searchVec(db, queryEmbedding, k);
705
- const scores = new Map();
706
- for (const { id, distance } of vecResults) {
707
- // Convert L2 distance to cosine similarity (vectors are normalized).
708
- // Guard against NaN/Infinity from sqlite-vec edge cases.
709
- const raw = 1 - (distance * distance) / 2;
710
- scores.set(id, Number.isFinite(raw) ? Math.max(0, raw) : 0);
711
- }
712
- return { scores };
713
- }
714
- catch (error) {
715
- return { scores: null, warning: buildVectorFallbackWarning(config, error) };
716
- }
717
- }
718
974
  function buildVectorFallbackWarning(config, error) {
719
975
  const endpoint = safeEmbeddingEndpoint(config);
720
976
  const reason = classifyVectorFailure(error);
@@ -726,6 +982,21 @@ function buildVectorFallbackWarning(config, error) {
726
982
  const unavailable = reason === "connection failed" ? `cannot reach ${target}` : `${target} is unavailable`;
727
983
  return `Vector search unavailable: ${unavailable} (${reason}) — falling back to keyword search.`;
728
984
  }
985
+ /**
986
+ * item 5 — same shape as {@link buildVectorFallbackWarning}, for the case
987
+ * where embedding itself succeeded but the query was embedded under a
988
+ * different identity than the index (`embedding.model`/`embedding.endpoint`
989
+ * edited since the last index run).
990
+ */
991
+ function buildIdentityMismatchWarning(config) {
992
+ const endpoint = safeEmbeddingEndpoint(config);
993
+ const target = endpoint
994
+ ? `embedding endpoint ${endpoint}`
995
+ : config.embedding?.endpoint
996
+ ? "configured embedding endpoint"
997
+ : "local embedding model";
998
+ return `Vector search unavailable: ${target} is embedding queries under a different identity than the index was built with (embedding.model/embedding.endpoint changed since the last index) — falling back to keyword search.`;
999
+ }
729
1000
  /**
730
1001
  * Name the useful endpoint without ever carrying URL userinfo, query secrets,
731
1002
  * or fragments into a warning. Invalid authored values fail closed.
@@ -792,24 +1063,36 @@ export async function buildDbHit(input) {
792
1063
  ? (input.bundleId ?? undefined)
793
1064
  : undefined);
794
1065
  const parentRef = resolveSearchHitRef(input.entry, input, defaultBundleId);
795
- // Fragments prove lexical relevance, but executable assets must retain the
796
- // parent ref consumed by their advertised action (for example workflow run).
797
- // The central type-presentation contract opts those types out explicitly.
798
- const ref = input.fragmentId && allowsFragmentRef(input.entry.type) ? `${parentRef}#${input.fragmentId}` : parentRef;
1066
+ // index-redesign-contract.md B5f item 1 — the hit's primary `ref` is ALWAYS
1067
+ // the entry ref now, never `${parentRef}#${fragmentId}`. On the units path
1068
+ // the best-matching unit for a hit is routinely a Markdown fragment (unit
1069
+ // kind `fragment`), so a fragment-suffixed `ref` here would silently mismatch
1070
+ // every consumer (a judgment, a stored `derivedFrom`, a copy-pasted CLI
1071
+ // command) that names the bare entry. A consumer that genuinely wants the
1072
+ // matched fragment's own ref reads `selectedRef` below instead — computed
1073
+ // exactly the way `ref` itself used to be, so its availability (gated by
1074
+ // `allowsFragmentRef`) is unchanged; only the PRIMARY ref stopped carrying it.
1075
+ const ref = parentRef;
799
1076
  const editable = isEditable(absolutePath, input.config, input.sources);
800
1077
  const indexedFragment = input.indexedFragment === undefined
801
1078
  ? input.fragmentId && input.db
802
1079
  ? getIndexedMarkdownFragment(input.db, input.itemRef, input.fragmentId)
803
1080
  : undefined
804
1081
  : (input.indexedFragment ?? undefined);
805
- const selectedRef = input.fragmentId && ref !== parentRef ? `${parentRef}#${input.fragmentId}` : undefined;
1082
+ // Fragments prove lexical relevance, but executable assets must retain the
1083
+ // parent ref consumed by their advertised action (for example workflow run).
1084
+ // The central type-presentation contract opts those types out explicitly.
1085
+ const selectedRef = input.fragmentId && allowsFragmentRef(input.entry.type) ? `${parentRef}#${input.fragmentId}` : undefined;
806
1086
  const parentEstimatedTokens = typeof input.entry.fileSize === "number"
807
1087
  ? Math.round(input.entry.fileSize / 4)
808
1088
  : indexedFragment
809
1089
  ? Math.round(indexedFragment.parentChars / 4)
810
1090
  : undefined;
811
1091
  const fragmentEstimatedTokens = indexedFragment ? Math.round(indexedFragment.fragmentChars / 4) : undefined;
812
- const estimatedTokens = selectedRef === ref && fragmentEstimatedTokens !== undefined ? fragmentEstimatedTokens : parentEstimatedTokens;
1092
+ // `ref` addresses the whole entry now (see above), so the size it stands for
1093
+ // is always the parent's — a caller that wants the fragment's own size reads
1094
+ // `fragmentEstimatedTokens` from the `selectedRef` block below.
1095
+ const estimatedTokens = parentEstimatedTokens;
813
1096
  const hit = {
814
1097
  type: input.entry.type,
815
1098
  name: input.entry.name,
@@ -861,6 +1144,7 @@ export async function buildDbHit(input) {
861
1144
  // hit. Omitted when the hit has no FTS component (pure-semantic hybrid
862
1145
  // contribution).
863
1146
  ...(input.lexicalMatch ? { matchStage: input.lexicalMatch } : {}),
1147
+ ...(input.matchedUnit ? { matchedUnit: input.matchedUnit } : {}),
864
1148
  };
865
1149
  attachDbHitAttribution(hit, input);
866
1150
  if (input.entry.derivedFrom) {
@@ -106,20 +106,25 @@ function beliefStateBoost(item) {
106
106
  * stash-conventions-code-spec.md — corrections demotion).
107
107
  *
108
108
  * Why the additive {@link beliefStateBoost} penalties alone are not enough:
109
- * keyword base scores have a bounded lexical floor (`normalizeFtsScores`),
110
- * while the boost sum then MULTIPLIES the base (`score *= 1 + boostSum`,
111
- * {@link applyScoreContributors}). A superseded incumbent can still earn
112
- * enough independent boosts to outrank its own correction, so additive
113
- * penalties alone cannot guarantee the corrections pattern's point ("so the
114
- * ranker demotes the stale version instead of letting it outrank your fix").
109
+ * keyword base scores have a bounded lexical floor (`stableFtsScore`, in
110
+ * `core/lexical-score.ts` — the calibrated per-row bm25 transform
111
+ * `ranking.ts`'s `fuseByEntry` fuses on), while the boost sum then
112
+ * MULTIPLIES the base (`score *= 1 + boostSum`, {@link applyScoreContributors}).
113
+ * A superseded incumbent can still earn enough independent boosts to
114
+ * outrank its own correction, so additive penalties alone cannot guarantee
115
+ * the corrections pattern's point ("so the ranker demotes the stale version
116
+ * instead of letting it outrank your fix").
115
117
  *
116
118
  * The ceilings guarantee the demotion while keeping flagged entries VISIBLE:
117
- * un-demoted keyword hits floor at a 0.3 base, so any un-demoted hit outranks
118
- * a ceilinged one; demoted entries still list (belief FILTERING stays a
119
- * separate opt-in axis, `--belief`), and scores already below a ceiling keep
120
- * their relative ordering. Ceiling order mirrors the additive-penalty
121
- * severity order pinned in tests/integration/belief-state-phase1a.test.ts:
122
- * deprecated (mildest) > superseded > contradicted > archived.
119
+ * un-demoted keyword hits floor at `stableFtsScore`'s 0.3 base, so any
120
+ * un-demoted matching hit outranks a ceilinged one (verified end-to-end by
121
+ * `search-units-fusion.test.ts`'s belief-ceiling-invariant case, not just the
122
+ * ceiling constants below); demoted entries still list (belief FILTERING
123
+ * stays a separate opt-in axis, `--belief`), and scores already below a
124
+ * ceiling keep their relative ordering. Ceiling order mirrors the
125
+ * additive-penalty severity order pinned in
126
+ * tests/integration/belief-state-phase1a.test.ts: deprecated (mildest) >
127
+ * superseded > contradicted > archived.
123
128
  */
124
129
  const BELIEF_STATE_SCORE_CEILINGS = {
125
130
  deprecated: 0.28,
@@ -134,10 +139,10 @@ const BELIEF_STATE_SCORE_CEILINGS = {
134
139
  * order and displayed scores stay consistent (single scoring pipeline).
135
140
  *
136
141
  * When the ceiling clamps, the pre-clamp score is recorded as
137
- * `preCeilingScore` so db-search's semantic-only `minScore` floor can judge
138
- * the hit by what it would have scored WITHOUT the demotion — a ceiling below
139
- * the floor (archived 0.15 < default minScore 0.2) must demote a hit to last
140
- * place, never silently drop it from the results.
142
+ * `preCeilingScore` so db-search's final ranking comparator can order
143
+ * demoted hits by what they would have scored WITHOUT the demotion — a
144
+ * ceilinged item still lists (ranked last among un-demoted peers), never
145
+ * silently dropped from the results.
141
146
  */
142
147
  export function applyBeliefStateScoreCeiling(item) {
143
148
  const state = item.entry.beliefState;