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