calliope-ts 0.0.1

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 (50) hide show
  1. package/.claude/settings.local.json +8 -0
  2. package/.opencode/skills/calliope-ts/SKILL.md +74 -0
  3. package/LICENSE +201 -0
  4. package/README.md +485 -0
  5. package/dist/depfix.d.ts +13 -0
  6. package/dist/depfix.d.ts.map +1 -0
  7. package/dist/depfix.js +84 -0
  8. package/dist/display.d.ts +38 -0
  9. package/dist/display.d.ts.map +1 -0
  10. package/dist/display.js +890 -0
  11. package/dist/index.d.ts +50 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +504 -0
  14. package/dist/parser.d.ts +10 -0
  15. package/dist/parser.d.ts.map +1 -0
  16. package/dist/parser.js +688 -0
  17. package/dist/phonological.d.ts +41 -0
  18. package/dist/phonological.d.ts.map +1 -0
  19. package/dist/phonological.js +788 -0
  20. package/dist/rhyme.d.ts +26 -0
  21. package/dist/rhyme.d.ts.map +1 -0
  22. package/dist/rhyme.js +340 -0
  23. package/dist/scandroid.d.ts +17 -0
  24. package/dist/scandroid.d.ts.map +1 -0
  25. package/dist/scandroid.js +435 -0
  26. package/dist/scansion.d.ts +37 -0
  27. package/dist/scansion.d.ts.map +1 -0
  28. package/dist/scansion.js +1007 -0
  29. package/dist/scansion_debug.js +586 -0
  30. package/dist/stress.d.ts +32 -0
  31. package/dist/stress.d.ts.map +1 -0
  32. package/dist/stress.js +1372 -0
  33. package/dist/tagfix.d.ts +6 -0
  34. package/dist/tagfix.d.ts.map +1 -0
  35. package/dist/tagfix.js +101 -0
  36. package/dist/types.d.ts +173 -0
  37. package/dist/types.d.ts.map +1 -0
  38. package/dist/types.js +4 -0
  39. package/package.json +62 -0
  40. package/src/depfix.ts +88 -0
  41. package/src/display.ts +954 -0
  42. package/src/index.ts +541 -0
  43. package/src/parser.ts +837 -0
  44. package/src/phonological.ts +849 -0
  45. package/src/rhyme.ts +328 -0
  46. package/src/scandroid.ts +434 -0
  47. package/src/scansion.ts +1053 -0
  48. package/src/stress.ts +1381 -0
  49. package/src/tagfix.ts +104 -0
  50. package/src/types.ts +230 -0
package/src/display.ts ADDED
@@ -0,0 +1,954 @@
1
+ // display.ts — Unified, integrated CLI display for Calliope TS
2
+ // Shows ALL information layers in a single comprehensive view
3
+
4
+ import chalk from 'chalk';
5
+ import {
6
+ ClsWord,
7
+ ClsSentence,
8
+ IntonationalUnit,
9
+ PhonologicalPhrase,
10
+ CliticGroup,
11
+ StressLevel,
12
+ LineResult,
13
+ SyllableDisplayEntry,
14
+ MeterScore,
15
+ } from './types.js';
16
+ import { isPunctuation } from './parser.js';
17
+ import { syllabifyWord, syllableVowelLengths } from './phonological.js';
18
+
19
+ // ═══════════════════════════════════════════════════════════════════════
20
+ // COLOUR SYSTEM — Conceptually motivated palettes
21
+ // ═══════════════════════════════════════════════════════════════════════
22
+
23
+ // Lexical stress (numeric 0–3): blue→magenta→red→bold red
24
+ // Represents phonetic prominence from dictionary
25
+ const LEX0 = (s: string) => chalk.blue(s);
26
+ const LEX1 = (s: string) => chalk.magenta(s);
27
+ const LEX2 = (s: string) => chalk.red(s);
28
+ const LEX3 = (s: string) => chalk.red.bold(s);
29
+
30
+ function lexColour(val: number): (s: string) => string {
31
+ if (val === 0) return LEX0;
32
+ if (val === 1) return LEX1;
33
+ if (val === 2) return LEX2;
34
+ return LEX3;
35
+ }
36
+
37
+ // Relative / phonological stress (x w n m s): light-grey→cyan→green→yellow→bright red
38
+ // Represents phonological prominence after phrasal rules. `x` = zero-provision
39
+ // (maximally-reduced clitic), one rung below the stressless-overt floor `w`.
40
+ // Light grey (not dark blue) so it stays legible on a black terminal.
41
+ const REL_X = (s: string) => chalk.hex('#b0b0b0')(s);
42
+ const REL_W = (s: string) => chalk.cyan(s);
43
+ const REL_N = (s: string) => chalk.green(s);
44
+ const REL_M = (s: string) => chalk.yellow(s);
45
+ const REL_S = (s: string) => chalk.redBright(s);
46
+
47
+ function relColour(rel: StressLevel): (s: string) => string {
48
+ if (rel === 'x') return REL_X;
49
+ if (rel === 'w') return REL_W;
50
+ if (rel === 'n') return REL_N;
51
+ if (rel === 'm') return REL_M;
52
+ if (rel === 's') return REL_S;
53
+ return chalk.gray.dim;
54
+ }
55
+
56
+ // Phrasal boundaries — distinct palette (purple/blue/green)
57
+ const B_CP = chalk.magentaBright;
58
+ const B_PP = chalk.blueBright;
59
+ const B_IU = chalk.greenBright;
60
+ const B_CAESURA = chalk.whiteBright.bold; // hard caesura (overt: punctuation / IU edge)
61
+ const B_CAESURA_SOFT = chalk.cyan.dim; // inferred caesura (phonological-phrase pause)
62
+ const B_FOOT = chalk.gray;
63
+ const B_SILENT = chalk.gray.dim;
64
+
65
+ // Word roles
66
+ const W_CONTENT = chalk.white;
67
+ const W_FUNCTION = chalk.gray;
68
+ const W_DEP = chalk.italic.dim;
69
+
70
+ // Section headers
71
+ const H1 = chalk.bold.underline;
72
+ const H2 = chalk.bold;
73
+
74
+ const HR = '─'.repeat(70);
75
+ const HR_THIN = '─'.repeat(50);
76
+
77
+ // ═══════════════════════════════════════════════════════════════════════
78
+ // PER-SYLLABLE DATA STRUCTURE
79
+ // ═══════════════════════════════════════════════════════════════════════
80
+
81
+ interface ColSyl {
82
+ chunk: string;
83
+ word: string;
84
+ pos: string;
85
+ isContent: boolean;
86
+ lexStress: number;
87
+ relStress: StressLevel;
88
+ cpId: number;
89
+ ppId: number;
90
+ iuId: number;
91
+ isFirstInWord: boolean;
92
+ isFirstInCP: boolean;
93
+ isFirstInPP: boolean;
94
+ isFirstInIU: boolean;
95
+ isLastInCP: boolean;
96
+ isLastInPP: boolean;
97
+ isLastInIU: boolean;
98
+ depLabel: string;
99
+ govWord: string;
100
+ globalIdx: number;
101
+ wordRef: ClsWord;
102
+ }
103
+
104
+ function buildColSyls(words: ClsWord[], ius: IntonationalUnit[]): ColSyl[] {
105
+ const result: ColSyl[] = [];
106
+ let globalIdx = 0;
107
+
108
+ for (let iuIdx = 0; iuIdx < ius.length; iuIdx++) {
109
+ const iu = ius[iuIdx];
110
+ for (let ppIdx = 0; ppIdx < iu.phonologicalPhrases.length; ppIdx++) {
111
+ const pp = iu.phonologicalPhrases[ppIdx];
112
+ for (let cpIdx = 0; cpIdx < pp.cliticGroups.length; cpIdx++) {
113
+ const cg = pp.cliticGroups[cpIdx];
114
+ for (let tIdx = 0; tIdx < cg.tokens.length; tIdx++) {
115
+ const w = cg.tokens[tIdx];
116
+ if (isPunctuation(w.lexicalClass)) continue;
117
+ const dep = w.dependency;
118
+ const sylCount = w.syllables.length;
119
+ const chunks = syllabifyWord(w.word, sylCount, syllableVowelLengths(w.syllables), w.morphSuffix);
120
+
121
+ for (let si = 0; si < sylCount; si++) {
122
+ const syl = w.syllables[si];
123
+ const lex = syl.lexicalStress ?? syl.stress;
124
+ const rel = syl.relativeStress ?? 'w';
125
+
126
+ result.push({
127
+ chunk: chunks[si] || w.word,
128
+ word: w.word,
129
+ pos: w.lexicalClass,
130
+ isContent: w.isContent,
131
+ lexStress: lex,
132
+ relStress: rel,
133
+ cpId: cpIdx,
134
+ ppId: ppIdx,
135
+ iuId: iuIdx,
136
+ isFirstInWord: si === 0,
137
+ isFirstInCP: tIdx === 0 && si === 0,
138
+ isFirstInPP: cpIdx === 0 && tIdx === 0 && si === 0,
139
+ isFirstInIU: ppIdx === 0 && cpIdx === 0 && tIdx === 0 && si === 0,
140
+ isLastInCP: tIdx === cg.tokens.length - 1 && si === sylCount - 1,
141
+ isLastInPP: cpIdx === pp.cliticGroups.length - 1 &&
142
+ tIdx === cg.tokens.length - 1 && si === sylCount - 1,
143
+ isLastInIU: ppIdx === iu.phonologicalPhrases.length - 1 &&
144
+ cpIdx === pp.cliticGroups.length - 1 &&
145
+ tIdx === cg.tokens.length - 1 && si === sylCount - 1,
146
+ depLabel: dep?.dependentType ?? '',
147
+ govWord: dep?.governor?.word ?? '',
148
+ globalIdx: globalIdx++,
149
+ wordRef: w,
150
+ });
151
+ }
152
+ }
153
+ }
154
+ }
155
+ }
156
+
157
+ return result;
158
+ }
159
+
160
+ // ═══════════════════════════════════════════════════════════════════════
161
+ // UNIFIED DISPLAY — All layers integrated
162
+ // ═══════════════════════════════════════════════════════════════════════
163
+
164
+ export function renderUnifiedDisplay(result: LineResult, rawLine?: string): string {
165
+ const words = result.sentence.words;
166
+ const ius = result.phonologicalHierarchy;
167
+ const detail = result.phonologicalScansion;
168
+ const colSyls = buildColSyls(words, ius);
169
+
170
+ const lines: string[] = [];
171
+ lines.push('');
172
+ lines.push(HR);
173
+
174
+ // ── Layer 1: Original text with word-role coloring ──────────────
175
+ lines.push(H1('Original Text'));
176
+ lines.push('');
177
+ const textParts: string[] = [];
178
+ for (const w of words) {
179
+ if (isPunctuation(w.lexicalClass)) continue;
180
+ const wc = w.isContent ? W_CONTENT : W_FUNCTION;
181
+ const posTag = W_DEP('(' + w.lexicalClass + ')');
182
+ textParts.push(wc(w.word) + posTag);
183
+ }
184
+ lines.push(' ' + textParts.join(' '));
185
+ lines.push('');
186
+
187
+ // ── Layer 2: Phrasal structure tree ─────────────────────────────
188
+ lines.push(H1('Phrasal Structure') + ' ' + B_IU('IU') + ' → ' + B_PP('PP') + ' → ' + B_CP('CP'));
189
+ // Mini-legend: only the POS tags & dependencies that occur in THIS line.
190
+ lines.push(...renderLineGlossary(words));
191
+ lines.push('');
192
+
193
+ const wordSet = new Set<ClsWord>();
194
+ const dedupedEntries: { col: ColSyl; word: ClsWord }[] = [];
195
+ for (const cs of colSyls) {
196
+ if (!wordSet.has(cs.wordRef)) {
197
+ wordSet.add(cs.wordRef);
198
+ dedupedEntries.push({ col: cs, word: cs.wordRef });
199
+ }
200
+ }
201
+
202
+ let lastIU = -1, lastPP = -1;
203
+ for (const we of dedupedEntries) {
204
+ const cs = we.col;
205
+ if (cs.iuId !== lastIU) {
206
+ lines.push(B_IU(' IU' + (cs.iuId + 1)));
207
+ lastIU = cs.iuId;
208
+ lastPP = -1;
209
+ }
210
+ if (cs.ppId !== lastPP) {
211
+ lines.push(B_PP(' PP' + (cs.ppId + 1) + ': {'));
212
+ lastPP = cs.ppId;
213
+ }
214
+ const dep = we.word.dependency;
215
+ const depInfo = dep && dep.governorIndex > 0
216
+ ? W_DEP(' ←' + dep.dependentType)
217
+ : '';
218
+ const wordLabel = W_CONTENT(we.word.word) + W_DEP('(' + we.word.lexicalClass + ')');
219
+ lines.push(' ' + B_CP('[') + wordLabel + depInfo + B_CP(']'));
220
+ }
221
+ lines.push(' ' + B_PP('}'));
222
+ lines.push('');
223
+
224
+ // ── Layer 3: Lexical stress (numeric) ───────────────────────────
225
+ lines.push(H1('Lexical Stress') + ' ' + LEX0('0') + LEX1('1') + LEX2('2') + LEX3('3') + ' (0=none 1=secondary 2=primary 3+=boosted)');
226
+ lines.push('');
227
+
228
+ const lexParts: string[] = [];
229
+ for (const cs of colSyls) {
230
+ if (cs.isFirstInWord && cs.globalIdx > 0) lexParts.push(' ');
231
+ lexParts.push(lexColour(cs.lexStress)(String(cs.lexStress)));
232
+ }
233
+ lines.push(' ' + lexParts.join(''));
234
+ lines.push('');
235
+
236
+ // ── Layer 4: Relative stress (w/n/m/s) ──────────────────────────
237
+ lines.push(H1('Relative Stress') + ' ' + REL_X('x') + REL_W('w') + REL_N('n') + REL_M('m') + REL_S('s') + ' (zero‑provision→weak→low→moderate→strong)');
238
+ lines.push('');
239
+
240
+ const relParts: string[] = [];
241
+ for (const cs of colSyls) {
242
+ if (cs.isFirstInWord && cs.globalIdx > 0) relParts.push(' ');
243
+ relParts.push(relColour(cs.relStress)(cs.relStress));
244
+ }
245
+ lines.push(' ' + relParts.join(''));
246
+ lines.push('');
247
+
248
+ // ── Layer 5: Phonological bracketing ────────────────────────────
249
+ lines.push(H1('Phonological Bracketing') + ' ' + B_CP('[]') + ' CP ' + B_PP('{}') + ' PP ' + B_IU('<>') + ' IU');
250
+ lines.push('');
251
+
252
+ const sylParts: string[] = [];
253
+ let iuOpen = false, ppOpen = false, cpOpen = false;
254
+ for (const cs of colSyls) {
255
+ if (cs.isFirstInIU && !iuOpen) { sylParts.push(B_IU('<')); iuOpen = true; }
256
+ if (cs.isFirstInPP && !ppOpen) { sylParts.push(B_PP('{')); ppOpen = true; }
257
+ if (cs.isFirstInCP && !cpOpen) { sylParts.push(B_CP('[')); cpOpen = true; }
258
+
259
+ if (cs.isFirstInWord && cs.globalIdx > 0) sylParts.push(' ');
260
+ sylParts.push(relColour(cs.relStress)(cs.chunk));
261
+
262
+ if (cs.isLastInCP && cpOpen) { sylParts.push(B_CP(']')); cpOpen = false; }
263
+ if (cs.isLastInPP && ppOpen) { sylParts.push(B_PP('}')); ppOpen = false; }
264
+ if (cs.isLastInIU && iuOpen) { sylParts.push(B_IU('>')); iuOpen = false; }
265
+ }
266
+ lines.push(' ' + sylParts.join(''));
267
+ lines.push('');
268
+
269
+ // ── Layer 6: Metrical scansion with caesura ─────────────────────
270
+ lines.push(H1('Metrical Scansion'));
271
+ lines.push('');
272
+
273
+ const scansion = detail.scansion;
274
+ const feetRaw = scansion.split('|');
275
+
276
+ interface LinearSyl {
277
+ chunk: string;
278
+ relStress: StressLevel;
279
+ wordRef: ClsWord;
280
+ }
281
+ const linearSyls: LinearSyl[] = [];
282
+ for (const w of words) {
283
+ if (isPunctuation(w.lexicalClass)) continue;
284
+ const sylCount = w.syllables.length;
285
+ const chunks = syllabifyWord(w.word, sylCount, syllableVowelLengths(w.syllables), w.morphSuffix);
286
+ for (let si = 0; si < sylCount; si++) {
287
+ linearSyls.push({
288
+ chunk: chunks[si] || w.word,
289
+ relStress: w.syllables[si].relativeStress ?? 'w',
290
+ wordRef: w,
291
+ });
292
+ }
293
+ }
294
+
295
+ // Caesurae: hard at IU/punctuation breaks, plus one inferred (soft) medial
296
+ // caesura at a phonological-phrase boundary for a punctuation-free line.
297
+ const caesurae = computeCaesurae(words, ius, scansion);
298
+
299
+ function isSyllableChar(ch: string): boolean {
300
+ return 'xXwWnNmMsS'.includes(ch);
301
+ }
302
+
303
+ let sylIdx = 0;
304
+ const footDisplays: string[] = [];
305
+ let prevWordRef: ClsWord | null = null;
306
+ for (const rawFoot of feetRaw) {
307
+ let footOut = '';
308
+ for (const ch of rawFoot) {
309
+ if (ch === '-') {
310
+ footOut += B_SILENT('·');
311
+ continue;
312
+ }
313
+ if (!isSyllableChar(ch)) continue;
314
+ if (sylIdx < linearSyls.length) {
315
+ const ls = linearSyls[sylIdx];
316
+ if (prevWordRef !== null && ls.wordRef !== prevWordRef) footOut += ' ';
317
+ footOut += relColour(ls.relStress)(ls.chunk);
318
+ prevWordRef = ls.wordRef;
319
+ sylIdx++;
320
+ }
321
+ }
322
+ const ck = caesurae.get(sylIdx); if (ck) footOut += ' ' + caesuraGlyph(ck);
323
+ footDisplays.push(footOut);
324
+ }
325
+ lines.push(' ' + H2('Feet: ') + footDisplays.join(B_FOOT(' | ')));
326
+
327
+ const stressDisplays: string[] = [];
328
+ let rIdx = 0;
329
+ for (const rawFoot of feetRaw) {
330
+ let s = '';
331
+ for (const ch of rawFoot) {
332
+ if (ch === '-') { s += B_SILENT('_'); continue; }
333
+ if (!isSyllableChar(ch)) continue;
334
+ if (rIdx < linearSyls.length) {
335
+ s += relColour(linearSyls[rIdx].relStress)(linearSyls[rIdx].relStress);
336
+ rIdx++;
337
+ }
338
+ }
339
+ const ck2 = caesurae.get(rIdx); if (ck2) s += ' ' + caesuraGlyph(ck2);
340
+ stressDisplays.push(s);
341
+ }
342
+ lines.push(' ' + H2('Stress: ') + stressDisplays.join(B_FOOT(' | ')));
343
+ lines.push('');
344
+
345
+ // ── Layer 7: Dependencies ───────────────────────────────────────
346
+ lines.push(H1('Dependencies'));
347
+ lines.push('');
348
+ for (const we of dedupedEntries) {
349
+ const w = we.word;
350
+ if (isPunctuation(w.lexicalClass)) continue;
351
+ const dep = w.dependency;
352
+ if (!dep) continue;
353
+ if (dep.governorIndex === 0 || dep.dependentType === 'root') {
354
+ lines.push(' ' + B_IU('ROOT →') + ' ' + W_CONTENT(w.word));
355
+ } else {
356
+ lines.push(' ' +
357
+ W_FUNCTION(w.word.padEnd(12)) +
358
+ W_DEP('←' + dep.dependentType + '← ') +
359
+ W_CONTENT(dep.governorName)
360
+ );
361
+ }
362
+ }
363
+ lines.push('');
364
+
365
+ // ── Layer 8: Summary ────────────────────────────────────────────
366
+ lines.push(H1('Summary'));
367
+ lines.push('');
368
+ lines.push(' ' + H2('Meter: ') + detail.meter + chalk.dim(' (' + detail.footCount + ' feet)') + consensusNote(detail) + rhythmNoteStr(detail));
369
+ const rank = formatRanking(detail.ranking);
370
+ lines.push(' ' + H2('Fit: ') + chalk.yellow(detail.certainty + '%') + (rank ? ' ' + rank : ''));
371
+ lines.push(' ' + H2('Scansion: ') + detail.scansion);
372
+ lines.push(' ' + H2('Summary: ') + detail.summary);
373
+ lines.push('');
374
+
375
+ // ── Layer 9: Scandroid comparison (if available) ────────────────
376
+ if (result.scandroidCorral || result.scandroidMaximise) {
377
+ lines.push(H1('Scandroid Comparison'));
378
+ lines.push('');
379
+ if (result.scandroidCorral) {
380
+ lines.push(' ' + H2('CW: ') + result.scandroidCorral.scansion);
381
+ }
382
+ if (result.scandroidMaximise) {
383
+ lines.push(' ' + H2('MN: ') + result.scandroidMaximise.scansion);
384
+ }
385
+ lines.push('');
386
+ }
387
+
388
+ // ── Layer 10: Reading projection (stress gradient over the input) ──
389
+ // A reading-view-style colourisation of the verbatim input, so the finalised
390
+ // analysis always shows "something that looks like the input". Falls back to
391
+ // the parsed surface forms when the raw line wasn't supplied.
392
+ lines.push(H1('Reading Projection') + chalk.dim(' — stress gradient over the input'));
393
+ lines.push('');
394
+ const projection = rawLine && rawLine.trim()
395
+ ? projectStressOntoLine(rawLine, words)
396
+ : words.filter(w => !isPunctuation(w.lexicalClass)).map(w => colourToken(w.word, w)).join(' ');
397
+ lines.push(' ' + projection);
398
+ lines.push('');
399
+
400
+ // ── Layer 11: Legend ────────────────────────────────────────────
401
+ lines.push(HR_THIN);
402
+ lines.push(renderLegend());
403
+ lines.push(HR);
404
+
405
+ return lines.join('\n');
406
+ }
407
+
408
+ // ═══════════════════════════════════════════════════════════════════════
409
+ // LEGEND
410
+ // ═══════════════════════════════════════════════════════════════════════
411
+
412
+ export function renderLegend(): string {
413
+ return [
414
+ H2('Legend'),
415
+ ' ' + B_CP('[]') + ' Clitic Phrase ' + B_PP('{}') + ' Phonological Phrase ' + B_IU('<>') + ' Intonational Unit',
416
+ ' ' + LEX0('0') + LEX1('1') + LEX2('2') + LEX3('3') + ' Lexical stress (0=none 1=secondary 2=primary 3+=boosted)',
417
+ ' ' + REL_X('x') + REL_W('w') + REL_N('n') + REL_M('m') + REL_S('s') + ' Relative stress (zero‑provision→weak→low→moderate→strong)',
418
+ ' ' + W_CONTENT('content') + ' ' + W_FUNCTION('function') + ' Word class',
419
+ ' ' + B_CAESURA('‖') + ' Caesura (phrasal break) ' + B_CAESURA_SOFT('¦') + ' Inferred caesura ' +
420
+ B_FOOT('|') + ' Foot boundary ' + B_SILENT('·') + ' Silent beat',
421
+ ].join('\n');
422
+ }
423
+
424
+ // ═══════════════════════════════════════════════════════════════════════
425
+ // GLOSSARIES — Penn POS tags & grammatical dependencies
426
+ // (For the long-form Display Legend [menu] and the per-line mini-legend in the
427
+ // detailed views. NOT shown in the compact in-output legend above.)
428
+ // ═══════════════════════════════════════════════════════════════════════
429
+
430
+ interface Gloss { name: string; eg: string; }
431
+
432
+ // Penn Treebank POS tags that FinNLP's en-pos tagger assigns to words (the tags
433
+ // shown as "(TAG)" in the Original Text / Phrasal Structure layers). Pure
434
+ // punctuation/symbol/list tags (, : . ( ) # $ SYM LS) are intentionally omitted —
435
+ // they label no lexical word in the prosodic analysis. Grouped by word class so
436
+ // the distinctions read clearly.
437
+ const POS_GROUPS: { label: string; tags: [string, Gloss][] }[] = [
438
+ { label: 'Nouns', tags: [
439
+ ['NN', { name: 'noun, singular or mass', eg: 'table, water, dust' }],
440
+ ['NNS', { name: 'noun, plural', eg: 'tables, waters' }],
441
+ ['NNP', { name: 'proper noun, singular', eg: 'London, Pound' }],
442
+ ['NNPS', { name: 'proper noun, plural', eg: 'Americans, Smiths' }],
443
+ ]},
444
+ { label: 'Verbs & modals', tags: [
445
+ ['VB', { name: 'verb, base form', eg: 'throw, eat, run' }],
446
+ ['VBD', { name: 'verb, past tense', eg: 'threw, ate, ran' }],
447
+ ['VBG', { name: 'verb, gerund / present part.', eg: 'throwing, eating' }],
448
+ ['VBN', { name: 'verb, past participle', eg: 'thrown, eaten' }],
449
+ ['VBP', { name: 'verb, non-3rd-sg present', eg: '(I) throw, run' }],
450
+ ['VBZ', { name: 'verb, 3rd-sg present', eg: 'throws, runs' }],
451
+ ['MD', { name: 'modal', eg: 'can, will, must' }],
452
+ ]},
453
+ { label: 'Adjectives & adverbs', tags: [
454
+ ['JJ', { name: 'adjective', eg: 'green, large' }],
455
+ ['JJR', { name: 'adjective, comparative', eg: 'greener, larger' }],
456
+ ['JJS', { name: 'adjective, superlative', eg: 'greenest, largest' }],
457
+ ['RB', { name: 'adverb', eg: 'quickly, very' }],
458
+ ['RBR', { name: 'adverb, comparative', eg: 'faster, better' }],
459
+ ['RBS', { name: 'adverb, superlative', eg: 'fastest, best' }],
460
+ ]},
461
+ { label: 'Determiners & numbers', tags: [
462
+ ['DT', { name: 'determiner', eg: 'the, a, an' }],
463
+ ['PDT', { name: 'predeterminer', eg: 'all (the books), both' }],
464
+ ['CD', { name: 'cardinal number', eg: 'one, two, three' }],
465
+ ]},
466
+ { label: 'Pronouns', tags: [
467
+ ['PRP', { name: 'personal pronoun', eg: 'I, you, he, they' }],
468
+ ['PRP$', { name: 'possessive pronoun', eg: 'my, your, their' }],
469
+ ]},
470
+ { label: 'Wh-words', tags: [
471
+ ['WDT', { name: 'wh-determiner', eg: 'which, that' }],
472
+ ['WP', { name: 'wh-pronoun', eg: 'who, what' }],
473
+ ['WP$', { name: 'possessive wh-pronoun', eg: 'whose' }],
474
+ ['WRB', { name: 'wh-adverb', eg: 'when, where, why' }],
475
+ ]},
476
+ { label: 'Function & other', tags: [
477
+ ['IN', { name: 'preposition / subord. conj.', eg: 'in, of, although' }],
478
+ ['TO', { name: 'infinitival "to"', eg: 'to (go)' }],
479
+ ['CC', { name: 'coordinating conjunction', eg: 'and, but, or' }],
480
+ ['RP', { name: 'particle', eg: 'up (give up), off' }],
481
+ ['EX', { name: 'existential "there"', eg: 'there (is)' }],
482
+ ['POS', { name: 'possessive ending', eg: "'s, '" }],
483
+ ['UH', { name: 'interjection', eg: 'oh, wow, ah' }],
484
+ ['FW', { name: 'foreign word', eg: 'je ne sais quoi' }],
485
+ ]},
486
+ ];
487
+
488
+ // Grammatical dependency relations AS THE TOOLKIT DISPLAYS THEM (the lowercase
489
+ // labels shown as "←label", after FinNLP's relations are mapped to the
490
+ // Antelope/Universal-Dependencies scheme in parser.ts). Grouped by role.
491
+ const DEP_GROUPS: { label: string; deps: [string, Gloss][] }[] = [
492
+ { label: 'Core arguments', deps: [
493
+ ['nsubj', { name: 'nominal subject', eg: 'I like you' }],
494
+ ['nsubjpass', { name: 'nominal subject (passive)', eg: 'I was given a chance' }],
495
+ ['dobj', { name: 'direct object', eg: 'I like you' }],
496
+ ['iobj', { name: 'indirect object', eg: 'she gave me a book' }],
497
+ ['pobj', { name: 'object of preposition (oblique)', eg: 'to the children' }],
498
+ ]},
499
+ { label: 'Clausal relations', deps: [
500
+ ['ccomp', { name: 'clausal complement', eg: 'ordered to dig' }],
501
+ ['xcomp', { name: 'open clausal complement', eg: 'told us to dig' }],
502
+ ['advcl', { name: 'adverbial clause modifier', eg: 'walking as rain fell' }],
503
+ ['acl', { name: 'clausal modifier of a noun', eg: 'the man you love' }],
504
+ ]},
505
+ { label: 'Modifiers', deps: [
506
+ ['amod', { name: 'adjectival modifier', eg: 'good to him' }],
507
+ ['advmod', { name: 'adverbial modifier', eg: 'genetically modified' }],
508
+ ['nummod', { name: 'numeric modifier', eg: '2 eggs' }],
509
+ ['nmod', { name: 'nominal modifier', eg: 'news of the market' }],
510
+ ['poss', { name: 'possessive / nominal mod.', eg: "Senka's match" }],
511
+ ['det', { name: 'determiner', eg: 'the book' }],
512
+ ]},
513
+ { label: 'Function & markers', deps: [
514
+ ['prep', { name: 'case / preposition marker', eg: 'went to Rome' }],
515
+ ['aux', { name: 'auxiliary', eg: 'am going' }],
516
+ ['auxpass', { name: 'auxiliary (passive)', eg: 'have been marked' }],
517
+ ['cc', { name: 'coordinating conjunction', eg: 'Matt and Alex' }],
518
+ ['mark', { name: 'clause / complement marker', eg: 'if I like it' }],
519
+ ['prt', { name: 'verb particle', eg: 'switched it off' }],
520
+ ['expl', { name: 'expletive', eg: 'there is' }],
521
+ ['discourse', { name: 'discourse element', eg: 'I like that :)' }],
522
+ ['intj', { name: 'interjection', eg: 'pass it, please' }],
523
+ ]},
524
+ { label: 'Other', deps: [
525
+ ['root', { name: 'root (head of the sentence)', eg: 'the main predicate' }],
526
+ ['dep', { name: 'unspecified dependency', eg: '(unresolved)' }],
527
+ ['punct', { name: 'punctuation', eg: 'Guys, calm!' }],
528
+ ]},
529
+ ];
530
+
531
+ // Flat lookups (used by the per-line mini-legend).
532
+ const POS_GLOSS: Record<string, Gloss> = Object.fromEntries(POS_GROUPS.flatMap(g => g.tags));
533
+ const DEP_GLOSS: Record<string, Gloss> = Object.fromEntries(DEP_GROUPS.flatMap(g => g.deps));
534
+
535
+ /** A glossary row, padded on the RAW strings (so chalk colour codes don't skew
536
+ * alignment). `tagWidth` is sized to the widest tag in the table. */
537
+ function glossRow(tag: string, g: Gloss, tagWidth: number): string {
538
+ return ' ' + chalk.cyan(tag.padEnd(tagWidth)) + W_CONTENT(g.name.padEnd(32)) + chalk.dim('e.g. ' + g.eg);
539
+ }
540
+
541
+ /**
542
+ * The long-form legend triggered from the main menu's "Display Legend" option:
543
+ * the compact legend PLUS full Penn POS-tag and grammatical-dependency glossaries.
544
+ * (These glossaries are deliberately NOT part of the compact in-output legend.)
545
+ */
546
+ export function renderFullLegend(): string {
547
+ const out: string[] = [];
548
+ out.push(renderLegend());
549
+ out.push('');
550
+ out.push(HR_THIN);
551
+ out.push(H1('Part-of-Speech Tags') + chalk.dim(' — Penn Treebank, as tagged by en-pos'));
552
+ const posWidth = Math.max(...POS_GROUPS.flatMap(g => g.tags.map(([t]) => t.length))) + 2;
553
+ for (const grp of POS_GROUPS) {
554
+ out.push('');
555
+ out.push(' ' + H2(grp.label));
556
+ for (const [tag, g] of grp.tags) out.push(glossRow(tag, g, posWidth));
557
+ }
558
+ out.push('');
559
+ out.push(HR_THIN);
560
+ out.push(H1('Grammatical Dependencies') + chalk.dim(' — relation of each word to its governor (←label)'));
561
+ const depWidth = Math.max(...DEP_GROUPS.flatMap(g => g.deps.map(([d]) => d.length))) + 2;
562
+ for (const grp of DEP_GROUPS) {
563
+ out.push('');
564
+ out.push(' ' + H2(grp.label));
565
+ for (const [dep, g] of grp.deps) out.push(glossRow(dep, g, depWidth));
566
+ }
567
+ return out.join('\n');
568
+ }
569
+
570
+ /**
571
+ * A compact per-line mini-legend: only the POS tags and dependency relations that
572
+ * actually occur in THIS line's parse, defined briefly (no examples), for the head
573
+ * of the detailed view's Phrasal Structure section. Fits in one or two lines.
574
+ */
575
+ function renderLineGlossary(words: ClsWord[]): string[] {
576
+ const posSeen: string[] = [];
577
+ const depSeen: string[] = [];
578
+ for (const w of words) {
579
+ if (isPunctuation(w.lexicalClass)) continue;
580
+ if (!posSeen.includes(w.lexicalClass)) posSeen.push(w.lexicalClass);
581
+ const dep = w.dependency;
582
+ if (dep && dep.governorIndex > 0 && dep.dependentType && !depSeen.includes(dep.dependentType)) {
583
+ depSeen.push(dep.dependentType);
584
+ }
585
+ }
586
+ // Concise gloss for the mini-legend: drop the comma/parenthesis qualifier that
587
+ // the full legend carries ("noun, singular or mass" → "noun").
588
+ const brief = (name: string): string => name.split(/,| \(/)[0].trim();
589
+ const out: string[] = [];
590
+ if (posSeen.length) {
591
+ const items = posSeen.map(t => chalk.cyan(t) + chalk.dim('=') + W_FUNCTION(brief(POS_GLOSS[t]?.name ?? t)));
592
+ out.push(' ' + chalk.dim('PoS ') + items.join(chalk.dim(' · ')));
593
+ }
594
+ if (depSeen.length) {
595
+ const items = depSeen.map(d => chalk.cyan(d) + chalk.dim('=') + W_FUNCTION(brief(DEP_GLOSS[d]?.name ?? d)));
596
+ out.push(' ' + chalk.dim('Dep ') + items.join(chalk.dim(' · ')));
597
+ }
598
+ return out;
599
+ }
600
+
601
+ // ═══════════════════════════════════════════════════════════════════════
602
+ // READING VIEW — original formatting, stress-gradient coloured per syllable
603
+ // ═══════════════════════════════════════════════════════════════════════
604
+
605
+ /** One input line with its (1+) parsed sentence results. */
606
+ export interface ReadingLine {
607
+ raw: string; // the original line text, verbatim
608
+ results: LineResult[]; // a line may parse into more than one sentence
609
+ }
610
+
611
+ /** A stanza: a run of consecutive non-blank input lines. */
612
+ export interface ReadingStanza {
613
+ lines: ReadingLine[];
614
+ }
615
+
616
+ /** Surface form reduced to bare lowercase letters (drops apostrophes/hyphens). */
617
+ function normWordForm(s: string): string {
618
+ return s.toLowerCase().replace(/[^a-z]/g, '');
619
+ }
620
+
621
+ /** Colour each orthographic syllable of an original token by its relative stress. */
622
+ function colourToken(tokenText: string, word: ClsWord): string {
623
+ const sylCount = Math.max(1, word.syllables.length);
624
+ const chunks = syllabifyWord(tokenText, sylCount, syllableVowelLengths(word.syllables), word.morphSuffix); // partitions the WHOLE token
625
+ const stresses = chunks.map((_, i) => word.syllables[i]?.relativeStress ?? 'w');
626
+
627
+ // Fast path: chunks reconstruct the token exactly (the common case).
628
+ if (chunks.join('') === tokenText) {
629
+ return chunks.map((c, i) => relColour(stresses[i])(c)).join('');
630
+ }
631
+
632
+ // Fallback: the syllabifier dropped a delimiter (it strips hyphens), so walk
633
+ // the ORIGINAL token char-by-char, assigning each kept char to its syllable
634
+ // by the chunk lengths and emitting dropped hyphens verbatim. Every original
635
+ // character is emitted exactly once, so nothing is ever lost.
636
+ const lens = chunks.map(c => c.length);
637
+ let out = '';
638
+ let ci = 0;
639
+ let consumed = 0;
640
+ for (const ch of tokenText) {
641
+ if (ch === '-') { out += ch; continue; } // dropped delimiter, verbatim
642
+ while (ci < lens.length - 1 && consumed >= lens[ci]) { ci++; consumed = 0; }
643
+ out += relColour(stresses[ci])(ch);
644
+ consumed++;
645
+ }
646
+ return out;
647
+ }
648
+
649
+ /**
650
+ * Project per-syllable stress colours back onto the original line, preserving
651
+ * capitalisation, punctuation, spacing and any extrametrical fragments the
652
+ * pipeline dropped (e.g. possessive "'s"). Word-like tokens are coloured;
653
+ * everything between them (spaces, punctuation, dashes) is emitted verbatim.
654
+ *
655
+ * Alignment is tolerant: it matches each token to the next parsed word by
656
+ * normalised form (equal, or token starts with the word — handling "cat's"),
657
+ * with a small look-ahead resync so a stray/unsyllabified token never derails
658
+ * the rest of the line. No original character is ever dropped.
659
+ */
660
+ export function projectStressOntoLine(rawLine: string, words: ClsWord[]): string {
661
+ const tokenRe = /[A-Za-z]+(?:['’\-][A-Za-z]+)*/g;
662
+ let out = '';
663
+ let cursor = 0;
664
+ let wi = 0;
665
+ let m: RegExpExecArray | null;
666
+ while ((m = tokenRe.exec(rawLine)) !== null) {
667
+ const start = m.index;
668
+ const end = start + m[0].length;
669
+ out += rawLine.slice(cursor, start); // gap text, verbatim
670
+ cursor = end;
671
+ const token = m[0];
672
+ const tokNorm = normWordForm(token);
673
+
674
+ const matches = (w: ClsWord | undefined): boolean => {
675
+ if (!w) return false;
676
+ const wn = normWordForm(w.word);
677
+ return wn.length > 0 && (tokNorm === wn || tokNorm.startsWith(wn));
678
+ };
679
+
680
+ if (matches(words[wi])) {
681
+ out += colourToken(token, words[wi]);
682
+ wi++;
683
+ } else {
684
+ // Resync: a parsed word may have been skipped (e.g. unsyllabified "'s").
685
+ let found = -1;
686
+ for (let k = wi; k < Math.min(words.length, wi + 4); k++) {
687
+ if (matches(words[k])) { found = k; break; }
688
+ }
689
+ if (found >= 0) {
690
+ out += colourToken(token, words[found]);
691
+ wi = found + 1;
692
+ } else {
693
+ out += token; // leave verbatim; do not advance the word cursor
694
+ }
695
+ }
696
+ }
697
+ out += rawLine.slice(cursor); // trailing text, verbatim
698
+ return out;
699
+ }
700
+
701
+ // ═══════════════════════════════════════════════════════════════════════
702
+ // CAESURA PLACEMENT
703
+ // ═══════════════════════════════════════════════════════════════════════
704
+
705
+ type CaesuraKind = 'hard' | 'soft';
706
+
707
+ /** Foot-boundary syllable indices from a scansion string (cumulative syllable
708
+ * count after each foot; silent beats '-' are not syllables). */
709
+ function footBoundarySet(scansion: string): Set<number> {
710
+ const set = new Set<number>();
711
+ let c = 0;
712
+ for (const foot of scansion.split('|')) {
713
+ for (const ch of foot) if ('xwnms'.includes(ch)) c++;
714
+ set.add(c);
715
+ }
716
+ return set;
717
+ }
718
+
719
+ // A new phonological/syntactic phrase opens at these POS tags: prepositions &
720
+ // subordinators (IN), infinitival "to" (TO), coordinators (CC), wh/relativizers,
721
+ // verb-particles (RP), and the predicate's verb/modal. The major medial caesura
722
+ // of a line falls immediately BEFORE such a word — far more reliably read off the
723
+ // (robust) POS tags than off FinNLP's (noisy) phonological-phrase grouping, which
724
+ // mis-bracketed e.g. "The epic | feast…". Determiners/articles are excluded (they
725
+ // continue a phrase a preposition already opened: "as | one empty bag").
726
+ const PHRASE_ONSET_POS = new Set([
727
+ 'IN', 'TO', 'CC', 'WDT', 'WP', 'WP$', 'WRB', 'RP',
728
+ 'VB', 'VBD', 'VBG', 'VBN', 'VBP', 'VBZ', 'MD',
729
+ ]);
730
+
731
+ // Directional/spatial adverbs that open a phrase ("snowed-in OUT of many routes",
732
+ // "drifting DOWN to sleep"). FinNLP often tags these RB rather than RP/IN, so the
733
+ // POS set alone misses them; a small curated lemma list recovers them with low
734
+ // over-fire risk (a generic RB like "very"/"quickly" is NOT a phrase onset).
735
+ const DIRECTIONAL_ONSET = new Set([
736
+ 'out', 'in', 'up', 'down', 'off', 'away', 'back', 'forth', 'over',
737
+ 'around', 'along', 'through', 'apart', 'aside', 'onward', 'onwards',
738
+ ]);
739
+
740
+ /** True if a word opens a new phonological/syntactic phrase (a caesura candidate). */
741
+ function isPhraseOnset(w: ClsWord): boolean {
742
+ if (PHRASE_ONSET_POS.has(w.lexicalClass)) return true;
743
+ return w.lexicalClass === 'RB' && DIRECTIONAL_ONSET.has(w.word.toLowerCase());
744
+ }
745
+
746
+ /**
747
+ * Caesura positions for a line, keyed by the syllable index AFTER which the pause
748
+ * falls (= number of syllables to its left).
749
+ * • 'hard' — an overt break at an Intonational-Unit boundary (comma, dash, colon,
750
+ * semicolon …): the punctuation projection.
751
+ * • 'soft' — a single INFERRED medial caesura for a punctuation-free line (Kiparsky
752
+ * 1975, via McAleese: "phonological phrasing determines the location of caesurae
753
+ * in verse"). Candidates are the boundaries just before a phrase/clause onset
754
+ * (PHRASE_ONSET_POS); the one nearest the line's midpoint that lies in the
755
+ * central third AND coincides with a foot boundary wins — so it is medial, never
756
+ * mid-foot, and consistent across structurally-parallel lines. Read in LINEAR
757
+ * order (robust to clitic-group reordering); needs a line of ≥ 8 syllables.
758
+ */
759
+ function computeCaesurae(words: ClsWord[], ius: IntonationalUnit[], scansion?: string): Map<number, CaesuraKind> {
760
+ const caes = new Map<number, CaesuraKind>();
761
+ const iuOf = new Map<ClsWord, number>();
762
+ for (let i = 0; i < ius.length; i++) {
763
+ for (const pp of ius[i].phonologicalPhrases) {
764
+ for (const cg of pp.cliticGroups) for (const tok of cg.tokens) iuOf.set(tok, i);
765
+ }
766
+ }
767
+ let cum = 0;
768
+ let prevIu: number | undefined;
769
+ let prevWasContentful = false;
770
+ const onsetPositions: number[] = [];
771
+ for (const w of words) {
772
+ if (isPunctuation(w.lexicalClass) || w.syllables.length === 0) continue;
773
+ const iu = iuOf.get(w);
774
+ if (prevIu !== undefined && iu !== undefined && iu !== prevIu) {
775
+ caes.set(cum, 'hard'); // IU boundary → hard caesura
776
+ }
777
+ // A phrase-onset word that is NOT the line's first word opens a candidate
778
+ // caesura immediately before it.
779
+ if (prevWasContentful && isPhraseOnset(w)) onsetPositions.push(cum);
780
+ cum += w.syllables.length;
781
+ prevIu = iu;
782
+ prevWasContentful = true;
783
+ }
784
+ const total = cum;
785
+
786
+ // Infer ONE medial caesura only when the line carries no overt (hard) break.
787
+ if (caes.size === 0 && total >= 8 && onsetPositions.length > 0) {
788
+ const footEdges = scansion ? footBoundarySet(scansion) : null;
789
+ const mid = total / 2;
790
+ const lo = Math.max(2, Math.ceil(total / 3));
791
+ const hi = Math.floor((2 * total) / 3);
792
+ let best = -1, bestDist = Infinity;
793
+ for (const c of onsetPositions) {
794
+ if (c < lo || c > hi) continue; // medial third only
795
+ if (footEdges && !footEdges.has(c)) continue; // align to a foot boundary
796
+ const d = Math.abs(c - mid);
797
+ if (d < bestDist) { bestDist = d; best = c; }
798
+ }
799
+ if (best > 0) caes.set(best, 'soft');
800
+ }
801
+ return caes;
802
+ }
803
+
804
+ /** Render the glyph for a caesura kind. */
805
+ function caesuraGlyph(kind: CaesuraKind): string {
806
+ return kind === 'hard' ? B_CAESURA('‖') : B_CAESURA_SOFT('¦');
807
+ }
808
+
809
+ /** Colour a scansion string ("nws|nns|-wns") letter-by-letter, inserting caesura
810
+ * marks (at foot boundaries) when a caesura map is supplied. */
811
+ function colourScansionMap(scansion: string, caesurae?: Map<number, CaesuraKind>): string {
812
+ let out = '';
813
+ let sc = 0; // syllables emitted so far
814
+ const emitted = new Set<number>();
815
+ const caesAt = (): string => {
816
+ if (caesurae && caesurae.has(sc) && !emitted.has(sc)) {
817
+ emitted.add(sc);
818
+ return ' ' + caesuraGlyph(caesurae.get(sc)!) + ' ';
819
+ }
820
+ return '';
821
+ };
822
+ for (const ch of scansion) {
823
+ if (ch === '|') {
824
+ const c = caesAt();
825
+ out += c || B_FOOT('|');
826
+ } else if (ch === '-') {
827
+ out += B_SILENT('-');
828
+ } else if ('xwnms'.includes(ch)) {
829
+ out += caesAt(); // a (rare) mid-foot caesura, inserted inline
830
+ out += relColour(ch as StressLevel)(ch);
831
+ sc++;
832
+ } else {
833
+ out += ch;
834
+ }
835
+ }
836
+ return out;
837
+ }
838
+
839
+ const METER_ABBR: Record<string, string> = {
840
+ iambic: 'iamb', trochaic: 'troch', anapestic: 'anap', dactylic: 'dact',
841
+ amphibrachic: 'amph', bacchic: 'bacch', spondaic: 'spon', pyrrhic: 'pyrr',
842
+ 'free verse': 'free',
843
+ };
844
+
845
+ /** Compact top-3 meter fit scores, e.g. "(anap 0.81 · iamb 0.77 · amph 0.74)". */
846
+ function formatRanking(ranking?: MeterScore[]): string {
847
+ if (!ranking || ranking.length === 0) return '';
848
+ const top = ranking.slice(0, 3)
849
+ .map(r => `${METER_ABBR[r.meter] ?? r.meter} ${r.score.toFixed(2)}`);
850
+ return chalk.dim('(' + top.join(' · ') + ')');
851
+ }
852
+
853
+ /** Divergence notes. After the continuity rename, a near-tie line's BASE
854
+ * meter is already the stanza/poem-dominant one and `standaloneMeter` records
855
+ * the numerically-best standalone reading ("≈ continuity; standalone:
856
+ * dactylic tetrameter"). `consensusMeter` survives only when the forced
857
+ * re-fit failed — then the old "aligns w/" annotation still applies. */
858
+ function consensusNote(detail: { consensusMeter?: string; standaloneMeter?: string }): string {
859
+ if (detail.standaloneMeter) {
860
+ return chalk.dim.italic(` ≈ continuity; standalone: ${detail.standaloneMeter}`);
861
+ }
862
+ if (!detail.consensusMeter) return '';
863
+ return chalk.dim.italic(` ↔ aligns w/ stanza ${detail.consensusMeter}`);
864
+ }
865
+
866
+ /** Non-classical rhythm annotation (dolnik / taktovik / accentual /
867
+ * alternating ictus counts), set by the rhythm layer. Empty when the line
868
+ * has no such reading. */
869
+ function rhythmNoteStr(detail: { rhythmNote?: string }): string {
870
+ if (!detail.rhythmNote) return '';
871
+ return chalk.magenta.dim(` ♪ ${detail.rhythmNote}`);
872
+ }
873
+
874
+ /** End-rhyme chip for a line: scheme letter plus (dimmed) rhyme type when the
875
+ * line rhymes with an earlier one — e.g. "B(perfect)"; '·' = unrhymed. */
876
+ function rhymeStr(detail: { rhyme?: { letter: string; type?: string } }): string {
877
+ if (!detail.rhyme) return '';
878
+ const { letter, type } = detail.rhyme;
879
+ if (letter === '·') return ' ' + chalk.dim('·');
880
+ return ' ' + chalk.yellowBright(letter) + (type ? chalk.dim(`(${type})`) : '');
881
+ }
882
+
883
+ /** Non-punctuation, syllable-bearing words across all of a line's sentences. */
884
+ function collectLineWords(ln: ReadingLine): ClsWord[] {
885
+ const ws: ClsWord[] = [];
886
+ for (const res of ln.results) {
887
+ for (const w of res.sentence.words) {
888
+ if (!isPunctuation(w.lexicalClass) && w.syllables.length > 0) ws.push(w);
889
+ }
890
+ }
891
+ return ws;
892
+ }
893
+
894
+ /**
895
+ * Reading view: the poem itself in its original formatting, each syllable
896
+ * coloured by 4-tier relative stress, followed by a same-structure block of
897
+ * per-line stress maps + meter (with top-3 fit scores). This is the whole
898
+ * output for this mode — not the full per-line analytic dump.
899
+ */
900
+ export function renderReadingView(stanzas: ReadingStanza[]): string {
901
+ const out: string[] = [];
902
+ const multiStanza = stanzas.length > 1;
903
+
904
+ out.push('');
905
+ out.push(HR);
906
+ out.push(H1('Reading View') + chalk.dim(' — stress gradient over the original text'));
907
+ out.push('');
908
+
909
+ // ── Block 1: the poem, original formatting, syllables coloured ──
910
+ for (let s = 0; s < stanzas.length; s++) {
911
+ for (const ln of stanzas[s].lines) {
912
+ out.push(projectStressOntoLine(ln.raw, collectLineWords(ln)));
913
+ }
914
+ if (s < stanzas.length - 1) out.push('');
915
+ }
916
+
917
+ out.push('');
918
+ out.push(HR_THIN);
919
+ out.push(H1('Stress Maps & Meter') + chalk.dim(' — top-3 fit scores per line'));
920
+ out.push('');
921
+
922
+ // ── Block 2: stress maps + meter, same stanza/line structure ──
923
+ for (let s = 0; s < stanzas.length; s++) {
924
+ const firstDetail = stanzas[s].lines.flatMap(l => l.results)[0]?.phonologicalScansion;
925
+ const formNote = firstDetail?.formNote ? chalk.green.dim(' ❡ ' + firstDetail.formNote) : '';
926
+ if (multiStanza) out.push(H2('Stanza ' + (s + 1)) + formNote);
927
+ else if (formNote) out.push(formNote.trim());
928
+ for (let l = 0; l < stanzas[s].lines.length; l++) {
929
+ const ln = stanzas[s].lines[l];
930
+ const baseLabel = multiStanza ? `S${s + 1}L${l + 1}` : `L${l + 1}`;
931
+ if (ln.results.length === 0) {
932
+ out.push(' ' + chalk.dim(baseLabel.padEnd(8) + '(no parse)'));
933
+ continue;
934
+ }
935
+ for (let r = 0; r < ln.results.length; r++) {
936
+ const res = ln.results[r];
937
+ const d = res.phonologicalScansion;
938
+ const label = ln.results.length > 1 ? `${baseLabel}.${r + 1}` : baseLabel;
939
+ const caesurae = computeCaesurae(res.sentence.words, res.phonologicalHierarchy, d.scansion);
940
+ const map = colourScansionMap(d.scansion, caesurae);
941
+ const rank = formatRanking(d.ranking);
942
+ out.push(' ' + chalk.bold(label.padEnd(8)) + map + ' ' +
943
+ chalk.whiteBright(d.meter) + (rank ? ' ' + rank : '') + consensusNote(d) + rhythmNoteStr(d) + rhymeStr(d));
944
+ }
945
+ }
946
+ if (multiStanza && s < stanzas.length - 1) out.push('');
947
+ }
948
+
949
+ out.push('');
950
+ out.push(HR_THIN);
951
+ out.push(renderLegend());
952
+ out.push(HR);
953
+ return out.join('\n');
954
+ }