diff --git a/common/web/types/src/lexical-model-types.ts b/common/web/types/src/lexical-model-types.ts index 82619b4ce4..a3c04063fd 100644 --- a/common/web/types/src/lexical-model-types.ts +++ b/common/web/types/src/lexical-model-types.ts @@ -32,30 +32,37 @@ export type TextWithProbability = { */ export interface LexiconTraversal { /** - * Provides an iterable pattern used to search for words with a 'keyed' prefix matching - * the current traversal state's prefix when a new character is appended. Iterating - * across `children` provides 'breadth' to a lexical search. + * Provides an iterable pattern used to search for words with a 'keyed' prefix + * matching the current traversal state's prefix when a new character is + * appended. Iterating across `children` provides 'breadth' to a lexical + * search. * - * For an example with English, if the current traversal state corresponds to 'th', - * children() may return an iterator with states corresponding 'e' - * (for 'the', 'then', 'there'), 'a' (for 'than', 'that'), etc. + * For an example with English, if the current traversal state corresponds to + * 'th', children() may return an iterator with states corresponding 'e' (for + * 'the', 'then', 'there'), 'a' (for 'than', 'that'), etc. * - * @param char A full character (a UTF-16 code point, which may be comprised of two - * code units) corresponding to the child node, appended to the current - * node's prefix to produce the child node's prefix. + * @param char A full character (a UTF-16 code point, which may be + * comprised of two code units) corresponding to the child + * node, appended to the current node's prefix to produce the + * child node's prefix. *

* Example: if the current traversal's represented prefix is 'th', * char = 'e' would indicate a child node with prefix = 'the'. - * @param traversal a LexiconTraversal starting from (or "rooted at") the child node. Use - * of the returned object provides 'depth' to a lexical search. + * @param traversal a LexiconTraversal starting from (or "rooted at") the + * child node. Use of the returned object provides 'depth' + * to a lexical search. *

* Example: * * - Suppose our current LexiconTraversal represents a prefix of 'th'. * - If `char` = 'e', the child represents a prefix of 'the'. - * - Then `traversal` allows traversing the part of the lexicon prefixed by 'the'. + * - Then `traversal` allows traversing the part of the lexicon prefixed by + * 'the'. + * + * Note that there is no requirement for the ordering of the returned entries; + * no sorting of any kind is required. */ - children(): Generator<{char: string, traversal: () => LexiconTraversal}>; + children(): {char: string, p: number, traversal: () => LexiconTraversal}[]; /** * Allows direct access to the traversal state that results when appending one diff --git a/developer/src/kmc-model/build.sh b/developer/src/kmc-model/build.sh index 4b5f15cc30..9cefefa6e7 100755 --- a/developer/src/kmc-model/build.sh +++ b/developer/src/kmc-model/build.sh @@ -11,8 +11,10 @@ THIS_SCRIPT="$(readlink -f "${BASH_SOURCE[0]}")" builder_describe "Keyman kmc Lexical Model Compiler module" \ "@/common/web/keyman-version" \ + "@/common/web/types" \ "@/developer/src/common/web/test-helpers" \ - "@/web/src/engine/predictive-text/templates/ test" \ + "@/developer/src/common/web/utils" \ + "@/web/src/engine/predictive-text/templates/" \ "clean" \ "configure" \ "build" \ diff --git a/developer/src/kmc-model/package.json b/developer/src/kmc-model/package.json index 52ef40c425..74e24833b9 100644 --- a/developer/src/kmc-model/package.json +++ b/developer/src/kmc-model/package.json @@ -32,11 +32,14 @@ "dependencies": { "@keymanapp/common-types": "*", "@keymanapp/keyman-version": "*", + "@keymanapp/models-templates": "*", "typescript": "^5.4.5" }, "devDependencies": { "@keymanapp/developer-test-helpers": "*", "@keymanapp/models-templates": "*", + "@types/mocha": "^5.2.7", + "@types/node": "^20.4.1", "c8": "^7.12.0", "chalk": "^2.4.2", "esbuild": "^0.25.0" diff --git a/developer/src/kmc-model/src/build-trie.ts b/developer/src/kmc-model/src/build-trie.ts index e65b2fc42c..8473ef5c23 100644 --- a/developer/src/kmc-model/src/build-trie.ts +++ b/developer/src/kmc-model/src/build-trie.ts @@ -1,6 +1,8 @@ import { ModelCompilerError, ModelCompilerMessageContext, ModelCompilerMessages } from "./model-compiler-messages.js"; import { callbacks } from "./compiler-callbacks.js"; +import { TrieBuilder } from '@keymanapp/models-templates'; + // Supports LF or CRLF line terminators. const NEWLINE_SEPARATOR = /\u000d?\u000a/; @@ -29,7 +31,7 @@ export function createTrieDataStructure(filenames: string[], searchTermToKey?: ( const wordlist: WordList = {}; filenames.forEach(filename => parseWordListFromFilename(wordlist, filename)); - const trie = Trie.buildTrie(wordlist, searchTermToKey as Trie.SearchTermToKey); + const trie = buildTrie(wordlist, searchTermToKey as SearchTermToKey); return JSON.stringify(trie); } @@ -188,302 +190,51 @@ function* enumerateLines(lines: string[]): Generator { } } -namespace Trie { - /** - * An **opaque** type for a string that is exclusively used as a search key in - * the trie. There should be a function that converts arbitrary strings - * (queries) and converts them into a standard search key for a given language - * model. - * - * Fun fact: This opaque type has ALREADY saved my bacon and found a bug! - */ - type SearchKey = string & { _: 'SearchKey'}; +/** + * An **opaque** type for a string that is exclusively used as a search key in + * the trie. There should be a function that converts arbitrary strings + * (queries) and converts them into a standard search key for a given language + * model. + * + * Fun fact: This opaque type has ALREADY saved my bacon and found a bug! + */ +type SearchKey = string & { _: 'SearchKey'}; - /** - * A function that converts a string (word form or query) into a search key - * (secretly, this is also a string). - */ - export interface SearchTermToKey { - (wordform: string): SearchKey; +/** + * A function that converts a string (word form or query) into a search key + * (secretly, this is also a string). + */ +export interface SearchTermToKey { + (wordform: string): SearchKey; +} + +/** + * Builds a trie from a word list. + * + * @param wordlist The wordlist with non-negative weights. + * @param keyFunction Function that converts word forms into indexed search keys + * @returns A JSON-serialiable object that can be given to the TrieModel constructor. + */ +export function buildTrie(wordlist: WordList, keyFunction: SearchTermToKey): object { + const collater = new TrieBuilder(keyFunction); + + buildFromWordList(collater, wordlist); + return { + totalWeight: collater.getTotalWeight(), + root: collater.getRoot() } +} - // The following trie implementation has been (heavily) derived from trie-ing - // by Conrad Irwin. - // - // trie-ing is distributed under the terms of the MIT license, reproduced here: - // - // The MIT License - // Copyright (c) 2015-2017 Conrad Irwin - // Copyright (c) 2011 Marc Campbell - // - // Permission is hereby granted, free of charge, to any person obtaining a copy - // of this software and associated documentation files (the "Software"), to deal - // in the Software without restriction, including without limitation the rights - // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - // copies of the Software, and to permit persons to whom the Software is - // furnished to do so, subject to the following conditions: - // - // The above copyright notice and this permission notice shall be included in - // all copies or substantial portions of the Software. - // - // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - // THE SOFTWARE. - // - // See: https://github.com/ConradIrwin/trie-ing/blob/df55d7af7068d357829db9e0a7faa8a38add1d1d/LICENSE - - /** - * An entry in the prefix trie. The matched word is "content". - */ - interface Entry { - content: string; - key: SearchKey; - weight: number; - } - - /** - * The trie is made up of nodes. A node can be EITHER an internal node (whose - * only children are other nodes) OR a leaf, which actually contains the word - * form entries. - */ - type Node = InternalNode | Leaf; - - /** - * An internal node. - */ - interface InternalNode { - type: 'internal'; - weight: number; - // TODO: As an optimization, "values" can be a single string! - values: string[]; - children: { [codeunit: string]: Node }; - unsorted?: true; - } - - /** - * A leaf node. - */ - interface Leaf { - type: 'leaf'; - weight: number; - entries: Entry[]; - unsorted?: true; - } - - /** - * A sentinel value for when an internal node has contents and requires an - * "internal" leaf. That is, this internal node has content. Instead of placing - * entries as children in an internal node, a "fake" leaf is created, and its - * key is this special internal value. - * - * The value is a valid Unicode BMP code point, but it is a "non-character". - * Unicode will never assign semantics to these characters, as they are - * intended to be used internally as sentinel values. - */ - const INTERNAL_VALUE = '\uFDD0'; - - /** - * Builds a trie from a word list. - * - * @param wordlist The wordlist with non-negative weights. - * @param keyFunction Function that converts word forms into indexed search keys - * @returns A JSON-serialiable object that can be given to the TrieModel constructor. - */ - export function buildTrie(wordlist: WordList, keyFunction: SearchTermToKey): object { - const root = new Trie(keyFunction).buildFromWordList(wordlist).root; - return { - totalWeight: sumWeights(root), - root: root - } - } - - /** - * Wrapper class for the trie and its nodes and wordform to search - */ - class Trie { - readonly root = createRootNode(); - toKey: SearchTermToKey; - constructor(wordform2key: SearchTermToKey) { - this.toKey = wordform2key; - } - - /** - * Populates the trie with the contents of an entire wordlist. - * @param words a list of word and count pairs. - */ - buildFromWordList(words: WordList): Trie { - for (const [wordform, weight] of Object.entries(words)) { - const key = this.toKey(wordform); - addUnsorted(this.root, { key, weight, content: wordform }, 0); - } - sortTrie(this.root); - return this; - } - } - - // "Constructors" - function createRootNode(): Node { - return { - type: 'leaf', - weight: 0, - entries: [] - }; - } - - // Implement Trie creation. - - /** - * Adds an entry to the trie. - * - * Note that the trie will likely be unsorted after the add occurs. Before - * performing a lookup on the trie, use call sortTrie() on the root note! - * - * @param node Which node should the entry be added to? - * @param entry the wordform/weight/key to add to the trie - * @param index the index in the key and also the trie depth. Should be set to - * zero when adding onto the root node of the trie. - */ - function addUnsorted(node: Node, entry: Entry, index: number = 0) { - // Each node stores the MAXIMUM weight out of all of its decesdents, to - // enable a greedy search through the trie. - node.weight = Math.max(node.weight, entry.weight); - - // When should a leaf become an interior node? - // When it already has a value, but the key of the current value is longer - // than the prefix. - if (node.type === 'leaf' && index < entry.key.length && node.entries.length >= 1) { - convertLeafToInternalNode(node, index); - } - - if (node.type === 'leaf') { - // The key matches this leaf node, so add yet another entry. - addItemToLeaf(node, entry); - } else { - // Push the node down to a lower node. - addItemToInternalNode(node, entry, index); - } - - node.unsorted = true; - } - - /** - * Adds an item to the internal node at a given depth. - * @param item - * @param index - */ - function addItemToInternalNode(node: InternalNode, item: Entry, index: number) { - let char = item.key[index]; - // If an internal node is the proper site for item, it belongs under the - // corresponding (sentinel, internal-use) child node signifying this. - if(char == undefined) { - char = INTERNAL_VALUE; - } - if (!node.children[char]) { - node.children[char] = createRootNode(); - node.values.push(char); - } - addUnsorted(node.children[char], item, index + 1); - } - - function addItemToLeaf(leaf: Leaf, item: Entry) { - leaf.entries.push(item); - } - - /** - * Mutates the given Leaf to turn it into an InternalNode. - * - * NOTE: the node passed in will be DESTRUCTIVELY CHANGED into a different - * type when passed into this function! - * - * @param depth depth of the trie at this level. - */ - function convertLeafToInternalNode(leaf: Leaf, depth: number): void { - const entries = leaf.entries; - - // Alias the current node, as the desired type. - const internal = ( leaf) as InternalNode; - internal.type = 'internal'; - - delete leaf.entries; - internal.values = []; - internal.children = {}; - - // Convert the old values array into the format for interior nodes. - for (const item of entries) { - let char: string; - if (depth < item.key.length) { - char = item.key[depth]; - } else { - char = INTERNAL_VALUE; - } - - if (!internal.children[char]) { - internal.children[char] = createRootNode(); - internal.values.push(char); - } - addUnsorted(internal.children[char], item, depth + 1); - } - - internal.unsorted = true; - } - - /** - * Recursively sort the trie, in descending order of weight. - * @param node any node in the trie - */ - function sortTrie(node: Node) { - if (node.type === 'leaf') { - if (!node.unsorted) { - return; - } - - node.entries.sort(function (a, b) { return b.weight - a.weight; }); - } else { - // We MUST recurse and sort children before returning. - for (const char of node.values) { - sortTrie(node.children[char]); - } - - if (!node.unsorted) { - return; - } - - node.values.sort((a, b) => { - return node.children[b].weight - node.children[a].weight; - }); - } - - delete node.unsorted; - } - - /** - * O(n) recursive traversal to sum the total weight of all leaves in the - * trie, starting at the provided node. - * - * @param node The node to start summing weights. - */ - function sumWeights(node: Node): number { - let val: number; - if (node.type === 'leaf') { - val = node.entries - .map(entry => entry.weight) - //.map(entry => isNaN(entry.weight) ? 1 : entry.weight) - .reduce((acc, count) => acc + count, 0); - } else { - val = Object.keys(node.children) - .map((key) => sumWeights(node.children[key])) - .reduce((acc, count) => acc + count, 0); - } - - if(isNaN(val)) { - throw new Error("Unexpected NaN has appeared!"); - } - return val; +/** + * Populates the trie with the contents of an entire wordlist. + * @param words a list of word and count pairs. + */ +function buildFromWordList(trieCollator: TrieBuilder, words: WordList): TrieBuilder { + for (const [wordform, weight] of Object.entries(words)) { + trieCollator.addEntry(wordform, weight); } + trieCollator.sort(); + return trieCollator; } /** diff --git a/package-lock.json b/package-lock.json index c05360af53..668fd0fe41 100644 --- a/package-lock.json +++ b/package-lock.json @@ -804,11 +804,14 @@ "dependencies": { "@keymanapp/common-types": "*", "@keymanapp/keyman-version": "*", + "@keymanapp/models-templates": "*", "typescript": "^5.4.5" }, "devDependencies": { "@keymanapp/developer-test-helpers": "*", "@keymanapp/models-templates": "*", + "@types/mocha": "^5.2.7", + "@types/node": "^20.4.1", "c8": "^7.12.0", "chalk": "^2.4.2", "esbuild": "^0.25.0" @@ -883,6 +886,13 @@ "node": ">=4" } }, + "developer/src/kmc-model/node_modules/@types/mocha": { + "version": "5.2.7", + "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-5.2.7.tgz", + "integrity": "sha512-NYrtPht0wGzhwe9+/idPaBB+TqkY9AhTvOLMkThm0IoEfLaiVQZwBwyJ5puCkO3AUCWrmcoePjp2mbFocKy4SQ==", + "dev": true, + "license": "MIT" + }, "developer/src/kmc-model/node_modules/ansi-styles": { "version": "3.2.1", "dev": true, diff --git a/web/src/engine/predictive-text/templates/src/common.ts b/web/src/engine/predictive-text/templates/src/common.ts index f23ad6095b..1595b3f2e9 100644 --- a/web/src/engine/predictive-text/templates/src/common.ts +++ b/web/src/engine/predictive-text/templates/src/common.ts @@ -157,3 +157,19 @@ export function defaultApplyCasing(casing: CasingForm, text: string): string { return text.substring(0, headUnitLength).toUpperCase() .concat(text.substring(headUnitLength)); } } + +/** + * An **opaque** type for a string that is exclusively used as a search key in + * the trie. There should be a function that converts arbitrary strings + * (queries) and converts them into a standard search key for a given language + * model. + */ +export type SearchKey = string & { _: 'SearchKey'}; + +/** + * A function that converts a string (word form or query) into a search key + * (which, internally, is also a string type). + */ +export interface Wordform2Key { + (wordform: string): SearchKey; +} diff --git a/web/src/engine/predictive-text/templates/src/index.ts b/web/src/engine/predictive-text/templates/src/index.ts index 0966fb4494..fb681472fa 100644 --- a/web/src/engine/predictive-text/templates/src/index.ts +++ b/web/src/engine/predictive-text/templates/src/index.ts @@ -1,7 +1,12 @@ export { - SENTINEL_CODE_UNIT, applyTransform, buildMergedTransform, isHighSurrogate, isLowSurrogate, isSentinel, - transformToSuggestion, defaultApplyCasing + applyTransform, buildMergedTransform, isHighSurrogate, isLowSurrogate, isSentinel, + SearchKey, SENTINEL_CODE_UNIT, transformToSuggestion, defaultApplyCasing } from "./common.js"; export { default as QuoteBehavior } from "./quote-behavior.js"; export { getLastPreCaretToken, Token, Tokenization, tokenize, wordbreak } from "./tokenization.js"; +export { + Entry, InternalNode, Leaf, Node +} from './trie.js'; +export { TrieBuilder } from './trie-builder.js'; +export * as trieConstruction from './trie-builder.js'; export { default as TrieModel, TrieModelOptions } from "./trie-model.js"; \ No newline at end of file diff --git a/web/src/engine/predictive-text/templates/src/traversal-model.ts b/web/src/engine/predictive-text/templates/src/traversal-model.ts new file mode 100644 index 0000000000..a81f45d277 --- /dev/null +++ b/web/src/engine/predictive-text/templates/src/traversal-model.ts @@ -0,0 +1,297 @@ +/* + * Copyright (c) 2019 National Research Council Canada (author: Eddie A. Santos) + * Copyright (c) 2019 SIL International + * Copyright (c) 2015–2017 Conrad Irwin + * Copyright (c) 2011–2015 Marc Campbell + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +// Allows the kmwstring bindings to resolve. +import { KMWString, PriorityQueue } from "@keymanapp/web-utils"; +import { default as defaultWordBreaker } from "@keymanapp/models-wordbreakers"; + +import { applyTransform, SearchKey, transformToSuggestion, Wordform2Key } from "./common.js"; +import { getLastPreCaretToken } from "./tokenization.js"; +import { LexicalModelTypes } from "@keymanapp/common-types"; +import Capabilities = LexicalModelTypes.Capabilities; +import CasingFunction = LexicalModelTypes.CasingFunction; +import Configuration = LexicalModelTypes.Configuration; +import Context = LexicalModelTypes.Context; +import Distribution = LexicalModelTypes.Distribution; +import LexicalModel = LexicalModelTypes.LexicalModel; +import LexicalModelPunctuation = LexicalModelTypes.LexicalModelPunctuation; +import LexiconTraversal = LexicalModelTypes.LexiconTraversal; +import Suggestion = LexicalModelTypes.Suggestion; +import TextWithProbability = LexicalModelTypes.TextWithProbability; +import Transform = LexicalModelTypes.Transform; +import WithOutcome = LexicalModelTypes.WithOutcome; +import WordBreakingFunction = LexicalModelTypes.WordBreakingFunction; + +/** + * @file traversal-model.ts + * + * Defines a lexical model based upon the implementation of a `LexiconTraversal`. + */ + +/** Upper bound on the amount of suggestions to generate. */ +const MAX_SUGGESTIONS = 12; + +/** + * Additional arguments to pass into the model, in addition to the model + * parameters themselves. + */ +export interface ModelOptions { + /** + * How to break words in a phrase. + */ + wordBreaker?: WordBreakingFunction; + /** + * This should simplify a search term into a key. + */ + searchTermToKey?: (searchTerm: string) => string; + + /** + * Indicates that the model's written form has 'casing' behavior. + */ + languageUsesCasing?: boolean; + + /** + * Specifies a function used to apply the language's casing rules to a word. + */ + applyCasing?: CasingFunction; + + /** + * Any punctuation to expose to the user. + */ + punctuation?: LexicalModelPunctuation; +} + +/** + * @class TraversalModel + * + * Defines a lexical model based upon the implementation of a `LexiconTraversal`. + */ +export class TraversalModel implements LexicalModel { + configuration?: Configuration; + private rootTraversal: LexiconTraversal; + readonly breakWords: WordBreakingFunction; + readonly punctuation?: LexicalModelPunctuation; + readonly languageUsesCasing?: boolean; + readonly _toKey: Wordform2Key; + + readonly applyCasing?: CasingFunction; + + constructor(rootTraversal: LexiconTraversal, options: ModelOptions = {}) { + this.languageUsesCasing = options.languageUsesCasing; + this.applyCasing = options.applyCasing; + + this.rootTraversal = rootTraversal; + this._toKey = options.searchTermToKey as Wordform2Key || defaultSearchTermToKey + this.breakWords = options.wordBreaker || defaultWordBreaker; + this.punctuation = options.punctuation; + } + + configure(capabilities: Capabilities): Configuration { + return this.configuration = { + leftContextCodePoints: capabilities.maxLeftContextCodePoints, + rightContextCodePoints: capabilities.maxRightContextCodePoints ?? 0 + }; + } + + toKey(text: string): string { + return this._toKey(text); + } + + predict(transform: Transform, context: Context): Distribution { + const makeDistribution = function(suggestions: WithOutcome[]): Distribution { + let distribution: Distribution = []; + + for(const s of suggestions) { + distribution.push({sample: s, p: s.p}); + } + + return distribution; + } + + // Special-case the empty buffer/transform: return the top suggestions. + if (!transform.insert && !context.left && !context.right && context.startOfBuffer && context.endOfBuffer) { + return makeDistribution(this.firstN(MAX_SUGGESTIONS).map(({text, p}) => ({ + transform: { + insert: text, + deleteLeft: 0 + }, + displayAs: text, + p: p + }))); + } + + // Compute the results of the keystroke: + const newContext = applyTransform(transform, context); + + // Computes the different in word length after applying the transform above. + const leftDelOffset = transform.deleteLeft - KMWString.length(transform.insert); + + // All text to the left of the cursor INCLUDING anything that has + // just been typed. + const prefix = getLastPreCaretToken(this.breakWords, newContext); + + // Return suggestions from the trie. + return makeDistribution(this.lookup(prefix).map(({text, p}) => + transformToSuggestion({ + insert: text, + // Delete whatever the prefix that the user wrote. + deleteLeft: leftDelOffset + KMWString.length(prefix) + // Note: a separate capitalization/orthography engine can take this + // result and transform it as needed. + }, + p + ))); + } + + get wordbreaker(): WordBreakingFunction { + return this.breakWords; + } + + public traverseFromRoot(): LexiconTraversal { + return this.rootTraversal; + } + + /** + * Returns the top N suggestions from the trie. + * @param n How many suggestions, maximum, to return. + */ + firstN(n: number): TextWithProbability[] { + return getSortedResults(this.rootTraversal, n); + } + + /** + * Lookups an arbitrary prefix (a query) in the trie. Returns the top 3 + * results in sorted order. + * + * @param prefix + */ + lookup(prefix: string): TextWithProbability[] { + const searchKey = this.toKey(prefix); + const rootTraversal = this.traverseFromRoot().child(searchKey); + + if(!rootTraversal) { + return []; + } + + const directEntries = rootTraversal.entries; + // `Set` requires Chrome 38+, which is more recent than Chrome 35. + const directSet: Record = {}; + for(const entry of directEntries) { + directSet[entry.text] = entry.text; + } + + const bestEntries = getSortedResults(rootTraversal); + const deduplicated = bestEntries.filter((entry) => !directSet[entry.text]); + + // Any entries directly hosted on the current node should get full display + // priority over anything from its descendants. + return directEntries.concat(deduplicated); + } +}; + +/** + * The priority queue will always pop the most probable item - be it a Traversal + * state or a lexical entry reached via Traversal. + */ +type TraversableWithProb = TextWithProbability | LexiconTraversal; + +/** + * Returns all entries matching the given prefix, in descending order of + * weight. + * + * @param prefix the prefix to match. + * @param results the current results + * @param queue + */ +function getSortedResults(traversal: LexiconTraversal, limit = MAX_SUGGESTIONS): TextWithProbability[] { + let queue = new PriorityQueue(function(a: TraversableWithProb, b: TraversableWithProb) { + // In case of Trie compilation issues that emit `null` or `undefined` + return (b ? b.p : 0) - (a ? a.p : 0); + }); + let results: TextWithProbability[] = []; + + queue.enqueue(traversal); + + while(queue.count > 0) { + const entry = queue.dequeue(); + + if((entry as TextWithProbability)!.text !== undefined) { + const lexicalEntry = entry as TextWithProbability; + results.push(lexicalEntry); + if(results.length >= limit) { + return results; + } + } else { + const traversal = entry as LexiconTraversal; + queue.enqueueAll(traversal.entries); + let children: LexiconTraversal[] = [] + for(let child of traversal.children()) { + children.push(child.traversal()); + } + queue.enqueueAll(children); + } + } + + return results; +} + +/** + * Converts wordforms into an indexable form. It does this by + * normalizing into NFD, removing diacritics, and then converting + * the result to lowercase. + * + * This is a very naïve implementation, that I only think will work on + * some languages that use the Latin script. As of 2020-04-08, only + * 4 out of 11 (36%) of published language models use the Latin script, + * so this might not actually be a great default. + * + * This uses String.prototype.normalize() to convert normalize into NFD. + * NFD is an easy way to separate a Latin character from its diacritics; + * Even then, some Latin-based orthographies use code points that, + * under NFD normalization, do NOT decompose into an ASCII letter and a + * combining diacritical mark (e.g., SENĆOŦEN). + * + * Use this only in early iterations of the model. For a production lexical + * model, you SHOULD write/generate your own key function, tailored to your + * language. + */ +function defaultSearchTermToKey(wordform: string): SearchKey { + /** + * N.B.: this is (slightly) DIFFERENT than the version in + * keymanapp/lexical-model-compiler/build-trie + * as this is for compatibility for models built + * BEFORE the searchTermToKey function was bundled with + * all models. + * + * This compatibility version lowercases AFTER removing diacritics; + * the new version (bundled in future models) lowercases, + * NFD normalizes, THEN removes diacritics. + */ + return wordform + .normalize('NFD') + // Remove all combining diacritics (if input is in NFD) + // common to Latin-orthographies. + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() as SearchKey; +} diff --git a/web/src/engine/predictive-text/templates/src/trie-builder.ts b/web/src/engine/predictive-text/templates/src/trie-builder.ts new file mode 100644 index 0000000000..790923a1e4 --- /dev/null +++ b/web/src/engine/predictive-text/templates/src/trie-builder.ts @@ -0,0 +1,146 @@ +import { SENTINEL_CODE_UNIT, Wordform2Key } from "./common.js"; +import { Entry, InternalNode, Leaf, Node, Trie, sortNode } from "./trie.js"; + +export function createRootNode(): Node { + return { + type: 'leaf', + weight: 0, + entries: [] + }; +} + +/** + * Adds an entry to the trie. Currently assumes there is no pre-existing match + * for the entry. + * + * Note that the trie will likely be unsorted after the add occurs. Before + * performing a lookup on the trie, use call sortTrie() on the root note! + * + * @param node Which node should the entry be added to? + * @param entry the wordform/weight/key to add to the trie + * @param index the index in the key and also the trie depth. Should be set to + * zero when adding onto the root node of the trie. + */ +export function addUnsorted(node: Node, entry: Entry, index: number = 0) { + // Each node stores the MAXIMUM weight out of all of its decesdents, to + // enable a greedy search through the trie. + node.weight = Math.max(node.weight, entry.weight); + + // When should a leaf become an interior node? + // When it already has a value, but the key of the current value is longer + // than the prefix. + if (node.type === 'leaf' && index < entry.key.length && node.entries.length >= 1) { + convertLeafToInternalNode(node, index); + } + + if (node.type === 'leaf') { + // The key matches this leaf node, so add yet another entry. + addItemToLeaf(node, entry); + } else { + // Push the node down to a lower node. + addItemToInternalNode(node, entry, index); + } + + node.unsorted = true; +} + +/** + * Adds an item to the internal node at a given depth. + * @param item + * @param index + */ +export function addItemToInternalNode(node: InternalNode, item: Entry, index: number) { + let char = item.key[index]; + // If an internal node is the proper site for item, it belongs under the + // corresponding (sentinel, internal-use) child node signifying this. + if(char == undefined) { + char = SENTINEL_CODE_UNIT; + } + if (!node.children[char]) { + node.children[char] = createRootNode(); + node.values.push(char); + } + addUnsorted(node.children[char], item, index + 1); +} + +export function addItemToLeaf(leaf: Leaf, item: Entry) { + leaf.entries.push(item); +} + +/** + * Mutates the given Leaf to turn it into an InternalNode. + * + * NOTE: the node passed in will be DESTRUCTIVELY CHANGED into a different + * type when passed into this function! + * + * @param depth depth of the trie at this level. + */ +export function convertLeafToInternalNode(leaf: Leaf, depth: number): void { + let entries = leaf.entries; + + // Alias the current node, as the desired type. + let internal = ( leaf) as InternalNode; + internal.type = 'internal'; + + delete (leaf as Partial).entries; + internal.values = []; + internal.children = {}; + + // Convert the old values array into the format for interior nodes. + for (let item of entries) { + let char: string; + if (depth < item.key.length) { + char = item.key[depth]; + } else { + char = SENTINEL_CODE_UNIT; + } + + if (!internal.children[char]) { + internal.children[char] = createRootNode(); + internal.values.push(char); + } + addUnsorted(internal.children[char], item, depth + 1); + } + + internal.unsorted = true; +} + +/** + * Wrapper class for the trie and its nodes. + */ +export class TrieBuilder extends Trie { + /** The total weight of the entire trie. */ + totalWeight: number; + + constructor(toKey: Wordform2Key) { + super(createRootNode(), 0, toKey); + this.totalWeight = 0; + } + + addEntry(word: string, weight?: number) { + weight = (isNaN(weight ?? NaN) || !weight) ? 1 : weight; + this.totalWeight += weight; + + addUnsorted( + this.root, { + key: this.toKey(word), + content: word, + weight: weight + }, + 0 + ); + } + + sort() { + // Sorts the full Trie, not just a part of it. + sortNode(this.root); + } + + getRoot(): Node { + return this.root; + } + + getTotalWeight(): number { + return this.totalWeight; + } +} \ No newline at end of file diff --git a/web/src/engine/predictive-text/templates/src/trie-model.ts b/web/src/engine/predictive-text/templates/src/trie-model.ts index 938553330d..f998d0b131 100644 --- a/web/src/engine/predictive-text/templates/src/trie-model.ts +++ b/web/src/engine/predictive-text/templates/src/trie-model.ts @@ -25,25 +25,15 @@ // Worth noting: we're starting to get quite a 'library' of common model/LMLayer functionality. // Should probably make a 'lm-utils' submodule. -import { KMWString, PriorityQueue } from "@keymanapp/web-utils"; -import { default as defaultWordBreaker } from "@keymanapp/models-wordbreakers"; - -import { applyTransform, isHighSurrogate, isSentinel, SENTINEL_CODE_UNIT, transformToSuggestion } from "./common.js"; -import { getLastPreCaretToken } from "./tokenization.js"; import { LexicalModelTypes } from "@keymanapp/common-types"; -import Capabilities = LexicalModelTypes.Capabilities; import CasingFunction = LexicalModelTypes.CasingFunction; import Configuration = LexicalModelTypes.Configuration; -import Context = LexicalModelTypes.Context; -import Distribution = LexicalModelTypes.Distribution; import LexicalModel = LexicalModelTypes.LexicalModel; import LexicalModelPunctuation = LexicalModelTypes.LexicalModelPunctuation; -import LexiconTraversal = LexicalModelTypes.LexiconTraversal; -import Suggestion = LexicalModelTypes.Suggestion; -import TextWithProbability = LexicalModelTypes.TextWithProbability; -import Transform = LexicalModelTypes.Transform; -import WithOutcome = LexicalModelTypes.WithOutcome; import WordBreakingFunction = LexicalModelTypes.WordBreakingFunction; +import { SearchKey, Wordform2Key } from "./common.js"; +import { Node, Trie, TrieTraversal } from './trie.js'; +import { TraversalModel } from './traversal-model.js'; /** * @file trie-model.ts @@ -51,9 +41,6 @@ import WordBreakingFunction = LexicalModelTypes.WordBreakingFunction; * Defines a simple word list (unigram) model. */ -/** Upper bound on the amount of suggestions to generate. */ -const MAX_SUGGESTIONS = 12; - /** * Additional arguments to pass into the model, in addition to the model * parameters themselves. @@ -84,182 +71,6 @@ export interface TrieModelOptions { punctuation?: LexicalModelPunctuation; } -export class Traversal implements LexiconTraversal { - /** - * The lexical prefix corresponding to the current traversal state. - */ - readonly prefix: string; - - /** - * The current traversal node. Serves as the 'root' of its own sub-Trie, - * and we cannot navigate back to its parent. - */ - readonly root: Node; - - /** - * The max weight for the Trie being 'traversed'. Needed for probability - * calculations. - */ - readonly totalWeight: number; - - constructor(root: Node, prefix: string, totalWeight: number) { - this.root = root; - this.prefix = prefix; - this.totalWeight = totalWeight; - } - - child(char: string): Traversal | undefined { - // May result for blank tokens resulting immediately after whitespace. - if(char == '') { - return this; - } - - // Split into individual code units. - let steps = char.split(''); - let traversal: Traversal | undefined = this; - - while(steps.length > 0 && traversal) { - const step: string = steps.shift()!; - traversal = traversal._child(step); - } - - return traversal; - } - - // Handles one code unit at a time. - private _child(char: string): Traversal | undefined { - const root = this.root; - const totalWeight = this.totalWeight; - const nextPrefix = this.prefix + char; - - if(root.type == 'internal') { - let childNode = root.children[char]; - if(!childNode) { - return undefined; - } - - return new Traversal(childNode, nextPrefix, totalWeight); - } else { - // root.type == 'leaf'; - const legalChildren = root.entries.filter(function(entry) { - return entry.key.indexOf(nextPrefix) == 0; - }); - - if(!legalChildren.length) { - return undefined; - } - - return new Traversal(root, nextPrefix, totalWeight); - } - } - - *children(): Generator<{char: string, traversal: () => Traversal}> { - let root = this.root; - - // We refer to the field multiple times in this method, and it doesn't change. - // This also assists minification a bit, since we can't minify when re-accessing - // through `this.`. - const totalWeight = this.totalWeight; - - if(root.type == 'internal') { - for(let entry of root.values) { - let entryNode = root.children[entry]; - - // UTF-16 astral plane check. - if(isHighSurrogate(entry)) { - // First code unit of a UTF-16 code point. - // For now, we'll just assume the second always completes such a char. - // - // Note: Things get nasty here if this is only sometimes true; in the future, - // we should compile-time enforce that this assumption is always true if possible. - if(entryNode.type == 'internal') { - let internalNode = entryNode; - for(let lowSurrogate of internalNode.values) { - let prefix = this.prefix + entry + lowSurrogate; - yield { - char: entry + lowSurrogate, - traversal: function() { return new Traversal(internalNode.children[lowSurrogate], prefix, totalWeight) } - } - } - } else { - // Determine how much of the 'leaf' entry has no Trie nodes, emulate them. - let fullText = entryNode.entries[0].key; - entry = entry + fullText[this.prefix.length + 1]; // The other half of the non-BMP char. - let prefix = this.prefix + entry; - - yield { - char: entry, - traversal: function () {return new Traversal(entryNode, prefix, totalWeight)} - } - } - } else if(isSentinel(entry)) { - continue; - } else if(!entry) { - // Prevent any accidental 'null' or 'undefined' entries from having an effect. - continue; - } else { - let prefix = this.prefix + entry; - yield { - char: entry, - traversal: function() { return new Traversal(entryNode, prefix, totalWeight)} - } - } - } - - return; - } else { // type == 'leaf' - let prefix = this.prefix; - - let children = root.entries.filter(function(entry) { - return entry.key != prefix && prefix.length < entry.key.length; - }) - - for(let {key} of children) { - let nodeKey = key[prefix.length]; - - if(isHighSurrogate(nodeKey)) { - // Merge the other half of an SMP char in! - nodeKey = nodeKey + key[prefix.length+1]; - } - yield { - char: nodeKey, - traversal: function() { return new Traversal(root, prefix + nodeKey, totalWeight)} - } - }; - return; - } - } - - get entries() { - const entryMapper = (value: Entry) => { - return { - text: value.content, - p: value.weight / this.totalWeight - } - } - - if(this.root.type == 'leaf') { - let prefix = this.prefix; - let matches = this.root.entries.filter(function(entry) { - return entry.key == prefix; - }); - - return matches.map(entryMapper); - } else { - let matchingLeaf = this.root.children[SENTINEL_CODE_UNIT]; - if(matchingLeaf && matchingLeaf.type == 'leaf') { - return matchingLeaf.entries.map(entryMapper); - } else { - return []; - } - } - } - - get p(): number { - return this.root.weight / this.totalWeight; - } -} - /** * @class TrieModel * @@ -269,267 +80,30 @@ export class Traversal implements LexiconTraversal { * prefix searches within words, however they are not very good * at predicting the next word. */ -export default class TrieModel implements LexicalModel { +export default class TrieModel extends TraversalModel implements LexicalModel { configuration?: Configuration; - private _trie: Trie; - readonly breakWords: WordBreakingFunction; + public readonly trie: Trie; readonly punctuation?: LexicalModelPunctuation; readonly languageUsesCasing?: boolean; readonly applyCasing?: CasingFunction; constructor(trieData: {root: Node, totalWeight: number}, options: TrieModelOptions = {}) { - this.languageUsesCasing = options.languageUsesCasing; - this.applyCasing = options.applyCasing; - - this._trie = new Trie( + const trie = new Trie( trieData['root'], trieData['totalWeight'], options.searchTermToKey as Wordform2Key || defaultSearchTermToKey ); - this.breakWords = options.wordBreaker || defaultWordBreaker; - this.punctuation = options.punctuation; + + super(trie.traverseFromRoot(), options); + this.trie = trie; } - configure(capabilities: Capabilities): Configuration { - return this.configuration = { - leftContextCodePoints: capabilities.maxLeftContextCodePoints, - rightContextCodePoints: capabilities.maxRightContextCodePoints ?? 0 - }; - } - - toKey(text: string): string { - return this._trie.toKey(text); - } - - predict(transform: Transform, context: Context): Distribution { - const makeDistribution = function(suggestions: WithOutcome[]): Distribution { - const distribution: Distribution = []; - - for (const s of suggestions) { - distribution.push({ sample: s, p: s.p }); - } - - return distribution; - } - - // Special-case the empty buffer/transform: return the top suggestions. - if (!transform.insert && !context.left && !context.right && context.startOfBuffer && context.endOfBuffer) { - return makeDistribution(this._trie.firstN(MAX_SUGGESTIONS).map(({text, p}) => ({ - transform: { - insert: text, - deleteLeft: 0 - }, - displayAs: text, - p: p - }))); - } - - // Compute the results of the keystroke: - const newContext = applyTransform(transform, context); - - // Computes the different in word length after applying the transform above. - const leftDelOffset = transform.deleteLeft - KMWString.length(transform.insert); - - // All text to the left of the cursor INCLUDING anything that has - // just been typed. - const prefix = getLastPreCaretToken(this.breakWords, newContext); - - // Return suggestions from the trie. - return makeDistribution(this._trie.lookup(prefix).map(({text, p}) => - transformToSuggestion({ - insert: text, - // Delete whatever the prefix that the user wrote. - deleteLeft: leftDelOffset + KMWString.length(prefix) - // Note: a separate capitalization/orthography engine can take this - // result and transform it as needed. - }, - p - ))); - } - - get wordbreaker(): WordBreakingFunction { - return this.breakWords; - } - - public traverseFromRoot(): Traversal { - return this._trie.traverseFromRoot(); + public traverseFromRoot(): TrieTraversal { + return super.traverseFromRoot() as TrieTraversal; } }; -///////////////////////////////////////////////////////////////////////////////// -// What remains in this file is the trie implementation proper. Note: to // -// reduce bundle size, any functions/methods related to creating the trie have // -// been removed. // -///////////////////////////////////////////////////////////////////////////////// - -/** - * An **opaque** type for a string that is exclusively used as a search key in - * the trie. There should be a function that converts arbitrary strings - * (queries) and converts them into a standard search key for a given language - * model. - * - * Fun fact: This opaque type has ALREADY saved my bacon and found a bug! - */ -type SearchKey = string & { _: 'SearchKey'}; - -/** - * The priority queue will always pop the most probable item - be it a Traversal - * state or a lexical entry reached via Traversal. - */ -type TraversableWithProb = TextWithProbability | LexiconTraversal; - -/** - * A function that converts a string (word form or query) into a search key - * (secretly, this is also a string). - */ -interface Wordform2Key { - (wordform: string): SearchKey; -} - -// The following trie implementation has been (heavily) derived from trie-ing -// by Conrad Irwin. -// trie-ing is copyright (C) 2015–2017 Conrad Irwin. -// Distributed under the terms of the MIT license: -// https://github.com/ConradIrwin/trie-ing/blob/df55d7af7068d357829db9e0a7faa8a38add1d1d/LICENSE - -type Node = InternalNode | Leaf; -/** - * An internal node in the trie. Internal nodes NEVER contain entries; if an - * internal node should contain an entry, then it has a dummy leaf node (see - * below), that can be accessed by node.children["\uFDD0"]. - */ -interface InternalNode { - type: 'internal'; - weight: number; - /** Maintains the keys of children in descending order of weight. */ - values: string[]; // TODO: As an optimization, "values" can be a single string! - /** - * Maps a single UTF-16 code unit to a child node in the trie. This child - * node may be a leaf or an internal node. The keys of this object are kept - * in sorted order in the .values array. - */ - children: { [codeunit: string]: Node }; -} -/** Only leaf nodes actually contain entries (i.e., the words proper). */ -interface Leaf { - type: 'leaf'; - weight: number; - entries: Entry[]; -} - -/** - * An entry in the prefix trie (stored in leaf nodes exclusively!) - */ -interface Entry { - /** The actual word form, stored in the trie. */ - content: string; - /** A search key that usually simplifies the word form, for ease of search. */ - key: SearchKey; - weight: number; -} - -/** - * Wrapper class for the trie and its nodes. - */ -class Trie { - public readonly root: Node; - /** The total weight of the entire trie. */ - readonly totalWeight: number; - /** - * Converts arbitrary strings to a search key. The trie is built up of - * search keys; not each entry's word form! - */ - toKey: Wordform2Key; - - constructor(root: Node, totalWeight: number, wordform2key: Wordform2Key) { - this.root = root; - this.toKey = wordform2key; - this.totalWeight = totalWeight; - } - - public traverseFromRoot(): Traversal { - return new Traversal(this.root, '', this.totalWeight); - } - - /** - * Lookups an arbitrary prefix (a query) in the trie. Returns the top 3 - * results in sorted order. - * - * @param prefix - */ - lookup(prefix: string): TextWithProbability[] { - const searchKey = this.toKey(prefix); - const rootTraversal = this.traverseFromRoot().child(searchKey); - - if(!rootTraversal) { - return []; - } - - const directEntries = rootTraversal.entries; - // `Set` requires Chrome 38+, which is more recent than Chrome 35. - const directSet: Record = {}; - for(const entry of directEntries) { - directSet[entry.text] = entry.text; - } - - const bestEntries = getSortedResults(rootTraversal); - const deduplicated = bestEntries.filter((entry) => !directSet[entry.text]); - - // Any entries directly hosted on the current node should get full display - // priority over anything from its descendants. - return directEntries.concat(deduplicated); - } - - /** - * Returns the top N suggestions from the trie. - * @param n How many suggestions, maximum, to return. - */ - firstN(n: number): TextWithProbability[] { - return getSortedResults(this.traverseFromRoot(), n); - } -} - -/** - * Returns all entries matching the given prefix, in descending order of - * weight. - * - * @param prefix the prefix to match. - * @param results the current results - * @param queue - */ -function getSortedResults(traversal: LexiconTraversal, limit = MAX_SUGGESTIONS): TextWithProbability[] { - let queue = new PriorityQueue(function(a: TraversableWithProb, b: TraversableWithProb) { - // In case of Trie compilation issues that emit `null` or `undefined` - return (b ? b.p : 0) - (a ? a.p : 0); - }); - let results: TextWithProbability[] = []; - - queue.enqueue(traversal); - - while(queue.count > 0) { - const entry = queue.dequeue(); - - if((entry as TextWithProbability)!.text !== undefined) { - const lexicalEntry = entry as TextWithProbability; - results.push(lexicalEntry); - if(results.length >= limit) { - return results; - } - } else { - const traversal = entry as LexiconTraversal; - queue.enqueueAll(traversal.entries); - let children: LexiconTraversal[] = [] - for(let child of traversal.children()) { - children.push(child.traversal()); - } - queue.enqueueAll(children); - } - } - - return results; -} - /** * Converts wordforms into an indexable form. It does this by * normalizing into NFD, removing diacritics, and then converting diff --git a/web/src/engine/predictive-text/templates/src/trie.ts b/web/src/engine/predictive-text/templates/src/trie.ts new file mode 100644 index 0000000000..6c5be6c3c1 --- /dev/null +++ b/web/src/engine/predictive-text/templates/src/trie.ts @@ -0,0 +1,308 @@ +import { isHighSurrogate, isSentinel, SearchKey, SENTINEL_CODE_UNIT, Wordform2Key } from "./common.js"; +import { LexicalModelTypes } from "@keymanapp/common-types"; +import LexiconTraversal = LexicalModelTypes.LexiconTraversal; + +// The following trie implementation has been (heavily) derived from trie-ing +// by Conrad Irwin. +// trie-ing is copyright (C) 2015–2017 Conrad Irwin. +// Distributed under the terms of the MIT license: +// https://github.com/ConradIrwin/trie-ing/blob/df55d7af7068d357829db9e0a7faa8a38add1d1d/LICENSE + +export type Node = InternalNode | Leaf; +/** + * An internal node in the trie. Internal nodes NEVER contain entries; if an + * internal node should contain an entry, then it has a dummy leaf node (see + * below), that can be accessed by node.children["\uFDD0"]. + */ +export interface InternalNode { + type: 'internal'; + weight: number; + /** Maintains the keys of children in descending order of weight. */ + values: string[]; // TODO: As an optimization, "values" can be a single string! + /** + * Maps a single UTF-16 code unit to a child node in the trie. This child + * node may be a leaf or an internal node. The keys of this object are kept + * in sorted order in the .values array. + */ + children: { [codeunit: string]: Node }; + + /** + * Used during compilation. + */ + unsorted?: boolean; +} +/** Only leaf nodes actually contain entries (i.e., the words proper). */ +export interface Leaf { + type: 'leaf'; + weight: number; + entries: Entry[]; + + /** + * Used during compilation. + */ + unsorted?: boolean; +} + +/** + * An entry in the prefix trie (stored in leaf nodes exclusively!) + */ +export interface Entry { + /** The actual word form, stored in the trie. */ + content: string; + /** A search key that usually simplifies the word form, for ease of search. */ + key: SearchKey; + weight: number; +} + +/** + * Recursively sort the trie, in descending order of weight. + * @param node any node in the trie + */ +export function sortNode(node: Node, onlyLocal?: boolean) { + if (node.type === 'leaf') { + if (!node.unsorted) { + return; + } + + node.entries.sort(function (a, b) { return b.weight - a.weight; }); + } else { + if(!onlyLocal) { + // We recurse and sort children before returning if sorting the full Trie. + for (let char of node.values) { + sortNode(node.children[char], onlyLocal); + } + } + + if (!node.unsorted) { + return; + } + + node.values.sort((a, b) => { + return node.children[b].weight - node.children[a].weight; + }); + } + + delete node.unsorted; +} + +export class TrieTraversal implements LexiconTraversal { + /** + * The lexical prefix corresponding to the current traversal state. + */ + readonly prefix: string; + + /** + * The current traversal node. Serves as the 'root' of its own sub-Trie, + * and we cannot navigate back to its parent. + */ + readonly root: Node; + + /** + * The max weight for the Trie being 'traversed'. Needed for probability + * calculations. + */ + readonly totalWeight: number; + + constructor(root: Node, prefix: string, totalWeight: number) { + this.root = root; + this.prefix = prefix; + this.totalWeight = totalWeight; + } + + child(char: string): LexiconTraversal | undefined { + // May result for blank tokens resulting immediately after whitespace. + if(char == '') { + return this; + } + + // Split into individual code units. + let steps = char.split(''); + let traversal: ReturnType = this; + + while(steps.length > 0 && traversal) { + const step: string = steps.shift()!; + traversal = traversal._child(step); + } + + return traversal; + } + + // Handles one code unit at a time. + private _child(char: string): TrieTraversal | undefined { + const root = this.root; + const totalWeight = this.totalWeight; + const nextPrefix = this.prefix + char; + + // Sorts _just_ the current level, and only if needed. + // We only care about sorting parts that we're actually accessing. + sortNode(root, true); + + if(root.type == 'internal') { + let childNode = root.children[char]; + if(!childNode) { + return undefined; + } + + return new TrieTraversal(childNode, nextPrefix, totalWeight); + } else { + // root.type == 'leaf'; + const legalChildren = root.entries.filter(function(entry) { + return entry.key.indexOf(nextPrefix) == 0; + }); + + if(!legalChildren.length) { + return undefined; + } + + return new TrieTraversal(root, nextPrefix, totalWeight); + } + } + + children(): {char: string, p: number, traversal: () => LexiconTraversal}[] { + let root = this.root; + + // We refer to the field multiple times in this method, and it doesn't change. + // This also assists minification a bit, since we can't minify when re-accessing + // through `this.`. + const totalWeight = this.totalWeight; + + const results: {char: string, p: number, traversal: () => LexiconTraversal}[] = []; + + // Sorts _just_ the current level, and only if needed. + // We only care about sorting parts that we're actually accessing. + sortNode(root, true); + + if(root.type == 'internal') { + for(let entry of root.values) { + let entryNode = root.children[entry]; + + // UTF-16 astral plane check. + if(isHighSurrogate(entry)) { + // First code unit of a UTF-16 code point. + // For now, we'll just assume the second always completes such a char. + // + // Note: Things get nasty here if this is only sometimes true; in the future, + // we should compile-time enforce that this assumption is always true if possible. + if(entryNode.type == 'internal') { + let internalNode = entryNode; + for(let lowSurrogate of internalNode.values) { + let prefix = this.prefix + entry + lowSurrogate; + results.push({ + char: entry + lowSurrogate, + p: internalNode.children[lowSurrogate].weight / totalWeight, + traversal: function() { return new TrieTraversal(internalNode.children[lowSurrogate], prefix, totalWeight) } + }); + } + } else { + // No deduplication needed. If there were multiple keys available, + // we wouldn't be in a leaf node. + let fullText = entryNode.entries[0].key; + entry = entry + fullText[this.prefix.length + 1]; // The other half of the non-BMP char. + let prefix = this.prefix + entry; + + const weight = entryNode.entries.reduce((best, current) => Math.max(best, current.weight), 0); + + results.push({ + char: entry, + p: weight / totalWeight, + traversal: function () {return new TrieTraversal(entryNode, prefix, totalWeight)} + }); + } + } else if(isSentinel(entry)) { + continue; + } else if(!entry) { + // Prevent any accidental 'null' or 'undefined' entries from having an effect. + continue; + } else { + let prefix = this.prefix + entry; + results.push({ + char: entry, + p: entryNode.weight / totalWeight, + traversal: function() { return new TrieTraversal(entryNode, prefix, totalWeight)} + }); + } + } + + return results; + } else { // type == 'leaf' + let prefix = this.prefix; + + // No actual deduplication needed. If there were multiple keys available, + // we wouldn't be in a leaf node. + let children = root.entries.filter(function(entry) { + return entry.key != prefix && prefix.length < entry.key.length; + }); + + let weight = children.reduce((best, current) => Math.max(current.weight, best), 0); + + if(children.length > 0) { + const key = children[0].content; + let nodeKey = key[prefix.length]; + + if(isHighSurrogate(nodeKey)) { + // Merge the other half of an SMP char in! + nodeKey = nodeKey + key[prefix.length+1]; + } + results.push({ + char: nodeKey, + p: weight / totalWeight, + traversal: function() { return new TrieTraversal(root, prefix + nodeKey, totalWeight)} + }); + }; + return results; + } + } + + get entries() { + const entryMapper = (value: Entry) => { + return { + text: value.content, + p: value.weight / this.totalWeight + } + } + + if(this.root.type == 'leaf') { + let prefix = this.prefix; + let matches = this.root.entries.filter(function(entry) { + return entry.key == prefix; + }); + + return matches.map(entryMapper); + } else { + let matchingLeaf = this.root.children[SENTINEL_CODE_UNIT]; + if(matchingLeaf && matchingLeaf.type == 'leaf') { + return matchingLeaf.entries.map(entryMapper); + } else { + return []; + } + } + } + + get p(): number { + return this.root.weight / this.totalWeight; + } +} + +/** + * Wrapper class for the trie and its nodes. + */ +export class Trie { + protected root: Node; + /** The total weight of the entire trie. */ + readonly totalWeight: number; + /** + * Converts arbitrary strings to a search key. The trie is built up of + * search keys; not each entry's word form! + */ + toKey: Wordform2Key; + + constructor(root: Node, totalWeight: number, wordform2key: Wordform2Key) { + this.root = root; + this.toKey = wordform2key; + this.totalWeight = totalWeight; + } + + public traverseFromRoot(): LexiconTraversal { + return new TrieTraversal(this.root, '', this.totalWeight); + } +} \ No newline at end of file diff --git a/web/src/engine/predictive-text/worker-main/README.md b/web/src/engine/predictive-text/worker-main/README.md index b44aba5016..0271650580 100644 --- a/web/src/engine/predictive-text/worker-main/README.md +++ b/web/src/engine/predictive-text/worker-main/README.md @@ -17,11 +17,13 @@ Build Run `build.sh`. This will also automatically install dependencies with `npm`. ```sh -./build.sh +./build.sh configure build ``` ### Two-stage compilation process +TODO: this is well and truly out of date... + Since the primary LMLayer code runs within a [Web Worker][], `build.sh` compiles the LMLayer in two stages: @@ -43,19 +45,7 @@ This will run both headless unit tests, and in-browser unit tests and integratio tests: ```sh -./build.sh -test +./build.sh test ``` -### Test-Driven Development - -I like to use [entr]() to automatically build and re-run the unit tests anytime I -change a source code file. Here's the command I run in separate window: - -```sh -git ls-files | entr -c ./build.sh -tdd -``` - -Importantly, `./build.sh -tdd` skips running the in-browser tests, and skips -downloading/updating `npm` dependencies. - -[entr]: http://eradman.com/entrproject/ +### TODO: epic/user-dict \ No newline at end of file diff --git a/web/src/engine/predictive-text/worker-thread/src/main/models/composited-traversal.ts b/web/src/engine/predictive-text/worker-thread/src/main/models/composited-traversal.ts new file mode 100644 index 0000000000..e743bbb638 --- /dev/null +++ b/web/src/engine/predictive-text/worker-thread/src/main/models/composited-traversal.ts @@ -0,0 +1,262 @@ +import { LexicalModelTypes } from "@keymanapp/common-types"; +import Distribution = LexicalModelTypes.Distribution; +import LexiconTraversal = LexicalModelTypes.LexiconTraversal; +import TextWithProbability = LexicalModelTypes.TextWithProbability; +import { PriorityQueue } from "@keymanapp/web-utils"; + +type ChildEdge = { char: string; p: number, traversal: () => LexiconTraversal }; + +export class CompositedTraversal implements LexiconTraversal { + private readonly componentTraversals: Distribution; + + // Used both internally and externally - and we don't want to re-weight internal calls! + constructor(traversalsWithWeighting: Distribution) { + // let totalP = traversalsWithWeighting.reduce((accum, current) => accum + current.p, 0); + this.componentTraversals = traversalsWithWeighting.map((entry) => { + // Shallow-copy the traversals while normalizing the associated weightings. + // The sum of all `p` values should sum to 1. + return { + sample: entry.sample, + p: entry.p // totalP + }; + }); + } + + children(): ChildEdge[] { + // Easy case: when there's no source on the current Traversal path, there + // are no children. + // + // Though... we shouldn't even reach this point; if a child path lacks + // support; `undefined` should have been returned instead of a class + // instance. + + /* c8 ignore start */ + if(this.componentTraversals.length == 0) { + return []; + } + /* c8 ignore stop */ + + // If there were no duplicated children, merging the arrays returned from the + // following statement, as is, would be the correct answer. + const unblendedComponents = this.componentTraversals.map((component) => { + const weight = component.p; + return { + sample: component.sample.children().map((entry) => { + return { + char: entry.char, + p: entry.p, + traversal: () => entry.traversal() + }; + }), + // Make sure to track the model-source's weighting! + weight: weight + }; + }); + + // If there's only a single active component, well, there actually are + // no duplicates - thus it is the right answer. + if(unblendedComponents.length == 1) { + const singleSource = unblendedComponents[0]; + return singleSource.sample.map((child) => { + return { + char: child.char, + p: singleSource.weight * child.p, + traversal: () => new CompositedTraversal([{ sample: child.traversal(), p: singleSource.weight }]) + }; + }); + } + + // Now, the tricky part: actual blending. + + // Find sets of traversals that traverse the same char path after the represented prefix. + const componentBuckets: Record = {}; + unblendedComponents.forEach((componentChildren) => { + componentChildren.sample.forEach((entry) => { + componentBuckets[entry.char] ??= []; + componentBuckets[entry.char].push({ + component: entry, + weight: componentChildren.weight + }); + }); + }); + + // For each char path... + const keys = Object.keys(componentBuckets); + return keys.map((key) => { + const bucket = componentBuckets[key]; + // Build the blended traversal for that path + const traversalFunc = () => { + const traversalsWithWeighting = bucket.map((entry) => { + return { + sample: entry.component.traversal(), + // Apply the model's weighting! + // (This is not the `p` for that model's traversal path.) + p: entry.weight + }; + }); + return new CompositedTraversal(traversalsWithWeighting); + }; + + // If there's only one contributing model, there's no need to worry about duplicates + // affecting the "highest probability" value for the path; no blending needed. + // + // It's thus safe to just return the model's probability for the path. + if(bucket.length == 1) { + return { + char: key, + p: bucket[0].weight * bucket[0].component.p, + traversal: traversalFunc + }; + } else { + // Here, there's a non-zero chance that duplicate entries may exist. + // We thus need to worry about the potential for blending... + // which means we're best off delegating "highest probability" calculation + // to the child, which is the level at which the issue may be resolved. + const traversal = traversalFunc(); + + return { + char: key, + // Use the traversal early to ensure duplicate entries aren't improperly + // neglected due to their probability sources being split. + p: traversal.p, + // We've actually built it already, so just re-use the instance. + traversal: () => traversal + }; + } + }); + } + + child(char: string): LexiconTraversal { + let children = this.componentTraversals.map((traversalTuple) => { + return { + sample: traversalTuple.sample.child(char), + p: traversalTuple.p + }; + }); + + children = children.filter((entry) => !!entry.sample); + + if(children.length == 0) { + return undefined; + } else { + // Will sort out blending on its own. + return new CompositedTraversal(children); + } + } + + get entries(): TextWithProbability[] { + // If there were guaranteed to be no duplicates, .flatMap instead of .map would be fine here. + const entrySets = this.componentTraversals.map((traversalTuple) => { + return traversalTuple.sample.entries.map((entry) => { + const reweightedEntry: TextWithProbability = { + text: entry.text, + p: entry.p * traversalTuple.p + }; + + return reweightedEntry; + }); + }); + + // Combine any duplicated entries. + const resultMap: Record = {}; + entrySets.forEach((set) => { + set.forEach((entry) => { + if(resultMap[entry.text]) { + resultMap[entry.text].p += entry.p; + } else { + resultMap[entry.text] = entry; + } + }); + }); + + // Finalize as array + sort by descending probability + const results = Object.values(resultMap); + results.sort((a, b) => b.p - a.p); + + return results; + } + + get p(): number { + // If it is impossible for a blended traversal path to yield a higher- + // probability entry than the best unblended result, there's no point + // in considering it here. + const bestUnblendedProb = this.componentTraversals + // to 'traversal weighting' * 'traversal node's max probability' + .map((entry) => entry.p * entry.sample.p) + // find the best entry post-weighting + .reduce((best, current) => (current > best) ? current : best, 0); + + // Easy case: 1 component at this level? No blending is possible, + // so just return the best entry found this way. + if(this.componentTraversals.length == 1) { + return bestUnblendedProb; + } + + // Check for traversal children that travel the same path. O(sum of child + // nodes across all components) + // + // If we wanted to try optimizing this... + // - priority queue for each componentTraversal, descending prob order + // - track which sources have contributed while accumulating path probs + // - if best entry from sources not contributed + current accumulation can't + // win, we can stop blending early. + // - further iterations can no longer exceed bestUnblendedProb. + // + // This likely avoids iterating over all paths... but invoking priority + // queues means worst-case O(sum N_i log N_i), which is worse. (With N_i = + // path count per component traversal) + // - constructable "worst case": fully balanced pMaxes? + // - for "good cases"... would the complexity even be worth it? + // - suppose 3 sources + English: worst-case iteration count with current + // setup is 4 * 26 = 104. That's not "terrible"; fairly low to be + // considering big-O optimizations. + // - +1 multiplier due to the following `filter` block. + const childBuckets: Record = {}; + const entryBuckets: Record = {}; + this.componentTraversals.forEach((entry) => { + const weight = entry.p; + const component = entry.sample; + const entries = component.entries; + + component.children().forEach((child) => { + const record = childBuckets[child.char] ??= { pMax: 0, char: child.char }; + record.pMax += weight * child.p; + }); + + entries.forEach((entry) => entryBuckets[entry.text] = (entryBuckets[entry.text] ?? 0) + entry.p * weight); + }); + + // If a path's maximum potential probability does not exceed the best entry, + // it can't affect our answer; skip it. + // Is O(alphabet count) + const potentialBlendedChildren = Object.keys(childBuckets).map((char) => { + const record = childBuckets[char]; + if(record.pMax <= bestUnblendedProb) { + return null; + } else { + return record; + } + }).filter((record) => !!record); + + let bestBlendedEntry = Object.values(entryBuckets).reduce((best, current) => Math.max(best, current), 0); + + // Prep the path-search for a potential winner. + let bestProb = Math.max(bestUnblendedProb, bestBlendedEntry); + // May have zero entries if no blended path can exceed the best non-blended path's probability. + // (Assumption: this is the common case.) + const queue = new PriorityQueue((a, b) => b.pMax - a.pMax, potentialBlendedChildren); + + while(queue.peek()?.pMax > bestProb) { + const entry = queue.dequeue(); + const childPath = this.child(entry.char); + + // Note: this pattern, as-is, does not allow cross-node optimizations + // (such as seeing a better potential here once we've started evaluating + // potentials within the child) + // + // It's a reasonable "first pass" though. + bestProb = Math.max(childPath.p, bestProb); + } + return bestProb; + } +} \ No newline at end of file diff --git a/web/src/engine/predictive-text/worker-thread/src/main/models/index.ts b/web/src/engine/predictive-text/worker-thread/src/main/models/index.ts index 4a6f20da15..6c51a33e0f 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/models/index.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/models/index.ts @@ -1,2 +1,3 @@ export * from '@keymanapp/models-templates'; +export { CompositedTraversal } from './composited-traversal.js'; export { default as DummyModel, DummyOptions } from './dummy-model.js'; diff --git a/web/src/test/auto/headless/engine/predictive-text/templates/trie-builder.tests.ts b/web/src/test/auto/headless/engine/predictive-text/templates/trie-builder.tests.ts new file mode 100644 index 0000000000..f7cadd2d48 --- /dev/null +++ b/web/src/test/auto/headless/engine/predictive-text/templates/trie-builder.tests.ts @@ -0,0 +1,264 @@ +/* + * Unit tests for the Trie prediction model. + */ + +import { assert } from 'chai'; +import { InternalNode, Leaf, SearchKey, SENTINEL_CODE_UNIT, trieConstruction, TrieBuilder } from '@keymanapp/models-templates'; + +describe('trie construction', () => { + it('default root node', () => { + const defaultRoot = trieConstruction.createRootNode() as Leaf; + assert.equal(defaultRoot.type, 'leaf'); + assert.equal(defaultRoot.weight, 0); + assert.sameMembers(defaultRoot.entries, []); + }); + + it('addItemToLeaf', () => { + // `weight` is managed by addUnsorted, not by addItemToLeaf. + // As a result, we don't test for it here. + const defaultRoot = trieConstruction.createRootNode() as Leaf; + trieConstruction.addItemToLeaf(defaultRoot, { + content: 'cafe', + weight: 2, + key: 'cafe' as SearchKey + }); + + assert.equal(defaultRoot.type, 'leaf'); + assert.equal(defaultRoot.entries.length, 1); + + trieConstruction.addItemToLeaf(defaultRoot, { + content: 'café', + weight: 1, + key: 'cafe' as SearchKey + }); + + assert.equal(defaultRoot.type, 'leaf'); + assert.equal(defaultRoot.entries.length, 2); + }); + + it('convertLeafToInternalNode', () => { + const defaultRoot = trieConstruction.createRootNode() as InternalNode; + const cafeEntry = { + content: 'cafe', + weight: 5, + key: 'cafe' as SearchKey + }; + trieConstruction.addItemToLeaf(defaultRoot as unknown as Leaf, cafeEntry); + + defaultRoot.weight = 5; + trieConstruction.convertLeafToInternalNode(defaultRoot as unknown as Leaf, 4); + + assert.equal(defaultRoot.type, 'internal'); + assert.sameMembers(defaultRoot.values, [SENTINEL_CODE_UNIT]); + assert.sameMembers(Object.keys(defaultRoot.children), defaultRoot.values); + assert.equal(defaultRoot.weight, 5); + + const sentinelChild = defaultRoot.children[SENTINEL_CODE_UNIT] as Leaf; + assert.equal(sentinelChild.type, 'leaf'); + assert.equal(sentinelChild.weight, 5); + assert.equal(sentinelChild.entries.length, 1); + // Should be strict-equal too, but we only really care if it's deep-equal. + assert.deepEqual(sentinelChild.entries[0], cafeEntry); + }); + + it('addUnsorted - simple initial entry', () => { + // `weight` is managed by addUnsorted, not by addItemToLeaf. + // As a result, we don't test for it here. + const defaultRoot = trieConstruction.createRootNode() as Leaf; + trieConstruction.addUnsorted(defaultRoot, { + content: 'cafe', + weight: 2, + key: 'cafe' as SearchKey + }, 4); + + assert.equal(defaultRoot.type, 'leaf'); + assert.equal(defaultRoot.weight, 2); + assert.equal(defaultRoot.entries.length, 1); + + // smaller weight, will not override. Is not additive. + trieConstruction.addUnsorted(defaultRoot, { + content: 'café', + weight: 1, + key: 'cafe' as SearchKey + }, 4); + + assert.equal(defaultRoot.type, 'leaf'); + assert.equal(defaultRoot.weight, 2); + assert.equal(defaultRoot.entries.length, 2); + }); + + it('addUnsorted - short word, then longer word with same prefix', () => { + const defaultRoot = trieConstruction.createRootNode(); + trieConstruction.addUnsorted(defaultRoot, { + content: 'cafe', + weight: 5, + key: 'cafe' as SearchKey + }, 4); + + const rootAsLeaf = defaultRoot as Leaf; + + assert.equal(rootAsLeaf.type, 'leaf'); + assert.equal(rootAsLeaf.weight, 5); + assert.equal(rootAsLeaf.entries.length, 1); + + // smaller weight, will not override. Is not additive. + trieConstruction.addUnsorted(defaultRoot, { + content: 'cafeteria', + weight: 3, + key: 'cafeteria' as SearchKey + }, 4); + + const rootAsInternal = defaultRoot as InternalNode; + + assert.equal(rootAsInternal.type, 'internal'); + assert.equal(rootAsInternal.weight, 5); + assert.deepEqual(rootAsInternal.values, [SENTINEL_CODE_UNIT, 't']); + assert.equal((rootAsInternal.children[SENTINEL_CODE_UNIT] as Leaf).weight, 5); + assert.equal((rootAsInternal.children['t'] as Leaf).weight, 3); + }); + + it('addUnsorted', () => { + // Perspective: current root is actually representing `ca`. + const defaultRoot = trieConstruction.createRootNode(); + trieConstruction.addUnsorted(defaultRoot, { + content: 'cafe', + weight: 5, + key: 'cafe' as SearchKey + }, 2); + + // There's only one child, so there's no reason to start making + // internal structure just yet. + assert.equal(defaultRoot.type, 'leaf'); + assert.equal(defaultRoot.weight, 5); + + // Adds a second child; that child has more than one letter not-in-common with the first. + trieConstruction.addUnsorted(defaultRoot, { + content: 'call', + weight: 8, + key: 'call' as SearchKey + }, 2); + + const rootAsInternal = defaultRoot as InternalNode; + + assert.equal(rootAsInternal.type, 'internal'); + assert.equal(rootAsInternal.weight, 8); + assert.sameMembers(rootAsInternal.values, ['f', 'l']); + assert.equal((rootAsInternal.children['f'] as Leaf).weight, 5); + assert.equal((rootAsInternal.children['l'] as Leaf).weight, 8); + }); + + it('addUnsorted - lower frequency prefix', () => { + // Will correspond to 'thin'. + const root = trieConstruction.createRootNode(); + trieConstruction.addUnsorted(root, { + key: 'think' as SearchKey, + content: 'think', + weight: 100 + }, 4); + trieConstruction.addUnsorted(root, { + key: 'thing' as SearchKey, + content: 'thing', + weight: 40 + }, 4); + + const rootAsInternal = root as InternalNode; + assert.equal(rootAsInternal.type, 'internal'); + assert.sameMembers(rootAsInternal.values, ['k', 'g']); + + // Interesting noted behavior: if just 'think' into 'thin', and nothing else... + // it remains a leaf! That doesn't seem... entirely proper. With 'thing', it + // will at least be an 'internal' node already. + trieConstruction.addUnsorted(rootAsInternal, { + key: 'thin' as SearchKey, + content: 'thin', + weight: 20 + }, 4); + + assert.sameMembers(rootAsInternal.values, ['k', 'g', SENTINEL_CODE_UNIT]); + assert.isOk(rootAsInternal.children[SENTINEL_CODE_UNIT]); + assert.equal((rootAsInternal.children[SENTINEL_CODE_UNIT] as Leaf).weight, 20); + }); + + describe('TrieBuilder', () => { + it('standard Trie construction', () => { + const builder = new TrieBuilder((text) => text as SearchKey); + builder.addEntry('caffeine', 2); + builder.addEntry('cafe', 5); + builder.addEntry('calm', 3); + builder.addEntry('calf', 4); + builder.addEntry('call', 6); // total: 20 + builder.addEntry('can', 10); // total: 30 + builder.addEntry('and', 20); // total: 50 + + assert.equal(builder.getTotalWeight(), 50); + + const root = builder.getRoot() as InternalNode; + const aNode = (root.children['c'] as InternalNode).children['a'] as InternalNode; + + // As the nodes were not added in sorted order, they should not currently be ordered. + assert.sameMembers(aNode.values, ['n', 'l', 'f']); + assert.notSameOrderedMembers(aNode.values, ['n', 'l', 'f']); + + const lNode = aNode.children['l'] as InternalNode; + assert.sameMembers(lNode.values, ['l', 'f', 'm']); + assert.notSameOrderedMembers(lNode.values, ['l', 'f', 'm']); + + builder.sort(); + + assert.sameOrderedMembers(aNode.values, ['n', 'l', 'f']); + assert.sameOrderedMembers(lNode.values, ['l', 'f', 'm']); + }); + + // In case of high startup-time for user-dictionary processing, this could allow use + // of a 'partially' processed user wordlist. (We'd prioritize predicting when the + // user is interacting with text, then resume processing once 'idle'.) + it('interspersed construction + lookup', () => { + const builder = new TrieBuilder((text) => text as SearchKey); + builder.addEntry('caffeine', 2); + builder.addEntry('cafe', 5); + builder.addEntry('calm', 3); + builder.addEntry('calf', 4); + builder.addEntry('call', 6); // total: 20 + + // ------------------------------------------ + // Pause construction; actually use the Trie. + + assert.equal(builder.getTotalWeight(), 20); + const root = builder.getRoot() as InternalNode; + const caNode = (root.children['c'] as InternalNode).children['a'] as InternalNode; + + // As the nodes were not added in sorted order, they should not currently be ordered. + assert.sameMembers(caNode.values, ['l', 'f']); + assert.notSameOrderedMembers(caNode.values, ['l', 'f']); + + const cal = builder.traverseFromRoot().child('cal'); + // Actually traversing through the node should auto-sort the entries. + assert.sameOrderedMembers(caNode.values, ['l', 'f']); + // Including the reached node's children. + assert.sameOrderedMembers([...cal.children()].map((entry) => entry.char), ['l', 'f', 'm']); + + const cafNode = caNode.children['f'] as InternalNode; + + // 'caffeine' was added before 'cafe'. + // Parts not 'traversed' should not be unnecessarily sorted. + assert.notSameOrderedMembers(cafNode.values, ['e', 'f']); + + // ------------------- + // Resume construction + + builder.addEntry('can', 10); // total: 30 + builder.addEntry('and', 20); // total: 50 + + assert.equal(builder.getTotalWeight(), 50); + + // As the nodes were not added in sorted order, they should not currently be ordered. + assert.sameMembers(caNode.values, ['n', 'l', 'f']); + // 'n' was added later, thus will be out of sorted order. + assert.notSameOrderedMembers(caNode.values, ['n', 'l', 'f']); + + builder.sort(); + + assert.sameOrderedMembers(caNode.values, ['n', 'l', 'f']); + }); + }); +}); \ No newline at end of file diff --git a/web/src/test/auto/headless/engine/predictive-text/templates/trie-traversal.tests.ts b/web/src/test/auto/headless/engine/predictive-text/templates/trie-traversal.tests.ts index 6c3239f884..7f8947ca16 100644 --- a/web/src/test/auto/headless/engine/predictive-text/templates/trie-traversal.tests.ts +++ b/web/src/test/auto/headless/engine/predictive-text/templates/trie-traversal.tests.ts @@ -175,15 +175,13 @@ describe('Trie traversal abstractions', function() { do { assert.isNotEmpty(leafChildSequence); - let iter = curChild.traversal().children(); - let curr = iter.next(); - curChild = curr.value; + let children = curChild.traversal().children(); + assert.equal(children.length, 1); + curChild = children[0]; // Test generator behavior - there should be one child, then the 'done' state. assert.isDefined(curChild); assert.equal(curChild.char, leafChildSequence[0]); - curr = iter.next(); - assert.isTrue(curr.done); // Prepare for iteration. leafChildSequence.shift(); @@ -276,15 +274,13 @@ describe('Trie traversal abstractions', function() { do { assert.isNotEmpty(leafChildSequence); - let iter = curChild.traversal().children(); - let curr = iter.next(); - curChild = curr.value; + let children = curChild.traversal().children(); + assert.equal(children.length, 1); + curChild = children[0]; // Test generator behavior - there should be one child, then the 'done' state. assert.isDefined(curChild); assert.equal(curChild.char, leafChildSequence[0]); - curr = iter.next(); - assert.isTrue(curr.done); // Prepare for iteration. leafChildSequence.shift(); diff --git a/web/src/test/auto/headless/engine/predictive-text/worker-thread/composited-traversal.ts b/web/src/test/auto/headless/engine/predictive-text/worker-thread/composited-traversal.ts new file mode 100644 index 0000000000..de6a547def --- /dev/null +++ b/web/src/test/auto/headless/engine/predictive-text/worker-thread/composited-traversal.ts @@ -0,0 +1,174 @@ +import { assert } from 'chai'; + +import { SearchKey, TrieBuilder } from '@keymanapp/models-templates'; +import { models } from '@keymanapp/lm-worker/test-index'; + +import CompositedTraversal = models.CompositedTraversal; + +const simpleLexicalModel = (function() { + const builder = new TrieBuilder((str) => str as SearchKey); + + // Sum total: 10. + builder.addEntry('are', 1); + builder.addEntry('and', 2); + + builder.addEntry('not', 1); + + builder.addEntry('the', 3); + builder.addEntry('try', 1); + builder.addEntry('to', 1); + builder.addEntry('tom', 1); + + return builder; +}()); + +const namesModel = (function() { + const builder = new TrieBuilder((str) => str as SearchKey); + + builder.addEntry('amy', 1); + builder.addEntry('bob', 1); + builder.addEntry('mark', 1); + builder.addEntry('tom', 1); + + return builder; +}()); + +const blendedModel = new CompositedTraversal([ + { sample: simpleLexicalModel.traverseFromRoot(), p: 0.5 }, + { sample: namesModel.traverseFromRoot(), p: 0.5 } +]); + +describe('CompositedTraversal', () => { + describe('direct prefix lookup', () => { + it('returns undefined for non-existent paths', () => { + assert.isUndefined(blendedModel.child('tomcat')); + assert.isUndefined(blendedModel.child('tone')); + assert.isUndefined(blendedModel.child('ton')); + assert.isUndefined(blendedModel.child('apple')); + assert.isUndefined(blendedModel.child('aim')); + assert.isUndefined(blendedModel.child('xylophone')); + }); + + it('reweighs entries found in only one source', () => { + const andNode = blendedModel.child('and'); + assert.equal(andNode.p, 0.1); + + assert.equal(andNode.entries.length, 1); + const and = andNode.entries[0]; + assert.equal(and.text, 'and'); + assert.equal(and.p, 0.1); + }); + + it('merges entries found in multiple sources, combining their probability and entries', () => { + const tomNode = blendedModel.child('tom'); + assert.equal(tomNode.p, 0.175); + + assert.equal(tomNode.entries.length, 1); + const tom = tomNode.entries[0]; + assert.equal(tom.text, 'tom'); + assert.equal(tom.p, 0.175); + }); + + it('correctly returns the probability of intermediate child paths', () => { + // simpleLexical: best is 'and', 0.2 + // names: best is 'amy', 0.25 + // Weight of each is equal at 0.5; 0.25 * 0.5 = 0.125. + // Should not combine. + const aNode = blendedModel.child('a'); + assert.equal(aNode.p, 0.125, "probability of paths were merged despite lack of duplicate"); + assert.equal(aNode.entries.length, 0); + + // 'tom' is on both paths, and is highest-frequency entry when merged. + // We want the combined, net probability to show up on its predecessor paths. + const toNode = blendedModel.child('to'); + assert.equal(toNode.p, 0.175, "probability of path with winning duplicate not merged"); + + // simpleLexical - p('to') = 0.1; is then multiplied by model weight of 0.5. + assert.equal(toNode.entries.length, 1); + const to = toNode.entries[0]; + assert.equal(to.text, 'to'); + assert.equal(to.p, 0.05); + }); + }); + + describe('iteration for correction-search', () => { + it('correctly enumerates child paths from root', () => { + const topChildren = blendedModel.children(); + // 'n' from "simpleLexical", 'b' and 'm' from "names" + assert.sameMembers(topChildren.map((child) => child.char), ['a', 'b', 'm', 'n', 't']); + assert.sameDeepMembers(topChildren.map((child) => { + return { + char: child.char, + p: child.p + } + }), [ + { char: 'a', p: .125 }, // highest: 'amy' + { char: 'b', p: .125 }, // highest: 'bob' + { char: 'm', p: .125 }, // highest: 'mark' + { char: 'n', p: .05 }, // highest: 'not' + { char: 't', p: .175 } // highest: 'tom' + ]); + topChildren.forEach((entry) => assert.equal(entry.p, entry.traversal().p)); + }); + + it('correctly enumerates child paths from section supported by a single member', () => { + const topChildren = blendedModel.children(); + const bNode = topChildren.find((entry) => entry.char == 'b').traversal(); + const bChildren = bNode.children(); + assert.sameDeepMembers(bChildren.map((child) => { + return { + char: child.char, + p: child.p + }; + }), [ + { char: 'o', p: 0.125 } + ]); + bChildren.forEach((entry) => assert.equal(entry.p, entry.traversal().p)); + + const boNode = bChildren.find((entry) => entry.char == 'o').traversal(); + const boChildren = boNode.children(); + assert.sameDeepMembers(boChildren.map((child) => { + return { + char: child.char, + p: child.p + }; + }), [ + { char: 'b', p: 0.125 } + ]); + boChildren.forEach((entry) => assert.equal(entry.p, entry.traversal().p)); + }); + + it('correctly enumerates child paths from section supported by multiple members', () => { + const topChildren = blendedModel.children(); + const tNode = topChildren.find((entry) => entry.char == 't').traversal(); + const tChildren = tNode.children(); + + assert.sameMembers(tChildren.map((child) => child.char), ['h', 'o', 'r']); + assert.sameDeepMembers(tChildren.map((child) => { + return { + char: child.char, + p: child.p + } + }), [ + { char: 'h', p: 0.15 }, + { char: 'o', p: 0.175 }, + { char: 'r', p: 0.05 } + ]); + tChildren.forEach((entry) => assert.equal(entry.p, entry.traversal().p)); + + const toNode = tChildren.find((entry) => entry.char == 'o').traversal(); + const toChildren = toNode.children(); + + assert.sameMembers(toChildren.map((child) => child.char), ['m']); + assert.sameDeepMembers(toChildren.map((child) => { + return { + char: child.char, + p: child.p + }; + }), [ + { char: 'm', p: 0.175 } + ]); + toChildren.forEach((entry) => assert.equal(entry.p, entry.traversal().p)); + }); + }); +}); \ No newline at end of file