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/display.js CHANGED
@@ -3,6 +3,9 @@
3
3
  import chalk from 'chalk';
4
4
  import { isPunctuation } from './parser.js';
5
5
  import { syllabifyWord, syllableVowelLengths } from './phonological.js';
6
+ import { computeCaesurae } from './caesura.js';
7
+ import { computeBoundaries } from './calliope/boundaries.js';
8
+ import { summarizePoem, analyzePhonopoetics } from './rhyme.js';
6
9
  // ═══════════════════════════════════════════════════════════════════════
7
10
  // COLOUR SYSTEM — Conceptually motivated palettes
8
11
  // ═══════════════════════════════════════════════════════════════════════
@@ -51,6 +54,38 @@ const B_CAESURA = chalk.whiteBright.bold; // hard caesura (overt: punctuation /
51
54
  const B_CAESURA_SOFT = chalk.cyan.dim; // inferred caesura (phonological-phrase pause)
52
55
  const B_FOOT = chalk.gray;
53
56
  const B_SILENT = chalk.gray.dim;
57
+ // ── Graded boundary-strength colour (Wagner Ch.4–5): cold blue (weak) → warm red
58
+ // (strong). The relational grid says boundaries differ in DEGREE, not just kind, so
59
+ // ϕ/ι brackets are tinted along a continuous spectrum by their NSBR-scaled strength
60
+ // (boundaries.ts). κ (clitic-group) boundaries are the weakest tier — a constant dim
61
+ // blue. This makes the boundary-strength dimension VISIBLE in the bracketing view.
62
+ const GRAD_STOPS = [
63
+ [0.00, [0x6a, 0x8c, 0xc7]], // cold blue
64
+ [0.30, [0x5f, 0xc7, 0xc0]], // teal
65
+ [0.55, [0xd9, 0xc2, 0x4d]], // yellow
66
+ [0.78, [0xe0, 0x91, 0x3f]], // orange
67
+ [1.00, [0xe0, 0x56, 0x4b]], // red
68
+ ];
69
+ function gradHex(t) {
70
+ const x = Math.max(0, Math.min(1, t));
71
+ for (let i = 1; i < GRAD_STOPS.length; i++) {
72
+ const [t1, c1] = GRAD_STOPS[i - 1];
73
+ const [t2, c2] = GRAD_STOPS[i];
74
+ if (x <= t2) {
75
+ const f = t2 === t1 ? 0 : (x - t1) / (t2 - t1);
76
+ const r = Math.round(c1[0] + (c2[0] - c1[0]) * f);
77
+ const g = Math.round(c1[1] + (c2[1] - c1[1]) * f);
78
+ const b = Math.round(c1[2] + (c2[2] - c1[2]) * f);
79
+ return '#' + [r, g, b].map(v => v.toString(16).padStart(2, '0')).join('');
80
+ }
81
+ }
82
+ return '#e0564b';
83
+ }
84
+ /** Colour for a ϕ/ι bracket given its boundary strength (0..1). */
85
+ function boundaryColour(strength) {
86
+ return (s) => chalk.hex(gradHex(strength))(s);
87
+ }
88
+ const B_KAPPA = (s) => chalk.hex('#5a6f9e').dim(s); // κ — weakest, dim blue
54
89
  // Word roles
55
90
  const W_CONTENT = chalk.white;
56
91
  const W_FUNCTION = chalk.gray;
@@ -75,7 +110,7 @@ function buildColSyls(words, ius) {
75
110
  continue;
76
111
  const dep = w.dependency;
77
112
  const sylCount = w.syllables.length;
78
- const chunks = syllabifyWord(w.word, sylCount, syllableVowelLengths(w.syllables), w.morphSuffix);
113
+ const chunks = syllabifyWord(w.word, sylCount, syllableVowelLengths(w.syllables), w.morphSuffix, w.morphPrefix);
79
114
  for (let si = 0; si < sylCount; si++) {
80
115
  const syl = w.syllables[si];
81
116
  const lex = syl.lexicalStress ?? syl.stress;
@@ -106,10 +141,31 @@ function buildColSyls(words, ius) {
106
141
  wordRef: w,
107
142
  });
108
143
  }
144
+ // A 0-syllable possessive enclitic ('s) has no syllable column of its own;
145
+ // append its surface to the preceding syllable so "Nature's" renders WITH its
146
+ // 's instead of as bare "Nature" (and so its κ-boundary does not collapse into
147
+ // the next group — the "Nature first" mis-bracketing the maintainer flagged).
148
+ if (sylCount === 0 && w.lexicalClass === 'POS' && result.length > 0) {
149
+ result[result.length - 1].chunk += w.word;
150
+ }
109
151
  }
110
152
  }
111
153
  }
112
154
  }
155
+ // Bracket-boundary flags via look-around over the SYLLABLE-bearing columns, so a
156
+ // 0-syllable token (possessive 's, an elided clitic) can never swallow a κ/ϕ/ι
157
+ // boundary: a column is first/last in its unit when the adjacent column belongs to a
158
+ // different unit. (Composite key, since cpId/ppId are indices LOCAL to their parent.)
159
+ const uKey = (c, lvl) => lvl === 'cp' ? `${c.iuId}.${c.ppId}.${c.cpId}` : lvl === 'pp' ? `${c.iuId}.${c.ppId}` : `${c.iuId}`;
160
+ for (let i = 0; i < result.length; i++) {
161
+ const cur = result[i], prev = result[i - 1], next = result[i + 1];
162
+ result[i].isFirstInCP = !prev || uKey(prev, 'cp') !== uKey(cur, 'cp');
163
+ result[i].isLastInCP = !next || uKey(next, 'cp') !== uKey(cur, 'cp');
164
+ result[i].isFirstInPP = !prev || uKey(prev, 'pp') !== uKey(cur, 'pp');
165
+ result[i].isLastInPP = !next || uKey(next, 'pp') !== uKey(cur, 'pp');
166
+ result[i].isFirstInIU = !prev || uKey(prev, 'iu') !== uKey(cur, 'iu');
167
+ result[i].isLastInIU = !next || uKey(next, 'iu') !== uKey(cur, 'iu');
168
+ }
113
169
  return result;
114
170
  }
115
171
  // ═══════════════════════════════════════════════════════════════════════
@@ -181,6 +237,32 @@ export function renderUnifiedDisplay(result, rawLine) {
181
237
  }
182
238
  lines.push(' ' + lexParts.join(''));
183
239
  lines.push('');
240
+ // ── Layer 3b: Phrase stress (genuine cyclic Compound + Nuclear Stress Rules) ───
241
+ // The real phrase-stress stage (bracketing.ts): the SPE/Hayes cyclic CSR (compound
242
+ // → primary LEFT) + NSR (phrase → primary RIGHT) over the dependency tree's
243
+ // constituent bracketing. 1 = STRONGEST (the utterance nuclear); higher = weaker.
244
+ // Reproduces "Mary 2, ate 3, sweet 4, ice 1, cream 5". An integer prominence
245
+ // ranking, computed INDEPENDENTLY of the relative contour below.
246
+ lines.push(H1('Phrase Stress') + ' ' + chalk.dim('1 = strongest (utterance nuclear) → higher = weaker · 0 = none'));
247
+ lines.push('');
248
+ const phrParts = [];
249
+ for (const cs of colSyls) {
250
+ if (cs.isFirstInWord && cs.globalIdx > 0)
251
+ phrParts.push(' ');
252
+ if (cs.isFirstInWord) {
253
+ const ps = cs.wordRef.phraseStress || 0;
254
+ const colour = ps === 0 ? chalk.dim
255
+ : ps === 1 ? chalk.cyanBright // the utterance nuclear (strongest)
256
+ : ps <= 3 ? chalk.cyan // strong
257
+ : chalk.dim; // weak / deeply demoted
258
+ phrParts.push(colour(String(ps)));
259
+ }
260
+ else {
261
+ phrParts.push(' '); // continuation syllable — keep word-start alignment
262
+ }
263
+ }
264
+ lines.push(' ' + phrParts.join(''));
265
+ lines.push('');
184
266
  // ── Layer 4: Relative stress (w/n/m/s) ──────────────────────────
185
267
  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)');
186
268
  lines.push('');
@@ -192,37 +274,52 @@ export function renderUnifiedDisplay(result, rawLine) {
192
274
  }
193
275
  lines.push(' ' + relParts.join(''));
194
276
  lines.push('');
195
- // ── Layer 5: Phonological bracketing ────────────────────────────
196
- lines.push(H1('Phonological Bracketing') + ' ' + B_CP('[]') + ' CP ' + B_PP('{}') + ' PP ' + B_IU('<>') + ' IU');
277
+ // ── Layer 5: Phonological bracketing (graded by boundary strength) ──────
278
+ lines.push(H1('Phonological Bracketing') + ' ' + B_KAPPA('[]') + ' κ ' + B_PP('{}') + ' ϕ ' + B_IU('<>') + ' ι' +
279
+ chalk.dim(' — ϕ/ι tint: ') + boundaryColour(0.1)('weak') + chalk.dim('→') + boundaryColour(1)('strong'));
197
280
  lines.push('');
281
+ // Graded boundary strengths (NSBR, boundaries.ts), zipped to the ϕ/ι opens as we
282
+ // walk the syllable columns: each ϕ is tinted by the strength of the break that
283
+ // introduced it (its left-edge boundary); κ stays the weakest dim-blue tier.
284
+ const bounds = computeBoundaries(words, ius);
285
+ let phiOrd = -1;
286
+ let ppColourFn = B_PP;
287
+ let iuColourFn = B_IU;
198
288
  const sylParts = [];
199
289
  let iuOpen = false, ppOpen = false, cpOpen = false;
200
290
  for (const cs of colSyls) {
291
+ if (cs.isFirstInPP) {
292
+ phiOrd++;
293
+ const st = bounds.phi[phiOrd]?.strength ?? 0;
294
+ ppColourFn = boundaryColour(st);
295
+ if (cs.isFirstInIU)
296
+ iuColourFn = boundaryColour(st);
297
+ }
201
298
  if (cs.isFirstInIU && !iuOpen) {
202
- sylParts.push(B_IU('<'));
299
+ sylParts.push(iuColourFn('<'));
203
300
  iuOpen = true;
204
301
  }
205
302
  if (cs.isFirstInPP && !ppOpen) {
206
- sylParts.push(B_PP('{'));
303
+ sylParts.push(ppColourFn('{'));
207
304
  ppOpen = true;
208
305
  }
209
306
  if (cs.isFirstInCP && !cpOpen) {
210
- sylParts.push(B_CP('['));
307
+ sylParts.push(B_KAPPA('['));
211
308
  cpOpen = true;
212
309
  }
213
310
  if (cs.isFirstInWord && cs.globalIdx > 0)
214
311
  sylParts.push(' ');
215
312
  sylParts.push(relColour(cs.relStress)(cs.chunk));
216
313
  if (cs.isLastInCP && cpOpen) {
217
- sylParts.push(B_CP(']'));
314
+ sylParts.push(B_KAPPA(']'));
218
315
  cpOpen = false;
219
316
  }
220
317
  if (cs.isLastInPP && ppOpen) {
221
- sylParts.push(B_PP('}'));
318
+ sylParts.push(ppColourFn('}'));
222
319
  ppOpen = false;
223
320
  }
224
321
  if (cs.isLastInIU && iuOpen) {
225
- sylParts.push(B_IU('>'));
322
+ sylParts.push(iuColourFn('>'));
226
323
  iuOpen = false;
227
324
  }
228
325
  }
@@ -238,7 +335,7 @@ export function renderUnifiedDisplay(result, rawLine) {
238
335
  if (isPunctuation(w.lexicalClass))
239
336
  continue;
240
337
  const sylCount = w.syllables.length;
241
- const chunks = syllabifyWord(w.word, sylCount, syllableVowelLengths(w.syllables), w.morphSuffix);
338
+ const chunks = syllabifyWord(w.word, sylCount, syllableVowelLengths(w.syllables), w.morphSuffix, w.morphPrefix);
242
339
  for (let si = 0; si < sylCount; si++) {
243
340
  linearSyls.push({
244
341
  chunk: chunks[si] || w.word,
@@ -253,8 +350,20 @@ export function renderUnifiedDisplay(result, rawLine) {
253
350
  function isSyllableChar(ch) {
254
351
  return 'xXwWnNmMsS'.includes(ch);
255
352
  }
353
+ // Feet whose right edge carries a caesura take the caesura GLYPH as their
354
+ // separator (matching the reading view's "xs ‖ xnw"), never a doubled "‖ |".
355
+ const joinFeet = (feet, caesAfter) => {
356
+ let out = '';
357
+ for (let i = 0; i < feet.length; i++) {
358
+ out += feet[i];
359
+ if (i < feet.length - 1)
360
+ out += caesAfter[i] ? ' ' : B_FOOT(' | ');
361
+ }
362
+ return out;
363
+ };
256
364
  let sylIdx = 0;
257
365
  const footDisplays = [];
366
+ const footCaes = [];
258
367
  let prevWordRef = null;
259
368
  for (const rawFoot of feetRaw) {
260
369
  let footOut = '';
@@ -277,10 +386,12 @@ export function renderUnifiedDisplay(result, rawLine) {
277
386
  const ck = caesurae.get(sylIdx);
278
387
  if (ck)
279
388
  footOut += ' ' + caesuraGlyph(ck);
389
+ footCaes.push(!!ck);
280
390
  footDisplays.push(footOut);
281
391
  }
282
- lines.push(' ' + H2('Feet: ') + footDisplays.join(B_FOOT(' | ')));
392
+ lines.push(' ' + H2('Feet: ') + joinFeet(footDisplays, footCaes));
283
393
  const stressDisplays = [];
394
+ const stressCaes = [];
284
395
  let rIdx = 0;
285
396
  for (const rawFoot of feetRaw) {
286
397
  let s = '';
@@ -299,9 +410,10 @@ export function renderUnifiedDisplay(result, rawLine) {
299
410
  const ck2 = caesurae.get(rIdx);
300
411
  if (ck2)
301
412
  s += ' ' + caesuraGlyph(ck2);
413
+ stressCaes.push(!!ck2);
302
414
  stressDisplays.push(s);
303
415
  }
304
- lines.push(' ' + H2('Stress: ') + stressDisplays.join(B_FOOT(' | ')));
416
+ lines.push(' ' + H2('Stress: ') + joinFeet(stressDisplays, stressCaes));
305
417
  lines.push('');
306
418
  // ── Layer 7: Dependencies ───────────────────────────────────────
307
419
  lines.push(H1('Dependencies'));
@@ -550,7 +662,7 @@ function normWordForm(s) {
550
662
  /** Colour each orthographic syllable of an original token by its relative stress. */
551
663
  function colourToken(tokenText, word) {
552
664
  const sylCount = Math.max(1, word.syllables.length);
553
- const chunks = syllabifyWord(tokenText, sylCount, syllableVowelLengths(word.syllables), word.morphSuffix); // partitions the WHOLE token
665
+ const chunks = syllabifyWord(tokenText, sylCount, syllableVowelLengths(word.syllables), word.morphSuffix, word.morphPrefix); // partitions the WHOLE token
554
666
  const stresses = chunks.map((_, i) => word.syllables[i]?.relativeStress ?? 'w');
555
667
  // Fast path: chunks reconstruct the token exactly (the common case).
556
668
  if (chunks.join('') === tokenText) {
@@ -633,113 +745,17 @@ export function projectStressOntoLine(rawLine, words) {
633
745
  out += rawLine.slice(cursor); // trailing text, verbatim
634
746
  return out;
635
747
  }
636
- /** Foot-boundary syllable indices from a scansion string (cumulative syllable
637
- * count after each foot; silent beats '-' are not syllables). */
638
- function footBoundarySet(scansion) {
639
- const set = new Set();
640
- let c = 0;
641
- for (const foot of scansion.split('|')) {
642
- for (const ch of foot)
643
- if ('xwnms'.includes(ch))
644
- c++;
645
- set.add(c);
646
- }
647
- return set;
648
- }
649
- // A new phonological/syntactic phrase opens at these POS tags: prepositions &
650
- // subordinators (IN), infinitival "to" (TO), coordinators (CC), wh/relativizers,
651
- // verb-particles (RP), and the predicate's verb/modal. The major medial caesura
652
- // of a line falls immediately BEFORE such a word — far more reliably read off the
653
- // (robust) POS tags than off FinNLP's (noisy) phonological-phrase grouping, which
654
- // mis-bracketed e.g. "The epic | feast…". Determiners/articles are excluded (they
655
- // continue a phrase a preposition already opened: "as | one empty bag").
656
- const PHRASE_ONSET_POS = new Set([
657
- 'IN', 'TO', 'CC', 'WDT', 'WP', 'WP$', 'WRB', 'RP',
658
- 'VB', 'VBD', 'VBG', 'VBN', 'VBP', 'VBZ', 'MD',
659
- ]);
660
- // Directional/spatial adverbs that open a phrase ("snowed-in OUT of many routes",
661
- // "drifting DOWN to sleep"). FinNLP often tags these RB rather than RP/IN, so the
662
- // POS set alone misses them; a small curated lemma list recovers them with low
663
- // over-fire risk (a generic RB like "very"/"quickly" is NOT a phrase onset).
664
- const DIRECTIONAL_ONSET = new Set([
665
- 'out', 'in', 'up', 'down', 'off', 'away', 'back', 'forth', 'over',
666
- 'around', 'along', 'through', 'apart', 'aside', 'onward', 'onwards',
667
- ]);
668
- /** True if a word opens a new phonological/syntactic phrase (a caesura candidate). */
669
- function isPhraseOnset(w) {
670
- if (PHRASE_ONSET_POS.has(w.lexicalClass))
671
- return true;
672
- return w.lexicalClass === 'RB' && DIRECTIONAL_ONSET.has(w.word.toLowerCase());
673
- }
674
- /**
675
- * Caesura positions for a line, keyed by the syllable index AFTER which the pause
676
- * falls (= number of syllables to its left).
677
- * • 'hard' — an overt break at an Intonational-Unit boundary (comma, dash, colon,
678
- * semicolon …): the punctuation projection.
679
- * • 'soft' — a single INFERRED medial caesura for a punctuation-free line (Kiparsky
680
- * 1975, via McAleese: "phonological phrasing determines the location of caesurae
681
- * in verse"). Candidates are the boundaries just before a phrase/clause onset
682
- * (PHRASE_ONSET_POS); the one nearest the line's midpoint that lies in the
683
- * central third AND coincides with a foot boundary wins — so it is medial, never
684
- * mid-foot, and consistent across structurally-parallel lines. Read in LINEAR
685
- * order (robust to clitic-group reordering); needs a line of ≥ 8 syllables.
686
- */
687
- function computeCaesurae(words, ius, scansion) {
688
- const caes = new Map();
689
- const iuOf = new Map();
690
- for (let i = 0; i < ius.length; i++) {
691
- for (const pp of ius[i].phonologicalPhrases) {
692
- for (const cg of pp.cliticGroups)
693
- for (const tok of cg.tokens)
694
- iuOf.set(tok, i);
695
- }
696
- }
697
- let cum = 0;
698
- let prevIu;
699
- let prevWasContentful = false;
700
- const onsetPositions = [];
701
- for (const w of words) {
702
- if (isPunctuation(w.lexicalClass) || w.syllables.length === 0)
703
- continue;
704
- const iu = iuOf.get(w);
705
- if (prevIu !== undefined && iu !== undefined && iu !== prevIu) {
706
- caes.set(cum, 'hard'); // IU boundary → hard caesura
707
- }
708
- // A phrase-onset word that is NOT the line's first word opens a candidate
709
- // caesura immediately before it.
710
- if (prevWasContentful && isPhraseOnset(w))
711
- onsetPositions.push(cum);
712
- cum += w.syllables.length;
713
- prevIu = iu;
714
- prevWasContentful = true;
715
- }
716
- const total = cum;
717
- // Infer ONE medial caesura only when the line carries no overt (hard) break.
718
- if (caes.size === 0 && total >= 8 && onsetPositions.length > 0) {
719
- const footEdges = scansion ? footBoundarySet(scansion) : null;
720
- const mid = total / 2;
721
- const lo = Math.max(2, Math.ceil(total / 3));
722
- const hi = Math.floor((2 * total) / 3);
723
- let best = -1, bestDist = Infinity;
724
- for (const c of onsetPositions) {
725
- if (c < lo || c > hi)
726
- continue; // medial third only
727
- if (footEdges && !footEdges.has(c))
728
- continue; // align to a foot boundary
729
- const d = Math.abs(c - mid);
730
- if (d < bestDist) {
731
- bestDist = d;
732
- best = c;
733
- }
734
- }
735
- if (best > 0)
736
- caes.set(best, 'soft');
737
- }
738
- return caes;
739
- }
740
- /** Render the glyph for a caesura kind. */
741
- function caesuraGlyph(kind) {
742
- return kind === 'hard' ? B_CAESURA('‖') : B_CAESURA_SOFT('¦');
748
+ // ═══════════════════════════════════════════════════════════════════════
749
+ // CAESURA RENDERING (placement logic now lives in caesura.ts)
750
+ // ═══════════════════════════════════════════════════════════════════════
751
+ /** Render the glyph for a caesura, coloured by its boundary strength (Wagner
752
+ * Ch.4–5): a strong break is a warm-red '‖', a medium one an orange '¦', a weak
753
+ * one a dim '·' — the same cold-blue→warm-red spectrum as the brackets. */
754
+ function caesuraGlyph(info) {
755
+ const glyph = info.kind === 'hard' ? '‖' : '¦';
756
+ if (info.strength < 0.34)
757
+ return chalk.hex(gradHex(info.strength))('·');
758
+ return chalk.hex(gradHex(info.strength)).bold(glyph);
743
759
  }
744
760
  /** Colour a scansion string ("nws|nns|-wns") letter-by-letter, inserting caesura
745
761
  * marks (at foot boundaries) when a caesura map is supplied. */
@@ -778,13 +794,60 @@ const METER_ABBR = {
778
794
  amphibrachic: 'amph', bacchic: 'bacch', spondaic: 'spon', pyrrhic: 'pyrr',
779
795
  'free verse': 'free',
780
796
  };
781
- /** Compact top-3 meter fit scores, e.g. "(anap 0.81 · iamb 0.77 · amph 0.74)". */
797
+ // ── Meter-family colours ───────────────────────────────────────────
798
+ // One consistent, legible LIGHT tone per metre family, reused EVERYWHERE a
799
+ // metre is named (the reading per-line meter, the top-3 ranking, and the
800
+ // synopsis). The foot-count label (pentameter / octameter…) stays white — we
801
+ // tint only the family word, so the output is informative without being gaudy.
802
+ const METER_HUE = {
803
+ iambic: chalk.hex('#7fb8ff'), // light blue
804
+ trochaic: chalk.hex('#ffc24d'), // yellow / orange
805
+ dactylic: chalk.hex('#88e0a0'), // mid / light green
806
+ amphibrachic: chalk.hex('#ff9ec4'), // pinkish
807
+ anapestic: chalk.hex('#ff7a6b'), // reddish
808
+ bacchic: chalk.hex('#c08be6'), // purple / wine
809
+ spondaic: chalk.hex('#b8b8b8'),
810
+ pyrrhic: chalk.hex('#b8b8b8'),
811
+ };
812
+ const METER_FALLBACK = chalk.hex('#cfd8e3'); // free verse / unknown
813
+ /** Tint a metre-family WORD (the first token of a metre name) by its hue. */
814
+ function meterFamilyColour(family) {
815
+ return METER_HUE[family.toLowerCase()] ?? METER_FALLBACK;
816
+ }
817
+ /** Colour a full metre label ("iambic pentameter"): family tinted, foot-count
818
+ * label left white. Bare "free verse" / multi-word non-families: fallback. */
819
+ function colourMeterLabel(meter) {
820
+ const sp = meter.indexOf(' ');
821
+ if (sp < 0)
822
+ return meterFamilyColour(meter)(meter);
823
+ const family = meter.slice(0, sp);
824
+ const hue = METER_HUE[family.toLowerCase()];
825
+ if (!hue)
826
+ return METER_FALLBACK(meter);
827
+ return hue(family) + chalk.whiteBright(meter.slice(sp));
828
+ }
829
+ /** Tint every metre-family word/abbreviation occurring inside a free-form
830
+ * string (used to colour the synopsis values without restructuring them).
831
+ * Longest-first so "iamb" inside "iambic" is not matched before the full word. */
832
+ const _METER_WORD_RE = /\b(iambic|trochaic|dactylic|amphibrachic|anapestic|bacchic|spondaic|pyrrhic|iamb|troch|dact|amph|anap|bacch|spon|pyrr)\b/gi;
833
+ function tintMeterNames(s) {
834
+ return s.replace(_METER_WORD_RE, (w) => {
835
+ const key = w.toLowerCase();
836
+ const fam = key.startsWith('iamb') ? 'iambic' : key.startsWith('troch') ? 'trochaic'
837
+ : key.startsWith('dact') ? 'dactylic' : key.startsWith('amph') ? 'amphibrachic'
838
+ : key.startsWith('anap') ? 'anapestic' : key.startsWith('bacch') ? 'bacchic'
839
+ : key.startsWith('spon') ? 'spondaic' : 'pyrrhic';
840
+ return meterFamilyColour(fam)(w);
841
+ });
842
+ }
843
+ /** Compact top-3 meter fit scores, e.g. "anap 0.81 · iamb 0.77 · amph 0.74" —
844
+ * each family abbreviation tinted its hue, the score dimmed, no enclosing
845
+ * parentheses (set off from the meter name by a dim "|" at the call site). */
782
846
  function formatRanking(ranking) {
783
847
  if (!ranking || ranking.length === 0)
784
848
  return '';
785
- const top = ranking.slice(0, 3)
786
- .map(r => `${METER_ABBR[r.meter] ?? r.meter} ${r.score.toFixed(2)}`);
787
- return chalk.dim('(' + top.join(' · ') + ')');
849
+ const top = ranking.slice(0, 3).map(r => meterFamilyColour(r.meter)(METER_ABBR[r.meter] ?? r.meter) + chalk.dim(' ' + r.score.toFixed(2)));
850
+ return top.join(chalk.dim(' · '));
788
851
  }
789
852
  /** Divergence notes. After the continuity rename, a near-tie line's BASE
790
853
  * meter is already the stanza/poem-dominant one and `standaloneMeter` records
@@ -799,23 +862,35 @@ function consensusNote(detail) {
799
862
  return '';
800
863
  return chalk.dim.italic(` ↔ aligns w/ stanza ${detail.consensusMeter}`);
801
864
  }
802
- /** Non-classical rhythm annotation (dolnik / taktovik / accentual /
803
- * alternating ictus counts), set by the rhythm layer. Empty when the line
804
- * has no such reading. */
865
+ /** Non-classical rhythm annotation (dolnik / taktovik / accentual), set by the
866
+ * rhythm layer. Shown as a separate chip AFTER the meter it supplements the
867
+ * classical reading (in beats), it never replaces it. */
805
868
  function rhythmNoteStr(detail) {
806
869
  if (!detail.rhythmNote)
807
870
  return '';
808
- return chalk.magenta.dim(` ♪ ${detail.rhythmNote}`);
871
+ const note = detail.rhythmNote;
872
+ // Some notes (the 4/3 accentual) already carry a ♪; don't double it.
873
+ return chalk.magenta.dim(' ' + (note.includes('♪') ? note : '♪ ' + note));
809
874
  }
810
- /** End-rhyme chip for a line: scheme letter plus (dimmed) rhyme type when the
811
- * line rhymes with an earlier one — e.g. "B(perfect)"; '·' = unrhymed. */
875
+ /** Rhyme chip for a line: the end-rhyme scheme letter with its rhyme TYPE
876
+ * (e.g. "A(perfect)"; '·' = unrhymed), PLUS any pre-caesural INTERNAL rhymes,
877
+ * each parenthesised and cyan with its own type, shown before the end letter:
878
+ * e.g. "(C)(perfect) A(perfect)". */
812
879
  function rhymeStr(detail) {
813
- if (!detail.rhyme)
880
+ const r = detail.rhyme;
881
+ if (!r)
814
882
  return '';
815
- const { letter, type } = detail.rhyme;
816
- if (letter === '·')
817
- return ' ' + chalk.dim('·');
818
- return ' ' + chalk.yellowBright(letter) + (type ? chalk.dim(`(${type})`) : '');
883
+ const parts = [];
884
+ for (const iw of r.internal ?? []) {
885
+ parts.push(chalk.cyan(`(${iw.letter})`) + (iw.type ? chalk.dim(`(${iw.type})`) : ''));
886
+ }
887
+ if (r.letter && r.letter !== '·') {
888
+ parts.push(chalk.yellowBright(r.letter) + (r.type ? chalk.dim(`(${r.type})`) : ''));
889
+ }
890
+ else if (parts.length === 0) {
891
+ parts.push(chalk.dim('·'));
892
+ }
893
+ return ' ' + parts.join(' ');
819
894
  }
820
895
  /** Non-punctuation, syllable-bearing words across all of a line's sentences. */
821
896
  function collectLineWords(ln) {
@@ -828,30 +903,105 @@ function collectLineWords(ln) {
828
903
  }
829
904
  return ws;
830
905
  }
906
+ /**
907
+ * The Phonopoetics block of the synopsis: end / caesural / head rhymes (each
908
+ * letter coloured by the strongest relative-stress tier it spans), alliteration,
909
+ * and acrostics. Only subsections actually present in the poem are shown.
910
+ */
911
+ function renderPhonopoetics(p) {
912
+ // a rhyme pair "word [A|L1(|kind)] -> word [A|L4]", letter tinted by top stress
913
+ const rel = (r) => {
914
+ const L = relColour(r.topStress)(r.letter);
915
+ const D = chalk.dim;
916
+ const kindTag = r.kind === 'end' ? '' : D('|' + r.kind);
917
+ const typ = r.type ? D(` ${r.type}`) : '';
918
+ return chalk.white(r.fromWord) + ' ' + D('[') + L + D('|') + D(r.fromLabel) + kindTag + D(']')
919
+ + D(' → ') + chalk.white(r.toWord) + ' ' + D('[') + L + D('|') + D(r.toLabel) + D(']') + typ;
920
+ };
921
+ const SEP = chalk.dim(' · ');
922
+ const sub = [];
923
+ if (p.end.length)
924
+ sub.push({ label: 'End-Rhymes', body: p.end.map(rel).join(SEP) });
925
+ if (p.caesural.length)
926
+ sub.push({ label: 'Caesural Rhymes', body: p.caesural.map(rel).join(SEP) });
927
+ if (p.head.length)
928
+ sub.push({ label: 'Head Rhymes', body: p.head.map(rel).join(SEP) });
929
+ if (p.alliteration.length)
930
+ sub.push({
931
+ label: 'Alliteration',
932
+ body: p.alliteration.map(a => chalk.white(a.words.join(' ')) + chalk.dim(` (${a.label})`)).join(SEP),
933
+ });
934
+ if (p.acrostics.length)
935
+ sub.push({
936
+ label: 'Acrostic',
937
+ body: p.acrostics.map(a => a.firsts.map((f, i) => chalk.dim('[' + a.labels[i] + ':') + chalk.whiteBright(f) + chalk.dim(']')).join('')
938
+ + chalk.dim(' → ') + chalk.yellowBright(a.word)).join(SEP),
939
+ });
940
+ if (sub.length === 0)
941
+ return [];
942
+ const out = ['', chalk.bold.cyan('Phonopoetics:')];
943
+ const w = Math.max(...sub.map(s => s.label.length)) + 2;
944
+ for (const s of sub)
945
+ out.push(' ' + chalk.bold((s.label + ':').padEnd(w)) + s.body);
946
+ return out;
947
+ }
831
948
  /**
832
949
  * Reading view: the poem itself in its original formatting, each syllable
833
950
  * coloured by 4-tier relative stress, followed by a same-structure block of
834
951
  * per-line stress maps + meter (with top-3 fit scores). This is the whole
835
952
  * output for this mode — not the full per-line analytic dump.
836
953
  */
954
+ /** A verse line CLOSED by terminal or clause punctuation is END-STOPPED (a
955
+ * prosodic pause at the line break); one ending on a word with no boundary
956
+ * punctuation RUNS ON — enjambment — its intonational unit spilling into the
957
+ * next line. (Trailing quotes/brackets are ignored when judging the close.) */
958
+ function lineRunsOn(raw) {
959
+ const t = raw.replace(/["'’”»)\]]+$/, '').trimEnd();
960
+ if (!t)
961
+ return false;
962
+ return !/[.!?;:,—–…]$/.test(t);
963
+ }
964
+ /** Poem-wide enjambment summary (end-stopped vs run-on line-ends), or null for
965
+ * a single line. The final line is terminal by position, so only the
966
+ * line-INTERNAL breaks (lines 1..n-1) are judged. */
967
+ function summariseEnjambment(stanzas) {
968
+ const raws = stanzas.flatMap(st => st.lines.map(l => l.raw));
969
+ if (raws.length < 2)
970
+ return null;
971
+ const interior = raws.slice(0, -1);
972
+ const enjambed = [];
973
+ interior.forEach((r, i) => { if (lineRunsOn(r))
974
+ enjambed.push(i + 1); });
975
+ const n = interior.length, k = enjambed.length;
976
+ if (k === 0)
977
+ return 'end-stopped throughout';
978
+ const where = k <= 6 ? ' (lines ' + enjambed.join(', ') + ')' : '';
979
+ return k >= Math.ceil(n / 2)
980
+ ? `predominantly enjambed — ${k} of ${n} line-ends run on${where}`
981
+ : `mostly end-stopped — ${k} of ${n} line-ends enjambed${where}`;
982
+ }
837
983
  export function renderReadingView(stanzas) {
838
984
  const out = [];
839
985
  const multiStanza = stanzas.length > 1;
840
986
  out.push('');
841
987
  out.push(HR);
842
- out.push(H1('Reading View') + chalk.dim(' — stress gradient over the original text'));
988
+ out.push(H1('Reading View') + chalk.dim(' — stress gradient over input text'));
843
989
  out.push('');
844
990
  // ── Block 1: the poem, original formatting, syllables coloured ──
991
+ // Multi-stanza poems get a right-aligned "Stanza N" counter in the blank line
992
+ // before each stanza after the first (the gaps between stanzas).
845
993
  for (let s = 0; s < stanzas.length; s++) {
994
+ if (multiStanza && s > 0) {
995
+ out.push('');
996
+ out.push(chalk.dim.italic(('Stanza ' + (s + 1)).padStart(HR.length)));
997
+ }
846
998
  for (const ln of stanzas[s].lines) {
847
999
  out.push(projectStressOntoLine(ln.raw, collectLineWords(ln)));
848
1000
  }
849
- if (s < stanzas.length - 1)
850
- out.push('');
851
1001
  }
852
1002
  out.push('');
853
1003
  out.push(HR_THIN);
854
- out.push(H1('Stress Maps & Meter') + chalk.dim(' — top-3 fit scores per line'));
1004
+ out.push(H1('Stress Maps, Meter, & Rhymes') + chalk.dim(' — top-3 fit scores per line'));
855
1005
  out.push('');
856
1006
  // ── Block 2: stress maps + meter, same stanza/line structure ──
857
1007
  for (let s = 0; s < stanzas.length; s++) {
@@ -875,16 +1025,47 @@ export function renderReadingView(stanzas) {
875
1025
  const caesurae = computeCaesurae(res.sentence.words, res.phonologicalHierarchy, d.scansion);
876
1026
  const map = colourScansionMap(d.scansion, caesurae);
877
1027
  const rank = formatRanking(d.ranking);
878
- out.push(' ' + chalk.bold(label.padEnd(8)) + map + ' ' +
879
- chalk.whiteBright(d.meter) + (rank ? ' ' + rank : '') + consensusNote(d) + rhythmNoteStr(d) + rhymeStr(d));
1028
+ out.push(' ' + chalk.bold(label.padEnd(8)) + map + ' ' +
1029
+ colourMeterLabel(d.meter) + (rank ? chalk.dim(' | ') + rank : '') + consensusNote(d) + rhythmNoteStr(d) + rhymeStr(d));
880
1030
  }
881
1031
  }
882
1032
  if (multiStanza && s < stanzas.length - 1)
883
1033
  out.push('');
884
1034
  }
1035
+ // ── Block 3: Legend ──
1036
+ // Kept ABOVE the synopsis: the legend serves the Stress Maps & Meter, and the
1037
+ // Phonopoetics subsection of the synopsis below can run long — left at the
1038
+ // bottom it gets pushed out of the field of view.
885
1039
  out.push('');
886
1040
  out.push(HR_THIN);
887
1041
  out.push(renderLegend());
1042
+ // ── Block 4: cumulative poem synopsis (non-interfering meta-measure) ──
1043
+ // Several top conclusions about the poem as a whole, drawn only from the
1044
+ // per-line determinations above — never overriding any of them.
1045
+ const synopsis = summarizePoem(stanzas.map(st => st.lines.flatMap(l => l.results)));
1046
+ if (synopsis.length > 0) {
1047
+ out.push('');
1048
+ out.push(HR_THIN);
1049
+ out.push(H1('Poem Synopsis') + chalk.dim(' In short, we have:'));
1050
+ out.push('');
1051
+ const w = Math.max(...synopsis.map(r => r.label.length)) + 2;
1052
+ for (const row of synopsis) {
1053
+ const label = chalk.bold.cyan((row.label + ':').padEnd(w));
1054
+ // Colour the value so the block is not a wall of white: tint any metre
1055
+ // names their family hue, and highlight the mean-fit %.
1056
+ let val = tintMeterNames(row.value);
1057
+ if (row.label === 'Meter')
1058
+ val = val.replace(/~\d+%/, (m) => chalk.yellow(m));
1059
+ out.push(' ' + label + val);
1060
+ }
1061
+ // Enjambment / end-stop — a poem-wide reading of the line-ends.
1062
+ const enj = summariseEnjambment(stanzas);
1063
+ if (enj)
1064
+ out.push(' ' + chalk.bold.cyan('Enjambment:'.padEnd(w)) + chalk.dim(enj));
1065
+ // Phonopoetics — end / caesural / head rhymes, alliteration, acrostic.
1066
+ out.push(...renderPhonopoetics(analyzePhonopoetics(stanzas.map(st => st.lines.flatMap(l => l.results)))));
1067
+ }
1068
+ out.push('');
888
1069
  out.push(HR);
889
1070
  return out.join('\n');
890
1071
  }
@@ -0,0 +1,9 @@
1
+ import { ClsSentence, IntonationalUnit } from './types.js';
2
+ export type EngineName = 'calliope' | 'clio';
3
+ export interface ProsodyEngine {
4
+ readonly name: EngineName;
5
+ /** Populate lexical/phrase/relative stress on `sent.words` and return the
6
+ * sentence's intonational units (the prosodic hierarchy). */
7
+ analyzeSentence(sent: ClsSentence): IntonationalUnit[];
8
+ }
9
+ //# sourceMappingURL=engine.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"AAYA,OAAO,EAAE,WAAW,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAE3D,MAAM,MAAM,UAAU,GAAG,UAAU,GAAG,MAAM,CAAC;AAE7C,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAC;IAC1B;kEAC8D;IAC9D,eAAe,CAAC,IAAI,EAAE,WAAW,GAAG,gBAAgB,EAAE,CAAC;CACxD"}