mirror of
https://github.com/keymanapp/keyman.git
synced 2026-09-01 05:07:40 +00:00
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:
commit
2db1e3fd20
4 changed files with 121 additions and 16 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue