From 9e2e969b62562952c0790a3b5abf4ffb61a544dc Mon Sep 17 00:00:00 2001 From: will wade Date: Mon, 20 Apr 2026 14:02:59 +0100 Subject: [PATCH] added suggest words to metrics --- src/utilities/analytics/metrics/core.ts | 26 ++++- src/utilities/analytics/metrics/effort.ts | 1 + src/utilities/analytics/metrics/types.ts | 1 + test/suggestWordsEffort.test.ts | 121 ++++++++++++++++++++++ 4 files changed, 144 insertions(+), 5 deletions(-) create mode 100644 test/suggestWordsEffort.test.ts diff --git a/src/utilities/analytics/metrics/core.ts b/src/utilities/analytics/metrics/core.ts index e410733..e26c49d 100644 --- a/src/utilities/analytics/metrics/core.ts +++ b/src/utilities/analytics/metrics/core.ts @@ -989,6 +989,13 @@ export class MetricsCalculator { } if (!parentMetrics) return; + // Build set of original Suggest Words predictions (from Prediction.PredictThis). + // These require an extra confirmation tap from the user. Smart grammar + // morphology outcomes are generated automatically and need no extra tap. + const suggestWordsSet = new Set( + ((btn.parameters?.predictions || []) as string[]).map((w) => w.toLowerCase()) + ); + // Calculate effort for each word form btn.predictions.forEach((wordForm: string, index: number) => { const wordFormLower = wordForm.toLowerCase(); @@ -1005,8 +1012,16 @@ export class MetricsCalculator { predictionRowIndex * predictionsGridCols + predictionColIndex; const predictionSelectionEffort = visualScanEffort(predictionPriorItems); - // Word form effort = parent button's cumulative effort + selection effort - const wordFormEffort = parentMetrics.effort + predictionSelectionEffort; + // Add confirmation cost for Suggest Words outcomes only. + // Suggest Words requires an explicit tap on the prediction bar, + // while smart grammar morphology forms are auto-generated (no extra tap). + const suggestWordsConfirmation = suggestWordsSet.has(wordFormLower) + ? EFFORT_CONSTANTS.SUGGEST_WORDS_SELECTION_EFFORT + : 0; + + // Word form effort = parent button's cumulative effort + selection effort + confirmation + const wordFormEffort = + parentMetrics.effort + predictionSelectionEffort + suggestWordsConfirmation; // Check if this word already exists as a regular button const existingBtn = existingLabels.get(wordFormLower); @@ -1031,9 +1046,10 @@ export class MetricsCalculator { semantic_id: parentMetrics.semantic_id, clone_id: parentMetrics.clone_id, temporary_home_id: parentMetrics.temporary_home_id, - is_word_form: true, // Mark this as a word form metric - parent_button_id: btn.id, // Track parent button - parent_button_label: parentMetrics.label, // Track parent label + is_word_form: true, + is_suggest_words: suggestWordsSet.has(wordFormLower) || undefined, + parent_button_id: btn.id, + parent_button_label: parentMetrics.label, }; wordFormMetrics.push(wordFormBtn); diff --git a/src/utilities/analytics/metrics/effort.ts b/src/utilities/analytics/metrics/effort.ts index 90bf6a9..406460e 100644 --- a/src/utilities/analytics/metrics/effort.ts +++ b/src/utilities/analytics/metrics/effort.ts @@ -32,6 +32,7 @@ export const EFFORT_CONSTANTS = { SCAN_SELECTION_COST: 0.1, // Cost of a switch selection DEFAULT_SCAN_ERROR_RATE: 0.1, // 10% chance of missing a selection SCAN_RETRY_PENALTY: 1.0, // Cost multiplier for a full loop retry + SUGGEST_WORDS_SELECTION_EFFORT: 0.5, // Extra tap to confirm a Suggest Words prediction } as const; /** diff --git a/src/utilities/analytics/metrics/types.ts b/src/utilities/analytics/metrics/types.ts index da117e9..3747bc7 100644 --- a/src/utilities/analytics/metrics/types.ts +++ b/src/utilities/analytics/metrics/types.ts @@ -24,6 +24,7 @@ export interface ButtonMetrics { comp_effort?: number; // Comparison: effort in comparison set // Word form metrics (for smart grammar predictions) is_word_form?: boolean; // True if this is a word form from predictions + is_suggest_words?: boolean; // True if produced via Suggest Words (requires extra tap) parent_button_id?: string; // ID of parent button that has these predictions parent_button_label?: string; // Label of parent button pos?: string; // Part-of-speech tag from gridset (e.g., 'Verb', 'Noun') diff --git a/test/suggestWordsEffort.test.ts b/test/suggestWordsEffort.test.ts new file mode 100644 index 0000000..f3a8934 --- /dev/null +++ b/test/suggestWordsEffort.test.ts @@ -0,0 +1,121 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { describe, expect, it } from '@jest/globals'; +import { AACTree, AACPage, AACButton } from '../src/core/treeStructure'; +import { MetricsCalculator } from '../src/utilities/analytics/metrics/core'; +import { EFFORT_CONSTANTS, visualScanEffort } from '../src/utilities/analytics/metrics/effort'; + +function buildTreeWithPredictions( + predictions: string[], + parametersPredictions?: string[], + pos?: string +): { tree: AACTree; btnId: string } { + const tree = new AACTree(); + const page = new AACPage({ + id: 'root', + name: 'Home', + grid: { columns: 2, rows: 2 }, + }); + + const btn = new AACButton({ + id: 'some_btn', + label: 'some', + type: 'SPEAK', + x: 0, + y: 0, + predictions, + pos, + parameters: parametersPredictions ? { predictions: parametersPredictions } : undefined, + }); + + page.grid[0][0] = btn; + page.addButton(btn); + tree.addPage(page); + tree.rootId = 'root'; + + return { tree, btnId: btn.id }; +} + +describe('Suggest Words effort cost', () => { + it('adds confirmation cost to Suggest Words word forms', () => { + const suggestWords = ['something', 'someone', 'somewhere']; + const { tree } = buildTreeWithPredictions(suggestWords, suggestWords); + + const calculator = new MetricsCalculator(); + const result = calculator.analyze(tree, { useSmartGrammar: true }); + + const parentBtn = result.buttons.find((b) => b.label === 'some'); + expect(parentBtn).toBeDefined(); + + const something = result.buttons.find((b) => b.label === 'something'); + expect(something).toBeDefined(); + expect(something!.is_word_form).toBe(true); + expect(something!.is_suggest_words).toBe(true); + + const expectedEffort = + parentBtn!.effort + visualScanEffort(0) + EFFORT_CONSTANTS.SUGGEST_WORDS_SELECTION_EFFORT; + expect(something!.effort).toBeCloseTo(expectedEffort, 4); + }); + + it('does not add confirmation cost to morphology word forms', () => { + const predictions = ['goes', 'going', 'went']; + const { tree } = buildTreeWithPredictions(predictions, undefined, 'Verb'); + + const calculator = new MetricsCalculator(); + const result = calculator.analyze(tree, { useSmartGrammar: true }); + + const parentBtn = result.buttons.find((b) => b.label === 'some'); + expect(parentBtn).toBeDefined(); + + const goes = result.buttons.find((b) => b.label === 'goes'); + expect(goes).toBeDefined(); + expect(goes!.is_word_form).toBe(true); + expect(goes!.is_suggest_words).toBeUndefined(); + + expect(goes!.effort).toBeCloseTo(parentBtn!.effort + visualScanEffort(0), 4); + }); + + it('only adds confirmation to Suggest Words forms when predictions are mixed', () => { + const suggestWordsOriginals = ['something', 'someone']; + const allPredictions = ['something', 'someone', 'somes']; + const { tree } = buildTreeWithPredictions(allPredictions, suggestWordsOriginals, 'Noun'); + + const calculator = new MetricsCalculator(); + const result = calculator.analyze(tree, { useSmartGrammar: true }); + + const parentBtn = result.buttons.find((b) => b.label === 'some'); + + const something = result.buttons.find((b) => b.label === 'something'); + expect(something).toBeDefined(); + expect(something!.is_suggest_words).toBe(true); + expect(something!.effort).toBeCloseTo( + parentBtn!.effort + visualScanEffort(0) + EFFORT_CONSTANTS.SUGGEST_WORDS_SELECTION_EFFORT, + 4 + ); + + // "somes" is at index 2 → predictionPriorItems = 2 + const somes = result.buttons.find((b) => b.label === 'somes'); + expect(somes).toBeDefined(); + expect(somes!.is_suggest_words).toBeUndefined(); + expect(somes!.effort).toBeCloseTo(parentBtn!.effort + visualScanEffort(2), 4); + }); + + it('has no confirmation when parameters.predictions is absent', () => { + const predictions = ['something', 'someone']; + const { tree } = buildTreeWithPredictions(predictions, undefined, 'Noun'); + + const calculator = new MetricsCalculator(); + const result = calculator.analyze(tree, { useSmartGrammar: true }); + + const parentBtn = result.buttons.find((b) => b.label === 'some'); + + const something = result.buttons.find((b) => b.label === 'something'); + expect(something).toBeDefined(); + expect(something!.is_suggest_words).toBeUndefined(); + expect(something!.effort).toBeCloseTo(parentBtn!.effort + visualScanEffort(0), 4); + }); + + it('SUGGEST_WORDS_SELECTION_EFFORT is between 0.5 and 1.0', () => { + expect(EFFORT_CONSTANTS.SUGGEST_WORDS_SELECTION_EFFORT).toBeGreaterThanOrEqual(0.5); + expect(EFFORT_CONSTANTS.SUGGEST_WORDS_SELECTION_EFFORT).toBeLessThanOrEqual(1.0); + }); +});