Lowerer reads entity forms: entity referents lower to the entity's en-US citation form #11

Merged
weksa merged 2 commits from weksa-lowering/entity-forms into main 2026-11-07 10:00:48 +00:00
7 changed files with 252 additions and 36 deletions

View file

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

View file

@ -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<Option<Entity>>;
}
impl EntityForms for EntityStore {
fn entity(&self, id: &EntityId) -> anyhow::Result<Option<Entity>> {
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<String, LowerError> {
pub fn lower(
input: &LoweringInput,
inv: &Inventory,
entities: &dyn EntityForms,
) -> Result<String, LowerError> {
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<String, LowerErro
};
let mut sentences = Vec::new();
for p in input.predications() {
sentences.push(clause(input, inv, p)?);
sentences.push(clause(input, inv, entities, p)?);
}
let mentioned = |r: &NodeId| {
input
@ -124,7 +158,7 @@ pub fn lower(input: &LoweringInput, inv: &Inventory) -> Result<String, LowerErro
return unrendered("a referent no role mentions");
}
let mut out = String::new();
for (i, clause) in sentences.iter().enumerate() {
for (i, (clause, name_first)) in sentences.iter().enumerate() {
if i > 0 {
out.push(' ');
}
@ -133,7 +167,8 @@ pub fn lower(input: &LoweringInput, inv: &Inventory) -> Result<String, LowerErro
}
let mut chars = clause.chars();
if let Some(first) = chars.next() {
if i == 0 && !opener.is_empty() {
// A name opens its clause as written; only template words are cased.
if (i == 0 && !opener.is_empty()) || *name_first {
out.push(first);
} else {
out.extend(first.to_uppercase());
@ -145,11 +180,17 @@ pub fn lower(input: &LoweringInput, inv: &Inventory) -> Result<String, LowerErro
Ok(out)
}
fn clause(input: &LoweringInput, inv: &Inventory, p: &Predication) -> Result<String, LowerError> {
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<Str
predication: p.id.clone(),
role: name.to_string(),
})?;
out.push_str(&filler(input, inv, &fill.filler, &said)?);
let (phrase, is_name) = filler(input, inv, entities, &fill.filler, &said)?;
name_first |= is_name && out.is_empty();
out.push_str(&phrase);
said.push(&fill.filler);
}
out.push_str(rest);
@ -180,26 +223,47 @@ fn clause(input: &LoweringInput, inv: &Inventory, p: &Predication) -> Result<Str
what: "a role the frame does not name",
});
}
Ok(out)
Ok((out, name_first))
}
/// A role filler as a phrase: the addressee is "you", a predication is "that",
/// another referent is "the" and its concept word. The addressee said twice in
/// another referent is its entity's citation form when it cites one, else "the"
/// and its concept word. The addressee said twice in
/// one predication is reflexive; no other referent is, because the packet has no
/// animacy to choose "themself" or "itself" by.
fn filler(
input: &LoweringInput,
inv: &Inventory,
entities: &dyn EntityForms,
node: &NodeId,
said: &[&NodeId],
) -> Result<String, LowerError> {
) -> 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<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
.forms_in(TARGET_LANGUAGE)
.and_then(<[String]>::first)
.cloned()
.ok_or_else(|| LowerError::NoForm { entity: id.clone() })
}

View file

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

View file

@ -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![],
},
],

View file

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

View file

@ -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<String, LowerError> {
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<Entity>,
fault: bool,
}
impl EntityForms for Entities {
fn entity(&self, id: &EntityId) -> anyhow::Result<Option<Entity>> {
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<String, LowerError> {
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"
})
);
}

View file

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