@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.
- 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/bm25.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministisches Okapi BM25 ueber den Kandidatenpool des Abrufkerns.
|
|
3
|
+
*
|
|
4
|
+
* WARUM EIGEN UND NICHT AUS DEM SPEICHER: Der `VectorStore` liefert im
|
|
5
|
+
* spaerlichen Zweig einen Rang, den der Speicher berechnet hat (bei Qdrant
|
|
6
|
+
* mit seiner eigenen IDF-Statistik ueber die ganze Collection). Dieser Rang
|
|
7
|
+
* ist ausgezeichnet fuer den ABRUF (Recall), aber er ist nicht in diesem
|
|
8
|
+
* Prozess nachrechenbar und aendert sich mit dem Speicherstand. Die
|
|
9
|
+
* Score-Zerlegung soll erklaeren, WARUM ein Treffer rankt — dafuer muss der
|
|
10
|
+
* lexikalische Anteil hier berechenbar sein. Deshalb:
|
|
11
|
+
*
|
|
12
|
+
* - der Speicher liefert KANDIDATEN (dichter und spaerlicher Zweig),
|
|
13
|
+
* - der lexikalische RANG entsteht in diesem Modul, mit den Statistiken des
|
|
14
|
+
* Kandidatenpools.
|
|
15
|
+
*
|
|
16
|
+
* Der Preis dieser Entscheidung ist benannt und nicht wegdiskutiert: IDF
|
|
17
|
+
* ueber den Kandidatenpool ist nicht IDF ueber das ganze Korpus. Bei kleinen
|
|
18
|
+
* Pools kann ein Wort dadurch seltener aussehen, als es ist. Der Pool ist
|
|
19
|
+
* `candidateLimit` gross (Default 50, Maximum 200) und damit gross genug fuer
|
|
20
|
+
* eine stabile Ordnung INNERHALB des Pools — und nur diese Ordnung geht in
|
|
21
|
+
* die Fusion ein, nicht der absolute Wert. Siehe README „Bekannte Grenzen".
|
|
22
|
+
*
|
|
23
|
+
* Konstanten: k1 = 1.2, b = 0.75 — die Standardwerte aus Robertson/Sparck
|
|
24
|
+
* Jones. Sie sind hier keine „magischen Gewichte", sondern die dokumentierte
|
|
25
|
+
* Voreinstellung des Verfahrens; abweichende Werte muessten begruendet werden.
|
|
26
|
+
*/
|
|
27
|
+
import { compareCodepoints } from "@untangle-code/domain";
|
|
28
|
+
|
|
29
|
+
import { informativeQueryTokens, termFrequencies, tokenizeIdentifiers } from "./tokenize.js";
|
|
30
|
+
|
|
31
|
+
/** Saettigungskonstante der Termhaeufigkeit (Standardwert). */
|
|
32
|
+
export const BM25_K1 = 1.2;
|
|
33
|
+
/** Laengennormalisierung (Standardwert). */
|
|
34
|
+
export const BM25_B = 0.75;
|
|
35
|
+
|
|
36
|
+
/** Ein Dokument des lexikalischen Index. */
|
|
37
|
+
export interface LexicalDocument {
|
|
38
|
+
id: string;
|
|
39
|
+
text: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Ein Rangeintrag des lexikalischen Zweigs. */
|
|
43
|
+
export interface LexicalRanking {
|
|
44
|
+
id: string;
|
|
45
|
+
score: number;
|
|
46
|
+
/** 1-basierter Rang. */
|
|
47
|
+
rank: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface PreparedDocument {
|
|
51
|
+
id: string;
|
|
52
|
+
frequencies: Map<string, number>;
|
|
53
|
+
length: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* BM25 ueber eine feste Dokumentmenge. Die Statistiken werden EINMAL im
|
|
58
|
+
* Konstruktor gebildet; danach ist `score()` eine reine Funktion.
|
|
59
|
+
*/
|
|
60
|
+
export class Bm25Ranker {
|
|
61
|
+
private readonly documents: PreparedDocument[] = [];
|
|
62
|
+
private readonly documentFrequency = new Map<string, number>();
|
|
63
|
+
private readonly averageLength: number;
|
|
64
|
+
|
|
65
|
+
public constructor(
|
|
66
|
+
documents: readonly LexicalDocument[],
|
|
67
|
+
private readonly k1: number = BM25_K1,
|
|
68
|
+
private readonly b: number = BM25_B,
|
|
69
|
+
) {
|
|
70
|
+
let totalLength = 0;
|
|
71
|
+
for (const document of documents) {
|
|
72
|
+
const tokens = tokenizeIdentifiers(document.text);
|
|
73
|
+
const frequencies = termFrequencies(tokens);
|
|
74
|
+
this.documents.push({ id: document.id, frequencies, length: tokens.length });
|
|
75
|
+
totalLength += tokens.length;
|
|
76
|
+
for (const term of frequencies.keys()) {
|
|
77
|
+
this.documentFrequency.set(term, (this.documentFrequency.get(term) ?? 0) + 1);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
this.averageLength = this.documents.length === 0 ? 1 : totalLength / this.documents.length;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
public get documentCount(): number {
|
|
84
|
+
return this.documents.length;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Inverse Dokumenthaeufigkeit in der geglaetteten Form
|
|
89
|
+
* `ln(1 + (N - df + 0.5) / (df + 0.5))` — immer positiv, damit ein in jedem
|
|
90
|
+
* Dokument vorkommender Term den Score nicht negativ macht.
|
|
91
|
+
*/
|
|
92
|
+
private inverseDocumentFrequency(term: string): number {
|
|
93
|
+
const df = this.documentFrequency.get(term) ?? 0;
|
|
94
|
+
const n = this.documents.length;
|
|
95
|
+
return Math.log(1 + (n - df + 0.5) / (df + 0.5));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private scoreDocument(queryTerms: readonly string[], document: PreparedDocument): number {
|
|
99
|
+
let total = 0;
|
|
100
|
+
for (const term of queryTerms) {
|
|
101
|
+
const tf = document.frequencies.get(term) ?? 0;
|
|
102
|
+
if (tf === 0) {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const numerator = tf * (this.k1 + 1);
|
|
106
|
+
const denominator =
|
|
107
|
+
tf + this.k1 * (1 - this.b + this.b * (document.length / this.averageLength));
|
|
108
|
+
total += this.inverseDocumentFrequency(term) * (numerator / denominator);
|
|
109
|
+
}
|
|
110
|
+
return total;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Rangliste zu einem Suchtext. Dokumente mit Score 0 (kein Term getroffen)
|
|
115
|
+
* fallen heraus — sie haben lexikalisch nichts beigetragen und duerfen die
|
|
116
|
+
* Fusion nicht mit einem Scheinrang beeinflussen.
|
|
117
|
+
*
|
|
118
|
+
* Gleichstand wird deterministisch nach Codepoints der ID aufgeloest, nie
|
|
119
|
+
* nach Einfuegereihenfolge und nie ueber `localeCompare`.
|
|
120
|
+
*/
|
|
121
|
+
public rank(queryText: string): LexicalRanking[] {
|
|
122
|
+
const queryTerms = [...termFrequencies(informativeQueryTokens(queryText)).keys()];
|
|
123
|
+
const scored: { id: string; score: number }[] = [];
|
|
124
|
+
for (const document of this.documents) {
|
|
125
|
+
const score = this.scoreDocument(queryTerms, document);
|
|
126
|
+
if (score > 0) {
|
|
127
|
+
scored.push({ id: document.id, score });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
scored.sort((a, b) =>
|
|
131
|
+
a.score === b.score ? compareCodepoints(a.id, b.id) : b.score - a.score,
|
|
132
|
+
);
|
|
133
|
+
return scored.map((entry, index) => ({ id: entry.id, score: entry.score, rank: index + 1 }));
|
|
134
|
+
}
|
|
135
|
+
}
|
package/src/deadline.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { UntangleError, type PortCallOptions } from "@untangle-code/domain";
|
|
2
|
+
|
|
3
|
+
/** Bound hybrid branches and propagate outer cancellation even if an adapter hangs. */
|
|
4
|
+
export async function withSearchBranchDeadline<T>(
|
|
5
|
+
branch: "dense" | "sparse" | "symbol",
|
|
6
|
+
timeoutMs: number | undefined,
|
|
7
|
+
retrieve: (options: PortCallOptions) => Promise<T>,
|
|
8
|
+
options?: PortCallOptions,
|
|
9
|
+
storeFailureCode: "QDRANT_UNAVAILABLE" | "LANCEDB_UNAVAILABLE" = "QDRANT_UNAVAILABLE",
|
|
10
|
+
): Promise<T> {
|
|
11
|
+
options?.signal?.throwIfAborted();
|
|
12
|
+
const controller = new AbortController();
|
|
13
|
+
let timeoutError: UntangleError | undefined;
|
|
14
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
15
|
+
let onAbort: (() => void) | undefined;
|
|
16
|
+
const interrupted = new Promise<never>((_, reject) => {
|
|
17
|
+
onAbort = () => {
|
|
18
|
+
const reason: unknown = options?.signal?.reason;
|
|
19
|
+
reject(reason);
|
|
20
|
+
controller.abort(reason);
|
|
21
|
+
};
|
|
22
|
+
options?.signal?.addEventListener("abort", onAbort, { once: true });
|
|
23
|
+
if (timeoutMs !== undefined) {
|
|
24
|
+
timer = setTimeout(() => {
|
|
25
|
+
timeoutError = new UntangleError(
|
|
26
|
+
branch === "dense" ? "EMBEDDING_UNAVAILABLE" : storeFailureCode,
|
|
27
|
+
`retrieval branch "${branch}" exceeded its ${String(timeoutMs)}ms deadline`,
|
|
28
|
+
{
|
|
29
|
+
operation: "search_branch",
|
|
30
|
+
retryable: true,
|
|
31
|
+
context: { branch, timeoutMs, timedOut: true },
|
|
32
|
+
},
|
|
33
|
+
);
|
|
34
|
+
// Reject before notifying the adapter: an adapter's JOB_CANCELLED is
|
|
35
|
+
// caused by this local deadline and must not cancel successful peers.
|
|
36
|
+
reject(timeoutError);
|
|
37
|
+
controller.abort(timeoutError);
|
|
38
|
+
}, timeoutMs);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
try {
|
|
42
|
+
// Promise.race installs handlers on both promises, including a late
|
|
43
|
+
// adapter rejection after a deadline. It cannot become unhandled.
|
|
44
|
+
return await Promise.race([
|
|
45
|
+
Promise.resolve().then(() => {
|
|
46
|
+
controller.signal.throwIfAborted();
|
|
47
|
+
return retrieve({ ...options, signal: controller.signal });
|
|
48
|
+
}),
|
|
49
|
+
interrupted,
|
|
50
|
+
]);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
options?.signal?.throwIfAborted();
|
|
53
|
+
if (timeoutError !== undefined) throw timeoutError;
|
|
54
|
+
throw error;
|
|
55
|
+
} finally {
|
|
56
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
57
|
+
if (onAbort !== undefined) options?.signal?.removeEventListener("abort", onAbort);
|
|
58
|
+
}
|
|
59
|
+
}
|
package/src/dedupe.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deduplizierung (spec.md §13.5, architecture.md §18.4).
|
|
3
|
+
*
|
|
4
|
+
* Mehrere Fragmente desselben Symbols duerfen nicht alle Top-Plaetze belegen.
|
|
5
|
+
* Der Dedupe-Schluessel ist woertlich der aus architecture.md §18.4:
|
|
6
|
+
*
|
|
7
|
+
* workspace_id + repo_id + ref_id + metrics_scope_id
|
|
8
|
+
*
|
|
9
|
+
* Der Ref gehoert bewusst hinein: derselbe Code in zwei Branches oder zwei
|
|
10
|
+
* Worktrees sind ZWEI Antworten, nicht eine — wer ueber mehrere Refs sucht,
|
|
11
|
+
* will beide sehen, jeweils mit ihrer Herkunft (vision.md §1a „Branch- und
|
|
12
|
+
* Worktree-Bewusstsein").
|
|
13
|
+
*
|
|
14
|
+
* Verbunden werden die Bestandteile mit `ID_COMPONENT_SEPARATOR` (U+001F),
|
|
15
|
+
* derselben Trennzeichenkonvention wie bei allen Untangle-IDs — ein Pfad oder
|
|
16
|
+
* Name kann so keinen Schluessel „ueberlaufen" lassen.
|
|
17
|
+
*/
|
|
18
|
+
import type { CodeChunkPayloadV1, RelatedFragmentV1 } from "@untangle-code/domain";
|
|
19
|
+
import { ID_COMPONENT_SEPARATOR, compareCodepoints } from "@untangle-code/domain";
|
|
20
|
+
|
|
21
|
+
import type { SearchCandidate } from "./payload.js";
|
|
22
|
+
|
|
23
|
+
/** Baut den Dedupe-Schluessel eines Chunk-Payloads. */
|
|
24
|
+
export function buildDedupeKey(payload: CodeChunkPayloadV1): string {
|
|
25
|
+
return [payload.workspace_id, payload.repo_id, payload.ref_id, payload.metrics_scope_id].join(
|
|
26
|
+
ID_COMPONENT_SEPARATOR,
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Ein Kandidat mit seinem Basisscore, wie ihn die Fusion geliefert hat. */
|
|
31
|
+
export interface ScoredCandidate {
|
|
32
|
+
candidate: SearchCandidate;
|
|
33
|
+
/** Normalisierter Basisscore aus der Fusion, in [0, 1]. */
|
|
34
|
+
baseScore: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Ergebnis der Deduplizierung einer Gruppe. */
|
|
38
|
+
export interface DedupedCandidate {
|
|
39
|
+
primary: ScoredCandidate;
|
|
40
|
+
dedupeKey: string;
|
|
41
|
+
relatedFragments: RelatedFragmentV1[];
|
|
42
|
+
/** Die verdraengten Kandidaten — fuer den Skip-Ledger (E4). */
|
|
43
|
+
absorbed: ScoredCandidate[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Fasst Kandidaten mit gleichem Dedupe-Schluessel zusammen.
|
|
48
|
+
*
|
|
49
|
+
* Primaer bleibt der bestbewertete; bei Gleichstand entscheidet die
|
|
50
|
+
* Codepoint-Ordnung der Chunk-ID — nie die Reihenfolge, in der der Speicher
|
|
51
|
+
* geantwortet hat.
|
|
52
|
+
*
|
|
53
|
+
* Die Eingabereihenfolge bleibt fuer die Ausgabe massgeblich: die Gruppen
|
|
54
|
+
* erscheinen in der Reihenfolge ihres ersten Vorkommens, damit die Rangfolge
|
|
55
|
+
* der Fusion erhalten bleibt.
|
|
56
|
+
*/
|
|
57
|
+
export function dedupeCandidates(scored: readonly ScoredCandidate[]): DedupedCandidate[] {
|
|
58
|
+
const groups = new Map<string, ScoredCandidate[]>();
|
|
59
|
+
for (const entry of scored) {
|
|
60
|
+
const key = buildDedupeKey(entry.candidate.payload);
|
|
61
|
+
const existing = groups.get(key);
|
|
62
|
+
if (existing === undefined) {
|
|
63
|
+
groups.set(key, [entry]);
|
|
64
|
+
} else {
|
|
65
|
+
existing.push(entry);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return [...groups].map(([key, group]) => {
|
|
70
|
+
const members = [...group].sort(compareScored);
|
|
71
|
+
const primary = members[0] as ScoredCandidate;
|
|
72
|
+
const absorbed = members.slice(1);
|
|
73
|
+
return {
|
|
74
|
+
primary,
|
|
75
|
+
dedupeKey: key,
|
|
76
|
+
absorbed,
|
|
77
|
+
relatedFragments: absorbed.map((member) => toRelatedFragment(member)),
|
|
78
|
+
};
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function compareScored(a: ScoredCandidate, b: ScoredCandidate): number {
|
|
83
|
+
if (a.baseScore !== b.baseScore) {
|
|
84
|
+
return b.baseScore - a.baseScore;
|
|
85
|
+
}
|
|
86
|
+
return compareCodepoints(a.candidate.payload.chunk_id, b.candidate.payload.chunk_id);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function toRelatedFragment(entry: ScoredCandidate): RelatedFragmentV1 {
|
|
90
|
+
const { payload } = entry.candidate;
|
|
91
|
+
return {
|
|
92
|
+
chunkId: payload.chunk_id,
|
|
93
|
+
chunkKind: payload.chunk_kind,
|
|
94
|
+
...(payload.fragment_index === undefined ? {} : { fragmentIndex: payload.fragment_index }),
|
|
95
|
+
startLine: payload.start_line,
|
|
96
|
+
endLine: payload.end_line,
|
|
97
|
+
score: entry.baseScore,
|
|
98
|
+
};
|
|
99
|
+
}
|
package/src/diversify.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Diversifizierung (spec.md §13.2 Schritt 10, architecture.md §18.5).
|
|
3
|
+
*
|
|
4
|
+
* Ziel ist der Fall „zehn nahezu identische Treffer derselben Datei". Nach der
|
|
5
|
+
* Deduplizierung ueber `metrics_scope_id` bleiben immer noch mehrere Symbole
|
|
6
|
+
* derselben Datei uebrig — und wer nach „Funktion X" sucht, will nicht
|
|
7
|
+
* zehnmal dieselbe Datei sehen, sondern die zehn besten STELLEN.
|
|
8
|
+
*
|
|
9
|
+
* Verfahren (bewusst ohne Inhaltsvektoren): Die Liste wird in Rangfolge
|
|
10
|
+
* durchlaufen; jeder Treffer bekommt eine Strafe, die davon abhaengt, wie oft
|
|
11
|
+
* seine Datei (und, schwaecher, sein Repository) vorher schon vertreten war:
|
|
12
|
+
*
|
|
13
|
+
* strafe(n) = 1 - 1 / (1 + n)
|
|
14
|
+
*
|
|
15
|
+
* mit `n` = Zahl der vorher schon vertretenen Treffer derselben Datei. Der
|
|
16
|
+
* erste Treffer einer Datei bekommt also 0, der zweite 0.5, der dritte 0.667 —
|
|
17
|
+
* eine saettigende Kurve, die nie 1 erreicht. Damit verschwindet keine Datei
|
|
18
|
+
* vollstaendig aus dem Ergebnis; sie wird nur immer teurer.
|
|
19
|
+
*
|
|
20
|
+
* Warum keine Content-MMR: dafuer braeuchte es die dichten Vektoren aller
|
|
21
|
+
* Kandidaten. Der `VectorStore`-Port liefert sie beim Suchen nicht mit
|
|
22
|
+
* (`hybridQuery` gibt Payload, keine Vektoren), und sie nachzuladen kostet
|
|
23
|
+
* eine zweite Runde ueber den Speicher. Die datei-/repo-basierte Streuung ist
|
|
24
|
+
* die Dimension, die architecture.md §18.5 zuerst nennt, und sie kostet nichts.
|
|
25
|
+
* Der Verzicht auf die Content-Dimension steht in der README unter „Bekannte
|
|
26
|
+
* Grenzen".
|
|
27
|
+
*/
|
|
28
|
+
import type { CodeChunkPayloadV1 } from "@untangle-code/domain";
|
|
29
|
+
import { ID_COMPONENT_SEPARATOR } from "@untangle-code/domain";
|
|
30
|
+
|
|
31
|
+
/** Anteil der Datei-Strafe, den ein reiner Repo-Wiederholer bekommt. */
|
|
32
|
+
export const REPO_REPETITION_SHARE = 0.25;
|
|
33
|
+
|
|
34
|
+
/** Ein Eintrag der Diversifizierung: Gegenstand plus zugeteilte Strafe. */
|
|
35
|
+
export interface DiversificationEntry<T> {
|
|
36
|
+
item: T;
|
|
37
|
+
/** Rohwert der `duplicate_penalty` in [0, 1). */
|
|
38
|
+
penaltyRawValue: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function saturatingPenalty(previousOccurrences: number): number {
|
|
42
|
+
return 1 - 1 / (1 + previousOccurrences);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Berechnet die Duplikatsstrafen in Rangfolge.
|
|
47
|
+
*
|
|
48
|
+
* Die Funktion sortiert NICHT um — sie bewertet nur. Die Neuordnung passiert
|
|
49
|
+
* spaeter ueber den fertigen Endscore, damit jede Verschiebung in der
|
|
50
|
+
* Score-Zerlegung sichtbar ist statt in einem unsichtbaren Sortierschritt.
|
|
51
|
+
*/
|
|
52
|
+
export function assignDuplicatePenalties<T>(
|
|
53
|
+
items: readonly T[],
|
|
54
|
+
payloadOf: (item: T) => CodeChunkPayloadV1,
|
|
55
|
+
): DiversificationEntry<T>[] {
|
|
56
|
+
const fileCounts = new Map<string, number>();
|
|
57
|
+
const repoCounts = new Map<string, number>();
|
|
58
|
+
return items.map((item) => {
|
|
59
|
+
const payload = payloadOf(item);
|
|
60
|
+
const repoKey = [payload.repo_id, payload.ref_id].join(ID_COMPONENT_SEPARATOR);
|
|
61
|
+
const fileKey = [repoKey, payload.file_id].join(ID_COMPONENT_SEPARATOR);
|
|
62
|
+
const previousFile = fileCounts.get(fileKey) ?? 0;
|
|
63
|
+
const previousRepo = repoCounts.get(repoKey) ?? 0;
|
|
64
|
+
fileCounts.set(fileKey, previousFile + 1);
|
|
65
|
+
repoCounts.set(repoKey, previousRepo + 1);
|
|
66
|
+
const filePenalty = saturatingPenalty(previousFile);
|
|
67
|
+
const repoPenalty =
|
|
68
|
+
previousFile === 0 ? saturatingPenalty(previousRepo) * REPO_REPETITION_SHARE : 0;
|
|
69
|
+
return { item, penaltyRawValue: Math.min(1, filePenalty + repoPenalty) };
|
|
70
|
+
});
|
|
71
|
+
}
|