Merge pull request #14756 from keymanapp/refactor/web/source-input-vs-example-input

refactor(web): differentiate between true and represented inputs for context tokens 🚂
This commit is contained in:
Joshua Horton 2025-10-06 21:50:46 +07:00 committed by GitHub
commit 2db1e3fd20
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 121 additions and 16 deletions

View file

@ -255,7 +255,7 @@ export class ContextState {
const tokens = resultTokenization.tokens;
const lastIndex = tokens.length - 1;
// Ignore a context-final empty '' token; the interesting one is what comes before.
const nonEmptyTail = tokens[lastIndex].exampleInput != '' ? tokens[lastIndex] : tokens[lastIndex - 1];
const nonEmptyTail = !tokens[lastIndex].isEmptyToken ? tokens[lastIndex] : tokens[lastIndex - 1];
const appliedSuggestionTransitionId = nonEmptyTail?.appliedTransitionId;
// Used to construct and represent the part of the incoming transform that

View file

@ -16,6 +16,14 @@ import Distribution = LexicalModelTypes.Distribution;
import LexicalModel = LexicalModelTypes.LexicalModel;
import Transform = LexicalModelTypes.Transform;
/**
* Notes critical properties of the inputs comprising each ContextToken.
*/
export interface TokenInputSource {
trueTransform: Transform;
inputStartIndex: number;
}
/**
* Breaks apart a raw text string into individual, single-codepoint
* transforms, all set with the specified transform ID.
@ -58,6 +66,13 @@ export class ContextToken {
*/
appliedTransitionId?: number;
/**
* Represents the original, 'true' input transforms (tokenized, as necessary)
* applied to the actual context for the set of keystrokes contributing to
* this token.
*/
private _inputRange: TokenInputSource[];
/**
* Constructs a new, empty instance for use with the specified LexicalModel.
* @param model
@ -84,6 +99,7 @@ export class ContextToken {
// In case we are unable to perfectly track context (say, due to multitaps)
// we need to ensure that only fully-utilized keystrokes are considered.
this.searchSpace = new SearchSpace(priorToken.searchSpace);
this._inputRange = priorToken._inputRange.slice();
// Preserve any annotated applied-suggestion transition ID data; it's useful
// for delayed reversion operations.
@ -96,6 +112,7 @@ export class ContextToken {
// May be altered outside of the constructor.
this.isWhitespace = false;
this.searchSpace = new SearchSpace(model);
this._inputRange = [];
rawText ||= '';
@ -103,17 +120,86 @@ export class ContextToken {
const rawTransformDistributions: Distribution<Transform>[] = textToCharTransforms(rawText).map(function(transform) {
return [{sample: transform, p: 1.0}];
});
rawTransformDistributions.forEach((entry) => this.searchSpace.addInput(entry));
rawTransformDistributions.forEach((entry) => {
this._inputRange.push({
trueTransform: entry[0].sample,
inputStartIndex: 0
});
this.searchSpace.addInput(entry);
});
}
}
/**
* Displays text corresponding to the net effects of the most likely inputs received
* that can correspond to the current instance.
* Call this to record the original keystroke Transforms for the context range
* corresponding to this token.
*/
addInput(inputSource: TokenInputSource, distribution: Distribution<Transform>) {
this._inputRange.push(inputSource);
this.searchSpace.addInput(distribution);
}
/**
* Denotes the original keystroke Transforms comprising the range corresponding
* to this token.
*/
get inputRange(): Readonly<TokenInputSource[]> {
return this._inputRange;
}
/**
* Indicates whether or not this ContextToken likely represents an empty token.
*/
get isEmptyToken(): boolean {
return this.exampleInput == '';
}
/**
* Gets a compact string-based representation of `inputRange` that
* maps compatible token source ranges to each other.
*/
get sourceRangeKey(): string {
const components: string[] = [];
for(const source of this.inputRange) {
const i = source.inputStartIndex;
components.push(`T${source.trueTransform.id}${i != 0 ? '@' + i : ''}`);
}
return components.join('+');
}
/**
* Gets a simple, compact string-based representation of `inputRange`.
*
* This should only ever be used for debugging purposes.
*/
get sourceText(): string {
const composite = this._inputRange.reduce((accum, current) => {
const alteredTransform = {...current.trueTransform};
alteredTransform.insert = alteredTransform.insert.slice(current.inputStartIndex);
return buildMergedTransform(accum, current.trueTransform)
}, { insert: '', deleteLeft: 0 });
const prefix = '\u{2421}'.repeat(composite.deleteLeft);
return prefix + composite.insert;
}
/**
* Generates text corresponding to the net effects of the most likely inputs
* received that can correspond to the current instance.
*/
get exampleInput(): string {
/*
* TODO: with clear limits (strict cost minimization?) / prior calculation
* attempts, return the best _suggestion_ for this token. This is
* especially relevant for epic/dict-breaker - we want to best model the token
* as it would apply within the word-breaking algorithm.
*
* If not possible, find the best of the deepest search paths and append the
* most likely keystroke data afterward.
*/
const transforms = this.searchSpace.inputSequence.map((dist) => dist[0].sample)
const composite = transforms.reduce((accum, current) => buildMergedTransform(accum, current), { insert: '', deleteLeft: 0});
const composite = transforms.reduce((accum, current) => buildMergedTransform(accum, current), {insert: '', deleteLeft: 0});
return composite.insert;
}
}

View file

@ -7,16 +7,18 @@
* the sliding context window for one specific instance of context state.
*/
import { ContextToken } from './context-token.js';
import { computeAlignment, ContextStateAlignment } from './alignment-helpers.js';
import { Token } from '@keymanapp/models-templates';
import { LexicalModelTypes } from '@keymanapp/common-types';
import { KMWString } from '@keymanapp/web-utils';
import { ContextToken } from './context-token.js';
import TransformUtils from '../transformUtils.js';
import { computeAlignment, ContextStateAlignment } from './alignment-helpers.js';
import Distribution = LexicalModelTypes.Distribution;
import LexicalModel = LexicalModelTypes.LexicalModel;
import ProbabilityMass = LexicalModelTypes.ProbabilityMass;
import Transform = LexicalModelTypes.Transform;
import TransformUtils from '../transformUtils.js';
/**
* This class represents the sequence of tokens (words and whitespace blocks)
@ -47,15 +49,22 @@ export class ContextTokenization {
return this.tokens[this.tokens.length - 1];
}
/**
* Returns plain-text strings representing the most probable representation for all
* tokens represented by this tokenization instance.
*
* Intended for debugging use only.
*/
get sourceText() {
return this.tokens.map(token => token.sourceText);
}
/**
* Returns a plain-text string representing the most probable representation for all
* tokens represented by this tokenization instance.
*/
get exampleInput(): string[] {
return this.tokens
// Hide any tokens representing invisible wordbreaks. (Thinking ahead to phrase-level possibilities)
.filter(token => token.exampleInput !== null)
.map(token => token.exampleInput);
return this.tokens.map(token => token.exampleInput);
}
/**
@ -162,6 +171,10 @@ export class ContextTokenization {
// The assumed input from the input distribution is always at index 0.
const tokenizedPrimaryInput = hasDistribution ? alignedTransformDistribution[0].sample : null;
// now that we've identified the 'primary input', sort the distributions.
alignedTransformDistribution.sort((a, b) => b.p - a.p);
// first index: original sample's tokenization
// second index: token index within original sample
const tokenDistribution = alignedTransformDistribution.map((entry) => {
@ -182,6 +195,7 @@ export class ContextTokenization {
// edited, those edits occur to the left as well - and further left of whatever
// the new tail token is *if* tokens were removed.
const firstTailEditIndex = Math.min((1 - tailEditLength), 0) + Math.min(tailTokenShift, 0);
let primaryInputAppliedLen = 0;
for(let i = 0; i < tailEditLength; i++) {
const tailIndex = firstTailEditIndex + i;
@ -203,12 +217,16 @@ export class ContextTokenization {
// Assumption: there have been no intervening keystrokes since the last well-aligned context.
// (May not be valid with epic/dict-breaker or with complex, word-boundary crossing transforms)
token = new ContextToken(matchedToken);
// Erase any applied-suggestion transition ID; it is no longer valid.
token.appliedTransitionId = undefined;
token.searchSpace.addInput(tokenDistribution.map((seq) => seq.get(tailIndex) ?? { sample: { insert: '', deleteLeft: 0 }, p: 1 }));
const emptySample: ProbabilityMass<Transform> = { sample: { insert: '', deleteLeft: 0 }, p: 1 };
const dist = tokenDistribution.map((seq) => seq.get(tailIndex) ?? emptySample);
token.addInput({trueTransform: primaryInput ?? emptySample.sample, inputStartIndex: primaryInputAppliedLen}, dist);
}
tokenization[incomingIndex] = token;
primaryInputAppliedLen += KMWString.length(primaryInput?.insert ?? '');
}
if(tailTokenShift < 0) {
@ -268,7 +286,7 @@ export class ContextTokenization {
// If we ever stop filtering tokenized transform distributions, it may
// be worth adding an empty transform here with weight to balance
// the distribution back to a cumulative prob sum of 1.
pushedToken.searchSpace.addInput(transformDistribution);
pushedToken.addInput({ trueTransform: primaryInput, inputStartIndex: primaryInputAppliedLen }, transformDistribution);
}
} else if(incomingToken.text) {
// We have no transform data to match against an inserted token with text; abort!
@ -280,6 +298,7 @@ export class ContextTokenization {
// Auto-replaces the search space to correspond with the new token.
tokenization.push(pushedToken);
primaryInputAppliedLen += KMWString.length(primaryInput.insert);
}
}

View file

@ -394,7 +394,7 @@ export function determineSuggestionAlignment(
// Did the wordbreaker (or similar) append a blank token before the caret? If so,
// preserve that by preventing corrections from triggering left-deletion.
if(transition.final.tokenization.tail.exampleInput == '') {
if(transition.final.tokenization.tail.isEmptyToken) {
deleteLeft = 0;
}