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/src/scansion.ts CHANGED
@@ -612,10 +612,20 @@ function buildScansionString(syls: FlatSyl[], feet: number[], ius?: Intonational
612
612
 
613
613
  // ─── DISPLAY / NAMING HELPERS ──────────────────────────────────────
614
614
 
615
+ // Greek-numeral foot-length names, exhaustive through 20 (icosameter) per the
616
+ // maintainer's standardised nomenclature. Long lines that are genuinely metrical
617
+ // deserve a real meter name rather than the "N-feet" othering, so the ladder runs
618
+ // all the way up; beyond 20 the bare "N-feet" fallback remains (lines that long
619
+ // are almost never integrally metrical, and the prose-likeness hedge handles them).
620
+ const LINE_LENGTH_NAMES = [
621
+ '', 'monometer', 'dimeter', 'trimeter', 'tetrameter', 'pentameter',
622
+ 'hexameter', 'heptameter', 'octameter', 'nonometer', 'decameter',
623
+ 'hendecameter', 'dodecameter', 'triskaidecameter', 'tetradecameter',
624
+ 'pentadecameter', 'hexadecameter', 'heptadecameter', 'octadecameter',
625
+ 'enneadecameter', 'icosameter',
626
+ ];
615
627
  function lineLengthName(feet: number): string {
616
- const names = ['', 'monometer', 'dimeter', 'trimeter', 'tetrameter', 'pentameter',
617
- 'hexameter', 'heptameter', 'octameter', 'nonameter', 'decameter'];
618
- return names[feet] || `${feet}-feet`;
628
+ return LINE_LENGTH_NAMES[feet] || `${feet}-feet`;
619
629
  }
620
630
 
621
631
  // ─── TOP-LEVEL METER SCORING ────────────────────────────────────────
@@ -631,6 +641,7 @@ const METER_PRIOR: Partial<Record<MetreName, number>> = { iambic: 0.02 };
631
641
  // enough that it never overturns a clearly-duple line.
632
642
  const TERNARY_BIAS = 0.02;
633
643
  const TERNARY_METERS = new Set<MetreName>(['anapestic', 'dactylic', 'amphibrachic', 'bacchic']);
644
+ const DUPLE_METERS = new Set<MetreName>(['iambic', 'trochaic']);
634
645
 
635
646
  // Weights against the (0..1) normalised fit fraction.
636
647
  const REDGE_WEIGHT = 0.28; // right-edge (key-stress) agreement — disambiguates polarity
@@ -658,6 +669,71 @@ function onsetBonus(flat: FlatSyl[], meter: MetreName): number {
658
669
  return RISING_METERS.has(meter) ? ONSET_WEIGHT : 0;
659
670
  }
660
671
 
672
+ // ─── METRICALITY ASSESSMENT (Option-0 prose-likeness hedge) ─────────
673
+ //
674
+ // A single decontextualised line is, in generative-metrics terms, almost always
675
+ // fittable to SOME grid (English prose alternates; phrase-ends are right-strong),
676
+ // so absolute fit cannot separate prose from verse — empirically, loose real
677
+ // verse ("Half a league…", Prufrock) scores BELOW expository prose. What DOES
678
+ // separate them is non-periodicity accumulated AT LENGTH: a long run of text that
679
+ // (a) commits to no meter — its top candidates straddle BOTH the rising/falling
680
+ // and the duple/triple divides within a hair — and (b) realises that best fit only
681
+ // weakly. This is a deliberately HIGH-PRECISION gate: it fires only on the
682
+ // unmistakable un-lineated-prose case and never on short, loose, or ternary verse
683
+ // (the project's prized cases). It is advisory — it changes the displayed verdict
684
+ // WORDING only; the scansion, fit, ranking, foot count and certainty are intact.
685
+
686
+ // Necessary length: real metrical lines top out around the hepta-/octameter, so
687
+ // 9+ feet is almost never an integral line. Combined (AND) with the commitment
688
+ // and confidence gates below, clean verse that happens to exceed this length keeps
689
+ // a healthy margin and certainty and is therefore spared.
690
+ const PROSE_MIN_FEET = 9;
691
+ const PROSE_MAX_MARGIN = 0.10; // top1 − top2: prose does not commit to one meter
692
+ // …and realises even its best fit only weakly. This ceiling is an empirical
693
+ // calibration against the relative-stress contour, NOT a theory — re-fit when the
694
+ // contour changes. Verified (2026-06-21, after the dependency-driven ϕ rebuild):
695
+ // across 1736 real litlab verse lines, ZERO pass the length+margin+straddle gates,
696
+ // so the certainty ceiling never gates real verse — its only job is to admit the
697
+ // genuinely-prose case, which the improved contour now realises at 68%.
698
+ const PROSE_MAX_CERTAINTY = 70;
699
+
700
+ /** Does the top-3 ranking straddle BOTH polarity (rising/falling) AND foot-size
701
+ * (duple/triple)? The fingerprint of a contour equidistant from every meter —
702
+ * present in prose, absent in committed verse (whose ties stay within a family). */
703
+ function rankingStraddles(ranking: MeterScore[]): boolean {
704
+ const top = ranking.slice(0, 3).map(r => r.meter as MetreName);
705
+ const rising = top.some(m => RISING_METERS.has(m));
706
+ const falling = top.some(m => FALLING_METERS.has(m));
707
+ const triple = top.some(m => TERNARY_METERS.has(m));
708
+ const duple = top.some(m => DUPLE_METERS.has(m));
709
+ return rising && falling && triple && duple;
710
+ }
711
+
712
+ /**
713
+ * If a line reads as plausible prose (see above), return the advisory hedge
714
+ * string; otherwise undefined. Reads only fields already present on the detail,
715
+ * so it runs as a late, non-destructive pipeline pass (`applyMetricalityLayer`).
716
+ */
717
+ export function metricalityVerdict(detail: PhonologicalScansionDetail): string | undefined {
718
+ if (detail.meterName === 'free verse') return undefined; // already non-committal
719
+ if (detail.rhythmNote) return undefined; // accentual/dolnik already named
720
+ if (detail.footCount < PROSE_MIN_FEET) return undefined;
721
+ const ranking = detail.ranking;
722
+ if (!ranking || ranking.length < 2) return undefined;
723
+ const margin = ranking[0].score - ranking[1].score;
724
+ if (margin >= PROSE_MAX_MARGIN) return undefined; // commits to one meter
725
+ if (detail.certainty >= PROSE_MAX_CERTAINTY) return undefined;
726
+ if (!rankingStraddles(ranking)) return undefined;
727
+ return `No consistent metered rhythm(s) discerned. Reads as plausible prose. `
728
+ + `(Closest fit: ${detail.meter}, ${detail.certainty}%)`;
729
+ }
730
+
731
+ /** Set `detail.metricalityNote` on every line that reads as plausible prose.
732
+ * Non-destructive: only the new advisory field is written. */
733
+ export function applyMetricalityLayer(details: PhonologicalScansionDetail[]): void {
734
+ for (const d of details) d.metricalityNote = metricalityVerdict(d);
735
+ }
736
+
661
737
  export function scoreMeters(
662
738
  keyStresses: KeyStress[],
663
739
  words: ClsWord[],
@@ -938,7 +1014,9 @@ export function applyRhythmLayer(details: PhonologicalScansionDetail[]): void {
938
1014
  const pooled = profiles.flatMap(p => p.intervals);
939
1015
  const family = intervalFamily(pooled);
940
1016
  const flavour = family === 'dolnik' ? 'dolnik' : 'accentual';
941
- note = `alternating ${evens[0]}·${odds[0]}-ictus ${flavour}`;
1017
+ // "4/3 ♪beat accentual" — no "alternating" (too long), "/" not "·" (so
1018
+ // "4·3" is not misread as 12), ♪ marks that these are beat counts.
1019
+ note = `${evens[0]}/${odds[0]} ♪beat ${flavour}`;
942
1020
  } else {
943
1021
  // Constant ictus count (mode covering ≥70% of lines, total spread ≤1).
944
1022
  const mode = [...new Set(counts)].map(v => [v, counts.filter(c => c === v).length] as const)
@@ -952,9 +1030,14 @@ export function applyRhythmLayer(details: PhonologicalScansionDetail[]): void {
952
1030
  else if (family === 'accentual') note = `${mode[0]}-beat accentual`;
953
1031
  // duple/ternary pooled intervals with varying syllable counts =
954
1032
  // anacrusis/clausula variation only — classical machinery's domain.
955
- } else if (spread >= 3) {
956
- note = 'free verse (heterometric)';
957
1033
  }
1034
+ // NB: a high-spread stanza with NO constant beat count is NOT stamped
1035
+ // here. Forcing a "heterometric" rhythmNote onto every line both
1036
+ // polluted the per-line display and (because the continuity pass is
1037
+ // gated by rhythmNote) blocked the stanza-continuity rename. Lines
1038
+ // instead keep their own meter and get a per-line dolnik/accentual
1039
+ // reading below; the whole-poem heterometric observation is reported
1040
+ // by the synopsis (summarizePoem), outside the per-line section.
958
1041
  }
959
1042
  if (note) for (const d of lines) d.rhythmNote = note;
960
1043
  }
@@ -996,15 +1079,28 @@ export function applyStanzaConsensus(
996
1079
  if (d.meterName === 'free verse') continue;
997
1080
  counts.set(d.meterName, (counts.get(d.meterName) ?? 0) + 1);
998
1081
  }
999
- // Dominant meter = the strict, unique plurality (≥2 lines).
1082
+ // Dominant meter = the strict plurality (≥2 lines). A TIED plurality is broken
1083
+ // by each tied family's total ranking-score mass across the stanza (2026-07-02):
1084
+ // catalectic lines make sibling readings trade wins line-by-line (The Raven's
1085
+ // 15-syllable lines flip trochaic-octameter ↔ "iambic heptameter", tying the
1086
+ // count 3–3), but the family the stanza actually commits to carries more total
1087
+ // fit mass — so the tie is evidence-weighted, never a coin toss or a give-up.
1000
1088
  let dominant = '';
1001
1089
  let max = 0;
1002
- let tied = false;
1090
+ const atMax: string[] = [];
1003
1091
  for (const [m, c] of counts) {
1004
- if (c > max) { max = c; dominant = m; tied = false; }
1005
- else if (c === max) tied = true;
1092
+ if (c > max) { max = c; atMax.length = 0; atMax.push(m); }
1093
+ else if (c === max) atMax.push(m);
1006
1094
  }
1007
- if (max < 2 || tied || !dominant) return;
1095
+ if (max < 2) return;
1096
+ if (atMax.length === 1) {
1097
+ dominant = atMax[0];
1098
+ } else {
1099
+ const mass = (name: string) => details.reduce((s, d) =>
1100
+ s + (d.ranking?.find(r => r.meter === name)?.score ?? 0), 0);
1101
+ dominant = atMax.reduce((a, b) => (mass(b) > mass(a) ? b : a));
1102
+ }
1103
+ if (!dominant) return;
1008
1104
 
1009
1105
  // Ternary ANACRUSIS ANCHOR (Gasparov): when the stanza's dominant meter is
1010
1106
  // ternary, the family is fixed by the stanza's anacrusis profile, not by the
@@ -1019,7 +1115,25 @@ export function applyStanzaConsensus(
1019
1115
  for (const d of details) {
1020
1116
  if (!TERNARY_METERS.has(d.meterName as MetreName)) continue;
1021
1117
  const p = ictusProfile(d.scansion);
1022
- if (p.ictuses >= 2 && p.anacrusis <= 2) anacs.push(p.anacrusis);
1118
+ if (p.ictuses < 2) continue;
1119
+ let anac = p.anacrusis;
1120
+ if (RISING_METERS.has(d.meterName as MetreName)) {
1121
+ // For a RISING line the raw profile's Attridge promotions pollute the
1122
+ // anacrusis: a promoted 'w'/'n' upbeat at position 0 ("'TWAS the night
1123
+ // before…") reads as anacrusis 0 and vetoes the anapest call. The
1124
+ // family anchor wants the SCHEME's first beat, so measure to the first
1125
+ // STRONG (m/s) ictus instead. Falling lines keep the raw profile —
1126
+ // their genuine first beat is often a light 'n' ("HALF a league").
1127
+ const letters = d.scansion.replace(/[^xwnms]/g, '');
1128
+ const firstStrong = [...letters].findIndex(c => c === 's' || c === 'm');
1129
+ if (firstStrong >= 0) anac = firstStrong;
1130
+ // Over-stressed anacrusis (Gasparov): an extra stress on the upbeat
1131
+ // does NOT change the meter — a rising line whose first strong sits at
1132
+ // position 0 ("NOT a creature was stirring…") has a stressed upbeat,
1133
+ // not a dactylic opening; drop it from the anchor vote entirely.
1134
+ if (anac === 0) continue;
1135
+ }
1136
+ if (anac <= 2) anacs.push(anac);
1023
1137
  }
1024
1138
  if (anacs.length >= 2) {
1025
1139
  const has = (v: number) => anacs.includes(v);
@@ -1041,9 +1155,16 @@ export function applyStanzaConsensus(
1041
1155
  // spondaic anacrusis ("big BOOKS that are HURting…") lets the dactylic fit
1042
1156
  // edge out the stanza's amphibrachs by seizing the clash syllable as an
1043
1157
  // extra beat (Gasparov: an over-stressed anacrusis does NOT change the
1044
- // meter). Non-sibling divergence keeps the stricter 0.975 near-tie.
1045
- const siblings = TERNARY_METERS.has(d.meterName as MetreName)
1046
- && TERNARY_METERS.has(dominant as MetreName);
1158
+ // meter). The DUPLE pair (iamb/trochee) are siblings in exactly the same
1159
+ // sense (2026-07-02): a catalectic trochaic line IS an "iambic" grid plus
1160
+ // an offset — The Raven's 15-syllable lines read trochaic-octameter or
1161
+ // iambic-heptameter on the same beats — so a stanza committed to one duple
1162
+ // family pulls near-tie sibling readings in at the same relaxed window.
1163
+ // Non-sibling divergence keeps the stricter 0.975 near-tie.
1164
+ const siblings = (TERNARY_METERS.has(d.meterName as MetreName)
1165
+ && TERNARY_METERS.has(dominant as MetreName))
1166
+ || (DUPLE_METERS.has(d.meterName as MetreName)
1167
+ && DUPLE_METERS.has(dominant as MetreName));
1047
1168
  const threshold = siblings ? 0.95 : tie;
1048
1169
  if (own > 0 && dom >= own * threshold) {
1049
1170
  const lengthWord = d.meter.split(' ')[1] ?? '';
@@ -0,0 +1,134 @@
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
+
12
+ import { ClsWord } from './types.js';
13
+ import { isPunctuation } from './parser.js';
14
+
15
+ /** True if some other token has `word` as its dependency governor (i.e. word
16
+ * has a complement/dependent of its own). */
17
+ function hasDependent(word: ClsWord, words: ClsWord[]): boolean {
18
+ for (const w of words) {
19
+ if (w !== word && w.dependency && w.dependency.governor === word) return true;
20
+ }
21
+ return false;
22
+ }
23
+
24
+ /** Last non-punctuation token index in the sentence at/after `from`? */
25
+ function isClauseFinal(word: ClsWord, words: ClsWord[]): boolean {
26
+ const idx = words.indexOf(word);
27
+ if (idx < 0) return false;
28
+ for (let k = idx + 1; k < words.length; k++) {
29
+ if (!isPunctuation(words[k].lexicalClass)) return false;
30
+ }
31
+ return true;
32
+ }
33
+
34
+ /**
35
+ * A STRANDED preposition: an IN preposition whose complement has been extracted
36
+ * (wh-movement / relativisation / topicalisation), so it governs no object and
37
+ * sits clause-finally — "what are you waiting FOR", "…what you stare AT". Such
38
+ * a preposition bears stress (it is not the reducible proclitic of "in the
39
+ * house"). Conservative: IN only (infinitival TO is excluded — "I want to go"
40
+ * is not stranding), no dependent, and clause-final (the canonical strand site).
41
+ */
42
+ export function isStrandedPreposition(word: ClsWord, words: ClsWord[]): boolean {
43
+ if (word.lexicalClass !== 'IN') return false;
44
+ if (hasDependent(word, words)) return false; // has a complement → ordinary preposition
45
+ return isClauseFinal(word, words);
46
+ }
47
+
48
+ /** Absolute / elliptical possessive pronouns used as the contrasted element. */
49
+ const ABSOLUTE_POSSESSIVES = new Set([
50
+ 'mine', 'thine', 'yours', 'hers', 'ours', 'theirs', 'his',
51
+ ]);
52
+ const CONTRAST_MARKERS = new Set(['not', 'but', 'nor']);
53
+
54
+ /**
55
+ * A CONTRASTIVE possessive: a possessive determiner (PRP$: thy/my/your/her…)
56
+ * in the elliptical contrast frame "X's … not/but MINE" — the contrast lifts
57
+ * the possessor out of reduction ("it was THY choice, not mine"). Tight by
58
+ * construction: requires a contrast marker (not/but/nor) adjacent to an
59
+ * absolute possessive somewhere in the clause, so an ordinary unfocused
60
+ * possessive ("I lost my way") is left alone.
61
+ */
62
+ export function isContrastivePossessive(word: ClsWord, words: ClsWord[]): boolean {
63
+ if (word.lexicalClass !== 'PRP$') return false;
64
+ for (let i = 0; i + 1 < words.length; i++) {
65
+ const a = words[i].word.toLowerCase();
66
+ const b = words[i + 1].word.toLowerCase().replace(/['’]/g, '');
67
+ if (CONTRAST_MARKERS.has(a) && ABSOLUTE_POSSESSIVES.has(b)) return true;
68
+ }
69
+ return false;
70
+ }
71
+
72
+ /** Finite auxiliaries / modals whose appearance before a subject pronoun marks
73
+ * subject-aux inversion. */
74
+ const INVERSION_AUX = new Set(['MD', 'VBP', 'VBZ', 'VBD']);
75
+
76
+ /**
77
+ * A fronted DEICTIC LOCATIVE "there"/"here" in locative inversion — "THERE
78
+ * could I marvel", "HERE could I rest". FinNLP mis-tags the fronted locative
79
+ * as existential (EX / expl) or reduces it as a discourse adverb, flattening it
80
+ * to 'w'; but a fronted locative that triggers subject-aux inversion (an
81
+ * aux/modal immediately followed by a subject pronoun) is a stressed deictic
82
+ * focus, NOT the reduced existential of "there IS a house" (no inversion) or
83
+ * the presentational "there LIVED a king" (verb + NP, no inversion).
84
+ */
85
+ export function isDeicticLocative(word: ClsWord, words: ClsWord[]): boolean {
86
+ const lemma = word.word.toLowerCase().replace(/['’]/g, '');
87
+ if (lemma !== 'there' && lemma !== 'here') return false;
88
+ const idx = words.indexOf(word);
89
+ // must be the first non-punctuation token (fronted)
90
+ let first = -1;
91
+ for (let i = 0; i < words.length; i++) {
92
+ if (!isPunctuation(words[i].lexicalClass)) { first = i; break; }
93
+ }
94
+ if (idx !== first) return false;
95
+ // subject-aux inversion: <there/here> <aux|modal> <subject pronoun>
96
+ const aux = words[idx + 1];
97
+ const subj = words[idx + 2];
98
+ return !!(aux && subj && INVERSION_AUX.has(aux.lexicalClass) && subj.lexicalClass === 'PRP');
99
+ }
100
+
101
+ /**
102
+ * Imperative clause: the ROOT is a base-form verb (VB) with no overt subject
103
+ * (no NSUBJ dependent) — "Tell me…", "Do not go…". Used by the nuclear pass:
104
+ * the accent falls on the verb / its object, not on a (dropped) subject, and an
105
+ * imperative-clause vocative is a direct address.
106
+ */
107
+ export function isImperativeClause(words: ClsWord[]): boolean {
108
+ const root = words.find(w => w.dependency && w.dependency.dependentType === 'root');
109
+ if (!root) return false;
110
+ if (root.lexicalClass !== 'VB' && root.lexicalClass !== 'VBP') return false;
111
+ for (const w of words) {
112
+ if (w.dependency && w.dependency.governor === root
113
+ && /nsubj/i.test(w.dependency.dependentType)) return false;
114
+ }
115
+ return true;
116
+ }
117
+
118
+ /**
119
+ * A VOCATIVE (direct address): a noun tagged DISCOURSE/INTJ/DEP and set off by
120
+ * adjacent punctuation (a comma or "!"), in a clause that is imperative or
121
+ * subject-less — "Sing, O GODDESS…", "blow, BUGLE, blow". Conservative: the
122
+ * noun must be comma/!-adjacent so an ordinary argument noun is not swept in.
123
+ */
124
+ export function isVocative(word: ClsWord, words: ClsWord[]): boolean {
125
+ if (!/^(NN|NNS|NNP|NNPS)$/.test(word.lexicalClass)) return false;
126
+ const role = word.dependency?.dependentType ?? '';
127
+ if (!/discourse|intj|dep|vocative/i.test(role)) return false;
128
+ const idx = words.indexOf(word);
129
+ const prev = idx > 0 ? words[idx - 1] : null;
130
+ const next = idx + 1 < words.length ? words[idx + 1] : null;
131
+ const commaAdjacent =
132
+ (prev && /^[,!]$/.test(prev.word)) || (next && /^[,!]$/.test(next.word));
133
+ return !!commaAdjacent && isImperativeClause(words);
134
+ }