@atlaskit/editor-plugin-autocomplete 8.0.0 → 9.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/dist/cjs/analytics/ufo.js +2 -1
  3. package/dist/cjs/pm-plugins/artifact-loader.js +135 -0
  4. package/dist/cjs/pm-plugins/artifacts-manifest.js +3 -0
  5. package/dist/cjs/pm-plugins/autocomplete-plugin.js +416 -129
  6. package/dist/cjs/pm-plugins/canonical-lm-scoring.js +371 -0
  7. package/dist/cjs/pm-plugins/debug-mode.js +90 -7
  8. package/dist/cjs/pm-plugins/ghost-text-decoration.js +12 -3
  9. package/dist/cjs/pm-plugins/local-slow-lane-client.js +1202 -453
  10. package/dist/cjs/pm-plugins/scoring-pipeline.js +164 -19
  11. package/dist/cjs/pm-plugins/slow-lane-client.js +100 -37
  12. package/dist/cjs/pm-plugins/text-predictor.js +1951 -273
  13. package/dist/es2019/analytics/ufo.js +2 -0
  14. package/dist/es2019/pm-plugins/artifact-loader.js +70 -0
  15. package/dist/es2019/pm-plugins/artifacts-manifest.js +3 -0
  16. package/dist/es2019/pm-plugins/autocomplete-plugin.js +425 -130
  17. package/dist/es2019/pm-plugins/canonical-lm-scoring.js +270 -0
  18. package/dist/es2019/pm-plugins/debug-mode.js +87 -7
  19. package/dist/es2019/pm-plugins/ghost-text-decoration.js +12 -3
  20. package/dist/es2019/pm-plugins/local-slow-lane-client.js +940 -326
  21. package/dist/es2019/pm-plugins/scoring-pipeline.js +137 -13
  22. package/dist/es2019/pm-plugins/slow-lane-client.js +94 -37
  23. package/dist/es2019/pm-plugins/text-predictor.js +1603 -199
  24. package/dist/esm/analytics/ufo.js +2 -1
  25. package/dist/esm/pm-plugins/artifact-loader.js +128 -0
  26. package/dist/esm/pm-plugins/artifacts-manifest.js +3 -0
  27. package/dist/esm/pm-plugins/autocomplete-plugin.js +419 -132
  28. package/dist/esm/pm-plugins/canonical-lm-scoring.js +364 -0
  29. package/dist/esm/pm-plugins/debug-mode.js +89 -6
  30. package/dist/esm/pm-plugins/ghost-text-decoration.js +12 -3
  31. package/dist/esm/pm-plugins/local-slow-lane-client.js +1200 -451
  32. package/dist/esm/pm-plugins/scoring-pipeline.js +163 -18
  33. package/dist/esm/pm-plugins/slow-lane-client.js +100 -37
  34. package/dist/esm/pm-plugins/text-predictor.js +1953 -277
  35. package/dist/types/analytics/ufo.d.ts +1 -0
  36. package/dist/types/pm-plugins/artifact-loader.d.ts +44 -0
  37. package/dist/types/pm-plugins/artifacts-manifest.d.ts +3 -0
  38. package/dist/types/pm-plugins/autocomplete-plugin.d.ts +39 -0
  39. package/dist/types/pm-plugins/canonical-lm-scoring.d.ts +158 -0
  40. package/dist/types/pm-plugins/debug-mode.d.ts +36 -1
  41. package/dist/types/pm-plugins/ghost-text-decoration.d.ts +5 -2
  42. package/dist/types/pm-plugins/local-slow-lane-client.d.ts +87 -54
  43. package/dist/types/pm-plugins/scoring-pipeline.d.ts +33 -1
  44. package/dist/types/pm-plugins/slow-lane-client.d.ts +51 -3
  45. package/dist/types/pm-plugins/text-predictor.d.ts +120 -2
  46. package/package.json +2 -2
  47. package/src/analytics/ufo.ts +5 -0
  48. package/src/pm-plugins/artifact-loader.ts +92 -0
  49. package/src/pm-plugins/artifacts-manifest.ts +3 -0
  50. package/src/pm-plugins/autocomplete-plugin.ts +517 -133
  51. package/src/pm-plugins/canonical-lm-scoring.ts +412 -0
  52. package/src/pm-plugins/debug-mode.ts +109 -7
  53. package/src/pm-plugins/ghost-text-decoration.ts +13 -4
  54. package/src/pm-plugins/local-slow-lane-client.ts +1302 -386
  55. package/src/pm-plugins/scoring-pipeline.ts +167 -17
  56. package/src/pm-plugins/slow-lane-client.ts +147 -62
  57. package/src/pm-plugins/text-predictor.ts +2006 -255
  58. package/CANONICAL_FIX__DO_NOT_USE_ME_A/package.json +0 -8
  59. package/CANONICAL_FIX__DO_NOT_USE_ME_B/package.json +0 -8
  60. package/CANONICAL_FIX__DO_NOT_USE_ME_C/package.json +0 -8
  61. package/dist/cjs/entry-points/src-pm-plugins-autocomplete-plugin.js +0 -18
  62. package/dist/cjs/entry-points/src-pm-plugins-slow-lane-client.js +0 -36
  63. package/dist/cjs/entry-points/src-pm-plugins-text-predictor.js +0 -66
  64. package/dist/es2019/entry-points/src-pm-plugins-autocomplete-plugin.js +0 -2
  65. package/dist/es2019/entry-points/src-pm-plugins-slow-lane-client.js +0 -2
  66. package/dist/es2019/entry-points/src-pm-plugins-text-predictor.js +0 -2
  67. package/dist/esm/entry-points/src-pm-plugins-autocomplete-plugin.js +0 -2
  68. package/dist/esm/entry-points/src-pm-plugins-slow-lane-client.js +0 -2
  69. package/dist/esm/entry-points/src-pm-plugins-text-predictor.js +0 -2
  70. package/dist/types/entry-points/src-pm-plugins-autocomplete-plugin.d.ts +0 -2
  71. package/dist/types/entry-points/src-pm-plugins-slow-lane-client.d.ts +0 -2
  72. package/dist/types/entry-points/src-pm-plugins-text-predictor.d.ts +0 -2
  73. package/scripts/gen_first_token_to_words.py +0 -170
  74. package/src/entry-points/src-pm-plugins-autocomplete-plugin.ts +0 -7
  75. package/src/entry-points/src-pm-plugins-slow-lane-client.ts +0 -13
  76. package/src/entry-points/src-pm-plugins-text-predictor.ts +0 -14
  77. package/src/pm-plugins/autocomplete-plugin/package.json +0 -8
  78. package/src/pm-plugins/slow-lane-client/package.json +0 -8
  79. package/src/pm-plugins/text-predictor/package.json +0 -8
@@ -1,4 +1,3 @@
1
- import _defineProperty from "@babel/runtime/helpers/defineProperty";
2
1
  /**
3
2
  * Local Slow Lane Client: On-device inference via @mlc-ai/web-llm.
4
3
  *
@@ -6,11 +5,9 @@ import _defineProperty from "@babel/runtime/helpers/defineProperty";
6
5
  * a backend API, this client runs two models in the browser via WebGPU, in a
7
6
  * single MLCEngine, to reproduce the BE encoder's outputs on-device:
8
7
  *
9
- * - Causal LM (SmolLM2-135M-Instruct): one decode step per word boundary. A
10
- * registered LogitProcessor captures the raw next-token logits, which
11
- * `computeBePayload` turns into a whole-word `lm_logits` payload — a faithful
12
- * port of the BE `CausalLMEncoder._get_top_k_probs` (masked softmax over the
13
- * vocab's first-tokens, prefix expansion, L2 reservation, log-space pooling).
8
+ * - Causal LM (SmolLM2-135M-Instruct): context-keyed one-step boundary primes
9
+ * provide canonical first-token logits. A persistent token-prefix scheduler
10
+ * expands shared paths and exact-scores only plausible finalists.
14
11
  * - Semantic embedder (Snowflake Arctic Embed S): produces the real 384-d
15
12
  * `semantic_vector`. Inputs are wrapped as passages (see `wrapForArctic`) so
16
13
  * the runtime vector lands in the same space as the precomputed word bin.
@@ -20,7 +17,7 @@ import _defineProperty from "@babel/runtime/helpers/defineProperty";
20
17
  * the main thread is viable:
21
18
  *
22
19
  * - WebGPU GPU compute is inherently async (doesn't block the main thread)
23
- * - CPU overhead (BE-parity post-processing) is a few ms
20
+ * - CPU overhead for named-token reads and cache bookkeeping is small
24
21
  * - Per-inference latency is well within autocomplete expectations
25
22
  * (~250 ms between word boundaries)
26
23
  *
@@ -31,16 +28,21 @@ import _defineProperty from "@babel/runtime/helpers/defineProperty";
31
28
  * - Standard npm import — just works
32
29
  *
33
30
  * ── Interface ────────────────────────────────────────────────────────────
34
- * Same shape as createSlowLaneClient so text-predictor.ts needs zero changes.
35
- * The client exposes getContextVector() and getLmLogits() which are populated
36
- * asynchronously after each updateContext() call.
31
+ * Same base shape as createSlowLaneClient, plus on-device canonical-surface
32
+ * scoring. Semantic updates remain word-boundary timed; causal work is requested
33
+ * independently for exact pre-surface contexts.
37
34
  */
38
35
 
39
36
  import { abortExp, EXPERIENCE_NAME, failExp, startExp, succeedExp } from '../analytics/ufo';
40
- import { ARTIFACT_NAME, fetchArtifactJson } from './artifacts-manifest';
41
- import { isAutocompleteDebugEnabled } from './debug-mode';
37
+ import { fetchAutocompleteArtifactJson } from './artifact-loader';
38
+ import { ARTIFACT_NAME } from './artifacts-manifest';
39
+ import { CanonicalLogitProcessor, CanonicalSurfaceTokenTrie, logSoftmaxAt, logSumExp } from './canonical-lm-scoring';
40
+ import { CTC_STYLES, isAutocompleteDebugEnabled, isAutocompleteDebugVerbose } from './debug-mode';
41
+ import { MIN_WINNER_MARGIN, STAGE1_WEIGHT, STAGE2_WEIGHT } from './scoring-pipeline';
42
42
  import { isWordBoundary } from './slow-lane-client';
43
43
 
44
+ /** Which of the two causal-LM call shapes a measurement or log line describes. */
45
+
44
46
  // ─── Types ───────────────────────────────────────────────────────────────────
45
47
 
46
48
  // Same return type as createSlowLaneClient for drop-in compatibility
@@ -90,22 +92,7 @@ export const LOCAL_MLC_EMBEDDING_MODEL_ID = 'snowflake-arctic-embed-s-q0f32-MLC-
90
92
  * the relationship. Encode both sides as passages. See implementation.md §4.3.
91
93
  */
92
94
  export const wrapForArctic = text => `[CLS] ${text} [SEP]`;
93
-
94
- /**
95
- * BE-parity constants — must match `CausalLMEncoder` defaults in the Python
96
- * sidecar (`cc-smarts/python-sidecar/src/causal_lm_encoder.py`) and
97
- * `SlowLaneEngine` (`typeahead_context_encoding.py`) so local payloads behave
98
- * identically to the server-client setup.
99
- */
100
- export const BE_PARITY = {
101
- /** Final payload size cap (BE: `top_k_words`). */
102
- TOP_K_WORDS: 2000,
103
- /** L2 (domain) words admitted unconditionally before pooling (BE: `reserved_l2_slots`). */
104
- RESERVED_L2_SLOTS: 500,
105
- /** Log-space additive bias favouring L2 over L3 in the pool (BE: `l2_bias`). */
106
- L2_BIAS: 1.0,
107
- /** Drop words below this probability from the final payload (BE: `> 0.00001`). */
108
- MIN_PROB: 0.00001,
95
+ export const LOCAL_INFERENCE = {
109
96
  /**
110
97
  * Word-level approximation of the BE causal LM token limit.
111
98
  *
@@ -126,6 +113,72 @@ export const BE_PARITY = {
126
113
  */
127
114
  MAX_CONTEXT_WORDS: 100
128
115
  };
116
+ export const CANONICAL_SCORING = {
117
+ /**
118
+ * How many contexts keep their prefilled state before the oldest is dropped.
119
+ *
120
+ * Sizes every context-keyed cache together on purpose: an expansion needs
121
+ * both the boundary logits and the candidate list to still be resident, so
122
+ * bounding them separately would evict half of a context and strand the
123
+ * other half.
124
+ *
125
+ * The context of a keystroke is the text *before* the word being typed,
126
+ * which does not change while that word is typed, so one word should cost
127
+ * one prefill and then hit. Measured hit rate was 45% over the first 84s of
128
+ * a session and 24% over the following four minutes, well short of that, and
129
+ * prompt prefills per decision rose 0.80 → 1.25 across the same split while
130
+ * cost per prefill stayed flat. Contexts were being dropped while still live.
131
+ *
132
+ * A boundary entry holds a `Float32Array` over the 49,152-token vocabulary,
133
+ * so each one is ~192KB and this bound is the dominant term in the scorer's
134
+ * footprint: ~24MB resident here, against ~6MB at the 32 this replaced. That
135
+ * cost is what kept the bound low, not a hit rate anyone had measured.
136
+ */
137
+ BOUNDARY_CACHE_MAX: 128,
138
+ EXACT_MAX_TARGET_TOKENS: 8,
139
+ PREFIX_CACHE_MAX: 256,
140
+ /**
141
+ * How many candidates compete for expansion in one context.
142
+ *
143
+ * Each distinct token prefix among them is a separate branch, and a branch
144
+ * the engine is not already standing on costs a prompt prefill before its
145
+ * first decode. A wide field therefore spreads a decision's round trips
146
+ * across candidates and finishes none of them inside the budget.
147
+ */
148
+ PROGRESSIVE_CANDIDATES_MAX: 8,
149
+ /**
150
+ * Ceiling on distinct token prefixes explored per context.
151
+ *
152
+ * Raising this to 8 to give long surfaces more room did the opposite: model
153
+ * calls per decision went 2.35 → 4.32, the warm-KV extend share fell 40% →
154
+ * 31% as the extra branches displaced the live path, the prefix queue backed
155
+ * up to 29 deep, and acceptances per thousand decisions fell 9.3 → 6.7. No
156
+ * phrase was shown either way, so breadth was never the binding constraint.
157
+ *
158
+ * What blocked them then was normalisation against a short word's per-token
159
+ * mean, which has since been replaced by a posterior over sequence
160
+ * log-likelihoods. That removes the bias towards short surfaces but does not
161
+ * by itself make phrases reachable: a longer surface is strictly less likely
162
+ * than a shorter one, so a phrase sharing a shortlist with a unigram still
163
+ * holds little of its mass. Whether the remaining gap is the threshold or the
164
+ * comparison is still open.
165
+ */
166
+ PROGRESSIVE_EXPANSIONS_PER_CONTEXT_MAX: 4,
167
+ PROGRESSIVE_INPUT_MAX: 400,
168
+ SURFACE_CACHE_MAX: 128,
169
+ TIER_A_PRIMES_MAX: 3
170
+ };
171
+ /**
172
+ * How much optimistic score we give up to stay on the warm KV path.
173
+ *
174
+ * Expanding a prefix the cache already holds costs one decode step, while
175
+ * branching to any other prefix costs a full prompt prefill first. Measured,
176
+ * that is about 30ms against about 66ms, so continuing the live path is worth
177
+ * roughly half a round trip and the margin has to be wide enough to reflect
178
+ * that. At the previous 0.05 almost any ranking difference was enough to
179
+ * abandon the sequence, and 70% of expansions ended up re-prefilling.
180
+ */
181
+ const PROGRESSIVE_WARM_PATH_MARGIN = 0.25;
129
182
  const splitOnWhitespace = text => {
130
183
  const trimmed = text.trim();
131
184
  if (trimmed === '') {
@@ -160,235 +213,48 @@ const truncateToLastNWords = (text, n) => {
160
213
  return words.length <= n ? text : words.slice(-n).join(' ');
161
214
  };
162
215
 
163
- // ─── Logit capture ─────────────────────────────────────────────────────────
164
-
165
- /**
166
- * A LogitProcessor that captures the raw next-token logits and passes them
167
- * through unmodified.
168
- *
169
- * web-llm invokes `processLogits` on the CPU after the model's forward pass and
170
- * before sampling, handing us the full `Float32Array(vocab_size)` at the current
171
- * decode position. We copy it off web-llm's shared buffer (which it may reuse
172
- * across calls) and return the original untouched so sampling is unaffected.
173
- *
174
- * This is the raw-logit access the BE-parity algorithm needs (masked softmax +
175
- * prefix expansion, consumed in a later step). Registered for the causal LM
176
- * only — the embedder never decodes tokens, so it produces no logits.
177
- */
178
- class CapturingLogitProcessor {
179
- constructor() {
180
- _defineProperty(this, "captured", null);
181
- _defineProperty(this, "processLogits", logits => {
182
- // Copy off web-llm's shared buffer — it may reuse `logits` across calls.
183
- this.captured = new Float32Array(logits);
184
- return logits;
185
- });
186
- _defineProperty(this, "processSampledToken", () => {
187
- // No-op — we don't track sampled tokens.
188
- });
189
- _defineProperty(this, "resetState", () => {
190
- this.captured = null;
191
- });
192
- }
193
- }
194
-
195
- // ─── BE-parity data + algorithm ──────────────────────────────────────────────
196
-
197
- /**
198
- * Prefix-expansion map: first-token id → words whose space-prefixed SmolLM2
199
- * encoding starts with that token. Generated offline by
200
- * `scripts/gen_first_token_to_words.py`, which mirrors the BE's in-memory map
201
- * (`CausalLMEncoder._ensure_loaded`).
202
- *
203
- * Populated lazily by `loadBePayloadData()` from a dynamically-imported JSON so
204
- * the (large) payload is only fetched when the local client is actually
205
- * initialised — keeping it out of the editor's main chunk for the vast majority
206
- * of users (who run with `useLocalModel` off).
207
- */
208
- let firstTokenToWords = new Map();
209
-
210
216
  /**
211
- * L2 (Atlassian-domain) word set, derived from the keys of `vocabulary_10k.json`.
212
- * Used by `computeBePayload` for tier-aware ranking: any word in the prefix map
213
- * that is not in this set is treated as L3 (general English), matching the BE.
214
- * Populated lazily alongside `firstTokenToWords` — see `loadBePayloadData()`.
217
+ * Full canonical leading-space token sequence for every served surface. The
218
+ * producer keeps the existing `phrase-continuation-tokens.json` wire name while
219
+ * expanding its key set to the complete word/bigram/phrase union.
215
220
  */
216
- let l2Words = new Set();
217
-
218
- /**
219
- * Array of token IDs that appear as a first token for at least one vocabulary
220
- * word. Derived from `firstTokenToWords` when the data loads so `computeBePayload`
221
- * does not re-allocate this array on every word-boundary call.
222
- */
223
- let prefixMapTokenIds = [];
221
+ let surfaceTokenIds = new Map();
222
+ let surfaceTokenTrie = new CanonicalSurfaceTokenTrie();
224
223
 
225
224
  /** De-dupes concurrent loads and lets repeated calls await the same payload. */
226
- let bePayloadDataPromise;
225
+ let surfaceTokenIdsPromise;
226
+ const isPhraseContinuationTokens = payload => {
227
+ if (payload == null || typeof payload !== 'object') {
228
+ return false;
229
+ }
230
+ return Object.values(payload).every(value => Array.isArray(value) && value.every(entry => typeof entry === 'number' && entry >= 0));
231
+ };
227
232
 
228
- /**
229
- * Lazily load and build the BE-parity lookup tables from their CDN payloads.
230
- * Both artifacts are fetched on demand so neither is downloaded unless local
231
- * inference is initialised.
232
- *
233
- * :returns:
234
- * A promise that resolves once `firstTokenToWords`, `l2Words` and
235
- * `prefixMapTokenIds` are populated.
236
- */
237
- const loadBePayloadData = () => {
238
- if (!bePayloadDataPromise) {
239
- bePayloadDataPromise = (async () => {
240
- const [firstTokenToWordsData, vocabularyData] = await Promise.all([fetchArtifactJson(ARTIFACT_NAME.FIRST_TOKEN_TO_WORDS), fetchArtifactJson(ARTIFACT_NAME.VOCABULARY)]);
241
- if (firstTokenToWordsData == null || (vocabularyData === null || vocabularyData === void 0 ? void 0 : vocabularyData.words) == null) {
242
- // Hard-fail with a precise message so the catch() in initEngine logs
243
- // exactly which payload was malformed, rather than the generic V8
244
- // "Cannot convert undefined or null to object".
245
- throw new Error(`[LocalSlowLane] unexpected artifact payload — ` + `firstTokenToWordsData=${firstTokenToWordsData == null ? 'null/undefined' : 'defined'}, ` + `vocabularyData=${vocabularyData == null ? 'null/undefined' : vocabularyData.words == null ? 'defined but missing .words' : 'defined'}`);
246
- }
247
- firstTokenToWords = new Map(Object.entries(firstTokenToWordsData).map(([tokenId, words]) => [Number(tokenId), words]));
248
- l2Words = new Set(Object.keys(vocabularyData.words));
249
- prefixMapTokenIds = Array.from(firstTokenToWords.keys());
233
+ /** Lazily load the producer's full-union canonical surface token map. */
234
+ const loadCanonicalSurfaceTokens = () => {
235
+ if (!surfaceTokenIdsPromise) {
236
+ surfaceTokenIdsPromise = (async () => {
237
+ const continuationTokensData = await fetchAutocompleteArtifactJson(ARTIFACT_NAME.PHRASE_CONTINUATION_TOKENS, {
238
+ summarize: payload => `${Object.keys(payload).length} surfaces`,
239
+ validate: isPhraseContinuationTokens
240
+ }).catch(() => null);
241
+ surfaceTokenIds = new Map(Object.entries(continuationTokensData !== null && continuationTokensData !== void 0 ? continuationTokensData : {}).map(([surface, tokenIds]) => [surface.toLowerCase(), tokenIds]));
242
+ surfaceTokenTrie = new CanonicalSurfaceTokenTrie(surfaceTokenIds.entries());
250
243
  if (isAutocompleteDebugEnabled()) {
251
244
  // eslint-disable-next-line no-console
252
- console.log('%c[LocalSlowLane] %c✅ BE-parity payload data loaded:', 'color: #9c27b0; font-weight: bold;', 'color: #4caf50; font-weight: bold;', {
253
- firstTokenToWordsEntries: firstTokenToWords.size,
254
- l2WordsCount: l2Words.size,
255
- prefixMapTokenIdsLength: prefixMapTokenIds.length
245
+ console.log(`%c[CTC:model] %c${surfaceTokenIds.size > 0 ? '✅ canonical surface tokens loaded:' : '⚠️ canonical surface tokens unavailable — LM evidence will remain absent:'}`, 'color: #9c27b0; font-weight: bold;', surfaceTokenIds.size > 0 ? 'color: #4caf50; font-weight: bold;' : 'color: #ff9800; font-weight: bold;', {
246
+ surfaces: surfaceTokenIds.size
256
247
  });
257
248
  }
258
249
  })().catch(e => {
259
250
  // Don't cache a rejected promise — a transient import failure would
260
251
  // otherwise prevent the local model from ever initialising again this
261
252
  // session. Reset so the next init attempt retries.
262
- bePayloadDataPromise = undefined;
253
+ surfaceTokenIdsPromise = undefined;
263
254
  throw e;
264
255
  });
265
256
  }
266
- return bePayloadDataPromise;
267
- };
268
-
269
- /**
270
- * Convert a raw next-token logit vector into a whole-word probability payload,
271
- * faithfully porting the BE `CausalLMEncoder._get_top_k_probs`
272
- * (`cc-smarts/python-sidecar/src/causal_lm_encoder.py`).
273
- *
274
- * Steps: (1) numerically-stable masked softmax over only the token ids present
275
- * in the prefix-expansion map; (2) spread each token's probability to every
276
- * whole word sharing that first token, taking the max; (3) reserve the top L2
277
- * words unconditionally; (4) rank the remainder in a log-space pool with an
278
- * additive L2 bias; (5) emit raw probabilities for the survivors, lowercased
279
- * and trimmed at `MIN_PROB`.
280
- *
281
- * :params:
282
- * rawLogits: Full-vocabulary logits from the LM's single decode step
283
- * prefixMap: Map of first-token id to the words starting with that token
284
- * domainWords: Set of L2 (domain) words, for tier-aware ranking
285
- * :returns:
286
- * A record of lowercase word to probability — the BE `lm_logits` payload
287
- */
288
- export const computeBePayload = (rawLogits, prefixMap, domainWords,
289
- /**
290
- * Pre-derived token-ID array for the softmax mask. Defaults to the
291
- * module-level `prefixMapTokenIds` (zero allocation in production). Pass
292
- * `Array.from(prefixMap.keys())` in tests that supply a custom prefixMap so
293
- * the softmax mask stays consistent with the iteration in Step 2.
294
- */
295
- validTokenIds = prefixMapTokenIds) => {
296
- // 1. Numerically-stable masked softmax over validTokenIds only.
297
- let maxLogit = -Infinity;
298
- for (const id of validTokenIds) {
299
- const v = rawLogits[id];
300
- if (v > maxLogit) {
301
- maxLogit = v;
302
- }
303
- }
304
- let sumExp = 0;
305
- const expByToken = new Map();
306
- for (const id of validTokenIds) {
307
- const e = Math.exp(rawLogits[id] - maxLogit);
308
- expByToken.set(id, e);
309
- sumExp += e;
310
- }
311
-
312
- // 2. Prefix expansion with max aggregation (probabilities sum to 1 over the
313
- // masked subset, so divide each token's exp by sumExp on the fly).
314
- const wordProbs = new Map();
315
- for (const [id, words] of prefixMap) {
316
- var _expByToken$get;
317
- const p = sumExp > 0 ? ((_expByToken$get = expByToken.get(id)) !== null && _expByToken$get !== void 0 ? _expByToken$get : 0) / sumExp : 0;
318
- for (const w of words) {
319
- var _wordProbs$get;
320
- const prev = (_wordProbs$get = wordProbs.get(w)) !== null && _wordProbs$get !== void 0 ? _wordProbs$get : 0;
321
- if (p > prev) {
322
- wordProbs.set(w, p);
323
- }
324
- }
325
- }
326
-
327
- // 3. Split into L2 / L3 and reserve the top L2 slots unconditionally.
328
- const l2Matches = [];
329
- const l3Matches = [];
330
- for (const [w, p] of wordProbs) {
331
- if (domainWords.has(w)) {
332
- l2Matches.push([w, p]);
333
- } else {
334
- l3Matches.push([w, p]);
335
- }
336
- }
337
- l2Matches.sort((a, b) => b[1] - a[1]);
338
- const reserved = l2Matches.slice(0, BE_PARITY.RESERVED_L2_SLOTS);
339
-
340
- // 4. Pool the leftovers in log space; the L2 bias only affects ranking here.
341
- // Words in l2Matches are unique and the array is sorted descending, so the
342
- // non-reserved entries are exactly the tail after the reserved prefix — slice
343
- // it directly rather than allocating a Set and scanning every entry on this
344
- // hot path (runs ~every word boundary while typing).
345
- const pool = [];
346
- for (const [w, p] of l2Matches.slice(BE_PARITY.RESERVED_L2_SLOTS)) {
347
- pool.push([w, Math.log(Math.max(p, 1e-10)) + BE_PARITY.L2_BIAS]);
348
- }
349
- for (const [w, p] of l3Matches) {
350
- pool.push([w, Math.log(Math.max(p, 1e-10))]);
351
- }
352
- pool.sort((a, b) => b[1] - a[1]);
353
- const remainingSlots = Math.max(0, BE_PARITY.TOP_K_WORDS - reserved.length);
354
- const poolWinners = pool.slice(0, remainingSlots);
355
-
356
- // 5. Assemble payload: store RAW probabilities (the bias was ranking-only),
357
- // lowercase keys, trimmed at MIN_PROB. Reserved first, then pool winners.
358
- // Reserved entries are written first; pool-winner writes must NOT clobber a
359
- // reserved entry whose normalised key collides (two source words can
360
- // `.trim().toLowerCase()` to the same key — e.g. "Function" vs "function ").
361
- // Without the existence guard, a low-probability pool winner would silently
362
- // overwrite the (higher-probability) reserved entry, degrading top-K
363
- // quality in a way that's invisible from the debug summary.
364
- const result = {};
365
- const addEntry = (word, prob, allowOverwrite) => {
366
- if (prob <= BE_PARITY.MIN_PROB) {
367
- return;
368
- }
369
- const key = word.trim().toLowerCase();
370
- if (!allowOverwrite && key in result) {
371
- return;
372
- }
373
- result[key] = prob;
374
- };
375
- for (const [w, p] of reserved) {
376
- addEntry(w, p, true);
377
- }
378
- for (const [w] of poolWinners) {
379
- var _wordProbs$get2;
380
- addEntry(w, (_wordProbs$get2 = wordProbs.get(w)) !== null && _wordProbs$get2 !== void 0 ? _wordProbs$get2 : 0, false);
381
- }
382
- if (isAutocompleteDebugEnabled()) {
383
- const topReserved = reserved.slice(0, 5).map(([w, p]) => `${w}:${(p * 100).toFixed(2)}%`).join(', ');
384
- const topPool = poolWinners.slice(0, 5).map(([w]) => {
385
- var _wordProbs$get3;
386
- return `${w}:${(((_wordProbs$get3 = wordProbs.get(w)) !== null && _wordProbs$get3 !== void 0 ? _wordProbs$get3 : 0) * 100).toFixed(2)}%`;
387
- }).join(', ');
388
- // eslint-disable-next-line no-console
389
- console.log('%c[computeBePayload] %c%d valid tokens → %d words expanded | L2: %d / L3: %d | reserved: %d | pool winners: %d | final: %d words\n maxLogit(masked): %s | sumExp: %s\n top reserved L2: %s\n top pool: %s', 'color: #9c27b0; font-weight: bold;', 'color: inherit;', validTokenIds.length, wordProbs.size, l2Matches.length, l3Matches.length, reserved.length, poolWinners.length, Object.keys(result).length, maxLogit.toFixed(3), sumExp.toFixed(1), topReserved || '(none)', topPool || '(none)');
390
- }
391
- return result;
257
+ return surfaceTokenIdsPromise;
392
258
  };
393
259
 
394
260
  // ─── Factory ─────────────────────────────────────────────────────────────────
@@ -404,9 +270,8 @@ validTokenIds = prefixMapTokenIds) => {
404
270
  * const client = createLocalSlowLaneClient({ debounceMs: 300 });
405
271
  * // On word boundaries:
406
272
  * client.updateContext(docText);
407
- * // In scoring pipeline:
408
- * const vec = client.getContextVector();
409
- * const logits = client.getLmLogits();
273
+ * // Candidate scoring independently calls
274
+ * // primeBoundaryLm/requestProgressiveSurfaceScores.
410
275
  * // On plugin teardown:
411
276
  * client.destroy();
412
277
  * ```
@@ -415,6 +280,8 @@ export const createLocalSlowLaneClient = (config = {}) => {
415
280
  const {
416
281
  debounceMs = DEFAULT_DEBOUNCE_MS,
417
282
  onUpdate,
283
+ onBoundaryLmUpdate,
284
+ onSurfaceScoreUpdate,
418
285
  onStatus,
419
286
  onLoadError,
420
287
  onLoadSuccess,
@@ -424,8 +291,48 @@ export const createLocalSlowLaneClient = (config = {}) => {
424
291
  } = config;
425
292
 
426
293
  // ── State ──────────────────────────────────────────────────────────────
294
+ let storedContextInput = null;
427
295
  let storedContextVector = null;
428
296
  let storedLmLogits = null;
297
+ const boundaryCache = new Map();
298
+ const prefixExpansionCache = new Map();
299
+ const surfaceScoreCache = new Map();
300
+ let causalInFlight = false;
301
+ let inFlightBoundaryContextKey = null;
302
+ let latestCausalFamilyKey = '';
303
+ let causalRequestsThisFamily = 0;
304
+ let causalGeneratedTokensThisFamily = 0;
305
+ let tierAPrimeCacheHitsThisFamily = 0;
306
+ let tierAPrimeCacheMissesThisFamily = 0;
307
+ let causalInferenceOrdinal = 0;
308
+ const causalInferenceByContext = new Map();
309
+ const causalInferenceByFamily = new Map();
310
+ const exactEvidenceCountByContext = new Map();
311
+ const pendingBoundaryPrimes = new Map();
312
+ const pendingPrefixExpansions = new Map();
313
+ const inFlightPrefixExpansions = new Set();
314
+ // The engine holds exactly one linear KV sequence. WebLLM lets us extend it
315
+ // (`forwardTokensAndSample`) or drop it (a text completion always resets
316
+ // first), but never fork or rewind it. Tracking what is currently
317
+ // materialised is what makes decoding continuous: expanding a token prefix
318
+ // that extends `kvPath` costs a single decode step, while any other prefix
319
+ // costs a fresh prompt prefill.
320
+ let kvContextKey = null;
321
+ let kvPath = [];
322
+ const isPrefixOf = (prefix, path) => prefix.length <= path.length && prefix.every((token, index) => token === path[index]);
323
+ /**
324
+ * Whether running this expansion would extend the sequence the engine is
325
+ * already holding rather than discarding it for a fresh prompt prefill.
326
+ *
327
+ * `planProgressiveExpansion` also prefers a warm group, but it decides when
328
+ * the work is queued and the queue is drained later. A boundary prime or
329
+ * another context's expansion running in between moves the path out from
330
+ * under that choice, so the preference has to be re-checked at the moment
331
+ * something is picked up.
332
+ */
333
+ const extendsLiveKvPath = request => kvContextKey === request.contextKey && isPrefixOf(kvPath, request.tokenPrefix);
334
+ const progressiveRequests = new Map();
335
+ const progressivePrefixesByContext = new Map();
429
336
  let debounceTimer = null;
430
337
  let lastRequestedText = '';
431
338
  let requestCounter = 0;
@@ -443,14 +350,12 @@ export const createLocalSlowLaneClient = (config = {}) => {
443
350
  let initFailed = false;
444
351
  let engine = null;
445
352
  let engineInitPromise = null;
446
- // Captures raw next-token logits from the LM's single decode step. Registered
447
- // with the engine below; `lmLogitsCapture.captured` is consumed in a later step.
448
- const lmLogitsCapture = new CapturingLogitProcessor();
353
+ const causalLogitProcessor = new CanonicalLogitProcessor();
449
354
  const unloadEngine = engineToUnload => {
450
355
  engineToUnload.unload().catch(error => {
451
356
  if (isAutocompleteDebugEnabled()) {
452
357
  // eslint-disable-next-line no-console
453
- console.log('%c[LocalSlowLane] %cFailed to unload engine', 'color: #9c27b0; font-weight: bold;', 'color: inherit;', error);
358
+ console.log('%c[CTC:model] %cFailed to unload engine', 'color: #9c27b0; font-weight: bold;', 'color: inherit;', error);
454
359
  }
455
360
  });
456
361
  };
@@ -461,7 +366,7 @@ export const createLocalSlowLaneClient = (config = {}) => {
461
366
  const message = `[${(progress.progress * 100).toFixed(0)}%] ${progress.text}`;
462
367
  if (isAutocompleteDebugEnabled()) {
463
368
  // eslint-disable-next-line no-console
464
- console.log(`%c[LocalSlowLane] %c🔄 ${message}`, 'color: #9c27b0; font-weight: bold;', 'color: inherit;');
369
+ console.log(`%c[CTC:model] %c🔄 ${message}`, 'color: #9c27b0; font-weight: bold;', 'color: inherit;');
465
370
  }
466
371
  onStatus === null || onStatus === void 0 ? void 0 : onStatus(message);
467
372
  };
@@ -552,7 +457,7 @@ export const createLocalSlowLaneClient = (config = {}) => {
552
457
  const message = LOAD_FAILURE_MESSAGE[reason];
553
458
  if (isAutocompleteDebugEnabled()) {
554
459
  // eslint-disable-next-line no-console
555
- console.log(`[LocalSlowLane] Engine initialisation failed (${reason}): ${debugDetail !== null && debugDetail !== void 0 ? debugDetail : message}`);
460
+ console.log(`[CTC:model] Engine initialisation failed (${reason}): ${debugDetail !== null && debugDetail !== void 0 ? debugDetail : message}`);
556
461
  }
557
462
  onStatus === null || onStatus === void 0 ? void 0 : onStatus(`Engine initialisation failed: ${message}`);
558
463
  onLoadError === null || onLoadError === void 0 ? void 0 : onLoadError({
@@ -585,16 +490,16 @@ export const createLocalSlowLaneClient = (config = {}) => {
585
490
  try {
586
491
  if (isAutocompleteDebugEnabled()) {
587
492
  // eslint-disable-next-line no-console
588
- console.log(`%c[LocalSlowLane] %c🚀 Initialising MLC engine with models: ${modelId} (LM) + ${LOCAL_MLC_EMBEDDING_MODEL_ID} (embedder)`, 'color: #9c27b0; font-weight: bold;', 'color: inherit;');
493
+ console.log(`%c[CTC:model] %c🚀 Initialising MLC engine with models: ${modelId} (LM) + ${LOCAL_MLC_EMBEDDING_MODEL_ID} (embedder)`, 'color: #9c27b0; font-weight: bold;', 'color: inherit;');
589
494
  }
590
495
  onStatus === null || onStatus === void 0 ? void 0 : onStatus(`Initialising models: ${modelId} + ${LOCAL_MLC_EMBEDDING_MODEL_ID}…`);
591
496
 
592
- // Fetch the web-llm runtime and the BE-parity lookup tables in parallel;
497
+ // Fetch the web-llm runtime and canonical token artifact in parallel;
593
498
  // both are dynamically imported so they stay out of the main editor chunk.
594
499
  const [{
595
500
  MLCEngine: MLCEngineCtor,
596
501
  prebuiltAppConfig
597
- }] = await Promise.all([import( /* webpackChunkName: "@atlaskit-internal_editor-plugin-autocomplete-mlc-web-llm" */'@mlc-ai/web-llm'), loadBePayloadData()]);
502
+ }] = await Promise.all([import( /* webpackChunkName: "@atlaskit-internal_editor-plugin-autocomplete-mlc-web-llm" */'@mlc-ai/web-llm'), loadCanonicalSurfaceTokens()]);
598
503
  const customModelRecord = customModelConfig ? {
599
504
  model: customModelConfig.model,
600
505
  model_id: modelId,
@@ -620,7 +525,7 @@ export const createLocalSlowLaneClient = (config = {}) => {
620
525
  const newEngine = new MLCEngineCtor({
621
526
  appConfig,
622
527
  initProgressCallback,
623
- logitProcessorRegistry: new Map([[modelId, lmLogitsCapture]])
528
+ logitProcessorRegistry: new Map([[modelId, causalLogitProcessor]])
624
529
  });
625
530
  await newEngine.reload([modelId, LOCAL_MLC_EMBEDDING_MODEL_ID]);
626
531
  if (destroyed) {
@@ -633,13 +538,13 @@ export const createLocalSlowLaneClient = (config = {}) => {
633
538
  const loadDurationMs = Math.round(performance.now() - startTime);
634
539
  if (isAutocompleteDebugEnabled()) {
635
540
  // eslint-disable-next-line no-console
636
- console.log('%c[LocalSlowLane] %c✅ Both models loaded and ready', 'color: #9c27b0; font-weight: bold;', 'color: #4caf50;');
541
+ console.log('%c[CTC:model] %c✅ Both models loaded and ready', 'color: #9c27b0; font-weight: bold;', 'color: #4caf50;');
637
542
  // One-time identity summary so you can confirm which models are active
638
543
  // without digging through the init-progress scroll.
639
544
  // eslint-disable-next-line no-console
640
- console.log('%c[LocalSlowLane] %c🧠 Causal LM →', 'color: #9c27b0; font-weight: bold;', 'color: #2196f3; font-weight: bold;', modelId);
545
+ console.log('%c[CTC:model] %c🧠 Causal LM →', 'color: #9c27b0; font-weight: bold;', 'color: #2196f3; font-weight: bold;', modelId);
641
546
  // eslint-disable-next-line no-console
642
- console.log('%c[LocalSlowLane] %c🔢 Embedder →', 'color: #9c27b0; font-weight: bold;', 'color: #009688; font-weight: bold;', LOCAL_MLC_EMBEDDING_MODEL_ID);
547
+ console.log('%c[CTC:model] %c🔢 Embedder →', 'color: #9c27b0; font-weight: bold;', 'color: #009688; font-weight: bold;', LOCAL_MLC_EMBEDDING_MODEL_ID);
643
548
  }
644
549
  onStatus === null || onStatus === void 0 ? void 0 : onStatus('Model loaded and ready.');
645
550
  onLoadSuccess === null || onLoadSuccess === void 0 ? void 0 : onLoadSuccess({
@@ -665,47 +570,17 @@ export const createLocalSlowLaneClient = (config = {}) => {
665
570
 
666
571
  // ── Inference ──────────────────────────────────────────────────────────
667
572
 
668
- /**
669
- * Run a single forward pass to produce the BE-parity slow-lane outputs.
670
- *
671
- * Two calls run in parallel on the shared engine:
672
- * - `completions.create({ max_tokens: 1 })` runs the causal LM for exactly
673
- * one decode step. We ignore the generated text; the LogitProcessor
674
- * captures the raw next-token logits during that step, which we turn into
675
- * a whole-word payload via `computeBePayload`.
676
- * - `embeddings.create(...)` runs the Arctic embedder to produce the real
677
- * 384-d semantic vector (passage-encoded; see `wrapForArctic`).
678
- */
573
+ /** Run the boundary-timed Arctic semantic inference independently of the LM. */
679
574
  const runInference = async (text, requestId) => {
680
575
  if (!engine || destroyed) {
681
576
  return;
682
577
  }
683
578
  const experienceId = String(requestId);
684
-
685
- // Clear the capture buffer so we read only this pass's logits. The engine
686
- // serialises per-model requests and updateContext is debounced, so the
687
- // latest request's decode step is the last to populate `captured` before
688
- // we read it below; stale requests bail on the latestRequestId guard.
689
- lmLogitsCapture.resetState();
690
-
691
- // Apply BE-parity rolling-window truncation before both encoders.
692
- // BE semantic: last max_context_words words (typeahead_context_encoding.py:36)
693
- // BE causal LM: last max_context_tokens BPE tokens (causal_lm_encoder.py:194–198),
694
- // approximated here with word count (no tokenizer available on FE).
695
- const lmText = truncateToLastNWords(text, BE_PARITY.MAX_CONTEXT_TOKENS);
696
- const semanticText = truncateToLastNWords(text, BE_PARITY.MAX_CONTEXT_WORDS);
579
+ const semanticText = truncateToLastNWords(text, LOCAL_INFERENCE.MAX_CONTEXT_WORDS);
697
580
  const arcticInput = wrapForArctic(semanticText);
698
- function captureCompletionTime(promise, onResolved) {
699
- return promise.then(value => {
700
- onResolved(performance.now());
701
- return value;
702
- });
703
- }
704
- if (isAutocompleteDebugEnabled()) {
581
+ if (isAutocompleteDebugVerbose()) {
705
582
  // eslint-disable-next-line no-console
706
- console.log(`%c[LocalSlowLane] %c🔢 Arctic input (${arcticInput.length} chars, ${splitOnWhitespace(semanticText).length} words): "${arcticInput.length > 100 ? `${arcticInput.slice(0, 100)}…` : arcticInput}"`, 'color: #9c27b0; font-weight: bold;', 'color: #009688;');
707
- // eslint-disable-next-line no-console
708
- console.log(`%c[LocalSlowLane] %c🧠 LM input (${lmText.length} chars, ${splitOnWhitespace(lmText).length} words): "${lmText.length > 100 ? `${lmText.slice(0, 100)}…` : lmText}"`, 'color: #9c27b0; font-weight: bold;', 'color: #2196f3;');
583
+ console.log(`%c[CTC:model] %c🔢 Arctic input (${arcticInput.length} chars, ${splitOnWhitespace(semanticText).length} words): "${arcticInput.length > 100 ? `${arcticInput.slice(0, 100)}…` : arcticInput}"`, 'color: #9c27b0; font-weight: bold;', 'color: #009688;');
709
584
  }
710
585
  try {
711
586
  var _data, _data$;
@@ -723,25 +598,14 @@ export const createLocalSlowLaneClient = (config = {}) => {
723
598
  } : {})
724
599
  });
725
600
  const tStart = performance.now();
726
- let tLmDone = 0;
727
- let tEmbDone = 0;
728
- const [, embeddingResponse] = await Promise.all([captureCompletionTime(engine.completions.create({
729
- model: modelId,
730
- prompt: lmText,
731
- max_tokens: 1,
732
- temperature: 0,
733
- logprobs: false
734
- }), resolvedAt => {
735
- tLmDone = resolvedAt;
736
- }), captureCompletionTime(engine.embeddings.create({
601
+ const embeddingResponse = await engine.embeddings.create({
737
602
  model: LOCAL_MLC_EMBEDDING_MODEL_ID,
738
603
  input: arcticInput
739
- }), resolvedAt => {
740
- tEmbDone = resolvedAt;
741
- })]);
604
+ });
605
+ const tEmbDone = performance.now();
742
606
  if (isAutocompleteDebugEnabled()) {
743
607
  // eslint-disable-next-line no-console
744
- console.log(`%c[LocalSlowLane] %c⏱ LM: ${(tLmDone - tStart).toFixed(0)}ms | Embedder: ${(tEmbDone - tStart).toFixed(0)}ms | Total: ${(Math.max(tLmDone, tEmbDone) - tStart).toFixed(0)}ms`, 'color: #9c27b0; font-weight: bold;', 'color: #ff9800;');
608
+ console.log(`%c[CTC:model] %c⏱ Embedder: ${(tEmbDone - tStart).toFixed(0)}ms`, 'color: #9c27b0; font-weight: bold;', 'color: #ff9800;');
745
609
  }
746
610
 
747
611
  // Discard stale results
@@ -755,24 +619,16 @@ export const createLocalSlowLaneClient = (config = {}) => {
755
619
  return;
756
620
  }
757
621
 
758
- // ── LM logits: whole-word BE-parity payload ──────────────────
759
- const rawLogits = lmLogitsCapture.captured;
760
- if (rawLogits) {
761
- const payload = computeBePayload(rawLogits, firstTokenToWords, l2Words);
762
- storedLmLogits = Object.keys(payload).length > 0 ? payload : null;
763
- } else {
764
- storedLmLogits = null;
765
- }
766
-
767
622
  // ── Semantic vector: real 384-d Arctic embedding ─────────────
768
623
  // Guard against base64-encoded responses (encoding_format: 'base64' would
769
624
  // yield a string, and new Float32Array(string) silently produces an empty
770
625
  // array, corrupting downstream cosine-similarity scoring).
771
626
  const embedding = (_data = embeddingResponse.data) === null || _data === void 0 ? void 0 : (_data$ = _data[0]) === null || _data$ === void 0 ? void 0 : _data$.embedding;
772
627
  storedContextVector = Array.isArray(embedding) && embedding.length > 0 ? new Float32Array(embedding) : null;
628
+ storedContextInput = storedContextVector ? semanticText : null;
773
629
  if (isAutocompleteDebugEnabled()) {
774
630
  // eslint-disable-next-line no-console
775
- console.groupCollapsed(`%c[LocalSlowLane] %c📥 Inference result (request #${requestId})`, 'color: #9c27b0; font-weight: bold;', 'color: inherit;');
631
+ console.groupCollapsed(`%c[CTC:model] %c📥 Inference result (request #${requestId})`, 'color: #9c27b0; font-weight: bold;', 'color: inherit;');
776
632
  if (storedContextVector) {
777
633
  let sumSq = 0;
778
634
  for (let i = 0; i < storedContextVector.length; i++) {
@@ -785,19 +641,14 @@ export const createLocalSlowLaneClient = (config = {}) => {
785
641
  console.log('❌ No vector');
786
642
  }
787
643
  // eslint-disable-next-line no-console
788
- console.log(storedLmLogits ? `✅ lm_logits: ${Object.keys(storedLmLogits).length} words` : '❌ No lm_logits');
789
- if (storedLmLogits) {
790
- const topTokens = Object.entries(storedLmLogits).sort(([, a], [, b]) => b - a).slice(0, 10);
791
- // eslint-disable-next-line no-console
792
- console.log('Top 10 predictions:', topTokens.map(([t, p]) => `${t}: ${(p * 100).toFixed(1)}%`).join(', '));
793
- }
644
+ console.log('🧠 causal LM: independently primed by exact candidate contexts');
794
645
  // eslint-disable-next-line no-console
795
646
  console.groupEnd();
796
647
  }
797
648
  succeedExp(EXPERIENCE_NAME.SLOW_LANE_FETCH, experienceId, {
798
649
  textLength: text.length,
799
650
  hasVector: storedContextVector !== null,
800
- hasLmLogits: storedLmLogits !== null,
651
+ hasLmLogits: false,
801
652
  isLocalLLM: true,
802
653
  ...(surface ? {
803
654
  surface
@@ -806,7 +657,7 @@ export const createLocalSlowLaneClient = (config = {}) => {
806
657
  onUpdate === null || onUpdate === void 0 ? void 0 : onUpdate({
807
658
  textLength: text.length,
808
659
  hasVector: storedContextVector !== null,
809
- hasLmLogits: storedLmLogits !== null
660
+ hasLmLogits: false
810
661
  });
811
662
  } catch (err) {
812
663
  // Discard errors for stale requests or after teardown
@@ -819,6 +670,7 @@ export const createLocalSlowLaneClient = (config = {}) => {
819
670
  });
820
671
  return;
821
672
  }
673
+ storedContextInput = null;
822
674
  storedContextVector = null;
823
675
  storedLmLogits = null;
824
676
  failExp(EXPERIENCE_NAME.SLOW_LANE_FETCH, experienceId, {
@@ -836,11 +688,671 @@ export const createLocalSlowLaneClient = (config = {}) => {
836
688
  const errorMsg = err instanceof Error ? err.message : String(err);
837
689
  if (isAutocompleteDebugEnabled()) {
838
690
  // eslint-disable-next-line no-console
839
- console.log(`%c[LocalSlowLane] %c❌ Inference error (request #${requestId}): ${errorMsg}`, 'color: #9c27b0; font-weight: bold;', 'color: #f44336;');
691
+ console.log(`%c[CTC:model] %c❌ Inference error (request #${requestId}): ${errorMsg}`, 'color: #9c27b0; font-weight: bold;', 'color: #f44336;');
840
692
  }
841
693
  }
842
694
  };
843
695
 
696
+ // ── Canonical causal scorer ─────────────────────────────────────────────
697
+
698
+ const surfaceCacheKey = (contextKey, surface, tokenIds = (_surfaceTokenIds$get => (_surfaceTokenIds$get = surfaceTokenIds.get(surface.toLowerCase())) !== null && _surfaceTokenIds$get !== void 0 ? _surfaceTokenIds$get : [])()) => `${contextKey}\u0000${surface.toLowerCase()}\u0000${tokenIds.join(',')}`;
699
+
700
+ // Declared as functions rather than generic arrows: this file carries a `webpackChunkName`
701
+ // comment, which opts it into a build-time parse that reads `<T>(` as a JSX tag.
702
+ function getLru(cache, key) {
703
+ const value = cache.get(key);
704
+ if (value === undefined) {
705
+ return null;
706
+ }
707
+ cache.delete(key);
708
+ cache.set(key, value);
709
+ return value;
710
+ }
711
+ function setLru(cache, key, value, maxSize) {
712
+ cache.delete(key);
713
+ cache.set(key, value);
714
+ while (cache.size > maxSize) {
715
+ const oldest = cache.keys().next().value;
716
+ if (oldest === undefined) {
717
+ break;
718
+ }
719
+ cache.delete(oldest);
720
+ }
721
+ }
722
+ const prefixCacheKey = (contextKey, tokenPrefix) => `${contextKey}\u0000${tokenPrefix.join(',')}`;
723
+ const logProgressiveState = (state, detail) => {
724
+ if (!isAutocompleteDebugEnabled()) {
725
+ return;
726
+ }
727
+ // eslint-disable-next-line no-console
728
+ console.log(`%c[CTC:model] %c🔀 grouped ${state} · ${detail}`, 'color: #9c27b0; font-weight: bold;', state === 'failed' ? 'color: #f44336;' : state === 'completed' || state === 'cached' ? 'color: #4caf50;' : 'color: #2196f3;');
729
+ };
730
+ const createCausalInferenceAggregate = startedAt => ({
731
+ e2eLatencyMs: 0,
732
+ failedRequests: 0,
733
+ firstStartedAt: startedAt,
734
+ promptTokens: 0,
735
+ promptUsageSamples: 0,
736
+ requestedCompletionTokens: 0,
737
+ requests: 0,
738
+ sampledOutputTokens: 0,
739
+ timeToFirstTokenMs: 0,
740
+ wallClockMs: 0,
741
+ webLlmDecodeSteps: 0,
742
+ webLlmUsageSamples: 0
743
+ });
744
+ const updateCausalInferenceAggregate = (cache, key, measurement) => {
745
+ var _getLru;
746
+ const aggregate = (_getLru = getLru(cache, key)) !== null && _getLru !== void 0 ? _getLru : createCausalInferenceAggregate(performance.now() - measurement.wallClockMs);
747
+ aggregate.requests++;
748
+ aggregate.requestedCompletionTokens += measurement.requestedCompletionTokens;
749
+ aggregate.wallClockMs += measurement.wallClockMs;
750
+ if (measurement.outcome === 'failed') {
751
+ aggregate.failedRequests++;
752
+ }
753
+ if (measurement.promptTokens !== null) {
754
+ aggregate.promptTokens += measurement.promptTokens;
755
+ aggregate.promptUsageSamples++;
756
+ }
757
+ if (measurement.sampledOutputTokens !== null && measurement.webLlmDecodeSteps !== null) {
758
+ aggregate.sampledOutputTokens += measurement.sampledOutputTokens;
759
+ aggregate.webLlmDecodeSteps += measurement.webLlmDecodeSteps;
760
+ aggregate.webLlmUsageSamples++;
761
+ }
762
+ if (measurement.timeToFirstTokenMs !== null) {
763
+ aggregate.timeToFirstTokenMs += measurement.timeToFirstTokenMs;
764
+ }
765
+ if (measurement.e2eLatencyMs !== null) {
766
+ aggregate.e2eLatencyMs += measurement.e2eLatencyMs;
767
+ }
768
+ setLru(cache, key, aggregate, CANONICAL_SCORING.BOUNDARY_CACHE_MAX);
769
+ return aggregate;
770
+ };
771
+ const causalAggregateForLog = aggregate => ({
772
+ calls: aggregate.requests,
773
+ elapsedMs: Number((performance.now() - aggregate.firstStartedAt).toFixed(1)),
774
+ failedCalls: aggregate.failedRequests,
775
+ inferenceWallMs: Number(aggregate.wallClockMs.toFixed(1)),
776
+ promptTokens: aggregate.promptUsageSamples > 0 ? aggregate.promptTokens : 'unreported',
777
+ requestedOutputTokens: aggregate.requestedCompletionTokens,
778
+ sampledOutputTokens: aggregate.webLlmUsageSamples > 0 ? aggregate.sampledOutputTokens : 'unreported',
779
+ timeToFirstTokenMs: aggregate.timeToFirstTokenMs > 0 ? Number(aggregate.timeToFirstTokenMs.toFixed(1)) : 'unreported',
780
+ webLlmE2eMs: aggregate.e2eLatencyMs > 0 ? Number(aggregate.e2eLatencyMs.toFixed(1)) : 'unreported',
781
+ webLlmDecodeSteps: aggregate.webLlmUsageSamples > 0 ? aggregate.webLlmDecodeSteps : 'unreported'
782
+ });
783
+ const recordCausalInference = measurement => {
784
+ var _measurement$promptTo, _measurement$sampledO, _measurement$webLlmDe, _measurement$timeToFi, _measurement$timeToFi2;
785
+ if (!isAutocompleteDebugEnabled()) {
786
+ return;
787
+ }
788
+ const familyAggregate = updateCausalInferenceAggregate(causalInferenceByFamily, measurement.familyKey, measurement);
789
+ const contextAggregate = updateCausalInferenceAggregate(causalInferenceByContext, measurement.contextKey, measurement);
790
+ // One line plus two nested objects per LM call is heavy enough to distort
791
+ // the latencies it reports, so keep the per-call breakdown behind verbose.
792
+ if (!isAutocompleteDebugVerbose()) {
793
+ return;
794
+ }
795
+ // eslint-disable-next-line no-console
796
+ console.log(`%c[CTC:model-cost]%c ${measurement.kind} ${measurement.outcome} · ${measurement.wallClockMs.toFixed(1)}ms · prompt ${(_measurement$promptTo = measurement.promptTokens) !== null && _measurement$promptTo !== void 0 ? _measurement$promptTo : '?'} tok/${measurement.promptWords} words · output ${(_measurement$sampledO = measurement.sampledOutputTokens) !== null && _measurement$sampledO !== void 0 ? _measurement$sampledO : '?'} sampled/${measurement.requestedCompletionTokens} requested · decode ${(_measurement$webLlmDe = measurement.webLlmDecodeSteps) !== null && _measurement$webLlmDe !== void 0 ? _measurement$webLlmDe : '?'} step${measurement.webLlmDecodeSteps === 1 ? '' : 's'} · TTFT ${(_measurement$timeToFi = (_measurement$timeToFi2 = measurement.timeToFirstTokenMs) === null || _measurement$timeToFi2 === void 0 ? void 0 : _measurement$timeToFi2.toFixed(1)) !== null && _measurement$timeToFi !== void 0 ? _measurement$timeToFi : '?'}ms · ${measurement.warmState}`, CTC_STYLES.section, CTC_STYLES.body, {
797
+ request: {
798
+ contextKey: measurement.contextKey,
799
+ familyKey: measurement.familyKey,
800
+ kind: measurement.kind,
801
+ outcome: measurement.outcome,
802
+ warmState: measurement.warmState
803
+ },
804
+ actual: {
805
+ decodeTokensPerSecond: measurement.decodeTokensPerSecond,
806
+ e2eLatencyMs: measurement.e2eLatencyMs,
807
+ prefillTokensPerSecond: measurement.prefillTokensPerSecond,
808
+ promptTokens: measurement.promptTokens,
809
+ promptWords: measurement.promptWords,
810
+ sampledOutputTokens: measurement.sampledOutputTokens,
811
+ timePerDecodeTokenMs: measurement.timePerDecodeTokenMs,
812
+ timeToFirstTokenMs: measurement.timeToFirstTokenMs,
813
+ wallClockMs: measurement.wallClockMs,
814
+ webLlmDecodeSteps: measurement.webLlmDecodeSteps
815
+ },
816
+ context: causalAggregateForLog(contextAggregate),
817
+ family: causalAggregateForLog(familyAggregate)
818
+ });
819
+ };
820
+
821
+ /**
822
+ * Pull the captured distribution out of the processor, timing the handover.
823
+ *
824
+ * Every call yields one array the width of the vocabulary. If that width is
825
+ * large and the handover is slow, thousands of calls per session turn into
826
+ * allocation churn that shows up as latency without any model work behind it.
827
+ */
828
+ const captureLogits = () => causalLogitProcessor.getCapturedLogits();
829
+ const createMeasuredCausalCompletion = async (activeEngine, input) => {
830
+ const prompt = truncateToLastNWords(input.prompt, LOCAL_INFERENCE.MAX_CONTEXT_TOKENS);
831
+ const promptWords = splitOnWhitespace(prompt).length;
832
+ const startedAt = performance.now();
833
+ const warmState = ++causalInferenceOrdinal === 1 ? 'cold-first-call' : 'warm';
834
+ try {
835
+ var _usage$completion_tok, _usage$extra$decode_t, _usage$extra, _usage$extra2, _usage$extra$prefill_, _usage$extra3, _usage$prompt_tokens, _usage$extra4, _usage$extra5;
836
+ const completion = await activeEngine.completions.create({
837
+ model: modelId,
838
+ prompt,
839
+ max_tokens: input.requestedCompletionTokens,
840
+ temperature: 0,
841
+ logprobs: false,
842
+ ignore_eos: true
843
+ });
844
+ const latencyMs = performance.now() - startedAt;
845
+ const usage = completion.usage;
846
+ const webLlmDecodeSteps = (_usage$completion_tok = usage === null || usage === void 0 ? void 0 : usage.completion_tokens) !== null && _usage$completion_tok !== void 0 ? _usage$completion_tok : null;
847
+ // WebLLM samples the first output token during prefill, but its
848
+ // completion_tokens usage counter increments only in decodeStep().
849
+ // Add that prefill-sampled token back without exceeding max_tokens.
850
+ const sampledOutputTokens = webLlmDecodeSteps === null ? null : Math.min(input.requestedCompletionTokens, webLlmDecodeSteps + 1);
851
+ recordCausalInference({
852
+ contextKey: input.contextKey,
853
+ decodeTokensPerSecond: (_usage$extra$decode_t = usage === null || usage === void 0 ? void 0 : (_usage$extra = usage.extra) === null || _usage$extra === void 0 ? void 0 : _usage$extra.decode_tokens_per_s) !== null && _usage$extra$decode_t !== void 0 ? _usage$extra$decode_t : null,
854
+ e2eLatencyMs: (usage === null || usage === void 0 ? void 0 : (_usage$extra2 = usage.extra) === null || _usage$extra2 === void 0 ? void 0 : _usage$extra2.e2e_latency_s) !== undefined ? usage.extra.e2e_latency_s * 1000 : null,
855
+ familyKey: input.familyKey,
856
+ kind: input.kind,
857
+ outcome: 'completed',
858
+ prefillTokensPerSecond: (_usage$extra$prefill_ = usage === null || usage === void 0 ? void 0 : (_usage$extra3 = usage.extra) === null || _usage$extra3 === void 0 ? void 0 : _usage$extra3.prefill_tokens_per_s) !== null && _usage$extra$prefill_ !== void 0 ? _usage$extra$prefill_ : null,
859
+ promptTokens: (_usage$prompt_tokens = usage === null || usage === void 0 ? void 0 : usage.prompt_tokens) !== null && _usage$prompt_tokens !== void 0 ? _usage$prompt_tokens : null,
860
+ promptWords,
861
+ requestedCompletionTokens: input.requestedCompletionTokens,
862
+ sampledOutputTokens,
863
+ timePerDecodeTokenMs: (usage === null || usage === void 0 ? void 0 : (_usage$extra4 = usage.extra) === null || _usage$extra4 === void 0 ? void 0 : _usage$extra4.time_per_output_token_s) !== undefined ? usage.extra.time_per_output_token_s * 1000 : null,
864
+ timeToFirstTokenMs: (usage === null || usage === void 0 ? void 0 : (_usage$extra5 = usage.extra) === null || _usage$extra5 === void 0 ? void 0 : _usage$extra5.time_to_first_token_s) !== undefined ? usage.extra.time_to_first_token_s * 1000 : null,
865
+ wallClockMs: latencyMs,
866
+ warmState,
867
+ webLlmDecodeSteps
868
+ });
869
+ return {
870
+ latencyMs
871
+ };
872
+ } catch (error) {
873
+ const latencyMs = performance.now() - startedAt;
874
+ recordCausalInference({
875
+ contextKey: input.contextKey,
876
+ decodeTokensPerSecond: null,
877
+ e2eLatencyMs: null,
878
+ familyKey: input.familyKey,
879
+ kind: input.kind,
880
+ outcome: 'failed',
881
+ prefillTokensPerSecond: null,
882
+ promptTokens: null,
883
+ promptWords,
884
+ requestedCompletionTokens: input.requestedCompletionTokens,
885
+ sampledOutputTokens: null,
886
+ timePerDecodeTokenMs: null,
887
+ timeToFirstTokenMs: null,
888
+ wallClockMs: latencyMs,
889
+ warmState,
890
+ webLlmDecodeSteps: null
891
+ });
892
+ throw error;
893
+ }
894
+ };
895
+ const logExactEvidenceReadiness = (contextKey, familyKey, exactSurfaceCount, source) => {
896
+ var _exactEvidenceCountBy;
897
+ if (!isAutocompleteDebugEnabled()) {
898
+ return;
899
+ }
900
+ const previousCount = (_exactEvidenceCountBy = exactEvidenceCountByContext.get(contextKey)) !== null && _exactEvidenceCountBy !== void 0 ? _exactEvidenceCountBy : 0;
901
+ if (exactSurfaceCount <= previousCount) {
902
+ return;
903
+ }
904
+ setLru(exactEvidenceCountByContext, contextKey, exactSurfaceCount, CANONICAL_SCORING.BOUNDARY_CACHE_MAX);
905
+ const contextAggregate = getLru(causalInferenceByContext, contextKey);
906
+ const familyAggregate = getLru(causalInferenceByFamily, familyKey);
907
+ // eslint-disable-next-line no-console
908
+ console.log(`%c[CTC:readiness]%c exact evidence · ${exactSurfaceCount} surface${exactSurfaceCount === 1 ? '' : 's'} · source=${source} · ctx=${contextKey.slice(0, 64)}`, CTC_STYLES.good, CTC_STYLES.body, {
909
+ context: contextAggregate ? causalAggregateForLog(contextAggregate) : null,
910
+ contextKey,
911
+ exactSurfaceCount,
912
+ family: familyAggregate ? causalAggregateForLog(familyAggregate) : null,
913
+ familyKey,
914
+ source
915
+ });
916
+ };
917
+ const getProgressiveEvidence = (contextKey, candidateSurface, tokenIds = (_surfaceTokenTrie$get => (_surfaceTokenTrie$get = surfaceTokenTrie.getTokenIds(candidateSurface)) !== null && _surfaceTokenTrie$get !== void 0 ? _surfaceTokenTrie$get : [])()) => {
918
+ if (tokenIds.length === 0) {
919
+ return null;
920
+ }
921
+ const exactKey = surfaceCacheKey(contextKey, candidateSurface, tokenIds);
922
+ const exact = getLru(surfaceScoreCache, exactKey);
923
+ if (exact) {
924
+ return {
925
+ meanTokenLogProbabilityUpperBound: exact.meanTokenLogProbability,
926
+ scoredTokenCount: exact.tokenCount,
927
+ totalLogProbability: exact.totalLogProbability,
928
+ totalTokenCount: exact.tokenCount
929
+ };
930
+ }
931
+ const boundary = getLru(boundaryCache, contextKey);
932
+ const firstToken = tokenIds[0];
933
+ if (!boundary || firstToken === undefined) {
934
+ return null;
935
+ }
936
+ let totalLogProbability = logSoftmaxAt(boundary.rawLogits, firstToken);
937
+ if (!Number.isFinite(totalLogProbability)) {
938
+ return null;
939
+ }
940
+ let scoredTokenCount = 1;
941
+ while (scoredTokenCount < tokenIds.length) {
942
+ const prefix = tokenIds.slice(0, scoredTokenCount);
943
+ const expansion = getLru(prefixExpansionCache, prefixCacheKey(contextKey, prefix));
944
+ if (!expansion) {
945
+ break;
946
+ }
947
+ const nextTokenLogProbability = logSoftmaxAt(expansion.rawNextTokenLogits, tokenIds[scoredTokenCount]);
948
+ if (!Number.isFinite(nextTokenLogProbability)) {
949
+ break;
950
+ }
951
+ totalLogProbability = expansion.totalLogProbability + nextTokenLogProbability;
952
+ scoredTokenCount++;
953
+ }
954
+ if (scoredTokenCount === tokenIds.length) {
955
+ const exactScore = {
956
+ contextKey,
957
+ surface: candidateSurface,
958
+ totalLogProbability,
959
+ meanTokenLogProbability: totalLogProbability / tokenIds.length,
960
+ tokenCount: tokenIds.length
961
+ };
962
+ setLru(surfaceScoreCache, exactKey, exactScore, CANONICAL_SCORING.SURFACE_CACHE_MAX);
963
+ }
964
+ return {
965
+ // Every unscored future token has log probability <= 0. Dividing the
966
+ // scored total by the final token count is therefore a safe optimistic
967
+ // bound on the eventual mean.
968
+ meanTokenLogProbabilityUpperBound: totalLogProbability / tokenIds.length,
969
+ scoredTokenCount,
970
+ totalLogProbability,
971
+ totalTokenCount: tokenIds.length
972
+ };
973
+ };
974
+ const planProgressiveExpansion = contextKey => {
975
+ var _progressivePrefixesB;
976
+ const request = progressiveRequests.get(contextKey);
977
+ if (!request || destroyed || !boundaryCache.has(contextKey)) {
978
+ return;
979
+ }
980
+ const eligible = request.candidates.filter(candidate => candidate.tokenIds.length > 0 && candidate.tokenIds.length <= CANONICAL_SCORING.EXACT_MAX_TARGET_TOKENS).map(candidate => ({
981
+ ...candidate,
982
+ evidence: getProgressiveEvidence(contextKey, candidate.surface, candidate.tokenIds)
983
+ })).filter(candidate => candidate.evidence !== null);
984
+
985
+ // Score candidates the way arbitration will: a posterior over sequence
986
+ // log-likelihoods, blended with the corpus prior at the shipped weights.
987
+ //
988
+ // Sharing the rule is the point. Ranking on `exp(per-token mean)` instead
989
+ // answers a different question — it favours short surfaces, because
990
+ // dividing by fewer tokens flatters them — so this scheduler used to stop
991
+ // reading on a margin the decision layer did not recognise, and then
992
+ // abstain for want of the very tokens it declined to read. Nothing about
993
+ // that was visible from either side.
994
+ //
995
+ // The normaliser spans every eligible candidate rather than only the
996
+ // scheduled ones. That can only make each posterior smaller and each margin
997
+ // narrower, so the error is always towards reading another token instead of
998
+ // stopping early — the safe direction ahead of a precision-first gate.
999
+ const normalizer = logSumExp(eligible.map(candidate => candidate.evidence.totalLogProbability));
1000
+ const confidenceScore = candidate => {
1001
+ var _candidate$rankHint;
1002
+ return STAGE1_WEIGHT * ((_candidate$rankHint = candidate.rankHint) !== null && _candidate$rankHint !== void 0 ? _candidate$rankHint : 0) + STAGE2_WEIGHT * (Number.isFinite(normalizer) ? Math.exp(candidate.evidence.totalLogProbability - normalizer) : 0);
1003
+ };
1004
+ const candidates = eligible.sort((a, b) => confidenceScore(b) - confidenceScore(a)).slice(0, CANONICAL_SCORING.PROGRESSIVE_CANDIDATES_MAX);
1005
+ const unresolved = candidates.filter(candidate => candidate.evidence.scoredTokenCount < candidate.tokenIds.length);
1006
+ if (unresolved.length === 0) {
1007
+ logProgressiveState('completed', `ctx=${contextKey.slice(0, 48)} · exact=${candidates.length}/${candidates.length}`);
1008
+ return;
1009
+ }
1010
+ const bestExactScore = Math.max(...candidates.filter(candidate => candidate.evidence.scoredTokenCount === candidate.tokenIds.length).map(confidenceScore), -Infinity);
1011
+ const bestUnresolvedScore = Math.max(...unresolved.map(confidenceScore), -Infinity);
1012
+ if (bestExactScore - bestUnresolvedScore >= MIN_WINNER_MARGIN) {
1013
+ logProgressiveState('completed', `safe bound · ctx=${contextKey.slice(0, 40)} · margin=${(bestExactScore - bestUnresolvedScore).toFixed(2)} · unresolved=${unresolved.length}`);
1014
+ return;
1015
+ }
1016
+ const groups = surfaceTokenTrie.groupByScoredPrefix(unresolved.map(candidate => ({
1017
+ surface: candidate.surface,
1018
+ tokenIds: candidate.tokenIds,
1019
+ scoredTokenCount: candidate.evidence.scoredTokenCount
1020
+ })));
1021
+ const bySurface = new Map(candidates.map(candidate => [candidate.surface, candidate]));
1022
+ const rankedGroups = groups.map(group => {
1023
+ const members = group.surfaces.map(candidateSurface => bySurface.get(candidateSurface)).filter(candidate => Boolean(candidate));
1024
+ const optimisticScore = Math.max(...members.map(confidenceScore));
1025
+ return {
1026
+ ...group,
1027
+ optimisticScore
1028
+ };
1029
+ }).sort((a, b) => b.optimisticScore - a.optimisticScore);
1030
+ const bestGroup = rankedGroups[0];
1031
+ if (!bestGroup) {
1032
+ return;
1033
+ }
1034
+ const warmGroup = kvContextKey === contextKey ? rankedGroups.find(group => isPrefixOf(kvPath, group.tokenPrefix)) : undefined;
1035
+ const nextGroup = warmGroup && warmGroup.optimisticScore >= bestGroup.optimisticScore - PROGRESSIVE_WARM_PATH_MARGIN ? warmGroup : bestGroup;
1036
+ const contextPrefixes = (_progressivePrefixesB = progressivePrefixesByContext.get(contextKey)) !== null && _progressivePrefixesB !== void 0 ? _progressivePrefixesB : new Set();
1037
+ progressivePrefixesByContext.set(contextKey, contextPrefixes);
1038
+ const key = prefixCacheKey(contextKey, nextGroup.tokenPrefix);
1039
+ if (getLru(prefixExpansionCache, key)) {
1040
+ logProgressiveState('cached', `ctx=${contextKey.slice(0, 40)} · prefix=[${nextGroup.tokenPrefix.join(',')}]`);
1041
+ planProgressiveExpansion(contextKey);
1042
+ return;
1043
+ }
1044
+ if (pendingPrefixExpansions.has(key) || inFlightPrefixExpansions.has(key)) {
1045
+ logProgressiveState('deduplicated', `ctx=${contextKey.slice(0, 40)} · prefix=[${nextGroup.tokenPrefix.join(',')}]`);
1046
+ return;
1047
+ }
1048
+ if (contextPrefixes.size >= CANONICAL_SCORING.PROGRESSIVE_EXPANSIONS_PER_CONTEXT_MAX) {
1049
+ logProgressiveState('completed', `ctx=${contextKey.slice(0, 48)} · expansion cap=${contextPrefixes.size} · unresolved=${unresolved.length}`);
1050
+ return;
1051
+ }
1052
+ contextPrefixes.add(key);
1053
+ pendingPrefixExpansions.set(key, {
1054
+ contextKey,
1055
+ familyKey: request.familyKey,
1056
+ prompt: request.prompt,
1057
+ tokenPrefix: nextGroup.tokenPrefix,
1058
+ surfaces: nextGroup.surfaces
1059
+ });
1060
+ logProgressiveState('queued', `ctx=${contextKey.slice(0, 40)} · prefix=[${nextGroup.tokenPrefix.join(',')}] · surfaces=${nextGroup.surfaces.length}`);
1061
+ drainCausalQueue();
1062
+ };
1063
+
1064
+ /**
1065
+ * Prefill `prompt` and cache the boundary distribution that follows it.
1066
+ *
1067
+ * A text completion resets the KV cache before prefilling, so asking for a
1068
+ * single token leaves the cache holding exactly the prompt. That is the
1069
+ * anchor every later decode step extends.
1070
+ */
1071
+ const prefillBoundary = async (activeEngine, request) => {
1072
+ kvContextKey = null;
1073
+ kvPath = [];
1074
+ causalRequestsThisFamily++;
1075
+ causalGeneratedTokensThisFamily++;
1076
+ causalLogitProcessor.startCapture();
1077
+ const {
1078
+ latencyMs
1079
+ } = await createMeasuredCausalCompletion(activeEngine, {
1080
+ contextKey: request.contextKey,
1081
+ familyKey: request.familyKey,
1082
+ kind: 'boundary',
1083
+ prompt: request.prompt,
1084
+ requestedCompletionTokens: 1
1085
+ });
1086
+ const rawLogits = captureLogits();
1087
+ if (!rawLogits) {
1088
+ return null;
1089
+ }
1090
+ setLru(boundaryCache, request.contextKey, {
1091
+ contextKey: request.contextKey,
1092
+ prompt: request.prompt,
1093
+ rawLogits
1094
+ }, CANONICAL_SCORING.BOUNDARY_CACHE_MAX);
1095
+ kvContextKey = request.contextKey;
1096
+ kvPath = [];
1097
+ return {
1098
+ latencyMs,
1099
+ rawLogits
1100
+ };
1101
+ };
1102
+
1103
+ /**
1104
+ * Append one token to the KV cache and return the distribution that follows.
1105
+ *
1106
+ * This deliberately bypasses the completion API: a completion would reset the
1107
+ * cache and re-prefill the whole prompt, whereas this forwards a single token
1108
+ * on top of the work already done.
1109
+ */
1110
+ const decodeOneToken = async (activeEngine, token) => {
1111
+ causalLogitProcessor.startCapture();
1112
+ try {
1113
+ // The engine also holds the embedder, so it refuses to forward unless
1114
+ // the caller says which model to forward through.
1115
+ await activeEngine.forwardTokensAndSample([token], false, modelId);
1116
+ } catch (error) {
1117
+ kvContextKey = null;
1118
+ kvPath = [];
1119
+ throw error;
1120
+ }
1121
+ causalRequestsThisFamily++;
1122
+ causalGeneratedTokensThisFamily++;
1123
+ return captureLogits();
1124
+ };
1125
+ const runBoundaryPrime = async request => {
1126
+ var _progressiveRequest$c, _progressiveRequest$f, _progressiveRequests$, _progressiveRequests$2;
1127
+ if (!engine || destroyed) {
1128
+ return;
1129
+ }
1130
+ const primed = await prefillBoundary(engine, request);
1131
+ if (!primed) {
1132
+ return;
1133
+ }
1134
+ const {
1135
+ latencyMs,
1136
+ rawLogits
1137
+ } = primed;
1138
+ planProgressiveExpansion(request.contextKey);
1139
+ const progressiveRequest = progressiveRequests.get(request.contextKey);
1140
+ const exactSurfaceCount = (_progressiveRequest$c = progressiveRequest === null || progressiveRequest === void 0 ? void 0 : progressiveRequest.candidates.filter(candidate => {
1141
+ const evidence = getProgressiveEvidence(request.contextKey, candidate.surface, candidate.tokenIds);
1142
+ return evidence !== null && evidence.scoredTokenCount === evidence.totalTokenCount;
1143
+ }).length) !== null && _progressiveRequest$c !== void 0 ? _progressiveRequest$c : 0;
1144
+ logExactEvidenceReadiness(request.contextKey, (_progressiveRequest$f = progressiveRequest === null || progressiveRequest === void 0 ? void 0 : progressiveRequest.familyKey) !== null && _progressiveRequest$f !== void 0 ? _progressiveRequest$f : request.familyKey, exactSurfaceCount, 'boundary');
1145
+ if (isAutocompleteDebugEnabled()) {
1146
+ // eslint-disable-next-line no-console
1147
+ console.log(`%c[CTC:model] %c🧠 Tier A prime #${request.priority + 1} · ctx=${request.contextKey.slice(0, 48)} · ${rawLogits.length} logits · ${latencyMs.toFixed(0)}ms · family req:${causalRequestsThisFamily} tok:${causalGeneratedTokensThisFamily} A hit/miss:${tierAPrimeCacheHitsThisFamily}/${tierAPrimeCacheMissesThisFamily}`, 'color: #9c27b0; font-weight: bold;', 'color: #2196f3;');
1148
+ }
1149
+ const callbackFamilyKey = (_progressiveRequests$ = (_progressiveRequests$2 = progressiveRequests.get(request.contextKey)) === null || _progressiveRequests$2 === void 0 ? void 0 : _progressiveRequests$2.familyKey) !== null && _progressiveRequests$ !== void 0 ? _progressiveRequests$ : request.familyKey;
1150
+ if (callbackFamilyKey === latestCausalFamilyKey && !destroyed) {
1151
+ onBoundaryLmUpdate === null || onBoundaryLmUpdate === void 0 ? void 0 : onBoundaryLmUpdate({
1152
+ contextKey: request.contextKey,
1153
+ familyKey: callbackFamilyKey,
1154
+ latencyMs
1155
+ });
1156
+ }
1157
+ };
1158
+ const runPrefixExpansion = async request => {
1159
+ var _progressivePrefixesB2, _progressiveRequest$f2, _progressiveRequests$3, _progressiveRequests$4;
1160
+ if (!engine || destroyed) {
1161
+ return;
1162
+ }
1163
+ const activeEngine = engine;
1164
+ const startedAt = performance.now();
1165
+ logProgressiveState('started', `ctx=${request.contextKey.slice(0, 40)} · prefix=[${request.tokenPrefix.join(',')}] · surfaces=${request.surfaces.length}`);
1166
+
1167
+ // Resume from whatever the KV cache already holds for this context, and
1168
+ // fall back to a prompt prefill only when the requested prefix branches
1169
+ // away from it.
1170
+ let logitsAtPath = null;
1171
+ let totalAtPath = 0;
1172
+ const pathIsLive = extendsLiveKvPath(request);
1173
+ if (pathIsLive) {
1174
+ if (kvPath.length === 0) {
1175
+ var _getLru$rawLogits, _getLru2;
1176
+ logitsAtPath = (_getLru$rawLogits = (_getLru2 = getLru(boundaryCache, request.contextKey)) === null || _getLru2 === void 0 ? void 0 : _getLru2.rawLogits) !== null && _getLru$rawLogits !== void 0 ? _getLru$rawLogits : null;
1177
+ } else {
1178
+ var _resume$rawNextTokenL, _resume$totalLogProba;
1179
+ const resume = getLru(prefixExpansionCache, prefixCacheKey(request.contextKey, kvPath));
1180
+ logitsAtPath = (_resume$rawNextTokenL = resume === null || resume === void 0 ? void 0 : resume.rawNextTokenLogits) !== null && _resume$rawNextTokenL !== void 0 ? _resume$rawNextTokenL : null;
1181
+ totalAtPath = (_resume$totalLogProba = resume === null || resume === void 0 ? void 0 : resume.totalLogProbability) !== null && _resume$totalLogProba !== void 0 ? _resume$totalLogProba : 0;
1182
+ }
1183
+ }
1184
+ if (!logitsAtPath) {
1185
+ const primed = await prefillBoundary(activeEngine, request);
1186
+ if (!primed) {
1187
+ logProgressiveState('failed', `ctx=${request.contextKey.slice(0, 40)} · prefix=[${request.tokenPrefix.join(',')}] · missing boundary logits`);
1188
+ return;
1189
+ }
1190
+ logitsAtPath = primed.rawLogits;
1191
+ totalAtPath = 0;
1192
+ }
1193
+
1194
+ // Walking the path caches every depth along it, not just the requested
1195
+ // one, so a later expansion that shares this prefix costs nothing.
1196
+ const contextPrefixes = (_progressivePrefixesB2 = progressivePrefixesByContext.get(request.contextKey)) !== null && _progressivePrefixesB2 !== void 0 ? _progressivePrefixesB2 : new Set();
1197
+ progressivePrefixesByContext.set(request.contextKey, contextPrefixes);
1198
+ for (let index = kvPath.length; index < request.tokenPrefix.length; index++) {
1199
+ const token = request.tokenPrefix[index];
1200
+ if (token === undefined) {
1201
+ break;
1202
+ }
1203
+ const stepLogProbability = logSoftmaxAt(logitsAtPath, token);
1204
+ if (!Number.isFinite(stepLogProbability)) {
1205
+ logProgressiveState('failed', `ctx=${request.contextKey.slice(0, 40)} · prefix=[${request.tokenPrefix.join(',')}] · token ${token} unscoreable`);
1206
+ return;
1207
+ }
1208
+ // eslint-disable-next-line no-await-in-loop
1209
+ const nextLogits = await decodeOneToken(activeEngine, token);
1210
+ if (!nextLogits || destroyed) {
1211
+ kvContextKey = null;
1212
+ kvPath = [];
1213
+ logProgressiveState('failed', `ctx=${request.contextKey.slice(0, 40)} · prefix=[${request.tokenPrefix.join(',')}] · missing logits`);
1214
+ return;
1215
+ }
1216
+ kvContextKey = request.contextKey;
1217
+ kvPath = [...kvPath, token];
1218
+ totalAtPath += stepLogProbability;
1219
+ logitsAtPath = nextLogits;
1220
+ const stepKey = prefixCacheKey(request.contextKey, kvPath);
1221
+ const expansion = {
1222
+ contextKey: request.contextKey,
1223
+ tokenPrefix: [...kvPath],
1224
+ totalLogProbability: totalAtPath,
1225
+ rawNextTokenLogits: nextLogits
1226
+ };
1227
+ setLru(prefixExpansionCache, stepKey, expansion, CANONICAL_SCORING.PREFIX_CACHE_MAX);
1228
+ contextPrefixes.add(stepKey);
1229
+ }
1230
+ const latencyMs = performance.now() - startedAt;
1231
+ const progressiveRequest = progressiveRequests.get(request.contextKey);
1232
+ let newlyExact = 0;
1233
+ let exactSurfaceCount = 0;
1234
+ if (progressiveRequest) {
1235
+ for (const candidate of progressiveRequest.candidates) {
1236
+ const exactKey = surfaceCacheKey(request.contextKey, candidate.surface, candidate.tokenIds);
1237
+ const wasExact = surfaceScoreCache.has(exactKey);
1238
+ const evidence = getProgressiveEvidence(request.contextKey, candidate.surface, candidate.tokenIds);
1239
+ if (!wasExact && evidence !== null && evidence.scoredTokenCount === evidence.totalTokenCount && surfaceScoreCache.has(exactKey)) {
1240
+ newlyExact++;
1241
+ }
1242
+ if (evidence !== null && evidence.scoredTokenCount === evidence.totalTokenCount) {
1243
+ exactSurfaceCount++;
1244
+ }
1245
+ }
1246
+ }
1247
+ logExactEvidenceReadiness(request.contextKey, (_progressiveRequest$f2 = progressiveRequest === null || progressiveRequest === void 0 ? void 0 : progressiveRequest.familyKey) !== null && _progressiveRequest$f2 !== void 0 ? _progressiveRequest$f2 : request.familyKey, exactSurfaceCount, 'prefix');
1248
+ logProgressiveState('expanded', `ctx=${request.contextKey.slice(0, 40)} · prefix=[${request.tokenPrefix.join(',')}] · exact+${newlyExact} · ${latencyMs.toFixed(0)}ms`);
1249
+ planProgressiveExpansion(request.contextKey);
1250
+ const callbackFamilyKey = (_progressiveRequests$3 = (_progressiveRequests$4 = progressiveRequests.get(request.contextKey)) === null || _progressiveRequests$4 === void 0 ? void 0 : _progressiveRequests$4.familyKey) !== null && _progressiveRequests$3 !== void 0 ? _progressiveRequests$3 : request.familyKey;
1251
+ if (callbackFamilyKey === latestCausalFamilyKey && !destroyed) {
1252
+ onSurfaceScoreUpdate === null || onSurfaceScoreUpdate === void 0 ? void 0 : onSurfaceScoreUpdate({
1253
+ contextKey: request.contextKey,
1254
+ count: newlyExact,
1255
+ familyKey: callbackFamilyKey,
1256
+ latencyMs
1257
+ });
1258
+ } else {
1259
+ logProgressiveState('stale', `cached only · ctx=${request.contextKey.slice(0, 40)} · family=${request.familyKey.slice(0, 32)}`);
1260
+ }
1261
+ };
1262
+
1263
+ /**
1264
+ * Whether any live decision would still take this request's result.
1265
+ *
1266
+ * Ranking on the family a request was created under, as this used to, misses
1267
+ * in both directions. Work queued a keystroke ago for a context still under
1268
+ * the cursor sorts as stale even though the callbacks resolve delivery
1269
+ * through `progressiveRequests` and would hand it over. Work for a context
1270
+ * nothing asks about any more sorts as runnable even though the same
1271
+ * resolution drops it on arrival — and that one is expensive, because an
1272
+ * abandoned context is never the one the KV cache holds, so running it pays
1273
+ * a prompt prefill and leaves the live context evicted, charging the next
1274
+ * live request a second prefill. Two prefills for a discarded result.
1275
+ *
1276
+ * Either signal alone is enough to keep the work, which matters because a
1277
+ * boundary prime is queued before its context's surfaces are requested: at
1278
+ * that moment the stored family is still the previous decision's, and only
1279
+ * the request's own family says it is current.
1280
+ *
1281
+ * Agreeing with the layer being fed is the correction the expansion planner
1282
+ * already carries for arbitration's scoring rule — a scheduler deciding on
1283
+ * its own rule stops on margins the consumer does not recognise.
1284
+ */
1285
+ const stillWanted = request => {
1286
+ var _progressiveRequests$5;
1287
+ return request.familyKey === latestCausalFamilyKey || ((_progressiveRequests$5 = progressiveRequests.get(request.contextKey)) === null || _progressiveRequests$5 === void 0 ? void 0 : _progressiveRequests$5.familyKey) === latestCausalFamilyKey;
1288
+ };
1289
+ const drainCausalQueue = () => {
1290
+ if (causalInFlight || destroyed) {
1291
+ return;
1292
+ }
1293
+ // Drop abandoned work rather than leaving it to be picked up whenever the
1294
+ // live family happens to have nothing queued. Its cached side effects are
1295
+ // speculative — they only pay off if the user deletes back into exactly
1296
+ // this context and prefix — and the prefill pair above is certain.
1297
+ for (const [key, request] of pendingPrefixExpansions) {
1298
+ if (!stillWanted(request)) {
1299
+ pendingPrefixExpansions.delete(key);
1300
+ }
1301
+ }
1302
+ for (const [contextKey, request] of pendingBoundaryPrimes) {
1303
+ if (!stillWanted(request)) {
1304
+ pendingBoundaryPrimes.delete(contextKey);
1305
+ }
1306
+ }
1307
+ const sortedPrimes = Array.from(pendingBoundaryPrimes.values()).sort((a, b) => a.priority - b.priority);
1308
+ // Everything left is wanted, so relevance no longer needs a sort key.
1309
+ // Take whichever request continues the live sequence: that expansion costs
1310
+ // one decode step where any other costs a full prompt prefill, and nothing
1311
+ // is skipped, only reordered.
1312
+ //
1313
+ // Staying inside the live context when nothing continues it was tried and
1314
+ // reverted. It moved re-primes from `reprime-context` to `reprime-branch`
1315
+ // and left the total flat, because both pay for a prompt prefill: a
1316
+ // context whose remaining prefixes diverge at the first token re-primes as
1317
+ // a branch instead of as a context. Ordering cannot recover that; only
1318
+ // queueing fewer divergent branches can, which is what the prune above
1319
+ // does.
1320
+ const sortedPrefixes = Array.from(pendingPrefixExpansions.values()).sort((a, b) => Number(extendsLiveKvPath(b)) - Number(extendsLiveKvPath(a)));
1321
+ const nextPrefix = sortedPrefixes[0];
1322
+ const nextPrime = nextPrefix ? undefined : sortedPrimes[0];
1323
+ if (!nextPrefix && !nextPrime) {
1324
+ return;
1325
+ }
1326
+ causalInFlight = true;
1327
+ let prefixKey = null;
1328
+ if (nextPrefix) {
1329
+ prefixKey = prefixCacheKey(nextPrefix.contextKey, nextPrefix.tokenPrefix);
1330
+ pendingPrefixExpansions.delete(prefixKey);
1331
+ inFlightPrefixExpansions.add(prefixKey);
1332
+ } else if (nextPrime) {
1333
+ pendingBoundaryPrimes.delete(nextPrime.contextKey);
1334
+ inFlightBoundaryContextKey = nextPrime.contextKey;
1335
+ }
1336
+ void ensureEngineInitialized().then(() => nextPrefix ? runPrefixExpansion(nextPrefix) : nextPrime ? runBoundaryPrime(nextPrime) : undefined).catch(error => {
1337
+ if (nextPrefix) {
1338
+ logProgressiveState('failed', `ctx=${nextPrefix.contextKey.slice(0, 40)} · prefix=[${nextPrefix.tokenPrefix.join(',')}] · ${error instanceof Error ? error.message : String(error)}`);
1339
+ }
1340
+ if (isAutocompleteDebugEnabled()) {
1341
+ // eslint-disable-next-line no-console
1342
+ console.log(`%c[CTC:model] %c❌ canonical causal request failed: ${error instanceof Error ? error.message : String(error)}`, 'color: #9c27b0; font-weight: bold;', 'color: #f44336;');
1343
+ }
1344
+ }).finally(() => {
1345
+ if (prefixKey) {
1346
+ inFlightPrefixExpansions.delete(prefixKey);
1347
+ }
1348
+ if (nextPrime) {
1349
+ inFlightBoundaryContextKey = null;
1350
+ }
1351
+ causalInFlight = false;
1352
+ drainCausalQueue();
1353
+ });
1354
+ };
1355
+
844
1356
  // ── Context update (debounced) ─────────────────────────────────────────
845
1357
 
846
1358
  const startInference = (text, requestId) => {
@@ -887,7 +1399,7 @@ export const createLocalSlowLaneClient = (config = {}) => {
887
1399
  latestRequestId = requestId;
888
1400
  if (isAutocompleteDebugEnabled()) {
889
1401
  // eslint-disable-next-line no-console
890
- console.groupCollapsed(`%c[LocalSlowLane] %c📤 Context update (request #${requestId}) | ${text.length} chars`, 'color: #9c27b0; font-weight: bold;', 'color: inherit;');
1402
+ console.groupCollapsed(`%c[CTC:model] %c📤 Context update (request #${requestId}) | ${text.length} chars`, 'color: #9c27b0; font-weight: bold;', 'color: inherit;');
891
1403
  const lines = text.split('\n');
892
1404
  lines.forEach((line, i) => {
893
1405
  // eslint-disable-next-line no-console
@@ -923,18 +1435,102 @@ export const createLocalSlowLaneClient = (config = {}) => {
923
1435
  doUpdateContext(lastRequestedText);
924
1436
  }, debounceMs);
925
1437
  };
1438
+ const setLatestCausalFamily = familyKey => {
1439
+ if (familyKey === latestCausalFamilyKey) {
1440
+ return;
1441
+ }
1442
+ latestCausalFamilyKey = familyKey;
1443
+ causalRequestsThisFamily = 0;
1444
+ causalGeneratedTokensThisFamily = 0;
1445
+ tierAPrimeCacheHitsThisFamily = 0;
1446
+ tierAPrimeCacheMissesThisFamily = 0;
1447
+ };
1448
+ const primeBoundaryLm = input => {
1449
+ if (destroyed) {
1450
+ return;
1451
+ }
1452
+ setLatestCausalFamily(input.familyKey);
1453
+ if (getLru(boundaryCache, input.contextKey)) {
1454
+ tierAPrimeCacheHitsThisFamily++;
1455
+ if (isAutocompleteDebugVerbose()) {
1456
+ // eslint-disable-next-line no-console
1457
+ console.log(`%c[CTC:model] %c⚡ Tier A cache hit · ctx=${input.contextKey.slice(0, 48)}`, 'color: #9c27b0; font-weight: bold;', 'color: #2196f3;');
1458
+ }
1459
+ return;
1460
+ }
1461
+ if (pendingBoundaryPrimes.has(input.contextKey) || inFlightBoundaryContextKey === input.contextKey) {
1462
+ logProgressiveState('deduplicated', `boundary · ctx=${input.contextKey.slice(0, 48)}`);
1463
+ return;
1464
+ }
1465
+ if (pendingBoundaryPrimes.size >= CANONICAL_SCORING.TIER_A_PRIMES_MAX) {
1466
+ const stalePending = Array.from(pendingBoundaryPrimes.entries()).find(([, request]) => request.familyKey !== latestCausalFamilyKey);
1467
+ if (stalePending) {
1468
+ pendingBoundaryPrimes.delete(stalePending[0]);
1469
+ logProgressiveState('stale', `dropped unstarted boundary · ctx=${stalePending[0].slice(0, 48)}`);
1470
+ }
1471
+ }
1472
+ if (pendingBoundaryPrimes.size < CANONICAL_SCORING.TIER_A_PRIMES_MAX) {
1473
+ tierAPrimeCacheMissesThisFamily++;
1474
+ pendingBoundaryPrimes.set(input.contextKey, input);
1475
+ }
1476
+ drainCausalQueue();
1477
+ };
1478
+ const requestProgressiveSurfaceScores = input => {
1479
+ if (destroyed) {
1480
+ return;
1481
+ }
1482
+ setLatestCausalFamily(input.familyKey);
1483
+ const candidates = input.candidates.filter(candidate => candidate.tokenIds.length > 0 && candidate.tokenIds.length <= CANONICAL_SCORING.EXACT_MAX_TARGET_TOKENS).slice(0, CANONICAL_SCORING.PROGRESSIVE_INPUT_MAX);
1484
+ if (candidates.length === 0) {
1485
+ return;
1486
+ }
1487
+ const previous = progressiveRequests.get(input.contextKey);
1488
+ const candidateSignature = candidates.map(candidate => `${candidate.surface}:${candidate.tokenIds.join(',')}`).join('\u0001');
1489
+ const previousSignature = previous === null || previous === void 0 ? void 0 : previous.candidates.map(candidate => `${candidate.surface}:${candidate.tokenIds.join(',')}`).join('\u0001');
1490
+ setLru(progressiveRequests, input.contextKey, {
1491
+ ...input,
1492
+ candidates
1493
+ }, CANONICAL_SCORING.BOUNDARY_CACHE_MAX);
1494
+ for (const cachedContextKey of progressivePrefixesByContext.keys()) {
1495
+ if (!progressiveRequests.has(cachedContextKey)) {
1496
+ progressivePrefixesByContext.delete(cachedContextKey);
1497
+ }
1498
+ }
1499
+ const cachedProgressCount = candidates.filter(candidate => {
1500
+ const evidence = getProgressiveEvidence(input.contextKey, candidate.surface, candidate.tokenIds);
1501
+ return evidence !== null && (evidence.scoredTokenCount > 1 || evidence.scoredTokenCount === evidence.totalTokenCount);
1502
+ }).length;
1503
+ if (cachedProgressCount > 0) {
1504
+ logProgressiveState('cached', `ctx=${input.contextKey.slice(0, 40)} · surfaces=${cachedProgressCount}/${candidates.length}`);
1505
+ }
1506
+ if (candidateSignature === previousSignature) {
1507
+ logProgressiveState('deduplicated', `candidate set · ctx=${input.contextKey.slice(0, 40)} · surfaces=${candidates.length}`);
1508
+ } else {
1509
+ logProgressiveState('queued', `candidate set · ctx=${input.contextKey.slice(0, 40)} · surfaces=${candidates.length}`);
1510
+ }
1511
+ planProgressiveExpansion(input.contextKey);
1512
+ };
926
1513
 
927
1514
  // ── Public API (same shape as createSlowLaneClient) ────────────────────
928
1515
  return {
929
1516
  updateContext: updateContextDebounced,
1517
+ getBoundaryLmState: contextKey => getLru(boundaryCache, contextKey),
1518
+ getCanonicalSurfaceTokenIds: candidateSurface => surfaceTokenTrie.getTokenIds(candidateSurface),
1519
+ getCanonicalSurfaceCount: () => surfaceTokenIds.size,
1520
+ getContextInput: () => storedContextInput,
930
1521
  getContextVector: () => storedContextVector,
931
1522
  getLmLogits: () => storedLmLogits,
1523
+ getProgressiveSurfaceEvidence: (contextKey, candidateSurface) => getProgressiveEvidence(contextKey, candidateSurface),
1524
+ getSurfaceScore: (contextKey, candidateSurface) => getLru(surfaceScoreCache, surfaceCacheKey(contextKey, candidateSurface)),
932
1525
  setContextVector: vector => {
1526
+ storedContextInput = null;
933
1527
  storedContextVector = vector;
934
1528
  },
935
1529
  setLmLogits: logits => {
936
1530
  storedLmLogits = logits;
937
1531
  },
1532
+ primeBoundaryLm,
1533
+ requestProgressiveSurfaceScores,
938
1534
  isWordBoundary,
939
1535
  isReady: () => ready,
940
1536
  destroy: () => {
@@ -953,8 +1549,26 @@ export const createLocalSlowLaneClient = (config = {}) => {
953
1549
  activeInferenceText = null;
954
1550
  activeInferenceRequestId = -1;
955
1551
  pendingInference = null;
1552
+ storedContextInput = null;
956
1553
  storedContextVector = null;
957
1554
  storedLmLogits = null;
1555
+ causalInFlight = false;
1556
+ inFlightBoundaryContextKey = null;
1557
+ kvContextKey = null;
1558
+ kvPath = [];
1559
+ latestCausalFamilyKey = '';
1560
+ pendingBoundaryPrimes.clear();
1561
+ pendingPrefixExpansions.clear();
1562
+ inFlightPrefixExpansions.clear();
1563
+ progressiveRequests.clear();
1564
+ progressivePrefixesByContext.clear();
1565
+ boundaryCache.clear();
1566
+ causalInferenceByContext.clear();
1567
+ causalInferenceByFamily.clear();
1568
+ exactEvidenceCountByContext.clear();
1569
+ prefixExpansionCache.clear();
1570
+ surfaceScoreCache.clear();
1571
+ causalLogitProcessor.resetState();
958
1572
  }
959
1573
  };
960
1574
  };