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/display.ts CHANGED
@@ -15,6 +15,9 @@ import {
15
15
  } from './types.js';
16
16
  import { isPunctuation } from './parser.js';
17
17
  import { syllabifyWord, syllableVowelLengths } from './phonological.js';
18
+ import { computeCaesurae, CaesuraInfo } from './caesura.js';
19
+ import { computeBoundaries } from './calliope/boundaries.js';
20
+ import { summarizePoem, analyzePhonopoetics, type Phonopoetics, type RhymeRel } from './rhyme.js';
18
21
 
19
22
  // ═══════════════════════════════════════════════════════════════════════
20
23
  // COLOUR SYSTEM — Conceptually motivated palettes
@@ -62,6 +65,39 @@ const B_CAESURA_SOFT = chalk.cyan.dim; // inferred caesura (phonologica
62
65
  const B_FOOT = chalk.gray;
63
66
  const B_SILENT = chalk.gray.dim;
64
67
 
68
+ // ── Graded boundary-strength colour (Wagner Ch.4–5): cold blue (weak) → warm red
69
+ // (strong). The relational grid says boundaries differ in DEGREE, not just kind, so
70
+ // ϕ/ι brackets are tinted along a continuous spectrum by their NSBR-scaled strength
71
+ // (boundaries.ts). κ (clitic-group) boundaries are the weakest tier — a constant dim
72
+ // blue. This makes the boundary-strength dimension VISIBLE in the bracketing view.
73
+ const GRAD_STOPS: [number, [number, number, number]][] = [
74
+ [0.00, [0x6a, 0x8c, 0xc7]], // cold blue
75
+ [0.30, [0x5f, 0xc7, 0xc0]], // teal
76
+ [0.55, [0xd9, 0xc2, 0x4d]], // yellow
77
+ [0.78, [0xe0, 0x91, 0x3f]], // orange
78
+ [1.00, [0xe0, 0x56, 0x4b]], // red
79
+ ];
80
+ function gradHex(t: number): string {
81
+ const x = Math.max(0, Math.min(1, t));
82
+ for (let i = 1; i < GRAD_STOPS.length; i++) {
83
+ const [t1, c1] = GRAD_STOPS[i - 1];
84
+ const [t2, c2] = GRAD_STOPS[i];
85
+ if (x <= t2) {
86
+ const f = t2 === t1 ? 0 : (x - t1) / (t2 - t1);
87
+ const r = Math.round(c1[0] + (c2[0] - c1[0]) * f);
88
+ const g = Math.round(c1[1] + (c2[1] - c1[1]) * f);
89
+ const b = Math.round(c1[2] + (c2[2] - c1[2]) * f);
90
+ return '#' + [r, g, b].map(v => v.toString(16).padStart(2, '0')).join('');
91
+ }
92
+ }
93
+ return '#e0564b';
94
+ }
95
+ /** Colour for a ϕ/ι bracket given its boundary strength (0..1). */
96
+ function boundaryColour(strength: number): (s: string) => string {
97
+ return (s: string) => chalk.hex(gradHex(strength))(s);
98
+ }
99
+ const B_KAPPA = (s: string) => chalk.hex('#5a6f9e').dim(s); // κ — weakest, dim blue
100
+
65
101
  // Word roles
66
102
  const W_CONTENT = chalk.white;
67
103
  const W_FUNCTION = chalk.gray;
@@ -116,7 +152,7 @@ function buildColSyls(words: ClsWord[], ius: IntonationalUnit[]): ColSyl[] {
116
152
  if (isPunctuation(w.lexicalClass)) continue;
117
153
  const dep = w.dependency;
118
154
  const sylCount = w.syllables.length;
119
- const chunks = syllabifyWord(w.word, sylCount, syllableVowelLengths(w.syllables), w.morphSuffix);
155
+ const chunks = syllabifyWord(w.word, sylCount, syllableVowelLengths(w.syllables), w.morphSuffix, w.morphPrefix);
120
156
 
121
157
  for (let si = 0; si < sylCount; si++) {
122
158
  const syl = w.syllables[si];
@@ -149,11 +185,34 @@ function buildColSyls(words: ClsWord[], ius: IntonationalUnit[]): ColSyl[] {
149
185
  wordRef: w,
150
186
  });
151
187
  }
188
+ // A 0-syllable possessive enclitic ('s) has no syllable column of its own;
189
+ // append its surface to the preceding syllable so "Nature's" renders WITH its
190
+ // 's instead of as bare "Nature" (and so its κ-boundary does not collapse into
191
+ // the next group — the "Nature first" mis-bracketing the maintainer flagged).
192
+ if (sylCount === 0 && w.lexicalClass === 'POS' && result.length > 0) {
193
+ result[result.length - 1].chunk += w.word;
194
+ }
152
195
  }
153
196
  }
154
197
  }
155
198
  }
156
199
 
200
+ // Bracket-boundary flags via look-around over the SYLLABLE-bearing columns, so a
201
+ // 0-syllable token (possessive 's, an elided clitic) can never swallow a κ/ϕ/ι
202
+ // boundary: a column is first/last in its unit when the adjacent column belongs to a
203
+ // different unit. (Composite key, since cpId/ppId are indices LOCAL to their parent.)
204
+ const uKey = (c: { iuId: number; ppId: number; cpId: number }, lvl: 'cp' | 'pp' | 'iu') =>
205
+ lvl === 'cp' ? `${c.iuId}.${c.ppId}.${c.cpId}` : lvl === 'pp' ? `${c.iuId}.${c.ppId}` : `${c.iuId}`;
206
+ for (let i = 0; i < result.length; i++) {
207
+ const cur = result[i], prev = result[i - 1], next = result[i + 1];
208
+ result[i].isFirstInCP = !prev || uKey(prev, 'cp') !== uKey(cur, 'cp');
209
+ result[i].isLastInCP = !next || uKey(next, 'cp') !== uKey(cur, 'cp');
210
+ result[i].isFirstInPP = !prev || uKey(prev, 'pp') !== uKey(cur, 'pp');
211
+ result[i].isLastInPP = !next || uKey(next, 'pp') !== uKey(cur, 'pp');
212
+ result[i].isFirstInIU = !prev || uKey(prev, 'iu') !== uKey(cur, 'iu');
213
+ result[i].isLastInIU = !next || uKey(next, 'iu') !== uKey(cur, 'iu');
214
+ }
215
+
157
216
  return result;
158
217
  }
159
218
 
@@ -233,6 +292,32 @@ export function renderUnifiedDisplay(result: LineResult, rawLine?: string): stri
233
292
  lines.push(' ' + lexParts.join(''));
234
293
  lines.push('');
235
294
 
295
+ // ── Layer 3b: Phrase stress (genuine cyclic Compound + Nuclear Stress Rules) ───
296
+ // The real phrase-stress stage (bracketing.ts): the SPE/Hayes cyclic CSR (compound
297
+ // → primary LEFT) + NSR (phrase → primary RIGHT) over the dependency tree's
298
+ // constituent bracketing. 1 = STRONGEST (the utterance nuclear); higher = weaker.
299
+ // Reproduces "Mary 2, ate 3, sweet 4, ice 1, cream 5". An integer prominence
300
+ // ranking, computed INDEPENDENTLY of the relative contour below.
301
+ lines.push(H1('Phrase Stress') + ' ' + chalk.dim('1 = strongest (utterance nuclear) → higher = weaker · 0 = none'));
302
+ lines.push('');
303
+
304
+ const phrParts: string[] = [];
305
+ for (const cs of colSyls) {
306
+ if (cs.isFirstInWord && cs.globalIdx > 0) phrParts.push(' ');
307
+ if (cs.isFirstInWord) {
308
+ const ps = cs.wordRef.phraseStress || 0;
309
+ const colour = ps === 0 ? chalk.dim
310
+ : ps === 1 ? chalk.cyanBright // the utterance nuclear (strongest)
311
+ : ps <= 3 ? chalk.cyan // strong
312
+ : chalk.dim; // weak / deeply demoted
313
+ phrParts.push(colour(String(ps)));
314
+ } else {
315
+ phrParts.push(' '); // continuation syllable — keep word-start alignment
316
+ }
317
+ }
318
+ lines.push(' ' + phrParts.join(''));
319
+ lines.push('');
320
+
236
321
  // ── Layer 4: Relative stress (w/n/m/s) ──────────────────────────
237
322
  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
323
  lines.push('');
@@ -245,23 +330,37 @@ export function renderUnifiedDisplay(result: LineResult, rawLine?: string): stri
245
330
  lines.push(' ' + relParts.join(''));
246
331
  lines.push('');
247
332
 
248
- // ── Layer 5: Phonological bracketing ────────────────────────────
249
- lines.push(H1('Phonological Bracketing') + ' ' + B_CP('[]') + ' CP ' + B_PP('{}') + ' PP ' + B_IU('<>') + ' IU');
333
+ // ── Layer 5: Phonological bracketing (graded by boundary strength) ──────
334
+ lines.push(H1('Phonological Bracketing') + ' ' + B_KAPPA('[]') + ' κ ' + B_PP('{}') + ' ϕ ' + B_IU('<>') + ' ι' +
335
+ chalk.dim(' — ϕ/ι tint: ') + boundaryColour(0.1)('weak') + chalk.dim('→') + boundaryColour(1)('strong'));
250
336
  lines.push('');
251
337
 
338
+ // Graded boundary strengths (NSBR, boundaries.ts), zipped to the ϕ/ι opens as we
339
+ // walk the syllable columns: each ϕ is tinted by the strength of the break that
340
+ // introduced it (its left-edge boundary); κ stays the weakest dim-blue tier.
341
+ const bounds = computeBoundaries(words, ius);
342
+ let phiOrd = -1;
343
+ let ppColourFn: (s: string) => string = B_PP;
344
+ let iuColourFn: (s: string) => string = B_IU;
252
345
  const sylParts: string[] = [];
253
346
  let iuOpen = false, ppOpen = false, cpOpen = false;
254
347
  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; }
348
+ if (cs.isFirstInPP) {
349
+ phiOrd++;
350
+ const st = bounds.phi[phiOrd]?.strength ?? 0;
351
+ ppColourFn = boundaryColour(st);
352
+ if (cs.isFirstInIU) iuColourFn = boundaryColour(st);
353
+ }
354
+ if (cs.isFirstInIU && !iuOpen) { sylParts.push(iuColourFn('<')); iuOpen = true; }
355
+ if (cs.isFirstInPP && !ppOpen) { sylParts.push(ppColourFn('{')); ppOpen = true; }
356
+ if (cs.isFirstInCP && !cpOpen) { sylParts.push(B_KAPPA('[')); cpOpen = true; }
258
357
 
259
358
  if (cs.isFirstInWord && cs.globalIdx > 0) sylParts.push(' ');
260
359
  sylParts.push(relColour(cs.relStress)(cs.chunk));
261
360
 
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; }
361
+ if (cs.isLastInCP && cpOpen) { sylParts.push(B_KAPPA(']')); cpOpen = false; }
362
+ if (cs.isLastInPP && ppOpen) { sylParts.push(ppColourFn('}')); ppOpen = false; }
363
+ if (cs.isLastInIU && iuOpen) { sylParts.push(iuColourFn('>')); iuOpen = false; }
265
364
  }
266
365
  lines.push(' ' + sylParts.join(''));
267
366
  lines.push('');
@@ -282,7 +381,7 @@ export function renderUnifiedDisplay(result: LineResult, rawLine?: string): stri
282
381
  for (const w of words) {
283
382
  if (isPunctuation(w.lexicalClass)) continue;
284
383
  const sylCount = w.syllables.length;
285
- const chunks = syllabifyWord(w.word, sylCount, syllableVowelLengths(w.syllables), w.morphSuffix);
384
+ const chunks = syllabifyWord(w.word, sylCount, syllableVowelLengths(w.syllables), w.morphSuffix, w.morphPrefix);
286
385
  for (let si = 0; si < sylCount; si++) {
287
386
  linearSyls.push({
288
387
  chunk: chunks[si] || w.word,
@@ -300,8 +399,20 @@ export function renderUnifiedDisplay(result: LineResult, rawLine?: string): stri
300
399
  return 'xXwWnNmMsS'.includes(ch);
301
400
  }
302
401
 
402
+ // Feet whose right edge carries a caesura take the caesura GLYPH as their
403
+ // separator (matching the reading view's "xs ‖ xnw"), never a doubled "‖ |".
404
+ const joinFeet = (feet: string[], caesAfter: boolean[]): string => {
405
+ let out = '';
406
+ for (let i = 0; i < feet.length; i++) {
407
+ out += feet[i];
408
+ if (i < feet.length - 1) out += caesAfter[i] ? ' ' : B_FOOT(' | ');
409
+ }
410
+ return out;
411
+ };
412
+
303
413
  let sylIdx = 0;
304
414
  const footDisplays: string[] = [];
415
+ const footCaes: boolean[] = [];
305
416
  let prevWordRef: ClsWord | null = null;
306
417
  for (const rawFoot of feetRaw) {
307
418
  let footOut = '';
@@ -320,11 +431,13 @@ export function renderUnifiedDisplay(result: LineResult, rawLine?: string): stri
320
431
  }
321
432
  }
322
433
  const ck = caesurae.get(sylIdx); if (ck) footOut += ' ' + caesuraGlyph(ck);
434
+ footCaes.push(!!ck);
323
435
  footDisplays.push(footOut);
324
436
  }
325
- lines.push(' ' + H2('Feet: ') + footDisplays.join(B_FOOT(' | ')));
437
+ lines.push(' ' + H2('Feet: ') + joinFeet(footDisplays, footCaes));
326
438
 
327
439
  const stressDisplays: string[] = [];
440
+ const stressCaes: boolean[] = [];
328
441
  let rIdx = 0;
329
442
  for (const rawFoot of feetRaw) {
330
443
  let s = '';
@@ -337,9 +450,10 @@ export function renderUnifiedDisplay(result: LineResult, rawLine?: string): stri
337
450
  }
338
451
  }
339
452
  const ck2 = caesurae.get(rIdx); if (ck2) s += ' ' + caesuraGlyph(ck2);
453
+ stressCaes.push(!!ck2);
340
454
  stressDisplays.push(s);
341
455
  }
342
- lines.push(' ' + H2('Stress: ') + stressDisplays.join(B_FOOT(' | ')));
456
+ lines.push(' ' + H2('Stress: ') + joinFeet(stressDisplays, stressCaes));
343
457
  lines.push('');
344
458
 
345
459
  // ── Layer 7: Dependencies ───────────────────────────────────────
@@ -621,7 +735,7 @@ function normWordForm(s: string): string {
621
735
  /** Colour each orthographic syllable of an original token by its relative stress. */
622
736
  function colourToken(tokenText: string, word: ClsWord): string {
623
737
  const sylCount = Math.max(1, word.syllables.length);
624
- const chunks = syllabifyWord(tokenText, sylCount, syllableVowelLengths(word.syllables), word.morphSuffix); // partitions the WHOLE token
738
+ const chunks = syllabifyWord(tokenText, sylCount, syllableVowelLengths(word.syllables), word.morphSuffix, word.morphPrefix); // partitions the WHOLE token
625
739
  const stresses = chunks.map((_, i) => word.syllables[i]?.relativeStress ?? 'w');
626
740
 
627
741
  // Fast path: chunks reconstruct the token exactly (the common case).
@@ -699,116 +813,21 @@ export function projectStressOntoLine(rawLine: string, words: ClsWord[]): string
699
813
  }
700
814
 
701
815
  // ═══════════════════════════════════════════════════════════════════════
702
- // CAESURA PLACEMENT
816
+ // CAESURA RENDERING (placement logic now lives in caesura.ts)
703
817
  // ═══════════════════════════════════════════════════════════════════════
704
818
 
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('¦');
819
+ /** Render the glyph for a caesura, coloured by its boundary strength (Wagner
820
+ * Ch.4–5): a strong break is a warm-red '‖', a medium one an orange '¦', a weak
821
+ * one a dim '·' the same cold-blue→warm-red spectrum as the brackets. */
822
+ function caesuraGlyph(info: CaesuraInfo): string {
823
+ const glyph = info.kind === 'hard' ? '‖' : '¦';
824
+ if (info.strength < 0.34) return chalk.hex(gradHex(info.strength))('·');
825
+ return chalk.hex(gradHex(info.strength)).bold(glyph);
807
826
  }
808
827
 
809
828
  /** Colour a scansion string ("nws|nns|-wns") letter-by-letter, inserting caesura
810
829
  * marks (at foot boundaries) when a caesura map is supplied. */
811
- function colourScansionMap(scansion: string, caesurae?: Map<number, CaesuraKind>): string {
830
+ function colourScansionMap(scansion: string, caesurae?: Map<number, CaesuraInfo>): string {
812
831
  let out = '';
813
832
  let sc = 0; // syllables emitted so far
814
833
  const emitted = new Set<number>();
@@ -842,12 +861,62 @@ const METER_ABBR: Record<string, string> = {
842
861
  'free verse': 'free',
843
862
  };
844
863
 
845
- /** Compact top-3 meter fit scores, e.g. "(anap 0.81 · iamb 0.77 · amph 0.74)". */
864
+ // ── Meter-family colours ───────────────────────────────────────────
865
+ // One consistent, legible LIGHT tone per metre family, reused EVERYWHERE a
866
+ // metre is named (the reading per-line meter, the top-3 ranking, and the
867
+ // synopsis). The foot-count label (pentameter / octameter…) stays white — we
868
+ // tint only the family word, so the output is informative without being gaudy.
869
+ const METER_HUE: Record<string, (s: string) => string> = {
870
+ iambic: chalk.hex('#7fb8ff'), // light blue
871
+ trochaic: chalk.hex('#ffc24d'), // yellow / orange
872
+ dactylic: chalk.hex('#88e0a0'), // mid / light green
873
+ amphibrachic: chalk.hex('#ff9ec4'), // pinkish
874
+ anapestic: chalk.hex('#ff7a6b'), // reddish
875
+ bacchic: chalk.hex('#c08be6'), // purple / wine
876
+ spondaic: chalk.hex('#b8b8b8'),
877
+ pyrrhic: chalk.hex('#b8b8b8'),
878
+ };
879
+ const METER_FALLBACK = chalk.hex('#cfd8e3'); // free verse / unknown
880
+
881
+ /** Tint a metre-family WORD (the first token of a metre name) by its hue. */
882
+ function meterFamilyColour(family: string): (s: string) => string {
883
+ return METER_HUE[family.toLowerCase()] ?? METER_FALLBACK;
884
+ }
885
+
886
+ /** Colour a full metre label ("iambic pentameter"): family tinted, foot-count
887
+ * label left white. Bare "free verse" / multi-word non-families: fallback. */
888
+ function colourMeterLabel(meter: string): string {
889
+ const sp = meter.indexOf(' ');
890
+ if (sp < 0) return meterFamilyColour(meter)(meter);
891
+ const family = meter.slice(0, sp);
892
+ const hue = METER_HUE[family.toLowerCase()];
893
+ if (!hue) return METER_FALLBACK(meter);
894
+ return hue(family) + chalk.whiteBright(meter.slice(sp));
895
+ }
896
+
897
+ /** Tint every metre-family word/abbreviation occurring inside a free-form
898
+ * string (used to colour the synopsis values without restructuring them).
899
+ * Longest-first so "iamb" inside "iambic" is not matched before the full word. */
900
+ const _METER_WORD_RE = /\b(iambic|trochaic|dactylic|amphibrachic|anapestic|bacchic|spondaic|pyrrhic|iamb|troch|dact|amph|anap|bacch|spon|pyrr)\b/gi;
901
+ function tintMeterNames(s: string): string {
902
+ return s.replace(_METER_WORD_RE, (w) => {
903
+ const key = w.toLowerCase();
904
+ const fam = key.startsWith('iamb') ? 'iambic' : key.startsWith('troch') ? 'trochaic'
905
+ : key.startsWith('dact') ? 'dactylic' : key.startsWith('amph') ? 'amphibrachic'
906
+ : key.startsWith('anap') ? 'anapestic' : key.startsWith('bacch') ? 'bacchic'
907
+ : key.startsWith('spon') ? 'spondaic' : 'pyrrhic';
908
+ return meterFamilyColour(fam)(w);
909
+ });
910
+ }
911
+
912
+ /** Compact top-3 meter fit scores, e.g. "anap 0.81 · iamb 0.77 · amph 0.74" —
913
+ * each family abbreviation tinted its hue, the score dimmed, no enclosing
914
+ * parentheses (set off from the meter name by a dim "|" at the call site). */
846
915
  function formatRanking(ranking?: MeterScore[]): string {
847
916
  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(' · ') + ')');
917
+ const top = ranking.slice(0, 3).map(r =>
918
+ meterFamilyColour(r.meter)(METER_ABBR[r.meter] ?? r.meter) + chalk.dim(' ' + r.score.toFixed(2)));
919
+ return top.join(chalk.dim(' · '));
851
920
  }
852
921
 
853
922
  /** Divergence notes. After the continuity rename, a near-tie line's BASE
@@ -863,21 +932,35 @@ function consensusNote(detail: { consensusMeter?: string; standaloneMeter?: stri
863
932
  return chalk.dim.italic(` ↔ aligns w/ stanza ${detail.consensusMeter}`);
864
933
  }
865
934
 
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. */
935
+ /** Non-classical rhythm annotation (dolnik / taktovik / accentual), set by the
936
+ * rhythm layer. Shown as a separate chip AFTER the meter it supplements the
937
+ * classical reading (in beats), it never replaces it. */
869
938
  function rhythmNoteStr(detail: { rhythmNote?: string }): string {
870
939
  if (!detail.rhythmNote) return '';
871
- return chalk.magenta.dim(` ♪ ${detail.rhythmNote}`);
940
+ const note = detail.rhythmNote;
941
+ // Some notes (the 4/3 accentual) already carry a ♪; don't double it.
942
+ return chalk.magenta.dim(' ' + (note.includes('♪') ? note : '♪ ' + note));
872
943
  }
873
944
 
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})`) : '');
945
+ /** Rhyme chip for a line: the end-rhyme scheme letter with its rhyme TYPE
946
+ * (e.g. "A(perfect)"; '·' = unrhymed), PLUS any pre-caesural INTERNAL rhymes,
947
+ * each parenthesised and cyan with its own type, shown before the end letter:
948
+ * e.g. "(C)(perfect) A(perfect)". */
949
+ function rhymeStr(detail: {
950
+ rhyme?: { letter: string; type?: string; internal?: { letter: string; type?: string }[] };
951
+ }): string {
952
+ const r = detail.rhyme;
953
+ if (!r) return '';
954
+ const parts: string[] = [];
955
+ for (const iw of r.internal ?? []) {
956
+ parts.push(chalk.cyan(`(${iw.letter})`) + (iw.type ? chalk.dim(`(${iw.type})`) : ''));
957
+ }
958
+ if (r.letter && r.letter !== '·') {
959
+ parts.push(chalk.yellowBright(r.letter) + (r.type ? chalk.dim(`(${r.type})`) : ''));
960
+ } else if (parts.length === 0) {
961
+ parts.push(chalk.dim('·'));
962
+ }
963
+ return ' ' + parts.join(' ');
881
964
  }
882
965
 
883
966
  /** Non-punctuation, syllable-bearing words across all of a line's sentences. */
@@ -891,32 +974,102 @@ function collectLineWords(ln: ReadingLine): ClsWord[] {
891
974
  return ws;
892
975
  }
893
976
 
977
+ /**
978
+ * The Phonopoetics block of the synopsis: end / caesural / head rhymes (each
979
+ * letter coloured by the strongest relative-stress tier it spans), alliteration,
980
+ * and acrostics. Only subsections actually present in the poem are shown.
981
+ */
982
+ function renderPhonopoetics(p: Phonopoetics): string[] {
983
+ // a rhyme pair "word [A|L1(|kind)] -> word [A|L4]", letter tinted by top stress
984
+ const rel = (r: RhymeRel): string => {
985
+ const L = relColour(r.topStress)(r.letter);
986
+ const D = chalk.dim;
987
+ const kindTag = r.kind === 'end' ? '' : D('|' + r.kind);
988
+ const typ = r.type ? D(` ${r.type}`) : '';
989
+ return chalk.white(r.fromWord) + ' ' + D('[') + L + D('|') + D(r.fromLabel) + kindTag + D(']')
990
+ + D(' → ') + chalk.white(r.toWord) + ' ' + D('[') + L + D('|') + D(r.toLabel) + D(']') + typ;
991
+ };
992
+ const SEP = chalk.dim(' · ');
993
+ const sub: { label: string; body: string }[] = [];
994
+ if (p.end.length) sub.push({ label: 'End-Rhymes', body: p.end.map(rel).join(SEP) });
995
+ if (p.caesural.length) sub.push({ label: 'Caesural Rhymes', body: p.caesural.map(rel).join(SEP) });
996
+ if (p.head.length) sub.push({ label: 'Head Rhymes', body: p.head.map(rel).join(SEP) });
997
+ if (p.alliteration.length) sub.push({
998
+ label: 'Alliteration',
999
+ body: p.alliteration.map(a => chalk.white(a.words.join(' ')) + chalk.dim(` (${a.label})`)).join(SEP),
1000
+ });
1001
+ if (p.acrostics.length) sub.push({
1002
+ label: 'Acrostic',
1003
+ body: p.acrostics.map(a =>
1004
+ a.firsts.map((f, i) => chalk.dim('[' + a.labels[i] + ':') + chalk.whiteBright(f) + chalk.dim(']')).join('')
1005
+ + chalk.dim(' → ') + chalk.yellowBright(a.word)).join(SEP),
1006
+ });
1007
+ if (sub.length === 0) return [];
1008
+
1009
+ const out: string[] = ['', chalk.bold.cyan('Phonopoetics:')];
1010
+ const w = Math.max(...sub.map(s => s.label.length)) + 2;
1011
+ for (const s of sub) out.push(' ' + chalk.bold((s.label + ':').padEnd(w)) + s.body);
1012
+ return out;
1013
+ }
1014
+
894
1015
  /**
895
1016
  * Reading view: the poem itself in its original formatting, each syllable
896
1017
  * coloured by 4-tier relative stress, followed by a same-structure block of
897
1018
  * per-line stress maps + meter (with top-3 fit scores). This is the whole
898
1019
  * output for this mode — not the full per-line analytic dump.
899
1020
  */
1021
+ /** A verse line CLOSED by terminal or clause punctuation is END-STOPPED (a
1022
+ * prosodic pause at the line break); one ending on a word with no boundary
1023
+ * punctuation RUNS ON — enjambment — its intonational unit spilling into the
1024
+ * next line. (Trailing quotes/brackets are ignored when judging the close.) */
1025
+ function lineRunsOn(raw: string): boolean {
1026
+ const t = raw.replace(/["'’”»)\]]+$/, '').trimEnd();
1027
+ if (!t) return false;
1028
+ return !/[.!?;:,—–…]$/.test(t);
1029
+ }
1030
+
1031
+ /** Poem-wide enjambment summary (end-stopped vs run-on line-ends), or null for
1032
+ * a single line. The final line is terminal by position, so only the
1033
+ * line-INTERNAL breaks (lines 1..n-1) are judged. */
1034
+ function summariseEnjambment(stanzas: ReadingStanza[]): string | null {
1035
+ const raws = stanzas.flatMap(st => st.lines.map(l => l.raw));
1036
+ if (raws.length < 2) return null;
1037
+ const interior = raws.slice(0, -1);
1038
+ const enjambed: number[] = [];
1039
+ interior.forEach((r, i) => { if (lineRunsOn(r)) enjambed.push(i + 1); });
1040
+ const n = interior.length, k = enjambed.length;
1041
+ if (k === 0) return 'end-stopped throughout';
1042
+ const where = k <= 6 ? ' (lines ' + enjambed.join(', ') + ')' : '';
1043
+ return k >= Math.ceil(n / 2)
1044
+ ? `predominantly enjambed — ${k} of ${n} line-ends run on${where}`
1045
+ : `mostly end-stopped — ${k} of ${n} line-ends enjambed${where}`;
1046
+ }
1047
+
900
1048
  export function renderReadingView(stanzas: ReadingStanza[]): string {
901
1049
  const out: string[] = [];
902
1050
  const multiStanza = stanzas.length > 1;
903
1051
 
904
1052
  out.push('');
905
1053
  out.push(HR);
906
- out.push(H1('Reading View') + chalk.dim(' — stress gradient over the original text'));
1054
+ out.push(H1('Reading View') + chalk.dim(' — stress gradient over input text'));
907
1055
  out.push('');
908
1056
 
909
1057
  // ── Block 1: the poem, original formatting, syllables coloured ──
1058
+ // Multi-stanza poems get a right-aligned "Stanza N" counter in the blank line
1059
+ // before each stanza after the first (the gaps between stanzas).
910
1060
  for (let s = 0; s < stanzas.length; s++) {
1061
+ if (multiStanza && s > 0) {
1062
+ out.push('');
1063
+ out.push(chalk.dim.italic(('Stanza ' + (s + 1)).padStart(HR.length)));
1064
+ }
911
1065
  for (const ln of stanzas[s].lines) {
912
1066
  out.push(projectStressOntoLine(ln.raw, collectLineWords(ln)));
913
1067
  }
914
- if (s < stanzas.length - 1) out.push('');
915
1068
  }
916
1069
 
917
1070
  out.push('');
918
1071
  out.push(HR_THIN);
919
- out.push(H1('Stress Maps & Meter') + chalk.dim(' — top-3 fit scores per line'));
1072
+ out.push(H1('Stress Maps, Meter, & Rhymes') + chalk.dim(' — top-3 fit scores per line'));
920
1073
  out.push('');
921
1074
 
922
1075
  // ── Block 2: stress maps + meter, same stanza/line structure ──
@@ -939,16 +1092,47 @@ export function renderReadingView(stanzas: ReadingStanza[]): string {
939
1092
  const caesurae = computeCaesurae(res.sentence.words, res.phonologicalHierarchy, d.scansion);
940
1093
  const map = colourScansionMap(d.scansion, caesurae);
941
1094
  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));
1095
+ out.push(' ' + chalk.bold(label.padEnd(8)) + map + ' ' +
1096
+ colourMeterLabel(d.meter) + (rank ? chalk.dim(' | ') + rank : '') + consensusNote(d) + rhythmNoteStr(d) + rhymeStr(d));
944
1097
  }
945
1098
  }
946
1099
  if (multiStanza && s < stanzas.length - 1) out.push('');
947
1100
  }
948
1101
 
1102
+ // ── Block 3: Legend ──
1103
+ // Kept ABOVE the synopsis: the legend serves the Stress Maps & Meter, and the
1104
+ // Phonopoetics subsection of the synopsis below can run long — left at the
1105
+ // bottom it gets pushed out of the field of view.
949
1106
  out.push('');
950
1107
  out.push(HR_THIN);
951
1108
  out.push(renderLegend());
1109
+
1110
+ // ── Block 4: cumulative poem synopsis (non-interfering meta-measure) ──
1111
+ // Several top conclusions about the poem as a whole, drawn only from the
1112
+ // per-line determinations above — never overriding any of them.
1113
+ const synopsis = summarizePoem(stanzas.map(st => st.lines.flatMap(l => l.results)));
1114
+ if (synopsis.length > 0) {
1115
+ out.push('');
1116
+ out.push(HR_THIN);
1117
+ out.push(H1('Poem Synopsis') + chalk.dim(' In short, we have:'));
1118
+ out.push('');
1119
+ const w = Math.max(...synopsis.map(r => r.label.length)) + 2;
1120
+ for (const row of synopsis) {
1121
+ const label = chalk.bold.cyan((row.label + ':').padEnd(w));
1122
+ // Colour the value so the block is not a wall of white: tint any metre
1123
+ // names their family hue, and highlight the mean-fit %.
1124
+ let val = tintMeterNames(row.value);
1125
+ if (row.label === 'Meter') val = val.replace(/~\d+%/, (m) => chalk.yellow(m));
1126
+ out.push(' ' + label + val);
1127
+ }
1128
+ // Enjambment / end-stop — a poem-wide reading of the line-ends.
1129
+ const enj = summariseEnjambment(stanzas);
1130
+ if (enj) out.push(' ' + chalk.bold.cyan('Enjambment:'.padEnd(w)) + chalk.dim(enj));
1131
+ // Phonopoetics — end / caesural / head rhymes, alliteration, acrostic.
1132
+ out.push(...renderPhonopoetics(analyzePhonopoetics(stanzas.map(st => st.lines.flatMap(l => l.results)))));
1133
+ }
1134
+
1135
+ out.push('');
952
1136
  out.push(HR);
953
1137
  return out.join('\n');
954
1138
  }
package/src/engine.ts ADDED
@@ -0,0 +1,22 @@
1
+ // engine.ts — the prosody-engine abstraction (type-only; no runtime deps, so
2
+ // the concrete engines can import this without a cycle).
3
+ //
4
+ // Two engines produce the SAME shape — a per-sentence prosodic hierarchy with
5
+ // lexical/phrase/relative stress populated on the words:
6
+ // • "calliope" — the faithful, default, syntax-driven rebuild (Match-Theory
7
+ // hierarchy + Scenario A–O relation-keyed stress);
8
+ // • "clio" — a frozen snapshot of the prior pipeline, the legacy /
9
+ // alternative parse, selectable via the CLI.
10
+ // Everything downstream (metrical scoring, rhyme/form, display, synopsis) is
11
+ // shared and engine-agnostic.
12
+
13
+ import { ClsSentence, IntonationalUnit } from './types.js';
14
+
15
+ export type EngineName = 'calliope' | 'clio';
16
+
17
+ export interface ProsodyEngine {
18
+ readonly name: EngineName;
19
+ /** Populate lexical/phrase/relative stress on `sent.words` and return the
20
+ * sentence's intonational units (the prosodic hierarchy). */
21
+ analyzeSentence(sent: ClsSentence): IntonationalUnit[];
22
+ }