diff --git a/AGENTS.md b/AGENTS.md index ce540a6..55d488a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,3 +51,13 @@ The live state architecture is ported from EpiphanyAgent in reduced form: Project truth does not belong in agent personality memory. If Weksa later grows role dossiers or heartbeat scheduling, those surfaces must remain separate from language-project truth. + +## Building And Verifying + +- `crates/weksa` depends on CultLib's `cultcache-rs` as a git dependency pinned by + `rev`, fetched from GitHub. With the default `CARGO_HOME` that fetch fails on a + Mind's host. Use the pre-fetched home: + `CARGO_HOME=/var/lib/mind-weksa-self/cargo-home cargo test --workspace`. +- `weksa packet proof ` admits the nibu proof packet under the key + `packet:6e6962752d303031` (hex of `nibu-001`), not `nibu-proof-001`. Use that key + with `weksa lower `. diff --git a/crates/weksa/src/inventory.rs b/crates/weksa/src/inventory.rs index b6fccf3..22538dd 100644 --- a/crates/weksa/src/inventory.rs +++ b/crates/weksa/src/inventory.rs @@ -188,6 +188,7 @@ pub enum Slot { Referent, Predication, Relation, + Stance, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] @@ -439,10 +440,28 @@ pub fn seed() -> (InventoryRevision, Vec) { &pred, ); let nuance = [Slot::Discourse, Slot::Predication]; - for g in ["hedge", "certainty", "warmth", "formality", "intensity"] { + for g in ["hedge", "certainty"] { add(Family::Feature, g, Some(ValueKind::Grade), &nuance); } add(Family::Feature, "irony", Some(ValueKind::Flag), &nuance); + // Felt affect (permille of how strongly it is felt) and the restraint the speaker + // puts on it: slot Stance only. Affection covers fondness toward the + // addressee. Displayed affect is each language's ontology's business. + let stance = [Slot::Stance]; + for g in [ + "anger", + "fear", + "sadness", + "joy", + "disgust", + "surprise", + "contempt", + "affection", + "shame", + "restraint", + ] { + add(Family::Feature, g, Some(ValueKind::Grade), &stance); + } let changes = entries.iter().map(|e| Change::Add(e.id)).collect(); ( InventoryRevision { diff --git a/crates/weksa/src/lib.rs b/crates/weksa/src/lib.rs index ca92940..0bfdb8c 100644 --- a/crates/weksa/src/lib.rs +++ b/crates/weksa/src/lib.rs @@ -24,7 +24,7 @@ pub use inventory::{ }; pub use lower::{LowerError, lower}; pub use packet::{ - EncoderId, EntityId, Grade, NodeId, Packet, PacketId, PacketKind, SCHEMA_VERSION, + EncoderId, EntityId, Grade, NodeId, Packet, PacketId, PacketKind, SCHEMA_VERSION, Stance, }; pub use score::{Embedder, ItemScore, OllamaEmbedder, Scorer, chrf}; pub use split::{Split, SplitScheme}; diff --git a/crates/weksa/src/main.rs b/crates/weksa/src/main.rs index a5830f6..f36ba27 100644 --- a/crates/weksa/src/main.rs +++ b/crates/weksa/src/main.rs @@ -486,20 +486,22 @@ fn main() -> Result<()> { println!("admitted {key}"); } Command::Lower { store, key } => { - let inventory = InventoryStore::open(&cli.inventory)?.inventory()?; - let packet = PacketStore::open(&store)? + let inventory_store = InventoryStore::open(&cli.inventory)?; + let inventory = inventory_store.inventory()?; + let mut packets = PacketStore::open(&store)?; + let packet = packets .get(&key)? .with_context(|| format!("no packet {key}"))?; - let view = validate(&packet, &inventory).map_err(|refusals| { - anyhow::anyhow!( - "{}", - refusals - .iter() - .map(ToString::to_string) - .collect::>() - .join("; ") + // Admission is the only source of a LoweringInput; re-admitting the + // stored packet is a no-op write that re-runs every door check. + let view = packets + .admit( + packet, + &inventory_store, + &CorpusStore::open(&cli.corpus)?, + &EntityStore::open(&cli.entities)?, ) - })?; + .map_err(refused)?; println!("{}", lower(&view, &inventory)?); } Command::Score(args) => { diff --git a/crates/weksa/src/packet.rs b/crates/weksa/src/packet.rs index 72132dd..cae001b 100644 --- a/crates/weksa/src/packet.rs +++ b/crates/weksa/src/packet.rs @@ -16,7 +16,7 @@ pub use crate::inventory::InvId; use crate::inventory::OptionLabel; /// Bumped by a commit that changes these types; the published schema moves with it. -pub const SCHEMA_VERSION: u32 = 3; +pub const SCHEMA_VERSION: u32 = 4; /// Id of a referent or predication within one packet: `n`, `k` a canonical /// decimal 1..=9999. @@ -208,6 +208,8 @@ pub struct Packet { pub extensions: Vec, #[cultcache(key = 12)] pub trace: Trace, + #[cultcache(key = 13)] + pub stance: Stance, } impl Packet { @@ -262,6 +264,14 @@ pub struct Discourse { pub features: Vec, } +/// The speaker's felt affect and restraint, never displayed affect: how the +/// speaker shows a feeling is each language's ontology's business. A +/// held-back feeling is not a claim, so it is never a predication. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct Stance { + pub features: Vec, +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub struct FeatureValue { /// Feature id. @@ -417,5 +427,6 @@ pub fn proof_packet() -> Packet { notes: vec!["First proof packet for Nibu flavored-English lowering.".to_string()], rejected_readings: vec![], }, + stance: Stance::default(), } } diff --git a/crates/weksa/src/store.rs b/crates/weksa/src/store.rs index 138ff62..1caa6ac 100644 --- a/crates/weksa/src/store.rs +++ b/crates/weksa/src/store.rs @@ -16,7 +16,7 @@ use crate::entity::{Entity, EntityOrigin}; use crate::form::{is_attestation, is_joining_only, is_language_tag, is_refused_format}; use crate::inventory::{Change, InvId, Inventory, InventoryEntry, InventoryRevision, revision_key}; use crate::packet::{EntityId, Packet}; -use crate::validate::{Refusal, validate}; +use crate::validate::{LoweringInput, Refusal, admissible}; /// A broken store rule, named. #[derive(Clone, Debug, PartialEq, Eq)] @@ -74,6 +74,9 @@ pub enum StoreRefusal { /// A corpus packet's referent cites an entity that is neither named on its /// item nor the item's speaker or a prior-turn speaker. EntityNotOnItem { entity: EntityId, item: String }, + /// A new packet names a corpus item already withdrawn. Packets admitted + /// before the withdrawal stay stored and readable. + WithdrawnCorpusItem { key: String }, /// Any other malformed document or change. Malformed { key: String, why: &'static str }, } @@ -666,9 +669,10 @@ impl PacketStore { self.cache.get::(key) } - /// The only door: a packet exists only if `validate` passes against the - /// inventory the store holds at the packet's revision, its `corpus_item` - /// resolves, every referent's entity is in the entity store and is named on + /// The only door, and the only source of a [`LoweringInput`]: a packet + /// exists only if the validator's rules pass against the inventory the + /// store holds at the packet's revision, its `corpus_item` resolves to an + /// item that is not withdrawn, every referent's entity is in the entity store and is named on /// that item or is its speaker or a prior-turn speaker (an id-set check). A /// key holds its packet for good. A packet that names no corpus item has no /// door here. @@ -678,9 +682,15 @@ impl PacketStore { inventory: &InventoryStore, corpus: &CorpusStore, entities: &EntityStore, - ) -> Result<(), StoreError> { - validate(&packet, &inventory.inventory()?).map_err(StoreError::Invalid)?; + ) -> Result { + let view = admissible(&packet, &inventory.inventory()?).map_err(StoreError::Invalid)?; let item = corpus.resolve(&packet.provenance.corpus_item)?; + if item.withdrawn { + return Err(StoreRefusal::WithdrawnCorpusItem { + key: item.id.clone(), + } + .into()); + } for entity in packet.referents.iter().filter_map(|r| r.entity.as_ref()) { if entities.get(entity)?.is_none() { return Err(StoreRefusal::UnknownEntity { @@ -702,11 +712,11 @@ impl PacketStore { let key = packet.id.to_string(); if let Some(existing) = self.get(&key)? { if existing == packet { - return Ok(()); + return Ok(view); } return Err(StoreRefusal::PacketImmutable { key }.into()); } self.cache.put(key, &packet)?; - Ok(()) + Ok(view) } } diff --git a/crates/weksa/src/validate.rs b/crates/weksa/src/validate.rs index a88c18e..7fbf6aa 100644 --- a/crates/weksa/src/validate.rs +++ b/crates/weksa/src/validate.rs @@ -1,6 +1,9 @@ -//! The validator owns whether a packet exists. Every path that admits or lowers a -//! packet calls `validate`, and its Ok value, [`LoweringInput`], is the only -//! thing a lowerer sees. The rules are V1..V8 and V10..V12: the shape of an id +//! 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 and V10..V12: the shape of an id //! or a grade is its type's parse, not a rule, so V9 is retired and not //! renumbered. //! @@ -25,7 +28,7 @@ use std::fmt; use crate::inventory::{Family, InvId, Inventory, InventoryEntry, Slot, ValueKind}; use crate::packet::{ Constraints, Context, Discourse, Extension, FeatValue, FeatureValue, NodeId, Packet, - PacketKind, Predication, Referent, Relation, SCHEMA_VERSION, + PacketKind, Predication, Referent, Relation, SCHEMA_VERSION, Stance, }; /// A broken rule, named. @@ -91,9 +94,42 @@ impl fmt::Display for Refusal { } } -/// Whether `packet` may exist under `inventory`. All broken rules are returned; -/// a packet that passes yields the lowering view, which only this function builds. -pub fn validate(packet: &Packet, inventory: &Inventory) -> Result> { +/// Whether `packet` meets the rules under `inventory`. All broken rules are +/// returned. A pass carries no lowering view: that comes from +/// `PacketStore::admit` only. +/// +/// ``` +/// use weksa::{Inventory, Refusal, packet::Packet}; +/// fn judge(p: &Packet, i: &Inventory) -> Result<(), Vec> { weksa::validate(p, i) } +/// ``` +/// +/// ```compile_fail +/// use weksa::{Inventory, LoweringInput, Refusal, packet::Packet}; +/// 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 +257,7 @@ impl<'a> Inventoried<'a> { predications, relations, extensions, + stance, .. } = p; let Context { @@ -238,6 +275,7 @@ impl<'a> Inventoried<'a> { self.slot(id, Family::SpeechAct, "speech act"); } self.features(features, Slot::Discourse); + self.features(&stance.features, Slot::Stance); for Referent { kind, concept, @@ -349,8 +387,9 @@ fn fits(kind: Option<&ValueKind>, value: &FeatValue) -> bool { /// 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 `validate` is the only constructor, so a lowerer -/// cannot be handed an unvalidated packet or the key to an answer text. +/// fields are private and the constructor is crate-private, called only by +/// `PacketStore::admit`, so a lowerer cannot be handed an unadmitted packet or +/// the key to an answer text. /// /// Each field has a doctest that reads it from outside the crate. A read /// compiles exactly when the field is `pub`, so the doctest (which must fail to @@ -394,6 +433,10 @@ fn fits(kind: Option<&ValueKind>, value: &FeatValue) -> bool { /// fn read(l: weksa::LoweringInput) { let _ = l.extensions; } /// ``` /// +/// ```compile_fail +/// fn read(l: weksa::LoweringInput) { let _ = l.stance; } +/// ``` +/// /// 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. @@ -430,6 +473,7 @@ pub struct LoweringInput { relations: Vec, constraints: Constraints, extensions: Vec, + stance: Stance, } impl LoweringInput { @@ -444,6 +488,7 @@ impl LoweringInput { relations: p.relations.clone(), constraints: p.constraints.clone(), extensions: p.extensions.clone(), + stance: p.stance.clone(), } } @@ -474,4 +519,7 @@ impl LoweringInput { pub fn extensions(&self) -> &[Extension] { &self.extensions } + pub fn stance(&self) -> &Stance { + &self.stance + } } diff --git a/crates/weksa/tests/common/mod.rs b/crates/weksa/tests/common/mod.rs index 13bad1f..07e95ee 100644 --- a/crates/weksa/tests/common/mod.rs +++ b/crates/weksa/tests/common/mod.rs @@ -19,7 +19,11 @@ pub const WARN: &str = "speech_act:5"; pub const POLARITY: &str = "feature:1"; pub const MODALITY: &str = "feature:4"; pub const HEDGE: &str = "feature:6"; -pub const IRONY: &str = "feature:11"; +pub const IRONY: &str = "feature:8"; +/// Stance features, slot Stance only (Grade). +pub const ANGER: &str = "feature:9"; +pub const AFFECTION: &str = "feature:16"; +pub const RESTRAINT: &str = "feature:18"; /// Introduced at rev 2. pub const CONCEPT_A: &str = "concept:1"; pub const CONCEPT_B: &str = "concept:2"; @@ -213,6 +217,7 @@ pub fn world() -> World { Slot::Referent, Slot::Predication, Slot::Relation, + Slot::Stance, ], ) }, @@ -310,5 +315,17 @@ pub fn good() -> Packet { notes: vec!["a note".into()], rejected_readings: vec![], }, + stance: Stance { + features: vec![ + FeatureValue { + feature: iv(ANGER), + value: FeatValue::Grade(grade(300)), + }, + FeatureValue { + feature: iv(RESTRAINT), + value: FeatValue::Grade(grade(700)), + }, + ], + }, } } diff --git a/crates/weksa/tests/entities.rs b/crates/weksa/tests/entities.rs index 4bf0569..46aeb71 100644 --- a/crates/weksa/tests/entities.rs +++ b/crates/weksa/tests/entities.rs @@ -395,7 +395,7 @@ fn packet_entities_resolve() { #[test] fn packet_schema_v2() { - assert_eq!(SCHEMA_VERSION, 3); + assert_eq!(SCHEMA_VERSION, 4); let mut p = good(); p.schema_version = 2; let Err(refused) = validate(&p, &inventory()) else { @@ -420,4 +420,10 @@ fn packet_schema_v2() { serde_json::from_str(&published["weksa.corpus_item.schema.json"]).unwrap(); assert!(item["properties"].get("names").is_some()); assert!(published.contains_key("weksa.entity.schema.json")); + // v4: the packet carries the speaker's stance, a bare feature list. + assert!(packet["properties"].get("stance").is_some(), "{packet}"); + assert_eq!( + packet["$defs"]["Stance"]["properties"]["features"]["type"], + "array" + ); } diff --git a/crates/weksa/tests/lower.rs b/crates/weksa/tests/lower.rs index 346513a..f50f096 100644 --- a/crates/weksa/tests/lower.rs +++ b/crates/weksa/tests/lower.rs @@ -22,8 +22,24 @@ fn committed() -> (Inventory, tempfile::TempDir) { (inv.inventory().unwrap(), tempfile::tempdir().unwrap()) } -fn view(packet: &Packet, inv: &Inventory) -> LoweringInput { - validate(packet, inv).unwrap() +/// A `LoweringInput` has one source, `PacketStore::admit`, so every packet +/// under test goes through a throwaway store backed by the committed stores. +fn view(packet: &Packet, _inv: &Inventory) -> LoweringInput { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let inventory = InventoryStore::open(&root.join("data/interlingua/inventory.cc")).unwrap(); + admitted(packet, &inventory) +} + +/// The same door against an inventory store the test built. +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 lowered(packet: &Packet, inv: &Inventory) -> Result { @@ -137,7 +153,7 @@ fn a_packet_carrying_what_the_lowerer_does_not_render_is_refused() { let mut p = proof_packet(); edit(&mut p); assert_eq!( - lower(&view(&p, &inv), &inv), + lower(&admitted(&p, &store), &inv), Err(LowerError::Unrendered { what }), "{what}" ); @@ -187,7 +203,7 @@ fn a_row_cannot_speak_for_an_id_whose_gloss_differs() { store.admit_revision(rev2, entries).unwrap(); let inv = store.inventory().unwrap(); assert_eq!( - lowered(&proof_packet(), &inv), + lower(&admitted(&proof_packet(), &store), &inv), Err(LowerError::GlossMismatch { id: iv("concept:4"), row: "control" diff --git a/crates/weksa/tests/stores.rs b/crates/weksa/tests/stores.rs index e22b498..07cbe0c 100644 --- a/crates/weksa/tests/stores.rs +++ b/crates/weksa/tests/stores.rs @@ -3,6 +3,7 @@ use common::*; use tempfile::TempDir; use weksa::corpus::{CorpusItem, ModelFamily, Source, Turn}; use weksa::inventory::{Change, seed}; +use weksa::packet::PacketId; use weksa::{ AddOutcome, CorpusStore, EntityStore, Family, InventoryEntry, InventoryRevision, InventoryStore, PacketStore, Refusal, Slot, SplitScheme, StoreError, StoreRefusal, ValueKind, @@ -867,7 +868,7 @@ fn retiring_keeps_the_entry_for_earlier_revisions() { fn revision_one_survives_revision_two() { let inv = inventory(); let revisions: Vec<(u32, usize)> = inv.revisions().map(|r| (r.n, r.changes.len())).collect(); - assert_eq!(revisions, vec![(1, 54), (2, 6), (3, 1), (4, 1)]); + assert_eq!(revisions, vec![(1, 61), (2, 6), (3, 1), (4, 1)]); // A packet of revision 1 is judged by the inventory of revision 1: the // revision is known (no V10), and it names nothing that came later. let mut p = good(); @@ -891,7 +892,7 @@ fn the_seed_is_the_standards_enumerations_and_the_nuance_features() { assert_eq!(count(Family::Relation), 16); assert_eq!(count(Family::Role), 8); assert_eq!(count(Family::SpeechAct), 8); - assert_eq!(count(Family::Feature), 11); + assert_eq!(count(Family::Feature), 18); assert_eq!( count(Family::Concept) + count(Family::Frame) + count(Family::Extension), 0 @@ -909,6 +910,46 @@ fn the_seed_is_the_standards_enumerations_and_the_nuance_features() { inv.entry(&iv(IRONY)).unwrap().value_kind, Some(ValueKind::Flag) ); + // Felt affect and restraint: Grade features in slot Stance alone. + for (id, name) in [ + (ANGER, "anger"), + (AFFECTION, "affection"), + (RESTRAINT, "restraint"), + ] { + let e = inv.entry(&iv(id)).unwrap(); + assert_eq!(e.gloss, name); + assert_eq!(e.value_kind, Some(ValueKind::Grade)); + assert_eq!(e.applies_to, vec![Slot::Stance]); + } + let stance_names: Vec<&str> = inv + .entries() + .filter(|e| e.applies_to == [Slot::Stance]) + .map(|e| e.gloss.as_str()) + .collect(); + assert_eq!( + stance_names, + [ + "anger", + "fear", + "sadness", + "joy", + "disgust", + "surprise", + "contempt", + "affection", + "shame", + "restraint" + ] + ); + // Hedge, certainty and irony stay on content; displayed formality, warmth + // and intensity are each language's ontology's, not the seed's. + for e in inv.entries() { + assert!( + !["warmth", "formality", "intensity"].contains(&e.gloss.as_str()), + "{}", + e.gloss + ); + } assert!( inv.entries() .all(|e| e.well_formed() && e.introduced_rev == 1 && e.retired_rev.is_none()) @@ -945,7 +986,7 @@ fn gloss_is_bounded_text() { #[test] fn packet_admission_goes_through_the_validator() { - let w = world(); + let mut w = world(); let mut store = PacketStore::open(&w.dir.path().join("p.cc")).unwrap(); let mut bad = good(); bad.schema_version = 5; @@ -960,13 +1001,18 @@ fn packet_admission_goes_through_the_validator() { other => panic!("{other:?}"), } assert!(store.all().unwrap().is_empty()); - store + // The door yields the lowering view, stance included. + let view = store .admit(good(), &w.inventory, &w.corpus, &w.entities) .unwrap(); - // The same packet is idempotent; a different one under the same key is refused. - store + assert_eq!(view.stance(), &good().stance); + assert!(!view.stance().features.is_empty()); + // The same packet is idempotent and yields the same view; a different one + // under the same key is refused. + let replay = store .admit(good(), &w.inventory, &w.corpus, &w.entities) .unwrap(); + assert_eq!(replay, view); let mut other = good(); other.provenance.confidence = grade(1); assert_eq!( @@ -977,6 +1023,24 @@ fn packet_admission_goes_through_the_validator() { ); assert_eq!(store.all().unwrap().len(), 1); assert_eq!(store.get(PACKET_ID).unwrap(), Some(good())); + // A withdrawn item admits no packet, a replay included; the packet admitted + // before the withdrawal stays stored and readable. + w.corpus.withdraw("item:nibu:1").unwrap(); + let mut fresh = good(); + fresh.id = PacketId::parse("packet:00000000000000aa").unwrap(); + let withdrawn = StoreRefusal::WithdrawnCorpusItem { + key: "item:nibu:1".into(), + }; + assert_eq!( + refusal(store.admit(fresh, &w.inventory, &w.corpus, &w.entities)), + withdrawn + ); + assert_eq!( + refusal(store.admit(good(), &w.inventory, &w.corpus, &w.entities)), + withdrawn + ); + assert_eq!(store.all().unwrap().len(), 1); + assert_eq!(store.get(PACKET_ID).unwrap(), Some(good())); } #[test] @@ -1206,6 +1270,21 @@ fn seed_reseeded() { .all() .unwrap(); assert_eq!(items.len(), 3); + // The committed inventory is the seed, reseeded (stance features in, the + // displayed-affect features out), then the nibu-proof revision. + let committed = InventoryStore::open(&root.join("data/interlingua/inventory.cc")) + .unwrap() + .inventory() + .unwrap(); + let (rev, mut entries) = seed(); + let (proof, proof_entries) = weksa::inventory::proof_revision(); + assert!(committed.has_revision(rev.n) && committed.has_revision(proof.n)); + entries.extend(proof_entries); + let mut have: Vec<_> = committed.entries().cloned().collect(); + let mut want = entries; + have.sort_by_key(|e| e.id); + want.sort_by_key(|e| e.id); + assert_eq!(have, want); let d = dir(); let mut replay = CorpusStore::open(&d.path().join("c.cc")).unwrap(); for i in items { diff --git a/crates/weksa/tests/validate.rs b/crates/weksa/tests/validate.rs index cdf7644..3453376 100644 --- a/crates/weksa/tests/validate.rs +++ b/crates/weksa/tests/validate.rs @@ -1,7 +1,7 @@ mod common; use common::*; use weksa::packet::*; -use weksa::{Family, Refusal, Slot, validate}; +use weksa::{Family, PacketStore, Refusal, Slot, validate}; fn refusals(p: &Packet) -> Vec { validate(p, &inventory()).err().unwrap_or_default() @@ -334,6 +334,83 @@ fn v8_feature_applies_to() { assert_eq!(refusals(&pred), vec![]); } +#[test] +fn stance_features_apply_to_the_stance_only() { + // Affect and restraint sit in slot Stance and nowhere else. + assert_eq!(refusals(&good()), vec![]); + for id in [ANGER, RESTRAINT] { + let mut p = good(); + p.discourse + .features + .push(feat(id, FeatValue::Grade(grade(1)))); + assert_eq!( + refusals(&p), + vec![Refusal::V8NotApplicable { + feature: iv(id), + slot: Slot::Discourse + }] + ); + } + let mut referent = good(); + referent.referents[0] + .features + .push(feat(AFFECTION, FeatValue::Grade(grade(1)))); + assert_eq!( + refusals(&referent), + vec![Refusal::V8NotApplicable { + feature: iv(AFFECTION), + slot: Slot::Referent + }] + ); + let mut predication = good(); + predication.predications[0] + .features + .push(feat(ANGER, FeatValue::Grade(grade(1)))); + assert_eq!( + refusals(&predication), + vec![Refusal::V8NotApplicable { + feature: iv(ANGER), + slot: Slot::Predication + }] + ); + // Hedge is a stance on content, not a felt state: not in the stance. + let mut p = good(); + p.stance + .features + .push(feat(HEDGE, FeatValue::Grade(grade(1)))); + assert_eq!( + refusals(&p), + vec![Refusal::V8NotApplicable { + feature: iv(HEDGE), + slot: Slot::Stance + }] + ); + // A stance feature takes its inventory's value kind (V7) and must exist (V5). + let mut flag = good(); + flag.stance.features = vec![feat(ANGER, FeatValue::Flag(true))]; + assert_eq!( + refusals(&flag), + vec![Refusal::V7ValueKind { what: ANGER.into() }] + ); + let mut missing = good(); + missing.stance.features = vec![feat("feature:99", FeatValue::Grade(grade(1)))]; + assert_eq!(refusals(&missing), vec![unresolved("feature:99")]); + // A default stance is valid: the packet records no felt state. + let mut none = good(); + none.stance = Stance::default(); + assert_eq!(refusals(&none), vec![]); +} + +#[test] +fn displayed_formality_warmth_and_intensity_are_not_in_the_inventory() { + // Felt affect is the packet's; how it is displayed is each language's + // ontology's, so no feature carries it. + let inv = inventory(); + for gone in ["warmth", "formality", "intensity"] { + assert!(inv.entries().all(|e| e.gloss != gone), "{gone}"); + } +} + #[test] fn v10_rev_exists() { let mut p = good(); @@ -358,7 +435,12 @@ fn lowering_input_carries_no_trace_id_or_provenance() { let mut p = good(); p.trace.notes = vec!["SECRET-NOTE".into()]; p.trace.rejected_readings = vec!["SECRET-READING".into()]; - let input = validate(&p, &inventory()).unwrap(); + // Only the packet store's door yields the view. + let w = world(); + let mut store = PacketStore::open(&w.dir.path().join("p.cc")).unwrap(); + let input = store + .admit(p.clone(), &w.inventory, &w.corpus, &w.entities) + .unwrap(); let seen = format!("{input:?}"); for hidden in [PACKET_ID, ENCODER_ID, "item:nibu:1", "SECRET"] { assert!(!seen.contains(hidden), "{hidden} reached the input: {seen}"); @@ -373,6 +455,9 @@ fn lowering_input_carries_no_trace_id_or_provenance() { assert_eq!(input.context(), &p.context); assert_eq!(input.discourse(), &p.discourse); assert_eq!(input.constraints(), &p.constraints); + // The speaker's stance is meaning, not provenance: it reaches the lowerer. + assert_eq!(input.stance(), &p.stance); + assert!(!p.stance.features.is_empty()); } #[test] @@ -435,6 +520,14 @@ 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 + .push(feat(ANGER, FeatValue::Grade(grade(5)))); + assert_eq!(refusals(&felt), vec![v11(Some(Slot::Stance), ANGER)]); let mut extension = good(); extension.extensions.push(Extension { key: iv(EXT), diff --git a/data/interlingua/inventory.cc b/data/interlingua/inventory.cc index 77e437b..becfc92 100644 Binary files a/data/interlingua/inventory.cc and b/data/interlingua/inventory.cc differ diff --git a/docs/interlingua-standard.md b/docs/interlingua-standard.md index a59a963..8b36983 100644 --- a/docs/interlingua-standard.md +++ b/docs/interlingua-standard.md @@ -127,6 +127,7 @@ Packet constraints { preserve, deterministic, max_latency_ms } extensions [ { key: InvId (family extension), value: Grade | Option | Flag } ] trace { notes, rejected_readings } + stance { features } ``` `schema_version` and `inventory_rev` name the exact schema and inventory @@ -135,8 +136,10 @@ revision a packet was written against. A grade is an integer permille, ### Validation -A packet exists only if `validate` passes; every path that admits or lowers a -packet calls it. A refusal names the rule. +A packet exists only if the validator's rules pass and the packet store admits +it. `validate` judges and returns `Ok(())` or the refusals; the packet store's +`admit` is the one door and the only source of the lowering view. A refusal +names the rule. - V1: `schema_version` is the version these types define. - V2: at least one of `referents`, `predications`, `relations` is present. @@ -158,9 +161,11 @@ packet calls it. A refusal names the rule. (a calibration constant, capacity and not meaning). 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. `validate` returns the lowering view of a -packet that passes; it carries no packet id, no provenance and no trace, and is -built nowhere else. A packet is admitted against the inventory the packet store +an extension key's family is V6. `PacketStore::admit` returns the lowering view of a +packet it admits; the view carries the meaning and the stance, no packet id, no +provenance and no trace, and is built nowhere else. The store also refuses a +packet that names a withdrawn corpus item (packets admitted before the +withdrawal stay stored). A packet is admitted against the inventory the packet store reads at the packet's revision, never one the caller supplies. ### `kind` @@ -182,9 +187,22 @@ ids. ### `discourse` Why the packet is said: `speech_act` is a speech-act id, `addressee` a node id, -and `features` carry source-side nuance (hedge, certainty, warmth, formality, -intensity, irony and whatever the inventory grows). This is source-side -communicative pressure, not target-language register. +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. + +### `stance` + +The speaker's felt affect and how much of it the speaker holds back. `features` +is a feature list in slot `Stance`, checked like every other (V5 to V8, V11). +The seed carries felt affect as grades of how strongly it is felt (anger, fear, sadness, +joy, disgust, surprise, contempt, affection, shame) and `restraint`, a grade for +how much of the felt affect is suppressed (0 none, 1000 fully). The stance is +what is felt, never what is displayed: how a feeling shows, and how much of a +restrained one leaks, are cultural, and each language's ontology maps felt +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. ### `referents` diff --git a/schemas/cultnet/weksa.interlingua_packet.schema.json b/schemas/cultnet/weksa.interlingua_packet.schema.json index 24d7507..d839203 100644 --- a/schemas/cultnet/weksa.interlingua_packet.schema.json +++ b/schemas/cultnet/weksa.interlingua_packet.schema.json @@ -55,6 +55,9 @@ "format": "uint32", "minimum": 0 }, + "stance": { + "$ref": "#/$defs/Stance" + }, "trace": { "$ref": "#/$defs/Trace" } @@ -72,7 +75,8 @@ "relations", "constraints", "extensions", - "trace" + "trace", + "stance" ], "$defs": { "Constraints": { @@ -446,6 +450,21 @@ "filler" ] }, + "Stance": { + "description": "The speaker's felt affect and restraint, never displayed affect: how the\nspeaker shows a feeling is each language's ontology's business. A\nheld-back feeling is not a claim, so it is never a predication.", + "type": "object", + "properties": { + "features": { + "type": "array", + "items": { + "$ref": "#/$defs/FeatureValue" + } + } + }, + "required": [ + "features" + ] + }, "Trace": { "description": "Authoring and debug notes. Stripped before lowering.", "type": "object", diff --git a/schemas/cultnet/weksa.inventory_entry.schema.json b/schemas/cultnet/weksa.inventory_entry.schema.json index 6187f02..ffefd95 100644 --- a/schemas/cultnet/weksa.inventory_entry.schema.json +++ b/schemas/cultnet/weksa.inventory_entry.schema.json @@ -74,7 +74,8 @@ "Discourse", "Referent", "Predication", - "Relation" + "Relation", + "Stance" ] }, "ValueKind": {