Merge pull request 'Entity forms carry a ruled pronunciation; lower --ipa emits it' (#13) from weksa-lowering/entity-pronunciation into main

This commit is contained in:
Weksa 2026-11-07 12:05:56 +00:00
commit 73ce2de07f
13 changed files with 443 additions and 20 deletions

View file

@ -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<String>,
/// 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<Pronunciation>,
}
/// 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<String>,
}
/// 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())
}
}

View file

@ -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,
};

View file

@ -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<Vec<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());
}
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<String, LowerError> {
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<Entity, LowerError> {
entities
.entity(id)
.map_err(|e| LowerError::EntityRead {
entity: id.clone(),
why: e.to_string(),
})?
.ok_or_else(|| LowerError::UnknownEntity { entity: id.clone() })
}

View file

@ -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<String>,
/// `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<String>,
/// Who ruled the pronunciation.
#[arg(long)]
ruled_by: Option<String>,
/// `YYYY-MM-DD`.
#[arg(long)]
ruled_on: Option<String>,
/// Where it was ruled: a room, a document, a run.
#[arg(long)]
ruling_source: Option<String>,
/// A refused pronunciation, bare IPA, repeated.
#[arg(long = "rejected", requires = "pronunciation")]
rejected: Vec<String>,
}
#[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 {

View file

@ -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::<u32>().unwrap_or(0))
&& (1..=31).contains(&p.date[8..].parse::<u32>().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,
}

View file

@ -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(

View file

@ -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(),

View file

@ -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);
}

View file

@ -158,6 +158,7 @@ fn entity_capacity_is_bounded() {
let lf = |language: &str, forms: Vec<String>| LanguageForms {
language: language.into(),
forms,
pronunciation: None,
};
let one = |f: &str| vec![f.to_string()];
let make = |forms: Vec<LanguageForms>| 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(),

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, 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<Vec<String>, 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)
);
}

View file

@ -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<EFBFBD>’”­entity:brokkr¬weksa.entity´2026-10-29T16:43:28ZÄn“­entity:brokkr‘’¥en-US‘¦Brokkr<EFBFBD>¤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<EFBFBD>¤Cast’¤nibuÙ<examples/english-lowering/nibu-proof-001/lowered-output.yaml
“²cultcache.store.v1‘—¬weksa.entity¬weksa.entity¯weksa.entity.v1¬weksa.entityÙL{"schemaName":"weksa.entity","schemaVersion":"weksa.entity.v1","members":[]}‘¬weksa.entity<EFBFBD>’”­entity:brokkr¬weksa.entity´2026-10-29T16:43:28ZÄn“­entity:brokkr‘’¥en-US‘¦Brokkr<EFBFBD>¤Cast’¦brokkrÙ?examples/persona-lowering/brokkr-edda-voice/lowered-output.yaml”«entity:nibu¬weksa.entity´2026-11-07T12:03:37ZÄâ“«entity:nibu‘“¥en-US‘¤Nibu•¦Ëˆnibu¤Nibuª2026-11-07ÙZweksa-body:follow_up:chat-s3981-3983 (#minds, tense vowel, two syllables, stress on first)‘§Ëˆnɪbu<EFBFBD>¤Cast’¤nibuÙ<examples/english-lowering/nibu-proof-001/lowered-output.yaml

View file

@ -80,6 +80,17 @@ The first version may be hand-authored fixture data. The contract matters before
automation. Yes, this is paperwork with teeth. Annoying, but it bites the right
thing.
## Ruled Entity Pronunciations
An entity's citation form in a language may carry a ruled pronunciation
(`LanguageForms.pronunciation`): bare IPA with stress marks, who ruled it, the
date, the source, and an optional list of rejected variants kept so nobody
re-proposes them. `weksa lower <store> <key> --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

View file

@ -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"
]
}
}
}