calliope-ts 0.0.4 → 0.1.0
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 +69 -3
- package/dist/calliope/bracketing.d.ts.map +1 -1
- package/dist/calliope/bracketing.js +59 -5
- package/dist/calliope/deps.d.ts +5 -1
- package/dist/calliope/deps.d.ts.map +1 -1
- package/dist/calliope/deps.js +53 -3
- package/dist/calliope/engine.d.ts.map +1 -1
- package/dist/calliope/engine.js +6 -1
- package/dist/calliope/relstress.d.ts.map +1 -1
- package/dist/calliope/relstress.js +411 -15
- package/dist/calliope/syntax.d.ts +33 -0
- package/dist/calliope/syntax.d.ts.map +1 -1
- package/dist/calliope/syntax.js +199 -0
- package/dist/data/en_common_words.json +1 -0
- package/dist/display.d.ts +15 -1
- package/dist/display.d.ts.map +1 -1
- package/dist/display.js +112 -10
- package/dist/fabbhalle.d.ts +54 -0
- package/dist/fabbhalle.d.ts.map +1 -0
- package/dist/fabbhalle.js +404 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +86 -33
- package/dist/phonological.d.ts.map +1 -1
- package/dist/phonological.js +7 -1
- package/dist/rewriteEn.d.ts +14 -0
- package/dist/rewriteEn.d.ts.map +1 -0
- package/dist/rewriteEn.js +392 -0
- package/dist/rhyme.d.ts +35 -3
- package/dist/rhyme.d.ts.map +1 -1
- package/dist/rhyme.js +176 -11
- package/dist/russian/accentuator.d.ts +31 -0
- package/dist/russian/accentuator.d.ts.map +1 -0
- package/dist/russian/accentuator.js +546 -0
- package/dist/russian/collocations.d.ts +7 -0
- package/dist/russian/collocations.d.ts.map +1 -0
- package/dist/russian/collocations.js +38 -0
- package/dist/russian/compounds.d.ts +6 -0
- package/dist/russian/compounds.d.ts.map +1 -0
- package/dist/russian/compounds.js +81 -0
- package/dist/russian/download.d.ts +23 -0
- package/dist/russian/download.d.ts.map +1 -0
- package/dist/russian/download.js +117 -0
- package/dist/russian/engine.d.ts +6 -0
- package/dist/russian/engine.d.ts.map +1 -0
- package/dist/russian/engine.js +1424 -0
- package/dist/russian/parser.d.ts +8 -0
- package/dist/russian/parser.d.ts.map +1 -0
- package/dist/russian/parser.js +92 -0
- package/dist/russian/paths.d.ts +55 -0
- package/dist/russian/paths.d.ts.map +1 -0
- package/dist/russian/paths.js +114 -0
- package/dist/russian/rewrite.d.ts +6 -0
- package/dist/russian/rewrite.d.ts.map +1 -0
- package/dist/russian/rewrite.js +400 -0
- package/dist/russian/rhyme.d.ts +18 -0
- package/dist/russian/rhyme.d.ts.map +1 -0
- package/dist/russian/rhyme.js +230 -0
- package/dist/russian/syllabifier.d.ts +14 -0
- package/dist/russian/syllabifier.d.ts.map +1 -0
- package/dist/russian/syllabifier.js +475 -0
- package/dist/russian/types.d.ts +94 -0
- package/dist/russian/types.d.ts.map +1 -0
- package/dist/russian/types.js +27 -0
- package/dist/scandroidNative/constants.d.ts +16 -0
- package/dist/scandroidNative/constants.d.ts.map +1 -0
- package/dist/scandroidNative/constants.js +60 -0
- package/dist/scandroidNative/deduceParameters.d.ts +16 -0
- package/dist/scandroidNative/deduceParameters.d.ts.map +1 -0
- package/dist/scandroidNative/deduceParameters.js +69 -0
- package/dist/scandroidNative/dictionary-data.d.ts +2 -0
- package/dist/scandroidNative/dictionary-data.d.ts.map +1 -0
- package/dist/scandroidNative/dictionary-data.js +744 -0
- package/dist/scandroidNative/dictionary.d.ts +12 -0
- package/dist/scandroidNative/dictionary.d.ts.map +1 -0
- package/dist/scandroidNative/dictionary.js +50 -0
- package/dist/scandroidNative/engine.d.ts +41 -0
- package/dist/scandroidNative/engine.d.ts.map +1 -0
- package/dist/scandroidNative/engine.js +119 -0
- package/dist/scandroidNative/machine.d.ts +93 -0
- package/dist/scandroidNative/machine.d.ts.map +1 -0
- package/dist/scandroidNative/machine.js +795 -0
- package/dist/scandroidNative/positioner.d.ts +32 -0
- package/dist/scandroidNative/positioner.d.ts.map +1 -0
- package/dist/scandroidNative/positioner.js +208 -0
- package/dist/scandroidNative/syllabizer.d.ts +3 -0
- package/dist/scandroidNative/syllabizer.d.ts.map +1 -0
- package/dist/scandroidNative/syllabizer.js +215 -0
- package/dist/scandroidNative/utilities.d.ts +37 -0
- package/dist/scandroidNative/utilities.d.ts.map +1 -0
- package/dist/scandroidNative/utilities.js +86 -0
- package/dist/scansion.d.ts.map +1 -1
- package/dist/scansion.js +118 -14
- package/dist/stress.d.ts +1 -0
- package/dist/stress.d.ts.map +1 -1
- package/dist/stress.js +502 -42
- package/dist/types.d.ts +3 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +27 -6
- package/tools/fetch-russian-data.mjs +30 -0
- package/webapp/public/app.js +2418 -0
- package/webapp/public/assets/GithubLogo.png +0 -0
- package/webapp/public/assets/NPM2.png +0 -0
- package/webapp/public/assets/fonts/MurenaBold-webfont.woff2 +0 -0
- package/webapp/public/assets/fonts/MurenaItalic-webfont.woff2 +0 -0
- package/webapp/public/assets/fonts/MurenaRegular-webfont.woff2 +0 -0
- package/webapp/public/assets/fonts/Myra_4F_Caps_Bold-webfont.woff2 +0 -0
- package/webapp/public/assets/fonts/SinkinSans-700Bold.otf +0 -0
- package/webapp/public/assets/fonts/butterflies.ttf +0 -0
- package/webapp/public/assets/fonts/midcase_blackline-webfont.woff2 +0 -0
- package/webapp/public/assets/fonts/neueral-bold-webfont.woff2 +0 -0
- package/webapp/public/assets/fonts/neueral-regular-webfont.woff2 +0 -0
- package/webapp/public/assets/fonts/popelka-webfont.woff2 +0 -0
- package/webapp/public/assets/fonts/vremenagrotesk-webfont.woff +0 -0
- package/webapp/public/assets/fonts/wicky_javick_bold-webfont.woff2 +0 -0
- package/webapp/public/assets/logos/Logo_Var_10_Celtic_Knot_SVG.svg +116 -0
- package/webapp/public/assets/logos/Logo_Var_11_Graph_SVG.svg +313 -0
- package/webapp/public/assets/logos/Logo_Var_12_Limacon_Knot_SVG.svg +3942 -0
- package/webapp/public/assets/logos/Logo_Var_13_Crescents_SVG.svg +1030 -0
- package/webapp/public/assets/logos/Logo_Var_1_Spiral_SVG.svg +1491 -0
- package/webapp/public/assets/logos/Logo_Var_2_Tunnel_SVG.svg +12 -0
- package/webapp/public/assets/logos/Logo_Var_3_Triskelion_SVG.svg +10 -0
- package/webapp/public/assets/logos/Logo_Var_4_Quinquetra_PNG.png +0 -0
- package/webapp/public/assets/logos/Logo_Var_5_Penrose_Pentagram_SVG.svg +37 -0
- package/webapp/public/assets/logos/Logo_Var_6_Triskelion2_SVG.svg +10 -0
- package/webapp/public/assets/logos/Logo_Var_7_SixfoldSpirals_SVG.svg +20 -0
- package/webapp/public/assets/logos/Logo_Var_8_Radial_Labyrinth_SVG.svg +163 -0
- package/webapp/public/assets/logos/Logo_Var_9_Radial_Labyrinth_2_SVG.svg +106 -0
- package/webapp/public/index.html +316 -0
- package/webapp/public/styles.css +1114 -0
- package/webapp/server.mjs +680 -0
- package/src/caesura.ts +0 -201
- package/src/calliope/boundaries.ts +0 -190
- package/src/calliope/bracketing.ts +0 -390
- package/src/calliope/deps.ts +0 -160
- package/src/calliope/engine.ts +0 -77
- package/src/calliope/feats.ts +0 -46
- package/src/calliope/names.ts +0 -44
- package/src/calliope/postag.ts +0 -253
- package/src/calliope/prosodic.ts +0 -262
- package/src/calliope/relstress.ts +0 -645
- package/src/calliope/stressrules.ts +0 -147
- package/src/calliope/syntax.ts +0 -218
- package/src/calliope/udpos.ts +0 -152
- package/src/calliope_src_contents.md +0 -19049
- package/src/clio/caesura.ts +0 -145
- package/src/clio/depfix.ts +0 -88
- package/src/clio/display.ts +0 -1042
- package/src/clio/engine.ts +0 -38
- package/src/clio/parser.ts +0 -845
- package/src/clio/phonological.ts +0 -849
- package/src/clio/phrasestress.ts +0 -108
- package/src/clio/pipeline.ts +0 -154
- package/src/clio/rhyme.ts +0 -740
- package/src/clio/scandroid.ts +0 -434
- package/src/clio/scansion.ts +0 -1130
- package/src/clio/semantics.ts +0 -134
- package/src/clio/stress.ts +0 -1731
- package/src/clio/tagfix.ts +0 -104
- package/src/depfix.ts +0 -88
- package/src/display.ts +0 -1138
- package/src/engine.ts +0 -22
- package/src/index.ts +0 -690
- package/src/parser.ts +0 -501
- package/src/phonological.ts +0 -917
- package/src/phrasestress.ts +0 -108
- package/src/rhyme.ts +0 -748
- package/src/scandroid.ts +0 -434
- package/src/scansion.ts +0 -1174
- package/src/semantics.ts +0 -134
- package/src/stress.ts +0 -2111
- package/src/tagfix.ts +0 -104
- package/src/types.ts +0 -265
- package/tests/DataForHayesLinesOnly.txt +0 -364
- package/tests/DataForHayesStressSymbolsRevised.txt +0 -728
- package/tests/basic.test.ts +0 -1102
- package/tests/bench-hayes.mjs +0 -72
- package/tests/epg64.meter.train.txt +0 -8139
- package/tests/litlab-sample-2016.txt +0 -1738
- package/tests/prosodic.meter.train.txt +0 -13192
- package/tests/wagner-stress.test.ts +0 -188
- package/tsconfig.json +0 -29
- package/vitest.config.d.ts +0 -2
- package/vitest.config.js +0 -14
package/dist/index.js
CHANGED
|
@@ -12,9 +12,10 @@ import { clioEngine } from './clio/engine.js';
|
|
|
12
12
|
import { analyzeReadingDocumentClio, renderUnifiedDisplay as clioRenderUnifiedDisplay, renderReadingView as clioRenderReadingView, renderFullLegend as clioRenderFullLegend, parseDocument as clioParseDocument, isPunctuation as clioIsPunctuation, } from './clio/pipeline.js';
|
|
13
13
|
import { renderHierarchy, renderKeyStresses, } from './phonological.js';
|
|
14
14
|
import { extractKeyStresses, scoreMeters, applyStanzaConsensus, applyRhythmLayer, applyMetricalityLayer } from './scansion.js';
|
|
15
|
-
import { applyRhymeAndForm } from './rhyme.js';
|
|
16
|
-
import {
|
|
17
|
-
import { renderUnifiedDisplay, renderFullLegend, renderReadingView, } from './display.js';
|
|
15
|
+
import { applyRhymeAndForm, isBeatTransferRhyme } from './rhyme.js';
|
|
16
|
+
import { scanTextNatively } from './scandroidNative/engine.js';
|
|
17
|
+
import { renderUnifiedDisplay, renderFullLegend, renderReadingView, fabbHalleSyllablesOf, } from './display.js';
|
|
18
|
+
import { buildFabbHalleGridsForPoem } from './fabbhalle.js';
|
|
18
19
|
// The active prosody engine for this process: Calliope (faithful, default) or
|
|
19
20
|
// Clio (the frozen legacy / alternative parse), chosen by `--clio` or the REPL
|
|
20
21
|
// menu. The exported analysis functions default their `engine` parameter to
|
|
@@ -70,6 +71,51 @@ function markStanzaGivenness(docPerLine) {
|
|
|
70
71
|
seen.add(k);
|
|
71
72
|
}
|
|
72
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* Stanza-level RHYME-FELLOW focus — the mirror image of givenness. The end-rhyme
|
|
76
|
+
* is the verse form's own focus device: a rhyme falls where the ear expects a
|
|
77
|
+
* beat, and the poet who closes a line on a pronoun has PUT the punchline there
|
|
78
|
+
* ("And he stoppeth one of THREE … Now wherefore stopp'st thou ME?" — the rhyme
|
|
79
|
+
* pair three/me IS the contrast pair). A weak-class line-final word therefore
|
|
80
|
+
* inherits beat-eligibility from a rhyme partner that carries one lexically.
|
|
81
|
+
* Guards (all must hold — none of this is a categorical pronoun rule):
|
|
82
|
+
* • only the LINE-FINAL word of a line is ever flagged (mid-line "me" untouched);
|
|
83
|
+
* • the word itself is FUNCTION-class (a content final already has its beat);
|
|
84
|
+
* • the partner is a CONTENT-class line-final in the SAME stanza — the beat is
|
|
85
|
+
* transferred from an attested template, never invented;
|
|
86
|
+
* • the rhyme is BEAT-BEARING (perfect/rich/family, or augmented/diminished —
|
|
87
|
+
* same stressed vowel ± one terminal consonant) and non-identical — looser
|
|
88
|
+
* slant hunches and mere repetition do not license promotion;
|
|
89
|
+
* • single lines and unrhymed finals (enjambment, free verse) are untouched.
|
|
90
|
+
* Mutates `rhymeFocal`; must run BEFORE the per-line relativisation (the
|
|
91
|
+
* relativiser gives a flagged MONOSYLLABLE the 'n' tier — a real, promotable
|
|
92
|
+
* beat, graded below the clause's content beats).
|
|
93
|
+
*/
|
|
94
|
+
function markRhymeFellows(docPerLine) {
|
|
95
|
+
if (docPerLine.length < 2)
|
|
96
|
+
return; // single line → no scheme
|
|
97
|
+
const finals = docPerLine.map(sents => {
|
|
98
|
+
const ws = sents.flatMap(s => s.words)
|
|
99
|
+
.filter(w => /[a-z]/i.test(w.word) && !isPunctuation(w.lexicalClass));
|
|
100
|
+
return ws.length ? ws.reduce((a, b) => (b.absoluteIndex > a.absoluteIndex ? b : a)) : null;
|
|
101
|
+
});
|
|
102
|
+
for (let i = 0; i < finals.length; i++) {
|
|
103
|
+
const w = finals[i];
|
|
104
|
+
if (!w || w.isContent)
|
|
105
|
+
continue; // weak-class finals only
|
|
106
|
+
for (let j = 0; j < finals.length; j++) {
|
|
107
|
+
if (j === i)
|
|
108
|
+
continue;
|
|
109
|
+
const p = finals[j];
|
|
110
|
+
if (!p || !p.isContent)
|
|
111
|
+
continue; // template must be a content beat
|
|
112
|
+
if (isBeatTransferRhyme(w.word, p.word)) {
|
|
113
|
+
w.rhymeFocal = true;
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
73
119
|
/**
|
|
74
120
|
* Scan one VERSE LINE (which may parse into several grammatical sentences).
|
|
75
121
|
*
|
|
@@ -119,39 +165,11 @@ function processLine(sents, engine = activeEngine) {
|
|
|
119
165
|
const phonoDetail = scoreMeters(keyStresses, words, ius);
|
|
120
166
|
phonoDetail.all = renderHierarchy(ius, words);
|
|
121
167
|
phonoDetail.keyStresses = renderKeyStresses(ius, words, keyStresses);
|
|
122
|
-
// Scandroid – use the actual foot count from the phonological detection
|
|
123
|
-
const stressPattern = words.flatMap((w) => w.syllables.map((s) => s.relativeStress ?? 'w'));
|
|
124
|
-
const marks = stressToMarks(stressPattern);
|
|
125
|
-
const actualFeet = phonoDetail.footCount > 0 ? phonoDetail.footCount : 5; // fallback only if unknown
|
|
126
|
-
const corral = scandroidCorralWeird(marks, actualFeet);
|
|
127
|
-
const max = scandroidMaximizeNormal(marks, actualFeet);
|
|
128
|
-
const corralResult = corral.footlist.length
|
|
129
|
-
? {
|
|
130
|
-
meter: 'iambic',
|
|
131
|
-
scansion: corral.footlist.map(f => f.replace(/[()]/g, '')).join(' | '),
|
|
132
|
-
certainty: 0,
|
|
133
|
-
weightScore: 0,
|
|
134
|
-
maxPossibleWeight: 0,
|
|
135
|
-
algorithm: 'Scandroid Corral the Weird',
|
|
136
|
-
}
|
|
137
|
-
: undefined;
|
|
138
|
-
const maxResult = max.footlist.length
|
|
139
|
-
? {
|
|
140
|
-
meter: 'iambic',
|
|
141
|
-
scansion: max.footlist.map(f => f.replace(/[()]/g, '')).join(' | '),
|
|
142
|
-
certainty: 0,
|
|
143
|
-
weightScore: 0,
|
|
144
|
-
maxPossibleWeight: 0,
|
|
145
|
-
algorithm: 'Scandroid Maximise the Normal',
|
|
146
|
-
}
|
|
147
|
-
: undefined;
|
|
148
168
|
return {
|
|
149
169
|
sentence: merged,
|
|
150
170
|
phonologicalHierarchy: ius,
|
|
151
171
|
keyStresses,
|
|
152
172
|
phonologicalScansion: phonoDetail,
|
|
153
|
-
scandroidCorral: corralResult,
|
|
154
|
-
scandroidMaximise: maxResult,
|
|
155
173
|
};
|
|
156
174
|
}
|
|
157
175
|
/**
|
|
@@ -194,6 +212,15 @@ function applyContinuityRename(results) {
|
|
|
194
212
|
export function analyzeStanzas(text, useScandroid = true, engine = activeEngine) {
|
|
195
213
|
const stanzas = text.split(/\n\s*\n/);
|
|
196
214
|
const results = [];
|
|
215
|
+
// Scandroid deduces its Metron/line-length ONCE over the WHOLE document (up
|
|
216
|
+
// to a dozen sampled lines for a real multi-line poem; a per-line decision
|
|
217
|
+
// for a single typed line) — exactly Hartman's own "loaded document" vs
|
|
218
|
+
// "typed line" distinction — so this runs across stanza breaks, not per
|
|
219
|
+
// stanza. Independent of everything else computed below: raw text in,
|
|
220
|
+
// Scandroid's own verdict out.
|
|
221
|
+
const allRawLines = stanzas.flatMap(st => st.split('\n').filter(l => l.trim() !== ''));
|
|
222
|
+
const nativeResults = useScandroid ? scanTextNatively(allRawLines) : [];
|
|
223
|
+
let nativeIdx = 0;
|
|
197
224
|
for (const stanza of stanzas) {
|
|
198
225
|
const lines = stanza.split('\n').filter(l => l.trim() !== '');
|
|
199
226
|
const stanzaResults = [];
|
|
@@ -202,8 +229,12 @@ export function analyzeStanzas(text, useScandroid = true, engine = activeEngine)
|
|
|
202
229
|
// default (Calliope) relativiser consults it; Clio ignores the flag.
|
|
203
230
|
const docs = lines.map(line => parseDocument(line));
|
|
204
231
|
markStanzaGivenness(docs.map(d => d.sentences));
|
|
232
|
+
markRhymeFellows(docs.map(d => d.sentences));
|
|
205
233
|
for (const doc of docs) {
|
|
206
234
|
const res = processLine(doc.sentences, engine);
|
|
235
|
+
if (res && useScandroid)
|
|
236
|
+
res.scandroidNative = nativeResults[nativeIdx];
|
|
237
|
+
nativeIdx++;
|
|
207
238
|
if (res)
|
|
208
239
|
stanzaResults.push(res);
|
|
209
240
|
}
|
|
@@ -253,14 +284,23 @@ export function analyzeText(text, useScandroid = true, engine = activeEngine) {
|
|
|
253
284
|
export function analyzeReadingDocument(text, engine = activeEngine) {
|
|
254
285
|
const stanzas = text.split(/\n\s*\n/);
|
|
255
286
|
const out = [];
|
|
287
|
+
// See analyzeStanzas: Scandroid deduces Metron/line-length once over the
|
|
288
|
+
// whole document, spanning stanza breaks.
|
|
289
|
+
const allRawLines = stanzas.flatMap(st => st.split('\n').filter(l => l.trim() !== ''));
|
|
290
|
+
const nativeResults = scanTextNatively(allRawLines);
|
|
291
|
+
let nativeIdx = 0;
|
|
256
292
|
for (const stanza of stanzas) {
|
|
257
293
|
const rawLines = stanza.split('\n').filter(l => l.trim() !== '');
|
|
258
294
|
if (rawLines.length === 0)
|
|
259
295
|
continue;
|
|
260
296
|
const rawDocs = rawLines.map(raw => ({ raw, doc: parseDocument(raw) }));
|
|
261
297
|
markStanzaGivenness(rawDocs.map(rd => rd.doc.sentences));
|
|
298
|
+
markRhymeFellows(rawDocs.map(rd => rd.doc.sentences));
|
|
262
299
|
const lines = rawDocs.map(({ raw, doc }) => {
|
|
263
300
|
const res = processLine(doc.sentences, engine);
|
|
301
|
+
if (res)
|
|
302
|
+
res.scandroidNative = nativeResults[nativeIdx];
|
|
303
|
+
nativeIdx++;
|
|
264
304
|
return { raw, results: res ? [res] : [] };
|
|
265
305
|
});
|
|
266
306
|
applyStanzaConsensus(lines.flatMap(l => l.results.map(r => r.phonologicalScansion)));
|
|
@@ -286,14 +326,27 @@ function showResults(text) {
|
|
|
286
326
|
// Clio routes through its own frozen pipeline + renderer.
|
|
287
327
|
const isClio = activeEngine.name === 'clio';
|
|
288
328
|
const stanzas = (isClio ? analyzeReadingDocumentClio : analyzeReadingDocument)(text);
|
|
289
|
-
|
|
329
|
+
// Fabb–Halle's rule set is discovered by its OWN poem-level grid
|
|
330
|
+
// construction over the whole document (lexical stress only), then each
|
|
331
|
+
// line is rendered under it — the engine's verdict is only reported for
|
|
332
|
+
// comparison. Clio's frozen renderer keeps its own behaviour.
|
|
333
|
+
let fhSchema = null;
|
|
334
|
+
if (!isClio) {
|
|
335
|
+
try {
|
|
336
|
+
const allResults = stanzas.flatMap(st => st.lines.flatMap(l => l.results));
|
|
337
|
+
fhSchema = buildFabbHalleGridsForPoem(allResults.map(fabbHalleSyllablesOf))?.schema ?? null;
|
|
338
|
+
}
|
|
339
|
+
catch { /* section falls back to stipulating the engine's meter */ }
|
|
340
|
+
}
|
|
290
341
|
for (let s = 0; s < stanzas.length; s++) {
|
|
291
342
|
if (stanzas.length > 1) {
|
|
292
343
|
console.log('\n' + chalk.bold('═══ Stanza ' + (s + 1) + ' ═══'));
|
|
293
344
|
}
|
|
294
345
|
for (const ln of stanzas[s].lines) {
|
|
295
346
|
for (const res of ln.results) {
|
|
296
|
-
console.log(
|
|
347
|
+
console.log(isClio
|
|
348
|
+
? clioRenderUnifiedDisplay(res, ln.raw)
|
|
349
|
+
: renderUnifiedDisplay(res, ln.raw, { fhSchema }));
|
|
297
350
|
}
|
|
298
351
|
}
|
|
299
352
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"phonological.d.ts","sourceRoot":"","sources":["../src/phonological.ts"],"names":[],"mappings":"AAGA,OAAO,EACL,WAAW,EACX,OAAO,EAGP,kBAAkB,EAClB,gBAAgB,EAChB,SAAS,EAET,oBAAoB,EACrB,MAAM,YAAY,CAAC;AAIpB;;;;;;;;GAQG;AACH,wBAAgB,0BAA0B,CACxC,QAAQ,EAAE,WAAW,GACpB,gBAAgB,EAAE,CAoBpB;AA0UD,wBAAgB,eAAe,CAAC,EAAE,EAAE,gBAAgB,GAAG,OAAO,EAAE,CAM/D;AAED,wBAAgB,eAAe,CAAC,EAAE,EAAE,kBAAkB,GAAG,OAAO,EAAE,CAMjE;AA+ED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,gBAAgB,EAAE,EAAE,KAAK,EAAE,OAAO,EAAE,GAAG,MAAM,CAGjF;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAC/B,GAAG,EAAE,gBAAgB,EAAE,EACvB,KAAK,EAAE,OAAO,EAAE,EAChB,WAAW,EAAE,SAAS,EAAE,GACvB,MAAM,CASR;
|
|
1
|
+
{"version":3,"file":"phonological.d.ts","sourceRoot":"","sources":["../src/phonological.ts"],"names":[],"mappings":"AAGA,OAAO,EACL,WAAW,EACX,OAAO,EAGP,kBAAkB,EAClB,gBAAgB,EAChB,SAAS,EAET,oBAAoB,EACrB,MAAM,YAAY,CAAC;AAIpB;;;;;;;;GAQG;AACH,wBAAgB,0BAA0B,CACxC,QAAQ,EAAE,WAAW,GACpB,gBAAgB,EAAE,CAoBpB;AA0UD,wBAAgB,eAAe,CAAC,EAAE,EAAE,gBAAgB,GAAG,OAAO,EAAE,CAM/D;AAED,wBAAgB,eAAe,CAAC,EAAE,EAAE,kBAAkB,GAAG,OAAO,EAAE,CAMjE;AA+ED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,gBAAgB,EAAE,EAAE,KAAK,EAAE,OAAO,EAAE,GAAG,MAAM,CAGjF;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAC/B,GAAG,EAAE,gBAAgB,EAAE,EACvB,KAAK,EAAE,OAAO,EAAE,EAChB,WAAW,EAAE,SAAS,EAAE,GACvB,MAAM,CASR;AA8BD,MAAM,MAAM,WAAW,GAAG,MAAM,GAAG,OAAO,GAAG,SAAS,CAAC;AAEvD,2FAA2F;AAC3F,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,GAAG,WAAW,CAQzD;AAED,mFAAmF;AACnF,wBAAgB,oBAAoB,CAClC,SAAS,EAAE;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,aAAa,CAAC,EAAE,MAAM,CAAA;CAAE,EAAE,GACvE,WAAW,EAAE,CASf;AAsDD,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,EAAE,YAAY,CAAC,EAAE,WAAW,EAAE,EAAE,WAAW,CAAC,EAAE,MAAM,EAAE,WAAW,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CA8RrJ;AAED;;;;;GAKG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,OAAO,EAAE,GAAG,oBAAoB,EAAE,CAwB9E"}
|
package/dist/phonological.js
CHANGED
|
@@ -435,7 +435,13 @@ export function renderKeyStresses(ius, words, keyStresses) {
|
|
|
435
435
|
* Respects English phonotactics: digraphs stay together, consonants go to
|
|
436
436
|
* the onset of the following syllable when they form a legal cluster.
|
|
437
437
|
*/
|
|
438
|
-
|
|
438
|
+
// Accented Latin vowels included: an "é" read as a consonant shifts every
|
|
439
|
+
// chunk boundary in the word (Milésien split Milé·si·en, parking the stress
|
|
440
|
+
// mark on the wrong displayed syllable).
|
|
441
|
+
const VOWEL_CHARS = new Set('aeiouyAEIOUY'
|
|
442
|
+
+ 'àáâãäåæèéêëìíîïòóôõöøùúûüýÿ'
|
|
443
|
+
+ 'ÀÁÂÃÄÅÆÈÉÊËÌÍÎÏÒÓÔÕÖØÙÚÛÜÝ'
|
|
444
|
+
+ 'āăēĕīĭōŏūŭȳœŒ');
|
|
439
445
|
const CONSONANT_DIGRAPHS = new Set(['th', 'sh', 'ch', 'wh', 'ph', 'gh', 'ck', 'ng', 'nk', 'tch', 'dge', 'sc', 'sk', 'sp', 'st']);
|
|
440
446
|
// ARPABET vowels, split into "free/long" (can end a syllable → favours an OPEN
|
|
441
447
|
// split: e·ven, ta·ble, o·pen) and "checked/short" (needs a coda → favours a
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface EnRewriteOptions {
|
|
2
|
+
/** rhyme mode: widen the pool to family and assonant rhyme tiers. */
|
|
3
|
+
fuzzyRhyme?: boolean;
|
|
4
|
+
/** derivational-suffix + CMU-morphology-class grounding. */
|
|
5
|
+
morphGround?: boolean;
|
|
6
|
+
/** 0–4: weight of the |Δzipf| register-band penalty (0 = off). */
|
|
7
|
+
registerFidelity?: number;
|
|
8
|
+
/** use dictionary POS for source words instead of the contextual tagger. */
|
|
9
|
+
dictPos?: boolean;
|
|
10
|
+
}
|
|
11
|
+
/** Rewrite English verse in place — spacing, punctuation, and case survive
|
|
12
|
+
* (nounsing's built-ins lowercase and drop punctuation). */
|
|
13
|
+
export declare function rewriteEnglishText(text: string, mode: 'stress' | 'rhyme' | 'phones', posPrecision?: number, freqThreshold?: number, opts?: EnRewriteOptions): string;
|
|
14
|
+
//# sourceMappingURL=rewriteEn.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rewriteEn.d.ts","sourceRoot":"","sources":["../src/rewriteEn.ts"],"names":[],"mappings":"AAuLA,MAAM,WAAW,gBAAgB;IAC/B,qEAAqE;IACrE,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,4DAA4D;IAC5D,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,kEAAkE;IAClE,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,4EAA4E;IAC5E,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAsKD;6DAC6D;AAC7D,wBAAgB,kBAAkB,CAChC,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,QAAQ,GAAG,OAAO,GAAG,QAAQ,EACnC,YAAY,SAAI,EAChB,aAAa,SAAI,EACjB,IAAI,GAAE,gBAAqB,GAC1B,MAAM,CAmCR"}
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
// rewriteEn.ts — the English Transmutation Chamber, rebuilt over nounsing-pro
|
|
2
|
+
// primitives with the Russian side's scored-ranking architecture.
|
|
3
|
+
//
|
|
4
|
+
// stress — candidates share the source's exact stress contour;
|
|
5
|
+
// rhyme — candidates rhyme with the source (perfect; or, with fuzzyRhyme,
|
|
6
|
+
// the wider family/assonant tiers of nounsing's rhyme typology —
|
|
7
|
+
// many words have one or zero perfect rhyme-mates: temperate ↔
|
|
8
|
+
// intemperate is the whole perfect pool);
|
|
9
|
+
// phones — candidates share the opening two phonemes.
|
|
10
|
+
//
|
|
11
|
+
// Gates (preserved semantics from nounsing's built-in rewrites):
|
|
12
|
+
// posPrecision 0–3 — Penn-tag prefix match. The SOURCE word's tag comes
|
|
13
|
+
// from the scansion engine's CONTEXTUAL tagger by default (parseDocument:
|
|
14
|
+
// UDPipe-in-context with clitic re-merging and archaic forms — "frame" in
|
|
15
|
+
// "Could frame thy fearful symmetry" gates as VB, not the dictionary's
|
|
16
|
+
// NN); candidates are dictionary-tagged (they occur in no context).
|
|
17
|
+
// `dictPos: true` reverts the source side to dictionary tags (fast path,
|
|
18
|
+
// no per-line parse).
|
|
19
|
+
// freqThreshold — Zipf floor (≥ 1.0 enables).
|
|
20
|
+
//
|
|
21
|
+
// Grounding score (new, composed like src/russian/rewrite.ts):
|
|
22
|
+
// morphGround — derivational-suffix match (-ness/-tion/-ing/-ly …)
|
|
23
|
+
// + the augmented CMU's morphology classes (suffixType,
|
|
24
|
+
// prefixType, simple/complex), + same-root avoidance;
|
|
25
|
+
// registerFidelity — 0–4 slider: −r·|Δzipf| penalty, so a rare word swaps
|
|
26
|
+
// for a similarly rare word and a common one for a
|
|
27
|
+
// common one (register/diction banding);
|
|
28
|
+
// fuzzy rhyme tiers — perfect +8 > family +5 > assonant +2, so perfect
|
|
29
|
+
// rhymes still win wherever they exist.
|
|
30
|
+
//
|
|
31
|
+
// Unlike nounsing's built-ins (lowercased whitespace-join), this rewriter
|
|
32
|
+
// replaces words in place — punctuation, spacing, and capitalisation survive.
|
|
33
|
+
import { lexicon, morphology, phonesForWord, stresses, search, searchStresses, rhymes, familyRhyme, assonantRhyme, } from 'nounsing-pro';
|
|
34
|
+
import { parseDocument, isPunctuation } from './parser.js';
|
|
35
|
+
import { lexicon as enLexicon } from 'en-lexicon';
|
|
36
|
+
import { readFileSync } from 'fs';
|
|
37
|
+
import { fileURLToPath } from 'url';
|
|
38
|
+
import { dirname, join } from 'path';
|
|
39
|
+
/** Words attested lowercase in Webster's 2nd (web2 ∩ lexicon, de-inflected
|
|
40
|
+
* at build time — tools note: regenerate src/data/en_common_words.json from
|
|
41
|
+
* /usr/share/dict/words if the lexicon changes; tsc does NOT copy it to
|
|
42
|
+
* dist/data/). The CMU is ~half proper names that carry NO tag/frequency
|
|
43
|
+
* marker (aaron is NN at Zipf 4.2), so dictionary-attestation is the one
|
|
44
|
+
* signal that separates eagle/warrior/chorus from aaron/melcher/salisbury. */
|
|
45
|
+
let _commonWords = null;
|
|
46
|
+
function commonWords() {
|
|
47
|
+
if (_commonWords)
|
|
48
|
+
return _commonWords;
|
|
49
|
+
try {
|
|
50
|
+
const p = join(dirname(fileURLToPath(import.meta.url)), 'data', 'en_common_words.json');
|
|
51
|
+
_commonWords = new Set(JSON.parse(readFileSync(p, 'utf8')));
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
_commonWords = new Set();
|
|
55
|
+
}
|
|
56
|
+
return _commonWords;
|
|
57
|
+
}
|
|
58
|
+
// ── Grounding helpers ─────────────────────────────────────────────────
|
|
59
|
+
/** English derivational suffixes, longest-first. Surface match beats the
|
|
60
|
+
* CMU's class-level fields for register: brightness → sweetness keeps the
|
|
61
|
+
* deadjectival-abstract cast even when Penn tags already agree. */
|
|
62
|
+
const DERIV_SUFFIXES = [
|
|
63
|
+
'ability', 'ibility', 'ization', 'fulness', 'ousness',
|
|
64
|
+
'ation', 'ition', 'iness', 'ingly', 'ement', 'ships',
|
|
65
|
+
'ness', 'tion', 'sion', 'ment', 'able', 'ible', 'ance', 'ence',
|
|
66
|
+
'ship', 'hood', 'ward', 'wise', 'less', 'ful', 'ous', 'ive',
|
|
67
|
+
'ity', 'ism', 'ist', 'ize', 'ise', 'ify', 'ard', 'dom',
|
|
68
|
+
'ing', 'est', 'eth', 'ess', 'ly', 'ed', 'er', 'en',
|
|
69
|
+
];
|
|
70
|
+
const _sufCache = new Map();
|
|
71
|
+
function derivSuffix(word) {
|
|
72
|
+
const hit = _sufCache.get(word);
|
|
73
|
+
if (hit !== undefined)
|
|
74
|
+
return hit;
|
|
75
|
+
let out = '';
|
|
76
|
+
for (const s of DERIV_SUFFIXES) {
|
|
77
|
+
if (word.length - s.length >= 3 && word.endsWith(s)) {
|
|
78
|
+
out = s;
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
_sufCache.set(word, out);
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
function morphClassOf(word) {
|
|
86
|
+
const m = morphology(word);
|
|
87
|
+
if (!m || m.length === 0)
|
|
88
|
+
return null;
|
|
89
|
+
return {
|
|
90
|
+
suffixType: m[0].suffixType ?? 'NA',
|
|
91
|
+
prefixType: m[0].prefixType ?? 'NA',
|
|
92
|
+
morphology: m[0].morphology ?? 'NA',
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
function zipfOf(word) {
|
|
96
|
+
const lex = lexicon(word);
|
|
97
|
+
if (!lex || lex.length === 0)
|
|
98
|
+
return null;
|
|
99
|
+
const f = parseFloat(lex[0].freq);
|
|
100
|
+
return Number.isNaN(f) ? null : f;
|
|
101
|
+
}
|
|
102
|
+
function posOf(word) {
|
|
103
|
+
const lex = lexicon(word);
|
|
104
|
+
const pos = lex?.[0]?.pos;
|
|
105
|
+
return pos && pos !== 'NA' ? pos.toUpperCase() : null;
|
|
106
|
+
}
|
|
107
|
+
/** Candidate-side tag SET, from the FinNLP en-lexicon (112k words, full
|
|
108
|
+
* Penn tag distributions: burning → NN|VBG|JJ) with nounsing's single
|
|
109
|
+
* first-profile tag as fallback. Two wins over first-profile-only:
|
|
110
|
+
* a VB source no longer rejects "frame" (NN|VBP|VB), and en-lexicon
|
|
111
|
+
* tags proper names as NNP (aaron, jake, kyle) where the augmented CMU
|
|
112
|
+
* calls them NN — the name signal no other resource carries. */
|
|
113
|
+
const _tagSetCache = new Map();
|
|
114
|
+
function tagSetOf(word) {
|
|
115
|
+
const hit = _tagSetCache.get(word);
|
|
116
|
+
if (hit)
|
|
117
|
+
return hit;
|
|
118
|
+
// Own-property + type guard: the lexicon is a plain object, so bare
|
|
119
|
+
// indexing with "constructor" (a real CMU word!) returns the Object
|
|
120
|
+
// constructor function — .toUpperCase() then crashes the cast.
|
|
121
|
+
const entry = Object.prototype.hasOwnProperty.call(enLexicon, word)
|
|
122
|
+
? enLexicon[word] : undefined;
|
|
123
|
+
let out;
|
|
124
|
+
if (typeof entry === 'string' && entry) {
|
|
125
|
+
out = entry.toUpperCase().split('|');
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
const p = posOf(word);
|
|
129
|
+
out = p ? [p] : [];
|
|
130
|
+
}
|
|
131
|
+
_tagSetCache.set(word, out);
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
/** Same-root detector: English derives at both edges (night→nightly,
|
|
135
|
+
* more→anymore, temperate→intemperate), so containment at either edge
|
|
136
|
+
* flags the pair. Applied unconditionally — a same-root cast is a
|
|
137
|
+
* transmutation in name only, and temperate's sole perfect rhyme is
|
|
138
|
+
* its own negation. */
|
|
139
|
+
function sameRoot(a, b) {
|
|
140
|
+
if (Math.min(a.length, b.length) < 3)
|
|
141
|
+
return false;
|
|
142
|
+
return a.endsWith(b) || b.endsWith(a) || a.startsWith(b) || b.startsWith(a);
|
|
143
|
+
}
|
|
144
|
+
// ── Closed-class scaffolding (active at posPrecision ≥ 1) ─────────────
|
|
145
|
+
//
|
|
146
|
+
// The Russian side's quality edge is exactly this: function words are the
|
|
147
|
+
// grammatical scaffolding, and swapping them through the open-class pipeline
|
|
148
|
+
// yields "of → would've" and "the → an call". Closed-class tags are KEPT;
|
|
149
|
+
// pronouns and modals — where swaps stay grammatical — transmute only
|
|
150
|
+
// WITHIN curated sets that respect case and possession (Penn's PRP covers
|
|
151
|
+
// he AND him, so the tag alone can't guard case).
|
|
152
|
+
const KEEP_TAGS = new Set([
|
|
153
|
+
'DT', 'IN', 'CC', 'TO', 'EX', 'POS', 'RP', 'PDT',
|
|
154
|
+
'WDT', 'WP', 'WP$', 'WRB', 'UH', 'CD', 'LS', 'SYM', 'FW',
|
|
155
|
+
]);
|
|
156
|
+
const PRON_NOM = ['i', 'he', 'she', 'we', 'they', 'thou', 'ye', 'you', 'it'];
|
|
157
|
+
const PRON_ACC = ['me', 'him', 'her', 'us', 'them', 'thee', 'you', 'it'];
|
|
158
|
+
const PRON_POSS = ['my', 'thy', 'his', 'her', 'its', 'our', 'their', 'your'];
|
|
159
|
+
const MODALS = ['can', 'could', 'may', 'might', 'must', 'shall', 'should', 'will', 'would'];
|
|
160
|
+
function closedClassSet(tag, lower) {
|
|
161
|
+
if (tag === 'MD')
|
|
162
|
+
return MODALS;
|
|
163
|
+
if (tag === 'PRP$')
|
|
164
|
+
return PRON_POSS;
|
|
165
|
+
if (tag === 'PRP') {
|
|
166
|
+
if (PRON_NOM.includes(lower))
|
|
167
|
+
return PRON_NOM;
|
|
168
|
+
if (PRON_ACC.includes(lower))
|
|
169
|
+
return PRON_ACC;
|
|
170
|
+
return []; // unknown pronoun form — keep it
|
|
171
|
+
}
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
function matchCase(original, replacement) {
|
|
175
|
+
if (original === original.toUpperCase() && original.length > 1)
|
|
176
|
+
return replacement.toUpperCase();
|
|
177
|
+
if (original[0] === original[0].toUpperCase()) {
|
|
178
|
+
return replacement[0].toUpperCase() + replacement.slice(1);
|
|
179
|
+
}
|
|
180
|
+
return replacement;
|
|
181
|
+
}
|
|
182
|
+
const MAX_SCORED = 4000; // sample cap before scoring very large pools
|
|
183
|
+
function pickReplacement(mode, lower, ctxTag, posPrecision, zipfFloor, opts) {
|
|
184
|
+
const prons = phonesForWord(lower);
|
|
185
|
+
if (prons.length === 0)
|
|
186
|
+
return null;
|
|
187
|
+
// Candidate pool, with rhyme-tier tags in fuzzy rhyme mode.
|
|
188
|
+
let pool;
|
|
189
|
+
if (mode === 'stress') {
|
|
190
|
+
const pat = stresses(prons[0]);
|
|
191
|
+
if (!pat)
|
|
192
|
+
return null;
|
|
193
|
+
pool = searchStresses('^' + pat + '$').map(w => ({ word: w, tier: 0 }));
|
|
194
|
+
}
|
|
195
|
+
else if (mode === 'rhyme') {
|
|
196
|
+
const perfect = rhymes(lower);
|
|
197
|
+
if (opts.fuzzyRhyme) {
|
|
198
|
+
const seen = new Set(perfect);
|
|
199
|
+
pool = perfect.map(w => ({ word: w, tier: 8 }));
|
|
200
|
+
for (const w of familyRhyme(lower)) {
|
|
201
|
+
if (!seen.has(w)) {
|
|
202
|
+
seen.add(w);
|
|
203
|
+
pool.push({ word: w, tier: 5 });
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
for (const w of assonantRhyme(lower)) {
|
|
207
|
+
if (!seen.has(w)) {
|
|
208
|
+
seen.add(w);
|
|
209
|
+
pool.push({ word: w, tier: 2 });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
pool = perfect.map(w => ({ word: w, tier: 0 }));
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
const first2 = prons[0].split(' ').slice(0, 2).join(' ');
|
|
219
|
+
pool = search('^' + first2).map(w => ({ word: w, tier: 0 }));
|
|
220
|
+
}
|
|
221
|
+
if (pool.length === 0)
|
|
222
|
+
return null;
|
|
223
|
+
// Gates (same semantics as nounsing's built-in rewrites). The source tag
|
|
224
|
+
// prefers the contextual parse; dictionary POS is the fallback (and the
|
|
225
|
+
// whole story when dictPos is set).
|
|
226
|
+
const srcPos = ctxTag ?? posOf(lower);
|
|
227
|
+
const posPrefix = posPrecision > 0 && srcPos ? srcPos.slice(0, posPrecision) : null;
|
|
228
|
+
// Closed-class scaffolding: prepositions, determiners, conjunctions etc.
|
|
229
|
+
// survive untouched; pronouns and modals transmute only within their
|
|
230
|
+
// curated sets — the mode constraint already lives in the pool, so
|
|
231
|
+
// could→should/would in rhyme mode, he→she/thee/we, thy→her/our.
|
|
232
|
+
if (posPrecision > 0 && srcPos) {
|
|
233
|
+
if (KEEP_TAGS.has(srcPos))
|
|
234
|
+
return null;
|
|
235
|
+
const set = closedClassSet(srcPos, lower);
|
|
236
|
+
if (set) {
|
|
237
|
+
const members = pool.filter(c => c.word !== lower && set.includes(c.word));
|
|
238
|
+
if (members.length === 0)
|
|
239
|
+
return null;
|
|
240
|
+
return members[(Math.random() * members.length) | 0].word;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
// Sample very large pools before the scoring pass.
|
|
244
|
+
if (pool.length > MAX_SCORED) {
|
|
245
|
+
for (let i = pool.length - 1; i > pool.length - 1 - MAX_SCORED; i--) {
|
|
246
|
+
const j = (Math.random() * (i + 1)) | 0;
|
|
247
|
+
[pool[i], pool[j]] = [pool[j], pool[i]];
|
|
248
|
+
}
|
|
249
|
+
pool = pool.slice(pool.length - MAX_SCORED);
|
|
250
|
+
}
|
|
251
|
+
const srcSuffix = opts.morphGround ? derivSuffix(lower) : '';
|
|
252
|
+
const srcMorph = opts.morphGround ? morphClassOf(lower) : null;
|
|
253
|
+
const srcZipf = (opts.registerFidelity ?? 0) > 0 ? zipfOf(lower) : null;
|
|
254
|
+
const rFid = opts.registerFidelity ?? 0;
|
|
255
|
+
const srcNsylls = mode !== 'stress' ? (lexicon(lower)?.[0]?.nsylls ?? null) : null;
|
|
256
|
+
const scored = [];
|
|
257
|
+
for (const { word: cand, tier } of pool) {
|
|
258
|
+
if (cand === lower)
|
|
259
|
+
continue;
|
|
260
|
+
// Letters only, two or more: kills CMU noise (a42128), tokenisation
|
|
261
|
+
// shards (ca), and the apostrophe entries (morris', would've, it'll)
|
|
262
|
+
// that read as junk in a cast.
|
|
263
|
+
if (!/^[a-z][a-z]+$/.test(cand))
|
|
264
|
+
continue;
|
|
265
|
+
const candZipf = zipfOf(cand);
|
|
266
|
+
if (zipfFloor >= 1.0 && (candZipf === null || candZipf < zipfFloor))
|
|
267
|
+
continue;
|
|
268
|
+
// Tag-set gate: the candidate passes if ANY of its readings matches the
|
|
269
|
+
// source tag at the requested precision (precision 3 = EXACT tag — a
|
|
270
|
+
// prefix slice conflated PRP with PRP$; thy→me was a case wreck).
|
|
271
|
+
// Proper-noun readings are struck first when the source isn't proper:
|
|
272
|
+
// jake is NNP-only and vanishes; frank keeps JJ|NN|VB and stays.
|
|
273
|
+
let primaryMatch = false;
|
|
274
|
+
if (posPrefix && srcPos) {
|
|
275
|
+
let tags = tagSetOf(cand);
|
|
276
|
+
if (!srcPos.startsWith('NNP')) {
|
|
277
|
+
tags = tags.filter(t => !t.startsWith('NNP'));
|
|
278
|
+
if (tags.length === 0)
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
const match = (t) => posPrecision >= 3 ? t === srcPos : t.slice(0, posPrecision) === posPrefix;
|
|
282
|
+
if (!tags.some(match))
|
|
283
|
+
continue;
|
|
284
|
+
// The dominant (first-listed) reading matching is worth a bonus —
|
|
285
|
+
// "frame" as VB is its secondary reading, "blame" its primary.
|
|
286
|
+
primaryMatch = match(tags[0]);
|
|
287
|
+
}
|
|
288
|
+
let s = tier;
|
|
289
|
+
if (primaryMatch)
|
|
290
|
+
s += 2;
|
|
291
|
+
// Attestation penalty (not a hard filter — thin pools still cast):
|
|
292
|
+
// unattested candidates are overwhelmingly the CMU's untagged proper
|
|
293
|
+
// names. Skipped for proper-noun sources, where names are the point.
|
|
294
|
+
if (posPrecision > 0 && (!srcPos || !srcPos.startsWith('NNP')) && commonWords().size > 0
|
|
295
|
+
&& !commonWords().has(cand))
|
|
296
|
+
s -= 7;
|
|
297
|
+
if (opts.morphGround) {
|
|
298
|
+
if (srcSuffix && derivSuffix(cand) === srcSuffix)
|
|
299
|
+
s += 4;
|
|
300
|
+
if (srcMorph) {
|
|
301
|
+
const cm = morphClassOf(cand);
|
|
302
|
+
if (cm) {
|
|
303
|
+
if (cm.suffixType === srcMorph.suffixType)
|
|
304
|
+
s += 2;
|
|
305
|
+
if (cm.morphology === srcMorph.morphology)
|
|
306
|
+
s += 1;
|
|
307
|
+
if (cm.prefixType === srcMorph.prefixType)
|
|
308
|
+
s += 1;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
if (sameRoot(lower, cand))
|
|
313
|
+
s -= 6;
|
|
314
|
+
if (srcZipf !== null && candZipf !== null) {
|
|
315
|
+
// Quantised to half-Zipf bands: a continuous penalty would leave a
|
|
316
|
+
// unique argmax and kill the dice — candidates within a band tie,
|
|
317
|
+
// and the random tiebreak keeps each cast fresh.
|
|
318
|
+
s -= rFid * (Math.round(Math.abs(srcZipf - candZipf) * 2) / 2);
|
|
319
|
+
}
|
|
320
|
+
// Rhyme/phones pools ignore length, so meter breaks worst on long
|
|
321
|
+
// lines (hand→understand, frame→francisco). Prefer same-syllable
|
|
322
|
+
// casts without forcing them — rhyme pools can be thin.
|
|
323
|
+
if (srcNsylls !== null) {
|
|
324
|
+
const cn = lexicon(cand)?.[0]?.nsylls;
|
|
325
|
+
if (cn)
|
|
326
|
+
s -= 1.5 * Math.min(Math.abs(cn - srcNsylls), 3);
|
|
327
|
+
}
|
|
328
|
+
scored.push({ word: cand, s, r: Math.random() });
|
|
329
|
+
}
|
|
330
|
+
if (scored.length === 0)
|
|
331
|
+
return null;
|
|
332
|
+
scored.sort((a, b) => b.s - a.s || a.r - b.r);
|
|
333
|
+
return scored[0].word;
|
|
334
|
+
}
|
|
335
|
+
const WORD_RE = /[A-Za-z][A-Za-z'’]*/g;
|
|
336
|
+
/** a/an agreement post-pass: articles survive (closed class), but the noun
|
|
337
|
+
* after them changes, so "an call" and "a hour" need re-agreement. The
|
|
338
|
+
* first PHONE decides (an hour, a unicorn), with a letter fallback. */
|
|
339
|
+
function startsWithVowelSound(word) {
|
|
340
|
+
const p = phonesForWord(word.toLowerCase().replace(/[’]/g, "'"))[0];
|
|
341
|
+
if (p)
|
|
342
|
+
return /^[AEIOU]/.test(p);
|
|
343
|
+
return /^[aeiou]/i.test(word);
|
|
344
|
+
}
|
|
345
|
+
function fixArticles(line) {
|
|
346
|
+
return line.replace(/\b([Aa])(n?)(\s+)([A-Za-z][A-Za-z'’]*)/g, (_m, a, _n, sp, next) => {
|
|
347
|
+
return (startsWithVowelSound(next) ? a + 'n' : a) + sp + next;
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
/** Normalised lookup key: lowercase, straight apostrophe (parseDocument
|
|
351
|
+
* normalises curly apostrophes before tokenising, so keys must match). */
|
|
352
|
+
function keyOf(s) {
|
|
353
|
+
return s.toLowerCase().replace(/[‘’ʼ′]/g, "'");
|
|
354
|
+
}
|
|
355
|
+
/** Rewrite English verse in place — spacing, punctuation, and case survive
|
|
356
|
+
* (nounsing's built-ins lowercase and drop punctuation). */
|
|
357
|
+
export function rewriteEnglishText(text, mode, posPrecision = 1, freqThreshold = 0, opts = {}) {
|
|
358
|
+
return text.split('\n').map(line => {
|
|
359
|
+
if (!line.trim())
|
|
360
|
+
return line;
|
|
361
|
+
// Contextual tagging (default): one parse per line; the aligner walks
|
|
362
|
+
// the parsed words in order, matching surface forms within a small
|
|
363
|
+
// window (clitic re-merging keeps contractions whole on both sides).
|
|
364
|
+
let ctxWords = [];
|
|
365
|
+
if (posPrecision > 0 && !opts.dictPos) {
|
|
366
|
+
try {
|
|
367
|
+
ctxWords = parseDocument(line).sentences
|
|
368
|
+
.flatMap(s => s.words)
|
|
369
|
+
.filter(w => !isPunctuation(w.lexicalClass))
|
|
370
|
+
.map(w => ({ form: keyOf(w.word), tag: w.lexicalClass.toUpperCase() }));
|
|
371
|
+
}
|
|
372
|
+
catch { /* dictionary fallback for the whole line */ }
|
|
373
|
+
}
|
|
374
|
+
let ti = 0;
|
|
375
|
+
const nextTag = (surface) => {
|
|
376
|
+
const target = keyOf(surface);
|
|
377
|
+
for (let k = ti; k < Math.min(ctxWords.length, ti + 6); k++) {
|
|
378
|
+
if (ctxWords[k].form === target) {
|
|
379
|
+
ti = k + 1;
|
|
380
|
+
return ctxWords[k].tag;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return null;
|
|
384
|
+
};
|
|
385
|
+
return fixArticles(line.replace(WORD_RE, (surface) => {
|
|
386
|
+
const ctxTag = nextTag(surface);
|
|
387
|
+
const lower = keyOf(surface);
|
|
388
|
+
const repl = pickReplacement(mode, lower, ctxTag, posPrecision, freqThreshold, opts);
|
|
389
|
+
return repl ? matchCase(surface, repl) : surface;
|
|
390
|
+
}));
|
|
391
|
+
}).join('\n');
|
|
392
|
+
}
|