diff --git a/common/predictive-text/build.sh b/common/predictive-text/build.sh index ca5c4e77bf..5876eeffe9 100755 --- a/common/predictive-text/build.sh +++ b/common/predictive-text/build.sh @@ -9,12 +9,18 @@ LMLAYER_OUTPUT=build WORKER_OUTPUT=build/intermediate +INCLUDES_OUTPUT=build/includes NAKED_WORKER=$WORKER_OUTPUT/index.js EMBEDDED_WORKER=$WORKER_OUTPUT/embedded_worker.js # Build the worker and the main script. build ( ) { + # Ensure that the build-product destination for any generated include .d.ts files exists. + if ! [ -d $INCLUDES_OUTPUT ]; then + mkdir -p "$INCLUDES_OUTPUT" + fi + # Build worker first; the main file depends on it. # Then wrap the worker; Then build the main file. @@ -24,8 +30,8 @@ build ( ) { # Builds the top-level JavaScript file (the second stage of compilation) build-main () { npm run tsc -- -p ./tsconfig.json || fail "Could not build top-level JavaScript file." - cp ./index.d.ts build/index.d.ts - cp ./message.d.ts build/message.d.ts + cp ./index.d.ts $INCLUDES_OUTPUT/LMLayer.d.ts + cp ./message.d.ts $INCLUDES_OUTPUT/message.d.ts } # Builds the inner JavaScript worker (the first stage of compilation). @@ -36,6 +42,20 @@ build-worker () { fi npm run tsc -- -p ./worker/tsconfig.json || fail "Could not build worker." + + get_builder_OS + + # macOS has a slightly different sed, which needs an extension to use for a backup file. Thanks, Apple. + BACKUP_EXT= + if [ $os_id == 'mac' ]; then + BACKUP_EXT='.bak' + fi + + # Tweak the output index.d.ts to have an updated reference to message.d.ts + sed -i $BACKUP_EXT 's/path="\.\.\/\.\.\/message\.d\.ts"/path="message\.d\.ts"/g' "${WORKER_OUTPUT}/index.d.ts" \ + || fail "Could not update message.d.ts reference" + + mv $WORKER_OUTPUT/index.d.ts $INCLUDES_OUTPUT/LMLayerWorker.d.ts } # A nice, extensible method for -clean operations. Add to this as necessary. diff --git a/common/predictive-text/docs/build.sh b/common/predictive-text/docs/build.sh new file mode 100755 index 0000000000..dc2aec4abd --- /dev/null +++ b/common/predictive-text/docs/build.sh @@ -0,0 +1,18 @@ +#!/bin/sh + +# The diagram is built using the Graphviz suite. +# You can get it with most package managers, e.g., +# +# sudo apt installl graphviz # Ubuntu +# +# brew install graphviz # macOS + +# Check if Graphviz/dot is installed +if ! hash dot ; then + echo "Cannot (re)build state diagram" 1>&2 + echo "Missing the Graphviz suite" 1>&2 + echo "Download at $(tput bold)https://www.graphviz.org/$(tput sgr0)" + exit 1 +fi + +dot -Tpng lmlayer-states.dot -o lmlayer-states.png diff --git a/common/predictive-text/docs/lmlayer-states.dot b/common/predictive-text/docs/lmlayer-states.dot new file mode 100644 index 0000000000..6c0af8d210 --- /dev/null +++ b/common/predictive-text/docs/lmlayer-states.dot @@ -0,0 +1,13 @@ +digraph LMLayerFSM { + rankdir=LR; + + node [shape = point] 0; + edge [fontname="Courier"]; + + node [shape = circle]; + 0 -> unconfigured; + unconfigured -> modelless [label="config"]; + modelless -> ready [label="load"]; + ready -> ready [label="predict"]; + ready -> modelless [label="unload"]; +} diff --git a/common/predictive-text/docs/lmlayer-states.png b/common/predictive-text/docs/lmlayer-states.png new file mode 100644 index 0000000000..60cfd0a595 Binary files /dev/null and b/common/predictive-text/docs/lmlayer-states.png differ diff --git a/common/predictive-text/docs/worker-communication-protocol.md b/common/predictive-text/docs/worker-communication-protocol.md index 3e2fd585fd..9602424788 100644 --- a/common/predictive-text/docs/worker-communication-protocol.md +++ b/common/predictive-text/docs/worker-communication-protocol.md @@ -117,77 +117,99 @@ Currently there are four message types: Message | Direction | Parameters | Expected reply | Uses token --------------|--------------------|---------------------|---------------------|--------------- -`initialize` | keyboard → LMLayer | capabilities, model | Yes — `ready` | No +`config` | LMLayer -> worker | capabilities | No | No +`load` | keyboard → LMLayer | model | Yes — `ready` | No +`unload` | keyboard → LMLayer | none | No | No `ready` | LMLayer → keyboard | configuration | No | No `predict` | keyboard → LMLayer | transform, context | Yes — `suggestions` | Yes `suggestions` | LMLayer → keyboard | suggestions | No | Yes - -### Message: `initialize` +### Message: `config` 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. +may properly configure loaded models. It will send `config`, a plain +JavaScript object specifying 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. +The keyboard **MUST NOT** send any messages to the LMLayer prior to sending `config`. +After this, it is safe to assume the `config` was performed successfully and is ready to +`load` a model. -The LMLayer needs to know the platform's abilities and restrictions (capabilities), as well as which concrete language model to instantiate. These properties are passed as `capabilities` and `model`, respectively. +The LMLayer needs to know the platform's abilities and restrictions (capabilities). ```typescript -interface InitializeMessage { - message: 'initialize'; +interface LoadMessage { + message: 'load'; /** - * A ModelDescription that describes the language model and its parameters. - * The concrete documentation on is a valid ModelDescription - * can be found elsewhere. + * The path to the model's compiled script file. */ - model: { - /** - * What kind of model to instantiate. This is subject to availability, - * but common examples are 'wordlist', 'fst', and 'dummy'. - */ - type: string; - /** - * Each model type defines a set of configurable parameters. Please - * see the corresponding model's documentation for an extensive list. - */ - ...parameters: any; - }; - capabilities: { /** - * 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. + * 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 the platform is incapable of supplying right contexts. - * See also, [[supportsRightContexts]]. + * The maximum amount of code units that the keyboard will provide to the + * right of the cursor, as an integer. The value 0 or the absence of this + * rule implies that the right contexts are not supported. */ maxRightContextCodeUnits?: number, + + /** + * Whether the platform supports deleting to the right. The absence of this + * rule implies false. + */ + supportsDeleteRight?: false, } } ``` +### Message: `load` + +Must be sent from the keyboard to the LMLayer so that the LMLayer +loads a model. It will send `load` which is a plain +JavaScript object specify the path to the model, as well configurations +and platform restrictions. + +After a single `config` message, the keyboard **MUST NOT** send any messages +to the LMLayer prior to sending `load`. The keyboard **SHOULD NOT** send another +message to the keyboard until it receives `ready` message from the LMLayer +before sending another message. + +The LMLayer needs to know which concrete language model to instantiate. This is provided +by the file at the path specified by the `model` string parameter. + +```typescript +interface LoadMessage { + message: 'load'; + + /** + * The path to the model's compiled script file. + */ + model: string +} +``` + +### Message: `unload` + +Must be sent from the keyboard to the LMLayer so that the LMLayer +resets itself in preparation for loading a new model. It will send `unload` +which is a plain message to trigger release of old model resources. + + +```typescript +interface UnloadMessage { + message: 'unload'; +} +``` + ### 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 +as a response to `load`. It will send `configuration`, which is a plain JavaScript object requesting configuration from the keyboard. There are only two options defined so far: @@ -393,3 +415,17 @@ 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. + + +## *Informative*: LMLayer worker as a state machine + +The LMLayer worker can be seen in the following states: + + - `unconfigured` + - `model-less` + - `ready` + +The transitions of this diagram correspond to messages as described +above. + +![State machine of the LMLayer](./lmlayer-states.png) diff --git a/common/predictive-text/index.d.ts b/common/predictive-text/index.d.ts index 25e9414f4b..85420b0711 100644 --- a/common/predictive-text/index.d.ts +++ b/common/predictive-text/index.d.ts @@ -13,18 +13,23 @@ declare namespace com.keyman.text.prediction { /** * Construct the top-level LMLayer interface. This also starts the underlying Worker. - * Make sure to call .initialize() when using the default Worker. + * Make sure to call .load() 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(worker?: Worker); + constructor(capabilities: Capabilities, worker?: Worker); /** * Initializes the LMLayer worker with the keyboard/platform's capabilities, * as well as a description of the model required. */ - initialize(capabilities: Capabilities, model: ModelDescription): Promise; + loadModel(model: string): Promise; + + /** + * Prepares the LMLayer for reinitialization with a different model/capability set. + */ + unloadModel(); predict(transform: Transform, context: Context): Promise; @@ -57,5 +62,11 @@ declare namespace com.keyman.text.prediction { * })); */ static asBlobURI(fn: Function): string; + + /** + * Clears out any computational resources in use by the LMLayer, including shutting + * down any internal WebWorkers. + */ + public shutdown(): void; } } \ No newline at end of file diff --git a/common/predictive-text/index.ts b/common/predictive-text/index.ts index 1e55cb2cce..5e9f9779e6 100644 --- a/common/predictive-text/index.ts +++ b/common/predictive-text/index.ts @@ -34,9 +34,11 @@ * 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 + * - #loadModel() -- loads a specified model file * - #predict() -- ask the LMLayer to offer suggestions (predictions or corrections) for * the input event + * - #unloadModel() -- unloads the LMLayer's currently loaded model, preparing it to + * receive (load) a new model * * The top-level LMLayer will automatically starts up its own Web Worker. */ @@ -51,33 +53,46 @@ namespace com.keyman.text.prediction { private _declareLMLayerReady: (conf: Configuration) => void; private _promises: PromiseStore; private _nextToken: number; + private capabilities: Capabilities; /** * 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(worker?: Worker) { + constructor(capabilities: Capabilities, worker?: Worker) { // Either use the given worker, or instantiate the default worker. this._worker = worker || new Worker(LMLayer.asBlobURI(LMLayerWorkerCode)); this._worker.onmessage = this.onMessage.bind(this) this._declareLMLayerReady = null; this._promises = new PromiseStore; this._nextToken = Number.MIN_SAFE_INTEGER; + + this.sendConfig(capabilities); } /** - * Initializes the LMLayer worker with the keyboard/platform's capabilities, - * as well as a description of the model required. + * Initializes the LMLayer worker with the host platform's capability set. + * + * @param capabilities The host platform's capability spec - a model cannot assume access to more context + * than specified by this parameter. */ - initialize(capabilities: Capabilities, model: ModelDescription): Promise { + private sendConfig(capabilities: Capabilities) { + this._worker.postMessage({ + message: 'config', + capabilities: capabilities + }); + } + + /** + * Initializes the LMLayer worker with a path to the desired model file. + */ + loadModel(modelFilePath: string): Promise { return new Promise((resolve, _reject) => { this._worker.postMessage({ - message: 'initialize', - capabilities, - model + message: 'load', + model: modelFilePath }); // Sets up so the promise is resolved in the onMessage() callback, when it receives @@ -86,6 +101,16 @@ namespace com.keyman.text.prediction { }); } + /** + * Unloads the previously-active model from memory, resetting the LMLayer to prep + * for transition to use of a new model. + */ + public unloadModel() { + this._worker.postMessage({ + message: 'unload' + }); + } + predict(transform: Transform, context: Context): Promise { let token = this._nextToken++; return new Promise((resolve, reject) => { @@ -115,6 +140,14 @@ namespace com.keyman.text.prediction { } } + /** + * Clears out any computational resources in use by the LMLayer, including shutting + * down any internal WebWorkers. + */ + public shutdown() { + this._worker.terminate(); + } + /** * 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 diff --git a/common/predictive-text/unit_tests/headless/top-level-lmlayer.js b/common/predictive-text/unit_tests/headless/top-level-lmlayer.js index 7ec7e33b48..f340044a19 100644 --- a/common/predictive-text/unit_tests/headless/top-level-lmlayer.js +++ b/common/predictive-text/unit_tests/headless/top-level-lmlayer.js @@ -8,48 +8,48 @@ let LMLayer = require('../../build'); describe('LMLayer', function() { describe('[[constructor]]', function () { it('should accept a Worker to instantiate', function () { - new LMLayer(createFakeWorker()); + new LMLayer(capabilities(), createFakeWorker()); + }); + + it('should send the `config` message to the LMLayer', async function () { + let fakeWorker = createFakeWorker(fakePostMessage); + let lmLayer = new LMLayer(capabilities(), fakeWorker); + + assert.propertyVal(fakeWorker.postMessage, 'callCount', 1); + // In the "Worker", assert the message looks right + function fakePostMessage(data) { + assert.propertyVal(data, 'message', 'config'); + assert.isObject(data.capabilities); + } }); }); - describe('#initialize()', function () { + describe('#loadModel()', function () { it('should accept capabilities and model description', function () { let fakeWorker = createFakeWorker(); - let lmLayer = new LMLayer(fakeWorker); - lmLayer.initialize( - { - maxLeftContextCodeUnits: 32, - }, - { - kind: 'wordlist', - words: ['foo', 'bar', 'baz', 'quux'] - } - ); + let lmLayer = new LMLayer(capabilities(), fakeWorker); + lmLayer.loadModel("./unit_tests/in_browser/resources/models/simple-dummy.js"); assert.isFunction(fakeWorker.onmessage, 'LMLayer failed to set a callback!'); }); - it('should send the `initialize` message to the LMLayer', async function () { + it('should send the `load` message to the LMLayer', async function () { let fakeWorker = createFakeWorker(fakePostMessage); - let lmLayer = new LMLayer(fakeWorker); - let configuration = await lmLayer.initialize( - { - maxLeftContextCodeUnits: 32, - }, - { - kind: 'wordlist', - words: ['foo', 'bar', 'baz', 'quux'] - } - ); + let lmLayer = new LMLayer(capabilities(), fakeWorker); + let configuration = await lmLayer.loadModel("./unit_tests/in_browser/resources/models/simple-dummy.js"); - assert.propertyVal(fakeWorker.postMessage, 'callCount', 1); + assert.propertyVal(fakeWorker.postMessage, 'callCount', 2); // In the "Worker", assert the message looks right and // ASYNCHRONOUSLY reply with ready message. function fakePostMessage(data) { - assert.propertyVal(data, 'message', 'initialize') - assert.isObject(data.capabilities); - assert.isObject(data.model); + // Expected first call: config. Ignore it. + if(data.message == 'config') { + return; + } + + assert.propertyVal(data, 'message', 'load'); + assert.isString(data.model); callAsynchronously(() => fakeWorker.onmessage({ data: { @@ -75,8 +75,8 @@ describe('LMLayer', function() { })); }); - let lmLayer = new LMLayer(fakeWorker); - let actualConfiguration = await lmLayer.initialize( + let lmLayer = new LMLayer(capabilities, fakeWorker); + let actualConfiguration = await lmLayer.loadModel( { maxLeftContextCodeUnits: 32, }, @@ -86,7 +86,7 @@ describe('LMLayer', function() { } ); - // This SHOULD be called by initialize(). + // This SHOULD be called by loadModel(). assert.deepEqual(actualConfiguration, expectedConfiguration); }) }); diff --git a/common/predictive-text/unit_tests/headless/worker-initialization.js b/common/predictive-text/unit_tests/headless/worker-initialization.js index 958236c153..1a17a0676f 100644 --- a/common/predictive-text/unit_tests/headless/worker-initialization.js +++ b/common/predictive-text/unit_tests/headless/worker-initialization.js @@ -12,13 +12,20 @@ describe('LMLayerWorker', function() { describe('#constructor()', function() { it('should allow for the mocking of postMessage()', function () { var fakePostMessage = sinon.fake(); - var worker = new LMLayerWorker({ postMessage: fakePostMessage }); + var context = { + postMessage: fakePostMessage + }; + context.importScripts = importScriptsWith(context); + + var worker = LMLayerWorker.install(context); - // Sending it the initialize it should notify us that it's initialized! + // First the worker must receive config data... + configWorker(worker); + + // Sending it the `load` message should notify us that it's loaded! worker.onMessage(createMessageEventWithData({ - message: 'initialize', - model: dummyModel(), - capabilities: defaultCapabilities() + message: 'load', + model: "./unit_tests/in_browser/resources/models/simple-dummy.js" })); assert(fakePostMessage.calledOnce); }); @@ -26,9 +33,11 @@ describe('LMLayerWorker', function() { describe('#onMessage()', function() { it('should fail if not given the `message` attribute', function () { - var worker = new LMLayerWorker({ - postMessage: sinon.fake(), // required, but ignored in this test case - }); + var context = { + postMessage: sinon.fake() + }; + context.importScripts = importScriptsWith(context); + var worker = LMLayerWorker.install(context); // Every message is a discriminated union with the tag being `message`. // If it doesn't see 'message', something is deeply wrong, // and it should loudly let us know. @@ -46,17 +55,21 @@ describe('LMLayerWorker', function() { onmessage: undefined, postMessage: new sinon.fake(), }; + fakeWorkerGlobal.importScripts = importScriptsWith(fakeWorkerGlobal); + // Instantiate and install a worker on our global object. var worker = LMLayerWorker.install(fakeWorkerGlobal); assert.instanceOf(worker, LMLayerWorker); // It should have installed a callback. assert.isFunction(fakeWorkerGlobal.onmessage); + // First the worker must receive config data... + configWorker(worker); + // Send a message; we should get something back. worker.onMessage(createMessageEventWithData({ - message: 'initialize', - model: dummyModel(), - capabilities: defaultCapabilities() + message: 'load', + model: "./unit_tests/in_browser/resources/models/simple-dummy.js" })); // It called the postMessage() in its global scope. @@ -64,11 +77,48 @@ describe('LMLayerWorker', function() { }); }); - describe('Message: initialize', function () { + describe('Message: config', function () { it('should disallow any other message', function () { - var worker = new LMLayerWorker({ - postMessage: sinon.fake(), // required, but ignored - }); + var context = { + postMessage: sinon.fake() + }; + context.importScripts = importScriptsWith(context); + + var worker = LMLayerWorker.install(context); + + // It should not respond to 'predict' + assert.throws(function () { + worker.onMessage(createMessageEventWithData({ + message: 'predict', + })); + }, /invalid message/i); + }); + + it('accepts a capability set and transitions to the "modelless" state', function () { + var context = { + postMessage: sinon.fake() + }; + context.importScripts = importScriptsWith(context); + + var worker = LMLayerWorker.install(context); + + // Trigger the config message + configWorker(worker); + + assert.equal(worker.state.name, 'modelless'); + }); + }); + + describe('Message: load', function () { + it('should disallow any other message', function () { + var context = { + postMessage: sinon.fake() + }; + context.importScripts = importScriptsWith(context); + + var worker = LMLayerWorker.install(context); + + configWorker(worker); // It should not respond to 'predict' assert.throws(function () { @@ -80,11 +130,17 @@ describe('LMLayerWorker', function() { it('should send back a "ready" message', function () { var fakePostMessage = sinon.fake(); - var worker = new LMLayerWorker({ postMessage: fakePostMessage }); + var context = { + postMessage: fakePostMessage + }; + context.importScripts = importScriptsWith(context); + + var worker = LMLayerWorker.install(context); + configWorker(worker); + worker.onMessage(createMessageEventWithData({ - message: 'initialize', - model: dummyModel(), - capabilities: defaultCapabilities() + message: 'load', + model: "./unit_tests/in_browser/resources/models/simple-dummy.js" })); assert(fakePostMessage.calledOnceWith(sinon.match({ @@ -94,14 +150,19 @@ describe('LMLayerWorker', function() { it('should send back configuration', function () { var fakePostMessage = sinon.fake(); - var worker = new LMLayerWorker({ postMessage: fakePostMessage }); + var context = { + postMessage: fakePostMessage + }; + context.importScripts = importScriptsWith(context); + + var worker = LMLayerWorker.install(context); + configWorker(worker); + + // simple-dummy.js is set with the following. var maxCodeUnits = 64; worker.onMessage(createMessageEventWithData({ - message: 'initialize', - model: dummyModel(), - capabilities: { - maxLeftContextCodeUnits: maxCodeUnits, - } + message: 'load', + model: "./unit_tests/in_browser/resources/models/simple-dummy.js" })); sinon.assert.calledWithMatch(fakePostMessage, { diff --git a/common/predictive-text/unit_tests/headless/worker-predict-dummy.js b/common/predictive-text/unit_tests/headless/worker-predict-dummy.js index e42e3002ed..af31d1ab62 100644 --- a/common/predictive-text/unit_tests/headless/worker-predict-dummy.js +++ b/common/predictive-text/unit_tests/headless/worker-predict-dummy.js @@ -3,31 +3,14 @@ */ var assert = require('chai').assert; - var DummyModel = require('../../build/intermediate').models.DummyModel; describe('LMLayerWorker dummy model', function() { describe('instantiation', function () { - it('can be instantiated with capabilities', function () { - var model = new DummyModel(defaultCapabilities); + it('can be instantiated with no arguments', function () { + var model = new DummyModel(); assert.isObject(model); }); - - it('supports dependency-injected configuration', function () { - let configuration = { - leftContextCodeUnits: 64, - rightContextCodeUnits: 0 - }; - - var model = new DummyModel({ - maxLeftContextCodeUnits: 64, - }, - { - configuration: configuration, - }); - - assert.deepEqual(model.configuration, configuration); - }); }); describe('prediction', function () { @@ -66,7 +49,7 @@ describe('LMLayerWorker dummy model', function() { }, ]; - var model = new DummyModel(defaultCapabilities()); + var model = new DummyModel(); // Type a 't' var suggestions = model.predict({ @@ -90,9 +73,7 @@ describe('LMLayerWorker dummy model', function() { assert.isDefined(futureSuggestions[2]); assert.isDefined(futureSuggestions[3]); - var model = new DummyModel(defaultCapabilities, { - futureSuggestions: futureSuggestions - }); + var model = new DummyModel({futureSuggestions: futureSuggestions}); // The dummy model should give suggestions in order, // regardless of the provided transform and context. diff --git a/common/predictive-text/unit_tests/headless/worker-predict-wordlist.js b/common/predictive-text/unit_tests/headless/worker-predict-wordlist.js index 8e895ba07d..1e72c7ed8f 100644 --- a/common/predictive-text/unit_tests/headless/worker-predict-wordlist.js +++ b/common/predictive-text/unit_tests/headless/worker-predict-wordlist.js @@ -3,13 +3,12 @@ */ var assert = require('chai').assert; - var WordListModel = require('../../build/intermediate').models.WordListModel; describe('LMLayerWorker word list model', function() { describe('instantiation', function () { it('can be instantiated with an empty word list', function () { - var model = new WordListModel(defaultCapabilities(), []); + var model = new WordListModel([]); assert.isObject(model); }); @@ -26,7 +25,6 @@ describe('LMLayerWorker word list model', function() { // «t| » [Send] // [ to ] [ the ] [ this ] var model = new WordListModel( - defaultCapabilities(), jsonFixture('wordlists/english-1000') ); @@ -52,7 +50,6 @@ describe('LMLayerWorker word list model', function() { // «th| » [Send] // [ this ] [ the ] [ there ] var model = new WordListModel( - defaultCapabilities(), jsonFixture('wordlists/english-1000') ); @@ -93,7 +90,6 @@ describe('LMLayerWorker word list model', function() { // «| » [Send] // [ I'm ] [ I ] [ Hey ] var model = new WordListModel( - defaultCapabilities(), jsonFixture('wordlists/english-1000') ); @@ -116,7 +112,6 @@ describe('LMLayerWorker word list model', function() { // «I g| » [Send] // [ gave ] [ got ] [ got the ] var model = new WordListModel( - defaultCapabilities(), jsonFixture('wordlists/english-1000') ); diff --git a/common/predictive-text/unit_tests/headless/worker-predict.js b/common/predictive-text/unit_tests/headless/worker-predict.js index 83d3f937f1..8f935c7804 100644 --- a/common/predictive-text/unit_tests/headless/worker-predict.js +++ b/common/predictive-text/unit_tests/headless/worker-predict.js @@ -17,13 +17,17 @@ describe('LMLayerWorker', function () { // Initialize the worker with a model that will produce one suggestion. var fakePostMessage = sinon.fake(); - var worker = new LMLayerWorker({ postMessage: fakePostMessage }); + var context = { + postMessage: fakePostMessage + }; + context.importScripts = importScriptsWith(context); + + var worker = LMLayerWorker.install(context); + configWorker(worker); + worker.onMessage(createMessageEventWithData({ - message: 'initialize', - model: dummyModel([ - [suggestion] - ]), - capabilities: defaultCapabilities() + message: 'load', + model: "./unit_tests/in_browser/resources/models/simple-dummy.js" })); sinon.assert.calledWithMatch(fakePostMessage.lastCall, { message: 'ready', @@ -38,10 +42,14 @@ describe('LMLayerWorker', function () { transform: zeroTransform(), context: emptyContext() })); + + // Retrieve the internal 'dummy' suggestions for comparison. + var hazel = iGotDistractedByHazel(); + sinon.assert.calledWithMatch(fakePostMessage.lastCall, { message: 'suggestions', token: token, - suggestions: sinon.match.array.deepEquals([suggestion]) + suggestions: hazel[0] }); }); diff --git a/common/predictive-text/unit_tests/helpers.js b/common/predictive-text/unit_tests/helpers.js index c38ce879dc..0ac0425fe5 100644 --- a/common/predictive-text/unit_tests/helpers.js +++ b/common/predictive-text/unit_tests/helpers.js @@ -4,6 +4,9 @@ * Globally-defined helper functions for use in in Mocha tests. */ +var fs = require("fs"); +var vm = require("vm"); + // Choose the appropriate global object. Either `global` in // Node, or `window` in browsers. var _ = global || window; @@ -17,6 +20,26 @@ _.createMessageEventWithData = function createMessageEventWithData(data) { return { data }; } +/** + * Creates a simple, default capabilities object for standard-case LMLayer init. + */ +_.capabilities = function capabilities() { + return { + maxLeftContextCodeUnits: 64 + } +} + +/** + * Mimics a message from the outer LMLayer shell with a simple, default config object. + * Used for Worker tests. + */ +_.configWorker = function configWorker(worker) { + worker.onMessage(createMessageEventWithData({ + message: 'config', + capabilities: _.capabilities() + })); +} + /** * A valid model that suggests exactly what you want it to suggest. * @@ -97,4 +120,20 @@ if (typeof require === 'function') { // └── ... return require('./in_browser/json/' + name); } + + // This worker-global function does not exist by default in Node! + _.importScriptsWith = function(context) { + return function() { // the constructed context's importScripts method. + + /* Use of vm.createContext and script.runInContext allow us to avoid + * polluting the global scope with imports. When we throw away the + * context object, imported scripts will be automatically GC'd. + */ + for(var i=0; i < arguments.length; i++) { + context = vm.createContext(context); + var script = new vm.Script(fs.readFileSync(arguments[i])); + script.runInContext(context); + } + } + } } diff --git a/common/predictive-text/unit_tests/in_browser/base.conf.js b/common/predictive-text/unit_tests/in_browser/base.conf.js index 25de1b9c1b..2a3adccd4b 100644 --- a/common/predictive-text/unit_tests/in_browser/base.conf.js +++ b/common/predictive-text/unit_tests/in_browser/base.conf.js @@ -33,7 +33,7 @@ module.exports = { files: [ // Include the generated worker code. Make sure it's linked before any of the test cases. '../../build/index.js', - + 'helpers.js', // Provides utility helpers and objects for tests. 'cases/**/*.js', // Where the tests actually reside. // We don't have anything in these locations... yet. But they'll be useful for test resources. @@ -59,6 +59,10 @@ module.exports = { variableName: '__json__' }, + proxies: { + "/resources/": "/base/resources/" + }, + // web server port port: 9876, diff --git a/common/predictive-text/unit_tests/in_browser/cases/top-level-lmlayer.js b/common/predictive-text/unit_tests/in_browser/cases/top-level-lmlayer.js index 2083e2e29c..48ee845cf9 100644 --- a/common/predictive-text/unit_tests/in_browser/cases/top-level-lmlayer.js +++ b/common/predictive-text/unit_tests/in_browser/cases/top-level-lmlayer.js @@ -3,9 +3,10 @@ var LMLayer = com.keyman.text.prediction.LMLayer; describe('LMLayer', function () { describe('[[constructor]]', function () { - it('should construct with zero arguments', function () { - let lmLayer = new LMLayer(); + it('should construct with a single argument', function () { + let lmLayer = new LMLayer(helpers.defaultCapabilities); assert.instanceOf(lmLayer, LMLayer); + lmLayer.shutdown(); }); }); @@ -25,6 +26,7 @@ describe('LMLayer', function () { let worker = new Worker(uri); worker.onmessage = function thisShouldBeCalled(event) { assert.propertyVal(event, 'data', 'fhqwhgads'); + worker.terminate(); done(); }; }) diff --git a/common/predictive-text/unit_tests/in_browser/cases/worker-dummy-integration.js b/common/predictive-text/unit_tests/in_browser/cases/worker-dummy-integration.js index f68f736da8..f5c6e8ce1c 100644 --- a/common/predictive-text/unit_tests/in_browser/cases/worker-dummy-integration.js +++ b/common/predictive-text/unit_tests/in_browser/cases/worker-dummy-integration.js @@ -7,25 +7,19 @@ var LMLayer = com.keyman.text.prediction.LMLayer; * **injectable** suggestions: that is, you, as the tester, have * to provide the predictions. The dummy model does not create any * suggestions on its own. The dummy model can take in a series - * of suggestions when initialized and return them sequentially. + * of suggestions when loaded and return them sequentially. */ describe('LMLayer using dummy model', function () { describe('Prediction', function () { it('will predict future suggestions', function () { - var lmLayer = new LMLayer(); - var capabilities = { - maxLeftContextCodeUnits: 32 + ~~Math.random() * 32 - }; + var lmLayer = new LMLayer(helpers.defaultCapabilities); // We're testing many as asynchronous messages in a row. // this would be cleaner using async/await syntax, but // alas some of our browsers don't support it. - return lmLayer.initialize( - capabilities, - { - type: 'dummy', - futureSuggestions: iGotDistractedByHazel() - } + return lmLayer.loadModel( + // We need to provide an absolute path since the worker is based within a blob. + document.location.protocol + '//' + document.location.host + "/resources/models/simple-dummy.js" ).then(function (actualConfiguration) { return Promise.resolve(); }).then(function () { @@ -41,6 +35,7 @@ describe('LMLayer using dummy model', function () { return lmLayer.predict(zeroTransform(), emptyContext()); }).then(function (suggestions) { assert.deepEqual(suggestions, iGotDistractedByHazel()[3]); + lmLayer.shutdown(); return Promise.resolve(); }); }); diff --git a/common/predictive-text/unit_tests/in_browser/cases/worker-wordlist-integration.js b/common/predictive-text/unit_tests/in_browser/cases/worker-wordlist-integration.js index fb6f1a6503..690cc3804b 100644 --- a/common/predictive-text/unit_tests/in_browser/cases/worker-wordlist-integration.js +++ b/common/predictive-text/unit_tests/in_browser/cases/worker-wordlist-integration.js @@ -8,20 +8,14 @@ describe('LMLayer using the word list model', function () { describe('Prediction', function () { var EXPECTED_SUGGESTIONS = 3; it('will predict an empty buffer', function () { - var lmLayer = new LMLayer(); - var capabilities = { - maxLeftContextCodeUnits: 32 + ~~Math.random() * 32 - }; + var lmLayer = new LMLayer(helpers.defaultCapabilities); // We're testing many as asynchronous messages in a row. // this would be cleaner using async/await syntax, but // alas some of our browsers don't support it. - return lmLayer.initialize( - capabilities, - { - type: 'wordlist', - wordlist: __json__['wordlists/english-1000'] - } + return lmLayer.loadModel( + // We need to provide an absolute path since the worker is based within a blob. + document.location.protocol + '//' + document.location.host + "/resources/models/simple-wordlist.js" ).then(function (_actualConfiguration) { return Promise.resolve(); }).then(function () { @@ -38,6 +32,7 @@ describe('LMLayer using the word list model', function () { return lmLayer.predict(type('q'), atEndOfBuffer('the ')); }).then(function (suggestions) { assert.isAtLeast(suggestions.length, EXPECTED_SUGGESTIONS); + lmLayer.shutdown(); return Promise.resolve(); }); }); diff --git a/common/predictive-text/unit_tests/in_browser/cases/worker.js b/common/predictive-text/unit_tests/in_browser/cases/worker.js index 544c35d638..13bd705ef0 100644 --- a/common/predictive-text/unit_tests/in_browser/cases/worker.js +++ b/common/predictive-text/unit_tests/in_browser/cases/worker.js @@ -2,6 +2,7 @@ var assert = chai.assert; var LMLayer = com.keyman.text.prediction.LMLayer; describe('LMLayerWorker', function () { + this.timeout(5000); describe('LMLayerWorkerCode', function() { it('should exist!', function() { assert.isFunction(LMLayerWorkerCode, @@ -16,11 +17,17 @@ describe('LMLayerWorker', function () { let worker = new Worker(uri); worker.onmessage = function thisShouldBeCalled(message) { done(); + worker.terminate(); }; + // While the config message doesn't trigger a reply message, we have to send it a configuration message first. worker.postMessage({ - message: 'initialize', - model: { type: 'dummy' }, - capabilities: { maxLeftContextCodeUnits: 64 } + message: 'config', + capabilities: helpers.defaultCapabilities + }) + worker.postMessage({ + message: 'load', + // Since the worker's based in a blob, it's not on the 'same domain'. We need to absolute-path the model file. + model: document.location.protocol + '//' + document.location.host + "/resources/models/simple-dummy.js" }); }); }); diff --git a/common/predictive-text/unit_tests/in_browser/helpers.js b/common/predictive-text/unit_tests/in_browser/helpers.js new file mode 100644 index 0000000000..a2d8ab127e --- /dev/null +++ b/common/predictive-text/unit_tests/in_browser/helpers.js @@ -0,0 +1,8 @@ +var helpers; + +// Establishes the equivalent of a TS namespace. +(function(helpers){ + helpers.defaultCapabilities = { + maxLeftContextCodeUnits: 64 + }; +})(helpers || (helpers = {})); \ No newline at end of file diff --git a/common/predictive-text/unit_tests/in_browser/resources/models/simple-dummy.js b/common/predictive-text/unit_tests/in_browser/resources/models/simple-dummy.js new file mode 100644 index 0000000000..85d7b3057d --- /dev/null +++ b/common/predictive-text/unit_tests/in_browser/resources/models/simple-dummy.js @@ -0,0 +1,111 @@ +/** + * While handwritten, this class is designed to mirror the potential results of TypeScript compilation of + * model TS source files. + */ +(function(){ + var Model = /** @class */ (function() { + function Model() { // implements Model + } + + // A direct import/copy from i_got_distracted_by_hazel.json. + Model.futureSuggestions = [ + [ + { + "transform": { + "insert": "I ", + "deleteLeft": 0 + }, + "displayAs": "I" + }, + { + "transform": { + "insert": "I'm ", + "deleteLeft": 0 + }, + "displayAs": "I'm" + }, + { + "transform": { + "insert": "Oh ", + "deleteLeft": 0 + }, + "displayAs": "Oh " + } + ], + [ + { + "transform": { + "insert": "love ", + "deleteLeft": 0 + }, + "displayAs": "love" + }, + { + "transform": { + "insert": "am ", + "deleteLeft": 0 + }, + "displayAs": "am" + }, + { + "transform": { + "insert": "got ", + "deleteLeft": 0 + }, + "displayAs": "got" + } + ], + [ + { + "transform": { + "insert": "distracted ", + "deleteLeft": 0 + }, + "displayAs": "distracted by" + }, + { + "transform": { + "insert": "distracted ", + "deleteLeft": 0 + }, + "displayAs": "distracted" + }, + { + "transform": { + "insert": "a ", + "deleteLeft": 0 + }, + "displayAs": "a" + } + ], + [ + { + "transform": { + "insert": "Hazel ", + "deleteLeft": 0 + }, + "displayAs": "Hazel" + }, + { + "transform": { + "insert": "the ", + "deleteLeft": 0 + }, + "displayAs": "the" + }, + { + "transform": { + "insert": "a ", + "deleteLeft": 0 + }, + "displayAs": "a" + } + ] + ]; + + return Model; + }()); + + // It's a 'dummy' model, so there's no need for extra methods and such within the Model's class definition. + LMLayerWorker.loadModel(new models.DummyModel({futureSuggestions: Model.futureSuggestions})); +})(); \ No newline at end of file diff --git a/common/predictive-text/unit_tests/in_browser/resources/models/simple-wordlist.js b/common/predictive-text/unit_tests/in_browser/resources/models/simple-wordlist.js new file mode 100644 index 0000000000..df485e7626 --- /dev/null +++ b/common/predictive-text/unit_tests/in_browser/resources/models/simple-wordlist.js @@ -0,0 +1,18 @@ +/** + * While handwritten, this class is designed to mirror the potential results of TypeScript compilation of + * model TS source files. + */ +(function(){ + var Model = /** @class */ (function() { + function Model() { // implements Model + } + + // A direct import/copy from english-1000.json. + Model.wordlist = ["the", "of", "and", "to", "a", "in", "that", "is", "was", "he", "for", "it", "with", "as", "his", "on", "be", "at", "by", "i", "this", "had", "not", "are", "but", "from", "or", "have", "an", "they", "which", "one", "you", "were", "her", "all", "she", "there", "would", "their", "we", "him", "been", "has", "when", "who", "will", "more", "if", "no", "out", "so", "said", "what", "up", "its", "about", "into", "than", "them", "can", "only", "other", "new", "some", "could", "time", "these", "two", "may", "then", "do", "first", "any", "my", "now", "such", "like", "our", "over", "man", "me", "even", "most", "made", "after", "also", "did", "many", "before", "must", "af", "through", "back", "years", "much", "where", "your", "way", "well", "down", "should", "because", "each", "just", "those", "people", "too", "how", "little", "state", "good", "very", "make", "world", "still", "own", "see", "men", "work", "long", "here", "get", "between", "both", "life", "being", "under", "never", "day", "same", "another", "know", "while", "last", "us", "might", "great", "old", "year", "off", "come", "since", "against", "go", "came", "right", "used", "three", "take", "himself", "states", "few", "use", "house", "during", "without", "again", "place", "american", "around", "however", "home", "small", "found", "thought", "went", "say", "part", "once", "general", "high", "upon", "school", "every", "does", "got", "united", "left", "number", "course", "war", "until", "always", "away", "something", "fact", "water", "though", "public", "put", "less", "think", "almost", "hand", "enough", "took", "far", "head", "yet", "government", "system", "set", "better", "told", "nothing", "night", "end", "why", "called", "eyes", "find", "look", "going", "asked", "later", "point", "knew", "next", "program", "city", "business", "group", "give", "toward", "young", "let", "room", "days", "president", "side", "social", "given", "present", "several", "order", "national", "possible", "rather", "second", "face", "per", "among", "form", "often", "important", "things", "looked", "early", "white", "case", "john", "large", "four", "need", "big", "within", "become", "felt", "along", "children", "saw", "best", "church", "ever", "least", "power", "development", "thing", "seemed", "light", "family", "interest", "want", "mind", "members", "area", "country", "others", "although", "turned", "done", "open", "god", "service", "problem", "kind", "certain", "different", "thus", "began", "door", "sense", "help", "means", "whole", "matter", "perhaps", "itself", "york", "times", "human", "law", "line", "above", "name", "example", "action", "company", "hands", "local", "show", "whether", "history", "five", "gave", "either", "today", "act", "feet", "across", "quite", "taken", "past", "anything", "having", "seen", "death", "experience", "body", "really", "half", "week", "car", "field", "words", "word", "already", "themselves", "information", "tell", "shall", "together", "college", "money", "period", "keep", "held", "sure", "probably", "free", "seems", "behind", "real", "cannot", "political", "question", "air", "making", "office", "brought", "miss", "whose", "special", "problems", "major", "heard", "became", "moment", "study", "ago", "federal", "known", "available", "street", "result", "economic", "boy", "reason", "position", "south", "change", "board", "individual", "job", "society", "am", "areas", "west", "close", "turn", "love", "community", "true", "force", "full", "court", "seem", "cost", "wife", "age", "future", "wanted", "voice", "department", "center", "woman", "common", "control", "necessary", "policy", "front", "following", "sometimes", "girl", "six", "clear", "further", "land", "music", "feel", "mother", "able", "party", "provide", "university", "education", "level", "run", "students", "effect", "child", "stood", "town", "short", "military", "total", "morning", "outside", "rate", "figure", "art", "class", "century", "usually", "north", "washington", "leave", "therefore", "plan", "top", "evidence", "sound", "million", "black", "hard", "strong", "various", "tax", "believe", "type", "value", "says", "play", "surface", "mean", "soon", "modern", "near", "peace", "lines", "table", "book", "road", "red", "situation", "personal", "minutes", "process", "nor", "idea", "alone", "women", "english", "schools", "gone", "increase", "living", "america", "started", "longer", "cut", "finally", "private", "nature", "secretary", "third", "section", "months", "greater", "call", "fire", "needed", "expected", "view", "values", "kept", "ground", "everything", "pressure", "dark", "basis", "space", "father", "east", "spirit", "required", "union", "complete", "wrote", "except", "moved", "support", "return", "conditions", "recent", "particular", "attention", "late", "hope", "live", "else", "costs", "brown", "beyond", "stage", "taking", "material", "nations", "forces", "report", "inside", "dead", "read", "coming", "person", "hours", "heart", "instead", "low", "miles", "lost", "looking", "data", "added", "makes", "single", "followed", "amount", "pay", "feeling", "basic", "including", "simply", "move", "cold", "hundred", "research", "industry", "tried", "developed", "reached", "hold", "committee", "defense", "equipment", "island", "actually", "shown", "son", "central", "religious", "river", "getting", "beginning", "ten", "sort", "received", "rest", "terms", "doing", "trying", "indeed", "care", "friends", "medical", "picture", "especially", "administration", "subject", "fine", "difficult", "building", "higher", "simple", "wall", "walked", "meeting", "bring", "floor", "foreign", "passed", "similar", "paper", "range", "natural", "final", "property", "training", "police", "international", "cent", "county", "market", "growth", "england", "talk", "written", "start", "story", "suddenly", "hear", "issue", "hall", "answer", "needs", "congress", "working", "likely", "considered", "countries", "earth", "sat", "entire", "meet", "purpose", "labor", "happened", "results", "difference", "cases", "stand", "hair", "william", "production", "stock", "involved", "fall", "food", "whom", "earlier", "increased", "particularly", "thinking", "club", "letter", "knowledge", "below", "effort", "hour", "using", "paid", "sent", "christian", "yes", "points", "boys", "industrial", "bill", "deal", "ready", "ideas", "certainly", "square", "blue", "trade", "moral", "bad", "due", "methods", "girls", "method", "addition", "neither", "showed", "throughout", "directly", "nearly", "statement", "decided", "reading", "weeks", "according", "questions", "anyone", "try", "color", "kennedy", "programs", "nation", "services", "lay", "french", "remember", "physical", "size", "member", "understand", "western", "strength", "record", "comes", "southern", "normal", "population", "appeared", "merely", "concerned", "district", "volume", "temperature", "direction", "trial", "trouble", "summer", "aid", "ran", "friend", "sales", "literature", "list", "maybe", "continued", "evening", "association", "generally", "influence", "led", "provided", "met", "army", "science", "changes", "husband", "step", "chance", "student", "opened", "former", "average", "month", "series", "hot", "works", "cause", "lead", "direct", "systems", "planning", "stopped", "myself", "theory", "piece", "wrong", "effective", "george", "soviet", "freedom", "worked", "ask", "movement", "organization", "clearly", "ways", "treatment", "fear", "beautiful", "meaning", "consider", "bed", "spring", "somewhat", "press", "lot", "forms", "efforts", "note", "truth", "placed", "hotel", "carried", "numbers", "respect", "plant", "herself", "apparently", "groups", "degree", "wide", "easy", "manner", "reaction", "farm", "lower", "recently", "approach", "immediately", "game", "larger", "running", "daily", "charge", "couple", "eye", "oh", "performance", "feed", "de", "persons", "c", "understanding", "arms", "blood", "opportunity", "march", "progress", "additional", "described", "technical", "stop", "fiscal", "radio", "religion", "test", "reported", "main", "based", "steps", "image", "chief", "served", "window", "decision", "determined", "responsibility", "character", "middle", "british", "aj", "europe", "gun", "writing", "horse", "appear", "learned", "account", "serious", "ones", "types", "activity", "green", "length", "letters", "nuclear", "specific", "slowly", "forward", "returned", "lived", "activities", "corner", "obtained", "audience", "justice", "doubt", "latter", "hit", "plane", "gives", "moving", "obviously", "straight", "design", "quality", "saying", "function", "choice", "staff", "include", "pattern", "figures", "born", "parts", "stay", "seven", "poor", "plans", "operation", "shot", "whatever", "cars", "sun", "faith", "pool", "extent", "speak", "heavy", "completely", "lack", "waiting", "standard", "hospital", "ball", "wish", "corps", "visit", "language", "ahead", "deep", "income", "principle", "democratic", "firm", "importance", "growing", "analysis", "designed", "effects", "price", "none", "distance", "indicated", "established", "products", "expect", "division", "continue", "leaders", "existence", "determine", "serve", "stress", "attitude", "pretty", "elements", "easily", "cities", "negro", "remained", "factors", "hardly", "applied", "limited", "closed", "agreement", "scene", "write", "afternoon", "b", "suggested", "health", "professional", "attack", "reach", "drive", "season", "interested", "station", "rhode", "married", "despite", "covered", "role", "becomes", "eight", "current", "played", "spent", "reasons", "council", "unit", "built", "commission", "date", "mouth", "exactly", "original", "studies", "race", "machine"]; + + return Model; + }()); + + // As a wordlist model, we can rely on our "model library" implementation, models.WordListModel. + LMLayerWorker.loadModel(new models.WordListModel(Model.wordlist)); +})(); \ No newline at end of file diff --git a/common/predictive-text/worker/defaultWordBreaker.ts b/common/predictive-text/worker/defaultWordBreaker.ts new file mode 100644 index 0000000000..64ca4081ef --- /dev/null +++ b/common/predictive-text/worker/defaultWordBreaker.ts @@ -0,0 +1,9 @@ +class DefaultWordBreaker implements WorkerInternalWordBreaker { + // TODO: Specify the data needed to implement a 'default' word breaker. + constructor(obj) { + } + + break(text: string): string[] { + throw "Not yet implemented."; + } +} \ No newline at end of file diff --git a/common/predictive-text/worker/index.ts b/common/predictive-text/worker/index.ts index fc4d00b830..a842ddffd5 100644 --- a/common/predictive-text/worker/index.ts +++ b/common/predictive-text/worker/index.ts @@ -30,40 +30,33 @@ */ /// +/// +/// /** * Encapsulates all the state required for the LMLayer's worker thread. * - * Implements the state pattern. There are two states: + * Implements the state pattern. There are three states: * - * - `uninitialized` (initial state) - * - `ready` (accepting state) + * - `unconfigured` (initial state before configuration) + * - `modelless` (state without model loaded) + * - `ready` (state with model loaded, accepts prediction requests) * * Transitions are initiated by valid messages. Invalid * messages are errors, and do not lead to transitions. * - * +-----------------+ +---------+ - * | | initialize | | - * +------> uninitialized +----------->+ ready +---+ - * | | | | | - * +-----------------+ +----^----+ | predict - * | | - * +--------+ + * +-------------+ load +---------+ + * config | |----------->| | + * +-------> modelless + + ready +---+ + * | |<-----------| | | + * +-------------+ unload +----^----+ | predict + * | | + * +--------+ * * The model and the configuration are ONLY relevant in the `ready` state; * as such, they are NOT direct properties of the LMLayerWorker. */ class LMLayerWorker { - /** - * All of the bundled model implementations will add themselves here: - * Note: the models will add themselves by including this - * file using a triple-slash directive: - * /// - * then adding the constructor to this static object: - * LMLayerWorker.models.MyModelImplementation = class {} - */ - static models: {[key: string]: WorkerInternalModelConstructor} = {}; - /** * State pattern. This object handles onMessage(). * handleMessage() can transition to a different state, if @@ -77,11 +70,26 @@ class LMLayerWorker { */ private _postMessage: PostMessage; + /** + * By default, it's self.importScripts(), but can be overridden + * so that this can be tested **outside of a Worker**. + * + * To function properly, self.importScripts() must be bound to self + * before being stored here, else it will fail. + */ + private _importScripts: ImportScripts; + + private _platformCapabilities: Capabilities; + + private _hostURL: string; + constructor(options = { - postMessage: null, + importScripts: null, + postMessage: null }) { this._postMessage = options.postMessage || postMessage; - this.setupInitialState(); + this._importScripts = options.importScripts || importScripts; + this.setupConfigState(); } /** @@ -134,58 +142,79 @@ class LMLayerWorker { * @param desc Type of the model to instantiate and its parameters. * @param capabilities Capabilities on offer from the keyboard. */ - private loadModel(desc: ModelDescription, capabilities: Capabilities) { - let model: WorkerInternalModel; - let configuration: Configuration = { - leftContextCodeUnits: 0, - rightContextCodeUnits: 0 - }; - - if (desc.type === 'dummy') { - model = new LMLayerWorker.models.DummyModel(capabilities, { - futureSuggestions: desc.futureSuggestions - }); - } else if (desc.type === 'wordlist') { - model = new LMLayerWorker.models.WordListModel(capabilities, desc.wordlist); - } else { - throw new Error('Invalid model'); - } - // TODO: when model is object with kind 'wordlist' or 'fst' + public loadModel(model: WorkerInternalModel) { + // TODO: pass _platformConfig to model so that it can self-configure to the platform, + // returning a Configuration. + let configuration = model.configure(this._platformCapabilities); // Set reasonable defaults for the configuration. if (!configuration.leftContextCodeUnits) { - configuration.leftContextCodeUnits = capabilities.maxLeftContextCodeUnits; + configuration.leftContextCodeUnits = this._platformCapabilities.maxLeftContextCodeUnits; } if (!configuration.rightContextCodeUnits) { - configuration.rightContextCodeUnits = capabilities.maxRightContextCodeUnits || 0; + configuration.rightContextCodeUnits = this._platformCapabilities.maxRightContextCodeUnits || 0; } - return {model, configuration}; + this.transitionToReadyState(model); + this.cast('ready', { configuration }); + } + + private loadModelFile(url: string) { + // The self/global WebWorker method, allowing us to directly import another script file into WebWorker scope. + // If built correctly, the model's script file will auto-register the model with loadModel() above. + this._importScripts(url); + } + + public unloadModel() { + // Right now, this seems sufficient to clear out the old model. + // The only existing reference to a loaded model is held by + // transitionToReadyState's `handleMessage` closure. (The `model` var) + this.transitionToLoadingState(); } /** - * Sets the initial state, i.e., `uninitialized`. - * This state only handles `initialized` messages, and will + * Sets the initial state, i.e., `unconfigured`. + * This state only handles `config` messages, and will + * transition to the `modelless` state once it receives + * the config data from the host platform. + */ + private setupConfigState() { + this.state = { + name: 'unconfigured', + handleMessage: (payload) => { + // ... that message must have been 'config'! + if (payload.message !== 'config') { + throw new Error(`invalid message; expected 'config' but got ${payload.message}`); + } + + this._platformCapabilities = payload.capabilities; + + this.transitionToLoadingState(); + } + } + } + + public loadWordBreaker(breaker: WorkerInternalWordBreaker) { + // TODO: Actually store it somewhere for future use. Make sure we can forget it with `unloadModel` as well. + } + + /** + * Sets the model-loading state, i.e., `modelless`. + * This state only handles `load` messages, and will * transition to the `ready` state once it receives a model * description and capabilities. */ - private setupInitialState() { + private transitionToLoadingState() { this.state = { - name: 'uninitialized', + name: 'modelless', handleMessage: (payload) => { - // ...that message must have been 'initialize'! - if (payload.message !== 'initialize') { - throw new Error(`invalid message; expected 'initialize' but got ${payload.message}`); + // ...that message must have been 'load'! + if (payload.message !== 'load') { + throw new Error(`invalid message; expected 'load' but got ${payload.message}`); } // TODO: validate configuration? - let {model, configuration} = this.loadModel( - // TODO: validate configuration, and provide valid configuration in tests. - payload.model, payload.capabilities - ); - - this.transitionToReadyState(model); - this.cast('ready', { configuration }); + this.loadModelFile(payload.model); } }; } @@ -195,21 +224,26 @@ class LMLayerWorker { * fully-instantiated model. The `ready` state only responds * to `predict` message, and is an accepting state. * - * @param model The initialized language model. + * @param model The loaded language model. */ private transitionToReadyState(model: WorkerInternalModel) { this.state = { name: 'ready', handleMessage: (payload) => { - if (payload.message !== 'predict') { - throw new Error(`invalid message; expected 'predict' but got ${payload.message}`); + switch(payload.message) { + case 'predict': + let {transform, context} = payload; + this.cast('suggestions', { + token: payload.token, + suggestions: model.predict(transform, context) + }); + break; + case 'unload': + this.unloadModel(); + break; + default: + throw new Error(`invalid message; expected one of {'predict', 'unload'} but got ${payload.message}`); } - - let {transform, context} = payload; - this.cast('suggestions', { - token: payload.token, - suggestions: model.predict(transform, context) - }); } }; } @@ -233,9 +267,14 @@ class LMLayerWorker { * @param scope A global scope to install upon. */ static install(scope: DedicatedWorkerGlobalScope): LMLayerWorker { - let worker = new LMLayerWorker({ postMessage: scope.postMessage }); + let worker = new LMLayerWorker({ postMessage: scope.postMessage, importScripts: scope.importScripts.bind(scope) }); scope.onmessage = worker.onMessage.bind(worker); + // Ensures that the worker instance is accessible for loaded model scripts. + // Assists unit-testing. + scope['LMLayerWorker'] = worker; + scope['models'] = models; + return worker; } } @@ -243,6 +282,7 @@ class LMLayerWorker { // Let LMLayerWorker be available both in the browser and in Node. if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') { module.exports = LMLayerWorker; + module.exports['models'] = models; } else if (typeof self !== 'undefined' && 'postMessage' in self) { // Automatically install if we're in a Web Worker. LMLayerWorker.install(self as DedicatedWorkerGlobalScope); diff --git a/common/predictive-text/worker/dummy-model.ts b/common/predictive-text/worker/models/dummy-model.ts similarity index 51% rename from common/predictive-text/worker/dummy-model.ts rename to common/predictive-text/worker/models/dummy-model.ts index b6f89b582f..ca78f490d9 100644 --- a/common/predictive-text/worker/dummy-model.ts +++ b/common/predictive-text/worker/models/dummy-model.ts @@ -20,35 +20,43 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -/// +namespace models { + /** + * @file dummy-model.ts + * + * Defines the Dummy model, which is used for testing the + * prediction API exclusively. + */ -/** - * @file dummy-model.ts - * - * Defines the Dummy model, which is used for testing the - * prediction API exclusively. - */ + /** + * The Dummy Model that returns nonsensical, but predictable results. + */ + export class DummyModel implements WorkerInternalModel { + configuration: Configuration; + private _futureSuggestions: Suggestion[][]; -/** - * The Dummy Model that returns nonsensical, but predictable results. - */ -LMLayerWorker.models.DummyModel = class DummyModel implements WorkerInternalModel { - configuration: Configuration; - private _futureSuggestions: Suggestion[][]; - - constructor(capabilities: Capabilities, options?: any) { - options = options || {}; - this.configuration = options.configuration || {}; - // Create a shallow copy of the suggestions; - // this class mutates the array. - this._futureSuggestions = options.futureSuggestions - ? options.futureSuggestions.slice() : []; - } - - predict(transform: Transform, context: Context, injectedSuggestions?: Suggestion[]): Suggestion[] { - if (injectedSuggestions) { - return injectedSuggestions; + constructor(options?: any) { + options = options || {}; + // Create a shallow copy of the suggestions; + // this class mutates the array. + this._futureSuggestions = options.futureSuggestions + ? options.futureSuggestions.slice() : []; } - return this._futureSuggestions.shift(); - } -}; + + configure(capabilities: Capabilities): Configuration { + this.configuration = { + leftContextCodeUnits: capabilities.maxLeftContextCodeUnits, + rightContextCodeUnits: capabilities.maxRightContextCodeUnits + }; + + return this.configuration; + } + + predict(transform: Transform, context: Context, injectedSuggestions?: Suggestion[]): Suggestion[] { + if (injectedSuggestions) { + return injectedSuggestions; + } + return this._futureSuggestions.shift(); + } + }; +} \ No newline at end of file diff --git a/common/predictive-text/worker/wordlist-model.ts b/common/predictive-text/worker/models/wordlist-model.ts similarity index 80% rename from common/predictive-text/worker/wordlist-model.ts rename to common/predictive-text/worker/models/wordlist-model.ts index 518a304e58..2f3d564eb3 100644 --- a/common/predictive-text/worker/wordlist-model.ts +++ b/common/predictive-text/worker/models/wordlist-model.ts @@ -20,34 +20,41 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -/// - /** * @file wordlist-model.ts * * Defines a simple word list (unigram) model. */ -/** - * @class WordListModel - * - * Defines the word list model, or the unigram model. - * Unigram models throw away all preceding words, and search - * for the next word exclusively. As such, they can perform simple - * prefix searches within words, however they are not very good - * at predicting the next word. - */ -LMLayerWorker.models.WordListModel = (function () { + namespace models { + /** + * @class WordListModel + * + * Defines the word list model, or the unigram model. + * Unigram models throw away all preceding words, and search + * for the next word exclusively. As such, they can perform simple + * prefix searches within words, however they are not very good + * at predicting the next word. + */ + /** Upper bound on the amount of suggestions to generate. */ const MAX_SUGGESTIONS = 3; - return class WordListModel implements WorkerInternalModel { + export class WordListModel implements WorkerInternalModel { + configuration: Configuration; private _wordlist: string[]; - constructor(_capabilities: Capabilities, wordlist: string[]) { + constructor(wordlist: string[]) { this._wordlist = wordlist; } + configure(capabilities: Capabilities): Configuration { + return this.configuration = { + leftContextCodeUnits: capabilities.maxLeftContextCodeUnits, + rightContextCodeUnits: capabilities.maxRightContextCodeUnits + }; + } + predict(transform: Transform, context: Context): Suggestion[] { // EVERYTHING to the left of the cursor: let fullLeftContext = context.left || ''; @@ -95,4 +102,4 @@ LMLayerWorker.models.WordListModel = (function () { return suggestions; } }; -}()); +} \ No newline at end of file diff --git a/common/predictive-text/worker/tsconfig.json b/common/predictive-text/worker/tsconfig.json index a8625d5ab3..ff5d48e451 100644 --- a/common/predictive-text/worker/tsconfig.json +++ b/common/predictive-text/worker/tsconfig.json @@ -1,6 +1,7 @@ { "compilerOptions": { "allowJs": false, + "declaration": true, "module": "none", "outFile": "../build/intermediate/index.js", "inlineSources": true, diff --git a/common/predictive-text/worker/worker-interfaces.ts b/common/predictive-text/worker/worker-interfaces.ts index 30ebf86ada..bbee3aac0a 100644 --- a/common/predictive-text/worker/worker-interfaces.ts +++ b/common/predictive-text/worker/worker-interfaces.ts @@ -32,29 +32,39 @@ * The signature of self.postMessage(), so that unit tests can mock it. */ type PostMessage = typeof DedicatedWorkerGlobalScope.prototype.postMessage; +type ImportScripts = typeof DedicatedWorkerGlobalScope.prototype.importScripts; /** * The valid incoming message kinds. */ -type IncomingMessageKind = 'initialize' | 'predict'; -type IncomingMessage = InitializeMessage | PredictMessage; +type IncomingMessageKind = 'config' | 'load' | 'predict' | 'unload'; +type IncomingMessage = ConfigMessage | LoadMessage | PredictMessage | UnloadMessage; + +/** + * The structure of a config message. It should include the platform's supported + * capabilities. + */ +interface ConfigMessage { + message: 'config'; + + /** + * The platform's supported capabilities. + */ + capabilities: Capabilities; +} /** * 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 { - message: 'initialize'; +interface LoadMessage { + message: 'load'; /** - * The model type, and all of its parameters. + * The model's compiled JS file. */ - model: ModelDescription; - /** - * The configuration that the keyboard can offer to the model. - */ - capabilities: Capabilities; + model: string; } /** @@ -86,6 +96,9 @@ interface PredictMessage { context: Context; } +interface UnloadMessage { + message: 'unload' +} /** @@ -96,7 +109,7 @@ interface LMLayerWorkerState { * Informative property. Name of the state. Currently, the LMLayerWorker can only * be the following states: */ - name: 'uninitialized' | 'ready'; + name: 'unconfigured' | 'modelless' | 'ready'; handleMessage(payload: IncomingMessage): void; } @@ -104,6 +117,7 @@ interface LMLayerWorkerState { * The model implementation, within the Worker. */ interface WorkerInternalModel { + configure(capabilities: Capabilities): Configuration; predict(transform: Transform, context: Context): Suggestion[]; } @@ -115,5 +129,9 @@ interface WorkerInternalModelConstructor { * WorkerInternalModel instances are all given the keyboard's * capabilities, plus any parameters they require. */ - new(capabilities: Capabilities, ...modelParameters: any[]): WorkerInternalModel; + new(...modelParameters: any[]): WorkerInternalModel; } + +interface WorkerInternalWordBreaker { + break(text: string): string[]; // +} \ No newline at end of file diff --git a/web/history.md b/web/history.md index 3c9d239077..eeb61b9a59 100644 --- a/web/history.md +++ b/web/history.md @@ -7,6 +7,7 @@ * Attachment to all supported element types has now been abstracted, splitting keystroke processing from related DOM management. * Centralizes code paths to improve parity between hardware and OSK-based keystrokes. * In both cases above, significant unit testing was facilitated and has been added as a result, further improving code maintainability into the future. +* Began adding support for our common LMLayer interface for predictive modeling. ## 2019-02-25 11.0.220 stable * 11.0 Stable release diff --git a/web/source/includes/lmMsgs.d.ts b/web/source/includes/lmMsgs.d.ts index c91043a7ba..2ef1e5a80b 100644 --- a/web/source/includes/lmMsgs.d.ts +++ b/web/source/includes/lmMsgs.d.ts @@ -1 +1 @@ -/// \ No newline at end of file +/// \ No newline at end of file diff --git a/web/source/includes/lmlayer.ts b/web/source/includes/lmlayer.ts index dd0c9ef425..e34f8c6d21 100644 --- a/web/source/includes/lmlayer.ts +++ b/web/source/includes/lmlayer.ts @@ -5,3 +5,6 @@ // Defines the main interface of the Language Modeling Layer (LMLayer) and its original typing information. /// /// + +// We DO need the embedded_worker.d.ts file - since we're directly linking into the LMLayer's code, +// we need the typedef for the embedded worker to be linked. \ No newline at end of file diff --git a/web/source/kmwbase.ts b/web/source/kmwbase.ts index 6f3e02694d..eee76fe48e 100644 --- a/web/source/kmwbase.ts +++ b/web/source/kmwbase.ts @@ -159,6 +159,7 @@ namespace com.keyman { this.osk.shutdown(); this.util.shutdown(); this.keyboardManager.shutdown(); + this.modelManager.shutdown(); if(this.ui && this.ui.shutdown) { this.ui.shutdown(); diff --git a/web/source/text/prediction/modelManager.ts b/web/source/text/prediction/modelManager.ts index eee71ac101..c622cb5c74 100644 --- a/web/source/text/prediction/modelManager.ts +++ b/web/source/text/prediction/modelManager.ts @@ -36,28 +36,29 @@ namespace com.keyman.text.prediction { init() { let keyman = com.keyman.singleton; - this.lmEngine = new LMLayer(); + // Establishes KMW's platform 'capabilities', which limit the range of context a LMLayer + // model may expect. + let capabilities: Capabilities = { + maxLeftContextCodeUnits: 64 + } + this.lmEngine = new LMLayer(capabilities); // Registers this module for keyboard (and thus, language) change events. keyman['addEventListener']('keyboardchange', this.onKeyboardChange.bind(this)); } - private deactivateModel() { - // TODO: Call a LMLayer method for model deactivation. + private unloadModel() { + this.lmEngine.unloadModel(); this.currentModel = null; } - private activateModel(model: ModelSpec) { + private loadModel(model: ModelSpec) { if(!model) { throw new Error("Null reference not allowed."); } - // TODO: Activate this model within the LMLayer! let file = model.path; - - //this.lmEngine.initialize(file) // Currently unsupported. - console.log("Model detected!"); - + this.lmEngine.loadModel(file); this.currentModel = model; } @@ -67,10 +68,10 @@ namespace com.keyman.text.prediction { let model = this.languageModelMap[lgCode]; if(this.currentModel !== model) { - this.deactivateModel(); + this.unloadModel(); if(model) { - this.activateModel(model); + this.loadModel(model); } } } @@ -102,5 +103,11 @@ namespace com.keyman.text.prediction { isRegistered(model: ModelSpec): boolean { return !! this.registeredModels[model.id]; } + + // TODO: actually calling this.lmEngine.predict. Will need its own method(s). + + public shutdown() { + this.lmEngine.shutdown(); + } } } \ No newline at end of file