akm-cli 0.9.13 → 0.9.14-beta.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.
@@ -34,6 +34,7 @@
34
34
  * Pure, type-only imports (no cycle participation).
35
35
  */
36
36
  import path from "node:path";
37
+ import { getMarkdownFragmentContent, hasMarkdownFragmentContent, setMarkdownFragmentContent, } from "../passes/metadata.js";
37
38
  /**
38
39
  * Reconstruct the `IndexDocument` an `IndexDocument` was mapped from. First-class
39
40
  * IndexDocument members and the `documentJson`-carried extras are both restored;
@@ -60,6 +61,8 @@ export function indexDocumentToStashEntry(doc) {
60
61
  entry.content = doc.content;
61
62
  if (doc.contentTruncated !== undefined)
62
63
  entry.contentTruncated = doc.contentTruncated;
64
+ if (hasMarkdownFragmentContent(doc))
65
+ setMarkdownFragmentContent(entry, getMarkdownFragmentContent(doc));
63
66
  if (doc.ownsPresentation !== undefined)
64
67
  entry.ownsPresentation = doc.ownsPresentation;
65
68
  if (doc.updated !== undefined)
@@ -30,7 +30,7 @@ import path from "node:path";
30
30
  import { akmAdapter } from "../../core/adapter/adapters/akm-adapter.js";
31
31
  import { compareCodePoints } from "../../core/common.js";
32
32
  import { canonicalizeWorkflowName } from "../../core/recognition-util.js";
33
- import { resolveUniqueWorkflowSource, WorkflowSourceRejectionError, workflowNameForSourcePath, } from "../../workflows/source-files.js";
33
+ import { resolveWorkflowSourceDomains, workflowNameForSourcePath } from "../../workflows/source-files.js";
34
34
  import { compileWorkflowSource } from "../../workflows/source-ir/compile.js";
35
35
  import { buildMetadataSkipWarning } from "../passes/metadata.js";
36
36
  import { buildFileContext } from "../walk/file-context.js";
@@ -53,32 +53,41 @@ export function drainDirDocuments(adapter, component, fileContexts) {
53
53
  const conceptIdByFile = new Map();
54
54
  const rejectedPaths = new Set();
55
55
  const rejectedConceptIds = new Set();
56
- const workflowLookups = new Map();
57
- for (const file of fileContexts) {
56
+ // A full directory drain may contain both peer workflow formats for one
57
+ // canonical ref. Ownership arbitration must happen *before* recognition:
58
+ // otherwise both documents reach the persistence fold and SQLite's final
59
+ // row is determined by the walk/readdir order. Resolve exactly the paths
60
+ // this drain owns, so a full scan retains only the deterministic `.md`
61
+ // winner while a targeted one-file reindex deliberately keeps its written
62
+ // source and therefore marks an existing peer row stale for read fallback.
63
+ const workflowOwnerPathByCanonicalName = new Map(resolveWorkflowSourceDomains(component.root, adapter.id, fileContexts.map((file) => file.absPath))
64
+ .filter((resolution) => resolution.source !== undefined)
65
+ .map((resolution) => [resolution.canonicalName, path.resolve(resolution.source.path)]));
66
+ const invalidWorkflowOwnerNames = new Set();
67
+ const orderedFileContexts = [...fileContexts].sort((left, right) => {
68
+ const leftName = workflowNameForSourcePath(component.root, adapter.id, left.absPath);
69
+ const rightName = workflowNameForSourcePath(component.root, adapter.id, right.absPath);
70
+ const leftOwner = leftName !== undefined &&
71
+ workflowOwnerPathByCanonicalName.get(canonicalizeWorkflowName(leftName)) === path.resolve(left.absPath);
72
+ const rightOwner = rightName !== undefined &&
73
+ workflowOwnerPathByCanonicalName.get(canonicalizeWorkflowName(rightName)) === path.resolve(right.absPath);
74
+ if (leftOwner !== rightOwner)
75
+ return leftOwner ? -1 : 1;
76
+ return compareCodePoints(left.absPath, right.absPath);
77
+ });
78
+ for (const file of orderedFileContexts) {
79
+ if (rejectedPaths.has(file.absPath))
80
+ continue;
58
81
  const workflowName = workflowNameForSourcePath(component.root, adapter.id, file.absPath);
59
82
  if (workflowName !== undefined) {
60
83
  const canonicalName = canonicalizeWorkflowName(workflowName);
61
- if (!workflowLookups.has(canonicalName))
62
- workflowLookups.set(canonicalName, workflowName);
63
- }
64
- }
65
- for (const [canonicalName, workflowName] of [...workflowLookups].sort(([left], [right]) => compareCodePoints(left, right))) {
66
- try {
67
- resolveUniqueWorkflowSource(component.root, adapter.id, workflowName);
68
- }
69
- catch (error) {
70
- if (!(error instanceof WorkflowSourceRejectionError))
71
- throw error;
72
- rejectedConceptIds.add(adapter.id === "akm" ? `workflows/${canonicalName}` : canonicalName);
73
- for (const relativePath of error.sourcePaths) {
74
- rejectedPaths.add(path.join(component.root, relativePath));
84
+ const ownerPath = workflowOwnerPathByCanonicalName.get(canonicalName);
85
+ if (ownerPath !== undefined &&
86
+ ownerPath !== path.resolve(file.absPath) &&
87
+ !invalidWorkflowOwnerNames.has(canonicalName)) {
88
+ continue;
75
89
  }
76
- warnings.push(error.message);
77
90
  }
78
- }
79
- for (const file of fileContexts) {
80
- if (rejectedPaths.has(file.absPath))
81
- continue;
82
91
  const doc = adapter.recognize(component, file);
83
92
  if (doc === null)
84
93
  continue;
@@ -92,6 +101,8 @@ export function drainDirDocuments(adapter, component, fileContexts) {
92
101
  const dropWarning = handleWorkflowDoc(doc, file, component.root);
93
102
  if (dropWarning !== null) {
94
103
  warnings.push(dropWarning);
104
+ if (workflowName !== undefined)
105
+ invalidWorkflowOwnerNames.add(canonicalizeWorkflowName(workflowName));
95
106
  continue;
96
107
  }
97
108
  if (doc.hash !== undefined)
@@ -17,10 +17,11 @@ import path from "node:path";
17
17
  import { buildActionFromContributors, defaultActionContributors } from "../../core/action-contributors.js";
18
18
  import { stashDirFor } from "../../core/asset/asset-placement.js";
19
19
  import { displayRef } from "../../core/asset/resolve-ref.js";
20
+ import { compareCodePoints } from "../../core/common.js";
20
21
  import { classifyPathAccess } from "../../core/path-access.js";
21
22
  import { getDbPath } from "../../core/paths.js";
22
23
  import { systemErrorCode } from "../../core/system-error.js";
23
- import { defaultRendererRegistry } from "../../core/type-presentation.js";
24
+ import { allowsFragmentRef, defaultRendererRegistry } from "../../core/type-presentation.js";
24
25
  import { normalizeEmbeddingEndpoint } from "../../llm/embedders/remote.js";
25
26
  import { assertIndexPathReadable, closeDatabase, openExistingDatabase, } from "../../storage/repositories/index-connection.js";
26
27
  import { getAllEntries, getBaseBeliefStatesForDerivedTwins, getEntryById, getEntryCount, getPositiveFeedbackCountsByIds, } from "../../storage/repositories/index-entries-repository.js";
@@ -186,13 +187,47 @@ export async function searchLocal(input) {
186
187
  }
187
188
  // ── Database search ─────────────────────────────────────────────────────────
188
189
  /**
189
- * Keep one deterministic ranking order before stable path deduplication. Exact
190
- * names survive the public score ceiling, while raw contributor differences
191
- * are quantized so utility-recency epsilon cannot reorder visible ties.
190
+ * Keep public scores in [0, 1] without flattening every boosted result to the
191
+ * same hard-clamped value. The ranking pipeline deliberately keeps its raw
192
+ * score for deterministic ordering before stable path deduplication; this
193
+ * monotone display projection preserves that order and leaves visible
194
+ * separation for graph, type, and project-context signals.
192
195
  */
196
+ function displaySearchScore(score) {
197
+ return 1 - Math.exp(-Math.max(0, score));
198
+ }
199
+ /**
200
+ * A final deterministic key for genuinely tied candidates. It deliberately
201
+ * excludes the asset name, filename, path, durable ref, and SQLite id: callers
202
+ * such as the memory-pack adapter generate each of those from an opaque source
203
+ * id, so using one here makes an otherwise equal search depend on that id.
204
+ *
205
+ * The normal AKM Markdown adapter keeps an H1 title in `content`; strip that
206
+ * one synthetic title too, because the adapter may derive it from the opaque
207
+ * filename. Identical remaining bodies are semantically indistinguishable at
208
+ * this ranking stage and intentionally continue to the existing name/path
209
+ * fallback for repeatable local presentation.
210
+ */
211
+ function asciiCaseFold(value) {
212
+ // SQLite's built-in lower() folds ASCII only unless a build opts into ICU.
213
+ // Keep this key deliberately in that portable shared subset instead of
214
+ // introducing locale-dependent JavaScript ordering for non-ASCII content.
215
+ return value.replace(/[A-Z]/g, (letter) => String.fromCharCode(letter.charCodeAt(0) + 32));
216
+ }
217
+ /** The portable byte-level title/body rule mirrored in index-fts-repository. */
218
+ export function canonicalContentTieKey(entry) {
219
+ const content = entry.content ?? "";
220
+ const newline = content.startsWith("# ") ? content.indexOf("\n") : -1;
221
+ // SQLite uses ltrim(value, char(13) || char(10) || ' ') after an exact '# '
222
+ // title and trim(value, ' ') otherwise. Keep exactly that deliberately
223
+ // narrow byte contract; do not use locale or Unicode-whitespace helpers.
224
+ const body = newline >= 0 ? content.slice(newline + 1).replace(/^[\r\n ]+/, "") : content;
225
+ const source = (body || entry.description || "").replace(/^ +| +$/g, "");
226
+ return Buffer.from(asciiCaseFold(source), "utf8").toString("hex");
227
+ }
193
228
  function buildSearchResultComparator(query) {
194
229
  const queryTokens = buildLexicalQueryPlan(query).tokens.map((token) => token.toLowerCase());
195
- const displayScore = (score) => Math.round(Math.min(1, Math.max(0, score)) * 10000) / 10000;
230
+ const displayScore = (score) => Math.round(displaySearchScore(score) * 10000) / 10000;
196
231
  const stableRankScore = (score) => Math.round(score * 10000) / 10000;
197
232
  return (a, b) => {
198
233
  const aNameTier = lexicalNameMatchTier(a.entry, queryTokens);
@@ -208,11 +243,28 @@ function buildSearchResultComparator(query) {
208
243
  const rawScoreDiff = stableRankScore(b.score) - stableRankScore(a.score);
209
244
  if (rawScoreDiff !== 0)
210
245
  return rawScoreDiff;
246
+ // Ceiling values are intentionally allowed to demote visibility, but not
247
+ // 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`.
251
+ const preCeilingRelevance = (item) => item.preRelaxedCeilingScore ?? item.preCeilingScore ?? item.score;
252
+ const ceilingDiff = stableRankScore(preCeilingRelevance(b)) - stableRankScore(preCeilingRelevance(a));
253
+ if (ceilingDiff !== 0)
254
+ return ceilingDiff;
211
255
  const nameDiff = bNameTier - aNameTier;
212
256
  if (nameDiff !== 0)
213
257
  return nameDiff;
214
258
  const typeDiff = typeBoostFor(b.entry.type) - typeBoostFor(a.entry.type);
215
- return typeDiff || a.filePath.localeCompare(b.filePath);
259
+ if (typeDiff !== 0)
260
+ return typeDiff;
261
+ // Keep opaque generated IDs out of the final relevance tie-break. This
262
+ // runs only after every ranking contributor (including the #940 preserved
263
+ // pre-ceiling evidence), exact-name, and type comparison has tied.
264
+ const contentDiff = compareCodePoints(canonicalContentTieKey(a.entry), canonicalContentTieKey(b.entry));
265
+ if (contentDiff !== 0)
266
+ return contentDiff;
267
+ return a.filePath.localeCompare(b.filePath);
216
268
  };
217
269
  }
218
270
  async function searchDatabase(db, query, searchType, limit, stashDir, allSourceDirs, config, sources, rendererRegistry = defaultRendererRegistry, filters, includeProposed = false, beliefFilter = "all", restrictToSources = false, includeExcludedTypes = false, disableProjectContext = false, disableScopedUtility = false) {
@@ -279,8 +331,9 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceD
279
331
  const { ftsResults, embeddingScores, embedMs, mode, semanticWarning } = await collectSearchSignals(db, query, limit * 3, typeFilter, defaultExcludes, config);
280
332
  const tRank0 = Date.now();
281
333
  // ── Score normalization ──────────────────────────────────────────────
282
- // Normalized BM25 + cosine similarity with weighted addition
283
- // (FTS 0.7, vector 0.3) for well-differentiated combined scores.
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.
284
337
  const ftsScoreMap = normalizeFtsScores(ftsResults);
285
338
  // Build embedding score map (cosine similarities already 0-1)
286
339
  const embedScoreMap = new Map();
@@ -400,11 +453,11 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceD
400
453
  const selected = beliefFiltered.slice(0, limit);
401
454
  const hits = await Promise.all(selected.map((ranked) => {
402
455
  const { entry, filePath, score, rankingMode, utilityBoosted } = ranked;
403
- // CLAUDE.md locks SearchHit.score in [0,1]. The boost loop above can
404
- // exceed 1.0 (this was a pre-existing breach that #207's graph boost
405
- // up to ~1.05 additive contribution made detectable); clamp here
406
- // so the score handed to buildDbHit always satisfies the spec.
407
- const finalScore = Math.min(1, Math.max(0, score));
456
+ // CLAUDE.md locks SearchHit.score in [0,1]. The boost loop deliberately
457
+ // remains raw for ranking, then takes a monotone bounded projection at
458
+ // the public boundary so contributors do not collapse into hard-clamped
459
+ // ties.
460
+ const finalScore = displaySearchScore(score);
408
461
  return buildDbHit({
409
462
  entry,
410
463
  path: filePath,
@@ -413,6 +466,7 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceD
413
466
  query,
414
467
  rankingMode,
415
468
  lexicalMatch: ranked.lexicalMatch,
469
+ fragmentId: ranked.fragmentId,
416
470
  defaultStashDir: stashDir,
417
471
  allSourceDirs,
418
472
  sources,
@@ -728,7 +782,11 @@ export async function buildDbHit(input) {
728
782
  (source && path.resolve(source.path) === path.resolve(input.defaultStashDir)
729
783
  ? (input.bundleId ?? undefined)
730
784
  : undefined);
731
- const ref = resolveSearchHitRef(input.entry, input, defaultBundleId);
785
+ const parentRef = resolveSearchHitRef(input.entry, input, defaultBundleId);
786
+ // Fragments prove lexical relevance, but executable assets must retain the
787
+ // parent ref consumed by their advertised action (for example workflow run).
788
+ // The central type-presentation contract opts those types out explicitly.
789
+ const ref = input.fragmentId && allowsFragmentRef(input.entry.type) ? `${parentRef}#${input.fragmentId}` : parentRef;
732
790
  const editable = isEditable(absolutePath, input.config, input.sources);
733
791
  const estimatedTokens = typeof input.entry.fileSize === "number" ? Math.round(input.entry.fileSize / 4) : undefined;
734
792
  const hit = {
@@ -0,0 +1,35 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ import { buildLexicalQueryPlan } from "./fts-query.js";
5
+ /**
6
+ * Tokenize a display name through the same Unicode-aware lexical planner as a
7
+ * query. Ranking must not invent a second, punctuation-dependent name grammar.
8
+ */
9
+ export function lexicalNameTokens(name) {
10
+ return buildLexicalQueryPlan(name).tokens.map((token) => token.toLowerCase());
11
+ }
12
+ /**
13
+ * Structural name-token evidence. Exact tokens are always meaningful; fuzzy
14
+ * prefix evidence requires three code points on both sides so a question's
15
+ * short words or digits cannot match an opaque generated storage name.
16
+ */
17
+ export function structuralNameTokenMatch(left, right) {
18
+ return (left === right ||
19
+ (Math.min([...left].length, [...right].length) >= 3 && (left.startsWith(right) || right.startsWith(left))));
20
+ }
21
+ /**
22
+ * Match a query phrase only across complete, contiguous name tokens. This is
23
+ * deliberately not raw substring matching: `000` must not become name
24
+ * evidence merely because an opaque token happens to be `z9xq000`.
25
+ */
26
+ export function structuralNamePhraseMatch(nameTokens, queryTokens) {
27
+ if (queryTokens.length === 0 || queryTokens.length > nameTokens.length)
28
+ return false;
29
+ for (let start = 0; start <= nameTokens.length - queryTokens.length; start += 1) {
30
+ if (queryTokens.every((queryToken, index) => structuralNameTokenMatch(nameTokens[start + index], queryToken))) {
31
+ return true;
32
+ }
33
+ }
34
+ return false;
35
+ }
@@ -3,6 +3,7 @@
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
4
  import { isKnownType } from "../../core/recognition-util.js";
5
5
  import { computeGraphBoost } from "../graph/graph-boost.js";
6
+ import { lexicalNameTokens, structuralNamePhraseMatch, structuralNameTokenMatch } from "./name-match.js";
6
7
  import { attachSearchHitAttribution } from "./search-attribution.js";
7
8
  /**
8
9
  * Chunk 1.5 (D1.5-5) — retyped from `Record<string, number>` to a FULL
@@ -105,13 +106,11 @@ function beliefStateBoost(item) {
105
106
  * stash-conventions-code-spec.md — corrections demotion).
106
107
  *
107
108
  * Why the additive {@link beliefStateBoost} penalties alone are not enough:
108
- * keyword base scores are min-max normalized into [0.3, 1.0]
109
- * (`normalizeFtsScores`), so the spread between the best FTS hit and its
110
- * runner-up can be as large as 0.7 and the boost sum then MULTIPLIES the
111
- * base (`score *= 1 + boostSum`, {@link applyScoreContributors}). A
112
- * superseded incumbent that is the best keyword match for a query therefore
113
- * stays clamp-pinned at 1.0 above its own correction no matter what additive
114
- * penalty it receives — defeating the corrections pattern's point ("so the
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
115
114
  * ranker demotes the stale version instead of letting it outrank your fix").
116
115
  *
117
116
  * The ceilings guarantee the demotion while keeping flagged entries VISIBLE:
@@ -161,11 +160,10 @@ const exactNameRankingContributor = {
161
160
  if (nameBase === ctx.queryLower || nameLower === ctx.queryLower) {
162
161
  return 2.0;
163
162
  }
164
- if (nameBase.includes(ctx.queryLower) || ctx.queryLower.includes(nameBase)) {
163
+ const nameTokens = lexicalNameTokens(nameBase);
164
+ if (structuralNamePhraseMatch(nameTokens, ctx.queryTokens))
165
165
  return 1.0;
166
- }
167
- const nameTokens = nameBase.split(/[-_\s]+/).filter(Boolean);
168
- const matchCount = ctx.queryTokens.filter((qt) => nameTokens.some((nt) => nt === qt || nt.includes(qt))).length;
166
+ const matchCount = ctx.queryTokens.filter((qt) => nameTokens.some((nt) => structuralNameTokenMatch(nt, qt))).length;
169
167
  return matchCount > 0 ? Math.min(0.9, matchCount * 0.3) : 0;
170
168
  },
171
169
  };
@@ -245,7 +243,12 @@ const aliasRankingContributor = {
245
243
  const descriptionRankingContributor = {
246
244
  name: "description-ranking",
247
245
  appliesTo(item) {
248
- return typeof item.entry.description === "string" && item.entry.description.length > 0;
246
+ // A relaxed FTS query admits an OR pool. Awarding a flat +0.1 merely
247
+ // because of a partial description coincidence double-counts a weak
248
+ // signal and can outrank materially stronger BM25 body evidence. The FTS
249
+ // score already accounts for descriptions; retain this secondary boost
250
+ // only for conjunctive candidates.
251
+ return (item.lexicalMatch !== "relaxed" && typeof item.entry.description === "string" && item.entry.description.length > 0);
249
252
  },
250
253
  adjust(item, ctx) {
251
254
  const descLower = item.entry.description?.toLowerCase() ?? "";
@@ -1,20 +1,36 @@
1
1
  // This Source Code Form is subject to the terms of the Mozilla Public
2
2
  // License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ import { stableFtsScore } from "../../core/lexical-score.js";
4
5
  import { getUtilityScoresByIds } from "../../storage/repositories/index-utility-repository.js";
5
6
  import { buildLexicalQueryPlan } from "./fts-query.js";
7
+ import { lexicalNameTokens, structuralNameTokenMatch } from "./name-match.js";
6
8
  import { applyBeliefStateScoreCeiling, applyScoreContributors, applyUtilityContributors, defaultRankingContributors, defaultUtilityRankingContributors, } from "./ranking-contributors.js";
9
+ /**
10
+ * Lower bounds keep a lexical hit competitive with a vector-only neighbour;
11
+ * the upper bound deliberately leaves room for the ranking contributors that
12
+ * run after retrieval (notably the bounded graph boost). This is a
13
+ * calibration for the one search pipeline, not a claim that BM25 is
14
+ * comparable across different queries or FTS tables.
15
+ */
16
+ /**
17
+ * Convert FTS5's negative BM25 value into the lexical contribution used by
18
+ * this pipeline. The transform is fixed and monotone: it depends only on a
19
+ * row's own BM25 value, so appending weaker candidates cannot rewrite an
20
+ * existing row's score. FTS5 commonly emits relevance near 1e-6 for broad
21
+ * queries, so first put relevance on a log scale around that observed value.
22
+ * The shape constant intentionally makes the curve approach its ceiling
23
+ * slowly: rare-term scores retain separation instead of all reading as 0.8.
24
+ *
25
+ * FTS5 produces finite non-positive values in normal operation. Keeping the
26
+ * defensive cases here finite makes this boundary safe if a driver or fixture
27
+ * hands us an invalid value: `-Infinity` is the strongest possible match,
28
+ * while NaN, +Infinity, and positive scores contribute no lexical evidence.
29
+ */
7
30
  export function normalizeFtsScores(results) {
8
31
  const ftsScoreMap = new Map();
9
- if (results.length === 0)
10
- return ftsScoreMap;
11
- const bestBm25 = results[0].bm25Score;
12
- const worstBm25 = results[results.length - 1].bm25Score;
13
- const range = bestBm25 - worstBm25;
14
32
  for (const result of results) {
15
- const normalized = range !== 0 ? (result.bm25Score - worstBm25) / range : 1.0;
16
- const ftsScore = 0.3 + normalized * 0.7;
17
- ftsScoreMap.set(result.id, { score: ftsScore, result });
33
+ ftsScoreMap.set(result.id, { score: result.lexicalScore ?? stableFtsScore(result.bm25Score), result });
18
34
  }
19
35
  return ftsScoreMap;
20
36
  }
@@ -38,6 +54,7 @@ export function combineSearchScores(options) {
38
54
  itemRef: result.itemRef,
39
55
  bundleId: result.bundleId,
40
56
  conceptId: result.conceptId,
57
+ fragmentId: result.fragmentId,
41
58
  });
42
59
  }
43
60
  for (const [id, cosine] of options.embedScoreMap) {
@@ -98,9 +115,8 @@ export function applyRankingRules(options) {
98
115
  applyRelaxedLexicalScoreCeiling(item, queryTokens);
99
116
  // SPEC-5: demoting belief states (superseded/contradicted/archived/
100
117
  // deprecated) cap the FINAL score. The additive belief penalty inside the
101
- // multiplicative boost sum cannot overcome the FTS min-max normalization
102
- // spread, so without the ceiling a superseded incumbent that is the best
103
- // keyword match outranks its own correction forever.
118
+ // multiplicative boost sum can still overwhelm an additive belief penalty,
119
+ // so without the ceiling a superseded incumbent can outrank its correction.
104
120
  applyBeliefStateScoreCeiling(item);
105
121
  }
106
122
  return options.items;
@@ -115,14 +131,12 @@ export function lexicalNameMatchTier(entry, queryTokens) {
115
131
  if (queryTokens.length === 0)
116
132
  return 0;
117
133
  const nameBase = entry.name.toLowerCase().split("/").pop() ?? entry.name.toLowerCase();
118
- const nameTokens = buildLexicalQueryPlan(nameBase).tokens.map((token) => token.toLowerCase());
119
- const tokenMatches = (left, right) => left === right ||
120
- (Math.min([...left].length, [...right].length) >= 3 && (left.startsWith(right) || right.startsWith(left)));
134
+ const nameTokens = lexicalNameTokens(nameBase);
121
135
  if (nameTokens.length === queryTokens.length &&
122
- nameTokens.every((token, index) => tokenMatches(token, queryTokens[index]))) {
136
+ nameTokens.every((token, index) => structuralNameTokenMatch(token, queryTokens[index]))) {
123
137
  return 3;
124
138
  }
125
- const matched = queryTokens.filter((token) => nameTokens.some((nameToken) => tokenMatches(nameToken, token))).length;
139
+ const matched = queryTokens.filter((token) => nameTokens.some((nameToken) => structuralNameTokenMatch(nameToken, token))).length;
126
140
  if (matched === queryTokens.length)
127
141
  return 2;
128
142
  return matched > 0 ? 1 : 0;
@@ -130,10 +144,20 @@ export function lexicalNameMatchTier(entry, queryTokens) {
130
144
  /**
131
145
  * A relaxed OR query admits intentionally weak candidates. Candidates with no
132
146
  * query token in their name remain visible for body-only recall, but cannot
133
- * saturate at the same displayed score as stronger name-bearing recoveries.
147
+ * share the same bounded displayed score as stronger name-bearing recoveries.
148
+ * The raw ceiling is 0.65; the public score projection is applied later, so
149
+ * callers never literally receive `0.65` just because this ceiling bound.
150
+ *
151
+ * Preserve the pre-ceiling relevance separately from `preCeilingScore`, which
152
+ * belongs to belief-state demotion and may be written afterwards. A relaxed,
153
+ * belief-demoted candidate otherwise loses both its body relevance and its
154
+ * ordering signal when the second ceiling overwrites the first.
134
155
  */
135
156
  function applyRelaxedLexicalScoreCeiling(item, queryTokens) {
136
157
  if (item.lexicalMatch !== "relaxed" || lexicalNameMatchTier(item.entry, queryTokens) > 0)
137
158
  return;
138
- item.score = Math.min(item.score, RELAXED_NON_NAME_SCORE_CEILING);
159
+ if (item.score > RELAXED_NON_NAME_SCORE_CEILING) {
160
+ item.preRelaxedCeilingScore = item.score;
161
+ item.score = RELAXED_NON_NAME_SCORE_CEILING;
162
+ }
139
163
  }
@@ -22,6 +22,18 @@ export function recentShowCount(ref) {
22
22
  return 0;
23
23
  }
24
24
  }
25
+ /** Compare search/display selectors by their durable parent asset identity. */
26
+ function traceParentRef(ref) {
27
+ try {
28
+ const parsed = parseBundleRef(ref);
29
+ return makeBundleRef(parsed.bundle, parsed.conceptId);
30
+ }
31
+ catch {
32
+ // Registry result refs are not local bundle refs and cannot have a local
33
+ // selector. Keep their existing exact comparison behavior.
34
+ return ref;
35
+ }
36
+ }
25
37
  function appendShowTrace(ref, type, name) {
26
38
  appendEvent({ eventType: "show", ref, metadata: { type, name } });
27
39
  try {
@@ -34,7 +46,7 @@ function appendShowTrace(ref, type, name) {
34
46
  if (!event.ts || new Date(event.ts).getTime() < cutoffMs)
35
47
  return false;
36
48
  const refs = event.metadata?.resultRefs ?? [];
37
- return refs.includes(ref);
49
+ return refs.some((candidate) => traceParentRef(candidate) === ref);
38
50
  });
39
51
  if (!matchingSearch)
40
52
  return;
@@ -45,7 +57,7 @@ function appendShowTrace(ref, type, name) {
45
57
  metadata: {
46
58
  query: matchingSearch.metadata?.query,
47
59
  searchTs: matchingSearch.ts,
48
- rankPosition: resultRefs.indexOf(ref),
60
+ rankPosition: resultRefs.findIndex((candidate) => traceParentRef(candidate) === ref),
49
61
  },
50
62
  });
51
63
  }
@@ -20,6 +20,7 @@
20
20
  */
21
21
  import systemPromptTemplate from "../assets/prompts/graph-extract-system.md" with { type: "text" };
22
22
  import userPromptTemplate from "../assets/prompts/graph-extract-user-prompt.md" with { type: "text" };
23
+ import { splitMarkdownFragmentStats } from "../core/asset/markdown-fragments.js";
23
24
  import { toErrorMessage } from "../core/common.js";
24
25
  import { ConfigError } from "../core/errors.js";
25
26
  import { parseEmbeddedJsonResponse } from "../core/parse.js";
@@ -117,79 +118,29 @@ function normalizeBatchState(state) {
117
118
  state.nonArrayBatchFailures = Math.max(0, state.nonArrayBatchFailures ?? 0);
118
119
  return state;
119
120
  }
120
- function splitParagraph(text, maxChars) {
121
- if (text.length <= maxChars)
122
- return { chunks: [text], truncationCount: 0 };
123
- const chunks = [];
124
- let truncationCount = 0;
125
- let remaining = text;
126
- while (remaining.length > maxChars) {
127
- let splitAt = remaining.lastIndexOf(" ", maxChars);
128
- if (splitAt < Math.floor(maxChars * 0.6))
129
- splitAt = maxChars;
130
- const piece = remaining.slice(0, splitAt).trim();
131
- if (piece)
132
- chunks.push(piece);
133
- remaining = remaining.slice(splitAt).trim();
134
- truncationCount += 1;
135
- }
136
- if (remaining)
137
- chunks.push(remaining);
138
- return { chunks, truncationCount };
139
- }
140
121
  function splitBodyIntoChunks(body, maxChars = MAX_CHUNK_BODY_CHARS) {
141
- const sections = body
142
- .split(/\n(?=#{1,6}\s)/)
143
- .map((section) => section.trim())
144
- .filter(Boolean);
145
- if (sections.length === 0)
146
- return { chunks: [body.trim()].filter(Boolean), truncationCount: 0 };
122
+ const split = splitMarkdownFragmentStats(body, maxChars);
123
+ // Graph extraction keeps its historical cost shape: adjacent safe fragments
124
+ // share one LLM call whenever they fit. The fragment splitter is still the
125
+ // sole boundary authority; this is only prompt packing, never a second
126
+ // parser/chunker. Hard splits stay isolated and telemetry remains the core
127
+ // split count rather than counting ordinary heading boundaries.
147
128
  const chunks = [];
148
129
  let current = "";
149
- let truncationCount = 0;
150
- const flush = () => {
151
- const trimmed = current.trim();
152
- if (trimmed)
153
- chunks.push(trimmed);
154
- current = "";
155
- };
156
- for (const section of sections) {
157
- if (section.length <= maxChars) {
158
- const candidate = current ? `${current}\n\n${section}` : section;
159
- if (candidate.length <= maxChars)
160
- current = candidate;
161
- else {
162
- flush();
163
- current = section;
164
- }
165
- continue;
130
+ for (const fragment of split.fragments) {
131
+ const candidate = current ? `${current}\n\n${fragment.text}` : fragment.text;
132
+ if (candidate.length <= maxChars) {
133
+ current = candidate;
166
134
  }
167
- const paragraphs = section
168
- .split(/\n\s*\n/)
169
- .map((part) => part.trim())
170
- .filter(Boolean);
171
- for (const paragraph of paragraphs) {
172
- if (paragraph.length <= maxChars) {
173
- const candidate = current ? `${current}\n\n${paragraph}` : paragraph;
174
- if (candidate.length <= maxChars)
175
- current = candidate;
176
- else {
177
- flush();
178
- current = paragraph;
179
- }
180
- continue;
181
- }
182
- flush();
183
- const split = splitParagraph(paragraph, maxChars);
184
- truncationCount += split.truncationCount;
185
- for (const piece of split.chunks) {
186
- if (piece.length <= maxChars)
187
- chunks.push(piece);
188
- }
135
+ else {
136
+ if (current)
137
+ chunks.push(current);
138
+ current = fragment.text;
189
139
  }
190
140
  }
191
- flush();
192
- return { chunks, truncationCount };
141
+ if (current)
142
+ chunks.push(current);
143
+ return { chunks, truncationCount: split.hardSplitCount };
193
144
  }
194
145
  /** Consistency weight for blending chunk-agreement with LLM confidence. */
195
146
  const CONSISTENCY_WEIGHT = 0.4;