Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 21 additions & 5 deletions src/utilities/analytics/metrics/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>(
((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();
Expand All @@ -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);
Expand All @@ -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);
Expand Down
1 change: 1 addition & 0 deletions src/utilities/analytics/metrics/effort.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down
1 change: 1 addition & 0 deletions src/utilities/analytics/metrics/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
121 changes: 121 additions & 0 deletions test/suggestWordsEffort.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading