@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.
- package/LICENSE +661 -0
- package/NOTICE +90 -0
- package/README.md +433 -2
- package/THIRD_PARTY_LICENSES.md +29 -0
- package/dist/benchmark-integrity.d.ts +12 -0
- package/dist/benchmark-integrity.d.ts.map +1 -0
- package/dist/benchmark-integrity.js +10 -0
- package/dist/benchmark-integrity.js.map +1 -0
- package/dist/bm25.d.ts +46 -0
- package/dist/bm25.d.ts.map +1 -0
- package/dist/bm25.js +105 -0
- package/dist/bm25.js.map +1 -0
- package/dist/deadline.d.ts +4 -0
- package/dist/deadline.d.ts.map +1 -0
- package/dist/deadline.js +54 -0
- package/dist/deadline.js.map +1 -0
- package/dist/dedupe.d.ts +48 -0
- package/dist/dedupe.d.ts.map +1 -0
- package/dist/dedupe.js +58 -0
- package/dist/dedupe.js.map +1 -0
- package/dist/diversify.d.ts +45 -0
- package/dist/diversify.d.ts.map +1 -0
- package/dist/diversify.js +30 -0
- package/dist/diversify.js.map +1 -0
- package/dist/engine.d.ts +157 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +1498 -0
- package/dist/engine.js.map +1 -0
- package/dist/enrichment.d.ts +16 -0
- package/dist/enrichment.d.ts.map +1 -0
- package/dist/enrichment.js +92 -0
- package/dist/enrichment.js.map +1 -0
- package/dist/filter.d.ts +86 -0
- package/dist/filter.d.ts.map +1 -0
- package/dist/filter.js +166 -0
- package/dist/filter.js.map +1 -0
- package/dist/findability.d.ts +144 -0
- package/dist/findability.d.ts.map +1 -0
- package/dist/findability.js +393 -0
- package/dist/findability.js.map +1 -0
- package/dist/format.d.ts +28 -0
- package/dist/format.d.ts.map +1 -0
- package/dist/format.js +172 -0
- package/dist/format.js.map +1 -0
- package/dist/fusion.d.ts +47 -0
- package/dist/fusion.d.ts.map +1 -0
- package/dist/fusion.js +84 -0
- package/dist/fusion.js.map +1 -0
- package/dist/glob.d.ts +50 -0
- package/dist/glob.d.ts.map +1 -0
- package/dist/glob.js +144 -0
- package/dist/glob.js.map +1 -0
- package/dist/grep.d.ts +76 -0
- package/dist/grep.d.ts.map +1 -0
- package/dist/grep.js +475 -0
- package/dist/grep.js.map +1 -0
- package/dist/index.d.ts +42 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +63 -0
- package/dist/index.js.map +1 -0
- package/dist/ledger.d.ts +45 -0
- package/dist/ledger.d.ts.map +1 -0
- package/dist/ledger.js +39 -0
- package/dist/ledger.js.map +1 -0
- package/dist/payload.d.ts +108 -0
- package/dist/payload.d.ts.map +1 -0
- package/dist/payload.js +263 -0
- package/dist/payload.js.map +1 -0
- package/dist/port.d.ts +112 -0
- package/dist/port.d.ts.map +1 -0
- package/dist/port.js +2 -0
- package/dist/port.js.map +1 -0
- package/dist/product-complexity.d.ts +89 -0
- package/dist/product-complexity.d.ts.map +1 -0
- package/dist/product-complexity.js +1367 -0
- package/dist/product-complexity.js.map +1 -0
- package/dist/regex-safety.d.ts +75 -0
- package/dist/regex-safety.d.ts.map +1 -0
- package/dist/regex-safety.js +902 -0
- package/dist/regex-safety.js.map +1 -0
- package/dist/relevance-prerequisites.d.ts +21 -0
- package/dist/relevance-prerequisites.d.ts.map +1 -0
- package/dist/relevance-prerequisites.js +47 -0
- package/dist/relevance-prerequisites.js.map +1 -0
- package/dist/relevance.d.ts +107 -0
- package/dist/relevance.d.ts.map +1 -0
- package/dist/relevance.js +199 -0
- package/dist/relevance.js.map +1 -0
- package/dist/retrieval.d.ts +46 -0
- package/dist/retrieval.d.ts.map +1 -0
- package/dist/retrieval.js +182 -0
- package/dist/retrieval.js.map +1 -0
- package/dist/scoring.d.ts +102 -0
- package/dist/scoring.d.ts.map +1 -0
- package/dist/scoring.js +175 -0
- package/dist/scoring.js.map +1 -0
- package/dist/sort.d.ts +21 -0
- package/dist/sort.d.ts.map +1 -0
- package/dist/sort.js +53 -0
- package/dist/sort.js.map +1 -0
- package/dist/symbol-match.d.ts +21 -0
- package/dist/symbol-match.d.ts.map +1 -0
- package/dist/symbol-match.js +72 -0
- package/dist/symbol-match.js.map +1 -0
- package/dist/tokenize.d.ts +41 -0
- package/dist/tokenize.d.ts.map +1 -0
- package/dist/tokenize.js +202 -0
- package/dist/tokenize.js.map +1 -0
- package/dist/types.d.ts +173 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +42 -0
- package/dist/types.js.map +1 -0
- package/package.json +61 -3
- package/src/benchmark-integrity.ts +25 -0
- package/src/bm25.ts +135 -0
- package/src/deadline.ts +59 -0
- package/src/dedupe.ts +99 -0
- package/src/diversify.ts +71 -0
- package/src/engine.ts +1957 -0
- package/src/enrichment.ts +115 -0
- package/src/filter.ts +267 -0
- package/src/findability.ts +580 -0
- package/src/format.ts +228 -0
- package/src/fusion.ts +121 -0
- package/src/glob.ts +151 -0
- package/src/grep.ts +618 -0
- package/src/index.ts +83 -0
- package/src/ledger.ts +72 -0
- package/src/payload.ts +382 -0
- package/src/port.ts +133 -0
- package/src/product-complexity.ts +2168 -0
- package/src/regex-safety.ts +1002 -0
- package/src/relevance-prerequisites.ts +83 -0
- package/src/relevance.ts +328 -0
- package/src/retrieval.ts +299 -0
- package/src/scoring.ts +321 -0
- package/src/sort.ts +86 -0
- package/src/symbol-match.ts +87 -0
- package/src/tokenize.ts +213 -0
- package/src/types.ts +206 -0
package/src/format.ts
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ausgabeformate (Nutzerwunsch A4: „Text, JSON, JSONL").
|
|
3
|
+
*
|
|
4
|
+
* Drei Formate, drei Zwecke:
|
|
5
|
+
*
|
|
6
|
+
* - `text` — fuer Menschen im Terminal. Eine Zeile Kopf je Treffer, darunter
|
|
7
|
+
* die Score-Zerlegung in Klartext. Die Zerlegung ist hier KEIN Debug-Anhang,
|
|
8
|
+
* sondern der eigentliche Mehrwert: sie beantwortet „warum steht das oben?".
|
|
9
|
+
* - `json` — ein Dokument, vollstaendig, mit Ledger und Warnungen. Fuer
|
|
10
|
+
* Werkzeuge, die das Ganze wollen.
|
|
11
|
+
* - `jsonl` — eine Zeile je Treffer, streambar. Fuer Agents und Pipes.
|
|
12
|
+
* Die erste Zeile ist ein Kopfsatz mit Anfrage-Fingerabdruck, Warnungen und
|
|
13
|
+
* Ledger — sonst wuerde beim Streamen genau die Information verloren gehen,
|
|
14
|
+
* die E4 sichtbar machen soll.
|
|
15
|
+
*
|
|
16
|
+
* Alle drei sind deterministisch: gleiche Ergebnismenge, gleiche Bytes.
|
|
17
|
+
*/
|
|
18
|
+
import type {
|
|
19
|
+
ScoreComponentV1,
|
|
20
|
+
SearchGraphRelationsV1,
|
|
21
|
+
SearchHitV1,
|
|
22
|
+
SearchResultSetV1,
|
|
23
|
+
} from "@untangle-code/domain";
|
|
24
|
+
|
|
25
|
+
import type { GrepResultSetV1, OutputFormat } from "./types.js";
|
|
26
|
+
|
|
27
|
+
/** Formatiert eine Zahl mit fester Stellenzahl — stabil ueber Plattformen. */
|
|
28
|
+
function fixed(value: number, digits = 4): string {
|
|
29
|
+
return value.toFixed(digits);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function formatComponent(component: ScoreComponentV1): string {
|
|
33
|
+
const sign = component.contribution < 0 ? "-" : "+";
|
|
34
|
+
const evidence = component.evidence === undefined ? "" : ` [${component.evidence}]`;
|
|
35
|
+
return ` ${sign} ${component.kind} = ${fixed(Math.abs(component.contribution))} (raw ${fixed(component.rawValue)} x weight ${fixed(component.weight)})${evidence}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Kompakte, aber vollstaendige Textdarstellung einer begrenzten Graphfacette. */
|
|
39
|
+
export function formatSearchGraphRelationsAsText(
|
|
40
|
+
label: "context" | "linked",
|
|
41
|
+
value: SearchGraphRelationsV1,
|
|
42
|
+
): string[] {
|
|
43
|
+
const lines = value.relations.map((relation) => {
|
|
44
|
+
const arrow = relation.direction === "incoming" ? "<-" : "->";
|
|
45
|
+
const symbol = relation.symbol === undefined ? "" : ` symbol=${relation.symbol.id}`;
|
|
46
|
+
return (
|
|
47
|
+
` ${label} ${arrow} ${relation.repoId}@${relation.refId} ` +
|
|
48
|
+
`${relation.path} file=${relation.fileId}${symbol} ` +
|
|
49
|
+
`edge=${relation.edgeId} kind=${relation.edgeKind} crossRepository=${String(relation.crossRepository)}`
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
for (const omission of value.omissions) {
|
|
53
|
+
lines.push(
|
|
54
|
+
` ${label} omitted ${omission.reason} x${String(omission.count)}: ${omission.detail}`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
return lines;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Eine Trefferzeile plus Score-Zerlegung im Textformat. */
|
|
61
|
+
export function formatHitAsText(hit: SearchHitV1): string {
|
|
62
|
+
const lines: string[] = [];
|
|
63
|
+
const symbol =
|
|
64
|
+
hit.symbolName === undefined ? "" : ` ${hit.symbolKind ?? "symbol"} ${hit.symbolName}`;
|
|
65
|
+
lines.push(
|
|
66
|
+
`${String(hit.rank).padStart(3, " ")}. ${fixed(hit.score.final)} ${hit.repoLabel}@${hit.refName} ${hit.filePath}:${hit.range.startLine}-${hit.range.endLine}${symbol}`,
|
|
67
|
+
);
|
|
68
|
+
if (hit.complexity?.cognitive !== undefined) {
|
|
69
|
+
lines.push(` complexity: cognitive ${hit.complexity.cognitive}`);
|
|
70
|
+
}
|
|
71
|
+
if (hit.findability !== undefined) {
|
|
72
|
+
lines.push(
|
|
73
|
+
` findability: ${fixed(hit.findability.score, 2)} (heuristic${hit.findability.primaryCause === undefined ? "" : `, ${hit.findability.primaryCause}`})`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
lines.push(" why:");
|
|
77
|
+
for (const component of hit.score.components) {
|
|
78
|
+
lines.push(formatComponent(component));
|
|
79
|
+
}
|
|
80
|
+
if (hit.score.clamped) {
|
|
81
|
+
lines.push(` clamped: ${hit.score.clampReason ?? ""}`);
|
|
82
|
+
}
|
|
83
|
+
if (hit.relatedFragments.length > 0) {
|
|
84
|
+
lines.push(
|
|
85
|
+
` related fragments: ${hit.relatedFragments.map((fragment) => `${fragment.chunkKind}@${fragment.startLine}`).join(", ")}`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
if (hit.graphContext !== undefined) {
|
|
89
|
+
lines.push(...formatSearchGraphRelationsAsText("context", hit.graphContext));
|
|
90
|
+
}
|
|
91
|
+
if (hit.linkedSymbols !== undefined) {
|
|
92
|
+
lines.push(...formatSearchGraphRelationsAsText("linked", hit.linkedSymbols));
|
|
93
|
+
}
|
|
94
|
+
if (hit.contentOmittedReason !== undefined) {
|
|
95
|
+
lines.push(` content omitted: ${hit.contentOmittedReason}`);
|
|
96
|
+
}
|
|
97
|
+
if (hit.content !== undefined) {
|
|
98
|
+
for (const line of hit.content.split("\n")) {
|
|
99
|
+
lines.push(` | ${line}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return lines.join("\n");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Formatiert eine ganze Ergebnismenge. */
|
|
106
|
+
export function formatSearchResultSet(set: SearchResultSetV1, format: OutputFormat): string {
|
|
107
|
+
if (format === "json") {
|
|
108
|
+
return JSON.stringify(set, null, 2);
|
|
109
|
+
}
|
|
110
|
+
if (format === "jsonl") {
|
|
111
|
+
const header = {
|
|
112
|
+
kind: "header" as const,
|
|
113
|
+
queryFingerprint: set.queryFingerprint,
|
|
114
|
+
...(set.indexFreshness === undefined ? {} : { indexFreshness: set.indexFreshness }),
|
|
115
|
+
query: set.query,
|
|
116
|
+
profileSeparation: set.profileSeparation,
|
|
117
|
+
totalHits: set.totalHits,
|
|
118
|
+
warnings: set.warnings,
|
|
119
|
+
skipped: set.skipped,
|
|
120
|
+
};
|
|
121
|
+
const lines = [JSON.stringify(header)];
|
|
122
|
+
for (const group of set.groups) {
|
|
123
|
+
for (const hit of group.hits) {
|
|
124
|
+
lines.push(
|
|
125
|
+
JSON.stringify({ kind: "hit", embeddingProfileId: group.embeddingProfileId, hit }),
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return lines.join("\n");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const lines: string[] = [];
|
|
133
|
+
lines.push(`query: ${set.query.text}`);
|
|
134
|
+
lines.push(`fingerprint: ${set.queryFingerprint}`);
|
|
135
|
+
if (set.profileSeparation) {
|
|
136
|
+
lines.push(
|
|
137
|
+
`NOTE: ${set.groups.length} embedding profiles — scores are comparable only within a group (spec.md §13.4)`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
for (const group of set.groups) {
|
|
141
|
+
lines.push("");
|
|
142
|
+
lines.push(`profile ${group.embeddingProfileId} (repos: ${group.repoIds.join(", ")})`);
|
|
143
|
+
for (const caveat of group.caveats) {
|
|
144
|
+
lines.push(` caveat: ${caveat}`);
|
|
145
|
+
}
|
|
146
|
+
// Das Verdikt kommt aus der Entscheidung, nie aus der Listenlaenge. Ein
|
|
147
|
+
// blosses „(no hits)" waere bei einer Ablehnung eine Tatsachenbehauptung
|
|
148
|
+
// ueber den Code: es GAB Kandidaten, die Policy hat sie verworfen.
|
|
149
|
+
const verdict = group.relevanceDecision;
|
|
150
|
+
if (verdict?.status === "rejected") {
|
|
151
|
+
lines.push(
|
|
152
|
+
` (relevance policy ${verdict.calibrationId ?? "unknown"} judged the evidence ` +
|
|
153
|
+
`insufficient: ${verdict.basis}; abstention=${verdict.abstention})`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
if (group.hits.length === 0 && verdict?.status !== "rejected") {
|
|
157
|
+
lines.push(" (no hits)");
|
|
158
|
+
}
|
|
159
|
+
for (const hit of group.hits) {
|
|
160
|
+
lines.push(formatHitAsText(hit));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (set.warnings.length > 0) {
|
|
164
|
+
lines.push("");
|
|
165
|
+
lines.push("warnings:");
|
|
166
|
+
for (const warning of set.warnings) {
|
|
167
|
+
lines.push(` ${warning.code}: ${warning.message}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (set.skipped.length > 0) {
|
|
171
|
+
lines.push("");
|
|
172
|
+
lines.push(`skipped (${set.skipped.length}) — nothing is dropped silently:`);
|
|
173
|
+
for (const entry of set.skipped) {
|
|
174
|
+
lines.push(
|
|
175
|
+
` ${entry.reason} ${entry.subjectKind} ${entry.subjectId ?? entry.path ?? ""}: ${entry.detail}`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return lines.join("\n");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Formatiert grep-Treffer. */
|
|
183
|
+
export function formatGrepResultSet(set: GrepResultSetV1, format: OutputFormat): string {
|
|
184
|
+
if (format === "json") {
|
|
185
|
+
return JSON.stringify(set, null, 2);
|
|
186
|
+
}
|
|
187
|
+
if (format === "jsonl") {
|
|
188
|
+
const header = {
|
|
189
|
+
kind: "header" as const,
|
|
190
|
+
pattern: set.pattern,
|
|
191
|
+
...(set.indexFreshness === undefined ? {} : { indexFreshness: set.indexFreshness }),
|
|
192
|
+
isRegex: set.isRegex,
|
|
193
|
+
totalMatches: set.totalMatches,
|
|
194
|
+
truncated: set.truncated,
|
|
195
|
+
...(set.truncationReason === undefined ? {} : { truncationReason: set.truncationReason }),
|
|
196
|
+
scannedChunks: set.scannedChunks,
|
|
197
|
+
warnings: set.warnings,
|
|
198
|
+
skipped: set.skipped,
|
|
199
|
+
};
|
|
200
|
+
return [header, ...set.matches.map((match) => ({ kind: "match" as const, match }))]
|
|
201
|
+
.map((entry) => JSON.stringify(entry))
|
|
202
|
+
.join("\n");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const lines: string[] = [];
|
|
206
|
+
for (const match of set.matches) {
|
|
207
|
+
for (const context of match.before) {
|
|
208
|
+
lines.push(`${match.filePath}-${context.line}- ${context.text}`);
|
|
209
|
+
}
|
|
210
|
+
lines.push(`${match.filePath}:${match.line}:${match.column}: ${match.lineText}`);
|
|
211
|
+
for (const context of match.after) {
|
|
212
|
+
lines.push(`${match.filePath}-${context.line}- ${context.text}`);
|
|
213
|
+
}
|
|
214
|
+
if (match.before.length > 0 || match.after.length > 0) {
|
|
215
|
+
lines.push("--");
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
lines.push(
|
|
219
|
+
`${set.totalMatches} match(es) in ${set.scannedChunks} scanned chunk(s)${set.truncated ? ` — TRUNCATED (${set.truncationReason ?? "unknown"})` : ""}`,
|
|
220
|
+
);
|
|
221
|
+
if (set.skipped.length > 0) {
|
|
222
|
+
lines.push(`skipped (${set.skipped.length}):`);
|
|
223
|
+
for (const entry of set.skipped) {
|
|
224
|
+
lines.push(` ${entry.reason} ${entry.subjectId ?? entry.path ?? ""}: ${entry.detail}`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return lines.join("\n");
|
|
228
|
+
}
|
package/src/fusion.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fusion der drei Ranglisten (spec.md §13.2 Schritt 6, §13.4).
|
|
3
|
+
*
|
|
4
|
+
* Verfahren: gewichtete Reciprocal Rank Fusion.
|
|
5
|
+
*
|
|
6
|
+
* rrf(d) = Σ_zweig w_zweig / (k + rang_zweig(d))
|
|
7
|
+
*
|
|
8
|
+
* WARUM RRF UND NICHT EINE SCORE-SUMME: Cosine-Aehnlichkeit, BM25 und eine
|
|
9
|
+
* Namensuebereinstimmung leben in drei verschiedenen Wertebereichen. Sie zu
|
|
10
|
+
* addieren hiesse, sie stillschweigend gleich zu skalieren. RRF benutzt nur
|
|
11
|
+
* die RANGFOLGE jedes Zweigs und ist damit gegen die Skala jedes einzelnen
|
|
12
|
+
* Zweigs unempfindlich — dieselbe Eigenschaft, wegen der spec.md §13.4 RRF
|
|
13
|
+
* als einziges Fusionsverfahren fuer v1 festlegt.
|
|
14
|
+
*
|
|
15
|
+
* WARUM k = 60: der Wert aus der RRF-Originalarbeit (Cormack, Clarke,
|
|
16
|
+
* Buettcher 2009), seither die uebliche Voreinstellung — unter anderem in
|
|
17
|
+
* Qdrants eigener RRF-Implementierung, gegen die dieser Abrufkern austauschbar
|
|
18
|
+
* bleiben soll. Grosses k daempft den Vorsprung der ersten Plaetze, kleines k
|
|
19
|
+
* ueberhoeht ihn; 60 ist der dokumentierte Mittelweg, nicht ein geratener.
|
|
20
|
+
*
|
|
21
|
+
* WARUM DIE ZWEIGGEWICHTE SO GEWAEHLT SIND:
|
|
22
|
+
* - dicht 1.0 und lexikalisch 1.0 — die beiden Abrufzweige sind gleichwertig;
|
|
23
|
+
* welcher besser ist, haengt an der Anfrage (Absichtsfrage vs. Bezeichner),
|
|
24
|
+
* und genau deshalb werden beide gefragt;
|
|
25
|
+
* - Symbolname 0.5 — der Zweig ist eine PRAEZISIONS-, keine Abrufquelle. Ein
|
|
26
|
+
* exakter Namenstreffer wird zusaetzlich als `exact_symbol_boost` belohnt
|
|
27
|
+
* (spec.md §13.3). Mit vollem Gewicht in der Fusion UND vollem Boost
|
|
28
|
+
* wuerde derselbe Beleg doppelt zaehlen; das halbe Gewicht ist der
|
|
29
|
+
* ausdrueckliche Abschlag dafuer.
|
|
30
|
+
*/
|
|
31
|
+
import { compareCodepoints } from "@untangle-code/domain";
|
|
32
|
+
|
|
33
|
+
/** Glaettungskonstante der RRF-Formel (siehe Modulkommentar). */
|
|
34
|
+
export const RRF_K = 60;
|
|
35
|
+
|
|
36
|
+
/** Voreingestellte Zweiggewichte (siehe Modulkommentar). */
|
|
37
|
+
export const DEFAULT_BRANCH_WEIGHTS = {
|
|
38
|
+
dense: 1,
|
|
39
|
+
lexical: 1,
|
|
40
|
+
symbol: 0.5,
|
|
41
|
+
} as const;
|
|
42
|
+
|
|
43
|
+
export type FusionBranch = keyof typeof DEFAULT_BRANCH_WEIGHTS;
|
|
44
|
+
|
|
45
|
+
/** Eine Rangliste eines Zweigs: IDs, bester zuerst. */
|
|
46
|
+
export interface FusionInput {
|
|
47
|
+
branch: FusionBranch;
|
|
48
|
+
/** IDs in Rangfolge; Position 0 ist Rang 1. */
|
|
49
|
+
rankedIds: readonly string[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface FusionResult {
|
|
53
|
+
id: string;
|
|
54
|
+
/** Roher RRF-Wert. */
|
|
55
|
+
rrf: number;
|
|
56
|
+
/** Auf [0, 1] normalisierter RRF-Wert (siehe {@link maximumRrf}). */
|
|
57
|
+
normalized: number;
|
|
58
|
+
/** Beitragende Zweige mit ihrem 1-basierten Rang. */
|
|
59
|
+
ranks: Partial<Record<FusionBranch, number>>;
|
|
60
|
+
/** Wirksame Gewichte der beteiligten Zweige (fuer die Score-Evidenz). */
|
|
61
|
+
weights?: Partial<Record<FusionBranch, number>>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type BranchWeights = Record<FusionBranch, number>;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Der groesstmoegliche RRF-Wert: jeder beteiligte Zweig auf Rang 1.
|
|
68
|
+
*
|
|
69
|
+
* Damit wird auf [0, 1] normalisiert, OHNE durch den Maximalwert der aktuellen
|
|
70
|
+
* Trefferliste zu teilen. Das ist der Unterschied zwischen „dieser Treffer ist
|
|
71
|
+
* gut" und „dieser Treffer war der beste, den wir hatten": eine Normalisierung
|
|
72
|
+
* am Listenmaximum gaebe dem besten Treffer immer 1.0, auch wenn er schlecht
|
|
73
|
+
* ist — und die Findability-Diagnose (vision.md §1d) braucht genau diesen
|
|
74
|
+
* Unterschied.
|
|
75
|
+
*/
|
|
76
|
+
export function maximumRrf(branches: readonly FusionBranch[], weights: BranchWeights): number {
|
|
77
|
+
const uniqueBranches = [...new Set(branches)];
|
|
78
|
+
return uniqueBranches.reduce((sum, branch) => sum + weights[branch] / (RRF_K + 1), 0);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Fusioniert die Ranglisten. Ein Kandidat, der in einem Zweig nicht vorkommt,
|
|
83
|
+
* bekommt von diesem Zweig schlicht keinen Beitrag — er wird nicht bestraft.
|
|
84
|
+
*
|
|
85
|
+
* Die Ausgabe ist absteigend nach RRF sortiert, bei Gleichstand nach
|
|
86
|
+
* Codepoints der ID (deterministisch).
|
|
87
|
+
*/
|
|
88
|
+
export function fuseRankings(
|
|
89
|
+
inputs: readonly FusionInput[],
|
|
90
|
+
weights: BranchWeights = { ...DEFAULT_BRANCH_WEIGHTS },
|
|
91
|
+
): FusionResult[] {
|
|
92
|
+
const accumulator = new Map<
|
|
93
|
+
string,
|
|
94
|
+
{ rrf: number; ranks: Partial<Record<FusionBranch, number>> }
|
|
95
|
+
>();
|
|
96
|
+
for (const input of inputs) {
|
|
97
|
+
const weight = weights[input.branch];
|
|
98
|
+
input.rankedIds.forEach((id, index) => {
|
|
99
|
+
const rank = index + 1;
|
|
100
|
+
const entry = accumulator.get(id) ?? { rrf: 0, ranks: {} };
|
|
101
|
+
entry.rrf += weight / (RRF_K + rank);
|
|
102
|
+
entry.ranks[input.branch] = rank;
|
|
103
|
+
accumulator.set(id, entry);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
const maximum = maximumRrf(
|
|
107
|
+
inputs.map((input) => input.branch),
|
|
108
|
+
weights,
|
|
109
|
+
);
|
|
110
|
+
const results: FusionResult[] = [...accumulator.entries()].map(([id, entry]) => ({
|
|
111
|
+
id,
|
|
112
|
+
rrf: entry.rrf,
|
|
113
|
+
normalized: maximum === 0 ? 0 : entry.rrf / maximum,
|
|
114
|
+
ranks: entry.ranks,
|
|
115
|
+
weights: Object.fromEntries(
|
|
116
|
+
[...new Set(inputs.map((input) => input.branch))].map((branch) => [branch, weights[branch]]),
|
|
117
|
+
),
|
|
118
|
+
}));
|
|
119
|
+
results.sort((a, b) => (a.rrf === b.rrf ? compareCodepoints(a.id, b.id) : b.rrf - a.rrf));
|
|
120
|
+
return results;
|
|
121
|
+
}
|
package/src/glob.ts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministischer Glob-Abgleich fuer Pfad-Ein- und -Ausschluss
|
|
3
|
+
* (Nutzerwunsch A4: „Pfad-Ein-/Ausschluss" wie ein grep-Werkzeug).
|
|
4
|
+
*
|
|
5
|
+
* Warum eine eigene, kleine Implementierung statt einer Bibliothek: der
|
|
6
|
+
* Abgleich muss auf „/"-normalisierten, repo-relativen Pfaden arbeiten
|
|
7
|
+
* (spec.md §5.2), darf keine Dateisystemzugriffe machen (wir globben ueber
|
|
8
|
+
* INDIZIERTE Pfade, nicht ueber die Platte) und muss auf jeder Maschine
|
|
9
|
+
* dieselbe Antwort geben. Die unterstuetzte Teilmenge ist bewusst klein und
|
|
10
|
+
* hier vollstaendig dokumentiert:
|
|
11
|
+
*
|
|
12
|
+
* | Muster | Bedeutung |
|
|
13
|
+
* |---|---|
|
|
14
|
+
* | `?` | genau ein Zeichen, aber nie `/` |
|
|
15
|
+
* | `*` | beliebig viele Zeichen, aber nie `/` |
|
|
16
|
+
* | `**` | beliebig viele Zeichen inklusive `/` |
|
|
17
|
+
* | `[abc]` | eine Zeichenklasse, `[!abc]`/`[^abc]` negiert |
|
|
18
|
+
* | sonst | Literal |
|
|
19
|
+
*
|
|
20
|
+
* Ein Muster ohne `/` wird zusaetzlich gegen den BASENAMEN geprueft, damit
|
|
21
|
+
* `*.ts` das tut, was jeder erwartet, ohne dass der Nutzer `**\/*.ts`
|
|
22
|
+
* schreiben muss. Diese Bequemlichkeit ist eine bewusste Entscheidung und in
|
|
23
|
+
* der README als solche vermerkt.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** Wandelt ein Glob-Muster in einen verankerten regulaeren Ausdruck. */
|
|
27
|
+
export function globToRegExp(pattern: string): RegExp {
|
|
28
|
+
let source = "^";
|
|
29
|
+
let index = 0;
|
|
30
|
+
while (index < pattern.length) {
|
|
31
|
+
const char = pattern[index] as string;
|
|
32
|
+
if (char === "*") {
|
|
33
|
+
const isDouble = pattern[index + 1] === "*";
|
|
34
|
+
if (isDouble) {
|
|
35
|
+
// `a/**/b` soll auch `a/b` treffen: der Schraegstrich hinter `**`
|
|
36
|
+
// wird optional mitgeschluckt.
|
|
37
|
+
if (pattern[index + 2] === "/") {
|
|
38
|
+
source += "(?:.*/)?";
|
|
39
|
+
index += 3;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
source += ".*";
|
|
43
|
+
index += 2;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
source += "[^/]*";
|
|
47
|
+
index += 1;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (char === "?") {
|
|
51
|
+
source += "[^/]";
|
|
52
|
+
index += 1;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (char === "[") {
|
|
56
|
+
const close = pattern.indexOf("]", index + 1);
|
|
57
|
+
if (close === -1) {
|
|
58
|
+
// Unpaarige Klammer ist ein Literal, kein Fehler — ein Suchmuster
|
|
59
|
+
// darf nicht daran scheitern, dass jemand `[` im Pfad meint.
|
|
60
|
+
source += "\\[";
|
|
61
|
+
index += 1;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const body = pattern.slice(index + 1, close);
|
|
65
|
+
const negated = body.startsWith("!") || body.startsWith("^");
|
|
66
|
+
const content = negated ? body.slice(1) : body;
|
|
67
|
+
source += `[${negated ? "^" : ""}${content.replace(/[\\\]]/g, "\\$&")}]`;
|
|
68
|
+
index = close + 1;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
source += char.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
72
|
+
index += 1;
|
|
73
|
+
}
|
|
74
|
+
source += "$";
|
|
75
|
+
try {
|
|
76
|
+
return new RegExp(source, "u");
|
|
77
|
+
} catch (error) {
|
|
78
|
+
// Ein fehlerhaftes Muster ist ein EINGABEFEHLER des Nutzers. Ohne diesen
|
|
79
|
+
// Rahmen wandert der rohe `SyntaxError` durch den Abgleich bis in ein
|
|
80
|
+
// catch, das ihn als Speicherausfall verbucht — der Nutzer bekaeme dann
|
|
81
|
+
// „Provider nicht verfuegbar" fuer sein eigenes Zeichenklassenmuster.
|
|
82
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
83
|
+
throw new Error(`glob pattern "${pattern}" is not a valid pattern: ${message}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Prueft eine Musterliste, BEVOR sie benutzt wird.
|
|
89
|
+
*
|
|
90
|
+
* Warum vorab und nicht erst beim Abgleich: der Abgleich laeuft je Kandidat,
|
|
91
|
+
* tief in der Auswertung. Ein Fehler dort trifft eine Stelle, die ihn nur noch
|
|
92
|
+
* als „dieses Ziel hat nicht geantwortet" deuten kann. Vorab geprueft, ist er
|
|
93
|
+
* das, was er ist: eine abgelehnte Anfrage mit benanntem Muster.
|
|
94
|
+
*/
|
|
95
|
+
export function assertValidGlobPatterns(patterns: readonly string[], label: string): void {
|
|
96
|
+
for (const pattern of patterns) {
|
|
97
|
+
try {
|
|
98
|
+
globToRegExp(normalizeGlobPath(pattern));
|
|
99
|
+
} catch (error) {
|
|
100
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
101
|
+
throw new Error(`search query field "${label}" contains an invalid pattern: ${message}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Kanonisiert einen Pfad fuer den Abgleich: Backslashes zu `/`, kein `./`. */
|
|
107
|
+
export function normalizeGlobPath(path: string): string {
|
|
108
|
+
const slashed = path.replace(/\\/g, "/");
|
|
109
|
+
return slashed.startsWith("./") ? slashed.slice(2) : slashed;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** True, wenn `path` auf `pattern` passt (siehe Modulkommentar). */
|
|
113
|
+
export function matchesGlob(path: string, pattern: string): boolean {
|
|
114
|
+
const normalizedPath = normalizeGlobPath(path);
|
|
115
|
+
const normalizedPattern = normalizeGlobPath(pattern);
|
|
116
|
+
const expression = globToRegExp(normalizedPattern);
|
|
117
|
+
if (expression.test(normalizedPath)) {
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
if (!normalizedPattern.includes("/")) {
|
|
121
|
+
const basename = normalizedPath.slice(normalizedPath.lastIndexOf("/") + 1);
|
|
122
|
+
return expression.test(basename);
|
|
123
|
+
}
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** True, wenn `path` auf mindestens eines der Muster passt. */
|
|
128
|
+
export function matchesAnyGlob(path: string, patterns: readonly string[]): boolean {
|
|
129
|
+
return patterns.some((pattern) => matchesGlob(path, pattern));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Entscheidet ueber Ein-/Ausschluss eines Pfades.
|
|
134
|
+
*
|
|
135
|
+
* - leere Einschlussliste heisst „alles einschliessen" (wie in SearchFiltersV1
|
|
136
|
+
* dokumentiert: eine leere Liste ist kein Filter);
|
|
137
|
+
* - Ausschluss gewinnt immer gegen Einschluss.
|
|
138
|
+
*/
|
|
139
|
+
export function isPathSelected(
|
|
140
|
+
path: string,
|
|
141
|
+
include: readonly string[],
|
|
142
|
+
exclude: readonly string[],
|
|
143
|
+
): boolean {
|
|
144
|
+
if (exclude.length > 0 && matchesAnyGlob(path, exclude)) {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
if (include.length === 0) {
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
return matchesAnyGlob(path, include);
|
|
151
|
+
}
|