From 9b29339594309048634226ef78d969164dc4b695 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Thu, 8 Sep 2022 13:13:58 +0700 Subject: [PATCH 1/3] fix(common/models): blocks full-text corrections --- .../src/correction/distance-modeler.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/common/web/lm-worker/src/correction/distance-modeler.ts b/common/web/lm-worker/src/correction/distance-modeler.ts index 7b4456e4d8..47c7ad88eb 100644 --- a/common/web/lm-worker/src/correction/distance-modeler.ts +++ b/common/web/lm-worker/src/correction/distance-modeler.ts @@ -204,6 +204,15 @@ namespace correction { // TODO: might should also track diagonalWidth. return inputString + models.SENTINEL_CODE_UNIT + matchString; } + + get isFullReplacement(): boolean { + // If the known edit-distance cost is equal to the input length, this means + // that literally every input has been full-on replaced. Thus, this is + // likely not a good 'root' to use for predictions. + // + // Logic exception: 0 cost, 0 length != a "replacement". + return this.knownCost && this.knownCost == this.priorInput.length; + } } class SearchSpaceTier { @@ -590,6 +599,14 @@ namespace correction { // Build batches of same-cost entries. while(preprocessedQueue.count > 0) { let entry = preprocessedQueue.dequeue(); + + // Is the entry a reasonable result? + if(entry.isFullReplacement) { + // If the entry's 'match' fully replaces the input string, we consider it + // unreasonable and ignore it. + continue; + } + let batch = batcher.checkAndAdd(entry); if(batch) { @@ -626,6 +643,13 @@ namespace correction { if(newResult.type == 'none') { break; } else if(newResult.type == 'complete') { + // Is the entry a reasonable result? + if(newResult.finalNode.isFullReplacement) { + // If the entry's 'match' fully replaces the input string, we consider it + // unreasonable and ignore it. Also, if we've reached this point... + // we can(?) assume that everything thereafter is as well. + break; + } batch = batcher.checkAndAdd(newResult.finalNode); } From 666b63940407f2a945263dc9090ff25ecc2435f1 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Fri, 9 Sep 2022 15:35:12 +0700 Subject: [PATCH 2/3] fix(common/models): adds 'soft bound' for reasonable 100% fat-finger corrections --- common/web/lm-worker/src/model-compositor.ts | 42 +++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/common/web/lm-worker/src/model-compositor.ts b/common/web/lm-worker/src/model-compositor.ts index 329460776d..0010371e63 100644 --- a/common/web/lm-worker/src/model-compositor.ts +++ b/common/web/lm-worker/src/model-compositor.ts @@ -171,6 +171,16 @@ class ModelCompositor { } } + // Is the token under construction newly-constructed / is there no pre-existing root? + // If so, we want to strongly avoid overcorrection, even for 'nearby' keys. + // (Strong lexical frequency differences can easily cause overcorrection when only + // one key's available.) + // + // NOTE: we only want this applied word-initially, when any corrections 'correct' + // 100% of the word. Things are generally fine once it's not "all or nothing." + let tailToken = contextTokens[contextTokens.length - 1]; + const isTokenStart = tailToken.transformDistributions.length <= 1; + // TODO: whitespace, backspace filtering. Do it here. // Whitespace is probably fine, actually. Less sure about backspace. @@ -212,9 +222,39 @@ class ModelCompositor { id: inputTransform.id // The correction should always be based on the most recent external transform/transcription ID. } + let rootCost = match.totalCost; + + /* If we're dealing with the FIRST keystroke of a new sequence, we'll **dramatically** boost + * the exponent to ensure only VERY nearby corrections have a chance of winning, and only if + * there are significantly more likely words. We only need this to allow very minor fat-finger + * adjustments for 100% keystroke-sequence corrections in order to prevent finickiness on + * key borders. + * + * Technically, the probabilities this produces won't be normalized as-is... but there's no + * true NEED to do so for it, even if it'd be 'nice to have'. Consistently tracking when + * to apply it could become tricky, so it's simpler to leave out. + * + * Worst-case, it's possible to temporarily add normalization if a code deep-dive + * is needed in the future. + */ + if(isTokenStart) { + /* Suppose a key distribution: most likely with p=0.5,, second-most with 0.4 - a pretty + * ambiguous case that would only arise very near the center of the boundary between two keys. + * Raising (0.5/0.4)^16 ~= 35.53. + * That seems 'within reason' for correction very near boundaries. + * + * So, with the second-most-likely key being that close in probability, its best suggestion + * must be ~ 35.5x more likely than that of the truly-most-likely key to "win". So, it's not + * a HARD cutoff, but more of a 'soft' one. Keeping the principles in mind documented above, + * it's possible to tweak this to a more harsh or lenient setting if desired, rather than + * being totally "all or nothing" on which key is taken for highly-ambiguous keypresses. + */ + rootCost *= 16; + } + return { sample: correctionTransform, - p: Math.exp(-match.totalCost) + p: Math.exp(-rootCost) }; }, this); From 6632d597d524e83a71001869582af698d09a8af4 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Mon, 12 Sep 2022 10:25:24 +0700 Subject: [PATCH 3/3] feat(common/models): adds unit test, minor branch polish --- .../headless/worker-model-compositor.js | 39 +++++++++++++++++++ common/web/lm-worker/src/model-compositor.ts | 23 +++++++++-- 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/common/predictive-text/unit_tests/headless/worker-model-compositor.js b/common/predictive-text/unit_tests/headless/worker-model-compositor.js index f7e9173cee..464ed7b203 100644 --- a/common/predictive-text/unit_tests/headless/worker-model-compositor.js +++ b/common/predictive-text/unit_tests/headless/worker-model-compositor.js @@ -50,6 +50,45 @@ describe('ModelCompositor', function() { }); }); + it('strongly avoids corrections for single-character roots', function() { + let compositor = new ModelCompositor(plainModel); + let context = { + left: '', startOfBuffer: true, endOfBuffer: true, + }; + + // The 'weights' involved imply that we have an edge-case fat finger on the bottom of + // the 'q' key, slightly in its favor. + let inputDistribution = [ + {sample: {insert: 'q', deleteLeft: 0}, p: 0.5}, // 'quite' (679) and 'question' (644) are included! + {sample: {insert: 'a', deleteLeft: 0}, p: 0.4} // but at lower weight than 'and' (998). + ]; + + compositor.predict({insert: '', deleteLeft: 0}, context); // Initialize context tracking first! + let suggestions = compositor.predict(inputDistribution, context); + + // remove the keep suggestion; we're not testing that here. + suggestions = suggestions.filter((suggestion) => suggestion.tag != 'keep'); + + suggestions.sort((a, b) => b.p - a.p); + + // There are only 4 suggestions in this limited test model that begin with 'q'. + // We expect more than that, since 'a' is indicated to be very close by. + assert.isAbove(suggestions.length, 4, "fat-finger style corrections needed for test comparisons are missing"); + + // Note: 'and' is (currently) modeled by the text-fixture model to have 9.3x the base probability + // that the worst 'q'-rooted suggestion ('quality') does. Without single-character correction + // avoidance logic, this test _will_ fail. + // + // In case a tweak to test parameters is desired, note that 'and' beats rank #3 - 'questions' - + // at 3.36x base. At the time of writing this test, upping 'a's probability to 0.45 will block + // 'quality' while the top three 'q's (ending with 'questions') remain in place. + let qRange = suggestions.slice(0, 4); + assert.isUndefined(qRange.find((suggestion) => suggestion.transform.insert.charAt(0) != 'q')); + + let aRange = suggestions.slice(4); + assert.isUndefined(aRange.find((suggestion) => suggestion.transform.insert.charAt(0) == 'q')); + }); + it('properly handles suggestions after a backspace', function() { let compositor = new ModelCompositor(plainModel); let context = { diff --git a/common/web/lm-worker/src/model-compositor.ts b/common/web/lm-worker/src/model-compositor.ts index 0010371e63..d048d8b246 100644 --- a/common/web/lm-worker/src/model-compositor.ts +++ b/common/web/lm-worker/src/model-compositor.ts @@ -6,6 +6,23 @@ class ModelCompositor { private static readonly MAX_SUGGESTIONS = 12; readonly punctuation: LexicalModelPunctuation; + /** + * Controls the strength of anti-corrective measures for single-character scenarios. + * The base key probability will be raised to this power for this specific case. + * + * Current selection's motivation: (0.5 / 0.4) ^ 16 ~= 35.5. + * - if the most likely has p=0.5 and second-most has p=0.4 - a highly-inaccurate key + * stroke - the net effect will apply a factor of 35.5 to the lexical probability of + * the best key's prediction roots, favoring it in this manner. + * - less extreme edge cases will have a significantly stronger factor, acting as a + * "soft threshold". + * - truly ambiguous, "coin flip" cases will have a lower factor and thus favor the + * more likely words from the pair. + * - Our OSK key-element borders aren't visible to the user, so the 'spot' where + * behavior changes might feel arbitrary to users if we used a hard threshold instead. + */ + private static readonly SINGLE_CHAR_KEY_PROB_EXPONENT = 16; + private SUGGESTION_ID_SEED = 0; constructor(lexicalModel: LexicalModel) { @@ -238,9 +255,9 @@ class ModelCompositor { * is needed in the future. */ if(isTokenStart) { - /* Suppose a key distribution: most likely with p=0.5,, second-most with 0.4 - a pretty + /* Suppose a key distribution: most likely with p=0.5, second-most with 0.4 - a pretty * ambiguous case that would only arise very near the center of the boundary between two keys. - * Raising (0.5/0.4)^16 ~= 35.53. + * Raising (0.5/0.4)^16 ~= 35.53. (At time of writing, SINGLE_CHAR_KEY_PROB_EXPONENT = 16.) * That seems 'within reason' for correction very near boundaries. * * So, with the second-most-likely key being that close in probability, its best suggestion @@ -249,7 +266,7 @@ class ModelCompositor { * it's possible to tweak this to a more harsh or lenient setting if desired, rather than * being totally "all or nothing" on which key is taken for highly-ambiguous keypresses. */ - rootCost *= 16; + rootCost *= ModelCompositor.SINGLE_CHAR_KEY_PROB_EXPONENT; // note the `Math.exp` below. } return {