diff --git a/crates/weksa/src/entity.rs b/crates/weksa/src/entity.rs index 33d22e1..5862913 100644 --- a/crates/weksa/src/entity.rs +++ b/crates/weksa/src/entity.rs @@ -16,6 +16,29 @@ pub struct LanguageForms { /// 1 to 64 bytes each, no control or invisible format character, no edge /// whitespace, none repeated. pub forms: Vec, + /// How the citation form (`forms[0]`) is said, when someone with the + /// authority to rule on it has. Absent means unruled, never "spelled as + /// read". + #[serde(default)] + pub pronunciation: Option, +} + +/// A ruled pronunciation of an entity's citation form in one language. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct Pronunciation { + /// Broad IPA with stress marks, bare: no enclosing `/` or `[ ]`. 1 to 64 + /// bytes, NFC. + pub ipa: String, + /// Who ruled: the entity itself, a writer, an operator. + pub ruled_by: String, + /// `YYYY-MM-DD`. + pub date: String, + /// Where the ruling was made: a room, a document, a run. + pub source: String, + /// Pronunciations that were considered and refused, same shape as `ipa`. + /// Kept so nobody re-proposes one; never emitted. + #[serde(default)] + pub rejected: Vec, } /// Where an entity was minted. @@ -50,4 +73,12 @@ impl Entity { .find(|f| f.language == language) .map(|f| f.forms.as_slice()) } + + /// The ruled pronunciation of the citation form in `language`, if any. + pub fn pronunciation_in(&self, language: &str) -> Option<&Pronunciation> { + self.forms + .iter() + .find(|f| f.language == language) + .and_then(|f| f.pronunciation.as_ref()) + } } diff --git a/crates/weksa/src/lib.rs b/crates/weksa/src/lib.rs index 8595226..3f6b43c 100644 --- a/crates/weksa/src/lib.rs +++ b/crates/weksa/src/lib.rs @@ -16,13 +16,13 @@ pub mod store; pub mod validate; pub use corpus::{CorpusItem, CorpusItemId, NameSpan, Source, Turn}; -pub use entity::{Entity, EntityOrigin, LanguageForms}; +pub use entity::{Entity, EntityOrigin, LanguageForms, Pronunciation}; pub use form::FormError; pub use inventory::{ Change, Family, InvId, Inventory, InventoryEntry, InventoryRevision, OptionLabel, OptionSet, Slot, ValueKind, }; -pub use lower::{EntityForms, LowerError, TARGET_LANGUAGE, lower}; +pub use lower::{EntityForms, LowerError, TARGET_LANGUAGE, lower, pronounce}; pub use packet::{ EncoderId, EntityId, Grade, NodeId, Packet, PacketId, PacketKind, SCHEMA_VERSION, Stance, }; diff --git a/crates/weksa/src/lower.rs b/crates/weksa/src/lower.rs index 93f0074..43a8752 100644 --- a/crates/weksa/src/lower.rs +++ b/crates/weksa/src/lower.rs @@ -90,6 +90,11 @@ pub enum LowerError { UnknownEntity { entity: EntityId }, /// The entity has no form in [`TARGET_LANGUAGE`]. NoForm { entity: EntityId }, + /// IPA was requested for an entity whose [`TARGET_LANGUAGE`] citation form + /// has no ruled pronunciation. Spelling is not a pronunciation. + NoPronunciation { entity: EntityId }, + /// IPA was requested for a packet that cites no entity. + NoEntityReferent, /// The entity port failed to read. EntityRead { entity: EntityId, why: String }, } @@ -180,6 +185,29 @@ pub fn lower( Ok(out) } +/// The ruled IPA of every entity a packet cites, one per entity referent in +/// the packet's referent order, as stored: bare, stress-marked, nothing +/// derived. A rejected pronunciation is never read. An entity the port does not +/// hold, or one with no ruled pronunciation in [`TARGET_LANGUAGE`], is refused: +/// the lowerer has no grapheme-to-phoneme guess to offer in its place. +pub fn pronounce( + input: &LoweringInput, + entities: &dyn EntityForms, +) -> Result, 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()); + } + if out.is_empty() { + return Err(LowerError::NoEntityReferent); + } + Ok(out) +} + fn clause( input: &LoweringInput, inv: &Inventory, @@ -254,16 +282,19 @@ fn filler( /// The entity's citation form in [`TARGET_LANGUAGE`]. fn name(entities: &dyn EntityForms, id: &EntityId) -> Result { - let entity = entities - .entity(id) - .map_err(|e| LowerError::EntityRead { - entity: id.clone(), - why: e.to_string(), - })? - .ok_or_else(|| LowerError::UnknownEntity { entity: id.clone() })?; - entity + entity(entities, id)? .forms_in(TARGET_LANGUAGE) .and_then(<[String]>::first) .cloned() .ok_or_else(|| LowerError::NoForm { entity: id.clone() }) } + +fn entity(entities: &dyn EntityForms, id: &EntityId) -> Result { + entities + .entity(id) + .map_err(|e| LowerError::EntityRead { + entity: id.clone(), + why: e.to_string(), + })? + .ok_or_else(|| LowerError::UnknownEntity { entity: id.clone() }) +} diff --git a/crates/weksa/src/main.rs b/crates/weksa/src/main.rs index 0b52bb3..5518823 100644 --- a/crates/weksa/src/main.rs +++ b/crates/weksa/src/main.rs @@ -7,13 +7,13 @@ use anyhow::{Context, Result, bail}; use clap::{Args, Parser, Subcommand, ValueEnum}; use unicode_normalization::UnicodeNormalization; use weksa::corpus::{CorpusItem, ModelFamily, NameSpan, Source, Turn}; -use weksa::entity::{Entity, EntityOrigin, LanguageForms}; +use weksa::entity::{Entity, EntityOrigin, LanguageForms, Pronunciation}; use weksa::inventory::{proof_revision, seed}; use weksa::packet::proof_packet; use weksa::score::ScoreInput; use weksa::{ CorpusStore, EntityId, EntityStore, InventoryStore, OllamaEmbedder, PacketStore, Scorer, - SplitScheme, StoreError, lower, validate, + SplitScheme, StoreError, lower, pronounce, validate, }; #[derive(Parser)] @@ -48,7 +48,14 @@ enum Command { #[command(subcommand)] Packet(PacketCommand), /// Lower a stored packet to English text. - Lower { store: PathBuf, key: String }, + Lower { + store: PathBuf, + 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)] + ipa: bool, + }, /// Score output text (stdin, or --output) against corpus items, one embed call. Score(ScoreArgs), /// Print the split of a corpus item. @@ -127,6 +134,22 @@ struct PutArgs { /// The work of a `public-domain` origin; refused with any other origin. #[arg(long)] work: Option, + /// `language=ipa`: how that language's citation form is said, bare IPA with + /// stress marks. Needs `--ruled-by`, `--ruled-on` and `--ruling-source`. + #[arg(long, requires_all = ["ruled_by", "ruled_on", "ruling_source"])] + pronunciation: Option, + /// Who ruled the pronunciation. + #[arg(long)] + ruled_by: Option, + /// `YYYY-MM-DD`. + #[arg(long)] + ruled_on: Option, + /// Where it was ruled: a room, a document, a run. + #[arg(long)] + ruling_source: Option, + /// A refused pronunciation, bare IPA, repeated. + #[arg(long = "rejected", requires = "pronunciation")] + rejected: Vec, } #[derive(Clone, Copy, ValueEnum)] @@ -146,9 +169,28 @@ impl PutArgs { None => forms.push(LanguageForms { language: language.to_string(), forms: vec![form.nfc().collect()], + pronunciation: None, }), } } + if let Some(p) = &self.pronunciation { + let (language, ipa) = p + .split_once('=') + .context("--pronunciation is not language=ipa")?; + let lf = forms + .iter_mut() + .find(|lf| lf.language == language) + .with_context(|| { + format!("--pronunciation names {language}, which has no --form") + })?; + lf.pronunciation = Some(Pronunciation { + ipa: ipa.nfc().collect(), + ruled_by: need(self.ruled_by.clone(), "ruled-by")?, + date: need(self.ruled_on.clone(), "ruled-on")?, + source: need(self.ruling_source.clone(), "ruling-source")?, + rejected: self.rejected.iter().map(|r| r.nfc().collect()).collect(), + }); + } let origin = match (self.origin, self.persona, self.fixture, self.work) { (OriginKind::Cast, persona, fixture, None) => EntityOrigin::Cast { persona: need(persona, "persona")?, @@ -485,7 +527,7 @@ fn main() -> Result<()> { .map_err(refused)?; println!("admitted {key}"); } - Command::Lower { store, key } => { + Command::Lower { store, key, ipa } => { let inventory_store = InventoryStore::open(&cli.inventory)?; let inventory = inventory_store.inventory()?; let mut packets = PacketStore::open(&store)?; @@ -502,10 +544,14 @@ fn main() -> Result<()> { &EntityStore::open(&cli.entities)?, ) .map_err(refused)?; - println!( - "{}", - lower(&view, &inventory, &EntityStore::open(&cli.entities)?)? - ); + let entities = EntityStore::open(&cli.entities)?; + if ipa { + for line in pronounce(&view, &entities)? { + println!("{line}"); + } + } else { + println!("{}", lower(&view, &inventory, &entities)?); + } } Command::Score(args) => { let output = match &args.output { diff --git a/crates/weksa/src/store.rs b/crates/weksa/src/store.rs index 1caa6ac..764a7be 100644 --- a/crates/weksa/src/store.rs +++ b/crates/weksa/src/store.rs @@ -12,7 +12,7 @@ use unicode_normalization::{UnicodeNormalization, char::is_combining_mark, is_nf use unicode_segmentation::UnicodeSegmentation; use crate::corpus::{CorpusItem, CorpusItemId, Source, item_key, key_slug}; -use crate::entity::{Entity, EntityOrigin}; +use crate::entity::{Entity, EntityOrigin, Pronunciation}; use crate::form::{is_attestation, is_joining_only, is_language_tag, is_refused_format}; use crate::inventory::{Change, InvId, Inventory, InventoryEntry, InventoryRevision, revision_key}; use crate::packet::{EntityId, Packet}; @@ -453,6 +453,7 @@ impl EntityStore { o.language == n.language && o.forms.len() <= n.forms.len() && n.forms.starts_with(&o.forms) + && (o.pronunciation.is_none() || o.pronunciation == n.pronunciation) }); if !grows { return Err(StoreRefusal::EntityNotAppendOnly { id: entity.id }.into()); @@ -506,6 +507,9 @@ fn check_entity_shape(entity: &Entity) -> Result<(), StoreError> { if lf.forms.len() > EntityBound::MAX_FORMS { return Err(bound(EntityBound::Forms)); } + if let Some(p) = &lf.pronunciation { + check_pronunciation(entity, p)?; + } let mut seen = BTreeSet::new(); for f in &lf.forms { if f.len() > EntityBound::MAX_FORM_BYTES { @@ -536,6 +540,59 @@ fn check_entity_shape(entity: &Entity) -> Result<(), StoreError> { Ok(()) } +/// A pronunciation is bare IPA, NFC, with no brackets, control character or +/// edge whitespace; every rejected variant is the same, differs from the ruled +/// one and is named once; the ruling names its author, a `YYYY-MM-DD` date and +/// its source. +fn check_pronunciation(entity: &Entity, p: &Pronunciation) -> Result<(), StoreError> { + let bad = |why| malformed(entity.id.as_str(), why); + let ipa_ok = |s: &str| { + !s.is_empty() + && s.len() <= EntityBound::MAX_FORM_BYTES + && is_nfc(s) + && s.trim() == s + && !s + .chars() + .any(|c| c.is_control() || is_refused_format(c) || "/[]".contains(c)) + }; + if !ipa_ok(&p.ipa) { + return Err(bad( + "a pronunciation is bare NFC IPA with no brackets or slashes", + )); + } + if !is_attestation(&p.ruled_by) || !is_attestation(&p.source) { + return Err(bad("a pronunciation names who ruled it and where")); + } + let b = p.date.as_bytes(); + let date_ok = b.len() == 10 + && b.iter().enumerate().all(|(i, c)| { + if i == 4 || i == 7 { + *c == b'-' + } else { + c.is_ascii_digit() + } + }) + && (1..=12).contains(&p.date[5..7].parse::().unwrap_or(0)) + && (1..=31).contains(&p.date[8..].parse::().unwrap_or(0)); + if !date_ok { + return Err(bad("a pronunciation is dated YYYY-MM-DD")); + } + if p.rejected.len() > EntityBound::MAX_FORMS { + return Err(StoreRefusal::EntityBounds { + id: entity.id.clone(), + bound: EntityBound::Forms, + } + .into()); + } + let mut seen = BTreeSet::from([&p.ipa]); + if !p.rejected.iter().all(|r| ipa_ok(r) && seen.insert(r)) { + return Err(bad( + "a rejected pronunciation is well-formed, named once and not the ruled one", + )); + } + Ok(()) +} + pub struct InventoryStore { cache: CultCache, } diff --git a/crates/weksa/tests/cli.rs b/crates/weksa/tests/cli.rs index 29d8a4b..72ad140 100644 --- a/crates/weksa/tests/cli.rs +++ b/crates/weksa/tests/cli.rs @@ -838,6 +838,47 @@ fn proof_packet_lowers_and_the_lowering_scores_against_items() { let missing = weksa(d.path(), &["lower", "./p.cc", "packet:0000000000000000"]); assert!(!missing.status.success() && out(&missing).contains("no packet")); + // IPA is the ruled pronunciation or nothing: spelling is not one. + let args = ["lower", "./p.cc", "packet:6e6962752d303031", "--ipa"]; + let unruled = weksa(d.path(), &args); + assert!( + !unruled.status.success() && out(&unruled).contains("NoPronunciation"), + "{}", + out(&unruled) + ); + let ruled = weksa( + d.path(), + &[ + "entity", + "put", + "entity:nibu", + "--form", + "en-US=NIBU", + "--origin", + "cast", + "--persona", + "nibu", + "--fixture", + "f.yaml", + "--pronunciation", + "en-US=ˈnibu", + "--ruled-by", + "Nibu", + "--ruled-on", + "2026-11-07", + "--ruling-source", + "test", + "--rejected", + "ˈnɪbu", + ], + ); + assert!(ruled.status.success(), "{}", out(&ruled)); + let ipa = weksa(d.path(), &args); + assert!(ipa.status.success(), "{}", out(&ipa)); + assert_eq!(String::from_utf8(ipa.stdout).unwrap(), "ˈnibu\n"); + let again = weksa(d.path(), &["lower", "./p.cc", "packet:6e6962752d303031"]); + assert_eq!(String::from_utf8(again.stdout).unwrap(), text); + std::fs::write(d.path().join("out.txt"), &text).unwrap(); let url = fake_embedder(); let scored = weksa( diff --git a/crates/weksa/tests/common/mod.rs b/crates/weksa/tests/common/mod.rs index 07e95ee..d866904 100644 --- a/crates/weksa/tests/common/mod.rs +++ b/crates/weksa/tests/common/mod.rs @@ -93,6 +93,7 @@ pub fn ent(slug: &str, language: &str, forms: &[&str]) -> Entity { forms: vec![LanguageForms { language: language.into(), forms: forms.iter().map(|f| f.to_string()).collect(), + pronunciation: None, }], origin: EntityOrigin::Cast { persona: slug.into(), diff --git a/crates/weksa/tests/entities.rs b/crates/weksa/tests/entities.rs index 46aeb71..5493943 100644 --- a/crates/weksa/tests/entities.rs +++ b/crates/weksa/tests/entities.rs @@ -88,6 +88,7 @@ fn entity_forms_only_grow() { more.forms.push(weksa::LanguageForms { language: "tr".into(), forms: vec!["Kay".into()], + pronunciation: None, }); assert_eq!(store.put(more.clone()).unwrap(), AddOutcome::Added); // The store read back from disk holds the growth. @@ -131,6 +132,7 @@ fn entity_forms_only_grow() { let lf = |language: &str, forms: &[&str]| weksa::LanguageForms { language: language.into(), forms: forms.iter().map(|f| f.to_string()).collect(), + pronunciation: None, }; for bad in [ vec![], @@ -427,3 +429,76 @@ fn packet_schema_v2() { "array" ); } + +#[test] +fn a_pronunciation_is_added_once_and_then_frozen() { + let d = tempfile::tempdir().unwrap(); + let mut store = EntityStore::open(&d.path().join("e.cc")).unwrap(); + let kay = ent("kay", "en-US", &["Kay"]); + store.put(kay.clone()).unwrap(); + let said = |ipa: &str, rejected: &[&str]| { + let mut e = kay.clone(); + e.forms[0].pronunciation = Some(weksa::Pronunciation { + ipa: ipa.into(), + ruled_by: "Kay".into(), + date: "2026-11-07".into(), + source: "test".into(), + rejected: rejected.iter().map(|r| r.to_string()).collect(), + }); + e + }; + // An unruled entity gains a ruling. + let ruled = said("ˈkeɪ", &["ˈkaɪ"]); + assert_eq!(store.put(ruled.clone()).unwrap(), AddOutcome::Added); + let mut store = EntityStore::open(&d.path().join("e.cc")).unwrap(); + assert_eq!(store.get(&eid("kay")).unwrap(), Some(ruled.clone())); + // A ruling is neither changed nor dropped; a changed ruling is a new decision. + for e in [said("ˈkaɪ", &[]), said("ˈkeɪ", &[]), kay.clone()] { + assert_eq!( + refusal(store.put(e)), + StoreRefusal::EntityNotAppendOnly { id: eid("kay") } + ); + } + assert_eq!(store.get(&eid("kay")).unwrap(), Some(ruled)); +} + +#[test] +fn a_malformed_pronunciation_is_refused() { + let d = tempfile::tempdir().unwrap(); + let mut store = EntityStore::open(&d.path().join("e.cc")).unwrap(); + let with = |edit: fn(&mut weksa::Pronunciation)| { + let mut e = ent("kay", "en-US", &["Kay"]); + let mut p = weksa::Pronunciation { + ipa: "ˈkeɪ".into(), + ruled_by: "Kay".into(), + date: "2026-11-07".into(), + source: "test".into(), + rejected: vec!["ˈkaɪ".into()], + }; + edit(&mut p); + e.forms[0].pronunciation = Some(p); + e + }; + let edits: [fn(&mut weksa::Pronunciation); 9] = [ + |p| p.ipa = "".into(), + |p| p.ipa = "/ˈkeɪ/".into(), + |p| p.ipa = "[ˈkeɪ]".into(), + |p| p.ipa = " ˈkeɪ".into(), + |p| p.ruled_by = "".into(), + |p| p.date = "07-11-2026".into(), + |p| p.date = "2026-13-07".into(), + |p| p.rejected = vec!["ˈkeɪ".into()], + |p| p.rejected = vec!["ˈkaɪ".into(), "ˈkaɪ".into()], + ]; + for edit in edits { + assert!( + matches!( + store.put(with(edit)), + Err(StoreError::Refused(StoreRefusal::Malformed { .. })) + ), + "{:?}", + with(edit) + ); + } + assert_eq!(store.get(&eid("kay")).unwrap(), None); +} diff --git a/crates/weksa/tests/entity_door.rs b/crates/weksa/tests/entity_door.rs index bcd53e8..191cb74 100644 --- a/crates/weksa/tests/entity_door.rs +++ b/crates/weksa/tests/entity_door.rs @@ -158,6 +158,7 @@ fn entity_capacity_is_bounded() { let lf = |language: &str, forms: Vec| LanguageForms { language: language.into(), forms, + pronunciation: None, }; let one = |f: &str| vec![f.to_string()]; let make = |forms: Vec| Entity { @@ -331,6 +332,7 @@ fn entities_are_append_only_and_replay_needs_them() { LanguageForms { language: "fr".into(), forms: vec!["Z\u{e8}de".into()], + pronunciation: None, }, ); assert_eq!(refusal(w.entities.put(prepend)), not_append); @@ -344,6 +346,7 @@ fn entities_are_append_only_and_replay_needs_them() { duplicate.forms.push(LanguageForms { language: "en-US".into(), forms: vec!["Zeddy".into()], + pronunciation: None, }); assert!(matches!( refusal(w.entities.put(duplicate)), @@ -371,6 +374,7 @@ fn with_form(slug: &str, form: &str) -> Entity { forms: vec![LanguageForms { language: "en-US".into(), forms: vec![form.into()], + pronunciation: None, }], origin: EntityOrigin::Live { persona: "p".into(), diff --git a/crates/weksa/tests/lower.rs b/crates/weksa/tests/lower.rs index ff90fd7..4f1691c 100644 --- a/crates/weksa/tests/lower.rs +++ b/crates/weksa/tests/lower.rs @@ -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, lower, validate, + PacketStore, Pronunciation, lower, pronounce, validate, }; const PROOF: &str = "Warning: you may endanger yourself by touching the control. \ @@ -245,6 +245,7 @@ fn nibu(forms: &[(&str, &[&str])]) -> Entity { .map(|(language, forms)| LanguageForms { language: (*language).into(), forms: forms.iter().map(|f| (*f).into()).collect(), + pronunciation: None, }) .collect(), origin: EntityOrigin::Cast { @@ -356,3 +357,80 @@ fn a_purpose_the_frame_does_not_say_is_refused_not_dropped() { }) ); } + +fn ruled(ipa: &str, rejected: &[&str]) -> Pronunciation { + Pronunciation { + ipa: ipa.into(), + ruled_by: "Nibu".into(), + date: "2026-11-07".into(), + source: "test".into(), + rejected: rejected.iter().map(|r| (*r).into()).collect(), + } +} + +fn say(entities: &dyn EntityForms) -> Result, LowerError> { + let (inv, _dir) = committed(); + pronounce(&view(&proof_packet(), &inv), entities) +} + +#[test] +fn nibu_round_trips_from_the_committed_seed_to_its_ruled_ipa() { + 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.ipa.as_str(), p.ruled_by.as_str(), p.date.as_str()), + ("ˈnibu", "Nibu", "2026-11-07") + ); + assert_eq!(say(&seed).unwrap(), ["ˈnibu"]); +} + +#[test] +fn a_rejected_pronunciation_is_never_emitted() { + let mut e = Entities::default(); + let mut n = nibu(&[("en-US", &["Nibu"])]); + n.forms[0].pronunciation = Some(ruled("ˈnibu", &["ˈnɪbu", "ˈnaɪbu"])); + e.held.push(n); + assert_eq!(say(&e).unwrap(), ["ˈnibu"]); + // The committed seed carries the same rejection and still says only the ruling. + let seed = say(&committed_entities()).unwrap().concat(); + assert!(!seed.contains('ɪ'), "{seed}"); +} + +#[test] +fn an_entity_with_no_ruled_pronunciation_is_refused_not_spelled_out() { + let id = EntityId::parse("entity:nibu").unwrap(); + // No pronunciation at all. + let mut e = Entities::default(); + e.held.push(nibu(&[("en-US", &["Nibu"])])); + assert_eq!( + say(&e), + Err(LowerError::NoPronunciation { entity: id.clone() }) + ); + // A pronunciation under another language is not the target language's. + let mut n = nibu(&[("sa-Latn", &["Nibuwa"]), ("en-US", &["Nibu"])]); + n.forms[0].pronunciation = Some(ruled("ˈnibuwa", &[])); + let mut e = Entities::default(); + e.held.push(n); + assert_eq!(say(&e), Err(LowerError::NoPronunciation { entity: id })); +} + +#[test] +fn ipa_needs_an_entity_the_port_holds_and_a_packet_that_cites_one() { + assert_eq!( + say(&Entities::default()), + Err(LowerError::UnknownEntity { + entity: EntityId::parse("entity:nibu").unwrap() + }) + ); + let (inv, _dir) = committed(); + let mut p = proof_packet(); + p.referents[2].entity = None; + assert_eq!( + pronounce(&view(&p, &inv), &committed_entities()), + Err(LowerError::NoEntityReferent) + ); +} diff --git a/data/entity/entity.cc b/data/entity/entity.cc index 8f9a198..1338530 100644 --- a/data/entity/entity.cc +++ b/data/entity/entity.cc @@ -1 +1 @@ -“²cultcache.store.v1‘—¬weksa.entity¬weksa.entity¯weksa.entity.v1¬weksa.entityÙL{"schemaName":"weksa.entity","schemaVersion":"weksa.entity.v1","members":[]}‘¬weksa.entity�’”­entity:brokkr¬weksa.entity´2026-10-29T16:43:28ZÄn“­entity:brokkr‘’¥en-US‘¦Brokkr�¤Cast’¦brokkrÙ?examples/persona-lowering/brokkr-edda-voice/lowered-output.yaml”«entity:nibu¬weksa.entity´2026-10-29T16:43:28ZÄe“«entity:nibu‘’¥en-US‘¤Nibu�¤Cast’¤nibuÙ --ipa` prints the stored IPA of each +entity the packet cites, one per line, and refuses with `NoPronunciation` for an +entity with none; rejected variants are never read. This is not the plan below: +it carries no timing, contour, length or invented prosody, and Weksa has no +English G2P, so an unruled name has no IPA. + ## Pronunciation Plan Shape ```yaml diff --git a/schemas/cultnet/weksa.entity.schema.json b/schemas/cultnet/weksa.entity.schema.json index b2b14f2..7298534 100644 --- a/schemas/cultnet/weksa.entity.schema.json +++ b/schemas/cultnet/weksa.entity.schema.json @@ -112,12 +112,60 @@ }, "language": { "type": "string" + }, + "pronunciation": { + "description": "How the citation form (`forms[0]`) is said, when someone with the\nauthority to rule on it has. Absent means unruled, never \"spelled as\nread\".", + "anyOf": [ + { + "$ref": "#/$defs/Pronunciation" + }, + { + "type": "null" + } + ], + "default": null } }, "required": [ "language", "forms" ] + }, + "Pronunciation": { + "description": "A ruled pronunciation of an entity's citation form in one language.", + "type": "object", + "properties": { + "date": { + "description": "`YYYY-MM-DD`.", + "type": "string" + }, + "ipa": { + "description": "Broad IPA with stress marks, bare: no enclosing `/` or `[ ]`. 1 to 64\nbytes, NFC.", + "type": "string" + }, + "rejected": { + "description": "Pronunciations that were considered and refused, same shape as `ipa`.\nKept so nobody re-proposes one; never emitted.", + "type": "array", + "default": [], + "items": { + "type": "string" + } + }, + "ruled_by": { + "description": "Who ruled: the entity itself, a writer, an operator.", + "type": "string" + }, + "source": { + "description": "Where the ruling was made: a room, a document, a run.", + "type": "string" + } + }, + "required": [ + "ipa", + "ruled_by", + "date", + "source" + ] } } }