calliope-ts 0.0.2 → 0.0.3

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 (159) hide show
  1. package/README.md +3 -13
  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/clio/caesura.ts +145 -0
  130. package/src/clio/depfix.ts +88 -0
  131. package/src/clio/display.ts +1042 -0
  132. package/src/clio/engine.ts +38 -0
  133. package/src/clio/parser.ts +845 -0
  134. package/src/clio/phonological.ts +849 -0
  135. package/src/clio/phrasestress.ts +108 -0
  136. package/src/clio/pipeline.ts +154 -0
  137. package/src/clio/rhyme.ts +740 -0
  138. package/src/clio/scandroid.ts +434 -0
  139. package/src/clio/scansion.ts +1130 -0
  140. package/src/clio/semantics.ts +134 -0
  141. package/src/clio/stress.ts +1731 -0
  142. package/src/clio/tagfix.ts +104 -0
  143. package/src/display.ts +321 -137
  144. package/src/engine.ts +22 -0
  145. package/src/index.ts +176 -32
  146. package/src/parser.ts +346 -682
  147. package/src/phonological.ts +72 -4
  148. package/src/phrasestress.ts +108 -0
  149. package/src/rhyme.ts +428 -8
  150. package/src/scansion.ts +136 -15
  151. package/src/semantics.ts +134 -0
  152. package/src/stress.ts +794 -64
  153. package/src/types.ts +39 -4
  154. package/tests/DataForHayesLinesOnly.txt +364 -0
  155. package/tests/DataForHayesStressSymbolsRevised.txt +728 -0
  156. package/tests/basic.test.ts +443 -37
  157. package/tests/bench-hayes.mjs +72 -0
  158. package/tests/wagner-stress.test.ts +188 -0
  159. package/vitest.config.ts +0 -15
package/dist/rhyme.js CHANGED
@@ -15,6 +15,7 @@
15
15
  import * as nounsing from 'nounsing-pro';
16
16
  import { isPunctuation } from './parser.js';
17
17
  import { ictusProfile } from './scansion.js';
18
+ import { preCaesuralWords } from './caesura.js';
18
19
  const VOWEL_RE = /^(AA|AE|AH|AO|AW|AY|EH|ER|EY|IH|IY|OW|OY|UH|UW)/;
19
20
  const PLOSIVES = new Set(['B', 'D', 'G', 'P', 'T', 'K']);
20
21
  const FRICATIVES = new Set(['V', 'DH', 'Z', 'ZH', 'JH', 'F', 'TH', 'S', 'SH', 'CH', 'HH']);
@@ -138,6 +139,8 @@ export function classifyRhymePair(a, b) {
138
139
  // Strength tiers for scheme detection.
139
140
  const STRONG = new Set(['identical', 'rich', 'perfect', 'family']);
140
141
  const SLANT = new Set(['assonant', 'consonant', 'augmented', 'diminished', 'wrenched', 'eye']);
142
+ // Full rhyme only — the stricter bar a pre-caesural INTERNAL rhyme must clear.
143
+ const STRICT_INTERNAL = new Set(['identical', 'rich', 'perfect']);
141
144
  /** Detect a stanza's rhyme scheme from its line-end words. Strong rhymes
142
145
  * bind; slant-tier rhymes bind only when no strong candidate exists. */
143
146
  export function detectScheme(endWords) {
@@ -308,10 +311,127 @@ function poemForm(stanzas) {
308
311
  }
309
312
  return undefined;
310
313
  }
314
+ /** Syllable-bearing (non-punctuation) words of a line, in linear order. */
315
+ function lineWords(line) {
316
+ return line.sentence.words.filter(w => !isPunctuation(w.lexicalClass) && w.syllables.length > 0);
317
+ }
318
+ /** Reader-facing surface of a word: the original case when the parser's
319
+ * sentence-initial de-capitalisation touched it ("Nap"->word:"nap"), else `word`.
320
+ * Rhyme CLASSIFICATION stays on `word` (it lowercases internally anyway); only
321
+ * what we REPORT uses this. */
322
+ function surfaceOf(w) {
323
+ return w.displayWord ?? w.word;
324
+ }
311
325
  /** Last syllable-bearing word of a line (across its merged sentences). */
312
326
  function lineEndWord(line) {
313
- const ws = line.sentence.words.filter(w => !isPunctuation(w.lexicalClass) && w.syllables.length > 0);
314
- return ws.length ? ws[ws.length - 1].word : '';
327
+ const ws = lineWords(line);
328
+ return ws.length ? surfaceOf(ws[ws.length - 1]) : '';
329
+ }
330
+ // ─── INTERNAL (PRE-CAESURAL) RHYME — additive layer ─────────────────
331
+ //
332
+ // The per-stanza END-rhyme scheme (detectScheme) is the primary, UNTOUCHED
333
+ // system: every line keeps its end letter AND its rhyme type. Layered ON TOP,
334
+ // a word immediately preceding a caesura that FULLY rhymes (identical/rich/
335
+ // perfect — never the looser slant tiers, which on a caesura word are mostly
336
+ // coincidental noise) with an end word, or with another such internal word, is
337
+ // annotated as an INTERNAL rhyme. It reuses the end-rhyme letter it echoes, or
338
+ // — for an internal-only pair — a fresh letter that does not collide with the
339
+ // stanza's end letters. Internal rhymes render parenthesised before the end
340
+ // letter ("(A)B"), each carrying its own type. Purely additive: the end
341
+ // scheme's letters and types are never modified.
342
+ /** Attach pre-caesural internal rhymes to each line's `detail.rhyme`, on top of
343
+ * the per-stanza end scheme `rhymes` (from detectScheme). Mutates in place. */
344
+ function attachInternalRhymes(lines, rhymes) {
345
+ const positions = [];
346
+ for (let i = 0; i < lines.length; i++) {
347
+ const lw = lineWords(lines[i]);
348
+ const endObj = lw.length ? lw[lw.length - 1] : undefined;
349
+ positions.push({ line: i, word: rhymes[i].endWord, kind: 'end' });
350
+ const pre = preCaesuralWords(lines[i].sentence.words, lines[i].phonologicalHierarchy, lines[i].phonologicalScansion.scansion);
351
+ for (const { word } of pre) {
352
+ if (word === endObj || !word.isContent)
353
+ continue; // the end word / function words are not internal-rhyme bearers
354
+ positions.push({ line: i, word: word.word, kind: 'internal' });
355
+ }
356
+ }
357
+ // Union-find over positions. End-end links are NOT made (detectScheme already
358
+ // lettered the ends); only links INVOLVING an internal, at the full-rhyme tier.
359
+ const parent = positions.map((_, i) => i);
360
+ const find = (x) => { while (parent[x] !== x) {
361
+ parent[x] = parent[parent[x]];
362
+ x = parent[x];
363
+ } return x; };
364
+ const union = (a, b) => { const ra = find(a), rb = find(b); if (ra !== rb)
365
+ parent[ra] = rb; };
366
+ for (let a = 0; a < positions.length; a++) {
367
+ for (let b = a + 1; b < positions.length; b++) {
368
+ if (positions[a].kind === 'end' && positions[b].kind === 'end')
369
+ continue;
370
+ if (!positions[a].word || !positions[b].word)
371
+ continue;
372
+ const pair = classifyRhymePair(positions[a].word, positions[b].word);
373
+ if (pair && STRICT_INTERNAL.has(pair.type))
374
+ union(a, b);
375
+ }
376
+ }
377
+ // Letter per class: a class containing a lettered end takes that end's letter;
378
+ // an internal-only class of >=2 gets a fresh letter clear of the stanza's ends.
379
+ const endLetterAt = new Map();
380
+ positions.forEach((p, i) => { if (p.kind === 'end' && rhymes[p.line].letter !== '\u00b7')
381
+ endLetterAt.set(i, rhymes[p.line].letter); });
382
+ const used = new Set(rhymes.filter(r => r.letter !== '\u00b7').map(r => r.letter));
383
+ let code = 0;
384
+ const freshLetter = () => { let c; do {
385
+ c = String.fromCharCode(65 + (code++ % 26));
386
+ } while (used.has(c)); used.add(c); return c; };
387
+ const classMembers = new Map();
388
+ for (let i = 0; i < positions.length; i++) {
389
+ const r = find(i);
390
+ if (!classMembers.has(r))
391
+ classMembers.set(r, []);
392
+ classMembers.get(r).push(i);
393
+ }
394
+ const classLetter = new Map();
395
+ for (const [root, members] of classMembers) {
396
+ if (members.length < 2)
397
+ continue;
398
+ const endMember = members.find(m => endLetterAt.has(m));
399
+ if (endMember !== undefined)
400
+ classLetter.set(root, endLetterAt.get(endMember));
401
+ else if (members.some(m => positions[m].kind === 'internal'))
402
+ classLetter.set(root, freshLetter());
403
+ }
404
+ const typeOf = (p) => {
405
+ for (const m of classMembers.get(find(p)) ?? []) {
406
+ if (m === p)
407
+ continue;
408
+ const pair = classifyRhymePair(positions[m].word, positions[p].word);
409
+ if (pair && STRICT_INTERNAL.has(pair.type))
410
+ return pair.type;
411
+ }
412
+ return undefined;
413
+ };
414
+ for (let i = 0; i < lines.length; i++) {
415
+ const internal = [];
416
+ positions.forEach((p, idx) => {
417
+ if (p.line !== i || p.kind !== 'internal')
418
+ return;
419
+ const letter = classLetter.get(find(idx));
420
+ if (letter)
421
+ internal.push({ word: p.word, letter, type: typeOf(idx) });
422
+ });
423
+ const endLetter = rhymes[i].letter;
424
+ const intStr = internal.map(x => `(${x.letter})`).join('');
425
+ const notation = intStr + (endLetter !== '\u00b7' ? endLetter : (internal.length ? '' : '\u00b7'));
426
+ lines[i].phonologicalScansion.rhyme = {
427
+ endWord: rhymes[i].endWord,
428
+ letter: endLetter,
429
+ type: rhymes[i].type,
430
+ matchedLine: rhymes[i].matchedLine,
431
+ internal: internal.length ? internal : undefined,
432
+ notation,
433
+ };
434
+ }
315
435
  }
316
436
  /**
317
437
  * Annotate rhyme letters/types (`detail.rhyme`) and form verdicts
@@ -320,21 +440,329 @@ function lineEndWord(line) {
320
440
  * meter/scansion/certainty is touched.
321
441
  */
322
442
  export function applyRhymeAndForm(stanzas) {
443
+ // FORM detection uses per-stanza end-rhyme schemes (relative, restarting each
444
+ // stanza) — that is what couplet/quatrain/sonnet patterns are defined over.
323
445
  const analyzed = stanzas.map(lines => {
324
446
  const details = lines.map(l => l.phonologicalScansion);
325
447
  const rhymes = detectScheme(lines.map(lineEndWord));
326
- return { details, rhymes };
448
+ return { lines, details, rhymes };
327
449
  });
328
450
  for (const { details, rhymes } of analyzed) {
329
451
  const form = stanzaForm(rhymes, details);
330
- for (let i = 0; i < details.length; i++) {
331
- details[i].rhyme = rhymes[i];
332
- details[i].formNote = form;
333
- }
452
+ for (const d of details)
453
+ d.formNote = form;
334
454
  }
335
455
  const pform = poemForm(analyzed);
336
456
  if (pform)
337
457
  for (const { details } of analyzed)
338
458
  for (const d of details)
339
459
  d.formNote = pform;
460
+ // Per-stanza end-rhyme letters + TYPES (detectScheme), then the additive
461
+ // pre-caesural internal-rhyme layer. Writes `detail.rhyme`; the original
462
+ // end-rhyme display is restored and extended, never replaced.
463
+ for (const { lines, rhymes } of analyzed)
464
+ attachInternalRhymes(lines, rhymes);
465
+ }
466
+ // ─── PHONOPOETICS — poem-wide end / caesural / head rhyme, alliteration,
467
+ // acrostic, with the maintainer's three-phase lettering ─────────────
468
+ //
469
+ // Lettering proceeds in three phases over ONE shared alphabet:
470
+ // 1. END words (line 1→N): each rhyme group gets a letter A,B,C…; a later word
471
+ // reuses an earlier group's letter when it rhymes with it (across stanzas).
472
+ // 2. CAESURAL words (pre-caesura): continue the alphabet (new letter per new
473
+ // caesural rhyme); a caesural word reuses an END letter ONLY if it rhymes
474
+ // with that exact end group.
475
+ // 3. HEAD words (line-initial): continue the alphabet, a new letter per head
476
+ // rhyme (head rhymes do not reuse end/caesural letters).
477
+ // Alliteration carries NO letters; an acrostic's letters are inherent.
478
+ // Each rhyme letter is to be COLOURED by the strongest relative-stress tier among
479
+ // the syllables it spans (`topStress`) — done at the display layer.
480
+ const STRESS_ORDER = ['x', 'w', 'n', 'm', 's'];
481
+ function topStressOf(...ws) {
482
+ let best = 'x';
483
+ for (const w of ws) {
484
+ if (!w)
485
+ continue;
486
+ for (const s of w.syllables) {
487
+ const r = s.relativeStress ?? 'w';
488
+ if (STRESS_ORDER.indexOf(r) > STRESS_ORDER.indexOf(best))
489
+ best = r;
490
+ }
491
+ }
492
+ return best;
493
+ }
494
+ /** First consonant phoneme of a word (for alliteration), or '' if vowel-initial/OOV. */
495
+ function onsetPhone(word) {
496
+ const ph = phonesOf(word);
497
+ if (!ph || ph.length === 0)
498
+ return '';
499
+ const p0 = base(ph[0]);
500
+ return isVowelPhone(p0) ? '' : p0;
501
+ }
502
+ /** Whole-poem phonopoetic analysis: structured data for the Phonopoetics
503
+ * synopsis section. Read-only — never mutates the per-line scansion. */
504
+ export function analyzePhonopoetics(stanzas) {
505
+ const multi = stanzas.length > 1;
506
+ const PLs = [];
507
+ stanzas.forEach((st, s) => st.forEach((line, l) => PLs.push({ line, label: multi ? `S${s + 1}L${l + 1}` : `L${l + 1}` })));
508
+ const endObj = (pl) => { const ws = lineWords(pl.line); return ws[ws.length - 1]; };
509
+ const headObj = (pl) => lineWords(pl.line).find(w => w.isContent);
510
+ const caesuralObjs = (pl) => {
511
+ const end = endObj(pl);
512
+ const pre = preCaesuralWords(pl.line.sentence.words, pl.line.phonologicalHierarchy, pl.line.phonologicalScansion.scansion);
513
+ return pre.map(p => p.word).filter(w => w !== end && w.isContent);
514
+ };
515
+ // ── Phase 1: END rhymes (poem-wide) ──
516
+ const endWords = PLs.map(pl => lineEndWord(pl.line));
517
+ const es = detectScheme(endWords);
518
+ const endScheme = canonicalScheme(es);
519
+ const byLetter = new Map();
520
+ es.forEach((r, i) => { if (r.letter !== '·') {
521
+ (byLetter.get(r.letter) ?? byLetter.set(r.letter, []).get(r.letter)).push(i);
522
+ } });
523
+ let nextCode = byLetter.size;
524
+ const freshLetter = () => String.fromCharCode(65 + (nextCode++ % 26));
525
+ const end = [];
526
+ for (const [letter, idxs] of byLetter) {
527
+ for (let k = 1; k < idxs.length; k++) {
528
+ const i = idxs[k], j = idxs[k - 1]; // j earlier, i later → show earlier → later
529
+ end.push({
530
+ fromWord: endWords[j], fromLabel: PLs[j].label,
531
+ toWord: endWords[i], toLabel: PLs[i].label,
532
+ letter, type: es[i].type, kind: 'end',
533
+ topStress: topStressOf(endObj(PLs[i]), endObj(PLs[j])),
534
+ });
535
+ }
536
+ }
537
+ const cws = [];
538
+ PLs.forEach((pl, i) => caesuralObjs(pl).forEach(w => cws.push({ pl: i, word: w })));
539
+ // (a) bind to an exact END group it rhymes with → reuse that end letter
540
+ for (const cw of cws) {
541
+ for (let i = 0; i < PLs.length; i++) {
542
+ if (es[i].letter === '·')
543
+ continue; // unrhymed end → not a "pair"
544
+ const ew = endObj(PLs[i]);
545
+ if (!ew || ew === cw.word)
546
+ continue;
547
+ const pair = classifyRhymePair(cw.word.word, ew.word);
548
+ if (pair && STRICT_INTERNAL.has(pair.type)) {
549
+ cw.letter = es[i].letter;
550
+ cw.pLabel = PLs[i].label;
551
+ cw.pWord = surfaceOf(ew);
552
+ cw.type = pair.type;
553
+ break;
554
+ }
555
+ }
556
+ }
557
+ // (b) caesural↔caesural among the still-unbound → fresh letters
558
+ const unbound = cws.filter(c => !c.letter);
559
+ for (let i = 0; i < unbound.length; i++) {
560
+ if (unbound[i].letter)
561
+ continue;
562
+ for (let j = 0; j < i; j++) {
563
+ const pair = classifyRhymePair(unbound[i].word.word, unbound[j].word.word);
564
+ if (pair && STRICT_INTERNAL.has(pair.type)) {
565
+ if (!unbound[j].letter)
566
+ unbound[j].letter = freshLetter();
567
+ unbound[i].letter = unbound[j].letter;
568
+ // only the later member is annotated (points back) so each pair shows once
569
+ unbound[i].pLabel = PLs[unbound[j].pl].label;
570
+ unbound[i].pWord = surfaceOf(unbound[j].word);
571
+ unbound[i].type = pair.type;
572
+ break;
573
+ }
574
+ }
575
+ }
576
+ const caesural = cws.filter(c => c.letter && c.pLabel).map(c => ({
577
+ fromWord: surfaceOf(c.word), fromLabel: PLs[c.pl].label,
578
+ toWord: c.pWord, toLabel: c.pLabel,
579
+ letter: c.letter, type: c.type, kind: 'caesural',
580
+ topStress: topStressOf(c.word),
581
+ }));
582
+ const hws = PLs.map((pl, i) => ({ pl: i, word: headObj(pl) })).filter(h => h.word);
583
+ for (let i = 0; i < hws.length; i++) {
584
+ if (hws[i].letter)
585
+ continue;
586
+ for (let j = 0; j < i; j++) {
587
+ const pair = classifyRhymePair(hws[i].word.word, hws[j].word.word);
588
+ if (pair && STRICT_INTERNAL.has(pair.type)) {
589
+ if (!hws[j].letter)
590
+ hws[j].letter = freshLetter();
591
+ hws[i].letter = hws[j].letter;
592
+ // only the later member is annotated (points back) so each pair shows once
593
+ hws[i].pLabel = PLs[hws[j].pl].label;
594
+ hws[i].pWord = surfaceOf(hws[j].word);
595
+ hws[i].type = pair.type;
596
+ break;
597
+ }
598
+ }
599
+ }
600
+ const head = hws.filter(h => h.letter && h.pLabel).map(h => ({
601
+ fromWord: surfaceOf(h.word), fromLabel: PLs[h.pl].label,
602
+ toWord: h.pWord, toLabel: h.pLabel,
603
+ letter: h.letter, type: h.type, kind: 'head',
604
+ topStress: topStressOf(h.word),
605
+ }));
606
+ // ── Alliteration: maximal runs (≥2) of content words sharing first letter AND
607
+ // first consonant phoneme (function words may sit between, but do not count). ──
608
+ const alliteration = [];
609
+ for (const pl of PLs) {
610
+ const cw = lineWords(pl.line).filter(w => w.isContent);
611
+ let run = [];
612
+ const flush = () => { if (run.length >= 2)
613
+ alliteration.push({ label: pl.label, words: run.map(surfaceOf) }); run = []; };
614
+ for (const w of cw) {
615
+ const letter0 = (w.word.match(/[a-z]/i)?.[0] ?? '').toLowerCase();
616
+ const phone0 = onsetPhone(w.word);
617
+ if (!letter0 || !phone0) {
618
+ flush();
619
+ continue;
620
+ }
621
+ if (run.length === 0) {
622
+ run = [w];
623
+ continue;
624
+ }
625
+ const prev = run[run.length - 1];
626
+ const pLetter = (prev.word.match(/[a-z]/i)?.[0] ?? '').toLowerCase();
627
+ if (letter0 === pLetter && phone0 === onsetPhone(prev.word))
628
+ run.push(w);
629
+ else {
630
+ flush();
631
+ run = [w];
632
+ }
633
+ }
634
+ flush();
635
+ }
636
+ // ── Acrostic: per-stanza (and, if multi-stanza, whole-poem) first letters that
637
+ // spell a dictionary word (≥3 letters). ──
638
+ const acrostics = [];
639
+ const firstLetterOf = (line) => {
640
+ const ws = lineWords(line);
641
+ return ws.length ? (ws[0].word.match(/[a-z]/i)?.[0] ?? '').toUpperCase() : '';
642
+ };
643
+ const checkAcrostic = (lines, labels) => {
644
+ const firsts = lines.map(firstLetterOf);
645
+ if (firsts.some(f => !f))
646
+ return;
647
+ const word = firsts.join('');
648
+ if (word.length >= 3 && phonesOf(word))
649
+ acrostics.push({ labels, firsts, word });
650
+ };
651
+ stanzas.forEach((st, s) => checkAcrostic(st, st.map((_, l) => multi ? `S${s + 1}L${l + 1}` : `L${l + 1}`)));
652
+ if (multi)
653
+ checkAcrostic(stanzas.flat(), PLs.map(p => p.label));
654
+ return { endScheme, end, caesural, head, alliteration, acrostics };
655
+ }
656
+ // ─── POEM-LEVEL META-MEASURE (synopsis) ─────────────────────────────
657
+ //
658
+ // A cumulative, NON-INTERFERING reading of the whole poem, shown at the foot of
659
+ // the reading views. It draws solely on determinations already made per line
660
+ // (meter, rhythm, rhyme, form) and never overrides them. Deliberately offers
661
+ // SEVERAL top conclusions rather than forcing a single verdict.
662
+ // Canonical end-rhyme schemes of the LYRICAL FORM_REGISTRY forms whose pattern is
663
+ // expressible in plain letters (refrain forms — villanelle, pantoum, rondeau … —
664
+ // need repetition checking and are deferred). Used to NOTE whole-poem rhyme-
665
+ // scheme alignment in the synopsis; the per-stanza/poem form layer remains the
666
+ // authority for the FORM verdict itself.
667
+ const REGISTRY_FORM_SCHEMES = [
668
+ { lines: 2, scheme: 'AA', name: 'Couplet' },
669
+ { lines: 3, scheme: 'ABA', name: 'Triplet' },
670
+ { lines: 4, scheme: 'ABAB', name: 'Quatrain' },
671
+ { lines: 5, scheme: 'AABBA', name: 'Limerick' },
672
+ { lines: 6, scheme: 'ABABCC', name: 'Sextilla' },
673
+ { lines: 7, scheme: 'ABABBCC', name: 'Septet (rhyme royal)' },
674
+ { lines: 9, scheme: 'AAABBBCCC', name: 'Triad' },
675
+ { lines: 10, scheme: 'ABABCDECDE', name: 'English Ode' },
676
+ { lines: 14, scheme: 'ABABCDCDEFEFGG', name: 'Shakespearean Sonnet' },
677
+ { lines: 14, scheme: 'ABBAABBACDCDCD', name: 'Petrarchan Sonnet' },
678
+ { lines: 14, scheme: 'ABBAABBACDECDE', name: 'Petrarchan Sonnet' },
679
+ ];
680
+ function matchRegistryForm(totalLines, canonicalEndScheme) {
681
+ return REGISTRY_FORM_SCHEMES.find(f => f.lines === totalLines && f.scheme === canonicalEndScheme)?.name;
682
+ }
683
+ const descriptorOf = (d) => d.metricalityNote ? 'plausible prose'
684
+ : d.rhythmNote ? d.rhythmNote // accentual/dolnik/taktovik → beats
685
+ : d.meterName === 'free verse' ? 'free verse'
686
+ : d.meter; // e.g. "iambic pentameter"
687
+ // Compact metre labels for the synopsis ("iambic pentameter" → "iamb penta").
688
+ // Rhythm notes ("4/3 ♪beat accentual"), "free verse", and "plausible prose" are
689
+ // left untouched (they are not family+foot labels).
690
+ const _FAMILY_ABBR = {
691
+ iambic: 'iamb', trochaic: 'troch', dactylic: 'dact', anapestic: 'anap',
692
+ amphibrachic: 'amph', bacchic: 'bacch', spondaic: 'spon', pyrrhic: 'pyrr',
693
+ };
694
+ const _FOOT_ABBR = {
695
+ monometer: 'mono', dimeter: 'di', trimeter: 'tri', tetrameter: 'tetra',
696
+ pentameter: 'penta', hexameter: 'hexa', heptameter: 'hepta', octameter: 'octa',
697
+ };
698
+ function abbrevMeter(label) {
699
+ const parts = label.split(' ');
700
+ if (parts.length === 2 && _FAMILY_ABBR[parts[0]] && _FOOT_ABBR[parts[1]]) {
701
+ return `${_FAMILY_ABBR[parts[0]]} ${_FOOT_ABBR[parts[1]]}`;
702
+ }
703
+ return label;
704
+ }
705
+ /**
706
+ * Build the cumulative poem synopsis: top meter(s)/mixed meters (accentual forms
707
+ * reported in beats), the poem-wide rhyme scheme (with internal rhymes), and the
708
+ * poetic-form alignment. Reads only existing per-line determinations.
709
+ */
710
+ export function summarizePoem(stanzas) {
711
+ const lines = stanzas.flat();
712
+ const details = lines.map(l => l.phonologicalScansion);
713
+ const N = details.length;
714
+ const rows = [];
715
+ if (N === 0)
716
+ return rows;
717
+ // ── Meter — several top conclusions, never forced into one ──
718
+ const tally = new Map();
719
+ for (const d of details)
720
+ tally.set(descriptorOf(d), (tally.get(descriptorOf(d)) ?? 0) + 1);
721
+ const ranked = [...tally.entries()].sort((a, b) => b[1] - a[1]);
722
+ let meterVal;
723
+ if (N === 1) {
724
+ meterVal = abbrevMeter(ranked[0][0]);
725
+ }
726
+ else if (ranked[0][1] / N >= 0.6) {
727
+ const rest = ranked.slice(1, 3).map(([m, c]) => `${abbrevMeter(m)} (${c})`);
728
+ meterVal = `predominantly ${abbrevMeter(ranked[0][0])} (${ranked[0][1]}/${N})`
729
+ + (rest.length ? `; also ${rest.join(', ')}` : '');
730
+ }
731
+ else {
732
+ meterVal = 'Mixed | ' + ranked.slice(0, 3).map(([m, c]) => `${abbrevMeter(m)} (${c}/${N})`).join(' · ');
733
+ }
734
+ // "Rhythm" = the metre make-up; "Meter" = the mean fit % (the maintainer's
735
+ // synopsis taxonomy).
736
+ rows.push({ label: 'Rhythm', value: meterVal });
737
+ // ── Meter — mean of the per-line fit certainties ──
738
+ const meanCert = Math.round(details.reduce((s, d) => s + d.certainty, 0) / N);
739
+ rows.push({ label: 'Meter', value: `~${meanCert}% mean line-wise fit` });
740
+ // ── Form — the form layer's verdict(s) + registry-scheme alignment. (The
741
+ // detailed rhyme listing now lives in the Phonopoetics section; the synopsis
742
+ // carries only the canonical End-Rhyme Scheme, below.) ──
743
+ const endRhymes = N >= 2 ? detectScheme(lines.map(lineEndWord)) : [];
744
+ const endScheme = endRhymes.length ? canonicalScheme(endRhymes) : '';
745
+ const hasEndRhyme = endRhymes.some(r => r.letter !== '·');
746
+ const forms = [...new Set(details.map(d => d.formNote).filter((x) => !!x))];
747
+ let formVal = forms.length ? forms.join(' · ') : undefined;
748
+ if (N >= 2) {
749
+ const reg = matchRegistryForm(N, endScheme);
750
+ if (reg && !(formVal && formVal.includes(reg))) {
751
+ formVal = (formVal ? formVal + ' · ' : '') + `aligns with the ${reg} scheme`;
752
+ }
753
+ }
754
+ if (formVal)
755
+ rows.push({ label: 'Form', value: formVal });
756
+ // ── End-Rhyme Scheme — poem-wide canonical scheme, its own row (only end
757
+ // rhymes are matched against forms; caesural/head live in Phonopoetics). ──
758
+ if (hasEndRhyme)
759
+ rows.push({ label: 'End-Rhyme Scheme', value: endScheme });
760
+ // ── Heterometric advisory — the whole-poem observation that per-line beat
761
+ // counts vary widely (kept OUT of the per-line display, as an advisory here). ──
762
+ const beats = details.map(d => (d.footCount > 0 ? d.footCount : ictusProfile(d.scansion).ictuses));
763
+ const lo = Math.min(...beats), hi = Math.max(...beats);
764
+ if (N >= 3 && hi - lo >= 3) {
765
+ rows.push({ label: 'Note', value: `line lengths vary — ${lo}–${hi} beats per line` });
766
+ }
767
+ return rows;
340
768
  }
@@ -1,5 +1,14 @@
1
1
  import { ClsWord, IntonationalUnit, KeyStress, MetreName, PhonologicalScansionDetail } from './types.js';
2
2
  export declare function extractKeyStresses(ius: IntonationalUnit[], words: ClsWord[]): KeyStress[];
3
+ /**
4
+ * If a line reads as plausible prose (see above), return the advisory hedge
5
+ * string; otherwise undefined. Reads only fields already present on the detail,
6
+ * so it runs as a late, non-destructive pipeline pass (`applyMetricalityLayer`).
7
+ */
8
+ export declare function metricalityVerdict(detail: PhonologicalScansionDetail): string | undefined;
9
+ /** Set `detail.metricalityNote` on every line that reads as plausible prose.
10
+ * Non-destructive: only the new advisory field is written. */
11
+ export declare function applyMetricalityLayer(details: PhonologicalScansionDetail[]): void;
3
12
  export declare function scoreMeters(keyStresses: KeyStress[], words: ClsWord[], ius?: IntonationalUnit[], force?: MetreName): PhonologicalScansionDetail;
4
13
  /** Per-line ictus profile parsed from a scansion string ("ns|wx|ns|ws|ws"). */
5
14
  export interface IctusProfile {
@@ -1 +1 @@
1
- {"version":3,"file":"scansion.d.ts","sourceRoot":"","sources":["../src/scansion.ts"],"names":[],"mappings":"AAuBA,OAAO,EACL,OAAO,EACP,gBAAgB,EAGhB,SAAS,EACT,SAAS,EAET,0BAA0B,EAE3B,MAAM,YAAY,CAAC;AAgLpB,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,gBAAgB,EAAE,EAAE,KAAK,EAAE,OAAO,EAAE,GAAG,SAAS,EAAE,CAwCzF;AA2ZD,wBAAgB,WAAW,CACzB,WAAW,EAAE,SAAS,EAAE,EACxB,KAAK,EAAE,OAAO,EAAE,EAChB,GAAG,CAAC,EAAE,gBAAgB,EAAE,EACxB,KAAK,CAAC,EAAE,SAAS,GAChB,0BAA0B,CA4I5B;AAmBD,+EAA+E;AAC/E,MAAM,WAAW,YAAY;IAC3B,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,wBAAgB,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,YAAY,CA8B3D;AAkBD;;;;;;;;;;GAUG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,0BAA0B,EAAE,GAAG,IAAI,CAmF5E;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,oBAAoB,CAClC,OAAO,EAAE,0BAA0B,EAAE,EACrC,GAAG,GAAE,MAAc,GAClB,IAAI,CA6DN"}
1
+ {"version":3,"file":"scansion.d.ts","sourceRoot":"","sources":["../src/scansion.ts"],"names":[],"mappings":"AAuBA,OAAO,EACL,OAAO,EACP,gBAAgB,EAGhB,SAAS,EACT,SAAS,EAET,0BAA0B,EAE3B,MAAM,YAAY,CAAC;AAgLpB,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,gBAAgB,EAAE,EAAE,KAAK,EAAE,OAAO,EAAE,GAAG,SAAS,EAAE,CAwCzF;AA8cD;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,0BAA0B,GAAG,MAAM,GAAG,SAAS,CAYzF;AAED;+DAC+D;AAC/D,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,0BAA0B,EAAE,GAAG,IAAI,CAEjF;AAED,wBAAgB,WAAW,CACzB,WAAW,EAAE,SAAS,EAAE,EACxB,KAAK,EAAE,OAAO,EAAE,EAChB,GAAG,CAAC,EAAE,gBAAgB,EAAE,EACxB,KAAK,CAAC,EAAE,SAAS,GAChB,0BAA0B,CA4I5B;AAmBD,+EAA+E;AAC/E,MAAM,WAAW,YAAY;IAC3B,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,wBAAgB,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,YAAY,CA8B3D;AAkBD;;;;;;;;;;GAUG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,0BAA0B,EAAE,GAAG,IAAI,CA0F5E;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,oBAAoB,CAClC,OAAO,EAAE,0BAA0B,EAAE,EACrC,GAAG,GAAE,MAAc,GAClB,IAAI,CAmGN"}