calliope-ts 0.0.2 → 0.0.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 (160) hide show
  1. package/README.md +32 -20
  2. package/dist/caesura.d.ts +33 -0
  3. package/dist/caesura.d.ts.map +1 -0
  4. package/dist/caesura.js +202 -0
  5. package/dist/calliope/boundaries.d.ts +19 -0
  6. package/dist/calliope/boundaries.d.ts.map +1 -0
  7. package/dist/calliope/boundaries.js +182 -0
  8. package/dist/calliope/bracketing.d.ts +11 -0
  9. package/dist/calliope/bracketing.d.ts.map +1 -0
  10. package/dist/calliope/bracketing.js +416 -0
  11. package/dist/calliope/deps.d.ts +4 -0
  12. package/dist/calliope/deps.d.ts.map +1 -0
  13. package/dist/calliope/deps.js +181 -0
  14. package/dist/calliope/engine.d.ts +3 -0
  15. package/dist/calliope/engine.d.ts.map +1 -0
  16. package/dist/calliope/engine.js +71 -0
  17. package/dist/calliope/feats.d.ts +9 -0
  18. package/dist/calliope/feats.d.ts.map +1 -0
  19. package/dist/calliope/feats.js +45 -0
  20. package/dist/calliope/names.d.ts +7 -0
  21. package/dist/calliope/names.d.ts.map +1 -0
  22. package/dist/calliope/names.js +42 -0
  23. package/dist/calliope/postag.d.ts +8 -0
  24. package/dist/calliope/postag.d.ts.map +1 -0
  25. package/dist/calliope/postag.js +250 -0
  26. package/dist/calliope/prosodic.d.ts +3 -0
  27. package/dist/calliope/prosodic.d.ts.map +1 -0
  28. package/dist/calliope/prosodic.js +275 -0
  29. package/dist/calliope/relstress.d.ts +4 -0
  30. package/dist/calliope/relstress.d.ts.map +1 -0
  31. package/dist/calliope/relstress.js +688 -0
  32. package/dist/calliope/stressrules.d.ts +28 -0
  33. package/dist/calliope/stressrules.d.ts.map +1 -0
  34. package/dist/calliope/stressrules.js +147 -0
  35. package/dist/calliope/syntax.d.ts +38 -0
  36. package/dist/calliope/syntax.d.ts.map +1 -0
  37. package/dist/calliope/syntax.js +234 -0
  38. package/dist/calliope/udpos.d.ts +13 -0
  39. package/dist/calliope/udpos.d.ts.map +1 -0
  40. package/dist/calliope/udpos.js +156 -0
  41. package/dist/clio/caesura.d.ts +27 -0
  42. package/dist/clio/caesura.d.ts.map +1 -0
  43. package/dist/clio/caesura.js +148 -0
  44. package/dist/clio/depfix.d.ts +13 -0
  45. package/dist/clio/depfix.d.ts.map +1 -0
  46. package/dist/clio/depfix.js +84 -0
  47. package/dist/clio/display.d.ts +32 -0
  48. package/dist/clio/display.d.ts.map +1 -0
  49. package/dist/clio/display.js +976 -0
  50. package/dist/clio/engine.d.ts +3 -0
  51. package/dist/clio/engine.d.ts.map +1 -0
  52. package/dist/clio/engine.js +28 -0
  53. package/dist/clio/parser.d.ts +10 -0
  54. package/dist/clio/parser.d.ts.map +1 -0
  55. package/dist/clio/parser.js +696 -0
  56. package/dist/clio/phonological.d.ts +41 -0
  57. package/dist/clio/phonological.d.ts.map +1 -0
  58. package/dist/clio/phonological.js +788 -0
  59. package/dist/clio/phrasestress.d.ts +6 -0
  60. package/dist/clio/phrasestress.d.ts.map +1 -0
  61. package/dist/clio/phrasestress.js +106 -0
  62. package/dist/clio/pipeline.d.ts +11 -0
  63. package/dist/clio/pipeline.d.ts.map +1 -0
  64. package/dist/clio/pipeline.js +147 -0
  65. package/dist/clio/rhyme.d.ts +65 -0
  66. package/dist/clio/rhyme.d.ts.map +1 -0
  67. package/dist/clio/rhyme.js +761 -0
  68. package/dist/clio/scandroid.d.ts +17 -0
  69. package/dist/clio/scandroid.d.ts.map +1 -0
  70. package/dist/clio/scandroid.js +435 -0
  71. package/dist/clio/scansion.d.ts +46 -0
  72. package/dist/clio/scansion.d.ts.map +1 -0
  73. package/dist/clio/scansion.js +1086 -0
  74. package/dist/clio/semantics.d.ts +44 -0
  75. package/dist/clio/semantics.d.ts.map +1 -0
  76. package/dist/clio/semantics.js +139 -0
  77. package/dist/clio/stress.d.ts +83 -0
  78. package/dist/clio/stress.d.ts.map +1 -0
  79. package/dist/clio/stress.js +1737 -0
  80. package/dist/clio/tagfix.d.ts +6 -0
  81. package/dist/clio/tagfix.d.ts.map +1 -0
  82. package/dist/clio/tagfix.js +101 -0
  83. package/dist/display.d.ts +0 -6
  84. package/dist/display.d.ts.map +1 -1
  85. package/dist/display.js +322 -141
  86. package/dist/engine.d.ts +9 -0
  87. package/dist/engine.d.ts.map +1 -0
  88. package/dist/engine.js +12 -0
  89. package/dist/index.d.ts +4 -3
  90. package/dist/index.d.ts.map +1 -1
  91. package/dist/index.js +169 -26
  92. package/dist/parser.d.ts +3 -3
  93. package/dist/parser.d.ts.map +1 -1
  94. package/dist/parser.js +320 -563
  95. package/dist/phonological.d.ts +1 -1
  96. package/dist/phonological.d.ts.map +1 -1
  97. package/dist/phonological.js +73 -4
  98. package/dist/phrasestress.d.ts +6 -0
  99. package/dist/phrasestress.d.ts.map +1 -0
  100. package/dist/phrasestress.js +106 -0
  101. package/dist/rhyme.d.ts +40 -1
  102. package/dist/rhyme.d.ts.map +1 -1
  103. package/dist/rhyme.js +435 -7
  104. package/dist/scansion.d.ts +9 -0
  105. package/dist/scansion.d.ts.map +1 -1
  106. package/dist/scansion.js +145 -18
  107. package/dist/semantics.d.ts +44 -0
  108. package/dist/semantics.d.ts.map +1 -0
  109. package/dist/semantics.js +139 -0
  110. package/dist/stress.d.ts +127 -4
  111. package/dist/stress.d.ts.map +1 -1
  112. package/dist/stress.js +834 -61
  113. package/dist/types.d.ts +15 -0
  114. package/dist/types.d.ts.map +1 -1
  115. package/package.json +5 -3
  116. package/src/caesura.ts +201 -0
  117. package/src/calliope/boundaries.ts +190 -0
  118. package/src/calliope/bracketing.ts +390 -0
  119. package/src/calliope/deps.ts +160 -0
  120. package/src/calliope/engine.ts +77 -0
  121. package/src/calliope/feats.ts +46 -0
  122. package/src/calliope/names.ts +44 -0
  123. package/src/calliope/postag.ts +253 -0
  124. package/src/calliope/prosodic.ts +262 -0
  125. package/src/calliope/relstress.ts +645 -0
  126. package/src/calliope/stressrules.ts +147 -0
  127. package/src/calliope/syntax.ts +218 -0
  128. package/src/calliope/udpos.ts +152 -0
  129. package/src/calliope_src_contents.md +19049 -0
  130. package/src/clio/caesura.ts +145 -0
  131. package/src/clio/depfix.ts +88 -0
  132. package/src/clio/display.ts +1042 -0
  133. package/src/clio/engine.ts +38 -0
  134. package/src/clio/parser.ts +845 -0
  135. package/src/clio/phonological.ts +849 -0
  136. package/src/clio/phrasestress.ts +108 -0
  137. package/src/clio/pipeline.ts +154 -0
  138. package/src/clio/rhyme.ts +740 -0
  139. package/src/clio/scandroid.ts +434 -0
  140. package/src/clio/scansion.ts +1130 -0
  141. package/src/clio/semantics.ts +134 -0
  142. package/src/clio/stress.ts +1731 -0
  143. package/src/clio/tagfix.ts +104 -0
  144. package/src/display.ts +321 -137
  145. package/src/engine.ts +22 -0
  146. package/src/index.ts +176 -32
  147. package/src/parser.ts +346 -682
  148. package/src/phonological.ts +72 -4
  149. package/src/phrasestress.ts +108 -0
  150. package/src/rhyme.ts +428 -8
  151. package/src/scansion.ts +136 -15
  152. package/src/semantics.ts +134 -0
  153. package/src/stress.ts +794 -64
  154. package/src/types.ts +39 -4
  155. package/tests/DataForHayesLinesOnly.txt +364 -0
  156. package/tests/DataForHayesStressSymbolsRevised.txt +728 -0
  157. package/tests/basic.test.ts +443 -37
  158. package/tests/bench-hayes.mjs +72 -0
  159. package/tests/wagner-stress.test.ts +188 -0
  160. package/vitest.config.ts +0 -15
package/src/rhyme.ts CHANGED
@@ -14,9 +14,10 @@
14
14
  // (Couplet, Triplet, Quatrain, Limerick, Petrarchan/Shakespearean Sonnet…).
15
15
 
16
16
  import * as nounsing from 'nounsing-pro';
17
- import { LineResult, PhonologicalScansionDetail } from './types.js';
17
+ import { ClsWord, LineResult, PhonologicalScansionDetail, StressLevel } from './types.js';
18
18
  import { isPunctuation } from './parser.js';
19
19
  import { ictusProfile } from './scansion.js';
20
+ import { preCaesuralWords } from './caesura.js';
20
21
 
21
22
  export type RhymeTypeName =
22
23
  | 'identical' | 'rich' | 'perfect' | 'family'
@@ -144,6 +145,8 @@ export function classifyRhymePair(a: string, b: string): RhymePair | null {
144
145
  // Strength tiers for scheme detection.
145
146
  const STRONG: Set<RhymeTypeName> = new Set(['identical', 'rich', 'perfect', 'family']);
146
147
  const SLANT: Set<RhymeTypeName> = new Set(['assonant', 'consonant', 'augmented', 'diminished', 'wrenched', 'eye']);
148
+ // Full rhyme only — the stricter bar a pre-caesural INTERNAL rhyme must clear.
149
+ const STRICT_INTERNAL: Set<RhymeTypeName> = new Set(['identical', 'rich', 'perfect']);
147
150
 
148
151
  export interface LineRhyme {
149
152
  endWord: string;
@@ -298,10 +301,110 @@ function poemForm(stanzas: { rhymes: LineRhyme[]; details: PhonologicalScansionD
298
301
  return undefined;
299
302
  }
300
303
 
304
+ /** Syllable-bearing (non-punctuation) words of a line, in linear order. */
305
+ function lineWords(line: LineResult): ClsWord[] {
306
+ return line.sentence.words.filter(w => !isPunctuation(w.lexicalClass) && w.syllables.length > 0);
307
+ }
308
+
309
+ /** Reader-facing surface of a word: the original case when the parser's
310
+ * sentence-initial de-capitalisation touched it ("Nap"->word:"nap"), else `word`.
311
+ * Rhyme CLASSIFICATION stays on `word` (it lowercases internally anyway); only
312
+ * what we REPORT uses this. */
313
+ function surfaceOf(w: ClsWord): string {
314
+ return w.displayWord ?? w.word;
315
+ }
316
+
301
317
  /** Last syllable-bearing word of a line (across its merged sentences). */
302
318
  function lineEndWord(line: LineResult): string {
303
- const ws = line.sentence.words.filter(w => !isPunctuation(w.lexicalClass) && w.syllables.length > 0);
304
- return ws.length ? ws[ws.length - 1].word : '';
319
+ const ws = lineWords(line);
320
+ return ws.length ? surfaceOf(ws[ws.length - 1]) : '';
321
+ }
322
+
323
+ // ─── INTERNAL (PRE-CAESURAL) RHYME — additive layer ─────────────────
324
+ //
325
+ // The per-stanza END-rhyme scheme (detectScheme) is the primary, UNTOUCHED
326
+ // system: every line keeps its end letter AND its rhyme type. Layered ON TOP,
327
+ // a word immediately preceding a caesura that FULLY rhymes (identical/rich/
328
+ // perfect — never the looser slant tiers, which on a caesura word are mostly
329
+ // coincidental noise) with an end word, or with another such internal word, is
330
+ // annotated as an INTERNAL rhyme. It reuses the end-rhyme letter it echoes, or
331
+ // — for an internal-only pair — a fresh letter that does not collide with the
332
+ // stanza's end letters. Internal rhymes render parenthesised before the end
333
+ // letter ("(A)B"), each carrying its own type. Purely additive: the end
334
+ // scheme's letters and types are never modified.
335
+
336
+ /** Attach pre-caesural internal rhymes to each line's `detail.rhyme`, on top of
337
+ * the per-stanza end scheme `rhymes` (from detectScheme). Mutates in place. */
338
+ function attachInternalRhymes(lines: LineResult[], rhymes: LineRhyme[]): void {
339
+ interface Pos { line: number; word: string; kind: 'end' | 'internal'; }
340
+ const positions: Pos[] = [];
341
+ for (let i = 0; i < lines.length; i++) {
342
+ const lw = lineWords(lines[i]);
343
+ const endObj = lw.length ? lw[lw.length - 1] : undefined;
344
+ positions.push({ line: i, word: rhymes[i].endWord, kind: 'end' });
345
+ const pre = preCaesuralWords(
346
+ lines[i].sentence.words, lines[i].phonologicalHierarchy, lines[i].phonologicalScansion.scansion);
347
+ for (const { word } of pre) {
348
+ if (word === endObj || !word.isContent) continue; // the end word / function words are not internal-rhyme bearers
349
+ positions.push({ line: i, word: word.word, kind: 'internal' });
350
+ }
351
+ }
352
+ // Union-find over positions. End-end links are NOT made (detectScheme already
353
+ // lettered the ends); only links INVOLVING an internal, at the full-rhyme tier.
354
+ const parent = positions.map((_, i) => i);
355
+ const find = (x: number): number => { while (parent[x] !== x) { parent[x] = parent[parent[x]]; x = parent[x]; } return x; };
356
+ const union = (a: number, b: number) => { const ra = find(a), rb = find(b); if (ra !== rb) parent[ra] = rb; };
357
+ for (let a = 0; a < positions.length; a++) {
358
+ for (let b = a + 1; b < positions.length; b++) {
359
+ if (positions[a].kind === 'end' && positions[b].kind === 'end') continue;
360
+ if (!positions[a].word || !positions[b].word) continue;
361
+ const pair = classifyRhymePair(positions[a].word, positions[b].word);
362
+ if (pair && STRICT_INTERNAL.has(pair.type)) union(a, b);
363
+ }
364
+ }
365
+ // Letter per class: a class containing a lettered end takes that end's letter;
366
+ // an internal-only class of >=2 gets a fresh letter clear of the stanza's ends.
367
+ const endLetterAt = new Map<number, string>();
368
+ positions.forEach((p, i) => { if (p.kind === 'end' && rhymes[p.line].letter !== '\u00b7') endLetterAt.set(i, rhymes[p.line].letter); });
369
+ const used = new Set<string>(rhymes.filter(r => r.letter !== '\u00b7').map(r => r.letter));
370
+ let code = 0;
371
+ const freshLetter = () => { let c: string; do { c = String.fromCharCode(65 + (code++ % 26)); } while (used.has(c)); used.add(c); return c; };
372
+ const classMembers = new Map<number, number[]>();
373
+ for (let i = 0; i < positions.length; i++) { const r = find(i); if (!classMembers.has(r)) classMembers.set(r, []); classMembers.get(r)!.push(i); }
374
+ const classLetter = new Map<number, string>();
375
+ for (const [root, members] of classMembers) {
376
+ if (members.length < 2) continue;
377
+ const endMember = members.find(m => endLetterAt.has(m));
378
+ if (endMember !== undefined) classLetter.set(root, endLetterAt.get(endMember)!);
379
+ else if (members.some(m => positions[m].kind === 'internal')) classLetter.set(root, freshLetter());
380
+ }
381
+ const typeOf = (p: number): RhymeTypeName | undefined => {
382
+ for (const m of classMembers.get(find(p)) ?? []) {
383
+ if (m === p) continue;
384
+ const pair = classifyRhymePair(positions[m].word, positions[p].word);
385
+ if (pair && STRICT_INTERNAL.has(pair.type)) return pair.type;
386
+ }
387
+ return undefined;
388
+ };
389
+ for (let i = 0; i < lines.length; i++) {
390
+ const internal: { word: string; letter: string; type?: string }[] = [];
391
+ positions.forEach((p, idx) => {
392
+ if (p.line !== i || p.kind !== 'internal') return;
393
+ const letter = classLetter.get(find(idx));
394
+ if (letter) internal.push({ word: p.word, letter, type: typeOf(idx) });
395
+ });
396
+ const endLetter = rhymes[i].letter;
397
+ const intStr = internal.map(x => `(${x.letter})`).join('');
398
+ const notation = intStr + (endLetter !== '\u00b7' ? endLetter : (internal.length ? '' : '\u00b7'));
399
+ lines[i].phonologicalScansion.rhyme = {
400
+ endWord: rhymes[i].endWord,
401
+ letter: endLetter,
402
+ type: rhymes[i].type,
403
+ matchedLine: rhymes[i].matchedLine,
404
+ internal: internal.length ? internal : undefined,
405
+ notation,
406
+ };
407
+ }
305
408
  }
306
409
 
307
410
  /**
@@ -311,18 +414,335 @@ function lineEndWord(line: LineResult): string {
311
414
  * meter/scansion/certainty is touched.
312
415
  */
313
416
  export function applyRhymeAndForm(stanzas: LineResult[][]): void {
417
+ // FORM detection uses per-stanza end-rhyme schemes (relative, restarting each
418
+ // stanza) — that is what couplet/quatrain/sonnet patterns are defined over.
314
419
  const analyzed = stanzas.map(lines => {
315
420
  const details = lines.map(l => l.phonologicalScansion);
316
421
  const rhymes = detectScheme(lines.map(lineEndWord));
317
- return { details, rhymes };
422
+ return { lines, details, rhymes };
318
423
  });
319
424
  for (const { details, rhymes } of analyzed) {
320
425
  const form = stanzaForm(rhymes, details);
321
- for (let i = 0; i < details.length; i++) {
322
- details[i].rhyme = rhymes[i];
323
- details[i].formNote = form;
324
- }
426
+ for (const d of details) d.formNote = form;
325
427
  }
326
428
  const pform = poemForm(analyzed);
327
429
  if (pform) for (const { details } of analyzed) for (const d of details) d.formNote = pform;
430
+ // Per-stanza end-rhyme letters + TYPES (detectScheme), then the additive
431
+ // pre-caesural internal-rhyme layer. Writes `detail.rhyme`; the original
432
+ // end-rhyme display is restored and extended, never replaced.
433
+ for (const { lines, rhymes } of analyzed) attachInternalRhymes(lines, rhymes);
434
+ }
435
+
436
+ // ─── PHONOPOETICS — poem-wide end / caesural / head rhyme, alliteration,
437
+ // acrostic, with the maintainer's three-phase lettering ─────────────
438
+ //
439
+ // Lettering proceeds in three phases over ONE shared alphabet:
440
+ // 1. END words (line 1→N): each rhyme group gets a letter A,B,C…; a later word
441
+ // reuses an earlier group's letter when it rhymes with it (across stanzas).
442
+ // 2. CAESURAL words (pre-caesura): continue the alphabet (new letter per new
443
+ // caesural rhyme); a caesural word reuses an END letter ONLY if it rhymes
444
+ // with that exact end group.
445
+ // 3. HEAD words (line-initial): continue the alphabet, a new letter per head
446
+ // rhyme (head rhymes do not reuse end/caesural letters).
447
+ // Alliteration carries NO letters; an acrostic's letters are inherent.
448
+ // Each rhyme letter is to be COLOURED by the strongest relative-stress tier among
449
+ // the syllables it spans (`topStress`) — done at the display layer.
450
+
451
+ const STRESS_ORDER: StressLevel[] = ['x', 'w', 'n', 'm', 's'];
452
+ function topStressOf(...ws: (ClsWord | undefined)[]): StressLevel {
453
+ let best: StressLevel = 'x';
454
+ for (const w of ws) {
455
+ if (!w) continue;
456
+ for (const s of w.syllables) {
457
+ const r = s.relativeStress ?? 'w';
458
+ if (STRESS_ORDER.indexOf(r) > STRESS_ORDER.indexOf(best)) best = r;
459
+ }
460
+ }
461
+ return best;
462
+ }
463
+
464
+ /** First consonant phoneme of a word (for alliteration), or '' if vowel-initial/OOV. */
465
+ function onsetPhone(word: string): string {
466
+ const ph = phonesOf(word);
467
+ if (!ph || ph.length === 0) return '';
468
+ const p0 = base(ph[0]);
469
+ return isVowelPhone(p0) ? '' : p0;
470
+ }
471
+
472
+ export interface RhymeRel {
473
+ fromWord: string; fromLabel: string; // the rhyme site being annotated
474
+ toWord: string; toLabel: string; // its partner
475
+ letter: string;
476
+ type?: RhymeTypeName;
477
+ kind: 'end' | 'caesural' | 'head';
478
+ topStress: StressLevel; // strongest tier spanned (letter colour)
479
+ }
480
+ export interface Phonopoetics {
481
+ endScheme: string; // poem-wide canonical end scheme ("ABAB…")
482
+ end: RhymeRel[];
483
+ caesural: RhymeRel[];
484
+ head: RhymeRel[];
485
+ alliteration: { label: string; words: string[] }[];
486
+ acrostics: { labels: string[]; firsts: string[]; word: string }[];
487
+ }
488
+
489
+ /** Whole-poem phonopoetic analysis: structured data for the Phonopoetics
490
+ * synopsis section. Read-only — never mutates the per-line scansion. */
491
+ export function analyzePhonopoetics(stanzas: LineResult[][]): Phonopoetics {
492
+ const multi = stanzas.length > 1;
493
+ interface PL { line: LineResult; label: string; }
494
+ const PLs: PL[] = [];
495
+ stanzas.forEach((st, s) => st.forEach((line, l) =>
496
+ PLs.push({ line, label: multi ? `S${s + 1}L${l + 1}` : `L${l + 1}` })));
497
+
498
+ const endObj = (pl: PL): ClsWord | undefined => { const ws = lineWords(pl.line); return ws[ws.length - 1]; };
499
+ const headObj = (pl: PL): ClsWord | undefined => lineWords(pl.line).find(w => w.isContent);
500
+ const caesuralObjs = (pl: PL): ClsWord[] => {
501
+ const end = endObj(pl);
502
+ const pre = preCaesuralWords(pl.line.sentence.words, pl.line.phonologicalHierarchy, pl.line.phonologicalScansion.scansion);
503
+ return pre.map(p => p.word).filter(w => w !== end && w.isContent);
504
+ };
505
+
506
+ // ── Phase 1: END rhymes (poem-wide) ──
507
+ const endWords = PLs.map(pl => lineEndWord(pl.line));
508
+ const es = detectScheme(endWords);
509
+ const endScheme = canonicalScheme(es);
510
+ const byLetter = new Map<string, number[]>();
511
+ es.forEach((r, i) => { if (r.letter !== '·') { (byLetter.get(r.letter) ?? byLetter.set(r.letter, []).get(r.letter)!).push(i); } });
512
+ let nextCode = byLetter.size;
513
+ const freshLetter = (): string => String.fromCharCode(65 + (nextCode++ % 26));
514
+
515
+ const end: RhymeRel[] = [];
516
+ for (const [letter, idxs] of byLetter) {
517
+ for (let k = 1; k < idxs.length; k++) {
518
+ const i = idxs[k], j = idxs[k - 1]; // j earlier, i later → show earlier → later
519
+ end.push({
520
+ fromWord: endWords[j], fromLabel: PLs[j].label,
521
+ toWord: endWords[i], toLabel: PLs[i].label,
522
+ letter, type: es[i].type, kind: 'end',
523
+ topStress: topStressOf(endObj(PLs[i]), endObj(PLs[j])),
524
+ });
525
+ }
526
+ }
527
+
528
+ // ── Phase 2: CAESURAL rhymes ──
529
+ interface CW { pl: number; word: ClsWord; letter?: string; pLabel?: string; pWord?: string; type?: RhymeTypeName; }
530
+ const cws: CW[] = [];
531
+ PLs.forEach((pl, i) => caesuralObjs(pl).forEach(w => cws.push({ pl: i, word: w })));
532
+ // (a) bind to an exact END group it rhymes with → reuse that end letter
533
+ for (const cw of cws) {
534
+ for (let i = 0; i < PLs.length; i++) {
535
+ if (es[i].letter === '·') continue; // unrhymed end → not a "pair"
536
+ const ew = endObj(PLs[i]); if (!ew || ew === cw.word) continue;
537
+ const pair = classifyRhymePair(cw.word.word, ew.word);
538
+ if (pair && STRICT_INTERNAL.has(pair.type)) {
539
+ cw.letter = es[i].letter; cw.pLabel = PLs[i].label; cw.pWord = surfaceOf(ew); cw.type = pair.type; break;
540
+ }
541
+ }
542
+ }
543
+ // (b) caesural↔caesural among the still-unbound → fresh letters
544
+ const unbound = cws.filter(c => !c.letter);
545
+ for (let i = 0; i < unbound.length; i++) {
546
+ if (unbound[i].letter) continue;
547
+ for (let j = 0; j < i; j++) {
548
+ const pair = classifyRhymePair(unbound[i].word.word, unbound[j].word.word);
549
+ if (pair && STRICT_INTERNAL.has(pair.type)) {
550
+ if (!unbound[j].letter) unbound[j].letter = freshLetter();
551
+ unbound[i].letter = unbound[j].letter;
552
+ // only the later member is annotated (points back) so each pair shows once
553
+ unbound[i].pLabel = PLs[unbound[j].pl].label; unbound[i].pWord = surfaceOf(unbound[j].word); unbound[i].type = pair.type;
554
+ break;
555
+ }
556
+ }
557
+ }
558
+ const caesural: RhymeRel[] = cws.filter(c => c.letter && c.pLabel).map(c => ({
559
+ fromWord: surfaceOf(c.word), fromLabel: PLs[c.pl].label,
560
+ toWord: c.pWord!, toLabel: c.pLabel!,
561
+ letter: c.letter!, type: c.type, kind: 'caesural' as const,
562
+ topStress: topStressOf(c.word),
563
+ }));
564
+
565
+ // ── Phase 3: HEAD rhymes (line-initial) — fresh letters, no reuse ──
566
+ interface HW { pl: number; word: ClsWord; letter?: string; pLabel?: string; pWord?: string; type?: RhymeTypeName; }
567
+ const hws: HW[] = PLs.map((pl, i) => ({ pl: i, word: headObj(pl)! })).filter(h => h.word);
568
+ for (let i = 0; i < hws.length; i++) {
569
+ if (hws[i].letter) continue;
570
+ for (let j = 0; j < i; j++) {
571
+ const pair = classifyRhymePair(hws[i].word.word, hws[j].word.word);
572
+ if (pair && STRICT_INTERNAL.has(pair.type)) {
573
+ if (!hws[j].letter) hws[j].letter = freshLetter();
574
+ hws[i].letter = hws[j].letter;
575
+ // only the later member is annotated (points back) so each pair shows once
576
+ hws[i].pLabel = PLs[hws[j].pl].label; hws[i].pWord = surfaceOf(hws[j].word); hws[i].type = pair.type;
577
+ break;
578
+ }
579
+ }
580
+ }
581
+ const head: RhymeRel[] = hws.filter(h => h.letter && h.pLabel).map(h => ({
582
+ fromWord: surfaceOf(h.word), fromLabel: PLs[h.pl].label,
583
+ toWord: h.pWord!, toLabel: h.pLabel!,
584
+ letter: h.letter!, type: h.type, kind: 'head' as const,
585
+ topStress: topStressOf(h.word),
586
+ }));
587
+
588
+ // ── Alliteration: maximal runs (≥2) of content words sharing first letter AND
589
+ // first consonant phoneme (function words may sit between, but do not count). ──
590
+ const alliteration: { label: string; words: string[] }[] = [];
591
+ for (const pl of PLs) {
592
+ const cw = lineWords(pl.line).filter(w => w.isContent);
593
+ let run: ClsWord[] = [];
594
+ const flush = () => { if (run.length >= 2) alliteration.push({ label: pl.label, words: run.map(surfaceOf) }); run = []; };
595
+ for (const w of cw) {
596
+ const letter0 = (w.word.match(/[a-z]/i)?.[0] ?? '').toLowerCase();
597
+ const phone0 = onsetPhone(w.word);
598
+ if (!letter0 || !phone0) { flush(); continue; }
599
+ if (run.length === 0) { run = [w]; continue; }
600
+ const prev = run[run.length - 1];
601
+ const pLetter = (prev.word.match(/[a-z]/i)?.[0] ?? '').toLowerCase();
602
+ if (letter0 === pLetter && phone0 === onsetPhone(prev.word)) run.push(w);
603
+ else { flush(); run = [w]; }
604
+ }
605
+ flush();
606
+ }
607
+
608
+ // ── Acrostic: per-stanza (and, if multi-stanza, whole-poem) first letters that
609
+ // spell a dictionary word (≥3 letters). ──
610
+ const acrostics: { labels: string[]; firsts: string[]; word: string }[] = [];
611
+ const firstLetterOf = (line: LineResult): string => {
612
+ const ws = lineWords(line);
613
+ return ws.length ? (ws[0].word.match(/[a-z]/i)?.[0] ?? '').toUpperCase() : '';
614
+ };
615
+ const checkAcrostic = (lines: LineResult[], labels: string[]) => {
616
+ const firsts = lines.map(firstLetterOf);
617
+ if (firsts.some(f => !f)) return;
618
+ const word = firsts.join('');
619
+ if (word.length >= 3 && phonesOf(word)) acrostics.push({ labels, firsts, word });
620
+ };
621
+ stanzas.forEach((st, s) => checkAcrostic(st, st.map((_, l) => multi ? `S${s + 1}L${l + 1}` : `L${l + 1}`)));
622
+ if (multi) checkAcrostic(stanzas.flat(), PLs.map(p => p.label));
623
+
624
+ return { endScheme, end, caesural, head, alliteration, acrostics };
625
+ }
626
+
627
+ // ─── POEM-LEVEL META-MEASURE (synopsis) ─────────────────────────────
628
+ //
629
+ // A cumulative, NON-INTERFERING reading of the whole poem, shown at the foot of
630
+ // the reading views. It draws solely on determinations already made per line
631
+ // (meter, rhythm, rhyme, form) and never overrides them. Deliberately offers
632
+ // SEVERAL top conclusions rather than forcing a single verdict.
633
+
634
+ // Canonical end-rhyme schemes of the LYRICAL FORM_REGISTRY forms whose pattern is
635
+ // expressible in plain letters (refrain forms — villanelle, pantoum, rondeau … —
636
+ // need repetition checking and are deferred). Used to NOTE whole-poem rhyme-
637
+ // scheme alignment in the synopsis; the per-stanza/poem form layer remains the
638
+ // authority for the FORM verdict itself.
639
+ const REGISTRY_FORM_SCHEMES: { lines: number; scheme: string; name: string }[] = [
640
+ { lines: 2, scheme: 'AA', name: 'Couplet' },
641
+ { lines: 3, scheme: 'ABA', name: 'Triplet' },
642
+ { lines: 4, scheme: 'ABAB', name: 'Quatrain' },
643
+ { lines: 5, scheme: 'AABBA', name: 'Limerick' },
644
+ { lines: 6, scheme: 'ABABCC', name: 'Sextilla' },
645
+ { lines: 7, scheme: 'ABABBCC', name: 'Septet (rhyme royal)' },
646
+ { lines: 9, scheme: 'AAABBBCCC', name: 'Triad' },
647
+ { lines: 10, scheme: 'ABABCDECDE', name: 'English Ode' },
648
+ { lines: 14, scheme: 'ABABCDCDEFEFGG', name: 'Shakespearean Sonnet' },
649
+ { lines: 14, scheme: 'ABBAABBACDCDCD', name: 'Petrarchan Sonnet' },
650
+ { lines: 14, scheme: 'ABBAABBACDECDE', name: 'Petrarchan Sonnet' },
651
+ ];
652
+
653
+ function matchRegistryForm(totalLines: number, canonicalEndScheme: string): string | undefined {
654
+ return REGISTRY_FORM_SCHEMES.find(f => f.lines === totalLines && f.scheme === canonicalEndScheme)?.name;
655
+ }
656
+
657
+ /** A labelled line of the poem synopsis (display renders each). */
658
+ export interface SynopsisRow { label: string; value: string; }
659
+
660
+ const descriptorOf = (d: PhonologicalScansionDetail): string =>
661
+ d.metricalityNote ? 'plausible prose'
662
+ : d.rhythmNote ? d.rhythmNote // accentual/dolnik/taktovik → beats
663
+ : d.meterName === 'free verse' ? 'free verse'
664
+ : d.meter; // e.g. "iambic pentameter"
665
+
666
+ // Compact metre labels for the synopsis ("iambic pentameter" → "iamb penta").
667
+ // Rhythm notes ("4/3 ♪beat accentual"), "free verse", and "plausible prose" are
668
+ // left untouched (they are not family+foot labels).
669
+ const _FAMILY_ABBR: Record<string, string> = {
670
+ iambic: 'iamb', trochaic: 'troch', dactylic: 'dact', anapestic: 'anap',
671
+ amphibrachic: 'amph', bacchic: 'bacch', spondaic: 'spon', pyrrhic: 'pyrr',
672
+ };
673
+ const _FOOT_ABBR: Record<string, string> = {
674
+ monometer: 'mono', dimeter: 'di', trimeter: 'tri', tetrameter: 'tetra',
675
+ pentameter: 'penta', hexameter: 'hexa', heptameter: 'hepta', octameter: 'octa',
676
+ };
677
+ function abbrevMeter(label: string): string {
678
+ const parts = label.split(' ');
679
+ if (parts.length === 2 && _FAMILY_ABBR[parts[0]] && _FOOT_ABBR[parts[1]]) {
680
+ return `${_FAMILY_ABBR[parts[0]]} ${_FOOT_ABBR[parts[1]]}`;
681
+ }
682
+ return label;
683
+ }
684
+
685
+ /**
686
+ * Build the cumulative poem synopsis: top meter(s)/mixed meters (accentual forms
687
+ * reported in beats), the poem-wide rhyme scheme (with internal rhymes), and the
688
+ * poetic-form alignment. Reads only existing per-line determinations.
689
+ */
690
+ export function summarizePoem(stanzas: LineResult[][]): SynopsisRow[] {
691
+ const lines = stanzas.flat();
692
+ const details = lines.map(l => l.phonologicalScansion);
693
+ const N = details.length;
694
+ const rows: SynopsisRow[] = [];
695
+ if (N === 0) return rows;
696
+
697
+ // ── Meter — several top conclusions, never forced into one ──
698
+ const tally = new Map<string, number>();
699
+ for (const d of details) tally.set(descriptorOf(d), (tally.get(descriptorOf(d)) ?? 0) + 1);
700
+ const ranked = [...tally.entries()].sort((a, b) => b[1] - a[1]);
701
+ let meterVal: string;
702
+ if (N === 1) {
703
+ meterVal = abbrevMeter(ranked[0][0]);
704
+ } else if (ranked[0][1] / N >= 0.6) {
705
+ const rest = ranked.slice(1, 3).map(([m, c]) => `${abbrevMeter(m)} (${c})`);
706
+ meterVal = `predominantly ${abbrevMeter(ranked[0][0])} (${ranked[0][1]}/${N})`
707
+ + (rest.length ? `; also ${rest.join(', ')}` : '');
708
+ } else {
709
+ meterVal = 'Mixed | ' + ranked.slice(0, 3).map(([m, c]) => `${abbrevMeter(m)} (${c}/${N})`).join(' · ');
710
+ }
711
+ // "Rhythm" = the metre make-up; "Meter" = the mean fit % (the maintainer's
712
+ // synopsis taxonomy).
713
+ rows.push({ label: 'Rhythm', value: meterVal });
714
+
715
+ // ── Meter — mean of the per-line fit certainties ──
716
+ const meanCert = Math.round(details.reduce((s, d) => s + d.certainty, 0) / N);
717
+ rows.push({ label: 'Meter', value: `~${meanCert}% mean line-wise fit` });
718
+
719
+ // ── Form — the form layer's verdict(s) + registry-scheme alignment. (The
720
+ // detailed rhyme listing now lives in the Phonopoetics section; the synopsis
721
+ // carries only the canonical End-Rhyme Scheme, below.) ──
722
+ const endRhymes = N >= 2 ? detectScheme(lines.map(lineEndWord)) : [];
723
+ const endScheme = endRhymes.length ? canonicalScheme(endRhymes) : '';
724
+ const hasEndRhyme = endRhymes.some(r => r.letter !== '·');
725
+ const forms = [...new Set(details.map(d => d.formNote).filter((x): x is string => !!x))];
726
+ let formVal: string | undefined = forms.length ? forms.join(' · ') : undefined;
727
+ if (N >= 2) {
728
+ const reg = matchRegistryForm(N, endScheme);
729
+ if (reg && !(formVal && formVal.includes(reg))) {
730
+ formVal = (formVal ? formVal + ' · ' : '') + `aligns with the ${reg} scheme`;
731
+ }
732
+ }
733
+ if (formVal) rows.push({ label: 'Form', value: formVal });
734
+
735
+ // ── End-Rhyme Scheme — poem-wide canonical scheme, its own row (only end
736
+ // rhymes are matched against forms; caesural/head live in Phonopoetics). ──
737
+ if (hasEndRhyme) rows.push({ label: 'End-Rhyme Scheme', value: endScheme });
738
+
739
+ // ── Heterometric advisory — the whole-poem observation that per-line beat
740
+ // counts vary widely (kept OUT of the per-line display, as an advisory here). ──
741
+ const beats = details.map(d => (d.footCount > 0 ? d.footCount : ictusProfile(d.scansion).ictuses));
742
+ const lo = Math.min(...beats), hi = Math.max(...beats);
743
+ if (N >= 3 && hi - lo >= 3) {
744
+ rows.push({ label: 'Note', value: `line lengths vary — ${lo}–${hi} beats per line` });
745
+ }
746
+
747
+ return rows;
328
748
  }