@untangle-code/search 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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,83 @@
1
+ import type {
2
+ SearchAbstention,
3
+ SearchRelevanceVerdictV1,
4
+ ToolWarningV1,
5
+ } from "@untangle-code/domain";
6
+
7
+ import {
8
+ RELEVANCE_POLICY_NOT_EVALUATED_WARNING_CODE,
9
+ type RelevancePolicyBindingV1,
10
+ } from "./relevance.js";
11
+ import type { RetrievalBranch } from "./retrieval.js";
12
+
13
+ interface RelevancePrerequisiteInput {
14
+ binding: RelevancePolicyBindingV1;
15
+ embeddingProfileId: string;
16
+ workspaceId: string;
17
+ collection: string;
18
+ completedBranches: ReadonlySet<RetrievalBranch>;
19
+ exactSymbolEvidenceAvailable: boolean;
20
+ abstention: SearchAbstention;
21
+ }
22
+
23
+ interface RelevancePrerequisiteFailure {
24
+ detail: string;
25
+ warning: ToolWarningV1;
26
+ verdict: SearchRelevanceVerdictV1;
27
+ }
28
+
29
+ function notEvaluated(
30
+ input: RelevancePrerequisiteInput,
31
+ basis: string,
32
+ detail: string,
33
+ details: Record<string, unknown>,
34
+ ): RelevancePrerequisiteFailure {
35
+ return {
36
+ detail,
37
+ warning: {
38
+ code: RELEVANCE_POLICY_NOT_EVALUATED_WARNING_CODE,
39
+ message: detail,
40
+ details: {
41
+ status: "not_evaluated",
42
+ basis,
43
+ embeddingProfileId: input.embeddingProfileId,
44
+ ...details,
45
+ },
46
+ },
47
+ verdict: {
48
+ abstention: input.abstention,
49
+ status: "not_evaluated",
50
+ basis,
51
+ calibrationId: input.binding.policy.calibrationId,
52
+ },
53
+ };
54
+ }
55
+
56
+ /** A policy may judge only its calibrated scope and evidence; exact symbols are direct evidence. */
57
+ export function relevancePrerequisiteFailure(
58
+ input: RelevancePrerequisiteInput,
59
+ ): RelevancePrerequisiteFailure | undefined {
60
+ const { binding } = input;
61
+ if (binding.workspaceId !== input.workspaceId || binding.collection !== input.collection) {
62
+ const detail = `relevance policy "${binding.policy.calibrationId}" was not evaluated because its calibrated scope workspace="${binding.workspaceId}", collection="${binding.collection}" does not match the active scope workspace="${input.workspaceId}", collection="${input.collection}"; results were left unchanged`;
63
+ return notEvaluated(input, "policy_scope_mismatch", detail, {
64
+ expectedWorkspaceId: binding.workspaceId,
65
+ actualWorkspaceId: input.workspaceId,
66
+ expectedCollection: binding.collection,
67
+ actualCollection: input.collection,
68
+ policy: binding.policy,
69
+ });
70
+ }
71
+ const unavailableEvidenceBranches = (["dense", "sparse"] as const).filter(
72
+ (branch) => !input.completedBranches.has(branch),
73
+ );
74
+ if (unavailableEvidenceBranches.length === 0 || input.exactSymbolEvidenceAvailable)
75
+ return undefined;
76
+ const detail = `relevance policy "${binding.policy.calibrationId}" was not evaluated because its calibrated evidence requires both dense and sparse retrieval, but the following branches did not run successfully: ${unavailableEvidenceBranches.join(", ")}; results were left unchanged`;
77
+ return notEvaluated(input, "calibrated_evidence_branch_unavailable", detail, {
78
+ collection: input.collection,
79
+ requiredEvidenceBranches: ["dense", "sparse"],
80
+ unavailableEvidenceBranches,
81
+ policy: binding.policy,
82
+ });
83
+ }
@@ -0,0 +1,328 @@
1
+ /**
2
+ * Ergebnis-Relevanz und Abwesenheitsaussage (WP-231).
3
+ *
4
+ * Ein Listenmaximum allein ist kein Relevanzbeleg: irgendein Kandidat ist
5
+ * immer der beste, auch wenn die gesuchte Sache im Repository nicht
6
+ * existiert. Die kalibrierte Entscheidung hat deshalb genau zwei Wege:
7
+ *
8
+ * 1. ein exakter Symbolname ist direkte Evidenz;
9
+ * 2. sonst muessen ALLE drei gemessenen Signale tragen: absoluter dichter
10
+ * Topwert, relativer Abstand zu einem festen dichten Referenzrang und die
11
+ * IDF-gewichtete Abdeckung der normalisierten Query-Terme in einem
12
+ * einzelnen Kandidaten der festen Dense-/Sparse-Evidenzunion.
13
+ *
14
+ * Die exportierte qwen-Policy ist am stehenden, in
15
+ * `benchmark/signals.mjs` vermessenen Untangle-Selbstindex kalibriert. Die
16
+ * Konjunktion behaelt dort alle 22 beantwortbaren Hauptfragen sowie sieben
17
+ * getrennte deutsche Prosa-Proben und verwirft 11 von 14 klaren bzw.
18
+ * adversarialen Negativfragen. Die beiden nachgemessenen Grenzwerte liegen
19
+ * bewusst zwischen dem jeweils schwaechsten positiven und staerksten klar
20
+ * negativen Signal; kein einzelnes Signal darf allein entscheiden. Andere
21
+ * Modelle oder Korpora koennen voellig andere Wertebereiche haben. Deshalb
22
+ * hat die Engine keinen universellen Default, und jede Policy muss zusaetzlich
23
+ * an Workspace und Collection gebunden werden. Auch nach einer wesentlichen
24
+ * Revision desselben Korpus ist neu zu messen und zu kalibrieren.
25
+ */
26
+ import { termFrequencies, tokenizeIdentifiers } from "./tokenize.js";
27
+
28
+ export const NO_SUFFICIENT_MATCH_WARNING_CODE = "NO_SUFFICIENT_MATCH";
29
+ export const RELEVANCE_POLICY_NOT_EVALUATED_WARNING_CODE = "RELEVANCE_POLICY_NOT_EVALUATED";
30
+
31
+ export interface RelevancePolicyV1 {
32
+ /** Menschen- und maschinenlesbare Herkunft der Kalibrierung. */
33
+ calibrationId: string;
34
+ /** Absoluter Mindestwert des besten dichten Kandidaten. */
35
+ minimumDenseScore: number;
36
+ /** Mindestabstand zwischen bestem dichten Wert und `denseReferenceRank`. */
37
+ minimumDenseSpread: number;
38
+ /** 1-basierter fester Vergleichsrang; fehlt er, wird NICHT entschieden. */
39
+ denseReferenceRank: number;
40
+ /**
41
+ * IDF-gewichteter Mindestanteil aller normalisierten Query-Terme im festen
42
+ * Evidenzpool aus Dense- und Sparse-Praefix.
43
+ */
44
+ minimumCandidateIdfCoverage: number;
45
+ }
46
+
47
+ /** Bindet eine Modellkalibrierung an genau das vermessene Korpus. */
48
+ export interface RelevancePolicyBindingV1 {
49
+ workspaceId: string;
50
+ collection: string;
51
+ policy: RelevancePolicyV1;
52
+ }
53
+
54
+ export const QWEN3_EMBEDDING_06B_RELEVANCE_POLICY: Readonly<RelevancePolicyV1> = {
55
+ calibrationId: "qwen3-embedding:0.6b/untangle-self-index-2026-09-05",
56
+ minimumDenseScore: 0.45,
57
+ minimumDenseSpread: 0.061,
58
+ denseReferenceRank: 50,
59
+ minimumCandidateIdfCoverage: 0.291,
60
+ };
61
+
62
+ export type RelevancePolicyConfig = false | RelevancePolicyV1;
63
+
64
+ export interface RelevanceCandidateEvidence {
65
+ denseScore?: number;
66
+ lexicalText: string;
67
+ exactSymbolMatch: boolean;
68
+ }
69
+
70
+ export type RelevanceDecisionStatus = "accepted" | "rejected" | "not_evaluated";
71
+
72
+ export type RelevanceDecisionBasis =
73
+ | "exact_symbol"
74
+ | "dense_absolute_relative_and_candidate_idf_coverage"
75
+ | "insufficient_evidence"
76
+ | "dense_reference_unavailable";
77
+
78
+ /** Vollstaendige, maschinenlesbare Begruendung fuer Treffer, Enthaltung oder Nichtentscheidung. */
79
+ export interface RelevanceDecisionV1 {
80
+ status: RelevanceDecisionStatus;
81
+ basis: RelevanceDecisionBasis;
82
+ exactSymbolMatches: number;
83
+ topDenseScore?: number;
84
+ /** Nur gesetzt, wenn der kalibrierte feste Referenzrang wirklich beobachtet wurde. */
85
+ denseReferenceScore?: number;
86
+ /** Nur aus Topwert und dem wirklich beobachteten festen Referenzrang berechnet. */
87
+ denseSpread?: number;
88
+ denseScoresObserved: number;
89
+ /** Tiefster tatsaechlich beobachteter dichter Rang; 0 bedeutet keine dichte Evidenz. */
90
+ deepestDenseRankObserved: number;
91
+ /** Nur gesetzt, wenn genau der kalibrierte Referenzrang beobachtet wurde. */
92
+ denseReferenceRankObserved?: number;
93
+ /** Zahl der Kandidaten in der festen, residual gueltigen Evidenzunion. */
94
+ candidateEvidenceCount: number;
95
+ candidateIdfCoverage: number;
96
+ queryTerms: string[];
97
+ policy: RelevancePolicyV1;
98
+ }
99
+
100
+ export interface CandidateIdfCoverageV1 {
101
+ coverage: number;
102
+ queryTerms: string[];
103
+ }
104
+
105
+ export interface CompleteRelevanceMeasurementsV1 {
106
+ topDenseScore: number;
107
+ denseSpread: number;
108
+ candidateIdfCoverage: number;
109
+ }
110
+
111
+ /** Die qwen-Kalibrierung misst die feste Union aus dichtem und spaerlichem Abruf. */
112
+ export type RelevanceEvidenceBranch = "dense" | "sparse";
113
+
114
+ export interface RelevanceEvidenceBranchV1 {
115
+ branch: RelevanceEvidenceBranch;
116
+ candidateIds: readonly string[];
117
+ }
118
+
119
+ export interface FixedRelevanceEvidencePoolV1 {
120
+ /** Deterministische Union der kalibrierten Praefixe in Zweigreihenfolge. */
121
+ candidateIds: string[];
122
+ /** Gueltige Kandidaten im festen dichten Praefix; nur sie tragen Dense-Scores. */
123
+ denseCandidateIds: string[];
124
+ }
125
+
126
+ /**
127
+ * Baut einen von einem groesseren Abruflimit unabhaengigen Evidenzpool.
128
+ *
129
+ * Pro Abrufzweig zaehlt ausschliesslich das feste rohe Praefix der
130
+ * Kalibrierung. Erst danach werden residual ungueltige Kandidaten entfernt;
131
+ * ein tieferer Kandidat darf einen ausgeschlossenen Praefix-Kandidaten nicht
132
+ * ersetzen, nur weil der Aufrufer statt 50 nun 200 Treffer abruft. Fehlt
133
+ * dadurch ein dichter Referenzrang, trifft `decideResultRelevance` die
134
+ * sichtbare konservative Nichtentscheidung.
135
+ */
136
+ export function selectFixedRelevanceEvidencePool(
137
+ branches: readonly RelevanceEvidenceBranchV1[],
138
+ eligibleCandidateIds: ReadonlySet<string>,
139
+ branchDepth: number,
140
+ ): FixedRelevanceEvidencePoolV1 {
141
+ if (!Number.isInteger(branchDepth) || branchDepth < 1) {
142
+ throw new Error(`relevance evidence branchDepth must be an integer >= 1, got ${branchDepth}`);
143
+ }
144
+ const candidateIds: string[] = [];
145
+ const denseCandidateIds: string[] = [];
146
+ const seen = new Set<string>();
147
+ const denseSeen = new Set<string>();
148
+ for (const branch of branches) {
149
+ for (const candidateId of branch.candidateIds.slice(0, branchDepth)) {
150
+ if (!eligibleCandidateIds.has(candidateId)) {
151
+ continue;
152
+ }
153
+ if (branch.branch === "dense" && !denseSeen.has(candidateId)) {
154
+ denseSeen.add(candidateId);
155
+ denseCandidateIds.push(candidateId);
156
+ }
157
+ if (!seen.has(candidateId)) {
158
+ seen.add(candidateId);
159
+ candidateIds.push(candidateId);
160
+ }
161
+ }
162
+ }
163
+ return { candidateIds, denseCandidateIds };
164
+ }
165
+
166
+ /** Validiert eine explizit profilgebundene Policy; ohne Vorgabe bleibt Legacy-Verhalten. */
167
+ export function resolveRelevancePolicy(
168
+ config: RelevancePolicyConfig | undefined,
169
+ ): RelevancePolicyV1 | false {
170
+ if (config === false || config === undefined) {
171
+ return false;
172
+ }
173
+ const policy = { ...config };
174
+ if (policy.calibrationId.trim() === "") {
175
+ throw new Error("relevancePolicy.calibrationId must not be empty");
176
+ }
177
+ assertFiniteRange("minimumDenseScore", policy.minimumDenseScore, -1, 1);
178
+ assertFiniteRange("minimumDenseSpread", policy.minimumDenseSpread, 0, 2);
179
+ if (!Number.isInteger(policy.denseReferenceRank) || policy.denseReferenceRank < 2) {
180
+ throw new Error(
181
+ `relevancePolicy.denseReferenceRank must be an integer >= 2, got ${String(policy.denseReferenceRank)}`,
182
+ );
183
+ }
184
+ assertFiniteRange("minimumCandidateIdfCoverage", policy.minimumCandidateIdfCoverage, 0, 1);
185
+ return policy;
186
+ }
187
+
188
+ /** Validiert und kopiert die Korpusbindung einer Policy. */
189
+ export function resolveRelevancePolicyBinding(
190
+ binding: RelevancePolicyBindingV1,
191
+ ): RelevancePolicyBindingV1 {
192
+ const workspaceId = binding.workspaceId.trim();
193
+ const collection = binding.collection.trim();
194
+ if (workspaceId === "") {
195
+ throw new Error("relevancePolicy binding workspaceId must not be empty");
196
+ }
197
+ if (collection === "") {
198
+ throw new Error("relevancePolicy binding collection must not be empty");
199
+ }
200
+ const policy = resolveRelevancePolicy(binding.policy);
201
+ if (policy === false) {
202
+ throw new Error("relevancePolicy binding must contain an enabled policy");
203
+ }
204
+ return { workspaceId, collection, policy };
205
+ }
206
+
207
+ /**
208
+ * Entscheidet rein aus Kandidatenevidenz; keine Liste wird am eigenen
209
+ * Maximum normalisiert. Fehlt der feste Referenzrang, ist eine kalibrierte
210
+ * Negativentscheidung unzulaessig. Ein exakter Symbolname darf weiterhin
211
+ * direkt akzeptieren.
212
+ */
213
+ export function decideResultRelevance(
214
+ queryText: string,
215
+ candidates: readonly RelevanceCandidateEvidence[],
216
+ policy: RelevancePolicyV1,
217
+ ): RelevanceDecisionV1 {
218
+ const exactSymbolMatches = candidates.filter((candidate) => candidate.exactSymbolMatch).length;
219
+ const denseScores = candidates
220
+ .map((candidate) => candidate.denseScore)
221
+ .filter((score): score is number => score !== undefined && Number.isFinite(score))
222
+ .sort((left, right) => right - left);
223
+ const topDenseScore = denseScores[0];
224
+ const coverage = maximumCandidateIdfCoverage(
225
+ queryText,
226
+ candidates.map((candidate) => candidate.lexicalText),
227
+ );
228
+ const shared = {
229
+ exactSymbolMatches,
230
+ ...(topDenseScore === undefined ? {} : { topDenseScore }),
231
+ denseScoresObserved: denseScores.length,
232
+ deepestDenseRankObserved: denseScores.length,
233
+ candidateEvidenceCount: candidates.length,
234
+ candidateIdfCoverage: coverage.coverage,
235
+ queryTerms: coverage.queryTerms,
236
+ policy: { ...policy },
237
+ };
238
+
239
+ if (exactSymbolMatches > 0) {
240
+ return { status: "accepted", basis: "exact_symbol", ...shared };
241
+ }
242
+ if (denseScores.length < policy.denseReferenceRank) {
243
+ return {
244
+ status: "not_evaluated",
245
+ basis: "dense_reference_unavailable",
246
+ ...shared,
247
+ };
248
+ }
249
+
250
+ const denseReferenceScore = denseScores[policy.denseReferenceRank - 1] as number;
251
+ const denseSpread = (topDenseScore as number) - denseReferenceScore;
252
+ const sufficient = satisfiesNonSymbolRelevancePolicy(
253
+ {
254
+ topDenseScore: topDenseScore as number,
255
+ denseSpread,
256
+ candidateIdfCoverage: coverage.coverage,
257
+ },
258
+ policy,
259
+ );
260
+ return {
261
+ status: sufficient ? "accepted" : "rejected",
262
+ basis: sufficient
263
+ ? "dense_absolute_relative_and_candidate_idf_coverage"
264
+ : "insufficient_evidence",
265
+ ...shared,
266
+ denseReferenceScore,
267
+ denseSpread,
268
+ denseReferenceRankObserved: policy.denseReferenceRank,
269
+ };
270
+ }
271
+
272
+ /** Die drei Nicht-Symbol-Signale sind bewusst eine Konjunktion. */
273
+ export function satisfiesNonSymbolRelevancePolicy(
274
+ measurements: CompleteRelevanceMeasurementsV1,
275
+ policy: RelevancePolicyV1,
276
+ ): boolean {
277
+ return (
278
+ measurements.topDenseScore >= policy.minimumDenseScore &&
279
+ measurements.denseSpread >= policy.minimumDenseSpread &&
280
+ measurements.candidateIdfCoverage >= policy.minimumCandidateIdfCoverage
281
+ );
282
+ }
283
+
284
+ /**
285
+ * Groesster IDF-gewichteter Anteil ALLER normalisierten Query-Terme, den EIN
286
+ * Kandidat gemeinsam belegt. IDF wird ausschliesslich im tatsaechlichen
287
+ * Kandidatenpool berechnet; auch dort unbelegte Query-Terme bleiben mit ihrem
288
+ * hohen Gewicht im Nenner. Ein ueber mehrere Treffer verteilter Wortsalat
289
+ * gilt damit nicht als Antwort.
290
+ */
291
+ export function maximumCandidateIdfCoverage(
292
+ queryText: string,
293
+ documents: readonly string[],
294
+ ): CandidateIdfCoverageV1 {
295
+ const queryTerms = [...termFrequencies(tokenizeIdentifiers(queryText)).keys()];
296
+ if (queryTerms.length === 0 || documents.length === 0) {
297
+ return { coverage: 0, queryTerms };
298
+ }
299
+
300
+ const documentTerms = documents.map((document) => new Set(tokenizeIdentifiers(document)));
301
+ const poolSize = documentTerms.length;
302
+ const weightedTerms = queryTerms.map((term) => {
303
+ const documentFrequency = documentTerms.filter((terms) => terms.has(term)).length;
304
+ const idf = Math.log(1 + (poolSize - documentFrequency + 0.5) / (documentFrequency + 0.5));
305
+ return { term, idf };
306
+ });
307
+ const totalIdf = weightedTerms.reduce((sum, entry) => sum + entry.idf, 0);
308
+ if (totalIdf === 0) {
309
+ return { coverage: 0, queryTerms };
310
+ }
311
+
312
+ let maximum = 0;
313
+ for (const terms of documentTerms) {
314
+ const matchedIdf = weightedTerms
315
+ .filter((entry) => terms.has(entry.term))
316
+ .reduce((sum, entry) => sum + entry.idf, 0);
317
+ maximum = Math.max(maximum, matchedIdf / totalIdf);
318
+ }
319
+ return { coverage: maximum, queryTerms };
320
+ }
321
+
322
+ function assertFiniteRange(name: string, value: number, minimum: number, maximum: number): void {
323
+ if (!Number.isFinite(value) || value < minimum || value > maximum) {
324
+ throw new Error(
325
+ `relevancePolicy.${name} must be finite and in [${minimum}, ${maximum}], got ${String(value)}`,
326
+ );
327
+ }
328
+ }