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.
- package/CHANGELOG.md +144 -0
- package/dist/assets/tasks/core/index-refresh.yml +1 -1
- package/dist/cli/retired-commands.js +2 -0
- package/dist/cli/unknown-flags.js +36 -3
- package/dist/commands/improve/collapse-detector.js +2 -2
- package/dist/commands/improve/consolidate.js +6 -4
- package/dist/commands/improve/improve-cli.js +1 -1
- package/dist/commands/proposal/repository.js +12 -3
- package/dist/commands/read/curate.js +34 -44
- package/dist/commands/read/search.js +50 -2
- package/dist/commands/sources/index-status.js +99 -0
- package/dist/commands/sources/info.js +8 -8
- package/dist/commands/sources/installed-stashes.js +33 -12
- package/dist/commands/sources/source-add.js +21 -6
- package/dist/commands/sources/stash-cli.js +119 -111
- package/dist/core/adapter/adapters/akm-adapter.js +35 -3
- package/dist/core/adapter/adapters/akm-metadata.js +11 -1
- package/dist/core/asset/asset-placement.js +35 -0
- package/dist/core/config/schema/embedding.js +7 -30
- package/dist/core/config/schema/search.js +11 -9
- package/dist/core/errors.js +5 -2
- package/dist/core/hash.js +18 -0
- package/dist/core/maintenance-barrier.js +8 -6
- package/dist/core/paths.js +0 -11
- package/dist/core/run-lock.js +5 -2
- package/dist/core/state/migrations.js +26 -1
- package/dist/core/state-db.js +63 -27
- package/dist/indexer/drain.js +306 -0
- package/dist/indexer/embedding-identity.js +20 -0
- package/dist/indexer/enrich.js +260 -0
- package/dist/indexer/ensure-index.js +5 -0
- package/dist/indexer/index-written-assets.js +133 -171
- package/dist/indexer/indexer.js +458 -1621
- package/dist/indexer/lookup/adapter-concept-owner.js +19 -5
- package/dist/indexer/passes/metadata.js +18 -1
- package/dist/indexer/reconcile.js +890 -0
- package/dist/indexer/scan/drain-dir.js +27 -70
- package/dist/indexer/scan/parse-file.js +66 -0
- package/dist/indexer/search/db-search.js +373 -89
- package/dist/indexer/search/ranking-contributors.js +21 -16
- package/dist/indexer/search/ranking.js +135 -57
- package/dist/indexer/units/unit.js +159 -0
- package/dist/llm/client.js +10 -1
- package/dist/llm/embedder.js +10 -3
- package/dist/llm/embedders/provider-limits.js +288 -0
- package/dist/llm/embedders/remote.js +133 -104
- package/dist/llm/feature-gate.js +4 -2
- package/dist/llm/rerank-client.js +3 -3
- package/dist/output/shapes/passthrough.js +1 -0
- package/dist/output/text/command-format.js +19 -13
- package/dist/output/text/helpers.js +1 -1
- package/dist/output/text/index.js +5 -2
- package/dist/scripts/akm-migrate-node.js +1141 -1237
- package/dist/scripts/akm-migrate.js +1141 -1237
- package/dist/setup/semantic-assets.js +2 -2
- package/dist/setup/steps/connection.js +3 -2
- package/dist/storage/repositories/files-repository.js +181 -0
- package/dist/storage/repositories/index-connection.js +1 -3
- package/dist/storage/repositories/index-entries-repository.js +77 -68
- package/dist/storage/repositories/index-entry-schema.js +16 -25
- package/dist/storage/repositories/index-fts-repository.js +29 -263
- package/dist/storage/repositories/index-meta-repository.js +0 -29
- package/dist/storage/repositories/index-schema.js +115 -122
- package/dist/storage/repositories/index-utility-repository.js +1 -1
- package/dist/storage/repositories/index-vec-repository.js +21 -334
- package/dist/storage/repositories/units-repository.js +510 -0
- package/docs/migration/release-notes/0.9.15.md +34 -36
- package/docs/migration/release-notes/0.9.16.md +110 -0
- package/docs/migration/release-notes/README.md +5 -0
- package/docs/reference/cli.md +93 -87
- package/docs/reference/configuration.md +128 -89
- package/docs/reference/data-and-telemetry.md +2 -1
- package/package.json +1 -1
- package/schemas/akm-config.json +2 -58
- package/dist/indexer/index-db-contention.js +0 -56
- package/dist/indexer/index-rebuild-lock.js +0 -73
- package/dist/indexer/materialize-embeddings.js +0 -771
- package/dist/indexer/passes/dir-staleness.js +0 -161
- 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,
|
|
28
|
-
import { getIndexedMarkdownFragment, getIndexedMarkdownFragments,
|
|
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 {
|
|
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,
|
|
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
|
|
249
|
-
//
|
|
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
|
|
329
|
-
// synchronously while the HTTP/local embedding request is
|
|
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 {
|
|
368
|
+
const { embedMs, mode, semanticWarning, unitScored } = await collectSearchSignals(db, query, limit * 3, typeFilter, defaultExcludes, config);
|
|
332
369
|
const tRank0 = Date.now();
|
|
333
|
-
|
|
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
|
-
//
|
|
423
|
-
//
|
|
424
|
-
//
|
|
425
|
-
//
|
|
426
|
-
//
|
|
427
|
-
//
|
|
428
|
-
|
|
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(
|
|
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
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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
|
-
:
|
|
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:
|
|
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
|
-
//
|
|
796
|
-
//
|
|
797
|
-
//
|
|
798
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (`
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
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
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
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
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
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;
|