@untangle-code/search 0.0.0-stage → 0.1.0

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 (140) hide show
  1. package/LICENSE +661 -0
  2. package/NOTICE +90 -0
  3. package/README.md +433 -2
  4. package/THIRD_PARTY_LICENSES.md +29 -0
  5. package/dist/benchmark-integrity.d.ts +12 -0
  6. package/dist/benchmark-integrity.d.ts.map +1 -0
  7. package/dist/benchmark-integrity.js +10 -0
  8. package/dist/benchmark-integrity.js.map +1 -0
  9. package/dist/bm25.d.ts +46 -0
  10. package/dist/bm25.d.ts.map +1 -0
  11. package/dist/bm25.js +105 -0
  12. package/dist/bm25.js.map +1 -0
  13. package/dist/deadline.d.ts +4 -0
  14. package/dist/deadline.d.ts.map +1 -0
  15. package/dist/deadline.js +54 -0
  16. package/dist/deadline.js.map +1 -0
  17. package/dist/dedupe.d.ts +48 -0
  18. package/dist/dedupe.d.ts.map +1 -0
  19. package/dist/dedupe.js +58 -0
  20. package/dist/dedupe.js.map +1 -0
  21. package/dist/diversify.d.ts +45 -0
  22. package/dist/diversify.d.ts.map +1 -0
  23. package/dist/diversify.js +30 -0
  24. package/dist/diversify.js.map +1 -0
  25. package/dist/engine.d.ts +157 -0
  26. package/dist/engine.d.ts.map +1 -0
  27. package/dist/engine.js +1498 -0
  28. package/dist/engine.js.map +1 -0
  29. package/dist/enrichment.d.ts +16 -0
  30. package/dist/enrichment.d.ts.map +1 -0
  31. package/dist/enrichment.js +92 -0
  32. package/dist/enrichment.js.map +1 -0
  33. package/dist/filter.d.ts +86 -0
  34. package/dist/filter.d.ts.map +1 -0
  35. package/dist/filter.js +166 -0
  36. package/dist/filter.js.map +1 -0
  37. package/dist/findability.d.ts +144 -0
  38. package/dist/findability.d.ts.map +1 -0
  39. package/dist/findability.js +393 -0
  40. package/dist/findability.js.map +1 -0
  41. package/dist/format.d.ts +28 -0
  42. package/dist/format.d.ts.map +1 -0
  43. package/dist/format.js +172 -0
  44. package/dist/format.js.map +1 -0
  45. package/dist/fusion.d.ts +47 -0
  46. package/dist/fusion.d.ts.map +1 -0
  47. package/dist/fusion.js +84 -0
  48. package/dist/fusion.js.map +1 -0
  49. package/dist/glob.d.ts +50 -0
  50. package/dist/glob.d.ts.map +1 -0
  51. package/dist/glob.js +144 -0
  52. package/dist/glob.js.map +1 -0
  53. package/dist/grep.d.ts +76 -0
  54. package/dist/grep.d.ts.map +1 -0
  55. package/dist/grep.js +475 -0
  56. package/dist/grep.js.map +1 -0
  57. package/dist/index.d.ts +42 -0
  58. package/dist/index.d.ts.map +1 -0
  59. package/dist/index.js +63 -0
  60. package/dist/index.js.map +1 -0
  61. package/dist/ledger.d.ts +45 -0
  62. package/dist/ledger.d.ts.map +1 -0
  63. package/dist/ledger.js +39 -0
  64. package/dist/ledger.js.map +1 -0
  65. package/dist/payload.d.ts +108 -0
  66. package/dist/payload.d.ts.map +1 -0
  67. package/dist/payload.js +263 -0
  68. package/dist/payload.js.map +1 -0
  69. package/dist/port.d.ts +112 -0
  70. package/dist/port.d.ts.map +1 -0
  71. package/dist/port.js +2 -0
  72. package/dist/port.js.map +1 -0
  73. package/dist/product-complexity.d.ts +89 -0
  74. package/dist/product-complexity.d.ts.map +1 -0
  75. package/dist/product-complexity.js +1367 -0
  76. package/dist/product-complexity.js.map +1 -0
  77. package/dist/regex-safety.d.ts +75 -0
  78. package/dist/regex-safety.d.ts.map +1 -0
  79. package/dist/regex-safety.js +902 -0
  80. package/dist/regex-safety.js.map +1 -0
  81. package/dist/relevance-prerequisites.d.ts +21 -0
  82. package/dist/relevance-prerequisites.d.ts.map +1 -0
  83. package/dist/relevance-prerequisites.js +47 -0
  84. package/dist/relevance-prerequisites.js.map +1 -0
  85. package/dist/relevance.d.ts +107 -0
  86. package/dist/relevance.d.ts.map +1 -0
  87. package/dist/relevance.js +199 -0
  88. package/dist/relevance.js.map +1 -0
  89. package/dist/retrieval.d.ts +46 -0
  90. package/dist/retrieval.d.ts.map +1 -0
  91. package/dist/retrieval.js +182 -0
  92. package/dist/retrieval.js.map +1 -0
  93. package/dist/scoring.d.ts +102 -0
  94. package/dist/scoring.d.ts.map +1 -0
  95. package/dist/scoring.js +175 -0
  96. package/dist/scoring.js.map +1 -0
  97. package/dist/sort.d.ts +21 -0
  98. package/dist/sort.d.ts.map +1 -0
  99. package/dist/sort.js +53 -0
  100. package/dist/sort.js.map +1 -0
  101. package/dist/symbol-match.d.ts +21 -0
  102. package/dist/symbol-match.d.ts.map +1 -0
  103. package/dist/symbol-match.js +72 -0
  104. package/dist/symbol-match.js.map +1 -0
  105. package/dist/tokenize.d.ts +41 -0
  106. package/dist/tokenize.d.ts.map +1 -0
  107. package/dist/tokenize.js +202 -0
  108. package/dist/tokenize.js.map +1 -0
  109. package/dist/types.d.ts +173 -0
  110. package/dist/types.d.ts.map +1 -0
  111. package/dist/types.js +42 -0
  112. package/dist/types.js.map +1 -0
  113. package/package.json +61 -3
  114. package/src/benchmark-integrity.ts +25 -0
  115. package/src/bm25.ts +135 -0
  116. package/src/deadline.ts +59 -0
  117. package/src/dedupe.ts +99 -0
  118. package/src/diversify.ts +71 -0
  119. package/src/engine.ts +1957 -0
  120. package/src/enrichment.ts +115 -0
  121. package/src/filter.ts +267 -0
  122. package/src/findability.ts +580 -0
  123. package/src/format.ts +228 -0
  124. package/src/fusion.ts +121 -0
  125. package/src/glob.ts +151 -0
  126. package/src/grep.ts +618 -0
  127. package/src/index.ts +83 -0
  128. package/src/ledger.ts +72 -0
  129. package/src/payload.ts +382 -0
  130. package/src/port.ts +133 -0
  131. package/src/product-complexity.ts +2168 -0
  132. package/src/regex-safety.ts +1002 -0
  133. package/src/relevance-prerequisites.ts +83 -0
  134. package/src/relevance.ts +328 -0
  135. package/src/retrieval.ts +299 -0
  136. package/src/scoring.ts +321 -0
  137. package/src/sort.ts +86 -0
  138. package/src/symbol-match.ts +87 -0
  139. package/src/tokenize.ts +213 -0
  140. package/src/types.ts +206 -0
@@ -0,0 +1,299 @@
1
+ /** Independent recall branches, bounded provider calls and deterministic failure diagnostics. */
2
+ import {
3
+ UntangleError,
4
+ compareCodepoints,
5
+ isUntangleError,
6
+ type ErrorCode,
7
+ type PayloadFilter,
8
+ type PortCallOptions,
9
+ type ScoredPointV1,
10
+ type ScrollPageV1,
11
+ type ToolWarningV1,
12
+ type VectorStore,
13
+ } from "@untangle-code/domain";
14
+
15
+ import { withSearchBranchDeadline } from "./deadline.js";
16
+ import { buildSymbolNameFilter } from "./filter.js";
17
+ import type { SearchSkipInput } from "./ledger.js";
18
+ import type { ProfileTargetV1, SparseVectorizer } from "./port.js";
19
+ import { symbolNameFilterCandidates } from "./symbol-match.js";
20
+ import type { SearchMode } from "./types.js";
21
+
22
+ export type RetrievalBranch = "dense" | "sparse" | "symbol";
23
+ type RetrievalTarget = Pick<ProfileTargetV1, "collection" | "embeddingProfileId" | "embedder">;
24
+
25
+ export interface IndependentRetrievalRequest {
26
+ vectorStore: Pick<VectorStore, "hybridQuery" | "scroll" | "backend">;
27
+ target: RetrievalTarget;
28
+ text: string;
29
+ filter: PayloadFilter;
30
+ candidateLimit: number;
31
+ mode: SearchMode;
32
+ sparseVectorizer?: SparseVectorizer;
33
+ branchTimeoutMs: number;
34
+ }
35
+
36
+ interface FailedBranch {
37
+ branch: RetrievalBranch;
38
+ status: "failed";
39
+ error: unknown;
40
+ }
41
+
42
+ type BranchOutcome<T> =
43
+ | FailedBranch
44
+ | { branch: RetrievalBranch; status: "not_requested" }
45
+ | { branch: RetrievalBranch; status: "succeeded"; result: T };
46
+
47
+ export interface IndependentRetrievalResult {
48
+ densePoints: readonly ScoredPointV1[];
49
+ sparsePoints: readonly ScoredPointV1[];
50
+ symbolPage?: ScrollPageV1;
51
+ completedBranches: ReadonlySet<RetrievalBranch>;
52
+ failures: readonly FailedBranch[];
53
+ warnings: ToolWarningV1[];
54
+ caveats: string[];
55
+ skipped: SearchSkipInput[];
56
+ }
57
+
58
+ /** The mode controls both recall and ranking, so it has one shared definition. */
59
+ export function branchesOfMode(mode: SearchMode): {
60
+ dense: boolean;
61
+ lexical: boolean;
62
+ symbol: boolean;
63
+ } {
64
+ switch (mode) {
65
+ case "semantic":
66
+ return { dense: true, lexical: false, symbol: false };
67
+ case "lexical":
68
+ return { dense: false, lexical: true, symbol: false };
69
+ case "symbol":
70
+ return { dense: false, lexical: false, symbol: true };
71
+ case "hybrid":
72
+ return { dense: true, lexical: true, symbol: true };
73
+ }
74
+ }
75
+
76
+ /** Cancellation is control flow, never a degraded retrieval result. */
77
+ export function rethrowCancellation(error: unknown, options?: PortCallOptions): void {
78
+ options?.signal?.throwIfAborted();
79
+ if (
80
+ (isUntangleError(error) && error.code === "JOB_CANCELLED") ||
81
+ (error instanceof Error && error.name === "AbortError")
82
+ ) {
83
+ throw error;
84
+ }
85
+ }
86
+
87
+ async function retrieveDense(
88
+ request: IndependentRetrievalRequest,
89
+ options: PortCallOptions,
90
+ ): Promise<ScoredPointV1[]> {
91
+ const { target } = request;
92
+ const preparedQuery = `${target.embedder.profile.queryPrefix}${request.text}`;
93
+ const dense = await target.embedder.embedQuery(preparedQuery, options);
94
+ options.signal?.throwIfAborted();
95
+ return request.vectorStore.hybridQuery(
96
+ target.collection,
97
+ {
98
+ dense,
99
+ filter: request.filter,
100
+ limit: request.candidateLimit,
101
+ candidateLimit: request.candidateLimit,
102
+ withPayload: true,
103
+ },
104
+ options,
105
+ );
106
+ }
107
+
108
+ async function retrieveSparse(
109
+ request: IndependentRetrievalRequest,
110
+ options: PortCallOptions,
111
+ ): Promise<ScoredPointV1[]> {
112
+ if (request.sparseVectorizer === undefined) {
113
+ throw new UntangleError("INDEX_INCOMPLETE", "no sparse vectorizer is wired", {
114
+ operation: "search_sparse_retrieval",
115
+ });
116
+ }
117
+ return request.vectorStore.hybridQuery(
118
+ request.target.collection,
119
+ {
120
+ sparse: request.sparseVectorizer(request.text),
121
+ filter: request.filter,
122
+ limit: request.candidateLimit,
123
+ candidateLimit: request.candidateLimit,
124
+ withPayload: true,
125
+ },
126
+ options,
127
+ );
128
+ }
129
+
130
+ async function retrieveSymbol(
131
+ request: IndependentRetrievalRequest,
132
+ options: PortCallOptions,
133
+ ): Promise<ScrollPageV1> {
134
+ const filter = buildSymbolNameFilter(request.filter, symbolNameFilterCandidates(request.text));
135
+ if (filter === undefined) return { points: [] };
136
+ return request.vectorStore.scroll(
137
+ request.target.collection,
138
+ { filter, limit: request.candidateLimit },
139
+ options,
140
+ );
141
+ }
142
+
143
+ /** Each branch produces an explicit outcome; a provider failure does not cancel its peers. */
144
+ async function executeBranch<T>(
145
+ branch: RetrievalBranch,
146
+ enabled: boolean,
147
+ request: IndependentRetrievalRequest,
148
+ retrieve: (request: IndependentRetrievalRequest, options: PortCallOptions) => Promise<T>,
149
+ options: PortCallOptions,
150
+ ): Promise<BranchOutcome<T>> {
151
+ if (!enabled) return { branch, status: "not_requested" };
152
+ try {
153
+ const result = await withSearchBranchDeadline(
154
+ branch,
155
+ request.mode === "hybrid" ? request.branchTimeoutMs : undefined,
156
+ retrieve.bind(undefined, request),
157
+ options,
158
+ request.vectorStore.backend === "lancedb" ? "LANCEDB_UNAVAILABLE" : "QDRANT_UNAVAILABLE",
159
+ );
160
+ options.signal?.throwIfAborted();
161
+ return { branch, status: "succeeded", result };
162
+ } catch (error) {
163
+ rethrowCancellation(error, options);
164
+ return {
165
+ branch,
166
+ status: "failed",
167
+ error:
168
+ isUntangleError(error) || branch === "dense" || request.vectorStore.backend !== "lancedb"
169
+ ? error
170
+ : new UntangleError(
171
+ "LANCEDB_UNAVAILABLE",
172
+ error instanceof Error ? error.message : String(error),
173
+ { operation: "search_retrieval", retryable: true, cause: error },
174
+ ),
175
+ };
176
+ }
177
+ }
178
+
179
+ function branchResult<T>(outcome: BranchOutcome<T>): T | undefined {
180
+ return outcome.status === "succeeded" ? outcome.result : undefined;
181
+ }
182
+
183
+ function isFailedBranch(outcome: BranchOutcome<unknown>): outcome is FailedBranch {
184
+ return outcome.status === "failed";
185
+ }
186
+
187
+ function branchFailureCode(failure: FailedBranch | undefined): ErrorCode {
188
+ if (failure !== undefined && isUntangleError(failure.error)) return failure.error.code;
189
+ return failure?.branch === "dense" ? "EMBEDDING_UNAVAILABLE" : "QDRANT_UNAVAILABLE";
190
+ }
191
+
192
+ function timeoutDetails(error: unknown): Record<string, unknown> {
193
+ if (!isUntangleError(error) || error.context["timedOut"] !== true) return {};
194
+ return { timedOut: true, timeoutMs: error.context["timeoutMs"] };
195
+ }
196
+
197
+ /** Build evidence once in branch-name order, independent of completion timing. */
198
+ function failureDiagnostics(
199
+ target: RetrievalTarget,
200
+ failures: readonly FailedBranch[],
201
+ ): Pick<IndependentRetrievalResult, "warnings" | "caveats" | "skipped"> {
202
+ const warnings: ToolWarningV1[] = [];
203
+ const caveats: string[] = [];
204
+ const skipped: SearchSkipInput[] = [];
205
+ for (const failure of failures) {
206
+ const { branch, error } = failure;
207
+ const message = error instanceof Error ? error.message : String(error);
208
+ const detail = `profile "${target.embeddingProfileId}" retrieval branch "${branch}" failed: ${message}`;
209
+ const timeout = timeoutDetails(error);
210
+ caveats.push(detail);
211
+ warnings.push({
212
+ code: "SEARCH_BRANCH_UNAVAILABLE",
213
+ message: detail,
214
+ details: {
215
+ embeddingProfileId: target.embeddingProfileId,
216
+ collection: target.collection,
217
+ branch,
218
+ causeCode: branchFailureCode(failure),
219
+ ...timeout,
220
+ },
221
+ });
222
+ if (timeout["timedOut"] === true) {
223
+ warnings.push({
224
+ code: "SEARCH_BRANCH_TIMEOUT",
225
+ message: detail,
226
+ details: { branch, timeoutMs: timeout["timeoutMs"] },
227
+ });
228
+ }
229
+ skipped.push({
230
+ subjectKind: "search_candidate",
231
+ subjectId: `${target.embeddingProfileId}:${branch}`,
232
+ reason: "provider_unavailable",
233
+ detail,
234
+ recoverable: true,
235
+ });
236
+ }
237
+ return { warnings, caveats, skipped };
238
+ }
239
+
240
+ /** Return all requested branch outcomes in fixed dense/sparse/symbol order. */
241
+ export async function retrieveIndependentBranches(
242
+ request: IndependentRetrievalRequest,
243
+ options?: PortCallOptions,
244
+ ): Promise<IndependentRetrievalResult> {
245
+ options?.signal?.throwIfAborted();
246
+ const active = branchesOfMode(request.mode);
247
+ const controller = new AbortController();
248
+ const linkedOptions: PortCallOptions = {
249
+ ...options,
250
+ signal:
251
+ options?.signal === undefined
252
+ ? controller.signal
253
+ : AbortSignal.any([options.signal, controller.signal]),
254
+ };
255
+ const [dense, sparse, symbol] = await Promise.all([
256
+ executeBranch("dense", active.dense, request, retrieveDense, linkedOptions),
257
+ executeBranch("sparse", active.lexical, request, retrieveSparse, linkedOptions),
258
+ executeBranch("symbol", active.symbol, request, retrieveSymbol, linkedOptions),
259
+ ]).finally(() => controller.abort());
260
+ options?.signal?.throwIfAborted();
261
+ const outcomes = [dense, sparse, symbol];
262
+ const failures = outcomes
263
+ .filter(isFailedBranch)
264
+ .sort((left, right) => compareCodepoints(left.branch, right.branch));
265
+ const completedBranches = new Set(
266
+ outcomes.filter((outcome) => outcome.status === "succeeded").map((outcome) => outcome.branch),
267
+ );
268
+ const symbolPage = branchResult(symbol);
269
+ return {
270
+ densePoints: branchResult(dense) ?? [],
271
+ sparsePoints: branchResult(sparse) ?? [],
272
+ ...(symbolPage === undefined ? {} : { symbolPage }),
273
+ completedBranches,
274
+ failures,
275
+ ...failureDiagnostics(request.target, failures),
276
+ };
277
+ }
278
+
279
+ /** The caller first records diagnostics, then distinguishes total failure from empty success. */
280
+ export function assertCompletedRetrieval(
281
+ result: IndependentRetrievalResult,
282
+ target: RetrievalTarget,
283
+ ): void {
284
+ if (result.completedBranches.size > 0) return;
285
+ const first = result.failures[0];
286
+ throw new UntangleError(
287
+ branchFailureCode(first),
288
+ `all requested retrieval branches failed for profile "${target.embeddingProfileId}": ${result.warnings.map((warning) => warning.message).join("; ")}`,
289
+ {
290
+ operation: "search_retrieval",
291
+ retryable: result.failures.some(({ error }) => !isUntangleError(error) || error.retryable),
292
+ context: {
293
+ collection: target.collection,
294
+ failedBranches: result.failures.map(({ branch }) => branch),
295
+ },
296
+ cause: first?.error,
297
+ },
298
+ );
299
+ }
package/src/scoring.ts ADDED
@@ -0,0 +1,321 @@
1
+ /**
2
+ * Score-Zerlegung (spec.md §13.3).
3
+ *
4
+ * Das ist keine Debug-Ausgabe. Die Zerlegung ist oeffentliche API und die
5
+ * Grundlage der Findability-Diagnose (vision.md §1d): ohne sie laesst sich
6
+ * „der richtige Treffer rankt niedrig" nicht von „es gibt keinen Treffer"
7
+ * unterscheiden, und genau diese Unterscheidung ist die Aussage ueber den
8
+ * Code, nicht ueber die Suche.
9
+ *
10
+ * Aufbau:
11
+ *
12
+ * final = base_hybrid
13
+ * + exact_symbol_boost + exact_path_boost + repo_priority_boost
14
+ * + graph_proximity_boost + recency_boost
15
+ * + complexity_modifier
16
+ * - duplicate_penalty
17
+ *
18
+ * Jeder Bestandteil traegt `rawValue` (das normalisierte Signal), `weight`
19
+ * (das Gewicht aus der Konfiguration) und `contribution = rawValue * weight`.
20
+ * `validateScoreBreakdown()` aus der Domain prueft genau diese Identitaet und
21
+ * die Summenprobe — die Tests dieses Pakets rufen sie auf jedem erzeugten
22
+ * Treffer auf.
23
+ *
24
+ * BEGRENZUNG DER BOOSTS (spec.md §13.3: „Heuristische Boosts DUERFEN
25
+ * Dense/BM25 nicht vollstaendig ueberstimmen"): Uebersteigt die Summe der
26
+ * Betraege aller Nicht-Basis-Bestandteile den Anteil {@link MAX_BOOST_SHARE}
27
+ * des Basisbetrags, werden ALLE Nicht-Basis-Gewichte mit demselben Faktor
28
+ * verkleinert und `clamped`/`clampReason` gesetzt. Verkleinert wird das
29
+ * GEWICHT, nicht der Beitrag: nur so bleibt `contribution = rawValue * weight`
30
+ * wahr und das Signal (`rawValue`) sichtbar.
31
+ */
32
+ import type { CodeChunkPayloadV1, ScoreBreakdownV1, ScoreComponentV1 } from "@untangle-code/domain";
33
+ import { RANKER_VERSION } from "@untangle-code/domain";
34
+
35
+ import type { FusionResult } from "./fusion.js";
36
+ import type { SearchCandidate } from "./payload.js";
37
+ import { readRawNumber } from "./payload.js";
38
+ import { matchSymbolName } from "./symbol-match.js";
39
+ import type { ComplexityBias } from "./types.js";
40
+
41
+ /**
42
+ * Hoechster zulaessiger Anteil aller Boosts/Strafen am Basisscore.
43
+ *
44
+ * 0.5 heisst: die Heuristik darf einen Treffer um hoechstens die Haelfte
45
+ * seines Basisscores verschieben. Damit bleibt der Basisanteil (`baseShare`
46
+ * aus `validateScoreBreakdown`) immer bei mindestens 1/1.5 ≈ 0.667 — der
47
+ * Basiszweig behaelt also strukturell die Mehrheit. Der Wert ist eine
48
+ * Politik-Entscheidung dieses Pakets und hier bewusst als benannte Konstante
49
+ * sichtbar statt als Zahl im Code verstreut.
50
+ */
51
+ export const MAX_BOOST_SHARE = 0.5;
52
+
53
+ /**
54
+ * Referenzwert der kognitiven Komplexitaet fuer den Komplexitaets-Modifikator.
55
+ * 25 ist der Punkt, ab dem der Modifikator seinen vollen Ausschlag erreicht;
56
+ * er ist eine Skalierungsgroesse fuer die Rangfolge und ausdruecklich KEINE
57
+ * Qualitaetsschwelle (Schwellen gehoeren in die Quality Gate, spec.md §18).
58
+ */
59
+ export const COMPLEXITY_REFERENCE_COGNITIVE = 25;
60
+
61
+ /** Gewichte der additiven Score-Bestandteile (Form aus `SearchConfig`). */
62
+ export interface BoostWeights {
63
+ exactSymbol: number;
64
+ exactPath: number;
65
+ repoPriority: number;
66
+ graphProximity: number;
67
+ recency: number;
68
+ duplicatePenalty: number;
69
+ }
70
+
71
+ /** Gewicht des Komplexitaets-Modifikators, wenn die Anfrage ihn anfordert. */
72
+ export const COMPLEXITY_MODIFIER_WEIGHT = 0.1;
73
+
74
+ /** Alles, was zur Bewertung eines einzelnen Kandidaten gebraucht wird. */
75
+ export interface ScoringContext {
76
+ queryText: string;
77
+ weights: BoostWeights;
78
+ complexityBias: ComplexityBias;
79
+ /** Repo-Prioritaet in [0, 1] je `repo_id`; fehlt = kein Bestandteil. */
80
+ repoPriorities: Readonly<Record<string, number>>;
81
+ /** Graph-Naehe in [0, 1] je `symbol_id`; fehlt = kein Bestandteil. */
82
+ graphProximity: ReadonlyMap<string, number>;
83
+ /** „Jetzt" in Epoch-Millisekunden, ueber den Clock-Port bezogen. */
84
+ nowEpochMs?: number;
85
+ /** Halbwertszeit des Recency-Boosts in Tagen. */
86
+ recencyHalfLifeDays: number;
87
+ }
88
+
89
+ function pathBoost(payload: CodeChunkPayloadV1, queryText: string): ScoreComponentV1 | null {
90
+ const query = queryText.trim();
91
+ if (query === "") {
92
+ return null;
93
+ }
94
+ const path = payload.file_path;
95
+ const segments = path.split("/");
96
+ if (path === query) {
97
+ return raw("exact_path_boost", 1, `file_path == "${path}"`);
98
+ }
99
+ if (segments.includes(query)) {
100
+ return raw("exact_path_boost", 0.75, `path segment == "${query}"`);
101
+ }
102
+ if (path.includes(query)) {
103
+ return raw("exact_path_boost", 0.5, `file_path contains "${query}"`);
104
+ }
105
+ return null;
106
+ }
107
+
108
+ function raw(kind: ScoreComponentV1["kind"], rawValue: number, evidence: string): ScoreComponentV1 {
109
+ // `weight` wird erst in `assembleScoreBreakdown` gesetzt; hier steht das
110
+ // rohe Signal mit seinem Beleg.
111
+ return { kind, rawValue, weight: 0, contribution: 0, evidence };
112
+ }
113
+
114
+ /** Zeitzerfall in [0, 1]: 1 heisst „gerade eben", 0.5 nach einer Halbwertszeit. */
115
+ export function recencyDecay(ageMs: number, halfLifeDays: number): number {
116
+ if (ageMs <= 0) {
117
+ return 1;
118
+ }
119
+ const halfLifeMs = halfLifeDays * 24 * 60 * 60 * 1000;
120
+ if (halfLifeMs <= 0) {
121
+ return 0;
122
+ }
123
+ return Math.pow(0.5, ageMs / halfLifeMs);
124
+ }
125
+
126
+ /**
127
+ * Komplexitaets-Modifikator in [-1, 1]. Positiv, wenn der Treffer der
128
+ * angeforderten Richtung entspricht.
129
+ */
130
+ export function complexityModifierRawValue(
131
+ payload: CodeChunkPayloadV1,
132
+ bias: ComplexityBias,
133
+ ): number | null {
134
+ if (bias === "none") {
135
+ return null;
136
+ }
137
+ const cognitive = payload.cognitive;
138
+ if (cognitive === undefined) {
139
+ return null;
140
+ }
141
+ const normalized = Math.min(cognitive / COMPLEXITY_REFERENCE_COGNITIVE, 1);
142
+ return bias === "prefer-simple" ? 1 - 2 * normalized : 2 * normalized - 1;
143
+ }
144
+
145
+ /**
146
+ * Baut die vollstaendige Score-Zerlegung eines Kandidaten.
147
+ *
148
+ * `duplicatePenaltyRawValue` kommt aus der Diversifizierung (siehe
149
+ * `diversify.ts`) und ist bereits in [0, 1]; das Vorzeichen setzt diese
150
+ * Funktion, damit die Strafe an genau EINER Stelle negativ wird.
151
+ */
152
+ export function assembleScoreBreakdown(
153
+ candidate: SearchCandidate,
154
+ fusion: FusionResult,
155
+ context: ScoringContext,
156
+ duplicatePenaltyRawValue = 0,
157
+ ): ScoreBreakdownV1 {
158
+ const { payload } = candidate;
159
+ const base: ScoreComponentV1 = {
160
+ kind: "base_hybrid",
161
+ rawValue: fusion.normalized,
162
+ weight: 1,
163
+ contribution: fusion.normalized,
164
+ evidence: describeBranches(fusion),
165
+ };
166
+
167
+ const boosts: ScoreComponentV1[] = [];
168
+
169
+ const symbolMatch = matchSymbolName(payload, context.queryText);
170
+ if (symbolMatch !== null) {
171
+ boosts.push(
172
+ withWeight(
173
+ raw("exact_symbol_boost", symbolMatch.strength, symbolMatch.evidence),
174
+ context.weights.exactSymbol,
175
+ ),
176
+ );
177
+ }
178
+
179
+ const pathComponent = pathBoost(payload, context.queryText);
180
+ if (pathComponent !== null) {
181
+ boosts.push(withWeight(pathComponent, context.weights.exactPath));
182
+ }
183
+
184
+ const priority = context.repoPriorities[payload.repo_id];
185
+ if (priority !== undefined && priority !== 0) {
186
+ boosts.push(
187
+ withWeight(
188
+ raw("repo_priority_boost", priority, `configured priority of repo "${payload.repo_id}"`),
189
+ context.weights.repoPriority,
190
+ ),
191
+ );
192
+ }
193
+
194
+ const proximity =
195
+ payload.symbol_id === undefined ? undefined : context.graphProximity.get(payload.symbol_id);
196
+ if (proximity !== undefined && proximity !== 0) {
197
+ boosts.push(
198
+ withWeight(
199
+ raw(
200
+ "graph_proximity_boost",
201
+ proximity,
202
+ `graph proximity of symbol "${payload.symbol_id ?? ""}"`,
203
+ ),
204
+ context.weights.graphProximity,
205
+ ),
206
+ );
207
+ }
208
+
209
+ const changedAt = readRawNumber(candidate.raw, "changed_at_epoch_ms");
210
+ if (changedAt !== undefined && context.nowEpochMs !== undefined) {
211
+ const decay = recencyDecay(context.nowEpochMs - changedAt, context.recencyHalfLifeDays);
212
+ if (decay > 0) {
213
+ boosts.push(
214
+ withWeight(
215
+ raw("recency_boost", decay, `changed_at_epoch_ms = ${changedAt}`),
216
+ context.weights.recency,
217
+ ),
218
+ );
219
+ }
220
+ }
221
+
222
+ const complexityRaw = complexityModifierRawValue(payload, context.complexityBias);
223
+ if (complexityRaw !== null) {
224
+ boosts.push(
225
+ withWeight(
226
+ raw(
227
+ "complexity_modifier",
228
+ complexityRaw,
229
+ `cognitive = ${String(payload.cognitive)} (bias "${context.complexityBias}", reference ${COMPLEXITY_REFERENCE_COGNITIVE})`,
230
+ ),
231
+ COMPLEXITY_MODIFIER_WEIGHT,
232
+ ),
233
+ );
234
+ }
235
+
236
+ if (duplicatePenaltyRawValue > 0) {
237
+ boosts.push(
238
+ withWeight(
239
+ raw(
240
+ "duplicate_penalty",
241
+ duplicatePenaltyRawValue,
242
+ `file "${payload.file_path}" is already represented higher in the ranking`,
243
+ ),
244
+ -context.weights.duplicatePenalty,
245
+ ),
246
+ );
247
+ }
248
+
249
+ const clamp = clampBoosts(base, boosts);
250
+ const components = [base, ...clamp.boosts];
251
+ const final = components.reduce((sum, component) => sum + component.contribution, 0);
252
+
253
+ return {
254
+ schemaVersion: 1,
255
+ final,
256
+ components,
257
+ inputs: {
258
+ ...(candidate.denseScore === undefined ? {} : { dense: candidate.denseScore }),
259
+ ...(candidate.denseRank === undefined ? {} : { denseRank: candidate.denseRank }),
260
+ ...(candidate.lexicalRank === undefined
261
+ ? {}
262
+ : { sparseRank: candidate.lexicalRank, lexicalRank: candidate.lexicalRank }),
263
+ ...(candidate.lexicalScore === undefined ? {} : { lexicalScore: candidate.lexicalScore }),
264
+ ...(candidate.symbolRank === undefined ? {} : { symbolRank: candidate.symbolRank }),
265
+ rrf: fusion.rrf,
266
+ },
267
+ rankerVersion: RANKER_VERSION,
268
+ clamped: clamp.clamped,
269
+ ...(clamp.reason === undefined ? {} : { clampReason: clamp.reason }),
270
+ };
271
+ }
272
+
273
+ function withWeight(component: ScoreComponentV1, weight: number): ScoreComponentV1 {
274
+ return { ...component, weight, contribution: component.rawValue * weight };
275
+ }
276
+
277
+ function describeBranches(fusion: FusionResult): string {
278
+ const parts: string[] = [];
279
+ for (const branch of ["dense", "lexical", "symbol"] as const) {
280
+ const rank = fusion.ranks[branch];
281
+ if (rank !== undefined) {
282
+ const weight = fusion.weights?.[branch];
283
+ parts.push(
284
+ weight === undefined ? `${branch}#${rank}` : `${branch}#${rank} x weight ${weight}`,
285
+ );
286
+ }
287
+ }
288
+ return parts.length === 0 ? "no branch contributed" : `RRF over ${parts.join(", ")}`;
289
+ }
290
+
291
+ interface ClampResult {
292
+ boosts: ScoreComponentV1[];
293
+ clamped: boolean;
294
+ reason?: string;
295
+ }
296
+
297
+ /** Setzt {@link MAX_BOOST_SHARE} durch (siehe Modulkommentar). */
298
+ export function clampBoosts(
299
+ base: ScoreComponentV1,
300
+ boosts: readonly ScoreComponentV1[],
301
+ ): ClampResult {
302
+ const boostMagnitude = boosts.reduce(
303
+ (sum, component) => sum + Math.abs(component.contribution),
304
+ 0,
305
+ );
306
+ const baseMagnitude = Math.abs(base.contribution);
307
+ const budget = baseMagnitude * MAX_BOOST_SHARE;
308
+ if (boostMagnitude <= budget || boostMagnitude === 0) {
309
+ return { boosts: [...boosts], clamped: false };
310
+ }
311
+ const factor = budget / boostMagnitude;
312
+ return {
313
+ boosts: boosts.map((component) => ({
314
+ ...component,
315
+ weight: component.weight * factor,
316
+ contribution: component.contribution * factor,
317
+ })),
318
+ clamped: true,
319
+ reason: `boost magnitude ${boostMagnitude.toFixed(6)} exceeded ${MAX_BOOST_SHARE} of the base score ${baseMagnitude.toFixed(6)}; all non-base weights scaled by ${factor.toFixed(6)} (spec.md §13.3)`,
320
+ };
321
+ }
package/src/sort.ts ADDED
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Sortierung der Trefferliste (Nutzerwunsch A4: „Sortierung").
3
+ *
4
+ * Jede Sortierung ist zweistufig: erst das gewaehlte Kriterium, dann — IMMER —
5
+ * der Dedupe-Schluessel nach Codepoints. Der zweite Schritt ist nicht Kosmetik:
6
+ * ohne ihn haengt die Reihenfolge gleichbewerteter Treffer an der
7
+ * Antwortreihenfolge des Speichers, und dieselbe Anfrage liefert auf zwei
8
+ * Maschinen zwei Listen. `String#localeCompare` kommt dabei nie zum Einsatz
9
+ * (docs/BUILD_PLAN.md §3).
10
+ *
11
+ * Treffer ohne den jeweiligen Wert (keine Komplexitaet gemessen, keine
12
+ * Findability berechnet) landen ans ENDE der Liste, nicht an den Anfang: ein
13
+ * fehlender Wert ist kein Bestwert.
14
+ */
15
+ import type { SearchHitV1 } from "@untangle-code/domain";
16
+ import { compareCodepoints } from "@untangle-code/domain";
17
+
18
+ import type { SearchSort } from "./types.js";
19
+
20
+ function tiebreak(a: SearchHitV1, b: SearchHitV1): number {
21
+ return compareCodepoints(a.dedupeKey, b.dedupeKey);
22
+ }
23
+
24
+ function compareOptionalNumber(
25
+ a: number | undefined,
26
+ b: number | undefined,
27
+ direction: "asc" | "desc",
28
+ ): number {
29
+ if (a === undefined && b === undefined) {
30
+ return 0;
31
+ }
32
+ if (a === undefined) {
33
+ return 1;
34
+ }
35
+ if (b === undefined) {
36
+ return -1;
37
+ }
38
+ return direction === "asc" ? a - b : b - a;
39
+ }
40
+
41
+ /** Sortiert eine Kopie der Treffer nach dem gewaehlten Kriterium. */
42
+ export function sortHits(hits: readonly SearchHitV1[], sort: SearchSort): SearchHitV1[] {
43
+ const copy = [...hits];
44
+ switch (sort) {
45
+ case "relevance":
46
+ copy.sort((a, b) =>
47
+ a.score.final === b.score.final ? tiebreak(a, b) : b.score.final - a.score.final,
48
+ );
49
+ break;
50
+ case "path":
51
+ copy.sort(
52
+ (a, b) =>
53
+ compareCodepoints(a.repoId, b.repoId) ||
54
+ compareCodepoints(a.filePath, b.filePath) ||
55
+ (a.range.startLine !== b.range.startLine
56
+ ? a.range.startLine - b.range.startLine
57
+ : tiebreak(a, b)),
58
+ );
59
+ break;
60
+ case "complexity-desc":
61
+ case "complexity-asc": {
62
+ const direction = sort === "complexity-asc" ? "asc" : "desc";
63
+ copy.sort((a, b) => {
64
+ const result = compareOptionalNumber(
65
+ a.complexity?.cognitive,
66
+ b.complexity?.cognitive,
67
+ direction,
68
+ );
69
+ return result === 0 ? tiebreak(a, b) : result;
70
+ });
71
+ break;
72
+ }
73
+ case "findability-asc":
74
+ copy.sort((a, b) => {
75
+ const result = compareOptionalNumber(a.findability?.score, b.findability?.score, "asc");
76
+ return result === 0 ? tiebreak(a, b) : result;
77
+ });
78
+ break;
79
+ }
80
+ return copy;
81
+ }
82
+
83
+ /** Weist die 1-basierten Raenge INNERHALB einer Profilgruppe zu. */
84
+ export function assignRanks(hits: readonly SearchHitV1[]): SearchHitV1[] {
85
+ return hits.map((hit, index) => ({ ...hit, rank: index + 1 }));
86
+ }