language-ontology-scene s1-2: the run pins the speaker-state store, assembled is the one scene door, select reads slot Scene, Prompt::lower refuses an unread scene, weksa scene put|dump, speaker_scene_revision(n)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Weksa 2026-11-12 16:27:13 +00:00
commit f7f4e58d78
17 changed files with 388 additions and 45 deletions

View file

@ -259,6 +259,7 @@ fn lower_error(e: &LowerError) -> Value {
LowerError::ConsentMissing { predication } => ("ConsentMissing", format!("{predication} is a request the packet does not carry the addressee's consent to")),
LowerError::AskerNotAddressee { predication } => ("AskerNotAddressee", format!("the agent of {predication} is not the addressee")),
LowerError::EntityRead { entity, .. } => ("EntityRead", format!("the entity store failed reading {entity}")),
LowerError::SceneUnread => ("SceneUnread", "the view's scene was never read".to_string()),
};
json!({ "variant": variant, "reason": reason })
}

View file

@ -37,7 +37,7 @@ use crate::packet::{
use crate::run::LADDER_STEP;
use crate::split::{Checkpoint, Split, SplitScheme, fnv1a64, hex16};
use crate::store::{CorpusStore, normalized};
use crate::validate::LoweringInput;
use crate::validate::{LoweringInput, SceneRead};
/// The instructions a mint prompt starts with: the editor's whole brief. Fixed
/// per run by `PROMPT_SOURCE_DIGEST`.
@ -71,7 +71,7 @@ A feature is {feature, value}; value is {\"Grade\": 0 to 1000}, {\"Option\": lab
/// Fixed per run by `PROMPT_SOURCE_DIGEST`.
pub const LOWER_INSTRUCTIONS: &str = "\
Say the packet below in the language on the language line, as one utterance of its speaker: all of its meaning and nothing it does not carry.
Every id is the inventory's listed below and every feature value is as listed; a grade is permille, 0 to 1000. The stance is the speaker's felt affect and restraint: it shapes how the utterance is said, not what it says. The ontology lines are choices this language makes for this speaker and stance: realize each where the packet allows; they add no meaning.
Every id is the inventory's listed below and every feature value is as listed; a grade is permille, 0 to 1000. The stance is the speaker's felt affect and restraint: it shapes how the utterance is said, not what it says. The ontology lines are choices this language makes for this speaker, stance and scene: realize each where the packet allows; they add no meaning.
A referent with an entity is named by one of that entity's forms on the entities line.
Answer with one JSON object and nothing else: {\"text\": \"...\"}. No code fence, no commentary.";
@ -89,7 +89,7 @@ Answer with one JSON object and nothing else: {\"text\": \"...\"}, the continuat
/// ```no_run
/// # let dir = tempfile::tempdir().unwrap();
/// # let p = |n: &str| dir.path().join(n);
/// # let paths = weksa::run::RunPaths { corpus: &p("c.cc"), inventory: &p("i.cc"), entities: &p("e.cc"), ontology: &p("o.cc") };
/// # let paths = weksa::run::RunPaths { corpus: &p("c.cc"), inventory: &p("i.cc"), entities: &p("e.cc"), ontology: &p("o.cc"), speaker_state: &p("s.cc") };
/// # let panel = ["en-US".to_string()];
/// # let run = weksa::run::RunStores::create(&p("run"), "t", paths, "m", "e", weksa::run::LowererReach::ToolLessRelay, weksa::corpus::ModelFamily::Unrecorded, &panel).unwrap();
/// let t = run.train().unwrap();
@ -104,7 +104,7 @@ Answer with one JSON object and nothing else: {\"text\": \"...\"}, the continuat
/// # let dir = tempfile::tempdir().unwrap();
/// # let p = |n: &str| dir.path().join(n);
/// # for n in ["c.cc", "i.cc", "e.cc", "o.cc"] { std::fs::write(p(n), [0x90]).unwrap(); }
/// # let paths = weksa::run::RunPaths { corpus: &p("c.cc"), inventory: &p("i.cc"), entities: &p("e.cc"), ontology: &p("o.cc") };
/// # let paths = weksa::run::RunPaths { corpus: &p("c.cc"), inventory: &p("i.cc"), entities: &p("e.cc"), ontology: &p("o.cc"), speaker_state: &p("s.cc") };
/// # let run = weksa::run::RunStores::create(&p("run"), "t", paths, "m", "e", weksa::run::LowererReach::ToolLessRelay, weksa::corpus::ModelFamily::Unrecorded, &["en-US".to_string()]).unwrap();
/// let t = run.train().unwrap();
/// let _u = t.clone();
@ -115,7 +115,7 @@ Answer with one JSON object and nothing else: {\"text\": \"...\"}, the continuat
/// # let dir = tempfile::tempdir().unwrap();
/// # let p = |n: &str| dir.path().join(n);
/// # for n in ["c.cc", "i.cc", "e.cc", "o.cc"] { std::fs::write(p(n), [0x90]).unwrap(); }
/// # let paths = weksa::run::RunPaths { corpus: &p("c.cc"), inventory: &p("i.cc"), entities: &p("e.cc"), ontology: &p("o.cc") };
/// # let paths = weksa::run::RunPaths { corpus: &p("c.cc"), inventory: &p("i.cc"), entities: &p("e.cc"), ontology: &p("o.cc"), speaker_state: &p("s.cc") };
/// # let run = weksa::run::RunStores::create(&p("run"), "t", paths, "m", "e", weksa::run::LowererReach::ToolLessRelay, weksa::corpus::ModelFamily::Unrecorded, &["en-US".to_string()]).unwrap();
/// let t = run.train().unwrap();
/// s(&t);
@ -337,7 +337,7 @@ impl EditFeedback {
/// ```no_run
/// # let dir = tempfile::tempdir().unwrap();
/// # let p = |n: &str| dir.path().join(n);
/// # let paths = weksa::run::RunPaths { corpus: &p("c.cc"), inventory: &p("i.cc"), entities: &p("e.cc"), ontology: &p("o.cc") };
/// # let paths = weksa::run::RunPaths { corpus: &p("c.cc"), inventory: &p("i.cc"), entities: &p("e.cc"), ontology: &p("o.cc"), speaker_state: &p("s.cc") };
/// # let panel = ["en-US".to_string()];
/// # let run = weksa::run::RunStores::create(&p("run"), "t", paths, "m", "e", weksa::run::LowererReach::ToolLessRelay, weksa::corpus::ModelFamily::Unrecorded, &panel).unwrap();
/// let t = run.train().unwrap();
@ -356,7 +356,7 @@ impl EditFeedback {
/// # let dir = tempfile::tempdir().unwrap();
/// # let p = |n: &str| dir.path().join(n);
/// # for n in ["c.cc", "i.cc", "e.cc", "o.cc"] { std::fs::write(p(n), [0x90]).unwrap(); }
/// # let paths = weksa::run::RunPaths { corpus: &p("c.cc"), inventory: &p("i.cc"), entities: &p("e.cc"), ontology: &p("o.cc") };
/// # let paths = weksa::run::RunPaths { corpus: &p("c.cc"), inventory: &p("i.cc"), entities: &p("e.cc"), ontology: &p("o.cc"), speaker_state: &p("s.cc") };
/// # let run = weksa::run::RunStores::create(&p("run"), "t", paths, "m", "e", weksa::run::LowererReach::ToolLessRelay, weksa::corpus::ModelFamily::Unrecorded, &["en-US".to_string()]).unwrap();
/// let t = run.train().unwrap();
/// let inv = run.inventory().inventory().unwrap();
@ -369,7 +369,7 @@ impl EditFeedback {
/// # let dir = tempfile::tempdir().unwrap();
/// # let p = |n: &str| dir.path().join(n);
/// # for n in ["c.cc", "i.cc", "e.cc", "o.cc"] { std::fs::write(p(n), [0x90]).unwrap(); }
/// # let paths = weksa::run::RunPaths { corpus: &p("c.cc"), inventory: &p("i.cc"), entities: &p("e.cc"), ontology: &p("o.cc") };
/// # let paths = weksa::run::RunPaths { corpus: &p("c.cc"), inventory: &p("i.cc"), entities: &p("e.cc"), ontology: &p("o.cc"), speaker_state: &p("s.cc") };
/// # let run = weksa::run::RunStores::create(&p("run"), "t", paths, "m", "e", weksa::run::LowererReach::ToolLessRelay, weksa::corpus::ModelFamily::Unrecorded, &["en-US".to_string()]).unwrap();
/// let t = run.train().unwrap();
/// let inv = run.inventory().inventory().unwrap();
@ -528,9 +528,11 @@ impl Prompt {
/// `input` (no kind, constraints, scene, speaker or group), the forms in
/// `language` of every entity its referents cite, the `ontology:` section
/// and the entries of `inventory` live at the view's revision. The section
/// holds the entries `ontology::select` chooses from the view's stance and
/// discourse features and `language`, never the caller; the view's speaker
/// and group are still never read. `item`
/// holds the entries `ontology::select` chooses from the view's stance,
/// discourse and the scene relation `with_scene` read, and `language`,
/// never the caller; the scene's values are never printed, only the entries
/// they select. A view whose scene was never read is refused
/// ([`LowerError::SceneUnread`]). `item`
/// goes only to [`Prompt::item_keys`], never into the text. A speaker's
/// entity id appears
/// only when the encoder cites that entity on a referent, which is naming a
@ -545,6 +547,9 @@ impl Prompt {
entities: &dyn EntityForms,
ontology: &Ontology,
) -> Result<Prompt, LowerError> {
if input.scene_read() == &SceneRead::Unread {
return Err(LowerError::SceneUnread);
}
let mut forms: BTreeMap<&str, Vec<String>> = BTreeMap::new();
for id in input.referents().iter().filter_map(|r| r.entity.as_ref()) {
let found = entity(entities, id)?;

View file

@ -551,7 +551,7 @@ pub fn proof_revision() -> (InventoryRevision, Vec<InventoryEntry>) {
/// 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>) {
pub fn speaker_scene_revision(n: u32) -> (InventoryRevision, Vec<InventoryEntry>) {
let taken = seed()
.1
.iter()
@ -593,15 +593,15 @@ pub fn speaker_scene_revision() -> (InventoryRevision, Vec<InventoryEntry>) {
gloss: gloss.to_string(),
value_kind: Some(kind),
applies_to: vec![slot],
introduced_rev: 3,
introduced_rev: n,
retired_rev: None,
});
}
let changes = entries.iter().map(|e| Change::Add(e.id)).collect();
(
InventoryRevision {
n: 3,
parent: Some(2),
n,
parent: Some(n - 1),
changes,
frozen: false,
},

View file

@ -182,6 +182,8 @@ pub enum LowerError {
AskerNotAddressee { predication: NodeId },
/// The entity port failed to read.
EntityRead { entity: EntityId, why: String },
/// A lowering prompt was asked of a view whose scene was never read.
SceneUnread,
}
impl fmt::Display for LowerError {
@ -203,6 +205,7 @@ impl fmt::Display for LowerError {
LowerError::ConsentMissing { .. } => "request lacks the addressee's consent",
LowerError::AskerNotAddressee { .. } => "request agent is not the addressee",
LowerError::EntityRead { .. } => "entity port failed to read",
LowerError::SceneUnread => "the view's scene was never read",
})
}
}

View file

@ -10,7 +10,8 @@ use unicode_normalization::UnicodeNormalization;
use weksa::corpus::{CorpusItem, ModelFamily, NameSpan, Source, Turn};
use weksa::encode::OntologyDraft;
use weksa::entity::{Entity, EntityOrigin, LanguageForms, Pronunciation};
use weksa::inventory::{proof_revision, seed};
use weksa::inventory::{proof_revision, seed, speaker_scene_revision};
use weksa::packet::{FeatValue, FeatureValue, Grade};
use weksa::packet::proof_packet;
use weksa::model::RecordedAnswers;
use weksa::run::{
@ -18,8 +19,8 @@ use weksa::run::{
};
use weksa::score::ScoreInput;
use weksa::{
Annotator, AnswerFiles, CorpusStore, EntityId, EntityStore, InventoryStore, OllamaEmbedder, OntologyStore, PacketStore,
Scorer, Split, SplitScheme, StoreError, lower, pronounce, validate,
Annotator, AnswerFiles, CorpusStore, EntityId, EntityStore, Family, InventoryStore, OllamaEmbedder, OntologyStore, OptionLabel,
PacketStore, PairRelation, Party, Scene, Slot, SpeakerStateStore, ValueKind, Scorer, Split, SplitScheme, StoreError, lower, pronounce, validate,
};
#[derive(Parser)]
@ -40,6 +41,9 @@ struct Cli {
/// The language ontology store.
#[arg(long, global = true, default_value = "data/ontology/ontology.cc")]
ontology: PathBuf,
/// The speaker-state store (scenes).
#[arg(long, global = true, default_value = "data/corpus/speaker-state.cc")]
speaker_state: PathBuf,
#[command(subcommand)]
command: Command,
}
@ -57,6 +61,8 @@ enum Command {
#[command(subcommand)]
Ontology(OntologyCommand),
#[command(subcommand)]
Scene(SceneCommand),
#[command(subcommand)]
Packet(PacketCommand),
/// Lower a stored packet to English text.
Lower {
@ -87,7 +93,7 @@ enum Command {
#[derive(Subcommand)]
enum LoopCommand {
/// Pin the global --corpus, --inventory, --entities and --ontology as a run at ROOT.
/// Pin the global --corpus, --inventory, --entities, --ontology and --speaker-state as a run at ROOT.
Open {
root: PathBuf,
#[arg(long)]
@ -191,6 +197,38 @@ enum InventoryCommand {
Proof,
/// Admit revision 3 of the Dao De Jing test bed stores (after seed and proof).
Dao,
/// Admit the speaker-state revision (Scene and Speaker features) as the
/// inventory's latest plus one.
SpeakerScene,
}
#[derive(Subcommand)]
enum SceneCommand {
/// Append a scene revision for one corpus group: its one live speaker's
/// relation to the addressee, as Scene-slot feature values.
Put {
#[arg(long)]
group: String,
/// `entity:<slug>` of a named addressee, or `unnamed`.
#[arg(long)]
to: String,
/// The inventory revision the glosses resolve at.
#[arg(long)]
inventory_rev: u32,
/// `gloss=value`, repeated: a grade 0-1000, an option label or true/false.
#[arg(long = "set", value_name = "GLOSS=VALUE")]
set: Vec<String>,
#[arg(long, requires = "generator", conflicts_with = "operator")]
model_family: Option<ModelFamilyArg>,
/// The attestation string naming the annotating model.
#[arg(long, requires = "model_family")]
generator: Option<String>,
/// Annotated by the operator, not a model.
#[arg(long)]
operator: bool,
},
/// Text projection of the scenes for review; not a store.
Dump,
}
#[derive(Subcommand)]
@ -734,6 +772,143 @@ fn main() -> Result<()> {
.map_err(refused)?;
println!("admitted rev:2 with {count} entries");
}
Command::Inventory(InventoryCommand::SpeakerScene) => {
let store = InventoryStore::open(&cli.inventory)?;
let latest = store
.inventory()?
.latest()
.map(|r| r.n)
.context("the inventory holds no revision")?;
let (rev, entries) = speaker_scene_revision(latest + 1);
let (n, count) = (rev.n, entries.len());
let mut store = store;
store.admit_revision(rev, entries).map_err(refused)?;
println!("admitted rev:{n} with {count} entries");
}
Command::Scene(SceneCommand::Put {
group,
to,
inventory_rev,
set,
model_family,
generator,
operator,
}) => {
let annotator = match (model_family, generator, operator) {
(Some(family), Some(generator), false) => Annotator::Model {
family: family.into(),
generator,
},
(None, None, true) => Annotator::Operator,
_ => bail!("name the annotator: --model-family with --generator, or --operator"),
};
let inventory_store = InventoryStore::open(&cli.inventory)?;
let inventory = inventory_store.inventory()?;
let corpus = CorpusStore::open(&cli.corpus)?;
let live: Vec<CorpusItem> = corpus
.all()?
.into_iter()
.filter(|i| i.group == group && !i.withdrawn)
.collect();
let mut speakers: Vec<EntityId> = live.iter().map(|i| i.speaker.clone()).collect();
speakers.sort();
speakers.dedup();
let [from] = speakers.as_slice() else {
bail!("a scene group holds one live speaker; multi-party scenes wait");
};
let to = match to.as_str() {
"unnamed" => Party::Unnamed,
named => Party::Named(
EntityId::parse(named).map_err(|_| anyhow::anyhow!("the addressee is unnamed or an entity id"))?,
),
};
let mut features = Vec::new();
for pair in &set {
let bad = || anyhow::anyhow!("a --set is gloss=value of a live Scene feature");
let (gloss, value) = pair.split_once('=').ok_or_else(bad)?;
let entry = inventory
.entries()
.find(|e| {
e.family() == Family::Feature
&& e.gloss == gloss
&& e.live_at(inventory_rev)
&& e.applies_to.contains(&Slot::Scene)
})
.ok_or_else(bad)?;
let value = match entry.value_kind.as_ref().ok_or_else(bad)? {
ValueKind::Grade => FeatValue::Grade(
Grade::new(value.parse().map_err(|_| bad())?).map_err(|_| bad())?,
),
ValueKind::Options(_) => {
FeatValue::Option(OptionLabel::parse(value).map_err(|_| bad())?)
}
ValueKind::Flag => FeatValue::Flag(value.parse().map_err(|_| bad())?),
};
features.push(FeatureValue { feature: entry.id.clone(), value });
}
let mut participants = vec![from.clone()];
if let Party::Named(a) = &to
&& a != from
{
participants.push(a.clone());
}
let mut state = SpeakerStateStore::open(&cli.speaker_state)?;
let latest = state.scene(&group)?;
let mut scene = Scene {
group,
n: latest.as_ref().map_or(1, |l| l.n + 1),
inventory_rev,
participants,
relations: vec![PairRelation { from: from.clone(), to, features }],
annotator,
};
if let Some(l) = &latest {
let same = Scene { n: l.n, ..scene.clone() };
if &same == l {
println!("unchanged");
return Ok(());
}
}
scene.n = latest.map_or(1, |l| l.n + 1);
let key = weksa::speaker::scene_key(&scene.group, scene.n);
let outcome = state
.put_scene(scene, &inventory_store, &corpus, &EntityStore::open(&cli.entities)?)
.map_err(refused)?;
println!("{outcome:?} {key}");
}
Command::Scene(SceneCommand::Dump) => {
let inventory = InventoryStore::open(&cli.inventory)?.inventory()?;
let gloss = |f: &FeatureValue| {
let name = inventory.entry(&f.feature).map_or("?", |e| e.gloss.as_str());
match &f.value {
FeatValue::Grade(g) => format!("{name}={}", g.get()),
FeatValue::Option(l) => format!("{name}={l}"),
FeatValue::Flag(b) => format!("{name}={b}"),
}
};
for s in SpeakerStateStore::open(&cli.speaker_state)?.all_scenes()? {
let who = match &s.annotator {
Annotator::Operator => "Operator".to_string(),
Annotator::Model { family, generator } => format!("Model {family:?} {generator}"),
};
let people: Vec<&str> = s.participants.iter().map(|p| p.as_str()).collect();
println!(
"scene:{}@{} rev:{} {who} [{}]",
s.group,
s.n,
s.inventory_rev,
people.join(",")
);
for r in &s.relations {
let to = match &r.to {
Party::Named(e) => e.as_str().to_string(),
Party::Unnamed => "unnamed".to_string(),
};
let feats: Vec<String> = r.features.iter().map(&gloss).collect();
println!(" {}->{to} {}", r.from, feats.join(" "));
}
}
}
Command::Inventory(InventoryCommand::Dao) => {
let (rev, entries) = weksa::daodejing::revision();
let count = entries.len();
@ -850,6 +1025,7 @@ fn main() -> Result<()> {
inventory: &cli.inventory,
entities: &cli.entities,
ontology: &cli.ontology,
speaker_state: &cli.speaker_state,
},
&answerer,
&embed_model,

View file

@ -1,7 +1,7 @@
//! The language ontology: per target language, the choices its culture makes
//! about word, grammar, discourse, sound and script for a speaker in a stance
//! (ruling `persona-drives-ontology`). An entry says when it applies (feature
//! conditions over the lowering view's stance and discourse) and what it
//! conditions over the lowering view's stance, discourse and scene relation) and what it
//! chooses. `select` is the only reader that decides which entries a lowering
//! prompt carries, and it runs only inside `Prompt::lower`;
//! `OntologyStore::admit` is the only writer. Entries are immutable.
@ -51,8 +51,8 @@ pub enum Test {
Is(FeatValue),
}
/// One feature of one slot of the view, tested. Only `Stance` and `Discourse`
/// reach a lowering view today.
/// One feature of one slot of the view, tested. `Stance`, `Discourse` and
/// `Scene` reach a lowering view; `Speaker` does not yet.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct Condition {
pub slot: Slot,
@ -149,8 +149,9 @@ impl Ontology {
}
/// The entries of exactly `language` whose every condition holds against the
/// view's stance and discourse features, in `n` order. An absent feature meets
/// no condition. Nothing else of the view is read.
/// view's stance, discourse and scene-relation features (the scene's only when
/// `with_scene` selected a relation), in `n` order. An absent feature meets no
/// condition. Nothing else of the view is read.
pub fn select<'a>(
o: &'a Ontology,
language: &str,
@ -163,6 +164,7 @@ pub fn select<'a>(
e.when.iter().all(|c| match c.slot {
Slot::Stance => c.holds(&input.stance().features),
Slot::Discourse => c.holds(&input.discourse().features),
Slot::Scene => c.holds(input.scene()),
_ => false,
})
})

View file

@ -48,10 +48,12 @@ use crate::packet::{Packet, Stance};
use crate::score::{CHRF_VERSION, chrf, Embedder, ItemScore, ScoreInput, Scorer, SplitMean, aggregate};
use crate::split::{Split, SplitScheme, fnv1a64, fnv1a64_of, hex16};
use crate::store::{
CorpusStore, EntityStore, InventoryStore, OntologyStore, PacketStore, StoreError,
CorpusStore, EntityStore, InventoryStore, OntologyStore, PacketStore, SpeakerStateStore,
StoreError,
check_ontology_entry, open_cache_over,
};
use crate::validate::LoweringInput;
use crate::inventory::Slot;
use crate::validate::{LoweringInput, feature_refusals};
/// The digest of the prompt builder's source: `context.rs`, then `split.rs`,
/// whose scheme and digest function decide which lines the prompt holds as
@ -137,6 +139,13 @@ pub struct RunRecord {
/// 16 hex of the ontology file's bytes.
#[cultcache(key = 16)]
pub ontology_digest: String,
/// Canonical absolute path of the speaker-state store (scenes): read-only
/// for the run, pinned by `speaker_state_digest`.
#[cultcache(key = 17)]
pub speaker_state: String,
/// 16 hex of the speaker-state file's bytes.
#[cultcache(key = 18)]
pub speaker_state_digest: String,
}
/// How a run's lowering prompts reach the model that answers them (rulings
@ -442,6 +451,7 @@ pub enum DriftOf {
Corpus,
Entities,
Ontology,
SpeakerState,
Prompts,
Model,
Embedder,
@ -480,6 +490,8 @@ pub enum RunError {
/// An ontology entry of a panel language does not read against the run's
/// head inventory at its revision.
Ontology,
/// A scene does not read against the run's inventory.
SpeakerState,
}
impl fmt::Display for RunError {
@ -504,6 +516,9 @@ impl fmt::Display for RunError {
RunError::Ontology => f.write_str(
"an ontology entry of a panel language does not read against the run's inventory",
),
RunError::SpeakerState => {
f.write_str("a scene does not read against the run's inventory")
}
RunError::Panel => f.write_str(
"the panel is empty, repeats or misspells a language, or names one with no live Train or Validation item",
),
@ -519,13 +534,14 @@ impl fmt::Debug for RunError {
impl std::error::Error for RunError {}
/// The four stores a run pins.
/// The five stores a run pins.
#[derive(Clone, Copy)]
pub struct RunPaths<'a> {
pub corpus: &'a Path,
pub inventory: &'a Path,
pub entities: &'a Path,
pub ontology: &'a Path,
pub speaker_state: &'a Path,
}
/// A run's open stores, reopened only from its record. The one public producer
@ -538,6 +554,7 @@ pub struct RunStores {
inventory: InventoryStore,
entities: EntityStore,
ontology: OntologyStore,
speaker_state: SpeakerStateStore,
}
/// The run's own copy of the inventory, made at `create`.
@ -554,6 +571,9 @@ fn check_pins(cache: &CultCache, root: &Path, record: &RunRecord) -> Result<(),
if file_digest(&record.ontology).as_ref() != Some(&record.ontology_digest) {
return Err(RunError::Drift(DriftOf::Ontology));
}
if file_digest(&record.speaker_state).as_ref() != Some(&record.speaker_state_digest) {
return Err(RunError::Drift(DriftOf::SpeakerState));
}
let (path, digest, _) = head_of(cache, root, record)?;
if file_digest_at(&path).as_ref() != Some(&digest) {
return Err(RunError::Drift(DriftOf::Inventory));
@ -692,6 +712,8 @@ impl RunStores {
entities: canonical(paths.entities)?,
ontology: canonical(paths.ontology)?,
ontology_digest: String::new(),
speaker_state: canonical(paths.speaker_state)?,
speaker_state_digest: String::new(),
corpus_digest: String::new(),
entity_digest: String::new(),
prompt_digest: hex16(PROMPT_SOURCE_DIGEST),
@ -714,12 +736,14 @@ impl RunStores {
entity_digest: file_digest(&record.entities).ok_or(RunError::Store)?,
inventory_digest: file_digest_at(&head).ok_or(RunError::Store)?,
ontology_digest: file_digest(&record.ontology).ok_or(RunError::Store)?,
speaker_state_digest: file_digest(&record.speaker_state).ok_or(RunError::Store)?,
..record
};
between();
let mut run = Self::with(root, cache, record)?;
run.check_panel()?;
run.check_ontology()?;
run.check_scenes()?;
// The stores were read between the digests: unless they still match
// the pins, what was opened is not what was pinned. Nothing is
// recorded for a run that did not pin.
@ -771,6 +795,8 @@ impl RunStores {
inventory: InventoryStore::open(&head).map_err(store)?,
entities: EntityStore::open(Path::new(&record.entities)).map_err(store)?,
ontology: OntologyStore::open(Path::new(&record.ontology)).map_err(store)?,
speaker_state: SpeakerStateStore::open(Path::new(&record.speaker_state))
.map_err(store)?,
cache,
record,
})
@ -803,6 +829,40 @@ impl RunStores {
})
}
/// Every scene's relation features read against the run's inventory at the
/// scene's own revision, and again at the head: the checks `put_scene`
/// applied, applied again to this inventory.
fn check_scenes(&self) -> Result<(), RunError> {
let inventory = self.inventory.inventory().map_err(|_| RunError::Store)?;
let scenes = self.speaker_state.all_scenes().map_err(|_| RunError::Store)?;
for scene in &scenes {
let head = inventory.latest().map(|r| r.n).ok_or(RunError::SpeakerState)?;
for r in &scene.relations {
for rev in [scene.inventory_rev, head] {
if !feature_refusals(&inventory, rev, &r.features, Slot::Scene).is_empty() {
return Err(RunError::SpeakerState);
}
}
}
if scene.relations.is_empty()
&& !(inventory.has_revision(scene.inventory_rev) && inventory.has_revision(head))
{
return Err(RunError::SpeakerState);
}
}
Ok(())
}
/// The one door by which a loop view gets its scene: `with_scene` over the
/// run's pinned speaker-state and corpus stores.
fn assembled(
&self,
view: LoweringInput,
inventory: &InventoryStore,
) -> Result<LoweringInput, StoreError> {
view.with_scene(&inventory.inventory()?, &self.speaker_state, &self.corpus)
}
/// Every panel language holds a live Train and a live Validation item.
fn check_panel(&self) -> Result<(), RunError> {
let items = self.corpus.all().map_err(|_| RunError::Store)?;
@ -1300,6 +1360,9 @@ impl RunStores {
let view = stores[slot]
.admit(packet, &self.inventory, &self.corpus, &self.entities)
.map_err(|_| RunError::Store)?;
let view = self
.assembled(view, &self.inventory)
.map_err(|_| RunError::Store)?;
Prompt::lower(
&view,
&packet_item,
@ -1586,19 +1649,23 @@ fn score_round<M: ModelPort, E: Embedder>(
// Phase 2: one lowering prompt per admitted view, answered through the
// port. Nothing is kept unless every one is answered.
let mut unanswered = Vec::new();
for (at, id, packet_key, view) in &admitted {
for (at, id, packet_key, view) in admitted {
// Each item is lowered in its own language: the only reference the
// scorer has is the item's own text.
let language = &items[*at].language;
let Ok(prompt) = Prompt::lower(view, id, language, &inventory, &run.entities, &ontology) else {
fates[*at] = Fate::Refused(Stage::Lower, Some(packet_key.clone()));
let language = &items[at].language;
let Ok(view) = run.assembled(view, inventory_store) else {
fates[at] = Fate::Refused(Stage::Lower, Some(packet_key.clone()));
continue;
};
let Ok(prompt) = Prompt::lower(&view, &id, language, &inventory, &run.entities, &ontology) else {
fates[at] = Fate::Refused(Stage::Lower, Some(packet_key.clone()));
continue;
};
calls.push(Call {
prompt: prompt.digest(),
items: prompt.item_keys().to_vec(),
});
fates[*at] = match model.answer(&prompt) {
fates[at] = match model.answer(&prompt) {
Ok(answer) => match LoweringDraft::parse(&answer) {
Ok(draft) if draft.usable() => {
Fate::Lowered(draft.text, packet_key.clone())
@ -2793,6 +2860,8 @@ mod tests {
panel: vec!["en-US".into()],
ontology: String::new(),
ontology_digest: String::new(),
speaker_state: String::new(),
speaker_state_digest: String::new(),
}
}
@ -2985,6 +3054,8 @@ mod tests {
);
let ontology = dir.path().join("o.cc");
fs::write(&ontology, [0x90]).unwrap();
let speaker_state = dir.path().join("s.cc");
fs::write(&speaker_state, [0x90]).unwrap();
let root = dir.path().join("run");
let mut run = RunStores::create(
&root,
@ -2994,6 +3065,7 @@ mod tests {
inventory: &inventory,
entities: &entities,
ontology: &ontology,
speaker_state: &speaker_state,
},
"fake-model",
"bag",

View file

@ -1320,8 +1320,11 @@ pub(crate) fn check_ontology_entry(
}
let mut out = Vec::new();
for c in &e.when {
if !matches!(c.slot, Slot::Stance | Slot::Discourse) {
return Err(malformed(&key, "a condition reads slot Stance or Discourse"));
if !matches!(c.slot, Slot::Stance | Slot::Discourse | Slot::Scene) {
return Err(malformed(
&key,
"a condition reads slot Stance, Discourse or Scene",
));
}
if matches!(c.test, Test::Is(FeatValue::Grade(_))) {
return Err(malformed(&key, "Is names an option or a flag"));

View file

@ -14,6 +14,8 @@ fn weksa(dir: &std::path::Path, args: &[&str]) -> Output {
"./e.cc",
"--ontology",
"./o.cc",
"--speaker-state",
"./s.cc",
])
.args(args)
.output()
@ -1341,6 +1343,7 @@ fn dao_stores(dir: &std::path::Path) {
std::fs::copy(src.join(from), dir.join(to)).unwrap();
}
std::fs::write(dir.join("o.cc"), [0x90]).unwrap();
std::fs::write(dir.join("s.cc"), [0x90]).unwrap();
}
#[test]

View file

@ -123,6 +123,20 @@ pub fn ontology_in(dir: &std::path::Path) -> std::path::PathBuf {
path
}
/// An empty speaker-state store beside `ontology` (same directory), made if absent.
pub fn speaker_state_beside(ontology: &std::path::Path) -> std::path::PathBuf {
let path = ontology.with_file_name("speaker-state.cc");
if !path.exists() {
std::fs::write(&path, [0x90]).unwrap();
}
path
}
/// An empty speaker-state store at `<dir>/speaker-state.cc`, made if absent.
pub fn speaker_state_in(dir: &std::path::Path) -> std::path::PathBuf {
speaker_state_beside(&dir.join("ontology.cc"))
}
/// A run over the stores `world()` and `corpus()` keep in `dir`: `c.cc`,
/// `i.cc` and `entities.cc`. Read at the call, so create it after the items.
/// The run's en-US panel needs a live Train and a live Validation item: a
@ -158,6 +172,7 @@ pub fn run_over(dir: &std::path::Path) -> weksa::run::RunStores {
inventory: &dir.join("i.cc"),
entities: &dir.join("entities.cc"),
ontology: &dir.join("ontology.cc"),
speaker_state: &speaker_state_beside(&dir.join("ontology.cc")),
},
"fake-model",
"bag",

View file

@ -547,7 +547,8 @@ mod lowering {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data").join(sub)
}
fn admitted(
/// The admitted view, its scene never read.
fn admitted_unread(
packet: Packet,
inv: &InventoryStore,
corpus: &str,
@ -561,6 +562,37 @@ mod lowering {
.unwrap()
}
/// The admitted view with its scene read over an empty speaker-state store.
fn admitted(
packet: Packet,
inv: &InventoryStore,
corpus: &str,
entities: &EntityStore,
) -> LoweringInput {
let view = admitted_unread(packet, inv, corpus, entities);
let dir = tempfile::tempdir().unwrap();
let state = weksa::SpeakerStateStore::open(&dir.path().join("s.cc")).unwrap();
let corpus = CorpusStore::open(&data(corpus)).unwrap();
view.with_scene(&inv.inventory().unwrap(), &state, &corpus).unwrap()
}
#[test]
fn lowering_prompt_refuses_an_unread_scene() {
let cast = InventoryStore::open(&data("interlingua/inventory.cc")).unwrap();
let entities = EntityStore::open(&data("entity/entity.cc")).unwrap();
let inv = cast.inventory().unwrap();
let item = CorpusItemId::parse("item:nibu:1").unwrap();
let held = Held(vec![nibu(&[("en-US", &["Nibu"])])]);
let unread = admitted_unread(proof_packet(), &cast, "corpus/corpus.cc", &entities);
let empty = weksa::Ontology::empty();
assert_eq!(
Prompt::lower(&unread, &item, "en-US", &inv, &held, &empty).err(),
Some(LowerError::SceneUnread)
);
let read = admitted(proof_packet(), &cast, "corpus/corpus.cc", &entities);
assert!(Prompt::lower(&read, &item, "en-US", &inv, &held, &empty).is_ok());
}
fn dao(index: usize) -> (LoweringInput, Inventory, EntityStore, CorpusItemId) {
let inv = InventoryStore::open(&data("daodejing/inventory.cc")).unwrap();
let entities = EntityStore::open(&data("daodejing/entity.cc")).unwrap();

View file

@ -24,12 +24,13 @@ fn rewrite_run(root: &Path, change: impl FnOnce(&mut RunRecord)) {
cache.put(format!("run:{}", record.label), &record).unwrap();
}
fn paths(dir: &Path) -> (PathBuf, PathBuf, PathBuf, PathBuf) {
fn paths(dir: &Path) -> (PathBuf, PathBuf, PathBuf, PathBuf, PathBuf) {
(
dir.join("c.cc"),
dir.join("i.cc"),
dir.join("entities.cc"),
common::ontology_in(dir),
common::speaker_state_in(dir),
)
}
@ -42,7 +43,7 @@ fn a_run_pins_its_stores_and_refuses_drift() {
let mut w = world();
let dir = w.dir.path().to_path_buf();
let root = dir.join("run");
let (c, i, e, o) = paths(&dir);
let (c, i, e, o, s) = paths(&dir);
let at = |label: &str, model: &str| {
RunStores::create(
&root,
@ -52,6 +53,7 @@ fn a_run_pins_its_stores_and_refuses_drift() {
inventory: &i,
entities: &e,
ontology: &o,
speaker_state: &s,
},
model,
"bag",
@ -147,7 +149,7 @@ fn a_run_works_on_its_own_copy_of_the_inventory() {
let w = world();
let dir = w.dir.path().to_path_buf();
let root = dir.join("run");
let (c, i, e, o) = paths(&dir);
let (c, i, e, o, s) = paths(&dir);
let source_bytes = std::fs::read(&i).unwrap();
let mut run = RunStores::create(
&root,
@ -157,6 +159,7 @@ fn a_run_works_on_its_own_copy_of_the_inventory() {
inventory: &i,
entities: &e,
ontology: &o,
speaker_state: &s,
},
"fake-model",
"bag",
@ -236,6 +239,7 @@ fn create_refuses_a_root_that_holds_an_inventory_and_never_truncates_it() {
inventory,
entities: &dao.entities,
ontology: &dao.ontology,
speaker_state: &common::speaker_state_beside(&dao.ontology),
},
"fake-model",
"bag",
@ -285,6 +289,7 @@ fn a_create_that_fails_leaves_no_inventory_and_the_retry_succeeds() {
inventory,
entities,
ontology: &dao.ontology,
speaker_state: &common::speaker_state_beside(&dao.ontology),
},
"fake-model",
"bag",
@ -398,11 +403,13 @@ fn a_store_changed_between_its_digest_and_its_open_is_drift() {
}
let (c, i, e) = (dir.join("c.cc"), dir.join("i.cc"), dir.join("entities.cc"));
let o = dir.join("ontology.cc");
let s = common::speaker_state_beside(&o);
let paths = || weksa::run::RunPaths {
corpus: &c,
inventory: &i,
entities: &e,
ontology: &o,
speaker_state: &s,
};
assert_eq!(
RunStores::create_between(
@ -457,6 +464,7 @@ fn window(
inventory: &i,
entities: &e,
ontology: &o,
speaker_state: &common::speaker_state_beside(&o),
};
let created = RunStores::create_between(
&root,
@ -905,6 +913,7 @@ fn dao_build(
inventory: &inventory,
entities: &entities,
ontology: &ontology,
speaker_state: &common::speaker_state_beside(&ontology),
},
"fake-model",
"bag",
@ -1087,6 +1096,8 @@ fn lowering_prompts(dao: &Dao, inventory: &InventoryStore) -> Vec<(String, Promp
let view = store
.admit(packet, inventory, &corpus, dao.run.entities())
.unwrap();
let state = weksa::SpeakerStateStore::open(&dir.path().join("s.cc")).unwrap();
let view = view.with_scene(&inv, &state, &corpus).unwrap();
let prompt = Prompt::lower(&view, &key, "en-US", &inv, dao.run.entities(), &weksa::Ontology::empty()).unwrap();
(key.to_string(), prompt)
})
@ -1560,6 +1571,7 @@ fn errors_echo_no_input() {
inventory: &missing,
entities: &missing,
ontology: &missing,
speaker_state: &common::speaker_state_beside(&missing),
};
let e = RunStores::create(
&root,
@ -1629,6 +1641,7 @@ fn errors_echo_no_input() {
inventory: &dao.inventory,
entities: &dao.entities,
ontology: &dao.ontology,
speaker_state: &common::speaker_state_beside(&dao.ontology),
},
"fake-model",
"bag",
@ -2551,6 +2564,7 @@ fn a_changed_head_is_drift() {
inventory: &dao.inventory,
entities: &dao.entities,
ontology: &dao.ontology,
speaker_state: &common::speaker_state_beside(&dao.ontology),
},
"fake-model",
"bag",
@ -2891,6 +2905,7 @@ fn a_summary_line_says_how_lowerings_were_answered() {
inventory: &dao.inventory,
entities: &dao.entities,
ontology: &dao.ontology,
speaker_state: &common::speaker_state_beside(&dao.ontology),
};
RunStores::create(
&root,
@ -3615,6 +3630,7 @@ fn mixed_family_round(family: ModelFamily) -> u32 {
inventory: &dir.join("i.cc"),
entities: &dir.join("entities.cc"),
ontology: &dir.join("ontology.cc"),
speaker_state: &common::speaker_state_beside(&dir.join("ontology.cc")),
},
"not-a-draft",
"bag",
@ -4402,7 +4418,7 @@ fn open_refuses_a_bad_panel() {
let created = RunStores::create(
&root,
"p",
RunPaths { corpus, inventory, entities, ontology: &dao.ontology },
RunPaths { corpus, inventory, entities, ontology: &dao.ontology, speaker_state: &common::speaker_state_beside(&dao.ontology) },
"fake-model",
"bag",
weksa::run::LowererReach::ToolLessRelay,
@ -4762,6 +4778,7 @@ fn create_refuses_an_entry_its_inventory_cannot_read() {
inventory: &dir.join("i.cc"),
entities: &dir.join("e.cc"),
ontology,
speaker_state: &common::speaker_state_beside(&ontology),
},
"fake-model",
"bag",

View file

@ -307,6 +307,7 @@ fn committed_ontology_is_model_drafted_and_reads_every_inventory() {
inventory: &i,
entities: &e,
ontology: &onto,
speaker_state: &common::speaker_state_beside(&onto),
},
"m",
"bag",
@ -351,6 +352,7 @@ fn the_recall_probe_covers_every_live_original_item_and_names_no_work() {
inventory: &i,
entities: &e,
ontology: &o,
speaker_state: &common::speaker_state_beside(&o),
},
"m",
"bag",
@ -429,6 +431,7 @@ fn original_round(family: ModelFamily) -> (weksa::run::RunStores, weksa::run::Ro
inventory: &tmp.join("inventory.cc"),
entities: &tmp.join("entity.cc"),
ontology: &tmp.join("ontology.cc"),
speaker_state: &common::speaker_state_beside(&tmp.join("ontology.cc")),
},
"nope",
"bag",
@ -588,6 +591,7 @@ fn no_test_item_is_offered_to_a_round_or_recall_or_recorded() {
inventory: &p("inventory.cc"),
entities: &p("entity.cc"),
ontology: &p("ontology.cc"),
speaker_state: &common::speaker_state_beside(&p("ontology.cc")),
},
"nope",
"bag",

View file

@ -27,7 +27,7 @@ struct W {
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()] {
for (rev, entries) in [seed(), proof_revision(), speaker_scene_revision(3)] {
inventory.admit_revision(rev, entries).unwrap();
}
let entities = entities_in(dir.path());
@ -60,7 +60,7 @@ fn feat(gloss: &str, value: FeatValue) -> FeatureValue {
let feature = seed()
.1
.into_iter()
.chain(speaker_scene_revision().1)
.chain(speaker_scene_revision(3).1)
.find(|e| e.gloss == gloss)
.unwrap_or_else(|| panic!("no feature {gloss}"))
.id;

View file

@ -26,7 +26,7 @@ struct W {
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()] {
for (rev, entries) in [seed(), proof_revision(), speaker_scene_revision(3)] {
inventory.admit_revision(rev, entries).unwrap();
}
let entities = entities_in(dir.path());
@ -60,7 +60,7 @@ fn feat(gloss: &str, value: FeatValue) -> FeatureValue {
let feature = seed()
.1
.into_iter()
.chain(speaker_scene_revision().1)
.chain(speaker_scene_revision(3).1)
.find(|e| e.gloss == gloss)
.unwrap_or_else(|| panic!("no feature {gloss}"))
.id;

View file

@ -66,6 +66,14 @@
},
"prompt_digest": {
"type": "string"
},
"speaker_state": {
"description": "Canonical absolute path of the speaker-state store (scenes): read-only\nfor the run, pinned by `speaker_state_digest`.",
"type": "string"
},
"speaker_state_digest": {
"description": "16 hex of the speaker-state file's bytes.",
"type": "string"
}
},
"required": [
@ -83,7 +91,9 @@
"lowerer_reach",
"panel",
"ontology",
"ontology_digest"
"ontology_digest",
"speaker_state",
"speaker_state_digest"
],
"$defs": {
"LowererReach": {

View file

@ -89,7 +89,7 @@
]
},
"Condition": {
"description": "One feature of one slot of the view, tested. Only `Stance` and `Discourse`\nreach a lowering view today.",
"description": "One feature of one slot of the view, tested. `Stance`, `Discourse` and\n`Scene` reach a lowering view; `Speaker` does not yet.",
"type": "object",
"properties": {
"feature": {