Phone inventory, segmenter and weksa lower --phones (the Weksa->AquaSynth phones line) #16

Merged
weksa merged 4 commits from weksa-lowering/phones-line into main 2026-11-09 10:15:51 +00:00
8 changed files with 419 additions and 5 deletions

View file

@ -9,6 +9,7 @@ pub mod form;
pub mod inventory;
pub mod lower;
pub mod packet;
pub mod phones;
pub mod schema;
pub mod score;
pub mod split;
@ -22,7 +23,8 @@ pub use inventory::{
Change, Family, InvId, Inventory, InventoryEntry, InventoryRevision, OptionLabel, OptionSet,
Slot, ValueKind,
};
pub use lower::{EntityForms, LowerError, TARGET_LANGUAGE, lower, pronounce};
pub use lower::{EntityForms, LowerError, PhoneLine, TARGET_LANGUAGE, lower, phones, pronounce};
pub use phones::{Phone, PhoneInventory, SegmentError, Stress};
pub use packet::{
EncoderId, EntityId, Grade, NodeId, Packet, PacketId, PacketKind, SCHEMA_VERSION, Stance,
};

View file

@ -31,9 +31,12 @@
use std::fmt;
use serde::Serialize;
use crate::entity::Entity;
use crate::inventory::{Family, InvId, Inventory};
use crate::packet::{EntityId, NodeId, Predication};
use crate::phones::{Phone, PhoneInventory, SegmentError};
use crate::store::EntityStore;
use crate::validate::LoweringInput;
@ -112,6 +115,13 @@ pub enum LowerError {
NoPronunciation { entity: EntityId },
/// IPA was requested for a packet that cites no entity.
NoEntityReferent,
/// Phones were requested for an entity whose ruled IPA holds a symbol the
/// language's phone inventory does not list (or the language has no
/// inventory). The lowerer does not guess a phone.
UnknownPhone { entity: EntityId, at: usize, text: String },
/// Phones were requested for an entity whose ruled IPA has a stress mark
/// with no vowel after it.
DanglingStress { entity: EntityId, at: usize },
/// A request predication the packet does not carry the addressee's consent
/// to: no consent, or a consent that names another predication. The
/// lowerer does not guess who asked.
@ -271,13 +281,54 @@ pub fn pronounce(
input: &LoweringInput,
entities: &dyn EntityForms,
) -> Result<Vec<String>, LowerError> {
Ok(ruled(input, entities)?.into_iter().map(|(_, ipa)| ipa).collect())
}
/// One entity's ruled pronunciation as the phones line a sound consumer reads:
/// the language tag, the stored IPA it came from, and the segmented phones.
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct PhoneLine {
pub language: &'static str,
pub source: String,
pub phones: Vec<Phone>,
}
/// The phones line of every entity a packet cites, in [`pronounce`] order, each
/// segmented by [`TARGET_LANGUAGE`]'s phone inventory. A symbol the inventory
/// does not list refuses the whole call.
pub fn phones(
input: &LoweringInput,
entities: &dyn EntityForms,
) -> Result<Vec<PhoneLine>, LowerError> {
let inventory = PhoneInventory::for_language(TARGET_LANGUAGE);
ruled(input, entities)?
.into_iter()
.map(|(entity, source)| {
let unknown = |at, text| LowerError::UnknownPhone { entity: entity.clone(), at, text };
let inventory = inventory.ok_or_else(|| unknown(0, source.clone()))?;
let phones = inventory.segment(&source).map_err(|e| match e {
SegmentError::UnknownPhone { at, text } => unknown(at, text),
SegmentError::DanglingStress { at } => {
LowerError::DanglingStress { entity: entity.clone(), at }
}
})?;
Ok(PhoneLine { language: TARGET_LANGUAGE, source, phones })
})
.collect()
}
/// `(entity, ruled IPA)` for each entity referent, in referent order.
fn ruled(
input: &LoweringInput,
entities: &dyn EntityForms,
) -> Result<Vec<(EntityId, String)>, LowerError> {
let mut out = Vec::new();
for id in input.referents().iter().filter_map(|r| r.entity.as_ref()) {
let entity = entity(entities, id)?;
let p = entity
.pronunciation_in(TARGET_LANGUAGE)
.ok_or_else(|| LowerError::NoPronunciation { entity: id.clone() })?;
out.push(p.ipa.clone());
out.push((id.clone(), p.ipa.clone()));
}
if out.is_empty() {
return Err(LowerError::NoEntityReferent);

View file

@ -53,8 +53,13 @@ enum Command {
key: String,
/// Print the ruled IPA of each entity the packet cites, one per line,
/// instead of the text; refused for an entity with no ruled pronunciation.
#[arg(long)]
#[arg(long, conflicts_with = "phones")]
ipa: bool,
/// Print the phones line of each entity the packet cites, one JSON
/// object per line ({language, source, phones: [{ipa, stress}]}),
/// instead of the text; refused for a phone the inventory lacks.
#[arg(long)]
phones: bool,
},
/// Score output text (stdin, or --output) against corpus items, one embed call.
Score(ScoreArgs),
@ -527,7 +532,7 @@ fn main() -> Result<()> {
.map_err(refused)?;
println!("admitted {key}");
}
Command::Lower { store, key, ipa } => {
Command::Lower { store, key, ipa, phones } => {
let inventory_store = InventoryStore::open(&cli.inventory)?;
let inventory = inventory_store.inventory()?;
let mut packets = PacketStore::open(&store)?;
@ -549,6 +554,10 @@ fn main() -> Result<()> {
for line in pronounce(&view, &entities)? {
println!("{line}");
}
} else if phones {
for line in weksa::phones(&view, &entities)? {
println!("{}", serde_json::to_string(&line)?);
}
} else {
println!("{}", lower(&view, &inventory, &entities)?);
}

152
crates/weksa/src/phones.rs Normal file
View file

@ -0,0 +1,152 @@
//! The phone inventory and the segmenter: a ruled IPA string to an ordered list of
//! phones with stress, for the consumer that turns phones into sound. The
//! inventory is data (`data/phonology/<language>.phones.tsv`); this module reads
//! it and owns one rule, that every symbol of the string is a phone the inventory
//! lists. A symbol it does not list is refused: no nearest phone, no spelling
//! rule, no guess. Tense/lax is a column of the inventory, so /i/ and /ɪ/ are two
//! phones here, not one phone and a flag on an entity.
//!
//! Stress marks are not phones. `ˈ` (primary) and `ˌ` (secondary) put their
//! stress on the next vowel phone, the syllable's nucleus; every other phone is
//! `none`. A mark with no vowel after it is refused. Durations, pitch and
//! acoustics are the consumer's, never read or written here.
use std::collections::BTreeMap;
use std::sync::OnceLock;
use serde::Serialize;
const EN_US: &str = include_str!("../../../data/phonology/en-US.phones.tsv");
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Stress {
None,
Primary,
Secondary,
}
/// One phone of a segmented string: its IPA symbol as the inventory spells it
/// and its stress.
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct Phone {
pub ipa: String,
pub stress: Stress,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Tenseness {
Tense,
Lax,
Reduced,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Class {
Consonant,
Vowel(Tenseness),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SegmentError {
/// The symbol (or the text from it on) matches no inventory phone.
UnknownPhone { at: usize, text: String },
/// A stress mark (`at` is its byte offset) that finds no vowel before the
/// string ends or another mark comes.
DanglingStress { at: usize },
}
pub struct PhoneInventory {
phones: BTreeMap<String, Class>,
longest: usize,
}
impl PhoneInventory {
/// The inventory for a language tag, if one is committed.
pub fn for_language(language: &str) -> Option<&'static PhoneInventory> {
static EN_US_INV: OnceLock<PhoneInventory> = OnceLock::new();
match language {
"en-US" => Some(EN_US_INV.get_or_init(|| {
PhoneInventory::parse(EN_US).expect("the committed en-US phone inventory parses")
})),
_ => None,
}
}
/// Parse the tab-separated inventory. Malformed lines are errors, as are
/// repeated symbols.
pub fn parse(text: &str) -> Result<Self, String> {
let mut phones = BTreeMap::new();
for (n, line) in text.lines().enumerate() {
if line.is_empty() || line.starts_with('#') {
continue;
}
let cols: Vec<&str> = line.split('\t').collect();
let [ipa, class, tenseness, gloss] = cols[..] else {
return Err(format!("line {}: want 4 tab-separated columns", n + 1));
};
let class = match (class, tenseness) {
("consonant", "-") => Class::Consonant,
("vowel", "tense") => Class::Vowel(Tenseness::Tense),
("vowel", "lax") => Class::Vowel(Tenseness::Lax),
("vowel", "reduced") => Class::Vowel(Tenseness::Reduced),
_ => return Err(format!("line {}: bad class/tenseness {class}/{tenseness}", n + 1)),
};
if ipa.is_empty() || gloss.is_empty() || ipa.chars().any(|c| matches!(c, 'ˈ' | 'ˌ')) {
return Err(format!("line {}: empty or stress-marked entry", n + 1));
}
if phones.insert(ipa.to_string(), class).is_some() {
return Err(format!("line {}: {ipa} repeats", n + 1));
}
}
let longest = phones.keys().map(|k| k.chars().count()).max().unwrap_or(0);
Ok(Self { phones, longest })
}
pub fn class(&self, ipa: &str) -> Option<&Class> {
self.phones.get(ipa)
}
/// Segment `ipa` into phones: longest inventory match at each position.
pub fn segment(&self, ipa: &str) -> Result<Vec<Phone>, SegmentError> {
let chars: Vec<(usize, char)> = ipa.char_indices().collect();
let mut out: Vec<Phone> = Vec::new();
let mut pending: Option<(Stress, usize)> = None;
let mut i = 0;
while i < chars.len() {
let at = chars[i].0;
let mark = match chars[i].1 {
'ˈ' => Some(Stress::Primary),
'ˌ' => Some(Stress::Secondary),
_ => None,
};
if let Some(m) = mark {
if let Some((_, was)) = pending {
return Err(SegmentError::DanglingStress { at: was });
}
pending = Some((m, at));
i += 1;
continue;
}
let hit = (1..=self.longest.min(chars.len() - i)).rev().find_map(|len| {
let end = chars.get(i + len).map_or(ipa.len(), |c| c.0);
let s = &ipa[at..end];
self.phones.get(s).map(|c| (len, s, c))
});
let Some((len, s, class)) = hit else {
return Err(SegmentError::UnknownPhone { at, text: ipa[at..].to_string() });
};
let stress = if matches!(class, Class::Vowel(_)) {
pending.take().map_or(Stress::None, |(m, _)| m)
} else {
Stress::None
};
out.push(Phone { ipa: s.to_string(), stress });
i += len;
}
if let Some((_, at)) = pending {
return Err(SegmentError::DanglingStress { at });
}
Ok(out)
}
}

View file

@ -876,6 +876,18 @@ fn proof_packet_lowers_and_the_lowering_scores_against_items() {
let ipa = weksa(d.path(), &args);
assert!(ipa.status.success(), "{}", out(&ipa));
assert_eq!(String::from_utf8(ipa.stdout).unwrap(), "ˈnibu\n");
// The phones line is the committed fixture, byte for byte, and is a different
// output from --ipa, so the two flags cannot be combined.
let phones = weksa(d.path(), &["lower", "./p.cc", "packet:6e6962752d303031", "--phones"]);
assert!(phones.status.success(), "{}", out(&phones));
let fixture = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../data/phonology/fixtures/nibu-en-US.phones.json"),
)
.unwrap();
assert_eq!(String::from_utf8(phones.stdout).unwrap(), fixture);
let both = weksa(d.path(), &["lower", "./p.cc", "packet:6e6962752d303031", "--phones", "--ipa"]);
assert!(!both.status.success(), "{}", out(&both));
let again = weksa(d.path(), &["lower", "./p.cc", "packet:6e6962752d303031"]);
assert_eq!(String::from_utf8(again.stdout).unwrap(), text);

View file

@ -11,7 +11,7 @@ use weksa::inventory::proof_revision;
use weksa::packet::{EntityId, FeatValue, FeatureValue, Packet, proof_packet};
use weksa::{
CorpusStore, EntityForms, EntityStore, Inventory, InventoryStore, LowerError, LoweringInput,
PacketStore, Pronunciation, lower, pronounce, validate,
PacketStore, PhoneInventory, PhoneLine, Pronunciation, Stress, lower, pronounce, validate,
};
const PROOF: &str = "Warning: you may endanger yourself by touching the control. \
@ -437,6 +437,143 @@ fn ipa_needs_an_entity_the_port_holds_and_a_packet_that_cites_one() {
);
}
// ---- the phones line: the phone inventory and the segmenter ----
fn phones_of(ipa: &str) -> Result<Vec<PhoneLine>, LowerError> {
let mut n = nibu(&[("en-US", &["Nibu"])]);
n.forms[0].pronunciation = Some(ruled(ipa, &[]));
let mut e = Entities::default();
e.held.push(n);
let (inv, _dir) = committed();
weksa::phones(&view(&proof_packet(), &inv), &e)
}
fn seg(ipa: &str) -> Vec<(String, Stress)> {
let inv = PhoneInventory::for_language("en-US").unwrap();
inv.segment(ipa)
.unwrap()
.into_iter()
.map(|p| (p.ipa, p.stress))
.collect()
}
fn pairs(v: &[(&str, Stress)]) -> Vec<(String, Stress)> {
v.iter().map(|(i, s)| ((*i).to_string(), *s)).collect()
}
#[test]
fn nibus_committed_ruling_lowers_to_the_committed_phones_fixture() {
let (inv, _dir) = committed();
let lines = weksa::phones(&view(&proof_packet(), &inv), &committed_entities()).unwrap();
let fixture = std::fs::read_to_string(
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../data/phonology/fixtures/nibu-en-US.phones.json"),
)
.unwrap();
assert_eq!(lines.len(), 1);
assert_eq!(serde_json::to_string(&lines[0]).unwrap(), fixture.trim_end());
}
#[test]
fn tense_i_is_a_phone_and_the_rejected_lax_form_is_a_different_one() {
use weksa::phones::{Class, Tenseness};
let inv = PhoneInventory::for_language("en-US").unwrap();
assert_eq!(inv.class("i"), Some(&Class::Vowel(Tenseness::Tense)));
assert_eq!(inv.class("ɪ"), Some(&Class::Vowel(Tenseness::Lax)));
assert_eq!(inv.class("u"), Some(&Class::Vowel(Tenseness::Tense)));
let ruled_form = seg("ˈnibu");
let lax = seg("ˈnɪbu");
assert_ne!(ruled_form, lax);
assert_eq!(lax[1], ("ɪ".to_string(), Stress::Primary));
// The committed seed rejects the lax form, and it is not what the seed emits.
let seed = committed_entities();
let nibu = seed.get(&EntityId::parse("entity:nibu").unwrap()).unwrap().unwrap();
let p = nibu.pronunciation_in("en-US").unwrap();
assert_eq!(p.rejected, ["ˈnɪbu"]);
assert_eq!(seg(&p.ipa), ruled_form);
assert!(p.rejected.iter().all(|r| seg(r) != seg(&p.ipa)));
}
#[test]
fn stress_lands_on_the_vowel_after_the_mark_and_nowhere_else() {
use Stress::{None as N, Primary as P, Secondary as S};
assert_eq!(seg("ˈnibu"), pairs(&[("n", N), ("i", P), ("b", N), ("u", N)]));
assert_eq!(seg("nˈibu"), seg("ˈnibu"));
assert_eq!(seg("bˈunˌi"), pairs(&[("b", N), ("u", P), ("n", N), ("i", S)]));
assert_eq!(seg("nibu"), pairs(&[("n", N), ("i", N), ("b", N), ("u", N)]));
}
#[test]
fn multi_character_phones_are_one_phone_by_longest_match() {
use Stress::{None as N, Primary as P};
assert_eq!(seg("ˈtʃeɪ"), pairs(&[("tʃ", N), ("eɪ", P)]));
assert_eq!(seg("dʒaʊ"), pairs(&[("dʒ", N), ("aʊ", N)]));
// /t/ then /ʃ/ is not what a lone "t" and "ʃ" mean when the affricate is listed,
// but a symbol sequence the inventory cannot join still splits.
assert_eq!(seg("ɔɪt"), pairs(&[("ɔɪ", N), ("t", N)]));
}
#[test]
fn a_phone_the_inventory_lacks_is_refused_with_where_and_never_guessed() {
let id = EntityId::parse("entity:nibu").unwrap();
for (ipa, at, text) in [
("ˈnixu", "ˈni".len(), "xu"),
("ˈniːbu", "ˈni".len(), "ːbu"),
("ˈnibu.", "ˈnibu".len(), "."),
("ˈni bu", "ˈni".len(), " bu"),
] {
assert_eq!(
phones_of(ipa),
Err(LowerError::UnknownPhone { entity: id.clone(), at, text: text.into() }),
"{ipa}"
);
}
}
#[test]
fn a_stress_mark_with_no_vowel_to_land_on_is_refused() {
let id = EntityId::parse("entity:nibu").unwrap();
for (ipa, at) in [("nibuˈ", "nibu".len()), ("ˈˈnibu", 0), ("ˈnˈibu", 0), ("ˈnb", 0)] {
assert_eq!(
phones_of(ipa),
Err(LowerError::DanglingStress { entity: id.clone(), at }),
"{ipa}"
);
}
}
#[test]
fn phones_need_a_ruling_and_an_entity_exactly_as_ipa_does() {
assert_eq!(
weksa::phones(&view(&proof_packet(), &committed().0), &Entities::default()),
Err(LowerError::UnknownEntity { entity: EntityId::parse("entity:nibu").unwrap() })
);
let mut e = Entities::default();
e.held.push(nibu(&[("en-US", &["Nibu"])]));
assert_eq!(
weksa::phones(&view(&proof_packet(), &committed().0), &e),
Err(LowerError::NoPronunciation { entity: EntityId::parse("entity:nibu").unwrap() })
);
}
#[test]
fn the_inventory_parser_refuses_what_it_cannot_read() {
for bad in [
"i\tvowel\ttense",
"i\tvowel\t-\tx",
"n\tconsonant\ttense\tx",
"i\tvowel\tmid\tx",
"ˈi\tvowel\ttense\tx",
"\tvowel\ttense\tx",
"i\tvowel\ttense\t",
"i\tvowel\ttense\ta\ni\tvowel\tlax\tb",
] {
assert!(PhoneInventory::parse(bad).is_err(), "{bad:?}");
}
assert!(PhoneInventory::parse("# c\n\ni\tvowel\ttense\ta\n").is_ok());
assert!(PhoneInventory::for_language("sa-Latn").is_none());
}
/// `nibu-proof-001` re-cut as the request: the addressee asked Nibu to stop,
/// one frame:3 predication (n4), consent naming it, and no other claim.
fn asked() -> Packet {

View file

@ -0,0 +1,50 @@
# Weksa phone inventory, en-US (General American, broad). One phone per line, tab separated:
# ipa <TAB> class <TAB> tenseness <TAB> gloss
# class: consonant | vowel. tenseness: tense | lax | reduced for a vowel, `-` for a consonant. A multi-character
# phone (affricate, diphthong) is one entry and one phone. The segmenter reads only this file: an IPA symbol with no
# line here is refused, never guessed. Tense/lax is the textbook classification; it is what separates /i/ (nee) from
# /ɪ/ (nih).
# Provenance of the i/ɪ rows: /i/ is tense and /ɪ/ is lax. Nibu's own ruling of 2026-11-07 (source
# weksa-body:follow_up:chat-s3981-3983, #minds: tense vowel, two syllables, stress on the first) chose ˈnibu and rejected
# ˈnɪbu; the entity entry records it, this file is what makes the two forms two different phones.
# Stress is not a phone: ˈ and ˌ are read by the segmenter and put on the vowel that follows.
p consonant - voiceless bilabial stop
b consonant - voiced bilabial stop
t consonant - voiceless alveolar stop
d consonant - voiced alveolar stop
k consonant - voiceless velar stop
g consonant - voiced velar stop
f consonant - voiceless labiodental fricative
v consonant - voiced labiodental fricative
θ consonant - voiceless dental fricative
ð consonant - voiced dental fricative
s consonant - voiceless alveolar fricative
z consonant - voiced alveolar fricative
ʃ consonant - voiceless postalveolar fricative
ʒ consonant - voiced postalveolar fricative
h consonant - voiceless glottal fricative
tʃ consonant - voiceless postalveolar affricate
dʒ consonant - voiced postalveolar affricate
m consonant - bilabial nasal
n consonant - alveolar nasal
ŋ consonant - velar nasal
l consonant - alveolar lateral approximant
ɹ consonant - alveolar approximant
j consonant - palatal approximant
w consonant - labial-velar approximant
i vowel tense close front unrounded (fleece, nee)
ɪ vowel lax near-close front unrounded (kit, nih)
ɛ vowel lax open-mid front unrounded (dress)
æ vowel lax near-open front unrounded (trap)
ɑ vowel tense open back unrounded (palm)
ɔ vowel tense open-mid back rounded (thought)
ʊ vowel lax near-close back rounded (foot)
u vowel tense close back rounded (goose, boo)
ʌ vowel lax open-mid back unrounded (strut)
ə vowel reduced schwa
ɚ vowel reduced r-coloured schwa
eɪ vowel tense face diphthong
oʊ vowel tense goat diphthong
aɪ vowel tense price diphthong
aʊ vowel tense mouth diphthong
ɔɪ vowel tense choice diphthong
Can't render this file because it has a wrong number of fields in line 11.

View file

@ -0,0 +1 @@
{"language":"en-US","source":"ˈnibu","phones":[{"ipa":"n","stress":"none"},{"ipa":"i","stress":"primary"},{"ipa":"b","stress":"none"},{"ipa":"u","stress":"none"}]}