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/scansion.js CHANGED
@@ -567,10 +567,20 @@ function buildScansionString(syls, feet, ius) {
567
567
  return out.join('|');
568
568
  }
569
569
  // ─── DISPLAY / NAMING HELPERS ──────────────────────────────────────
570
+ // Greek-numeral foot-length names, exhaustive through 20 (icosameter) per the
571
+ // maintainer's standardised nomenclature. Long lines that are genuinely metrical
572
+ // deserve a real meter name rather than the "N-feet" othering, so the ladder runs
573
+ // all the way up; beyond 20 the bare "N-feet" fallback remains (lines that long
574
+ // are almost never integrally metrical, and the prose-likeness hedge handles them).
575
+ const LINE_LENGTH_NAMES = [
576
+ '', 'monometer', 'dimeter', 'trimeter', 'tetrameter', 'pentameter',
577
+ 'hexameter', 'heptameter', 'octameter', 'nonometer', 'decameter',
578
+ 'hendecameter', 'dodecameter', 'triskaidecameter', 'tetradecameter',
579
+ 'pentadecameter', 'hexadecameter', 'heptadecameter', 'octadecameter',
580
+ 'enneadecameter', 'icosameter',
581
+ ];
570
582
  function lineLengthName(feet) {
571
- const names = ['', 'monometer', 'dimeter', 'trimeter', 'tetrameter', 'pentameter',
572
- 'hexameter', 'heptameter', 'octameter', 'nonameter', 'decameter'];
573
- return names[feet] || `${feet}-feet`;
583
+ return LINE_LENGTH_NAMES[feet] || `${feet}-feet`;
574
584
  }
575
585
  // ─── TOP-LEVEL METER SCORING ────────────────────────────────────────
576
586
  // A meter's small intrinsic prior. Iamb is the unmarked default of English
@@ -583,6 +593,7 @@ const METER_PRIOR = { iambic: 0.02 };
583
593
  // enough that it never overturns a clearly-duple line.
584
594
  const TERNARY_BIAS = 0.02;
585
595
  const TERNARY_METERS = new Set(['anapestic', 'dactylic', 'amphibrachic', 'bacchic']);
596
+ const DUPLE_METERS = new Set(['iambic', 'trochaic']);
586
597
  // Weights against the (0..1) normalised fit fraction.
587
598
  const REDGE_WEIGHT = 0.28; // right-edge (key-stress) agreement — disambiguates polarity
588
599
  const CLEAN_WEIGHT = 0.12; // share of feet realised cleanly (real beats, no substitution)
@@ -613,6 +624,74 @@ function onsetBonus(flat, meter) {
613
624
  return FALLING_METERS.has(meter) ? ONSET_WEIGHT : 0;
614
625
  return RISING_METERS.has(meter) ? ONSET_WEIGHT : 0;
615
626
  }
627
+ // ─── METRICALITY ASSESSMENT (Option-0 prose-likeness hedge) ─────────
628
+ //
629
+ // A single decontextualised line is, in generative-metrics terms, almost always
630
+ // fittable to SOME grid (English prose alternates; phrase-ends are right-strong),
631
+ // so absolute fit cannot separate prose from verse — empirically, loose real
632
+ // verse ("Half a league…", Prufrock) scores BELOW expository prose. What DOES
633
+ // separate them is non-periodicity accumulated AT LENGTH: a long run of text that
634
+ // (a) commits to no meter — its top candidates straddle BOTH the rising/falling
635
+ // and the duple/triple divides within a hair — and (b) realises that best fit only
636
+ // weakly. This is a deliberately HIGH-PRECISION gate: it fires only on the
637
+ // unmistakable un-lineated-prose case and never on short, loose, or ternary verse
638
+ // (the project's prized cases). It is advisory — it changes the displayed verdict
639
+ // WORDING only; the scansion, fit, ranking, foot count and certainty are intact.
640
+ // Necessary length: real metrical lines top out around the hepta-/octameter, so
641
+ // 9+ feet is almost never an integral line. Combined (AND) with the commitment
642
+ // and confidence gates below, clean verse that happens to exceed this length keeps
643
+ // a healthy margin and certainty and is therefore spared.
644
+ const PROSE_MIN_FEET = 9;
645
+ const PROSE_MAX_MARGIN = 0.10; // top1 − top2: prose does not commit to one meter
646
+ // …and realises even its best fit only weakly. This ceiling is an empirical
647
+ // calibration against the relative-stress contour, NOT a theory — re-fit when the
648
+ // contour changes. Verified (2026-06-21, after the dependency-driven ϕ rebuild):
649
+ // across 1736 real litlab verse lines, ZERO pass the length+margin+straddle gates,
650
+ // so the certainty ceiling never gates real verse — its only job is to admit the
651
+ // genuinely-prose case, which the improved contour now realises at 68%.
652
+ const PROSE_MAX_CERTAINTY = 70;
653
+ /** Does the top-3 ranking straddle BOTH polarity (rising/falling) AND foot-size
654
+ * (duple/triple)? The fingerprint of a contour equidistant from every meter —
655
+ * present in prose, absent in committed verse (whose ties stay within a family). */
656
+ function rankingStraddles(ranking) {
657
+ const top = ranking.slice(0, 3).map(r => r.meter);
658
+ const rising = top.some(m => RISING_METERS.has(m));
659
+ const falling = top.some(m => FALLING_METERS.has(m));
660
+ const triple = top.some(m => TERNARY_METERS.has(m));
661
+ const duple = top.some(m => DUPLE_METERS.has(m));
662
+ return rising && falling && triple && duple;
663
+ }
664
+ /**
665
+ * If a line reads as plausible prose (see above), return the advisory hedge
666
+ * string; otherwise undefined. Reads only fields already present on the detail,
667
+ * so it runs as a late, non-destructive pipeline pass (`applyMetricalityLayer`).
668
+ */
669
+ export function metricalityVerdict(detail) {
670
+ if (detail.meterName === 'free verse')
671
+ return undefined; // already non-committal
672
+ if (detail.rhythmNote)
673
+ return undefined; // accentual/dolnik already named
674
+ if (detail.footCount < PROSE_MIN_FEET)
675
+ return undefined;
676
+ const ranking = detail.ranking;
677
+ if (!ranking || ranking.length < 2)
678
+ return undefined;
679
+ const margin = ranking[0].score - ranking[1].score;
680
+ if (margin >= PROSE_MAX_MARGIN)
681
+ return undefined; // commits to one meter
682
+ if (detail.certainty >= PROSE_MAX_CERTAINTY)
683
+ return undefined;
684
+ if (!rankingStraddles(ranking))
685
+ return undefined;
686
+ return `No consistent metered rhythm(s) discerned. Reads as plausible prose. `
687
+ + `(Closest fit: ${detail.meter}, ${detail.certainty}%)`;
688
+ }
689
+ /** Set `detail.metricalityNote` on every line that reads as plausible prose.
690
+ * Non-destructive: only the new advisory field is written. */
691
+ export function applyMetricalityLayer(details) {
692
+ for (const d of details)
693
+ d.metricalityNote = metricalityVerdict(d);
694
+ }
616
695
  export function scoreMeters(keyStresses, words, ius, force) {
617
696
  const flat = flattenSyllables(words, ius);
618
697
  const N = flat.length;
@@ -872,7 +951,9 @@ export function applyRhythmLayer(details) {
872
951
  const pooled = profiles.flatMap(p => p.intervals);
873
952
  const family = intervalFamily(pooled);
874
953
  const flavour = family === 'dolnik' ? 'dolnik' : 'accentual';
875
- note = `alternating ${evens[0]}·${odds[0]}-ictus ${flavour}`;
954
+ // "4/3 ♪beat accentual" — no "alternating" (too long), "/" not "·" (so
955
+ // "4·3" is not misread as 12), ♪ marks that these are beat counts.
956
+ note = `${evens[0]}/${odds[0]} ♪beat ${flavour}`;
876
957
  }
877
958
  else {
878
959
  // Constant ictus count (mode covering ≥70% of lines, total spread ≤1).
@@ -891,9 +972,13 @@ export function applyRhythmLayer(details) {
891
972
  // duple/ternary pooled intervals with varying syllable counts =
892
973
  // anacrusis/clausula variation only — classical machinery's domain.
893
974
  }
894
- else if (spread >= 3) {
895
- note = 'free verse (heterometric)';
896
- }
975
+ // NB: a high-spread stanza with NO constant beat count is NOT stamped
976
+ // here. Forcing a "heterometric" rhythmNote onto every line both
977
+ // polluted the per-line display and (because the continuity pass is
978
+ // gated by rhythmNote) blocked the stanza-continuity rename. Lines
979
+ // instead keep their own meter and get a per-line dolnik/accentual
980
+ // reading below; the whole-poem heterometric observation is reported
981
+ // by the synopsis (summarizePoem), outside the per-line section.
897
982
  }
898
983
  if (note)
899
984
  for (const d of lines)
@@ -939,20 +1024,34 @@ export function applyStanzaConsensus(details, tie = 0.975) {
939
1024
  continue;
940
1025
  counts.set(d.meterName, (counts.get(d.meterName) ?? 0) + 1);
941
1026
  }
942
- // Dominant meter = the strict, unique plurality (≥2 lines).
1027
+ // Dominant meter = the strict plurality (≥2 lines). A TIED plurality is broken
1028
+ // by each tied family's total ranking-score mass across the stanza (2026-07-02):
1029
+ // catalectic lines make sibling readings trade wins line-by-line (The Raven's
1030
+ // 15-syllable lines flip trochaic-octameter ↔ "iambic heptameter", tying the
1031
+ // count 3–3), but the family the stanza actually commits to carries more total
1032
+ // fit mass — so the tie is evidence-weighted, never a coin toss or a give-up.
943
1033
  let dominant = '';
944
1034
  let max = 0;
945
- let tied = false;
1035
+ const atMax = [];
946
1036
  for (const [m, c] of counts) {
947
1037
  if (c > max) {
948
1038
  max = c;
949
- dominant = m;
950
- tied = false;
1039
+ atMax.length = 0;
1040
+ atMax.push(m);
951
1041
  }
952
1042
  else if (c === max)
953
- tied = true;
1043
+ atMax.push(m);
954
1044
  }
955
- if (max < 2 || tied || !dominant)
1045
+ if (max < 2)
1046
+ return;
1047
+ if (atMax.length === 1) {
1048
+ dominant = atMax[0];
1049
+ }
1050
+ else {
1051
+ const mass = (name) => details.reduce((s, d) => s + (d.ranking?.find(r => r.meter === name)?.score ?? 0), 0);
1052
+ dominant = atMax.reduce((a, b) => (mass(b) > mass(a) ? b : a));
1053
+ }
1054
+ if (!dominant)
956
1055
  return;
957
1056
  // Ternary ANACRUSIS ANCHOR (Gasparov): when the stanza's dominant meter is
958
1057
  // ternary, the family is fixed by the stanza's anacrusis profile, not by the
@@ -968,8 +1067,29 @@ export function applyStanzaConsensus(details, tie = 0.975) {
968
1067
  if (!TERNARY_METERS.has(d.meterName))
969
1068
  continue;
970
1069
  const p = ictusProfile(d.scansion);
971
- if (p.ictuses >= 2 && p.anacrusis <= 2)
972
- anacs.push(p.anacrusis);
1070
+ if (p.ictuses < 2)
1071
+ continue;
1072
+ let anac = p.anacrusis;
1073
+ if (RISING_METERS.has(d.meterName)) {
1074
+ // For a RISING line the raw profile's Attridge promotions pollute the
1075
+ // anacrusis: a promoted 'w'/'n' upbeat at position 0 ("'TWAS the night
1076
+ // before…") reads as anacrusis 0 and vetoes the anapest call. The
1077
+ // family anchor wants the SCHEME's first beat, so measure to the first
1078
+ // STRONG (m/s) ictus instead. Falling lines keep the raw profile —
1079
+ // their genuine first beat is often a light 'n' ("HALF a league").
1080
+ const letters = d.scansion.replace(/[^xwnms]/g, '');
1081
+ const firstStrong = [...letters].findIndex(c => c === 's' || c === 'm');
1082
+ if (firstStrong >= 0)
1083
+ anac = firstStrong;
1084
+ // Over-stressed anacrusis (Gasparov): an extra stress on the upbeat
1085
+ // does NOT change the meter — a rising line whose first strong sits at
1086
+ // position 0 ("NOT a creature was stirring…") has a stressed upbeat,
1087
+ // not a dactylic opening; drop it from the anchor vote entirely.
1088
+ if (anac === 0)
1089
+ continue;
1090
+ }
1091
+ if (anac <= 2)
1092
+ anacs.push(anac);
973
1093
  }
974
1094
  if (anacs.length >= 2) {
975
1095
  const has = (v) => anacs.includes(v);
@@ -995,9 +1115,16 @@ export function applyStanzaConsensus(details, tie = 0.975) {
995
1115
  // spondaic anacrusis ("big BOOKS that are HURting…") lets the dactylic fit
996
1116
  // edge out the stanza's amphibrachs by seizing the clash syllable as an
997
1117
  // extra beat (Gasparov: an over-stressed anacrusis does NOT change the
998
- // meter). Non-sibling divergence keeps the stricter 0.975 near-tie.
999
- const siblings = TERNARY_METERS.has(d.meterName)
1000
- && TERNARY_METERS.has(dominant);
1118
+ // meter). The DUPLE pair (iamb/trochee) are siblings in exactly the same
1119
+ // sense (2026-07-02): a catalectic trochaic line IS an "iambic" grid plus
1120
+ // an offset — The Raven's 15-syllable lines read trochaic-octameter or
1121
+ // iambic-heptameter on the same beats — so a stanza committed to one duple
1122
+ // family pulls near-tie sibling readings in at the same relaxed window.
1123
+ // Non-sibling divergence keeps the stricter 0.975 near-tie.
1124
+ const siblings = (TERNARY_METERS.has(d.meterName)
1125
+ && TERNARY_METERS.has(dominant))
1126
+ || (DUPLE_METERS.has(d.meterName)
1127
+ && DUPLE_METERS.has(dominant));
1001
1128
  const threshold = siblings ? 0.95 : tie;
1002
1129
  if (own > 0 && dom >= own * threshold) {
1003
1130
  const lengthWord = d.meter.split(' ')[1] ?? '';
@@ -0,0 +1,44 @@
1
+ import { ClsWord } from './types.js';
2
+ /**
3
+ * A STRANDED preposition: an IN preposition whose complement has been extracted
4
+ * (wh-movement / relativisation / topicalisation), so it governs no object and
5
+ * sits clause-finally — "what are you waiting FOR", "…what you stare AT". Such
6
+ * a preposition bears stress (it is not the reducible proclitic of "in the
7
+ * house"). Conservative: IN only (infinitival TO is excluded — "I want to go"
8
+ * is not stranding), no dependent, and clause-final (the canonical strand site).
9
+ */
10
+ export declare function isStrandedPreposition(word: ClsWord, words: ClsWord[]): boolean;
11
+ /**
12
+ * A CONTRASTIVE possessive: a possessive determiner (PRP$: thy/my/your/her…)
13
+ * in the elliptical contrast frame "X's … not/but MINE" — the contrast lifts
14
+ * the possessor out of reduction ("it was THY choice, not mine"). Tight by
15
+ * construction: requires a contrast marker (not/but/nor) adjacent to an
16
+ * absolute possessive somewhere in the clause, so an ordinary unfocused
17
+ * possessive ("I lost my way") is left alone.
18
+ */
19
+ export declare function isContrastivePossessive(word: ClsWord, words: ClsWord[]): boolean;
20
+ /**
21
+ * A fronted DEICTIC LOCATIVE "there"/"here" in locative inversion — "THERE
22
+ * could I marvel", "HERE could I rest". FinNLP mis-tags the fronted locative
23
+ * as existential (EX / expl) or reduces it as a discourse adverb, flattening it
24
+ * to 'w'; but a fronted locative that triggers subject-aux inversion (an
25
+ * aux/modal immediately followed by a subject pronoun) is a stressed deictic
26
+ * focus, NOT the reduced existential of "there IS a house" (no inversion) or
27
+ * the presentational "there LIVED a king" (verb + NP, no inversion).
28
+ */
29
+ export declare function isDeicticLocative(word: ClsWord, words: ClsWord[]): boolean;
30
+ /**
31
+ * Imperative clause: the ROOT is a base-form verb (VB) with no overt subject
32
+ * (no NSUBJ dependent) — "Tell me…", "Do not go…". Used by the nuclear pass:
33
+ * the accent falls on the verb / its object, not on a (dropped) subject, and an
34
+ * imperative-clause vocative is a direct address.
35
+ */
36
+ export declare function isImperativeClause(words: ClsWord[]): boolean;
37
+ /**
38
+ * A VOCATIVE (direct address): a noun tagged DISCOURSE/INTJ/DEP and set off by
39
+ * adjacent punctuation (a comma or "!"), in a clause that is imperative or
40
+ * subject-less — "Sing, O GODDESS…", "blow, BUGLE, blow". Conservative: the
41
+ * noun must be comma/!-adjacent so an ordinary argument noun is not swept in.
42
+ */
43
+ export declare function isVocative(word: ClsWord, words: ClsWord[]): boolean;
44
+ //# sourceMappingURL=semantics.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"semantics.d.ts","sourceRoot":"","sources":["../src/semantics.ts"],"names":[],"mappings":"AAWA,OAAO,EAAE,OAAO,EAAE,MAAM,YAAY,CAAC;AAsBrC;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,OAAO,EAAE,GAAG,OAAO,CAI9E;AAQD;;;;;;;GAOG;AACH,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,OAAO,EAAE,GAAG,OAAO,CAQhF;AAMD;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,OAAO,EAAE,GAAG,OAAO,CAc1E;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,OAAO,EAAE,GAAG,OAAO,CAS5D;AAED;;;;;GAKG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,OAAO,EAAE,GAAG,OAAO,CAUnE"}
@@ -0,0 +1,139 @@
1
+ // semantics.ts — Prominence signals mined from the dependency parse + POS.
2
+ //
3
+ // Dependency roles ARE the semantic layer: NSUBJ=agent, DOBJ/IOBJ=patient/
4
+ // recipient, OBL/ADVMOD/ADVCL=peripheral, PRP$=possessor, INTJ/DISCOURSE=
5
+ // address. A flat POS floor crushes function words that these configurations
6
+ // reveal to be prominent — a STRANDED preposition ("what are you waiting FOR"),
7
+ // a CONTRASTIVE possessive ("thy choice, not mine"), a VOCATIVE. These
8
+ // detectors recover that prominence from observable structure only (no semantic
9
+ // guessing, no cross-poem givenness). They are consumed by the relativiser
10
+ // (stress.ts) as targeted floor RAISES, and by the nuclear pass (Phase 4).
11
+ import { isPunctuation } from './parser.js';
12
+ /** True if some other token has `word` as its dependency governor (i.e. word
13
+ * has a complement/dependent of its own). */
14
+ function hasDependent(word, words) {
15
+ for (const w of words) {
16
+ if (w !== word && w.dependency && w.dependency.governor === word)
17
+ return true;
18
+ }
19
+ return false;
20
+ }
21
+ /** Last non-punctuation token index in the sentence at/after `from`? */
22
+ function isClauseFinal(word, words) {
23
+ const idx = words.indexOf(word);
24
+ if (idx < 0)
25
+ return false;
26
+ for (let k = idx + 1; k < words.length; k++) {
27
+ if (!isPunctuation(words[k].lexicalClass))
28
+ return false;
29
+ }
30
+ return true;
31
+ }
32
+ /**
33
+ * A STRANDED preposition: an IN preposition whose complement has been extracted
34
+ * (wh-movement / relativisation / topicalisation), so it governs no object and
35
+ * sits clause-finally — "what are you waiting FOR", "…what you stare AT". Such
36
+ * a preposition bears stress (it is not the reducible proclitic of "in the
37
+ * house"). Conservative: IN only (infinitival TO is excluded — "I want to go"
38
+ * is not stranding), no dependent, and clause-final (the canonical strand site).
39
+ */
40
+ export function isStrandedPreposition(word, words) {
41
+ if (word.lexicalClass !== 'IN')
42
+ return false;
43
+ if (hasDependent(word, words))
44
+ return false; // has a complement → ordinary preposition
45
+ return isClauseFinal(word, words);
46
+ }
47
+ /** Absolute / elliptical possessive pronouns used as the contrasted element. */
48
+ const ABSOLUTE_POSSESSIVES = new Set([
49
+ 'mine', 'thine', 'yours', 'hers', 'ours', 'theirs', 'his',
50
+ ]);
51
+ const CONTRAST_MARKERS = new Set(['not', 'but', 'nor']);
52
+ /**
53
+ * A CONTRASTIVE possessive: a possessive determiner (PRP$: thy/my/your/her…)
54
+ * in the elliptical contrast frame "X's … not/but MINE" — the contrast lifts
55
+ * the possessor out of reduction ("it was THY choice, not mine"). Tight by
56
+ * construction: requires a contrast marker (not/but/nor) adjacent to an
57
+ * absolute possessive somewhere in the clause, so an ordinary unfocused
58
+ * possessive ("I lost my way") is left alone.
59
+ */
60
+ export function isContrastivePossessive(word, words) {
61
+ if (word.lexicalClass !== 'PRP$')
62
+ return false;
63
+ for (let i = 0; i + 1 < words.length; i++) {
64
+ const a = words[i].word.toLowerCase();
65
+ const b = words[i + 1].word.toLowerCase().replace(/['’]/g, '');
66
+ if (CONTRAST_MARKERS.has(a) && ABSOLUTE_POSSESSIVES.has(b))
67
+ return true;
68
+ }
69
+ return false;
70
+ }
71
+ /** Finite auxiliaries / modals whose appearance before a subject pronoun marks
72
+ * subject-aux inversion. */
73
+ const INVERSION_AUX = new Set(['MD', 'VBP', 'VBZ', 'VBD']);
74
+ /**
75
+ * A fronted DEICTIC LOCATIVE "there"/"here" in locative inversion — "THERE
76
+ * could I marvel", "HERE could I rest". FinNLP mis-tags the fronted locative
77
+ * as existential (EX / expl) or reduces it as a discourse adverb, flattening it
78
+ * to 'w'; but a fronted locative that triggers subject-aux inversion (an
79
+ * aux/modal immediately followed by a subject pronoun) is a stressed deictic
80
+ * focus, NOT the reduced existential of "there IS a house" (no inversion) or
81
+ * the presentational "there LIVED a king" (verb + NP, no inversion).
82
+ */
83
+ export function isDeicticLocative(word, words) {
84
+ const lemma = word.word.toLowerCase().replace(/['’]/g, '');
85
+ if (lemma !== 'there' && lemma !== 'here')
86
+ return false;
87
+ const idx = words.indexOf(word);
88
+ // must be the first non-punctuation token (fronted)
89
+ let first = -1;
90
+ for (let i = 0; i < words.length; i++) {
91
+ if (!isPunctuation(words[i].lexicalClass)) {
92
+ first = i;
93
+ break;
94
+ }
95
+ }
96
+ if (idx !== first)
97
+ return false;
98
+ // subject-aux inversion: <there/here> <aux|modal> <subject pronoun>
99
+ const aux = words[idx + 1];
100
+ const subj = words[idx + 2];
101
+ return !!(aux && subj && INVERSION_AUX.has(aux.lexicalClass) && subj.lexicalClass === 'PRP');
102
+ }
103
+ /**
104
+ * Imperative clause: the ROOT is a base-form verb (VB) with no overt subject
105
+ * (no NSUBJ dependent) — "Tell me…", "Do not go…". Used by the nuclear pass:
106
+ * the accent falls on the verb / its object, not on a (dropped) subject, and an
107
+ * imperative-clause vocative is a direct address.
108
+ */
109
+ export function isImperativeClause(words) {
110
+ const root = words.find(w => w.dependency && w.dependency.dependentType === 'root');
111
+ if (!root)
112
+ return false;
113
+ if (root.lexicalClass !== 'VB' && root.lexicalClass !== 'VBP')
114
+ return false;
115
+ for (const w of words) {
116
+ if (w.dependency && w.dependency.governor === root
117
+ && /nsubj/i.test(w.dependency.dependentType))
118
+ return false;
119
+ }
120
+ return true;
121
+ }
122
+ /**
123
+ * A VOCATIVE (direct address): a noun tagged DISCOURSE/INTJ/DEP and set off by
124
+ * adjacent punctuation (a comma or "!"), in a clause that is imperative or
125
+ * subject-less — "Sing, O GODDESS…", "blow, BUGLE, blow". Conservative: the
126
+ * noun must be comma/!-adjacent so an ordinary argument noun is not swept in.
127
+ */
128
+ export function isVocative(word, words) {
129
+ if (!/^(NN|NNS|NNP|NNPS)$/.test(word.lexicalClass))
130
+ return false;
131
+ const role = word.dependency?.dependentType ?? '';
132
+ if (!/discourse|intj|dep|vocative/i.test(role))
133
+ return false;
134
+ const idx = words.indexOf(word);
135
+ const prev = idx > 0 ? words[idx - 1] : null;
136
+ const next = idx + 1 < words.length ? words[idx + 1] : null;
137
+ const commaAdjacent = (prev && /^[,!]$/.test(prev.word)) || (next && /^[,!]$/.test(next.word));
138
+ return !!commaAdjacent && isImperativeClause(words);
139
+ }
package/dist/stress.d.ts CHANGED
@@ -1,4 +1,79 @@
1
1
  import { ClsWord, IntonationalUnit } from './types.js';
2
+ /** Check if a pair of words forms a left‑stressed compound. */
3
+ export declare function isLeftStressedPair(w1: string, w2: string): boolean;
4
+ /** True if `w2` is a head noun that keeps phrasal/right stress against an N1
5
+ * modifier (apple PIE, summer DAY, Fifth AVenue) — the marked right-stress
6
+ * exceptions to the otherwise fore-stressing Compound Stress Rule. */
7
+ export declare function isRightStressedHead(w2: string): boolean;
8
+ /**
9
+ * Direction of primary stress for an adjacent two-word modification structure,
10
+ * the SINGLE source of truth shared by the lexical compound pass
11
+ * (`applyCompoundStress`) and the Phrase-Stress phase (`computePhraseStress`),
12
+ * so the two layers cannot disagree.
13
+ *
14
+ * 'left' = fore-stress, primary on w1 — the Compound Stress Rule default for
15
+ * an N+N compound (Chomsky–Halle; McAleese's worked example marks
16
+ * ICE cream with primary on "ice", not "cream"): KITCHen table,
17
+ * WINdow frame, BEDroom door, plus the curated LEFT_STRESS_* sets.
18
+ * 'right' = end-stress, primary on w2 — the marked exceptions: food/temporal
19
+ * "made of N1" heads (apple PIE, summer DAY), Adj+N which is phrasal
20
+ * not compound (sweet CREAM, red CAR), and proper-name sequences
21
+ * which carry their own right-headed prosody (New YORK, John SMITH).
22
+ * null = not a compound/modification pair at all.
23
+ *
24
+ * The fore-stress default is restricted to COMMON-noun N+N: proper-noun pairs
25
+ * (NNP/NNPS) are excluded because place- and personal-name sequences are not
26
+ * reliably fore-stressed, and flipping them would mis-teach New YORK / John SMITH.
27
+ */
28
+ export declare function compoundStressSide(w1: string, pos1: string, w2: string, pos2: string): 'left' | 'right' | null;
29
+ /** Display-only prefix detection for ALL words (in-vocab AND OOV).
30
+ *
31
+ * The OOV-only `morphologicalStress` prefix path (Tier 1b) only fires when no
32
+ * suffix decomposition is found — so "disillusions" (OOV, suffix "-s" → stem
33
+ * "disillusion" IN-VOCAB) returns before the prefix path runs, and
34
+ * "uneducated" (IN-VOCAB) never hits the OOV branch at all. Neither gets
35
+ * `morphPrefix` set, and the display syllabifier's Maximal Onset principle
36
+ * pulls the prefix's final consonant into the next syllable (di·sil·lu·sions,
37
+ * u·ne·du·ca·ted).
38
+ *
39
+ * This pass runs AFTER `assignLexicalStress` for every word: if it starts with
40
+ * a known productive prefix (from `PREFIX_RULES`) and the stripped stem is in
41
+ * the nounsing-pro dictionary, set `word.morphPrefix`. The syllabifier then
42
+ * peels the prefix as the first syllable(s) and syllabifies the stem
43
+ * separately, respecting the morpheme boundary (dis·il·lu·sions,
44
+ * un·ed·u·ca·ted). DISPLAY-ONLY — never changes stress or meter.
45
+ *
46
+ * Guards: the stem must carry a primary stress (so we don't peel a prefix off
47
+ * a function word like "into"), and the prefix must be at least 2 characters
48
+ * (to avoid false positives on short words). */
49
+ export declare function detectDisplayPrefixes(words: ClsWord[]): void;
50
+ /**
51
+ * Stress Shift — swap primary↔secondary when Nounsing-Pro confirms the word
52
+ * CAN shift (`suffixShiftPotential` returns `shiftLikely: true`) and the
53
+ * phonological context motivates it. This is NOT a global rule — it is
54
+ * grounded in the dictionary's own shift-likelihood assessment plus a
55
+ * syntactic/phonological context gate.
56
+ *
57
+ * Conditions (ALL must hold):
58
+ * 1. `suffixShiftPotential(word)` returns `shiftLikely: true`
59
+ * 2. The word's `stressTrans` has both `1` (primary) and `2` (secondary)
60
+ * 3. The LAST digit of `stressTrans` is `2` — secondary is on the final
61
+ * syllable, so swapping 1↔2 moves primary TO the final
62
+ * 4. Context gate (either):
63
+ * a. The word is a VB at the start of a phonological phrase (imperative
64
+ * at phrase start — "REcognize" → "recogNIZE")
65
+ * b. Rhythm Rule clash: a stressed syllable follows within 2 syllables
66
+ * (the shift avoids a clash with the following stress)
67
+ *
68
+ * Words like "realize" (shiftLikely=false) are correctly EXCLUDED —
69
+ * Nounsing-Pro says they cannot shift, and the code respects that.
70
+ *
71
+ * Effect: swap the `lexicalStress` values of the primary (2) and secondary (1)
72
+ * syllables, so `peakSyllable` and all downstream computation see the shifted
73
+ * peak. The syllables' `stress` (raw CMU) values are also swapped for
74
+ * consistency.
75
+ */
76
+ export declare function applyStressShift(words: ClsWord[], ius: IntonationalUnit[]): void;
2
77
  /**
3
78
  * Assign per‑syllable lexical stress to each word in a sentence.
4
79
  *
@@ -9,12 +84,16 @@ export declare function assignLexicalStress(words: ClsWord[]): void;
9
84
  /**
10
85
  * Adjust stresses for nominal compounds.
11
86
  *
12
- * Right‑stressed by default: the second content word keeps primary (2),
13
- * the first is reduced to secondary (1).
14
- * Known left‑stressed compounds (material, time, measure, location, self)
15
- * reverse the pattern.
87
+ * Fore-stressed by default: an N+N compound puts primary (2) on the first
88
+ * element, secondary (1) on the second the Compound Stress Rule (Chomsky–
89
+ * Halle; McAleese marks ICE cream with primary on "ice", KITCHen table,
90
+ * WINdow frame). The marked right-stress exceptions — food/temporal "made of"
91
+ * heads (apple PIE), Adj+N phrases (sweet CREAM), and proper-name sequences
92
+ * (New YORK) — reverse it. All of this lives in `compoundStressSide`.
16
93
  */
17
94
  export declare function applyCompoundStress(ius: IntonationalUnit[]): void;
95
+ /** Locate the syllable with the highest stress and set it to `value`. */
96
+ export declare function setPrimaryStress(word: ClsWord, value: number): void;
18
97
  /**
19
98
  * Recursively assign higher stress to content words from right to left.
20
99
  * Only the rightmost content word receives a boost (+1 above lexical primary).
@@ -29,4 +108,48 @@ export declare function applyNuclearStress(ius: IntonationalUnit[]): void;
29
108
  * information.
30
109
  */
31
110
  export declare function assignRelativeStresses(words: ClsWord[], ius: IntonationalUnit[]): void;
111
+ /**
112
+ * Surface-order post-processing passes that re-assert forestress and resolve
113
+ * residual clashes AFTER the main relativisation. These run in the Clio engine
114
+ * inside `assignRelativeStresses`; the Calliope engine calls this separately
115
+ * after `computeRelativeStress` so the same repairs reach both pipelines.
116
+ *
117
+ * The passes are:
118
+ * 1. resolveCompoundForestress — re-assert left-stress on surface-adjacent
119
+ * N+N/J+N compounds the tagger mislabels (WASTE·shore, SEA·shore).
120
+ * 2. resolveCollocationForestress — forestress lexicalised collocations
121
+ * (GOOD old, END-all, OLD days).
122
+ * 3. resolveHyphenCompounds — resolve dual-strong clashes at hyphen seams
123
+ * (torch-flames, blood-red).
124
+ * 4. resolveLinearClashes — catch residual s-s / m-m / n-n surface clashes
125
+ * with the full 7-level demotion cascade (lexical integrity → prominence →
126
+ * syntactic headedness → content/function → weight → Rhythm Rule → default).
127
+ * 5. Exclaimed interjection raise — an interjection immediately followed by
128
+ * "!" is lifted one tier ("But—Oh! ye lords…").
129
+ *
130
+ * All passes are DEMOTE-ONLY (or forestress re-assertions that raise the LEFT
131
+ * element of a known compound); none inflate the contour beyond what the lexicon
132
+ * and phrase-stress rules already established.
133
+ */
134
+ export declare function applySurfacePostProcessing(words: ClsWord[]): void;
135
+ /**
136
+ * THE CLASH FILTER — an absolute surface well-formedness constraint.
137
+ *
138
+ * On the STRESSED tier {n, m, s} no two *contiguous* syllables may carry the SAME
139
+ * level: that is a stress clash (two equal prominences with no gradation between
140
+ * them), which English categorically disallows. Gradient pairs (sm/ms/sn/ns/mn/nm)
141
+ * are fine — there is still a step down — as are runs of the unstressed tiers
142
+ * {w, x} (an unstressed sequence is tolerated, if not ideal). This generalises
143
+ * McAleese's Appendix-A step 3d-ii ("stress clashes (ss, ms) > s-s") and Liberman
144
+ * & Prince's (1977) grid alternation to every level of the strong tier.
145
+ *
146
+ * Resolution is DEMOTE-ONLY (never promote — promotion is the meter layer's job,
147
+ * McAleese Test 2), so the contour is never inflated to break a clash; we iterate
148
+ * to a fixed point (each change strictly lowers total stress mass, so it
149
+ * terminates). Which member yields is decided by `demoteRightOfClash` (grid-based
150
+ * relative prominence). Runs on the LINEAR surface order because a clash is a
151
+ * property of contiguous *pronounced* syllables (Hayes), which a mis-grouped
152
+ * dependency parse can scatter across phrases.
153
+ */
154
+ export declare function resolveLinearClashes(words: ClsWord[]): void;
32
155
  //# sourceMappingURL=stress.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"stress.d.ts","sourceRoot":"","sources":["../src/stress.ts"],"names":[],"mappings":"AAKA,OAAO,EAAE,OAAO,EAAyB,gBAAgB,EAAsB,MAAM,YAAY,CAAC;AAujBlG;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,OAAO,EAAE,GAAG,IAAI,CA6Q1D;AAID;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,gBAAgB,EAAE,GAAG,IAAI,CAgCjE;AAmBD;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,gBAAgB,EAAE,GAAG,IAAI,CAyBhE;AAID;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,OAAO,EAAE,EAAE,GAAG,EAAE,gBAAgB,EAAE,GAAG,IAAI,CA0GtF"}
1
+ {"version":3,"file":"stress.d.ts","sourceRoot":"","sources":["../src/stress.ts"],"names":[],"mappings":"AAKA,OAAO,EAAE,OAAO,EAAyB,gBAAgB,EAAsB,MAAM,YAAY,CAAC;AA2VlG,+DAA+D;AAC/D,wBAAgB,kBAAkB,CAAC,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAelE;AAED;;uEAEuE;AACvE,wBAAgB,mBAAmB,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAEvD;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,kBAAkB,CAChC,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GACjD,MAAM,GAAG,OAAO,GAAG,IAAI,CASzB;AAoMD;;;;;;;;;;;;;;;;;;;iDAmBiD;AACjD,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,OAAO,EAAE,GAAG,IAAI,CAkB5D;AAED;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,OAAO,EAAE,EAAE,GAAG,EAAE,gBAAgB,EAAE,GAAG,IAAI,CA2EhF;AA+KD;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,OAAO,EAAE,GAAG,IAAI,CAuR1D;AAID;;;;;;;;;GASG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,gBAAgB,EAAE,GAAG,IAAI,CA2BjE;AAED,yEAAyE;AACzE,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAYnE;AAID;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,gBAAgB,EAAE,GAAG,IAAI,CAwChE;AA8CD;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,OAAO,EAAE,EAAE,GAAG,EAAE,gBAAgB,EAAE,GAAG,IAAI,CAsLtF;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,0BAA0B,CAAC,KAAK,EAAE,OAAO,EAAE,GAAG,IAAI,CAoBjE;AAyMD;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,OAAO,EAAE,GAAG,IAAI,CAuB3D"}