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}$" + } + } +}