diff --git a/crates/weksa/src/lib.rs b/crates/weksa/src/lib.rs index 0bfdb8c..8595226 100644 --- a/crates/weksa/src/lib.rs +++ b/crates/weksa/src/lib.rs @@ -22,7 +22,7 @@ pub use inventory::{ Change, Family, InvId, Inventory, InventoryEntry, InventoryRevision, OptionLabel, OptionSet, Slot, ValueKind, }; -pub use lower::{LowerError, lower}; +pub use lower::{EntityForms, LowerError, TARGET_LANGUAGE, lower}; 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 d6fcdfb..93f0074 100644 --- a/crates/weksa/src/lower.rs +++ b/crates/weksa/src/lower.rs @@ -8,16 +8,44 @@ //! constraints steer delivery and are not rendered; the concept of a predication //! must resolve but the frame owns the verb. //! +//! A referent that cites an entity renders as that entity's own form, read +//! through the [`EntityForms`] port: the first (citation) form stored for +//! [`TARGET_LANGUAGE`], verbatim. The choice is a table lookup on the language +//! tag and the form's position, not a reading of the form. Forms after the +//! first are inflected or particle-bound shapes; no English frame asks for one, +//! so none is selected. An entity the port does not hold, or one with no form in +//! the target language, is refused: the concept word would say a different +//! thing than the name the packet cites. +//! //! The table is a temporary owner. Each row speaks for an inventory entry and //! names the gloss it speaks for, so a row cannot outlive a change of what its id //! means. `language-ontology` moves the rows into ontology documents. use std::fmt; +use crate::entity::Entity; use crate::inventory::{Family, InvId, Inventory}; -use crate::packet::{NodeId, Predication}; +use crate::packet::{EntityId, NodeId, Predication}; +use crate::store::EntityStore; use crate::validate::LoweringInput; +/// The language tag this lowerer's entity forms are read under, spelled as an +/// entity's `LanguageForms::language` and a corpus item's language are. +pub const TARGET_LANGUAGE: &str = "en-US"; + +/// The lowerer's whole view of the entity store: one entity by id. Narrow so a +/// test or a loop can hand it a map and the lowerer cannot write, list or +/// search. +pub trait EntityForms { + fn entity(&self, id: &EntityId) -> anyhow::Result>; +} + +impl EntityForms for EntityStore { + fn entity(&self, id: &EntityId) -> anyhow::Result> { + self.get(id) + } +} + /// `(inventory id, the gloss the row speaks for, surface)`. type Row = (&'static str, &'static str, &'static str); @@ -36,11 +64,7 @@ const FRAMES: &[Row] = &[ "hazard_warning", "{agent} may endanger {patient} by touching {instrument}", ), - ( - "frame:2", - "intervention", - "{agent} will prevent {patient} to protect {beneficiary}", - ), + ("frame:2", "intervention", "{agent} will stop {patient}"), ]; /// Speech acts: the opener of the first sentence. @@ -62,6 +86,12 @@ pub enum LowerError { UnknownRole { frame: InvId, role: String }, /// The predication fills no role its frame's template needs. MissingRole { predication: NodeId, role: String }, + /// A referent cites an entity the entity port does not hold. + UnknownEntity { entity: EntityId }, + /// The entity has no form in [`TARGET_LANGUAGE`]. + NoForm { entity: EntityId }, + /// The entity port failed to read. + EntityRead { entity: EntityId, why: String }, } impl fmt::Display for LowerError { @@ -88,7 +118,11 @@ fn row( } } -pub fn lower(input: &LoweringInput, inv: &Inventory) -> Result { +pub fn lower( + input: &LoweringInput, + inv: &Inventory, + entities: &dyn EntityForms, +) -> Result { let rev = input.inventory_rev(); let unrendered = |what| Err(LowerError::Unrendered { what }); if input.predications().is_empty() { @@ -112,7 +146,7 @@ pub fn lower(input: &LoweringInput, inv: &Inventory) -> Result Result 0 { out.push(' '); } @@ -133,7 +167,8 @@ pub fn lower(input: &LoweringInput, inv: &Inventory) -> Result Result Result { +fn clause( + input: &LoweringInput, + inv: &Inventory, + entities: &dyn EntityForms, + p: &Predication, +) -> Result<(String, bool), LowerError> { let rev = input.inventory_rev(); let template = row(FRAMES, p.frame, inv, rev)?; let mut said: Vec<&NodeId> = Vec::new(); let mut out = String::new(); + let mut name_first = false; let mut rest = template; while let Some(open) = rest.find('{') { out.push_str(&rest[..open]); @@ -171,7 +212,9 @@ fn clause(input: &LoweringInput, inv: &Inventory, p: &Predication) -> Result Result Result { +) -> Result<(String, bool), LowerError> { let again = said.contains(&node); if let Some(r) = input.referents().iter().find(|r| &r.id == node) { if input.discourse().addressee.as_ref() == Some(node) { - return Ok(if again { "yourself" } else { "you" }.to_string()); + return Ok((if again { "yourself" } else { "you" }.to_string(), false)); + } + if let Some(id) = &r.entity { + return Ok((name(entities, id)?, true)); } let word = row(CONCEPTS, r.concept, inv, input.inventory_rev())?; - return Ok(format!("the {word}")); + return Ok((format!("the {word}"), false)); } - Ok("that".to_string()) + Ok(("that".to_string(), false)) +} + +/// 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 + .forms_in(TARGET_LANGUAGE) + .and_then(<[String]>::first) + .cloned() + .ok_or_else(|| LowerError::NoForm { entity: id.clone() }) } diff --git a/crates/weksa/src/main.rs b/crates/weksa/src/main.rs index f36ba27..0b52bb3 100644 --- a/crates/weksa/src/main.rs +++ b/crates/weksa/src/main.rs @@ -502,7 +502,10 @@ fn main() -> Result<()> { &EntityStore::open(&cli.entities)?, ) .map_err(refused)?; - println!("{}", lower(&view, &inventory)?); + println!( + "{}", + lower(&view, &inventory, &EntityStore::open(&cli.entities)?)? + ); } Command::Score(args) => { let output = match &args.output { diff --git a/crates/weksa/src/packet.rs b/crates/weksa/src/packet.rs index cae001b..f2f06de 100644 --- a/crates/weksa/src/packet.rs +++ b/crates/weksa/src/packet.rs @@ -349,7 +349,7 @@ pub struct Trace { } /// The `nibu-proof-001` packet: the meaning of `examples/english-lowering/nibu-proof-001/interlingua.yaml` -/// (warn the player off the damaged control; Nibu prevents it for them) as typed +/// (warn the player off the damaged control; Nibu will stop it) as typed /// ids of inventory revision 2, for the corpus item `item:nibu:1`. No features: /// nothing here is style. pub fn proof_packet() -> Packet { @@ -408,11 +408,7 @@ pub fn proof_packet() -> Packet { id: node("n5"), concept: id("concept:2"), frame: id("frame:2"), - roles: vec![ - fill("role:1", "n3"), - fill("role:2", "n4"), - fill("role:8", "n1"), - ], + roles: vec![fill("role:1", "n3"), fill("role:2", "n4")], features: vec![], }, ], diff --git a/crates/weksa/tests/cli.rs b/crates/weksa/tests/cli.rs index e05b24a..29d8a4b 100644 --- a/crates/weksa/tests/cli.rs +++ b/crates/weksa/tests/cli.rs @@ -807,7 +807,7 @@ fn proof_packet_lowers_and_the_lowering_scores_against_items() { "--slug", "nibu", "--text", - "Warning: you may endanger yourself by touching the control. The ship mind will prevent that to protect you.", + "Warning: you may endanger yourself by touching the control. NIBU will stop that.", "--speaker", "entity:nibu", "--group", @@ -832,6 +832,8 @@ fn proof_packet_lowers_and_the_lowering_scores_against_items() { text.starts_with("Warning: you may endanger yourself"), "{text}" ); + // The referent cites entity:nibu: its stored en-US form (NIBU here) reaches the text as stored. + assert!(text.contains("NIBU will stop"), "{text}"); let missing = weksa(d.path(), &["lower", "./p.cc", "packet:0000000000000000"]); assert!(!missing.status.success() && out(&missing).contains("no packet")); diff --git a/crates/weksa/tests/lower.rs b/crates/weksa/tests/lower.rs index f50f096..ff90fd7 100644 --- a/crates/weksa/tests/lower.rs +++ b/crates/weksa/tests/lower.rs @@ -6,15 +6,16 @@ mod common; use std::path::Path; use common::{iv, nd}; +use weksa::entity::{Entity, EntityOrigin, LanguageForms}; use weksa::inventory::proof_revision; -use weksa::packet::{FeatValue, FeatureValue, Packet, proof_packet}; +use weksa::packet::{EntityId, FeatValue, FeatureValue, Packet, proof_packet}; use weksa::{ - CorpusStore, EntityStore, Inventory, InventoryStore, LowerError, LoweringInput, PacketStore, - lower, validate, + CorpusStore, EntityForms, EntityStore, Inventory, InventoryStore, LowerError, LoweringInput, + PacketStore, lower, validate, }; const PROOF: &str = "Warning: you may endanger yourself by touching the control. \ - The ship mind will prevent that to protect you."; + Nibu will stop that."; fn committed() -> (Inventory, tempfile::TempDir) { let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); @@ -42,8 +43,13 @@ fn admitted(packet: &Packet, inventory: &InventoryStore) -> LoweringInput { .unwrap() } +fn committed_entities() -> EntityStore { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + EntityStore::open(&root.join("data/entity/entity.cc")).unwrap() +} + fn lowered(packet: &Packet, inv: &Inventory) -> Result { - lower(&view(packet, inv), inv) + lower(&view(packet, inv), inv, &committed_entities()) } #[test] @@ -69,7 +75,7 @@ fn only_the_addressee_is_you_and_is_reflexive() { assert_eq!( lowered(&p, &inv).unwrap(), "Warning: the player may endanger the player by touching the control. \ - The ship mind will prevent that to protect the player." + Nibu will stop that." ); } @@ -153,7 +159,7 @@ fn a_packet_carrying_what_the_lowerer_does_not_render_is_refused() { let mut p = proof_packet(); edit(&mut p); assert_eq!( - lower(&admitted(&p, &store), &inv), + lower(&admitted(&p, &store), &inv, &committed_entities()), Err(LowerError::Unrendered { what }), "{what}" ); @@ -203,10 +209,150 @@ fn a_row_cannot_speak_for_an_id_whose_gloss_differs() { store.admit_revision(rev2, entries).unwrap(); let inv = store.inventory().unwrap(); assert_eq!( - lower(&admitted(&proof_packet(), &store), &inv), + lower( + &admitted(&proof_packet(), &store), + &inv, + &committed_entities() + ), Err(LowerError::GlossMismatch { id: iv("concept:4"), row: "control" }) ); } + +/// An entity port a test controls: ids to entities, and an optional read fault. +#[derive(Default)] +struct Entities { + held: Vec, + fault: bool, +} + +impl EntityForms for Entities { + fn entity(&self, id: &EntityId) -> anyhow::Result> { + if self.fault { + anyhow::bail!("store unreadable"); + } + Ok(self.held.iter().find(|e| &e.id == id).cloned()) + } +} + +fn nibu(forms: &[(&str, &[&str])]) -> Entity { + Entity { + id: EntityId::parse("entity:nibu").unwrap(), + forms: forms + .iter() + .map(|(language, forms)| LanguageForms { + language: (*language).into(), + forms: forms.iter().map(|f| (*f).into()).collect(), + }) + .collect(), + origin: EntityOrigin::Cast { + persona: "nibu".into(), + fixture: "f.yaml".into(), + }, + } +} + +fn through(entities: &Entities) -> Result { + let (inv, _dir) = committed(); + lower(&view(&proof_packet(), &inv), &inv, entities) +} + +#[test] +fn a_name_survives_lowering_verbatim_where_the_concept_word_was() { + // The entity's own form, not "the ship mind", and not re-cased or inflected: + // the form is lowercase and stays so mid-sentence. + let mut e = Entities::default(); + let n = nibu(&[("en-US", &["nibu-of-the-deep"])]); + e.held.push(n); + // It opens the second sentence, where a template word would be capitalised. + assert_eq!( + through(&e).unwrap(), + "Warning: you may endanger yourself by touching the control. \ + nibu-of-the-deep will stop that." + ); +} + +#[test] +fn only_the_target_languages_citation_form_is_chosen() { + // Forms after the first are inflected shapes no English frame asks for; a + // form under another language tag is never read, whatever order it is in. + let mut e = Entities::default(); + let n = nibu(&[ + ("sa-Latn", &["Nibuwa"]), + ("en-US", &["Nibu", "Nibu's", "Nibus"]), + ]); + e.held.push(n); + assert_eq!(through(&e).unwrap(), PROOF); +} + +#[test] +fn an_entity_the_port_does_not_hold_is_refused_not_replaced_by_its_concept_word() { + let id = EntityId::parse("entity:nibu").unwrap(); + assert_eq!( + through(&Entities::default()), + Err(LowerError::UnknownEntity { entity: id }) + ); +} + +#[test] +fn an_entity_with_no_target_language_form_is_refused() { + let mut e = Entities::default(); + let n = nibu(&[("sa-Latn", &["Nibuwa"])]); + let id = n.id.clone(); + e.held.push(n); + assert_eq!(through(&e), Err(LowerError::NoForm { entity: id })); +} + +#[test] +fn a_failing_entity_port_is_refused_by_name() { + let e = Entities { + fault: true, + ..Entities::default() + }; + assert_eq!( + through(&e), + Err(LowerError::EntityRead { + entity: EntityId::parse("entity:nibu").unwrap(), + why: "store unreadable".into() + }) + ); +} + +#[test] +fn an_addressee_entity_still_lowers_to_you_and_is_never_looked_up() { + // Naming the addressee would break "you"/"yourself": the port is not asked, + // so a port that faults on every read changes nothing. + let (inv, _dir) = committed(); + let mut p = proof_packet(); + p.referents[0].entity = Some(EntityId::parse("entity:nibu").unwrap()); + p.referents[2].entity = None; + let faulting = Entities { + fault: true, + ..Entities::default() + }; + assert_eq!( + lower(&view(&p, &inv), &inv, &faulting).unwrap(), + "Warning: you may endanger yourself by touching the control. \ + The ship mind will stop that." + ); +} + +#[test] +fn a_purpose_the_frame_does_not_say_is_refused_not_dropped() { + // The intervention row says "will stop"; a packet that also fills the + // beneficiary role would assert a purpose the text does not carry. + let (inv, _dir) = committed(); + let mut p = proof_packet(); + p.predications[1].roles.push(weksa::packet::RoleFill { + role: iv("role:8"), + filler: nd("n1"), + }); + assert_eq!( + lowered(&p, &inv), + Err(LowerError::Unrendered { + what: "a role the frame does not name" + }) + ); +} diff --git a/crates/weksa/tests/scene.rs b/crates/weksa/tests/scene.rs index 896a4e9..0fc4168 100644 --- a/crates/weksa/tests/scene.rs +++ b/crates/weksa/tests/scene.rs @@ -78,7 +78,12 @@ fn a_scene_relation_reaches_the_lowering_view_and_changes_no_english() { ]; let with = plain.clone().with_scene(&inv, scene.clone()).unwrap(); assert_eq!(with.scene(), scene.as_slice()); - assert_eq!(lower(&with, &inv).unwrap(), lower(&plain, &inv).unwrap()); + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let entities = EntityStore::open(&root.join("data/entity/entity.cc")).unwrap(); + assert_eq!( + lower(&with, &inv, &entities).unwrap(), + lower(&plain, &inv, &entities).unwrap() + ); } #[test]