Skip to content

Commit c12cef9

Browse files
committed
change(web): simplify mapWhitespacedTokenization requirements
To better handle inputs that shift the word-boundary in some custom models and models released before Keyman 14.0, this PR provides generalized re-use of the whitespace-based token-transition algorithm used for our most prominently-supported models. Build-bot: skip build:web Test-bot: skip
1 parent 8db8f32 commit c12cef9

1 file changed

Lines changed: 187 additions & 154 deletions

File tree

web/src/engine/predictive-text/worker-thread/src/main/correction/context-tokenization.ts

Lines changed: 187 additions & 154 deletions
Original file line numberDiff line numberDiff line change
@@ -334,7 +334,7 @@ export class ContextTokenization {
334334
}
335335

336336
/**
337-
* Given the existing tokenization and an incoming input `Transform`, this
337+
* Given this existing tokenization and an incoming input `Transform`, this
338338
* method precomputes how both the current, pre-application tokenization will
339339
* be altered and how the incoming Transform will be tokenized.
340340
*
@@ -351,158 +351,7 @@ export class ContextTokenization {
351351
transform: Transform,
352352
edgeOptions?: EdgeWindowOptions
353353
): TokenizationTransitionEdits {
354-
// Step 4: now that our window's been properly updated, determine what the
355-
// input's effects on the context is.
356-
//
357-
// Context does not slide within this function.
358-
//
359-
// Assumption: this alignment cannot fail; we KNOW there's a solid
360-
// before-and-after relationship here, and we can base it on the results of
361-
// a prior syncToSourceWindow call.
362-
//
363-
// We don't wish to do the full tokenization here - we only want to check
364-
// over the last few tokens that might reasonably shift. We also want to
365-
// batch effects.
366-
367-
// Do not mutate the original transform; it can cause unexpected assertion
368-
// effects in unit tests.
369-
const edgeTransform = {...transform, deleteRight: transform.deleteRight || 0};
370-
const edgeWindow = buildEdgeWindow(this.tokens, edgeTransform, false, edgeOptions);
371-
const {
372-
retokenizationText,
373-
editBoundary,
374-
sliceIndex: edgeSliceIndex
375-
} = edgeWindow;
376-
// Prevent mutation of the original return property.
377-
const stackedDeletes = edgeWindow.deleteLengths.slice();
378-
379-
const tokenize = determineModelTokenizer(lexicalModel);
380-
const postTokenization = tokenize({left: retokenizationText + transform.insert, startOfBuffer: true, endOfBuffer: true}).left.map(t => t.text);
381-
if(postTokenization.length == 0) {
382-
postTokenization.push('');
383-
}
384-
const { stackedInserts, firstInsertPostIndex } = traceInsertEdits(postTokenization, transform);
385-
386-
// What does the edge's retokenization look like when we remove the inserted portions?
387-
const retokenizedEdge = postTokenization.slice(0, firstInsertPostIndex);
388-
const insertBoundaryToken = postTokenization[firstInsertPostIndex];
389-
390-
// Note: requires that helpers have not mutated `stackedInserts`.
391-
const uninsertedBoundaryToken = KMWString.substring(insertBoundaryToken, 0, KMWString.lastIndexOf(insertBoundaryToken, stackedInserts[0]));
392-
393-
// Do not preserve empty tokens here, even if tokenization normally would produce one.
394-
// It's redundant and replaceable for tokenization batching efforts.
395-
if(uninsertedBoundaryToken != '') {
396-
retokenizedEdge.push(uninsertedBoundaryToken);
397-
}
398-
399-
// We've found the root token within the root context state to which deletes (and inserts)
400-
// may be applied.
401-
// We've also found the last post-application token to which transform changes contributed.
402-
// How do these indices line up - we need to properly construct and index our transforms,
403-
// but 'merge' and 'split' edits can mess up that indexing.
404-
405-
const currentTokens = this.tokens;
406-
const preTokenization = currentTokens
407-
.slice(edgeSliceIndex, editBoundary.tokenIndex+1)
408-
.map(t => t.exampleInput);
409-
410-
// Determine the effects of splits & merges as applied to the original
411-
// cached context state.
412-
const { mergeOffset, splitOffset, editPath, merges, splits } = analyzePathMergesAndSplits(
413-
preTokenization,
414-
postTokenization.slice(0, firstInsertPostIndex+1)
415-
);
416-
417-
/*
418-
* Final steps: We can now safely index the transforms. Let's do it!
419-
* 1. Determine the first index a Transform may align to
420-
* 2. Build the transforms
421-
*
422-
* Notes:
423-
* - text applied to the end of a 'merged' token at the tail: should have
424-
* index 0, not -1.
425-
* - pretokenization index will mismatch by -1: -SUM(merge size - 1)
426-
* - Ex: can + ' + t => can't
427-
* -1 0 0
428-
* - text applied to the end of a 'split' token at the tail: should also
429-
* have index 0, not 1.
430-
* - posttokenization index will mismatch by +1: SUM(split size - 1)
431-
* - new token after 'split': index 1
432-
* - Ex: can' + ? => can + ' + ?
433-
* 0 -1 0 1
434-
*
435-
* The first transform applies at the end of the retokenized zone and its
436-
* associated index. The question: were there deletes that occurred?
437-
*/
438-
439-
const lastEditedPreTokenIndex = editBoundary.tokenIndex - edgeSliceIndex;
440-
let shiftDeletes = false;
441-
// first popped entry == 0 - a delete no-op.
442-
if(stackedDeletes[stackedDeletes.length - 1] == 0) {
443-
// the boundary indices found by both methods above differ
444-
if(lastEditedPreTokenIndex + mergeOffset != firstInsertPostIndex + splitOffset) {
445-
shiftDeletes = true;
446-
}
447-
448-
// there are no inserts, so we don't affect the boundary token we landed on.
449-
if(stackedDeletes.length > 1 && transform.insert == '') {
450-
shiftDeletes = true;
451-
}
452-
}
453-
454-
if(shiftDeletes) {
455-
// Do not add a zero-length delete if we're not actually altering the
456-
// corresponding token at all.
457-
stackedDeletes.pop();
458-
}
459-
460-
// The first delete always applies to index 0. If the built edge window
461-
// omits a context-final empty-string, adjust the tokenization indices
462-
// accordingly.
463-
const tailIndex = 0 - (stackedDeletes.length - 1) + (editBoundary.omitsEmptyToken ? -1 : 0);
464-
// Mutates stackedInserts, stackedDeletes.
465-
const baseRemovedTokenCount = Math.max(0, stackedDeletes.length - stackedInserts.length);
466-
const transformMap = assembleTransforms(stackedInserts, stackedDeletes, tailIndex);
467-
468-
// If there's an empty transform in the 0 position and we already know we're
469-
// dropping tokens - and only deleting - we're dropping an
470-
// otherwise-untracked empty token - make sure it's included!
471-
const droppedFinalTransform = baseRemovedTokenCount > 0 && transform.insert == '' && TransformUtils.isEmpty(transformMap.get(0));
472-
// Past that, if we have more delete entries than insert entries for our transforms, we
473-
// dropped some tokens outright.
474-
const removedTokenCount = baseRemovedTokenCount + (droppedFinalTransform ? 1 : 0);
475-
476-
// Final step: check for any unexpected boundary shifts not mappable to 'merge' / 'split'
477-
// and not caused by transforms. All transforms always apply in sequence at the end.
478-
const unmappedEdits: EditTuple<EditOperation>[] = [];
479-
for(let i = 0; i < editPath.length - transformMap.size; i++) {
480-
const op = editPath[i].op;
481-
switch(op) {
482-
case 'merge':
483-
case 'split':
484-
// already calculated
485-
// can fall through to the `continue;` line.
486-
case 'match':
487-
continue;
488-
default:
489-
// Should only be substitutions here.
490-
// We may wish to add extra analysis in the future when supporting
491-
// prediction from multiple competing tokenizations.
492-
unmappedEdits.push(editPath[i] as EditTuple<EditOperation>);
493-
}
494-
}
495-
496-
return {
497-
alignment: {
498-
edgeWindow: {...edgeWindow, retokenization: retokenizedEdge},
499-
merges,
500-
splits,
501-
unmappedEdits,
502-
removedTokenCount
503-
},
504-
tokenizedTransform: transformMap,
505-
};
354+
return mapWhitespacedTokenization(this.tokens, lexicalModel, transform, edgeOptions);
506355
}
507356

508357
/**
@@ -763,6 +612,190 @@ interface RetokenizedEdgeWindow extends EdgeWindow {
763612
retokenization: string[];
764613
}
765614

615+
export interface ContextTokenLike {
616+
exampleInput: string;
617+
isPartial?: boolean;
618+
sourceRangeKey?: string;
619+
}
620+
621+
/**
622+
* Given an existing tokenization and an incoming input `Transform`, this
623+
* method precomputes how both the current, pre-application tokenization will
624+
* be altered and how the incoming Transform will be tokenized.
625+
*
626+
* This function is able to operate with a reduced interface, not requiring
627+
* the full ContextToken/ContextState/etc subsystem and its related
628+
* SearchQuotientNode requirements.
629+
*
630+
* Note that this method is designed for use with languages that employ
631+
* classical space-based wordbreaking. Do not use it for languages that need
632+
* dictionary-based wordbreaking support!
633+
* @param tokens
634+
* @param lexicalModel
635+
* @param transform
636+
* @param edgeOptions
637+
* @returns
638+
*/
639+
export function mapWhitespacedTokenization(
640+
tokens: ContextTokenLike[],
641+
lexicalModel: LexicalModel,
642+
transform: Transform,
643+
edgeOptions?: EdgeWindowOptions
644+
): TokenizationTransitionEdits {
645+
// Step 4: now that our window's been properly updated, determine what the
646+
// input's effects on the context is.
647+
//
648+
// Context does not slide within this function.
649+
//
650+
// Assumption: this alignment cannot fail; we KNOW there's a solid
651+
// before-and-after relationship here, and we can base it on the results of
652+
// a prior syncToSourceWindow call.
653+
//
654+
// We don't wish to do the full tokenization here - we only want to check
655+
// over the last few tokens that might reasonably shift. We also want to
656+
// batch effects.
657+
658+
// Do not mutate the original transform; it can cause unexpected assertion
659+
// effects in unit tests.
660+
const edgeTransform = {...transform, deleteRight: transform.deleteRight || 0};
661+
const edgeWindow = buildEdgeWindow(tokens, edgeTransform, false, edgeOptions);
662+
const {
663+
retokenizationText,
664+
editBoundary,
665+
sliceIndex: edgeSliceIndex
666+
} = edgeWindow;
667+
// Prevent mutation of the original return property.
668+
const stackedDeletes = edgeWindow.deleteLengths.slice();
669+
670+
const tokenize = determineModelTokenizer(lexicalModel);
671+
const postTokenization = tokenize({left: retokenizationText + transform.insert, startOfBuffer: true, endOfBuffer: true}).left.map(t => t.text);
672+
if(postTokenization.length == 0) {
673+
postTokenization.push('');
674+
}
675+
const { stackedInserts, firstInsertPostIndex } = traceInsertEdits(postTokenization, transform);
676+
677+
// What does the edge's retokenization look like when we remove the inserted portions?
678+
const retokenizedEdge = postTokenization.slice(0, firstInsertPostIndex);
679+
const insertBoundaryToken = postTokenization[firstInsertPostIndex];
680+
681+
// Note: requires that helpers have not mutated `stackedInserts`.
682+
const uninsertedBoundaryToken = KMWString.substring(insertBoundaryToken, 0, KMWString.lastIndexOf(insertBoundaryToken, stackedInserts[0]));
683+
684+
// Do not preserve empty tokens here, even if tokenization normally would produce one.
685+
// It's redundant and replaceable for tokenization batching efforts.
686+
if(uninsertedBoundaryToken != '') {
687+
retokenizedEdge.push(uninsertedBoundaryToken);
688+
}
689+
690+
// We've found the root token within the root context state to which deletes (and inserts)
691+
// may be applied.
692+
// We've also found the last post-application token to which transform changes contributed.
693+
// How do these indices line up - we need to properly construct and index our transforms,
694+
// but 'merge' and 'split' edits can mess up that indexing.
695+
696+
const currentTokens = tokens;
697+
const preTokenization = currentTokens
698+
.slice(edgeSliceIndex, editBoundary.tokenIndex+1)
699+
.map(t => t.exampleInput);
700+
701+
// Determine the effects of splits & merges as applied to the original
702+
// cached context state.
703+
const { mergeOffset, splitOffset, editPath, merges, splits } = analyzePathMergesAndSplits(
704+
preTokenization,
705+
postTokenization.slice(0, firstInsertPostIndex+1)
706+
);
707+
708+
/*
709+
* Final steps: We can now safely index the transforms. Let's do it!
710+
* 1. Determine the first index a Transform may align to
711+
* 2. Build the transforms
712+
*
713+
* Notes:
714+
* - text applied to the end of a 'merged' token at the tail: should have
715+
* index 0, not -1.
716+
* - pretokenization index will mismatch by -1: -SUM(merge size - 1)
717+
* - Ex: can + ' + t => can't
718+
* -1 0 0
719+
* - text applied to the end of a 'split' token at the tail: should also
720+
* have index 0, not 1.
721+
* - posttokenization index will mismatch by +1: SUM(split size - 1)
722+
* - new token after 'split': index 1
723+
* - Ex: can' + ? => can + ' + ?
724+
* 0 -1 0 1
725+
*
726+
* The first transform applies at the end of the retokenized zone and its
727+
* associated index. The question: were there deletes that occurred?
728+
*/
729+
730+
const lastEditedPreTokenIndex = editBoundary.tokenIndex - edgeSliceIndex;
731+
let shiftDeletes = false;
732+
// first popped entry == 0 - a delete no-op.
733+
if(stackedDeletes[stackedDeletes.length - 1] == 0) {
734+
// the boundary indices found by both methods above differ
735+
if(lastEditedPreTokenIndex + mergeOffset != firstInsertPostIndex + splitOffset) {
736+
shiftDeletes = true;
737+
}
738+
739+
// there are no inserts, so we don't affect the boundary token we landed on.
740+
if(stackedDeletes.length > 1 && transform.insert == '') {
741+
shiftDeletes = true;
742+
}
743+
}
744+
745+
if(shiftDeletes) {
746+
// Do not add a zero-length delete if we're not actually altering the
747+
// corresponding token at all.
748+
stackedDeletes.pop();
749+
}
750+
751+
// The first delete always applies to index 0. If the built edge window
752+
// omits a context-final empty-string, adjust the tokenization indices
753+
// accordingly.
754+
const tailIndex = 0 - (stackedDeletes.length - 1) + (editBoundary.omitsEmptyToken ? -1 : 0);
755+
// Mutates stackedInserts, stackedDeletes.
756+
const baseRemovedTokenCount = Math.max(0, stackedDeletes.length - stackedInserts.length);
757+
const transformMap = assembleTransforms(stackedInserts, stackedDeletes, tailIndex);
758+
759+
// If there's an empty transform in the 0 position and we already know we're
760+
// dropping tokens - and only deleting - we're dropping an
761+
// otherwise-untracked empty token - make sure it's included!
762+
const droppedFinalTransform = baseRemovedTokenCount > 0 && transform.insert == '' && TransformUtils.isEmpty(transformMap.get(0));
763+
// Past that, if we have more delete entries than insert entries for our transforms, we
764+
// dropped some tokens outright.
765+
const removedTokenCount = baseRemovedTokenCount + (droppedFinalTransform ? 1 : 0);
766+
767+
// Final step: check for any unexpected boundary shifts not mappable to 'merge' / 'split'
768+
// and not caused by transforms. All transforms always apply in sequence at the end.
769+
const unmappedEdits: EditTuple<EditOperation>[] = [];
770+
for(let i = 0; i < editPath.length - transformMap.size; i++) {
771+
const op = editPath[i].op;
772+
switch(op) {
773+
case 'merge':
774+
case 'split':
775+
// already calculated
776+
// can fall through to the `continue;` line.
777+
case 'match':
778+
continue;
779+
default:
780+
// Should only be substitutions here.
781+
// We may wish to add extra analysis in the future when supporting
782+
// prediction from multiple competing tokenizations.
783+
unmappedEdits.push(editPath[i] as EditTuple<EditOperation>);
784+
}
785+
}
786+
787+
return {
788+
alignment: {
789+
edgeWindow: {...edgeWindow, retokenization: retokenizedEdge},
790+
merges,
791+
splits,
792+
unmappedEdits,
793+
removedTokenCount
794+
},
795+
tokenizedTransform: transformMap,
796+
};
797+
}
798+
766799
/**
767800
* Constructs a window on one side of the represented context that is aligned to
768801
* existing tokenization.
@@ -777,7 +810,7 @@ interface RetokenizedEdgeWindow extends EdgeWindow {
777810
* @returns
778811
*/
779812
export function buildEdgeWindow(
780-
currentTokens: ContextToken[],
813+
currentTokens: ContextTokenLike[],
781814
// Requires deleteRight be explicitly set.
782815
transform: Transform & { deleteRight: number },
783816
applyAtFront: boolean,

0 commit comments

Comments
 (0)