@willwade/aac-processors 0.1.7 → 0.1.9

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 (39) hide show
  1. package/dist/analytics.d.ts +7 -0
  2. package/dist/analytics.js +23 -0
  3. package/dist/browser/index.browser.js +5 -0
  4. package/dist/browser/metrics.js +17 -0
  5. package/dist/browser/processors/gridset/helpers.js +390 -0
  6. package/dist/browser/processors/gridset/pluginTypes.js +1 -0
  7. package/dist/browser/processors/gridsetProcessor.js +68 -1
  8. package/dist/browser/processors/obfProcessor.js +21 -13
  9. package/dist/browser/processors/snap/helpers.js +252 -0
  10. package/dist/browser/utilities/analytics/history.js +116 -0
  11. package/dist/browser/utilities/analytics/metrics/comparison.js +477 -0
  12. package/dist/browser/utilities/analytics/metrics/core.js +775 -0
  13. package/dist/browser/utilities/analytics/metrics/effort.js +221 -0
  14. package/dist/browser/utilities/analytics/metrics/obl-types.js +6 -0
  15. package/dist/browser/utilities/analytics/metrics/obl.js +282 -0
  16. package/dist/browser/utilities/analytics/metrics/sentence.js +121 -0
  17. package/dist/browser/utilities/analytics/metrics/types.js +6 -0
  18. package/dist/browser/utilities/analytics/metrics/vocabulary.js +138 -0
  19. package/dist/browser/utilities/analytics/reference/browser.js +67 -0
  20. package/dist/browser/utilities/analytics/reference/index.js +129 -0
  21. package/dist/browser/utils/dotnetTicks.js +17 -0
  22. package/dist/index.browser.d.ts +1 -0
  23. package/dist/index.browser.js +18 -1
  24. package/dist/index.node.d.ts +2 -2
  25. package/dist/index.node.js +5 -5
  26. package/dist/metrics.d.ts +17 -0
  27. package/dist/metrics.js +44 -0
  28. package/dist/processors/gridset/pluginTypes.d.ts +1 -0
  29. package/dist/processors/gridset/pluginTypes.js +1 -0
  30. package/dist/processors/gridsetProcessor.js +68 -1
  31. package/dist/processors/obfProcessor.js +21 -13
  32. package/dist/utilities/analytics/metrics/comparison.d.ts +2 -1
  33. package/dist/utilities/analytics/metrics/comparison.js +3 -3
  34. package/dist/utilities/analytics/metrics/vocabulary.d.ts +2 -2
  35. package/dist/utilities/analytics/reference/browser.d.ts +31 -0
  36. package/dist/utilities/analytics/reference/browser.js +73 -0
  37. package/dist/utilities/analytics/reference/index.d.ts +21 -0
  38. package/dist/utilities/analytics/reference/index.js +22 -46
  39. package/package.json +9 -1
@@ -0,0 +1,477 @@
1
+ /**
2
+ * Comparative Analysis
3
+ *
4
+ * Compares two AAC board sets to identify missing/extra words,
5
+ * analyze vocabulary differences, and generate CARE component scores.
6
+ */
7
+ import { SentenceAnalyzer } from './sentence';
8
+ import { VocabularyAnalyzer } from './vocabulary';
9
+ import { ReferenceLoader } from '../reference/index';
10
+ import { spellingEffort, predictionEffort } from './effort';
11
+ export class ComparisonAnalyzer {
12
+ constructor(referenceLoader) {
13
+ this.vocabAnalyzer = new VocabularyAnalyzer(referenceLoader);
14
+ this.sentenceAnalyzer = new SentenceAnalyzer();
15
+ this.referenceLoader = referenceLoader || new ReferenceLoader();
16
+ }
17
+ normalize(word) {
18
+ return word
19
+ .toLowerCase()
20
+ .trim()
21
+ .replace(/[.?!,]/g, '');
22
+ }
23
+ /**
24
+ * Compare two board sets
25
+ */
26
+ compare(targetResult, compareResult, options) {
27
+ // Create base result from target
28
+ const baseResult = { ...targetResult };
29
+ // Create word maps with normalized keys
30
+ const targetWords = new Map();
31
+ targetResult.buttons.forEach((btn) => {
32
+ const key = this.normalize(btn.label);
33
+ const existing = targetWords.get(key);
34
+ if (!existing || btn.effort < existing.effort) {
35
+ targetWords.set(key, btn);
36
+ }
37
+ });
38
+ const compareWords = new Map();
39
+ compareResult.buttons.forEach((btn) => {
40
+ const key = this.normalize(btn.label);
41
+ const existing = compareWords.get(key);
42
+ if (!existing || btn.effort < existing.effort) {
43
+ compareWords.set(key, btn);
44
+ }
45
+ });
46
+ // Find missing/extra/overlapping words
47
+ const missingWords = [];
48
+ const extraWords = [];
49
+ const overlappingWords = [];
50
+ // Words in comparison but not in target
51
+ compareWords.forEach((btn, label) => {
52
+ if (!targetWords.has(label)) {
53
+ missingWords.push(label);
54
+ }
55
+ else {
56
+ overlappingWords.push(label);
57
+ }
58
+ });
59
+ // Words in target but not in comparison
60
+ targetWords.forEach((btn, label) => {
61
+ if (!compareWords.has(label)) {
62
+ extraWords.push(label);
63
+ }
64
+ });
65
+ // Sort alphabetically
66
+ missingWords.sort((a, b) => a.localeCompare(b));
67
+ extraWords.sort((a, b) => a.localeCompare(b));
68
+ overlappingWords.sort((a, b) => a.localeCompare(b));
69
+ // Add comparison metrics to buttons
70
+ const enrichedButtons = targetResult.buttons.map((btn) => {
71
+ const key = this.normalize(btn.label);
72
+ const compBtn = compareWords.get(key);
73
+ return {
74
+ ...btn,
75
+ comp_level: compBtn?.level,
76
+ comp_effort: compBtn?.effort,
77
+ };
78
+ });
79
+ // Calculate CARE components
80
+ const careComponents = this.calculateCareComponents(targetResult, compareResult, overlappingWords, options);
81
+ // Analyze high/low effort words
82
+ const highEffortWords = [];
83
+ const lowEffortWords = [];
84
+ targetWords.forEach((btn, label) => {
85
+ const compBtn = compareWords.get(label);
86
+ const compEffort = compBtn?.effort || 0;
87
+ // Word is harder in target than comparison
88
+ if (compEffort > 0 && btn.effort > compEffort * 1.5) {
89
+ highEffortWords.push(label);
90
+ }
91
+ // Word is easier in target than comparison
92
+ else if (compEffort > 0 && btn.effort < compEffort * 0.67) {
93
+ lowEffortWords.push(label);
94
+ }
95
+ });
96
+ highEffortWords.sort((a, b) => {
97
+ const targetBtnA = targetWords.get(a);
98
+ const targetBtnB = targetWords.get(b);
99
+ const diffA = (targetBtnA?.effort || 0) - (compareWords.get(a)?.effort || 0);
100
+ const diffB = (targetBtnB?.effort || 0) - (compareWords.get(b)?.effort || 0);
101
+ return diffB - diffA;
102
+ });
103
+ lowEffortWords.sort((a, b) => {
104
+ const targetBtnA = targetWords.get(a);
105
+ const targetBtnB = targetWords.get(b);
106
+ const diffA = (compareWords.get(a)?.effort || 0) - (targetBtnA?.effort || 0);
107
+ const diffB = (compareWords.get(b)?.effort || 0) - (targetBtnB?.effort || 0);
108
+ return diffB - diffA;
109
+ });
110
+ // Sentence analysis
111
+ let sentences = [];
112
+ if (options?.includeSentences) {
113
+ const testSentences = this.referenceLoader.loadSentences();
114
+ const targetSentences = this.sentenceAnalyzer.analyzeSentences(targetResult, testSentences);
115
+ const compareSentences = this.sentenceAnalyzer.analyzeSentences(compareResult, testSentences);
116
+ sentences = targetSentences.map((ts, idx) => ({
117
+ sentence: ts.sentence,
118
+ words: ts.words,
119
+ effort: ts.effort,
120
+ typing: ts.typing,
121
+ comp_effort: compareSentences[idx]?.effort || 0,
122
+ comp_typing: compareSentences[idx]?.typing || false,
123
+ }));
124
+ }
125
+ // Core vocabulary analysis
126
+ const coreLists = this.referenceLoader.loadCoreLists();
127
+ const cores = {};
128
+ coreLists.forEach((list) => {
129
+ let targetTotal = 0;
130
+ let compareTotal = 0;
131
+ let targetCovered = 0;
132
+ let compareCovered = 0;
133
+ list.words.forEach((word) => {
134
+ const key = this.normalize(word);
135
+ const targetBtn = targetWords.get(key);
136
+ const compareBtn = compareWords.get(key);
137
+ if (targetBtn) {
138
+ targetCovered++;
139
+ targetTotal += targetBtn.effort;
140
+ }
141
+ if (compareBtn) {
142
+ compareCovered++;
143
+ compareTotal += compareBtn.effort;
144
+ }
145
+ });
146
+ cores[list.id] = {
147
+ name: list.name,
148
+ list: list.words,
149
+ average_effort: targetCovered > 0 ? targetTotal / targetCovered : 0,
150
+ comp_effort: compareCovered > 0 ? compareTotal / compareCovered : 0,
151
+ target_covered: targetCovered,
152
+ compare_covered: compareCovered,
153
+ total_words: list.words.length,
154
+ };
155
+ });
156
+ // Analyze missing from specific lists
157
+ const missing = {};
158
+ coreLists.forEach((list) => {
159
+ const listMissing = [];
160
+ list.words.forEach((word) => {
161
+ const key = this.normalize(word);
162
+ if (!targetWords.has(key)) {
163
+ listMissing.push(word);
164
+ }
165
+ });
166
+ if (listMissing.length > 0) {
167
+ missing[list.id] = {
168
+ name: list.name,
169
+ list: listMissing,
170
+ };
171
+ }
172
+ });
173
+ // Fringe vocabulary analysis
174
+ const fringeWords = this.analyzeFringe(targetWords, compareWords);
175
+ const commonFringeWords = this.analyzeCommonFringe(targetWords, compareWords);
176
+ return {
177
+ ...baseResult,
178
+ buttons: enrichedButtons,
179
+ // Target set metrics
180
+ target_effort_score: this.calculateEffortScore(targetResult),
181
+ // Comparison set metrics
182
+ comp_boards: compareResult.total_boards,
183
+ comp_buttons: compareResult.total_buttons,
184
+ comp_words: compareResult.total_words,
185
+ comp_grid: compareResult.grid,
186
+ comp_effort_score: this.calculateEffortScore(compareResult),
187
+ comp_spelling_effort_base: compareResult.spelling_effort_base,
188
+ comp_spelling_effort_per_letter: compareResult.spelling_effort_per_letter,
189
+ comp_spelling_page_id: compareResult.spelling_page_id,
190
+ has_dynamic_prediction: targetResult.has_dynamic_prediction,
191
+ prediction_page_id: targetResult.prediction_page_id,
192
+ comp_has_dynamic_prediction: compareResult.has_dynamic_prediction,
193
+ comp_prediction_page_id: compareResult.prediction_page_id,
194
+ // Vocabulary comparison
195
+ missing_words: missingWords,
196
+ extra_words: extraWords,
197
+ overlapping_words: overlappingWords,
198
+ // Missing from lists
199
+ missing,
200
+ // High/low effort words
201
+ high_effort_words: highEffortWords.slice(0, 100),
202
+ low_effort_words: lowEffortWords.slice(0, 100),
203
+ // Core analysis
204
+ cores,
205
+ // CARE components
206
+ care_components: careComponents,
207
+ // Sentences
208
+ sentences,
209
+ // Fringe
210
+ fringe_words: fringeWords,
211
+ common_fringe_words: commonFringeWords,
212
+ };
213
+ }
214
+ /**
215
+ * Calculate CARE component scores
216
+ */
217
+ calculateCareComponents(targetResult, compareResult, _overlappingWords, options) {
218
+ // Load common words with baseline efforts (matching Ruby line 527-534)
219
+ const commonWordsData = this.referenceLoader.loadCommonWords();
220
+ const commonWords = new Map();
221
+ commonWordsData.words.forEach((word) => {
222
+ commonWords.set(word.toLowerCase(), commonWordsData.efforts[word] || 0);
223
+ });
224
+ // Determine prediction settings (default: use common words efforts, not prediction)
225
+ const usePrediction = options?.usePrediction || false; // Default FALSE (use common words)
226
+ const predictionSelections = options?.predictionSelections || 1.5;
227
+ const debugMode = process.env.DEBUG_METRICS === 'true';
228
+ // Helper function to calculate fallback effort
229
+ const getFallbackEffort = (word, hasPrediction, spellingBaseEffort) => {
230
+ const wordLower = word.toLowerCase();
231
+ // Check common words efforts first (matching Ruby line 533)
232
+ if (commonWords.has(wordLower)) {
233
+ const effort = commonWords.get(wordLower);
234
+ return effort !== undefined ? effort : spellingEffort(word, 10, 2.5);
235
+ }
236
+ // If usePrediction is true and prediction is available, use prediction
237
+ if (usePrediction && hasPrediction && spellingBaseEffort !== undefined) {
238
+ return predictionEffort(spellingBaseEffort, 2.5, predictionSelections, 2);
239
+ }
240
+ // Fallback to manual spelling (matching Ruby spelling_effort: 10 + word.length * 2.5)
241
+ return spellingEffort(word, 10, 2.5);
242
+ };
243
+ // Debug: Check settings
244
+ const targetHasPrediction = targetResult.has_dynamic_prediction && targetResult.spelling_effort_base !== undefined;
245
+ const _compareHasPrediction = compareResult.has_dynamic_prediction && compareResult.spelling_effort_base !== undefined;
246
+ if (debugMode) {
247
+ console.log(`\n🔍 DEBUG Fallback Effort Settings:`);
248
+ console.log(` Common words loaded: ${commonWords.size}`);
249
+ console.log(` usePrediction option: ${usePrediction}`);
250
+ console.log(` Target has prediction capability: ${targetHasPrediction}`);
251
+ console.log(` Target spelling_base: ${targetResult.spelling_effort_base?.toFixed(2) || 'undefined'}`);
252
+ }
253
+ // Create word maps with normalized keys
254
+ const targetWords = new Map();
255
+ targetResult.buttons.forEach((btn) => {
256
+ const key = this.normalize(btn.label);
257
+ const existing = targetWords.get(key);
258
+ if (!existing || btn.effort < existing.effort) {
259
+ targetWords.set(key, btn);
260
+ }
261
+ });
262
+ const compareWords = new Map();
263
+ compareResult.buttons.forEach((btn) => {
264
+ const key = this.normalize(btn.label);
265
+ const existing = compareWords.get(key);
266
+ if (!existing || btn.effort < existing.effort) {
267
+ compareWords.set(key, btn);
268
+ }
269
+ });
270
+ // Load reference data
271
+ const coreLists = this.referenceLoader.loadCoreLists();
272
+ const fringe = this.referenceLoader.loadFringe();
273
+ const commonFringe = this.referenceLoader.loadCommonFringe();
274
+ const sentences = this.referenceLoader.loadSentences();
275
+ // Calculate core coverage and effort (matching Ruby lines 609-647)
276
+ let coreCount = 0;
277
+ let compCoreCount = 0;
278
+ let targetCoreEffort = 0;
279
+ let compCoreEffort = 0;
280
+ const allCoreWords = new Set();
281
+ coreLists.forEach((list) => {
282
+ list.words.forEach((word) => allCoreWords.add(word.toLowerCase()));
283
+ });
284
+ allCoreWords.forEach((word) => {
285
+ const key = this.normalize(word);
286
+ const targetBtn = targetWords.get(key);
287
+ const compareBtn = compareWords.get(key);
288
+ if (targetBtn) {
289
+ coreCount++;
290
+ targetCoreEffort += targetBtn.effort;
291
+ }
292
+ else {
293
+ // Fallback to spelling or prediction effort
294
+ targetCoreEffort += getFallbackEffort(word, targetResult.has_dynamic_prediction || false, targetResult.spelling_effort_base);
295
+ }
296
+ if (compareBtn) {
297
+ compCoreCount++;
298
+ compCoreEffort += compareBtn.effort;
299
+ }
300
+ else {
301
+ compCoreEffort += getFallbackEffort(word, compareResult.has_dynamic_prediction || false, compareResult.spelling_effort_base);
302
+ }
303
+ });
304
+ const avgCoreEffort = allCoreWords.size > 0 ? targetCoreEffort / allCoreWords.size : 0;
305
+ const avgCompCoreEffort = allCoreWords.size > 0 ? compCoreEffort / allCoreWords.size : 0;
306
+ // Calculate core component scores (matching Ruby lines 644-647)
307
+ const coreScore = avgCoreEffort * 5.0;
308
+ const compCoreScore = avgCompCoreEffort * 5.0;
309
+ // Calculate sentence construction effort (matching Ruby lines 654-668)
310
+ const sentenceEfforts = [];
311
+ const compSentenceEfforts = [];
312
+ sentences.forEach((words) => {
313
+ let targetSentenceEffort = 0;
314
+ let compSentenceEffort = 0;
315
+ words.forEach((word) => {
316
+ const key = this.normalize(word);
317
+ const targetBtn = targetWords.get(key);
318
+ const compareBtn = compareWords.get(key);
319
+ if (targetBtn) {
320
+ targetSentenceEffort += targetBtn.effort;
321
+ }
322
+ else {
323
+ targetSentenceEffort += getFallbackEffort(word, targetResult.has_dynamic_prediction || false, targetResult.spelling_effort_base);
324
+ }
325
+ if (compareBtn) {
326
+ compSentenceEffort += compareBtn.effort;
327
+ }
328
+ else {
329
+ compSentenceEffort += getFallbackEffort(word, compareResult.has_dynamic_prediction || false, compareResult.spelling_effort_base);
330
+ }
331
+ });
332
+ // Average effort per sentence (matching Ruby line 657)
333
+ sentenceEfforts.push(targetSentenceEffort / words.length);
334
+ compSentenceEfforts.push(compSentenceEffort / words.length);
335
+ });
336
+ const avgSentenceEffort = sentenceEfforts.length > 0
337
+ ? sentenceEfforts.reduce((a, b) => a + b, 0) / sentenceEfforts.length
338
+ : 0;
339
+ const compAvgSentenceEffort = compSentenceEfforts.length > 0
340
+ ? compSentenceEfforts.reduce((a, b) => a + b, 0) / compSentenceEfforts.length
341
+ : 0;
342
+ // Sentence component scores (matching Ruby line 665-668)
343
+ const sentenceScore = avgSentenceEffort * 3.0;
344
+ const compSentenceScore = compAvgSentenceEffort * 3.0;
345
+ // Calculate fringe effort (matching Ruby lines 670-687)
346
+ const fringeEfforts = [];
347
+ const compFringeEfforts = [];
348
+ let fringeCount = 0;
349
+ let compFringeCount = 0;
350
+ fringe.forEach((word) => {
351
+ const key = this.normalize(word);
352
+ const targetBtn = targetWords.get(key);
353
+ const compareBtn = compareWords.get(key);
354
+ if (targetBtn) {
355
+ fringeEfforts.push(targetBtn.effort);
356
+ fringeCount++;
357
+ }
358
+ else {
359
+ fringeEfforts.push(getFallbackEffort(word, targetResult.has_dynamic_prediction || false, targetResult.spelling_effort_base));
360
+ }
361
+ if (compareBtn) {
362
+ compFringeEfforts.push(compareBtn.effort);
363
+ compFringeCount++;
364
+ }
365
+ else {
366
+ compFringeEfforts.push(getFallbackEffort(word, compareResult.has_dynamic_prediction || false, compareResult.spelling_effort_base));
367
+ }
368
+ });
369
+ const avgFringeEffort = fringeEfforts.length > 0
370
+ ? fringeEfforts.reduce((a, b) => a + b, 0) / fringeEfforts.length
371
+ : 0;
372
+ const avgCompFringeEffort = compFringeEfforts.length > 0
373
+ ? compFringeEfforts.reduce((a, b) => a + b, 0) / compFringeEfforts.length
374
+ : 0;
375
+ // Fringe component scores (matching Ruby line 684-687)
376
+ const fringeScore = avgFringeEffort * 2.0;
377
+ const compFringeScore = avgCompFringeEffort * 2.0;
378
+ // Calculate common fringe effort (matching Ruby lines 689-705)
379
+ const commonFringeEfforts = [];
380
+ const compCommonFringeEfforts = [];
381
+ let commonFringeCount = 0;
382
+ commonFringe.forEach((word) => {
383
+ const key = this.normalize(word);
384
+ const targetBtn = targetWords.get(key);
385
+ const compareBtn = compareWords.get(key);
386
+ if (targetBtn && compareBtn) {
387
+ commonFringeEfforts.push(targetBtn.effort);
388
+ compCommonFringeEfforts.push(compareBtn.effort);
389
+ commonFringeCount++;
390
+ }
391
+ });
392
+ const avgCommonFringeEffort = commonFringeEfforts.length > 0
393
+ ? commonFringeEfforts.reduce((a, b) => a + b, 0) / commonFringeEfforts.length
394
+ : 0;
395
+ const avgCompCommonFringeEffort = compCommonFringeEfforts.length > 0
396
+ ? compCommonFringeEfforts.reduce((a, b) => a + b, 0) / compCommonFringeEfforts.length
397
+ : 0;
398
+ // Common fringe component scores (matching Ruby line 702-705)
399
+ const commonFringeScore = avgCommonFringeEffort * 1.0;
400
+ const compCommonFringeScore = avgCompCommonFringeEffort * 1.0;
401
+ // Calculate total CARE effort tally (matching Ruby lines 707-708)
402
+ const PLACEHOLDER = 70;
403
+ const targetEffortTally = coreScore + sentenceScore + fringeScore + commonFringeScore + PLACEHOLDER;
404
+ const compEffortTally = compCoreScore + compSentenceScore + compFringeScore + compCommonFringeScore + PLACEHOLDER;
405
+ // Calculate final CARE scores (matching Ruby line 710-711)
406
+ // res[:target_effort_score] = [0.0, 350.0 - target_effort_tally].max
407
+ const careScore = Math.max(0, 350.0 - targetEffortTally);
408
+ const compCareScore = Math.max(0, 350.0 - compEffortTally);
409
+ return {
410
+ core: coreCount,
411
+ comp_core: compCoreCount,
412
+ sentences: avgSentenceEffort,
413
+ comp_sentences: compAvgSentenceEffort,
414
+ fringe: fringeCount,
415
+ comp_fringe: compFringeCount,
416
+ common_fringe: commonFringeCount,
417
+ comp_common_fringe: commonFringeCount,
418
+ // New composite CARE scores
419
+ care_score: careScore,
420
+ comp_care_score: compCareScore,
421
+ };
422
+ }
423
+ /**
424
+ * Analyze fringe vocabulary
425
+ */
426
+ analyzeFringe(targetWords, compareWords) {
427
+ const fringe = this.referenceLoader.loadFringe();
428
+ const result = [];
429
+ fringe.forEach((word) => {
430
+ const key = this.normalize(word);
431
+ const targetBtn = targetWords.get(key);
432
+ const compareBtn = compareWords.get(key);
433
+ if (targetBtn) {
434
+ result.push({
435
+ word,
436
+ effort: targetBtn.effort,
437
+ comp_effort: compareBtn?.effort || 0,
438
+ });
439
+ }
440
+ });
441
+ result.sort((a, b) => a.effort - b.effort);
442
+ return result;
443
+ }
444
+ /**
445
+ * Analyze common fringe vocabulary
446
+ */
447
+ analyzeCommonFringe(targetWords, compareWords) {
448
+ const fringe = this.referenceLoader.loadFringe();
449
+ const result = [];
450
+ fringe.forEach((word) => {
451
+ const key = this.normalize(word);
452
+ const targetBtn = targetWords.get(key);
453
+ const compareBtn = compareWords.get(key);
454
+ if (targetBtn && compareBtn) {
455
+ result.push({
456
+ word,
457
+ effort: targetBtn.effort,
458
+ comp_effort: compareBtn.effort,
459
+ });
460
+ }
461
+ });
462
+ result.sort((a, b) => a.effort - b.effort);
463
+ return result;
464
+ }
465
+ /**
466
+ * Calculate overall effort score for a metrics result
467
+ */
468
+ calculateEffortScore(result) {
469
+ if (result.buttons.length === 0)
470
+ return 0;
471
+ let totalEffort = 0;
472
+ result.buttons.forEach((btn) => {
473
+ totalEffort += btn.effort;
474
+ });
475
+ return totalEffort / result.buttons.length;
476
+ }
477
+ }