@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
@@ -5,11 +5,9 @@
5
5
  * a backend API, this client runs two models in the browser via WebGPU, in a
6
6
  * single MLCEngine, to reproduce the BE encoder's outputs on-device:
7
7
  *
8
- * - Causal LM (SmolLM2-135M-Instruct): one decode step per word boundary. A
9
- * registered LogitProcessor captures the raw next-token logits, which
10
- * `computeBePayload` turns into a whole-word `lm_logits` payload — a faithful
11
- * port of the BE `CausalLMEncoder._get_top_k_probs` (masked softmax over the
12
- * 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.
13
11
  * - Semantic embedder (Snowflake Arctic Embed S): produces the real 384-d
14
12
  * `semantic_vector`. Inputs are wrapped as passages (see `wrapForArctic`) so
15
13
  * the runtime vector lands in the same space as the precomputed word bin.
@@ -19,7 +17,7 @@
19
17
  * the main thread is viable:
20
18
  *
21
19
  * - WebGPU GPU compute is inherently async (doesn't block the main thread)
22
- * - CPU overhead (BE-parity post-processing) is a few ms
20
+ * - CPU overhead for named-token reads and cache bookkeeping is small
23
21
  * - Per-inference latency is well within autocomplete expectations
24
22
  * (~250 ms between word boundaries)
25
23
  *
@@ -30,21 +28,71 @@
30
28
  * - Standard npm import — just works
31
29
  *
32
30
  * ── Interface ────────────────────────────────────────────────────────────
33
- * Same shape as createSlowLaneClient so text-predictor.ts needs zero changes.
34
- * The client exposes getContextVector() and getLmLogits() which are populated
35
- * 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.
36
34
  */
37
35
 
38
- import type { MLCEngine, InitProgressReport, AppConfig, LogitProcessor } from '@mlc-ai/web-llm';
36
+ import type { AppConfig, InitProgressReport, MLCEngine } from '@mlc-ai/web-llm';
39
37
 
40
38
  import { abortExp, EXPERIENCE_NAME, failExp, startExp, succeedExp } from '../analytics/ufo';
41
39
 
42
- import { ARTIFACT_NAME, fetchArtifactJson } from './artifacts-manifest';
43
- import { isAutocompleteDebugEnabled } from './debug-mode';
40
+ import { fetchAutocompleteArtifactJson } from './artifact-loader';
41
+ import { ARTIFACT_NAME } from './artifacts-manifest';
42
+ import {
43
+ CanonicalLogitProcessor,
44
+ CanonicalSurfaceTokenTrie,
45
+ logSoftmaxAt,
46
+ logSumExp,
47
+ type BoundaryLmState,
48
+ type BoundaryPrimeRequest,
49
+ type ProgressiveSurfaceEvidence,
50
+ type SurfaceScore,
51
+ type SurfaceScoreRequest,
52
+ type TokenPrefixExpansion,
53
+ } from './canonical-lm-scoring';
54
+ import { CTC_STYLES, isAutocompleteDebugEnabled, isAutocompleteDebugVerbose } from './debug-mode';
55
+ import { MIN_WINNER_MARGIN, STAGE1_WEIGHT, STAGE2_WEIGHT } from './scoring-pipeline';
44
56
  import { isWordBoundary } from './slow-lane-client';
45
57
 
46
58
  type WebLlmModelRecord = NonNullable<AppConfig['model_list']>[number];
47
59
  type EmbeddingApiResponse = { data?: Array<{ embedding?: unknown }> };
60
+ /** Which of the two causal-LM call shapes a measurement or log line describes. */
61
+ type CausalInferenceKind = 'boundary' | 'prefix';
62
+
63
+ interface CausalInferenceAggregate {
64
+ e2eLatencyMs: number;
65
+ failedRequests: number;
66
+ firstStartedAt: number;
67
+ promptTokens: number;
68
+ promptUsageSamples: number;
69
+ requestedCompletionTokens: number;
70
+ requests: number;
71
+ sampledOutputTokens: number;
72
+ timeToFirstTokenMs: number;
73
+ wallClockMs: number;
74
+ webLlmDecodeSteps: number;
75
+ webLlmUsageSamples: number;
76
+ }
77
+
78
+ interface CausalInferenceMeasurement {
79
+ contextKey: string;
80
+ decodeTokensPerSecond: number | null;
81
+ e2eLatencyMs: number | null;
82
+ familyKey: string;
83
+ kind: CausalInferenceKind;
84
+ outcome: 'completed' | 'failed';
85
+ prefillTokensPerSecond: number | null;
86
+ promptTokens: number | null;
87
+ promptWords: number;
88
+ requestedCompletionTokens: number;
89
+ sampledOutputTokens: number | null;
90
+ timePerDecodeTokenMs: number | null;
91
+ timeToFirstTokenMs: number | null;
92
+ wallClockMs: number;
93
+ warmState: 'cold-first-call' | 'warm';
94
+ webLlmDecodeSteps: number | null;
95
+ }
48
96
 
49
97
  // ─── Types ───────────────────────────────────────────────────────────────────
50
98
 
@@ -74,12 +122,21 @@ export interface LocalSlowLaneClientConfig {
74
122
  * `customModelConfig` with the model URL and WASM library URL.
75
123
  */
76
124
  modelId?: string;
125
+ /** Callback fired when a context-keyed Tier-A raw-logit vector is available. */
126
+ onBoundaryLmUpdate?: (opts: { contextKey: string; familyKey: string; latencyMs: number }) => void;
77
127
  /** Callback fired when the engine fails to load/start. */
78
128
  onLoadError?: (error: LocalSlowLaneLoadError) => void;
79
129
  /** Callback fired when the engine successfully loads and is ready. */
80
130
  onLoadSuccess?: (info: LocalSlowLaneLoadSuccess) => void;
81
131
  /** Callback fired with status messages (model loading progress, etc.). */
82
132
  onStatus?: (message: string) => void;
133
+ /** Callback fired when grouped progress or exact surface scores become available. */
134
+ onSurfaceScoreUpdate?: (opts: {
135
+ contextKey: string;
136
+ count: number;
137
+ familyKey: string;
138
+ latencyMs: number;
139
+ }) => void;
83
140
  /** Callback fired when inference returns new results. */
84
141
  onUpdate?: (opts: { hasLmLogits: boolean; hasVector: boolean; textLength: number }) => void;
85
142
  /** Product/editor surface where autocomplete runs. */
@@ -90,11 +147,22 @@ export interface LocalSlowLaneClientConfig {
90
147
  export interface LocalSlowLaneClient {
91
148
  /** Clean up resources. */
92
149
  destroy: () => void;
150
+ getBoundaryLmState: (contextKey: string) => BoundaryLmState | null;
151
+ getCanonicalSurfaceCount: () => number;
152
+ getCanonicalSurfaceTokenIds: (surface: string) => number[] | null;
153
+ getContextInput: () => string | null;
93
154
  getContextVector: () => Float32Array | null;
94
155
  getLmLogits: () => Record<string, number> | null;
156
+ getProgressiveSurfaceEvidence: (
157
+ contextKey: string,
158
+ surface: string,
159
+ ) => ProgressiveSurfaceEvidence | null;
160
+ getSurfaceScore: (contextKey: string, surface: string) => SurfaceScore | null;
95
161
  /** Whether the model is loaded and ready for inference. */
96
162
  isReady: () => boolean;
97
163
  isWordBoundary: (text: string) => boolean;
164
+ primeBoundaryLm: (input: BoundaryPrimeRequest) => void;
165
+ requestProgressiveSurfaceScores: (input: SurfaceScoreRequest) => void;
98
166
  setContextVector: (vector: Float32Array | null) => void;
99
167
  setLmLogits: (logits: Record<string, number> | null) => void;
100
168
  updateContext: (text: string) => void;
@@ -208,21 +276,7 @@ export const LOCAL_MLC_EMBEDDING_MODEL_ID = 'snowflake-arctic-embed-s-q0f32-MLC-
208
276
  */
209
277
  export const wrapForArctic = (text: string): string => `[CLS] ${text} [SEP]`;
210
278
 
211
- /**
212
- * BE-parity constants — must match `CausalLMEncoder` defaults in the Python
213
- * sidecar (`cc-smarts/python-sidecar/src/causal_lm_encoder.py`) and
214
- * `SlowLaneEngine` (`typeahead_context_encoding.py`) so local payloads behave
215
- * identically to the server-client setup.
216
- */
217
- export const BE_PARITY = {
218
- /** Final payload size cap (BE: `top_k_words`). */
219
- TOP_K_WORDS: 2000,
220
- /** L2 (domain) words admitted unconditionally before pooling (BE: `reserved_l2_slots`). */
221
- RESERVED_L2_SLOTS: 500,
222
- /** Log-space additive bias favouring L2 over L3 in the pool (BE: `l2_bias`). */
223
- L2_BIAS: 1.0,
224
- /** Drop words below this probability from the final payload (BE: `> 0.00001`). */
225
- MIN_PROB: 0.00001,
279
+ export const LOCAL_INFERENCE = {
226
280
  /**
227
281
  * Word-level approximation of the BE causal LM token limit.
228
282
  *
@@ -244,6 +298,73 @@ export const BE_PARITY = {
244
298
  MAX_CONTEXT_WORDS: 100,
245
299
  } as const;
246
300
 
301
+ export const CANONICAL_SCORING = {
302
+ /**
303
+ * How many contexts keep their prefilled state before the oldest is dropped.
304
+ *
305
+ * Sizes every context-keyed cache together on purpose: an expansion needs
306
+ * both the boundary logits and the candidate list to still be resident, so
307
+ * bounding them separately would evict half of a context and strand the
308
+ * other half.
309
+ *
310
+ * The context of a keystroke is the text *before* the word being typed,
311
+ * which does not change while that word is typed, so one word should cost
312
+ * one prefill and then hit. Measured hit rate was 45% over the first 84s of
313
+ * a session and 24% over the following four minutes, well short of that, and
314
+ * prompt prefills per decision rose 0.80 → 1.25 across the same split while
315
+ * cost per prefill stayed flat. Contexts were being dropped while still live.
316
+ *
317
+ * A boundary entry holds a `Float32Array` over the 49,152-token vocabulary,
318
+ * so each one is ~192KB and this bound is the dominant term in the scorer's
319
+ * footprint: ~24MB resident here, against ~6MB at the 32 this replaced. That
320
+ * cost is what kept the bound low, not a hit rate anyone had measured.
321
+ */
322
+ BOUNDARY_CACHE_MAX: 128,
323
+ EXACT_MAX_TARGET_TOKENS: 8,
324
+ PREFIX_CACHE_MAX: 256,
325
+ /**
326
+ * How many candidates compete for expansion in one context.
327
+ *
328
+ * Each distinct token prefix among them is a separate branch, and a branch
329
+ * the engine is not already standing on costs a prompt prefill before its
330
+ * first decode. A wide field therefore spreads a decision's round trips
331
+ * across candidates and finishes none of them inside the budget.
332
+ */
333
+ PROGRESSIVE_CANDIDATES_MAX: 8,
334
+ /**
335
+ * Ceiling on distinct token prefixes explored per context.
336
+ *
337
+ * Raising this to 8 to give long surfaces more room did the opposite: model
338
+ * calls per decision went 2.35 → 4.32, the warm-KV extend share fell 40% →
339
+ * 31% as the extra branches displaced the live path, the prefix queue backed
340
+ * up to 29 deep, and acceptances per thousand decisions fell 9.3 → 6.7. No
341
+ * phrase was shown either way, so breadth was never the binding constraint.
342
+ *
343
+ * What blocked them then was normalisation against a short word's per-token
344
+ * mean, which has since been replaced by a posterior over sequence
345
+ * log-likelihoods. That removes the bias towards short surfaces but does not
346
+ * by itself make phrases reachable: a longer surface is strictly less likely
347
+ * than a shorter one, so a phrase sharing a shortlist with a unigram still
348
+ * holds little of its mass. Whether the remaining gap is the threshold or the
349
+ * comparison is still open.
350
+ */
351
+ PROGRESSIVE_EXPANSIONS_PER_CONTEXT_MAX: 4,
352
+ PROGRESSIVE_INPUT_MAX: 400,
353
+ SURFACE_CACHE_MAX: 128,
354
+ TIER_A_PRIMES_MAX: 3,
355
+ } as const;
356
+ /**
357
+ * How much optimistic score we give up to stay on the warm KV path.
358
+ *
359
+ * Expanding a prefix the cache already holds costs one decode step, while
360
+ * branching to any other prefix costs a full prompt prefill first. Measured,
361
+ * that is about 30ms against about 66ms, so continuing the live path is worth
362
+ * roughly half a round trip and the margin has to be wide enough to reflect
363
+ * that. At the previous 0.05 almost any ranking difference was enough to
364
+ * abandon the sequence, and 70% of expansions ended up re-prefilling.
365
+ */
366
+ const PROGRESSIVE_WARM_PATH_MARGIN = 0.25;
367
+
247
368
  const splitOnWhitespace = (text: string): string[] => {
248
369
  const trimmed = text.trim();
249
370
  if (trimmed === '') {
@@ -283,274 +404,67 @@ const truncateToLastNWords = (text: string, n: number): string => {
283
404
  return words.length <= n ? text : words.slice(-n).join(' ');
284
405
  };
285
406
 
286
- // ─── Logit capture ─────────────────────────────────────────────────────────
287
-
288
407
  /**
289
- * A LogitProcessor that captures the raw next-token logits and passes them
290
- * through unmodified.
291
- *
292
- * web-llm invokes `processLogits` on the CPU after the model's forward pass and
293
- * before sampling, handing us the full `Float32Array(vocab_size)` at the current
294
- * decode position. We copy it off web-llm's shared buffer (which it may reuse
295
- * across calls) and return the original untouched so sampling is unaffected.
296
- *
297
- * This is the raw-logit access the BE-parity algorithm needs (masked softmax +
298
- * prefix expansion, consumed in a later step). Registered for the causal LM
299
- * only — the embedder never decodes tokens, so it produces no logits.
408
+ * Full canonical leading-space token sequence for every served surface. The
409
+ * producer keeps the existing `phrase-continuation-tokens.json` wire name while
410
+ * expanding its key set to the complete word/bigram/phrase union.
300
411
  */
301
- class CapturingLogitProcessor implements LogitProcessor {
302
- captured: Float32Array | null = null;
303
-
304
- processLogits = (logits: Float32Array): Float32Array => {
305
- // Copy off web-llm's shared buffer — it may reuse `logits` across calls.
306
- this.captured = new Float32Array(logits);
307
- return logits;
308
- };
309
-
310
- processSampledToken = (): void => {
311
- // No-op — we don't track sampled tokens.
312
- };
313
-
314
- resetState = (): void => {
315
- this.captured = null;
316
- };
317
- }
318
-
319
- // ─── BE-parity data + algorithm ──────────────────────────────────────────────
320
-
321
- /**
322
- * Prefix-expansion map: first-token id → words whose space-prefixed SmolLM2
323
- * encoding starts with that token. Generated offline by
324
- * `scripts/gen_first_token_to_words.py`, which mirrors the BE's in-memory map
325
- * (`CausalLMEncoder._ensure_loaded`).
326
- *
327
- * Populated lazily by `loadBePayloadData()` from a dynamically-imported JSON so
328
- * the (large) payload is only fetched when the local client is actually
329
- * initialised — keeping it out of the editor's main chunk for the vast majority
330
- * of users (who run with `useLocalModel` off).
331
- */
332
- let firstTokenToWords: Map<number, string[]> = new Map();
333
-
334
- /**
335
- * L2 (Atlassian-domain) word set, derived from the keys of `vocabulary_10k.json`.
336
- * Used by `computeBePayload` for tier-aware ranking: any word in the prefix map
337
- * that is not in this set is treated as L3 (general English), matching the BE.
338
- * Populated lazily alongside `firstTokenToWords` — see `loadBePayloadData()`.
339
- */
340
- let l2Words: Set<string> = new Set();
341
-
342
- /**
343
- * Array of token IDs that appear as a first token for at least one vocabulary
344
- * word. Derived from `firstTokenToWords` when the data loads so `computeBePayload`
345
- * does not re-allocate this array on every word-boundary call.
346
- */
347
- let prefixMapTokenIds: number[] = [];
412
+ let surfaceTokenIds: Map<string, number[]> = new Map();
413
+ let surfaceTokenTrie = new CanonicalSurfaceTokenTrie();
348
414
 
349
415
  /** De-dupes concurrent loads and lets repeated calls await the same payload. */
350
- let bePayloadDataPromise: Promise<void> | undefined;
351
-
352
- /**
353
- * Lazily load and build the BE-parity lookup tables from their CDN payloads.
354
- * Both artifacts are fetched on demand so neither is downloaded unless local
355
- * inference is initialised.
356
- *
357
- * :returns:
358
- * A promise that resolves once `firstTokenToWords`, `l2Words` and
359
- * `prefixMapTokenIds` are populated.
360
- */
361
- const loadBePayloadData = (): Promise<void> => {
362
- if (!bePayloadDataPromise) {
363
- bePayloadDataPromise = (async () => {
364
- const [firstTokenToWordsData, vocabularyData] = await Promise.all([
365
- fetchArtifactJson<Record<string, string[]>>(ARTIFACT_NAME.FIRST_TOKEN_TO_WORDS),
366
- fetchArtifactJson<{ words: Record<string, unknown> }>(ARTIFACT_NAME.VOCABULARY),
367
- ]);
416
+ let surfaceTokenIdsPromise: Promise<void> | undefined;
368
417
 
369
- if (firstTokenToWordsData == null || vocabularyData?.words == null) {
370
- // Hard-fail with a precise message so the catch() in initEngine logs
371
- // exactly which payload was malformed, rather than the generic V8
372
- // "Cannot convert undefined or null to object".
373
- throw new Error(
374
- `[LocalSlowLane] unexpected artifact payload — ` +
375
- `firstTokenToWordsData=${firstTokenToWordsData == null ? 'null/undefined' : 'defined'}, ` +
376
- `vocabularyData=${vocabularyData == null ? 'null/undefined' : vocabularyData.words == null ? 'defined but missing .words' : 'defined'}`,
377
- );
378
- }
418
+ const isPhraseContinuationTokens = (payload: unknown): payload is Record<string, number[]> => {
419
+ if (payload == null || typeof payload !== 'object') {
420
+ return false;
421
+ }
422
+ return Object.values(payload as Record<string, unknown>).every(
423
+ (value) =>
424
+ Array.isArray(value) && value.every((entry) => typeof entry === 'number' && entry >= 0),
425
+ );
426
+ };
379
427
 
380
- firstTokenToWords = new Map(
381
- Object.entries(firstTokenToWordsData).map(([tokenId, words]) => [Number(tokenId), words]),
428
+ /** Lazily load the producer's full-union canonical surface token map. */
429
+ const loadCanonicalSurfaceTokens = (): Promise<void> => {
430
+ if (!surfaceTokenIdsPromise) {
431
+ surfaceTokenIdsPromise = (async () => {
432
+ const continuationTokensData = await fetchAutocompleteArtifactJson<Record<string, number[]>>(
433
+ ARTIFACT_NAME.PHRASE_CONTINUATION_TOKENS,
434
+ {
435
+ summarize: (payload) => `${Object.keys(payload).length} surfaces`,
436
+ validate: isPhraseContinuationTokens,
437
+ },
438
+ ).catch(() => null);
439
+
440
+ surfaceTokenIds = new Map(
441
+ Object.entries(continuationTokensData ?? {}).map(([surface, tokenIds]) => [
442
+ surface.toLowerCase(),
443
+ tokenIds,
444
+ ]),
382
445
  );
383
- l2Words = new Set(Object.keys(vocabularyData.words));
384
- prefixMapTokenIds = Array.from(firstTokenToWords.keys());
446
+ surfaceTokenTrie = new CanonicalSurfaceTokenTrie(surfaceTokenIds.entries());
385
447
 
386
448
  if (isAutocompleteDebugEnabled()) {
387
449
  // eslint-disable-next-line no-console
388
450
  console.log(
389
- '%c[LocalSlowLane] %c✅ BE-parity payload data loaded:',
451
+ `%c[CTC:model] %c${surfaceTokenIds.size > 0 ? '✅ canonical surface tokens loaded:' : '⚠️ canonical surface tokens unavailable — LM evidence will remain absent:'}`,
390
452
  'color: #9c27b0; font-weight: bold;',
391
- 'color: #4caf50; font-weight: bold;',
392
- {
393
- firstTokenToWordsEntries: firstTokenToWords.size,
394
- l2WordsCount: l2Words.size,
395
- prefixMapTokenIdsLength: prefixMapTokenIds.length,
396
- },
453
+ surfaceTokenIds.size > 0
454
+ ? 'color: #4caf50; font-weight: bold;'
455
+ : 'color: #ff9800; font-weight: bold;',
456
+ { surfaces: surfaceTokenIds.size },
397
457
  );
398
458
  }
399
459
  })().catch((e) => {
400
460
  // Don't cache a rejected promise — a transient import failure would
401
461
  // otherwise prevent the local model from ever initialising again this
402
462
  // session. Reset so the next init attempt retries.
403
- bePayloadDataPromise = undefined;
463
+ surfaceTokenIdsPromise = undefined;
404
464
  throw e;
405
465
  });
406
466
  }
407
- return bePayloadDataPromise;
408
- };
409
-
410
- /**
411
- * Convert a raw next-token logit vector into a whole-word probability payload,
412
- * faithfully porting the BE `CausalLMEncoder._get_top_k_probs`
413
- * (`cc-smarts/python-sidecar/src/causal_lm_encoder.py`).
414
- *
415
- * Steps: (1) numerically-stable masked softmax over only the token ids present
416
- * in the prefix-expansion map; (2) spread each token's probability to every
417
- * whole word sharing that first token, taking the max; (3) reserve the top L2
418
- * words unconditionally; (4) rank the remainder in a log-space pool with an
419
- * additive L2 bias; (5) emit raw probabilities for the survivors, lowercased
420
- * and trimmed at `MIN_PROB`.
421
- *
422
- * :params:
423
- * rawLogits: Full-vocabulary logits from the LM's single decode step
424
- * prefixMap: Map of first-token id to the words starting with that token
425
- * domainWords: Set of L2 (domain) words, for tier-aware ranking
426
- * :returns:
427
- * A record of lowercase word to probability — the BE `lm_logits` payload
428
- */
429
- export const computeBePayload = (
430
- rawLogits: Float32Array,
431
- prefixMap: Map<number, string[]>,
432
- domainWords: Set<string>,
433
- /**
434
- * Pre-derived token-ID array for the softmax mask. Defaults to the
435
- * module-level `prefixMapTokenIds` (zero allocation in production). Pass
436
- * `Array.from(prefixMap.keys())` in tests that supply a custom prefixMap so
437
- * the softmax mask stays consistent with the iteration in Step 2.
438
- */
439
- validTokenIds: number[] = prefixMapTokenIds,
440
- ): Record<string, number> => {
441
- // 1. Numerically-stable masked softmax over validTokenIds only.
442
- let maxLogit = -Infinity;
443
- for (const id of validTokenIds) {
444
- const v = rawLogits[id];
445
- if (v > maxLogit) {
446
- maxLogit = v;
447
- }
448
- }
449
- let sumExp = 0;
450
- const expByToken = new Map<number, number>();
451
- for (const id of validTokenIds) {
452
- const e = Math.exp(rawLogits[id] - maxLogit);
453
- expByToken.set(id, e);
454
- sumExp += e;
455
- }
456
-
457
- // 2. Prefix expansion with max aggregation (probabilities sum to 1 over the
458
- // masked subset, so divide each token's exp by sumExp on the fly).
459
- const wordProbs = new Map<string, number>();
460
- for (const [id, words] of prefixMap) {
461
- const p = sumExp > 0 ? (expByToken.get(id) ?? 0) / sumExp : 0;
462
- for (const w of words) {
463
- const prev = wordProbs.get(w) ?? 0;
464
- if (p > prev) {
465
- wordProbs.set(w, p);
466
- }
467
- }
468
- }
469
-
470
- // 3. Split into L2 / L3 and reserve the top L2 slots unconditionally.
471
- const l2Matches: Array<[string, number]> = [];
472
- const l3Matches: Array<[string, number]> = [];
473
- for (const [w, p] of wordProbs) {
474
- if (domainWords.has(w)) {
475
- l2Matches.push([w, p]);
476
- } else {
477
- l3Matches.push([w, p]);
478
- }
479
- }
480
- l2Matches.sort((a, b) => b[1] - a[1]);
481
- const reserved = l2Matches.slice(0, BE_PARITY.RESERVED_L2_SLOTS);
482
-
483
- // 4. Pool the leftovers in log space; the L2 bias only affects ranking here.
484
- // Words in l2Matches are unique and the array is sorted descending, so the
485
- // non-reserved entries are exactly the tail after the reserved prefix — slice
486
- // it directly rather than allocating a Set and scanning every entry on this
487
- // hot path (runs ~every word boundary while typing).
488
- const pool: Array<[string, number]> = [];
489
- for (const [w, p] of l2Matches.slice(BE_PARITY.RESERVED_L2_SLOTS)) {
490
- pool.push([w, Math.log(Math.max(p, 1e-10)) + BE_PARITY.L2_BIAS]);
491
- }
492
- for (const [w, p] of l3Matches) {
493
- pool.push([w, Math.log(Math.max(p, 1e-10))]);
494
- }
495
- pool.sort((a, b) => b[1] - a[1]);
496
- const remainingSlots = Math.max(0, BE_PARITY.TOP_K_WORDS - reserved.length);
497
- const poolWinners = pool.slice(0, remainingSlots);
498
-
499
- // 5. Assemble payload: store RAW probabilities (the bias was ranking-only),
500
- // lowercase keys, trimmed at MIN_PROB. Reserved first, then pool winners.
501
- // Reserved entries are written first; pool-winner writes must NOT clobber a
502
- // reserved entry whose normalised key collides (two source words can
503
- // `.trim().toLowerCase()` to the same key — e.g. "Function" vs "function ").
504
- // Without the existence guard, a low-probability pool winner would silently
505
- // overwrite the (higher-probability) reserved entry, degrading top-K
506
- // quality in a way that's invisible from the debug summary.
507
- const result: Record<string, number> = {};
508
- const addEntry = (word: string, prob: number, allowOverwrite: boolean): void => {
509
- if (prob <= BE_PARITY.MIN_PROB) {
510
- return;
511
- }
512
- const key = word.trim().toLowerCase();
513
- if (!allowOverwrite && key in result) {
514
- return;
515
- }
516
- result[key] = prob;
517
- };
518
- for (const [w, p] of reserved) {
519
- addEntry(w, p, true);
520
- }
521
- for (const [w] of poolWinners) {
522
- addEntry(w, wordProbs.get(w) ?? 0, false);
523
- }
524
-
525
- if (isAutocompleteDebugEnabled()) {
526
- const topReserved = reserved
527
- .slice(0, 5)
528
- .map(([w, p]) => `${w}:${(p * 100).toFixed(2)}%`)
529
- .join(', ');
530
- const topPool = poolWinners
531
- .slice(0, 5)
532
- .map(([w]) => `${w}:${((wordProbs.get(w) ?? 0) * 100).toFixed(2)}%`)
533
- .join(', ');
534
- // eslint-disable-next-line no-console
535
- console.log(
536
- '%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',
537
- 'color: #9c27b0; font-weight: bold;',
538
- 'color: inherit;',
539
- validTokenIds.length,
540
- wordProbs.size,
541
- l2Matches.length,
542
- l3Matches.length,
543
- reserved.length,
544
- poolWinners.length,
545
- Object.keys(result).length,
546
- maxLogit.toFixed(3),
547
- sumExp.toFixed(1),
548
- topReserved || '(none)',
549
- topPool || '(none)',
550
- );
551
- }
552
-
553
- return result;
467
+ return surfaceTokenIdsPromise;
554
468
  };
555
469
 
556
470
  // ─── Factory ─────────────────────────────────────────────────────────────────
@@ -566,9 +480,8 @@ export const computeBePayload = (
566
480
  * const client = createLocalSlowLaneClient({ debounceMs: 300 });
567
481
  * // On word boundaries:
568
482
  * client.updateContext(docText);
569
- * // In scoring pipeline:
570
- * const vec = client.getContextVector();
571
- * const logits = client.getLmLogits();
483
+ * // Candidate scoring independently calls
484
+ * // primeBoundaryLm/requestProgressiveSurfaceScores.
572
485
  * // On plugin teardown:
573
486
  * client.destroy();
574
487
  * ```
@@ -579,6 +492,8 @@ export const createLocalSlowLaneClient = (
579
492
  const {
580
493
  debounceMs = DEFAULT_DEBOUNCE_MS,
581
494
  onUpdate,
495
+ onBoundaryLmUpdate,
496
+ onSurfaceScoreUpdate,
582
497
  onStatus,
583
498
  onLoadError,
584
499
  onLoadSuccess,
@@ -588,8 +503,58 @@ export const createLocalSlowLaneClient = (
588
503
  } = config;
589
504
 
590
505
  // ── State ──────────────────────────────────────────────────────────────
506
+ let storedContextInput: string | null = null;
591
507
  let storedContextVector: Float32Array | null = null;
592
508
  let storedLmLogits: Record<string, number> | null = null;
509
+ const boundaryCache = new Map<string, BoundaryLmState>();
510
+ const prefixExpansionCache = new Map<string, TokenPrefixExpansion>();
511
+ const surfaceScoreCache = new Map<string, SurfaceScore>();
512
+ let causalInFlight = false;
513
+ let inFlightBoundaryContextKey: string | null = null;
514
+ let latestCausalFamilyKey = '';
515
+ let causalRequestsThisFamily = 0;
516
+ let causalGeneratedTokensThisFamily = 0;
517
+ let tierAPrimeCacheHitsThisFamily = 0;
518
+ let tierAPrimeCacheMissesThisFamily = 0;
519
+ let causalInferenceOrdinal = 0;
520
+ const causalInferenceByContext = new Map<string, CausalInferenceAggregate>();
521
+ const causalInferenceByFamily = new Map<string, CausalInferenceAggregate>();
522
+ const exactEvidenceCountByContext = new Map<string, number>();
523
+ const pendingBoundaryPrimes = new Map<string, BoundaryPrimeRequest>();
524
+ interface PrefixExpansionRequest {
525
+ contextKey: string;
526
+ familyKey: string;
527
+ prompt: string;
528
+ surfaces: string[];
529
+ tokenPrefix: number[];
530
+ }
531
+ const pendingPrefixExpansions = new Map<string, PrefixExpansionRequest>();
532
+ const inFlightPrefixExpansions = new Set<string>();
533
+ // The engine holds exactly one linear KV sequence. WebLLM lets us extend it
534
+ // (`forwardTokensAndSample`) or drop it (a text completion always resets
535
+ // first), but never fork or rewind it. Tracking what is currently
536
+ // materialised is what makes decoding continuous: expanding a token prefix
537
+ // that extends `kvPath` costs a single decode step, while any other prefix
538
+ // costs a fresh prompt prefill.
539
+ let kvContextKey: string | null = null;
540
+ let kvPath: number[] = [];
541
+
542
+ const isPrefixOf = (prefix: number[], path: number[]): boolean =>
543
+ prefix.length <= path.length && prefix.every((token, index) => token === path[index]);
544
+ /**
545
+ * Whether running this expansion would extend the sequence the engine is
546
+ * already holding rather than discarding it for a fresh prompt prefill.
547
+ *
548
+ * `planProgressiveExpansion` also prefers a warm group, but it decides when
549
+ * the work is queued and the queue is drained later. A boundary prime or
550
+ * another context's expansion running in between moves the path out from
551
+ * under that choice, so the preference has to be re-checked at the moment
552
+ * something is picked up.
553
+ */
554
+ const extendsLiveKvPath = (request: PrefixExpansionRequest): boolean =>
555
+ kvContextKey === request.contextKey && isPrefixOf(kvPath, request.tokenPrefix);
556
+ const progressiveRequests = new Map<string, SurfaceScoreRequest>();
557
+ const progressivePrefixesByContext = new Map<string, Set<string>>();
593
558
  let debounceTimer: ReturnType<typeof setTimeout> | null = null;
594
559
  let lastRequestedText = '';
595
560
  let requestCounter = 0;
@@ -607,16 +572,14 @@ export const createLocalSlowLaneClient = (
607
572
  let initFailed = false;
608
573
  let engine: MLCEngine | null = null;
609
574
  let engineInitPromise: Promise<void> | null = null;
610
- // Captures raw next-token logits from the LM's single decode step. Registered
611
- // with the engine below; `lmLogitsCapture.captured` is consumed in a later step.
612
- const lmLogitsCapture = new CapturingLogitProcessor();
575
+ const causalLogitProcessor = new CanonicalLogitProcessor();
613
576
 
614
577
  const unloadEngine = (engineToUnload: MLCEngine): void => {
615
578
  engineToUnload.unload().catch((error: unknown) => {
616
579
  if (isAutocompleteDebugEnabled()) {
617
580
  // eslint-disable-next-line no-console
618
581
  console.log(
619
- '%c[LocalSlowLane] %cFailed to unload engine',
582
+ '%c[CTC:model] %cFailed to unload engine',
620
583
  'color: #9c27b0; font-weight: bold;',
621
584
  'color: inherit;',
622
585
  error,
@@ -632,7 +595,7 @@ export const createLocalSlowLaneClient = (
632
595
  if (isAutocompleteDebugEnabled()) {
633
596
  // eslint-disable-next-line no-console
634
597
  console.log(
635
- `%c[LocalSlowLane] %c🔄 ${message}`,
598
+ `%c[CTC:model] %c🔄 ${message}`,
636
599
  'color: #9c27b0; font-weight: bold;',
637
600
  'color: inherit;',
638
601
  );
@@ -744,7 +707,7 @@ export const createLocalSlowLaneClient = (
744
707
  if (isAutocompleteDebugEnabled()) {
745
708
  // eslint-disable-next-line no-console
746
709
  console.log(
747
- `[LocalSlowLane] Engine initialisation failed (${reason}): ${debugDetail ?? message}`,
710
+ `[CTC:model] Engine initialisation failed (${reason}): ${debugDetail ?? message}`,
748
711
  );
749
712
  }
750
713
  onStatus?.(`Engine initialisation failed: ${message}`);
@@ -782,20 +745,20 @@ export const createLocalSlowLaneClient = (
782
745
  if (isAutocompleteDebugEnabled()) {
783
746
  // eslint-disable-next-line no-console
784
747
  console.log(
785
- `%c[LocalSlowLane] %c🚀 Initialising MLC engine with models: ${modelId} (LM) + ${LOCAL_MLC_EMBEDDING_MODEL_ID} (embedder)`,
748
+ `%c[CTC:model] %c🚀 Initialising MLC engine with models: ${modelId} (LM) + ${LOCAL_MLC_EMBEDDING_MODEL_ID} (embedder)`,
786
749
  'color: #9c27b0; font-weight: bold;',
787
750
  'color: inherit;',
788
751
  );
789
752
  }
790
753
  onStatus?.(`Initialising models: ${modelId} + ${LOCAL_MLC_EMBEDDING_MODEL_ID}…`);
791
754
 
792
- // Fetch the web-llm runtime and the BE-parity lookup tables in parallel;
755
+ // Fetch the web-llm runtime and canonical token artifact in parallel;
793
756
  // both are dynamically imported so they stay out of the main editor chunk.
794
757
  const [{ MLCEngine: MLCEngineCtor, prebuiltAppConfig }] = await Promise.all([
795
758
  import(
796
759
  /* webpackChunkName: "@atlaskit-internal_editor-plugin-autocomplete-mlc-web-llm" */ '@mlc-ai/web-llm'
797
760
  ),
798
- loadBePayloadData(),
761
+ loadCanonicalSurfaceTokens(),
799
762
  ]);
800
763
 
801
764
  const customModelRecord: WebLlmModelRecord | undefined = customModelConfig
@@ -833,7 +796,7 @@ export const createLocalSlowLaneClient = (
833
796
  const newEngine = new MLCEngineCtor({
834
797
  appConfig,
835
798
  initProgressCallback,
836
- logitProcessorRegistry: new Map([[modelId, lmLogitsCapture]]),
799
+ logitProcessorRegistry: new Map([[modelId, causalLogitProcessor]]),
837
800
  });
838
801
 
839
802
  await newEngine.reload([modelId, LOCAL_MLC_EMBEDDING_MODEL_ID]);
@@ -851,7 +814,7 @@ export const createLocalSlowLaneClient = (
851
814
  if (isAutocompleteDebugEnabled()) {
852
815
  // eslint-disable-next-line no-console
853
816
  console.log(
854
- '%c[LocalSlowLane] %c✅ Both models loaded and ready',
817
+ '%c[CTC:model] %c✅ Both models loaded and ready',
855
818
  'color: #9c27b0; font-weight: bold;',
856
819
  'color: #4caf50;',
857
820
  );
@@ -859,14 +822,14 @@ export const createLocalSlowLaneClient = (
859
822
  // without digging through the init-progress scroll.
860
823
  // eslint-disable-next-line no-console
861
824
  console.log(
862
- '%c[LocalSlowLane] %c🧠 Causal LM →',
825
+ '%c[CTC:model] %c🧠 Causal LM →',
863
826
  'color: #9c27b0; font-weight: bold;',
864
827
  'color: #2196f3; font-weight: bold;',
865
828
  modelId,
866
829
  );
867
830
  // eslint-disable-next-line no-console
868
831
  console.log(
869
- '%c[LocalSlowLane] %c🔢 Embedder →',
832
+ '%c[CTC:model] %c🔢 Embedder →',
870
833
  'color: #9c27b0; font-weight: bold;',
871
834
  'color: #009688; font-weight: bold;',
872
835
  LOCAL_MLC_EMBEDDING_MODEL_ID,
@@ -897,17 +860,7 @@ export const createLocalSlowLaneClient = (
897
860
 
898
861
  // ── Inference ──────────────────────────────────────────────────────────
899
862
 
900
- /**
901
- * Run a single forward pass to produce the BE-parity slow-lane outputs.
902
- *
903
- * Two calls run in parallel on the shared engine:
904
- * - `completions.create({ max_tokens: 1 })` runs the causal LM for exactly
905
- * one decode step. We ignore the generated text; the LogitProcessor
906
- * captures the raw next-token logits during that step, which we turn into
907
- * a whole-word payload via `computeBePayload`.
908
- * - `embeddings.create(...)` runs the Arctic embedder to produce the real
909
- * 384-d semantic vector (passage-encoded; see `wrapForArctic`).
910
- */
863
+ /** Run the boundary-timed Arctic semantic inference independently of the LM. */
911
864
  const runInference = async (text: string, requestId: number): Promise<void> => {
912
865
  if (!engine || destroyed) {
913
866
  return;
@@ -915,42 +868,16 @@ export const createLocalSlowLaneClient = (
915
868
 
916
869
  const experienceId = String(requestId);
917
870
 
918
- // Clear the capture buffer so we read only this pass's logits. The engine
919
- // serialises per-model requests and updateContext is debounced, so the
920
- // latest request's decode step is the last to populate `captured` before
921
- // we read it below; stale requests bail on the latestRequestId guard.
922
- lmLogitsCapture.resetState();
923
-
924
- // Apply BE-parity rolling-window truncation before both encoders.
925
- // BE semantic: last max_context_words words (typeahead_context_encoding.py:36)
926
- // BE causal LM: last max_context_tokens BPE tokens (causal_lm_encoder.py:194–198),
927
- // approximated here with word count (no tokenizer available on FE).
928
- const lmText = truncateToLastNWords(text, BE_PARITY.MAX_CONTEXT_TOKENS);
929
- const semanticText = truncateToLastNWords(text, BE_PARITY.MAX_CONTEXT_WORDS);
871
+ const semanticText = truncateToLastNWords(text, LOCAL_INFERENCE.MAX_CONTEXT_WORDS);
930
872
  const arcticInput = wrapForArctic(semanticText);
931
- function captureCompletionTime<T>(
932
- promise: Promise<T>,
933
- onResolved: (resolvedAt: number) => void,
934
- ): Promise<T> {
935
- return promise.then((value: T) => {
936
- onResolved(performance.now());
937
- return value;
938
- });
939
- }
940
873
 
941
- if (isAutocompleteDebugEnabled()) {
874
+ if (isAutocompleteDebugVerbose()) {
942
875
  // eslint-disable-next-line no-console
943
876
  console.log(
944
- `%c[LocalSlowLane] %c🔢 Arctic input (${arcticInput.length} chars, ${splitOnWhitespace(semanticText).length} words): "${arcticInput.length > 100 ? `${arcticInput.slice(0, 100)}…` : arcticInput}"`,
877
+ `%c[CTC:model] %c🔢 Arctic input (${arcticInput.length} chars, ${splitOnWhitespace(semanticText).length} words): "${arcticInput.length > 100 ? `${arcticInput.slice(0, 100)}…` : arcticInput}"`,
945
878
  'color: #9c27b0; font-weight: bold;',
946
879
  'color: #009688;',
947
880
  );
948
- // eslint-disable-next-line no-console
949
- console.log(
950
- `%c[LocalSlowLane] %c🧠 LM input (${lmText.length} chars, ${splitOnWhitespace(lmText).length} words): "${lmText.length > 100 ? `${lmText.slice(0, 100)}…` : lmText}"`,
951
- 'color: #9c27b0; font-weight: bold;',
952
- 'color: #2196f3;',
953
- );
954
881
  }
955
882
 
956
883
  try {
@@ -967,37 +894,16 @@ export const createLocalSlowLaneClient = (
967
894
  });
968
895
 
969
896
  const tStart = performance.now();
970
- let tLmDone = 0;
971
- let tEmbDone = 0;
972
-
973
- const [, embeddingResponse] = await Promise.all([
974
- captureCompletionTime(
975
- engine.completions.create({
976
- model: modelId,
977
- prompt: lmText,
978
- max_tokens: 1,
979
- temperature: 0,
980
- logprobs: false,
981
- }),
982
- (resolvedAt) => {
983
- tLmDone = resolvedAt;
984
- },
985
- ),
986
- captureCompletionTime(
987
- engine.embeddings.create({
988
- model: LOCAL_MLC_EMBEDDING_MODEL_ID,
989
- input: arcticInput,
990
- }),
991
- (resolvedAt) => {
992
- tEmbDone = resolvedAt;
993
- },
994
- ),
995
- ]);
897
+ const embeddingResponse = await engine.embeddings.create({
898
+ model: LOCAL_MLC_EMBEDDING_MODEL_ID,
899
+ input: arcticInput,
900
+ });
901
+ const tEmbDone = performance.now();
996
902
 
997
903
  if (isAutocompleteDebugEnabled()) {
998
904
  // eslint-disable-next-line no-console
999
905
  console.log(
1000
- `%c[LocalSlowLane] %c⏱ LM: ${(tLmDone - tStart).toFixed(0)}ms | Embedder: ${(tEmbDone - tStart).toFixed(0)}ms | Total: ${(Math.max(tLmDone, tEmbDone) - tStart).toFixed(0)}ms`,
906
+ `%c[CTC:model] %c⏱ Embedder: ${(tEmbDone - tStart).toFixed(0)}ms`,
1001
907
  'color: #9c27b0; font-weight: bold;',
1002
908
  'color: #ff9800;',
1003
909
  );
@@ -1014,15 +920,6 @@ export const createLocalSlowLaneClient = (
1014
920
  return;
1015
921
  }
1016
922
 
1017
- // ── LM logits: whole-word BE-parity payload ──────────────────
1018
- const rawLogits = lmLogitsCapture.captured;
1019
- if (rawLogits) {
1020
- const payload = computeBePayload(rawLogits, firstTokenToWords, l2Words);
1021
- storedLmLogits = Object.keys(payload).length > 0 ? payload : null;
1022
- } else {
1023
- storedLmLogits = null;
1024
- }
1025
-
1026
923
  // ── Semantic vector: real 384-d Arctic embedding ─────────────
1027
924
  // Guard against base64-encoded responses (encoding_format: 'base64' would
1028
925
  // yield a string, and new Float32Array(string) silently produces an empty
@@ -1032,11 +929,12 @@ export const createLocalSlowLaneClient = (
1032
929
  Array.isArray(embedding) && embedding.length > 0
1033
930
  ? new Float32Array(embedding as number[])
1034
931
  : null;
932
+ storedContextInput = storedContextVector ? semanticText : null;
1035
933
 
1036
934
  if (isAutocompleteDebugEnabled()) {
1037
935
  // eslint-disable-next-line no-console
1038
936
  console.groupCollapsed(
1039
- `%c[LocalSlowLane] %c📥 Inference result (request #${requestId})`,
937
+ `%c[CTC:model] %c📥 Inference result (request #${requestId})`,
1040
938
  'color: #9c27b0; font-weight: bold;',
1041
939
  'color: inherit;',
1042
940
  );
@@ -1054,21 +952,7 @@ export const createLocalSlowLaneClient = (
1054
952
  console.log('❌ No vector');
1055
953
  }
1056
954
  // eslint-disable-next-line no-console
1057
- console.log(
1058
- storedLmLogits
1059
- ? `✅ lm_logits: ${Object.keys(storedLmLogits).length} words`
1060
- : '❌ No lm_logits',
1061
- );
1062
- if (storedLmLogits) {
1063
- const topTokens = Object.entries(storedLmLogits)
1064
- .sort(([, a], [, b]) => b - a)
1065
- .slice(0, 10);
1066
- // eslint-disable-next-line no-console
1067
- console.log(
1068
- 'Top 10 predictions:',
1069
- topTokens.map(([t, p]) => `${t}: ${(p * 100).toFixed(1)}%`).join(', '),
1070
- );
1071
- }
955
+ console.log('🧠 causal LM: independently primed by exact candidate contexts');
1072
956
  // eslint-disable-next-line no-console
1073
957
  console.groupEnd();
1074
958
  }
@@ -1076,7 +960,7 @@ export const createLocalSlowLaneClient = (
1076
960
  succeedExp(EXPERIENCE_NAME.SLOW_LANE_FETCH, experienceId, {
1077
961
  textLength: text.length,
1078
962
  hasVector: storedContextVector !== null,
1079
- hasLmLogits: storedLmLogits !== null,
963
+ hasLmLogits: false,
1080
964
  isLocalLLM: true,
1081
965
  ...(surface ? { surface } : {}),
1082
966
  });
@@ -1084,7 +968,7 @@ export const createLocalSlowLaneClient = (
1084
968
  onUpdate?.({
1085
969
  textLength: text.length,
1086
970
  hasVector: storedContextVector !== null,
1087
- hasLmLogits: storedLmLogits !== null,
971
+ hasLmLogits: false,
1088
972
  });
1089
973
  } catch (err) {
1090
974
  // Discard errors for stale requests or after teardown
@@ -1098,6 +982,7 @@ export const createLocalSlowLaneClient = (
1098
982
  return;
1099
983
  }
1100
984
 
985
+ storedContextInput = null;
1101
986
  storedContextVector = null;
1102
987
  storedLmLogits = null;
1103
988
  failExp(EXPERIENCE_NAME.SLOW_LANE_FETCH, experienceId, {
@@ -1111,7 +996,7 @@ export const createLocalSlowLaneClient = (
1111
996
  if (isAutocompleteDebugEnabled()) {
1112
997
  // eslint-disable-next-line no-console
1113
998
  console.log(
1114
- `%c[LocalSlowLane] %c❌ Inference error (request #${requestId}): ${errorMsg}`,
999
+ `%c[CTC:model] %c❌ Inference error (request #${requestId}): ${errorMsg}`,
1115
1000
  'color: #9c27b0; font-weight: bold;',
1116
1001
  'color: #f44336;',
1117
1002
  );
@@ -1119,6 +1004,889 @@ export const createLocalSlowLaneClient = (
1119
1004
  }
1120
1005
  };
1121
1006
 
1007
+ // ── Canonical causal scorer ─────────────────────────────────────────────
1008
+
1009
+ const surfaceCacheKey = (
1010
+ contextKey: string,
1011
+ surface: string,
1012
+ tokenIds = surfaceTokenIds.get(surface.toLowerCase()) ?? [],
1013
+ ): string => `${contextKey}\u0000${surface.toLowerCase()}\u0000${tokenIds.join(',')}`;
1014
+
1015
+ // Declared as functions rather than generic arrows: this file carries a `webpackChunkName`
1016
+ // comment, which opts it into a build-time parse that reads `<T>(` as a JSX tag.
1017
+ function getLru<T>(cache: Map<string, T>, key: string): T | null {
1018
+ const value = cache.get(key);
1019
+ if (value === undefined) {
1020
+ return null;
1021
+ }
1022
+ cache.delete(key);
1023
+ cache.set(key, value);
1024
+ return value;
1025
+ }
1026
+
1027
+ function setLru<T>(cache: Map<string, T>, key: string, value: T, maxSize: number): void {
1028
+ cache.delete(key);
1029
+ cache.set(key, value);
1030
+ while (cache.size > maxSize) {
1031
+ const oldest = cache.keys().next().value;
1032
+ if (oldest === undefined) {
1033
+ break;
1034
+ }
1035
+ cache.delete(oldest);
1036
+ }
1037
+ }
1038
+
1039
+ const prefixCacheKey = (contextKey: string, tokenPrefix: number[]): string =>
1040
+ `${contextKey}\u0000${tokenPrefix.join(',')}`;
1041
+
1042
+ const logProgressiveState = (
1043
+ state:
1044
+ | 'cached'
1045
+ | 'completed'
1046
+ | 'deduplicated'
1047
+ | 'expanded'
1048
+ | 'failed'
1049
+ | 'queued'
1050
+ | 'stale'
1051
+ | 'started',
1052
+ detail: string,
1053
+ ): void => {
1054
+ if (!isAutocompleteDebugEnabled()) {
1055
+ return;
1056
+ }
1057
+ // eslint-disable-next-line no-console
1058
+ console.log(
1059
+ `%c[CTC:model] %c🔀 grouped ${state} · ${detail}`,
1060
+ 'color: #9c27b0; font-weight: bold;',
1061
+ state === 'failed'
1062
+ ? 'color: #f44336;'
1063
+ : state === 'completed' || state === 'cached'
1064
+ ? 'color: #4caf50;'
1065
+ : 'color: #2196f3;',
1066
+ );
1067
+ };
1068
+
1069
+ const createCausalInferenceAggregate = (startedAt: number): CausalInferenceAggregate => ({
1070
+ e2eLatencyMs: 0,
1071
+ failedRequests: 0,
1072
+ firstStartedAt: startedAt,
1073
+ promptTokens: 0,
1074
+ promptUsageSamples: 0,
1075
+ requestedCompletionTokens: 0,
1076
+ requests: 0,
1077
+ sampledOutputTokens: 0,
1078
+ timeToFirstTokenMs: 0,
1079
+ wallClockMs: 0,
1080
+ webLlmDecodeSteps: 0,
1081
+ webLlmUsageSamples: 0,
1082
+ });
1083
+
1084
+ const updateCausalInferenceAggregate = (
1085
+ cache: Map<string, CausalInferenceAggregate>,
1086
+ key: string,
1087
+ measurement: CausalInferenceMeasurement,
1088
+ ): CausalInferenceAggregate => {
1089
+ const aggregate =
1090
+ getLru(cache, key) ??
1091
+ createCausalInferenceAggregate(performance.now() - measurement.wallClockMs);
1092
+ aggregate.requests++;
1093
+ aggregate.requestedCompletionTokens += measurement.requestedCompletionTokens;
1094
+ aggregate.wallClockMs += measurement.wallClockMs;
1095
+ if (measurement.outcome === 'failed') {
1096
+ aggregate.failedRequests++;
1097
+ }
1098
+ if (measurement.promptTokens !== null) {
1099
+ aggregate.promptTokens += measurement.promptTokens;
1100
+ aggregate.promptUsageSamples++;
1101
+ }
1102
+ if (measurement.sampledOutputTokens !== null && measurement.webLlmDecodeSteps !== null) {
1103
+ aggregate.sampledOutputTokens += measurement.sampledOutputTokens;
1104
+ aggregate.webLlmDecodeSteps += measurement.webLlmDecodeSteps;
1105
+ aggregate.webLlmUsageSamples++;
1106
+ }
1107
+ if (measurement.timeToFirstTokenMs !== null) {
1108
+ aggregate.timeToFirstTokenMs += measurement.timeToFirstTokenMs;
1109
+ }
1110
+ if (measurement.e2eLatencyMs !== null) {
1111
+ aggregate.e2eLatencyMs += measurement.e2eLatencyMs;
1112
+ }
1113
+ setLru(cache, key, aggregate, CANONICAL_SCORING.BOUNDARY_CACHE_MAX);
1114
+ return aggregate;
1115
+ };
1116
+
1117
+ const causalAggregateForLog = (aggregate: CausalInferenceAggregate) => ({
1118
+ calls: aggregate.requests,
1119
+ elapsedMs: Number((performance.now() - aggregate.firstStartedAt).toFixed(1)),
1120
+ failedCalls: aggregate.failedRequests,
1121
+ inferenceWallMs: Number(aggregate.wallClockMs.toFixed(1)),
1122
+ promptTokens: aggregate.promptUsageSamples > 0 ? aggregate.promptTokens : 'unreported',
1123
+ requestedOutputTokens: aggregate.requestedCompletionTokens,
1124
+ sampledOutputTokens:
1125
+ aggregate.webLlmUsageSamples > 0 ? aggregate.sampledOutputTokens : 'unreported',
1126
+ timeToFirstTokenMs:
1127
+ aggregate.timeToFirstTokenMs > 0
1128
+ ? Number(aggregate.timeToFirstTokenMs.toFixed(1))
1129
+ : 'unreported',
1130
+ webLlmE2eMs:
1131
+ aggregate.e2eLatencyMs > 0 ? Number(aggregate.e2eLatencyMs.toFixed(1)) : 'unreported',
1132
+ webLlmDecodeSteps:
1133
+ aggregate.webLlmUsageSamples > 0 ? aggregate.webLlmDecodeSteps : 'unreported',
1134
+ });
1135
+
1136
+ const recordCausalInference = (measurement: CausalInferenceMeasurement): void => {
1137
+ if (!isAutocompleteDebugEnabled()) {
1138
+ return;
1139
+ }
1140
+ const familyAggregate = updateCausalInferenceAggregate(
1141
+ causalInferenceByFamily,
1142
+ measurement.familyKey,
1143
+ measurement,
1144
+ );
1145
+ const contextAggregate = updateCausalInferenceAggregate(
1146
+ causalInferenceByContext,
1147
+ measurement.contextKey,
1148
+ measurement,
1149
+ );
1150
+ // One line plus two nested objects per LM call is heavy enough to distort
1151
+ // the latencies it reports, so keep the per-call breakdown behind verbose.
1152
+ if (!isAutocompleteDebugVerbose()) {
1153
+ return;
1154
+ }
1155
+ // eslint-disable-next-line no-console
1156
+ console.log(
1157
+ `%c[CTC:model-cost]%c ${measurement.kind} ${measurement.outcome} · ${measurement.wallClockMs.toFixed(1)}ms · prompt ${measurement.promptTokens ?? '?'} tok/${measurement.promptWords} words · output ${measurement.sampledOutputTokens ?? '?'} sampled/${measurement.requestedCompletionTokens} requested · decode ${measurement.webLlmDecodeSteps ?? '?'} step${measurement.webLlmDecodeSteps === 1 ? '' : 's'} · TTFT ${measurement.timeToFirstTokenMs?.toFixed(1) ?? '?'}ms · ${measurement.warmState}`,
1158
+ CTC_STYLES.section,
1159
+ CTC_STYLES.body,
1160
+ {
1161
+ request: {
1162
+ contextKey: measurement.contextKey,
1163
+ familyKey: measurement.familyKey,
1164
+ kind: measurement.kind,
1165
+ outcome: measurement.outcome,
1166
+ warmState: measurement.warmState,
1167
+ },
1168
+ actual: {
1169
+ decodeTokensPerSecond: measurement.decodeTokensPerSecond,
1170
+ e2eLatencyMs: measurement.e2eLatencyMs,
1171
+ prefillTokensPerSecond: measurement.prefillTokensPerSecond,
1172
+ promptTokens: measurement.promptTokens,
1173
+ promptWords: measurement.promptWords,
1174
+ sampledOutputTokens: measurement.sampledOutputTokens,
1175
+ timePerDecodeTokenMs: measurement.timePerDecodeTokenMs,
1176
+ timeToFirstTokenMs: measurement.timeToFirstTokenMs,
1177
+ wallClockMs: measurement.wallClockMs,
1178
+ webLlmDecodeSteps: measurement.webLlmDecodeSteps,
1179
+ },
1180
+ context: causalAggregateForLog(contextAggregate),
1181
+ family: causalAggregateForLog(familyAggregate),
1182
+ },
1183
+ );
1184
+ };
1185
+
1186
+ /**
1187
+ * Pull the captured distribution out of the processor, timing the handover.
1188
+ *
1189
+ * Every call yields one array the width of the vocabulary. If that width is
1190
+ * large and the handover is slow, thousands of calls per session turn into
1191
+ * allocation churn that shows up as latency without any model work behind it.
1192
+ */
1193
+ const captureLogits = (): Float32Array | null => causalLogitProcessor.getCapturedLogits();
1194
+
1195
+ const createMeasuredCausalCompletion = async (
1196
+ activeEngine: MLCEngine,
1197
+ input: {
1198
+ contextKey: string;
1199
+ familyKey: string;
1200
+ kind: CausalInferenceKind;
1201
+ prompt: string;
1202
+ requestedCompletionTokens: number;
1203
+ },
1204
+ ): Promise<{ latencyMs: number }> => {
1205
+ const prompt = truncateToLastNWords(input.prompt, LOCAL_INFERENCE.MAX_CONTEXT_TOKENS);
1206
+ const promptWords = splitOnWhitespace(prompt).length;
1207
+ const startedAt = performance.now();
1208
+ const warmState = ++causalInferenceOrdinal === 1 ? 'cold-first-call' : 'warm';
1209
+ try {
1210
+ const completion = await activeEngine.completions.create({
1211
+ model: modelId,
1212
+ prompt,
1213
+ max_tokens: input.requestedCompletionTokens,
1214
+ temperature: 0,
1215
+ logprobs: false,
1216
+ ignore_eos: true,
1217
+ });
1218
+ const latencyMs = performance.now() - startedAt;
1219
+ const usage = completion.usage;
1220
+ const webLlmDecodeSteps = usage?.completion_tokens ?? null;
1221
+ // WebLLM samples the first output token during prefill, but its
1222
+ // completion_tokens usage counter increments only in decodeStep().
1223
+ // Add that prefill-sampled token back without exceeding max_tokens.
1224
+ const sampledOutputTokens =
1225
+ webLlmDecodeSteps === null
1226
+ ? null
1227
+ : Math.min(input.requestedCompletionTokens, webLlmDecodeSteps + 1);
1228
+ recordCausalInference({
1229
+ contextKey: input.contextKey,
1230
+ decodeTokensPerSecond: usage?.extra?.decode_tokens_per_s ?? null,
1231
+ e2eLatencyMs:
1232
+ usage?.extra?.e2e_latency_s !== undefined ? usage.extra.e2e_latency_s * 1000 : null,
1233
+ familyKey: input.familyKey,
1234
+ kind: input.kind,
1235
+ outcome: 'completed',
1236
+ prefillTokensPerSecond: usage?.extra?.prefill_tokens_per_s ?? null,
1237
+ promptTokens: usage?.prompt_tokens ?? null,
1238
+ promptWords,
1239
+ requestedCompletionTokens: input.requestedCompletionTokens,
1240
+ sampledOutputTokens,
1241
+ timePerDecodeTokenMs:
1242
+ usage?.extra?.time_per_output_token_s !== undefined
1243
+ ? usage.extra.time_per_output_token_s * 1000
1244
+ : null,
1245
+ timeToFirstTokenMs:
1246
+ usage?.extra?.time_to_first_token_s !== undefined
1247
+ ? usage.extra.time_to_first_token_s * 1000
1248
+ : null,
1249
+ wallClockMs: latencyMs,
1250
+ warmState,
1251
+ webLlmDecodeSteps,
1252
+ });
1253
+ return { latencyMs };
1254
+ } catch (error) {
1255
+ const latencyMs = performance.now() - startedAt;
1256
+ recordCausalInference({
1257
+ contextKey: input.contextKey,
1258
+ decodeTokensPerSecond: null,
1259
+ e2eLatencyMs: null,
1260
+ familyKey: input.familyKey,
1261
+ kind: input.kind,
1262
+ outcome: 'failed',
1263
+ prefillTokensPerSecond: null,
1264
+ promptTokens: null,
1265
+ promptWords,
1266
+ requestedCompletionTokens: input.requestedCompletionTokens,
1267
+ sampledOutputTokens: null,
1268
+ timePerDecodeTokenMs: null,
1269
+ timeToFirstTokenMs: null,
1270
+ wallClockMs: latencyMs,
1271
+ warmState,
1272
+ webLlmDecodeSteps: null,
1273
+ });
1274
+ throw error;
1275
+ }
1276
+ };
1277
+
1278
+ const logExactEvidenceReadiness = (
1279
+ contextKey: string,
1280
+ familyKey: string,
1281
+ exactSurfaceCount: number,
1282
+ source: CausalInferenceKind,
1283
+ ): void => {
1284
+ if (!isAutocompleteDebugEnabled()) {
1285
+ return;
1286
+ }
1287
+ const previousCount = exactEvidenceCountByContext.get(contextKey) ?? 0;
1288
+ if (exactSurfaceCount <= previousCount) {
1289
+ return;
1290
+ }
1291
+ setLru(
1292
+ exactEvidenceCountByContext,
1293
+ contextKey,
1294
+ exactSurfaceCount,
1295
+ CANONICAL_SCORING.BOUNDARY_CACHE_MAX,
1296
+ );
1297
+ const contextAggregate = getLru(causalInferenceByContext, contextKey);
1298
+ const familyAggregate = getLru(causalInferenceByFamily, familyKey);
1299
+ // eslint-disable-next-line no-console
1300
+ console.log(
1301
+ `%c[CTC:readiness]%c exact evidence · ${exactSurfaceCount} surface${exactSurfaceCount === 1 ? '' : 's'} · source=${source} · ctx=${contextKey.slice(0, 64)}`,
1302
+ CTC_STYLES.good,
1303
+ CTC_STYLES.body,
1304
+ {
1305
+ context: contextAggregate ? causalAggregateForLog(contextAggregate) : null,
1306
+ contextKey,
1307
+ exactSurfaceCount,
1308
+ family: familyAggregate ? causalAggregateForLog(familyAggregate) : null,
1309
+ familyKey,
1310
+ source,
1311
+ },
1312
+ );
1313
+ };
1314
+
1315
+ const getProgressiveEvidence = (
1316
+ contextKey: string,
1317
+ candidateSurface: string,
1318
+ tokenIds = surfaceTokenTrie.getTokenIds(candidateSurface) ?? [],
1319
+ ): ProgressiveSurfaceEvidence | null => {
1320
+ if (tokenIds.length === 0) {
1321
+ return null;
1322
+ }
1323
+ const exactKey = surfaceCacheKey(contextKey, candidateSurface, tokenIds);
1324
+ const exact = getLru(surfaceScoreCache, exactKey);
1325
+ if (exact) {
1326
+ return {
1327
+ meanTokenLogProbabilityUpperBound: exact.meanTokenLogProbability,
1328
+ scoredTokenCount: exact.tokenCount,
1329
+ totalLogProbability: exact.totalLogProbability,
1330
+ totalTokenCount: exact.tokenCount,
1331
+ };
1332
+ }
1333
+ const boundary = getLru(boundaryCache, contextKey);
1334
+ const firstToken = tokenIds[0];
1335
+ if (!boundary || firstToken === undefined) {
1336
+ return null;
1337
+ }
1338
+ let totalLogProbability = logSoftmaxAt(boundary.rawLogits, firstToken);
1339
+ if (!Number.isFinite(totalLogProbability)) {
1340
+ return null;
1341
+ }
1342
+ let scoredTokenCount = 1;
1343
+ while (scoredTokenCount < tokenIds.length) {
1344
+ const prefix = tokenIds.slice(0, scoredTokenCount);
1345
+ const expansion = getLru(prefixExpansionCache, prefixCacheKey(contextKey, prefix));
1346
+ if (!expansion) {
1347
+ break;
1348
+ }
1349
+ const nextTokenLogProbability = logSoftmaxAt(
1350
+ expansion.rawNextTokenLogits,
1351
+ tokenIds[scoredTokenCount],
1352
+ );
1353
+ if (!Number.isFinite(nextTokenLogProbability)) {
1354
+ break;
1355
+ }
1356
+ totalLogProbability = expansion.totalLogProbability + nextTokenLogProbability;
1357
+ scoredTokenCount++;
1358
+ }
1359
+
1360
+ if (scoredTokenCount === tokenIds.length) {
1361
+ const exactScore: SurfaceScore = {
1362
+ contextKey,
1363
+ surface: candidateSurface,
1364
+ totalLogProbability,
1365
+ meanTokenLogProbability: totalLogProbability / tokenIds.length,
1366
+ tokenCount: tokenIds.length,
1367
+ };
1368
+ setLru(surfaceScoreCache, exactKey, exactScore, CANONICAL_SCORING.SURFACE_CACHE_MAX);
1369
+ }
1370
+
1371
+ return {
1372
+ // Every unscored future token has log probability <= 0. Dividing the
1373
+ // scored total by the final token count is therefore a safe optimistic
1374
+ // bound on the eventual mean.
1375
+ meanTokenLogProbabilityUpperBound: totalLogProbability / tokenIds.length,
1376
+ scoredTokenCount,
1377
+ totalLogProbability,
1378
+ totalTokenCount: tokenIds.length,
1379
+ };
1380
+ };
1381
+
1382
+ const planProgressiveExpansion = (contextKey: string): void => {
1383
+ const request = progressiveRequests.get(contextKey);
1384
+ if (!request || destroyed || !boundaryCache.has(contextKey)) {
1385
+ return;
1386
+ }
1387
+ const eligible = request.candidates
1388
+ .filter(
1389
+ (candidate) =>
1390
+ candidate.tokenIds.length > 0 &&
1391
+ candidate.tokenIds.length <= CANONICAL_SCORING.EXACT_MAX_TARGET_TOKENS,
1392
+ )
1393
+ .map((candidate) => ({
1394
+ ...candidate,
1395
+ evidence: getProgressiveEvidence(contextKey, candidate.surface, candidate.tokenIds),
1396
+ }))
1397
+ .filter(
1398
+ (candidate): candidate is typeof candidate & { evidence: ProgressiveSurfaceEvidence } =>
1399
+ candidate.evidence !== null,
1400
+ );
1401
+
1402
+ // Score candidates the way arbitration will: a posterior over sequence
1403
+ // log-likelihoods, blended with the corpus prior at the shipped weights.
1404
+ //
1405
+ // Sharing the rule is the point. Ranking on `exp(per-token mean)` instead
1406
+ // answers a different question — it favours short surfaces, because
1407
+ // dividing by fewer tokens flatters them — so this scheduler used to stop
1408
+ // reading on a margin the decision layer did not recognise, and then
1409
+ // abstain for want of the very tokens it declined to read. Nothing about
1410
+ // that was visible from either side.
1411
+ //
1412
+ // The normaliser spans every eligible candidate rather than only the
1413
+ // scheduled ones. That can only make each posterior smaller and each margin
1414
+ // narrower, so the error is always towards reading another token instead of
1415
+ // stopping early — the safe direction ahead of a precision-first gate.
1416
+ const normalizer = logSumExp(
1417
+ eligible.map((candidate) => candidate.evidence.totalLogProbability),
1418
+ );
1419
+ const confidenceScore = (candidate: (typeof eligible)[number]): number =>
1420
+ STAGE1_WEIGHT * (candidate.rankHint ?? 0) +
1421
+ STAGE2_WEIGHT *
1422
+ (Number.isFinite(normalizer)
1423
+ ? Math.exp(candidate.evidence.totalLogProbability - normalizer)
1424
+ : 0);
1425
+
1426
+ const candidates = eligible
1427
+ .sort((a, b) => confidenceScore(b) - confidenceScore(a))
1428
+ .slice(0, CANONICAL_SCORING.PROGRESSIVE_CANDIDATES_MAX);
1429
+ const unresolved = candidates.filter(
1430
+ (candidate) => candidate.evidence.scoredTokenCount < candidate.tokenIds.length,
1431
+ );
1432
+ if (unresolved.length === 0) {
1433
+ logProgressiveState(
1434
+ 'completed',
1435
+ `ctx=${contextKey.slice(0, 48)} · exact=${candidates.length}/${candidates.length}`,
1436
+ );
1437
+ return;
1438
+ }
1439
+ const bestExactScore = Math.max(
1440
+ ...candidates
1441
+ .filter((candidate) => candidate.evidence.scoredTokenCount === candidate.tokenIds.length)
1442
+ .map(confidenceScore),
1443
+ -Infinity,
1444
+ );
1445
+ const bestUnresolvedScore = Math.max(...unresolved.map(confidenceScore), -Infinity);
1446
+ if (bestExactScore - bestUnresolvedScore >= MIN_WINNER_MARGIN) {
1447
+ logProgressiveState(
1448
+ 'completed',
1449
+ `safe bound · ctx=${contextKey.slice(0, 40)} · margin=${(bestExactScore - bestUnresolvedScore).toFixed(2)} · unresolved=${unresolved.length}`,
1450
+ );
1451
+ return;
1452
+ }
1453
+
1454
+ const groups = surfaceTokenTrie.groupByScoredPrefix(
1455
+ unresolved.map((candidate) => ({
1456
+ surface: candidate.surface,
1457
+ tokenIds: candidate.tokenIds,
1458
+ scoredTokenCount: candidate.evidence.scoredTokenCount,
1459
+ })),
1460
+ );
1461
+ const bySurface = new Map(candidates.map((candidate) => [candidate.surface, candidate]));
1462
+ const rankedGroups = groups
1463
+ .map((group) => {
1464
+ const members = group.surfaces
1465
+ .map((candidateSurface) => bySurface.get(candidateSurface))
1466
+ .filter((candidate): candidate is NonNullable<typeof candidate> => Boolean(candidate));
1467
+ const optimisticScore = Math.max(...members.map(confidenceScore));
1468
+ return { ...group, optimisticScore };
1469
+ })
1470
+ .sort((a, b) => b.optimisticScore - a.optimisticScore);
1471
+ const bestGroup = rankedGroups[0];
1472
+ if (!bestGroup) {
1473
+ return;
1474
+ }
1475
+ const warmGroup =
1476
+ kvContextKey === contextKey
1477
+ ? rankedGroups.find((group) => isPrefixOf(kvPath, group.tokenPrefix))
1478
+ : undefined;
1479
+ const nextGroup =
1480
+ warmGroup &&
1481
+ warmGroup.optimisticScore >= bestGroup.optimisticScore - PROGRESSIVE_WARM_PATH_MARGIN
1482
+ ? warmGroup
1483
+ : bestGroup;
1484
+
1485
+ const contextPrefixes = progressivePrefixesByContext.get(contextKey) ?? new Set<string>();
1486
+ progressivePrefixesByContext.set(contextKey, contextPrefixes);
1487
+ const key = prefixCacheKey(contextKey, nextGroup.tokenPrefix);
1488
+ if (getLru(prefixExpansionCache, key)) {
1489
+ logProgressiveState(
1490
+ 'cached',
1491
+ `ctx=${contextKey.slice(0, 40)} · prefix=[${nextGroup.tokenPrefix.join(',')}]`,
1492
+ );
1493
+ planProgressiveExpansion(contextKey);
1494
+ return;
1495
+ }
1496
+ if (pendingPrefixExpansions.has(key) || inFlightPrefixExpansions.has(key)) {
1497
+ logProgressiveState(
1498
+ 'deduplicated',
1499
+ `ctx=${contextKey.slice(0, 40)} · prefix=[${nextGroup.tokenPrefix.join(',')}]`,
1500
+ );
1501
+ return;
1502
+ }
1503
+ if (contextPrefixes.size >= CANONICAL_SCORING.PROGRESSIVE_EXPANSIONS_PER_CONTEXT_MAX) {
1504
+ logProgressiveState(
1505
+ 'completed',
1506
+ `ctx=${contextKey.slice(0, 48)} · expansion cap=${contextPrefixes.size} · unresolved=${unresolved.length}`,
1507
+ );
1508
+ return;
1509
+ }
1510
+
1511
+ contextPrefixes.add(key);
1512
+ pendingPrefixExpansions.set(key, {
1513
+ contextKey,
1514
+ familyKey: request.familyKey,
1515
+ prompt: request.prompt,
1516
+ tokenPrefix: nextGroup.tokenPrefix,
1517
+ surfaces: nextGroup.surfaces,
1518
+ });
1519
+ logProgressiveState(
1520
+ 'queued',
1521
+ `ctx=${contextKey.slice(0, 40)} · prefix=[${nextGroup.tokenPrefix.join(',')}] · surfaces=${nextGroup.surfaces.length}`,
1522
+ );
1523
+ drainCausalQueue();
1524
+ };
1525
+
1526
+ /**
1527
+ * Prefill `prompt` and cache the boundary distribution that follows it.
1528
+ *
1529
+ * A text completion resets the KV cache before prefilling, so asking for a
1530
+ * single token leaves the cache holding exactly the prompt. That is the
1531
+ * anchor every later decode step extends.
1532
+ */
1533
+ const prefillBoundary = async (
1534
+ activeEngine: MLCEngine,
1535
+ request: { contextKey: string; familyKey: string; prompt: string },
1536
+ ): Promise<{ latencyMs: number; rawLogits: Float32Array } | null> => {
1537
+ kvContextKey = null;
1538
+ kvPath = [];
1539
+ causalRequestsThisFamily++;
1540
+ causalGeneratedTokensThisFamily++;
1541
+ causalLogitProcessor.startCapture();
1542
+ const { latencyMs } = await createMeasuredCausalCompletion(activeEngine, {
1543
+ contextKey: request.contextKey,
1544
+ familyKey: request.familyKey,
1545
+ kind: 'boundary',
1546
+ prompt: request.prompt,
1547
+ requestedCompletionTokens: 1,
1548
+ });
1549
+ const rawLogits = captureLogits();
1550
+ if (!rawLogits) {
1551
+ return null;
1552
+ }
1553
+ setLru(
1554
+ boundaryCache,
1555
+ request.contextKey,
1556
+ { contextKey: request.contextKey, prompt: request.prompt, rawLogits },
1557
+ CANONICAL_SCORING.BOUNDARY_CACHE_MAX,
1558
+ );
1559
+ kvContextKey = request.contextKey;
1560
+ kvPath = [];
1561
+ return { latencyMs, rawLogits };
1562
+ };
1563
+
1564
+ /**
1565
+ * Append one token to the KV cache and return the distribution that follows.
1566
+ *
1567
+ * This deliberately bypasses the completion API: a completion would reset the
1568
+ * cache and re-prefill the whole prompt, whereas this forwards a single token
1569
+ * on top of the work already done.
1570
+ */
1571
+ const decodeOneToken = async (
1572
+ activeEngine: MLCEngine,
1573
+ token: number,
1574
+ ): Promise<Float32Array | null> => {
1575
+ causalLogitProcessor.startCapture();
1576
+ try {
1577
+ // The engine also holds the embedder, so it refuses to forward unless
1578
+ // the caller says which model to forward through.
1579
+ await activeEngine.forwardTokensAndSample([token], false, modelId);
1580
+ } catch (error) {
1581
+ kvContextKey = null;
1582
+ kvPath = [];
1583
+ throw error;
1584
+ }
1585
+ causalRequestsThisFamily++;
1586
+ causalGeneratedTokensThisFamily++;
1587
+ return captureLogits();
1588
+ };
1589
+
1590
+ const runBoundaryPrime = async (request: BoundaryPrimeRequest): Promise<void> => {
1591
+ if (!engine || destroyed) {
1592
+ return;
1593
+ }
1594
+ const primed = await prefillBoundary(engine, request);
1595
+ if (!primed) {
1596
+ return;
1597
+ }
1598
+ const { latencyMs, rawLogits } = primed;
1599
+ planProgressiveExpansion(request.contextKey);
1600
+ const progressiveRequest = progressiveRequests.get(request.contextKey);
1601
+ const exactSurfaceCount =
1602
+ progressiveRequest?.candidates.filter((candidate) => {
1603
+ const evidence = getProgressiveEvidence(
1604
+ request.contextKey,
1605
+ candidate.surface,
1606
+ candidate.tokenIds,
1607
+ );
1608
+ return evidence !== null && evidence.scoredTokenCount === evidence.totalTokenCount;
1609
+ }).length ?? 0;
1610
+ logExactEvidenceReadiness(
1611
+ request.contextKey,
1612
+ progressiveRequest?.familyKey ?? request.familyKey,
1613
+ exactSurfaceCount,
1614
+ 'boundary',
1615
+ );
1616
+
1617
+ if (isAutocompleteDebugEnabled()) {
1618
+ // eslint-disable-next-line no-console
1619
+ console.log(
1620
+ `%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}`,
1621
+ 'color: #9c27b0; font-weight: bold;',
1622
+ 'color: #2196f3;',
1623
+ );
1624
+ }
1625
+ const callbackFamilyKey =
1626
+ progressiveRequests.get(request.contextKey)?.familyKey ?? request.familyKey;
1627
+ if (callbackFamilyKey === latestCausalFamilyKey && !destroyed) {
1628
+ onBoundaryLmUpdate?.({
1629
+ contextKey: request.contextKey,
1630
+ familyKey: callbackFamilyKey,
1631
+ latencyMs,
1632
+ });
1633
+ }
1634
+ };
1635
+
1636
+ const runPrefixExpansion = async (request: PrefixExpansionRequest): Promise<void> => {
1637
+ if (!engine || destroyed) {
1638
+ return;
1639
+ }
1640
+ const activeEngine = engine;
1641
+ const startedAt = performance.now();
1642
+ logProgressiveState(
1643
+ 'started',
1644
+ `ctx=${request.contextKey.slice(0, 40)} · prefix=[${request.tokenPrefix.join(',')}] · surfaces=${request.surfaces.length}`,
1645
+ );
1646
+
1647
+ // Resume from whatever the KV cache already holds for this context, and
1648
+ // fall back to a prompt prefill only when the requested prefix branches
1649
+ // away from it.
1650
+ let logitsAtPath: Float32Array | null = null;
1651
+ let totalAtPath = 0;
1652
+ const pathIsLive = extendsLiveKvPath(request);
1653
+ if (pathIsLive) {
1654
+ if (kvPath.length === 0) {
1655
+ logitsAtPath = getLru(boundaryCache, request.contextKey)?.rawLogits ?? null;
1656
+ } else {
1657
+ const resume = getLru(prefixExpansionCache, prefixCacheKey(request.contextKey, kvPath));
1658
+ logitsAtPath = resume?.rawNextTokenLogits ?? null;
1659
+ totalAtPath = resume?.totalLogProbability ?? 0;
1660
+ }
1661
+ }
1662
+
1663
+ if (!logitsAtPath) {
1664
+ const primed = await prefillBoundary(activeEngine, request);
1665
+ if (!primed) {
1666
+ logProgressiveState(
1667
+ 'failed',
1668
+ `ctx=${request.contextKey.slice(0, 40)} · prefix=[${request.tokenPrefix.join(',')}] · missing boundary logits`,
1669
+ );
1670
+ return;
1671
+ }
1672
+ logitsAtPath = primed.rawLogits;
1673
+ totalAtPath = 0;
1674
+ }
1675
+
1676
+ // Walking the path caches every depth along it, not just the requested
1677
+ // one, so a later expansion that shares this prefix costs nothing.
1678
+ const contextPrefixes =
1679
+ progressivePrefixesByContext.get(request.contextKey) ?? new Set<string>();
1680
+ progressivePrefixesByContext.set(request.contextKey, contextPrefixes);
1681
+ for (let index = kvPath.length; index < request.tokenPrefix.length; index++) {
1682
+ const token = request.tokenPrefix[index];
1683
+ if (token === undefined) {
1684
+ break;
1685
+ }
1686
+ const stepLogProbability = logSoftmaxAt(logitsAtPath, token);
1687
+ if (!Number.isFinite(stepLogProbability)) {
1688
+ logProgressiveState(
1689
+ 'failed',
1690
+ `ctx=${request.contextKey.slice(0, 40)} · prefix=[${request.tokenPrefix.join(',')}] · token ${token} unscoreable`,
1691
+ );
1692
+ return;
1693
+ }
1694
+ // eslint-disable-next-line no-await-in-loop
1695
+ const nextLogits = await decodeOneToken(activeEngine, token);
1696
+ if (!nextLogits || destroyed) {
1697
+ kvContextKey = null;
1698
+ kvPath = [];
1699
+ logProgressiveState(
1700
+ 'failed',
1701
+ `ctx=${request.contextKey.slice(0, 40)} · prefix=[${request.tokenPrefix.join(',')}] · missing logits`,
1702
+ );
1703
+ return;
1704
+ }
1705
+ kvContextKey = request.contextKey;
1706
+ kvPath = [...kvPath, token];
1707
+ totalAtPath += stepLogProbability;
1708
+ logitsAtPath = nextLogits;
1709
+ const stepKey = prefixCacheKey(request.contextKey, kvPath);
1710
+ const expansion: TokenPrefixExpansion = {
1711
+ contextKey: request.contextKey,
1712
+ tokenPrefix: [...kvPath],
1713
+ totalLogProbability: totalAtPath,
1714
+ rawNextTokenLogits: nextLogits,
1715
+ };
1716
+ setLru(prefixExpansionCache, stepKey, expansion, CANONICAL_SCORING.PREFIX_CACHE_MAX);
1717
+ contextPrefixes.add(stepKey);
1718
+ }
1719
+ const latencyMs = performance.now() - startedAt;
1720
+
1721
+ const progressiveRequest = progressiveRequests.get(request.contextKey);
1722
+ let newlyExact = 0;
1723
+ let exactSurfaceCount = 0;
1724
+ if (progressiveRequest) {
1725
+ for (const candidate of progressiveRequest.candidates) {
1726
+ const exactKey = surfaceCacheKey(request.contextKey, candidate.surface, candidate.tokenIds);
1727
+ const wasExact = surfaceScoreCache.has(exactKey);
1728
+ const evidence = getProgressiveEvidence(
1729
+ request.contextKey,
1730
+ candidate.surface,
1731
+ candidate.tokenIds,
1732
+ );
1733
+ if (
1734
+ !wasExact &&
1735
+ evidence !== null &&
1736
+ evidence.scoredTokenCount === evidence.totalTokenCount &&
1737
+ surfaceScoreCache.has(exactKey)
1738
+ ) {
1739
+ newlyExact++;
1740
+ }
1741
+ if (evidence !== null && evidence.scoredTokenCount === evidence.totalTokenCount) {
1742
+ exactSurfaceCount++;
1743
+ }
1744
+ }
1745
+ }
1746
+ logExactEvidenceReadiness(
1747
+ request.contextKey,
1748
+ progressiveRequest?.familyKey ?? request.familyKey,
1749
+ exactSurfaceCount,
1750
+ 'prefix',
1751
+ );
1752
+ logProgressiveState(
1753
+ 'expanded',
1754
+ `ctx=${request.contextKey.slice(0, 40)} · prefix=[${request.tokenPrefix.join(',')}] · exact+${newlyExact} · ${latencyMs.toFixed(0)}ms`,
1755
+ );
1756
+ planProgressiveExpansion(request.contextKey);
1757
+
1758
+ const callbackFamilyKey =
1759
+ progressiveRequests.get(request.contextKey)?.familyKey ?? request.familyKey;
1760
+ if (callbackFamilyKey === latestCausalFamilyKey && !destroyed) {
1761
+ onSurfaceScoreUpdate?.({
1762
+ contextKey: request.contextKey,
1763
+ count: newlyExact,
1764
+ familyKey: callbackFamilyKey,
1765
+ latencyMs,
1766
+ });
1767
+ } else {
1768
+ logProgressiveState(
1769
+ 'stale',
1770
+ `cached only · ctx=${request.contextKey.slice(0, 40)} · family=${request.familyKey.slice(0, 32)}`,
1771
+ );
1772
+ }
1773
+ };
1774
+
1775
+ /**
1776
+ * Whether any live decision would still take this request's result.
1777
+ *
1778
+ * Ranking on the family a request was created under, as this used to, misses
1779
+ * in both directions. Work queued a keystroke ago for a context still under
1780
+ * the cursor sorts as stale even though the callbacks resolve delivery
1781
+ * through `progressiveRequests` and would hand it over. Work for a context
1782
+ * nothing asks about any more sorts as runnable even though the same
1783
+ * resolution drops it on arrival — and that one is expensive, because an
1784
+ * abandoned context is never the one the KV cache holds, so running it pays
1785
+ * a prompt prefill and leaves the live context evicted, charging the next
1786
+ * live request a second prefill. Two prefills for a discarded result.
1787
+ *
1788
+ * Either signal alone is enough to keep the work, which matters because a
1789
+ * boundary prime is queued before its context's surfaces are requested: at
1790
+ * that moment the stored family is still the previous decision's, and only
1791
+ * the request's own family says it is current.
1792
+ *
1793
+ * Agreeing with the layer being fed is the correction the expansion planner
1794
+ * already carries for arbitration's scoring rule — a scheduler deciding on
1795
+ * its own rule stops on margins the consumer does not recognise.
1796
+ */
1797
+ const stillWanted = (request: { contextKey: string; familyKey: string }): boolean =>
1798
+ request.familyKey === latestCausalFamilyKey ||
1799
+ progressiveRequests.get(request.contextKey)?.familyKey === latestCausalFamilyKey;
1800
+
1801
+ const drainCausalQueue = (): void => {
1802
+ if (causalInFlight || destroyed) {
1803
+ return;
1804
+ }
1805
+ // Drop abandoned work rather than leaving it to be picked up whenever the
1806
+ // live family happens to have nothing queued. Its cached side effects are
1807
+ // speculative — they only pay off if the user deletes back into exactly
1808
+ // this context and prefix — and the prefill pair above is certain.
1809
+ for (const [key, request] of pendingPrefixExpansions) {
1810
+ if (!stillWanted(request)) {
1811
+ pendingPrefixExpansions.delete(key);
1812
+ }
1813
+ }
1814
+ for (const [contextKey, request] of pendingBoundaryPrimes) {
1815
+ if (!stillWanted(request)) {
1816
+ pendingBoundaryPrimes.delete(contextKey);
1817
+ }
1818
+ }
1819
+ const sortedPrimes = Array.from(pendingBoundaryPrimes.values()).sort(
1820
+ (a, b) => a.priority - b.priority,
1821
+ );
1822
+ // Everything left is wanted, so relevance no longer needs a sort key.
1823
+ // Take whichever request continues the live sequence: that expansion costs
1824
+ // one decode step where any other costs a full prompt prefill, and nothing
1825
+ // is skipped, only reordered.
1826
+ //
1827
+ // Staying inside the live context when nothing continues it was tried and
1828
+ // reverted. It moved re-primes from `reprime-context` to `reprime-branch`
1829
+ // and left the total flat, because both pay for a prompt prefill: a
1830
+ // context whose remaining prefixes diverge at the first token re-primes as
1831
+ // a branch instead of as a context. Ordering cannot recover that; only
1832
+ // queueing fewer divergent branches can, which is what the prune above
1833
+ // does.
1834
+ const sortedPrefixes = Array.from(pendingPrefixExpansions.values()).sort(
1835
+ (a, b) => Number(extendsLiveKvPath(b)) - Number(extendsLiveKvPath(a)),
1836
+ );
1837
+ const nextPrefix = sortedPrefixes[0];
1838
+ const nextPrime = nextPrefix ? undefined : sortedPrimes[0];
1839
+ if (!nextPrefix && !nextPrime) {
1840
+ return;
1841
+ }
1842
+
1843
+ causalInFlight = true;
1844
+ let prefixKey: string | null = null;
1845
+ if (nextPrefix) {
1846
+ prefixKey = prefixCacheKey(nextPrefix.contextKey, nextPrefix.tokenPrefix);
1847
+ pendingPrefixExpansions.delete(prefixKey);
1848
+ inFlightPrefixExpansions.add(prefixKey);
1849
+ } else if (nextPrime) {
1850
+ pendingBoundaryPrimes.delete(nextPrime.contextKey);
1851
+ inFlightBoundaryContextKey = nextPrime.contextKey;
1852
+ }
1853
+
1854
+ void ensureEngineInitialized()
1855
+ .then(() =>
1856
+ nextPrefix
1857
+ ? runPrefixExpansion(nextPrefix)
1858
+ : nextPrime
1859
+ ? runBoundaryPrime(nextPrime)
1860
+ : undefined,
1861
+ )
1862
+ .catch((error: unknown) => {
1863
+ if (nextPrefix) {
1864
+ logProgressiveState(
1865
+ 'failed',
1866
+ `ctx=${nextPrefix.contextKey.slice(0, 40)} · prefix=[${nextPrefix.tokenPrefix.join(',')}] · ${error instanceof Error ? error.message : String(error)}`,
1867
+ );
1868
+ }
1869
+ if (isAutocompleteDebugEnabled()) {
1870
+ // eslint-disable-next-line no-console
1871
+ console.log(
1872
+ `%c[CTC:model] %c❌ canonical causal request failed: ${error instanceof Error ? error.message : String(error)}`,
1873
+ 'color: #9c27b0; font-weight: bold;',
1874
+ 'color: #f44336;',
1875
+ );
1876
+ }
1877
+ })
1878
+ .finally(() => {
1879
+ if (prefixKey) {
1880
+ inFlightPrefixExpansions.delete(prefixKey);
1881
+ }
1882
+ if (nextPrime) {
1883
+ inFlightBoundaryContextKey = null;
1884
+ }
1885
+ causalInFlight = false;
1886
+ drainCausalQueue();
1887
+ });
1888
+ };
1889
+
1122
1890
  // ── Context update (debounced) ─────────────────────────────────────────
1123
1891
 
1124
1892
  const startInference = (text: string, requestId: number): void => {
@@ -1176,7 +1944,7 @@ export const createLocalSlowLaneClient = (
1176
1944
  if (isAutocompleteDebugEnabled()) {
1177
1945
  // eslint-disable-next-line no-console
1178
1946
  console.groupCollapsed(
1179
- `%c[LocalSlowLane] %c📤 Context update (request #${requestId}) | ${text.length} chars`,
1947
+ `%c[CTC:model] %c📤 Context update (request #${requestId}) | ${text.length} chars`,
1180
1948
  'color: #9c27b0; font-weight: bold;',
1181
1949
  'color: inherit;',
1182
1950
  );
@@ -1216,17 +1984,147 @@ export const createLocalSlowLaneClient = (
1216
1984
  }, debounceMs);
1217
1985
  };
1218
1986
 
1987
+ const setLatestCausalFamily = (familyKey: string): void => {
1988
+ if (familyKey === latestCausalFamilyKey) {
1989
+ return;
1990
+ }
1991
+ latestCausalFamilyKey = familyKey;
1992
+ causalRequestsThisFamily = 0;
1993
+ causalGeneratedTokensThisFamily = 0;
1994
+ tierAPrimeCacheHitsThisFamily = 0;
1995
+ tierAPrimeCacheMissesThisFamily = 0;
1996
+ };
1997
+
1998
+ const primeBoundaryLm = (input: BoundaryPrimeRequest): void => {
1999
+ if (destroyed) {
2000
+ return;
2001
+ }
2002
+ setLatestCausalFamily(input.familyKey);
2003
+ if (getLru(boundaryCache, input.contextKey)) {
2004
+ tierAPrimeCacheHitsThisFamily++;
2005
+ if (isAutocompleteDebugVerbose()) {
2006
+ // eslint-disable-next-line no-console
2007
+ console.log(
2008
+ `%c[CTC:model] %c⚡ Tier A cache hit · ctx=${input.contextKey.slice(0, 48)}`,
2009
+ 'color: #9c27b0; font-weight: bold;',
2010
+ 'color: #2196f3;',
2011
+ );
2012
+ }
2013
+ return;
2014
+ }
2015
+ if (
2016
+ pendingBoundaryPrimes.has(input.contextKey) ||
2017
+ inFlightBoundaryContextKey === input.contextKey
2018
+ ) {
2019
+ logProgressiveState('deduplicated', `boundary · ctx=${input.contextKey.slice(0, 48)}`);
2020
+ return;
2021
+ }
2022
+ if (pendingBoundaryPrimes.size >= CANONICAL_SCORING.TIER_A_PRIMES_MAX) {
2023
+ const stalePending = Array.from(pendingBoundaryPrimes.entries()).find(
2024
+ ([, request]) => request.familyKey !== latestCausalFamilyKey,
2025
+ );
2026
+ if (stalePending) {
2027
+ pendingBoundaryPrimes.delete(stalePending[0]);
2028
+ logProgressiveState(
2029
+ 'stale',
2030
+ `dropped unstarted boundary · ctx=${stalePending[0].slice(0, 48)}`,
2031
+ );
2032
+ }
2033
+ }
2034
+ if (pendingBoundaryPrimes.size < CANONICAL_SCORING.TIER_A_PRIMES_MAX) {
2035
+ tierAPrimeCacheMissesThisFamily++;
2036
+ pendingBoundaryPrimes.set(input.contextKey, input);
2037
+ }
2038
+ drainCausalQueue();
2039
+ };
2040
+
2041
+ const requestProgressiveSurfaceScores = (input: SurfaceScoreRequest): void => {
2042
+ if (destroyed) {
2043
+ return;
2044
+ }
2045
+ setLatestCausalFamily(input.familyKey);
2046
+ const candidates = input.candidates
2047
+ .filter(
2048
+ (candidate) =>
2049
+ candidate.tokenIds.length > 0 &&
2050
+ candidate.tokenIds.length <= CANONICAL_SCORING.EXACT_MAX_TARGET_TOKENS,
2051
+ )
2052
+ .slice(0, CANONICAL_SCORING.PROGRESSIVE_INPUT_MAX);
2053
+ if (candidates.length === 0) {
2054
+ return;
2055
+ }
2056
+ const previous = progressiveRequests.get(input.contextKey);
2057
+ const candidateSignature = candidates
2058
+ .map((candidate) => `${candidate.surface}:${candidate.tokenIds.join(',')}`)
2059
+ .join('\u0001');
2060
+ const previousSignature = previous?.candidates
2061
+ .map((candidate) => `${candidate.surface}:${candidate.tokenIds.join(',')}`)
2062
+ .join('\u0001');
2063
+ setLru(
2064
+ progressiveRequests,
2065
+ input.contextKey,
2066
+ { ...input, candidates },
2067
+ CANONICAL_SCORING.BOUNDARY_CACHE_MAX,
2068
+ );
2069
+ for (const cachedContextKey of progressivePrefixesByContext.keys()) {
2070
+ if (!progressiveRequests.has(cachedContextKey)) {
2071
+ progressivePrefixesByContext.delete(cachedContextKey);
2072
+ }
2073
+ }
2074
+ const cachedProgressCount = candidates.filter((candidate) => {
2075
+ const evidence = getProgressiveEvidence(
2076
+ input.contextKey,
2077
+ candidate.surface,
2078
+ candidate.tokenIds,
2079
+ );
2080
+ return (
2081
+ evidence !== null &&
2082
+ (evidence.scoredTokenCount > 1 || evidence.scoredTokenCount === evidence.totalTokenCount)
2083
+ );
2084
+ }).length;
2085
+ if (cachedProgressCount > 0) {
2086
+ logProgressiveState(
2087
+ 'cached',
2088
+ `ctx=${input.contextKey.slice(0, 40)} · surfaces=${cachedProgressCount}/${candidates.length}`,
2089
+ );
2090
+ }
2091
+ if (candidateSignature === previousSignature) {
2092
+ logProgressiveState(
2093
+ 'deduplicated',
2094
+ `candidate set · ctx=${input.contextKey.slice(0, 40)} · surfaces=${candidates.length}`,
2095
+ );
2096
+ } else {
2097
+ logProgressiveState(
2098
+ 'queued',
2099
+ `candidate set · ctx=${input.contextKey.slice(0, 40)} · surfaces=${candidates.length}`,
2100
+ );
2101
+ }
2102
+ planProgressiveExpansion(input.contextKey);
2103
+ };
2104
+
1219
2105
  // ── Public API (same shape as createSlowLaneClient) ────────────────────
1220
2106
  return {
1221
2107
  updateContext: updateContextDebounced,
2108
+ getBoundaryLmState: (contextKey) => getLru(boundaryCache, contextKey),
2109
+ getCanonicalSurfaceTokenIds: (candidateSurface) =>
2110
+ surfaceTokenTrie.getTokenIds(candidateSurface),
2111
+ getCanonicalSurfaceCount: () => surfaceTokenIds.size,
2112
+ getContextInput: () => storedContextInput,
1222
2113
  getContextVector: () => storedContextVector,
1223
2114
  getLmLogits: () => storedLmLogits,
2115
+ getProgressiveSurfaceEvidence: (contextKey, candidateSurface) =>
2116
+ getProgressiveEvidence(contextKey, candidateSurface),
2117
+ getSurfaceScore: (contextKey, candidateSurface) =>
2118
+ getLru(surfaceScoreCache, surfaceCacheKey(contextKey, candidateSurface)),
1224
2119
  setContextVector: (vector) => {
2120
+ storedContextInput = null;
1225
2121
  storedContextVector = vector;
1226
2122
  },
1227
2123
  setLmLogits: (logits) => {
1228
2124
  storedLmLogits = logits;
1229
2125
  },
2126
+ primeBoundaryLm,
2127
+ requestProgressiveSurfaceScores,
1230
2128
  isWordBoundary,
1231
2129
  isReady: () => ready,
1232
2130
  destroy: () => {
@@ -1245,8 +2143,26 @@ export const createLocalSlowLaneClient = (
1245
2143
  activeInferenceText = null;
1246
2144
  activeInferenceRequestId = -1;
1247
2145
  pendingInference = null;
2146
+ storedContextInput = null;
1248
2147
  storedContextVector = null;
1249
2148
  storedLmLogits = null;
2149
+ causalInFlight = false;
2150
+ inFlightBoundaryContextKey = null;
2151
+ kvContextKey = null;
2152
+ kvPath = [];
2153
+ latestCausalFamilyKey = '';
2154
+ pendingBoundaryPrimes.clear();
2155
+ pendingPrefixExpansions.clear();
2156
+ inFlightPrefixExpansions.clear();
2157
+ progressiveRequests.clear();
2158
+ progressivePrefixesByContext.clear();
2159
+ boundaryCache.clear();
2160
+ causalInferenceByContext.clear();
2161
+ causalInferenceByFamily.clear();
2162
+ exactEvidenceCountByContext.clear();
2163
+ prefixExpansionCache.clear();
2164
+ surfaceScoreCache.clear();
2165
+ causalLogitProcessor.resetState();
1250
2166
  },
1251
2167
  };
1252
2168
  };