cts / src /clio /parser.ts
AlekseyCalvin's picture
Upload 119 files
59f93ff verified
Raw
History Blame Contribute Delete
31.4 kB
// 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<string, ContractionEntry> = {
"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<string, string> = {
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<number, number>();
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<number, number>();
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<number, number>();
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<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: '' };
}
// ─── 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<number>();
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<number, ClsNode>): 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<number>
): 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);
}
}
}