Merge pull request 'speaker-state onto main (stance in packet, admit-only LoweringInput) + build notes' (#3) from weksa-lowering/speaker-state-on-main into main

This commit is contained in:
Weksa 2026-11-03 19:27:33 +00:00
commit 157892a95a
16 changed files with 404 additions and 55 deletions

View file

@ -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 Project truth does not belong in agent personality memory. If Weksa later grows
role dossiers or heartbeat scheduling, those surfaces must remain separate from role dossiers or heartbeat scheduling, those surfaces must remain separate from
language-project truth. 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 <store>` admits the nibu proof packet under the key
`packet:6e6962752d303031` (hex of `nibu-001`), not `nibu-proof-001`. Use that key
with `weksa lower <store> <key>`.

View file

@ -188,6 +188,7 @@ pub enum Slot {
Referent, Referent,
Predication, Predication,
Relation, Relation,
Stance,
} }
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
@ -439,10 +440,28 @@ pub fn seed() -> (InventoryRevision, Vec<InventoryEntry>) {
&pred, &pred,
); );
let nuance = [Slot::Discourse, Slot::Predication]; 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, g, Some(ValueKind::Grade), &nuance);
} }
add(Family::Feature, "irony", Some(ValueKind::Flag), &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(); let changes = entries.iter().map(|e| Change::Add(e.id)).collect();
( (
InventoryRevision { InventoryRevision {

View file

@ -24,7 +24,7 @@ pub use inventory::{
}; };
pub use lower::{LowerError, lower}; pub use lower::{LowerError, lower};
pub use packet::{ 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 score::{Embedder, ItemScore, OllamaEmbedder, Scorer, chrf};
pub use split::{Split, SplitScheme}; pub use split::{Split, SplitScheme};

View file

@ -486,20 +486,22 @@ fn main() -> Result<()> {
println!("admitted {key}"); println!("admitted {key}");
} }
Command::Lower { store, key } => { Command::Lower { store, key } => {
let inventory = InventoryStore::open(&cli.inventory)?.inventory()?; let inventory_store = InventoryStore::open(&cli.inventory)?;
let packet = PacketStore::open(&store)? let inventory = inventory_store.inventory()?;
let mut packets = PacketStore::open(&store)?;
let packet = packets
.get(&key)? .get(&key)?
.with_context(|| format!("no packet {key}"))?; .with_context(|| format!("no packet {key}"))?;
let view = validate(&packet, &inventory).map_err(|refusals| { // Admission is the only source of a LoweringInput; re-admitting the
anyhow::anyhow!( // stored packet is a no-op write that re-runs every door check.
"{}", let view = packets
refusals .admit(
.iter() packet,
.map(ToString::to_string) &inventory_store,
.collect::<Vec<_>>() &CorpusStore::open(&cli.corpus)?,
.join("; ") &EntityStore::open(&cli.entities)?,
) )
})?; .map_err(refused)?;
println!("{}", lower(&view, &inventory)?); println!("{}", lower(&view, &inventory)?);
} }
Command::Score(args) => { Command::Score(args) => {

View file

@ -16,7 +16,7 @@ pub use crate::inventory::InvId;
use crate::inventory::OptionLabel; use crate::inventory::OptionLabel;
/// Bumped by a commit that changes these types; the published schema moves with it. /// 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>`, `k` a canonical /// Id of a referent or predication within one packet: `n<k>`, `k` a canonical
/// decimal 1..=9999. /// decimal 1..=9999.
@ -208,6 +208,8 @@ pub struct Packet {
pub extensions: Vec<Extension>, pub extensions: Vec<Extension>,
#[cultcache(key = 12)] #[cultcache(key = 12)]
pub trace: Trace, pub trace: Trace,
#[cultcache(key = 13)]
pub stance: Stance,
} }
impl Packet { impl Packet {
@ -262,6 +264,14 @@ pub struct Discourse {
pub features: Vec<FeatureValue>, pub features: Vec<FeatureValue>,
} }
/// 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<FeatureValue>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct FeatureValue { pub struct FeatureValue {
/// Feature id. /// Feature id.
@ -417,5 +427,6 @@ pub fn proof_packet() -> Packet {
notes: vec!["First proof packet for Nibu flavored-English lowering.".to_string()], notes: vec!["First proof packet for Nibu flavored-English lowering.".to_string()],
rejected_readings: vec![], rejected_readings: vec![],
}, },
stance: Stance::default(),
} }
} }

View file

@ -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::form::{is_attestation, is_joining_only, is_language_tag, is_refused_format};
use crate::inventory::{Change, InvId, Inventory, InventoryEntry, InventoryRevision, revision_key}; use crate::inventory::{Change, InvId, Inventory, InventoryEntry, InventoryRevision, revision_key};
use crate::packet::{EntityId, Packet}; use crate::packet::{EntityId, Packet};
use crate::validate::{Refusal, validate}; use crate::validate::{LoweringInput, Refusal, admissible};
/// A broken store rule, named. /// A broken store rule, named.
#[derive(Clone, Debug, PartialEq, Eq)] #[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 /// 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. /// item nor the item's speaker or a prior-turn speaker.
EntityNotOnItem { entity: EntityId, item: String }, 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. /// Any other malformed document or change.
Malformed { key: String, why: &'static str }, Malformed { key: String, why: &'static str },
} }
@ -666,9 +669,10 @@ impl PacketStore {
self.cache.get::<Packet>(key) self.cache.get::<Packet>(key)
} }
/// The only door: a packet exists only if `validate` passes against the /// The only door, and the only source of a [`LoweringInput`]: a packet
/// inventory the store holds at the packet's revision, its `corpus_item` /// exists only if the validator's rules pass against the inventory the
/// resolves, every referent's entity is in the entity store and is named on /// 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 /// 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 /// key holds its packet for good. A packet that names no corpus item has no
/// door here. /// door here.
@ -678,9 +682,15 @@ impl PacketStore {
inventory: &InventoryStore, inventory: &InventoryStore,
corpus: &CorpusStore, corpus: &CorpusStore,
entities: &EntityStore, entities: &EntityStore,
) -> Result<(), StoreError> { ) -> Result<LoweringInput, StoreError> {
validate(&packet, &inventory.inventory()?).map_err(StoreError::Invalid)?; let view = admissible(&packet, &inventory.inventory()?).map_err(StoreError::Invalid)?;
let item = corpus.resolve(&packet.provenance.corpus_item)?; 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()) { for entity in packet.referents.iter().filter_map(|r| r.entity.as_ref()) {
if entities.get(entity)?.is_none() { if entities.get(entity)?.is_none() {
return Err(StoreRefusal::UnknownEntity { return Err(StoreRefusal::UnknownEntity {
@ -702,11 +712,11 @@ impl PacketStore {
let key = packet.id.to_string(); let key = packet.id.to_string();
if let Some(existing) = self.get(&key)? { if let Some(existing) = self.get(&key)? {
if existing == packet { if existing == packet {
return Ok(()); return Ok(view);
} }
return Err(StoreRefusal::PacketImmutable { key }.into()); return Err(StoreRefusal::PacketImmutable { key }.into());
} }
self.cache.put(key, &packet)?; self.cache.put(key, &packet)?;
Ok(()) Ok(view)
} }
} }

View file

@ -1,6 +1,9 @@
//! The validator owns whether a packet exists. Every path that admits or lowers a //! The validator owns the inventory and form rules a packet must meet. Every
//! packet calls `validate`, and its Ok value, [`LoweringInput`], is the only //! path that admits a packet applies them, but `validate` only judges: it
//! thing a lowerer sees. The rules are V1..V8 and V10..V12: the shape of an id //! 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 //! or a grade is its type's parse, not a rule, so V9 is retired and not
//! renumbered. //! renumbered.
//! //!
@ -25,7 +28,7 @@ use std::fmt;
use crate::inventory::{Family, InvId, Inventory, InventoryEntry, Slot, ValueKind}; use crate::inventory::{Family, InvId, Inventory, InventoryEntry, Slot, ValueKind};
use crate::packet::{ use crate::packet::{
Constraints, Context, Discourse, Extension, FeatValue, FeatureValue, NodeId, 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. /// A broken rule, named.
@ -91,9 +94,42 @@ impl fmt::Display for Refusal {
} }
} }
/// Whether `packet` may exist under `inventory`. All broken rules are returned; /// Whether `packet` meets the rules under `inventory`. All broken rules are
/// a packet that passes yields the lowering view, which only this function builds. /// returned. A pass carries no lowering view: that comes from
pub fn validate(packet: &Packet, inventory: &Inventory) -> Result<LoweringInput, Vec<Refusal>> { /// `PacketStore::admit` only.
///
/// ```
/// use weksa::{Inventory, Refusal, packet::Packet};
/// fn judge(p: &Packet, i: &Inventory) -> Result<(), Vec<Refusal>> { weksa::validate(p, i) }
/// ```
///
/// ```compile_fail
/// use weksa::{Inventory, LoweringInput, Refusal, packet::Packet};
/// fn lower(p: &Packet, i: &Inventory) -> Result<LoweringInput, Vec<Refusal>> { weksa::validate(p, i) }
/// ```
pub fn validate(packet: &Packet, inventory: &Inventory) -> Result<(), Vec<Refusal>> {
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<LoweringInput, Vec<Refusal>> {
let mut out = Vec::new(); let mut out = Vec::new();
if packet.schema_version != SCHEMA_VERSION { if packet.schema_version != SCHEMA_VERSION {
out.push(Refusal::V1SchemaVersion { out.push(Refusal::V1SchemaVersion {
@ -221,6 +257,7 @@ impl<'a> Inventoried<'a> {
predications, predications,
relations, relations,
extensions, extensions,
stance,
.. ..
} = p; } = p;
let Context { let Context {
@ -238,6 +275,7 @@ impl<'a> Inventoried<'a> {
self.slot(id, Family::SpeechAct, "speech act"); self.slot(id, Family::SpeechAct, "speech act");
} }
self.features(features, Slot::Discourse); self.features(features, Slot::Discourse);
self.features(&stance.features, Slot::Stance);
for Referent { for Referent {
kind, kind,
concept, 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 /// 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 /// 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 /// fields are private and the constructor is crate-private, called only by
/// cannot be handed an unvalidated packet or the key to an answer text. /// `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 /// 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 /// 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; } /// 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 /// 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 /// `LoweringInput::of` is `pub`. The first block is the same code without the
/// call, so the second fails for that call and nothing else. /// call, so the second fails for that call and nothing else.
@ -430,6 +473,7 @@ pub struct LoweringInput {
relations: Vec<Relation>, relations: Vec<Relation>,
constraints: Constraints, constraints: Constraints,
extensions: Vec<Extension>, extensions: Vec<Extension>,
stance: Stance,
} }
impl LoweringInput { impl LoweringInput {
@ -444,6 +488,7 @@ impl LoweringInput {
relations: p.relations.clone(), relations: p.relations.clone(),
constraints: p.constraints.clone(), constraints: p.constraints.clone(),
extensions: p.extensions.clone(), extensions: p.extensions.clone(),
stance: p.stance.clone(),
} }
} }
@ -474,4 +519,7 @@ impl LoweringInput {
pub fn extensions(&self) -> &[Extension] { pub fn extensions(&self) -> &[Extension] {
&self.extensions &self.extensions
} }
pub fn stance(&self) -> &Stance {
&self.stance
}
} }

View file

@ -19,7 +19,11 @@ pub const WARN: &str = "speech_act:5";
pub const POLARITY: &str = "feature:1"; pub const POLARITY: &str = "feature:1";
pub const MODALITY: &str = "feature:4"; pub const MODALITY: &str = "feature:4";
pub const HEDGE: &str = "feature:6"; 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. /// Introduced at rev 2.
pub const CONCEPT_A: &str = "concept:1"; pub const CONCEPT_A: &str = "concept:1";
pub const CONCEPT_B: &str = "concept:2"; pub const CONCEPT_B: &str = "concept:2";
@ -213,6 +217,7 @@ pub fn world() -> World {
Slot::Referent, Slot::Referent,
Slot::Predication, Slot::Predication,
Slot::Relation, Slot::Relation,
Slot::Stance,
], ],
) )
}, },
@ -310,5 +315,17 @@ pub fn good() -> Packet {
notes: vec!["a note".into()], notes: vec!["a note".into()],
rejected_readings: vec![], 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)),
},
],
},
} }
} }

View file

@ -395,7 +395,7 @@ fn packet_entities_resolve() {
#[test] #[test]
fn packet_schema_v2() { fn packet_schema_v2() {
assert_eq!(SCHEMA_VERSION, 3); assert_eq!(SCHEMA_VERSION, 4);
let mut p = good(); let mut p = good();
p.schema_version = 2; p.schema_version = 2;
let Err(refused) = validate(&p, &inventory()) else { 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(); serde_json::from_str(&published["weksa.corpus_item.schema.json"]).unwrap();
assert!(item["properties"].get("names").is_some()); assert!(item["properties"].get("names").is_some());
assert!(published.contains_key("weksa.entity.schema.json")); 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"
);
} }

View file

@ -22,8 +22,24 @@ fn committed() -> (Inventory, tempfile::TempDir) {
(inv.inventory().unwrap(), tempfile::tempdir().unwrap()) (inv.inventory().unwrap(), tempfile::tempdir().unwrap())
} }
fn view(packet: &Packet, inv: &Inventory) -> LoweringInput { /// A `LoweringInput` has one source, `PacketStore::admit`, so every packet
validate(packet, inv).unwrap() /// 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<String, LowerError> { fn lowered(packet: &Packet, inv: &Inventory) -> Result<String, LowerError> {
@ -137,7 +153,7 @@ fn a_packet_carrying_what_the_lowerer_does_not_render_is_refused() {
let mut p = proof_packet(); let mut p = proof_packet();
edit(&mut p); edit(&mut p);
assert_eq!( assert_eq!(
lower(&view(&p, &inv), &inv), lower(&admitted(&p, &store), &inv),
Err(LowerError::Unrendered { what }), Err(LowerError::Unrendered { what }),
"{what}" "{what}"
); );
@ -187,7 +203,7 @@ fn a_row_cannot_speak_for_an_id_whose_gloss_differs() {
store.admit_revision(rev2, entries).unwrap(); store.admit_revision(rev2, entries).unwrap();
let inv = store.inventory().unwrap(); let inv = store.inventory().unwrap();
assert_eq!( assert_eq!(
lowered(&proof_packet(), &inv), lower(&admitted(&proof_packet(), &store), &inv),
Err(LowerError::GlossMismatch { Err(LowerError::GlossMismatch {
id: iv("concept:4"), id: iv("concept:4"),
row: "control" row: "control"

View file

@ -3,6 +3,7 @@ use common::*;
use tempfile::TempDir; use tempfile::TempDir;
use weksa::corpus::{CorpusItem, ModelFamily, Source, Turn}; use weksa::corpus::{CorpusItem, ModelFamily, Source, Turn};
use weksa::inventory::{Change, seed}; use weksa::inventory::{Change, seed};
use weksa::packet::PacketId;
use weksa::{ use weksa::{
AddOutcome, CorpusStore, EntityStore, Family, InventoryEntry, InventoryRevision, AddOutcome, CorpusStore, EntityStore, Family, InventoryEntry, InventoryRevision,
InventoryStore, PacketStore, Refusal, Slot, SplitScheme, StoreError, StoreRefusal, ValueKind, 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() { fn revision_one_survives_revision_two() {
let inv = inventory(); let inv = inventory();
let revisions: Vec<(u32, usize)> = inv.revisions().map(|r| (r.n, r.changes.len())).collect(); 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 // 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. // revision is known (no V10), and it names nothing that came later.
let mut p = good(); 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::Relation), 16);
assert_eq!(count(Family::Role), 8); assert_eq!(count(Family::Role), 8);
assert_eq!(count(Family::SpeechAct), 8); assert_eq!(count(Family::SpeechAct), 8);
assert_eq!(count(Family::Feature), 11); assert_eq!(count(Family::Feature), 18);
assert_eq!( assert_eq!(
count(Family::Concept) + count(Family::Frame) + count(Family::Extension), count(Family::Concept) + count(Family::Frame) + count(Family::Extension),
0 0
@ -909,6 +910,46 @@ fn the_seed_is_the_standards_enumerations_and_the_nuance_features() {
inv.entry(&iv(IRONY)).unwrap().value_kind, inv.entry(&iv(IRONY)).unwrap().value_kind,
Some(ValueKind::Flag) 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!( assert!(
inv.entries() inv.entries()
.all(|e| e.well_formed() && e.introduced_rev == 1 && e.retired_rev.is_none()) .all(|e| e.well_formed() && e.introduced_rev == 1 && e.retired_rev.is_none())
@ -945,7 +986,7 @@ fn gloss_is_bounded_text() {
#[test] #[test]
fn packet_admission_goes_through_the_validator() { 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 store = PacketStore::open(&w.dir.path().join("p.cc")).unwrap();
let mut bad = good(); let mut bad = good();
bad.schema_version = 5; bad.schema_version = 5;
@ -960,13 +1001,18 @@ fn packet_admission_goes_through_the_validator() {
other => panic!("{other:?}"), other => panic!("{other:?}"),
} }
assert!(store.all().unwrap().is_empty()); 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) .admit(good(), &w.inventory, &w.corpus, &w.entities)
.unwrap(); .unwrap();
// The same packet is idempotent; a different one under the same key is refused. assert_eq!(view.stance(), &good().stance);
store 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) .admit(good(), &w.inventory, &w.corpus, &w.entities)
.unwrap(); .unwrap();
assert_eq!(replay, view);
let mut other = good(); let mut other = good();
other.provenance.confidence = grade(1); other.provenance.confidence = grade(1);
assert_eq!( assert_eq!(
@ -977,6 +1023,24 @@ fn packet_admission_goes_through_the_validator() {
); );
assert_eq!(store.all().unwrap().len(), 1); assert_eq!(store.all().unwrap().len(), 1);
assert_eq!(store.get(PACKET_ID).unwrap(), Some(good())); 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] #[test]
@ -1206,6 +1270,21 @@ fn seed_reseeded() {
.all() .all()
.unwrap(); .unwrap();
assert_eq!(items.len(), 3); 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 d = dir();
let mut replay = CorpusStore::open(&d.path().join("c.cc")).unwrap(); let mut replay = CorpusStore::open(&d.path().join("c.cc")).unwrap();
for i in items { for i in items {

View file

@ -1,7 +1,7 @@
mod common; mod common;
use common::*; use common::*;
use weksa::packet::*; use weksa::packet::*;
use weksa::{Family, Refusal, Slot, validate}; use weksa::{Family, PacketStore, Refusal, Slot, validate};
fn refusals(p: &Packet) -> Vec<Refusal> { fn refusals(p: &Packet) -> Vec<Refusal> {
validate(p, &inventory()).err().unwrap_or_default() validate(p, &inventory()).err().unwrap_or_default()
@ -334,6 +334,83 @@ fn v8_feature_applies_to() {
assert_eq!(refusals(&pred), vec![]); 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] #[test]
fn v10_rev_exists() { fn v10_rev_exists() {
let mut p = good(); let mut p = good();
@ -358,7 +435,12 @@ fn lowering_input_carries_no_trace_id_or_provenance() {
let mut p = good(); let mut p = good();
p.trace.notes = vec!["SECRET-NOTE".into()]; p.trace.notes = vec!["SECRET-NOTE".into()];
p.trace.rejected_readings = vec!["SECRET-READING".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:?}"); let seen = format!("{input:?}");
for hidden in [PACKET_ID, ENCODER_ID, "item:nibu:1", "SECRET"] { for hidden in [PACKET_ID, ENCODER_ID, "item:nibu:1", "SECRET"] {
assert!(!seen.contains(hidden), "{hidden} reached the input: {seen}"); 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.context(), &p.context);
assert_eq!(input.discourse(), &p.discourse); assert_eq!(input.discourse(), &p.discourse);
assert_eq!(input.constraints(), &p.constraints); 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] #[test]
@ -435,6 +520,14 @@ fn v11_duplicate_feature_each_slot() {
refusals(&relation), refusals(&relation),
vec![v11(Some(Slot::Relation), FEAT_ALL)] 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(); let mut extension = good();
extension.extensions.push(Extension { extension.extensions.push(Extension {
key: iv(EXT), key: iv(EXT),

Binary file not shown.

View file

@ -127,6 +127,7 @@ Packet
constraints { preserve, deterministic, max_latency_ms } constraints { preserve, deterministic, max_latency_ms }
extensions [ { key: InvId (family extension), value: Grade | Option | Flag } ] extensions [ { key: InvId (family extension), value: Grade | Option | Flag } ]
trace { notes, rejected_readings } trace { notes, rejected_readings }
stance { features }
``` ```
`schema_version` and `inventory_rev` name the exact schema and inventory `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 ### Validation
A packet exists only if `validate` passes; every path that admits or lowers a A packet exists only if the validator's rules pass and the packet store admits
packet calls it. A refusal names the rule. 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. - V1: `schema_version` is the version these types define.
- V2: at least one of `referents`, `predications`, `relations` is present. - 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). (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 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 an extension key's family is V6. `PacketStore::admit` returns the lowering view of a
packet that passes; it carries no packet id, no provenance and no trace, and is packet it admits; the view carries the meaning and the stance, no packet id, no
built nowhere else. A packet is admitted against the inventory the packet store 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. reads at the packet's revision, never one the caller supplies.
### `kind` ### `kind`
@ -182,9 +187,22 @@ ids.
### `discourse` ### `discourse`
Why the packet is said: `speech_act` is a speech-act id, `addressee` a node id, Why the packet is said: `speech_act` is a speech-act id, `addressee` a node id,
and `features` carry source-side nuance (hedge, certainty, warmth, formality, and `features` carry source-side nuance on content (hedge, certainty, irony and
intensity, irony and whatever the inventory grows). This is source-side whatever the inventory grows). This is source-side communicative pressure, not
communicative pressure, not target-language register. 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` ### `referents`

View file

@ -55,6 +55,9 @@
"format": "uint32", "format": "uint32",
"minimum": 0 "minimum": 0
}, },
"stance": {
"$ref": "#/$defs/Stance"
},
"trace": { "trace": {
"$ref": "#/$defs/Trace" "$ref": "#/$defs/Trace"
} }
@ -72,7 +75,8 @@
"relations", "relations",
"constraints", "constraints",
"extensions", "extensions",
"trace" "trace",
"stance"
], ],
"$defs": { "$defs": {
"Constraints": { "Constraints": {
@ -446,6 +450,21 @@
"filler" "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": { "Trace": {
"description": "Authoring and debug notes. Stripped before lowering.", "description": "Authoring and debug notes. Stripped before lowering.",
"type": "object", "type": "object",

View file

@ -74,7 +74,8 @@
"Discourse", "Discourse",
"Referent", "Referent",
"Predication", "Predication",
"Relation" "Relation",
"Stance"
] ]
}, },
"ValueKind": { "ValueKind": {