@willwade/aac-processors 0.2.3 → 0.2.4

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.
Files changed (47) hide show
  1. package/dist/browser/core/treeStructure.js +3 -1
  2. package/dist/browser/metrics.js +2 -0
  3. package/dist/browser/processors/astericsGridProcessor.js +21 -12
  4. package/dist/browser/processors/gridset/helpers.js +3 -3
  5. package/dist/browser/processors/gridsetProcessor.js +16 -8
  6. package/dist/browser/processors/obfProcessor.js +4 -4
  7. package/dist/browser/processors/snap/helpers.js +3 -3
  8. package/dist/browser/processors/snapProcessor.js +2 -2
  9. package/dist/browser/processors/touchchatProcessor.js +6 -6
  10. package/dist/browser/utilities/analytics/metrics/core.js +213 -8
  11. package/dist/browser/utilities/analytics/metrics/vocabulary.js +13 -1
  12. package/dist/browser/utilities/analytics/morphology/engine.js +910 -0
  13. package/dist/browser/utilities/analytics/morphology/grid3VerbsParser.js +455 -0
  14. package/dist/browser/utilities/analytics/morphology/index.js +3 -0
  15. package/dist/browser/utilities/analytics/morphology/types.js +1 -0
  16. package/dist/browser/utilities/analytics/morphology/wordFormGenerator.js +74 -0
  17. package/dist/core/treeStructure.d.ts +17 -1
  18. package/dist/core/treeStructure.js +3 -1
  19. package/dist/index.node.d.ts +2 -0
  20. package/dist/index.node.js +6 -1
  21. package/dist/metrics.d.ts +3 -0
  22. package/dist/metrics.js +5 -1
  23. package/dist/processors/astericsGridProcessor.js +21 -12
  24. package/dist/processors/excelProcessor.js +5 -1
  25. package/dist/processors/gridset/helpers.js +3 -3
  26. package/dist/processors/gridset/imageDebug.js +2 -2
  27. package/dist/processors/gridsetProcessor.js +16 -8
  28. package/dist/processors/obfProcessor.js +4 -4
  29. package/dist/processors/snap/helpers.js +3 -3
  30. package/dist/processors/snapProcessor.js +2 -2
  31. package/dist/processors/touchchatProcessor.js +6 -6
  32. package/dist/utilities/analytics/metrics/core.d.ts +14 -0
  33. package/dist/utilities/analytics/metrics/core.js +213 -8
  34. package/dist/utilities/analytics/metrics/types.d.ts +18 -3
  35. package/dist/utilities/analytics/metrics/vocabulary.d.ts +3 -0
  36. package/dist/utilities/analytics/metrics/vocabulary.js +13 -1
  37. package/dist/utilities/analytics/morphology/engine.d.ts +30 -0
  38. package/dist/utilities/analytics/morphology/engine.js +914 -0
  39. package/dist/utilities/analytics/morphology/grid3VerbsParser.d.ts +36 -0
  40. package/dist/utilities/analytics/morphology/grid3VerbsParser.js +485 -0
  41. package/dist/utilities/analytics/morphology/index.d.ts +5 -0
  42. package/dist/utilities/analytics/morphology/index.js +9 -0
  43. package/dist/utilities/analytics/morphology/types.d.ts +40 -0
  44. package/dist/utilities/analytics/morphology/types.js +2 -0
  45. package/dist/utilities/analytics/morphology/wordFormGenerator.d.ts +10 -0
  46. package/dist/utilities/analytics/morphology/wordFormGenerator.js +78 -0
  47. package/package.json +12 -11
@@ -51,7 +51,7 @@ export var AACScanType;
51
51
  AACScanType["BLOCK_COLUMN_ROW"] = "block-column-row";
52
52
  })(AACScanType || (AACScanType = {}));
53
53
  export class AACButton {
54
- constructor({ id, label = '', message = '', targetPageId, semanticAction, audioRecording, style, contentType, contentSubType, image, resolvedImageEntry, symbolLibrary, symbolPath, x, y, columnSpan, rowSpan, scanBlocks, scanBlock, visibility, directActivate, parameters, predictions, semantic_id, clone_id,
54
+ constructor({ id, label = '', message = '', targetPageId, semanticAction, audioRecording, style, contentType, contentSubType, image, resolvedImageEntry, symbolLibrary, symbolPath, x, y, columnSpan, rowSpan, scanBlocks, scanBlock, visibility, directActivate, parameters, predictions, pos, wordForms, semantic_id, clone_id,
55
55
  // Legacy input support
56
56
  type, action, }) {
57
57
  this.id = id;
@@ -77,6 +77,8 @@ export class AACButton {
77
77
  this.directActivate = directActivate;
78
78
  this.parameters = parameters;
79
79
  this.predictions = predictions;
80
+ this.pos = pos;
81
+ this.wordForms = wordForms;
80
82
  this.semantic_id = semantic_id;
81
83
  this.clone_id = clone_id;
82
84
  // Legacy mapping: if no semanticAction provided, derive from legacy `action` first
@@ -12,6 +12,8 @@ export { MetricsCalculator } from './utilities/analytics/metrics/core';
12
12
  export { VocabularyAnalyzer } from './utilities/analytics/metrics/vocabulary';
13
13
  export { SentenceAnalyzer } from './utilities/analytics/metrics/sentence';
14
14
  export { ComparisonAnalyzer } from './utilities/analytics/metrics/comparison';
15
+ export { MorphologyEngine } from './utilities/analytics/morphology';
16
+ export { WordFormGenerator } from './utilities/analytics/morphology';
15
17
  export { ReferenceLoader } from './utilities/analytics/reference';
16
18
  export { InMemoryReferenceLoader, createBrowserReferenceLoader, loadReferenceDataFromUrl, } from './utilities/analytics/reference/browser';
17
19
  export * from './utilities/analytics/utils/idGenerator';
@@ -605,7 +605,7 @@ class AstericsGridProcessor extends BaseProcessor {
605
605
  });
606
606
  });
607
607
  }
608
- catch (error) {
608
+ catch (_error) {
609
609
  // If JSON parsing fails, return empty array
610
610
  }
611
611
  return texts;
@@ -1021,7 +1021,7 @@ class AstericsGridProcessor extends BaseProcessor {
1021
1021
  // Use detected format for filename
1022
1022
  imageName = element.image.id || `image.${imageFormat}`;
1023
1023
  }
1024
- catch (e) {
1024
+ catch (_e) {
1025
1025
  // Invalid base64 data, skip image
1026
1026
  }
1027
1027
  }
@@ -1034,19 +1034,23 @@ class AstericsGridProcessor extends BaseProcessor {
1034
1034
  audioRecording: audioRecording,
1035
1035
  visibility: mapAstericsVisibility(element.hidden),
1036
1036
  image: imageName, // Store image filename/reference
1037
- parameters: imageData
1038
- ? {
1039
- ...{ imageData: imageData }, // Store actual image data in parameters for conversion
1040
- }
1041
- : undefined,
1042
1037
  style: {
1043
1038
  backgroundColor: finalBackgroundColor,
1044
1039
  borderColor: colorStyles.borderColor || colorConfig?.elementBorderColor || '#CCCCCC',
1045
1040
  borderWidth: colorConfig?.borderWidth || 1,
1046
1041
  fontFamily: colorConfig?.fontFamily || 'Arial',
1047
- fontSize: colorConfig?.fontSizePct ? colorConfig.fontSizePct * 16 : 16, // Default to 16px
1042
+ fontSize: colorConfig?.fontSizePct ? colorConfig.fontSizePct * 16 : 16,
1048
1043
  fontColor: fontColor,
1049
1044
  },
1045
+ wordForms: element.wordForms && element.wordForms.length > 0 ? element.wordForms : undefined,
1046
+ parameters: {
1047
+ ...(imageData ? { imageData: imageData } : {}),
1048
+ ...(element.actions?.some((a) => a.modelName === 'GridActionWordForm')
1049
+ ? {
1050
+ wordFormActions: element.actions.filter((a) => a.modelName === 'GridActionWordForm'),
1051
+ }
1052
+ : {}),
1053
+ },
1050
1054
  });
1051
1055
  }
1052
1056
  async processTexts(filePathOrBuffer, translations, outputPath) {
@@ -1313,6 +1317,11 @@ class AstericsGridProcessor extends BaseProcessor {
1313
1317
  });
1314
1318
  }
1315
1319
  const locale = tree.metadata?.locale || 'en';
1320
+ if (button.parameters?.wordFormActions &&
1321
+ Array.isArray(button.parameters.wordFormActions)) {
1322
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
1323
+ actions.push(...button.parameters.wordFormActions);
1324
+ }
1316
1325
  return {
1317
1326
  id: button.id,
1318
1327
  modelName: 'GridElement',
@@ -1322,7 +1331,7 @@ class AstericsGridProcessor extends BaseProcessor {
1322
1331
  x: calculatedX,
1323
1332
  y: calculatedY,
1324
1333
  label: { [locale]: button.label },
1325
- wordForms: [],
1334
+ wordForms: button.wordForms || [],
1326
1335
  image: {
1327
1336
  data: null,
1328
1337
  author: undefined,
@@ -1414,7 +1423,7 @@ class AstericsGridProcessor extends BaseProcessor {
1414
1423
  audioAction.durationMs = parsedMetadata.durationMs || audioAction.durationMs;
1415
1424
  audioAction.filename = parsedMetadata.filename || audioAction.filename;
1416
1425
  }
1417
- catch (e) {
1426
+ catch (_e) {
1418
1427
  // Use defaults if metadata parsing fails
1419
1428
  }
1420
1429
  }
@@ -1466,7 +1475,7 @@ class AstericsGridProcessor extends BaseProcessor {
1466
1475
  });
1467
1476
  });
1468
1477
  }
1469
- catch (error) {
1478
+ catch (_error) {
1470
1479
  // If JSON parsing fails, return empty array
1471
1480
  }
1472
1481
  return elementIds;
@@ -1491,7 +1500,7 @@ class AstericsGridProcessor extends BaseProcessor {
1491
1500
  }
1492
1501
  }
1493
1502
  }
1494
- catch (error) {
1503
+ catch (_error) {
1495
1504
  // If JSON parsing fails, return false
1496
1505
  }
1497
1506
  return false;
@@ -66,7 +66,7 @@ export async function openImage(gridsetBuffer, entryPath, password = resolveGrid
66
66
  }
67
67
  return data;
68
68
  }
69
- catch (error) {
69
+ catch (_error) {
70
70
  return null;
71
71
  }
72
72
  }
@@ -156,7 +156,7 @@ export function getCommonDocumentsPath() {
156
156
  return match[1].trim();
157
157
  }
158
158
  }
159
- catch (error) {
159
+ catch (_error) {
160
160
  // Registry access failed, fall back to default
161
161
  }
162
162
  // Default fallback path
@@ -212,7 +212,7 @@ export async function findGrid3UserPaths(fileAdapter = defaultFileAdapter) {
212
212
  }
213
213
  }
214
214
  }
215
- catch (error) {
215
+ catch (_error) {
216
216
  // Silently fail if directory access fails
217
217
  }
218
218
  return results;
@@ -462,7 +462,7 @@ class GridsetProcessor extends BaseProcessor {
462
462
  }
463
463
  }
464
464
  }
465
- catch (e) {
465
+ catch (_e) {
466
466
  /* ignore: optional FileMap.xml may be missing or malformed */
467
467
  }
468
468
  // First, load styles from Settings0/Styles/styles.xml (Grid3 format)
@@ -533,7 +533,7 @@ class GridsetProcessor extends BaseProcessor {
533
533
  const normalizedEntry = imageEntry.entryName.replace(/\\/g, '/');
534
534
  imageDataCache.set(normalizedEntry, data);
535
535
  }
536
- catch (err) {
536
+ catch (_err) {
537
537
  // Silently fail - individual image loading failures shouldn't break the entire load
538
538
  }
539
539
  }
@@ -566,7 +566,7 @@ class GridsetProcessor extends BaseProcessor {
566
566
  }
567
567
  }
568
568
  }
569
- catch (e) {
569
+ catch (_e) {
570
570
  // Skip errors in first pass
571
571
  }
572
572
  }
@@ -581,7 +581,7 @@ class GridsetProcessor extends BaseProcessor {
581
581
  xmlContent = decodeText(buffer);
582
582
  console.log(`[Gridset] Raw XML content (first 200 chars) for ${entry.entryName}:`, xmlContent.substring(0, 200));
583
583
  }
584
- catch (e) {
584
+ catch (_e) {
585
585
  // Skip unreadable files
586
586
  continue;
587
587
  }
@@ -940,6 +940,7 @@ class GridsetProcessor extends BaseProcessor {
940
940
  // infer action type implicitly from commands; no explicit enum needed
941
941
  let navigationTarget;
942
942
  let detectedCommands = []; // Store detected command metadata
943
+ let buttonPos; // Part-of-speech from Action.InsertText
943
944
  const commands = content.Commands?.Command || content.commands?.command;
944
945
  let predictionWords;
945
946
  // Resolve image for this cell using FileMap and coordinate heuristics
@@ -1226,8 +1227,11 @@ class GridsetProcessor extends BaseProcessor {
1226
1227
  break;
1227
1228
  }
1228
1229
  case 'Action.InsertText': {
1229
- // speak
1230
1230
  const insertText = getParam('text');
1231
+ const posParam = getParam('pos');
1232
+ if (posParam) {
1233
+ buttonPos = posParam;
1234
+ }
1231
1235
  semanticAction = {
1232
1236
  category: AACSemanticCategory.COMMUNICATION,
1233
1237
  intent: AACSemanticIntent.INSERT_TEXT,
@@ -1235,7 +1239,7 @@ class GridsetProcessor extends BaseProcessor {
1235
1239
  platformData: {
1236
1240
  grid3: {
1237
1241
  commandId,
1238
- parameters: { text: insertText },
1242
+ parameters: { text: insertText, pos: posParam },
1239
1243
  },
1240
1244
  },
1241
1245
  fallback: {
@@ -1454,8 +1458,10 @@ class GridsetProcessor extends BaseProcessor {
1454
1458
  }
1455
1459
  // Extract grammar tags from commands (Smart Grammar)
1456
1460
  const grammar = {};
1461
+ if (buttonPos)
1462
+ grammar.pos = buttonPos;
1457
1463
  detectedCommands.forEach((cmd) => {
1458
- if (cmd.parameters.pos)
1464
+ if (!grammar.pos && cmd.parameters.pos)
1459
1465
  grammar.pos = cmd.parameters.pos;
1460
1466
  if (cmd.parameters.person)
1461
1467
  grammar.person = cmd.parameters.person;
@@ -1465,6 +1471,7 @@ class GridsetProcessor extends BaseProcessor {
1465
1471
  grammar.feature = cmd.parameters.feature;
1466
1472
  });
1467
1473
  const isSmartGrammarCell = Object.keys(grammar).length > 0;
1474
+ const effectivePos = buttonPos || grammar.pos || undefined;
1468
1475
  const button = new AACButton({
1469
1476
  id: `${gridId}_btn_${idx}`,
1470
1477
  label: String(label),
@@ -1502,6 +1509,7 @@ class GridsetProcessor extends BaseProcessor {
1502
1509
  : gridPredictionWords.length > 0
1503
1510
  ? [...gridPredictionWords]
1504
1511
  : undefined,
1512
+ pos: effectivePos,
1505
1513
  parameters: {
1506
1514
  pluginMetadata: pluginMetadata, // Store full plugin metadata for future use
1507
1515
  grid3Commands: detectedCommands, // Store detected command metadata
@@ -1673,7 +1681,7 @@ class GridsetProcessor extends BaseProcessor {
1673
1681
  }
1674
1682
  }
1675
1683
  }
1676
- catch (e) {
1684
+ catch (_e) {
1677
1685
  // If settings.xml parsing fails, tree.rootId will default to first page
1678
1686
  }
1679
1687
  // Set metadata on tree
@@ -48,7 +48,7 @@ class ObfProcessor extends BaseProcessor {
48
48
  return null;
49
49
  }
50
50
  }
51
- catch (err) {
51
+ catch (_err) {
52
52
  continue;
53
53
  }
54
54
  }
@@ -94,7 +94,7 @@ class ObfProcessor extends BaseProcessor {
94
94
  return dataUrl;
95
95
  }
96
96
  }
97
- catch (err) {
97
+ catch (_err) {
98
98
  // Continue to next path
99
99
  continue;
100
100
  }
@@ -318,7 +318,7 @@ class ObfProcessor extends BaseProcessor {
318
318
  return obj;
319
319
  }
320
320
  }
321
- catch (error) {
321
+ catch (_error) {
322
322
  // Log parsing errors for debugging but don't throw
323
323
  }
324
324
  return null;
@@ -725,7 +725,7 @@ class ObfProcessor extends BaseProcessor {
725
725
  // eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-unsafe-return
726
726
  return require('../validation/obfValidator').ObfValidator;
727
727
  }
728
- catch (error) {
728
+ catch (_error) {
729
729
  throw new Error('Validation utilities are not available in this environment.');
730
730
  }
731
731
  }
@@ -24,7 +24,7 @@ async function collectFiles(root, matcher, maxDepth = 3, fileAdapter = defaultFi
24
24
  try {
25
25
  entries = await listDir(current.dir);
26
26
  }
27
- catch (error) {
27
+ catch (_error) {
28
28
  continue;
29
29
  }
30
30
  for (const entry of entries) {
@@ -130,7 +130,7 @@ export async function openImage(dbOrFile, entryPath, fileAdapter = defaultFileAd
130
130
  const dir = dirname(dbPath);
131
131
  await removePath(dir);
132
132
  }
133
- catch (e) {
133
+ catch (_e) {
134
134
  // Ignore cleanup errors
135
135
  }
136
136
  }
@@ -174,7 +174,7 @@ export async function findSnapPackages(packageNamePattern = 'TobiiDynavox', file
174
174
  }
175
175
  }
176
176
  }
177
- catch (error) {
177
+ catch (_error) {
178
178
  // Silently fail if directory access fails
179
179
  }
180
180
  return results;
@@ -215,7 +215,7 @@ class SnapProcessor extends BaseProcessor {
215
215
  try {
216
216
  positions = JSON.parse(sg.SerializedGridPositions);
217
217
  }
218
- catch (e) {
218
+ catch (_e) {
219
219
  // Invalid JSON, skip this group
220
220
  return;
221
221
  }
@@ -436,7 +436,7 @@ class SnapProcessor extends BaseProcessor {
436
436
  }
437
437
  }
438
438
  }
439
- catch (e) {
439
+ catch (_e) {
440
440
  // Ignore JSON parse errors in commands
441
441
  }
442
442
  }
@@ -101,7 +101,7 @@ class TouchChatProcessor extends BaseProcessor {
101
101
  idMappings.set(mapping.numeric_id, mapping.string_id);
102
102
  });
103
103
  }
104
- catch (e) {
104
+ catch (_e) {
105
105
  // No mapping table, use numeric IDs as strings
106
106
  }
107
107
  // Load styles
@@ -119,7 +119,7 @@ class TouchChatProcessor extends BaseProcessor {
119
119
  pageStyles.set(style.id, style);
120
120
  });
121
121
  }
122
- catch (e) {
122
+ catch (_e) {
123
123
  // console.log('No styles found:', e);
124
124
  }
125
125
  // First, load all pages and get their names from resources
@@ -307,7 +307,7 @@ class TouchChatProcessor extends BaseProcessor {
307
307
  }
308
308
  });
309
309
  }
310
- catch (e) {
310
+ catch (_e) {
311
311
  // console.log('No button box cells found:', e);
312
312
  }
313
313
  // Load buttons directly linked to pages via resources
@@ -366,7 +366,7 @@ class TouchChatProcessor extends BaseProcessor {
366
366
  page.addButton(button);
367
367
  });
368
368
  }
369
- catch (e) {
369
+ catch (_e) {
370
370
  // console.log('No direct page buttons found:', e);
371
371
  }
372
372
  // Load navigation actions
@@ -413,7 +413,7 @@ class TouchChatProcessor extends BaseProcessor {
413
413
  }
414
414
  });
415
415
  }
416
- catch (e) {
416
+ catch (_e) {
417
417
  // console.log('No navigation actions found:', e);
418
418
  }
419
419
  // Try to load root ID from multiple sources in order of priority
@@ -454,7 +454,7 @@ class TouchChatProcessor extends BaseProcessor {
454
454
  tree.metadata.defaultHomePageId = rootPageId;
455
455
  }
456
456
  }
457
- catch (e) {
457
+ catch (_e) {
458
458
  // No metadata table or other error, use first page as root
459
459
  if (rootPageId) {
460
460
  tree.rootId = rootPageId;
@@ -9,6 +9,7 @@
9
9
  import { AACSemanticCategory, AACScanType, } from '../../../core/treeStructure';
10
10
  import { CellScanningOrder, ScanningSelectionMethod } from '../../../types/aac';
11
11
  import { baseBoardEffort, distanceEffort, visualScanEffort, EFFORT_CONSTANTS, localScanEffort, scanningEffort, } from './effort';
12
+ import { MorphologyEngine } from '../morphology';
12
13
  export class MetricsCalculator {
13
14
  constructor() {
14
15
  this.locale = 'en';
@@ -80,9 +81,11 @@ export class MetricsCalculator {
80
81
  });
81
82
  // Update buttons using dynamic spelling effort if applicable
82
83
  const buttons = Array.from(knownButtons.values()).sort((a, b) => a.effort - b.effort);
83
- // Calculate metrics for word forms (smart grammar predictions) if enabled
84
- // Default to true if not specified
85
- const useSmartGrammar = options.useSmartGrammar !== false;
84
+ // Expand morphological predictions from POS tags if enabled or auto-detected
85
+ const useSmartGrammar = options.useSmartGrammar === true || this.treeHasPosTags(tree);
86
+ if (useSmartGrammar) {
87
+ this.expandMorphologicalPredictions(tree, options);
88
+ }
86
89
  if (useSmartGrammar) {
87
90
  const { wordFormMetrics, replacedLabels } = this.calculateWordFormMetrics(tree, buttons, options);
88
91
  // Remove buttons that were replaced by lower-effort word forms
@@ -151,13 +154,21 @@ export class MetricsCalculator {
151
154
  }) || null;
152
155
  }
153
156
  if (!spellingPage)
154
- return { spellingPage: null, spellingBaseEffort: 10, spellingAvgLetterEffort: 2.5 };
157
+ return {
158
+ spellingPage: null,
159
+ spellingBaseEffort: 10,
160
+ spellingAvgLetterEffort: 2.5,
161
+ };
155
162
  // Calculate effort to reach this page from root
156
163
  const rootBoard = tree.rootId
157
164
  ? tree.pages[tree.rootId]
158
165
  : Object.values(tree.pages).find((p) => !p.parentId);
159
166
  if (!rootBoard)
160
- return { spellingPage, spellingBaseEffort: 10, spellingAvgLetterEffort: 2.5 };
167
+ return {
168
+ spellingPage,
169
+ spellingBaseEffort: 10,
170
+ spellingAvgLetterEffort: 2.5,
171
+ };
161
172
  // Analyze specifically to find the lowest effort path to the spelling page
162
173
  const result = this.analyzeFrom(tree, rootBoard, setPcts, true, options);
163
174
  const spellingBaseEffort = result.visitedBoardEfforts.get(spellingPage.id) ?? 10;
@@ -571,6 +582,157 @@ export class MetricsCalculator {
571
582
  boardPcts['all'] = totalLinks;
572
583
  return boardPcts;
573
584
  }
585
+ /**
586
+ * Quick check whether any button in the tree has a POS tag.
587
+ * Used to auto-enable smart grammar without requiring explicit opt-in.
588
+ */
589
+ treeHasPosTags(tree) {
590
+ for (const page of Object.values(tree.pages)) {
591
+ for (const row of page.grid) {
592
+ for (const btn of row) {
593
+ if (btn?.pos && btn.pos !== 'Unknown' && btn.pos !== 'Ignore') {
594
+ return true;
595
+ }
596
+ }
597
+ }
598
+ }
599
+ return false;
600
+ }
601
+ /**
602
+ * Expand morphological predictions from POS tags on buttons
603
+ *
604
+ * For each button that has a POS tag (e.g., 'Verb', 'Noun'), use the
605
+ * MorphologyEngine to generate inflected word forms and populate the
606
+ * button's predictions array. This is done as a pre-processing step
607
+ * before calculateWordFormMetrics assigns effort to each form.
608
+ */
609
+ expandMorphologicalPredictions(tree, options) {
610
+ const locale = options.morphologyLocale || 'en-gb';
611
+ const morph = new MorphologyEngine(locale);
612
+ // Words that should never be POS-inferred (function words, determiners, etc.)
613
+ const skipInference = new Set([
614
+ 'a',
615
+ 'an',
616
+ 'the',
617
+ 'to',
618
+ 'in',
619
+ 'on',
620
+ 'at',
621
+ 'of',
622
+ 'for',
623
+ 'and',
624
+ 'or',
625
+ 'but',
626
+ 'not',
627
+ 'no',
628
+ 'yes',
629
+ 'is',
630
+ 'am',
631
+ 'are',
632
+ 'was',
633
+ 'were',
634
+ 'be',
635
+ 'been',
636
+ 'being',
637
+ 'has',
638
+ 'have',
639
+ 'had',
640
+ 'do',
641
+ 'does',
642
+ 'did',
643
+ 'will',
644
+ 'would',
645
+ 'could',
646
+ 'should',
647
+ 'shall',
648
+ 'may',
649
+ 'might',
650
+ 'can',
651
+ 'must',
652
+ 'with',
653
+ 'from',
654
+ 'by',
655
+ 'up',
656
+ 'down',
657
+ 'out',
658
+ 'off',
659
+ 'over',
660
+ 'under',
661
+ 'again',
662
+ 'then',
663
+ 'than',
664
+ 'so',
665
+ 'if',
666
+ 'when',
667
+ 'where',
668
+ 'how',
669
+ 'what',
670
+ 'who',
671
+ 'which',
672
+ 'that',
673
+ 'this',
674
+ 'these',
675
+ 'those',
676
+ 'here',
677
+ 'there',
678
+ 'now',
679
+ 'very',
680
+ 'just',
681
+ 'more',
682
+ 'also',
683
+ 'too',
684
+ 'please',
685
+ 'thank',
686
+ 'hi',
687
+ 'hello',
688
+ 'bye',
689
+ 'goodbye',
690
+ 'okay',
691
+ 'oh',
692
+ 'wow',
693
+ 'sorry',
694
+ ]);
695
+ for (const page of Object.values(tree.pages)) {
696
+ for (const row of page.grid) {
697
+ for (const btn of row) {
698
+ if (!btn || !btn.label)
699
+ continue;
700
+ let pos = btn.pos;
701
+ // If no POS tag (or Unknown/Ignore), attempt POS inference.
702
+ // Many content words on topic pages lack POS tags even though
703
+ // they are clearly nouns (e.g., "bird", "tree", "cloud").
704
+ // Strategy: check irregular tables first for confident POS,
705
+ // then fall back to Noun for single-word content labels.
706
+ if (!pos || pos === 'Unknown' || pos === 'Ignore') {
707
+ const lower = btn.label.toLowerCase();
708
+ // Skip function words and multi-word labels
709
+ if (!skipInference.has(lower) && !lower.includes(' ') && lower.length > 1) {
710
+ // Check irregular tables for confident POS assignment
711
+ const inferredPOS = morph.inferPOS(lower);
712
+ if (inferredPOS) {
713
+ pos = inferredPOS;
714
+ btn.pos = inferredPOS;
715
+ }
716
+ else {
717
+ // Default to Noun for untagged content words.
718
+ // This generates plurals (e.g., bird → birds, tree → trees).
719
+ pos = 'Noun';
720
+ btn.pos = 'Noun';
721
+ }
722
+ }
723
+ }
724
+ if (!pos || pos === 'Unknown' || pos === 'Ignore')
725
+ continue;
726
+ const forms = morph.inflect(btn.label, pos);
727
+ if (forms.length > 0) {
728
+ const existing = btn.predictions || [];
729
+ const merged = new Set([...existing, ...forms]);
730
+ btn.predictions = Array.from(merged);
731
+ }
732
+ }
733
+ }
734
+ }
735
+ }
574
736
  /**
575
737
  * Calculate metrics for word forms (smart grammar predictions)
576
738
  *
@@ -593,14 +755,53 @@ export class MetricsCalculator {
593
755
  // Track buttons by label to compare efforts
594
756
  const existingLabels = new Map();
595
757
  buttons.forEach((btn) => existingLabels.set(btn.label.toLowerCase(), btn));
758
+ // Build a map of POS tags from ALL tree buttons, keyed by lowercase label.
759
+ // This ensures words on BFS-unreachable pages still contribute POS data.
760
+ const treePosMap = new Map();
761
+ const treePredictionsMap = new Map();
762
+ Object.values(tree.pages).forEach((page) => {
763
+ page.grid.forEach((row) => {
764
+ row.forEach((btn) => {
765
+ if (!btn || !btn.label)
766
+ return;
767
+ const lower = btn.label.toLowerCase();
768
+ if (btn.pos && btn.pos !== 'Unknown' && btn.pos !== 'Ignore') {
769
+ treePosMap.set(lower, btn.pos);
770
+ }
771
+ if (btn.predictions && btn.predictions.length > 0) {
772
+ const existing = treePredictionsMap.get(lower);
773
+ if (!existing || btn.predictions.length > existing.length) {
774
+ treePredictionsMap.set(lower, btn.predictions);
775
+ }
776
+ }
777
+ });
778
+ });
779
+ });
780
+ // For metrics buttons that lack POS but have a tree counterpart with POS,
781
+ // propagate the POS tag so it's available in the output.
782
+ buttons.forEach((btn) => {
783
+ const lower = btn.label.toLowerCase();
784
+ if (!btn.pos || btn.pos === 'Unknown' || btn.pos === 'Ignore') {
785
+ const treePos = treePosMap.get(lower);
786
+ if (treePos)
787
+ btn.pos = treePos;
788
+ }
789
+ });
790
+ // Note: buttons on pages unreachable via BFS from the root page are
791
+ // intentionally excluded. If there is no navigation path to a page,
792
+ // those buttons are not accessible to the user and should not count
793
+ // as available vocabulary.
596
794
  // Iterate through all pages to find buttons with predictions
597
795
  Object.values(tree.pages).forEach((page) => {
598
796
  page.grid.forEach((row) => {
599
797
  row.forEach((btn) => {
600
798
  if (!btn || !btn.predictions || btn.predictions.length === 0)
601
799
  return;
602
- // Find the parent button's metrics
603
- const parentMetrics = buttons.find((b) => b.id === btn.id);
800
+ // Find the parent button's metrics (by id first, then by label)
801
+ let parentMetrics = buttons.find((b) => b.id === btn.id);
802
+ if (!parentMetrics && btn.label) {
803
+ parentMetrics = existingLabels.get(btn.label.toLowerCase());
804
+ }
604
805
  if (!parentMetrics)
605
806
  return;
606
807
  // Calculate effort for each word form
@@ -698,7 +899,11 @@ export class MetricsCalculator {
698
899
  // If no block assigned, treat as its own block at the end (fallback)
699
900
  if (blockId === null) {
700
901
  const loop = board.grid.length + (board.grid[0]?.length || 0);
701
- return { steps: rowIndex + colIndex + 1, selections: 1, loopSteps: loop };
902
+ return {
903
+ steps: rowIndex + colIndex + 1,
904
+ selections: 1,
905
+ loopSteps: loop,
906
+ };
702
907
  }
703
908
  const blockConfig = board.scanBlocksConfig?.find((c) => c.id === blockId);
704
909
  const blockOrder = blockConfig?.order ?? blockId;