Spaces:
Running
Running
| // parser.ts — Syntactic dependency parser powered by UDPipe (English GUM model), | |
| // producing a ClsDocument with a full dependency graph and a phrase‑structure node | |
| // tree in the Universal Dependencies format used in McAleese's Calliope. | |
| // | |
| // HISTORY: this module previously ran a staged FinNLP pipeline | |
| // (en-norm → lexed → en-pos → en-parse) with hand-written tag/dep correction | |
| // layers (tagfix.ts / depfix.ts) to patch en-parse's systematic errors. It now | |
| // delegates tokenisation, POS tagging, and dependency parsing to UDPipe via the | |
| // `udpipe-node` package (a pure-WASM build — no native binary, no subprocess), | |
| // which is a far more accurate parser. UDPipe's output maps cleanly onto the | |
| // existing data model: | |
| // • XPOS column is Penn Treebank → ClsWord.lexicalClass (unchanged downstream) | |
| // • DEPREL column is Universal Dependencies → ClsDependency.dependentType | |
| // The correction layers are therefore no longer applied on this path. | |
| import { createUDPipe } from 'udpipe-node/wasm'; | |
| import type { UDSentence, UDWord } from 'udpipe-node'; | |
| import { correctUDPipePos } from './calliope/postag.js'; | |
| import { pennTagOf } from './calliope/udpos.js'; | |
| import { | |
| ClsDocument, | |
| ClsSentence, | |
| ClsWord, | |
| ClsDependency, | |
| ClsNode, | |
| } from './types.js'; | |
| // ── UDPipe instance (lazy singleton) ──────────────────────────────── | |
| // The "./wasm" entry point pre-initialises the WASM runtime via top-level await, | |
| // so by the time this module is imported the engine is ready and construction / | |
| // parsing are fully synchronous — `parseDocument` keeps its synchronous contract. | |
| let _nlp: ReturnType<typeof createUDPipe> | null = null; | |
| function nlp(): ReturnType<typeof createUDPipe> { | |
| // CALLIOPE_UDPIPE_MODEL lets us swap the UDPipe model (EWT / GUM / LinES / | |
| // ParTUT) for auditing — different treebanks tag XPOS quite differently, so the | |
| // model choice materially affects the parse the phonological pipeline consumes. | |
| // Unset → the bundled GUM model. | |
| const modelPath = process.env.CALLIOPE_UDPIPE_MODEL || undefined; | |
| return (_nlp ??= createUDPipe({ defaultInputMode: 'presegmented', modelPath })); | |
| } | |
| // ── POS / punctuation classification (unchanged) ───────────────────── | |
| const CONTENT_POS = new Set([ | |
| 'NN', 'NNS', 'NNP', 'NNPS', | |
| 'JJ', 'JJR', 'JJS', | |
| 'VB', 'VBD', 'VBG', 'VBN', 'VBP', 'VBZ', | |
| 'RB', 'RBR', 'RBS', | |
| 'CD', // cardinal numbers (content‑like) | |
| ]); | |
| /** Punctuation POS tags that should not be syllabified. */ | |
| const PUNCT_TAGS = new Set([ | |
| ',', '.', ':', ';', '!', '?', | |
| '-LRB-', '-RRB-', '``', "''", | |
| '--', '...', '"', "'", | |
| '(', ')', '[', ']', '{', '}', | |
| ]); | |
| export function isPunctuation(tag: string): boolean { | |
| return PUNCT_TAGS.has(tag); | |
| } | |
| /** | |
| * Quotation-mark tags. Quotes are tokens (never syllabified) but NOT prosodic | |
| * breaks: a quoted word inside a clause is read in one breath — no intonational | |
| * boundary, no caesura. | |
| */ | |
| const QUOTE_TAGS = new Set(['``', "''", '"', "'"]); | |
| export function isQuoteTag(tag: string): boolean { | |
| return QUOTE_TAGS.has(tag); | |
| } | |
| function isContentWord(tag: string): boolean { | |
| return CONTENT_POS.has(tag); | |
| } | |
| /** | |
| * Lowercase the first alphabetic character of every line. Kept available but | |
| * NOT called by default — empirically net-negative with UDPipe (see parseDocument). | |
| */ | |
| export function lowerLineInitials(text: string): string { | |
| return text | |
| .split('\n') | |
| .map((line) => line.replace(/[A-Za-z]/, (c) => c.toLowerCase())) | |
| .join('\n'); | |
| } | |
| // Archaic / Early-Modern English forms the UD model (trained on modern text) | |
| // systematically mis-tags. This is a closed lexicon of forms that are virtually | |
| // never modern words, so an unconditional retag is safe domain adaptation for a | |
| // verse tool (it replaces the role the old en-pos correction layer played for | |
| // these tokens). Surface forms are matched lowercased, sans apostrophes. | |
| const ARCHAIC_POS: Record<string, string> = { | |
| thy: 'PRP$', thine: 'PRP$', | |
| thee: 'PRP', thou: 'PRP', ye: 'PRP', | |
| hath: 'VBZ', doth: 'VBZ', saith: 'VBZ', | |
| hast: 'VBP', dost: 'VBP', wilt: 'MD', | |
| shalt: 'MD', canst: 'MD', wouldst: 'MD', shouldst: 'MD', couldst: 'MD', | |
| hadst: 'VBD', didst: 'VBD', wast: 'VBD', wert: 'VBD', | |
| }; | |
| // ── Dash handling (unchanged) ──────────────────────────────────────── | |
| /** | |
| * Standalone en/em/figure/bar/minus dashes (or a run of 2+ hyphens) are prosodic | |
| * breaks (a dash caesura), not stress-bearing tokens. Re-tagged to the Penn dash | |
| * class ':' so they drop out of syllabification & scansion and mark a pause. | |
| */ | |
| const DASH_GLYPH_RE = /^(?:[‒–—―−]+|-{2,})$/; | |
| function isDashGlyph(word: string): boolean { | |
| return DASH_GLYPH_RE.test(word); | |
| } | |
| const DASH_CLASS = '‒–—―−'; | |
| const DASH_GLYPHS_RE = new RegExp(`[${DASH_CLASS}]`, 'g'); | |
| const DASH_PAREN_RE = new RegExp(`([${DASH_CLASS}])([^${DASH_CLASS}]*?[.!?][^${DASH_CLASS}]*?)([${DASH_CLASS}])`, 'g'); | |
| /** | |
| * Normalize dash *usages* to colon-class clause-breaks BEFORE parsing. A dash is | |
| * an ι (intonational-unit) boundary — a stronger pause than a comma. We fold | |
| * every dash usage into a canonical glyph, neutralise sentence-final punctuation | |
| * inside a dash-delimited parenthetical (so the line stays one sentence), then | |
| * rewrite the dashes to a colon-class break (which prosodic.ts reads as an ι | |
| * boundary). Unspaced hyphen compounds ("torch-flames") are left intact. | |
| */ | |
| function normalizeDashesToClauseBreaks(text: string): string { | |
| text = text.replace(/(^|\s)-+(?=\s|$)/g, '$1–'); | |
| text = text.replace(/-{2,}/g, '–'); | |
| text = text.replace(DASH_PAREN_RE, (_m, a, inner, b) => a + inner.replace(/[.!?]+/g, ',') + b); | |
| text = text.replace(DASH_GLYPHS_RE, ' : '); | |
| text = text.replace(/(?:\s*:\s*){2,}/g, ' : ') | |
| .replace(/\s+:/g, ' :') | |
| .replace(/:(\S)/g, ': $1') | |
| .replace(/^\s*:\s*/, '') | |
| .replace(/\s{2,}/g, ' ') | |
| .trim(); | |
| return text; | |
| } | |
| // ── Clitic / contraction re‑merge (UDPipe-specific) ────────────────── | |
| // UDPipe tokenises contractions and elisions on the apostrophe boundary, e.g. | |
| // it's → it + 's don't → do + n't we'll → we + 'll | |
| // th'expense → th' + expense 'Tis → ' + Tis fix'd → fix + 'd | |
| // For scansion a contraction must be ONE orthographic word (one syllable count, | |
| // one stress domain). We re-merge using UDPipe's SpaceAfter flag (which marks | |
| // tokens that were contiguous in the source) plus the apostrophe shape: | |
| // • a LEFT clitic (apostrophe-initial, or n't) merges into the previous word, | |
| // EXCEPT the possessive 's (XPOS=POS), which stays split (as it always has); | |
| // • a RIGHT proclitic (a short apostrophe-final piece like "th'", or a bare | |
| // leading apostrophe before an aphaeresis like 'tis/'twas) merges into the | |
| // next word. | |
| const APOS = /['’]/; | |
| const LEFT_CLITIC_RE = /^['’]([a-z]+)?$|^n['’]?t$/i; // 's 've 'll 'd 're 'm n't | |
| const RIGHT_PROCLITIC_RE = /^[a-z]{1,3}['’]$/i; // th' o' d' ne' | |
| const APHAERESIS = new Set(['tis', 'twas', 'twere', 'twill', 'twould', 'gainst', 'neath', 'tween', 'twixt', 'til', 'cause', 'em', 'round', 'bout']); | |
| interface Cluster { | |
| tokens: UDWord[]; | |
| repr: UDWord; // the token that carries the syntactic role / POS | |
| } | |
| /** Group UDPipe words into orthographic clusters, re-merging clitics. */ | |
| function clusterWords(uds: UDWord[]): { clusters: Cluster[]; idToCluster: Map<number, number> } { | |
| const idToCluster = new Map<number, number>(); | |
| const clusters: Cluster[] = []; | |
| for (let i = 0; i < uds.length; i++) { | |
| const w = uds[i]; | |
| const prev = uds[i - 1]; | |
| const contiguous = prev ? prev.spaceAfter === false : false; | |
| const isLeftClitic = | |
| contiguous && w.xpos !== 'POS' && LEFT_CLITIC_RE.test(w.form); | |
| if (isLeftClitic && clusters.length > 0) { | |
| clusters[clusters.length - 1].tokens.push(w); | |
| idToCluster.set(w.id, clusters.length - 1); | |
| continue; | |
| } | |
| clusters.push({ tokens: [w], repr: w }); | |
| idToCluster.set(w.id, clusters.length - 1); | |
| } | |
| // Right-merge pass: a cluster that is a lone proclitic (th') or a bare leading | |
| // apostrophe before an aphaeresis ('tis) folds into the following cluster. | |
| const merged: Cluster[] = []; | |
| for (let c = 0; c < clusters.length; c++) { | |
| const cl = clusters[c]; | |
| const next = clusters[c + 1]; | |
| const onlyTok = cl.tokens.length === 1 ? cl.tokens[0] : null; | |
| const contiguous = onlyTok ? onlyTok.spaceAfter === false : false; | |
| const nextWord = next?.repr; | |
| const isProclitic = | |
| !!onlyTok && contiguous && !!nextWord && | |
| (RIGHT_PROCLITIC_RE.test(onlyTok.form) || | |
| (/^['’]$/.test(onlyTok.form) && APHAERESIS.has(nextWord.form.toLowerCase()))); | |
| if (isProclitic && next) { | |
| next.tokens.unshift(onlyTok!); // prepend proclitic | |
| for (const t of cl.tokens) idToCluster.set(t.id, merged.length); // re-point to next cluster's eventual index | |
| // The next cluster will be pushed next iteration; fix its index mapping then. | |
| // Mark by leaving cl out (skip pushing it). | |
| // Re-point all of next's tokens to current merged length too: | |
| continue; | |
| } | |
| merged.push(cl); | |
| } | |
| // Rebuild idToCluster cleanly against the merged list (indices shifted by right-merges). | |
| idToCluster.clear(); | |
| for (let c = 0; c < merged.length; c++) { | |
| for (const t of merged[c].tokens) idToCluster.set(t.id, c); | |
| // representative = first token that is neither a left-clitic nor a proclitic | |
| merged[c].repr = | |
| merged[c].tokens.find( | |
| (t) => !(t.xpos !== 'POS' && LEFT_CLITIC_RE.test(t.form)) && !RIGHT_PROCLITIC_RE.test(t.form) && !/^['’]$/.test(t.form), | |
| ) ?? merged[c].tokens[0]; | |
| } | |
| return { clusters: merged, idToCluster }; | |
| } | |
| // Dependency labels are passed through to `ClsDependency.dependentType` as RAW | |
| // Universal Dependencies relations (obl, nsubj:pass, compound, nmod:poss, …) — | |
| // they are deliberately NOT folded into the old Stanford names. The canonical | |
| // normaliser `calliope/deps.ts` maps every UD relation onto the engine's Scenario | |
| // label space (canonicalRel), so new UD tags are accommodated there, not hidden | |
| // here. | |
| // ── Public API ─────────────────────────────────────────────────────── | |
| export function parseDocument(text: string): ClsDocument { | |
| // Normalise curly/typographic apostrophes to straight ' so contractions and | |
| // elisions tokenise identically regardless of glyph. | |
| text = text.replace(/[‘’ʼ′]/g, "'"); | |
| // Collapse runs of sentence-final punctuation (ellipsis, "!!") to a single mark. | |
| text = text.replace(/([.!?])\1+/g, '$1'); | |
| // Dashes → colon-class clause-breaks (see helper above). | |
| text = normalizeDashesToClauseBreaks(text); | |
| // NOTE on line-initial caps: lowering the first letter of each line before | |
| // tagging (the role the old `normalizeCaps` played) was tested and is NET | |
| // NEGATIVE with UDPipe — it recovers cases like "Nap"/"Gap" (UH→NN) but a | |
| // line-initial capital often HELPS UDPipe's parse (e.g. "Through Eden took…" | |
| // parses "Eden" as nsubj when capitalised, obl when lowercased), so it changes | |
| // more scansions than it fixes. Left disabled; see lowerLineInitials() below. | |
| const udSentences: UDSentence[] = nlp().parse(text, { inputMode: 'presegmented' }); | |
| const sentences: ClsSentence[] = []; | |
| let absoluteOffset = 0; | |
| udSentences.forEach((ud, si) => { | |
| const { clusters, idToCluster } = clusterWords(ud.words); | |
| // ---- 1. Build ClsWord array ---- | |
| const words: ClsWord[] = clusters.map((cl, i) => { | |
| // Preserve the ORIGINAL case of the surface form (UDPipe keeps it); only | |
| // lowercase a private lookup key for the archaic-lexicon / dash checks. | |
| // Lowercasing `word` itself lost every proper-noun capital ("pakistan", | |
| // "marcel proust") in the display and projection; downstream stress/name | |
| // lookups all lowercase internally, so case in `word` is display-only. | |
| const surfaceRaw = cl.tokens.map((t) => t.form).join(''); | |
| const surface = surfaceRaw.toLowerCase(); | |
| // Penn tag: use the raw XPOS when it already is Penn (EWT/GUM), else derive | |
| // it from UPOS+FEATS (LinES/ParTUT emit non-Penn XPOS the pipeline can't read). | |
| const rawTag = pennTagOf(cl.repr); | |
| const archaic = ARCHAIC_POS[surface.replace(/['’]/g, '')]; | |
| const tag = isDashGlyph(surfaceRaw) ? ':' : (archaic ?? rawTag); | |
| return { | |
| index: i + 1, | |
| lexicalClass: tag, | |
| lexicalDetails: cl.repr.feats, | |
| lexicalPlural: tag === 'NNS' || tag === 'NNPS', | |
| position: '', | |
| word: surfaceRaw, | |
| absoluteIndex: absoluteOffset + i, | |
| isContent: isContentWord(tag), | |
| syllables: [], | |
| phraseStress: 0, | |
| dependency: undefined, | |
| node: undefined, | |
| }; | |
| }); | |
| // Sentence-initial de-capitalisation (mirrors en-norm.normalizeCaps in the | |
| // pre-UD path): lower the first letter of the sentence's first word UNLESS it | |
| // is a proper noun, so "The"→"the" and "I"→"i" (an orthographic capital forced | |
| // by line position carries no lexical signal) while mid-line proper nouns | |
| // ("Marcel Proust", "Pakistan") keep their caps for display/projection. | |
| for (const w of words) { | |
| if (isPunctuation(w.lexicalClass)) continue; | |
| if (!/^(NNP|NNPS)$/.test(w.lexicalClass) && /^[A-Z]/.test(w.word)) { | |
| w.displayWord = w.word; // keep the original surface for reports/phonopoetics | |
| w.word = w.word[0].toLowerCase() + w.word.slice(1); | |
| } | |
| break; | |
| } | |
| // ---- 2. Build ClsDependency array ---- | |
| const dependencies: ClsDependency[] = []; | |
| clusters.forEach((cl, depIdx) => { | |
| const r = cl.repr; | |
| let govIdx: number | undefined; | |
| if (r.head === 0) { | |
| govIdx = undefined; // attaches to root | |
| } else { | |
| const g = idToCluster.get(r.head); | |
| // If the representative's head fell inside its own cluster (e.g. a copula | |
| // clitic), follow that clitic's head out of the cluster. | |
| if (g === depIdx) { | |
| const external = cl.tokens | |
| .map((t) => idToCluster.get(t.head)) | |
| .find((gi) => gi !== undefined && gi !== depIdx); | |
| govIdx = external; | |
| } else { | |
| govIdx = g; | |
| } | |
| } | |
| const depWord = words[depIdx]; | |
| const govWord = govIdx !== undefined ? words[govIdx] : null; | |
| dependencies.push({ | |
| index: depIdx + 1, | |
| governorIndex: govIdx !== undefined ? govIdx + 1 : 0, | |
| dependentIndex: depIdx + 1, | |
| dependentType: govIdx === undefined ? 'root' : r.deprel, | |
| governorName: govWord ? govWord.word : 'ROOT', | |
| dependentName: depWord.word, | |
| governor: govWord as unknown as ClsWord, | |
| dependent: depWord, | |
| }); | |
| }); | |
| // Ensure a ROOT dependency exists. | |
| if (!dependencies.some((d) => d.governorIndex === 0) && words.length > 0) { | |
| dependencies.push({ | |
| index: 0, | |
| governorIndex: 0, | |
| dependentIndex: 1, | |
| dependentType: 'root', | |
| governorName: 'ROOT', | |
| dependentName: words[0].word, | |
| governor: null as unknown as ClsWord, | |
| dependent: words[0], | |
| }); | |
| } | |
| // Back‑reference: each word stores the dependency edge where it is dependent. | |
| words.forEach((w) => { | |
| w.dependency = dependencies.find((d) => d.dependent === w); | |
| }); | |
| // UDPipe XPOS correction (the role en-pos + tagfix.ts played pre-UD): fix the | |
| // systematic mis-tags UDPipe makes on terse, decontextualised verse via | |
| // en-lexicon cross-check. Runs HERE (in the parser, after deps are attached) | |
| // so direct `parseDocument` consumers — and every engine — see corrected tags; | |
| // rule (3) needs the dependency back-references just set above. | |
| correctUDPipePos({ index: si + 1, nodes: null, dependencies, words, xml: '' }); | |
| // ---- 3. Build phrase‑structure node tree from the dependency graph ---- | |
| const rootNode = buildDepNodeTree(words, dependencies); | |
| const wordNodeMap = new Map<number, ClsNode>(); | |
| collectWordNodes(rootNode, wordNodeMap); | |
| words.forEach((w) => { | |
| w.node = wordNodeMap.get(w.index); | |
| }); | |
| sentences.push({ | |
| index: si + 1, | |
| nodes: rootNode, | |
| dependencies, | |
| words, | |
| xml: '', | |
| }); | |
| absoluteOffset += words.length; | |
| }); | |
| return { sentences, xml: '' }; | |
| } | |
| // ── Dependency → constituency projection ───────────────────────────── | |
| // phonological.ts groups clitic groups into phonological phrases by finding the | |
| // smallest phrase node containing them, so it needs a properly nested, position- | |
| // ordered constituency tree. We synthesise one by projection: each head plus its | |
| // dependent subtrees forms a phrase, labelled by the head's POS family. | |
| function phraseType(tag: string): string { | |
| if (/^(NN|NNS|NNP|NNPS|PRP|PRP\$|DT|CD|WP|WDT|EX)$/.test(tag)) return 'NP'; | |
| if (/^(VB|VBD|VBG|VBN|VBP|VBZ|MD)$/.test(tag)) return 'VP'; | |
| if (/^(IN|TO)$/.test(tag)) return 'PP'; | |
| if (/^(JJ|JJR|JJS)$/.test(tag)) return 'ADJP'; | |
| if (/^(RB|RBR|RBS|WRB)$/.test(tag)) return 'ADVP'; | |
| return 'XP'; | |
| } | |
| function buildDepNodeTree(words: ClsWord[], deps: ClsDependency[]): ClsNode { | |
| const sq: ClsNode = { index: '1', nodeName: 'SQ', parent: null, contains: [] }; | |
| if (words.length === 0) return sq; | |
| // children[g] = list of dependent word-indices (1-based) governed by g (1-based); | |
| // roots are governed by 0. | |
| const children = new Map<number, number[]>(); | |
| for (const d of deps) { | |
| if (d.dependentIndex < 1 || d.dependentIndex > words.length) continue; | |
| const g = d.governorIndex; | |
| if (!children.has(g)) children.set(g, []); | |
| children.get(g)!.push(d.dependentIndex); | |
| } | |
| const build = (wordIdx: number, parent: ClsNode): ClsNode => { | |
| const word = words[wordIdx - 1]; | |
| const kids = (children.get(wordIdx) ?? []).filter((k) => k !== wordIdx); | |
| if (kids.length === 0) { | |
| const leaf = createWordLeaf(word); | |
| leaf.parent = parent; | |
| return leaf; | |
| } | |
| const node: ClsNode = { | |
| index: `ph_${wordIdx}`, | |
| nodeName: phraseType(word.lexicalClass), | |
| parent, | |
| contains: [], | |
| }; | |
| // Order head + dependents by surface position for a projective tree. | |
| const ordered = [...kids, wordIdx].sort((a, b) => a - b); | |
| for (const idx of ordered) { | |
| if (idx === wordIdx) { | |
| const leaf = createWordLeaf(word); | |
| leaf.parent = node; | |
| node.contains.push(leaf); | |
| } else { | |
| node.contains.push(build(idx, node)); | |
| } | |
| } | |
| return node; | |
| }; | |
| const roots = (children.get(0) ?? []).sort((a, b) => a - b); | |
| if (roots.length === 0) { | |
| // No explicit root: attach all words as leaves under SQ. | |
| for (const w of words) { | |
| const leaf = createWordLeaf(w); | |
| leaf.parent = sq; | |
| sq.contains.push(leaf); | |
| } | |
| return sq; | |
| } | |
| for (const r of roots) { | |
| const child = build(r, sq); | |
| sq.contains.push(child); | |
| } | |
| // Attach any orphan words (rare) directly under SQ. | |
| const attached = new Set<number>(); | |
| collectAttachedWordIndices(sq, attached); | |
| for (const w of words) { | |
| if (!attached.has(w.index)) { | |
| const leaf = createWordLeaf(w); | |
| leaf.parent = sq; | |
| sq.contains.push(leaf); | |
| } | |
| } | |
| return sq; | |
| } | |
| // ── Leaf / traversal helpers (unchanged) ───────────────────────────── | |
| function createWordLeaf(word: ClsWord): ClsNode { | |
| return { | |
| index: `w${word.index}`, | |
| nodeName: word.index.toString(), | |
| parent: null, | |
| contains: [word], | |
| }; | |
| } | |
| function collectWordNodes(node: ClsNode, map: Map<number, ClsNode>): void { | |
| for (const child of node.contains) { | |
| if (child instanceof Object && 'word' in (child as any)) { | |
| map.set((child as ClsWord).index, node); | |
| } else if (child instanceof Object && 'index' in (child as any)) { | |
| collectWordNodes(child as ClsNode, map); | |
| } | |
| } | |
| } | |
| function collectAttachedWordIndices(node: ClsNode, set: Set<number>): void { | |
| for (const child of node.contains) { | |
| if (child instanceof Object && 'word' in (child as any)) { | |
| set.add((child as ClsWord).index); | |
| } else if (child instanceof Object && 'index' in (child as any)) { | |
| collectAttachedWordIndices(child as ClsNode, set); | |
| } | |
| } | |
| } | |