@untangle-code/search 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/NOTICE +90 -0
- package/README.md +433 -2
- package/THIRD_PARTY_LICENSES.md +29 -0
- package/dist/benchmark-integrity.d.ts +12 -0
- package/dist/benchmark-integrity.d.ts.map +1 -0
- package/dist/benchmark-integrity.js +10 -0
- package/dist/benchmark-integrity.js.map +1 -0
- package/dist/bm25.d.ts +46 -0
- package/dist/bm25.d.ts.map +1 -0
- package/dist/bm25.js +105 -0
- package/dist/bm25.js.map +1 -0
- package/dist/deadline.d.ts +4 -0
- package/dist/deadline.d.ts.map +1 -0
- package/dist/deadline.js +54 -0
- package/dist/deadline.js.map +1 -0
- package/dist/dedupe.d.ts +48 -0
- package/dist/dedupe.d.ts.map +1 -0
- package/dist/dedupe.js +58 -0
- package/dist/dedupe.js.map +1 -0
- package/dist/diversify.d.ts +45 -0
- package/dist/diversify.d.ts.map +1 -0
- package/dist/diversify.js +30 -0
- package/dist/diversify.js.map +1 -0
- package/dist/engine.d.ts +157 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +1498 -0
- package/dist/engine.js.map +1 -0
- package/dist/enrichment.d.ts +16 -0
- package/dist/enrichment.d.ts.map +1 -0
- package/dist/enrichment.js +92 -0
- package/dist/enrichment.js.map +1 -0
- package/dist/filter.d.ts +86 -0
- package/dist/filter.d.ts.map +1 -0
- package/dist/filter.js +166 -0
- package/dist/filter.js.map +1 -0
- package/dist/findability.d.ts +144 -0
- package/dist/findability.d.ts.map +1 -0
- package/dist/findability.js +393 -0
- package/dist/findability.js.map +1 -0
- package/dist/format.d.ts +28 -0
- package/dist/format.d.ts.map +1 -0
- package/dist/format.js +172 -0
- package/dist/format.js.map +1 -0
- package/dist/fusion.d.ts +47 -0
- package/dist/fusion.d.ts.map +1 -0
- package/dist/fusion.js +84 -0
- package/dist/fusion.js.map +1 -0
- package/dist/glob.d.ts +50 -0
- package/dist/glob.d.ts.map +1 -0
- package/dist/glob.js +144 -0
- package/dist/glob.js.map +1 -0
- package/dist/grep.d.ts +76 -0
- package/dist/grep.d.ts.map +1 -0
- package/dist/grep.js +475 -0
- package/dist/grep.js.map +1 -0
- package/dist/index.d.ts +42 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +63 -0
- package/dist/index.js.map +1 -0
- package/dist/ledger.d.ts +45 -0
- package/dist/ledger.d.ts.map +1 -0
- package/dist/ledger.js +39 -0
- package/dist/ledger.js.map +1 -0
- package/dist/payload.d.ts +108 -0
- package/dist/payload.d.ts.map +1 -0
- package/dist/payload.js +263 -0
- package/dist/payload.js.map +1 -0
- package/dist/port.d.ts +112 -0
- package/dist/port.d.ts.map +1 -0
- package/dist/port.js +2 -0
- package/dist/port.js.map +1 -0
- package/dist/product-complexity.d.ts +89 -0
- package/dist/product-complexity.d.ts.map +1 -0
- package/dist/product-complexity.js +1367 -0
- package/dist/product-complexity.js.map +1 -0
- package/dist/regex-safety.d.ts +75 -0
- package/dist/regex-safety.d.ts.map +1 -0
- package/dist/regex-safety.js +902 -0
- package/dist/regex-safety.js.map +1 -0
- package/dist/relevance-prerequisites.d.ts +21 -0
- package/dist/relevance-prerequisites.d.ts.map +1 -0
- package/dist/relevance-prerequisites.js +47 -0
- package/dist/relevance-prerequisites.js.map +1 -0
- package/dist/relevance.d.ts +107 -0
- package/dist/relevance.d.ts.map +1 -0
- package/dist/relevance.js +199 -0
- package/dist/relevance.js.map +1 -0
- package/dist/retrieval.d.ts +46 -0
- package/dist/retrieval.d.ts.map +1 -0
- package/dist/retrieval.js +182 -0
- package/dist/retrieval.js.map +1 -0
- package/dist/scoring.d.ts +102 -0
- package/dist/scoring.d.ts.map +1 -0
- package/dist/scoring.js +175 -0
- package/dist/scoring.js.map +1 -0
- package/dist/sort.d.ts +21 -0
- package/dist/sort.d.ts.map +1 -0
- package/dist/sort.js +53 -0
- package/dist/sort.js.map +1 -0
- package/dist/symbol-match.d.ts +21 -0
- package/dist/symbol-match.d.ts.map +1 -0
- package/dist/symbol-match.js +72 -0
- package/dist/symbol-match.js.map +1 -0
- package/dist/tokenize.d.ts +41 -0
- package/dist/tokenize.d.ts.map +1 -0
- package/dist/tokenize.js +202 -0
- package/dist/tokenize.js.map +1 -0
- package/dist/types.d.ts +173 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +42 -0
- package/dist/types.js.map +1 -0
- package/package.json +61 -3
- package/src/benchmark-integrity.ts +25 -0
- package/src/bm25.ts +135 -0
- package/src/deadline.ts +59 -0
- package/src/dedupe.ts +99 -0
- package/src/diversify.ts +71 -0
- package/src/engine.ts +1957 -0
- package/src/enrichment.ts +115 -0
- package/src/filter.ts +267 -0
- package/src/findability.ts +580 -0
- package/src/format.ts +228 -0
- package/src/fusion.ts +121 -0
- package/src/glob.ts +151 -0
- package/src/grep.ts +618 -0
- package/src/index.ts +83 -0
- package/src/ledger.ts +72 -0
- package/src/payload.ts +382 -0
- package/src/port.ts +133 -0
- package/src/product-complexity.ts +2168 -0
- package/src/regex-safety.ts +1002 -0
- package/src/relevance-prerequisites.ts +83 -0
- package/src/relevance.ts +328 -0
- package/src/retrieval.ts +299 -0
- package/src/scoring.ts +321 -0
- package/src/sort.ts +86 -0
- package/src/symbol-match.ts +87 -0
- package/src/tokenize.ts +213 -0
- package/src/types.ts +206 -0
|
@@ -0,0 +1,2168 @@
|
|
|
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 {
|
|
10
|
+
DYNAMIC_SEARCH_COMPLEXITY_ALGORITHM_ID,
|
|
11
|
+
INDEX_CONTEXT_INCOMPLETE_WARNING_CODE,
|
|
12
|
+
MAX_SEARCH_JOURNEY_ATTEMPTS as DOMAIN_MAX_SEARCH_JOURNEY_ATTEMPTS,
|
|
13
|
+
MAX_SEARCH_JOURNEY_EXECUTION_CONTEXT_LENGTH,
|
|
14
|
+
MAX_SEARCH_JOURNEY_FEATURE_ID_LENGTH as DOMAIN_MAX_SEARCH_JOURNEY_FEATURE_ID_LENGTH,
|
|
15
|
+
MAX_SEARCH_JOURNEY_LOCATOR_LENGTH,
|
|
16
|
+
MAX_SEARCH_JOURNEY_NAVIGATION_EVENTS as DOMAIN_MAX_SEARCH_JOURNEY_NAVIGATION_EVENTS,
|
|
17
|
+
MAX_SEARCH_JOURNEY_REASON_LENGTH,
|
|
18
|
+
MAX_SEARCH_JOURNEY_TEXT_LENGTH,
|
|
19
|
+
MAX_SEARCH_JOURNEY_TOOL_NAME_LENGTH,
|
|
20
|
+
MINIMUM_PRODUCT_COMPLEXITY_SESSIONS as DOMAIN_MINIMUM_PRODUCT_COMPLEXITY_SESSIONS,
|
|
21
|
+
RANKER_VERSION,
|
|
22
|
+
SEARCH_JOURNEY_EXECUTION_CONTEXT_DIMENSIONS,
|
|
23
|
+
SEARCH_JOURNEY_STATUSES,
|
|
24
|
+
canonicalJson,
|
|
25
|
+
compareCodepoints,
|
|
26
|
+
hmacSha256Hex,
|
|
27
|
+
isUntangleError,
|
|
28
|
+
searchJourneyValidationIssue,
|
|
29
|
+
sha256Hex,
|
|
30
|
+
UntangleError,
|
|
31
|
+
type Clock,
|
|
32
|
+
type ComplexitySummaryV1,
|
|
33
|
+
type DynamicProductComplexityCohortV1,
|
|
34
|
+
type DynamicProductComplexityFeatureMappingV1,
|
|
35
|
+
type DynamicProductComplexityReportV1,
|
|
36
|
+
type DynamicSearchComplexityV1,
|
|
37
|
+
type DynamicSearchOutcomeCohortV1,
|
|
38
|
+
type PortCallOptions,
|
|
39
|
+
type Random,
|
|
40
|
+
type SearchAbstention,
|
|
41
|
+
type SearchJourneyAttemptV1,
|
|
42
|
+
type SearchJourneyAbandonReasonCode,
|
|
43
|
+
type SearchJourneyCandidateV1,
|
|
44
|
+
type SearchJourneyExecutionContextEvidenceV1,
|
|
45
|
+
type SearchJourneyExecutionContextInputV1,
|
|
46
|
+
type SearchJourneyExecutionContextV1,
|
|
47
|
+
type SearchJourneyNavigationEventV1,
|
|
48
|
+
type SearchJourneyNavigationKind,
|
|
49
|
+
type SearchJourneyStorePort,
|
|
50
|
+
type SearchJourneyTargetCodeComplexityEvidenceV1,
|
|
51
|
+
type SearchJourneyTargetV1,
|
|
52
|
+
type SearchJourneyV1,
|
|
53
|
+
type SearchRetrievalDiagnosticsV1,
|
|
54
|
+
type SearchResultSetV1,
|
|
55
|
+
} from "@untangle-code/domain";
|
|
56
|
+
|
|
57
|
+
export const MINIMUM_PRODUCT_COMPLEXITY_SESSIONS = DOMAIN_MINIMUM_PRODUCT_COMPLEXITY_SESSIONS;
|
|
58
|
+
export const MAX_SEARCH_JOURNEY_ATTEMPTS = DOMAIN_MAX_SEARCH_JOURNEY_ATTEMPTS;
|
|
59
|
+
export const MAX_SEARCH_JOURNEY_FEATURE_ID_LENGTH = DOMAIN_MAX_SEARCH_JOURNEY_FEATURE_ID_LENGTH;
|
|
60
|
+
export const MAX_SEARCH_JOURNEY_NAVIGATION_EVENTS = DOMAIN_MAX_SEARCH_JOURNEY_NAVIGATION_EVENTS;
|
|
61
|
+
|
|
62
|
+
interface SearchJourneyServiceBaseDeps {
|
|
63
|
+
workspaceId: string;
|
|
64
|
+
store: SearchJourneyStorePort;
|
|
65
|
+
clock: Clock;
|
|
66
|
+
random: Random;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Ein stabiler Key oder ein persistenter Provider ist fuer korrekte Wiederaufnahme zwingend. */
|
|
70
|
+
export type SearchJourneyServiceDeps = SearchJourneyServiceBaseDeps &
|
|
71
|
+
(
|
|
72
|
+
| { fingerprintKey: Uint8Array; fingerprintKeyProvider?: never }
|
|
73
|
+
| {
|
|
74
|
+
fingerprintKey?: never;
|
|
75
|
+
/** Lazy Variante, damit reine Leseoperationen keinen lokalen Schluessel anlegen. */
|
|
76
|
+
fingerprintKeyProvider: (options?: PortCallOptions) => Promise<Uint8Array>;
|
|
77
|
+
}
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
export interface StartSearchJourneyInput {
|
|
81
|
+
goal: string;
|
|
82
|
+
/** Stabile, vom Produkt vergebene Kennung fuer feature-uebergreifende Probevergleiche. */
|
|
83
|
+
featureId?: string;
|
|
84
|
+
/** Caller labels are pseudonymized by default; no automatic model or task inference. */
|
|
85
|
+
executionContext?: SearchJourneyExecutionContextInputV1;
|
|
86
|
+
/** Persistiert Ziel- und Querytext; Default false speichert nur Fingerprints. */
|
|
87
|
+
retainText?: boolean;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface ObserveSearchJourneyNavigationInput {
|
|
91
|
+
kind: SearchJourneyNavigationKind;
|
|
92
|
+
toolName?: string;
|
|
93
|
+
target?: SearchJourneyTargetV1;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface CompleteSearchJourneyInput {
|
|
97
|
+
target: SearchJourneyTargetV1;
|
|
98
|
+
/** Praezisiert den akzeptierten Versuch, wenn derselbe Treffer mehrfach vorkam. */
|
|
99
|
+
acceptedAttemptId?: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface RecordFailedSearchInput {
|
|
103
|
+
query: string;
|
|
104
|
+
failureCode: string;
|
|
105
|
+
failureReason: string;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface AbandonSearchJourneyInput {
|
|
109
|
+
/** Kontrollierter Grund, der auch im textfreien Default sicher gespeichert werden kann. */
|
|
110
|
+
reasonCode?: SearchJourneyAbandonReasonCode;
|
|
111
|
+
/** Freitext; wird nur bei einer mit `retainText` gestarteten Journey gespeichert. */
|
|
112
|
+
reason?: string;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface SearchJourneySearchLink {
|
|
116
|
+
/** 1-basierter, pro Session eindeutiger Schritt. */
|
|
117
|
+
step?: number;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface RecordedSearchAttempt {
|
|
121
|
+
session: SearchJourneyV1;
|
|
122
|
+
attempt: SearchJourneyAttemptV1;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function assertNonEmpty(value: string, field: string): string {
|
|
126
|
+
const normalized = value.trim();
|
|
127
|
+
if (normalized === "") throw new Error(`${field} must not be empty`);
|
|
128
|
+
return normalized;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function assertBoundedNonEmpty(value: string, field: string, maximum: number): string {
|
|
132
|
+
const normalized = assertNonEmpty(value, field);
|
|
133
|
+
if (normalized.length > maximum) {
|
|
134
|
+
throw new Error(`${field} must contain at most ${String(maximum)} UTF-16 code units`);
|
|
135
|
+
}
|
|
136
|
+
return normalized;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function assertBoundedText(value: string, field: string, maximum: number): string {
|
|
140
|
+
if (value.length > maximum) {
|
|
141
|
+
throw new Error(`${field} must contain at most ${String(maximum)} UTF-16 code units`);
|
|
142
|
+
}
|
|
143
|
+
return value;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function normalizedTarget(target: SearchJourneyTargetV1): SearchJourneyTargetV1 {
|
|
147
|
+
const normalized: SearchJourneyTargetV1 = {
|
|
148
|
+
...(target.repoId === undefined
|
|
149
|
+
? {}
|
|
150
|
+
: {
|
|
151
|
+
repoId: assertBoundedNonEmpty(
|
|
152
|
+
target.repoId,
|
|
153
|
+
"search journey target repoId",
|
|
154
|
+
MAX_SEARCH_JOURNEY_LOCATOR_LENGTH,
|
|
155
|
+
),
|
|
156
|
+
}),
|
|
157
|
+
...(target.refId === undefined
|
|
158
|
+
? {}
|
|
159
|
+
: {
|
|
160
|
+
refId: assertBoundedNonEmpty(
|
|
161
|
+
target.refId,
|
|
162
|
+
"search journey target refId",
|
|
163
|
+
MAX_SEARCH_JOURNEY_LOCATOR_LENGTH,
|
|
164
|
+
),
|
|
165
|
+
}),
|
|
166
|
+
...(target.filePath === undefined
|
|
167
|
+
? {}
|
|
168
|
+
: {
|
|
169
|
+
filePath: assertBoundedNonEmpty(
|
|
170
|
+
target.filePath,
|
|
171
|
+
"search journey target filePath",
|
|
172
|
+
MAX_SEARCH_JOURNEY_LOCATOR_LENGTH,
|
|
173
|
+
),
|
|
174
|
+
}),
|
|
175
|
+
...(target.chunkId === undefined
|
|
176
|
+
? {}
|
|
177
|
+
: {
|
|
178
|
+
chunkId: assertBoundedNonEmpty(
|
|
179
|
+
target.chunkId,
|
|
180
|
+
"search journey target chunkId",
|
|
181
|
+
MAX_SEARCH_JOURNEY_LOCATOR_LENGTH,
|
|
182
|
+
),
|
|
183
|
+
}),
|
|
184
|
+
...(target.symbolId === undefined
|
|
185
|
+
? {}
|
|
186
|
+
: {
|
|
187
|
+
symbolId: assertBoundedNonEmpty(
|
|
188
|
+
target.symbolId,
|
|
189
|
+
"search journey target symbolId",
|
|
190
|
+
MAX_SEARCH_JOURNEY_LOCATOR_LENGTH,
|
|
191
|
+
),
|
|
192
|
+
}),
|
|
193
|
+
...(target.embeddingProfileId === undefined
|
|
194
|
+
? {}
|
|
195
|
+
: {
|
|
196
|
+
embeddingProfileId: assertBoundedNonEmpty(
|
|
197
|
+
target.embeddingProfileId,
|
|
198
|
+
"search journey target embeddingProfileId",
|
|
199
|
+
MAX_SEARCH_JOURNEY_LOCATOR_LENGTH,
|
|
200
|
+
),
|
|
201
|
+
}),
|
|
202
|
+
...(target.indexGeneration === undefined
|
|
203
|
+
? {}
|
|
204
|
+
: {
|
|
205
|
+
indexGeneration: assertBoundedNonEmpty(
|
|
206
|
+
target.indexGeneration,
|
|
207
|
+
"search journey target indexGeneration",
|
|
208
|
+
MAX_SEARCH_JOURNEY_LOCATOR_LENGTH,
|
|
209
|
+
),
|
|
210
|
+
}),
|
|
211
|
+
};
|
|
212
|
+
const identity = [normalized.chunkId, normalized.symbolId, normalized.filePath].some(
|
|
213
|
+
(value) => typeof value === "string" && value.trim() !== "",
|
|
214
|
+
);
|
|
215
|
+
if (!identity) {
|
|
216
|
+
throw new Error("search journey target requires chunkId, symbolId or filePath");
|
|
217
|
+
}
|
|
218
|
+
return normalized;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
interface SearchObservationHitProjection {
|
|
222
|
+
repoId: string;
|
|
223
|
+
refId: string;
|
|
224
|
+
filePath: string;
|
|
225
|
+
chunkId: string;
|
|
226
|
+
symbolId?: string;
|
|
227
|
+
symbolFqn?: string;
|
|
228
|
+
embeddingProfileId: string;
|
|
229
|
+
indexGeneration: string;
|
|
230
|
+
rank: number;
|
|
231
|
+
score: number;
|
|
232
|
+
complexity: ComplexitySummaryV1 | null;
|
|
233
|
+
incompletePayloadFields: string[];
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
interface SearchObservationProfileProjection {
|
|
237
|
+
embeddingProfileId: string;
|
|
238
|
+
indexGenerations: string[] | null;
|
|
239
|
+
corpusRevisionFingerprint: string | null;
|
|
240
|
+
notSearchedReason?: string;
|
|
241
|
+
retrieval?: SearchRetrievalDiagnosticsV1;
|
|
242
|
+
hits: SearchObservationHitProjection[];
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
interface SearchObservationProjection {
|
|
246
|
+
queryFingerprint: string;
|
|
247
|
+
queryText: string;
|
|
248
|
+
rankerVersion: string;
|
|
249
|
+
rankingContextFingerprint: string | null;
|
|
250
|
+
resultLimit: number;
|
|
251
|
+
candidateLimit: number;
|
|
252
|
+
retrievalHealthy: boolean;
|
|
253
|
+
groups: SearchObservationProfileProjection[];
|
|
254
|
+
warningCodes: string[];
|
|
255
|
+
/**
|
|
256
|
+
* Unter welchem Abstinenzverhalten der Lauf stand. `null` heisst: fuer keine
|
|
257
|
+
* Gruppe wurde eine Relevanzpolicy konsultiert — dann sind beide Verhalten
|
|
258
|
+
* beweisbar identisch. Es wird NIE aus einer leeren Kandidatenliste
|
|
259
|
+
* erschlossen.
|
|
260
|
+
*/
|
|
261
|
+
abstention: SearchAbstention | null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function profilesOfProjection(
|
|
265
|
+
projection: SearchObservationProjection,
|
|
266
|
+
): SearchJourneyAttemptV1["profiles"] {
|
|
267
|
+
return projection.groups.map((group) => ({
|
|
268
|
+
embeddingProfileId: group.embeddingProfileId,
|
|
269
|
+
indexGenerations:
|
|
270
|
+
group.indexGenerations === null
|
|
271
|
+
? [...new Set(group.hits.map((hit) => hit.indexGeneration))].sort(compareCodepoints)
|
|
272
|
+
: [...new Set(group.indexGenerations)].sort(compareCodepoints),
|
|
273
|
+
...(group.corpusRevisionFingerprint === null
|
|
274
|
+
? {}
|
|
275
|
+
: { corpusRevisionFingerprint: group.corpusRevisionFingerprint }),
|
|
276
|
+
retrievalPerformed: group.notSearchedReason === undefined,
|
|
277
|
+
...(group.notSearchedReason === undefined
|
|
278
|
+
? {}
|
|
279
|
+
: { notSearchedReason: group.notSearchedReason }),
|
|
280
|
+
...(group.retrieval === undefined ? {} : { retrieval: group.retrieval }),
|
|
281
|
+
candidates: group.hits.map((hit) => ({
|
|
282
|
+
repoId: hit.repoId,
|
|
283
|
+
refId: hit.refId,
|
|
284
|
+
filePath: hit.filePath,
|
|
285
|
+
chunkId: hit.chunkId,
|
|
286
|
+
...(hit.symbolId === undefined ? {} : { symbolId: hit.symbolId }),
|
|
287
|
+
...(hit.symbolFqn === undefined ? {} : { symbolFqn: hit.symbolFqn }),
|
|
288
|
+
embeddingProfileId: hit.embeddingProfileId,
|
|
289
|
+
indexGeneration: hit.indexGeneration,
|
|
290
|
+
rank: hit.rank,
|
|
291
|
+
score: hit.score,
|
|
292
|
+
...(hit.complexity === null ? {} : { complexity: hit.complexity }),
|
|
293
|
+
incompletePayloadFields: [...hit.incompletePayloadFields],
|
|
294
|
+
})),
|
|
295
|
+
}));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function assertValidObservationProjection(projection: SearchObservationProjection): void {
|
|
299
|
+
const probe: SearchJourneyV1 = {
|
|
300
|
+
schemaVersion: 1,
|
|
301
|
+
revision: 0,
|
|
302
|
+
sessionId: "projection-validation",
|
|
303
|
+
workspaceId: "projection-validation",
|
|
304
|
+
status: "active",
|
|
305
|
+
recordingConsent: "explicit",
|
|
306
|
+
retainText: false,
|
|
307
|
+
goal: { fingerprint: "0".repeat(64) },
|
|
308
|
+
createdAt: "1970-01-01T00:00:00.000Z",
|
|
309
|
+
updatedAt: "1970-01-01T00:00:00.000Z",
|
|
310
|
+
attempts: [
|
|
311
|
+
{
|
|
312
|
+
attemptId: "projection-attempt",
|
|
313
|
+
sequence: 1,
|
|
314
|
+
measurementContextVersion: 2,
|
|
315
|
+
...(projection.abstention === null ? {} : { abstention: projection.abstention }),
|
|
316
|
+
observationFingerprint: "0".repeat(64),
|
|
317
|
+
queryFingerprint: "0".repeat(64),
|
|
318
|
+
rankerVersion: projection.rankerVersion,
|
|
319
|
+
...(projection.rankingContextFingerprint === null
|
|
320
|
+
? {}
|
|
321
|
+
: { rankingContextFingerprint: projection.rankingContextFingerprint }),
|
|
322
|
+
resultLimit: projection.resultLimit,
|
|
323
|
+
candidateLimit: projection.candidateLimit,
|
|
324
|
+
observedAt: "1970-01-01T00:00:00.000Z",
|
|
325
|
+
status: "succeeded",
|
|
326
|
+
warningCodes: [...projection.warningCodes],
|
|
327
|
+
retrievalHealthy: projection.retrievalHealthy,
|
|
328
|
+
profiles: profilesOfProjection(projection),
|
|
329
|
+
},
|
|
330
|
+
],
|
|
331
|
+
navigation: [],
|
|
332
|
+
};
|
|
333
|
+
assertValidSessionState(probe);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function projectComplexitySummary(complexity: ComplexitySummaryV1): ComplexitySummaryV1 {
|
|
337
|
+
return {
|
|
338
|
+
metricsScope: complexity.metricsScope,
|
|
339
|
+
metricsScopeId: assertBoundedNonEmpty(
|
|
340
|
+
complexity.metricsScopeId,
|
|
341
|
+
"search hit metricsScopeId",
|
|
342
|
+
MAX_SEARCH_JOURNEY_LOCATOR_LENGTH,
|
|
343
|
+
),
|
|
344
|
+
metricsVersion: assertBoundedNonEmpty(
|
|
345
|
+
complexity.metricsVersion,
|
|
346
|
+
"search hit metricsVersion",
|
|
347
|
+
MAX_SEARCH_JOURNEY_LOCATOR_LENGTH,
|
|
348
|
+
),
|
|
349
|
+
...(complexity.cyclomaticClassic === undefined
|
|
350
|
+
? {}
|
|
351
|
+
: { cyclomaticClassic: complexity.cyclomaticClassic }),
|
|
352
|
+
...(complexity.cyclomaticExtended === undefined
|
|
353
|
+
? {}
|
|
354
|
+
: { cyclomaticExtended: complexity.cyclomaticExtended }),
|
|
355
|
+
...(complexity.cognitive === undefined ? {} : { cognitive: complexity.cognitive }),
|
|
356
|
+
...(complexity.maxNesting === undefined ? {} : { maxNesting: complexity.maxNesting }),
|
|
357
|
+
...(complexity.sloc === undefined ? {} : { sloc: complexity.sloc }),
|
|
358
|
+
...(complexity.halsteadVolume === undefined
|
|
359
|
+
? {}
|
|
360
|
+
: { halsteadVolume: complexity.halsteadVolume }),
|
|
361
|
+
...(complexity.maintainabilityIndex === undefined
|
|
362
|
+
? {}
|
|
363
|
+
: { maintainabilityIndex: complexity.maintainabilityIndex }),
|
|
364
|
+
...(complexity.riskScore === undefined ? {} : { riskScore: complexity.riskScore }),
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function projectRetrievalDiagnostics(
|
|
369
|
+
retrieval: SearchRetrievalDiagnosticsV1,
|
|
370
|
+
): SearchRetrievalDiagnosticsV1 {
|
|
371
|
+
return {
|
|
372
|
+
branchCandidateCounts: {
|
|
373
|
+
dense: retrieval.branchCandidateCounts.dense,
|
|
374
|
+
sparse: retrieval.branchCandidateCounts.sparse,
|
|
375
|
+
symbol: retrieval.branchCandidateCounts.symbol,
|
|
376
|
+
},
|
|
377
|
+
uniqueRetrievedCandidateCount: retrieval.uniqueRetrievedCandidateCount,
|
|
378
|
+
eligibleCandidateCount: retrieval.eligibleCandidateCount,
|
|
379
|
+
rankedCandidateCount: retrieval.rankedCandidateCount,
|
|
380
|
+
deduplicatedCandidateCount: retrieval.deduplicatedCandidateCount,
|
|
381
|
+
qualifiedHitCount: retrieval.qualifiedHitCount,
|
|
382
|
+
returnedHitCount: retrieval.returnedHitCount,
|
|
383
|
+
uniqueEligibleFileCount: retrieval.uniqueEligibleFileCount,
|
|
384
|
+
uniqueEligibleSymbolCount: retrieval.uniqueEligibleSymbolCount,
|
|
385
|
+
eligibleFileDispersion: retrieval.eligibleFileDispersion,
|
|
386
|
+
topScoreGap: retrieval.topScoreGap,
|
|
387
|
+
candidateLimitReached: retrieval.candidateLimitReached,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function projectSearchObservation(
|
|
392
|
+
result: SearchResultSetV1,
|
|
393
|
+
queryText: string,
|
|
394
|
+
): SearchObservationProjection {
|
|
395
|
+
const queryFingerprint = assertBoundedNonEmpty(
|
|
396
|
+
result.queryFingerprint,
|
|
397
|
+
"search result queryFingerprint",
|
|
398
|
+
MAX_SEARCH_JOURNEY_LOCATOR_LENGTH,
|
|
399
|
+
);
|
|
400
|
+
const rankerVersion = assertBoundedNonEmpty(
|
|
401
|
+
result.query.rankerVersion,
|
|
402
|
+
"search result rankerVersion",
|
|
403
|
+
MAX_SEARCH_JOURNEY_LOCATOR_LENGTH,
|
|
404
|
+
);
|
|
405
|
+
const rankingContextFingerprint =
|
|
406
|
+
result.rankingContextFingerprint === undefined
|
|
407
|
+
? null
|
|
408
|
+
: assertBoundedNonEmpty(
|
|
409
|
+
result.rankingContextFingerprint,
|
|
410
|
+
"search result rankingContextFingerprint",
|
|
411
|
+
MAX_SEARCH_JOURNEY_LOCATOR_LENGTH,
|
|
412
|
+
);
|
|
413
|
+
const warningCodes = result.warnings
|
|
414
|
+
.map((warning) =>
|
|
415
|
+
assertBoundedNonEmpty(
|
|
416
|
+
warning.code,
|
|
417
|
+
"search result warning code",
|
|
418
|
+
MAX_SEARCH_JOURNEY_LOCATOR_LENGTH,
|
|
419
|
+
),
|
|
420
|
+
)
|
|
421
|
+
.sort(compareCodepoints);
|
|
422
|
+
const groups = result.groups.map((group): SearchObservationProfileProjection => {
|
|
423
|
+
const embeddingProfileId = assertBoundedNonEmpty(
|
|
424
|
+
group.embeddingProfileId,
|
|
425
|
+
"search result embeddingProfileId",
|
|
426
|
+
MAX_SEARCH_JOURNEY_LOCATOR_LENGTH,
|
|
427
|
+
);
|
|
428
|
+
const indexGenerations =
|
|
429
|
+
group.indexGenerations === undefined
|
|
430
|
+
? null
|
|
431
|
+
: group.indexGenerations.map((generation) =>
|
|
432
|
+
assertBoundedNonEmpty(
|
|
433
|
+
generation,
|
|
434
|
+
"search result indexGeneration",
|
|
435
|
+
MAX_SEARCH_JOURNEY_LOCATOR_LENGTH,
|
|
436
|
+
),
|
|
437
|
+
);
|
|
438
|
+
const corpusRevisionFingerprint =
|
|
439
|
+
group.corpusRevisionFingerprint === undefined
|
|
440
|
+
? null
|
|
441
|
+
: assertBoundedNonEmpty(
|
|
442
|
+
group.corpusRevisionFingerprint,
|
|
443
|
+
"search result corpusRevisionFingerprint",
|
|
444
|
+
MAX_SEARCH_JOURNEY_LOCATOR_LENGTH,
|
|
445
|
+
);
|
|
446
|
+
const notSearchedReason =
|
|
447
|
+
group.notSearchedReason === undefined
|
|
448
|
+
? undefined
|
|
449
|
+
: assertBoundedText(
|
|
450
|
+
group.notSearchedReason,
|
|
451
|
+
"search result notSearchedReason",
|
|
452
|
+
MAX_SEARCH_JOURNEY_REASON_LENGTH,
|
|
453
|
+
);
|
|
454
|
+
if (group.hits.length > result.query.limit) {
|
|
455
|
+
throw new Error("search result group contains more hits than its result limit");
|
|
456
|
+
}
|
|
457
|
+
const hits = group.hits.map((hit): SearchObservationHitProjection => {
|
|
458
|
+
const complexity =
|
|
459
|
+
hit.complexity === undefined ? null : projectComplexitySummary(hit.complexity);
|
|
460
|
+
return {
|
|
461
|
+
repoId: assertBoundedNonEmpty(
|
|
462
|
+
hit.repoId,
|
|
463
|
+
"search hit repoId",
|
|
464
|
+
MAX_SEARCH_JOURNEY_LOCATOR_LENGTH,
|
|
465
|
+
),
|
|
466
|
+
refId: assertBoundedNonEmpty(
|
|
467
|
+
hit.refId,
|
|
468
|
+
"search hit refId",
|
|
469
|
+
MAX_SEARCH_JOURNEY_LOCATOR_LENGTH,
|
|
470
|
+
),
|
|
471
|
+
filePath: assertBoundedText(
|
|
472
|
+
hit.filePath,
|
|
473
|
+
"search hit filePath",
|
|
474
|
+
MAX_SEARCH_JOURNEY_LOCATOR_LENGTH,
|
|
475
|
+
),
|
|
476
|
+
chunkId: assertBoundedNonEmpty(
|
|
477
|
+
hit.chunkId,
|
|
478
|
+
"search hit chunkId",
|
|
479
|
+
MAX_SEARCH_JOURNEY_LOCATOR_LENGTH,
|
|
480
|
+
),
|
|
481
|
+
...(hit.symbolId === undefined
|
|
482
|
+
? {}
|
|
483
|
+
: {
|
|
484
|
+
symbolId: assertBoundedNonEmpty(
|
|
485
|
+
hit.symbolId,
|
|
486
|
+
"search hit symbolId",
|
|
487
|
+
MAX_SEARCH_JOURNEY_LOCATOR_LENGTH,
|
|
488
|
+
),
|
|
489
|
+
}),
|
|
490
|
+
...(hit.symbolFqn === undefined
|
|
491
|
+
? {}
|
|
492
|
+
: {
|
|
493
|
+
symbolFqn: assertBoundedText(
|
|
494
|
+
hit.symbolFqn,
|
|
495
|
+
"search hit symbolFqn",
|
|
496
|
+
MAX_SEARCH_JOURNEY_LOCATOR_LENGTH,
|
|
497
|
+
),
|
|
498
|
+
}),
|
|
499
|
+
embeddingProfileId: assertBoundedNonEmpty(
|
|
500
|
+
hit.embeddingProfileId,
|
|
501
|
+
"search hit embeddingProfileId",
|
|
502
|
+
MAX_SEARCH_JOURNEY_LOCATOR_LENGTH,
|
|
503
|
+
),
|
|
504
|
+
indexGeneration: assertBoundedNonEmpty(
|
|
505
|
+
hit.indexGeneration,
|
|
506
|
+
"search hit indexGeneration",
|
|
507
|
+
MAX_SEARCH_JOURNEY_LOCATOR_LENGTH,
|
|
508
|
+
),
|
|
509
|
+
rank: hit.rank,
|
|
510
|
+
score: hit.score.final,
|
|
511
|
+
complexity,
|
|
512
|
+
incompletePayloadFields: (hit.incompletePayloadFields ?? []).map((field) =>
|
|
513
|
+
assertBoundedNonEmpty(
|
|
514
|
+
field,
|
|
515
|
+
"search hit incomplete payload field",
|
|
516
|
+
MAX_SEARCH_JOURNEY_LOCATOR_LENGTH,
|
|
517
|
+
),
|
|
518
|
+
),
|
|
519
|
+
};
|
|
520
|
+
});
|
|
521
|
+
return {
|
|
522
|
+
embeddingProfileId,
|
|
523
|
+
indexGenerations,
|
|
524
|
+
corpusRevisionFingerprint,
|
|
525
|
+
...(notSearchedReason === undefined ? {} : { notSearchedReason }),
|
|
526
|
+
...(group.retrieval === undefined
|
|
527
|
+
? {}
|
|
528
|
+
: { retrieval: projectRetrievalDiagnostics(group.retrieval) }),
|
|
529
|
+
hits,
|
|
530
|
+
};
|
|
531
|
+
});
|
|
532
|
+
const projection: SearchObservationProjection = {
|
|
533
|
+
queryFingerprint,
|
|
534
|
+
queryText,
|
|
535
|
+
rankerVersion,
|
|
536
|
+
rankingContextFingerprint,
|
|
537
|
+
resultLimit: result.query.limit,
|
|
538
|
+
candidateLimit: result.query.candidateLimit,
|
|
539
|
+
retrievalHealthy: retrievalHealthyOfResult(result),
|
|
540
|
+
groups,
|
|
541
|
+
warningCodes,
|
|
542
|
+
abstention:
|
|
543
|
+
result.groups.find((group) => group.relevanceDecision !== undefined)?.relevanceDecision
|
|
544
|
+
?.abstention ?? null,
|
|
545
|
+
};
|
|
546
|
+
assertValidObservationProjection(projection);
|
|
547
|
+
return projection;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function assertValidSessionState(session: SearchJourneyV1): void {
|
|
551
|
+
const issue = searchJourneyValidationIssue(session);
|
|
552
|
+
if (issue !== null) {
|
|
553
|
+
throw new UntangleError(
|
|
554
|
+
"SEARCH_SESSION_INVALID_STATE",
|
|
555
|
+
`search journey service produced an invalid state: ${issue}`,
|
|
556
|
+
{
|
|
557
|
+
operation: "search_journey_service",
|
|
558
|
+
context: { sessionId: session.sessionId, validationIssue: issue },
|
|
559
|
+
},
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function targetMatches(
|
|
565
|
+
candidate: SearchJourneyCandidateV1,
|
|
566
|
+
target: SearchJourneyTargetV1,
|
|
567
|
+
): boolean {
|
|
568
|
+
const fields: readonly (keyof SearchJourneyTargetV1)[] = [
|
|
569
|
+
"repoId",
|
|
570
|
+
"refId",
|
|
571
|
+
"filePath",
|
|
572
|
+
"chunkId",
|
|
573
|
+
"symbolId",
|
|
574
|
+
"embeddingProfileId",
|
|
575
|
+
"indexGeneration",
|
|
576
|
+
];
|
|
577
|
+
return fields.every((field) => target[field] === undefined || candidate[field] === target[field]);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function candidateKey(candidate: SearchJourneyCandidateV1): string {
|
|
581
|
+
return canonicalJson([
|
|
582
|
+
candidate.embeddingProfileId,
|
|
583
|
+
candidate.indexGeneration,
|
|
584
|
+
candidate.repoId,
|
|
585
|
+
candidate.refId,
|
|
586
|
+
candidate.chunkId,
|
|
587
|
+
]);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function candidateCodeEvidence(candidate: SearchJourneyCandidateV1): string {
|
|
591
|
+
return canonicalJson({
|
|
592
|
+
complexity: candidate.complexity ?? null,
|
|
593
|
+
incompletePayloadFields: candidate.incompletePayloadFields ?? null,
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function cloneSession(session: SearchJourneyV1): SearchJourneyV1 {
|
|
598
|
+
return structuredClone(session);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function journeyFingerprint(
|
|
602
|
+
fingerprintKey: Uint8Array,
|
|
603
|
+
workspaceId: string,
|
|
604
|
+
purpose:
|
|
605
|
+
| "feature"
|
|
606
|
+
| "goal"
|
|
607
|
+
| "query"
|
|
608
|
+
| "successful-observation"
|
|
609
|
+
| "failed-observation"
|
|
610
|
+
| "execution-context",
|
|
611
|
+
value: unknown,
|
|
612
|
+
): string {
|
|
613
|
+
return hmacSha256Hex(
|
|
614
|
+
fingerprintKey,
|
|
615
|
+
canonicalJson({
|
|
616
|
+
schemaVersion: 1,
|
|
617
|
+
namespace: "untangle-search-journey",
|
|
618
|
+
workspaceId,
|
|
619
|
+
purpose,
|
|
620
|
+
value,
|
|
621
|
+
}),
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function normalizeExecutionContext(
|
|
626
|
+
value: SearchJourneyExecutionContextInputV1 | undefined,
|
|
627
|
+
): SearchJourneyExecutionContextInputV1 | undefined {
|
|
628
|
+
if (value === undefined) return undefined;
|
|
629
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
630
|
+
throw new Error("executionContext must be an object of explicit caller labels");
|
|
631
|
+
}
|
|
632
|
+
if (
|
|
633
|
+
Object.keys(value).some(
|
|
634
|
+
(key) => !SEARCH_JOURNEY_EXECUTION_CONTEXT_DIMENSIONS.some((dimension) => dimension === key),
|
|
635
|
+
)
|
|
636
|
+
) {
|
|
637
|
+
throw new Error("executionContext contains an unknown dimension");
|
|
638
|
+
}
|
|
639
|
+
const normalized: Record<string, string> = {};
|
|
640
|
+
for (const dimension of SEARCH_JOURNEY_EXECUTION_CONTEXT_DIMENSIONS) {
|
|
641
|
+
const label = value[dimension];
|
|
642
|
+
if (label === undefined && (dimension === "taskMix" || dimension === "priorContext")) continue;
|
|
643
|
+
if (typeof label !== "string") {
|
|
644
|
+
throw new Error(`executionContext.${dimension} must be a non-empty string`);
|
|
645
|
+
}
|
|
646
|
+
normalized[dimension] = assertBoundedNonEmpty(
|
|
647
|
+
label,
|
|
648
|
+
`executionContext.${dimension}`,
|
|
649
|
+
MAX_SEARCH_JOURNEY_EXECUTION_CONTEXT_LENGTH,
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
return normalized as unknown as SearchJourneyExecutionContextInputV1;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function persistExecutionContext(
|
|
656
|
+
input: SearchJourneyExecutionContextInputV1,
|
|
657
|
+
fingerprintKey: Uint8Array,
|
|
658
|
+
workspaceId: string,
|
|
659
|
+
retainText: boolean,
|
|
660
|
+
): SearchJourneyExecutionContextV1 {
|
|
661
|
+
const fields = Object.fromEntries(
|
|
662
|
+
SEARCH_JOURNEY_EXECUTION_CONTEXT_DIMENSIONS.flatMap((dimension) => {
|
|
663
|
+
const label = input[dimension];
|
|
664
|
+
return label === undefined
|
|
665
|
+
? []
|
|
666
|
+
: [
|
|
667
|
+
[
|
|
668
|
+
dimension,
|
|
669
|
+
{
|
|
670
|
+
fingerprint: journeyFingerprint(fingerprintKey, workspaceId, "execution-context", {
|
|
671
|
+
dimension,
|
|
672
|
+
label,
|
|
673
|
+
}),
|
|
674
|
+
...(retainText ? { text: label } : {}),
|
|
675
|
+
},
|
|
676
|
+
],
|
|
677
|
+
];
|
|
678
|
+
}),
|
|
679
|
+
);
|
|
680
|
+
return {
|
|
681
|
+
schemaVersion: 1,
|
|
682
|
+
source: "caller-declared",
|
|
683
|
+
...fields,
|
|
684
|
+
} as SearchJourneyExecutionContextV1;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function executionContextEvidence(
|
|
688
|
+
session: SearchJourneyV1,
|
|
689
|
+
): SearchJourneyExecutionContextEvidenceV1 {
|
|
690
|
+
const context = session.executionContext;
|
|
691
|
+
if (context === undefined) {
|
|
692
|
+
return {
|
|
693
|
+
status: "not-recorded",
|
|
694
|
+
uncontrolledDimensions: [...SEARCH_JOURNEY_EXECUTION_CONTEXT_DIMENSIONS],
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
// Caller fingerprints are grouping evidence only after the persisted shape and retention contract pass.
|
|
698
|
+
assertValidSessionState(session);
|
|
699
|
+
const dimensions = SEARCH_JOURNEY_EXECUTION_CONTEXT_DIMENSIONS.map((dimension) => [
|
|
700
|
+
dimension,
|
|
701
|
+
context[dimension]?.fingerprint ?? null,
|
|
702
|
+
]);
|
|
703
|
+
return {
|
|
704
|
+
status: "caller-declared",
|
|
705
|
+
fingerprint: sha256Hex(canonicalJson({ schemaVersion: 1, dimensions })),
|
|
706
|
+
uncontrolledDimensions: SEARCH_JOURNEY_EXECUTION_CONTEXT_DIMENSIONS.filter(
|
|
707
|
+
(dimension) => context[dimension] === undefined,
|
|
708
|
+
),
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function reportContextId(session: SearchJourneyV1, retrievalContextId: string): string {
|
|
713
|
+
const execution = executionContextEvidence(session);
|
|
714
|
+
return execution.status === "not-recorded"
|
|
715
|
+
? retrievalContextId
|
|
716
|
+
: sha256Hex(
|
|
717
|
+
canonicalJson({
|
|
718
|
+
schemaVersion: 1,
|
|
719
|
+
retrievalContextId,
|
|
720
|
+
executionContext: execution.fingerprint,
|
|
721
|
+
}),
|
|
722
|
+
);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function journeyQueryFingerprint(
|
|
726
|
+
fingerprintKey: Uint8Array,
|
|
727
|
+
workspaceId: string,
|
|
728
|
+
query: string,
|
|
729
|
+
): string {
|
|
730
|
+
return journeyFingerprint(fingerprintKey, workspaceId, "query", query.trim());
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function assertClientStep(step: number | undefined): void {
|
|
734
|
+
if (
|
|
735
|
+
step !== undefined &&
|
|
736
|
+
(!Number.isSafeInteger(step) || step < 1 || step > MAX_SEARCH_JOURNEY_ATTEMPTS)
|
|
737
|
+
) {
|
|
738
|
+
throw new Error(
|
|
739
|
+
`search journey step must be an integer in [1, ${String(MAX_SEARCH_JOURNEY_ATTEMPTS)}]`,
|
|
740
|
+
);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function assertConsistentStepMode(
|
|
745
|
+
attempts: readonly SearchJourneyAttemptV1[],
|
|
746
|
+
step: number | undefined,
|
|
747
|
+
sessionId: string,
|
|
748
|
+
): void {
|
|
749
|
+
if (attempts.length === 0) return;
|
|
750
|
+
const numbered = attempts.filter((attempt) => attempt.clientStep !== undefined).length;
|
|
751
|
+
if (numbered !== 0 && numbered !== attempts.length) {
|
|
752
|
+
throw new UntangleError(
|
|
753
|
+
"SEARCH_SESSION_INVALID_STATE",
|
|
754
|
+
`search journey "${sessionId}" contains mixed numbered and unnumbered attempts`,
|
|
755
|
+
{ operation: "search_journey_record", context: { sessionId } },
|
|
756
|
+
);
|
|
757
|
+
}
|
|
758
|
+
const expectsStep = numbered === attempts.length;
|
|
759
|
+
if (expectsStep !== (step !== undefined)) {
|
|
760
|
+
throw new UntangleError(
|
|
761
|
+
"SEARCH_SESSION_INVALID_STATE",
|
|
762
|
+
`search journey "${sessionId}" cannot mix numbered and unnumbered attempts`,
|
|
763
|
+
{ operation: "search_journey_record", context: { sessionId } },
|
|
764
|
+
);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function sortAttempts(attempts: SearchJourneyAttemptV1[]): void {
|
|
769
|
+
attempts.sort((left, right) => {
|
|
770
|
+
return (
|
|
771
|
+
(left.clientStep ?? left.sequence) - (right.clientStep ?? right.sequence) ||
|
|
772
|
+
compareCodepoints(left.observedAt, right.observedAt) ||
|
|
773
|
+
compareCodepoints(left.attemptId, right.attemptId)
|
|
774
|
+
);
|
|
775
|
+
});
|
|
776
|
+
attempts.forEach((attempt, index) => {
|
|
777
|
+
attempt.sequence = index + 1;
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function retrievalHealthyOfResult(result: SearchResultSetV1): boolean {
|
|
782
|
+
return (
|
|
783
|
+
result.groups.length > 0 &&
|
|
784
|
+
result.groups.every((group) => group.notSearchedReason === undefined) &&
|
|
785
|
+
!result.warnings.some(
|
|
786
|
+
(warning) =>
|
|
787
|
+
warning.code === INDEX_CONTEXT_INCOMPLETE_WARNING_CODE ||
|
|
788
|
+
warning.code === "SEARCH_BRANCH_UNAVAILABLE" ||
|
|
789
|
+
warning.code === "INCOMPLETE_PAYLOAD",
|
|
790
|
+
) &&
|
|
791
|
+
!result.skipped.some(
|
|
792
|
+
(entry) =>
|
|
793
|
+
entry.subjectKind === "search_candidate" &&
|
|
794
|
+
(entry.reason === "provider_unavailable" || entry.reason === "missing_index_entry"),
|
|
795
|
+
)
|
|
796
|
+
);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function observationFingerprintOfProjection(
|
|
800
|
+
fingerprintKey: Uint8Array,
|
|
801
|
+
workspaceId: string,
|
|
802
|
+
projection: SearchObservationProjection,
|
|
803
|
+
includeCodeEvidence = true,
|
|
804
|
+
): string {
|
|
805
|
+
return journeyFingerprint(fingerprintKey, workspaceId, "successful-observation", {
|
|
806
|
+
queryFingerprint: projection.queryFingerprint,
|
|
807
|
+
queryText: projection.queryText,
|
|
808
|
+
rankerVersion: projection.rankerVersion,
|
|
809
|
+
rankingContextFingerprint: projection.rankingContextFingerprint,
|
|
810
|
+
resultLimit: projection.resultLimit,
|
|
811
|
+
candidateLimit: projection.candidateLimit,
|
|
812
|
+
retrievalHealthy: projection.retrievalHealthy,
|
|
813
|
+
groups: projection.groups.map((group) => ({
|
|
814
|
+
embeddingProfileId: group.embeddingProfileId,
|
|
815
|
+
indexGenerations: group.indexGenerations,
|
|
816
|
+
...(includeCodeEvidence
|
|
817
|
+
? { corpusRevisionFingerprint: group.corpusRevisionFingerprint }
|
|
818
|
+
: {}),
|
|
819
|
+
notSearchedReason: group.notSearchedReason,
|
|
820
|
+
retrieval: group.retrieval,
|
|
821
|
+
hits: group.hits.map((hit) => ({
|
|
822
|
+
repoId: hit.repoId,
|
|
823
|
+
refId: hit.refId,
|
|
824
|
+
filePath: hit.filePath,
|
|
825
|
+
chunkId: hit.chunkId,
|
|
826
|
+
symbolId: hit.symbolId,
|
|
827
|
+
symbolFqn: hit.symbolFqn,
|
|
828
|
+
indexGeneration: hit.indexGeneration,
|
|
829
|
+
rank: hit.rank,
|
|
830
|
+
score: hit.score,
|
|
831
|
+
...(includeCodeEvidence
|
|
832
|
+
? {
|
|
833
|
+
complexity: hit.complexity,
|
|
834
|
+
incompletePayloadFields: hit.incompletePayloadFields,
|
|
835
|
+
}
|
|
836
|
+
: {}),
|
|
837
|
+
})),
|
|
838
|
+
})),
|
|
839
|
+
warningCodes: projection.warningCodes,
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
function compareCandidates(
|
|
844
|
+
left: SearchJourneyCandidateV1,
|
|
845
|
+
right: SearchJourneyCandidateV1,
|
|
846
|
+
): number {
|
|
847
|
+
return (
|
|
848
|
+
left.rank - right.rank ||
|
|
849
|
+
compareCodepoints(left.embeddingProfileId, right.embeddingProfileId) ||
|
|
850
|
+
compareCodepoints(left.indexGeneration, right.indexGeneration) ||
|
|
851
|
+
compareCodepoints(left.repoId, right.repoId) ||
|
|
852
|
+
compareCodepoints(left.refId, right.refId) ||
|
|
853
|
+
compareCodepoints(left.filePath, right.filePath) ||
|
|
854
|
+
compareCodepoints(left.chunkId, right.chunkId)
|
|
855
|
+
);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function targetCodeComplexityEvidence(
|
|
859
|
+
acceptedCandidates: readonly SearchJourneyCandidateV1[],
|
|
860
|
+
accepted: SearchJourneyCandidateV1 | null,
|
|
861
|
+
): SearchJourneyTargetCodeComplexityEvidenceV1 {
|
|
862
|
+
if (acceptedCandidates.length === 0) {
|
|
863
|
+
return { status: "unavailable", reason: "target-not-returned" };
|
|
864
|
+
}
|
|
865
|
+
if (
|
|
866
|
+
accepted === null ||
|
|
867
|
+
new Set(acceptedCandidates.map((candidate) => candidateKey(candidate))).size !== 1 ||
|
|
868
|
+
new Set(acceptedCandidates.map((candidate) => candidateCodeEvidence(candidate))).size !== 1
|
|
869
|
+
) {
|
|
870
|
+
return { status: "unavailable", reason: "target-context-ambiguous" };
|
|
871
|
+
}
|
|
872
|
+
// New observations always carry this field, including the empty array.
|
|
873
|
+
// Its absence therefore means that an older persisted candidate cannot
|
|
874
|
+
// prove whether substituted payload values were involved.
|
|
875
|
+
if (accepted.incompletePayloadFields === undefined) {
|
|
876
|
+
return { status: "unavailable", reason: "legacy-observation" };
|
|
877
|
+
}
|
|
878
|
+
if (accepted.complexity === undefined) {
|
|
879
|
+
return { status: "unavailable", reason: "complexity-not-indexed" };
|
|
880
|
+
}
|
|
881
|
+
const incompletePayloadFields = [...new Set(accepted.incompletePayloadFields)].sort(
|
|
882
|
+
compareCodepoints,
|
|
883
|
+
);
|
|
884
|
+
return {
|
|
885
|
+
status: "observed",
|
|
886
|
+
source: "indexed-search-hit",
|
|
887
|
+
candidate: {
|
|
888
|
+
repoId: accepted.repoId,
|
|
889
|
+
refId: accepted.refId,
|
|
890
|
+
filePath: accepted.filePath,
|
|
891
|
+
chunkId: accepted.chunkId,
|
|
892
|
+
...(accepted.symbolId === undefined ? {} : { symbolId: accepted.symbolId }),
|
|
893
|
+
...(accepted.symbolFqn === undefined ? {} : { symbolFqn: accepted.symbolFqn }),
|
|
894
|
+
embeddingProfileId: accepted.embeddingProfileId,
|
|
895
|
+
indexGeneration: accepted.indexGeneration,
|
|
896
|
+
},
|
|
897
|
+
complexity: structuredClone(accepted.complexity),
|
|
898
|
+
payloadComplete: incompletePayloadFields.length === 0,
|
|
899
|
+
incompletePayloadFields,
|
|
900
|
+
};
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function attemptContextKey(attempt: SearchJourneyAttemptV1): string {
|
|
904
|
+
const profiles = attempt.profiles
|
|
905
|
+
.map((profile) => ({
|
|
906
|
+
embeddingProfileId: profile.embeddingProfileId,
|
|
907
|
+
indexGenerations:
|
|
908
|
+
profile.indexGenerations.length === 0
|
|
909
|
+
? ["unknown"]
|
|
910
|
+
: [...profile.indexGenerations].sort(compareCodepoints),
|
|
911
|
+
corpusRevisionFingerprint: profile.corpusRevisionFingerprint ?? "unknown",
|
|
912
|
+
}))
|
|
913
|
+
.sort((left, right) => compareCodepoints(canonicalJson(left), canonicalJson(right)));
|
|
914
|
+
return canonicalJson({
|
|
915
|
+
rankerVersion: attempt.rankerVersion,
|
|
916
|
+
rankingContextFingerprint: attempt.rankingContextFingerprint ?? "unknown",
|
|
917
|
+
resultLimit: attempt.resultLimit ?? "unknown",
|
|
918
|
+
candidateLimit: attempt.candidateLimit ?? "unknown",
|
|
919
|
+
profiles,
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
function legacyContextId(attempts: readonly SearchJourneyAttemptV1[]): string {
|
|
924
|
+
const rankers = [...new Set(attempts.map((attempt) => attempt.rankerVersion))].sort(
|
|
925
|
+
compareCodepoints,
|
|
926
|
+
);
|
|
927
|
+
const profiles = [
|
|
928
|
+
...new Set(
|
|
929
|
+
attempts.flatMap((attempt) =>
|
|
930
|
+
attempt.profiles.flatMap((profile) =>
|
|
931
|
+
profile.indexGenerations.length === 0
|
|
932
|
+
? [`${profile.embeddingProfileId}\u001funknown`]
|
|
933
|
+
: profile.indexGenerations.map(
|
|
934
|
+
(generation) => `${profile.embeddingProfileId}\u001f${generation}`,
|
|
935
|
+
),
|
|
936
|
+
),
|
|
937
|
+
),
|
|
938
|
+
),
|
|
939
|
+
].sort(compareCodepoints);
|
|
940
|
+
const rankingContexts = [
|
|
941
|
+
...new Set(attempts.map((attempt) => attempt.rankingContextFingerprint ?? "unknown")),
|
|
942
|
+
].sort(compareCodepoints);
|
|
943
|
+
const limits = [
|
|
944
|
+
...new Set(
|
|
945
|
+
attempts.map(
|
|
946
|
+
(attempt) =>
|
|
947
|
+
`${String(attempt.resultLimit ?? "unknown")}\u001f${String(
|
|
948
|
+
attempt.candidateLimit ?? "unknown",
|
|
949
|
+
)}`,
|
|
950
|
+
),
|
|
951
|
+
),
|
|
952
|
+
].sort(compareCodepoints);
|
|
953
|
+
return sha256Hex(canonicalJson({ rankers, profiles, rankingContexts, limits }));
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
function contextId(attempts: readonly SearchJourneyAttemptV1[]): string {
|
|
957
|
+
const contextualAttempts = attempts.some((attempt) => attempt.status === "succeeded")
|
|
958
|
+
? attempts.filter((attempt) => attempt.status === "succeeded")
|
|
959
|
+
: attempts;
|
|
960
|
+
if (contextualAttempts.every((attempt) => attempt.measurementContextVersion === undefined)) {
|
|
961
|
+
return legacyContextId(contextualAttempts);
|
|
962
|
+
}
|
|
963
|
+
const attemptContexts = [...new Set(contextualAttempts.map(attemptContextKey))].sort(
|
|
964
|
+
compareCodepoints,
|
|
965
|
+
);
|
|
966
|
+
return sha256Hex(canonicalJson({ schemaVersion: 2, attemptContexts }));
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
function measurementContextComplete(attempts: readonly SearchJourneyAttemptV1[]): boolean {
|
|
970
|
+
const versioned = attempts.some((attempt) => attempt.measurementContextVersion !== undefined);
|
|
971
|
+
return (
|
|
972
|
+
attempts.length > 0 &&
|
|
973
|
+
attempts.every(
|
|
974
|
+
(attempt) =>
|
|
975
|
+
(!versioned || attempt.measurementContextVersion === 2) &&
|
|
976
|
+
attempt.status === "succeeded" &&
|
|
977
|
+
attempt.retrievalHealthy &&
|
|
978
|
+
attempt.rankingContextFingerprint !== undefined &&
|
|
979
|
+
attempt.resultLimit !== undefined &&
|
|
980
|
+
attempt.candidateLimit !== undefined &&
|
|
981
|
+
attempt.profiles.length > 0 &&
|
|
982
|
+
attempt.profiles.every(
|
|
983
|
+
(profile) =>
|
|
984
|
+
(!versioned || profile.corpusRevisionFingerprint !== undefined) &&
|
|
985
|
+
profile.retrieval !== undefined &&
|
|
986
|
+
profile.indexGenerations.length > 0 &&
|
|
987
|
+
profile.indexGenerations.every((generation) => generation !== "unknown"),
|
|
988
|
+
),
|
|
989
|
+
) &&
|
|
990
|
+
new Set(attempts.map(attemptContextKey)).size === 1
|
|
991
|
+
);
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
function attemptHasRetrieval(attempt: SearchJourneyAttemptV1): boolean {
|
|
995
|
+
return (
|
|
996
|
+
attempt.status === "succeeded" && attempt.profiles.some((profile) => profile.retrievalPerformed)
|
|
997
|
+
);
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
function jaccardDistance(left: ReadonlySet<string>, right: ReadonlySet<string>): number {
|
|
1001
|
+
const union = new Set([...left, ...right]);
|
|
1002
|
+
if (union.size === 0) return 0;
|
|
1003
|
+
let intersection = 0;
|
|
1004
|
+
for (const value of left) if (right.has(value)) intersection++;
|
|
1005
|
+
return 1 - intersection / union.size;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
/** Berechnet die erklaerbaren Komponenten einer abgeschlossenen Journey. */
|
|
1009
|
+
export function assessSearchJourney(
|
|
1010
|
+
session: SearchJourneyV1,
|
|
1011
|
+
input: CompleteSearchJourneyInput,
|
|
1012
|
+
): DynamicSearchComplexityV1 {
|
|
1013
|
+
const target = normalizedTarget(input.target);
|
|
1014
|
+
if (session.attempts.length === 0) {
|
|
1015
|
+
throw new Error("search journey cannot be completed before at least one search attempt");
|
|
1016
|
+
}
|
|
1017
|
+
const numberedSteps = session.attempts.flatMap((attempt) =>
|
|
1018
|
+
attempt.clientStep === undefined ? [] : [attempt.clientStep],
|
|
1019
|
+
);
|
|
1020
|
+
if (numberedSteps.length > 0) {
|
|
1021
|
+
const maximum = Math.max(...numberedSteps);
|
|
1022
|
+
const present = new Set(numberedSteps);
|
|
1023
|
+
const missing = Array.from({ length: maximum }, (_, index) => index + 1).filter(
|
|
1024
|
+
(step) => !present.has(step),
|
|
1025
|
+
);
|
|
1026
|
+
if (missing.length > 0) {
|
|
1027
|
+
throw new Error(
|
|
1028
|
+
`search journey cannot be completed with missing client step(s): ${missing.join(", ")}`,
|
|
1029
|
+
);
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
const requestedAttempt =
|
|
1034
|
+
input.acceptedAttemptId === undefined
|
|
1035
|
+
? undefined
|
|
1036
|
+
: session.attempts.find((attempt) => attempt.attemptId === input.acceptedAttemptId);
|
|
1037
|
+
if (input.acceptedAttemptId !== undefined && requestedAttempt === undefined) {
|
|
1038
|
+
throw new Error(`search journey has no attempt "${input.acceptedAttemptId}"`);
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
const attemptsWithTarget = session.attempts.filter((attempt) =>
|
|
1042
|
+
attempt.profiles.some((profile) =>
|
|
1043
|
+
profile.candidates.some((candidate) => targetMatches(candidate, target)),
|
|
1044
|
+
),
|
|
1045
|
+
);
|
|
1046
|
+
if (
|
|
1047
|
+
requestedAttempt !== undefined &&
|
|
1048
|
+
!attemptsWithTarget.some((attempt) => attempt.attemptId === requestedAttempt.attemptId)
|
|
1049
|
+
) {
|
|
1050
|
+
throw new Error(
|
|
1051
|
+
`accepted attempt "${requestedAttempt.attemptId}" does not contain the confirmed target`,
|
|
1052
|
+
);
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
// Der letzte passende Versuch entspricht ohne explizite Auswahl am ehesten
|
|
1056
|
+
// dem Treffer, den der Agent gerade akzeptiert hat. Bei expliziter Auswahl
|
|
1057
|
+
// zaehlen nur Versuche bis zu diesem Schritt: spaeter/out-of-order
|
|
1058
|
+
// eingetroffene Beobachtungen duerfen die Suche bis zum Ziel nicht aufblasen.
|
|
1059
|
+
const acceptedAttempt =
|
|
1060
|
+
requestedAttempt ?? attemptsWithTarget[attemptsWithTarget.length - 1] ?? null;
|
|
1061
|
+
const attemptsToTarget =
|
|
1062
|
+
requestedAttempt === undefined
|
|
1063
|
+
? session.attempts
|
|
1064
|
+
: session.attempts.filter((attempt) => attempt.sequence <= requestedAttempt.sequence);
|
|
1065
|
+
const acceptedCandidates =
|
|
1066
|
+
acceptedAttempt === null
|
|
1067
|
+
? []
|
|
1068
|
+
: acceptedAttempt.profiles
|
|
1069
|
+
.flatMap((profile) => profile.candidates)
|
|
1070
|
+
.filter((candidate) => targetMatches(candidate, target))
|
|
1071
|
+
.sort(compareCandidates);
|
|
1072
|
+
const acceptedContexts = new Set(
|
|
1073
|
+
acceptedCandidates.map(
|
|
1074
|
+
(candidate) => `${candidate.embeddingProfileId}\u001f${candidate.indexGeneration}`,
|
|
1075
|
+
),
|
|
1076
|
+
);
|
|
1077
|
+
const targetWasReturned = acceptedCandidates.length > 0;
|
|
1078
|
+
const accepted = acceptedContexts.size <= 1 ? (acceptedCandidates[0] ?? null) : null;
|
|
1079
|
+
const acceptedTargetCodeComplexity = targetCodeComplexityEvidence(acceptedCandidates, accepted);
|
|
1080
|
+
const firstReturnedAttempt =
|
|
1081
|
+
attemptsWithTarget.find(
|
|
1082
|
+
(attempt) => acceptedAttempt === null || attempt.sequence <= acceptedAttempt.sequence,
|
|
1083
|
+
) ?? null;
|
|
1084
|
+
|
|
1085
|
+
const distinctQueries = new Set(attemptsToTarget.map((attempt) => attempt.queryFingerprint));
|
|
1086
|
+
const allCandidates = attemptsToTarget.flatMap((attempt) =>
|
|
1087
|
+
attempt.profiles.flatMap((profile) => profile.candidates),
|
|
1088
|
+
);
|
|
1089
|
+
const uniqueCandidates = new Map(
|
|
1090
|
+
allCandidates.map((candidate) => [candidateKey(candidate), candidate]),
|
|
1091
|
+
);
|
|
1092
|
+
const uniqueFiles = new Set(
|
|
1093
|
+
allCandidates.map(
|
|
1094
|
+
(candidate) => `${candidate.repoId}\u001f${candidate.refId}\u001f${candidate.filePath}`,
|
|
1095
|
+
),
|
|
1096
|
+
);
|
|
1097
|
+
const retrievals = attemptsToTarget.flatMap((attempt) =>
|
|
1098
|
+
attempt.profiles.flatMap((profile) =>
|
|
1099
|
+
profile.retrieval === undefined ? [] : [profile.retrieval],
|
|
1100
|
+
),
|
|
1101
|
+
);
|
|
1102
|
+
const eligibleCandidatesExamined = retrievals.reduce(
|
|
1103
|
+
(sum, retrieval) => sum + retrieval.eligibleCandidateCount,
|
|
1104
|
+
0,
|
|
1105
|
+
);
|
|
1106
|
+
const dispersionWeight = retrievals.reduce(
|
|
1107
|
+
(sum, retrieval) => sum + retrieval.eligibleCandidateCount,
|
|
1108
|
+
0,
|
|
1109
|
+
);
|
|
1110
|
+
const meanCandidateFileDispersion =
|
|
1111
|
+
dispersionWeight === 0
|
|
1112
|
+
? null
|
|
1113
|
+
: retrievals.reduce(
|
|
1114
|
+
(sum, retrieval) =>
|
|
1115
|
+
sum + retrieval.eligibleFileDispersion * retrieval.eligibleCandidateCount,
|
|
1116
|
+
0,
|
|
1117
|
+
) / dispersionWeight;
|
|
1118
|
+
const retrievedAttempts = attemptsToTarget.filter(attemptHasRetrieval);
|
|
1119
|
+
const candidateLimitHitRate =
|
|
1120
|
+
retrievedAttempts.length === 0
|
|
1121
|
+
? 0
|
|
1122
|
+
: retrievedAttempts.filter((attempt) =>
|
|
1123
|
+
attempt.profiles.some((profile) => profile.retrieval?.candidateLimitReached === true),
|
|
1124
|
+
).length / retrievedAttempts.length;
|
|
1125
|
+
const successfulCandidateSets = attemptsToTarget.flatMap((attempt) =>
|
|
1126
|
+
!attemptHasRetrieval(attempt)
|
|
1127
|
+
? []
|
|
1128
|
+
: [
|
|
1129
|
+
new Set(
|
|
1130
|
+
attempt.profiles.flatMap((profile) =>
|
|
1131
|
+
profile.candidates.map((candidate) => candidateKey(candidate)),
|
|
1132
|
+
),
|
|
1133
|
+
),
|
|
1134
|
+
],
|
|
1135
|
+
);
|
|
1136
|
+
const churnPairs = successfulCandidateSets
|
|
1137
|
+
.slice(1)
|
|
1138
|
+
.map((current, index) =>
|
|
1139
|
+
jaccardDistance(successfulCandidateSets[index] as ReadonlySet<string>, current),
|
|
1140
|
+
);
|
|
1141
|
+
const meanCandidateChurn = churnPairs.length === 0 ? null : mean(churnPairs);
|
|
1142
|
+
const successfulSearchesToTarget = attemptsToTarget.filter(
|
|
1143
|
+
(attempt) => attempt.status === "succeeded",
|
|
1144
|
+
).length;
|
|
1145
|
+
const failedSearchesToTarget = attemptsToTarget.length - successfulSearchesToTarget;
|
|
1146
|
+
const healthyRetrievalRate =
|
|
1147
|
+
attemptsToTarget.length === 0
|
|
1148
|
+
? 0
|
|
1149
|
+
: attemptsToTarget.filter((attempt) => attempt.retrievalHealthy).length /
|
|
1150
|
+
attemptsToTarget.length;
|
|
1151
|
+
|
|
1152
|
+
let acceptedTargetScoreMargin: number | null = null;
|
|
1153
|
+
if (accepted !== null && acceptedAttempt !== null) {
|
|
1154
|
+
const profile = acceptedAttempt.profiles.find(
|
|
1155
|
+
(entry) => entry.embeddingProfileId === accepted.embeddingProfileId,
|
|
1156
|
+
);
|
|
1157
|
+
const sameProfile = (profile?.candidates ?? []).filter(
|
|
1158
|
+
(candidate) => candidate.indexGeneration === accepted.indexGeneration,
|
|
1159
|
+
);
|
|
1160
|
+
const competitor = sameProfile
|
|
1161
|
+
.filter((candidate) => candidateKey(candidate) !== candidateKey(accepted))
|
|
1162
|
+
.sort((left, right) => right.score - left.score || compareCandidates(left, right))[0];
|
|
1163
|
+
if (competitor !== undefined) acceptedTargetScoreMargin = accepted.score - competitor.score;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
const caveats = [
|
|
1167
|
+
"This is a heuristic observation of one task, not cognitive complexity.",
|
|
1168
|
+
"A confirmed target labels one accepted result; it does not label every candidate, so precision is not claimed.",
|
|
1169
|
+
"Observed target code complexity is a separate static evidence axis; it is not combined with retrieval effort into a score or a causal claim.",
|
|
1170
|
+
session.executionContext === undefined
|
|
1171
|
+
? "Agent model, harness, prior context and search strategy are not captured for this session; compare observations only under externally controlled caller conditions."
|
|
1172
|
+
: "Execution context was explicitly caller-declared at journey start; report cohorts segment those fingerprints without independently verifying the declarations or asserting causation.",
|
|
1173
|
+
];
|
|
1174
|
+
if (!targetWasReturned) {
|
|
1175
|
+
caveats.push(
|
|
1176
|
+
"The confirmed target was not present in a returned result; rank, reciprocal rank and score margin are unavailable.",
|
|
1177
|
+
);
|
|
1178
|
+
}
|
|
1179
|
+
if (session.navigation.length === 0) {
|
|
1180
|
+
caveats.push(
|
|
1181
|
+
"No navigation events were reported; observedToolAndFileHops contains search calls only.",
|
|
1182
|
+
);
|
|
1183
|
+
}
|
|
1184
|
+
if (retrievals.length === 0) {
|
|
1185
|
+
caveats.push(
|
|
1186
|
+
"No retrieval diagnostics were available; eligible-candidate and dispersion indicators are incomplete.",
|
|
1187
|
+
);
|
|
1188
|
+
}
|
|
1189
|
+
if (attemptsToTarget.some((attempt) => attempt.status === "failed")) {
|
|
1190
|
+
caveats.push(
|
|
1191
|
+
"At least one search attempt failed; failures remain counted as search effort and their effective retrieval limits are unavailable.",
|
|
1192
|
+
);
|
|
1193
|
+
}
|
|
1194
|
+
if (acceptedContexts.size > 1) {
|
|
1195
|
+
caveats.push(
|
|
1196
|
+
"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.",
|
|
1197
|
+
);
|
|
1198
|
+
}
|
|
1199
|
+
if (acceptedTargetCodeComplexity.status === "unavailable") {
|
|
1200
|
+
caveats.push(
|
|
1201
|
+
`Static code complexity for the confirmed target is unavailable (${acceptedTargetCodeComplexity.reason}); no value was inferred.`,
|
|
1202
|
+
);
|
|
1203
|
+
} else if (!acceptedTargetCodeComplexity.payloadComplete) {
|
|
1204
|
+
caveats.push(
|
|
1205
|
+
"The confirmed target carries static code metrics, but its search payload used documented substitutes; inspect incompletePayloadFields before comparison.",
|
|
1206
|
+
);
|
|
1207
|
+
}
|
|
1208
|
+
if (requestedAttempt !== undefined && requestedAttempt.sequence < session.attempts.length) {
|
|
1209
|
+
caveats.push(
|
|
1210
|
+
"Search metrics stop at the explicitly accepted attempt; independently ordered navigation events still include every event reported before completion.",
|
|
1211
|
+
);
|
|
1212
|
+
}
|
|
1213
|
+
if (requestedAttempt === undefined && acceptedAttempt !== null) {
|
|
1214
|
+
caveats.push(
|
|
1215
|
+
"No acceptedAttemptId was supplied; every search through completion remains counted, while target rank and score margin use the last matching attempt.",
|
|
1216
|
+
);
|
|
1217
|
+
}
|
|
1218
|
+
const contextComplete = measurementContextComplete(attemptsToTarget);
|
|
1219
|
+
if (!contextComplete) {
|
|
1220
|
+
caveats.push(
|
|
1221
|
+
"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.",
|
|
1222
|
+
);
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
return {
|
|
1226
|
+
schemaVersion: 1,
|
|
1227
|
+
algorithm: DYNAMIC_SEARCH_COMPLEXITY_ALGORITHM_ID,
|
|
1228
|
+
isHeuristic: true,
|
|
1229
|
+
searchesToTarget: attemptsToTarget.length,
|
|
1230
|
+
successfulSearchesToTarget,
|
|
1231
|
+
failedSearchesToTarget,
|
|
1232
|
+
distinctQueryCount: distinctQueries.size,
|
|
1233
|
+
queryReformulations: Math.max(0, distinctQueries.size - 1),
|
|
1234
|
+
acceptedTargetRank: accepted?.rank ?? null,
|
|
1235
|
+
reciprocalRank: accepted === null ? null : 1 / accepted.rank,
|
|
1236
|
+
targetWasReturned,
|
|
1237
|
+
targetFirstReturnedAtSearch: firstReturnedAttempt?.sequence ?? null,
|
|
1238
|
+
targetAcceptedAtSearch: acceptedAttempt?.sequence ?? null,
|
|
1239
|
+
selectionLagSearches:
|
|
1240
|
+
acceptedAttempt === null || firstReturnedAttempt === null
|
|
1241
|
+
? null
|
|
1242
|
+
: acceptedAttempt.sequence - firstReturnedAttempt.sequence,
|
|
1243
|
+
directHit:
|
|
1244
|
+
attemptsToTarget.length === 1 && acceptedAttempt?.sequence === 1 && accepted?.rank === 1,
|
|
1245
|
+
eligibleCandidatesExamined,
|
|
1246
|
+
uniqueCandidatesExposed: uniqueCandidates.size,
|
|
1247
|
+
uniqueFilesExposed: uniqueFiles.size,
|
|
1248
|
+
navigationEventsToTarget: session.navigation.length,
|
|
1249
|
+
observedToolAndFileHops: attemptsToTarget.length + session.navigation.length,
|
|
1250
|
+
meanCandidateFileDispersion,
|
|
1251
|
+
meanCandidateChurn,
|
|
1252
|
+
candidateLimitHitRate,
|
|
1253
|
+
healthyRetrievalRate,
|
|
1254
|
+
acceptedTargetScoreMargin,
|
|
1255
|
+
acceptedTargetCodeComplexity,
|
|
1256
|
+
measurementContextId: contextId(attemptsToTarget),
|
|
1257
|
+
measurementContextComplete: contextComplete,
|
|
1258
|
+
caveats,
|
|
1259
|
+
};
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
function quantile(values: readonly number[], fraction: number): number {
|
|
1263
|
+
if (values.length === 0) throw new Error("quantile needs at least one value");
|
|
1264
|
+
const ordered = [...values].sort((left, right) => left - right);
|
|
1265
|
+
const index = Math.max(0, Math.ceil(fraction * ordered.length) - 1);
|
|
1266
|
+
return ordered[index] as number;
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
function median(values: readonly number[]): number {
|
|
1270
|
+
const ordered = [...values].sort((left, right) => left - right);
|
|
1271
|
+
const middle = Math.floor(ordered.length / 2);
|
|
1272
|
+
if (ordered.length % 2 === 1) return ordered[middle] as number;
|
|
1273
|
+
return ((ordered[middle - 1] as number) + (ordered[middle] as number)) / 2;
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
function mean(values: readonly number[]): number {
|
|
1277
|
+
return values.reduce((sum, value) => sum + value, 0) / values.length;
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
function buildCohort(
|
|
1281
|
+
measurementContextId: string,
|
|
1282
|
+
assessments: readonly DynamicSearchComplexityV1[],
|
|
1283
|
+
executionContext: SearchJourneyExecutionContextEvidenceV1,
|
|
1284
|
+
): DynamicProductComplexityCohortV1 {
|
|
1285
|
+
const ranks = assessments.flatMap((assessment) =>
|
|
1286
|
+
assessment.acceptedTargetRank === null ? [] : [assessment.acceptedTargetRank],
|
|
1287
|
+
);
|
|
1288
|
+
const reciprocalRanks = assessments.flatMap((assessment) =>
|
|
1289
|
+
assessment.reciprocalRank === null ? [] : [assessment.reciprocalRank],
|
|
1290
|
+
);
|
|
1291
|
+
const dispersions = assessments.flatMap((assessment) =>
|
|
1292
|
+
assessment.meanCandidateFileDispersion === null ? [] : [assessment.meanCandidateFileDispersion],
|
|
1293
|
+
);
|
|
1294
|
+
const churn = assessments.flatMap((assessment) =>
|
|
1295
|
+
assessment.meanCandidateChurn === null ? [] : [assessment.meanCandidateChurn],
|
|
1296
|
+
);
|
|
1297
|
+
const contextComplete = assessments.every((assessment) => assessment.measurementContextComplete);
|
|
1298
|
+
const sufficientEvidence =
|
|
1299
|
+
contextComplete && assessments.length >= MINIMUM_PRODUCT_COMPLEXITY_SESSIONS;
|
|
1300
|
+
return {
|
|
1301
|
+
measurementContextId,
|
|
1302
|
+
completedSessionCount: assessments.length,
|
|
1303
|
+
measurementContextComplete: contextComplete,
|
|
1304
|
+
sufficientEvidence,
|
|
1305
|
+
minimumCompletedSessions: MINIMUM_PRODUCT_COMPLEXITY_SESSIONS,
|
|
1306
|
+
executionContext,
|
|
1307
|
+
callerConditionsControlled: executionContext.uncontrolledDimensions.length === 0,
|
|
1308
|
+
medianSearchesToTarget: median(assessments.map((assessment) => assessment.searchesToTarget)),
|
|
1309
|
+
p90SearchesToTarget: quantile(
|
|
1310
|
+
assessments.map((assessment) => assessment.searchesToTarget),
|
|
1311
|
+
0.9,
|
|
1312
|
+
),
|
|
1313
|
+
medianQueryReformulations: median(
|
|
1314
|
+
assessments.map((assessment) => assessment.queryReformulations),
|
|
1315
|
+
),
|
|
1316
|
+
medianAcceptedTargetRank: ranks.length === 0 ? null : median(ranks),
|
|
1317
|
+
meanReciprocalRank: reciprocalRanks.length === 0 ? null : mean(reciprocalRanks),
|
|
1318
|
+
targetReturnedRate:
|
|
1319
|
+
assessments.filter((assessment) => assessment.targetWasReturned).length / assessments.length,
|
|
1320
|
+
directHitRate:
|
|
1321
|
+
assessments.filter((assessment) => assessment.directHit).length / assessments.length,
|
|
1322
|
+
healthyRetrievalRate: mean(assessments.map((assessment) => assessment.healthyRetrievalRate)),
|
|
1323
|
+
medianEligibleCandidatesExamined: median(
|
|
1324
|
+
assessments.map((assessment) => assessment.eligibleCandidatesExamined),
|
|
1325
|
+
),
|
|
1326
|
+
medianUniqueFilesExposed: median(
|
|
1327
|
+
assessments.map((assessment) => assessment.uniqueFilesExposed),
|
|
1328
|
+
),
|
|
1329
|
+
meanCandidateFileDispersion: dispersions.length === 0 ? null : mean(dispersions),
|
|
1330
|
+
meanCandidateChurn: churn.length === 0 ? null : mean(churn),
|
|
1331
|
+
candidateLimitHitRate: mean(assessments.map((assessment) => assessment.candidateLimitHitRate)),
|
|
1332
|
+
caveats: [
|
|
1333
|
+
...(!contextComplete
|
|
1334
|
+
? [
|
|
1335
|
+
"At least one observation has an incomplete effective retrieval context; this cohort cannot be treated as sufficient product-level evidence.",
|
|
1336
|
+
]
|
|
1337
|
+
: sufficientEvidence
|
|
1338
|
+
? [
|
|
1339
|
+
"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.",
|
|
1340
|
+
]
|
|
1341
|
+
: [
|
|
1342
|
+
`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.`,
|
|
1343
|
+
]),
|
|
1344
|
+
executionContext.status === "not-recorded"
|
|
1345
|
+
? "Caller execution conditions were not recorded; retrieval sufficiency does not establish comparable agent behavior."
|
|
1346
|
+
: "Execution conditions are caller-declared, not independently verified; equal labels do not establish a causal product effect.",
|
|
1347
|
+
...(executionContext.uncontrolledDimensions.length === 0
|
|
1348
|
+
? []
|
|
1349
|
+
: [
|
|
1350
|
+
`Uncontrolled execution dimensions: ${executionContext.uncontrolledDimensions.join(", ")}.`,
|
|
1351
|
+
]),
|
|
1352
|
+
],
|
|
1353
|
+
};
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
interface CompletedFeatureObservation {
|
|
1357
|
+
goalFingerprint: string;
|
|
1358
|
+
target: SearchJourneyTargetV1;
|
|
1359
|
+
assessment: DynamicSearchComplexityV1;
|
|
1360
|
+
executionContext: SearchJourneyExecutionContextEvidenceV1;
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
function reportAssessment(session: SearchJourneyV1): DynamicSearchComplexityV1 {
|
|
1364
|
+
if (session.outcome?.kind !== "completed") {
|
|
1365
|
+
throw new Error("report assessment requires a completed search journey");
|
|
1366
|
+
}
|
|
1367
|
+
const assessment = session.outcome.assessment;
|
|
1368
|
+
const contextualAssessment = {
|
|
1369
|
+
...assessment,
|
|
1370
|
+
measurementContextId: reportContextId(session, assessment.measurementContextId),
|
|
1371
|
+
};
|
|
1372
|
+
if (session.attempts.every((attempt) => attempt.measurementContextVersion === 2)) {
|
|
1373
|
+
return contextualAssessment;
|
|
1374
|
+
}
|
|
1375
|
+
return {
|
|
1376
|
+
...contextualAssessment,
|
|
1377
|
+
measurementContextComplete: false,
|
|
1378
|
+
caveats: [
|
|
1379
|
+
...assessment.caveats,
|
|
1380
|
+
"This legacy observation predates immutable corpus-revision fingerprints; it remains visible but cannot contribute sufficient product-level evidence.",
|
|
1381
|
+
],
|
|
1382
|
+
};
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
function buildFeatureMapping(
|
|
1386
|
+
featureFingerprint: string,
|
|
1387
|
+
measurementContextId: string,
|
|
1388
|
+
observations: readonly CompletedFeatureObservation[],
|
|
1389
|
+
): DynamicProductComplexityFeatureMappingV1 {
|
|
1390
|
+
const targetGroups = new Map<string, CompletedFeatureObservation[]>();
|
|
1391
|
+
for (const observation of observations) {
|
|
1392
|
+
const key = canonicalJson(observation.target);
|
|
1393
|
+
const group = targetGroups.get(key) ?? [];
|
|
1394
|
+
group.push(observation);
|
|
1395
|
+
targetGroups.set(key, group);
|
|
1396
|
+
}
|
|
1397
|
+
const targets = [...targetGroups.entries()]
|
|
1398
|
+
.sort(([left], [right]) => compareCodepoints(left, right))
|
|
1399
|
+
.map(([, targetObservations]) => {
|
|
1400
|
+
const first = targetObservations[0];
|
|
1401
|
+
if (first === undefined) throw new Error("feature target group must not be empty");
|
|
1402
|
+
const variants = new Map<
|
|
1403
|
+
string,
|
|
1404
|
+
{ evidence: SearchJourneyTargetCodeComplexityEvidenceV1; completedSessionCount: number }
|
|
1405
|
+
>();
|
|
1406
|
+
for (const observation of targetObservations) {
|
|
1407
|
+
const evidence = observation.assessment.acceptedTargetCodeComplexity ?? {
|
|
1408
|
+
status: "unavailable" as const,
|
|
1409
|
+
reason: "legacy-observation" as const,
|
|
1410
|
+
};
|
|
1411
|
+
const key = canonicalJson(evidence);
|
|
1412
|
+
const existing = variants.get(key);
|
|
1413
|
+
if (existing === undefined) {
|
|
1414
|
+
variants.set(key, { evidence: structuredClone(evidence), completedSessionCount: 1 });
|
|
1415
|
+
} else {
|
|
1416
|
+
existing.completedSessionCount += 1;
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
return {
|
|
1420
|
+
target: structuredClone(first.target),
|
|
1421
|
+
completedSessionCount: targetObservations.length,
|
|
1422
|
+
complexityVariants: [...variants.entries()]
|
|
1423
|
+
.sort(([left], [right]) => compareCodepoints(left, right))
|
|
1424
|
+
.map(([, variant]) => variant),
|
|
1425
|
+
};
|
|
1426
|
+
});
|
|
1427
|
+
const evidence = observations.map(
|
|
1428
|
+
(observation) =>
|
|
1429
|
+
observation.assessment.acceptedTargetCodeComplexity ?? {
|
|
1430
|
+
status: "unavailable" as const,
|
|
1431
|
+
reason: "legacy-observation" as const,
|
|
1432
|
+
},
|
|
1433
|
+
);
|
|
1434
|
+
const observedComplexitySessionCount = evidence.filter(
|
|
1435
|
+
(entry) => entry.status === "observed",
|
|
1436
|
+
).length;
|
|
1437
|
+
const incompletePayloadCount = evidence.filter(
|
|
1438
|
+
(entry) => entry.status === "observed" && !entry.payloadComplete,
|
|
1439
|
+
).length;
|
|
1440
|
+
const unavailableComplexitySessionCount = evidence.length - observedComplexitySessionCount;
|
|
1441
|
+
const distinctGoalCount = new Set(observations.map((entry) => entry.goalFingerprint)).size;
|
|
1442
|
+
const caveats = [
|
|
1443
|
+
"Search effort and static target-code complexity are separate evidence axes; no combined score or causal relationship is asserted.",
|
|
1444
|
+
"Different target-locator precision can represent the same physical code as multiple confirmed targets; compare locators before interpreting target counts.",
|
|
1445
|
+
];
|
|
1446
|
+
if (distinctGoalCount > 1) {
|
|
1447
|
+
caveats.push(
|
|
1448
|
+
"This feature mapping contains multiple task goals; search medians describe the observed feature workload, not repeated identical prompts.",
|
|
1449
|
+
);
|
|
1450
|
+
}
|
|
1451
|
+
if (unavailableComplexitySessionCount > 0) {
|
|
1452
|
+
caveats.push(
|
|
1453
|
+
`${String(unavailableComplexitySessionCount)} completed session(s) have no attributable static target-code complexity; missing values remain explicit and are never treated as zero.`,
|
|
1454
|
+
);
|
|
1455
|
+
}
|
|
1456
|
+
if (incompletePayloadCount > 0) {
|
|
1457
|
+
caveats.push(
|
|
1458
|
+
`${String(incompletePayloadCount)} observed static complexity value(s) came from search hits with incomplete payload provenance.`,
|
|
1459
|
+
);
|
|
1460
|
+
}
|
|
1461
|
+
return {
|
|
1462
|
+
featureFingerprint,
|
|
1463
|
+
measurementContextId,
|
|
1464
|
+
distinctGoalCount,
|
|
1465
|
+
search: buildCohort(
|
|
1466
|
+
measurementContextId,
|
|
1467
|
+
observations.map((observation) => observation.assessment),
|
|
1468
|
+
observations[0]?.executionContext ?? {
|
|
1469
|
+
status: "not-recorded",
|
|
1470
|
+
uncontrolledDimensions: [...SEARCH_JOURNEY_EXECUTION_CONTEXT_DIMENSIONS],
|
|
1471
|
+
},
|
|
1472
|
+
),
|
|
1473
|
+
code: {
|
|
1474
|
+
confirmedTargetCount: targets.length,
|
|
1475
|
+
observedComplexitySessionCount,
|
|
1476
|
+
unavailableComplexitySessionCount,
|
|
1477
|
+
targets,
|
|
1478
|
+
},
|
|
1479
|
+
caveats,
|
|
1480
|
+
};
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
function buildOutcomeCohorts(sessions: readonly SearchJourneyV1[]): DynamicSearchOutcomeCohortV1[] {
|
|
1484
|
+
const grouped = new Map<
|
|
1485
|
+
string,
|
|
1486
|
+
{
|
|
1487
|
+
measurementContextId: string;
|
|
1488
|
+
retrievalContextId: string;
|
|
1489
|
+
executionContext: SearchJourneyExecutionContextEvidenceV1;
|
|
1490
|
+
featureFingerprint?: string;
|
|
1491
|
+
sessions: SearchJourneyV1[];
|
|
1492
|
+
}
|
|
1493
|
+
>();
|
|
1494
|
+
for (const session of sessions) {
|
|
1495
|
+
const retrievalContextId = contextId(session.attempts);
|
|
1496
|
+
const measurementContextId = reportContextId(session, retrievalContextId);
|
|
1497
|
+
const key = canonicalJson([measurementContextId, session.feature?.fingerprint ?? null]);
|
|
1498
|
+
const group = grouped.get(key) ?? {
|
|
1499
|
+
measurementContextId,
|
|
1500
|
+
retrievalContextId,
|
|
1501
|
+
executionContext: executionContextEvidence(session),
|
|
1502
|
+
...(session.feature === undefined ? {} : { featureFingerprint: session.feature.fingerprint }),
|
|
1503
|
+
sessions: [],
|
|
1504
|
+
};
|
|
1505
|
+
group.sessions.push(session);
|
|
1506
|
+
grouped.set(key, group);
|
|
1507
|
+
}
|
|
1508
|
+
return [...grouped.entries()]
|
|
1509
|
+
.sort(([left], [right]) => compareCodepoints(left, right))
|
|
1510
|
+
.map(([, group]) => {
|
|
1511
|
+
const completed = group.sessions.filter((session) => session.status === "completed").length;
|
|
1512
|
+
const abandoned = group.sessions.filter((session) => session.status === "abandoned").length;
|
|
1513
|
+
const terminal = completed + abandoned;
|
|
1514
|
+
const reasons = new Map<
|
|
1515
|
+
NonNullable<DynamicSearchOutcomeCohortV1["abandonmentReasons"]>[number]["reasonCode"],
|
|
1516
|
+
number
|
|
1517
|
+
>();
|
|
1518
|
+
for (const session of group.sessions) {
|
|
1519
|
+
if (session.outcome?.kind !== "abandoned") continue;
|
|
1520
|
+
const reasonCode = session.outcome.reasonCode ?? "not-recorded";
|
|
1521
|
+
reasons.set(reasonCode, (reasons.get(reasonCode) ?? 0) + 1);
|
|
1522
|
+
}
|
|
1523
|
+
const { sessions: observations, ...context } = group;
|
|
1524
|
+
return {
|
|
1525
|
+
...context,
|
|
1526
|
+
distinctGoalCount: new Set(observations.map((session) => session.goal.fingerprint)).size,
|
|
1527
|
+
sessionCounts: {
|
|
1528
|
+
total: observations.length,
|
|
1529
|
+
active: observations.length - terminal,
|
|
1530
|
+
completed,
|
|
1531
|
+
abandoned,
|
|
1532
|
+
terminal,
|
|
1533
|
+
},
|
|
1534
|
+
terminalCompletionRate: terminal === 0 ? null : completed / terminal,
|
|
1535
|
+
retrievalContextComplete: observations.every(
|
|
1536
|
+
(session) =>
|
|
1537
|
+
session.attempts.every((attempt) => attempt.measurementContextVersion === 2) &&
|
|
1538
|
+
measurementContextComplete(session.attempts),
|
|
1539
|
+
),
|
|
1540
|
+
statusEffort: SEARCH_JOURNEY_STATUSES.map((status) => {
|
|
1541
|
+
const matching = observations.filter((session) => session.status === status);
|
|
1542
|
+
const searches = matching.map((session) => session.attempts.length);
|
|
1543
|
+
const totalSearches = searches.reduce((sum, count) => sum + count, 0);
|
|
1544
|
+
const totalNavigationEvents = matching.reduce(
|
|
1545
|
+
(sum, session) => sum + session.navigation.length,
|
|
1546
|
+
0,
|
|
1547
|
+
);
|
|
1548
|
+
return {
|
|
1549
|
+
status,
|
|
1550
|
+
sessionCount: matching.length,
|
|
1551
|
+
sessionsWithSearches: searches.filter((count) => count > 0).length,
|
|
1552
|
+
totalSearches,
|
|
1553
|
+
failedSearches: matching.reduce(
|
|
1554
|
+
(sum, session) =>
|
|
1555
|
+
sum + session.attempts.filter((attempt) => attempt.status === "failed").length,
|
|
1556
|
+
0,
|
|
1557
|
+
),
|
|
1558
|
+
totalNavigationEvents,
|
|
1559
|
+
totalObservedHops: totalSearches + totalNavigationEvents,
|
|
1560
|
+
medianSearches: searches.length === 0 ? null : median(searches),
|
|
1561
|
+
};
|
|
1562
|
+
}),
|
|
1563
|
+
abandonmentReasons: [...reasons.entries()]
|
|
1564
|
+
.sort(([left], [right]) => compareCodepoints(left, right))
|
|
1565
|
+
.map(([reasonCode, count]) => ({ reasonCode, count })),
|
|
1566
|
+
caveats: [
|
|
1567
|
+
"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.",
|
|
1568
|
+
"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.",
|
|
1569
|
+
"Unknown or incompatible retrieval/execution contexts remain separate, including retrieval-unavailable tasks with no known profile; inspect all cohorts before interpreting successful-task effort.",
|
|
1570
|
+
"Task mix, sampling and declaration accuracy are not independently verified or normalized; there is no temporal trend, causal quality claim or ROI estimate.",
|
|
1571
|
+
],
|
|
1572
|
+
};
|
|
1573
|
+
});
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
/** Aggregiert ausschliesslich kompatible Messkontexte in getrennten Kohorten. */
|
|
1577
|
+
export function buildDynamicProductComplexityReport(
|
|
1578
|
+
workspaceId: string,
|
|
1579
|
+
sessions: readonly SearchJourneyV1[],
|
|
1580
|
+
generatedAt: string,
|
|
1581
|
+
): DynamicProductComplexityReportV1 {
|
|
1582
|
+
const scoped = sessions.filter((session) => session.workspaceId === workspaceId);
|
|
1583
|
+
const completed = scoped.filter(
|
|
1584
|
+
(session) => session.status === "completed" && session.outcome?.kind === "completed",
|
|
1585
|
+
);
|
|
1586
|
+
const grouped = new Map<
|
|
1587
|
+
string,
|
|
1588
|
+
{
|
|
1589
|
+
assessments: DynamicSearchComplexityV1[];
|
|
1590
|
+
executionContext: SearchJourneyExecutionContextEvidenceV1;
|
|
1591
|
+
}
|
|
1592
|
+
>();
|
|
1593
|
+
for (const session of completed) {
|
|
1594
|
+
if (session.outcome?.kind !== "completed") continue;
|
|
1595
|
+
const assessment = reportAssessment(session);
|
|
1596
|
+
const cohort = grouped.get(assessment.measurementContextId) ?? {
|
|
1597
|
+
assessments: [],
|
|
1598
|
+
executionContext: executionContextEvidence(session),
|
|
1599
|
+
};
|
|
1600
|
+
cohort.assessments.push(assessment);
|
|
1601
|
+
grouped.set(assessment.measurementContextId, cohort);
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
const cohorts: DynamicProductComplexityCohortV1[] = [...grouped.entries()]
|
|
1605
|
+
.sort(([left], [right]) => compareCodepoints(left, right))
|
|
1606
|
+
.map(([measurementContextId, cohort]) =>
|
|
1607
|
+
buildCohort(measurementContextId, cohort.assessments, cohort.executionContext),
|
|
1608
|
+
);
|
|
1609
|
+
|
|
1610
|
+
const featureGroups = new Map<
|
|
1611
|
+
string,
|
|
1612
|
+
{
|
|
1613
|
+
featureFingerprint: string;
|
|
1614
|
+
measurementContextId: string;
|
|
1615
|
+
observations: CompletedFeatureObservation[];
|
|
1616
|
+
}
|
|
1617
|
+
>();
|
|
1618
|
+
for (const session of completed) {
|
|
1619
|
+
if (session.feature === undefined || session.outcome?.kind !== "completed") continue;
|
|
1620
|
+
const { target } = session.outcome;
|
|
1621
|
+
const assessment = reportAssessment(session);
|
|
1622
|
+
const key = canonicalJson([session.feature.fingerprint, assessment.measurementContextId]);
|
|
1623
|
+
const group = featureGroups.get(key) ?? {
|
|
1624
|
+
featureFingerprint: session.feature.fingerprint,
|
|
1625
|
+
measurementContextId: assessment.measurementContextId,
|
|
1626
|
+
observations: [],
|
|
1627
|
+
};
|
|
1628
|
+
group.observations.push({
|
|
1629
|
+
goalFingerprint: session.goal.fingerprint,
|
|
1630
|
+
target,
|
|
1631
|
+
assessment,
|
|
1632
|
+
executionContext: executionContextEvidence(session),
|
|
1633
|
+
});
|
|
1634
|
+
featureGroups.set(key, group);
|
|
1635
|
+
}
|
|
1636
|
+
const featureMappings = [...featureGroups.values()]
|
|
1637
|
+
.sort(
|
|
1638
|
+
(left, right) =>
|
|
1639
|
+
compareCodepoints(left.featureFingerprint, right.featureFingerprint) ||
|
|
1640
|
+
compareCodepoints(left.measurementContextId, right.measurementContextId),
|
|
1641
|
+
)
|
|
1642
|
+
.map((group) =>
|
|
1643
|
+
buildFeatureMapping(group.featureFingerprint, group.measurementContextId, group.observations),
|
|
1644
|
+
);
|
|
1645
|
+
|
|
1646
|
+
return {
|
|
1647
|
+
schemaVersion: 1,
|
|
1648
|
+
algorithm: DYNAMIC_SEARCH_COMPLEXITY_ALGORITHM_ID,
|
|
1649
|
+
isHeuristic: true,
|
|
1650
|
+
workspaceId,
|
|
1651
|
+
generatedAt,
|
|
1652
|
+
sessionCounts: {
|
|
1653
|
+
total: scoped.length,
|
|
1654
|
+
active: scoped.filter((session) => session.status === "active").length,
|
|
1655
|
+
completed: completed.length,
|
|
1656
|
+
featureTaggedCompleted: completed.filter((session) => session.feature !== undefined).length,
|
|
1657
|
+
abandoned: scoped.filter((session) => session.status === "abandoned").length,
|
|
1658
|
+
},
|
|
1659
|
+
cohorts,
|
|
1660
|
+
featureMappings,
|
|
1661
|
+
outcomeCohorts: buildOutcomeCohorts(scoped),
|
|
1662
|
+
caveats: [
|
|
1663
|
+
"Completed-to-target cohort metrics exclude active and abandoned sessions; separate outcome cohorts expose all statuses, denominators and observed effort.",
|
|
1664
|
+
"Incompatible ranker/profile/generation contexts are never merged.",
|
|
1665
|
+
"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.",
|
|
1666
|
+
"Task mix and sampling policy are not normalized in v1; each cohort describes the workload that was actually observed.",
|
|
1667
|
+
"The report describes observed retrieval effort; it does not replace cognitive complexity and does not claim unlabeled precision.",
|
|
1668
|
+
"Feature mappings include only journeys started with an explicit stable feature ID and keep search effort separate from static target-code complexity.",
|
|
1669
|
+
],
|
|
1670
|
+
};
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
export class SearchJourneyService {
|
|
1674
|
+
private readonly pendingMutations = new Map<string, Promise<void>>();
|
|
1675
|
+
private fingerprintKey: Uint8Array | undefined;
|
|
1676
|
+
|
|
1677
|
+
public constructor(private readonly deps: SearchJourneyServiceDeps) {
|
|
1678
|
+
assertBoundedNonEmpty(deps.workspaceId, "workspaceId", MAX_SEARCH_JOURNEY_LOCATOR_LENGTH);
|
|
1679
|
+
if (deps.fingerprintKey !== undefined) {
|
|
1680
|
+
this.fingerprintKey = this.validatedFingerprintKey(deps.fingerprintKey);
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
public async start(
|
|
1685
|
+
input: StartSearchJourneyInput,
|
|
1686
|
+
options?: PortCallOptions,
|
|
1687
|
+
): Promise<SearchJourneyV1> {
|
|
1688
|
+
options?.signal?.throwIfAborted();
|
|
1689
|
+
const goal = assertBoundedNonEmpty(input.goal, "goal", MAX_SEARCH_JOURNEY_TEXT_LENGTH);
|
|
1690
|
+
const featureId =
|
|
1691
|
+
input.featureId === undefined ? undefined : assertNonEmpty(input.featureId, "featureId");
|
|
1692
|
+
if (featureId !== undefined && featureId.length > MAX_SEARCH_JOURNEY_FEATURE_ID_LENGTH) {
|
|
1693
|
+
throw new Error(
|
|
1694
|
+
`featureId must contain at most ${String(MAX_SEARCH_JOURNEY_FEATURE_ID_LENGTH)} UTF-16 code units`,
|
|
1695
|
+
);
|
|
1696
|
+
}
|
|
1697
|
+
const executionContext = normalizeExecutionContext(input.executionContext);
|
|
1698
|
+
const fingerprintKey = await this.getFingerprintKey(options);
|
|
1699
|
+
const now = this.deps.clock.nowIso();
|
|
1700
|
+
const session: SearchJourneyV1 = {
|
|
1701
|
+
schemaVersion: 1,
|
|
1702
|
+
revision: 0,
|
|
1703
|
+
sessionId: this.deps.random.uuid(),
|
|
1704
|
+
workspaceId: this.deps.workspaceId,
|
|
1705
|
+
status: "active",
|
|
1706
|
+
recordingConsent: "explicit",
|
|
1707
|
+
retainText: input.retainText ?? false,
|
|
1708
|
+
goal: {
|
|
1709
|
+
fingerprint: journeyFingerprint(fingerprintKey, this.deps.workspaceId, "goal", goal),
|
|
1710
|
+
...(input.retainText === true ? { text: goal } : {}),
|
|
1711
|
+
},
|
|
1712
|
+
...(featureId === undefined
|
|
1713
|
+
? {}
|
|
1714
|
+
: {
|
|
1715
|
+
feature: {
|
|
1716
|
+
fingerprint: journeyFingerprint(
|
|
1717
|
+
fingerprintKey,
|
|
1718
|
+
this.deps.workspaceId,
|
|
1719
|
+
"feature",
|
|
1720
|
+
featureId,
|
|
1721
|
+
),
|
|
1722
|
+
...(input.retainText === true ? { text: featureId } : {}),
|
|
1723
|
+
},
|
|
1724
|
+
}),
|
|
1725
|
+
...(executionContext === undefined
|
|
1726
|
+
? {}
|
|
1727
|
+
: {
|
|
1728
|
+
executionContext: persistExecutionContext(
|
|
1729
|
+
executionContext,
|
|
1730
|
+
fingerprintKey,
|
|
1731
|
+
this.deps.workspaceId,
|
|
1732
|
+
input.retainText === true,
|
|
1733
|
+
),
|
|
1734
|
+
}),
|
|
1735
|
+
createdAt: now,
|
|
1736
|
+
updatedAt: now,
|
|
1737
|
+
attempts: [],
|
|
1738
|
+
navigation: [],
|
|
1739
|
+
};
|
|
1740
|
+
assertValidSessionState(session);
|
|
1741
|
+
await this.deps.store.put(session, {
|
|
1742
|
+
...options,
|
|
1743
|
+
expectedRevision: null,
|
|
1744
|
+
});
|
|
1745
|
+
return cloneSession(session);
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
public async get(sessionId: string, options?: PortCallOptions): Promise<SearchJourneyV1> {
|
|
1749
|
+
const id = assertNonEmpty(sessionId, "sessionId");
|
|
1750
|
+
const session = await this.deps.store.get(id, options);
|
|
1751
|
+
if (session === null || session.workspaceId !== this.deps.workspaceId) {
|
|
1752
|
+
throw new UntangleError(
|
|
1753
|
+
"SEARCH_SESSION_NOT_FOUND",
|
|
1754
|
+
`search journey "${id}" was not found in this workspace`,
|
|
1755
|
+
{ operation: "search_journey_get", context: { sessionId: id } },
|
|
1756
|
+
);
|
|
1757
|
+
}
|
|
1758
|
+
return cloneSession(session);
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
public async recordSearch(
|
|
1762
|
+
sessionId: string,
|
|
1763
|
+
result: SearchResultSetV1,
|
|
1764
|
+
link: SearchJourneySearchLink = {},
|
|
1765
|
+
options?: PortCallOptions,
|
|
1766
|
+
): Promise<RecordedSearchAttempt> {
|
|
1767
|
+
assertClientStep(link.step);
|
|
1768
|
+
const queryText = assertBoundedNonEmpty(
|
|
1769
|
+
result.query.text,
|
|
1770
|
+
"query",
|
|
1771
|
+
MAX_SEARCH_JOURNEY_TEXT_LENGTH,
|
|
1772
|
+
);
|
|
1773
|
+
if (result.query.workspaceId !== this.deps.workspaceId) {
|
|
1774
|
+
throw new Error(
|
|
1775
|
+
`search result belongs to workspace "${result.query.workspaceId}", not "${this.deps.workspaceId}"`,
|
|
1776
|
+
);
|
|
1777
|
+
}
|
|
1778
|
+
const profileIds = result.groups.map((group) => group.embeddingProfileId);
|
|
1779
|
+
if (new Set(profileIds).size !== profileIds.length) {
|
|
1780
|
+
throw new UntangleError(
|
|
1781
|
+
"SEARCH_SESSION_INVALID_STATE",
|
|
1782
|
+
"search result contains duplicate embedding-profile groups",
|
|
1783
|
+
{ operation: "search_journey_record", context: { sessionId } },
|
|
1784
|
+
);
|
|
1785
|
+
}
|
|
1786
|
+
const projection = projectSearchObservation(result, queryText);
|
|
1787
|
+
const fingerprintKey = await this.getFingerprintKey(options);
|
|
1788
|
+
const observationFingerprint = observationFingerprintOfProjection(
|
|
1789
|
+
fingerprintKey,
|
|
1790
|
+
this.deps.workspaceId,
|
|
1791
|
+
projection,
|
|
1792
|
+
);
|
|
1793
|
+
const legacyObservationFingerprint = observationFingerprintOfProjection(
|
|
1794
|
+
fingerprintKey,
|
|
1795
|
+
this.deps.workspaceId,
|
|
1796
|
+
projection,
|
|
1797
|
+
false,
|
|
1798
|
+
);
|
|
1799
|
+
return this.mutate(
|
|
1800
|
+
sessionId,
|
|
1801
|
+
(session) => {
|
|
1802
|
+
assertConsistentStepMode(session.attempts, link.step, sessionId);
|
|
1803
|
+
const existing = session.attempts.find(
|
|
1804
|
+
(attempt) => link.step !== undefined && attempt.clientStep === link.step,
|
|
1805
|
+
);
|
|
1806
|
+
if (existing !== undefined) {
|
|
1807
|
+
const legacyReplay =
|
|
1808
|
+
existing.profiles.every((profile) =>
|
|
1809
|
+
profile.candidates.every(
|
|
1810
|
+
(candidate) =>
|
|
1811
|
+
candidate.complexity === undefined &&
|
|
1812
|
+
candidate.incompletePayloadFields === undefined,
|
|
1813
|
+
),
|
|
1814
|
+
) && existing.observationFingerprint === legacyObservationFingerprint;
|
|
1815
|
+
if (existing.observationFingerprint !== observationFingerprint && !legacyReplay) {
|
|
1816
|
+
throw new UntangleError(
|
|
1817
|
+
"SEARCH_SESSION_CONFLICT",
|
|
1818
|
+
`search journey step ${String(link.step)} already records a different observation`,
|
|
1819
|
+
{
|
|
1820
|
+
operation: "search_journey_record",
|
|
1821
|
+
context: { sessionId, step: link.step },
|
|
1822
|
+
},
|
|
1823
|
+
);
|
|
1824
|
+
}
|
|
1825
|
+
return { session, value: existing, changed: false };
|
|
1826
|
+
}
|
|
1827
|
+
if (session.attempts.length >= MAX_SEARCH_JOURNEY_ATTEMPTS) {
|
|
1828
|
+
throw new UntangleError(
|
|
1829
|
+
"SEARCH_SESSION_INVALID_STATE",
|
|
1830
|
+
`search journey reached its limit of ${String(MAX_SEARCH_JOURNEY_ATTEMPTS)} attempts`,
|
|
1831
|
+
{ operation: "search_journey_record", context: { sessionId } },
|
|
1832
|
+
);
|
|
1833
|
+
}
|
|
1834
|
+
const attempt: SearchJourneyAttemptV1 = {
|
|
1835
|
+
attemptId: this.deps.random.uuid(),
|
|
1836
|
+
sequence: session.attempts.length + 1,
|
|
1837
|
+
measurementContextVersion: 2,
|
|
1838
|
+
// Aus dem Verdikt abgeleitet, nie aus einer leeren Kandidatenliste.
|
|
1839
|
+
...(projection.abstention === null ? {} : { abstention: projection.abstention }),
|
|
1840
|
+
...(link.step === undefined ? {} : { clientStep: link.step }),
|
|
1841
|
+
observationFingerprint,
|
|
1842
|
+
queryFingerprint: journeyQueryFingerprint(
|
|
1843
|
+
fingerprintKey,
|
|
1844
|
+
this.deps.workspaceId,
|
|
1845
|
+
queryText,
|
|
1846
|
+
),
|
|
1847
|
+
...(session.retainText ? { queryText } : {}),
|
|
1848
|
+
rankerVersion: projection.rankerVersion,
|
|
1849
|
+
...(projection.rankingContextFingerprint === null
|
|
1850
|
+
? {}
|
|
1851
|
+
: { rankingContextFingerprint: projection.rankingContextFingerprint }),
|
|
1852
|
+
resultLimit: projection.resultLimit,
|
|
1853
|
+
candidateLimit: projection.candidateLimit,
|
|
1854
|
+
observedAt: this.deps.clock.nowIso(),
|
|
1855
|
+
status: "succeeded",
|
|
1856
|
+
warningCodes: [...projection.warningCodes],
|
|
1857
|
+
retrievalHealthy: projection.retrievalHealthy,
|
|
1858
|
+
profiles: profilesOfProjection(projection),
|
|
1859
|
+
};
|
|
1860
|
+
session.attempts.push(attempt);
|
|
1861
|
+
sortAttempts(session.attempts);
|
|
1862
|
+
session.updatedAt = attempt.observedAt;
|
|
1863
|
+
return { session, value: attempt };
|
|
1864
|
+
},
|
|
1865
|
+
options,
|
|
1866
|
+
{ allowTerminalIdempotentReplay: link.step !== undefined },
|
|
1867
|
+
).then(({ session, value }) => ({ session, attempt: value }));
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
public async recordFailedSearch(
|
|
1871
|
+
sessionId: string,
|
|
1872
|
+
input: RecordFailedSearchInput,
|
|
1873
|
+
link: SearchJourneySearchLink = {},
|
|
1874
|
+
options?: PortCallOptions,
|
|
1875
|
+
): Promise<RecordedSearchAttempt> {
|
|
1876
|
+
assertClientStep(link.step);
|
|
1877
|
+
const query = assertBoundedNonEmpty(input.query, "query", MAX_SEARCH_JOURNEY_TEXT_LENGTH);
|
|
1878
|
+
const failureCode = assertBoundedNonEmpty(
|
|
1879
|
+
input.failureCode,
|
|
1880
|
+
"failureCode",
|
|
1881
|
+
MAX_SEARCH_JOURNEY_LOCATOR_LENGTH,
|
|
1882
|
+
);
|
|
1883
|
+
const failureReason = assertBoundedNonEmpty(
|
|
1884
|
+
input.failureReason,
|
|
1885
|
+
"failureReason",
|
|
1886
|
+
MAX_SEARCH_JOURNEY_REASON_LENGTH,
|
|
1887
|
+
);
|
|
1888
|
+
const fingerprintKey = await this.getFingerprintKey(options);
|
|
1889
|
+
const observationFingerprint = journeyFingerprint(
|
|
1890
|
+
fingerprintKey,
|
|
1891
|
+
this.deps.workspaceId,
|
|
1892
|
+
"failed-observation",
|
|
1893
|
+
{
|
|
1894
|
+
query,
|
|
1895
|
+
failureCode,
|
|
1896
|
+
failureReason,
|
|
1897
|
+
rankerVersion: RANKER_VERSION,
|
|
1898
|
+
},
|
|
1899
|
+
);
|
|
1900
|
+
return this.mutate(
|
|
1901
|
+
sessionId,
|
|
1902
|
+
(session) => {
|
|
1903
|
+
assertConsistentStepMode(session.attempts, link.step, sessionId);
|
|
1904
|
+
const existing = session.attempts.find(
|
|
1905
|
+
(attempt) => link.step !== undefined && attempt.clientStep === link.step,
|
|
1906
|
+
);
|
|
1907
|
+
if (existing !== undefined) {
|
|
1908
|
+
if (existing.observationFingerprint !== observationFingerprint) {
|
|
1909
|
+
throw new UntangleError(
|
|
1910
|
+
"SEARCH_SESSION_CONFLICT",
|
|
1911
|
+
`search journey step ${String(link.step)} already records a different observation`,
|
|
1912
|
+
{
|
|
1913
|
+
operation: "search_journey_record_failure",
|
|
1914
|
+
context: { sessionId, step: link.step },
|
|
1915
|
+
},
|
|
1916
|
+
);
|
|
1917
|
+
}
|
|
1918
|
+
return { session, value: existing, changed: false };
|
|
1919
|
+
}
|
|
1920
|
+
if (session.attempts.length >= MAX_SEARCH_JOURNEY_ATTEMPTS) {
|
|
1921
|
+
throw new UntangleError(
|
|
1922
|
+
"SEARCH_SESSION_INVALID_STATE",
|
|
1923
|
+
`search journey reached its limit of ${String(MAX_SEARCH_JOURNEY_ATTEMPTS)} attempts`,
|
|
1924
|
+
{ operation: "search_journey_record", context: { sessionId } },
|
|
1925
|
+
);
|
|
1926
|
+
}
|
|
1927
|
+
const observedAt = this.deps.clock.nowIso();
|
|
1928
|
+
const attempt: SearchJourneyAttemptV1 = {
|
|
1929
|
+
attemptId: this.deps.random.uuid(),
|
|
1930
|
+
sequence: session.attempts.length + 1,
|
|
1931
|
+
measurementContextVersion: 2,
|
|
1932
|
+
// Ein fehlgeschlagener Versuch hat kein Ergebnis und damit kein
|
|
1933
|
+
// Verdikt. Hier `strict` einzutragen waere geraten, nicht beobachtet.
|
|
1934
|
+
...(link.step === undefined ? {} : { clientStep: link.step }),
|
|
1935
|
+
observationFingerprint,
|
|
1936
|
+
queryFingerprint: journeyQueryFingerprint(fingerprintKey, this.deps.workspaceId, query),
|
|
1937
|
+
...(session.retainText ? { queryText: query } : {}),
|
|
1938
|
+
rankerVersion: RANKER_VERSION,
|
|
1939
|
+
observedAt,
|
|
1940
|
+
status: "failed",
|
|
1941
|
+
failureCode,
|
|
1942
|
+
...(session.retainText ? { failureReason } : {}),
|
|
1943
|
+
warningCodes: [],
|
|
1944
|
+
retrievalHealthy: false,
|
|
1945
|
+
profiles: [],
|
|
1946
|
+
};
|
|
1947
|
+
session.attempts.push(attempt);
|
|
1948
|
+
sortAttempts(session.attempts);
|
|
1949
|
+
session.updatedAt = observedAt;
|
|
1950
|
+
return { session, value: attempt };
|
|
1951
|
+
},
|
|
1952
|
+
options,
|
|
1953
|
+
{ allowTerminalIdempotentReplay: link.step !== undefined },
|
|
1954
|
+
).then(({ session, value }) => ({ session, attempt: value }));
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
public async observeNavigation(
|
|
1958
|
+
sessionId: string,
|
|
1959
|
+
input: ObserveSearchJourneyNavigationInput,
|
|
1960
|
+
options?: PortCallOptions,
|
|
1961
|
+
): Promise<SearchJourneyV1> {
|
|
1962
|
+
const target = input.target === undefined ? undefined : normalizedTarget(input.target);
|
|
1963
|
+
const toolName =
|
|
1964
|
+
input.toolName === undefined
|
|
1965
|
+
? undefined
|
|
1966
|
+
: assertBoundedNonEmpty(input.toolName, "toolName", MAX_SEARCH_JOURNEY_TOOL_NAME_LENGTH);
|
|
1967
|
+
return this.mutate(
|
|
1968
|
+
sessionId,
|
|
1969
|
+
(session) => {
|
|
1970
|
+
if (session.navigation.length >= MAX_SEARCH_JOURNEY_NAVIGATION_EVENTS) {
|
|
1971
|
+
throw new UntangleError(
|
|
1972
|
+
"SEARCH_SESSION_INVALID_STATE",
|
|
1973
|
+
`search journey reached its limit of ${String(MAX_SEARCH_JOURNEY_NAVIGATION_EVENTS)} navigation events`,
|
|
1974
|
+
{ operation: "search_journey_observe", context: { sessionId } },
|
|
1975
|
+
);
|
|
1976
|
+
}
|
|
1977
|
+
const observedAt = this.deps.clock.nowIso();
|
|
1978
|
+
const event: SearchJourneyNavigationEventV1 = {
|
|
1979
|
+
eventId: this.deps.random.uuid(),
|
|
1980
|
+
sequence: session.navigation.length + 1,
|
|
1981
|
+
kind: input.kind,
|
|
1982
|
+
observedAt,
|
|
1983
|
+
...(toolName === undefined ? {} : { toolName }),
|
|
1984
|
+
...(target === undefined ? {} : { target }),
|
|
1985
|
+
};
|
|
1986
|
+
session.navigation.push(event);
|
|
1987
|
+
session.updatedAt = observedAt;
|
|
1988
|
+
return { session, value: undefined };
|
|
1989
|
+
},
|
|
1990
|
+
options,
|
|
1991
|
+
).then(({ session }) => session);
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
public async complete(
|
|
1995
|
+
sessionId: string,
|
|
1996
|
+
input: CompleteSearchJourneyInput,
|
|
1997
|
+
options?: PortCallOptions,
|
|
1998
|
+
): Promise<SearchJourneyV1> {
|
|
1999
|
+
const target = normalizedTarget(input.target);
|
|
2000
|
+
const acceptedAttemptId =
|
|
2001
|
+
input.acceptedAttemptId === undefined
|
|
2002
|
+
? undefined
|
|
2003
|
+
: assertBoundedNonEmpty(
|
|
2004
|
+
input.acceptedAttemptId,
|
|
2005
|
+
"acceptedAttemptId",
|
|
2006
|
+
MAX_SEARCH_JOURNEY_LOCATOR_LENGTH,
|
|
2007
|
+
);
|
|
2008
|
+
return this.mutate(
|
|
2009
|
+
sessionId,
|
|
2010
|
+
(session) => {
|
|
2011
|
+
const completedAt = this.deps.clock.nowIso();
|
|
2012
|
+
const assessment = assessSearchJourney(session, {
|
|
2013
|
+
target,
|
|
2014
|
+
...(acceptedAttemptId === undefined ? {} : { acceptedAttemptId }),
|
|
2015
|
+
});
|
|
2016
|
+
session.status = "completed";
|
|
2017
|
+
session.updatedAt = completedAt;
|
|
2018
|
+
session.outcome = {
|
|
2019
|
+
kind: "completed",
|
|
2020
|
+
completedAt,
|
|
2021
|
+
target,
|
|
2022
|
+
...(acceptedAttemptId === undefined ? {} : { acceptedAttemptId }),
|
|
2023
|
+
assessment,
|
|
2024
|
+
};
|
|
2025
|
+
return { session, value: undefined };
|
|
2026
|
+
},
|
|
2027
|
+
options,
|
|
2028
|
+
).then(({ session }) => session);
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
public async abandon(
|
|
2032
|
+
sessionId: string,
|
|
2033
|
+
input: AbandonSearchJourneyInput = {},
|
|
2034
|
+
options?: PortCallOptions,
|
|
2035
|
+
): Promise<SearchJourneyV1> {
|
|
2036
|
+
const reason =
|
|
2037
|
+
input.reason === undefined
|
|
2038
|
+
? undefined
|
|
2039
|
+
: assertBoundedNonEmpty(input.reason, "reason", MAX_SEARCH_JOURNEY_REASON_LENGTH);
|
|
2040
|
+
return this.mutate(
|
|
2041
|
+
sessionId,
|
|
2042
|
+
(session) => {
|
|
2043
|
+
const abandonedAt = this.deps.clock.nowIso();
|
|
2044
|
+
session.status = "abandoned";
|
|
2045
|
+
session.updatedAt = abandonedAt;
|
|
2046
|
+
session.outcome = {
|
|
2047
|
+
kind: "abandoned",
|
|
2048
|
+
abandonedAt,
|
|
2049
|
+
...(input.reasonCode === undefined ? {} : { reasonCode: input.reasonCode }),
|
|
2050
|
+
...(session.retainText && reason !== undefined ? { reason } : {}),
|
|
2051
|
+
};
|
|
2052
|
+
return { session, value: undefined };
|
|
2053
|
+
},
|
|
2054
|
+
options,
|
|
2055
|
+
).then(({ session }) => session);
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
public async report(options?: PortCallOptions): Promise<DynamicProductComplexityReportV1> {
|
|
2059
|
+
const sessions = await this.deps.store.list(this.deps.workspaceId, options);
|
|
2060
|
+
return buildDynamicProductComplexityReport(
|
|
2061
|
+
this.deps.workspaceId,
|
|
2062
|
+
sessions,
|
|
2063
|
+
this.deps.clock.nowIso(),
|
|
2064
|
+
);
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
private validatedFingerprintKey(key: Uint8Array): Uint8Array {
|
|
2068
|
+
if (key.byteLength < 32) {
|
|
2069
|
+
throw new Error("search journey fingerprint key must contain at least 32 bytes");
|
|
2070
|
+
}
|
|
2071
|
+
return key.slice();
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
private async getFingerprintKey(options?: PortCallOptions): Promise<Uint8Array> {
|
|
2075
|
+
if (this.fingerprintKey !== undefined) return this.fingerprintKey;
|
|
2076
|
+
const provider = this.deps.fingerprintKeyProvider;
|
|
2077
|
+
if (provider === undefined) throw new Error("search journey fingerprint key is unavailable");
|
|
2078
|
+
const loaded = this.validatedFingerprintKey(await provider(options));
|
|
2079
|
+
this.fingerprintKey = loaded;
|
|
2080
|
+
return loaded;
|
|
2081
|
+
}
|
|
2082
|
+
|
|
2083
|
+
private async mutate<T>(
|
|
2084
|
+
sessionId: string,
|
|
2085
|
+
reducer: (session: SearchJourneyV1) => {
|
|
2086
|
+
session: SearchJourneyV1;
|
|
2087
|
+
value: T;
|
|
2088
|
+
changed?: boolean;
|
|
2089
|
+
},
|
|
2090
|
+
options?: PortCallOptions,
|
|
2091
|
+
mutationOptions: { allowTerminalIdempotentReplay?: boolean } = {},
|
|
2092
|
+
): Promise<{ session: SearchJourneyV1; value: T }> {
|
|
2093
|
+
const id = assertNonEmpty(sessionId, "sessionId");
|
|
2094
|
+
let resolved: { session: SearchJourneyV1; value: T } | undefined;
|
|
2095
|
+
const previous = this.pendingMutations.get(id) ?? Promise.resolve();
|
|
2096
|
+
// Ein fehlgeschlagener parallel vorausgehender Mutator darf die lokale
|
|
2097
|
+
// Serialisierung nicht dauerhaft vergiften. Jeder Aufruf liest seinen
|
|
2098
|
+
// eigenen frischen CAS-Stand und gibt seinen eigenen Fehler weiter.
|
|
2099
|
+
const current = previous
|
|
2100
|
+
.catch(() => undefined)
|
|
2101
|
+
.then(async () => {
|
|
2102
|
+
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
2103
|
+
options?.signal?.throwIfAborted();
|
|
2104
|
+
const loaded = await this.deps.store.get(id, options);
|
|
2105
|
+
if (loaded === null || loaded.workspaceId !== this.deps.workspaceId) {
|
|
2106
|
+
throw new UntangleError(
|
|
2107
|
+
"SEARCH_SESSION_NOT_FOUND",
|
|
2108
|
+
`search journey "${id}" was not found in this workspace`,
|
|
2109
|
+
{ operation: "search_journey_mutate", context: { sessionId: id } },
|
|
2110
|
+
);
|
|
2111
|
+
}
|
|
2112
|
+
if (
|
|
2113
|
+
loaded.status !== "active" &&
|
|
2114
|
+
mutationOptions.allowTerminalIdempotentReplay !== true
|
|
2115
|
+
) {
|
|
2116
|
+
throw new UntangleError(
|
|
2117
|
+
"SEARCH_SESSION_INVALID_STATE",
|
|
2118
|
+
`search journey "${id}" is ${loaded.status} and cannot be changed`,
|
|
2119
|
+
{
|
|
2120
|
+
operation: "search_journey_mutate",
|
|
2121
|
+
context: { sessionId: id, status: loaded.status },
|
|
2122
|
+
},
|
|
2123
|
+
);
|
|
2124
|
+
}
|
|
2125
|
+
const next = reducer(cloneSession(loaded));
|
|
2126
|
+
if (next.changed === false) {
|
|
2127
|
+
resolved = { session: cloneSession(loaded), value: next.value };
|
|
2128
|
+
return;
|
|
2129
|
+
}
|
|
2130
|
+
if (loaded.status !== "active") {
|
|
2131
|
+
throw new UntangleError(
|
|
2132
|
+
"SEARCH_SESSION_INVALID_STATE",
|
|
2133
|
+
`search journey "${id}" is ${loaded.status} and cannot be changed`,
|
|
2134
|
+
{
|
|
2135
|
+
operation: "search_journey_mutate",
|
|
2136
|
+
context: { sessionId: id, status: loaded.status },
|
|
2137
|
+
},
|
|
2138
|
+
);
|
|
2139
|
+
}
|
|
2140
|
+
next.session.revision = loaded.revision + 1;
|
|
2141
|
+
assertValidSessionState(next.session);
|
|
2142
|
+
try {
|
|
2143
|
+
await this.deps.store.put(next.session, {
|
|
2144
|
+
...options,
|
|
2145
|
+
expectedRevision: loaded.revision,
|
|
2146
|
+
});
|
|
2147
|
+
resolved = { session: cloneSession(next.session), value: next.value };
|
|
2148
|
+
return;
|
|
2149
|
+
} catch (error) {
|
|
2150
|
+
if (
|
|
2151
|
+
!isUntangleError(error) ||
|
|
2152
|
+
error.code !== "SEARCH_SESSION_CONFLICT" ||
|
|
2153
|
+
attempt === 3
|
|
2154
|
+
) {
|
|
2155
|
+
throw error;
|
|
2156
|
+
}
|
|
2157
|
+
}
|
|
2158
|
+
}
|
|
2159
|
+
});
|
|
2160
|
+
this.pendingMutations.set(id, current);
|
|
2161
|
+
try {
|
|
2162
|
+
await current;
|
|
2163
|
+
return resolved as { session: SearchJourneyV1; value: T };
|
|
2164
|
+
} finally {
|
|
2165
|
+
if (this.pendingMutations.get(id) === current) this.pendingMutations.delete(id);
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
2168
|
+
}
|