diff --git a/doc/SUMMARY.md b/doc/SUMMARY.md index c2d4586..633f953 100644 --- a/doc/SUMMARY.md +++ b/doc/SUMMARY.md @@ -6,6 +6,8 @@ - [Phonemes](./lang/phonemes.md) - [Phonotactics](./lang/phonotactics.md) - [Frequencies](./lang/frequencies.md) + - [Stress](./lang/stress.md) + - [Vowel Reduction](./lang/reduction.md) - [Grammar](./lang/grammar.md) - [Morphology](./lang/morphology.md) - [Voices](./voices/README.md) diff --git a/doc/lang/grammar.md b/doc/lang/grammar.md index dc4281d..adc6fff 100644 --- a/doc/lang/grammar.md +++ b/doc/lang/grammar.md @@ -86,71 +86,8 @@ that makes sentences feel like language rather than random sound. ## Stress Assignment -Generated words can receive IPA stress marks (ˈ for primary, ˌ for secondary) according to a configurable strategy. -Stress is assigned after syllable generation, so it works with both fresh generation and lexicon-based sampling. - -### Top-Level Configuration - -Add a `"stress"` field to your sketch to set the default strategy for all word classes: - -```json -{ - "stress": { - "default": "trochaic", - "secondary": true - } -} -``` - -`"default"` is the stress strategy applied to every word class that does not override it. -`"secondary"` enables secondary stress on alternating syllables in words with 3 or more syllables. -It defaults to `false` when omitted. - -### Strategies - -| Strategy | Primary stress position | Typical languages | -|:--------------|:---------------------------------|:------------------| -| `trochaic` | First syllable | English, German | -| `iambic` | Second syllable (first if mono) | French-ish | -| `penultimate` | Second-to-last syllable | Latin, Spanish | -| `final` | Last syllable | French, Turkish | -| `none` | No stress assigned | Function words | - -Monosyllabic words receive primary stress unless the strategy is `none`. - -### Per-Class Overrides - -Each word class can override the default strategy with its own `"stress"` field. -This is how you keep function words unstressed while content words carry stress: - -```json -{ - "stress": { "default": "trochaic", "secondary": true }, - "word_classes": { - "det": { "patterns": ["CV"], "stress": "none" }, - "noun": { "patterns": ["$OV$K", "$OV$K $OV$K"] }, - "verb": { "patterns": ["$OV$K"], "stress": "penultimate" } - } -} -``` - -Here, determiners get no stress, nouns inherit the top-level `trochaic` strategy, and verbs use `penultimate`. - -### Secondary Stress - -When `"secondary": true` and a word has 3 or more syllables, secondary stress (ˌ) is placed on alternating -even-indexed syllables that do not already carry primary stress. -For example, a four-syllable trochaic word: **ˈ**ta.ˌka.ta.ka. - -### Without Stress Configuration - -When no `"stress"` field is present at either level, no stress is assigned -- words are generated without stress marks. - -Note that omitting `"stress"` is distinct from setting `"stress": "none"`. -Both produce unstressed words, but `"none"` is meaningful as a per-class override: it keeps specific word classes -unstressed when a top-level default would otherwise give them stress. -This is common for function words; for example, in English, articles like "a" `/ə/` and "the" `/ðə/` and prepositions -like "to" `/tu/` and "of" `/ʌv/` carry no stress. +The top-level `"stress"` field and per-class `"stress"` overrides (like `"stress": "none"` in the example above) +are documented on the [Stress](stress.md) page. ## Sentence Templates diff --git a/doc/lang/morphology.md b/doc/lang/morphology.md index 4b4d03b..f892832 100644 --- a/doc/lang/morphology.md +++ b/doc/lang/morphology.md @@ -59,7 +59,7 @@ Add a `morphology` section to your config JSON: ### Application Order -Morphology runs **after** word generation but **before** stress assignment. +Morphology runs **after** word generation but **before** [stress assignment](stress.md). This means stress is computed over the full derived form including affixes, matching how natural languages work (e.g., English "PHOto" vs "phoTOgraphy"). ### Decay diff --git a/doc/lang/reduction.md b/doc/lang/reduction.md new file mode 100644 index 0000000..d3acbc6 --- /dev/null +++ b/doc/lang/reduction.md @@ -0,0 +1,106 @@ +# Vowel Reduction + +Vowel reduction collapses the vowels of unstressed syllables to a weaker, more central vowel (e.g.: +schwa `ə`). +This is characteristic of stress-timed languages: English pronounces `banana` as /bəˈnænə/ rather +than /baˈnana/, Russian merges unstressed /o/ and /a/ (*молоко* /məlɐˈko/), European Portuguese +reduces unstressed vowels so far they often nearly disappear, and Central Catalan collapses +unstressed /a/, /e/, and /ɛ/ to [ə]. +Syllable-timed languages like Spanish, standard Italian, and Japanese instead keep full vowel +quality everywhere; leave reduction off for languages with that rhythm. + +Natural languages vary in what unstressed vowels reduce *to* (Russian [ɐ], European Portuguese [ɨ], +Catalan [u] for back vowels) and in which vowels reduce at all. +The `target` and `targets` fields configure both aspects. + +## Configuration + +Add a `reduction` section to your config JSON: + +```json +{ + "reduction": { "probability": 0.7, "target": "ə" } +} +``` + +### Fields + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `probability` | number | Yes | Probability that an unstressed syllable's vowel reduces, between 0.0 and 1.0. | +| `target` | string | One of `target`/`targets` | Single vowel that every reduced vowel collapses to. | +| `targets` | object | One of `target`/`targets` | Map from source vowels to the vowel they reduce to. Only mapped vowels reduce. | + +Exactly one of `target` and `targets` must be present. +If the `reduction` section is absent, no reduction occurs. + +### Targets + +`target` is the simple form: every vowel in a reduced syllable collapses to that one vowel, as in +the English-style example above. + +`targets` gives per-vowel control. +Each key is a string of one or more IPA vowels; the value is the single vowel they reduce to. +Only the listed vowels reduce — everything else keeps its quality even in unstressed syllables. +A vowel may appear in at most one key. + +A Central Catalan–style system, where /a e ɛ/ merge to [ə] and /o ɔ/ raise to [u] while the high +vowels /i u/ never reduce: + +```json +{ + "reduction": { + "probability": 1.0, + "targets": { "aeɛ": "ə", "oɔ": "u" } + } +} +``` + +A Russian-style *akanye*, merging unstressed /o/ and /a/ into [ɐ]: + +```json +{ + "reduction": { + "probability": 0.9, + "targets": { "oa": "ɐ" } + } +} +``` + +### Interaction with Stress + +Reduction targets whatever syllables are left unstressed by [stress assignment](./stress.md), so +which syllables are eligible is determined by the `stress` configuration: + +- Syllables carrying primary stress never reduce. + This includes monosyllabic words, which always receive primary stress (unless the strategy is + `none`). +- A class with `"stress": "none"` has every syllable eligible, so the word can reduce entirely. +- With `"secondary": true`, alternating syllables carry secondary stress and are also protected, + halving the eligible syllables in longer words. + +Because of this dependency, a sketch with a `reduction` section but no stress configuration at all +(no top-level `stress` and no per-class `"stress"` fields) is rejected as a validation error. +Without stress, every vowel is a candidate for reduction, which leads to an undirected mush of +whatever your target is. +If you still want this, you can explicitly write `"stress": "none"` at the top-level or per-class. + +## Behavior + +- Reduction runs after [stress assignment](./stress.md), so it depends on the final stress marking + of each word. +- Only fully unstressed syllables are eligible. + Syllables with primary or secondary stress never reduce. + Secondary stress protects vowel quality, as it does in English (compare the final syllables of + `ˈal.pha.ˌbet` and `ˈtrum.pet`). +- Word classes with `"stress": "none"` have every syllable unstressed, so function-word classes + (determiners, pronouns, prepositions) reduce throughout — giving the "the → ðə, of → əv" effect. +- The whole vocalic nucleus reduces: diacritics on the vowel are dropped, and a diphthong collapses + to a single vowel. + With `targets`, a diphthong is keyed by its first component (so with `"aeɛ": "ə"`, /ai/ collapses + to [ə] but /ui/ is untouched). + Consonants in the onset and coda are untouched. +- Reduction applies to a word's lexicon entry when the vocabulary is pre-generated, so a reduced + word form is stable across a session. +- A reduction target may appear in output even if it is not in the configured vowel inventory; + targets are outcomes of reduction, not inventory phonemes. diff --git a/doc/lang/stress.md b/doc/lang/stress.md new file mode 100644 index 0000000..3a3074f --- /dev/null +++ b/doc/lang/stress.md @@ -0,0 +1,73 @@ +# Stress + +Generated words can receive IPA stress marks (ˈ for primary, ˌ for secondary) according to a configurable strategy. +Stress is assigned after syllable generation, so it works with both fresh generation and lexicon-based sampling. +When [morphology](morphology.md) is configured, affixation runs first, so stress is computed over the full derived form. +Syllables left unstressed are eligible for [vowel reduction](reduction.md). + +## Top-Level Configuration + +Add a `"stress"` field to your sketch to set the default strategy for all word classes: + +```json +{ + "stress": { + "default": "trochaic", + "secondary": true + } +} +``` + +`"default"` is the stress strategy applied to every word class that does not override it. +`"secondary"` enables secondary stress on alternating syllables in words with 3 or more syllables. +It defaults to `false` when omitted. + +## Strategies + +| Strategy | Primary stress position | Typical languages | +|:--------------|:---------------------------------|:------------------| +| `trochaic` | First syllable | English, German | +| `iambic` | Second syllable (first if mono) | French-ish | +| `penultimate` | Second-to-last syllable | Latin, Spanish | +| `final` | Last syllable | French, Turkish | +| `none` | No stress assigned | Function words | + +Monosyllabic words receive primary stress unless the strategy is `none`. + +## Per-Class Overrides + +Each [word class](grammar.md#word-classes) can override the default strategy with its own `"stress"` field. +This is how you keep function words unstressed while content words carry stress: + +```json +{ + "stress": { "default": "trochaic", "secondary": true }, + "word_classes": { + "det": { "patterns": ["CV"], "stress": "none" }, + "noun": { "patterns": ["$OV$K", "$OV$K $OV$K"] }, + "verb": { "patterns": ["$OV$K"], "stress": "penultimate" } + } +} +``` + +Here, determiners get no stress, nouns inherit the top-level `trochaic` strategy, and verbs use `penultimate`. + +## Secondary Stress + +When `"secondary": true` and a word has 3 or more syllables, secondary stress (ˌ) is placed on alternating +syllables aligned with the primary stress, so the word parses into uniform two-syllable feet and +secondary stress never lands adjacent to the primary. + +- `trochaic` builds strong-weak feet from the start of the word: ˈta.ka.ˌta.ka +- `iambic` builds weak-strong feet: ta.ˈta.ta.ˌta +- `penultimate` and `final` alternate outward from the primary: ta.ˌta.ta.ˈta.ta and ta.ˌta.ta.ˈta + +## Without Stress Configuration + +When no `"stress"` field is present at either level, no stress is assigned -- words are generated without stress marks. + +Note that omitting `"stress"` is distinct from setting `"stress": "none"`. +Both produce unstressed words, but `"none"` is meaningful as a per-class override: it keeps specific word classes +unstressed when a top-level default would otherwise give them stress. +This is common for function words; for example, in English, articles like "a" `/ə/` and "the" `/ðə/` and prepositions +like "to" `/tu/` and "of" `/ʌv/` carry no stress. diff --git a/examples/english.json b/examples/english.json index 81339f2..d0beaaa 100644 --- a/examples/english.json +++ b/examples/english.json @@ -8,6 +8,7 @@ "distribution": { "curve": "cosine" } }, "stress": { "default": "trochaic", "secondary": true }, + "reduction": { "probability": 0.7, "target": "ə" }, "sets": { "O": ["n", "t", "s", "ɹ", "d", "l", "k", "m", "p", "f", "b", "h", "ʃ", "g", "j", "θ", "st", "sp", "sk", "tɹ", "pɹ", "pl", "bl", "kl"], "K": ["n", "t", "s", "ɹ", "d", "l", "ð", "k", "z", "m", "p", "v", "f", "ŋ", "ʃ", "θ", "nd", "nt", "ns", "ks", "lz", "lk"] diff --git a/src/generate.rs b/src/generate.rs index dbd75ad..40b2a4a 100644 --- a/src/generate.rs +++ b/src/generate.rs @@ -426,8 +426,11 @@ pub fn assign_stress( let syl = word.remove(primary_idx).with_stress(phone::Stress::Primary); word.insert(primary_idx, syl); + // Secondary stress falls on alternating syllables sharing the primary's + // parity, so the word parses into uniform two-syllable feet (trochaic: + // ˈta.ka.ˌta.ka; iambic: ta.ˈta.ta.ˌta) and never clashes with the primary. if secondary && word.len() >= 3 { - for i in (0..word.len()).step_by(2) { + for i in (primary_idx % 2..word.len()).step_by(2) { if i != primary_idx && word[i].stress() == phone::Stress::None { let syl = word.remove(i).with_stress(phone::Stress::Secondary); word.insert(i, syl); @@ -436,6 +439,24 @@ pub fn assign_stress( } } +/// Reduce vowels in unstressed syllables with the configured probability and +/// targets. Must run after `assign_stress` so stress marking is final; +/// secondary-stressed syllables are never reduced. +pub fn apply_reduction( + word: &mut SmallVec<[phone::Syllable; 4]>, + reduction: &sketch::ResolvedReduction, + rng: &mut impl Rng, +) { + if reduction.probability <= 0.0 { + return; + } + for syl in word.iter_mut() { + if syl.stress() == phone::Stress::None && rng.random::() < reduction.probability { + syl.reduce_vowels(|v| reduction.targets.target_for(v)); + } + } +} + /// A single resolved affix with its parsed generator and probability. pub struct AffixGenerator { generator: WordGenerator, @@ -503,6 +524,7 @@ pub struct Lexicon { impl Lexicon { /// Pre-generate `size` distinct words using the given patterns and optional pattern weights. + #[allow(clippy::too_many_arguments)] pub fn new( size: usize, patterns: &[WordGenerator], @@ -510,6 +532,7 @@ impl Lexicon { morphology: Option<&ClassMorphology>, stress: Option, secondary_stress: bool, + reduction: &sketch::ResolvedReduction, rng: &mut impl Rng, ) -> Self { let mut words = Vec::with_capacity(size); @@ -522,6 +545,7 @@ impl Lexicon { if let Some(strategy) = stress { assign_stress(&mut word, strategy, secondary_stress); } + apply_reduction(&mut word, reduction, rng); words.push(word); } // Zipfian weights: rank r (1-based) gets weight proportional to 1/r. @@ -635,6 +659,7 @@ pub struct ClassGenerator { morphology: Option, stress: Option, secondary_stress: bool, + reduction: sketch::ResolvedReduction, } impl ClassGenerator { @@ -644,6 +669,7 @@ impl ClassGenerator { morphology: Option, stress: Option, secondary_stress: bool, + reduction: sketch::ResolvedReduction, ) -> Self { Self { patterns, @@ -652,6 +678,7 @@ impl ClassGenerator { morphology, stress, secondary_stress, + reduction, } } @@ -664,6 +691,7 @@ impl ClassGenerator { self.morphology.as_ref(), self.stress, self.secondary_stress, + &self.reduction, rng, )); self @@ -687,6 +715,7 @@ impl ClassGenerator { if let Some(strategy) = self.stress { assign_stress(&mut word, strategy, self.secondary_stress); } + apply_reduction(&mut word, &self.reduction, rng); (None, word) } } @@ -1159,6 +1188,7 @@ mod gen_tests { None, None, false, + sketch::ResolvedReduction::default(), )]; let sg = UnstructuredSentenceGenerator::new(classes, 4, 4); let mut rng = rand::rng(); @@ -1177,6 +1207,7 @@ mod gen_tests { None, None, false, + sketch::ResolvedReduction::default(), )]; let sg = UnstructuredSentenceGenerator::new(classes, 3, 5); let mut rng = rand::rng(); @@ -1203,6 +1234,7 @@ mod gen_tests { None, None, false, + sketch::ResolvedReduction::default(), ), ClassGenerator::new( vec![WordGenerator::parse("CVC CV", &ctx(&inventory)).unwrap()], @@ -1210,6 +1242,7 @@ mod gen_tests { None, None, false, + sketch::ResolvedReduction::default(), ), ]; let sg = UnstructuredSentenceGenerator::new(classes, 5, 5); @@ -1234,7 +1267,7 @@ mod gen_tests { fn class_generator_produces_words() { let inventory = phone::Inventory::with_everything(); let patterns = vec![WordGenerator::parse("CVC", &ctx(&inventory)).unwrap()]; - let cg = ClassGenerator::new(patterns, None, None, None, false); + let cg = ClassGenerator::new(patterns, None, None, None, false, sketch::ResolvedReduction::default()); let mut rng = rand::rng(); for _ in 0..50 { let (prev, word) = cg.generate(None, &mut rng); @@ -1248,7 +1281,7 @@ mod gen_tests { let inventory = phone::Inventory::with_everything(); let patterns = vec![WordGenerator::parse("CV", &ctx(&inventory)).unwrap()]; let mut rng = rand::rng(); - let cg = ClassGenerator::new(patterns, None, None, None, false).with_lexicon(5, &mut rng); + let cg = ClassGenerator::new(patterns, None, None, None, false, sketch::ResolvedReduction::default()).with_lexicon(5, &mut rng); let mut all_words = Vec::new(); for _ in 0..100 { let (prev, word) = cg.generate(None, &mut rng); @@ -1274,6 +1307,7 @@ mod gen_tests { None, None, false, + sketch::ResolvedReduction::default(), ), ); classes.insert( @@ -1284,6 +1318,7 @@ mod gen_tests { None, None, false, + sketch::ResolvedReduction::default(), ), ); let templates = vec![vec!["det".to_string(), "noun".to_string()]]; @@ -1307,6 +1342,7 @@ mod gen_tests { None, None, false, + sketch::ResolvedReduction::default(), ), ); classes.insert( @@ -1317,6 +1353,7 @@ mod gen_tests { None, None, false, + sketch::ResolvedReduction::default(), ), ); let templates = vec![ @@ -1354,6 +1391,7 @@ mod gen_tests { None, None, false, + sketch::ResolvedReduction::default(), ) .with_lexicon(3, &mut rng), ); @@ -1365,6 +1403,7 @@ mod gen_tests { None, None, false, + sketch::ResolvedReduction::default(), ) .with_lexicon(10, &mut rng), ); @@ -1464,11 +1503,31 @@ mod gen_tests { fn assign_stress_secondary_penultimate() { let mut word = make_syllables(5); assign_stress(&mut word, sketch::StressStrategy::Penultimate, true); - assert_eq!(word[0].stress(), phone::Stress::Secondary); - assert_eq!(word[1].stress(), phone::Stress::None); - assert_eq!(word[2].stress(), phone::Stress::Secondary); + assert_eq!(word[0].stress(), phone::Stress::None); + assert_eq!(word[1].stress(), phone::Stress::Secondary); + assert_eq!(word[2].stress(), phone::Stress::None); + assert_eq!(word[3].stress(), phone::Stress::Primary); + assert_eq!(word[4].stress(), phone::Stress::None); + } + + #[test] + fn assign_stress_secondary_iambic() { + let mut word = make_syllables(4); + assign_stress(&mut word, sketch::StressStrategy::Iambic, true); + assert_eq!(word[0].stress(), phone::Stress::None); + assert_eq!(word[1].stress(), phone::Stress::Primary); + assert_eq!(word[2].stress(), phone::Stress::None); + assert_eq!(word[3].stress(), phone::Stress::Secondary); + } + + #[test] + fn assign_stress_secondary_final() { + let mut word = make_syllables(4); + assign_stress(&mut word, sketch::StressStrategy::Final, true); + assert_eq!(word[0].stress(), phone::Stress::None); + assert_eq!(word[1].stress(), phone::Stress::Secondary); + assert_eq!(word[2].stress(), phone::Stress::None); assert_eq!(word[3].stress(), phone::Stress::Primary); - assert_eq!(word[4].stress(), phone::Stress::Secondary); } #[test] @@ -1485,6 +1544,112 @@ mod gen_tests { assert_eq!(format_word(&word), "ˈta.ta"); } + fn reduction(probability: f64) -> sketch::ResolvedReduction { + sketch::ResolvedReduction { + probability, + targets: sketch::ReductionTargets::All(phone::Vowel::Schwa), + } + } + + #[test] + fn apply_reduction_unstressed_only() { + let mut rng = rand::rng(); + let mut word = make_syllables(2); + assign_stress(&mut word, sketch::StressStrategy::Trochaic, false); + apply_reduction(&mut word, &reduction(1.0), &mut rng); + assert_eq!(format_word(&word), "ˈta.tə"); + } + + #[test] + fn apply_reduction_zero_probability_is_noop() { + let mut rng = rand::rng(); + let mut word = make_syllables(2); + assign_stress(&mut word, sketch::StressStrategy::Trochaic, false); + apply_reduction(&mut word, &reduction(0.0), &mut rng); + assert_eq!(format_word(&word), "ˈta.ta"); + } + + #[test] + fn apply_reduction_fully_unstressed_word() { + let mut rng = rand::rng(); + let mut word = make_syllables(3); + assign_stress(&mut word, sketch::StressStrategy::None, false); + apply_reduction(&mut word, &reduction(1.0), &mut rng); + assert_eq!(format_word(&word), "tə.tə.tə"); + } + + #[test] + fn apply_reduction_protects_secondary_stress() { + let mut rng = rand::rng(); + let mut word = make_syllables(4); + assign_stress(&mut word, sketch::StressStrategy::Trochaic, true); + apply_reduction(&mut word, &reduction(1.0), &mut rng); + assert_eq!(format_word(&word), "ˈta.tə.ˌta.tə"); + } + + #[test] + fn apply_reduction_collapses_diphthong() { + let mut rng = rand::rng(); + let mut word: SmallVec<[phone::Syllable; 4]> = smallvec![phone::Syllable::new(&[ + phone::Segment::from(phone::Consonant::T), + phone::Segment::compound( + phone::Phoneme::Vowel(phone::Vowel::A), + phone::Phoneme::Vowel(phone::Vowel::I), + ), + ])]; + apply_reduction(&mut word, &reduction(1.0), &mut rng); + assert_eq!( + word[0].segments()[1], + phone::Segment::from(phone::Vowel::Schwa) + ); + } + + #[test] + fn apply_reduction_targets_only_mapped_vowels() { + let mut rng = rand::rng(); + // /ta.ti/, fully unstressed: with targets {a → ɐ}, /a/ reduces and /i/ is untouched. + let mut word: SmallVec<[phone::Syllable; 4]> = smallvec![ + phone::Syllable::new(&[ + phone::Segment::from(phone::Consonant::T), + phone::Segment::from(phone::Vowel::A), + ]), + phone::Syllable::new(&[ + phone::Segment::from(phone::Consonant::T), + phone::Segment::from(phone::Vowel::I), + ]), + ]; + let red = sketch::ResolvedReduction { + probability: 1.0, + targets: sketch::ReductionTargets::Map(HashMap::from([( + phone::Vowel::A, + phone::Vowel::AFlip, + )])), + }; + apply_reduction(&mut word, &red, &mut rng); + assert_eq!(format_word(&word), "tɐ.ti"); + } + + #[test] + fn apply_reduction_targets_key_diphthong_by_first_component() { + let mut rng = rand::rng(); + let mut word: SmallVec<[phone::Syllable; 4]> = smallvec![phone::Syllable::new(&[ + phone::Segment::from(phone::Consonant::T), + phone::Segment::compound( + phone::Phoneme::Vowel(phone::Vowel::A), + phone::Phoneme::Vowel(phone::Vowel::I), + ), + ])]; + let red = sketch::ResolvedReduction { + probability: 1.0, + targets: sketch::ReductionTargets::Map(HashMap::from([( + phone::Vowel::A, + phone::Vowel::U, + )])), + }; + apply_reduction(&mut word, &red, &mut rng); + assert_eq!(word[0].segments()[1], phone::Segment::from(phone::Vowel::U)); + } + fn make_morphology( inventory: &phone::Inventory, decay: f64, @@ -1573,6 +1738,7 @@ mod gen_tests { Some(morph), Some(sketch::StressStrategy::Final), false, + sketch::ResolvedReduction::default(), ); let mut rng = rand::rng(); for _ in 0..50 { diff --git a/src/main.rs b/src/main.rs index 4e0204d..20815a5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -141,6 +141,7 @@ impl PhonemeArgs { lexicon: None, stress: None, secondary_stress: false, + reduction: sketch::ResolvedReduction::default(), }, ); Ok(ResolvedArgs { @@ -664,6 +665,7 @@ async fn main() -> anyhow::Result<()> { morph, wc.stress, wc.secondary_stress, + wc.reduction, ); if let Some(lex) = wc.lexicon { cg = cg.with_lexicon(lex.size, &mut rng); @@ -739,6 +741,7 @@ async fn main() -> anyhow::Result<()> { morph, wc.stress, wc.secondary_stress, + wc.reduction, ); if let Some(lex) = wc.lexicon { cg = cg.with_lexicon(lex.size, rng); diff --git a/src/phone.rs b/src/phone.rs index e5f7b16..a03b8bc 100644 --- a/src/phone.rs +++ b/src/phone.rs @@ -1535,6 +1535,20 @@ impl Syllable { &self.tone } + /// Reduce vocalic segments in this syllable, leaving consonants (onset/coda) + /// untouched. `target` maps each vowel to what it reduces to, or `None` to + /// leave it alone. Diacritics on a reduced vowel are dropped; a diphthong is + /// keyed by its first component and collapses to a single vowel. + pub fn reduce_vowels(&mut self, target: impl Fn(Vowel) -> Option) { + for seg in self.inner.iter_mut() { + if let Phoneme::Vowel(v) = seg.base() + && let Some(t) = target(v) + { + *seg = Segment::from(t); + } + } + } + /// Parse an IPA string into a syllable, handling stress marks, segments with /// diacritics/tie bars, and trailing tone letters. pub fn parse_ipa(input: &str) -> Result { diff --git a/src/sketch.rs b/src/sketch.rs index 85ef6d0..ea07136 100644 --- a/src/sketch.rs +++ b/src/sketch.rs @@ -61,6 +61,56 @@ pub struct StressConfig { pub secondary: bool, } +/// Configuration for unstressed-vowel reduction. +#[derive(Debug, Deserialize)] +pub struct ReductionConfig { + /// Probability (0.0–1.0) an unstressed syllable's vowel reduces. + pub probability: f64, + /// Single vowel every reduced vowel collapses to. Mutually exclusive with + /// `targets`; exactly one of the two is required. + pub target: Option, + /// Map from source vowels (each key a string of IPA vowels) to the single + /// vowel they reduce to. Mutually exclusive with `target`. + pub targets: Option>, +} + +/// What reduced vowels turn into. +#[derive(Debug, Clone)] +pub enum ReductionTargets { + /// Every vowel reduces to this single target. + All(phone::Vowel), + /// Only mapped vowels reduce, each to its mapped target. + Map(HashMap), +} + +impl ReductionTargets { + /// The vowel `v` reduces to, or `None` if `v` does not reduce. + pub fn target_for(&self, v: phone::Vowel) -> Option { + match self { + Self::All(t) => Some(*t), + Self::Map(map) => map.get(&v).copied(), + } + } +} + +/// Resolved vowel-reduction settings. +#[derive(Debug, Clone)] +pub struct ResolvedReduction { + /// Probability (0.0–1.0) an unstressed syllable's vowel reduces. + pub probability: f64, + pub targets: ReductionTargets, +} + +impl Default for ResolvedReduction { + /// Reduction disabled: zero probability (the target is never consulted). + fn default() -> Self { + Self { + probability: 0.0, + targets: ReductionTargets::All(phone::Vowel::Schwa), + } + } +} + /// Configuration for morphological affixation. #[derive(Debug, Deserialize)] pub struct MorphologyConfig { @@ -113,6 +163,7 @@ pub struct Sketch { pub sentence: Option, pub grammar: Option>, pub stress: Option, + pub reduction: Option, pub morphology: Option, } @@ -150,6 +201,7 @@ pub struct ResolvedWordClass { pub lexicon: Option, pub stress: Option, pub secondary_stress: bool, + pub reduction: ResolvedReduction, } /// A resolved affix ready for generation. @@ -237,6 +289,75 @@ impl Sketch { let default_strategy = self.stress.as_ref().map(|s| s.default); let default_secondary = self.stress.as_ref().is_some_and(|s| s.secondary); + // Resolve vowel reduction (absent = feature off). + let reduction = match self.reduction { + None => ResolvedReduction::default(), + Some(rc) => { + // Reduction targets unstressed syllables, so without any stress + // configuration every syllable would reduce — reject the likely + // mistake. An explicit "none" (top-level or per-class) is allowed. + if self.stress.is_none() && self.word_classes.values().all(|wc| wc.stress.is_none()) + { + return Err(SketchError::Validation( + "reduction requires stress assignment; add a top-level stress section \ + or per-class stress fields" + .into(), + )); + } + if !(0.0..=1.0).contains(&rc.probability) { + return Err(SketchError::Validation( + "reduction.probability must be between 0.0 and 1.0".into(), + )); + } + let parse_target = |target: &str| -> Result { + let mut chars = target.chars(); + match (chars.next(), chars.next()) { + (Some(c), None) => Ok(phone::Vowel::try_from(c)?), + _ => Err(SketchError::Validation(format!( + "reduction target must be a single vowel, got {target:?}" + ))), + } + }; + let targets = match (rc.target, rc.targets) { + (Some(_), Some(_)) => { + return Err(SketchError::Validation( + "reduction.target and reduction.targets are mutually exclusive".into(), + )); + } + (None, None) => { + return Err(SketchError::Validation( + "reduction requires either a target or a targets map".into(), + )); + } + (Some(t), None) => ReductionTargets::All(parse_target(&t)?), + (None, Some(raw)) => { + let mut map = HashMap::new(); + for (sources, target) in raw { + let t = parse_target(&target)?; + if sources.is_empty() { + return Err(SketchError::Validation( + "reduction.targets keys must not be empty".into(), + )); + } + for ch in sources.chars() { + let v = phone::Vowel::try_from(ch)?; + if map.insert(v, t).is_some() { + return Err(SketchError::Validation(format!( + "vowel {ch:?} appears in multiple reduction targets" + ))); + } + } + } + ReductionTargets::Map(map) + } + }; + ResolvedReduction { + probability: rc.probability, + targets, + } + } + }; + // Resolve word classes. let mut resolved_classes = HashMap::new(); for (name, config) in self.word_classes { @@ -256,6 +377,7 @@ impl Sketch { lexicon, stress, secondary_stress: default_secondary, + reduction: reduction.clone(), }, ); } @@ -987,6 +1109,156 @@ mod tests { assert!(Sketch::load(json).is_err()); } + #[test] + fn reduction_invalid_probability_error() { + let json = r#"{ + "consonants": "ptk", + "vowels": "aiu", + "word_classes": { "word": { "patterns": ["CVC"] } }, + "stress": { "default": "trochaic" }, + "reduction": { "probability": 1.5, "target": "ə" } + }"#; + assert!(Sketch::load(json).is_err()); + } + + #[test] + fn reduction_resolves_onto_word_classes() { + let json = r#"{ + "consonants": "ptk", + "vowels": "aiu", + "word_classes": { "word": { "patterns": ["CVC"] } }, + "stress": { "default": "trochaic" }, + "reduction": { "probability": 0.7, "target": "ə" } + }"#; + let resolved = Sketch::load(json).unwrap(); + let reduction = &resolved.word_classes["word"].reduction; + assert_eq!(reduction.probability, 0.7); + assert!(matches!( + reduction.targets, + ReductionTargets::All(phone::Vowel::Schwa) + )); + } + + #[test] + fn reduction_without_stress_error() { + let json = r#"{ + "consonants": "ptk", + "vowels": "aiu", + "word_classes": { "word": { "patterns": ["CVC"] } }, + "reduction": { "probability": 0.7, "target": "ə" } + }"#; + assert!(Sketch::load(json).is_err()); + } + + #[test] + fn reduction_with_only_per_class_stress_ok() { + let json = r#"{ + "consonants": "ptk", + "vowels": "aiu", + "word_classes": { + "word": { "patterns": ["CVC"], "stress": "trochaic" }, + "det": { "patterns": ["CV"] } + }, + "reduction": { "probability": 0.7, "target": "ə" } + }"#; + assert!(Sketch::load(json).is_ok()); + } + + #[test] + fn reduction_without_target_error() { + let json = r#"{ + "consonants": "ptk", + "vowels": "aiu", + "word_classes": { "word": { "patterns": ["CVC"] } }, + "stress": { "default": "trochaic" }, + "reduction": { "probability": 0.7 } + }"#; + assert!(Sketch::load(json).is_err()); + } + + #[test] + fn reduction_target_and_targets_error() { + let json = r#"{ + "consonants": "ptk", + "vowels": "aiu", + "word_classes": { "word": { "patterns": ["CVC"] } }, + "stress": { "default": "trochaic" }, + "reduction": { "probability": 0.7, "target": "ə", "targets": { "a": "ə" } } + }"#; + assert!(Sketch::load(json).is_err()); + } + + #[test] + fn reduction_absent_defaults_to_zero() { + let json = r#"{ + "consonants": "ptk", + "vowels": "aiu", + "word_classes": { "word": { "patterns": ["CVC"] } } + }"#; + let resolved = Sketch::load(json).unwrap(); + assert_eq!(resolved.word_classes["word"].reduction.probability, 0.0); + } + + #[test] + fn reduction_targets_resolve() { + let json = r#"{ + "consonants": "ptk", + "vowels": "aeɛou", + "word_classes": { "word": { "patterns": ["CVC"] } }, + "stress": { "default": "trochaic" }, + "reduction": { + "probability": 0.8, + "targets": { "aeɛ": "ə", "o": "u" } + } + }"#; + let resolved = Sketch::load(json).unwrap(); + let ReductionTargets::Map(ref targets) = resolved.word_classes["word"].reduction.targets + else { + panic!("expected a targets map"); + }; + assert_eq!(targets.len(), 4); + assert_eq!(targets[&phone::Vowel::A], phone::Vowel::Schwa); + assert_eq!(targets[&phone::Vowel::EOpen], phone::Vowel::Schwa); + assert_eq!(targets[&phone::Vowel::O], phone::Vowel::U); + assert!(!targets.contains_key(&phone::Vowel::U)); + } + + #[test] + fn reduction_target_not_single_vowel_error() { + let json = r#"{ + "consonants": "ptk", + "vowels": "aiu", + "word_classes": { "word": { "patterns": ["CVC"] } }, + "stress": { "default": "trochaic" }, + "reduction": { "probability": 0.5, "targets": { "a": "əu" } } + }"#; + assert!(Sketch::load(json).is_err()); + } + + #[test] + fn reduction_target_consonant_error() { + let json = r#"{ + "consonants": "ptk", + "vowels": "aiu", + "word_classes": { "word": { "patterns": ["CVC"] } }, + "stress": { "default": "trochaic" }, + "reduction": { "probability": 0.5, "targets": { "t": "ə" } } + }"#; + assert!(Sketch::load(json).is_err()); + } + + #[test] + fn reduction_duplicate_source_vowel_error() { + let json = r#"{ + "consonants": "ptk", + "vowels": "aiu", + "word_classes": { "word": { "patterns": ["CVC"] } }, + "stress": { "default": "trochaic" }, + "reduction": { "probability": 0.5, "targets": { "ai": "ə", "au": "u" } } + }"#; + assert!(Sketch::load(json).is_err()); + } + #[test] fn morphology_unknown_class_error() { let json = r#"{