Merge pull request #11979 from keymanapp/change/web/full-context-tokenization-tracking

change(web): track whitespace-aware tokenization for context + correction-search caching 📚
This commit is contained in:
Joshua Horton 2024-08-06 09:56:48 +07:00 committed by GitHub
commit 7138778431
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 285 additions and 255 deletions

View file

@ -1,4 +1,4 @@
import { applyTransform } from '@keymanapp/models-templates';
import { Token, applyTransform } from '@keymanapp/models-templates';
import { ClassicalDistanceCalculation } from './classical-calculation.js';
import { SearchSpace } from './distance-modeler.js';
@ -31,6 +31,7 @@ export class TrackedContextSuggestion {
export class TrackedContextToken {
raw: string;
replacementText: string;
isWhitespace?: boolean;
transformDistributions: Distribution<Transform>[] = [];
replacements: TrackedContextSuggestion[];
@ -54,6 +55,30 @@ export class TrackedContextToken {
revert() {
delete this.activeReplacementId;
}
/**
* Used for 14.0's backspace workaround, which flattens all previous Distribution<Transform>
* entries because of limitations with direct use of backspace transforms.
* @param tokenText
* @param transformId
*/
updateWithBackspace(tokenText: USVString, transformId: number) {
// It's a backspace transform; time for special handling!
//
// For now, with 14.0, we simply compress all remaining Transforms for the token into
// multiple single-char transforms. Probabalistically modeling BKSP is quite complex,
// so we simplify by assuming everything remaining after a BKSP is 'true' and 'intended' text.
//
// Note that we cannot just use a single, monolithic transform at this point b/c
// of our current edit-distance optimization strategy; diagonalization is currently...
// not very compatible with that.
let backspacedTokenContext: Distribution<Transform>[] = textToCharTransforms(tokenText, transformId).map(function(transform) {
return [{sample: transform, p: 1.0}];
});
this.raw = tokenText;
this.transformDistributions = backspacedTokenContext;
}
}
export class TrackedContextState {
@ -126,7 +151,7 @@ export class TrackedContextState {
}
popHead() {
this.tokens.splice(0, 2);
this.tokens.splice(0, 1);
this.indexOffset -= 1;
}
@ -144,59 +169,18 @@ export class TrackedContextState {
}
}
pushWhitespaceToTail(transformDistribution: Distribution<Transform> = null) {
let whitespaceToken = new TrackedContextToken();
// Track the Transform that resulted in the whitespace 'token'.
// Will be needed for phrase-level correction/prediction.
whitespaceToken.transformDistributions = transformDistribution ? [transformDistribution] : [];
whitespaceToken.raw = null;
this.tokens.push(whitespaceToken);
}
/**
* Used for 14.0's backspace workaround, which flattens all previous Distribution<Transform>
* entries because of limitations with direct use of backspace transforms.
* @param tokenText
* @param transformId
*/
replaceTailForBackspace(tokenText: USVString, transformId: number) {
this.tokens.pop();
// It's a backspace transform; time for special handling!
//
// For now, with 14.0, we simply compress all remaining Transforms for the token into
// multiple single-char transforms. Probabalistically modeling BKSP is quite complex,
// so we simplify by assuming everything remaining after a BKSP is 'true' and 'intended' text.
//
// Note that we cannot just use a single, monolithic transform at this point b/c
// of our current edit-distance optimization strategy; diagonalization is currently...
// not very compatible with that.
let backspacedTokenContext: Distribution<Transform>[] = textToCharTransforms(tokenText, transformId).map(function(transform) {
return [{sample: transform, p: 1.0}];
});
let compactedToken = new TrackedContextToken();
compactedToken.raw = tokenText;
compactedToken.transformDistributions = backspacedTokenContext;
this.pushTail(compactedToken);
}
updateTail(transformDistribution: Distribution<Transform>, tokenText?: USVString) {
let editedToken = this.tail;
updateToken(token: TrackedContextToken, transformDistribution: Distribution<Transform>, tokenText?: USVString) {
// Preserve existing text if new text isn't specified.
tokenText = tokenText || (tokenText === '' ? '' : editedToken.raw);
tokenText = tokenText || (tokenText === '' ? '' : token.raw);
if(transformDistribution && transformDistribution.length > 0) {
editedToken.transformDistributions.push(transformDistribution);
token.transformDistributions.push(transformDistribution);
if(this.searchSpace) {
this.searchSpace.forEach(space => space.addInput(transformDistribution));
}
}
// Replace old token's raw-text with new token's raw-text.
editedToken.raw = tokenText;
token.raw = tokenText;
}
toRawTokenization() {
@ -295,7 +279,8 @@ class CircularArray<Item> {
*/
item(index: number) {
if(index >= this.count) {
throw "Invalid array index";
// JS arrays return `undefined` for invalid array indices.
return undefined;
}
let mappedIndex = (this.currentTail + index) % this.maxCount;
@ -304,187 +289,179 @@ class CircularArray<Item> {
}
export class ContextTracker extends CircularArray<TrackedContextState> {
static attemptMatchContext(tokenizedContext: USVString[],
matchState: TrackedContextState,
transformDistribution?: Distribution<Transform>): TrackedContextState {
static attemptMatchContext(
tokenizedContext: Token[],
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 mapping = ClassicalDistanceCalculation.computeDistance(
matchContext.map(value => ({key: value})),
tokenizedContext.map(value => ({key: value.text})),
// Must be at least 2, as adding a single whitespace after a token tends
// to add two tokens: one for whitespace, one for the empty token to
// follow it.
3
);
let editPath = mapping.editPath();
let poppedHead = false;
let pushedTail = false;
// When the context has but two tokens, the path algorithm tends to invert
// 'insert' and 'substitute' from our preferred ordering for them.
// Logically, either order makes sense... but logic for other cases is
// far simpler if we have 'substitute' before 'insert'.
if(editPath.length == 2 && editPath[0] == 'insert' && editPath[1] == 'substitute') {
editPath[0] = 'substitute';
editPath[1] = 'insert';
}
// Matters greatly when starting from a nil context.
if(editPath.length > 1) {
// First entry: may not be an 'insert' or a 'transpose' op.
// 'insert' allowed if the next token is 'substitute', as this may occur with an edit path of length 2.
if((editPath[0] == 'insert' && !(editPath[1] == 'substitute' && editPath.length == 2)) || editPath[0].indexOf('transpose') >= 0) {
return null;
} else if(editPath[0] == 'delete') {
poppedHead = true; // a token from the previous state has been wholly removed.
const firstMatch = editPath.indexOf('match');
const lastMatch = editPath.lastIndexOf('match');
// Assertion: for a long context, the bulk of the edit path should be a
// continuous block of 'match' entries. If there's anything else in
// the middle, we have a context mismatch.
if(firstMatch) {
for(let i = firstMatch+1; i < lastMatch; i++) {
if(editPath[i] != 'match') {
return null;
}
}
}
// Last entry: may not be a 'delete' or a 'transpose' op.
let tailIndex = editPath.length -1;
let ignorePenultimateMatch = false;
if(editPath[tailIndex] == 'delete' || editPath[0].indexOf('transpose') >= 0) {
return null;
} else if(editPath[tailIndex] == 'insert') {
pushedTail = true;
} else if(tailIndex > 0 && editPath[tailIndex-1] == 'insert' && editPath[tailIndex] == 'substitute') {
// Tends to happen when accepting suggestions.
pushedTail = true;
ignorePenultimateMatch = true;
// If we have a perfect match with a pre-existing context, no mutations have
// happened; just re-use the old context state.
if(firstMatch == 0 && lastMatch == editPath.length - 1) {
return matchState;
}
// Can happen for the first text input after backspace deletes a wordbreaking character,
// thus the new input continues a previous word while dropping the empty word after
// that prior wordbreaking character.
//
// We can't handle it reliably from this match state, but a previous entry (without the empty token)
// should still be in the cache and will be reliable for this example case.
if(tailIndex > 0 && editPath[tailIndex-1] == 'delete' && editPath[tailIndex] == 'substitute') {
return null;
}
// If mutations HAVE happened, we have work to do.
let state = matchState;
// Now to check everything in-between: should be exclusively 'match'es.
for(let index = 1; index < editPath.length - (ignorePenultimateMatch ? 2 : 1); index++) {
if(editPath[index] != 'match') {
return null;
let priorEdit: typeof editPath[0];
let poppedTokenCount = 0;
for(let i = 0; i < firstMatch; i++) {
switch(editPath[i]) {
case 'delete':
if(priorEdit && priorEdit != 'delete') {
return null;
}
if(state == matchState) {
state = new TrackedContextState(state);
}
state.popHead();
poppedTokenCount++;
break;
case 'substitute':
// There's no major need to drop parts of a token being 'slid' out of the context window.
// We'll leave it intact.
break;
default:
// No 'insert' should exist on the leading edge of context when the
// context window slides.
//
// No 'transform' edits should exist within this section, either.
return null;
}
}
// If we've made it here... success! We have a context match!
let state: TrackedContextState;
if(pushedTail) {
// On suggestion acceptance, we should update the previous final token.
// We do it first so that the acceptance is replicated in the new TrackedContextState
// as well.
if(ignorePenultimateMatch) {
// For this case, we were likely called by ModelCompositor.acceptSuggestion(), which
// would have marked the accepted suggestion.
matchState.tail.replacementText = tokenizedContext[tokenizedContext.length-2];
}
state = new TrackedContextState(matchState);
} else {
// We're continuing a previously-cached context; create a deep-copy of it.
// We can't just re-use the old instance, unfortunately; predictions break
// with multitaps otherwise - we should avoid tracking keystrokes that were
// rewound.
//
// If there are no incoming transforms, though... yeah, re-use is safe then.
state = !!transformDistribution ? new TrackedContextState(matchState) : matchState;
}
const hasDistribution = transformDistribution && Array.isArray(transformDistribution);
let primaryInput = hasDistribution ? transformDistribution[0].sample : null;
if(primaryInput && primaryInput.insert == "" && primaryInput.deleteLeft == 0 && !primaryInput.deleteRight) {
primaryInput = null;
}
const isWhitespace = primaryInput && TransformUtils.isWhitespace(primaryInput);
// TODO: "wordbreak" the `insert` section of the transform (if it exists).
// ... wait, might have to be done at a higher level...
// ... and will probably want its own unit test ...
const isBackspace = primaryInput && TransformUtils.isBackspace(primaryInput);
const finalToken = tokenizedContext[tokenizedContext.length-1];
/* 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.
*/
// Reset priorEdit for the end-of-context updating loop.
priorEdit = undefined;
function maintainLastToken() {
if(isWhitespace && editPath[tailIndex] == 'match') {
/*
We can land here if there are multiple whitespaces in a row.
There's already an implied whitespace to the left, so we conceptually
merge the new whitespace with that one.
*/
return;
} else if(isBackspace) {
// Consider backspace entry for this case?
state.replaceTailForBackspace(finalToken, primaryInput.id);
} else {
state.updateTail(primaryInput ? transformDistribution : null, finalToken);
}
}
// Now to update the end of the context window.
for(let i = lastMatch+1; i < editPath.length; i++) {
const isLastToken = i == editPath.length - 1;
// If there is/was more than one context token available...
if(editPath.length > 1) {
// We're removing a context token, but at least one remains.
if(poppedHead) {
state.popHead();
}
const incomingToken = tokenizedContext[i - poppedTokenCount]
switch(editPath[i]) {
case 'substitute':
if(isLastToken) {
state = new TrackedContextState(state);
}
// We're adding an additional context token.
if(pushedTail) {
const tokenizedTail = tokenizedContext[tokenizedContext.length - 1];
/*
* Common-case: most transforms that trigger this case are from pure-whitespace Transforms. MOST.
*
* Less-common, but noteworthy: some wordbreaks may occur without whitespace. Example:
* `"o` => ['"', 'o']. Make sure to double-check against `tokenizedContext`!
*/
let pushedToken = new TrackedContextToken();
pushedToken.raw = tokenizedTail;
const token = state.tokens[i - poppedTokenCount];
const matchToken = matchState.tokens[i];
if(isWhitespace || !primaryInput) {
state.pushWhitespaceToTail(transformDistribution ?? []);
// Continuing the earlier assumption, that 'pure-whitespace Transform' does not emit any initial characters
// for the new word (token), so the input keystrokes do not correspond to the new text token.
pushedToken.transformDistributions = [];
} else {
state.pushWhitespaceToTail();
// Assumption: Since we only allow one-transform-at-a-time changes between states, we shouldn't be missing
// any metadata used to construct the new context state token.
if(isBackspace) {
token.updateWithBackspace(incomingToken.text, primaryInput.id);
} else {
state.updateToken(token, transformDistribution, incomingToken.text);
}
// For this case, we were _likely_ called by
// ModelCompositor.acceptSuggestion(), which would have marked the
// accepted suggestion.
//
// Upon inspection, this doesn't seem entirely ideal. It works for
// the common case, but not for specially crafted keystroke
// transforms. That said, it's also very low impact. Best as I can
// see, this is only really used for debugging info?
if(state != matchState && !isLastToken) {
matchToken.replacementText = incomingToken.text;
}
break;
case 'insert':
if(priorEdit && priorEdit != 'substitute' && priorEdit != 'match') {
return null;
}
if(state == matchState) {
state = new TrackedContextState(state);
}
let pushedToken = new TrackedContextToken();
pushedToken.raw = incomingToken.text;
// TODO: May need something more complicated if the keystroke's
// transform triggers a wordbreak _within_ its boundaries (rather than
// on an edge). (Probably some way to map the tokenization to the indices
// within `insert`.)
pushedToken.transformDistributions = transformDistribution ? [transformDistribution] : [];
}
pushedToken.isWhitespace = incomingToken.isWhitespace;
state.pushTail(pushedToken);
} else {
// We're editing the final context token.
// 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.
maintainLastToken();
}
// There is only one word in the context.
} 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.
if(editPath[tailIndex] == 'insert') {
// Construct appropriate initial token.
let token = new TrackedContextToken();
token.raw = tokenizedContext[0];
token.transformDistributions = [transformDistribution];
state.pushTail(token);
} else {
// Edit the lone context token.
maintainLastToken();
state.pushTail(pushedToken);
break;
default:
// No 'delete' should exist on the trailing edge of context when the
// context window slides. While it can happen due to keystrokes with
// `deleteLeft`, we keep a cache of recent contexts - an older one will
// likely match sufficiently.
// - may see 'delete' followed by 'substitute' in such cases.
//
// No 'transform' edits should exist within this section, either.
return null;
}
}
return state;
}
private static modelContextState(tokenizedContext: USVString[],
transformDistribution: Distribution<Transform>,
lexicalModel: LexicalModel): TrackedContextState {
private static modelContextState(
tokenizedContext: {text: USVString, isWhitespace?: boolean}[],
lexicalModel: LexicalModel
): TrackedContextState {
let baseTokens = tokenizedContext.map(function(entry) {
let token = new TrackedContextToken();
token.raw = entry;
token.raw = entry.text;
if(entry.isWhitespace) {
token.isWhitespace = true;
}
if(token.raw) {
token.transformDistributions = textToCharTransforms(token.raw).map(function(transform) {
return [{sample: transform, p: 1.0}];
@ -504,7 +481,6 @@ export class ContextTracker extends CircularArray<TrackedContextState> {
}
while(baseTokens.length > 0) {
state.pushWhitespaceToTail();
state.pushTail(baseTokens.splice(0, 1)[0]);
}
@ -527,21 +503,32 @@ export class ContextTracker extends CircularArray<TrackedContextState> {
* @param context
* @param transformDistribution
*/
analyzeState(model: LexicalModel,
context: Context,
transformDistribution?: Distribution<Transform>): TrackedContextState {
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";
}
const inputTransform = transformDistribution?.[0];
if(inputTransform) {
context = applyTransform(inputTransform.sample, context);
}
const tokenize = determineModelTokenizer(model);
const tokenizedContext = tokenize(context);
if(tokenizedContext.left.length > 0) {
for(let i = this.count - 1; i >= 0; i--) {
const priorMatchState = this.item(i);
// Skip intermediate multitap-produced contexts.
// When multitapping, we skip all contexts from prior taps within the same interaction,
// but not any contexts from before the multitap started.
const priorTaggedContext = priorMatchState.taggedContext;
if(priorTaggedContext && transformDistribution && transformDistribution.length > 0) {
// Using the potential `matchState` + the incoming transform, do the results line up for
@ -584,7 +571,7 @@ export class ContextTracker extends CircularArray<TrackedContextState> {
//
// 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.left, transformDistribution, model);
let state = ContextTracker.modelContextState(tokenizedContext.left, model);
state.taggedContext = context;
this.enqueue(state);
return state;

View file

@ -216,11 +216,8 @@ export class ModelCompositor {
let postContextTokenization = this.tokenize(postContext);
if(postContextTokenization) {
// Handles display string for reversions triggered by accepting a suggestion mid-token.
if(postContextTokenization.left.length > 0) {
revertedPrefix = postContextTokenization.left[postContextTokenization.left.length-1];
} else {
revertedPrefix = '';
}
const preCaretToken = postContextTokenization.left[postContextTokenization.left.length - 1];
revertedPrefix = (preCaretToken && !preCaretToken.isWhitespace) ? preCaretToken.text : '';
revertedPrefix += postContextTokenization.caretSplitsToken ? postContextTokenization.right[0] : '';
} else {
revertedPrefix = this.wordbreak(postContext);

View file

@ -62,13 +62,7 @@ export function determineModelWordbreaker(model: LexicalModel): (context: Contex
export function determineModelTokenizer(model: LexicalModel) {
return (context: Context) => {
if(model.wordbreaker) {
const fullTokenization = models.tokenize(model.wordbreaker, context);
return {
left: fullTokenization.left .filter((entry) => !entry.isWhitespace).map((entry) => entry.text),
right: fullTokenization.right.filter((entry) => !entry.isWhitespace).map((entry) => entry.text),
caretSplitsToken: fullTokenization.caretSplitsToken
}
return models.tokenize(model.wordbreaker, context);
} else {
return null;
}

View file

@ -162,14 +162,19 @@ export async function correctAndEnumerate(
// facilitates a more thorough correction-search pattern.
// Token replacement benefits greatly from knowledge of the prior context state.
let contextState = contextTracker.analyzeState(lexicalModel, context, null);
let contextState = contextTracker.analyzeState(
lexicalModel,
context,
null
);
// Corrections and predictions are based upon the post-context state, though.
postContextState = contextTracker.analyzeState( lexicalModel,
postContext,
!TransformUtils.isEmpty(inputTransform)
? transformDistribution
: null
);
postContextState = contextTracker.analyzeState(
lexicalModel,
context,
!TransformUtils.isEmpty(inputTransform)
? transformDistribution
: null
);
// TODO: Should we filter backspaces & whitespaces out of the transform distribution?
// Ideally, the answer (in the future) will be no, but leaving it in right now may pose an issue.

View file

@ -4,6 +4,9 @@ import { ContextTracker } from '#./correction/context-tracker.js';
import ModelCompositor from '#./model-compositor.js';
import * as models from '#./models/index.js';
import { default as defaultBreaker } from '@keymanapp/models-wordbreakers';
import { deepCopy } from '@keymanapp/web-utils';
import { jsonFixture } from '@keymanapp/common-test-resources/model-helpers.mjs';
describe('ContextTracker', function() {
@ -16,49 +19,76 @@ describe('ContextTracker', function() {
describe('attemptMatchContext', function() {
it("properly matches and aligns when lead token is removed", function() {
let existingContext = ["an", "apple", "a", "day", "keeps", "the", "doctor"];
let existingContext = models.tokenize(defaultBreaker, {
left: "an apple a day keeps the doctor"
});
let transform = {
insert: '',
deleteLeft: 0
}
let newContext = existingContext.slice(0);
newContext.splice(0, 1);
let rawTokens = ["apple", null, "a", null, "day", null, "keeps", null, "the", null, "doctor"];
let newContext = deepCopy(existingContext);
newContext.left.splice(0, 1);
let rawTokens = [" ", "apple", " ", "a", " ", "day", " ", "keeps", " ", "the", " ", "doctor"];
let existingState = ContextTracker.modelContextState(existingContext);
let state = ContextTracker.attemptMatchContext(newContext, existingState, null, toWrapperDistribution(transform));
let existingState = ContextTracker.modelContextState(existingContext.left);
let state = ContextTracker.attemptMatchContext(newContext.left, existingState, null, toWrapperDistribution(transform));
assert.isNotNull(state);
assert.deepEqual(state.tokens.map(token => token.raw), rawTokens);
});
it("properly matches and aligns when lead token + following whitespace are removed", function() {
let existingContext = models.tokenize(defaultBreaker, {
left: "an apple a day keeps the doctor"
});
let transform = {
insert: '',
deleteLeft: 0
}
let newContext = deepCopy(existingContext);
newContext.left.splice(0, 2);
let rawTokens = ["apple", " ", "a", " ", "day", " ", "keeps", " ", "the", " ", "doctor"];
let existingState = ContextTracker.modelContextState(existingContext.left);
let state = ContextTracker.attemptMatchContext(newContext.left, existingState, null, toWrapperDistribution(transform));
assert.isNotNull(state);
assert.deepEqual(state.tokens.map(token => token.raw), rawTokens);
});
it("properly matches and aligns when final token is edited", function() {
let existingContext = ["an", "apple", "a", "day", "keeps", "the", "docto"];
let existingContext = models.tokenize(defaultBreaker, {
left: "an apple a day keeps the docto"
});
let transform = {
insert: 'r',
deleteLeft: 0
}
let newContext = existingContext.slice(0);
newContext[newContext.length - 1] = 'doctor';
let rawTokens = ["an", null, "apple", null, "a", null, "day", null, "keeps", null, "the", null, "doctor"];
let newContext = models.tokenize(defaultBreaker, {
left: "an apple a day keeps the doctor"
});
let rawTokens = ["an", " ", "apple", " ", "a", " ", "day", " ", "keeps", " ", "the", " ", "doctor"];
let existingState = ContextTracker.modelContextState(existingContext);
let state = ContextTracker.attemptMatchContext(newContext, existingState, null, toWrapperDistribution(transform));
let existingState = ContextTracker.modelContextState(existingContext.left);
let state = ContextTracker.attemptMatchContext(newContext.left, existingState, null, toWrapperDistribution(transform));
assert.isNotNull(state);
assert.deepEqual(state.tokens.map(token => token.raw), rawTokens);
});
// Needs improved context-state management (due to 2x tokens)
it("properly matches and aligns when a 'wordbreak' is added", function() {
let existingContext = ["an", "apple", "a", "day", "keeps", "the", "doctor"];
let existingContext = models.tokenize(defaultBreaker, {
left: "an apple a day keeps the doctor"
});
let transform = {
insert: ' ',
deleteLeft: 0
}
let newContext = existingContext.slice(0);
newContext.push('');
let rawTokens = ["an", null, "apple", null, "a", null, "day", null, "keeps", null, "the", null, "doctor", null, ""];
let newContext = models.tokenize(defaultBreaker, {
left: "an apple a day keeps the doctor "
});
let rawTokens = ["an", " ", "apple", " ", "a", " ", "day", " ", "keeps", " ", "the", " ", "doctor", " ", ""];
let existingState = ContextTracker.modelContextState(existingContext);
let state = ContextTracker.attemptMatchContext(newContext, existingState, toWrapperDistribution(transform));
let existingState = ContextTracker.modelContextState(existingContext.left);
let state = ContextTracker.attemptMatchContext(newContext.left, existingState, toWrapperDistribution(transform));
assert.isNotNull(state);
assert.deepEqual(state.tokens.map(token => token.raw), rawTokens);
@ -68,38 +98,44 @@ describe('ContextTracker', function() {
});
it("properly matches and aligns when an implied 'wordbreak' occurs (as when following \"'\")", function() {
let existingContext = ["'"];
let existingContext = models.tokenize(defaultBreaker, {
left: "'"
});
let transform = {
insert: 'a',
deleteLeft: 0
}
let newContext = existingContext.slice(0);
newContext.push('a'); // The incoming transform should produce a new token WITH TEXT.
let rawTokens = ["'", null, "a"];
let newContext = models.tokenize(defaultBreaker, {
left: "'a"
});
let rawTokens = ["'", "a"];
let existingState = ContextTracker.modelContextState(existingContext);
let state = ContextTracker.attemptMatchContext(newContext, existingState, toWrapperDistribution(transform));
let existingState = ContextTracker.modelContextState(existingContext.left);
let state = ContextTracker.attemptMatchContext(newContext.left, existingState, toWrapperDistribution(transform));
assert.isNotNull(state);
assert.deepEqual(state.tokens.map(token => token.raw), rawTokens);
// The 'wordbreak' transform
assert.isEmpty(state.tokens[state.tokens.length - 2].transformDistributions);
assert.isNotEmpty(state.tokens[state.tokens.length - 2].transformDistributions);
assert.isNotEmpty(state.tokens[state.tokens.length - 1].transformDistributions);
});
})
// Needs improved context-state management (due to 2x tokens)
it("properly matches and aligns when lead token is removed AND a 'wordbreak' is added'", function() {
let existingContext = ["an", "apple", "a", "day", "keeps", "the", "doctor"];
let existingContext = models.tokenize(defaultBreaker, {
left: "an apple a day keeps the doctor"
});
let transform = {
insert: ' ',
deleteLeft: 0
}
let newContext = existingContext.slice(0);
newContext.splice(0, 1);
newContext.push('');
let rawTokens = ["apple", null, "a", null, "day", null, "keeps", null, "the", null, "doctor", null, ""];
let newContext = models.tokenize(defaultBreaker, {
left: "apple a day keeps the doctor "
});
let rawTokens = ["apple", " ", "a", " ", "day", " ", "keeps", " ", "the", " ", "doctor", " ", ""];
let existingState = ContextTracker.modelContextState(existingContext);
let state = ContextTracker.attemptMatchContext(newContext, existingState, toWrapperDistribution(transform));
let existingState = ContextTracker.modelContextState(existingContext.left);
let state = ContextTracker.attemptMatchContext(newContext.left, existingState, toWrapperDistribution(transform));
assert.isNotNull(state);
assert.deepEqual(state.tokens.map(token => token.raw), rawTokens);
@ -111,18 +147,28 @@ describe('ContextTracker', function() {
describe('modelContextState', function() {
it('models without final wordbreak', function() {
let context = ["an", "apple", "a", "day", "keeps", "the", "doctor"];
let rawTokens = ["an", null, "apple", null, "a", null, "day", null, "keeps", null, "the", null, "doctor"];
let tokenized = ["an", " ", "apple", " ", "a", " ", "day", " ", "keeps", " ", "the", " ", "doctor"].map((entry) => {
return {
text: entry,
isWhitespace: entry == " "
};
});
let rawTokens = ["an", " ", "apple", " ", "a", " ", "day", " ", "keeps", " ", "the", " ", "doctor"];
let state = ContextTracker.modelContextState(context);
let state = ContextTracker.modelContextState(tokenized);
assert.deepEqual(state.tokens.map(token => token.raw), rawTokens);
});
it('models with final wordbreak', function() {
let context = ["an", "apple", "a", "day", "keeps", "the", "doctor", ""];
let rawTokens = ["an", null, "apple", null, "a", null, "day", null, "keeps", null, "the", null, "doctor", null, ""];
let tokenized = ["an", " ", "apple", " ", "a", " ", "day", " ", "keeps", " ", "the", " ", "doctor", " ", ""].map((entry) => {
return {
text: entry,
isWhitespace: entry == " "
};
});
let rawTokens = ["an", " ", "apple", " ", "a", " ", "day", " ", "keeps", " ", "the", " ", "doctor", " ", ""];
let state = ContextTracker.modelContextState(context);
let state = ContextTracker.modelContextState(tokenized);
assert.deepEqual(state.tokens.map(token => token.raw), rawTokens);
});
});
@ -133,6 +179,7 @@ describe('ContextTracker', function() {
insertAfterWord: ' '
};
// Needs improved context-state management (due to 2x tokens)
it('tracks an accepted suggestion', function() {
let baseSuggestion = {
transform: {
@ -180,7 +227,7 @@ describe('ContextTracker', function() {
let postContextState = compositor.contextTracker.analyzeState(model, postContext);
// Penultimate token corresponds to whitespace, which does not have a 'raw' representation.
assert.isNull(postContextState.tokens[postContextState.tokens.length - 2].raw);
assert.equal(postContextState.tokens[postContextState.tokens.length - 2].raw, ' ');
// Final token is empty (follows a wordbreak)
assert.equal(postContextState.tail.raw, '');