calliope-ts 0.0.2 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/README.md +32 -20
  2. package/dist/caesura.d.ts +33 -0
  3. package/dist/caesura.d.ts.map +1 -0
  4. package/dist/caesura.js +202 -0
  5. package/dist/calliope/boundaries.d.ts +19 -0
  6. package/dist/calliope/boundaries.d.ts.map +1 -0
  7. package/dist/calliope/boundaries.js +182 -0
  8. package/dist/calliope/bracketing.d.ts +11 -0
  9. package/dist/calliope/bracketing.d.ts.map +1 -0
  10. package/dist/calliope/bracketing.js +416 -0
  11. package/dist/calliope/deps.d.ts +4 -0
  12. package/dist/calliope/deps.d.ts.map +1 -0
  13. package/dist/calliope/deps.js +181 -0
  14. package/dist/calliope/engine.d.ts +3 -0
  15. package/dist/calliope/engine.d.ts.map +1 -0
  16. package/dist/calliope/engine.js +71 -0
  17. package/dist/calliope/feats.d.ts +9 -0
  18. package/dist/calliope/feats.d.ts.map +1 -0
  19. package/dist/calliope/feats.js +45 -0
  20. package/dist/calliope/names.d.ts +7 -0
  21. package/dist/calliope/names.d.ts.map +1 -0
  22. package/dist/calliope/names.js +42 -0
  23. package/dist/calliope/postag.d.ts +8 -0
  24. package/dist/calliope/postag.d.ts.map +1 -0
  25. package/dist/calliope/postag.js +250 -0
  26. package/dist/calliope/prosodic.d.ts +3 -0
  27. package/dist/calliope/prosodic.d.ts.map +1 -0
  28. package/dist/calliope/prosodic.js +275 -0
  29. package/dist/calliope/relstress.d.ts +4 -0
  30. package/dist/calliope/relstress.d.ts.map +1 -0
  31. package/dist/calliope/relstress.js +688 -0
  32. package/dist/calliope/stressrules.d.ts +28 -0
  33. package/dist/calliope/stressrules.d.ts.map +1 -0
  34. package/dist/calliope/stressrules.js +147 -0
  35. package/dist/calliope/syntax.d.ts +38 -0
  36. package/dist/calliope/syntax.d.ts.map +1 -0
  37. package/dist/calliope/syntax.js +234 -0
  38. package/dist/calliope/udpos.d.ts +13 -0
  39. package/dist/calliope/udpos.d.ts.map +1 -0
  40. package/dist/calliope/udpos.js +156 -0
  41. package/dist/clio/caesura.d.ts +27 -0
  42. package/dist/clio/caesura.d.ts.map +1 -0
  43. package/dist/clio/caesura.js +148 -0
  44. package/dist/clio/depfix.d.ts +13 -0
  45. package/dist/clio/depfix.d.ts.map +1 -0
  46. package/dist/clio/depfix.js +84 -0
  47. package/dist/clio/display.d.ts +32 -0
  48. package/dist/clio/display.d.ts.map +1 -0
  49. package/dist/clio/display.js +976 -0
  50. package/dist/clio/engine.d.ts +3 -0
  51. package/dist/clio/engine.d.ts.map +1 -0
  52. package/dist/clio/engine.js +28 -0
  53. package/dist/clio/parser.d.ts +10 -0
  54. package/dist/clio/parser.d.ts.map +1 -0
  55. package/dist/clio/parser.js +696 -0
  56. package/dist/clio/phonological.d.ts +41 -0
  57. package/dist/clio/phonological.d.ts.map +1 -0
  58. package/dist/clio/phonological.js +788 -0
  59. package/dist/clio/phrasestress.d.ts +6 -0
  60. package/dist/clio/phrasestress.d.ts.map +1 -0
  61. package/dist/clio/phrasestress.js +106 -0
  62. package/dist/clio/pipeline.d.ts +11 -0
  63. package/dist/clio/pipeline.d.ts.map +1 -0
  64. package/dist/clio/pipeline.js +147 -0
  65. package/dist/clio/rhyme.d.ts +65 -0
  66. package/dist/clio/rhyme.d.ts.map +1 -0
  67. package/dist/clio/rhyme.js +761 -0
  68. package/dist/clio/scandroid.d.ts +17 -0
  69. package/dist/clio/scandroid.d.ts.map +1 -0
  70. package/dist/clio/scandroid.js +435 -0
  71. package/dist/clio/scansion.d.ts +46 -0
  72. package/dist/clio/scansion.d.ts.map +1 -0
  73. package/dist/clio/scansion.js +1086 -0
  74. package/dist/clio/semantics.d.ts +44 -0
  75. package/dist/clio/semantics.d.ts.map +1 -0
  76. package/dist/clio/semantics.js +139 -0
  77. package/dist/clio/stress.d.ts +83 -0
  78. package/dist/clio/stress.d.ts.map +1 -0
  79. package/dist/clio/stress.js +1737 -0
  80. package/dist/clio/tagfix.d.ts +6 -0
  81. package/dist/clio/tagfix.d.ts.map +1 -0
  82. package/dist/clio/tagfix.js +101 -0
  83. package/dist/display.d.ts +0 -6
  84. package/dist/display.d.ts.map +1 -1
  85. package/dist/display.js +322 -141
  86. package/dist/engine.d.ts +9 -0
  87. package/dist/engine.d.ts.map +1 -0
  88. package/dist/engine.js +12 -0
  89. package/dist/index.d.ts +4 -3
  90. package/dist/index.d.ts.map +1 -1
  91. package/dist/index.js +169 -26
  92. package/dist/parser.d.ts +3 -3
  93. package/dist/parser.d.ts.map +1 -1
  94. package/dist/parser.js +320 -563
  95. package/dist/phonological.d.ts +1 -1
  96. package/dist/phonological.d.ts.map +1 -1
  97. package/dist/phonological.js +73 -4
  98. package/dist/phrasestress.d.ts +6 -0
  99. package/dist/phrasestress.d.ts.map +1 -0
  100. package/dist/phrasestress.js +106 -0
  101. package/dist/rhyme.d.ts +40 -1
  102. package/dist/rhyme.d.ts.map +1 -1
  103. package/dist/rhyme.js +435 -7
  104. package/dist/scansion.d.ts +9 -0
  105. package/dist/scansion.d.ts.map +1 -1
  106. package/dist/scansion.js +145 -18
  107. package/dist/semantics.d.ts +44 -0
  108. package/dist/semantics.d.ts.map +1 -0
  109. package/dist/semantics.js +139 -0
  110. package/dist/stress.d.ts +127 -4
  111. package/dist/stress.d.ts.map +1 -1
  112. package/dist/stress.js +834 -61
  113. package/dist/types.d.ts +15 -0
  114. package/dist/types.d.ts.map +1 -1
  115. package/package.json +5 -3
  116. package/src/caesura.ts +201 -0
  117. package/src/calliope/boundaries.ts +190 -0
  118. package/src/calliope/bracketing.ts +390 -0
  119. package/src/calliope/deps.ts +160 -0
  120. package/src/calliope/engine.ts +77 -0
  121. package/src/calliope/feats.ts +46 -0
  122. package/src/calliope/names.ts +44 -0
  123. package/src/calliope/postag.ts +253 -0
  124. package/src/calliope/prosodic.ts +262 -0
  125. package/src/calliope/relstress.ts +645 -0
  126. package/src/calliope/stressrules.ts +147 -0
  127. package/src/calliope/syntax.ts +218 -0
  128. package/src/calliope/udpos.ts +152 -0
  129. package/src/calliope_src_contents.md +19049 -0
  130. package/src/clio/caesura.ts +145 -0
  131. package/src/clio/depfix.ts +88 -0
  132. package/src/clio/display.ts +1042 -0
  133. package/src/clio/engine.ts +38 -0
  134. package/src/clio/parser.ts +845 -0
  135. package/src/clio/phonological.ts +849 -0
  136. package/src/clio/phrasestress.ts +108 -0
  137. package/src/clio/pipeline.ts +154 -0
  138. package/src/clio/rhyme.ts +740 -0
  139. package/src/clio/scandroid.ts +434 -0
  140. package/src/clio/scansion.ts +1130 -0
  141. package/src/clio/semantics.ts +134 -0
  142. package/src/clio/stress.ts +1731 -0
  143. package/src/clio/tagfix.ts +104 -0
  144. package/src/display.ts +321 -137
  145. package/src/engine.ts +22 -0
  146. package/src/index.ts +176 -32
  147. package/src/parser.ts +346 -682
  148. package/src/phonological.ts +72 -4
  149. package/src/phrasestress.ts +108 -0
  150. package/src/rhyme.ts +428 -8
  151. package/src/scansion.ts +136 -15
  152. package/src/semantics.ts +134 -0
  153. package/src/stress.ts +794 -64
  154. package/src/types.ts +39 -4
  155. package/tests/DataForHayesLinesOnly.txt +364 -0
  156. package/tests/DataForHayesStressSymbolsRevised.txt +728 -0
  157. package/tests/basic.test.ts +443 -37
  158. package/tests/bench-hayes.mjs +72 -0
  159. package/tests/wagner-stress.test.ts +188 -0
  160. package/vitest.config.ts +0 -15
@@ -0,0 +1,147 @@
1
+ // calliope/stressrules.ts — Scenario A–O relation-keyed stress (the maintainer's
2
+ // authoritative spec, each rule cross-checked against external linguistics).
3
+ // Operates over POS + word.canonicalRel + the person/place name flags.
4
+ //
5
+ // THIS FILE so far implements the nominal-modifier scenarios — the most directly
6
+ // testable, and where the canonical substrate first pays off:
7
+ // A N+N noun-adjunct (NOMD) → LEFT (SLATE roof, BIRD nest, MSNBC news,
8
+ // REAGAN era) — the ~⅔ baseline;
9
+ // food/temporal heads stay RIGHT.
10
+ // B Adj+N (AMOD) → RIGHT (a green ROOF); a LEXICALISED Adj+N
11
+ // compound fore-stresses (HOT dog).
12
+ // C Proper NAME (NNP+NNP) → RIGHT/head (New YORK, Bay BRIDGE) EXCEPT a
13
+ // de-accenting classifier head ("Street"
14
+ // → WALL Street).
15
+ // M is subsumed here (proper-name extension → rightmost head).
16
+ // Verbal/clausal/override scenarios (D–L, N, O) are added next.
17
+ //
18
+ // External-theory notes (binding): N+N left is only a ~⅔ baseline (Kunter & Plag),
19
+ // so the curated right-stress heads are first-class; lexicalised Adj+N fore-stress
20
+ // is a documented lexicalisation marker, not merely contrast; "Street" de-accents.
21
+
22
+ import { ClsWord } from '../types.js';
23
+ import { setPrimaryStress, isRightStressedHead } from '../stress.js';
24
+
25
+ const NOUN = /^(NN|NNS|NNP|NNPS)$/;
26
+ const PROPER = /^(NNP|NNPS)$/;
27
+ const ADJ = /^JJ/;
28
+
29
+ function bare(w: ClsWord): string {
30
+ return w.word.toLowerCase().replace(/[^a-z]/g, '');
31
+ }
32
+ function isNoun(w: ClsWord): boolean { return NOUN.test(w.lexicalClass); }
33
+ function isProper(w: ClsWord): boolean { return PROPER.test(w.lexicalClass); }
34
+ function isAdjMod(w: ClsWord): boolean { return ADJ.test(w.lexicalClass) || w.canonicalRel === 'AMOD'; }
35
+
36
+ // Scenario C exception: proper-name heads that de-accent the name LEFT. "Street"
37
+ // is the well-documented case (WALL Street, STATE Street) vs right-stressing
38
+ // Avenue/Road/Square/Bridge (Fifth AVENUE) — see the plan's external cross-check.
39
+ const PROPER_NAME_LEFT_HEADS = new Set(['street']);
40
+
41
+ // Scenario B exception: lexicalised Adj+N compounds that fore-stress despite the
42
+ // AMOD right-stress baseline (the stress shift is the lexicalisation marker:
43
+ // "HOT dog" the food vs "hot DOG" a dog that is hot). Small, curated, defensible.
44
+ const LEXICALIZED_FORESTRESS = new Set([
45
+ 'hot dog', 'hot dogs', 'high school', 'high schools', 'white house',
46
+ 'black board', 'blackboard', 'green house', 'blue blood', 'hot rod',
47
+ ]);
48
+ function isLexicalizedForestress(a: ClsWord, b: ClsWord): boolean {
49
+ return LEXICALIZED_FORESTRESS.has(`${bare(a)} ${bare(b)}`);
50
+ }
51
+
52
+ type Side = 'left' | 'right';
53
+
54
+ /** Which element of an adjacent (modifier w1, head-noun w2) structure carries
55
+ * primary stress, per Scenarios A/B/C. */
56
+ export function scenarioModifierSide(w1: ClsWord, w2: ClsWord): Side {
57
+ // Participial/gerundial HYPHENATED compound modifier (book-carrying, law-abiding,
58
+ // well-known) is adjectival/phrasal, NOT a noun adjunct — so it end-stresses, the
59
+ // head keeping prominence: "a book-carrying MAN", "a law-abiding CITIZEN". (The
60
+ // table's VBN/VBG-participle → end-stress; only a single-word -ing GERUND like
61
+ // WAITING room fore-stresses, and those are not hyphenated.)
62
+ if (/-/.test(w1.word) && /(ing|ed)$/i.test(bare(w1))) return 'right';
63
+ // C — proper NAME (both proper nouns): head/right, classifier "Street" → left.
64
+ if (isProper(w1) && isProper(w2)) {
65
+ return PROPER_NAME_LEFT_HEADS.has(bare(w2)) ? 'left' : 'right';
66
+ }
67
+ // B — Adj+N: right (compositional); lexicalised Adj+N compound → left.
68
+ if (isAdjMod(w1)) {
69
+ return isLexicalizedForestress(w1, w2) ? 'left' : 'right';
70
+ }
71
+ // A — N+N noun-adjunct (incl. proper-modifier + common head, REAGAN era / MSNBC
72
+ // news): left by default; food/temporal right-stress heads (apple PIE) → right.
73
+ if (isNoun(w1) && isNoun(w2)) {
74
+ return isRightStressedHead(w2.word) ? 'right' : 'left';
75
+ }
76
+ return 'right';
77
+ }
78
+
79
+ /**
80
+ * Apply the nominal-modifier stress scenarios over a sentence's words. Mirrors
81
+ * the legacy `applyCompoundStress` iteration (adjacent content words, noun head)
82
+ * but decides via the canonical relations / name flags rather than POS alone, so
83
+ * the proper-modifier and lexicalised cases come out right. Replaces
84
+ * `applyCompoundStress` in the Calliope engine.
85
+ */
86
+ export function applyScenarioStress(words: ClsWord[]): void {
87
+ const content = words.filter(w => w.isContent);
88
+ for (let i = 0; i + 1 < content.length; i++) {
89
+ const w1 = content[i];
90
+ const w2 = content[i + 1];
91
+ if (w1.absoluteIndex + 1 !== w2.absoluteIndex) continue; // surface-adjacent only
92
+ if (!isNoun(w2)) continue; // head of the structure is a noun
93
+ if (!isNoun(w1) && !isAdjMod(w1)) continue; // modifier is a noun or adjective
94
+ if (scenarioModifierSide(w1, w2) === 'left') {
95
+ setPrimaryStress(w1, 2);
96
+ setPrimaryStress(w2, 1);
97
+ } else {
98
+ setPrimaryStress(w1, 1);
99
+ setPrimaryStress(w2, 2);
100
+ }
101
+ }
102
+ }
103
+
104
+ const RANK: Record<string, number> = { x: 0, w: 1, n: 2, m: 3, s: 4 };
105
+ const LEVELS = ['x', 'w', 'n', 'm', 's'] as const;
106
+
107
+ function peakSyll(w: ClsWord) {
108
+ let best = null as ClsWord['syllables'][number] | null;
109
+ let bestR = -1;
110
+ for (const s of w.syllables) {
111
+ const r = RANK[s.relativeStress ?? 'w'];
112
+ if (r >= bestR) { bestR = r; best = s; }
113
+ }
114
+ return best;
115
+ }
116
+
117
+ /**
118
+ * Make the LEFT-stress scenario decision DURABLE in the relative-stress contour.
119
+ *
120
+ * The lexical compound pass (`applyScenarioStress`) sets the modifier primary, but
121
+ * the nuclear pass + relativiser re-promote the rightmost word, so a *non-curated*
122
+ * fore-stressed compound (SLATE roof, REAGAN era, WALL Street, HOT dog) would still
123
+ * surface as end-stressed. The legacy `resolveCompoundForestress` repairs this only
124
+ * for the curated `isLeftStressedPair` set; this Calliope post-pass — run AFTER
125
+ * `assignRelativeStresses` — re-asserts the same demote-only fore-stress for EVERY
126
+ * scenario-left pair: the modifier rises to the pair's max prominence, the head is
127
+ * demoted one rung below (never raised), which also keeps the no-equal-clash rule.
128
+ */
129
+ export function enforceScenarioStress(words: ClsWord[]): void {
130
+ const content = words.filter(w => w.isContent);
131
+ for (let i = 0; i + 1 < content.length; i++) {
132
+ const w1 = content[i];
133
+ const w2 = content[i + 1];
134
+ if (w1.absoluteIndex + 1 !== w2.absoluteIndex) continue;
135
+ if (!isNoun(w2)) continue;
136
+ if (!isNoun(w1) && !isAdjMod(w1)) continue;
137
+ if (scenarioModifierSide(w1, w2) !== 'left') continue;
138
+ const s1 = peakSyll(w1);
139
+ const s2 = peakSyll(w2);
140
+ if (!s1 || !s2) continue;
141
+ const r1 = RANK[s1.relativeStress ?? 'w'];
142
+ const r2 = RANK[s2.relativeStress ?? 'w'];
143
+ const hi = Math.max(r1, r2);
144
+ s1.relativeStress = LEVELS[hi]; // modifier ≥ both
145
+ s2.relativeStress = LEVELS[Math.min(r2, Math.max(0, hi - 1))]; // demote head only
146
+ }
147
+ }
@@ -0,0 +1,218 @@
1
+ // calliope/syntax.ts — Wagner/Krifka structural predicates shared by the cyclic
2
+ // stress (bracketing.ts) and the relativiser (relstress.ts).
3
+ //
4
+ // These encode the functor/argument geometry of Wagner (2005) Ch. 6 and the
5
+ // argument/adjunct/locative typology of Krifka (2001) §4.5 / Wagner §6.5.1, read
6
+ // off the canonical UD relations + Penn POS + morphological FEATS. No semantic
7
+ // role labeller is available, so the obl subtyping and transitivity tests are
8
+ // heuristic (preposition lemma + surface position + complement presence); every
9
+ // classification is approximate and, where uncertain, defaults to the reading
10
+ // that preserves prior behaviour (integration / transitive).
11
+
12
+ import { ClsWord } from '../types.js';
13
+ import { feat } from './feats.js';
14
+
15
+ const NOUN = /^(NN|NNS|NNP|NNPS)$/;
16
+ const PROPER = /^(NNP|NNPS)$/;
17
+ const VERB = /^VB/;
18
+ const PRON = /^(PRP|PRP\$)$/;
19
+
20
+ export function isPunct(w: ClsWord): boolean {
21
+ return /^[^A-Za-z0-9]+$/.test(w.lexicalClass) || w.syllables.length === 0;
22
+ }
23
+ export function isNoun(w: ClsWord): boolean { return NOUN.test(w.lexicalClass); }
24
+ export function isVerb(w: ClsWord): boolean { return VERB.test(w.lexicalClass); }
25
+ function bare(w: ClsWord): string { return w.word.toLowerCase().replace(/['’]/g, ''); }
26
+
27
+ /** A PRONOUN — inherently given (Wagner §7.2.3): a personal/possessive pronoun by
28
+ * POS, or any word the parse marks PronType=Prs. Demonstratives (this/that),
29
+ * wh-words (Rel/Int), negative (nobody/nothing) and indefinite-as-focus pronouns
30
+ * are NOT treated as inherently given here. */
31
+ export function isPronoun(w: ClsWord): boolean {
32
+ if (PRON.test(w.lexicalClass)) return true;
33
+ return feat(w, 'PronType') === 'Prs';
34
+ }
35
+
36
+ /** A POSITIVE indefinite pronoun — inherently given (Wagner §7.2.3): its
37
+ * existential presupposition is trivially satisfied in any context, so it is
38
+ * subordinated by default and yields the utterance nuclear to a heavier sister
39
+ * ("Something for the modern STAGE"). Only the SOME-/ANY- existential series:
40
+ * NEGATIVE (nothing/nobody) and temporal/emphatic words are deliberately excluded
41
+ * — those are FOCAL, not given (Eliot's "Nothing", McCartney's "Yesterday"), and
42
+ * per the maintainer's directive must keep their prominence. UDPipe routinely
43
+ * mis-tags these as NN, so detection is by lemma (+ PronType guard). */
44
+ const INDEFINITE_GIVEN = new Set([
45
+ 'something', 'someone', 'somebody', 'somewhat', 'somewhere', 'someplace',
46
+ ]);
47
+ export function isInherentlyGiven(w: ClsWord): boolean {
48
+ if (!INDEFINITE_GIVEN.has(bare(w))) return false;
49
+ const pt = feat(w, 'PronType');
50
+ return pt !== 'Neg'; // never a negative pronoun
51
+ }
52
+
53
+ /** A LIGHT nominal head — a personal pronoun or an inherently-given indefinite —
54
+ * whose post-nominal PP modifier cannot be hosted inside the head's own group and
55
+ * so needs its OWN ϕ (the phrasing break the ear hears after "Something …"). */
56
+ export function isLightNominalHead(w: ClsWord): boolean {
57
+ return isPronoun(w) || isInherentlyGiven(w);
58
+ }
59
+
60
+ /** A child relation that makes a verb BRANCH (carry an internal argument) — the
61
+ * specifier-restriction trigger (Wagner §6.3.2): a transitive/clausal VP whose
62
+ * branchingness blocks subordination of its subject. */
63
+ const ARG_CHILD = new Set(['DOBJ', 'IOBJ', 'OBJ', 'OBL', 'CCOMP', 'XCOMP']);
64
+ export function verbHasArgChild(
65
+ verb: ClsWord, children: Map<number, ClsWord[]>,
66
+ ): boolean {
67
+ return (children.get(verb.absoluteIndex) ?? []).some(c => {
68
+ const rel = c.canonicalRel ?? '';
69
+ if (!ARG_CHILD.has(rel)) return false;
70
+ // A low locative oblique is an adjunct, not a branching argument.
71
+ if (rel === 'OBL' && isLowLocative(c, verb)) return false;
72
+ return true;
73
+ });
74
+ }
75
+
76
+ /** An unaccusative / passive subject is an UNDERLYING OBJECT, so it can bear the
77
+ * nuclear accent (Krifka §4.5.2): NSUBJPASS, or Voice=Pass on the verb, or a
78
+ * small set of unaccusative intransitives. */
79
+ const UNACCUSATIVE = new Set([
80
+ 'arrive', 'come', 'go', 'fall', 'rise', 'appear', 'die', 'happen', 'remain',
81
+ 'emerge', 'occur', 'vanish', 'grow', 'sink', 'flow', 'melt', 'wake',
82
+ ]);
83
+ export function isUnaccusativeOrPassive(subj: ClsWord, verb: ClsWord): boolean {
84
+ if ((subj.canonicalRel ?? '') === 'NSUBJPASS') return true;
85
+ if (feat(verb, 'Voice') === 'Pass') return true;
86
+ return UNACCUSATIVE.has(bare(verb).replace(/(ed|s|ing)$/, ''));
87
+ }
88
+
89
+ // ─── oblique typology (Wagner §6.5.1 / Larson 2005 / Krifka §4.5.1) ──────────
90
+ const GOAL_PREPS = new Set(['to', 'into', 'onto', 'toward', 'towards', 'unto']);
91
+ const SOURCE_PREPS = new Set(['from', 'out']);
92
+ const ADJUNCT_PREPS = new Set([
93
+ 'in', 'on', 'at', 'by', 'for', 'during', 'throughout', 'within', 'amid',
94
+ 'amidst', 'among', 'amongst', 'beneath', 'beside', 'over', 'under', 'above',
95
+ ]);
96
+ const FRAME_PREPS = new Set([
97
+ 'in', 'on', 'at', 'by', 'during', 'after', 'before', 'since', 'until', 'upon',
98
+ ]);
99
+ const TIME_NOUNS = new Set([
100
+ 'hour', 'hours', 'day', 'days', 'week', 'weeks', 'month', 'months', 'year',
101
+ 'years', 'minute', 'minutes', 'moment', 'moments', 'century', 'centuries',
102
+ 'morning', 'evening', 'night', 'nights', 'spring', 'summer', 'autumn', 'winter',
103
+ 'dawn', 'dusk', 'noon', 'midnight', 'season', 'seasons',
104
+ ]);
105
+
106
+ /** The preposition lemma governing an oblique noun: its `case`/`CASE` dependent. */
107
+ export function prepLemmaOf(obl: ClsWord, words: ClsWord[]): string | null {
108
+ for (const w of words) {
109
+ if (w.dependency?.governor === obl && (w.canonicalRel === 'CASE' ||
110
+ w.lexicalClass === 'IN' || w.lexicalClass === 'TO')) {
111
+ return bare(w);
112
+ }
113
+ }
114
+ return null;
115
+ }
116
+
117
+ /** An oblique ARGUMENT (goal/source/recipient) — can integrate with the verb so
118
+ * the verb is subordinated and the oblique NP bears the single accent. */
119
+ export function isObliqueArgument(obl: ClsWord, words: ClsWord[]): boolean {
120
+ const p = prepLemmaOf(obl, words);
121
+ if (!p) return false;
122
+ if (GOAL_PREPS.has(p) || SOURCE_PREPS.has(p)) return true;
123
+ return false;
124
+ }
125
+
126
+ /** A LOW LOCATIVE / temporal adjunct (Larson 2005): a VP-final place/time oblique
127
+ * that gets its OWN accent regardless of position — NOT a functor of the VP. */
128
+ export function isLowLocative(obl: ClsWord, head: ClsWord, words?: ClsWord[]): boolean {
129
+ // Detect by preposition + noun semantics; if we have no word list, fall back to
130
+ // the noun being a time word.
131
+ if (words) {
132
+ const p = prepLemmaOf(obl, words);
133
+ if (p && ADJUNCT_PREPS.has(p)) {
134
+ // VP-final: the oblique follows its verbal head.
135
+ if (obl.absoluteIndex > head.absoluteIndex) return true;
136
+ }
137
+ }
138
+ if (TIME_NOUNS.has(bare(obl))) return true;
139
+ return false;
140
+ }
141
+
142
+ /** A FRAME-SETTING locative (Wagner §6.5.1): a sentence-initial temporal/spatial
143
+ * oblique that frames the proposition — IS a functor, gets its own domain. */
144
+ export function isFrameSetting(obl: ClsWord, words: ClsWord[]): boolean {
145
+ const p = prepLemmaOf(obl, words);
146
+ if (!p || !FRAME_PREPS.has(p)) return false;
147
+ // sentence-initial: the preposition is (near) the first content token.
148
+ let firstContent = -1;
149
+ for (let i = 0; i < words.length; i++) {
150
+ if (!isPunct(words[i])) { firstContent = words[i].absoluteIndex; break; }
151
+ }
152
+ return obl.absoluteIndex <= firstContent + 2;
153
+ }
154
+
155
+ // ─── function-word transitivity (Wagner §6.5.5) ──────────────────────────────
156
+ const PARTICLE_LEMMAS = new Set([
157
+ 'in', 'out', 'up', 'down', 'off', 'away', 'back', 'forth', 'over', 'around',
158
+ 'along', 'through', 'apart', 'aside', 'onward', 'about',
159
+ ]);
160
+
161
+ /** Last non-punct token of the clause at/after `w`? (the canonical strand site). */
162
+ function isClauseFinal(w: ClsWord, words: ClsWord[]): boolean {
163
+ const idx = words.findIndex(x => x === w);
164
+ if (idx < 0) return false;
165
+ for (let k = idx + 1; k < words.length; k++) {
166
+ if (!isPunct(words[k])) return false;
167
+ }
168
+ return true;
169
+ }
170
+
171
+ /** A TRANSITIVE function word governs a nominal complement (Wagner §6.5.5: only
172
+ * complement-taking functors have weak/stressless allomorphs). An INTRANSITIVE
173
+ * or STRANDED preposition/particle keeps stress — returns false. */
174
+ export function isTransitiveFunctionWord(w: ClsWord, words: ClsWord[]): boolean {
175
+ const pos = w.lexicalClass;
176
+ const rel = w.canonicalRel ?? '';
177
+ if (pos === 'RP' || rel === 'VPRT') return false; // particles are intransitive
178
+ if (pos === 'IN' || pos === 'TO' || rel === 'CASE') {
179
+ // Does it govern a noun (have a nominal complement)? In UD the preposition is
180
+ // a CASE dependent of its noun, so check both directions.
181
+ const gov = w.dependency?.governor;
182
+ if (gov && !isPunct(gov) && NOUN.test(gov.lexicalClass) &&
183
+ gov.absoluteIndex > w.absoluteIndex) {
184
+ return true; // prep → noun complement to its right
185
+ }
186
+ const hasNominalDep = words.some(d => d.dependency?.governor === w &&
187
+ (NOUN.test(d.lexicalClass) || d.canonicalRel === 'OBL' || d.canonicalRel === 'DOBJ'));
188
+ if (hasNominalDep) return true;
189
+ // Clause-final adverbial particle/preposition with no complement → stranded.
190
+ if (PARTICLE_LEMMAS.has(bare(w)) && isClauseFinal(w, words)) return false;
191
+ if (isClauseFinal(w, words)) return false; // stranded
192
+ return true; // default: transitive
193
+ }
194
+ return true; // det/aux/cc/etc.: transitive
195
+ }
196
+
197
+ // ─── functor of a head-dependent pair (Wagner §6.2.2 mapping) ────────────────
198
+ export type FunctorSide = 'dep' | 'head' | 'match';
199
+
200
+ /** Which sister of a (dep, head) UD edge is the FUNCTOR — or 'match' for an
201
+ * associative pairing (Wagner §2.2.2). This is the relation-keyed mapping in
202
+ * the plan's Gap-2 table: for some relations the GOVERNOR is the functor
203
+ * (DOBJ/OBL/CCOMP), for others the DEPENDENT is (AMOD/CASE/AUX/DET/ADVMOD). */
204
+ export function functorOf(dep: ClsWord): FunctorSide {
205
+ switch (dep.canonicalRel ?? '') {
206
+ case 'DOBJ': case 'IOBJ': case 'OBJ': case 'OBL':
207
+ case 'CCOMP': case 'XCOMP': case 'NSUBJ': case 'NSUBJPASS':
208
+ return 'head'; // governor (verb/VP) is functor
209
+ case 'AMOD': case 'ADVMOD': case 'ACL': case 'ADVCL':
210
+ case 'CASE': case 'AUX': case 'AUXPASS': case 'DET': case 'NUMMOD':
211
+ case 'CC': case 'COMPMARK': case 'ADVMARK': case 'EXPL': case 'VPRT':
212
+ return 'dep'; // dependent is functor
213
+ case 'CONJ': case 'DISCOURSE': case 'INTJ':
214
+ return 'match'; // associative / own accent
215
+ default:
216
+ return 'head';
217
+ }
218
+ }
@@ -0,0 +1,152 @@
1
+ // calliope/udpos.ts — UNIVERSAL POS → Penn Treebank conversion.
2
+ //
3
+ // WHY: the phonological pipeline keys on Penn XPOS (NN/VBZ/JJ/DT/IN…), but
4
+ // UDPipe's XPOS column is treebank-specific and INCONSISTENT across models —
5
+ // EWT/GUM emit Penn tags, but LinES emits its own morphological tagset
6
+ // (DEF/SG-NOM/PL-NOM/ING/REL…) and ParTUT an Italian-derived one (RD/S/V/E/A/PD…),
7
+ // neither of which the downstream understands. UPOS and FEATS, by contrast, are
8
+ // the Universal Dependencies standard and are consistent (and more accurate:
9
+ // ~94% UPOS vs ~93% XPOS) across ALL four models. So we DERIVE the Penn tag the
10
+ // pipeline needs from UPOS + morphological FEATS, making the parser model-agnostic
11
+ // AND giving us morphology en-parse never had (Number, Tense, Degree, PronType,
12
+ // VerbForm) to make finer, more reliable distinctions.
13
+
14
+ import type { UDWord } from 'udpipe-node';
15
+
16
+ // The Penn Treebank tag set the downstream understands. EWT/GUM emit these
17
+ // directly (and reliably — a lexicalised PDT like "all" stays PDT even when the
18
+ // parse mislabels its relation); LinES/ParTUT do NOT, so for those we derive the
19
+ // Penn tag from UPOS+FEATS instead.
20
+ const PENN_TAGS = new Set([
21
+ 'NN', 'NNS', 'NNP', 'NNPS', 'JJ', 'JJR', 'JJS',
22
+ 'VB', 'VBD', 'VBG', 'VBN', 'VBP', 'VBZ', 'MD',
23
+ 'RB', 'RBR', 'RBS', 'PRP', 'PRP$', 'WP', 'WP$', 'WDT', 'WRB',
24
+ 'DT', 'PDT', 'CD', 'IN', 'TO', 'CC', 'RP', 'EX', 'POS', 'UH', 'FW', 'SYM', 'LS',
25
+ ]);
26
+
27
+ /** True if `xpos` is a Penn tag the pipeline consumes directly (EWT/GUM). */
28
+ export function isPennTag(xpos: string | undefined): boolean {
29
+ return !!xpos && PENN_TAGS.has(xpos);
30
+ }
31
+
32
+ /** The Penn tag for a token: the raw XPOS when it is already Penn (EWT/GUM),
33
+ * otherwise derived from UPOS+FEATS (LinES/ParTUT, or a missing XPOS). */
34
+ export function pennTagOf(w: UDWord): string {
35
+ if (isPennTag(w.xpos)) return w.xpos;
36
+ return udToPenn(w);
37
+ }
38
+
39
+ const MODAL_LEMMAS = new Set([
40
+ 'can', 'could', 'will', 'would', 'shall', 'should', 'may', 'might', 'must',
41
+ 'ought', "'ll", "'d", 'wilt', 'shalt', 'canst', 'wouldst', 'shouldst', 'couldst',
42
+ ]);
43
+ // Pre-determiners ("ALL the books", "BOTH his hands", "such a day", "HALF the night").
44
+ const PREDET_LEMMAS = new Set(['all', 'both', 'half', 'such', 'quite', 'many']);
45
+
46
+ function feat(w: UDWord, k: string): string | undefined {
47
+ return w.featsMap ? w.featsMap[k] : undefined;
48
+ }
49
+
50
+ /**
51
+ * Convert one UDPipe token to a Penn Treebank tag from UPOS + FEATS (+ lemma /
52
+ * deprel for the cases morphology alone can't settle). Falls back to the raw XPOS
53
+ * only when UPOS is absent.
54
+ */
55
+ export function udToPenn(w: UDWord): string {
56
+ const upos = w.upos || '';
57
+ const lemma = (w.lemma || w.form || '').toLowerCase();
58
+ const deprel = (w.deprel || '').toLowerCase();
59
+ const num = feat(w, 'Number');
60
+ const degree = feat(w, 'Degree');
61
+ const pron = feat(w, 'PronType');
62
+ const poss = feat(w, 'Poss');
63
+ const vform = feat(w, 'VerbForm');
64
+ const tense = feat(w, 'Tense');
65
+ const person = feat(w, 'Person');
66
+
67
+ // Pre-determiner ("ALL the time", "BOTH his hands"): a stress-bearing quantifier
68
+ // (Penn PDT, content), regardless of whether the model calls it DET or PRON. UD
69
+ // marks it with the det:predet relation; the non-Penn models lose that, so back
70
+ // it up with the lemma list.
71
+ if (deprel === 'det:predet') return 'PDT';
72
+
73
+ switch (upos) {
74
+ case 'NOUN':
75
+ return num === 'Plur' ? 'NNS' : 'NN';
76
+ case 'PROPN':
77
+ return num === 'Plur' ? 'NNPS' : 'NNP';
78
+
79
+ case 'ADJ':
80
+ if (degree === 'Cmp') return 'JJR';
81
+ if (degree === 'Sup') return 'JJS';
82
+ // Ordinal numerals tag JJ in UD but CD-like in Penn; keep JJ (attributive).
83
+ return 'JJ';
84
+
85
+ case 'ADV':
86
+ if (pron === 'Int' || pron === 'Rel') return 'WRB'; // when/where/why/how
87
+ if (degree === 'Cmp') return 'RBR';
88
+ if (degree === 'Sup') return 'RBS';
89
+ return 'RB';
90
+
91
+ case 'VERB':
92
+ case 'AUX': {
93
+ if (upos === 'AUX' && (feat(w, 'VerbType') === 'Mod' || MODAL_LEMMAS.has(lemma))) return 'MD';
94
+ if (vform === 'Ger') return 'VBG';
95
+ if (vform === 'Part') return tense === 'Past' ? 'VBN' : 'VBG';
96
+ if (vform === 'Inf') return 'VB';
97
+ if (vform === 'Fin') {
98
+ if (tense === 'Past') return 'VBD';
99
+ if (person === '3' && num === 'Sing') return 'VBZ';
100
+ return 'VBP';
101
+ }
102
+ // No VerbForm feature: best-effort by tense/person.
103
+ if (tense === 'Past') return 'VBD';
104
+ if (person === '3' && num === 'Sing') return 'VBZ';
105
+ return upos === 'AUX' ? 'VBP' : 'VB';
106
+ }
107
+
108
+ case 'PRON':
109
+ if (poss === 'Yes') return (pron === 'Rel' || pron === 'Int') ? 'WP$' : 'PRP$';
110
+ if (pron === 'Rel' || pron === 'Int') return 'WP';
111
+ if (pron === 'Dem') return 'DT'; // "this/that" pronominal
112
+ return 'PRP';
113
+
114
+ case 'DET':
115
+ if (pron === 'Rel' || pron === 'Int') return 'WDT'; // which/that(rel)/what
116
+ if (poss === 'Yes') return 'PRP$'; // my/your/their (UD DET)
117
+ if (PREDET_LEMMAS.has(lemma) && deprel === 'det:predet') return 'PDT';
118
+ return 'DT'; // articles + demonstratives
119
+
120
+ case 'ADP':
121
+ // A particle of a phrasal verb ("came DOWN", "give UP") is stress-bearing RP;
122
+ // an ordinary preposition is the reducible IN. UD marks the particle by the
123
+ // compound:prt relation.
124
+ return deprel === 'compound:prt' ? 'RP' : 'IN';
125
+ case 'SCONJ':
126
+ return 'IN';
127
+ case 'CCONJ':
128
+ return 'CC';
129
+
130
+ case 'PART':
131
+ if (lemma === 'to') return 'TO';
132
+ if (lemma === "'s" || lemma === '’s' || deprel === 'case') return 'POS';
133
+ if (lemma === 'not' || lemma === "n't" || lemma === "n’t") return 'RB';
134
+ return 'RB';
135
+
136
+ case 'NUM':
137
+ return 'CD';
138
+ case 'INTJ':
139
+ return 'UH';
140
+ case 'SYM':
141
+ return 'SYM';
142
+ case 'X':
143
+ return 'FW';
144
+
145
+ case 'PUNCT':
146
+ // EWT/GUM give the punctuation char as XPOS already; otherwise use the form.
147
+ return (w.xpos && /[^A-Za-z0-9]/.test(w.xpos)) ? w.xpos : (w.form || w.xpos || ':');
148
+
149
+ default:
150
+ return w.xpos || w.upos || 'NN';
151
+ }
152
+ }