diff --git a/common/predictive-text/README.md b/common/predictive-text/README.md index 5f41cea69e..4b91416a6c 100644 --- a/common/predictive-text/README.md +++ b/common/predictive-text/README.md @@ -1,408 +1,61 @@ -Keyman/Language Modelling layer -================================ +Language Modelling Layer (LMLayer) +================================== -This document introduces the protocol for communicating between -KeymanWeb and the language modeling layer (i.e., the configurable -prediction and suggestion engine, (henceforth referred to as the -"LMLayer"). +Provide predictions and corrections while you type! -Note: I'm using the term **keyboard** as a synonym for **KeymanWeb**. +See [Worker Communication Protocol](./docs/worker-communication-protocol.md) for a +semi-formal specification on how the Worker and the main thread communicate. -> The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL -> NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and -> "OPTIONAL" in this document are to be interpreted as described in -> [RFC 2119]. +System dependencies +------------------- -[RFC 2119]: https://www.ietf.org/rfc/rfc2119.txt +You will need Bash and Node.js >= 6.0. +Build +----- -Communication protocol between keyboard and asynchronous worker ---------------------------------------------------------------- +Run `build.sh`. This will also automatically install dependencies with `npm`. -![Sequence diagram of obtaining a prediction](./docs/predictive-text-sequence.png) - -We have decided that everything to the right of the `KeymanWeb` will be -in a Web Worker. However, communication can happen only through -[`postMessage(data)`][postMessage] commands, -where `data` is a serializable object (via the [structured clone][] -algorithm). - -What serializable object can we send that will adhere to the [open-closed principle]? - -### Messages - -The idea is to use a [discriminated union][]. The protocol involves -plain JavaScript objects with one property called `message` that takes -a finite set of `string` values. - -These string values indicate what message should be sent. The rest of -the properties in the object are the parameters send with the message. - -```javascript -{ - message: 'predict', - // message-specific properties here -} +```sh +./build.sh ``` -See also: [XML-RPC][] +### Two-stage compilation process -Messages are **not** methods. That is, there is no assumption that -a client will receive a reply when a message is sent. However, some -pairs of messages, such as `predict` and `suggestions`, lightly assume -request/response semantics. +Since the primary LMLayer code runs within a [Web Worker][], `build.sh` compiles the +LMLayer in two stages: -[discriminated union]: http://www.typescriptlang.org/docs/handbook/advanced-types.html#discriminated-unions -[open-closed principle]: https://en.wikipedia.org/wiki/Open%E2%80%93closed_principle -[XML-RPC]: https://en.wikipedia.org/wiki/XML-RPC -[structured clone]: https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm -[postMessage]: https://developer.mozilla.org/en-US/docs/Web/API/Worker/postMessage + 1. Compile the inner worker code + 1. Compile the TypeScript sources for _only_ the Worker code. + 2. Wrap the Worker code as `embedded_worker.js` + 2. Compile the top-level code + 1. Include `embedded_worker.js` verbatim using a TypeScript directive. + 2. Compile the top-level TypeScript code. + 3. Unwrap the Worker code at runtime. -### Tokens +[Web Worker]: https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers -Tokens uniquely identify an input event, such as a key press. Since -Keyman should ask for an asynchronous prediction on most key presses, the -token is intended to associate a prediction and its response with -a particular input; Keyman is free to ignore prediction responses if -they are for outdated input events. +Test +---- -The `Token` type is opaque to LMLayer. That is, LMLayer does not inspect -its contents; it simply uses it to identify a request and pass it back -to Keyman. There are a few requirements on the concrete type of the -`Token`: +This will run both headless unit tests, and in-browser unit tests and integration +tests: - 1. Tokens **MUST** be serializable via the [structured clone][] algorithm; - 2. Tokens **MUST** be usable as a key in a [`Map`][Map object] object. - 3. Tokens **MUST** be unique across messages. That is, tokens **MUST - NOT** be duplicated between different messages. - -It is up to the keyboard to create unambiguous tokens that can be uniquely -identified through the round-trip process. - -In the following examples, I'll use the subset of `number` values that -are interpretable as [31-bit signed integers][Smi]. - -[Map object]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map#Key_equality -[Smi]: https://github.com/thlorenz/v8-perf/blob/master/data-types.md#efficiently-representing-values-and-tagging -[Tokens]: #tokens - -### Example - -An asynchronous message to predict after typing 'D': - -```javascript -{ - message: 'predict', - - token: 1, - transform: { - insert: 'D', - deleteLeft: 0, - deleteRight: 0 - }, - context: { - left: '', - right: '', - startOfBuffer: true, - endOfBuffer: true - }, -} +```sh +./build.sh -test ``` -Message types -------------- +### Test-Driven Development -Currently there are four message types: +I like to use [entr]() to automatically build and re-run the unit tests anytime I +change a source code file. Here's the command I run in separate window: -Message | Direction | Parameters | Expected reply | Uses token ---------------|--------------------|---------------------|---------------------|--------------- -`initialize` | keyboard → LMLayer | initialization | Yes — `ready` | No -`ready` | LMLayer → keyboard | configuration | No | No -`predict` | keyboard → LMLayer | transform, context | Yes — `suggestions` | Yes -`suggestions` | LMLayer → keyboard | suggestions | No | Yes - - -### Message: `initialize` - -Must be sent from the keyboard to the LMLayer so that the LMLayer -initializes a model. It will send `initialization` which is a plain -JavaScript object specify the path to the model, as well configurations -and platform restrictions. - -The keyboard **MUST NOT** send any messages to the LMLayer prior to -sending `initialize`. The keyboard **SHOULD NOT** send another message -to the keyboard until it receives `ready` message from the LMLayer -before sending another message. - -These are the configurations, and platform restrictions sent to -initialize the LMLayer and its model. - -```typescript -interface InitializeMessage { - message: 'initialize'; - - /** - * Path to the model. There are no concrete restrictions on the path - * to the model, so long as the LMLayer can successfully use it to - * initialize the model. - */ - model: string; - - configuration: { - /** - * Whether the platform supports right contexts. - * The absence of this rule implies false. - */ - supportsRightContexts?: false, - - /** - * Whether the platform supports deleting to the right. - * The absence of this rule implies false. - */ - supportsDeleteRight?: false, - - /** - * The maximum amount of UTF-16 code units that the keyboard will - * provide to the left of the cursor, as an integer. - */ - maxLeftContextCodeUnits: number, - - /** - * The maximum amount of code units that the keyboard will provide to - * the right of the cursor, as an integer. The absence of this rule - * implies 0. - * See also, [[supportsRightContexts]]. - */ - maxRightContextCodeUnits?: number, - } -} +```sh +git ls-files | entr -c ./build.sh -tdd ``` +Importantly, `./build.sh -tdd` skips running the in-browser tests, and skips +downloading/updating `npm` dependencies. -### Message: `ready` - -Must be sent from the LMLayer to the keyboard when the LMLayer's model -as a response to `initialize`. It will send `configuration`, which is -a plain JavaScript object requesting configuration from the keyboard. - -There are only two options defined so far: - -```typescript -interface ReadyMessage { - message: 'ready'; - configuration: { - /** - * How many UTF-16 code units maximum to send as the context to the - * left of the cursor ("left" in the Unicode character stream). - * - * Affects the `context` property sent in `predict` messages. - * - * While the left context MUST NOT bisect surrogate pairs, they MAY - * bisect graphical clusters. - */ - leftContextCodeUnits: number, - - /** - * How many UTF-16 code units maximum to send as the context to the - * right of the cursor ("right" in the Unicode character stream). - * - * Affects the `context` property sent in `predict` messages. - * - * While the left context MUST NOT bisect surrogate pairs, they MAY - * bisect graphical clusters. - */ - rightContextCodeUnits: number, - }; -} -``` - - -### Message: `predict` - -Sent from the keyboard to the LMLayer whenever a new prediction should -be generated. This is typically initiated by a key press event. The -keyboard **SHOULD** track each `predict` message using a [token][Tokens]. The -token **MUST** be unique across all prediction events. The LMLayer -**SHOULD** respond to each `predict` message with a `suggestions` -message. The `suggestions` message **MUST** contain the corresponding -token as sent in the initial `predict` message. - -The keyboard **MUST** send the `context` parameter. The keyboard -**SHOULD** send the `transform` parameter. The keyboard **MUST** send -a unique token. - -The semantics of the `predict` message **MUST** be from the -perspective of this sequence of events: - - 1. After the input event is received by the keyboard. - 2. Before the keyboard applies the associated `transform` to the buffer. - -**NOTE**: The keyboard **MAY** apply the `transform` associated with the -input event before receiving the corresponding `suggestions` message -from the LMLayer. The intention is that once the suggestions are displayed, -the typist may select one of the suggestions in the place of the effects -of their original input. - -**NOTE**: The keyboard **MAY** send the `predict` message after it has -applied the `transform` associated with the input event to the buffer. -Regardless of the actual sequence, the semantics **MUST** remain the -same:the prediction happens from the perspective before the `transform` -has been applied. Therefore, if the keyboard eagerly transforms the -buffer before it has sent `predict` message, it must anticipate _undoing_ -the `transform` it has already applied if it is to apply a `transfrom` -send in the `suggestions` message. The keyboard must act as if it has -never applied the `transform` associated with the input event in the -first place. - -The context is the text surrounding the insertion point, _before_ the -transform is applied to the buffer. - -```typescript -interface Context { - /** - * Up to maxLeftContextCodeUnits code units of Unicode scalar value - * (i. e., characters) to the left of the insertion point in the - * buffer. If there is nothing to the left of the buffer, this returns - * an empty string. - */ - left: USVString; - - /** - * Up to maxRightContextCodeUnits code units of Unicode scalar value - * (i. e., characters) to the right of the insertion point in the - * buffer. If there is nothing to the right of the buffer, this returns - * an empty string. - */ - right?: USVString; - - /** - * Whether the insertion point is at the start of the buffer. - */ - startOfBuffer: boolean; - - /** - * Whether the insertion point is at the end of the buffer. - */ - endOfBuffer: boolean; -} -``` - -The transform parameter describes how the input event will change the -buffer. - -```typescript -interface Transform { - /** - * The Unicode scalar values (i.e., characters) to be inserted at the - * cursor position. - * - * Corresponds to `s` in com.keyman.KeyboardInterface.output. - */ - insert: USVString; - - /** - * The number of code units to delete to the left of the cursor. - * - * Corresponds to `dn` in com.keyman.KeyboardInterface.output. - */ - delete: number; - - /** - * The number of code units to delete to the right of the cursor. - * Not available on all platforms. - */ - deleteRight?: number; -} -``` - - -### Message: `suggestions` - -The `suggestions` message is sent from the LMLayer to the keyboard. This -message sends a ranked array of suggestions, in descending order of -probability (i.e., entry `0` is most likely, followed by entry `1`, -etc.). This message **MUST** be in response to a `predict` message, and -it **MUST** respond with the corresponding [token]. - -```typescript -/** - * `suggestions` is an ordered array of suggestion objects. - * Each suggestion is a transform bundled with a `displayAs` property. - */ -let suggestions = Suggestion[]; - -interface Suggestion { - /** - * Same object as an input event transform. - * Note that the transform is applied AFTER the input event - * transform. - */ - transform: Transform; - /** - * A string to display the suggestion to the typist. - * This should aid the typist understand what the transform - * will do to their text. - */ - displayAs: string; -} -``` - -```javascript -let suggestions = [ - { - transform: { - insert: 'teapot', - deleteLeft: 1, - deleteRight: 0 - }, - displayAs: '🍵' - } -]; -``` - - -#### Timing - -Each suggestion provides a `transform`. This transform is applied -_after_ the transform associated with the input event that initiated -this prediction. That is, the suggested transform applies to the buffer -after the transform associated with the input event. - -Rephrased in somewhat mathematical terms: -Let 𝑥 ∈ 𝐵 be the input text buffer. Let 𝑇𝑖 be the transform -associated with an input event that maps a text buffer 𝐵 → 𝐵. Let -𝑇𝑠 be a transform suggested through the LMLayer. 𝑦 ∈ 𝐵 is the -text buffer after the suggestion transform has been applied. The -correct sequence of applications should be as follows: - -> 𝑦 = 𝑇𝑠(𝑇𝑖(𝑥)) - - -#### Late suggestions - -Sometimes, a `suggestions` message may arrive after an input event has -already invalidated its request. This is called a **"late" suggestion**. -The LMLayer **MAY** send late suggestions. Consequently, the keyboard -**MAY** discard the late suggestions. There is no requirement for the -keyboard to acknowledge late suggestions, or for the LMLayer to avoid -sending late `suggestions` messages. In either case, a `suggestions` -message can be identified as appropriate or "late" via its `token` -property. - - -TODO -==== - - - [x] Document `suggestions` - - [x] TypeScript! - - [ ] make simple `index.html` that demos a dummy model - - [x] Update class definitions in README from TypeScript sources. - - [ ] Do word segmentation - - [ ] Determine the exact arguments given to the model's `predict()` - method. - - [ ] Make an `error` initialization message. - - [ ] Make a `cancel` message. - - [ ] LOGLIKEIHOOD IN THE TRANSFORM! - - [ ] `possibleTransforms` where `transform` is an alias for `possibleTransforms[0]` - - [ ] Use [puppeteer](https://github.com/GoogleChrome/puppeteer)? +[entr]: http://eradman.com/entrproject/ diff --git a/common/predictive-text/build.sh b/common/predictive-text/build.sh index a3f55d303d..f48c658753 100755 --- a/common/predictive-text/build.sh +++ b/common/predictive-text/build.sh @@ -4,11 +4,15 @@ # Designed for optimal compatibility with the Keyman Suite. # +# Include some helper functions from resources +. ../../resources/shellHelperFunctions.sh + LMLAYER_OUTPUT=build WORKER_OUTPUT=build/intermediate NAKED_WORKER=$WORKER_OUTPUT/index.js EMBEDDED_WORKER=$WORKER_OUTPUT/embedded_worker.js + # Build the worker and the main script. build ( ) { # Build worker first; the main file depends on it. @@ -53,21 +57,6 @@ display_usage ( ) { echo " -test runs unit and integration tests after building" } -# Prints a nice, common error message. -fail ( ) { - # TODO: source shellHelperFunctions.sh - local ERROR_RED - local NORMAL - ERROR_RED="$(tput setaf 1)" - NORMAL="$(tput sgr0)" - FAILURE_MSG="$1" - if [[ "$FAILURE_MSG" == "" ]]; then - FAILURE_MSG="Unknown failure." - fi - echo "$0: ${ERROR_RED}$FAILURE_MSG${NORMAL}" - exit 1 -} - # Creates embedded_worker.js. Must be run after the worker is built for the # first time wrap-worker ( ) { diff --git a/common/predictive-text/docs/worker-communication-protocol.md b/common/predictive-text/docs/worker-communication-protocol.md new file mode 100644 index 0000000000..dd269e0dd0 --- /dev/null +++ b/common/predictive-text/docs/worker-communication-protocol.md @@ -0,0 +1,391 @@ +Keyman/Language Modelling layer +================================ + +This document introduces the protocol for communicating between +KeymanWeb and the language modeling layer (i.e., the configurable +prediction and suggestion engine, (henceforth referred to as the +"LMLayer"). + +Note: I'm using the term **keyboard** as a synonym for **KeymanWeb**. + +> The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL +> NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and +> "OPTIONAL" in this document are to be interpreted as described in +> [RFC 2119]. + +[RFC 2119]: https://www.ietf.org/rfc/rfc2119.txt + + +Communication protocol between keyboard and asynchronous worker +--------------------------------------------------------------- + +![Sequence diagram of obtaining a prediction](./predictive-text-sequence.png) + +We have decided that everything to the right of the `KeymanWeb` will be +in a Web Worker. However, communication can happen only through +[`postMessage(data)`][postMessage] commands, +where `data` is a serializable object (via the [structured clone][] +algorithm). + +What serializable object can we send that will adhere to the [open-closed principle]? + +### Messages + +The idea is to use a [discriminated union][]. The protocol involves +plain JavaScript objects with one property called `message` that takes +a finite set of `string` values. + +These string values indicate what message should be sent. The rest of +the properties in the object are the parameters send with the message. + +```javascript +{ + message: 'predict', + // message-specific properties here +} +``` + +See also: [XML-RPC][] + +Messages are **not** methods. That is, there is no assumption that +a client will receive a reply when a message is sent. However, some +pairs of messages, such as `predict` and `suggestions`, lightly assume +request/response semantics. + +[discriminated union]: http://www.typescriptlang.org/docs/handbook/advanced-types.html#discriminated-unions +[open-closed principle]: https://en.wikipedia.org/wiki/Open%E2%80%93closed_principle +[XML-RPC]: https://en.wikipedia.org/wiki/XML-RPC +[structured clone]: https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm +[postMessage]: https://developer.mozilla.org/en-US/docs/Web/API/Worker/postMessage + + +### Tokens + +Tokens uniquely identify an input event, such as a key press. Since +Keyman should ask for an asynchronous prediction on most key presses, the +token is intended to associate a prediction and its response with +a particular input; Keyman is free to ignore prediction responses if +they are for outdated input events. + +The `Token` type is opaque to LMLayer. That is, LMLayer does not inspect +its contents; it simply uses it to identify a request and pass it back +to Keyman. There are a few requirements on the concrete type of the +`Token`: + + 1. Tokens **MUST** be serializable via the [structured clone][] algorithm; + 2. Tokens **MUST** be usable as a key in a [`Map`][Map object] object. + 3. Tokens **MUST** be unique across messages. That is, tokens **MUST + NOT** be duplicated between different messages. + +It is up to the keyboard to create unambiguous tokens that can be uniquely +identified through the round-trip process. + +In the following examples, I'll use the subset of `number` values that +are interpretable as [31-bit signed integers][Smi]. + +[Map object]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map#Key_equality +[Smi]: https://github.com/thlorenz/v8-perf/blob/master/data-types.md#efficiently-representing-values-and-tagging +[Tokens]: #tokens + +### Example + +An asynchronous message to predict after typing 'D': + +```javascript +{ + message: 'predict', + + token: 1, + transform: { + insert: 'D', + deleteLeft: 0, + deleteRight: 0 + }, + context: { + left: '', + right: '', + startOfBuffer: true, + endOfBuffer: true + }, +} +``` + +Message types +------------- + +Currently there are four message types: + +Message | Direction | Parameters | Expected reply | Uses token +--------------|--------------------|---------------------|---------------------|--------------- +`initialize` | keyboard → LMLayer | initialization | Yes — `ready` | No +`ready` | LMLayer → keyboard | configuration | No | No +`predict` | keyboard → LMLayer | transform, context | Yes — `suggestions` | Yes +`suggestions` | LMLayer → keyboard | suggestions | No | Yes + + +### Message: `initialize` + +Must be sent from the keyboard to the LMLayer so that the LMLayer +initializes a model. It will send `initialization` which is a plain +JavaScript object specify the path to the model, as well configurations +and platform restrictions. + +The keyboard **MUST NOT** send any messages to the LMLayer prior to +sending `initialize`. The keyboard **SHOULD NOT** send another message +to the keyboard until it receives `ready` message from the LMLayer +before sending another message. + +These are the configurations, and platform restrictions sent to +initialize the LMLayer and its model. + +```typescript +interface InitializeMessage { + message: 'initialize'; + + /** + * Path to the model. There are no concrete restrictions on the path + * to the model, so long as the LMLayer can successfully use it to + * initialize the model. + */ + model: string; + + configuration: { + /** + * Whether the platform supports right contexts. + * The absence of this rule implies false. + */ + supportsRightContexts?: false, + + /** + * Whether the platform supports deleting to the right. + * The absence of this rule implies false. + */ + supportsDeleteRight?: false, + + /** + * The maximum amount of UTF-16 code units that the keyboard will + * provide to the left of the cursor, as an integer. + */ + maxLeftContextCodeUnits: number, + + /** + * The maximum amount of code units that the keyboard will provide to + * the right of the cursor, as an integer. The absence of this rule + * implies 0. + * See also, [[supportsRightContexts]]. + */ + maxRightContextCodeUnits?: number, + } +} +``` + + +### Message: `ready` + +Must be sent from the LMLayer to the keyboard when the LMLayer's model +as a response to `initialize`. It will send `configuration`, which is +a plain JavaScript object requesting configuration from the keyboard. + +There are only two options defined so far: + +```typescript +interface ReadyMessage { + message: 'ready'; + configuration: { + /** + * How many UTF-16 code units maximum to send as the context to the + * left of the cursor ("left" in the Unicode character stream). + * + * Affects the `context` property sent in `predict` messages. + * + * While the left context MUST NOT bisect surrogate pairs, they MAY + * bisect graphical clusters. + */ + leftContextCodeUnits: number, + + /** + * How many UTF-16 code units maximum to send as the context to the + * right of the cursor ("right" in the Unicode character stream). + * + * Affects the `context` property sent in `predict` messages. + * + * While the left context MUST NOT bisect surrogate pairs, they MAY + * bisect graphical clusters. + */ + rightContextCodeUnits: number, + }; +} +``` + + +### Message: `predict` + +Sent from the keyboard to the LMLayer whenever a new prediction should +be generated. This is typically initiated by a key press event. The +keyboard **SHOULD** track each `predict` message using a [token][Tokens]. The +token **MUST** be unique across all prediction events. The LMLayer +**SHOULD** respond to each `predict` message with a `suggestions` +message. The `suggestions` message **MUST** contain the corresponding +token as sent in the initial `predict` message. + +The keyboard **MUST** send the `context` parameter. The keyboard +**SHOULD** send the `transform` parameter. The keyboard **MUST** send +a unique token. + +The semantics of the `predict` message **MUST** be from the +perspective of this sequence of events: + + 1. After the input event is received by the keyboard. + 2. Before the keyboard applies the associated `transform` to the buffer. + +**NOTE**: The keyboard **MAY** apply the `transform` associated with the +input event before receiving the corresponding `suggestions` message +from the LMLayer. The intention is that once the suggestions are displayed, +the typist may select one of the suggestions in the place of the effects +of their original input. + +**NOTE**: The keyboard **MAY** send the `predict` message after it has +applied the `transform` associated with the input event to the buffer. +Regardless of the actual sequence, the semantics **MUST** remain the +same:the prediction happens from the perspective before the `transform` +has been applied. Therefore, if the keyboard eagerly transforms the +buffer before it has sent `predict` message, it must anticipate _undoing_ +the `transform` it has already applied if it is to apply a `transfrom` +send in the `suggestions` message. The keyboard must act as if it has +never applied the `transform` associated with the input event in the +first place. + +The context is the text surrounding the insertion point, _before_ the +transform is applied to the buffer. + +```typescript +interface Context { + /** + * Up to maxLeftContextCodeUnits code units of Unicode scalar value + * (i. e., characters) to the left of the insertion point in the + * buffer. If there is nothing to the left of the buffer, this returns + * an empty string. + */ + left: USVString; + + /** + * Up to maxRightContextCodeUnits code units of Unicode scalar value + * (i. e., characters) to the right of the insertion point in the + * buffer. If there is nothing to the right of the buffer, this returns + * an empty string. + */ + right?: USVString; + + /** + * Whether the insertion point is at the start of the buffer. + */ + startOfBuffer: boolean; + + /** + * Whether the insertion point is at the end of the buffer. + */ + endOfBuffer: boolean; +} +``` + +The transform parameter describes how the input event will change the +buffer. + +```typescript +interface Transform { + /** + * The Unicode scalar values (i.e., characters) to be inserted at the + * cursor position. + * + * Corresponds to `s` in com.keyman.KeyboardInterface.output. + */ + insert: USVString; + + /** + * The number of code units to delete to the left of the cursor. + * + * Corresponds to `dn` in com.keyman.KeyboardInterface.output. + */ + delete: number; + + /** + * The number of code units to delete to the right of the cursor. + * Not available on all platforms. + */ + deleteRight?: number; +} +``` + + +### Message: `suggestions` + +The `suggestions` message is sent from the LMLayer to the keyboard. This +message sends a ranked array of suggestions, in descending order of +probability (i.e., entry `0` is most likely, followed by entry `1`, +etc.). This message **MUST** be in response to a `predict` message, and +it **MUST** respond with the corresponding [token]. + +```typescript +/** + * `suggestions` is an ordered array of suggestion objects. + * Each suggestion is a transform bundled with a `displayAs` property. + */ +let suggestions = Suggestion[]; + +interface Suggestion { + /** + * Same object as an input event transform. + * Note that the transform is applied AFTER the input event + * transform. + */ + transform: Transform; + /** + * A string to display the suggestion to the typist. + * This should aid the typist understand what the transform + * will do to their text. + */ + displayAs: string; +} +``` + +```javascript +let suggestions = [ + { + transform: { + insert: 'teapot', + deleteLeft: 1, + deleteRight: 0 + }, + displayAs: '🍵' + } +]; +``` + + +#### Timing + +Each suggestion provides a `transform`. This transform is applied +_after_ the transform associated with the input event that initiated +this prediction. That is, the suggested transform applies to the buffer +after the transform associated with the input event. + +Rephrased in somewhat mathematical terms: +Let 𝑥 ∈ 𝐵 be the input text buffer. Let 𝑇𝑖 be the transform +associated with an input event that maps a text buffer 𝐵 → 𝐵. Let +𝑇𝑠 be a transform suggested through the LMLayer. 𝑦 ∈ 𝐵 is the +text buffer after the suggestion transform has been applied. The +correct sequence of applications should be as follows: + +> 𝑦 = 𝑇𝑠(𝑇𝑖(𝑥)) + + +#### Late suggestions + +Sometimes, a `suggestions` message may arrive after an input event has +already invalidated its request. This is called a **"late" suggestion**. +The LMLayer **MAY** send late suggestions. Consequently, the keyboard +**MAY** discard the late suggestions. There is no requirement for the +keyboard to acknowledge late suggestions, or for the LMLayer to avoid +sending late `suggestions` messages. In either case, a `suggestions` +message can be identified as appropriate or "late" via its `token` +property. diff --git a/common/predictive-text/embedded_worker.d.ts b/common/predictive-text/embedded_worker.d.ts index 30f2575232..d7df1885d8 100644 --- a/common/predictive-text/embedded_worker.d.ts +++ b/common/predictive-text/embedded_worker.d.ts @@ -21,7 +21,9 @@ */ // Include the code intended to run WITHIN the Web Worker. -// The worker code must be compiled before this file is compiled. +// The worker code MUST be compiled before this file is compiled. +// If you see a 'File: embedded_worker.js not found.' error message, please +// compile the worker first (stage one). /// /** diff --git a/common/predictive-text/index.ts b/common/predictive-text/index.ts index 02cc9c2d79..1af4ab6871 100644 --- a/common/predictive-text/index.ts +++ b/common/predictive-text/index.ts @@ -32,20 +32,55 @@ */ type USVString = string; -// TODO: document +/** + * Top-level interface to the Language Modelling layer, or "LMLayer" for short. + * + * The Language Modelling layer provides a way for keyboards to offer prediction and + * correction functionalities. The LMLayer proper runs within a Web Worker, however, + * this class is intended to run in the main thread, and automatically spawn a Web + * Worker, capable of offering predictions. + * + * Since the Worker runs in a different thread, the public methods of this class are + * asynchronous. Methods of note include: + * + * - #initialize() -- initialize the LMLayer with a configuration and language model + * - #predict() -- ask the LMLayer to offer suggestions (predictions or corrections) for + * the input event + * + * The top-level LMLayer will automatically starts up its own Web Worker. + */ class LMLayer { /** * The underlying worker instance. By default, this is the LMLayerWorker. */ private _worker: Worker; + /** + * Construct the top-level LMLayer interface. This also starts the underlying Worker. + * Make sure to call .initialize() when using the default Worker. + * + * @param uri URI of the underlying LMLayer worker code. This will usually be a blob: + * or file: URI. If uri is not provided, this will start the default Worker. + */ constructor(uri?: string) { this._worker = new Worker(uri || LMLayer.asBlobURI(LMLayerWorkerCode)); } + // TODO: asynchronous initialize() method, based on + // https://github.com/eddieantonio/keyman-lmlayer-prototype/blob/f8e6268b03190d08cf5d35f9428cf9150d6d219e/index.ts#L42-L62 + + // TODO: asynchronous predict() method, based on + // https://github.com/eddieantonio/keyman-lmlayer-prototype/blob/f8e6268b03190d08cf5d35f9428cf9150d6d219e/index.ts#L64-L80 + + // TODO: asynchronous close() method. + // Worker code must recognize message and call self.close(). + /** - * Given a function, this utility returns the source code within it. - * @param fn + * Given a function, this utility returns the source code within it, as a string. + * This is intended to unwrap the "wrapped" source code created in the LMLayerWorker + * build process. + * + * @param fn The function whose body will be returned. */ static unwrap(fn: Function): string { let wrapper = fn.toString(); @@ -74,7 +109,7 @@ class LMLayer { } } -// Let LMLayer be available both in browser and in Node. +// Let LMLayer be available both in the browser and in Node. if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') { module.exports = LMLayer; } else { diff --git a/common/predictive-text/models/en-x-test-teapot.js b/common/predictive-text/models/en-x-test-teapot.js deleted file mode 100644 index ac225e89f4..0000000000 --- a/common/predictive-text/models/en-x-test-teapot.js +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright (c) 2018 National Research Council Canada (author: Eddie A. Santos) - * Copyright (c) 2018 SIL International - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to - * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of - * the Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER - * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN - * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ - -/** - * This is a test model that simply returns "teapot" when given the context of - * «I'm a little ». - */ -registerModel(function () { - return { - predict(context, transform) { - return [ - { - transform: { - insert: 'teapot', - delete: transform.insert.length, - deleteRight: 0, - }, - displayAs: '🍵', - weight: 0.00, - } - ]; - - /* TODO: - if (context.wordsLeft === ["I'm", "a", "little"] && - transform.insert === 't') { - } - - return []; - */ - }, - - configuration: { - } - }; -}); -/*global registerModel*/ diff --git a/common/predictive-text/unit_tests/headless/lmlayer.js b/common/predictive-text/unit_tests/headless/top-level-lmlayer.js similarity index 55% rename from common/predictive-text/unit_tests/headless/lmlayer.js rename to common/predictive-text/unit_tests/headless/top-level-lmlayer.js index e13ec29838..645395f4cf 100644 --- a/common/predictive-text/unit_tests/headless/lmlayer.js +++ b/common/predictive-text/unit_tests/headless/top-level-lmlayer.js @@ -1,8 +1,10 @@ var assert = require('chai').assert; var sinon = require('sinon'); -let LMLayer = require('../../build/'); +let LMLayer = require('../../build'); +// Test the top-level LMLayer interface. +// Note: these tests can only be run after BOTH stages of compilation are completed. describe('LMLayer', function() { describe('[[constructor]]', function () { it.skip('should be take a URI to instantiate', function () { @@ -10,17 +12,16 @@ describe('LMLayer', function() { }); }); - /** - * .unwrap() is a static function that unwraps some function code. - */ + // Since the Blob API is browser-specific, look for those tests + // for .asBlobURI() in the in_browser tests. describe('.unwrap', function () { - // Since the Blob API is DOM-specific, look for those tests - // in the in_browser tests. it('should return the inner code of a function', function () { + // Create a multi-line function body we can match in a RegExp. let text = LMLayer.unwrap(function hello() { var hello; var world; }); + // Unwrap should give us back ONLY the body. Whitespace isn't really important. assert.match(text, /^\s*var\s+hello;\s*var\s+world;\s*$/); }); }); diff --git a/common/predictive-text/unit_tests/headless/worker-initialization.js b/common/predictive-text/unit_tests/headless/worker-initialization.js index de2ad0cefc..d543de6be1 100644 --- a/common/predictive-text/unit_tests/headless/worker-initialization.js +++ b/common/predictive-text/unit_tests/headless/worker-initialization.js @@ -3,8 +3,12 @@ var sinon = require('sinon'); let LMLayerWorker = require('../../build/intermediate'); +// Unit tests for instantiating and initializing the LMLayer Worker in isolation. +// +// Although the LMLayerWorker expected to be used inside a DedicatedWorkerGlobalScope, +// these unit tests DO NOT run inside a Worker, and instead use Sinon fakes to assert +// behavior. describe('LMLayerWorker', function() { - describe('#constructor()', function() { it('should allow for the mocking of postMessage()', function () { var fakePostMessage = sinon.fake(); @@ -147,6 +151,7 @@ describe('LMLayerWorker', function() { }); }); + // TODO: move these tests to a different file. describe('Message: predict', function () { it.skip('should predict from a local model', function () { // will need import scripts figured out @@ -161,6 +166,11 @@ describe('LMLayerWorker', function() { return { data }; } + /** + * Deprecation warning: Soon, models will not be requierd to be passed as source code; + * when this happens, tests should refrain from sending source code for the model + * parameter. + */ function dummyModelCode() { return 'return {model: {}, configuration: {}}'; } diff --git a/common/predictive-text/unit_tests/in_browser/cases/lmlayer-integration.js b/common/predictive-text/unit_tests/in_browser/cases/top-level-lmlayer.js similarity index 66% rename from common/predictive-text/unit_tests/in_browser/cases/lmlayer-integration.js rename to common/predictive-text/unit_tests/in_browser/cases/top-level-lmlayer.js index a37bfed7a4..f995fc1d20 100644 --- a/common/predictive-text/unit_tests/in_browser/cases/lmlayer-integration.js +++ b/common/predictive-text/unit_tests/in_browser/cases/top-level-lmlayer.js @@ -8,12 +8,15 @@ describe('LMLayer', function () { }); }); - describe('#asBlobURI', function () { + describe('#asBlobURI()', function () { + // #asBlobURI() requires browser APIs, hence why it cannot be tested headless in Node. it('should take a function and convert it into a blob function', function (done) { let uri = LMLayer.asBlobURI(function dummyHandler() { - // Post something weird, so we can be reasonably certain it's not a fluke. - // WARNING: Do NOT factor out this string as a variable. - // It MUST remain a string in this function body, because it gets stringified! + // Post something weird, so we can be reasonably certain the Web Worker is... + // well, working. + // WARNING: Do NOT refactor this string as a variable. It **MUST** remain a string + // in this function body, because the code in this function's body gets + // stringified! postMessage('fhqwhgads'); }); assert.match(uri, /^blob:/); diff --git a/common/predictive-text/unit_tests/in_browser/cases/basic.js b/common/predictive-text/unit_tests/in_browser/cases/worker.js similarity index 100% rename from common/predictive-text/unit_tests/in_browser/cases/basic.js rename to common/predictive-text/unit_tests/in_browser/cases/worker.js diff --git a/common/predictive-text/worker/index.ts b/common/predictive-text/worker/index.ts index 55473f4c14..33ca33151f 100644 --- a/common/predictive-text/worker/index.ts +++ b/common/predictive-text/worker/index.ts @@ -20,33 +20,37 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +/** + * The signature of self.postMessage(), so that unit tests can mock it. + */ type PostMessage = typeof DedicatedWorkerGlobalScope.prototype.postMessage; + +/** + * The valid outgoing message types. + */ type OutgoingMessageKind = 'ready' | 'suggestions'; +/** + * The structure of an initialization message. It should include the model (either in + * source code or parameter form), as well as the keyboard's capabilities. + */ interface InitializeMessage { /** - * Source code of the model. - * TODO: write a description of what this source code should look like. + * The model, and its configuration. + * TODO: write a description of what this actually is! */ - model: string; - /** - * The configuration that the keyboard can offer to the model. - */ - configuration: RequestedConfiguration; -} - -interface InitializeMessage { - /** - * Source code of the model. - * TODO: write a description of what this source code should look like. - */ - model: string; + model: any; + /** * The configuration that the keyboard can offer to the model. */ + // TODO: rename to capabilities? They **are** the capabilities of the keyboard's platform. configuration: RequestedConfiguration; } +/** + * The structure of the message back to the keyboard. + */ interface ReadyMessage { configuration: ModelConfiguration; } @@ -104,7 +108,7 @@ interface ModelConfiguration { rightContextCodeUnits: number; }; -// TODO: +// TODO: define what valid values of the model are. interface Model {}; /** @@ -222,7 +226,7 @@ class LMLayerWorker { } } -// Let LMLayerWorker be available both in browser and in Node. +// Let LMLayerWorker be available both in the browser and in Node. if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') { module.exports = LMLayerWorker; } else if (typeof self !== 'undefined' && 'postMessage' in self) {