mirror of
https://github.com/keymanapp/keyman.git
synced 2026-10-17 03:17:33 +00:00
324 lines
No EOL
12 KiB
TypeScript
324 lines
No EOL
12 KiB
TypeScript
/// <reference path="distance-modeler.ts" />
|
|
|
|
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.maxCount;
|
|
return this.circle[mappedIndex];
|
|
}
|
|
}
|
|
|
|
export class ContextTracker extends CircularArray<TrackedContextState> {
|
|
static attemptMatchContext(tokenizedContext: USVString[],
|
|
matchState: TrackedContextState,
|
|
rootTraversal: LexiconTraversal,
|
|
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();
|
|
|
|
// Be sure to deep-copy the tokens! Pointer-aliasing is bad here.
|
|
newState.tokens = matchState.tokens.map(function(token) {
|
|
let copy = new TrackedContextToken();
|
|
copy.raw = token.raw;
|
|
copy.replacements = token.replacements
|
|
copy.activeReplacement = token.activeReplacement;
|
|
copy.transformDistributions = token.transformDistributions;
|
|
|
|
return copy;
|
|
});
|
|
|
|
// Since we're continuing a previously-cached context, we can reuse the same SearchSpace
|
|
// to continue making predictions.
|
|
newState.searchSpace = matchState.searchSpace;
|
|
newState.poppedHead = poppedHead;
|
|
newState.pushedTail = pushedTail;
|
|
|
|
/* 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.
|
|
|
|
// Note: we don't bother 'correcting' whitespace tokens at this time.
|
|
// They don't get any SearchSpace handling.
|
|
|
|
whitespaceToken.raw = null;
|
|
newState.tokens.push(whitespaceToken);
|
|
|
|
let emptyToken = new TrackedContextToken();
|
|
emptyToken.raw = '';
|
|
emptyToken.transformDistributions = [];
|
|
|
|
// For now... new final token => throw out old SearchSpace, use new SearchSpace.
|
|
if(rootTraversal) {
|
|
newState.searchSpace = [new correction.SearchSpace(rootTraversal)];
|
|
}
|
|
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);
|
|
if(newState.searchSpace) {
|
|
newState.searchSpace.forEach(space => space.addInput(transformDistribution));
|
|
}
|
|
}
|
|
// Replace old token's raw-text with new token's raw-text.
|
|
editedToken.raw = tokenizedContext[tokenizedContext.length - 1];
|
|
}
|
|
} 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);
|
|
if(newState.searchSpace) {
|
|
newState.searchSpace.forEach(space => space.addInput(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[] = [];
|
|
|
|
if(baseTokens.length > 0) {
|
|
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;
|
|
if(traversalRoot) {
|
|
state.searchSpace = [new SearchSpace(traversalRoot)];
|
|
if(finalTokens.length > 0) {
|
|
state.tokens[state.tokens.length - 1].transformDistributions.forEach(distrib => state.searchSpace[0].addInput(distrib));
|
|
}
|
|
}
|
|
|
|
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), model.traverseFromRoot(), 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.
|
|
//
|
|
// Assumption: as a caret needs to move to context before any actual transform distributions occur,
|
|
// this state is only reached on caret moves; thus, transformDistribution is actually just a single null transform.
|
|
let state = ContextTracker.modelContextState(tokenizedContext, model.traverseFromRoot());
|
|
state.context = context;
|
|
this.enqueue(state);
|
|
return state;
|
|
}
|
|
}
|
|
} |