@willwade/aac-processors 0.0.15 → 0.0.17

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.
@@ -10,31 +10,40 @@ 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();
16
17
  this.sentenceAnalyzer = new sentence_1.SentenceAnalyzer();
17
18
  this.referenceLoader = new index_1.ReferenceLoader();
18
19
  }
20
+ normalize(word) {
21
+ return word
22
+ .toLowerCase()
23
+ .trim()
24
+ .replace(/[.?!,]/g, '');
25
+ }
19
26
  /**
20
27
  * Compare two board sets
21
28
  */
22
29
  compare(targetResult, compareResult, options) {
23
30
  // Create base result from target
24
31
  const baseResult = { ...targetResult };
25
- // Create word maps
32
+ // Create word maps with normalized keys
26
33
  const targetWords = new Map();
27
34
  targetResult.buttons.forEach((btn) => {
28
- const existing = targetWords.get(btn.label);
35
+ const key = this.normalize(btn.label);
36
+ const existing = targetWords.get(key);
29
37
  if (!existing || btn.effort < existing.effort) {
30
- targetWords.set(btn.label, btn);
38
+ targetWords.set(key, btn);
31
39
  }
32
40
  });
33
41
  const compareWords = new Map();
34
42
  compareResult.buttons.forEach((btn) => {
35
- const existing = compareWords.get(btn.label);
43
+ const key = this.normalize(btn.label);
44
+ const existing = compareWords.get(key);
36
45
  if (!existing || btn.effort < existing.effort) {
37
- compareWords.set(btn.label, btn);
46
+ compareWords.set(key, btn);
38
47
  }
39
48
  });
40
49
  // Find missing/extra/overlapping words
@@ -62,7 +71,8 @@ class ComparisonAnalyzer {
62
71
  overlappingWords.sort((a, b) => a.localeCompare(b));
63
72
  // Add comparison metrics to buttons
64
73
  const enrichedButtons = targetResult.buttons.map((btn) => {
65
- const compBtn = compareWords.get(btn.label);
74
+ const key = this.normalize(btn.label);
75
+ const compBtn = compareWords.get(key);
66
76
  return {
67
77
  ...btn,
68
78
  comp_level: compBtn?.level,
@@ -70,7 +80,7 @@ class ComparisonAnalyzer {
70
80
  };
71
81
  });
72
82
  // Calculate CARE components
73
- const careComponents = this.calculateCareComponents(targetResult, compareResult, overlappingWords);
83
+ const careComponents = this.calculateCareComponents(targetResult, compareResult, overlappingWords, options);
74
84
  // Analyze high/low effort words
75
85
  const highEffortWords = [];
76
86
  const lowEffortWords = [];
@@ -124,8 +134,9 @@ class ComparisonAnalyzer {
124
134
  let targetCovered = 0;
125
135
  let compareCovered = 0;
126
136
  list.words.forEach((word) => {
127
- const targetBtn = targetWords.get(word);
128
- const compareBtn = compareWords.get(word);
137
+ const key = this.normalize(word);
138
+ const targetBtn = targetWords.get(key);
139
+ const compareBtn = compareWords.get(key);
129
140
  if (targetBtn) {
130
141
  targetCovered++;
131
142
  targetTotal += targetBtn.effort;
@@ -140,6 +151,9 @@ class ComparisonAnalyzer {
140
151
  list: list.words,
141
152
  average_effort: targetCovered > 0 ? targetTotal / targetCovered : 0,
142
153
  comp_effort: compareCovered > 0 ? compareTotal / compareCovered : 0,
154
+ target_covered: targetCovered,
155
+ compare_covered: compareCovered,
156
+ total_words: list.words.length,
143
157
  };
144
158
  });
145
159
  // Analyze missing from specific lists
@@ -147,7 +161,8 @@ class ComparisonAnalyzer {
147
161
  coreLists.forEach((list) => {
148
162
  const listMissing = [];
149
163
  list.words.forEach((word) => {
150
- if (!targetWords.has(word)) {
164
+ const key = this.normalize(word);
165
+ if (!targetWords.has(key)) {
151
166
  listMissing.push(word);
152
167
  }
153
168
  });
@@ -172,6 +187,13 @@ class ComparisonAnalyzer {
172
187
  comp_words: compareResult.total_words,
173
188
  comp_grid: compareResult.grid,
174
189
  comp_effort_score: this.calculateEffortScore(compareResult),
190
+ comp_spelling_effort_base: compareResult.spelling_effort_base,
191
+ comp_spelling_effort_per_letter: compareResult.spelling_effort_per_letter,
192
+ comp_spelling_page_id: compareResult.spelling_page_id,
193
+ has_dynamic_prediction: targetResult.has_dynamic_prediction,
194
+ prediction_page_id: targetResult.prediction_page_id,
195
+ comp_has_dynamic_prediction: compareResult.has_dynamic_prediction,
196
+ comp_prediction_page_id: compareResult.prediction_page_id,
175
197
  // Vocabulary comparison
176
198
  missing_words: missingWords,
177
199
  extra_words: extraWords,
@@ -195,78 +217,198 @@ class ComparisonAnalyzer {
195
217
  /**
196
218
  * Calculate CARE component scores
197
219
  */
198
- calculateCareComponents(targetResult, compareResult, _overlappingWords) {
199
- // Create word maps
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
+ }
256
+ // Create word maps with normalized keys
200
257
  const targetWords = new Map();
201
258
  targetResult.buttons.forEach((btn) => {
202
- const existing = targetWords.get(btn.label);
259
+ const key = this.normalize(btn.label);
260
+ const existing = targetWords.get(key);
203
261
  if (!existing || btn.effort < existing.effort) {
204
- targetWords.set(btn.label, btn);
262
+ targetWords.set(key, btn);
205
263
  }
206
264
  });
207
265
  const compareWords = new Map();
208
266
  compareResult.buttons.forEach((btn) => {
209
- const existing = compareWords.get(btn.label);
267
+ const key = this.normalize(btn.label);
268
+ const existing = compareWords.get(key);
210
269
  if (!existing || btn.effort < existing.effort) {
211
- compareWords.set(btn.label, btn);
270
+ compareWords.set(key, btn);
212
271
  }
213
272
  });
214
273
  // Load reference data
215
274
  const coreLists = this.referenceLoader.loadCoreLists();
216
275
  const fringe = this.referenceLoader.loadFringe();
276
+ const commonFringe = this.referenceLoader.loadCommonFringe();
217
277
  const sentences = this.referenceLoader.loadSentences();
218
- // Calculate core coverage
278
+ // Calculate core coverage and effort (matching Ruby lines 609-647)
219
279
  let coreCount = 0;
220
280
  let compCoreCount = 0;
281
+ let targetCoreEffort = 0;
282
+ let compCoreEffort = 0;
221
283
  const allCoreWords = new Set();
222
284
  coreLists.forEach((list) => {
223
285
  list.words.forEach((word) => allCoreWords.add(word.toLowerCase()));
224
286
  });
225
287
  allCoreWords.forEach((word) => {
226
- if (targetWords.has(word))
288
+ const key = this.normalize(word);
289
+ const targetBtn = targetWords.get(key);
290
+ const compareBtn = compareWords.get(key);
291
+ if (targetBtn) {
227
292
  coreCount++;
228
- if (compareWords.has(word))
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) {
229
300
  compCoreCount++;
301
+ compCoreEffort += compareBtn.effort;
302
+ }
303
+ else {
304
+ compCoreEffort += getFallbackEffort(word, compareResult.has_dynamic_prediction || false, compareResult.spelling_effort_base);
305
+ }
230
306
  });
231
- // Calculate sentence construction effort
232
- let sentenceEffort = 0;
233
- let compSentenceEffort = 0;
234
- 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 = [];
235
315
  sentences.forEach((words) => {
316
+ let targetSentenceEffort = 0;
317
+ let compSentenceEffort = 0;
236
318
  words.forEach((word) => {
237
- const targetBtn = targetWords.get(word);
238
- const compareBtn = compareWords.get(word);
319
+ const key = this.normalize(word);
320
+ const targetBtn = targetWords.get(key);
321
+ const compareBtn = compareWords.get(key);
239
322
  if (targetBtn) {
240
- sentenceEffort += targetBtn.effort;
323
+ targetSentenceEffort += targetBtn.effort;
241
324
  }
242
325
  else {
243
- sentenceEffort += 10 + word.length * 2.5; // Spelling effort
326
+ targetSentenceEffort += getFallbackEffort(word, targetResult.has_dynamic_prediction || false, targetResult.spelling_effort_base);
244
327
  }
245
328
  if (compareBtn) {
246
329
  compSentenceEffort += compareBtn.effort;
247
330
  }
248
331
  else {
249
- compSentenceEffort += 10 + word.length * 2.5;
332
+ compSentenceEffort += getFallbackEffort(word, compareResult.has_dynamic_prediction || false, compareResult.spelling_effort_base);
250
333
  }
251
- sentenceWordCount++;
252
334
  });
335
+ // Average effort per sentence (matching Ruby line 657)
336
+ sentenceEfforts.push(targetSentenceEffort / words.length);
337
+ compSentenceEfforts.push(compSentenceEffort / words.length);
253
338
  });
254
- const avgSentenceEffort = sentenceWordCount > 0 ? sentenceEffort / sentenceWordCount : 0;
255
- const compAvgSentenceEffort = sentenceWordCount > 0 ? compSentenceEffort / sentenceWordCount : 0;
256
- // 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 = [];
257
351
  let fringeCount = 0;
258
352
  let compFringeCount = 0;
259
- let commonFringeCount = 0;
260
353
  fringe.forEach((word) => {
261
- const inTarget = targetWords.has(word);
262
- const inCompare = compareWords.has(word);
263
- if (inTarget)
354
+ const key = this.normalize(word);
355
+ const targetBtn = targetWords.get(key);
356
+ const compareBtn = compareWords.get(key);
357
+ if (targetBtn) {
358
+ fringeEfforts.push(targetBtn.effort);
264
359
  fringeCount++;
265
- 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);
266
366
  compFringeCount++;
267
- 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);
268
392
  commonFringeCount++;
393
+ }
269
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);
270
412
  return {
271
413
  core: coreCount,
272
414
  comp_core: compCoreCount,
@@ -276,6 +418,9 @@ class ComparisonAnalyzer {
276
418
  comp_fringe: compFringeCount,
277
419
  common_fringe: commonFringeCount,
278
420
  comp_common_fringe: commonFringeCount,
421
+ // New composite CARE scores
422
+ care_score: careScore,
423
+ comp_care_score: compCareScore,
279
424
  };
280
425
  }
281
426
  /**
@@ -285,8 +430,9 @@ class ComparisonAnalyzer {
285
430
  const fringe = this.referenceLoader.loadFringe();
286
431
  const result = [];
287
432
  fringe.forEach((word) => {
288
- const targetBtn = targetWords.get(word);
289
- const compareBtn = compareWords.get(word);
433
+ const key = this.normalize(word);
434
+ const targetBtn = targetWords.get(key);
435
+ const compareBtn = compareWords.get(key);
290
436
  if (targetBtn) {
291
437
  result.push({
292
438
  word,
@@ -305,8 +451,9 @@ class ComparisonAnalyzer {
305
451
  const fringe = this.referenceLoader.loadFringe();
306
452
  const result = [];
307
453
  fringe.forEach((word) => {
308
- const targetBtn = targetWords.get(word);
309
- const compareBtn = compareWords.get(word);
454
+ const key = this.normalize(word);
455
+ const targetBtn = targetWords.get(key);
456
+ const compareBtn = compareWords.get(key);
310
457
  if (targetBtn && compareBtn) {
311
458
  result.push({
312
459
  word,
@@ -7,16 +7,21 @@
7
7
  * Based on: aac-metrics/lib/aac-metrics/metrics.rb
8
8
  */
9
9
  import { AACTree } from '../../../core/treeStructure';
10
- import { MetricsResult } from './types';
10
+ import { MetricsOptions, MetricsResult } from './types';
11
11
  export declare class MetricsCalculator {
12
12
  private locale;
13
13
  /**
14
14
  * Main analysis function - calculates metrics for an AAC tree
15
15
  *
16
16
  * @param tree - The AAC tree to analyze
17
+ * @param options - Optional configuration for metrics calculation
17
18
  * @returns Complete metrics result
18
19
  */
19
- analyze(tree: AACTree): MetricsResult;
20
+ analyze(tree: AACTree, options?: MetricsOptions): MetricsResult;
21
+ /**
22
+ * Identify keyboard/spelling page and calculate base/avg effort
23
+ */
24
+ private identifySpellingMetrics;
20
25
  /**
21
26
  * Build reference maps for semantic_id and clone_id frequencies
22
27
  */