@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,1367 @@
1
+ /**
2
+ * Task-basierte Search Journeys und Dynamic Product Complexity.
3
+ *
4
+ * Dieser Reducer ist I/O-agnostisch. Er speichert ausschliesslich ueber den
5
+ * Domain-Port, liest Zeit/Zufall ueber Ports und berechnet jede Kennzahl aus
6
+ * den persistierten Beobachtungen neu. Damit kann derselbe Ablauf lokal, im
7
+ * MCP-Server und in Tests identisch laufen.
8
+ */
9
+ import { DYNAMIC_SEARCH_COMPLEXITY_ALGORITHM_ID, INDEX_CONTEXT_INCOMPLETE_WARNING_CODE, MAX_SEARCH_JOURNEY_ATTEMPTS as DOMAIN_MAX_SEARCH_JOURNEY_ATTEMPTS, MAX_SEARCH_JOURNEY_EXECUTION_CONTEXT_LENGTH, MAX_SEARCH_JOURNEY_FEATURE_ID_LENGTH as DOMAIN_MAX_SEARCH_JOURNEY_FEATURE_ID_LENGTH, MAX_SEARCH_JOURNEY_LOCATOR_LENGTH, MAX_SEARCH_JOURNEY_NAVIGATION_EVENTS as DOMAIN_MAX_SEARCH_JOURNEY_NAVIGATION_EVENTS, MAX_SEARCH_JOURNEY_REASON_LENGTH, MAX_SEARCH_JOURNEY_TEXT_LENGTH, MAX_SEARCH_JOURNEY_TOOL_NAME_LENGTH, MINIMUM_PRODUCT_COMPLEXITY_SESSIONS as DOMAIN_MINIMUM_PRODUCT_COMPLEXITY_SESSIONS, RANKER_VERSION, SEARCH_JOURNEY_EXECUTION_CONTEXT_DIMENSIONS, SEARCH_JOURNEY_STATUSES, canonicalJson, compareCodepoints, hmacSha256Hex, isUntangleError, searchJourneyValidationIssue, sha256Hex, UntangleError, } from "@untangle-code/domain";
10
+ export const MINIMUM_PRODUCT_COMPLEXITY_SESSIONS = DOMAIN_MINIMUM_PRODUCT_COMPLEXITY_SESSIONS;
11
+ export const MAX_SEARCH_JOURNEY_ATTEMPTS = DOMAIN_MAX_SEARCH_JOURNEY_ATTEMPTS;
12
+ export const MAX_SEARCH_JOURNEY_FEATURE_ID_LENGTH = DOMAIN_MAX_SEARCH_JOURNEY_FEATURE_ID_LENGTH;
13
+ export const MAX_SEARCH_JOURNEY_NAVIGATION_EVENTS = DOMAIN_MAX_SEARCH_JOURNEY_NAVIGATION_EVENTS;
14
+ function assertNonEmpty(value, field) {
15
+ const normalized = value.trim();
16
+ if (normalized === "")
17
+ throw new Error(`${field} must not be empty`);
18
+ return normalized;
19
+ }
20
+ function assertBoundedNonEmpty(value, field, maximum) {
21
+ const normalized = assertNonEmpty(value, field);
22
+ if (normalized.length > maximum) {
23
+ throw new Error(`${field} must contain at most ${String(maximum)} UTF-16 code units`);
24
+ }
25
+ return normalized;
26
+ }
27
+ function assertBoundedText(value, field, maximum) {
28
+ if (value.length > maximum) {
29
+ throw new Error(`${field} must contain at most ${String(maximum)} UTF-16 code units`);
30
+ }
31
+ return value;
32
+ }
33
+ function normalizedTarget(target) {
34
+ const normalized = {
35
+ ...(target.repoId === undefined
36
+ ? {}
37
+ : {
38
+ repoId: assertBoundedNonEmpty(target.repoId, "search journey target repoId", MAX_SEARCH_JOURNEY_LOCATOR_LENGTH),
39
+ }),
40
+ ...(target.refId === undefined
41
+ ? {}
42
+ : {
43
+ refId: assertBoundedNonEmpty(target.refId, "search journey target refId", MAX_SEARCH_JOURNEY_LOCATOR_LENGTH),
44
+ }),
45
+ ...(target.filePath === undefined
46
+ ? {}
47
+ : {
48
+ filePath: assertBoundedNonEmpty(target.filePath, "search journey target filePath", MAX_SEARCH_JOURNEY_LOCATOR_LENGTH),
49
+ }),
50
+ ...(target.chunkId === undefined
51
+ ? {}
52
+ : {
53
+ chunkId: assertBoundedNonEmpty(target.chunkId, "search journey target chunkId", MAX_SEARCH_JOURNEY_LOCATOR_LENGTH),
54
+ }),
55
+ ...(target.symbolId === undefined
56
+ ? {}
57
+ : {
58
+ symbolId: assertBoundedNonEmpty(target.symbolId, "search journey target symbolId", MAX_SEARCH_JOURNEY_LOCATOR_LENGTH),
59
+ }),
60
+ ...(target.embeddingProfileId === undefined
61
+ ? {}
62
+ : {
63
+ embeddingProfileId: assertBoundedNonEmpty(target.embeddingProfileId, "search journey target embeddingProfileId", MAX_SEARCH_JOURNEY_LOCATOR_LENGTH),
64
+ }),
65
+ ...(target.indexGeneration === undefined
66
+ ? {}
67
+ : {
68
+ indexGeneration: assertBoundedNonEmpty(target.indexGeneration, "search journey target indexGeneration", MAX_SEARCH_JOURNEY_LOCATOR_LENGTH),
69
+ }),
70
+ };
71
+ const identity = [normalized.chunkId, normalized.symbolId, normalized.filePath].some((value) => typeof value === "string" && value.trim() !== "");
72
+ if (!identity) {
73
+ throw new Error("search journey target requires chunkId, symbolId or filePath");
74
+ }
75
+ return normalized;
76
+ }
77
+ function profilesOfProjection(projection) {
78
+ return projection.groups.map((group) => ({
79
+ embeddingProfileId: group.embeddingProfileId,
80
+ indexGenerations: group.indexGenerations === null
81
+ ? [...new Set(group.hits.map((hit) => hit.indexGeneration))].sort(compareCodepoints)
82
+ : [...new Set(group.indexGenerations)].sort(compareCodepoints),
83
+ ...(group.corpusRevisionFingerprint === null
84
+ ? {}
85
+ : { corpusRevisionFingerprint: group.corpusRevisionFingerprint }),
86
+ retrievalPerformed: group.notSearchedReason === undefined,
87
+ ...(group.notSearchedReason === undefined
88
+ ? {}
89
+ : { notSearchedReason: group.notSearchedReason }),
90
+ ...(group.retrieval === undefined ? {} : { retrieval: group.retrieval }),
91
+ candidates: group.hits.map((hit) => ({
92
+ repoId: hit.repoId,
93
+ refId: hit.refId,
94
+ filePath: hit.filePath,
95
+ chunkId: hit.chunkId,
96
+ ...(hit.symbolId === undefined ? {} : { symbolId: hit.symbolId }),
97
+ ...(hit.symbolFqn === undefined ? {} : { symbolFqn: hit.symbolFqn }),
98
+ embeddingProfileId: hit.embeddingProfileId,
99
+ indexGeneration: hit.indexGeneration,
100
+ rank: hit.rank,
101
+ score: hit.score,
102
+ ...(hit.complexity === null ? {} : { complexity: hit.complexity }),
103
+ incompletePayloadFields: [...hit.incompletePayloadFields],
104
+ })),
105
+ }));
106
+ }
107
+ function assertValidObservationProjection(projection) {
108
+ const probe = {
109
+ schemaVersion: 1,
110
+ revision: 0,
111
+ sessionId: "projection-validation",
112
+ workspaceId: "projection-validation",
113
+ status: "active",
114
+ recordingConsent: "explicit",
115
+ retainText: false,
116
+ goal: { fingerprint: "0".repeat(64) },
117
+ createdAt: "1970-01-01T00:00:00.000Z",
118
+ updatedAt: "1970-01-01T00:00:00.000Z",
119
+ attempts: [
120
+ {
121
+ attemptId: "projection-attempt",
122
+ sequence: 1,
123
+ measurementContextVersion: 2,
124
+ ...(projection.abstention === null ? {} : { abstention: projection.abstention }),
125
+ observationFingerprint: "0".repeat(64),
126
+ queryFingerprint: "0".repeat(64),
127
+ rankerVersion: projection.rankerVersion,
128
+ ...(projection.rankingContextFingerprint === null
129
+ ? {}
130
+ : { rankingContextFingerprint: projection.rankingContextFingerprint }),
131
+ resultLimit: projection.resultLimit,
132
+ candidateLimit: projection.candidateLimit,
133
+ observedAt: "1970-01-01T00:00:00.000Z",
134
+ status: "succeeded",
135
+ warningCodes: [...projection.warningCodes],
136
+ retrievalHealthy: projection.retrievalHealthy,
137
+ profiles: profilesOfProjection(projection),
138
+ },
139
+ ],
140
+ navigation: [],
141
+ };
142
+ assertValidSessionState(probe);
143
+ }
144
+ function projectComplexitySummary(complexity) {
145
+ return {
146
+ metricsScope: complexity.metricsScope,
147
+ metricsScopeId: assertBoundedNonEmpty(complexity.metricsScopeId, "search hit metricsScopeId", MAX_SEARCH_JOURNEY_LOCATOR_LENGTH),
148
+ metricsVersion: assertBoundedNonEmpty(complexity.metricsVersion, "search hit metricsVersion", MAX_SEARCH_JOURNEY_LOCATOR_LENGTH),
149
+ ...(complexity.cyclomaticClassic === undefined
150
+ ? {}
151
+ : { cyclomaticClassic: complexity.cyclomaticClassic }),
152
+ ...(complexity.cyclomaticExtended === undefined
153
+ ? {}
154
+ : { cyclomaticExtended: complexity.cyclomaticExtended }),
155
+ ...(complexity.cognitive === undefined ? {} : { cognitive: complexity.cognitive }),
156
+ ...(complexity.maxNesting === undefined ? {} : { maxNesting: complexity.maxNesting }),
157
+ ...(complexity.sloc === undefined ? {} : { sloc: complexity.sloc }),
158
+ ...(complexity.halsteadVolume === undefined
159
+ ? {}
160
+ : { halsteadVolume: complexity.halsteadVolume }),
161
+ ...(complexity.maintainabilityIndex === undefined
162
+ ? {}
163
+ : { maintainabilityIndex: complexity.maintainabilityIndex }),
164
+ ...(complexity.riskScore === undefined ? {} : { riskScore: complexity.riskScore }),
165
+ };
166
+ }
167
+ function projectRetrievalDiagnostics(retrieval) {
168
+ return {
169
+ branchCandidateCounts: {
170
+ dense: retrieval.branchCandidateCounts.dense,
171
+ sparse: retrieval.branchCandidateCounts.sparse,
172
+ symbol: retrieval.branchCandidateCounts.symbol,
173
+ },
174
+ uniqueRetrievedCandidateCount: retrieval.uniqueRetrievedCandidateCount,
175
+ eligibleCandidateCount: retrieval.eligibleCandidateCount,
176
+ rankedCandidateCount: retrieval.rankedCandidateCount,
177
+ deduplicatedCandidateCount: retrieval.deduplicatedCandidateCount,
178
+ qualifiedHitCount: retrieval.qualifiedHitCount,
179
+ returnedHitCount: retrieval.returnedHitCount,
180
+ uniqueEligibleFileCount: retrieval.uniqueEligibleFileCount,
181
+ uniqueEligibleSymbolCount: retrieval.uniqueEligibleSymbolCount,
182
+ eligibleFileDispersion: retrieval.eligibleFileDispersion,
183
+ topScoreGap: retrieval.topScoreGap,
184
+ candidateLimitReached: retrieval.candidateLimitReached,
185
+ };
186
+ }
187
+ function projectSearchObservation(result, queryText) {
188
+ const queryFingerprint = assertBoundedNonEmpty(result.queryFingerprint, "search result queryFingerprint", MAX_SEARCH_JOURNEY_LOCATOR_LENGTH);
189
+ const rankerVersion = assertBoundedNonEmpty(result.query.rankerVersion, "search result rankerVersion", MAX_SEARCH_JOURNEY_LOCATOR_LENGTH);
190
+ const rankingContextFingerprint = result.rankingContextFingerprint === undefined
191
+ ? null
192
+ : assertBoundedNonEmpty(result.rankingContextFingerprint, "search result rankingContextFingerprint", MAX_SEARCH_JOURNEY_LOCATOR_LENGTH);
193
+ const warningCodes = result.warnings
194
+ .map((warning) => assertBoundedNonEmpty(warning.code, "search result warning code", MAX_SEARCH_JOURNEY_LOCATOR_LENGTH))
195
+ .sort(compareCodepoints);
196
+ const groups = result.groups.map((group) => {
197
+ const embeddingProfileId = assertBoundedNonEmpty(group.embeddingProfileId, "search result embeddingProfileId", MAX_SEARCH_JOURNEY_LOCATOR_LENGTH);
198
+ const indexGenerations = group.indexGenerations === undefined
199
+ ? null
200
+ : group.indexGenerations.map((generation) => assertBoundedNonEmpty(generation, "search result indexGeneration", MAX_SEARCH_JOURNEY_LOCATOR_LENGTH));
201
+ const corpusRevisionFingerprint = group.corpusRevisionFingerprint === undefined
202
+ ? null
203
+ : assertBoundedNonEmpty(group.corpusRevisionFingerprint, "search result corpusRevisionFingerprint", MAX_SEARCH_JOURNEY_LOCATOR_LENGTH);
204
+ const notSearchedReason = group.notSearchedReason === undefined
205
+ ? undefined
206
+ : assertBoundedText(group.notSearchedReason, "search result notSearchedReason", MAX_SEARCH_JOURNEY_REASON_LENGTH);
207
+ if (group.hits.length > result.query.limit) {
208
+ throw new Error("search result group contains more hits than its result limit");
209
+ }
210
+ const hits = group.hits.map((hit) => {
211
+ const complexity = hit.complexity === undefined ? null : projectComplexitySummary(hit.complexity);
212
+ return {
213
+ repoId: assertBoundedNonEmpty(hit.repoId, "search hit repoId", MAX_SEARCH_JOURNEY_LOCATOR_LENGTH),
214
+ refId: assertBoundedNonEmpty(hit.refId, "search hit refId", MAX_SEARCH_JOURNEY_LOCATOR_LENGTH),
215
+ filePath: assertBoundedText(hit.filePath, "search hit filePath", MAX_SEARCH_JOURNEY_LOCATOR_LENGTH),
216
+ chunkId: assertBoundedNonEmpty(hit.chunkId, "search hit chunkId", MAX_SEARCH_JOURNEY_LOCATOR_LENGTH),
217
+ ...(hit.symbolId === undefined
218
+ ? {}
219
+ : {
220
+ symbolId: assertBoundedNonEmpty(hit.symbolId, "search hit symbolId", MAX_SEARCH_JOURNEY_LOCATOR_LENGTH),
221
+ }),
222
+ ...(hit.symbolFqn === undefined
223
+ ? {}
224
+ : {
225
+ symbolFqn: assertBoundedText(hit.symbolFqn, "search hit symbolFqn", MAX_SEARCH_JOURNEY_LOCATOR_LENGTH),
226
+ }),
227
+ embeddingProfileId: assertBoundedNonEmpty(hit.embeddingProfileId, "search hit embeddingProfileId", MAX_SEARCH_JOURNEY_LOCATOR_LENGTH),
228
+ indexGeneration: assertBoundedNonEmpty(hit.indexGeneration, "search hit indexGeneration", MAX_SEARCH_JOURNEY_LOCATOR_LENGTH),
229
+ rank: hit.rank,
230
+ score: hit.score.final,
231
+ complexity,
232
+ incompletePayloadFields: (hit.incompletePayloadFields ?? []).map((field) => assertBoundedNonEmpty(field, "search hit incomplete payload field", MAX_SEARCH_JOURNEY_LOCATOR_LENGTH)),
233
+ };
234
+ });
235
+ return {
236
+ embeddingProfileId,
237
+ indexGenerations,
238
+ corpusRevisionFingerprint,
239
+ ...(notSearchedReason === undefined ? {} : { notSearchedReason }),
240
+ ...(group.retrieval === undefined
241
+ ? {}
242
+ : { retrieval: projectRetrievalDiagnostics(group.retrieval) }),
243
+ hits,
244
+ };
245
+ });
246
+ const projection = {
247
+ queryFingerprint,
248
+ queryText,
249
+ rankerVersion,
250
+ rankingContextFingerprint,
251
+ resultLimit: result.query.limit,
252
+ candidateLimit: result.query.candidateLimit,
253
+ retrievalHealthy: retrievalHealthyOfResult(result),
254
+ groups,
255
+ warningCodes,
256
+ abstention: result.groups.find((group) => group.relevanceDecision !== undefined)?.relevanceDecision
257
+ ?.abstention ?? null,
258
+ };
259
+ assertValidObservationProjection(projection);
260
+ return projection;
261
+ }
262
+ function assertValidSessionState(session) {
263
+ const issue = searchJourneyValidationIssue(session);
264
+ if (issue !== null) {
265
+ throw new UntangleError("SEARCH_SESSION_INVALID_STATE", `search journey service produced an invalid state: ${issue}`, {
266
+ operation: "search_journey_service",
267
+ context: { sessionId: session.sessionId, validationIssue: issue },
268
+ });
269
+ }
270
+ }
271
+ function targetMatches(candidate, target) {
272
+ const fields = [
273
+ "repoId",
274
+ "refId",
275
+ "filePath",
276
+ "chunkId",
277
+ "symbolId",
278
+ "embeddingProfileId",
279
+ "indexGeneration",
280
+ ];
281
+ return fields.every((field) => target[field] === undefined || candidate[field] === target[field]);
282
+ }
283
+ function candidateKey(candidate) {
284
+ return canonicalJson([
285
+ candidate.embeddingProfileId,
286
+ candidate.indexGeneration,
287
+ candidate.repoId,
288
+ candidate.refId,
289
+ candidate.chunkId,
290
+ ]);
291
+ }
292
+ function candidateCodeEvidence(candidate) {
293
+ return canonicalJson({
294
+ complexity: candidate.complexity ?? null,
295
+ incompletePayloadFields: candidate.incompletePayloadFields ?? null,
296
+ });
297
+ }
298
+ function cloneSession(session) {
299
+ return structuredClone(session);
300
+ }
301
+ function journeyFingerprint(fingerprintKey, workspaceId, purpose, value) {
302
+ return hmacSha256Hex(fingerprintKey, canonicalJson({
303
+ schemaVersion: 1,
304
+ namespace: "untangle-search-journey",
305
+ workspaceId,
306
+ purpose,
307
+ value,
308
+ }));
309
+ }
310
+ function normalizeExecutionContext(value) {
311
+ if (value === undefined)
312
+ return undefined;
313
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
314
+ throw new Error("executionContext must be an object of explicit caller labels");
315
+ }
316
+ if (Object.keys(value).some((key) => !SEARCH_JOURNEY_EXECUTION_CONTEXT_DIMENSIONS.some((dimension) => dimension === key))) {
317
+ throw new Error("executionContext contains an unknown dimension");
318
+ }
319
+ const normalized = {};
320
+ for (const dimension of SEARCH_JOURNEY_EXECUTION_CONTEXT_DIMENSIONS) {
321
+ const label = value[dimension];
322
+ if (label === undefined && (dimension === "taskMix" || dimension === "priorContext"))
323
+ continue;
324
+ if (typeof label !== "string") {
325
+ throw new Error(`executionContext.${dimension} must be a non-empty string`);
326
+ }
327
+ normalized[dimension] = assertBoundedNonEmpty(label, `executionContext.${dimension}`, MAX_SEARCH_JOURNEY_EXECUTION_CONTEXT_LENGTH);
328
+ }
329
+ return normalized;
330
+ }
331
+ function persistExecutionContext(input, fingerprintKey, workspaceId, retainText) {
332
+ const fields = Object.fromEntries(SEARCH_JOURNEY_EXECUTION_CONTEXT_DIMENSIONS.flatMap((dimension) => {
333
+ const label = input[dimension];
334
+ return label === undefined
335
+ ? []
336
+ : [
337
+ [
338
+ dimension,
339
+ {
340
+ fingerprint: journeyFingerprint(fingerprintKey, workspaceId, "execution-context", {
341
+ dimension,
342
+ label,
343
+ }),
344
+ ...(retainText ? { text: label } : {}),
345
+ },
346
+ ],
347
+ ];
348
+ }));
349
+ return {
350
+ schemaVersion: 1,
351
+ source: "caller-declared",
352
+ ...fields,
353
+ };
354
+ }
355
+ function executionContextEvidence(session) {
356
+ const context = session.executionContext;
357
+ if (context === undefined) {
358
+ return {
359
+ status: "not-recorded",
360
+ uncontrolledDimensions: [...SEARCH_JOURNEY_EXECUTION_CONTEXT_DIMENSIONS],
361
+ };
362
+ }
363
+ // Caller fingerprints are grouping evidence only after the persisted shape and retention contract pass.
364
+ assertValidSessionState(session);
365
+ const dimensions = SEARCH_JOURNEY_EXECUTION_CONTEXT_DIMENSIONS.map((dimension) => [
366
+ dimension,
367
+ context[dimension]?.fingerprint ?? null,
368
+ ]);
369
+ return {
370
+ status: "caller-declared",
371
+ fingerprint: sha256Hex(canonicalJson({ schemaVersion: 1, dimensions })),
372
+ uncontrolledDimensions: SEARCH_JOURNEY_EXECUTION_CONTEXT_DIMENSIONS.filter((dimension) => context[dimension] === undefined),
373
+ };
374
+ }
375
+ function reportContextId(session, retrievalContextId) {
376
+ const execution = executionContextEvidence(session);
377
+ return execution.status === "not-recorded"
378
+ ? retrievalContextId
379
+ : sha256Hex(canonicalJson({
380
+ schemaVersion: 1,
381
+ retrievalContextId,
382
+ executionContext: execution.fingerprint,
383
+ }));
384
+ }
385
+ function journeyQueryFingerprint(fingerprintKey, workspaceId, query) {
386
+ return journeyFingerprint(fingerprintKey, workspaceId, "query", query.trim());
387
+ }
388
+ function assertClientStep(step) {
389
+ if (step !== undefined &&
390
+ (!Number.isSafeInteger(step) || step < 1 || step > MAX_SEARCH_JOURNEY_ATTEMPTS)) {
391
+ throw new Error(`search journey step must be an integer in [1, ${String(MAX_SEARCH_JOURNEY_ATTEMPTS)}]`);
392
+ }
393
+ }
394
+ function assertConsistentStepMode(attempts, step, sessionId) {
395
+ if (attempts.length === 0)
396
+ return;
397
+ const numbered = attempts.filter((attempt) => attempt.clientStep !== undefined).length;
398
+ if (numbered !== 0 && numbered !== attempts.length) {
399
+ throw new UntangleError("SEARCH_SESSION_INVALID_STATE", `search journey "${sessionId}" contains mixed numbered and unnumbered attempts`, { operation: "search_journey_record", context: { sessionId } });
400
+ }
401
+ const expectsStep = numbered === attempts.length;
402
+ if (expectsStep !== (step !== undefined)) {
403
+ throw new UntangleError("SEARCH_SESSION_INVALID_STATE", `search journey "${sessionId}" cannot mix numbered and unnumbered attempts`, { operation: "search_journey_record", context: { sessionId } });
404
+ }
405
+ }
406
+ function sortAttempts(attempts) {
407
+ attempts.sort((left, right) => {
408
+ return ((left.clientStep ?? left.sequence) - (right.clientStep ?? right.sequence) ||
409
+ compareCodepoints(left.observedAt, right.observedAt) ||
410
+ compareCodepoints(left.attemptId, right.attemptId));
411
+ });
412
+ attempts.forEach((attempt, index) => {
413
+ attempt.sequence = index + 1;
414
+ });
415
+ }
416
+ function retrievalHealthyOfResult(result) {
417
+ return (result.groups.length > 0 &&
418
+ result.groups.every((group) => group.notSearchedReason === undefined) &&
419
+ !result.warnings.some((warning) => warning.code === INDEX_CONTEXT_INCOMPLETE_WARNING_CODE ||
420
+ warning.code === "SEARCH_BRANCH_UNAVAILABLE" ||
421
+ warning.code === "INCOMPLETE_PAYLOAD") &&
422
+ !result.skipped.some((entry) => entry.subjectKind === "search_candidate" &&
423
+ (entry.reason === "provider_unavailable" || entry.reason === "missing_index_entry")));
424
+ }
425
+ function observationFingerprintOfProjection(fingerprintKey, workspaceId, projection, includeCodeEvidence = true) {
426
+ return journeyFingerprint(fingerprintKey, workspaceId, "successful-observation", {
427
+ queryFingerprint: projection.queryFingerprint,
428
+ queryText: projection.queryText,
429
+ rankerVersion: projection.rankerVersion,
430
+ rankingContextFingerprint: projection.rankingContextFingerprint,
431
+ resultLimit: projection.resultLimit,
432
+ candidateLimit: projection.candidateLimit,
433
+ retrievalHealthy: projection.retrievalHealthy,
434
+ groups: projection.groups.map((group) => ({
435
+ embeddingProfileId: group.embeddingProfileId,
436
+ indexGenerations: group.indexGenerations,
437
+ ...(includeCodeEvidence
438
+ ? { corpusRevisionFingerprint: group.corpusRevisionFingerprint }
439
+ : {}),
440
+ notSearchedReason: group.notSearchedReason,
441
+ retrieval: group.retrieval,
442
+ hits: group.hits.map((hit) => ({
443
+ repoId: hit.repoId,
444
+ refId: hit.refId,
445
+ filePath: hit.filePath,
446
+ chunkId: hit.chunkId,
447
+ symbolId: hit.symbolId,
448
+ symbolFqn: hit.symbolFqn,
449
+ indexGeneration: hit.indexGeneration,
450
+ rank: hit.rank,
451
+ score: hit.score,
452
+ ...(includeCodeEvidence
453
+ ? {
454
+ complexity: hit.complexity,
455
+ incompletePayloadFields: hit.incompletePayloadFields,
456
+ }
457
+ : {}),
458
+ })),
459
+ })),
460
+ warningCodes: projection.warningCodes,
461
+ });
462
+ }
463
+ function compareCandidates(left, right) {
464
+ return (left.rank - right.rank ||
465
+ compareCodepoints(left.embeddingProfileId, right.embeddingProfileId) ||
466
+ compareCodepoints(left.indexGeneration, right.indexGeneration) ||
467
+ compareCodepoints(left.repoId, right.repoId) ||
468
+ compareCodepoints(left.refId, right.refId) ||
469
+ compareCodepoints(left.filePath, right.filePath) ||
470
+ compareCodepoints(left.chunkId, right.chunkId));
471
+ }
472
+ function targetCodeComplexityEvidence(acceptedCandidates, accepted) {
473
+ if (acceptedCandidates.length === 0) {
474
+ return { status: "unavailable", reason: "target-not-returned" };
475
+ }
476
+ if (accepted === null ||
477
+ new Set(acceptedCandidates.map((candidate) => candidateKey(candidate))).size !== 1 ||
478
+ new Set(acceptedCandidates.map((candidate) => candidateCodeEvidence(candidate))).size !== 1) {
479
+ return { status: "unavailable", reason: "target-context-ambiguous" };
480
+ }
481
+ // New observations always carry this field, including the empty array.
482
+ // Its absence therefore means that an older persisted candidate cannot
483
+ // prove whether substituted payload values were involved.
484
+ if (accepted.incompletePayloadFields === undefined) {
485
+ return { status: "unavailable", reason: "legacy-observation" };
486
+ }
487
+ if (accepted.complexity === undefined) {
488
+ return { status: "unavailable", reason: "complexity-not-indexed" };
489
+ }
490
+ const incompletePayloadFields = [...new Set(accepted.incompletePayloadFields)].sort(compareCodepoints);
491
+ return {
492
+ status: "observed",
493
+ source: "indexed-search-hit",
494
+ candidate: {
495
+ repoId: accepted.repoId,
496
+ refId: accepted.refId,
497
+ filePath: accepted.filePath,
498
+ chunkId: accepted.chunkId,
499
+ ...(accepted.symbolId === undefined ? {} : { symbolId: accepted.symbolId }),
500
+ ...(accepted.symbolFqn === undefined ? {} : { symbolFqn: accepted.symbolFqn }),
501
+ embeddingProfileId: accepted.embeddingProfileId,
502
+ indexGeneration: accepted.indexGeneration,
503
+ },
504
+ complexity: structuredClone(accepted.complexity),
505
+ payloadComplete: incompletePayloadFields.length === 0,
506
+ incompletePayloadFields,
507
+ };
508
+ }
509
+ function attemptContextKey(attempt) {
510
+ const profiles = attempt.profiles
511
+ .map((profile) => ({
512
+ embeddingProfileId: profile.embeddingProfileId,
513
+ indexGenerations: profile.indexGenerations.length === 0
514
+ ? ["unknown"]
515
+ : [...profile.indexGenerations].sort(compareCodepoints),
516
+ corpusRevisionFingerprint: profile.corpusRevisionFingerprint ?? "unknown",
517
+ }))
518
+ .sort((left, right) => compareCodepoints(canonicalJson(left), canonicalJson(right)));
519
+ return canonicalJson({
520
+ rankerVersion: attempt.rankerVersion,
521
+ rankingContextFingerprint: attempt.rankingContextFingerprint ?? "unknown",
522
+ resultLimit: attempt.resultLimit ?? "unknown",
523
+ candidateLimit: attempt.candidateLimit ?? "unknown",
524
+ profiles,
525
+ });
526
+ }
527
+ function legacyContextId(attempts) {
528
+ const rankers = [...new Set(attempts.map((attempt) => attempt.rankerVersion))].sort(compareCodepoints);
529
+ const profiles = [
530
+ ...new Set(attempts.flatMap((attempt) => attempt.profiles.flatMap((profile) => profile.indexGenerations.length === 0
531
+ ? [`${profile.embeddingProfileId}\u001funknown`]
532
+ : profile.indexGenerations.map((generation) => `${profile.embeddingProfileId}\u001f${generation}`)))),
533
+ ].sort(compareCodepoints);
534
+ const rankingContexts = [
535
+ ...new Set(attempts.map((attempt) => attempt.rankingContextFingerprint ?? "unknown")),
536
+ ].sort(compareCodepoints);
537
+ const limits = [
538
+ ...new Set(attempts.map((attempt) => `${String(attempt.resultLimit ?? "unknown")}\u001f${String(attempt.candidateLimit ?? "unknown")}`)),
539
+ ].sort(compareCodepoints);
540
+ return sha256Hex(canonicalJson({ rankers, profiles, rankingContexts, limits }));
541
+ }
542
+ function contextId(attempts) {
543
+ const contextualAttempts = attempts.some((attempt) => attempt.status === "succeeded")
544
+ ? attempts.filter((attempt) => attempt.status === "succeeded")
545
+ : attempts;
546
+ if (contextualAttempts.every((attempt) => attempt.measurementContextVersion === undefined)) {
547
+ return legacyContextId(contextualAttempts);
548
+ }
549
+ const attemptContexts = [...new Set(contextualAttempts.map(attemptContextKey))].sort(compareCodepoints);
550
+ return sha256Hex(canonicalJson({ schemaVersion: 2, attemptContexts }));
551
+ }
552
+ function measurementContextComplete(attempts) {
553
+ const versioned = attempts.some((attempt) => attempt.measurementContextVersion !== undefined);
554
+ return (attempts.length > 0 &&
555
+ attempts.every((attempt) => (!versioned || attempt.measurementContextVersion === 2) &&
556
+ attempt.status === "succeeded" &&
557
+ attempt.retrievalHealthy &&
558
+ attempt.rankingContextFingerprint !== undefined &&
559
+ attempt.resultLimit !== undefined &&
560
+ attempt.candidateLimit !== undefined &&
561
+ attempt.profiles.length > 0 &&
562
+ attempt.profiles.every((profile) => (!versioned || profile.corpusRevisionFingerprint !== undefined) &&
563
+ profile.retrieval !== undefined &&
564
+ profile.indexGenerations.length > 0 &&
565
+ profile.indexGenerations.every((generation) => generation !== "unknown"))) &&
566
+ new Set(attempts.map(attemptContextKey)).size === 1);
567
+ }
568
+ function attemptHasRetrieval(attempt) {
569
+ return (attempt.status === "succeeded" && attempt.profiles.some((profile) => profile.retrievalPerformed));
570
+ }
571
+ function jaccardDistance(left, right) {
572
+ const union = new Set([...left, ...right]);
573
+ if (union.size === 0)
574
+ return 0;
575
+ let intersection = 0;
576
+ for (const value of left)
577
+ if (right.has(value))
578
+ intersection++;
579
+ return 1 - intersection / union.size;
580
+ }
581
+ /** Berechnet die erklaerbaren Komponenten einer abgeschlossenen Journey. */
582
+ export function assessSearchJourney(session, input) {
583
+ const target = normalizedTarget(input.target);
584
+ if (session.attempts.length === 0) {
585
+ throw new Error("search journey cannot be completed before at least one search attempt");
586
+ }
587
+ const numberedSteps = session.attempts.flatMap((attempt) => attempt.clientStep === undefined ? [] : [attempt.clientStep]);
588
+ if (numberedSteps.length > 0) {
589
+ const maximum = Math.max(...numberedSteps);
590
+ const present = new Set(numberedSteps);
591
+ const missing = Array.from({ length: maximum }, (_, index) => index + 1).filter((step) => !present.has(step));
592
+ if (missing.length > 0) {
593
+ throw new Error(`search journey cannot be completed with missing client step(s): ${missing.join(", ")}`);
594
+ }
595
+ }
596
+ const requestedAttempt = input.acceptedAttemptId === undefined
597
+ ? undefined
598
+ : session.attempts.find((attempt) => attempt.attemptId === input.acceptedAttemptId);
599
+ if (input.acceptedAttemptId !== undefined && requestedAttempt === undefined) {
600
+ throw new Error(`search journey has no attempt "${input.acceptedAttemptId}"`);
601
+ }
602
+ const attemptsWithTarget = session.attempts.filter((attempt) => attempt.profiles.some((profile) => profile.candidates.some((candidate) => targetMatches(candidate, target))));
603
+ if (requestedAttempt !== undefined &&
604
+ !attemptsWithTarget.some((attempt) => attempt.attemptId === requestedAttempt.attemptId)) {
605
+ throw new Error(`accepted attempt "${requestedAttempt.attemptId}" does not contain the confirmed target`);
606
+ }
607
+ // Der letzte passende Versuch entspricht ohne explizite Auswahl am ehesten
608
+ // dem Treffer, den der Agent gerade akzeptiert hat. Bei expliziter Auswahl
609
+ // zaehlen nur Versuche bis zu diesem Schritt: spaeter/out-of-order
610
+ // eingetroffene Beobachtungen duerfen die Suche bis zum Ziel nicht aufblasen.
611
+ const acceptedAttempt = requestedAttempt ?? attemptsWithTarget[attemptsWithTarget.length - 1] ?? null;
612
+ const attemptsToTarget = requestedAttempt === undefined
613
+ ? session.attempts
614
+ : session.attempts.filter((attempt) => attempt.sequence <= requestedAttempt.sequence);
615
+ const acceptedCandidates = acceptedAttempt === null
616
+ ? []
617
+ : acceptedAttempt.profiles
618
+ .flatMap((profile) => profile.candidates)
619
+ .filter((candidate) => targetMatches(candidate, target))
620
+ .sort(compareCandidates);
621
+ const acceptedContexts = new Set(acceptedCandidates.map((candidate) => `${candidate.embeddingProfileId}\u001f${candidate.indexGeneration}`));
622
+ const targetWasReturned = acceptedCandidates.length > 0;
623
+ const accepted = acceptedContexts.size <= 1 ? (acceptedCandidates[0] ?? null) : null;
624
+ const acceptedTargetCodeComplexity = targetCodeComplexityEvidence(acceptedCandidates, accepted);
625
+ const firstReturnedAttempt = attemptsWithTarget.find((attempt) => acceptedAttempt === null || attempt.sequence <= acceptedAttempt.sequence) ?? null;
626
+ const distinctQueries = new Set(attemptsToTarget.map((attempt) => attempt.queryFingerprint));
627
+ const allCandidates = attemptsToTarget.flatMap((attempt) => attempt.profiles.flatMap((profile) => profile.candidates));
628
+ const uniqueCandidates = new Map(allCandidates.map((candidate) => [candidateKey(candidate), candidate]));
629
+ const uniqueFiles = new Set(allCandidates.map((candidate) => `${candidate.repoId}\u001f${candidate.refId}\u001f${candidate.filePath}`));
630
+ const retrievals = attemptsToTarget.flatMap((attempt) => attempt.profiles.flatMap((profile) => profile.retrieval === undefined ? [] : [profile.retrieval]));
631
+ const eligibleCandidatesExamined = retrievals.reduce((sum, retrieval) => sum + retrieval.eligibleCandidateCount, 0);
632
+ const dispersionWeight = retrievals.reduce((sum, retrieval) => sum + retrieval.eligibleCandidateCount, 0);
633
+ const meanCandidateFileDispersion = dispersionWeight === 0
634
+ ? null
635
+ : retrievals.reduce((sum, retrieval) => sum + retrieval.eligibleFileDispersion * retrieval.eligibleCandidateCount, 0) / dispersionWeight;
636
+ const retrievedAttempts = attemptsToTarget.filter(attemptHasRetrieval);
637
+ const candidateLimitHitRate = retrievedAttempts.length === 0
638
+ ? 0
639
+ : retrievedAttempts.filter((attempt) => attempt.profiles.some((profile) => profile.retrieval?.candidateLimitReached === true)).length / retrievedAttempts.length;
640
+ const successfulCandidateSets = attemptsToTarget.flatMap((attempt) => !attemptHasRetrieval(attempt)
641
+ ? []
642
+ : [
643
+ new Set(attempt.profiles.flatMap((profile) => profile.candidates.map((candidate) => candidateKey(candidate)))),
644
+ ]);
645
+ const churnPairs = successfulCandidateSets
646
+ .slice(1)
647
+ .map((current, index) => jaccardDistance(successfulCandidateSets[index], current));
648
+ const meanCandidateChurn = churnPairs.length === 0 ? null : mean(churnPairs);
649
+ const successfulSearchesToTarget = attemptsToTarget.filter((attempt) => attempt.status === "succeeded").length;
650
+ const failedSearchesToTarget = attemptsToTarget.length - successfulSearchesToTarget;
651
+ const healthyRetrievalRate = attemptsToTarget.length === 0
652
+ ? 0
653
+ : attemptsToTarget.filter((attempt) => attempt.retrievalHealthy).length /
654
+ attemptsToTarget.length;
655
+ let acceptedTargetScoreMargin = null;
656
+ if (accepted !== null && acceptedAttempt !== null) {
657
+ const profile = acceptedAttempt.profiles.find((entry) => entry.embeddingProfileId === accepted.embeddingProfileId);
658
+ const sameProfile = (profile?.candidates ?? []).filter((candidate) => candidate.indexGeneration === accepted.indexGeneration);
659
+ const competitor = sameProfile
660
+ .filter((candidate) => candidateKey(candidate) !== candidateKey(accepted))
661
+ .sort((left, right) => right.score - left.score || compareCandidates(left, right))[0];
662
+ if (competitor !== undefined)
663
+ acceptedTargetScoreMargin = accepted.score - competitor.score;
664
+ }
665
+ const caveats = [
666
+ "This is a heuristic observation of one task, not cognitive complexity.",
667
+ "A confirmed target labels one accepted result; it does not label every candidate, so precision is not claimed.",
668
+ "Observed target code complexity is a separate static evidence axis; it is not combined with retrieval effort into a score or a causal claim.",
669
+ session.executionContext === undefined
670
+ ? "Agent model, harness, prior context and search strategy are not captured for this session; compare observations only under externally controlled caller conditions."
671
+ : "Execution context was explicitly caller-declared at journey start; report cohorts segment those fingerprints without independently verifying the declarations or asserting causation.",
672
+ ];
673
+ if (!targetWasReturned) {
674
+ caveats.push("The confirmed target was not present in a returned result; rank, reciprocal rank and score margin are unavailable.");
675
+ }
676
+ if (session.navigation.length === 0) {
677
+ caveats.push("No navigation events were reported; observedToolAndFileHops contains search calls only.");
678
+ }
679
+ if (retrievals.length === 0) {
680
+ caveats.push("No retrieval diagnostics were available; eligible-candidate and dispersion indicators are incomplete.");
681
+ }
682
+ if (attemptsToTarget.some((attempt) => attempt.status === "failed")) {
683
+ caveats.push("At least one search attempt failed; failures remain counted as search effort and their effective retrieval limits are unavailable.");
684
+ }
685
+ if (acceptedContexts.size > 1) {
686
+ caveats.push("The confirmed target appeared in multiple embedding-profile or generation contexts; rank, reciprocal rank and score margin remain unavailable until the target locator selects one context.");
687
+ }
688
+ if (acceptedTargetCodeComplexity.status === "unavailable") {
689
+ caveats.push(`Static code complexity for the confirmed target is unavailable (${acceptedTargetCodeComplexity.reason}); no value was inferred.`);
690
+ }
691
+ else if (!acceptedTargetCodeComplexity.payloadComplete) {
692
+ caveats.push("The confirmed target carries static code metrics, but its search payload used documented substitutes; inspect incompletePayloadFields before comparison.");
693
+ }
694
+ if (requestedAttempt !== undefined && requestedAttempt.sequence < session.attempts.length) {
695
+ caveats.push("Search metrics stop at the explicitly accepted attempt; independently ordered navigation events still include every event reported before completion.");
696
+ }
697
+ if (requestedAttempt === undefined && acceptedAttempt !== null) {
698
+ caveats.push("No acceptedAttemptId was supplied; every search through completion remains counted, while target rank and score margin use the last matching attempt.");
699
+ }
700
+ const contextComplete = measurementContextComplete(attemptsToTarget);
701
+ if (!contextComplete) {
702
+ caveats.push("The effective ranking policy, limits, embedding profiles, index generations or retrieval diagnostics are incomplete or changed across counted attempts; this observation cannot contribute sufficient product-level cohort evidence.");
703
+ }
704
+ return {
705
+ schemaVersion: 1,
706
+ algorithm: DYNAMIC_SEARCH_COMPLEXITY_ALGORITHM_ID,
707
+ isHeuristic: true,
708
+ searchesToTarget: attemptsToTarget.length,
709
+ successfulSearchesToTarget,
710
+ failedSearchesToTarget,
711
+ distinctQueryCount: distinctQueries.size,
712
+ queryReformulations: Math.max(0, distinctQueries.size - 1),
713
+ acceptedTargetRank: accepted?.rank ?? null,
714
+ reciprocalRank: accepted === null ? null : 1 / accepted.rank,
715
+ targetWasReturned,
716
+ targetFirstReturnedAtSearch: firstReturnedAttempt?.sequence ?? null,
717
+ targetAcceptedAtSearch: acceptedAttempt?.sequence ?? null,
718
+ selectionLagSearches: acceptedAttempt === null || firstReturnedAttempt === null
719
+ ? null
720
+ : acceptedAttempt.sequence - firstReturnedAttempt.sequence,
721
+ directHit: attemptsToTarget.length === 1 && acceptedAttempt?.sequence === 1 && accepted?.rank === 1,
722
+ eligibleCandidatesExamined,
723
+ uniqueCandidatesExposed: uniqueCandidates.size,
724
+ uniqueFilesExposed: uniqueFiles.size,
725
+ navigationEventsToTarget: session.navigation.length,
726
+ observedToolAndFileHops: attemptsToTarget.length + session.navigation.length,
727
+ meanCandidateFileDispersion,
728
+ meanCandidateChurn,
729
+ candidateLimitHitRate,
730
+ healthyRetrievalRate,
731
+ acceptedTargetScoreMargin,
732
+ acceptedTargetCodeComplexity,
733
+ measurementContextId: contextId(attemptsToTarget),
734
+ measurementContextComplete: contextComplete,
735
+ caveats,
736
+ };
737
+ }
738
+ function quantile(values, fraction) {
739
+ if (values.length === 0)
740
+ throw new Error("quantile needs at least one value");
741
+ const ordered = [...values].sort((left, right) => left - right);
742
+ const index = Math.max(0, Math.ceil(fraction * ordered.length) - 1);
743
+ return ordered[index];
744
+ }
745
+ function median(values) {
746
+ const ordered = [...values].sort((left, right) => left - right);
747
+ const middle = Math.floor(ordered.length / 2);
748
+ if (ordered.length % 2 === 1)
749
+ return ordered[middle];
750
+ return (ordered[middle - 1] + ordered[middle]) / 2;
751
+ }
752
+ function mean(values) {
753
+ return values.reduce((sum, value) => sum + value, 0) / values.length;
754
+ }
755
+ function buildCohort(measurementContextId, assessments, executionContext) {
756
+ const ranks = assessments.flatMap((assessment) => assessment.acceptedTargetRank === null ? [] : [assessment.acceptedTargetRank]);
757
+ const reciprocalRanks = assessments.flatMap((assessment) => assessment.reciprocalRank === null ? [] : [assessment.reciprocalRank]);
758
+ const dispersions = assessments.flatMap((assessment) => assessment.meanCandidateFileDispersion === null ? [] : [assessment.meanCandidateFileDispersion]);
759
+ const churn = assessments.flatMap((assessment) => assessment.meanCandidateChurn === null ? [] : [assessment.meanCandidateChurn]);
760
+ const contextComplete = assessments.every((assessment) => assessment.measurementContextComplete);
761
+ const sufficientEvidence = contextComplete && assessments.length >= MINIMUM_PRODUCT_COMPLEXITY_SESSIONS;
762
+ return {
763
+ measurementContextId,
764
+ completedSessionCount: assessments.length,
765
+ measurementContextComplete: contextComplete,
766
+ sufficientEvidence,
767
+ minimumCompletedSessions: MINIMUM_PRODUCT_COMPLEXITY_SESSIONS,
768
+ executionContext,
769
+ callerConditionsControlled: executionContext.uncontrolledDimensions.length === 0,
770
+ medianSearchesToTarget: median(assessments.map((assessment) => assessment.searchesToTarget)),
771
+ p90SearchesToTarget: quantile(assessments.map((assessment) => assessment.searchesToTarget), 0.9),
772
+ medianQueryReformulations: median(assessments.map((assessment) => assessment.queryReformulations)),
773
+ medianAcceptedTargetRank: ranks.length === 0 ? null : median(ranks),
774
+ meanReciprocalRank: reciprocalRanks.length === 0 ? null : mean(reciprocalRanks),
775
+ targetReturnedRate: assessments.filter((assessment) => assessment.targetWasReturned).length / assessments.length,
776
+ directHitRate: assessments.filter((assessment) => assessment.directHit).length / assessments.length,
777
+ healthyRetrievalRate: mean(assessments.map((assessment) => assessment.healthyRetrievalRate)),
778
+ medianEligibleCandidatesExamined: median(assessments.map((assessment) => assessment.eligibleCandidatesExamined)),
779
+ medianUniqueFilesExposed: median(assessments.map((assessment) => assessment.uniqueFilesExposed)),
780
+ meanCandidateFileDispersion: dispersions.length === 0 ? null : mean(dispersions),
781
+ meanCandidateChurn: churn.length === 0 ? null : mean(churn),
782
+ candidateLimitHitRate: mean(assessments.map((assessment) => assessment.candidateLimitHitRate)),
783
+ caveats: [
784
+ ...(!contextComplete
785
+ ? [
786
+ "At least one observation has an incomplete effective retrieval context; this cohort cannot be treated as sufficient product-level evidence.",
787
+ ]
788
+ : sufficientEvidence
789
+ ? [
790
+ "This is a heuristic cohort signal segmented by ranker, embedding profile and index generation; it is neither a temporal trend nor a static code metric.",
791
+ ]
792
+ : [
793
+ `Only ${String(assessments.length)} completed session(s) are available; at least ${String(MINIMUM_PRODUCT_COMPLEXITY_SESSIONS)} are required before treating this cohort as sufficient product-level evidence.`,
794
+ ]),
795
+ executionContext.status === "not-recorded"
796
+ ? "Caller execution conditions were not recorded; retrieval sufficiency does not establish comparable agent behavior."
797
+ : "Execution conditions are caller-declared, not independently verified; equal labels do not establish a causal product effect.",
798
+ ...(executionContext.uncontrolledDimensions.length === 0
799
+ ? []
800
+ : [
801
+ `Uncontrolled execution dimensions: ${executionContext.uncontrolledDimensions.join(", ")}.`,
802
+ ]),
803
+ ],
804
+ };
805
+ }
806
+ function reportAssessment(session) {
807
+ if (session.outcome?.kind !== "completed") {
808
+ throw new Error("report assessment requires a completed search journey");
809
+ }
810
+ const assessment = session.outcome.assessment;
811
+ const contextualAssessment = {
812
+ ...assessment,
813
+ measurementContextId: reportContextId(session, assessment.measurementContextId),
814
+ };
815
+ if (session.attempts.every((attempt) => attempt.measurementContextVersion === 2)) {
816
+ return contextualAssessment;
817
+ }
818
+ return {
819
+ ...contextualAssessment,
820
+ measurementContextComplete: false,
821
+ caveats: [
822
+ ...assessment.caveats,
823
+ "This legacy observation predates immutable corpus-revision fingerprints; it remains visible but cannot contribute sufficient product-level evidence.",
824
+ ],
825
+ };
826
+ }
827
+ function buildFeatureMapping(featureFingerprint, measurementContextId, observations) {
828
+ const targetGroups = new Map();
829
+ for (const observation of observations) {
830
+ const key = canonicalJson(observation.target);
831
+ const group = targetGroups.get(key) ?? [];
832
+ group.push(observation);
833
+ targetGroups.set(key, group);
834
+ }
835
+ const targets = [...targetGroups.entries()]
836
+ .sort(([left], [right]) => compareCodepoints(left, right))
837
+ .map(([, targetObservations]) => {
838
+ const first = targetObservations[0];
839
+ if (first === undefined)
840
+ throw new Error("feature target group must not be empty");
841
+ const variants = new Map();
842
+ for (const observation of targetObservations) {
843
+ const evidence = observation.assessment.acceptedTargetCodeComplexity ?? {
844
+ status: "unavailable",
845
+ reason: "legacy-observation",
846
+ };
847
+ const key = canonicalJson(evidence);
848
+ const existing = variants.get(key);
849
+ if (existing === undefined) {
850
+ variants.set(key, { evidence: structuredClone(evidence), completedSessionCount: 1 });
851
+ }
852
+ else {
853
+ existing.completedSessionCount += 1;
854
+ }
855
+ }
856
+ return {
857
+ target: structuredClone(first.target),
858
+ completedSessionCount: targetObservations.length,
859
+ complexityVariants: [...variants.entries()]
860
+ .sort(([left], [right]) => compareCodepoints(left, right))
861
+ .map(([, variant]) => variant),
862
+ };
863
+ });
864
+ const evidence = observations.map((observation) => observation.assessment.acceptedTargetCodeComplexity ?? {
865
+ status: "unavailable",
866
+ reason: "legacy-observation",
867
+ });
868
+ const observedComplexitySessionCount = evidence.filter((entry) => entry.status === "observed").length;
869
+ const incompletePayloadCount = evidence.filter((entry) => entry.status === "observed" && !entry.payloadComplete).length;
870
+ const unavailableComplexitySessionCount = evidence.length - observedComplexitySessionCount;
871
+ const distinctGoalCount = new Set(observations.map((entry) => entry.goalFingerprint)).size;
872
+ const caveats = [
873
+ "Search effort and static target-code complexity are separate evidence axes; no combined score or causal relationship is asserted.",
874
+ "Different target-locator precision can represent the same physical code as multiple confirmed targets; compare locators before interpreting target counts.",
875
+ ];
876
+ if (distinctGoalCount > 1) {
877
+ caveats.push("This feature mapping contains multiple task goals; search medians describe the observed feature workload, not repeated identical prompts.");
878
+ }
879
+ if (unavailableComplexitySessionCount > 0) {
880
+ caveats.push(`${String(unavailableComplexitySessionCount)} completed session(s) have no attributable static target-code complexity; missing values remain explicit and are never treated as zero.`);
881
+ }
882
+ if (incompletePayloadCount > 0) {
883
+ caveats.push(`${String(incompletePayloadCount)} observed static complexity value(s) came from search hits with incomplete payload provenance.`);
884
+ }
885
+ return {
886
+ featureFingerprint,
887
+ measurementContextId,
888
+ distinctGoalCount,
889
+ search: buildCohort(measurementContextId, observations.map((observation) => observation.assessment), observations[0]?.executionContext ?? {
890
+ status: "not-recorded",
891
+ uncontrolledDimensions: [...SEARCH_JOURNEY_EXECUTION_CONTEXT_DIMENSIONS],
892
+ }),
893
+ code: {
894
+ confirmedTargetCount: targets.length,
895
+ observedComplexitySessionCount,
896
+ unavailableComplexitySessionCount,
897
+ targets,
898
+ },
899
+ caveats,
900
+ };
901
+ }
902
+ function buildOutcomeCohorts(sessions) {
903
+ const grouped = new Map();
904
+ for (const session of sessions) {
905
+ const retrievalContextId = contextId(session.attempts);
906
+ const measurementContextId = reportContextId(session, retrievalContextId);
907
+ const key = canonicalJson([measurementContextId, session.feature?.fingerprint ?? null]);
908
+ const group = grouped.get(key) ?? {
909
+ measurementContextId,
910
+ retrievalContextId,
911
+ executionContext: executionContextEvidence(session),
912
+ ...(session.feature === undefined ? {} : { featureFingerprint: session.feature.fingerprint }),
913
+ sessions: [],
914
+ };
915
+ group.sessions.push(session);
916
+ grouped.set(key, group);
917
+ }
918
+ return [...grouped.entries()]
919
+ .sort(([left], [right]) => compareCodepoints(left, right))
920
+ .map(([, group]) => {
921
+ const completed = group.sessions.filter((session) => session.status === "completed").length;
922
+ const abandoned = group.sessions.filter((session) => session.status === "abandoned").length;
923
+ const terminal = completed + abandoned;
924
+ const reasons = new Map();
925
+ for (const session of group.sessions) {
926
+ if (session.outcome?.kind !== "abandoned")
927
+ continue;
928
+ const reasonCode = session.outcome.reasonCode ?? "not-recorded";
929
+ reasons.set(reasonCode, (reasons.get(reasonCode) ?? 0) + 1);
930
+ }
931
+ const { sessions: observations, ...context } = group;
932
+ return {
933
+ ...context,
934
+ distinctGoalCount: new Set(observations.map((session) => session.goal.fingerprint)).size,
935
+ sessionCounts: {
936
+ total: observations.length,
937
+ active: observations.length - terminal,
938
+ completed,
939
+ abandoned,
940
+ terminal,
941
+ },
942
+ terminalCompletionRate: terminal === 0 ? null : completed / terminal,
943
+ retrievalContextComplete: observations.every((session) => session.attempts.every((attempt) => attempt.measurementContextVersion === 2) &&
944
+ measurementContextComplete(session.attempts)),
945
+ statusEffort: SEARCH_JOURNEY_STATUSES.map((status) => {
946
+ const matching = observations.filter((session) => session.status === status);
947
+ const searches = matching.map((session) => session.attempts.length);
948
+ const totalSearches = searches.reduce((sum, count) => sum + count, 0);
949
+ const totalNavigationEvents = matching.reduce((sum, session) => sum + session.navigation.length, 0);
950
+ return {
951
+ status,
952
+ sessionCount: matching.length,
953
+ sessionsWithSearches: searches.filter((count) => count > 0).length,
954
+ totalSearches,
955
+ failedSearches: matching.reduce((sum, session) => sum + session.attempts.filter((attempt) => attempt.status === "failed").length, 0),
956
+ totalNavigationEvents,
957
+ totalObservedHops: totalSearches + totalNavigationEvents,
958
+ medianSearches: searches.length === 0 ? null : median(searches),
959
+ };
960
+ }),
961
+ abandonmentReasons: [...reasons.entries()]
962
+ .sort(([left], [right]) => compareCodepoints(left, right))
963
+ .map(([reasonCode, count]) => ({ reasonCode, count })),
964
+ caveats: [
965
+ "Outcome cohorts retain every observed search and navigation event, including failed searches, zero-search sessions and searches after an explicitly accepted earlier attempt; these are not completed-to-target metrics.",
966
+ "The completion-rate denominator is completed plus abandoned sessions in this cohort; active sessions are reported separately, and the rate describes recorded tasks rather than benchmark accuracy.",
967
+ "Unknown or incompatible retrieval/execution contexts remain separate, including retrieval-unavailable tasks with no known profile; inspect all cohorts before interpreting successful-task effort.",
968
+ "Task mix, sampling and declaration accuracy are not independently verified or normalized; there is no temporal trend, causal quality claim or ROI estimate.",
969
+ ],
970
+ };
971
+ });
972
+ }
973
+ /** Aggregiert ausschliesslich kompatible Messkontexte in getrennten Kohorten. */
974
+ export function buildDynamicProductComplexityReport(workspaceId, sessions, generatedAt) {
975
+ const scoped = sessions.filter((session) => session.workspaceId === workspaceId);
976
+ const completed = scoped.filter((session) => session.status === "completed" && session.outcome?.kind === "completed");
977
+ const grouped = new Map();
978
+ for (const session of completed) {
979
+ if (session.outcome?.kind !== "completed")
980
+ continue;
981
+ const assessment = reportAssessment(session);
982
+ const cohort = grouped.get(assessment.measurementContextId) ?? {
983
+ assessments: [],
984
+ executionContext: executionContextEvidence(session),
985
+ };
986
+ cohort.assessments.push(assessment);
987
+ grouped.set(assessment.measurementContextId, cohort);
988
+ }
989
+ const cohorts = [...grouped.entries()]
990
+ .sort(([left], [right]) => compareCodepoints(left, right))
991
+ .map(([measurementContextId, cohort]) => buildCohort(measurementContextId, cohort.assessments, cohort.executionContext));
992
+ const featureGroups = new Map();
993
+ for (const session of completed) {
994
+ if (session.feature === undefined || session.outcome?.kind !== "completed")
995
+ continue;
996
+ const { target } = session.outcome;
997
+ const assessment = reportAssessment(session);
998
+ const key = canonicalJson([session.feature.fingerprint, assessment.measurementContextId]);
999
+ const group = featureGroups.get(key) ?? {
1000
+ featureFingerprint: session.feature.fingerprint,
1001
+ measurementContextId: assessment.measurementContextId,
1002
+ observations: [],
1003
+ };
1004
+ group.observations.push({
1005
+ goalFingerprint: session.goal.fingerprint,
1006
+ target,
1007
+ assessment,
1008
+ executionContext: executionContextEvidence(session),
1009
+ });
1010
+ featureGroups.set(key, group);
1011
+ }
1012
+ const featureMappings = [...featureGroups.values()]
1013
+ .sort((left, right) => compareCodepoints(left.featureFingerprint, right.featureFingerprint) ||
1014
+ compareCodepoints(left.measurementContextId, right.measurementContextId))
1015
+ .map((group) => buildFeatureMapping(group.featureFingerprint, group.measurementContextId, group.observations));
1016
+ return {
1017
+ schemaVersion: 1,
1018
+ algorithm: DYNAMIC_SEARCH_COMPLEXITY_ALGORITHM_ID,
1019
+ isHeuristic: true,
1020
+ workspaceId,
1021
+ generatedAt,
1022
+ sessionCounts: {
1023
+ total: scoped.length,
1024
+ active: scoped.filter((session) => session.status === "active").length,
1025
+ completed: completed.length,
1026
+ featureTaggedCompleted: completed.filter((session) => session.feature !== undefined).length,
1027
+ abandoned: scoped.filter((session) => session.status === "abandoned").length,
1028
+ },
1029
+ cohorts,
1030
+ featureMappings,
1031
+ outcomeCohorts: buildOutcomeCohorts(scoped),
1032
+ caveats: [
1033
+ "Completed-to-target cohort metrics exclude active and abandoned sessions; separate outcome cohorts expose all statuses, denominators and observed effort.",
1034
+ "Incompatible ranker/profile/generation contexts are never merged.",
1035
+ "Execution context is optional and caller-declared; absent context remains visibly uncontrolled and is never inferred from historical sessions. Equal declared conditions do not independently verify comparability or causation.",
1036
+ "Task mix and sampling policy are not normalized in v1; each cohort describes the workload that was actually observed.",
1037
+ "The report describes observed retrieval effort; it does not replace cognitive complexity and does not claim unlabeled precision.",
1038
+ "Feature mappings include only journeys started with an explicit stable feature ID and keep search effort separate from static target-code complexity.",
1039
+ ],
1040
+ };
1041
+ }
1042
+ export class SearchJourneyService {
1043
+ deps;
1044
+ pendingMutations = new Map();
1045
+ fingerprintKey;
1046
+ constructor(deps) {
1047
+ this.deps = deps;
1048
+ assertBoundedNonEmpty(deps.workspaceId, "workspaceId", MAX_SEARCH_JOURNEY_LOCATOR_LENGTH);
1049
+ if (deps.fingerprintKey !== undefined) {
1050
+ this.fingerprintKey = this.validatedFingerprintKey(deps.fingerprintKey);
1051
+ }
1052
+ }
1053
+ async start(input, options) {
1054
+ options?.signal?.throwIfAborted();
1055
+ const goal = assertBoundedNonEmpty(input.goal, "goal", MAX_SEARCH_JOURNEY_TEXT_LENGTH);
1056
+ const featureId = input.featureId === undefined ? undefined : assertNonEmpty(input.featureId, "featureId");
1057
+ if (featureId !== undefined && featureId.length > MAX_SEARCH_JOURNEY_FEATURE_ID_LENGTH) {
1058
+ throw new Error(`featureId must contain at most ${String(MAX_SEARCH_JOURNEY_FEATURE_ID_LENGTH)} UTF-16 code units`);
1059
+ }
1060
+ const executionContext = normalizeExecutionContext(input.executionContext);
1061
+ const fingerprintKey = await this.getFingerprintKey(options);
1062
+ const now = this.deps.clock.nowIso();
1063
+ const session = {
1064
+ schemaVersion: 1,
1065
+ revision: 0,
1066
+ sessionId: this.deps.random.uuid(),
1067
+ workspaceId: this.deps.workspaceId,
1068
+ status: "active",
1069
+ recordingConsent: "explicit",
1070
+ retainText: input.retainText ?? false,
1071
+ goal: {
1072
+ fingerprint: journeyFingerprint(fingerprintKey, this.deps.workspaceId, "goal", goal),
1073
+ ...(input.retainText === true ? { text: goal } : {}),
1074
+ },
1075
+ ...(featureId === undefined
1076
+ ? {}
1077
+ : {
1078
+ feature: {
1079
+ fingerprint: journeyFingerprint(fingerprintKey, this.deps.workspaceId, "feature", featureId),
1080
+ ...(input.retainText === true ? { text: featureId } : {}),
1081
+ },
1082
+ }),
1083
+ ...(executionContext === undefined
1084
+ ? {}
1085
+ : {
1086
+ executionContext: persistExecutionContext(executionContext, fingerprintKey, this.deps.workspaceId, input.retainText === true),
1087
+ }),
1088
+ createdAt: now,
1089
+ updatedAt: now,
1090
+ attempts: [],
1091
+ navigation: [],
1092
+ };
1093
+ assertValidSessionState(session);
1094
+ await this.deps.store.put(session, {
1095
+ ...options,
1096
+ expectedRevision: null,
1097
+ });
1098
+ return cloneSession(session);
1099
+ }
1100
+ async get(sessionId, options) {
1101
+ const id = assertNonEmpty(sessionId, "sessionId");
1102
+ const session = await this.deps.store.get(id, options);
1103
+ if (session === null || session.workspaceId !== this.deps.workspaceId) {
1104
+ throw new UntangleError("SEARCH_SESSION_NOT_FOUND", `search journey "${id}" was not found in this workspace`, { operation: "search_journey_get", context: { sessionId: id } });
1105
+ }
1106
+ return cloneSession(session);
1107
+ }
1108
+ async recordSearch(sessionId, result, link = {}, options) {
1109
+ assertClientStep(link.step);
1110
+ const queryText = assertBoundedNonEmpty(result.query.text, "query", MAX_SEARCH_JOURNEY_TEXT_LENGTH);
1111
+ if (result.query.workspaceId !== this.deps.workspaceId) {
1112
+ throw new Error(`search result belongs to workspace "${result.query.workspaceId}", not "${this.deps.workspaceId}"`);
1113
+ }
1114
+ const profileIds = result.groups.map((group) => group.embeddingProfileId);
1115
+ if (new Set(profileIds).size !== profileIds.length) {
1116
+ throw new UntangleError("SEARCH_SESSION_INVALID_STATE", "search result contains duplicate embedding-profile groups", { operation: "search_journey_record", context: { sessionId } });
1117
+ }
1118
+ const projection = projectSearchObservation(result, queryText);
1119
+ const fingerprintKey = await this.getFingerprintKey(options);
1120
+ const observationFingerprint = observationFingerprintOfProjection(fingerprintKey, this.deps.workspaceId, projection);
1121
+ const legacyObservationFingerprint = observationFingerprintOfProjection(fingerprintKey, this.deps.workspaceId, projection, false);
1122
+ return this.mutate(sessionId, (session) => {
1123
+ assertConsistentStepMode(session.attempts, link.step, sessionId);
1124
+ const existing = session.attempts.find((attempt) => link.step !== undefined && attempt.clientStep === link.step);
1125
+ if (existing !== undefined) {
1126
+ const legacyReplay = existing.profiles.every((profile) => profile.candidates.every((candidate) => candidate.complexity === undefined &&
1127
+ candidate.incompletePayloadFields === undefined)) && existing.observationFingerprint === legacyObservationFingerprint;
1128
+ if (existing.observationFingerprint !== observationFingerprint && !legacyReplay) {
1129
+ throw new UntangleError("SEARCH_SESSION_CONFLICT", `search journey step ${String(link.step)} already records a different observation`, {
1130
+ operation: "search_journey_record",
1131
+ context: { sessionId, step: link.step },
1132
+ });
1133
+ }
1134
+ return { session, value: existing, changed: false };
1135
+ }
1136
+ if (session.attempts.length >= MAX_SEARCH_JOURNEY_ATTEMPTS) {
1137
+ throw new UntangleError("SEARCH_SESSION_INVALID_STATE", `search journey reached its limit of ${String(MAX_SEARCH_JOURNEY_ATTEMPTS)} attempts`, { operation: "search_journey_record", context: { sessionId } });
1138
+ }
1139
+ const attempt = {
1140
+ attemptId: this.deps.random.uuid(),
1141
+ sequence: session.attempts.length + 1,
1142
+ measurementContextVersion: 2,
1143
+ // Aus dem Verdikt abgeleitet, nie aus einer leeren Kandidatenliste.
1144
+ ...(projection.abstention === null ? {} : { abstention: projection.abstention }),
1145
+ ...(link.step === undefined ? {} : { clientStep: link.step }),
1146
+ observationFingerprint,
1147
+ queryFingerprint: journeyQueryFingerprint(fingerprintKey, this.deps.workspaceId, queryText),
1148
+ ...(session.retainText ? { queryText } : {}),
1149
+ rankerVersion: projection.rankerVersion,
1150
+ ...(projection.rankingContextFingerprint === null
1151
+ ? {}
1152
+ : { rankingContextFingerprint: projection.rankingContextFingerprint }),
1153
+ resultLimit: projection.resultLimit,
1154
+ candidateLimit: projection.candidateLimit,
1155
+ observedAt: this.deps.clock.nowIso(),
1156
+ status: "succeeded",
1157
+ warningCodes: [...projection.warningCodes],
1158
+ retrievalHealthy: projection.retrievalHealthy,
1159
+ profiles: profilesOfProjection(projection),
1160
+ };
1161
+ session.attempts.push(attempt);
1162
+ sortAttempts(session.attempts);
1163
+ session.updatedAt = attempt.observedAt;
1164
+ return { session, value: attempt };
1165
+ }, options, { allowTerminalIdempotentReplay: link.step !== undefined }).then(({ session, value }) => ({ session, attempt: value }));
1166
+ }
1167
+ async recordFailedSearch(sessionId, input, link = {}, options) {
1168
+ assertClientStep(link.step);
1169
+ const query = assertBoundedNonEmpty(input.query, "query", MAX_SEARCH_JOURNEY_TEXT_LENGTH);
1170
+ const failureCode = assertBoundedNonEmpty(input.failureCode, "failureCode", MAX_SEARCH_JOURNEY_LOCATOR_LENGTH);
1171
+ const failureReason = assertBoundedNonEmpty(input.failureReason, "failureReason", MAX_SEARCH_JOURNEY_REASON_LENGTH);
1172
+ const fingerprintKey = await this.getFingerprintKey(options);
1173
+ const observationFingerprint = journeyFingerprint(fingerprintKey, this.deps.workspaceId, "failed-observation", {
1174
+ query,
1175
+ failureCode,
1176
+ failureReason,
1177
+ rankerVersion: RANKER_VERSION,
1178
+ });
1179
+ return this.mutate(sessionId, (session) => {
1180
+ assertConsistentStepMode(session.attempts, link.step, sessionId);
1181
+ const existing = session.attempts.find((attempt) => link.step !== undefined && attempt.clientStep === link.step);
1182
+ if (existing !== undefined) {
1183
+ if (existing.observationFingerprint !== observationFingerprint) {
1184
+ throw new UntangleError("SEARCH_SESSION_CONFLICT", `search journey step ${String(link.step)} already records a different observation`, {
1185
+ operation: "search_journey_record_failure",
1186
+ context: { sessionId, step: link.step },
1187
+ });
1188
+ }
1189
+ return { session, value: existing, changed: false };
1190
+ }
1191
+ if (session.attempts.length >= MAX_SEARCH_JOURNEY_ATTEMPTS) {
1192
+ throw new UntangleError("SEARCH_SESSION_INVALID_STATE", `search journey reached its limit of ${String(MAX_SEARCH_JOURNEY_ATTEMPTS)} attempts`, { operation: "search_journey_record", context: { sessionId } });
1193
+ }
1194
+ const observedAt = this.deps.clock.nowIso();
1195
+ const attempt = {
1196
+ attemptId: this.deps.random.uuid(),
1197
+ sequence: session.attempts.length + 1,
1198
+ measurementContextVersion: 2,
1199
+ // Ein fehlgeschlagener Versuch hat kein Ergebnis und damit kein
1200
+ // Verdikt. Hier `strict` einzutragen waere geraten, nicht beobachtet.
1201
+ ...(link.step === undefined ? {} : { clientStep: link.step }),
1202
+ observationFingerprint,
1203
+ queryFingerprint: journeyQueryFingerprint(fingerprintKey, this.deps.workspaceId, query),
1204
+ ...(session.retainText ? { queryText: query } : {}),
1205
+ rankerVersion: RANKER_VERSION,
1206
+ observedAt,
1207
+ status: "failed",
1208
+ failureCode,
1209
+ ...(session.retainText ? { failureReason } : {}),
1210
+ warningCodes: [],
1211
+ retrievalHealthy: false,
1212
+ profiles: [],
1213
+ };
1214
+ session.attempts.push(attempt);
1215
+ sortAttempts(session.attempts);
1216
+ session.updatedAt = observedAt;
1217
+ return { session, value: attempt };
1218
+ }, options, { allowTerminalIdempotentReplay: link.step !== undefined }).then(({ session, value }) => ({ session, attempt: value }));
1219
+ }
1220
+ async observeNavigation(sessionId, input, options) {
1221
+ const target = input.target === undefined ? undefined : normalizedTarget(input.target);
1222
+ const toolName = input.toolName === undefined
1223
+ ? undefined
1224
+ : assertBoundedNonEmpty(input.toolName, "toolName", MAX_SEARCH_JOURNEY_TOOL_NAME_LENGTH);
1225
+ return this.mutate(sessionId, (session) => {
1226
+ if (session.navigation.length >= MAX_SEARCH_JOURNEY_NAVIGATION_EVENTS) {
1227
+ throw new UntangleError("SEARCH_SESSION_INVALID_STATE", `search journey reached its limit of ${String(MAX_SEARCH_JOURNEY_NAVIGATION_EVENTS)} navigation events`, { operation: "search_journey_observe", context: { sessionId } });
1228
+ }
1229
+ const observedAt = this.deps.clock.nowIso();
1230
+ const event = {
1231
+ eventId: this.deps.random.uuid(),
1232
+ sequence: session.navigation.length + 1,
1233
+ kind: input.kind,
1234
+ observedAt,
1235
+ ...(toolName === undefined ? {} : { toolName }),
1236
+ ...(target === undefined ? {} : { target }),
1237
+ };
1238
+ session.navigation.push(event);
1239
+ session.updatedAt = observedAt;
1240
+ return { session, value: undefined };
1241
+ }, options).then(({ session }) => session);
1242
+ }
1243
+ async complete(sessionId, input, options) {
1244
+ const target = normalizedTarget(input.target);
1245
+ const acceptedAttemptId = input.acceptedAttemptId === undefined
1246
+ ? undefined
1247
+ : assertBoundedNonEmpty(input.acceptedAttemptId, "acceptedAttemptId", MAX_SEARCH_JOURNEY_LOCATOR_LENGTH);
1248
+ return this.mutate(sessionId, (session) => {
1249
+ const completedAt = this.deps.clock.nowIso();
1250
+ const assessment = assessSearchJourney(session, {
1251
+ target,
1252
+ ...(acceptedAttemptId === undefined ? {} : { acceptedAttemptId }),
1253
+ });
1254
+ session.status = "completed";
1255
+ session.updatedAt = completedAt;
1256
+ session.outcome = {
1257
+ kind: "completed",
1258
+ completedAt,
1259
+ target,
1260
+ ...(acceptedAttemptId === undefined ? {} : { acceptedAttemptId }),
1261
+ assessment,
1262
+ };
1263
+ return { session, value: undefined };
1264
+ }, options).then(({ session }) => session);
1265
+ }
1266
+ async abandon(sessionId, input = {}, options) {
1267
+ const reason = input.reason === undefined
1268
+ ? undefined
1269
+ : assertBoundedNonEmpty(input.reason, "reason", MAX_SEARCH_JOURNEY_REASON_LENGTH);
1270
+ return this.mutate(sessionId, (session) => {
1271
+ const abandonedAt = this.deps.clock.nowIso();
1272
+ session.status = "abandoned";
1273
+ session.updatedAt = abandonedAt;
1274
+ session.outcome = {
1275
+ kind: "abandoned",
1276
+ abandonedAt,
1277
+ ...(input.reasonCode === undefined ? {} : { reasonCode: input.reasonCode }),
1278
+ ...(session.retainText && reason !== undefined ? { reason } : {}),
1279
+ };
1280
+ return { session, value: undefined };
1281
+ }, options).then(({ session }) => session);
1282
+ }
1283
+ async report(options) {
1284
+ const sessions = await this.deps.store.list(this.deps.workspaceId, options);
1285
+ return buildDynamicProductComplexityReport(this.deps.workspaceId, sessions, this.deps.clock.nowIso());
1286
+ }
1287
+ validatedFingerprintKey(key) {
1288
+ if (key.byteLength < 32) {
1289
+ throw new Error("search journey fingerprint key must contain at least 32 bytes");
1290
+ }
1291
+ return key.slice();
1292
+ }
1293
+ async getFingerprintKey(options) {
1294
+ if (this.fingerprintKey !== undefined)
1295
+ return this.fingerprintKey;
1296
+ const provider = this.deps.fingerprintKeyProvider;
1297
+ if (provider === undefined)
1298
+ throw new Error("search journey fingerprint key is unavailable");
1299
+ const loaded = this.validatedFingerprintKey(await provider(options));
1300
+ this.fingerprintKey = loaded;
1301
+ return loaded;
1302
+ }
1303
+ async mutate(sessionId, reducer, options, mutationOptions = {}) {
1304
+ const id = assertNonEmpty(sessionId, "sessionId");
1305
+ let resolved;
1306
+ const previous = this.pendingMutations.get(id) ?? Promise.resolve();
1307
+ // Ein fehlgeschlagener parallel vorausgehender Mutator darf die lokale
1308
+ // Serialisierung nicht dauerhaft vergiften. Jeder Aufruf liest seinen
1309
+ // eigenen frischen CAS-Stand und gibt seinen eigenen Fehler weiter.
1310
+ const current = previous
1311
+ .catch(() => undefined)
1312
+ .then(async () => {
1313
+ for (let attempt = 1; attempt <= 3; attempt++) {
1314
+ options?.signal?.throwIfAborted();
1315
+ const loaded = await this.deps.store.get(id, options);
1316
+ if (loaded === null || loaded.workspaceId !== this.deps.workspaceId) {
1317
+ throw new UntangleError("SEARCH_SESSION_NOT_FOUND", `search journey "${id}" was not found in this workspace`, { operation: "search_journey_mutate", context: { sessionId: id } });
1318
+ }
1319
+ if (loaded.status !== "active" &&
1320
+ mutationOptions.allowTerminalIdempotentReplay !== true) {
1321
+ throw new UntangleError("SEARCH_SESSION_INVALID_STATE", `search journey "${id}" is ${loaded.status} and cannot be changed`, {
1322
+ operation: "search_journey_mutate",
1323
+ context: { sessionId: id, status: loaded.status },
1324
+ });
1325
+ }
1326
+ const next = reducer(cloneSession(loaded));
1327
+ if (next.changed === false) {
1328
+ resolved = { session: cloneSession(loaded), value: next.value };
1329
+ return;
1330
+ }
1331
+ if (loaded.status !== "active") {
1332
+ throw new UntangleError("SEARCH_SESSION_INVALID_STATE", `search journey "${id}" is ${loaded.status} and cannot be changed`, {
1333
+ operation: "search_journey_mutate",
1334
+ context: { sessionId: id, status: loaded.status },
1335
+ });
1336
+ }
1337
+ next.session.revision = loaded.revision + 1;
1338
+ assertValidSessionState(next.session);
1339
+ try {
1340
+ await this.deps.store.put(next.session, {
1341
+ ...options,
1342
+ expectedRevision: loaded.revision,
1343
+ });
1344
+ resolved = { session: cloneSession(next.session), value: next.value };
1345
+ return;
1346
+ }
1347
+ catch (error) {
1348
+ if (!isUntangleError(error) ||
1349
+ error.code !== "SEARCH_SESSION_CONFLICT" ||
1350
+ attempt === 3) {
1351
+ throw error;
1352
+ }
1353
+ }
1354
+ }
1355
+ });
1356
+ this.pendingMutations.set(id, current);
1357
+ try {
1358
+ await current;
1359
+ return resolved;
1360
+ }
1361
+ finally {
1362
+ if (this.pendingMutations.get(id) === current)
1363
+ this.pendingMutations.delete(id);
1364
+ }
1365
+ }
1366
+ }
1367
+ //# sourceMappingURL=product-complexity.js.map