diff --git a/crates/weksa/src/inventory.rs b/crates/weksa/src/inventory.rs index 405b4ae..c9d69a2 100644 --- a/crates/weksa/src/inventory.rs +++ b/crates/weksa/src/inventory.rs @@ -485,9 +485,20 @@ pub fn proof_revision() -> (InventoryRevision, Vec) { for (family, glosses) in [ ( Family::Concept, - ["endanger", "prevent", "player", "control", "ship_mind"].as_slice(), + [ + "endanger", + "prevent", + "player", + "control", + "ship_mind", + "request", + ] + .as_slice(), + ), + ( + Family::Frame, + ["hazard_warning", "intervention", "request_intervention"].as_slice(), ), - (Family::Frame, ["hazard_warning", "intervention"].as_slice()), ] { for (i, gloss) in glosses.iter().enumerate() { entries.push(InventoryEntry { diff --git a/crates/weksa/src/lower.rs b/crates/weksa/src/lower.rs index 8f7b856..9eb9ec7 100644 --- a/crates/weksa/src/lower.rs +++ b/crates/weksa/src/lower.rs @@ -17,6 +17,14 @@ //! the target language, is refused: the concept word would say a different //! thing than the name the packet cites. //! +//! A request clause ("You asked Nibu to stop.") says what the addressee did, so +//! it is gated on the packet's own record of it: `Discourse.addressee_consent` +//! (set only by authoring or game telemetry, V15) must name that predication, +//! and its agent must be the addressee. Without it the lowerer refuses +//! ([`LowerError::ConsentMissing`], [`LowerError::AskerNotAddressee`]); it does +//! not read wording or guess who asked. A consent naming any other predication +//! is an unrendered slot. +//! //! 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. @@ -65,8 +73,17 @@ const FRAMES: &[Row] = &[ "{agent} may endanger {patient} by touching {instrument}", ), ("frame:2", "intervention", "{agent} will stop {patient}"), + ( + "frame:3", + "request_intervention", + "{agent} asked {patient} to stop", + ), ]; +/// The frame of "{agent} asked {patient} to stop": the one frame that reads +/// the discourse's addressee consent. +const REQUEST_FRAME: &str = "frame:3"; + /// Speech acts: the opener of the first sentence. const SPEECH_ACTS: &[Row] = &[ ("speech_act:1", "assert", ""), @@ -95,6 +112,12 @@ pub enum LowerError { NoPronunciation { entity: EntityId }, /// IPA was requested for a packet that cites no entity. NoEntityReferent, + /// A request predication the packet does not carry the addressee's consent + /// to: no consent, or a consent that names another predication. The + /// lowerer does not guess who asked. + ConsentMissing { predication: NodeId }, + /// A request predication whose agent is not the discourse addressee. + AskerNotAddressee { predication: NodeId }, /// The entity port failed to read. EntityRead { entity: EntityId, why: String }, } @@ -139,9 +162,7 @@ pub fn lower( { return unrendered("features"); } - if input.discourse().addressee_consent.is_some() { - return unrendered("addressee consent"); - } + request_gate(input, inv)?; if !input.relations().is_empty() { return unrendered("relations"); } @@ -188,6 +209,59 @@ pub fn lower( Ok(out) } +/// The one place consent is read. A request clause ([`REQUEST_FRAME`]) says what +/// the addressee did, so it needs the packet's own record of it: the discourse +/// `addressee_consent`, which validation lets only authoring or game telemetry +/// set (V15), must name that predication, and its agent must be the addressee. +/// Nothing here reads wording. A consent naming any other predication is a slot +/// the lowerer does not render. +fn request_gate(input: &LoweringInput, inv: &Inventory) -> Result<(), LowerError> { + let consent = input.discourse().addressee_consent.as_ref(); + let requests = input + .predications() + .iter() + .filter(|p| p.frame.to_string() == REQUEST_FRAME); + for p in requests { + if consent != Some(&p.id) { + return Err(LowerError::ConsentMissing { + predication: p.id.clone(), + }); + } + let agent = inv + .entries() + .find(|e| { + e.family() == Family::Role && e.gloss == "agent" && e.live_at(input.inventory_rev()) + }) + .ok_or_else(|| LowerError::UnknownRole { + frame: p.frame, + role: "agent".to_string(), + })?; + let asker = p + .roles + .iter() + .find(|r| r.role == agent.id) + .map(|r| &r.filler); + if asker.is_none() || asker != input.discourse().addressee.as_ref() { + return Err(LowerError::AskerNotAddressee { + predication: p.id.clone(), + }); + } + } + match consent { + Some(_) + if !input + .predications() + .iter() + .any(|p| p.frame.to_string() == REQUEST_FRAME) => + { + Err(LowerError::Unrendered { + what: "addressee consent", + }) + } + _ => Ok(()), + } +} + /// 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 diff --git a/crates/weksa/tests/lower.rs b/crates/weksa/tests/lower.rs index 1d8301d..3d796a5 100644 --- a/crates/weksa/tests/lower.rs +++ b/crates/weksa/tests/lower.rs @@ -436,3 +436,132 @@ fn ipa_needs_an_entity_the_port_holds_and_a_packet_that_cites_one() { Err(LowerError::NoEntityReferent) ); } + +/// `nibu-proof-001` re-cut as the request: the addressee asked Nibu to stop, +/// one frame:3 predication (n4), consent naming it, and no other claim. +fn asked() -> Packet { + let mut p = proof_packet(); + p.referents.remove(1); + let mut ask = p.predications[1].clone(); + ask.id = nd("n4"); + ask.concept = iv("concept:6"); + ask.frame = iv("frame:3"); + ask.roles = vec![ + weksa::packet::RoleFill { + role: iv("role:1"), + filler: nd("n1"), + }, + weksa::packet::RoleFill { + role: iv("role:2"), + filler: nd("n3"), + }, + ]; + p.predications = vec![ask]; + p.discourse.speech_act = Some(iv("speech_act:1")); + p.discourse.addressee_consent = Some(nd("n4")); + p.constraints.preserve = vec![nd("n1")]; + p +} + +/// The request plus a frame:2 predication (n5) the consent could wrongly name. +fn asked_and_stopped() -> Packet { + let mut p = asked(); + let mut stop = proof_packet().predications.remove(1); + stop.roles[1].filler = nd("n4"); + p.predications.push(stop); + p +} + +#[test] +fn a_consented_request_lowers_to_what_the_addressee_asked() { + let (inv, _dir) = committed(); + assert_eq!(lowered(&asked(), &inv).unwrap(), "You asked Nibu to stop."); + let mut p = asked(); + p.provenance.source = weksa::packet::ProvenanceSource::GameTelemetry; + assert_eq!(lowered(&p, &inv).unwrap(), "You asked Nibu to stop."); +} + +#[test] +fn a_request_without_its_consent_is_refused_not_invented() { + let (inv, _dir) = committed(); + let mut p = asked(); + p.discourse.addressee_consent = None; + assert_eq!( + lowered(&p, &inv), + Err(LowerError::ConsentMissing { + predication: nd("n4") + }) + ); + // Consent that names a different predication is no consent to the request. + let mut p = asked_and_stopped(); + p.discourse.addressee_consent = Some(nd("n5")); + assert_eq!( + lowered(&p, &inv), + Err(LowerError::ConsentMissing { + predication: nd("n4") + }) + ); +} + +#[test] +fn consent_to_one_of_two_requests_leaves_the_other_unconsented() { + let (inv, _dir) = committed(); + let mut p = asked(); + let mut second = p.predications[0].clone(); + second.id = nd("n5"); + p.predications.push(second); + assert_eq!( + lowered(&p, &inv), + Err(LowerError::ConsentMissing { + predication: nd("n5") + }) + ); +} + +#[test] +fn a_request_by_someone_other_than_the_addressee_is_refused() { + let (inv, _dir) = committed(); + let mut p = asked(); + p.predications[0].roles[0].filler = nd("n3"); + p.predications[0].roles[1].filler = nd("n1"); + assert_eq!( + lowered(&p, &inv), + Err(LowerError::AskerNotAddressee { + predication: nd("n4") + }) + ); +} + +#[test] +fn consent_on_a_claim_that_is_no_request_stays_unrendered() { + let (inv, _dir) = committed(); + let mut p = proof_packet(); + p.discourse.addressee_consent = Some(nd("n5")); + assert_eq!( + lowered(&p, &inv), + Err(LowerError::Unrendered { + what: "addressee consent" + }) + ); +} + +#[test] +fn a_decomposition_or_import_cannot_reach_the_lowerer_with_consent() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let inventory = InventoryStore::open(&root.join("data/interlingua/inventory.cc")).unwrap(); + let corpus = CorpusStore::open(&root.join("data/corpus/corpus.cc")).unwrap(); + let entities = EntityStore::open(&root.join("data/entity/entity.cc")).unwrap(); + for source in [ + weksa::packet::ProvenanceSource::ModelDecomposition, + weksa::packet::ProvenanceSource::ImportedCorpus, + ] { + let mut p = asked(); + p.provenance.source = source; + let dir = tempfile::tempdir().unwrap(); + let refused = PacketStore::open(&dir.path().join("p.cc")) + .unwrap() + .admit(p, &inventory, &corpus, &entities) + .unwrap_err(); + assert!(format!("{refused:?}").contains("V15"), "{source:?}"); + } +} diff --git a/data/interlingua/inventory.cc b/data/interlingua/inventory.cc index becfc92..cf353db 100644 Binary files a/data/interlingua/inventory.cc and b/data/interlingua/inventory.cc differ diff --git a/docs/interlingua-standard.md b/docs/interlingua-standard.md index ca392d1..e349a90 100644 --- a/docs/interlingua-standard.md +++ b/docs/interlingua-standard.md @@ -195,9 +195,10 @@ and `features` carry source-side nuance on content (hedge, certainty, irony and whatever the inventory grows). This is source-side communicative pressure, not target-language register. `addressee_consent` is no feature: it names the predication the addressee asked for or agreed to, a fact about what the -addressee did. The English lowerer renders no consent and refuses a packet that -carries one; a row that speaks for the request ("you asked her to") reads it -and refuses without it. +addressee did. The English lowerer's request row (`frame:3`, "{agent} asked +{patient} to stop") reads it: the consent must name that predication and its +agent must be the addressee, else `ConsentMissing` or `AskerNotAddressee`. A +consent naming any other predication is refused as unrendered. ### `stance` diff --git a/docs/targets/interlingua-lowering-map.md b/docs/targets/interlingua-lowering-map.md index 4517320..5fa3022 100644 --- a/docs/targets/interlingua-lowering-map.md +++ b/docs/targets/interlingua-lowering-map.md @@ -342,7 +342,7 @@ measurable baseline from a packet: `lower` gives text, `score` gives numbers. | Whether a packet exists | `validate` / `PacketStore::admit` (unchanged) | packet, inventory, corpus, entities | admitted packet or typed refusal | the lowerer | | Wording of a packet in English | `crates/weksa/src/lower.rs`: `lower(&LoweringInput, &Inventory) -> Result` | the validated lowering view and the inventory at its revision | text, or a typed refusal naming the missing template | the corpus item, the scorer, any model | | English template table (concept words, frame templates, speech-act openers) | the same module, as rows `(inventory id, gloss it speaks for, surface)` | none; it is data in code | rows the lowerer looks up | the inventory (it owns the ids and glosses, not the English) | -| The inventory's concepts and frames | an inventory revision; here `inventory::proof_revision()` (rev 2), hand-seeded as `seed()` is | none | the 5 concepts and 2 frames `nibu-proof-001` names | the lowerer | +| The inventory's concepts and frames | an inventory revision; here `inventory::proof_revision()` (rev 2), hand-seeded as `seed()` is | none | the 6 concepts and 3 frames `nibu-proof-001` and the consent-gated request packet name | the lowerer | | What a score counts | `Scorer::score` (unchanged) | output text, reference text | `ItemScore` | `weksa score`, which only reads text and calls it | | The proof packet | `packet::proof_packet()`, typed ids of rev 2, admitted by `weksa packet proof ` through `PacketStore::admit`. Not JSON: `Packet`'s serde is CultCache's positional tuple, and JSON in is the xenos boundary the cut 7 encoder owns | none | admitted packet | the CLI decides nothing about validity |