From 70ffb14c8b003de8653e1209ace708ea8e104a85 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Wed, 10 Jan 2024 10:09:53 +0700 Subject: [PATCH 1/3] change(web): core prediction method now internally async --- common/web/lm-worker/src/main/index.ts | 21 ++-- .../lm-worker/src/main/model-compositor.ts | 18 ++-- .../test/cases/worker-custom-punctuation.js | 8 +- .../src/test/cases/worker-model-compositor.js | 100 +++++++++--------- .../src/test/cases/worker-predict.js | 7 +- 5 files changed, 81 insertions(+), 73 deletions(-) diff --git a/common/web/lm-worker/src/main/index.ts b/common/web/lm-worker/src/main/index.ts index a44d4e68bbe..3de492e7dee 100644 --- a/common/web/lm-worker/src/main/index.ts +++ b/common/web/lm-worker/src/main/index.ts @@ -325,12 +325,12 @@ export default class LMLayerWorker { switch(payload.message) { case 'predict': var {transform, context} = payload; - var suggestions = compositor.predict(transform, context); - - // Now that the suggestions are ready, send them out! - this.cast('suggestions', { - token: payload.token, - suggestions: suggestions + compositor.predict(transform, context).then((suggestions) => { + // Now that the suggestions are ready, send them out! + this.cast('suggestions', { + token: payload.token, + suggestions: suggestions + }); }); break; case 'wordbreak': @@ -355,11 +355,12 @@ export default class LMLayerWorker { break; case 'revert': var {reversion, context} = payload; - var suggestions: Suggestion[] = compositor.applyReversion(reversion, context); - this.cast('postrevert', { - token: payload.token, - suggestions: suggestions + compositor.applyReversion(reversion, context).then((suggestions) => { + this.cast('postrevert', { + token: payload.token, + suggestions: suggestions + }); }); break; case 'reset-context': diff --git a/common/web/lm-worker/src/main/model-compositor.ts b/common/web/lm-worker/src/main/model-compositor.ts index ebb8c084bb1..8d697c309c6 100644 --- a/common/web/lm-worker/src/main/model-compositor.ts +++ b/common/web/lm-worker/src/main/model-compositor.ts @@ -65,7 +65,7 @@ export default class ModelCompositor { return returnedPredictions; } - predict(transformDistribution: Transform | Distribution, context: Context): Suggestion[] { + async predict(transformDistribution: Transform | Distribution, context: Context): Promise { let suggestionDistribution: Distribution = []; let lexicalModel = this.lexicalModel; let punctuation = this.punctuation; @@ -651,19 +651,21 @@ export default class ModelCompositor { return reversion; } - applyReversion(reversion: Reversion, context: Context): Suggestion[] { + async applyReversion(reversion: Reversion, context: Context): Promise { // If we are unable to track context (because the model does not support LexiconTraversal), // we need a "fallback" strategy. let compositor = this; let fallbackSuggestions = function() { let revertedContext = models.applyTransform(reversion.transform, context); - let suggestions = compositor.predict({insert: '', deleteLeft: 0}, revertedContext); - suggestions.forEach(function(suggestion) { - // A reversion's transform ID is the additive inverse of its original suggestion; - // we revert to the state of said original suggestion. - suggestion.transformId = -reversion.transformId; + return compositor.predict({insert: '', deleteLeft: 0}, revertedContext).then((suggestions) => { + suggestions.forEach(function(suggestion) { + // A reversion's transform ID is the additive inverse of its original suggestion; + // we revert to the state of said original suggestion. + suggestion.transformId = -reversion.transformId; + }); + + return suggestions; }); - return suggestions; } if(!this.contextTracker) { diff --git a/common/web/lm-worker/src/test/cases/worker-custom-punctuation.js b/common/web/lm-worker/src/test/cases/worker-custom-punctuation.js index 8114ac530ac..e5c0eacc80f 100644 --- a/common/web/lm-worker/src/test/cases/worker-custom-punctuation.js +++ b/common/web/lm-worker/src/test/cases/worker-custom-punctuation.js @@ -8,7 +8,7 @@ import DummyModel from '#./models/dummy-model.js'; import ModelCompositor from '#./model-compositor.js'; describe('Custom Punctuation', function () { - it('appears in the keep suggestion', function () { + it('appears in the keep suggestion', async function () { let dummySuggestions = [{ transform: { insert: 'Hello', @@ -37,7 +37,7 @@ describe('Custom Punctuation', function () { // The model compositor is responsible for adding this to the display as // string. var composite = new ModelCompositor(model, true); - var suggestions = composite.predict([{ sample: { insert: 'o', deleteLeft: 0 }, p: 1.00 }], { + var suggestions = await composite.predict([{ sample: { insert: 'o', deleteLeft: 0 }, p: 1.00 }], { left: 'Hrll', startOfBuffer: false, endOfBuffer: true }); assert.lengthOf(suggestions, 3); @@ -54,7 +54,7 @@ describe('Custom Punctuation', function () { }); describe("insertAfterWord", function () { - it('appears after "word" suggestion', function () { + it('appears after "word" suggestion', async function () { let dummySuggestions = [ { transform: { insert: 'ᚈᚑᚋ', deleteLeft: 0, }, @@ -82,7 +82,7 @@ describe('Custom Punctuation', function () { // The model compositor is responsible for adding this to the display as // string. var composite = new ModelCompositor(model, true); - var suggestions = composite.predict([{ sample: { insert: 'ᚋ', deleteLeft: 0 }, p: 1.00 }], { + var suggestions = await composite.predict([{ sample: { insert: 'ᚋ', deleteLeft: 0 }, p: 1.00 }], { left: '᚛ᚈᚑ', startOfBuffer: false, endOfBuffer: true }); assert.lengthOf(suggestions, dummySuggestions.length); diff --git a/common/web/lm-worker/src/test/cases/worker-model-compositor.js b/common/web/lm-worker/src/test/cases/worker-model-compositor.js index 82672b4a885..716c28c61c0 100644 --- a/common/web/lm-worker/src/test/cases/worker-model-compositor.js +++ b/common/web/lm-worker/src/test/cases/worker-model-compositor.js @@ -19,7 +19,7 @@ describe('ModelCompositor', function() { {wordBreaker: wordBreakers.default} ); - it('generates suggestions with expected properties', function() { + it('generates suggestions with expected properties', async function() { let compositor = new ModelCompositor(plainModel, true); let context = { left: 'th', startOfBuffer: true, endOfBuffer: true, @@ -30,7 +30,7 @@ describe('ModelCompositor', function() { deleteLeft: 0 }; - let suggestions = compositor.predict(inputTransform, context); + let suggestions = await compositor.predict(inputTransform, context); suggestions.forEach(function(suggestion) { // Suggstions are built based on the context state BEFORE the triggering // input, replacing the prediction's root with the complete word. @@ -55,7 +55,7 @@ describe('ModelCompositor', function() { }); }); - it('strongly avoids corrections for single-character roots', function() { + it('strongly avoids corrections for single-character roots', async function() { let compositor = new ModelCompositor(plainModel, true); let context = { left: '', startOfBuffer: true, endOfBuffer: true, @@ -68,8 +68,8 @@ describe('ModelCompositor', function() { {sample: {insert: 'a', deleteLeft: 0}, p: 0.4} // but at lower weight than 'and' (998). ]; - compositor.predict({insert: '', deleteLeft: 0}, context); // Initialize context tracking first! - let suggestions = compositor.predict(inputDistribution, context); + await compositor.predict({insert: '', deleteLeft: 0}, context); // Initialize context tracking first! + let suggestions = await compositor.predict(inputDistribution, context); // remove the keep suggestion; we're not testing that here. suggestions = suggestions.filter((suggestion) => suggestion.tag != 'keep'); @@ -94,7 +94,7 @@ describe('ModelCompositor', function() { assert.isUndefined(aRange.find((suggestion) => suggestion.transform.insert.charAt(0) == 'q')); }); - it('properly handles suggestions after a backspace', function() { + it('properly handles suggestions after a backspace', async function() { let compositor = new ModelCompositor(plainModel, true); let context = { left: 'the ', startOfBuffer: true, endOfBuffer: true, @@ -105,7 +105,7 @@ describe('ModelCompositor', function() { deleteLeft: 1 }; - let suggestions = compositor.predict(inputTransform, context); + let suggestions = await compositor.predict(inputTransform, context); suggestions.forEach(function(suggestion) { // Suggestions always delete the full root of the suggestion. // @@ -117,7 +117,7 @@ describe('ModelCompositor', function() { }); }); - it('properly handles suggestions for the first letter after a ` `', function() { + it('properly handles suggestions for the first letter after a ` `', async function() { let compositor = new ModelCompositor(plainModel, true); let context = { left: 'the', startOfBuffer: true, endOfBuffer: true, @@ -128,7 +128,7 @@ describe('ModelCompositor', function() { deleteLeft: 0 }; - let suggestions = compositor.predict(inputTransform, context); + let suggestions = await compositor.predict(inputTransform, context); suggestions.forEach(function(suggestion) { // After a space, predictions are based on a new, zero-length root. // With nothing to replace, .deleteLeft should be zero. @@ -136,7 +136,7 @@ describe('ModelCompositor', function() { }); }); - it('properly handles suggestions for the first letter after a `\'`', function() { + it('properly handles suggestions for the first letter after a `\'`', async function() { let compositor = new ModelCompositor(plainModel, true); let context = { left: "the '", startOfBuffer: true, endOfBuffer: true, @@ -149,7 +149,7 @@ describe('ModelCompositor', function() { deleteLeft: 0 }; - let suggestions = compositor.predict(inputTransform, context); + let suggestions = await compositor.predict(inputTransform, context); suggestions.forEach(function(suggestion) { // Suggestions always delete the full root of the suggestion. // Which, here, didn't exist before the input. Nothing to @@ -320,20 +320,20 @@ describe('ModelCompositor', function() { } ); - it('should produce suggestions from uncased input', function() { + it('should produce suggestions from uncased input', async function() { let model = uncasedModel; - var composite = new ModelCompositor(model, true); + var compositor = new ModelCompositor(model, true); // Initialize context let context = { left: 'th', startOfBuffer: false, endOfBuffer: true, }; - composite.predict({insert: '', deleteLeft: 0}, context); + await compositor.predict({insert: '', deleteLeft: 0}, context); // Pretend to fat finger "the" as "thr" var the = { sample: { insert: 'r', deleteLeft: 0}, p: 0.45 }; var thr = { sample: { insert: 'e', deleteLeft: 0}, p: 0.55 }; - var suggestions = composite.predict([thr, the], context); + var suggestions = await compositor.predict([thr, the], context); // Get the top suggest for 'the' and 'thr*'. var theSuggestion = suggestions.filter(function (s) { return s.displayAs === 'the' || s.displayAs === '“the”'; })[0]; @@ -348,20 +348,20 @@ describe('ModelCompositor', function() { assert.isAbove(theSuggestion.p, thrSuggestion.p); }); - it('should not produce suggestions from cased input', function() { + it('should not produce suggestions from cased input', async function() { let model = uncasedModel; - var composite = new ModelCompositor(model, true); + var compositor = new ModelCompositor(model, true); // Initialize context let context = { left: 'TH', startOfBuffer: false, endOfBuffer: true, }; - composite.predict({insert: '', deleteLeft: 0}, context); + await compositor.predict({insert: '', deleteLeft: 0}, context); // Pretend to fat finger "the" as "thr" var the = { sample: { insert: 'R', deleteLeft: 0}, p: 0.45 }; var thr = { sample: { insert: 'E', deleteLeft: 0}, p: 0.55 }; - var suggestions = composite.predict([thr, the], context); + var suggestions = await compositor.predict([thr, the], context); // We should only receive a 'keep' suggestion. assert.equal(suggestions.length, 1); @@ -395,20 +395,20 @@ describe('ModelCompositor', function() { } ); - it('should produce suggestions from uncased input', function() { + it('should produce suggestions from uncased input', async function() { let model = casedModel; - var composite = new ModelCompositor(model, true); + let compositor = new ModelCompositor(model, true); // Initialize context let context = { left: 'th', startOfBuffer: false, endOfBuffer: true, }; - composite.predict({insert: '', deleteLeft: 0}, context); + await compositor.predict({insert: '', deleteLeft: 0}, context); // Pretend to fat finger "the" as "thr" var the = { sample: { insert: 'r', deleteLeft: 0}, p: 0.45 }; var thr = { sample: { insert: 'e', deleteLeft: 0}, p: 0.55 }; - var suggestions = composite.predict([thr, the], context); + var suggestions = await compositor.predict([thr, the], context); // Get the top suggest for 'the' and 'thr*'. var theSuggestion = suggestions.filter(function (s) { return s.displayAs === 'the' || s.displayAs === '“the”'; })[0]; @@ -423,20 +423,20 @@ describe('ModelCompositor', function() { assert.isAbove(theSuggestion.p, thrSuggestion.p); }); - it('should produce capitalized suggestions from fully-uppercased input', function() { + it('should produce capitalized suggestions from fully-uppercased input', async function() { let model = casedModel; - var composite = new ModelCompositor(model, true); + let compositor = new ModelCompositor(model, true); // Initialize context let context = { left: 'TH', startOfBuffer: false, endOfBuffer: true, }; - composite.predict({insert: '', deleteLeft: 0}, context); + await compositor.predict({insert: '', deleteLeft: 0}, context); // Pretend to fat finger "the" as "thr" var the = { sample: { insert: 'R', deleteLeft: 0}, p: 0.45 }; var thr = { sample: { insert: 'E', deleteLeft: 0}, p: 0.55 }; - var suggestions = composite.predict([thr, the], context); + var suggestions = await compositor.predict([thr, the], context); // Get the top suggest for 'the' and 'thr*'. var theSuggestion = suggestions.filter(function (s) { return s.displayAs === 'THE' || s.displayAs === '“THE”'; })[0]; @@ -451,20 +451,20 @@ describe('ModelCompositor', function() { assert.isAbove(theSuggestion.p, thrSuggestion.p); }); - it('should produce "initial-case" suggestions from input with an initial capital', function() { + it('should produce "initial-case" suggestions from input with an initial capital', async function() { let model = casedModel; - var composite = new ModelCompositor(model, true); + let compositor = new ModelCompositor(model, true); // Initialize context let context = { left: 'Th', startOfBuffer: false, endOfBuffer: true, }; - composite.predict({insert: '', deleteLeft: 0}, context); + await compositor.predict({insert: '', deleteLeft: 0}, context); // Pretend to fat finger "the" as "thr" var the = { sample: { insert: 'r', deleteLeft: 0}, p: 0.45 }; var thr = { sample: { insert: 'e', deleteLeft: 0}, p: 0.55 }; - var suggestions = composite.predict([thr, the], context); + var suggestions = await compositor.predict([thr, the], context); // Get the top suggest for 'the' and 'thr*'. var theSuggestion = suggestions.filter(function (s) { return s.displayAs === 'The' || s.displayAs === '“The”'; })[0]; @@ -479,20 +479,20 @@ describe('ModelCompositor', function() { assert.isAbove(theSuggestion.p, thrSuggestion.p); }); - it('also from input with partial capitalization when including an initial capital', function() { + it('also from input with partial capitalization when including an initial capital', async function() { let model = casedModel; - var composite = new ModelCompositor(model, true); + let compositor = new ModelCompositor(model, true); // Initialize context let context = { left: 'TH', startOfBuffer: false, endOfBuffer: true, }; - composite.predict({insert: '', deleteLeft: 0}, context); + await compositor.predict({insert: '', deleteLeft: 0}, context); // Pretend to fat finger "the" as "thr" var the = { sample: { insert: 'r', deleteLeft: 0}, p: 0.45 }; var thr = { sample: { insert: 'e', deleteLeft: 0}, p: 0.55 }; - var suggestions = composite.predict([thr, the], context); + var suggestions = await compositor.predict([thr, the], context); // Get the top suggest for 'the' and 'thr*'. var theSuggestion = suggestions.filter(function (s) { return s.displayAs.startsWith('The'); })[0]; @@ -508,22 +508,22 @@ describe('ModelCompositor', function() { }); describe('Prediction with legacy Models (12.0 / 13.0)', function() { - it('should compose suggestions from a fat-fingered keypress (no keying needed)', function () { + it('should compose suggestions from a fat-fingered keypress (no keying needed)', async function () { var model = new TrieModel( jsonFixture('models/tries/english-1000') ); - var composite = new ModelCompositor(model, true); + let compositor = new ModelCompositor(model, true); // Initialize context let context = { left: 'th', startOfBuffer: false, endOfBuffer: true, }; - composite.predict({insert: '', deleteLeft: 0}, context); + await compositor.predict({insert: '', deleteLeft: 0}, context); // Pretend to fat finger "the" as "thr" var the = { sample: { insert: 'r', deleteLeft: 0}, p: 0.45 }; var thr = { sample: { insert: 'e', deleteLeft: 0}, p: 0.55 }; - var suggestions = composite.predict([thr, the], context); + var suggestions = await compositor.predict([thr, the], context); // Get the top suggest for 'the' and 'thr*'. var theSuggestion = suggestions.filter(function (s) { return s.displayAs === 'the' || s.displayAs === '“the”'; })[0]; @@ -538,22 +538,22 @@ describe('ModelCompositor', function() { assert.isAbove(theSuggestion.p, thrSuggestion.p); }); - it('should compose suggestions from a fat-fingered keypress (keying needed)', function () { + it('should compose suggestions from a fat-fingered keypress (keying needed)', async function () { var model = new TrieModel( jsonFixture('models/tries/english-1000') ); - var composite = new ModelCompositor(model, true); + let compositor = new ModelCompositor(model, true); // Initialize context let context = { left: 'Th', startOfBuffer: false, endOfBuffer: true, }; - composite.predict({insert: '', deleteLeft: 0}, context); + await compositor.predict({insert: '', deleteLeft: 0}, context); // Pretend to fat finger "the" as "thr" var the = { sample: { insert: 'r', deleteLeft: 0}, p: 0.45 }; var thr = { sample: { insert: 'e', deleteLeft: 0}, p: 0.55 }; - var suggestions = composite.predict([thr, the], context); + var suggestions = await compositor.predict([thr, the], context); // Get the top suggest for 'the' and 'thr*'. // As of 15.0+, because of #5429, the keep suggestion `"The"` will not be merged @@ -841,7 +841,7 @@ describe('ModelCompositor', function() { // While this isn't a state the LMLayer should ever operate within, this provides // a useful base state for developing further tests against the method. - it('model without traversals: returns appropriate suggestions upon reversion', function() { + it('model without traversals: returns appropriate suggestions upon reversion', async function() { // This setup matches 'acceptSuggestion' the test case // it('first word of context + postTransform provided, .deleteLeft > 0') // seen earlier in the file. @@ -878,7 +878,7 @@ describe('ModelCompositor', function() { let appliedContext = models.applyTransform(baseSuggestion.transform, baseContext); assert.equal(appliedContext.left, "hello "); - let suggestions = compositor.applyReversion(reversion, appliedContext); + let suggestions = await compositor.applyReversion(reversion, appliedContext); // As this test is a bit... 'hard-wired', we only get the 'keep' suggestion. // It should still be accurate, though. @@ -891,7 +891,7 @@ describe('ModelCompositor', function() { assert.deepEqual(suggestions[0].transform, expectedTransform); }); - it('model with traversals: returns appropriate suggestions upon reversion', function() { + it('model with traversals: returns appropriate suggestions upon reversion', async function() { // This setup matches 'acceptSuggestion' the test case // it('first word of context + postTransform provided, .deleteLeft > 0') // seen earlier in the file. @@ -911,7 +911,7 @@ describe('ModelCompositor', function() { let model = new models.TrieModel(jsonFixture('models/tries/english-1000'), {punctuation: englishPunctuation}); let compositor = new ModelCompositor(model, true); - let initialSuggestions = compositor.predict(postTransform, baseContext); + let initialSuggestions = await compositor.predict(postTransform, baseContext); let keepSuggestion = initialSuggestions[0]; assert.equal(keepSuggestion.tag, 'keep'); // corresponds to `postTransform`, but the transform isn't equal. @@ -921,13 +921,13 @@ describe('ModelCompositor', function() { assert.equal(reversion.id, -baseSuggestion.id); let appliedContext = models.applyTransform(baseSuggestion.transform, baseContext); - let reversionSuggestions = compositor.applyReversion(reversion, appliedContext); + let reversionSuggestions = await compositor.applyReversion(reversion, appliedContext); // The returned suggestion list should match the original suggestion list. assert.deepEqual(reversionSuggestions, initialSuggestions); }); - it('model with traversals: properly tracks context state', function() { + it('model with traversals: properly tracks context state', async function() { // Could be merged with the previous test case, but I think it's good to have the error // sets flagged separately. @@ -946,7 +946,7 @@ describe('ModelCompositor', function() { let model = new models.TrieModel(jsonFixture('models/tries/english-1000'), {punctuation: englishPunctuation}); let compositor = new ModelCompositor(model, true); - let initialSuggestions = compositor.predict(postTransform, baseContext); + let initialSuggestions = await compositor.predict(postTransform, baseContext); const suggestionContextState = compositor.contextTracker.newest; let keepSuggestion = initialSuggestions[0]; diff --git a/common/web/lm-worker/src/test/cases/worker-predict.js b/common/web/lm-worker/src/test/cases/worker-predict.js index f050998e878..cbc4d433d3f 100644 --- a/common/web/lm-worker/src/test/cases/worker-predict.js +++ b/common/web/lm-worker/src/test/cases/worker-predict.js @@ -8,10 +8,11 @@ import LMLayerWorker from '#./index.js'; import { configWorker, createMessageEventWithData, emptyContext, iGotDistractedByHazel, importScriptsWith, randomToken, zeroTransform } from '@keymanapp/common-test-resources/model-helpers.mjs'; +import { timedPromise } from '@keymanapp/web-utils'; describe('LMLayerWorker', function () { describe('#predict()', function () { - it('should send back suggestions', function () { + it('should send back suggestions', async function () { var suggestion = { transform: { insert: 'I ', @@ -63,6 +64,10 @@ describe('LMLayerWorker', function () { context: emptyContext() })); + // predict() is async, so we need to relinquish control flow temporarily + // in order for a return message to become available. + await timedPromise(500); + // Retrieve the internal 'dummy' suggestions for comparison. var hazel = iGotDistractedByHazel(); From 4fd8e6b9186efc93656b1412e08e69484d6ce1cf Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Thu, 11 Jan 2024 12:37:41 +0700 Subject: [PATCH 2/3] chore(web): Apply suggestions from code review Co-authored-by: Marc Durdin --- common/web/lm-worker/src/main/model-compositor.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/common/web/lm-worker/src/main/model-compositor.ts b/common/web/lm-worker/src/main/model-compositor.ts index 8d697c309c6..8efd69f2bdb 100644 --- a/common/web/lm-worker/src/main/model-compositor.ts +++ b/common/web/lm-worker/src/main/model-compositor.ts @@ -657,15 +657,14 @@ export default class ModelCompositor { let compositor = this; let fallbackSuggestions = function() { let revertedContext = models.applyTransform(reversion.transform, context); - return compositor.predict({insert: '', deleteLeft: 0}, revertedContext).then((suggestions) => { - suggestions.forEach(function(suggestion) { - // A reversion's transform ID is the additive inverse of its original suggestion; - // we revert to the state of said original suggestion. - suggestion.transformId = -reversion.transformId; - }); - - return suggestions; + const suggestions = await compositor.predict({insert: '', deleteLeft: 0}, revertedContext); + suggestions.forEach(function(suggestion) { + // A reversion's transform ID is the additive inverse of its original suggestion; + // we revert to the state of said original suggestion. + suggestion.transformId = -reversion.transformId; }); + + return suggestions; } if(!this.contextTracker) { From dcc51a29eb042875cbffd6800c18a8b5e0579d71 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Thu, 11 Jan 2024 13:51:08 +0700 Subject: [PATCH 3/3] fix(web): await from prior suggestion missing async --- common/web/lm-worker/src/main/model-compositor.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/web/lm-worker/src/main/model-compositor.ts b/common/web/lm-worker/src/main/model-compositor.ts index 8efd69f2bdb..5b60d37a073 100644 --- a/common/web/lm-worker/src/main/model-compositor.ts +++ b/common/web/lm-worker/src/main/model-compositor.ts @@ -655,7 +655,7 @@ export default class ModelCompositor { // If we are unable to track context (because the model does not support LexiconTraversal), // we need a "fallback" strategy. let compositor = this; - let fallbackSuggestions = function() { + let fallbackSuggestions = async function() { let revertedContext = models.applyTransform(reversion.transform, context); const suggestions = await compositor.predict({insert: '', deleteLeft: 0}, revertedContext); suggestions.forEach(function(suggestion) {