@atlaskit/editor-plugin-autocomplete 9.0.0 → 9.2.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.
@@ -12,7 +12,10 @@
12
12
  * Falls back to cold mode (freq-only) when vectors not yet loaded.
13
13
  *
14
14
  * Session personalization (L1): words the user types are incrementally boosted
15
- * via incrementSessionFreq(), called on word boundaries from the plugin.
15
+ * via incrementSessionFreq(), called on word boundaries from the plugin, and
16
+ * words in ingested context text via ingestDocumentPage(). What the session has
17
+ * boosted is visible at any time from the console: `__atlCtcDebug__.session()`,
18
+ * or `__atlCtcDebug__.session('poll')` for one family — see inspectSessionBoosts.
16
19
  */
17
20
 
18
21
  import { EXPERIENCE_NAME, failExp, startExp, succeedExp } from '../analytics/ufo';
@@ -35,6 +38,7 @@ import {
35
38
  ctcTag,
36
39
  isAutocompleteDebugEnabled,
37
40
  isAutocompleteDebugVerbose,
41
+ registerCtcSessionInspector,
38
42
  } from './debug-mode';
39
43
  import {
40
44
  loadGrammarDataAsync,
@@ -208,7 +212,7 @@ const DEBUG_TEXT_TAIL_CHARS = 120;
208
212
  * `cold-competitor` and `unresolved-rival` are scheduling, and `no-candidate`
209
213
  * is vocabulary coverage.
210
214
  */
211
- type CtcAbstainReason =
215
+ export type CtcAbstainReason =
212
216
  | 'below-posterior-gate'
213
217
  | 'cold-competitor'
214
218
  | 'empty-completion'
@@ -217,11 +221,34 @@ type CtcAbstainReason =
217
221
  | 'missing-artifact'
218
222
  | 'no-candidate'
219
223
  | 'no-evidence'
224
+ | 'no-surface-token'
225
+ | 'not-initialized'
220
226
  | 'prefetch'
221
227
  | 'short-completion'
222
228
  | 'unresolved-rival'
223
229
  | 'winner-margin';
224
230
 
231
+ /**
232
+ * What the scored path concluded, recorded on every evaluation whether or not
233
+ * debug is on.
234
+ *
235
+ * This exists for the inline-code harvester, which may only offer a harvested
236
+ * surface once the scored path has finished and come away empty. The reason is
237
+ * the load-bearing part: `no-candidate` means no vocabulary reaches this prefix
238
+ * at all, while `winner-margin` means two known words the model cannot yet
239
+ * separate — the first is a gap worth filling and the second is a prefix
240
+ * ambiguous enough that filling it would be a guess.
241
+ */
242
+ export interface PredictionOutcome {
243
+ abstainReason: CtcAbstainReason | null;
244
+ /** True while a pending async signal could still change the verdict. */
245
+ awaitingAsyncEvidence: boolean;
246
+ /** How many vocabulary candidates were scored for this prefix. */
247
+ scoredCandidateCount: number;
248
+ /** The exact string passed to `predict`, so a caller can confirm identity. */
249
+ textBefore: string;
250
+ }
251
+
225
252
  interface Candidate {
226
253
  node: TrieNode;
227
254
  word: string;
@@ -284,6 +311,17 @@ class TrieNode {
284
311
 
285
312
  class WeightedWordTrie {
286
313
  private root = new TrieNode();
314
+ /**
315
+ * Every node a session boost has been written to.
316
+ *
317
+ * Kept because dropping the boosts is no longer a rare event — it happens
318
+ * each time the reader changes page or conversation — and walking a vocabulary
319
+ * of tens of thousands of words to find the few hundred that were touched
320
+ * costs about 10ms of main thread every time. The two writers below are the
321
+ * only way a `sessionFreq` moves, so keeping this in step costs one Set
322
+ * insertion on a path that is already descending the trie.
323
+ */
324
+ private boostedNodes = new Set<TrieNode>();
287
325
  /** Highest tenantFreq seen — used to normalize freq scores at query time */
288
326
  maxTenantFreq: number = 1;
289
327
 
@@ -363,6 +401,11 @@ class WeightedWordTrie {
363
401
  return node.word !== null ? node : null;
364
402
  }
365
403
 
404
+ /** Whether this exact surface is stored as a terminal word. */
405
+ hasWord(word: string): boolean {
406
+ return this.findNode(word) !== null;
407
+ }
408
+
366
409
  /**
367
410
  * Set the session frequency for a word.
368
411
  * Returns true if the word exists in the trie.
@@ -373,6 +416,7 @@ class WeightedWordTrie {
373
416
  return false;
374
417
  }
375
418
  node.sessionFreq = count;
419
+ this.boostedNodes.add(node);
376
420
  return true;
377
421
  }
378
422
 
@@ -386,8 +430,58 @@ class WeightedWordTrie {
386
430
  return false;
387
431
  }
388
432
  node.sessionFreq += 1;
433
+ this.boostedNodes.add(node);
389
434
  return true;
390
435
  }
436
+
437
+ /**
438
+ * Zero every session boost, in the number of words boosted rather than the
439
+ * number of words known.
440
+ */
441
+ clearSessionBoosts(): void {
442
+ for (const node of this.boostedNodes) {
443
+ node.sessionFreq = 0;
444
+ }
445
+ this.boostedNodes.clear();
446
+ }
447
+
448
+ /**
449
+ * Every word carrying a session boost, optionally limited to one prefix's
450
+ * subtree. Unlike `getCandidates` a word equal to the prefix is included,
451
+ * since the question here is what the session holds rather than what could
452
+ * still be typed.
453
+ *
454
+ * Walks the trie rather than reading `boostedNodes`, since a prefix answer is
455
+ * a subtree question and this only runs when a human asks it.
456
+ */
457
+ collectSessionBoosted(prefix: string = ''): Candidate[] {
458
+ let node = this.root;
459
+ for (const char of prefix.toLowerCase()) {
460
+ const next = node.children.get(char);
461
+ if (!next) {
462
+ return [];
463
+ }
464
+ node = next;
465
+ }
466
+
467
+ const boosted: Candidate[] = [];
468
+ const stack: TrieNode[] = [node];
469
+
470
+ while (stack.length > 0) {
471
+ const current = stack.pop();
472
+ if (!current) {
473
+ continue;
474
+ }
475
+ if (current.word !== null && current.sessionFreq > 0) {
476
+ boosted.push({ word: current.word, node: current });
477
+ }
478
+ for (const child of current.children.values()) {
479
+ stack.push(child);
480
+ }
481
+ }
482
+
483
+ return boosted;
484
+ }
391
485
  }
392
486
 
393
487
  // L1/L2 Trie (Session + Atlassian Domain)
@@ -458,6 +552,15 @@ let lastPredictionDebug: {
458
552
  }>;
459
553
  } | null = null;
460
554
 
555
+ let lastPredictionOutcome: PredictionOutcome | null = null;
556
+
557
+ const recordPredictionOutcome = (outcome: PredictionOutcome): void => {
558
+ lastPredictionOutcome = outcome;
559
+ };
560
+
561
+ /** The verdict from the most recent `predict` call. Always populated. */
562
+ export const getLastPredictionOutcome = (): PredictionOutcome | null => lastPredictionOutcome;
563
+
461
564
  // ── Stabilization (QI-2): post-accept cooldown + whole-surface repetition ────
462
565
  // Two guards that stop the accept→echo (`end to end` → `end to end to end`) and
463
566
  // generally keep a just-accepted unit from being re-offered:
@@ -488,6 +591,25 @@ export const noteSuggestionAccepted = (surface: string): void => {
488
591
  }
489
592
  };
490
593
 
594
+ /**
595
+ * Whether `surface` is the one the user just accepted and is still inside its
596
+ * cooldown window.
597
+ *
598
+ * Read-only, unlike the advance inside `predict`: the cooldown is measured in
599
+ * predictions, and a caller asking whether it is active must not consume one of
600
+ * them. Exported for the harvest path, which displays without going through
601
+ * arbitration and so would otherwise re-offer what was just accepted.
602
+ */
603
+ export const isSurfaceInAcceptCooldown = (surface: string): boolean => {
604
+ if (!acceptCooldown || acceptCooldown.surface !== surface.trim().toLowerCase()) {
605
+ return false;
606
+ }
607
+ return (
608
+ acceptCooldown.predictionsSince <= COOLDOWN_KEYSTROKES ||
609
+ performance.now() - acceptCooldown.ts < COOLDOWN_MS
610
+ );
611
+ };
612
+
491
613
  /** Get vector for a word from the store. */
492
614
  const getWordVector = (word: string): Float32Array | null => {
493
615
  if (!vectorStore) {
@@ -797,6 +919,36 @@ export const incrementSessionFreq = (word: string): void => {
797
919
  wordTrie.incrementSessionFreq(word);
798
920
  };
799
921
 
922
+ /**
923
+ * Drop every L1 boost this session has accumulated.
924
+ *
925
+ * The vocabulary itself is left alone: only `sessionFreq` is cleared, so the
926
+ * tenant and generic frequencies a boost was sitting on top of survive. Called
927
+ * when the plugin decides the session it was learning for has ended — a new
928
+ * conversation, or a different page — since a boost is a claim about what is
929
+ * being discussed and that claim does not carry over.
930
+ */
931
+ export const resetSessionBoosts = (): void => {
932
+ wordTrie.clearSessionBoosts();
933
+ // Anything memoized against the old boosts is now describing a session that
934
+ // no longer exists.
935
+ recallGeneration++;
936
+ };
937
+
938
+ /**
939
+ * Which vocabulary already holds this surface, if any.
940
+ *
941
+ * Used by the inline-code harvester to drop terms the scored path can already
942
+ * serve, so that harvesting stays limited to words with no route to a
943
+ * suggestion today.
944
+ */
945
+ export const lookupVocabularySource = (word: string): 'l2' | 'l3' | null => {
946
+ if (wordTrie.hasWord(word)) {
947
+ return 'l2';
948
+ }
949
+ return l3Trie.hasWord(word) ? 'l3' : null;
950
+ };
951
+
800
952
  /**
801
953
  * Prime session frequencies from a document page string.
802
954
  *
@@ -823,7 +975,11 @@ export const ingestDocumentPage = (pageContent: string | undefined): void => {
823
975
  }
824
976
 
825
977
  if (isAutocompleteDebugEnabled() && validBoostedWords.size > 0) {
826
- ctcTag('init', `L1 session primed ${validBoostedWords.size} words from page`, CTC_STYLES.brand);
978
+ ctcTag(
979
+ 'init',
980
+ `L1 session primed ${validBoostedWords.size} words from page · __atlCtcDebug__.session() to inspect`,
981
+ CTC_STYLES.brand,
982
+ );
827
983
  if (isAutocompleteDebugVerbose()) {
828
984
  // eslint-disable-next-line no-console
829
985
  console.dir(Array.from(validBoostedWords).sort());
@@ -831,6 +987,75 @@ export const ingestDocumentPage = (pageContent: string | undefined): void => {
831
987
  }
832
988
  };
833
989
 
990
+ /**
991
+ * How many boosted words `inspectSessionBoosts` lists.
992
+ *
993
+ * A page ingest can boost thousands, and a list that long is not read. The
994
+ * strongest boosts are the ones that change an ordering, and `boosted` still
995
+ * reports the full size, so the cap loses nothing but volume.
996
+ */
997
+ const MAX_LISTED_SESSION_WORDS = 100;
998
+
999
+ interface SessionWordSnapshot {
1000
+ /** Times this session has seen it: words typed plus words in ingested text. */
1001
+ sessionFreq: number;
1002
+ /** No corpus frequency behind it, so L1 is the whole of its standing. */
1003
+ sessionOnly: boolean;
1004
+ surface: string;
1005
+ /** Corpus frequency shipped with the vocabulary, for scale against the boost. */
1006
+ tenantFreq: number;
1007
+ }
1008
+
1009
+ export interface SessionSnapshot {
1010
+ /** How many words hold a boost, whether or not they are listed below. */
1011
+ boosted: number;
1012
+ /** Ceiling on `words`; beyond it the weakest boosts are left out of the listing. */
1013
+ limit: number;
1014
+ /** The prefix asked about, when one was passed. */
1015
+ prefix?: string;
1016
+ /** Strongest boost first, then alphabetically. */
1017
+ words: SessionWordSnapshot[];
1018
+ }
1019
+
1020
+ /**
1021
+ * Read the session's L1 boosts, optionally narrowed to a prefix.
1022
+ *
1023
+ * Installed as `__atlCtcDebug__.session()`, with `__atlCtcDebug__.session('poll')`
1024
+ * to ask about one family. Returned rather than logged, so the console renders it
1025
+ * as an inspectable object and a caller can assert on it.
1026
+ *
1027
+ * Only words the vocabulary already holds can carry a boost, because both writers
1028
+ * go through `incrementSessionFreq` and it only finds existing nodes. An ingested
1029
+ * word absent from the vocabulary is therefore missing from here and always will
1030
+ * be — that gap is what the inline-code harvester covers, and those surfaces show
1031
+ * up under `__atlCtcDebug__.harvest()` instead.
1032
+ */
1033
+ export const inspectSessionBoosts = (prefix?: string): SessionSnapshot => {
1034
+ const boosted = wordTrie.collectSessionBoosted(prefix ?? '');
1035
+ return {
1036
+ boosted: boosted.length,
1037
+ limit: MAX_LISTED_SESSION_WORDS,
1038
+ ...(prefix === undefined ? {} : { prefix }),
1039
+ words: boosted
1040
+ .sort(
1041
+ (a, b) =>
1042
+ b.node.sessionFreq - a.node.sessionFreq ||
1043
+ a.word.localeCompare(b.word, 'en', { numeric: true, sensitivity: 'base' }),
1044
+ )
1045
+ .slice(0, MAX_LISTED_SESSION_WORDS)
1046
+ .map(({ node, word }) => ({
1047
+ sessionFreq: node.sessionFreq,
1048
+ sessionOnly: node.tenantFreq === 0,
1049
+ surface: word,
1050
+ tenantFreq: node.tenantFreq,
1051
+ })),
1052
+ };
1053
+ };
1054
+
1055
+ // At module scope so the console answers before the first keystroke, which is
1056
+ // when someone reaching for it usually asks.
1057
+ registerCtcSessionInspector(inspectSessionBoosts);
1058
+
834
1059
  /**
835
1060
  * Result of a prediction: the ghost tail to insert plus an immutable record of
836
1061
  * the evidence that authorized the UI commitment.
@@ -843,8 +1068,15 @@ export interface PredictionResult {
843
1068
  verifiedChars: number;
844
1069
  verifiedTokens: number;
845
1070
  };
846
- /** Evidence tier that authorized display. Tier A is never display-eligible. */
847
- evidenceTier: 'canonical-full-surface' | 'network-logit';
1071
+ /**
1072
+ * Evidence tier that authorized display. Tier A is never display-eligible.
1073
+ *
1074
+ * `session-harvest` never passes through this module: an inline-code surface
1075
+ * harvested from the session has no frequencies, vector or canonical token
1076
+ * ids, so it is authorized by being marked as code on a prefix the scored
1077
+ * path left unclaimed rather than by model evidence.
1078
+ */
1079
+ evidenceTier: 'canonical-full-surface' | 'network-logit' | 'session-harvest';
848
1080
  /**
849
1081
  * Mean per-token log-probability of the verified prefix.
850
1082
  *
@@ -1013,6 +1245,14 @@ export const predict = (textBefore: string): PredictionResult | null => {
1013
1245
  // Kick off the load and skip this keystroke; the plugin also primes it on
1014
1246
  // focus, so the tries are usually ready before the user types.
1015
1247
  void loadDefaultVocabulary({ source: 'predict' }).catch(() => {});
1248
+ // Awaiting, not empty-handed: with no vocabulary loaded the harvester's own
1249
+ // intake filter has not been applied to anything either.
1250
+ recordPredictionOutcome({
1251
+ abstainReason: 'not-initialized',
1252
+ awaitingAsyncEvidence: true,
1253
+ scoredCandidateCount: 0,
1254
+ textBefore,
1255
+ });
1016
1256
  return null;
1017
1257
  }
1018
1258
 
@@ -1043,12 +1283,24 @@ export const predict = (textBefore: string): PredictionResult | null => {
1043
1283
  priority: 0,
1044
1284
  });
1045
1285
  }
1286
+ recordPredictionOutcome({
1287
+ abstainReason: 'prefetch',
1288
+ awaitingAsyncEvidence: true,
1289
+ scoredCandidateCount: 0,
1290
+ textBefore,
1291
+ });
1046
1292
  return null;
1047
1293
  }
1048
1294
 
1049
1295
  const trimmed = textBefore.trimEnd();
1050
1296
  const trailingSurfaceToken = trimmed.match(TRAILING_SURFACE_TOKEN_REGEX)?.[0] ?? '';
1051
1297
  if (trailingSurfaceToken.length === 0) {
1298
+ recordPredictionOutcome({
1299
+ abstainReason: 'no-surface-token',
1300
+ awaitingAsyncEvidence: false,
1301
+ scoredCandidateCount: 0,
1302
+ textBefore,
1303
+ });
1052
1304
  return null;
1053
1305
  }
1054
1306
  const currentWord = trailingSurfaceToken;
@@ -1062,6 +1314,14 @@ export const predict = (textBefore: string): PredictionResult | null => {
1062
1314
 
1063
1315
  // If every trie was empty for this prefix
1064
1316
  if (canonicalMatched.length === 0) {
1317
+ // Terminal, and the only verdict that says the vocabulary has no claim on
1318
+ // this prefix at all — which is what makes it the harvester's cue.
1319
+ recordPredictionOutcome({
1320
+ abstainReason: 'no-candidate',
1321
+ awaitingAsyncEvidence: false,
1322
+ scoredCandidateCount: 0,
1323
+ textBefore,
1324
+ });
1065
1325
  if (isAutocompleteDebugEnabled()) {
1066
1326
  // eslint-disable-next-line no-console
1067
1327
  console.log(
@@ -1821,6 +2081,12 @@ export const predict = (textBefore: string): PredictionResult | null => {
1821
2081
  return ranked.some(hasJudgeableEvidence) ? 'below-posterior-gate' : 'no-evidence';
1822
2082
  };
1823
2083
  const abstainReason: CtcAbstainReason | null = resolveAbstainReason();
2084
+ recordPredictionOutcome({
2085
+ abstainReason,
2086
+ awaitingAsyncEvidence: hasUnresolvedPotential && !clearsWinnerMargin,
2087
+ scoredCandidateCount: ranked.length,
2088
+ textBefore,
2089
+ });
1824
2090
 
1825
2091
  // The leader is the best-supported candidate, not the selected one: an
1826
2092
  // evaluation that showed nothing is exactly the one whose posterior needs
@@ -1874,6 +2140,9 @@ export const predict = (textBefore: string): PredictionResult | null => {
1874
2140
  'missing-artifact': 'canonical artifact coverage missing',
1875
2141
  'no-candidate': 'nothing cleared scoring',
1876
2142
  'no-evidence': 'full-surface evidence absent',
2143
+ // Recorded at the two exits above this block, so they never print here.
2144
+ 'no-surface-token': 'no trailing surface token',
2145
+ 'not-initialized': 'vocabulary not loaded',
1877
2146
  prefetch: `prefetch: ${currentWord.length}/${DISPLAY_MIN_PREFIX_LENGTH} chars`,
1878
2147
  'short-completion': `completion shorter than ${MIN_SUGGESTION_LENGTH} chars`,
1879
2148
  'unresolved-rival': 'expanding plausible token-prefix groups',