Merge pull request 'Consent-gated request row: 'You asked Nibu to stop.' lowers only when the packet carries addressee consent' (#18) from weksa-body/consent-gated-asked-her-row into main

This commit is contained in:
Weksa 2026-11-09 10:12:23 +00:00
commit 2308b91fea
6 changed files with 224 additions and 9 deletions

View file

@ -485,9 +485,20 @@ pub fn proof_revision() -> (InventoryRevision, Vec<InventoryEntry>) {
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 {

View file

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

View file

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

Binary file not shown.

View file

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

View file

@ -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<String, LowerError>` | 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 <store>` 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 |