diff --git a/common/predictive-text/docs/worker-communication-protocol.md b/common/predictive-text/docs/worker-communication-protocol.md index f109ed5c7c..f6d9315871 100644 --- a/common/predictive-text/docs/worker-communication-protocol.md +++ b/common/predictive-text/docs/worker-communication-protocol.md @@ -128,6 +128,8 @@ Message | Direction | Parameters | Expected reply | `accept` | keyboard → LMLayer | suggestion, | Yes - `postaccept` | Yes | context, transform | | `postaccept` | LMLayer → keyboard | reversion | No | Yes +`revert` | keyboard → LMLayer | reversion, context | Yes - `reversion` | Yes +`postrevert` | LMLayer → keyboard | suggestions | No | Yes ### Message: `config` diff --git a/common/predictive-text/index.ts b/common/predictive-text/index.ts index 1d20c13ddf..2d5ef53f5b 100644 --- a/common/predictive-text/index.ts +++ b/common/predictive-text/index.ts @@ -54,6 +54,7 @@ namespace com.keyman.text.prediction { private _predictPromises: PromiseStore; private _wordbreakPromises: PromiseStore; private _acceptPromises: PromiseStore; + private _revertPromises: PromiseStore; private _nextToken: number; private capabilities: Capabilities; @@ -71,6 +72,7 @@ namespace com.keyman.text.prediction { this._predictPromises = new PromiseStore(); this._wordbreakPromises = new PromiseStore(); this._acceptPromises = new PromiseStore(); + this._revertPromises = new PromiseStore(); this._nextToken = Number.MIN_SAFE_INTEGER; this.sendConfig(capabilities); @@ -154,6 +156,19 @@ namespace com.keyman.text.prediction { }); } + revertSuggestion(reversion: Reversion, context: Context): Promise { + let token = this._nextToken++; + return new Promise((resolve, reject) => { + this._revertPromises.make(token, resolve, reject); + this._worker.postMessage({ + message: 'revert', + token: token, + reversion: reversion, + context: context + }) + }); + } + // TODO: asynchronous close() method. // Worker code must recognize message and call self.close(). @@ -173,6 +188,8 @@ namespace com.keyman.text.prediction { this._wordbreakPromises.keep(payload.token, payload.word); } else if (payload.message === 'postaccept') { this._acceptPromises.keep(payload.token, payload.reversion); + } else if (payload.message === 'postrevert') { + this._revertPromises.keep(payload.token, payload.suggestions); } else { // This branch should never execute, but just in case... //@ts-ignore diff --git a/common/predictive-text/message.d.ts b/common/predictive-text/message.d.ts index 8765a616e9..823bfa5e68 100644 --- a/common/predictive-text/message.d.ts +++ b/common/predictive-text/message.d.ts @@ -30,8 +30,8 @@ type Token = number; /** * The valid outgoing message kinds. */ -type OutgoingMessageKind = 'error' | 'ready' | 'suggestions' | 'currentword' | 'postaccept'; -type OutgoingMessage = ErrorMessage | ReadyMessage | SuggestionMessage | CurrentWordMessage | PostAcceptMessage; +type OutgoingMessageKind = 'error' | 'ready' | 'suggestions' | 'currentword' | 'postaccept' | 'postrevert'; +type OutgoingMessage = ErrorMessage | ReadyMessage | SuggestionMessage | CurrentWordMessage | PostAcceptMessage | PostRevertMessage; interface ErrorMessage { message: 'error'; @@ -95,7 +95,7 @@ interface PostAcceptMessage { /** * Opaque, unique token that pairs this message - * with the wordbreak message that initiated it. + * with the accept message that initiated it. */ token: Token; @@ -105,6 +105,22 @@ interface PostAcceptMessage { reversion: Reversion; } +interface PostRevertMessage { + message: 'postrevert'; + + /** + * Opaque, unique token that pairs this message + * with the revert message that initiated it. + */ + token: Token; + + /** + * The original set of Suggestions returned that included the + * reverted Suggestion + */ + suggestions: Suggestion[]; +} + /** * Describes what kind of model to instantiate. */ diff --git a/common/predictive-text/worker/correction/context-tracker.ts b/common/predictive-text/worker/correction/context-tracker.ts index cc64dbe9d6..f6d9e68058 100644 --- a/common/predictive-text/worker/correction/context-tracker.ts +++ b/common/predictive-text/worker/correction/context-tracker.ts @@ -35,7 +35,6 @@ namespace correction { revert() { delete this.activeReplacementId; - delete this.replacements; } } @@ -188,7 +187,7 @@ namespace correction { return undefined; } - return this.item[0]; + return this.item(0); } get newest(): Item { @@ -196,7 +195,7 @@ namespace correction { return undefined; } - return this.item[this.count - 1]; + return this.item(this.count - 1); } enqueue(item: Item): Item { @@ -224,6 +223,21 @@ namespace correction { } } + popNewest(): Item { + if(this.currentTail == this.currentHead) { + return null; + } else { + let item = this.circle[this.currentHead]; + this.currentHead = (this.currentHead - 1 + this.maxCount) % this.maxCount; + return item; + } + } + + /** + * Returns items contained within the circular array, ordered from 'oldest' to 'newest' - + * the same order in which the items will be dequeued. + * @param index + */ item(index: number) { if(index >= this.count) { throw "Invalid array index"; diff --git a/common/predictive-text/worker/index.ts b/common/predictive-text/worker/index.ts index a828033dd6..5d296f42d2 100644 --- a/common/predictive-text/worker/index.ts +++ b/common/predictive-text/worker/index.ts @@ -280,7 +280,7 @@ class LMLayerWorker { switch(payload.message) { case 'predict': var {transform, context} = payload; - let suggestions = compositor.predict(transform, context); + var suggestions = compositor.predict(transform, context); // Now that the suggestions are ready, send them out! this.cast('suggestions', { @@ -308,8 +308,17 @@ class LMLayerWorker { reversion: reversion }); break; + case 'revert': + var {reversion, context} = payload; + var suggestions: Suggestion[] = compositor.applyReversion(reversion, context); + + this.cast('postrevert', { + token: payload.token, + suggestions: suggestions + }); + break; default: - throw new Error(`invalid message; expected one of {'predict', 'wordbreak', 'accept', 'unload'} but got ${payload.message}`); + throw new Error(`invalid message; expected one of {'predict', 'wordbreak', 'accept', 'revert', 'unload'} but got ${payload.message}`); } }, compositor: compositor diff --git a/common/predictive-text/worker/model-compositor.ts b/common/predictive-text/worker/model-compositor.ts index 1f93cb62e9..0d63a59e91 100644 --- a/common/predictive-text/worker/model-compositor.ts +++ b/common/predictive-text/worker/model-compositor.ts @@ -434,6 +434,49 @@ class ModelCompositor { return reversion; } + + applyReversion(reversion: Reversion, context: Context): Suggestion[] { + // 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); + return compositor.predict({ insert: '', deleteLeft: 0}, revertedContext); + } + + if(!this.contextTracker) { + return fallbackSuggestions(); + } + + // When the context is tracked, we prefer the tracked information. + let contextMatchFound = false; + for(let c = this.contextTracker.count - 1; c >= 0; c--) { + let contextState = this.contextTracker.item(c); + + if(contextState.tail.activeReplacementId == -reversion.id) { + contextMatchFound = true; + break; + } + } + + if(!contextMatchFound) { + return fallbackSuggestions(); + } + + // Remove all contexts more recent than the one we're reverting to. + while(this.contextTracker.newest.tail.activeReplacementId != -reversion.id) { + this.contextTracker.popNewest(); + } + + this.contextTracker.newest.tail.revert(); + + // Will need to be modified a bit if/when phrase-level suggestions are implemented. + // Those will be tracked on the first token of the phrase, which won't be the tail + // if they cover multiple tokens. + return this.contextTracker.newest.tail.replacements.map(function(trackedSuggestion) { + return trackedSuggestion.suggestion; + }); + } } /** diff --git a/common/predictive-text/worker/worker-interfaces.ts b/common/predictive-text/worker/worker-interfaces.ts index db503a09c7..ba56438d60 100644 --- a/common/predictive-text/worker/worker-interfaces.ts +++ b/common/predictive-text/worker/worker-interfaces.ts @@ -38,8 +38,8 @@ type ImportScripts = typeof DedicatedWorkerGlobalScope.prototype.importScripts; /** * The valid incoming message kinds. */ -type IncomingMessageKind = 'config' | 'load' | 'predict' | 'unload' | 'wordbreak' | 'accept'; -type IncomingMessage = ConfigMessage | LoadMessage | PredictMessage | UnloadMessage | WordbreakMessage | AcceptMessage; +type IncomingMessageKind = 'config' | 'load' | 'predict' | 'unload' | 'wordbreak' | 'accept' | 'revert'; +type IncomingMessage = ConfigMessage | LoadMessage | PredictMessage | UnloadMessage | WordbreakMessage | AcceptMessage | RevertMessage; /** * The structure of a config message. It should include the platform's supported @@ -149,6 +149,27 @@ interface AcceptMessage { postTransform?: Transform; } +interface RevertMessage { + message: 'revert'; + + /** + * Opaque, unique token that pairs this accept message with its return message. + */ + token: Token; + + /** + * The Reversion being applied. The ID must be assigned and should be the additive inverse + * of the Suggestion being reverted. + */ + reversion: Reversion; + + /** + * The Context being reverted, which should be the same context as resulted from applying the + * corresponding Suggestion. + */ + context: Context; +} + /** * The LMLayer can be in one of the following states. The LMLayer can only produce predictions in the 'ready' state. */