@willwade/aac-processors 0.0.16 → 0.0.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,6 +5,7 @@
5
5
  * analyze vocabulary differences, and generate CARE component scores.
6
6
  */
7
7
  import { MetricsResult, ComparisonResult } from './types';
8
+ import { MetricsOptions } from './types';
8
9
  export declare class ComparisonAnalyzer {
9
10
  private vocabAnalyzer;
10
11
  private sentenceAnalyzer;
@@ -17,7 +18,7 @@ export declare class ComparisonAnalyzer {
17
18
  compare(targetResult: MetricsResult, compareResult: MetricsResult, options?: {
18
19
  includeSentences?: boolean;
19
20
  locale?: string;
20
- }): ComparisonResult;
21
+ } & Partial<MetricsOptions>): ComparisonResult;
21
22
  /**
22
23
  * Calculate CARE component scores
23
24
  */
@@ -10,6 +10,7 @@ exports.ComparisonAnalyzer = void 0;
10
10
  const sentence_1 = require("./sentence");
11
11
  const vocabulary_1 = require("./vocabulary");
12
12
  const index_1 = require("../reference/index");
13
+ const effort_1 = require("./effort");
13
14
  class ComparisonAnalyzer {
14
15
  constructor() {
15
16
  this.vocabAnalyzer = new vocabulary_1.VocabularyAnalyzer();
@@ -79,7 +80,7 @@ class ComparisonAnalyzer {
79
80
  };
80
81
  });
81
82
  // Calculate CARE components
82
- const careComponents = this.calculateCareComponents(targetResult, compareResult, overlappingWords);
83
+ const careComponents = this.calculateCareComponents(targetResult, compareResult, overlappingWords, options);
83
84
  // Analyze high/low effort words
84
85
  const highEffortWords = [];
85
86
  const lowEffortWords = [];
@@ -216,7 +217,42 @@ class ComparisonAnalyzer {
216
217
  /**
217
218
  * Calculate CARE component scores
218
219
  */
219
- calculateCareComponents(targetResult, compareResult, _overlappingWords) {
220
+ calculateCareComponents(targetResult, compareResult, _overlappingWords, options) {
221
+ // Load common words with baseline efforts (matching Ruby line 527-534)
222
+ const commonWordsData = this.referenceLoader.loadCommonWords();
223
+ const commonWords = new Map();
224
+ commonWordsData.words.forEach((word) => {
225
+ commonWords.set(word.toLowerCase(), commonWordsData.efforts[word] || 0);
226
+ });
227
+ // Determine prediction settings (default: use common words efforts, not prediction)
228
+ const usePrediction = options?.usePrediction || false; // Default FALSE (use common words)
229
+ const predictionSelections = options?.predictionSelections || 1.5;
230
+ const debugMode = process.env.DEBUG_METRICS === 'true';
231
+ // Helper function to calculate fallback effort
232
+ const getFallbackEffort = (word, hasPrediction, spellingBaseEffort) => {
233
+ const wordLower = word.toLowerCase();
234
+ // Check common words efforts first (matching Ruby line 533)
235
+ if (commonWords.has(wordLower)) {
236
+ const effort = commonWords.get(wordLower);
237
+ return effort !== undefined ? effort : (0, effort_1.spellingEffort)(word, 10, 2.5);
238
+ }
239
+ // If usePrediction is true and prediction is available, use prediction
240
+ if (usePrediction && hasPrediction && spellingBaseEffort !== undefined) {
241
+ return (0, effort_1.predictionEffort)(spellingBaseEffort, 2.5, predictionSelections, 2);
242
+ }
243
+ // Fallback to manual spelling (matching Ruby spelling_effort: 10 + word.length * 2.5)
244
+ return (0, effort_1.spellingEffort)(word, 10, 2.5);
245
+ };
246
+ // Debug: Check settings
247
+ const targetHasPrediction = targetResult.has_dynamic_prediction && targetResult.spelling_effort_base !== undefined;
248
+ const _compareHasPrediction = compareResult.has_dynamic_prediction && compareResult.spelling_effort_base !== undefined;
249
+ if (debugMode) {
250
+ console.log(`\nšŸ” DEBUG Fallback Effort Settings:`);
251
+ console.log(` Common words loaded: ${commonWords.size}`);
252
+ console.log(` usePrediction option: ${usePrediction}`);
253
+ console.log(` Target has prediction capability: ${targetHasPrediction}`);
254
+ console.log(` Target spelling_base: ${targetResult.spelling_effort_base?.toFixed(2) || 'undefined'}`);
255
+ }
220
256
  // Create word maps with normalized keys
221
257
  const targetWords = new Map();
222
258
  targetResult.buttons.forEach((btn) => {
@@ -237,62 +273,142 @@ class ComparisonAnalyzer {
237
273
  // Load reference data
238
274
  const coreLists = this.referenceLoader.loadCoreLists();
239
275
  const fringe = this.referenceLoader.loadFringe();
276
+ const commonFringe = this.referenceLoader.loadCommonFringe();
240
277
  const sentences = this.referenceLoader.loadSentences();
241
- // Calculate core coverage
278
+ // Calculate core coverage and effort (matching Ruby lines 609-647)
242
279
  let coreCount = 0;
243
280
  let compCoreCount = 0;
281
+ let targetCoreEffort = 0;
282
+ let compCoreEffort = 0;
244
283
  const allCoreWords = new Set();
245
284
  coreLists.forEach((list) => {
246
285
  list.words.forEach((word) => allCoreWords.add(word.toLowerCase()));
247
286
  });
248
287
  allCoreWords.forEach((word) => {
249
288
  const key = this.normalize(word);
250
- if (targetWords.has(key))
289
+ const targetBtn = targetWords.get(key);
290
+ const compareBtn = compareWords.get(key);
291
+ if (targetBtn) {
251
292
  coreCount++;
252
- if (compareWords.has(key))
293
+ targetCoreEffort += targetBtn.effort;
294
+ }
295
+ else {
296
+ // Fallback to spelling or prediction effort
297
+ targetCoreEffort += getFallbackEffort(word, targetResult.has_dynamic_prediction || false, targetResult.spelling_effort_base);
298
+ }
299
+ if (compareBtn) {
253
300
  compCoreCount++;
301
+ compCoreEffort += compareBtn.effort;
302
+ }
303
+ else {
304
+ compCoreEffort += getFallbackEffort(word, compareResult.has_dynamic_prediction || false, compareResult.spelling_effort_base);
305
+ }
254
306
  });
255
- // Calculate sentence construction effort
256
- let sentenceEffort = 0;
257
- let compSentenceEffort = 0;
258
- let sentenceWordCount = 0;
307
+ const avgCoreEffort = allCoreWords.size > 0 ? targetCoreEffort / allCoreWords.size : 0;
308
+ const avgCompCoreEffort = allCoreWords.size > 0 ? compCoreEffort / allCoreWords.size : 0;
309
+ // Calculate core component scores (matching Ruby lines 644-647)
310
+ const coreScore = avgCoreEffort * 5.0;
311
+ const compCoreScore = avgCompCoreEffort * 5.0;
312
+ // Calculate sentence construction effort (matching Ruby lines 654-668)
313
+ const sentenceEfforts = [];
314
+ const compSentenceEfforts = [];
259
315
  sentences.forEach((words) => {
316
+ let targetSentenceEffort = 0;
317
+ let compSentenceEffort = 0;
260
318
  words.forEach((word) => {
261
319
  const key = this.normalize(word);
262
320
  const targetBtn = targetWords.get(key);
263
321
  const compareBtn = compareWords.get(key);
264
322
  if (targetBtn) {
265
- sentenceEffort += targetBtn.effort;
323
+ targetSentenceEffort += targetBtn.effort;
266
324
  }
267
325
  else {
268
- sentenceEffort += 10 + word.length * 2.5; // Spelling effort
326
+ targetSentenceEffort += getFallbackEffort(word, targetResult.has_dynamic_prediction || false, targetResult.spelling_effort_base);
269
327
  }
270
328
  if (compareBtn) {
271
329
  compSentenceEffort += compareBtn.effort;
272
330
  }
273
331
  else {
274
- compSentenceEffort += 10 + word.length * 2.5;
332
+ compSentenceEffort += getFallbackEffort(word, compareResult.has_dynamic_prediction || false, compareResult.spelling_effort_base);
275
333
  }
276
- sentenceWordCount++;
277
334
  });
335
+ // Average effort per sentence (matching Ruby line 657)
336
+ sentenceEfforts.push(targetSentenceEffort / words.length);
337
+ compSentenceEfforts.push(compSentenceEffort / words.length);
278
338
  });
279
- const avgSentenceEffort = sentenceWordCount > 0 ? sentenceEffort / sentenceWordCount : 0;
280
- const compAvgSentenceEffort = sentenceWordCount > 0 ? compSentenceEffort / sentenceWordCount : 0;
281
- // Calculate fringe coverage
339
+ const avgSentenceEffort = sentenceEfforts.length > 0
340
+ ? sentenceEfforts.reduce((a, b) => a + b, 0) / sentenceEfforts.length
341
+ : 0;
342
+ const compAvgSentenceEffort = compSentenceEfforts.length > 0
343
+ ? compSentenceEfforts.reduce((a, b) => a + b, 0) / compSentenceEfforts.length
344
+ : 0;
345
+ // Sentence component scores (matching Ruby line 665-668)
346
+ const sentenceScore = avgSentenceEffort * 3.0;
347
+ const compSentenceScore = compAvgSentenceEffort * 3.0;
348
+ // Calculate fringe effort (matching Ruby lines 670-687)
349
+ const fringeEfforts = [];
350
+ const compFringeEfforts = [];
282
351
  let fringeCount = 0;
283
352
  let compFringeCount = 0;
284
- let commonFringeCount = 0;
285
353
  fringe.forEach((word) => {
286
354
  const key = this.normalize(word);
287
- const inTarget = targetWords.has(key);
288
- const inCompare = compareWords.has(key);
289
- if (inTarget)
355
+ const targetBtn = targetWords.get(key);
356
+ const compareBtn = compareWords.get(key);
357
+ if (targetBtn) {
358
+ fringeEfforts.push(targetBtn.effort);
290
359
  fringeCount++;
291
- if (inCompare)
360
+ }
361
+ else {
362
+ fringeEfforts.push(getFallbackEffort(word, targetResult.has_dynamic_prediction || false, targetResult.spelling_effort_base));
363
+ }
364
+ if (compareBtn) {
365
+ compFringeEfforts.push(compareBtn.effort);
292
366
  compFringeCount++;
293
- if (inTarget && inCompare)
367
+ }
368
+ else {
369
+ compFringeEfforts.push(getFallbackEffort(word, compareResult.has_dynamic_prediction || false, compareResult.spelling_effort_base));
370
+ }
371
+ });
372
+ const avgFringeEffort = fringeEfforts.length > 0
373
+ ? fringeEfforts.reduce((a, b) => a + b, 0) / fringeEfforts.length
374
+ : 0;
375
+ const avgCompFringeEffort = compFringeEfforts.length > 0
376
+ ? compFringeEfforts.reduce((a, b) => a + b, 0) / compFringeEfforts.length
377
+ : 0;
378
+ // Fringe component scores (matching Ruby line 684-687)
379
+ const fringeScore = avgFringeEffort * 2.0;
380
+ const compFringeScore = avgCompFringeEffort * 2.0;
381
+ // Calculate common fringe effort (matching Ruby lines 689-705)
382
+ const commonFringeEfforts = [];
383
+ const compCommonFringeEfforts = [];
384
+ let commonFringeCount = 0;
385
+ commonFringe.forEach((word) => {
386
+ const key = this.normalize(word);
387
+ const targetBtn = targetWords.get(key);
388
+ const compareBtn = compareWords.get(key);
389
+ if (targetBtn && compareBtn) {
390
+ commonFringeEfforts.push(targetBtn.effort);
391
+ compCommonFringeEfforts.push(compareBtn.effort);
294
392
  commonFringeCount++;
393
+ }
295
394
  });
395
+ const avgCommonFringeEffort = commonFringeEfforts.length > 0
396
+ ? commonFringeEfforts.reduce((a, b) => a + b, 0) / commonFringeEfforts.length
397
+ : 0;
398
+ const avgCompCommonFringeEffort = compCommonFringeEfforts.length > 0
399
+ ? compCommonFringeEfforts.reduce((a, b) => a + b, 0) / compCommonFringeEfforts.length
400
+ : 0;
401
+ // Common fringe component scores (matching Ruby line 702-705)
402
+ const commonFringeScore = avgCommonFringeEffort * 1.0;
403
+ const compCommonFringeScore = avgCompCommonFringeEffort * 1.0;
404
+ // Calculate total CARE effort tally (matching Ruby lines 707-708)
405
+ const PLACEHOLDER = 70;
406
+ const targetEffortTally = coreScore + sentenceScore + fringeScore + commonFringeScore + PLACEHOLDER;
407
+ const compEffortTally = compCoreScore + compSentenceScore + compFringeScore + compCommonFringeScore + PLACEHOLDER;
408
+ // Calculate final CARE scores (matching Ruby line 710-711)
409
+ // res[:target_effort_score] = [0.0, 350.0 - target_effort_tally].max
410
+ const careScore = Math.max(0, 350.0 - targetEffortTally);
411
+ const compCareScore = Math.max(0, 350.0 - compEffortTally);
296
412
  return {
297
413
  core: coreCount,
298
414
  comp_core: compCoreCount,
@@ -302,6 +418,9 @@ class ComparisonAnalyzer {
302
418
  comp_fringe: compFringeCount,
303
419
  common_fringe: commonFringeCount,
304
420
  comp_common_fringe: commonFringeCount,
421
+ // New composite CARE scores
422
+ care_score: careScore,
423
+ comp_care_score: compCareScore,
305
424
  };
306
425
  }
307
426
  /**
@@ -263,7 +263,6 @@ class MetricsCalculator {
263
263
  analyzeFrom(tree, brd, setPcts, _isRoot, options = {}) {
264
264
  const visitedBoardIds = new Map();
265
265
  const visitedBoardEfforts = new Map();
266
- let totalButtons = 0;
267
266
  const toVisit = [
268
267
  {
269
268
  board: brd,
@@ -289,12 +288,6 @@ class MetricsCalculator {
289
288
  visitedBoardEfforts.set(board.id, priorEffort);
290
289
  const rows = board.grid.length;
291
290
  const cols = board.grid[0]?.length || 0;
292
- // Count all non-empty buttons reached in this pageset
293
- board.buttons.forEach((btn) => {
294
- if ((btn.label || '').length > 0) {
295
- totalButtons++;
296
- }
297
- });
298
291
  // Calculate board-level effort
299
292
  // Ruby uses grid size (rows * cols) for field size effort
300
293
  const gridSize = rows * cols;
@@ -540,10 +533,13 @@ class MetricsCalculator {
540
533
  }
541
534
  levels[btn.level].push(btn);
542
535
  });
536
+ // Calculate total_buttons as sum of all button counts (matching Ruby line 136)
537
+ // Ruby: total_buttons: buttons.map{|b| b[:count] || 1}.sum
538
+ const calculatedTotalButtons = buttons.reduce((sum, btn) => sum + (btn.count || 1), 0);
543
539
  return {
544
540
  buttons,
545
541
  levels,
546
- totalButtons,
542
+ totalButtons: calculatedTotalButtons,
547
543
  visitedBoardEfforts,
548
544
  };
549
545
  }
@@ -77,6 +77,21 @@ export declare function distanceEffort(x: number, y: number, entryX?: number, en
77
77
  * @returns Spelling effort score
78
78
  */
79
79
  export declare function spellingEffort(word: string, entryEffort?: number, perLetterEffort?: number): number;
80
+ /**
81
+ * Calculate effort to access a word via prediction
82
+ *
83
+ * When prediction is available, the user:
84
+ * 1. Navigates to the spelling/keyboard page (entryEffort)
85
+ * 2. Types first 1-3 letters to trigger predictions
86
+ * 3. Selects from 1-3 predictions (average selections)
87
+ *
88
+ * @param entryEffort - Effort to reach the spelling/keyboard page
89
+ * @param perLetterEffort - Average effort per letter on the keyboard
90
+ * @param avgSelections - Average number of predictions to check (default 1.5)
91
+ * @param lettersToType - Letters to type before prediction appears (default 2)
92
+ * @returns Prediction effort score
93
+ */
94
+ export declare function predictionEffort(entryEffort?: number, perLetterEffort?: number, avgSelections?: number, lettersToType?: number): number;
80
95
  /**
81
96
  * Calculate base board effort
82
97
  * Combines button size and field size efforts
@@ -13,6 +13,7 @@ exports.fieldSizeEffort = fieldSizeEffort;
13
13
  exports.visualScanEffort = visualScanEffort;
14
14
  exports.distanceEffort = distanceEffort;
15
15
  exports.spellingEffort = spellingEffort;
16
+ exports.predictionEffort = predictionEffort;
16
17
  exports.baseBoardEffort = baseBoardEffort;
17
18
  exports.applyReuseDiscount = applyReuseDiscount;
18
19
  exports.calculateButtonEffort = calculateButtonEffort;
@@ -103,6 +104,26 @@ function distanceEffort(x, y, entryX = 1.0, entryY = 1.0) {
103
104
  function spellingEffort(word, entryEffort = 10, perLetterEffort = 2.5) {
104
105
  return entryEffort + word.length * perLetterEffort;
105
106
  }
107
+ /**
108
+ * Calculate effort to access a word via prediction
109
+ *
110
+ * When prediction is available, the user:
111
+ * 1. Navigates to the spelling/keyboard page (entryEffort)
112
+ * 2. Types first 1-3 letters to trigger predictions
113
+ * 3. Selects from 1-3 predictions (average selections)
114
+ *
115
+ * @param entryEffort - Effort to reach the spelling/keyboard page
116
+ * @param perLetterEffort - Average effort per letter on the keyboard
117
+ * @param avgSelections - Average number of predictions to check (default 1.5)
118
+ * @param lettersToType - Letters to type before prediction appears (default 2)
119
+ * @returns Prediction effort score
120
+ */
121
+ function predictionEffort(entryEffort = 10, perLetterEffort = 2.5, avgSelections = 1.5, lettersToType = 2) {
122
+ // Cost to navigate to keyboard + type first few letters + select from predictions
123
+ const typingCost = lettersToType * perLetterEffort;
124
+ const selectionCost = avgSelections * exports.EFFORT_CONSTANTS.SCAN_SELECTION_COST;
125
+ return entryEffort + typingCost + selectionCost;
126
+ }
106
127
  /**
107
128
  * Calculate base board effort
108
129
  * Combines button size and field size efforts
@@ -94,6 +94,26 @@ export interface MetricsOptions {
94
94
  * Optional explicit ID of the spelling/keyboard page
95
95
  */
96
96
  spellingPageId?: string;
97
+ /**
98
+ * Whether to use prediction for missing words
99
+ *
100
+ * When true (default): Words not in the board are assumed to be accessible
101
+ * via prediction at reduced effort (spelling_page_base + prediction_selection)
102
+ *
103
+ * When false: Words not in the board must be manually spelled at full effort
104
+ * (10 + word_length * 2.5 per letter)
105
+ *
106
+ * Only applies when the board has prediction capability (e.g., SwiftKey)
107
+ */
108
+ usePrediction?: boolean;
109
+ /**
110
+ * Average number of selections to find a word in prediction
111
+ *
112
+ * When prediction is enabled, this estimates how many prediction
113
+ * slots a user needs to check before finding their target word.
114
+ * Default is 1.5 (checking 1-2 predictions on average).
115
+ */
116
+ predictionSelections?: number;
97
117
  }
98
118
  /**
99
119
  * Comparison result between two board sets
@@ -143,6 +163,8 @@ export interface ComparisonResult extends MetricsResult {
143
163
  comp_fringe: number;
144
164
  common_fringe: number;
145
165
  comp_common_fringe: number;
166
+ care_score: number;
167
+ comp_care_score: number;
146
168
  };
147
169
  sentences: SentenceAnalysis[];
148
170
  fringe_words: FringeWord[];