spiegel-keyman/common/predictive-text/worker/correction/context-tracker.ts

273 lines
No EOL
10 KiB
TypeScript

namespace correction {
export class TrackedContextSuggestion {
suggestion: Suggestion;
tokenWidth: number;
}
export class TrackedContextToken {
raw: string;
transformDistributions: Distribution<Transform>[];
replacements: TrackedContextSuggestion;
activeReplacement: number = -1;
}
export class TrackedContextState {
context: Context;
tokens: TrackedContextToken[];
poppedHead: boolean;
pushedTail: boolean;
// Tracks all search spaces starting at the current token.
// In the lm-layer's current form, this should only ever have one entry.
// Leaves 'design space' for if/when we add support for phrase-level corrections/predictions.
searchSpace: SearchSpace[] = [];
toRawTokenization() {
// TODO: support token replacement (for accepted suggestions).
let sequence: USVString[] = [];
for(let token of this.tokens) {
// Hide any tokens representing wordbreaks. (Thinking ahead to phrase-level possibilities)
if(token.raw !== null) {
sequence.push(token.raw);
}
}
return sequence;
}
}
class CircularArray<Item> {
static readonly DEFAULT_ARRAY_SIZE = 5;
private circle: Item[];
private currentHead: number=0;
private currentTail: number=0;
constructor(size: number = CircularArray.DEFAULT_ARRAY_SIZE) {
this.circle = Array(size);
}
get count(): number {
let diff = this.currentHead - this.currentTail;
if(diff < 0) {
diff = diff + this.circle.length;
}
return diff;
}
get maxCount(): number {
return this.circle.length;
}
enqueue(item: Item): Item {
var prevItem = null;
let nextHead = (this.currentHead + 1) % this.maxCount;
if(nextHead == this.currentTail) {
prevItem = this.circle[this.currentTail];
this.currentTail = (this.currentTail + 1) % this.maxCount;
}
this.circle[this.currentHead] = item;
this.currentHead = nextHead;
return prevItem;
}
dequeue(): Item {
if(this.currentTail == this.currentHead) {
return null;
} else {
let item = this.circle[this.currentTail];
this.currentTail = (this.currentTail + 1) % this.maxCount;
return item;
}
}
item(index: number) {
if(index >= this.count) {
throw "Invalid array index";
}
let mappedIndex = (this.currentTail + index) % this.count;
return this.circle[mappedIndex];
}
}
export class ContextTracker extends CircularArray<TrackedContextState> {
static attemptMatchContext(tokenizedContext: USVString[],
matchState: TrackedContextState,
transformDistribution?: Distribution<Transform>): TrackedContextState {
// Map the previous tokenized state to an edit-distance friendly version.
let matchContext: USVString[] = matchState.toRawTokenization();
// Inverted order, since 'match' existed before our new context.
let mapping = ClassicalDistanceCalculation.computeDistance(matchContext.map(value => ({key: value})),
tokenizedContext.map(value => ({key: value})),
1);
let editPath = mapping.editPath();
let poppedHead = false;
let pushedTail = false;
// First entry: may not be an 'insert' or a 'transpose' op.
if(editPath[0] == 'insert' || editPath[0].indexOf('transpose') >= 0) {
return null;
} else if(editPath[0] == 'delete') {
poppedHead = true; // a token from the previous state has been wholly removed.
}
// Last entry: may not be a 'delete' or a 'transpose' op.
let tailIndex = editPath.length -1;
if(editPath[tailIndex] == 'delete' || editPath[0].indexOf('transpose') >= 0) {
return null;
} else if(editPath[tailIndex] == 'insert') {
pushedTail = true;
}
// Now to check everything in-between: should be exclusively 'match'es.
for(let index = 1; index < editPath.length - 2; index++) {
if(editPath[index] != 'match') {
return null;
}
}
// If we've made it here... success! We have a context match!
let newState = new TrackedContextState();
newState.tokens = Array.from(matchState.tokens);
// Since we're continuing a previously-cached context, we can reuse the same SearchSpace
// to continue making predictions.
newState.searchSpace = matchState.searchSpace;
/* Assumption: This is an adequate check for its two sub-branches.
*
* Basis:
* - Assumption: one keystroke may only cause a single token to rotate out of context.
* - That is, no "reasonable" keystroke would emit enough code points to 'bump' two words simultaneously.
* - ... This one may need to be loosened a bit... but it should be enough for initial correction testing as-is.
* - Assumption: one keystroke may only cause a single token to be appended to the context
* - That is, no "reasonable" keystroke would emit a Transform adding two separate word tokens
* - For languages using whitespace to word-break, said keystroke would have to include said whitespace to break the assumption.
*/
if(editPath.length > 1) {
if(poppedHead) {
newState.tokens.splice(0, 2); // Chop off the first token and its subsequent 'whitespace' token.
}
if(pushedTail) {
let whitespaceToken = new TrackedContextToken();
// ASSUMPTION: any transform that triggers this case is a pure-whitespace Transform.
// Worth note: when invalid, the lm-layer already has problems in other aspects too.
whitespaceToken.transformDistributions = [transformDistribution]; // Track the Transform that resulted in the whitespace 'token'.
// Will be needed for phrase-level correction/prediction.
whitespaceToken.raw = null;
newState.tokens.push(whitespaceToken);
let emptyToken = new TrackedContextToken();
emptyToken.raw = '';
emptyToken.transformDistributions = [];
newState.tokens.push(emptyToken);
} else {
// TODO: Assumption: we didn't 'miss' any inputs somehow.
// As is, may be prone to fragility should the lm-layer's tracked context 'desync' from its host's.
let editedToken = newState.tokens[newState.tokens.length - 1];
if(transformDistribution && transformDistribution.length > 0) {
editedToken.transformDistributions.push(transformDistribution);
}
// Replace old token's raw-text with new token's raw-text.
editedToken.raw = tokenizedContext[tokenizedContext.length - 1];
}
}
return newState;
}
static modelContextState(tokenizedContext: USVString[], traversalRoot: LexiconTraversal): TrackedContextState {
let baseTokens = tokenizedContext.map(function(entry) {
let token = new TrackedContextToken();
token.raw = entry;
if(token.raw) {
let tokenTransform = {
insert: entry,
deleteLeft: 0
};
// Build a single-entry prob-distribution array... where the single distribution is 100% for the token's actual form.
// Basically, assume the token was the correct input, since we lack any actual probability data about the keystrokes
// that generated it.
token.transformDistributions = [[{
sample: tokenTransform,
p: 1.0
}]];
} else {
// Helps model context-final wordbreaks.
token.transformDistributions = [];
}
return token;
});
// And now to add the whitespace.
let finalTokens: TrackedContextToken[] = [];
finalTokens.push(baseTokens.splice(0, 1)[0]);
while(baseTokens.length > 0) {
let whitespaceToken = new TrackedContextToken();
whitespaceToken.transformDistributions = [];
whitespaceToken.raw = null;
finalTokens.push(whitespaceToken);
finalTokens.push(baseTokens.splice(0, 1)[0]);
}
let state = new TrackedContextState();
state.poppedHead = false;
state.pushedTail = false;
state.tokens = finalTokens;
state.searchSpace = [new SearchSpace(traversalRoot)];
return state;
}
/**
* Compares the current, post-input context against the most recently-seen contexts from previous prediction calls, returning
* the most information-rich `TrackedContextState` possible. If a match is found, the state will be annotated with the
* input information provided to previous prediction calls and persisted correction-search calculations for re-use.
*
* @param model
* @param context
* @param mainTransform
* @param transformDistribution
*/
analyzeState(model: LexicalModel,
context: Context,
transformDistribution?: Distribution<Transform>): TrackedContextState {
if(!model.traverseFromRoot) {
// Assumption: LexicalModel provides a valid traverseFromRoot function. (Is technically optional)
// Without it, no 'corrections' may be made; the model can only be used to predict, not correct.
throw "This lexical model does not provide adequate data for correction algorithms and context reuse";
}
let tokenizedContext = model.tokenize(context);
if(tokenizedContext.length > 0) {
for(let i = this.count - 1; i >= 0; i--) {
let resultState = ContextTracker.attemptMatchContext(tokenizedContext, this.item[i], transformDistribution);
if(resultState) {
resultState.context = context;
this.enqueue(resultState);
return resultState;
}
}
}
// Else: either empty OR we've detected a 'new context'. Initialize from scratch; no prior input information is
// available. Only the results of the prior inputs are known.
let state = ContextTracker.modelContextState(tokenizedContext, model.traverseFromRoot());
state.context = context;
this.enqueue(state);
return state;
}
}
}