diff --git a/crates/weksa/src/inventory.rs b/crates/weksa/src/inventory.rs index 22538dd..199333f 100644 --- a/crates/weksa/src/inventory.rs +++ b/crates/weksa/src/inventory.rs @@ -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) { 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) { + 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, + ) +} diff --git a/crates/weksa/src/validate.rs b/crates/weksa/src/validate.rs index 7fbf6aa..0f26278 100644 --- a/crates/weksa/src/validate.rs +++ b/crates/weksa/src/validate.rs @@ -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, stance: Stance, + scene: Vec, } 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, + ) -> Result> { + 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 + } } diff --git a/crates/weksa/tests/scene.rs b/crates/weksa/tests/scene.rs new file mode 100644 index 0000000..7c922c5 --- /dev/null +++ b/crates/weksa/tests/scene.rs @@ -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>) -> 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"] + ); +} diff --git a/docs/targets/interlingua-lowering-map.md b/docs/targets/interlingua-lowering-map.md index 2183f5b..c828266 100644 --- a/docs/targets/interlingua-lowering-map.md +++ b/docs/targets/interlingua-lowering-map.md @@ -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:@`) and `weksa.scene` (`scene:@`, 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`. diff --git a/schemas/cultnet/weksa.inventory_entry.schema.json b/schemas/cultnet/weksa.inventory_entry.schema.json index ffefd95..ba3fe5d 100644 --- a/schemas/cultnet/weksa.inventory_entry.schema.json +++ b/schemas/cultnet/weksa.inventory_entry.schema.json @@ -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": {