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.
- package/README.md +3 -13
- package/dist/caesura.d.ts +33 -0
- package/dist/caesura.d.ts.map +1 -0
- package/dist/caesura.js +202 -0
- package/dist/calliope/boundaries.d.ts +19 -0
- package/dist/calliope/boundaries.d.ts.map +1 -0
- package/dist/calliope/boundaries.js +182 -0
- package/dist/calliope/bracketing.d.ts +11 -0
- package/dist/calliope/bracketing.d.ts.map +1 -0
- package/dist/calliope/bracketing.js +416 -0
- package/dist/calliope/deps.d.ts +4 -0
- package/dist/calliope/deps.d.ts.map +1 -0
- package/dist/calliope/deps.js +181 -0
- package/dist/calliope/engine.d.ts +3 -0
- package/dist/calliope/engine.d.ts.map +1 -0
- package/dist/calliope/engine.js +71 -0
- package/dist/calliope/feats.d.ts +9 -0
- package/dist/calliope/feats.d.ts.map +1 -0
- package/dist/calliope/feats.js +45 -0
- package/dist/calliope/names.d.ts +7 -0
- package/dist/calliope/names.d.ts.map +1 -0
- package/dist/calliope/names.js +42 -0
- package/dist/calliope/postag.d.ts +8 -0
- package/dist/calliope/postag.d.ts.map +1 -0
- package/dist/calliope/postag.js +250 -0
- package/dist/calliope/prosodic.d.ts +3 -0
- package/dist/calliope/prosodic.d.ts.map +1 -0
- package/dist/calliope/prosodic.js +275 -0
- package/dist/calliope/relstress.d.ts +4 -0
- package/dist/calliope/relstress.d.ts.map +1 -0
- package/dist/calliope/relstress.js +688 -0
- package/dist/calliope/stressrules.d.ts +28 -0
- package/dist/calliope/stressrules.d.ts.map +1 -0
- package/dist/calliope/stressrules.js +147 -0
- package/dist/calliope/syntax.d.ts +38 -0
- package/dist/calliope/syntax.d.ts.map +1 -0
- package/dist/calliope/syntax.js +234 -0
- package/dist/calliope/udpos.d.ts +13 -0
- package/dist/calliope/udpos.d.ts.map +1 -0
- package/dist/calliope/udpos.js +156 -0
- package/dist/clio/caesura.d.ts +27 -0
- package/dist/clio/caesura.d.ts.map +1 -0
- package/dist/clio/caesura.js +148 -0
- package/dist/clio/depfix.d.ts +13 -0
- package/dist/clio/depfix.d.ts.map +1 -0
- package/dist/clio/depfix.js +84 -0
- package/dist/clio/display.d.ts +32 -0
- package/dist/clio/display.d.ts.map +1 -0
- package/dist/clio/display.js +976 -0
- package/dist/clio/engine.d.ts +3 -0
- package/dist/clio/engine.d.ts.map +1 -0
- package/dist/clio/engine.js +28 -0
- package/dist/clio/parser.d.ts +10 -0
- package/dist/clio/parser.d.ts.map +1 -0
- package/dist/clio/parser.js +696 -0
- package/dist/clio/phonological.d.ts +41 -0
- package/dist/clio/phonological.d.ts.map +1 -0
- package/dist/clio/phonological.js +788 -0
- package/dist/clio/phrasestress.d.ts +6 -0
- package/dist/clio/phrasestress.d.ts.map +1 -0
- package/dist/clio/phrasestress.js +106 -0
- package/dist/clio/pipeline.d.ts +11 -0
- package/dist/clio/pipeline.d.ts.map +1 -0
- package/dist/clio/pipeline.js +147 -0
- package/dist/clio/rhyme.d.ts +65 -0
- package/dist/clio/rhyme.d.ts.map +1 -0
- package/dist/clio/rhyme.js +761 -0
- package/dist/clio/scandroid.d.ts +17 -0
- package/dist/clio/scandroid.d.ts.map +1 -0
- package/dist/clio/scandroid.js +435 -0
- package/dist/clio/scansion.d.ts +46 -0
- package/dist/clio/scansion.d.ts.map +1 -0
- package/dist/clio/scansion.js +1086 -0
- package/dist/clio/semantics.d.ts +44 -0
- package/dist/clio/semantics.d.ts.map +1 -0
- package/dist/clio/semantics.js +139 -0
- package/dist/clio/stress.d.ts +83 -0
- package/dist/clio/stress.d.ts.map +1 -0
- package/dist/clio/stress.js +1737 -0
- package/dist/clio/tagfix.d.ts +6 -0
- package/dist/clio/tagfix.d.ts.map +1 -0
- package/dist/clio/tagfix.js +101 -0
- package/dist/display.d.ts +0 -6
- package/dist/display.d.ts.map +1 -1
- package/dist/display.js +322 -141
- package/dist/engine.d.ts +9 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +12 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +169 -26
- package/dist/parser.d.ts +3 -3
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +320 -563
- package/dist/phonological.d.ts +1 -1
- package/dist/phonological.d.ts.map +1 -1
- package/dist/phonological.js +73 -4
- package/dist/phrasestress.d.ts +6 -0
- package/dist/phrasestress.d.ts.map +1 -0
- package/dist/phrasestress.js +106 -0
- package/dist/rhyme.d.ts +40 -1
- package/dist/rhyme.d.ts.map +1 -1
- package/dist/rhyme.js +435 -7
- package/dist/scansion.d.ts +9 -0
- package/dist/scansion.d.ts.map +1 -1
- package/dist/scansion.js +145 -18
- package/dist/semantics.d.ts +44 -0
- package/dist/semantics.d.ts.map +1 -0
- package/dist/semantics.js +139 -0
- package/dist/stress.d.ts +127 -4
- package/dist/stress.d.ts.map +1 -1
- package/dist/stress.js +834 -61
- package/dist/types.d.ts +15 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +5 -3
- package/src/caesura.ts +201 -0
- package/src/calliope/boundaries.ts +190 -0
- package/src/calliope/bracketing.ts +390 -0
- package/src/calliope/deps.ts +160 -0
- package/src/calliope/engine.ts +77 -0
- package/src/calliope/feats.ts +46 -0
- package/src/calliope/names.ts +44 -0
- package/src/calliope/postag.ts +253 -0
- package/src/calliope/prosodic.ts +262 -0
- package/src/calliope/relstress.ts +645 -0
- package/src/calliope/stressrules.ts +147 -0
- package/src/calliope/syntax.ts +218 -0
- package/src/calliope/udpos.ts +152 -0
- package/src/clio/caesura.ts +145 -0
- package/src/clio/depfix.ts +88 -0
- package/src/clio/display.ts +1042 -0
- package/src/clio/engine.ts +38 -0
- package/src/clio/parser.ts +845 -0
- package/src/clio/phonological.ts +849 -0
- package/src/clio/phrasestress.ts +108 -0
- package/src/clio/pipeline.ts +154 -0
- package/src/clio/rhyme.ts +740 -0
- package/src/clio/scandroid.ts +434 -0
- package/src/clio/scansion.ts +1130 -0
- package/src/clio/semantics.ts +134 -0
- package/src/clio/stress.ts +1731 -0
- package/src/clio/tagfix.ts +104 -0
- package/src/display.ts +321 -137
- package/src/engine.ts +22 -0
- package/src/index.ts +176 -32
- package/src/parser.ts +346 -682
- package/src/phonological.ts +72 -4
- package/src/phrasestress.ts +108 -0
- package/src/rhyme.ts +428 -8
- package/src/scansion.ts +136 -15
- package/src/semantics.ts +134 -0
- package/src/stress.ts +794 -64
- package/src/types.ts +39 -4
- package/tests/DataForHayesLinesOnly.txt +364 -0
- package/tests/DataForHayesStressSymbolsRevised.txt +728 -0
- package/tests/basic.test.ts +443 -37
- package/tests/bench-hayes.mjs +72 -0
- package/tests/wagner-stress.test.ts +188 -0
- 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
|
+
}
|