@atlaskit/editor-plugin-autocomplete 8.0.0 → 9.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/CHANGELOG.md +108 -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 +498 -160
  6. package/dist/cjs/pm-plugins/canonical-lm-scoring.js +371 -0
  7. package/dist/cjs/pm-plugins/debug-mode.js +105 -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 +2099 -309
  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 +489 -152
  17. package/dist/es2019/pm-plugins/canonical-lm-scoring.js +270 -0
  18. package/dist/es2019/pm-plugins/debug-mode.js +102 -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 +1692 -200
  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 +501 -163
  28. package/dist/esm/pm-plugins/canonical-lm-scoring.js +364 -0
  29. package/dist/esm/pm-plugins/debug-mode.js +104 -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 +2101 -313
  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 +55 -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 +156 -3
  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 +584 -154
  51. package/src/pm-plugins/canonical-lm-scoring.ts +412 -0
  52. package/src/pm-plugins/debug-mode.ts +134 -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 +2119 -254
  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
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Two prediction modes:
5
5
  * 1. Word boundary → bigram-based next-word suggestion (grammar-filtered)
6
- * 2. Mid-word (≥3 chars) → trie prefix search → scoring pipeline → top result
6
+ * 2. Mid-word (≥1 char) → grouped prefetch; ≥3 chars + exact evidence → ghost result
7
7
  *
8
8
  * Scoring is delegated to scoring-pipeline.ts which handles:
9
9
  * Stage 1 (semantic + frequency), grammar filter, Stage 2 (optional LM re-ranking).
@@ -12,19 +12,57 @@
12
12
  * Falls back to cold mode (freq-only) when vectors not yet loaded.
13
13
  *
14
14
  * Session personalization (L1): words the user types are incrementally boosted
15
- * via incrementSessionFreq(), called on word boundaries from the plugin.
15
+ * via incrementSessionFreq(), called on word boundaries from the plugin, and
16
+ * words in ingested context text via ingestDocumentPage(). What the session has
17
+ * boosted is visible at any time from the console: `__atlCtcDebug__.session()`,
18
+ * or `__atlCtcDebug__.session('poll')` for one family — see inspectSessionBoosts.
16
19
  */
17
20
 
18
21
  import { EXPERIENCE_NAME, failExp, startExp, succeedExp } from '../analytics/ufo';
19
22
 
20
- // The vocabulary, L3 and word-index payloads are fetched from the CDN in
21
- // loadDefaultVocabulary / loadVectorsAsync so their (large) contents never enter
22
- // the editor's bundle and only load when autocomplete is initialised.
23
- import { ARTIFACT_NAME, fetchArtifactJson, getArtifactUrl } from './artifacts-manifest';
24
- import { isAutocompleteDebugEnabled } from './debug-mode';
25
- import { rankCandidates, STAGE1_WEIGHT, STAGE2_WEIGHT, MIN_STAGE1_SCORE } from './scoring-pipeline';
26
- import type { ScoringCandidate } from './scoring-pipeline';
27
- import { getStoredContextVector, getStoredLmLogits } from './slow-lane-client';
23
+ import { fetchAutocompleteArtifactBinary, fetchAutocompleteArtifactJson } from './artifact-loader';
24
+ import { ARTIFACT_NAME, type ArtifactName } from './artifacts-manifest';
25
+ import {
26
+ createCanonicalContextPositionCache,
27
+ deriveCanonicalCandidateContext,
28
+ deriveWhitespaceBoundaryContext,
29
+ logSoftmaxAt,
30
+ logSumExp,
31
+ selectBoundaryPrimeRequests,
32
+ type CanonicalCandidateContext,
33
+ type CanonicalLmEvidence,
34
+ } from './canonical-lm-scoring';
35
+ import {
36
+ CTC_STYLES,
37
+ ctcSection,
38
+ ctcTag,
39
+ isAutocompleteDebugEnabled,
40
+ isAutocompleteDebugVerbose,
41
+ registerCtcSessionInspector,
42
+ } from './debug-mode';
43
+ import {
44
+ loadGrammarDataAsync,
45
+ rankCandidates,
46
+ STAGE1_WEIGHT,
47
+ STAGE2_WEIGHT,
48
+ MIN_STAGE1_SCORE,
49
+ MIN_WINNER_MARGIN,
50
+ } from './scoring-pipeline';
51
+ import type { ScoringCandidate, ScoredCandidate, TermType } from './scoring-pipeline';
52
+ import {
53
+ getBoundaryLmState,
54
+ getCanonicalSurfaceCount,
55
+ getCanonicalSurfaceTokenIds,
56
+ getDefaultSlowLaneClientStatus,
57
+ getProgressiveSurfaceEvidence,
58
+ getStoredContextInput,
59
+ getStoredContextVector,
60
+ getStoredLmLogits,
61
+ getSurfaceScore,
62
+ isCanonicalSurfaceScoringSupported,
63
+ primeBoundaryLm,
64
+ requestProgressiveSurfaceScores,
65
+ } from './slow-lane-client';
28
66
 
29
67
  // ─── Constants ───────────────────────────────────────────────────────────────
30
68
 
@@ -36,20 +74,178 @@ const WHITESPACE_SPLIT_REGEX = /\s+/;
36
74
  const SENTENCE_BOUNDARY_REGEX = /[\n.?!]+/;
37
75
  // eslint-disable-next-line require-unicode-regexp
38
76
  const TRAILING_WHITESPACE_REGEX = /\s$/;
77
+ const TRAILING_SURFACE_TOKEN_REGEX = /[\p{L}\p{N}_'-]+$/u;
78
+ const SURFACE_TOKEN_REGEX = /[\p{L}\p{N}_'-]+/gu;
79
+ const ONLY_WHITESPACE_REGEX = /^\s+$/u;
39
80
 
40
- const MIN_PREFIX_LENGTH = 3;
81
+ const UNIGRAM_PREFETCH_MIN_PREFIX_LENGTH = 1;
82
+ const MULTIWORD_PREFETCH_MIN_PREFIX_LENGTH = 1;
83
+ const DISPLAY_MIN_PREFIX_LENGTH = 3;
41
84
  const MAX_CANDIDATES = 200;
42
85
  const CONTEXT_WORDS = 10;
43
- const MIN_SCORE_THRESHOLD = 0.35;
44
86
  const L3_BASELINE_FREQ = 0.001;
87
+ // Max tokens in a completion term (bigram=2, phrase=3). Mirrors the producer's
88
+ // `phrase_max_words` default so the FE window never exceeds what was mined.
89
+ const PHRASE_MAX_WORDS = 3;
90
+ // Per-window cap on phrase-trie subtree collection (keeps the hot path bounded).
91
+ const MAX_PHRASE_CANDIDATES = 50;
92
+ /**
93
+ * Share of its context shortlist's probability mass the model must put on a
94
+ * surface before that surface may be shown.
95
+ *
96
+ * Candidates competing at the same boundary are normalised into a posterior
97
+ * over that shortlist, so this reads directly as "the model is at least this
98
+ * sure". Measuring distance from the best candidate instead cannot express
99
+ * being unsure: the leader scores full marks whether it won by a nose or a
100
+ * mile, which pushes the entire question of how contested a context is onto
101
+ * `MIN_WINNER_MARGIN` — and there the blended score's only remaining spread is
102
+ * the corpus prior, the weakest ranking signal we have.
103
+ *
104
+ * Each extra token raises the bar. A wrong multi-word ghost costs the reader
105
+ * more to notice and undo than a wrong single word, and it is offered from the
106
+ * same keystroke, so precision has to be bought per token of commitment.
107
+ */
108
+ const MIN_LM_POSTERIOR: Record<TermType, number> = {
109
+ word: 0.5,
110
+ bigram: 0.6,
111
+ phrase: 0.7,
112
+ };
113
+ /**
114
+ * How many scored candidates a context must hold before its leader may show.
115
+ *
116
+ * A posterior is a share of a shortlist, so a shortlist of one hands its only
117
+ * member 1.0 whatever the model thinks of it — `logSumExp` of a single value
118
+ * returns that value. Such a ghost sits in the top bucket without having beaten
119
+ * anything, and it is the gate's blind spot rather than a case the gate ruled
120
+ * on: 1 in 14 of them were accepted against 34% for word ghosts overall, and
121
+ * every phrase shown through this hole was rejected.
122
+ *
123
+ * Counted over pool membership rather than over the normaliser, because those
124
+ * two deliberately differ. Where a pool holds a surface and its own extension
125
+ * the chain rule leaves one term in the denominator, and the resulting 1.0 is
126
+ * earned — whichever the user meant, the shorter form is a correct ghost.
127
+ *
128
+ * Waived where the context never held a second candidate at all. The 1-in-14
129
+ * acceptance above was measured over a population dominated by pools that read
130
+ * as one because rivals had not come back, and it is that absence of evidence
131
+ * the floor is for. A vocabulary offering a single continuation of a long
132
+ * distinctive prefix is the opposite situation, and it is also where a ghost
133
+ * saves the most keystrokes, so refusing it spends the most to learn the least.
134
+ */
135
+ const MIN_SCORED_POOL_SIZE = 2;
136
+ /**
137
+ * Mean per-token log-probability a surface must hold, in nats, independently of
138
+ * anything it was competing against.
139
+ *
140
+ * Every other display test is relative: a posterior is a share of a shortlist,
141
+ * and a winner margin is a distance from a rival. So a surface reaches the
142
+ * screen by being the best of what happened to be in its pool, and nothing ever
143
+ * asks whether the model finds it plausible at all. That gap was tolerable while
144
+ * little was being read, and stopped being tolerable once the scheduler started
145
+ * reading roughly twice as much per decision: shown-per-decision went from 3.2%
146
+ * to 7.4% with no threshold moved, and acceptance of what showed fell from 32%
147
+ * to 24.5%. More reading does not weaken a relative test — it hands it far more
148
+ * pools to be the winner of, and the marginal winner is worse than the average.
149
+ *
150
+ * Set at the point where a uniform distribution over the model's vocabulary
151
+ * sits: 49,152 tokens, so `ln(1/49152)` is about -10.8 nats. Below it the model
152
+ * assigns the surface less mass than a token drawn at random, which is the only
153
+ * statement about a surface that needs no reference to what it competed with.
154
+ *
155
+ * It was first tried at -8, chosen from the bucket where acceptance was measured
156
+ * to fall away, and that reasoning does not survive more data. Acceptance against
157
+ * this quantity turned out to be a smooth slope — about 19% below -8, 26% from -8
158
+ * to -6, then flat near 35% above - with no discontinuity to place a threshold
159
+ * at. A floor on a smooth slope is a coverage dial rather than a test: -8 removed
160
+ * a band converting at 26% and cost 3.7 points of show rate, for an acceptance
161
+ * movement no sample of this size can resolve. Anchoring to uniform instead makes
162
+ * the value an argument about what is indefensible rather than a point picked off
163
+ * a histogram, which is what stops it from being retuned every batch.
164
+ *
165
+ * Applied to the mean over the verified prefix, the same quantity
166
+ * `ghostsByMeanLogProb` buckets, and deliberately not to
167
+ * `meanTokenLogProbabilityUpperBound`. The bound is the right instrument for
168
+ * keeping a candidate in the race, where dropping something that could still
169
+ * clear the bar would be an error, but it is systematically higher than the
170
+ * quantity acceptance is measured against. Judging a surface on the prefix that
171
+ * was actually read is also what every other test here does.
172
+ *
173
+ * One value across all three shapes. Splitting it per shape needs an acceptance
174
+ * curve per shape, and 19 bigrams and 1 phrase across five sessions cannot
175
+ * support one.
176
+ */
177
+ const MIN_MEAN_TOKEN_LOG_PROBABILITY = -10.8;
178
+ // Tab replaces the whole remaining suffix, so an N-character completion saves
179
+ // N-1 keystrokes: at one character it saves nothing at all and at two it saves
180
+ // one, which does not repay noticing the ghost and reaching for Tab. Offering
181
+ // them anyway spends the acceptance-rate denominator on completions nobody
182
+ // wants and teaches the user to stop reading ghosts. `DISPLAY_MIN_PREFIX_LENGTH`
183
+ // is the same guard on the side the user has already typed.
184
+ const MIN_SUGGESTION_LENGTH = 3;
185
+ /**
186
+ * How many leading tokens of a surface must be scored before it may be shown.
187
+ *
188
+ * Scoring a token costs one model round trip, and a round trip costs about the
189
+ * same no matter how much is in it, so the price of a candidate is its token
190
+ * count. Demanding every token therefore caps what can ever reach the screen at
191
+ * roughly two tokens inside the decision budget — which is why no phrase has
192
+ * ever been displayed. Verifying a fixed prefix makes the cost of a candidate
193
+ * independent of its length.
194
+ */
195
+ const REQUIRED_VERIFIED_TOKENS = 2;
196
+ /**
197
+ * Extra winner margin demanded of a surface judged on a verified prefix rather
198
+ * than on every one of its tokens, to buy back the precision given up by
199
+ * leaving the tail unread.
200
+ */
201
+ const PARTIAL_EVIDENCE_MARGIN_PREMIUM = 0.08;
202
+ const ARBITRATION_MODE = 'confidence-v2';
203
+ const DEBUG_TEXT_TAIL_CHARS = 120;
45
204
 
46
205
  // ─── Types ───────────────────────────────────────────────────────────────────
47
206
 
207
+ /**
208
+ * Which constraint stopped an evaluation from putting a ghost on screen.
209
+ *
210
+ * Each one implies different work: `below-posterior-gate` is a threshold to
211
+ * calibrate, `winner-margin` is two candidates the model cannot separate,
212
+ * `cold-competitor` and `unresolved-rival` are scheduling, and `no-candidate`
213
+ * is vocabulary coverage.
214
+ */
215
+ type CtcAbstainReason =
216
+ | 'below-posterior-gate'
217
+ | 'cold-competitor'
218
+ | 'empty-completion'
219
+ | 'implausible-surface'
220
+ | 'lone-candidate'
221
+ | 'missing-artifact'
222
+ | 'no-candidate'
223
+ | 'no-evidence'
224
+ | 'prefetch'
225
+ | 'short-completion'
226
+ | 'unresolved-rival'
227
+ | 'winner-margin';
228
+
48
229
  interface Candidate {
49
230
  node: TrieNode;
50
231
  word: string;
51
232
  }
52
233
 
234
+ /**
235
+ * A candidate paired with the length of the already-typed prefix it completes.
236
+ * For a single word this is the current partial token length; for a phrase it
237
+ * is the length of the matched multi-word window (previous words + partial),
238
+ * so the ghost tail is `word.slice(matchedPrefixLen)`.
239
+ */
240
+ interface MatchedCandidate {
241
+ matchedPrefixLen: number;
242
+ node: TrieNode;
243
+ surfaceStart: number;
244
+ word: string;
245
+ }
246
+
247
+ interface CanonicalMatchedCandidate extends MatchedCandidate, CanonicalCandidateContext {}
248
+
53
249
  export interface WeightedTerm {
54
250
  authorFreq: number;
55
251
  docFreq: number;
@@ -61,6 +257,14 @@ export interface TenantVocabulary {
61
257
  terms: WeightedTerm[];
62
258
  }
63
259
 
260
+ /** Optional per-term metadata carried by bigram/phrase completion terms. */
261
+ interface TermMeta {
262
+ association?: number | null;
263
+ headPos?: string | null;
264
+ posSeq?: string[] | null;
265
+ termType?: TermType;
266
+ }
267
+
64
268
  interface VectorStore {
65
269
  dim: number;
66
270
  float32: Float32Array;
@@ -74,6 +278,12 @@ class TrieNode {
74
278
  docFreq: number = 0;
75
279
  authorFreq: number = 0;
76
280
  sessionFreq: number = 0;
281
+ // Term-type + phrase metadata. Plain words keep `termType='word'` and null
282
+ // metadata; bigram/phrase nodes carry the producer-shipped POS + association.
283
+ termType: TermType = 'word';
284
+ posSeq: string[] | null = null;
285
+ headPos: string | null = null;
286
+ association: number | null = null;
77
287
  }
78
288
 
79
289
  class WeightedWordTrie {
@@ -81,7 +291,13 @@ class WeightedWordTrie {
81
291
  /** Highest tenantFreq seen — used to normalize freq scores at query time */
82
292
  maxTenantFreq: number = 1;
83
293
 
84
- insert(word: string, tenantFreq: number, docFreq: number, authorFreq: number): void {
294
+ insert(
295
+ word: string,
296
+ tenantFreq: number,
297
+ docFreq: number,
298
+ authorFreq: number,
299
+ meta?: TermMeta,
300
+ ): void {
85
301
  let node = this.root;
86
302
  for (const char of word.toLowerCase()) {
87
303
  let next = node.children.get(char);
@@ -95,6 +311,10 @@ class WeightedWordTrie {
95
311
  node.tenantFreq = tenantFreq;
96
312
  node.docFreq = docFreq;
97
313
  node.authorFreq = authorFreq;
314
+ node.termType = meta?.termType ?? 'word';
315
+ node.posSeq = meta?.posSeq ?? null;
316
+ node.headPos = meta?.headPos ?? null;
317
+ node.association = meta?.association ?? null;
98
318
  if (tenantFreq > this.maxTenantFreq) {
99
319
  this.maxTenantFreq = tenantFreq;
100
320
  }
@@ -172,6 +392,44 @@ class WeightedWordTrie {
172
392
  node.sessionFreq += 1;
173
393
  return true;
174
394
  }
395
+
396
+ /**
397
+ * Every word carrying a session boost, optionally limited to one prefix's
398
+ * subtree. Unlike `getCandidates` a word equal to the prefix is included,
399
+ * since the question here is what the session holds rather than what could
400
+ * still be typed.
401
+ *
402
+ * Walks the trie instead of reading an index, so nothing has to be kept in
403
+ * step on the write path for the sake of being able to ask.
404
+ */
405
+ collectSessionBoosted(prefix: string = ''): Candidate[] {
406
+ let node = this.root;
407
+ for (const char of prefix.toLowerCase()) {
408
+ const next = node.children.get(char);
409
+ if (!next) {
410
+ return [];
411
+ }
412
+ node = next;
413
+ }
414
+
415
+ const boosted: Candidate[] = [];
416
+ const stack: TrieNode[] = [node];
417
+
418
+ while (stack.length > 0) {
419
+ const current = stack.pop();
420
+ if (!current) {
421
+ continue;
422
+ }
423
+ if (current.word !== null && current.sessionFreq > 0) {
424
+ boosted.push({ word: current.word, node: current });
425
+ }
426
+ for (const child of current.children.values()) {
427
+ stack.push(child);
428
+ }
429
+ }
430
+
431
+ return boosted;
432
+ }
175
433
  }
176
434
 
177
435
  // L1/L2 Trie (Session + Atlassian Domain)
@@ -180,6 +438,15 @@ const wordTrie = new WeightedWordTrie();
180
438
  // L3 Trie (General English Fallback)
181
439
  const l3Trie = new WeightedWordTrie();
182
440
 
441
+ /**
442
+ * L2 phrase/bigram trie. Kept SEPARATE from `wordTrie` so single-word
443
+ * completion + L3 gap-fill stay untouched; multi-word units are only surfaced
444
+ * by the (12b) multiword prefix-window matcher. Keys are the full normalized
445
+ * surface string (spaces included), e.g. `"return on investment"`, so a lookup
446
+ * is a plain char-trie walk over the whole phrase.
447
+ */
448
+ const phraseTrie = new WeightedWordTrie();
449
+
183
450
  /**
184
451
  * Loads the General English vocabulary.
185
452
  * expects a simple array of strings: ["about", "above", "actually", ...]
@@ -190,10 +457,8 @@ export const initL3Vocabulary = (l3Words: string[]): void => {
190
457
  // loses to any domain word in Stage 1, but still scores above 0.
191
458
  l3Trie.insert(word, L3_BASELINE_FREQ, 0, 0);
192
459
  }
193
- if (isAutocompleteDebugEnabled()) {
194
- // eslint-disable-next-line no-console
195
- console.log(`[text-predictor] L3 General English loaded: ${l3Words.length} words`);
196
- }
460
+ recallGeneration++;
461
+ ctcTag('init', `L3 general English loaded: ${l3Words.length} words`);
197
462
  };
198
463
 
199
464
  // const bigramMap: Map<string, Record<string, number>> = new Map(
@@ -206,9 +471,23 @@ let vectorStore: VectorStore | null = null;
206
471
 
207
472
  let vectorsLoadStarted = false;
208
473
 
474
+ // Phrase/bigram artifacts load independently of the word vocabulary so a
475
+ // missing/late `bigrams.json`/`phrases.json` never breaks word completion.
476
+ let phrasesLoadStarted = false;
477
+ let phrasesLoaded = false;
478
+ let phraseTermCount = 0;
479
+ // Per-type frequency maxima, tracked separately because bigrams and phrases
480
+ // share one trie but have very different absolute frequency ranges (bigrams are
481
+ // far more frequent than 3-word phrases). Normalizing each type by its OWN max
482
+ // stops phrase freq scores from being crushed against the top bigram's count.
483
+ let maxBigramFreq = 1;
484
+ let maxPhraseFreq = 1;
485
+
209
486
  let lastPredictionDebug: {
487
+ awaitingAsyncEvidence: boolean;
210
488
  contextWords: string[];
211
489
  currentWord: string;
490
+ decision: string;
212
491
  mode: 'cold' | 'warm';
213
492
  suggestion: string | null;
214
493
  textBefore: string;
@@ -221,6 +500,36 @@ let lastPredictionDebug: {
221
500
  }>;
222
501
  } | null = null;
223
502
 
503
+ // ── Stabilization (QI-2): post-accept cooldown + whole-surface repetition ────
504
+ // Two guards that stop the accept→echo (`end to end` → `end to end to end`) and
505
+ // generally keep a just-accepted unit from being re-offered:
506
+ // • repetition: never surface a candidate whose full surface already equals the
507
+ // run of tokens immediately before the cursor (handled inline in predict()).
508
+ // • cooldown: after an accept, briefly suppress re-offering that same unit. The
509
+ // cooldown lifts only once BOTH the keystroke budget and the time window have
510
+ // elapsed (either one still counting keeps it active).
511
+ const COOLDOWN_KEYSTROKES = 5;
512
+ const COOLDOWN_MS = 2000;
513
+
514
+ // Active post-accept cooldown, or null when none.
515
+ let acceptCooldown: { predictionsSince: number; surface: string; ts: number } | null = null;
516
+
517
+ /**
518
+ * Start a short cooldown for the exact surface the editor inserted. The caller
519
+ * passes the committed snapshot's surface so background re-ranking can never
520
+ * move cooldown bookkeeping away from what the user actually accepted.
521
+ */
522
+ export const noteSuggestionAccepted = (surface: string): void => {
523
+ const normalizedSurface = surface.trim().toLowerCase();
524
+ if (normalizedSurface) {
525
+ acceptCooldown = {
526
+ predictionsSince: 0,
527
+ surface: normalizedSurface,
528
+ ts: performance.now(),
529
+ };
530
+ }
531
+ };
532
+
224
533
  /** Get vector for a word from the store. */
225
534
  const getWordVector = (word: string): Float32Array | null => {
226
535
  if (!vectorStore) {
@@ -302,6 +611,118 @@ const extractPreviousWord = (text: string): string => {
302
611
  return words.length >= 2 ? words[words.length - 2] : '';
303
612
  };
304
613
 
614
+ /**
615
+ * Collect phrase/bigram completion candidates for the multi-word window ending
616
+ * at the current partial token.
617
+ *
618
+ * Builds windows of the last `2..PHRASE_MAX_WORDS` tokens of the current
619
+ * sentence (widest first) and prefix-searches the phrase trie for each, so
620
+ * `quarterly pla` matches `quarterly planning meeting`. Windows never cross a
621
+ * sentence boundary (mirrors `extractPreviousWord`). Each result records the
622
+ * matched window length so the caller can compute the ghost tail.
623
+ *
624
+ * :params:
625
+ * trimmed: Text before the cursor, trailing whitespace already removed
626
+ * :returns:
627
+ * Matched phrase candidates (deduped, widest-window match wins per term)
628
+ */
629
+ const getPhraseCandidates = (trimmed: string): MatchedCandidate[] => {
630
+ // Records every prefix window we probe and its match count so the (verbose)
631
+ // debug line makes it obvious whether the phrase path found nothing because the
632
+ // trie is empty, or because no phrase begins with the typed window.
633
+ const windowsTried: Array<{ matches: number; window: string }> = [];
634
+ const logPhrasePath = (skipReason?: string): void => {
635
+ if (!isAutocompleteDebugVerbose()) {
636
+ return;
637
+ }
638
+ const detail = skipReason
639
+ ? skipReason
640
+ : `windows: ${windowsTried.map((w) => `"${w.window}"→${w.matches}`).join(', ') || '(none)'}`;
641
+ ctcTag(
642
+ 'phrase',
643
+ `loaded=${phraseTermCount} (${phrasesLoaded ? 'ready' : 'not loaded'}) · ${detail}`,
644
+ CTC_STYLES.section,
645
+ );
646
+ };
647
+
648
+ if (phraseTermCount === 0) {
649
+ logPhrasePath('phrase trie empty — bigrams.json/phrases.json not loaded (or 0 terms)');
650
+ return [];
651
+ }
652
+
653
+ const tokenMatches = Array.from(trimmed.matchAll(SURFACE_TOKEN_REGEX));
654
+ const lastMatch = tokenMatches[tokenMatches.length - 1];
655
+ const lastStart = lastMatch?.index;
656
+ if (!lastMatch || lastStart === undefined || lastStart + lastMatch[0].length !== trimmed.length) {
657
+ logPhrasePath('no tokens in current sentence');
658
+ return [];
659
+ }
660
+
661
+ const trailingTokens: Array<{ start: number; value: string }> = [
662
+ { start: lastStart, value: lastMatch[0] },
663
+ ];
664
+ for (let i = tokenMatches.length - 2; i >= 0; i--) {
665
+ const current = trailingTokens[0];
666
+ const match = tokenMatches[i];
667
+ const start = match.index;
668
+ if (start === undefined) {
669
+ break;
670
+ }
671
+ const end = start + match[0].length;
672
+ const gap = trimmed.slice(end, current.start);
673
+ if (!ONLY_WHITESPACE_REGEX.test(gap)) {
674
+ break;
675
+ }
676
+ trailingTokens.unshift({ start, value: match[0] });
677
+ if (trailingTokens.length >= PHRASE_MAX_WORDS) {
678
+ break;
679
+ }
680
+ }
681
+
682
+ const partial = trailingTokens[trailingTokens.length - 1].value;
683
+ const results: MatchedCandidate[] = [];
684
+ const seen = new Set<string>();
685
+ const maxPrev = trailingTokens.length - 1;
686
+
687
+ // Widest window first so the most specific (longest-context) match wins the
688
+ // dedup for a given phrase term. `prev` is the number of *preceding* complete
689
+ // words included ahead of the current partial:
690
+ // prev ≥ 1 → "continue a phrase I've started" (e.g. "root c" → root cause).
691
+ // Preceding-word context is strong, so a short partial is fine.
692
+ // prev = 0 → "the partial is a phrase's FIRST word" (e.g. "root" → root
693
+ // cause). No preceding context, so require the partial to be a
694
+ // one character so grouped LM work can begin early. UI remains
695
+ // gated at DISPLAY_MIN_PREFIX_LENGTH.
696
+ for (let prev = maxPrev; prev >= 0; prev--) {
697
+ if (prev === 0 && partial.length < MULTIWORD_PREFETCH_MIN_PREFIX_LENGTH) {
698
+ continue;
699
+ }
700
+ const windowTokens = trailingTokens.slice(-(prev + 1));
701
+ const windowPrefix = windowTokens
702
+ .map((token) => token.value)
703
+ .join(' ')
704
+ .toLowerCase();
705
+ const windowLen = windowPrefix.length;
706
+ const matches = phraseTrie.getCandidates(windowPrefix, MAX_PHRASE_CANDIDATES);
707
+ windowsTried.push({ window: windowPrefix, matches: matches.length });
708
+ for (const match of matches) {
709
+ if (seen.has(match.word)) {
710
+ continue;
711
+ }
712
+ seen.add(match.word);
713
+ results.push({
714
+ node: match.node,
715
+ word: match.word,
716
+ matchedPrefixLen: windowLen,
717
+ surfaceStart: windowTokens[0].start,
718
+ });
719
+ }
720
+ }
721
+
722
+ logPhrasePath();
723
+ return results;
724
+ };
725
+
305
726
  // ─── Debug Helpers ───────────────────────────────────────────────────────────
306
727
 
307
728
  /**
@@ -310,16 +731,39 @@ const extractPreviousWord = (text: string): string => {
310
731
  * wordCount: number of words in vector store (0 if not loaded)
311
732
  */
312
733
  export const getPredictorStatus = (): {
734
+ canonicalScoringSupported: boolean;
735
+ canonicalSurfaceCount: number | null;
736
+ clientId: string | null;
737
+ clientKind: 'localLlm' | 'server' | null;
313
738
  isInitialized: boolean;
739
+ localModelReady: boolean | null;
740
+ maxFreqByType: { bigram: number; phrase: number; word: number };
741
+ phraseCount: number;
742
+ phrasesLoaded: boolean;
743
+ slowLaneRegistered: boolean;
314
744
  vectorsLoaded: boolean;
315
745
  vectorsLoadStarted: boolean;
316
746
  wordCount: number;
317
747
  } => {
748
+ const slowLaneStatus = getDefaultSlowLaneClientStatus();
318
749
  return {
750
+ canonicalScoringSupported: slowLaneStatus.canonicalScoringSupported,
751
+ canonicalSurfaceCount: slowLaneStatus.canonicalSurfaceCount,
752
+ clientId: slowLaneStatus.clientId,
753
+ clientKind: slowLaneStatus.clientKind,
754
+ localModelReady: slowLaneStatus.localModelReady,
755
+ slowLaneRegistered: slowLaneStatus.registered,
319
756
  vectorsLoaded: vectorStore !== null,
320
757
  wordCount: vectorStore ? Object.keys(vectorStore.wordIndex).length : 0,
321
758
  vectorsLoadStarted,
322
759
  isInitialized,
760
+ phrasesLoaded,
761
+ phraseCount: phraseTermCount,
762
+ maxFreqByType: {
763
+ word: wordTrie.maxTenantFreq,
764
+ bigram: maxBigramFreq,
765
+ phrase: maxPhraseFreq,
766
+ },
323
767
  };
324
768
  };
325
769
 
@@ -328,8 +772,10 @@ export const getPredictorStatus = (): {
328
772
  * Returns null if no prediction has run yet or debug was off.
329
773
  */
330
774
  export const getLastPredictionDebug = (): {
775
+ awaitingAsyncEvidence: boolean;
331
776
  contextWords: string[];
332
777
  currentWord: string;
778
+ decision: string;
333
779
  mode: 'cold' | 'warm';
334
780
  suggestion: string | null;
335
781
  textBefore: string;
@@ -349,6 +795,40 @@ export const initVocabulary = (vocabulary: TenantVocabulary): void => {
349
795
  wordTrie.insert(term.word, term.freq, term.docFreq, term.authorFreq);
350
796
  }
351
797
  isInitialized = true;
798
+ recallGeneration++;
799
+ };
800
+
801
+ /**
802
+ * Insert a producer-shipped bigram/phrase artifact into the phrase trie.
803
+ *
804
+ * :params:
805
+ * artifact: Normalized `{term: {freq, doc_freq, author_freq, pos_seq, head_pos, association}}` map
806
+ * termType: Whether these terms are `bigram` (2 tokens) or `phrase` (3 tokens)
807
+ * :returns:
808
+ * The number of terms inserted
809
+ */
810
+ export const initPhrases = (artifact: PhraseArtifactJson, termType: TermType): number => {
811
+ let count = 0;
812
+ for (const [term, stats] of Object.entries(artifact)) {
813
+ phraseTrie.insert(term, stats.freq, stats.doc_freq, stats.author_freq, {
814
+ termType,
815
+ posSeq: stats.pos_seq ?? null,
816
+ headPos: stats.head_pos ?? null,
817
+ association: stats.association ?? null,
818
+ });
819
+ // Track each type's own frequency ceiling for per-type normalization.
820
+ if (termType === 'bigram') {
821
+ if (stats.freq > maxBigramFreq) {
822
+ maxBigramFreq = stats.freq;
823
+ }
824
+ } else if (stats.freq > maxPhraseFreq) {
825
+ maxPhraseFreq = stats.freq;
826
+ }
827
+ count++;
828
+ }
829
+ phraseTermCount += count;
830
+ recallGeneration++;
831
+ return count;
352
832
  };
353
833
 
354
834
  /**
@@ -385,100 +865,325 @@ export const ingestDocumentPage = (pageContent: string | undefined): void => {
385
865
  }
386
866
 
387
867
  if (isAutocompleteDebugEnabled() && validBoostedWords.size > 0) {
388
- // eslint-disable-next-line no-console
389
- console.groupCollapsed(
390
- `%c[L1 Session] %cPrimed ${validBoostedWords.size} valid dictionary words from page`,
391
- 'color: #00b8d9; font-weight: bold;',
392
- 'color: inherit; font-style: italic;',
868
+ ctcTag(
869
+ 'init',
870
+ `L1 session primed ${validBoostedWords.size} words from page · __atlCtcDebug__.session() to inspect`,
871
+ CTC_STYLES.brand,
393
872
  );
394
- // eslint-disable-next-line no-console
395
- console.dir(Array.from(validBoostedWords).sort());
396
- // eslint-disable-next-line no-console
397
- console.groupEnd();
873
+ if (isAutocompleteDebugVerbose()) {
874
+ // eslint-disable-next-line no-console
875
+ console.dir(Array.from(validBoostedWords).sort());
876
+ }
398
877
  }
399
878
  };
400
879
 
401
- export const predict = (textBefore: string): string | null => {
402
- if (!isInitialized) {
403
- // Vocabulary JSON is code-split and loads asynchronously. Kick off the load
404
- // and skip this keystroke; the plugin also primes it on focus, so the tries
405
- // are usually ready before the user types.
406
- void loadDefaultVocabulary().catch(() => {});
407
- return null;
408
- }
880
+ /**
881
+ * How many boosted words `inspectSessionBoosts` lists.
882
+ *
883
+ * A page ingest can boost thousands, and a list that long is not read. The
884
+ * strongest boosts are the ones that change an ordering, and `boosted` still
885
+ * reports the full size, so the cap loses nothing but volume.
886
+ */
887
+ const MAX_LISTED_SESSION_WORDS = 50;
888
+
889
+ interface SessionWordSnapshot {
890
+ /** Times this session has seen it: words typed plus words in ingested text. */
891
+ sessionFreq: number;
892
+ /** No corpus frequency behind it, so L1 is the whole of its standing. */
893
+ sessionOnly: boolean;
894
+ surface: string;
895
+ /** Corpus frequency shipped with the vocabulary, for scale against the boost. */
896
+ tenantFreq: number;
897
+ }
409
898
 
410
- const t0 = performance.now();
899
+ export interface SessionSnapshot {
900
+ /** How many words hold a boost, whether or not they are listed below. */
901
+ boosted: number;
902
+ /** Ceiling on `words`; beyond it the weakest boosts are left out of the listing. */
903
+ limit: number;
904
+ /** The prefix asked about, when one was passed. */
905
+ prefix?: string;
906
+ /** Strongest boost first, then alphabetically. */
907
+ words: SessionWordSnapshot[];
908
+ }
411
909
 
412
- // ── Step 1: Bigram-based next-word suggestion at word boundary ───────────
413
- // if (textBefore.length > 0 && /\s$/u.test(textBefore)) {
414
- // const words = textBefore.toLowerCase().trimEnd().split(/\s+/u);
415
- // const prevWord = words[words.length - 1];
416
- // const nextWords = bigramMap.get(prevWord);
417
- // if (nextWords) {
418
- // const sorted = Object.entries(nextWords).sort((a, b) => b[1] - a[1]);
419
- // let bestWord = '';
420
- // for (const [word] of sorted) {
421
- // if (!isGrammarAllowed(prevWord, word)) {
422
- // continue;
423
- // }
424
- // bestWord = word;
425
- // break;
426
- // }
427
- // if (bestWord) {
428
- // if (debugMode) {
429
- // const latencyMs = performance.now() - t0;
430
- // console.log(
431
- // '%c[autocomplete] BIGRAM',
432
- // 'color:cyan',
433
- // '| "' + prevWord + '" -> "' + bestWord + '" | ' + latencyMs.toFixed(1) + 'ms',
434
- // );
435
- // }
436
- // return bestWord;
437
- // }
438
- // }
439
- // if (debugMode) {
440
- // console.log(
441
- // '%c[autocomplete] BIGRAM-MISS',
442
- // 'color:gray',
443
- // '| no bigram for "' + words[words.length - 1] + '", skipping prefix completion',
444
- // );
445
- // }
446
- // return null;
447
- // }
448
-
449
- // ── Step 2: Prefix completion (≥3 chars typed) ──────────────────────────
450
- if (textBefore.length > 0 && TRAILING_WHITESPACE_REGEX.test(textBefore)) {
451
- return null;
452
- }
910
+ /**
911
+ * Read the session's L1 boosts, optionally narrowed to a prefix.
912
+ *
913
+ * Installed as `__atlCtcDebug__.session()`, with `__atlCtcDebug__.session('poll')`
914
+ * to ask about one family. Returned rather than logged, so the console renders it
915
+ * as an inspectable object and a caller can assert on it.
916
+ *
917
+ * Only words the vocabulary already holds can carry a boost, because both writers
918
+ * go through `incrementSessionFreq` and it only finds existing nodes. An ingested
919
+ * word absent from the vocabulary is therefore missing from here and always will
920
+ * be.
921
+ */
922
+ export const inspectSessionBoosts = (prefix?: string): SessionSnapshot => {
923
+ const boosted = wordTrie.collectSessionBoosted(prefix ?? '');
924
+ return {
925
+ boosted: boosted.length,
926
+ limit: MAX_LISTED_SESSION_WORDS,
927
+ ...(prefix === undefined ? {} : { prefix }),
928
+ words: boosted
929
+ .sort(
930
+ (a, b) =>
931
+ b.node.sessionFreq - a.node.sessionFreq ||
932
+ a.word.localeCompare(b.word, 'en', { numeric: true, sensitivity: 'base' }),
933
+ )
934
+ .slice(0, MAX_LISTED_SESSION_WORDS)
935
+ .map(({ node, word }) => ({
936
+ sessionFreq: node.sessionFreq,
937
+ sessionOnly: node.tenantFreq === 0,
938
+ surface: word,
939
+ tenantFreq: node.tenantFreq,
940
+ })),
941
+ };
942
+ };
453
943
 
454
- const trimmed = textBefore.trimEnd();
455
- const lastSpaceIdx = trimmed.lastIndexOf(' ');
456
- const currentWord = lastSpaceIdx === -1 ? trimmed : trimmed.slice(lastSpaceIdx + 1);
944
+ // At module scope so the console answers before the first keystroke, which is
945
+ // when someone reaching for it usually asks.
946
+ registerCtcSessionInspector(inspectSessionBoosts);
457
947
 
458
- if (currentWord.length < MIN_PREFIX_LENGTH) {
459
- return null;
460
- }
948
+ /**
949
+ * Result of a prediction: the ghost tail to insert plus an immutable record of
950
+ * the evidence that authorized the UI commitment.
951
+ */
952
+ export interface PredictionResult {
953
+ /** Canonical evidence depth for the selected full surface. */
954
+ evidenceDepth: {
955
+ totalChars: number;
956
+ totalTokens: number;
957
+ verifiedChars: number;
958
+ verifiedTokens: number;
959
+ };
960
+ /** Evidence tier that authorized display. Tier A is never display-eligible. */
961
+ evidenceTier: 'canonical-full-surface' | 'network-logit';
962
+ /**
963
+ * Mean per-token log-probability of the verified prefix.
964
+ *
965
+ * Carried alongside the posterior because the two answer different
966
+ * questions and a plausibility floor is placed on this one. A surface alone
967
+ * in its normaliser holds the whole pool whatever the model thinks of it,
968
+ * so its posterior cannot say whether it was worth showing and this can.
969
+ */
970
+ meanTokenLogProbability: number;
971
+ /**
972
+ * Whether a longer candidate in the same pool extends the selected surface.
973
+ *
974
+ * The chain rule leaves such a surface holding its pool's mass undivided, so
975
+ * its posterior is high for a structural reason rather than a modelled one —
976
+ * whichever continuation was meant, the prefix was right. Carried so the top
977
+ * posterior bucket can be split on it, since a lifted nested leader and a
978
+ * genuinely preferred surface are otherwise the same number.
979
+ */
980
+ poolHeldExtension: boolean;
981
+ /** Share of its shortlist's mass the model put on the selected surface. */
982
+ posterior: number;
983
+ /** Final Stage-1 + LM ranking score. */
984
+ rankScore: number;
985
+ /** How many scored candidates the selected surface's normaliser divided between. */
986
+ shortlistSize: number;
987
+ /** Full surface of the selected candidate (identity for keep-vs-swap checks). */
988
+ surface: string;
989
+ /** Whether the selected surface is a single word, a bigram, or a phrase. */
990
+ termType: TermType;
991
+ /** Ghost tail actually shown/inserted (surface minus the already-typed prefix). */
992
+ text: string;
993
+ /** Final-score lead over the strongest evidence-backed runner-up. */
994
+ winnerMargin: number;
995
+ }
461
996
 
462
- const candidates = wordTrie.getCandidates(currentWord, MAX_CANDIDATES);
997
+ // ─── Recall memo ─────────────────────────────────────────────────────────────
998
+
999
+ /** Trie recall plus the canonical context derived for each matched term. */
1000
+ interface CanonicalRecall {
1001
+ canonicalMatched: CanonicalMatchedCandidate[];
1002
+ prefixLenByWord: Map<string, number>;
1003
+ }
1004
+
1005
+ interface RecallMemo extends CanonicalRecall {
1006
+ generation: number;
1007
+ surfaceCount: number;
1008
+ trimmed: string;
1009
+ }
1010
+
1011
+ /**
1012
+ * Bumped whenever an artifact load changes what the tries can return, which is
1013
+ * the only way the candidate *set* for a given prefix can change.
1014
+ *
1015
+ * Session-frequency boosts are deliberately not counted: they mutate trie nodes
1016
+ * in place, and the memo holds those nodes by reference, so a boost is already
1017
+ * visible through a cached entry.
1018
+ */
1019
+ let recallGeneration = 0;
1020
+
1021
+ let recallMemo: RecallMemo | null = null;
1022
+
1023
+ const computeCanonicalRecall = (
1024
+ trimmed: string,
1025
+ currentWord: string,
1026
+ currentWordStart: number,
1027
+ ): CanonicalRecall => {
1028
+ // Start recall at one character so grouped causal work can overlap later
1029
+ // keystrokes. Nothing is displayed until three characters.
1030
+ const wordCandidates: Candidate[] =
1031
+ currentWord.length >= UNIGRAM_PREFETCH_MIN_PREFIX_LENGTH
1032
+ ? wordTrie.getCandidates(currentWord, MAX_CANDIDATES)
1033
+ : [];
463
1034
 
464
1035
  // Gap-fill from the L3 general-English trie, requesting a full buffer so
465
1036
  // enough survive de-duplication against the L2 results.
466
- if (candidates.length < MAX_CANDIDATES) {
1037
+ if (
1038
+ currentWord.length >= UNIGRAM_PREFETCH_MIN_PREFIX_LENGTH &&
1039
+ wordCandidates.length < MAX_CANDIDATES
1040
+ ) {
467
1041
  const l3Candidates = l3Trie.getCandidates(currentWord, MAX_CANDIDATES);
468
1042
 
469
- const existingWords = new Set(candidates.map((c) => c.word));
1043
+ const existingWords = new Set(wordCandidates.map((c) => c.word));
470
1044
 
471
1045
  for (const l3c of l3Candidates) {
472
- if (candidates.length >= MAX_CANDIDATES) break;
1046
+ if (wordCandidates.length >= MAX_CANDIDATES) break;
473
1047
 
474
1048
  if (!existingWords.has(l3c.word)) {
475
- candidates.push(l3c);
1049
+ wordCandidates.push(l3c);
476
1050
  }
477
1051
  }
478
1052
  }
479
1053
 
480
- // If both Tries are completely empty for this prefix
481
- if (candidates.length === 0) {
1054
+ // Multi-word completion terms (bigrams/phrases) matched on the trailing
1055
+ // window ending at the current partial token. Runs even when the unigram path
1056
+ // was skipped for a short partial, so first-word/early phrase matches surface.
1057
+ const phraseCandidates = getPhraseCandidates(trimmed);
1058
+
1059
+ // Unify: a word completes the current partial token; a phrase completes its
1060
+ // matched multi-word window. Track the prefix length per term so the ghost
1061
+ // tail is sliced correctly regardless of term type.
1062
+ const matched: MatchedCandidate[] = [
1063
+ ...wordCandidates.map(({ word, node }) => ({
1064
+ word,
1065
+ node,
1066
+ matchedPrefixLen: currentWord.length,
1067
+ surfaceStart: currentWordStart,
1068
+ })),
1069
+ ...phraseCandidates,
1070
+ ];
1071
+
1072
+ const positionCache = createCanonicalContextPositionCache();
1073
+ const canonicalMatched: CanonicalMatchedCandidate[] = matched.map((candidate) => ({
1074
+ ...candidate,
1075
+ ...deriveCanonicalCandidateContext(
1076
+ trimmed,
1077
+ candidate.matchedPrefixLen,
1078
+ candidate.word,
1079
+ getCanonicalSurfaceTokenIds,
1080
+ candidate.surfaceStart,
1081
+ positionCache,
1082
+ ),
1083
+ }));
1084
+
1085
+ const prefixLenByWord = new Map<string, number>();
1086
+ for (const m of canonicalMatched) {
1087
+ if (!prefixLenByWord.has(m.word)) {
1088
+ prefixLenByWord.set(m.word, m.matchedPrefixLen);
1089
+ }
1090
+ }
1091
+
1092
+ return { canonicalMatched, prefixLenByWord };
1093
+ };
1094
+
1095
+ /**
1096
+ * Recall and canonical derivation for `trimmed`, reusing the previous result
1097
+ * when nothing they depend on has changed.
1098
+ *
1099
+ * `predict()` runs several times for one keystroke — once when the decision
1100
+ * opens and again for each async evidence signal that lands inside the budget —
1101
+ * and only the evidence differs between those runs. Without this, every run
1102
+ * re-walks the tries and re-derives a canonical context per candidate.
1103
+ */
1104
+ const getCanonicalRecall = (
1105
+ trimmed: string,
1106
+ currentWord: string,
1107
+ currentWordStart: number,
1108
+ ): CanonicalRecall => {
1109
+ const surfaceCount = getCanonicalSurfaceCount();
1110
+ if (
1111
+ recallMemo !== null &&
1112
+ recallMemo.trimmed === trimmed &&
1113
+ recallMemo.generation === recallGeneration &&
1114
+ recallMemo.surfaceCount === surfaceCount
1115
+ ) {
1116
+ return recallMemo;
1117
+ }
1118
+
1119
+ const recall = computeCanonicalRecall(trimmed, currentWord, currentWordStart);
1120
+ recallMemo = { ...recall, generation: recallGeneration, surfaceCount, trimmed };
1121
+ return recall;
1122
+ };
1123
+
1124
+ export const predict = (textBefore: string): PredictionResult | null => {
1125
+ if (!isInitialized) {
1126
+ // Vocabulary artifacts load asynchronously over the autocomplete gateway.
1127
+ // Kick off the load and skip this keystroke; the plugin also primes it on
1128
+ // focus, so the tries are usually ready before the user types.
1129
+ void loadDefaultVocabulary({ source: 'predict' }).catch(() => {});
1130
+ return null;
1131
+ }
1132
+
1133
+ const t0 = performance.now();
1134
+
1135
+ // Advance the post-accept cooldown once per prediction. It stays active until
1136
+ // BOTH the keystroke budget and the time window have elapsed; while active it
1137
+ // suppresses re-offering the just-accepted surface (see arbitration below).
1138
+ let cooledSurface: string | null = null;
1139
+ if (acceptCooldown) {
1140
+ acceptCooldown.predictionsSince += 1;
1141
+ const stillByKeys = acceptCooldown.predictionsSince <= COOLDOWN_KEYSTROKES;
1142
+ const stillByTime = performance.now() - acceptCooldown.ts < COOLDOWN_MS;
1143
+ if (stillByKeys || stillByTime) {
1144
+ cooledSurface = acceptCooldown.surface;
1145
+ } else {
1146
+ acceptCooldown = null;
1147
+ }
1148
+ }
1149
+
1150
+ // ── Boundary/early-prefix prefetch; display remains gated at ≥3 ─────────
1151
+ if (textBefore.length > 0 && TRAILING_WHITESPACE_REGEX.test(textBefore)) {
1152
+ const boundaryContext = deriveWhitespaceBoundaryContext(textBefore);
1153
+ if (boundaryContext) {
1154
+ primeBoundaryLm({
1155
+ ...boundaryContext,
1156
+ familyKey: boundaryContext.contextKey,
1157
+ priority: 0,
1158
+ });
1159
+ }
1160
+ return null;
1161
+ }
1162
+
1163
+ const trimmed = textBefore.trimEnd();
1164
+ const trailingSurfaceToken = trimmed.match(TRAILING_SURFACE_TOKEN_REGEX)?.[0] ?? '';
1165
+ if (trailingSurfaceToken.length === 0) {
1166
+ return null;
1167
+ }
1168
+ const currentWord = trailingSurfaceToken;
1169
+ const currentWordStart = trimmed.length - currentWord.length;
1170
+
1171
+ const { canonicalMatched, prefixLenByWord } = getCanonicalRecall(
1172
+ trimmed,
1173
+ currentWord,
1174
+ currentWordStart,
1175
+ );
1176
+
1177
+ // If every trie was empty for this prefix
1178
+ if (canonicalMatched.length === 0) {
1179
+ if (isAutocompleteDebugEnabled()) {
1180
+ // eslint-disable-next-line no-console
1181
+ console.log(
1182
+ `%c[CTC]%c — abstain: no matches for "${currentWord}"`,
1183
+ CTC_STYLES.brand,
1184
+ CTC_STYLES.body,
1185
+ );
1186
+ }
482
1187
  return null;
483
1188
  }
484
1189
 
@@ -487,36 +1192,135 @@ export const predict = (textBefore: string): string | null => {
487
1192
  const contextVector = getContextVectorForScoring(trimmed);
488
1193
  const lmLogits = getStoredLmLogits();
489
1194
 
490
- // Raw LM Output Logger
491
- if (isAutocompleteDebugEnabled() && lmLogits && Object.keys(lmLogits).length > 0) {
492
- const rawLmTop = Object.entries(lmLogits)
493
- .sort((a, b) => b[1] - a[1])
494
- .slice(0, 5)
495
- .map(([word, score]) => ({
496
- Word: word,
497
- Prob: Number(score.toFixed(5)),
498
- }));
499
- // eslint-disable-next-line no-console
500
- console.log('%c[Raw LM Prediction]🧠', 'color: #e83e8c; font-weight: bold;', rawLmTop);
501
- }
502
-
503
1195
  const mode: 'cold' | 'warm' = contextVector ? 'warm' : 'cold';
504
1196
 
505
- // Build ScoringCandidate array from TrieNodes
506
- const scoringCandidates: ScoringCandidate[] = candidates.map(({ word, node }) => ({
1197
+ // Build ScoringCandidate array from matched terms (words + phrases)
1198
+ const scoringCandidates: ScoringCandidate[] = canonicalMatched.map(({ word, node }) => ({
507
1199
  word,
508
1200
  tenantFreq: node.tenantFreq,
509
1201
  docFreq: node.docFreq,
510
1202
  authorFreq: node.authorFreq,
511
1203
  sessionFreq: node.sessionFreq,
1204
+ termType: node.termType,
1205
+ posSeq: node.posSeq,
1206
+ headPos: node.headPos,
1207
+ association: node.association,
512
1208
  }));
513
1209
 
514
1210
  // Filter the LM payload to only words matching the current prefix so that
515
1211
  // lmMax in rankCandidates reflects prefix-relevant signal, not the global distribution.
516
1212
  const prefix = currentWord.toLowerCase();
517
- const prefixLmLogits = lmLogits
518
- ? Object.fromEntries(Object.entries(lmLogits).filter(([word]) => word.startsWith(prefix)))
519
- : null;
1213
+ const currentWordSeparator = canonicalMatched.find(
1214
+ (candidate) => candidate.node.termType === 'word',
1215
+ )?.separatorKind;
1216
+ const prefixLmLogits =
1217
+ lmLogits && currentWordSeparator === 'whitespace'
1218
+ ? Object.fromEntries(Object.entries(lmLogits).filter(([word]) => word.startsWith(prefix)))
1219
+ : null;
1220
+
1221
+ const canonicalScoringSupported = isCanonicalSurfaceScoringSupported();
1222
+ const eligibleContextKeys = Array.from(
1223
+ new Set(
1224
+ canonicalMatched
1225
+ .filter((candidate) => candidate.canonicalTokenIds !== null)
1226
+ .map((candidate) => candidate.contextKey),
1227
+ ),
1228
+ ).sort();
1229
+ const familyKey = eligibleContextKeys.join('\u0001');
1230
+ const primeRequests = selectBoundaryPrimeRequests(familyKey, canonicalMatched, PHRASE_MAX_WORDS);
1231
+ for (const request of primeRequests) {
1232
+ primeBoundaryLm(request);
1233
+ }
1234
+
1235
+ const runtimeBySurface = new Map(
1236
+ canonicalMatched.map((candidate) => [candidate.word, candidate]),
1237
+ );
1238
+ const canonicalEvidence = new Map<string, CanonicalLmEvidence>();
1239
+ const firstTokenGroups = new Map<string, CanonicalMatchedCandidate[]>();
1240
+ for (const candidate of canonicalMatched) {
1241
+ if (candidate.canonicalTokenIds === null) {
1242
+ continue;
1243
+ }
1244
+ // This accessor also materializes an exact cache entry when all tokens
1245
+ // have already been covered by shared-prefix work.
1246
+ getProgressiveSurfaceEvidence(candidate.contextKey, candidate.word);
1247
+ const exact = getSurfaceScore(candidate.contextKey, candidate.word);
1248
+ if (exact) {
1249
+ canonicalEvidence.set(candidate.word, {
1250
+ contextKey: candidate.contextKey,
1251
+ meanTokenLogProbability: exact.meanTokenLogProbability,
1252
+ score: 0,
1253
+ separatorKind: 'whitespace',
1254
+ source: 'canonical-full-surface',
1255
+ totalLogProbability: exact.totalLogProbability,
1256
+ totalSurfaceCharCount: candidate.word.length,
1257
+ totalTokenCount: exact.tokenCount,
1258
+ verifiedCharCount: candidate.word.length,
1259
+ verifiedTokenCount: exact.tokenCount,
1260
+ });
1261
+ continue;
1262
+ }
1263
+ const group = firstTokenGroups.get(candidate.contextKey) ?? [];
1264
+ group.push(candidate);
1265
+ firstTokenGroups.set(candidate.contextKey, group);
1266
+ }
1267
+
1268
+ for (const [contextKey, group] of firstTokenGroups) {
1269
+ const boundary = getBoundaryLmState(contextKey);
1270
+ if (!boundary) {
1271
+ continue;
1272
+ }
1273
+ let maxLogit = -Infinity;
1274
+ for (const candidate of group) {
1275
+ const tokenId = candidate.canonicalTokenIds?.[0];
1276
+ const rawLogit = tokenId === undefined ? undefined : boundary.rawLogits[tokenId];
1277
+ if (rawLogit !== undefined && Number.isFinite(rawLogit) && rawLogit > maxLogit) {
1278
+ maxLogit = rawLogit;
1279
+ }
1280
+ }
1281
+ if (!Number.isFinite(maxLogit)) {
1282
+ continue;
1283
+ }
1284
+ for (const candidate of group) {
1285
+ const tokenId = candidate.canonicalTokenIds?.[0];
1286
+ const rawLogit = tokenId === undefined ? undefined : boundary.rawLogits[tokenId];
1287
+ if (rawLogit === undefined || !Number.isFinite(rawLogit)) {
1288
+ continue;
1289
+ }
1290
+ const tokenCount = candidate.canonicalTokenIds?.length ?? 0;
1291
+ if (tokenId !== undefined && tokenCount === 1) {
1292
+ const totalLogProbability = logSoftmaxAt(boundary.rawLogits, tokenId);
1293
+ if (!Number.isFinite(totalLogProbability)) {
1294
+ continue;
1295
+ }
1296
+ canonicalEvidence.set(candidate.word, {
1297
+ contextKey,
1298
+ meanTokenLogProbability: totalLogProbability,
1299
+ score: 0,
1300
+ separatorKind: 'whitespace',
1301
+ source: 'canonical-full-surface',
1302
+ totalLogProbability,
1303
+ totalSurfaceCharCount: candidate.word.length,
1304
+ totalTokenCount: 1,
1305
+ verifiedCharCount: candidate.word.length,
1306
+ verifiedTokenCount: 1,
1307
+ });
1308
+ continue;
1309
+ }
1310
+ canonicalEvidence.set(candidate.word, {
1311
+ contextKey,
1312
+ rawLogit,
1313
+ score: 0,
1314
+ separatorKind: 'whitespace',
1315
+ source: 'canonical-first-token',
1316
+ totalSurfaceCharCount: candidate.word.length,
1317
+ totalTokenCount: tokenCount,
1318
+ verifiedCharCount: 0,
1319
+ verifiedTokenCount: 1,
1320
+ });
1321
+ }
1322
+ }
1323
+
520
1324
  const {
521
1325
  candidates: ranked,
522
1326
  grammarMeta,
@@ -526,29 +1330,681 @@ export const predict = (textBefore: string): string | null => {
526
1330
  contextVector,
527
1331
  (w: string) => getWordVector(w),
528
1332
  prefixLmLogits,
529
- wordTrie.maxTenantFreq,
1333
+ {
1334
+ word: wordTrie.maxTenantFreq,
1335
+ bigram: maxBigramFreq,
1336
+ phrase: maxPhraseFreq,
1337
+ },
530
1338
  previousWord,
1339
+ (surface) => canonicalEvidence.get(surface) ?? null,
531
1340
  );
532
1341
 
533
- const best = ranked[0];
1342
+ const progressiveEligible = ranked.filter((candidate) => {
1343
+ const runtime = runtimeBySurface.get(candidate.word);
1344
+ return runtime?.canonicalTokenIds !== null && runtime?.canonicalTokenIds !== undefined;
1345
+ });
1346
+ if (canonicalScoringSupported && progressiveEligible.length > 0) {
1347
+ const byContext = new Map<
1348
+ string,
1349
+ Array<{ rankHint: number; runtime: CanonicalMatchedCandidate }>
1350
+ >();
1351
+ for (const candidate of progressiveEligible) {
1352
+ const runtime = runtimeBySurface.get(candidate.word);
1353
+ if (!runtime || runtime.canonicalTokenIds === null) {
1354
+ continue;
1355
+ }
1356
+ const group = byContext.get(runtime.contextKey) ?? [];
1357
+ group.push({ runtime, rankHint: candidate.stage1Score });
1358
+ byContext.set(runtime.contextKey, group);
1359
+ }
1360
+ for (const [contextKey, group] of byContext) {
1361
+ requestProgressiveSurfaceScores({
1362
+ familyKey,
1363
+ contextKey,
1364
+ prompt: group[0].runtime.contextBeforeSurface,
1365
+ candidates: group.map(({ runtime, rankHint }) => ({
1366
+ surface: runtime.word,
1367
+ tokenIds: runtime.canonicalTokenIds ?? [],
1368
+ rankHint,
1369
+ })),
1370
+ });
1371
+ }
1372
+ }
1373
+
1374
+ // The one/two-character passes exist only to hide model latency. They may
1375
+ // prime and expand token groups above, but cannot commit UI.
1376
+ const displayPrefixReady = currentWord.length >= DISPLAY_MIN_PREFIX_LENGTH;
1377
+
1378
+ // ── Arbitration: full-surface evidence → plausibility → winner margin ──
1379
+ const canonicalLmSupported = canonicalScoringSupported;
1380
+ const hasExactEvidence = (candidate: ScoredCandidate): boolean =>
1381
+ candidate.lmEvidence?.source === 'canonical-full-surface' &&
1382
+ candidate.lmEvidence.meanTokenLogProbability !== undefined &&
1383
+ Number.isFinite(candidate.lmEvidence.meanTokenLogProbability);
1384
+
1385
+ /**
1386
+ * The evidence this candidate is judged on: the summed log-probability of
1387
+ * the tokens that have been read, their per-token mean, and whether they
1388
+ * cover the whole surface.
1389
+ *
1390
+ * `total` is the quantity the posterior is built from — it is a sequence
1391
+ * log-likelihood, so it is comparable across surfaces of different lengths
1392
+ * in a way the mean is not. `mean` is retained for reporting and for the
1393
+ * oracle arms, which compare aggregation rules against each other.
1394
+ *
1395
+ * Every token read costs a round trip, so a surface longer than
1396
+ * `REQUIRED_VERIFIED_TOKENS` is judged on its leading tokens and its tail is
1397
+ * left unread. Callers compensate with a wider winner margin rather than
1398
+ * treating the two kinds of evidence as equivalent.
1399
+ *
1400
+ * This is the depth-unaware read, and it is what the pool below is built
1401
+ * from. Arbitration goes through `judgedEvidenceFor`, which adds the one
1402
+ * requirement that can only be stated once the pool is known: a surface
1403
+ * cannot be judged against members it has not yet been read level with.
1404
+ */
1405
+ const readJudgedEvidence = (
1406
+ candidate: ScoredCandidate,
1407
+ ): { complete: boolean; mean: number; total: number } | null => {
1408
+ const evidence = candidate.lmEvidence;
1409
+ const exactMean = evidence?.meanTokenLogProbability;
1410
+ if (hasExactEvidence(candidate) && evidence && exactMean !== undefined) {
1411
+ const total = evidence.totalLogProbability ?? exactMean * evidence.totalTokenCount;
1412
+ return Number.isFinite(total) ? { complete: true, mean: exactMean, total } : null;
1413
+ }
1414
+ const runtime = runtimeBySurface.get(candidate.word);
1415
+ if (!runtime || runtime.canonicalTokenIds === null) {
1416
+ return null;
1417
+ }
1418
+ const progressive = getProgressiveSurfaceEvidence(runtime.contextKey, candidate.word);
1419
+ if (!progressive || progressive.scoredTokenCount < REQUIRED_VERIFIED_TOKENS) {
1420
+ return null;
1421
+ }
1422
+ const total = progressive.totalLogProbability;
1423
+ const mean = total / progressive.scoredTokenCount;
1424
+ return Number.isFinite(mean) && Number.isFinite(total)
1425
+ ? { complete: false, mean, total }
1426
+ : null;
1427
+ };
1428
+
1429
+ // Normalise each context's shortlist into a posterior. `total` is a sequence
1430
+ // log-likelihood, so `exp(total - logSumExp(totals))` is the share of the
1431
+ // shortlist's probability mass the model puts on that surface.
1432
+ //
1433
+ // Unknown future token log-probabilities cannot exceed zero, so a partially
1434
+ // read candidate's total is an upper bound on its eventual total, and its
1435
+ // posterior an upper bound on its eventual posterior. That is what lets the
1436
+ // plausibility floor and the display gate be the same threshold — one
1437
+ // applied to the bound, one to the verified value — instead of two numbers
1438
+ // that have to be kept consistent by hand.
1439
+ //
1440
+ // Candidates with no evidence at all are left out of the sum rather than
1441
+ // defaulted. Any default would have to stand in for a log-probability, and a
1442
+ // value low enough not to distort the normaliser is indistinguishable from
1443
+ // omitting the candidate.
1444
+ const optimisticTotalByWord = new Map<string, number>();
1445
+ const contextTotals = new Map<string, number[]>();
1446
+ const contextSurfaces = new Map<string, string[]>();
1447
+ // Counted before the total is looked up, so this is every candidate the
1448
+ // context could have scored rather than every candidate it did. The gap
1449
+ // between the two is what separates a context holding one match from a
1450
+ // context whose rivals have not come back yet.
1451
+ const contextRequestedCount = new Map<string, number>();
1452
+ const bestTotalByContext = new Map<string, number>();
1453
+ const bestCandidateByContext = new Map<string, ScoredCandidate>();
1454
+ for (const candidate of ranked) {
1455
+ const runtime = runtimeBySurface.get(candidate.word);
1456
+ if (!runtime || runtime.canonicalTokenIds === null) {
1457
+ continue;
1458
+ }
1459
+ contextRequestedCount.set(
1460
+ runtime.contextKey,
1461
+ (contextRequestedCount.get(runtime.contextKey) ?? 0) + 1,
1462
+ );
1463
+ const judged = readJudgedEvidence(candidate);
1464
+ const progressive = getProgressiveSurfaceEvidence(runtime.contextKey, candidate.word);
1465
+ // Once a candidate has been read far enough to judge, its verified total
1466
+ // replaces the bound.
1467
+ const optimisticTotal = judged?.total ?? progressive?.totalLogProbability;
1468
+ if (optimisticTotal === undefined || !Number.isFinite(optimisticTotal)) {
1469
+ continue;
1470
+ }
1471
+ optimisticTotalByWord.set(candidate.word, optimisticTotal);
1472
+ const totals = contextTotals.get(runtime.contextKey);
1473
+ const surfaces = contextSurfaces.get(runtime.contextKey);
1474
+ if (totals && surfaces) {
1475
+ totals.push(optimisticTotal);
1476
+ surfaces.push(candidate.word);
1477
+ } else {
1478
+ contextTotals.set(runtime.contextKey, [optimisticTotal]);
1479
+ contextSurfaces.set(runtime.contextKey, [candidate.word]);
1480
+ }
1481
+ const best = bestTotalByContext.get(runtime.contextKey);
1482
+ if (best === undefined || optimisticTotal > best) {
1483
+ bestTotalByContext.set(runtime.contextKey, optimisticTotal);
1484
+ bestCandidateByContext.set(runtime.contextKey, candidate);
1485
+ }
1486
+ }
1487
+
1488
+ /**
1489
+ * Whether a shorter member of the same pool is this surface's word-boundary
1490
+ * prefix — `happy path` where `happy` is also present.
1491
+ *
1492
+ * Only a multi-word surface can extend another, and a pool is a set, so this
1493
+ * costs one lookup per space rather than a comparison against every member.
1494
+ */
1495
+ const extendsAPoolMember = (surface: string, pool: ReadonlySet<string>): boolean => {
1496
+ for (let space = surface.indexOf(' '); space !== -1; space = surface.indexOf(' ', space + 1)) {
1497
+ if (pool.has(surface.slice(0, space))) {
1498
+ return true;
1499
+ }
1500
+ }
1501
+ return false;
1502
+ };
1503
+
1504
+ // A sequence total only falls as more of the sequence is read, so a partial
1505
+ // sum over `k` tokens sits above the same surface's total over `m > k`. An
1506
+ // extension read less far than the member it continues therefore carries a
1507
+ // total above that member's — and because the chain rule narrows the
1508
+ // denominator to the pool's minimal members, `exp(total - normaliser)` comes
1509
+ // out above 1 for a surface whose own continuation has not been looked at.
1510
+ // It needs a three-token first word to happen, which 2.6% of served bigrams
1511
+ // have, and it inflates both the posterior the gate reads and the
1512
+ // `confidenceScore` every other candidate's margin is measured against.
1513
+ //
1514
+ // A surface's canonical tokenization begins with the tokenization of its own
1515
+ // leading words — the producer encodes `" " + surface` in one call and the
1516
+ // ByteLevel pre-tokenizer splits on word boundaries, so no merge crosses a
1517
+ // space — which is what makes both corrections below exact rather than
1518
+ // approximate.
1519
+ //
1520
+ // The total is capped at what it continues: `P(happy path)` cannot exceed
1521
+ // `P(happy)`. Shortest-first, so a three-word phrase is capped against a
1522
+ // two-word prefix that has already been capped itself.
1523
+ //
1524
+ // And the surface is held unjudgeable until it has been read level with what
1525
+ // it continues, because the cap alone would leave it looking exactly as
1526
+ // certain as its prefix while saying nothing about its own tail.
1527
+ const underReadExtensions = new Set<string>();
1528
+ for (const [contextKey, surfaces] of contextSurfaces) {
1529
+ const pool = new Set(surfaces);
1530
+ for (const surface of [...surfaces].sort((a, b) => a.length - b.length)) {
1531
+ const own = optimisticTotalByWord.get(surface);
1532
+ if (own === undefined) {
1533
+ continue;
1534
+ }
1535
+ let cap = Infinity;
1536
+ let continuedDepth = 0;
1537
+ for (
1538
+ let space = surface.indexOf(' ');
1539
+ space !== -1;
1540
+ space = surface.indexOf(' ', space + 1)
1541
+ ) {
1542
+ const prefix = surface.slice(0, space);
1543
+ if (!pool.has(prefix)) {
1544
+ continue;
1545
+ }
1546
+ const prefixTotal = optimisticTotalByWord.get(prefix);
1547
+ if (prefixTotal !== undefined && prefixTotal < cap) {
1548
+ cap = prefixTotal;
1549
+ }
1550
+ const prefixDepth = runtimeBySurface.get(prefix)?.canonicalTokenIds?.length ?? 0;
1551
+ if (prefixDepth > continuedDepth) {
1552
+ continuedDepth = prefixDepth;
1553
+ }
1554
+ }
1555
+ if (own > cap) {
1556
+ optimisticTotalByWord.set(surface, cap);
1557
+ }
1558
+ const scoredDepth = getProgressiveSurfaceEvidence(contextKey, surface)?.scoredTokenCount ?? 0;
1559
+ const surfaceDepth = runtimeBySurface.get(surface)?.canonicalTokenIds?.length ?? 0;
1560
+ if (scoredDepth < continuedDepth && scoredDepth < surfaceDepth) {
1561
+ underReadExtensions.add(surface);
1562
+ }
1563
+ }
1564
+ }
1565
+
1566
+ /**
1567
+ * The evidence arbitration judges a candidate on.
1568
+ *
1569
+ * Wraps the depth-unaware read with the pool-relative requirement: a surface
1570
+ * that continues another pool member is not judgeable until it has been read
1571
+ * level with it, so the two totals being compared are taken at comparable
1572
+ * depths.
1573
+ */
1574
+ const judgedEvidenceFor = (
1575
+ candidate: ScoredCandidate,
1576
+ ): { complete: boolean; mean: number; total: number } | null =>
1577
+ underReadExtensions.has(candidate.word) ? null : readJudgedEvidence(candidate);
1578
+ const hasJudgeableEvidence = (candidate: ScoredCandidate): boolean =>
1579
+ judgedEvidenceFor(candidate) !== null;
1580
+
1581
+ // Normalise over the pool's minimal members only, and score everyone —
1582
+ // extensions included — against that.
1583
+ //
1584
+ // A sequence total is its prefix's total plus a log-probability that cannot
1585
+ // be positive, so summing a surface and its own extension double-counts:
1586
+ // `exp(total)` for `happy` already contains every continuation of `happy`,
1587
+ // `happy path` among them. Dividing one pool's mass between the two capped
1588
+ // the shorter form at half however certain it was, and the longer form below
1589
+ // that — both under gates neither could then clear, for a reason that was
1590
+ // arithmetic rather than evidence.
1591
+ //
1592
+ // Excluding extensions from the denominator is the chain rule: the minimal
1593
+ // members partition the mass, and an extension's share of that same
1594
+ // denominator is `P(prefix) * P(continuation | prefix)`, which is exactly
1595
+ // what a multi-word suggestion should be held to. Extensions stay in
1596
+ // `contextTotals`, so how contested a context is still counts every scored
1597
+ // candidate.
1598
+ const logSumExpByContext = new Map<string, number>();
1599
+ for (const [contextKey, surfaces] of contextSurfaces) {
1600
+ const pool = new Set(surfaces);
1601
+ const minimalTotals: number[] = [];
1602
+ for (const surface of surfaces) {
1603
+ const total = optimisticTotalByWord.get(surface);
1604
+ if (total !== undefined && !extendsAPoolMember(surface, pool)) {
1605
+ minimalTotals.push(total);
1606
+ }
1607
+ }
1608
+ // An extension is strictly longer than what it extends, so the shortest
1609
+ // member of any non-empty pool is always minimal and this is never empty.
1610
+ logSumExpByContext.set(contextKey, logSumExp(minimalTotals));
1611
+ }
1612
+
1613
+ /**
1614
+ * A candidate's share of its context shortlist's probability mass.
1615
+ *
1616
+ * Pass an optimistic (partially read) total for an upper bound on the
1617
+ * eventual posterior, or a verified total for the posterior itself.
1618
+ */
1619
+ const posteriorFor = (candidate: ScoredCandidate, total: number | undefined): number => {
1620
+ const runtime = runtimeBySurface.get(candidate.word);
1621
+ const normalizer = runtime ? logSumExpByContext.get(runtime.contextKey) : undefined;
1622
+ if (total === undefined || normalizer === undefined || !Number.isFinite(normalizer)) {
1623
+ return 0;
1624
+ }
1625
+ return Math.exp(total - normalizer);
1626
+ };
1627
+ const optimisticPosterior = (candidate: ScoredCandidate): number =>
1628
+ posteriorFor(candidate, optimisticTotalByWord.get(candidate.word));
1629
+ const judgedPosterior = (candidate: ScoredCandidate): number =>
1630
+ posteriorFor(candidate, judgedEvidenceFor(candidate)?.total);
1631
+
1632
+ // A candidate stays in the race for as long as its optimistic posterior
1633
+ // could still clear the gate it will be held to. The bound only falls as
1634
+ // more tokens are read, so nothing dropped here could have gone on to win.
1635
+ const couldClearGate = (candidate: ScoredCandidate): boolean =>
1636
+ optimisticPosterior(candidate) >= MIN_LM_POSTERIOR[candidate.termType];
1637
+ const passesLmFloor = (candidate: ScoredCandidate): boolean =>
1638
+ judgedEvidenceFor(candidate) !== null && couldClearGate(candidate);
1639
+ const hasRequiredEvidence = (
1640
+ candidate: ScoredCandidate,
1641
+ ): candidate is ScoredCandidate & {
1642
+ lmEvidence: CanonicalLmEvidence;
1643
+ } => {
1644
+ if (canonicalLmSupported) {
1645
+ return passesLmFloor(candidate);
1646
+ }
1647
+ return candidate.termType === 'word' && candidate.lmEvidence?.source === 'network-logit';
1648
+ };
1649
+
1650
+ // ── Stabilization guards (QI-2) ──────────────────────────────────────────
1651
+ // Whole-surface repetition: drop a candidate whose full surface already equals
1652
+ // the run of tokens immediately before the cursor — accepting it would
1653
+ // duplicate what was just typed (`end to end` → `end to end to end`).
1654
+ const beforeTokens = tokenize(trimmed);
1655
+ const duplicatesPreceding = (c: ScoredCandidate): boolean => {
1656
+ const surfaceTokens = tokenize(c.word);
1657
+ const n = surfaceTokens.length;
1658
+ if (n === 0 || beforeTokens.length < n) {
1659
+ return false;
1660
+ }
1661
+ for (let i = 0; i < n; i++) {
1662
+ if (beforeTokens[beforeTokens.length - n + i] !== surfaceTokens[i]) {
1663
+ return false;
1664
+ }
1665
+ }
1666
+ return true;
1667
+ };
1668
+ // Post-accept cooldown: don't re-offer the surface the user just accepted while
1669
+ // its cooldown is still active (see cooldown advance near t0).
1670
+ const isCooledDown = (c: ScoredCandidate): boolean =>
1671
+ cooledSurface !== null && c.word.toLowerCase() === cooledSurface;
1672
+
1673
+ const isStabilized = (candidate: ScoredCandidate): boolean =>
1674
+ !duplicatesPreceding(candidate) && !isCooledDown(candidate);
1675
+ // A candidate the user has all but finished typing is not worth a ghost, and
1676
+ // a candidate we would never show is not worth waiting for either.
1677
+ const suggestionLengthFor = (candidate: ScoredCandidate): number =>
1678
+ candidate.word.length - (prefixLenByWord.get(candidate.word) ?? currentWord.length);
1679
+ const isWorthShowing = (candidate: ScoredCandidate): boolean =>
1680
+ suggestionLengthFor(candidate) >= MIN_SUGGESTION_LENGTH;
1681
+ const evidenceBacked = ranked.filter(
1682
+ (candidate) => hasRequiredEvidence(candidate) && isStabilized(candidate),
1683
+ );
1684
+
1685
+ const confidenceScore = (candidate: ScoredCandidate): number => {
1686
+ if (!canonicalLmSupported) {
1687
+ return candidate.finalScore;
1688
+ }
1689
+ const runtime = runtimeBySurface.get(candidate.word);
1690
+ if (runtime?.canonicalTokenIds === null || !optimisticTotalByWord.has(candidate.word)) {
1691
+ return candidate.stage1Score;
1692
+ }
1693
+ return STAGE1_WEIGHT * candidate.stage1Score + STAGE2_WEIGHT * optimisticPosterior(candidate);
1694
+ };
1695
+ const exactConfidenceScore = (candidate: ScoredCandidate): number => {
1696
+ if (!canonicalLmSupported || judgedEvidenceFor(candidate) === null) {
1697
+ return candidate.finalScore;
1698
+ }
1699
+ return STAGE1_WEIGHT * candidate.stage1Score + STAGE2_WEIGHT * judgedPosterior(candidate);
1700
+ };
1701
+
1702
+ /**
1703
+ * How many candidates in this one's context were scored at all.
1704
+ *
1705
+ * Read off pool membership, which counts every candidate that contributed a
1706
+ * total, rather than off the normaliser, which the chain rule narrows to the
1707
+ * pool's minimal members.
1708
+ */
1709
+ const scoredPoolSize = (candidate: ScoredCandidate): number => {
1710
+ const contextKey = runtimeBySurface.get(candidate.word)?.contextKey;
1711
+ return contextKey === undefined ? 0 : (contextTotals.get(contextKey)?.length ?? 0);
1712
+ };
1713
+
1714
+ /**
1715
+ * How many candidates the context held before any of them were scored.
1716
+ *
1717
+ * A pool of one means two opposite things. Either the vocabulary offered a
1718
+ * single continuation of what was typed, which is the least contested case
1719
+ * there is; or rivals were offered and have not been read yet, which is the
1720
+ * least informed. Only the second deserves refusing, and the two are
1721
+ * distinguishable exactly here.
1722
+ */
1723
+ const requestedPoolSize = (candidate: ScoredCandidate): number => {
1724
+ const contextKey = runtimeBySurface.get(candidate.word)?.contextKey;
1725
+ return contextKey === undefined ? 0 : (contextRequestedCount.get(contextKey) ?? 0);
1726
+ };
1727
+
1728
+ // A suppressed short completion still counts as competition below, so losing
1729
+ // to one abstains rather than promoting the runner-up in its place.
1730
+ const gateCleared = evidenceBacked
1731
+ .filter(isWorthShowing)
1732
+ .map((candidate) => ({
1733
+ candidate,
1734
+ posterior: judgedPosterior(candidate),
1735
+ score: exactConfidenceScore(candidate),
1736
+ }))
1737
+ // The gate is the model's own confidence in the surface; the blended score
1738
+ // only orders what has already cleared it, so a strong corpus prior can no
1739
+ // longer carry a surface the model is unsure of onto the screen.
1740
+ .filter(({ candidate, posterior }) => posterior >= MIN_LM_POSTERIOR[candidate.termType]);
1741
+ /**
1742
+ * Whether the model finds the surface plausible on its own terms.
1743
+ *
1744
+ * The gate above asks which candidate won its pool; this asks whether
1745
+ * winning it meant anything. The two come apart exactly where a pool is
1746
+ * thin — a shortlist of one hands its only member 1.0 by construction — and
1747
+ * a relative test can never see through that however it is tuned.
1748
+ *
1749
+ * Waived without canonical scoring, where there is no judged mean to test
1750
+ * and the network-logit path would otherwise refuse everything.
1751
+ */
1752
+ const isPlausibleSurface = (candidate: ScoredCandidate): boolean => {
1753
+ if (!canonicalLmSupported) {
1754
+ return true;
1755
+ }
1756
+ const mean = judgedEvidenceFor(candidate)?.mean;
1757
+ return mean !== undefined && mean >= MIN_MEAN_TOKEN_LOG_PROBABILITY;
1758
+ };
1759
+ // Kept as its own stage rather than folded into the gate so the three
1760
+ // populations stay separable in the abstain cascade: cleared its pool and was
1761
+ // implausible, was plausible and had nothing to clear the pool against, or
1762
+ // cleared both. A candidate refused here stays in `ranked` and so still
1763
+ // counts as competition below — promoting the runner-up in place of an
1764
+ // implausible leader would show something worse, not something better.
1765
+ const plausible = gateCleared.filter(({ candidate }) => isPlausibleSurface(candidate));
1766
+ // Applied after the gate rather than folded into it, so the two populations
1767
+ // stay separable: a surface refused here cleared its threshold and was
1768
+ // refused for having had nothing to clear it against.
1769
+ const eligible = plausible
1770
+ .filter(
1771
+ ({ candidate }) =>
1772
+ !canonicalLmSupported ||
1773
+ scoredPoolSize(candidate) >= MIN_SCORED_POOL_SIZE ||
1774
+ requestedPoolSize(candidate) <= 1,
1775
+ )
1776
+ .sort((a, b) => b.score - a.score);
1777
+ const selected = eligible[0]?.candidate;
1778
+ const selectedDisplayScore = eligible[0]?.score ?? 0;
1779
+ const selectedEvidence = selected && hasRequiredEvidence(selected) ? selected.lmEvidence : null;
1780
+ /**
1781
+ * Whether `longer` is `shorter` continued past a word boundary, in the same
1782
+ * normaliser.
1783
+ *
1784
+ * Shared by the two places that have to agree on what an extension is: the
1785
+ * chain rule, which stops dividing a pool's mass between a sequence and its
1786
+ * own prefix, and the winner margin below, which stops treating one as the
1787
+ * other's rival. They were the same category error and are now the same test.
1788
+ */
1789
+ const extendsInSamePool = (shorter: ScoredCandidate, longer: ScoredCandidate): boolean => {
1790
+ if (longer.word === shorter.word || !optimisticTotalByWord.has(longer.word)) {
1791
+ return false;
1792
+ }
1793
+ const pool = runtimeBySurface.get(shorter.word)?.contextKey;
1794
+ if (pool === undefined || runtimeBySurface.get(longer.word)?.contextKey !== pool) {
1795
+ return false;
1796
+ }
1797
+ return (
1798
+ longer.word.length > shorter.word.length &&
1799
+ longer.word.startsWith(shorter.word) &&
1800
+ longer.word[shorter.word.length] === ' '
1801
+ );
1802
+ };
1803
+ // A candidate that extends the selection is not competing with it. Whichever
1804
+ // continuation the user meant, the shorter surface is a correct ghost — it is
1805
+ // the prefix of both — so the margin has nothing to arbitrate and charging the
1806
+ // selection for its own extension abstains on a case that could not be wrong.
1807
+ // The chain rule already removed this from the denominator; leaving it in the
1808
+ // margin meant a nested leader cleared its gate and then lost to the candidate
1809
+ // that had just been freed from dividing its mass. It penalised bigrams and
1810
+ // phrases specifically, because a nested pool is where they mostly appear.
1811
+ const runnerUpScore = selected
1812
+ ? Math.max(
1813
+ ...ranked
1814
+ .filter(
1815
+ (candidate) =>
1816
+ candidate.word !== selected.word &&
1817
+ isStabilized(candidate) &&
1818
+ !extendsInSamePool(selected, candidate),
1819
+ )
1820
+ .map(confidenceScore),
1821
+ 0,
1822
+ )
1823
+ : 0;
1824
+ const winnerMargin = selected ? selectedDisplayScore - runnerUpScore : 0;
1825
+ const hasMissingArtifactCompetitor =
1826
+ canonicalLmSupported &&
1827
+ ranked.some((candidate) => {
1828
+ const runtime = runtimeBySurface.get(candidate.word);
1829
+ return runtime?.separatorKind === 'whitespace' && runtime.canonicalTokenIds === null;
1830
+ });
1831
+ // A candidate with canonical tokens but no evidence at all cannot join the
1832
+ // posterior: it has no likelihood to contribute, and any stand-in value would
1833
+ // distort the normaliser for every other candidate in its context. Absent
1834
+ // evidence is not evidence of absence though — the context's boundary may
1835
+ // simply not be primed yet — so it holds the ghost back instead of being
1836
+ // silently ignored. In a primed context every candidate has at least
1837
+ // first-token evidence, so this only fires while a context is still cold.
1838
+ const hasUnscoredCompetitor =
1839
+ canonicalLmSupported &&
1840
+ ranked.some((candidate) => {
1841
+ const runtime = runtimeBySurface.get(candidate.word);
1842
+ return (
1843
+ candidate.word !== selected?.word &&
1844
+ runtime?.canonicalTokenIds !== null &&
1845
+ runtime?.canonicalTokenIds !== undefined &&
1846
+ !optimisticTotalByWord.has(candidate.word) &&
1847
+ isStabilized(candidate) &&
1848
+ isWorthShowing(candidate)
1849
+ );
1850
+ });
1851
+ // A rival only holds the ghost back while it is still too unread to judge.
1852
+ // Under full-surface evidence that meant any long surface blocked everything
1853
+ // until every one of its tokens had been paid for, which abstained far more
1854
+ // often than it ever changed the winner. A rival now becomes judgeable after
1855
+ // `REQUIRED_VERIFIED_TOKENS`, so this settles within the budget.
1856
+ const hasUnresolvedPotential =
1857
+ canonicalLmSupported &&
1858
+ ranked.some((candidate) => {
1859
+ const runtime = runtimeBySurface.get(candidate.word);
1860
+ return (
1861
+ candidate.word !== selected?.word &&
1862
+ runtime?.canonicalTokenIds !== null &&
1863
+ runtime?.canonicalTokenIds !== undefined &&
1864
+ !hasJudgeableEvidence(candidate) &&
1865
+ couldClearGate(candidate) &&
1866
+ isStabilized(candidate) &&
1867
+ isWorthShowing(candidate)
1868
+ );
1869
+ });
1870
+ const requiredWinnerMargin =
1871
+ MIN_WINNER_MARGIN +
1872
+ (selected && judgedEvidenceFor(selected)?.complete === false
1873
+ ? PARTIAL_EVIDENCE_MARGIN_PREMIUM
1874
+ : 0);
1875
+ // Everything the display decision needs except the veto itself. Splitting it
1876
+ // out is what lets the veto be priced: the value here at the moment the veto
1877
+ // fires is exactly the ghost the veto cost us.
1878
+ const clearsMarginBeforeColdVeto =
1879
+ selected !== undefined && !hasMissingArtifactCompetitor && winnerMargin >= requiredWinnerMargin;
1880
+ const clearsWinnerMargin = clearsMarginBeforeColdVeto && !hasUnscoredCompetitor;
1881
+
534
1882
  const suggestion =
535
- best && best.finalScore >= MIN_SCORE_THRESHOLD ? best.word.slice(currentWord.length) : null;
1883
+ displayPrefixReady && selected && clearsWinnerMargin
1884
+ ? selected.word.slice(prefixLenByWord.get(selected.word) ?? currentWord.length)
1885
+ : null;
1886
+
1887
+ // Which constraint bound. Computed on every evaluation rather than only under
1888
+ // debug, because it is counted for the session as well as printed, and
1889
+ // deriving the debug header from it keeps the two from disagreeing.
1890
+ const resolveAbstainReason = (): CtcAbstainReason | null => {
1891
+ if (suggestion !== null && suggestion.length > 0) {
1892
+ return null;
1893
+ }
1894
+ if (!displayPrefixReady) {
1895
+ return 'prefetch';
1896
+ }
1897
+ if (ranked.length === 0) {
1898
+ return 'no-candidate';
1899
+ }
1900
+ if (hasMissingArtifactCompetitor) {
1901
+ return 'missing-artifact';
1902
+ }
1903
+ if (hasUnscoredCompetitor) {
1904
+ return 'cold-competitor';
1905
+ }
1906
+ if (hasUnresolvedPotential && !clearsWinnerMargin) {
1907
+ return 'unresolved-rival';
1908
+ }
1909
+ if (eligible.length > 0) {
1910
+ return selected && !clearsWinnerMargin ? 'winner-margin' : 'empty-completion';
1911
+ }
1912
+ if (plausible.length > 0) {
1913
+ // Cleared its gate, was plausible on its own terms, and was refused for
1914
+ // the shortlist it cleared that gate against holding nobody else.
1915
+ return 'lone-candidate';
1916
+ }
1917
+ if (gateCleared.length > 0) {
1918
+ // Held the largest share of its shortlist and still was not a surface
1919
+ // the model found plausible — which is only possible because a share is
1920
+ // relative and this is not.
1921
+ return 'implausible-surface';
1922
+ }
1923
+ if (
1924
+ evidenceBacked.length > 0 &&
1925
+ evidenceBacked.every((candidate) => !isWorthShowing(candidate))
1926
+ ) {
1927
+ return 'short-completion';
1928
+ }
1929
+ // The floor and the gate are one threshold, applied to the bound and to
1930
+ // the verified value, so a candidate short of it is dropped before
1931
+ // `evidenceBacked` is built. Asking whether anything was judgeable
1932
+ // separates a gate that rejected read candidates from a context where
1933
+ // nothing was read at all — the first is a threshold to calibrate, the
1934
+ // second is scheduling.
1935
+ return ranked.some(hasJudgeableEvidence) ? 'below-posterior-gate' : 'no-evidence';
1936
+ };
1937
+ const abstainReason: CtcAbstainReason | null = resolveAbstainReason();
1938
+
1939
+ // The leader is the best-supported candidate, not the selected one: an
1940
+ // evaluation that showed nothing is exactly the one whose posterior needs
1941
+ // recording, and it has no selection to report.
1942
+ const posteriorLeader = ranked.reduce<{
1943
+ candidate: ScoredCandidate;
1944
+ posterior: number;
1945
+ } | null>((best, candidate) => {
1946
+ const posterior = optimisticPosterior(candidate);
1947
+ return posterior > 0 && (best === null || posterior > best.posterior)
1948
+ ? { candidate, posterior }
1949
+ : best;
1950
+ }, null);
1951
+ /**
1952
+ * The longer surface in the leader's normaliser that extends the given
1953
+ * candidate, if the pool holds one.
1954
+ *
1955
+ * `logSumExp` divides one pool's mass between a sequence and its own prefix,
1956
+ * and the longer total is the shorter one plus a log-probability that cannot
1957
+ * be positive — so the longer form holds at most half, or at most a third
1958
+ * where both shorter forms are present. Both sit under the gate it is held
1959
+ * to, so it cannot display however certain its continuation is.
1960
+ *
1961
+ * Asked from the shorter form because that is the one that leads: the
1962
+ * extending candidate is strictly lower in the same pool and so can never be
1963
+ * the leader.
1964
+ */
1965
+ const poolExtensionOf = (shorter: ScoredCandidate): ScoredCandidate | undefined =>
1966
+ ranked.find((candidate) => extendsInSamePool(shorter, candidate));
536
1967
 
537
1968
  if (isAutocompleteDebugEnabled()) {
1969
+ const verbose = isAutocompleteDebugVerbose();
538
1970
  const latencyMs = performance.now() - t0;
539
1971
  const tokens = tokenize(trimmed);
540
1972
  const contextWords = tokens.slice(-CONTEXT_WORDS);
541
- const belowThreshold = best && best.finalScore < MIN_SCORE_THRESHOLD;
542
1973
 
543
- const suggestionLabel = belowThreshold
544
- ? '🚫 (below threshold)'
545
- : suggestion && suggestion.length > 0
546
- ? `✨ "${suggestion}"`
547
- : '🚫 (no match)';
1974
+ // Decision drives the (collapsed) group header. Abstains carry the reason
1975
+ // the session counters recorded, plus the numbers behind it, so it is clear
1976
+ // *why* nothing showed without expanding the group.
1977
+ const abstainDetail: Record<CtcAbstainReason, string> = {
1978
+ 'below-posterior-gate': `posterior below ${
1979
+ MIN_LM_POSTERIOR[(posteriorLeader?.candidate ?? ranked[0]).termType]
1980
+ } (best ${(posteriorLeader?.posterior ?? 0).toFixed(2)})`,
1981
+ 'cold-competitor': 'competitor has no LM evidence yet',
1982
+ 'empty-completion': 'empty completion',
1983
+ 'implausible-surface': `mean per-token log-probability below ${MIN_MEAN_TOKEN_LOG_PROBABILITY} (best ${gateCleared
1984
+ .map(({ candidate }) => judgedEvidenceFor(candidate)?.mean ?? -Infinity)
1985
+ .reduce((best, mean) => Math.max(best, mean), -Infinity)
1986
+ .toFixed(2)})`,
1987
+ 'lone-candidate': `cleared its gate against fewer than ${MIN_SCORED_POOL_SIZE} scored candidates, rivals unread`,
1988
+ 'missing-artifact': 'canonical artifact coverage missing',
1989
+ 'no-candidate': 'nothing cleared scoring',
1990
+ 'no-evidence': 'full-surface evidence absent',
1991
+ prefetch: `prefetch: ${currentWord.length}/${DISPLAY_MIN_PREFIX_LENGTH} chars`,
1992
+ 'short-completion': `completion shorter than ${MIN_SUGGESTION_LENGTH} chars`,
1993
+ 'unresolved-rival': 'expanding plausible token-prefix groups',
1994
+ 'winner-margin': `winner margin ${winnerMargin.toFixed(2)} < ${requiredWinnerMargin.toFixed(2)}`,
1995
+ };
1996
+ const decision =
1997
+ abstainReason === null
1998
+ ? `✨ "${suggestion ?? ''}"`
1999
+ : abstainReason === 'prefetch'
2000
+ ? `⏳ ${abstainDetail.prefetch}`
2001
+ : `— abstain: ${abstainDetail[abstainReason]}`;
548
2002
 
549
2003
  lastPredictionDebug = {
2004
+ awaitingAsyncEvidence: hasUnresolvedPotential && !clearsWinnerMargin,
550
2005
  textBefore: trimmed,
551
2006
  currentWord,
2007
+ decision,
552
2008
  mode,
553
2009
  contextWords,
554
2010
  topCandidates: ranked.slice(0, 5).map((r) => ({
@@ -561,98 +2017,239 @@ export const predict = (textBefore: string): string | null => {
561
2017
  suggestion: suggestion && suggestion.length > 0 ? suggestion : null,
562
2018
  };
563
2019
 
564
- // ── Mode label: COLD / WARM(local) / WARM(BE) ───────────────────────
2020
+ // Mode describes the semantic-vector source without implying that a
2021
+ // network backend ran; the snapshot may come from the local MLC embedder.
565
2022
  const slowLaneVec = getStoredContextVector();
566
2023
  const isUsingSlowLaneVector =
567
2024
  slowLaneVec !== null && vectorStore !== null && slowLaneVec.length === vectorStore.dim;
568
- const modeLabel = !contextVector ? 'COLD' : isUsingSlowLaneVector ? 'WARM(BE)' : 'WARM(local)';
2025
+ const modeLabel = !contextVector
2026
+ ? 'COLD'
2027
+ : isUsingSlowLaneVector
2028
+ ? 'WARM(semantic)'
2029
+ : 'WARM(local-avg)';
569
2030
  const modeColor =
570
- modeLabel === 'WARM(BE)'
571
- ? 'color: #ff9800; font-weight: bold;'
572
- : modeLabel === 'WARM(local)'
573
- ? 'color: #4caf50; font-weight: bold;'
574
- : 'color: #9e9e9e; font-weight: bold;';
575
-
576
- // 1. Collapsible group header
2031
+ modeLabel === 'WARM(semantic)'
2032
+ ? CTC_STYLES.good
2033
+ : modeLabel === 'WARM(local-avg)'
2034
+ ? CTC_STYLES.warn
2035
+ : CTC_STYLES.cold;
2036
+
2037
+ // Group header: [CTC:<mode>] <decision> · <mode> · <type> · <latency>
2038
+ // arbitrationMode ('v1') tags the line so this deploy's logs diff cleanly
2039
+ // against the baseline deploy's.
577
2040
  // eslint-disable-next-line no-console
578
2041
  console.groupCollapsed(
579
- `%c[Autocomplete] %c${modeLabel} %c| "${currentWord}" ➔ ${suggestionLabel} | ⏱ ${latencyMs.toFixed(1)}ms`,
580
- 'color: #00b8d9; font-weight: bold;',
2042
+ `%c[CTC:${ARBITRATION_MODE}]%c ${decision} %c· ${modeLabel}${selected ? ` · ${selected.termType}` : ''} · ⏱ ${latencyMs.toFixed(1)}ms`,
2043
+ CTC_STYLES.brand,
2044
+ CTC_STYLES.body,
581
2045
  modeColor,
582
- 'color: inherit; font-weight: normal;',
583
2046
  );
584
2047
 
585
- // 2. Context window (what local vector averaging sees)
586
- // eslint-disable-next-line no-console
587
- console.log(
588
- '%cContext Window:',
589
- 'color: #888; font-style: italic;',
590
- contextWords.length ? contextWords.join(' ') : '(none)',
2048
+ const semanticInput = getStoredContextInput();
2049
+ const causalPrompts = primeRequests.map((request, index) => {
2050
+ const tail = request.prompt.slice(-DEBUG_TEXT_TAIL_CHARS);
2051
+ return `#${index + 1}:${JSON.stringify(tail)}`;
2052
+ });
2053
+
2054
+ // Keep the exact model inputs separate from normalized lookup tokens.
2055
+ ctcSection(
2056
+ 'INPUT',
2057
+ `raw: ${JSON.stringify(trimmed.slice(-DEBUG_TEXT_TAIL_CHARS))} · prev: ${previousWord ? JSON.stringify(previousWord) : '—'}`,
2058
+ );
2059
+ ctcSection(
2060
+ 'LOOKUP',
2061
+ `normalized(last ${CONTEXT_WORDS}): ${contextWords.length ? JSON.stringify(contextWords.join(' ')) : '(none)'}`,
2062
+ );
2063
+ ctcSection(
2064
+ 'SEMANTIC',
2065
+ `snapshot: ${semanticInput ? JSON.stringify(semanticInput.slice(-DEBUG_TEXT_TAIL_CHARS)) : '(unavailable)'}`,
2066
+ );
2067
+ ctcSection(
2068
+ 'CAUSAL',
2069
+ `prompt${causalPrompts.length === 1 ? '' : 's'}: ${causalPrompts.join(' · ') || '(not ready)'}`,
591
2070
  );
592
2071
 
593
- // 3. Slow-lane status
594
- const vectorStatus = isUsingSlowLaneVector
595
- ? `✅ BE semantic vector (dim=${slowLaneVec?.length})`
2072
+ // SIGNALS — semantic state plus context-keyed canonical LM coverage.
2073
+ const logitCount = lmLogits ? Object.keys(lmLogits).length : 0;
2074
+ const semanticStatus = isUsingSlowLaneVector
2075
+ ? `semantic ✅ snapshot(${slowLaneVec?.length}d)`
596
2076
  : vectorStore
597
- ? '⚠️ Local vector average (slow-lane not yet returned)'
598
- : '❌ No vectors (cold)';
2077
+ ? 'semantic ⚠️ local-avg'
2078
+ : 'semantic ❌ cold';
2079
+ const tierACount = ranked.filter(
2080
+ (candidate) => candidate.lmEvidence?.source === 'canonical-first-token',
2081
+ ).length;
2082
+ const exactCount = ranked.filter(
2083
+ (candidate) => candidate.lmEvidence?.source === 'canonical-full-surface',
2084
+ ).length;
2085
+ const boundaryReady = primeRequests.filter(
2086
+ (request) => getBoundaryLmState(request.contextKey) !== null,
2087
+ ).length;
2088
+ const artifactEligible = canonicalMatched.filter(
2089
+ (candidate) => candidate.canonicalTokenIds !== null,
2090
+ ).length;
2091
+ const documentStartFallbacks = canonicalMatched.filter(
2092
+ (candidate) => candidate.separatorKind === 'document-start',
2093
+ ).length;
2094
+ const punctuationFallbacks = canonicalMatched.filter(
2095
+ (candidate) => candidate.separatorKind === 'non-space',
2096
+ ).length;
599
2097
  const logitsStatus =
600
- lmLogits && Object.keys(lmLogits).length > 0
601
- ? `✅ LM logits active (${Object.keys(lmLogits).length} tokens)`
602
- : '⏳ No LM logits (slow-lane pending or failed)';
603
- // eslint-disable-next-line no-console
604
- console.log(
605
- '%cSlow Lane:',
606
- 'color: #888; font-style: italic;',
607
- vectorStatus,
608
- '|',
609
- logitsStatus,
2098
+ tierACount + exactCount > 0
2099
+ ? `canonical ✅ first-token:${tierACount} exact:${exactCount}`
2100
+ : logitCount > 0
2101
+ ? `network logits ✅ ${logitCount}`
2102
+ : 'canonical ⏳/absent';
2103
+ const formula =
2104
+ tierACount + exactCount + logitCount > 0
2105
+ ? `Stage1×${STAGE1_WEIGHT}+LM×${STAGE2_WEIGHT}`
2106
+ : 'Stage1-only';
2107
+ ctcSection('SIGNALS', `${semanticStatus} · ${logitsStatus} · ${formula}`);
2108
+ ctcSection(
2109
+ 'CANONICAL',
2110
+ `eligible ${artifactEligible}/${canonicalMatched.length} · separator fallback doc:${documentStartFallbacks} punct:${punctuationFallbacks} · contexts ${boundaryReady}/${primeRequests.length} ready (≤${PHRASE_MAX_WORDS}) · family=${familyKey.slice(0, 72) || 'none'}`,
610
2111
  );
2112
+ if (verbose && logitCount > 0 && lmLogits) {
2113
+ const rawLmTop = Object.entries(lmLogits)
2114
+ .sort((a, b) => b[1] - a[1])
2115
+ .slice(0, 5)
2116
+ .map(([word, score]) => `${word}:${score.toFixed(3)}`)
2117
+ .join(', ');
2118
+ ctcSection(' rawLM', `🧠 ${rawLmTop}`);
2119
+ }
611
2120
 
612
- // 4. Scoring formula active this prediction
613
- const formulaLabel =
614
- lmLogits && Object.keys(lmLogits).length > 0
615
- ? `Stage1(×${STAGE1_WEIGHT}) + LM(×${STAGE2_WEIGHT})`
616
- : 'Stage1 only (no LM logits)';
617
- // eslint-disable-next-line no-console
618
- console.log('%cFormula:', 'color: #888; font-style: italic;', formulaLabel);
2121
+ // GENERATE — how many candidates matched, split by term type
2122
+ const genByType: Record<TermType, number> = { word: 0, bigram: 0, phrase: 0 };
2123
+ for (const m of canonicalMatched) {
2124
+ genByType[m.node.termType] += 1;
2125
+ }
2126
+ ctcSection(
2127
+ 'GENERATE',
2128
+ `matched ${canonicalMatched.length} → word:${genByType.word} bigram:${genByType.bigram} phrase:${genByType.phrase}${canonicalLmSupported ? ' · display needs exact surface evidence' : ''}`,
2129
+ );
619
2130
 
620
- // 5. Grammar filter result (collected inside rankCandidates, logged here)
2131
+ // SCORE — Stage-1 + grammar funnel and canonical evidence depth.
2132
+ const lmCoverage = ranked.slice(0, 10).filter((r) => r.lmScore > 0).length;
2133
+ ctcSection(
2134
+ 'SCORE',
2135
+ `in ${pipelineDebug.initial} → stage1(<${MIN_STAGE1_SCORE}) −${pipelineDebug.stage1Rejected.length} → grammar −${pipelineDebug.grammarRejected.length} → final ${pipelineDebug.final} · LM cov ${lmCoverage}/${Math.min(ranked.length, 10)} · first-token ${tierACount} · exact ${exactCount}`,
2136
+ );
621
2137
  if (grammarMeta) {
622
- // eslint-disable-next-line no-console
623
- console.log(
624
- `%c[Grammar] "${grammarMeta.prevWord}" [${grammarMeta.prevTags.join('|')}] → ${grammarMeta.before} candidates → ${grammarMeta.after} after filter`,
625
- 'color: #4caf50; font-weight: bold;',
2138
+ ctcSection(
2139
+ ' grammar',
2140
+ `"${grammarMeta.prevWord}" [${grammarMeta.prevTags.join('|')}] ${grammarMeta.before}→${grammarMeta.after}${verbose && grammarMeta.dropped.length > 0 ? ` · dropped: ${grammarMeta.dropped.join(', ')}` : ''}`,
626
2141
  );
627
- if (grammarMeta.dropped.length > 0) {
628
- // eslint-disable-next-line no-console
629
- console.log(
630
- `%c🚫 Dropped: ${grammarMeta.dropped.join(', ')}`,
631
- 'color: #f44336; font-style: italic;',
632
- );
633
- }
634
2142
  }
635
2143
 
636
- // 6. Pipeline funnel
637
- // eslint-disable-next-line no-console
638
- console.log(
639
- `%c[Pipeline Funnel] %c📥 In: ${pipelineDebug.initial} | ❌ Stage 1 (< ${MIN_STAGE1_SCORE}): -${pipelineDebug.stage1Rejected.length} | ❌ Grammar: -${pipelineDebug.grammarRejected.length} | ✅ Final: ${pipelineDebug.final}`,
640
- 'color: #9c27b0; font-weight: bold;',
641
- 'color: inherit;',
2144
+ const gateReasonFor = (candidate: ScoredCandidate): string => {
2145
+ const runtime = runtimeBySurface.get(candidate.word);
2146
+ if (runtime?.separatorKind !== 'whitespace') {
2147
+ return `separator:${runtime?.separatorKind ?? 'unknown'}`;
2148
+ }
2149
+ if (runtime.canonicalTokenIds === null) {
2150
+ return 'missing-artifact';
2151
+ }
2152
+ if (!hasJudgeableEvidence(candidate)) {
2153
+ return `awaiting-tokens:${REQUIRED_VERIFIED_TOKENS}`;
2154
+ }
2155
+ if (!passesLmFloor(candidate)) {
2156
+ return `bound:${optimisticPosterior(candidate).toFixed(2)}<${MIN_LM_POSTERIOR[candidate.termType]}`;
2157
+ }
2158
+ if (duplicatesPreceding(candidate)) {
2159
+ return 'repetition';
2160
+ }
2161
+ if (isCooledDown(candidate)) {
2162
+ return 'cooldown';
2163
+ }
2164
+ if (!isWorthShowing(candidate)) {
2165
+ return `suffix:${suggestionLengthFor(candidate)}<${MIN_SUGGESTION_LENGTH}`;
2166
+ }
2167
+ const floor = MIN_LM_POSTERIOR[candidate.termType];
2168
+ const posterior = judgedPosterior(candidate);
2169
+ if (posterior < floor) {
2170
+ return `posterior:${posterior.toFixed(2)}<${floor}`;
2171
+ }
2172
+ if (!isPlausibleSurface(candidate)) {
2173
+ return `implausible:${(judgedEvidenceFor(candidate)?.mean ?? NaN).toFixed(2)}<${MIN_MEAN_TOKEN_LOG_PROBABILITY}`;
2174
+ }
2175
+ return 'eligible';
2176
+ };
2177
+ const gateFunnel = (termType: TermType): string => {
2178
+ const typeRanked = ranked.filter((candidate) => candidate.termType === termType);
2179
+ const exact = typeRanked.filter(hasJudgeableEvidence);
2180
+ const absolute = exact.filter(passesLmFloor);
2181
+ const stabilized = absolute.filter(isStabilized);
2182
+ const longEnough = stabilized.filter(isWorthShowing);
2183
+ const floorPassed = longEnough.filter(
2184
+ (candidate) => judgedPosterior(candidate) >= MIN_LM_POSTERIOR[termType],
2185
+ );
2186
+ const plausiblePassed = floorPassed.filter(isPlausibleSurface);
2187
+ return `${termType} m:${genByType[termType]} r:${typeRanked.length} exact:${exact.length} abs:${absolute.length} stable:${stabilized.length} suffix:${longEnough.length} floor:${floorPassed.length} plausible:${plausiblePassed.length} eligible:${eligible.filter(({ candidate }) => candidate.termType === termType).length}`;
2188
+ };
2189
+ ctcSection(
2190
+ 'GATES',
2191
+ `${gateFunnel('word')} | ${gateFunnel('bigram')} | ${gateFunnel('phrase')}`,
642
2192
  );
643
2193
 
644
- // 7. Candidate table
645
- if (ranked.length > 0) {
646
- const lmCoverage = ranked.slice(0, 10).filter((r) => r.lmScore > 0).length;
647
- // eslint-disable-next-line no-console
648
- console.log(
649
- `%cLM coverage: ${lmCoverage}/${Math.min(ranked.length, 10)} candidates had real logit scores`,
650
- 'color: #888; font-style: italic;',
651
- );
2194
+ // One line per context: how many candidates share the normaliser, and how
2195
+ // much of the mass the leader holds. A leader well under its threshold
2196
+ // means the context is contested, which is the abstention we want.
2197
+ const contextLeaders = Array.from(bestCandidateByContext.entries())
2198
+ .slice(0, PHRASE_MAX_WORDS)
2199
+ .map(([contextKey, leader]) => {
2200
+ const shortlistSize = contextTotals.get(contextKey)?.length ?? 0;
2201
+ const evidenceKind = hasExactEvidence(leader) ? 'exact' : 'upper';
2202
+ return `${contextKey.slice(0, 32)} → n=${shortlistSize} ${leader.termType}:"${leader.word}" ${evidenceKind} p=${optimisticPosterior(leader).toFixed(3)}/${MIN_LM_POSTERIOR[leader.termType]}`;
2203
+ })
2204
+ .join(' | ');
2205
+ ctcSection(
2206
+ 'NORMALIZE',
2207
+ contextLeaders.length > 0 ? contextLeaders : 'no scored candidate in any context',
2208
+ );
2209
+ ctcSection(
2210
+ 'READINESS',
2211
+ `prefix ${currentWord.length}/${DISPLAY_MIN_PREFIX_LENGTH} · boundary ${boundaryReady}/${primeRequests.length} · exact word:${ranked.filter((candidate) => candidate.termType === 'word' && hasExactEvidence(candidate)).length} bigram:${ranked.filter((candidate) => candidate.termType === 'bigram' && hasExactEvidence(candidate)).length} phrase:${ranked.filter((candidate) => candidate.termType === 'phrase' && hasExactEvidence(candidate)).length} · ${suggestion ? 'display-ready' : decision}`,
2212
+ );
652
2213
 
2214
+ // ARBITRATE — expose every condition that can authorize or suppress ghost.
2215
+ const topOfType = (t: TermType): ScoredCandidate | undefined =>
2216
+ ranked.find((c) => c.termType === t);
2217
+ const fmtType = (t: TermType): string => {
2218
+ const top = topOfType(t);
2219
+ if (!top) {
2220
+ return `${t}:—`;
2221
+ }
2222
+ const floor = MIN_LM_POSTERIOR[t];
2223
+ const bound = optimisticPosterior(top);
2224
+ const evidence = hasRequiredEvidence(top)
2225
+ ? 'TierB✅'
2226
+ : `${top.lmEvidence?.source ?? 'absent'}❌`;
2227
+ return `${t}:"${top.word}" p≤${bound.toFixed(3)}${bound >= floor ? '≥' : '<'}${floor} fs≤${confidenceScore(top).toFixed(2)} ${evidence}`;
2228
+ };
2229
+ ctcSection(
2230
+ 'ARBITRATE',
2231
+ `${selected ? `▸ ${selected.termType} "${selected.word}" fs=${selectedDisplayScore.toFixed(2)} mean=${selectedEvidence?.meanTokenLogProbability?.toFixed(2) ?? 'network'} margin=${winnerMargin.toFixed(2)}${clearsWinnerMargin ? '✅' : '❌'}` : '▸ (none)'} · prefix ${currentWord.length}/${DISPLAY_MIN_PREFIX_LENGTH}${displayPrefixReady ? '✅' : '⏳'} · grouped pending ${hasUnresolvedPotential ? 'yes' : 'no'} | ${fmtType('word')} ${fmtType('bigram')} ${fmtType('phrase')}`,
2232
+ );
2233
+
2234
+ // STABILIZE — post-accept cooldown + whole-surface repetition (QI-2)
2235
+ const repetitionBlocked = ranked
2236
+ .filter((c) => hasRequiredEvidence(c) && duplicatesPreceding(c))
2237
+ .map((c) => c.word);
2238
+ const cooldownLabel = cooledSurface
2239
+ ? `cooldown "${cooledSurface}" (${acceptCooldown?.predictionsSince ?? 0}/${COOLDOWN_KEYSTROKES} keys · ${COOLDOWN_MS}ms)`
2240
+ : 'cooldown —';
2241
+ ctcSection(
2242
+ 'STABILIZE',
2243
+ `${cooldownLabel} · repetition-blocked: ${repetitionBlocked.length > 0 ? repetitionBlocked.join(', ') : '—'}`,
2244
+ );
2245
+
2246
+ // CANDIDATES — full scored table (verbose only)
2247
+ if (verbose && ranked.length > 0) {
653
2248
  const tableData = ranked.slice(0, 10).map((r) => {
654
2249
  let rawLogit: string | number = 'Not in Payload';
655
- if (prefixLmLogits) {
2250
+ if (r.lmEvidence?.rawLogit !== undefined) {
2251
+ rawLogit = Number(r.lmEvidence.rawLogit.toFixed(5));
2252
+ } else if (prefixLmLogits) {
656
2253
  const val = prefixLmLogits[r.word.toLowerCase()];
657
2254
  if (val !== undefined) {
658
2255
  rawLogit = Number(val.toFixed(5));
@@ -660,9 +2257,19 @@ export const predict = (textBefore: string): string | null => {
660
2257
  }
661
2258
 
662
2259
  const original = scoringCandidates.find((sc) => sc.word === r.word);
2260
+ const runtime = runtimeBySurface.get(r.word);
2261
+ const progress = runtime ? getProgressiveSurfaceEvidence(runtime.contextKey, r.word) : null;
2262
+ const optimisticTotal = optimisticTotalByWord.get(r.word);
2263
+ const normalizer = runtime ? logSumExpByContext.get(runtime.contextKey) : undefined;
2264
+ const shortlistSize = runtime ? (contextTotals.get(runtime.contextKey)?.length ?? 0) : 0;
2265
+ const contextLeader = runtime ? bestCandidateByContext.get(runtime.contextKey) : undefined;
663
2266
  let source = 'Unknown';
664
2267
  if (original) {
665
- if (original.docFreq === 0 && original.tenantFreq === L3_BASELINE_FREQ) {
2268
+ if (original.termType === 'bigram') {
2269
+ source = '🔗 L2 (Bigram)';
2270
+ } else if (original.termType === 'phrase') {
2271
+ source = '🧩 L2 (Phrase)';
2272
+ } else if (original.docFreq === 0 && original.tenantFreq === L3_BASELINE_FREQ) {
666
2273
  source = '🌍 L3 (Generic)';
667
2274
  } else if (original.sessionFreq > 0 && original.tenantFreq === 0) {
668
2275
  source = '👤 L1 (Session Only)';
@@ -671,13 +2278,70 @@ export const predict = (textBefore: string): string | null => {
671
2278
  }
672
2279
  }
673
2280
 
2281
+ const floor = MIN_LM_POSTERIOR[r.termType];
674
2282
  return {
675
2283
  Candidate: r.word,
676
2284
  Source: source,
2285
+ Type: r.termType,
677
2286
  'Final Score': Number(r.finalScore.toFixed(4)),
2287
+ 'Bounded Score': Number(confidenceScore(r).toFixed(4)),
2288
+ 'Exact Gate Score': hasExactEvidence(r)
2289
+ ? Number(exactConfidenceScore(r).toFixed(4))
2290
+ : '—',
2291
+ Floor: floor,
2292
+ 'Posterior Floor': optimisticPosterior(r) >= floor ? '✅' : '❌',
2293
+ 'LM Floor': hasExactEvidence(r) ? (passesLmFloor(r) ? '✅' : '❌') : '⏳',
2294
+ 'Gate Reason': gateReasonFor(r),
2295
+ 'Display Eligible':
2296
+ hasRequiredEvidence(r) && isStabilized(r) && judgedPosterior(r) >= floor ? '✅' : '❌',
678
2297
  Semantics: Number(r.semanticScore.toFixed(4)),
679
2298
  Freq: Number(r.freqScore.toFixed(4)),
680
2299
  'LM Score': Number(r.lmScore.toFixed(4)),
2300
+ 'LM Evidence': r.lmEvidence?.source ?? 'absent',
2301
+ 'Verified Tokens': r.lmEvidence
2302
+ ? `${r.lmEvidence.verifiedTokenCount}/${r.lmEvidence.totalTokenCount}`
2303
+ : '0/0',
2304
+ 'Progressive Tokens': progress
2305
+ ? `${progress.scoredTokenCount}/${progress.totalTokenCount}`
2306
+ : '0/0',
2307
+ 'LM Mean Upper':
2308
+ progress !== null ? Number(progress.meanTokenLogProbabilityUpperBound.toFixed(5)) : '—',
2309
+ 'Optimistic Total':
2310
+ optimisticTotal !== undefined ? Number(optimisticTotal.toFixed(5)) : '—',
2311
+ 'Context logSumExp':
2312
+ normalizer !== undefined && Number.isFinite(normalizer)
2313
+ ? Number(normalizer.toFixed(5))
2314
+ : '—',
2315
+ 'Shortlist Size': shortlistSize,
2316
+ 'Posterior (bound)': Number(optimisticPosterior(r).toFixed(5)),
2317
+ 'Posterior (judged)': hasJudgeableEvidence(r)
2318
+ ? Number(judgedPosterior(r).toFixed(5))
2319
+ : '—',
2320
+ 'Context Leader': contextLeader ? `${contextLeader.termType}:${contextLeader.word}` : '—',
2321
+ 'Verified Chars': r.lmEvidence
2322
+ ? `${r.lmEvidence.verifiedCharCount}/${r.lmEvidence.totalSurfaceCharCount}`
2323
+ : `0/${r.word.length}`,
2324
+ Separator: runtime?.separatorKind ?? 'unknown',
2325
+ 'Canonical Eligibility':
2326
+ runtime?.separatorKind !== 'whitespace'
2327
+ ? 'incompatible-separator'
2328
+ : runtime.canonicalTokenIds === null
2329
+ ? 'missing-artifact'
2330
+ : 'eligible',
2331
+ 'Artifact Tokens': runtime?.canonicalTokenIds?.join(',') ?? 'absent',
2332
+ 'Context Key': runtime?.contextKey ?? '—',
2333
+ 'Exact Total':
2334
+ r.lmEvidence?.totalLogProbability !== undefined
2335
+ ? Number(r.lmEvidence.totalLogProbability.toFixed(5))
2336
+ : '—',
2337
+ 'Exact Mean':
2338
+ r.lmEvidence?.meanTokenLogProbability !== undefined
2339
+ ? Number(r.lmEvidence.meanTokenLogProbability.toFixed(5))
2340
+ : '—',
2341
+ Assoc:
2342
+ typeof original?.association === 'number'
2343
+ ? Number(original.association.toFixed(4))
2344
+ : '—',
681
2345
  'Raw Logit': rawLogit,
682
2346
  'Session Freq': original?.sessionFreq || 0,
683
2347
  };
@@ -685,16 +2349,38 @@ export const predict = (textBefore: string): string | null => {
685
2349
 
686
2350
  // eslint-disable-next-line no-console
687
2351
  console.table(tableData);
688
- } else {
689
- // eslint-disable-next-line no-console
690
- console.log('No candidates found.');
691
2352
  }
692
2353
 
693
2354
  // eslint-disable-next-line no-console
694
2355
  console.groupEnd();
695
2356
  }
696
2357
 
697
- return suggestion && suggestion.length > 0 ? suggestion : null;
2358
+ const selectedContextKey = selected ? runtimeBySurface.get(selected.word)?.contextKey : undefined;
2359
+
2360
+ return suggestion && suggestion.length > 0 && selected && selectedEvidence
2361
+ ? {
2362
+ text: suggestion,
2363
+ surface: selected.word,
2364
+ termType: selected.termType,
2365
+ posterior: judgedPosterior(selected),
2366
+ meanTokenLogProbability: judgedEvidenceFor(selected)?.mean ?? NaN,
2367
+ poolHeldExtension: poolExtensionOf(selected) !== undefined,
2368
+ shortlistSize:
2369
+ selectedContextKey === undefined
2370
+ ? 1
2371
+ : (contextTotals.get(selectedContextKey)?.length ?? 1),
2372
+ rankScore: selectedDisplayScore,
2373
+ evidenceTier:
2374
+ selectedEvidence?.source === 'network-logit' ? 'network-logit' : 'canonical-full-surface',
2375
+ evidenceDepth: {
2376
+ verifiedTokens: selectedEvidence?.verifiedTokenCount ?? 0,
2377
+ totalTokens: selectedEvidence?.totalTokenCount ?? 0,
2378
+ verifiedChars: selectedEvidence?.verifiedCharCount ?? 0,
2379
+ totalChars: selected.word.length,
2380
+ },
2381
+ winnerMargin,
2382
+ }
2383
+ : null;
698
2384
  };
699
2385
 
700
2386
  // ─── Data Loading ────────────────────────────────────────────────────────────
@@ -710,6 +2396,100 @@ interface VocabularyJson {
710
2396
  >;
711
2397
  }
712
2398
 
2399
+ const isVocabularyJson = (payload: unknown): payload is VocabularyJson => {
2400
+ if (payload == null || typeof payload !== 'object') {
2401
+ return false;
2402
+ }
2403
+ const words = (payload as { words?: unknown }).words;
2404
+ return words != null && typeof words === 'object';
2405
+ };
2406
+
2407
+ const isStringArray = (payload: unknown): payload is string[] =>
2408
+ Array.isArray(payload) && payload.every((entry) => typeof entry === 'string');
2409
+
2410
+ type WordIndexPayload = Record<string, number> | { index: Record<string, number> };
2411
+
2412
+ const isWordToOffsetMap = (value: unknown): value is Record<string, number> =>
2413
+ value != null &&
2414
+ typeof value === 'object' &&
2415
+ Object.values(value as Record<string, unknown>).every((entry) => typeof entry === 'number');
2416
+
2417
+ const isWordIndexPayload = (payload: unknown): payload is WordIndexPayload => {
2418
+ if (payload == null || typeof payload !== 'object') {
2419
+ return false;
2420
+ }
2421
+ const index = (payload as { index?: unknown }).index;
2422
+ return index === undefined ? isWordToOffsetMap(payload) : isWordToOffsetMap(index);
2423
+ };
2424
+
2425
+ /**
2426
+ * The word index has shipped both bare and wrapped in `{ index: ... }`, so
2427
+ * accept either rather than silently building an empty vector store.
2428
+ */
2429
+ const unwrapWordIndex = (payload: WordIndexPayload): Record<string, number> => {
2430
+ const wrapped = (payload as { index?: Record<string, number> }).index;
2431
+ return wrapped ?? (payload as Record<string, number>);
2432
+ };
2433
+
2434
+ /**
2435
+ * Per-term stats shape for `bigrams.json` / `phrases.json`. Field names/types
2436
+ * confirmed against the producer (`l2_vocabulary_creation._build_ngram_stat_payload`
2437
+ * + `artifact_release._validate_ngram_payload`): `freq`/`doc_freq`/`author_freq`
2438
+ * are ints, `pos_seq` a string array, `head_pos` a string or null, `association`
2439
+ * a number.
2440
+ */
2441
+ interface PhraseStatsJson {
2442
+ association?: number;
2443
+ author_freq: number;
2444
+ doc_freq: number;
2445
+ freq: number;
2446
+ head_pos?: string | null;
2447
+ pos_seq?: string[];
2448
+ }
2449
+
2450
+ /** Normalized `{term: stats}` map after unwrapping any producer wrapper key. */
2451
+ type PhraseArtifactJson = Record<string, PhraseStatsJson>;
2452
+
2453
+ const isPhraseStats = (value: unknown): value is PhraseStatsJson =>
2454
+ value != null &&
2455
+ typeof value === 'object' &&
2456
+ typeof (value as { freq?: unknown }).freq === 'number';
2457
+
2458
+ const isTermStatsMap = (value: unknown): value is PhraseArtifactJson => {
2459
+ if (value == null || typeof value !== 'object') {
2460
+ return false;
2461
+ }
2462
+ const values = Object.values(value as Record<string, unknown>);
2463
+ return values.length > 0 && values.every(isPhraseStats);
2464
+ };
2465
+
2466
+ /**
2467
+ * Normalize a `bigrams.json` / `phrases.json` payload into a flat
2468
+ * `{term: stats}` map. Tolerant of the exact wire shape (not yet finalized by
2469
+ * the producer): accepts either the flat map from spec §9 or a single wrapper
2470
+ * key (`terms`/`bigrams`/`phrases`/`words`). Returns `null` if unrecognized.
2471
+ *
2472
+ * :params:
2473
+ * payload: Raw JSON parsed from the artifact endpoint
2474
+ * :returns:
2475
+ * A `{term: stats}` map, or `null` when the shape is not a term-stats map
2476
+ */
2477
+ const normalizePhraseArtifact = (payload: unknown): PhraseArtifactJson | null => {
2478
+ if (payload == null || typeof payload !== 'object') {
2479
+ return null;
2480
+ }
2481
+ if (isTermStatsMap(payload)) {
2482
+ return payload;
2483
+ }
2484
+ const obj = payload as Record<string, unknown>;
2485
+ for (const key of ['terms', 'bigrams', 'phrases', 'words']) {
2486
+ if (isTermStatsMap(obj[key])) {
2487
+ return obj[key] as PhraseArtifactJson;
2488
+ }
2489
+ }
2490
+ return null;
2491
+ };
2492
+
713
2493
  export const loadVectorsAsync = async (options?: {
714
2494
  isLocalLLM?: boolean;
715
2495
  surface?: string;
@@ -725,57 +2505,26 @@ export const loadVectorsAsync = async (options?: {
725
2505
  ...(surface ? { surface } : {}),
726
2506
  });
727
2507
 
728
- let url: string;
729
- try {
730
- url = await getArtifactUrl(ARTIFACT_NAME.WORD_VECTORS);
731
- } catch (e) {
732
- vectorsLoadStarted = false;
733
- failExp(EXPERIENCE_NAME.LOAD_VECTORS, 'singleton', {
734
- isLocalLLM,
735
- errorType: 'resolve_url',
736
- ...(surface ? { surface } : {}),
737
- });
738
- // eslint-disable-next-line no-console
739
- console.warn('[text-predictor] Failed to resolve vectors URL:', e);
740
- return;
741
- }
742
-
743
2508
  try {
744
- const res = await fetch(url);
745
- if (!res.ok) {
746
- vectorsLoadStarted = false;
747
- failExp(EXPERIENCE_NAME.LOAD_VECTORS, 'singleton', {
748
- isLocalLLM,
749
- status: res.status,
750
- errorType: 'http_error',
751
- ...(surface ? { surface } : {}),
752
- });
753
- // eslint-disable-next-line no-console
754
- console.warn(`[text-predictor] Failed to load vectors: ${res.status}`);
755
- return;
756
- }
757
- const buffer = await res.arrayBuffer();
2509
+ const buffer = await fetchAutocompleteArtifactBinary(
2510
+ ARTIFACT_NAME.WORD_VECTORS,
2511
+ 'word_vectors_10k',
2512
+ );
758
2513
  const float32 = new Float32Array(buffer);
759
-
760
- // The word index is published either bare or wrapped as `{ "index": {…} }`
761
- // (the wrapper exists so no real entry, e.g. the word "default", can shadow
762
- // a bundler-synthesised ESM export). Detect the wrapper by value type, not
763
- // by key presence — "index" is itself a plausible vocabulary word, and in a
764
- // bare index every value is a number.
765
- const wordIndexPayload = await fetchArtifactJson<Record<string, unknown>>(
2514
+ const wordIndexPayload = await fetchAutocompleteArtifactJson<WordIndexPayload>(
766
2515
  ARTIFACT_NAME.WORD_INDEX,
2516
+ {
2517
+ summarize: (payload) => `${Object.keys(unwrapWordIndex(payload)).length} entries`,
2518
+ validate: isWordIndexPayload,
2519
+ },
767
2520
  );
768
- const wrappedIndex = wordIndexPayload?.index;
769
- const wordIndex = (
770
- typeof wrappedIndex === 'object' && wrappedIndex !== null
771
- ? wrappedIndex
772
- : (wordIndexPayload ?? {})
773
- ) as Record<string, number>;
2521
+ const wordIndex = unwrapWordIndex(wordIndexPayload);
774
2522
  const nWords = Object.keys(wordIndex).length;
775
2523
  if (nWords === 0) {
776
- // eslint-disable-next-line no-console
777
- console.warn(
778
- `[text-predictor] ${ARTIFACT_NAME.WORD_INDEX} was empty — semantic scoring will be a no-op.`,
2524
+ ctcTag(
2525
+ 'init',
2526
+ '⚠️ word_index_10k.json was empty — semantic scoring will be a no-op',
2527
+ CTC_STYLES.warn,
779
2528
  );
780
2529
  }
781
2530
  const dim = float32.length / nWords;
@@ -788,14 +2537,7 @@ export const loadVectorsAsync = async (options?: {
788
2537
  sizeBytes: float32.byteLength,
789
2538
  ...(surface ? { surface } : {}),
790
2539
  });
791
- if (isAutocompleteDebugEnabled()) {
792
- // eslint-disable-next-line no-console
793
- console.log('[text-predictor] Vectors loaded:', {
794
- wordCount: nWords,
795
- dim,
796
- sizeBytes: float32.byteLength,
797
- });
798
- }
2540
+ ctcTag('init', `vectors loaded: ${nWords} words · dim ${dim} · ${float32.byteLength}B`);
799
2541
  } catch (e) {
800
2542
  vectorsLoadStarted = false;
801
2543
  failExp(EXPERIENCE_NAME.LOAD_VECTORS, 'singleton', {
@@ -803,8 +2545,7 @@ export const loadVectorsAsync = async (options?: {
803
2545
  errorType: 'network',
804
2546
  ...(surface ? { surface } : {}),
805
2547
  });
806
- // eslint-disable-next-line no-console
807
- console.warn('[text-predictor] Failed to load vectors:', e);
2548
+ ctcTag('init', `⚠️ failed to load vectors: ${String(e)}`, CTC_STYLES.bad);
808
2549
  }
809
2550
  };
810
2551
 
@@ -812,10 +2553,92 @@ export const initVectors = (store: VectorStore): void => {
812
2553
  vectorStore = store;
813
2554
  };
814
2555
 
2556
+ /**
2557
+ * Load the producer's `bigrams.json` + `phrases.json` completion-term artifacts
2558
+ * over the TDP-OS gateway and insert them into the phrase trie.
2559
+ *
2560
+ * Each file is fetched independently (`Promise.allSettled`) so a missing or
2561
+ * late-published artifact only skips that term type — word completion and the
2562
+ * other term type are unaffected. Phrase VECTORS are not fetched here: per the
2563
+ * producer's `extend` decision they are appended into the existing
2564
+ * `word-index-10k.json` / `word-vectors-10k.bin`, so `loadVectorsAsync` already
2565
+ * covers them.
2566
+ *
2567
+ * :params:
2568
+ * options.isLocalLLM: Tags the UFO experience so latency/success feeds the same SLO
2569
+ * :returns:
2570
+ * A promise that resolves once both fetches have settled
2571
+ */
2572
+ export const loadPhraseArtifacts = async (options?: { isLocalLLM?: boolean }): Promise<void> => {
2573
+ if (phrasesLoadStarted) {
2574
+ return;
2575
+ }
2576
+ phrasesLoadStarted = true;
2577
+ const isLocalLLM = options?.isLocalLLM ?? false;
2578
+ startExp(EXPERIENCE_NAME.LOAD_PHRASES, 'singleton', { isLocalLLM });
2579
+
2580
+ const loadOne = async (
2581
+ artifactName: ArtifactName,
2582
+ termType: TermType,
2583
+ label: string,
2584
+ ): Promise<number> => {
2585
+ const payload = await fetchAutocompleteArtifactJson<unknown>(artifactName, {
2586
+ label,
2587
+ validate: (p): p is unknown => p != null && typeof p === 'object',
2588
+ });
2589
+ const normalized = normalizePhraseArtifact(payload);
2590
+ if (!normalized) {
2591
+ throw new Error(`[text-predictor] ${label} payload was not a recognised term-stats map`);
2592
+ }
2593
+ return initPhrases(normalized, termType);
2594
+ };
2595
+
2596
+ const [bigramsResult, phrasesResult] = await Promise.allSettled([
2597
+ loadOne(ARTIFACT_NAME.BIGRAMS, 'bigram', 'bigrams'),
2598
+ loadOne(ARTIFACT_NAME.PHRASES, 'phrase', 'phrases'),
2599
+ ]);
2600
+
2601
+ const bigramCount = bigramsResult.status === 'fulfilled' ? bigramsResult.value : 0;
2602
+ const phraseCount = phrasesResult.status === 'fulfilled' ? phrasesResult.value : 0;
2603
+
2604
+ if (bigramsResult.status === 'rejected') {
2605
+ ctcTag(
2606
+ 'init',
2607
+ `⚠️ failed to load bigrams.json: ${String(bigramsResult.reason)}`,
2608
+ CTC_STYLES.warn,
2609
+ );
2610
+ }
2611
+ if (phrasesResult.status === 'rejected') {
2612
+ ctcTag(
2613
+ 'init',
2614
+ `⚠️ failed to load phrases.json: ${String(phrasesResult.reason)}`,
2615
+ CTC_STYLES.warn,
2616
+ );
2617
+ }
2618
+
2619
+ if (bigramsResult.status === 'fulfilled' || phrasesResult.status === 'fulfilled') {
2620
+ phrasesLoaded = true;
2621
+ succeedExp(EXPERIENCE_NAME.LOAD_PHRASES, 'singleton', {
2622
+ isLocalLLM,
2623
+ bigramCount,
2624
+ phraseCount,
2625
+ });
2626
+ ctcTag('init', `phrase artifacts loaded: ${bigramCount} bigrams · ${phraseCount} phrases`);
2627
+ } else {
2628
+ // Both failed — e.g. artifacts not published to the tenant manifest yet.
2629
+ // Reset the guard so a later focus/predict can retry once they land.
2630
+ phrasesLoadStarted = false;
2631
+ failExp(EXPERIENCE_NAME.LOAD_PHRASES, 'singleton', { isLocalLLM, errorType: 'network' });
2632
+ }
2633
+ };
2634
+
815
2635
  let vocabularyLoadPromise: Promise<void> | undefined;
816
2636
 
2637
+ type VocabularyLoadSource = 'focus' | 'predict';
2638
+
817
2639
  export const loadDefaultVocabulary = (options?: {
818
2640
  isLocalLLM?: boolean;
2641
+ source?: VocabularyLoadSource;
819
2642
  surface?: string;
820
2643
  }): Promise<void> => {
821
2644
  if (isInitialized) {
@@ -826,6 +2649,7 @@ export const loadDefaultVocabulary = (options?: {
826
2649
  }
827
2650
 
828
2651
  const isLocalLLM = options?.isLocalLLM ?? false;
2652
+ const source = options?.source ?? 'predict';
829
2653
  const surface = options?.surface;
830
2654
  vocabularyLoadPromise = (async () => {
831
2655
  startExp(EXPERIENCE_NAME.LOAD_VOCABULARY, 'singleton', {
@@ -834,19 +2658,56 @@ export const loadDefaultVocabulary = (options?: {
834
2658
  });
835
2659
 
836
2660
  try {
837
- const [vocabularyData, l3VocabularyData] = await Promise.all([
838
- fetchArtifactJson<VocabularyJson>(ARTIFACT_NAME.VOCABULARY),
839
- fetchArtifactJson<string[]>(ARTIFACT_NAME.L3_VOCABULARY),
840
- ]);
841
-
842
- if (vocabularyData?.words == null || !Array.isArray(l3VocabularyData)) {
843
- throw new Error(
844
- `[text-predictor] unexpected vocabulary payload — ` +
845
- `${ARTIFACT_NAME.VOCABULARY} ${vocabularyData?.words == null ? 'is missing its `words` wrapper' : 'is valid'}, ` +
846
- `${ARTIFACT_NAME.L3_VOCABULARY} ${Array.isArray(l3VocabularyData) ? 'is valid' : 'is not an array'}`,
2661
+ ctcTag('init', `loading artifacts (trigger: ${source} · localLLM: ${isLocalLLM})`);
2662
+ if (isAutocompleteDebugVerbose()) {
2663
+ // eslint-disable-next-line no-console
2664
+ console.groupCollapsed(
2665
+ '%c[CTC:init]%c artifact manifest',
2666
+ CTC_STYLES.brand,
2667
+ CTC_STYLES.body,
847
2668
  );
2669
+ // eslint-disable-next-line no-console
2670
+ console.log('Eager:', [
2671
+ ARTIFACT_NAME.VOCABULARY,
2672
+ ARTIFACT_NAME.L3_VOCABULARY,
2673
+ ARTIFACT_NAME.POS_TAGS,
2674
+ ARTIFACT_NAME.GHOST_POS_TAGS,
2675
+ ARTIFACT_NAME.GRAMMAR_TRANSITIONS,
2676
+ ]);
2677
+ // eslint-disable-next-line no-console
2678
+ console.log('Conditional:', [
2679
+ `${ARTIFACT_NAME.WORD_INDEX} (only when vectors load; phrase/bigram surfaces share this index)`,
2680
+ `${ARTIFACT_NAME.BIGRAMS} + ${ARTIFACT_NAME.PHRASES} (completion-term stats; fire-and-forget)`,
2681
+ `${ARTIFACT_NAME.PHRASE_CONTINUATION_TOKENS} (local model only; canonical token IDs for the full served union)`,
2682
+ ]);
2683
+ // eslint-disable-next-line no-console
2684
+ console.groupEnd();
848
2685
  }
849
2686
 
2687
+ const [vocabularyData, l3VocabularyData] = await Promise.all([
2688
+ fetchAutocompleteArtifactJson<VocabularyJson>(ARTIFACT_NAME.VOCABULARY, {
2689
+ summarize: (payload) => `${Object.keys(payload.words).length} words`,
2690
+ validate: isVocabularyJson,
2691
+ }),
2692
+ fetchAutocompleteArtifactJson<string[]>(ARTIFACT_NAME.L3_VOCABULARY, {
2693
+ summarize: (payload) => `${payload.length} words`,
2694
+ validate: isStringArray,
2695
+ }),
2696
+ // Awaited alongside the vocabulary so the grammar filter is settled
2697
+ // before the first suggestion can be produced. Without this the
2698
+ // predictor initialises on the smaller vocabulary payload and serves
2699
+ // candidates the filter would have dropped until the grammar lands.
2700
+ // Only settlement is required: a failed load leaves the filter as a
2701
+ // pass-through, which is the same degradation as an absent artifact.
2702
+ loadGrammarDataAsync({ isLocalLLM, surface }).catch((error) => {
2703
+ ctcTag(
2704
+ 'init',
2705
+ `⚠️ grammar data unavailable; filtering skipped: ${String(error)}`,
2706
+ CTC_STYLES.warn,
2707
+ );
2708
+ }),
2709
+ ]);
2710
+
850
2711
  const terms = Object.entries(vocabularyData.words).map(([word, stats]) => ({
851
2712
  word,
852
2713
  freq: stats.freq,
@@ -860,6 +2721,10 @@ export const loadDefaultVocabulary = (options?: {
860
2721
  initL3Vocabulary(l3VocabularyData);
861
2722
  initVocabulary({ terms });
862
2723
 
2724
+ // Phrase/bigram artifacts load independently and must never affect the
2725
+ // word path — fire-and-forget with its own error handling inside.
2726
+ void loadPhraseArtifacts({ isLocalLLM }).catch(() => {});
2727
+
863
2728
  succeedExp(EXPERIENCE_NAME.LOAD_VOCABULARY, 'singleton', {
864
2729
  isLocalLLM,
865
2730
  l2WordCount: terms.length,
@@ -869,7 +2734,7 @@ export const loadDefaultVocabulary = (options?: {
869
2734
  } catch (e) {
870
2735
  failExp(EXPERIENCE_NAME.LOAD_VOCABULARY, 'singleton', {
871
2736
  isLocalLLM,
872
- errorType: 'load_error',
2737
+ errorType: 'parse_error',
873
2738
  ...(surface ? { surface } : {}),
874
2739
  });
875
2740
  // Allow a later call to retry the load rather than caching the failure.