Speaker and Scene documents, SpeakerStateStore, with_scene on a Scene document

Slot Speaker, weksa.speaker and weksa.scene append-only revisions in one
store, V16, feature_refusals as the one feature check, speaker_scene_revision
(Speaker features 22-24), schemas, docs and map lines. Part 1 of map cut 4b.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Weksa 2026-11-10 02:34:10 +00:00
commit 6ef1b7e034
16 changed files with 1638 additions and 38 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)]
@ -229,7 +231,7 @@ impl InventoryEntry {
/// Whether the entry's shape matches its family: features carry a value kind
/// and slots, extensions a value kind and no slots, nothing else either. A
/// feature of slot `Stance` or `Scene` sits in that slot alone: each
/// feature of slot `Stance`, `Speaker` or `Scene` sits in that slot alone: each
/// speaker-state layer has one writer and one lifetime, so its features sit
/// nowhere else.
pub fn well_formed(&self) -> bool {
@ -241,7 +243,7 @@ impl InventoryEntry {
|| !self
.applies_to
.iter()
.any(|s| matches!(s, Slot::Stance | Slot::Scene)))
.any(|s| matches!(s, Slot::Stance | Slot::Speaker | Slot::Scene)))
}
Family::Extension => self.value_kind.is_some() && self.applies_to.is_empty(),
_ => self.value_kind.is_none() && self.applies_to.is_empty(),
@ -534,28 +536,49 @@ pub fn proof_revision() -> (InventoryRevision, Vec<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()
@ -564,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};

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>(),
]
}

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

@ -0,0 +1,115 @@
//! The speaker-state documents: a speaker's stable axes and a scene's relations.
//! Both are append-only revisions under one key per revision and hold typed
//! feature values only, never text (ruling `leak-owned-by-flow-and-scorer-3`).
//! `SpeakerStateStore` is their only writer; `LoweringInput::with_scene` is the
//! only reader that reaches a lowering view.
use cultcache_rs::DatabaseEntry;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::corpus::ModelFamily;
use crate::packet::{EntityId, FeatureValue};
/// The most participants one scene holds.
pub const MAX_PARTICIPANTS: usize = 64;
/// Who annotated a document. A model's generator is an attestation string, held
/// to the same form as every provenance string.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub enum Annotator {
Operator,
Model {
family: ModelFamily,
generator: String,
},
}
/// The speaker's stable axes. Key `speaker:<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; empty when the scene holds no such
/// relation. A named addressee the scene lacks never falls back to `Unnamed`.
pub fn relation(&self, speaker: &EntityId, addressee: Option<&EntityId>) -> &[FeatureValue] {
self.relations
.iter()
.find(|r| {
&r.from == speaker
&& match (&r.to, addressee) {
(Party::Named(a), Some(b)) => a == b,
(Party::Unnamed, None) => true,
_ => false,
}
})
.map_or(&[], |r| r.features.as_slice())
}
}
/// `speaker:<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, admissible, feature_refusals};
/// A broken store rule, named.
#[derive(Clone, Debug, PartialEq, Eq)]
@ -79,6 +82,15 @@ pub enum StoreRefusal {
/// A new packet names a corpus item already withdrawn. Packets admitted
/// before the withdrawal stay stored and readable.
WithdrawnCorpusItem { key: String },
/// A speaker or scene revision that is not the entity's or group's latest
/// plus one (1 when none).
RevisionNotNext {
key: String,
expected: u32,
got: u32,
},
/// A scene that omits the speaker of a live item of its group.
SceneSpeakerMissing { group: String, entity: EntityId },
/// Any other malformed document or change.
Malformed { key: String, why: &'static str },
}
@ -116,6 +128,10 @@ impl fmt::Display for StoreRefusal {
EntityNotAppendOnly { .. } => f.write_str("EntityNotAppendOnly"),
EntityNotOnItem { .. } => f.write_str("EntityNotOnItem"),
WithdrawnCorpusItem { .. } => f.write_str("WithdrawnCorpusItem"),
RevisionNotNext { expected, got, .. } => {
write!(f, "RevisionNotNext expected {expected} got {got}")
}
SceneSpeakerMissing { .. } => f.write_str("SceneSpeakerMissing"),
Malformed { why, .. } => write!(f, "Malformed: {why}"),
}
}
@ -978,3 +994,210 @@ impl PacketStore {
Ok(view)
}
}
/// The speaker and scene documents: append-only revisions, one key each. The
/// only writer of `weksa.speaker` and `weksa.scene`.
pub struct SpeakerStateStore {
cache: CultCache,
}
impl SpeakerStateStore {
pub fn open(path: &Path) -> anyhow::Result<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

@ -3,9 +3,11 @@
//! returns `Ok(())` or the refusals. [`LoweringInput`], the only thing a
//! lowerer sees, comes from `PacketStore::admit` alone, which builds it through
//! the crate-private `admissible`. The stance is checked like every feature
//! list, in slot `Stance`. The rules are V1..V8, V10..V12 and V14..V15: the
//! list, in slot `Stance`. The rules are V1..V8, V10..V12 and V14..V16: the
//! shape of an id or a grade is its type's parse, not a rule, so V9 is retired
//! and not renumbered; V13 is retired too.
//! and not renumbered; V13 is retired too. [`LoweringInput::with_scene`] takes
//! a [`Scene`](crate::speaker::Scene) document, and `feature_refusals` is the
//! one feature check it and the speaker-state store share.
//!
//! `validate` owns form and capacity and makes no leak decision. A packet's one
//! free text is its trace (`notes` and `rejected_readings`): `validate` does not
@ -27,9 +29,10 @@ use std::fmt;
use crate::inventory::{Family, InvId, Inventory, InventoryEntry, Slot, ValueKind};
use crate::packet::{
Constraints, Context, Discourse, Extension, FeatValue, FeatureValue, NodeId, Packet,
Constraints, Context, Discourse, EntityId, Extension, FeatValue, FeatureValue, NodeId, Packet,
PacketKind, Predication, ProvenanceSource, Referent, Relation, SCHEMA_VERSION, Stance,
};
use crate::speaker::Scene;
/// A broken rule, named.
#[derive(Clone, Debug, PartialEq, Eq)]
@ -107,6 +110,8 @@ pub enum Refusal {
V15ConsentSource {
source: ProvenanceSource,
},
/// V16: `with_scene` was given a speaker the scene does not list.
V16SpeakerNotInScene,
}
impl Refusal {
@ -129,6 +134,7 @@ impl Refusal {
"V14"
}
Refusal::V15ConsentSource { .. } => "V15",
Refusal::V16SpeakerNotInScene => "V16",
}
}
}
@ -154,6 +160,7 @@ impl fmt::Display for Refusal {
Refusal::V14ConsentWithoutAddressee => "consent given without an addressee",
Refusal::V14ConsentNotAPredication { .. } => "consent names no predication",
Refusal::V15ConsentSource { .. } => "consent set by a source that only read wording",
Refusal::V16SpeakerNotInScene => "the speaker is not a participant of the scene",
};
write!(f, "{}: {what}", self.rule())
}
@ -465,6 +472,29 @@ impl<'a> Inventoried<'a> {
}
}
/// The one feature check of the speaker-state documents and `with_scene`: V10
/// when `rev` has no record, else the rules the packet's own check applies to a
/// feature list in `slot` (V5 to V8, V11).
pub(crate) fn feature_refusals(
inventory: &Inventory,
rev: u32,
features: &[FeatureValue],
slot: Slot,
) -> Vec<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,
@ -530,6 +560,27 @@ fn fits(kind: Option<&ValueKind>, value: &FeatValue) -> bool {
/// fn read(l: weksa::LoweringInput) { let _ = l.scene; }
/// ```
///
/// Scene values come only from a `weksa.scene` document: the raw-values door
/// is gone. The first block is the same call with a document; the second hands
/// it feature values and fails for that alone.
///
/// ```
/// fn pick(
/// l: weksa::LoweringInput,
/// i: &weksa::Inventory,
/// s: &weksa::speaker::Scene,
/// e: &weksa::EntityId,
/// ) {
/// let _ = l.with_scene(i, s, e);
/// }
/// ```
///
/// ```compile_fail
/// fn pick(l: weksa::LoweringInput, i: &weksa::Inventory) {
/// let _ = l.with_scene(i, vec![]);
/// }
/// ```
///
/// The constructor is private: a call from outside the crate compiles only if
/// `LoweringInput::of` is `pub`. The first block is the same code without the
/// call, so the second fails for that call and nothing else.
@ -587,6 +638,39 @@ impl LoweringInput {
}
}
/// Adds the speaker's relation to the addressee, the only path for scene
/// values into a view; they come only from a `weksa.scene` document. The
/// speaker must be a participant of `scene` (V16). With no addressee, or
/// one that names no referent, nothing is selected; otherwise the relation
/// from the speaker to the addressee referent's entity (to `Unnamed` when
/// the referent has none) is checked like every feature list, in slot
/// `Scene` at this view's inventory revision, and replaces any earlier
/// scene. The English lowerer ignores it.
pub fn with_scene(
mut self,
inventory: &Inventory,
scene: &Scene,
speaker: &EntityId,
) -> Result<Self, Vec<Refusal>> {
if !scene.participants.contains(speaker) {
return Err(vec![Refusal::V16SpeakerNotInScene]);
}
let addressee = self
.discourse
.addressee
.as_ref()
.and_then(|id| self.referents.iter().find(|r| &r.id == id));
let selected = addressee
.map(|r| scene.relation(speaker, r.entity.as_ref()))
.unwrap_or_default();
let out = feature_refusals(inventory, self.inventory_rev, selected, Slot::Scene);
if !out.is_empty() {
return Err(out);
}
self.scene = selected.to_vec();
Ok(self)
}
pub fn inventory_rev(&self) -> u32 {
self.inventory_rev
}
@ -617,7 +701,8 @@ impl LoweringInput {
pub fn stance(&self) -> &Stance {
&self.stance
}
/// The speaker's relation to the addressee; empty until `with_scene`.
/// The features of the scene relation from the speaker to the addressee;
/// empty until `with_scene`, or when the scene holds none.
pub fn scene(&self) -> &[FeatureValue] {
&self.scene
}

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

244
crates/weksa/tests/scene.rs Normal file
View file

@ -0,0 +1,244 @@
//! The speaker's relation to the addressee as a lowering input: the relation of
//! a `weksa.scene` document from the speaker to the addressee referent's
//! entity, typed feature values of slot `Scene` checked against the inventory
//! at the view's revision, never wording.
mod common;
use std::path::Path;
use common::{eid, grade, iv, lab, nd};
use weksa::inventory::{proof_revision, seed, speaker_scene_revision};
use weksa::packet::{FeatValue, FeatureValue, Packet, proof_packet};
use weksa::speaker::{Annotator, PairRelation, Party, Scene};
use weksa::{
CorpusStore, EntityId, EntityStore, Inventory, InventoryStore, LoweringInput, PacketStore,
Refusal, lower,
};
fn store() -> (InventoryStore, Inventory, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let mut store = InventoryStore::open(&dir.path().join("i.cc")).unwrap();
for (rev, entries) in [seed(), proof_revision(), speaker_scene_revision()] {
store.admit_revision(rev, entries).unwrap();
}
let inv = store.inventory().unwrap();
(store, inv, dir)
}
fn admitted(packet: &Packet, inventory: &InventoryStore) -> LoweringInput {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
let corpus = CorpusStore::open(&root.join("data/corpus/corpus.cc")).unwrap();
let entities = EntityStore::open(&root.join("data/entity/entity.cc")).unwrap();
let dir = tempfile::tempdir().unwrap();
PacketStore::open(&dir.path().join("p.cc"))
.unwrap()
.admit(packet.clone(), inventory, &corpus, &entities)
.unwrap()
}
/// The proof packet at `rev`. Its referent `n1` has no entity and `n3` is
/// `entity:nibu`; `addressee` names which one is spoken to, or none.
fn packet(rev: u32, addressee: Option<&str>) -> Packet {
let mut p = proof_packet();
p.inventory_rev = rev;
p.discourse.addressee = addressee.map(nd);
p
}
fn feat(inv: &Inventory, gloss: &str, value: FeatValue) -> FeatureValue {
let id = inv
.entries()
.find(|e| e.gloss == gloss)
.unwrap_or_else(|| panic!("no feature {gloss}"))
.id;
FeatureValue { feature: id, value }
}
fn rules(r: Result<LoweringInput, Vec<Refusal>>) -> Vec<&'static str> {
r.unwrap_err().iter().map(Refusal::rule).collect()
}
fn speaker() -> EntityId {
eid("brokkr")
}
/// Brokkr speaks; the scene holds a relation to Nibu and one to an unnamed
/// party, with different values.
fn scene(inv: &Inventory) -> Scene {
scene_with(vec![
PairRelation {
from: speaker(),
to: Party::Named(eid("nibu")),
features: vec![
feat(inv, "relative_status", FeatValue::Option(lab("above"))),
feat(inv, "intimacy", FeatValue::Grade(grade(200))),
feat(inv, "in_group", FeatValue::Flag(false)),
],
},
PairRelation {
from: speaker(),
to: Party::Unnamed,
features: vec![feat(inv, "intimacy", FeatValue::Grade(grade(7)))],
},
])
}
fn scene_with(relations: Vec<PairRelation>) -> Scene {
Scene {
group: "g".into(),
n: 1,
inventory_rev: 3,
participants: vec![speaker(), eid("nibu")],
relations,
annotator: Annotator::Operator,
}
}
fn selected(view: LoweringInput, inv: &Inventory, s: &Scene) -> Vec<FeatureValue> {
view.with_scene(inv, s, &speaker())
.unwrap()
.scene()
.to_vec()
}
#[test]
fn a_scene_relation_reaches_the_lowering_view_and_changes_no_english() {
let (store, inv, _dir) = store();
let plain = admitted(&packet(3, Some("n3")), &store);
assert!(plain.scene().is_empty());
let s = scene(&inv);
let with = plain.clone().with_scene(&inv, &s, &speaker()).unwrap();
assert_eq!(with.scene(), s.relations[0].features.as_slice());
assert!(!with.scene().is_empty());
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
let entities = EntityStore::open(&root.join("data/entity/entity.cc")).unwrap();
assert_eq!(
lower(&with, &inv, &entities).unwrap(),
lower(&plain, &inv, &entities).unwrap()
);
}
#[test]
fn with_scene_selects_by_addressee() {
let (store, inv, _dir) = store();
let s = scene(&inv);
// A referent with an entity selects the relation to that entity.
let named = selected(admitted(&packet(3, Some("n3")), &store), &inv, &s);
assert_eq!(named, s.relations[0].features);
// A referent without one selects the relation to an unnamed party.
let unnamed = selected(admitted(&packet(3, Some("n1")), &store), &inv, &s);
assert_eq!(unnamed, s.relations[1].features);
// No addressee selects nothing.
assert!(selected(admitted(&packet(3, None), &store), &inv, &s).is_empty());
// A named addressee the scene lacks selects nothing, though an unnamed
// relation exists.
let only_unnamed = scene_with(vec![s.relations[1].clone()]);
assert!(
selected(
admitted(&packet(3, Some("n3")), &store),
&inv,
&only_unnamed
)
.is_empty()
);
// Another speaker's relations are not selected.
let view = admitted(&packet(3, Some("n3")), &store);
let theirs = Scene {
participants: vec![speaker(), eid("nibu"), eid("hero")],
..s.clone()
};
assert!(
view.with_scene(&inv, &theirs, &eid("hero"))
.unwrap()
.scene()
.is_empty()
);
// A replacement replaces: an empty selection clears the earlier one.
let first = admitted(&packet(3, Some("n3")), &store)
.with_scene(&inv, &s, &speaker())
.unwrap();
assert!(
first
.with_scene(&inv, &only_unnamed, &speaker())
.unwrap()
.scene()
.is_empty()
);
}
#[test]
fn with_scene_refusals() {
let (store, inv, _dir) = store();
let view = admitted(&packet(3, Some("n3")), &store);
let to_nibu = |features| {
scene_with(vec![PairRelation {
from: speaker(),
to: Party::Named(eid("nibu")),
features,
}])
};
let take = |s: &Scene| view.clone().with_scene(&inv, s, &speaker());
// A speaker the scene does not list.
assert_eq!(
view.clone()
.with_scene(&inv, &scene(&inv), &eid("hero"))
.map(|_| ())
.unwrap_err(),
[Refusal::V16SpeakerNotInScene]
);
// A label the feature does not declare.
let lordly = feat(&inv, "relative_status", FeatValue::Option(lab("lordly")));
assert_eq!(rules(take(&to_nibu(vec![lordly]))), ["V7"]);
// A value of the wrong kind.
let flag = feat(&inv, "intimacy", FeatValue::Flag(true));
assert_eq!(rules(take(&to_nibu(vec![flag]))), ["V7"]);
// A stance feature in a relation.
let anger = feat(&inv, "anger", FeatValue::Grade(grade(1)));
assert_eq!(rules(take(&to_nibu(vec![anger]))), ["V8"]);
// A speaker feature in a relation.
let age = feat(&inv, "education", FeatValue::Grade(grade(1)));
assert_eq!(rules(take(&to_nibu(vec![age]))), ["V8"]);
// Twice in one list.
let twice = vec![
feat(&inv, "intimacy", FeatValue::Grade(grade(1))),
feat(&inv, "intimacy", FeatValue::Grade(grade(2))),
];
assert_eq!(rules(take(&to_nibu(twice))), ["V11"]);
// Revision 2 predates the Scene features, so their ids do not resolve there.
let old = admitted(&packet(2, Some("n3")), &store);
assert_eq!(
rules(old.with_scene(&inv, &scene(&inv), &speaker())),
["V5", "V5", "V5"]
);
// An unselected relation is not checked: only the selected one is.
let bad_elsewhere = scene_with(vec![
PairRelation {
from: speaker(),
to: Party::Unnamed,
features: vec![feat(&inv, "anger", FeatValue::Grade(grade(1)))],
},
PairRelation {
from: speaker(),
to: Party::Named(eid("nibu")),
features: vec![],
},
]);
assert!(take(&bad_elsewhere).unwrap().scene().is_empty());
}
#[test]
fn feature_ids_are_stable() {
let (_store, inv, _dir) = store();
let ids: Vec<_> = [
"relative_status",
"intimacy",
"in_group",
"socioeconomic_standing",
"education",
"age_cohort",
]
.map(|g| feat(&inv, g, FeatValue::Flag(true)).feature)
.into();
let want: Vec<_> = (19..=24).map(|n| iv(&format!("feature:{n}"))).collect();
assert_eq!(ids, want);
}

View file

@ -0,0 +1,478 @@
//! The speaker-state store: speaker and scene documents are append-only
//! revisions, checked against the inventory, the entity store and the corpus.
mod common;
use common::{cast, eid, entities_in, grade, item_in, lab};
use weksa::inventory::{proof_revision, seed, speaker_scene_revision};
use weksa::packet::{FeatValue, FeatureValue};
use weksa::speaker::{
Annotator, MAX_PARTICIPANTS, PairRelation, Party, Scene, Speaker, scene_key, speaker_key,
};
use weksa::{
AddOutcome, CorpusStore, EntityStore, InventoryStore, Refusal, SpeakerStateStore, StoreError,
StoreRefusal,
};
struct W {
dir: tempfile::TempDir,
inventory: InventoryStore,
corpus: CorpusStore,
entities: EntityStore,
state: SpeakerStateStore,
}
/// Group `g` holds two live items (speakers nibu and brokkr) and one withdrawn
/// item (speaker hero).
fn world() -> W {
let dir = tempfile::tempdir().unwrap();
let mut inventory = InventoryStore::open(&dir.path().join("i.cc")).unwrap();
for (rev, entries) in [seed(), proof_revision(), speaker_scene_revision()] {
inventory.admit_revision(rev, entries).unwrap();
}
let entities = entities_in(dir.path());
let mut corpus = CorpusStore::open(&dir.path().join("c.cc")).unwrap();
for (key, text, speaker) in [
("item:nibu:1", "Come here.", "nibu"),
("item:nibu:2", "Go there.", "brokkr"),
("item:nibu:3", "Stay away.", "hero"),
] {
let mut item = item_in(key, text, "g", cast());
item.speaker = eid(speaker);
corpus.add(item, &entities).unwrap();
}
corpus.withdraw("item:nibu:3").unwrap();
let state = SpeakerStateStore::open(&dir.path().join("s.cc")).unwrap();
W {
dir,
inventory,
corpus,
entities,
state,
}
}
fn feat(gloss: &str, value: FeatValue) -> FeatureValue {
let feature = seed()
.1
.into_iter()
.chain(speaker_scene_revision().1)
.find(|e| e.gloss == gloss)
.unwrap_or_else(|| panic!("no feature {gloss}"))
.id;
FeatureValue { feature, value }
}
fn speaker(n: u32, age: &str) -> Speaker {
Speaker {
entity: eid("nibu"),
n,
inventory_rev: 3,
features: vec![feat("age_cohort", FeatValue::Option(lab(age)))],
annotator: Annotator::Operator,
}
}
fn scene(n: u32, intimacy: u16) -> Scene {
Scene {
group: "g".into(),
n,
inventory_rev: 3,
participants: vec![eid("nibu"), eid("brokkr")],
relations: vec![PairRelation {
from: eid("nibu"),
to: Party::Named(eid("brokkr")),
features: vec![feat("intimacy", FeatValue::Grade(grade(intimacy)))],
}],
annotator: Annotator::Operator,
}
}
fn put_speaker(w: &mut W, s: Speaker) -> Result<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<_>>());
}
#[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

@ -1103,6 +1103,10 @@ fn speaker_state_slots_are_exclusive() {
&[Slot::Scene, Slot::Relation],
&[Slot::Discourse, Slot::Scene],
&[Slot::Scene, Slot::Scene],
&[Slot::Speaker, Slot::Scene],
&[Slot::Speaker, Slot::Stance],
&[Slot::Speaker, Slot::Relation],
&[Slot::Speaker, Slot::Speaker],
&[Slot::Stance, Slot::Stance],
] {
let rev = rev_with(vec![Change::Add(iv("feature:50"))], 2, Some(1));
@ -1121,7 +1125,8 @@ fn speaker_state_slots_are_exclusive() {
for (n, slots) in [
(2, &[Slot::Scene][..]),
(3, &[Slot::Stance]),
(4, &[Slot::Discourse, Slot::Predication]),
(4, &[Slot::Speaker]),
(5, &[Slot::Discourse, Slot::Predication]),
] {
let id = format!("feature:{}", 50 + n);
let rev = rev_with(vec![Change::Add(iv(&id))], n, Some(n - 1));

View file

@ -213,6 +213,25 @@ affect and restraint to its own register. A held-back feeling is not a claim
about the world, so it is never a predication. An empty stance is valid. The
stance reaches the lowerer in the lowering view.
### Speaker and scene documents
Two documents outside the packet hold the speaker's standing, in the store that
owns them (`SpeakerStateStore`). `weksa.speaker` (key `speaker:<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 a
scene document and the speaker; the speaker must be a participant (V16), and
the relation selected is the speaker's to the entity of the addressee referent
(to `Unnamed` when that referent has no entity, to nothing when the packet has
no addressee). V13 is retired.
### `referents`
Entities, groups, places, abstractions and the rest. `kind` is a referent-kind

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 a `weksa.scene` document and selects the relation by the addressee referent's entity (V16 when the speaker is not a participant; V13 is retired). The English lowerer does not render it. (PR for follow-up `chat-s3352-3352`)
4b. `speaker-scene` (spec r1; `speaker-scene-seeds` is its cut 4c: CLI, inventory revision 3, cast speakers and scenes; `Party::Unnamed` covers an addressee with no entity; Speaker and Scene share one `SpeakerStateStore`): slots `Speaker` and `Scene` with their seed features; `weksa.speaker` (`speaker:<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 a Scene document, schemas. The CLI, the committed inventory revision 3 and the seeds stay with 4c.
5. `language-ontology`: `weksa.community` from `data/cultural-ontology/` (owns follow-up `edda-referent-yaml-parse`), `weksa.ontology_entry` for en-US and ja-JP from `data/target-language-ontology/` (YAML deleted), speaker communities, deterministic `select`, the lowering view (packet without trace, speaker, scene, selected entries). Waits on 4.
6. `pd-importer`: public-domain play and fiction dialogue into the corpus with speaker, scene group, edition provenance, and Speaker and Scene annotations by the ruled annotator. Waits on 4, follow-up `gutenberg-terms` and question `pd-speaker-annotation`.
7a. `loop-context-builder`: `context.rs` (`TrainItems`, `EncoderOffer`, `Prompt`: corpus text reaches a model call only through `Prompt::mint` over live Train items or `Prompt::encode` over one offered item; instructions are `&'static str`), `SplitScheme::live`, `CorpusItem::cited_entities` (one owner of the id set `PacketStore::admit` and the offer share), and checkpoint-only Test readings (`Checkpoint`, `TestReadings`; `Aggregate` and `leak_diagnostic` carry no Test). Library only. Merges follow-ups `loop-context-builder-2` and `scorer-test-readings-type`. (spec r1)

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