// parser.ts — Syntactic dependency parser powered by FinNLP, producing // ClsDocument with full dependency graph and phrase‑structure node tree // that matches the Universal Dependencies format as used in McAleese’s Calliope. import * as Lexed from 'lexed'; import * as EnPos from 'en-pos'; import * as EnParse from 'en-parse'; import * as EnNorm from 'en-norm'; import { correctTags } from './tagfix.js'; import { applyDepFixes } from './depfix.js'; import { ClsDocument, ClsSentence, ClsWord, ClsDependency, ClsNode } from '../types.js'; // ─── Local type declarations for the FinNLP API ──────────────────── // These mirror the actual interfaces exported by finnlp / en-parse. // (The library ships with .d.ts files, but declaring them here ensures // type‑safety even if the consumer’s tsconfig resolution differs.) interface FinDepNode { label: string; // dependency label, e.g. "NSUBJ", "ROOT" type: string; // phrase type, e.g. "NP", "VP" parent: number; // 0‑based index of governor token; -1 for root } interface FinNodeInterface { left: FinNodeInterface[]; right: FinNodeInterface[]; tokens: string[]; tags: string[]; index: number[]; // [from, to] inclusive token indices (0‑based) type: string; label: string; } interface FinSentenceResult { sentence: string; tokens: string[]; lemmas: string[]; tags: string[]; deps: FinDepNode[]; depsTree: FinNodeInterface; confidence: number; } interface FinRunInstance { raw: string; intercepted: string; sentences: FinSentenceResult[]; } // ─── Contraction re‑merging ─────────────────────────────────────── // FinNLP's en‑norm module resolves contractions (e.g. "we'll" → "we" + "will"), // producing 2 tokens from 1. For scansion, the contracted form must be // 1 phonetic unit. This step re‑merges dehiscised contraction pairs // after parsing, using the raw text to distinguish genuine contractions // from accidental "host + clitic" word sequences. interface ContractionEntry { host: string; clitic: string; } const CONTRACTION_MAP: Record = { "we'll": { host: 'we', clitic: 'will' }, "we've": { host: 'we', clitic: 'have' }, "we're": { host: 'we', clitic: 'are' }, "we'd": { host: 'we', clitic: 'would' }, "i'll": { host: 'i', clitic: 'will' }, "i've": { host: 'i', clitic: 'have' }, "i'm": { host: 'i', clitic: 'am' }, "i'd": { host: 'i', clitic: 'would' }, "you'll": { host: 'you', clitic: 'will' }, "you've": { host: 'you', clitic: 'have' }, "you're": { host: 'you', clitic: 'are' }, "you'd": { host: 'you', clitic: 'would' }, "he'll": { host: 'he', clitic: 'will' }, "he's": { host: 'he', clitic: 'is' }, "he'd": { host: 'he', clitic: 'would' }, "she'll": { host: 'she', clitic: 'will' }, "she's": { host: 'she', clitic: 'is' }, "she'd": { host: 'she', clitic: 'would' }, "it'll": { host: 'it', clitic: 'will' }, "it'd": { host: 'it', clitic: 'would' }, "they'll": { host: 'they', clitic: 'will' }, "they've": { host: 'they', clitic: 'have' }, "they're": { host: 'they', clitic: 'are' }, "they'd": { host: 'they', clitic: 'would' }, "that's": { host: 'that', clitic: 'is' }, "that'll": { host: 'that', clitic: 'will' }, "this'll": { host: 'this', clitic: 'will' }, "it's": { host: 'it', clitic: 'is' }, "who'll": { host: 'who', clitic: 'will' }, "who's": { host: 'who', clitic: 'is' }, "who'd": { host: 'who', clitic: 'would' }, "who've": { host: 'who', clitic: 'have' }, "what's": { host: 'what', clitic: 'is' }, "there's": { host: 'there', clitic: 'is' }, "here's": { host: 'here', clitic: 'is' }, "where's": { host: 'where', clitic: 'is' }, "when's": { host: 'when', clitic: 'is' }, "how's": { host: 'how', clitic: 'is' }, "why's": { host: 'why', clitic: 'is' }, "one's": { host: 'one', clitic: 'is' }, "let's": { host: 'let', clitic: 'us' }, "y'all": { host: 'you', clitic: 'all' }, "don't": { host: 'do', clitic: 'not' }, "can't": { host: 'can', clitic: 'not' }, "won't": { host: 'will', clitic: 'not' }, "shouldn't": { host: 'should', clitic: 'not' }, "couldn't": { host: 'could', clitic: 'not' }, "wouldn't": { host: 'would', clitic: 'not' }, "isn't": { host: 'is', clitic: 'not' }, "aren't": { host: 'are', clitic: 'not' }, "wasn't": { host: 'was', clitic: 'not' }, "weren't": { host: 'were', clitic: 'not' }, "haven't": { host: 'have', clitic: 'not' }, "hasn't": { host: 'has', clitic: 'not' }, "hadn't": { host: 'had', clitic: 'not' }, "didn't": { host: 'did', clitic: 'not' }, "doesn't": { host: 'does', clitic: 'not' }, "ain't": { host: 'am', clitic: 'not' }, "might've": { host: 'might', clitic: 'have' }, "would've": { host: 'would', clitic: 'have' }, "should've": { host: 'should', clitic: 'have' }, "could've": { host: 'could', clitic: 'have' }, "must've": { host: 'must', clitic: 'have' }, }; interface RawSegment { text: string; isContraction: boolean; isArchaicD: boolean; // poetic preterite "-'d" (fix'd, lov'd, charm'd) — NOT a real // contraction, but en-norm dehiscises it as host + "would", // misaligning the whole rest of the line. Re-merged // conditionally (only when the would/had token is present). } /** Archaic poetic "-'d" preterite: any -'d form that is not a genuine pronoun/wh * contraction (those live in CONTRACTION_MAP and are checked first). Hyphenated * compounds count too (hen-peck'd, half-hid'd) — en-norm keeps the compound as one * token but dehiscises the -'d into "would", which the merge re-attaches. */ const ARCHAIC_D_RE = /^[a-z]+(?:-[a-z]+)*'d$/; /** * Tokenise the raw (un‑normalised) text into word‑like segments, * marking which are contracted forms. */ function tokenizeRawText(text: string): RawSegment[] { // A hyphenated word that carries an apostrophe-suffix (hen-peck'd, ne'er-do-well's) // is ONE segment — en-norm keeps the hyphen compound as a single token, so splitting // it here (→ "hen" + "peck'd") desynced the segment↔token walk and broke the -'d // re-merge. Plain hyphen compounds WITHOUT an apostrophe (torch-flames) still split, // exactly as before (the first alternative requires a trailing 'x), so mergeHyphenated- // Words keeps handling those. const re = /\b[a-zA-Z]+(?:-[a-zA-Z]+)*'[a-zA-Z]+\b|\b[a-zA-Z]+(?:'[a-zA-Z]+)?\b/g; const segments: RawSegment[] = []; let match; while ((match = re.exec(text)) !== null) { const lower = match[0].toLowerCase(); const isContraction = lower in CONTRACTION_MAP; segments.push({ text: lower, isContraction, isArchaicD: !isContraction && ARCHAIC_D_RE.test(lower), }); } return segments; } /** * Re‑merge contraction pairs in a sentence's ClsWord array. * * Segments from the raw text are walked in parallel with the sentence's * tokens. Non‑contraction segments consume 1 token; contraction segments * consume 2 tokens (host + clitic), which are merged into a single ClsWord * that preserves the host's properties and the original contracted form. * * Returns the updated word array. */ function mergeContractionsInSentence( words: ClsWord[], segments: RawSegment[], startSegmentIdx: number ): { words: ClsWord[]; consumedSegments: number } { const merged: ClsWord[] = []; let tokenIdx = 0; let segIdx = startSegmentIdx; while (tokenIdx < words.length && segIdx < segments.length) { // Punctuation tokens have NO raw-text segment (tokenizeRawText matches only // letter sequences), so they must not consume a segment. Otherwise a // sentence ending in "!"/"." over-advances segIdx and misaligns every later // sentence — dropping a pronoun that precedes a contraction ("No more! He // won't…" lost "He" and mis-tagged the contraction PRP). if (isPunctuation(words[tokenIdx].lexicalClass)) { merged.push(words[tokenIdx]); tokenIdx++; continue; } const seg = segments[segIdx]; // Archaic poetic preterite ("fix'd", "lov'd"): en-norm expands the -'d into a // separate "would"/"had" token, splitting one syllable into two words AND // shifting every later token off its raw segment. Re-merge host + modal back // into the apostrophized form — but ONLY when the spurious modal is actually // there (conditional, so a hand-typed "fix'd" that survived intact is safe). if (seg.isArchaicD) { const next = tokenIdx + 1 < words.length ? words[tokenIdx + 1].word.toLowerCase() : ''; if (next === 'would' || next === 'had') { merged.push({ ...words[tokenIdx], word: seg.text }); tokenIdx += 2; segIdx++; continue; } merged.push(words[tokenIdx]); tokenIdx++; segIdx++; continue; } if (seg.isContraction) { if (tokenIdx + 1 >= words.length) { merged.push(words[tokenIdx]); tokenIdx++; segIdx++; continue; } const hostWord = words[tokenIdx]; const cliticWord = words[tokenIdx + 1]; // Keep the host as the merged word, update its text to the contracted form. const mergedWord: ClsWord = { ...hostWord, word: seg.text, }; merged.push(mergedWord); tokenIdx += 2; segIdx++; } else { merged.push(words[tokenIdx]); tokenIdx++; segIdx++; } } // Append any remaining words that exceeded segment count. while (tokenIdx < words.length) { merged.push(words[tokenIdx]); tokenIdx++; } return { words: merged, consumedSegments: segIdx - startSegmentIdx }; } function mergeHyphenatedWords(words: ClsWord[]): ClsWord[] { const merged: ClsWord[] = []; let i = 0; while (i < words.length) { if (i + 2 < words.length && words[i + 1].word === '-' && !isPunctuation(words[i].lexicalClass) && !isPunctuation(words[i + 2].lexicalClass)) { const combined = words[i].word + '-' + words[i + 2].word; const mergedWord: ClsWord = { ...words[i], word: combined, lexicalClass: words[i + 2].lexicalClass.startsWith('N') ? words[i + 2].lexicalClass : words[i].lexicalClass, isContent: words[i].isContent || words[i + 2].isContent, }; merged.push(mergedWord); i += 3; } else { merged.push(words[i]); i++; } } return merged; } // ─── Mappings: FinNLP → Antelope‑compatible labels ────────────────── // FinNLP (aka Stanford Dependency Types) versus Antelope NLP dependency (aka Universal Dependencies) type equivalencies. const FIN_TO_ANTELOPE_LABEL: Record = { AUX: 'aux', AUXPASS: 'auxpass', NSUBJ: 'nsubj', NSUBJPASS: 'nsubjpass', DOBJ: 'dobj', IOBJ: 'iobj', OBL: 'pobj', // OBL (oblique) ≈ pobj in Antelope DET: 'det', CASE: 'prep', // CASE marks the preposition; matched to UD prep CC: 'cc', COMPMARK: 'mark', ADVMARK: 'mark', NOMD: 'poss', // nominal modifier ≈ possessive relation AMOD: 'amod', ADVMOD: 'advmod', ADVCL: 'advcl', XCOMP: 'xcomp', CCOMP: 'ccomp', ACL: 'acl', VPRT: 'prt', NUMDMOD: 'nummod', EXPL: 'expl', DISCOURSE: 'discourse', PUNCT: 'punct', INTERJ: 'intj', EXT: 'dep', // extension – best mapped to generic 'dep' DEP: 'dep', ROOT: 'root', }; function toAntelopeLabel(finLabel: string): string { return FIN_TO_ANTELOPE_LABEL[finLabel] ?? finLabel.toLowerCase(); } 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-', '``', "''", '--', '...', '"', "'", '(', ')', '[', ']', '{', '}', // FinNLP emits literal bracket tags, not -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 ('call them "wisdom" teeth') is read in * one breath — no intonational boundary, no caesura. Treating quotes as IU * boundaries fragmented such lines into 3-4 IUs and flipped their meter. */ const QUOTE_TAGS = new Set(['``', "''", '"', "'"]); export function isQuoteTag(tag: string): boolean { return QUOTE_TAGS.has(tag); } /** * Typographic dashes that FinNLP mis-tags as content words. A standalone en-dash * "–", em-dash "—", horizontal bar "―", minus sign "−" or a run of 2+ hyphens is a * prosodic break (a dash caesura), NOT a stress-bearing token — but FinNLP's POS * model labels the bare glyph `NNP` (proper noun), so it flowed through the * pipeline, received a syllable, and even attracted a strong metrical beat * ("crunch – a guilt" scanned the dash as 's'). We re-tag any such glyph to the * Penn dash/colon class ':' (already an IU/caesura boundary) at parse time, so the * dash drops out of syllabification & scansion and instead marks a pause. * A *single* hyphen-minus is deliberately excluded — it joins hyphenated compounds * ("torch-flames") handled by mergeHyphenatedWords. */ const DASH_GLYPH_RE = /^(?:[‒–—―−]+|-{2,})$/; function isDashGlyph(word: string): boolean { return DASH_GLYPH_RE.test(word); } const DASH_CLASS = '‒–—―−'; // figure / en / em / bar / minus 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 comma clause-breaks BEFORE parsing. In verse a dash * is a comma-like prosodic break — not a word, not a sentence end — but FinNLP * mis-handles it two ways: it glues a SPACE-flanked hyphen-minus into the * neighbouring word ("I still carry - Oh" → token "carry-Oh", then OOV), and it * tags a bare en/em-dash as a proper noun (NNP) that pollutes the dependency tree. * Worse, a parenthetical aside set off by dashes often contains sentence-final * punctuation ("– Oh, Petersburg! –") that splits the line into separate * sentences and severs the main clause's dependencies (here, carry↔address). * * So we (1) fold every dash usage — em/en/figure/bar/minus, a 2+ hyphen run, or a * space-flanked single hyphen — into a canonical dash glyph (leaving unspaced * hyphen compounds like "torch-flames" intact for `mergeHyphenatedWords`); * (2) neutralize sentence-final punctuation INSIDE a dash-delimited parenthetical * so the line stays one sentence; (3) rewrite the dashes to commas, which FinNLP * parses cleanly and which are the same prosodic break (a comma is an IU boundary * → caesura). The verbatim original (dashes and all) is preserved by the caller * for the reading-view projection; only the parser's working copy is normalized. */ function normalizeDashesToClauseBreaks(text: string): string { // (1) space-flanked single/multi hyphen-minus, and any 2+ hyphen run → en-dash text = text.replace(/(^|\s)-+(?=\s|$)/g, '$1–'); text = text.replace(/-{2,}/g, '–'); // (2) neutralize sentence-final punctuation between paired dashes (keeps it one sentence) text = text.replace(DASH_PAREN_RE, (_m, a, inner, b) => a + inner.replace(/[.!?]+/g, ',') + b); // (3) dash glyphs → comma clause-break text = text.replace(DASH_GLYPHS_RE, ','); // tidy: collapse comma runs, no space before a comma, one space after, no leading comma text = text.replace(/(?:\s*,\s*){2,}/g, ', ') .replace(/\s+,/g, ',') .replace(/,(\S)/g, ', $1') .replace(/^\s*,\s*/, ''); return text; } function isContentWord(tag: string): boolean { return CONTENT_POS.has(tag); } // ─── Public API ─────────────────────────────────────────────────── /** * Parse a multi‑sentence text string and return a ClsDocument whose * internal structure mirrors the Antelope NLP (aka Universal Dependency type) * output from McAleese’s original Calliope implementation. */ export function parseDocument(text: string): ClsDocument { // Collapse runs of sentence-final punctuation (ellipsis "...", "!!", "??") // to a single mark BEFORE tokenisation. FinNLP otherwise glues the surplus // marks onto the preceding word ("springtime..." → token "springtime.." → OOV, // mis-tagged JJ, mis-syllabified, and re-phrased), which made two lines that // differ only in trailing punctuation scan differently. This is metrically // harmless (punctuation bears no syllable); the verbatim original is preserved // by the caller and used for the reading projection. text = text.replace(/([.!?])\1+/g, '$1'); // Dashes → comma clause-breaks (fixes "carry-Oh" gluing AND the parenthetical // sentence-split that severs main-clause dependencies). See the helper above. text = normalizeDashesToClauseBreaks(text); // Pre‑scan the raw text for contraction positions before FinNLP // normalises them away. const rawSegments = tokenizeRawText(text); // Run the FinNLP pipeline STAGED rather than via `Fin.Run`, so the tag- // correction layer (tagfix.ts) sits between en-pos and en-parse: corrected // tags repair the tagging AND the dependency tree built from it. The // stages below mirror finnlp's own Run() exactly (en-norm → lexed → // en-pos → en-parse); lemmas are skipped (unused downstream). const intercepted = EnNorm.resolveContractions(EnNorm.replaceConfusables(text)); const lexer = new Lexed.Lexed(intercepted).lexer(); const runner: FinRunInstance = { raw: text, intercepted, sentences: [] }; for (let li = 0; li < lexer.sentences.length; li++) { const tokens = EnNorm.normalizeCaps(lexer.tokens[li]); const tagging = new EnPos.Tag(tokens).initial().smooth(); const tags = correctTags(tokens, tagging.tags); const depsTree = EnParse.tree(tags, tokens)[0]; runner.sentences.push({ sentence: lexer.sentences[li], tokens, tags, lemmas: [], depsTree, // Post-parse dependency repair (depfix.ts): systematic en-parse // attachment errors (noun-compound double-objects, dangling DT). deps: applyDepFixes(tokens, tags, EnParse.toArray(depsTree)), confidence: 0, }); } const sentences: ClsSentence[] = []; let absoluteOffset = 0; let segmentIdx = 0; for (let si = 0; si < runner.sentences.length; si++) { const s = runner.sentences[si]; const rawTokens: string[] = s.tokens; const rawTags: string[] = s.tags; const rawDeps: FinDepNode[] = s.deps; // ---- 1. Build ClsWord array ---- const wordsPre: ClsWord[] = rawTokens.map((word, i) => { // Re-tag a mis-tagged dash glyph (FinNLP labels "–"/"—" as NNP) to the Penn // dash class ':' so it acts as a caesura/IU boundary, not a stressable word. const tag = isDashGlyph(word) ? ':' : rawTags[i]; return ({ index: i + 1, // 1‑based, matching Antelope lexicalClass: tag, lexicalDetails: '', lexicalPlural: tag === 'NNS' || tag === 'NNPS', position: '', word, absoluteIndex: absoluteOffset + i, isContent: isContentWord(tag), syllables: [], // filled later by stress module phraseStress: 0, dependency: undefined, // patched below node: undefined, // patched below }); }); // ---- 1a. Re‑merge contraction pairs ---- const { words: contractedWords, consumedSegments } = mergeContractionsInSentence( wordsPre, rawSegments, segmentIdx ); segmentIdx += consumedSegments; // ---- 1b. Re‑merge hyphenated words ---- const words = mergeHyphenatedWords(contractedWords); // Re‑index words after merging (1‑based). words.forEach((w, i) => { w.index = i + 1; }); // ---- 2. Build ClsDependency array ---- const dependencies: ClsDependency[] = []; // Build contraction merge map: wordsPre idx → contractedWords idx. // MUST replay mergeContractionsInSentence exactly: punctuation tokens have no // raw segment (consume none), and an archaic -'d merges only when the // spurious would/had token follows. const contractionMap = new Map(); let pi = 0; let qi = 0; let segOff = segmentIdx - consumedSegments; while (pi < wordsPre.length && segOff < segmentIdx) { if (isPunctuation(wordsPre[pi].lexicalClass)) { contractionMap.set(pi, qi); pi++; qi++; continue; } const seg = rawSegments[segOff]; const archaicMerge = seg.isArchaicD && pi + 1 < wordsPre.length && ['would', 'had'].includes(wordsPre[pi + 1].word.toLowerCase()); if ((seg.isContraction && pi + 1 < wordsPre.length) || archaicMerge) { contractionMap.set(pi, qi); contractionMap.set(pi + 1, qi); pi += 2; qi += 1; segOff++; } else { contractionMap.set(pi, qi); pi++; qi++; segOff++; } } while (pi < wordsPre.length) { contractionMap.set(pi, qi); pi++; qi++; } // Build hyphen merge map: contractedWords idx → words idx const hyphenMap = new Map(); let ci = 0; let wi = 0; while (ci < contractedWords.length) { if (ci + 2 < contractedWords.length && contractedWords[ci + 1].word === '-' && !isPunctuation(contractedWords[ci].lexicalClass) && !isPunctuation(contractedWords[ci + 2].lexicalClass)) { hyphenMap.set(ci, wi); hyphenMap.set(ci + 1, wi); hyphenMap.set(ci + 2, wi); ci += 3; wi++; } else { hyphenMap.set(ci, wi); ci++; wi++; } } // Compose: wordsPre idx → words idx const mergeMap2 = new Map(); for (const [preIdx, cIdx] of contractionMap) { const wIdx = hyphenMap.get(cIdx); if (wIdx !== undefined) mergeMap2.set(preIdx, wIdx); } // Build dependencies from rawDeps, remapping governor and dependent indices. for (let i = 0; i < rawDeps.length; i++) { const dep = rawDeps[i]; const govPreIdx = dep.parent; // 0‑based, -1 for root const depPreIdx = i; const govPostIdx = govPreIdx >= 0 ? mergeMap2.get(govPreIdx) : undefined; const depPostIdx = mergeMap2.get(depPreIdx); if (depPostIdx === undefined) continue; if (govPreIdx >= 0 && govPostIdx === depPostIdx) continue; // self-loop from merged pair // If this dependent is the clitic half of a contraction, skip // (its dependency info is already captured through the host mapping). // Check if this is the second token of a contraction: const isCliticHalf = i > 0 && mergeMap2.get(i) === mergeMap2.get(i - 1); if (isCliticHalf) continue; const govWord: ClsWord | undefined = govPostIdx !== undefined && govPostIdx >= 0 ? words[govPostIdx] : undefined; const depWord: ClsWord = words[depPostIdx]; // If governor was a clitic half that got merged into host, re-point to host const actualGovWord = govWord || (govPreIdx >= 0 && govPreIdx < wordsPre.length ? words[mergeMap2.get(govPreIdx)!] : undefined); dependencies.push({ index: depPostIdx + 1, governorIndex: govPostIdx !== undefined ? govPostIdx + 1 : 0, dependentIndex: depPostIdx + 1, dependentType: toAntelopeLabel(dep.label), governorName: (govPostIdx !== undefined && govPostIdx >= 0 && words[govPostIdx]) ? words[govPostIdx].word : 'ROOT', dependentName: depWord.word, governor: (govPostIdx !== undefined && govPostIdx >= 0 && words[govPostIdx]) ? words[govPostIdx] : null as unknown as ClsWord, dependent: depWord, }); } // Ensure ROOT dependency exists. const hasRoot = dependencies.some(d => d.governorIndex === 0); if (!hasRoot && 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 the dependent. words.forEach(w => { w.dependency = dependencies.find(d => d.dependent === w); }); // ---- 3. Build phrase‑structure node tree from FinNLP's depsTree ---- const rootNode = buildNodeTree(s.depsTree, words); // Attach each word’s corresponding leaf node (if any). const wordNodeMap = new Map(); 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: '' }; } // ─── Node‑tree construction ────────────────────────────────────── /** Sentinel used for empty / unparsable trees. */ const EMPTY_NODE: ClsNode = { index: '0', nodeName: 'EMPTY', parent: null, contains: [], }; /** * Recursively convert a FinNLP NodeInterface tree into a ClsNode tree * that mirrors Antelope’s phrase‑structure output. * * The root of the FinNLP tree is always wrapped in an SQ node. */ function buildNodeTree( finRoot: FinNodeInterface | null | undefined, words: ClsWord[] ): ClsNode { // Guard: missing or empty tree if (!finRoot || !finRoot.tokens || finRoot.tokens.length === 0) { // Create a minimal SQ node containing all words as direct leaves. const sq: ClsNode = { index: '1', nodeName: 'SQ', parent: null, contains: words.map(w => createWordLeaf(w)), }; return sq; } // The top‑level SQ node (Antelope style) const sqNode: ClsNode = { index: '1', nodeName: 'SQ', parent: null, contains: [], }; // Convert the root FinNLP node and attach it under SQ. const convertedRoot = convertFinNode(finRoot, words, sqNode); if (convertedRoot) { convertedRoot.parent = sqNode; sqNode.contains.push(convertedRoot); } // Ensure every word is represented somewhere in the tree. // Words not yet attached (e.g., punctuation at the edges) are added // directly under SQ. const attachedIndices = new Set(); collectAttachedWordIndices(sqNode, attachedIndices); for (const w of words) { if (!attachedIndices.has(w.index)) { const leaf = createWordLeaf(w); leaf.parent = sqNode; sqNode.contains.push(leaf); } } return sqNode; } /** * Convert a single FinNodeInterface (sub‑tree) into a ClsNode. */ function convertFinNode( finNode: FinNodeInterface, words: ClsWord[], parentNode: ClsNode ): ClsNode { // Determine whether this is a leaf (single‑word) node. const isLeaf = (!finNode.left || finNode.left.length === 0) && (!finNode.right || finNode.right.length === 0); if (isLeaf && finNode.tokens.length === 1) { // Single‑word leaf → reference the ClsWord const wordIdx = finNode.index[0]; // 0‑based const word = words[wordIdx]; if (!word) { // Fallback: create a text leaf return { index: `leaf_${wordIdx}`, nodeName: finNode.tokens[0], parent: parentNode, contains: [], }; } return createWordLeaf(word); } // Phrase node – use the FinNLP type as label (NP, VP, PP, etc.) const phraseType = finNode.type && finNode.type !== 'ROOT' ? finNode.type : 'XP'; const phraseNode: ClsNode = { index: `ph_${finNode.index[0]}_${finNode.index[1]}`, nodeName: phraseType, parent: parentNode, contains: [], }; // Process left children (pre‑head dependents) if (finNode.left && finNode.left.length > 0) { for (const leftChild of finNode.left) { const childNode = convertFinNode(leftChild, words, phraseNode); if (childNode) { childNode.parent = phraseNode; phraseNode.contains.push(childNode); } } } // The head token(s) of this node for (let i = finNode.index[0]; i <= finNode.index[1]; i++) { const word = words[i]; if (word) { const leaf = createWordLeaf(word); leaf.parent = phraseNode; phraseNode.contains.push(leaf); } } // Process right children (post‑head dependents) if (finNode.right && finNode.right.length > 0) { for (const rightChild of finNode.right) { const childNode = convertFinNode(rightChild, words, phraseNode); if (childNode) { childNode.parent = phraseNode; phraseNode.contains.push(childNode); } } } return phraseNode; } // ─── Leaf‑node helpers ──────────────────────────────────────────── function createWordLeaf(word: ClsWord): ClsNode { return { index: `w${word.index}`, nodeName: word.index.toString(), // Antelope style: the word’s 1‑based index as string parent: null, contains: [word], }; } // ─── Tree traversal helpers ─────────────────────────────────────── function collectWordNodes(node: ClsNode, map: Map): void { for (const child of node.contains) { if (child instanceof Object && 'word' in (child as any)) { // child is a ClsWord const w = child as ClsWord; // The leaf is the current node (since word leaves contain the word directly) map.set(w.index, node); } else if (child instanceof Object && 'index' in (child as any)) { // child is a ClsNode collectWordNodes(child as ClsNode, map); } } } function collectAttachedWordIndices( node: ClsNode, set: Set ): void { for (const child of node.contains) { if (child instanceof Object && 'word' in (child as any)) { const w = child as ClsWord; set.add(w.index); } else if (child instanceof Object && 'index' in (child as any)) { collectAttachedWordIndices(child as ClsNode, set); } } }