@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.
@@ -13,14 +13,17 @@ import _defineProperty from "@babel/runtime/helpers/defineProperty";
13
13
  * Falls back to cold mode (freq-only) when vectors not yet loaded.
14
14
  *
15
15
  * Session personalization (L1): words the user types are incrementally boosted
16
- * via incrementSessionFreq(), called on word boundaries from the plugin.
16
+ * via incrementSessionFreq(), called on word boundaries from the plugin, and
17
+ * words in ingested context text via ingestDocumentPage(). What the session has
18
+ * boosted is visible at any time from the console: `__atlCtcDebug__.session()`,
19
+ * or `__atlCtcDebug__.session('poll')` for one family — see inspectSessionBoosts.
17
20
  */
18
21
 
19
22
  import { EXPERIENCE_NAME, failExp, startExp, succeedExp } from '../analytics/ufo';
20
23
  import { fetchAutocompleteArtifactBinary, fetchAutocompleteArtifactJson } from './artifact-loader';
21
24
  import { ARTIFACT_NAME } from './artifacts-manifest';
22
25
  import { createCanonicalContextPositionCache, deriveCanonicalCandidateContext, deriveWhitespaceBoundaryContext, logSoftmaxAt, logSumExp, selectBoundaryPrimeRequests } from './canonical-lm-scoring';
23
- import { CTC_STYLES, ctcSection, ctcTag, isAutocompleteDebugEnabled, isAutocompleteDebugVerbose } from './debug-mode';
26
+ import { CTC_STYLES, ctcSection, ctcTag, isAutocompleteDebugEnabled, isAutocompleteDebugVerbose, registerCtcSessionInspector } from './debug-mode';
24
27
  import { loadGrammarDataAsync, rankCandidates, STAGE1_WEIGHT, STAGE2_WEIGHT, MIN_STAGE1_SCORE, MIN_WINNER_MARGIN } from './scoring-pipeline';
25
28
  import { getBoundaryLmState, getCanonicalSurfaceCount, getCanonicalSurfaceTokenIds, getDefaultSlowLaneClientStatus, getProgressiveSurfaceEvidence, getStoredContextInput, getStoredContextVector, getStoredLmLogits, getSurfaceScore, isCanonicalSurfaceScoringSupported, primeBoundaryLm, requestProgressiveSurfaceScores } from './slow-lane-client';
26
29
 
@@ -172,6 +175,18 @@ const DEBUG_TEXT_TAIL_CHARS = 120;
172
175
  * is vocabulary coverage.
173
176
  */
174
177
 
178
+ /**
179
+ * What the scored path concluded, recorded on every evaluation whether or not
180
+ * debug is on.
181
+ *
182
+ * This exists for the inline-code harvester, which may only offer a harvested
183
+ * surface once the scored path has finished and come away empty. The reason is
184
+ * the load-bearing part: `no-candidate` means no vocabulary reaches this prefix
185
+ * at all, while `winner-margin` means two known words the model cannot yet
186
+ * separate — the first is a gap worth filling and the second is a prefix
187
+ * ambiguous enough that filling it would be a guess.
188
+ */
189
+
175
190
  /**
176
191
  * A candidate paired with the length of the already-typed prefix it completes.
177
192
  * For a single word this is the current partial token length; for a phrase it
@@ -200,6 +215,17 @@ class TrieNode {
200
215
  class WeightedWordTrie {
201
216
  constructor() {
202
217
  _defineProperty(this, "root", new TrieNode());
218
+ /**
219
+ * Every node a session boost has been written to.
220
+ *
221
+ * Kept because dropping the boosts is no longer a rare event — it happens
222
+ * each time the reader changes page or conversation — and walking a vocabulary
223
+ * of tens of thousands of words to find the few hundred that were touched
224
+ * costs about 10ms of main thread every time. The two writers below are the
225
+ * only way a `sessionFreq` moves, so keeping this in step costs one Set
226
+ * insertion on a path that is already descending the trie.
227
+ */
228
+ _defineProperty(this, "boostedNodes", new Set());
203
229
  /** Highest tenantFreq seen — used to normalize freq scores at query time */
204
230
  _defineProperty(this, "maxTenantFreq", 1);
205
231
  }
@@ -273,6 +299,11 @@ class WeightedWordTrie {
273
299
  return node.word !== null ? node : null;
274
300
  }
275
301
 
302
+ /** Whether this exact surface is stored as a terminal word. */
303
+ hasWord(word) {
304
+ return this.findNode(word) !== null;
305
+ }
306
+
276
307
  /**
277
308
  * Set the session frequency for a word.
278
309
  * Returns true if the word exists in the trie.
@@ -283,6 +314,7 @@ class WeightedWordTrie {
283
314
  return false;
284
315
  }
285
316
  node.sessionFreq = count;
317
+ this.boostedNodes.add(node);
286
318
  return true;
287
319
  }
288
320
 
@@ -296,8 +328,58 @@ class WeightedWordTrie {
296
328
  return false;
297
329
  }
298
330
  node.sessionFreq += 1;
331
+ this.boostedNodes.add(node);
299
332
  return true;
300
333
  }
334
+
335
+ /**
336
+ * Zero every session boost, in the number of words boosted rather than the
337
+ * number of words known.
338
+ */
339
+ clearSessionBoosts() {
340
+ for (const node of this.boostedNodes) {
341
+ node.sessionFreq = 0;
342
+ }
343
+ this.boostedNodes.clear();
344
+ }
345
+
346
+ /**
347
+ * Every word carrying a session boost, optionally limited to one prefix's
348
+ * subtree. Unlike `getCandidates` a word equal to the prefix is included,
349
+ * since the question here is what the session holds rather than what could
350
+ * still be typed.
351
+ *
352
+ * Walks the trie rather than reading `boostedNodes`, since a prefix answer is
353
+ * a subtree question and this only runs when a human asks it.
354
+ */
355
+ collectSessionBoosted(prefix = '') {
356
+ let node = this.root;
357
+ for (const char of prefix.toLowerCase()) {
358
+ const next = node.children.get(char);
359
+ if (!next) {
360
+ return [];
361
+ }
362
+ node = next;
363
+ }
364
+ const boosted = [];
365
+ const stack = [node];
366
+ while (stack.length > 0) {
367
+ const current = stack.pop();
368
+ if (!current) {
369
+ continue;
370
+ }
371
+ if (current.word !== null && current.sessionFreq > 0) {
372
+ boosted.push({
373
+ word: current.word,
374
+ node: current
375
+ });
376
+ }
377
+ for (const child of current.children.values()) {
378
+ stack.push(child);
379
+ }
380
+ }
381
+ return boosted;
382
+ }
301
383
  }
302
384
 
303
385
  // L1/L2 Trie (Session + Atlassian Domain)
@@ -349,6 +431,13 @@ let phraseTermCount = 0;
349
431
  let maxBigramFreq = 1;
350
432
  let maxPhraseFreq = 1;
351
433
  let lastPredictionDebug = null;
434
+ let lastPredictionOutcome = null;
435
+ const recordPredictionOutcome = outcome => {
436
+ lastPredictionOutcome = outcome;
437
+ };
438
+
439
+ /** The verdict from the most recent `predict` call. Always populated. */
440
+ export const getLastPredictionOutcome = () => lastPredictionOutcome;
352
441
 
353
442
  // ── Stabilization (QI-2): post-accept cooldown + whole-surface repetition ────
354
443
  // Two guards that stop the accept→echo (`end to end` → `end to end to end`) and
@@ -380,6 +469,22 @@ export const noteSuggestionAccepted = surface => {
380
469
  }
381
470
  };
382
471
 
472
+ /**
473
+ * Whether `surface` is the one the user just accepted and is still inside its
474
+ * cooldown window.
475
+ *
476
+ * Read-only, unlike the advance inside `predict`: the cooldown is measured in
477
+ * predictions, and a caller asking whether it is active must not consume one of
478
+ * them. Exported for the harvest path, which displays without going through
479
+ * arbitration and so would otherwise re-offer what was just accepted.
480
+ */
481
+ export const isSurfaceInAcceptCooldown = surface => {
482
+ if (!acceptCooldown || acceptCooldown.surface !== surface.trim().toLowerCase()) {
483
+ return false;
484
+ }
485
+ return acceptCooldown.predictionsSince <= COOLDOWN_KEYSTROKES || performance.now() - acceptCooldown.ts < COOLDOWN_MS;
486
+ };
487
+
383
488
  /** Get vector for a word from the store. */
384
489
  const getWordVector = word => {
385
490
  if (!vectorStore) {
@@ -649,6 +754,36 @@ export const incrementSessionFreq = word => {
649
754
  wordTrie.incrementSessionFreq(word);
650
755
  };
651
756
 
757
+ /**
758
+ * Drop every L1 boost this session has accumulated.
759
+ *
760
+ * The vocabulary itself is left alone: only `sessionFreq` is cleared, so the
761
+ * tenant and generic frequencies a boost was sitting on top of survive. Called
762
+ * when the plugin decides the session it was learning for has ended — a new
763
+ * conversation, or a different page — since a boost is a claim about what is
764
+ * being discussed and that claim does not carry over.
765
+ */
766
+ export const resetSessionBoosts = () => {
767
+ wordTrie.clearSessionBoosts();
768
+ // Anything memoized against the old boosts is now describing a session that
769
+ // no longer exists.
770
+ recallGeneration++;
771
+ };
772
+
773
+ /**
774
+ * Which vocabulary already holds this surface, if any.
775
+ *
776
+ * Used by the inline-code harvester to drop terms the scored path can already
777
+ * serve, so that harvesting stays limited to words with no route to a
778
+ * suggestion today.
779
+ */
780
+ export const lookupVocabularySource = word => {
781
+ if (wordTrie.hasWord(word)) {
782
+ return 'l2';
783
+ }
784
+ return l3Trie.hasWord(word) ? 'l3' : null;
785
+ };
786
+
652
787
  /**
653
788
  * Prime session frequencies from a document page string.
654
789
  *
@@ -672,7 +807,7 @@ export const ingestDocumentPage = pageContent => {
672
807
  }
673
808
  }
674
809
  if (isAutocompleteDebugEnabled() && validBoostedWords.size > 0) {
675
- ctcTag('init', `L1 session primed ${validBoostedWords.size} words from page`, CTC_STYLES.brand);
810
+ ctcTag('init', `L1 session primed ${validBoostedWords.size} words from page · __atlCtcDebug__.session() to inspect`, CTC_STYLES.brand);
676
811
  if (isAutocompleteDebugVerbose()) {
677
812
  // eslint-disable-next-line no-console
678
813
  console.dir(Array.from(validBoostedWords).sort());
@@ -680,6 +815,54 @@ export const ingestDocumentPage = pageContent => {
680
815
  }
681
816
  };
682
817
 
818
+ /**
819
+ * How many boosted words `inspectSessionBoosts` lists.
820
+ *
821
+ * A page ingest can boost thousands, and a list that long is not read. The
822
+ * strongest boosts are the ones that change an ordering, and `boosted` still
823
+ * reports the full size, so the cap loses nothing but volume.
824
+ */
825
+ const MAX_LISTED_SESSION_WORDS = 100;
826
+ /**
827
+ * Read the session's L1 boosts, optionally narrowed to a prefix.
828
+ *
829
+ * Installed as `__atlCtcDebug__.session()`, with `__atlCtcDebug__.session('poll')`
830
+ * to ask about one family. Returned rather than logged, so the console renders it
831
+ * as an inspectable object and a caller can assert on it.
832
+ *
833
+ * Only words the vocabulary already holds can carry a boost, because both writers
834
+ * go through `incrementSessionFreq` and it only finds existing nodes. An ingested
835
+ * word absent from the vocabulary is therefore missing from here and always will
836
+ * be — that gap is what the inline-code harvester covers, and those surfaces show
837
+ * up under `__atlCtcDebug__.harvest()` instead.
838
+ */
839
+ export const inspectSessionBoosts = prefix => {
840
+ const boosted = wordTrie.collectSessionBoosted(prefix !== null && prefix !== void 0 ? prefix : '');
841
+ return {
842
+ boosted: boosted.length,
843
+ limit: MAX_LISTED_SESSION_WORDS,
844
+ ...(prefix === undefined ? {} : {
845
+ prefix
846
+ }),
847
+ words: boosted.sort((a, b) => b.node.sessionFreq - a.node.sessionFreq || a.word.localeCompare(b.word, 'en', {
848
+ numeric: true,
849
+ sensitivity: 'base'
850
+ })).slice(0, MAX_LISTED_SESSION_WORDS).map(({
851
+ node,
852
+ word
853
+ }) => ({
854
+ sessionFreq: node.sessionFreq,
855
+ sessionOnly: node.tenantFreq === 0,
856
+ surface: word,
857
+ tenantFreq: node.tenantFreq
858
+ }))
859
+ };
860
+ };
861
+
862
+ // At module scope so the console answers before the first keystroke, which is
863
+ // when someone reaching for it usually asks.
864
+ registerCtcSessionInspector(inspectSessionBoosts);
865
+
683
866
  /**
684
867
  * Result of a prediction: the ghost tail to insert plus an immutable record of
685
868
  * the evidence that authorized the UI commitment.
@@ -783,6 +966,14 @@ export const predict = textBefore => {
783
966
  void loadDefaultVocabulary({
784
967
  source: 'predict'
785
968
  }).catch(() => {});
969
+ // Awaiting, not empty-handed: with no vocabulary loaded the harvester's own
970
+ // intake filter has not been applied to anything either.
971
+ recordPredictionOutcome({
972
+ abstainReason: 'not-initialized',
973
+ awaitingAsyncEvidence: true,
974
+ scoredCandidateCount: 0,
975
+ textBefore
976
+ });
786
977
  return null;
787
978
  }
788
979
  const t0 = performance.now();
@@ -812,11 +1003,23 @@ export const predict = textBefore => {
812
1003
  priority: 0
813
1004
  });
814
1005
  }
1006
+ recordPredictionOutcome({
1007
+ abstainReason: 'prefetch',
1008
+ awaitingAsyncEvidence: true,
1009
+ scoredCandidateCount: 0,
1010
+ textBefore
1011
+ });
815
1012
  return null;
816
1013
  }
817
1014
  const trimmed = textBefore.trimEnd();
818
1015
  const trailingSurfaceToken = (_trimmed$match$ = (_trimmed$match = trimmed.match(TRAILING_SURFACE_TOKEN_REGEX)) === null || _trimmed$match === void 0 ? void 0 : _trimmed$match[0]) !== null && _trimmed$match$ !== void 0 ? _trimmed$match$ : '';
819
1016
  if (trailingSurfaceToken.length === 0) {
1017
+ recordPredictionOutcome({
1018
+ abstainReason: 'no-surface-token',
1019
+ awaitingAsyncEvidence: false,
1020
+ scoredCandidateCount: 0,
1021
+ textBefore
1022
+ });
820
1023
  return null;
821
1024
  }
822
1025
  const currentWord = trailingSurfaceToken;
@@ -828,6 +1031,14 @@ export const predict = textBefore => {
828
1031
 
829
1032
  // If every trie was empty for this prefix
830
1033
  if (canonicalMatched.length === 0) {
1034
+ // Terminal, and the only verdict that says the vocabulary has no claim on
1035
+ // this prefix at all — which is what makes it the harvester's cue.
1036
+ recordPredictionOutcome({
1037
+ abstainReason: 'no-candidate',
1038
+ awaitingAsyncEvidence: false,
1039
+ scoredCandidateCount: 0,
1040
+ textBefore
1041
+ });
831
1042
  if (isAutocompleteDebugEnabled()) {
832
1043
  // eslint-disable-next-line no-console
833
1044
  console.log(`%c[CTC]%c — abstain: no matches for "${currentWord}"`, CTC_STYLES.brand, CTC_STYLES.body);
@@ -1511,6 +1722,12 @@ export const predict = textBefore => {
1511
1722
  return ranked.some(hasJudgeableEvidence) ? 'below-posterior-gate' : 'no-evidence';
1512
1723
  };
1513
1724
  const abstainReason = resolveAbstainReason();
1725
+ recordPredictionOutcome({
1726
+ abstainReason,
1727
+ awaitingAsyncEvidence: hasUnresolvedPotential && !clearsWinnerMargin,
1728
+ scoredCandidateCount: ranked.length,
1729
+ textBefore
1730
+ });
1514
1731
 
1515
1732
  // The leader is the best-supported candidate, not the selected one: an
1516
1733
  // evaluation that showed nothing is exactly the one whose posterior needs
@@ -1561,6 +1778,9 @@ export const predict = textBefore => {
1561
1778
  'missing-artifact': 'canonical artifact coverage missing',
1562
1779
  'no-candidate': 'nothing cleared scoring',
1563
1780
  'no-evidence': 'full-surface evidence absent',
1781
+ // Recorded at the two exits above this block, so they never print here.
1782
+ 'no-surface-token': 'no trailing surface token',
1783
+ 'not-initialized': 'vocabulary not loaded',
1564
1784
  prefetch: `prefetch: ${currentWord.length}/${DISPLAY_MIN_PREFIX_LENGTH} chars`,
1565
1785
  'short-completion': `completion shorter than ${MIN_SUGGESTION_LENGTH} chars`,
1566
1786
  'unresolved-rival': 'expanding plausible token-prefix groups',