From 0b62d2f3b422ca2d3e77df0e9c4d91f1c6340e34 Mon Sep 17 00:00:00 2001 From: Weksa Date: Tue, 10 Nov 2026 02:29:51 +0000 Subject: [PATCH 1/5] Retire V13 and with_scene's raw door; Stance and Scene features sit in one slot with_scene is removed until it takes a Scene document (next commit); V13 goes with it. Slot exclusivity becomes a rule of InventoryEntry::well_formed, so FEAT_ALL leaves Stance and its stance duplicate case goes (ANGER pins V11 in Stance). tests/scene.rs is deleted and rewritten on Scene documents later. Co-Authored-By: Claude Sonnet 5.5 --- crates/weksa/src/inventory.rs | 15 ++- crates/weksa/src/validate.rs | 47 +--------- crates/weksa/tests/common/mod.rs | 4 +- crates/weksa/tests/display.rs | 1 - crates/weksa/tests/scene.rs | 156 ------------------------------- crates/weksa/tests/stores.rs | 40 ++++++++ crates/weksa/tests/validate.rs | 3 - 7 files changed, 58 insertions(+), 208 deletions(-) delete mode 100644 crates/weksa/tests/scene.rs diff --git a/crates/weksa/src/inventory.rs b/crates/weksa/src/inventory.rs index c9d69a2..d5f2e81 100644 --- a/crates/weksa/src/inventory.rs +++ b/crates/weksa/src/inventory.rs @@ -228,10 +228,21 @@ impl InventoryEntry { } /// Whether the entry's shape matches its family: features carry a value kind - /// and slots, extensions a value kind and no slots, nothing else either. + /// and slots, extensions a value kind and no slots, nothing else either. A + /// feature of slot `Stance` or `Scene` sits in that slot alone: each + /// speaker-state layer has one writer and one lifetime, so its features sit + /// nowhere else. pub fn well_formed(&self) -> bool { match self.family() { - Family::Feature => self.value_kind.is_some() && !self.applies_to.is_empty(), + Family::Feature => { + self.value_kind.is_some() + && !self.applies_to.is_empty() + && (self.applies_to.len() == 1 + || !self + .applies_to + .iter() + .any(|s| matches!(s, Slot::Stance | Slot::Scene))) + } Family::Extension => self.value_kind.is_some() && self.applies_to.is_empty(), _ => self.value_kind.is_none() && self.applies_to.is_empty(), } diff --git a/crates/weksa/src/validate.rs b/crates/weksa/src/validate.rs index 6a0075f..e5a917e 100644 --- a/crates/weksa/src/validate.rs +++ b/crates/weksa/src/validate.rs @@ -3,10 +3,9 @@ //! returns `Ok(())` or the refusals. [`LoweringInput`], the only thing a //! lowerer sees, comes from `PacketStore::admit` alone, which builds it through //! the crate-private `admissible`. The stance is checked like every feature -//! list, in slot `Stance`. The rules are V1..V8 and V10..V15: the shape of an id -//! or a grade is its type's parse, not a rule, so V9 is retired and not -//! renumbered. V13 guards the one other way features reach a lowering view: -//! [`LoweringInput::with_scene`]. +//! list, in slot `Stance`. The rules are V1..V8, V10..V12 and V14..V15: the +//! shape of an id or a grade is its type's parse, not a rule, so V9 is retired +//! and not renumbered; V13 is retired too. //! //! `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 @@ -95,10 +94,6 @@ pub enum Refusal { 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, /// V14: an addressee consent that no addressee can have given, or that /// names a node that is not a predication. It is what the addressee asked /// for or agreed to, an act, so a referent cannot be its object. @@ -130,7 +125,6 @@ impl Refusal { | Refusal::V11DuplicateNodeRef { .. } | Refusal::V11DuplicateRole { .. } => "V11", Refusal::V12TooManyNodes { .. } => "V12", - Refusal::V13SceneWithoutAddressee => "V13", Refusal::V14ConsentWithoutAddressee | Refusal::V14ConsentNotAPredication { .. } => { "V14" } @@ -157,7 +151,6 @@ impl fmt::Display for Refusal { Refusal::V11DuplicateNodeRef { .. } => "a node id repeats in one list", Refusal::V11DuplicateRole { .. } => "a role id repeats in one predication", Refusal::V12TooManyNodes { .. } => "too many nodes", - Refusal::V13SceneWithoutAddressee => "scene features given without an addressee", Refusal::V14ConsentWithoutAddressee => "consent given without an addressee", Refusal::V14ConsentNotAPredication { .. } => "consent names no predication", Refusal::V15ConsentSource { .. } => "consent set by a source that only read wording", @@ -594,40 +587,6 @@ impl LoweringInput { } } - /// 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 } diff --git a/crates/weksa/tests/common/mod.rs b/crates/weksa/tests/common/mod.rs index 60ba544..9368227 100644 --- a/crates/weksa/tests/common/mod.rs +++ b/crates/weksa/tests/common/mod.rs @@ -34,7 +34,8 @@ pub const CONCEPT_LATE: &str = "concept:4"; pub const FRAME: &str = "frame:1"; /// An extension entry of value kind Grade, introduced at rev 2. pub const EXT: &str = "extension:1"; -/// A Grade feature introduced at rev 2 that sits in every slot. +/// A Grade feature introduced at rev 2 in every content slot (Discourse, +/// Referent, Predication, Relation). pub const FEAT_ALL: &str = "feature:50"; pub const PACKET_ID: &str = "packet:0123456789abcdef"; @@ -218,7 +219,6 @@ pub fn world() -> World { Slot::Referent, Slot::Predication, Slot::Relation, - Slot::Stance, ], ) }, diff --git a/crates/weksa/tests/display.rs b/crates/weksa/tests/display.rs index 3c1ba63..44063fa 100644 --- a/crates/weksa/tests/display.rs +++ b/crates/weksa/tests/display.rs @@ -117,7 +117,6 @@ fn refusal_display_names_the_rule_and_no_identifier() { count: 4242, max: 4242, }, - Refusal::V13SceneWithoutAddressee, Refusal::V14ConsentWithoutAddressee, Refusal::V14ConsentNotAPredication { id: node() }, Refusal::V15ConsentSource { diff --git a/crates/weksa/tests/scene.rs b/crates/weksa/tests/scene.rs deleted file mode 100644 index 0fc4168..0000000 --- a/crates/weksa/tests/scene.rs +++ /dev/null @@ -1,156 +0,0 @@ -//! 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, - } -} - -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()); - 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] -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/crates/weksa/tests/stores.rs b/crates/weksa/tests/stores.rs index 22ecb83..fbcce9a 100644 --- a/crates/weksa/tests/stores.rs +++ b/crates/weksa/tests/stores.rs @@ -1093,6 +1093,46 @@ fn revision_change_rules() { assert!(inv.entry(&iv("role:1")).unwrap().retired_rev.is_none()); } +#[test] +fn speaker_state_slots_are_exclusive() { + let shape = "entry shape does not match its family"; + let d = dir(); + let mut store = seeded(&d.path().join("i.cc")); + for slots in [ + &[Slot::Stance, Slot::Discourse][..], + &[Slot::Scene, Slot::Relation], + &[Slot::Discourse, Slot::Scene], + &[Slot::Scene, Slot::Scene], + &[Slot::Stance, Slot::Stance], + ] { + let rev = rev_with(vec![Change::Add(iv("feature:50"))], 2, Some(1)); + let entries = vec![InventoryEntry { + introduced_rev: 2, + ..feature("feature:50", ValueKind::Flag, slots) + }]; + assert_eq!( + refusal(store.admit_revision(rev, entries)), + malformed("feature:50", shape), + "{slots:?}" + ); + } + assert_eq!(store.inventory().unwrap().latest().unwrap().n, 1); + // One slot alone, and several content slots, are admitted. + for (n, slots) in [ + (2, &[Slot::Scene][..]), + (3, &[Slot::Stance]), + (4, &[Slot::Discourse, Slot::Predication]), + ] { + let id = format!("feature:{}", 50 + n); + let rev = rev_with(vec![Change::Add(iv(&id))], n, Some(n - 1)); + let entries = vec![InventoryEntry { + introduced_rev: n, + ..feature(&id, ValueKind::Flag, slots) + }]; + store.admit_revision(rev, entries).unwrap(); + } +} + #[test] fn a_well_formed_extension_entry_is_admitted() { let d = dir(); diff --git a/crates/weksa/tests/validate.rs b/crates/weksa/tests/validate.rs index aaa87bd..c5bc172 100644 --- a/crates/weksa/tests/validate.rs +++ b/crates/weksa/tests/validate.rs @@ -520,9 +520,6 @@ fn v11_duplicate_feature_each_slot() { refusals(&relation), vec![v11(Some(Slot::Relation), FEAT_ALL)] ); - let mut stance = good(); - stance.stance.features = vec![grade_feature(1), grade_feature(2)]; - assert_eq!(refusals(&stance), vec![v11(Some(Slot::Stance), FEAT_ALL)]); let mut felt = good(); felt.stance .features -- 2.47.3 From 6ef1b7e0347600a2430ff778ad4d40bbe9a0b871 Mon Sep 17 00:00:00 2001 From: Weksa Date: Tue, 10 Nov 2026 02:34:10 +0000 Subject: [PATCH 2/5] Speaker and Scene documents, SpeakerStateStore, with_scene on a Scene document Slot Speaker, weksa.speaker and weksa.scene append-only revisions in one store, V16, feature_refusals as the one feature check, speaker_scene_revision (Speaker features 22-24), schemas, docs and map lines. Part 1 of map cut 4b. Co-Authored-By: Claude Sonnet 5.5 --- crates/weksa/src/inventory.rs | 65 ++- crates/weksa/src/lib.rs | 8 +- crates/weksa/src/schema.rs | 3 + crates/weksa/src/speaker.rs | 115 +++++ crates/weksa/src/store.rs | 229 ++++++++- crates/weksa/src/validate.rs | 93 +++- crates/weksa/tests/cli.rs | 4 +- crates/weksa/tests/scene.rs | 244 +++++++++ crates/weksa/tests/speaker_state.rs | 478 ++++++++++++++++++ crates/weksa/tests/stores.rs | 7 +- docs/interlingua-standard.md | 19 + docs/targets/dao-de-jing-test-bed.md | 2 +- docs/targets/interlingua-lowering-map.md | 7 +- .../cultnet/weksa.inventory_entry.schema.json | 7 +- schemas/cultnet/weksa.scene.schema.json | 224 ++++++++ schemas/cultnet/weksa.speaker.schema.json | 171 +++++++ 16 files changed, 1638 insertions(+), 38 deletions(-) create mode 100644 crates/weksa/src/speaker.rs create mode 100644 crates/weksa/tests/scene.rs create mode 100644 crates/weksa/tests/speaker_state.rs create mode 100644 schemas/cultnet/weksa.scene.schema.json create mode 100644 schemas/cultnet/weksa.speaker.schema.json diff --git a/crates/weksa/src/inventory.rs b/crates/weksa/src/inventory.rs index d5f2e81..6c8b984 100644 --- a/crates/weksa/src/inventory.rs +++ b/crates/weksa/src/inventory.rs @@ -189,9 +189,11 @@ pub enum Slot { Predication, Relation, Stance, - /// The relation of the speaker to the addressee in the scene; see - /// `LoweringInput::with_scene`. + /// The speaker's relation to one party of the scene, held by `weksa.scene` + /// documents; see `LoweringInput::with_scene`. Scene, + /// The speaker's stable axes, held by `weksa.speaker` documents. + Speaker, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] @@ -229,7 +231,7 @@ impl InventoryEntry { /// Whether the entry's shape matches its family: features carry a value kind /// and slots, extensions a value kind and no slots, nothing else either. A - /// feature of slot `Stance` or `Scene` sits in that slot alone: each + /// feature of slot `Stance`, `Speaker` or `Scene` sits in that slot alone: each /// speaker-state layer has one writer and one lifetime, so its features sit /// nowhere else. pub fn well_formed(&self) -> bool { @@ -241,7 +243,7 @@ impl InventoryEntry { || !self .applies_to .iter() - .any(|s| matches!(s, Slot::Stance | Slot::Scene))) + .any(|s| matches!(s, Slot::Stance | Slot::Speaker | Slot::Scene))) } Family::Extension => self.value_kind.is_some() && self.applies_to.is_empty(), _ => self.value_kind.is_none() && self.applies_to.is_empty(), @@ -534,28 +536,49 @@ pub fn proof_revision() -> (InventoryRevision, Vec) { ) } -/// 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. It mints the three -/// Scene features after the seed's features. -pub fn scene_revision() -> (InventoryRevision, Vec) { +/// Revision 3: the speaker-state vocabulary. First the Scene features, the +/// speaker's relation to one party of a scene (slot `Scene`): `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. Then the Speaker features +/// (slot `Speaker`): `socioeconomic_standing` is standing in the speaker's own +/// society, 0 lowest; `education` is formal schooling against that society; +/// `age_cohort` is a life stage. Origin and community are not features; they +/// arrive as community ids with cut `language-ontology`. Hand-seeded like +/// [`proof_revision`] until the loop's minting round owns the vocabulary; the +/// features take the ids after the seed's. +pub fn speaker_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 options = |labels: &[&str]| { + ValueKind::Options( + OptionSet::new( + labels + .iter() + .map(|l| OptionLabel::parse(l).expect("seed labels are well formed")) + .collect(), + ) + .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), + for (i, (gloss, kind, slot)) in [ + ( + "relative_status", + options(&["below", "level", "above"]), + Slot::Scene, + ), + ("intimacy", ValueKind::Grade, Slot::Scene), + ("in_group", ValueKind::Flag, Slot::Scene), + ("socioeconomic_standing", ValueKind::Grade, Slot::Speaker), + ("education", ValueKind::Grade, Slot::Speaker), + ( + "age_cohort", + options(&["child", "adolescent", "young_adult", "adult", "elder"]), + Slot::Speaker, + ), ] .into_iter() .enumerate() @@ -564,7 +587,7 @@ pub fn scene_revision() -> (InventoryRevision, Vec) { 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], + applies_to: vec![slot], introduced_rev: 3, retired_rev: None, }); diff --git a/crates/weksa/src/lib.rs b/crates/weksa/src/lib.rs index bb42311..e147524 100644 --- a/crates/weksa/src/lib.rs +++ b/crates/weksa/src/lib.rs @@ -14,6 +14,7 @@ pub mod packet; pub mod phones; pub mod schema; pub mod score; +pub mod speaker; pub mod split; pub mod store; pub mod validate; @@ -26,14 +27,15 @@ pub use inventory::{ Slot, ValueKind, }; pub use lower::{EntityForms, LowerError, PhoneLine, TARGET_LANGUAGE, lower, phones, pronounce}; -pub use phones::{Phone, PhoneInventory, SegmentError, Stress}; pub use packet::{ EncoderId, EntityId, Grade, NodeId, Packet, PacketId, PacketKind, SCHEMA_VERSION, Stance, }; +pub use phones::{Phone, PhoneInventory, SegmentError, Stress}; pub use score::{Embedder, ItemScore, OllamaEmbedder, Scorer, chrf}; +pub use speaker::{Annotator, PairRelation, Party, Scene, Speaker}; pub use split::{Split, SplitScheme}; pub use store::{ - AddOutcome, CorpusStore, EntityBound, EntityStore, InventoryStore, PacketStore, StoreError, - StoreRefusal, + AddOutcome, CorpusStore, EntityBound, EntityStore, InventoryStore, PacketStore, + SpeakerStateStore, StoreError, StoreRefusal, }; pub use validate::{LoweringInput, Refusal, validate}; diff --git a/crates/weksa/src/schema.rs b/crates/weksa/src/schema.rs index 72c27e4..883f44a 100644 --- a/crates/weksa/src/schema.rs +++ b/crates/weksa/src/schema.rs @@ -5,6 +5,7 @@ use crate::corpus::CorpusItem; use crate::entity::Entity; use crate::inventory::{InventoryEntry, InventoryRevision}; use crate::packet::Packet; +use crate::speaker::{Scene, Speaker}; use cultcache_rs::DatabaseEntry; use schemars::{JsonSchema, schema_for}; @@ -21,5 +22,7 @@ pub fn published() -> Vec<(String, String)> { published_for::(), published_for::(), published_for::(), + published_for::(), + published_for::(), ] } diff --git a/crates/weksa/src/speaker.rs b/crates/weksa/src/speaker.rs new file mode 100644 index 0000000..51367e7 --- /dev/null +++ b/crates/weksa/src/speaker.rs @@ -0,0 +1,115 @@ +//! The speaker-state documents: a speaker's stable axes and a scene's relations. +//! Both are append-only revisions under one key per revision and hold typed +//! feature values only, never text (ruling `leak-owned-by-flow-and-scorer-3`). +//! `SpeakerStateStore` is their only writer; `LoweringInput::with_scene` is the +//! only reader that reaches a lowering view. + +use cultcache_rs::DatabaseEntry; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::corpus::ModelFamily; +use crate::packet::{EntityId, FeatureValue}; + +/// The most participants one scene holds. +pub const MAX_PARTICIPANTS: usize = 64; + +/// Who annotated a document. A model's generator is an attestation string, held +/// to the same form as every provenance string. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub enum Annotator { + Operator, + Model { + family: ModelFamily, + generator: String, + }, +} + +/// The speaker's stable axes. Key `speaker:@`; a revision +/// appends and never edits. +#[derive(Clone, Debug, PartialEq, Eq, JsonSchema, DatabaseEntry)] +#[cultcache(type = "weksa.speaker")] +pub struct Speaker { + #[cultcache(key = 0)] + pub entity: EntityId, + /// The revision, from 1. + #[cultcache(key = 1)] + pub n: u32, + #[cultcache(key = 2)] + pub inventory_rev: u32, + /// Feature values of slot `Speaker`. + #[cultcache(key = 3)] + pub features: Vec, + #[cultcache(key = 4)] + pub annotator: Annotator, +} + +/// The other party of a relation. `Unnamed` is an addressee the scene does not +/// name, such as the player or a crowd. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)] +pub enum Party { + Named(EntityId), + Unnamed, +} + +/// The speaker's relation to one party: feature values of slot `Scene`. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct PairRelation { + pub from: EntityId, + pub to: Party, + pub features: Vec, +} + +/// A scene: its participants and the relations between them. Key +/// `scene:@`; a revision appends and never edits. +#[derive(Clone, Debug, PartialEq, Eq, JsonSchema, DatabaseEntry)] +#[cultcache(type = "weksa.scene")] +pub struct Scene { + /// The corpus split group of the scene, a slug. + #[cultcache(key = 0)] + pub group: String, + /// The revision, from 1. + #[cultcache(key = 1)] + pub n: u32, + #[cultcache(key = 2)] + pub inventory_rev: u32, + #[cultcache(key = 3)] + pub participants: Vec, + #[cultcache(key = 4)] + pub relations: Vec, + #[cultcache(key = 5)] + pub annotator: Annotator, +} + +impl Scene { + /// The features of the relation from `speaker` to the named `addressee`, or + /// to `Unnamed` when there is none; empty when the scene holds no such + /// relation. A named addressee the scene lacks never falls back to `Unnamed`. + pub fn relation(&self, speaker: &EntityId, addressee: Option<&EntityId>) -> &[FeatureValue] { + self.relations + .iter() + .find(|r| { + &r.from == speaker + && match (&r.to, addressee) { + (Party::Named(a), Some(b)) => a == b, + (Party::Unnamed, None) => true, + _ => false, + } + }) + .map_or(&[], |r| r.features.as_slice()) + } +} + +/// `speaker:@`. +pub fn speaker_key(entity: &EntityId, n: u32) -> String { + let slug = entity + .as_str() + .strip_prefix("entity:") + .unwrap_or(entity.as_str()); + format!("speaker:{slug}@{n}") +} + +/// `scene:@`. +pub fn scene_key(group: &str, n: u32) -> String { + format!("scene:{group}@{n}") +} diff --git a/crates/weksa/src/store.rs b/crates/weksa/src/store.rs index 25909c0..0263712 100644 --- a/crates/weksa/src/store.rs +++ b/crates/weksa/src/store.rs @@ -14,11 +14,14 @@ use unicode_segmentation::UnicodeSegmentation; use crate::corpus::{CorpusItem, CorpusItemId, Source, item_key, key_slug}; use crate::entity::{Entity, EntityOrigin, Pronunciation}; use crate::form::{ - is_attestation, is_calendar_date, is_joining_only, is_language_tag, is_refused_format, + is_attestation, is_calendar_date, is_joining_only, is_language_tag, is_refused_format, is_slug, +}; +use crate::inventory::{ + Change, InvId, Inventory, InventoryEntry, InventoryRevision, Slot, revision_key, }; -use crate::inventory::{Change, InvId, Inventory, InventoryEntry, InventoryRevision, revision_key}; use crate::packet::{EntityId, Packet}; -use crate::validate::{LoweringInput, Refusal, admissible}; +use crate::speaker::{Annotator, MAX_PARTICIPANTS, Party, Scene, Speaker, scene_key, speaker_key}; +use crate::validate::{LoweringInput, Refusal, admissible, feature_refusals}; /// A broken store rule, named. #[derive(Clone, Debug, PartialEq, Eq)] @@ -79,6 +82,15 @@ pub enum StoreRefusal { /// A new packet names a corpus item already withdrawn. Packets admitted /// before the withdrawal stay stored and readable. WithdrawnCorpusItem { key: String }, + /// A speaker or scene revision that is not the entity's or group's latest + /// plus one (1 when none). + RevisionNotNext { + key: String, + expected: u32, + got: u32, + }, + /// A scene that omits the speaker of a live item of its group. + SceneSpeakerMissing { group: String, entity: EntityId }, /// Any other malformed document or change. Malformed { key: String, why: &'static str }, } @@ -116,6 +128,10 @@ impl fmt::Display for StoreRefusal { EntityNotAppendOnly { .. } => f.write_str("EntityNotAppendOnly"), EntityNotOnItem { .. } => f.write_str("EntityNotOnItem"), WithdrawnCorpusItem { .. } => f.write_str("WithdrawnCorpusItem"), + RevisionNotNext { expected, got, .. } => { + write!(f, "RevisionNotNext expected {expected} got {got}") + } + SceneSpeakerMissing { .. } => f.write_str("SceneSpeakerMissing"), Malformed { why, .. } => write!(f, "Malformed: {why}"), } } @@ -978,3 +994,210 @@ impl PacketStore { Ok(view) } } + +/// The speaker and scene documents: append-only revisions, one key each. The +/// only writer of `weksa.speaker` and `weksa.scene`. +pub struct SpeakerStateStore { + cache: CultCache, +} + +impl SpeakerStateStore { + pub fn open(path: &Path) -> anyhow::Result { + Ok(Self { + cache: open_cache(path, |c| { + c.register_entry_type::()?; + c.register_entry_type::() + })?, + }) + } + + /// Admits the next revision of a speaker, or an identical replay. The key + /// derives from the document. Revision `n` is the entity's latest plus one + /// (1 when none). The entity is in the entity store, the features pass + /// `feature_refusals` in slot `Speaker` at the document's inventory + /// revision, and a model annotator's generator is an attestation. + pub fn put_speaker( + &mut self, + s: Speaker, + inventory: &InventoryStore, + entities: &EntityStore, + ) -> Result { + let key = speaker_key(&s.entity, s.n); + if self.get_speaker(&key)?.is_some_and(|old| old == s) { + return Ok(AddOutcome::Unchanged); + } + check_annotator(&key, &s.annotator)?; + known_entity(entities, &s.entity)?; + let out = feature_refusals( + &inventory.inventory()?, + s.inventory_rev, + &s.features, + Slot::Speaker, + ); + if !out.is_empty() { + return Err(StoreError::Invalid(out)); + } + let latest = self.speaker(&s.entity)?.map_or(0, |l| l.n); + check_next(&key, latest, s.n)?; + self.cache.put(key, &s)?; + Ok(AddOutcome::Added) + } + + /// Admits the next revision of a scene, or an identical replay. The group + /// is a slug some corpus item (live or withdrawn) holds; there are 1 to + /// `MAX_PARTICIPANTS` participants, each named once and in the entity + /// store; a relation's `from` is a participant, a named `to` is another + /// participant, and an ordered pair has at most one relation; every speaker + /// of a live item of the group is a participant. Features pass + /// `feature_refusals` in slot `Scene` for every relation. + pub fn put_scene( + &mut self, + s: Scene, + inventory: &InventoryStore, + corpus: &CorpusStore, + entities: &EntityStore, + ) -> Result { + let key = scene_key(&s.group, s.n); + if self.get_scene(&key)?.is_some_and(|old| old == s) { + return Ok(AddOutcome::Unchanged); + } + let bad = |why| malformed(&key, why); + if !is_slug(&s.group) { + return Err(bad("a scene group is a slug")); + } + check_annotator(&key, &s.annotator)?; + let items: Vec = corpus + .all()? + .into_iter() + .filter(|i| i.group == s.group) + .collect(); + if items.is_empty() { + return Err(bad("no corpus item holds the scene's group")); + } + if s.participants.is_empty() || s.participants.len() > MAX_PARTICIPANTS { + return Err(bad("a scene has 1 to 64 participants")); + } + let mut seen = BTreeSet::new(); + for p in &s.participants { + if !seen.insert(p) { + return Err(bad("a participant is named once")); + } + known_entity(entities, p)?; + } + let mut pairs = BTreeSet::new(); + for r in &s.relations { + if !seen.contains(&r.from) { + return Err(bad("a relation's from is a participant")); + } + if let Party::Named(to) = &r.to + && (!seen.contains(to) || to == &r.from) + { + return Err(bad("a relation's named to is another participant")); + } + if !pairs.insert((&r.from, &r.to)) { + return Err(bad("an ordered pair has one relation")); + } + } + if let Some(i) = items + .iter() + .find(|i| !i.withdrawn && !seen.contains(&i.speaker)) + { + return Err(StoreRefusal::SceneSpeakerMissing { + group: s.group, + entity: i.speaker.clone(), + } + .into()); + } + let inv = inventory.inventory()?; + let mut out = Vec::new(); + if s.relations.is_empty() { + out = feature_refusals(&inv, s.inventory_rev, &[], Slot::Scene); + } + for r in &s.relations { + out.extend(feature_refusals( + &inv, + s.inventory_rev, + &r.features, + Slot::Scene, + )); + } + if !out.is_empty() { + return Err(StoreError::Invalid(out)); + } + let latest = self.scene(&s.group)?.map_or(0, |l| l.n); + check_next(&key, latest, s.n)?; + self.cache.put(key, &s)?; + Ok(AddOutcome::Added) + } + + /// The latest revision (highest `n`) of the entity's speaker document. + pub fn speaker(&self, entity: &EntityId) -> anyhow::Result> { + Ok(self + .all_speakers()? + .into_iter() + .filter(|s| &s.entity == entity) + .max_by_key(|s| s.n)) + } + + /// The latest revision (highest `n`) of the group's scene document. + pub fn scene(&self, group: &str) -> anyhow::Result> { + Ok(self + .all_scenes()? + .into_iter() + .filter(|s| s.group == group) + .max_by_key(|s| s.n)) + } + + pub fn get_speaker(&self, key: &str) -> anyhow::Result> { + self.cache.get::(key) + } + + pub fn get_scene(&self, key: &str) -> anyhow::Result> { + self.cache.get::(key) + } + + /// Every speaker revision, by entity then revision number. + pub fn all_speakers(&self) -> anyhow::Result> { + let mut all = self.cache.get_all::()?; + all.sort_by(|a, b| (&a.entity, a.n).cmp(&(&b.entity, b.n))); + Ok(all) + } + + /// Every scene revision, by group then revision number. + pub fn all_scenes(&self) -> anyhow::Result> { + let mut all = self.cache.get_all::()?; + all.sort_by(|a, b| (&a.group, a.n).cmp(&(&b.group, b.n))); + Ok(all) + } +} + +fn known_entity(entities: &EntityStore, id: &EntityId) -> Result<(), StoreError> { + match entities.get(id)? { + Some(_) => Ok(()), + None => Err(StoreRefusal::UnknownEntity { entity: id.clone() }.into()), + } +} + +fn check_annotator(key: &str, a: &Annotator) -> Result<(), StoreError> { + match a { + Annotator::Model { generator, .. } if !is_attestation(generator) => { + Err(malformed(key, "a model annotator attests its generator")) + } + _ => Ok(()), + } +} + +fn check_next(key: &str, latest: u32, got: u32) -> Result<(), StoreError> { + let expected = latest + .checked_add(1) + .ok_or_else(|| malformed(key, "no revision follows the latest"))?; + if got != expected { + return Err(StoreRefusal::RevisionNotNext { + key: key.to_string(), + expected, + got, + } + .into()); + } + Ok(()) +} diff --git a/crates/weksa/src/validate.rs b/crates/weksa/src/validate.rs index e5a917e..9641062 100644 --- a/crates/weksa/src/validate.rs +++ b/crates/weksa/src/validate.rs @@ -3,9 +3,11 @@ //! returns `Ok(())` or the refusals. [`LoweringInput`], the only thing a //! lowerer sees, comes from `PacketStore::admit` alone, which builds it through //! the crate-private `admissible`. The stance is checked like every feature -//! list, in slot `Stance`. The rules are V1..V8, V10..V12 and V14..V15: the +//! list, in slot `Stance`. The rules are V1..V8, V10..V12 and V14..V16: the //! shape of an id or a grade is its type's parse, not a rule, so V9 is retired -//! and not renumbered; V13 is retired too. +//! and not renumbered; V13 is retired too. [`LoweringInput::with_scene`] takes +//! a [`Scene`](crate::speaker::Scene) document, and `feature_refusals` is the +//! one feature check it and the speaker-state store share. //! //! `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 @@ -27,9 +29,10 @@ use std::fmt; use crate::inventory::{Family, InvId, Inventory, InventoryEntry, Slot, ValueKind}; use crate::packet::{ - Constraints, Context, Discourse, Extension, FeatValue, FeatureValue, NodeId, Packet, + Constraints, Context, Discourse, EntityId, Extension, FeatValue, FeatureValue, NodeId, Packet, PacketKind, Predication, ProvenanceSource, Referent, Relation, SCHEMA_VERSION, Stance, }; +use crate::speaker::Scene; /// A broken rule, named. #[derive(Clone, Debug, PartialEq, Eq)] @@ -107,6 +110,8 @@ pub enum Refusal { V15ConsentSource { source: ProvenanceSource, }, + /// V16: `with_scene` was given a speaker the scene does not list. + V16SpeakerNotInScene, } impl Refusal { @@ -129,6 +134,7 @@ impl Refusal { "V14" } Refusal::V15ConsentSource { .. } => "V15", + Refusal::V16SpeakerNotInScene => "V16", } } } @@ -154,6 +160,7 @@ impl fmt::Display for Refusal { Refusal::V14ConsentWithoutAddressee => "consent given without an addressee", Refusal::V14ConsentNotAPredication { .. } => "consent names no predication", Refusal::V15ConsentSource { .. } => "consent set by a source that only read wording", + Refusal::V16SpeakerNotInScene => "the speaker is not a participant of the scene", }; write!(f, "{}: {what}", self.rule()) } @@ -465,6 +472,29 @@ impl<'a> Inventoried<'a> { } } +/// The one feature check of the speaker-state documents and `with_scene`: V10 +/// when `rev` has no record, else the rules the packet's own check applies to a +/// feature list in `slot` (V5 to V8, V11). +pub(crate) fn feature_refusals( + inventory: &Inventory, + rev: u32, + features: &[FeatureValue], + slot: Slot, +) -> Vec { + let mut out = Vec::new(); + if !inventory.has_revision(rev) { + out.push(Refusal::V10UnknownRevision { rev }); + } else { + Inventoried { + inv: inventory, + rev, + out: &mut out, + } + .features(features, slot); + } + out +} + fn fits(kind: Option<&ValueKind>, value: &FeatValue) -> bool { match (kind, value) { (Some(ValueKind::Grade), FeatValue::Grade(_)) => true, @@ -530,6 +560,27 @@ fn fits(kind: Option<&ValueKind>, value: &FeatValue) -> bool { /// fn read(l: weksa::LoweringInput) { let _ = l.scene; } /// ``` /// +/// Scene values come only from a `weksa.scene` document: the raw-values door +/// is gone. The first block is the same call with a document; the second hands +/// it feature values and fails for that alone. +/// +/// ``` +/// fn pick( +/// l: weksa::LoweringInput, +/// i: &weksa::Inventory, +/// s: &weksa::speaker::Scene, +/// e: &weksa::EntityId, +/// ) { +/// let _ = l.with_scene(i, s, e); +/// } +/// ``` +/// +/// ```compile_fail +/// fn pick(l: weksa::LoweringInput, i: &weksa::Inventory) { +/// let _ = l.with_scene(i, vec![]); +/// } +/// ``` +/// /// 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. @@ -587,6 +638,39 @@ impl LoweringInput { } } + /// Adds the speaker's relation to the addressee, the only path for scene + /// values into a view; they come only from a `weksa.scene` document. The + /// speaker must be a participant of `scene` (V16). With no addressee, or + /// one that names no referent, nothing is selected; otherwise the relation + /// from the speaker to the addressee referent's entity (to `Unnamed` when + /// the referent has none) is checked like every feature list, in slot + /// `Scene` at this view's inventory revision, and replaces any earlier + /// scene. The English lowerer ignores it. + pub fn with_scene( + mut self, + inventory: &Inventory, + scene: &Scene, + speaker: &EntityId, + ) -> Result> { + if !scene.participants.contains(speaker) { + return Err(vec![Refusal::V16SpeakerNotInScene]); + } + let addressee = self + .discourse + .addressee + .as_ref() + .and_then(|id| self.referents.iter().find(|r| &r.id == id)); + let selected = addressee + .map(|r| scene.relation(speaker, r.entity.as_ref())) + .unwrap_or_default(); + let out = feature_refusals(inventory, self.inventory_rev, selected, Slot::Scene); + if !out.is_empty() { + return Err(out); + } + self.scene = selected.to_vec(); + Ok(self) + } + pub fn inventory_rev(&self) -> u32 { self.inventory_rev } @@ -617,7 +701,8 @@ impl LoweringInput { pub fn stance(&self) -> &Stance { &self.stance } - /// The speaker's relation to the addressee; empty until `with_scene`. + /// The features of the scene relation from the speaker to the addressee; + /// empty until `with_scene`, or when the scene holds none. pub fn scene(&self) -> &[FeatureValue] { &self.scene } diff --git a/crates/weksa/tests/cli.rs b/crates/weksa/tests/cli.rs index 18edb84..4b439b0 100644 --- a/crates/weksa/tests/cli.rs +++ b/crates/weksa/tests/cli.rs @@ -308,7 +308,9 @@ fn schema_writes_the_published_files() { "weksa.entity.schema.json", "weksa.interlingua_packet.schema.json", "weksa.inventory_entry.schema.json", - "weksa.inventory_revision.schema.json" + "weksa.inventory_revision.schema.json", + "weksa.scene.schema.json", + "weksa.speaker.schema.json" ] ); } diff --git a/crates/weksa/tests/scene.rs b/crates/weksa/tests/scene.rs new file mode 100644 index 0000000..bc64acc --- /dev/null +++ b/crates/weksa/tests/scene.rs @@ -0,0 +1,244 @@ +//! The speaker's relation to the addressee as a lowering input: the relation of +//! a `weksa.scene` document from the speaker to the addressee referent's +//! entity, typed feature values of slot `Scene` checked against the inventory +//! at the view's revision, never wording. + +mod common; +use std::path::Path; + +use common::{eid, grade, iv, lab, nd}; +use weksa::inventory::{proof_revision, seed, speaker_scene_revision}; +use weksa::packet::{FeatValue, FeatureValue, Packet, proof_packet}; +use weksa::speaker::{Annotator, PairRelation, Party, Scene}; +use weksa::{ + CorpusStore, EntityId, 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(), speaker_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() +} + +/// The proof packet at `rev`. Its referent `n1` has no entity and `n3` is +/// `entity:nibu`; `addressee` names which one is spoken to, or none. +fn packet(rev: u32, addressee: Option<&str>) -> Packet { + let mut p = proof_packet(); + p.inventory_rev = rev; + p.discourse.addressee = addressee.map(nd); + p +} + +fn feat(inv: &Inventory, gloss: &str, value: FeatValue) -> FeatureValue { + let id = inv + .entries() + .find(|e| e.gloss == gloss) + .unwrap_or_else(|| panic!("no feature {gloss}")) + .id; + FeatureValue { feature: id, value } +} + +fn rules(r: Result>) -> Vec<&'static str> { + r.unwrap_err().iter().map(Refusal::rule).collect() +} + +fn speaker() -> EntityId { + eid("brokkr") +} + +/// Brokkr speaks; the scene holds a relation to Nibu and one to an unnamed +/// party, with different values. +fn scene(inv: &Inventory) -> Scene { + scene_with(vec![ + PairRelation { + from: speaker(), + to: Party::Named(eid("nibu")), + features: vec![ + feat(inv, "relative_status", FeatValue::Option(lab("above"))), + feat(inv, "intimacy", FeatValue::Grade(grade(200))), + feat(inv, "in_group", FeatValue::Flag(false)), + ], + }, + PairRelation { + from: speaker(), + to: Party::Unnamed, + features: vec![feat(inv, "intimacy", FeatValue::Grade(grade(7)))], + }, + ]) +} + +fn scene_with(relations: Vec) -> Scene { + Scene { + group: "g".into(), + n: 1, + inventory_rev: 3, + participants: vec![speaker(), eid("nibu")], + relations, + annotator: Annotator::Operator, + } +} + +fn selected(view: LoweringInput, inv: &Inventory, s: &Scene) -> Vec { + view.with_scene(inv, s, &speaker()) + .unwrap() + .scene() + .to_vec() +} + +#[test] +fn a_scene_relation_reaches_the_lowering_view_and_changes_no_english() { + let (store, inv, _dir) = store(); + let plain = admitted(&packet(3, Some("n3")), &store); + assert!(plain.scene().is_empty()); + let s = scene(&inv); + let with = plain.clone().with_scene(&inv, &s, &speaker()).unwrap(); + assert_eq!(with.scene(), s.relations[0].features.as_slice()); + assert!(!with.scene().is_empty()); + 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] +fn with_scene_selects_by_addressee() { + let (store, inv, _dir) = store(); + let s = scene(&inv); + // A referent with an entity selects the relation to that entity. + let named = selected(admitted(&packet(3, Some("n3")), &store), &inv, &s); + assert_eq!(named, s.relations[0].features); + // A referent without one selects the relation to an unnamed party. + let unnamed = selected(admitted(&packet(3, Some("n1")), &store), &inv, &s); + assert_eq!(unnamed, s.relations[1].features); + // No addressee selects nothing. + assert!(selected(admitted(&packet(3, None), &store), &inv, &s).is_empty()); + // A named addressee the scene lacks selects nothing, though an unnamed + // relation exists. + let only_unnamed = scene_with(vec![s.relations[1].clone()]); + assert!( + selected( + admitted(&packet(3, Some("n3")), &store), + &inv, + &only_unnamed + ) + .is_empty() + ); + // Another speaker's relations are not selected. + let view = admitted(&packet(3, Some("n3")), &store); + let theirs = Scene { + participants: vec![speaker(), eid("nibu"), eid("hero")], + ..s.clone() + }; + assert!( + view.with_scene(&inv, &theirs, &eid("hero")) + .unwrap() + .scene() + .is_empty() + ); + // A replacement replaces: an empty selection clears the earlier one. + let first = admitted(&packet(3, Some("n3")), &store) + .with_scene(&inv, &s, &speaker()) + .unwrap(); + assert!( + first + .with_scene(&inv, &only_unnamed, &speaker()) + .unwrap() + .scene() + .is_empty() + ); +} + +#[test] +fn with_scene_refusals() { + let (store, inv, _dir) = store(); + let view = admitted(&packet(3, Some("n3")), &store); + let to_nibu = |features| { + scene_with(vec![PairRelation { + from: speaker(), + to: Party::Named(eid("nibu")), + features, + }]) + }; + let take = |s: &Scene| view.clone().with_scene(&inv, s, &speaker()); + // A speaker the scene does not list. + assert_eq!( + view.clone() + .with_scene(&inv, &scene(&inv), &eid("hero")) + .map(|_| ()) + .unwrap_err(), + [Refusal::V16SpeakerNotInScene] + ); + // A label the feature does not declare. + let lordly = feat(&inv, "relative_status", FeatValue::Option(lab("lordly"))); + assert_eq!(rules(take(&to_nibu(vec![lordly]))), ["V7"]); + // A value of the wrong kind. + let flag = feat(&inv, "intimacy", FeatValue::Flag(true)); + assert_eq!(rules(take(&to_nibu(vec![flag]))), ["V7"]); + // A stance feature in a relation. + let anger = feat(&inv, "anger", FeatValue::Grade(grade(1))); + assert_eq!(rules(take(&to_nibu(vec![anger]))), ["V8"]); + // A speaker feature in a relation. + let age = feat(&inv, "education", FeatValue::Grade(grade(1))); + assert_eq!(rules(take(&to_nibu(vec![age]))), ["V8"]); + // Twice in one list. + let twice = vec![ + feat(&inv, "intimacy", FeatValue::Grade(grade(1))), + feat(&inv, "intimacy", FeatValue::Grade(grade(2))), + ]; + assert_eq!(rules(take(&to_nibu(twice))), ["V11"]); + // Revision 2 predates the Scene features, so their ids do not resolve there. + let old = admitted(&packet(2, Some("n3")), &store); + assert_eq!( + rules(old.with_scene(&inv, &scene(&inv), &speaker())), + ["V5", "V5", "V5"] + ); + // An unselected relation is not checked: only the selected one is. + let bad_elsewhere = scene_with(vec![ + PairRelation { + from: speaker(), + to: Party::Unnamed, + features: vec![feat(&inv, "anger", FeatValue::Grade(grade(1)))], + }, + PairRelation { + from: speaker(), + to: Party::Named(eid("nibu")), + features: vec![], + }, + ]); + assert!(take(&bad_elsewhere).unwrap().scene().is_empty()); +} + +#[test] +fn feature_ids_are_stable() { + let (_store, inv, _dir) = store(); + let ids: Vec<_> = [ + "relative_status", + "intimacy", + "in_group", + "socioeconomic_standing", + "education", + "age_cohort", + ] + .map(|g| feat(&inv, g, FeatValue::Flag(true)).feature) + .into(); + let want: Vec<_> = (19..=24).map(|n| iv(&format!("feature:{n}"))).collect(); + assert_eq!(ids, want); +} diff --git a/crates/weksa/tests/speaker_state.rs b/crates/weksa/tests/speaker_state.rs new file mode 100644 index 0000000..864d65a --- /dev/null +++ b/crates/weksa/tests/speaker_state.rs @@ -0,0 +1,478 @@ +//! The speaker-state store: speaker and scene documents are append-only +//! revisions, checked against the inventory, the entity store and the corpus. + +mod common; +use common::{cast, eid, entities_in, grade, item_in, lab}; +use weksa::inventory::{proof_revision, seed, speaker_scene_revision}; +use weksa::packet::{FeatValue, FeatureValue}; +use weksa::speaker::{ + Annotator, MAX_PARTICIPANTS, PairRelation, Party, Scene, Speaker, scene_key, speaker_key, +}; +use weksa::{ + AddOutcome, CorpusStore, EntityStore, InventoryStore, Refusal, SpeakerStateStore, StoreError, + StoreRefusal, +}; + +struct W { + dir: tempfile::TempDir, + inventory: InventoryStore, + corpus: CorpusStore, + entities: EntityStore, + state: SpeakerStateStore, +} + +/// Group `g` holds two live items (speakers nibu and brokkr) and one withdrawn +/// item (speaker hero). +fn world() -> W { + let dir = tempfile::tempdir().unwrap(); + let mut inventory = InventoryStore::open(&dir.path().join("i.cc")).unwrap(); + for (rev, entries) in [seed(), proof_revision(), speaker_scene_revision()] { + inventory.admit_revision(rev, entries).unwrap(); + } + let entities = entities_in(dir.path()); + let mut corpus = CorpusStore::open(&dir.path().join("c.cc")).unwrap(); + for (key, text, speaker) in [ + ("item:nibu:1", "Come here.", "nibu"), + ("item:nibu:2", "Go there.", "brokkr"), + ("item:nibu:3", "Stay away.", "hero"), + ] { + let mut item = item_in(key, text, "g", cast()); + item.speaker = eid(speaker); + corpus.add(item, &entities).unwrap(); + } + corpus.withdraw("item:nibu:3").unwrap(); + let state = SpeakerStateStore::open(&dir.path().join("s.cc")).unwrap(); + W { + dir, + inventory, + corpus, + entities, + state, + } +} + +fn feat(gloss: &str, value: FeatValue) -> FeatureValue { + let feature = seed() + .1 + .into_iter() + .chain(speaker_scene_revision().1) + .find(|e| e.gloss == gloss) + .unwrap_or_else(|| panic!("no feature {gloss}")) + .id; + FeatureValue { feature, value } +} + +fn speaker(n: u32, age: &str) -> Speaker { + Speaker { + entity: eid("nibu"), + n, + inventory_rev: 3, + features: vec![feat("age_cohort", FeatValue::Option(lab(age)))], + annotator: Annotator::Operator, + } +} + +fn scene(n: u32, intimacy: u16) -> Scene { + Scene { + group: "g".into(), + n, + inventory_rev: 3, + participants: vec![eid("nibu"), eid("brokkr")], + relations: vec![PairRelation { + from: eid("nibu"), + to: Party::Named(eid("brokkr")), + features: vec![feat("intimacy", FeatValue::Grade(grade(intimacy)))], + }], + annotator: Annotator::Operator, + } +} + +fn put_speaker(w: &mut W, s: Speaker) -> Result { + w.state.put_speaker(s, &w.inventory, &w.entities) +} + +fn put_scene(w: &mut W, s: Scene) -> Result { + w.state.put_scene(s, &w.inventory, &w.corpus, &w.entities) +} + +fn refused(r: Result) -> StoreRefusal { + match r.unwrap_err() { + StoreError::Refused(r) => r, + other => panic!("not a store refusal: {other}"), + } +} + +fn malformed(r: Result, key: &str, why: &str) { + match refused(r) { + StoreRefusal::Malformed { key: k, why: w } => assert_eq!((k.as_str(), w), (key, why)), + other => panic!("not Malformed: {other:?}"), + } +} + +fn rules(r: Result) -> Vec<&'static str> { + match r.unwrap_err() { + StoreError::Invalid(rs) => rs.iter().map(Refusal::rule).collect(), + other => panic!("not a rule refusal: {other}"), + } +} + +fn not_next(key: &str, expected: u32, got: u32) -> StoreRefusal { + StoreRefusal::RevisionNotNext { + key: key.into(), + expected, + got, + } +} + +#[test] +fn keys_derive_from_the_document() { + assert_eq!(speaker_key(&eid("nibu"), 2), "speaker:nibu@2"); + assert_eq!(scene_key("g", 3), "scene:g@3"); +} + +#[test] +fn speaker_revisions_append() { + let mut w = world(); + let key = |n| speaker_key(&eid("nibu"), n); + assert_eq!( + refused(put_speaker(&mut w, speaker(2, "adult"))), + not_next(&key(2), 1, 2) + ); + assert!(w.state.speaker(&eid("nibu")).unwrap().is_none()); + assert_eq!( + put_speaker(&mut w, speaker(1, "adult")).unwrap(), + AddOutcome::Added + ); + assert_eq!( + put_speaker(&mut w, speaker(1, "adult")).unwrap(), + AddOutcome::Unchanged + ); + assert_eq!( + refused(put_speaker(&mut w, speaker(1, "elder"))), + not_next(&key(1), 2, 1) + ); + assert_eq!( + refused(put_speaker(&mut w, speaker(3, "elder"))), + not_next(&key(3), 2, 3) + ); + assert_eq!( + put_speaker(&mut w, speaker(2, "elder")).unwrap(), + AddOutcome::Added + ); + assert_eq!( + w.state.speaker(&eid("nibu")).unwrap(), + Some(speaker(2, "elder")) + ); + assert_eq!( + w.state.get_speaker(&key(1)).unwrap(), + Some(speaker(1, "adult")) + ); + // Another entity's revisions count from 1 and stay apart. + let mut other = speaker(1, "child"); + other.entity = eid("brokkr"); + assert_eq!(put_speaker(&mut w, other).unwrap(), AddOutcome::Added); + assert_eq!(w.state.speaker(&eid("nibu")).unwrap().unwrap().n, 2); + // Revision 10 sorts after 2 in the listing. + for n in 3..=10 { + put_speaker(&mut w, speaker(n, "adult")).unwrap(); + } + let listed: Vec<_> = w + .state + .all_speakers() + .unwrap() + .iter() + .map(|s| (s.entity.to_string(), s.n)) + .collect(); + assert_eq!(listed.len(), 11); + assert_eq!(listed[0], ("entity:brokkr".into(), 1)); + assert_eq!(listed[10], ("entity:nibu".into(), 10)); + assert_eq!(w.state.speaker(&eid("nibu")).unwrap().unwrap().n, 10); + // The documents survive a reopen. + let again = SpeakerStateStore::open(&w.dir.path().join("s.cc")).unwrap(); + assert_eq!(again.all_speakers().unwrap().len(), 11); +} + +#[test] +fn scene_revisions_append() { + let mut w = world(); + let key = |n| scene_key("g", n); + assert_eq!( + refused(put_scene(&mut w, scene(2, 5))), + not_next(&key(2), 1, 2) + ); + assert!(w.state.scene("g").unwrap().is_none()); + assert_eq!(put_scene(&mut w, scene(1, 5)).unwrap(), AddOutcome::Added); + assert_eq!( + put_scene(&mut w, scene(1, 5)).unwrap(), + AddOutcome::Unchanged + ); + assert_eq!( + refused(put_scene(&mut w, scene(1, 6))), + not_next(&key(1), 2, 1) + ); + assert_eq!(put_scene(&mut w, scene(2, 6)).unwrap(), AddOutcome::Added); + assert_eq!(w.state.scene("g").unwrap(), Some(scene(2, 6))); + assert_eq!(w.state.get_scene(&key(1)).unwrap(), Some(scene(1, 5))); + for n in 3..=10 { + put_scene(&mut w, scene(n, 6)).unwrap(); + } + assert_eq!(w.state.scene("g").unwrap().unwrap().n, 10); + let listed: Vec = w.state.all_scenes().unwrap().iter().map(|s| s.n).collect(); + assert_eq!(listed, (1..=10).collect::>()); +} + +#[test] +fn speaker_needs_a_known_entity() { + let mut w = world(); + let mut s = speaker(1, "adult"); + s.entity = eid("nobody"); + assert_eq!( + refused(put_speaker(&mut w, s)), + StoreRefusal::UnknownEntity { + entity: eid("nobody") + } + ); + assert!(w.state.all_speakers().unwrap().is_empty()); +} + +#[test] +fn annotation_features_are_checked_like_packet_features() { + let mut w = world(); + let age = || feat("age_cohort", FeatValue::Option(lab("adult"))); + let with = |features: Vec, rev| Speaker { + features, + inventory_rev: rev, + ..speaker(1, "adult") + }; + // A Scene feature and a Stance feature do not apply to a speaker. + let f = vec![feat("intimacy", FeatValue::Grade(grade(1)))]; + assert_eq!(rules(put_speaker(&mut w, with(f, 3))), ["V8"]); + let f = vec![feat("anger", FeatValue::Grade(grade(1)))]; + assert_eq!(rules(put_speaker(&mut w, with(f, 3))), ["V8"]); + // A wrong kind and an undeclared label. + let f = vec![feat("education", FeatValue::Flag(true))]; + assert_eq!(rules(put_speaker(&mut w, with(f, 3))), ["V7"]); + let f = vec![feat("age_cohort", FeatValue::Option(lab("ancient")))]; + assert_eq!(rules(put_speaker(&mut w, with(f, 3))), ["V7"]); + // Feature 22 does not exist at revision 2; revision 9 has no record. + assert_eq!(rules(put_speaker(&mut w, with(vec![age()], 2))), ["V5"]); + assert_eq!(rules(put_speaker(&mut w, with(vec![age()], 9))), ["V10"]); + // A repeat. + assert_eq!( + rules(put_speaker(&mut w, with(vec![age(), age()], 3))), + ["V11"] + ); + // A Speaker feature in a scene relation. + let mut s = scene(1, 1); + s.relations[0].features = vec![age()]; + assert_eq!(rules(put_scene(&mut w, s)), ["V8"]); + // Every relation is checked, and a scene of an unknown revision is V10. + let mut s = scene(1, 1); + s.relations.push(PairRelation { + from: eid("brokkr"), + to: Party::Unnamed, + features: vec![age()], + }); + assert_eq!(rules(put_scene(&mut w, s)), ["V8"]); + let mut s = scene(1, 1); + s.inventory_rev = 9; + assert_eq!(rules(put_scene(&mut w, s)), ["V10"]); + let mut s = scene(1, 1); + s.inventory_rev = 9; + s.relations.clear(); + assert_eq!(rules(put_scene(&mut w, s)), ["V10"]); + assert!(w.state.all_speakers().unwrap().is_empty()); + assert!(w.state.all_scenes().unwrap().is_empty()); +} + +#[test] +fn scene_shape_rules() { + let mut w = world(); + let key = scene_key("g", 1); + let relation = |from: &str, to: Party| PairRelation { + from: eid(from), + to, + features: vec![], + }; + let named = |to: &str| Party::Named(eid(to)); + let base = || scene(1, 1); + + let mut s = base(); + s.group = "Not A Slug".into(); + malformed( + put_scene(&mut w, s), + &scene_key("Not A Slug", 1), + "a scene group is a slug", + ); + let mut s = base(); + s.group = "elsewhere".into(); + malformed( + put_scene(&mut w, s), + &scene_key("elsewhere", 1), + "no corpus item holds the scene's group", + ); + let mut s = base(); + s.participants.clear(); + s.relations.clear(); + malformed( + put_scene(&mut w, s), + &key, + "a scene has 1 to 64 participants", + ); + // 64 participants are fine as far as the count goes (the rest fail later); + // 65 are refused for the count. + let many = |count: usize| -> Vec<_> { (0..count).map(|i| eid(&format!("p{i}"))).collect() }; + let mut s = base(); + s.participants = many(MAX_PARTICIPANTS + 1); + s.relations.clear(); + malformed( + put_scene(&mut w, s), + &key, + "a scene has 1 to 64 participants", + ); + let mut s = base(); + s.participants = many(MAX_PARTICIPANTS); + s.relations.clear(); + assert!(matches!( + refused(put_scene(&mut w, s)), + StoreRefusal::UnknownEntity { .. } + )); + let mut s = base(); + s.participants.push(eid("nibu")); + malformed(put_scene(&mut w, s), &key, "a participant is named once"); + let mut s = base(); + s.participants.push(eid("nobody")); + assert_eq!( + refused(put_scene(&mut w, s)), + StoreRefusal::UnknownEntity { + entity: eid("nobody") + } + ); + let mut s = base(); + s.relations = vec![relation("hero", named("nibu"))]; + malformed( + put_scene(&mut w, s), + &key, + "a relation's from is a participant", + ); + let mut s = base(); + s.relations = vec![relation("nibu", named("hero"))]; + malformed( + put_scene(&mut w, s), + &key, + "a relation's named to is another participant", + ); + let mut s = base(); + s.relations = vec![relation("nibu", named("nibu"))]; + malformed( + put_scene(&mut w, s), + &key, + "a relation's named to is another participant", + ); + let mut s = base(); + s.relations = vec![ + relation("nibu", named("brokkr")), + relation("nibu", named("brokkr")), + ]; + malformed( + put_scene(&mut w, s), + &key, + "an ordered pair has one relation", + ); + let mut s = base(); + s.relations = vec![ + relation("nibu", Party::Unnamed), + relation("nibu", Party::Unnamed), + ]; + malformed( + put_scene(&mut w, s), + &key, + "an ordered pair has one relation", + ); + // The reverse pair, and an unnamed party beside a named one, are distinct. + let mut s = base(); + s.relations = vec![ + relation("nibu", named("brokkr")), + relation("brokkr", named("nibu")), + relation("nibu", Party::Unnamed), + ]; + assert_eq!(put_scene(&mut w, s).unwrap(), AddOutcome::Added); +} + +#[test] +fn a_scene_holds_every_live_speaker_of_its_group() { + let mut w = world(); + // Brokkr speaks a live item of the group. + let mut s = scene(1, 1); + s.participants = vec![eid("nibu")]; + s.relations.clear(); + assert_eq!( + refused(put_scene(&mut w, s)), + StoreRefusal::SceneSpeakerMissing { + group: "g".into(), + entity: eid("brokkr") + } + ); + // Hero speaks only a withdrawn item, so the scene may omit them. + assert_eq!(put_scene(&mut w, scene(1, 1)).unwrap(), AddOutcome::Added); + // The group is held by a withdrawn item alone: still a group. + w.corpus.withdraw("item:nibu:1").unwrap(); + w.corpus.withdraw("item:nibu:2").unwrap(); + let mut s = scene(2, 1); + s.participants = vec![eid("player")]; + s.relations.clear(); + assert_eq!(put_scene(&mut w, s).unwrap(), AddOutcome::Added); +} + +#[test] +fn model_annotator_attests_its_generator() { + let mut w = world(); + let model = |generator: &str| Annotator::Model { + family: weksa::corpus::ModelFamily::Anthropic, + generator: generator.into(), + }; + let key = speaker_key(&eid("nibu"), 1); + for bad in ["", " ", "gen\u{7}erator", "gen\nerator", "\u{200d}"] { + let mut s = speaker(1, "adult"); + s.annotator = model(bad); + malformed( + put_speaker(&mut w, s), + &key, + "a model annotator attests its generator", + ); + let mut s = scene(1, 1); + s.annotator = model(bad); + malformed( + put_scene(&mut w, s), + &scene_key("g", 1), + "a model annotator attests its generator", + ); + } + let mut s = speaker(1, "adult"); + s.annotator = model(&"x".repeat(201)); + malformed( + put_speaker(&mut w, s), + &key, + "a model annotator attests its generator", + ); + let mut s = speaker(1, "adult"); + s.annotator = model("claude-sonnet 5.5"); + assert_eq!(put_speaker(&mut w, s).unwrap(), AddOutcome::Added); + let mut s = scene(1, 1); + s.annotator = model("claude-sonnet 5.5"); + assert_eq!(put_scene(&mut w, s).unwrap(), AddOutcome::Added); + let mut s = speaker(2, "adult"); + s.annotator = Annotator::Operator; + assert_eq!(put_speaker(&mut w, s).unwrap(), AddOutcome::Added); +} + +#[test] +fn refusal_displays_print_numbers_and_no_identifiers() { + let r = not_next("speaker:secretname@2", 1, 2); + assert_eq!(r.to_string(), "RevisionNotNext expected 1 got 2"); + let r = StoreRefusal::SceneSpeakerMissing { + group: "secretgroup".into(), + entity: eid("secretname"), + }; + assert_eq!(r.to_string(), "SceneSpeakerMissing"); +} diff --git a/crates/weksa/tests/stores.rs b/crates/weksa/tests/stores.rs index fbcce9a..fa51e47 100644 --- a/crates/weksa/tests/stores.rs +++ b/crates/weksa/tests/stores.rs @@ -1103,6 +1103,10 @@ fn speaker_state_slots_are_exclusive() { &[Slot::Scene, Slot::Relation], &[Slot::Discourse, Slot::Scene], &[Slot::Scene, Slot::Scene], + &[Slot::Speaker, Slot::Scene], + &[Slot::Speaker, Slot::Stance], + &[Slot::Speaker, Slot::Relation], + &[Slot::Speaker, Slot::Speaker], &[Slot::Stance, Slot::Stance], ] { let rev = rev_with(vec![Change::Add(iv("feature:50"))], 2, Some(1)); @@ -1121,7 +1125,8 @@ fn speaker_state_slots_are_exclusive() { for (n, slots) in [ (2, &[Slot::Scene][..]), (3, &[Slot::Stance]), - (4, &[Slot::Discourse, Slot::Predication]), + (4, &[Slot::Speaker]), + (5, &[Slot::Discourse, Slot::Predication]), ] { let id = format!("feature:{}", 50 + n); let rev = rev_with(vec![Change::Add(iv(&id))], n, Some(n - 1)); diff --git a/docs/interlingua-standard.md b/docs/interlingua-standard.md index e349a90..830a2f7 100644 --- a/docs/interlingua-standard.md +++ b/docs/interlingua-standard.md @@ -213,6 +213,25 @@ affect and restraint to its own register. A held-back feeling is not a claim about the world, so it is never a predication. An empty stance is valid. The stance reaches the lowerer in the lowering view. +### Speaker and scene documents + +Two documents outside the packet hold the speaker's standing, in the store that +owns them (`SpeakerStateStore`). `weksa.speaker` (key `speaker:@`) +holds one entity's stable axes as feature values of slot `Speaker`. +`weksa.scene` (key `scene:@`) holds a corpus group's participants and, +per ordered pair, the speaker's relation to a party (slot `Scene`); the party is +a named participant or `Party::Unnamed`, an addressee the scene does not name, +such as the player or a crowd. A revision appends and never edits: `n` is the +latest plus one, and the latest revision is the highest `n`. Each carries an +`Annotator`, `Operator` or `Model` with an attestation string for its generator. +Both hold typed feature values only, never text. A feature of slot `Stance`, +`Speaker` or `Scene` sits in that slot alone (each layer has one writer and one +lifetime), which `admit_revision` enforces. `LoweringInput::with_scene` takes a +scene document and the speaker; the speaker must be a participant (V16), and +the relation selected is the speaker's to the entity of the addressee referent +(to `Unnamed` when that referent has no entity, to nothing when the packet has +no addressee). V13 is retired. + ### `referents` Entities, groups, places, abstractions and the rest. `kind` is a referent-kind diff --git a/docs/targets/dao-de-jing-test-bed.md b/docs/targets/dao-de-jing-test-bed.md index 4f4de2e..516e2e3 100644 --- a/docs/targets/dao-de-jing-test-bed.md +++ b/docs/targets/dao-de-jing-test-bed.md @@ -15,7 +15,7 @@ plus a separately recorded chosen gloss per lowering). - `data/daodejing/`: its own stores (inventory revisions 1 to 3, one entity, five corpus items, five packets), apart from the production stores in `data/`. Revision 3 is `weksa::daodejing::revision()`; the packets are - `weksa::daodejing::packets()`. The scene revision (`scene_revision()`, also + `weksa::daodejing::packets()`. The scene revision (`speaker_scene_revision()`, also numbered 3) is not applied in these stores and will need renumbering when it is applied anywhere both meet. - Rebuild: `weksa --corpus C --inventory I --entities E` with `inventory seed`, diff --git a/docs/targets/interlingua-lowering-map.md b/docs/targets/interlingua-lowering-map.md index d2917de..2b1959c 100644 --- a/docs/targets/interlingua-lowering-map.md +++ b/docs/targets/interlingua-lowering-map.md @@ -263,7 +263,8 @@ operator-attested annotations alone. Who annotates is question - Seed inventory (2026-11-10, base `5e10d7c`): features 6-8 are hedge, certainty and irony; 9-18 are the stance affects and restraint (slot Stance). The committed inventory holds revisions 1-2 only. Nothing calls `with_scene` - except tests, and the proof packet's addressee carries no entity. + except tests, and the proof packet's addressee carries no entity (its + referent `n3` does: `entity:nibu`). - Yggdrasil's Ollama (`127.0.0.1:11434`) is the Docker container `ollama-cpu`, Huginn's embedder (runbook `gamecult-ops/runbooks/huginn-yggdrasil.md`, ruling `eureka-body:ruling:huginn-embedder-yggdrasil-primary`). `/api/embed` @@ -398,8 +399,8 @@ 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` (spec r1; `speaker-scene-seeds` is its cut 4c: CLI, inventory revision 3, cast speakers and scenes; `Party::Unnamed` covers an addressee with no entity; Speaker and Scene share one `SpeakerStateStore`): 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. +4a. `speaker-relation-input`: slot `Scene` and its first seed features (`relative_status`, `intimacy`, `in_group`; inventory revision 3, now `speaker_scene_revision`); `LoweringInput::with_scene` carries the speaker's relation to the addressee as typed `Scene` feature values. Since 4b it takes a `weksa.scene` document and selects the relation by the addressee referent's entity (V16 when the speaker is not a participant; V13 is retired). The English lowerer does not render it. (PR for follow-up `chat-s3352-3352`) +4b. `speaker-scene` (spec r1; `speaker-scene-seeds` is its cut 4c: CLI, inventory revision 3, cast speakers and scenes; `Party::Unnamed` covers an addressee with no entity; Speaker and Scene share one `SpeakerStateStore`): 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. Part 1 landed (branch `weksa-lowering/speaker-scene`): `speaker.rs`, `SpeakerStateStore`, slots `Speaker` and `Scene` exclusive to their own feature lists, `speaker_scene_revision()` (Speaker features 22-24), `with_scene` on a Scene document, schemas. The CLI, the committed inventory revision 3 and the seeds stay with 4c. 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`. 7a. `loop-context-builder`: `context.rs` (`TrainItems`, `EncoderOffer`, `Prompt`: corpus text reaches a model call only through `Prompt::mint` over live Train items or `Prompt::encode` over one offered item; instructions are `&'static str`), `SplitScheme::live`, `CorpusItem::cited_entities` (one owner of the id set `PacketStore::admit` and the offer share), and checkpoint-only Test readings (`Checkpoint`, `TestReadings`; `Aggregate` and `leak_diagnostic` carry no Test). Library only. Merges follow-ups `loop-context-builder-2` and `scorer-test-readings-type`. (spec r1) diff --git a/schemas/cultnet/weksa.inventory_entry.schema.json b/schemas/cultnet/weksa.inventory_entry.schema.json index ba3fe5d..e23ecc0 100644 --- a/schemas/cultnet/weksa.inventory_entry.schema.json +++ b/schemas/cultnet/weksa.inventory_entry.schema.json @@ -81,9 +81,14 @@ ] }, { - "description": "The relation of the speaker to the addressee in the scene; see\n`LoweringInput::with_scene`.", + "description": "The speaker's relation to one party of the scene, held by `weksa.scene`\ndocuments; see `LoweringInput::with_scene`.", "type": "string", "const": "Scene" + }, + { + "description": "The speaker's stable axes, held by `weksa.speaker` documents.", + "type": "string", + "const": "Speaker" } ] }, diff --git a/schemas/cultnet/weksa.scene.schema.json b/schemas/cultnet/weksa.scene.schema.json new file mode 100644 index 0000000..90e240b --- /dev/null +++ b/schemas/cultnet/weksa.scene.schema.json @@ -0,0 +1,224 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Scene", + "description": "A scene: its participants and the relations between them. Key\n`scene:@`; a revision appends and never edits.", + "type": "object", + "properties": { + "annotator": { + "$ref": "#/$defs/Annotator" + }, + "group": { + "description": "The corpus split group of the scene, a slug.", + "type": "string" + }, + "inventory_rev": { + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "n": { + "description": "The revision, from 1.", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "participants": { + "type": "array", + "items": { + "$ref": "#/$defs/EntityId" + } + }, + "relations": { + "type": "array", + "items": { + "$ref": "#/$defs/PairRelation" + } + } + }, + "required": [ + "group", + "n", + "inventory_rev", + "participants", + "relations", + "annotator" + ], + "$defs": { + "Annotator": { + "description": "Who annotated a document. A model's generator is an attestation string, held\nto the same form as every provenance string.", + "oneOf": [ + { + "type": "string", + "enum": [ + "Operator" + ] + }, + { + "type": "object", + "properties": { + "Model": { + "type": "object", + "properties": { + "family": { + "$ref": "#/$defs/ModelFamily" + }, + "generator": { + "type": "string" + } + }, + "required": [ + "family", + "generator" + ] + } + }, + "additionalProperties": false, + "required": [ + "Model" + ] + } + ] + }, + "EntityId": { + "type": "string", + "pattern": "^entity:[a-z0-9]([a-z0-9-]{0,46}[a-z0-9])?$" + }, + "FeatValue": { + "oneOf": [ + { + "type": "object", + "properties": { + "Grade": { + "$ref": "#/$defs/Grade" + } + }, + "additionalProperties": false, + "required": [ + "Grade" + ] + }, + { + "description": "A label the feature's inventory entry declares.", + "type": "object", + "properties": { + "Option": { + "$ref": "#/$defs/OptionLabel" + } + }, + "additionalProperties": false, + "required": [ + "Option" + ] + }, + { + "type": "object", + "properties": { + "Flag": { + "type": "boolean" + } + }, + "additionalProperties": false, + "required": [ + "Flag" + ] + } + ] + }, + "FeatureValue": { + "type": "object", + "properties": { + "feature": { + "description": "Feature id.", + "$ref": "#/$defs/InvId" + }, + "value": { + "$ref": "#/$defs/FeatValue" + } + }, + "required": [ + "feature", + "value" + ] + }, + "Grade": { + "type": "integer", + "maximum": 1000, + "minimum": 0 + }, + "InvId": { + "type": "string", + "pattern": "^(concept|frame|role|relation|speech_act|feature|referent_kind|extension):[1-9][0-9]*$" + }, + "ModelFamily": { + "description": "The family of the model that wrote a line.", + "oneOf": [ + { + "type": "string", + "enum": [ + "Anthropic", + "OpenAi", + "Google", + "Alibaba", + "Meta", + "Mistral" + ] + }, + { + "description": "Nothing records which family wrote the line.", + "type": "string", + "const": "Unrecorded" + } + ] + }, + "OptionLabel": { + "type": "string", + "pattern": "^[a-z][a-z0-9_]{0,23}$" + }, + "PairRelation": { + "description": "The speaker's relation to one party: feature values of slot `Scene`.", + "type": "object", + "properties": { + "features": { + "type": "array", + "items": { + "$ref": "#/$defs/FeatureValue" + } + }, + "from": { + "$ref": "#/$defs/EntityId" + }, + "to": { + "$ref": "#/$defs/Party" + } + }, + "required": [ + "from", + "to", + "features" + ] + }, + "Party": { + "description": "The other party of a relation. `Unnamed` is an addressee the scene does not\nname, such as the player or a crowd.", + "oneOf": [ + { + "type": "string", + "enum": [ + "Unnamed" + ] + }, + { + "type": "object", + "properties": { + "Named": { + "$ref": "#/$defs/EntityId" + } + }, + "additionalProperties": false, + "required": [ + "Named" + ] + } + ] + } + } +} diff --git a/schemas/cultnet/weksa.speaker.schema.json b/schemas/cultnet/weksa.speaker.schema.json new file mode 100644 index 0000000..4113537 --- /dev/null +++ b/schemas/cultnet/weksa.speaker.schema.json @@ -0,0 +1,171 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Speaker", + "description": "The speaker's stable axes. Key `speaker:@`; a revision\nappends and never edits.", + "type": "object", + "properties": { + "annotator": { + "$ref": "#/$defs/Annotator" + }, + "entity": { + "$ref": "#/$defs/EntityId" + }, + "features": { + "description": "Feature values of slot `Speaker`.", + "type": "array", + "items": { + "$ref": "#/$defs/FeatureValue" + } + }, + "inventory_rev": { + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "n": { + "description": "The revision, from 1.", + "type": "integer", + "format": "uint32", + "minimum": 0 + } + }, + "required": [ + "entity", + "n", + "inventory_rev", + "features", + "annotator" + ], + "$defs": { + "Annotator": { + "description": "Who annotated a document. A model's generator is an attestation string, held\nto the same form as every provenance string.", + "oneOf": [ + { + "type": "string", + "enum": [ + "Operator" + ] + }, + { + "type": "object", + "properties": { + "Model": { + "type": "object", + "properties": { + "family": { + "$ref": "#/$defs/ModelFamily" + }, + "generator": { + "type": "string" + } + }, + "required": [ + "family", + "generator" + ] + } + }, + "additionalProperties": false, + "required": [ + "Model" + ] + } + ] + }, + "EntityId": { + "type": "string", + "pattern": "^entity:[a-z0-9]([a-z0-9-]{0,46}[a-z0-9])?$" + }, + "FeatValue": { + "oneOf": [ + { + "type": "object", + "properties": { + "Grade": { + "$ref": "#/$defs/Grade" + } + }, + "additionalProperties": false, + "required": [ + "Grade" + ] + }, + { + "description": "A label the feature's inventory entry declares.", + "type": "object", + "properties": { + "Option": { + "$ref": "#/$defs/OptionLabel" + } + }, + "additionalProperties": false, + "required": [ + "Option" + ] + }, + { + "type": "object", + "properties": { + "Flag": { + "type": "boolean" + } + }, + "additionalProperties": false, + "required": [ + "Flag" + ] + } + ] + }, + "FeatureValue": { + "type": "object", + "properties": { + "feature": { + "description": "Feature id.", + "$ref": "#/$defs/InvId" + }, + "value": { + "$ref": "#/$defs/FeatValue" + } + }, + "required": [ + "feature", + "value" + ] + }, + "Grade": { + "type": "integer", + "maximum": 1000, + "minimum": 0 + }, + "InvId": { + "type": "string", + "pattern": "^(concept|frame|role|relation|speech_act|feature|referent_kind|extension):[1-9][0-9]*$" + }, + "ModelFamily": { + "description": "The family of the model that wrote a line.", + "oneOf": [ + { + "type": "string", + "enum": [ + "Anthropic", + "OpenAi", + "Google", + "Alibaba", + "Meta", + "Mistral" + ] + }, + { + "description": "Nothing records which family wrote the line.", + "type": "string", + "const": "Unrecorded" + } + ] + }, + "OptionLabel": { + "type": "string", + "pattern": "^[a-z][a-z0-9_]{0,23}$" + } + } +} -- 2.47.3 From 71e6058b640b049eed15d1fafd80d1dc2551d649 Mon Sep 17 00:00:00 2001 From: Weksa Date: Tue, 10 Nov 2026 02:51:35 +0000 Subject: [PATCH 3/5] with_scene reads the item's speaker and group scene from the store; V17 addressee names a referent The view keeps its corpus item's speaker and group (LoweringInput::of takes the item; admissible is gone), and with_scene takes the SpeakerStateStore, so neither speaker nor scene comes from the caller and only admitted scenes reach a view. An addressee that names a predication is refused as V17. Pins scene numbering per group and the no-fallback case for a named addressee outside the participants; tests/scene.rs fixture now speaks as the item's speaker. Co-Authored-By: Claude Sonnet 5.5 --- crates/weksa/src/store.rs | 5 +- crates/weksa/src/validate.rs | 126 +++---- crates/weksa/tests/display.rs | 2 + crates/weksa/tests/scene.rs | 404 ++++++++++++----------- crates/weksa/tests/speaker_state.rs | 27 +- crates/weksa/tests/validate.rs | 23 ++ docs/interlingua-standard.md | 15 +- docs/targets/interlingua-lowering-map.md | 4 +- 8 files changed, 351 insertions(+), 255 deletions(-) diff --git a/crates/weksa/src/store.rs b/crates/weksa/src/store.rs index 0263712..67f8f1a 100644 --- a/crates/weksa/src/store.rs +++ b/crates/weksa/src/store.rs @@ -21,7 +21,7 @@ use crate::inventory::{ }; use crate::packet::{EntityId, Packet}; use crate::speaker::{Annotator, MAX_PARTICIPANTS, Party, Scene, Speaker, scene_key, speaker_key}; -use crate::validate::{LoweringInput, Refusal, admissible, feature_refusals}; +use crate::validate::{LoweringInput, Refusal, feature_refusals, validate}; /// A broken store rule, named. #[derive(Clone, Debug, PartialEq, Eq)] @@ -959,7 +959,7 @@ impl PacketStore { corpus: &CorpusStore, entities: &EntityStore, ) -> Result { - let view = admissible(&packet, &inventory.inventory()?).map_err(StoreError::Invalid)?; + validate(&packet, &inventory.inventory()?).map_err(StoreError::Invalid)?; let item = corpus.resolve(&packet.provenance.corpus_item)?; if item.withdrawn { return Err(StoreRefusal::WithdrawnCorpusItem { @@ -983,6 +983,7 @@ impl PacketStore { .into()); } } + let view = LoweringInput::of(&packet, &item); let key = packet.id.to_string(); if let Some(existing) = self.get(&key)? { if existing == packet { diff --git a/crates/weksa/src/validate.rs b/crates/weksa/src/validate.rs index 9641062..06600e7 100644 --- a/crates/weksa/src/validate.rs +++ b/crates/weksa/src/validate.rs @@ -1,13 +1,14 @@ //! The validator owns the inventory and form rules a packet must meet. Every //! path that admits a packet applies them, but `validate` only judges: it //! returns `Ok(())` or the refusals. [`LoweringInput`], the only thing a -//! lowerer sees, comes from `PacketStore::admit` alone, which builds it through -//! the crate-private `admissible`. The stance is checked like every feature -//! list, in slot `Stance`. The rules are V1..V8, V10..V12 and V14..V16: the +//! lowerer sees, comes from `PacketStore::admit` alone, which builds it from +//! the admitted packet and its corpus item through the crate-private +//! `LoweringInput::of`. The stance is checked like every feature +//! list, in slot `Stance`. The rules are V1..V8, V10..V12 and V14..V17: the //! shape of an id or a grade is its type's parse, not a rule, so V9 is retired -//! and not renumbered; V13 is retired too. [`LoweringInput::with_scene`] takes -//! a [`Scene`](crate::speaker::Scene) document, and `feature_refusals` is the -//! one feature check it and the speaker-state store share. +//! and not renumbered; V13 is retired too. [`LoweringInput::with_scene`] reads the +//! scene of the view's own item group from the speaker-state store, and +//! `feature_refusals` is the one feature check it and that store share. //! //! `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 @@ -27,12 +28,13 @@ use std::collections::BTreeSet; use std::fmt; +use crate::corpus::CorpusItem; use crate::inventory::{Family, InvId, Inventory, InventoryEntry, Slot, ValueKind}; use crate::packet::{ Constraints, Context, Discourse, EntityId, Extension, FeatValue, FeatureValue, NodeId, Packet, PacketKind, Predication, ProvenanceSource, Referent, Relation, SCHEMA_VERSION, Stance, }; -use crate::speaker::Scene; +use crate::store::{SpeakerStateStore, StoreError}; /// A broken rule, named. #[derive(Clone, Debug, PartialEq, Eq)] @@ -110,8 +112,13 @@ pub enum Refusal { V15ConsentSource { source: ProvenanceSource, }, - /// V16: `with_scene` was given a speaker the scene does not list. + /// V16: `with_scene` found the item's speaker absent from its group's scene. V16SpeakerNotInScene, + /// V17: an addressee that names a predication. An act is not someone + /// spoken to, so the addressee names a referent. + V17AddresseeNotAReferent { + id: NodeId, + }, } impl Refusal { @@ -135,6 +142,7 @@ impl Refusal { } Refusal::V15ConsentSource { .. } => "V15", Refusal::V16SpeakerNotInScene => "V16", + Refusal::V17AddresseeNotAReferent { .. } => "V17", } } } @@ -161,6 +169,7 @@ impl fmt::Display for Refusal { Refusal::V14ConsentNotAPredication { .. } => "consent names no predication", Refusal::V15ConsentSource { .. } => "consent set by a source that only read wording", Refusal::V16SpeakerNotInScene => "the speaker is not a participant of the scene", + Refusal::V17AddresseeNotAReferent { .. } => "the addressee names no referent", }; write!(f, "{}: {what}", self.rule()) } @@ -180,28 +189,6 @@ impl fmt::Display for Refusal { /// fn lower(p: &Packet, i: &Inventory) -> Result> { weksa::validate(p, i) } /// ``` pub fn validate(packet: &Packet, inventory: &Inventory) -> Result<(), Vec> { - admissible(packet, inventory).map(|_| ()) -} - -/// The one implementation of the rules. Crate-private: `PacketStore::admit` is -/// the only caller that keeps the view. -/// -/// The first block is the same call through the public name, so the second -/// fails for the private path and nothing else. -/// -/// ``` -/// use weksa::{Inventory, packet::Packet}; -/// fn judge(p: &Packet, i: &Inventory) { let _ = weksa::validate::validate(p, i); } -/// ``` -/// -/// ```compile_fail -/// use weksa::{Inventory, packet::Packet}; -/// fn lower(p: &Packet, i: &Inventory) { let _ = weksa::validate::admissible(p, i); } -/// ``` -pub(crate) fn admissible( - packet: &Packet, - inventory: &Inventory, -) -> Result> { let mut out = Vec::new(); if packet.schema_version != SCHEMA_VERSION { out.push(Refusal::V1SchemaVersion { @@ -221,6 +208,7 @@ pub(crate) fn admissible( } let nodes = check_nodes(packet, &mut out); check_refs(packet, &nodes, &mut out); + check_addressee(packet, &nodes, &mut out); check_repeats(packet, &mut out); check_consent(packet, &nodes, &mut out); if inventory.has_revision(packet.inventory_rev) { @@ -235,11 +223,7 @@ pub(crate) fn admissible( rev: packet.inventory_rev, }); } - if out.is_empty() { - Ok(LoweringInput::of(packet)) - } else { - Err(out) - } + if out.is_empty() { Ok(()) } else { Err(out) } } fn check_nodes(packet: &Packet, out: &mut Vec) -> BTreeSet { @@ -285,6 +269,17 @@ fn check_refs(packet: &Packet, nodes: &BTreeSet, out: &mut Vec) } } +/// V17: the addressee, when it names a node, names a referent. A dangling id +/// is V4's. +fn check_addressee(packet: &Packet, nodes: &BTreeSet, out: &mut Vec) { + if let Some(a) = &packet.discourse.addressee + && nodes.contains(a) + && !packet.referents.iter().any(|r| &r.id == a) + { + out.push(Refusal::V17AddresseeNotAReferent { id: a.clone() }); + } +} + /// V14 and V15: the addressee's consent is a typed fact with one origin. A /// dangling id is V4's, so V14 judges only a node that exists. fn check_consent(packet: &Packet, nodes: &BTreeSet, out: &mut Vec) { @@ -560,11 +555,17 @@ fn fits(kind: Option<&ValueKind>, value: &FeatValue) -> bool { /// fn read(l: weksa::LoweringInput) { let _ = l.scene; } /// ``` /// -/// Scene values come only from a `weksa.scene` document: the raw-values door -/// is gone. The first block is the same call with a document; the second hands -/// it feature values and fails for that alone. +/// Scene values come only from the store: the call names neither speaker nor +/// scene. The first block is the call with the store; the second hands it a +/// scene value and a speaker and fails for that alone. /// /// ``` +/// fn pick(l: weksa::LoweringInput, i: &weksa::Inventory, s: &weksa::SpeakerStateStore) { +/// let _ = l.with_scene(i, s); +/// } +/// ``` +/// +/// ```compile_fail /// fn pick( /// l: weksa::LoweringInput, /// i: &weksa::Inventory, @@ -618,11 +619,16 @@ pub struct LoweringInput { constraints: Constraints, extensions: Vec, stance: Stance, + /// The item's speaker and group, as the corpus store holds them: the + /// only source of whose relation `with_scene` selects, and from which scene. + speaker: EntityId, + group: String, scene: Vec, } impl LoweringInput { - fn of(p: &Packet) -> Self { + /// The view of an admitted packet and the corpus item it was minted for. + pub(crate) fn of(p: &Packet, item: &CorpusItem) -> Self { Self { inventory_rev: p.inventory_rev, kind: p.kind, @@ -634,38 +640,44 @@ impl LoweringInput { constraints: p.constraints.clone(), extensions: p.extensions.clone(), stance: p.stance.clone(), + speaker: item.speaker.clone(), + group: item.group.clone(), scene: Vec::new(), } } /// Adds the speaker's relation to the addressee, the only path for scene - /// values into a view; they come only from a `weksa.scene` document. The - /// speaker must be a participant of `scene` (V16). With no addressee, or - /// one that names no referent, nothing is selected; otherwise the relation - /// from the speaker to the addressee referent's entity (to `Unnamed` when - /// the referent has none) is checked like every feature list, in slot - /// `Scene` at this view's inventory revision, and replaces any earlier - /// scene. The English lowerer ignores it. + /// values into a view. The speaker is the item's speaker and the scene is + /// the latest the store holds for the item's group; the caller names + /// neither. No scene for the group selects nothing; a scene that does not + /// list the item's speaker is V16. With no addressee nothing is selected; + /// otherwise the relation from the speaker to the addressee referent's + /// entity (to `Unnamed` when the referent has none) is checked like every + /// feature list, in slot `Scene` at this view's inventory revision, and + /// replaces any earlier scene. The English lowerer ignores it. pub fn with_scene( mut self, inventory: &Inventory, - scene: &Scene, - speaker: &EntityId, - ) -> Result> { - if !scene.participants.contains(speaker) { - return Err(vec![Refusal::V16SpeakerNotInScene]); + state: &SpeakerStateStore, + ) -> Result { + self.scene = Vec::new(); + let Some(scene) = state.scene(&self.group)? else { + return Ok(self); + }; + if !scene.participants.contains(&self.speaker) { + return Err(StoreError::Invalid(vec![Refusal::V16SpeakerNotInScene])); } - let addressee = self + let selected = self .discourse .addressee .as_ref() - .and_then(|id| self.referents.iter().find(|r| &r.id == id)); - let selected = addressee - .map(|r| scene.relation(speaker, r.entity.as_ref())) - .unwrap_or_default(); + .and_then(|a| self.referents.iter().find(|r| &r.id == a)) + .map_or(&[][..], |r| { + scene.relation(&self.speaker, r.entity.as_ref()) + }); let out = feature_refusals(inventory, self.inventory_rev, selected, Slot::Scene); if !out.is_empty() { - return Err(out); + return Err(StoreError::Invalid(out)); } self.scene = selected.to_vec(); Ok(self) diff --git a/crates/weksa/tests/display.rs b/crates/weksa/tests/display.rs index 44063fa..7a1eaf0 100644 --- a/crates/weksa/tests/display.rs +++ b/crates/weksa/tests/display.rs @@ -122,6 +122,8 @@ fn refusal_display_names_the_rule_and_no_identifier() { Refusal::V15ConsentSource { source: ProvenanceSource::ImportedCorpus, }, + Refusal::V16SpeakerNotInScene, + Refusal::V17AddresseeNotAReferent { id: node() }, ]; for r in &all { assert!(r.to_string().starts_with(r.rule()), "{r:?}"); diff --git a/crates/weksa/tests/scene.rs b/crates/weksa/tests/scene.rs index bc64acc..46885ae 100644 --- a/crates/weksa/tests/scene.rs +++ b/crates/weksa/tests/scene.rs @@ -1,244 +1,274 @@ -//! The speaker's relation to the addressee as a lowering input: the relation of -//! a `weksa.scene` document from the speaker to the addressee referent's -//! entity, typed feature values of slot `Scene` checked against the inventory -//! at the view's revision, never wording. +//! The speaker's relation to the addressee as a lowering input. `with_scene` +//! names neither speaker nor scene: the view knows its corpus item, so the +//! speaker is the item's speaker and the scene is the store's latest for the +//! item's group. The relation is the one from that speaker to the addressee +//! referent's entity. Typed feature values only, never wording. mod common; -use std::path::Path; - -use common::{eid, grade, iv, lab, nd}; +use common::{cast, eid, entities_in, grade, item_in, lab, nd}; use weksa::inventory::{proof_revision, seed, speaker_scene_revision}; use weksa::packet::{FeatValue, FeatureValue, Packet, proof_packet}; use weksa::speaker::{Annotator, PairRelation, Party, Scene}; use weksa::{ - CorpusStore, EntityId, EntityStore, Inventory, InventoryStore, LoweringInput, PacketStore, - Refusal, lower, + CorpusStore, EntityStore, Inventory, InventoryStore, LoweringInput, NameSpan, PacketStore, + Refusal, SpeakerStateStore, StoreError, lower, }; -fn store() -> (InventoryStore, Inventory, tempfile::TempDir) { +struct W { + dir: tempfile::TempDir, + inventory: InventoryStore, + corpus: CorpusStore, + entities: EntityStore, + state: SpeakerStateStore, +} + +/// Group `g` holds `item:nibu:1`: Nibu says "Brokkr, come here." Group `h` +/// holds `item:nibu:2`, also spoken by Nibu. +fn world() -> W { let dir = tempfile::tempdir().unwrap(); - let mut store = InventoryStore::open(&dir.path().join("i.cc")).unwrap(); + let mut inventory = InventoryStore::open(&dir.path().join("i.cc")).unwrap(); for (rev, entries) in [seed(), proof_revision(), speaker_scene_revision()] { - store.admit_revision(rev, entries).unwrap(); + inventory.admit_revision(rev, entries).unwrap(); + } + let entities = entities_in(dir.path()); + let mut corpus = CorpusStore::open(&dir.path().join("c.cc")).unwrap(); + let mut first = item_in("item:nibu:1", "Brokkr, come here.", "g", cast()); + first.names = vec![NameSpan { + entity: eid("brokkr"), + start: 0, + end: 6, + }]; + corpus.add(first, &entities).unwrap(); + corpus + .add(item_in("item:nibu:2", "Go away.", "h", cast()), &entities) + .unwrap(); + let state = SpeakerStateStore::open(&dir.path().join("s.cc")).unwrap(); + W { + dir, + inventory, + corpus, + entities, + state, } - 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 inv(w: &W) -> Inventory { + w.inventory.inventory().unwrap() } -/// The proof packet at `rev`. Its referent `n1` has no entity and `n3` is -/// `entity:nibu`; `addressee` names which one is spoken to, or none. -fn packet(rev: u32, addressee: Option<&str>) -> Packet { - let mut p = proof_packet(); - p.inventory_rev = rev; - p.discourse.addressee = addressee.map(nd); - p -} - -fn feat(inv: &Inventory, gloss: &str, value: FeatValue) -> FeatureValue { - let id = inv - .entries() +fn feat(gloss: &str, value: FeatValue) -> FeatureValue { + let feature = seed() + .1 + .into_iter() + .chain(speaker_scene_revision().1) .find(|e| e.gloss == gloss) .unwrap_or_else(|| panic!("no feature {gloss}")) .id; - FeatureValue { feature: id, value } + FeatureValue { feature, value } } -fn rules(r: Result>) -> Vec<&'static str> { - r.unwrap_err().iter().map(Refusal::rule).collect() +fn intimacy(v: u16) -> Vec { + vec![feat("intimacy", FeatValue::Grade(grade(v)))] } -fn speaker() -> EntityId { - eid("brokkr") +fn status() -> Vec { + vec![ + feat("relative_status", FeatValue::Option(lab("above"))), + feat("intimacy", FeatValue::Grade(grade(200))), + feat("in_group", FeatValue::Flag(false)), + ] } -/// Brokkr speaks; the scene holds a relation to Nibu and one to an unnamed -/// party, with different values. -fn scene(inv: &Inventory) -> Scene { - scene_with(vec![ - PairRelation { - from: speaker(), - to: Party::Named(eid("nibu")), - features: vec![ - feat(inv, "relative_status", FeatValue::Option(lab("above"))), - feat(inv, "intimacy", FeatValue::Grade(grade(200))), - feat(inv, "in_group", FeatValue::Flag(false)), - ], - }, - PairRelation { - from: speaker(), - to: Party::Unnamed, - features: vec![feat(inv, "intimacy", FeatValue::Grade(grade(7)))], - }, - ]) +fn relation(from: &str, to: Party, features: Vec) -> PairRelation { + PairRelation { + from: eid(from), + to, + features, + } } -fn scene_with(relations: Vec) -> Scene { +/// Nibu's relations to Brokkr and to an unnamed party, and Brokkr's to Nibu. +fn scene(group: &str, n: u32, participants: &[&str], relations: Vec) -> Scene { Scene { - group: "g".into(), - n: 1, + group: group.into(), + n, inventory_rev: 3, - participants: vec![speaker(), eid("nibu")], + participants: participants.iter().map(|p| eid(p)).collect(), relations, annotator: Annotator::Operator, } } -fn selected(view: LoweringInput, inv: &Inventory, s: &Scene) -> Vec { - view.with_scene(inv, s, &speaker()) +fn standard(group: &str, n: u32) -> Scene { + scene( + group, + n, + &["nibu", "brokkr"], + vec![ + relation("nibu", Party::Named(eid("brokkr")), status()), + relation("nibu", Party::Unnamed, intimacy(7)), + relation("brokkr", Party::Named(eid("nibu")), intimacy(99)), + ], + ) +} + +fn put(w: &mut W, s: Scene) { + w.state + .put_scene(s, &w.inventory, &w.corpus, &w.entities) + .unwrap(); +} + +/// The line of `item:nibu:1` at `rev`, its addressee node `addressee` (`n1` +/// has no entity, `n3` is Brokkr), through the packet door. +fn view_of(w: &W, rev: u32, addressee: Option<&str>) -> LoweringInput { + let mut p: Packet = proof_packet(); + p.inventory_rev = rev; + p.referents[2].entity = Some(eid("brokkr")); + p.discourse.addressee = addressee.map(nd); + // A packet key holds its packet for good, so each view gets a fresh store. + let scratch = tempfile::tempdir().unwrap(); + PacketStore::open(&scratch.path().join("p.cc")) .unwrap() - .scene() - .to_vec() + .admit(p, &w.inventory, &w.corpus, &w.entities) + .unwrap() +} + +fn rules(r: Result) -> Vec<&'static str> { + match r.map(|_| ()).unwrap_err() { + StoreError::Invalid(rs) => rs.iter().map(Refusal::rule).collect(), + other => panic!("not a rule refusal: {other}"), + } } #[test] fn a_scene_relation_reaches_the_lowering_view_and_changes_no_english() { - let (store, inv, _dir) = store(); - let plain = admitted(&packet(3, Some("n3")), &store); + let mut w = world(); + put(&mut w, standard("g", 1)); + let plain = view_of(&w, 3, Some("n3")); assert!(plain.scene().is_empty()); - let s = scene(&inv); - let with = plain.clone().with_scene(&inv, &s, &speaker()).unwrap(); - assert_eq!(with.scene(), s.relations[0].features.as_slice()); - assert!(!with.scene().is_empty()); - let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); - let entities = EntityStore::open(&root.join("data/entity/entity.cc")).unwrap(); + let with = plain.clone().with_scene(&inv(&w), &w.state).unwrap(); + assert_eq!(with.scene(), status().as_slice()); assert_eq!( - lower(&with, &inv, &entities).unwrap(), - lower(&plain, &inv, &entities).unwrap() + lower(&with, &inv(&w), &w.entities).unwrap(), + lower(&plain, &inv(&w), &w.entities).unwrap() ); } #[test] fn with_scene_selects_by_addressee() { - let (store, inv, _dir) = store(); - let s = scene(&inv); - // A referent with an entity selects the relation to that entity. - let named = selected(admitted(&packet(3, Some("n3")), &store), &inv, &s); - assert_eq!(named, s.relations[0].features); - // A referent without one selects the relation to an unnamed party. - let unnamed = selected(admitted(&packet(3, Some("n1")), &store), &inv, &s); - assert_eq!(unnamed, s.relations[1].features); - // No addressee selects nothing. - assert!(selected(admitted(&packet(3, None), &store), &inv, &s).is_empty()); - // A named addressee the scene lacks selects nothing, though an unnamed - // relation exists. - let only_unnamed = scene_with(vec![s.relations[1].clone()]); - assert!( - selected( - admitted(&packet(3, Some("n3")), &store), - &inv, - &only_unnamed - ) - .is_empty() - ); - // Another speaker's relations are not selected. - let view = admitted(&packet(3, Some("n3")), &store); - let theirs = Scene { - participants: vec![speaker(), eid("nibu"), eid("hero")], - ..s.clone() + let mut w = world(); + put(&mut w, standard("g", 1)); + let take = |w: &W, addressee| { + view_of(w, 3, addressee) + .with_scene(&inv(w), &w.state) + .unwrap() + .scene() + .to_vec() }; - assert!( - view.with_scene(&inv, &theirs, &eid("hero")) - .unwrap() - .scene() - .is_empty() + // A referent with an entity selects the relation to that entity; a + // referent without one, the relation to an unnamed party; and the + // speaker's relation, never Brokkr's to Nibu. + assert_eq!(take(&w, Some("n3")), status()); + assert_eq!(take(&w, Some("n1")), intimacy(7)); + // No addressee selects nothing. + assert!(take(&w, None).is_empty()); +} + +#[test] +fn a_named_addressee_the_scene_lacks_never_falls_back_to_unnamed() { + let mut w = world(); + // Brokkr is not a participant and Nibu has only an unnamed relation. + put( + &mut w, + scene( + "g", + 1, + &["nibu", "player"], + vec![relation("nibu", Party::Unnamed, intimacy(7))], + ), ); - // A replacement replaces: an empty selection clears the earlier one. - let first = admitted(&packet(3, Some("n3")), &store) - .with_scene(&inv, &s, &speaker()) + let got = view_of(&w, 3, Some("n3")) + .with_scene(&inv(&w), &w.state) .unwrap(); - assert!( - first - .with_scene(&inv, &only_unnamed, &speaker()) - .unwrap() - .scene() - .is_empty() + assert!(got.scene().is_empty()); + // A participant without a relation behaves the same. + put( + &mut w, + scene( + "g", + 2, + &["nibu", "brokkr"], + vec![relation("nibu", Party::Unnamed, intimacy(7))], + ), ); + let got = view_of(&w, 3, Some("n3")) + .with_scene(&inv(&w), &w.state) + .unwrap(); + assert!(got.scene().is_empty()); +} + +#[test] +fn the_scene_is_the_items_group_and_its_latest_revision() { + let mut w = world(); + // Group h holds a different relation for the same pair. + put( + &mut w, + scene( + "h", + 1, + &["nibu", "brokkr"], + vec![relation("nibu", Party::Named(eid("brokkr")), intimacy(1))], + ), + ); + // No scene for the item's group selects nothing, whatever other groups hold. + let got = view_of(&w, 3, Some("n3")) + .with_scene(&inv(&w), &w.state) + .unwrap(); + assert!(got.scene().is_empty()); + // Revisions: the highest n is read. + put(&mut w, standard("g", 1)); + let mut second = standard("g", 2); + second.relations[0].features = intimacy(5); + put(&mut w, second); + let got = view_of(&w, 3, Some("n3")) + .with_scene(&inv(&w), &w.state) + .unwrap(); + assert_eq!(got.scene(), intimacy(5).as_slice()); + // A replacement replaces: a store without the scene clears the selection. + let empty = SpeakerStateStore::open(&w.dir.path().join("empty.cc")).unwrap(); + let cleared = got.with_scene(&inv(&w), &empty).unwrap(); + assert!(cleared.scene().is_empty()); } #[test] fn with_scene_refusals() { - let (store, inv, _dir) = store(); - let view = admitted(&packet(3, Some("n3")), &store); - let to_nibu = |features| { - scene_with(vec![PairRelation { - from: speaker(), - to: Party::Named(eid("nibu")), - features, - }]) - }; - let take = |s: &Scene| view.clone().with_scene(&inv, s, &speaker()); - // A speaker the scene does not list. + let mut w = world(); + put(&mut w, standard("g", 1)); + // The scene was admitted at revision 3; a view of revision 2 predates the + // Scene features, so the selected relation's ids do not resolve there. assert_eq!( - view.clone() - .with_scene(&inv, &scene(&inv), &eid("hero")) - .map(|_| ()) - .unwrap_err(), - [Refusal::V16SpeakerNotInScene] - ); - // A label the feature does not declare. - let lordly = feat(&inv, "relative_status", FeatValue::Option(lab("lordly"))); - assert_eq!(rules(take(&to_nibu(vec![lordly]))), ["V7"]); - // A value of the wrong kind. - let flag = feat(&inv, "intimacy", FeatValue::Flag(true)); - assert_eq!(rules(take(&to_nibu(vec![flag]))), ["V7"]); - // A stance feature in a relation. - let anger = feat(&inv, "anger", FeatValue::Grade(grade(1))); - assert_eq!(rules(take(&to_nibu(vec![anger]))), ["V8"]); - // A speaker feature in a relation. - let age = feat(&inv, "education", FeatValue::Grade(grade(1))); - assert_eq!(rules(take(&to_nibu(vec![age]))), ["V8"]); - // Twice in one list. - let twice = vec![ - feat(&inv, "intimacy", FeatValue::Grade(grade(1))), - feat(&inv, "intimacy", FeatValue::Grade(grade(2))), - ]; - assert_eq!(rules(take(&to_nibu(twice))), ["V11"]); - // Revision 2 predates the Scene features, so their ids do not resolve there. - let old = admitted(&packet(2, Some("n3")), &store); - assert_eq!( - rules(old.with_scene(&inv, &scene(&inv), &speaker())), + rules(view_of(&w, 2, Some("n3")).with_scene(&inv(&w), &w.state)), ["V5", "V5", "V5"] ); - // An unselected relation is not checked: only the selected one is. - let bad_elsewhere = scene_with(vec![ - PairRelation { - from: speaker(), - to: Party::Unnamed, - features: vec![feat(&inv, "anger", FeatValue::Grade(grade(1)))], - }, - PairRelation { - from: speaker(), - to: Party::Named(eid("nibu")), - features: vec![], - }, - ]); - assert!(take(&bad_elsewhere).unwrap().scene().is_empty()); -} - -#[test] -fn feature_ids_are_stable() { - let (_store, inv, _dir) = store(); - let ids: Vec<_> = [ - "relative_status", - "intimacy", - "in_group", - "socioeconomic_standing", - "education", - "age_cohort", - ] - .map(|g| feat(&inv, g, FeatValue::Flag(true)).feature) - .into(); - let want: Vec<_> = (19..=24).map(|n| iv(&format!("feature:{n}"))).collect(); - assert_eq!(ids, want); + // A scene that does not list the item's speaker: group k admitted while + // Brokkr was its only speaker, then an item of Nibu's joins it. + let mut item = item_in("item:brokkr:1", "Hello.", "k", cast()); + item.speaker = eid("brokkr"); + w.corpus.add(item, &w.entities).unwrap(); + put(&mut w, scene("k", 1, &["brokkr"], vec![])); + w.corpus + .add( + item_in("item:nibu:3", "Hello there.", "k", cast()), + &w.entities, + ) + .unwrap(); + let mut p = proof_packet(); + p.inventory_rev = 3; + p.provenance.corpus_item = weksa::CorpusItemId::parse("item:nibu:3").unwrap(); + p.referents[2].entity = Some(eid("nibu")); + let view = PacketStore::open(&w.dir.path().join("p3.cc")) + .unwrap() + .admit(p, &w.inventory, &w.corpus, &w.entities) + .unwrap(); + assert_eq!(rules(view.with_scene(&inv(&w), &w.state)), ["V16"]); } diff --git a/crates/weksa/tests/speaker_state.rs b/crates/weksa/tests/speaker_state.rs index 864d65a..dfb4c59 100644 --- a/crates/weksa/tests/speaker_state.rs +++ b/crates/weksa/tests/speaker_state.rs @@ -22,7 +22,7 @@ struct W { } /// Group `g` holds two live items (speakers nibu and brokkr) and one withdrawn -/// item (speaker hero). +/// item (speaker hero); group `g2` holds one live item of nibu's. fn world() -> W { let dir = tempfile::tempdir().unwrap(); let mut inventory = InventoryStore::open(&dir.path().join("i.cc")).unwrap(); @@ -35,9 +35,14 @@ fn world() -> W { ("item:nibu:1", "Come here.", "nibu"), ("item:nibu:2", "Go there.", "brokkr"), ("item:nibu:3", "Stay away.", "hero"), + ("item:nibu:4", "Elsewhere.", "g2"), ] { let mut item = item_in(key, text, "g", cast()); item.speaker = eid(speaker); + if key == "item:nibu:4" { + item.group = "g2".into(); + item.speaker = eid("nibu"); + } corpus.add(item, &entities).unwrap(); } corpus.withdraw("item:nibu:3").unwrap(); @@ -219,6 +224,26 @@ fn scene_revisions_append() { assert_eq!(w.state.scene("g").unwrap().unwrap().n, 10); let listed: Vec = w.state.all_scenes().unwrap().iter().map(|s| s.n).collect(); assert_eq!(listed, (1..=10).collect::>()); + // Numbering is per group: the first scene of another group is revision 1 + // whatever g holds, and its second is 2. + let other = |n| Scene { + group: "g2".into(), + participants: vec![eid("nibu")], + relations: vec![], + ..scene(n, 1) + }; + assert_eq!( + refused(put_scene(&mut w, other(2))), + not_next(&scene_key("g2", 2), 1, 2) + ); + assert_eq!(put_scene(&mut w, other(1)).unwrap(), AddOutcome::Added); + assert_eq!( + refused(put_scene(&mut w, other(3))), + not_next(&scene_key("g2", 3), 2, 3) + ); + assert_eq!(put_scene(&mut w, other(2)).unwrap(), AddOutcome::Added); + assert_eq!(w.state.scene("g2").unwrap().unwrap().n, 2); + assert_eq!(w.state.scene("g").unwrap().unwrap().n, 10); } #[test] diff --git a/crates/weksa/tests/validate.rs b/crates/weksa/tests/validate.rs index c5bc172..b46cd85 100644 --- a/crates/weksa/tests/validate.rs +++ b/crates/weksa/tests/validate.rs @@ -739,3 +739,26 @@ fn a_source_that_only_read_wording_cannot_set_consent() { assert_eq!(refusals(&p), vec![Refusal::V15ConsentSource { source }]); } } + +// V17: the addressee is someone spoken to, a referent. + +#[test] +fn the_addressee_names_a_referent_not_a_predication() { + let mut p = good(); + p.discourse.addressee = Some(nd("n2")); + assert_eq!( + refusals(&p), + vec![Refusal::V17AddresseeNotAReferent { id: nd("n2") }] + ); + // A referent passes, and a dangling id is V4's alone. + p.discourse.addressee = Some(nd("n1")); + assert_eq!(refusals(&p), vec![]); + p.discourse.addressee = Some(nd("n99")); + assert_eq!( + refusals(&p), + vec![Refusal::V4DanglingRef { + id: nd("n99"), + at: "addressee" + }] + ); +} diff --git a/docs/interlingua-standard.md b/docs/interlingua-standard.md index 830a2f7..3a300fd 100644 --- a/docs/interlingua-standard.md +++ b/docs/interlingua-standard.md @@ -163,6 +163,7 @@ names the rule. - V15: an `addressee_consent` is set only by provenance `Authored` or `GameTelemetry`; a decomposition of text or imported corpus text cannot mint it. +- V17: an `addressee` names a referent, not a predication. V9 is retired and not renumbered: the form of a key or a grade is its type, and an extension key's family is V6. `PacketStore::admit` returns the lowering view of a @@ -190,7 +191,7 @@ ids. ### `discourse` -Why the packet is said: `speech_act` is a speech-act id, `addressee` a node id, +Why the packet is said: `speech_act` is a speech-act id, `addressee` a referent's node id (V17), 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 @@ -226,11 +227,13 @@ latest plus one, and the latest revision is the highest `n`. Each carries an `Annotator`, `Operator` or `Model` with an attestation string for its generator. Both hold typed feature values only, never text. A feature of slot `Stance`, `Speaker` or `Scene` sits in that slot alone (each layer has one writer and one -lifetime), which `admit_revision` enforces. `LoweringInput::with_scene` takes a -scene document and the speaker; the speaker must be a participant (V16), and -the relation selected is the speaker's to the entity of the addressee referent -(to `Unnamed` when that referent has no entity, to nothing when the packet has -no addressee). V13 is retired. +lifetime), which `admit_revision` enforces. `LoweringInput::with_scene` takes the +store and names neither speaker nor scene: the view knows its corpus item, so +the speaker is the item's speaker and the scene is the store's latest for the +item's group. The speaker must be a participant (V16), and the relation +selected is the speaker's to the entity of the addressee referent (to +`Unnamed` when that referent has no entity, to nothing when the packet has no +addressee or the scene holds no such relation). V13 is retired. ### `referents` diff --git a/docs/targets/interlingua-lowering-map.md b/docs/targets/interlingua-lowering-map.md index 2b1959c..3cdc59e 100644 --- a/docs/targets/interlingua-lowering-map.md +++ b/docs/targets/interlingua-lowering-map.md @@ -399,8 +399,8 @@ 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, now `speaker_scene_revision`); `LoweringInput::with_scene` carries the speaker's relation to the addressee as typed `Scene` feature values. Since 4b it takes a `weksa.scene` document and selects the relation by the addressee referent's entity (V16 when the speaker is not a participant; V13 is retired). The English lowerer does not render it. (PR for follow-up `chat-s3352-3352`) -4b. `speaker-scene` (spec r1; `speaker-scene-seeds` is its cut 4c: CLI, inventory revision 3, cast speakers and scenes; `Party::Unnamed` covers an addressee with no entity; Speaker and Scene share one `SpeakerStateStore`): 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. Part 1 landed (branch `weksa-lowering/speaker-scene`): `speaker.rs`, `SpeakerStateStore`, slots `Speaker` and `Scene` exclusive to their own feature lists, `speaker_scene_revision()` (Speaker features 22-24), `with_scene` on a Scene document, schemas. The CLI, the committed inventory revision 3 and the seeds stay with 4c. +4a. `speaker-relation-input`: slot `Scene` and its first seed features (`relative_status`, `intimacy`, `in_group`; inventory revision 3, now `speaker_scene_revision`); `LoweringInput::with_scene` carries the speaker's relation to the addressee as typed `Scene` feature values. Since 4b it takes the `SpeakerStateStore`, reads the scene of the view's own item group for the item's speaker, and selects the relation by the addressee referent's entity (V16 when the item's speaker is not a participant; V17 refuses an addressee that names a predication; V13 is retired). The English lowerer does not render it. (PR for follow-up `chat-s3352-3352`) +4b. `speaker-scene` (spec r1; `speaker-scene-seeds` is its cut 4c: CLI, inventory revision 3, cast speakers and scenes; `Party::Unnamed` covers an addressee with no entity; Speaker and Scene share one `SpeakerStateStore`): 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. Part 1 landed (branch `weksa-lowering/speaker-scene`): `speaker.rs`, `SpeakerStateStore`, slots `Speaker` and `Scene` exclusive to their own feature lists, `speaker_scene_revision()` (Speaker features 22-24), `with_scene` on the store, schemas. The CLI, the committed inventory revision 3 and the seeds stay with 4c. 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`. 7a. `loop-context-builder`: `context.rs` (`TrainItems`, `EncoderOffer`, `Prompt`: corpus text reaches a model call only through `Prompt::mint` over live Train items or `Prompt::encode` over one offered item; instructions are `&'static str`), `SplitScheme::live`, `CorpusItem::cited_entities` (one owner of the id set `PacketStore::admit` and the offer share), and checkpoint-only Test readings (`Checkpoint`, `TestReadings`; `Aggregate` and `leak_diagnostic` carry no Test). Library only. Merges follow-ups `loop-context-builder-2` and `scorer-test-readings-type`. (spec r1) -- 2.47.3 From bb152d02ce8319e03af8f6e557bb6b2619b71103 Mon Sep 17 00:00:00 2001 From: Weksa Date: Tue, 10 Nov 2026 03:10:21 +0000 Subject: [PATCH 4/5] with_scene records what it read: SceneRead says no scene, no addressee, self-addressed, no relation or selected at a revision The view no longer collapses five cases into one empty feature list. Scene::relation returns None for an absent relation. Pins the item speaker over the first participant and V16 without an addressee. Co-Authored-By: Claude Sonnet 5.5 --- crates/weksa/src/lib.rs | 2 +- crates/weksa/src/speaker.rs | 13 ++- crates/weksa/src/validate.rs | 86 +++++++++++++++----- crates/weksa/tests/scene.rs | 153 ++++++++++++++++++++++++++--------- 4 files changed, 192 insertions(+), 62 deletions(-) diff --git a/crates/weksa/src/lib.rs b/crates/weksa/src/lib.rs index e147524..f8043a3 100644 --- a/crates/weksa/src/lib.rs +++ b/crates/weksa/src/lib.rs @@ -38,4 +38,4 @@ pub use store::{ AddOutcome, CorpusStore, EntityBound, EntityStore, InventoryStore, PacketStore, SpeakerStateStore, StoreError, StoreRefusal, }; -pub use validate::{LoweringInput, Refusal, validate}; +pub use validate::{LoweringInput, Refusal, SceneRead, validate}; diff --git a/crates/weksa/src/speaker.rs b/crates/weksa/src/speaker.rs index 51367e7..c51a6b1 100644 --- a/crates/weksa/src/speaker.rs +++ b/crates/weksa/src/speaker.rs @@ -83,9 +83,14 @@ pub struct Scene { impl Scene { /// The features of the relation from `speaker` to the named `addressee`, or - /// to `Unnamed` when there is none; empty when the scene holds no such - /// relation. A named addressee the scene lacks never falls back to `Unnamed`. - pub fn relation(&self, speaker: &EntityId, addressee: Option<&EntityId>) -> &[FeatureValue] { + /// to `Unnamed` when there is none; `None` when the scene holds no such + /// relation (a relation with no features is `Some(&[])`). A named + /// addressee the scene lacks never falls back to `Unnamed`. + pub fn relation( + &self, + speaker: &EntityId, + addressee: Option<&EntityId>, + ) -> Option<&[FeatureValue]> { self.relations .iter() .find(|r| { @@ -96,7 +101,7 @@ impl Scene { _ => false, } }) - .map_or(&[], |r| r.features.as_slice()) + .map(|r| r.features.as_slice()) } } diff --git a/crates/weksa/src/validate.rs b/crates/weksa/src/validate.rs index 06600e7..d8989ca 100644 --- a/crates/weksa/src/validate.rs +++ b/crates/weksa/src/validate.rs @@ -499,6 +499,28 @@ fn fits(kind: Option<&ValueKind>, value: &FeatValue) -> bool { } } +/// What `with_scene` found for a view: the scene revision `rev` it read, or why +/// it selected no relation. Each case is distinct so a missing annotation or a +/// store at the wrong path cannot look like a scene that held nothing. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum SceneRead { + /// `with_scene` has not run. + Unread, + /// The store holds no scene for the item's group. + NoScene, + /// The scene was read and the packet names no addressee. + NoAddressee { rev: u32 }, + /// The addressee referent is the speaker themself. + SelfAddressed { rev: u32 }, + /// The scene holds no relation from the speaker to this addressee. + NoRelation { rev: u32 }, + /// The relation was found; its features may be none. + Selected { + rev: u32, + features: Vec, + }, +} + /// What the lowerer sees: a validated packet's meaning, with no packet id, no /// provenance (so no corpus item key, encoder or source) and no trace. Its /// fields are private and the constructor is crate-private, called only by @@ -623,7 +645,7 @@ pub struct LoweringInput { /// only source of whose relation `with_scene` selects, and from which scene. speaker: EntityId, group: String, - scene: Vec, + scene: SceneRead, } impl LoweringInput { @@ -642,44 +664,61 @@ impl LoweringInput { stance: p.stance.clone(), speaker: item.speaker.clone(), group: item.group.clone(), - scene: Vec::new(), + scene: SceneRead::Unread, } } - /// Adds the speaker's relation to the addressee, the only path for scene + /// Reads the speaker's relation to the addressee, the only path for scene /// values into a view. The speaker is the item's speaker and the scene is /// the latest the store holds for the item's group; the caller names - /// neither. No scene for the group selects nothing; a scene that does not - /// list the item's speaker is V16. With no addressee nothing is selected; - /// otherwise the relation from the speaker to the addressee referent's - /// entity (to `Unnamed` when the referent has none) is checked like every - /// feature list, in slot `Scene` at this view's inventory revision, and - /// replaces any earlier scene. The English lowerer ignores it. + /// neither. A scene that does not list the item's speaker is V16, with or + /// without an addressee. What the read found is recorded on the view as a + /// [`SceneRead`], never an empty feature list standing for five cases: + /// no scene for the group, no addressee, an addressee who is the speaker, + /// no such relation, or the relation from the speaker to the addressee + /// referent's entity (to `Unnamed` when the referent has none), whose + /// features are checked like every feature list in slot `Scene` at this + /// view's inventory revision. A read replaces any earlier one. The English + /// lowerer ignores it. pub fn with_scene( mut self, inventory: &Inventory, state: &SpeakerStateStore, ) -> Result { - self.scene = Vec::new(); + self.scene = SceneRead::Unread; let Some(scene) = state.scene(&self.group)? else { + self.scene = SceneRead::NoScene; return Ok(self); }; + let rev = scene.n; if !scene.participants.contains(&self.speaker) { return Err(StoreError::Invalid(vec![Refusal::V16SpeakerNotInScene])); } - let selected = self - .discourse - .addressee - .as_ref() - .and_then(|a| self.referents.iter().find(|r| &r.id == a)) - .map_or(&[][..], |r| { - scene.relation(&self.speaker, r.entity.as_ref()) - }); - let out = feature_refusals(inventory, self.inventory_rev, selected, Slot::Scene); + let Some(addressee) = self.discourse.addressee.as_ref() else { + self.scene = SceneRead::NoAddressee { rev }; + return Ok(self); + }; + let entity = self + .referents + .iter() + .find(|r| &r.id == addressee) + .and_then(|r| r.entity.as_ref()); + if entity == Some(&self.speaker) { + self.scene = SceneRead::SelfAddressed { rev }; + return Ok(self); + } + let Some(features) = scene.relation(&self.speaker, entity) else { + self.scene = SceneRead::NoRelation { rev }; + return Ok(self); + }; + let out = feature_refusals(inventory, self.inventory_rev, features, Slot::Scene); if !out.is_empty() { return Err(StoreError::Invalid(out)); } - self.scene = selected.to_vec(); + self.scene = SceneRead::Selected { + rev, + features: features.to_vec(), + }; Ok(self) } @@ -716,6 +755,13 @@ impl LoweringInput { /// The features of the scene relation from the speaker to the addressee; /// empty until `with_scene`, or when the scene holds none. pub fn scene(&self) -> &[FeatureValue] { + match &self.scene { + SceneRead::Selected { features, .. } => features, + _ => &[], + } + } + /// What `with_scene` read; `Unread` until it ran. + pub fn scene_read(&self) -> &SceneRead { &self.scene } } diff --git a/crates/weksa/tests/scene.rs b/crates/weksa/tests/scene.rs index 46885ae..f27bc76 100644 --- a/crates/weksa/tests/scene.rs +++ b/crates/weksa/tests/scene.rs @@ -11,7 +11,7 @@ use weksa::packet::{FeatValue, FeatureValue, Packet, proof_packet}; use weksa::speaker::{Annotator, PairRelation, Party, Scene}; use weksa::{ CorpusStore, EntityStore, Inventory, InventoryStore, LoweringInput, NameSpan, PacketStore, - Refusal, SpeakerStateStore, StoreError, lower, + Refusal, SceneRead, SpeakerStateStore, StoreError, lower, }; struct W { @@ -121,9 +121,15 @@ fn put(w: &mut W, s: Scene) { /// The line of `item:nibu:1` at `rev`, its addressee node `addressee` (`n1` /// has no entity, `n3` is Brokkr), through the packet door. fn view_of(w: &W, rev: u32, addressee: Option<&str>) -> LoweringInput { + view_to(w, rev, addressee, "brokkr") +} + +/// As `view_of`, with referent `n3` naming `entity` (Nibu is the item's +/// speaker, so he may be named on the line; Brokkr is named by its text). +fn view_to(w: &W, rev: u32, addressee: Option<&str>, entity: &str) -> LoweringInput { let mut p: Packet = proof_packet(); p.inventory_rev = rev; - p.referents[2].entity = Some(eid("brokkr")); + p.referents[2].entity = Some(eid(entity)); p.discourse.addressee = addressee.map(nd); // A packet key holds its packet for good, so each view gets a fresh store. let scratch = tempfile::tempdir().unwrap(); @@ -140,14 +146,27 @@ fn rules(r: Result) -> Vec<&'static str> { } } +fn read(w: &W, view: LoweringInput) -> SceneRead { + view.with_scene(&inv(w), &w.state) + .unwrap() + .scene_read() + .clone() +} + +fn selected(rev: u32, features: Vec) -> SceneRead { + SceneRead::Selected { rev, features } +} + #[test] fn a_scene_relation_reaches_the_lowering_view_and_changes_no_english() { let mut w = world(); put(&mut w, standard("g", 1)); let plain = view_of(&w, 3, Some("n3")); + assert_eq!(plain.scene_read(), &SceneRead::Unread); assert!(plain.scene().is_empty()); let with = plain.clone().with_scene(&inv(&w), &w.state).unwrap(); assert_eq!(with.scene(), status().as_slice()); + assert_eq!(with.scene_read(), &selected(1, status())); assert_eq!( lower(&with, &inv(&w), &w.entities).unwrap(), lower(&plain, &inv(&w), &w.entities).unwrap() @@ -155,23 +174,54 @@ fn a_scene_relation_reaches_the_lowering_view_and_changes_no_english() { } #[test] -fn with_scene_selects_by_addressee() { +fn with_scene_says_which_of_five_things_it_found() { let mut w = world(); + // No scene for the group. + assert_eq!(read(&w, view_of(&w, 3, Some("n3"))), SceneRead::NoScene); put(&mut w, standard("g", 1)); - let take = |w: &W, addressee| { - view_of(w, 3, addressee) - .with_scene(&inv(w), &w.state) - .unwrap() - .scene() - .to_vec() - }; // A referent with an entity selects the relation to that entity; a // referent without one, the relation to an unnamed party; and the // speaker's relation, never Brokkr's to Nibu. - assert_eq!(take(&w, Some("n3")), status()); - assert_eq!(take(&w, Some("n1")), intimacy(7)); - // No addressee selects nothing. - assert!(take(&w, None).is_empty()); + assert_eq!(read(&w, view_of(&w, 3, Some("n3"))), selected(1, status())); + assert_eq!( + read(&w, view_of(&w, 3, Some("n1"))), + selected(1, intimacy(7)) + ); + // The scene was read and the packet names no addressee. + assert_eq!( + read(&w, view_of(&w, 3, None)), + SceneRead::NoAddressee { rev: 1 } + ); + // The addressee is the speaker. + assert_eq!( + read(&w, view_to(&w, 3, Some("n3"), "nibu")), + SceneRead::SelfAddressed { rev: 1 } + ); + // The scene holds no relation to this addressee. + put( + &mut w, + scene( + "g", + 2, + &["nibu", "brokkr"], + vec![relation("nibu", Party::Unnamed, intimacy(7))], + ), + ); + assert_eq!( + read(&w, view_of(&w, 3, Some("n3"))), + SceneRead::NoRelation { rev: 2 } + ); + // A relation with no features is found, not absent. + put( + &mut w, + scene( + "g", + 3, + &["nibu", "brokkr"], + vec![relation("nibu", Party::Named(eid("brokkr")), vec![])], + ), + ); + assert_eq!(read(&w, view_of(&w, 3, Some("n3"))), selected(3, vec![])); } #[test] @@ -187,10 +237,10 @@ fn a_named_addressee_the_scene_lacks_never_falls_back_to_unnamed() { vec![relation("nibu", Party::Unnamed, intimacy(7))], ), ); - let got = view_of(&w, 3, Some("n3")) - .with_scene(&inv(&w), &w.state) - .unwrap(); - assert!(got.scene().is_empty()); + assert_eq!( + read(&w, view_of(&w, 3, Some("n3"))), + SceneRead::NoRelation { rev: 1 } + ); // A participant without a relation behaves the same. put( &mut w, @@ -201,10 +251,32 @@ fn a_named_addressee_the_scene_lacks_never_falls_back_to_unnamed() { vec![relation("nibu", Party::Unnamed, intimacy(7))], ), ); - let got = view_of(&w, 3, Some("n3")) - .with_scene(&inv(&w), &w.state) - .unwrap(); - assert!(got.scene().is_empty()); + assert_eq!( + read(&w, view_of(&w, 3, Some("n3"))), + SceneRead::NoRelation { rev: 2 } + ); +} + +#[test] +fn the_speaker_is_the_items_not_the_first_participant() { + let mut w = world(); + // Brokkr is listed first and has an unnamed relation of his own. + put( + &mut w, + scene( + "g", + 1, + &["brokkr", "nibu"], + vec![ + relation("brokkr", Party::Unnamed, intimacy(99)), + relation("nibu", Party::Unnamed, intimacy(7)), + ], + ), + ); + assert_eq!( + read(&w, view_of(&w, 3, Some("n1"))), + selected(1, intimacy(7)) + ); } #[test] @@ -220,11 +292,8 @@ fn the_scene_is_the_items_group_and_its_latest_revision() { vec![relation("nibu", Party::Named(eid("brokkr")), intimacy(1))], ), ); - // No scene for the item's group selects nothing, whatever other groups hold. - let got = view_of(&w, 3, Some("n3")) - .with_scene(&inv(&w), &w.state) - .unwrap(); - assert!(got.scene().is_empty()); + // No scene for the item's group, whatever other groups hold. + assert_eq!(read(&w, view_of(&w, 3, Some("n3"))), SceneRead::NoScene); // Revisions: the highest n is read. put(&mut w, standard("g", 1)); let mut second = standard("g", 2); @@ -233,10 +302,12 @@ fn the_scene_is_the_items_group_and_its_latest_revision() { let got = view_of(&w, 3, Some("n3")) .with_scene(&inv(&w), &w.state) .unwrap(); + assert_eq!(got.scene_read(), &selected(2, intimacy(5))); assert_eq!(got.scene(), intimacy(5).as_slice()); - // A replacement replaces: a store without the scene clears the selection. + // A read replaces the earlier one: a store without the scene says so. let empty = SpeakerStateStore::open(&w.dir.path().join("empty.cc")).unwrap(); let cleared = got.with_scene(&inv(&w), &empty).unwrap(); + assert_eq!(cleared.scene_read(), &SceneRead::NoScene); assert!(cleared.scene().is_empty()); } @@ -262,13 +333,21 @@ fn with_scene_refusals() { &w.entities, ) .unwrap(); - let mut p = proof_packet(); - p.inventory_rev = 3; - p.provenance.corpus_item = weksa::CorpusItemId::parse("item:nibu:3").unwrap(); - p.referents[2].entity = Some(eid("nibu")); - let view = PacketStore::open(&w.dir.path().join("p3.cc")) - .unwrap() - .admit(p, &w.inventory, &w.corpus, &w.entities) - .unwrap(); - assert_eq!(rules(view.with_scene(&inv(&w), &w.state)), ["V16"]); + for addressee in [Some("n3"), None] { + let mut p = proof_packet(); + p.inventory_rev = 3; + p.provenance.corpus_item = weksa::CorpusItemId::parse("item:nibu:3").unwrap(); + p.referents[2].entity = Some(eid("nibu")); + p.discourse.addressee = addressee.map(nd); + let scratch = tempfile::tempdir().unwrap(); + let view = PacketStore::open(&scratch.path().join("p.cc")) + .unwrap() + .admit(p, &w.inventory, &w.corpus, &w.entities) + .unwrap(); + assert_eq!( + rules(view.with_scene(&inv(&w), &w.state)), + ["V16"], + "{addressee:?}" + ); + } } -- 2.47.3 From eff94fb4afdc67d9734cd4137bc1fad2c267c201 Mon Sep 17 00:00:00 2001 From: Weksa Date: Tue, 10 Nov 2026 03:25:44 +0000 Subject: [PATCH 5/5] Pin LoweringInput::of's privacy with the right arity; scene() doc points to scene_read() Co-Authored-By: Claude Sonnet 5.5 --- crates/weksa/src/validate.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/crates/weksa/src/validate.rs b/crates/weksa/src/validate.rs index d8989ca..a74066f 100644 --- a/crates/weksa/src/validate.rs +++ b/crates/weksa/src/validate.rs @@ -609,11 +609,13 @@ pub enum SceneRead { /// call, so the second fails for that call and nothing else. /// /// ``` -/// fn build(p: &weksa::packet::Packet) { let _ = p; } +/// fn build(p: &weksa::packet::Packet, item: &weksa::CorpusItem) { let _ = (p, item); } /// ``` /// /// ```compile_fail -/// fn build(p: &weksa::packet::Packet) { let _ = weksa::LoweringInput::of(p); } +/// fn build(p: &weksa::packet::Packet, item: &weksa::CorpusItem) { +/// let _ = weksa::LoweringInput::of(p, item); +/// } /// ``` /// /// A lowering view is never read from bytes: `Deserialize` would let a caller @@ -752,8 +754,9 @@ impl LoweringInput { pub fn stance(&self) -> &Stance { &self.stance } - /// The features of the scene relation from the speaker to the addressee; - /// empty until `with_scene`, or when the scene holds none. + /// The features of the scene relation from the speaker to the addressee. + /// Empty for every [`SceneRead`] but `Selected` (and for a selected + /// relation that has no features): ask [`scene_read`](Self::scene_read) which. pub fn scene(&self) -> &[FeatureValue] { match &self.scene { SceneRead::Selected { features, .. } => features, -- 2.47.3