Lowering input: the speaker's relation to the addressee (Scene slot, with_scene) #8

Merged
weksa merged 1 commit from weksa-lowering/speaker-input into main 2026-11-06 18:19:50 +00:00
5 changed files with 270 additions and 8 deletions

View file

@ -189,6 +189,9 @@ pub enum Slot {
Predication,
Relation,
Stance,
/// The relation of the speaker to the addressee in the scene; see
/// `LoweringInput::with_scene`.
Scene,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
@ -508,3 +511,49 @@ pub fn proof_revision() -> (InventoryRevision, Vec<InventoryEntry>) {
entries,
)
}
/// Revision 3: the speaker's relation to the addressee, features of slot
/// `Scene`. Hand-seeded like [`proof_revision`] until the loop's minting round
/// and cut `speaker-scene` own the Scene vocabulary. `relative_status` is the
/// speaker's standing against the addressee's, not a register word:
/// what it selects is each language's ontology's business.
pub fn scene_revision() -> (InventoryRevision, Vec<InventoryEntry>) {
let taken = seed()
.1
.iter()
.filter(|e| e.family() == Family::Feature)
.count() as u32;
let labels = ["below", "level", "above"]
.map(|l| OptionLabel::parse(l).expect("seed labels are well formed"));
let relative_status = ValueKind::Options(
OptionSet::new(labels.to_vec()).expect("seed option sets are well formed"),
);
let mut entries = Vec::new();
for (i, (gloss, kind)) in [
("relative_status", relative_status),
("intimacy", ValueKind::Grade),
("in_group", ValueKind::Flag),
]
.into_iter()
.enumerate()
{
entries.push(InventoryEntry {
id: Family::Feature.id(NonZeroU32::MIN.saturating_add(taken + i as u32)),
gloss: gloss.to_string(),
value_kind: Some(kind),
applies_to: vec![Slot::Scene],
introduced_rev: 3,
retired_rev: None,
});
}
let changes = entries.iter().map(|e| Change::Add(e.id)).collect();
(
InventoryRevision {
n: 3,
parent: Some(2),
changes,
frozen: false,
},
entries,
)
}

View file

@ -5,7 +5,8 @@
//! the crate-private `admissible`. The stance is checked like every feature
//! list, in slot `Stance`. The rules are V1..V8 and V10..V12: the shape of an id
//! or a grade is its type's parse, not a rule, so V9 is retired and not
//! renumbered.
//! renumbered. V13 guards the one other way features reach a lowering view:
//! [`LoweringInput::with_scene`].
//!
//! `validate` owns form and capacity and makes no leak decision. A packet's one
//! free text is its trace (`notes` and `rejected_readings`): `validate` does not
@ -66,6 +67,10 @@ pub enum Refusal {
V11DuplicateRole { role: InvId, predication: NodeId },
/// V12: more referents, predications and relations than `Packet::MAX_NODES`.
V12TooManyNodes { count: usize, max: usize },
/// V13: scene features were given for a packet with no addressee. They
/// state the speaker's relation to the addressee, so with none they
/// describe nobody.
V13SceneWithoutAddressee,
}
impl Refusal {
@ -84,6 +89,7 @@ impl Refusal {
| Refusal::V11DuplicateNodeRef { .. }
| Refusal::V11DuplicateRole { .. } => "V11",
Refusal::V12TooManyNodes { .. } => "V12",
Refusal::V13SceneWithoutAddressee => "V13",
}
}
}
@ -437,6 +443,11 @@ fn fits(kind: Option<&ValueKind>, value: &FeatValue) -> bool {
/// fn read(l: weksa::LoweringInput) { let _ = l.stance; }
/// ```
///
///
/// ```compile_fail
/// fn read(l: weksa::LoweringInput) { let _ = l.scene; }
/// ```
///
/// The constructor is private: a call from outside the crate compiles only if
/// `LoweringInput::of` is `pub`. The first block is the same code without the
/// call, so the second fails for that call and nothing else.
@ -474,6 +485,7 @@ pub struct LoweringInput {
constraints: Constraints,
extensions: Vec<Extension>,
stance: Stance,
scene: Vec<FeatureValue>,
}
impl LoweringInput {
@ -489,9 +501,44 @@ impl LoweringInput {
constraints: p.constraints.clone(),
extensions: p.extensions.clone(),
stance: p.stance.clone(),
scene: Vec::new(),
}
}
/// Adds the speaker's relation to the addressee: feature values of slot
/// `Scene`, checked like every feature list (V5 to V8, V11) at this view's
/// inventory revision. They state a relation, so the packet must name an
/// addressee (V13). Typed ids and grades only, never wording, so a scene
/// carries no text to the lowerer. The scene is not part of the packet: a
/// scene document supplies it, and replacing it replaces the earlier one.
pub fn with_scene(
mut self,
inventory: &Inventory,
scene: Vec<FeatureValue>,
) -> Result<Self, Vec<Refusal>> {
let mut out = Vec::new();
if !inventory.has_revision(self.inventory_rev) {
out.push(Refusal::V10UnknownRevision {
rev: self.inventory_rev,
});
} else {
Inventoried {
inv: inventory,
rev: self.inventory_rev,
out: &mut out,
}
.features(&scene, Slot::Scene);
}
if !scene.is_empty() && self.discourse.addressee.is_none() {
out.push(Refusal::V13SceneWithoutAddressee);
}
if !out.is_empty() {
return Err(out);
}
self.scene = scene;
Ok(self)
}
pub fn inventory_rev(&self) -> u32 {
self.inventory_rev
}
@ -522,4 +569,8 @@ impl LoweringInput {
pub fn stance(&self) -> &Stance {
&self.stance
}
/// The speaker's relation to the addressee; empty until `with_scene`.
pub fn scene(&self) -> &[FeatureValue] {
&self.scene
}
}

152
crates/weksa/tests/scene.rs Normal file
View file

@ -0,0 +1,152 @@
//! The speaker's relation to the addressee as a lowering input: typed feature
//! values of slot `Scene`, checked against the inventory at the view's revision,
//! never wording, and only for a packet that names an addressee.
mod common;
use std::path::Path;
use common::{grade, iv, lab};
use weksa::inventory::{proof_revision, scene_revision, seed};
use weksa::packet::{FeatValue, FeatureValue, Packet, proof_packet};
use weksa::{
CorpusStore, EntityStore, Inventory, InventoryStore, LoweringInput, PacketStore, Refusal, lower,
};
fn store() -> (InventoryStore, Inventory, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let mut store = InventoryStore::open(&dir.path().join("i.cc")).unwrap();
for (rev, entries) in [seed(), proof_revision(), scene_revision()] {
store.admit_revision(rev, entries).unwrap();
}
let inv = store.inventory().unwrap();
(store, inv, dir)
}
fn admitted(packet: &Packet, inventory: &InventoryStore) -> LoweringInput {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
let corpus = CorpusStore::open(&root.join("data/corpus/corpus.cc")).unwrap();
let entities = EntityStore::open(&root.join("data/entity/entity.cc")).unwrap();
let dir = tempfile::tempdir().unwrap();
PacketStore::open(&dir.path().join("p.cc"))
.unwrap()
.admit(packet.clone(), inventory, &corpus, &entities)
.unwrap()
}
fn at_rev(rev: u32) -> Packet {
let mut p = proof_packet();
p.inventory_rev = rev;
p
}
fn feat(id: &str, value: FeatValue) -> FeatureValue {
FeatureValue {
feature: iv(id),
value,
}
}
/// `scene_revision` mints the three Scene features after the seed's features.
fn status(inv: &Inventory) -> String {
inv.entries()
.find(|e| e.gloss == "relative_status")
.unwrap()
.id
.to_string()
}
fn id_of(inv: &Inventory, gloss: &str) -> String {
inv.entries()
.find(|e| e.gloss == gloss)
.unwrap()
.id
.to_string()
}
fn rules(r: Result<LoweringInput, Vec<Refusal>>) -> Vec<&'static str> {
r.unwrap_err().iter().map(Refusal::rule).collect()
}
#[test]
fn a_scene_relation_reaches_the_lowering_view_and_changes_no_english() {
let (store, inv, _dir) = store();
let plain = admitted(&at_rev(3), &store);
assert!(plain.scene().is_empty());
let scene = vec![
feat(&status(&inv), FeatValue::Option(lab("above"))),
feat(&id_of(&inv, "intimacy"), FeatValue::Grade(grade(200))),
feat(&id_of(&inv, "in_group"), FeatValue::Flag(false)),
];
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());
}
#[test]
fn a_relation_needs_an_addressee() {
let (store, inv, _dir) = store();
let mut p = at_rev(3);
p.discourse.addressee = None;
let view = admitted(&p, &store);
let scene = vec![feat(&id_of(&inv, "in_group"), FeatValue::Flag(true))];
assert_eq!(rules(view.clone().with_scene(&inv, scene)), ["V13"]);
assert!(view.with_scene(&inv, vec![]).unwrap().scene().is_empty());
}
#[test]
fn a_relation_value_is_checked_like_any_feature() {
let (store, inv, _dir) = store();
let view = admitted(&at_rev(3), &store);
let status = status(&inv);
let intimacy = id_of(&inv, "intimacy");
let take = |scene| view.clone().with_scene(&inv, scene);
// A label the feature does not declare.
assert_eq!(
rules(take(vec![feat(&status, FeatValue::Option(lab("lordly")))])),
["V7"]
);
// A value of the wrong kind.
assert_eq!(
rules(take(vec![feat(&intimacy, FeatValue::Flag(true))])),
["V7"]
);
// A feature declared for another slot: seed feature 1 sits in Predication.
assert_eq!(
rules(take(vec![feat(
"feature:1",
FeatValue::Option(lab("negative"))
)])),
["V8"]
);
// An id that is not a feature at all.
assert_eq!(
rules(take(vec![feat("concept:3", FeatValue::Flag(true))])),
["V6"]
);
// Twice in one list.
let twice = vec![
feat(&intimacy, FeatValue::Grade(grade(1))),
feat(&intimacy, FeatValue::Grade(grade(2))),
];
assert_eq!(rules(take(twice)), ["V11"]);
}
#[test]
fn a_view_of_a_revision_before_the_scene_vocabulary_cannot_carry_one() {
let (store, inv, _dir) = store();
// Revision 2 predates the Scene features, so their ids do not resolve there.
let view = admitted(&at_rev(2), &store);
let scene = vec![feat(&id_of(&inv, "intimacy"), FeatValue::Grade(grade(1)))];
assert_eq!(rules(view.with_scene(&inv, scene)), ["V5"]);
}
#[test]
fn a_stance_feature_is_not_a_scene_feature() {
let (store, inv, _dir) = store();
let view = admitted(&at_rev(3), &store);
let anger = id_of(&inv, "anger");
assert_eq!(
rules(view.with_scene(&inv, vec![feat(&anger, FeatValue::Grade(grade(1)))])),
["V8"]
);
}

View file

@ -394,6 +394,7 @@ One line per cut; specs exist for the cuts about to run (2d and 4, siblings on `
2d. `entity-door-lows`: invisible format characters refused in entity forms (ZWJ, ZWNJ, CGJ and variation selectors kept); the form length bound per question `form-word-bound` (recommended: bytes only); attested origins (`Cast { persona, fixture }`, `Live { persona }`) and `CastAuthored.author`; empty provenance strings refused at the store; S6 under NFKC plus case fold and no-alphanumeric texts refused; `next_key` refuses at `u32::MAX`; CLI flags that do not apply refuse and no error echoes a value. Owns follow-ups `entity-door-lows` and `s6-unicode-normal-form`. (spec r1)
3b. `lowerer-first-cut` (follow-up `golf-loop-lowerer-cut`): the section above. `lower`, `weksa lower`, `weksa score`, `weksa packet proof`, inventory rev 2 for `nibu-proof-001`.
4. `speaker-state`: packet `stance` (felt affect and restraint, slot `Stance`, packet schema v4); formality, warmth and intensity out of the seed and the standard; only `PacketStore::admit` yields a `LoweringInput`, and it refuses packets for withdrawn items (follow-up `lowering-input-from-packet-store`). (spec r1)
4a. `speaker-relation-input`: slot `Scene` and its first seed features (`relative_status`, `intimacy`, `in_group`; inventory revision 3, `scene_revision`); `LoweringInput::with_scene` takes the speaker's relation to the addressee as typed `Scene` feature values, validated like every feature list, refused without an addressee (V13). The English lowerer does not render it. 4b's `weksa.scene` document is its future supplier; no store, document or CLI yet. (PR for follow-up `chat-s3352-3352`)
4b. `speaker-scene`: slots `Speaker` and `Scene` with their seed features; `weksa.speaker` (`speaker:<entity-slug>@<n>`) and `weksa.scene` (`scene:<group>@<n>`, participants and ordered-pair relations) documents with `Annotator` `Operator` and `Model { family, generator }`, validated by the packet's shared feature check, append-only per key; their stores, `weksa speaker` and `weksa scene` commands, published schemas; Model-annotated cast speakers and scenes for the committed items. Waits on 2d and 4; `pd-speaker-annotation` blocks only the public-domain annotator.
5. `language-ontology`: `weksa.community` from `data/cultural-ontology/` (owns follow-up `edda-referent-yaml-parse`), `weksa.ontology_entry` for en-US and ja-JP from `data/target-language-ontology/` (YAML deleted), speaker communities, deterministic `select`, the lowering view (packet without trace, speaker, scene, selected entries). Waits on 4.
6. `pd-importer`: public-domain play and fiction dialogue into the corpus with speaker, scene group, edition provenance, and Speaker and Scene annotations by the ruled annotator. Waits on 4, follow-up `gutenberg-terms` and question `pd-speaker-annotation`.

View file

@ -69,13 +69,22 @@
},
"Slot": {
"description": "Where a feature may sit in a packet.",
"type": "string",
"enum": [
"Discourse",
"Referent",
"Predication",
"Relation",
"Stance"
"oneOf": [
{
"type": "string",
"enum": [
"Discourse",
"Referent",
"Predication",
"Relation",
"Stance"
]
},
{
"description": "The relation of the speaker to the addressee in the scene; see\n`LoweringInput::with_scene`.",
"type": "string",
"const": "Scene"
}
]
},
"ValueKind": {