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
@@ -0,0 +1,46 @@
1
+ // calliope/feats.ts — parse UD morphological FEATS onto ClsWord (Phase 1 enabler).
2
+ //
3
+ // The parser stores the raw FEATS string ("Number=Sing|Person=3|Tense=Pres|
4
+ // VerbForm=Fin") on `word.lexicalDetails`, but no downstream module could read it:
5
+ // `udpos.ts:feat()` only sees the UDPipe-level `UDWord.featsMap` at parse time. This
6
+ // pass parses `lexicalDetails` into `word.featsMap` ONCE, early in the Calliope
7
+ // pipeline, so the stress / bracketing / relativiser modules can key on morphology
8
+ // (VerbForm=Part → participle, Voice=Pass → underlying object subject, PronType=Prs →
9
+ // inherently given, Degree=Cmp → JJR, …). Pure plumbing — it mutates only the
10
+ // additive `featsMap`, never the parse.
11
+
12
+ import { ClsSentence, ClsWord } from '../types.js';
13
+
14
+ /** Parse a UD FEATS string ("A=b|C=d") into a key→value record. */
15
+ function parseFeatsString(raw: string | undefined): Record<string, string> {
16
+ const out: Record<string, string> = {};
17
+ if (!raw) return out;
18
+ for (const pair of raw.split('|')) {
19
+ const eq = pair.indexOf('=');
20
+ if (eq <= 0) continue;
21
+ const k = pair.slice(0, eq).trim();
22
+ const v = pair.slice(eq + 1).trim();
23
+ if (k && v) out[k] = v;
24
+ }
25
+ return out;
26
+ }
27
+
28
+ /** Populate `word.featsMap` for every word in the sentence from `lexicalDetails`. */
29
+ export function parseFeats(sent: ClsSentence): void {
30
+ for (const w of sent.words) {
31
+ if (w.featsMap) continue; // already parsed (idempotent)
32
+ w.featsMap = parseFeatsString(w.lexicalDetails);
33
+ }
34
+ }
35
+
36
+ /** Read one morphological feature, undefined if absent. Lazily parses on demand
37
+ * so callers reached before `parseFeats` still work. */
38
+ export function feat(w: ClsWord, key: string): string | undefined {
39
+ if (!w.featsMap) w.featsMap = parseFeatsString(w.lexicalDetails);
40
+ return w.featsMap[key];
41
+ }
42
+
43
+ /** True when a feature has the given value. */
44
+ export function featIs(w: ClsWord, key: string, value: string): boolean {
45
+ return feat(w, key) === value;
46
+ }
@@ -0,0 +1,44 @@
1
+ // calliope/names.ts — proper-NOUN (person) vs proper-NAME (place) typing via the
2
+ // `humannames` and `cities-list` membership lists. These packages are JUST lists
3
+ // ({ "John": 1, … }), so this is a boolean enrichment, not a pipeline: it flags a
4
+ // token as a known person and/or place name to drive Scenario C (proper-name
5
+ // head-stress) and EXT.
6
+ //
7
+ // Lookups are GATED to proper-noun-tagged tokens (NNP/NNPS). Both lists are huge
8
+ // (197k names, 79k cities) and overlap heavily with common words — "Will", "May",
9
+ // "Rose", "Sun", "York", "Reading" are all in them — so flagging an untyped token
10
+ // would be noise. Restricting to NNP(S) keeps the signal honest.
11
+
12
+ import { createRequire } from 'module';
13
+ import { ClsSentence } from '../types.js';
14
+
15
+ const req = createRequire(import.meta.url);
16
+
17
+ function loadList(pkg: string): Record<string, number> {
18
+ try {
19
+ const m = req(pkg);
20
+ return (m && typeof m === 'object' ? m : {}) as Record<string, number>;
21
+ } catch {
22
+ return {};
23
+ }
24
+ }
25
+
26
+ export const PERSON = loadList('humannames');
27
+ export const PLACE = loadList('cities-list');
28
+ const PROPER = /^(NNP|NNPS)$/;
29
+
30
+ export function inList(list: Record<string, number>, surface: string): boolean {
31
+ const key = surface.replace(/['’].*$/, '').replace(/[^A-Za-z-]/g, '');
32
+ if (!key) return false;
33
+ const cap = key[0].toUpperCase() + key.slice(1);
34
+ return !!(list[key] || list[cap]);
35
+ }
36
+
37
+ /** Flag proper-noun tokens as person and/or place names (membership only). */
38
+ export function tagNames(sent: ClsSentence): void {
39
+ for (const w of sent.words) {
40
+ if (!PROPER.test(w.lexicalClass)) continue;
41
+ if (inList(PERSON, w.word)) w.isPersonName = true;
42
+ if (inList(PLACE, w.word)) w.isPlaceName = true;
43
+ }
44
+ }
@@ -0,0 +1,253 @@
1
+ // calliope/postag.ts — Calliope-only POS correction via en-lexicon.
2
+ //
3
+ // THE BUG THIS FIXES: en-pos tags a capitalised line-initial common word as a
4
+ // proper noun ("Pale rain" → Pale/NNP, "High tide" → High/NNP), purely from the
5
+ // capital. That mis-tag flips content class, derails the dependency parse, and —
6
+ // once relation-keyed stress is reintroduced — fore-stresses an adjective.
7
+ //
8
+ // en-lexicon (a CORE FinNLP module, the POS dictionary that en-pos/en-parse are
9
+ // built on) carries the true multi-role reading keyed on the LOWERCASE form:
10
+ // lexicon['pale'] = "JJ|VBP|NN|VB" (adjective first)
11
+ // lexicon['Pale'] = "NNP|RB" (the capitalised key is the trap)
12
+ // lexicon['high'] = "JJ|NN|RB|RP"
13
+ // so consulting the lowercase entry recovers the real word class.
14
+ //
15
+ // This runs as the FIRST Calliope step — NOT in the shared `tagfix.ts`, because
16
+ // that seam feeds BOTH engines and would un-freeze Clio. It corrects the ClsWord
17
+ // POS in place (Calliope's own pre-pass); Clio, invoked via `--clio`, never calls
18
+ // it and so keeps its frozen reading. (It does not re-run en-parse; the κ/ϕ/ι
19
+ // builder downstream is built to be robust to residual head-attachment errors,
20
+ // and `normalizeDeps` re-derives `canonicalRel` from the corrected POS.)
21
+ //
22
+ // Discipline (anti-gaming): the demotion is gated so it can ONLY fire where the
23
+ // capital is uninformative (sentence/line-initial) and the word is demonstrably a
24
+ // common word, never a known or sequenced proper name.
25
+
26
+ import { createRequire } from 'module';
27
+ import { ClsSentence, ClsWord } from '../types.js';
28
+
29
+ const req = createRequire(import.meta.url);
30
+
31
+ function loadLexicon(): Record<string, string> {
32
+ try {
33
+ const m = req('en-lexicon');
34
+ return (m && (m.lexicon ?? m)) as Record<string, string>;
35
+ } catch {
36
+ return {};
37
+ }
38
+ }
39
+ const LEXICON = loadLexicon();
40
+
41
+ const PRONOUN_SUBJECT_CONTRACTIONS_LOCAL = new Set([
42
+ "i'm", "i'll", "i've", "i'd",
43
+ "you're", "you'll", "you've", "you'd",
44
+ "he'll", "he'd", "he's", "she'll", "she'd", "she's", "it'll",
45
+ "we're", "we'll", "we've", "we'd",
46
+ "they're", "they'll", "they've", "they'd",
47
+ ]);
48
+
49
+ const PROPER = /^(NNP|NNPS)$/;
50
+ const PUNCT = /^[^A-Za-z0-9]+$/;
51
+ // Content POS, matching parser.ts CONTENT_POS (kept local to avoid a cycle).
52
+ const CONTENT = new Set([
53
+ 'NN', 'NNS', 'NNP', 'NNPS', 'JJ', 'JJR', 'JJS',
54
+ 'VB', 'VBD', 'VBG', 'VBN', 'VBP', 'VBZ', 'RB', 'RBR', 'RBS', 'CD',
55
+ ]);
56
+ // Demotion is restricted to the ATTRIBUTIVE/NOMINAL common classes — the exact
57
+ // mis-as-proper class (pale→JJ, slate→NN). A lowercase-primary that is a finite
58
+ // VERB (rose→VBD) is genuinely ambiguous sentence-initially, so we leave NNP
59
+ // rather than risk a wrong verb tag wrecking the parse.
60
+ const DEMOTE_TARGET = /^(JJ|JJR|JJS|NN|NNS)$/;
61
+
62
+ function bareLower(w: ClsWord): string {
63
+ return w.word.toLowerCase().replace(/[^a-z]/g, '');
64
+ }
65
+ function isPunctTag(tag: string): boolean {
66
+ return PUNCT.test(tag) || tag === '-LRB-' || tag === '-RRB-';
67
+ }
68
+
69
+ /**
70
+ * Demote spurious proper-noun tags to their true common reading via en-lexicon.
71
+ * Mutates `word.lexicalClass` / `word.isContent` in place for the Calliope engine.
72
+ */
73
+ export function correctPosWithLexicon(sent: ClsSentence): void {
74
+ const words = sent.words;
75
+
76
+ // Index of the first non-punctuation word — the one whose capital is forced by
77
+ // sentence/line position and therefore carries no proper-noun evidence.
78
+ let firstContentIdx = -1;
79
+ for (let i = 0; i < words.length; i++) {
80
+ if (!isPunctTag(words[i].lexicalClass)) { firstContentIdx = i; break; }
81
+ }
82
+
83
+ for (let i = 0; i < words.length; i++) {
84
+ const w = words[i];
85
+ if (!PROPER.test(w.lexicalClass)) continue;
86
+
87
+ // (a) The lowercase lexicon PRIMARY reading must be an attributive/nominal
88
+ // common word. This — not name-list membership — is the real signal: the
89
+ // huge humannames/cities-list flag almost every capitalised common word
90
+ // ("Pale", "Slate", "Green" are all in them), so membership cannot gate
91
+ // here. Genuinely proper words are protected instead: their lowercase
92
+ // entry is either absent (london, york) or proper-primary (reagan → NNP).
93
+ const entry = LEXICON[bareLower(w)];
94
+ if (!entry) continue;
95
+ const primary = entry.split('|')[0];
96
+ if (!DEMOTE_TARGET.test(primary)) continue;
97
+
98
+ // (b) Caps must be uninformative: the token is the sentence/line-initial word,
99
+ // and it is NOT part of a proper-name sequence (a neighbour tagged NNP).
100
+ if (i !== firstContentIdx) continue;
101
+ const prev = i > 0 ? words[i - 1] : undefined;
102
+ const next = i + 1 < words.length ? words[i + 1] : undefined;
103
+ const adjacentProper =
104
+ (prev && PROPER.test(prev.lexicalClass)) || (next && PROPER.test(next.lexicalClass));
105
+ if (adjacentProper) continue;
106
+
107
+ w.lexicalClass = primary;
108
+ w.isContent = CONTENT.has(primary);
109
+ }
110
+ }
111
+
112
+ // ─── UDPipe XPOS correction (the role en-pos + tagfix.ts played pre-UD) ──────
113
+ //
114
+ // UDPipe is trained on running prose and systematically MIS-TAGS terse,
115
+ // decontextualised verse fragments — it has no sentence context to lean on.
116
+ // Observed on the test corpus: "hat"→WP, "Woolen"→NNS, "gray"→VBP, "constantly"
117
+ // →NN, "slate"/"clay"→JJ, "bicycle"→NN. The faithful downstream then scans the
118
+ // garbage. en-pos avoided this because it is lexicon-backed; we restore that by
119
+ // cross-checking UDPipe's XPOS against en-lexicon's multi-role reading (AGENTS.md:
120
+ // "nounsing-pro / lexicon POS to aid FinNLP… cross-check"). HIGH PRECISION — it
121
+ // only overrides a tag the lexicon positively contradicts; an unknown word keeps
122
+ // UDPipe's tag.
123
+
124
+ /** Coarse word-class of a Penn tag (N/V/J/R), else the exact tag. */
125
+ function coarseClass(tag: string): string {
126
+ if (/^(NN|NNS|NNP|NNPS)$/.test(tag)) return 'N';
127
+ if (/^VB/.test(tag)) return 'V';
128
+ if (/^JJ/.test(tag)) return 'J';
129
+ if (/^RB/.test(tag)) return 'R';
130
+ return tag;
131
+ }
132
+ const NOUN_TAG = /^(NN|NNS|NNP|NNPS)$/;
133
+
134
+ function retag(w: ClsWord, tag: string): void {
135
+ w.lexicalClass = tag;
136
+ w.isContent = CONTENT.has(tag);
137
+ w.lexicalPlural = tag === 'NNS' || tag === 'NNPS';
138
+ }
139
+
140
+ /** Is the immediately-preceding non-punctuation word a subject pronoun? */
141
+ function prevIsSubjectPronoun(words: ClsWord[], i: number): boolean {
142
+ for (let k = i - 1; k >= 0; k--) {
143
+ if (isPunctTag(words[k].lexicalClass)) continue;
144
+ const rel = (words[k].dependency?.dependentType ?? '').toLowerCase();
145
+ return words[k].lexicalClass === 'PRP' && /nsubj/.test(rel);
146
+ }
147
+ return false;
148
+ }
149
+
150
+ /** Next non-punctuation word, or undefined. */
151
+ function nextContentful(words: ClsWord[], i: number): ClsWord | undefined {
152
+ for (let k = i + 1; k < words.length; k++) {
153
+ if (!isPunctTag(words[k].lexicalClass)) return words[k];
154
+ }
155
+ return undefined;
156
+ }
157
+
158
+ export function correctUDPipePos(sent: ClsSentence): void {
159
+ const words = sent.words;
160
+ for (let i = 0; i < words.length; i++) {
161
+ const w = words[i];
162
+ if (isPunctTag(w.lexicalClass)) continue;
163
+
164
+ // (0) CONTRACTED SUBJECT PRONOUNS: UDPipe/FinNLP often mis-tags
165
+ // "I'll", "I've", "I'd", "I'm" as JJ, NNP, or other content classes,
166
+ // giving them a spurious content beat. Correct them to PRP (personal
167
+ // pronoun) so they floor to 'w' or 'n' like other pronouns.
168
+ const lowerWord = w.word.toLowerCase().replace(/[’]/g, "'");
169
+ if (PRONOUN_SUBJECT_CONTRACTIONS_LOCAL.has(lowerWord)) {
170
+ retag(w, 'PRP');
171
+ continue;
172
+ }
173
+
174
+ // (0) POSSESSIVE clitic mis-tagged as a verb: UDPipe tags the genitive "'s"
175
+ // in "laugher's licence" as VBZ (confusing it with the copula "he's" = he
176
+ // is), giving the clitic a spurious content beat. The genitive 's carries
177
+ // a `case` relation onto its possessor noun; the copula does not — so that
178
+ // relation distinguishes them. Retag POS (0 syllables downstream).
179
+ // (0) POSSESSIVE clitic mis-tagged as a verb: UDPipe sometimes tags the
180
+ // genitive "'s" (in "laugher's licence") as VBZ — confusing it with the
181
+ // copula "he's" = he is — giving the clitic a spurious content beat. The
182
+ // genitive 's carries a `case` relation onto its possessor noun; the copula
183
+ // does not — so that relation distinguishes them. Retag POS.
184
+ if ((w.word === "'s" || w.word === "’s") && /^VB/.test(w.lexicalClass)
185
+ && (w.dependency?.dependentType ?? '').toLowerCase() === 'case') {
186
+ retag(w, 'POS');
187
+ continue;
188
+ }
189
+ // The possessive ending itself is never corrected by the lexicon pass below
190
+ // (en-lexicon has a spurious verb entry for the bare letter "s").
191
+ if (w.lexicalClass === 'POS') continue;
192
+
193
+ const lemma = bareLower(w);
194
+ if (lemma.length <= 1) continue; // "s"/"a"/"o" → too ambiguous
195
+ const entry = LEXICON[lemma];
196
+ if (!entry) continue; // unknown → trust UDPipe
197
+ const classes = entry.split('|');
198
+ const primary = classes[0];
199
+ const lexCoarse = new Set(classes.map(coarseClass));
200
+ const udCoarse = coarseClass(w.lexicalClass);
201
+ const CONTENT_COARSE = new Set(['N', 'V', 'J', 'R']);
202
+
203
+ // (1) PLAUSIBILITY: UDPipe's coarse class is absent from the lexicon entirely
204
+ // (hat→WP, Woolen→NNS, constantly→NN, clay→JJ) → fall back to the lexicon's
205
+ // primary reading. Only fires on a positive contradiction AND only when the
206
+ // correction is TOWARD a content class (never invents a function tag).
207
+ if (!lexCoarse.has(udCoarse) && CONTENT_COARSE.has(coarseClass(primary))
208
+ && coarseClass(primary) !== udCoarse) {
209
+ retag(w, primary);
210
+ continue;
211
+ }
212
+
213
+ // (2) ATTRIBUTIVE NOUN mis-tagged JJ: a lexicon-primary noun UDPipe tagged JJ,
214
+ // sitting immediately before a noun, is the modifier of an N+N compound
215
+ // ("SLATE roof", "CLAY jar"), not an adjective. Retag NN so the compound
216
+ // fore-stresses (deps.ts NOMD).
217
+ if (/^JJ/.test(w.lexicalClass) && coarseClass(primary) === 'N') {
218
+ const nxt = nextContentful(words, i);
219
+ if (nxt && NOUN_TAG.test(nxt.lexicalClass) && nxt.absoluteIndex === w.absoluteIndex + 1) {
220
+ retag(w, 'NN');
221
+ continue;
222
+ }
223
+ }
224
+
225
+ // (3) NOUN that should be a finite VERB: a lexicon-verb-capable common noun
226
+ // UDPipe tagged NN, immediately preceded by a SUBJECT PRONOUN ("they
227
+ // BICYCLE through", "we PAPER walls"), is the clause's predicate → VBP.
228
+ if (NOUN_TAG.test(w.lexicalClass) && lexCoarse.has('V') && prevIsSubjectPronoun(words, i)) {
229
+ retag(w, 'VBP');
230
+ continue;
231
+ }
232
+
233
+ // (4) TEMPORAL ADVERB mis-tagged IN: "before"/"after"/"since"/"once" tagged IN
234
+ // but with no nominal complement before the next clause boundary is an
235
+ // adverb (RB), not a preposition. Penn treebank: IN takes an NP
236
+ // complement ("before the storm"); RB does not ("never before", "long
237
+ // after"). A following verb/pronoun signals a subordinate clause ("before
238
+ // I go") → leave as IN (subordinating conjunction sense).
239
+ const TEMPORAL_ADVS = new Set(['before', 'after', 'since', 'once']);
240
+ if (w.lexicalClass === 'IN' && TEMPORAL_ADVS.has(lemma)) {
241
+ let hasNoun = false, hasClauseStart = false;
242
+ for (let k = i + 1; k < words.length; k++) {
243
+ if (isPunctTag(words[k].lexicalClass)) break;
244
+ if (NOUN_TAG.test(words[k].lexicalClass)) { hasNoun = true; break; }
245
+ if (/^(VB|VBP|VBD|VBG|VBN|VBZ|PRP)$/.test(words[k].lexicalClass)) hasClauseStart = true;
246
+ }
247
+ if (!hasNoun && !hasClauseStart) {
248
+ retag(w, 'RB');
249
+ continue;
250
+ }
251
+ }
252
+ }
253
+ }
@@ -0,0 +1,262 @@
1
+ // calliope/prosodic.ts — the Match-Theory prosodic hierarchy for Calliope.
2
+ //
3
+ // Builds the κ (clitic group) / ϕ (phonological phrase) / ι (intonational unit)
4
+ // structure from the canonical dependency relations and the REAL utterance
5
+ // boundaries — replacing the legacy builder (phonological.ts), which split an IU
6
+ // at every comma and grouped phrases off the constituent tree. The corrections,
7
+ // per McAleese A1/A2 and the maintainer's critique:
8
+ //
9
+ // ι only at genuine intonational breaks: terminal punctuation (. ? !), colon,
10
+ // semicolon, and parentheticals. A plain COMMA is NOT an ι — it is a minor
11
+ // ϕ break. (This is the "utterance boundaries, not line breaks / not every
12
+ // comma" fix.)
13
+ // ϕ at: a comma; the head of an oblique PP (a preposition opens a new phrase —
14
+ // "…compare thee | to a summer's day"); each coordinate conjunct; a clause
15
+ // complement (CCOMP/XCOMP/ADVCL); and the junction between a full nominal
16
+ // SUBJECT and its predicate verb.
17
+ // κ a content head plus its function words by DIRECTION: determiners, case
18
+ // markers/prepositions, auxiliaries, coordinators, complementisers, numerals,
19
+ // possessives and (sub/obj) pronouns procliticise rightward onto the following
20
+ // head; the possessive 's and verb particles encliticise leftward. A trailing
21
+ // proclitic with no head to its right attaches to the preceding group (so
22
+ // "…compare thee" keeps the object pronoun in the verb's group).
23
+ //
24
+ // Output is the shared IntonationalUnit[] shape, so display/scansion are unchanged.
25
+ // It reads only POS + canonicalRel + surface order; it never mutates the parse.
26
+
27
+ import {
28
+ ClsSentence, ClsWord, CliticGroup, PhonologicalPhrase, IntonationalUnit,
29
+ } from '../types.js';
30
+ import { computePhiDomains } from './bracketing.js';
31
+
32
+ // ─── punctuation → break kind ──────────────────────────────────────
33
+ const IU_PUNCT_TAGS = new Set(['.', '!', '?', ':', ';', '-LRB-', '-RRB-', '(', ')']);
34
+ const IU_PUNCT_WORDS = new Set(['.', '!', '?', '…', ':', ';', '(', ')']);
35
+
36
+ type BreakKind = 'iu' | 'phi' | null;
37
+ function breakKind(w: ClsWord): BreakKind {
38
+ if (IU_PUNCT_TAGS.has(w.lexicalClass) || IU_PUNCT_WORDS.has(w.word)) return 'iu';
39
+ if (w.lexicalClass === ',' || w.word === ',') return 'phi';
40
+ return null; // other punctuation (quotes, etc.) — transparent to phrasing
41
+ }
42
+ function isAnyPunct(w: ClsWord): boolean {
43
+ return /^[^A-Za-z0-9]+$/.test(w.lexicalClass) ||
44
+ w.lexicalClass === '-LRB-' || w.lexicalClass === '-RRB-';
45
+ }
46
+
47
+ // ─── κ: directional cliticisation ──────────────────────────────────
48
+ const PROCLITIC_REL = new Set([
49
+ 'DET', 'CASE', 'AUX', 'AUXPASS', 'CC', 'COMPMARK', 'ADVMARK', 'NUMMOD', 'EXPL',
50
+ ]);
51
+ const PROCLITIC_POS = /^(DT|PDT|IN|TO|CC|MD|WDT|WP|WP\$|EX|PRP\$|PRP)$/;
52
+
53
+ const OBJECT_REL = new Set(['DOBJ', 'IOBJ', 'OBL', 'OBJ']);
54
+
55
+ /** A clitic that leans LEFTWARD onto the preceding head (possessive 's, particle,
56
+ * OBJECT pronoun). An object pronoun encliticises to its verb ("compare thee",
57
+ * "give me") — so it must NOT be pulled rightward into the next phrase, which is
58
+ * what buried the preposition in "…compare thee | to a summer's day". */
59
+ function isEnclitic(w: ClsWord): boolean {
60
+ if (w.lexicalClass === 'POS' || w.lexicalClass === 'RP' || w.canonicalRel === 'VPRT') return true;
61
+ if (w.lexicalClass === 'PRP' && OBJECT_REL.has(w.canonicalRel ?? '')) return true;
62
+ return false;
63
+ }
64
+ /** A function word that leans RIGHTWARD onto the following content head. */
65
+ function isProclitic(w: ClsWord): boolean {
66
+ if (w.isContent) return false; // a promoted particle/demonstrative is a head
67
+ if (isEnclitic(w)) return false;
68
+ if (PROCLITIC_REL.has(w.canonicalRel ?? '')) return true;
69
+ return PROCLITIC_POS.test(w.lexicalClass);
70
+ }
71
+
72
+ /** Build clitic groups over one IU segment's content/function words (no punct). */
73
+ function buildCliticGroups(words: ClsWord[]): CliticGroup[] {
74
+ const groups: CliticGroup[] = [];
75
+ let pending: ClsWord[] = []; // proclitics awaiting their head
76
+ for (const w of words) {
77
+ if (isEnclitic(w) && groups.length > 0 && pending.length === 0) {
78
+ groups[groups.length - 1].tokens.push(w);
79
+ continue;
80
+ }
81
+ if (isProclitic(w)) { pending.push(w); continue; }
82
+ // content head (or any non-clitic): open a CP with its pending proclitics.
83
+ groups.push({ tokens: [...pending, w] });
84
+ pending = [];
85
+ }
86
+ if (pending.length) {
87
+ if (groups.length) groups[groups.length - 1].tokens.push(...pending);
88
+ else groups.push({ tokens: pending });
89
+ }
90
+ return groups;
91
+ }
92
+
93
+ // ─── ϕ: phrase grouping (by dependency ϕ-domain) ───────────────────
94
+ // A ϕ boundary opens between two clitic groups when they fall in DIFFERENT
95
+ // ϕ-domains of the dependency tree (computePhiDomains, bracketing.ts) — the
96
+ // SAME constituent structure the cyclic stress rules use — replacing the old
97
+ // POS-keyed opensPhrase heuristic. So a stranded particle no longer opens a
98
+ // spurious oblique ϕ ("thought of" stays together), a branching object NP gets
99
+ // its own ϕ (reading | the latest biography), and a clause does not flatten
100
+ // when its head is mis-tagged (the relations, not the POS, decide).
101
+
102
+ function rightmostContent(cg: CliticGroup): ClsWord | undefined {
103
+ for (let i = cg.tokens.length - 1; i >= 0; i--) if (cg.tokens[i].isContent) return cg.tokens[i];
104
+ return cg.tokens[cg.tokens.length - 1];
105
+ }
106
+
107
+ /** The ϕ-domain id a clitic group sits in: the domain of its head (rightmost
108
+ * content word, else last token); a function-only group falls back to any of
109
+ * its tokens' domains (its proclitics share the domain of the head they lean
110
+ * onto). A new ϕ opens whenever this id changes between adjacent groups. */
111
+ function domainOf(cg: CliticGroup, dom: Map<ClsWord, number>): number {
112
+ const head = rightmostContent(cg) ?? cg.tokens[cg.tokens.length - 1];
113
+ const d = dom.get(head);
114
+ if (d !== undefined) return d;
115
+ for (const t of cg.tokens) { const dd = dom.get(t); if (dd !== undefined) return dd; }
116
+ return -1;
117
+ }
118
+
119
+ /** Two PARSE-ROBUST ϕ-boundary markers from McAleese's Table-1 that the
120
+ * dependency-domain core cannot see when en-parse mis-attaches:
121
+ *
122
+ * • a COORDINATOR (CC "and / or / but") — each coordinate conjunct is its own
123
+ * ϕ; en-parse often flattens "old and gray and full" into a list of AMODs,
124
+ * so the conj relation is gone, but the CC token survives reliably.
125
+ * • a RELATIVE PRONOUN (WDT/WP "that / which / who") — it opens the relative
126
+ * clause's ϕ; en-parse routinely fails to build the ACL ("…the cat that
127
+ * caught the rat" flattened into one clause), but the relativiser is tagged.
128
+ *
129
+ * These are the two Table-1 triggers that survive attachment errors, so they
130
+ * supplement the dependency domains rather than re-introducing POS guesswork. */
131
+ function startsCoordOrRelative(cg: CliticGroup): boolean {
132
+ const t = cg.tokens[0];
133
+ if (!t) return false;
134
+ if (t.lexicalClass === 'CC' || t.canonicalRel === 'CC') return true;
135
+ return /^(WDT|WP|WP\$)$/.test(t.lexicalClass);
136
+ }
137
+
138
+ // A φ needs a stress-bearing ANCHOR to stand on its own. A content word is the
139
+ // usual one, but the 'n'-tier function words — demonstratives, quantifiers, and
140
+ // wh-words (relstress.functionLevel → 'n') — also carry a real beat, so a phrase
141
+ // built around one ("for THAT", "to EACH", "by WHICH") is a genuine φ with a
142
+ // nuclear and must NOT be dissolved into a neighbour the way a pure article /
143
+ // preposition / pronoun run ("around it") is. (Lemma/POS list mirrors relstress'
144
+ // 'n' category; kept local so the hierarchy layer does not depend on the stress one.)
145
+ const ANCHOR_LEMMAS = new Set([
146
+ 'this', 'that', 'these', 'those',
147
+ 'all', 'both', 'each', 'every', 'some', 'any', 'many', 'much', 'few', 'most',
148
+ 'half', 'several', 'either', 'neither', 'enough', 'none',
149
+ ]);
150
+ const ANCHOR_POS = /^(PDT|WDT|WP|WP\$|WRB)$/;
151
+ function canAnchorBeat(pp: PhonologicalPhrase): boolean {
152
+ return pp.cliticGroups.some(cg => cg.tokens.some(t =>
153
+ t.isContent ||
154
+ /^VB/.test(t.lexicalClass) || // a verb group ("has been") is a real ϕ even
155
+ // when its tokens are non-content auxiliaries —
156
+ // it must not fold back into the subject NP
157
+ ANCHOR_LEMMAS.has(t.word.toLowerCase().replace(/['’]/g, '')) ||
158
+ ANCHOR_POS.test(t.lexicalClass)));
159
+ }
160
+
161
+ function groupIntoPhrases(
162
+ cgs: CliticGroup[], commaBeforeCG: Set<CliticGroup>, dom: Map<ClsWord, number>
163
+ ): PhonologicalPhrase[] {
164
+ const phrases: { cgs: CliticGroup[]; commaPreceded: boolean }[] = [];
165
+ let current: CliticGroup[] = [];
166
+ let prev: CliticGroup | null = null;
167
+ let currentCommaPreceded = false;
168
+ for (const cg of cgs) {
169
+ const brk = current.length > 0 && prev !== null &&
170
+ (commaBeforeCG.has(cg) || startsCoordOrRelative(cg) ||
171
+ domainOf(cg, dom) !== domainOf(prev, dom));
172
+ if (brk) {
173
+ phrases.push({ cgs: current, commaPreceded: currentCommaPreceded });
174
+ current = [];
175
+ currentCommaPreceded = commaBeforeCG.has(cg);
176
+ }
177
+ current.push(cg);
178
+ prev = cg;
179
+ }
180
+ if (current.length) phrases.push({ cgs: current, commaPreceded: currentCommaPreceded });
181
+
182
+ // A ϕ must have a beat-anchor: a phrase of only un-anchored function words (a
183
+ // stranded preposition + pronoun like "around it", an orphan determiner) has no
184
+ // nuclear to carry a beat, so it MERGES into its neighbour — into the previous
185
+ // phrase when there is one (it leans back onto the head it modifies), else the
186
+ // next. A phrase anchored by a demonstrative/quantifier/wh ("for THAT") is NOT
187
+ // merged — it is a genuine φ.
188
+ //
189
+ // BUT: a phrase preceded by a COMMA is NEVER merged, even if it lacks a
190
+ // beat-anchor. A comma is an OVERT prosodic boundary the poet placed; merging
191
+ // across it destroys the boundary and flattens the post-comma material into the
192
+ // pre-comma phrase ("among them" after "And of the best," → xww instead of xnw).
193
+ // A function-word-only post-comma phrase keeps its separate ϕ so the
194
+ // relativiser can apply the phrase-initial beat and the givenness escape.
195
+ const merged: PhonologicalPhrase[] = [];
196
+ for (const p of phrases) {
197
+ const pp: PhonologicalPhrase = { cliticGroups: p.cgs };
198
+ if (!canAnchorBeat(pp) && merged.length > 0 && !p.commaPreceded) {
199
+ merged[merged.length - 1].cliticGroups.push(...p.cgs);
200
+ } else {
201
+ merged.push(pp);
202
+ }
203
+ }
204
+ // A leading un-anchored phrase folds forward into the next — but NOT if it was
205
+ // comma-preceded (a comma before the first phrase would be line-initial, which
206
+ // doesn't happen; this guard is for safety).
207
+ if (merged.length >= 2 && !canAnchorBeat(merged[0]) && !phrases[0].commaPreceded) {
208
+ merged[1].cliticGroups.unshift(...merged[0].cliticGroups);
209
+ merged.shift();
210
+ }
211
+ return merged;
212
+ }
213
+
214
+ // ─── top level ─────────────────────────────────────────────────────
215
+ export function buildProsodicHierarchy(sent: ClsSentence): IntonationalUnit[] {
216
+ // ϕ-domains over the dependency tree (the same constituent structure the
217
+ // cyclic stress rules use) — the grouping signal for phonological phrases.
218
+ const dom = computePhiDomains(sent);
219
+
220
+ const ius: IntonationalUnit[] = [];
221
+ let segWords: ClsWord[] = [];
222
+ let commaAfterPos = new Set<number>(); // positions in segWords with a comma after
223
+
224
+ const flush = () => {
225
+ if (segWords.length === 0) { commaAfterPos = new Set(); return; }
226
+ // A φ-break (comma) is a HARD boundary for clitic-group formation: a proclitic
227
+ // must not lean across a comma onto a head in the next phrase. (Letting it do
228
+ // so silently glued "for that ," onto the following "a specialized branch" — both
229
+ // "for" and "that" procliticise rightward onto "specialized" — so the comma fell
230
+ // INSIDE one clitic group and the φ-break was lost.) So we split the IU segment
231
+ // into RUNS at the comma positions, build clitic groups WITHIN each run, and mark
232
+ // the first CG of every run after the first as φ-preceded.
233
+ const runs: ClsWord[][] = [];
234
+ let run: ClsWord[] = [];
235
+ segWords.forEach((w, i) => {
236
+ run.push(w);
237
+ if (commaAfterPos.has(i)) { runs.push(run); run = []; }
238
+ });
239
+ if (run.length) runs.push(run);
240
+
241
+ const cgs: CliticGroup[] = [];
242
+ const commaBeforeCG = new Set<CliticGroup>();
243
+ runs.forEach((r, ri) => {
244
+ const rcgs = buildCliticGroups(r);
245
+ if (ri > 0 && rcgs.length > 0) commaBeforeCG.add(rcgs[0]);
246
+ cgs.push(...rcgs);
247
+ });
248
+ ius.push({ phonologicalPhrases: groupIntoPhrases(cgs, commaBeforeCG, dom) });
249
+ segWords = [];
250
+ commaAfterPos = new Set();
251
+ };
252
+
253
+ for (const w of sent.words) {
254
+ const kind = breakKind(w);
255
+ if (kind === 'iu') { flush(); continue; }
256
+ if (kind === 'phi') { if (segWords.length) commaAfterPos.add(segWords.length - 1); continue; }
257
+ if (isAnyPunct(w)) continue; // transparent punctuation
258
+ segWords.push(w);
259
+ }
260
+ flush();
261
+ return ius;
262
+ }