speaker-scene: weksa.speaker and weksa.scene documents, with_scene reads the store #26

Merged
weksa merged 6 commits from weksa-lowering/speaker-scene into main 2026-11-10 03:26:05 +00:00
19 changed files with 1895 additions and 216 deletions

View file

@ -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)]
@ -228,10 +230,21 @@ impl InventoryEntry {
}
/// Whether the entry's shape matches its family: features carry a value kind
/// and slots, extensions a value kind and no slots, nothing else either.
/// and slots, extensions a value kind and no slots, nothing else either. A
/// feature of slot `Stance`, `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 {
match self.family() {
Family::Feature => self.value_kind.is_some() && !self.applies_to.is_empty(),
Family::Feature => {
self.value_kind.is_some()
&& !self.applies_to.is_empty()
&& (self.applies_to.len() == 1
|| !self
.applies_to
.iter()
.any(|s| matches!(s, Slot::Stance | Slot::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(),
}
@ -523,28 +536,49 @@ pub fn proof_revision() -> (InventoryRevision, Vec<InventoryEntry>) {
)
}
/// 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<InventoryEntry>) {
/// 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<InventoryEntry>) {
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()
@ -553,7 +587,7 @@ pub fn scene_revision() -> (InventoryRevision, Vec<InventoryEntry>) {
id: Family::Feature.id(NonZeroU32::MIN.saturating_add(taken + i as u32)),
gloss: gloss.to_string(),
value_kind: Some(kind),
applies_to: vec![Slot::Scene],
applies_to: vec![slot],
introduced_rev: 3,
retired_rev: None,
});

View file

@ -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};
pub use validate::{LoweringInput, Refusal, SceneRead, validate};

View file

@ -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::<InventoryRevision>(),
published_for::<CorpusItem>(),
published_for::<Entity>(),
published_for::<Speaker>(),
published_for::<Scene>(),
]
}

120
crates/weksa/src/speaker.rs Normal file
View file

@ -0,0 +1,120 @@
//! 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:<entity slug>@<n>`; 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<FeatureValue>,
#[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<FeatureValue>,
}
/// A scene: its participants and the relations between them. Key
/// `scene:<group>@<n>`; 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<EntityId>,
#[cultcache(key = 4)]
pub relations: Vec<PairRelation>,
#[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; `None` when the scene holds no such
/// relation (a relation with no features is `Some(&[])`). A named
/// addressee the scene lacks never falls back to `Unnamed`.
pub fn relation(
&self,
speaker: &EntityId,
addressee: Option<&EntityId>,
) -> Option<&[FeatureValue]> {
self.relations
.iter()
.find(|r| {
&r.from == speaker
&& match (&r.to, addressee) {
(Party::Named(a), Some(b)) => a == b,
(Party::Unnamed, None) => true,
_ => false,
}
})
.map(|r| r.features.as_slice())
}
}
/// `speaker:<entity slug>@<n>`.
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:<group>@<n>`.
pub fn scene_key(group: &str, n: u32) -> String {
format!("scene:{group}@{n}")
}

View file

@ -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, feature_refusals, validate};
/// 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}"),
}
}
@ -943,7 +959,7 @@ impl PacketStore {
corpus: &CorpusStore,
entities: &EntityStore,
) -> Result<LoweringInput, StoreError> {
let view = admissible(&packet, &inventory.inventory()?).map_err(StoreError::Invalid)?;
validate(&packet, &inventory.inventory()?).map_err(StoreError::Invalid)?;
let item = corpus.resolve(&packet.provenance.corpus_item)?;
if item.withdrawn {
return Err(StoreRefusal::WithdrawnCorpusItem {
@ -967,6 +983,7 @@ impl PacketStore {
.into());
}
}
let view = LoweringInput::of(&packet, &item);
let key = packet.id.to_string();
if let Some(existing) = self.get(&key)? {
if existing == packet {
@ -978,3 +995,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<Self> {
Ok(Self {
cache: open_cache(path, |c| {
c.register_entry_type::<Speaker>()?;
c.register_entry_type::<Scene>()
})?,
})
}
/// 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<AddOutcome, StoreError> {
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<AddOutcome, StoreError> {
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<CorpusItem> = 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<Option<Speaker>> {
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<Option<Scene>> {
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<Option<Speaker>> {
self.cache.get::<Speaker>(key)
}
pub fn get_scene(&self, key: &str) -> anyhow::Result<Option<Scene>> {
self.cache.get::<Scene>(key)
}
/// Every speaker revision, by entity then revision number.
pub fn all_speakers(&self) -> anyhow::Result<Vec<Speaker>> {
let mut all = self.cache.get_all::<Speaker>()?;
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<Vec<Scene>> {
let mut all = self.cache.get_all::<Scene>()?;
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(())
}

View file

@ -1,12 +1,14 @@
//! The validator owns the inventory and form rules a packet must meet. Every
//! path that admits a packet applies them, but `validate` only judges: it
//! returns `Ok(())` or the refusals. [`LoweringInput`], the only thing a
//! lowerer sees, comes from `PacketStore::admit` alone, which builds it through
//! the crate-private `admissible`. The stance is checked like every feature
//! list, in slot `Stance`. The rules are V1..V8 and V10..V15: the shape of an id
//! or a grade is its type's parse, not a rule, so V9 is retired and not
//! renumbered. V13 guards the one other way features reach a lowering view:
//! [`LoweringInput::with_scene`].
//! lowerer sees, comes from `PacketStore::admit` alone, which builds it from
//! the admitted packet and its corpus item through the crate-private
//! `LoweringInput::of`. The stance is checked like every feature
//! list, in slot `Stance`. The rules are V1..V8, V10..V12 and V14..V17: the
//! shape of an id or a grade is its type's parse, not a rule, so V9 is retired
//! and not renumbered; V13 is retired too. [`LoweringInput::with_scene`] reads the
//! scene of the view's own item group from the speaker-state store, and
//! `feature_refusals` is the one feature check it and that store share.
//!
//! `validate` owns form and capacity and makes no leak decision. A packet's one
//! free text is its trace (`notes` and `rejected_readings`): `validate` does not
@ -26,11 +28,13 @@
use std::collections::BTreeSet;
use std::fmt;
use crate::corpus::CorpusItem;
use crate::inventory::{Family, InvId, Inventory, InventoryEntry, Slot, ValueKind};
use crate::packet::{
Constraints, Context, Discourse, Extension, FeatValue, FeatureValue, NodeId, Packet,
Constraints, Context, Discourse, EntityId, Extension, FeatValue, FeatureValue, NodeId, Packet,
PacketKind, Predication, ProvenanceSource, Referent, Relation, SCHEMA_VERSION, Stance,
};
use crate::store::{SpeakerStateStore, StoreError};
/// A broken rule, named.
#[derive(Clone, Debug, PartialEq, Eq)]
@ -95,10 +99,6 @@ pub enum Refusal {
count: usize,
max: usize,
},
/// V13: scene features were given for a packet with no addressee. They
/// state the speaker's relation to the addressee, so with none they
/// describe nobody.
V13SceneWithoutAddressee,
/// V14: an addressee consent that no addressee can have given, or that
/// names a node that is not a predication. It is what the addressee asked
/// for or agreed to, an act, so a referent cannot be its object.
@ -112,6 +112,13 @@ pub enum Refusal {
V15ConsentSource {
source: ProvenanceSource,
},
/// V16: `with_scene` found the item's speaker absent from its group's scene.
V16SpeakerNotInScene,
/// V17: an addressee that names a predication. An act is not someone
/// spoken to, so the addressee names a referent.
V17AddresseeNotAReferent {
id: NodeId,
},
}
impl Refusal {
@ -130,11 +137,12 @@ impl Refusal {
| Refusal::V11DuplicateNodeRef { .. }
| Refusal::V11DuplicateRole { .. } => "V11",
Refusal::V12TooManyNodes { .. } => "V12",
Refusal::V13SceneWithoutAddressee => "V13",
Refusal::V14ConsentWithoutAddressee | Refusal::V14ConsentNotAPredication { .. } => {
"V14"
}
Refusal::V15ConsentSource { .. } => "V15",
Refusal::V16SpeakerNotInScene => "V16",
Refusal::V17AddresseeNotAReferent { .. } => "V17",
}
}
}
@ -157,10 +165,11 @@ impl fmt::Display for Refusal {
Refusal::V11DuplicateNodeRef { .. } => "a node id repeats in one list",
Refusal::V11DuplicateRole { .. } => "a role id repeats in one predication",
Refusal::V12TooManyNodes { .. } => "too many nodes",
Refusal::V13SceneWithoutAddressee => "scene features given without an addressee",
Refusal::V14ConsentWithoutAddressee => "consent given without an addressee",
Refusal::V14ConsentNotAPredication { .. } => "consent names no predication",
Refusal::V15ConsentSource { .. } => "consent set by a source that only read wording",
Refusal::V16SpeakerNotInScene => "the speaker is not a participant of the scene",
Refusal::V17AddresseeNotAReferent { .. } => "the addressee names no referent",
};
write!(f, "{}: {what}", self.rule())
}
@ -180,28 +189,6 @@ impl fmt::Display for Refusal {
/// fn lower(p: &Packet, i: &Inventory) -> Result<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();
if packet.schema_version != SCHEMA_VERSION {
out.push(Refusal::V1SchemaVersion {
@ -221,6 +208,7 @@ pub(crate) fn admissible(
}
let nodes = check_nodes(packet, &mut out);
check_refs(packet, &nodes, &mut out);
check_addressee(packet, &nodes, &mut out);
check_repeats(packet, &mut out);
check_consent(packet, &nodes, &mut out);
if inventory.has_revision(packet.inventory_rev) {
@ -235,11 +223,7 @@ pub(crate) fn admissible(
rev: packet.inventory_rev,
});
}
if out.is_empty() {
Ok(LoweringInput::of(packet))
} else {
Err(out)
}
if out.is_empty() { Ok(()) } else { Err(out) }
}
fn check_nodes(packet: &Packet, out: &mut Vec<Refusal>) -> BTreeSet<NodeId> {
@ -285,6 +269,17 @@ fn check_refs(packet: &Packet, nodes: &BTreeSet<NodeId>, out: &mut Vec<Refusal>)
}
}
/// V17: the addressee, when it names a node, names a referent. A dangling id
/// is V4's.
fn check_addressee(packet: &Packet, nodes: &BTreeSet<NodeId>, out: &mut Vec<Refusal>) {
if let Some(a) = &packet.discourse.addressee
&& nodes.contains(a)
&& !packet.referents.iter().any(|r| &r.id == a)
{
out.push(Refusal::V17AddresseeNotAReferent { id: a.clone() });
}
}
/// V14 and V15: the addressee's consent is a typed fact with one origin. A
/// dangling id is V4's, so V14 judges only a node that exists.
fn check_consent(packet: &Packet, nodes: &BTreeSet<NodeId>, out: &mut Vec<Refusal>) {
@ -472,6 +467,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<Refusal> {
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,
@ -481,6 +499,28 @@ fn fits(kind: Option<&ValueKind>, value: &FeatValue) -> bool {
}
}
/// What `with_scene` found for a view: the scene revision `rev` it read, or why
/// it selected no relation. Each case is distinct so a missing annotation or a
/// store at the wrong path cannot look like a scene that held nothing.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SceneRead {
/// `with_scene` has not run.
Unread,
/// The store holds no scene for the item's group.
NoScene,
/// The scene was read and the packet names no addressee.
NoAddressee { rev: u32 },
/// The addressee referent is the speaker themself.
SelfAddressed { rev: u32 },
/// The scene holds no relation from the speaker to this addressee.
NoRelation { rev: u32 },
/// The relation was found; its features may be none.
Selected {
rev: u32,
features: Vec<FeatureValue>,
},
}
/// What the lowerer sees: a validated packet's meaning, with no packet id, no
/// provenance (so no corpus item key, encoder or source) and no trace. Its
/// fields are private and the constructor is crate-private, called only by
@ -537,16 +577,45 @@ fn fits(kind: Option<&ValueKind>, value: &FeatValue) -> bool {
/// fn read(l: weksa::LoweringInput) { let _ = l.scene; }
/// ```
///
/// Scene values come only from the store: the call names neither speaker nor
/// scene. The first block is the call with the store; the second hands it a
/// scene value and a speaker and fails for that alone.
///
/// ```
/// fn pick(l: weksa::LoweringInput, i: &weksa::Inventory, s: &weksa::SpeakerStateStore) {
/// let _ = l.with_scene(i, s);
/// }
/// ```
///
/// ```compile_fail
/// fn pick(
/// l: weksa::LoweringInput,
/// i: &weksa::Inventory,
/// 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.
///
/// ```
/// fn build(p: &weksa::packet::Packet) { let _ = p; }
/// fn build(p: &weksa::packet::Packet, item: &weksa::CorpusItem) { let _ = (p, item); }
/// ```
///
/// ```compile_fail
/// fn build(p: &weksa::packet::Packet) { let _ = weksa::LoweringInput::of(p); }
/// fn build(p: &weksa::packet::Packet, item: &weksa::CorpusItem) {
/// let _ = weksa::LoweringInput::of(p, item);
/// }
/// ```
///
/// A lowering view is never read from bytes: `Deserialize` would let a caller
@ -574,11 +643,16 @@ pub struct LoweringInput {
constraints: Constraints,
extensions: Vec<Extension>,
stance: Stance,
scene: Vec<FeatureValue>,
/// The item's speaker and group, as the corpus store holds them: the
/// only source of whose relation `with_scene` selects, and from which scene.
speaker: EntityId,
group: String,
scene: SceneRead,
}
impl LoweringInput {
fn of(p: &Packet) -> Self {
/// The view of an admitted packet and the corpus item it was minted for.
pub(crate) fn of(p: &Packet, item: &CorpusItem) -> Self {
Self {
inventory_rev: p.inventory_rev,
kind: p.kind,
@ -590,41 +664,63 @@ impl LoweringInput {
constraints: p.constraints.clone(),
extensions: p.extensions.clone(),
stance: p.stance.clone(),
scene: Vec::new(),
speaker: item.speaker.clone(),
group: item.group.clone(),
scene: SceneRead::Unread,
}
}
/// Adds the speaker's relation to the addressee: feature values of slot
/// `Scene`, checked like every feature list (V5 to V8, V11) at this view's
/// inventory revision. They state a relation, so the packet must name an
/// addressee (V13). Typed ids and grades only, never wording, so a scene
/// carries no text to the lowerer. The scene is not part of the packet: a
/// scene document supplies it, and replacing it replaces the earlier one.
/// Reads the speaker's relation to the addressee, the only path for scene
/// values into a view. The speaker is the item's speaker and the scene is
/// the latest the store holds for the item's group; the caller names
/// neither. A scene that does not list the item's speaker is V16, with or
/// without an addressee. What the read found is recorded on the view as a
/// [`SceneRead`], never an empty feature list standing for five cases:
/// no scene for the group, no addressee, an addressee who is the speaker,
/// no such relation, or the relation from the speaker to the addressee
/// referent's entity (to `Unnamed` when the referent has none), whose
/// features are checked like every feature list in slot `Scene` at this
/// view's inventory revision. A read replaces any earlier one. The English
/// lowerer ignores it.
pub fn with_scene(
mut self,
inventory: &Inventory,
scene: Vec<FeatureValue>,
) -> Result<Self, Vec<Refusal>> {
let mut out = Vec::new();
if !inventory.has_revision(self.inventory_rev) {
out.push(Refusal::V10UnknownRevision {
rev: self.inventory_rev,
});
} else {
Inventoried {
inv: inventory,
rev: self.inventory_rev,
out: &mut out,
}
.features(&scene, Slot::Scene);
state: &SpeakerStateStore,
) -> Result<Self, StoreError> {
self.scene = SceneRead::Unread;
let Some(scene) = state.scene(&self.group)? else {
self.scene = SceneRead::NoScene;
return Ok(self);
};
let rev = scene.n;
if !scene.participants.contains(&self.speaker) {
return Err(StoreError::Invalid(vec![Refusal::V16SpeakerNotInScene]));
}
if !scene.is_empty() && self.discourse.addressee.is_none() {
out.push(Refusal::V13SceneWithoutAddressee);
let Some(addressee) = self.discourse.addressee.as_ref() else {
self.scene = SceneRead::NoAddressee { rev };
return Ok(self);
};
let entity = self
.referents
.iter()
.find(|r| &r.id == addressee)
.and_then(|r| r.entity.as_ref());
if entity == Some(&self.speaker) {
self.scene = SceneRead::SelfAddressed { rev };
return Ok(self);
}
let Some(features) = scene.relation(&self.speaker, entity) else {
self.scene = SceneRead::NoRelation { rev };
return Ok(self);
};
let out = feature_refusals(inventory, self.inventory_rev, features, Slot::Scene);
if !out.is_empty() {
return Err(out);
return Err(StoreError::Invalid(out));
}
self.scene = scene;
self.scene = SceneRead::Selected {
rev,
features: features.to_vec(),
};
Ok(self)
}
@ -658,8 +754,17 @@ 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 for every [`SceneRead`] but `Selected` (and for a selected
/// relation that has no features): ask [`scene_read`](Self::scene_read) which.
pub fn scene(&self) -> &[FeatureValue] {
match &self.scene {
SceneRead::Selected { features, .. } => features,
_ => &[],
}
}
/// What `with_scene` read; `Unread` until it ran.
pub fn scene_read(&self) -> &SceneRead {
&self.scene
}
}

View file

@ -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"
]
);
}

View file

@ -34,7 +34,8 @@ pub const CONCEPT_LATE: &str = "concept:4";
pub const FRAME: &str = "frame:1";
/// An extension entry of value kind Grade, introduced at rev 2.
pub const EXT: &str = "extension:1";
/// A Grade feature introduced at rev 2 that sits in every slot.
/// A Grade feature introduced at rev 2 in every content slot (Discourse,
/// Referent, Predication, Relation).
pub const FEAT_ALL: &str = "feature:50";
pub const PACKET_ID: &str = "packet:0123456789abcdef";
@ -218,7 +219,6 @@ pub fn world() -> World {
Slot::Referent,
Slot::Predication,
Slot::Relation,
Slot::Stance,
],
)
},

View file

@ -117,12 +117,13 @@ fn refusal_display_names_the_rule_and_no_identifier() {
count: 4242,
max: 4242,
},
Refusal::V13SceneWithoutAddressee,
Refusal::V14ConsentWithoutAddressee,
Refusal::V14ConsentNotAPredication { id: node() },
Refusal::V15ConsentSource {
source: ProvenanceSource::ImportedCorpus,
},
Refusal::V16SpeakerNotInScene,
Refusal::V17AddresseeNotAReferent { id: node() },
];
for r in &all {
assert!(r.to_string().starts_with(r.rule()), "{r:?}");

View file

@ -1,156 +1,353 @@
//! The speaker's relation to the addressee as a lowering input: typed feature
//! values of slot `Scene`, checked against the inventory at the view's revision,
//! never wording, and only for a packet that names an addressee.
//! The speaker's relation to the addressee as a lowering input. `with_scene`
//! names neither speaker nor scene: the view knows its corpus item, so the
//! speaker is the item's speaker and the scene is the store's latest for the
//! item's group. The relation is the one from that speaker to the addressee
//! referent's entity. Typed feature values only, never wording.
mod common;
use std::path::Path;
use common::{grade, iv, lab};
use weksa::inventory::{proof_revision, scene_revision, seed};
use common::{cast, eid, entities_in, grade, item_in, lab, nd};
use weksa::inventory::{proof_revision, seed, speaker_scene_revision};
use weksa::packet::{FeatValue, FeatureValue, Packet, proof_packet};
use weksa::speaker::{Annotator, PairRelation, Party, Scene};
use weksa::{
CorpusStore, EntityStore, Inventory, InventoryStore, LoweringInput, PacketStore, Refusal, lower,
CorpusStore, EntityStore, Inventory, InventoryStore, LoweringInput, NameSpan, PacketStore,
Refusal, SceneRead, SpeakerStateStore, StoreError, lower,
};
fn store() -> (InventoryStore, Inventory, tempfile::TempDir) {
struct W {
dir: tempfile::TempDir,
inventory: InventoryStore,
corpus: CorpusStore,
entities: EntityStore,
state: SpeakerStateStore,
}
/// Group `g` holds `item:nibu:1`: Nibu says "Brokkr, come here." Group `h`
/// holds `item:nibu:2`, also spoken by Nibu.
fn world() -> W {
let dir = tempfile::tempdir().unwrap();
let mut store = InventoryStore::open(&dir.path().join("i.cc")).unwrap();
for (rev, entries) in [seed(), proof_revision(), scene_revision()] {
store.admit_revision(rev, entries).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 inv = store.inventory().unwrap();
(store, inv, dir)
}
fn admitted(packet: &Packet, inventory: &InventoryStore) -> LoweringInput {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
let corpus = CorpusStore::open(&root.join("data/corpus/corpus.cc")).unwrap();
let entities = EntityStore::open(&root.join("data/entity/entity.cc")).unwrap();
let dir = tempfile::tempdir().unwrap();
PacketStore::open(&dir.path().join("p.cc"))
.unwrap()
.admit(packet.clone(), inventory, &corpus, &entities)
.unwrap()
}
fn at_rev(rev: u32) -> Packet {
let mut p = proof_packet();
p.inventory_rev = rev;
p
}
fn feat(id: &str, value: FeatValue) -> FeatureValue {
FeatureValue {
feature: iv(id),
value,
let entities = entities_in(dir.path());
let mut corpus = CorpusStore::open(&dir.path().join("c.cc")).unwrap();
let mut first = item_in("item:nibu:1", "Brokkr, come here.", "g", cast());
first.names = vec![NameSpan {
entity: eid("brokkr"),
start: 0,
end: 6,
}];
corpus.add(first, &entities).unwrap();
corpus
.add(item_in("item:nibu:2", "Go away.", "h", cast()), &entities)
.unwrap();
let state = SpeakerStateStore::open(&dir.path().join("s.cc")).unwrap();
W {
dir,
inventory,
corpus,
entities,
state,
}
}
fn status(inv: &Inventory) -> String {
inv.entries()
.find(|e| e.gloss == "relative_status")
.unwrap()
.id
.to_string()
fn inv(w: &W) -> Inventory {
w.inventory.inventory().unwrap()
}
fn id_of(inv: &Inventory, gloss: &str) -> String {
inv.entries()
fn feat(gloss: &str, value: FeatValue) -> FeatureValue {
let feature = seed()
.1
.into_iter()
.chain(speaker_scene_revision().1)
.find(|e| e.gloss == gloss)
.unwrap()
.id
.to_string()
.unwrap_or_else(|| panic!("no feature {gloss}"))
.id;
FeatureValue { feature, value }
}
fn rules(r: Result<LoweringInput, Vec<Refusal>>) -> Vec<&'static str> {
r.unwrap_err().iter().map(Refusal::rule).collect()
fn intimacy(v: u16) -> Vec<FeatureValue> {
vec![feat("intimacy", FeatValue::Grade(grade(v)))]
}
fn status() -> Vec<FeatureValue> {
vec![
feat("relative_status", FeatValue::Option(lab("above"))),
feat("intimacy", FeatValue::Grade(grade(200))),
feat("in_group", FeatValue::Flag(false)),
]
}
fn relation(from: &str, to: Party, features: Vec<FeatureValue>) -> PairRelation {
PairRelation {
from: eid(from),
to,
features,
}
}
/// Nibu's relations to Brokkr and to an unnamed party, and Brokkr's to Nibu.
fn scene(group: &str, n: u32, participants: &[&str], relations: Vec<PairRelation>) -> Scene {
Scene {
group: group.into(),
n,
inventory_rev: 3,
participants: participants.iter().map(|p| eid(p)).collect(),
relations,
annotator: Annotator::Operator,
}
}
fn standard(group: &str, n: u32) -> Scene {
scene(
group,
n,
&["nibu", "brokkr"],
vec![
relation("nibu", Party::Named(eid("brokkr")), status()),
relation("nibu", Party::Unnamed, intimacy(7)),
relation("brokkr", Party::Named(eid("nibu")), intimacy(99)),
],
)
}
fn put(w: &mut W, s: Scene) {
w.state
.put_scene(s, &w.inventory, &w.corpus, &w.entities)
.unwrap();
}
/// The line of `item:nibu:1` at `rev`, its addressee node `addressee` (`n1`
/// has no entity, `n3` is Brokkr), through the packet door.
fn view_of(w: &W, rev: u32, addressee: Option<&str>) -> LoweringInput {
view_to(w, rev, addressee, "brokkr")
}
/// As `view_of`, with referent `n3` naming `entity` (Nibu is the item's
/// speaker, so he may be named on the line; Brokkr is named by its text).
fn view_to(w: &W, rev: u32, addressee: Option<&str>, entity: &str) -> LoweringInput {
let mut p: Packet = proof_packet();
p.inventory_rev = rev;
p.referents[2].entity = Some(eid(entity));
p.discourse.addressee = addressee.map(nd);
// A packet key holds its packet for good, so each view gets a fresh store.
let scratch = tempfile::tempdir().unwrap();
PacketStore::open(&scratch.path().join("p.cc"))
.unwrap()
.admit(p, &w.inventory, &w.corpus, &w.entities)
.unwrap()
}
fn rules(r: Result<LoweringInput, StoreError>) -> Vec<&'static str> {
match r.map(|_| ()).unwrap_err() {
StoreError::Invalid(rs) => rs.iter().map(Refusal::rule).collect(),
other => panic!("not a rule refusal: {other}"),
}
}
fn read(w: &W, view: LoweringInput) -> SceneRead {
view.with_scene(&inv(w), &w.state)
.unwrap()
.scene_read()
.clone()
}
fn selected(rev: u32, features: Vec<FeatureValue>) -> SceneRead {
SceneRead::Selected { rev, features }
}
#[test]
fn a_scene_relation_reaches_the_lowering_view_and_changes_no_english() {
let (store, inv, _dir) = store();
let plain = admitted(&at_rev(3), &store);
let mut w = world();
put(&mut w, standard("g", 1));
let plain = view_of(&w, 3, Some("n3"));
assert_eq!(plain.scene_read(), &SceneRead::Unread);
assert!(plain.scene().is_empty());
let scene = vec![
feat(&status(&inv), FeatValue::Option(lab("above"))),
feat(&id_of(&inv, "intimacy"), FeatValue::Grade(grade(200))),
feat(&id_of(&inv, "in_group"), FeatValue::Flag(false)),
];
let with = plain.clone().with_scene(&inv, scene.clone()).unwrap();
assert_eq!(with.scene(), scene.as_slice());
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
let entities = EntityStore::open(&root.join("data/entity/entity.cc")).unwrap();
let with = plain.clone().with_scene(&inv(&w), &w.state).unwrap();
assert_eq!(with.scene(), status().as_slice());
assert_eq!(with.scene_read(), &selected(1, status()));
assert_eq!(
lower(&with, &inv, &entities).unwrap(),
lower(&plain, &inv, &entities).unwrap()
lower(&with, &inv(&w), &w.entities).unwrap(),
lower(&plain, &inv(&w), &w.entities).unwrap()
);
}
#[test]
fn a_relation_needs_an_addressee() {
let (store, inv, _dir) = store();
let mut p = at_rev(3);
p.discourse.addressee = None;
let view = admitted(&p, &store);
let scene = vec![feat(&id_of(&inv, "in_group"), FeatValue::Flag(true))];
assert_eq!(rules(view.clone().with_scene(&inv, scene)), ["V13"]);
assert!(view.with_scene(&inv, vec![]).unwrap().scene().is_empty());
fn with_scene_says_which_of_five_things_it_found() {
let mut w = world();
// No scene for the group.
assert_eq!(read(&w, view_of(&w, 3, Some("n3"))), SceneRead::NoScene);
put(&mut w, standard("g", 1));
// A referent with an entity selects the relation to that entity; a
// referent without one, the relation to an unnamed party; and the
// speaker's relation, never Brokkr's to Nibu.
assert_eq!(read(&w, view_of(&w, 3, Some("n3"))), selected(1, status()));
assert_eq!(
read(&w, view_of(&w, 3, Some("n1"))),
selected(1, intimacy(7))
);
// The scene was read and the packet names no addressee.
assert_eq!(
read(&w, view_of(&w, 3, None)),
SceneRead::NoAddressee { rev: 1 }
);
// The addressee is the speaker.
assert_eq!(
read(&w, view_to(&w, 3, Some("n3"), "nibu")),
SceneRead::SelfAddressed { rev: 1 }
);
// The scene holds no relation to this addressee.
put(
&mut w,
scene(
"g",
2,
&["nibu", "brokkr"],
vec![relation("nibu", Party::Unnamed, intimacy(7))],
),
);
assert_eq!(
read(&w, view_of(&w, 3, Some("n3"))),
SceneRead::NoRelation { rev: 2 }
);
// A relation with no features is found, not absent.
put(
&mut w,
scene(
"g",
3,
&["nibu", "brokkr"],
vec![relation("nibu", Party::Named(eid("brokkr")), vec![])],
),
);
assert_eq!(read(&w, view_of(&w, 3, Some("n3"))), selected(3, vec![]));
}
#[test]
fn a_relation_value_is_checked_like_any_feature() {
let (store, inv, _dir) = store();
let view = admitted(&at_rev(3), &store);
let status = status(&inv);
let intimacy = id_of(&inv, "intimacy");
let take = |scene| view.clone().with_scene(&inv, scene);
// A label the feature does not declare.
assert_eq!(
rules(take(vec![feat(&status, FeatValue::Option(lab("lordly")))])),
["V7"]
fn a_named_addressee_the_scene_lacks_never_falls_back_to_unnamed() {
let mut w = world();
// Brokkr is not a participant and Nibu has only an unnamed relation.
put(
&mut w,
scene(
"g",
1,
&["nibu", "player"],
vec![relation("nibu", Party::Unnamed, intimacy(7))],
),
);
// A value of the wrong kind.
assert_eq!(
rules(take(vec![feat(&intimacy, FeatValue::Flag(true))])),
["V7"]
read(&w, view_of(&w, 3, Some("n3"))),
SceneRead::NoRelation { rev: 1 }
);
// A participant without a relation behaves the same.
put(
&mut w,
scene(
"g",
2,
&["nibu", "brokkr"],
vec![relation("nibu", Party::Unnamed, intimacy(7))],
),
);
// A feature declared for another slot: seed feature 1 sits in Predication.
assert_eq!(
rules(take(vec![feat(
"feature:1",
FeatValue::Option(lab("negative"))
)])),
["V8"]
read(&w, view_of(&w, 3, Some("n3"))),
SceneRead::NoRelation { rev: 2 }
);
// An id that is not a feature at all.
assert_eq!(
rules(take(vec![feat("concept:3", FeatValue::Flag(true))])),
["V6"]
);
// Twice in one list.
let twice = vec![
feat(&intimacy, FeatValue::Grade(grade(1))),
feat(&intimacy, FeatValue::Grade(grade(2))),
];
assert_eq!(rules(take(twice)), ["V11"]);
}
#[test]
fn a_view_of_a_revision_before_the_scene_vocabulary_cannot_carry_one() {
let (store, inv, _dir) = store();
// Revision 2 predates the Scene features, so their ids do not resolve there.
let view = admitted(&at_rev(2), &store);
let scene = vec![feat(&id_of(&inv, "intimacy"), FeatValue::Grade(grade(1)))];
assert_eq!(rules(view.with_scene(&inv, scene)), ["V5"]);
fn the_speaker_is_the_items_not_the_first_participant() {
let mut w = world();
// Brokkr is listed first and has an unnamed relation of his own.
put(
&mut w,
scene(
"g",
1,
&["brokkr", "nibu"],
vec![
relation("brokkr", Party::Unnamed, intimacy(99)),
relation("nibu", Party::Unnamed, intimacy(7)),
],
),
);
assert_eq!(
read(&w, view_of(&w, 3, Some("n1"))),
selected(1, intimacy(7))
);
}
#[test]
fn a_stance_feature_is_not_a_scene_feature() {
let (store, inv, _dir) = store();
let view = admitted(&at_rev(3), &store);
let anger = id_of(&inv, "anger");
assert_eq!(
rules(view.with_scene(&inv, vec![feat(&anger, FeatValue::Grade(grade(1)))])),
["V8"]
fn the_scene_is_the_items_group_and_its_latest_revision() {
let mut w = world();
// Group h holds a different relation for the same pair.
put(
&mut w,
scene(
"h",
1,
&["nibu", "brokkr"],
vec![relation("nibu", Party::Named(eid("brokkr")), intimacy(1))],
),
);
// No scene for the item's group, whatever other groups hold.
assert_eq!(read(&w, view_of(&w, 3, Some("n3"))), SceneRead::NoScene);
// Revisions: the highest n is read.
put(&mut w, standard("g", 1));
let mut second = standard("g", 2);
second.relations[0].features = intimacy(5);
put(&mut w, second);
let got = view_of(&w, 3, Some("n3"))
.with_scene(&inv(&w), &w.state)
.unwrap();
assert_eq!(got.scene_read(), &selected(2, intimacy(5)));
assert_eq!(got.scene(), intimacy(5).as_slice());
// A read replaces the earlier one: a store without the scene says so.
let empty = SpeakerStateStore::open(&w.dir.path().join("empty.cc")).unwrap();
let cleared = got.with_scene(&inv(&w), &empty).unwrap();
assert_eq!(cleared.scene_read(), &SceneRead::NoScene);
assert!(cleared.scene().is_empty());
}
#[test]
fn with_scene_refusals() {
let mut w = world();
put(&mut w, standard("g", 1));
// The scene was admitted at revision 3; a view of revision 2 predates the
// Scene features, so the selected relation's ids do not resolve there.
assert_eq!(
rules(view_of(&w, 2, Some("n3")).with_scene(&inv(&w), &w.state)),
["V5", "V5", "V5"]
);
// A scene that does not list the item's speaker: group k admitted while
// Brokkr was its only speaker, then an item of Nibu's joins it.
let mut item = item_in("item:brokkr:1", "Hello.", "k", cast());
item.speaker = eid("brokkr");
w.corpus.add(item, &w.entities).unwrap();
put(&mut w, scene("k", 1, &["brokkr"], vec![]));
w.corpus
.add(
item_in("item:nibu:3", "Hello there.", "k", cast()),
&w.entities,
)
.unwrap();
for addressee in [Some("n3"), None] {
let mut p = proof_packet();
p.inventory_rev = 3;
p.provenance.corpus_item = weksa::CorpusItemId::parse("item:nibu:3").unwrap();
p.referents[2].entity = Some(eid("nibu"));
p.discourse.addressee = addressee.map(nd);
let scratch = tempfile::tempdir().unwrap();
let view = PacketStore::open(&scratch.path().join("p.cc"))
.unwrap()
.admit(p, &w.inventory, &w.corpus, &w.entities)
.unwrap();
assert_eq!(
rules(view.with_scene(&inv(&w), &w.state)),
["V16"],
"{addressee:?}"
);
}
}

View file

@ -0,0 +1,503 @@
//! 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); group `g2` holds one live item of nibu's.
fn world() -> W {
let dir = tempfile::tempdir().unwrap();
let mut inventory = InventoryStore::open(&dir.path().join("i.cc")).unwrap();
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"),
("item:nibu:4", "Elsewhere.", "g2"),
] {
let mut item = item_in(key, text, "g", cast());
item.speaker = eid(speaker);
if key == "item:nibu:4" {
item.group = "g2".into();
item.speaker = eid("nibu");
}
corpus.add(item, &entities).unwrap();
}
corpus.withdraw("item:nibu:3").unwrap();
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<AddOutcome, StoreError> {
w.state.put_speaker(s, &w.inventory, &w.entities)
}
fn put_scene(w: &mut W, s: Scene) -> Result<AddOutcome, StoreError> {
w.state.put_scene(s, &w.inventory, &w.corpus, &w.entities)
}
fn refused<T: std::fmt::Debug>(r: Result<T, StoreError>) -> StoreRefusal {
match r.unwrap_err() {
StoreError::Refused(r) => r,
other => panic!("not a store refusal: {other}"),
}
}
fn malformed(r: Result<AddOutcome, StoreError>, 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<AddOutcome, StoreError>) -> 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<u32> = w.state.all_scenes().unwrap().iter().map(|s| s.n).collect();
assert_eq!(listed, (1..=10).collect::<Vec<_>>());
// Numbering is per group: the first scene of another group is revision 1
// whatever g holds, and its second is 2.
let other = |n| Scene {
group: "g2".into(),
participants: vec![eid("nibu")],
relations: vec![],
..scene(n, 1)
};
assert_eq!(
refused(put_scene(&mut w, other(2))),
not_next(&scene_key("g2", 2), 1, 2)
);
assert_eq!(put_scene(&mut w, other(1)).unwrap(), AddOutcome::Added);
assert_eq!(
refused(put_scene(&mut w, other(3))),
not_next(&scene_key("g2", 3), 2, 3)
);
assert_eq!(put_scene(&mut w, other(2)).unwrap(), AddOutcome::Added);
assert_eq!(w.state.scene("g2").unwrap().unwrap().n, 2);
assert_eq!(w.state.scene("g").unwrap().unwrap().n, 10);
}
#[test]
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<FeatureValue>, 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");
}

View file

@ -1093,6 +1093,51 @@ fn revision_change_rules() {
assert!(inv.entry(&iv("role:1")).unwrap().retired_rev.is_none());
}
#[test]
fn speaker_state_slots_are_exclusive() {
let shape = "entry shape does not match its family";
let d = dir();
let mut store = seeded(&d.path().join("i.cc"));
for slots in [
&[Slot::Stance, Slot::Discourse][..],
&[Slot::Scene, Slot::Relation],
&[Slot::Discourse, Slot::Scene],
&[Slot::Scene, Slot::Scene],
&[Slot::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));
let entries = vec![InventoryEntry {
introduced_rev: 2,
..feature("feature:50", ValueKind::Flag, slots)
}];
assert_eq!(
refusal(store.admit_revision(rev, entries)),
malformed("feature:50", shape),
"{slots:?}"
);
}
assert_eq!(store.inventory().unwrap().latest().unwrap().n, 1);
// One slot alone, and several content slots, are admitted.
for (n, slots) in [
(2, &[Slot::Scene][..]),
(3, &[Slot::Stance]),
(4, &[Slot::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));
let entries = vec![InventoryEntry {
introduced_rev: n,
..feature(&id, ValueKind::Flag, slots)
}];
store.admit_revision(rev, entries).unwrap();
}
}
#[test]
fn a_well_formed_extension_entry_is_admitted() {
let d = dir();

View file

@ -520,9 +520,6 @@ fn v11_duplicate_feature_each_slot() {
refusals(&relation),
vec![v11(Some(Slot::Relation), FEAT_ALL)]
);
let mut stance = good();
stance.stance.features = vec![grade_feature(1), grade_feature(2)];
assert_eq!(refusals(&stance), vec![v11(Some(Slot::Stance), FEAT_ALL)]);
let mut felt = good();
felt.stance
.features
@ -742,3 +739,26 @@ fn a_source_that_only_read_wording_cannot_set_consent() {
assert_eq!(refusals(&p), vec![Refusal::V15ConsentSource { source }]);
}
}
// V17: the addressee is someone spoken to, a referent.
#[test]
fn the_addressee_names_a_referent_not_a_predication() {
let mut p = good();
p.discourse.addressee = Some(nd("n2"));
assert_eq!(
refusals(&p),
vec![Refusal::V17AddresseeNotAReferent { id: nd("n2") }]
);
// A referent passes, and a dangling id is V4's alone.
p.discourse.addressee = Some(nd("n1"));
assert_eq!(refusals(&p), vec![]);
p.discourse.addressee = Some(nd("n99"));
assert_eq!(
refusals(&p),
vec![Refusal::V4DanglingRef {
id: nd("n99"),
at: "addressee"
}]
);
}

View file

@ -163,6 +163,7 @@ names the rule.
- V15: an `addressee_consent` is set only by provenance `Authored` or
`GameTelemetry`; a decomposition of text or imported corpus text cannot mint
it.
- V17: an `addressee` names a referent, not a predication.
V9 is retired and not renumbered: the form of a key or a grade is its type, and
an extension key's family is V6. `PacketStore::admit` returns the lowering view of a
@ -190,7 +191,7 @@ ids.
### `discourse`
Why the packet is said: `speech_act` is a speech-act id, `addressee` a node id,
Why the packet is said: `speech_act` is a speech-act id, `addressee` a referent's node id (V17),
and `features` carry source-side nuance on content (hedge, certainty, irony and
whatever the inventory grows). This is source-side communicative pressure, not
target-language register. `addressee_consent` is no feature: it names the
@ -213,6 +214,27 @@ 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:<entity slug>@<n>`)
holds one entity's stable axes as feature values of slot `Speaker`.
`weksa.scene` (key `scene:<group>@<n>`) 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 the
store and names neither speaker nor scene: the view knows its corpus item, so
the speaker is the item's speaker and the scene is the store's latest for the
item's group. The speaker must be a participant (V16), and the relation
selected is the speaker's to the entity of the addressee referent (to
`Unnamed` when that referent has no entity, to nothing when the packet has no
addressee or the scene holds no such relation). V13 is retired.
### `referents`
Entities, groups, places, abstractions and the rest. `kind` is a referent-kind

View file

@ -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`,

View file

@ -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:<entity-slug>@<n>`) and `weksa.scene` (`scene:<group>@<n>`, 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 the `SpeakerStateStore`, reads the scene of the view's own item group for the item's speaker, and selects the relation by the addressee referent's entity (V16 when the item's speaker is not a participant; V17 refuses an addressee that names a predication; V13 is retired). The English lowerer does not render it. (PR for follow-up `chat-s3352-3352`)
4b. `speaker-scene` (spec r1; `speaker-scene-seeds` is its cut 4c: CLI, inventory revision 3, cast speakers and scenes; `Party::Unnamed` covers an addressee with no entity; Speaker and Scene share one `SpeakerStateStore`): slots `Speaker` and `Scene` with their seed features; `weksa.speaker` (`speaker:<entity-slug>@<n>`) and `weksa.scene` (`scene:<group>@<n>`, participants and ordered-pair relations) documents with `Annotator` `Operator` and `Model { family, generator }`, validated by the packet's shared feature check, append-only per key; their stores, `weksa speaker` and `weksa scene` commands, published schemas; Model-annotated cast speakers and scenes for the committed items. Waits on 2d and 4; `pd-speaker-annotation` blocks only the public-domain annotator. Part 1 landed (branch `weksa-lowering/speaker-scene`): `speaker.rs`, `SpeakerStateStore`, slots `Speaker` and `Scene` exclusive to their own feature lists, `speaker_scene_revision()` (Speaker features 22-24), `with_scene` on the store, schemas. The CLI, the committed inventory revision 3 and the seeds stay with 4c.
5. `language-ontology`: `weksa.community` from `data/cultural-ontology/` (owns follow-up `edda-referent-yaml-parse`), `weksa.ontology_entry` for en-US and ja-JP from `data/target-language-ontology/` (YAML deleted), speaker communities, deterministic `select`, the lowering view (packet without trace, speaker, scene, selected entries). Waits on 4.
6. `pd-importer`: public-domain play and fiction dialogue into the corpus with speaker, scene group, edition provenance, and Speaker and Scene annotations by the ruled annotator. Waits on 4, follow-up `gutenberg-terms` and question `pd-speaker-annotation`.
7a. `loop-context-builder`: `context.rs` (`TrainItems`, `EncoderOffer`, `Prompt`: corpus text reaches a model call only through `Prompt::mint` over live Train items or `Prompt::encode` over one offered item; instructions are the consts `MINT_INSTRUCTIONS` and `ENCODE_INSTRUCTIONS` in `context.rs`; `Checkpoint` has no public constructor, offers each item once, and its offers borrow it (the gate is typed `TestReadings` and Test offers on the round path; operator tools such as `weksa corpus dump` and `weksa score` still read corpus text of any split); prior turns print the held Train text), `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)

View file

@ -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"
}
]
},

View file

@ -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:<group>@<n>`; 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"
]
}
]
}
}
}

View file

@ -0,0 +1,171 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Speaker",
"description": "The speaker's stable axes. Key `speaker:<entity slug>@<n>`; 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}$"
}
}
}