mirror of
https://github.com/keymanapp/keyman.git
synced 2026-09-11 10:07:42 +00:00
Merge pull request #1644 from keymanapp/web-lmlayer-model-registration
[Web] [LMLayer] Model registration rework & integration
This commit is contained in:
commit
2c99cb723f
32 changed files with 758 additions and 308 deletions
|
|
@ -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.
|
||||
|
|
|
|||
18
common/predictive-text/docs/build.sh
Executable file
18
common/predictive-text/docs/build.sh
Executable file
|
|
@ -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
|
||||
13
common/predictive-text/docs/lmlayer-states.dot
Normal file
13
common/predictive-text/docs/lmlayer-states.dot
Normal file
|
|
@ -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"];
|
||||
}
|
||||
BIN
common/predictive-text/docs/lmlayer-states.png
Normal file
BIN
common/predictive-text/docs/lmlayer-states.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
|
|
@ -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.
|
||||
|
||||

|
||||
|
|
|
|||
17
common/predictive-text/index.d.ts
vendored
17
common/predictive-text/index.d.ts
vendored
|
|
@ -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<Configuration>;
|
||||
loadModel(model: string): Promise<Configuration>;
|
||||
|
||||
/**
|
||||
* Prepares the LMLayer for reinitialization with a different model/capability set.
|
||||
*/
|
||||
unloadModel();
|
||||
|
||||
predict(transform: Transform, context: Context): Promise<Suggestion[]>;
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Suggestion[]>;
|
||||
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<Configuration> {
|
||||
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<Configuration> {
|
||||
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<Suggestion[]> {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
})
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
};
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
8
common/predictive-text/unit_tests/in_browser/helpers.js
Normal file
8
common/predictive-text/unit_tests/in_browser/helpers.js
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
var helpers;
|
||||
|
||||
// Establishes the equivalent of a TS namespace.
|
||||
(function(helpers){
|
||||
helpers.defaultCapabilities = {
|
||||
maxLeftContextCodeUnits: 64
|
||||
};
|
||||
})(helpers || (helpers = {}));
|
||||
|
|
@ -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}));
|
||||
})();
|
||||
File diff suppressed because one or more lines are too long
9
common/predictive-text/worker/defaultWordBreaker.ts
Normal file
9
common/predictive-text/worker/defaultWordBreaker.ts
Normal file
|
|
@ -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.";
|
||||
}
|
||||
}
|
||||
|
|
@ -30,40 +30,33 @@
|
|||
*/
|
||||
|
||||
/// <reference path="../message.d.ts" />
|
||||
/// <reference path="models/dummy-model.ts" />
|
||||
/// <reference path="models/wordlist-model.ts" />
|
||||
|
||||
/**
|
||||
* 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:
|
||||
* /// <reference path="path/to/this/file.ts" />
|
||||
* 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);
|
||||
|
|
|
|||
|
|
@ -20,35 +20,43 @@
|
|||
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
/// <reference path="./index.ts" />
|
||||
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();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -20,34 +20,41 @@
|
|||
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
/// <reference path="./index.ts" />
|
||||
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
};
|
||||
}());
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"allowJs": false,
|
||||
"declaration": true,
|
||||
"module": "none",
|
||||
"outFile": "../build/intermediate/index.js",
|
||||
"inlineSources": true,
|
||||
|
|
|
|||
|
|
@ -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[]; //
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
2
web/source/includes/lmMsgs.d.ts
vendored
2
web/source/includes/lmMsgs.d.ts
vendored
|
|
@ -1 +1 @@
|
|||
///<reference path="../../../common/predictive-text/message.d.ts" />
|
||||
///<reference path="../../../common/predictive-text/build/includes/message.d.ts" />
|
||||
|
|
@ -5,3 +5,6 @@
|
|||
// Defines the main interface of the Language Modeling Layer (LMLayer) and its original typing information.
|
||||
///<reference path="../../../common/predictive-text/embedded_worker.d.ts" />
|
||||
///<reference path="../../../common/predictive-text/index.ts" />
|
||||
|
||||
// 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.
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue