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:
commit
73ce2de07f
13 changed files with 443 additions and 20 deletions
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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() })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue