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]