@willwade/aac-processors 0.2.16 → 0.2.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.
Files changed (27) hide show
  1. package/dist/browser/processors/gridset/commands.js +56 -1
  2. package/dist/browser/processors/gridsetProcessor.js +16 -2
  3. package/dist/browser/processors/snapProcessor.js +42 -4
  4. package/dist/browser/utilities/analytics/metrics/core.js +182 -33
  5. package/dist/browser/utilities/analytics/metrics/effort.js +1 -0
  6. package/dist/browser/utilities/analytics/morphology/engine.js +24 -2
  7. package/dist/browser/utilities/analytics/morphology/index.js +1 -0
  8. package/dist/browser/utilities/analytics/morphology/tdsnapLexiconParser.js +182 -0
  9. package/dist/core/treeStructure.d.ts +3 -2
  10. package/dist/index.node.d.ts +1 -0
  11. package/dist/index.node.js +4 -2
  12. package/dist/processors/gridset/commands.js +56 -1
  13. package/dist/processors/gridsetProcessor.js +16 -2
  14. package/dist/processors/snapProcessor.js +42 -4
  15. package/dist/types/aac.d.ts +1 -1
  16. package/dist/utilities/analytics/metrics/core.d.ts +33 -0
  17. package/dist/utilities/analytics/metrics/core.js +182 -33
  18. package/dist/utilities/analytics/metrics/effort.d.ts +1 -0
  19. package/dist/utilities/analytics/metrics/effort.js +1 -0
  20. package/dist/utilities/analytics/metrics/types.d.ts +26 -0
  21. package/dist/utilities/analytics/morphology/engine.d.ts +4 -0
  22. package/dist/utilities/analytics/morphology/engine.js +24 -2
  23. package/dist/utilities/analytics/morphology/index.d.ts +2 -0
  24. package/dist/utilities/analytics/morphology/index.js +3 -1
  25. package/dist/utilities/analytics/morphology/tdsnapLexiconParser.d.ts +28 -0
  26. package/dist/utilities/analytics/morphology/tdsnapLexiconParser.js +186 -0
  27. package/package.json +5 -5
@@ -0,0 +1,182 @@
1
+ export class TDSnapLexiconParser {
2
+ parseDb(dbPath, locale) {
3
+ const detectedLocale = locale || this.inferLocale(dbPath);
4
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
5
+ const Database = require('better-sqlite3');
6
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
7
+ const db = new Database(dbPath, { readonly: true });
8
+ try {
9
+ return this.extractAll(db, detectedLocale);
10
+ }
11
+ finally {
12
+ db.close();
13
+ }
14
+ }
15
+ inferLocale(dbPath) {
16
+ const match = dbPath.match(/lang_([a-z]{2}_[A-Z]{2})/i);
17
+ return match ? match[1] : 'unknown';
18
+ }
19
+ extractAll(db, locale) {
20
+ const words = new Map();
21
+ const subclassCache = new Map();
22
+ const getSubclass = (id) => {
23
+ let name = subclassCache.get(id);
24
+ if (name !== undefined)
25
+ return name;
26
+ const row = db.prepare('SELECT Name FROM PosSubclass WHERE Id = ?').get(id);
27
+ name = row?.Name;
28
+ if (name) {
29
+ subclassCache.set(id, name);
30
+ return name;
31
+ }
32
+ return undefined;
33
+ };
34
+ const allWords = db
35
+ .prepare(`SELECT w.Id as wordId, w.Text as text,
36
+ i.Id as inflectionId, i.LexemeId as lexemeId, i.PosSubclassId as posSubclassId
37
+ FROM Word w
38
+ JOIN Spelling s ON s.WordId = w.Id
39
+ JOIN Inflection i ON i.Id = s.InflectionId
40
+ WHERE i.PosSubclassId != 0
41
+ ORDER BY w.Text`)
42
+ .all();
43
+ const lexemeForms = new Map();
44
+ for (const row of allWords) {
45
+ const tag = getSubclass(row.posSubclassId);
46
+ if (!tag)
47
+ continue;
48
+ let formsByTag = lexemeForms.get(row.lexemeId);
49
+ if (!formsByTag) {
50
+ formsByTag = new Map();
51
+ lexemeForms.set(row.lexemeId, formsByTag);
52
+ }
53
+ const existing = formsByTag.get(tag);
54
+ if (existing) {
55
+ if (!existing.includes(row.text))
56
+ existing.push(row.text);
57
+ }
58
+ else {
59
+ formsByTag.set(tag, [row.text]);
60
+ }
61
+ }
62
+ const wordToLexeme = new Map();
63
+ for (const row of allWords) {
64
+ if (!wordToLexeme.has(row.text.toLowerCase())) {
65
+ wordToLexeme.set(row.text.toLowerCase(), row.lexemeId);
66
+ }
67
+ }
68
+ for (const [text, lexemeId] of wordToLexeme) {
69
+ const formsByTag = lexemeForms.get(lexemeId);
70
+ if (!formsByTag || formsByTag.size === 0)
71
+ continue;
72
+ const forms = [];
73
+ for (const [tag, formTexts] of formsByTag) {
74
+ for (const formText of formTexts) {
75
+ if (formText.toLowerCase() !== text) {
76
+ forms.push({ tag, form: formText });
77
+ }
78
+ }
79
+ }
80
+ if (forms.length > 0) {
81
+ words.set(text, { lexemeId, forms });
82
+ }
83
+ }
84
+ return { locale, words };
85
+ }
86
+ lookupWord(data, word) {
87
+ const entry = data.words.get(word.toLowerCase());
88
+ if (!entry)
89
+ return [];
90
+ return entry.forms.map((f) => f.form);
91
+ }
92
+ lookupWordByTag(data, word, tag) {
93
+ const entry = data.words.get(word.toLowerCase());
94
+ if (!entry)
95
+ return [];
96
+ return entry.forms.filter((f) => f.tag === tag).map((f) => f.form);
97
+ }
98
+ static parseContentTypeHandler(handler) {
99
+ if (!handler)
100
+ return null;
101
+ const colonIdx = handler.indexOf(':');
102
+ if (colonIdx === -1) {
103
+ const parts = handler.split(',');
104
+ return { category: parts[0], subtype: '', params: parts.slice(1) };
105
+ }
106
+ const category = handler.substring(0, colonIdx);
107
+ const rest = handler.substring(colonIdx + 1);
108
+ const commaIdx = rest.indexOf(',');
109
+ if (commaIdx === -1) {
110
+ return { category, subtype: rest, params: [] };
111
+ }
112
+ const subtype = rest.substring(0, commaIdx);
113
+ const paramsStr = rest.substring(commaIdx + 1);
114
+ const params = paramsStr.split(',').map((p) => p.trim());
115
+ return { category, subtype, params };
116
+ }
117
+ static tagToPos(tag) {
118
+ return TDSnapLexiconParser.TAG_TO_POS[tag] || 'Unknown';
119
+ }
120
+ static handlerToPos(handler) {
121
+ const parsed = TDSnapLexiconParser.parseContentTypeHandler(handler);
122
+ if (!parsed)
123
+ return 'Unknown';
124
+ if (parsed.category === 'RESET' || parsed.category === 'SPECIAL')
125
+ return 'Ignore';
126
+ const key = `${parsed.category}:${parsed.subtype}`;
127
+ const tag = TDSnapLexiconParser.HANDLER_TAG_MAP[key];
128
+ if (tag)
129
+ return TDSnapLexiconParser.TAG_TO_POS[tag] || 'Unknown';
130
+ return TDSnapLexiconParser.TAG_TO_POS[parsed.subtype] || 'Unknown';
131
+ }
132
+ }
133
+ TDSnapLexiconParser.TAG_TO_POS = {
134
+ V0: 'Verb',
135
+ VZ: 'Verb',
136
+ VG: 'Verb',
137
+ VD: 'Verb',
138
+ VN: 'Verb',
139
+ SNG: 'Noun',
140
+ PLU: 'Noun',
141
+ ADJ: 'Adjective',
142
+ ADJR: 'Adjective',
143
+ ADJT: 'Adjective',
144
+ ADV: 'Adjective',
145
+ SUB: 'Pronoun',
146
+ OBJ: 'Pronoun',
147
+ POS: 'Pronoun',
148
+ NPOS: 'Pronoun',
149
+ REF: 'Pronoun',
150
+ B0: 'Verb',
151
+ BZ: 'Verb',
152
+ BM: 'Verb',
153
+ BR: 'Verb',
154
+ BDZ: 'Verb',
155
+ BDR: 'Verb',
156
+ BG: 'Verb',
157
+ BN: 'Verb',
158
+ };
159
+ TDSnapLexiconParser.HANDLER_TAG_MAP = {
160
+ 'NOUN:PLU': 'PLU',
161
+ 'DESCRIBE:ADJR': 'ADJR',
162
+ 'DESCRIBE:ADJT': 'ADJT',
163
+ 'DESCRIBE:ADV': 'ADV',
164
+ 'VERB:V0': 'V0',
165
+ 'VERB:VZ': 'VZ',
166
+ 'VERB:VG': 'VG',
167
+ 'VERB:VD': 'VD',
168
+ 'VERB:VN': 'VN',
169
+ 'PRONOUN:SUB': 'SUB',
170
+ 'PRONOUN:OBJ': 'OBJ',
171
+ 'PRONOUN:POS': 'POS',
172
+ 'PRONOUN:NPOS': 'NPOS',
173
+ 'PRONOUN:REF': 'REF',
174
+ 'BE:B0': 'B0',
175
+ 'BE:BZ': 'BZ',
176
+ 'BE:BM': 'BM',
177
+ 'BE:BR': 'BR',
178
+ 'BE:BDZ': 'BDZ',
179
+ 'BE:BDR': 'BDR',
180
+ 'BE:BG': 'BG',
181
+ 'BE:BN': 'BN',
182
+ };
@@ -121,7 +121,7 @@ export declare class AACButton {
121
121
  identifier?: string;
122
122
  metadata?: string;
123
123
  };
124
- contentType?: 'Normal' | 'AutoContent' | 'Workspace' | 'LiveCell';
124
+ contentType?: 'Normal' | 'AutoContent' | 'Workspace' | 'LiveCell' | 'Inflector' | 'Prediction';
125
125
  contentSubType?: string;
126
126
  image?: string;
127
127
  resolvedImageEntry?: string;
@@ -169,7 +169,7 @@ export declare class AACButton {
169
169
  metadata?: string;
170
170
  };
171
171
  style?: AACStyle;
172
- contentType?: 'Normal' | 'AutoContent' | 'Workspace' | 'LiveCell';
172
+ contentType?: 'Normal' | 'AutoContent' | 'Workspace' | 'LiveCell' | 'Inflector' | 'Prediction';
173
173
  contentSubType?: string;
174
174
  image?: string;
175
175
  resolvedImageEntry?: string;
@@ -222,6 +222,7 @@ export declare class AACPage {
222
222
  descriptionHtml?: string;
223
223
  images?: any[];
224
224
  sounds?: any[];
225
+ wordListItems?: import('../types/aac').AACWordListItem[];
225
226
  semantic_ids?: string[];
226
227
  clone_ids?: string[];
227
228
  scanningConfig?: import('../types/aac').ScanningConfig;
@@ -13,6 +13,7 @@ export * as Analytics from './analytics';
13
13
  export * as Validation from './validation';
14
14
  export * as Metrics from './metrics';
15
15
  export { Grid3VerbsParser } from './utilities/analytics/morphology/grid3VerbsParser';
16
+ export { TDSnapLexiconParser } from './utilities/analytics/morphology/tdsnapLexiconParser';
16
17
  export { WordFormGenerator } from './utilities/analytics/morphology/wordFormGenerator';
17
18
  export * as Gridset from './gridset';
18
19
  export * as Snap from './snap';
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || function (mod) {
33
33
  return result;
34
34
  };
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.Translation = exports.AstericsGrid = exports.ApplePanels = exports.Opml = exports.Excel = exports.Dot = exports.TouchChat = exports.Obfset = exports.OBF = exports.Snap = exports.Gridset = exports.WordFormGenerator = exports.Grid3VerbsParser = exports.Metrics = exports.Validation = exports.Analytics = void 0;
36
+ exports.Translation = exports.AstericsGrid = exports.ApplePanels = exports.Opml = exports.Excel = exports.Dot = exports.TouchChat = exports.Obfset = exports.OBF = exports.Snap = exports.Gridset = exports.WordFormGenerator = exports.TDSnapLexiconParser = exports.Grid3VerbsParser = exports.Metrics = exports.Validation = exports.Analytics = void 0;
37
37
  exports.getProcessor = getProcessor;
38
38
  exports.getSupportedExtensions = getSupportedExtensions;
39
39
  exports.isExtensionSupported = isExtensionSupported;
@@ -56,9 +56,11 @@ exports.Analytics = __importStar(require("./analytics"));
56
56
  exports.Validation = __importStar(require("./validation"));
57
57
  // Metrics namespace (pageset analytics)
58
58
  exports.Metrics = __importStar(require("./metrics"));
59
- // Node-only morphology utilities (Grid 3 verbs parser)
59
+ // Node-only morphology utilities (Grid 3 verbs parser, TDSnap lexicon parser)
60
60
  var grid3VerbsParser_1 = require("./utilities/analytics/morphology/grid3VerbsParser");
61
61
  Object.defineProperty(exports, "Grid3VerbsParser", { enumerable: true, get: function () { return grid3VerbsParser_1.Grid3VerbsParser; } });
62
+ var tdsnapLexiconParser_1 = require("./utilities/analytics/morphology/tdsnapLexiconParser");
63
+ Object.defineProperty(exports, "TDSnapLexiconParser", { enumerable: true, get: function () { return tdsnapLexiconParser_1.TDSnapLexiconParser; } });
62
64
  var wordFormGenerator_1 = require("./utilities/analytics/morphology/wordFormGenerator");
63
65
  Object.defineProperty(exports, "WordFormGenerator", { enumerable: true, get: function () { return wordFormGenerator_1.WordFormGenerator; } });
64
66
  // Processor namespaces (platform-specific utilities)
@@ -920,6 +920,61 @@ function getAllPluginIds() {
920
920
  const plugins = new Set(Object.values(exports.GRID3_COMMANDS).map((cmd) => cmd.pluginId));
921
921
  return Array.from(plugins).sort();
922
922
  }
923
+ function textOfStructured(val) {
924
+ if (!val || typeof val !== 'object')
925
+ return undefined;
926
+ const parts = [];
927
+ const processS = (s) => {
928
+ if (!s)
929
+ return;
930
+ if (s.r !== undefined) {
931
+ const rElements = Array.isArray(s.r) ? s.r : [s.r];
932
+ for (const r of rElements) {
933
+ if (typeof r === 'number') {
934
+ if (r !== 0)
935
+ parts.push(String(r));
936
+ continue;
937
+ }
938
+ if (typeof r === 'object' && r !== null) {
939
+ if ('#text' in r)
940
+ parts.push(String(r['#text']));
941
+ else if ('#cdata' in r)
942
+ parts.push(String(r['#cdata']));
943
+ else
944
+ parts.push(String(r));
945
+ }
946
+ else {
947
+ parts.push(String(r));
948
+ }
949
+ }
950
+ }
951
+ };
952
+ if (val.p) {
953
+ const sElements = Array.isArray(val.p.s) ? val.p.s : val.p.s ? [val.p.s] : [];
954
+ sElements.forEach(processS);
955
+ }
956
+ else if (val.s) {
957
+ const sElements = Array.isArray(val.s) ? val.s : [val.s];
958
+ sElements.forEach(processS);
959
+ }
960
+ else if (val.r !== undefined) {
961
+ processS(val);
962
+ }
963
+ return parts.length > 0 ? parts.join('').trim() : undefined;
964
+ }
965
+ function extractParamValue(param) {
966
+ if (typeof param === 'string')
967
+ return param;
968
+ if (param.p || param.s || (param.r !== undefined && typeof param.r !== 'string')) {
969
+ const structured = textOfStructured(param);
970
+ if (structured !== undefined)
971
+ return structured;
972
+ }
973
+ const simple = param['#text'] ?? param.text ?? param.value;
974
+ if (simple !== undefined)
975
+ return simple;
976
+ return textOfStructured(param);
977
+ }
923
978
  function extractCommandParameters(command) {
924
979
  const parameters = {};
925
980
  const params = command.Parameter || command.parameter;
@@ -928,7 +983,7 @@ function extractCommandParameters(command) {
928
983
  const paramArray = Array.isArray(params) ? params : [params];
929
984
  for (const param of paramArray) {
930
985
  const key = param['@_Key'] || param.Key || param.key;
931
- let value = param['#text'] ?? param.text ?? param.value;
986
+ let value = extractParamValue(param);
932
987
  if (key && value !== undefined) {
933
988
  // Try to convert to number if it looks numeric
934
989
  if (typeof value === 'string' && /^\d+$/.test(value)) {
@@ -703,6 +703,13 @@ class GridsetProcessor extends baseProcessor_1.BaseProcessor {
703
703
  }
704
704
  }
705
705
  }
706
+ if (pageWordListItems.length > 0) {
707
+ page.wordListItems = pageWordListItems.map((item) => ({
708
+ text: item.text,
709
+ image: item.image,
710
+ partOfSpeech: item.partOfSpeech,
711
+ }));
712
+ }
706
713
  // Track WordList AutoContent cells and their positions for "more" button placement
707
714
  const wordListAutoContentCells = [];
708
715
  let wordListCellIndex = 0;
@@ -1035,6 +1042,15 @@ class GridsetProcessor extends baseProcessor_1.BaseProcessor {
1035
1042
  const param = getRawParam(key);
1036
1043
  if (param === undefined)
1037
1044
  return undefined;
1045
+ if (typeof param === 'string')
1046
+ return param;
1047
+ if (param.p ||
1048
+ param.s ||
1049
+ (param.r !== undefined && typeof param.r !== 'string')) {
1050
+ const structuredValue = this.textOf(param);
1051
+ if (structuredValue !== undefined)
1052
+ return structuredValue;
1053
+ }
1038
1054
  const simpleValue = param['#text'] ?? param.text ?? param.value;
1039
1055
  if (typeof simpleValue === 'string')
1040
1056
  return simpleValue;
@@ -1043,8 +1059,6 @@ class GridsetProcessor extends baseProcessor_1.BaseProcessor {
1043
1059
  const structuredValue = this.textOf(param);
1044
1060
  if (structuredValue !== undefined)
1045
1061
  return structuredValue;
1046
- if (typeof param === 'string')
1047
- return param;
1048
1062
  return undefined;
1049
1063
  };
1050
1064
  // Skip PredictThis in primary action loop as it was handled in pre-pass
@@ -336,6 +336,9 @@ class SnapProcessor extends baseProcessor_1.BaseProcessor {
336
336
  buttonColumns.has('BackgroundColor') ? 'b.BackgroundColor' : 'NULL AS BackgroundColor',
337
337
  buttonColumns.has('NavigatePageId') ? 'b.NavigatePageId' : 'NULL AS NavigatePageId',
338
338
  buttonColumns.has('ContentType') ? 'b.ContentType' : 'NULL AS ContentType',
339
+ buttonColumns.has('SerializedContentTypeHandler')
340
+ ? 'b.SerializedContentTypeHandler'
341
+ : 'NULL AS SerializedContentTypeHandler',
339
342
  ];
340
343
  if (this.loadAudio) {
341
344
  selectFields.push(buttonColumns.has('MessageRecordingId')
@@ -551,22 +554,54 @@ class SnapProcessor extends baseProcessor_1.BaseProcessor {
551
554
  },
552
555
  };
553
556
  }
557
+ let snapContentType;
558
+ let snapContentSubType;
559
+ let snapGrammarPos;
560
+ let snapGrammarHandler;
561
+ if (btnRow.ContentType === 1) {
562
+ snapContentType = 'AutoContent';
563
+ snapContentSubType = 'Prediction';
564
+ }
565
+ else if (btnRow.ContentType === 3 && btnRow.SerializedContentTypeHandler) {
566
+ snapContentType = 'Inflector';
567
+ snapGrammarHandler = String(btnRow.SerializedContentTypeHandler);
568
+ const colonIdx = snapGrammarHandler.indexOf(':');
569
+ if (colonIdx !== -1) {
570
+ const subtype = snapGrammarHandler.substring(colonIdx + 1).split(',')[0];
571
+ snapContentSubType = subtype;
572
+ const { TDSnapLexiconParser, } = require('../utilities/analytics/morphology/tdsnapLexiconParser');
573
+ snapGrammarPos = TDSnapLexiconParser.tagToPos(subtype);
574
+ }
575
+ }
554
576
  const button = new treeStructure_1.AACButton({
555
577
  id: String(btnRow.Id),
556
578
  label: btnRow.Label || (btnRow.ContentType === 1 ? '[Prediction]' : ''),
557
579
  message: btnRow.Message || (btnRow.ContentType === 1 ? '[Prediction]' : btnRow.Label || ''),
558
580
  targetPageId: targetPageUniqueId,
559
581
  semanticAction: semanticAction,
560
- contentType: btnRow.ContentType === 1 ? 'AutoContent' : undefined,
561
- contentSubType: btnRow.ContentType === 1 ? 'Prediction' : undefined,
582
+ contentType: snapContentType,
583
+ contentSubType: snapContentSubType,
562
584
  audioRecording: audioRecording,
563
585
  visibility: mapSnapVisibility(btnRow.Visible),
564
586
  semantic_id: btnRow.LibrarySymbolId
565
587
  ? `snap_symbol_${btnRow.LibrarySymbolId}`
566
- : undefined, // Extract semantic_id from LibrarySymbolId
588
+ : undefined,
567
589
  image: buttonImage,
568
590
  resolvedImageEntry: buttonImage,
569
- parameters: Object.keys(buttonParameters).length > 0 ? buttonParameters : undefined,
591
+ parameters: {
592
+ ...(Object.keys(buttonParameters).length > 0 ? buttonParameters : {}),
593
+ ...(snapGrammarHandler
594
+ ? {
595
+ grammar: {
596
+ handler: snapGrammarHandler,
597
+ category: snapGrammarHandler.substring(0, snapGrammarHandler.indexOf(':') !== -1
598
+ ? snapGrammarHandler.indexOf(':')
599
+ : snapGrammarHandler.length),
600
+ subtype: snapContentSubType,
601
+ },
602
+ }
603
+ : {}),
604
+ },
570
605
  style: {
571
606
  backgroundColor: btnRow.BackgroundColor
572
607
  ? `#${btnRow.BackgroundColor.toString(16)}`
@@ -579,6 +614,9 @@ class SnapProcessor extends baseProcessor_1.BaseProcessor {
579
614
  fontStyle: btnRow.FontStyle?.toString(),
580
615
  },
581
616
  });
617
+ if (snapGrammarPos) {
618
+ button.pos = snapGrammarPos;
619
+ }
582
620
  // Add to the intended parent page
583
621
  const parentPage = tree.getPage(parentUniqueId);
584
622
  if (parentPage) {
@@ -83,7 +83,7 @@ export interface AACButton {
83
83
  identifier?: string;
84
84
  metadata?: string;
85
85
  };
86
- contentType?: 'Normal' | 'AutoContent' | 'Workspace' | 'LiveCell';
86
+ contentType?: 'Normal' | 'AutoContent' | 'Workspace' | 'LiveCell' | 'Inflector' | 'Prediction';
87
87
  contentSubType?: string;
88
88
  image?: string;
89
89
  resolvedImageEntry?: string;
@@ -42,6 +42,10 @@ export declare class MetricsCalculator {
42
42
  /**
43
43
  * Quick check whether any button in the tree has a POS tag.
44
44
  * Used to auto-enable smart grammar without requiring explicit opt-in.
45
+ *
46
+ * IMPORTANT: Only counts POS from non-Inflector and non-Suffix buttons.
47
+ * TDSnap Inflector buttons and Grid3 Suffix buttons are grammar controls,
48
+ * not content words — they should NOT auto-enable morphology.
45
49
  */
46
50
  private treeHasPosTags;
47
51
  /**
@@ -53,6 +57,35 @@ export declare class MetricsCalculator {
53
57
  * before calculateWordFormMetrics assigns effort to each form.
54
58
  */
55
59
  private expandMorphologicalPredictions;
60
+ /**
61
+ * Expand morphological predictions for Grid3 pagesets.
62
+ *
63
+ * Grid3 uses suffix buttons (pos='Suffix') on the same page as content words.
64
+ * Different pages have different suffix buttons — e.g., topic pages may only
65
+ * have -s (plural), while the Magic Wand page has -s, -er, -est, -ly, -y, -'s.
66
+ *
67
+ * Rules:
68
+ * 1. Build a suffix→formSlot map (-s → plural, -er → comparative, etc.)
69
+ * 2. For each page, collect available suffix buttons
70
+ * 3. Only generate forms for slots that have matching suffix buttons on that page
71
+ * 4. POS inference is used for untagged content words (Grid3 grids often lack POS)
72
+ */
73
+ private expandGrid3Predictions;
74
+ /**
75
+ * Expand morphological predictions for TDSnap pagesets.
76
+ *
77
+ * TDSnap uses Inflector buttons (ContentType=3) on "Word Forms" pages to
78
+ * provide morphology. These pages are loaded dynamically by the runtime,
79
+ * NOT via navigation buttons, so they are unreachable in our tree model.
80
+ *
81
+ * Rules:
82
+ * 1. If the pageset has NO Inflector buttons → no morphology at all
83
+ * 2. Only generate forms whose grammar tag matches an available Inflector
84
+ * (e.g., if there's no -ly Inflector, don't generate "happily")
85
+ * 3. No POS inference — only the lexicon determines which words get forms
86
+ */
87
+ private expandTDSnapPredictions;
88
+ private filterFormsByAvailableTags;
56
89
  /**
57
90
  * Calculate metrics for word forms (smart grammar predictions)
58
91
  *