@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,580 @@
1
+ /**
2
+ * Findability als Eigenschaft je Symbol (vision.md §1d).
3
+ *
4
+ * Die These, um die es hier geht, steht woertlich in vision.md §1d:
5
+ *
6
+ * > Wenn die Suche nach „Funktion X" nicht schnell an der richtigen Stelle
7
+ * > landet, ist das ein Befund ueber den Code — nicht nur ueber die Suche.
8
+ *
9
+ * Deshalb sucht sich hier ein Symbol SELBST: einmal mit seinem Namen, einmal
10
+ * mit seiner Signatur. Was dabei herauskommt — Rang, Streuung der Treffer,
11
+ * Sprungzahl — ist die Messung. Dass die Score-Zerlegung des Abrufkerns dabei
12
+ * mitlaeuft, ist kein Zufall: ohne sie liesse sich „der richtige Treffer
13
+ * rankt niedrig" nicht von „es gibt gar keinen Treffer" unterscheiden, und das
14
+ * sind zwei VERSCHIEDENE Befunde ueber den Code.
15
+ *
16
+ * ALLES HIER IST HEURISTIK, und die Domain erzwingt das strukturell:
17
+ * `isHeuristic` ist auf `true` typisiert, die Confidence auf `heuristic`
18
+ * festgenagelt. Ein Findability-Wert kann deshalb nirgends die Stelle eines
19
+ * gemessenen Complexity-Werts einnehmen.
20
+ */
21
+ import type {
22
+ Clock,
23
+ FindabilityDiagnosisBasisV1,
24
+ FindabilityDiagnosisV1,
25
+ FindabilityDispersionV1,
26
+ FindabilityEvidenceV1,
27
+ FindabilityHopsV1,
28
+ FindabilityOutcomeV1,
29
+ FindabilityProbeKind,
30
+ FindabilityProbeV1,
31
+ FindabilityRankMissingReason,
32
+ FindabilityRetrievalUnavailableReason,
33
+ FindabilityUnavailableV1,
34
+ HeuristicConfidenceV1,
35
+ PortCallOptions,
36
+ SearchResultSetV1,
37
+ SkipLedgerEntryV1,
38
+ } from "@untangle-code/domain";
39
+ import {
40
+ FINDABILITY_ALGORITHM_ID,
41
+ RANKER_VERSION,
42
+ buildFindabilityId,
43
+ buildSkipLedgerEntry,
44
+ isUntangleError,
45
+ makeFindabilityUnavailable,
46
+ openFindabilityDiagnosisBasis,
47
+ sortByCodepoints,
48
+ } from "@untangle-code/domain";
49
+
50
+ import type { SearchPort } from "./port.js";
51
+
52
+ /** Das Symbol, dessen Auffindbarkeit gemessen wird. */
53
+ export interface FindabilitySubject {
54
+ workspaceId: string;
55
+ repoId: string;
56
+ refId: string;
57
+ symbolId: string;
58
+ symbolName: string;
59
+ symbolFqn: string;
60
+ /** „/"-normalisierter, repo-relativer Pfad. */
61
+ filePath: string;
62
+ signature?: string;
63
+ }
64
+
65
+ /** Liefert die Sprungzahl vom Rang-1-Treffer zum Zielsymbol (Symbolgraph). */
66
+ export type HopsLookup = (input: {
67
+ fromSymbolId: string;
68
+ toSymbolId: string;
69
+ }) => FindabilityHopsV1 | undefined;
70
+
71
+ export interface FindabilityOptions {
72
+ /** Wie viele Treffer eine Sonde anfordert; Default 10. */
73
+ probeLimit?: number;
74
+ hops?: HopsLookup;
75
+ generationId: string;
76
+ /**
77
+ * Optionale Vorpruefung: ist das Symbol ueberhaupt indexiert? Fehlt sie,
78
+ * wird nicht geraten — dann bleibt nur, was die Suche selbst meldet.
79
+ * Antwortet sie `false`, entsteht ein Zustandsvermerk statt einer Messung:
80
+ * ein nicht indexiertes Symbol findet sich nicht, und das sagt nichts ueber
81
+ * seinen Code (vision.md §1d).
82
+ */
83
+ isSymbolIndexed?: (symbolId: string) => boolean;
84
+ }
85
+
86
+ /**
87
+ * Schwellen der Diagnose. Sie sind Politik, keine Naturkonstanten — deshalb
88
+ * stehen sie hier benannt und werden in jedem Beleg (`threshold`) mitgeliefert,
89
+ * damit ein Leser die Aussage nachrechnen kann.
90
+ */
91
+ export const FINDABILITY_THRESHOLDS = {
92
+ /** Ab diesem Rang gilt der eigene Treffer als „rankt niedrig". */
93
+ weakRank: 3,
94
+ /** Ab dieser Streuung gilt die Verantwortlichkeit als zerfasert. */
95
+ scatteredDispersion: 0.6,
96
+ /** Ab dieser Sprungzahl gilt der Weg als zu verflochten. */
97
+ entangledHops: 2,
98
+ } as const;
99
+
100
+ /** Benannte Formel hinter `FindabilityDispersionV1.dispersion`. */
101
+ export const DISPERSION_FORMULA =
102
+ "(matchedFileCount - 1) / max(1, matchedHitCount - 1), clamped to [0, 1]";
103
+
104
+ /** Benannte Formel hinter `FindabilityV1.score`. */
105
+ export const FINDABILITY_SCORE_FORMULA =
106
+ "0.5*rankScore(nameProbe) + 0.2*rankScore(signatureProbe) + 0.2*(1 - dispersion) + 0.1*hopScore, with rankScore(r) = 0 for no rank else 1/r, hopScore = 0.5 when hops are unknown else 1/(1+hops)";
107
+
108
+ /** Rangwert einer Sonde: Rang 1 → 1.0, Rang 2 → 0.5, kein Rang → 0. */
109
+ export function rankScore(rank: number | null): number {
110
+ return rank === null ? 0 : 1 / rank;
111
+ }
112
+
113
+ function heuristicConfidence(value: number, signals: string[]): HeuristicConfidenceV1 {
114
+ return { value, basis: "heuristic", signals };
115
+ }
116
+
117
+ interface ProbeOutcome {
118
+ probe: FindabilityProbeV1;
119
+ /** Dateien der Treffer, fuer die Streuung. */
120
+ filePaths: string[];
121
+ hitCount: number;
122
+ ownFileHitCount: number;
123
+ }
124
+
125
+ function evaluateProbe(
126
+ kind: FindabilityProbeKind,
127
+ queryText: string,
128
+ subject: FindabilitySubject,
129
+ set: SearchResultSetV1,
130
+ ): ProbeOutcome {
131
+ // Alle Gruppen zusammen: eine Sonde fragt nach EINEM Symbol, und in welcher
132
+ // Profilgruppe es auftaucht, ist fuer die Auffindbarkeit unerheblich. Die
133
+ // Raenge bleiben gruppenintern korrekt, weil die Sonde den Rang des eigenen
134
+ // Treffers innerhalb SEINER Gruppe liest.
135
+ const hits = set.groups.flatMap((group) => group.hits);
136
+ const own = hits.find((hit) => hit.symbolId === subject.symbolId);
137
+ const topScore = hits[0]?.score.final;
138
+ const probe: FindabilityProbeV1 = {
139
+ kind,
140
+ queryText,
141
+ queryFingerprint: set.queryFingerprint,
142
+ rank: own === undefined ? null : own.rank,
143
+ ...(own === undefined ? { rankMissingReason: rankMissingReasonFor(set, hits.length) } : {}),
144
+ hitCount: hits.length,
145
+ ...(topScore === undefined ? {} : { topScore }),
146
+ ...(own === undefined ? {} : { selfScore: own.score.final }),
147
+ };
148
+ return {
149
+ probe,
150
+ filePaths: hits.map((hit) => hit.filePath),
151
+ hitCount: hits.length,
152
+ ownFileHitCount: hits.filter((hit) => hit.filePath === subject.filePath).length,
153
+ };
154
+ }
155
+
156
+ /**
157
+ * Warum eine Sonde keinen Rang ergab — aus der ENTSCHEIDUNG, nie aus der
158
+ * Listenlaenge.
159
+ *
160
+ * Vorher stand hier `hits.length === 0 ? "no_match" : "outside_result_limit"`.
161
+ * Beide sind Behauptungen ueber den CODE. Eine Enthaltung ist aber eine
162
+ * Aussage ueber die KALIBRIERUNG: der Abruf lief, es gab Kandidaten, und die
163
+ * Policy hielt die Evidenz fuer unzureichend. Als `no_match` veroeffentlicht
164
+ * wurde daraus „dazu gibt es nichts" — und ein Symbol, das fuer seinen eigenen
165
+ * Namen auf Rang 1 stuende, galt als unauffindbar.
166
+ */
167
+ function rankMissingReasonFor(
168
+ set: SearchResultSetV1,
169
+ hitCount: number,
170
+ ): FindabilityRankMissingReason {
171
+ if (set.groups.some((group) => group.relevanceDecision?.status === "rejected")) {
172
+ return "relevance_policy_rejected";
173
+ }
174
+ // Kein Kandidat hat den Abruf ueberhaupt erreicht: dann ist „nichts
175
+ // gefunden" eine belegte Aussage und keine Vermutung.
176
+ if (
177
+ set.groups.length > 0 &&
178
+ set.groups.every((group) => group.retrieval?.deduplicatedCandidateCount === 0)
179
+ ) {
180
+ return "no_match";
181
+ }
182
+ return hitCount === 0 ? "no_match" : "outside_result_limit";
183
+ }
184
+
185
+ /** Streuung ueber Dateien (siehe {@link DISPERSION_FORMULA}). */
186
+ export function computeDispersion(
187
+ filePaths: readonly string[],
188
+ ownFileHitCount: number,
189
+ ): FindabilityDispersionV1 {
190
+ const matchedHitCount = filePaths.length;
191
+ const matchedFileCount = new Set(filePaths).size;
192
+ const raw = (matchedFileCount - 1) / Math.max(1, matchedHitCount - 1);
193
+ const dispersion = Math.min(1, Math.max(0, raw));
194
+ return { matchedFileCount, matchedHitCount, ownFileHitCount, dispersion };
195
+ }
196
+
197
+ function evidence(
198
+ kind: FindabilityEvidenceV1["kind"],
199
+ observedValue: number,
200
+ threshold: number,
201
+ references: string[],
202
+ note?: string,
203
+ ): FindabilityEvidenceV1 {
204
+ return {
205
+ kind,
206
+ observedValue,
207
+ threshold,
208
+ references,
209
+ ...(note === undefined ? {} : { note }),
210
+ };
211
+ }
212
+
213
+ /**
214
+ * Leitet die benannten Ursachen aus den Messwerten ab (vision.md §1d Tabelle).
215
+ *
216
+ * Nimmt bewusst KEINE losen Sonden entgegen, sondern nur einen
217
+ * {@link FindabilityDiagnosisBasisV1} — den Nachweis aus `packages/domain`,
218
+ * dass der Abruf tatsaechlich gelaufen ist. Ein Zustandsvermerk
219
+ * (`FindabilityUnavailableV1`) ist auf diesen Typ nicht zuweisbar, eine
220
+ * ausgefallene Sonde bekommt gar keinen Nachweis. Damit ist es im TYP
221
+ * unmoeglich, aus einem Werkzeugversagen eine Aussage ueber den Code zu
222
+ * machen — genau der Denkfehler, den vision.md §1d frueher offen liess.
223
+ */
224
+ export function diagnose(
225
+ subject: FindabilitySubject,
226
+ basis: FindabilityDiagnosisBasisV1,
227
+ ): FindabilityDiagnosisV1[] {
228
+ const { nameProbe, dispersion, hops } = basis;
229
+ const diagnoses: FindabilityDiagnosisV1[] = [];
230
+
231
+ // Die Fallunterscheidung folgt der Tabelle aus vision.md §1d:
232
+ // „Gar kein passender Treffer" -> das Symbol taucht in seinen eigenen
233
+ // Suchergebnissen NICHT auf -> `undocumented`;
234
+ // „Der richtige Treffer rankt niedrig" -> es taucht auf, aber weit unten
235
+ // -> `weak-naming`.
236
+ // Wichtig: „kein Rang" heisst NICHT „keine Treffer". Der dichte Zweig
237
+ // liefert fast immer irgendetwas; entscheidend ist, ob das GESUCHTE Symbol
238
+ // darunter ist.
239
+ if (nameProbe.rank === null) {
240
+ diagnoses.push({
241
+ cause: "undocumented",
242
+ confidence: heuristicConfidence(nameProbe.hitCount === 0 ? 0.6 : 0.5, [
243
+ "self_not_in_own_name_results",
244
+ ...(nameProbe.hitCount === 0 ? ["no_results_at_all"] : ["other_results_ranked_instead"]),
245
+ ]),
246
+ evidence: [
247
+ evidence(
248
+ "hit_count",
249
+ nameProbe.hitCount,
250
+ 1,
251
+ [subject.symbolFqn],
252
+ "searching for the symbol's own name did not return the symbol itself",
253
+ ),
254
+ ],
255
+ isHeuristic: true,
256
+ note: "the symbol's own name does not retrieve it; either the name carries no intent or nothing describes it",
257
+ });
258
+ } else if (nameProbe.rank > FINDABILITY_THRESHOLDS.weakRank) {
259
+ diagnoses.push({
260
+ cause: "weak-naming",
261
+ confidence: heuristicConfidence(0.5, ["self_rank_below_threshold"]),
262
+ evidence: [
263
+ evidence(
264
+ "self_rank",
265
+ nameProbe.rank,
266
+ FINDABILITY_THRESHOLDS.weakRank,
267
+ [subject.symbolFqn],
268
+ "the symbol does not rank near the top for its own name",
269
+ ),
270
+ ],
271
+ isHeuristic: true,
272
+ });
273
+ }
274
+
275
+ if (dispersion.dispersion > FINDABILITY_THRESHOLDS.scatteredDispersion) {
276
+ diagnoses.push({
277
+ cause: "scattered-responsibility",
278
+ confidence: heuristicConfidence(0.5, ["high_file_dispersion"]),
279
+ evidence: [
280
+ evidence(
281
+ "matched_file_count",
282
+ dispersion.matchedFileCount,
283
+ FINDABILITY_THRESHOLDS.scatteredDispersion,
284
+ [subject.filePath],
285
+ `hits spread over ${dispersion.matchedFileCount} files (dispersion ${dispersion.dispersion.toFixed(3)})`,
286
+ ),
287
+ ],
288
+ isHeuristic: true,
289
+ });
290
+ }
291
+
292
+ if (hops.hops !== null && hops.hops > FINDABILITY_THRESHOLDS.entangledHops) {
293
+ diagnoses.push({
294
+ cause: "high-entanglement",
295
+ confidence: heuristicConfidence(hops.pathUsesUncertainEdges ? 0.4 : 0.6, [
296
+ "hop_count_above_threshold",
297
+ ...(hops.pathUsesUncertainEdges ? ["path_uses_uncertain_edges"] : []),
298
+ ]),
299
+ evidence: [evidence("hop_count", hops.hops, FINDABILITY_THRESHOLDS.entangledHops, hops.path)],
300
+ isHeuristic: true,
301
+ });
302
+ }
303
+
304
+ return diagnoses;
305
+ }
306
+
307
+ // ---------------------------------------------------------------------------
308
+ // Lief die Suche ueberhaupt? (vision.md §1d)
309
+ // ---------------------------------------------------------------------------
310
+
311
+ /**
312
+ * Warncodes, mit denen der Abrufkern ein AUSGEFALLENES Suchziel meldet.
313
+ *
314
+ * Diese Liste ist noetig, weil `UntangleSearchEngine` ein ausgefallenes Ziel
315
+ * NICHT wirft, sondern nach spec.md §25.2 sichtbar degradiert: das Ergebnis
316
+ * kommt mit einer Gruppe weniger — im schlimmsten Fall ganz ohne Gruppe — und
317
+ * einer Warnung zurueck. Wer nur auf `hits.length` schaut, liest dann „kein
318
+ * Treffer", und genau daraus entstuende die falsche Aussage ueber den Code.
319
+ */
320
+ export const FINDABILITY_RETRIEVAL_FAILURE_WARNING_CODES = [
321
+ "EMBEDDING_DIMENSION_MISMATCH",
322
+ "EMBEDDING_UNAVAILABLE",
323
+ "INDEX_INCOMPLETE",
324
+ "INDEX_PROFILE_MISMATCH",
325
+ "PROFILE_UNAVAILABLE",
326
+ "QDRANT_SCHEMA_INCOMPATIBLE",
327
+ "LANCEDB_UNAVAILABLE",
328
+ "LANCEDB_SCHEMA_INCOMPATIBLE",
329
+ "QDRANT_UNAVAILABLE",
330
+ "SEARCH_BRANCH_UNAVAILABLE",
331
+ ] as const;
332
+
333
+ /** Grund plus Begruendung fuer einen Zustandsvermerk. */
334
+ export interface FindabilityRetrievalFailureV1 {
335
+ reason: FindabilityRetrievalUnavailableReason;
336
+ detail: string;
337
+ }
338
+
339
+ function reasonForCode(code: string): FindabilityRetrievalUnavailableReason {
340
+ if (code === "INDEX_PROFILE_MISMATCH") return "search_profile_missing";
341
+ if (code === "INDEX_INCOMPLETE") return "symbol_not_indexed";
342
+ return "search_unavailable";
343
+ }
344
+
345
+ /**
346
+ * Prueft ein Suchergebnis darauf, ob die Suche wirklich gelaufen ist.
347
+ *
348
+ * `undefined` heisst: sie lief. Alles andere ist ein Zustandsvermerk, kein
349
+ * Messwert. Eine Ergebnismenge ohne jede Profilgruppe zaehlt dabei als
350
+ * Ausfall — ohne Profil hat niemand gesucht, und „nichts gefunden" waere eine
351
+ * Behauptung ueber den Code, die auf keiner Beobachtung steht.
352
+ *
353
+ * DASSELBE GILT FUER EINE GRUPPE, DIE NICHT DURCHSUCHT WURDE (Befund B1 der
354
+ * Nachpruefung, Fall B). Liegt der Geltungsbereich der Sonde — Workspace,
355
+ * Repository, Ref des Symbols — vollstaendig ausserhalb der verdrahteten
356
+ * Ziele, liefert der Abrufkern trotzdem eine Gruppe: leer, mit
357
+ * `notSearchedReason` und einem Eintrag im Skip-Ledger. Wer nur `groups.length`
358
+ * zaehlt, sieht eine Gruppe und haelt die Sonde fuer gelaufen — und veroeffent-
359
+ * licht danach „no_match" und die Diagnose „undocumented" ueber ein Symbol,
360
+ * nach dem nie gesucht wurde. Deshalb zaehlen hier nur Gruppen, die
361
+ * TATSAECHLICH durchsucht wurden.
362
+ */
363
+ export function detectRetrievalFailure(
364
+ set: SearchResultSetV1,
365
+ ): FindabilityRetrievalFailureV1 | undefined {
366
+ const codes = new Set<string>(FINDABILITY_RETRIEVAL_FAILURE_WARNING_CODES);
367
+ const failed = set.warnings.find((warning) => codes.has(warning.code));
368
+ if (failed !== undefined) {
369
+ return { reason: reasonForCode(failed.code), detail: `${failed.code}: ${failed.message}` };
370
+ }
371
+ if (set.groups.length === 0) {
372
+ return {
373
+ reason: "search_profile_missing",
374
+ detail:
375
+ "the probe came back without a single profile group; no embedding profile answered, so nothing was actually searched",
376
+ };
377
+ }
378
+ const searched = set.groups.filter((group) => group.notSearchedReason === undefined);
379
+ if (searched.length === 0) {
380
+ const reasons = sortByCodepoints(
381
+ set.groups.map((group) => group.notSearchedReason as string),
382
+ ).join("; ");
383
+ return {
384
+ reason: "probe_not_executed",
385
+ detail: `not one of the ${set.groups.length} profile group(s) was actually searched, so the probe observed nothing: ${reasons}`,
386
+ };
387
+ }
388
+ return undefined;
389
+ }
390
+
391
+ function failureForError(error: unknown): FindabilityRetrievalFailureV1 {
392
+ const message = error instanceof Error ? error.message : String(error);
393
+ const code = isUntangleError(error) ? error.code : "SEARCH_FAILED";
394
+ return { reason: reasonForCode(code), detail: `${code}: ${message}` };
395
+ }
396
+
397
+ /**
398
+ * Uebersetzt einen Zustandsvermerk in einen Skip-Ledger-Eintrag (E4). Ein
399
+ * ausgefallener Abruf darf nicht nur im Datensatz stehen, sondern gehoert in
400
+ * dieselbe Liste wie jede andere Auslassung.
401
+ */
402
+ export function findabilitySkipEntry(note: FindabilityUnavailableV1): SkipLedgerEntryV1 {
403
+ return buildSkipLedgerEntry({
404
+ stage: "findability",
405
+ subjectKind: "symbol",
406
+ subjectId: note.symbolId,
407
+ path: note.filePath,
408
+ repoId: note.repoId,
409
+ refId: note.refId,
410
+ reason: note.reason === "symbol_not_indexed" ? "missing_index_entry" : "provider_unavailable",
411
+ detail: `findability not measured for "${note.symbolFqn}" (${note.reason}): ${note.detail}`,
412
+ recoverable: true,
413
+ });
414
+ }
415
+
416
+ /**
417
+ * Misst die Auffindbarkeit eines Symbols mit zwei Selbstsonden.
418
+ *
419
+ * Der Aufrufer stellt den Port; diese Funktion macht nichts anderes, als ihn
420
+ * zweimal zu fragen und das Ergebnis ehrlich zu verbuchen — insbesondere den
421
+ * Fall „nicht gefunden", der als Rang `null` MIT benanntem Grund im Datensatz
422
+ * steht und nicht als fehlender Wert verschwindet (E4).
423
+ *
424
+ * ZWEI ERGEBNISSE, NICHT EINS (vision.md §1d): lief die Suche, kommt eine
425
+ * `FindabilityV1`-MESSUNG zurueck. Lief sie nicht — Speicher nicht
426
+ * erreichbar, Profil fehlt, Symbol nicht indexiert —, kommt ein
427
+ * `FindabilityUnavailableV1`-ZUSTANDSVERMERK zurueck. Der traegt keinen Score
428
+ * und keine Diagnose, weil ein Werkzeugversagen kein Befund ueber den Code
429
+ * ist. Aufrufer MUESSEN die beiden Faelle unterscheiden; der Typ zwingt sie
430
+ * dazu.
431
+ */
432
+ export async function measureFindability(
433
+ port: SearchPort,
434
+ subject: FindabilitySubject,
435
+ clock: Clock,
436
+ options: FindabilityOptions,
437
+ callOptions?: PortCallOptions,
438
+ ): Promise<FindabilityOutcomeV1> {
439
+ const limit = options.probeLimit ?? 10;
440
+ const baseRequest = {
441
+ workspaceId: subject.workspaceId,
442
+ repositories: [subject.repoId],
443
+ refs: [subject.refId],
444
+ limit,
445
+ // Ausdruecklich gepinnt: eine veroeffentlichte Kennzahl darf nicht von
446
+ // einer ungepinnten Vorgabe abhaengen. Zoege der Default eines Tages nach
447
+ // `evidence`, bekaemen abgelehnte Sonden still einen Rang.
448
+ abstention: "strict" as const,
449
+ };
450
+
451
+ const note = (
452
+ failure: FindabilityRetrievalFailureV1,
453
+ probeKinds: readonly FindabilityProbeKind[],
454
+ ): FindabilityUnavailableV1 =>
455
+ makeFindabilityUnavailable({
456
+ workspaceId: subject.workspaceId,
457
+ repoId: subject.repoId,
458
+ refId: subject.refId,
459
+ symbolId: subject.symbolId,
460
+ symbolFqn: subject.symbolFqn,
461
+ filePath: subject.filePath,
462
+ reason: failure.reason,
463
+ detail: failure.detail,
464
+ probeKinds,
465
+ observedAt: clock.nowIso(),
466
+ generationId: options.generationId,
467
+ });
468
+
469
+ const bothProbes: readonly FindabilityProbeKind[] = ["self-name", "self-signature"];
470
+
471
+ if (options.isSymbolIndexed !== undefined && !options.isSymbolIndexed(subject.symbolId)) {
472
+ return note(
473
+ {
474
+ reason: "symbol_not_indexed",
475
+ detail: `symbol "${subject.symbolFqn}" is not in the index, so no probe could reach it`,
476
+ },
477
+ bothProbes,
478
+ );
479
+ }
480
+
481
+ let nameSet: SearchResultSetV1;
482
+ try {
483
+ nameSet = await port.search({ ...baseRequest, query: subject.symbolName }, callOptions);
484
+ } catch (error) {
485
+ return note(failureForError(error), bothProbes);
486
+ }
487
+ const nameFailure = detectRetrievalFailure(nameSet);
488
+ if (nameFailure !== undefined) return note(nameFailure, bothProbes);
489
+ const nameOutcome = evaluateProbe("self-name", subject.symbolName, subject, nameSet);
490
+
491
+ const signatureText = subject.signature ?? subject.symbolFqn;
492
+ let signatureSet: SearchResultSetV1;
493
+ try {
494
+ signatureSet = await port.search({ ...baseRequest, query: signatureText }, callOptions);
495
+ } catch (error) {
496
+ return note(failureForError(error), ["self-signature"]);
497
+ }
498
+ const signatureFailure = detectRetrievalFailure(signatureSet);
499
+ if (signatureFailure !== undefined) return note(signatureFailure, ["self-signature"]);
500
+ const signatureOutcome = evaluateProbe("self-signature", signatureText, subject, signatureSet);
501
+
502
+ const dispersion = computeDispersion(
503
+ [...nameOutcome.filePaths, ...signatureOutcome.filePaths],
504
+ nameOutcome.ownFileHitCount + signatureOutcome.ownFileHitCount,
505
+ );
506
+
507
+ const topHit = nameSet.groups.flatMap((group) => group.hits)[0];
508
+ const hops: FindabilityHopsV1 =
509
+ options.hops === undefined || topHit?.symbolId === undefined
510
+ ? {
511
+ hops: null,
512
+ hopsMissingReason:
513
+ options.hops === undefined
514
+ ? "no_symbol_graph_lookup_wired"
515
+ : "top_hit_carries_no_symbol_id",
516
+ path: [],
517
+ pathUsesUncertainEdges: false,
518
+ }
519
+ : (options.hops({ fromSymbolId: topHit.symbolId, toSymbolId: subject.symbolId }) ?? {
520
+ hops: null,
521
+ hopsMissingReason: "no_path_in_symbol_graph",
522
+ path: [],
523
+ pathUsesUncertainEdges: false,
524
+ });
525
+
526
+ const hopScore = hops.hops === null ? 0.5 : 1 / (1 + hops.hops);
527
+ const score =
528
+ 0.5 * rankScore(nameOutcome.probe.rank) +
529
+ 0.2 * rankScore(signatureOutcome.probe.rank) +
530
+ 0.2 * (1 - dispersion.dispersion) +
531
+ 0.1 * hopScore;
532
+
533
+ const signals = [
534
+ "self_name_probe",
535
+ "self_signature_probe",
536
+ "file_dispersion",
537
+ hops.hops === null ? "hops_unknown" : "hop_count",
538
+ ];
539
+
540
+ // Letzte Instanz vor der Diagnose: der Nachweis aus `packages/domain`. Er
541
+ // bleibt aus, sobald eine Sonde ein Werkzeugversagen meldet — dann gibt es
542
+ // schlicht nichts, woraus sich eine Diagnose bilden LIESSE.
543
+ const basis = openFindabilityDiagnosisBasis({
544
+ nameProbe: nameOutcome.probe,
545
+ signatureProbe: signatureOutcome.probe,
546
+ dispersion,
547
+ hops,
548
+ });
549
+
550
+ return {
551
+ schemaVersion: 1,
552
+ findabilityId: buildFindabilityId({
553
+ workspaceId: subject.workspaceId,
554
+ symbolId: subject.symbolId,
555
+ algorithm: FINDABILITY_ALGORITHM_ID,
556
+ rankerVersion: RANKER_VERSION,
557
+ }),
558
+ workspaceId: subject.workspaceId,
559
+ repoId: subject.repoId,
560
+ refId: subject.refId,
561
+ symbolId: subject.symbolId,
562
+ symbolFqn: subject.symbolFqn,
563
+ filePath: subject.filePath,
564
+ algorithm: FINDABILITY_ALGORITHM_ID,
565
+ rankerVersion: RANKER_VERSION,
566
+ score: Math.min(1, Math.max(0, score)),
567
+ isHeuristic: true,
568
+ nameProbe: nameOutcome.probe,
569
+ signatureProbe: signatureOutcome.probe,
570
+ dispersion,
571
+ dispersionFormula: DISPERSION_FORMULA,
572
+ hops,
573
+ // Die Gesamt-Confidence sinkt, wenn ein Bestandteil fehlt: eine Messung
574
+ // ohne Sprungzahl ist weniger belastbar als eine mit.
575
+ confidence: heuristicConfidence(hops.hops === null ? 0.5 : 0.7, signals),
576
+ diagnoses: basis === undefined ? [] : diagnose(subject, basis),
577
+ measuredAt: clock.nowIso(),
578
+ generationId: options.generationId,
579
+ };
580
+ }