calliope-ts 0.0.1

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.
Files changed (50) hide show
  1. package/.claude/settings.local.json +8 -0
  2. package/.opencode/skills/calliope-ts/SKILL.md +74 -0
  3. package/LICENSE +201 -0
  4. package/README.md +485 -0
  5. package/dist/depfix.d.ts +13 -0
  6. package/dist/depfix.d.ts.map +1 -0
  7. package/dist/depfix.js +84 -0
  8. package/dist/display.d.ts +38 -0
  9. package/dist/display.d.ts.map +1 -0
  10. package/dist/display.js +890 -0
  11. package/dist/index.d.ts +50 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +504 -0
  14. package/dist/parser.d.ts +10 -0
  15. package/dist/parser.d.ts.map +1 -0
  16. package/dist/parser.js +688 -0
  17. package/dist/phonological.d.ts +41 -0
  18. package/dist/phonological.d.ts.map +1 -0
  19. package/dist/phonological.js +788 -0
  20. package/dist/rhyme.d.ts +26 -0
  21. package/dist/rhyme.d.ts.map +1 -0
  22. package/dist/rhyme.js +340 -0
  23. package/dist/scandroid.d.ts +17 -0
  24. package/dist/scandroid.d.ts.map +1 -0
  25. package/dist/scandroid.js +435 -0
  26. package/dist/scansion.d.ts +37 -0
  27. package/dist/scansion.d.ts.map +1 -0
  28. package/dist/scansion.js +1007 -0
  29. package/dist/scansion_debug.js +586 -0
  30. package/dist/stress.d.ts +32 -0
  31. package/dist/stress.d.ts.map +1 -0
  32. package/dist/stress.js +1372 -0
  33. package/dist/tagfix.d.ts +6 -0
  34. package/dist/tagfix.d.ts.map +1 -0
  35. package/dist/tagfix.js +101 -0
  36. package/dist/types.d.ts +173 -0
  37. package/dist/types.d.ts.map +1 -0
  38. package/dist/types.js +4 -0
  39. package/package.json +62 -0
  40. package/src/depfix.ts +88 -0
  41. package/src/display.ts +954 -0
  42. package/src/index.ts +541 -0
  43. package/src/parser.ts +837 -0
  44. package/src/phonological.ts +849 -0
  45. package/src/rhyme.ts +328 -0
  46. package/src/scandroid.ts +434 -0
  47. package/src/scansion.ts +1053 -0
  48. package/src/stress.ts +1381 -0
  49. package/src/tagfix.ts +104 -0
  50. package/src/types.ts +230 -0
package/src/rhyme.ts ADDED
@@ -0,0 +1,328 @@
1
+ // rhyme.ts — Rhyme-pair classification, stanza rhyme-scheme detection, and
2
+ // poetic FORM identification.
3
+ //
4
+ // Rhyme typology follows the maintainer's LYRICAL app (meter_exemplars.ts
5
+ // RHYME_TYPES) so the two toolkits stay cross-compatible: perfect / rich /
6
+ // family / assonant / consonant / augmented / diminished / wrenched / eye /
7
+ // identical, with the structural qualifiers masculine / feminine / dactylic.
8
+ // Phonology comes from the augmented CMU dictionary (nounsing-pro); the
9
+ // orthographic (eye/wrenched) tier is a deliberately guarded fallback.
10
+ //
11
+ // FORM is a stanza/poem-level verdict (this is where "ballad" lives — a
12
+ // quatrain with ballad rhyme AND alternating 4·3 beats — NOT in the rhythm
13
+ // pass). Form names align with LYRICAL's FORM_REGISTRY where they overlap
14
+ // (Couplet, Triplet, Quatrain, Limerick, Petrarchan/Shakespearean Sonnet…).
15
+
16
+ import * as nounsing from 'nounsing-pro';
17
+ import { LineResult, PhonologicalScansionDetail } from './types.js';
18
+ import { isPunctuation } from './parser.js';
19
+ import { ictusProfile } from './scansion.js';
20
+
21
+ export type RhymeTypeName =
22
+ | 'identical' | 'rich' | 'perfect' | 'family'
23
+ | 'assonant' | 'consonant' | 'augmented' | 'diminished'
24
+ | 'wrenched' | 'eye';
25
+
26
+ export interface RhymePair {
27
+ type: RhymeTypeName;
28
+ /** masculine (stress on final syllable) / feminine (penult) / dactylic (antepenult) */
29
+ structure?: 'masculine' | 'feminine' | 'dactylic';
30
+ }
31
+
32
+ const VOWEL_RE = /^(AA|AE|AH|AO|AW|AY|EH|ER|EY|IH|IY|OW|OY|UH|UW)/;
33
+ const PLOSIVES = new Set(['B', 'D', 'G', 'P', 'T', 'K']);
34
+ const FRICATIVES = new Set(['V', 'DH', 'Z', 'ZH', 'JH', 'F', 'TH', 'S', 'SH', 'CH', 'HH']);
35
+ const NASALS = new Set(['M', 'N', 'NG']);
36
+
37
+ const isVowelPhone = (p: string) => VOWEL_RE.test(p);
38
+ const base = (p: string) => p.replace(/[0-9]/g, '');
39
+ const sameFamily = (a: string, b: string) =>
40
+ (PLOSIVES.has(a) && PLOSIVES.has(b)) || (FRICATIVES.has(a) && FRICATIVES.has(b)) || (NASALS.has(a) && NASALS.has(b));
41
+
42
+ function phonesOf(word: string): string[] | null {
43
+ const clean = word.toLowerCase().replace(/[^a-z']/g, '');
44
+ if (!clean) return null;
45
+ try {
46
+ const ph = nounsing.firstPhonesForWord(clean);
47
+ if (typeof ph === 'string' && ph.length > 0) return ph.split(' ');
48
+ } catch { /* OOV */ }
49
+ return null;
50
+ }
51
+
52
+ /** Index of the LAST stressed (1/2) vowel; falls back to the last vowel. */
53
+ function lastStressedIdx(ph: string[]): number {
54
+ let lastVowel = -1;
55
+ for (let i = ph.length - 1; i >= 0; i--) {
56
+ if (!isVowelPhone(ph[i])) continue;
57
+ if (lastVowel < 0) lastVowel = i;
58
+ if (/[12]$/.test(ph[i])) return i;
59
+ }
60
+ return lastVowel;
61
+ }
62
+
63
+ /** masculine/feminine/dactylic from how many vowels FOLLOW the rhyming vowel. */
64
+ function structureOf(ph: string[], idx: number): RhymePair['structure'] {
65
+ let after = 0;
66
+ for (let i = idx + 1; i < ph.length; i++) if (isVowelPhone(ph[i])) after++;
67
+ return after === 0 ? 'masculine' : after === 1 ? 'feminine' : 'dactylic';
68
+ }
69
+
70
+ /** Guarded orthographic tier: shared ending ≥3 chars, matching final phone if
71
+ * known, and never on a shared bare "-ing" (mass false positives). */
72
+ function orthographicTier(a: string, b: string, pa: string[] | null, pb: string[] | null): RhymeTypeName | null {
73
+ const wa = a.toLowerCase().replace(/[^a-z]/g, '');
74
+ const wb = b.toLowerCase().replace(/[^a-z]/g, '');
75
+ if (wa.length < 3 || wb.length < 3) return null;
76
+ let common = 0;
77
+ while (common < Math.min(wa.length, wb.length) && wa[wa.length - 1 - common] === wb[wb.length - 1 - common]) common++;
78
+ if (common < 3) return null;
79
+ if (wa.slice(-3) === 'ing' && wb.slice(-3) === 'ing' && common <= 4) return null;
80
+ if (pa && pb && base(pa[pa.length - 1]) !== base(pb[pb.length - 1])) return null;
81
+ // Wrenched when the shared ending is an UNSTRESSED suffix of a polysyllable
82
+ // (temperate/date, manifestation/convention); plain eye-rhyme otherwise.
83
+ const polyUnstressed = (ph: string[] | null) => {
84
+ if (!ph) return false;
85
+ const idx = lastStressedIdx(ph);
86
+ return idx >= 0 && structureOf(ph, idx) !== 'masculine';
87
+ };
88
+ return polyUnstressed(pa) !== polyUnstressed(pb) ? 'wrenched' : 'eye';
89
+ }
90
+
91
+ /** Classify the rhyme relation between two line-end words (or null). */
92
+ export function classifyRhymePair(a: string, b: string): RhymePair | null {
93
+ const wa = a.toLowerCase().replace(/[^a-z']/g, '');
94
+ const wb = b.toLowerCase().replace(/[^a-z']/g, '');
95
+ if (!wa || !wb) return null;
96
+ const pa = phonesOf(a);
97
+ const pb = phonesOf(b);
98
+ if (wa === wb) return { type: 'identical', structure: pa ? structureOf(pa, lastStressedIdx(pa)) : undefined };
99
+ if (!pa || !pb) {
100
+ const t = orthographicTier(a, b, pa, pb);
101
+ return t ? { type: t } : null;
102
+ }
103
+ const ia = lastStressedIdx(pa);
104
+ const ib = lastStressedIdx(pb);
105
+ if (ia < 0 || ib < 0) return null;
106
+ const ra = pa.slice(ia);
107
+ const rb = pb.slice(ib);
108
+ const structure = structureOf(pa, ia);
109
+ const sameStructure = structure === structureOf(pb, ib);
110
+
111
+ const norm = (seg: string[]) => seg.map(base);
112
+ const na = norm(ra);
113
+ const nb = norm(rb);
114
+ const partsEqual = na.length === nb.length && na.every((p, i) => p === nb[i]);
115
+
116
+ if (partsEqual && sameStructure) {
117
+ const onsetA = ia > 0 ? base(pa[ia - 1]) : '';
118
+ const onsetB = ib > 0 ? base(pb[ib - 1]) : '';
119
+ return { type: onsetA === onsetB ? 'rich' : 'perfect', structure };
120
+ }
121
+
122
+ const vowelSame = na[0] === nb[0];
123
+ const codaA = na.slice(1).filter(p => !isVowelPhone(p));
124
+ const codaB = nb.slice(1).filter(p => !isVowelPhone(p));
125
+ const codaEq = codaA.length === codaB.length && codaA.every((p, i) => p === codaB[i]);
126
+
127
+ if (vowelSame) {
128
+ // Same stressed vowel. Matching-length codas whose consonants pair up
129
+ // within one phonetic family (wet/deck, dame/grain) → family rhyme.
130
+ if (codaA.length === codaB.length && codaA.length > 0
131
+ && codaA.every((p, i) => p === codaB[i] || sameFamily(p, codaB[i]))) {
132
+ return { type: 'family', structure };
133
+ }
134
+ // One extra trailing consonant on the second/first word (bray/brave).
135
+ if (codaA.length + 1 === codaB.length && codaA.every((p, i) => p === codaB[i])) return { type: 'augmented', structure };
136
+ if (codaB.length + 1 === codaA.length && codaB.every((p, i) => p === codaA[i])) return { type: 'diminished', structure };
137
+ return { type: 'assonant', structure };
138
+ }
139
+ if (codaEq && codaA.length > 0) return { type: 'consonant', structure };
140
+ const t = orthographicTier(a, b, pa, pb);
141
+ return t ? { type: t } : null;
142
+ }
143
+
144
+ // Strength tiers for scheme detection.
145
+ const STRONG: Set<RhymeTypeName> = new Set(['identical', 'rich', 'perfect', 'family']);
146
+ const SLANT: Set<RhymeTypeName> = new Set(['assonant', 'consonant', 'augmented', 'diminished', 'wrenched', 'eye']);
147
+
148
+ export interface LineRhyme {
149
+ endWord: string;
150
+ letter: string; // scheme letter ('A', 'B', …; '·' = unrhymed)
151
+ type?: RhymeTypeName; // relation to the matched earlier line
152
+ matchedLine?: number; // 0-based index within the stanza
153
+ }
154
+
155
+ /** Detect a stanza's rhyme scheme from its line-end words. Strong rhymes
156
+ * bind; slant-tier rhymes bind only when no strong candidate exists. */
157
+ export function detectScheme(endWords: string[]): LineRhyme[] {
158
+ const out: LineRhyme[] = [];
159
+ let nextLetter = 0;
160
+ for (let i = 0; i < endWords.length; i++) {
161
+ let best: { j: number; pair: RhymePair } | null = null;
162
+ for (let j = i - 1; j >= 0; j--) {
163
+ const pair = classifyRhymePair(endWords[j], endWords[i]);
164
+ if (!pair) continue;
165
+ if (STRONG.has(pair.type)) { best = { j, pair }; break; } // nearest strong wins
166
+ if (!best && SLANT.has(pair.type)) best = { j, pair }; // else nearest slant
167
+ }
168
+ if (best) {
169
+ out.push({ endWord: endWords[i], letter: out[best.j].letter, type: best.pair.type, matchedLine: best.j });
170
+ } else {
171
+ out.push({ endWord: endWords[i], letter: String.fromCharCode(65 + (nextLetter++ % 26)) });
172
+ }
173
+ }
174
+ // Rebind pass: a STRONG rhyme claims its partner back from an earlier
175
+ // slant-tier binding. (Sonnet 130: "rare" first slant-binds to the red/head
176
+ // group, then "compare" arrives as its perfect partner — the couplet wins.)
177
+ for (let k = 0; k < out.length; k++) {
178
+ const r = out[k];
179
+ if (r.matchedLine === undefined || !r.type || !STRONG.has(r.type)) continue;
180
+ const target = out[r.matchedLine];
181
+ if (target.matchedLine !== undefined && target.type && SLANT.has(target.type)) {
182
+ const fresh = String.fromCharCode(65 + (nextLetter++ % 26));
183
+ target.letter = fresh;
184
+ target.type = undefined;
185
+ target.matchedLine = undefined;
186
+ r.letter = fresh;
187
+ }
188
+ }
189
+ // Lines whose letter never recurs are unrhymed: mark '·' for readability.
190
+ const counts = new Map<string, number>();
191
+ for (const r of out) counts.set(r.letter, (counts.get(r.letter) ?? 0) + 1);
192
+ for (const r of out) if ((counts.get(r.letter) ?? 0) < 2) r.letter = '·';
193
+ // Re-letter the survivors in order of first appearance (A, B, C…).
194
+ const remap = new Map<string, string>();
195
+ let k = 0;
196
+ for (const r of out) {
197
+ if (r.letter === '·') continue;
198
+ if (!remap.has(r.letter)) remap.set(r.letter, String.fromCharCode(65 + (k++ % 26)));
199
+ r.letter = remap.get(r.letter)!;
200
+ }
201
+ return out;
202
+ }
203
+
204
+ const schemeStr = (rs: LineRhyme[]) => rs.map(r => r.letter).join('');
205
+
206
+ /** Canonical scheme for FORM matching: every line gets a letter in sequential
207
+ * first-appearance order, unrhymed lines (·) each their own — so "·A·A"
208
+ * compares as "ABCB". */
209
+ function canonicalScheme(rs: LineRhyme[]): string {
210
+ const remap = new Map<string, string>();
211
+ let k = 0;
212
+ const next = () => String.fromCharCode(65 + (k++ % 26));
213
+ let out = '';
214
+ for (const r of rs) {
215
+ if (r.letter === '·') { out += next(); continue; }
216
+ if (!remap.has(r.letter)) remap.set(r.letter, next());
217
+ out += remap.get(r.letter)!;
218
+ }
219
+ return out;
220
+ }
221
+
222
+ /** Stanza-level form verdict (LYRICAL-compatible names where they overlap). */
223
+ function stanzaForm(rhymes: LineRhyme[], details: PhonologicalScansionDetail[]): string | undefined {
224
+ const s = canonicalScheme(rhymes);
225
+ const n = rhymes.length;
226
+ const meters = details.map(d => (d.consensusMeter ?? d.meter).split(' ')[0]);
227
+ const dominant = (name: string, frac = 0.5) => meters.filter(m => m === name).length / n >= frac;
228
+
229
+ if (n === 2 && s === 'AA') return 'couplet';
230
+ if (n === 3) {
231
+ if (s === 'ABA') return 'triplet (tercet, ABA)';
232
+ if (s === 'AAA') return 'mono-rhymed triplet';
233
+ }
234
+ if (n === 4) {
235
+ // Beat counts for the ballad gate: footCount (classical) or ictus count.
236
+ const beats = details.map((d, i) =>
237
+ d.footCount > 0 ? d.footCount : ictusProfile(d.scansion).ictuses);
238
+ const alt43 = beats.length === 4 && beats[0] === beats[2] && beats[1] === beats[3]
239
+ && beats[0] === beats[1] + 1;
240
+ if (s === 'ABAB' || s === 'ABCB') {
241
+ if (alt43) return `ballad stanza (${s}, ${beats[0]}·${beats[1]})`;
242
+ return s === 'ABAB' ? 'quatrain (cross-rhymed, ABAB)' : `ballad-rhyme quatrain (ABCB)`;
243
+ }
244
+ if (s === 'ABBA') return 'quatrain (envelope, ABBA)';
245
+ if (s === 'AABB') return 'quatrain (couplet pair, AABB)';
246
+ if (s === 'AAAA') return 'mono-rhymed quatrain';
247
+ }
248
+ if (n === 5 && s === 'AABBA') {
249
+ const ternary = meters.filter(m => m === 'anapestic' || m === 'amphibrachic' || m === 'dactylic').length >= 3;
250
+ return ternary ? 'limerick (AABBA, ternary)' : 'limerick rhyme (AABBA)';
251
+ }
252
+ if (n === 6 && s === 'ABABCC') return 'sextilla / Venus-and-Adonis stanza (ABABCC)';
253
+ if (n === 7 && s === 'ABABBCC') return 'rhyme royal (ABABBCC)';
254
+ if (n === 8 && (s === 'ABABABCC' || s === 'ABABABAB')) return `octave (${s})`;
255
+
256
+ // Unrhymed stanza: blank verse when iambic pentameter dominates.
257
+ const unrhymed = rhymes.every(r => r.letter === '·');
258
+ if (unrhymed && n >= 3) {
259
+ const iambicPenta = details.filter(d =>
260
+ (d.consensusMeter ?? d.meter) === 'iambic pentameter').length / n;
261
+ if (iambicPenta >= 0.6) return 'blank verse';
262
+ if (details.every(d => d.meterName === 'free verse') || details.some(d => d.rhythmNote)) return undefined; // rhythm layer already speaks
263
+ }
264
+ return undefined;
265
+ }
266
+
267
+ /** Poem-level form verdicts that span stanzas (sonnets, terza rima…). */
268
+ function poemForm(stanzas: { rhymes: LineRhyme[]; details: PhonologicalScansionDetail[] }[]): string | undefined {
269
+ const all = stanzas.flatMap(st => st.rhymes);
270
+ const n = all.length;
271
+ // Whole-poem scheme with stanza-local letters concatenated is NOT meaningful;
272
+ // re-detect across the full poem for sonnet/terza checks.
273
+ if (n === 14) {
274
+ const rs = detectScheme(all.map(r => r.endWord));
275
+ const s = canonicalScheme(rs);
276
+ if (/^ABABCDCDEFEFGG$/.test(s)) return 'Shakespearean Sonnet';
277
+ if (/^ABBAABBA/.test(s)) return 'Petrarchan Sonnet';
278
+ if (s.endsWith('GG') || /(..)\1*..$/.test(s)) {
279
+ // 14 iambic-pentameter lines with a closing couplet still reads sonnet-like.
280
+ const last2 = rs[12].letter !== '·' && rs[12].letter === rs[13].letter;
281
+ const iambicPenta = stanzas.flatMap(st => st.details)
282
+ .filter(d => (d.consensusMeter ?? d.meter) === 'iambic pentameter').length / 14;
283
+ if (last2 && iambicPenta >= 0.5) return 'sonnet (14 lines, closing couplet)';
284
+ }
285
+ }
286
+ // Terza rima: chained tercets ABA BCB CDC…
287
+ if (stanzas.length >= 3 && stanzas.every(st => st.rhymes.length === 3)) {
288
+ let chained = true;
289
+ for (let i = 0; i + 1 < stanzas.length && chained; i++) {
290
+ const mid = stanzas[i].rhymes[1].endWord;
291
+ const nxt = stanzas[i + 1].rhymes;
292
+ const p1 = classifyRhymePair(mid, nxt[0].endWord);
293
+ const p3 = classifyRhymePair(mid, nxt[2].endWord);
294
+ if (!(p1 && STRONG.has(p1.type)) || !(p3 && STRONG.has(p3.type))) chained = false;
295
+ }
296
+ if (chained) return 'terza rima (ABA BCB CDC…)';
297
+ }
298
+ return undefined;
299
+ }
300
+
301
+ /** Last syllable-bearing word of a line (across its merged sentences). */
302
+ function lineEndWord(line: LineResult): string {
303
+ const ws = line.sentence.words.filter(w => !isPunctuation(w.lexicalClass) && w.syllables.length > 0);
304
+ return ws.length ? ws[ws.length - 1].word : '';
305
+ }
306
+
307
+ /**
308
+ * Annotate rhyme letters/types (`detail.rhyme`) and form verdicts
309
+ * (`detail.formNote`) across a poem. Stanza forms are per-stanza; a poem-level
310
+ * form (sonnet, terza rima) overrides stanza notes. Annotation-only — no
311
+ * meter/scansion/certainty is touched.
312
+ */
313
+ export function applyRhymeAndForm(stanzas: LineResult[][]): void {
314
+ const analyzed = stanzas.map(lines => {
315
+ const details = lines.map(l => l.phonologicalScansion);
316
+ const rhymes = detectScheme(lines.map(lineEndWord));
317
+ return { details, rhymes };
318
+ });
319
+ for (const { details, rhymes } of analyzed) {
320
+ const form = stanzaForm(rhymes, details);
321
+ for (let i = 0; i < details.length; i++) {
322
+ details[i].rhyme = rhymes[i];
323
+ details[i].formNote = form;
324
+ }
325
+ }
326
+ const pform = poemForm(analyzed);
327
+ if (pform) for (const { details } of analyzed) for (const d of details) d.formNote = pform;
328
+ }